26 Commits

Author SHA1 Message Date
Jason Stedwell e7792003a7 2.1.1 — MCP Phase 0: every high-level op returns an envelope (return-not-print)
Internal refactor per docs/MCP-SERVER-SPEC.md §4; CLI output and exit codes
unchanged (wrappers reproduce them from the envelopes):

- echo_ops: capture_op/resolve_op/link_op — duplicate gate and offline queueing
  are data (action: duplicate-gate + candidates; queued: true); the --json
  stdout-swallow hack is deleted (helper chatter -> stderr inside the cores);
  capture_op takes body_text= directly (the MCP path, no temp file).
- echo_recall.recall_op — one structured result backs prose and --json.
- echo_triage.list_op/route_op, echo_reflect.apply_op,
  echo_session.session_end_op — call capture_op internally, count gate outcomes
  from envelopes.
- echo.scope_show_op/scope_set_op/load_op (+ shared _load_gather), and
  echo_doctor.run_op (checks as data).

New eval/test_ops_api.py asserts the contract for every core: envelope shape
AND stdout purity (a stray print would corrupt an MCP response stream). All six
suites green. The 2.2 server build now starts directly at the app layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:51:33 -05:00
Jason Stedwell 446cd9a0ff 2.1.0 — quick-wins train: brief load, offline-durable capture, session-end verb
Three independently useful changes (IMPROVEMENT-PLANS #1/#3/#7), no schema changes:

- load --brief: token-budgeted cold-start digest (facts full, last-10 observations,
  scope+freshness, last session's key sections, agent-log lines, inbox count;
  ECHO_LOAD_BUDGET ~8000 chars, lowest-priority-first trimming). SessionStart hook
  injects the brief form; /echo-load keeps the full dump. All load reads (+ the
  sessions listing, which also feeds the heartbeat fallback) fetched in parallel.
- Offline capture durability: a fully-offline capture queues the WHOLE op as one
  semantic record; flush replays it through capture so routing/gate/aliasing re-run
  against the current index; a gate stop on replay is kept + flagged
  (needs_attention, surfaced by flush and load), never landed blind; update-path
  and ensure_daily_log writes ride safe_request; same-args captures dedupe.
- session-end: one call, one lock — session log -> agent-log line -> reflect
  proposals (gate-aware) -> optional scope set -> heartbeat LAST as the commit
  marker; dry-run default; ECHO_NOW pins HHMM; validation runs before any write.
  Stop hook nudge names the command and recognizes it as reflected.
- Fix: main() now catches the __main__ twin-module EchoError (via RuntimeError +
  .code) so helper-module errors exit with their intended codes, not tracebacks.

+19 e2e checks; all suites green. Plans renumbered: MCP container is 2.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:34:42 -05:00
Jason Stedwell 1deef7299e ROADMAP-2.0: skills-only install re-verified on desktop + CoWork — roadmap complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:20:47 -05:00
Jason Stedwell 5aace9e430 2.0.0 — packaging & structure major: skills-only plugin, release-based artifacts
Memory behavior unchanged; the major signals a distribution/install break.

- Legacy commands/ directory DELETED (skills-only; the 1.6.0 skills were verified
  on desktop + CoWork and already took precedence). Pre-skills clients install
  1.6.0 from the release history.
- Artifact policy: the repo tracks only the echo-memory.plugin pointer; the 15
  historical versioned zips leave the tree (git history retains them); versioned
  builds ship as Gitea release assets, one release per v<version> tag, starting
  v2.0.0. .gitignore: *.plugin except the pointer.
- README: post-2.0 repository layout; "packaging for other agent runtimes" note
  (ports are build targets from the canonical tree, never a second source tree).
- ROADMAP-2.0 items ticked; manifest -> 2.0.0.

All suites green (25/25 unit + 4 e2e suites); artifact rebuilt skills-only
(79 entries, no commands/).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:12:26 -05:00
Jason Stedwell ab1e514f78 TODO-1.6: install verification passed on desktop + CoWork — commands/ deletion unblocked for 2.0
All four checks pass on the baked 1.6.0 artifact (skills register + precedence,
allowed-tools, operator-only sweep/triage, CoWork $ECHO fallback). Caveat noted:
allowed-tools globs match literal script names, not the "$ECHO" variable form —
re-confirm on the Claude Code CLI if that surface becomes routine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:08:07 -05:00
Jason Stedwell fb407d606c Add the 1.6.0 lint regression test (missed in the release commit staging)
Seeds archive/notes/README.md and asserts vault_lint flags retired-path —
fails against the pre-1.6.0 linter, passes with the retired-first check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:59:39 -05:00
Jason Stedwell 55afdce70c 1.6.0 — skills-format migration, lint retired-path fix, plugin.json completeness
Skills migration: each of the eight slash commands gains a skills/<name>/SKILL.md
twin (same invocation name, byte-identical body). Per-skill allowed-tools
pre-authorize the resolved python3/python/py -3 invocations + the CoWork ls
fallback probe; disable-model-invocation: true makes /echo-sweep and /echo-triage
operator-only. Docs-confirmed: a same-name skill takes precedence over the legacy
command, so commands/ coexists until its scheduled 2.0 deletion. The $ECHO
path-resolution dedupe is resolved as not-supported (skill dirs are
self-contained; no cross-skill snippet sharing).

Lint fix: vault_lint.py checks retired patterns BEFORE routes, so a seed README
inside a retired tree (archive/...) flags retired-path instead of being absolved
by the permissive leaf-readme route. New end-to-end regression test seeds
archive/notes/README.md (fails pre-fix, passes now).

plugin.json: version 1.6.0, homepage + repository -> git.alwisp.com/jason/echo.

All suites green: 25/25 client unit tests (incl. routing-sync guard), feature,
reflect, offline-queue, PATCH-semantics. Artifact rebuilt (87 entries, both
formats); pointer refreshed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:58:57 -05:00
Jason Stedwell e7d86e14da Add post-1.5.1 planning docs: 9 improvement plans + containerized MCP server spec
docs/IMPROVEMENT-PLANS.md — nine improvements from the 2026-07-28 full-code
review (brief load, offline capture durability, session-end verb, local-first
recall index, incremental sweep, lifecycle decay, supersession, stemming +
alias expansion, resolve-miss learning), sequenced with TODO-1.6 and
ROADMAP-2.0 into one release train (1.6.0 -> 1.6.x -> 2.0 -> 2.1 -> 2.2 -> 2.3).

docs/MCP-SERVER-SPEC.md — build spec for the 2.1 flagship: echo-mcp as a
Docker container on ALPHA (FastMCP, streamable HTTP, bearer auth, published
as echomcp.alwisp.com), talking to Obsidian directly at http://10.2.0.35:27123.
13 v1 tools with result budgets + tool profiles; v1.1 dividends (nightly vault
backup, shadow write history/undo, background sweeps, ops alerting, rollup
data). Prereqs: session-end verb + return-not-print refactor (Phase 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:49:53 -05:00
Jason Stedwell d27db4ae34 Add docs/USAGE.md — operator usage guide for returning users (0.7 -> 1.5.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:56:28 -05:00
Jason Stedwell 4eea53c6c8 TODO-1.6: note the REST delete-directories limitation on the retired-tree item
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:44:52 -05:00
Jason Stedwell 9fd0246634 Document REST DELETE limitation: files only, empty folders orphan on disk
Learned removing the retired archive/ and _agent/outputs/ trees: DELETE
cannot remove directories; the last-file deletion leaves empty folders
that the REST listing 404s (falsely reading as gone) while Obsidian
still shows them — manual cleanup required. Noted in api-reference.md
(Deleting Files) and linked to the TODO-1.6 retired-tree lint item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:44:03 -05:00
Jason Stedwell 746a30a08b TODO-1.6: log lint blind spot — retired dirs masked by leaf-README route
Vault sanity check found archive/ and _agent/outputs/ (retired/unrouted
pre-0.6 trees) surviving in the live vault with only seed READMEs; the
permissive leaf-readme route matches before the retired-path check, so
/echo-health never flags them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:38:49 -05:00
Jason Stedwell 46e4b18a00 Move field report to docs/history; refresh README repository layout
- docs/history/echo-improvements-prompt.md (ROADMAP-2.0 §5 box ticked)
- README repository layout brought to 1.5.1 reality: root docs
  (CHANGELOG/TODO-1.6/ROADMAP-2.0/docs/history), build.py + dist/
  (gitignored, secret-bearing), current eval harness contents, hooks/,
  and the full scripts/ roster (recall/reflect/triage/queue/
  concurrency/quality/output/doctor/hook modules) with the legacy
  commands/ dir annotated as the TODO-1.6 migration target. Also
  restores the eval-descriptor fix lost in the prior commit (edit was
  applied in memory but never written back).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:32:07 -05:00
Jason Stedwell 993abdc846 Retire ROADMAP-1.0; rewrite run_eval.py as current-version metrics harness
Knocks two items off ROADMAP-2.0 (§4 eval refresh, §5 roadmap retirement):

- run_eval.py: the stale 0.6-vs-0.7 A/B is replaced (old version in git
  history) with a four-part current-version eval against the mock —
  retrieval (hybrid+priors vs keyword/entities-only baseline: R@5 1.00
  vs 0.75, MRR 1.00 vs 0.75, session/journal queries 2/2 vs 0/2,
  freshness top-1 correct vs wrong), dedup (0 dupes gate-on vs 3
  gate-off, 0 false blocks), write safety (0 silent failures across 4
  fault scenarios), durability (3/3 offline writes queued+landed, 0
  lost, 0 re-flush dupes). Results in eval/results/latest.json.
- README: metrics table published; repo-layout eval descriptor updated.
- eval/README: new harness documented; A/B framing marked historical.
- ROADMAP-1.0.md removed (fully shipped; story lives in the README
  version table + git history); dangling docstring pointer fixed in
  echo_concurrency.py; ROADMAP-2.0 checkboxes ticked.

All suites green; artifact rebuilt (1.5.1, docstring-only source change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:30:01 -05:00
Jason Stedwell 919e411136 Add TODO-1.6 + ROADMAP-2.0; retire MAINTENANCE.md and the codex tree
- TODO-1.6.md: skills-format migration of the eight slash commands
  (allowed-tools, disable-model-invocation, de-duped $ECHO block),
  plugin.json homepage, vault link repoints, and an open section for
  findings from 1.5.x live testing.
- ROADMAP-2.0.md: packaging & structure major — repo artifact policy
  (Gitea releases, prune versioned zips), delete legacy commands/ dir,
  codex-as-build-target if ever needed, eval refresh to current
  metrics. Includes a disposition table absorbing every open
  MAINTENANCE.md item, so that checklist is removed.
- Delete "codex plugin/": drifted derived tree; operator decision to
  regenerate from the canonical source after 2.0 if Codex use returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:17:31 -05:00
Jason Stedwell 3b6c3053cb ver 1.5.1 — duplicate-gate precision (common tokens, cross-kind)
Live 1.5.0 use caught the gate blocking a decision note over the single
vault-common token "echo" (min-normalized overlap gives any one-token
entity a 1.0 score). New echo_index.gate_candidates() + token_df():

- gate blocks same-kind candidates only; cross-kind name collisions
  (a decision titled after its project) warn instead of blocking
- a lone shared token blocks only when unique to that entity (df == 1);
  multi-token overlaps still block

fuzzy_candidates (advisory warnings) and exit-76/--merge-into/--force
semantics unchanged. +3 offline unit tests, gate e2e cases restructured
+2 new; verified live both directions. All suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:03:07 -05:00
Jason Stedwell be3fd36ebf ver 1.5.0 — six-improvement pass: retention, routing, self-firing memory
1. capture-update keeps the whole body (was truncating to first line)
2. frontmatter completeness: kind-default status + kind-seeded tags at
   capture, fm create-or-replace, incomplete-frontmatter lint, sweep
   backfill (one data source: KIND_STATUS/KIND_REQUIRED_FM)
3. pre-write duplicate gate (exit 76, --merge-into/--force)
4. recall corpus + ranking: sessions/journal indexed (down-weighted),
   BM25 x freshness x status fusion, recall --json, index schema 2
5. session hooks: SessionStart auto-load, Stop reflection nudge
6. one-tap triage verb with processing-log audit; --json on read verbs

+22 mock end-to-end tests; all suites green. Docs current (README,
CHANGELOG, SKILL.md, commands, references, MAINTENANCE, eval README).
Drops tracked .DS_Store (gitignored); retires README-gretchen.md
(moved to gitignored dist/); adds the field report that drove item 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:57:52 -05:00
Jason Stedwell e76add6192 ver 1.4.2 bump - sandbox mount issue 2026-06-26 20:05:47 -05:00
Jason Stedwell 9ee1530f97 v1.4.1 bump 2026-06-26 15:39:17 -05:00
Jason Stedwell 0cd8a54649 first external bake 2026-06-26 10:13:29 -05:00
Jason Stedwell bfcfdec4c9 ver 1.4.0 - baked in values and artifacts 2026-06-25 19:11:58 -05:00
jason c37b37c66a Update .gitignore 2026-06-25 19:06:57 -05:00
jason 192d14bd82 Delete API-KEY-SETUP.md 2026-06-25 19:05:57 -05:00
Jason Stedwell a1f2f02d3a ver 1.4.0 - baked in values 2026-06-25 17:54:03 -05:00
Jason Stedwell b4605a52f2 ver 1.3 and 1.3.1 2026-06-23 22:17:39 -05:00
Jason Stedwell 1881d2b105 documentation 2026-06-23 21:19:00 -05:00
132 changed files with 5100 additions and 4019 deletions
Vendored
BIN
View File
Binary file not shown.
+12
View File
@@ -15,3 +15,15 @@ venv/
# Eval output # Eval output
eval/results/*.json eval/results/*.json
!eval/results/.gitkeep !eval/results/.gitkeep
# ECHO config: the filled-in key file is yours to distribute, never commit it
/echo-memory.config.json
/dist/
# Build artifacts (2.0 policy): the repo tracks ONLY the echo-memory.plugin pointer.
# Versioned builds (echo-memory-<ver>.plugin) are published as Gitea release assets
# on git.alwisp.com/jason/echo, one release per tag — not committed. Per-user BAKED
# artifacts (echo-memory-<ver>-<label>.plugin) carry a live vault bearer token and
# must NEVER be committed anywhere (they belong in dist/, also ignored above).
*.plugin
!echo-memory.plugin
-119
View File
@@ -1,119 +0,0 @@
# ECHO — API key setup (Windows & macOS)
The ECHO plugin authenticates to the vault with a bearer token. `echo.py` resolves it in
this order — **first hit wins**:
1. **`ECHO_KEY` environment variable** ← recommended for workstations
2. **`~/.echo-memory/credentials`** file (one `key=…` line, `chmod 600`)
3. a deprecated baked-in fallback (prints a warning on every call until you do #1 or #2)
You only need **one** of these. Pick the env var (below) or the credentials file
(`python3 echo.py write-key <token>`, cross-platform, no shell config needed).
> Replace `<YOUR_ECHO_API_KEY>` everywhere below with your actual token. **Never** paste
> the token into a vault note, a committed file, or a screen-share.
---
## Option A — credentials file (simplest, cross-platform)
```bash
# from the plugin's scripts dir; works identically on Windows/macOS/Linux
python3 "<plugin>/skills/echo-memory/scripts/echo.py" write-key <YOUR_ECHO_API_KEY>
```
Writes `~/.echo-memory/credentials` (mode 600 on Unix). Nothing else to configure.
To rotate later, run it again with the new token.
---
## Option B — environment variable
### Windows
**PowerShell — persistent (recommended).** Sets it for your user account; open a **new**
terminal afterward (the current session won't see it):
```powershell
[Environment]::SetEnvironmentVariable("ECHO_KEY", "<YOUR_ECHO_API_KEY>", "User")
```
**Command Prompt — persistent** (alternative; note `setx` truncates values over 1024 chars,
so the 64-char ECHO key is fine):
```cmd
setx ECHO_KEY "<YOUR_ECHO_API_KEY>"
```
**Current session only** (not persistent):
```powershell
$env:ECHO_KEY = "<YOUR_ECHO_API_KEY>" # PowerShell
```
```cmd
set ECHO_KEY=<YOUR_ECHO_API_KEY> REM Command Prompt
```
**GUI alternative:** Start → "Edit the system environment variables" → *Environment
Variables…* → under *User variables* click *New…* → Name `ECHO_KEY`, Value the token.
**Verify** (in a NEW terminal):
```powershell
echo $env:ECHO_KEY # PowerShell
```
```cmd
echo %ECHO_KEY% :: Command Prompt
```
### macOS
Modern macOS uses **zsh**. Add the export to your shell profile, then reload it:
```zsh
echo 'export ECHO_KEY="<YOUR_ECHO_API_KEY>"' >> ~/.zshrc
source ~/.zshrc
```
If you use **bash** instead:
```bash
echo 'export ECHO_KEY="<YOUR_ECHO_API_KEY>"' >> ~/.bash_profile
source ~/.bash_profile
```
**For GUI apps** (a desktop app that doesn't read your shell profile) you can also set it
for the login session:
```bash
launchctl setenv ECHO_KEY "<YOUR_ECHO_API_KEY>"
```
(Not persistent across reboot on its own; the shell-profile export above covers terminal
sessions, which is what the plugin uses.)
**Verify** (in a NEW terminal):
```bash
echo "$ECHO_KEY"
```
---
## Confirm it works
```bash
python3 "<plugin>/skills/echo-memory/scripts/echo.py" doctor
```
`doctor` reports the Python version, vault reachability, auth, bootstrap/schema, and the
**API key source** — it should say `ECHO_KEY env` or `credentials file`, not the deprecated
baked-in fallback.
---
## Security notes
- The token is a vault credential — treat it like a password. Don't commit it, don't store
it in a vault note, rotate it if it leaks.
- `ECHO_KEY` (or `ECHO_BASE`) set in the environment **always overrides** the credentials
file and the baked-in default, so it's safe for CI / one-off overrides:
`ECHO_KEY=… python3 echo.py …`.
- Once you've set the key via Option A or B on every machine/agent that runs the plugin
(terminal, CoWork, any scheduled run), the deprecated baked-in fallback can be removed
from `scripts/echo.py` (`DEFAULT_KEY`) so no token lives in the source tree at all.
+325
View File
@@ -0,0 +1,325 @@
# Changelog
## 2.1.1
### Changed — Phase 0 of the MCP build: every high-level op returns an envelope
Internal refactor (MCP-SERVER-SPEC §4), no behavior change intended: each
high-level operation now has a core `*_op` function that **returns an envelope
dict and never prints to stdout** — the transport-agnostic seam the upcoming MCP
server (2.2) wraps. The CLI verbs are thin wrappers that print exactly what they
printed before and map envelope outcomes to the same exit codes.
- `echo_ops`: `capture_op` / `resolve_op` / `link_op` — the duplicate gate and
offline queueing are now **data** (`action: duplicate-gate` with candidates;
`queued: true`), mapped to exit 76 / "queued" text only in the wrapper. The
`--json` stdout-swallowing hack is gone: helper chatter from the low-level
verbs routes to **stderr** wholesale inside the cores. `capture_op` accepts
`body_text=` directly (no temp file needed — the MCP path).
- `echo_recall.recall_op` — one structured result backs both the prose and
`--json` renderings (the duplicated prose path was deleted).
- `echo_triage.list_op` / `route_op`, `echo_reflect.apply_op`,
`echo_session.session_end_op` — same pattern; reflect/triage/session now call
`capture_op` internally and count gate outcomes from envelopes.
- `echo.scope_show_op` / `scope_set_op` / `load_op` (sections + brief digest as
data), `echo_doctor.run_op` (checks as a list).
New suite `eval/test_ops_api.py` asserts the contract for every core: envelope
shape AND stdout purity (a stray print would corrupt an MCP response stream).
All existing suites pass unchanged.
## 2.1.0
The "quick-wins train" from the 2026-07-28 review (`docs/IMPROVEMENT-PLANS.md` #1,
#3, #7). Three independently useful changes; no schema or layout changes.
### Added — `load --brief`: a token-budgeted cold-start digest
The SessionStart hook injected the full text of six files every session (measured
15.9KB and growing without bound — Observations and Scope History never shrink).
`load --brief` renders a digest instead: Fact / Pattern rules in full, the last ~10
Observations (with a `+N older` note), scope + `scope_updated` + sessions-since,
the heartbeat-pointed session log's key sections (Goal / Decisions Made / Open
Threads / Suggested Next Step), today's Agent Log lines, and the inbox as a
**count + oldest age** — everything the load-time reconcile needs. Over
`ECHO_LOAD_BUDGET` (default ~8000 chars), sections trim lowest-priority-first
(observations → agent log → session summary) with explicit `(truncated)` markers.
The hook now injects the brief form; `/echo-load` and the manual procedure keep the
full dump. All `load` reads (plus the sessions listing, which also feeds the
heartbeat-absent fallback) are fetched **in parallel** over the pooled connections.
### Fixed — capture is now offline-durable (the write path's inversion bug)
Low-level verbs queued on an outage, but the *default* write (`capture`) died: the
update path's POST/PATCH and `ensure_daily_log` bypassed the queue entirely, and an
unreachable index aborted the whole op. Now: (1) a fully-offline `capture` queues
the **whole operation as one semantic record** (title/kind/body/tags/…, plus the
capture-time date); `flush` replays it **through `capture`** so create-vs-update,
the duplicate gate, and aliasing re-run against the index as it is at replay time —
byte-level replay would freeze a decision made blind. (2) A duplicate-gate stop on
replay keeps the record, flags it `needs_attention` (surfaced by `flush` and
`load`), and does NOT block later records. (3) Mid-capture outages: the update
path and `ensure_daily_log` writes ride `safe_request` (accepted edge: a queued
agent-log PATCH 400-drops loudly if the daily note never existed — the trace line,
not the memory). Same-args offline captures dedupe by idem-key.
### Added — `session-end`: the one-call session close
`echo.py session-end <bundle.json> [--apply]` performs the whole end-of-session
sequence under ONE lock acquisition, in order: session log PUT → Agent Log line →
reflect proposals (via `capture`, gate-aware) → optional `scope set` → **heartbeat
LAST as the commit marker** (a part-way failure leaves the previous pointer intact,
never dangling). Dry-run by default previews the full plan including the reflect
classification. Filename HHMM from `$ECHO_NOW` (else local clock), validated
against the canonical session-log regex **before any write**. The Stop hook's nudge
now names the command, and recognizes a `session-end` invocation as
"already reflected".
### Fixed — helper-module errors exited 2 instead of their intended codes
When `echo.py` runs as `__main__`, helper modules' `import echo` creates a twin
module whose `EchoError` is a different class object, so `main()`'s handler missed
it and the raw traceback leaked. The handler now catches `RuntimeError` (both
classes subclass it) and honors `.code`.
Tests: +19 end-to-end checks (brief-load digest/budget, offline-capture queue /
replay-through-capture / gate-on-replay / idem-key dedup, session-end dry-run /
apply / ordering / validation-abort). All suites green.
## 2.0.0
**Packaging & structure major — nothing about memory behavior changes.** The major
version signals "reinstall, re-clone expectations": how the plugin is distributed
and installed is what breaks.
### Removed — the legacy `commands/` directory (BREAKING for pre-skills clients)
The plugin is **skills-only**. The eight `/echo-*` entry points live exclusively at
`skills/<name>/SKILL.md` (introduced 1.6.0, verified on desktop + CoWork
2026-07-28, where they already took precedence). A Claude Code old enough to lack
skills support loses the slash commands — install 1.6.0 from the release history
instead.
### Changed — artifact policy (BREAKING for anyone pulling zips from the repo)
- The repo tracks **only** `echo-memory.plugin` (the current installable). The 15
historical `echo-memory-<version>.plugin` zips are deleted from the tree (git
history retains them).
- Versioned builds are published as **Gitea releases** on
`git.alwisp.com/jason/echo` — one release per `v<version>` tag, artifact
attached, starting with `v2.0.0`.
- `.gitignore` now ignores `*.plugin` except the pointer.
### Added — "packaging for other agent runtimes" README note
The retired Codex tree stays retired; the README now documents what a port needs
(manifest shape, skill entry point, script invocation + hooks) and the rule that a
port must be a **build target** from the canonical source tree, never a second
hand-maintained copy.
## 1.6.0
### Changed — the eight slash commands migrated to the skills format
Each `commands/<name>.md` now has a `skills/<name>/SKILL.md` twin with the same
invocation name and a byte-identical body — confirmed against the current docs
that a same-name skill **takes precedence** over the legacy command, so both
formats coexist safely in this release (deleting `commands/` is the 2.0
packaging break). Done for the features, not the deprecation notice:
- **`allowed-tools` per skill** — the resolved `python3/python/py -3` script
invocations (plus the CoWork `ls /sessions/*` fallback probe) are
pre-authorized, so `/echo-load`, `/echo-health`, `/echo-recall` etc. stop
prompting.
- **`disable-model-invocation: true`** on the write-heavy entry points
(`/echo-sweep`, `/echo-triage`) — only the operator triggers them; the
read-side skills stay model-invocable.
- `argument-hint` / `$ARGUMENTS` carry over unchanged (same semantics in
SKILL.md bodies).
- The `$ECHO` path-resolution block stays per-skill: the skills format has no
cross-skill snippet sharing (each skill directory is self-contained per the
official docs), and the block is already the 2-line minimum.
### Fixed — lint blind spot: retired trees masked by the leaf-README route
`vault_lint.py` checked retired patterns only for paths matching **no** route,
so the permissive `leaf-readme` route (`^(.+/)?README\.md$`) absolved any seed
README inside a retired tree — `archive/` and `_agent/outputs/` survived the
1.5.0 live cleanup unflagged. Retired patterns are now checked **first**: a
path in a retired tree flags `retired-path` regardless of what else matches.
New end-to-end test seeds `archive/notes/README.md` and asserts the flag
(fails against the pre-fix linter, passes now).
### Added — plugin.json completeness
`homepage` + `repository``https://git.alwisp.com/jason/echo`; manifest →
1.6.0.
## 1.5.1
### Fixed — duplicate gate over-firing on vault-common tokens and cross-kind names
Live 1.5.0 use surfaced a false positive: creating the decision "echo-memory 1.5.0
improvement set" was blocked by the archived project `echo-v-05` (score 1.0), because
`score = overlap / min(|tokens|)` lets any entity whose single distinctive token
appears in the new title score 1.0 — and "echo" appears across many entities in an
echo-centric vault.
New `echo_index.gate_candidates()` (backed by `token_df()`) applies two precision
rules before blocking; `capture` gates through it. `fuzzy_candidates()` — the advisory
warning list — is unchanged:
- **Same-kind only.** A cross-kind name collision (a decision titled after its
project, a meeting named for a company) is intentional naming, not a duplicate.
Cross-kind lookalikes still warn, never block.
- **No single-common-token blocks.** A lone shared token gates only when it is unique
to that entity (vault df == 1). Multi-token overlaps still gate even when the
individual tokens are common.
Exit-76 / `--merge-into` / `--force` semantics unchanged. Tests: 3 new offline unit
tests (`gate_candidates`), end-to-end gate cases restructured + 2 new (cross-kind
no-gate, common-token no-gate); verified against the live vault (the original false
positive now creates cleanly; a same-kind multi-token lookalike still gates).
## 1.5.0
Six improvements from the July 2026 review + the frontmatter-drift field report.
### Fixed — capture-on-update kept only the first body line (silent data loss)
`capture` on an existing entity appended `- <date>: <first line>` and **discarded the
rest of the body**. It now appends the whole body as a dated block — first line on the
bullet, continuation lines indented under it. Idempotency (bullet-per-day) unchanged.
### Added — frontmatter completeness (prevent → tool → detect → remediate)
A field audit found 18/71 entity notes missing `status`/`tags`; the drift was created
at write time and invisible to the tooling. Four coordinated changes, driven by one
data source (`KIND_STATUS` / `KIND_REQUIRED_FM` in `echo_index.py`):
- **`capture`** stamps a kind-appropriate default `status` (all kinds, not just
projects) and seeds `tags` with the note's kind; new `--tags a,b` enriches it.
Reflect/triage proposals accept a `"tags"` field.
- **`echo.py fm`** is now **create-or-replace**: on `400 invalid-target` (missing key)
it surgically inserts the one line into the frontmatter block and re-PUTs, verified —
it previously failed on exactly the notes that needed repair.
- **`vault_lint.py`** gains a kind-scoped `incomplete-frontmatter` check
(missing/empty `status`/`tags` on entity notes; block-style lists handled).
- **`sweep.py --apply`** backfills flagged notes (kind-default status + baseline tag).
### Added — pre-write duplicate gate in capture
A strong fuzzy candidate (score ≥ 0.6, env `ECHO_DUP_GATE`) now **stops** `capture`
before it creates a near-duplicate (exit `76` + candidate list) instead of warning
after the file exists. `--merge-into <slug>` routes the capture as an update to the
existing entity (mention learned as an alias); `--force` creates anyway. Weak
resemblances still create + warn as before.
### Changed — recall corpus + ranking (recall-index schema 2)
- The BM25 corpus now includes **session logs and journal notes** (down-weighted 0.5 /
0.4 so entity notes win ties) — past discussions are findable without having been
promoted into an entity note. `echo.py put` keeps the index current for corpus paths.
- Ranking fuses BM25 with **freshness** (half-life decay on `updated:`, floored at 0.6,
env `ECHO_FRESH_HALF_LIFE`) and **status** (`active` ×1.1, `on-hold` ×0.9,
`archived` ×0.75) — a stale archived note no longer outranks the live one.
- Hits print `updated:`/`status:`; `recall --json` emits structured results.
- The recall index carries per-doc meta (schema 2); an old index is discarded and
rebuilt automatically on the next recall/sweep.
### Added — session hooks (self-firing load & reflect)
New `hooks/hooks.json` + two hook scripts. **SessionStart** runs `echo.py load` and
injects the output as context (skips resume/compact; NOT-CONFIGURED degrades to an
instruction, never an error). **Stop** nudges once per substantive session (≥5 user
turns, env `ECHO_REFLECT_MIN_TURNS`) when nothing was reflected or session-logged —
reflection itself still previews and asks before writing. Both use the 1.4.2 CoWork
path fallback and always exit 0.
### Added — one-tap inbox triage + `--json` on read verbs
New `echo.py triage`: `--list --json` parses the inbox into `{line, date, text,
age_days}`; a proposals file (reflect schema + `"line"`) is classified → previewed →
applied via `capture`, with the `inbox/processing-log/` audit line written per move
(originals never deleted). `/echo-triage` rewritten around it. Also: `--json` for
`recall`, `link`, and `scope show` (`resolve`/`capture` already had it).
Tests: +22 feature cases (mock end-to-end incl. hooks), patch-semantics updated for
the new `fm` contract; all suites green. Docs: SKILL.md, README, command docs updated.
## 1.4.2
### Fixed — CoWork sandbox plugin-path resolution
In a remote CoWork sandbox, `${CLAUDE_PLUGIN_ROOT}` can resolve to a host path the
sandbox can't reach (e.g. `/var/folders/…`), while the plugin is actually mounted under
`…/mnt/.remote-plugins/…`. Every `python3 "${CLAUDE_PLUGIN_ROOT}/…"` invocation then
failed until the agent manually located the real path. (Root cause is the harness env
var, not the plugin — the scripts always used the variable correctly, never a hardcoded
path — but the plugin now self-heals.)
- SKILL.md and all eight slash commands resolve the scripts path with a fallback:
prefer `${CLAUDE_PLUGIN_ROOT}`, else locate the copy under
`/sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/`. Reused via
`$ECHO`/`$LINT`/`$SWEEP`/`$SDIR`; on a normal host the primary path always wins, so
behaviour is unchanged.
- `echo-health` / `echo-sweep` `allowed-tools` broadened to match the resolved
(variable-dir) invocation and the `ls`/`dirname` resolver steps.
## 1.4.1
### Fixed — baked credentials now win unconditionally
A per-user baked artifact could still be prompted for credentials on install. The
baked `DEFAULT_*` tier was lowest priority, so a stale `ECHO_*` env var or a
leftover/placeholder `~/.claude/echo-memory/config.json` shadowed the baked key —
even an unedited `"<placeholder>"` value won over it, flipping `is_configured()` to
false and triggering the first-run "ask the operator" flow despite valid baked creds.
- `echo_config.load()` now treats a **complete** baked set (endpoint + key both
present) as authoritative: it wins over env vars and the config file and cannot be
shadowed. The generic (unbaked) plugin keeps its env → file → first-run flow.
- Added `baked_complete()`; `source()` reports `baked-in (plugin)` for that tier.
### Security — untracked leaked baked artifacts
- Two labeled baked artifacts (`echo-memory-1.4.0-jason.plugin`,
`echo-memory-1.4.0-gretchen.plugin`), each carrying a live vault bearer token, had
been committed at the repo root. They are now `git rm --cached`-removed and
`.gitignore` gains `echo-memory-*-*.plugin` so labeled builds can't be re-committed
(the version-only pointer `echo-memory.plugin` and `echo-memory-<ver>.plugin` stay
tracked). Tokens remain in prior history — rotate if the repo was ever shared.
## 1.4.0
### Added — per-user baked-key builds (CoWork zero-touch)
The plugin is the only thing reliably mounted into a CoWork session (the sandbox's
`~/.claude` is synthetic, not your real one), so the config now *can* travel inside
the artifact. `echo_config` gains a lowest-priority fallback tier — the
`DEFAULT_OWNER` / `DEFAULT_BASE` / `DEFAULT_KEY` constants — **empty in source**, so
the committed tree still ships zero credentials.
- `build.py --bake-key --from <config.json> [--label <name>]` substitutes a single
user's owner/endpoint/key into those constants, producing a per-user artifact that
needs **no config file and no per-session paste**. Values may also come from
`--owner/--endpoint/--key` or `ECHO_OWNER/ECHO_BASE/ECHO_KEY`.
- Baked builds default to `dist/` (gitignored) and never touch the shared
`echo-memory.plugin` pointer.
- `build.py --strip-key` now force-blanks the same constants (guaranteed token-free).
- `config show` / `doctor` report `[baked-in default]` when a field resolves from the
baked tier.
### Changed — artifact hygiene
- `.gitignore` excludes `dist/` and the filled-in `echo-memory.config.json`, where
baked builds and the key file are meant to live. A baked artifact carries a vault
key and must be **delivered directly to that one user, never committed, pushed, or
published.** (Note: labeled baked artifacts built at the repo *root* were not yet
guarded — see 1.4.1.)
### Resolution order (unchanged for hosts)
`env``$ECHO_CONFIG` file → canonical `~/.claude/echo-memory/config.json`
**baked `DEFAULT_*`**. On a normal desktop the empty defaults mean behaviour is
identical to 1.3.x; the baked tier only matters in per-user artifacts.
-64
View File
@@ -1,64 +0,0 @@
# echo-memory — Maintenance & repo hygiene (M5)
Low-lift cleanups that remove "which copy is canonical?" traps before tagging 1.0.
None of these change runtime behavior; they reduce drift and confusion.
## Canonical source tree
- **`echo-memory.plugin.src/`** is the single canonical source tree. ✅
- **`codex plugin/`** has already drifted — it lacks the 0.9 modules
(`echo_index.py`, `echo_links.py`, `echo_ops.py`, `sweep.py`, `check_routing.py`).
- [ ] Decide: regenerate it from the canonical tree as a build target, **or** delete it
and document the codex/CoWork packaging step instead.
- [ ] Until resolved, add a `codex plugin/README.md` note: "derived — do not edit by hand."
## Build artifacts
The repo root carries every historical build (`echo-memory-0.6.0.plugin`
`echo-memory-0.9.0.plugin`) plus `echo-memory.plugin`.
- [ ] Keep only the latest `echo-memory.plugin` (the installable) tracked; move versioned
zips to GitHub Releases (or a `dist/` that is git-ignored).
- [ ] Add `*.plugin` build outputs to `.gitignore` except the current pointer, if desired.
- [x] `.gitignore` added (`.DS_Store`, `__pycache__`, local `.echo-memory/`, eval output).
## `plugin.json` completeness
- [x] Add `license` (UNLICENSED — personal, not for distribution).
- [x] Version bumped to **1.0.0**.
- [ ] Add `homepage` / repository URL (optional — repo URL not yet decided).
- [ ] (Optional) register slash commands in the manifest; they're auto-discovered from
`commands/` today (`echo-load|save|recall|triage|health|sweep|reflect|doctor`).
## Docs freshness
- [ ] `eval/run_eval.py` still benchmarks **0.6 vs 0.7** — refresh to current (open H4 follow-up).
- [x] README version-history has a **1.0.0** entry; title bumped to v1.0.0.
- [x] Scrub the literal bearer token from docs → `$ECHO_KEY` placeholder (M1, done:
`api-reference.md` ×11, `SKILL.md`, `bootstrap.md`, eval harness). Operator key setup
documented in `API-KEY-SETUP.md`. Last remaining copy is `echo.py` `DEFAULT_KEY`
(deprecated fallback) — **remove once `ECHO_KEY` is set in every environment**.
## Building the `.plugin` artifact
`python build.py` zips `echo-memory.plugin.src/` into `echo-memory-<version>.plugin` (version
read from the manifest) and refreshes the `echo-memory.plugin` pointer. Deterministic
(sorted entries + fixed timestamp → byte-identical rebuilds). Flags:
- `--strip-key` — blank `echo.py` `DEFAULT_KEY` so **no token ships** (runtime then needs
`ECHO_KEY` or `~/.echo-memory/credentials`). Use this once the key is set everywhere.
- `--no-pointer` — skip refreshing `echo-memory.plugin`.
- `--outdir DIR` — write artifacts elsewhere.
Without `--strip-key` the build warns that the baked-in token is present.
## Pre-1.0 release checklist
- [x] All 10 roadmap items wired; scaffold TODOs cleared (see `ROADMAP-1.0.md` status table).
- [x] `schema_version` bumped to 4 (sweep/migrate); `migrate.py` `[3→4]` step added.
*Verify on a copy of the live vault before migrating it (it currently reports schema 3).*
- [x] Offline suites green in CI on Win/macOS/Linux × Py 3.10/3.12 (check_routing,
test_echo_client, test_v1_scaffold, test_features, test_patch_semantics,
test_offline_queue, test_reflect).
- [ ] No live secret in the tracked tree or the built `.plugin`**one line left**: delete
`echo.py` `DEFAULT_KEY` after `ECHO_KEY`/credentials are set everywhere (per `API-KEY-SETUP.md`).
- [ ] `eval/run_eval.py` reports current-version retrieval/durability metrics (open follow-up).
+92 -23
View File
@@ -1,12 +1,12 @@
# echo-memory — v1.0.0 # echo-memory — v2.1.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`. 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. 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.
**0.9 in one line:** one-call `capture` routes and crosslinks a memory automatically, `recall` returns a topic plus its linked neighbourhood, and a machine-maintained entity index makes routing an alias-aware lookup — all on a cross-platform Python client with 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,25 @@ Three consequences follow:
## Configuration ## 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`. - **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": "…" }`.
- **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. - **Per-field env override:** `ECHO_OWNER`, `ECHO_BASE` (endpoint), and `ECHO_KEY` take precedence over the file.
- **TLS:** the endpoint presents a valid certificate, so `-k` is not needed. - **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.
### Per-user baked-key builds (v1.4.0) — for CoWork
The CoWork sandbox does **not** bridge your real `~/.claude` (the mounted `.claude` is the sandbox's own synthetic view), so a config file on your machine can't be read there. The only thing reliably mounted into every session is the **plugin itself**. So for a small set of known users, bake each user's credentials directly into a per-user artifact:
```bash
python build.py --bake-key --from alice-config.json --label alice
# -> dist/echo-memory-1.4.1-alice.plugin (carries Alice's vault key)
```
Resolution gains a **baked** tier (`DEFAULT_OWNER/BASE/KEY` in `echo_config.py`) that is **empty in source** — the committed tree still ships zero credentials. As of **v1.4.1** a *complete* baked set (endpoint **and** key both present) is **authoritative**: it wins over `ECHO_*` env vars and any `~/.claude` config file and cannot be shadowed, so a delivered per-user artifact "just works" even on a machine carrying a stale or placeholder config. When nothing is baked (the generic published plugin), host behaviour is unchanged — env → config-file → first-run prompt. `build.py --bake-key` fills those constants from a `config.json` (or `--owner/--endpoint/--key`, or `ECHO_*` env). The end user just installs the artifact — **no config file, no per-session paste**, works on desktop and in every CoWork session.
> **A baked artifact contains a vault bearer token.** Baked builds land in `dist/` (gitignored) and never touch the shared `echo-memory.plugin` pointer. Deliver each one **directly** to its single user; **never commit, push, or publish it.** Use `--strip-key` (or just a clean source build) for a token-free artifact. Rotation = rebuild + reinstall.
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. 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.
@@ -48,16 +62,28 @@ Vault paths are addressed **at the root** (e.g. `GET /vault/_agent/context/curre
``` ```
echo-v.05/ echo-v.05/
├── README.md · CHANGELOG.md ← this file + per-release change log
├── TODO-1.6.md · ROADMAP-2.0.md ← next-minor working list · packaging/structure major plan
├── docs/history/ ← historical inputs (e.g. the 1.5.0 frontmatter field report)
├── build.py ← deterministic .plugin builder (--bake-key/--strip-key/--label/--outdir)
├── echo-memory.plugin ← built, installable plugin (zip artifact, rebuilt on version bump) ├── echo-memory.plugin ← built, installable plugin (zip artifact, rebuilt on version bump)
├── echo-memory-<version>.plugin ← versioned build artifacts (history) │ — the ONLY tracked artifact; versioned builds are Gitea release
├── eval/ ← credential-free A/B eval harness (0.6 vs 0.7); not bundled │ assets (one release per v<version> tag), not committed (2.0 policy)
├── dist/ ← per-user baked artifacts (secret-bearing) — GITIGNORED, never committed
├── eval/ ← credential-free eval + test harness; not bundled
│ ├── mock_olrapi.py ← deterministic mock of the REST API + fault injection │ ├── mock_olrapi.py ← deterministic mock of the REST API + fault injection
│ ├── run_eval.py orchestrator (runs the real echo.py vs modeled raw curl) │ ├── mock_olrapi_hifi.pyhigher-fidelity mock (real PATCH heading/frontmatter semantics)
│ ├── run_eval.py ← current-version metrics (retrieval / dedup / write-safety / durability)
│ ├── test_*.py ← end-to-end suites (features, reflect, offline queue, PATCH semantics)
│ ├── results/latest.json ← machine-readable output of the last eval run
│ └── README.md │ └── README.md
└── echo-memory.plugin.src/ ← tracked source tree (the plugin) └── echo-memory.plugin.src/ ← tracked source tree (the plugin — single canonical tree)
├── .claude-plugin/plugin.json ← manifest (name, version, description) ├── .claude-plugin/plugin.json ← manifest (name, version, description)
├── README.md ← plugin-level README ├── README.md ← plugin-level README
├── commands/ ← slash commands: echo-load, echo-save, echo-recall, echo-triage, echo-health, echo-sweep ├── hooks/hooks.json ← session hooks: SessionStart auto-load · Stop reflection nudge
├── skills/echo-{load,save,recall,triage,health,sweep,reflect,doctor}/
│ ← the eight slash commands as skills (skills-only since 2.0):
│ per-skill allowed-tools; sweep/triage are operator-only
└── skills/echo-memory/ └── skills/echo-memory/
├── SKILL.md ← operating procedure (authoritative) ├── SKILL.md ← operating procedure (authoritative)
├── references/ ├── references/
@@ -68,17 +94,27 @@ echo-v.05/
│ ├── api-reference.md ← REST endpoint patterns + routing map │ ├── api-reference.md ← REST endpoint patterns + routing map
│ └── session-log-template.md │ └── session-log-template.md
├── scripts/ ← executable logic — pure Python (run: python3, or python / py -3) ├── 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 + CLI (capture/resolve/recall/link/triage/load/scope/lock/config/reflect/flush/doctor)
│ ├── echo_index.py entity index (registry, resolve, name->path map) │ ├── echo_config.py ← owner/endpoint/key resolution (baked > env > machine-local config file)
│ ├── echo_index.py ← entity index + kind maps (KIND_STATUS/KIND_REQUIRED_FM) + duplicate-gate scoring
│ ├── echo_links.py ← cross-link primitives (Related parsing, bidirectional linking) │ ├── echo_links.py ← cross-link primitives (Related parsing, bidirectional linking)
│ ├── echo_ops.py ← high-level ops (capture, recall, resolve, link, agent-log) │ ├── echo_ops.py ← high-level ops (capture with dup-gate, resolve, link, agent-log)
│ ├── echo_recall.py ← hybrid BM25 + graph recall over entities + sessions/journal (freshness/status priors)
│ ├── echo_reflect.py ← session-reflection pipeline (validate/classify/preview/apply)
│ ├── echo_triage.py ← one-tap inbox triage (reflect pipeline + processing-log audit)
│ ├── echo_queue.py ← offline write-ahead outbox + last-known-good read cache
│ ├── echo_concurrency.py ← advisory-lock context manager + atomic index updates
│ ├── echo_quality.py ← auto-link confidence + slug-collision guards
│ ├── echo_output.py ← --json result envelopes
│ ├── echo_doctor.py ← one-call readiness check (/echo-doctor)
│ ├── echo_hook_session_start.py · echo_hook_stop.py ← the session hooks (fail-safe)
│ ├── routing.json ← canonical machine-readable route manifest (linter enforces it) │ ├── routing.json ← canonical machine-readable route manifest (linter enforces it)
│ ├── vault_lint.py ← read-only invariant + graph-health checker (Vault Health) │ ├── vault_lint.py ← read-only invariant + graph-health + frontmatter-completeness checker
│ ├── check_routing.py ← verifies routing docs stay in sync with routing.json (offline) │ ├── check_routing.py ← verifies routing docs stay in sync with routing.json (offline)
│ ├── test_echo_client.py ← offline regression tests + the routing-sync guard │ ├── test_echo_client.py ← offline unit tests + the routing-sync guard
│ ├── bootstrap.py ← deterministic, idempotent vault setup/repair │ ├── bootstrap.py ← deterministic, idempotent vault setup/repair
│ ├── migrate.py ← deterministic schema migration (dry-run by default) │ ├── migrate.py ← deterministic schema migration (dry-run by default)
│ └── sweep.py ← bring an upgraded vault up to spec (build index + symmetrize links) │ └── sweep.py ← bring a vault up to spec (indexes + frontmatter backfill + link symmetrize)
└── scaffold/ ← verbatim files the bootstrap writes into the vault └── scaffold/ ← verbatim files the bootstrap writes into the vault
├── echo-vault.md ← bootstrap marker ├── echo-vault.md ← bootstrap marker
├── README.vault.md ← thin human signpost ├── README.vault.md ← thin human signpost
@@ -86,6 +122,8 @@ echo-v.05/
└── templates/ ← 8 canonical note templates └── templates/ ← 8 canonical note templates
``` ```
**Packaging for other agent runtimes** (from the retired Codex tree, deleted 2026-07-03): if a port to another runtime (Codex, etc.) is ever wanted, implement it as a **build target** generated from the canonical `echo-memory.plugin.src/` tree (`build.py --target <runtime>` or similar) — never as a second hand-maintained source tree; that's how the original Codex copy drifted. A port needs three adaptations: the manifest shape (`.claude-plugin/plugin.json` → the runtime's equivalent), the skill entry point (`skills/*/SKILL.md` frontmatter + body conventions), and the script-invocation convention (`${CLAUDE_PLUGIN_ROOT}` resolution + the session-hook wiring in `hooks/hooks.json`). The Python toolchain itself is runtime-agnostic (pure stdlib, env/config-driven).
**Division of responsibility:** `SKILL.md` owns day-to-day *procedure* (loading order, search-first, triage, scope switching, PATCH rules) and points at the bundled tooling. `references/operating-contract.md` owns the durable, client-independent *principles, safety rules, and concurrency model*. `scripts/routing.json` is the machine-readable source of truth for routing; `references/routing-map.md` is its human-readable authority. The other references are the canonical layout, API, and bootstrap specs. **Division of responsibility:** `SKILL.md` owns day-to-day *procedure* (loading order, search-first, triage, scope switching, PATCH rules) and points at the bundled tooling. `references/operating-contract.md` owns the durable, client-independent *principles, safety rules, and concurrency model*. `scripts/routing.json` is the machine-readable source of truth for routing; `references/routing-map.md` is its human-readable authority. The other references are the canonical layout, API, and bootstrap specs.
--- ---
@@ -96,9 +134,9 @@ Executable logic ships under `skills/echo-memory/scripts/`; the agent prefers it
| Tool | Purpose | | Tool | Purpose |
|------|---------| |------|---------|
| `echo.py` | The validated client + high-level CLI. Low-level verbs `get/ls/map/search/put/post/append/patch/fm/bump/delete/lock/unlock/scope/load` inject auth, **check HTTP status** (non-zero exit on ≥400), retry transient 5xx, read-back-verify PUT, idempotent whole-line `append`, correct `::` heading targets. High-level ops **`capture/resolve/recall/link`** do the routing/linking for you (see below). | | `echo.py` | The validated client + high-level CLI. The network layer is **keep-alive + connection-pooled** (one persistent connection per thread, reused across requests) with a concurrent `read_many()` bulk-GET (`ECHO_WORKERS`, default 8) — so full-vault passes that used to time out finish in under a second. Low-level verbs `get/ls/map/search/put/post/append/patch/fm/bump/delete/lock/unlock/scope/load` inject auth, **check HTTP status** (non-zero exit on ≥400), retry transient 5xx (and reconnect a stale pooled connection), read-back-verify PUT, idempotent whole-line `append`, correct `::` heading targets. High-level ops **`capture/resolve/recall/link`** do the routing/linking for you (see below). |
| `capture / resolve / recall / link` | The input-reducing layer. **`capture "<title>" --kind <k>`** routes via the entity index, stamps frontmatter, indexes, auto-links mentioned entities, and writes the Agent-Log line in one call. **`resolve "<mention>"`** → canonical path (alias-aware). **`recall "<query>"`** → matching notes + their one-hop linked neighbourhood. **`link A B`** → reciprocal `## Related` links. | | `capture / resolve / recall / link / triage` | The input-reducing layer. **`capture "<title>" --kind <k> [--tags a,b]`** routes via the entity index, stamps **complete** frontmatter (kind-default `status`, kind-seeded `tags`), indexes, auto-links mentioned entities, and writes the Agent-Log line in one call; on an existing entity it appends the **whole body** as a dated block; a strong fuzzy candidate **stops creation** (exit 76 — `--merge-into <slug>` / `--force` resolve it). **`resolve "<mention>"`** → canonical path (alias-aware). **`recall "<query>" [--json]`** → ranked matches + one-hop neighbourhood over entities **and sessions/journal**, fused with freshness + status priors. **`link A B`** → reciprocal `## Related` links. **`triage`** → one-tap inbox routing with an automatic processing-log audit trail. |
| `echo_index.py` | The **entity index** (`_agent/index/entities.json`): slug→{path, kind, title, aliases, last_seen}. Makes routing/resolve an O(1), alias-aware lookup and supplies the name→path map for linking and recall. Rebuilt automatically by `capture` and `sweep.py`. | | `echo_index.py` | The **entity index** (`_agent/index/entities.json`): slug→{path, kind, title, aliases, last_seen}. Makes routing/resolve an O(1), alias-aware lookup and supplies the name→path map for linking and recall. `resolve()` matches on slug/title/alias; a `fuzzy_candidates()` "did-you-mean" fallback (1.2) surfaces near-matches for shortened names without auto-merging. Aliases are auto-derived from titles, learned from mentions on update, stored in note frontmatter, and folded back in by `sweep.py`. Rebuilt automatically by `capture` and `sweep.py`. |
| `echo_links.py` / `echo_ops.py` | Cross-link primitives (parse/add `## Related`, bidirectional linking) and the high-level ops layer (capture/recall/resolve/link/agent-log). | | `echo_links.py` / `echo_ops.py` | Cross-link primitives (parse/add `## Related`, bidirectional linking) and the high-level ops layer (capture/recall/resolve/link/agent-log). |
| `routing.json` | The **canonical machine-readable** route manifest — one regex pattern per valid destination plus retired paths. The single source of truth for "what may be written where"; `vault_lint.py` enforces it against the vault, `check_routing.py` against the docs. | | `routing.json` | The **canonical machine-readable** route manifest — one regex pattern per valid destination plus retired paths. The single source of truth for "what may be written where"; `vault_lint.py` enforces it against the vault, `check_routing.py` against the docs. |
| `vault_lint.py` | Read-only invariant + **graph-health** checker (Vault Health). Tolerant frontmatter parsing, clock injected via `ECHO_TODAY`, exits `3` if the vault isn't bootstrapped. Checks scope-drift, **broken wikilinks, orphan notes, and entity-index drift**. | | `vault_lint.py` | Read-only invariant + **graph-health** checker (Vault Health). Tolerant frontmatter parsing, clock injected via `ECHO_TODAY`, exits `3` if the vault isn't bootstrapped. Checks scope-drift, **broken wikilinks, orphan notes, and entity-index drift**. |
@@ -109,7 +147,11 @@ Executable logic ships under `skills/echo-memory/scripts/`; the agent prefers it
### Slash commands ### 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 spec) — explicit, reproducible 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` (one-tap inbox routing via `echo.py triage`), `/echo-health` (run the linter), `/echo-sweep` (bring the vault up to spec), `/echo-reflect` (extract→preview→apply durable items from the session), `/echo-doctor` (readiness check: Python, vault reachability, auth, bootstrap/schema, key source) — explicit, reproducible entry points to the procedures below.
### Session hooks (v1.5)
The plugin ships `hooks/hooks.json`: a **SessionStart** hook runs `echo.py load` and injects the orientation reads as context (cold-start loading no longer depends on the model remembering), and a **Stop** hook nudges **once per substantive session** to run `/echo-reflect` + the session log when nothing was reflected yet (reflection still previews and asks before writing). Both are fail-safe — unconfigured/unreachable vaults degrade to a note, never an error — and use the CoWork path fallback.
### Concurrency (shared vault) ### Concurrency (shared vault)
@@ -232,7 +274,7 @@ The journal is one append-only time-series stream; rollups are coarser-grained e
### Vault Health (monthly) ### Vault Health (monthly)
Agent self-maintenance (not a journal entry), written to `_agent/health/YYYY-MM-vault-health.md`. Run `scripts/vault_lint.py` (or `/echo-health`) with `ECHO_TODAY` = the conversation's date so stale/aging math uses one clock. It mechanically asserts: folder↔status mismatch, duplicate slugs across lifecycle folders, wikilinks in frontmatter (swept across all folders), duplicate `## Agent Log` headings, stale active projects (`updated:` > 30 days), aging inbox items (> 14 days), **paths matching no route in `routing.json` or sitting at a retired path**, **frontmatter integrity** (missing required fields, `updated` < `created`, future dates, wikilinks leaking into `source_notes`), and **scope drift** (≥ `SCOPE_STALE_SESSIONS` session logs dated after `scope_updated`). Exit codes: `0` clean · `1` violations · `2` unreachable · `3` not bootstrapped. Findings are reported, not auto-fixed. Agent self-maintenance (not a journal entry), written to `_agent/health/YYYY-MM-vault-health.md`. Run `scripts/vault_lint.py` (or `/echo-health`) with `ECHO_TODAY` = the conversation's date so stale/aging math uses one clock. It mechanically asserts: folder↔status mismatch, duplicate slugs across lifecycle folders, wikilinks in frontmatter (swept across all folders), duplicate `## Agent Log` headings, stale active projects (`updated:` > 30 days), aging inbox items (> 14 days), **paths matching no route in `routing.json` or sitting at a retired path**, **frontmatter integrity** (missing required fields, `updated` < `created`, future dates, wikilinks leaking into `source_notes`), **incomplete entity frontmatter** (missing/empty `status`/`tags` per the kind-scoped `KIND_REQUIRED_FM` map — `sweep.py --apply` backfills), and **scope drift** (≥ `SCOPE_STALE_SESSIONS` session logs dated after `scope_updated`). Exit codes: `0` clean · `1` violations · `2` unreachable · `3` not bootstrapped. Findings are reported, not auto-fixed.
--- ---
@@ -361,10 +403,37 @@ If the API returns a connection error, timeout, or `502` (usually Obsidian / the
--- ---
## Eval metrics (v1.5.1, 2026-07-03)
From the credential-free harness (`eval/run_eval.py` against the deterministic mock — no live vault; full detail in `eval/results/latest.json`). Baseline = pre-1.5 behavior: keyword substring ranking over entity notes only, no gate.
| Metric | v1.5.1 | pre-1.5 baseline |
|---|---|---|
| Retrieval recall@5 / MRR (8-query gold set) | **1.00 / 1.00** | 0.75 / 0.75 |
| Queries answerable only from sessions/journal | **2/2** | 0/2 (not in corpus) |
| Freshness: live note outranks stale archived twin | **yes** | no |
| Duplicate notes created (3 renamed-entity captures) | **0** (gate blocks) | 3 |
| Legitimate captures wrongly blocked | **0** | 0 |
| Silent write failures (4 fault scenarios) | **0** (failures loud) | — |
| Offline writes lost / duplicated on re-flush | **0 / 0** (3/3 queued + landed) | — |
## Version history ## Version history
| Version | Highlights | | Version | Highlights |
|---------|-----------| |---------|-----------|
| **2.1.1** | **MCP Phase 0 — return-not-print.** Every high-level op gains a core `*_op` function returning an envelope dict with a stdout-purity guarantee (helper chatter → stderr); CLI verbs become thin wrappers with identical output and exit codes. Duplicate gate and offline queueing become data (`action: duplicate-gate` / `queued: true`). New `eval/test_ops_api.py` contract suite. This is the seam the 2.2 containerized MCP server wraps. |
| **2.1.0** | **Quick-wins train: cheaper sessions, durable capture, one-call session end.** (1) **`load --brief`** — a token-budgeted cold-start digest (Fact/Pattern in full, last ~10 Observations, scope+freshness, last session's key sections, Agent-Log lines, inbox *count*; `ECHO_LOAD_BUDGET` default ~8000 chars) now injected by the SessionStart hook, replacing the full six-file dump that grew unboundedly; all `load` reads are fetched in parallel. (2) **Offline capture durability**`capture` on an unreachable vault queues the *whole operation* as one semantic record; `flush` replays it **through capture** so routing/gate/aliasing re-run against the current index; a gate stop on replay is kept + flagged, never landed blind; `ensure_daily_log`/update-path writes ride the queue too. (3) **`session-end`** — one call (one lock) writes session log → Agent-Log line → reflect proposals → optional scope switch → **heartbeat last as the commit marker**; dry-run by default; `ECHO_NOW` pins the HHMM. Also fixes the `__main__` twin-module trap so helper-module `EchoError`s exit with their intended codes. +19 end-to-end checks. |
| **2.0.0** | **Packaging & structure major — memory behavior unchanged.** Skills-only: the legacy `commands/` directory is deleted (breaking for pre-skills clients; the 1.6.0-verified skills are the sole entry points). Artifact policy: the repo tracks only the `echo-memory.plugin` pointer; the 15 historical versioned zips leave the tree and versioned builds ship as **Gitea releases** (one per `v<version>` tag) from `v2.0.0` on; `.gitignore` blocks `*.plugin` except the pointer. README gains the "packaging for other agent runtimes" port note (build-target rule, never a second source tree). |
| **1.6.0** | **Skills-format migration + lint blind-spot fix.** The eight slash commands gain `skills/<name>/SKILL.md` twins (same names, byte-identical bodies; a same-name skill takes precedence, so `commands/` coexists until its 2.0 deletion): per-skill `allowed-tools` end the permission prompts, `disable-model-invocation: true` makes `/echo-sweep` + `/echo-triage` operator-only. `vault_lint` checks retired patterns **before** routes, so a seed README inside a retired tree (`archive/…`) is flagged instead of being absolved by the leaf-README route (+ regression test). `plugin.json` gains `homepage`/`repository`. |
| **1.5.1** | **Duplicate-gate precision.** Live use caught the gate blocking a decision note because it shared the single vault-common token "echo" with an archived project (min-normalized scoring gives any single-token entity a 1.0 match). New `gate_candidates()` blocks only on **same-kind** candidates (cross-kind name collisions — a decision titled after its project — warn instead), and a **lone shared token blocks only when unique to that entity** (vault df == 1); multi-token overlaps still block. Advisory warnings (`fuzzy_candidates`) unchanged; exit-76/`--merge-into`/`--force` unchanged. +5 tests; verified against the live vault both ways (false positive creates cleanly, true lookalike still gates). |
| **1.5.0** | **Six-improvement pass: retention, routing, and self-firing memory.** (1) **Capture-update keeps the whole body** — the update path previously appended only the first line of a multi-line body (silent data loss); now the full body rides under the dated bullet. (2) **Frontmatter completeness** — capture stamps a kind-default `status` and kind-seeded `tags` (`--tags` enriches); `fm` is create-or-replace (missing keys are surgically inserted instead of 400-failing); `vault_lint` gains a kind-scoped `incomplete-frontmatter` check; `sweep --apply` backfills (fixes the 18/71-notes field-audit drift, one data source: `KIND_STATUS`/`KIND_REQUIRED_FM`). (3) **Pre-write duplicate gate** — a strong fuzzy candidate stops `capture` (exit 76 + candidates) before the duplicate exists; `--merge-into <slug>` updates the canonical entity, `--force` overrides. (4) **Recall corpus + ranking** — sessions and journal notes join the BM25 corpus (down-weighted); scores fuse freshness (half-life on `updated:`) and status (`active` boosted, `archived` demoted); hits show `updated:`/`status:`; `recall --json`; recall-index schema 2 (auto-rebuilds). (5) **Session hooks** — SessionStart auto-loads memory into context, Stop nudges reflection once per substantive session; fail-safe, CoWork-fallback-aware. (6) **One-tap triage**`echo.py triage` lists the inbox structured, then classifies → previews → routes accepted items via `capture` with automatic processing-log audit lines; `--json` also lands on `link`/`scope show`. +22 mock end-to-end tests; all suites green. |
| **1.4.2** | **CoWork sandbox path resilience.** In a remote CoWork sandbox `${CLAUDE_PLUGIN_ROOT}` can point at a host path the sandbox can't reach (`/var/folders/…`) while the plugin is mounted under `…/mnt/.remote-plugins/…`, so script invocations failed until the agent found the real path by hand. SKILL.md and all eight slash commands now resolve the scripts dir with a fallback — prefer `${CLAUDE_PLUGIN_ROOT}`, else locate the mounted copy under `/sessions/*/mnt/.remote-plugins/*/…` — reused via `$ECHO`/`$LINT`/`$SWEEP`/`$SDIR`. On a normal host the primary path always wins (no behaviour change). `echo-health`/`echo-sweep` `allowed-tools` broadened to match the resolved invocation. (The scripts never hardcoded a path — root cause is the harness env var — but the plugin now self-heals.) |
| **1.4.1** | **Baked credentials now win unconditionally.** A per-user baked artifact could still be prompted for credentials on install: the baked `DEFAULT_*` tier was lowest priority, so a stale `ECHO_*` env var or a leftover/placeholder `~/.claude/echo-memory/config.json` shadowed the baked key — even an unedited `"<placeholder>"` value beat it, flipping `is_configured()` to false and triggering the first-run "ask the operator" flow despite valid baked creds. `echo_config.load()` now treats a **complete** baked set (endpoint + key both present) as authoritative — it wins over env vars and the config file and cannot be shadowed; the generic (unbaked) plugin keeps its env → file → first-run flow. New `baked_complete()`; `source()` reports `baked-in (plugin)`. Per-user artifacts rebaked; manifest → 1.4.1. |
| **1.4.0** | **Per-user baked-key builds — zero-touch CoWork install.** The CoWork sandbox doesn't bridge your real `~/.claude`, but the plugin itself is mounted every session — so credentials can travel *inside* the artifact. `echo_config` gains a baked tier (`DEFAULT_OWNER/BASE/KEY`, **empty in source** so the committed tree still ships zero credentials). New `build.py --bake-key --from <config.json> [--label <name>]` substitutes one user's owner/endpoint/key into those constants, producing a per-user artifact that needs **no config file and no per-session paste** (values may also come from `--owner/--endpoint/--key` or `ECHO_*` env). Baked builds default to `dist/` (gitignored) and never touch the shared `echo-memory.plugin` pointer; `--strip-key` force-blanks the constants for a guaranteed token-free build. `config show`/`doctor` report the baked source. |
| **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 23×; 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 (~23× 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`. | | **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`. |
| **0.3.0** | Source promoted from zip-only to a tracked tree (`echo-memory.plugin.src/`); `.plugin` becomes a build artifact. All 7 skill-improvement items applied: search-first before writes, resilient daily Agent Log, `created:` semantics, project lifecycle + folder↔status rule, canonical HHMM session filenames, read-most-recent-N sessions, `source_notes` defined as backward links. | | **0.3.0** | Source promoted from zip-only to a tracked tree (`echo-memory.plugin.src/`); `.plugin` becomes a build artifact. All 7 skill-improvement items applied: search-first before writes, resilient daily Agent Log, `created:` semantics, project lifecycle + folder↔status rule, canonical HHMM session filenames, read-most-recent-N sessions, `source_notes` defined as backward links. |
| **0.4.0** | Efficiency + robustness pass: parallel cold-start loading, idempotent POST (read-before-append), doc-map-before-first-PATCH, scoped `updated:` bump, Inbox Triage, Scope Switching, monthly Vault Health, Rules-vs-Observations split, formal deprecation of `decisions/by-project/`, heartbeat pointer. | | **0.4.0** | Efficiency + robustness pass: parallel cold-start loading, idempotent POST (read-before-append), doc-map-before-first-PATCH, scoped `updated:` bump, Inbox Triage, Scope Switching, monthly Vault Health, Rules-vs-Observations split, formal deprecation of `decisions/by-project/`, heartbeat pointer. |
-176
View File
@@ -1,176 +0,0 @@
# echo-memory — Roadmap to v1.0
> Status: **draft / in progress.** Current shipping version: **0.9.0 (schema 3)**.
> Target: **1.0.0 (schema 4)** — *"memory you can actually trust and retrieve."*
This roadmap turns the 10 code-review suggestions into a sequenced plan and tracks the
scaffold for each. It is the single index: every v1.0 workstream maps to one or more
files under `echo-memory.plugin.src/skills/echo-memory/scripts/` (plus tests and CI).
---
## 1.0 thesis
0.x made the plugin **correct and self-describing**: status-checked writes, idempotent
appends, a routing manifest the linter enforces, an entity index, and one-call
`capture/recall`. 1.0 makes it **trustworthy and genuinely recall-driven**:
1. You can **retrieve** a fact even when you phrase the query differently than you stored it. *(H1)*
2. Nothing is **lost** when the backend is down. *(H2)*
3. Two clients writing at once **cannot corrupt** the index or clobber state. *(H3)*
4. Every feature is **proven** by tests that model the real API, gated in CI. *(H4)*
5. Memory **accrues on its own** instead of only when the agent remembers to write. *(H5)*
…on a base that is secure (M1), clean-linking (M2), self-diagnosing (M3),
machine-parseable (M4), and maintainable (M5).
---
## Non-negotiable constraints (these bound every solution)
These are inherited from the 0.x design and **must not regress**:
| Constraint | Consequence for 1.0 |
|---|---|
| **Pure-Python stdlib, no third-party deps** | H1 recall = local BM25 + graph fusion, **not** an embedding API. No `numpy`, `keyring`, `requests`. |
| **Cross-platform (Win/macOS/Linux)** | No bash, no platform `date`; `pathlib`, `os`, UTF-8-safe streams. |
| **Plugin is the single source of truth; vault holds data only** | Derived artifacts (recall index) live in `_agent/index/` (machine-maintained, rebuildable) — never new control docs in the vault. |
| **Fail loud, never silent** | New write paths keep status-checking and non-zero exits. |
| **Local state can't depend on the vault being up** | H2 queue/cache lives in `ECHO_STATE_DIR` (default `~/.echo-memory/`), not the vault. |
---
## Build order vs. scaffold order
The user asked to **scaffold heaviest → lightest** (H1→M5). That is the order the
files below are stubbed. The **recommended build/merge order is different** — safety
and proof first, so the heavy features land on a tested, secure base:
```
Phase A (land first): M1 secrets · H4 tests+CI
Phase B (correctness): H3 concurrency · M2 link/slug quality
Phase C (durability): H2 offline queue + cache
Phase D (intelligence): H1 hybrid recall · H5 reflection capture
Phase E (polish): M3 doctor · M4 --json · M5 hygiene → tag 1.0.0
```
Scaffolds are independent stubs, so stubbing heaviest-first is safe; only the
*integration* (wiring into `echo.py`, schema bump, migration) follows Phase order.
---
## Workstreams (heaviest → lightest)
Each item: **goal · why · scope · files · storage/schema · acceptance · depends-on · lift.**
### H1 — Hybrid/semantic recall · lift: ●●●●●
- **Goal:** `recall "X"` returns relevant notes even when the wording differs, ranked, with scored multi-hop graph expansion.
- **Why:** Recall is the product. Today it's keyword `/search/simple` + fixed one-hop (`echo_ops.py:88`). Differently-phrased memory is unfindable → dead weight.
- **Scope:** local BM25 index over note bodies (stdlib only); fuse lexical score + graph distance (decay per hop); relevance threshold; configurable hops.
- **Files:** `scripts/echo_recall.py` (new) → later replaces `echo_ops.recall`.
- **Storage/schema:** `_agent/index/recall-index.json` (postings + doc stats), machine-maintained, rebuilt by `sweep.py`. **Schema 3→4.**
- **Acceptance:** retrieval precision/recall measured in eval beats keyword-only baseline; recall returns scored, deduped hits + neighbourhood; index rebuildable offline.
- **Depends-on:** entity index (have), H4 eval harness for the precision/recall numbers.
### H2 — Offline durability: write-ahead queue + read cache · lift: ●●●●●
- **Goal:** No durable write is lost when the API/Obsidian is down; cold-start degrades to last-known context instead of "no memory."
- **Why:** Today vault-unreachable = silently proceed without memory (`SKILL.md:383`). 502 (Obsidian not running) is the most common real failure.
- **Scope:** append-only NDJSON outbox; replay on next reachable session (idempotency makes replay safe); last-known-good read cache for `load`.
- **Files:** `scripts/echo_queue.py` (new); integration hook `safe_request()` wrapping `echo.request`.
- **Storage/schema:** local `ECHO_STATE_DIR/outbox.ndjson` + `ECHO_STATE_DIR/cache/`. No vault schema change.
- **Acceptance:** kill the API mid-session → writes queue; restart → replay lands exactly once (no dupes); `load` serves cache when offline and says so.
- **Depends-on:** existing idempotent-append discipline; H4 for fault-injection tests.
### H3 — Concurrency correctness · lift: ●●●●○
- **Goal:** Make the advertised multi-writer (Claude + CoWork) story actually safe.
- **Why:** Index is full-file load→PUT with no lock (`echo_index.py:105`, `echo_ops.py:230`) → concurrent `capture` silently drops an entity. Lock is manual/advisory only (`echo.py:293`).
- **Scope:** auto-lock context manager around `capture`/`scope set`/index writes; atomic index update (re-read under lock, merge, save); per-resource locks; crash-safe TTL reclaim (have).
- **Files:** `scripts/echo_concurrency.py` (new); refactor `echo_index.save` + `echo_ops.capture` to route through it.
- **Storage/schema:** reuse `_agent/locks/`. No schema change.
- **Acceptance:** two concurrent captures both land in the index; no lost entries; lock auto-released on normal + error exit.
- **Depends-on:** none (pure refactor); H4 to prove it.
### H4 — High-fidelity tests + CI + current eval · lift: ●●●●○
- **Goal:** Prove every 0.9 + 1.0 feature against an API model that reproduces real failure modes; gate on CI.
- **Why:** Mock PATCH is "naive append" (`mock_olrapi.py:138`) — can't model heading replace/insert; eval still benchmarks **0.6 vs 0.7** (two versions stale).
- **Scope:** higher-fidelity mock (real heading/frontmatter PATCH semantics) or containerized real Obsidian Local REST API; refresh eval to measure retrieval precision/recall, link correctness, dup rate; wire `test_echo_client.py` + `check_routing.py` + `test_features.py` + new module tests into GitHub Actions.
- **Files:** `.github/workflows/ci.yml` (new); `eval/mock_olrapi_hifi.py` (new); `scripts/test_v1_scaffold.py` (interface guard, new); refreshed `eval/run_eval.py`.
- **Acceptance:** CI green on every push; eval reports current-version metrics; mock reproduces the 40080 invalid-target and replace/insert paths.
- **Depends-on:** none — **foundational, build in Phase A.**
### H5 — Automatic session-reflection capture · lift: ●●●●○
- **Goal:** At session end, propose durable captures extracted from the conversation for one-tap confirm — memory that fills itself.
- **Why:** Memory only accrues when the agent decides to write. "As useful as possible" means not depending on that judgment firing.
- **Scope:** model-side extraction emits a JSON proposal set; script dedups against the entity index, previews, and applies on confirm (respects "show before large writes" safety rule).
- **Files:** `scripts/echo_reflect.py` (new); `/echo-reflect` command (later).
- **Storage/schema:** none new (routes through `capture`).
- **Acceptance:** given a transcript, proposals are deduped vs index, previewed, and only applied on explicit confirm; nothing written without go-ahead.
- **Depends-on:** H1 index/recall for dedup quality; H3 for safe concurrent apply.
### M1 — Harden secret handling · lift: ●●○○○ *(do now — Phase A)*
- **Goal:** Stop shipping a live bearer token in source, docs, and every `.plugin` zip.
- **Why:** Token hardcoded at `echo.py:70` and repeated through `api-reference.md`/README — committed to git, only rotatable by rebuild. Violates the plugin's own "never store secrets" rule.
- **Scope:** resolve key env → local `ECHO_STATE_DIR/credentials` (0600) → deprecated baked default **with a loud warning**; scrub literal from docs to a placeholder; document rotation.
- **Files:** `scripts/echo_secrets.py` (new); `echo.py` `KEY=``echo_secrets.resolve_key()`.
- **Acceptance:** no live token in tracked source/docs/package; `ECHO_KEY` still overrides; rotation documented.
- **Depends-on:** none.
### M2 — Tame auto-link false positives & slug collisions · lift: ●●○○○
- **Goal:** Keep the graph (which recall depends on) clean.
- **Why:** Auto-link fires on any ≥3-char name/alias word-match (`echo_ops.py:236`) → a concept "API" or alias "rs" links everywhere; `slugify` truncates to 40 chars with no collision check (`echo_index.py:58`) → distinct titles silently share a note.
- **Scope:** raise alias floor / require multi-token or kind-aware match / stopword guard; collision-aware slug disambiguation in `derive_path`.
- **Files:** `scripts/echo_quality.py` (new); patches to `echo_links`/`echo_index`.
- **Acceptance:** known false-positive cases no longer link; colliding titles get distinct slugs; covered by tests.
- **Depends-on:** none.
### M3 — `echo.py doctor` + complete `load` fallback · lift: ●●○○○
- **Goal:** One-call readiness check; make `load` self-sufficient.
- **Why:** No single "is everything OK" check. `cmd_load` reads the heartbeat but never falls back to listing `_agent/sessions/` though `SKILL.md:122` documents it (`echo.py:395`).
- **Scope:** doctor = Python version + reachability + auth + marker/`schema_version` + lint summary, green/red; `load` lists recent sessions when heartbeat missing/stale.
- **Files:** `scripts/echo_doctor.py` (new); patch `echo.cmd_load`.
- **Acceptance:** `doctor` prints actionable status and exits non-zero on any red; `load` orients with no heartbeat.
- **Depends-on:** none.
### M4 — Machine output (`--json`) + `--dry-run` · lift: ●○○○○
- **Goal:** Let the agent act on structured results and preview writes.
- **Why:** `capture/recall/resolve/scope` print prose; agent re-parses free text.
- **Scope:** `--json` result envelope (action, path, links_added, hits); `--dry-run` for `capture`/`scope set`.
- **Files:** `scripts/echo_output.py` (new helpers); thread through `echo_ops`/`echo.py`.
- **Acceptance:** every high-level op emits valid JSON under `--json`; `--dry-run` writes nothing.
- **Depends-on:** none.
### M5 — Repo hygiene & manifest completeness · lift: ●○○○○
- **Goal:** Remove "which tree is canonical?" traps; complete the manifest.
- **Why:** Two plugin trees (`codex plugin/` vs `echo-memory.plugin.src/`) have already drifted (codex lacks the 0.9 modules); stack of versioned `.plugin` artifacts; sparse `plugin.json`.
- **Scope:** consolidate/mark canonical tree; prune old artifacts; add `license`/`homepage`/command registration to `plugin.json`; refresh version-history + eval references.
- **Files:** `MAINTENANCE.md` (new checklist); `plugin.json`.
- **Acceptance:** one canonical source tree; complete manifest; docs reference current version.
- **Depends-on:** none.
---
## Schema 3 → 4 migration (owned by H1/H2)
- Add `_agent/index/recall-index.json` (H1), rebuilt by `sweep.py`.
- `migrate.py`: add `[3→4]` step (create recall-index placeholder; no destructive moves).
- `routing.json`: add a route for `_agent/index/recall-index.json` (already covered by the generic `^_agent/index/[^/]+\.json$` route — verify in `check_routing.py`).
- Bump `CURRENT_SCHEMA` to 4 in `sweep.py`/`migrate.py`; `vault_lint.py` unaffected.
---
## Scaffold status
| # | Workstream | Lift | Scaffold file(s) | State |
|---|---|---|---|---|
| H1 | Hybrid recall | ●●●●● | `scripts/echo_recall.py` | **✅ WIRED** — BM25 + vault crawl + `recall-index.json` persistence + graph fusion; maintained on `capture`, rebuilt by `sweep`; replaces `echo_ops.recall`; **schema 4**. Covered by `test_features.py`. |
| H2 | Offline queue/cache | ●●●●● | `scripts/echo_queue.py`, `eval/test_offline_queue.py` | **✅ WIRED** — write verbs (put/post/append/patch/delete) queue on outage and report "queued"; `flush` (+ flush-on-`load`) replays idempotently and **re-bases to the current endpoint** (survives base migration); `load` degrades to last-known-good cache and flags OFFLINE. Proven end-to-end (down→queue→up→flush→land, idempotent, cache fallback). Follow-up: route capture's internal index/link writes through the queue for full offline-atomic captures. |
| H3 | Concurrency | ●●●●○ | `scripts/echo_concurrency.py` | **✅ WIRED** — `vault_lock` CM (auto acquire/release, crash-safe, advisory) + `atomic_index_update` (lock + fresh re-read-merge). `capture` entity write and `echo_recall.update_note` both routed through it. Proven by `test_features.py` (merge-no-clobber + lock-release). Follow-up: deeper resolve-level title-collision detection; true concurrent stress test. |
| H4 | Tests + CI | ●●●●○ | `.github/workflows/ci.yml`, `eval/mock_olrapi_hifi.py`, `eval/test_patch_semantics.py`, `scripts/test_v1_scaffold.py` | **✅ hi-fi PATCH mock + semantics test + CI matrix (Win/macOS/Linux × 3.10/3.12) live.** Remaining: refresh `run_eval.py` (still 0.6-vs-0.7) → publish current metrics. |
| H5 | Reflection capture | ●●●●○ | `scripts/echo_reflect.py`, `eval/test_reflect.py`, `commands/echo-reflect.md` | **✅ WIRED** — `validate`/`classify`/`preview`/`apply` + `echo.py reflect` (dry-run unless `--apply`, reads file or stdin) + `/echo-reflect` command. Dedups proposals vs the entity index, drops below-confidence, routes `inbox`, applies via `capture` (indexed/linked/logged, self-lock-guarded). Proven by `test_reflect.py`. |
| M1 | Secret handling | ●●○○○ | `scripts/echo_secrets.py` | **✅ WIRED** — `resolve_key` (env → `~/.echo-memory/credentials` → deprecated fallback + loud warning), `write-key` CLI, docs scrubbed. Live token now only in `echo.py` `DEFAULT_KEY` (remove at 1.0; operator env already sets `ECHO_KEY`). |
| M2 | Link/slug quality | ●●○○○ | `scripts/echo_quality.py` | **✅ WIRED** — `is_confident_link` gates `capture` auto-linking (rejects <4-char / common tokens, trusts multi-word names); `safe_slug` disambiguates a colliding 40-char-truncated slug in the create path. Follow-up: resolve-level title collision (two long titles → same slug → false merge). |
| M3 | Doctor + load fallback | ●●○○○ | `scripts/echo_doctor.py`, `commands/echo-doctor.md` | **✅ WIRED** — `echo.py doctor` (Python/reachability/auth/bootstrap/schema/key-source) + `/echo-doctor`; `cmd_load` now falls back to a recent-sessions listing when the heartbeat pointer is absent. Verified against the live vault. |
| M4 | `--json` / `--dry-run` | ●○○○○ | `scripts/echo_output.py` | **✅ WIRED** — `capture --json` emits a clean result envelope (helper chatter redirected) and `--dry-run` previews the create/update plan without writing; `resolve` already emits JSON. Proven by `test_features.py`. Follow-up: `--json` for recall/scope/link. |
| M5 | Hygiene | ●○○○○ | `plugin.json`, `.gitignore`, `MAINTENANCE.md` | **✅ DONE** — `plugin.json`**1.0.0** + `license`; `.gitignore` (`.DS_Store`, `__pycache__`, local `.echo-memory/` state, eval output). Remaining (operator judgment, not code): consolidate/retire the drifted `codex plugin/` tree and prune old `.plugin` artifacts — see `MAINTENANCE.md`. |
**Wiring:** scaffolds are **not** imported by the live `echo.py` yet — 0.9.0 behavior is unchanged. Integration happens per Phase, each gated by its tests (H4).
+97
View File
@@ -0,0 +1,97 @@
# echo-memory — Roadmap to v2.0.0
> Status: **draft.** Current shipping version: **1.5.1**. Interim work tracked in
> `TODO-1.6.md`.
>
> **2.0 thesis: a packaging & structure major.** Nothing here changes what the plugin
> *does* for memory — 2.0 is the release where the repo layout, artifact policy, and
> plugin format are cleaned up in ways that are breaking for how the plugin is
> *distributed and installed*, so the major version signals "reinstall everything,
> re-clone expectations." Feature work keeps landing in 1.6.x until this is ready.
>
> This roadmap absorbs every open item from the retired `MAINTENANCE.md` (M5)
> checklist; the mapping is noted per item.
---
## 1. Repo-root artifact policy (from MAINTENANCE Build artifacts)
The repo root tracks every historical build (`echo-memory-0.6.0.plugin`
`echo-memory-1.5.1.plugin`) plus the `echo-memory.plugin` pointer. 2.0 changes the
policy — **breaking for anyone who pulls artifacts straight from the repo**:
- [x] Track only `echo-memory.plugin` (the current installable); delete the versioned
zips from the tree — **done 2.0.0** (15 zips removed; history keeps them).
- [x] Publish versioned builds as **Gitea releases** on `git.alwisp.com/jason/echo`
instead (one release per tag, artifact attached) — **v2.0.0 onward**.
- [x] `.gitignore`: `*.plugin` except the pointer — **done 2.0.0**.
- [x] Tag releases going forward (`v2.0.0`, …) so the release page is the version
history for artifacts, and the README table stays the narrative history.
## 2. Complete the skills-format migration (finishes the 1.6 work)
1.6 migrates the eight slash commands to `skills/*/SKILL.md` with both formats
coexisting (see `TODO-1.6.md`). 2.0 finishes it:
- [x] **Delete the legacy `commands/` directory****done 2.0.0** (1.6.0's install
verification on both surfaces cleared the gate; skills had precedence anyway).
- [x] Re-verify desktop + CoWork installs of the skills-only artifact — **verified
2026-07-28** on the baked 2.0.0 build: installs cleanly, all eight skills
register and work with no legacy `commands/` present.
- [x] Update SKILL.md / README / command docs that reference `commands/`**done 2.0.0**.
## 3. Codex packaging (from MAINTENANCE Canonical source tree)
The drifted `codex plugin/` tree was **deleted on 2026-07-03** (operator decision:
Codex usage doesn't currently justify a maintained second build; it will be
regenerated after 2.0 if needed). What remains for 2.0:
- [ ] If Codex support returns: implement it as a **build target** generated from the
canonical `echo-memory.plugin.src/` tree (`build.py --target codex` or similar) —
never as a second hand-maintained source tree (that's how the drift happened).
- [x] Otherwise: a short "packaging for other agent runtimes" note in the README
documenting what a Codex/other-runtime port needs (manifest shape, skill entry
point, script invocation) — **done 2.0.0** (README after Repository layout).
## 4. Eval refresh — publish current-version metrics (from MAINTENANCE Docs freshness; ROADMAP-1.0 H4 leftover) — ✅ DONE
`run_eval.py` was rewritten from the stale 0.6-vs-0.7 A/B into the current-version
metrics harness (retrieval / dedup / write-safety / durability); numbers published in
the README. Re-run it per release and refresh the README table.
- [x] Refresh `run_eval.py` to measure the current version — **done 2026-07-03**:
retrieval P@5/R@5/MRR + session/journal answerability + freshness top-1 (vs a
keyword/entities-only baseline), duplicate rate gate-on/off, silent-write-failure
rate under fault injection, offline-queue durability. `results/latest.json`.
- [x] Publish the numbers in the README and mark the old A/B harness historical
(git history) — **done 2026-07-03**.
## 5. Repo hygiene odds & ends
- [x] Retire `ROADMAP-1.0.md` (fully shipped; in git history) — **done 2026-07-03**.
- [x] Root README "Repository layout" section updated for the post-2.0 tree
(no versioned zips, no codex tree, skills-only plugin) — **done 2.0.0**.
- [x] `echo-improvements-prompt.md` (the 1.5.0 field report) moved to `docs/history/`
**done 2026-07-03**.
---
## MAINTENANCE.md disposition (why it was removed)
Every item from the retired checklist is either **done** or **absorbed above**:
| MAINTENANCE item | Status |
|---|---|
| Canonical tree = `echo-memory.plugin.src/` | Done (0.x) |
| `codex plugin/` decide regenerate-or-delete | **Deleted 2026-07-03**; regenerate-as-build-target tracked in §3 |
| Prune historical `.plugin` zips; gitignore policy | §1 |
| `.gitignore` basics | Done (1.0) |
| plugin.json `license` + version | Done (1.0) |
| plugin.json `homepage` | `TODO-1.6.md` §2 (URL now decided) |
| Register commands in manifest | Superseded by the skills migration (`TODO-1.6.md` §1, finished in §2 here) |
| `run_eval.py` refresh to current metrics | §4 |
| Secret handling (M1) | Done — superseded by `echo_config` in 1.3/1.4; committed tree ships zero credentials |
| Pre-1.0 checklist | Done/obsolete (1.0 shipped; the `DEFAULT_KEY` line it referenced was removed in 1.3) |
Build flags (`--bake-key`, `--strip-key`, `--no-pointer`, `--outdir`) are documented
in `build.py`'s docstring and the README Configuration section.
+79
View File
@@ -0,0 +1,79 @@
# echo-memory — v1.6 TODO
> Working list for the next minor release. Started 2026-07-03, right after 1.5.1
> shipped. **Add findings from 1.5.x live testing to the "Surfaced in testing"
> section as they come up** — that's what this doc is for.
## 1. Migrate the eight slash commands to the skills format
The installer flags the plugin's `commands/` directory as the legacy format
("Both formats work — consider migrating to skills/*/SKILL.md"). Confirmed against
the official docs (code.claude.com/docs/en/skills.md, plugins.md):
- **Non-breaking.** `commands/echo-save.md``skills/echo-save/SKILL.md` keeps the
invocation name: `/echo-save` works identically. No deprecation date on `commands/`;
this is future-proofing, not a fire.
- **Do it for the features, not the notice:**
- [x] `allowed-tools` per skill — **done 1.6.0** (all eight skills pre-authorize
the resolved `python3`/`python`/`py -3` invocations + the CoWork `ls` probe).
- [x] `disable-model-invocation: true` on the write-heavy entry points
(`/echo-sweep`, `/echo-triage`) — **done 1.6.0**; read-side stays
model-invocable.
- [x] De-duplicate the CoWork `$ECHO` path-resolution block — **resolved 1.6.0 as
not-supported**: per the official skills docs, skill directories are
self-contained (no cross-skill snippet sharing), so the 2-line block stays
per-skill. Docs confirmed same-name skill takes precedence over the legacy
command, so coexistence is safe.
- [x] `argument-hint` carries over as-is; `$ARGUMENTS` substitution unchanged —
**done 1.6.0** (save/recall/reflect).
- [x] Verify a migrated build installs cleanly on desktop **and** in a CoWork session
before deleting `commands/`**verified 2026-07-28** on the baked 1.6.0-jason
artifact, all four checks pass: (1) all eight skills register and take
precedence over the coexisting legacy commands; (2) `allowed-tools` present on
load/health/recall; (3) `disable-model-invocation` confirmed on exactly
sweep+triage; (4) CoWork `$ECHO` fallback resolves via
`/sessions/*/mnt/.remote-plugins/*` and executes. **2.0's `commands/` deletion
is unblocked.** *Known caveat, accepted (CLI is not a normal operating surface
here): the `allowed-tools` globs match the literal script names
(`*echo.py*`/`*vault_lint.py*`) while the bodies invoke via the resolved
`"$ECHO"` variable — if the Claude Code CLI matches permissions before
variable expansion, those skills could still prompt there. Re-confirm on the
CLI if that surface ever matters.*
## 2. plugin.json completeness (from the old MAINTENANCE checklist)
- [x] Add `homepage` / repository URL — **done 1.6.0**: both `homepage` and
`repository` set to `https://git.alwisp.com/jason/echo`.
## 3. Vault follow-ups (noticed during the 1.5.0 dead-link cleanup)
Three of the 27 unwrapped "dead" links were actually **typos pointing at real notes**;
they were unwrapped per instruction but repointing them would restore real graph edges:
- [ ] `projects/active/forgerunner.md` + `_agent/sessions/2026-06-29-2200-forgerunner-v010-ship.md`:
"Gitea CI Docker jobs run on the host label" → the decision
`decisions/by-date/2026-06-29-gitea-ci-docker-jobs-run-on-the-host-lab.md` ("lab", not "label").
- [ ] `projects/active/mpm-brand-voice.md`: "Message Point Media (MPM)" ×3 → the company
note `resources/companies/mpm.md` (or whatever is canonical for MPM).
## 4. Surfaced in 1.5.x testing
*(add items here as the new plugin gets real use)*
- [x] **Lint blind spot: retired dirs masked by the leaf-README route** (found
2026-07-03; **fixed 1.6.0** — retired patterns now checked before routes, with
the `archive/notes/README.md` regression test).
`archive/` and `_agent/outputs/` are retired/unrouted pre-0.6 leftovers that survived
in the live vault holding only their seed READMEs — and the permissive `leaf-readme`
route (`^(.+/)?README\.md$`) matches first, so `vault_lint` never flagged them.
Fix: in the path-membership check, test the *directory* against retired/unknown
patterns even when the only file in it is a README (e.g. evaluate `dirname(path) + "/"`
against retired patterns before letting `leaf-readme` absolve the file), or drop the
seed-README exemption inside retired trees. Add a lint test seeding
`archive/notes/README.md` → must flag `retired-path`.
*Related learning (same cleanup, 2026-07-03):* the REST API **cannot delete
directories** — removing a folder's last file orphans the empty folder on disk
(the REST listing 404s, falsely reading as gone; Obsidian still shows it — manual
deletion required). Documented in `references/api-reference.md` Deleting Files
and in the vault's `resources/references/obsidian-local-rest-api.md`. Any future
"remove retired tree" guidance must include the manual folder-cleanup step.
+111 -30
View File
@@ -2,13 +2,18 @@
"""build.py — package the echo-memory plugin source into a .plugin artifact. """build.py — package the echo-memory plugin source into a .plugin artifact.
Zips the CONTENTS of echo-memory.plugin.src/ at the archive root (the layout the plugin Zips the CONTENTS of echo-memory.plugin.src/ at the archive root (the layout the plugin
loader expects: .claude-plugin/plugin.json, commands/, skills/ all at top level), excluding loader expects: .claude-plugin/plugin.json, hooks/, skills/ all at top level), excluding
dev cruft. The version is read from the manifest, so the output is named automatically. dev cruft. The version is read from the manifest, so the output is named automatically.
Usage: Usage:
python build.py # build echo-memory-<version>.plugin + refresh echo-memory.plugin python build.py # build echo-memory-<version>.plugin + refresh echo-memory.plugin
python build.py --strip-key # ALSO blank echo.py's DEFAULT_KEY -> a token-free artifact # (token-free: ships no credentials; user configures at runtime)
# (requires ECHO_KEY or ~/.echo-memory/credentials at runtime) python build.py --bake-key --from coworker.json [--label alice]
# SECRET-BEARING per-user artifact: bake owner/endpoint/key from a
# config.json (or --owner/--endpoint/--key, or ECHO_* env) into the
# echo_config DEFAULT_* constants. Defaults to dist/ and skips the
# shared pointer. Deliver directly to that one user — NEVER commit it.
python build.py --strip-key # force-blank the DEFAULT_* constants -> guaranteed token-free artifact
python build.py --no-pointer # don't update the echo-memory.plugin "current" pointer python build.py --no-pointer # don't update the echo-memory.plugin "current" pointer
python build.py --outdir dist # write artifacts somewhere other than the repo root python build.py --outdir dist # write artifacts somewhere other than the repo root
@@ -20,6 +25,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import re import re
import sys import sys
import zipfile import zipfile
@@ -35,7 +41,8 @@ for _stream in (sys.stdout, sys.stderr):
REPO = Path(__file__).resolve().parent REPO = Path(__file__).resolve().parent
SRC = REPO / "echo-memory.plugin.src" SRC = REPO / "echo-memory.plugin.src"
MANIFEST = "/".join([".claude-plugin", "plugin.json"]) MANIFEST = "/".join([".claude-plugin", "plugin.json"])
ECHO_PY = "/".join(["skills", "echo-memory", "scripts", "echo.py"]) # The baked DEFAULT_* constants live in echo_config.py (resolution lowest tier).
CONFIG_PY = "/".join(["skills", "echo-memory", "scripts", "echo_config.py"])
EXCLUDE_DIRS = {"__pycache__", ".git"} EXCLUDE_DIRS = {"__pycache__", ".git"}
EXCLUDE_NAMES = {".DS_Store"} EXCLUDE_NAMES = {".DS_Store"}
@@ -44,7 +51,13 @@ FIXED_DATE = (2026, 1, 1, 0, 0, 0) # stable timestamp for reproducible archives
MAX_DESCRIPTION = 500 # plugin-marketplace cap: plugin.json "description" must be UNDER this MAX_DESCRIPTION = 500 # plugin-marketplace cap: plugin.json "description" must be UNDER this
# (it has silently regressed past the limit before — fail the build now) # (it has silently regressed past the limit before — fail the build now)
_KEY_RE = re.compile(r'(DEFAULT_KEY\s*=\s*")([0-9a-fA-F]{16,})(")') # field name -> the constant assigned in echo_config.py
_CONSTS = {"owner": "DEFAULT_OWNER", "endpoint": "DEFAULT_BASE", "key": "DEFAULT_KEY"}
def _const_re(const: str) -> re.Pattern:
"""Match a whole `CONST = <anything>` assignment line."""
return re.compile(r'^(' + re.escape(const) + r'\s*=\s*).*$', re.M)
def included_files() -> list[Path]: def included_files() -> list[Path]:
@@ -60,24 +73,33 @@ def included_files() -> list[Path]:
return out return out
def file_bytes(path: Path, arcname: str, strip_key: bool) -> bytes: def file_bytes(path: Path, arcname: str, bake: dict | None, strip_key: bool) -> bytes:
"""Return the bytes to archive. For echo_config.py, optionally rewrite the
DEFAULT_* constants — inject `bake` values, or blank them with --strip-key."""
data = path.read_bytes() data = path.read_bytes()
if strip_key and arcname == ECHO_PY: if arcname != CONFIG_PY or (bake is None and not strip_key):
text = data.decode("utf-8") return data
new, n = _KEY_RE.subn(r'\1\3', text) # collapse the hex value to "" text = data.decode("utf-8")
if n: for field, const in _CONSTS.items():
text = new.replace('DEFAULT_KEY = ""', repl = json.dumps(bake[field]) if bake is not None else '""'
'DEFAULT_KEY = "" # stripped at build time — set ECHO_KEY or run write-key') # function replacement so backslashes in repl (e.g. \uXXXX) stay literal
data = text.encode("utf-8") text, n = _const_re(const).subn(lambda m, r=repl: m.group(1) + r, text, count=1)
return data if n != 1:
raise RuntimeError(f"build: could not find `{const} = ...` in {arcname}")
return text.encode("utf-8")
def token_present(path: Path) -> bool: def token_present(path: Path) -> bool:
m = _KEY_RE.search(path.read_text(encoding="utf-8")) """True if any DEFAULT_* constant in echo_config.py holds a non-empty value."""
return bool(m and m.group(2)) text = path.read_text(encoding="utf-8")
for const in _CONSTS.values():
m = re.search(r'^' + re.escape(const) + r'\s*=\s*"(.*)"\s*$', text, re.M)
if m and m.group(1):
return True
return False
def build(out: Path, files: list[Path], strip_key: bool) -> int: def build(out: Path, files: list[Path], bake: dict | None, strip_key: bool) -> int:
if out.exists(): if out.exists():
out.unlink() out.unlink()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
@@ -86,23 +108,73 @@ def build(out: Path, files: list[Path], strip_key: bool) -> int:
info = zipfile.ZipInfo(arc, date_time=FIXED_DATE) info = zipfile.ZipInfo(arc, date_time=FIXED_DATE)
info.compress_type = zipfile.ZIP_DEFLATED info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16 info.external_attr = 0o644 << 16
z.writestr(info, file_bytes(p, arc, strip_key)) z.writestr(info, file_bytes(p, arc, bake, strip_key))
return out.stat().st_size return out.stat().st_size
def bake_values(args) -> dict:
"""Resolve owner/endpoint/key for --bake-key from --owner/--endpoint/--key,
else --from <config.json>, else ECHO_OWNER/ECHO_BASE/ECHO_KEY env."""
src = {}
if args.from_file:
fp = Path(args.from_file).expanduser()
if not fp.exists():
raise RuntimeError(f"build: --from file not found: {fp}")
try:
loaded = json.loads(fp.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise RuntimeError(f"build: --from file is not valid JSON ({exc})")
if isinstance(loaded, dict):
src = loaded
vals = {
"owner": args.owner or src.get("owner") or os.environ.get("ECHO_OWNER") or "",
"endpoint": (args.endpoint or src.get("endpoint") or os.environ.get("ECHO_BASE") or "").rstrip("/"),
"key": args.key or src.get("key") or os.environ.get("ECHO_KEY") or "",
}
missing = [f for f in ("endpoint", "key") if not vals[f]]
if missing:
raise RuntimeError(
"build: --bake-key needs a real endpoint and key — missing "
+ ", ".join(missing) + ". Provide --from <config.json>, the --owner/"
"--endpoint/--key flags, or set ECHO_OWNER/ECHO_BASE/ECHO_KEY.")
if "your-obsidian-rest-endpoint" in vals["endpoint"] or vals["key"].startswith("<"):
raise RuntimeError("build: --bake-key got the template placeholders, not real values.")
return vals
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Build the echo-memory .plugin artifact") ap = argparse.ArgumentParser(description="Build the echo-memory .plugin artifact")
ap.add_argument("--bake-key", action="store_true",
help="bake a user's owner/endpoint/key into the artifact (secret-bearing — never commit)")
ap.add_argument("--from", dest="from_file", metavar="FILE",
help="config.json to read owner/endpoint/key from when baking")
ap.add_argument("--owner", help="baked owner (overrides --from / env)")
ap.add_argument("--endpoint", help="baked endpoint (overrides --from / env)")
ap.add_argument("--key", help="baked key (overrides --from / env)")
ap.add_argument("--label", help="suffix for the artifact name, e.g. --label alice")
ap.add_argument("--strip-key", action="store_true", ap.add_argument("--strip-key", action="store_true",
help="blank echo.py DEFAULT_KEY so no token ships in the artifact") help="force-blank the DEFAULT_* constants so no token ships in the artifact")
ap.add_argument("--no-pointer", action="store_true", ap.add_argument("--no-pointer", action="store_true",
help="do not refresh the echo-memory.plugin 'current' pointer") help="do not refresh the echo-memory.plugin 'current' pointer")
ap.add_argument("--outdir", default=str(REPO), help="output directory (default: repo root)") ap.add_argument("--outdir", help="output directory (default: repo root; dist/ when --bake-key)")
args = ap.parse_args(argv) args = ap.parse_args(argv)
if args.bake_key and args.strip_key:
print("build: --bake-key and --strip-key are mutually exclusive", file=sys.stderr)
return 2
if not SRC.is_dir(): if not SRC.is_dir():
print(f"build: source tree not found at {SRC}", file=sys.stderr) print(f"build: source tree not found at {SRC}", file=sys.stderr)
return 1 return 1
bake = None
if args.bake_key:
try:
bake = bake_values(args)
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
manifest_path = SRC / ".claude-plugin" / "plugin.json" manifest_path = SRC / ".claude-plugin" / "plugin.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
version = manifest["version"] version = manifest["version"]
@@ -118,22 +190,31 @@ def main(argv: list[str] | None = None) -> int:
print(f"build: {MANIFEST} missing from the archive root — aborting", file=sys.stderr) print(f"build: {MANIFEST} missing from the archive root — aborting", file=sys.stderr)
return 1 return 1
outdir = Path(args.outdir) # Baked artifacts are secret-bearing: default them into dist/ (gitignored) and
# never touch the shared, committable echo-memory.plugin pointer.
default_outdir = (REPO / "dist") if bake else REPO
outdir = Path(args.outdir) if args.outdir else default_outdir
outdir.mkdir(parents=True, exist_ok=True) outdir.mkdir(parents=True, exist_ok=True)
targets = [outdir / f"echo-memory-{version}.plugin"]
if not args.no_pointer: label = f"-{args.label}" if args.label else ""
targets = [outdir / f"echo-memory-{version}{label}.plugin"]
if not args.no_pointer and not bake:
targets.append(outdir / "echo-memory.plugin") targets.append(outdir / "echo-memory.plugin")
for out in targets: for out in targets:
size = build(out, files, args.strip_key) size = build(out, files, bake, args.strip_key)
print(f"built {out.name} ({len(files)} entries, {size:,} bytes)") print(f"built {out.name} ({len(files)} entries, {size:,} bytes)")
echo_src = SRC / "skills" / "echo-memory" / "scripts" / "echo.py" if bake:
if args.strip_key: who = bake.get("owner") or "(no owner)"
print("note: --strip-key set — artifact is token-free; runtime needs ECHO_KEY or a credentials file.") print(f"note: --bake-key baked credentials for {who} into {CONFIG_PY}.")
elif token_present(echo_src): print("WARNING: this artifact carries a vault key — deliver it directly to that one "
print("WARNING: the baked-in DEFAULT_KEY is present in this artifact — do not share it. " "user and NEVER commit, push, or publish it (dist/ is gitignored).")
"Set ECHO_KEY everywhere (API-KEY-SETUP.md), then build with --strip-key.") elif args.strip_key:
print("note: --strip-key set — artifact is token-free; user configures owner/endpoint/key at runtime.")
elif token_present(SRC / "skills" / "echo-memory" / "scripts" / "echo_config.py"):
print("WARNING: a DEFAULT_* constant in echo_config.py is non-empty — this artifact carries a "
"secret. Do not commit it. Build from a clean source tree, or use --strip-key.")
return 0 return 0
@@ -1,20 +0,0 @@
{
"name": "echo-memory-local",
"interface": {
"displayName": "ECHO Memory Local"
},
"plugins": [
{
"name": "echo-memory",
"source": {
"source": "local",
"path": "./plugins/echo-memory"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
@@ -1,27 +0,0 @@
{
"name": "echo-memory",
"version": "0.7.1-codex.0+codex.20260620053750",
"description": "Persistent memory for Codex via the ECHO Obsidian vault over the Local REST API.",
"author": {
"name": "Jason"
},
"skills": "./skills/",
"interface": {
"displayName": "ECHO Memory",
"shortDescription": "Use the ECHO Obsidian vault as Codex memory.",
"longDescription": "Loads, writes, triages, and health-checks Jason's ECHO Obsidian vault using a status-checked Local REST API client and the plugin's bundled vault contract, routing map, templates, bootstrap, and migration logic.",
"developerName": "Jason",
"category": "Productivity",
"capabilities": [
"Memory",
"Obsidian",
"Automation"
],
"defaultPrompt": [
"Load my ECHO memory.",
"Save this to ECHO memory.",
"Run ECHO vault health."
],
"brandColor": "#2563EB"
}
}
@@ -1,23 +0,0 @@
# ECHO Memory Codex Plugin
Codex-native version of the ECHO memory plugin. It exposes one skill, `echo-memory`, with the vault contract, routing map, scaffold, and Python operational scripts needed to read, write, bootstrap, migrate, and health-check Jason's ECHO Obsidian vault.
## Runtime
Set `ECHO_KEY` before making live vault calls. The plugin does not ship a bearer token.
Optional:
- `ECHO_BASE`, default `https://echoapi.alwisp.com`
- `ECHO_TODAY`, for deterministic date handling
- `ECHO_VERIFY`, default `1`
- `ECHO_LOCK_TTL`, default `900`
Primary scripts live in `skills/echo-memory/scripts/`:
- `echo.py`
- `bootstrap.py`
- `migrate.py`
- `vault_lint.py`
- `routing.json`
@@ -1,141 +0,0 @@
---
name: echo-memory
description: Use Jason's ECHO Obsidian vault as persistent memory in Codex. Use whenever Jason asks to remember, save, note, log, capture, recall, load profile/context, check prior notes, triage the ECHO inbox, run vault health, or pick up previous work. Also use proactively at the start of substantive work sessions to load context and at the end to log outcomes. Do not use for unrelated local-file lookup, email lookup, timed reminders, generic second-brain advice, memory meaning RAM, or another person's vault.
---
# ECHO Memory
Use the **ECHO** Obsidian vault as Jason's persistent memory across Codex sessions. The vault is accessed through the Obsidian Local REST API at `https://echoapi.alwisp.com`.
This Codex port keeps the existing ECHO vault contract, routing map, scaffold, and safety rules, but the executable runtime is Python-first so it works in Codex on Windows without `/bin/bash`.
## Required Environment
Set `ECHO_KEY` in the environment before any live vault operation. The plugin intentionally does not ship a bearer token.
Optional environment variables:
- `ECHO_BASE`: defaults to `https://echoapi.alwisp.com`
- `ECHO_TODAY`: overrides today's date for deterministic writes and health checks
- `ECHO_VERIFY`: defaults to `1`; read-back verifies `put`
- `ECHO_LOCK_TTL`: defaults to `900` seconds
## Runtime
Prefer the bundled Python client for every vault read or write:
```bash
python skills/echo-memory/scripts/echo.py get _agent/memory/semantic/operator-preferences.md
python skills/echo-memory/scripts/echo.py append inbox/captures/inbox.md "- 2026-06-19: Jason wants this captured."
python skills/echo-memory/scripts/echo.py patch _agent/context/current-context.md replace heading "Current Context::Scope" scope.md
python skills/echo-memory/scripts/echo.py scope show
python skills/echo-memory/scripts/echo.py scope set "Converting ECHO memory into a Codex plugin"
```
The client centralizes auth injection, HTTP status checks, one bounded retry on transient 5xx/transport errors, idempotent append, read-back verification after `put`, frontmatter patching, advisory lock operations, and scope switching.
On Windows/PowerShell, prefer ASCII text in inline command arguments. For multiline markdown or text containing non-ASCII punctuation, write a UTF-8 temp file and pass the file path to `put` or `patch`; shell pipelines can down-convert characters before the client receives them.
Use these scripts for operational flows:
- `scripts/echo.py`: validated API client
- `scripts/bootstrap.py`: idempotent vault setup/repair from bundled `scaffold/`
- `scripts/migrate.py`: dry-run by default; use `--apply` for schema moves/deletes
- `scripts/vault_lint.py`: read-only vault health invariant checker
- `scripts/closeout.py`: writes the daily Agent Log entry, session log, and heartbeat pointer
- `scripts/routing.json`: canonical machine-readable routing manifest
## Load Memory
At the start of any substantive work session, load memory. Issue the independent reads in parallel when practical:
1. `_agent/echo-vault.md`: bootstrap marker and schema version
2. `_agent/memory/semantic/operator-preferences.md`: Jason profile/preferences
3. `_agent/context/current-context.md`: active scope and scope history
4. `_agent/heartbeat/last-session.md`, then the pointed session log if present
5. `journal/daily/YYYY-MM-DD.md`: today's note; 404 is acceptable
6. `inbox/captures/inbox.md`: inbox depth probe; 404 is acceptable
After loading, reconcile in one short line:
- If old inbox captures exist, surface the count and ask whether to triage.
- If the recorded scope diverges from the current request, switch it before working with `scope set`.
Do not read the whole vault. Search targeted project or topic names when needed.
## Save Memory
Write when Jason asks to remember, save, note, log, capture, or when a durable fact, preference, commitment, decision, or session outcome should persist.
Rules:
- Route writes through `references/routing-map.md`; if no destination fits, capture to `inbox/captures/inbox.md`.
- Search first before creating any slug-addressed note. Search both the slug and human title.
- Read before append. `append` does this automatically and skips exact duplicate lines.
- Preserve `created:` when merging. Bump `updated:` only for substantive changes.
- Write in third person about Jason.
- Never store secrets or API keys in a vault note.
- Never put `[[wikilinks]]` in frontmatter; use the body `## Related` section.
- Do not delete notes unless Jason explicitly asks and the destructive action is clearly safe.
After any substantive memory write, run the closeout workflow unless Jason explicitly says not to log it. This is separate from routing: ordinary writes update their canonical notes, and closeout records that the session happened.
```bash
python skills/echo-memory/scripts/closeout.py \
--slug "brief-session-topic" \
--goal "What this session was for." \
--actions "What was changed or recorded." \
--next-step "What to do first next time." \
--daily-line "One-line Agent Log entry." \
--related "[[path/to/relevant-note]]"
```
The helper ensures `journal/daily/YYYY-MM-DD.md`, appends under `## Agent Log`, writes `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md`, and updates `_agent/heartbeat/last-session.md`.
## Triage Inbox
For "triage ECHO inbox", read `inbox/captures/inbox.md`, identify dated captures older than about 7 days that have not been routed, and offer to route them. For accepted items, write to the canonical destination and append an audit line to `inbox/processing-log/YYYY-MM-DD.md`. Do not delete the original capture unless Jason explicitly asks.
## Vault Health
For "ECHO health" or monthly maintenance, run:
```bash
ECHO_TODAY=<current-date> python skills/echo-memory/scripts/vault_lint.py
```
Exit codes:
- `0`: clean
- `1`: violations found
- `2`: vault unreachable
- `3`: not bootstrapped
Summarize findings by category. Do not auto-fix without Jason's go-ahead.
## Bootstrap And Migrate
If `_agent/echo-vault.md` is missing, run a dry run first:
```bash
python skills/echo-memory/scripts/bootstrap.py --dry-run
```
Then run without `--dry-run` only if Jason wants the vault bootstrapped or repaired. The bootstrap is additive and probe-before-write.
If the marker schema is older than this plugin, run:
```bash
python skills/echo-memory/scripts/migrate.py
```
That prints a dry-run plan. Use `--apply` only with explicit approval for moves/deletes.
## References
- Durable operating contract: `references/operating-contract.md`
- Bootstrap/repair/migration details: `references/bootstrap.md`
- Vault layout and frontmatter: `references/vault-layout.md`
- Canonical routing map: `references/routing-map.md`
- REST API reference: `references/api-reference.md`
- Session log template: `references/session-log-template.md`
@@ -1,216 +0,0 @@
# ECHO — Obsidian Local REST API Reference
Server: `https://echoapi.alwisp.com` (reverse proxy → backend Obsidian Local REST API)
Auth header: `Authorization: Bearer <set ECHO_KEY in the environment>`
The endpoint has 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. The recipes here are the underlying mechanics and the fallback. **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.
---
## Reading Files
```bash
# Read any file by vault path
curl -s \
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
"https://echoapi.alwisp.com/vault/_agent/context/current-context.md"
```
Returns raw file content (text/markdown). On 404, the file does not exist.
```bash
# Read a specific heading's content only
curl -s \
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
```
Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
```
/vault/path/to/note.md/heading/Work%3A%3AMeetings
```
---
## Listing Directories
```bash
# List contents of a directory (trailing slash required)
curl -s \
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
"https://echoapi.alwisp.com/vault/_agent/sessions/"
```
Returns JSON: `{ "files": [...], "folders": [...] }`.
---
## Appending Content (POST)
`POST` appends to the **end** of an existing file. Creates the file if it doesn't exist.
```bash
cat > /tmp/obs_entry.md << 'OBSEOF'
- 2026-06-05: your entry here
OBSEOF
curl -s -X POST \
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
-H "Content-Type: text/markdown" \
--data-binary @/tmp/obs_entry.md \
"https://echoapi.alwisp.com/vault/inbox/captures/inbox.md"
```
---
## Creating or Overwriting Files (PUT)
`PUT` creates a new file or fully overwrites an existing one. Intermediate directories are created automatically.
```bash
cat > /tmp/obs_file.md << 'OBSEOF'
---
type: session-log
status: complete
created: 2026-06-05
updated: 2026-06-05
tags: [agent, session]
agent_written: true
source_notes: []
---
# content here
## Related
- [[projects/active/some-project]]
OBSEOF
curl -s -X PUT \
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
-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"
```
---
## Patching a Specific Section (PATCH)
`PATCH` edits inside a file without rewriting it. Target and operation are set via headers.
### Append under a heading
**Heading targets must be the FULL heading path, `::`-delimited from the top-level heading.** A bare subheading name returns `400 invalid-target` (errorCode 40080). Example: `## Fact / Pattern` nested under `# Operator Preferences``Target: Operator Preferences::Fact / Pattern`. Percent-encode non-ASCII characters (e.g. `H%C3%A9llo`); spaces are fine.
```bash
cat > /tmp/obs_patch.md << 'OBSEOF'
Jason prefers concise status updates — lead with the decision.
OBSEOF
curl -s -X PATCH \
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
-H "Operation: append" \
-H "Target-Type: heading" \
-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"
```
### Discover heading / block / frontmatter targets
When unsure of the exact heading path, GET the note with the document-map Accept header:
```bash
curl -s \
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
-H "Accept: application/vnd.olrapi.document-map+json" \
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md"
```
Returns `{ "headings": [...], "blocks": [...], "frontmatterFields": [...] }`. Copy the heading string verbatim into `Target`.
### Replace a heading's content entirely
Same call with `Operation: replace` — e.g. to refresh a project's `Project Name::Status`.
### Prepend under a heading
Same call with `Operation: prepend`.
### Patch a frontmatter field
```bash
curl -s -X PATCH \
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
-H "Operation: replace" \
-H "Target-Type: frontmatter" \
-H "Target: updated" \
-H "Content-Type: application/json" \
--data '"2026-06-05"' \
"https://echoapi.alwisp.com/vault/projects/active/vault-foundation.md"
```
---
## Searching
```bash
curl -s -X POST \
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
"https://echoapi.alwisp.com/search/simple/?query=weekly+review"
```
Returns an array of `{ filename, score, matches: [{ context, match }] }`.
---
## Deleting Files
```bash
curl -s -X DELETE \
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
"https://echoapi.alwisp.com/vault/inbox/imports/old-note.md"
```
Only on explicit operator request. Deletion is destructive.
---
## URL-Encoding Notes
- Path separators (`/`) in the vault path are **literal** — do not encode them.
- Spaces in filenames or heading targets in a URL: use `%20`.
- Nested heading levels in a URL path: use `%3A%3A` for `::`.
- Heading text in the `Target:` header: the **full heading path** joined by `::` (e.g. `Operator Preferences::Fact / Pattern`), no `#`; spaces are fine; percent-encode non-ASCII.
---
## Memory Routing Map
| Situation | Vault path | Method |
|-----------|-----------|--------|
| Quick capture / unsorted | `inbox/captures/inbox.md` (date-prefixed line) | POST |
| Raw imported material | `inbox/imports/` | PUT |
| Operator preference / durable fact | `_agent/memory/semantic/operator-preferences.md` | PATCH |
| Other durable facts, patterns | `_agent/memory/semantic/<slug>.md` | PUT |
| Event record (what happened) | `_agent/memory/episodic/<slug>.md` | PUT |
| Short-lived, time-boxed state | `_agent/memory/working/<slug>.md` | PUT |
| Task-scoped context / focus | `_agent/context/current-context.md` | PATCH / PUT |
| Working-session log | `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md` | PUT |
| Long-running project state | `projects/<lifecycle>/<slug>.md` (lifecycle: `incubating``active``on-hold`/`archived`; folder and `status:` MUST agree) | PUT + PATCH |
| Ongoing area of responsibility (standing domain, no end state) | `areas/<domain>/<slug>.md` (`<domain>`: `business`/`personal`/`learning`/`systems`) | PUT |
| Non-obvious decision (ADR) | `decisions/by-date/YYYY-MM-DD-<slug>.md` (mirror only into an existing project note's `## Key Decisions`; otherwise skip) | PUT |
| Person context | `resources/people/<name>.md` | PUT / PATCH |
| Company / organization context | `resources/companies/<slug>.md` | PUT / PATCH |
| Concept / reference note | `resources/concepts/` or `resources/references/` | PUT |
| Meeting notes / call recap | `resources/meetings/YYYY-MM-DD-<slug>.md` | PUT |
| Skill / plugin capability entry (catalog, not build work) | `_agent/skills/active/<slug>.md` (→ `archived/` when retired) | PUT |
| Daily activity / Agent Log | `journal/daily/YYYY-MM-DD.md` | POST / PATCH |
| Journal rollup | `journal/{weekly/YYYY-Www,monthly/YYYY-MM,quarterly/YYYY-Qn,annual/YYYY}.md` (weekly = opt-in on first session of a new ISO week; monthly = offered with Vault Health; quarterly/annual = manual) | PUT |
| Vault-health audit (agent self-maintenance) | `_agent/health/YYYY-MM-vault-health.md` (monthly; NOT a journal entry) | PUT |
| Session-end orientation pointer | `_agent/heartbeat/last-session.md` (one line, overwritten each session end) | PUT |
| Bootstrap marker (plugin-owned) | `_agent/echo-vault.md` (`schema_version`, bootstrap date) — the "is this vault set up?" probe | GET / PUT |
**Slug rules:** kebab-case, ASCII, ~40 chars max. Every file carries canonical frontmatter (see `vault-layout.md`).
@@ -1,140 +0,0 @@
# Bootstrap & Repair
The **plugin is the single source of truth** for ECHO's structure. Everything needed to stand up a vault ships in this skill under `scaffold/` — there is no dependency on any in-vault control doc and no external/local re-seed path. This makes the vault portable: point the REST API at any empty Obsidian vault, run this procedure, and it becomes a working ECHO vault.
The vault holds **data only**. It carries no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md`. The "is this vault set up?" signal is a small marker file, `_agent/echo-vault.md`.
## Quick path — run the scripts
Bootstrap, repair, and migration are deterministic scripts; prefer them over running the curl steps by hand. They resolve the scaffold relative to their own location, so they work regardless of the caller's CWD:
```bash
SCRIPTS="the scripts directory beside this skill"
python "$SCRIPTS/bootstrap.py" --dry-run # preview what would be seeded
python "$SCRIPTS/bootstrap.py" # idempotent, additive — fills only what is missing (also the repair path)
python "$SCRIPTS/migrate.py" # plan a schema migration (dry-run)
python "$SCRIPTS/migrate.py" --apply # perform the migration (moves/deletes, after review)
```
`bootstrap.py` writes through `echo.py`, so every step is status-checked and the marker is written last. The manual steps below document what the script does (and serve as a fallback if the script can't run).
```
AUTH="Authorization: Bearer <set ECHO_KEY in the environment>"
BASE="https://echoapi.alwisp.com"
```
---
## Probe — is the vault bootstrapped?
At session start, GET the marker:
```bash
curl -s -o /dev/null -w "%{http_code}" -H "$AUTH" "$BASE/vault/_agent/echo-vault.md"
```
- **200** → bootstrapped. Read the marker's `schema_version`; if it is **less than** the plugin's current schema (2), run a migration pass (see *Migrations* below), otherwise proceed straight to the loading procedure in `SKILL.md`.
- **404** → empty/unconfigured vault. Run **Fresh bootstrap** below. (If you expected an existing vault, confirm with the operator once that the REST API is pointed at the right vault before seeding.)
---
## Fresh bootstrap (empty vault)
Idempotent and additive. **Never overwrite an existing file** — GET-probe each path and skip any that already returns `200`. The marker is written **last**, so the vault is only flagged "set up" after every piece is in place.
Throughout, `{{DATE}}` means today's date (`YYYY-MM-DD`), resolved from the conversation's `currentDate`. Substitute it before PUTting any scaffold file or anchor seed.
### 1. Folder tree
The REST API auto-creates parent directories on PUT, so creating the tree = seeding a file into each leaf. Obsidian and git both ignore empty dirs, so drop a one-line `README.md` into any leaf that wouldn't otherwise receive a file. Required tree (leaves marked `→ README`):
```
inbox/captures inbox/imports inbox/processing-log
journal/daily journal/weekly journal/monthly journal/quarterly journal/annual journal/templates
projects/active projects/incubating projects/on-hold projects/archived
areas/business areas/personal areas/learning areas/systems
resources/concepts resources/references resources/people resources/companies resources/meetings
decisions/by-date
_agent/context _agent/memory/working _agent/memory/episodic _agent/memory/semantic
_agent/sessions _agent/health _agent/templates _agent/heartbeat
_agent/skills/active _agent/skills/archived
```
> `decisions/by-project/` is intentionally **not** created — it is retired. A project-relevant decision is mirrored as a `[[wikilink]]` under that project's `## Key Decisions` heading instead.
>
> `reviews/` is **not** created — it is retired. Journal rollups (weekly/monthly/quarterly/annual) live under `journal/`; the monthly vault-health audit lives under `_agent/health/`.
A leaf README is just a one-liner, e.g.:
```bash
printf '# %s\n\nMemory vault folder. See the echo-memory plugin for conventions.\n' "captures" \
| curl -s -X PUT -H "$AUTH" -H "Content-Type: text/markdown" --data-binary @- \
"$BASE/vault/inbox/captures/README.md"
```
### 2. Templates
PUT every file under this skill's `scaffold/templates/` to its mirrored vault path. The tree mirrors 1:1:
| Scaffold file | Vault path |
|---|---|
| `scaffold/templates/_agent/templates/session-log-template.md` | `_agent/templates/session-log-template.md` |
| `scaffold/templates/_agent/templates/context-bundle-template.md` | `_agent/templates/context-bundle-template.md` |
| `scaffold/templates/_agent/templates/working-memory-template.md` | `_agent/templates/working-memory-template.md` |
| `scaffold/templates/_agent/templates/semantic-memory-template.md` | `_agent/templates/semantic-memory-template.md` |
| `scaffold/templates/journal/templates/daily-note-template.md` | `journal/templates/daily-note-template.md` |
| `scaffold/templates/journal/templates/weekly-review-template.md` | `journal/templates/weekly-review-template.md` |
| `scaffold/templates/projects/project-template.md` | `projects/project-template.md` |
| `scaffold/templates/decisions/decision-template.md` | `decisions/decision-template.md` |
Templates keep their Obsidian Templater tokens (`{{date:YYYY-MM-DD}}` etc.) verbatim — those are resolved by Templater / by the skill's daily-note routine, not at seed time.
Resolve scaffold paths against the skill directory — **never a bare relative `@scaffold/...`**, which assumes the caller's CWD is the skill dir and silently sends an empty body otherwise:
```bash
SCAFFOLD="the scaffold directory beside this skill"
curl -s -X PUT -H "$AUTH" -H "Content-Type: text/markdown" \
--data-binary @"$SCAFFOLD/templates/journal/templates/daily-note-template.md" \
"$BASE/vault/journal/templates/daily-note-template.md"
```
### 3. Anchor seeds (only if absent — no fabricated facts)
Substitute `{{DATE}}` → today, then PUT each:
| Scaffold file | Vault path |
|---|---|
| `scaffold/anchors/operator-preferences.seed.md` | `_agent/memory/semantic/operator-preferences.md` |
| `scaffold/anchors/current-context.seed.md` | `_agent/context/current-context.md` |
| `scaffold/anchors/inbox.seed.md` | `inbox/captures/inbox.md` |
The operator-preferences seed is deliberately empty — **do not invent preferences.** Real facts accrue through use.
### 4. Vault README (human signpost)
Substitute `{{DATE}}` if present, then PUT `scaffold/README.vault.md``/vault/README.md`. This is the only human-facing control doc in the vault and is **not** read by the agent for routing.
### 5. Marker (write last)
Substitute `{{DATE}}`, then PUT `scaffold/echo-vault.md``/vault/_agent/echo-vault.md`. Once this returns `200`, the vault is bootstrapped.
### 6. First-run trace
Create today's daily note from `journal/templates/daily-note-template.md` (resolve the `{{date:…}}` tokens to today), append a one-line `## Agent Log` entry noting the bootstrap, and write a session log in `_agent/sessions/YYYY-MM-DD-HHMM-bootstrap.md`. Tell the operator briefly what was created.
---
## Repair (existing vault, something missing)
Run the same steps 15, but GET-probe each path first and **only create what is missing**. Never overwrite. If a file exists but looks malformed, flag it in the session log rather than replacing it. Repair is safe to run any time and is the right response if the loading procedure hits an unexpected `404` on a structural path.
---
## Migrations (`schema_version` mismatch)
`scripts/migrate.py` automates this: it reads the marker's `schema_version`, applies each intervening migration (idempotent, additive; destructive steps gated behind `--apply` and printed first), and stamps the marker at the end. Run it dry-run, review the plan, then `--apply`. The steps below are what it encodes — and the manual fallback.
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,45 +0,0 @@
# ECHO — Operating Contract
The durable, client-independent contract for any agent operating against the ECHO vault. These principles and safety rules formerly lived in the vault's `CLAUDE.md`; they now live in the plugin so they survive regardless of what is (or isn't) in the vault. Day-to-day *procedure* — loading order, search-first, triage, scope switching, PATCH/append rules — is owned by `SKILL.md`. This file holds the things that don't change between sessions or clients.
## What this agent is
You are an agent operating against an Obsidian vault that functions as a shared, long-term memory substrate for human work, Codex / CoWork sessions, and future REST/MCP clients. Your role is to read context, synthesize across notes, produce structured outputs, update memory carefully, and leave durable traces in the vault rather than keeping important state only in the conversation. The vault is the **system of record**, not a scratchpad.
## Core principles
- Treat the vault as the system of record for long-term memory.
- Prefer Markdown, YAML frontmatter, wiki-links, and stable folder conventions over proprietary structures.
- Write notes so both humans and agents can understand them without hidden context.
- Preserve history instead of silently overwriting important decisions, summaries, or inferred preferences.
- Keep agent-managed content (`agent_written: true` + `source_notes`) clearly separated from human-authored content.
- Default to **additive updates, explicit status changes, and traceable summaries.**
- Optimize for portability: the same vault should work across Codex, CoWork, MCP-compatible tools, and REST API clients.
## Memory model (where things live)
- **Working** → `_agent/memory/working/` — transient, time-boxed.
- **Episodic** → `_agent/memory/episodic/` — what happened, when.
- **Semantic** → `_agent/memory/semantic/` — durable facts, patterns, preferences (`operator-preferences.md`).
- **Context bundles** → `_agent/context/` — task-focused reading lists and active state.
## Safety rules
- Do not fabricate facts, relationships, or prior decisions.
- Do not mass-restructure the vault unless explicitly asked.
- Do not delete notes unless deletion is explicitly requested and clearly safe.
- Do not store secrets or API keys inside the vault, and never write the API key into a vault note.
- Default to additive updates and explicit status changes over destructive edits.
## REST/API readiness
Assume clients may operate without filesystem access, through the Obsidian Local REST API. Keep paths predictable, frontmatter parseable, titles stable, and all state stored in notes rather than hidden conversation memory. Structure must stay portable: a fresh, empty vault is brought fully online by the plugin's bootstrap (`references/bootstrap.md`), so nothing essential should depend on files existing in the vault ahead of time.
## Concurrency (shared vault)
The vault is a **shared** substrate — Codex, CoWork, and other REST/MCP clients may operate on it concurrently. The REST API offers no transactions, so writers coordinate cooperatively:
- Single-line, overwrite-style files (`_agent/heartbeat/last-session.md`, `current-context.md::Scope`) and append targets (`inbox.md`, `## Agent Log`) assume **one writer at a time**. Two sessions writing at once can clobber or duplicate.
- Before a burst of writes in a session that may overlap another, take the advisory lock (`echo.py lock <id>``_agent/locks/vault.lock`) and release it at session end. The lock is cooperative with a TTL (stale locks are reclaimable); it is a courtesy, not a hard mutex.
- Idempotent append (read-before-POST, via `echo.py append`) is the second line of defense against duplicate lines from retries or overlapping sessions.
- Status-check every write. A write that returns `>= 400` did **not** land — surface it rather than assuming success.
@@ -1,113 +0,0 @@
# ECHO Routing Map
**This document is canonical and complete.** Every write destination in the vault appears here exactly once, with the condition that routes content to it, what lands there, and why it is distinct from its neighbours. The rule for the whole system: **if a path is not in this map, nothing is written to it.** A path that cannot justify its separateness from a neighbour is a merge candidate, not a valid destination.
Views of the same truth: `scripts/routing.json` is the **machine-readable canonical source** (one route per destination, as a regex pattern + trigger + reason); `vault_lint.py` loads it and flags any vault path that matches no route (and any write to a retired path). This prose map is the human-readable authority and must stay in sync with `routing.json`. The `SKILL.md` *Where to Write* table is the quick-reference and `vault-layout.md` is the physical tree. When they disagree, `routing.json` + this map win and the others are fixed to match.
## How to read a row
- **Trigger** — the observable condition that sends content here. If two rows could both fire, the more specific trigger wins.
- **What lands** — the unit of content written.
- **Distinct because** — the one reason this path is not merged into its nearest neighbour. This is the load-bearing column; a row without it is a bug.
- **Method** — the dominant REST verb (see `api-reference.md` for mechanics).
---
## inbox/ — unsorted intake
| Path | Trigger | What lands | Distinct because | Method |
|------|---------|------------|------------------|--------|
| `inbox/captures/inbox.md` | "Save this, sort later" — destination genuinely unknown at capture time | One date-prefixed line | The only path whose contract is *deferred routing*; everything else here has a known home | POST |
| `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.
## journal/ — the one append-only time-series stream
Rollups are coarser-grained journal entries over the same timeline, so they live in the same tree. There is no separate `reviews/` tree.
| Path | Trigger | What lands | Distinct because | Method |
|------|---------|------------|------------------|--------|
| `journal/daily/YYYY-MM-DD.md` | First agent activity on a given day | `## Agent Log` line(s) + day notes | Finest grain; the only journal note PATCHed repeatedly within its period | PATCH (auto-create) |
| `journal/weekly/YYYY-Www.md` | First substantive session of a new ISO week — **opt-in**, offer first | Light digest: open `active/` threads, aging inbox, week's scope changes | Coarser than daily, finer than monthly; ISO-week grain | PUT |
| `journal/monthly/YYYY-MM.md` | First substantive session of a new month — offered alongside Vault Health | Month digest, same shape as weekly | Month grain; separate cadence and trigger from weekly | PUT |
| `journal/quarterly/YYYY-Qn.md` | **Manual / on request only** | Quarter-scale narrative review | Strategic grain; never auto-fires | PUT |
| `journal/annual/YYYY.md` | **Manual / on request only** | Year-scale narrative review | Coarsest grain; never auto-fires | PUT |
| `journal/templates/` | Bootstrap only (seeded from plugin masters) | Note templates | Holds templates, not journal content; never a runtime routing target | PUT (seed) |
## projects/ — work with an end state
Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter checks this).
| Path | Trigger | What lands | Distinct because | Method |
|------|---------|------------|------------------|--------|
| `projects/active/<slug>.md` | Work in motion now | Project note (Status PATCHed fresh) | Default state for anything being worked | PUT + PATCH |
| `projects/incubating/<slug>.md` | Idea captured, work not started | Project note | Pre-work; promote to `active/` when work begins | PUT |
| `projects/on-hold/<slug>.md` | Paused but still tracked | Project note | Resumable; distinct from `archived` (which is terminal) | PUT |
| `projects/archived/<slug>.md` | Done, abandoned, or rolled up | Project note | Terminal; kept for history, never deleted | PUT |
**Project vs area:** has an end state → `projects/`. Never "done" → `areas/`.
## areas/ — standing responsibilities, no finish line
| 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 |
## resources/ — reference material about the world
| Path | Trigger | What lands | Distinct because | Method |
|------|---------|------------|------------------|--------|
| `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/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 |
## decisions/ — non-obvious decisions (ADR)
| Path | Trigger | What lands | Distinct because | Method |
|------|---------|------------|------------------|--------|
| `decisions/by-date/YYYY-MM-DD-<slug>.md` | A non-obvious decision worth recording | ADR: Context → Decision → Consequences | The chronological system of record for decisions | PUT |
**Mirror, don't duplicate:** if the decision belongs to an existing `projects/active/` note, PATCH a `[[wikilink]]` to the ADR under that project's `## Key Decisions`. No matching project → skip the mirror; the by-date ADR stands alone.
## _agent/ — the agent's own working substrate
| Path | Trigger | What lands | Distinct because | Method |
|------|---------|------------|------------------|--------|
| `_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/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/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/archived/<slug>.md` | A catalogued skill is retired | Skill entry (moved from `active/`) | Terminal state of the skill catalog | PUT |
---
## Retired paths — explicitly never written
Listed so they are recognised as dead and never recreated. Any one of these appearing in a live vault is a migration miss (see `bootstrap.md` Migrations).
| Path | Status | Where it went instead |
|------|--------|-----------------------|
| `reviews/` (weekly/monthly/quarterly/annual) | Retired in schema 2 | Journal rollups → `journal/{weekly,monthly,quarterly,annual}/`; vault-health → `_agent/health/` |
| `decisions/by-project/` | Retired in schema 1 | ADR mirrored as a `[[wikilink]]` under the project's `## Key Decisions` |
| `archive/` (top-level) | Never existed | Project archival → `projects/archived/`; skill archival → `_agent/skills/archived/` |
| `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md` (in-vault) | Retired in schema 1 | All control logic lives in the plugin (`references/`), not the vault |
## 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.
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.
6. **A capability (skill/plugin):** `_agent/skills/` catalogs the capability; `projects/` tracks building it. Both can exist for the same skill.
@@ -1,110 +0,0 @@
# Session Log Template
Session logs go in: `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md`
**Filename format is canonical and not optional.** The four-digit local-time HHMM component is what makes session filenames lex-sort in true chronological order — the loading procedure depends on it. Before PUT-ing a new session log, validate the filename matches `^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9-]+\.md$`. Legacy session logs without HHMM exist in the vault; do not edit their names, but every new write must use the full form.
The slug describes what the session was about in 25 words, kebab-case.
Examples: `2026-06-05-1430-echo-plugin-build.md`, `2026-05-14-0900-q1-review-prep.md`.
Keep logs focused. Capture the goal, what was read/done, decisions, outputs, open threads, and the next step. This matches the vault's `_agent/templates/session-log-template.md`.
---
## Template
```markdown
---
type: session-log
status: complete
created: 2026-06-05T14:30
updated: 2026-06-05T14:30
tags: [agent, session]
agent_written: true
source_notes: []
session_date: 2026-06-05
client: claude-code
---
# Session Log
## Goal
One line — what this session set out to do.
## Notes Read
- [[note]] — why it was relevant
(omit if none)
## Actions Taken
Brief narrative or bullets of what was done.
## Decisions Made
- Decision — why
(omit section if none)
## Outputs Created
- `path/or/filename` — what it is
(omit section if none)
## Open Threads
- [ ] unresolved question or next step
(omit section if none)
## Suggested Next Step
One sentence: what to do first next time on this topic.
## Related
- [[wikilinks go here, in the body — never in frontmatter]]
```
---
## Example
```markdown
---
type: session-log
status: complete
created: 2026-06-05T14:30
updated: 2026-06-05T14:30
tags: [agent, session, plugin]
agent_written: true
source_notes: ["resources/references/obsidian-local-rest-api.md"]
session_date: 2026-06-05
client: claude-code
---
# Session Log
## Goal
Build and package the echo-memory CoWork plugin against the live vault.
## Notes Read
- [[BOOTSTRAP]], [[STRUCTURE]], [[_agent/memory/semantic/operator-preferences]]
## Actions Taken
Verified the REST API end-to-end, confirmed the scaffold copied into the live vault,
created missing empty folders, and built the plugin (SKILL + 4 reference files).
## Decisions Made
- Vault addressed at root — ECHO is a dedicated vault.
- Key hardcoded in the plugin (not in the vault) — personal plugin, per the reference pattern.
## Outputs Created
- `echo-memory.plugin` — installable CoWork plugin
## Open Threads
- [ ] Validate Codex direct filesystem access to the vault host.
## Suggested Next Step
Install the plugin and run a live load/write through it to confirm the skill triggers.
## Related
- [[projects/active/vault-foundation]]
- [[resources/references/obsidian-local-rest-api]]
```
After writing the log, append a one-line entry to the daily note's **Agent Log** section.
> **Reminder:** wiki links go in the body (e.g. this `## Related` section), never in YAML
> frontmatter. `source_notes` in frontmatter holds plain relative path strings, not `[[links]]`.
@@ -1,179 +0,0 @@
# Vault Layout & Frontmatter Conventions
**This document is canonical.** The ECHO vault holds data only — there are no `CLAUDE.md` / `STRUCTURE.md` / `BOOTSTRAP.md` / `index.md` control docs in it. Layout, taxonomy, and frontmatter conventions live here in the plugin; the bootstrap procedure (`references/bootstrap.md`) builds the tree below into any empty vault.
## Folder Map (root-addressed)
```
/vault/
├── README.md ← thin human signpost (NOT read for routing)
├── inbox/
│ ├── captures/ ← quick captures (inbox.md), date-prefixed lines
│ ├── imports/ ← raw imported material
│ └── processing-log/
├── journal/ ← one append-only time-series stream; rollups are coarser journal entries, NOT a separate reviews/ tree
│ ├── daily/ ← YYYY-MM-DD.md (has an "Agent Log" section)
│ ├── weekly/ ← YYYY-Www.md (ISO week, opt-in rollup)
│ ├── monthly/ ← YYYY-MM.md (monthly rollup)
│ ├── quarterly/ ← YYYY-Qn.md (manual / on request)
│ ├── annual/ ← YYYY.md (manual / on request)
│ └── templates/
├── projects/ ← lifecycle: incubating → active → on-hold/archived
│ ├── active/ ← current work (status: active)
│ ├── incubating/ ← idea captured, not yet started (status: incubating)
│ ├── on-hold/ ← paused but kept (status: on-hold)
│ ├── archived/ ← done / abandoned (status: archived)
│ └── project-template.md
├── areas/ ← business / personal / learning / systems
├── resources/
│ ├── companies/ ← <slug>.md — organizations (clients, vendors, partners, employers)
│ ├── concepts/ references/ meetings/
│ └── people/ ← <name>.md
├── decisions/
│ ├── by-date/ ← YYYY-MM-DD-<slug>.md (ADR-style) — the canonical home
│ └── decision-template.md
│ (decisions/by-project/ is retired and not created —
│ mirror an ADR into a project's `## Key Decisions` heading instead)
│ (reviews/ is retired — journal rollups live under journal/; vault-health audits under _agent/health/)
└── _agent/
├── echo-vault.md ← bootstrap marker: schema_version + bootstrap date (plugin-owned; the "is this vault set up?" probe)
├── context/ ← current-context.md and task bundles
├── memory/
│ ├── working/ ← transient, time-boxed
│ ├── episodic/ ← what happened, when
│ └── semantic/ ← durable facts/patterns (operator-preferences.md)
├── sessions/ ← YYYY-MM-DD-HHMM-<slug>.md
├── health/ ← YYYY-MM-vault-health.md (monthly self-maintenance audit; NOT a journal entry)
├── templates/ ← canonical note templates
├── skills/ ← active / archived
└── heartbeat/ ← single-line pointers (e.g. last-session.md → most-recent session log path)
```
**Heartbeat:** `_agent/heartbeat/last-session.md` is a one-line pointer (`<session-log-path> @ <ISO-timestamp>`) the **session-logging procedure writes (PUT, overwrite) at session end** and the **loading procedure reads first (Step 4)** as an O(1) shortcut to the latest session log. It is a hint, not a source of truth — fall back to the `sessions/` directory listing if it's missing or stale. Because it's PUT-overwritten, it never grows or duplicates.
**Slug rules:** kebab-case, ASCII only, truncate to ~40 chars.
---
## Canonical Frontmatter
Every note starts with this block. Fill what applies; leave the rest empty rather than guessing.
```yaml
---
type: # see Note Types below
status: # active | draft | done | archived | complete
created: # YYYY-MM-DD (or YYYY-MM-DDTHH:mm for sessions)
updated: # YYYY-MM-DD
tags: []
agent_written: false
source_notes: [] # plain relative paths as strings — NEVER [[wikilinks]]
---
```
`agent_written: true` + a populated `source_notes` is the key signal separating
agent-managed content from human-authored content. When appending with POST, do
not rewrite frontmatter — the append goes after existing content. To change
`updated:` or `status:`, use PATCH with `Target-Type: frontmatter`.
**Frontmatter field semantics:**
- `created:` is the **earliest known date** the entity was tracked in the vault — *not* "today". When merging notes (e.g. promoting an `on-hold/` project into `active/`), preserve the earliest `created:` from any merged source and only update `updated:`.
- `source_notes` is a **backward link** — the note(s) that triggered or supplied content for this one (e.g. the session log a project update came from). Forward links go in the `## Related` body section, never here.
- `status:` for a project MUST match its folder under `projects/` (`active`, `incubating`, `on-hold`, `archived`). Moving the file and updating `status:` are the same operation.
> **No `[[wikilinks]]` in frontmatter.** YAML parses `[[...]]` as nested lists, so wiki
> links there break and never render as clickable links in reading view. Put all
> cross-references in a **`## Related`** section in the note **body** (bulleted `[[links]]`).
> Frontmatter holds scalar/string metadata only; `source_notes` values are plain relative
> paths, not links.
## Note Types
`daily-note`, `weekly-note`, `monthly-note`, `project`, `project-update`, `area`,
`concept`, `reference`, `person`, `company`, `meeting`, `decision`, `review`, `session-log`,
`working-memory`, `episodic-memory`, `semantic-memory`, `context-bundle`, `skill`,
`draft`, `inbox-item`.
---
## File-Specific Conventions
### operator-preferences.md (`_agent/memory/semantic/`)
The profile analog. Canonical headings:
- `## Operator` — who Jason 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.
### projects/active/\<slug\>.md
Mirrors `scaffold/templates/projects/project-template.md` — keep this block in sync with that template.
```markdown
---
type: project
status: active
created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}}
tags: [project]
agent_written: false
source_notes: []
owner:
review_cycle: weekly
---
# Project Name
## Purpose
## Status
One paragraph, kept fresh via PATCH replace (target `Project Name::Status`).
## Goals
## Current Context
## Open Loops
- [ ] unresolved thing
## Key Decisions
- [[YYYY-MM-DD-decision-slug]] — one-line summary (ADR mirror target)
## Related Notes
## Session History
- 2026-06-05: observation or update
## Related
- [[areas/business/business-ops]]
```
### sessions/YYYY-MM-DD-HHMM-\<slug\>.md
See `session-log-template.md`. ECHO uses an **HHMM time component** in the filename — this is **canonical, not optional**. The four-digit local-time component makes filenames lex-sort in true chronological order, which the loading procedure relies on. Older session logs without HHMM exist; leave them alone, but every new one must use the full `YYYY-MM-DD-HHMM-<slug>.md` form.
### decisions/by-date/YYYY-MM-DD-\<slug\>.md
ADR-style: Context → Decision → Consequences. If the decision belongs to an existing project, PATCH-append the wikilink into that project's `## Key Decisions` heading. Don't use `decisions/by-project/` — it's legacy scaffolding that's intentionally left empty.
### people/\<name\>.md
`type: person`. Use lowercase kebab-case for the slug (e.g. `jason-stedwell.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.
---
## Cross-References
Use Obsidian wiki links freely: `[[note-name]]` or `[[folder/note]]`. The REST API
doesn't resolve them, but Obsidian does when the operator browses the vault.
@@ -1,12 +0,0 @@
# ECHO Memory Vault
This Obsidian vault is the persistent memory substrate ("second brain") for its operator. It is read and written across Codex / CoWork sessions through the Obsidian Local REST API.
**This vault holds data, not logic.** All operating procedure — how the vault is bootstrapped, how notes are routed, the taxonomy, frontmatter conventions, and safety rules — lives in the **`echo-memory` plugin**, which is the single source of truth. There are intentionally no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` control docs in this vault; updating or porting ECHO means updating or installing the plugin, not editing files here.
- **Layout:** see the plugin's `references/vault-layout.md`.
- **Operating contract & safety:** see the plugin's `references/operating-contract.md`.
- **Bootstrap / repair:** see the plugin's `references/bootstrap.md`.
- **Version marker:** `_agent/echo-vault.md` records the schema version and bootstrap date.
Folders: `inbox/`, `journal/` (daily + weekly/monthly/quarterly/annual rollups), `projects/`, `areas/`, `resources/`, `decisions/`, and the agent subtree `_agent/`.
@@ -1,26 +0,0 @@
---
type: context-bundle
status: active
created: {{DATE}}
updated: {{DATE}}
tags: [agent, context]
agent_written: true
source_notes: []
scope:
scope_updated: {{DATE}}
refresh_strategy: on-demand
---
# Current Context
## Scope
<!-- The single active scope. Replaced (PATCH replace) on each scope switch. -->
## Scope History
<!-- Dated bullets of prior scopes, newest first: `- {{DATE}}: <prior scope summary>`. Trim to ~10. -->
## Active Priorities
## Open Questions
## Related
@@ -1,3 +0,0 @@
# Inbox — Captures
Quick captures land here as date-prefixed lines (`- {{DATE}}: <thing>`), one per line, via POST. Triage routes durable items to their proper home (see the echo-memory skill's Inbox Triage). Don't delete originals on triage — the processing log is the audit trail.
@@ -1,31 +0,0 @@
---
type: semantic-memory
status: active
created: {{DATE}}
updated: {{DATE}}
tags: [agent, semantic-memory, operator]
agent_written: true
source_notes: []
confidence: low
last_reviewed: {{DATE}}
---
# Operator Preferences
## Operator
<!-- One paragraph: who the operator is. Leave for the operator to confirm; do not fabricate. -->
## Fact / Pattern
<!-- Promoted, deduped, timeless rules. No date prefix. Add only when a rule is stable. -->
## Observations
<!-- Timestamped raw observations. Default landing zone for new evidence: `- {{DATE}}: ...` -->
## Evidence
## Recommendation or Implication
## Review Notes
Seeded empty at bootstrap ({{DATE}}). Raise confidence once preferences are confirmed through day-to-day use.
## Related
@@ -1,19 +0,0 @@
---
type: reference
status: active
created: {{DATE}}
updated: {{DATE}}
tags: [agent, system, marker]
agent_written: true
source_notes: []
schema_version: 2
bootstrapped: {{DATE}}
managed_by: echo-memory-plugin
---
# ECHO Vault Marker
This file marks the vault as bootstrapped by the **echo-memory plugin** and records its schema version. The plugin's loading procedure GETs this file as its "is this vault set up?" probe; a `404` triggers a fresh bootstrap.
- `schema_version` — bumped by the plugin when the vault layout changes; a mismatch is the hook for a migration pass (see `references/bootstrap.md`).
- Do not hand-edit. The plugin owns this file.
@@ -1,27 +0,0 @@
---
type: context-bundle
status: active
created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}}
tags: [agent, context]
agent_written: true
source_notes: []
scope:
refresh_strategy: on-demand
---
# Context Bundle Title
## Scope
## Active Priorities
## Relevant Entities
## Key Decisions
## Open Questions
## Source Notes
## Related
@@ -1,23 +0,0 @@
---
type: semantic-memory
status: active
created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}}
tags: [agent, semantic-memory]
agent_written: true
source_notes: []
confidence: high
last_reviewed: {{date:YYYY-MM-DD}}
---
# Semantic Memory Title
## Fact / Pattern
## Evidence
## Recommendation or Implication
## Review Notes
## Related
@@ -1,29 +0,0 @@
---
type: session-log
status: complete
created: {{date:YYYY-MM-DDTHH:mm}}
updated: {{date:YYYY-MM-DDTHH:mm}}
tags: [agent, session]
agent_written: true
source_notes: []
session_date: {{date:YYYY-MM-DD}}
client: claude-code
---
# Session Log
## Goal
## Notes Read
## Actions Taken
## Decisions Made
## Outputs Created
## Open Threads
## Suggested Next Step
## Related
@@ -1,22 +0,0 @@
---
type: working-memory
status: active
created: {{date:YYYY-MM-DDTHH:mm}}
updated: {{date:YYYY-MM-DDTHH:mm}}
tags: [agent, working-memory]
agent_written: true
source_notes: []
expires_after: 48h
---
# Working Context
## Active Focus
## Recent Decisions
## Open Threads
## Relevant Links
## Related
@@ -1,25 +0,0 @@
---
type: decision
status: complete
created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}}
tags: [decision]
agent_written: true
source_notes: []
decision_date: {{date:YYYY-MM-DD}}
impact: medium
---
# Decision Title
## Context
## Decision
## Rationale
## Consequences
## Follow-Up
## Related
@@ -1,25 +0,0 @@
---
type: daily-note
status: active
created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}}
tags: [daily, journal]
agent_written: false
source_notes: []
---
# {{date:YYYY-MM-DD}}
## Top Priorities
## Schedule / Commitments
## Open Loops
## Notes
## Context for AI
## Agent Log
## Related
@@ -1,26 +0,0 @@
---
type: review
status: active
created: {{date:gggg-[W]WW}}
updated: {{date:gggg-[W]WW}}
tags: [review, weekly]
agent_written: true
source_notes: []
period: weekly
---
# Weekly Review
## Completed Work
## Open Loops
## Stale Projects
## Decisions
## Priorities Next Week
## Agent Notes
## Related
@@ -1,31 +0,0 @@
---
type: project
status: active
created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}}
tags: [project]
agent_written: false
source_notes: []
owner:
review_cycle: weekly
---
# Project Name
## Purpose
## Status
## Goals
## Current Context
## Open Loops
## Key Decisions
## Related Notes
## Session History
## Related
@@ -1,135 +0,0 @@
#!/usr/bin/env python3
"""Bootstrap or repair an ECHO vault from the bundled scaffold."""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
SCAFFOLD = SKILL_DIR / "scaffold"
sys.path.insert(0, str(SCRIPT_DIR))
import echo # noqa: E402
LEAVES = [
"inbox/captures",
"inbox/imports",
"inbox/processing-log",
"journal/daily",
"journal/weekly",
"journal/monthly",
"journal/quarterly",
"journal/annual",
"journal/templates",
"projects/active",
"projects/incubating",
"projects/on-hold",
"projects/archived",
"areas/business",
"areas/personal",
"areas/learning",
"areas/systems",
"resources/concepts",
"resources/references",
"resources/people",
"resources/companies",
"resources/meetings",
"decisions/by-date",
"_agent/context",
"_agent/memory/working",
"_agent/memory/episodic",
"_agent/memory/semantic",
"_agent/sessions",
"_agent/health",
"_agent/templates",
"_agent/heartbeat",
"_agent/skills/active",
"_agent/skills/archived",
"_agent/locks",
]
def exists(path: str) -> bool:
status, _ = echo.request("GET", echo.vault_url(path))
return status == 200
def put_text(path: str, text: str) -> None:
status, body = echo.request(
"PUT",
echo.vault_url(path),
data=text.encode(),
headers={"Content-Type": "text/markdown"},
)
echo.check(status, body, f"bootstrap put {path}")
def seed(path: str, source: Path, dry_run: bool) -> None:
if exists(path):
print(f"bootstrap: skip (exists) {path}")
return
if dry_run:
print(f"bootstrap: would seed {path} <- {source.relative_to(SKILL_DIR)}")
return
text = source.read_text(encoding="utf-8").replace("{{DATE}}", echo.today())
put_text(path, text)
print(f"bootstrap: seeded {path}")
def leaf_readme(folder: str, dry_run: bool) -> None:
path = f"{folder}/README.md"
if exists(path):
return
if dry_run:
print(f"bootstrap: would readme {path}")
return
name = folder.rsplit("/", 1)[-1]
put_text(path, f"# {name}\n\nMemory vault folder. See the echo-memory plugin for conventions.\n")
print(f"bootstrap: readme {path}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Bootstrap or repair an ECHO vault")
parser.add_argument("--dry-run", "-n", action="store_true")
args = parser.parse_args(argv)
if not SCAFFOLD.is_dir():
print(f"bootstrap: scaffold not found at {SCAFFOLD}", file=sys.stderr)
return 1
marker = exists("_agent/echo-vault.md")
if marker:
try:
status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
echo.check(status, body, "bootstrap marker")
version = next((line.split(":", 1)[1].strip() for line in body.decode().splitlines() if line.startswith("schema_version:")), "unknown")
except Exception:
version = "unknown"
print(f"bootstrap: marker present (schema_version={version}). Running repair pass.")
for folder in LEAVES:
leaf_readme(folder, args.dry_run)
templates = SCAFFOLD / "templates"
if templates.is_dir():
for source in sorted(templates.rglob("*.md")):
seed(source.relative_to(templates).as_posix(), source, args.dry_run)
seed("_agent/memory/semantic/operator-preferences.md", SCAFFOLD / "anchors" / "operator-preferences.seed.md", args.dry_run)
seed("_agent/context/current-context.md", SCAFFOLD / "anchors" / "current-context.seed.md", args.dry_run)
seed("inbox/captures/inbox.md", SCAFFOLD / "anchors" / "inbox.seed.md", args.dry_run)
seed("README.md", SCAFFOLD / "README.vault.md", args.dry_run)
seed("_agent/echo-vault.md", SCAFFOLD / "echo-vault.md", args.dry_run)
print(f"bootstrap: done ({'DRY-RUN ' if args.dry_run else ''}{echo.today()}).")
print("bootstrap: next: create today's daily note, bootstrap session log, and heartbeat if this is a first run.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""Write ECHO session closeout artifacts.
Creates or updates the daily Agent Log, writes a canonical session log, and
updates the heartbeat pointer. This keeps the logging workflow explicit while
making it hard to skip after substantive memory writes.
"""
from __future__ import annotations
import argparse
import datetime as dt
import re
import sys
import tempfile
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
sys.path.insert(0, str(SCRIPT_DIR))
import echo # noqa: E402
def slugify(text: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
slug = re.sub(r"-{2,}", "-", slug)
return slug[:60].strip("-") or "session"
def now_local() -> dt.datetime:
override = echo.today()
current = dt.datetime.now()
try:
date = dt.date.fromisoformat(override)
return current.replace(year=date.year, month=date.month, day=date.day)
except ValueError:
return current
def temp_file(text: str) -> str:
handle = tempfile.NamedTemporaryFile("w", encoding="utf-8", newline="\n", delete=False)
try:
handle.write(text)
handle.close()
return handle.name
finally:
try:
handle.close()
except Exception:
pass
def get_text(path: str) -> str | None:
status, body = echo.request("GET", echo.vault_url(path))
if status == 404:
return None
echo.check(status, body, f"get {path}")
return body.decode("utf-8", errors="replace")
def put_text(path: str, text: str) -> None:
echo.cmd_put(path, temp_file(text))
def patch_heading(path: str, op: str, target: str, text: str) -> None:
echo.cmd_patch(path, op, "heading", target, temp_file(text))
def ensure_daily_note(date: str) -> str:
path = f"journal/daily/{date}.md"
text = get_text(path)
if text is None:
template_path = SKILL_DIR / "scaffold" / "templates" / "journal" / "templates" / "daily-note-template.md"
template = template_path.read_text(encoding="utf-8")
body = template.replace("{{date:YYYY-MM-DD}}", date).replace("{{DATE}}", date)
put_text(path, body)
text = body
if not re.search(r"(?m)^## Agent Log\s*$", text):
echo.cmd_post(path, temp_file("\n\n## Agent Log\n"))
return path
def append_daily_log(date: str, line: str) -> str:
path = ensure_daily_note(date)
target = f"{date}::Agent Log"
entry = line if line.startswith("- ") else f"- {date}: {line}"
existing = get_text(path) or ""
if entry in existing:
print(f"skip: daily Agent Log already contains entry for {path}")
return path
patch_heading(path, "append", target, entry)
return path
def bullet_section(items: list[str]) -> str:
return "\n".join(f"- {item}" for item in items)
def render_session_log(args: argparse.Namespace, created: dt.datetime, session_path: str) -> str:
iso_minute = created.strftime("%Y-%m-%dT%H:%M")
date = created.date().isoformat()
related = args.related or []
notes = args.notes_read or []
outputs = args.outputs or []
decisions = args.decisions or []
open_threads = args.open_threads or []
sections = [
"---",
"type: session-log",
"status: complete",
f"created: {iso_minute}",
f"updated: {iso_minute}",
"tags: [agent, session, codex]",
"agent_written: true",
"source_notes: []",
f"session_date: {date}",
"client: codex",
"---",
"",
"# Session Log",
"",
"## Goal",
args.goal.strip(),
"",
]
if notes:
sections.extend(["## Notes Read", bullet_section(notes), ""])
sections.extend(["## Actions Taken", args.actions.strip(), ""])
if decisions:
sections.extend(["## Decisions Made", bullet_section(decisions), ""])
if outputs:
sections.extend(["## Outputs Created", bullet_section(outputs), ""])
if open_threads:
sections.extend(["## Open Threads", "\n".join(f"- [ ] {item}" for item in open_threads), ""])
sections.extend(["## Suggested Next Step", args.next_step.strip(), ""])
if related:
sections.extend(["## Related", bullet_section(related), ""])
sections.append(f"Session path: `{session_path}`")
return "\n".join(sections).rstrip() + "\n"
def write_session_log(args: argparse.Namespace, created: dt.datetime) -> str:
date = created.date().isoformat()
hhmm = created.strftime("%H%M")
slug = slugify(args.slug or args.goal)
filename = f"{date}-{hhmm}-{slug}.md"
if not re.match(r"^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9-]+\.md$", filename):
raise echo.EchoError(f"invalid session filename: {filename}", 2)
path = f"_agent/sessions/{filename}"
existing = get_text(path)
if existing is None:
put_text(path, render_session_log(args, created, path))
else:
print(f"skip: session log already exists at {path}")
return path
def update_heartbeat(session_path: str, created: dt.datetime) -> None:
stamp = created.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
put_text("_agent/heartbeat/last-session.md", f"{session_path} @ {stamp}\n")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Write ECHO session closeout artifacts")
parser.add_argument("--slug", help="2-5 word kebab-case-ish session topic")
parser.add_argument("--goal", required=True)
parser.add_argument("--actions", required=True)
parser.add_argument("--next-step", required=True)
parser.add_argument("--daily-line", required=True)
parser.add_argument("--related", action="append", default=[])
parser.add_argument("--notes-read", action="append", default=[])
parser.add_argument("--outputs", action="append", default=[])
parser.add_argument("--decisions", action="append", default=[])
parser.add_argument("--open-threads", action="append", default=[])
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
created = now_local()
session_path = write_session_log(args, created)
daily_path = append_daily_log(created.date().isoformat(), args.daily_line)
update_heartbeat(session_path, created)
print(f"ok: session log {session_path}")
print(f"ok: daily log {daily_path}")
print("ok: heartbeat updated")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,376 +0,0 @@
#!/usr/bin/env python3
"""Validated client for the ECHO Obsidian Local REST API.
This is the Codex-portable replacement for the original bash echo.py client.
It intentionally requires ECHO_KEY from the environment; no bearer token is
stored in the plugin.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import locale
import os
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
BASE = os.environ.get("ECHO_BASE", "https://echoapi.alwisp.com").rstrip("/")
KEY = os.environ.get("ECHO_KEY", "")
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
class EchoError(RuntimeError):
def __init__(self, message: str, code: int = 1) -> None:
super().__init__(message)
self.code = code
def require_key() -> None:
if not KEY:
raise EchoError("ECHO_KEY is not set; refusing to call the live vault")
def today() -> str:
return os.environ.get("ECHO_TODAY") or dt.date.today().isoformat()
def now_iso() -> str:
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def vault_url(path: str) -> str:
safe = "/".join(urllib.parse.quote(part) for part in path.split("/"))
if path.endswith("/") and not safe.endswith("/"):
safe += "/"
return f"{BASE}/vault/{safe}"
def request(method: str, url: str, data: bytes | None = None, headers: dict[str, str] | None = None) -> tuple[int, bytes]:
require_key()
merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
last_status = 0
last_body = b""
for attempt in range(2):
req = urllib.request.Request(url, data=data, method=method, headers=merged)
try:
with urllib.request.urlopen(req, timeout=30) as response:
return response.status, response.read()
except urllib.error.HTTPError as exc:
body = exc.read()
if exc.code >= 500 and attempt == 0:
time.sleep(1)
continue
return exc.code, body
except Exception as exc:
last_status, last_body = 0, str(exc).encode()
if attempt == 0:
time.sleep(1)
continue
return last_status, last_body
def check(status: int, body: bytes, context: str) -> None:
if status == 404:
raise EchoError(f"{context}: not found", 44)
if status == 0:
raise EchoError(f"{context}: vault unreachable ({body.decode(errors='replace')})", 1)
if status >= 400:
raise EchoError(f"{context}: HTTP {status} - {body.decode(errors='replace')}", 1)
def read_body(arg: str | None) -> bytes:
if arg in (None, "-"):
raw = sys.stdin.buffer.read()
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
text = raw.decode(locale.getpreferredencoding(False) or "cp1252", errors="replace")
return text.encode("utf-8")
return Path(arg).read_bytes()
def normalize_patch_body(data: bytes, op: str, target_type: str) -> bytes:
if target_type != "heading":
return data
text = data.decode("utf-8", errors="replace")
if op == "replace":
text = text.strip("\r\n")
if text:
text = f"\n{text}\n"
else:
text = "\n"
elif text and not text.endswith("\n"):
text += "\n"
return text.encode("utf-8")
def normalize_json_scalar(value: str) -> bytes:
value = value.strip()
try:
json.loads(value)
return value.encode("utf-8")
except json.JSONDecodeError:
return json.dumps(value).encode("utf-8")
def cmd_get(path: str) -> int:
status, body = request("GET", vault_url(path))
check(status, body, f"get {path}")
sys.stdout.buffer.write(body)
return 0
def cmd_map(path: str) -> int:
status, body = request("GET", vault_url(path), headers={"Accept": "application/vnd.olrapi.document-map+json"})
check(status, body, f"map {path}")
sys.stdout.buffer.write(body)
return 0
def cmd_ls(path: str) -> int:
if not path.endswith("/"):
path += "/"
status, body = request("GET", vault_url(path))
check(status, body, f"ls {path}")
sys.stdout.buffer.write(body)
return 0
def cmd_search(query: list[str]) -> int:
q = urllib.parse.quote_plus(" ".join(query))
status, body = request("POST", f"{BASE}/search/simple/?query={q}")
check(status, body, "search")
sys.stdout.buffer.write(body)
return 0
def cmd_put(path: str, file_arg: str | None) -> int:
data = read_body(file_arg)
status, body = request("PUT", vault_url(path), data=data, headers={"Content-Type": "text/markdown"})
check(status, body, f"put {path}")
if VERIFY:
status, body = request("GET", vault_url(path))
if status != 200:
raise EchoError(f"put {path}: write did not verify (GET returned {status})")
print(f"ok: PUT {path}")
return 0
def cmd_post(path: str, file_arg: str | None) -> int:
data = read_body(file_arg)
status, body = request("POST", vault_url(path), data=data, headers={"Content-Type": "text/markdown"})
check(status, body, f"post {path}")
print(f"ok: POST {path}")
return 0
def cmd_append(path: str, line: str) -> int:
status, body = request("GET", vault_url(path))
if status == 200 and line.encode() in body:
print(f"skip: line already present in {path}")
return 0
if status not in (200, 404):
check(status, body, f"append(read) {path}")
status, body = request("POST", vault_url(path), data=f"{line}\n".encode(), headers={"Content-Type": "text/markdown"})
check(status, body, f"append {path}")
print(f"ok: APPEND {path}")
return 0
def cmd_patch(path: str, op: str, target_type: str, target: str, file_arg: str | None) -> int:
if op not in {"append", "prepend", "replace"}:
raise EchoError("patch: op must be append, prepend, or replace", 2)
if target_type not in {"heading", "frontmatter", "block"}:
raise EchoError("patch: target-type must be heading, frontmatter, or block", 2)
data = normalize_patch_body(read_body(file_arg), op, target_type)
status, body = request(
"PATCH",
vault_url(path),
data=data,
headers={
"Operation": op,
"Target-Type": target_type,
"Target": target,
"Content-Type": "application/json" if target_type == "frontmatter" else "text/markdown",
},
)
check(status, body, f"patch {path} ({target})")
print(f"ok: PATCH {op} {target_type} '{target}' -> {path}")
return 0
def cmd_fm(path: str, field: str, value: str) -> int:
return cmd_patch(path, "replace", "frontmatter", field, temp_file(normalize_json_scalar(value)))
def temp_file(data: bytes) -> str:
fh = tempfile.NamedTemporaryFile(delete=False)
try:
fh.write(data)
fh.close()
return fh.name
finally:
try:
fh.close()
except Exception:
pass
def cmd_delete(path: str) -> int:
status, body = request("DELETE", vault_url(path))
check(status, body, f"delete {path}")
print(f"ok: DELETE {path}")
return 0
def parse_lock_time(value: str) -> int:
try:
return int(dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.timezone.utc).timestamp())
except Exception:
return 0
def cmd_lock(owner: str) -> int:
path = "_agent/locks/vault.lock"
status, body = request("GET", vault_url(path))
if status == 200 and body.strip():
current = body.decode(errors="replace").strip()
held_owner, _, held_iso = current.partition(" @ ")
now_epoch = int(time.time())
if held_owner != owner and now_epoch - parse_lock_time(held_iso) < LOCK_TTL:
print(f"lock held by '{held_owner}' since {held_iso} (fresh)", file=sys.stderr)
return 75
status, body = request("PUT", vault_url(path), data=f"{owner} @ {now_iso()}\n".encode(), headers={"Content-Type": "text/markdown"})
check(status, body, "lock")
print(f"ok: locked by {owner}")
return 0
def cmd_unlock(owner: str) -> int:
path = "_agent/locks/vault.lock"
status, body = request("GET", vault_url(path))
if status == 200:
held_owner = body.decode(errors="replace").split(" @ ", 1)[0]
if held_owner != owner:
print(f"lock owned by '{held_owner}', not '{owner}' - not releasing", file=sys.stderr)
return 75
status, body = request("DELETE", vault_url(path))
if status != 404:
check(status, body, "unlock")
print("ok: unlocked")
return 0
def extract_heading(markdown: str, heading: str) -> str:
lines = markdown.splitlines()
out: list[str] = []
capture = False
marker = f"## {heading}"
for line in lines:
if line.strip() == marker:
capture = True
continue
if capture and line.startswith("## "):
break
if capture:
out.append(line)
return "\n".join(out).strip()
def cmd_scope(subcommand: str, text: str | None = None) -> int:
path = "_agent/context/current-context.md"
status, body = request("GET", vault_url(path))
check(status, body, f"scope {subcommand}")
current = body.decode(errors="replace")
if subcommand == "show":
scope = extract_heading(current, "Scope")
print("-- Active scope --")
print(scope)
scope_updated = ""
for line in current.splitlines():
if line.startswith("scope_updated:"):
scope_updated = line.split(":", 1)[1].strip().strip('"')
break
print(f"scope_updated: {scope_updated or '<missing - drift cannot be detected; run scope set or repair>'}")
return 0
if subcommand != "set":
raise EchoError("scope: use show or set", 2)
if not text:
raise EchoError("scope set needs the new scope text", 2)
prior = extract_heading(current, "Scope").replace("\n", " ").strip()[:140] or "(prior scope)"
history_line = f"- {today()}: {prior}\n"
cmd_patch(path, "prepend", "heading", "Current Context::Scope History", temp_file(history_line.encode()))
cmd_patch(path, "replace", "heading", "Current Context::Scope", temp_file(f"{text}\n".encode()))
cmd_patch(path, "replace", "frontmatter", "scope_updated", temp_file(json.dumps(today()).encode()))
print(f"ok: scope switched (prior archived to Scope History; scope_updated={today()})")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validated ECHO vault client")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("get").add_argument("path")
sub.add_parser("map").add_argument("path")
sub.add_parser("ls").add_argument("path")
sub.add_parser("search").add_argument("query", nargs="+")
p = sub.add_parser("put"); p.add_argument("path"); p.add_argument("file", nargs="?")
p = sub.add_parser("post"); p.add_argument("path"); p.add_argument("file", nargs="?")
p = sub.add_parser("append"); p.add_argument("path"); p.add_argument("line")
p = sub.add_parser("patch"); p.add_argument("path"); p.add_argument("op"); p.add_argument("target_type"); p.add_argument("target"); p.add_argument("file", nargs="?")
p = sub.add_parser("fm"); p.add_argument("path"); p.add_argument("field"); p.add_argument("value")
p = sub.add_parser("bump"); p.add_argument("path"); p.add_argument("date", nargs="?")
sub.add_parser("delete").add_argument("path")
sub.add_parser("lock").add_argument("owner")
sub.add_parser("unlock").add_argument("owner")
p = sub.add_parser("scope"); p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"]); p.add_argument("text", nargs="?")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.cmd == "get":
return cmd_get(args.path)
if args.cmd == "map":
return cmd_map(args.path)
if args.cmd == "ls":
return cmd_ls(args.path)
if args.cmd == "search":
return cmd_search(args.query)
if args.cmd == "put":
return cmd_put(args.path, args.file)
if args.cmd == "post":
return cmd_post(args.path, args.file)
if args.cmd == "append":
return cmd_append(args.path, args.line)
if args.cmd == "patch":
return cmd_patch(args.path, args.op, args.target_type, args.target, args.file)
if args.cmd == "fm":
return cmd_fm(args.path, args.field, args.value)
if args.cmd == "bump":
return cmd_fm(args.path, "updated", json.dumps(args.date or today()))
if args.cmd == "delete":
return cmd_delete(args.path)
if args.cmd == "lock":
return cmd_lock(args.owner)
if args.cmd == "unlock":
return cmd_unlock(args.owner)
if args.cmd == "scope":
return cmd_scope(args.subcommand, args.text)
except EchoError as exc:
print(f"echo.py: {exc}", file=sys.stderr)
return exc.code
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,125 +0,0 @@
#!/usr/bin/env python3
"""Dry-run or apply ECHO vault schema migrations."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import echo # noqa: E402
CURRENT_SCHEMA = 2
def get_text(path: str) -> str | None:
status, body = echo.request("GET", echo.vault_url(path))
if status == 404:
return None
echo.check(status, body, f"get {path}")
return body.decode(errors="replace")
def list_files(path: str) -> list[str]:
if not path.endswith("/"):
path += "/"
status, body = echo.request("GET", echo.vault_url(path))
if status == 404:
return []
echo.check(status, body, f"ls {path}")
try:
payload = json.loads(body)
except json.JSONDecodeError:
return []
return [entry for entry in payload.get("files", []) if not entry.endswith("/")]
def put_text(path: str, text: str) -> None:
status, body = echo.request("PUT", echo.vault_url(path), data=text.encode(), headers={"Content-Type": "text/markdown"})
echo.check(status, body, f"put {path}")
def delete(path: str) -> None:
status, body = echo.request("DELETE", echo.vault_url(path))
if status != 404:
echo.check(status, body, f"delete {path}")
def move(src: str, dst: str) -> None:
text = get_text(src)
if text is None:
return
put_text(dst, text)
delete(src)
def do_or_show(apply: bool, desc: str, func=None) -> None:
if apply:
print(f"migrate: APPLY {desc}")
if func:
func()
else:
print(f"migrate: PLAN {desc}")
def marker_schema() -> int:
marker = get_text("_agent/echo-vault.md")
if marker is None:
print("migrate: marker missing - vault not bootstrapped. Run bootstrap.py, not migrate.py.")
raise SystemExit(3)
for line in marker.splitlines():
if line.startswith("schema_version:"):
try:
return int(line.split(":", 1)[1].strip())
except ValueError:
return 0
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Migrate ECHO vault schema")
parser.add_argument("--apply", action="store_true")
args = parser.parse_args(argv)
start = marker_schema()
print(f"migrate: vault schema_version={start}, plugin schema={CURRENT_SCHEMA} {'(APPLY)' if args.apply else '(dry-run)'}")
if start >= CURRENT_SCHEMA:
print("migrate: up to date - nothing to do.")
return 0
if start < 1:
print("migrate: [0->1] retire in-vault control docs")
for filename in ["CLAUDE.md", "BOOTSTRAP.md", "STRUCTURE.md", "index.md"]:
if get_text(filename) is not None:
do_or_show(args.apply, f"delete vault/{filename} after external backup", lambda f=filename: delete(f))
print("migrate: [0->1] reminder: scrub dangling control-doc links from Related sections.")
if start < 2:
print("migrate: [1->2] fold reviews/ into journal/ and _agent/health/")
for filename in list_files("reviews/weekly"):
if filename.endswith(".md"):
dst = f"journal/weekly/{filename.replace('-review.md', '.md')}"
do_or_show(args.apply, f"move reviews/weekly/{filename} -> {dst}", lambda f=filename, d=dst: move(f"reviews/weekly/{f}", d))
for filename in list_files("reviews/monthly"):
if filename.endswith(".md"):
dst = f"_agent/health/{filename}" if filename.endswith("vault-health.md") else f"journal/monthly/{filename}"
do_or_show(args.apply, f"move reviews/monthly/{filename} -> {dst}", lambda f=filename, d=dst: move(f"reviews/monthly/{f}", d))
for period in ["quarterly", "annual"]:
for filename in list_files(f"reviews/{period}"):
if filename.endswith(".md"):
dst = f"journal/{period}/{filename}"
do_or_show(args.apply, f"move reviews/{period}/{filename} -> {dst}", lambda p=period, f=filename, d=dst: move(f"reviews/{p}/{f}", d))
print("migrate: [1->2] reminder: update inbound reviews wikilinks manually.")
do_or_show(args.apply, f"set _agent/echo-vault.md schema_version -> {CURRENT_SCHEMA}", lambda: echo.cmd_fm("_agent/echo-vault.md", "schema_version", str(CURRENT_SCHEMA)))
print("migrate: migration complete." if args.apply else "migrate: dry-run only. Re-run with --apply to perform changes.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,54 +0,0 @@
{
"$comment": "CANONICAL machine-readable routing manifest for the ECHO vault. This is the single source of truth for 'what paths may be written to'. The human-readable tables in SKILL.md, references/routing-map.md, and references/api-reference.md are DERIVED views of this file — when they disagree, this file wins. vault_lint.py consumes it to enforce the core rule: if a path matches no route here (and is not a retired path), nothing should be written to it. Patterns are Python regexes matched against vault-root-relative paths (no leading slash, no /vault/ prefix).",
"schema_version": 2,
"routes": [
{ "id": "inbox-captures", "pattern": "^inbox/captures/inbox\\.md$", "method": "POST", "trigger": "Destination unknown at capture time", "distinct_because": "Only path whose contract is deferred routing" },
{ "id": "inbox-imports", "pattern": "^inbox/imports/[^/]+\\.md$", "method": "PUT", "trigger": "Raw external material dropped wholesale", "distinct_because": "Bulk un-triaged material vs single-line captures" },
{ "id": "inbox-processing-log", "pattern": "^inbox/processing-log/\\d{4}-\\d{2}-\\d{2}\\.md$", "method": "POST", "trigger": "An inbox item is routed to its real home", "distinct_because": "Audit trail of moves, not memory itself" },
{ "id": "journal-daily", "pattern": "^journal/daily/\\d{4}-\\d{2}-\\d{2}\\.md$", "method": "PATCH", "trigger": "First agent activity on a given day", "distinct_because": "Finest grain; PATCHed repeatedly within its period" },
{ "id": "journal-weekly", "pattern": "^journal/weekly/\\d{4}-W\\d{2}\\.md$", "method": "PUT", "trigger": "First substantive session of a new ISO week (opt-in)", "distinct_because": "ISO-week grain" },
{ "id": "journal-monthly", "pattern": "^journal/monthly/\\d{4}-\\d{2}\\.md$", "method": "PUT", "trigger": "First substantive session of a new month", "distinct_because": "Month grain" },
{ "id": "journal-quarterly", "pattern": "^journal/quarterly/\\d{4}-Q[1-4]\\.md$", "method": "PUT", "trigger": "Manual / on request only", "distinct_because": "Strategic grain; never auto-fires" },
{ "id": "journal-annual", "pattern": "^journal/annual/\\d{4}\\.md$", "method": "PUT", "trigger": "Manual / on request only", "distinct_because": "Coarsest grain; never auto-fires" },
{ "id": "journal-templates", "pattern": "^journal/templates/.+\\.md$", "method": "PUT", "trigger": "Bootstrap seed only", "distinct_because": "Holds templates, not journal content" },
{ "id": "projects-active", "pattern": "^projects/active/[^/]+\\.md$", "method": "PUT", "trigger": "Work in motion now", "distinct_because": "Default state for anything being worked", "status": "active" },
{ "id": "projects-incubating", "pattern": "^projects/incubating/[^/]+\\.md$", "method": "PUT", "trigger": "Idea captured, work not started", "distinct_because": "Pre-work", "status": "incubating" },
{ "id": "projects-on-hold", "pattern": "^projects/on-hold/[^/]+\\.md$", "method": "PUT", "trigger": "Paused but still tracked", "distinct_because": "Resumable; not terminal", "status": "on-hold" },
{ "id": "projects-archived", "pattern": "^projects/archived/[^/]+\\.md$", "method": "PUT", "trigger": "Done, abandoned, or rolled up", "distinct_because": "Terminal; kept for history", "status": "archived" },
{ "id": "projects-template", "pattern": "^projects/project-template\\.md$", "method": "PUT", "trigger": "Bootstrap seed only", "distinct_because": "Template, not a project" },
{ "id": "areas", "pattern": "^areas/(business|personal|learning|systems)/[^/]+\\.md$", "method": "PUT", "trigger": "Ongoing domain with no finish line", "distinct_because": "No end state — disqualifies it from projects/" },
{ "id": "resources-people", "pattern": "^resources/people/[^/]+\\.md$", "method": "PUT", "trigger": "A fact about a specific person", "distinct_because": "Keyed to a person" },
{ "id": "resources-companies", "pattern": "^resources/companies/[^/]+\\.md$", "method": "PUT", "trigger": "A fact about an organization", "distinct_because": "Keyed to an organization, not an individual" },
{ "id": "resources-concepts", "pattern": "^resources/concepts/[^/]+\\.md$", "method": "PUT", "trigger": "A reusable concept/idea", "distinct_because": "An idea vs an external source" },
{ "id": "resources-references", "pattern": "^resources/references/[^/]+\\.md$", "method": "PUT", "trigger": "An external source/link worth keeping", "distinct_because": "Points outward" },
{ "id": "resources-meetings", "pattern": "^resources/meetings/\\d{4}-\\d{2}-\\d{2}-[^/]+\\.md$", "method": "PUT", "trigger": "Notes tied to a specific meeting", "distinct_because": "Event-anchored to a meeting" },
{ "id": "decisions-by-date", "pattern": "^decisions/by-date/\\d{4}-\\d{2}-\\d{2}-[^/]+\\.md$", "method": "PUT", "trigger": "A non-obvious decision worth recording", "distinct_because": "Chronological system of record for decisions" },
{ "id": "decisions-template", "pattern": "^decisions/decision-template\\.md$", "method": "PUT", "trigger": "Bootstrap seed only", "distinct_because": "Template, not a decision" },
{ "id": "agent-marker", "pattern": "^_agent/echo-vault\\.md$", "method": "PUT", "trigger": "Bootstrap / schema migration only", "distinct_because": "Plugin-owned probe; never hand-edited" },
{ "id": "agent-context", "pattern": "^_agent/context/[^/]+\\.md$", "method": "PATCH", "trigger": "Active scope changes / task bundles", "distinct_because": "Single live scope pointer + bundles" },
{ "id": "agent-semantic", "pattern": "^_agent/memory/semantic/[^/]+\\.md$", "method": "PUT", "trigger": "A durable fact/pattern (incl. operator-preferences.md)", "distinct_because": "Timeless fact" },
{ "id": "agent-episodic", "pattern": "^_agent/memory/episodic/[^/]+\\.md$", "method": "PUT", "trigger": "A record of what happened, when", "distinct_because": "Anchored to an event in time" },
{ "id": "agent-working", "pattern": "^_agent/memory/working/[^/]+\\.md$", "method": "PUT", "trigger": "Short-lived state for the current effort", "distinct_because": "Explicitly transient" },
{ "id": "agent-sessions", "pattern": "^_agent/sessions/\\d{4}-\\d{2}-\\d{2}(-\\d{4})?-[^/]+\\.md$", "method": "PUT", "trigger": "A substantive session ends", "distinct_because": "Per-session record (new ones require HHMM)" },
{ "id": "agent-health", "pattern": "^_agent/health/\\d{4}-\\d{2}-vault-health\\.md$", "method": "PUT", "trigger": "First substantive session of a month", "distinct_because": "Vault integrity, not work narrative" },
{ "id": "agent-heartbeat", "pattern": "^_agent/heartbeat/[^/]+\\.md$", "method": "PUT", "trigger": "End of every session", "distinct_because": "O(1) orientation pointer; overwritten, never grows" },
{ "id": "agent-templates", "pattern": "^_agent/templates/.+\\.md$", "method": "PUT", "trigger": "Bootstrap seed only", "distinct_because": "Holds templates, not memory" },
{ "id": "agent-skills-active", "pattern": "^_agent/skills/active/[^/]+\\.md$", "method": "PUT", "trigger": "A skill/plugin catalogued as a capability", "distinct_because": "Catalogs a capability vs the build effort" },
{ "id": "agent-skills-archived","pattern": "^_agent/skills/archived/[^/]+\\.md$", "method": "PUT", "trigger": "A catalogued skill is retired", "distinct_because": "Terminal state of the skill catalog" },
{ "id": "agent-locks", "pattern": "^_agent/locks/[^/]+\\.lock$", "method": "PUT", "trigger": "Advisory multi-writer lock acquire/release", "distinct_because": "Concurrency coordination, not memory" },
{ "id": "leaf-readme", "pattern": "^(.+/)?README\\.md$", "method": "PUT", "trigger": "Bootstrap leaf signpost / vault root README", "distinct_because": "Human signpost, not read for routing" }
],
"retired": [
{ "pattern": "^reviews/", "retired_in_schema": 2, "replacement": "journal/{weekly,monthly,quarterly,annual}/ and _agent/health/" },
{ "pattern": "^decisions/by-project/", "retired_in_schema": 1, "replacement": "[[wikilink]] under the project's ## Key Decisions" },
{ "pattern": "^archive/", "retired_in_schema": 0, "replacement": "projects/archived/ and _agent/skills/archived/" },
{ "pattern": "^(CLAUDE|BOOTSTRAP|STRUCTURE|index)\\.md$", "retired_in_schema": 1, "replacement": "All control logic lives in the plugin references/, not the vault" }
]
}
@@ -1,42 +0,0 @@
#!/usr/bin/env python3
"""Regression tests for closeout.py helpers."""
from __future__ import annotations
import argparse
import datetime as dt
import closeout
def test_slugify() -> None:
assert closeout.slugify("Codex Plugin Closeout Fix!") == "codex-plugin-closeout-fix"
def test_session_log_render_uses_codex_client_and_related_links() -> None:
args = argparse.Namespace(
slug="codex-plugin-closeout-fix",
goal="Implement closeout helper.",
actions="Added closeout.py.",
next_step="Start a new thread to load the patched plugin.",
daily_line="Implemented closeout helper.",
related=["[[_agent/skills/active/echo-memory-codex-plugin]]"],
notes_read=[],
outputs=["`closeout.py` - session closeout helper"],
decisions=[],
open_threads=[],
)
body = closeout.render_session_log(
args,
dt.datetime(2026, 6, 20, 5, 45),
"_agent/sessions/2026-06-20-0545-codex-plugin-closeout-fix.md",
)
assert "client: codex" in body
assert "[[_agent/skills/active/echo-memory-codex-plugin]]" in body
assert "Session path:" in body
if __name__ == "__main__":
test_slugify()
test_session_log_render_uses_codex_client_and_related_links()
print("test_closeout: ok")
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
"""Regression tests for echo.py client-side normalization."""
from __future__ import annotations
import json
import echo
def test_heading_replace_body_is_newline_guarded() -> None:
assert (
echo.normalize_patch_body(b"- [x] Formalized\n", "replace", "heading")
== b"\n- [x] Formalized\n"
)
def test_heading_append_body_ends_with_newline() -> None:
assert echo.normalize_patch_body(b"- item", "append", "heading") == b"- item\n"
def test_frontmatter_plain_string_becomes_json_scalar() -> None:
assert json.loads(echo.normalize_json_scalar("Fabrication Lead")) == "Fabrication Lead"
def test_frontmatter_existing_json_is_preserved() -> None:
assert echo.normalize_json_scalar('"2026-06-20"') == b'"2026-06-20"'
if __name__ == "__main__":
test_heading_replace_body_is_newline_guarded()
test_heading_append_body_ends_with_newline()
test_frontmatter_plain_string_becomes_json_scalar()
test_frontmatter_existing_json_is_preserved()
print("test_echo_client: ok")
@@ -1,244 +0,0 @@
#!/usr/bin/env python3
"""Read-only ECHO vault invariant checker."""
from __future__ import annotations
import datetime as dt
import json
import os
import re
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import echo # noqa: E402
TODAY = dt.date.fromisoformat(os.environ.get("ECHO_TODAY") or dt.date.today().isoformat())
STALE_DAYS = int(os.environ.get("STALE_DAYS", "30"))
INBOX_DAYS = int(os.environ.get("INBOX_DAYS", "14"))
SCOPE_STALE_SESSIONS = int(os.environ.get("SCOPE_STALE_SESSIONS", "3"))
LIFECYCLES = ["active", "incubating", "on-hold", "archived"]
SKIP = {"README.md", "project-template.md", "decision-template.md"}
violations: list[tuple[str, str]] = []
def flag(check: str, message: str) -> None:
violations.append((check, message))
def get(path: str) -> str | None:
status, body = echo.request("GET", echo.vault_url(path))
if status == 404:
return None
echo.check(status, body, f"get {path}")
return body.decode(errors="replace")
def list_dir(path: str) -> tuple[list[str], list[str]]:
if path and not path.endswith("/"):
path += "/"
body = get(path)
if body is None:
return [], []
try:
payload = json.loads(body)
except json.JSONDecodeError:
return [], []
entries = list(payload.get("files", [])) + list(payload.get("folders", []))
files = [entry for entry in entries if not entry.endswith("/")]
folders = [entry[:-1] for entry in entries if entry.endswith("/")]
return files, folders
def walk(prefix: str = ""):
files, folders = list_dir(prefix)
for filename in files:
yield prefix + filename
for folder in folders:
yield from walk(f"{prefix}{folder}/")
def split_frontmatter(text: str) -> tuple[str, str]:
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return "", text
for index in range(1, len(lines)):
if lines[index].strip() == "---":
return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :])
return "", text
def parse_frontmatter(text: str) -> tuple[str, dict[str, object]]:
raw, _ = split_frontmatter(text)
fields: dict[str, object] = {}
for line in raw.splitlines():
match = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
if not match:
continue
value: object = match.group(2).strip().strip('"').strip("'")
if isinstance(value, str) and value.startswith("[") and value.endswith("]"):
value = [part.strip().strip('"').strip("'") for part in value[1:-1].split(",") if part.strip()]
fields[match.group(1)] = value
return raw, fields
def parse_date(value: object) -> dt.date | None:
match = re.match(r"(\d{4}-\d{2}-\d{2})", str(value or ""))
if not match:
return None
try:
return dt.date.fromisoformat(match.group(1))
except ValueError:
return None
def as_list(value: object) -> list[object]:
if value in (None, ""):
return []
return value if isinstance(value, list) else [value]
def route_matchers() -> tuple[list[tuple[str, re.Pattern[str]]], list[tuple[re.Pattern[str], str]]]:
routing = json.loads((SCRIPT_DIR / "routing.json").read_text(encoding="utf-8"))
routes = [(item["id"], re.compile(item["pattern"])) for item in routing.get("routes", [])]
retired = [(re.compile(item["pattern"]), item.get("replacement", "")) for item in routing.get("retired", [])]
return routes, retired
def main() -> int:
try:
if get("_agent/echo-vault.md") is None:
print("vault-lint: marker missing - vault not bootstrapped (run bootstrap.py).", file=sys.stderr)
return 3
except Exception as exc:
print(f"vault-lint: vault unreachable ({exc}).", file=sys.stderr)
return 2
try:
routes, retired = route_matchers()
except Exception as exc:
routes, retired = [], []
flag("routing-manifest", f"could not load routing.json ({exc}) - path checks skipped")
all_files = list(walk())
for path in all_files:
if routes and not any(rx.match(path) for _, rx in routes):
replacement = next((repl for rx, repl in retired if rx.match(path)), None)
if replacement is not None:
flag("retired-path", f"{path}: retired location - should be {replacement}")
else:
flag("unknown-path", f"{path}: matches no route in routing.json")
template_re = re.compile(r"(^|/)(templates/|.*-template\.md$)")
for path in all_files:
base = path.rsplit("/", 1)[-1]
if base in SKIP or template_re.search(path) or not path.endswith(".md"):
continue
text = get(path) or ""
raw, fm = parse_frontmatter(text)
if "[[" in raw:
flag("frontmatter-wikilink", f"{path}: '[[...]]' inside frontmatter")
for key in ["type", "created"]:
if fm and not str(fm.get(key, "")).strip():
flag("missing-frontmatter", f"{path}: missing {key}")
created, updated = parse_date(fm.get("created")), parse_date(fm.get("updated"))
if created and updated and updated < created:
flag("date-order", f"{path}: updated {updated} is before created {created}")
if updated and updated > TODAY:
flag("future-date", f"{path}: updated {updated} is in the future (today {TODAY})")
for source_note in as_list(fm.get("source_notes")):
if "[[" in str(source_note):
flag("source-notes-wikilink", f"{path}: source_notes contains wikilink '{source_note}'")
slug_homes: dict[str, list[str]] = {}
for lifecycle in LIFECYCLES:
files, _ = list_dir(f"projects/{lifecycle}")
for filename in files:
if filename in SKIP or not filename.endswith(".md"):
continue
slug = filename[:-3]
slug_homes.setdefault(slug, []).append(lifecycle)
text = get(f"projects/{lifecycle}/{filename}") or ""
_, fm = parse_frontmatter(text)
status = str(fm.get("status", "")).strip()
if status and status != lifecycle:
flag("folder/status", f"projects/{lifecycle}/{filename}: status='{status}' but folder='{lifecycle}'")
if lifecycle == "active":
updated = parse_date(fm.get("updated"))
if updated and (TODAY - updated).days > STALE_DAYS:
flag("stale-active", f"projects/active/{filename}: updated {updated} ({(TODAY - updated).days}d ago)")
for slug, homes in slug_homes.items():
if len(homes) > 1:
flag("duplicate-slug", f"'{slug}' exists in {', '.join(homes)}")
for path in all_files:
if re.match(r"^journal/daily/.*\.md$", path):
text = get(path) or ""
count = len(re.findall(r"(?m)^## Agent Log\s*$", text))
if count > 1:
flag("duplicate-agent-log", f"{path}: {count} '## Agent Log' headings")
inbox = get("inbox/captures/inbox.md") or ""
for line in inbox.splitlines():
match = re.match(r"^\s*-\s*(\d{4}-\d{2}-\d{2})\b", line)
date = parse_date(match.group(1)) if match else None
if date and (TODAY - date).days > INBOX_DAYS:
flag("aging-inbox", f"inbox capture {date} ({(TODAY - date).days}d): {line.strip()[:80]}")
context = get("_agent/context/current-context.md")
if context is not None:
_, fm = parse_frontmatter(context)
scope_updated = parse_date(fm.get("scope_updated"))
if scope_updated is None:
flag("scope-no-timestamp", "_agent/context/current-context.md: no scope_updated frontmatter")
else:
since = [
path
for path in all_files
if (match := re.match(r"^_agent/sessions/(\d{4}-\d{2}-\d{2})", path))
and (date := parse_date(match.group(1)))
and date > scope_updated
]
if len(since) >= SCOPE_STALE_SESSIONS:
flag("scope-stale", f"scope set {scope_updated}; {len(since)} sessions logged since without a switch")
if not violations:
print("vault-lint: clean - all invariants hold.")
return 0
print(f"vault-lint: {len(violations)} violation(s) found\n")
labels = {
"folder/status": "Folder <-> status mismatch",
"duplicate-slug": "Duplicate slug across lifecycle folders",
"frontmatter-wikilink": "Wikilink in frontmatter",
"duplicate-agent-log": "Duplicate Agent Log heading",
"stale-active": f"Stale active project (> {STALE_DAYS}d)",
"aging-inbox": f"Inbox capture aging (> {INBOX_DAYS}d)",
"unknown-path": "Path matches no route",
"retired-path": "Retired path",
"missing-frontmatter": "Missing required frontmatter",
"date-order": "updated earlier than created",
"future-date": "updated date is in the future",
"source-notes-wikilink": "Wikilink in source_notes",
"routing-manifest": "routing.json problem",
"scope-no-timestamp": "Scope has no timestamp",
"scope-stale": f"Scope may have drifted (>= {SCOPE_STALE_SESSIONS} sessions)",
}
grouped: dict[str, list[str]] = {}
for check, message in violations:
grouped.setdefault(check, []).append(message)
for check, messages in grouped.items():
print(f"## {labels.get(check, check)}")
for message in messages:
print(f" - {message}")
print()
return 1
if __name__ == "__main__":
raise SystemExit(main())
+353
View File
@@ -0,0 +1,353 @@
# echo-memory — Improvement Plans (post-1.5.1 review)
> Status: **planned, not started.** Source: full-code review 2026-07-28.
> Nine improvements, each scoped for an independent build session. The tenth item
> from that review — the **MCP server** — has its own detailed spec:
> `docs/MCP-SERVER-SPEC.md`.
>
> Sequencing is at the bottom. Nothing here conflicts with `TODO-1.6.md` or
> `ROADMAP-2.0.md`; release targets assume 1.6 (skills migration) ships first.
---
## 1. Token-budgeted load (`load --brief`) + parallel orientation reads
**Problem.** The SessionStart hook injects the *full text* of six files into every
session. Live measurement: 15.9KB of hook context on a single cold start, and two of
the six files grow without bound (operator-preferences `## Observations`,
current-context `## Scope History`). Every session pays this tax before any work
happens. Separately, `cmd_load` (echo.py) issues its six GETs serially even though
`read_many()` exists.
**Design.**
- New `echo.py load --brief` (and make the SessionStart hook use it):
- marker → one line (`schema_version`, bootstrapped yes/no).
- operator-preferences → `## Fact / Pattern` in full; `## Observations` capped at
the last 10 lines with a `(+N older — /echo-load for full)` note.
- current-context → `## Scope` + `scope_updated` + sessions-since (i.e. the
`scope show` output); omit `## Scope History`.
- heartbeat → the pointer line + only the `## Summary`/`## Outcomes` sections of
the pointed-at session log (fetch it, extract those headings, cap ~30 lines).
- today's daily note → `## Agent Log` lines only (or "absent").
- inbox → count + age of oldest item, not the contents (that's all the reconcile
needs; `/echo-triage --list` has the details).
- Budget guard: after assembly, if the brief output still exceeds
`ECHO_LOAD_BUDGET` chars (default ~8000), truncate lowest-priority sections first
(observations → agent log → session summary) with explicit `(truncated)` markers.
- Full `load` unchanged; `/echo-load` keeps using it.
- Fetch all reads via `read_many()` (the 6 targets + the heartbeat-pointed session
log + the sessions listing fallback) instead of the serial loop at
`echo.py:640`. Cache-put still applies per path.
**Files.** `echo.py` (cmd_load), `echo_hook_session_start.py`, SKILL.md (document
brief-vs-full), `eval/test_features.py`.
**Tests.** Mock vault with oversized preferences/history files → assert brief output
under budget, sections present, truncation markers correct; hook emits brief.
**Size/target.** Small-medium. **1.6.x point release** — highest leverage per line.
---
## 2. Local-first recall index
**Problem.** `echo_recall.update_note()` does GET-whole-index → add → PUT-whole-index
against the vault, under the global advisory lock, on every capture and every corpus
`put`. The index carries full BM25 postings for the whole corpus, so this round-trip
is O(vault) network per write and is the emerging bottleneck + lock hot-spot as the
vault grows past a few hundred notes.
**Design.**
- The **live** recall index moves to the local state dir
(`~/.echo-memory/recall-index.json`, honoring `ECHO_STATE_DIR`) keyed by endpoint
(hash of `BASE`) so multiple vaults don't collide.
- `update_note()` becomes a local read-modify-write (file lock via `os.O_EXCL`
sidecar or atomic `os.replace`), **no vault round-trip, no advisory lock**.
- The vault copy (`_agent/index/recall-index.json`) becomes a **snapshot**, written
by `sweep.py` and at session end (piggyback on the heartbeat write / future
`session-end` verb). It exists so a *fresh machine* can seed its local index
without a full rebuild.
- Freshness rule on recall: local index used when present; if the vault snapshot's
embedded `built` stamp is newer than the local one (another client swept), pull it.
Staleness across clients is tolerable — recall degrades gracefully and improvement
#4 (incremental sweep) trues it up cheaply.
- Schema bump: recall-index schema 3 adds `built` (ISO timestamp) + `endpoint_hash`.
Old schema-2 vault copies are still readable as seeds.
- The **entity index stays in the vault** (it is small, and it is the shared routing
authority for concurrent clients) — only the BM25 index moves.
**Files.** `echo_recall.py` (load/save/update_note/rebuild), `echo_queue.py`
(state-dir helpers shared), `sweep.py`, `echo.py` (cmd_put upkeep path), routing.json
(the vault snapshot path is unchanged), README performance section.
**Tests.** Capture with vault mock → assert zero recall-index PUTs; sweep → snapshot
written; fresh state dir + existing snapshot → seeded without rebuild; two-vault
(endpoint) separation.
**Size/target.** Medium. **Pair with #4 in one index-focused minor** (they touch the
same maintenance loop). Fine to land before or after 2.0.
---
## 3. Offline durability for the high-level ops (capture path)
**Problem.** The low-level verbs queue on outage via `safe_request`, but
`_append_to_existing()` and `ensure_daily_log()` (echo_ops.py) call `echo.request`
directly — so during an outage a raw `append` survives, while a `capture` **update**
and its Agent Log line silently vanish. Inverted from the documented contract
("capture is the default write").
**Design.** Queue *semantic* records, not byte-level requests, for the high-level
path:
- New outbox record type `{"op": "capture", "args": {...}}` alongside the existing
raw records. On an unreachable vault, `capture` short-circuits: enqueue the full
argument set (title/kind/body/tags/aliases/sources/date/domain/merge_into) and
report `queued (offline): capture …`.
- `flush()` replays `op:capture` records by calling `echo_ops.capture(...)` against
the *current* index — so routing, the duplicate gate, aliasing, and linking all
re-run with fresh state instead of replaying stale byte writes. A gate stop on
replay (exit 76) keeps the record queued with a `needs_attention` flag and is
surfaced by `load`/`flush` output ("1 queued capture needs --merge-into/--force").
- Rationale for semantic-over-byte: a byte replay of `_append_to_existing`'s PATCH
could target a heading that moved, and the create-vs-update decision made offline
may be wrong by replay time. Re-running the op is the idempotent, correct unit
(the dated-bullet idempotency key already prevents double-append).
- `ensure_daily_log` stays best-effort but routes its POST/PATCH through
`safe_request` so a standalone agent-log line survives an outage too.
- reflect/triage `--apply` inherit this for free (they call capture).
**Files.** `echo_ops.py`, `echo_queue.py` (record schema v2 + replay dispatch),
`eval/test_offline_queue.py`.
**Tests.** Fault-injected mock: capture-update offline → queued; flush replays via
capture; gate-on-replay keeps record + flags; agent-log line queued; no duplicate
bullets on double flush.
**Size/target.** Small-medium. **1.6.x point release** — it's a correctness gap.
---
## 4. Incremental sweep via content hashes
**Problem.** Human edits in Obsidian and writes by other clients never touch the
entity/recall indexes; drift is only corrected by a full sweep someone must remember
to run. Full sweep re-reads the whole vault (fast now, but O(vault)).
**Design.**
- Entity index entries and the recall-index snapshot gain a per-note
`h` — SHA-1 of the note body (entity index schema 2; tolerated-absent for old
entries).
- `sweep.py --fast`: walk the listing (cheap), `read_many` only paths that are
(a) new, (b) missing a stored hash, or (c) whose hash mismatches after fetch — to
avoid fetching everything just to hash it, fast mode fetches only paths whose
**listing is new/gone** plus a rotating shard (e.g. `hash(path) % 7 == weekday`)
of existing notes, so the whole vault is verified over a week of fast sweeps while
each run stays tiny. `--fast --all-shards` forces full verification.
- Deletions: listing walk catches removed files → drop index + recall entries.
- Auto-run: `load` (brief or full) runs `sweep --fast` opportunistically when the
last fast-sweep stamp (state dir) is older than `ECHO_FAST_SWEEP_DAYS` (default 7),
in the background-tolerant sense: bounded by the same read_many concurrency, and
skipped entirely when offline.
- `/echo-health` reports last fast/full sweep ages.
**Files.** `sweep.py`, `echo_index.py` (schema 2 + hash field), `echo_recall.py`
(snapshot hash reuse), `echo.py` (load hook-in), `vault_lint.py` (report), migrate
note (index schema is machine-owned; no vault migration needed).
**Tests.** Mock: edit a note out-of-band → fast sweep in that shard re-indexes it;
delete → entry dropped; stamp gating; full sweep unchanged.
**Size/target.** Medium. **Ship with #2** (same schema-bump train).
---
## 5. Memory lifecycle — enforce forgetting
**Problem.** The vault only accretes. Working memory is "time-boxed" by convention
only; Observations trimming is manual prose; stale-active detection only reports.
Recall precision degrades as dead weight accumulates.
**Design.**
- `capture --kind working` stamps `expires: <today + ECHO_WORKING_TTL_DAYS>`
(default 14; `--ttl <days>` overrides). Template updated to carry the field.
- `vault_lint.py` new checks: `expired-working` (past `expires`),
`stale-observations` (Observations > 30 bullets) — advisory, like the rest.
- `sweep.py --decay` (dry-run by default, `--apply` to write; **always
preview-first**, honoring the operating contract):
- expired working notes → `status: archived` + move body under a dated
`## Archived` marker (never delete);
- `projects/active/` untouched > 30 days → *propose* `on-hold` (folder move +
status, the usual paired change) — apply only the ones confirmed;
- Observations > 30 → trim oldest into a dated `_agent/memory/episodic/`
overflow note (`observations-archive-YYYY.md` append), keeping the last 30
in place. Nothing is lost, the hot file stays small (compounds with #1).
- `/echo-health` output nudges: "run `sweep --decay` to review N aging items".
**Files.** `echo_ops.py` (`--ttl`/expires stamp), `echo_index.py`
(KIND_REQUIRED_FM unchanged; working gains optional expires), `vault_lint.py`,
`sweep.py`, scaffold working-memory template, SKILL.md (decay section),
routing-map note.
**Tests.** Mock: expired note proposed+archived on apply; observations trim keeps
last 30 and appends overflow; stale-active proposal list; dry-run writes nothing.
**Size/target.** Medium. Feature minor (1.7.x).
---
## 6. Contradiction / supersession handling
**Problem.** A new fact that contradicts a stored one lands beside it; both surface
in recall with no signal about which is current. Biggest remaining trust gap in the
write path.
**Design.**
- **Write side:** `capture --supersedes "<slug-or-quoted-line>"`:
- if it names an entity slug → the old entity gets `status: superseded` +
a `superseded_by:` frontmatter path; new note carries `supersedes:` back-link
in `## Related`. Recall's STATUS_FACTOR gains `superseded: 0.5`.
- if it quotes a Fact/Pattern or Observations line in operator-preferences →
the line is struck (`~~old~~ (superseded YYYY-MM-DD)`) and the new line
appended, one PATCH each, in the same invocation.
- **Detect side (deterministic assist, model decides):** `echo_reflect.classify`
gains a `conflict scan` — for each proposal, run its distinctive tokens
(`_sig_tokens`) against the Fact/Pattern + recent Observations lines; overlapping
lines are attached as `_conflicts: [...]` and the preview row renders
`update-or-conflict?` with the matched line(s). The model (which has the
conversation) decides supersede-vs-append; the script only guarantees the
question gets asked before the write.
- Lint: `superseded_by` pointing at a missing note → violation.
**Files.** `echo_ops.py`, `echo_reflect.py`, `echo_recall.py` (STATUS_FACTOR),
`echo.py` (flag plumbing), `vault_lint.py`, SKILL.md + operating-contract
(supersession is additive: strike, never delete).
**Tests.** Mock: supersede entity → status/backlinks both sides; supersede line →
strike + append idempotent; reflect preview flags a planted conflict; recall ranks
superseded below active twin.
**Size/target.** Medium. Feature minor (1.7.x/1.8) — after #5 (shares the
status-vocabulary touchpoints).
---
## 7. `session-end` bundle verb
**Problem.** Ending a session correctly is 4+ invocations (session-log PUT, Agent
Log append, heartbeat PUT, optional scope set, optional reflect apply). Cost per
session, and a partial failure leaves broken orientation state (log written,
heartbeat stale).
**Design.**
- `echo.py session-end <bundle.json> [--apply]`, bundle:
```json
{
"slug": "echo-mcp-spec",
"log_body": "<full session-log markdown, frontmatter included>",
"agent_log_line": "- 2026-07-28: wrote MCP spec [[...]]",
"scope": "optional new scope text",
"reflect": [ { ...PROPOSAL_SCHEMA... } ]
}
```
- Dry-run previews the whole plan (paths + reflect preview). `--apply`, in order,
under **one** lock acquisition: PUT `_agent/sessions/<date>-<HHMM>-<slug>.md`
(HHMM from `ECHO_NOW` env or wall clock, validated against the canonical regex) →
agent-log line (ensure_daily_log) → reflect proposals via capture → scope set if
given → heartbeat PUT **last** (it's the commit marker: a crash before it leaves
the previous pointer intact, never a dangling one).
- Reports per-step ok/queued/gated; offline → every step queues (rides on #3).
- Stop hook reason text updated to name the one command; `/echo-reflect` docs point
session logging at it. **Prerequisite for the MCP `echo_log_session` tool** —
build this verb first so CLI and MCP share one implementation.
**Files.** new `echo_session.py`, `echo.py` (subcommand), `echo_hook_stop.py`
(REASON text), SKILL.md (Session Logging section), commands/echo-reflect.md.
**Tests.** Mock: full bundle lands all five artifacts; heartbeat-last ordering
(inject failure mid-way → heartbeat untouched); dry-run writes nothing; offline
queues all steps.
**Size/target.** Small-medium. **1.6.x point release** (and before the MCP build).
---
## 8. Recall robustness — stemming + alias query expansion
**Problem.** BM25 is purely lexical: "deploy"/"deployment"/"deployed" are distinct
terms; a paraphrased query misses. The 8-query gold set can't see this class of miss.
**Design.**
- **Stemming:** a light suffix-stripper (Porter-lite: s/es/ed/ing/ly/tion/ment/ness
families, ~40 lines, pure stdlib) applied in `tokenize()` at **both** index and
query time. Index schema bump (stemmed postings) → auto-rebuild on next
recall/sweep, exactly like the 1→2 transition.
- **Alias query expansion:** at recall time, run the query against
`fuzzy_candidates()` (top 2, score ≥ 0.5); fold the matched entities' title +
alias tokens into the BM25 query as **down-weighted expansion terms** (×0.5 idf
contribution) so "the ECHO plugin" also scores `echo-memory`-alias vocabulary.
Exact-match seeding stays as-is; expansion never *creates* hits ranked above
genuine lexical matches without corpus support.
- **Eval:** grow the gold set with ≥6 paraphrase queries (morphological variants +
alias phrasings); publish before/after recall@5/MRR in the README table.
**Files.** `echo_recall.py` (tokenize, INDEX_SCHEMA, score, recall), new
`echo_stem.py` (pure function, unit-testable), `eval/run_eval.py` + gold set.
**Tests.** Stemmer unit table (word → stem, incl. non-cases); paraphrase gold
queries pass; schema-2 index discarded and rebuilt cleanly.
**Size/target.** Small-medium. Ride the **same index-schema train as #2/#4**.
---
## 9. Resolve-miss telemetry → alias suggestions
**Problem.** Every resolve that misses but ends in a confirmed candidate
(`--merge-into`, or the operator picking a candidate) is a free mention→entity
training pair, currently discarded.
**Design.**
- `echo_index.resolve()` miss + `fuzzy_candidates` non-empty → append one NDJSON
record to the local state dir (`~/.echo-memory/resolve-misses.ndjson`):
`{mention, candidates:[slugs], ts?}` (no wall clock needed — session date via
ECHO_TODAY). `capture --merge-into <slug>` appends the *confirmation* record
`{mention, resolved: slug}` (this already learns the alias immediately — that
path stays; the log covers the cases that never reach --merge-into).
- `sweep.py` (and `/echo-health`) read the log: any `{mention → slug}` pair
confirmed ≥2 times, or any mention that repeatedly fuzzy-matched a single
candidate ≥3 times without confirmation, is **proposed** as an alias
(`sweep --apply` writes it into the note's `aliases:` frontmatter + index, the
existing fold-back path). Log entries consumed on apply; file capped (rotate at
~500 lines).
- Privacy note: mentions can contain operator phrasing — the log lives in the
state dir (never the vault), same trust level as the offline queue.
**Files.** `echo_index.py` (log hooks), `echo_ops.py` (confirmation hook),
`sweep.py` (suggest/apply), `vault_lint.py` or sweep report (surface count),
SKILL.md one-liner.
**Tests.** Miss → record written; confirm ×2 → sweep proposes; apply folds alias +
consumes log; cap/rotation.
**Size/target.** Small. Feature minor, pairs naturally with #8.
---
## Sequencing (unified with TODO-1.6 and ROADMAP-2.0)
TODO-1.6 folds in as the opening train — the skills migration restructures the
files everything below touches, so it goes first. ROADMAP-2.0 stays a **pure
packaging major** (its own stated thesis) and is *not* folded into any feature
train; it slots between the quick wins and the MCP server so the MCP work lands
on the final packaging baseline (single command format, Gitea releases, one
manifest-verification pass).
| Release | Items | Rationale |
|---|---|---|
| **1.6.0** | TODO-1.6: skills-format migration (+ per-skill `allowed-tools`, `disable-model-invocation` on write-heavy entries, `$ECHO` block dedupe) · lint blind-spot fix (retired-dir/leaf-README) · plugin.json `homepage` | Restructures the command/SKILL files later work edits; allowed-tools removes prompt friction for every verb added below. Vault link repointing (TODO-1.6 §3) is vault data, not plugin code — any session. |
| **1.6.0 — SHIPPED 2026-07-28** | Skills migration + lint fix + homepage | Verified on desktop + CoWork. |
| **2.0.0 — SHIPPED 2026-07-28** | ROADMAP-2.0: commands/ deleted · release-based artifacts · gitignore · Codex note | Packaging-only major; skills-only install re-verified. |
| **2.1.0 quick wins — SHIPPED 2026-07-28** | #1 brief load · #3 offline capture · #7 session-end | All three landed with +19 e2e checks; see CHANGELOG 2.1.0. The MCP spec's Phase 0 (return-not-print) did NOT ride along — it's the first phase of the MCP build session. |
| **2.2** | MCP server (containerized, `echomcp.alwisp.com`) — `docs/MCP-SERVER-SPEC.md` | Needs Phase 0 (return-not-print refactor, first step of the build session). #7 session-end shipped, so `echo_log_session` wraps a real verb. Deployed via CI + PORT, not the plugin manifest. |
| **2.3 index train** | #2 local-first recall (CLI-path half) · #4 incremental sweep · #8 stemming+expansion | One schema-bump train; all touch the same index/sweep loop. The container already keeps its index warm in memory (spec §7), so #2 here covers the CLI fallback path; #4's fast sweep also becomes the container's background job. |
| **2.4 memory train** | #5 lifecycle/decay · #6 supersession · #9 resolve-miss learning | Capability tier; #6 after #5 (status vocabulary). |
+391
View File
@@ -0,0 +1,391 @@
# echo-mcp — MCP Server Build Spec (containerized)
> Status: **spec for a future build session** (written 2026-07-28 against v1.5.1;
> revised same day: **remote container architecture**, operator decision — heavy
> lifting belongs in a deployed container, not on any one machine).
> Companion plan for the other nine review items: `docs/IMPROVEMENT-PLANS.md`.
>
> **Prerequisites before starting this build:**
> 1. The `session-end` verb (IMPROVEMENT-PLANS #7) — the MCP tool wraps it.
> 2. Phase 0 below (the return-not-print refactor) — lands in the plugin tree
> first and is independently shippable.
---
## 1. Why an MCP server
Every ECHO operation today rides through Bash: resolve `$ECHO` (with the CoWork
path-fallback snippet copy-pasted everywhere), quote a Python invocation, run
it, re-parse stdout. Costs:
- **Tokens** — each memory op carries bash scaffolding + output re-parsing; a
capture is ~3× the tokens of a typed tool call.
- **Fragility** — quoting hazards, the `${CLAUDE_PLUGIN_ROOT}` sandbox mismatch
(the whole 1.4.2 release), Windows `python3`-vs-`python` dispatch.
- **Reach** — surfaces without a Python-capable shell can't use ECHO at all.
- **Efficiency** — every CLI invocation is a cold Python start that re-reads the
entity index from the vault over HTTPS.
What the server does **not** replace: SKILL.md remains the procedure authority
(when to load, reconcile, search-first, third-person, etc.). The server replaces
the *mechanics* — tools are the verbs, the skill is the discipline.
## 2. Architecture: remote container (decided)
A standalone Docker container on the Unraid box (ALPHA), published behind
Cloudflare + NPM as **`echomcp.alwisp.com`**, speaking **MCP streamable HTTP
(stateless JSON)** with bearer-token auth. Chosen over the earlier local-stdio
draft for four reasons:
1. **The backend is already remote.** Clients round-trip to
`echoapi.alwisp.com` today; a server co-located with the vault host turns
every vault op into a LAN hop and collapses N client→vault calls into one
client→server call.
2. **Any device, no per-machine anything.** Desktop, CoWork sandbox, claude.ai
(custom connector), mobile — same URL + token. The `${CLAUDE_PLUGIN_ROOT}`
CoWork registration risk from the stdio draft disappears entirely.
3. **Credentials consolidate server-side.** The vault key lives in the
container env (PORT secret store); the image is credential-free; clients
hold only an MCP bearer token. The per-user baked-key builds (1.4.x) become
unnecessary for any surface that has MCP. Rotation = redeploy.
4. **Room to grow.** The plugin's pure-stdlib constraint exists because its
scripts run in arbitrary sandboxes. The container is ours: real SDK,
SQLite/in-memory indexes that stay warm, background jobs, embeddings later —
all without touching the plugin.
**What stays client-side (unchanged and shipped):** the skill + CLI path — it
is the fallback when the server or network is down, the offline queue + read
cache keep their per-machine role there, and the SessionStart/Stop **hooks stay
CLI-based** (hooks run shell commands regardless of MCP).
**Cost acknowledged:** a second service to run and an exposed auth surface.
Mitigations are existing infra: CF + NPM cert, long random bearer token, Kuma
monitor, PORT deploy/rollback. Server down ⇒ exactly today's behavior.
## 3. Decisions (made — don't relitigate in the build session)
| Decision | Choice | Why |
|---|---|---|
| Language | **Python** | Imports the existing `echo_*.py` modules in-process — the entire ops layer is reused, not wrapped. |
| SDK | **Official `mcp` Python SDK (FastMCP)** | The stdlib-only rule doesn't bind inside our own image. Hand-rolling the protocol (stdio draft) is no longer justified. |
| Transport | **Streamable HTTP, stateless JSON** | Remote, multi-client, simplest to scale; SSE/sessions explicitly avoided. |
| Auth | **Static bearer token** (`ECHO_MCP_TOKEN`), constant-time compare, 401 otherwise | Single-operator service; OAuth is overkill. Token from the PORT secret store. |
| Vault credentials | Container env: `ECHO_BASE`, `ECHO_KEY`, `ECHO_OWNER` (SECRET: refs in the PORT template) | `echo_config.py` already resolves env-first — zero code change. Image ships no secrets. |
| Server name / repo home | `echo-mcp`; lives in this repo under `mcp-server/` (own Dockerfile), deployed from `git.alwisp.com/jason/echo` CI | One canonical tree — the server vendors `skills/echo-memory/scripts/` into the image at build time; no second hand-maintained copy (the Codex-drift lesson). |
| Tool prefix | `echo_` | Namespace safety next to other servers. |
| Result shape | `structuredContent` (the `echo_output.envelope` dict) + a 13 line human text block | Envelope shape already exists. |
| Statefulness | Stateless protocol; warm in-process caches | See §7. |
| Version target | **2.2** — ALL prerequisites shipped 2026-07-28 (`session-end` in 2.1.0; **Phase 0 in 2.1.1**, contract-tested by `eval/test_ops_api.py`) | The build session starts directly at §5 (the server app). |
| Local stdio variant | **Dropped for v1** (documented, not built) | The CLI/skill fallback covers the no-server case; two transports = two test matrices for little gain. Phase 0 keeps the door open — the `*_op` core is transport-agnostic. |
| Result budgets | Every read tool takes an explicit size budget (`budget_chars` / `max_chars`) and truncates with a marker + "call X for more" | The single biggest token lever: one right-sized answer instead of follow-up fetches. |
| Tool profiles | `ECHO_MCP_TOOLS=core\|full` env: `core` exposes only load/recall/capture/triage_inbox/log_session/health | Tool schemas cost context on every surface that lists them; the claude.ai connector doesn't need the escape hatches. Desktop registers `full`. |
## 4. Phase 0 — the return-not-print refactor (prerequisite, lands in the plugin)
Unchanged from the stdio draft, and still first: the ops layer prints results
(even `--json` mode prints from inside `echo_ops.capture`) and returns exit
codes; the server needs return values.
1. Each high-level op gets a **core function returning an envelope dict**,
raising `echo.EchoError` on failure; CLI entry points become thin printing
wrappers:
| Module | New core fn | CLI wrapper keeps |
|---|---|---|
| `echo_ops` | `capture_op`, `resolve_op`, `link_op` | `capture/resolve/link` |
| `echo_recall` | `recall_op(query, limit)` | `recall` |
| `echo_triage` | `list_op()` · `route_op(proposals, apply)` | `list_inbox/apply` |
| `echo_reflect` | `apply_op(proposals, apply)` | `apply` |
| `echo.py` | `load_op(brief)` · `scope_show_op/scope_set_op` · `doctor_op` | `cmd_*` |
| `echo_session` (new, #7) | `session_end_op(bundle, apply)` | subcommand |
2. Special results are **data, not exit codes**: duplicate gate ⇒
`{ok: false, action: "duplicate-gate", candidates}` (CLI maps to exit 76);
offline queueing ⇒ `{ok: true, queued: true}`.
3. Helper chatter (`ok: PUT …`) moves behind a `notify(msg)` callback
defaulting to stderr; delete the `redirect_stdout` hack in `capture`.
4. Tests: existing suites pass unchanged; new unit tests hit `*_op` directly
against the mock and assert envelopes.
Shippable on its own as a 1.6.x refactor with zero behavior change.
## 5. Server application (`mcp-server/app.py`)
- **FastMCP** app, streamable HTTP, stateless. One `@mcp.tool` per tool in §6,
each a thin adapter: validate → call the `*_op` core → wrap.
- **Auth middleware**: require `Authorization: Bearer <ECHO_MCP_TOKEN>` on every
request (constant-time compare); 401 with no detail otherwise. Additionally
bind the app to the container interface only; exposure policy lives at
NPM/Cloudflare.
- **Result wrapper**:
```python
def tool_result(env: dict, summary: str) -> ...:
# content: [{type: "text", text: summary}] (13 lines, human)
# structuredContent: env (echo_output envelope)
# isError: not env.get("ok", True) — except the deliberate non-errors below
```
- **Error map** (tool-level results, never protocol errors):
| Condition | Result |
|---|---|
| Vault unreachable on a **read** | `isError: true` — "vault unreachable (Obsidian/REST plugin likely down); proceed without memory, writes will queue server-side". |
| Vault unreachable on a **write** | **Not an error**: `{ok: true, queued: true}` — the server-side outbox (§7) replays when the vault returns. |
| 404 | `isError: true`, `{code: "not-found", path}`. |
| Duplicate gate | **Not an error**: `{ok: false, action: "duplicate-gate", candidates}` + text naming the two resolutions (`merge_into` / `force`) — the model must decide, not blind-retry. |
| Lock held | `{ok: false, action: "lock-held", holder}`. |
| Misconfigured deployment (no `ECHO_BASE`/`ECHO_KEY`) | `isError: true` — "server deployment is missing vault credentials — operator: check the PORT template env". Startup also fails loudly (see §8 healthcheck). |
| Anything else | `isError: true`, first line only; traceback to the container log. |
- **Validation**: FastMCP/pydantic handles types/enums/required; add
`model_config = ConfigDict(extra="forbid")` so unknown params (model typos)
fail with a field-naming message.
## 6. Tool surface (13 tools)
Same surface as the stdio draft **minus `echo_configure`** — with a remote
server there is no per-machine config to import; credentials are
deployment-side. Descriptions are written for the model: what it does, when to
use it, what it returns, ≤3 sentences.
### 6.1 Orientation & read
- **`echo_load`** — cold-start orientation. `brief: bool = true`
(IMPROVEMENT-PLANS #1 digest; `false` = full six-file dump). Returns
`{sections: {marker, preferences, scope, last_session, today, inbox_count,
inbox_oldest_days}, queued_flushed, offline}`. Also flushes the server-side
outbox. `readOnlyHint: false` (flush), `openWorldHint: true`.
- **`echo_recall`** — `query: str`, `limit: int = 6 (120)`,
`include_linked: bool = true`, `budget_chars: int = 4000 (50020000)`. Returns
the `recall --json` shape (`primary` / `linked` with
path/score/type/updated/status/excerpt) **packed to the budget**: the server
allocates excerpt space by score, so the model gets the most relevant content
in one right-sized answer instead of follow-up fetches. Description: truncated
hits are marked — call `echo_get_note` for full content.
`readOnlyHint: true`, `idempotentHint: true`.
- **`echo_resolve`** — `mention: str` → match `{slug, path, kind, title,
aliases}` or `{match: false, suggest_slug, candidates}`. "Call before creating
any note by hand; `echo_capture` does this automatically." `readOnlyHint: true`.
- **`echo_get_note`** — `path: str` (vault-relative; reject `..`/leading `/`;
warn-not-block on paths outside `routing.json`); `section: str = null`
(a heading name — return only that section, e.g. `Status`);
`max_chars: int = 8000`. Returns `{path, content, frontmatter, truncated?}`.
`readOnlyHint: true`.
- **`echo_get_scope`** — `{scope, scope_updated, sessions_since}`; description
tells the model to confirm scope with the operator when `sessions_since ≥ 3`.
`readOnlyHint: true`.
- **`echo_set_scope`** — `scope: str`; atomic switch (history + replace +
stamp). `idempotentHint: true`.
- **`echo_health`** — `deep: bool = false`. Shallow = doctor checks (vault
reachability, auth, marker/schema, outbox depth). Deep = full `vault_lint`
→ `{violations: [{check, path, detail}]}`. `readOnlyHint: true`.
### 6.2 Write
- **`echo_capture`** — the default write (route + frontmatter + index +
auto-link + agent-log in one call). Params: `title` (req); `kind:
enum[person, company, concept, reference, meeting, project, area, semantic,
episodic, working, skill, decision]` (omit ⇒ inbox line); `body: str = ""`
(markdown, inline); `tags/aliases/sources: str[]`; `status: str`;
`date: YYYY-MM-DD` (meeting/decision); `domain: enum[business, personal,
learning, systems] = business` (area); `merge_into: str` (slug);
`force: bool = false`; `dry_run: bool = false`. Returns the capture envelope
(`action: created|updated|inbox|duplicate-gate`, `path`, `links_added`,
`near_duplicates?`, `candidates?`). Description spells out the gate contract
(never blind-retry; `merge_into` or confirmed `force`). `destructiveHint:
false`, `idempotentHint: true`.
- **`echo_link`** — `a`, `b` (paths **or resolvable names** — server resolves
via the index). Returns `{a, b, a_changed, b_changed}`. `idempotentHint: true`.
- **`echo_append_note`** — `path`, `line`; whole-line idempotent append.
- **`echo_patch_note`** — `path`, `operation: enum[append, prepend, replace]`,
`target_type: enum[heading, frontmatter, block]`, `target`, `content`.
Description carries the hard-won rules: heading targets are the full
`::`-delimited path from the H1; on a 400 invalid-target the server fetches
the document map and returns the actual heading list in the error — the worst
silent-loss failure becomes self-correcting. `destructiveHint: true`.
(Deliberately **no `echo_put_note` / `echo_delete_note`** in v1 — whole-file
overwrite and deletion stay behind the CLI + operator explicitness; session
logs go through `echo_log_session`.)
- **`echo_triage_inbox`** — `proposals: object[] = []` (PROPOSAL_SCHEMA +
optional `line`), `apply: bool = false`. Empty ⇒ structured listing; with
proposals ⇒ preview, then route + processing-log audit on `apply: true`.
"List → propose → preview → apply only after the operator confirms."
- **`echo_reflect`** — `proposals: object[]` (req), `apply: bool = false`. Same
preview/apply contract as the CLI; description restates: never apply without
the operator's go-ahead, never invent memories.
- **`echo_log_session`** — the session-end bundle (wraps `session_end_op`, #7):
`slug`, `log_body`, `agent_log_line`, `scope?: str`, `reflect?: object[]`,
`apply: bool = false`. Per-step results; heartbeat written last as the commit
marker.
### 6.3 v1.1 tools (specced now, built after v1 ships)
- **`echo_rollup_data`** — `period: enum[week, month]`, `date: YYYY-MM-DD`.
Assembles the rollup *digest data* in one call: open threads across
`projects/active/` (each note's `## Status` + `updated:`), inbox items aging
past 7 days, and the period's `## Scope History` entries. The model writes
the prose; the server does the N fetches. Same division of labor as reflect.
`readOnlyHint: true`.
- **`echo_note_history`** — `path: str` → `{versions: [{id, ts, bytes,
summary_line}]}` from the server's shadow write history (§7). `readOnlyHint:
true`.
- **`echo_restore_note`** — `path: str`, `version_id: str`,
`confirm: bool = false` (preview diff unless confirmed). The undo for a bad
`replace` PATCH or merge PUT. Description: operator confirmation required
before calling with `confirm: true`. `destructiveHint: true`.
Deliberately absent from v1 (documented in the server README): sweep,
bootstrap, migrate, lock/unlock (vault-wide maintenance stays operator-initiated
via slash commands — routine sweeps run as background jobs instead), raw
search/ls/map (recall/resolve/get_note cover reads; add only if transcripts
show the need), and reflect *extraction* (only the model has the conversation —
that division of labor is permanent, not a v1 scope cut).
## 7. Server internals — where the container earns its keep
### 7.1 v1
- **Warm entity index**: loaded once, invalidated on any index-writing tool and
on a short TTL (`ECHO_MCP_INDEX_TTL`, default 60s) to pick up other clients'
writes. Ends the per-invocation index re-read entirely.
- **Recall index in memory (+ SQLite file at `/data`)**: the BM25 index lives
in process memory, persisted to the container volume — recall answers in
milliseconds with **zero** vault round-trips. This *supersedes the
server-side half of IMPROVEMENT-PLANS #2*; #2's local-state-dir design still
applies to the CLI fallback path.
- **Note read cache**: short-TTL (3060s) body cache for `get_note` and
recall's neighbourhood expansion — repeated same-session reads of the same
files stop hitting the vault at all.
- **Per-path write serialization**: an internal per-path mutex serializes
conflicting MCP-path writes — since all server-mediated writes flow through
one process, the advisory-lock race window disappears for them. The server
still takes the vault advisory lock around index updates to coordinate with
CLI clients; idempotent appends stay as the last line of defense.
- **Server-side outbox**: the existing `echo_queue` pointed at `/data`
(`ECHO_STATE_DIR=/data`) — writes queue when the vault is down and flush on
recovery/load. One queue at the always-on host instead of per-laptop.
- **Concurrency**: FastMCP serves requests concurrently; guard the in-process
caches with a plain `threading.Lock`; write integrity per the bullet above.
- **Logging**: one line per call to stdout (container log): tool, ms,
ok/queued/gated/error. Never log bodies or keys.
### 7.2 v1.1 container dividends (backlog, in priority order)
- **Nightly vault backup** — the vault currently has **no disaster-recovery
story**; if the Obsidian host dies, memory is gone. A timer job walks the
vault via `read_many`, writes a dated zip to `/data/backups/` (rotate 30),
and reports the last-backup age in `/health`. Zero tokens; arguably the
highest-value item in this spec.
- **Shadow write history + undo** — before any PUT / PATCH-replace the server
passes through, snapshot the prior body to `/data/history/` (content-
addressed, per-path ring of ~20 versions). Backs the `echo_note_history` /
`echo_restore_note` tools (§6.3). The vault's additive philosophy finally
gets an undo for its residual risk: a bad `replace` or merge PUT.
- **Background maintenance jobs** — a timer thread running `sweep --fast`
(IMPROVEMENT-PLANS #4) hourly and the decay pass (#5) proposals weekly; both
publish findings to `/health` and the vault-health note rather than
auto-fixing.
- **Ops alerting** — outbox stuck > N hours, vault unreachable > 1h, new lint
violations after a background sweep ⇒ notify via the existing Unraid
notification plumbing (Kuma already watches `/health` for liveness).
Problems surface without anyone polling.
- **Embeddings (explicitly deferred)**: the container is where an embedding
recall tier would land later (model API or local), as an image upgrade —
no plugin change. Noted so nobody bolts it into the plugin.
## 8. Container & deployment
- **Image**: `python:3.12-slim` (not alpine — no musl surprises), `pip install
mcp` pinned, copy `skills/echo-memory/scripts/` + `mcp-server/` in.
**Dockerfile must be legacy-format** — the git.alwisp.com CI runner has no
BuildKit: no `# syntax=` line, no `RUN --mount`.
- **Healthcheck**: probe `http://127.0.0.1:<port>/health` — **127.0.0.1, not
localhost** (the ::1-vs-IPv4 lesson from cpas/memer/breedr). `/health`
(unauthenticated, no vault data): `{ok, vault_reachable, outbox_depth,
index_age_s}` — also the Kuma target.
- **Env** (via the PORT template, secrets as `SECRET:` refs): `ECHO_BASE`,
`ECHO_KEY`, `ECHO_OWNER`, `ECHO_MCP_TOKEN`, `ECHO_STATE_DIR=/data`,
`ECHO_MCP_TOOLS=full` (or `core` — see §3 tool profiles), optional
`ECHO_WORKERS/ECHO_TIMEOUT/ECHO_MCP_INDEX_TTL`. Volume: `/data`
(outbox + recall index + backups + shadow history).
- **Startup validation**: fail fast (non-zero exit) if `ECHO_BASE`/`ECHO_KEY`/
`ECHO_MCP_TOKEN` are missing — a misdeployed container should crash-loop
visibly, not serve errors.
- **CI/deploy**: Gitea workflow on push-to-main (tags/releases do **not**
autobuild on this host) builds the image and the PORT autodeploy webhook
rolls it out; `deploy.unraid.yml` manifest in `mcp-server/`. Publish
`echomcp.alwisp.com` via the usual CF + NPM flow; add the Kuma monitor.
- **Vault adjacency (confirmed: Obsidian runs on ALPHA)**: the Local REST
API's non-encrypted HTTP binding is enabled (2026-07-28) —
**`ECHO_BASE=http://10.2.0.35:27123`** — so the container talks to Obsidian
directly, never through the public `echoapi.alwisp.com` hairpin. All vault
chatter behind a tool call stays host/LAN-local; vault ops stop depending on
Cloudflare/DNS/NPM health entirely (the public chain is only needed for the
`echomcp` ingress). Why HTTP not HTTPS here: the REST API's HTTPS port
(27124) uses a self-signed cert, which `echo.py`'s default-verifying
`HTTPSConnection` rejects — cleartext on the internal network beats adding an
insecure-TLS flag to the client. **Exposure check (do once):** 27123 carries
the vault bearer token in cleartext, so it must not be reachable from
outside — confirm no router/firewall forward for 27123; the only public
doors stay HTTPS 443 via NPM (`echoapi` for the CLI fallback path, which
remains unchanged, and `echomcp` for this server).
## 9. Client registration & fallback
- **Claude Code / CoWork**: register as a remote MCP server —
`https://echomcp.alwisp.com/mcp` with the `Authorization: Bearer` header
(project or user scope). No plugin-manifest coupling; the plugin does **not**
register the server.
- **claude.ai**: custom connector with the same URL + token — ECHO memory from
the browser/phone, a surface the plugin could never reach.
- **SKILL.md** gains: *"When the `echo_*` MCP tools are available, prefer them
for every operation they cover; the `$ECHO` CLI recipes are the fallback for
hosts without the connector or when the server is unreachable."* Procedures
unchanged.
- **Fallback matrix**: server up ⇒ tools. Server down, machine has the plugin ⇒
CLI path exactly as today (including its own offline queue). Both down ⇒
today's "vault unreachable, proceed without memory".
## 10. Multi-user note (Andy / Bryan / Gretchen / Arsalan)
One container serves **one vault**. The image is credential-free, so additional
users are additional deployments of the same image with their own env (their
vault endpoint/key + their own `ECHO_MCP_TOKEN`), e.g. `echomcp-bryan` on
another port/subdomain. Cheap, isolated, no code change. A single multi-tenant
server (token → vault map) is deliberately out of scope — that's CHORUS's
territory.
## 11. Testing & eval
1. **Protocol/integration tests** (`eval/test_mcp_server.py`): run the FastMCP
app in-process (or via `httpx` test client) against `mock_olrapi`; assert
initialize/tools-list schemas, auth (no token ⇒ 401; bad token ⇒ 401;
`/health` open), envelope purity on every tool.
2. **Tool behavior**: per tool — happy path, validation failure (unknown param
rejected with field name), vault-down (writes ⇒ `queued: true`, reads ⇒
actionable error), duplicate-gate flow (gate → `merge_into` retry succeeds).
3. **Parity**: `capture` via MCP and via CLI against twin mock vaults produce
byte-identical notes/index entries (guards Phase 0).
4. **Container smoke** (CI): build image, start with mock env, `/health` green,
one authed `tools/call` round-trip; healthcheck probes 127.0.0.1.
5. **MCP Inspector** manual pass against the deployed container.
6. **Eval set** (mcp-builder Phase 4): 10 read-only Q&A pairs against a seeded
mock vault exercising recall→get_note→resolve chains; `eval/mcp_eval.xml`.
## 12. Build-session plan of record
| Phase | Deliverable | Est. size |
|---|---|---|
| 0 | Return-not-print refactor in the plugin tree (`*_op` cores); suites green | ~⅓ the session |
| 1 | `mcp-server/`: FastMCP app, auth middleware, result wrapper, error map, `/health` | ~200 lines |
| 2 | Read tools (load, recall, resolve, get_note, scope×2, health) | wiring |
| 3 | Write tools (capture, link, append/patch, triage, reflect, log_session) | wiring + descriptions |
| 4 | Warm caches + `/data` outbox + startup validation | small |
| 5 | Dockerfile (legacy syntax) + CI workflow + PORT manifest + deploy + CF/NPM publish + Kuma | infra pass |
| 6 | Tests (§11) + Inspector pass + eval XML; SKILL.md/README/CHANGELOG | ~⅓ the session |
Definition of done: Inspector lists 13 tools against `echomcp.alwisp.com`; a
live session (desktop **and** CoWork) performs a full memory day — load →
recall → capture → gate → merge_into → triage → log_session — without one Bash
call; parity test green; container smoke test in CI; Kuma monitor green.
+99
View File
@@ -0,0 +1,99 @@
# ECHO Memory — Usage Guide
*Written for Bryan (and anyone else coming back to ECHO after a break). Current as of v1.5.1, July 2026.*
ECHO gives Claude persistent memory across sessions. Everything durable — people, companies, projects, decisions, preferences, session history — lives as markdown notes in an Obsidian vault, read and written over the Obsidian Local REST API. The plugin ships the whole toolchain (pure Python, stdlib only — works the same on Windows/macOS/Linux) plus the operating procedure Claude follows.
The short version of how to use it: **you mostly don't have to do anything.** Since v1.5 the system loads itself at session start and nudges itself to save at session end. The slash commands below are for when you want to drive it explicitly.
---
## One-time setup
If Jason handed you a **per-user baked plugin** (`echo-memory-1.x.x-bryan.plugin`), just install it. Your vault credentials are baked into the artifact — no config file, no key pasting, works on desktop and in every CoWork session. This is the normal path.
If you have the **generic plugin** instead, it will report `NOT CONFIGURED` on first use and ask for your config file. Install it once with:
```bash
python3 echo.py config import <your-config.json>
```
That writes `~/.claude/echo-memory/config.json` (owner, endpoint, API key). It survives plugin updates — one time per machine.
**Verify either way:** run `/echo-doctor`. Green across the board (Python, vault reachable, auth, bootstrapped, key source) means you're live.
---
## Day-to-day usage
### What happens automatically (v1.5+)
- **Session start** — a hook runs the cold-start load and injects your memory into context: your profile/preferences, current scope, the last session log, today's journal note, and inbox depth. You don't have to ask Claude to "load memory" anymore.
- **Session end** — if the session was substantive and nothing was saved, a hook nudges (once) to run `/echo-reflect` and write the session log. Reflection always previews before writing — nothing lands without your OK.
- **If the vault is unreachable**, writes queue to a local outbox and replay automatically next time it's up; reads fall back to a cached last-known-good. An outage degrades gracefully instead of erroring.
### The slash commands
| Command | When to use it |
|---|---|
| `/echo-load` | Manually load memory context. Rarely needed now — the session-start hook does this — but useful mid-session after a lot of vault writes, or if the hook didn't fire. |
| `/echo-save <thing>` | Save something right now: "remember the Henagar tower password is in the vault", "log that we decided X". Routes it to the right note automatically. |
| `/echo-recall <topic>` | Ask what memory knows about a topic. Returns ranked matches **plus their linked neighbourhood** — a project comes back with its people, decisions, and recent session mentions attached. |
| `/echo-reflect` | End-of-session sweep: extracts durable facts, decisions, and new entities from the conversation, shows you the proposed writes, applies on confirm. The main way memory grows. |
| `/echo-triage` | Empty the inbox: classifies each quick capture, previews destinations, routes accepted items with an audit trail. Run when load reports the inbox is getting deep. |
| `/echo-doctor` | Readiness check — Python, vault reachability, auth, bootstrap/schema version, where the key came from. First stop when anything seems off. |
| `/echo-health` | Full vault lint: routing violations, broken wikilinks, orphan notes, stale scope, incomplete frontmatter, index drift. Run occasionally or when things feel inconsistent. |
| `/echo-sweep` | Bring the vault up to the current plugin spec after an upgrade — rebuilds the entity index, backfills frontmatter, fixes one-way links. Dry-run by default; **run this once right after updating from an old version.** |
### A typical session
1. Start a conversation — memory loads itself. Claude knows who you are, what's active, and what happened last session.
2. Work normally. Say "save that" / "remember this" whenever something durable comes up (or `/echo-save` explicitly). Not sure where something belongs? Save it anyway — it lands in the inbox and `/echo-triage` sorts it later.
3. At the end, accept the reflection nudge (or run `/echo-reflect` yourself). Confirm the preview. Done — next session picks up from here.
---
## What the memory system can do (current capabilities)
**Smart, deduplicating capture.** One `capture` call routes content to its canonical home via an entity index, stamps complete frontmatter (status, tags, timestamps), cross-links every entity it mentions, and logs the write. Names resolve through aliases and fuzzy matching, so "echo memory" finds the `echo` project instead of spawning a duplicate note. If a new title strongly resembles an existing **same-kind** entity, a pre-write duplicate gate *stops* the write and shows you the candidates — you choose merge or create. Duplicates get blocked before they exist, not cleaned up after.
**Real retrieval, not keyword grep.** Recall fuses BM25 full-text search with graph expansion along note links, over entities *and* session logs *and* journal entries. Ranking is freshness- and status-aware: recently updated and `active` notes float up, `archived` sinks. You get a topic's connected neighbourhood, not one isolated file.
**Structured memory model.** Working (transient) / episodic (what happened, when) / semantic (durable facts and preferences) memory layers, plus a current-context scope, per-session logs, an append-only journal with rollups, and PARA-style projects/areas/resources/decisions. A machine-readable routing manifest defines what may be written where, and the linter enforces it.
**Reflection.** `/echo-reflect` turns a conversation into memory: extract → classify → dedup against the index → confidence-filter → preview → apply. Low-confidence items go to the inbox instead of polluting the graph.
**Durability and safety.** Every write is HTTP-status-checked and read-back-verified (a failed write fails loudly, never silently). Appends are idempotent — retries can't double-write. Offline writes queue and replay. A cooperative advisory lock keeps Claude Code and CoWork from trampling each other on the shared vault.
**Self-maintaining.** The vault bootstraps itself from an empty Obsidian vault, migrates its own schema on version bumps, and `/echo-sweep` + `/echo-health` keep the graph honest (index rebuilds, link symmetry, frontmatter backfill, orphan/broken-link detection).
**Fast.** Connection pooling + concurrent reads mean full-vault operations that used to time out in the sandbox now finish in under a second.
---
## What's new since 0.7 (your last consistent version)
You left off right after the toolchain got its executable spine (`echo.py`, routing manifest, linter). Since then, in rough order of what you'll actually notice:
- **You don't route anything by hand anymore.** 0.7 was "pick the path, write with the right verb." Now `capture`/`recall`/`resolve`/`link` do routing, frontmatter, indexing, and cross-linking in one call — and in practice you just talk to Claude and it uses them.
- **Memory loads and saves itself** (v1.5 hooks). No more remembering to run the loading procedure or write the session log.
- **Recall is actually good now** (v0.91.5): entity index + hybrid BM25/graph search + freshness/status ranking, spanning sessions and journal too.
- **Duplicates get blocked at write time** (v1.5 gate) instead of accumulating.
- **Offline resilience** (v1.0): vault down ≠ data lost; writes queue and replay.
- **Everything is pure Python** (v0.8): no bash, works identically on Windows.
- **Credentials moved out of the plugin** (v1.31.4): generic builds prompt once per machine; your baked build carries them invisibly. Treat a baked `.plugin` file like a password — it contains your vault key. Don't share or commit it.
**After installing the new version, run `/echo-doctor`, then `/echo-sweep` once** to bring your vault up to the current schema (it dry-runs first and shows the plan).
---
## Troubleshooting
| Symptom | Fix |
|---|---|
| `NOT CONFIGURED` banner / doctor shows red config | Generic build with no config on this machine — `echo.py config import <file>`, or ask Jason for your baked build. |
| Vault unreachable | Check that Obsidian + the Local REST API plugin are running on the backend and the endpoint is reachable. Meanwhile writes queue safely and replay on reconnect. |
| Save blocked with "duplicate gate" (exit 76) | Not an error — it found a likely-existing entity. Review the candidates: merge into the existing note, or force-create if it's genuinely distinct. |
| Memory feels stale or inconsistent | `/echo-health` to see what's off, `/echo-sweep` to repair index/links/frontmatter. |
| Inbox keeps getting mentioned at load | `/echo-triage` — one pass empties it. |
| Anything else weird | `/echo-doctor` first, then ask Jason. |
+38
View File
@@ -0,0 +1,38 @@
# ECHO plugin — improvements & fixes (frontmatter completeness)
## Context
While doing routine vault edits I found that partially-populated entity notes accumulate silently. An audit of `resources/people|companies|references` found **18 of 71 notes** missing `status` and/or with empty `tags` (`tags: []` or no key). Neither `echo doctor` nor `vault_lint.py` (`/echo-health`) catches this. I hand-fixed the vault, but the tooling should prevent the drift and make it easy to remediate. Three changes, root-cause first.
## Root cause — `capture` writes incomplete canonical frontmatter
New entity notes created via `capture` land with `tags: []` and **no `status` key** (verified on freshly-captured `reference` and `company` notes). Every such note is then "incomplete" by convention (complete siblings like `resources/references/obsidian-local-rest-api.md` carry `status: active` + real tags). So the drift is created at write time.
**Fix (in `capture` / `_build_note`, `echo_ops.py`):**
- Stamp a kind-appropriate default `status` (e.g. `active` for person/company/reference/project) instead of omitting it.
- Replace the empty `tags: []` scaffold with either (a) an auto-seeded baseline tag (the note's `kind`, e.g. `- company`) so the key is never empty, or (b) a required `--tags` argument for entity kinds, or (c) a `needs-tags` marker tag that the completeness check (below) flags. Pick one; my preference is (a)+(b): seed `- <kind>` and accept `--tags a,b,c` to enrich at capture time.
- Acceptance: a note created by `capture "X" --kind company` passes the completeness check below with zero follow-up edits.
## Fix 2 — `fm` cannot create a missing key (only replace)
`echo.py: cmd_fm()` is `cmd_patch(path, "replace", "frontmatter", field, ...)`. If the field is absent, the Obsidian REST API returns `400 invalid-target` ("The patch you provided could not be applied"). This means `echo fm note.md status active` **fails on exactly the notes that need it** (status missing), forcing agents into GET-modify-PUT, which is error-prone (I clobbered `tags` on 2 notes with a naive rewrite before catching it).
**Fix:** make `fm` create-or-replace. On `invalid-target`, fall back to inserting the key into the frontmatter block (after `type:`, preserving all other lines) and re-PUT — or add an explicit `fm --create` / `set` op. Must be surgical: never rewrite unrelated frontmatter or body.
- Acceptance: `echo fm note.md status active` succeeds whether or not `status` already exists, changing nothing else in the file.
## Fix 3 — `vault_lint.py`: add an `incomplete-frontmatter` check
Today `REQUIRED_FM = ("type", "created")` only, and `status` is validated solely for projects (folder↔status match). Nothing requires `status`/`tags` on entity notes.
**Fix:** add a check that, for entity kinds (`person`, `company`, `reference`, and any others that should be classified — `area`, `skill`), flags:
- missing or empty `status`
- missing `tags` key, or `tags: []`, or a `tags:` key with no list items
Emit as a new check id `incomplete-frontmatter` with a message like `"{path}: missing status"` / `"{path}: empty tags"`. Keep it kind-scoped so episodic/journal/session notes (which legitimately omit these) are not flagged. Consider a per-kind required-field map rather than the single global `REQUIRED_FM` tuple, so the rule is data-driven.
- Acceptance: running `vault_lint.py` on a vault with a `tags: []` company note reports one `incomplete-frontmatter` violation; a fully-populated note reports none.
## Nice-to-have
- A `--fix` / remediation mode (or a `sweep` sub-pass) that backfills `status: <default>` and a `- <kind>` baseline tag on flagged notes, so cleanup is one command instead of a hand-rolled script.
- Note the zsh gotcha for any shipped helper loops: unquoted scalar `for x in $var` does NOT word-split in zsh — use arrays or `while read`.
## Evidence / where I looked
- `echo.py` `cmd_fm` (~L405), arg parser (~L673), `cmd_patch`.
- `vault_lint.py` `REQUIRED_FM` (L42), per-note loop (~L161173), check-label registry (~L301+), `walk()`/`list_dir()` enumeration.
- `echo_ops.py` `_build_note` (~L103) — has a `status_v` param already, so capture is positioned to stamp status.
- Entity index `_agent/index/entities.json` stores `path/kind/title/aliases/last_seen` — not `tags`/`status` (so a completeness check must read notes, as `vault_lint` already does).
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+66
View File
@@ -0,0 +1,66 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Squircle background: Deep Navy -> Royal -> Signal -->
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#25335C"/>
<stop offset="0.55" stop-color="#204C84"/>
<stop offset="1" stop-color="#1A6E96"/>
</linearGradient>
<!-- Orb glow: teal core -->
<radialGradient id="orb" cx="0.42" cy="0.40" r="0.70">
<stop offset="0" stop-color="#7FF0EC"/>
<stop offset="0.35" stop-color="#1FC9C4"/>
<stop offset="1" stop-color="#009B98"/>
</radialGradient>
<!-- Vault enclosure subtle gradient -->
<linearGradient id="vault" x1="106" y1="160" x2="406" y2="406" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#2A86AE"/>
<stop offset="1" stop-color="#155D80"/>
</linearGradient>
<filter id="soft" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="14"/>
</filter>
<filter id="softer" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="26"/>
</filter>
<clipPath id="squircle">
<path d="M256 8
C 120 8, 8 120, 8 256
C 8 392, 120 504, 256 504
C 392 504, 504 392, 504 256
C 504 120, 392 8, 256 8 Z"/>
</clipPath>
</defs>
<!-- Background -->
<g clip-path="url(#squircle)">
<rect x="0" y="0" width="512" height="512" fill="url(#bg)"/>
<!-- ambient teal glow from the orb spilling into the navy -->
<circle cx="256" cy="262" r="150" fill="#009B98" opacity="0.18" filter="url(#softer)"/>
</g>
<!-- Concentric ripple rings (echo / memory) -->
<g fill="none" stroke="#3FE0DB" stroke-linecap="round">
<circle cx="256" cy="262" r="96" stroke-width="3" opacity="0.55"/>
<circle cx="256" cy="262" r="120" stroke-width="2.5" opacity="0.32"/>
<circle cx="256" cy="262" r="142" stroke-width="2" opacity="0.18"/>
</g>
<!-- Vault / open container: ring open at the top, cradling the orb -->
<path d="M 370.9 165.6 A 150 150 0 1 1 141.1 165.6"
fill="none" stroke="url(#vault)" stroke-width="26" stroke-linecap="round"/>
<!-- inner highlight on the vault -->
<path d="M 370.9 165.6 A 150 150 0 1 1 141.1 165.6"
fill="none" stroke="#5BC7E6" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Orb glow halo -->
<circle cx="256" cy="262" r="74" fill="#00C2BE" opacity="0.55" filter="url(#soft)"/>
<!-- Orb -->
<circle cx="256" cy="262" r="62" fill="url(#orb)"/>
<!-- Orb specular highlight -->
<ellipse cx="236" cy="240" rx="22" ry="16" fill="#EAFFFE" opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -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.
Binary file not shown.
@@ -1,9 +1,11 @@
{ {
"name": "echo-memory", "name": "echo-memory",
"version": "1.2.0", "version": "2.1.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.", "homepage": "https://git.alwisp.com/jason/echo",
"repository": "https://git.alwisp.com/jason/echo",
"description": "Persistent memory via the ECHO Obsidian vault over the Obsidian Local REST API. Cross-platform Python client: one-call capture/resolve/recall/link/triage over an entity index, hybrid BM25 + graph recall spanning entities + sessions/journal (recency/status-aware), a pre-write duplicate gate, complete-frontmatter capture, session hooks that self-fire load/reflect, offline write-ahead queue, lock-guarded concurrency, linter-enforced routing, and /echo-* commands.",
"author": { "author": {
"name": "Jason" "name": "Jason Stedwell"
}, },
"license": "UNLICENSED", "license": "UNLICENSED",
"keywords": [ "keywords": [
+32 -11
View File
@@ -2,27 +2,44 @@
Persistent memory for Claude via the **ECHO** Obsidian vault, using the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api). 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. **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.
## What it does ## What it does
- Loads operator preferences, current context, and relevant project notes at the start of substantive conversations - Loads operator preferences, current context, and relevant project notes at the start of substantive conversations
- **One-call `capture`** routes a memory to its canonical home, stamps frontmatter, indexes it, auto-links any entity it mentions, and logs it — driven by a machine-maintained **entity index** so routing is an alias-aware lookup, not a fuzzy search - **One-call `capture`** routes a memory to its canonical home, stamps **complete** frontmatter (kind-default `status`, kind-seeded `tags`), indexes it, auto-links any entity it mentions, and logs it — driven by a machine-maintained **entity index** so routing is an alias-aware lookup, not a fuzzy search. Updates keep the **whole body**; a title that strongly resembles an existing entity **stops at a duplicate gate** (exit 76 — `--merge-into <slug>` / `--force`) instead of spawning a near-duplicate
- **`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 - **`recall`** returns a topic's matching notes *and* their one-hop linked neighbourhood (Related links + `source_notes`) — the corpus spans the entity graph **plus session logs and journal notes** (down-weighted), ranked by BM25 × freshness × status so live notes outrank stale ones; **`resolve`** maps any mention to its canonical path; **`link`** adds reciprocal cross-links; **`triage`** routes aging inbox captures with an automatic processing-log audit trail
- Logs working sessions so future conversations can pick up where they left off - 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` - **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
- **Ships session hooks** (`hooks/hooks.json`): SessionStart auto-runs the cold-start load into context, and Stop nudges `/echo-reflect` once per substantive session — the load/reflect discipline fires deterministically instead of depending on the model remembering (reflection still previews before writing)
## Configuration ## 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) ## Vault layout (root-addressed)
@@ -51,7 +68,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: 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/ skills/echo-memory/
├── SKILL.md ← operating procedure (authoritative) ├── SKILL.md ← operating procedure (authoritative)
├── references/ ├── references/
@@ -62,7 +79,8 @@ skills/echo-memory/
│ ├── api-reference.md ← REST endpoint patterns + routing map │ ├── api-reference.md ← REST endpoint patterns + routing map
│ └── session-log-template.md │ └── session-log-template.md
├── scripts/ ← pure Python; run with python3 (Windows: python / py -3) ├── 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_index.py ← entity index (registry, resolve, name→path map)
│ ├── echo_links.py ← cross-link primitives (Related parsing, bidirectional linking) │ ├── echo_links.py ← cross-link primitives (Related parsing, bidirectional linking)
│ ├── echo_ops.py ← high-level ops (capture, recall, resolve, link, agent-log) │ ├── echo_ops.py ← high-level ops (capture, recall, resolve, link, agent-log)
@@ -93,9 +111,12 @@ skills/echo-memory/
| `/echo-triage` | Drain aging inbox captures to their homes, logging each move | | `/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-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-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 ## Requirements
- **Python 3** available in the session environment (the scripts use only the standard library; invoke as `python3`, or `python` / `py -3` on Windows) - **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 - 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**
@@ -1,15 +0,0 @@
---
description: Check ECHO readiness — Python, vault reachability, auth, bootstrap/schema, and key source
---
Use the **echo-memory** skill to run a one-call readiness check before relying on memory.
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" doctor
# Windows: use `python` or `py -3` if `python3` is not on PATH.
```
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`.
@@ -1,15 +0,0 @@
---
description: Run the ECHO vault-health linter and summarize any invariant violations
allowed-tools: Bash(python3 */echo-memory/scripts/vault_lint.py*), Bash(python */echo-memory/scripts/vault_lint.py*)
---
Run the bundled, read-only vault linter and report findings. Pass the conversation's current date (`ECHO_TODAY`) so stale/aging math uses the same clock the agent writes with:
```bash
ECHO_TODAY=<currentDate> python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/vault_lint.py"
# Windows: use `python` or `py -3` if `python3` is not on PATH.
```
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).
@@ -1,17 +0,0 @@
---
description: Recall a topic from ECHO memory — matching notes plus their linked neighbourhood
argument-hint: "[topic, person, or project]"
---
Use the **echo-memory** skill to recall context for:
> $ARGUMENTS
Run the one-call recall — it resolves the term against the entity index, searches, and expands one hop along `## Related` links and `source_notes`, so you get the connected web (linked decisions, people, prior sessions), not an isolated note:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" recall "$ARGUMENTS"
# Windows: use `python` or `py -3` if `python3` is not on PATH.
```
Then answer from the assembled context. If you only need the canonical path for one entity, use `echo.py resolve "<title>"` instead. Don't narrate the retrieval.
@@ -1,25 +0,0 @@
---
description: Save to ECHO memory — route content to its canonical home (search-first, idempotent)
argument-hint: "[what to remember]"
---
Use the **echo-memory** skill to persist this to the ECHO vault:
> $ARGUMENTS
Prefer the one-call **`capture`** — it routes (via the entity index), derives the canonical path, stamps frontmatter, indexes, auto-links mentioned entities, and writes the Agent-Log line. Pick the `--kind` from the content (`person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision`); use `--inbox` only when the home is genuinely unknown. Write multi-line bodies to a file with the Write tool (cross-platform, no heredoc).
```bash
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" # run with: python3 "$ECHO" ... (Windows: python / py -3)
python3 "$ECHO" capture "<title>" <bodyfile> --kind <kind> [--aliases a,b] [--source p1,p2]
python3 "$ECHO" capture "<title>" --inbox # unknown home -> idempotent inbox line
```
Drop to the low-level verbs only for shapes `capture` doesn't model (a project `## Status` replace, an ADR mirror, a daily-note edit):
```bash
python3 "$ECHO" put <routed/path>.md <bodyfile> # create/overwrite (verifies)
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.
@@ -1,12 +0,0 @@
---
description: Triage the ECHO inbox — route aging captures to their canonical homes and log the moves
---
Use the **echo-memory** skill to run **Inbox Triage**. GET `inbox/captures/inbox.md`, list captures older than ~7 days that were never routed, and offer to route them.
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" get inbox/captures/inbox.md
# 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.
+27
View File
@@ -0,0 +1,27 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|clear",
"hooks": [
{
"type": "command",
"command": "S=\"${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo_hook_session_start.py\"; [ -f \"$S\" ] || S=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo_hook_session_start.py 2>/dev/null | head -1); [ -n \"$S\" ] || exit 0; python3 \"$S\" || python \"$S\"",
"timeout": 60
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "S=\"${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo_hook_stop.py\"; [ -f \"$S\" ] || S=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo_hook_stop.py 2>/dev/null | head -1); [ -n \"$S\" ] || exit 0; python3 \"$S\" || python \"$S\"",
"timeout": 30
}
]
}
]
}
}
Binary file not shown.
@@ -0,0 +1,22 @@
---
name: echo-doctor
description: Check ECHO readiness — Python, vault reachability, auth, bootstrap/schema, and key source
allowed-tools: Bash(python3 *echo.py*), Bash(python *echo.py*), Bash(py -3 *echo.py*), Bash(ls /sessions/*)
---
Use the **echo-memory** skill to run a one-call readiness check before relying on memory.
```bash
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py"
[ -f "$ECHO" ] || ECHO=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$ECHO" doctor
# Windows: use `python` or `py -3` if `python3` is not on PATH.
```
It prints green/red for: Python version, vault reachability, auth accepted, vault
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` — or `baked-in (plugin)` on a per-user baked build,
which is authoritative and reported as such). 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`.
@@ -0,0 +1,18 @@
---
name: echo-health
description: Run the ECHO vault-health linter and summarize any invariant violations
allowed-tools: Bash(python3 *vault_lint.py*), Bash(python *vault_lint.py*), Bash(py -3 *vault_lint.py*), Bash(ls /sessions/*), Bash(dirname *)
---
Run the bundled, read-only vault linter and report findings. Pass the conversation's current date (`ECHO_TODAY`) so stale/aging math uses the same clock the agent writes with:
```bash
SDIR="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts"
[ -d "$SDIR" ] || SDIR=$(dirname "$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/vault_lint.py 2>/dev/null | head -1)") # CoWork sandbox fallback
ECHO_TODAY=<currentDate> python3 "$SDIR/vault_lint.py"
# Windows: use `python` or `py -3` if `python3` is not on PATH.
```
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 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).
@@ -1,14 +1,20 @@
--- ---
name: echo-load
description: Load ECHO memory — cold-start context read (profile, scope, latest session, today, inbox) description: Load ECHO memory — cold-start context read (profile, scope, latest session, today, inbox)
allowed-tools: Bash(python3 *echo.py*), Bash(python *echo.py*), Bash(py -3 *echo.py*), Bash(ls /sessions/*)
--- ---
Use the **echo-memory** skill to load memory now. Run the cold-start **Loading procedure** from `SKILL.md`: the six orientation reads (marker, operator-preferences, current-context, heartbeat, today's daily note, inbox) in **one call**, then do the load-time **reconcile** (inbox-depth + scope-drift) and surface it in a single line. Use the **echo-memory** skill to load memory now. Run the cold-start **Loading procedure** from `SKILL.md`: the six orientation reads (marker, operator-preferences, current-context, heartbeat, today's daily note, inbox) in **one call**, then do the load-time **reconcile** (inbox-depth + scope-drift) and surface it in a single line.
```bash ```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" load ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py"
[ -f "$ECHO" ] || ECHO=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$ECHO" load
# Windows: use `python` or `py -3` if `python3` is not on PATH. # Windows: use `python` or `py -3` if `python3` is not on PATH.
``` ```
`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. `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. Do not narrate the reads. End with a one-line orientation: who/what/where the active scope is, and any reconcile prompt.
@@ -1,31 +1,40 @@
--- ---
name: echo-memory 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 # ECHO Memory
Use the **ECHO** Obsidian vault as persistent memory. Read context accumulated across sessions; write things the operator asks to be remembered. 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 ## API Configuration
All calls use these constants. The endpoint is fixed; the key is resolved by `echo.py` The owner, endpoint, and API key are **machine-local** the committed 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.
(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. Exception — **per-user baked builds:** a `build.py --bake-key` artifact carries the owner/endpoint/key inside the plugin. When a *complete* baked set is present it is **authoritative** — it wins over env vars and the config file and can't be shadowed (so a baked install just works, even on a machine with a stale or placeholder config). The committed source stays credential-free.
``` ```
OBSIDIAN_BASE = https://echoapi.alwisp.com endpoint = <config "endpoint" / $ECHO_BASE>
OBSIDIAN_KEY = <resolved at runtime from ECHO_KEY / credentials file — not stored here> 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 ### First run — set up the machine if it isn't configured
`~/.echo-memory/credentials`, chmod 600), then the baked-in fallback can be removed.
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: **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:
@@ -47,10 +56,13 @@ Executable logic ships under `scripts/` — **pure Python**, so the whole toolch
All paths below are under `${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/`. **Invoke with `python3`** (on Windows where that isn't on PATH, use `python` or `py -3`). All paths below are under `${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/`. **Invoke with `python3`** (on Windows where that isn't on PATH, use `python` or `py -3`).
**Plugin-root resolution (CoWork sandbox).** Prefer `${CLAUDE_PLUGIN_ROOT}`. In a remote CoWork sandbox that variable can point at a host path the sandbox can't reach (e.g. `/var/folders/…`); the plugin is actually mounted under `…/mnt/.remote-plugins/…`. So resolve the scripts dir once with a fallback and reuse it — the `$ECHO`/`$LINT`/`$SWEEP` snippets below already do this. The same fallback applies to `vault_lint.py`, `sweep.py`, `bootstrap.py`, and `migrate.py`.
**`scripts/echo.py` — use this for every read/write.** It centralizes auth, **HTTP-status checking** (a failed write exits non-zero instead of looking like success), one bounded retry on 5xx/connection errors, whole-line idempotent append, correct `::` heading targets, frontmatter patches, a read-back-confirmed advisory lock, and a one-call cold-start `load`. The raw `curl` recipes in `references/api-reference.md` are the underlying mechanics / *nix fallback — reach for them only if `echo.py` is unavailable, and if you do, **check the HTTP status yourself** (the PATCH-heading `400 invalid-target` failure silently loses writes otherwise). **`scripts/echo.py` — use this for every read/write.** It centralizes auth, **HTTP-status checking** (a failed write exits non-zero instead of looking like success), one bounded retry on 5xx/connection errors, whole-line idempotent append, correct `::` heading targets, frontmatter patches, a read-back-confirmed advisory lock, and a one-call cold-start `load`. The raw `curl` recipes in `references/api-reference.md` are the underlying mechanics / *nix fallback — reach for them only if `echo.py` is unavailable, and if you do, **check the HTTP status yourself** (the PATCH-heading `400 invalid-target` failure silently loses writes otherwise).
```bash ```bash
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" # call as: python3 "$ECHO" <cmd> ... ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" # call as: python3 "$ECHO" <cmd> ...
[ -f "$ECHO" ] || ECHO=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$ECHO" load # cold-start: the 6 orientation reads in one call python3 "$ECHO" load # cold-start: the 6 orientation reads in one call
python3 "$ECHO" get <path> # 404 -> exit 44 python3 "$ECHO" get <path> # 404 -> exit 44
python3 "$ECHO" ls <dir> ; python3 "$ECHO" map <path> # listing / document-map python3 "$ECHO" ls <dir> ; python3 "$ECHO" map <path> # listing / document-map
@@ -68,16 +80,18 @@ python3 "$ECHO" lock <session-id> ; python3 "$ECHO" unlock <session-id>
These collapse the multi-step write/recall discipline into one call each, backed by the **entity index** (`_agent/index/entities.json`, a slug→{path,kind,title,aliases} registry). **Default to them** — they are how this skill reduces the input needed to route and cross-link memory: These collapse the multi-step write/recall discipline into one call each, backed by the **entity index** (`_agent/index/entities.json`, a slug→{path,kind,title,aliases} registry). **Default to them** — they are how this skill reduces the input needed to route and cross-link memory:
```bash ```bash
python3 "$ECHO" capture "<title>" <bodyfile> --kind <kind> [--aliases a,b] [--source p1,p2] [--status s] [--date YYYY-MM-DD] [--domain business] python3 "$ECHO" capture "<title>" <bodyfile> --kind <kind> [--aliases a,b] [--tags t1,t2] [--source p1,p2] [--status s] [--date YYYY-MM-DD] [--domain business]
python3 "$ECHO" capture "<title>" --inbox # unknown home -> idempotent inbox line python3 "$ECHO" capture "<title>" --inbox # unknown home -> idempotent inbox line
python3 "$ECHO" resolve "<mention>" # mention -> canonical path (or where to create) python3 "$ECHO" resolve "<mention>" # mention -> canonical path (or where to create)
python3 "$ECHO" recall "<query>" # search + 1-hop link/source expansion -> connected context python3 "$ECHO" recall "<query>" [--json] # ranked search + 1-hop expansion -> connected context
python3 "$ECHO" link <pathA> <pathB> # add reciprocal [[Related]] links A<->B python3 "$ECHO" link <pathA> <pathB> [--json] # add reciprocal [[Related]] links A<->B
python3 "$ECHO" triage --list --json # structured inbox listing (see Inbox Triage)
``` ```
- **`capture` is the default write.** Given a title + `--kind`, it: resolves the entity via the index (create-vs-update, no manual search-first), derives the canonical path, stamps canonical frontmatter (`type/created/updated/aliases/agent_written/source_notes`), updates the index, **auto-links** any known entity mentioned in the body (bidirectionally), and appends today's Agent Log line — one call instead of search→put→bump→log. It also **auto-derives aliases** from the title, **learns the mention as an alias** when updating an entity under a different name, and **warns if the new note resembles an existing entity** (so a shortened name can't silently spawn a duplicate). `--kind``person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision`. Use `--inbox` (or omit `--kind`) only when the home is genuinely unknown. - **`capture` is the default write.** Given a title + `--kind`, it: resolves the entity via the index (create-vs-update, no manual search-first), derives the canonical path, stamps **complete** canonical frontmatter (`type/status/created/updated/tags/aliases/agent_written/source_notes` — a kind-appropriate default `status` and the kind seeded as a baseline tag, enriched by `--tags`), updates the index, **auto-links** any known entity mentioned in the body (bidirectionally), and appends today's Agent Log line — one call instead of search→put→bump→log. It also **auto-derives aliases** from the title and **learns the mention as an alias** when updating an entity under a different name. Updating an existing entity appends the **whole body** as a dated block (first line on the bullet, the rest indented under it) — nothing is truncated. `--kind``person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision`. Use `--inbox` (or omit `--kind`) only when the home is genuinely unknown.
- **Duplicate gate (create path).** When a new title strongly resembles an existing entity (fuzzy score ≥ 0.6, env `ECHO_DUP_GATE`), `capture` **stops before writing** and exits `76` with the candidate list, instead of creating a near-duplicate and warning afterwards. Two precision rules (1.5.1) keep it from over-firing: the gate only blocks on a **same-kind** candidate (a decision or meeting named after a project/person is intentional naming — those surface as warnings only), and a **single shared token blocks only when it's unique to that entity** (a vault-common word like a project family name can't gate everything that mentions it; multi-token overlaps still block). Resolve a gate stop deliberately: `--merge-into <slug>` routes the capture as an update to the existing entity (the new name is learned as an alias), or `--force` creates anyway after you've confirmed they're genuinely distinct. Weak resemblances still create + warn as before.
- **`resolve` before constructing any path by hand** — it returns the canonical note (matching slug/title/alias, so *Bob/Robert/RS* converge) or, when nothing matches exactly, a ranked list of **`candidates`** (entities sharing a distinctive token — e.g. "echo memory" surfaces the project `echo`). Always check candidates before creating a new note; a shortened name not matching exactly is the classic way a duplicate gets made. This replaces the two-search "search-first" dance for slug-addressed notes. - **`resolve` before constructing any path by hand** — it returns the canonical note (matching slug/title/alias, so *Bob/Robert/RS* converge) or, when nothing matches exactly, a ranked list of **`candidates`** (entities sharing a distinctive token — e.g. "echo memory" surfaces the project `echo`). Always check candidates before creating a new note; a shortened name not matching exactly is the classic way a duplicate gets made. This replaces the two-search "search-first" dance for slug-addressed notes.
- **`recall` for "what do we know about X"** — it returns the matching notes *and* their one-hop neighbourhood (Related links, `source_notes`), so recall surfaces the web around a topic, not an isolated note. - **`recall` for "what do we know about X"** — it returns the matching notes *and* their one-hop neighbourhood (Related links, `source_notes`), so recall surfaces the web around a topic, not an isolated note. The corpus covers the entity graph **plus session logs and journal notes** (down-weighted, so entity notes win ties) — past discussions and decisions are findable even when nobody promoted them into an entity note. Ranking fuses BM25 with **freshness** (`updated:` half-life decay) and **status** (`active` boosted, `archived` demoted), and each hit prints its `updated:`/`status:` so staleness is visible. `--json` emits structured hits (`path/score/type/updated/status/excerpt`) instead of prose.
- **`link`** when two existing notes are related but not yet connected; `capture` already auto-links what it detects. - **`link`** when two existing notes are related but not yet connected; `capture` already auto-links what it detects.
The index is maintained automatically by `capture`; `sweep.py` rebuilds it and back-fills links for an existing vault (see **Vault Maintenance**). The index is maintained automatically by `capture`; `sweep.py` rebuilds it and back-fills links for an existing vault (see **Vault Maintenance**).
@@ -94,11 +108,20 @@ python3 "$ECHO" lock "cc-<session-id>" # exit 75 if another session holds
python3 "$ECHO" unlock "cc-<session-id>" 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 ### 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.
### Session hooks (self-firing load & reflect)
The plugin ships `hooks/hooks.json`, so the two most drift-prone behaviors no longer depend on remembering them:
- **SessionStart** runs `echo.py load` and injects the output as context — cold-start loading is deterministic. Still do the **reconcile** (inbox depth, scope confirm) on that injected context before working.
- **Stop** fires **once per substantive session** when nothing has been reflected or session-logged yet: it blocks with a reminder to run the `/echo-reflect` flow and write the session log + heartbeat. Reflection still previews and asks the operator before writing — the hook only ensures the question gets asked. If nothing durable emerged, just finish; never invent memories to satisfy the nudge.
If the hooks are disabled or unavailable, the manual procedures below are unchanged and still authoritative.
## Operating Contract & Safety ## Operating Contract & Safety
@@ -113,16 +136,16 @@ Full contract (principles, agent role, memory model): `references/operating-cont
## When to Load Memory ## 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 ### Loading procedure
**Run `python3 "$ECHO" load`** — one call that performs the six orientation reads below and prints each labeled section (404s on today's note / inbox show as absent, not errors; an absent marker is flagged). This replaces issuing six separate GETs by hand. The table documents what `load` reads and why: **Run `python3 "$ECHO" load`** — one call that performs the six orientation reads below (fetched in parallel) and prints each labeled section (404s on today's note / inbox show as absent, not errors; an absent marker is flagged). This replaces issuing six separate GETs by hand. **`load --brief`** renders a token-budgeted digest instead — Fact/Pattern rules in full, the last ~10 Observations, scope + freshness, the last session's key sections, today's Agent Log lines, and the inbox as a *count* (`ECHO_LOAD_BUDGET`, default ~8000 chars, trims lowest-priority sections first). The SessionStart hook injects the brief form; `/echo-load` and this procedure use the full form. The table documents what `load` reads and why:
| # | GET | Notes | | # | 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. | | 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 | | 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. | | 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 | | 5 | `/vault/journal/daily/YYYY-MM-DD.md` | Today's note; 404 is fine — it's created on first agent activity |
@@ -133,9 +156,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: **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. 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: **If a specific topic/project/person is in play**, pull its connected context in one call:
@@ -147,22 +170,25 @@ python3 "$ECHO" recall "<topic or title>" # the matching notes AND their one-h
Do NOT narrate this loading. Reading memory is expected behavior. Do NOT narrate this loading. Reading memory is expected behavior.
## Inbox Triage ## Inbox Triage (one-tap: `echo.py triage`)
`inbox/captures/inbox.md` is the catch-all for "save this somewhere — figure out where later." Without triage it grows forever and durable facts get stranded there. `inbox/captures/inbox.md` is the catch-all for "save this somewhere — figure out where later." Without triage it grows forever and durable facts get stranded there.
At the start of a substantive session, GET `inbox/captures/inbox.md`. If it contains lines older than ~7 days that haven't been routed elsewhere, surface them once: At the start of a substantive session, check the inbox. If it holds lines older than ~7 days that haven't been routed elsewhere, surface them once:
> "Three captures from last week are still in the inbox — want to route them now or leave them?" > "Three captures from last week are still in the inbox — want to route them now or leave them?"
When routing accepted items, send each to its proper home: **The triage verb does the mechanical work** — it reuses the reflect pipeline (classify against the entity index → preview → apply via `capture`) and writes the processing-log audit line for every move:
- Preference / pattern → PATCH-append under `operator-preferences.md::Observations` ```bash
- Project idea → PUT `projects/incubating/<slug>.md` python3 "$ECHO" triage --list --json # structured captures: {line, date, text, age_days}
- Durable fact → PUT `_agent/memory/semantic/<slug>.md` # decide a kind/title per accepted item, write a proposals JSON (reflect schema +
- Person fact → PUT/PATCH `resources/people/<name>.md` # optional "line": the original inbox line, echoed into the audit log), then:
python3 "$ECHO" triage proposals.json # dry-run: classify + preview, writes nothing
python3 "$ECHO" triage proposals.json --apply # route via capture + log each move
```
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. Routing targets are the usual homes (preference/pattern → `operator-preferences.md::Observations` via a `semantic` proposal or a direct PATCH; project idea → `project` with `--status incubating`; durable fact → `semantic`; person fact → `person`). `--apply` records each move in `inbox/processing-log/YYYY-MM-DD.md` (`- <original line> → <destination path>`) automatically. Originals are **never deleted** unless the operator explicitly asks — the processing log is the audit trail.
## Project Lifecycle ## Project Lifecycle
@@ -181,14 +207,14 @@ Projects move through four folders under `projects/`. The folder name and the `s
## When to Write Memory ## 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") - 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 - Makes a non-obvious decision worth recording
- Says "remember that", "save this", "log this", "add to memory", "note that" - Says "remember that", "save this", "log this", "add to memory", "note that"
- Finishes a meaningful working session future sessions should pick up - 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. **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 +230,9 @@ python3 "$ECHO" search <slug> # fallback when the index isn't trusted
If a match is found: 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 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. - 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. 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.
@@ -235,6 +261,9 @@ python3 "$ECHO" put projects/active/<slug>.md <bodyfile>
# frontmatter freshness after a SUBSTANTIVE change (status/decision/scope) — NOT routine log appends # frontmatter freshness after a SUBSTANTIVE change (status/decision/scope) — NOT routine log appends
ECHO_TODAY=<currentDate> python3 "$ECHO" bump projects/active/<slug>.md # sets updated: to today ECHO_TODAY=<currentDate> python3 "$ECHO" bump projects/active/<slug>.md # sets updated: to today
# fm is CREATE-or-replace: a key the note lacks is inserted surgically (nothing else
# in the file is touched), so repairing a note missing `status:` is one call:
python3 "$ECHO" fm <path> status active
# search (run before creating any slug-addressed note — see the search-first rule above) # search (run before creating any slug-addressed note — see the search-first rule above)
python3 "$ECHO" search <terms...> python3 "$ECHO" search <terms...>
@@ -244,7 +273,7 @@ Every PUT body needs the canonical YAML frontmatter (see `references/vault-layou
## Scope Switching (`current-context.md`) ## 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:** **Preferred — one command:**
@@ -278,12 +307,14 @@ A weekly/monthly rollup is a **light digest** — open threads across `projects/
## Vault Health (monthly) ## 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): 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):
```bash ```bash
ECHO_TODAY=<currentDate> python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/vault_lint.py" LINT="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/vault_lint.py"
[ -f "$LINT" ] || LINT=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/vault_lint.py 2>/dev/null | head -1) # CoWork sandbox fallback
ECHO_TODAY=<currentDate> python3 "$LINT"
``` ```
Exit codes: `0` clean · `1` violations · `2` unreachable · `3` not bootstrapped. Checks (the linter asserts each and prints violations): Exit codes: `0` clean · `1` violations · `2` unreachable · `3` not bootstrapped. Checks (the linter asserts each and prints violations):
@@ -297,19 +328,22 @@ Exit codes: `0` clean · `1` violations · `2` unreachable · `3` not bootstrapp
7. **Unknown / retired paths** — any vault file that matches no route in `scripts/routing.json` (or sits at a retired path). 7. **Unknown / retired paths** — any vault file that matches no route in `scripts/routing.json` (or sits at a retired path).
8. **Frontmatter integrity** — missing required fields, `updated` < `created`, future `updated`, and `[[wikilinks]]` leaking into `source_notes`. 8. **Frontmatter integrity** — missing required fields, `updated` < `created`, future `updated`, and `[[wikilinks]]` leaking into `source_notes`.
9. **Graph health****broken wikilinks** (`[[X]]` resolving to no note), **orphans** (an entity note with no inbound links and an empty `## Related`), and **index drift** (entity-index entries whose file is gone, or indexable notes missing from `entities.json` — fix with `sweep.py`). 9. **Graph health****broken wikilinks** (`[[X]]` resolving to no note), **orphans** (an entity note with no inbound links and an empty `## Related`), and **index drift** (entity-index entries whose file is gone, or indexable notes missing from `entities.json` — fix with `sweep.py`).
10. **Incomplete entity frontmatter** — entity notes missing or empty `status:`/`tags:` per the kind-scoped requirement map (`KIND_REQUIRED_FM` in `echo_index.py`; journal/session notes are exempt). `capture` now stamps these at write time; `sweep.py --apply` backfills older notes (kind-default status + the kind as a baseline tag).
## Vault Maintenance — `sweep.py` (bring the vault up to spec) ## Vault Maintenance — `sweep.py` (bring the vault up to spec)
After a plugin upgrade, run the sweep once to back-fill what older vaults lack — it (1) creates `_agent/index/`, (2) **rebuilds the entity index** from the notes already present, (3) **symmetrizes cross-links** (for every `## Related` A→B, adds the missing B→A; it never invents new links from body text), and (4) stamps the marker `schema_version` to current. Dry-run by default: After a plugin upgrade, run the sweep once to back-fill what older vaults lack — it (1) creates `_agent/index/`, (2) **rebuilds the entity index** from the notes already present (and the recall index, now spanning entities + sessions/journal), (3) **backfills incomplete entity frontmatter** (kind-default `status`, the kind as a baseline tag — what `/echo-health` flags as `incomplete-frontmatter`), (4) **symmetrizes cross-links** (for every `## Related` A→B, adds the missing B→A; it never invents new links from body text), and (5) stamps the marker `schema_version` to current. Dry-run by default:
```bash ```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py" # plan (read-only) SWEEP="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py"
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py" --apply # write [ -f "$SWEEP" ] || SWEEP=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/sweep.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$SWEEP" # plan (read-only)
python3 "$SWEEP" --apply # write
``` ```
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. 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. **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 +393,11 @@ python3 "$ECHO" patch journal/daily/<currentDate>.md append heading "<currentDat
**Routing triggers — areas / meetings / skills:** **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`. - **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 ## Session Logging
@@ -371,30 +405,45 @@ At the end of substantive conversations (ones that produced decisions, artifacts
**Filename format is canonical: `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md`.** Always include the four-digit local-time HHMM component — it makes filenames lexically sort in true chronological order, which is what Step 3 of loading relies on. Older session logs without the HHMM part exist; leave them alone, but every new one must use the full form. **Filename format is canonical: `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md`.** Always include the four-digit local-time HHMM component — it makes filenames lexically sort in true chronological order, which is what Step 3 of loading relies on. Older session logs without the HHMM part exist; leave them alone, but every new one must use the full form.
Write the body (see `references/session-log-template.md`) to a file with the Write tool, then: **Preferred — one call (`session-end`).** Write a bundle JSON with the Write tool and run it; it performs the whole session-end sequence under one lock, in order, with the **heartbeat written last as the commit marker** (a failure part-way leaves the previous pointer intact):
```json
{ "slug": "<kebab-slug>",
"log_body": "<full session-log markdown, frontmatter included — see references/session-log-template.md>",
"agent_log_line": "- <currentDate>: <one-line summary>",
"scope": "<new scope text — omit to leave unchanged>",
"reflect": [ { "title": "…", "kind": "…", "body": "…", "confidence": 0.9 } ] }
```
```bash
python3 "$ECHO" session-end bundle.json # dry-run: previews the full plan
ECHO_NOW=<HHMM> python3 "$ECHO" session-end bundle.json --apply
```
`--apply` writes: session log → Agent Log line → reflect proposals (via `capture`, gate-aware) → optional scope switch → heartbeat. Set `ECHO_NOW` to the conversation's local HHMM so the filename sorts truthfully. Reflect proposals still follow the reflect contract — preview with the dry-run and apply only with the operator's go-ahead. Offline, every step queues durably.
**Manual fallback** (only if `session-end` is unavailable) — the same sequence by hand:
```bash ```bash
python3 "$ECHO" put _agent/sessions/<currentDate>-HHMM-<slug>.md <bodyfile> python3 "$ECHO" put _agent/sessions/<currentDate>-HHMM-<slug>.md <bodyfile>
``` ```
Then add a one-line entry to today's daily note via the **Daily Note — Agent Log** procedure above. Then add a one-line entry to today's daily note via the **Daily Note — Agent Log** procedure above, and finally update the heartbeat pointer so the next session can orient in one read (a pointer nobody writes is a pointer nobody can read) — a single line, `<session-log-path> @ <ISO-timestamp>`:
Finally, update the heartbeat pointer so the next session can orient in one read (this closes the loop with loading-procedure Step 4 — a pointer nobody writes is a pointer nobody can read). It is a single line, `<session-log-path> @ <ISO-timestamp>`; write it to a file and PUT it:
```bash ```bash
python3 "$ECHO" put _agent/heartbeat/last-session.md <bodyfile> python3 "$ECHO" put _agent/heartbeat/last-session.md <bodyfile>
``` ```
`last-session.md` is overwritten (PUT) each session end — never appended, so it can't grow or duplicate. `last-session.md` is overwritten (PUT) each session end — never appended, so it can't grow or duplicate. Keep the heartbeat write **last** in the manual sequence too.
## Vault Unreachable ## 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 — reads degrade to the last-known-good cache at load, and **writes queue durably**: the low-level verbs queue the request, and `capture` queues the whole operation as one semantic record that replays *through capture* (routing + duplicate gate re-run against the index at replay time) on the next reachable session. A queued capture that stops at the duplicate gate on replay is kept and flagged (`flush` lists it) rather than landed blind. Do not retry repeatedly.
## Style Rules ## Style Rules
- Write in third person about Jason: "Jason prefers X", not "I prefer X". - Write in third person about the operator: "the operator 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. - 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. - **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`. - 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). - Set `agent_written: true` on agent-generated notes and list `source_notes` (plain relative paths, not links).
@@ -402,15 +451,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. - **`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]]`. - **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. - 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. - 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 Jason the content first and confirm. - About to write something large or sensitive? Show the operator the content first and confirm.
## operator-preferences.md — Rules vs Observations ## operator-preferences.md — Rules vs Observations
`_agent/memory/semantic/operator-preferences.md` separates two kinds of content: `_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. - `## 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: Jason chose X over Y because Z.` This is where new evidence goes by default. - `## 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. 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.
@@ -1,7 +1,7 @@
# ECHO Performance Test Suite — Plan # ECHO Performance Test Suite — Plan
**Status:** plan only — nothing here is to be run yet. **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). **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. **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. 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. 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) ## 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. - **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. - **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. - **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) ### 3.1 Timed full endpoint read (full-vault bulk read)
@@ -58,7 +58,7 @@
### 3.3 Timed bulk get / put / append ### 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):** **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. - **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. concurrent code path — not a reimplementation.
This is a **manual / pre-release** tool, not a CI gate: it depends on the live 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. (portable), with absolute milliseconds kept as informational context.
See `PERF-TEST-PLAN.md` for the design rationale behind each metric. 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 python3 bench.py soak --soak-seconds 120 # stability / leak watch
``` ```
Set the key first if the baked-in fallback warning bothers you: Configure this machine first (owner/endpoint/key):
`python3 ../../scripts/echo.py write-key <token>` (or `export ECHO_KEY=...`). `python3 ../../scripts/echo.py config init` then edit the file, or `export ECHO_BASE=... ECHO_KEY=...`.
## Metrics ## 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 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 plan's synthetic-vault item). The metric still proves enqueue works and reports
queue depth. queue depth.
- **Absolute ms depend on where you run from.** WAN latency to `echoapi.alwisp.com` - **Absolute ms depend on where you run from.** WAN latency to the configured
dominates single-request times; that's why gates are ratios. endpoint dominates single-request times; that's why gates are ratios.
- **Synthetic-vault scaling (100/500/1000 notes) is deferred** per decision — the - **Synthetic-vault scaling (100/500/1000 notes) is deferred** per decision — the
suite benchmarks the real ~190-note vault today. 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": { "read-full": {
"gates": [ "gates": [
{ "field": "speedup_ratio", "op": ">=", "min": 1.8, { "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. 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 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 <metric> [options]
python3 bench.py all # the read-only safe set python3 bench.py all # the read-only safe set
@@ -11,8 +11,8 @@ from __future__ import annotations
# --- §3.2 subject pulls ------------------------------------------------------- # --- §3.2 subject pulls -------------------------------------------------------
# (label, query) pairs fed to `recall`. Chosen to span neighbourhood sizes — the # (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 # 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 # subjects the maintainer configured; "echo" and "operator preferences" are dense hubs
# stress the 1-hop expansion (the read_many fan-out 1.1.0 accelerated). # that stress the 1-hop expansion (the read_many fan-out 1.1.0 accelerated).
SUBJECTS: list[tuple[str, str]] = [ SUBJECTS: list[tuple[str, str]] = [
("apta", "APTA"), ("apta", "APTA"),
("capmetro", "CapMetro"), ("capmetro", "CapMetro"),
@@ -32,13 +32,16 @@ SUBJECTS: list[tuple[str, str]] = [
# to nothing and silently spawn a new note) # to nothing and silently spawn a new note)
# "miss" -> neither; genuinely unknown # "miss" -> neither; genuinely unknown
# Tune expect_slug to the live vault; unknowns are reported as INFO, not failures. # 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] = [ RESOLVE_CASES: list[dict] = [
{"mention": "echo", "expect": "exact", "expect_slug": "echo"}, {"mention": "echo", "expect": "exact", "expect_slug": "echo"},
{"mention": "echo memory", "expect": "candidates", "expect_slug": "echo"}, {"mention": "echo memory", "expect": "candidates", "expect_slug": "echo"},
{"mention": "ECHO plugin", "expect": "candidates", "expect_slug": "echo"}, {"mention": "ECHO plugin", "expect": "candidates", "expect_slug": "echo"},
{"mention": "goldbrain", "expect": "exact", "expect_slug": "goldbrain"}, {"mention": "other-vault", "expect": "exact", "expect_slug": "other-vault"},
{"mention": "jason stedwell", "expect": "exact", "expect_slug": "jason-stedwell"}, {"mention": "example subject", "expect": "exact", "expect_slug": "example-subject"},
{"mention": "jason", "expect": "candidates", "expect_slug": "jason-stedwell"}, {"mention": "operator", "expect": "candidates", "expect_slug": "example-subject"},
{"mention": "zzqx nonexistent entity", "expect": "miss", "expect_slug": None}, {"mention": "zzqx nonexistent entity", "expect": "miss", "expect_slug": None},
] ]
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:17:09", "date": "2026-06-23T20:17:09",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -353,7 +353,7 @@
"expect_slug": "echo", "expect_slug": "echo",
"resolved_slug": "echo", "resolved_slug": "echo",
"candidate_slugs": [ "candidate_slugs": [
"2026-06-19-goldbrain-full-echo-architect", "2026-06-19-other-vault-full-echo-architect",
"echo", "echo",
"echo-memory-codex-plugin", "echo-memory-codex-plugin",
"echo-plugin-build", "echo-plugin-build",
@@ -407,7 +407,7 @@
"echo-memory-codex-plugin", "echo-memory-codex-plugin",
"echo-plugin-build", "echo-plugin-build",
"echo-skill-improvements", "echo-skill-improvements",
"2026-06-19-goldbrain-full-echo-architect" "2026-06-19-other-vault-full-echo-architect"
], ],
"verdict": "INFO", "verdict": "INFO",
"resolve_timing": { "resolve_timing": {
@@ -456,8 +456,8 @@
"echo", "echo",
"echo-memory-codex-plugin", "echo-memory-codex-plugin",
"echo-plugin-build", "echo-plugin-build",
"2026-06-19-goldbrain-full-echo-architect", "2026-06-19-other-vault-full-echo-architect",
"alabama-wisp-brand-docs" "example-isp-brand-docs"
], ],
"verdict": "INFO", "verdict": "INFO",
"resolve_timing": { "resolve_timing": {
@@ -498,13 +498,13 @@
} }
}, },
{ {
"mention": "goldbrain", "mention": "other-vault",
"expect": "exact", "expect": "exact",
"expect_slug": "goldbrain", "expect_slug": "other-vault",
"resolved_slug": "goldbrain", "resolved_slug": "other-vault",
"candidate_slugs": [ "candidate_slugs": [
"2026-06-19-goldbrain-full-echo-architect", "2026-06-19-other-vault-full-echo-architect",
"goldbrain" "other-vault"
], ],
"verdict": "PASS", "verdict": "PASS",
"resolve_timing": { "resolve_timing": {
@@ -545,13 +545,13 @@
} }
}, },
{ {
"mention": "jason stedwell", "mention": "example subject",
"expect": "exact", "expect": "exact",
"expect_slug": "jason-stedwell", "expect_slug": "example-subject",
"resolved_slug": "jason-stedwell", "resolved_slug": "example-subject",
"candidate_slugs": [ "candidate_slugs": [
"jason-stedwell", "example-subject",
"jason-mcp-gateway" "operator-mcp-gateway"
], ],
"verdict": "PASS", "verdict": "PASS",
"resolve_timing": { "resolve_timing": {
@@ -592,13 +592,13 @@
} }
}, },
{ {
"mention": "jason", "mention": "operator",
"expect": "candidates", "expect": "candidates",
"expect_slug": "jason-stedwell", "expect_slug": "example-subject",
"resolved_slug": "jason-stedwell", "resolved_slug": "example-subject",
"candidate_slugs": [ "candidate_slugs": [
"jason-mcp-gateway", "operator-mcp-gateway",
"jason-stedwell" "example-subject"
], ],
"verdict": "INFO", "verdict": "INFO",
"resolve_timing": { "resolve_timing": {
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:22:00", "date": "2026-06-23T20:22:00",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:21:36", "date": "2026-06-23T20:21:36",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:21:21", "date": "2026-06-23T20:21:21",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:12:16", "date": "2026-06-23T20:12:16",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T21:22:29", "date": "2026-06-23T21:22:29",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:20:57", "date": "2026-06-23T20:20:57",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:21:01", "date": "2026-06-23T20:21:01",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:21:28", "date": "2026-06-23T20:21:28",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:12:12", "date": "2026-06-23T20:12:12",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:22:05", "date": "2026-06-23T20:22:05",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:17:09", "date": "2026-06-23T20:17:09",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:12:19", "date": "2026-06-23T20:12:19",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -20,7 +20,7 @@
"expect_slug": "echo", "expect_slug": "echo",
"resolved_slug": "echo", "resolved_slug": "echo",
"candidate_slugs": [ "candidate_slugs": [
"2026-06-19-goldbrain-full-echo-architect", "2026-06-19-other-vault-full-echo-architect",
"echo", "echo",
"echo-memory-codex-plugin", "echo-memory-codex-plugin",
"echo-plugin-build", "echo-plugin-build",
@@ -74,7 +74,7 @@
"echo-memory-codex-plugin", "echo-memory-codex-plugin",
"echo-plugin-build", "echo-plugin-build",
"echo-skill-improvements", "echo-skill-improvements",
"2026-06-19-goldbrain-full-echo-architect" "2026-06-19-other-vault-full-echo-architect"
], ],
"verdict": "INFO", "verdict": "INFO",
"resolve_timing": { "resolve_timing": {
@@ -123,8 +123,8 @@
"echo", "echo",
"echo-memory-codex-plugin", "echo-memory-codex-plugin",
"echo-plugin-build", "echo-plugin-build",
"2026-06-19-goldbrain-full-echo-architect", "2026-06-19-other-vault-full-echo-architect",
"alabama-wisp-brand-docs" "example-isp-brand-docs"
], ],
"verdict": "INFO", "verdict": "INFO",
"resolve_timing": { "resolve_timing": {
@@ -165,13 +165,13 @@
} }
}, },
{ {
"mention": "goldbrain", "mention": "other-vault",
"expect": "exact", "expect": "exact",
"expect_slug": "goldbrain", "expect_slug": "other-vault",
"resolved_slug": "goldbrain", "resolved_slug": "other-vault",
"candidate_slugs": [ "candidate_slugs": [
"2026-06-19-goldbrain-full-echo-architect", "2026-06-19-other-vault-full-echo-architect",
"goldbrain" "other-vault"
], ],
"verdict": "PASS", "verdict": "PASS",
"resolve_timing": { "resolve_timing": {
@@ -212,13 +212,13 @@
} }
}, },
{ {
"mention": "jason stedwell", "mention": "example subject",
"expect": "exact", "expect": "exact",
"expect_slug": "jason-stedwell", "expect_slug": "example-subject",
"resolved_slug": "jason-stedwell", "resolved_slug": "example-subject",
"candidate_slugs": [ "candidate_slugs": [
"jason-stedwell", "example-subject",
"jason-mcp-gateway" "operator-mcp-gateway"
], ],
"verdict": "PASS", "verdict": "PASS",
"resolve_timing": { "resolve_timing": {
@@ -259,13 +259,13 @@
} }
}, },
{ {
"mention": "jason", "mention": "operator",
"expect": "candidates", "expect": "candidates",
"expect_slug": "jason-stedwell", "expect_slug": "example-subject",
"resolved_slug": "jason-stedwell", "resolved_slug": "example-subject",
"candidate_slugs": [ "candidate_slugs": [
"jason-mcp-gateway", "operator-mcp-gateway",
"jason-stedwell" "example-subject"
], ],
"verdict": "INFO", "verdict": "INFO",
"resolve_timing": { "resolve_timing": {
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:22:15", "date": "2026-06-23T20:22:15",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T21:18:12", "date": "2026-06-23T21:18:12",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:20:23", "date": "2026-06-23T20:20:23",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -1,7 +1,7 @@
{ {
"env": { "env": {
"date": "2026-06-23T20:18:05", "date": "2026-06-23T20:18:05",
"endpoint": "https://echoapi.alwisp.com", "endpoint": "https://obsidian.example.com",
"commit": "af16598", "commit": "af16598",
"echo_workers": 8, "echo_workers": 8,
"echo_timeout": 30, "echo_timeout": 30,
@@ -3,7 +3,7 @@
"subtitle": "Validation of the 1.1.0 concurrency and 1.2.0 entity-resolution releases", "subtitle": "Validation of the 1.1.0 concurrency and 1.2.0 entity-resolution releases",
"reference": "echo-v.05 @ af16598", "reference": "echo-v.05 @ af16598",
"status": "11/11 gate checks pass", "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": [ "sections": [
{ {
"heading": "Test Environment", "heading": "Test Environment",
@@ -11,7 +11,7 @@
"rows": [ "rows": [
{ {
"label": "Endpoint", "label": "Endpoint",
"value": "echoapi.alwisp.com (live)" "value": "obsidian.example.com (live)"
}, },
{ {
"label": "Vault size", "label": "Vault size",
@@ -513,7 +513,7 @@
"items": [ "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.", "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.", "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.", "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.", "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." "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 # ECHO — Obsidian Local REST API Reference
Server: `https://echoapi.alwisp.com` (reverse proxy → backend Obsidian Local REST API) 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 (`echo.py` resolves it automatically: `ECHO_KEY``~/.echo-memory/credentials` → deprecated baked-in fallback). The literal key is never stored in docs. 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.
The endpoint has a **valid TLS certificate**`-k` is not required. Paths address the vault at its **root**. 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. > **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 # Read any file by vault path
curl -s \ curl -s \
-H "Authorization: Bearer $ECHO_KEY" \ -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. 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 # Read a specific heading's content only
curl -s \ curl -s \
-H "Authorization: Bearer $ECHO_KEY" \ -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`): 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) # List contents of a directory (trailing slash required)
curl -s \ curl -s \
-H "Authorization: Bearer $ECHO_KEY" \ -H "Authorization: Bearer $ECHO_KEY" \
"https://echoapi.alwisp.com/vault/_agent/sessions/" "$ECHO_BASE/vault/_agent/sessions/"
``` ```
Returns JSON: `{ "files": [...], "folders": [...] }`. Returns JSON: `{ "files": [...], "folders": [...] }`.
@@ -74,7 +74,7 @@ curl -s -X POST \
-H "Authorization: Bearer $ECHO_KEY" \ -H "Authorization: Bearer $ECHO_KEY" \
-H "Content-Type: text/markdown" \ -H "Content-Type: text/markdown" \
--data-binary @/tmp/obs_entry.md \ --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 "Authorization: Bearer $ECHO_KEY" \
-H "Content-Type: text/markdown" \ -H "Content-Type: text/markdown" \
--data-binary @/tmp/obs_file.md \ --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 ```bash
cat > /tmp/obs_patch.md << 'OBSEOF' 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 OBSEOF
curl -s -X PATCH \ curl -s -X PATCH \
@@ -130,7 +130,7 @@ curl -s -X PATCH \
-H "Target: Operator Preferences::Fact / Pattern" \ -H "Target: Operator Preferences::Fact / Pattern" \
-H "Content-Type: text/markdown" \ -H "Content-Type: text/markdown" \
--data-binary @/tmp/obs_patch.md \ --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 ### 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 \ curl -s \
-H "Authorization: Bearer $ECHO_KEY" \ -H "Authorization: Bearer $ECHO_KEY" \
-H "Accept: application/vnd.olrapi.document-map+json" \ -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`. Returns `{ "headings": [...], "blocks": [...], "frontmatterFields": [...] }`. Copy the heading string verbatim into `Target`.
@@ -156,6 +156,12 @@ Same call with `Operation: prepend`.
### Patch a frontmatter field ### Patch a frontmatter field
> A `PATCH` `Target-Type: frontmatter` **replace** on a key the note does not have returns
> `400 invalid-target` — the raw API cannot create a key. `echo.py fm` (v1.5+) handles this:
> on that 400 it surgically inserts the one `field: value` line into the frontmatter block
> and re-PUTs (verified), leaving every other line untouched. Raw-curl users must GET-edit-PUT
> themselves — carefully (a naive rewrite is how frontmatter gets clobbered).
```bash ```bash
curl -s -X PATCH \ curl -s -X PATCH \
-H "Authorization: Bearer $ECHO_KEY" \ -H "Authorization: Bearer $ECHO_KEY" \
@@ -164,7 +170,7 @@ curl -s -X PATCH \
-H "Target: updated" \ -H "Target: updated" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
--data '"2026-06-05"' \ --data '"2026-06-05"' \
"https://echoapi.alwisp.com/vault/projects/active/vault-foundation.md" "$ECHO_BASE/vault/projects/active/vault-foundation.md"
``` ```
--- ---
@@ -174,7 +180,7 @@ curl -s -X PATCH \
```bash ```bash
curl -s -X POST \ curl -s -X POST \
-H "Authorization: Bearer $ECHO_KEY" \ -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 }] }`. Returns an array of `{ filename, score, matches: [{ context, match }] }`.
@@ -186,11 +192,18 @@ Returns an array of `{ filename, score, matches: [{ context, match }] }`.
```bash ```bash
curl -s -X DELETE \ curl -s -X DELETE \
-H "Authorization: Bearer $ECHO_KEY" \ -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. Only on explicit operator request. Deletion is destructive.
> **DELETE removes files only — never directories.** Deleting the last file in a folder
> leaves the empty folder orphaned on disk; the REST listing then 404s (nothing left to
> list), which falsely reads as "the tree is gone", but Obsidian's file explorer still
> shows it. After removing a folder's last file, tell the operator the empty folder
> needs manual deletion in Obsidian — do not claim the tree is gone from a 404 listing.
> (Learned 2026-07-03 removing the retired `archive/` and `_agent/outputs/` trees.)
--- ---
## URL-Encoding Notes ## URL-Encoding Notes
@@ -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). `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 AUTH="Authorization: Bearer $ECHO_KEY" # export ECHO_KEY first; echo.py resolves it automatically via echo_config
BASE="https://echoapi.alwisp.com" BASE="$ECHO_BASE" # the endpoint from ~/.claude/echo-memory/config.json (override: ECHO_BASE)
``` ```
--- ---
@@ -34,7 +34,7 @@ At session start, GET the marker:
curl -s -o /dev/null -w "%{http_code}" -H "$AUTH" "$BASE/vault/_agent/echo-vault.md" curl -s -o /dev/null -w "%{http_code}" -H "$AUTH" "$BASE/vault/_agent/echo-vault.md"
``` ```
- **200** → bootstrapped. Read the marker's `schema_version`; if it is **less than** the plugin's current schema (2), run a migration pass (see *Migrations* below), otherwise proceed straight to the loading procedure in `SKILL.md`. - **200** → bootstrapped. Read the marker's `schema_version`; if it is **less than** the plugin's current schema (4), run a migration pass (see *Migrations* below), otherwise proceed straight to the loading procedure in `SKILL.md`.
- **404** → empty/unconfigured vault. Run **Fresh bootstrap** below. (If you expected an existing vault, confirm with the operator once that the REST API is pointed at the right vault before seeding.) - **404** → empty/unconfigured vault. Run **Fresh bootstrap** below. (If you expected an existing vault, confirm with the operator once that the REST API is pointed at the right vault before seeding.)
--- ---
@@ -138,5 +138,5 @@ Run the same steps 15, 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. 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. - **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. - **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/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 | | `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 ## 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 | | 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 ## 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/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/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/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 | | `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/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/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/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 a Jason-preference | Semantic note | Timeless fact, vs episodic (time-stamped event) and working (transient) | PUT | | `_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/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/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/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/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/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/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/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 | | `_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) ## 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. 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/`. 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/`. 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. 5. **A decision:** always `decisions/by-date/`; mirror into a project only if one already exists.
@@ -61,15 +61,15 @@
## Canonical Frontmatter ## Canonical Frontmatter
Every note starts with this block. Fill what applies; leave the rest empty rather than guessing. Every note starts with this block. Fill what applies; leave the rest empty rather than guessing**except `status:` and `tags:` on entity notes** (person/company/concept/reference/project/area/skill, plus `tags` on meeting/decision), which must be populated: `capture` stamps a kind-appropriate default `status` and seeds `tags` with the note's kind, `/echo-health` flags missing/empty ones (`incomplete-frontmatter`), and `sweep.py --apply` backfills older notes. The per-kind requirement map is `KIND_REQUIRED_FM` / `KIND_STATUS` in `scripts/echo_index.py`.
```yaml ```yaml
--- ---
type: # see Note Types below type: # see Note Types below
status: # active | draft | done | archived | complete status: # active | draft | done | archived | complete — REQUIRED on entity notes (kind default stamped by capture)
created: # YYYY-MM-DD (or YYYY-MM-DDTHH:mm for sessions) created: # YYYY-MM-DD (or YYYY-MM-DDTHH:mm for sessions)
updated: # YYYY-MM-DD updated: # YYYY-MM-DD
tags: [] tags: [] # REQUIRED (non-empty) on entity notes — capture seeds the kind (e.g. [company]); enrich via --tags
aliases: [] # optional — other names this entity is called (Obsidian-native). Folded aliases: [] # optional — other names this entity is called (Obsidian-native). Folded
# into the entity index so a shortened/expanded name resolves to this note. # into the entity index so a shortened/expanded name resolves to this note.
agent_written: false agent_written: false
@@ -111,14 +111,14 @@ not rewrite frontmatter — the append goes after existing content. To change
The profile analog. Canonical headings: 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. - `## 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. - `## Observations`**timestamped raw observations.** Date-prefixed lines (`- 2026-06-06: ...`). Default landing zone for new evidence.
- `## Evidence` — citations/links supporting the rules - `## Evidence` — citations/links supporting the rules
- `## Recommendation or Implication` — how the rules should shape behavior - `## Recommendation or Implication` — how the rules should shape behavior
- `## Review Notes` — confidence / last review date - `## 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 ### projects/active/\<slug\>.md
@@ -173,11 +173,11 @@ ADR-style: Context → Decision → Consequences. If the decision belongs to an
### people/\<name\>.md ### 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 ### 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.
--- ---

Some files were not shown because too many files have changed in this diff Show More