ver 1.3 and 1.3.1
This commit is contained in:
@@ -15,3 +15,6 @@ venv/
|
||||
# Eval output
|
||||
eval/results/*.json
|
||||
!eval/results/.gitkeep
|
||||
|
||||
# ECHO config: the filled-in key file is yours to distribute, never commit it
|
||||
/echo-memory.config.json
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# echo-memory — v1.2.0
|
||||
# echo-memory — v1.3.1
|
||||
|
||||
Persistent memory for Claude / CoWork sessions via the **ECHO** Obsidian vault, driven over the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api). The skill makes direct REST calls through a bundled validated client (`scripts/echo.py`). The whole toolchain is **pure Python** (stdlib only), so it runs identically on Windows, macOS, and Linux — no bash, no platform-specific `date`.
|
||||
|
||||
Built for **Jason Stedwell** (Director of Technical Services / Systems Engineer, MPM / ALABAMA wISP), who is both the **operator** and the **architect** of this vault. This is a personal plugin, not for distribution.
|
||||
Architected by **Jason Stedwell**. As of **v1.3** the plugin is **user-agnostic**: it ships no owner, endpoint, or API key — each machine supplies them through a local config file (see [Configuration](#configuration)), so the same plugin works for any owner and any vault.
|
||||
|
||||
This repository (`jason/echo-v.05`) holds the plugin **source** (tracked tree at `echo-memory.plugin.src/`), the built `echo-memory.plugin` package artifact (rebuilt on each version bump), and a credential-free A/B `eval/` harness.
|
||||
|
||||
**1.2 in one line:** one-call `capture` routes and crosslinks a memory automatically, `recall` fuses BM25 over note bodies with graph expansion, and alias-aware entity resolution routes shortened names to the right note instead of spawning duplicates — all on a cross-platform, **connection-pooled** Python client (concurrent full-vault reads, so a sweep/lint pass that used to time out now finishes in under a second) with an offline write-ahead queue, a linter-enforced routing manifest, and graph-health checks. See the [version history](#version-history) for how it got here.
|
||||
**1.3 in one line:** the plugin is now **user-agnostic** — the vault owner, endpoint, and API key live in a machine-local config (`~/.claude/echo-memory/config.json`), never in the source; an unconfigured machine **prompts for the key file on load** and installs it with one `config import`. Underneath: one-call `capture` routes and crosslinks memory, `recall` fuses BM25 over note bodies with graph expansion, alias-aware resolution avoids duplicate notes, and a connection-pooled client reads the whole vault concurrently. See the [version history](#version-history) for how it got here.
|
||||
|
||||
---
|
||||
|
||||
@@ -26,11 +26,12 @@ Three consequences follow:
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin is hardcoded for a single personal endpoint:
|
||||
The plugin ships **no** owner, endpoint, or key — it is user-agnostic. Each machine supplies them through a machine-local JSON config (resolution lives in `scripts/echo_config.py`):
|
||||
|
||||
- **Server:** `https://echoapi.alwisp.com` (reverse proxy → backend Obsidian Local REST API). This is the **only** valid endpoint — never use LAN addresses (`10.x`, `192.168.x`, `:27124`); those belong to retired memory systems. Override per-run with `ECHO_BASE`.
|
||||
- **Auth:** a bearer token, resolved by `scripts/echo.py` (via `echo_secrets`) in order: **`ECHO_KEY` env → `~/.echo-memory/credentials` → a deprecated baked-in fallback** (warns until you set one of the first two). Set it up per **[API-KEY-SETUP.md](API-KEY-SETUP.md)** (Windows & macOS) or run `echo.py write-key <token>`. **Never** put the key in a vault note.
|
||||
- **TLS:** the endpoint presents a valid certificate, so `-k` is not needed.
|
||||
- **File:** `~/.claude/echo-memory/config.json` — honors `$CLAUDE_CONFIG_DIR`; the file path itself can be overridden with `$ECHO_CONFIG`. Shape: `{ "owner": …, "endpoint": "https://…", "key": "…" }`.
|
||||
- **Per-field env override:** `ECHO_OWNER`, `ECHO_BASE` (endpoint), and `ECHO_KEY` take precedence over the file.
|
||||
- **Set up a machine:** `echo.py config import <file>` adopts a config you've been handed; `echo.py config set --owner … --endpoint … --key …` writes one from values; `echo.py config init` scaffolds a blank template to edit; `echo.py config` prints the resolved config (key redacted) and where each field came from.
|
||||
- **First run:** an unconfigured machine — or one left on the placeholder template — reports `NOT CONFIGURED` (`echo.py load` prints a banner and exits `78`; other verbs exit `2`; `/echo-doctor` flags it red), and the skill asks the operator for the key file before doing any memory work. The config is **never committed** and **never written into a vault note**; the key stays out of the plugin tree entirely.
|
||||
|
||||
Vault paths are addressed **at the root** (e.g. `GET /vault/_agent/context/current-context.md`). Prefer `scripts/echo.py` over raw `curl`: it injects auth, checks HTTP status (a failed write exits non-zero instead of looking like success), retries transient 5xx, verifies PUTs, and does idempotent appends.
|
||||
|
||||
@@ -68,7 +69,8 @@ echo-v.05/
|
||||
│ ├── api-reference.md ← REST endpoint patterns + routing map
|
||||
│ └── session-log-template.md
|
||||
├── scripts/ ← executable logic — pure Python (run: python3, or python / py -3)
|
||||
│ ├── echo.py ← validated client + high-level CLI (capture/resolve/recall/link/load/scope/lock)
|
||||
│ ├── echo.py ← validated client + high-level CLI (capture/resolve/recall/link/load/scope/lock/config)
|
||||
│ ├── echo_config.py ← resolves owner/endpoint/key from the machine-local config (env-overridable)
|
||||
│ ├── echo_index.py ← entity index (registry, resolve, name->path map)
|
||||
│ ├── echo_links.py ← cross-link primitives (Related parsing, bidirectional linking)
|
||||
│ ├── echo_ops.py ← high-level ops (capture, recall, resolve, link, agent-log)
|
||||
@@ -365,6 +367,8 @@ If the API returns a connection error, timeout, or `502` (usually Obsidian / the
|
||||
|
||||
| Version | Highlights |
|
||||
|---------|-----------|
|
||||
| **1.3.1** | **Prompts for the key on load.** A fresh or just-upgraded machine has no config yet, so the plugin detects that and asks for it instead of failing opaquely. `echo_config.is_configured()` treats a missing **or still-placeholder** config as unconfigured; `echo.py load` prints a `NOT CONFIGURED` banner and exits `78` (other verbs exit `2`; `/echo-doctor` reports it red). New **`echo.py config import <file>`** adopts a config you've been handed — it validates the file and writes `~/.claude/echo-memory/config.json`. `SKILL.md` gains a **First run** step: on the not-configured signal the agent asks the operator for their key file and installs it, then proceeds. One-time per machine — the config lives in `~/.claude/`, so later plugin updates don't disturb it. |
|
||||
| **1.3.0** | **User-agnostic — owner/endpoint/key out of the source.** The plugin previously baked in an API key and hard-coded the endpoint; both are gone. Owner, endpoint, and key are now **machine-local**, resolved by the new `echo_config.py` from `~/.claude/echo-memory/config.json` (env `ECHO_OWNER`/`ECHO_BASE`/`ECHO_KEY` override per field); `echo_secrets.py` removed. New `echo.py config` subcommand (`show`/`init`/`set`). All personal details (owner, role/employer), the live endpoint, and the literal key were **stripped** from the plugin source, references, commands, and eval harness — the only name that remains is the architect credit. A placeholder `config.template.json` is provided for new users/vaults. Routing manifest unchanged; offline test suites pass. Manifest → 1.3.0. |
|
||||
| **1.2.0** | **Entity-resolution overhaul — fewer duplicate notes.** Resolution was exact-match only, so a shortened or expanded mention (e.g. "echo memory" for the `echo`-slugged active project) returned *no match* and the writer created a parallel note. Now `resolve()` matches on slug / title / **aliases**, with a `fuzzy_candidates()` "did-you-mean" fallback ranked by shared distinctive tokens (guarded by `echo_quality` so generic words like "data"/"api"/"the" can't drive a match). Aliases are **auto-derived from titles** (hyphen/space/case variants), **learned from mentions** on update, and stored in each note's `aliases:` frontmatter (the Obsidian-native home), then folded back into the entity index by `sweep` — so the graph gets better at resolution over time, while exact-match safety still prevents wrong auto-merges. Ships with the v1.1 connection-pool + concurrency work. 33 automated tests pass across Win/macOS/Linux; linter clean. See the [performance brief](echo-memory-performance-brief.html). |
|
||||
| **1.1.0** | **Performance / network-layer rebuild — "from timing out to sub-second."** Full-vault scripts (`sweep.py`, `vault_lint.py`, recall rebuild) were making hundreds of *serial* requests, each opening a fresh TLS connection and re-reading every note 2–3×; on the constrained agent sandbox a single pass exceeded the ~120 s tool timeout and was killed mid-run, dropping the session/thread. Three structural fixes in `echo.py`: (1) **connection reuse (keep-alive)** — one persistent pooled connection per thread, reused across requests instead of a TCP+TLS handshake per file (**~4.5× faster per request**, the dominant win; benefits load/capture/recall for free); (2) **concurrent bulk reads** — new `read_many()` fans GETs across a thread pool (`ECHO_WORKERS`, default 8) for ~2× on full-vault scale; (3) **single-pass shared cache** — each note fetched *once* and reused across all passes (~2–3× fewer requests), eliminating `sweep`'s per-link-target re-fetch storm. Net effect: a full-vault pass that used to **time out now finishes in <1 s** (vault_lint 0.90 s, sweep plan 0.85 s on 186 notes); the practical change is fails → completes. Stdlib-only, no new dependencies. |
|
||||
| **1.0.0** | Schema 4. **"Memory you can trust and retrieve."** (H1) **Hybrid recall** — local BM25 over note bodies fused with decayed graph expansion (`echo_recall.py`, `_agent/index/recall-index.json`), maintained on `capture`, rebuilt by `sweep`; replaces keyword-only recall. (H2) **Offline durability** — write verbs queue to a local write-ahead outbox on an outage and report "queued"; `flush` (+ flush-on-`load`) replays idempotently and **re-bases to the current endpoint**; `load` degrades to a last-known-good read cache (`echo_queue.py`). (H3) **Concurrency** — `vault_lock` + `atomic_index_update` (lock + fresh re-read-merge) close the concurrent-`capture` entity-loss race (`echo_concurrency.py`). (H4) **Trust** — higher-fidelity PATCH mock + offline/reflect/patch-semantics suites gated in GitHub Actions across Win/macOS/Linux × Py 3.10/3.12. (H5) **Reflection capture** — `echo.py reflect` / `/echo-reflect` extract→dedup→preview→apply durable items (`echo_reflect.py`). (M1) **Secret hardening** — key resolves env → `~/.echo-memory/credentials` → deprecated fallback; `write-key` CLI; token scrubbed from docs (see [API-KEY-SETUP.md](API-KEY-SETUP.md)). (M2) confident auto-linking + slug-collision guard (`echo_quality.py`). (M3) `echo.py doctor` / `/echo-doctor` + heartbeat-less `load` fallback. (M4) `capture --json` / `--dry-run`. (M5) manifest → 1.0.0, `.gitignore`. |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"owner": "Full Name — how the agent should refer to the vault owner (third person)",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>"
|
||||
}
|
||||
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "echo-memory",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.1",
|
||||
"description": "Persistent memory via the ECHO Obsidian vault over the Obsidian Local REST API. A cross-platform, connection-pooled Python client: one-call capture/resolve/recall/link over an entity index, hybrid BM25 + graph recall, auto-linking, an offline write-ahead queue, and lock-guarded concurrency. Linter-enforced routing manifest plus /echo-load|save|recall|triage|health|sweep|reflect|doctor commands. Crosslinks notes across Claude and CoWork sessions.",
|
||||
"author": {
|
||||
"name": "Jason"
|
||||
"name": "Jason Stedwell"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"keywords": [
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
Persistent memory for Claude via the **ECHO** Obsidian vault, using the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api).
|
||||
|
||||
Reads and writes notes across Claude/CoWork sessions through direct REST calls, routed through a bundled validated client (`scripts/echo.py`) that status-checks every write. The toolchain is **pure Python**, so it runs identically on Windows, macOS, and Linux (only a Python 3 interpreter is needed — no bash). Built for **Jason Stedwell** (Director of Technical Services / Systems Engineer, MPM / ALABAMA wISP), who is both the operator and the architect of this vault.
|
||||
Reads and writes notes across Claude/CoWork sessions through direct REST calls, routed through a bundled validated client (`scripts/echo.py`) that status-checks every write. The toolchain is **pure Python**, so it runs identically on Windows, macOS, and Linux (only a Python 3 interpreter is needed — no bash). The plugin is **user-agnostic**: the vault owner, endpoint, and API key are not shipped in it — each machine supplies them through a local config file (see **Configuration**).
|
||||
|
||||
Architected by **Jason Stedwell**.
|
||||
|
||||
**The plugin is the single source of truth.** All control logic — bootstrap/repair, operating contract, taxonomy, frontmatter conventions, and the canonical note templates — ships inside this plugin under `skills/echo-memory/`. The vault itself holds **data only**: there are no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md` control docs in it. This makes ECHO self-bootstrapping (point it at an empty Obsidian vault and it stands up the full structure), easy to update (update the plugin, not the vault), and portable to any other vault.
|
||||
|
||||
@@ -13,16 +15,30 @@ Reads and writes notes across Claude/CoWork sessions through direct REST calls,
|
||||
- **`recall`** returns a topic's matching notes *and* their one-hop linked neighbourhood (Related links + `source_notes`) for comprehensive context; **`resolve`** maps any mention to its canonical path; **`link`** adds reciprocal cross-links
|
||||
- Logs working sessions so future conversations can pick up where they left off
|
||||
- **Bootstraps an empty vault from the bundled `scaffold/`** (folders, templates, anchor seeds, marker), repairs/migrates an existing one via `scripts/bootstrap.py` / `scripts/migrate.py`, and brings an upgraded vault up to spec (index + cross-links) via `scripts/sweep.py` — see `skills/echo-memory/references/bootstrap.md`
|
||||
- Exposes `/echo-load`, `/echo-save`, `/echo-recall`, `/echo-triage`, `/echo-health`, `/echo-sweep` slash commands as explicit entry points, and coordinates concurrent Claude/CoWork sessions via a cooperative advisory lock
|
||||
- Exposes `/echo-load`, `/echo-save`, `/echo-recall`, `/echo-triage`, `/echo-health`, `/echo-sweep`, `/echo-reflect`, `/echo-doctor` slash commands as explicit entry points, and coordinates concurrent Claude/CoWork sessions via a cooperative advisory lock
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin is hardcoded for:
|
||||
The plugin ships **no** owner, endpoint, or key. On each machine it installs on, provide a machine-local JSON config:
|
||||
|
||||
- **Server:** `https://echoapi.alwisp.com` (reverse proxy → backend Obsidian Local REST API)
|
||||
- **API Key:** stored in the skill (personal plugin, not for distribution)
|
||||
```
|
||||
~/.claude/echo-memory/config.json (honors $CLAUDE_CONFIG_DIR; file path overridable with $ECHO_CONFIG)
|
||||
{
|
||||
"owner": "Full Name — how the agent refers to the vault owner (third person)",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>"
|
||||
}
|
||||
```
|
||||
|
||||
The endpoint presents a **valid TLS certificate**, so `-k` is not required. The bearer key lives only in the plugin — never inside the vault (per the vault's own safety rules).
|
||||
Set it up on a fresh install by copying the bundled template and filling it in, or via the client:
|
||||
|
||||
```bash
|
||||
python3 skills/echo-memory/scripts/echo.py config init # writes the template if absent → edit it
|
||||
python3 skills/echo-memory/scripts/echo.py config set --owner "…" --endpoint "https://…" --key "…"
|
||||
python3 skills/echo-memory/scripts/echo.py config # show resolved config (key redacted) + source
|
||||
```
|
||||
|
||||
Per field, an environment variable overrides the file: `ECHO_OWNER`, `ECHO_BASE` (endpoint), `ECHO_KEY`. `config init` writes the template (shown above) when the file is absent; a copy also ships at the repo root (`echo-memory.config.template.json`). The config is **never committed** and **never written into a vault note**; the bearer key stays out of the plugin tree entirely.
|
||||
|
||||
## Vault layout (root-addressed)
|
||||
|
||||
@@ -51,7 +67,7 @@ The endpoint presents a **valid TLS certificate**, so `-k` is not required. The
|
||||
Control logic and the master scaffold live in the plugin, not the vault:
|
||||
|
||||
```
|
||||
commands/ ← slash commands: echo-load, echo-save, echo-recall, echo-triage, echo-health, echo-sweep
|
||||
commands/ ← slash commands: echo-load, echo-save, echo-recall, echo-triage, echo-health, echo-sweep, echo-reflect, echo-doctor
|
||||
skills/echo-memory/
|
||||
├── SKILL.md ← operating procedure (authoritative)
|
||||
├── references/
|
||||
@@ -62,7 +78,8 @@ skills/echo-memory/
|
||||
│ ├── api-reference.md ← REST endpoint patterns + routing map
|
||||
│ └── session-log-template.md
|
||||
├── scripts/ ← pure Python; run with python3 (Windows: python / py -3)
|
||||
│ ├── echo.py ← validated client + high-level CLI (capture/resolve/recall/link/load/scope/lock)
|
||||
│ ├── echo.py ← validated client + high-level CLI (capture/resolve/recall/link/load/scope/lock/config)
|
||||
│ ├── echo_config.py ← resolves owner/endpoint/key from the machine-local config (env-overridable)
|
||||
│ ├── echo_index.py ← entity index (registry, resolve, name→path map)
|
||||
│ ├── echo_links.py ← cross-link primitives (Related parsing, bidirectional linking)
|
||||
│ ├── echo_ops.py ← high-level ops (capture, recall, resolve, link, agent-log)
|
||||
@@ -93,9 +110,12 @@ skills/echo-memory/
|
||||
| `/echo-triage` | Drain aging inbox captures to their homes, logging each move |
|
||||
| `/echo-health` | Run `vault_lint.py` and summarize invariant + graph-health violations |
|
||||
| `/echo-sweep` | Bring the vault up to current spec (build index + symmetrize links) |
|
||||
| `/echo-reflect` | Extract durable items from the session, preview, and apply on approval |
|
||||
| `/echo-doctor` | Readiness check: config, endpoint reachability, auth, bootstrap/schema |
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3** available in the session environment (the scripts use only the standard library; invoke as `python3`, or `python` / `py -3` on Windows)
|
||||
- Obsidian running on the backend with the [Local REST API plugin](https://github.com/coddingtonbear/obsidian-local-rest-api) enabled
|
||||
- HTTPS access to `https://echoapi.alwisp.com` from the Claude/CoWork session environment
|
||||
- HTTPS access from the Claude/CoWork session environment to the endpoint configured in `~/.claude/echo-memory/config.json`
|
||||
- A machine-local config (`~/.claude/echo-memory/config.json`) supplying the vault owner, endpoint, and API key — see **Configuration**
|
||||
|
||||
@@ -10,6 +10,8 @@ python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" doctor
|
||||
```
|
||||
|
||||
It prints green/red for: Python version, vault reachability, auth accepted, vault
|
||||
bootstrapped (+ `schema_version`), and the **API key source** (`ECHO_KEY` env / credentials
|
||||
file / deprecated baked-in fallback — see `API-KEY-SETUP.md`). Exits non-zero if anything is
|
||||
red. For the full vault-invariant lint, run `/echo-health`.
|
||||
bootstrapped (+ `schema_version`), and the **config source** for owner/endpoint/key
|
||||
(per-field: env override `ECHO_OWNER`/`ECHO_BASE`/`ECHO_KEY`, then the machine-local
|
||||
`~/.claude/echo-memory/config.json`). Exits non-zero if anything is red — if the config is
|
||||
missing (or still the placeholder template), ask the operator for their echo-memory config file and install it with `echo.py config import <path>` (or `config set --owner … --endpoint … --key …`). For the full vault-invariant lint, run
|
||||
`/echo-health`.
|
||||
|
||||
@@ -12,4 +12,4 @@ ECHO_TODAY=<currentDate> python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scrip
|
||||
|
||||
Exit codes: `0` clean · `1` violations (printed, grouped by check) · `2` vault unreachable · `3` vault not bootstrapped (run `bootstrap.py`).
|
||||
|
||||
Summarize the violations grouped by category and propose fixes, but **do not auto-fix** without Jason's go-ahead. If this is the first substantive session of a calendar month, offer to write the findings to `_agent/health/YYYY-MM-vault-health.md` (per the skill's **Vault Health** section).
|
||||
Summarize the violations grouped by category and propose fixes, but **do not auto-fix** without the operator's go-ahead. If this is the first substantive session of a calendar month, offer to write the findings to `_agent/health/YYYY-MM-vault-health.md` (per the skill's **Vault Health** section).
|
||||
|
||||
@@ -11,4 +11,6 @@ python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" load
|
||||
|
||||
`load` prints all six sections (404s on today's note / inbox are shown as absent, not errors) and flags an un-bootstrapped vault. Follow up with `echo.py scope show` if you need the sessions-since-switch count, and a project search if a specific project is in play.
|
||||
|
||||
**If `load` prints `NOT CONFIGURED` (exit 78)**, this machine has no usable key file yet. Don't proceed with memory — follow **First run** in `SKILL.md`: tell the operator ECHO isn't configured, ask for their echo-memory config file (owner/endpoint/key), install it with `echo.py config import <path>` (or `config set`), then re-run `load`.
|
||||
|
||||
Do not narrate the reads. End with a one-line orientation: who/what/where the active scope is, and any reconcile prompt.
|
||||
|
||||
@@ -7,7 +7,7 @@ Use the **echo-memory** skill to run **session reflection** (H5). Scan this conv
|
||||
things worth remembering across sessions, then propose them — don't write blindly.
|
||||
|
||||
1. **Extract** durable items from the conversation: new facts, preferences, decisions,
|
||||
commitments, people/companies/projects introduced, and anything Jason said to remember.
|
||||
commitments, people/companies/projects introduced, and anything the operator said to remember.
|
||||
Skip the ephemeral. Anchor relative dates on the conversation's `currentDate`.
|
||||
2. **Emit a JSON array** of proposals (one per item), each:
|
||||
`{"title","kind","body","aliases","sources","confidence"}` — `kind` ∈
|
||||
@@ -15,8 +15,8 @@ things worth remembering across sessions, then propose them — don't write blin
|
||||
working, skill, decision`; set `"inbox": true` when the home is genuinely unknown;
|
||||
`confidence` 0–1 (items below 0.6 are dropped — send those to the inbox instead).
|
||||
Write the array to a file with the Write tool (cross-platform; no heredoc).
|
||||
3. **Preview, then apply.** Dry-run first (writes nothing) and show Jason the plan; only
|
||||
apply after he confirms.
|
||||
3. **Preview, then apply.** Dry-run first (writes nothing) and show the operator the plan; only
|
||||
apply after they confirm.
|
||||
|
||||
```bash
|
||||
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" # python3 (Windows: python / py -3)
|
||||
|
||||
@@ -22,4 +22,4 @@ python3 "$ECHO" put <routed/path>.md <bodyfile> # create
|
||||
python3 "$ECHO" patch <path>.md append heading "<H1::Sub>" <bodyfile> # targeted append
|
||||
```
|
||||
|
||||
Write in third person about Jason; never put `[[wikilinks]]` in frontmatter. `capture` handles `agent_written`, `source_notes`, indexing, and linking for you; if you write by hand, `resolve "<title>"` first to avoid duplicates and `bump` `updated:` only on substantive changes.
|
||||
Write in third person about the vault owner; never put `[[wikilinks]]` in frontmatter. `capture` handles `agent_written`, `source_notes`, indexing, and linking for you; if you write by hand, `resolve "<title>"` first to avoid duplicates and `bump` `updated:` only on substantive changes.
|
||||
|
||||
@@ -3,7 +3,7 @@ description: Bring the ECHO vault up to the current feature spec (entity index +
|
||||
allowed-tools: Bash(python3 */echo-memory/scripts/sweep.py*), Bash(python */echo-memory/scripts/sweep.py*)
|
||||
---
|
||||
|
||||
Use the **echo-memory** skill to bring the vault up to the current spec. Run the sweep **dry-run first**, show Jason the plan, and only `--apply` on his go-ahead:
|
||||
Use the **echo-memory** skill to bring the vault up to the current spec. Run the sweep **dry-run first**, show the operator the plan, and only `--apply` on their go-ahead:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py" # plan (read-only)
|
||||
|
||||
@@ -9,4 +9,4 @@ python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" get inbox/cap
|
||||
# Windows: use `python` or `py -3` if `python3` is not on PATH.
|
||||
```
|
||||
|
||||
For each capture Jason accepts: send it to its proper home per the routing map (preference → `operator-preferences.md::Observations`; project idea → `projects/incubating/`; durable fact → `_agent/memory/semantic/`; person → `resources/people/`), then record the move in `inbox/processing-log/YYYY-MM-DD.md` (`- <original> → <destination>`). Do **not** delete the original capture unless Jason explicitly asks — the processing log is the audit trail.
|
||||
For each capture the operator accepts: send it to its proper home per the routing map (preference → `operator-preferences.md::Observations`; project idea → `projects/incubating/`; durable fact → `_agent/memory/semantic/`; person → `resources/people/`), then record the move in `inbox/processing-log/YYYY-MM-DD.md` (`- <original> → <destination>`). Do **not** delete the original capture unless the operator explicitly asks — the processing log is the audit trail.
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
---
|
||||
name: echo-memory
|
||||
description: Use the ECHO Obsidian vault as Jason's persistent memory across Claude/CoWork sessions. Use whenever Jason asks to remember, save, note, log, or capture anything durable — facts, preferences, decisions, schedule changes, commitments — or asks what Claude knows about him, what was discussed or decided before, to check his notes, load his memory/profile/context, add to his inbox, or to track a new project. Trigger even without memory phrasing when the request implies recalling or persisting state across sessions ("pick up where we left off", "anything on X before my meeting?"). Also use proactively at the start of substantive work sessions to load context and at the end to log outcomes. Do NOT use for Bryan's goldbrain vault, Obsidian/REST-API development questions, "memory" meaning RAM, timed reminders, email or local-file lookups, generic second-brain advice, or notes Jason routes to a specific other file or app.
|
||||
description: Use the ECHO Obsidian vault as the operator's persistent memory across Claude/CoWork sessions. Use whenever the operator asks to remember, save, note, log, or capture anything durable — facts, preferences, decisions, schedule changes, commitments — or asks what Claude knows about them, what was discussed or decided before, to check their notes, load their memory/profile/context, add to their inbox, or to track a new project. Trigger even without memory phrasing when the request implies recalling or persisting state across sessions ("pick up where we left off", "anything on X before my meeting?"). Also use proactively at the start of substantive work sessions to load context and at the end to log outcomes. Do NOT use for a different owner's vault, Obsidian/REST-API development questions, "memory" meaning RAM, timed reminders, email or local-file lookups, generic second-brain advice, or notes the operator routes to a specific other file or app.
|
||||
---
|
||||
|
||||
# ECHO Memory
|
||||
|
||||
Use the **ECHO** Obsidian vault as persistent memory. Read context accumulated across sessions; write things the operator asks to be remembered.
|
||||
|
||||
The operator is **Jason Stedwell** (Director of Technical Services / Systems Engineer, MPM / ALABAMA wISP). Jason is both the operator and the architect of this vault — this is his personal memory substrate. Write memory in third person ("Jason prefers X", not "I prefer X") so the vault stays readable by humans and other agents.
|
||||
The vault belongs to a single **owner**, configured per machine (the `owner` field in `~/.claude/echo-memory/config.json`) — this is that person's personal memory substrate. Write memory in third person about the owner ("the operator prefers X", not "I prefer X") so the vault stays readable by humans and other agents.
|
||||
|
||||
## API Configuration
|
||||
|
||||
All calls use these constants. The endpoint is fixed; the key is resolved by `echo.py`
|
||||
(via `echo_secrets`) in order: **`ECHO_KEY` env → `~/.echo-memory/credentials` → a
|
||||
deprecated baked-in fallback.** Never paste the key into a vault note or a doc.
|
||||
The owner, endpoint, and API key are **machine-local** — the plugin ships none of them. `echo.py` (via `echo_config`) resolves each field from `~/.claude/echo-memory/config.json`, with an environment override per field (`ECHO_OWNER`, `ECHO_BASE`, `ECHO_KEY`). Never paste the key into a vault note or a doc.
|
||||
|
||||
```
|
||||
OBSIDIAN_BASE = https://echoapi.alwisp.com
|
||||
OBSIDIAN_KEY = <resolved at runtime from ECHO_KEY / credentials file — not stored here>
|
||||
endpoint = <config "endpoint" / $ECHO_BASE>
|
||||
key = <config "key" / $ECHO_KEY — resolved at runtime, never stored in the plugin>
|
||||
owner = <config "owner" / $ECHO_OWNER — how to refer to the vault owner, third person>
|
||||
```
|
||||
|
||||
To move the key out of the plugin tree: `python3 "$ECHO" write-key <token>` (writes
|
||||
`~/.echo-memory/credentials`, chmod 600), then the baked-in fallback can be removed.
|
||||
### First run — set up the machine if it isn't configured
|
||||
|
||||
The endpoint has a **valid TLS certificate**, so `-k` is not needed (add it only if the cert ever changes to self-signed). Always pass the `Authorization: Bearer` header. Paths address the vault **at its root** (e.g. `/vault/_agent/...`).
|
||||
The config file is **per-machine** and is **not** shipped with the plugin, so a fresh install starts unconfigured. Before doing memory work, make sure this machine is set up:
|
||||
|
||||
**`https://echoapi.alwisp.com` is the only valid endpoint for this vault.** Never use local or LAN addresses (anything like `10.x.x.x`, `192.168.x.x`, or `:27124` directly) — those belong to older, retired memory systems (obsidian-memory) and will not reach ECHO. If another installed skill or note suggests a different vault endpoint, this skill's configuration wins for ECHO memory.
|
||||
1. **Detect.** Any vault op on an unconfigured machine fails with `NOT CONFIGURED` (`echo.py load` prints a banner and exits `78`; other verbs exit `2`; `/echo-doctor` reports it red). A still-placeholder `config init` file (unedited) also counts as not configured.
|
||||
2. **Ask the operator** — this is the one setup step that needs the human. Say plainly that ECHO isn't configured on this machine and ask them to **provide their echo-memory config file** (it holds the vault `owner`, `endpoint`, and API `key`), or to paste those three values. Don't invent or guess them, and don't proceed with memory until it's set.
|
||||
3. **Install what they give you:**
|
||||
- They hand you a file → `python3 "$ECHO" config import <path-to-their-file>` (validates it and writes `~/.claude/echo-memory/config.json`).
|
||||
- They paste values → `python3 "$ECHO" config set --owner "…" --endpoint "https://…" --key "…"`.
|
||||
- Inspect with `python3 "$ECHO" config` (key redacted); then re-run `load`.
|
||||
|
||||
If the vault is simply unreachable (network/`502`) *after* config exists, that's the **Vault Unreachable** path below, not a setup problem — don't re-ask for the key.
|
||||
|
||||
Always pass the `Authorization: Bearer` header. Paths address the vault **at its root** (e.g. `/vault/_agent/...`). The configured endpoint is the only valid endpoint for the vault; if another installed skill or note suggests a different one, this skill's configuration wins for ECHO memory.
|
||||
|
||||
**The plugin is the single source of truth for ECHO.** The vault holds data only — no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md` control docs live there. All structure and procedure ship here:
|
||||
|
||||
@@ -94,11 +101,11 @@ python3 "$ECHO" lock "cc-<session-id>" # exit 75 if another session holds
|
||||
python3 "$ECHO" unlock "cc-<session-id>"
|
||||
```
|
||||
|
||||
Use a stable `<session-id>` for both calls (any unique string for this session). The lock is read-back-confirmed (after PUT, `echo.py` re-reads and backs off if another writer won the race) and cooperative (a stale lock past `ECHO_LOCK_TTL`, default 15 min, is reclaimable); it lives at `_agent/locks/vault.lock`. It is a courtesy, not a hard mutex — if you can't take it, tell Jason another session may be active rather than racing it.
|
||||
Use a stable `<session-id>` for both calls (any unique string for this session). The lock is read-back-confirmed (after PUT, `echo.py` re-reads and backs off if another writer won the race) and cooperative (a stale lock past `ECHO_LOCK_TTL`, default 15 min, is reclaimable); it lives at `_agent/locks/vault.lock`. It is a courtesy, not a hard mutex — if you can't take it, tell the operator another session may be active rather than racing it.
|
||||
|
||||
### Slash commands
|
||||
|
||||
`/echo-load` (cold-start read), `/echo-save <text>` (route + persist via `capture`), `/echo-recall <query>` (recall a topic's neighbourhood), `/echo-triage` (drain the inbox), `/echo-health` (run the linter), `/echo-sweep` (bring the vault up to current spec). These are explicit entry points to the procedures below.
|
||||
`/echo-load` (cold-start read), `/echo-save <text>` (route + persist via `capture`), `/echo-recall <query>` (recall a topic's neighbourhood), `/echo-triage` (drain the inbox), `/echo-health` (run the linter), `/echo-sweep` (bring the vault up to current spec), `/echo-reflect` (extract→preview→apply durable items from the session), `/echo-doctor` (readiness check: config, reachability, auth, bootstrap/schema). These are explicit entry points to the procedures below.
|
||||
|
||||
## Operating Contract & Safety
|
||||
|
||||
@@ -113,7 +120,7 @@ Full contract (principles, agent role, memory model): `references/operating-cont
|
||||
|
||||
## When to Load Memory
|
||||
|
||||
Load at the start of any substantive conversation — anything beyond a single quick factual question. The signal: Jason is starting work, planning, asking for help with something that has state, or referencing prior discussions.
|
||||
Load at the start of any substantive conversation — anything beyond a single quick factual question. The signal: the operator is starting work, planning, asking for help with something that has state, or referencing prior discussions.
|
||||
|
||||
### Loading procedure
|
||||
|
||||
@@ -122,7 +129,7 @@ Load at the start of any substantive conversation — anything beyond a single q
|
||||
| # | GET | Notes |
|
||||
|---|-----|-------|
|
||||
| 1 | `/vault/_agent/echo-vault.md` | The bootstrap marker. 404 → vault not set up; follow `references/bootstrap.md`. 200 → check its `schema_version` and migrate if older. |
|
||||
| 2 | `/vault/_agent/memory/semantic/operator-preferences.md` | Jason's profile |
|
||||
| 2 | `/vault/_agent/memory/semantic/operator-preferences.md` | the operator's profile |
|
||||
| 3 | `/vault/_agent/context/current-context.md` | Active scope + Scope History |
|
||||
| 4 | `/vault/_agent/heartbeat/last-session.md` → then `/vault/_agent/sessions/` | **Read the heartbeat first** — a one-line pointer (`<session-log-path> @ <ISO-timestamp>`) written at the end of the previous session. It's an O(1) jump to the latest log, so you can skip or shortcut the full listing. Fall back to the `sessions/` listing only if the pointer is missing or looks stale; then pick the ~5 most recent by reverse lex sort (filenames `YYYY-MM-DD-HHMM-<slug>.md`, so lex == chrono) and read only the relevant ones. |
|
||||
| 5 | `/vault/journal/daily/YYYY-MM-DD.md` | Today's note; 404 is fine — it's created on first agent activity |
|
||||
@@ -133,9 +140,9 @@ Do not read every session log — older sessions are reachable via `POST /search
|
||||
**Reconcile at load (do this every cold start, after the batch returns).** The batch already fetched everything needed for a cheap self-check — run it before diving into the work so memory maintains itself instead of drifting:
|
||||
|
||||
1. **Inbox depth (Inbox Triage).** If `inbox/captures/inbox.md` (GET #6) holds dated capture lines older than ~7 days that were never routed, surface the count once and offer to triage — see **Inbox Triage** below. This is the load-time trigger that makes triage self-firing rather than something you only run when asked.
|
||||
2. **Scope drift (state it, don't just check it).** Scope is the most churn-prone state — Jason runs several sessions a day across different topics, so the recorded `## Scope` is frequently stale at load. **Silently working under a stale scope is the default failure mode.** To prevent it, at load read the active scope and its freshness in one call — `python3 "$ECHO" scope show` (prints `## Scope`, `scope_updated`, and how many sessions have been logged since) — and form a one-line judgment: *does this session's request match the recorded scope?* If it diverges, switch **before** doing the work via `python3 "$ECHO" scope set "<new scope>"` (see **Scope Switching**). If `scope show` reports several sessions logged since the last switch, treat the recorded scope as suspect and confirm with Jason rather than trusting it.
|
||||
2. **Scope drift (state it, don't just check it).** Scope is the most churn-prone state — the operator runs several sessions a day across different topics, so the recorded `## Scope` is frequently stale at load. **Silently working under a stale scope is the default failure mode.** To prevent it, at load read the active scope and its freshness in one call — `python3 "$ECHO" scope show` (prints `## Scope`, `scope_updated`, and how many sessions have been logged since) — and form a one-line judgment: *does this session's request match the recorded scope?* If it diverges, switch **before** doing the work via `python3 "$ECHO" scope set "<new scope>"` (see **Scope Switching**). If `scope show` reports several sessions logged since the last switch, treat the recorded scope as suspect and confirm with the operator rather than trusting it.
|
||||
|
||||
Keep the reconcile to a single short line to Jason (e.g. "3 inbox captures from last week are still un-routed — triage now?"); don't let it crowd out the actual request.
|
||||
Keep the reconcile to a single short line to the operator (e.g. "3 inbox captures from last week are still un-routed — triage now?"); don't let it crowd out the actual request.
|
||||
|
||||
**If a specific topic/project/person is in play**, pull its connected context in one call:
|
||||
|
||||
@@ -162,7 +169,7 @@ When routing accepted items, send each to its proper home:
|
||||
- Durable fact → PUT `_agent/memory/semantic/<slug>.md`
|
||||
- Person fact → PUT/PATCH `resources/people/<name>.md`
|
||||
|
||||
Then record the move in `inbox/processing-log/YYYY-MM-DD.md` (one line per item: `- <original line> → <destination path>`). Don't delete the original capture unless Jason explicitly asks — the processing log is the audit trail.
|
||||
Then record the move in `inbox/processing-log/YYYY-MM-DD.md` (one line per item: `- <original line> → <destination path>`). Don't delete the original capture unless the operator explicitly asks — the processing log is the audit trail.
|
||||
|
||||
## Project Lifecycle
|
||||
|
||||
@@ -181,14 +188,14 @@ Projects move through four folders under `projects/`. The folder name and the `s
|
||||
|
||||
## When to Write Memory
|
||||
|
||||
Write when Jason:
|
||||
Write when the operator:
|
||||
|
||||
- States a fact, preference, or commitment worth keeping ("I prefer X", "we use uv not pip", "standup is Tuesday at 10")
|
||||
- Makes a non-obvious decision worth recording
|
||||
- Says "remember that", "save this", "log this", "add to memory", "note that"
|
||||
- Finishes a meaningful working session future sessions should pick up
|
||||
|
||||
Write in third person about Jason. Every note carries the canonical frontmatter (see below). Agent-generated notes set `agent_written: true`.
|
||||
Write in third person about the operator. Every note carries the canonical frontmatter (see below). Agent-generated notes set `agent_written: true`.
|
||||
|
||||
**Default write path: `capture`.** For a routed memory (a fact about a person/company/project/concept/etc.), prefer `python3 "$ECHO" capture "<title>" <bodyfile> --kind <kind>` — it does the search-first, path derivation, frontmatter, indexing, auto-linking, and Agent-Log line in one call (see **High-level memory ops**). Drop to the lower-level `put`/`patch`/`append` verbs below only for shapes `capture` doesn't model (a project `## Status` replacement, an ADR mirror, a daily-note edit) or to inspect/repair.
|
||||
|
||||
@@ -204,9 +211,9 @@ python3 "$ECHO" search <slug> # fallback when the index isn't trusted
|
||||
If a match is found:
|
||||
- In a non-active project subfolder (`on-hold/`, `incubating/`, `archived/`): **promote/merge** — PUT the merged content to `projects/active/<slug>.md` with `status: active`, then DELETE the old location. Preserve the earliest `created:` date.
|
||||
- In the same folder you intended to write: **update in place** (PATCH or merged PUT). Never silently overwrite — fold the existing content in first.
|
||||
- Elsewhere (e.g. a stale duplicate under `resources/`): tell Jason and ask which should be canonical before writing.
|
||||
- Elsewhere (e.g. a stale duplicate under `resources/`): tell the operator and ask which should be canonical before writing.
|
||||
|
||||
**Search both the slug AND any human title** Jason used (e.g. slug `echo-memory` and title `ECHO plugin`). Slug-only searches miss notes filed under a different naming scheme. Two cheap `POST /search/simple/?query=...` calls beat one expensive cleanup pass later.
|
||||
**Search both the slug AND any human title** the operator used (e.g. slug `echo-memory` and title `ECHO plugin`). Slug-only searches miss notes filed under a different naming scheme. Two cheap `POST /search/simple/?query=...` calls beat one expensive cleanup pass later.
|
||||
|
||||
Only after the search comes back empty (or you've decided to merge) is it safe to create a new note. This rule prevents the most common duplication bug: a note exists in `on-hold/` but the agent only checked `active/` and created a parallel record.
|
||||
|
||||
@@ -244,7 +251,7 @@ Every PUT body needs the canonical YAML frontmatter (see `references/vault-layou
|
||||
|
||||
## Scope Switching (`current-context.md`)
|
||||
|
||||
`_agent/context/current-context.md` tracks a single active scope. Jason routinely shifts scope within a day (echo plugin → MPM brand → WISP docs), so this is high-churn — switch deliberately, every time the work changes topic.
|
||||
`_agent/context/current-context.md` tracks a single active scope. The operator routinely shifts scope within a day (one project → another → docs), so this is high-churn — switch deliberately, every time the work changes topic.
|
||||
|
||||
**Preferred — one command:**
|
||||
|
||||
@@ -278,7 +285,7 @@ A weekly/monthly rollup is a **light digest** — open threads across `projects/
|
||||
|
||||
## Vault Health (monthly)
|
||||
|
||||
On the first substantive session of a calendar month, run a quick health pass and write findings to `_agent/health/YYYY-MM-vault-health.md`. This is **agent self-maintenance, not a journal entry** — it lives under `_agent/` because it's about the vault's mechanical integrity, not Jason's work narrative. Don't auto-fix without asking.
|
||||
On the first substantive session of a calendar month, run a quick health pass and write findings to `_agent/health/YYYY-MM-vault-health.md`. This is **agent self-maintenance, not a journal entry** — it lives under `_agent/` because it's about the vault's mechanical integrity, not the operator's work narrative. Don't auto-fix without asking.
|
||||
|
||||
Run the bundled linter first — it mechanically checks the invariants below so you don't eyeball them. **Pass `ECHO_TODAY` = the conversation's `currentDate`** so stale/aging math uses the same clock you write with (not the runner's machine date):
|
||||
|
||||
@@ -309,7 +316,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py" --apply #
|
||||
|
||||
Run it after `migrate.py` (which handles structural/schema changes) — or any time `/echo-health` reports index drift. It is idempotent; re-running is safe.
|
||||
|
||||
The pass is cheap and pays for itself by catching drift before it requires a reorg. Write the findings as a digest; act on them only with Jason's go-ahead.
|
||||
The pass is cheap and pays for itself by catching drift before it requires a reorg. Write the findings as a digest; act on them only with the operator's go-ahead.
|
||||
|
||||
**Performance.** Full-vault scripts (`sweep.py`, `vault_lint.py`, recall rebuild) are connection-pooled (keep-alive) and read the whole vault concurrently via `echo.read_many`, so they finish in about a second on a few-hundred-note vault instead of timing out on hundreds of serial TLS handshakes. Tune with `ECHO_WORKERS` (default 8, bulk-read concurrency) and `ECHO_TIMEOUT` (default 30s, per-request). For a vault large enough that even the concurrent pass nears the tool timeout, run the script with the harness's background-execution option rather than blocking the turn.
|
||||
|
||||
@@ -359,11 +366,11 @@ python3 "$ECHO" patch journal/daily/<currentDate>.md append heading "<currentDat
|
||||
|
||||
**Routing triggers — areas / meetings / skills:**
|
||||
|
||||
- **Area** (`areas/`): a standing responsibility Jason maintains with no finish line. Decision rule: has an end state → `projects/`; never "done" → `areas/`. Subfolder is the domain (`business`/`personal`/`learning`/`systems`); create it on first write. `type: area`.
|
||||
- **Area** (`areas/`): a standing responsibility the operator maintains with no finish line. Decision rule: has an end state → `projects/`; never "done" → `areas/`. Subfolder is the domain (`business`/`personal`/`learning`/`systems`); create it on first write. `type: area`.
|
||||
- **Meeting** (`resources/meetings/`): notes, recaps, or action items tied to a specific meeting or call. Mirror decisions to `decisions/by-date/` and commitments to the relevant project. `type: meeting`.
|
||||
- **Skill** (`_agent/skills/`): Jason authors, installs, or retires a skill/plugin and wants it tracked as a capability — distinct from `projects/`, which tracks the build effort. Active entries in `active/`, retired ones in `archived/`. `type: skill`.
|
||||
- **Skill** (`_agent/skills/`): the operator authors, installs, or retires a skill/plugin and wants it tracked as a capability — distinct from `projects/`, which tracks the build effort. Active entries in `active/`, retired ones in `archived/`. `type: skill`.
|
||||
|
||||
Never delete files unless Jason explicitly asks. Memory is append-friendly; deletion is destructive.
|
||||
Never delete files unless the operator explicitly asks. Memory is append-friendly; deletion is destructive.
|
||||
|
||||
## Session Logging
|
||||
|
||||
@@ -389,12 +396,12 @@ python3 "$ECHO" put _agent/heartbeat/last-session.md <bodyfile>
|
||||
|
||||
## Vault Unreachable
|
||||
|
||||
If the API returns a connection error, timeout, or `502`, tell Jason once that the memory vault is unreachable (a `502` usually means Obsidian/the REST plugin is not running on the backend), then proceed without memory. Do not retry repeatedly.
|
||||
If the API returns a connection error, timeout, or `502`, tell the operator once that the memory vault is unreachable (a `502` usually means Obsidian/the REST plugin is not running on the backend), then proceed without memory. Do not retry repeatedly.
|
||||
|
||||
## Style Rules
|
||||
|
||||
- Write in third person about Jason: "Jason prefers X", not "I prefer X".
|
||||
- Jason is both operator and architect here — unlike goldbrain (Bryan's vault, which Jason architected). Do not cross-write: Bryan's preferences belong in goldbrain, Jason's belong here.
|
||||
- Write in third person about the operator: "the operator prefers X", not "I prefer X".
|
||||
- This vault holds only its own owner's memory. Do not cross-write between distinct vaults/owners — another person's vault and preferences belong in their own vault, not here.
|
||||
- **Anchor relative dates on the conversation's `currentDate`** before writing. "Today" → `currentDate`. "Thursday" / "next week" → resolve to an absolute `YYYY-MM-DD`. Never guess from training-data knowledge of the current year.
|
||||
- Every memory file has canonical YAML frontmatter — see `references/vault-layout.md`.
|
||||
- Set `agent_written: true` on agent-generated notes and list `source_notes` (plain relative paths, not links).
|
||||
@@ -402,15 +409,15 @@ If the API returns a connection error, timeout, or `502`, tell Jason once that t
|
||||
- **`source_notes` lists the note(s) that *triggered* or *supplied content for* this one** — e.g. the session log that produced a project update, or the daily note where a captured fact originated. It is a *backward* link to inputs. Forward links (this note → other notes it references) belong in the `## Related` section in the note body, never in frontmatter.
|
||||
- **Never put `[[wikilinks]]` in frontmatter** — YAML parses them as nested lists and the links break in Obsidian's reading view. Put all cross-references in a `## Related` section in the note **body** as a bulleted list of `[[links]]`.
|
||||
- Use Obsidian wiki links (`[[note name]]`) freely in the note **body** for cross-references.
|
||||
- Keep entries short and focused. Fewer, sharper entries beat many noisy ones — Jason explicitly prefers concision.
|
||||
- About to write something large or sensitive? Show Jason the content first and confirm.
|
||||
- Keep entries short and focused. Fewer, sharper entries beat many noisy ones — the operator explicitly prefers concision.
|
||||
- About to write something large or sensitive? Show the operator the content first and confirm.
|
||||
|
||||
## operator-preferences.md — Rules vs Observations
|
||||
|
||||
`_agent/memory/semantic/operator-preferences.md` separates two kinds of content:
|
||||
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. These are timeless: "Jason prefers concise communication." Append here only when a rule is stable.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed: `- 2026-06-06: Jason chose X over Y because Z.` This is where new evidence goes by default.
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. These are timeless: "the operator prefers concise communication." Append here only when a rule is stable.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed: `- 2026-06-06: the operator chose X over Y because Z.` This is where new evidence goes by default.
|
||||
|
||||
During monthly Vault Health or when an observation stabilizes, promote it from `## Observations` into `## Fact / Pattern` (drop the date) and remove the duplicate from Observations. Trim Observations to the last ~30 entries when it grows past that — the rest live in session logs.
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
# ECHO Performance Test Suite — Plan
|
||||
|
||||
**Status:** plan only — nothing here is to be run yet.
|
||||
**Author:** drafted for Jason Stedwell, 2026-06-23.
|
||||
**Author:** drafted for the vault owner, 2026-06-23.
|
||||
**Targets the changes that landed in:** 1.1.0 (connection pooling + concurrent `read_many` + shared cache) and 1.2.0 (fuzzy `resolve`, alias derivation/learning, `capture` re-slug fix).
|
||||
**Scope:** a *performance / timing* harness. It complements — does not replace — the existing correctness tests (`test_echo_client.py`, `test_v1_scaffold.py`, the eval feature tests), which stay as the pass/fail gate for behaviour.
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. Put hard numbers on the three operations Jason called out — full endpoint read, full subject pulls, bulk get/put/append — so the 1.1.0 perf claims ("lint 0.90s / sweep 0.85s on 186 notes, ~4.5× from reuse, ~2× from concurrency") become a repeatable, checked-in measurement instead of a one-time observation.
|
||||
1. Put hard numbers on the three operations the operator called out — full endpoint read, full subject pulls, bulk get/put/append — so the 1.1.0 perf claims ("lint 0.90s / sweep 0.85s on 186 notes, ~4.5× from reuse, ~2× from concurrency") become a repeatable, checked-in measurement instead of a one-time observation.
|
||||
2. Establish **baselines + regression gates** so a future change that quietly reintroduces the pre-1.1.0 per-request TLS handshake (the bug that was timing out sessions) fails the suite instead of silently shipping.
|
||||
3. Keep every run **non-destructive to the production vault** — writes go to a disposable namespace and are cleaned up (per Jason's "prune test artifacts" rule).
|
||||
3. Keep every run **non-destructive to the production vault** — writes go to a disposable namespace and are cleaned up (per the operator's "prune test artifacts" rule).
|
||||
|
||||
## 2. Methodology (applies to every metric)
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
- **Environment capture:** record `ECHO_WORKERS`, `ECHO_TIMEOUT`, vault note count, git commit/plugin version, and wall-clock date in the results header so two runs are comparable.
|
||||
- **Output:** machine-readable JSON (`eval/perf/results/<date>-<commit>.json`) plus a short human table to stdout. JSON lets us diff runs over time and chart trend.
|
||||
- **Isolation:** all test writes live under a dedicated prefix, e.g. `_agent/_bench/<run-id>/...`, never in `projects/`, `resources/`, `inbox/`, or `journal/`. A `--cleanup` pass deletes the whole prefix at the end; a `--keep` flag preserves it for debugging. The advisory lock (`echo.py lock`) is taken for the write phases so a bench run can't race a real CoWork session.
|
||||
- **Network honesty:** the endpoint is the live `echoapi.alwisp.com`, so absolute numbers depend on WAN latency. Report **both** absolute ms and a relative ratio against the same run's single-GET baseline, so the suite stays meaningful regardless of where it's run from.
|
||||
- **Network honesty:** the endpoint is the live configured endpoint (`echo.BASE` / `$ECHO_BASE`), so absolute numbers depend on WAN latency. Report **both** absolute ms and a relative ratio against the same run's single-GET baseline, so the suite stays meaningful regardless of where it's run from.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core metrics (the three Jason asked for)
|
||||
## 3. Core metrics (the three the operator asked for)
|
||||
|
||||
### 3.1 Timed full endpoint read (full-vault bulk read)
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
### 3.3 Timed bulk get / put / append
|
||||
|
||||
**What it measures:** write-path throughput and the read-back/idempotency overhead Jason deliberately pays for.
|
||||
**What it measures:** write-path throughput and the read-back/idempotency overhead the operator deliberately pays for.
|
||||
|
||||
**Procedure (all in the `_agent/_bench/<run-id>/` namespace):**
|
||||
- **Bulk GET:** create K fixture notes once, then time reading them back — both serially and via `read_many` — to compare against §3.1 at a controlled, fixed K (e.g. 25/50/100) so the read curve is isolated from whatever the live vault happens to contain.
|
||||
|
||||
@@ -6,7 +6,7 @@ targeted. It reuses the live `echo` client module, so it measures the real poole
|
||||
concurrent code path — not a reimplementation.
|
||||
|
||||
This is a **manual / pre-release** tool, not a CI gate: it depends on the live
|
||||
endpoint (`echoapi.alwisp.com`) and WAN latency. Gates are **relative ratios**
|
||||
configured endpoint (`echo.BASE` / `$ECHO_BASE`) and WAN latency. Gates are **relative ratios**
|
||||
(portable), with absolute milliseconds kept as informational context.
|
||||
|
||||
See `PERF-TEST-PLAN.md` for the design rationale behind each metric.
|
||||
@@ -25,8 +25,8 @@ python3 bench.py resolve # 1.2.0 fuzzy correctness table + timin
|
||||
python3 bench.py soak --soak-seconds 120 # stability / leak watch
|
||||
```
|
||||
|
||||
Set the key first if the baked-in fallback warning bothers you:
|
||||
`python3 ../../scripts/echo.py write-key <token>` (or `export ECHO_KEY=...`).
|
||||
Configure this machine first (owner/endpoint/key):
|
||||
`python3 ../../scripts/echo.py config init` then edit the file, or `export ECHO_BASE=... ECHO_KEY=...`.
|
||||
|
||||
## Metrics
|
||||
|
||||
@@ -90,7 +90,7 @@ metric + gate above regression-guard this so it can't quietly revert to serial.
|
||||
sandbox vault so the replay doesn't write to production; that's deferred (the
|
||||
plan's synthetic-vault item). The metric still proves enqueue works and reports
|
||||
queue depth.
|
||||
- **Absolute ms depend on where you run from.** WAN latency to `echoapi.alwisp.com`
|
||||
dominates single-request times; that's why gates are ratios.
|
||||
- **Absolute ms depend on where you run from.** WAN latency to the configured
|
||||
endpoint dominates single-request times; that's why gates are ratios.
|
||||
- **Synthetic-vault scaling (100/500/1000 notes) is deferred** per decision — the
|
||||
suite benchmarks the real ~190-note vault today.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "Relative-ratio regression gates (Jason's chosen policy). Ratios are portable across machines/WAN; absolute ms in the results JSON stay informational. Tune these after the first real baseline run, then tighten. 'field' is a dotted path into a metric's result; for list items use the index (e.g. sweep.3.median_ms).",
|
||||
"_comment": "Relative-ratio regression gates (the maintainer's chosen policy). Ratios are portable across machines/WAN; absolute ms in the results JSON stay informational. Tune these after the first real baseline run, then tighten. 'field' is a dotted path into a metric's result; for list items use the index (e.g. sweep.3.median_ms).",
|
||||
"read-full": {
|
||||
"gates": [
|
||||
{ "field": "speedup_ratio", "op": ">=", "min": 1.8,
|
||||
|
||||
@@ -10,7 +10,7 @@ code path. Reads are non-destructive; write metrics live in a disposable
|
||||
namespace (_agent/_bench/<run-id>/) cleaned up at the end unless --keep.
|
||||
|
||||
This is a MANUAL / pre-release tool, not a CI gate — it depends on the live
|
||||
endpoint (echoapi.alwisp.com) and WAN latency.
|
||||
configured endpoint (echo.BASE / $ECHO_BASE) and WAN latency.
|
||||
|
||||
python3 bench.py <metric> [options]
|
||||
python3 bench.py all # the read-only safe set
|
||||
|
||||
@@ -11,8 +11,8 @@ from __future__ import annotations
|
||||
# --- §3.2 subject pulls -------------------------------------------------------
|
||||
# (label, query) pairs fed to `recall`. Chosen to span neighbourhood sizes — the
|
||||
# real cost driver — from a likely-leaf to a known hub. APTA and CapMetro are the
|
||||
# subjects Jason named; "echo" and "operator preferences" are dense hubs that
|
||||
# stress the 1-hop expansion (the read_many fan-out 1.1.0 accelerated).
|
||||
# subjects the maintainer configured; "echo" and "operator preferences" are dense hubs
|
||||
# that stress the 1-hop expansion (the read_many fan-out 1.1.0 accelerated).
|
||||
SUBJECTS: list[tuple[str, str]] = [
|
||||
("apta", "APTA"),
|
||||
("capmetro", "CapMetro"),
|
||||
@@ -32,13 +32,16 @@ SUBJECTS: list[tuple[str, str]] = [
|
||||
# to nothing and silently spawn a new note)
|
||||
# "miss" -> neither; genuinely unknown
|
||||
# Tune expect_slug to the live vault; unknowns are reported as INFO, not failures.
|
||||
# NOTE: the subject/slug values below are neutral placeholders. The maintainer
|
||||
# should point these fixtures at dense hubs that actually exist in their own
|
||||
# vault, otherwise the resolve cases will report INFO misses rather than hits.
|
||||
RESOLVE_CASES: list[dict] = [
|
||||
{"mention": "echo", "expect": "exact", "expect_slug": "echo"},
|
||||
{"mention": "echo memory", "expect": "candidates", "expect_slug": "echo"},
|
||||
{"mention": "ECHO plugin", "expect": "candidates", "expect_slug": "echo"},
|
||||
{"mention": "goldbrain", "expect": "exact", "expect_slug": "goldbrain"},
|
||||
{"mention": "jason stedwell", "expect": "exact", "expect_slug": "jason-stedwell"},
|
||||
{"mention": "jason", "expect": "candidates", "expect_slug": "jason-stedwell"},
|
||||
{"mention": "other-vault", "expect": "exact", "expect_slug": "other-vault"},
|
||||
{"mention": "example subject", "expect": "exact", "expect_slug": "example-subject"},
|
||||
{"mention": "operator", "expect": "candidates", "expect_slug": "example-subject"},
|
||||
{"mention": "zzqx nonexistent entity", "expect": "miss", "expect_slug": None},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:17:09",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
@@ -353,7 +353,7 @@
|
||||
"expect_slug": "echo",
|
||||
"resolved_slug": "echo",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
@@ -407,7 +407,7 @@
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"echo-skill-improvements",
|
||||
"2026-06-19-goldbrain-full-echo-architect"
|
||||
"2026-06-19-other-vault-full-echo-architect"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -456,8 +456,8 @@
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"alabama-wisp-brand-docs"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"example-isp-brand-docs"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -498,13 +498,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "goldbrain",
|
||||
"mention": "other-vault",
|
||||
"expect": "exact",
|
||||
"expect_slug": "goldbrain",
|
||||
"resolved_slug": "goldbrain",
|
||||
"expect_slug": "other-vault",
|
||||
"resolved_slug": "other-vault",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"goldbrain"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"other-vault"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -545,13 +545,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason stedwell",
|
||||
"mention": "example subject",
|
||||
"expect": "exact",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-stedwell",
|
||||
"jason-mcp-gateway"
|
||||
"example-subject",
|
||||
"operator-mcp-gateway"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -592,13 +592,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason",
|
||||
"mention": "operator",
|
||||
"expect": "candidates",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-mcp-gateway",
|
||||
"jason-stedwell"
|
||||
"operator-mcp-gateway",
|
||||
"example-subject"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:22:00",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:36",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:21",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:12:16",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T21:22:29",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:20:57",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:01",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:28",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:12:12",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:22:05",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:17:09",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:12:19",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
@@ -20,7 +20,7 @@
|
||||
"expect_slug": "echo",
|
||||
"resolved_slug": "echo",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
@@ -74,7 +74,7 @@
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"echo-skill-improvements",
|
||||
"2026-06-19-goldbrain-full-echo-architect"
|
||||
"2026-06-19-other-vault-full-echo-architect"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -123,8 +123,8 @@
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"alabama-wisp-brand-docs"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"example-isp-brand-docs"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -165,13 +165,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "goldbrain",
|
||||
"mention": "other-vault",
|
||||
"expect": "exact",
|
||||
"expect_slug": "goldbrain",
|
||||
"resolved_slug": "goldbrain",
|
||||
"expect_slug": "other-vault",
|
||||
"resolved_slug": "other-vault",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"goldbrain"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"other-vault"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -212,13 +212,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason stedwell",
|
||||
"mention": "example subject",
|
||||
"expect": "exact",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-stedwell",
|
||||
"jason-mcp-gateway"
|
||||
"example-subject",
|
||||
"operator-mcp-gateway"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -259,13 +259,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason",
|
||||
"mention": "operator",
|
||||
"expect": "candidates",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-mcp-gateway",
|
||||
"jason-stedwell"
|
||||
"operator-mcp-gateway",
|
||||
"example-subject"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:22:15",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T21:18:12",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:20:23",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:18:05",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"subtitle": "Validation of the 1.1.0 concurrency and 1.2.0 entity-resolution releases",
|
||||
"reference": "echo-v.05 @ af16598",
|
||||
"status": "11/11 gate checks pass",
|
||||
"summary": "Full-vault reads run 7.1x faster concurrent than serial (11.4 s down to 1.6 s, 197 notes). Entity resolution is sub-millisecond. Profiling traced recall() latency to serial graph expansion (not BM25); the fix makes expansion concurrent (3.5-4.2x, identical results). All 11 gate checks pass. Run live against echoapi.alwisp.com on 2026-06-23.",
|
||||
"summary": "Full-vault reads run 7.1x faster concurrent than serial (11.4 s down to 1.6 s, 197 notes). Entity resolution is sub-millisecond. Profiling traced recall() latency to serial graph expansion (not BM25); the fix makes expansion concurrent (3.5-4.2x, identical results). All 11 gate checks pass. Run live against obsidian.example.com on 2026-06-23.",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Test Environment",
|
||||
@@ -11,7 +11,7 @@
|
||||
"rows": [
|
||||
{
|
||||
"label": "Endpoint",
|
||||
"value": "echoapi.alwisp.com (live)"
|
||||
"value": "obsidian.example.com (live)"
|
||||
},
|
||||
{
|
||||
"label": "Vault size",
|
||||
@@ -513,7 +513,7 @@
|
||||
"items": [
|
||||
"Concurrency delivers as designed. Eight workers cut full-vault reads 7.5x; the curve is near-linear to 8 workers and flattens after (8->16 returns only 1.24x). The default of 8 is the right setting for this vault and link.",
|
||||
"Keep-alive is confirmed live. A cold request costs 163 ms against a 50 ms warm median (3.25x). This is the direct measure that the 1.1.0 pooling fix removed the per-request TLS handshake that was timing out full-vault passes.",
|
||||
"Entity resolution is effectively free. resolve() returns in under 1 ms across exact, alias, and shortened mentions. The 1.2.0 alias work resolves shortened names (\"echo memory\", \"ECHO plugin\", \"jason\") straight to the canonical note, so the anti-duplicate guard holds without a fuzzy fallback.",
|
||||
"Entity resolution is effectively free. resolve() returns in under 1 ms across exact, alias, and shortened mentions. The 1.2.0 alias work resolves shortened names (\"echo memory\", \"ECHO plugin\", \"operator\") straight to the canonical note, so the anti-duplicate guard holds without a fuzzy fallback.",
|
||||
"recall() latency was traced to the graph layer, not BM25. The BM25 index is already persisted and incrementally maintained (load 500 ms, score 0.1 ms). The cost was expand_graph fetching each neighbour serially \u2014 3.0 s for 126 nodes. Fix: the graph BFS now fetches each hop concurrently via the existing read_many. expand_graph is 3.5-4.2x faster with byte-identical ranking and scores; end-to-end recall() dropped from 2.8-4.7 s to 2.1-3.0 s per subject.",
|
||||
"Full-vault maintenance scripts stay under the tool timeout. sweep.py runs in 4.6 s and vault_lint.py in 5.1 s on 197 notes. The original failure mode (passes exceeding the timeout and dropping the session) does not recur.",
|
||||
"Write integrity holds. PUT verifies via read-back at 115 ms; APPEND is whole-line idempotent, skipping at 51 ms with zero duplicate writes on re-run. The advisory lock fast-fails a contended acquire with the expected exit 75."
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ECHO — Obsidian Local REST API Reference
|
||||
|
||||
Server: `https://echoapi.alwisp.com` (reverse proxy → backend Obsidian Local REST API)
|
||||
Auth header: `Authorization: Bearer $ECHO_KEY` — `export ECHO_KEY=<token>` before running these recipes (`echo.py` resolves it automatically: `ECHO_KEY` → `~/.echo-memory/credentials` → deprecated baked-in fallback). The literal key is never stored in docs.
|
||||
The endpoint has a **valid TLS certificate** — `-k` is not required. Paths address the vault at its **root**.
|
||||
Server: the configured endpoint — the `endpoint` from `~/.claude/echo-memory/config.json` (overridable with `ECHO_BASE`), a reverse proxy → backend Obsidian Local REST API. Examples below use `$ECHO_BASE` (or the neutral placeholder `https://obsidian.example.com`).
|
||||
Auth header: `Authorization: Bearer $ECHO_KEY` — `export ECHO_KEY=<token>` before running these recipes, or let `echo.py` resolve it automatically (per-field, first wins: env override `ECHO_KEY` → the `key` in `~/.claude/echo-memory/config.json`, resolved via the `echo_config` module). The literal key is never stored in docs.
|
||||
A configured endpoint should present a **valid TLS certificate** — `-k` is not required. Paths address the vault at its **root**.
|
||||
|
||||
> **Prefer `scripts/echo.py` over the raw recipes below.** It wraps every verb with auth, status checking, retry, idempotent append, and frontmatter patches, and runs on any platform with a Python interpreter. The `curl` recipes here are the underlying mechanics and a **\*nix/bash fallback** (they use heredocs and `--data-binary`); on Windows, prefer `echo.py` or an equivalent. **If you call `curl` directly, check the HTTP status** — add `-o /dev/null -w "%{http_code}"` and branch on it. A `PATCH` to a non-existent heading returns `400 invalid-target` (errorCode 40080) and the write is *silently lost*; a bare `curl` that ignores status will report success anyway. `GET` returns `404` for a missing file. Treat any `>= 400` as a failed operation, surface it, and do not continue as if it succeeded.
|
||||
|
||||
@@ -29,7 +29,7 @@ For a vault large enough that even the concurrent pass approaches the tool timeo
|
||||
# Read any file by vault path
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/context/current-context.md"
|
||||
"$ECHO_BASE/vault/_agent/context/current-context.md"
|
||||
```
|
||||
|
||||
Returns raw file content (text/markdown). On 404, the file does not exist.
|
||||
@@ -38,7 +38,7 @@ Returns raw file content (text/markdown). On 404, the file does not exist.
|
||||
# Read a specific heading's content only
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
|
||||
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
|
||||
```
|
||||
|
||||
Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
|
||||
@@ -54,7 +54,7 @@ Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
|
||||
# List contents of a directory (trailing slash required)
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/sessions/"
|
||||
"$ECHO_BASE/vault/_agent/sessions/"
|
||||
```
|
||||
|
||||
Returns JSON: `{ "files": [...], "folders": [...] }`.
|
||||
@@ -74,7 +74,7 @@ curl -s -X POST \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_entry.md \
|
||||
"https://echoapi.alwisp.com/vault/inbox/captures/inbox.md"
|
||||
"$ECHO_BASE/vault/inbox/captures/inbox.md"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -105,7 +105,7 @@ curl -s -X PUT \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_file.md \
|
||||
"https://echoapi.alwisp.com/vault/_agent/sessions/2026-06-05-1430-my-session.md"
|
||||
"$ECHO_BASE/vault/_agent/sessions/2026-06-05-1430-my-session.md"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -120,7 +120,7 @@ curl -s -X PUT \
|
||||
|
||||
```bash
|
||||
cat > /tmp/obs_patch.md << 'OBSEOF'
|
||||
Jason prefers concise status updates — lead with the decision.
|
||||
The operator prefers concise status updates — lead with the decision.
|
||||
OBSEOF
|
||||
|
||||
curl -s -X PATCH \
|
||||
@@ -130,7 +130,7 @@ curl -s -X PATCH \
|
||||
-H "Target: Operator Preferences::Fact / Pattern" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_patch.md \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
```
|
||||
|
||||
### Discover heading / block / frontmatter targets
|
||||
@@ -141,7 +141,7 @@ When unsure of the exact heading path, GET the note with the document-map Accept
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
-H "Accept: application/vnd.olrapi.document-map+json" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
```
|
||||
|
||||
Returns `{ "headings": [...], "blocks": [...], "frontmatterFields": [...] }`. Copy the heading string verbatim into `Target`.
|
||||
@@ -164,7 +164,7 @@ curl -s -X PATCH \
|
||||
-H "Target: updated" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '"2026-06-05"' \
|
||||
"https://echoapi.alwisp.com/vault/projects/active/vault-foundation.md"
|
||||
"$ECHO_BASE/vault/projects/active/vault-foundation.md"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -174,7 +174,7 @@ curl -s -X PATCH \
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/search/simple/?query=weekly+review"
|
||||
"$ECHO_BASE/search/simple/?query=weekly+review"
|
||||
```
|
||||
|
||||
Returns an array of `{ filename, score, matches: [{ context, match }] }`.
|
||||
@@ -186,7 +186,7 @@ Returns an array of `{ filename, score, matches: [{ context, match }] }`.
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/inbox/imports/old-note.md"
|
||||
"$ECHO_BASE/vault/inbox/imports/old-note.md"
|
||||
```
|
||||
|
||||
Only on explicit operator request. Deletion is destructive.
|
||||
|
||||
@@ -20,8 +20,8 @@ python3 "$SCRIPTS/migrate.py" --apply # perform the migration (moves/delet
|
||||
`bootstrap.py` writes through `echo.py`, so every step is status-checked and the marker is written last. The scripts are pure Python (only an interpreter required — no bash, no platform-specific `date`), so they run identically on Windows, macOS, and Linux. The manual steps below document what the script does (and serve as a fallback if the script can't run).
|
||||
|
||||
```
|
||||
AUTH="Authorization: Bearer $ECHO_KEY" # export ECHO_KEY first; echo.py resolves it automatically
|
||||
BASE="https://echoapi.alwisp.com"
|
||||
AUTH="Authorization: Bearer $ECHO_KEY" # export ECHO_KEY first; echo.py resolves it automatically via echo_config
|
||||
BASE="$ECHO_BASE" # the endpoint from ~/.claude/echo-memory/config.json (override: ECHO_BASE)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -138,5 +138,5 @@ Run the same steps 1–5, but GET-probe each path first and **only create what i
|
||||
When the marker's `schema_version` is older than the plugin's, apply the migration steps for each intervening version, then PATCH the marker's `schema_version` frontmatter to the new value.
|
||||
|
||||
- **0 → 1** (control-docs-in-plugin): the vault previously carried root control docs (`CLAUDE.md`, `BOOTSTRAP.md`, `STRUCTURE.md`, `index.md`). Back them up outside the vault, DELETE them, PUT the thin `scaffold/README.vault.md` over the old verbose `README.md`, write the `_agent/echo-vault.md` marker, and scrub now-dangling `[[CLAUDE]]`/`[[BOOTSTRAP]]`/`[[STRUCTURE]]`/`[[index]]` links from the `## Related` sections of `operator-preferences.md` and `current-context.md` (leave historical session logs alone). Confirm with the operator before deleting.
|
||||
- **1 → 2** (reviews-folded-into-journal): the `reviews/` tree is retired. (a) For each note under `reviews/weekly/` and `reviews/monthly/`, MOVE it into `journal/weekly/` (rename `YYYY-Www-review.md` → `YYYY-Www.md`) and `journal/monthly/` respectively, preserving the earliest `created:`. (b) Move any `reviews/monthly/YYYY-MM-vault-health.md` to `_agent/health/`. (c) Move `reviews/quarterly|annual/` artifacts to `journal/quarterly|annual/`. (d) Update inbound `[[reviews/...]]` wikilinks in `## Related` sections to the new paths. (e) DELETE the now-empty `reviews/` tree. Confirm with the operator before deleting; leave historical session logs alone. *(Jason's live vault was hand-migrated for the one existing `2026-W24` artifact on 2026-06-10; this step covers any vault bootstrapped under schema 1.)*
|
||||
- **1 → 2** (reviews-folded-into-journal): the `reviews/` tree is retired. (a) For each note under `reviews/weekly/` and `reviews/monthly/`, MOVE it into `journal/weekly/` (rename `YYYY-Www-review.md` → `YYYY-Www.md`) and `journal/monthly/` respectively, preserving the earliest `created:`. (b) Move any `reviews/monthly/YYYY-MM-vault-health.md` to `_agent/health/`. (c) Move `reviews/quarterly|annual/` artifacts to `journal/quarterly|annual/`. (d) Update inbound `[[reviews/...]]` wikilinks in `## Related` sections to the new paths. (e) DELETE the now-empty `reviews/` tree. Confirm with the operator before deleting; leave historical session logs alone. *(This step covers any vault bootstrapped under schema 1.)*
|
||||
- **2 → 3** (entity index + cross-linking): adds the `_agent/index/` folder for the machine-maintained entity registry. `migrate.py` creates the folder; the **data back-fill is `sweep.py`** — after migrating, run `python3 scripts/sweep.py --apply` to build `_agent/index/entities.json` from the notes already in the vault and to symmetrize existing `## Related` cross-links (it adds only the missing reciprocal direction, never invents links). `sweep.py` is idempotent and dry-run by default; re-run it any time `/echo-health` reports index drift.
|
||||
|
||||
@@ -21,7 +21,7 @@ Views of the same truth: `scripts/routing.json` is the **machine-readable canoni
|
||||
| `inbox/imports/<slug>.md` | Raw external material dropped in wholesale (export, paste, dump) | The raw artifact, unedited | Holds un-triaged *bulk*, vs `captures` which holds single lines | PUT |
|
||||
| `inbox/processing-log/YYYY-MM-DD.md` | An inbox item is routed to its real home | One line: `<original> → <destination path>` | Audit trail of moves — never holds memory itself, only the record of routing | POST |
|
||||
|
||||
Captures and imports are temporary by contract. Triage drains them into the homes below and logs the move; the original is left until Jason okays deletion.
|
||||
Captures and imports are temporary by contract. Triage drains them into the homes below and logs the move; the original is left until the operator okays deletion.
|
||||
|
||||
## journal/ — the one append-only time-series stream
|
||||
|
||||
@@ -53,7 +53,7 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
|
||||
|
||||
| Path | Trigger | What lands | Distinct because | Method |
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `areas/<domain>/<slug>.md` | Ongoing domain Jason maintains indefinitely (`<domain>`: business/personal/learning/systems) | Area note | No end state — the one thing that disqualifies it from `projects/` | PUT |
|
||||
| `areas/<domain>/<slug>.md` | Ongoing domain the operator maintains indefinitely (`<domain>`: business/personal/learning/systems) | Area note | No end state — the one thing that disqualifies it from `projects/` | PUT |
|
||||
|
||||
## resources/ — reference material about the world
|
||||
|
||||
@@ -61,7 +61,7 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `resources/people/<name>.md` | A fact about a specific person | Person note (kebab-case slug) | Keyed to a person, not a topic or event | PUT / PATCH |
|
||||
| `resources/companies/<slug>.md` | A fact about an organization (client, vendor, partner, employer) | Company note (kebab-case slug) | Keyed to an organization, not an individual (`people/`) or an external source (`references/`) | PUT / PATCH |
|
||||
| `resources/concepts/<slug>.md` | A reusable concept/idea Jason wants on file | Concept note | An idea, vs a `reference` which is an external source | PUT |
|
||||
| `resources/concepts/<slug>.md` | A reusable concept/idea the operator wants on file | Concept note | An idea, vs a `reference` which is an external source | PUT |
|
||||
| `resources/references/<slug>.md` | An external source/link worth keeping | Reference note | Points outward (a source), vs `concepts` (an idea) | PUT |
|
||||
| `resources/meetings/YYYY-MM-DD-<slug>.md` | Notes/recap tied to a specific meeting or call | Meeting note; mirror decisions to `decisions/by-date/`, commitments to the project | Event-anchored to a meeting, vs a project's ongoing thread | PUT |
|
||||
|
||||
@@ -80,15 +80,15 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `_agent/echo-vault.md` | Bootstrap / schema migration only | Marker: `schema_version`, bootstrap date | Plugin-owned probe; never hand-edited | GET / PUT |
|
||||
| `_agent/context/current-context.md` | Active scope changes; task focus shifts | `## Scope`, `## Scope History`, priorities | Single *live* scope pointer, vs episodic which is a *past* record | PATCH / PUT |
|
||||
| `_agent/memory/semantic/operator-preferences.md` | A preference/pattern about Jason | Append under `## Observations`; promote to `## Fact / Pattern` when stable | The one curated profile; distinct from ad-hoc semantic notes | PATCH |
|
||||
| `_agent/memory/semantic/<slug>.md` | A durable fact/pattern that isn't a Jason-preference | Semantic note | Timeless fact, vs episodic (time-stamped event) and working (transient) | PUT |
|
||||
| `_agent/memory/semantic/operator-preferences.md` | A preference/pattern about the operator | Append under `## Observations`; promote to `## Fact / Pattern` when stable | The one curated profile; distinct from ad-hoc semantic notes | PATCH |
|
||||
| `_agent/memory/semantic/<slug>.md` | A durable fact/pattern that isn't an operator-preference | Semantic note | Timeless fact, vs episodic (time-stamped event) and working (transient) | PUT |
|
||||
| `_agent/memory/episodic/<slug>.md` | A record of *what happened, when* | Episodic note | Anchored to an event in time; not a standing fact | PUT |
|
||||
| `_agent/memory/working/<slug>.md` | Short-lived state needed only for the current effort | Working note | Explicitly transient/time-boxed; safe to go stale | PUT |
|
||||
| `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md` | A substantive session ends (decisions/artifacts/commitments) | Session log (see template) | Per-session record; the unit loading Step 4 scans | PUT |
|
||||
| `_agent/health/YYYY-MM-vault-health.md` | First substantive session of a month (Vault Health pass) | Health-audit findings | Agent self-maintenance about vault integrity — NOT Jason's work narrative, so not in `journal/` | PUT |
|
||||
| `_agent/health/YYYY-MM-vault-health.md` | First substantive session of a month (Vault Health pass) | Health-audit findings | Agent self-maintenance about vault integrity — NOT the operator's work narrative, so not in `journal/` | PUT |
|
||||
| `_agent/heartbeat/last-session.md` | End of every session, after the session log is written | One line: `<session-log-path> @ <ISO-timestamp>` | O(1) orientation pointer read first at load (Step 4); overwritten, never grows | PUT |
|
||||
| `_agent/templates/` | Bootstrap only (seeded from plugin masters) | Canonical note templates | Holds templates, not memory; never a runtime routing target | PUT (seed) |
|
||||
| `_agent/skills/active/<slug>.md` | Jason authors/installs a skill and wants it catalogued | Skill capability entry | Catalogs a *capability*, vs `projects/` which tracks the *build effort* | PUT |
|
||||
| `_agent/skills/active/<slug>.md` | The operator authors/installs a skill and wants it catalogued | Skill capability entry | Catalogs a *capability*, vs `projects/` which tracks the *build effort* | PUT |
|
||||
| `_agent/skills/archived/<slug>.md` | A catalogued skill is retired | Skill entry (moved from `active/`) | Terminal state of the skill catalog | PUT |
|
||||
| `_agent/locks/vault.lock` | Advisory multi-writer lock acquire/release | One line: `<owner> @ <ISO-timestamp>` | Concurrency coordination, not memory; managed only by `echo.py lock/unlock` | PUT / DELETE |
|
||||
| `_agent/index/entities.json` | Rebuilt on every `capture` and on `sweep` | Slug→{path, kind, title, aliases, last_seen} registry | Machine-maintained routing/recall index, not a note; the one JSON in the vault | PUT |
|
||||
@@ -120,7 +120,7 @@ Listed so they are recognised as dead and never recreated. Any one of these appe
|
||||
## Routing decision tree (the calls that get mis-made)
|
||||
|
||||
1. **Destination unknown right now?** → `inbox/captures/`. Known? → route directly; never park a known fact in the inbox.
|
||||
2. **Is it about Jason's work over time?** → `journal/` (pick the grain by cadence). **About the vault's mechanical health?** → `_agent/health/`. These two look similar monthly but answer different questions.
|
||||
2. **Is it about the operator's work over time?** → `journal/` (pick the grain by cadence). **About the vault's mechanical health?** → `_agent/health/`. These two look similar monthly but answer different questions.
|
||||
3. **Does the effort have an end state?** → `projects/` (folder = `status:`). **No finish line?** → `areas/`.
|
||||
4. **A fact about the world:** timeless → `semantic/`; a thing that happened → `episodic/`; needed only for now → `working/`. A fact about a *person* → `resources/people/`; a fact about an *organization* → `resources/companies/`.
|
||||
5. **A decision:** always `decisions/by-date/`; mirror into a project only if one already exists.
|
||||
|
||||
@@ -111,14 +111,14 @@ not rewrite frontmatter — the append goes after existing content. To change
|
||||
|
||||
The profile analog. Canonical headings:
|
||||
|
||||
- `## Operator` — who Jason is (one paragraph)
|
||||
- `## Operator` — who the vault owner is (one paragraph)
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. Timeless.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed lines (`- 2026-06-06: ...`). Default landing zone for new evidence.
|
||||
- `## Evidence` — citations/links supporting the rules
|
||||
- `## Recommendation or Implication` — how the rules should shape behavior
|
||||
- `## Review Notes` — confidence / last review date
|
||||
|
||||
Append observed facts under `## Observations` by default. Promote to `## Fact / Pattern` (dropping the date) once a pattern stabilizes. "The operator" is Jason — he is both operator and architect of this vault.
|
||||
Append observed facts under `## Observations` by default. Promote to `## Fact / Pattern` (dropping the date) once a pattern stabilizes. "The operator" is the vault owner — refer to them generically and in the third person (the display name is the runtime `owner` config value).
|
||||
|
||||
### projects/active/\<slug\>.md
|
||||
|
||||
@@ -173,11 +173,11 @@ ADR-style: Context → Decision → Consequences. If the decision belongs to an
|
||||
|
||||
### people/\<name\>.md
|
||||
|
||||
`type: person`. Use lowercase kebab-case for the slug (e.g. `jason-stedwell.md`).
|
||||
`type: person`. Use lowercase kebab-case for the slug (e.g. `robert-smith.md`).
|
||||
|
||||
### companies/\<slug\>.md
|
||||
|
||||
`type: company`. An organization Jason works with — client, vendor, partner, or employer (e.g. `gillig.md`, `mpm.md`). Distinct from `people/` (individuals, who *belong to* companies) and `references/` (external sources). Lowercase kebab-case slug.
|
||||
`type: company`. An organization the vault owner works with — client, vendor, partner, or employer (e.g. `acme.md`). Distinct from `people/` (individuals, who *belong to* companies) and `references/` (external sources). Lowercase kebab-case slug.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,9 +19,11 @@ reused across requests), and `read_many` fans bulk GETs across a thread pool —
|
||||
full-vault scripts (sweep, lint, recall rebuild) make a handful of warm round-trips
|
||||
instead of hundreds of fresh TLS handshakes, and no longer blow past tool timeouts.
|
||||
|
||||
Config (env overrides; defaults match the rest of the plugin):
|
||||
ECHO_BASE default https://echoapi.alwisp.com
|
||||
ECHO_KEY default the plugin bearer token (env overrides it)
|
||||
Config (owner/endpoint/key are machine-local — see echo_config; the plugin ships none):
|
||||
owner/endpoint/key resolved by echo_config from ~/.claude/echo-memory/config.json
|
||||
(env ECHO_OWNER / ECHO_BASE / ECHO_KEY override per field).
|
||||
Set up a machine with `echo.py config init` then edit, or
|
||||
`echo.py config set --owner ... --endpoint ... --key ...`.
|
||||
ECHO_VERIFY default 1 — read-back verify after a PUT
|
||||
ECHO_LOCK_TTL default 900 — seconds before an advisory lock is considered stale
|
||||
ECHO_TIMEOUT default 30 — per-request socket timeout (seconds)
|
||||
@@ -45,8 +47,9 @@ Usage:
|
||||
echo.py unlock <owner-id> # release advisory lock if owned by <owner-id>
|
||||
echo.py scope show # print active scope, its freshness, and sessions-since
|
||||
echo.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
|
||||
echo.py config [show|init|set|import] # machine-local owner/endpoint/key (config import <file> adopts a provided file)
|
||||
|
||||
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 2 usage · 1 other HTTP/transport error.
|
||||
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 78 config-required · 2 usage · 1 other HTTP/transport error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -73,15 +76,17 @@ for _stream in (sys.stdout, sys.stderr):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
BASE = os.environ.get("ECHO_BASE", "https://echoapi.alwisp.com").rstrip("/")
|
||||
|
||||
# Key resolution is centralized in echo_secrets: ECHO_KEY env -> ~/.echo-memory/credentials
|
||||
# -> the deprecated baked-in fallback below. Move the key out of the tree with
|
||||
# `echo.py write-key <token>`; remove DEFAULT_KEY before the 1.0 tag (see ROADMAP-1.0.md > M1).
|
||||
# Owner / endpoint / key are machine-local and resolved by echo_config from
|
||||
# ~/.claude/echo-memory/config.json (env ECHO_OWNER / ECHO_BASE / ECHO_KEY override).
|
||||
# The plugin ships none of them; load() returns "" for anything unset so --help,
|
||||
# `config`, and `doctor` stay usable. Network ops raise a clear error if endpoint/key
|
||||
# are missing (see _require_endpoint / request).
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import echo_secrets # noqa: E402
|
||||
DEFAULT_KEY = "241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab"
|
||||
KEY = echo_secrets.resolve_key(DEFAULT_KEY)
|
||||
import echo_config # noqa: E402
|
||||
_CFG = echo_config.load()
|
||||
BASE = _CFG["endpoint"]
|
||||
KEY = _CFG["key"]
|
||||
OWNER = _CFG["owner"]
|
||||
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
|
||||
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
|
||||
# Per-request socket timeout. A single stuck file fails fast instead of stalling a
|
||||
@@ -125,6 +130,19 @@ _local = threading.local()
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _require_config() -> None:
|
||||
"""Fail loudly (not with an opaque DNS/connection error) when this machine has no
|
||||
usable key file yet — the most common fresh-install state. Also catches an
|
||||
untouched `config init` template (placeholder endpoint/key)."""
|
||||
if not echo_config.is_configured(_CFG):
|
||||
raise EchoError(
|
||||
"echo: NOT CONFIGURED — no usable ECHO key file on this machine. Ask the "
|
||||
f"operator for their config (owner/endpoint/key) and install it at "
|
||||
f"{echo_config.config_path()}: `echo.py config import <file>`, or "
|
||||
"`echo.py config set --owner ... --endpoint ... --key ...` "
|
||||
"(or set ECHO_BASE / ECHO_KEY).", 2)
|
||||
|
||||
|
||||
def _new_connection() -> http.client.HTTPConnection:
|
||||
parts = urllib.parse.urlsplit(BASE)
|
||||
host = parts.hostname or parts.path
|
||||
@@ -157,6 +175,7 @@ def request(method: str, url: str, data: bytes | None = None,
|
||||
status 0 == transport failure (echo_queue relies on this to trigger offline queueing).
|
||||
Retries: a stale pooled connection reconnects immediately; 5xx and other transport
|
||||
errors get one bounded sleep-retry, same as before."""
|
||||
_require_config()
|
||||
merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
target = parts.path or "/"
|
||||
@@ -514,6 +533,15 @@ def cmd_scope(subcommand: str, text: str | None = None) -> int:
|
||||
def cmd_load() -> int:
|
||||
"""Cold-start orientation: the canonical 6 reads in one call. 404s on today's
|
||||
daily note and the inbox are normal (printed as absent, not errors)."""
|
||||
if not echo_config.is_configured(_CFG):
|
||||
print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.")
|
||||
print(f"Expected at: {echo_config.config_path()}")
|
||||
print("ACTION: ask the operator for their echo-memory config file (it holds the")
|
||||
print(" vault owner, endpoint, and API key), then install it with ONE of:")
|
||||
print(" python3 echo.py config import <path-to-their-file>")
|
||||
print(" python3 echo.py config set --owner \"…\" --endpoint \"https://…\" --key \"…\"")
|
||||
print("Then re-run load. (Memory is unavailable until configured.)")
|
||||
return 78 # distinct: configuration required
|
||||
targets = [
|
||||
("marker", "_agent/echo-vault.md"),
|
||||
("preferences", "_agent/memory/semantic/operator-preferences.md"),
|
||||
@@ -588,6 +616,44 @@ def cmd_load() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_config(args) -> int:
|
||||
"""show | init | set the machine-local owner/endpoint/key config."""
|
||||
path = echo_config.config_path()
|
||||
if args.action == "init":
|
||||
p, created = echo_config.init_template()
|
||||
print(f"ok: wrote config template to {p} — edit it with your owner/endpoint/key"
|
||||
if created else f"config already exists at {p} (left unchanged)")
|
||||
return 0
|
||||
if args.action == "import":
|
||||
if not args.path:
|
||||
print("config import: pass the path to a config file "
|
||||
"(JSON with owner/endpoint/key)", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
p = echo_config.import_file(args.path)
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 2
|
||||
print(f"ok: imported config to {p} (chmod 600 best-effort)")
|
||||
return 0
|
||||
if args.action == "set":
|
||||
if args.owner is None and args.endpoint is None and args.key is None:
|
||||
print("config set: pass at least one of --owner / --endpoint / --key", file=sys.stderr)
|
||||
return 2
|
||||
p = echo_config.write_config(args.owner, args.endpoint, args.key)
|
||||
print(f"ok: updated {p} (chmod 600 best-effort)")
|
||||
return 0
|
||||
# show — never prints the key in the clear
|
||||
cfg = echo_config.load()
|
||||
key = cfg["key"]
|
||||
redacted = (key[:4] + "…" + key[-4:]) if len(key) >= 12 else ("set" if key else "")
|
||||
print(f"config file: {path}" + ("" if path.exists() else " (absent)"))
|
||||
for field, shown in (("owner", cfg["owner"]), ("endpoint", cfg["endpoint"]), ("key", redacted)):
|
||||
src = echo_config.source(field)
|
||||
print(f" {field:9} {shown or '(unset)'} [{src}]")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Validated cross-platform ECHO vault client")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
@@ -609,7 +675,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sub.add_parser("delete").add_argument("path")
|
||||
sub.add_parser("lock").add_argument("owner")
|
||||
sub.add_parser("unlock").add_argument("owner")
|
||||
sub.add_parser("write-key").add_argument("token") # M1: move the key to ~/.echo-memory/credentials
|
||||
p = sub.add_parser("config") # machine-local owner/endpoint/key (see echo_config)
|
||||
p.add_argument("action", nargs="?", default="show", choices=["show", "init", "set", "import"])
|
||||
p.add_argument("path", nargs="?") # for `config import <path>`
|
||||
p.add_argument("--owner"); p.add_argument("--endpoint"); p.add_argument("--key")
|
||||
p = sub.add_parser("scope")
|
||||
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
|
||||
p.add_argument("text", nargs="?")
|
||||
@@ -657,10 +726,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
else:
|
||||
print("ok: queue empty")
|
||||
return 0
|
||||
if args.cmd == "write-key":
|
||||
path = echo_secrets.write_key(args.token)
|
||||
print(f"ok: wrote credentials to {path} (chmod 600 best-effort); ECHO_KEY env still overrides")
|
||||
return 0
|
||||
if args.cmd == "config":
|
||||
return cmd_config(args)
|
||||
if args.cmd == "get":
|
||||
return cmd_get(args.path)
|
||||
if args.cmd == "map":
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""echo_config.py — resolve the vault owner, endpoint, and API key for ECHO.
|
||||
|
||||
The plugin ships NO owner, endpoint, or key — it is fully user-agnostic. On each
|
||||
machine, a machine-local JSON config supplies them:
|
||||
|
||||
~/.claude/echo-memory/config.json
|
||||
{
|
||||
"owner": "Full Name (how the agent refers to the vault owner, third person)",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>"
|
||||
}
|
||||
|
||||
This file is per-machine: it must be created on each fresh install, is never
|
||||
committed, and is never written into a vault note. To create it, run
|
||||
`python3 echo.py config init` (writes a placeholder template when absent) and edit
|
||||
it, or `python3 echo.py config set --owner ... --endpoint ... --key ...`.
|
||||
|
||||
Per-field resolution (first hit wins):
|
||||
owner env ECHO_OWNER -> config "owner"
|
||||
endpoint env ECHO_BASE -> config "endpoint"
|
||||
key env ECHO_KEY -> config "key"
|
||||
|
||||
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
|
||||
file path itself can be overridden with $ECHO_CONFIG. Pure stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
TEMPLATE = {
|
||||
"owner": "Full Name — how the agent should refer to the vault owner (third person)",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>",
|
||||
}
|
||||
|
||||
|
||||
def claude_dir() -> Path:
|
||||
"""The Claude config directory ($CLAUDE_CONFIG_DIR, else ~/.claude)."""
|
||||
return Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude"))
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
"""Absolute path to the machine-local config file."""
|
||||
override = os.environ.get("ECHO_CONFIG")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return claude_dir() / "echo-memory" / "config.json"
|
||||
|
||||
|
||||
def _from_file() -> dict:
|
||||
p = config_path()
|
||||
if not p.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
"""Return {owner, endpoint, key} with env overriding the file. Missing -> "".
|
||||
|
||||
Never raises — callers that REQUIRE a field use resolve_endpoint()/resolve_key(),
|
||||
which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable
|
||||
even when nothing is configured yet."""
|
||||
f = _from_file()
|
||||
endpoint = (os.environ.get("ECHO_BASE") or f.get("endpoint") or "").rstrip("/")
|
||||
return {
|
||||
"owner": os.environ.get("ECHO_OWNER") or f.get("owner") or "",
|
||||
"endpoint": endpoint,
|
||||
"key": os.environ.get("ECHO_KEY") or f.get("key") or "",
|
||||
}
|
||||
|
||||
|
||||
def _missing(field: str, env: str) -> RuntimeError:
|
||||
return RuntimeError(
|
||||
f"echo: no {field} configured. Set {env}, or create {config_path()} "
|
||||
f"(run `echo.py config init` to scaffold it, then edit). "
|
||||
f'Expected JSON keys: "owner", "endpoint", "key".'
|
||||
)
|
||||
|
||||
|
||||
def resolve_endpoint() -> str:
|
||||
ep = load()["endpoint"]
|
||||
if not ep:
|
||||
raise _missing("endpoint", "ECHO_BASE")
|
||||
return ep
|
||||
|
||||
|
||||
def resolve_key() -> str:
|
||||
k = load()["key"]
|
||||
if not k:
|
||||
raise _missing("key", "ECHO_KEY")
|
||||
return k
|
||||
|
||||
|
||||
def resolve_owner() -> str:
|
||||
"""The owner display name. Descriptive only, so an empty value is tolerated."""
|
||||
return load()["owner"]
|
||||
|
||||
|
||||
def is_configured(cfg: dict | None = None) -> bool:
|
||||
"""True only when endpoint AND key are present and NOT the untouched template
|
||||
placeholders — so a freshly `config init`-ed (but unedited) file reads as not yet
|
||||
configured, which is what triggers the first-run "ask for the key file" prompt."""
|
||||
c = cfg if cfg is not None else load()
|
||||
ep, key = c.get("endpoint", ""), c.get("key", "")
|
||||
if not ep or not key:
|
||||
return False
|
||||
if "your-obsidian-rest-endpoint" in ep or key.startswith("<"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def source(field: str) -> str:
|
||||
"""Where a field is currently coming from — for `config`/`doctor` reporting."""
|
||||
env = {"owner": "ECHO_OWNER", "endpoint": "ECHO_BASE", "key": "ECHO_KEY"}[field]
|
||||
if os.environ.get(env):
|
||||
return f"{env} env"
|
||||
if config_path().exists() and _from_file().get(field):
|
||||
return "config file"
|
||||
return "unset"
|
||||
|
||||
|
||||
def template_text() -> str:
|
||||
return json.dumps(TEMPLATE, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _secure(p: Path) -> None:
|
||||
try:
|
||||
os.chmod(p, 0o600) # advisory on filesystems without POSIX mode bits (e.g. Windows)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def write_config(owner: str, endpoint: str, key: str) -> Path:
|
||||
"""Persist a filled-in config (merging over any existing values), chmod 0600."""
|
||||
cur = _from_file()
|
||||
merged = {
|
||||
"owner": owner if owner is not None else cur.get("owner", ""),
|
||||
"endpoint": (endpoint if endpoint is not None else cur.get("endpoint", "")).rstrip("/"),
|
||||
"key": key if key is not None else cur.get("key", ""),
|
||||
}
|
||||
p = config_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(merged, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
_secure(p)
|
||||
return p
|
||||
|
||||
|
||||
def import_file(src) -> Path:
|
||||
"""Adopt a config file someone was handed: validate it has a usable endpoint+key,
|
||||
then copy it into config_path(). The 'provide the file -> plugin installs it' path."""
|
||||
p = Path(src).expanduser()
|
||||
if not p.exists():
|
||||
raise RuntimeError(f"config import: file not found: {p}")
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
raise RuntimeError(f"config import: {p} is not valid JSON ({exc})")
|
||||
if not isinstance(data, dict) or not is_configured(data):
|
||||
raise RuntimeError(
|
||||
'config import: file must be JSON with a real "endpoint" and "key" '
|
||||
'(and ideally "owner") — not the template placeholders.')
|
||||
return write_config(data.get("owner", ""), data["endpoint"], data["key"])
|
||||
|
||||
|
||||
def init_template() -> tuple[Path, bool]:
|
||||
"""Write the placeholder template to config_path() if absent.
|
||||
Returns (path, created) — created is False when a config already exists."""
|
||||
p = config_path()
|
||||
if p.exists():
|
||||
return p, False
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(template_text(), encoding="utf-8")
|
||||
_secure(p)
|
||||
return p, True
|
||||
@@ -13,7 +13,6 @@ Exit: 0 all green · 1 a check is red.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -31,13 +30,35 @@ def run() -> int:
|
||||
reds += 0 if ok else 1
|
||||
print(f" [{'OK ' if ok else 'RED'}] {label}" + (f" — {detail}" if detail else ""))
|
||||
|
||||
print(f"echo doctor — endpoint {echo.BASE}")
|
||||
import echo_config
|
||||
cfg = echo_config.load()
|
||||
print(f"echo doctor — endpoint {cfg['endpoint'] or '(not configured)'}")
|
||||
|
||||
# 1. interpreter
|
||||
line(sys.version_info >= MIN_PY, f"python >= {MIN_PY[0]}.{MIN_PY[1]}",
|
||||
f"running {sys.version_info.major}.{sys.version_info.minor}")
|
||||
|
||||
# 2. reachability + auth + marker, in one GET of the bootstrap marker
|
||||
# 2. machine-local config (owner/endpoint/key). Without a usable endpoint+key
|
||||
# nothing else can run, so report it clearly before touching the network.
|
||||
# A still-placeholder `config init` template counts as not configured.
|
||||
ep_real = bool(cfg["endpoint"]) and "your-obsidian-rest-endpoint" not in cfg["endpoint"]
|
||||
key_real = bool(cfg["key"]) and not cfg["key"].startswith("<")
|
||||
line(ep_real, "endpoint configured",
|
||||
f"[{echo_config.source('endpoint')}]" if ep_real
|
||||
else (f"placeholder — edit {echo_config.config_path()}" if cfg["endpoint"]
|
||||
else f"create {echo_config.config_path()} (run `echo.py config init`)"))
|
||||
line(key_real, "API key configured",
|
||||
f"[{echo_config.source('key')}]" if key_real
|
||||
else ("placeholder — not yet filled in" if cfg["key"] else "missing — see `echo.py config`"))
|
||||
line(bool(cfg["owner"]), "vault owner set",
|
||||
cfg["owner"] if cfg["owner"] else "optional, but recommended for third-person writes")
|
||||
if not echo_config.is_configured(cfg):
|
||||
print("\ndoctor: not configured — ask the operator for their key file and install it "
|
||||
"(`echo.py config import <file>`, or `config set --owner … --endpoint … --key …`), "
|
||||
"then re-run.")
|
||||
return 1
|
||||
|
||||
# 3. reachability + auth + marker, in one GET of the bootstrap marker
|
||||
status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
|
||||
if status == 0:
|
||||
line(False, "vault reachable", body.decode(errors="replace")[:120])
|
||||
@@ -54,13 +75,7 @@ def run() -> int:
|
||||
else:
|
||||
line(status < 400, "marker fetch", f"HTTP {status}")
|
||||
|
||||
# 3. key source (M1) + invariants pointer.
|
||||
import echo_secrets
|
||||
key_src = ("ECHO_KEY env" if os.environ.get("ECHO_KEY")
|
||||
else "credentials file" if (echo_secrets._state_dir() / "credentials").exists()
|
||||
else "baked-in fallback (deprecated — see API-KEY-SETUP.md)")
|
||||
line(os.environ.get("ECHO_KEY") is not None or (echo_secrets._state_dir() / "credentials").exists(),
|
||||
"API key source", key_src)
|
||||
# 4. invariants pointer.
|
||||
print(" [i] invariants — run `/echo-health` (vault_lint.py) for the full check")
|
||||
|
||||
print(f"\ndoctor: {'all green' if reds == 0 else f'{reds} issue(s) — see RED above'}")
|
||||
|
||||
@@ -21,7 +21,7 @@ import re
|
||||
MIN_NAME_LEN = 4
|
||||
_COMMON = frozenset(
|
||||
"api app data note task user team work code test main repo file plan goal item "
|
||||
"the and for with mpm".split()
|
||||
"the and for with from".split()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ PROPOSAL_SCHEMA (one per durable item the agent wants to remember):
|
||||
{
|
||||
"title": "Bob Smith", # required
|
||||
"kind": "person", # required unless "inbox": true
|
||||
"body": "Principal at MPM; ...", # markdown body (optional)
|
||||
"body": "Principal at Acme; ...", # markdown body (optional)
|
||||
"aliases": ["bob", "rs"], # optional
|
||||
"sources": ["_agent/sessions/..."], # optional backward links
|
||||
"date": "YYYY-MM-DD", # optional (meeting/decision kinds)
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""echo_secrets.py — M1: resolve the API bearer token without shipping it in source.
|
||||
[v1.0 SCAFFOLD — resolve_key() implemented; wiring into echo.py is the integration step]
|
||||
|
||||
Problem: the token is hardcoded at echo.py:70 and repeated through api-reference.md /
|
||||
README — committed to git and shipped inside every .plugin zip, rotatable only by a
|
||||
rebuild. The plugin's own safety rule is "never store secrets". This resolves the key
|
||||
from progressively less-trusted sources, with a loud warning if the legacy baked
|
||||
default is ever reached.
|
||||
|
||||
RESOLUTION ORDER (first hit wins):
|
||||
1. env ECHO_KEY — preferred; CI / per-session override.
|
||||
2. ECHO_STATE_DIR/credentials — local file, one `key=...` line, chmod 0600.
|
||||
3. legacy baked default (deprecated) — warns to stderr; remove before 1.0 ship.
|
||||
|
||||
Pure stdlib only (no `keyring`); the local file is the cross-platform stand-in for an
|
||||
OS keychain and keeps the secret OUT of the tracked tree.
|
||||
|
||||
INTEGRATION (do at merge time):
|
||||
echo.py: KEY = echo_secrets.resolve_key()
|
||||
docs: replace the literal token with `<ECHO_KEY>` placeholders
|
||||
rotate: document `echo write-key` (writes the credentials file) — see TODO below
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Kept ONLY so a fresh install still works during the migration window; delete for 1.0.
|
||||
_LEGACY_DEFAULT = "" # intentionally blank in the scaffold — do not re-introduce the literal
|
||||
|
||||
|
||||
def _state_dir() -> Path:
|
||||
return Path(os.environ.get("ECHO_STATE_DIR") or (Path.home() / ".echo-memory"))
|
||||
|
||||
|
||||
def _from_file() -> str | None:
|
||||
p = _state_dir() / "credentials"
|
||||
if not p.exists():
|
||||
return None
|
||||
for line in p.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("key=") or line.startswith("ECHO_KEY="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_key(legacy: str | None = None) -> str:
|
||||
"""Return the bearer token from the most trusted available source:
|
||||
ECHO_KEY env -> credentials file -> `legacy` baked-in fallback (with a loud, once-per-
|
||||
process warning that can be silenced via ECHO_KEY_LEGACY_OK=1). Raises if none exist."""
|
||||
env = os.environ.get("ECHO_KEY")
|
||||
if env:
|
||||
return env
|
||||
from_file = _from_file()
|
||||
if from_file:
|
||||
return from_file
|
||||
legacy = legacy or _LEGACY_DEFAULT
|
||||
if legacy:
|
||||
if os.environ.get("ECHO_KEY_LEGACY_OK") != "1":
|
||||
print("echo_secrets: WARNING — using the deprecated baked-in API key. Run "
|
||||
"`echo.py write-key <token>` (stores ~/.echo-memory/credentials) or set "
|
||||
"ECHO_KEY; set ECHO_KEY_LEGACY_OK=1 to silence.", file=sys.stderr)
|
||||
return legacy
|
||||
raise RuntimeError(
|
||||
"echo_secrets: no API key found. Set ECHO_KEY or create "
|
||||
f"{_state_dir() / 'credentials'} with a 'key=<token>' line."
|
||||
)
|
||||
|
||||
|
||||
def write_key(token: str) -> Path:
|
||||
"""Persist the token to the local credentials file (one `key=...` line, chmod 0600).
|
||||
Best-effort perms — Windows has no POSIX mode bits, so the chmod is advisory there."""
|
||||
d = _state_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
path = d / "credentials"
|
||||
path.write_text(f"key={token.strip()}\n", encoding="utf-8")
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass # platform without POSIX perms (e.g. some Windows filesystems)
|
||||
return path
|
||||
@@ -158,9 +158,9 @@ def test_aliases_in_frontmatter_flow_and_block() -> None:
|
||||
|
||||
def test_links_add_related_is_idempotent_and_bidirectional_token() -> None:
|
||||
text = "---\ntype: person\n---\n\n# Bob\n\n## Related\n"
|
||||
new, changed = echo_links.add_related(text, "resources/companies/mpm.md")
|
||||
assert changed and "[[resources/companies/mpm]]" in new
|
||||
again, changed2 = echo_links.add_related(new, "resources/companies/mpm.md")
|
||||
new, changed = echo_links.add_related(text, "resources/companies/acme.md")
|
||||
assert changed and "[[resources/companies/acme]]" in new
|
||||
again, changed2 = echo_links.add_related(new, "resources/companies/acme.md")
|
||||
assert not changed2 and again == new
|
||||
|
||||
|
||||
|
||||
@@ -76,19 +76,45 @@ def main() -> int:
|
||||
check("H5 classify/preview/apply wired",
|
||||
all(callable(getattr(rf, n, None)) for n in ("classify", "preview", "apply")))
|
||||
|
||||
# M1 — secret resolution order is implemented; env wins, then the credentials file.
|
||||
import echo_secrets as s
|
||||
os.environ["ECHO_KEY"] = "env-key-123"
|
||||
check("M1 env ECHO_KEY wins", s.resolve_key("legacy-fallback") == "env-key-123")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
os.environ["ECHO_STATE_DIR"] = d
|
||||
os.environ.pop("ECHO_KEY", None)
|
||||
s.write_key("file-key-xyz")
|
||||
check("M1 write_key -> credentials -> resolve_key reads it", s.resolve_key() == "file-key-xyz")
|
||||
os.environ["ECHO_KEY_LEGACY_OK"] = "1" # silence the warning for the legacy-path check
|
||||
os.environ["ECHO_STATE_DIR"] = os.path.join(d, "empty") # an empty dir: no creds file
|
||||
check("M1 legacy fallback when env+file absent", s.resolve_key("legacy-fallback") == "legacy-fallback")
|
||||
os.environ["ECHO_KEY"] = "env-key-123" # restore for any later checks
|
||||
# Config — owner/endpoint/key resolve from env -> machine-local config.json (no baked
|
||||
# defaults). Env wins per field; the file fills the rest; missing required -> raises.
|
||||
import echo_config as cfg
|
||||
saved_env = {k: os.environ.get(k) for k in ("ECHO_KEY", "ECHO_BASE", "ECHO_OWNER", "ECHO_CONFIG")}
|
||||
try:
|
||||
for k in saved_env:
|
||||
os.environ.pop(k, None)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
cfgfile = os.path.join(d, "config.json")
|
||||
os.environ["ECHO_CONFIG"] = cfgfile
|
||||
# init writes the placeholder template only when absent.
|
||||
p, created = cfg.init_template()
|
||||
check("config init scaffolds the template", created and os.path.exists(cfgfile))
|
||||
p2, created2 = cfg.init_template()
|
||||
check("config init is idempotent (no clobber)", created2 is False)
|
||||
# write_config persists a filled-in config; load() reads it back.
|
||||
cfg.write_config("Owner Name", "https://obsidian.example.com/", "file-key-xyz")
|
||||
loaded = cfg.load()
|
||||
check("config file round-trips owner/endpoint/key",
|
||||
loaded == {"owner": "Owner Name",
|
||||
"endpoint": "https://obsidian.example.com", # trailing / stripped
|
||||
"key": "file-key-xyz"})
|
||||
# env overrides the file, per field.
|
||||
os.environ["ECHO_KEY"] = "env-key-123"
|
||||
check("env ECHO_KEY overrides config", cfg.resolve_key() == "env-key-123")
|
||||
os.environ.pop("ECHO_KEY", None)
|
||||
# missing required field raises a helpful error.
|
||||
cfg.write_config("Owner Name", "", "")
|
||||
try:
|
||||
cfg.resolve_key()
|
||||
check("missing key raises", False)
|
||||
except RuntimeError:
|
||||
check("missing key raises", True)
|
||||
finally:
|
||||
for k, v in saved_env.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
# M2 — slug collision + link-confidence helpers are implemented.
|
||||
import echo_quality as qual
|
||||
|
||||
Reference in New Issue
Block a user