17 Commits

Author SHA1 Message Date
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
120 changed files with 2834 additions and 3732 deletions
Vendored
BIN
View File
Binary file not shown.
+10
View File
@@ -15,3 +15,13 @@ 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/
# Per-user BAKED artifacts (build.py --bake-key, named echo-memory-<ver>-<label>.plugin)
# carry a live vault bearer token — NEVER commit them. They belong in dist/ (above);
# this also guards any that get built at the repo root. The version-only pointer
# (echo-memory.plugin) and version-only artifacts (echo-memory-<ver>.plugin) stay tracked.
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.
+170
View File
@@ -0,0 +1,170 @@
# Changelog
## 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).
+84 -23
View File
@@ -1,12 +1,12 @@
# echo-memory — v1.0.0 # echo-memory — v1.5.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,26 @@ 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) ├── echo-memory-<version>.plugin ← versioned build artifacts (history; moves to Gitea releases in 2.0)
├── eval/ ← credential-free A/B eval harness (0.6 vs 0.7); not bundled ├── 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
├── commands/ ← slash commands (legacy format; skills-format migration = TODO-1.6):
│ echo-load|save|recall|triage|health|sweep|reflect|doctor
└── skills/echo-memory/ └── skills/echo-memory/
├── SKILL.md ← operating procedure (authoritative) ├── SKILL.md ← operating procedure (authoritative)
├── references/ ├── references/
@@ -68,17 +92,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
@@ -96,9 +130,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 +143,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 +270,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 +399,33 @@ 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 |
|---------|-----------| |---------|-----------|
| **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).
+95
View File
@@ -0,0 +1,95 @@
# 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**:
- [ ] Track only `echo-memory.plugin` (the current installable); delete the versioned
zips from the tree.
- [ ] Publish versioned builds as **Gitea releases** on `git.alwisp.com/jason/echo`
instead (one release per tag, artifact attached).
- [ ] `.gitignore`: `*.plugin` except the pointer.
- [ ] 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:
- [ ] **Delete the legacy `commands/` directory** — the packaging break that partly
motivates the major bump.
- [ ] Re-verify desktop + CoWork installs of the skills-only artifact.
- [ ] Update SKILL.md / README / command docs that reference `commands/`.
## 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).
- [ ] 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).
## 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**.
- [ ] Root README "Repository layout" section updated for the post-2.0 tree
(no versioned zips, no codex tree, skills-only plugin).
- [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.
+63
View File
@@ -0,0 +1,63 @@
# 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:**
- [ ] `allowed-tools` per skill — pre-authorize the resolved `python3 "$ECHO" …`
invocations so `/echo-load`, `/echo-health`, `/echo-recall` stop prompting
(1.4.2 hand-broadened two commands; the skill format does this properly).
- [ ] `disable-model-invocation: true` on the write-heavy entry points
(`/echo-sweep`, `/echo-triage`) so only the operator triggers them; leave the
read-side ones model-invocable.
- [ ] De-duplicate the CoWork `$ECHO` path-resolution block currently copy-pasted
into all eight command bodies — each skill folder can carry a shared snippet.
- [ ] `argument-hint` carries over as-is; `$ARGUMENTS` substitution unchanged.
- [ ] Verify a migrated build installs cleanly on desktop **and** in a CoWork session
before deleting `commands/` (deleting the legacy dir is a 2.0 item —
see `ROADMAP-2.0.md`; in 1.6 both may coexist).
## 2. plugin.json completeness (from the old MAINTENANCE checklist)
- [ ] Add `homepage` / repository URL — now decided: `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)*
- [ ] **Lint blind spot: retired dirs masked by the leaf-README route** (found 2026-07-03).
`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.
+110 -29
View File
@@ -7,8 +7,13 @@ dev cruft. The version is read from the manifest, so the output is named automat
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())
+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,9 @@
{ {
"name": "echo-memory", "name": "echo-memory",
"version": "1.2.0", "version": "1.5.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.", "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**
@@ -5,11 +5,16 @@ description: Check ECHO readiness — Python, vault reachability, auth, bootstra
Use the **echo-memory** skill to run a one-call readiness check before relying on memory. Use the **echo-memory** skill to run a one-call readiness check before relying on memory.
```bash ```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" doctor 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. # Windows: use `python` or `py -3` if `python3` is not on PATH.
``` ```
It prints green/red for: Python version, vault reachability, auth accepted, vault It prints green/red for: Python version, vault reachability, auth accepted, vault
bootstrapped (+ `schema_version`), and the **API key source** (`ECHO_KEY` env / credentials bootstrapped (+ `schema_version`), and the **config source** for owner/endpoint/key
file / deprecated baked-in fallback — see `API-KEY-SETUP.md`). Exits non-zero if anything is (per-field: env override `ECHO_OWNER`/`ECHO_BASE`/`ECHO_KEY`, then the machine-local
red. For the full vault-invariant lint, run `/echo-health`. `~/.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`.
@@ -1,15 +1,17 @@
--- ---
description: Run the ECHO vault-health linter and summarize any invariant violations 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*) allowed-tools: Bash(python3 *vault_lint.py*), Bash(python *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: 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 ```bash
ECHO_TODAY=<currentDate> python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/vault_lint.py" 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. # 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`). Exit codes: `0` clean · `1` violations (printed, grouped by check) · `2` vault unreachable · `3` vault not bootstrapped (run `bootstrap.py`).
Summarize the violations grouped by category and propose fixes, but **do not auto-fix** without Jason's go-ahead. If this is the first substantive session of a calendar month, offer to write the findings to `_agent/health/YYYY-MM-vault-health.md` (per the skill's **Vault Health** section). Summarize the violations grouped by category and propose fixes, but **do not auto-fix** without the operator's go-ahead. If this is the first substantive session of a calendar month, offer to write the findings to `_agent/health/YYYY-MM-vault-health.md` (per the skill's **Vault Health** section).
+5 -1
View File
@@ -5,10 +5,14 @@ description: Load ECHO memory — cold-start context read (profile, scope, lates
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.
@@ -10,8 +10,12 @@ Use the **echo-memory** skill to recall context for:
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: 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 ```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" recall "$ARGUMENTS" 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" recall "$ARGUMENTS"
# Windows: use `python` or `py -3` if `python3` is not on PATH. # Windows: use `python` or `py -3` if `python3` is not on PATH.
``` ```
The corpus spans the entity graph **plus session logs and journal notes**, ranked by relevance × freshness × status — each hit shows its `updated:`/`status:`, so prefer fresh/active hits and treat stale ones as history. Add `--json` for structured hits (`path/score/type/updated/status/excerpt`) when you need to act on the results programmatically.
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. 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.
@@ -7,19 +7,20 @@ Use the **echo-memory** skill to run **session reflection** (H5). Scan this conv
things worth remembering across sessions, then propose them — don't write blindly. things worth remembering across sessions, then propose them — don't write blindly.
1. **Extract** durable items from the conversation: new facts, preferences, decisions, 1. **Extract** durable items from the conversation: new facts, preferences, decisions,
commitments, people/companies/projects introduced, and anything Jason said to remember. commitments, people/companies/projects introduced, and anything the operator said to remember.
Skip the ephemeral. Anchor relative dates on the conversation's `currentDate`. Skip the ephemeral. Anchor relative dates on the conversation's `currentDate`.
2. **Emit a JSON array** of proposals (one per item), each: 2. **Emit a JSON array** of proposals (one per item), each:
`{"title","kind","body","aliases","sources","confidence"}``kind` `{"title","kind","body","aliases","tags","sources","confidence"}``kind`
`person, company, concept, reference, meeting, project, area, semantic, episodic, `person, company, concept, reference, meeting, project, area, semantic, episodic,
working, skill, decision`; set `"inbox": true` when the home is genuinely unknown; working, skill, decision`; set `"inbox": true` when the home is genuinely unknown;
`confidence` 01 (items below 0.6 are dropped — send those to the inbox instead). `confidence` 01 (items below 0.6 are dropped — send those to the inbox instead).
Write the array to a file with the Write tool (cross-platform; no heredoc). Write the array to a file with the Write tool (cross-platform; no heredoc).
3. **Preview, then apply.** Dry-run first (writes nothing) and show Jason the plan; only 3. **Preview, then apply.** Dry-run first (writes nothing) and show the operator the plan; only
apply after he confirms. apply after they confirm.
```bash ```bash
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" # python3 (Windows: python / py -3) ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" # python3 (Windows: python / py -3)
[ -f "$ECHO" ] || ECHO=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$ECHO" reflect proposals.json # dry-run: validate + classify + preview python3 "$ECHO" reflect proposals.json # dry-run: validate + classify + preview
python3 "$ECHO" reflect proposals.json --apply # write — routes each via capture (indexed, linked, logged) python3 "$ECHO" reflect proposals.json --apply # write — routes each via capture (indexed, linked, logged)
``` ```
+6 -3
View File
@@ -7,14 +7,17 @@ Use the **echo-memory** skill to persist this to the ECHO vault:
> $ARGUMENTS > $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). Prefer the one-call **`capture`** — it routes (via the entity index), derives the canonical path, stamps complete frontmatter (kind-default `status`, the kind seeded into `tags`), 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`); add `--tags` when obvious; use `--inbox` only when the home is genuinely unknown. Write multi-line bodies to a file with the Write tool (cross-platform, no heredoc) — updates to an existing entity preserve the whole body, so don't pre-trim it.
```bash ```bash
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" # run with: python3 "$ECHO" ... (Windows: python / py -3) 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] [ -f "$ECHO" ] || ECHO=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$ECHO" capture "<title>" <bodyfile> --kind <kind> [--aliases a,b] [--tags t1,t2] [--source p1,p2]
python3 "$ECHO" capture "<title>" --inbox # unknown home -> idempotent inbox line python3 "$ECHO" capture "<title>" --inbox # unknown home -> idempotent inbox line
``` ```
**If capture exits `76` (duplicate gate):** the title strongly resembles an existing entity — do NOT retry blindly. Show the operator the printed candidates and either update the existing note (`capture ... --merge-into <slug>`) or, only after they confirm it's genuinely distinct, re-run with `--force`.
Drop to the low-level verbs only for shapes `capture` doesn't model (a project `## Status` replace, an ADR mirror, a daily-note edit): 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 ```bash
@@ -22,4 +25,4 @@ python3 "$ECHO" put <routed/path>.md <bodyfile> # create
python3 "$ECHO" patch <path>.md append heading "<H1::Sub>" <bodyfile> # targeted append python3 "$ECHO" patch <path>.md append heading "<H1::Sub>" <bodyfile> # targeted append
``` ```
Write in third person about Jason; never put `[[wikilinks]]` in frontmatter. `capture` handles `agent_written`, `source_notes`, indexing, and linking for you; if you write by hand, `resolve "<title>"` first to avoid duplicates and `bump` `updated:` only on substantive changes. Write in third person about the vault owner; never put `[[wikilinks]]` in frontmatter. `capture` handles `agent_written`, `source_notes`, indexing, and linking for you; if you write by hand, `resolve "<title>"` first to avoid duplicates and `bump` `updated:` only on substantive changes.
@@ -1,13 +1,15 @@
--- ---
description: Bring the ECHO vault up to the current feature spec (entity index + cross-links) description: Bring the ECHO vault up to the current feature spec (entity index + cross-links)
allowed-tools: Bash(python3 */echo-memory/scripts/sweep.py*), Bash(python */echo-memory/scripts/sweep.py*) allowed-tools: Bash(python3 *sweep.py*), Bash(python *sweep.py*), Bash(ls /sessions/*), Bash(dirname *)
--- ---
Use the **echo-memory** skill to bring the vault up to the current spec. Run the sweep **dry-run first**, show Jason the plan, and only `--apply` on his go-ahead: Use the **echo-memory** skill to bring the vault up to the current spec. Run the sweep **dry-run first**, show the operator the plan, and only `--apply` on their go-ahead:
```bash ```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py" # plan (read-only) SDIR="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts"
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py" --apply # write [ -d "$SDIR" ] || SDIR=$(dirname "$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/sweep.py 2>/dev/null | head -1)") # CoWork sandbox fallback
python3 "$SDIR/sweep.py" # plan (read-only)
python3 "$SDIR/sweep.py" --apply # write
# Windows: use `python` or `py -3` if `python3` is not on PATH. # Windows: use `python` or `py -3` if `python3` is not on PATH.
``` ```
+14 -3
View File
@@ -2,11 +2,22 @@
description: Triage the ECHO inbox — route aging captures to their canonical homes and log the moves 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. Use the **echo-memory** skill to run **one-tap Inbox Triage** via the `triage` verb (it reuses the reflect pipeline: classify against the entity index → preview → apply via `capture`, and writes the audit log for you).
```bash ```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" get inbox/captures/inbox.md 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" triage --list --json
# Windows: use `python` or `py -3` if `python3` is not on PATH. # 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. 1. **List** — the JSON gives each capture as `{line, date, text, age_days}`. Surface the aging ones (≥ ~7 days) to the operator and ask which to route.
2. **Propose** — for accepted items, write a proposals JSON with the Write tool (reflect schema: `{"title","kind","body",...}` **plus `"line"`: the original inbox line**, echoed into the audit log). Route per the map: preference/pattern → `semantic` (or a direct PATCH to `operator-preferences.md::Observations`); project idea → `project` with `--status incubating`; durable fact → `semantic`; person fact → `person`.
3. **Preview, then apply** with the operator's go-ahead:
```bash
python3 "$ECHO" triage proposals.json # dry-run: classify + preview, writes nothing
python3 "$ECHO" triage proposals.json --apply # route via capture + log each move
```
`--apply` records every move in `inbox/processing-log/YYYY-MM-DD.md` (`- <original> → <destination>`) automatically. Do **not** delete the original captures unless the operator explicitly asks — the processing log is the audit trail. A proposal that stops at the duplicate gate (exit note in the summary) should be re-proposed with the existing entity's title.
+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.
@@ -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,7 +136,7 @@ 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
@@ -122,7 +145,7 @@ Load at the start of any substantive conversation — anything beyond a single q
| # | 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
@@ -389,12 +423,12 @@ python3 "$ECHO" put _agent/heartbeat/last-session.md <bodyfile>
## 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 without memory. 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 +436,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.
--- ---
@@ -6,7 +6,7 @@ updated: {{DATE}}
tags: [agent, system, marker] tags: [agent, system, marker]
agent_written: true agent_written: true
source_notes: [] source_notes: []
schema_version: 3 schema_version: 4
bootstrapped: {{DATE}} bootstrapped: {{DATE}}
managed_by: echo-memory-plugin managed_by: echo-memory-plugin
--- ---
@@ -19,9 +19,11 @@ reused across requests), and `read_many` fans bulk GETs across a thread pool —
full-vault scripts (sweep, lint, recall rebuild) make a handful of warm round-trips full-vault scripts (sweep, lint, recall rebuild) make a handful of warm round-trips
instead of hundreds of fresh TLS handshakes, and no longer blow past tool timeouts. instead of hundreds of fresh TLS handshakes, and no longer blow past tool timeouts.
Config (env overrides; defaults match the rest of the plugin): Config (owner/endpoint/key are machine-local see echo_config; the plugin ships none):
ECHO_BASE default https://echoapi.alwisp.com owner/endpoint/key resolved by echo_config from ~/.claude/echo-memory/config.json
ECHO_KEY default the plugin bearer token (env overrides it) (env ECHO_OWNER / ECHO_BASE / ECHO_KEY override per field).
Set up a machine with `echo.py config init` then edit, or
`echo.py config set --owner ... --endpoint ... --key ...`.
ECHO_VERIFY default 1 read-back verify after a PUT ECHO_VERIFY default 1 read-back verify after a PUT
ECHO_LOCK_TTL default 900 seconds before an advisory lock is considered stale ECHO_LOCK_TTL default 900 seconds before an advisory lock is considered stale
ECHO_TIMEOUT default 30 per-request socket timeout (seconds) ECHO_TIMEOUT default 30 per-request socket timeout (seconds)
@@ -38,15 +40,18 @@ Usage:
echo.py post <path> [file] # raw append (NON-idempotent; prefer `append`) echo.py post <path> [file] # raw append (NON-idempotent; prefer `append`)
echo.py append <path> <line> # idempotent append: skips only on an exact whole-line match echo.py append <path> <line> # idempotent append: skips only on an exact whole-line match
echo.py patch <path> <append|prepend|replace> <heading|frontmatter|block> <target> [file] echo.py patch <path> <append|prepend|replace> <heading|frontmatter|block> <target> [file]
echo.py fm <path> <field> <json-value> # PATCH a frontmatter scalar echo.py fm <path> <field> <json-value> # create-or-replace a frontmatter scalar
echo.py bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date) echo.py bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date)
echo.py triage [--list --json | <proposals.json> [--apply]] # one-tap inbox triage + audit log
echo.py delete <path> # DELETE (destructive; explicit use only) echo.py delete <path> # DELETE (destructive; explicit use only)
echo.py lock <owner-id> # acquire advisory lock (exit 75 if held & fresh, or lost the race) echo.py lock <owner-id> # acquire advisory lock (exit 75 if held & fresh, or lost the race)
echo.py unlock <owner-id> # release advisory lock if owned by <owner-id> echo.py unlock <owner-id> # release advisory lock if owned by <owner-id>
echo.py scope show # print active scope, its freshness, and sessions-since echo.py scope show # print active scope, its freshness, and sessions-since
echo.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated) echo.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
echo.py config [show|init|set|import] # machine-local owner/endpoint/key (config import <file> adopts a provided file)
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 2 usage · 1 other HTTP/transport error. Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 76 duplicate-gate (capture) ·
78 config-required · 2 usage · 1 other HTTP/transport error.
""" """
from __future__ import annotations from __future__ import annotations
@@ -57,6 +62,7 @@ import http.client
import json import json
import locale import locale
import os import os
import re
import sys import sys
import tempfile import tempfile
import threading import threading
@@ -73,15 +79,17 @@ for _stream in (sys.stdout, sys.stderr):
except Exception: except Exception:
pass pass
BASE = os.environ.get("ECHO_BASE", "https://echoapi.alwisp.com").rstrip("/") # Owner / endpoint / key are machine-local and resolved by echo_config from
# ~/.claude/echo-memory/config.json (env ECHO_OWNER / ECHO_BASE / ECHO_KEY override).
# Key resolution is centralized in echo_secrets: ECHO_KEY env -> ~/.echo-memory/credentials # The plugin ships none of them; load() returns "" for anything unset so --help,
# -> the deprecated baked-in fallback below. Move the key out of the tree with # `config`, and `doctor` stay usable. Network ops raise a clear error if endpoint/key
# `echo.py write-key <token>`; remove DEFAULT_KEY before the 1.0 tag (see ROADMAP-1.0.md > M1). # are missing (see _require_endpoint / request).
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo_secrets # noqa: E402 import echo_config # noqa: E402
DEFAULT_KEY = "241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab" _CFG = echo_config.load()
KEY = echo_secrets.resolve_key(DEFAULT_KEY) BASE = _CFG["endpoint"]
KEY = _CFG["key"]
OWNER = _CFG["owner"]
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0" VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900")) LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
# Per-request socket timeout. A single stuck file fails fast instead of stalling a # Per-request socket timeout. A single stuck file fails fast instead of stalling a
@@ -125,6 +133,19 @@ _local = threading.local()
MAX_ATTEMPTS = 3 MAX_ATTEMPTS = 3
def _require_config() -> None:
"""Fail loudly (not with an opaque DNS/connection error) when this machine has no
usable key file yet the most common fresh-install state. Also catches an
untouched `config init` template (placeholder endpoint/key)."""
if not echo_config.is_configured(_CFG):
raise EchoError(
"echo: NOT CONFIGURED — no usable ECHO key file on this machine. Ask the "
f"operator for their config (owner/endpoint/key) and install it at "
f"{echo_config.config_path()}: `echo.py config import <file>`, or "
"`echo.py config set --owner ... --endpoint ... --key ...` "
"(or set ECHO_BASE / ECHO_KEY).", 2)
def _new_connection() -> http.client.HTTPConnection: def _new_connection() -> http.client.HTTPConnection:
parts = urllib.parse.urlsplit(BASE) parts = urllib.parse.urlsplit(BASE)
host = parts.hostname or parts.path host = parts.hostname or parts.path
@@ -157,6 +178,7 @@ def request(method: str, url: str, data: bytes | None = None,
status 0 == transport failure (echo_queue relies on this to trigger offline queueing). status 0 == transport failure (echo_queue relies on this to trigger offline queueing).
Retries: a stale pooled connection reconnects immediately; 5xx and other transport Retries: a stale pooled connection reconnects immediately; 5xx and other transport
errors get one bounded sleep-retry, same as before.""" errors get one bounded sleep-retry, same as before."""
_require_config()
merged = {"Authorization": f"Bearer {KEY}", **(headers or {})} merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
parts = urllib.parse.urlsplit(url) parts = urllib.parse.urlsplit(url)
target = parts.path or "/" target = parts.path or "/"
@@ -320,6 +342,14 @@ def cmd_put(path: str, file_arg: str | None) -> int:
if status != 200: if status != 200:
raise EchoError(f"put {path}: write did not verify (GET returned {status})") raise EchoError(f"put {path}: write did not verify (GET returned {status})")
print(f"ok: PUT {path}") print(f"ok: PUT {path}")
# Keep the recall (BM25) index current when a corpus note is written directly —
# session logs, journal notes, hand-authored entity notes. update_note early-returns
# for non-corpus paths and never raises, so this can't fail or slow a normal put.
try:
import echo_recall
echo_recall.update_note(path, data.decode("utf-8", errors="replace"))
except Exception as exc: # noqa: BLE001 — index upkeep must never fail a put
print(f"echo.py: recall-index update skipped ({exc})", file=sys.stderr)
return 0 return 0
@@ -383,8 +413,64 @@ def cmd_patch(path: str, op: str, target_type: str, target: str, file_arg: str |
return 0 return 0
def _yaml_flow(value) -> str:
"""Render a JSON-decoded value as a YAML flow scalar/list for one frontmatter line."""
if isinstance(value, list):
return "[" + ", ".join(_yaml_flow(v) for v in value) + "]"
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return ""
s = str(value)
return s if re.match(r"^[A-Za-z0-9][A-Za-z0-9 ._/-]*$", s) else json.dumps(s)
def _fm_upsert_line(text: str, field: str, rendered: str) -> str:
"""Surgically set `field: rendered` inside the YAML frontmatter block, creating the
key (or the whole block) if absent. Every other line is preserved verbatim this is
the safety property that makes it OK to fall back from a failed PATCH."""
line = f"{field}: {rendered}".rstrip()
lines = text.splitlines()
if lines and lines[0].strip() == "---":
for end in range(1, len(lines)):
if lines[end].strip() == "---":
for i in range(1, end): # key exists -> replace that one line
if re.match(rf"^{re.escape(field)}\s*:", lines[i]):
lines[i] = line
break
else:
lines.insert(end, line) # else append just before the closing ---
return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
return f"---\n{line}\n---\n{text}" # no frontmatter at all -> prepend a minimal block
def cmd_fm(path: str, field: str, value: str) -> int: def cmd_fm(path: str, field: str, value: str) -> int:
return cmd_patch(path, "replace", "frontmatter", field, temp_file(normalize_json_scalar(value))) """Create-or-replace a frontmatter scalar. PATCH replace handles the common case;
a missing key returns 400 invalid-target (PATCH cannot create), which used to make
`fm` fail on exactly the notes that needed repair now it falls back to a surgical
GET -> insert-one-line -> verified PUT."""
data = normalize_json_scalar(value)
try:
return cmd_patch(path, "replace", "frontmatter", field, temp_file(data))
except EchoError as exc:
if "HTTP 400" not in str(exc):
raise
status, body = request("GET", vault_url(path))
check(status, body, f"fm {path}")
new = _fm_upsert_line(body.decode(errors="replace"), field,
_yaml_flow(json.loads(data.decode("utf-8"))))
status, body = _qwrite("PUT", vault_url(path), data=new.encode("utf-8"),
headers={"Content-Type": "text/markdown"})
if status == 202:
print(f"queued (offline): FM {field} -> {path} — will sync on the next reachable session")
return 0
check(status, body, f"fm {path}")
if VERIFY:
status, body = request("GET", vault_url(path))
if status != 200 or not re.search(rf"(?m)^{re.escape(field)}\s*:", body.decode(errors="replace")):
raise EchoError(f"fm {path}: created key '{field}' did not verify on read-back")
print(f"ok: FM created '{field}' in {path}")
return 0
def cmd_delete(path: str) -> int: def cmd_delete(path: str) -> int:
@@ -463,31 +549,39 @@ def extract_heading(markdown: str, heading: str) -> str:
return "\n".join(out).strip() return "\n".join(out).strip()
def cmd_scope(subcommand: str, text: str | None = None) -> int: def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -> int:
path = "_agent/context/current-context.md" path = "_agent/context/current-context.md"
status, body = request("GET", vault_url(path)) status, body = request("GET", vault_url(path))
check(status, body, f"scope {subcommand}") check(status, body, f"scope {subcommand}")
current = body.decode(errors="replace") current = body.decode(errors="replace")
if subcommand == "show": if subcommand == "show":
print("-- Active scope --") scope_text = extract_heading(current, "Scope")
print(extract_heading(current, "Scope"))
scope_updated = "" scope_updated = ""
for line in current.splitlines(): for line in current.splitlines():
if line.startswith("scope_updated:"): if line.startswith("scope_updated:"):
scope_updated = line.split(":", 1)[1].strip().strip('"').strip("'") scope_updated = line.split(":", 1)[1].strip().strip('"').strip("'")
break break
print(f"scope_updated: {scope_updated or '<missing — drift cannot be detected; run scope set or repair>'}") sessions_since = None
# Count session logs dated after scope_updated (the drift signal). # Count session logs dated after scope_updated (the drift signal).
if scope_updated: if scope_updated:
status, body = request("GET", vault_url("_agent/sessions/")) status, body = request("GET", vault_url("_agent/sessions/"))
if status == 200: if status == 200:
try: try:
files = json.loads(body).get("files", []) files = json.loads(body).get("files", [])
n = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated) sessions_since = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated)
print(f"sessions logged since: {n}")
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
if as_json:
print(json.dumps({"ok": True, "action": "scope-show", "scope": scope_text,
"scope_updated": scope_updated or None,
"sessions_since": sessions_since}, ensure_ascii=False))
return 0
print("-- Active scope --")
print(scope_text)
print(f"scope_updated: {scope_updated or '<missing — drift cannot be detected; run scope set or repair>'}")
if sessions_since is not None:
print(f"sessions logged since: {sessions_since}")
return 0 return 0
if subcommand != "set": if subcommand != "set":
@@ -514,6 +608,15 @@ def cmd_scope(subcommand: str, text: str | None = None) -> int:
def cmd_load() -> int: def cmd_load() -> int:
"""Cold-start orientation: the canonical 6 reads in one call. 404s on today's """Cold-start orientation: the canonical 6 reads in one call. 404s on today's
daily note and the inbox are normal (printed as absent, not errors).""" daily note and the inbox are normal (printed as absent, not errors)."""
if not echo_config.is_configured(_CFG):
print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.")
print(f"Expected at: {echo_config.config_path()}")
print("ACTION: ask the operator for their echo-memory config file (it holds the")
print(" vault owner, endpoint, and API key), then install it with ONE of:")
print(" python3 echo.py config import <path-to-their-file>")
print(" python3 echo.py config set --owner \"\" --endpoint \"https://…\" --key \"\"")
print("Then re-run load. (Memory is unavailable until configured.)")
return 78 # distinct: configuration required
targets = [ targets = [
("marker", "_agent/echo-vault.md"), ("marker", "_agent/echo-vault.md"),
("preferences", "_agent/memory/semantic/operator-preferences.md"), ("preferences", "_agent/memory/semantic/operator-preferences.md"),
@@ -588,6 +691,44 @@ def cmd_load() -> int:
return 0 return 0
def cmd_config(args) -> int:
"""show | init | set the machine-local owner/endpoint/key config."""
path = echo_config.config_path()
if args.action == "init":
p, created = echo_config.init_template()
print(f"ok: wrote config template to {p} — edit it with your owner/endpoint/key"
if created else f"config already exists at {p} (left unchanged)")
return 0
if args.action == "import":
if not args.path:
print("config import: pass the path to a config file "
"(JSON with owner/endpoint/key)", file=sys.stderr)
return 2
try:
p = echo_config.import_file(args.path)
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
print(f"ok: imported config to {p} (chmod 600 best-effort)")
return 0
if args.action == "set":
if args.owner is None and args.endpoint is None and args.key is None:
print("config set: pass at least one of --owner / --endpoint / --key", file=sys.stderr)
return 2
p = echo_config.write_config(args.owner, args.endpoint, args.key)
print(f"ok: updated {p} (chmod 600 best-effort)")
return 0
# show — never prints the key in the clear
cfg = echo_config.load()
key = cfg["key"]
redacted = (key[:4] + "" + key[-4:]) if len(key) >= 12 else ("set" if key else "")
print(f"config file: {path}" + ("" if path.exists() else " (absent)"))
for field, shown in (("owner", cfg["owner"]), ("endpoint", cfg["endpoint"]), ("key", redacted)):
src = echo_config.source(field)
print(f" {field:9} {shown or '(unset)'} [{src}]")
return 0
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validated cross-platform ECHO vault client") parser = argparse.ArgumentParser(description="Validated cross-platform ECHO vault client")
sub = parser.add_subparsers(dest="cmd", required=True) sub = parser.add_subparsers(dest="cmd", required=True)
@@ -609,18 +750,29 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("delete").add_argument("path") sub.add_parser("delete").add_argument("path")
sub.add_parser("lock").add_argument("owner") sub.add_parser("lock").add_argument("owner")
sub.add_parser("unlock").add_argument("owner") sub.add_parser("unlock").add_argument("owner")
sub.add_parser("write-key").add_argument("token") # M1: move the key to ~/.echo-memory/credentials p = sub.add_parser("config") # machine-local owner/endpoint/key (see echo_config)
p.add_argument("action", nargs="?", default="show", choices=["show", "init", "set", "import"])
p.add_argument("path", nargs="?") # for `config import <path>`
p.add_argument("--owner"); p.add_argument("--endpoint"); p.add_argument("--key")
p = sub.add_parser("scope") p = sub.add_parser("scope")
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"]) p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
p.add_argument("text", nargs="?") p.add_argument("text", nargs="?")
p.add_argument("--json", action="store_true") # structured scope + freshness
p = sub.add_parser("reflect") # H5: apply a JSON proposal set (dry-run unless --apply) p = sub.add_parser("reflect") # H5: apply a JSON proposal set (dry-run unless --apply)
p.add_argument("file", nargs="?") p.add_argument("file", nargs="?")
p.add_argument("--apply", action="store_true") p.add_argument("--apply", action="store_true")
p = sub.add_parser("triage") # one-tap inbox triage (reflect pipeline + audit log)
p.add_argument("file", nargs="?") # proposals JSON; omit to list
p.add_argument("--list", action="store_true", dest="list_inbox")
p.add_argument("--json", action="store_true") # structured inbox listing
p.add_argument("--apply", action="store_true") # route + write processing log
# High-level operations (entity index + cross-links + recall). See echo_ops.py. # High-level operations (entity index + cross-links + recall). See echo_ops.py.
sub.add_parser("resolve").add_argument("mention", nargs="+") sub.add_parser("resolve").add_argument("mention", nargs="+")
p = sub.add_parser("recall"); p.add_argument("query", nargs="+"); p.add_argument("--limit", type=int, default=6) p = sub.add_parser("recall"); p.add_argument("query", nargs="+"); p.add_argument("--limit", type=int, default=6)
p.add_argument("--json", action="store_true") # structured hits + neighbourhood
p = sub.add_parser("link"); p.add_argument("a"); p.add_argument("b") p = sub.add_parser("link"); p.add_argument("a"); p.add_argument("b")
p.add_argument("--json", action="store_true")
p = sub.add_parser("capture") p = sub.add_parser("capture")
p.add_argument("title") p.add_argument("title")
p.add_argument("file", nargs="?") p.add_argument("file", nargs="?")
@@ -629,11 +781,14 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--status", default="") p.add_argument("--status", default="")
p.add_argument("--aliases", default="") p.add_argument("--aliases", default="")
p.add_argument("--source", default="") p.add_argument("--source", default="")
p.add_argument("--tags", default="") # extra tags; the kind is always seeded
p.add_argument("--date") p.add_argument("--date")
p.add_argument("--domain", default="business") p.add_argument("--domain", default="business")
p.add_argument("--no-log", action="store_true") p.add_argument("--no-log", action="store_true")
p.add_argument("--json", action="store_true") # M4: emit a JSON result envelope p.add_argument("--json", action="store_true") # M4: emit a JSON result envelope
p.add_argument("--dry-run", action="store_true") # M4: preview the plan, write nothing p.add_argument("--dry-run", action="store_true") # M4: preview the plan, write nothing
p.add_argument("--force", action="store_true") # create despite a duplicate-gate hit
p.add_argument("--merge-into", metavar="SLUG") # route as an update to this entity
return parser return parser
@@ -657,10 +812,8 @@ def main(argv: list[str] | None = None) -> int:
else: else:
print("ok: queue empty") print("ok: queue empty")
return 0 return 0
if args.cmd == "write-key": if args.cmd == "config":
path = echo_secrets.write_key(args.token) return cmd_config(args)
print(f"ok: wrote credentials to {path} (chmod 600 best-effort); ECHO_KEY env still overrides")
return 0
if args.cmd == "get": if args.cmd == "get":
return cmd_get(args.path) return cmd_get(args.path)
if args.cmd == "map": if args.cmd == "map":
@@ -688,7 +841,7 @@ def main(argv: list[str] | None = None) -> int:
if args.cmd == "unlock": if args.cmd == "unlock":
return cmd_unlock(args.owner) return cmd_unlock(args.owner)
if args.cmd == "scope": if args.cmd == "scope":
return cmd_scope(args.subcommand, args.text) return cmd_scope(args.subcommand, args.text, as_json=args.json)
if args.cmd == "reflect": if args.cmd == "reflect":
import echo_reflect import echo_reflect
raw = read_body(args.file).decode("utf-8", errors="replace") raw = read_body(args.file).decode("utf-8", errors="replace")
@@ -699,20 +852,34 @@ def main(argv: list[str] | None = None) -> int:
if not isinstance(proposals, list): if not isinstance(proposals, list):
raise EchoError("reflect: proposals must be a JSON array of objects", 2) raise EchoError("reflect: proposals must be a JSON array of objects", 2)
return echo_reflect.apply(proposals, confirm=args.apply) return echo_reflect.apply(proposals, confirm=args.apply)
if args.cmd == "triage":
import echo_triage
if args.list_inbox or not args.file:
return echo_triage.list_inbox(as_json=args.json)
raw = read_body(args.file).decode("utf-8", errors="replace")
try:
proposals = json.loads(raw)
except json.JSONDecodeError as exc:
raise EchoError(f"triage: proposals must be a JSON array ({exc})", 2)
if not isinstance(proposals, list):
raise EchoError("triage: proposals must be a JSON array of objects", 2)
return echo_triage.apply(proposals, confirm=args.apply)
if args.cmd in ("resolve", "recall", "link", "capture"): if args.cmd in ("resolve", "recall", "link", "capture"):
import echo_ops # lazy: only the high-level ops pull in the index/link modules import echo_ops # lazy: only the high-level ops pull in the index/link modules
if args.cmd == "resolve": if args.cmd == "resolve":
return echo_ops.resolve(" ".join(args.mention)) return echo_ops.resolve(" ".join(args.mention))
if args.cmd == "recall": if args.cmd == "recall":
return echo_ops.recall(args.query, limit=args.limit) return echo_ops.recall(args.query, limit=args.limit, as_json=args.json)
if args.cmd == "link": if args.cmd == "link":
return echo_ops.link(args.a, args.b) return echo_ops.link(args.a, args.b, as_json=args.json)
return echo_ops.capture( return echo_ops.capture(
args.kind, args.title, args.file, status_v=args.status, args.kind, args.title, args.file, status_v=args.status,
aliases=args.aliases.split(",") if args.aliases else [], aliases=args.aliases.split(",") if args.aliases else [],
sources=args.source.split(",") if args.source else [], sources=args.source.split(",") if args.source else [],
tags=args.tags.split(",") if args.tags else [],
date=args.date, domain=args.domain, inbox=args.inbox, no_log=args.no_log, date=args.date, domain=args.domain, inbox=args.inbox, no_log=args.no_log,
as_json=args.json, dry_run=args.dry_run) as_json=args.json, dry_run=args.dry_run,
force=args.force, merge_into=args.merge_into)
except EchoError as exc: except EchoError as exc:
print(f"echo.py: {exc}", file=sys.stderr) print(f"echo.py: {exc}", file=sys.stderr)
return exc.code return exc.code
@@ -14,7 +14,7 @@ advisory lock around a write burst, with crash-safe release, and (b) an atomic
index-update that re-reads UNDER the lock before saving, so concurrent captures merge index-update that re-reads UNDER the lock before saving, so concurrent captures merge
instead of clobber. instead of clobber.
ACCEPTANCE (see ROADMAP-1.0.md > H3): ACCEPTANCE (v1.0 roadmap H3, shipped; roadmap retired see git history):
- two concurrent captures both land in the index (no lost entries) - two concurrent captures both land in the index (no lost entries)
- lock auto-released on normal AND error exit - lock auto-released on normal AND error exit
""" """

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