Compare commits
26 Commits
e89f4660f6
..
v2.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| e7792003a7 | |||
| 446cd9a0ff | |||
| 1deef7299e | |||
| 5aace9e430 | |||
| ab1e514f78 | |||
| fb407d606c | |||
| 55afdce70c | |||
| e7d86e14da | |||
| d27db4ae34 | |||
| 4eea53c6c8 | |||
| 9fd0246634 | |||
| 746a30a08b | |||
| 46e4b18a00 | |||
| 993abdc846 | |||
| 919e411136 | |||
| 3b6c3053cb | |||
| be3fd36ebf | |||
| e76add6192 | |||
| 9ee1530f97 | |||
| 0cd8a54649 | |||
| bfcfdec4c9 | |||
| c37b37c66a | |||
| 192d14bd82 | |||
| a1f2f02d3a | |||
| b4605a52f2 | |||
| 1881d2b105 |
+12
@@ -15,3 +15,15 @@ venv/
|
||||
# Eval output
|
||||
eval/results/*.json
|
||||
!eval/results/.gitkeep
|
||||
|
||||
# ECHO config: the filled-in key file is yours to distribute, never commit it
|
||||
/echo-memory.config.json
|
||||
/dist/
|
||||
|
||||
# Build artifacts (2.0 policy): the repo tracks ONLY the echo-memory.plugin pointer.
|
||||
# Versioned builds (echo-memory-<ver>.plugin) are published as Gitea release assets
|
||||
# on git.alwisp.com/jason/echo, one release per tag — not committed. Per-user BAKED
|
||||
# artifacts (echo-memory-<ver>-<label>.plugin) carry a live vault bearer token and
|
||||
# must NEVER be committed anywhere (they belong in dist/, also ignored above).
|
||||
*.plugin
|
||||
!echo-memory.plugin
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
# ECHO — API key setup (Windows & macOS)
|
||||
|
||||
The ECHO plugin authenticates to the vault with a bearer token. `echo.py` resolves it in
|
||||
this order — **first hit wins**:
|
||||
|
||||
1. **`ECHO_KEY` environment variable** ← recommended for workstations
|
||||
2. **`~/.echo-memory/credentials`** file (one `key=…` line, `chmod 600`)
|
||||
3. a deprecated baked-in fallback (prints a warning on every call until you do #1 or #2)
|
||||
|
||||
You only need **one** of these. Pick the env var (below) or the credentials file
|
||||
(`python3 echo.py write-key <token>`, cross-platform, no shell config needed).
|
||||
|
||||
> Replace `<YOUR_ECHO_API_KEY>` everywhere below with your actual token. **Never** paste
|
||||
> the token into a vault note, a committed file, or a screen-share.
|
||||
|
||||
---
|
||||
|
||||
## Option A — credentials file (simplest, cross-platform)
|
||||
|
||||
```bash
|
||||
# from the plugin's scripts dir; works identically on Windows/macOS/Linux
|
||||
python3 "<plugin>/skills/echo-memory/scripts/echo.py" write-key <YOUR_ECHO_API_KEY>
|
||||
```
|
||||
|
||||
Writes `~/.echo-memory/credentials` (mode 600 on Unix). Nothing else to configure.
|
||||
To rotate later, run it again with the new token.
|
||||
|
||||
---
|
||||
|
||||
## Option B — environment variable
|
||||
|
||||
### Windows
|
||||
|
||||
**PowerShell — persistent (recommended).** Sets it for your user account; open a **new**
|
||||
terminal afterward (the current session won't see it):
|
||||
|
||||
```powershell
|
||||
[Environment]::SetEnvironmentVariable("ECHO_KEY", "<YOUR_ECHO_API_KEY>", "User")
|
||||
```
|
||||
|
||||
**Command Prompt — persistent** (alternative; note `setx` truncates values over 1024 chars,
|
||||
so the 64-char ECHO key is fine):
|
||||
|
||||
```cmd
|
||||
setx ECHO_KEY "<YOUR_ECHO_API_KEY>"
|
||||
```
|
||||
|
||||
**Current session only** (not persistent):
|
||||
|
||||
```powershell
|
||||
$env:ECHO_KEY = "<YOUR_ECHO_API_KEY>" # PowerShell
|
||||
```
|
||||
```cmd
|
||||
set ECHO_KEY=<YOUR_ECHO_API_KEY> REM Command Prompt
|
||||
```
|
||||
|
||||
**GUI alternative:** Start → "Edit the system environment variables" → *Environment
|
||||
Variables…* → under *User variables* click *New…* → Name `ECHO_KEY`, Value the token.
|
||||
|
||||
**Verify** (in a NEW terminal):
|
||||
```powershell
|
||||
echo $env:ECHO_KEY # PowerShell
|
||||
```
|
||||
```cmd
|
||||
echo %ECHO_KEY% :: Command Prompt
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
Modern macOS uses **zsh**. Add the export to your shell profile, then reload it:
|
||||
|
||||
```zsh
|
||||
echo 'export ECHO_KEY="<YOUR_ECHO_API_KEY>"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
If you use **bash** instead:
|
||||
```bash
|
||||
echo 'export ECHO_KEY="<YOUR_ECHO_API_KEY>"' >> ~/.bash_profile
|
||||
source ~/.bash_profile
|
||||
```
|
||||
|
||||
**For GUI apps** (a desktop app that doesn't read your shell profile) you can also set it
|
||||
for the login session:
|
||||
```bash
|
||||
launchctl setenv ECHO_KEY "<YOUR_ECHO_API_KEY>"
|
||||
```
|
||||
(Not persistent across reboot on its own; the shell-profile export above covers terminal
|
||||
sessions, which is what the plugin uses.)
|
||||
|
||||
**Verify** (in a NEW terminal):
|
||||
```bash
|
||||
echo "$ECHO_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Confirm it works
|
||||
|
||||
```bash
|
||||
python3 "<plugin>/skills/echo-memory/scripts/echo.py" doctor
|
||||
```
|
||||
|
||||
`doctor` reports the Python version, vault reachability, auth, bootstrap/schema, and the
|
||||
**API key source** — it should say `ECHO_KEY env` or `credentials file`, not the deprecated
|
||||
baked-in fallback.
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
|
||||
- The token is a vault credential — treat it like a password. Don't commit it, don't store
|
||||
it in a vault note, rotate it if it leaks.
|
||||
- `ECHO_KEY` (or `ECHO_BASE`) set in the environment **always overrides** the credentials
|
||||
file and the baked-in default, so it's safe for CI / one-off overrides:
|
||||
`ECHO_KEY=… python3 echo.py …`.
|
||||
- Once you've set the key via Option A or B on every machine/agent that runs the plugin
|
||||
(terminal, CoWork, any scheduled run), the deprecated baked-in fallback can be removed
|
||||
from `scripts/echo.py` (`DEFAULT_KEY`) so no token lives in the source tree at all.
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
# Changelog
|
||||
|
||||
## 2.1.1
|
||||
|
||||
### Changed — Phase 0 of the MCP build: every high-level op returns an envelope
|
||||
|
||||
Internal refactor (MCP-SERVER-SPEC §4), no behavior change intended: each
|
||||
high-level operation now has a core `*_op` function that **returns an envelope
|
||||
dict and never prints to stdout** — the transport-agnostic seam the upcoming MCP
|
||||
server (2.2) wraps. The CLI verbs are thin wrappers that print exactly what they
|
||||
printed before and map envelope outcomes to the same exit codes.
|
||||
|
||||
- `echo_ops`: `capture_op` / `resolve_op` / `link_op` — the duplicate gate and
|
||||
offline queueing are now **data** (`action: duplicate-gate` with candidates;
|
||||
`queued: true`), mapped to exit 76 / "queued" text only in the wrapper. The
|
||||
`--json` stdout-swallowing hack is gone: helper chatter from the low-level
|
||||
verbs routes to **stderr** wholesale inside the cores. `capture_op` accepts
|
||||
`body_text=` directly (no temp file needed — the MCP path).
|
||||
- `echo_recall.recall_op` — one structured result backs both the prose and
|
||||
`--json` renderings (the duplicated prose path was deleted).
|
||||
- `echo_triage.list_op` / `route_op`, `echo_reflect.apply_op`,
|
||||
`echo_session.session_end_op` — same pattern; reflect/triage/session now call
|
||||
`capture_op` internally and count gate outcomes from envelopes.
|
||||
- `echo.scope_show_op` / `scope_set_op` / `load_op` (sections + brief digest as
|
||||
data), `echo_doctor.run_op` (checks as a list).
|
||||
|
||||
New suite `eval/test_ops_api.py` asserts the contract for every core: envelope
|
||||
shape AND stdout purity (a stray print would corrupt an MCP response stream).
|
||||
All existing suites pass unchanged.
|
||||
|
||||
## 2.1.0
|
||||
|
||||
The "quick-wins train" from the 2026-07-28 review (`docs/IMPROVEMENT-PLANS.md` #1,
|
||||
#3, #7). Three independently useful changes; no schema or layout changes.
|
||||
|
||||
### Added — `load --brief`: a token-budgeted cold-start digest
|
||||
|
||||
The SessionStart hook injected the full text of six files every session (measured
|
||||
15.9KB and growing without bound — Observations and Scope History never shrink).
|
||||
`load --brief` renders a digest instead: Fact / Pattern rules in full, the last ~10
|
||||
Observations (with a `+N older` note), scope + `scope_updated` + sessions-since,
|
||||
the heartbeat-pointed session log's key sections (Goal / Decisions Made / Open
|
||||
Threads / Suggested Next Step), today's Agent Log lines, and the inbox as a
|
||||
**count + oldest age** — everything the load-time reconcile needs. Over
|
||||
`ECHO_LOAD_BUDGET` (default ~8000 chars), sections trim lowest-priority-first
|
||||
(observations → agent log → session summary) with explicit `(truncated)` markers.
|
||||
The hook now injects the brief form; `/echo-load` and the manual procedure keep the
|
||||
full dump. All `load` reads (plus the sessions listing, which also feeds the
|
||||
heartbeat-absent fallback) are fetched **in parallel** over the pooled connections.
|
||||
|
||||
### Fixed — capture is now offline-durable (the write path's inversion bug)
|
||||
|
||||
Low-level verbs queued on an outage, but the *default* write (`capture`) died: the
|
||||
update path's POST/PATCH and `ensure_daily_log` bypassed the queue entirely, and an
|
||||
unreachable index aborted the whole op. Now: (1) a fully-offline `capture` queues
|
||||
the **whole operation as one semantic record** (title/kind/body/tags/…, plus the
|
||||
capture-time date); `flush` replays it **through `capture`** so create-vs-update,
|
||||
the duplicate gate, and aliasing re-run against the index as it is at replay time —
|
||||
byte-level replay would freeze a decision made blind. (2) A duplicate-gate stop on
|
||||
replay keeps the record, flags it `needs_attention` (surfaced by `flush` and
|
||||
`load`), and does NOT block later records. (3) Mid-capture outages: the update
|
||||
path and `ensure_daily_log` writes ride `safe_request` (accepted edge: a queued
|
||||
agent-log PATCH 400-drops loudly if the daily note never existed — the trace line,
|
||||
not the memory). Same-args offline captures dedupe by idem-key.
|
||||
|
||||
### Added — `session-end`: the one-call session close
|
||||
|
||||
`echo.py session-end <bundle.json> [--apply]` performs the whole end-of-session
|
||||
sequence under ONE lock acquisition, in order: session log PUT → Agent Log line →
|
||||
reflect proposals (via `capture`, gate-aware) → optional `scope set` → **heartbeat
|
||||
LAST as the commit marker** (a part-way failure leaves the previous pointer intact,
|
||||
never dangling). Dry-run by default previews the full plan including the reflect
|
||||
classification. Filename HHMM from `$ECHO_NOW` (else local clock), validated
|
||||
against the canonical session-log regex **before any write**. The Stop hook's nudge
|
||||
now names the command, and recognizes a `session-end` invocation as
|
||||
"already reflected".
|
||||
|
||||
### Fixed — helper-module errors exited 2 instead of their intended codes
|
||||
|
||||
When `echo.py` runs as `__main__`, helper modules' `import echo` creates a twin
|
||||
module whose `EchoError` is a different class object, so `main()`'s handler missed
|
||||
it and the raw traceback leaked. The handler now catches `RuntimeError` (both
|
||||
classes subclass it) and honors `.code`.
|
||||
|
||||
Tests: +19 end-to-end checks (brief-load digest/budget, offline-capture queue /
|
||||
replay-through-capture / gate-on-replay / idem-key dedup, session-end dry-run /
|
||||
apply / ordering / validation-abort). All suites green.
|
||||
|
||||
## 2.0.0
|
||||
|
||||
**Packaging & structure major — nothing about memory behavior changes.** The major
|
||||
version signals "reinstall, re-clone expectations": how the plugin is distributed
|
||||
and installed is what breaks.
|
||||
|
||||
### Removed — the legacy `commands/` directory (BREAKING for pre-skills clients)
|
||||
|
||||
The plugin is **skills-only**. The eight `/echo-*` entry points live exclusively at
|
||||
`skills/<name>/SKILL.md` (introduced 1.6.0, verified on desktop + CoWork
|
||||
2026-07-28, where they already took precedence). A Claude Code old enough to lack
|
||||
skills support loses the slash commands — install 1.6.0 from the release history
|
||||
instead.
|
||||
|
||||
### Changed — artifact policy (BREAKING for anyone pulling zips from the repo)
|
||||
|
||||
- The repo tracks **only** `echo-memory.plugin` (the current installable). The 15
|
||||
historical `echo-memory-<version>.plugin` zips are deleted from the tree (git
|
||||
history retains them).
|
||||
- Versioned builds are published as **Gitea releases** on
|
||||
`git.alwisp.com/jason/echo` — one release per `v<version>` tag, artifact
|
||||
attached, starting with `v2.0.0`.
|
||||
- `.gitignore` now ignores `*.plugin` except the pointer.
|
||||
|
||||
### Added — "packaging for other agent runtimes" README note
|
||||
|
||||
The retired Codex tree stays retired; the README now documents what a port needs
|
||||
(manifest shape, skill entry point, script invocation + hooks) and the rule that a
|
||||
port must be a **build target** from the canonical source tree, never a second
|
||||
hand-maintained copy.
|
||||
|
||||
## 1.6.0
|
||||
|
||||
### Changed — the eight slash commands migrated to the skills format
|
||||
|
||||
Each `commands/<name>.md` now has a `skills/<name>/SKILL.md` twin with the same
|
||||
invocation name and a byte-identical body — confirmed against the current docs
|
||||
that a same-name skill **takes precedence** over the legacy command, so both
|
||||
formats coexist safely in this release (deleting `commands/` is the 2.0
|
||||
packaging break). Done for the features, not the deprecation notice:
|
||||
|
||||
- **`allowed-tools` per skill** — the resolved `python3/python/py -3` script
|
||||
invocations (plus the CoWork `ls /sessions/*` fallback probe) are
|
||||
pre-authorized, so `/echo-load`, `/echo-health`, `/echo-recall` etc. stop
|
||||
prompting.
|
||||
- **`disable-model-invocation: true`** on the write-heavy entry points
|
||||
(`/echo-sweep`, `/echo-triage`) — only the operator triggers them; the
|
||||
read-side skills stay model-invocable.
|
||||
- `argument-hint` / `$ARGUMENTS` carry over unchanged (same semantics in
|
||||
SKILL.md bodies).
|
||||
- The `$ECHO` path-resolution block stays per-skill: the skills format has no
|
||||
cross-skill snippet sharing (each skill directory is self-contained per the
|
||||
official docs), and the block is already the 2-line minimum.
|
||||
|
||||
### Fixed — lint blind spot: retired trees masked by the leaf-README route
|
||||
|
||||
`vault_lint.py` checked retired patterns only for paths matching **no** route,
|
||||
so the permissive `leaf-readme` route (`^(.+/)?README\.md$`) absolved any seed
|
||||
README inside a retired tree — `archive/` and `_agent/outputs/` survived the
|
||||
1.5.0 live cleanup unflagged. Retired patterns are now checked **first**: a
|
||||
path in a retired tree flags `retired-path` regardless of what else matches.
|
||||
New end-to-end test seeds `archive/notes/README.md` and asserts the flag
|
||||
(fails against the pre-fix linter, passes now).
|
||||
|
||||
### Added — plugin.json completeness
|
||||
|
||||
`homepage` + `repository` → `https://git.alwisp.com/jason/echo`; manifest →
|
||||
1.6.0.
|
||||
|
||||
## 1.5.1
|
||||
|
||||
### Fixed — duplicate gate over-firing on vault-common tokens and cross-kind names
|
||||
|
||||
Live 1.5.0 use surfaced a false positive: creating the decision "echo-memory 1.5.0
|
||||
improvement set" was blocked by the archived project `echo-v-05` (score 1.0), because
|
||||
`score = overlap / min(|tokens|)` lets any entity whose single distinctive token
|
||||
appears in the new title score 1.0 — and "echo" appears across many entities in an
|
||||
echo-centric vault.
|
||||
|
||||
New `echo_index.gate_candidates()` (backed by `token_df()`) applies two precision
|
||||
rules before blocking; `capture` gates through it. `fuzzy_candidates()` — the advisory
|
||||
warning list — is unchanged:
|
||||
|
||||
- **Same-kind only.** A cross-kind name collision (a decision titled after its
|
||||
project, a meeting named for a company) is intentional naming, not a duplicate.
|
||||
Cross-kind lookalikes still warn, never block.
|
||||
- **No single-common-token blocks.** A lone shared token gates only when it is unique
|
||||
to that entity (vault df == 1). Multi-token overlaps still gate even when the
|
||||
individual tokens are common.
|
||||
|
||||
Exit-76 / `--merge-into` / `--force` semantics unchanged. Tests: 3 new offline unit
|
||||
tests (`gate_candidates`), end-to-end gate cases restructured + 2 new (cross-kind
|
||||
no-gate, common-token no-gate); verified against the live vault (the original false
|
||||
positive now creates cleanly; a same-kind multi-token lookalike still gates).
|
||||
|
||||
## 1.5.0
|
||||
|
||||
Six improvements from the July 2026 review + the frontmatter-drift field report.
|
||||
|
||||
### Fixed — capture-on-update kept only the first body line (silent data loss)
|
||||
|
||||
`capture` on an existing entity appended `- <date>: <first line>` and **discarded the
|
||||
rest of the body**. It now appends the whole body as a dated block — first line on the
|
||||
bullet, continuation lines indented under it. Idempotency (bullet-per-day) unchanged.
|
||||
|
||||
### Added — frontmatter completeness (prevent → tool → detect → remediate)
|
||||
|
||||
A field audit found 18/71 entity notes missing `status`/`tags`; the drift was created
|
||||
at write time and invisible to the tooling. Four coordinated changes, driven by one
|
||||
data source (`KIND_STATUS` / `KIND_REQUIRED_FM` in `echo_index.py`):
|
||||
|
||||
- **`capture`** stamps a kind-appropriate default `status` (all kinds, not just
|
||||
projects) and seeds `tags` with the note's kind; new `--tags a,b` enriches it.
|
||||
Reflect/triage proposals accept a `"tags"` field.
|
||||
- **`echo.py fm`** is now **create-or-replace**: on `400 invalid-target` (missing key)
|
||||
it surgically inserts the one line into the frontmatter block and re-PUTs, verified —
|
||||
it previously failed on exactly the notes that needed repair.
|
||||
- **`vault_lint.py`** gains a kind-scoped `incomplete-frontmatter` check
|
||||
(missing/empty `status`/`tags` on entity notes; block-style lists handled).
|
||||
- **`sweep.py --apply`** backfills flagged notes (kind-default status + baseline tag).
|
||||
|
||||
### Added — pre-write duplicate gate in capture
|
||||
|
||||
A strong fuzzy candidate (score ≥ 0.6, env `ECHO_DUP_GATE`) now **stops** `capture`
|
||||
before it creates a near-duplicate (exit `76` + candidate list) instead of warning
|
||||
after the file exists. `--merge-into <slug>` routes the capture as an update to the
|
||||
existing entity (mention learned as an alias); `--force` creates anyway. Weak
|
||||
resemblances still create + warn as before.
|
||||
|
||||
### Changed — recall corpus + ranking (recall-index schema 2)
|
||||
|
||||
- The BM25 corpus now includes **session logs and journal notes** (down-weighted 0.5 /
|
||||
0.4 so entity notes win ties) — past discussions are findable without having been
|
||||
promoted into an entity note. `echo.py put` keeps the index current for corpus paths.
|
||||
- Ranking fuses BM25 with **freshness** (half-life decay on `updated:`, floored at 0.6,
|
||||
env `ECHO_FRESH_HALF_LIFE`) and **status** (`active` ×1.1, `on-hold` ×0.9,
|
||||
`archived` ×0.75) — a stale archived note no longer outranks the live one.
|
||||
- Hits print `updated:`/`status:`; `recall --json` emits structured results.
|
||||
- The recall index carries per-doc meta (schema 2); an old index is discarded and
|
||||
rebuilt automatically on the next recall/sweep.
|
||||
|
||||
### Added — session hooks (self-firing load & reflect)
|
||||
|
||||
New `hooks/hooks.json` + two hook scripts. **SessionStart** runs `echo.py load` and
|
||||
injects the output as context (skips resume/compact; NOT-CONFIGURED degrades to an
|
||||
instruction, never an error). **Stop** nudges once per substantive session (≥5 user
|
||||
turns, env `ECHO_REFLECT_MIN_TURNS`) when nothing was reflected or session-logged —
|
||||
reflection itself still previews and asks before writing. Both use the 1.4.2 CoWork
|
||||
path fallback and always exit 0.
|
||||
|
||||
### Added — one-tap inbox triage + `--json` on read verbs
|
||||
|
||||
New `echo.py triage`: `--list --json` parses the inbox into `{line, date, text,
|
||||
age_days}`; a proposals file (reflect schema + `"line"`) is classified → previewed →
|
||||
applied via `capture`, with the `inbox/processing-log/` audit line written per move
|
||||
(originals never deleted). `/echo-triage` rewritten around it. Also: `--json` for
|
||||
`recall`, `link`, and `scope show` (`resolve`/`capture` already had it).
|
||||
|
||||
Tests: +22 feature cases (mock end-to-end incl. hooks), patch-semantics updated for
|
||||
the new `fm` contract; all suites green. Docs: SKILL.md, README, command docs updated.
|
||||
|
||||
## 1.4.2
|
||||
|
||||
### Fixed — CoWork sandbox plugin-path resolution
|
||||
|
||||
In a remote CoWork sandbox, `${CLAUDE_PLUGIN_ROOT}` can resolve to a host path the
|
||||
sandbox can't reach (e.g. `/var/folders/…`), while the plugin is actually mounted under
|
||||
`…/mnt/.remote-plugins/…`. Every `python3 "${CLAUDE_PLUGIN_ROOT}/…"` invocation then
|
||||
failed until the agent manually located the real path. (Root cause is the harness env
|
||||
var, not the plugin — the scripts always used the variable correctly, never a hardcoded
|
||||
path — but the plugin now self-heals.)
|
||||
|
||||
- SKILL.md and all eight slash commands resolve the scripts path with a fallback:
|
||||
prefer `${CLAUDE_PLUGIN_ROOT}`, else locate the copy under
|
||||
`/sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/`. Reused via
|
||||
`$ECHO`/`$LINT`/`$SWEEP`/`$SDIR`; on a normal host the primary path always wins, so
|
||||
behaviour is unchanged.
|
||||
- `echo-health` / `echo-sweep` `allowed-tools` broadened to match the resolved
|
||||
(variable-dir) invocation and the `ls`/`dirname` resolver steps.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
### Fixed — baked credentials now win unconditionally
|
||||
|
||||
A per-user baked artifact could still be prompted for credentials on install. The
|
||||
baked `DEFAULT_*` tier was lowest priority, so a stale `ECHO_*` env var or a
|
||||
leftover/placeholder `~/.claude/echo-memory/config.json` shadowed the baked key —
|
||||
even an unedited `"<placeholder>"` value won over it, flipping `is_configured()` to
|
||||
false and triggering the first-run "ask the operator" flow despite valid baked creds.
|
||||
|
||||
- `echo_config.load()` now treats a **complete** baked set (endpoint + key both
|
||||
present) as authoritative: it wins over env vars and the config file and cannot be
|
||||
shadowed. The generic (unbaked) plugin keeps its env → file → first-run flow.
|
||||
- Added `baked_complete()`; `source()` reports `baked-in (plugin)` for that tier.
|
||||
|
||||
### Security — untracked leaked baked artifacts
|
||||
|
||||
- Two labeled baked artifacts (`echo-memory-1.4.0-jason.plugin`,
|
||||
`echo-memory-1.4.0-gretchen.plugin`), each carrying a live vault bearer token, had
|
||||
been committed at the repo root. They are now `git rm --cached`-removed and
|
||||
`.gitignore` gains `echo-memory-*-*.plugin` so labeled builds can't be re-committed
|
||||
(the version-only pointer `echo-memory.plugin` and `echo-memory-<ver>.plugin` stay
|
||||
tracked). Tokens remain in prior history — rotate if the repo was ever shared.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Added — per-user baked-key builds (CoWork zero-touch)
|
||||
|
||||
The plugin is the only thing reliably mounted into a CoWork session (the sandbox's
|
||||
`~/.claude` is synthetic, not your real one), so the config now *can* travel inside
|
||||
the artifact. `echo_config` gains a lowest-priority fallback tier — the
|
||||
`DEFAULT_OWNER` / `DEFAULT_BASE` / `DEFAULT_KEY` constants — **empty in source**, so
|
||||
the committed tree still ships zero credentials.
|
||||
|
||||
- `build.py --bake-key --from <config.json> [--label <name>]` substitutes a single
|
||||
user's owner/endpoint/key into those constants, producing a per-user artifact that
|
||||
needs **no config file and no per-session paste**. Values may also come from
|
||||
`--owner/--endpoint/--key` or `ECHO_OWNER/ECHO_BASE/ECHO_KEY`.
|
||||
- Baked builds default to `dist/` (gitignored) and never touch the shared
|
||||
`echo-memory.plugin` pointer.
|
||||
- `build.py --strip-key` now force-blanks the same constants (guaranteed token-free).
|
||||
- `config show` / `doctor` report `[baked-in default]` when a field resolves from the
|
||||
baked tier.
|
||||
|
||||
### Changed — artifact hygiene
|
||||
|
||||
- `.gitignore` excludes `dist/` and the filled-in `echo-memory.config.json`, where
|
||||
baked builds and the key file are meant to live. A baked artifact carries a vault
|
||||
key and must be **delivered directly to that one user, never committed, pushed, or
|
||||
published.** (Note: labeled baked artifacts built at the repo *root* were not yet
|
||||
guarded — see 1.4.1.)
|
||||
|
||||
### Resolution order (unchanged for hosts)
|
||||
|
||||
`env` → `$ECHO_CONFIG` file → canonical `~/.claude/echo-memory/config.json` →
|
||||
**baked `DEFAULT_*`**. On a normal desktop the empty defaults mean behaviour is
|
||||
identical to 1.3.x; the baked tier only matters in per-user artifacts.
|
||||
@@ -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).
|
||||
@@ -1,12 +1,12 @@
|
||||
# echo-memory — v1.0.0
|
||||
# echo-memory — v2.1.1
|
||||
|
||||
Persistent memory for Claude / CoWork sessions via the **ECHO** Obsidian vault, driven over the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api). The skill makes direct REST calls through a bundled validated client (`scripts/echo.py`). The whole toolchain is **pure Python** (stdlib only), so it runs identically on Windows, macOS, and Linux — no bash, no platform-specific `date`.
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
The plugin is hardcoded for a single personal endpoint:
|
||||
The plugin ships **no** owner, endpoint, or key — it is user-agnostic. Each machine supplies them through a machine-local JSON config (resolution lives in `scripts/echo_config.py`):
|
||||
|
||||
- **Server:** `https://echoapi.alwisp.com` (reverse proxy → backend Obsidian Local REST API). This is the **only** valid endpoint — never use LAN addresses (`10.x`, `192.168.x`, `:27124`); those belong to retired memory systems. Override per-run with `ECHO_BASE`.
|
||||
- **Auth:** a bearer token, resolved by `scripts/echo.py` (via `echo_secrets`) in order: **`ECHO_KEY` env → `~/.echo-memory/credentials` → a deprecated baked-in fallback** (warns until you set one of the first two). Set it up per **[API-KEY-SETUP.md](API-KEY-SETUP.md)** (Windows & macOS) or run `echo.py write-key <token>`. **Never** put the key in a vault note.
|
||||
- **TLS:** the endpoint presents a valid certificate, so `-k` is not needed.
|
||||
- **File:** `~/.claude/echo-memory/config.json` — honors `$CLAUDE_CONFIG_DIR`; the file path itself can be overridden with `$ECHO_CONFIG`. Shape: `{ "owner": …, "endpoint": "https://…", "key": "…" }`.
|
||||
- **Per-field env override:** `ECHO_OWNER`, `ECHO_BASE` (endpoint), and `ECHO_KEY` take precedence over the file.
|
||||
- **Set up a machine:** `echo.py config import <file>` adopts a config you've been handed; `echo.py config set --owner … --endpoint … --key …` writes one from values; `echo.py config init` scaffolds a blank template to edit; `echo.py config` prints the resolved config (key redacted) and where each field came from.
|
||||
- **First run:** an unconfigured machine — or one left on the placeholder template — reports `NOT CONFIGURED` (`echo.py load` prints a banner and exits `78`; other verbs exit `2`; `/echo-doctor` flags it red), and the skill asks the operator for the key file before doing any memory work. The config is **never committed** and **never written into a vault note**; the key stays out of the plugin tree entirely.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -48,16 +62,28 @@ Vault paths are addressed **at the root** (e.g. `GET /vault/_agent/context/curre
|
||||
|
||||
```
|
||||
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-<version>.plugin ← versioned build artifacts (history)
|
||||
├── eval/ ← credential-free A/B eval harness (0.6 vs 0.7); not bundled
|
||||
│ — the ONLY tracked artifact; versioned builds are Gitea release
|
||||
│ assets (one release per v<version> tag), not committed (2.0 policy)
|
||||
├── dist/ ← per-user baked artifacts (secret-bearing) — GITIGNORED, never committed
|
||||
├── eval/ ← credential-free eval + test harness; not bundled
|
||||
│ ├── mock_olrapi.py ← deterministic mock of the REST API + fault injection
|
||||
│ ├── run_eval.py ← orchestrator (runs the real echo.py vs modeled raw curl)
|
||||
│ ├── mock_olrapi_hifi.py ← higher-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
|
||||
└── 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)
|
||||
├── README.md ← plugin-level README
|
||||
├── commands/ ← slash commands: echo-load, echo-save, echo-recall, echo-triage, echo-health, echo-sweep
|
||||
├── hooks/hooks.json ← session hooks: SessionStart auto-load · Stop reflection nudge
|
||||
├── skills/echo-{load,save,recall,triage,health,sweep,reflect,doctor}/
|
||||
│ ← the eight slash commands as skills (skills-only since 2.0):
|
||||
│ per-skill allowed-tools; sweep/triage are operator-only
|
||||
└── skills/echo-memory/
|
||||
├── SKILL.md ← operating procedure (authoritative)
|
||||
├── references/
|
||||
@@ -68,17 +94,27 @@ echo-v.05/
|
||||
│ ├── api-reference.md ← REST endpoint patterns + routing map
|
||||
│ └── session-log-template.md
|
||||
├── scripts/ ← executable logic — pure Python (run: python3, or python / py -3)
|
||||
│ ├── echo.py ← validated client + high-level CLI (capture/resolve/recall/link/load/scope/lock)
|
||||
│ ├── echo_index.py ← entity index (registry, resolve, name->path map)
|
||||
│ ├── echo.py ← validated client + CLI (capture/resolve/recall/link/triage/load/scope/lock/config/reflect/flush/doctor)
|
||||
│ ├── 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_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)
|
||||
│ ├── 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)
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
├── echo-vault.md ← bootstrap marker
|
||||
├── README.vault.md ← thin human signpost
|
||||
@@ -86,6 +122,8 @@ echo-v.05/
|
||||
└── templates/ ← 8 canonical note templates
|
||||
```
|
||||
|
||||
**Packaging for other agent runtimes** (from the retired Codex tree, deleted 2026-07-03): if a port to another runtime (Codex, etc.) is ever wanted, implement it as a **build target** generated from the canonical `echo-memory.plugin.src/` tree (`build.py --target <runtime>` or similar) — never as a second hand-maintained source tree; that's how the original Codex copy drifted. A port needs three adaptations: the manifest shape (`.claude-plugin/plugin.json` → the runtime's equivalent), the skill entry point (`skills/*/SKILL.md` frontmatter + body conventions), and the script-invocation convention (`${CLAUDE_PLUGIN_ROOT}` resolution + the session-hook wiring in `hooks/hooks.json`). The Python toolchain itself is runtime-agnostic (pure stdlib, env/config-driven).
|
||||
|
||||
**Division of responsibility:** `SKILL.md` owns day-to-day *procedure* (loading order, search-first, triage, scope switching, PATCH rules) and points at the bundled tooling. `references/operating-contract.md` owns the durable, client-independent *principles, safety rules, and concurrency model*. `scripts/routing.json` is the machine-readable source of truth for routing; `references/routing-map.md` is its human-readable authority. The other references are the canonical layout, API, and bootstrap specs.
|
||||
|
||||
---
|
||||
@@ -96,9 +134,9 @@ Executable logic ships under `skills/echo-memory/scripts/`; the agent prefers it
|
||||
|
||||
| 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). |
|
||||
| `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. |
|
||||
| `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.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 / 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. `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). |
|
||||
| `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**. |
|
||||
@@ -109,7 +147,11 @@ Executable logic ships under `skills/echo-memory/scripts/`; the agent prefers it
|
||||
|
||||
### Slash commands
|
||||
|
||||
`/echo-load` (cold-start read), `/echo-save <text>` (route + persist via `capture`), `/echo-recall <query>` (recall a topic's neighbourhood), `/echo-triage` (drain the inbox), `/echo-health` (run the linter), `/echo-sweep` (bring the vault up to 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)
|
||||
|
||||
@@ -232,7 +274,7 @@ The journal is one append-only time-series stream; rollups are coarser-grained e
|
||||
|
||||
### Vault Health (monthly)
|
||||
|
||||
Agent self-maintenance (not a journal entry), written to `_agent/health/YYYY-MM-vault-health.md`. Run `scripts/vault_lint.py` (or `/echo-health`) with `ECHO_TODAY` = the conversation's date so stale/aging math uses one clock. It mechanically asserts: folder↔status mismatch, duplicate slugs across lifecycle folders, wikilinks in frontmatter (swept across all folders), duplicate `## Agent Log` headings, stale active projects (`updated:` > 30 days), aging inbox items (> 14 days), **paths matching no route in `routing.json` or sitting at a retired path**, **frontmatter integrity** (missing required fields, `updated` < `created`, future dates, wikilinks leaking into `source_notes`), and **scope drift** (≥ `SCOPE_STALE_SESSIONS` session logs dated after `scope_updated`). Exit codes: `0` clean · `1` violations · `2` unreachable · `3` not bootstrapped. Findings are reported, not auto-fixed.
|
||||
Agent self-maintenance (not a journal entry), written to `_agent/health/YYYY-MM-vault-health.md`. Run `scripts/vault_lint.py` (or `/echo-health`) with `ECHO_TODAY` = the conversation's date so stale/aging math uses one clock. It mechanically asserts: folder↔status mismatch, duplicate slugs across lifecycle folders, wikilinks in frontmatter (swept across all folders), duplicate `## Agent Log` headings, stale active projects (`updated:` > 30 days), aging inbox items (> 14 days), **paths matching no route in `routing.json` or sitting at a retired path**, **frontmatter integrity** (missing required fields, `updated` < `created`, future dates, wikilinks leaking into `source_notes`), **incomplete entity frontmatter** (missing/empty `status`/`tags` per the kind-scoped `KIND_REQUIRED_FM` map — `sweep.py --apply` backfills), and **scope drift** (≥ `SCOPE_STALE_SESSIONS` session logs dated after `scope_updated`). Exit codes: `0` clean · `1` violations · `2` unreachable · `3` not bootstrapped. Findings are reported, not auto-fixed.
|
||||
|
||||
---
|
||||
|
||||
@@ -361,10 +403,37 @@ If the API returns a connection error, timeout, or `502` (usually Obsidian / the
|
||||
|
||||
---
|
||||
|
||||
## Eval metrics (v1.5.1, 2026-07-03)
|
||||
|
||||
From the credential-free harness (`eval/run_eval.py` against the deterministic mock — no live vault; full detail in `eval/results/latest.json`). Baseline = pre-1.5 behavior: keyword substring ranking over entity notes only, no gate.
|
||||
|
||||
| Metric | v1.5.1 | pre-1.5 baseline |
|
||||
|---|---|---|
|
||||
| Retrieval recall@5 / MRR (8-query gold set) | **1.00 / 1.00** | 0.75 / 0.75 |
|
||||
| Queries answerable only from sessions/journal | **2/2** | 0/2 (not in corpus) |
|
||||
| Freshness: live note outranks stale archived twin | **yes** | no |
|
||||
| Duplicate notes created (3 renamed-entity captures) | **0** (gate blocks) | 3 |
|
||||
| Legitimate captures wrongly blocked | **0** | 0 |
|
||||
| Silent write failures (4 fault scenarios) | **0** (failures loud) | — |
|
||||
| Offline writes lost / duplicated on re-flush | **0 / 0** (3/3 queued + landed) | — |
|
||||
|
||||
## Version history
|
||||
|
||||
| Version | Highlights |
|
||||
|---------|-----------|
|
||||
| **2.1.1** | **MCP Phase 0 — return-not-print.** Every high-level op gains a core `*_op` function returning an envelope dict with a stdout-purity guarantee (helper chatter → stderr); CLI verbs become thin wrappers with identical output and exit codes. Duplicate gate and offline queueing become data (`action: duplicate-gate` / `queued: true`). New `eval/test_ops_api.py` contract suite. This is the seam the 2.2 containerized MCP server wraps. |
|
||||
| **2.1.0** | **Quick-wins train: cheaper sessions, durable capture, one-call session end.** (1) **`load --brief`** — a token-budgeted cold-start digest (Fact/Pattern in full, last ~10 Observations, scope+freshness, last session's key sections, Agent-Log lines, inbox *count*; `ECHO_LOAD_BUDGET` default ~8000 chars) now injected by the SessionStart hook, replacing the full six-file dump that grew unboundedly; all `load` reads are fetched in parallel. (2) **Offline capture durability** — `capture` on an unreachable vault queues the *whole operation* as one semantic record; `flush` replays it **through capture** so routing/gate/aliasing re-run against the current index; a gate stop on replay is kept + flagged, never landed blind; `ensure_daily_log`/update-path writes ride the queue too. (3) **`session-end`** — one call (one lock) writes session log → Agent-Log line → reflect proposals → optional scope switch → **heartbeat last as the commit marker**; dry-run by default; `ECHO_NOW` pins the HHMM. Also fixes the `__main__` twin-module trap so helper-module `EchoError`s exit with their intended codes. +19 end-to-end checks. |
|
||||
| **2.0.0** | **Packaging & structure major — memory behavior unchanged.** Skills-only: the legacy `commands/` directory is deleted (breaking for pre-skills clients; the 1.6.0-verified skills are the sole entry points). Artifact policy: the repo tracks only the `echo-memory.plugin` pointer; the 15 historical versioned zips leave the tree and versioned builds ship as **Gitea releases** (one per `v<version>` tag) from `v2.0.0` on; `.gitignore` blocks `*.plugin` except the pointer. README gains the "packaging for other agent runtimes" port note (build-target rule, never a second source tree). |
|
||||
| **1.6.0** | **Skills-format migration + lint blind-spot fix.** The eight slash commands gain `skills/<name>/SKILL.md` twins (same names, byte-identical bodies; a same-name skill takes precedence, so `commands/` coexists until its 2.0 deletion): per-skill `allowed-tools` end the permission prompts, `disable-model-invocation: true` makes `/echo-sweep` + `/echo-triage` operator-only. `vault_lint` checks retired patterns **before** routes, so a seed README inside a retired tree (`archive/…`) is flagged instead of being absolved by the leaf-README route (+ regression test). `plugin.json` gains `homepage`/`repository`. |
|
||||
| **1.5.1** | **Duplicate-gate precision.** Live use caught the gate blocking a decision note because it shared the single vault-common token "echo" with an archived project (min-normalized scoring gives any single-token entity a 1.0 match). New `gate_candidates()` blocks only on **same-kind** candidates (cross-kind name collisions — a decision titled after its project — warn instead), and a **lone shared token blocks only when unique to that entity** (vault df == 1); multi-token overlaps still block. Advisory warnings (`fuzzy_candidates`) unchanged; exit-76/`--merge-into`/`--force` unchanged. +5 tests; verified against the live vault both ways (false positive creates cleanly, true lookalike still gates). |
|
||||
| **1.5.0** | **Six-improvement pass: retention, routing, and self-firing memory.** (1) **Capture-update keeps the whole body** — the update path previously appended only the first line of a multi-line body (silent data loss); now the full body rides under the dated bullet. (2) **Frontmatter completeness** — capture stamps a kind-default `status` and kind-seeded `tags` (`--tags` enriches); `fm` is create-or-replace (missing keys are surgically inserted instead of 400-failing); `vault_lint` gains a kind-scoped `incomplete-frontmatter` check; `sweep --apply` backfills (fixes the 18/71-notes field-audit drift, one data source: `KIND_STATUS`/`KIND_REQUIRED_FM`). (3) **Pre-write duplicate gate** — a strong fuzzy candidate stops `capture` (exit 76 + candidates) before the duplicate exists; `--merge-into <slug>` updates the canonical entity, `--force` overrides. (4) **Recall corpus + ranking** — sessions and journal notes join the BM25 corpus (down-weighted); scores fuse freshness (half-life on `updated:`) and status (`active` boosted, `archived` demoted); hits show `updated:`/`status:`; `recall --json`; recall-index schema 2 (auto-rebuilds). (5) **Session hooks** — SessionStart auto-loads memory into context, Stop nudges reflection once per substantive session; fail-safe, CoWork-fallback-aware. (6) **One-tap triage** — `echo.py triage` lists the inbox structured, then classifies → previews → routes accepted items via `capture` with automatic processing-log audit lines; `--json` also lands on `link`/`scope show`. +22 mock end-to-end tests; all suites green. |
|
||||
| **1.4.2** | **CoWork sandbox path resilience.** In a remote CoWork sandbox `${CLAUDE_PLUGIN_ROOT}` can point at a host path the sandbox can't reach (`/var/folders/…`) while the plugin is mounted under `…/mnt/.remote-plugins/…`, so script invocations failed until the agent found the real path by hand. SKILL.md and all eight slash commands now resolve the scripts dir with a fallback — prefer `${CLAUDE_PLUGIN_ROOT}`, else locate the mounted copy under `/sessions/*/mnt/.remote-plugins/*/…` — reused via `$ECHO`/`$LINT`/`$SWEEP`/`$SDIR`. On a normal host the primary path always wins (no behaviour change). `echo-health`/`echo-sweep` `allowed-tools` broadened to match the resolved invocation. (The scripts never hardcoded a path — root cause is the harness env var — but the plugin now self-heals.) |
|
||||
| **1.4.1** | **Baked credentials now win unconditionally.** A per-user baked artifact could still be prompted for credentials on install: the baked `DEFAULT_*` tier was lowest priority, so a stale `ECHO_*` env var or a leftover/placeholder `~/.claude/echo-memory/config.json` shadowed the baked key — even an unedited `"<placeholder>"` value beat it, flipping `is_configured()` to false and triggering the first-run "ask the operator" flow despite valid baked creds. `echo_config.load()` now treats a **complete** baked set (endpoint + key both present) as authoritative — it wins over env vars and the config file and cannot be shadowed; the generic (unbaked) plugin keeps its env → file → first-run flow. New `baked_complete()`; `source()` reports `baked-in (plugin)`. Per-user artifacts rebaked; manifest → 1.4.1. |
|
||||
| **1.4.0** | **Per-user baked-key builds — zero-touch CoWork install.** The CoWork sandbox doesn't bridge your real `~/.claude`, but the plugin itself is mounted every session — so credentials can travel *inside* the artifact. `echo_config` gains a baked tier (`DEFAULT_OWNER/BASE/KEY`, **empty in source** so the committed tree still ships zero credentials). New `build.py --bake-key --from <config.json> [--label <name>]` substitutes one user's owner/endpoint/key into those constants, producing a per-user artifact that needs **no config file and no per-session paste** (values may also come from `--owner/--endpoint/--key` or `ECHO_*` env). Baked builds default to `dist/` (gitignored) and never touch the shared `echo-memory.plugin` pointer; `--strip-key` force-blanks the constants for a guaranteed token-free build. `config show`/`doctor` report the baked source. |
|
||||
| **1.3.1** | **Prompts for the key on load.** A fresh or just-upgraded machine has no config yet, so the plugin detects that and asks for it instead of failing opaquely. `echo_config.is_configured()` treats a missing **or still-placeholder** config as unconfigured; `echo.py load` prints a `NOT CONFIGURED` banner and exits `78` (other verbs exit `2`; `/echo-doctor` reports it red). New **`echo.py config import <file>`** adopts a config you've been handed — it validates the file and writes `~/.claude/echo-memory/config.json`. `SKILL.md` gains a **First run** step: on the not-configured signal the agent asks the operator for their key file and installs it, then proceeds. One-time per machine — the config lives in `~/.claude/`, so later plugin updates don't disturb it. |
|
||||
| **1.3.0** | **User-agnostic — owner/endpoint/key out of the source.** The plugin previously baked in an API key and hard-coded the endpoint; both are gone. Owner, endpoint, and key are now **machine-local**, resolved by the new `echo_config.py` from `~/.claude/echo-memory/config.json` (env `ECHO_OWNER`/`ECHO_BASE`/`ECHO_KEY` override per field); `echo_secrets.py` removed. New `echo.py config` subcommand (`show`/`init`/`set`). All personal details (owner, role/employer), the live endpoint, and the literal key were **stripped** from the plugin source, references, commands, and eval harness — the only name that remains is the architect credit. A placeholder `config.template.json` is provided for new users/vaults. Routing manifest unchanged; offline test suites pass. Manifest → 1.3.0. |
|
||||
| **1.2.0** | **Entity-resolution overhaul — fewer duplicate notes.** Resolution was exact-match only, so a shortened or expanded mention (e.g. "echo memory" for the `echo`-slugged active project) returned *no match* and the writer created a parallel note. Now `resolve()` matches on slug / title / **aliases**, with a `fuzzy_candidates()` "did-you-mean" fallback ranked by shared distinctive tokens (guarded by `echo_quality` so generic words like "data"/"api"/"the" can't drive a match). Aliases are **auto-derived from titles** (hyphen/space/case variants), **learned from mentions** on update, and stored in each note's `aliases:` frontmatter (the Obsidian-native home), then folded back into the entity index by `sweep` — so the graph gets better at resolution over time, while exact-match safety still prevents wrong auto-merges. Ships with the v1.1 connection-pool + concurrency work. 33 automated tests pass across Win/macOS/Linux; linter clean. See the [performance brief](echo-memory-performance-brief.html). |
|
||||
| **1.1.0** | **Performance / network-layer rebuild — "from timing out to sub-second."** Full-vault scripts (`sweep.py`, `vault_lint.py`, recall rebuild) were making hundreds of *serial* requests, each opening a fresh TLS connection and re-reading every note 2–3×; on the constrained agent sandbox a single pass exceeded the ~120 s tool timeout and was killed mid-run, dropping the session/thread. Three structural fixes in `echo.py`: (1) **connection reuse (keep-alive)** — one persistent pooled connection per thread, reused across requests instead of a TCP+TLS handshake per file (**~4.5× faster per request**, the dominant win; benefits load/capture/recall for free); (2) **concurrent bulk reads** — new `read_many()` fans GETs across a thread pool (`ECHO_WORKERS`, default 8) for ~2× on full-vault scale; (3) **single-pass shared cache** — each note fetched *once* and reused across all passes (~2–3× fewer requests), eliminating `sweep`'s per-link-target re-fetch storm. Net effect: a full-vault pass that used to **time out now finishes in <1 s** (vault_lint 0.90 s, sweep plan 0.85 s on 186 notes); the practical change is fails → completes. Stdlib-only, no new dependencies. |
|
||||
| **1.0.0** | Schema 4. **"Memory you can trust and retrieve."** (H1) **Hybrid recall** — local BM25 over note bodies fused with decayed graph expansion (`echo_recall.py`, `_agent/index/recall-index.json`), maintained on `capture`, rebuilt by `sweep`; replaces keyword-only recall. (H2) **Offline durability** — write verbs queue to a local write-ahead outbox on an outage and report "queued"; `flush` (+ flush-on-`load`) replays idempotently and **re-bases to the current endpoint**; `load` degrades to a last-known-good read cache (`echo_queue.py`). (H3) **Concurrency** — `vault_lock` + `atomic_index_update` (lock + fresh re-read-merge) close the concurrent-`capture` entity-loss race (`echo_concurrency.py`). (H4) **Trust** — higher-fidelity PATCH mock + offline/reflect/patch-semantics suites gated in GitHub Actions across Win/macOS/Linux × Py 3.10/3.12. (H5) **Reflection capture** — `echo.py reflect` / `/echo-reflect` extract→dedup→preview→apply durable items (`echo_reflect.py`). (M1) **Secret hardening** — key resolves env → `~/.echo-memory/credentials` → deprecated fallback; `write-key` CLI; token scrubbed from docs (see [API-KEY-SETUP.md](API-KEY-SETUP.md)). (M2) confident auto-linking + slug-collision guard (`echo_quality.py`). (M3) `echo.py doctor` / `/echo-doctor` + heartbeat-less `load` fallback. (M4) `capture --json` / `--dry-run`. (M5) manifest → 1.0.0, `.gitignore`. |
|
||||
| **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. |
|
||||
|
||||
-176
@@ -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).
|
||||
@@ -0,0 +1,97 @@
|
||||
# echo-memory — Roadmap to v2.0.0
|
||||
|
||||
> Status: **draft.** Current shipping version: **1.5.1**. Interim work tracked in
|
||||
> `TODO-1.6.md`.
|
||||
>
|
||||
> **2.0 thesis: a packaging & structure major.** Nothing here changes what the plugin
|
||||
> *does* for memory — 2.0 is the release where the repo layout, artifact policy, and
|
||||
> plugin format are cleaned up in ways that are breaking for how the plugin is
|
||||
> *distributed and installed*, so the major version signals "reinstall everything,
|
||||
> re-clone expectations." Feature work keeps landing in 1.6.x until this is ready.
|
||||
>
|
||||
> This roadmap absorbs every open item from the retired `MAINTENANCE.md` (M5)
|
||||
> checklist; the mapping is noted per item.
|
||||
|
||||
---
|
||||
|
||||
## 1. Repo-root artifact policy (from MAINTENANCE › Build artifacts)
|
||||
|
||||
The repo root tracks every historical build (`echo-memory-0.6.0.plugin` …
|
||||
`echo-memory-1.5.1.plugin`) plus the `echo-memory.plugin` pointer. 2.0 changes the
|
||||
policy — **breaking for anyone who pulls artifacts straight from the repo**:
|
||||
|
||||
- [x] Track only `echo-memory.plugin` (the current installable); delete the versioned
|
||||
zips from the tree — **done 2.0.0** (15 zips removed; history keeps them).
|
||||
- [x] Publish versioned builds as **Gitea releases** on `git.alwisp.com/jason/echo`
|
||||
instead (one release per tag, artifact attached) — **v2.0.0 onward**.
|
||||
- [x] `.gitignore`: `*.plugin` except the pointer — **done 2.0.0**.
|
||||
- [x] Tag releases going forward (`v2.0.0`, …) so the release page is the version
|
||||
history for artifacts, and the README table stays the narrative history.
|
||||
|
||||
## 2. Complete the skills-format migration (finishes the 1.6 work)
|
||||
|
||||
1.6 migrates the eight slash commands to `skills/*/SKILL.md` with both formats
|
||||
coexisting (see `TODO-1.6.md`). 2.0 finishes it:
|
||||
|
||||
- [x] **Delete the legacy `commands/` directory** — **done 2.0.0** (1.6.0's install
|
||||
verification on both surfaces cleared the gate; skills had precedence anyway).
|
||||
- [x] Re-verify desktop + CoWork installs of the skills-only artifact — **verified
|
||||
2026-07-28** on the baked 2.0.0 build: installs cleanly, all eight skills
|
||||
register and work with no legacy `commands/` present.
|
||||
- [x] Update SKILL.md / README / command docs that reference `commands/` — **done 2.0.0**.
|
||||
|
||||
## 3. Codex packaging (from MAINTENANCE › Canonical source tree)
|
||||
|
||||
The drifted `codex plugin/` tree was **deleted on 2026-07-03** (operator decision:
|
||||
Codex usage doesn't currently justify a maintained second build; it will be
|
||||
regenerated after 2.0 if needed). What remains for 2.0:
|
||||
|
||||
- [ ] If Codex support returns: implement it as a **build target** generated from the
|
||||
canonical `echo-memory.plugin.src/` tree (`build.py --target codex` or similar) —
|
||||
never as a second hand-maintained source tree (that's how the drift happened).
|
||||
- [x] Otherwise: a short "packaging for other agent runtimes" note in the README
|
||||
documenting what a Codex/other-runtime port needs (manifest shape, skill entry
|
||||
point, script invocation) — **done 2.0.0** (README › after Repository layout).
|
||||
|
||||
## 4. Eval refresh — publish current-version metrics (from MAINTENANCE › Docs freshness; ROADMAP-1.0 H4 leftover) — ✅ DONE
|
||||
|
||||
`run_eval.py` was rewritten from the stale 0.6-vs-0.7 A/B into the current-version
|
||||
metrics harness (retrieval / dedup / write-safety / durability); numbers published in
|
||||
the README. Re-run it per release and refresh the README table.
|
||||
|
||||
- [x] Refresh `run_eval.py` to measure the current version — **done 2026-07-03**:
|
||||
retrieval P@5/R@5/MRR + session/journal answerability + freshness top-1 (vs a
|
||||
keyword/entities-only baseline), duplicate rate gate-on/off, silent-write-failure
|
||||
rate under fault injection, offline-queue durability. `results/latest.json`.
|
||||
- [x] Publish the numbers in the README and mark the old A/B harness historical
|
||||
(git history) — **done 2026-07-03**.
|
||||
|
||||
## 5. Repo hygiene odds & ends
|
||||
|
||||
- [x] Retire `ROADMAP-1.0.md` (fully shipped; in git history) — **done 2026-07-03**.
|
||||
- [x] Root README "Repository layout" section updated for the post-2.0 tree
|
||||
(no versioned zips, no codex tree, skills-only plugin) — **done 2.0.0**.
|
||||
- [x] `echo-improvements-prompt.md` (the 1.5.0 field report) moved to `docs/history/`
|
||||
— **done 2026-07-03**.
|
||||
|
||||
---
|
||||
|
||||
## MAINTENANCE.md disposition (why it was removed)
|
||||
|
||||
Every item from the retired checklist is either **done** or **absorbed above**:
|
||||
|
||||
| MAINTENANCE item | Status |
|
||||
|---|---|
|
||||
| Canonical tree = `echo-memory.plugin.src/` | Done (0.x) |
|
||||
| `codex plugin/` decide regenerate-or-delete | **Deleted 2026-07-03**; regenerate-as-build-target tracked in §3 |
|
||||
| Prune historical `.plugin` zips; gitignore policy | §1 |
|
||||
| `.gitignore` basics | Done (1.0) |
|
||||
| plugin.json `license` + version | Done (1.0) |
|
||||
| plugin.json `homepage` | `TODO-1.6.md` §2 (URL now decided) |
|
||||
| Register commands in manifest | Superseded by the skills migration (`TODO-1.6.md` §1, finished in §2 here) |
|
||||
| `run_eval.py` refresh to current metrics | §4 |
|
||||
| Secret handling (M1) | Done — superseded by `echo_config` in 1.3/1.4; committed tree ships zero credentials |
|
||||
| Pre-1.0 checklist | Done/obsolete (1.0 shipped; the `DEFAULT_KEY` line it referenced was removed in 1.3) |
|
||||
|
||||
Build flags (`--bake-key`, `--strip-key`, `--no-pointer`, `--outdir`) are documented
|
||||
in `build.py`'s docstring and the README Configuration section.
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# echo-memory — v1.6 TODO
|
||||
|
||||
> Working list for the next minor release. Started 2026-07-03, right after 1.5.1
|
||||
> shipped. **Add findings from 1.5.x live testing to the "Surfaced in testing"
|
||||
> section as they come up** — that's what this doc is for.
|
||||
|
||||
## 1. Migrate the eight slash commands to the skills format
|
||||
|
||||
The installer flags the plugin's `commands/` directory as the legacy format
|
||||
("Both formats work — consider migrating to skills/*/SKILL.md"). Confirmed against
|
||||
the official docs (code.claude.com/docs/en/skills.md, plugins.md):
|
||||
|
||||
- **Non-breaking.** `commands/echo-save.md` → `skills/echo-save/SKILL.md` keeps the
|
||||
invocation name: `/echo-save` works identically. No deprecation date on `commands/`;
|
||||
this is future-proofing, not a fire.
|
||||
- **Do it for the features, not the notice:**
|
||||
- [x] `allowed-tools` per skill — **done 1.6.0** (all eight skills pre-authorize
|
||||
the resolved `python3`/`python`/`py -3` invocations + the CoWork `ls` probe).
|
||||
- [x] `disable-model-invocation: true` on the write-heavy entry points
|
||||
(`/echo-sweep`, `/echo-triage`) — **done 1.6.0**; read-side stays
|
||||
model-invocable.
|
||||
- [x] De-duplicate the CoWork `$ECHO` path-resolution block — **resolved 1.6.0 as
|
||||
not-supported**: per the official skills docs, skill directories are
|
||||
self-contained (no cross-skill snippet sharing), so the 2-line block stays
|
||||
per-skill. Docs confirmed same-name skill takes precedence over the legacy
|
||||
command, so coexistence is safe.
|
||||
- [x] `argument-hint` carries over as-is; `$ARGUMENTS` substitution unchanged —
|
||||
**done 1.6.0** (save/recall/reflect).
|
||||
- [x] Verify a migrated build installs cleanly on desktop **and** in a CoWork session
|
||||
before deleting `commands/` — **verified 2026-07-28** on the baked 1.6.0-jason
|
||||
artifact, all four checks pass: (1) all eight skills register and take
|
||||
precedence over the coexisting legacy commands; (2) `allowed-tools` present on
|
||||
load/health/recall; (3) `disable-model-invocation` confirmed on exactly
|
||||
sweep+triage; (4) CoWork `$ECHO` fallback resolves via
|
||||
`/sessions/*/mnt/.remote-plugins/*` and executes. **2.0's `commands/` deletion
|
||||
is unblocked.** *Known caveat, accepted (CLI is not a normal operating surface
|
||||
here): the `allowed-tools` globs match the literal script names
|
||||
(`*echo.py*`/`*vault_lint.py*`) while the bodies invoke via the resolved
|
||||
`"$ECHO"` variable — if the Claude Code CLI matches permissions before
|
||||
variable expansion, those skills could still prompt there. Re-confirm on the
|
||||
CLI if that surface ever matters.*
|
||||
|
||||
## 2. plugin.json completeness (from the old MAINTENANCE checklist)
|
||||
|
||||
- [x] Add `homepage` / repository URL — **done 1.6.0**: both `homepage` and
|
||||
`repository` set to `https://git.alwisp.com/jason/echo`.
|
||||
|
||||
## 3. Vault follow-ups (noticed during the 1.5.0 dead-link cleanup)
|
||||
|
||||
Three of the 27 unwrapped "dead" links were actually **typos pointing at real notes**;
|
||||
they were unwrapped per instruction but repointing them would restore real graph edges:
|
||||
|
||||
- [ ] `projects/active/forgerunner.md` + `_agent/sessions/2026-06-29-2200-forgerunner-v010-ship.md`:
|
||||
"Gitea CI Docker jobs run on the host label" → the decision
|
||||
`decisions/by-date/2026-06-29-gitea-ci-docker-jobs-run-on-the-host-lab.md` ("lab", not "label").
|
||||
- [ ] `projects/active/mpm-brand-voice.md`: "Message Point Media (MPM)" ×3 → the company
|
||||
note `resources/companies/mpm.md` (or whatever is canonical for MPM).
|
||||
|
||||
## 4. Surfaced in 1.5.x testing
|
||||
|
||||
*(add items here as the new plugin gets real use)*
|
||||
|
||||
- [x] **Lint blind spot: retired dirs masked by the leaf-README route** (found
|
||||
2026-07-03; **fixed 1.6.0** — retired patterns now checked before routes, with
|
||||
the `archive/notes/README.md` regression test).
|
||||
`archive/` and `_agent/outputs/` are retired/unrouted pre-0.6 leftovers that survived
|
||||
in the live vault holding only their seed READMEs — and the permissive `leaf-readme`
|
||||
route (`^(.+/)?README\.md$`) matches first, so `vault_lint` never flagged them.
|
||||
Fix: in the path-membership check, test the *directory* against retired/unknown
|
||||
patterns even when the only file in it is a README (e.g. evaluate `dirname(path) + "/"`
|
||||
against retired patterns before letting `leaf-readme` absolve the file), or drop the
|
||||
seed-README exemption inside retired trees. Add a lint test seeding
|
||||
`archive/notes/README.md` → must flag `retired-path`.
|
||||
*Related learning (same cleanup, 2026-07-03):* the REST API **cannot delete
|
||||
directories** — removing a folder's last file orphans the empty folder on disk
|
||||
(the REST listing 404s, falsely reading as gone; Obsidian still shows it — manual
|
||||
deletion required). Documented in `references/api-reference.md` › Deleting Files
|
||||
and in the vault's `resources/references/obsidian-local-rest-api.md`. Any future
|
||||
"remove retired tree" guidance must include the manual folder-cleanup step.
|
||||
@@ -2,13 +2,18 @@
|
||||
"""build.py — package the echo-memory plugin source into a .plugin artifact.
|
||||
|
||||
Zips the CONTENTS of echo-memory.plugin.src/ at the archive root (the layout the plugin
|
||||
loader expects: .claude-plugin/plugin.json, commands/, skills/ all at top level), excluding
|
||||
loader expects: .claude-plugin/plugin.json, hooks/, skills/ all at top level), excluding
|
||||
dev cruft. The version is read from the manifest, so the output is named automatically.
|
||||
|
||||
Usage:
|
||||
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
|
||||
# (requires ECHO_KEY or ~/.echo-memory/credentials at runtime)
|
||||
# (token-free: ships no credentials; user configures 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 --outdir dist # write artifacts somewhere other than the repo root
|
||||
|
||||
@@ -20,6 +25,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
@@ -35,7 +41,8 @@ for _stream in (sys.stdout, sys.stderr):
|
||||
REPO = Path(__file__).resolve().parent
|
||||
SRC = REPO / "echo-memory.plugin.src"
|
||||
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_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
|
||||
# (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]:
|
||||
@@ -60,24 +73,33 @@ def included_files() -> list[Path]:
|
||||
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()
|
||||
if strip_key and arcname == ECHO_PY:
|
||||
text = data.decode("utf-8")
|
||||
new, n = _KEY_RE.subn(r'\1\3', text) # collapse the hex value to ""
|
||||
if n:
|
||||
text = new.replace('DEFAULT_KEY = ""',
|
||||
'DEFAULT_KEY = "" # stripped at build time — set ECHO_KEY or run write-key')
|
||||
data = text.encode("utf-8")
|
||||
if arcname != CONFIG_PY or (bake is None and not strip_key):
|
||||
return data
|
||||
text = data.decode("utf-8")
|
||||
for field, const in _CONSTS.items():
|
||||
repl = json.dumps(bake[field]) if bake is not None else '""'
|
||||
# function replacement so backslashes in repl (e.g. \uXXXX) stay literal
|
||||
text, n = _const_re(const).subn(lambda m, r=repl: m.group(1) + r, text, count=1)
|
||||
if n != 1:
|
||||
raise RuntimeError(f"build: could not find `{const} = ...` in {arcname}")
|
||||
return text.encode("utf-8")
|
||||
|
||||
|
||||
def token_present(path: Path) -> bool:
|
||||
m = _KEY_RE.search(path.read_text(encoding="utf-8"))
|
||||
return bool(m and m.group(2))
|
||||
"""True if any DEFAULT_* constant in echo_config.py holds a non-empty value."""
|
||||
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():
|
||||
out.unlink()
|
||||
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.compress_type = zipfile.ZIP_DEFLATED
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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",
|
||||
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",
|
||||
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)
|
||||
|
||||
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():
|
||||
print(f"build: source tree not found at {SRC}", file=sys.stderr)
|
||||
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 = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
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)
|
||||
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)
|
||||
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")
|
||||
|
||||
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)")
|
||||
|
||||
echo_src = SRC / "skills" / "echo-memory" / "scripts" / "echo.py"
|
||||
if args.strip_key:
|
||||
print("note: --strip-key set — artifact is token-free; runtime needs ECHO_KEY or a credentials file.")
|
||||
elif token_present(echo_src):
|
||||
print("WARNING: the baked-in DEFAULT_KEY is present in this artifact — do not share it. "
|
||||
"Set ECHO_KEY everywhere (API-KEY-SETUP.md), then build with --strip-key.")
|
||||
if bake:
|
||||
who = bake.get("owner") or "(no owner)"
|
||||
print(f"note: --bake-key baked credentials for {who} into {CONFIG_PY}.")
|
||||
print("WARNING: this artifact carries a vault key — deliver it directly to that one "
|
||||
"user and NEVER commit, push, or publish it (dist/ is gitignored).")
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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 1–5, 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.
|
||||
-110
@@ -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 2–5 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/`.
|
||||
-26
@@ -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.
|
||||
-31
@@ -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.
|
||||
-27
@@ -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
|
||||
-23
@@ -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
|
||||
-29
@@ -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
|
||||
-22
@@ -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
|
||||
-25
@@ -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
|
||||
-25
@@ -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
|
||||
-26
@@ -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
|
||||
-31
@@ -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())
|
||||
@@ -0,0 +1,353 @@
|
||||
# echo-memory — Improvement Plans (post-1.5.1 review)
|
||||
|
||||
> Status: **planned, not started.** Source: full-code review 2026-07-28.
|
||||
> Nine improvements, each scoped for an independent build session. The tenth item
|
||||
> from that review — the **MCP server** — has its own detailed spec:
|
||||
> `docs/MCP-SERVER-SPEC.md`.
|
||||
>
|
||||
> Sequencing is at the bottom. Nothing here conflicts with `TODO-1.6.md` or
|
||||
> `ROADMAP-2.0.md`; release targets assume 1.6 (skills migration) ships first.
|
||||
|
||||
---
|
||||
|
||||
## 1. Token-budgeted load (`load --brief`) + parallel orientation reads
|
||||
|
||||
**Problem.** The SessionStart hook injects the *full text* of six files into every
|
||||
session. Live measurement: 15.9KB of hook context on a single cold start, and two of
|
||||
the six files grow without bound (operator-preferences `## Observations`,
|
||||
current-context `## Scope History`). Every session pays this tax before any work
|
||||
happens. Separately, `cmd_load` (echo.py) issues its six GETs serially even though
|
||||
`read_many()` exists.
|
||||
|
||||
**Design.**
|
||||
- New `echo.py load --brief` (and make the SessionStart hook use it):
|
||||
- marker → one line (`schema_version`, bootstrapped yes/no).
|
||||
- operator-preferences → `## Fact / Pattern` in full; `## Observations` capped at
|
||||
the last 10 lines with a `(+N older — /echo-load for full)` note.
|
||||
- current-context → `## Scope` + `scope_updated` + sessions-since (i.e. the
|
||||
`scope show` output); omit `## Scope History`.
|
||||
- heartbeat → the pointer line + only the `## Summary`/`## Outcomes` sections of
|
||||
the pointed-at session log (fetch it, extract those headings, cap ~30 lines).
|
||||
- today's daily note → `## Agent Log` lines only (or "absent").
|
||||
- inbox → count + age of oldest item, not the contents (that's all the reconcile
|
||||
needs; `/echo-triage --list` has the details).
|
||||
- Budget guard: after assembly, if the brief output still exceeds
|
||||
`ECHO_LOAD_BUDGET` chars (default ~8000), truncate lowest-priority sections first
|
||||
(observations → agent log → session summary) with explicit `(truncated)` markers.
|
||||
- Full `load` unchanged; `/echo-load` keeps using it.
|
||||
- Fetch all reads via `read_many()` (the 6 targets + the heartbeat-pointed session
|
||||
log + the sessions listing fallback) instead of the serial loop at
|
||||
`echo.py:640`. Cache-put still applies per path.
|
||||
|
||||
**Files.** `echo.py` (cmd_load), `echo_hook_session_start.py`, SKILL.md (document
|
||||
brief-vs-full), `eval/test_features.py`.
|
||||
|
||||
**Tests.** Mock vault with oversized preferences/history files → assert brief output
|
||||
under budget, sections present, truncation markers correct; hook emits brief.
|
||||
|
||||
**Size/target.** Small-medium. **1.6.x point release** — highest leverage per line.
|
||||
|
||||
---
|
||||
|
||||
## 2. Local-first recall index
|
||||
|
||||
**Problem.** `echo_recall.update_note()` does GET-whole-index → add → PUT-whole-index
|
||||
against the vault, under the global advisory lock, on every capture and every corpus
|
||||
`put`. The index carries full BM25 postings for the whole corpus, so this round-trip
|
||||
is O(vault) network per write and is the emerging bottleneck + lock hot-spot as the
|
||||
vault grows past a few hundred notes.
|
||||
|
||||
**Design.**
|
||||
- The **live** recall index moves to the local state dir
|
||||
(`~/.echo-memory/recall-index.json`, honoring `ECHO_STATE_DIR`) keyed by endpoint
|
||||
(hash of `BASE`) so multiple vaults don't collide.
|
||||
- `update_note()` becomes a local read-modify-write (file lock via `os.O_EXCL`
|
||||
sidecar or atomic `os.replace`), **no vault round-trip, no advisory lock**.
|
||||
- The vault copy (`_agent/index/recall-index.json`) becomes a **snapshot**, written
|
||||
by `sweep.py` and at session end (piggyback on the heartbeat write / future
|
||||
`session-end` verb). It exists so a *fresh machine* can seed its local index
|
||||
without a full rebuild.
|
||||
- Freshness rule on recall: local index used when present; if the vault snapshot's
|
||||
embedded `built` stamp is newer than the local one (another client swept), pull it.
|
||||
Staleness across clients is tolerable — recall degrades gracefully and improvement
|
||||
#4 (incremental sweep) trues it up cheaply.
|
||||
- Schema bump: recall-index schema 3 adds `built` (ISO timestamp) + `endpoint_hash`.
|
||||
Old schema-2 vault copies are still readable as seeds.
|
||||
- The **entity index stays in the vault** (it is small, and it is the shared routing
|
||||
authority for concurrent clients) — only the BM25 index moves.
|
||||
|
||||
**Files.** `echo_recall.py` (load/save/update_note/rebuild), `echo_queue.py`
|
||||
(state-dir helpers shared), `sweep.py`, `echo.py` (cmd_put upkeep path), routing.json
|
||||
(the vault snapshot path is unchanged), README performance section.
|
||||
|
||||
**Tests.** Capture with vault mock → assert zero recall-index PUTs; sweep → snapshot
|
||||
written; fresh state dir + existing snapshot → seeded without rebuild; two-vault
|
||||
(endpoint) separation.
|
||||
|
||||
**Size/target.** Medium. **Pair with #4 in one index-focused minor** (they touch the
|
||||
same maintenance loop). Fine to land before or after 2.0.
|
||||
|
||||
---
|
||||
|
||||
## 3. Offline durability for the high-level ops (capture path)
|
||||
|
||||
**Problem.** The low-level verbs queue on outage via `safe_request`, but
|
||||
`_append_to_existing()` and `ensure_daily_log()` (echo_ops.py) call `echo.request`
|
||||
directly — so during an outage a raw `append` survives, while a `capture` **update**
|
||||
and its Agent Log line silently vanish. Inverted from the documented contract
|
||||
("capture is the default write").
|
||||
|
||||
**Design.** Queue *semantic* records, not byte-level requests, for the high-level
|
||||
path:
|
||||
- New outbox record type `{"op": "capture", "args": {...}}` alongside the existing
|
||||
raw records. On an unreachable vault, `capture` short-circuits: enqueue the full
|
||||
argument set (title/kind/body/tags/aliases/sources/date/domain/merge_into) and
|
||||
report `queued (offline): capture …`.
|
||||
- `flush()` replays `op:capture` records by calling `echo_ops.capture(...)` against
|
||||
the *current* index — so routing, the duplicate gate, aliasing, and linking all
|
||||
re-run with fresh state instead of replaying stale byte writes. A gate stop on
|
||||
replay (exit 76) keeps the record queued with a `needs_attention` flag and is
|
||||
surfaced by `load`/`flush` output ("1 queued capture needs --merge-into/--force").
|
||||
- Rationale for semantic-over-byte: a byte replay of `_append_to_existing`'s PATCH
|
||||
could target a heading that moved, and the create-vs-update decision made offline
|
||||
may be wrong by replay time. Re-running the op is the idempotent, correct unit
|
||||
(the dated-bullet idempotency key already prevents double-append).
|
||||
- `ensure_daily_log` stays best-effort but routes its POST/PATCH through
|
||||
`safe_request` so a standalone agent-log line survives an outage too.
|
||||
- reflect/triage `--apply` inherit this for free (they call capture).
|
||||
|
||||
**Files.** `echo_ops.py`, `echo_queue.py` (record schema v2 + replay dispatch),
|
||||
`eval/test_offline_queue.py`.
|
||||
|
||||
**Tests.** Fault-injected mock: capture-update offline → queued; flush replays via
|
||||
capture; gate-on-replay keeps record + flags; agent-log line queued; no duplicate
|
||||
bullets on double flush.
|
||||
|
||||
**Size/target.** Small-medium. **1.6.x point release** — it's a correctness gap.
|
||||
|
||||
---
|
||||
|
||||
## 4. Incremental sweep via content hashes
|
||||
|
||||
**Problem.** Human edits in Obsidian and writes by other clients never touch the
|
||||
entity/recall indexes; drift is only corrected by a full sweep someone must remember
|
||||
to run. Full sweep re-reads the whole vault (fast now, but O(vault)).
|
||||
|
||||
**Design.**
|
||||
- Entity index entries and the recall-index snapshot gain a per-note
|
||||
`h` — SHA-1 of the note body (entity index schema 2; tolerated-absent for old
|
||||
entries).
|
||||
- `sweep.py --fast`: walk the listing (cheap), `read_many` only paths that are
|
||||
(a) new, (b) missing a stored hash, or (c) whose hash mismatches after fetch — to
|
||||
avoid fetching everything just to hash it, fast mode fetches only paths whose
|
||||
**listing is new/gone** plus a rotating shard (e.g. `hash(path) % 7 == weekday`)
|
||||
of existing notes, so the whole vault is verified over a week of fast sweeps while
|
||||
each run stays tiny. `--fast --all-shards` forces full verification.
|
||||
- Deletions: listing walk catches removed files → drop index + recall entries.
|
||||
- Auto-run: `load` (brief or full) runs `sweep --fast` opportunistically when the
|
||||
last fast-sweep stamp (state dir) is older than `ECHO_FAST_SWEEP_DAYS` (default 7),
|
||||
in the background-tolerant sense: bounded by the same read_many concurrency, and
|
||||
skipped entirely when offline.
|
||||
- `/echo-health` reports last fast/full sweep ages.
|
||||
|
||||
**Files.** `sweep.py`, `echo_index.py` (schema 2 + hash field), `echo_recall.py`
|
||||
(snapshot hash reuse), `echo.py` (load hook-in), `vault_lint.py` (report), migrate
|
||||
note (index schema is machine-owned; no vault migration needed).
|
||||
|
||||
**Tests.** Mock: edit a note out-of-band → fast sweep in that shard re-indexes it;
|
||||
delete → entry dropped; stamp gating; full sweep unchanged.
|
||||
|
||||
**Size/target.** Medium. **Ship with #2** (same schema-bump train).
|
||||
|
||||
---
|
||||
|
||||
## 5. Memory lifecycle — enforce forgetting
|
||||
|
||||
**Problem.** The vault only accretes. Working memory is "time-boxed" by convention
|
||||
only; Observations trimming is manual prose; stale-active detection only reports.
|
||||
Recall precision degrades as dead weight accumulates.
|
||||
|
||||
**Design.**
|
||||
- `capture --kind working` stamps `expires: <today + ECHO_WORKING_TTL_DAYS>`
|
||||
(default 14; `--ttl <days>` overrides). Template updated to carry the field.
|
||||
- `vault_lint.py` new checks: `expired-working` (past `expires`),
|
||||
`stale-observations` (Observations > 30 bullets) — advisory, like the rest.
|
||||
- `sweep.py --decay` (dry-run by default, `--apply` to write; **always
|
||||
preview-first**, honoring the operating contract):
|
||||
- expired working notes → `status: archived` + move body under a dated
|
||||
`## Archived` marker (never delete);
|
||||
- `projects/active/` untouched > 30 days → *propose* `on-hold` (folder move +
|
||||
status, the usual paired change) — apply only the ones confirmed;
|
||||
- Observations > 30 → trim oldest into a dated `_agent/memory/episodic/`
|
||||
overflow note (`observations-archive-YYYY.md` append), keeping the last 30
|
||||
in place. Nothing is lost, the hot file stays small (compounds with #1).
|
||||
- `/echo-health` output nudges: "run `sweep --decay` to review N aging items".
|
||||
|
||||
**Files.** `echo_ops.py` (`--ttl`/expires stamp), `echo_index.py`
|
||||
(KIND_REQUIRED_FM unchanged; working gains optional expires), `vault_lint.py`,
|
||||
`sweep.py`, scaffold working-memory template, SKILL.md (decay section),
|
||||
routing-map note.
|
||||
|
||||
**Tests.** Mock: expired note proposed+archived on apply; observations trim keeps
|
||||
last 30 and appends overflow; stale-active proposal list; dry-run writes nothing.
|
||||
|
||||
**Size/target.** Medium. Feature minor (1.7.x).
|
||||
|
||||
---
|
||||
|
||||
## 6. Contradiction / supersession handling
|
||||
|
||||
**Problem.** A new fact that contradicts a stored one lands beside it; both surface
|
||||
in recall with no signal about which is current. Biggest remaining trust gap in the
|
||||
write path.
|
||||
|
||||
**Design.**
|
||||
- **Write side:** `capture --supersedes "<slug-or-quoted-line>"`:
|
||||
- if it names an entity slug → the old entity gets `status: superseded` +
|
||||
a `superseded_by:` frontmatter path; new note carries `supersedes:` back-link
|
||||
in `## Related`. Recall's STATUS_FACTOR gains `superseded: 0.5`.
|
||||
- if it quotes a Fact/Pattern or Observations line in operator-preferences →
|
||||
the line is struck (`~~old~~ (superseded YYYY-MM-DD)`) and the new line
|
||||
appended, one PATCH each, in the same invocation.
|
||||
- **Detect side (deterministic assist, model decides):** `echo_reflect.classify`
|
||||
gains a `conflict scan` — for each proposal, run its distinctive tokens
|
||||
(`_sig_tokens`) against the Fact/Pattern + recent Observations lines; overlapping
|
||||
lines are attached as `_conflicts: [...]` and the preview row renders
|
||||
`update-or-conflict?` with the matched line(s). The model (which has the
|
||||
conversation) decides supersede-vs-append; the script only guarantees the
|
||||
question gets asked before the write.
|
||||
- Lint: `superseded_by` pointing at a missing note → violation.
|
||||
|
||||
**Files.** `echo_ops.py`, `echo_reflect.py`, `echo_recall.py` (STATUS_FACTOR),
|
||||
`echo.py` (flag plumbing), `vault_lint.py`, SKILL.md + operating-contract
|
||||
(supersession is additive: strike, never delete).
|
||||
|
||||
**Tests.** Mock: supersede entity → status/backlinks both sides; supersede line →
|
||||
strike + append idempotent; reflect preview flags a planted conflict; recall ranks
|
||||
superseded below active twin.
|
||||
|
||||
**Size/target.** Medium. Feature minor (1.7.x/1.8) — after #5 (shares the
|
||||
status-vocabulary touchpoints).
|
||||
|
||||
---
|
||||
|
||||
## 7. `session-end` bundle verb
|
||||
|
||||
**Problem.** Ending a session correctly is 4+ invocations (session-log PUT, Agent
|
||||
Log append, heartbeat PUT, optional scope set, optional reflect apply). Cost per
|
||||
session, and a partial failure leaves broken orientation state (log written,
|
||||
heartbeat stale).
|
||||
|
||||
**Design.**
|
||||
- `echo.py session-end <bundle.json> [--apply]`, bundle:
|
||||
```json
|
||||
{
|
||||
"slug": "echo-mcp-spec",
|
||||
"log_body": "<full session-log markdown, frontmatter included>",
|
||||
"agent_log_line": "- 2026-07-28: wrote MCP spec [[...]]",
|
||||
"scope": "optional new scope text",
|
||||
"reflect": [ { ...PROPOSAL_SCHEMA... } ]
|
||||
}
|
||||
```
|
||||
- Dry-run previews the whole plan (paths + reflect preview). `--apply`, in order,
|
||||
under **one** lock acquisition: PUT `_agent/sessions/<date>-<HHMM>-<slug>.md`
|
||||
(HHMM from `ECHO_NOW` env or wall clock, validated against the canonical regex) →
|
||||
agent-log line (ensure_daily_log) → reflect proposals via capture → scope set if
|
||||
given → heartbeat PUT **last** (it's the commit marker: a crash before it leaves
|
||||
the previous pointer intact, never a dangling one).
|
||||
- Reports per-step ok/queued/gated; offline → every step queues (rides on #3).
|
||||
- Stop hook reason text updated to name the one command; `/echo-reflect` docs point
|
||||
session logging at it. **Prerequisite for the MCP `echo_log_session` tool** —
|
||||
build this verb first so CLI and MCP share one implementation.
|
||||
|
||||
**Files.** new `echo_session.py`, `echo.py` (subcommand), `echo_hook_stop.py`
|
||||
(REASON text), SKILL.md (Session Logging section), commands/echo-reflect.md.
|
||||
|
||||
**Tests.** Mock: full bundle lands all five artifacts; heartbeat-last ordering
|
||||
(inject failure mid-way → heartbeat untouched); dry-run writes nothing; offline
|
||||
queues all steps.
|
||||
|
||||
**Size/target.** Small-medium. **1.6.x point release** (and before the MCP build).
|
||||
|
||||
---
|
||||
|
||||
## 8. Recall robustness — stemming + alias query expansion
|
||||
|
||||
**Problem.** BM25 is purely lexical: "deploy"/"deployment"/"deployed" are distinct
|
||||
terms; a paraphrased query misses. The 8-query gold set can't see this class of miss.
|
||||
|
||||
**Design.**
|
||||
- **Stemming:** a light suffix-stripper (Porter-lite: s/es/ed/ing/ly/tion/ment/ness
|
||||
families, ~40 lines, pure stdlib) applied in `tokenize()` at **both** index and
|
||||
query time. Index schema bump (stemmed postings) → auto-rebuild on next
|
||||
recall/sweep, exactly like the 1→2 transition.
|
||||
- **Alias query expansion:** at recall time, run the query against
|
||||
`fuzzy_candidates()` (top 2, score ≥ 0.5); fold the matched entities' title +
|
||||
alias tokens into the BM25 query as **down-weighted expansion terms** (×0.5 idf
|
||||
contribution) so "the ECHO plugin" also scores `echo-memory`-alias vocabulary.
|
||||
Exact-match seeding stays as-is; expansion never *creates* hits ranked above
|
||||
genuine lexical matches without corpus support.
|
||||
- **Eval:** grow the gold set with ≥6 paraphrase queries (morphological variants +
|
||||
alias phrasings); publish before/after recall@5/MRR in the README table.
|
||||
|
||||
**Files.** `echo_recall.py` (tokenize, INDEX_SCHEMA, score, recall), new
|
||||
`echo_stem.py` (pure function, unit-testable), `eval/run_eval.py` + gold set.
|
||||
|
||||
**Tests.** Stemmer unit table (word → stem, incl. non-cases); paraphrase gold
|
||||
queries pass; schema-2 index discarded and rebuilt cleanly.
|
||||
|
||||
**Size/target.** Small-medium. Ride the **same index-schema train as #2/#4**.
|
||||
|
||||
---
|
||||
|
||||
## 9. Resolve-miss telemetry → alias suggestions
|
||||
|
||||
**Problem.** Every resolve that misses but ends in a confirmed candidate
|
||||
(`--merge-into`, or the operator picking a candidate) is a free mention→entity
|
||||
training pair, currently discarded.
|
||||
|
||||
**Design.**
|
||||
- `echo_index.resolve()` miss + `fuzzy_candidates` non-empty → append one NDJSON
|
||||
record to the local state dir (`~/.echo-memory/resolve-misses.ndjson`):
|
||||
`{mention, candidates:[slugs], ts?}` (no wall clock needed — session date via
|
||||
ECHO_TODAY). `capture --merge-into <slug>` appends the *confirmation* record
|
||||
`{mention, resolved: slug}` (this already learns the alias immediately — that
|
||||
path stays; the log covers the cases that never reach --merge-into).
|
||||
- `sweep.py` (and `/echo-health`) read the log: any `{mention → slug}` pair
|
||||
confirmed ≥2 times, or any mention that repeatedly fuzzy-matched a single
|
||||
candidate ≥3 times without confirmation, is **proposed** as an alias
|
||||
(`sweep --apply` writes it into the note's `aliases:` frontmatter + index, the
|
||||
existing fold-back path). Log entries consumed on apply; file capped (rotate at
|
||||
~500 lines).
|
||||
- Privacy note: mentions can contain operator phrasing — the log lives in the
|
||||
state dir (never the vault), same trust level as the offline queue.
|
||||
|
||||
**Files.** `echo_index.py` (log hooks), `echo_ops.py` (confirmation hook),
|
||||
`sweep.py` (suggest/apply), `vault_lint.py` or sweep report (surface count),
|
||||
SKILL.md one-liner.
|
||||
|
||||
**Tests.** Miss → record written; confirm ×2 → sweep proposes; apply folds alias +
|
||||
consumes log; cap/rotation.
|
||||
|
||||
**Size/target.** Small. Feature minor, pairs naturally with #8.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing (unified with TODO-1.6 and ROADMAP-2.0)
|
||||
|
||||
TODO-1.6 folds in as the opening train — the skills migration restructures the
|
||||
files everything below touches, so it goes first. ROADMAP-2.0 stays a **pure
|
||||
packaging major** (its own stated thesis) and is *not* folded into any feature
|
||||
train; it slots between the quick wins and the MCP server so the MCP work lands
|
||||
on the final packaging baseline (single command format, Gitea releases, one
|
||||
manifest-verification pass).
|
||||
|
||||
| Release | Items | Rationale |
|
||||
|---|---|---|
|
||||
| **1.6.0** | TODO-1.6: skills-format migration (+ per-skill `allowed-tools`, `disable-model-invocation` on write-heavy entries, `$ECHO` block dedupe) · lint blind-spot fix (retired-dir/leaf-README) · plugin.json `homepage` | Restructures the command/SKILL files later work edits; allowed-tools removes prompt friction for every verb added below. Vault link repointing (TODO-1.6 §3) is vault data, not plugin code — any session. |
|
||||
| **1.6.0 — SHIPPED 2026-07-28** | Skills migration + lint fix + homepage | Verified on desktop + CoWork. |
|
||||
| **2.0.0 — SHIPPED 2026-07-28** | ROADMAP-2.0: commands/ deleted · release-based artifacts · gitignore · Codex note | Packaging-only major; skills-only install re-verified. |
|
||||
| **2.1.0 quick wins — SHIPPED 2026-07-28** | #1 brief load · #3 offline capture · #7 session-end | All three landed with +19 e2e checks; see CHANGELOG 2.1.0. The MCP spec's Phase 0 (return-not-print) did NOT ride along — it's the first phase of the MCP build session. |
|
||||
| **2.2** | MCP server (containerized, `echomcp.alwisp.com`) — `docs/MCP-SERVER-SPEC.md` | Needs Phase 0 (return-not-print refactor, first step of the build session). #7 session-end shipped, so `echo_log_session` wraps a real verb. Deployed via CI + PORT, not the plugin manifest. |
|
||||
| **2.3 index train** | #2 local-first recall (CLI-path half) · #4 incremental sweep · #8 stemming+expansion | One schema-bump train; all touch the same index/sweep loop. The container already keeps its index warm in memory (spec §7), so #2 here covers the CLI fallback path; #4's fast sweep also becomes the container's background job. |
|
||||
| **2.4 memory train** | #5 lifecycle/decay · #6 supersession · #9 resolve-miss learning | Capability tier; #6 after #5 (status vocabulary). |
|
||||
@@ -0,0 +1,391 @@
|
||||
# echo-mcp — MCP Server Build Spec (containerized)
|
||||
|
||||
> Status: **spec for a future build session** (written 2026-07-28 against v1.5.1;
|
||||
> revised same day: **remote container architecture**, operator decision — heavy
|
||||
> lifting belongs in a deployed container, not on any one machine).
|
||||
> Companion plan for the other nine review items: `docs/IMPROVEMENT-PLANS.md`.
|
||||
>
|
||||
> **Prerequisites before starting this build:**
|
||||
> 1. The `session-end` verb (IMPROVEMENT-PLANS #7) — the MCP tool wraps it.
|
||||
> 2. Phase 0 below (the return-not-print refactor) — lands in the plugin tree
|
||||
> first and is independently shippable.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why an MCP server
|
||||
|
||||
Every ECHO operation today rides through Bash: resolve `$ECHO` (with the CoWork
|
||||
path-fallback snippet copy-pasted everywhere), quote a Python invocation, run
|
||||
it, re-parse stdout. Costs:
|
||||
|
||||
- **Tokens** — each memory op carries bash scaffolding + output re-parsing; a
|
||||
capture is ~3× the tokens of a typed tool call.
|
||||
- **Fragility** — quoting hazards, the `${CLAUDE_PLUGIN_ROOT}` sandbox mismatch
|
||||
(the whole 1.4.2 release), Windows `python3`-vs-`python` dispatch.
|
||||
- **Reach** — surfaces without a Python-capable shell can't use ECHO at all.
|
||||
- **Efficiency** — every CLI invocation is a cold Python start that re-reads the
|
||||
entity index from the vault over HTTPS.
|
||||
|
||||
What the server does **not** replace: SKILL.md remains the procedure authority
|
||||
(when to load, reconcile, search-first, third-person, etc.). The server replaces
|
||||
the *mechanics* — tools are the verbs, the skill is the discipline.
|
||||
|
||||
## 2. Architecture: remote container (decided)
|
||||
|
||||
A standalone Docker container on the Unraid box (ALPHA), published behind
|
||||
Cloudflare + NPM as **`echomcp.alwisp.com`**, speaking **MCP streamable HTTP
|
||||
(stateless JSON)** with bearer-token auth. Chosen over the earlier local-stdio
|
||||
draft for four reasons:
|
||||
|
||||
1. **The backend is already remote.** Clients round-trip to
|
||||
`echoapi.alwisp.com` today; a server co-located with the vault host turns
|
||||
every vault op into a LAN hop and collapses N client→vault calls into one
|
||||
client→server call.
|
||||
2. **Any device, no per-machine anything.** Desktop, CoWork sandbox, claude.ai
|
||||
(custom connector), mobile — same URL + token. The `${CLAUDE_PLUGIN_ROOT}`
|
||||
CoWork registration risk from the stdio draft disappears entirely.
|
||||
3. **Credentials consolidate server-side.** The vault key lives in the
|
||||
container env (PORT secret store); the image is credential-free; clients
|
||||
hold only an MCP bearer token. The per-user baked-key builds (1.4.x) become
|
||||
unnecessary for any surface that has MCP. Rotation = redeploy.
|
||||
4. **Room to grow.** The plugin's pure-stdlib constraint exists because its
|
||||
scripts run in arbitrary sandboxes. The container is ours: real SDK,
|
||||
SQLite/in-memory indexes that stay warm, background jobs, embeddings later —
|
||||
all without touching the plugin.
|
||||
|
||||
**What stays client-side (unchanged and shipped):** the skill + CLI path — it
|
||||
is the fallback when the server or network is down, the offline queue + read
|
||||
cache keep their per-machine role there, and the SessionStart/Stop **hooks stay
|
||||
CLI-based** (hooks run shell commands regardless of MCP).
|
||||
|
||||
**Cost acknowledged:** a second service to run and an exposed auth surface.
|
||||
Mitigations are existing infra: CF + NPM cert, long random bearer token, Kuma
|
||||
monitor, PORT deploy/rollback. Server down ⇒ exactly today's behavior.
|
||||
|
||||
## 3. Decisions (made — don't relitigate in the build session)
|
||||
|
||||
| Decision | Choice | Why |
|
||||
|---|---|---|
|
||||
| Language | **Python** | Imports the existing `echo_*.py` modules in-process — the entire ops layer is reused, not wrapped. |
|
||||
| SDK | **Official `mcp` Python SDK (FastMCP)** | The stdlib-only rule doesn't bind inside our own image. Hand-rolling the protocol (stdio draft) is no longer justified. |
|
||||
| Transport | **Streamable HTTP, stateless JSON** | Remote, multi-client, simplest to scale; SSE/sessions explicitly avoided. |
|
||||
| Auth | **Static bearer token** (`ECHO_MCP_TOKEN`), constant-time compare, 401 otherwise | Single-operator service; OAuth is overkill. Token from the PORT secret store. |
|
||||
| Vault credentials | Container env: `ECHO_BASE`, `ECHO_KEY`, `ECHO_OWNER` (SECRET: refs in the PORT template) | `echo_config.py` already resolves env-first — zero code change. Image ships no secrets. |
|
||||
| Server name / repo home | `echo-mcp`; lives in this repo under `mcp-server/` (own Dockerfile), deployed from `git.alwisp.com/jason/echo` CI | One canonical tree — the server vendors `skills/echo-memory/scripts/` into the image at build time; no second hand-maintained copy (the Codex-drift lesson). |
|
||||
| Tool prefix | `echo_` | Namespace safety next to other servers. |
|
||||
| Result shape | `structuredContent` (the `echo_output.envelope` dict) + a 1–3 line human text block | Envelope shape already exists. |
|
||||
| Statefulness | Stateless protocol; warm in-process caches | See §7. |
|
||||
| Version target | **2.2** — ALL prerequisites shipped 2026-07-28 (`session-end` in 2.1.0; **Phase 0 in 2.1.1**, contract-tested by `eval/test_ops_api.py`) | The build session starts directly at §5 (the server app). |
|
||||
| Local stdio variant | **Dropped for v1** (documented, not built) | The CLI/skill fallback covers the no-server case; two transports = two test matrices for little gain. Phase 0 keeps the door open — the `*_op` core is transport-agnostic. |
|
||||
| Result budgets | Every read tool takes an explicit size budget (`budget_chars` / `max_chars`) and truncates with a marker + "call X for more" | The single biggest token lever: one right-sized answer instead of follow-up fetches. |
|
||||
| Tool profiles | `ECHO_MCP_TOOLS=core\|full` env: `core` exposes only load/recall/capture/triage_inbox/log_session/health | Tool schemas cost context on every surface that lists them; the claude.ai connector doesn't need the escape hatches. Desktop registers `full`. |
|
||||
|
||||
## 4. Phase 0 — the return-not-print refactor (prerequisite, lands in the plugin)
|
||||
|
||||
Unchanged from the stdio draft, and still first: the ops layer prints results
|
||||
(even `--json` mode prints from inside `echo_ops.capture`) and returns exit
|
||||
codes; the server needs return values.
|
||||
|
||||
1. Each high-level op gets a **core function returning an envelope dict**,
|
||||
raising `echo.EchoError` on failure; CLI entry points become thin printing
|
||||
wrappers:
|
||||
|
||||
| Module | New core fn | CLI wrapper keeps |
|
||||
|---|---|---|
|
||||
| `echo_ops` | `capture_op`, `resolve_op`, `link_op` | `capture/resolve/link` |
|
||||
| `echo_recall` | `recall_op(query, limit)` | `recall` |
|
||||
| `echo_triage` | `list_op()` · `route_op(proposals, apply)` | `list_inbox/apply` |
|
||||
| `echo_reflect` | `apply_op(proposals, apply)` | `apply` |
|
||||
| `echo.py` | `load_op(brief)` · `scope_show_op/scope_set_op` · `doctor_op` | `cmd_*` |
|
||||
| `echo_session` (new, #7) | `session_end_op(bundle, apply)` | subcommand |
|
||||
|
||||
2. Special results are **data, not exit codes**: duplicate gate ⇒
|
||||
`{ok: false, action: "duplicate-gate", candidates}` (CLI maps to exit 76);
|
||||
offline queueing ⇒ `{ok: true, queued: true}`.
|
||||
3. Helper chatter (`ok: PUT …`) moves behind a `notify(msg)` callback
|
||||
defaulting to stderr; delete the `redirect_stdout` hack in `capture`.
|
||||
4. Tests: existing suites pass unchanged; new unit tests hit `*_op` directly
|
||||
against the mock and assert envelopes.
|
||||
|
||||
Shippable on its own as a 1.6.x refactor with zero behavior change.
|
||||
|
||||
## 5. Server application (`mcp-server/app.py`)
|
||||
|
||||
- **FastMCP** app, streamable HTTP, stateless. One `@mcp.tool` per tool in §6,
|
||||
each a thin adapter: validate → call the `*_op` core → wrap.
|
||||
- **Auth middleware**: require `Authorization: Bearer <ECHO_MCP_TOKEN>` on every
|
||||
request (constant-time compare); 401 with no detail otherwise. Additionally
|
||||
bind the app to the container interface only; exposure policy lives at
|
||||
NPM/Cloudflare.
|
||||
- **Result wrapper**:
|
||||
|
||||
```python
|
||||
def tool_result(env: dict, summary: str) -> ...:
|
||||
# content: [{type: "text", text: summary}] (1–3 lines, human)
|
||||
# structuredContent: env (echo_output envelope)
|
||||
# isError: not env.get("ok", True) — except the deliberate non-errors below
|
||||
```
|
||||
|
||||
- **Error map** (tool-level results, never protocol errors):
|
||||
|
||||
| Condition | Result |
|
||||
|---|---|
|
||||
| Vault unreachable on a **read** | `isError: true` — "vault unreachable (Obsidian/REST plugin likely down); proceed without memory, writes will queue server-side". |
|
||||
| Vault unreachable on a **write** | **Not an error**: `{ok: true, queued: true}` — the server-side outbox (§7) replays when the vault returns. |
|
||||
| 404 | `isError: true`, `{code: "not-found", path}`. |
|
||||
| Duplicate gate | **Not an error**: `{ok: false, action: "duplicate-gate", candidates}` + text naming the two resolutions (`merge_into` / `force`) — the model must decide, not blind-retry. |
|
||||
| Lock held | `{ok: false, action: "lock-held", holder}`. |
|
||||
| Misconfigured deployment (no `ECHO_BASE`/`ECHO_KEY`) | `isError: true` — "server deployment is missing vault credentials — operator: check the PORT template env". Startup also fails loudly (see §8 healthcheck). |
|
||||
| Anything else | `isError: true`, first line only; traceback to the container log. |
|
||||
|
||||
- **Validation**: FastMCP/pydantic handles types/enums/required; add
|
||||
`model_config = ConfigDict(extra="forbid")` so unknown params (model typos)
|
||||
fail with a field-naming message.
|
||||
|
||||
## 6. Tool surface (13 tools)
|
||||
|
||||
Same surface as the stdio draft **minus `echo_configure`** — with a remote
|
||||
server there is no per-machine config to import; credentials are
|
||||
deployment-side. Descriptions are written for the model: what it does, when to
|
||||
use it, what it returns, ≤3 sentences.
|
||||
|
||||
### 6.1 Orientation & read
|
||||
|
||||
- **`echo_load`** — cold-start orientation. `brief: bool = true`
|
||||
(IMPROVEMENT-PLANS #1 digest; `false` = full six-file dump). Returns
|
||||
`{sections: {marker, preferences, scope, last_session, today, inbox_count,
|
||||
inbox_oldest_days}, queued_flushed, offline}`. Also flushes the server-side
|
||||
outbox. `readOnlyHint: false` (flush), `openWorldHint: true`.
|
||||
- **`echo_recall`** — `query: str`, `limit: int = 6 (1–20)`,
|
||||
`include_linked: bool = true`, `budget_chars: int = 4000 (500–20000)`. Returns
|
||||
the `recall --json` shape (`primary` / `linked` with
|
||||
path/score/type/updated/status/excerpt) **packed to the budget**: the server
|
||||
allocates excerpt space by score, so the model gets the most relevant content
|
||||
in one right-sized answer instead of follow-up fetches. Description: truncated
|
||||
hits are marked — call `echo_get_note` for full content.
|
||||
`readOnlyHint: true`, `idempotentHint: true`.
|
||||
- **`echo_resolve`** — `mention: str` → match `{slug, path, kind, title,
|
||||
aliases}` or `{match: false, suggest_slug, candidates}`. "Call before creating
|
||||
any note by hand; `echo_capture` does this automatically." `readOnlyHint: true`.
|
||||
- **`echo_get_note`** — `path: str` (vault-relative; reject `..`/leading `/`;
|
||||
warn-not-block on paths outside `routing.json`); `section: str = null`
|
||||
(a heading name — return only that section, e.g. `Status`);
|
||||
`max_chars: int = 8000`. Returns `{path, content, frontmatter, truncated?}`.
|
||||
`readOnlyHint: true`.
|
||||
- **`echo_get_scope`** — `{scope, scope_updated, sessions_since}`; description
|
||||
tells the model to confirm scope with the operator when `sessions_since ≥ 3`.
|
||||
`readOnlyHint: true`.
|
||||
- **`echo_set_scope`** — `scope: str`; atomic switch (history + replace +
|
||||
stamp). `idempotentHint: true`.
|
||||
- **`echo_health`** — `deep: bool = false`. Shallow = doctor checks (vault
|
||||
reachability, auth, marker/schema, outbox depth). Deep = full `vault_lint`
|
||||
→ `{violations: [{check, path, detail}]}`. `readOnlyHint: true`.
|
||||
|
||||
### 6.2 Write
|
||||
|
||||
- **`echo_capture`** — the default write (route + frontmatter + index +
|
||||
auto-link + agent-log in one call). Params: `title` (req); `kind:
|
||||
enum[person, company, concept, reference, meeting, project, area, semantic,
|
||||
episodic, working, skill, decision]` (omit ⇒ inbox line); `body: str = ""`
|
||||
(markdown, inline); `tags/aliases/sources: str[]`; `status: str`;
|
||||
`date: YYYY-MM-DD` (meeting/decision); `domain: enum[business, personal,
|
||||
learning, systems] = business` (area); `merge_into: str` (slug);
|
||||
`force: bool = false`; `dry_run: bool = false`. Returns the capture envelope
|
||||
(`action: created|updated|inbox|duplicate-gate`, `path`, `links_added`,
|
||||
`near_duplicates?`, `candidates?`). Description spells out the gate contract
|
||||
(never blind-retry; `merge_into` or confirmed `force`). `destructiveHint:
|
||||
false`, `idempotentHint: true`.
|
||||
- **`echo_link`** — `a`, `b` (paths **or resolvable names** — server resolves
|
||||
via the index). Returns `{a, b, a_changed, b_changed}`. `idempotentHint: true`.
|
||||
- **`echo_append_note`** — `path`, `line`; whole-line idempotent append.
|
||||
- **`echo_patch_note`** — `path`, `operation: enum[append, prepend, replace]`,
|
||||
`target_type: enum[heading, frontmatter, block]`, `target`, `content`.
|
||||
Description carries the hard-won rules: heading targets are the full
|
||||
`::`-delimited path from the H1; on a 400 invalid-target the server fetches
|
||||
the document map and returns the actual heading list in the error — the worst
|
||||
silent-loss failure becomes self-correcting. `destructiveHint: true`.
|
||||
(Deliberately **no `echo_put_note` / `echo_delete_note`** in v1 — whole-file
|
||||
overwrite and deletion stay behind the CLI + operator explicitness; session
|
||||
logs go through `echo_log_session`.)
|
||||
- **`echo_triage_inbox`** — `proposals: object[] = []` (PROPOSAL_SCHEMA +
|
||||
optional `line`), `apply: bool = false`. Empty ⇒ structured listing; with
|
||||
proposals ⇒ preview, then route + processing-log audit on `apply: true`.
|
||||
"List → propose → preview → apply only after the operator confirms."
|
||||
- **`echo_reflect`** — `proposals: object[]` (req), `apply: bool = false`. Same
|
||||
preview/apply contract as the CLI; description restates: never apply without
|
||||
the operator's go-ahead, never invent memories.
|
||||
- **`echo_log_session`** — the session-end bundle (wraps `session_end_op`, #7):
|
||||
`slug`, `log_body`, `agent_log_line`, `scope?: str`, `reflect?: object[]`,
|
||||
`apply: bool = false`. Per-step results; heartbeat written last as the commit
|
||||
marker.
|
||||
|
||||
### 6.3 v1.1 tools (specced now, built after v1 ships)
|
||||
|
||||
- **`echo_rollup_data`** — `period: enum[week, month]`, `date: YYYY-MM-DD`.
|
||||
Assembles the rollup *digest data* in one call: open threads across
|
||||
`projects/active/` (each note's `## Status` + `updated:`), inbox items aging
|
||||
past 7 days, and the period's `## Scope History` entries. The model writes
|
||||
the prose; the server does the N fetches. Same division of labor as reflect.
|
||||
`readOnlyHint: true`.
|
||||
- **`echo_note_history`** — `path: str` → `{versions: [{id, ts, bytes,
|
||||
summary_line}]}` from the server's shadow write history (§7). `readOnlyHint:
|
||||
true`.
|
||||
- **`echo_restore_note`** — `path: str`, `version_id: str`,
|
||||
`confirm: bool = false` (preview diff unless confirmed). The undo for a bad
|
||||
`replace` PATCH or merge PUT. Description: operator confirmation required
|
||||
before calling with `confirm: true`. `destructiveHint: true`.
|
||||
|
||||
Deliberately absent from v1 (documented in the server README): sweep,
|
||||
bootstrap, migrate, lock/unlock (vault-wide maintenance stays operator-initiated
|
||||
via slash commands — routine sweeps run as background jobs instead), raw
|
||||
search/ls/map (recall/resolve/get_note cover reads; add only if transcripts
|
||||
show the need), and reflect *extraction* (only the model has the conversation —
|
||||
that division of labor is permanent, not a v1 scope cut).
|
||||
|
||||
## 7. Server internals — where the container earns its keep
|
||||
|
||||
### 7.1 v1
|
||||
|
||||
- **Warm entity index**: loaded once, invalidated on any index-writing tool and
|
||||
on a short TTL (`ECHO_MCP_INDEX_TTL`, default 60s) to pick up other clients'
|
||||
writes. Ends the per-invocation index re-read entirely.
|
||||
- **Recall index in memory (+ SQLite file at `/data`)**: the BM25 index lives
|
||||
in process memory, persisted to the container volume — recall answers in
|
||||
milliseconds with **zero** vault round-trips. This *supersedes the
|
||||
server-side half of IMPROVEMENT-PLANS #2*; #2's local-state-dir design still
|
||||
applies to the CLI fallback path.
|
||||
- **Note read cache**: short-TTL (30–60s) body cache for `get_note` and
|
||||
recall's neighbourhood expansion — repeated same-session reads of the same
|
||||
files stop hitting the vault at all.
|
||||
- **Per-path write serialization**: an internal per-path mutex serializes
|
||||
conflicting MCP-path writes — since all server-mediated writes flow through
|
||||
one process, the advisory-lock race window disappears for them. The server
|
||||
still takes the vault advisory lock around index updates to coordinate with
|
||||
CLI clients; idempotent appends stay as the last line of defense.
|
||||
- **Server-side outbox**: the existing `echo_queue` pointed at `/data`
|
||||
(`ECHO_STATE_DIR=/data`) — writes queue when the vault is down and flush on
|
||||
recovery/load. One queue at the always-on host instead of per-laptop.
|
||||
- **Concurrency**: FastMCP serves requests concurrently; guard the in-process
|
||||
caches with a plain `threading.Lock`; write integrity per the bullet above.
|
||||
- **Logging**: one line per call to stdout (container log): tool, ms,
|
||||
ok/queued/gated/error. Never log bodies or keys.
|
||||
|
||||
### 7.2 v1.1 container dividends (backlog, in priority order)
|
||||
|
||||
- **Nightly vault backup** — the vault currently has **no disaster-recovery
|
||||
story**; if the Obsidian host dies, memory is gone. A timer job walks the
|
||||
vault via `read_many`, writes a dated zip to `/data/backups/` (rotate 30),
|
||||
and reports the last-backup age in `/health`. Zero tokens; arguably the
|
||||
highest-value item in this spec.
|
||||
- **Shadow write history + undo** — before any PUT / PATCH-replace the server
|
||||
passes through, snapshot the prior body to `/data/history/` (content-
|
||||
addressed, per-path ring of ~20 versions). Backs the `echo_note_history` /
|
||||
`echo_restore_note` tools (§6.3). The vault's additive philosophy finally
|
||||
gets an undo for its residual risk: a bad `replace` or merge PUT.
|
||||
- **Background maintenance jobs** — a timer thread running `sweep --fast`
|
||||
(IMPROVEMENT-PLANS #4) hourly and the decay pass (#5) proposals weekly; both
|
||||
publish findings to `/health` and the vault-health note rather than
|
||||
auto-fixing.
|
||||
- **Ops alerting** — outbox stuck > N hours, vault unreachable > 1h, new lint
|
||||
violations after a background sweep ⇒ notify via the existing Unraid
|
||||
notification plumbing (Kuma already watches `/health` for liveness).
|
||||
Problems surface without anyone polling.
|
||||
- **Embeddings (explicitly deferred)**: the container is where an embedding
|
||||
recall tier would land later (model API or local), as an image upgrade —
|
||||
no plugin change. Noted so nobody bolts it into the plugin.
|
||||
|
||||
## 8. Container & deployment
|
||||
|
||||
- **Image**: `python:3.12-slim` (not alpine — no musl surprises), `pip install
|
||||
mcp` pinned, copy `skills/echo-memory/scripts/` + `mcp-server/` in.
|
||||
**Dockerfile must be legacy-format** — the git.alwisp.com CI runner has no
|
||||
BuildKit: no `# syntax=` line, no `RUN --mount`.
|
||||
- **Healthcheck**: probe `http://127.0.0.1:<port>/health` — **127.0.0.1, not
|
||||
localhost** (the ::1-vs-IPv4 lesson from cpas/memer/breedr). `/health`
|
||||
(unauthenticated, no vault data): `{ok, vault_reachable, outbox_depth,
|
||||
index_age_s}` — also the Kuma target.
|
||||
- **Env** (via the PORT template, secrets as `SECRET:` refs): `ECHO_BASE`,
|
||||
`ECHO_KEY`, `ECHO_OWNER`, `ECHO_MCP_TOKEN`, `ECHO_STATE_DIR=/data`,
|
||||
`ECHO_MCP_TOOLS=full` (or `core` — see §3 tool profiles), optional
|
||||
`ECHO_WORKERS/ECHO_TIMEOUT/ECHO_MCP_INDEX_TTL`. Volume: `/data`
|
||||
(outbox + recall index + backups + shadow history).
|
||||
- **Startup validation**: fail fast (non-zero exit) if `ECHO_BASE`/`ECHO_KEY`/
|
||||
`ECHO_MCP_TOKEN` are missing — a misdeployed container should crash-loop
|
||||
visibly, not serve errors.
|
||||
- **CI/deploy**: Gitea workflow on push-to-main (tags/releases do **not**
|
||||
autobuild on this host) builds the image and the PORT autodeploy webhook
|
||||
rolls it out; `deploy.unraid.yml` manifest in `mcp-server/`. Publish
|
||||
`echomcp.alwisp.com` via the usual CF + NPM flow; add the Kuma monitor.
|
||||
- **Vault adjacency (confirmed: Obsidian runs on ALPHA)**: the Local REST
|
||||
API's non-encrypted HTTP binding is enabled (2026-07-28) —
|
||||
**`ECHO_BASE=http://10.2.0.35:27123`** — so the container talks to Obsidian
|
||||
directly, never through the public `echoapi.alwisp.com` hairpin. All vault
|
||||
chatter behind a tool call stays host/LAN-local; vault ops stop depending on
|
||||
Cloudflare/DNS/NPM health entirely (the public chain is only needed for the
|
||||
`echomcp` ingress). Why HTTP not HTTPS here: the REST API's HTTPS port
|
||||
(27124) uses a self-signed cert, which `echo.py`'s default-verifying
|
||||
`HTTPSConnection` rejects — cleartext on the internal network beats adding an
|
||||
insecure-TLS flag to the client. **Exposure check (do once):** 27123 carries
|
||||
the vault bearer token in cleartext, so it must not be reachable from
|
||||
outside — confirm no router/firewall forward for 27123; the only public
|
||||
doors stay HTTPS 443 via NPM (`echoapi` for the CLI fallback path, which
|
||||
remains unchanged, and `echomcp` for this server).
|
||||
|
||||
## 9. Client registration & fallback
|
||||
|
||||
- **Claude Code / CoWork**: register as a remote MCP server —
|
||||
`https://echomcp.alwisp.com/mcp` with the `Authorization: Bearer` header
|
||||
(project or user scope). No plugin-manifest coupling; the plugin does **not**
|
||||
register the server.
|
||||
- **claude.ai**: custom connector with the same URL + token — ECHO memory from
|
||||
the browser/phone, a surface the plugin could never reach.
|
||||
- **SKILL.md** gains: *"When the `echo_*` MCP tools are available, prefer them
|
||||
for every operation they cover; the `$ECHO` CLI recipes are the fallback for
|
||||
hosts without the connector or when the server is unreachable."* Procedures
|
||||
unchanged.
|
||||
- **Fallback matrix**: server up ⇒ tools. Server down, machine has the plugin ⇒
|
||||
CLI path exactly as today (including its own offline queue). Both down ⇒
|
||||
today's "vault unreachable, proceed without memory".
|
||||
|
||||
## 10. Multi-user note (Andy / Bryan / Gretchen / Arsalan)
|
||||
|
||||
One container serves **one vault**. The image is credential-free, so additional
|
||||
users are additional deployments of the same image with their own env (their
|
||||
vault endpoint/key + their own `ECHO_MCP_TOKEN`), e.g. `echomcp-bryan` on
|
||||
another port/subdomain. Cheap, isolated, no code change. A single multi-tenant
|
||||
server (token → vault map) is deliberately out of scope — that's CHORUS's
|
||||
territory.
|
||||
|
||||
## 11. Testing & eval
|
||||
|
||||
1. **Protocol/integration tests** (`eval/test_mcp_server.py`): run the FastMCP
|
||||
app in-process (or via `httpx` test client) against `mock_olrapi`; assert
|
||||
initialize/tools-list schemas, auth (no token ⇒ 401; bad token ⇒ 401;
|
||||
`/health` open), envelope purity on every tool.
|
||||
2. **Tool behavior**: per tool — happy path, validation failure (unknown param
|
||||
rejected with field name), vault-down (writes ⇒ `queued: true`, reads ⇒
|
||||
actionable error), duplicate-gate flow (gate → `merge_into` retry succeeds).
|
||||
3. **Parity**: `capture` via MCP and via CLI against twin mock vaults produce
|
||||
byte-identical notes/index entries (guards Phase 0).
|
||||
4. **Container smoke** (CI): build image, start with mock env, `/health` green,
|
||||
one authed `tools/call` round-trip; healthcheck probes 127.0.0.1.
|
||||
5. **MCP Inspector** manual pass against the deployed container.
|
||||
6. **Eval set** (mcp-builder Phase 4): 10 read-only Q&A pairs against a seeded
|
||||
mock vault exercising recall→get_note→resolve chains; `eval/mcp_eval.xml`.
|
||||
|
||||
## 12. Build-session plan of record
|
||||
|
||||
| Phase | Deliverable | Est. size |
|
||||
|---|---|---|
|
||||
| 0 | Return-not-print refactor in the plugin tree (`*_op` cores); suites green | ~⅓ the session |
|
||||
| 1 | `mcp-server/`: FastMCP app, auth middleware, result wrapper, error map, `/health` | ~200 lines |
|
||||
| 2 | Read tools (load, recall, resolve, get_note, scope×2, health) | wiring |
|
||||
| 3 | Write tools (capture, link, append/patch, triage, reflect, log_session) | wiring + descriptions |
|
||||
| 4 | Warm caches + `/data` outbox + startup validation | small |
|
||||
| 5 | Dockerfile (legacy syntax) + CI workflow + PORT manifest + deploy + CF/NPM publish + Kuma | infra pass |
|
||||
| 6 | Tests (§11) + Inspector pass + eval XML; SKILL.md/README/CHANGELOG | ~⅓ the session |
|
||||
|
||||
Definition of done: Inspector lists 13 tools against `echomcp.alwisp.com`; a
|
||||
live session (desktop **and** CoWork) performs a full memory day — load →
|
||||
recall → capture → gate → merge_into → triage → log_session — without one Bash
|
||||
call; parity test green; container smoke test in CI; Kuma monitor green.
|
||||
@@ -0,0 +1,99 @@
|
||||
# ECHO Memory — Usage Guide
|
||||
|
||||
*Written for Bryan (and anyone else coming back to ECHO after a break). Current as of v1.5.1, July 2026.*
|
||||
|
||||
ECHO gives Claude persistent memory across sessions. Everything durable — people, companies, projects, decisions, preferences, session history — lives as markdown notes in an Obsidian vault, read and written over the Obsidian Local REST API. The plugin ships the whole toolchain (pure Python, stdlib only — works the same on Windows/macOS/Linux) plus the operating procedure Claude follows.
|
||||
|
||||
The short version of how to use it: **you mostly don't have to do anything.** Since v1.5 the system loads itself at session start and nudges itself to save at session end. The slash commands below are for when you want to drive it explicitly.
|
||||
|
||||
---
|
||||
|
||||
## One-time setup
|
||||
|
||||
If Jason handed you a **per-user baked plugin** (`echo-memory-1.x.x-bryan.plugin`), just install it. Your vault credentials are baked into the artifact — no config file, no key pasting, works on desktop and in every CoWork session. This is the normal path.
|
||||
|
||||
If you have the **generic plugin** instead, it will report `NOT CONFIGURED` on first use and ask for your config file. Install it once with:
|
||||
|
||||
```bash
|
||||
python3 echo.py config import <your-config.json>
|
||||
```
|
||||
|
||||
That writes `~/.claude/echo-memory/config.json` (owner, endpoint, API key). It survives plugin updates — one time per machine.
|
||||
|
||||
**Verify either way:** run `/echo-doctor`. Green across the board (Python, vault reachable, auth, bootstrapped, key source) means you're live.
|
||||
|
||||
---
|
||||
|
||||
## Day-to-day usage
|
||||
|
||||
### What happens automatically (v1.5+)
|
||||
|
||||
- **Session start** — a hook runs the cold-start load and injects your memory into context: your profile/preferences, current scope, the last session log, today's journal note, and inbox depth. You don't have to ask Claude to "load memory" anymore.
|
||||
- **Session end** — if the session was substantive and nothing was saved, a hook nudges (once) to run `/echo-reflect` and write the session log. Reflection always previews before writing — nothing lands without your OK.
|
||||
- **If the vault is unreachable**, writes queue to a local outbox and replay automatically next time it's up; reads fall back to a cached last-known-good. An outage degrades gracefully instead of erroring.
|
||||
|
||||
### The slash commands
|
||||
|
||||
| Command | When to use it |
|
||||
|---|---|
|
||||
| `/echo-load` | Manually load memory context. Rarely needed now — the session-start hook does this — but useful mid-session after a lot of vault writes, or if the hook didn't fire. |
|
||||
| `/echo-save <thing>` | Save something right now: "remember the Henagar tower password is in the vault", "log that we decided X". Routes it to the right note automatically. |
|
||||
| `/echo-recall <topic>` | Ask what memory knows about a topic. Returns ranked matches **plus their linked neighbourhood** — a project comes back with its people, decisions, and recent session mentions attached. |
|
||||
| `/echo-reflect` | End-of-session sweep: extracts durable facts, decisions, and new entities from the conversation, shows you the proposed writes, applies on confirm. The main way memory grows. |
|
||||
| `/echo-triage` | Empty the inbox: classifies each quick capture, previews destinations, routes accepted items with an audit trail. Run when load reports the inbox is getting deep. |
|
||||
| `/echo-doctor` | Readiness check — Python, vault reachability, auth, bootstrap/schema version, where the key came from. First stop when anything seems off. |
|
||||
| `/echo-health` | Full vault lint: routing violations, broken wikilinks, orphan notes, stale scope, incomplete frontmatter, index drift. Run occasionally or when things feel inconsistent. |
|
||||
| `/echo-sweep` | Bring the vault up to the current plugin spec after an upgrade — rebuilds the entity index, backfills frontmatter, fixes one-way links. Dry-run by default; **run this once right after updating from an old version.** |
|
||||
|
||||
### A typical session
|
||||
|
||||
1. Start a conversation — memory loads itself. Claude knows who you are, what's active, and what happened last session.
|
||||
2. Work normally. Say "save that" / "remember this" whenever something durable comes up (or `/echo-save` explicitly). Not sure where something belongs? Save it anyway — it lands in the inbox and `/echo-triage` sorts it later.
|
||||
3. At the end, accept the reflection nudge (or run `/echo-reflect` yourself). Confirm the preview. Done — next session picks up from here.
|
||||
|
||||
---
|
||||
|
||||
## What the memory system can do (current capabilities)
|
||||
|
||||
**Smart, deduplicating capture.** One `capture` call routes content to its canonical home via an entity index, stamps complete frontmatter (status, tags, timestamps), cross-links every entity it mentions, and logs the write. Names resolve through aliases and fuzzy matching, so "echo memory" finds the `echo` project instead of spawning a duplicate note. If a new title strongly resembles an existing **same-kind** entity, a pre-write duplicate gate *stops* the write and shows you the candidates — you choose merge or create. Duplicates get blocked before they exist, not cleaned up after.
|
||||
|
||||
**Real retrieval, not keyword grep.** Recall fuses BM25 full-text search with graph expansion along note links, over entities *and* session logs *and* journal entries. Ranking is freshness- and status-aware: recently updated and `active` notes float up, `archived` sinks. You get a topic's connected neighbourhood, not one isolated file.
|
||||
|
||||
**Structured memory model.** Working (transient) / episodic (what happened, when) / semantic (durable facts and preferences) memory layers, plus a current-context scope, per-session logs, an append-only journal with rollups, and PARA-style projects/areas/resources/decisions. A machine-readable routing manifest defines what may be written where, and the linter enforces it.
|
||||
|
||||
**Reflection.** `/echo-reflect` turns a conversation into memory: extract → classify → dedup against the index → confidence-filter → preview → apply. Low-confidence items go to the inbox instead of polluting the graph.
|
||||
|
||||
**Durability and safety.** Every write is HTTP-status-checked and read-back-verified (a failed write fails loudly, never silently). Appends are idempotent — retries can't double-write. Offline writes queue and replay. A cooperative advisory lock keeps Claude Code and CoWork from trampling each other on the shared vault.
|
||||
|
||||
**Self-maintaining.** The vault bootstraps itself from an empty Obsidian vault, migrates its own schema on version bumps, and `/echo-sweep` + `/echo-health` keep the graph honest (index rebuilds, link symmetry, frontmatter backfill, orphan/broken-link detection).
|
||||
|
||||
**Fast.** Connection pooling + concurrent reads mean full-vault operations that used to time out in the sandbox now finish in under a second.
|
||||
|
||||
---
|
||||
|
||||
## What's new since 0.7 (your last consistent version)
|
||||
|
||||
You left off right after the toolchain got its executable spine (`echo.py`, routing manifest, linter). Since then, in rough order of what you'll actually notice:
|
||||
|
||||
- **You don't route anything by hand anymore.** 0.7 was "pick the path, write with the right verb." Now `capture`/`recall`/`resolve`/`link` do routing, frontmatter, indexing, and cross-linking in one call — and in practice you just talk to Claude and it uses them.
|
||||
- **Memory loads and saves itself** (v1.5 hooks). No more remembering to run the loading procedure or write the session log.
|
||||
- **Recall is actually good now** (v0.9–1.5): entity index + hybrid BM25/graph search + freshness/status ranking, spanning sessions and journal too.
|
||||
- **Duplicates get blocked at write time** (v1.5 gate) instead of accumulating.
|
||||
- **Offline resilience** (v1.0): vault down ≠ data lost; writes queue and replay.
|
||||
- **Everything is pure Python** (v0.8): no bash, works identically on Windows.
|
||||
- **Credentials moved out of the plugin** (v1.3–1.4): generic builds prompt once per machine; your baked build carries them invisibly. Treat a baked `.plugin` file like a password — it contains your vault key. Don't share or commit it.
|
||||
|
||||
**After installing the new version, run `/echo-doctor`, then `/echo-sweep` once** to bring your vault up to the current schema (it dry-runs first and shows the plan).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| `NOT CONFIGURED` banner / doctor shows red config | Generic build with no config on this machine — `echo.py config import <file>`, or ask Jason for your baked build. |
|
||||
| Vault unreachable | Check that Obsidian + the Local REST API plugin are running on the backend and the endpoint is reachable. Meanwhile writes queue safely and replay on reconnect. |
|
||||
| Save blocked with "duplicate gate" (exit 76) | Not an error — it found a likely-existing entity. Review the candidates: merge into the existing note, or force-create if it's genuinely distinct. |
|
||||
| Memory feels stale or inconsistent | `/echo-health` to see what's off, `/echo-sweep` to repair index/links/frontmatter. |
|
||||
| Inbox keeps getting mentioned at load | `/echo-triage` — one pass empties it. |
|
||||
| Anything else weird | `/echo-doctor` first, then ask Jason. |
|
||||
@@ -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 (~L161–173), 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 |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -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.
@@ -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.
Vendored
BIN
Binary file not shown.
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "echo-memory",
|
||||
"version": "1.2.0",
|
||||
"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.",
|
||||
"version": "2.1.1",
|
||||
"homepage": "https://git.alwisp.com/jason/echo",
|
||||
"repository": "https://git.alwisp.com/jason/echo",
|
||||
"description": "Persistent memory via the ECHO Obsidian vault over the Obsidian Local REST API. Cross-platform Python client: one-call capture/resolve/recall/link/triage over an entity index, hybrid BM25 + graph recall spanning entities + sessions/journal (recency/status-aware), a pre-write duplicate gate, complete-frontmatter capture, session hooks that self-fire load/reflect, offline write-ahead queue, lock-guarded concurrency, linter-enforced routing, and /echo-* commands.",
|
||||
"author": {
|
||||
"name": "Jason"
|
||||
"name": "Jason Stedwell"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"keywords": [
|
||||
|
||||
@@ -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).
|
||||
|
||||
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.
|
||||
|
||||
## What it does
|
||||
|
||||
- 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
|
||||
- **`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
|
||||
- **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`) — 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
|
||||
- **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
|
||||
|
||||
The plugin is hardcoded for:
|
||||
The plugin ships **no** owner, endpoint, or key. On each machine it installs on, provide a machine-local JSON config:
|
||||
|
||||
- **Server:** `https://echoapi.alwisp.com` (reverse proxy → backend Obsidian Local REST API)
|
||||
- **API Key:** stored in the skill (personal plugin, not for distribution)
|
||||
```
|
||||
~/.claude/echo-memory/config.json (honors $CLAUDE_CONFIG_DIR; file path overridable with $ECHO_CONFIG)
|
||||
{
|
||||
"owner": "Full Name — how the agent refers to the vault owner (third person)",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>"
|
||||
}
|
||||
```
|
||||
|
||||
The endpoint presents a **valid TLS certificate**, so `-k` is not required. The bearer key lives only in the plugin — never inside the vault (per the vault's own safety rules).
|
||||
Set it up on a fresh install by copying the bundled template and filling it in, or via the client:
|
||||
|
||||
```bash
|
||||
python3 skills/echo-memory/scripts/echo.py config init # writes the template if absent → edit it
|
||||
python3 skills/echo-memory/scripts/echo.py config set --owner "…" --endpoint "https://…" --key "…"
|
||||
python3 skills/echo-memory/scripts/echo.py config # show resolved config (key redacted) + source
|
||||
```
|
||||
|
||||
Per field, an environment variable overrides the file: `ECHO_OWNER`, `ECHO_BASE` (endpoint), `ECHO_KEY`. `config init` writes the template (shown above) when the file is absent; a copy also ships at the repo root (`echo-memory.config.template.json`). The config is **never committed** and **never written into a vault note**; the bearer key stays out of the plugin tree entirely.
|
||||
|
||||
## Vault layout (root-addressed)
|
||||
|
||||
@@ -51,7 +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:
|
||||
|
||||
```
|
||||
commands/ ← slash commands: echo-load, echo-save, echo-recall, echo-triage, echo-health, echo-sweep
|
||||
commands/ ← slash commands: echo-load, echo-save, echo-recall, echo-triage, echo-health, echo-sweep, echo-reflect, echo-doctor
|
||||
skills/echo-memory/
|
||||
├── SKILL.md ← operating procedure (authoritative)
|
||||
├── references/
|
||||
@@ -62,7 +79,8 @@ skills/echo-memory/
|
||||
│ ├── api-reference.md ← REST endpoint patterns + routing map
|
||||
│ └── session-log-template.md
|
||||
├── scripts/ ← pure Python; run with python3 (Windows: python / py -3)
|
||||
│ ├── echo.py ← validated client + high-level CLI (capture/resolve/recall/link/load/scope/lock)
|
||||
│ ├── echo.py ← validated client + high-level CLI (capture/resolve/recall/link/load/scope/lock/config)
|
||||
│ ├── echo_config.py ← resolves owner/endpoint/key from the machine-local config (env-overridable)
|
||||
│ ├── echo_index.py ← entity index (registry, resolve, name→path map)
|
||||
│ ├── echo_links.py ← cross-link primitives (Related parsing, bidirectional linking)
|
||||
│ ├── echo_ops.py ← high-level ops (capture, recall, resolve, link, agent-log)
|
||||
@@ -93,9 +111,12 @@ skills/echo-memory/
|
||||
| `/echo-triage` | Drain aging inbox captures to their homes, logging each move |
|
||||
| `/echo-health` | Run `vault_lint.py` and summarize invariant + graph-health violations |
|
||||
| `/echo-sweep` | Bring the vault up to current spec (build index + symmetrize links) |
|
||||
| `/echo-reflect` | Extract durable items from the session, preview, and apply on approval |
|
||||
| `/echo-doctor` | Readiness check: config, endpoint reachability, auth, bootstrap/schema |
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3** available in the session environment (the scripts use only the standard library; invoke as `python3`, or `python` / `py -3` on Windows)
|
||||
- Obsidian running on the backend with the [Local REST API plugin](https://github.com/coddingtonbear/obsidian-local-rest-api) enabled
|
||||
- HTTPS access to `https://echoapi.alwisp.com` from the Claude/CoWork session environment
|
||||
- HTTPS access from the Claude/CoWork session environment to the endpoint configured in `~/.claude/echo-memory/config.json`
|
||||
- A machine-local config (`~/.claude/echo-memory/config.json`) supplying the vault owner, endpoint, and API key — see **Configuration**
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
description: Check ECHO readiness — Python, vault reachability, auth, bootstrap/schema, and key source
|
||||
---
|
||||
|
||||
Use the **echo-memory** skill to run a one-call readiness check before relying on memory.
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" doctor
|
||||
# Windows: use `python` or `py -3` if `python3` is not on PATH.
|
||||
```
|
||||
|
||||
It prints green/red for: Python version, vault reachability, auth accepted, vault
|
||||
bootstrapped (+ `schema_version`), and the **API key source** (`ECHO_KEY` env / credentials
|
||||
file / deprecated baked-in fallback — see `API-KEY-SETUP.md`). Exits non-zero if anything is
|
||||
red. For the full vault-invariant lint, run `/echo-health`.
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
description: Run the ECHO vault-health linter and summarize any invariant violations
|
||||
allowed-tools: Bash(python3 */echo-memory/scripts/vault_lint.py*), Bash(python */echo-memory/scripts/vault_lint.py*)
|
||||
---
|
||||
|
||||
Run the bundled, read-only vault linter and report findings. Pass the conversation's current date (`ECHO_TODAY`) so stale/aging math uses the same clock the agent writes with:
|
||||
|
||||
```bash
|
||||
ECHO_TODAY=<currentDate> python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/vault_lint.py"
|
||||
# Windows: use `python` or `py -3` if `python3` is not on PATH.
|
||||
```
|
||||
|
||||
Exit codes: `0` clean · `1` violations (printed, grouped by check) · `2` vault unreachable · `3` vault not bootstrapped (run `bootstrap.py`).
|
||||
|
||||
Summarize the violations grouped by category and propose fixes, but **do not auto-fix** without Jason's go-ahead. If this is the first substantive session of a calendar month, offer to write the findings to `_agent/health/YYYY-MM-vault-health.md` (per the skill's **Vault Health** section).
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
description: Recall a topic from ECHO memory — matching notes plus their linked neighbourhood
|
||||
argument-hint: "[topic, person, or project]"
|
||||
---
|
||||
|
||||
Use the **echo-memory** skill to recall context for:
|
||||
|
||||
> $ARGUMENTS
|
||||
|
||||
Run the one-call recall — it resolves the term against the entity index, searches, and expands one hop along `## Related` links and `source_notes`, so you get the connected web (linked decisions, people, prior sessions), not an isolated note:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" recall "$ARGUMENTS"
|
||||
# Windows: use `python` or `py -3` if `python3` is not on PATH.
|
||||
```
|
||||
|
||||
Then answer from the assembled context. If you only need the canonical path for one entity, use `echo.py resolve "<title>"` instead. Don't narrate the retrieval.
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
description: Save to ECHO memory — route content to its canonical home (search-first, idempotent)
|
||||
argument-hint: "[what to remember]"
|
||||
---
|
||||
|
||||
Use the **echo-memory** skill to persist this to the ECHO vault:
|
||||
|
||||
> $ARGUMENTS
|
||||
|
||||
Prefer the one-call **`capture`** — it routes (via the entity index), derives the canonical path, stamps frontmatter, indexes, auto-links mentioned entities, and writes the Agent-Log line. Pick the `--kind` from the content (`person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision`); use `--inbox` only when the home is genuinely unknown. Write multi-line bodies to a file with the Write tool (cross-platform, no heredoc).
|
||||
|
||||
```bash
|
||||
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" # run with: python3 "$ECHO" ... (Windows: python / py -3)
|
||||
python3 "$ECHO" capture "<title>" <bodyfile> --kind <kind> [--aliases a,b] [--source p1,p2]
|
||||
python3 "$ECHO" capture "<title>" --inbox # unknown home -> idempotent inbox line
|
||||
```
|
||||
|
||||
Drop to the low-level verbs only for shapes `capture` doesn't model (a project `## Status` replace, an ADR mirror, a daily-note edit):
|
||||
|
||||
```bash
|
||||
python3 "$ECHO" put <routed/path>.md <bodyfile> # create/overwrite (verifies)
|
||||
python3 "$ECHO" patch <path>.md append heading "<H1::Sub>" <bodyfile> # targeted append
|
||||
```
|
||||
|
||||
Write in third person about Jason; never put `[[wikilinks]]` in frontmatter. `capture` handles `agent_written`, `source_notes`, indexing, and linking for you; if you write by hand, `resolve "<title>"` first to avoid duplicates and `bump` `updated:` only on substantive changes.
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
description: Triage the ECHO inbox — route aging captures to their canonical homes and log the moves
|
||||
---
|
||||
|
||||
Use the **echo-memory** skill to run **Inbox Triage**. GET `inbox/captures/inbox.md`, list captures older than ~7 days that were never routed, and offer to route them.
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" get inbox/captures/inbox.md
|
||||
# Windows: use `python` or `py -3` if `python3` is not on PATH.
|
||||
```
|
||||
|
||||
For each capture Jason accepts: send it to its proper home per the routing map (preference → `operator-preferences.md::Observations`; project idea → `projects/incubating/`; durable fact → `_agent/memory/semantic/`; person → `resources/people/`), then record the move in `inbox/processing-log/YYYY-MM-DD.md` (`- <original> → <destination>`). Do **not** delete the original capture unless Jason explicitly asks — the processing log is the audit trail.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: echo-doctor
|
||||
description: Check ECHO readiness — Python, vault reachability, auth, bootstrap/schema, and key source
|
||||
allowed-tools: Bash(python3 *echo.py*), Bash(python *echo.py*), Bash(py -3 *echo.py*), Bash(ls /sessions/*)
|
||||
---
|
||||
|
||||
Use the **echo-memory** skill to run a one-call readiness check before relying on memory.
|
||||
|
||||
```bash
|
||||
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py"
|
||||
[ -f "$ECHO" ] || ECHO=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo.py 2>/dev/null | head -1) # CoWork sandbox fallback
|
||||
python3 "$ECHO" doctor
|
||||
# Windows: use `python` or `py -3` if `python3` is not on PATH.
|
||||
```
|
||||
|
||||
It prints green/red for: Python version, vault reachability, auth accepted, vault
|
||||
bootstrapped (+ `schema_version`), and the **config source** for owner/endpoint/key
|
||||
(per-field: env override `ECHO_OWNER`/`ECHO_BASE`/`ECHO_KEY`, then the machine-local
|
||||
`~/.claude/echo-memory/config.json` — or `baked-in (plugin)` on a per-user baked build,
|
||||
which is authoritative and reported as such). Exits non-zero if anything is red — if the config is
|
||||
missing (or still the placeholder template), ask the operator for their echo-memory config file and install it with `echo.py config import <path>` (or `config set --owner … --endpoint … --key …`). For the full vault-invariant lint, run
|
||||
`/echo-health`.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: echo-health
|
||||
description: Run the ECHO vault-health linter and summarize any invariant violations
|
||||
allowed-tools: Bash(python3 *vault_lint.py*), Bash(python *vault_lint.py*), Bash(py -3 *vault_lint.py*), Bash(ls /sessions/*), Bash(dirname *)
|
||||
---
|
||||
|
||||
Run the bundled, read-only vault linter and report findings. Pass the conversation's current date (`ECHO_TODAY`) so stale/aging math uses the same clock the agent writes with:
|
||||
|
||||
```bash
|
||||
SDIR="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts"
|
||||
[ -d "$SDIR" ] || SDIR=$(dirname "$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/vault_lint.py 2>/dev/null | head -1)") # CoWork sandbox fallback
|
||||
ECHO_TODAY=<currentDate> python3 "$SDIR/vault_lint.py"
|
||||
# Windows: use `python` or `py -3` if `python3` is not on PATH.
|
||||
```
|
||||
|
||||
Exit codes: `0` clean · `1` violations (printed, grouped by check) · `2` vault unreachable · `3` vault not bootstrapped (run `bootstrap.py`).
|
||||
|
||||
Summarize the violations grouped by category and propose fixes, but **do not auto-fix** without the operator's go-ahead. If this is the first substantive session of a calendar month, offer to write the findings to `_agent/health/YYYY-MM-vault-health.md` (per the skill's **Vault Health** section).
|
||||
+7
-1
@@ -1,14 +1,20 @@
|
||||
---
|
||||
name: echo-load
|
||||
description: Load ECHO memory — cold-start context read (profile, scope, latest session, today, inbox)
|
||||
allowed-tools: Bash(python3 *echo.py*), Bash(python *echo.py*), Bash(py -3 *echo.py*), Bash(ls /sessions/*)
|
||||
---
|
||||
|
||||
Use the **echo-memory** skill to load memory now. Run the cold-start **Loading procedure** from `SKILL.md`: the six orientation reads (marker, operator-preferences, current-context, heartbeat, today's daily note, inbox) in **one call**, then do the load-time **reconcile** (inbox-depth + scope-drift) and surface it in a single line.
|
||||
|
||||
```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.
|
||||
```
|
||||
|
||||
`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.
|
||||
@@ -1,31 +1,40 @@
|
||||
---
|
||||
name: echo-memory
|
||||
description: Use the ECHO Obsidian vault as Jason's persistent memory across Claude/CoWork sessions. Use whenever Jason asks to remember, save, note, log, or capture anything durable — facts, preferences, decisions, schedule changes, commitments — or asks what Claude knows about him, what was discussed or decided before, to check his notes, load his memory/profile/context, add to his inbox, or to track a new project. Trigger even without memory phrasing when the request implies recalling or persisting state across sessions ("pick up where we left off", "anything on X before my meeting?"). Also use proactively at the start of substantive work sessions to load context and at the end to log outcomes. Do NOT use for Bryan's goldbrain vault, Obsidian/REST-API development questions, "memory" meaning RAM, timed reminders, email or local-file lookups, generic second-brain advice, or notes Jason routes to a specific other file or app.
|
||||
description: Use the ECHO Obsidian vault as the operator's persistent memory across Claude/CoWork sessions. Use whenever the operator asks to remember, save, note, log, or capture anything durable — facts, preferences, decisions, schedule changes, commitments — or asks what Claude knows about them, what was discussed or decided before, to check their notes, load their memory/profile/context, add to their inbox, or to track a new project. Trigger even without memory phrasing when the request implies recalling or persisting state across sessions ("pick up where we left off", "anything on X before my meeting?"). Also use proactively at the start of substantive work sessions to load context and at the end to log outcomes. Do NOT use for a different owner's vault, Obsidian/REST-API development questions, "memory" meaning RAM, timed reminders, email or local-file lookups, generic second-brain advice, or notes the operator routes to a specific other file or app.
|
||||
---
|
||||
|
||||
# ECHO Memory
|
||||
|
||||
Use the **ECHO** Obsidian vault as persistent memory. Read context accumulated across sessions; write things the operator asks to be remembered.
|
||||
|
||||
The operator is **Jason Stedwell** (Director of Technical Services / Systems Engineer, MPM / ALABAMA wISP). Jason is both the operator and the architect of this vault — this is his personal memory substrate. Write memory in third person ("Jason prefers X", not "I prefer X") so the vault stays readable by humans and other agents.
|
||||
The vault belongs to a single **owner**, configured per machine (the `owner` field in `~/.claude/echo-memory/config.json`) — this is that person's personal memory substrate. Write memory in third person about the owner ("the operator prefers X", not "I prefer X") so the vault stays readable by humans and other agents.
|
||||
|
||||
## API Configuration
|
||||
|
||||
All calls use these constants. The endpoint is fixed; the key is resolved by `echo.py`
|
||||
(via `echo_secrets`) in order: **`ECHO_KEY` env → `~/.echo-memory/credentials` → a
|
||||
deprecated baked-in fallback.** Never paste the key into a vault note or a doc.
|
||||
The owner, endpoint, and API key are **machine-local** — the 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.
|
||||
|
||||
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
|
||||
OBSIDIAN_KEY = <resolved at runtime from ECHO_KEY / credentials file — not stored here>
|
||||
endpoint = <config "endpoint" / $ECHO_BASE>
|
||||
key = <config "key" / $ECHO_KEY — resolved at runtime, never stored in the plugin>
|
||||
owner = <config "owner" / $ECHO_OWNER — how to refer to the vault owner, third person>
|
||||
```
|
||||
|
||||
To move the key out of the plugin tree: `python3 "$ECHO" write-key <token>` (writes
|
||||
`~/.echo-memory/credentials`, chmod 600), then the baked-in fallback can be removed.
|
||||
### First run — set up the machine if it isn't configured
|
||||
|
||||
The endpoint has a **valid TLS certificate**, so `-k` is not needed (add it only if the cert ever changes to self-signed). Always pass the `Authorization: Bearer` header. Paths address the vault **at its root** (e.g. `/vault/_agent/...`).
|
||||
The config file is **per-machine** and is **not** shipped with the plugin, so a fresh install starts unconfigured. Before doing memory work, make sure this machine is set up:
|
||||
|
||||
**`https://echoapi.alwisp.com` is the only valid endpoint for this vault.** Never use local or LAN addresses (anything like `10.x.x.x`, `192.168.x.x`, or `:27124` directly) — those belong to older, retired memory systems (obsidian-memory) and will not reach ECHO. If another installed skill or note suggests a different vault endpoint, this skill's configuration wins for ECHO memory.
|
||||
1. **Detect.** Any vault op on an unconfigured machine fails with `NOT CONFIGURED` (`echo.py load` prints a banner and exits `78`; other verbs exit `2`; `/echo-doctor` reports it red). A still-placeholder `config init` file (unedited) also counts as not configured.
|
||||
2. **Ask the operator** — this is the one setup step that needs the human. Say plainly that ECHO isn't configured on this machine and ask them to **provide their echo-memory config file** (it holds the vault `owner`, `endpoint`, and API `key`), or to paste those three values. Don't invent or guess them, and don't proceed with memory until it's set.
|
||||
3. **Install what they give you:**
|
||||
- They hand you a file → `python3 "$ECHO" config import <path-to-their-file>` (validates it and writes `~/.claude/echo-memory/config.json`).
|
||||
- They paste values → `python3 "$ECHO" config set --owner "…" --endpoint "https://…" --key "…"`.
|
||||
- Inspect with `python3 "$ECHO" config` (key redacted); then re-run `load`.
|
||||
|
||||
If the vault is simply unreachable (network/`502`) *after* config exists, that's the **Vault Unreachable** path below, not a setup problem — don't re-ask for the key.
|
||||
|
||||
Always pass the `Authorization: Bearer` header. Paths address the vault **at its root** (e.g. `/vault/_agent/...`). The configured endpoint is the only valid endpoint for the vault; if another installed skill or note suggests a different one, this skill's configuration wins for ECHO memory.
|
||||
|
||||
**The plugin is the single source of truth for ECHO.** The vault holds data only — no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md` control docs live there. All structure and procedure ship here:
|
||||
|
||||
@@ -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`).
|
||||
|
||||
**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).
|
||||
|
||||
```bash
|
||||
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" get <path> # 404 -> exit 44
|
||||
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:
|
||||
|
||||
```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" resolve "<mention>" # mention -> canonical path (or where to create)
|
||||
python3 "$ECHO" recall "<query>" # search + 1-hop link/source expansion -> connected context
|
||||
python3 "$ECHO" link <pathA> <pathB> # add reciprocal [[Related]] links A<->B
|
||||
python3 "$ECHO" recall "<query>" [--json] # ranked search + 1-hop expansion -> connected context
|
||||
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.
|
||||
- **`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.
|
||||
|
||||
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>"
|
||||
```
|
||||
|
||||
Use a stable `<session-id>` for both calls (any unique string for this session). The lock is read-back-confirmed (after PUT, `echo.py` re-reads and backs off if another writer won the race) and cooperative (a stale lock past `ECHO_LOCK_TTL`, default 15 min, is reclaimable); it lives at `_agent/locks/vault.lock`. It is a courtesy, not a hard mutex — if you can't take it, tell Jason another session may be active rather than racing it.
|
||||
Use a stable `<session-id>` for both calls (any unique string for this session). The lock is read-back-confirmed (after PUT, `echo.py` re-reads and backs off if another writer won the race) and cooperative (a stale lock past `ECHO_LOCK_TTL`, default 15 min, is reclaimable); it lives at `_agent/locks/vault.lock`. It is a courtesy, not a hard mutex — if you can't take it, tell the operator another session may be active rather than racing it.
|
||||
|
||||
### Slash commands
|
||||
|
||||
`/echo-load` (cold-start read), `/echo-save <text>` (route + persist via `capture`), `/echo-recall <query>` (recall a topic's neighbourhood), `/echo-triage` (drain the inbox), `/echo-health` (run the linter), `/echo-sweep` (bring the vault up to current spec). These are explicit entry points to the procedures below.
|
||||
`/echo-load` (cold-start read), `/echo-save <text>` (route + persist via `capture`), `/echo-recall <query>` (recall a topic's neighbourhood), `/echo-triage` (drain the inbox), `/echo-health` (run the linter), `/echo-sweep` (bring the vault up to current spec), `/echo-reflect` (extract→preview→apply durable items from the session), `/echo-doctor` (readiness check: config, reachability, auth, bootstrap/schema). These are explicit entry points to the procedures below.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -113,16 +136,16 @@ Full contract (principles, agent role, memory model): `references/operating-cont
|
||||
|
||||
## When to Load Memory
|
||||
|
||||
Load at the start of any substantive conversation — anything beyond a single quick factual question. The signal: Jason is starting work, planning, asking for help with something that has state, or referencing prior discussions.
|
||||
Load at the start of any substantive conversation — anything beyond a single quick factual question. The signal: the operator is starting work, planning, asking for help with something that has state, or referencing prior discussions.
|
||||
|
||||
### Loading procedure
|
||||
|
||||
**Run `python3 "$ECHO" load`** — one call that performs the six orientation reads below and prints each labeled section (404s on today's note / inbox show as absent, not errors; an absent marker is flagged). This replaces issuing six separate GETs by hand. The table documents what `load` reads and why:
|
||||
**Run `python3 "$ECHO" load`** — one call that performs the six orientation reads below (fetched in parallel) and prints each labeled section (404s on today's note / inbox show as absent, not errors; an absent marker is flagged). This replaces issuing six separate GETs by hand. **`load --brief`** renders a token-budgeted digest instead — Fact/Pattern rules in full, the last ~10 Observations, scope + freshness, the last session's key sections, today's Agent Log lines, and the inbox as a *count* (`ECHO_LOAD_BUDGET`, default ~8000 chars, trims lowest-priority sections first). The SessionStart hook injects the brief form; `/echo-load` and this procedure use the full form. The table documents what `load` reads and why:
|
||||
|
||||
| # | GET | Notes |
|
||||
|---|-----|-------|
|
||||
| 1 | `/vault/_agent/echo-vault.md` | The bootstrap marker. 404 → vault not set up; follow `references/bootstrap.md`. 200 → check its `schema_version` and migrate if older. |
|
||||
| 2 | `/vault/_agent/memory/semantic/operator-preferences.md` | Jason's profile |
|
||||
| 2 | `/vault/_agent/memory/semantic/operator-preferences.md` | the operator's profile |
|
||||
| 3 | `/vault/_agent/context/current-context.md` | Active scope + Scope History |
|
||||
| 4 | `/vault/_agent/heartbeat/last-session.md` → then `/vault/_agent/sessions/` | **Read the heartbeat first** — a one-line pointer (`<session-log-path> @ <ISO-timestamp>`) written at the end of the previous session. It's an O(1) jump to the latest log, so you can skip or shortcut the full listing. Fall back to the `sessions/` listing only if the pointer is missing or looks stale; then pick the ~5 most recent by reverse lex sort (filenames `YYYY-MM-DD-HHMM-<slug>.md`, so lex == chrono) and read only the relevant ones. |
|
||||
| 5 | `/vault/journal/daily/YYYY-MM-DD.md` | Today's note; 404 is fine — it's created on first agent activity |
|
||||
@@ -133,9 +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:
|
||||
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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.
|
||||
|
||||
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?"
|
||||
|
||||
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`
|
||||
- Project idea → PUT `projects/incubating/<slug>.md`
|
||||
- Durable fact → PUT `_agent/memory/semantic/<slug>.md`
|
||||
- Person fact → PUT/PATCH `resources/people/<name>.md`
|
||||
```bash
|
||||
python3 "$ECHO" triage --list --json # structured captures: {line, date, text, age_days}
|
||||
# decide a kind/title per accepted item, write a proposals JSON (reflect schema +
|
||||
# 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
|
||||
|
||||
@@ -181,14 +207,14 @@ Projects move through four folders under `projects/`. The folder name and the `s
|
||||
|
||||
## When to Write Memory
|
||||
|
||||
Write when Jason:
|
||||
Write when the operator:
|
||||
|
||||
- States a fact, preference, or commitment worth keeping ("I prefer X", "we use uv not pip", "standup is Tuesday at 10")
|
||||
- Makes a non-obvious decision worth recording
|
||||
- Says "remember that", "save this", "log this", "add to memory", "note that"
|
||||
- Finishes a meaningful working session future sessions should pick up
|
||||
|
||||
Write in third person about Jason. Every note carries the canonical frontmatter (see below). Agent-generated notes set `agent_written: true`.
|
||||
Write in third person about the operator. Every note carries the canonical frontmatter (see below). Agent-generated notes set `agent_written: true`.
|
||||
|
||||
**Default write path: `capture`.** For a routed memory (a fact about a person/company/project/concept/etc.), prefer `python3 "$ECHO" capture "<title>" <bodyfile> --kind <kind>` — it does the search-first, path derivation, frontmatter, indexing, auto-linking, and Agent-Log line in one call (see **High-level memory ops**). Drop to the lower-level `put`/`patch`/`append` verbs below only for shapes `capture` doesn't model (a project `## Status` replacement, an ADR mirror, a daily-note edit) or to inspect/repair.
|
||||
|
||||
@@ -204,9 +230,9 @@ python3 "$ECHO" search <slug> # fallback when the index isn't trusted
|
||||
If a match is found:
|
||||
- In a non-active project subfolder (`on-hold/`, `incubating/`, `archived/`): **promote/merge** — PUT the merged content to `projects/active/<slug>.md` with `status: active`, then DELETE the old location. Preserve the earliest `created:` date.
|
||||
- In the same folder you intended to write: **update in place** (PATCH or merged PUT). Never silently overwrite — fold the existing content in first.
|
||||
- Elsewhere (e.g. a stale duplicate under `resources/`): tell Jason and ask which should be canonical before writing.
|
||||
- Elsewhere (e.g. a stale duplicate under `resources/`): tell the operator and ask which should be canonical before writing.
|
||||
|
||||
**Search both the slug AND any human title** Jason used (e.g. slug `echo-memory` and title `ECHO plugin`). Slug-only searches miss notes filed under a different naming scheme. Two cheap `POST /search/simple/?query=...` calls beat one expensive cleanup pass later.
|
||||
**Search both the slug AND any human title** the operator used (e.g. slug `echo-memory` and title `ECHO plugin`). Slug-only searches miss notes filed under a different naming scheme. Two cheap `POST /search/simple/?query=...` calls beat one expensive cleanup pass later.
|
||||
|
||||
Only after the search comes back empty (or you've decided to merge) is it safe to create a new note. This rule prevents the most common duplication bug: a note exists in `on-hold/` but the agent only checked `active/` and created a parallel record.
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
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`)
|
||||
|
||||
`_agent/context/current-context.md` tracks a single active scope. Jason routinely shifts scope within a day (echo plugin → MPM brand → WISP docs), so this is high-churn — switch deliberately, every time the work changes topic.
|
||||
`_agent/context/current-context.md` tracks a single active scope. The operator routinely shifts scope within a day (one project → another → docs), so this is high-churn — switch deliberately, every time the work changes topic.
|
||||
|
||||
**Preferred — one command:**
|
||||
|
||||
@@ -278,12 +307,14 @@ A weekly/monthly rollup is a **light digest** — open threads across `projects/
|
||||
|
||||
## Vault Health (monthly)
|
||||
|
||||
On the first substantive session of a calendar month, run a quick health pass and write findings to `_agent/health/YYYY-MM-vault-health.md`. This is **agent self-maintenance, not a journal entry** — it lives under `_agent/` because it's about the vault's mechanical integrity, not Jason's work narrative. Don't auto-fix without asking.
|
||||
On the first substantive session of a calendar month, run a quick health pass and write findings to `_agent/health/YYYY-MM-vault-health.md`. This is **agent self-maintenance, not a journal entry** — it lives under `_agent/` because it's about the vault's mechanical integrity, not the operator's work narrative. Don't auto-fix without asking.
|
||||
|
||||
Run the bundled linter first — it mechanically checks the invariants below so you don't eyeball them. **Pass `ECHO_TODAY` = the conversation's `currentDate`** so stale/aging math uses the same clock you write with (not the runner's machine date):
|
||||
|
||||
```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):
|
||||
@@ -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).
|
||||
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`).
|
||||
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)
|
||||
|
||||
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
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py" # plan (read-only)
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py" --apply # write
|
||||
SWEEP="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py"
|
||||
[ -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.
|
||||
|
||||
The pass is cheap and pays for itself by catching drift before it requires a reorg. Write the findings as a digest; act on them only with Jason's go-ahead.
|
||||
The pass is cheap and pays for itself by catching drift before it requires a reorg. Write the findings as a digest; act on them only with the operator's go-ahead.
|
||||
|
||||
**Performance.** Full-vault scripts (`sweep.py`, `vault_lint.py`, recall rebuild) are connection-pooled (keep-alive) and read the whole vault concurrently via `echo.read_many`, so they finish in about a second on a few-hundred-note vault instead of timing out on hundreds of serial TLS handshakes. Tune with `ECHO_WORKERS` (default 8, bulk-read concurrency) and `ECHO_TIMEOUT` (default 30s, per-request). For a vault large enough that even the concurrent pass nears the tool timeout, run the script with the harness's background-execution option rather than blocking the turn.
|
||||
|
||||
@@ -359,11 +393,11 @@ python3 "$ECHO" patch journal/daily/<currentDate>.md append heading "<currentDat
|
||||
|
||||
**Routing triggers — areas / meetings / skills:**
|
||||
|
||||
- **Area** (`areas/`): a standing responsibility Jason maintains with no finish line. Decision rule: has an end state → `projects/`; never "done" → `areas/`. Subfolder is the domain (`business`/`personal`/`learning`/`systems`); create it on first write. `type: area`.
|
||||
- **Area** (`areas/`): a standing responsibility the operator maintains with no finish line. Decision rule: has an end state → `projects/`; never "done" → `areas/`. Subfolder is the domain (`business`/`personal`/`learning`/`systems`); create it on first write. `type: area`.
|
||||
- **Meeting** (`resources/meetings/`): notes, recaps, or action items tied to a specific meeting or call. Mirror decisions to `decisions/by-date/` and commitments to the relevant project. `type: meeting`.
|
||||
- **Skill** (`_agent/skills/`): Jason authors, installs, or retires a skill/plugin and wants it tracked as a capability — distinct from `projects/`, which tracks the build effort. Active entries in `active/`, retired ones in `archived/`. `type: skill`.
|
||||
- **Skill** (`_agent/skills/`): the operator authors, installs, or retires a skill/plugin and wants it tracked as a capability — distinct from `projects/`, which tracks the build effort. Active entries in `active/`, retired ones in `archived/`. `type: skill`.
|
||||
|
||||
Never delete files unless Jason explicitly asks. Memory is append-friendly; deletion is destructive.
|
||||
Never delete files unless the operator explicitly asks. Memory is append-friendly; deletion is destructive.
|
||||
|
||||
## Session Logging
|
||||
|
||||
@@ -371,30 +405,45 @@ At the end of substantive conversations (ones that produced decisions, artifacts
|
||||
|
||||
**Filename format is canonical: `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md`.** Always include the four-digit local-time HHMM component — it makes filenames lexically sort in true chronological order, which is what Step 3 of loading relies on. Older session logs without the HHMM part exist; leave them alone, but every new one must use the full form.
|
||||
|
||||
Write the body (see `references/session-log-template.md`) to a file with the Write tool, then:
|
||||
**Preferred — one call (`session-end`).** Write a bundle JSON with the Write tool and run it; it performs the whole session-end sequence under one lock, in order, with the **heartbeat written last as the commit marker** (a failure part-way leaves the previous pointer intact):
|
||||
|
||||
```json
|
||||
{ "slug": "<kebab-slug>",
|
||||
"log_body": "<full session-log markdown, frontmatter included — see references/session-log-template.md>",
|
||||
"agent_log_line": "- <currentDate>: <one-line summary>",
|
||||
"scope": "<new scope text — omit to leave unchanged>",
|
||||
"reflect": [ { "title": "…", "kind": "…", "body": "…", "confidence": 0.9 } ] }
|
||||
```
|
||||
|
||||
```bash
|
||||
python3 "$ECHO" session-end bundle.json # dry-run: previews the full plan
|
||||
ECHO_NOW=<HHMM> python3 "$ECHO" session-end bundle.json --apply
|
||||
```
|
||||
|
||||
`--apply` writes: session log → Agent Log line → reflect proposals (via `capture`, gate-aware) → optional scope switch → heartbeat. Set `ECHO_NOW` to the conversation's local HHMM so the filename sorts truthfully. Reflect proposals still follow the reflect contract — preview with the dry-run and apply only with the operator's go-ahead. Offline, every step queues durably.
|
||||
|
||||
**Manual fallback** (only if `session-end` is unavailable) — the same sequence by hand:
|
||||
|
||||
```bash
|
||||
python3 "$ECHO" put _agent/sessions/<currentDate>-HHMM-<slug>.md <bodyfile>
|
||||
```
|
||||
|
||||
Then add a one-line entry to today's daily note via the **Daily Note — Agent Log** procedure above.
|
||||
|
||||
Finally, update the heartbeat pointer so the next session can orient in one read (this closes the loop with loading-procedure Step 4 — a pointer nobody writes is a pointer nobody can read). It is a single line, `<session-log-path> @ <ISO-timestamp>`; write it to a file and PUT it:
|
||||
Then add a one-line entry to today's daily note via the **Daily Note — Agent Log** procedure above, and finally update the heartbeat pointer so the next session can orient in one read (a pointer nobody writes is a pointer nobody can read) — a single line, `<session-log-path> @ <ISO-timestamp>`:
|
||||
|
||||
```bash
|
||||
python3 "$ECHO" put _agent/heartbeat/last-session.md <bodyfile>
|
||||
```
|
||||
|
||||
`last-session.md` is overwritten (PUT) each session end — never appended, so it can't grow or duplicate.
|
||||
`last-session.md` is overwritten (PUT) each session end — never appended, so it can't grow or duplicate. Keep the heartbeat write **last** in the manual sequence too.
|
||||
|
||||
## Vault Unreachable
|
||||
|
||||
If the API returns a connection error, timeout, or `502`, tell Jason once that the memory vault is unreachable (a `502` usually means Obsidian/the REST plugin is not running on the backend), then proceed without memory. Do not retry repeatedly.
|
||||
If the API returns a connection error, timeout, or `502`, tell the operator once that the memory vault is unreachable (a `502` usually means Obsidian/the REST plugin is not running on the backend), then proceed — reads degrade to the last-known-good cache at load, and **writes queue durably**: the low-level verbs queue the request, and `capture` queues the whole operation as one semantic record that replays *through capture* (routing + duplicate gate re-run against the index at replay time) on the next reachable session. A queued capture that stops at the duplicate gate on replay is kept and flagged (`flush` lists it) rather than landed blind. Do not retry repeatedly.
|
||||
|
||||
## Style Rules
|
||||
|
||||
- Write in third person about Jason: "Jason prefers X", not "I prefer X".
|
||||
- Jason is both operator and architect here — unlike goldbrain (Bryan's vault, which Jason architected). Do not cross-write: Bryan's preferences belong in goldbrain, Jason's belong here.
|
||||
- Write in third person about the operator: "the operator prefers X", not "I prefer X".
|
||||
- This vault holds only its own owner's memory. Do not cross-write between distinct vaults/owners — another person's vault and preferences belong in their own vault, not here.
|
||||
- **Anchor relative dates on the conversation's `currentDate`** before writing. "Today" → `currentDate`. "Thursday" / "next week" → resolve to an absolute `YYYY-MM-DD`. Never guess from training-data knowledge of the current year.
|
||||
- Every memory file has canonical YAML frontmatter — see `references/vault-layout.md`.
|
||||
- Set `agent_written: true` on agent-generated notes and list `source_notes` (plain relative paths, not links).
|
||||
@@ -402,15 +451,15 @@ If the API returns a connection error, timeout, or `502`, tell Jason once that t
|
||||
- **`source_notes` lists the note(s) that *triggered* or *supplied content for* this one** — e.g. the session log that produced a project update, or the daily note where a captured fact originated. It is a *backward* link to inputs. Forward links (this note → other notes it references) belong in the `## Related` section in the note body, never in frontmatter.
|
||||
- **Never put `[[wikilinks]]` in frontmatter** — YAML parses them as nested lists and the links break in Obsidian's reading view. Put all cross-references in a `## Related` section in the note **body** as a bulleted list of `[[links]]`.
|
||||
- Use Obsidian wiki links (`[[note name]]`) freely in the note **body** for cross-references.
|
||||
- Keep entries short and focused. Fewer, sharper entries beat many noisy ones — Jason explicitly prefers concision.
|
||||
- About to write something large or sensitive? Show Jason the content first and confirm.
|
||||
- Keep entries short and focused. Fewer, sharper entries beat many noisy ones — the operator explicitly prefers concision.
|
||||
- About to write something large or sensitive? Show the operator the content first and confirm.
|
||||
|
||||
## operator-preferences.md — Rules vs Observations
|
||||
|
||||
`_agent/memory/semantic/operator-preferences.md` separates two kinds of content:
|
||||
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. These are timeless: "Jason prefers concise communication." Append here only when a rule is stable.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed: `- 2026-06-06: Jason chose X over Y because Z.` This is where new evidence goes by default.
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. These are timeless: "the operator prefers concise communication." Append here only when a rule is stable.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed: `- 2026-06-06: the operator chose X over Y because Z.` This is where new evidence goes by default.
|
||||
|
||||
During monthly Vault Health or when an observation stabilizes, promote it from `## Observations` into `## Fact / Pattern` (drop the date) and remove the duplicate from Observations. Trim Observations to the last ~30 entries when it grows past that — the rest live in session logs.
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
# ECHO Performance Test Suite — Plan
|
||||
|
||||
**Status:** plan only — nothing here is to be run yet.
|
||||
**Author:** drafted for Jason Stedwell, 2026-06-23.
|
||||
**Author:** drafted for the vault owner, 2026-06-23.
|
||||
**Targets the changes that landed in:** 1.1.0 (connection pooling + concurrent `read_many` + shared cache) and 1.2.0 (fuzzy `resolve`, alias derivation/learning, `capture` re-slug fix).
|
||||
**Scope:** a *performance / timing* harness. It complements — does not replace — the existing correctness tests (`test_echo_client.py`, `test_v1_scaffold.py`, the eval feature tests), which stay as the pass/fail gate for behaviour.
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. Put hard numbers on the three operations Jason called out — full endpoint read, full subject pulls, bulk get/put/append — so the 1.1.0 perf claims ("lint 0.90s / sweep 0.85s on 186 notes, ~4.5× from reuse, ~2× from concurrency") become a repeatable, checked-in measurement instead of a one-time observation.
|
||||
1. Put hard numbers on the three operations the operator called out — full endpoint read, full subject pulls, bulk get/put/append — so the 1.1.0 perf claims ("lint 0.90s / sweep 0.85s on 186 notes, ~4.5× from reuse, ~2× from concurrency") become a repeatable, checked-in measurement instead of a one-time observation.
|
||||
2. Establish **baselines + regression gates** so a future change that quietly reintroduces the pre-1.1.0 per-request TLS handshake (the bug that was timing out sessions) fails the suite instead of silently shipping.
|
||||
3. Keep every run **non-destructive to the production vault** — writes go to a disposable namespace and are cleaned up (per Jason's "prune test artifacts" rule).
|
||||
3. Keep every run **non-destructive to the production vault** — writes go to a disposable namespace and are cleaned up (per the operator's "prune test artifacts" rule).
|
||||
|
||||
## 2. Methodology (applies to every metric)
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
- **Environment capture:** record `ECHO_WORKERS`, `ECHO_TIMEOUT`, vault note count, git commit/plugin version, and wall-clock date in the results header so two runs are comparable.
|
||||
- **Output:** machine-readable JSON (`eval/perf/results/<date>-<commit>.json`) plus a short human table to stdout. JSON lets us diff runs over time and chart trend.
|
||||
- **Isolation:** all test writes live under a dedicated prefix, e.g. `_agent/_bench/<run-id>/...`, never in `projects/`, `resources/`, `inbox/`, or `journal/`. A `--cleanup` pass deletes the whole prefix at the end; a `--keep` flag preserves it for debugging. The advisory lock (`echo.py lock`) is taken for the write phases so a bench run can't race a real CoWork session.
|
||||
- **Network honesty:** the endpoint is the live `echoapi.alwisp.com`, so absolute numbers depend on WAN latency. Report **both** absolute ms and a relative ratio against the same run's single-GET baseline, so the suite stays meaningful regardless of where it's run from.
|
||||
- **Network honesty:** the endpoint is the live configured endpoint (`echo.BASE` / `$ECHO_BASE`), so absolute numbers depend on WAN latency. Report **both** absolute ms and a relative ratio against the same run's single-GET baseline, so the suite stays meaningful regardless of where it's run from.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core metrics (the three Jason asked for)
|
||||
## 3. Core metrics (the three the operator asked for)
|
||||
|
||||
### 3.1 Timed full endpoint read (full-vault bulk read)
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
### 3.3 Timed bulk get / put / append
|
||||
|
||||
**What it measures:** write-path throughput and the read-back/idempotency overhead Jason deliberately pays for.
|
||||
**What it measures:** write-path throughput and the read-back/idempotency overhead the operator deliberately pays for.
|
||||
|
||||
**Procedure (all in the `_agent/_bench/<run-id>/` namespace):**
|
||||
- **Bulk GET:** create K fixture notes once, then time reading them back — both serially and via `read_many` — to compare against §3.1 at a controlled, fixed K (e.g. 25/50/100) so the read curve is isolated from whatever the live vault happens to contain.
|
||||
|
||||
@@ -6,7 +6,7 @@ targeted. It reuses the live `echo` client module, so it measures the real poole
|
||||
concurrent code path — not a reimplementation.
|
||||
|
||||
This is a **manual / pre-release** tool, not a CI gate: it depends on the live
|
||||
endpoint (`echoapi.alwisp.com`) and WAN latency. Gates are **relative ratios**
|
||||
configured endpoint (`echo.BASE` / `$ECHO_BASE`) and WAN latency. Gates are **relative ratios**
|
||||
(portable), with absolute milliseconds kept as informational context.
|
||||
|
||||
See `PERF-TEST-PLAN.md` for the design rationale behind each metric.
|
||||
@@ -25,8 +25,8 @@ python3 bench.py resolve # 1.2.0 fuzzy correctness table + timin
|
||||
python3 bench.py soak --soak-seconds 120 # stability / leak watch
|
||||
```
|
||||
|
||||
Set the key first if the baked-in fallback warning bothers you:
|
||||
`python3 ../../scripts/echo.py write-key <token>` (or `export ECHO_KEY=...`).
|
||||
Configure this machine first (owner/endpoint/key):
|
||||
`python3 ../../scripts/echo.py config init` then edit the file, or `export ECHO_BASE=... ECHO_KEY=...`.
|
||||
|
||||
## Metrics
|
||||
|
||||
@@ -90,7 +90,7 @@ metric + gate above regression-guard this so it can't quietly revert to serial.
|
||||
sandbox vault so the replay doesn't write to production; that's deferred (the
|
||||
plan's synthetic-vault item). The metric still proves enqueue works and reports
|
||||
queue depth.
|
||||
- **Absolute ms depend on where you run from.** WAN latency to `echoapi.alwisp.com`
|
||||
dominates single-request times; that's why gates are ratios.
|
||||
- **Absolute ms depend on where you run from.** WAN latency to the configured
|
||||
endpoint dominates single-request times; that's why gates are ratios.
|
||||
- **Synthetic-vault scaling (100/500/1000 notes) is deferred** per decision — the
|
||||
suite benchmarks the real ~190-note vault today.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "Relative-ratio regression gates (Jason's chosen policy). Ratios are portable across machines/WAN; absolute ms in the results JSON stay informational. Tune these after the first real baseline run, then tighten. 'field' is a dotted path into a metric's result; for list items use the index (e.g. sweep.3.median_ms).",
|
||||
"_comment": "Relative-ratio regression gates (the maintainer's chosen policy). Ratios are portable across machines/WAN; absolute ms in the results JSON stay informational. Tune these after the first real baseline run, then tighten. 'field' is a dotted path into a metric's result; for list items use the index (e.g. sweep.3.median_ms).",
|
||||
"read-full": {
|
||||
"gates": [
|
||||
{ "field": "speedup_ratio", "op": ">=", "min": 1.8,
|
||||
|
||||
@@ -10,7 +10,7 @@ code path. Reads are non-destructive; write metrics live in a disposable
|
||||
namespace (_agent/_bench/<run-id>/) cleaned up at the end unless --keep.
|
||||
|
||||
This is a MANUAL / pre-release tool, not a CI gate — it depends on the live
|
||||
endpoint (echoapi.alwisp.com) and WAN latency.
|
||||
configured endpoint (echo.BASE / $ECHO_BASE) and WAN latency.
|
||||
|
||||
python3 bench.py <metric> [options]
|
||||
python3 bench.py all # the read-only safe set
|
||||
|
||||
@@ -11,8 +11,8 @@ from __future__ import annotations
|
||||
# --- §3.2 subject pulls -------------------------------------------------------
|
||||
# (label, query) pairs fed to `recall`. Chosen to span neighbourhood sizes — the
|
||||
# real cost driver — from a likely-leaf to a known hub. APTA and CapMetro are the
|
||||
# subjects Jason named; "echo" and "operator preferences" are dense hubs that
|
||||
# stress the 1-hop expansion (the read_many fan-out 1.1.0 accelerated).
|
||||
# subjects the maintainer configured; "echo" and "operator preferences" are dense hubs
|
||||
# that stress the 1-hop expansion (the read_many fan-out 1.1.0 accelerated).
|
||||
SUBJECTS: list[tuple[str, str]] = [
|
||||
("apta", "APTA"),
|
||||
("capmetro", "CapMetro"),
|
||||
@@ -32,13 +32,16 @@ SUBJECTS: list[tuple[str, str]] = [
|
||||
# to nothing and silently spawn a new note)
|
||||
# "miss" -> neither; genuinely unknown
|
||||
# Tune expect_slug to the live vault; unknowns are reported as INFO, not failures.
|
||||
# NOTE: the subject/slug values below are neutral placeholders. The maintainer
|
||||
# should point these fixtures at dense hubs that actually exist in their own
|
||||
# vault, otherwise the resolve cases will report INFO misses rather than hits.
|
||||
RESOLVE_CASES: list[dict] = [
|
||||
{"mention": "echo", "expect": "exact", "expect_slug": "echo"},
|
||||
{"mention": "echo memory", "expect": "candidates", "expect_slug": "echo"},
|
||||
{"mention": "ECHO plugin", "expect": "candidates", "expect_slug": "echo"},
|
||||
{"mention": "goldbrain", "expect": "exact", "expect_slug": "goldbrain"},
|
||||
{"mention": "jason stedwell", "expect": "exact", "expect_slug": "jason-stedwell"},
|
||||
{"mention": "jason", "expect": "candidates", "expect_slug": "jason-stedwell"},
|
||||
{"mention": "other-vault", "expect": "exact", "expect_slug": "other-vault"},
|
||||
{"mention": "example subject", "expect": "exact", "expect_slug": "example-subject"},
|
||||
{"mention": "operator", "expect": "candidates", "expect_slug": "example-subject"},
|
||||
{"mention": "zzqx nonexistent entity", "expect": "miss", "expect_slug": None},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:17:09",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
@@ -353,7 +353,7 @@
|
||||
"expect_slug": "echo",
|
||||
"resolved_slug": "echo",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
@@ -407,7 +407,7 @@
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"echo-skill-improvements",
|
||||
"2026-06-19-goldbrain-full-echo-architect"
|
||||
"2026-06-19-other-vault-full-echo-architect"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -456,8 +456,8 @@
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"alabama-wisp-brand-docs"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"example-isp-brand-docs"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -498,13 +498,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "goldbrain",
|
||||
"mention": "other-vault",
|
||||
"expect": "exact",
|
||||
"expect_slug": "goldbrain",
|
||||
"resolved_slug": "goldbrain",
|
||||
"expect_slug": "other-vault",
|
||||
"resolved_slug": "other-vault",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"goldbrain"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"other-vault"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -545,13 +545,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason stedwell",
|
||||
"mention": "example subject",
|
||||
"expect": "exact",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-stedwell",
|
||||
"jason-mcp-gateway"
|
||||
"example-subject",
|
||||
"operator-mcp-gateway"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -592,13 +592,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason",
|
||||
"mention": "operator",
|
||||
"expect": "candidates",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-mcp-gateway",
|
||||
"jason-stedwell"
|
||||
"operator-mcp-gateway",
|
||||
"example-subject"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:22:00",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:36",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:21",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:12:16",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T21:22:29",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:20:57",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:01",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:28",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:12:12",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:22:05",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:17:09",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:12:19",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
@@ -20,7 +20,7 @@
|
||||
"expect_slug": "echo",
|
||||
"resolved_slug": "echo",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
@@ -74,7 +74,7 @@
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"echo-skill-improvements",
|
||||
"2026-06-19-goldbrain-full-echo-architect"
|
||||
"2026-06-19-other-vault-full-echo-architect"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -123,8 +123,8 @@
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"alabama-wisp-brand-docs"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"example-isp-brand-docs"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -165,13 +165,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "goldbrain",
|
||||
"mention": "other-vault",
|
||||
"expect": "exact",
|
||||
"expect_slug": "goldbrain",
|
||||
"resolved_slug": "goldbrain",
|
||||
"expect_slug": "other-vault",
|
||||
"resolved_slug": "other-vault",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"goldbrain"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"other-vault"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -212,13 +212,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason stedwell",
|
||||
"mention": "example subject",
|
||||
"expect": "exact",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-stedwell",
|
||||
"jason-mcp-gateway"
|
||||
"example-subject",
|
||||
"operator-mcp-gateway"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -259,13 +259,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason",
|
||||
"mention": "operator",
|
||||
"expect": "candidates",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-mcp-gateway",
|
||||
"jason-stedwell"
|
||||
"operator-mcp-gateway",
|
||||
"example-subject"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:22:15",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T21:18:12",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:20:23",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:18:05",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"subtitle": "Validation of the 1.1.0 concurrency and 1.2.0 entity-resolution releases",
|
||||
"reference": "echo-v.05 @ af16598",
|
||||
"status": "11/11 gate checks pass",
|
||||
"summary": "Full-vault reads run 7.1x faster concurrent than serial (11.4 s down to 1.6 s, 197 notes). Entity resolution is sub-millisecond. Profiling traced recall() latency to serial graph expansion (not BM25); the fix makes expansion concurrent (3.5-4.2x, identical results). All 11 gate checks pass. Run live against echoapi.alwisp.com on 2026-06-23.",
|
||||
"summary": "Full-vault reads run 7.1x faster concurrent than serial (11.4 s down to 1.6 s, 197 notes). Entity resolution is sub-millisecond. Profiling traced recall() latency to serial graph expansion (not BM25); the fix makes expansion concurrent (3.5-4.2x, identical results). All 11 gate checks pass. Run live against obsidian.example.com on 2026-06-23.",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Test Environment",
|
||||
@@ -11,7 +11,7 @@
|
||||
"rows": [
|
||||
{
|
||||
"label": "Endpoint",
|
||||
"value": "echoapi.alwisp.com (live)"
|
||||
"value": "obsidian.example.com (live)"
|
||||
},
|
||||
{
|
||||
"label": "Vault size",
|
||||
@@ -513,7 +513,7 @@
|
||||
"items": [
|
||||
"Concurrency delivers as designed. Eight workers cut full-vault reads 7.5x; the curve is near-linear to 8 workers and flattens after (8->16 returns only 1.24x). The default of 8 is the right setting for this vault and link.",
|
||||
"Keep-alive is confirmed live. A cold request costs 163 ms against a 50 ms warm median (3.25x). This is the direct measure that the 1.1.0 pooling fix removed the per-request TLS handshake that was timing out full-vault passes.",
|
||||
"Entity resolution is effectively free. resolve() returns in under 1 ms across exact, alias, and shortened mentions. The 1.2.0 alias work resolves shortened names (\"echo memory\", \"ECHO plugin\", \"jason\") straight to the canonical note, so the anti-duplicate guard holds without a fuzzy fallback.",
|
||||
"Entity resolution is effectively free. resolve() returns in under 1 ms across exact, alias, and shortened mentions. The 1.2.0 alias work resolves shortened names (\"echo memory\", \"ECHO plugin\", \"operator\") straight to the canonical note, so the anti-duplicate guard holds without a fuzzy fallback.",
|
||||
"recall() latency was traced to the graph layer, not BM25. The BM25 index is already persisted and incrementally maintained (load 500 ms, score 0.1 ms). The cost was expand_graph fetching each neighbour serially \u2014 3.0 s for 126 nodes. Fix: the graph BFS now fetches each hop concurrently via the existing read_many. expand_graph is 3.5-4.2x faster with byte-identical ranking and scores; end-to-end recall() dropped from 2.8-4.7 s to 2.1-3.0 s per subject.",
|
||||
"Full-vault maintenance scripts stay under the tool timeout. sweep.py runs in 4.6 s and vault_lint.py in 5.1 s on 197 notes. The original failure mode (passes exceeding the timeout and dropping the session) does not recur.",
|
||||
"Write integrity holds. PUT verifies via read-back at 115 ms; APPEND is whole-line idempotent, skipping at 51 ms with zero duplicate writes on re-run. The advisory lock fast-fails a contended acquire with the expected exit 75."
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ECHO — Obsidian Local REST API Reference
|
||||
|
||||
Server: `https://echoapi.alwisp.com` (reverse proxy → backend Obsidian Local REST API)
|
||||
Auth header: `Authorization: Bearer $ECHO_KEY` — `export ECHO_KEY=<token>` before running these recipes (`echo.py` resolves it automatically: `ECHO_KEY` → `~/.echo-memory/credentials` → deprecated baked-in fallback). The literal key is never stored in docs.
|
||||
The endpoint has a **valid TLS certificate** — `-k` is not required. Paths address the vault at its **root**.
|
||||
Server: the configured endpoint — the `endpoint` from `~/.claude/echo-memory/config.json` (overridable with `ECHO_BASE`), a reverse proxy → backend Obsidian Local REST API. Examples below use `$ECHO_BASE` (or the neutral placeholder `https://obsidian.example.com`).
|
||||
Auth header: `Authorization: Bearer $ECHO_KEY` — `export ECHO_KEY=<token>` before running these recipes, or let `echo.py` resolve it automatically (per-field, first wins: env override `ECHO_KEY` → the `key` in `~/.claude/echo-memory/config.json`, resolved via the `echo_config` module). The literal key is never stored in docs.
|
||||
A configured endpoint should present a **valid TLS certificate** — `-k` is not required. Paths address the vault at its **root**.
|
||||
|
||||
> **Prefer `scripts/echo.py` over the raw recipes below.** It wraps every verb with auth, status checking, retry, idempotent append, and frontmatter patches, and runs on any platform with a Python interpreter. The `curl` recipes here are the underlying mechanics and a **\*nix/bash fallback** (they use heredocs and `--data-binary`); on Windows, prefer `echo.py` or an equivalent. **If you call `curl` directly, check the HTTP status** — add `-o /dev/null -w "%{http_code}"` and branch on it. A `PATCH` to a non-existent heading returns `400 invalid-target` (errorCode 40080) and the write is *silently lost*; a bare `curl` that ignores status will report success anyway. `GET` returns `404` for a missing file. Treat any `>= 400` as a failed operation, surface it, and do not continue as if it succeeded.
|
||||
|
||||
@@ -29,7 +29,7 @@ For a vault large enough that even the concurrent pass approaches the tool timeo
|
||||
# Read any file by vault path
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/context/current-context.md"
|
||||
"$ECHO_BASE/vault/_agent/context/current-context.md"
|
||||
```
|
||||
|
||||
Returns raw file content (text/markdown). On 404, the file does not exist.
|
||||
@@ -38,7 +38,7 @@ Returns raw file content (text/markdown). On 404, the file does not exist.
|
||||
# Read a specific heading's content only
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
|
||||
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
|
||||
```
|
||||
|
||||
Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
|
||||
@@ -54,7 +54,7 @@ Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
|
||||
# List contents of a directory (trailing slash required)
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/sessions/"
|
||||
"$ECHO_BASE/vault/_agent/sessions/"
|
||||
```
|
||||
|
||||
Returns JSON: `{ "files": [...], "folders": [...] }`.
|
||||
@@ -74,7 +74,7 @@ curl -s -X POST \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_entry.md \
|
||||
"https://echoapi.alwisp.com/vault/inbox/captures/inbox.md"
|
||||
"$ECHO_BASE/vault/inbox/captures/inbox.md"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -105,7 +105,7 @@ curl -s -X PUT \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_file.md \
|
||||
"https://echoapi.alwisp.com/vault/_agent/sessions/2026-06-05-1430-my-session.md"
|
||||
"$ECHO_BASE/vault/_agent/sessions/2026-06-05-1430-my-session.md"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -120,7 +120,7 @@ curl -s -X PUT \
|
||||
|
||||
```bash
|
||||
cat > /tmp/obs_patch.md << 'OBSEOF'
|
||||
Jason prefers concise status updates — lead with the decision.
|
||||
The operator prefers concise status updates — lead with the decision.
|
||||
OBSEOF
|
||||
|
||||
curl -s -X PATCH \
|
||||
@@ -130,7 +130,7 @@ curl -s -X PATCH \
|
||||
-H "Target: Operator Preferences::Fact / Pattern" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_patch.md \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
```
|
||||
|
||||
### Discover heading / block / frontmatter targets
|
||||
@@ -141,7 +141,7 @@ When unsure of the exact heading path, GET the note with the document-map Accept
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
-H "Accept: application/vnd.olrapi.document-map+json" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
```
|
||||
|
||||
Returns `{ "headings": [...], "blocks": [...], "frontmatterFields": [...] }`. Copy the heading string verbatim into `Target`.
|
||||
@@ -156,6 +156,12 @@ Same call with `Operation: prepend`.
|
||||
|
||||
### 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
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
@@ -164,7 +170,7 @@ curl -s -X PATCH \
|
||||
-H "Target: updated" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '"2026-06-05"' \
|
||||
"https://echoapi.alwisp.com/vault/projects/active/vault-foundation.md"
|
||||
"$ECHO_BASE/vault/projects/active/vault-foundation.md"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -174,7 +180,7 @@ curl -s -X PATCH \
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/search/simple/?query=weekly+review"
|
||||
"$ECHO_BASE/search/simple/?query=weekly+review"
|
||||
```
|
||||
|
||||
Returns an array of `{ filename, score, matches: [{ context, match }] }`.
|
||||
@@ -186,11 +192,18 @@ Returns an array of `{ filename, score, matches: [{ context, match }] }`.
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/inbox/imports/old-note.md"
|
||||
"$ECHO_BASE/vault/inbox/imports/old-note.md"
|
||||
```
|
||||
|
||||
Only on explicit operator request. Deletion is destructive.
|
||||
|
||||
> **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
|
||||
|
||||
@@ -20,8 +20,8 @@ python3 "$SCRIPTS/migrate.py" --apply # perform the migration (moves/delet
|
||||
`bootstrap.py` writes through `echo.py`, so every step is status-checked and the marker is written last. The scripts are pure Python (only an interpreter required — no bash, no platform-specific `date`), so they run identically on Windows, macOS, and Linux. The manual steps below document what the script does (and serve as a fallback if the script can't run).
|
||||
|
||||
```
|
||||
AUTH="Authorization: Bearer $ECHO_KEY" # export ECHO_KEY first; echo.py resolves it automatically
|
||||
BASE="https://echoapi.alwisp.com"
|
||||
AUTH="Authorization: Bearer $ECHO_KEY" # export ECHO_KEY first; echo.py resolves it automatically via echo_config
|
||||
BASE="$ECHO_BASE" # the endpoint from ~/.claude/echo-memory/config.json (override: ECHO_BASE)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
- **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.)
|
||||
|
||||
---
|
||||
@@ -138,5 +138,5 @@ Run the same steps 1–5, but GET-probe each path first and **only create what i
|
||||
When the marker's `schema_version` is older than the plugin's, apply the migration steps for each intervening version, then PATCH the marker's `schema_version` frontmatter to the new value.
|
||||
|
||||
- **0 → 1** (control-docs-in-plugin): the vault previously carried root control docs (`CLAUDE.md`, `BOOTSTRAP.md`, `STRUCTURE.md`, `index.md`). Back them up outside the vault, DELETE them, PUT the thin `scaffold/README.vault.md` over the old verbose `README.md`, write the `_agent/echo-vault.md` marker, and scrub now-dangling `[[CLAUDE]]`/`[[BOOTSTRAP]]`/`[[STRUCTURE]]`/`[[index]]` links from the `## Related` sections of `operator-preferences.md` and `current-context.md` (leave historical session logs alone). Confirm with the operator before deleting.
|
||||
- **1 → 2** (reviews-folded-into-journal): the `reviews/` tree is retired. (a) For each note under `reviews/weekly/` and `reviews/monthly/`, MOVE it into `journal/weekly/` (rename `YYYY-Www-review.md` → `YYYY-Www.md`) and `journal/monthly/` respectively, preserving the earliest `created:`. (b) Move any `reviews/monthly/YYYY-MM-vault-health.md` to `_agent/health/`. (c) Move `reviews/quarterly|annual/` artifacts to `journal/quarterly|annual/`. (d) Update inbound `[[reviews/...]]` wikilinks in `## Related` sections to the new paths. (e) DELETE the now-empty `reviews/` tree. Confirm with the operator before deleting; leave historical session logs alone. *(Jason's live vault was hand-migrated for the one existing `2026-W24` artifact on 2026-06-10; this step covers any vault bootstrapped under schema 1.)*
|
||||
- **1 → 2** (reviews-folded-into-journal): the `reviews/` tree is retired. (a) For each note under `reviews/weekly/` and `reviews/monthly/`, MOVE it into `journal/weekly/` (rename `YYYY-Www-review.md` → `YYYY-Www.md`) and `journal/monthly/` respectively, preserving the earliest `created:`. (b) Move any `reviews/monthly/YYYY-MM-vault-health.md` to `_agent/health/`. (c) Move `reviews/quarterly|annual/` artifacts to `journal/quarterly|annual/`. (d) Update inbound `[[reviews/...]]` wikilinks in `## Related` sections to the new paths. (e) DELETE the now-empty `reviews/` tree. Confirm with the operator before deleting; leave historical session logs alone. *(This step covers any vault bootstrapped under schema 1.)*
|
||||
- **2 → 3** (entity index + cross-linking): adds the `_agent/index/` folder for the machine-maintained entity registry. `migrate.py` creates the folder; the **data back-fill is `sweep.py`** — after migrating, run `python3 scripts/sweep.py --apply` to build `_agent/index/entities.json` from the notes already in the vault and to symmetrize existing `## Related` cross-links (it adds only the missing reciprocal direction, never invents links). `sweep.py` is idempotent and dry-run by default; re-run it any time `/echo-health` reports index drift.
|
||||
|
||||
@@ -21,7 +21,7 @@ Views of the same truth: `scripts/routing.json` is the **machine-readable canoni
|
||||
| `inbox/imports/<slug>.md` | Raw external material dropped in wholesale (export, paste, dump) | The raw artifact, unedited | Holds un-triaged *bulk*, vs `captures` which holds single lines | PUT |
|
||||
| `inbox/processing-log/YYYY-MM-DD.md` | An inbox item is routed to its real home | One line: `<original> → <destination path>` | Audit trail of moves — never holds memory itself, only the record of routing | POST |
|
||||
|
||||
Captures and imports are temporary by contract. Triage drains them into the homes below and logs the move; the original is left until Jason okays deletion.
|
||||
Captures and imports are temporary by contract. Triage drains them into the homes below and logs the move; the original is left until the operator okays deletion.
|
||||
|
||||
## journal/ — the one append-only time-series stream
|
||||
|
||||
@@ -53,7 +53,7 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
|
||||
|
||||
| Path | Trigger | What lands | Distinct because | Method |
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `areas/<domain>/<slug>.md` | Ongoing domain Jason maintains indefinitely (`<domain>`: business/personal/learning/systems) | Area note | No end state — the one thing that disqualifies it from `projects/` | PUT |
|
||||
| `areas/<domain>/<slug>.md` | Ongoing domain the operator maintains indefinitely (`<domain>`: business/personal/learning/systems) | Area note | No end state — the one thing that disqualifies it from `projects/` | PUT |
|
||||
|
||||
## resources/ — reference material about the world
|
||||
|
||||
@@ -61,7 +61,7 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `resources/people/<name>.md` | A fact about a specific person | Person note (kebab-case slug) | Keyed to a person, not a topic or event | PUT / PATCH |
|
||||
| `resources/companies/<slug>.md` | A fact about an organization (client, vendor, partner, employer) | Company note (kebab-case slug) | Keyed to an organization, not an individual (`people/`) or an external source (`references/`) | PUT / PATCH |
|
||||
| `resources/concepts/<slug>.md` | A reusable concept/idea Jason wants on file | Concept note | An idea, vs a `reference` which is an external source | PUT |
|
||||
| `resources/concepts/<slug>.md` | A reusable concept/idea the operator wants on file | Concept note | An idea, vs a `reference` which is an external source | PUT |
|
||||
| `resources/references/<slug>.md` | An external source/link worth keeping | Reference note | Points outward (a source), vs `concepts` (an idea) | PUT |
|
||||
| `resources/meetings/YYYY-MM-DD-<slug>.md` | Notes/recap tied to a specific meeting or call | Meeting note; mirror decisions to `decisions/by-date/`, commitments to the project | Event-anchored to a meeting, vs a project's ongoing thread | PUT |
|
||||
|
||||
@@ -80,15 +80,15 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `_agent/echo-vault.md` | Bootstrap / schema migration only | Marker: `schema_version`, bootstrap date | Plugin-owned probe; never hand-edited | GET / PUT |
|
||||
| `_agent/context/current-context.md` | Active scope changes; task focus shifts | `## Scope`, `## Scope History`, priorities | Single *live* scope pointer, vs episodic which is a *past* record | PATCH / PUT |
|
||||
| `_agent/memory/semantic/operator-preferences.md` | A preference/pattern about Jason | Append under `## Observations`; promote to `## Fact / Pattern` when stable | The one curated profile; distinct from ad-hoc semantic notes | PATCH |
|
||||
| `_agent/memory/semantic/<slug>.md` | A durable fact/pattern that isn't a Jason-preference | Semantic note | Timeless fact, vs episodic (time-stamped event) and working (transient) | PUT |
|
||||
| `_agent/memory/semantic/operator-preferences.md` | A preference/pattern about the operator | Append under `## Observations`; promote to `## Fact / Pattern` when stable | The one curated profile; distinct from ad-hoc semantic notes | PATCH |
|
||||
| `_agent/memory/semantic/<slug>.md` | A durable fact/pattern that isn't an operator-preference | Semantic note | Timeless fact, vs episodic (time-stamped event) and working (transient) | PUT |
|
||||
| `_agent/memory/episodic/<slug>.md` | A record of *what happened, when* | Episodic note | Anchored to an event in time; not a standing fact | PUT |
|
||||
| `_agent/memory/working/<slug>.md` | Short-lived state needed only for the current effort | Working note | Explicitly transient/time-boxed; safe to go stale | PUT |
|
||||
| `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md` | A substantive session ends (decisions/artifacts/commitments) | Session log (see template) | Per-session record; the unit loading Step 4 scans | PUT |
|
||||
| `_agent/health/YYYY-MM-vault-health.md` | First substantive session of a month (Vault Health pass) | Health-audit findings | Agent self-maintenance about vault integrity — NOT Jason's work narrative, so not in `journal/` | PUT |
|
||||
| `_agent/health/YYYY-MM-vault-health.md` | First substantive session of a month (Vault Health pass) | Health-audit findings | Agent self-maintenance about vault integrity — NOT the operator's work narrative, so not in `journal/` | PUT |
|
||||
| `_agent/heartbeat/last-session.md` | End of every session, after the session log is written | One line: `<session-log-path> @ <ISO-timestamp>` | O(1) orientation pointer read first at load (Step 4); overwritten, never grows | PUT |
|
||||
| `_agent/templates/` | Bootstrap only (seeded from plugin masters) | Canonical note templates | Holds templates, not memory; never a runtime routing target | PUT (seed) |
|
||||
| `_agent/skills/active/<slug>.md` | Jason authors/installs a skill and wants it catalogued | Skill capability entry | Catalogs a *capability*, vs `projects/` which tracks the *build effort* | PUT |
|
||||
| `_agent/skills/active/<slug>.md` | The operator authors/installs a skill and wants it catalogued | Skill capability entry | Catalogs a *capability*, vs `projects/` which tracks the *build effort* | PUT |
|
||||
| `_agent/skills/archived/<slug>.md` | A catalogued skill is retired | Skill entry (moved from `active/`) | Terminal state of the skill catalog | PUT |
|
||||
| `_agent/locks/vault.lock` | Advisory multi-writer lock acquire/release | One line: `<owner> @ <ISO-timestamp>` | Concurrency coordination, not memory; managed only by `echo.py lock/unlock` | PUT / DELETE |
|
||||
| `_agent/index/entities.json` | Rebuilt on every `capture` and on `sweep` | Slug→{path, kind, title, aliases, last_seen} registry | Machine-maintained routing/recall index, not a note; the one JSON in the vault | PUT |
|
||||
@@ -120,7 +120,7 @@ Listed so they are recognised as dead and never recreated. Any one of these appe
|
||||
## Routing decision tree (the calls that get mis-made)
|
||||
|
||||
1. **Destination unknown right now?** → `inbox/captures/`. Known? → route directly; never park a known fact in the inbox.
|
||||
2. **Is it about Jason's work over time?** → `journal/` (pick the grain by cadence). **About the vault's mechanical health?** → `_agent/health/`. These two look similar monthly but answer different questions.
|
||||
2. **Is it about the operator's work over time?** → `journal/` (pick the grain by cadence). **About the vault's mechanical health?** → `_agent/health/`. These two look similar monthly but answer different questions.
|
||||
3. **Does the effort have an end state?** → `projects/` (folder = `status:`). **No finish line?** → `areas/`.
|
||||
4. **A fact about the world:** timeless → `semantic/`; a thing that happened → `episodic/`; needed only for now → `working/`. A fact about a *person* → `resources/people/`; a fact about an *organization* → `resources/companies/`.
|
||||
5. **A decision:** always `decisions/by-date/`; mirror into a project only if one already exists.
|
||||
|
||||
@@ -61,15 +61,15 @@
|
||||
|
||||
## 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
|
||||
---
|
||||
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)
|
||||
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
|
||||
# into the entity index so a shortened/expanded name resolves to this note.
|
||||
agent_written: false
|
||||
@@ -111,14 +111,14 @@ not rewrite frontmatter — the append goes after existing content. To change
|
||||
|
||||
The profile analog. Canonical headings:
|
||||
|
||||
- `## Operator` — who Jason is (one paragraph)
|
||||
- `## Operator` — who the vault owner is (one paragraph)
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. Timeless.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed lines (`- 2026-06-06: ...`). Default landing zone for new evidence.
|
||||
- `## Evidence` — citations/links supporting the rules
|
||||
- `## Recommendation or Implication` — how the rules should shape behavior
|
||||
- `## Review Notes` — confidence / last review date
|
||||
|
||||
Append observed facts under `## Observations` by default. Promote to `## Fact / Pattern` (dropping the date) once a pattern stabilizes. "The operator" is Jason — he is both operator and architect of this vault.
|
||||
Append observed facts under `## Observations` by default. Promote to `## Fact / Pattern` (dropping the date) once a pattern stabilizes. "The operator" is the vault owner — refer to them generically and in the third person (the display name is the runtime `owner` config value).
|
||||
|
||||
### projects/active/\<slug\>.md
|
||||
|
||||
@@ -173,11 +173,11 @@ ADR-style: Context → Decision → Consequences. If the decision belongs to an
|
||||
|
||||
### people/\<name\>.md
|
||||
|
||||
`type: person`. Use lowercase kebab-case for the slug (e.g. `jason-stedwell.md`).
|
||||
`type: person`. Use lowercase kebab-case for the slug (e.g. `robert-smith.md`).
|
||||
|
||||
### companies/\<slug\>.md
|
||||
|
||||
`type: company`. An organization Jason works with — client, vendor, partner, or employer (e.g. `gillig.md`, `mpm.md`). Distinct from `people/` (individuals, who *belong to* companies) and `references/` (external sources). Lowercase kebab-case slug.
|
||||
`type: company`. An organization the vault owner works with — client, vendor, partner, or employer (e.g. `acme.md`). Distinct from `people/` (individuals, who *belong to* companies) and `references/` (external sources). Lowercase kebab-case slug.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user