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>
19 KiB
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.mdorROADMAP-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 / Patternin full;## Observationscapped at the last 10 lines with a(+N older — /echo-load for full)note. - current-context →
## Scope+scope_updated+ sessions-since (i.e. thescope showoutput); omit## Scope History. - heartbeat → the pointer line + only the
## Summary/## Outcomessections of the pointed-at session log (fetch it, extract those headings, cap ~30 lines). - today's daily note →
## Agent Loglines only (or "absent"). - inbox → count + age of oldest item, not the contents (that's all the reconcile
needs;
/echo-triage --listhas the details).
- marker → one line (
- Budget guard: after assembly, if the brief output still exceeds
ECHO_LOAD_BUDGETchars (default ~8000), truncate lowest-priority sections first (observations → agent log → session summary) with explicit(truncated)markers. - Full
loadunchanged;/echo-loadkeeps 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 atecho.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, honoringECHO_STATE_DIR) keyed by endpoint (hash ofBASE) so multiple vaults don't collide. update_note()becomes a local read-modify-write (file lock viaos.O_EXCLsidecar or atomicos.replace), no vault round-trip, no advisory lock.- The vault copy (
_agent/index/recall-index.json) becomes a snapshot, written bysweep.pyand at session end (piggyback on the heartbeat write / futuresession-endverb). 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
builtstamp 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,captureshort-circuits: enqueue the full argument set (title/kind/body/tags/aliases/sources/date/domain/merge_into) and reportqueued (offline): capture …. flush()replaysop:capturerecords by callingecho_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 aneeds_attentionflag and is surfaced byload/flushoutput ("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_logstays best-effort but routes its POST/PATCH throughsafe_requestso a standalone agent-log line survives an outage too.- reflect/triage
--applyinherit 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_manyonly 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-shardsforces full verification.- Deletions: listing walk catches removed files → drop index + recall entries.
- Auto-run:
load(brief or full) runssweep --fastopportunistically when the last fast-sweep stamp (state dir) is older thanECHO_FAST_SWEEP_DAYS(default 7), in the background-tolerant sense: bounded by the same read_many concurrency, and skipped entirely when offline. /echo-healthreports 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 workingstampsexpires: <today + ECHO_WORKING_TTL_DAYS>(default 14;--ttl <days>overrides). Template updated to carry the field.vault_lint.pynew checks:expired-working(pastexpires),stale-observations(Observations > 30 bullets) — advisory, like the rest.sweep.py --decay(dry-run by default,--applyto write; always preview-first, honoring the operating contract):- expired working notes →
status: archived+ move body under a dated## Archivedmarker (never delete); projects/active/untouched > 30 days → proposeon-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.mdappend), keeping the last 30 in place. Nothing is lost, the hot file stays small (compounds with #1).
- expired working notes →
/echo-healthoutput nudges: "runsweep --decayto 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+ asuperseded_by:frontmatter path; new note carriessupersedes:back-link in## Related. Recall's STATUS_FACTOR gainssuperseded: 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.
- if it names an entity slug → the old entity gets
- Detect side (deterministic assist, model decides):
echo_reflect.classifygains aconflict 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 rendersupdate-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_bypointing 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:{ "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 fromECHO_NOWenv 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-reflectdocs point session logging at it. Prerequisite for the MCPecho_log_sessiontool — 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 scoresecho-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_candidatesnon-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 --applywrites it into the note'saliases: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.x quick wins | #1 brief load · #3 offline capture · #7 session-end (+ return-not-print refactor, MCP spec Phase 0, if it fits) | Small, correctness/efficiency, no schema changes. #7 unblocks the MCP tool. Users get these without a reinstall — don't gate them behind 2.0. |
| 2.0 | ROADMAP-2.0 unchanged: delete legacy commands/ · artifact policy (Gitea releases, gitignore, tags) · Codex note · README layout |
Packaging only, no feature riders — a bad feature must never force reverting a packaging break. |
| 2.1 | MCP server (containerized, echomcp.alwisp.com) — docs/MCP-SERVER-SPEC.md |
Needs #7 + Phase 0. Lands after 2.0 so its docs target one command format and the Gitea release channel exists. Deployed via CI + PORT, not the plugin manifest. |
| 2.2 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.3 memory train | #5 lifecycle/decay · #6 supersession · #9 resolve-miss learning | Capability tier; #6 after #5 (status vocabulary). |