1
0
forked from jason/echo

Phase 1: mechanical echo→chorus rename with back-compat shims

Identity rename, no behavior change (CHORUS-PLAN.md Phase 1):
- Plugin echo-memory → chorus-memory: manifest (v2.0.0-alpha.1), skill dir,
  16 scripts (chorus.py, chorus_config.py, …), EchoError → ChorusError,
  /chorus-* commands, hook paths, docs, scaffold seeds, eval harness,
  build.py. Docs endpoint → chorusapi.mpm.to.
- Env ECHO_* → CHORUS_*; config → ~/.claude/chorus-memory/config.json;
  state dir → ~/.chorus-memory/; marker → _agent/chorus-vault.md.

Back-compat shims (one major version):
- chorus_config aliases legacy ECHO_* env at import; reads a legacy
  echo-memory config.json when no CHORUS config exists (writes never
  land there); doctor/config report the legacy source.
- State dir honors an existing ~/.echo-memory when the new dir is absent
  (offline queue not stranded).
- Marker dual-probe in load/doctor/bootstrap/lint/sweep/migrate: a
  pre-fork _agent/echo-vault.md counts as bootstrapped; bootstrap repair
  won't write a second marker; routing gains agent-marker-legacy (GET).

Verified: 25/25 unit tests, scaffold + routing-sync checks, 4 mock e2e
suites, run_eval metrics unchanged, +6 shim smoke tests green.
Rebuilt chorus-memory.plugin (79 entries).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 13:35:51 -05:00
parent ff2f2efd0b
commit d366ca4032
98 changed files with 1312 additions and 1164 deletions
+4 -3
View File
@@ -8,16 +8,17 @@ __pycache__/
.venv/
venv/
# Local ECHO state (never commit — holds the offline queue + cached vault reads,
# and may hold the API key in ~/.echo-memory/credentials when not using ECHO_KEY)
# Local CHORUS/ECHO state (never commit — holds the offline queue + cached vault reads)
.echo-memory/
.chorus-memory/
# Eval output
eval/results/*.json
!eval/results/.gitkeep
# ECHO config: the filled-in key file is yours to distribute, never commit it
# CHORUS/ECHO config: the filled-in key file is yours to distribute, never commit it
/echo-memory.config.json
/chorus-memory.config.json
/dist/
# Build artifacts. Per-user BAKED artifacts (build.py --bake-key) carry a live vault
+33
View File
@@ -1,5 +1,38 @@
# Changelog
## 2.0.0-alpha.1 — CHORUS fork, Phase 1 (mechanical rename)
CHORUS is a group-based fork of ECHO (`jason/echo`, v1.5.1, schema 4 — preserved at the
`echo-fork-point` tag). Phase 1 is the identity rename with no behavior change; see
`CHORUS-PLAN.md` for the full conversion plan.
### Changed
- Plugin renamed `echo-memory``chorus-memory`; skill dir, all 16 `echo*.py` modules
(`chorus.py`, `chorus_config.py`, …), `EchoError``ChorusError`, the eight slash
commands (`/echo-*``/chorus-*`), hook script paths, and all docs/scaffold seeds.
- Env vars renamed `ECHO_*``CHORUS_*`; config moves to
`~/.claude/chorus-memory/config.json`; local state dir to `~/.chorus-memory/`;
bootstrap marker to `_agent/chorus-vault.md`; manifest version → 2.0.0-alpha.1.
- Docs' endpoint references now `chorusapi.mpm.to`.
### Back-compat (one major version)
- Legacy `ECHO_*` env vars are adopted as `CHORUS_*` at import when the new name is unset.
- A legacy `~/.claude/echo-memory/config.json` is read (never written) when no CHORUS
config exists; `config`/`doctor` report the legacy source.
- A legacy `~/.echo-memory/` state dir is honored when `~/.chorus-memory/` doesn't exist
yet, so queued offline writes aren't stranded.
- The bootstrap probe (`load`, `doctor`, `bootstrap`, `vault_lint`, `sweep`, `migrate`)
falls back to a pre-fork `_agent/echo-vault.md` marker; bootstrap's repair pass leaves
a legacy marker in place (the schema migration will rename it) rather than writing a
second marker. `routing.json` gains a read-only `agent-marker-legacy` route.
### Verified
- 25/25 client unit tests, scaffold checks, routing-doc sync, and all four mock
end-to-end suites green; `run_eval.py` metrics unchanged (0 dupes / 0 false blocks /
0 silent failures / 0 lost offline writes). Six new shim smoke tests (legacy env,
legacy config path, chorus-over-legacy precedence, legacy-marker load/doctor, fresh
vault) pass.
## 1.5.1
### Fixed — duplicate gate over-firing on vault-common tokens and cross-kind names
+14 -4
View File
@@ -1,10 +1,20 @@
# echo-memory — v1.5.1
# chorus-memory — v2.0.0-alpha.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`.
> **CHORUS is a group-based fork of [ECHO](https://git.alwisp.com/jason/echo)** — one shared
> Obsidian vault serving a group of members instead of a single operator. The conversion
> plan and settled design decisions live in [CHORUS-PLAN.md](CHORUS-PLAN.md). The pre-fork
> ECHO v1.5.1 state is preserved at the `echo-fork-point` tag. **Phase 1 (mechanical
> rename, back-compat shims) is done**; the body of this README below the fold still
> describes the inherited single-operator behavior and is rewritten in Phase 5.
> Deployment endpoint: `https://chorusapi.mpm.to`. Legacy `ECHO_*` env vars, an existing
> `~/.claude/echo-memory/config.json`, and a vault's old `_agent/echo-vault.md` marker
> all keep working for one major version.
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.
Persistent memory for Claude / CoWork sessions via the **CHORUS** 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/chorus.py`). The whole toolchain is **pure Python** (stdlib only), so it runs identically on Windows, macOS, and Linux — no bash, no platform-specific `date`.
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.
Architected by **Jason Stedwell**. 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 group and any vault.
This repository (`jason/chorus`) holds the plugin **source** (tracked tree at `chorus-memory.plugin.src/`), the built `chorus-memory.plugin` package artifact (rebuilt on each version bump), and a credential-free A/B `eval/` harness.
**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.
+24 -24
View File
@@ -1,20 +1,20 @@
#!/usr/bin/env python3
"""build.py — package the echo-memory plugin source into a .plugin artifact.
"""build.py — package the chorus-memory plugin source into a .plugin artifact.
Zips the CONTENTS of echo-memory.plugin.src/ at the archive root (the layout the plugin
Zips the CONTENTS of chorus-memory.plugin.src/ at the archive root (the layout the plugin
loader expects: .claude-plugin/plugin.json, commands/, 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 # build chorus-memory-<version>.plugin + refresh chorus-memory.plugin
# (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
# config.json (or --owner/--endpoint/--key, or CHORUS_* env) into the
# chorus_config DEFAULT_* constants. Defaults to dist/ and skips the
# shared pointer. Deliver directly to that one user — NEVER commit it.
python build.py --strip-key # force-blank the DEFAULT_* constants -> guaranteed token-free artifact
python build.py --no-pointer # don't update the echo-memory.plugin "current" pointer
python build.py --no-pointer # don't update the chorus-memory.plugin "current" pointer
python build.py --outdir dist # write artifacts somewhere other than the repo root
Deterministic: entries are sorted and stamped with a fixed timestamp, so an unchanged
@@ -39,10 +39,10 @@ for _stream in (sys.stdout, sys.stderr):
pass
REPO = Path(__file__).resolve().parent
SRC = REPO / "echo-memory.plugin.src"
SRC = REPO / "chorus-memory.plugin.src"
MANIFEST = "/".join([".claude-plugin", "plugin.json"])
# The baked DEFAULT_* constants live in echo_config.py (resolution lowest tier).
CONFIG_PY = "/".join(["skills", "echo-memory", "scripts", "echo_config.py"])
# The baked DEFAULT_* constants live in chorus_config.py (resolution lowest tier).
CONFIG_PY = "/".join(["skills", "chorus-memory", "scripts", "chorus_config.py"])
EXCLUDE_DIRS = {"__pycache__", ".git"}
EXCLUDE_NAMES = {".DS_Store"}
@@ -51,7 +51,7 @@ 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)
# field name -> the constant assigned in echo_config.py
# field name -> the constant assigned in chorus_config.py
_CONSTS = {"owner": "DEFAULT_OWNER", "endpoint": "DEFAULT_BASE", "key": "DEFAULT_KEY"}
@@ -74,7 +74,7 @@ def included_files() -> list[Path]:
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
"""Return the bytes to archive. For chorus_config.py, optionally rewrite the
DEFAULT_* constants — inject `bake` values, or blank them with --strip-key."""
data = path.read_bytes()
if arcname != CONFIG_PY or (bake is None and not strip_key):
@@ -90,7 +90,7 @@ def file_bytes(path: Path, arcname: str, bake: dict | None, strip_key: bool) ->
def token_present(path: Path) -> bool:
"""True if any DEFAULT_* constant in echo_config.py holds a non-empty value."""
"""True if any DEFAULT_* constant in chorus_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)
@@ -114,7 +114,7 @@ def build(out: Path, files: list[Path], bake: dict | None, strip_key: bool) -> i
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."""
else --from <config.json>, else CHORUS_OWNER/CHORUS_BASE/CHORUS_KEY env."""
src = {}
if args.from_file:
fp = Path(args.from_file).expanduser()
@@ -127,23 +127,23 @@ def bake_values(args) -> dict:
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 "",
"owner": args.owner or src.get("owner") or os.environ.get("CHORUS_OWNER") or "",
"endpoint": (args.endpoint or src.get("endpoint") or os.environ.get("CHORUS_BASE") or "").rstrip("/"),
"key": args.key or src.get("key") or os.environ.get("CHORUS_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.")
"--endpoint/--key flags, or set CHORUS_OWNER/CHORUS_BASE/CHORUS_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 = argparse.ArgumentParser(description="Build the chorus-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",
@@ -155,7 +155,7 @@ def main(argv: list[str] | None = None) -> int:
ap.add_argument("--strip-key", action="store_true",
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")
help="do not refresh the chorus-memory.plugin 'current' pointer")
ap.add_argument("--outdir", help="output directory (default: repo root; dist/ when --bake-key)")
args = ap.parse_args(argv)
@@ -191,15 +191,15 @@ def main(argv: list[str] | None = None) -> int:
return 1
# Baked artifacts are secret-bearing: default them into dist/ (gitignored) and
# never touch the shared, committable echo-memory.plugin pointer.
# never touch the shared, committable chorus-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)
label = f"-{args.label}" if args.label else ""
targets = [outdir / f"echo-memory-{version}{label}.plugin"]
targets = [outdir / f"chorus-memory-{version}{label}.plugin"]
if not args.no_pointer and not bake:
targets.append(outdir / "echo-memory.plugin")
targets.append(outdir / "chorus-memory.plugin")
for out in targets:
size = build(out, files, bake, args.strip_key)
@@ -212,8 +212,8 @@ def main(argv: list[str] | None = None) -> int:
"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 "
elif token_present(SRC / "skills" / "chorus-memory" / "scripts" / "chorus_config.py"):
print("WARNING: a DEFAULT_* constant in chorus_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
Binary file not shown.
@@ -0,0 +1,16 @@
{
"name": "chorus-memory",
"version": "2.0.0-alpha.1",
"description": "Group-based persistent memory via the shared CHORUS 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 /chorus-* commands.",
"author": {
"name": "Jason Stedwell"
},
"license": "UNLICENSED",
"keywords": [
"memory",
"obsidian",
"notes",
"persistence",
"chorus"
]
}
@@ -1,12 +1,12 @@
# echo-memory
# chorus-memory
Persistent memory for Claude via the **ECHO** Obsidian vault, using the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api).
Persistent memory for Claude via the **CHORUS** 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). 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**).
Reads and writes notes across Claude/CoWork sessions through direct REST calls, routed through a bundled validated client (`scripts/chorus.py`) that status-checks every write. The toolchain is **pure Python**, so it runs identically on Windows, macOS, and Linux (only a Python 3 interpreter is needed — no bash). The plugin is **user-agnostic**: the vault owner, endpoint, and API key are not shipped in it — each machine supplies them through a local config file (see **Configuration**).
Architected by **Jason Stedwell**.
**The plugin is the single source of truth.** All control logic — bootstrap/repair, operating contract, taxonomy, frontmatter conventions, and the canonical note templates — ships inside this plugin under `skills/echo-memory/`. The vault itself holds **data only**: there are no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md` control docs in it. This makes ECHO self-bootstrapping (point it at an empty Obsidian vault and it stands up the full structure), easy to update (update the plugin, not the vault), and portable to any other vault.
**The plugin is the single source of truth.** All control logic — bootstrap/repair, operating contract, taxonomy, frontmatter conventions, and the canonical note templates — ships inside this plugin under `skills/chorus-memory/`. The vault itself holds **data only**: there are no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md` control docs in it. This makes CHORUS 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
@@ -14,16 +14,16 @@ Architected by **Jason Stedwell**.
- **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`, `/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)
- **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/chorus-memory/references/bootstrap.md`
- Exposes `/chorus-load`, `/chorus-save`, `/chorus-recall`, `/chorus-triage`, `/chorus-health`, `/chorus-sweep`, `/chorus-reflect`, `/chorus-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 `/chorus-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 ships **no** owner, endpoint, or key. On each machine it installs on, provide a machine-local JSON config:
```
~/.claude/echo-memory/config.json (honors $CLAUDE_CONFIG_DIR; file path overridable with $ECHO_CONFIG)
~/.claude/chorus-memory/config.json (honors $CLAUDE_CONFIG_DIR; file path overridable with $CHORUS_CONFIG)
{
"owner": "Full Name — how the agent refers to the vault owner (third person)",
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
@@ -34,12 +34,12 @@ The plugin ships **no** owner, endpoint, or key. On each machine it installs on,
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
python3 skills/chorus-memory/scripts/chorus.py config init # writes the template if absent → edit it
python3 skills/chorus-memory/scripts/chorus.py config set --owner "…" --endpoint "https://…" --key "…"
python3 skills/chorus-memory/scripts/chorus.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.
Per field, an environment variable overrides the file: `CHORUS_OWNER`, `CHORUS_BASE` (endpoint), `CHORUS_KEY`. `config init` writes the template (shown above) when the file is absent; a copy also ships at the repo root (`chorus-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)
@@ -53,7 +53,7 @@ Per field, an environment variable overrides the file: `ECHO_OWNER`, `ECHO_BASE`
├── resources/ (companies, concepts, references, people, meetings)
├── decisions/ (by-date)
└── _agent/
├── echo-vault.md ← bootstrap marker (schema_version + date)
├── chorus-vault.md ← bootstrap marker (schema_version + date)
├── context/ ← task-scoped context bundles
├── memory/ ← working / episodic / semantic
├── sessions/ ← YYYY-MM-DD-HHMM-<slug>.md
@@ -68,8 +68,8 @@ Per field, an environment variable overrides the file: `ECHO_OWNER`, `ECHO_BASE`
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, echo-reflect, echo-doctor
skills/echo-memory/
commands/ ← slash commands: chorus-load, chorus-save, chorus-recall, chorus-triage, chorus-health, chorus-sweep, chorus-reflect, chorus-doctor
skills/chorus-memory/
├── SKILL.md ← operating procedure (authoritative)
├── references/
│ ├── operating-contract.md ← durable principles + safety + concurrency
@@ -79,20 +79,20 @@ 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/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)
│ ├── chorus.py ← validated client + high-level CLI (capture/resolve/recall/link/load/scope/lock/config)
│ ├── chorus_config.py ← resolves owner/endpoint/key from the machine-local config (env-overridable)
│ ├── chorus_index.py ← entity index (registry, resolve, name→path map)
│ ├── chorus_links.py ← cross-link primitives (Related parsing, bidirectional linking)
│ ├── chorus_ops.py ← high-level ops (capture, recall, resolve, link, agent-log)
│ ├── routing.json ← canonical machine-readable route manifest (linter enforces it)
│ ├── vault_lint.py ← read-only invariant + graph-health checker (Vault Health)
│ ├── 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_chorus_client.py ← offline regression 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)
└── scaffold/ ← verbatim files the bootstrap writes into the vault
├── README.vault.md echo-vault.md
├── README.vault.md chorus-vault.md
├── templates/ (8 note templates)
└── anchors/ (operator-preferences, current-context, inbox seeds)
```
@@ -101,22 +101,22 @@ skills/echo-memory/
| Skill | Triggers |
|-------|----------|
| `echo-memory` | "remember that", "save to memory", "what do you know about me", "load my profile", "check my notes", "log this decision", "add to my inbox" — and proactively at the start of substantive conversations |
| `chorus-memory` | "remember that", "save to memory", "what do you know about me", "load my profile", "check my notes", "log this decision", "add to my inbox" — and proactively at the start of substantive conversations |
| Command | Does |
|---------|------|
| `/echo-load` | Cold-start context read (profile, scope, latest session, today, inbox) |
| `/echo-save <text>` | Route + persist via one-call `capture` (index-resolved, auto-linked) |
| `/echo-recall <query>` | Recall a topic's matching notes plus their linked neighbourhood |
| `/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 |
| `/chorus-load` | Cold-start context read (profile, scope, latest session, today, inbox) |
| `/chorus-save <text>` | Route + persist via one-call `capture` (index-resolved, auto-linked) |
| `/chorus-recall <query>` | Recall a topic's matching notes plus their linked neighbourhood |
| `/chorus-triage` | Drain aging inbox captures to their homes, logging each move |
| `/chorus-health` | Run `vault_lint.py` and summarize invariant + graph-health violations |
| `/chorus-sweep` | Bring the vault up to current spec (build index + symmetrize links) |
| `/chorus-reflect` | Extract durable items from the session, preview, and apply on approval |
| `/chorus-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 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**
- HTTPS access from the Claude/CoWork session environment to the endpoint configured in `~/.claude/chorus-memory/config.json`
- A machine-local config (`~/.claude/chorus-memory/config.json`) supplying the vault owner, endpoint, and API key — see **Configuration**
@@ -0,0 +1,20 @@
---
description: Check CHORUS readiness — Python, vault reachability, auth, bootstrap/schema, and key source
---
Use the **chorus-memory** skill to run a one-call readiness check before relying on memory.
```bash
CHORUS="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/chorus.py"
[ -f "$CHORUS" ] || CHORUS=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/chorus.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$CHORUS" 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 `CHORUS_OWNER`/`CHORUS_BASE`/`CHORUS_KEY`, then the machine-local
`~/.claude/chorus-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 chorus-memory config file and install it with `chorus.py config import <path>` (or `config set --owner … --endpoint … --key …`). For the full vault-invariant lint, run
`/chorus-health`.
@@ -1,14 +1,14 @@
---
description: Run the ECHO vault-health linter and summarize any invariant violations
description: Run the CHORUS vault-health linter and summarize any invariant violations
allowed-tools: Bash(python3 *vault_lint.py*), Bash(python *vault_lint.py*), Bash(ls /sessions/*), Bash(dirname *)
---
Run the bundled, read-only vault linter and report findings. Pass the conversation's current date (`ECHO_TODAY`) so stale/aging math uses the same clock the agent writes with:
Run the bundled, read-only vault linter and report findings. Pass the conversation's current date (`CHORUS_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"
SDIR="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts"
[ -d "$SDIR" ] || SDIR=$(dirname "$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/vault_lint.py 2>/dev/null | head -1)") # CoWork sandbox fallback
CHORUS_TODAY=<currentDate> python3 "$SDIR/vault_lint.py"
# Windows: use `python` or `py -3` if `python3` is not on PATH.
```
@@ -0,0 +1,18 @@
---
description: Load CHORUS memory — cold-start context read (profile, scope, latest session, today, inbox)
---
Use the **chorus-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
CHORUS="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/chorus.py"
[ -f "$CHORUS" ] || CHORUS=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/chorus.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$CHORUS" 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 `chorus.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 CHORUS isn't configured, ask for their chorus-memory config file (owner/endpoint/key), install it with `chorus.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,21 +1,21 @@
---
description: Recall a topic from ECHO memory — matching notes plus their linked neighbourhood
description: Recall a topic from CHORUS memory — matching notes plus their linked neighbourhood
argument-hint: "[topic, person, or project]"
---
Use the **echo-memory** skill to recall context for:
Use the **chorus-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
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py"
[ -f "$ECHO" ] || ECHO=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$ECHO" recall "$ARGUMENTS"
CHORUS="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/chorus.py"
[ -f "$CHORUS" ] || CHORUS=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/chorus.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$CHORUS" recall "$ARGUMENTS"
# Windows: use `python` or `py -3` if `python3` is not on PATH.
```
The corpus spans the entity graph **plus session logs and journal notes**, ranked by relevance × freshness × status — each hit shows its `updated:`/`status:`, so prefer fresh/active hits and treat stale ones as history. Add `--json` for structured hits (`path/score/type/updated/status/excerpt`) when you need to act on the results programmatically.
Then answer from the assembled context. If you only need the canonical path for one entity, use `echo.py resolve "<title>"` instead. Don't narrate the retrieval.
Then answer from the assembled context. If you only need the canonical path for one entity, use `chorus.py resolve "<title>"` instead. Don't narrate the retrieval.
@@ -3,7 +3,7 @@ description: Reflect on this session — extract durable memories and propose th
argument-hint: "[optional focus, e.g. 'just decisions']"
---
Use the **echo-memory** skill to run **session reflection** (H5). Scan this conversation for
Use the **chorus-memory** skill to run **session reflection** (H5). Scan this conversation for
things worth remembering across sessions, then propose them — don't write blindly.
1. **Extract** durable items from the conversation: new facts, preferences, decisions,
@@ -19,10 +19,10 @@ things worth remembering across sessions, then propose them — don't write blin
apply after they confirm.
```bash
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" # python3 (Windows: python / py -3)
[ -f "$ECHO" ] || ECHO=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$ECHO" reflect proposals.json # dry-run: validate + classify + preview
python3 "$ECHO" reflect proposals.json --apply # write — routes each via capture (indexed, linked, logged)
CHORUS="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/chorus.py" # python3 (Windows: python / py -3)
[ -f "$CHORUS" ] || CHORUS=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/chorus.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$CHORUS" reflect proposals.json # dry-run: validate + classify + preview
python3 "$CHORUS" reflect proposals.json --apply # write — routes each via capture (indexed, linked, logged)
```
`reflect` dedups every proposal against the entity index (so it shows create-vs-update),
@@ -1,19 +1,19 @@
---
description: Save to ECHO memory — route content to its canonical home (search-first, idempotent)
description: Save to CHORUS 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:
Use the **chorus-memory** skill to persist this to the CHORUS vault:
> $ARGUMENTS
Prefer the one-call **`capture`** — it routes (via the entity index), derives the canonical path, stamps complete frontmatter (kind-default `status`, the kind seeded into `tags`), indexes, auto-links mentioned entities, and writes the Agent-Log line. Pick the `--kind` from the content (`person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision`); add `--tags` when obvious; use `--inbox` only when the home is genuinely unknown. Write multi-line bodies to a file with the Write tool (cross-platform, no heredoc) — updates to an existing entity preserve the whole body, so don't pre-trim it.
```bash
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py" # run with: python3 "$ECHO" ... (Windows: python / py -3)
[ -f "$ECHO" ] || ECHO=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$ECHO" capture "<title>" <bodyfile> --kind <kind> [--aliases a,b] [--tags t1,t2] [--source p1,p2]
python3 "$ECHO" capture "<title>" --inbox # unknown home -> idempotent inbox line
CHORUS="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/chorus.py" # run with: python3 "$CHORUS" ... (Windows: python / py -3)
[ -f "$CHORUS" ] || CHORUS=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/chorus.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$CHORUS" capture "<title>" <bodyfile> --kind <kind> [--aliases a,b] [--tags t1,t2] [--source p1,p2]
python3 "$CHORUS" capture "<title>" --inbox # unknown home -> idempotent inbox line
```
**If capture exits `76` (duplicate gate):** the title strongly resembles an existing entity — do NOT retry blindly. Show the operator the printed candidates and either update the existing note (`capture ... --merge-into <slug>`) or, only after they confirm it's genuinely distinct, re-run with `--force`.
@@ -21,8 +21,8 @@ python3 "$ECHO" capture "<title>" --inbox # unknown home -> idempotent inbox
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
python3 "$CHORUS" put <routed/path>.md <bodyfile> # create/overwrite (verifies)
python3 "$CHORUS" patch <path>.md append heading "<H1::Sub>" <bodyfile> # targeted append
```
Write in third person about the vault owner; never put `[[wikilinks]]` in frontmatter. `capture` handles `agent_written`, `source_notes`, indexing, and linking for you; if you write by hand, `resolve "<title>"` first to avoid duplicates and `bump` `updated:` only on substantive changes.
@@ -1,16 +1,16 @@
---
description: Bring the ECHO vault up to the current feature spec (entity index + cross-links)
description: Bring the CHORUS vault up to the current feature spec (entity index + cross-links)
allowed-tools: Bash(python3 *sweep.py*), Bash(python *sweep.py*), Bash(ls /sessions/*), Bash(dirname *)
---
Use the **echo-memory** skill to bring the vault up to the current spec. Run the sweep **dry-run first**, show the operator the plan, and only `--apply` on their go-ahead:
Use the **chorus-memory** skill to bring the vault up to the current spec. Run the sweep **dry-run first**, show the operator the plan, and only `--apply` on their go-ahead:
```bash
SDIR="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts"
[ -d "$SDIR" ] || SDIR=$(dirname "$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/sweep.py 2>/dev/null | head -1)") # CoWork sandbox fallback
SDIR="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts"
[ -d "$SDIR" ] || SDIR=$(dirname "$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/sweep.py 2>/dev/null | head -1)") # CoWork sandbox fallback
python3 "$SDIR/sweep.py" # plan (read-only)
python3 "$SDIR/sweep.py" --apply # write
# Windows: use `python` or `py -3` if `python3` is not on PATH.
```
The sweep (1) creates `_agent/index/`, (2) rebuilds the entity index from existing notes, (3) symmetrizes `## Related` cross-links (adds only the missing reciprocal direction — never invents new links), and (4) stamps the marker `schema_version`. It is idempotent and read-only without `--apply`. If `schema_version` is behind, run `migrate.py` first. After applying, run `/echo-health` to confirm invariants.
The sweep (1) creates `_agent/index/`, (2) rebuilds the entity index from existing notes, (3) symmetrizes `## Related` cross-links (adds only the missing reciprocal direction — never invents new links), and (4) stamps the marker `schema_version`. It is idempotent and read-only without `--apply`. If `schema_version` is behind, run `migrate.py` first. After applying, run `/chorus-health` to confirm invariants.
@@ -1,13 +1,13 @@
---
description: Triage the ECHO inbox — route aging captures to their canonical homes and log the moves
description: Triage the CHORUS inbox — route aging captures to their canonical homes and log the moves
---
Use the **echo-memory** skill to run **one-tap Inbox Triage** via the `triage` verb (it reuses the reflect pipeline: classify against the entity index → preview → apply via `capture`, and writes the audit log for you).
Use the **chorus-memory** skill to run **one-tap Inbox Triage** via the `triage` verb (it reuses the reflect pipeline: classify against the entity index → preview → apply via `capture`, and writes the audit log for you).
```bash
ECHO="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/echo.py"
[ -f "$ECHO" ] || ECHO=$(ls /sessions/*/mnt/.remote-plugins/*/skills/echo-memory/scripts/echo.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$ECHO" triage --list --json
CHORUS="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/chorus.py"
[ -f "$CHORUS" ] || CHORUS=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/chorus.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$CHORUS" triage --list --json
# Windows: use `python` or `py -3` if `python3` is not on PATH.
```
@@ -16,8 +16,8 @@ python3 "$ECHO" triage --list --json
3. **Preview, then apply** with the operator's go-ahead:
```bash
python3 "$ECHO" triage proposals.json # dry-run: classify + preview, writes nothing
python3 "$ECHO" triage proposals.json --apply # route via capture + log each move
python3 "$CHORUS" triage proposals.json # dry-run: classify + preview, writes nothing
python3 "$CHORUS" triage proposals.json --apply # route via capture + log each move
```
`--apply` records every move in `inbox/processing-log/YYYY-MM-DD.md` (`- <original> → <destination>`) automatically. Do **not** delete the original captures unless the operator explicitly asks — the processing log is the audit trail. A proposal that stops at the duplicate gate (exit note in the summary) should be re-proposed with the existing entity's title.
+27
View File
@@ -0,0 +1,27 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|clear",
"hooks": [
{
"type": "command",
"command": "S=\"${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/chorus_hook_session_start.py\"; [ -f \"$S\" ] || S=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/chorus_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/chorus-memory/scripts/chorus_hook_stop.py\"; [ -f \"$S\" ] || S=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/chorus_hook_stop.py 2>/dev/null | head -1); [ -n \"$S\" ] || exit 0; python3 \"$S\" || python \"$S\"",
"timeout": 30
}
]
}
]
}
}
@@ -1,42 +1,42 @@
---
name: echo-memory
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.
name: chorus-memory
description: Use the CHORUS 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
# CHORUS Memory
Use the **ECHO** Obsidian vault as persistent memory. Read context accumulated across sessions; write things the operator asks to be remembered.
Use the **CHORUS** Obsidian vault as persistent memory. Read context accumulated across sessions; write things the operator asks to be remembered.
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.
The vault belongs to a single **owner**, configured per machine (the `owner` field in `~/.claude/chorus-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
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.
The owner, endpoint, and API key are **machine-local** — the committed plugin ships none of them. `chorus.py` (via `chorus_config`) resolves each field from `~/.claude/chorus-memory/config.json`, with an environment override per field (`CHORUS_OWNER`, `CHORUS_BASE`, `CHORUS_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.
```
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>
endpoint = <config "endpoint" / $CHORUS_BASE>
key = <config "key" / $CHORUS_KEY — resolved at runtime, never stored in the plugin>
owner = <config "owner" / $CHORUS_OWNER — how to refer to the vault owner, third person>
```
### First run — set up the machine if it isn't configured
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:
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.
1. **Detect.** Any vault op on an unconfigured machine fails with `NOT CONFIGURED` (`chorus.py load` prints a banner and exits `78`; other verbs exit `2`; `/chorus-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 CHORUS isn't configured on this machine and ask them to **provide their chorus-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`.
- They hand you a file → `python3 "$CHORUS" config import <path-to-their-file>` (validates it and writes `~/.claude/chorus-memory/config.json`).
- They paste values → `python3 "$CHORUS" config set --owner "…" --endpoint "https://…" --key "…"`.
- Inspect with `python3 "$CHORUS" 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.
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 CHORUS memory.
**The plugin is the single source of truth for ECHO.** The vault holds data only — no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md` control docs live there. All structure and procedure ship here:
**The plugin is the single source of truth for CHORUS.** The vault holds data only — no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md` control docs live there. All structure and procedure ship here:
- Durable principles, memory model, and safety rules: `references/operating-contract.md`
- Bootstrapping an empty vault, repair, and schema migrations: `references/bootstrap.md`
@@ -46,7 +46,7 @@ Always pass the `Authorization: Bearer` header. Paths address the vault **at its
Executable logic ships under `scripts/`**pure Python**, so the whole toolchain runs identically on Windows, macOS, and Linux (only a Python 3 interpreter is required; no bash, no platform-specific `date`):
- `scripts/echo.py` — the **validated API client**; prefer it over hand-built `curl` (the *nix fallback in `references/api-reference.md`)
- `scripts/chorus.py` — the **validated API client**; prefer it over hand-built `curl` (the *nix fallback in `references/api-reference.md`)
- `scripts/routing.json` — the **canonical, machine-readable** route manifest (the routing map's source of truth; the linter enforces it)
- `scripts/vault_lint.py` — read-only invariant checker (Vault Health)
- `scripts/check_routing.py` — verifies the routing docs stay in sync with `routing.json` (dev/CI; offline)
@@ -54,25 +54,25 @@ Executable logic ships under `scripts/` — **pure Python**, so the whole toolch
## Bundled Tooling (prefer over raw curl)
All paths below are under `${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/`. **Invoke with `python3`** (on Windows where that isn't on PATH, use `python` or `py -3`).
All paths below are under `${CLAUDE_PLUGIN_ROOT}/skills/chorus-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`.
**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 `$CHORUS`/`$LINT`/`$SWEEP` snippets below already do this. The same fallback applies to `vault_lint.py`, `sweep.py`, `bootstrap.py`, and `migrate.py`.
**`scripts/echo.py` — use this for every read/write.** It centralizes auth, **HTTP-status checking** (a failed write exits non-zero instead of looking like success), one bounded retry on 5xx/connection errors, whole-line idempotent append, correct `::` heading targets, frontmatter patches, a read-back-confirmed advisory lock, and a one-call cold-start `load`. The raw `curl` recipes in `references/api-reference.md` are the underlying mechanics / *nix fallback — reach for them only if `echo.py` is unavailable, and if you do, **check the HTTP status yourself** (the PATCH-heading `400 invalid-target` failure silently loses writes otherwise).
**`scripts/chorus.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 `chorus.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
python3 "$ECHO" search <terms...>
python3 "$ECHO" put <path> <file> # create/overwrite (read-back verified)
python3 "$ECHO" append <path> "<line>" # idempotent: skips only on an exact whole-line match
python3 "$ECHO" patch <path> append heading "<H1::Sub>" <file>
python3 "$ECHO" fm <path> updated '"2026-06-19"' ; python3 "$ECHO" bump <path> # frontmatter
python3 "$ECHO" scope show ; python3 "$ECHO" scope set "<text>"
python3 "$ECHO" lock <session-id> ; python3 "$ECHO" unlock <session-id>
CHORUS="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/chorus.py" # call as: python3 "$CHORUS" <cmd> ...
[ -f "$CHORUS" ] || CHORUS=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/chorus.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$CHORUS" load # cold-start: the 6 orientation reads in one call
python3 "$CHORUS" get <path> # 404 -> exit 44
python3 "$CHORUS" ls <dir> ; python3 "$CHORUS" map <path> # listing / document-map
python3 "$CHORUS" search <terms...>
python3 "$CHORUS" put <path> <file> # create/overwrite (read-back verified)
python3 "$CHORUS" append <path> "<line>" # idempotent: skips only on an exact whole-line match
python3 "$CHORUS" patch <path> append heading "<H1::Sub>" <file>
python3 "$CHORUS" fm <path> updated '"2026-06-19"' ; python3 "$CHORUS" bump <path> # frontmatter
python3 "$CHORUS" scope show ; python3 "$CHORUS" scope set "<text>"
python3 "$CHORUS" lock <session-id> ; python3 "$CHORUS" unlock <session-id>
```
### High-level memory ops (prefer these — they do the routing for you)
@@ -80,17 +80,17 @@ 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] [--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>" [--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)
python3 "$CHORUS" capture "<title>" <bodyfile> --kind <kind> [--aliases a,b] [--tags t1,t2] [--source p1,p2] [--status s] [--date YYYY-MM-DD] [--domain business]
python3 "$CHORUS" capture "<title>" --inbox # unknown home -> idempotent inbox line
python3 "$CHORUS" resolve "<mention>" # mention -> canonical path (or where to create)
python3 "$CHORUS" recall "<query>" [--json] # ranked search + 1-hop expansion -> connected context
python3 "$CHORUS" link <pathA> <pathB> [--json] # add reciprocal [[Related]] links A<->B
python3 "$CHORUS" 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 **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.
- **Duplicate gate (create path).** When a new title strongly resembles an existing entity (fuzzy score ≥ 0.6, env `CHORUS_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. "chorus memory" surfaces the project `chorus`). 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. 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.
@@ -100,26 +100,26 @@ The index is maintained automatically by `capture`; `sweep.py` rebuilds it and b
### Concurrency — the vault is shared, so coordinate writes
ECHO is read/written by multiple clients (Claude Code **and** CoWork sessions). The single-line files (`heartbeat/last-session.md`, `current-context.md::Scope`, `inbox.md`) assume a single writer at a time. Before a burst of writes in a session that may overlap another, take the **advisory lock**, and release it at session end:
CHORUS is read/written by multiple clients (Claude Code **and** CoWork sessions). The single-line files (`heartbeat/last-session.md`, `current-context.md::Scope`, `inbox.md`) assume a single writer at a time. Before a burst of writes in a session that may overlap another, take the **advisory lock**, and release it at session end:
```bash
python3 "$ECHO" lock "cc-<session-id>" # exit 75 if another session holds a fresh lock, or you lost the race
python3 "$CHORUS" lock "cc-<session-id>" # exit 75 if another session holds a fresh lock, or you lost the race
# ... do the writes ...
python3 "$ECHO" unlock "cc-<session-id>"
python3 "$CHORUS" 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 the operator 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, `chorus.py` re-reads and backs off if another writer won the race) and cooperative (a stale lock past `CHORUS_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), `/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.
`/chorus-load` (cold-start read), `/chorus-save <text>` (route + persist via `capture`), `/chorus-recall <query>` (recall a topic's neighbourhood), `/chorus-triage` (drain the inbox), `/chorus-health` (run the linter), `/chorus-sweep` (bring the vault up to current spec), `/chorus-reflect` (extract→preview→apply durable items from the session), `/chorus-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.
- **SessionStart** runs `chorus.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 `/chorus-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.
@@ -140,11 +140,11 @@ Load at the start of any substantive conversation — anything beyond a single q
### 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 "$CHORUS" 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:
| # | GET | Notes |
|---|-----|-------|
| 1 | `/vault/_agent/echo-vault.md` | The bootstrap marker. 404 → vault not set up; follow `references/bootstrap.md`. 200 → check its `schema_version` and migrate if older. |
| 1 | `/vault/_agent/chorus-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` | 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. |
@@ -156,21 +156,21 @@ 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 — 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.
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 "$CHORUS" 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 "$CHORUS" 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 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:
```bash
python3 "$ECHO" recall "<topic or title>" # the matching notes AND their one-hop neighbourhood
python3 "$CHORUS" recall "<topic or title>" # the matching notes AND their one-hop neighbourhood
```
`recall` resolves the term against the entity index, searches, and expands one hop along `## Related` links and `source_notes` — so you get the project note *plus* its linked decisions, people, and prior sessions, not an isolated file. (If you only need the canonical path, `resolve "<title>"` is the O(1) lookup.)
Do NOT narrate this loading. Reading memory is expected behavior.
## Inbox Triage (one-tap: `echo.py triage`)
## Inbox Triage (one-tap: `chorus.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.
@@ -181,11 +181,11 @@ At the start of a substantive session, check the inbox. If it holds lines older
**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:
```bash
python3 "$ECHO" triage --list --json # structured captures: {line, date, text, age_days}
python3 "$CHORUS" 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
python3 "$CHORUS" triage proposals.json # dry-run: classify + preview, writes nothing
python3 "$CHORUS" triage proposals.json --apply # route via capture + log each move
```
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.
@@ -216,15 +216,15 @@ Write when the operator:
Write in third person about the operator. Every note carries the canonical frontmatter (see below). Agent-generated notes set `agent_written: true`.
**Default write path: `capture`.** For a routed memory (a fact about a person/company/project/concept/etc.), prefer `python3 "$ECHO" capture "<title>" <bodyfile> --kind <kind>` — it does the search-first, path derivation, frontmatter, indexing, auto-linking, and Agent-Log line in one call (see **High-level memory ops**). Drop to the lower-level `put`/`patch`/`append` verbs below only for shapes `capture` doesn't model (a project `## Status` replacement, an ADR mirror, a daily-note edit) or to inspect/repair.
**Default write path: `capture`.** For a routed memory (a fact about a person/company/project/concept/etc.), prefer `python3 "$CHORUS" 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.
### Before you write — search first (MANDATORY for new notes)
`capture` and `resolve` enforce this automatically via the entity index. If you write a slug-addressed note **by hand**, first **`resolve` the title/slug** (it matches aliases and returns the canonical path or confirms none exists); if `resolve` is unavailable, `search` the whole vault for the slug. Listing a single folder (e.g. `projects/active/`) is NOT sufficient — a note with the same slug may exist in `projects/on-hold/`, `projects/incubating/`, `projects/archived/`, or under a different folder entirely.
```bash
python3 "$ECHO" resolve "<title>" # preferred: alias-aware, O(1)
python3 "$ECHO" search <slug> # fallback when the index isn't trusted
python3 "$CHORUS" resolve "<title>" # preferred: alias-aware, O(1)
python3 "$CHORUS" search <slug> # fallback when the index isn't trusted
```
If a match is found:
@@ -232,13 +232,13 @@ If a match is found:
- 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 the operator and ask which should be canonical before writing.
**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.
**Search both the slug AND any human title** the operator used (e.g. slug `chorus-memory` and title `CHORUS 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.
### Before you append — read first (idempotency)
`echo.py append` is **whole-line idempotent**: it GETs the file and skips the POST when the exact line already exists, so a retry, replay, or overlapping session can't grow duplicate lines. Use it for single-line additions to `inbox/captures/inbox.md`, a daily note's `## Agent Log`, or a `## Fact / Pattern` / `## Observations` / `## Log` heading. (A raw POST is **not** idempotent — if you must use curl, GET first and whole-line-match the entry yourself; see `references/api-reference.md`.) This does **not** apply to PUT or PATCH `replace`, which overwrite by design.
`chorus.py append` is **whole-line idempotent**: it GETs the file and skips the POST when the exact line already exists, so a retry, replay, or overlapping session can't grow duplicate lines. Use it for single-line additions to `inbox/captures/inbox.md`, a daily note's `## Agent Log`, or a `## Fact / Pattern` / `## Observations` / `## Log` heading. (A raw POST is **not** idempotent — if you must use curl, GET first and whole-line-match the entry yourself; see `references/api-reference.md`.) This does **not** apply to PUT or PATCH `replace`, which overwrite by design.
### Append, patch, put — via the client
@@ -246,27 +246,27 @@ Write multi-line bodies to a file first (use the Write tool — cross-platform,
```bash
# additive line (idempotent). Anchor the date on the conversation's currentDate.
python3 "$ECHO" append inbox/captures/inbox.md "- <currentDate>: <entry>"
python3 "$CHORUS" append inbox/captures/inbox.md "- <currentDate>: <entry>"
# targeted heading append/replace. The heading Target is the FULL `::`-delimited path
# from the H1 — a bare subheading returns 400 invalid-target and the write is LOST.
# If unsure of the exact path, GET the document map first and copy the heading verbatim:
python3 "$ECHO" map _agent/memory/semantic/operator-preferences.md
python3 "$ECHO" patch _agent/memory/semantic/operator-preferences.md \
python3 "$CHORUS" map _agent/memory/semantic/operator-preferences.md
python3 "$CHORUS" patch _agent/memory/semantic/operator-preferences.md \
append heading "Operator Preferences::Fact / Pattern" <bodyfile>
# `replace` (instead of `append`) overwrites a section entirely (e.g. a project's "Name::Status").
# create/overwrite a whole file (read-back verified; intermediate dirs auto-created)
python3 "$ECHO" put projects/active/<slug>.md <bodyfile>
python3 "$CHORUS" 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
CHORUS_TODAY=<currentDate> python3 "$CHORUS" 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
python3 "$CHORUS" fm <path> status active
# search (run before creating any slug-addressed note — see the search-first rule above)
python3 "$ECHO" search <terms...>
python3 "$CHORUS" search <terms...>
```
Every PUT body needs the canonical YAML frontmatter (see `references/vault-layout.md`); set `agent_written: true` and `source_notes`. Bump `updated:` on substance, not on heartbeat — adding an Agent Log line, an inbox capture, or a timestamped Observations bullet is not a meaningful content change. Raw curl mechanics for every verb live in `references/api-reference.md`.
@@ -278,16 +278,16 @@ Every PUT body needs the canonical YAML frontmatter (see `references/vault-layou
**Preferred — one command:**
```bash
python3 "$ECHO" scope set "<new scope summary>"
python3 "$CHORUS" scope set "<new scope summary>"
```
`scope set` does the whole switch atomically and correctly: it archives the **prior** scope to `## Scope History` (dated, truncated), replaces `## Scope` with the new text, and stamps the `scope_updated:` frontmatter timestamp. That timestamp is the **freshness signal** — it's what `echo.py scope show` and the `vault_lint.py` drift check read to tell whether the recorded scope still reflects current work. Always switch through `scope set` so `scope_updated` stays honest; a hand-edited `## Scope` that skips the stamp reintroduces silent drift.
`scope set` does the whole switch atomically and correctly: it archives the **prior** scope to `## Scope History` (dated, truncated), replaces `## Scope` with the new text, and stamps the `scope_updated:` frontmatter timestamp. That timestamp is the **freshness signal** — it's what `chorus.py scope show` and the `vault_lint.py` drift check read to tell whether the recorded scope still reflects current work. Always switch through `scope set` so `scope_updated` stays honest; a hand-edited `## Scope` that skips the stamp reintroduces silent drift.
**Manual fallback** (only if `echo.py` is unavailable): PATCH `prepend` the prior scope to `## Scope History`, PATCH `replace` `## Scope`, then PATCH the frontmatter `scope_updated:` (and `updated:`) to today (see `references/api-reference.md` for the raw curl). Note `scope_updated` must already exist in frontmatter — a `PATCH replace` on a missing field returns `400 invalid-target`; run `bootstrap.py` to add it.
**Manual fallback** (only if `chorus.py` is unavailable): PATCH `prepend` the prior scope to `## Scope History`, PATCH `replace` `## Scope`, then PATCH the frontmatter `scope_updated:` (and `updated:`) to today (see `references/api-reference.md` for the raw curl). Note `scope_updated` must already exist in frontmatter — a `PATCH replace` on a missing field returns `400 invalid-target`; run `bootstrap.py` to add it.
This keeps a rolling trail of recent scopes in one file instead of spawning separate stash notes. Trim Scope History to the last ~10 entries when it grows past that.
**Drift backstop:** `vault_lint.py` flags when ≥ `SCOPE_STALE_SESSIONS` (default 3) session logs are dated after `scope_updated` — i.e. work happened without a scope switch. It's advisory (surfaced in Vault Health / `/echo-health`), the mechanical safety net under the load-time judgment above.
**Drift backstop:** `vault_lint.py` flags when ≥ `SCOPE_STALE_SESSIONS` (default 3) session logs are dated after `scope_updated` — i.e. work happened without a scope switch. It's advisory (surfaced in Vault Health / `/chorus-health`), the mechanical safety net under the load-time judgment above.
## Journal Rollups (the journal is one continuum)
@@ -309,12 +309,12 @@ A weekly/monthly rollup is a **light digest** — open threads across `projects/
On the first substantive session of a calendar month, run a quick health pass and write findings to `_agent/health/YYYY-MM-vault-health.md`. This is **agent self-maintenance, not a journal entry** — it lives under `_agent/` because it's about the vault's mechanical integrity, not the operator's work narrative. Don't auto-fix without asking.
Run the bundled linter first — it mechanically checks the invariants below so you don't eyeball them. **Pass `ECHO_TODAY` = the conversation's `currentDate`** so stale/aging math uses the same clock you write with (not the runner's machine date):
Run the bundled linter first — it mechanically checks the invariants below so you don't eyeball them. **Pass `CHORUS_TODAY` = the conversation's `currentDate`** so stale/aging math uses the same clock you write with (not the runner's machine date):
```bash
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"
LINT="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/vault_lint.py"
[ -f "$LINT" ] || LINT=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/vault_lint.py 2>/dev/null | head -1) # CoWork sandbox fallback
CHORUS_TODAY=<currentDate> python3 "$LINT"
```
Exit codes: `0` clean · `1` violations · `2` unreachable · `3` not bootstrapped. Checks (the linter asserts each and prints violations):
@@ -328,24 +328,24 @@ 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).
10. **Incomplete entity frontmatter** — entity notes missing or empty `status:`/`tags:` per the kind-scoped requirement map (`KIND_REQUIRED_FM` in `chorus_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 (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:
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 `/chorus-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
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
SWEEP="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/sweep.py"
[ -f "$SWEEP" ] || SWEEP=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/sweep.py 2>/dev/null | head -1) # CoWork sandbox fallback
python3 "$SWEEP" # plan (read-only)
python3 "$SWEEP" --apply # write
```
Run it after `migrate.py` (which handles structural/schema changes) — or any time `/echo-health` reports index drift. It is idempotent; re-running is safe.
Run it after `migrate.py` (which handles structural/schema changes) — or any time `/chorus-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 the operator's go-ahead.
**Performance.** Full-vault scripts (`sweep.py`, `vault_lint.py`, recall rebuild) are connection-pooled (keep-alive) and read the whole vault concurrently via `echo.read_many`, so they finish in about a second on a few-hundred-note vault instead of timing out on hundreds of serial TLS handshakes. Tune with `ECHO_WORKERS` (default 8, bulk-read concurrency) and `ECHO_TIMEOUT` (default 30s, per-request). For a vault large enough that even the concurrent pass nears the tool timeout, run the script with the harness's background-execution option rather than blocking the turn.
**Performance.** Full-vault scripts (`sweep.py`, `vault_lint.py`, recall rebuild) are connection-pooled (keep-alive) and read the whole vault concurrently via `chorus.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 `CHORUS_WORKERS` (default 8, bulk-read concurrency) and `CHORUS_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.
## Daily Note — Agent Log
@@ -353,13 +353,13 @@ After substantive activity, write a one-line entry to today's daily note's `## A
**Procedure (resilient)** — all dates are the conversation's `currentDate`, not the machine clock:
1. `python3 "$ECHO" get journal/daily/<currentDate>.md`.
1. `python3 "$CHORUS" get journal/daily/<currentDate>.md`.
2. **404** — fetch `journal/templates/daily-note-template.md`, substitute its `{{date:YYYY-MM-DD}}` tokens to `<currentDate>`, write the result to a file (Write tool), and `put` it to the daily path; then go to step 4.
3. **200 but no `## Agent Log`** — detect by matching an anchored `^## Agent Log` line in the **raw markdown** you just fetched. Do **not** check the document map, whose headings are full `::`-delimited paths (e.g. `2026-06-07::Agent Log`), so a bare `Agent Log` never matches and you'd append a duplicate heading. If missing, `post` a body of exactly `\n\n## Agent Log\n` to add the heading.
4. PATCH-append the entry under `<currentDate>::Agent Log` (the daily note's H1 is the date, so that is the full target path):
```bash
python3 "$ECHO" patch journal/daily/<currentDate>.md append heading "<currentDate>::Agent Log" <bodyfile>
python3 "$CHORUS" patch journal/daily/<currentDate>.md append heading "<currentDate>::Agent Log" <bodyfile>
```
## Where to Write
@@ -408,7 +408,7 @@ At the end of substantive conversations (ones that produced decisions, artifacts
Write the body (see `references/session-log-template.md`) to a file with the Write tool, then:
```bash
python3 "$ECHO" put _agent/sessions/<currentDate>-HHMM-<slug>.md <bodyfile>
python3 "$CHORUS" 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.
@@ -416,7 +416,7 @@ Then add a one-line entry to today's daily note via the **Daily Note — Agent L
Finally, update the heartbeat pointer so the next session can orient in one read (this closes the loop with loading-procedure Step 4 — a pointer nobody writes is a pointer nobody can read). It is a single line, `<session-log-path> @ <ISO-timestamp>`; write it to a file and PUT it:
```bash
python3 "$ECHO" put _agent/heartbeat/last-session.md <bodyfile>
python3 "$CHORUS" 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.
@@ -1,9 +1,9 @@
# ECHO Performance Test Suite — Plan
# CHORUS Performance Test Suite — Plan
**Status:** plan only — nothing here is to be run yet.
**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.
**Scope:** a *performance / timing* harness. It complements — does not replace — the existing correctness tests (`test_chorus_client.py`, `test_v1_scaffold.py`, the eval feature tests), which stay as the pass/fail gate for behaviour.
---
@@ -15,14 +15,14 @@
## 2. Methodology (applies to every metric)
- **Harness:** a new `bench.py` under `scripts/` (or `eval/perf/`), reusing the live `echo` client module so it measures the real pooled/concurrent code path, not a reimplementation.
- **Harness:** a new `bench.py` under `scripts/` (or `eval/perf/`), reusing the live `chorus` client module so it measures the real pooled/concurrent code path, not a reimplementation.
- **Timing:** `time.perf_counter()` around each operation. Report **min / median / p90 / p95 / max / mean** over N iterations — not a single number. Single timings hide GC pauses and TLS renegotiation.
- **Warm-up:** discard the first 12 iterations (cold connection pool, cold server cache) and report them *separately* as the cold-start cost — that delta is itself one of the most interesting numbers (it's what 1.1.0's keep-alive was meant to kill).
- **Iterations:** default N=10 for read-heavy metrics, N=5 for write-heavy (writes are slower and we don't want to bloat the vault). Make N a CLI flag.
- **Environment capture:** record `ECHO_WORKERS`, `ECHO_TIMEOUT`, vault note count, git commit/plugin version, and wall-clock date in the results header so two runs are comparable.
- **Environment capture:** record `CHORUS_WORKERS`, `CHORUS_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 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.
- **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 (`chorus.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 configured endpoint (`chorus.BASE` / `$CHORUS_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.
---
@@ -35,7 +35,7 @@
**Procedure:**
1. Enumerate all vault paths (the same listing `sweep.py`/`vault_lint.py` walk).
2. **Serial baseline:** GET each path one at a time on a single connection. Time total + per-note.
3. **Pooled+concurrent:** `read_many(paths)` at the default `ECHO_WORKERS`. Time total.
3. **Pooled+concurrent:** `read_many(paths)` at the default `CHORUS_WORKERS`. Time total.
4. Report total time, notes/sec, and the **speedup ratio** (serial ÷ concurrent). Repeat the concurrent pass N times for percentile spread.
**What "good" looks like:** concurrent should be multiples faster than serial; full-vault concurrent read should stay well under the agent tool timeout (the original failure mode). Record current note count alongside — the metric is only comparable at similar vault sizes.
@@ -47,14 +47,14 @@
**What it measures:** the cost of a `recall "<subject>"` — search + 1-hop neighbourhood expansion along `## Related` links and `source_notes` — which is the real "what do we know about X" operation, and exercises the 1.2.0 resolver. APTA and CapMetro are live subjects in the vault, so they're realistic fixtures.
**Procedure:**
1. Pick a fixed fixture set of subjects with **different neighbourhood sizes**: a small one, a hub note with many links (e.g. the `echo` project or `operator-preferences`), and the two named ones (APTA, CapMetro). Neighbourhood size is the real cost driver, so vary it deliberately.
1. Pick a fixed fixture set of subjects with **different neighbourhood sizes**: a small one, a hub note with many links (e.g. the `chorus` project or `operator-preferences`), and the two named ones (APTA, CapMetro). Neighbourhood size is the real cost driver, so vary it deliberately.
2. Time end-to-end `recall` for each: (a) the search call, (b) the 1-hop expansion reads, (c) total. Break out the search vs. expansion split — it tells us whether latency is in the index/search or in the fan-out reads (which 1.1.0's `read_many` should accelerate).
3. Also time bare `resolve "<subject>"` (the O(1) index lookup) separately as the floor — recall should be resolve + search + N expansion reads.
4. Report per-subject and aggregate percentiles; annotate each with its neighbourhood node count so time-per-node is derivable.
**What "good" looks like:** `resolve` is near-instant (in-memory index); `recall` scales with neighbourhood size but the expansion reads are concurrent, so a hub note shouldn't be linearly worse than a leaf.
**Live result + the `expand-graph` sub-metric (added 2026-06-23).** The first run showed `recall()` at 2.84.7 s/subject. Profiling pinned it on the graph layer, not BM25: `load_index()` ~500 ms (index already persisted/incremental), `score()` 0.1 ms, `expand_graph()` ~3,000 ms doing one serial GET per neighbour. Fix shipped in `echo_recall.py` — the BFS now fetches each hop via `read_many`. A dedicated **`expand-graph`** metric times serial vs concurrent expansion (gate: ≥2× min) and asserts byte-identical ranking + scores against a serial reference, so the fix can't silently regress. Measured 3.54.2×; end-to-end `recall()` down ~1.7×.
**Live result + the `expand-graph` sub-metric (added 2026-06-23).** The first run showed `recall()` at 2.84.7 s/subject. Profiling pinned it on the graph layer, not BM25: `load_index()` ~500 ms (index already persisted/incremental), `score()` 0.1 ms, `expand_graph()` ~3,000 ms doing one serial GET per neighbour. Fix shipped in `chorus_recall.py` — the BFS now fetches each hop via `read_many`. A dedicated **`expand-graph`** metric times serial vs concurrent expansion (gate: ≥2× min) and asserts byte-identical ranking + scores against a serial reference, so the fix can't silently regress. Measured 3.54.2×; end-to-end `recall()` down ~1.7×.
### 3.3 Timed bulk get / put / append
@@ -62,7 +62,7 @@
**Procedure (all in the `_agent/_bench/<run-id>/` namespace):**
- **Bulk GET:** create K fixture notes once, then time reading them back — both serially and via `read_many` — to compare against §3.1 at a controlled, fixed K (e.g. 25/50/100) so the read curve is isolated from whatever the live vault happens to contain.
- **Bulk PUT:** time creating K notes. `echo.py put` is read-back-verified (it re-GETs after writing), so this measures the true write+verify cost, not a fire-and-forget POST. Report puts/sec and per-put median.
- **Bulk PUT:** time creating K notes. `chorus.py put` is read-back-verified (it re-GETs after writing), so this measures the true write+verify cost, not a fire-and-forget POST. Report puts/sec and per-put median.
- **Bulk APPEND:** time K appends to a single growing note. `append` is whole-line idempotent (it GETs first and skips duplicates), so also measure the **idempotent-skip path**: re-append the same K lines and confirm near-zero net writes — time the skip vs. the real append. This validates that idempotency isn't quietly O(file size) per append as the note grows.
- **Concurrent write caveat:** writes to single-line shared files assume one writer; the bulk-write bench must use per-note targets (or hold the lock) so it doesn't model an unsupported pattern.
@@ -76,15 +76,15 @@ These target the rest of the 1.1.0/1.2.0 surface and the known historical failur
1. **Connection-pool cold vs. warm.** Isolate the keep-alive win directly: time the *first* request after pool creation vs. the median of subsequent requests. This is the single clearest proof the pooling fix is alive; a regression here is the early-warning signal for the old per-request-handshake bug.
2. **Concurrency scaling sweep (`ECHO_WORKERS`).** Run §3.1 at workers = 1, 2, 4, 8, 16. Plot total time vs. workers to find the knee and confirm the default of 8 is sensible for the current vault/WAN, and that it degrades gracefully (no errors/timeouts) at high concurrency.
2. **Concurrency scaling sweep (`CHORUS_WORKERS`).** Run §3.1 at workers = 1, 2, 4, 8, 16. Plot total time vs. workers to find the knee and confirm the default of 8 is sensible for the current vault/WAN, and that it degrades gracefully (no errors/timeouts) at high concurrency.
3. **Fuzzy-resolve latency + accuracy (1.2.0).** Two parts: (a) *timing*`resolve` on exact, alias, and shortened/typo'd mentions; the fuzzy-candidate path does more work, so measure its overhead vs. an exact hit. (b) *correctness* — a fixture table of mention → expected canonical slug (e.g. "echo memory" → `echo`, alias hits, a near-miss that *should* return ranked candidates rather than spawn a duplicate). This guards the actual bug 1.2.0 fixed. Correctness assertions, not just timing.
3. **Fuzzy-resolve latency + accuracy (1.2.0).** Two parts: (a) *timing*`resolve` on exact, alias, and shortened/typo'd mentions; the fuzzy-candidate path does more work, so measure its overhead vs. an exact hit. (b) *correctness* — a fixture table of mention → expected canonical slug (e.g. "chorus memory" → `chorus`, alias hits, a near-miss that *should* return ranked candidates rather than spawn a duplicate). This guards the actual bug 1.2.0 fixed. Correctness assertions, not just timing.
4. **Index build/rebuild time.** Time `sweep.py` entity-index rebuild + link symmetrization on the full vault. This is the other full-vault script that historically neared the timeout; gate it like §3.1.
5. **Lint + recall-rebuild full-vault timing.** Regression-gate `vault_lint.py` and the recall rebuild against the 1.1.0 baselines (lint ~0.90s, sweep ~0.85s @ 186 notes). These are the concrete numbers in the changelog — turn them into asserted thresholds scaled to current note count.
6. **Lock contention timing.** Time `lock` acquisition when free, when held-fresh (should fast-fail with exit 75), and reclaim of a stale lock past `ECHO_LOCK_TTL`. Confirms the read-back-confirmed lock doesn't add pathological latency and behaves under contention.
6. **Lock contention timing.** Time `lock` acquisition when free, when held-fresh (should fast-fail with exit 75), and reclaim of a stale lock past `CHORUS_LOCK_TTL`. Confirms the read-back-confirmed lock doesn't add pathological latency and behaves under contention.
7. **Offline write-ahead queue throughput (1.0 carry-over).** With the endpoint unreachable, time enqueue of K writes, then time `flush` replay on reconnect. Validates the queue doesn't degrade and replays in order. Useful because it's an untimed path today.
@@ -129,11 +129,11 @@ Harness built under `eval/perf/`:
Verified offline (compile + imports + `--list`/`--help`), then **run live** against the 197-note vault @ af16598 — 11/11 gate checks pass. First run surfaced the recall() finding below; the fix and a guarding metric were added the same session.
## 8. ECHO usage improvements (from the live run)
## 8. CHORUS usage improvements (from the live run)
The benchmark wasn't just validation — it surfaced concrete improvements to how ECHO should do reads. In priority order:
The benchmark wasn't just validation — it surfaced concrete improvements to how CHORUS should do reads. In priority order:
1. **Apply `read_many` everywhere a code path reads a *set* of notes (DONE for `expand_graph`).** The 1.1.0 release shipped concurrent bulk reads but only `sweep`/`lint`/full-vault read adopted it. `recall()`'s graph expansion was still serial — one GET per neighbour — which is why it ran 3 s. The rule going forward: *any* loop that GETs more than ~3 notes whose paths are known up front should batch them through `echo.read_many`, not iterate `get_text`. Audit candidates: `expand_graph` (fixed), `_brief` result printing (still serial), `rebuild()`'s vault walk (still serial), and any future multi-note reader.
1. **Apply `read_many` everywhere a code path reads a *set* of notes (DONE for `expand_graph`).** The 1.1.0 release shipped concurrent bulk reads but only `sweep`/`lint`/full-vault read adopted it. `recall()`'s graph expansion was still serial — one GET per neighbour — which is why it ran 3 s. The rule going forward: *any* loop that GETs more than ~3 notes whose paths are known up front should batch them through `chorus.read_many`, not iterate `get_text`. Audit candidates: `expand_graph` (fixed), `_brief` result printing (still serial), `rebuild()`'s vault walk (still serial), and any future multi-note reader.
2. **Prefetch `_brief` bodies in one `read_many` (NEXT).** After expansion, `recall` prints each primary hit + neighbour via `_brief()`, which GETs the note again to pull its type/status. Those are known paths — fetch them all in one concurrent batch (and reuse bodies already pulled during expansion via a per-call cache, so a note fetched in the BFS isn't re-fetched to print it).
@@ -1,12 +1,12 @@
# ECHO Performance Test Suite
# CHORUS Performance Test Suite
A timing harness for the operations the **1.1.0** (connection pooling + concurrent
`read_many` + shared cache) and **1.2.0** (fuzzy `resolve` / alias learning) updates
targeted. It reuses the live `echo` client module, so it measures the real pooled /
targeted. It reuses the live `chorus` client module, so it measures the real pooled /
concurrent code path — not a reimplementation.
This is a **manual / pre-release** tool, not a CI gate: it depends on the live
configured endpoint (`echo.BASE` / `$ECHO_BASE`) and WAN latency. Gates are **relative ratios**
configured endpoint (`chorus.BASE` / `$CHORUS_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.
@@ -20,13 +20,13 @@ python3 bench.py all # the read-only safe set (no vault writ
python3 bench.py read-full -n 10 # full-vault serial-vs-concurrent read
python3 bench.py subject-pull # recall APTA / CapMetro / hubs (resolve+search+recall split)
python3 bench.py bulk-put -k 50 # write metric -> uses the _bench namespace
python3 bench.py worker-sweep # ECHO_WORKERS = 1,2,4,8,16 scaling curve
python3 bench.py worker-sweep # CHORUS_WORKERS = 1,2,4,8,16 scaling curve
python3 bench.py resolve # 1.2.0 fuzzy correctness table + timing
python3 bench.py soak --soak-seconds 120 # stability / leak watch
```
Configure this machine first (owner/endpoint/key):
`python3 ../../scripts/echo.py config init` then edit the file, or `export ECHO_BASE=... ECHO_KEY=...`.
`python3 ../../scripts/chorus.py config init` then edit the file, or `export CHORUS_BASE=... CHORUS_KEY=...`.
## Metrics
@@ -60,7 +60,7 @@ the end. Pass `--keep` to preserve it for debugging. Nothing is written to
## Output
Each run writes `results/<date>-<commit>.json` (machine-readable, for trend diffing)
and prints a human table. The JSON header captures `ECHO_WORKERS`, `ECHO_TIMEOUT`,
and prints a human table. The JSON header captures `CHORUS_WORKERS`, `CHORUS_TIMEOUT`,
note count, git commit, and date so two runs are comparable.
## Gating
@@ -75,7 +75,7 @@ tune the thresholds to the observed numbers before treating a `FAIL` as blocking
A profiled `recall("operator preferences")` decomposes as: `load_index()` ~500 ms (the
BM25 index is already persisted + incrementally maintained, n_docs=119), `score()`
0.1 ms, and `expand_graph()` **~3,000 ms** — the graph BFS was fetching each neighbour
serially (one GET per node, ~126 nodes). The fix (shipped in `echo_recall.py`) makes the
serially (one GET per node, ~126 nodes). The fix (shipped in `chorus_recall.py`) makes the
BFS breadth-first by hop and fetches each frontier through the existing `read_many`
concurrency. Result: `expand_graph` 3.24.2x faster with byte-identical ranking and
scores; end-to-end `recall()` ~1.7x (e.g. APTA 3.8 s -> 2.2 s). The `expand-graph`
@@ -1,16 +1,16 @@
#!/usr/bin/env python3
"""bench.py — ECHO performance test suite (timing harness).
"""bench.py — CHORUS performance test suite (timing harness).
Measures the operations the 1.1.0 (pooling + concurrent read_many + shared cache)
and 1.2.0 (fuzzy resolve / alias learning) updates targeted, and gates them on
RELATIVE ratios (portable across machines/WAN) rather than absolute ms.
It reuses the live `echo` client module so it times the REAL pooled/concurrent
It reuses the live `chorus` client module so it times the REAL pooled/concurrent
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
configured endpoint (echo.BASE / $ECHO_BASE) and WAN latency.
configured endpoint (chorus.BASE / $CHORUS_BASE) and WAN latency.
python3 bench.py <metric> [options]
python3 bench.py all # the read-only safe set
@@ -27,7 +27,7 @@ Metrics: read-full, subject-pull, bulk-get, bulk-put, bulk-append,
Options:
-n / --iterations N timed iterations (default 10 read / 5 write)
-k / --count K notes/appends for bulk metrics (default 50)
--workers W override ECHO_WORKERS for this run
--workers W override CHORUS_WORKERS for this run
--warmup M warm-up iterations to discard (default 2)
--soak-seconds S duration for the soak metric (default 60)
--keep do NOT delete the _bench namespace after the run
@@ -57,9 +57,9 @@ HERE = Path(__file__).resolve().parent
SCRIPTS = HERE.parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS))
import echo # noqa: E402 the validated client — real pooled/concurrent path
import echo_index # noqa: E402 resolve / fuzzy_candidates (1.2.0)
import echo_recall # noqa: E402 recall (search + 1-hop expansion)
import chorus # noqa: E402 the validated client — real pooled/concurrent path
import chorus_index # noqa: E402 resolve / fuzzy_candidates (1.2.0)
import chorus_recall # noqa: E402 recall (search + 1-hop expansion)
BASELINES = HERE / "baselines.json"
RESULTS_DIR = HERE / "results"
@@ -124,7 +124,7 @@ SKIP_BASENAMES = {"README.md"}
def _list_dir(path: str):
p = "" if path in ("", "/") else (path if path.endswith("/") else path + "/")
body = echo.get_text(p)
body = chorus.get_text(p)
if body is None:
return [], []
try:
@@ -151,35 +151,35 @@ def all_notes() -> list[str]:
# ---------------------------------------------------------------------------
# write primitives (replicate echo.py semantics so we time the real cost)
# write primitives (replicate chorus.py semantics so we time the real cost)
# ---------------------------------------------------------------------------
def put_verified(path: str, body: str) -> None:
"""PUT then read-back GET — the same verify echo.py cmd_put does."""
status, b = echo.request("PUT", echo.vault_url(path),
"""PUT then read-back GET — the same verify chorus.py cmd_put does."""
status, b = chorus.request("PUT", chorus.vault_url(path),
data=body.encode(), headers={"Content-Type": "text/markdown"})
echo.check(status, b, f"put {path}")
status, _ = echo.request("GET", echo.vault_url(path))
chorus.check(status, b, f"put {path}")
status, _ = chorus.request("GET", chorus.vault_url(path))
if status != 200:
raise echo.EchoError(f"put {path}: did not verify (GET {status})")
raise chorus.ChorusError(f"put {path}: did not verify (GET {status})")
def append_line(path: str, line: str) -> bool:
"""Whole-line idempotent append (mirrors echo.py cmd_append). Returns True if a
"""Whole-line idempotent append (mirrors chorus.py cmd_append). Returns True if a
write happened, False if it was an idempotent skip."""
status, body = echo.request("GET", echo.vault_url(path))
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 200:
existing = [ln.rstrip("\r") for ln in body.decode(errors="replace").splitlines()]
if line.rstrip("\r") in existing:
return False
status, body = echo.request("POST", echo.vault_url(path),
status, body = chorus.request("POST", chorus.vault_url(path),
data=f"{line}\n".encode(),
headers={"Content-Type": "text/markdown"})
echo.check(status, body, f"append {path}")
chorus.check(status, body, f"append {path}")
return True
def delete_path(path: str) -> None:
echo.request("DELETE", echo.vault_url(path))
chorus.request("DELETE", chorus.vault_url(path))
# ---------------------------------------------------------------------------
@@ -189,17 +189,17 @@ def m_read_full(ctx) -> dict:
"""§3.1 full-vault read: serial GET vs concurrent read_many -> speedup ratio."""
paths = all_notes()
if not paths:
raise echo.EchoError("read-full: no notes enumerated (vault unreachable?)")
raise chorus.ChorusError("read-full: no notes enumerated (vault unreachable?)")
def serial():
for p in paths:
echo.get_text(p)
chorus.get_text(p)
def concurrent():
echo.read_many(paths)
chorus.read_many(paths)
# one warm pass so neither side pays the cold-handshake tax (pool-warmup owns that)
echo.get_text(paths[0])
chorus.get_text(paths[0])
# serial is a slow reference baseline (N serial round-trips); one warm pass is
# enough to establish the ratio, so we don't multiply the full-vault serial cost.
serial_t = timed(serial, n=1, warmup=0)
@@ -207,7 +207,7 @@ def m_read_full(ctx) -> dict:
ratio = (serial_t["median_ms"] / concurrent_t["median_ms"]) if concurrent_t.get("median_ms") else None
return {
"note_count": len(paths),
"workers": echo.MAX_WORKERS,
"workers": chorus.MAX_WORKERS,
"serial": serial_t,
"concurrent": concurrent_t,
"speedup_ratio": round(ratio, 2) if ratio else None,
@@ -219,18 +219,18 @@ def m_read_full(ctx) -> dict:
def m_subject_pull(ctx) -> dict:
"""§3.2 recall a subject: time resolve (floor), search, and full recall, split out."""
import fixtures
index = echo_index.load()
index = chorus_index.load()
out = {"subjects": []}
for label, query in fixtures.SUBJECTS:
# floor: O(1) index resolve
resolve_t = timed(lambda: echo_index.resolve(index, query), n=ctx.n, warmup=ctx.warmup)
resolve_t = timed(lambda: chorus_index.resolve(index, query), n=ctx.n, warmup=ctx.warmup)
# search-only layer
def search():
with contextlib.redirect_stdout(io.StringIO()):
echo.request("POST", f"{echo.BASE}/search/simple/?query={query}")
chorus.request("POST", f"{chorus.BASE}/search/simple/?query={query}")
search_t = timed(search, n=ctx.n, warmup=ctx.warmup)
# full recall (search + 1-hop expansion reads)
recall_t = timed(silent(lambda: echo_recall.recall(query)), n=ctx.n, warmup=ctx.warmup)
recall_t = timed(silent(lambda: chorus_recall.recall(query)), n=ctx.n, warmup=ctx.warmup)
out["subjects"].append({
"label": label, "query": query,
"resolve_floor": resolve_t,
@@ -247,15 +247,15 @@ def m_bulk_get(ctx) -> dict:
def serial():
for p in paths:
echo.get_text(p)
chorus.get_text(p)
def concurrent():
echo.read_many(paths)
chorus.read_many(paths)
serial_t = timed(serial, n=ctx.n, warmup=1)
concurrent_t = timed(concurrent, n=ctx.n, warmup=ctx.warmup)
ratio = (serial_t["median_ms"] / concurrent_t["median_ms"]) if concurrent_t.get("median_ms") else None
return {"k": len(paths), "workers": echo.MAX_WORKERS,
return {"k": len(paths), "workers": chorus.MAX_WORKERS,
"serial": serial_t, "concurrent": concurrent_t,
"speedup_ratio": round(ratio, 2) if ratio else None}
@@ -291,7 +291,7 @@ def m_bulk_append(ctx) -> dict:
wrote = append_line(target, ln)
write_samples.append(time.perf_counter() - t0)
if not wrote:
raise echo.EchoError("bulk-append: expected a write but got an idempotent skip")
raise chorus.ChorusError("bulk-append: expected a write but got an idempotent skip")
# second pass: every line already present -> must skip, and skip cost is the
# idempotency GET on a now-larger file. Confirms no superlinear blowup.
@@ -316,12 +316,12 @@ def m_bulk_append(ctx) -> dict:
def m_pool_warmup(ctx) -> dict:
"""§4.1 cold vs warm single GET — the clearest proof keep-alive is alive."""
paths = all_notes()
probe = paths[0] if paths else "_agent/echo-vault.md"
echo._drop_connection() # force a cold handshake
probe = paths[0] if paths else "_agent/chorus-vault.md"
chorus._drop_connection() # force a cold handshake
t0 = time.perf_counter()
echo.get_text(probe)
chorus.get_text(probe)
cold_ms = (time.perf_counter() - t0) * 1000.0
warm = timed(lambda: echo.get_text(probe), n=max(ctx.n, 10), warmup=1)
warm = timed(lambda: chorus.get_text(probe), n=max(ctx.n, 10), warmup=1)
ratio = round(cold_ms / warm["median_ms"], 2) if warm.get("median_ms") else None
return {"probe": probe, "cold_ms": round(cold_ms, 2), "warm": warm,
"cold_over_warm_ratio": ratio}
@@ -331,20 +331,20 @@ def m_worker_sweep(ctx) -> dict:
"""§4.2 concurrency scaling: full-vault concurrent read at workers 1,2,4,8,16."""
paths = all_notes()
if not paths:
raise echo.EchoError("worker-sweep: no notes enumerated")
original = echo.MAX_WORKERS
raise chorus.ChorusError("worker-sweep: no notes enumerated")
original = chorus.MAX_WORKERS
rows = []
try:
# w=1 is a full serial pass (slow); the curve only needs the shape, so use a
# modest sample count and a single pre-warm rather than per-setting warmups.
reps = max(1, ctx.n // 2)
for w in (1, 2, 4, 8, 16):
echo.MAX_WORKERS = w
echo.read_many(paths[:5]) # warm the pool at this worker count
t = timed(lambda: echo.read_many(paths), n=reps, warmup=0)
chorus.MAX_WORKERS = w
chorus.read_many(paths[:5]) # warm the pool at this worker count
t = timed(lambda: chorus.read_many(paths), n=reps, warmup=0)
rows.append({"workers": w, "median_ms": t["median_ms"], "p95_ms": t["p95_ms"]})
finally:
echo.MAX_WORKERS = original
chorus.MAX_WORKERS = original
best = min(rows, key=lambda r: r["median_ms"])
return {"note_count": len(paths), "sweep": rows, "knee_workers": best["workers"],
"default_workers": original}
@@ -353,16 +353,16 @@ def m_worker_sweep(ctx) -> dict:
def m_resolve(ctx) -> dict:
"""§4.3 fuzzy resolve — timing AND correctness against the 1.2.0 guard table."""
import fixtures
index = echo_index.load()
index = chorus_index.load()
cases = []
passes = info = 0
for case in fixtures.RESOLVE_CASES:
mention = case["mention"]
exact_t = timed(lambda: echo_index.resolve(index, mention), n=ctx.n, warmup=ctx.warmup)
fuzzy_t = timed(lambda: echo_index.fuzzy_candidates(index, mention), n=ctx.n, warmup=ctx.warmup)
_, entity = echo_index.resolve(index, mention)
exact_t = timed(lambda: chorus_index.resolve(index, mention), n=ctx.n, warmup=ctx.warmup)
fuzzy_t = timed(lambda: chorus_index.fuzzy_candidates(index, mention), n=ctx.n, warmup=ctx.warmup)
_, entity = chorus_index.resolve(index, mention)
exact_slug = (entity or {}).get("slug") or _slug_from_entity(entity)
cands = echo_index.fuzzy_candidates(index, mention) or []
cands = chorus_index.fuzzy_candidates(index, mention) or []
cand_slugs = [_cand_slug(c) for c in cands]
expect, want = case["expect"], case.get("expect_slug")
@@ -387,7 +387,7 @@ def m_resolve(ctx) -> dict:
def _serial_expand(seeds, nmap, base, max_hops):
"""The pre-fix serial graph BFS, kept here as the reference baseline so the suite
both (a) measures the concurrency speedup and (b) asserts the concurrent
expand_graph still returns byte-identical ranking. If echo_recall.expand_graph ever
expand_graph still returns byte-identical ranking. If chorus_recall.expand_graph ever
regresses to serial, the ratio gate fails; if its results drift, the parity check fails."""
from collections import deque
score_of = dict(base)
@@ -398,17 +398,17 @@ def _serial_expand(seeds, nmap, base, max_hops):
path, hop = dq.popleft()
if hop >= max_hops:
continue
text = echo_recall.links.get_text(path)
text = chorus_recall.links.get_text(path)
if text is None:
continue
body = echo_recall.strip_frontmatter(text)
targets = set(echo_recall.links.all_wikilinks(body)) | set(echo_recall.links.source_notes(text))
body = chorus_recall.strip_frontmatter(text)
targets = set(chorus_recall.links.all_wikilinks(body)) | set(chorus_recall.links.source_notes(text))
parent = score_of.get(path, 1.0)
for t in targets:
tp = echo_recall._resolve_target(t, nmap)
tp = chorus_recall._resolve_target(t, nmap)
if not tp or tp in seeds:
continue
decayed = parent * (echo_recall.GRAPH_DECAY ** (hop + 1))
decayed = parent * (chorus_recall.GRAPH_DECAY ** (hop + 1))
if decayed > results.get(tp, (0.0, ""))[0]:
results[tp] = (decayed, path)
if tp not in seen:
@@ -423,10 +423,10 @@ def m_expand_graph(ctx) -> dict:
the read_many concurrency fix. Also asserts the concurrent path's ranking + scores
match the serial reference exactly."""
import fixtures
ix = echo_recall.load_index()
index = echo_index.load()
nmap = echo_index.name_map(index)
max_hops = echo_recall.MAX_HOPS
ix = chorus_recall.load_index()
index = chorus_index.load()
nmap = chorus_index.name_map(index)
max_hops = chorus_recall.MAX_HOPS
# hub subjects stress expansion most; fall back to all subjects if none labelled hub
subjects = [s for s in fixtures.SUBJECTS if "hub" in s[0]] or fixtures.SUBJECTS
rows = []
@@ -436,12 +436,12 @@ def m_expand_graph(ctx) -> dict:
seeds = [p for p, _ in hits]
if not seeds:
continue
echo_recall.expand_graph(seeds, nmap, base, max_hops) # warm
chorus_recall.expand_graph(seeds, nmap, base, max_hops) # warm
serial_t = timed(lambda: _serial_expand(seeds, nmap, base, max_hops), n=1, warmup=0)
conc_t = timed(lambda: echo_recall.expand_graph(seeds, nmap, base, max_hops),
conc_t = timed(lambda: chorus_recall.expand_graph(seeds, nmap, base, max_hops),
n=ctx.n, warmup=1)
old = _serial_expand(seeds, nmap, base, max_hops)
new = echo_recall.expand_graph(seeds, nmap, base, max_hops)
new = chorus_recall.expand_graph(seeds, nmap, base, max_hops)
ranking_ok = [p for p, _ in old] == [p for p, _ in new]
scores_ok = ranking_ok and all(
abs(old[i][1][0] - new[i][1][0]) < 1e-9 for i in range(len(old)))
@@ -484,7 +484,7 @@ def m_lint(ctx) -> dict:
def _time_script(name: str, extra: list[str], allow_exit=frozenset({0})) -> dict:
script = SCRIPTS / name
env = dict(os.environ, ECHO_KEY_LEGACY_OK="1")
env = dict(os.environ, CHORUS_KEY_LEGACY_OK="1")
t0 = time.perf_counter()
proc = subprocess.run([sys.executable, str(script), *extra],
capture_output=True, text=True, env=env, timeout=120)
@@ -501,10 +501,10 @@ def m_lock(ctx) -> dict:
other = f"bench-other-{ctx.run_id}"
def acquire(o):
return echo.cmd_lock(o, quiet=True)
return chorus.cmd_lock(o, quiet=True)
def release(o):
return echo.cmd_unlock(o, quiet=True)
return chorus.cmd_unlock(o, quiet=True)
release(owner); release(other) # clean slate
t0 = time.perf_counter(); rc_free = acquire(owner); free_ms = (time.perf_counter() - t0) * 1000
@@ -521,18 +521,18 @@ def m_queue(ctx) -> dict:
then time the flush replay on 'reconnect'. Uses a bogus base so nothing real is
written; restores the base afterward. No production writes occur."""
import importlib
import echo_queue
import chorus_queue
k = min(ctx.k, 20)
saved_base = echo.BASE
saved_base = chorus.BASE
enqueue_samples: list[float] = []
try:
for i in range(k):
url = f"{echo.BASE}/vault/{fixtures_qpath(ctx.run_id, i)}"
url = f"{chorus.BASE}/vault/{fixtures_qpath(ctx.run_id, i)}"
t0 = time.perf_counter()
echo_queue.enqueue("PUT", url, b"x", {"Content-Type": "text/markdown"},
chorus_queue.enqueue("PUT", url, b"x", {"Content-Type": "text/markdown"},
idem_key=f"bench-{ctx.run_id}-{i}")
enqueue_samples.append(time.perf_counter() - t0)
pending_before = len(echo_queue.pending())
pending_before = len(chorus_queue.pending())
# flush is left UNMEASURED-as-success here: with a real endpoint it would
# try to write. We only assert enqueue worked and report depth; a true
# flush-replay timing needs a sandbox vault (see README "queue caveat").
@@ -541,8 +541,8 @@ def m_queue(ctx) -> dict:
"note": "flush replay intentionally not run against the live vault; "
"enqueue path timed only. See README queue caveat."}
finally:
echo.BASE = saved_base
importlib.reload(echo_queue)
chorus.BASE = saved_base
importlib.reload(chorus_queue)
def fixtures_qpath(run_id: str, i: int) -> str:
@@ -552,10 +552,10 @@ def fixtures_qpath(run_id: str, i: int) -> str:
def m_cache(ctx) -> dict:
"""§4.8 read_many dedup check: a path list with duplicates must collapse to the
unique set (the cache/dedup that stops a sweep re-fetching a link target)."""
paths = all_notes()[:20] or ["_agent/echo-vault.md"]
paths = all_notes()[:20] or ["_agent/chorus-vault.md"]
dupd = paths + paths + paths # 3x
t0 = time.perf_counter()
result = echo.read_many(dupd)
result = chorus.read_many(dupd)
elapsed = (time.perf_counter() - t0) * 1000
return {"requested": len(dupd), "unique": len(set(dupd)),
"returned": len(result), "deduped": len(result) == len(set(dupd)),
@@ -566,14 +566,14 @@ def m_soak(ctx) -> dict:
"""§4.10 stability: loop full-vault reads for N seconds; watch for errors/drift."""
paths = all_notes()
if not paths:
raise echo.EchoError("soak: no notes enumerated")
raise chorus.ChorusError("soak: no notes enumerated")
deadline = time.time() + ctx.soak_seconds
durations: list[float] = []
errors = 0
while time.time() < deadline:
t0 = time.perf_counter()
try:
res = echo.read_many(paths)
res = chorus.read_many(paths)
if any(v is None for v in res.values()):
errors += 1
except Exception:
@@ -696,10 +696,10 @@ def env_block(args) -> dict:
pass
return {
"date": dt.datetime.now().isoformat(timespec="seconds"),
"endpoint": echo.BASE,
"endpoint": chorus.BASE,
"commit": commit,
"echo_workers": echo.MAX_WORKERS,
"echo_timeout": echo.TIMEOUT,
"chorus_workers": chorus.MAX_WORKERS,
"chorus_timeout": chorus.TIMEOUT,
"python": sys.version.split()[0],
"iterations": args.iterations,
"count_k": args.count,
@@ -716,7 +716,7 @@ def human_table(name: str, result: dict, checks: list[dict]) -> str:
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="ECHO performance test suite (manual/pre-release)")
ap = argparse.ArgumentParser(description="CHORUS performance test suite (manual/pre-release)")
ap.add_argument("metric", nargs="?", help="metric name, or 'all' for the read-only set")
ap.add_argument("--list", action="store_true", help="list metrics and exit")
ap.add_argument("-n", "--iterations", type=int, default=None)
@@ -737,7 +737,7 @@ def main(argv=None) -> int:
return 0 if args.list else 2
if args.workers is not None:
echo.MAX_WORKERS = args.workers
chorus.MAX_WORKERS = args.workers
selected = READONLY if args.metric == "all" else [args.metric]
for m in selected:
@@ -756,7 +756,7 @@ def main(argv=None) -> int:
need_writes = any(m in WRITES for m in selected)
lock_owner = f"bench-{ctx.run_id}"
if need_writes:
if echo.cmd_lock(lock_owner, quiet=True) == 75:
if chorus.cmd_lock(lock_owner, quiet=True) == 75:
print("bench: vault lock is held by another session — aborting write metrics.",
file=sys.stderr)
return 2
@@ -779,7 +779,7 @@ def main(argv=None) -> int:
print(human_table(m, result, checks))
finally:
if need_writes:
echo.cmd_unlock(lock_owner, quiet=True)
chorus.cmd_unlock(lock_owner, quiet=True)
if need_writes and not args.keep:
n = cleanup_namespace(ctx.run_id)
report["cleanup"] = {"deleted": n}
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""fixtures.py — inputs for the ECHO performance harness (bench.py).
"""fixtures.py — inputs for the CHORUS performance harness (bench.py).
Holds the *what* (subjects to pull, notes to write, mentions to resolve) so the
harness (bench.py) stays the *how* (timing, stats, gating). Nothing here touches
@@ -11,12 +11,12 @@ 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 the maintainer configured; "echo" and "operator preferences" are dense hubs
# subjects the maintainer configured; "chorus" 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"),
("hub-echo", "echo"),
("hub-chorus", "chorus"),
("hub-prefs", "operator preferences"),
("leaf-rivnut", "rivnut torque spec"),
]
@@ -36,9 +36,9 @@ SUBJECTS: list[tuple[str, str]] = [
# 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": "chorus", "expect": "exact", "expect_slug": "chorus"},
{"mention": "chorus memory", "expect": "candidates", "expect_slug": "chorus"},
{"mention": "CHORUS plugin", "expect": "candidates", "expect_slug": "chorus"},
{"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"},
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:17:09",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": 4,
"count_k": 50
@@ -144,8 +144,8 @@
"result": {
"subjects": [
{
"label": "hub-echo",
"query": "echo",
"label": "hub-chorus",
"query": "chorus",
"neighbours": 120,
"serial_ms": 2227.67,
"concurrent_ms": 642.2,
@@ -245,7 +245,7 @@
},
"pool-warmup": {
"result": {
"probe": "_agent/echo-vault.md",
"probe": "_agent/chorus-vault.md",
"cold_ms": 163.25,
"warm": {
"n": 12,
@@ -348,16 +348,16 @@
"result": {
"cases": [
{
"mention": "echo",
"mention": "chorus",
"expect": "exact",
"expect_slug": "echo",
"resolved_slug": "echo",
"expect_slug": "chorus",
"resolved_slug": "chorus",
"candidate_slugs": [
"2026-06-19-other-vault-full-echo-architect",
"echo",
"echo-memory-codex-plugin",
"echo-plugin-build",
"echo-skill-improvements"
"2026-06-19-other-vault-full-chorus-architect",
"chorus",
"chorus-memory-codex-plugin",
"chorus-plugin-build",
"chorus-skill-improvements"
],
"verdict": "PASS",
"resolve_timing": {
@@ -398,16 +398,16 @@
}
},
{
"mention": "echo memory",
"mention": "chorus memory",
"expect": "candidates",
"expect_slug": "echo",
"resolved_slug": "echo",
"expect_slug": "chorus",
"resolved_slug": "chorus",
"candidate_slugs": [
"echo",
"echo-memory-codex-plugin",
"echo-plugin-build",
"echo-skill-improvements",
"2026-06-19-other-vault-full-echo-architect"
"chorus",
"chorus-memory-codex-plugin",
"chorus-plugin-build",
"chorus-skill-improvements",
"2026-06-19-other-vault-full-chorus-architect"
],
"verdict": "INFO",
"resolve_timing": {
@@ -448,15 +448,15 @@
}
},
{
"mention": "ECHO plugin",
"mention": "CHORUS plugin",
"expect": "candidates",
"expect_slug": "echo",
"resolved_slug": "echo",
"expect_slug": "chorus",
"resolved_slug": "chorus",
"candidate_slugs": [
"echo",
"echo-memory-codex-plugin",
"echo-plugin-build",
"2026-06-19-other-vault-full-echo-architect",
"chorus",
"chorus-memory-codex-plugin",
"chorus-plugin-build",
"2026-06-19-other-vault-full-chorus-architect",
"example-isp-brand-docs"
],
"verdict": "INFO",
@@ -503,7 +503,7 @@
"expect_slug": "other-vault",
"resolved_slug": "other-vault",
"candidate_slugs": [
"2026-06-19-other-vault-full-echo-architect",
"2026-06-19-other-vault-full-chorus-architect",
"other-vault"
],
"verdict": "PASS",
@@ -796,8 +796,8 @@
}
},
{
"label": "hub-echo",
"query": "echo",
"label": "hub-chorus",
"query": "chorus",
"resolve_floor": {
"n": 1,
"min_ms": 0.03,
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:22:00",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": null,
"count_k": 30
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:21:36",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": 5,
"count_k": 30
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:21:21",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": null,
"count_k": 30
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:12:16",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": null,
"count_k": 50
@@ -3,8 +3,8 @@
"date": "2026-06-23T21:22:29",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": 3,
"count_k": 50
@@ -15,8 +15,8 @@
"result": {
"subjects": [
{
"label": "hub-echo",
"query": "echo",
"label": "hub-chorus",
"query": "chorus",
"neighbours": 120,
"serial_ms": 2227.67,
"concurrent_ms": 642.2,
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:20:57",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": null,
"count_k": 50
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:21:01",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": null,
"count_k": 50
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:21:28",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": null,
"count_k": 50
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:12:12",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": 12,
"count_k": 50
@@ -13,7 +13,7 @@
"metrics": {
"pool-warmup": {
"result": {
"probe": "_agent/echo-vault.md",
"probe": "_agent/chorus-vault.md",
"cold_ms": 163.25,
"warm": {
"n": 12,
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:22:05",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": null,
"count_k": 10
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:17:09",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": 4,
"count_k": 50
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:12:19",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": 6,
"count_k": 50
@@ -15,16 +15,16 @@
"result": {
"cases": [
{
"mention": "echo",
"mention": "chorus",
"expect": "exact",
"expect_slug": "echo",
"resolved_slug": "echo",
"expect_slug": "chorus",
"resolved_slug": "chorus",
"candidate_slugs": [
"2026-06-19-other-vault-full-echo-architect",
"echo",
"echo-memory-codex-plugin",
"echo-plugin-build",
"echo-skill-improvements"
"2026-06-19-other-vault-full-chorus-architect",
"chorus",
"chorus-memory-codex-plugin",
"chorus-plugin-build",
"chorus-skill-improvements"
],
"verdict": "PASS",
"resolve_timing": {
@@ -65,16 +65,16 @@
}
},
{
"mention": "echo memory",
"mention": "chorus memory",
"expect": "candidates",
"expect_slug": "echo",
"resolved_slug": "echo",
"expect_slug": "chorus",
"resolved_slug": "chorus",
"candidate_slugs": [
"echo",
"echo-memory-codex-plugin",
"echo-plugin-build",
"echo-skill-improvements",
"2026-06-19-other-vault-full-echo-architect"
"chorus",
"chorus-memory-codex-plugin",
"chorus-plugin-build",
"chorus-skill-improvements",
"2026-06-19-other-vault-full-chorus-architect"
],
"verdict": "INFO",
"resolve_timing": {
@@ -115,15 +115,15 @@
}
},
{
"mention": "ECHO plugin",
"mention": "CHORUS plugin",
"expect": "candidates",
"expect_slug": "echo",
"resolved_slug": "echo",
"expect_slug": "chorus",
"resolved_slug": "chorus",
"candidate_slugs": [
"echo",
"echo-memory-codex-plugin",
"echo-plugin-build",
"2026-06-19-other-vault-full-echo-architect",
"chorus",
"chorus-memory-codex-plugin",
"chorus-plugin-build",
"2026-06-19-other-vault-full-chorus-architect",
"example-isp-brand-docs"
],
"verdict": "INFO",
@@ -170,7 +170,7 @@
"expect_slug": "other-vault",
"resolved_slug": "other-vault",
"candidate_slugs": [
"2026-06-19-other-vault-full-echo-architect",
"2026-06-19-other-vault-full-chorus-architect",
"other-vault"
],
"verdict": "PASS",
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:22:15",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": null,
"count_k": 50
@@ -3,8 +3,8 @@
"date": "2026-06-23T21:18:12",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": 1,
"count_k": 50
@@ -77,8 +77,8 @@
}
},
{
"label": "hub-echo",
"query": "echo",
"label": "hub-chorus",
"query": "chorus",
"resolve_floor": {
"n": 1,
"min_ms": 0.03,
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:20:23",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": 1,
"count_k": 50
@@ -77,8 +77,8 @@
}
},
{
"label": "hub-echo",
"query": "echo",
"label": "hub-chorus",
"query": "chorus",
"resolve_floor": {
"n": 1,
"min_ms": 0.02,
@@ -3,8 +3,8 @@
"date": "2026-06-23T20:18:05",
"endpoint": "https://obsidian.example.com",
"commit": "af16598",
"echo_workers": 8,
"echo_timeout": 30,
"chorus_workers": 8,
"chorus_timeout": 30,
"python": "3.10.12",
"iterations": 2,
"count_k": 50
@@ -1,7 +1,7 @@
{
"title": "ECHO Memory Plugin \u2014 Performance Benchmark",
"title": "CHORUS Memory Plugin \u2014 Performance Benchmark",
"subtitle": "Validation of the 1.1.0 concurrency and 1.2.0 entity-resolution releases",
"reference": "echo-v.05 @ af16598",
"reference": "chorus-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 obsidian.example.com on 2026-06-23.",
"sections": [
@@ -27,11 +27,11 @@
},
{
"label": "Concurrency (default)",
"value": "ECHO_WORKERS = 8"
"value": "CHORUS_WORKERS = 8"
},
{
"label": "Socket timeout",
"value": "ECHO_TIMEOUT = 30 s"
"value": "CHORUS_TIMEOUT = 30 s"
},
{
"label": "Runner",
@@ -209,7 +209,7 @@
}
],
[
"echo (hub)",
"chorus (hub)",
{
"value": "0.03 ms",
"num": true
@@ -268,7 +268,7 @@
],
"rows": [
[
"echo",
"chorus",
{
"value": "120",
"num": true
@@ -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\", \"operator\") 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 (\"chorus memory\", \"CHORUS 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."
@@ -526,14 +526,14 @@
"Apply read_many to every code path that reads a set of known notes. Done for expand_graph (gated). Remaining: prefetch the _brief result bodies recall prints, and parallelize rebuild()'s vault walk.",
"Reuse one in-process read cache across a recall() call so load_index, expansion, and _brief don't re-fetch overlapping notes \u2014 this closes the remaining ~500 ms + serial _brief cost.",
"Make 'prefer read_many over a GET loop' a documented contract in the plugin's API reference; serial multi-note reads are a performance bug. The expand-graph gate enforces it for recall.",
"Hold ECHO_WORKERS at 8 (16 buys only 1.24x). Promote the resolve correctness table from INFO to FAIL now that live behavior is confirmed. Re-baseline at the next 1.x release or past ~400 notes."
"Hold CHORUS_WORKERS at 8 (16 buys only 1.24x). Promote the resolve correctness table from INFO to FAIL now that live behavior is confirmed. Re-baseline at the next 1.x release or past ~400 notes."
]
},
{
"heading": "Method",
"type": "text",
"body": [
"Each metric was timed with a perf-counter harness that reuses the live ECHO client, so it exercises the real pooled and concurrent code path. Reads report median and p95 over repeated iterations after a discarded warm-up; write metrics ran in a disposable namespace under the advisory lock and were deleted on completion (zero residual files confirmed).",
"Each metric was timed with a perf-counter harness that reuses the live CHORUS client, so it exercises the real pooled and concurrent code path. Reads report median and p95 over repeated iterations after a discarded warm-up; write metrics ran in a disposable namespace under the advisory lock and were deleted on completion (zero residual files confirmed).",
"Gates use relative ratios rather than absolute milliseconds, so they stay valid regardless of the network the suite runs from. The harness, fixtures, baselines, and per-metric JSON are checked in under eval/perf/."
]
}
@@ -0,0 +1,12 @@
#!/bin/bash
export CHORUS_KEY_LEGACY_OK=1
cd "$(dirname "$0")"
{
chorus "[$(date +%T)] read-full"; python3 bench.py read-full -n 3 --quiet --json-out results/m-read-full.json
chorus "[$(date +%T)] worker-sweep"; python3 bench.py worker-sweep -n 4 --quiet --json-out results/m-worker-sweep.json
chorus "[$(date +%T)] subject-pull"; python3 bench.py subject-pull -n 4 --quiet --json-out results/m-subject-pull.json
chorus "[$(date +%T)] index"; python3 bench.py index --quiet --json-out results/m-index.json
chorus "[$(date +%T)] lint"; python3 bench.py lint --quiet --json-out results/m-lint.json
chorus "[$(date +%T)] DONE"
} > results/run.log 2>&1
touch results/DONE
@@ -1,23 +1,23 @@
# ECHO — Obsidian Local REST API Reference
# CHORUS — Obsidian Local REST API Reference
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.
Server: the configured endpoint — the `endpoint` from `~/.claude/chorus-memory/config.json` (overridable with `CHORUS_BASE`), a reverse proxy → backend Obsidian Local REST API. Examples below use `$CHORUS_BASE` (or the neutral placeholder `https://obsidian.example.com`).
Auth header: `Authorization: Bearer $CHORUS_KEY``export CHORUS_KEY=<token>` before running these recipes, or let `chorus.py` resolve it automatically (per-field, first wins: env override `CHORUS_KEY` → the `key` in `~/.claude/chorus-memory/config.json`, resolved via the `chorus_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.
> **Prefer `scripts/chorus.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 `chorus.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.
---
## Client performance (echo.py) — pooling & concurrency
## Client performance (chorus.py) — pooling & concurrency
`echo.request()` keeps **one persistent keep-alive connection per thread** and reuses it across calls, instead of opening a fresh TCP+TLS connection per request. Against a remote HTTPS endpoint the repeated handshake was the dominant cost, so a full-vault pass used to make hundreds of serial handshakes and exceed the agent/tool timeout (which kills the run mid-flight). A stale pooled connection reconnects transparently; the `(status, body)` contract is unchanged (status `0` still means transport failure, so the offline write-queue still triggers).
`chorus.request()` keeps **one persistent keep-alive connection per thread** and reuses it across calls, instead of opening a fresh TCP+TLS connection per request. Against a remote HTTPS endpoint the repeated handshake was the dominant cost, so a full-vault pass used to make hundreds of serial handshakes and exceed the agent/tool timeout (which kills the run mid-flight). A stale pooled connection reconnects transparently; the `(status, body)` contract is unchanged (status `0` still means transport failure, so the offline write-queue still triggers).
For bulk reads, **`echo.read_many(paths)`** fans GETs across a thread pool and returns `{path: text_or_None}` — resilient (one unreadable file becomes `None` rather than aborting the batch). `sweep.py` and `vault_lint.py` prefetch the whole vault once with it and share that single cache across every pass, so no note is fetched more than once. Net effect: a several-minute full-vault sweep becomes sub-second on a few-hundred-note vault.
For bulk reads, **`chorus.read_many(paths)`** fans GETs across a thread pool and returns `{path: text_or_None}` — resilient (one unreadable file becomes `None` rather than aborting the batch). `sweep.py` and `vault_lint.py` prefetch the whole vault once with it and share that single cache across every pass, so no note is fetched more than once. Net effect: a several-minute full-vault sweep becomes sub-second on a few-hundred-note vault.
Tuning knobs (env overrides):
- `ECHO_TIMEOUT` (default `30`) — per-request socket timeout in seconds. With concurrency, one slow file only blocks its own worker, not the run.
- `ECHO_WORKERS` (default `8`) — `read_many` thread-pool size. Raise it for very large vaults; lower it to be gentler on the backend.
- `CHORUS_TIMEOUT` (default `30`) — per-request socket timeout in seconds. With concurrency, one slow file only blocks its own worker, not the run.
- `CHORUS_WORKERS` (default `8`) — `read_many` thread-pool size. Raise it for very large vaults; lower it to be gentler on the backend.
For a vault large enough that even the concurrent pass approaches the tool timeout, run the script with the harness's background-execution option rather than blocking the turn.
@@ -28,8 +28,8 @@ For a vault large enough that even the concurrent pass approaches the tool timeo
```bash
# Read any file by vault path
curl -s \
-H "Authorization: Bearer $ECHO_KEY" \
"$ECHO_BASE/vault/_agent/context/current-context.md"
-H "Authorization: Bearer $CHORUS_KEY" \
"$CHORUS_BASE/vault/_agent/context/current-context.md"
```
Returns raw file content (text/markdown). On 404, the file does not exist.
@@ -37,8 +37,8 @@ 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 $ECHO_KEY" \
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
-H "Authorization: Bearer $CHORUS_KEY" \
"$CHORUS_BASE/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
```
Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
@@ -53,8 +53,8 @@ Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
```bash
# List contents of a directory (trailing slash required)
curl -s \
-H "Authorization: Bearer $ECHO_KEY" \
"$ECHO_BASE/vault/_agent/sessions/"
-H "Authorization: Bearer $CHORUS_KEY" \
"$CHORUS_BASE/vault/_agent/sessions/"
```
Returns JSON: `{ "files": [...], "folders": [...] }`.
@@ -71,10 +71,10 @@ cat > /tmp/obs_entry.md << 'OBSEOF'
OBSEOF
curl -s -X POST \
-H "Authorization: Bearer $ECHO_KEY" \
-H "Authorization: Bearer $CHORUS_KEY" \
-H "Content-Type: text/markdown" \
--data-binary @/tmp/obs_entry.md \
"$ECHO_BASE/vault/inbox/captures/inbox.md"
"$CHORUS_BASE/vault/inbox/captures/inbox.md"
```
---
@@ -102,10 +102,10 @@ source_notes: []
OBSEOF
curl -s -X PUT \
-H "Authorization: Bearer $ECHO_KEY" \
-H "Authorization: Bearer $CHORUS_KEY" \
-H "Content-Type: text/markdown" \
--data-binary @/tmp/obs_file.md \
"$ECHO_BASE/vault/_agent/sessions/2026-06-05-1430-my-session.md"
"$CHORUS_BASE/vault/_agent/sessions/2026-06-05-1430-my-session.md"
```
---
@@ -124,13 +124,13 @@ The operator prefers concise status updates — lead with the decision.
OBSEOF
curl -s -X PATCH \
-H "Authorization: Bearer $ECHO_KEY" \
-H "Authorization: Bearer $CHORUS_KEY" \
-H "Operation: append" \
-H "Target-Type: heading" \
-H "Target: Operator Preferences::Fact / Pattern" \
-H "Content-Type: text/markdown" \
--data-binary @/tmp/obs_patch.md \
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md"
"$CHORUS_BASE/vault/_agent/memory/semantic/operator-preferences.md"
```
### Discover heading / block / frontmatter targets
@@ -139,9 +139,9 @@ When unsure of the exact heading path, GET the note with the document-map Accept
```bash
curl -s \
-H "Authorization: Bearer $ECHO_KEY" \
-H "Authorization: Bearer $CHORUS_KEY" \
-H "Accept: application/vnd.olrapi.document-map+json" \
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md"
"$CHORUS_BASE/vault/_agent/memory/semantic/operator-preferences.md"
```
Returns `{ "headings": [...], "blocks": [...], "frontmatterFields": [...] }`. Copy the heading string verbatim into `Target`.
@@ -157,20 +157,20 @@ 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:
> `400 invalid-target` — the raw API cannot create a key. `chorus.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" \
-H "Authorization: Bearer $CHORUS_KEY" \
-H "Operation: replace" \
-H "Target-Type: frontmatter" \
-H "Target: updated" \
-H "Content-Type: application/json" \
--data '"2026-06-05"' \
"$ECHO_BASE/vault/projects/active/vault-foundation.md"
"$CHORUS_BASE/vault/projects/active/vault-foundation.md"
```
---
@@ -179,8 +179,8 @@ curl -s -X PATCH \
```bash
curl -s -X POST \
-H "Authorization: Bearer $ECHO_KEY" \
"$ECHO_BASE/search/simple/?query=weekly+review"
-H "Authorization: Bearer $CHORUS_KEY" \
"$CHORUS_BASE/search/simple/?query=weekly+review"
```
Returns an array of `{ filename, score, matches: [{ context, match }] }`.
@@ -191,8 +191,8 @@ Returns an array of `{ filename, score, matches: [{ context, match }] }`.
```bash
curl -s -X DELETE \
-H "Authorization: Bearer $ECHO_KEY" \
"$ECHO_BASE/vault/inbox/imports/old-note.md"
-H "Authorization: Bearer $CHORUS_KEY" \
"$CHORUS_BASE/vault/inbox/imports/old-note.md"
```
Only on explicit operator request. Deletion is destructive.
@@ -239,6 +239,6 @@ Only on explicit operator request. Deletion is destructive.
| 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 |
| Bootstrap marker (plugin-owned) | `_agent/chorus-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,15 +1,15 @@
# 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 **plugin is the single source of truth** for CHORUS'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 CHORUS 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`.
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/chorus-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="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts"
SCRIPTS="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts"
python3 "$SCRIPTS/bootstrap.py" --dry-run # preview what would be seeded
python3 "$SCRIPTS/bootstrap.py" # idempotent, additive — fills only what is missing (also the repair path)
python3 "$SCRIPTS/migrate.py" # plan a schema migration (dry-run)
@@ -17,11 +17,11 @@ python3 "$SCRIPTS/migrate.py" --apply # perform the migration (moves/delet
# Windows: use `python` or `py -3` if `python3` is not on PATH.
```
`bootstrap.py` writes through `echo.py`, so every step is status-checked and the marker is written last. The scripts are pure Python (only an interpreter required — no bash, no platform-specific `date`), so they run identically on Windows, macOS, and Linux. The manual steps below document what the script does (and serve as a fallback if the script can't run).
`bootstrap.py` writes through `chorus.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 via echo_config
BASE="$ECHO_BASE" # the endpoint from ~/.claude/echo-memory/config.json (override: ECHO_BASE)
AUTH="Authorization: Bearer $CHORUS_KEY" # export CHORUS_KEY first; chorus.py resolves it automatically via chorus_config
BASE="$CHORUS_BASE" # the endpoint from ~/.claude/chorus-memory/config.json (override: CHORUS_BASE)
```
---
@@ -31,7 +31,7 @@ BASE="$ECHO_BASE" # the endpoint from ~/.claude/echo-memo
At session start, GET the marker:
```bash
curl -s -o /dev/null -w "%{http_code}" -H "$AUTH" "$BASE/vault/_agent/echo-vault.md"
curl -s -o /dev/null -w "%{http_code}" -H "$AUTH" "$BASE/vault/_agent/chorus-vault.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`.
@@ -68,7 +68,7 @@ _agent/skills/active _agent/skills/archived
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" \
printf '# %s\n\nMemory vault folder. See the chorus-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"
```
@@ -93,7 +93,7 @@ Templates keep their Obsidian Templater tokens (`{{date:YYYY-MM-DD}}` etc.) verb
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="${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scaffold"
SCAFFOLD="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scaffold"
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"
@@ -117,7 +117,7 @@ Substitute `{{DATE}}` if present, then PUT `scaffold/README.vault.md` → `/vaul
### 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.
Substitute `{{DATE}}`, then PUT `scaffold/chorus-vault.md``/vault/_agent/chorus-vault.md`. Once this returns `200`, the vault is bootstrapped.
### 6. First-run trace
@@ -137,6 +137,6 @@ Run the same steps 15, but GET-probe each path first and **only create what i
When the marker's `schema_version` is older than the plugin's, apply the migration steps for each intervening version, then PATCH the marker's `schema_version` frontmatter to the new value.
- **0 → 1** (control-docs-in-plugin): the vault previously carried root control docs (`CLAUDE.md`, `BOOTSTRAP.md`, `STRUCTURE.md`, `index.md`). Back them up outside the vault, DELETE them, PUT the thin `scaffold/README.vault.md` over the old verbose `README.md`, write the `_agent/echo-vault.md` marker, and scrub now-dangling `[[CLAUDE]]`/`[[BOOTSTRAP]]`/`[[STRUCTURE]]`/`[[index]]` links from the `## Related` sections of `operator-preferences.md` and `current-context.md` (leave historical session logs alone). Confirm with the operator before deleting.
- **0 → 1** (control-docs-in-plugin): the vault previously carried root control docs (`CLAUDE.md`, `BOOTSTRAP.md`, `STRUCTURE.md`, `index.md`). Back them up outside the vault, DELETE them, PUT the thin `scaffold/README.vault.md` over the old verbose `README.md`, write the `_agent/chorus-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. *(This step covers any vault bootstrapped under schema 1.)*
- **2 → 3** (entity index + cross-linking): adds the `_agent/index/` folder for the machine-maintained entity registry. `migrate.py` creates the folder; the **data back-fill is `sweep.py`** — after migrating, run `python3 scripts/sweep.py --apply` to build `_agent/index/entities.json` from the notes already in the vault and to symmetrize existing `## Related` cross-links (it adds only the missing reciprocal direction, never invents links). `sweep.py` is idempotent and dry-run by default; re-run it any time `/echo-health` reports index drift.
- **2 → 3** (entity index + cross-linking): adds the `_agent/index/` folder for the machine-maintained entity registry. `migrate.py` creates the folder; the **data back-fill is `sweep.py`** — after migrating, run `python3 scripts/sweep.py --apply` to build `_agent/index/entities.json` from the notes already in the vault and to symmetrize existing `## Related` cross-links (it adds only the missing reciprocal direction, never invents links). `sweep.py` is idempotent and dry-run by default; re-run it any time `/chorus-health` reports index drift.
@@ -1,6 +1,6 @@
# ECHO — Operating Contract
# CHORUS — 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.
The durable, client-independent contract for any agent operating against the CHORUS 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
@@ -40,6 +40,6 @@ Assume clients may operate without filesystem access, through the Obsidian Local
The vault is a **shared** substrate — Claude Code, CoWork, and other REST API 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 read-back-confirmed and cooperative with a TTL (stale locks are reclaimable); it is a courtesy, not a hard mutex.
- Idempotent append (read-before-POST, whole-line match, via `echo.py append`) is the second line of defense against duplicate lines from retries or overlapping sessions.
- Before a burst of writes in a session that may overlap another, take the advisory lock (`chorus.py lock <id>``_agent/locks/vault.lock`) and release it at session end. The lock is read-back-confirmed and cooperative with a TTL (stale locks are reclaimable); it is a courtesy, not a hard mutex.
- Idempotent append (read-before-POST, whole-line match, via `chorus.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,4 +1,4 @@
# ECHO Routing Map
# CHORUS 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.
@@ -78,7 +78,8 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
| 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/chorus-vault.md` | Bootstrap / schema migration only | Marker: `schema_version`, bootstrap date | Plugin-owned probe; never hand-edited | GET / PUT |
| `_agent/echo-vault.md` | Pre-fork ECHO vault adoption (read-only probe) | Legacy ECHO marker, honored until the rename migration | CHORUS is a fork of ECHO; adopted vaults still carry the old marker | GET |
| `_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 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 |
@@ -90,7 +91,7 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
| `_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` | 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/locks/vault.lock` | Advisory multi-writer lock acquire/release | One line: `<owner> @ <ISO-timestamp>` | Concurrency coordination, not memory; managed only by `chorus.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 |
---
@@ -5,7 +5,7 @@ Session logs go in: `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md`
**Filename format is canonical and not optional.** The four-digit local-time HHMM component is what makes session filenames lex-sort in true chronological order — the loading procedure depends on it. Before PUT-ing a new session log, validate the filename matches `^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9-]+\.md$`. Legacy session logs without HHMM exist in the vault; do not edit their names, but every new write must use the full form.
The slug describes what the session was about in 25 words, kebab-case.
Examples: `2026-06-05-1430-echo-plugin-build.md`, `2026-05-14-0900-q1-review-prep.md`.
Examples: `2026-06-05-1430-chorus-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`.
@@ -77,7 +77,7 @@ client: claude-code
# Session Log
## Goal
Build and package the echo-memory CoWork plugin against the live vault.
Build and package the chorus-memory CoWork plugin against the live vault.
## Notes Read
- [[BOOTSTRAP]], [[STRUCTURE]], [[_agent/memory/semantic/operator-preferences]]
@@ -87,11 +87,11 @@ Verified the REST API end-to-end, confirmed the scaffold copied into the live va
created missing empty folders, and built the plugin (SKILL + 4 reference files).
## Decisions Made
- Vault addressed at root — ECHO is a dedicated vault.
- Vault addressed at root — CHORUS 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
- `chorus-memory.plugin` — installable CoWork plugin
## Open Threads
- [ ] Validate Claude Code direct filesystem access to the vault host.
@@ -1,6 +1,6 @@
# 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.
**This document is canonical.** The CHORUS 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)
@@ -36,7 +36,7 @@
│ 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)
├── chorus-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
@@ -51,7 +51,7 @@
└── locks/ ← vault.lock — cooperative advisory multi-writer lock
```
**Entity index:** `_agent/index/entities.json` is a machine-maintained registry mapping each entity slug to its `{path, kind, title, aliases, last_seen}`. It makes routing/resolve an O(1), alias-aware lookup and supplies the name→path map for cross-linking and recall. `resolve` matches slug/title/alias exactly; when nothing matches exactly it falls back to **fuzzy candidates** — entities sharing a distinctive token with the mention — so a shortened name ("echo memory" → the project `echo`) surfaces the existing note instead of spawning a duplicate. Aliases are auto-derived from titles, learned from mentions on update, and re-folded from note frontmatter on `sweep`. It is rebuilt automatically by `echo.py capture` and by `sweep.py`; do not hand-edit. The only JSON file in the vault.
**Entity index:** `_agent/index/entities.json` is a machine-maintained registry mapping each entity slug to its `{path, kind, title, aliases, last_seen}`. It makes routing/resolve an O(1), alias-aware lookup and supplies the name→path map for cross-linking and recall. `resolve` matches slug/title/alias exactly; when nothing matches exactly it falls back to **fuzzy candidates** — entities sharing a distinctive token with the mention — so a shortened name ("chorus memory" → the project `chorus`) surfaces the existing note instead of spawning a duplicate. Aliases are auto-derived from titles, learned from mentions on update, and re-folded from note frontmatter on `sweep`. It is rebuilt automatically by `chorus.py capture` and by `sweep.py`; do not hand-edit. The only JSON file in the vault.
**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.
@@ -61,7 +61,7 @@
## Canonical Frontmatter
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`.
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, `/chorus-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/chorus_index.py`.
```yaml
---
@@ -77,7 +77,7 @@ source_notes: [] # plain relative paths as strings — NEVER [[wikilinks]]
---
```
`aliases:` is the **durable, Obsidian-native home for alternate names** of an entity (e.g. the project `echo` is also "echo-memory" / "echo plugin"). `capture` auto-derives the obvious case/punctuation variants of the title and writes them here; `sweep` folds frontmatter aliases back into the entity index, so they survive an index rebuild. List plain strings, never `[[wikilinks]]`.
`aliases:` is the **durable, Obsidian-native home for alternate names** of an entity (e.g. the project `chorus` is also "chorus-memory" / "chorus plugin"). `capture` auto-derives the obvious case/punctuation variants of the title and writes them here; `sweep` folds frontmatter aliases back into the entity index, so they survive an index rebuild. List plain 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
@@ -165,7 +165,7 @@ One paragraph, kept fresh via PATCH replace (target `Project Name::Status`).
### 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.
See `session-log-template.md`. CHORUS 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
@@ -1,12 +1,12 @@
# ECHO Memory Vault
# CHORUS Memory Vault
This Obsidian vault is the persistent memory substrate ("second brain") for its operator. It is read and written across Claude / 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.
**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 **`chorus-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 CHORUS 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.
- **Version marker:** `_agent/chorus-vault.md` records the schema version and bootstrap date.
Folders: `inbox/`, `journal/` (daily + weekly/monthly/quarterly/annual rollups), `projects/`, `areas/`, `resources/`, `decisions/`, and the agent subtree `_agent/`.
@@ -1,3 +1,3 @@
# 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.
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 chorus-memory skill's Inbox Triage). Don't delete originals on triage — the processing log is the audit trail.
@@ -8,12 +8,12 @@ agent_written: true
source_notes: []
schema_version: 4
bootstrapped: {{DATE}}
managed_by: echo-memory-plugin
managed_by: chorus-memory-plugin
---
# ECHO Vault Marker
# CHORUS 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.
This file marks the vault as bootstrapped by the **chorus-memory plugin** and records its schema version. The plugin's loading procedure GETs this file as its "is this vault set up?" probe; a `404` triggers a fresh bootstrap.
- `schema_version` — bumped by the plugin when the vault layout changes; a mismatch is the hook for a migration pass (see `references/bootstrap.md`).
- Do not hand-edit. The plugin owns this file.
@@ -1,15 +1,15 @@
#!/usr/bin/env python3
"""bootstrap.py — stand up (or repair) an ECHO vault deterministically.
"""bootstrap.py — stand up (or repair) an CHORUS vault deterministically.
Idempotent and additive: every write is probe-before-write and NEVER overwrites
an existing file. The marker (_agent/echo-vault.md) is written LAST, so the vault
an existing file. The marker (_agent/chorus-vault.md) is written LAST, so the vault
is only flagged "set up" once every piece is in place. Re-running is the repair
path (it fills in only what is missing). Cross-platform: pure Python via echo.py.
path (it fills in only what is missing). Cross-platform: pure Python via chorus.py.
Usage:
bootstrap.py [--dry-run]
Env: ECHO_BASE, ECHO_KEY (via echo.py), ECHO_TODAY (YYYY-MM-DD for {{DATE}}).
Env: CHORUS_BASE, CHORUS_KEY (via chorus.py), CHORUS_TODAY (YYYY-MM-DD for {{DATE}}).
"""
from __future__ import annotations
@@ -23,7 +23,7 @@ SKILL_DIR = SCRIPT_DIR.parent
SCAFFOLD = SKILL_DIR / "scaffold"
sys.path.insert(0, str(SCRIPT_DIR))
import echo # noqa: E402
import chorus # noqa: E402
LEAVES = [
"inbox/captures", "inbox/imports", "inbox/processing-log",
@@ -42,14 +42,14 @@ LEAVES = [
def exists(path: str) -> bool:
status, _ = echo.request("GET", echo.vault_url(path))
status, _ = chorus.request("GET", chorus.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(),
status, body = chorus.request("PUT", chorus.vault_url(path), data=text.encode(),
headers={"Content-Type": "text/markdown"})
echo.check(status, body, f"bootstrap put {path}")
chorus.check(status, body, f"bootstrap put {path}")
def seed(path: str, source: Path, dry_run: bool) -> None:
@@ -59,7 +59,7 @@ def seed(path: str, source: Path, dry_run: bool) -> None:
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())
text = source.read_text(encoding="utf-8").replace("{{DATE}}", chorus.today())
put_text(path, text)
print(f"bootstrap: seeded {path}")
@@ -72,12 +72,12 @@ def leaf_readme(folder: str, dry_run: bool) -> None:
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")
put_text(path, f"# {name}\n\nMemory vault folder. See the chorus-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 = argparse.ArgumentParser(description="Bootstrap or repair an CHORUS vault")
parser.add_argument("--dry-run", "-n", action="store_true")
args = parser.parse_args(argv)
@@ -85,14 +85,17 @@ def main(argv: list[str] | None = None) -> int:
print(f"bootstrap: scaffold not found at {SCAFFOLD}", file=sys.stderr)
return 1
if exists("_agent/echo-vault.md"):
status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
legacy_marker = (not exists(chorus.MARKER_PATH)) and exists(chorus.LEGACY_MARKER_PATH)
if exists(chorus.MARKER_PATH) or legacy_marker:
probe = chorus.LEGACY_MARKER_PATH if legacy_marker else chorus.MARKER_PATH
status, body = chorus.request("GET", chorus.vault_url(probe))
version = "unknown"
if status == 200:
version = next((ln.split(":", 1)[1].strip()
for ln in body.decode(errors="replace").splitlines()
if ln.startswith("schema_version:")), "unknown")
print(f"bootstrap: marker present (schema_version={version}). Running repair pass (fills only missing files).")
note = " [legacy ECHO marker]" if legacy_marker else ""
print(f"bootstrap: marker present{note} (schema_version={version}). Running repair pass (fills only missing files).")
for folder in LEAVES:
leaf_readme(folder, args.dry_run)
@@ -106,9 +109,15 @@ def main(argv: list[str] | None = None) -> int:
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) # marker LAST
if legacy_marker:
# Adopted pre-fork ECHO vault: leave the legacy marker as the single
# authoritative probe — the schema migration renames it. Two markers
# would make "which is authoritative" ambiguous.
print("bootstrap: legacy ECHO marker present — not writing the CHORUS marker (migrate.py renames it).")
else:
seed(chorus.MARKER_PATH, SCAFFOLD / "chorus-vault.md", args.dry_run) # marker LAST
print(f"bootstrap: done ({'DRY-RUN ' if args.dry_run else ''}{echo.today()}).")
print(f"bootstrap: done ({'DRY-RUN ' if args.dry_run else ''}{chorus.today()}).")
print("bootstrap: next: create today's daily note + a bootstrap session log + heartbeat (see SKILL.md).")
return 0
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""echo.py — the single validated, cross-platform client for the ECHO Obsidian
"""chorus.py — the single validated, cross-platform client for the CHORUS Obsidian
Local REST API.
Replaces the original echo.sh so the toolchain runs identically on Windows,
Replaces the original chorus.sh so the toolchain runs identically on Windows,
macOS, and Linux with no bash / GNU-vs-BSD `date` dependency only a Python 3
interpreter (which the linter already requires).
@@ -19,36 +19,36 @@ reused across requests), and `read_many` fans bulk GETs across a thread pool —
full-vault scripts (sweep, lint, recall rebuild) make a handful of warm round-trips
instead of hundreds of fresh TLS handshakes, and no longer blow past tool timeouts.
Config (owner/endpoint/key are machine-local see echo_config; the plugin ships none):
owner/endpoint/key resolved by echo_config from ~/.claude/echo-memory/config.json
(env ECHO_OWNER / ECHO_BASE / ECHO_KEY override per field).
Set up a machine with `echo.py config init` then edit, or
`echo.py config set --owner ... --endpoint ... --key ...`.
ECHO_VERIFY default 1 read-back verify after a PUT
ECHO_LOCK_TTL default 900 seconds before an advisory lock is considered stale
ECHO_TIMEOUT default 30 per-request socket timeout (seconds)
ECHO_WORKERS default 8 concurrency for read_many bulk reads
ECHO_TODAY YYYY-MM-DD to use as "today" (pass the conversation's currentDate)
Config (owner/endpoint/key are machine-local see chorus_config; the plugin ships none):
owner/endpoint/key resolved by chorus_config from ~/.claude/chorus-memory/config.json
(env CHORUS_OWNER / CHORUS_BASE / CHORUS_KEY override per field).
Set up a machine with `chorus.py config init` then edit, or
`chorus.py config set --owner ... --endpoint ... --key ...`.
CHORUS_VERIFY default 1 read-back verify after a PUT
CHORUS_LOCK_TTL default 900 seconds before an advisory lock is considered stale
CHORUS_TIMEOUT default 30 per-request socket timeout (seconds)
CHORUS_WORKERS default 8 concurrency for read_many bulk reads
CHORUS_TODAY YYYY-MM-DD to use as "today" (pass the conversation's currentDate)
Usage:
echo.py load # cold-start: the 6 orientation reads in one call
echo.py get <path> # print file contents (404 -> exit 44)
echo.py map <path> # document-map JSON (headings/blocks/frontmatter)
echo.py ls <dir> # directory listing JSON
echo.py search <query...> # /search/simple
echo.py put <path> [file] # create/overwrite (body from file or stdin)
echo.py post <path> [file] # raw append (NON-idempotent; prefer `append`)
echo.py append <path> <line> # idempotent append: skips only on an exact whole-line match
echo.py patch <path> <append|prepend|replace> <heading|frontmatter|block> <target> [file]
echo.py fm <path> <field> <json-value> # create-or-replace a frontmatter scalar
echo.py bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date)
echo.py triage [--list --json | <proposals.json> [--apply]] # one-tap inbox triage + audit log
echo.py delete <path> # DELETE (destructive; explicit use only)
echo.py lock <owner-id> # acquire advisory lock (exit 75 if held & fresh, or lost the race)
echo.py unlock <owner-id> # release advisory lock if owned by <owner-id>
echo.py scope show # print active scope, its freshness, and sessions-since
echo.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
echo.py config [show|init|set|import] # machine-local owner/endpoint/key (config import <file> adopts a provided file)
chorus.py load # cold-start: the 6 orientation reads in one call
chorus.py get <path> # print file contents (404 -> exit 44)
chorus.py map <path> # document-map JSON (headings/blocks/frontmatter)
chorus.py ls <dir> # directory listing JSON
chorus.py search <query...> # /search/simple
chorus.py put <path> [file] # create/overwrite (body from file or stdin)
chorus.py post <path> [file] # raw append (NON-idempotent; prefer `append`)
chorus.py append <path> <line> # idempotent append: skips only on an exact whole-line match
chorus.py patch <path> <append|prepend|replace> <heading|frontmatter|block> <target> [file]
chorus.py fm <path> <field> <json-value> # create-or-replace a frontmatter scalar
chorus.py bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date)
chorus.py triage [--list --json | <proposals.json> [--apply]] # one-tap inbox triage + audit log
chorus.py delete <path> # DELETE (destructive; explicit use only)
chorus.py lock <owner-id> # acquire advisory lock (exit 75 if held & fresh, or lost the race)
chorus.py unlock <owner-id> # release advisory lock if owned by <owner-id>
chorus.py scope show # print active scope, its freshness, and sessions-since
chorus.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
chorus.py config [show|init|set|import] # machine-local owner/endpoint/key (config import <file> adopts a provided file)
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 76 duplicate-gate (capture) ·
78 config-required · 2 usage · 1 other HTTP/transport error.
@@ -79,36 +79,53 @@ for _stream in (sys.stdout, sys.stderr):
except Exception:
pass
# Owner / endpoint / key are machine-local and resolved by echo_config from
# ~/.claude/echo-memory/config.json (env ECHO_OWNER / ECHO_BASE / ECHO_KEY override).
# Owner / endpoint / key are machine-local and resolved by chorus_config from
# ~/.claude/chorus-memory/config.json (env CHORUS_OWNER / CHORUS_BASE / CHORUS_KEY override).
# The plugin ships none of them; load() returns "" for anything unset so --help,
# `config`, and `doctor` stay usable. Network ops raise a clear error if endpoint/key
# are missing (see _require_endpoint / request).
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo_config # noqa: E402
_CFG = echo_config.load()
import chorus_config # noqa: E402
_CFG = chorus_config.load()
BASE = _CFG["endpoint"]
KEY = _CFG["key"]
OWNER = _CFG["owner"]
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
VERIFY = os.environ.get("CHORUS_VERIFY", "1") != "0"
LOCK_TTL = int(os.environ.get("CHORUS_LOCK_TTL", "900"))
# Per-request socket timeout. A single stuck file fails fast instead of stalling a
# full-vault pass; with read_many concurrency it only blocks one worker, not the run.
TIMEOUT = int(os.environ.get("ECHO_TIMEOUT", "30"))
TIMEOUT = int(os.environ.get("CHORUS_TIMEOUT", "30"))
# Concurrency for bulk reads (read_many). I/O-bound, so threads bypass the GIL fine.
MAX_WORKERS = max(1, int(os.environ.get("ECHO_WORKERS", "8")))
MAX_WORKERS = max(1, int(os.environ.get("CHORUS_WORKERS", "8")))
LOCK_PATH = "_agent/locks/vault.lock"
MARKER_PATH = "_agent/chorus-vault.md"
# Pre-fork ECHO vaults carry the old marker name. It is honored read-only as a
# bootstrap probe (vault-adoption path); the schema migration renames it.
LEGACY_MARKER_PATH = "_agent/echo-vault.md"
class EchoError(RuntimeError):
def marker_get():
"""GET the bootstrap marker: the CHORUS name first, then the legacy ECHO
name. Returns (path, status, body) on a double miss, the CHORUS path with
its 404 (or error) result."""
status, body = request("GET", vault_url(MARKER_PATH))
if status == 404:
l_status, l_body = request("GET", vault_url(LEGACY_MARKER_PATH))
if l_status == 200:
return LEGACY_MARKER_PATH, l_status, l_body
return MARKER_PATH, status, body
class ChorusError(RuntimeError):
def __init__(self, message: str, code: int = 1) -> None:
super().__init__(message)
self.code = code
def today() -> str:
return os.environ.get("ECHO_TODAY") or dt.date.today().isoformat()
return os.environ.get("CHORUS_TODAY") or dt.date.today().isoformat()
def now_iso() -> str:
@@ -137,13 +154,13 @@ def _require_config() -> None:
"""Fail loudly (not with an opaque DNS/connection error) when this machine has no
usable key file yet the most common fresh-install state. Also catches an
untouched `config init` template (placeholder endpoint/key)."""
if not echo_config.is_configured(_CFG):
raise EchoError(
"echo: NOT CONFIGURED — no usable ECHO key file on this machine. Ask the "
if not chorus_config.is_configured(_CFG):
raise ChorusError(
"chorus: NOT CONFIGURED — no usable CHORUS key file on this machine. Ask the "
f"operator for their config (owner/endpoint/key) and install it at "
f"{echo_config.config_path()}: `echo.py config import <file>`, or "
"`echo.py config set --owner ... --endpoint ... --key ...` "
"(or set ECHO_BASE / ECHO_KEY).", 2)
f"{chorus_config.config_path()}: `chorus.py config import <file>`, or "
"`chorus.py config set --owner ... --endpoint ... --key ...` "
"(or set CHORUS_BASE / CHORUS_KEY).", 2)
def _new_connection() -> http.client.HTTPConnection:
@@ -175,7 +192,7 @@ def _drop_connection() -> None:
def request(method: str, url: str, data: bytes | None = None,
headers: dict[str, str] | None = None) -> tuple[int, bytes]:
"""Issue one request over this thread's reused connection. Returns (status, body);
status 0 == transport failure (echo_queue relies on this to trigger offline queueing).
status 0 == transport failure (chorus_queue relies on this to trigger offline queueing).
Retries: a stale pooled connection reconnects immediately; 5xx and other transport
errors get one bounded sleep-retry, same as before."""
_require_config()
@@ -237,11 +254,11 @@ def read_many(paths) -> dict[str, str | None]:
def check(status: int, body: bytes, context: str) -> None:
if status == 404:
raise EchoError(f"{context}: not found", 44)
raise ChorusError(f"{context}: not found", 44)
if status == 0:
raise EchoError(f"{context}: vault unreachable ({body.decode(errors='replace')}) [{BASE}]", 1)
raise ChorusError(f"{context}: vault unreachable ({body.decode(errors='replace')}) [{BASE}]", 1)
if status >= 400:
raise EchoError(f"{context}: HTTP {status} - {body.decode(errors='replace')}", 1)
raise ChorusError(f"{context}: HTTP {status} - {body.decode(errors='replace')}", 1)
def read_body(arg: str | None) -> bytes:
@@ -323,10 +340,10 @@ def cmd_search(query: list[str]) -> int:
def _qwrite(method: str, url: str, data: bytes | None = None, headers: dict | None = None):
"""A mutating request that survives an outage: routes through echo_queue.safe_request,
"""A mutating request that survives an outage: routes through chorus_queue.safe_request,
which enqueues the write and returns (202, ...) when the vault is unreachable (H2)."""
import echo_queue
return echo_queue.safe_request(method, url, data=data, headers=headers)
import chorus_queue
return chorus_queue.safe_request(method, url, data=data, headers=headers)
def cmd_put(path: str, file_arg: str | None) -> int:
@@ -340,16 +357,16 @@ def cmd_put(path: str, file_arg: str | None) -> int:
if VERIFY:
status, _ = request("GET", vault_url(path))
if status != 200:
raise EchoError(f"put {path}: write did not verify (GET returned {status})")
raise ChorusError(f"put {path}: write did not verify (GET returned {status})")
print(f"ok: PUT {path}")
# Keep the recall (BM25) index current when a corpus note is written directly —
# session logs, journal notes, hand-authored entity notes. update_note early-returns
# for non-corpus paths and never raises, so this can't fail or slow a normal put.
try:
import echo_recall
echo_recall.update_note(path, data.decode("utf-8", errors="replace"))
import chorus_recall
chorus_recall.update_note(path, data.decode("utf-8", errors="replace"))
except Exception as exc: # noqa: BLE001 — index upkeep must never fail a put
print(f"echo.py: recall-index update skipped ({exc})", file=sys.stderr)
print(f"chorus.py: recall-index update skipped ({exc})", file=sys.stderr)
return 0
@@ -390,9 +407,9 @@ def cmd_append(path: str, line: str) -> int:
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)
raise ChorusError("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)
raise ChorusError("patch: target-type must be heading, frontmatter, or block", 2)
data = normalize_patch_body(read_body(file_arg), op, target_type)
status, body = _qwrite(
"PATCH",
@@ -452,7 +469,7 @@ def cmd_fm(path: str, field: str, value: str) -> int:
data = normalize_json_scalar(value)
try:
return cmd_patch(path, "replace", "frontmatter", field, temp_file(data))
except EchoError as exc:
except ChorusError as exc:
if "HTTP 400" not in str(exc):
raise
status, body = request("GET", vault_url(path))
@@ -468,7 +485,7 @@ def cmd_fm(path: str, field: str, value: str) -> int:
if VERIFY:
status, body = request("GET", vault_url(path))
if status != 200 or not re.search(rf"(?m)^{re.escape(field)}\s*:", body.decode(errors="replace")):
raise EchoError(f"fm {path}: created key '{field}' did not verify on read-back")
raise ChorusError(f"fm {path}: created key '{field}' did not verify on read-back")
print(f"ok: FM created '{field}' in {path}")
return 0
@@ -585,9 +602,9 @@ def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -
return 0
if subcommand != "set":
raise EchoError("scope: use 'show' or 'set \"<text>\"'", 2)
raise ChorusError("scope: use 'show' or 'set \"<text>\"'", 2)
if not text:
raise EchoError("scope set needs the new scope text", 2)
raise ChorusError("scope set needs the new scope text", 2)
prior = extract_heading(current, "Scope").replace("\n", " ").strip()[:140] or "(prior scope)"
cmd_patch(path, "prepend", "heading", "Current Context::Scope History",
temp_file(f"- {today()}: {prior}\n".encode()))
@@ -596,8 +613,8 @@ def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -
try:
cmd_patch(path, "replace", "frontmatter", "scope_updated",
temp_file(json.dumps(today()).encode()))
except EchoError as exc:
raise EchoError(
except ChorusError as exc:
raise ChorusError(
"scope set: body switched, but scope_updated frontmatter is missing "
f"(run bootstrap.py to add it) [{exc}]"
)
@@ -608,27 +625,27 @@ def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -
def cmd_load() -> int:
"""Cold-start orientation: the canonical 6 reads in one call. 404s on today's
daily note and the inbox are normal (printed as absent, not errors)."""
if not echo_config.is_configured(_CFG):
print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.")
print(f"Expected at: {echo_config.config_path()}")
print("ACTION: ask the operator for their echo-memory config file (it holds the")
if not chorus_config.is_configured(_CFG):
print("chorus: NOT CONFIGURED — this machine has no usable CHORUS key file yet.")
print(f"Expected at: {chorus_config.config_path()}")
print("ACTION: ask the operator for their chorus-memory config file (it holds the")
print(" vault owner, endpoint, and API key), then install it with ONE of:")
print(" python3 echo.py config import <path-to-their-file>")
print(" python3 echo.py config set --owner \"\" --endpoint \"https://…\" --key \"\"")
print(" python3 chorus.py config import <path-to-their-file>")
print(" python3 chorus.py config set --owner \"\" --endpoint \"https://…\" --key \"\"")
print("Then re-run load. (Memory is unavailable until configured.)")
return 78 # distinct: configuration required
targets = [
("marker", "_agent/echo-vault.md"),
("marker", MARKER_PATH),
("preferences", "_agent/memory/semantic/operator-preferences.md"),
("context", "_agent/context/current-context.md"),
("heartbeat", "_agent/heartbeat/last-session.md"),
("today", f"journal/daily/{today()}.md"),
("inbox", "inbox/captures/inbox.md"),
]
import echo_queue
import chorus_queue
# H2: sync any writes queued during a prior outage, best-effort and quiet on empty.
try:
synced = echo_queue.flush()
synced = chorus_queue.flush()
if synced:
print(f"(synced {synced} write(s) queued during a prior offline session)\n")
except Exception as exc: # noqa: BLE001 — never let queue upkeep block a load
@@ -638,9 +655,12 @@ def cmd_load() -> int:
heartbeat_absent = False
offline = False
for label, path in targets:
if label == "marker":
path, status, body = marker_get() # falls back to a legacy ECHO marker
else:
status, body = request("GET", vault_url(path))
if status == 200:
echo_queue.cache_put(path, body) # refresh last-known-good for offline fallback
chorus_queue.cache_put(path, body) # refresh last-known-good for offline fallback
print(f"===== {label}: {path} (HTTP 200) =====")
text = body.decode(errors="replace")
sys.stdout.write(text)
@@ -655,7 +675,7 @@ def cmd_load() -> int:
heartbeat_absent = True
elif status == 0: # vault unreachable -> degrade to last-known-good cache
offline = True
cached = echo_queue.cache_get(path)
cached = chorus_queue.cache_get(path)
if cached is not None:
print(f"===== {label}: {path} (OFFLINE — serving stale cache) =====")
sys.stdout.write(cached.decode(errors="replace"))
@@ -693,9 +713,9 @@ def cmd_load() -> int:
def cmd_config(args) -> int:
"""show | init | set the machine-local owner/endpoint/key config."""
path = echo_config.config_path()
path = chorus_config.config_path()
if args.action == "init":
p, created = echo_config.init_template()
p, created = chorus_config.init_template()
print(f"ok: wrote config template to {p} — edit it with your owner/endpoint/key"
if created else f"config already exists at {p} (left unchanged)")
return 0
@@ -705,7 +725,7 @@ def cmd_config(args) -> int:
"(JSON with owner/endpoint/key)", file=sys.stderr)
return 2
try:
p = echo_config.import_file(args.path)
p = chorus_config.import_file(args.path)
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
@@ -715,22 +735,22 @@ def cmd_config(args) -> int:
if args.owner is None and args.endpoint is None and args.key is None:
print("config set: pass at least one of --owner / --endpoint / --key", file=sys.stderr)
return 2
p = echo_config.write_config(args.owner, args.endpoint, args.key)
p = chorus_config.write_config(args.owner, args.endpoint, args.key)
print(f"ok: updated {p} (chmod 600 best-effort)")
return 0
# show — never prints the key in the clear
cfg = echo_config.load()
cfg = chorus_config.load()
key = cfg["key"]
redacted = (key[:4] + "" + key[-4:]) if len(key) >= 12 else ("set" if key else "")
print(f"config file: {path}" + ("" if path.exists() else " (absent)"))
for field, shown in (("owner", cfg["owner"]), ("endpoint", cfg["endpoint"]), ("key", redacted)):
src = echo_config.source(field)
src = chorus_config.source(field)
print(f" {field:9} {shown or '(unset)'} [{src}]")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validated cross-platform ECHO vault client")
parser = argparse.ArgumentParser(description="Validated cross-platform CHORUS vault client")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("load")
sub.add_parser("flush") # H2: replay writes queued during a prior outage
@@ -750,7 +770,7 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("delete").add_argument("path")
sub.add_parser("lock").add_argument("owner")
sub.add_parser("unlock").add_argument("owner")
p = sub.add_parser("config") # machine-local owner/endpoint/key (see echo_config)
p = sub.add_parser("config") # machine-local owner/endpoint/key (see chorus_config)
p.add_argument("action", nargs="?", default="show", choices=["show", "init", "set", "import"])
p.add_argument("path", nargs="?") # for `config import <path>`
p.add_argument("--owner"); p.add_argument("--endpoint"); p.add_argument("--key")
@@ -767,7 +787,7 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--json", action="store_true") # structured inbox listing
p.add_argument("--apply", action="store_true") # route + write processing log
# High-level operations (entity index + cross-links + recall). See echo_ops.py.
# High-level operations (entity index + cross-links + recall). See chorus_ops.py.
sub.add_parser("resolve").add_argument("mention", nargs="+")
p = sub.add_parser("recall"); p.add_argument("query", nargs="+"); p.add_argument("--limit", type=int, default=6)
p.add_argument("--json", action="store_true") # structured hits + neighbourhood
@@ -798,12 +818,12 @@ def main(argv: list[str] | None = None) -> int:
if args.cmd == "load":
return cmd_load()
if args.cmd == "doctor":
import echo_doctor
return echo_doctor.run()
import chorus_doctor
return chorus_doctor.run()
if args.cmd == "flush":
import echo_queue
n = echo_queue.flush()
remaining = len(echo_queue.pending())
import chorus_queue
n = chorus_queue.flush()
remaining = len(chorus_queue.pending())
if n:
print(f"ok: flushed {n} queued write(s)"
+ (f"; {remaining} still queued (vault unreachable)" if remaining else ""))
@@ -843,36 +863,36 @@ def main(argv: list[str] | None = None) -> int:
if args.cmd == "scope":
return cmd_scope(args.subcommand, args.text, as_json=args.json)
if args.cmd == "reflect":
import echo_reflect
import chorus_reflect
raw = read_body(args.file).decode("utf-8", errors="replace")
try:
proposals = json.loads(raw)
except json.JSONDecodeError as exc:
raise EchoError(f"reflect: proposals must be a JSON array ({exc})", 2)
raise ChorusError(f"reflect: proposals must be a JSON array ({exc})", 2)
if not isinstance(proposals, list):
raise EchoError("reflect: proposals must be a JSON array of objects", 2)
return echo_reflect.apply(proposals, confirm=args.apply)
raise ChorusError("reflect: proposals must be a JSON array of objects", 2)
return chorus_reflect.apply(proposals, confirm=args.apply)
if args.cmd == "triage":
import echo_triage
import chorus_triage
if args.list_inbox or not args.file:
return echo_triage.list_inbox(as_json=args.json)
return chorus_triage.list_inbox(as_json=args.json)
raw = read_body(args.file).decode("utf-8", errors="replace")
try:
proposals = json.loads(raw)
except json.JSONDecodeError as exc:
raise EchoError(f"triage: proposals must be a JSON array ({exc})", 2)
raise ChorusError(f"triage: proposals must be a JSON array ({exc})", 2)
if not isinstance(proposals, list):
raise EchoError("triage: proposals must be a JSON array of objects", 2)
return echo_triage.apply(proposals, confirm=args.apply)
raise ChorusError("triage: proposals must be a JSON array of objects", 2)
return chorus_triage.apply(proposals, confirm=args.apply)
if args.cmd in ("resolve", "recall", "link", "capture"):
import echo_ops # lazy: only the high-level ops pull in the index/link modules
import chorus_ops # lazy: only the high-level ops pull in the index/link modules
if args.cmd == "resolve":
return echo_ops.resolve(" ".join(args.mention))
return chorus_ops.resolve(" ".join(args.mention))
if args.cmd == "recall":
return echo_ops.recall(args.query, limit=args.limit, as_json=args.json)
return chorus_ops.recall(args.query, limit=args.limit, as_json=args.json)
if args.cmd == "link":
return echo_ops.link(args.a, args.b, as_json=args.json)
return echo_ops.capture(
return chorus_ops.link(args.a, args.b, as_json=args.json)
return chorus_ops.capture(
args.kind, args.title, args.file, status_v=args.status,
aliases=args.aliases.split(",") if args.aliases else [],
sources=args.source.split(",") if args.source else [],
@@ -880,8 +900,8 @@ def main(argv: list[str] | None = None) -> int:
date=args.date, domain=args.domain, inbox=args.inbox, no_log=args.no_log,
as_json=args.json, dry_run=args.dry_run,
force=args.force, merge_into=args.merge_into)
except EchoError as exc:
print(f"echo.py: {exc}", file=sys.stderr)
except ChorusError as exc:
print(f"chorus.py: {exc}", file=sys.stderr)
return exc.code
return 2
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""echo_concurrency.py — H3: make the multi-writer story actually safe. [v1.0 SCAFFOLD — not yet wired]
"""chorus_concurrency.py — H3: make the multi-writer story actually safe. [v1.0 SCAFFOLD — not yet wired]
The plugin advertises a shared vault (Claude Code + CoWork), but two real races exist:
1. The entity index is a full-file load -> mutate -> PUT (echo_index.load/save) with
1. The entity index is a full-file load -> mutate -> PUT (chorus_index.load/save) with
NO lock. Two concurrent `capture` calls both read the old index; last PUT wins;
the other entity is silently dropped from the index (the note file survives, but
resolve/recall miss it until the next sweep).
2. The advisory lock is MANUAL (echo.py cmd_lock) capture / scope set never take it;
2. The advisory lock is MANUAL (chorus.py cmd_lock) capture / scope set never take it;
it relies on the model remembering to.
This module provides (a) a context manager that auto-acquires/releases the existing
@@ -26,14 +26,14 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
import echo_index as idx_mod # noqa: E402
import chorus # noqa: E402
import chorus_index as idx_mod # noqa: E402
def auto_owner() -> str:
"""A stable-per-process owner id: client tag + pid. Avoids Math.random/time-based
ids; pid is unique enough for cooperative locking and is reproducible in tests."""
tag = os.environ.get("ECHO_CLIENT", "cc")
tag = os.environ.get("CHORUS_CLIENT", "cc")
return f"{tag}-{os.getpid()}"
@@ -47,16 +47,16 @@ def vault_lock(owner: str | None = None, required: bool = False):
raise. Release is best-effort and never masks the body's own exception.
"""
owner = owner or auto_owner()
rc = echo.cmd_lock(owner, quiet=True)
rc = chorus.cmd_lock(owner, quiet=True)
held = rc == 0
if not held and required:
raise echo.EchoError(f"vault_lock: could not acquire lock for '{owner}' (held elsewhere)", 75)
raise chorus.ChorusError(f"vault_lock: could not acquire lock for '{owner}' (held elsewhere)", 75)
try:
yield held
finally:
if held:
try:
echo.cmd_unlock(owner, quiet=True)
chorus.cmd_unlock(owner, quiet=True)
except Exception: # noqa: BLE001 — release is best-effort; TTL reclaims a leak
pass
@@ -76,7 +76,7 @@ def atomic_index_update(mutate, owner: str | None = None):
mutate(index)
idx_mod.save(index)
if not held:
print("echo_concurrency: entity index updated WITHOUT the advisory lock "
print("chorus_concurrency: entity index updated WITHOUT the advisory lock "
"(another session may be active); the fresh re-read minimizes the race.",
file=sys.stderr)
return index
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""echo_config.py — resolve the vault owner, endpoint, and API key for ECHO.
"""chorus_config.py — resolve the vault owner, endpoint, and API key for CHORUS.
The plugin ships NO owner, endpoint, or key it is fully user-agnostic. On each
machine, a machine-local JSON config supplies them:
~/.claude/echo-memory/config.json
~/.claude/chorus-memory/config.json
{
"owner": "Full Name (how the agent refers to the vault owner, third person)",
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
@@ -13,25 +13,25 @@ machine, a machine-local JSON config supplies them:
This file is per-machine: it must be created on each fresh install, is never
committed, and is never written into a vault note. To create it, run
`python3 echo.py config init` (writes a placeholder template when absent) and edit
it, or `python3 echo.py config set --owner ... --endpoint ... --key ...`.
`python3 chorus.py config init` (writes a placeholder template when absent) and edit
it, or `python3 chorus.py config set --owner ... --endpoint ... --key ...`.
Resolution a COMPLETE baked credential set wins unconditionally:
If the artifact carries both a baked endpoint AND key (a per-user build), those
baked values are used as-is and CANNOT be shadowed by env vars or a config file.
This is the default path: a delivered per-user plugin "just works" with no setup,
and a stale ECHO_* env var or a leftover/placeholder ~/.claude config.json can
and a stale CHORUS_* env var or a leftover/placeholder ~/.claude config.json can
never override or break it.
Otherwise (nothing baked the generic published plugin), fall back per-field,
first hit wins:
owner env ECHO_OWNER -> config "owner"
endpoint env ECHO_BASE -> config "endpoint"
key env ECHO_KEY -> config "key"
owner env CHORUS_OWNER -> config "owner"
endpoint env CHORUS_BASE -> config "endpoint"
key env CHORUS_KEY -> config "key"
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
file path itself can be overridden with $ECHO_CONFIG. Pure stdlib only.
file path itself can be overridden with $CHORUS_CONFIG. Pure stdlib only.
BAKED CREDENTIALS (the default tier): the DEFAULT_* constants below are EMPTY in
source the committed plugin ships zero credentials. `build.py --bake-key`
@@ -54,6 +54,26 @@ DEFAULT_OWNER = ""
DEFAULT_BASE = ""
DEFAULT_KEY = ""
# ---------------------------------------------------------------- legacy shim
# CHORUS is a fork of ECHO. For one major version, legacy ECHO_* environment
# variables keep working: at import time each ECHO_<X> is adopted as CHORUS_<X>
# whenever CHORUS_<X> is not itself set. Scripts only ever read CHORUS_*.
_LEGACY_ENV_SUFFIXES = (
"OWNER", "BASE", "KEY", "CONFIG", "TODAY", "VERIFY", "LOCK_TTL", "TIMEOUT",
"WORKERS", "STATE_DIR", "CLIENT", "DUP_GATE", "REFLECT_MIN_TURNS",
"FRESH_HALF_LIFE",
)
def _alias_legacy_env() -> None:
for suffix in _LEGACY_ENV_SUFFIXES:
new, old = f"CHORUS_{suffix}", f"ECHO_{suffix}"
if not os.environ.get(new) and os.environ.get(old):
os.environ[new] = os.environ[old]
_alias_legacy_env()
TEMPLATE = {
"owner": "Full Name — how the agent should refer to the vault owner (third person)",
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
@@ -67,15 +87,32 @@ def claude_dir() -> Path:
def config_path() -> Path:
"""Absolute path to the machine-local config file."""
override = os.environ.get("ECHO_CONFIG")
"""Absolute path to the machine-local config file (the CHORUS path — this is
where writes always land)."""
override = os.environ.get("CHORUS_CONFIG")
if override:
return Path(override).expanduser()
return claude_dir() / "chorus-memory" / "config.json"
def legacy_config_path() -> Path:
"""The pre-fork ECHO config location, honored read-only as a fallback."""
return claude_dir() / "echo-memory" / "config.json"
def _from_file() -> dict:
def read_config_path() -> Path:
"""The config file to READ: the CHORUS path when overridden or present,
else a legacy ECHO config if one exists (fork adoption path). Writes never
go to the legacy path `config set`/`import` land at config_path()."""
p = config_path()
if os.environ.get("CHORUS_CONFIG") or p.exists():
return p
legacy = legacy_config_path()
return legacy if legacy.exists() else p
def _from_file() -> dict:
p = read_config_path()
if not p.exists():
return {}
try:
@@ -104,22 +141,22 @@ def load() -> dict:
# by a stale env var or a leftover/placeholder config file. Owner is purely
# descriptive, so it may still fall back when not baked.
return {
"owner": DEFAULT_OWNER or os.environ.get("ECHO_OWNER") or f.get("owner") or "",
"owner": DEFAULT_OWNER or os.environ.get("CHORUS_OWNER") or f.get("owner") or "",
"endpoint": DEFAULT_BASE.rstrip("/"),
"key": DEFAULT_KEY,
}
endpoint = (os.environ.get("ECHO_BASE") or f.get("endpoint") or "").rstrip("/")
endpoint = (os.environ.get("CHORUS_BASE") or f.get("endpoint") or "").rstrip("/")
return {
"owner": os.environ.get("ECHO_OWNER") or f.get("owner") or "",
"owner": os.environ.get("CHORUS_OWNER") or f.get("owner") or "",
"endpoint": endpoint,
"key": os.environ.get("ECHO_KEY") or f.get("key") or "",
"key": os.environ.get("CHORUS_KEY") or f.get("key") or "",
}
def _missing(field: str, env: str) -> RuntimeError:
return RuntimeError(
f"echo: no {field} configured. Set {env}, or create {config_path()} "
f"(run `echo.py config init` to scaffold it, then edit). "
f"chorus: no {field} configured. Set {env}, or create {config_path()} "
f"(run `chorus.py config init` to scaffold it, then edit). "
f'Expected JSON keys: "owner", "endpoint", "key".'
)
@@ -127,14 +164,14 @@ def _missing(field: str, env: str) -> RuntimeError:
def resolve_endpoint() -> str:
ep = load()["endpoint"]
if not ep:
raise _missing("endpoint", "ECHO_BASE")
raise _missing("endpoint", "CHORUS_BASE")
return ep
def resolve_key() -> str:
k = load()["key"]
if not k:
raise _missing("key", "ECHO_KEY")
raise _missing("key", "CHORUS_KEY")
return k
@@ -163,11 +200,12 @@ def source(field: str) -> str:
# come from the artifact, and owner too whenever it was baked.
if baked_complete() and (field in ("endpoint", "key") or baked):
return "baked-in (plugin)"
env = {"owner": "ECHO_OWNER", "endpoint": "ECHO_BASE", "key": "ECHO_KEY"}[field]
env = {"owner": "CHORUS_OWNER", "endpoint": "CHORUS_BASE", "key": "CHORUS_KEY"}[field]
if os.environ.get(env):
return f"{env} env"
if config_path().exists() and _from_file().get(field):
return "config file"
rp = read_config_path()
if rp.exists() and _from_file().get(field):
return "config file" if rp == config_path() else "config file (legacy echo-memory path)"
if baked:
return "baked-in (plugin)"
return "unset"
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""echo_doctor.py — M3: one-call readiness check. [v1.0 SCAFFOLD — run() implemented]
"""chorus_doctor.py — M3: one-call readiness check. [v1.0 SCAFFOLD — run() implemented]
There is no single "is everything OK" command. doctor checks, in order, the things
that make ECHO usable this session and prints a green/red report. Intended to back an
`echo.py doctor` subcommand and the start of /echo-load when something looks wrong.
that make CHORUS usable this session and prints a green/red report. Intended to back an
`chorus.py doctor` subcommand and the start of /chorus-load when something looks wrong.
Also tracked here (TODO): complete the `load` fallback echo.cmd_load reads the
Also tracked here (TODO): complete the `load` fallback chorus.cmd_load reads the
heartbeat pointer but never lists _agent/sessions/ when it's missing/stale, though
SKILL.md:122 documents that fallback. See patch_load_fallback() below.
@@ -17,7 +17,7 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
import chorus # noqa: E402
MIN_PY = (3, 9)
@@ -30,9 +30,9 @@ def run() -> int:
reds += 0 if ok else 1
print(f" [{'OK ' if ok else 'RED'}] {label}" + (f"{detail}" if detail else ""))
import echo_config
cfg = echo_config.load()
print(f"echo doctor — endpoint {cfg['endpoint'] or '(not configured)'}")
import chorus_config
cfg = chorus_config.load()
print(f"chorus doctor — endpoint {cfg['endpoint'] or '(not configured)'}")
# 1. interpreter
line(sys.version_info >= MIN_PY, f"python >= {MIN_PY[0]}.{MIN_PY[1]}",
@@ -44,22 +44,23 @@ def run() -> int:
ep_real = bool(cfg["endpoint"]) and "your-obsidian-rest-endpoint" not in cfg["endpoint"]
key_real = bool(cfg["key"]) and not cfg["key"].startswith("<")
line(ep_real, "endpoint configured",
f"[{echo_config.source('endpoint')}]" if ep_real
else (f"placeholder — edit {echo_config.config_path()}" if cfg["endpoint"]
else f"create {echo_config.config_path()} (run `echo.py config init`)"))
f"[{chorus_config.source('endpoint')}]" if ep_real
else (f"placeholder — edit {chorus_config.config_path()}" if cfg["endpoint"]
else f"create {chorus_config.config_path()} (run `chorus.py config init`)"))
line(key_real, "API key configured",
f"[{echo_config.source('key')}]" if key_real
else ("placeholder — not yet filled in" if cfg["key"] else "missing — see `echo.py config`"))
f"[{chorus_config.source('key')}]" if key_real
else ("placeholder — not yet filled in" if cfg["key"] else "missing — see `chorus.py config`"))
line(bool(cfg["owner"]), "vault owner set",
cfg["owner"] if cfg["owner"] else "optional, but recommended for third-person writes")
if not echo_config.is_configured(cfg):
if not chorus_config.is_configured(cfg):
print("\ndoctor: not configured — ask the operator for their key file and install it "
"(`echo.py config import <file>`, or `config set --owner … --endpoint … --key …`), "
"(`chorus.py config import <file>`, or `config set --owner … --endpoint … --key …`), "
"then re-run.")
return 1
# 3. reachability + auth + marker, in one GET of the bootstrap marker
status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
# (marker_get falls back to a pre-fork ECHO marker on adopted vaults)
marker_path, status, body = chorus.marker_get()
if status == 0:
line(False, "vault reachable", body.decode(errors="replace")[:120])
print("\ndoctor: endpoint unreachable — is Obsidian + the Local REST API running?")
@@ -71,12 +72,13 @@ def run() -> int:
text = body.decode(errors="replace")
ver = next((ln.split(":", 1)[1].strip() for ln in text.splitlines()
if ln.startswith("schema_version:")), "?")
line(True, "vault bootstrapped", f"schema_version={ver}")
legacy = " (legacy ECHO marker — run migrate.py to adopt)" if marker_path == chorus.LEGACY_MARKER_PATH else ""
line(True, "vault bootstrapped", f"schema_version={ver}{legacy}")
else:
line(status < 400, "marker fetch", f"HTTP {status}")
# 4. invariants pointer.
print(" [i] invariants — run `/echo-health` (vault_lint.py) for the full check")
print(" [i] invariants — run `/chorus-health` (vault_lint.py) for the full check")
print(f"\ndoctor: {'all green' if reds == 0 else f'{reds} issue(s) — see RED above'}")
return 1 if reds else 0
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""echo_hook_session_start.py — SessionStart hook: self-firing cold-start load.
"""chorus_hook_session_start.py — SessionStart hook: self-firing cold-start load.
The two behaviours the skill cares most about (load at session start, reflect at
session end) used to depend entirely on the model remembering to do them scope
drift and the inbox backlog are the residue of that discipline failing. This hook
makes loading deterministic: on session start it runs the canonical `echo.py load`
makes loading deterministic: on session start it runs the canonical `chorus.py load`
and hands the output to the model as additionalContext.
Contract (a hook must NEVER break a session):
@@ -35,29 +35,29 @@ def main() -> int:
if payload.get("source", "startup") not in ("startup", "clear"):
return 0
header = ("[echo-memory] Cold-start memory load (SessionStart hook). Reconcile per "
"the echo-memory skill: check inbox depth and confirm the scope still "
header = ("[chorus-memory] Cold-start memory load (SessionStart hook). Reconcile per "
"the chorus-memory skill: check inbox depth and confirm the scope still "
"matches this session's request before working.\n\n")
buf = io.StringIO()
try:
import echo
import chorus
with contextlib.redirect_stdout(buf):
rc = echo.cmd_load()
rc = chorus.cmd_load()
text = buf.getvalue()
if rc == 78:
context = ("[echo-memory] ECHO is NOT CONFIGURED on this machine. Before "
"doing memory work, ask the operator for their echo-memory config "
"(owner/endpoint/key) and install it via `echo.py config import "
context = ("[chorus-memory] CHORUS is NOT CONFIGURED on this machine. Before "
"doing memory work, ask the operator for their chorus-memory config "
"(owner/endpoint/key) and install it via `chorus.py config import "
"<file>` — see the skill's First-run section.")
elif rc == 0 and text.strip():
if len(text) > MAX_CONTEXT:
text = text[:MAX_CONTEXT] + "\n[... load output truncated by the hook ...]"
context = header + text
else:
context = ("[echo-memory] `echo.py load` returned nothing usable "
f"(exit {rc}) — run /echo-doctor if memory seems unavailable.")
context = ("[chorus-memory] `chorus.py load` returned nothing usable "
f"(exit {rc}) — run /chorus-doctor if memory seems unavailable.")
except Exception as exc: # noqa: BLE001 — never break session start
context = (f"[echo-memory] SessionStart load skipped ({exc}). The vault may be "
context = (f"[chorus-memory] SessionStart load skipped ({exc}). The vault may be "
"unreachable; proceed without memory and mention it once if relevant.")
print(json.dumps({"hookSpecificOutput": {
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""echo_hook_stop.py — Stop hook: one-shot session-end reflection nudge.
"""chorus_hook_stop.py — Stop hook: one-shot session-end reflection nudge.
Reflection (/echo-reflect) only captures memory if someone remembers to run it.
Reflection (/chorus-reflect) only captures memory if someone remembers to run it.
This hook watches the first Stop of a *substantive* session and, when nothing has
been reflected or session-logged yet, blocks once with a reminder so the model
runs the reflect flow (which still previews and asks the operator before writing
@@ -9,10 +9,10 @@ runs the reflect flow (which still previews and asks the operator before writing
Guards (a hook must NEVER trap a session in a loop or nag):
* `stop_hook_active` -> exit immediately (loop guard);
* fires at most ONCE per session (marker file under ECHO_STATE_DIR/hook-state);
* skips quiet sessions (< ECHO_REFLECT_MIN_TURNS user turns, default 5);
* fires at most ONCE per session (marker file under CHORUS_STATE_DIR/hook-state);
* skips quiet sessions (< CHORUS_REFLECT_MIN_TURNS user turns, default 5);
* skips when the transcript shows reflection / a session log already happened;
* skips when ECHO isn't configured on this machine;
* skips when CHORUS isn't configured on this machine;
* always exits 0.
"""
from __future__ import annotations
@@ -25,20 +25,30 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
MIN_TURNS = int(os.environ.get("ECHO_REFLECT_MIN_TURNS", "5"))
MIN_TURNS = int(os.environ.get("CHORUS_REFLECT_MIN_TURNS", "5"))
REASON = (
"[echo-memory] Session-end reflection has not run yet. If this session produced "
"[chorus-memory] Session-end reflection has not run yet. If this session produced "
"durable facts, decisions, commitments, or artifacts worth remembering: run the "
"/echo-reflect flow (extract -> preview -> apply only with the operator's confirm) "
"and write the session log + heartbeat per the echo-memory skill. If nothing "
"/chorus-reflect flow (extract -> preview -> apply only with the operator's confirm) "
"and write the session log + heartbeat per the chorus-memory skill. If nothing "
"durable emerged, simply finish — do not invent memories. "
"(This reminder fires once per session.)"
)
def state_marker(session_id: str) -> Path:
base = Path(os.environ.get("ECHO_STATE_DIR") or (Path.home() / ".echo-memory"))
# Mirrors chorus_queue.state_dir() (this hook runs standalone, so the
# ECHO->CHORUS env aliasing in chorus_config may not have run): env first
# (either name), else ~/.chorus-memory, honoring a pre-fork ~/.echo-memory
# when the new dir doesn't exist yet.
override = os.environ.get("CHORUS_STATE_DIR") or os.environ.get("ECHO_STATE_DIR")
if override:
base = Path(override)
else:
new = Path.home() / ".chorus-memory"
legacy = Path.home() / ".echo-memory"
base = legacy if (not new.exists() and legacy.exists()) else new
return base / "hook-state" / f"{session_id}.reflect-nudged"
@@ -62,8 +72,8 @@ def count_user_turns(raw: str) -> int:
def already_reflected(raw: str) -> bool:
"""Transcript evidence that reflection or session logging already happened."""
return ("/echo-reflect" in raw
or re.search(r'echo\.py"?\s+reflect\b', raw) is not None
return ("/chorus-reflect" in raw
or re.search(r'chorus\.py"?\s+reflect\b', raw) is not None
or "put _agent/sessions/" in raw
or "put _agent/heartbeat/last-session.md" in raw)
@@ -81,8 +91,8 @@ def main() -> int:
return 0
try:
import echo_config
if not echo_config.is_configured(echo_config.load()):
import chorus_config
if not chorus_config.is_configured(chorus_config.load()):
return 0 # nothing to reflect into — stay silent
except Exception: # noqa: BLE001
return 0
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""echo_index.py — the ECHO entity index.
"""chorus_index.py — the CHORUS entity index.
A small machine-readable registry at `_agent/index/entities.json` that turns
routing/resolve into an O(1) lookup (no fuzzy search per write) and supplies the
@@ -14,7 +14,7 @@ name->path map used for cross-linking and recall.
}
}
Imported by echo_ops.py, sweep.py, and vault_lint.py. Network goes through echo.py.
Imported by chorus_ops.py, sweep.py, and vault_lint.py. Network goes through chorus.py.
"""
from __future__ import annotations
@@ -26,8 +26,8 @@ from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
import echo_quality # noqa: E402 — distinctive-token guards for fuzzy candidate matching
import chorus # noqa: E402
import chorus_quality # noqa: E402 — distinctive-token guards for fuzzy candidate matching
INDEX_PATH = "_agent/index/entities.json"
@@ -92,7 +92,7 @@ def derive_aliases(title: str) -> list[str]:
"""Safe, lossless name variants of a title — case/punctuation normalizations that are
unambiguously the SAME name (hyphen<->space, lowercased). Deliberately NOT acronyms or
token subsets: those collide across entities and would make resolve() match too much.
Lets `echo-memory` and `echo memory` both resolve to a title written either way."""
Lets `chorus-memory` and `chorus memory` both resolve to a title written either way."""
t = str(title or "").strip()
if not t:
return []
@@ -109,7 +109,7 @@ def _sig_tokens(s) -> set[str]:
"""Distinctive tokens of a string: long enough and not a common word (reuses the
auto-link guards), so 'data'/'api'/'the' can't drive a fuzzy match on their own."""
return {t for t in _WORD.findall(str(s or "").lower())
if len(t) >= echo_quality.MIN_NAME_LEN and t not in echo_quality._COMMON}
if len(t) >= chorus_quality.MIN_NAME_LEN and t not in chorus_quality._COMMON}
def aliases_in_frontmatter(text: str) -> list[str]:
@@ -133,13 +133,13 @@ def aliases_in_frontmatter(text: str) -> list[str]:
def derive_path(kind: str, slug: str, date: str | None = None, domain: str = "business") -> str:
folder = KIND_FOLDER.get(kind)
if folder is None:
raise echo.EchoError(f"unknown kind '{kind}' (one of: {', '.join(sorted(KIND_FOLDER))})", 2)
raise chorus.ChorusError(f"unknown kind '{kind}' (one of: {', '.join(sorted(KIND_FOLDER))})", 2)
if kind == "area":
if domain not in AREA_DOMAINS:
raise echo.EchoError(f"area domain must be one of {AREA_DOMAINS}", 2)
raise chorus.ChorusError(f"area domain must be one of {AREA_DOMAINS}", 2)
return f"areas/{domain}/{slug}.md"
if kind in DATE_KINDS:
return f"{folder}/{date or echo.today()}-{slug}.md"
return f"{folder}/{date or chorus.today()}-{slug}.md"
return f"{folder}/{slug}.md"
@@ -168,14 +168,14 @@ def kind_for_path(path: str) -> str | None:
def empty_index() -> dict:
return {"schema": 1, "updated": echo.today(), "entities": {}}
return {"schema": 1, "updated": chorus.today(), "entities": {}}
def load() -> dict:
status, body = echo.request("GET", echo.vault_url(INDEX_PATH))
status, body = chorus.request("GET", chorus.vault_url(INDEX_PATH))
if status == 404:
return empty_index()
echo.check(status, body, "index load")
chorus.check(status, body, "index load")
try:
d = json.loads(body)
except json.JSONDecodeError:
@@ -185,11 +185,11 @@ def load() -> dict:
def save(index: dict) -> None:
index["updated"] = echo.today()
index["updated"] = chorus.today()
body = json.dumps(index, indent=2, ensure_ascii=False).encode("utf-8")
status, b = echo.request("PUT", echo.vault_url(INDEX_PATH), data=body,
status, b = chorus.request("PUT", chorus.vault_url(INDEX_PATH), data=body,
headers={"Content-Type": "application/json"})
echo.check(status, b, "index save")
chorus.check(status, b, "index save")
def _norm(s) -> str:
@@ -217,7 +217,7 @@ def resolve(index: dict, mention: str):
def fuzzy_candidates(index: dict, mention: str, limit: int = 5):
"""Advisory near-matches for when resolve() finds no exact hit. Ranks entities sharing
>=1 *distinctive* token (>= MIN_NAME_LEN, not a common word) with the mention, so a
shortened or expanded name 'echo memory' for the project 'echo' surfaces the existing
shortened or expanded name 'chorus memory' for the project 'chorus' surfaces the existing
note instead of vanishing. Returns [(slug, entry, score)] sorted best-first. NEVER used to
auto-merge (that's resolve's job); only to SURFACE 'did you mean' candidates."""
mtoks = _sig_tokens(mention)
@@ -246,7 +246,7 @@ def _entity_tokens(slug: str, e: dict) -> set[str]:
def token_df(index: dict) -> Counter:
"""Vault-wide token document frequency: token -> how many entities carry it in their
slug/title/aliases. A token shared by several entities ("echo" in an echo-centric
slug/title/aliases. A token shared by several entities ("chorus" in an chorus-centric
vault) is a weak duplicate signal on its own; df lets the gate discount it."""
df: Counter = Counter()
for slug, e in index.get("entities", {}).items():
@@ -266,7 +266,7 @@ def gate_candidates(index: dict, mention: str, kind: str | None = None,
duplicate. Cross-kind lookalikes still surface as warnings, never as a block.
* no single-common-token blocks score = overlap/min(|tokens|) means an entity
whose ONE distinctive token appears in the mention scores 1.0, so any title
containing a vault-common word ("echo") would be blocked by every such entity.
containing a vault-common word ("chorus") would be blocked by every such entity.
A lone shared token only blocks when it is unique to that entity (df == 1).
Returns [(slug, entry, score)] like fuzzy_candidates."""
@@ -300,7 +300,7 @@ def upsert(index: dict, slug: str, path: str, kind: str, title: str | None = Non
merged = set(e.get("aliases", [])) | set(aliases or []) | set(derive_aliases(e["title"]))
merged.discard(slug)
e["aliases"] = sorted(a for a in merged if a)
e["last_seen"] = echo.today()
e["last_seen"] = chorus.today()
ents[slug] = e
return e
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""echo_links.py — cross-link primitives.
"""chorus_links.py — cross-link primitives.
Parse and add `## Related` wikilinks, and create **bidirectional** links between
notes (read-modify-write, idempotent). Links use path-style targets without the
`.md` extension (e.g. `[[resources/people/bob-smith]]`), which are unambiguous
and resolve in Obsidian. Network goes through echo.py.
and resolve in Obsidian. Network goes through chorus.py.
"""
from __future__ import annotations
@@ -14,7 +14,7 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
import chorus # noqa: E402
WIKILINK = re.compile(r"\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]")
@@ -85,17 +85,17 @@ def add_related(text: str, target_path: str):
def get_text(path: str) -> str | None:
status, body = echo.request("GET", echo.vault_url(path))
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 404:
return None
echo.check(status, body, f"get {path}")
chorus.check(status, body, f"get {path}")
return body.decode(errors="replace")
def put_text(path: str, text: str) -> None:
status, body = echo.request("PUT", echo.vault_url(path), data=text.encode("utf-8"),
status, body = chorus.request("PUT", chorus.vault_url(path), data=text.encode("utf-8"),
headers={"Content-Type": "text/markdown"})
echo.check(status, body, f"put {path}")
chorus.check(status, body, f"put {path}")
def add_one(path: str, target_path: str) -> bool:
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""echo_ops.py — high-level ECHO operations layered on the client + index + links.
"""chorus_ops.py — high-level CHORUS operations layered on the client + index + links.
resolve map a mention to its canonical note path (or where to create it)
recall search, then expand one hop along links/source_notes for connected context
link create bidirectional `## Related` links between two notes
capture one-call write: route + canonical frontmatter + index + auto-link + agent-log
Network goes through echo.py; routing/aliases through echo_index; links through echo_links.
Network goes through chorus.py; routing/aliases through chorus_index; links through chorus_links.
"""
from __future__ import annotations
@@ -18,14 +18,14 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
import echo_index as idx_mod # noqa: E402
import echo_links as links # noqa: E402
import chorus # noqa: E402
import chorus_index as idx_mod # noqa: E402
import chorus_links as links # noqa: E402
# A fuzzy candidate scoring at/above this blocks `capture` from creating what is very
# likely a duplicate (require --force to create anyway, or --merge-into to update the
# existing entity). Below the gate, candidates are surfaced as a warning only.
DUP_GATE = float(os.environ.get("ECHO_DUP_GATE", "0.6"))
DUP_GATE = float(os.environ.get("CHORUS_DUP_GATE", "0.6"))
# Existing-entity capture appends a dated bullet under this per-kind heading.
LOG_HEADING = {
@@ -43,15 +43,15 @@ def resolve(mention: str) -> int:
if e:
print(json.dumps({"match": True, "slug": slug, **e}, ensure_ascii=False, indent=2))
return 0
# No exact match — surface fuzzy candidates so a shortened/expanded name (e.g. "echo
# memory" for the project "echo") reveals the existing note instead of looking absent.
# No exact match — surface fuzzy candidates so a shortened/expanded name (e.g. "chorus
# memory" for the project "chorus") reveals the existing note instead of looking absent.
cands = idx_mod.fuzzy_candidates(index, mention)
out = {"match": False, "mention": mention, "suggest_slug": idx_mod.slugify(mention)}
if cands:
out["candidates"] = [{"slug": s, "path": c.get("path"), "title": c.get("title"),
"kind": c.get("kind"), "score": sc} for s, c, sc in cands]
out["note"] = ("no exact match, but similar entities exist (see candidates) — check "
"these before creating a new note; reuse the right path or `echo.py link`.")
"these before creating a new note; reuse the right path or `chorus.py link`.")
else:
out["note"] = "no index entry — derive a path with the right --kind and create it"
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -62,8 +62,8 @@ def resolve(mention: str) -> int:
def link(a_path: str, b_path: str, as_json: bool = False) -> int:
a_changed, b_changed = links.link_bidirectional(a_path, b_path)
if as_json:
import echo_output
env = echo_output.envelope("link", {"a": a_path, "b": b_path,
import chorus_output
env = chorus_output.envelope("link", {"a": a_path, "b": b_path,
"a_changed": a_changed, "b_changed": b_changed})
print(json.dumps(env, ensure_ascii=False))
return 0
@@ -74,10 +74,10 @@ def link(a_path: str, b_path: str, as_json: bool = False) -> int:
# ----------------------------------------------------------------- recall -----
def recall(query, limit: int = 8, as_json: bool = False) -> int:
"""Hybrid lexical (BM25) + graph recall — implemented in echo_recall. Kept here as
the stable public entrypoint (echo.py and /echo-recall call echo_ops.recall)."""
import echo_recall # lazy: only pulls the BM25 modules when recall is actually run
return echo_recall.recall(query, limit=limit, as_json=as_json)
"""Hybrid lexical (BM25) + graph recall — implemented in chorus_recall. Kept here as
the stable public entrypoint (chorus.py and /chorus-recall call chorus_ops.recall)."""
import chorus_recall # lazy: only pulls the BM25 modules when recall is actually run
return chorus_recall.recall(query, limit=limit, as_json=as_json)
# ----------------------------------------------------------- agent log -------
@@ -85,30 +85,30 @@ def ensure_daily_log(line: str) -> None:
"""Resilient: ensure today's daily note + its `## Agent Log` heading exist, then
idempotently append the line. Best-effort never raises into the caller."""
try:
date = echo.today()
date = chorus.today()
path = f"journal/daily/{date}.md"
status, body = echo.request("GET", echo.vault_url(path))
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 404:
ts, tb = echo.request("GET", echo.vault_url("journal/templates/daily-note-template.md"))
ts, tb = chorus.request("GET", chorus.vault_url("journal/templates/daily-note-template.md"))
tmpl = tb.decode(errors="replace") if ts == 200 else f"# {date}\n\n## Agent Log\n\n## Related\n"
tmpl = tmpl.replace("{{date:YYYY-MM-DD}}", date).replace("{{DATE}}", date)
echo.request("PUT", echo.vault_url(path), data=tmpl.encode(),
chorus.request("PUT", chorus.vault_url(path), data=tmpl.encode(),
headers={"Content-Type": "text/markdown"})
text = tmpl
else:
text = body.decode(errors="replace")
if not re.search(r"(?m)^## Agent Log\s*$", text):
echo.request("POST", echo.vault_url(path), data=b"\n\n## Agent Log\n",
chorus.request("POST", chorus.vault_url(path), data=b"\n\n## Agent Log\n",
headers={"Content-Type": "text/markdown"})
status, body = echo.request("GET", echo.vault_url(path))
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 200 and line in body.decode(errors="replace"):
return
echo.request("PATCH", echo.vault_url(path),
data=echo.normalize_patch_body((line + "\n").encode(), "append", "heading"),
chorus.request("PATCH", chorus.vault_url(path),
data=chorus.normalize_patch_body((line + "\n").encode(), "append", "heading"),
headers={"Operation": "append", "Target-Type": "heading",
"Target": f"{date}::Agent Log", "Content-Type": "text/markdown"})
except Exception as exc:
print(f"echo_ops: agent-log skipped ({exc})", file=sys.stderr)
print(f"chorus_ops: agent-log skipped ({exc})", file=sys.stderr)
# ----------------------------------------------------------------- capture ---
@@ -152,17 +152,17 @@ def _append_to_existing(path: str, kind: str, today_s: str, body_text: str) -> N
h1 = links.first_h1(text) or path.rsplit("/", 1)[-1][:-3]
heading = LOG_HEADING.get(kind, "Notes")
if not re.search(rf"(?m)^##\s+{re.escape(heading)}\s*$", text):
echo.request("POST", echo.vault_url(path), data=f"\n\n## {heading}\n".encode(),
chorus.request("POST", chorus.vault_url(path), data=f"\n\n## {heading}\n".encode(),
headers={"Content-Type": "text/markdown"})
bullet, block = _dated_block(today_s, body_text)
status, body = echo.request("GET", echo.vault_url(path))
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 200 and bullet in body.decode(errors="replace"):
return
echo.request("PATCH", echo.vault_url(path),
data=echo.normalize_patch_body((block + "\n").encode(), "append", "heading"),
chorus.request("PATCH", chorus.vault_url(path),
data=chorus.normalize_patch_body((block + "\n").encode(), "append", "heading"),
headers={"Operation": "append", "Target-Type": "heading",
"Target": f"{h1}::{heading}", "Content-Type": "text/markdown"})
echo.cmd_fm(path, "updated", json.dumps(today_s))
chorus.cmd_fm(path, "updated", json.dumps(today_s))
def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "",
@@ -172,8 +172,8 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
merge_into: str | None = None) -> int:
import contextlib
import io
import echo_output
import echo_quality
import chorus_output
import chorus_quality
real_stdout = sys.stdout
@@ -188,15 +188,15 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
data = {"kind": kind, "path": path, "title": title, "links_added": links}
if near:
data["near_duplicates"] = near
env = echo_output.envelope(act, data, ok=ok)
env = chorus_output.envelope(act, data, ok=ok)
print(json.dumps(env, ensure_ascii=False), file=real_stdout)
elif dry:
print(f"would {action} {kind or '-'} -> {path}")
# non-dry human output is the helper "ok:" lines + the summary printed by the caller.
return 0 if ok else 1
body_text = echo.read_body(file_arg).decode("utf-8", errors="replace")
today_s = echo.today()
body_text = chorus.read_body(file_arg).decode("utf-8", errors="replace")
today_s = chorus.today()
aliases = [a.strip() for a in (aliases or []) if a.strip()]
sources = [s.strip() for s in (sources or []) if s.strip()]
tags = [t.strip() for t in (tags or []) if t.strip()]
@@ -209,7 +209,7 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
if body_text.strip():
line += f"{body_text.strip().splitlines()[0]}"
with quiet():
rc = echo.cmd_append("inbox/captures/inbox.md", line)
rc = chorus.cmd_append("inbox/captures/inbox.md", line)
return done("inbox", "inbox/captures/inbox.md", ok=rc == 0)
slug = idx_mod.slugify(title)
@@ -220,10 +220,10 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
if merge_into and not existing:
match_slug, existing = idx_mod.resolve(index, merge_into)
if not existing:
print(f"echo_ops: --merge-into '{merge_into}' matches no entity in the index "
print(f"chorus_ops: --merge-into '{merge_into}' matches no entity in the index "
f"(try `resolve` first)", file=sys.stderr)
return 2
existing_reachable = bool(existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200)
existing_reachable = bool(existing and chorus.request("GET", chorus.vault_url(existing["path"]))[0] == 200)
# Pre-write duplicate gate: a strong fuzzy candidate means this title is very likely
# an existing entity under a different name. STOP before creating the duplicate —
@@ -238,7 +238,7 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
threshold=DUP_GATE)]
if gate_hits:
if as_json:
env = echo_output.envelope("duplicate-gate", {
env = chorus_output.envelope("duplicate-gate", {
"kind": kind, "title": title, "candidates": gate_hits,
"note": "likely duplicate — re-run with --merge-into <slug> to update the "
"existing entity, or --force to create anyway"}, ok=False)
@@ -256,7 +256,7 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
if dry_run:
if existing_reachable:
return done("update", existing["path"], dry=True)
s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
s2 = chorus_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
near = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3]
plan = done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
dry=True, near=near)
@@ -280,7 +280,7 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
kind = existing.get("kind", kind)
index_title = existing.get("title") or title
known = {idx_mod._norm(a) for a in existing.get("aliases", [])} | {match_slug}
if idx_mod.slugify(title) not in known and len(title.strip()) >= echo_quality.MIN_NAME_LEN:
if idx_mod.slugify(title) not in known and len(title.strip()) >= chorus_quality.MIN_NAME_LEN:
index_aliases.append(title)
_append_to_existing(path, kind, today_s, body_text)
action = "updated"
@@ -297,20 +297,20 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
near_dupes = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3]
# M2: don't let a 40-char-truncated slug silently collide with a different
# entity already in the index — disambiguate to slug-2, slug-3, ...
slug = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
slug = chorus_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
path = idx_mod.derive_path(kind, slug, date=date, domain=domain)
note_aliases = sorted((set(index_aliases) | set(idx_mod.derive_aliases(title))) - {slug})
note_tags = sorted({kind, *tags}) # baseline `- <kind>` tag; --tags enriches
note = _build_note(idx_mod.KIND_TYPE.get(kind, kind), status_v, today_s, title,
body_text, sources, note_aliases, tags=note_tags)
echo.cmd_put(path, echo.temp_file(note.encode()))
chorus.cmd_put(path, chorus.temp_file(note.encode()))
action = "created"
# H3: the entity-index write is the race the review flagged — a bare load->save lets
# two concurrent captures clobber each other's entry. Route it through a lock-guarded,
# fresh-re-read transaction. Returns the fresh index (used by auto-link below).
import echo_concurrency
index = echo_concurrency.atomic_index_update(
import chorus_concurrency
index = chorus_concurrency.atomic_index_update(
lambda idx: idx_mod.upsert(idx, slug, path, kind, index_title, index_aliases))
# auto-link: any other known entity CONFIDENTLY named in the body (M2: is_confident_link
@@ -320,17 +320,17 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
if e2.get("path") in (None, path):
continue
names = [n for n in [e2.get("title", ""), s2, *e2.get("aliases", [])] if n]
if any(echo_quality.is_confident_link(n, body_text) for n in names):
if any(chorus_quality.is_confident_link(n, body_text) for n in names):
ca, cb = links.link_bidirectional(path, e2["path"])
linked += int(ca or cb)
# Keep the BM25 recall index current for this note (best-effort; lock-guarded inside
# update_note, so it can't clobber a concurrent writer either).
try:
import echo_recall
echo_recall.update_note(path, links.get_text(path) or "")
import chorus_recall
chorus_recall.update_note(path, links.get_text(path) or "")
except Exception as exc: # never let recall-index upkeep fail a capture
print(f"echo_ops: recall-index update skipped ({exc})", file=sys.stderr)
print(f"chorus_ops: recall-index update skipped ({exc})", file=sys.stderr)
if not no_log:
ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]")
@@ -342,6 +342,6 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
if near_dupes:
kind_word = "entity" if len(near_dupes) == 1 else "entities"
print(f"WARNING: similar existing {kind_word} ({', '.join(near_dupes)}) — if this is "
f"the same thing, merge or `echo.py link` instead of keeping a duplicate.",
f"the same thing, merge or `chorus.py link` instead of keeping a duplicate.",
file=real_stdout)
return 0
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""echo_output.py — M4: machine-readable output + dry-run plumbing. [v1.0 SCAFFOLD — implemented]
"""chorus_output.py — M4: machine-readable output + dry-run plumbing. [v1.0 SCAFFOLD — implemented]
The high-level ops (capture/recall/resolve/scope) print prose, forcing the agent to
re-parse free text to act on a result. This provides a single JSON envelope and a
small dry-run helper so every op can emit a structured result and preview writes.
INTEGRATION (merge step): thread a global `--json` / `--dry-run` flag through echo.py's
INTEGRATION (merge step): thread a global `--json` / `--dry-run` flag through chorus.py's
parser and have each cmd_* return its data dict; emit() it. In --dry-run, the write
verbs print the envelope with action="dry-run" and perform no network mutation.
"""
@@ -1,17 +1,17 @@
#!/usr/bin/env python3
"""echo_quality.py — M2: keep the graph (which recall depends on) clean.
[v1.0 SCAFFOLD helpers implemented; integration into echo_links/echo_index is the merge step]
"""chorus_quality.py — M2: keep the graph (which recall depends on) clean.
[v1.0 SCAFFOLD helpers implemented; integration into chorus_links/chorus_index is the merge step]
Two graph-polluting bugs today:
1. Auto-link fires on any indexed entity whose name/alias is >=3 chars and word-matches
the body (echo_ops.py:236). A concept titled "API" or a 2-3 char alias links to every
the body (chorus_ops.py:236). A concept titled "API" or a 2-3 char alias links to every
note that happens to mention the word -> noisy, wrong edges.
2. slugify truncates to 40 chars (echo_index.py:58) with NO collision check, so two
2. slugify truncates to 40 chars (chorus_index.py:58) with NO collision check, so two
distinct long titles can resolve to the same slug -> same path -> silent merge.
This module supplies the two guards. Integration:
* echo_ops auto-link loop -> gate each candidate on is_confident_link(name, body)
* echo_index.derive_path -> route the slug through safe_slug(existing_slugs, slug)
* chorus_ops auto-link loop -> gate each candidate on is_confident_link(name, body)
* chorus_index.derive_path -> route the slug through safe_slug(existing_slugs, slug)
"""
from __future__ import annotations
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""echo_queue.py — H2: offline write-ahead queue + read cache. [v1.0 — wired]
"""chorus_queue.py — H2: offline write-ahead queue + read cache. [v1.0 — wired]
Today vault-unreachable => "proceed without memory" (SKILL.md), so a 502 (Obsidian not
running the most common real failure) loses everything said that session. This module
@@ -10,14 +10,14 @@ DESIGN:
* Outbox : append-only NDJSON of intended writes. Replayed in order on the next
reachable session. Replay is idempotent by construction PUT/DELETE/PATCH-
replace overwrite, and POST / PATCH-append are content-deduped (skip if the
line/block is already present), the same strategy echo.py append uses. Each
line/block is already present), the same strategy chorus.py append uses. Each
record carries an idem_key so the SAME write is never queued twice.
* Cache : last-known-good GET bodies, so `load` has a fallback. Written by cmd_load.
* Location: a LOCAL state dir it CANNOT live in the vault, because the vault is the
thing that's down. Default ~/.echo-memory/, override ECHO_STATE_DIR.
thing that's down. Default ~/.chorus-memory/, override CHORUS_STATE_DIR.
Integration seam: `safe_request()` the write verbs (cmd_put/post/append/patch/delete)
call it instead of echo.request; on an outage it enqueues and returns a synthesized
call it instead of chorus.request; on an outage it enqueues and returns a synthesized
(202, b"queued ...") so the caller reports "queued, will sync" instead of failing.
"""
from __future__ import annotations
@@ -31,13 +31,22 @@ import urllib.parse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
import chorus # noqa: E402
MUTATING = {"PUT", "POST", "PATCH", "DELETE"}
def state_dir() -> Path:
return Path(os.environ.get("ECHO_STATE_DIR") or (Path.home() / ".echo-memory"))
# Env first (chorus_config aliases a legacy ECHO_STATE_DIR at import); the
# default is ~/.chorus-memory, but an existing pre-fork ~/.echo-memory is
# honored when the new dir doesn't exist yet — so queued offline writes
# from an ECHO install aren't stranded by the rename.
override = os.environ.get("CHORUS_STATE_DIR") or os.environ.get("ECHO_STATE_DIR")
if override:
return Path(override)
new = Path.home() / ".chorus-memory"
legacy = Path.home() / ".echo-memory"
return legacy if (not new.exists() and legacy.exists()) else new
def outbox_path() -> Path:
@@ -101,11 +110,11 @@ def _rewrite(records: list[dict]) -> None:
def _rebase(url: str) -> str:
"""Re-point a queued URL at the CURRENT echo.BASE. Writes are queued with the base
"""Re-point a queued URL at the CURRENT chorus.BASE. Writes are queued with the base
that was active when they failed; on replay the vault may be back at a different base
(e.g. an endpoint migration), so we always reconstruct the origin from echo.BASE."""
(e.g. an endpoint migration), so we always reconstruct the origin from chorus.BASE."""
parts = urllib.parse.urlsplit(url)
return echo.BASE + parts.path + (("?" + parts.query) if parts.query else "")
return chorus.BASE + parts.path + (("?" + parts.query) if parts.query else "")
def _replay(rec: dict) -> str:
@@ -119,17 +128,17 @@ def _replay(rec: dict) -> str:
# Append-style writes: skip if the content is already present (idempotency on replay).
is_append = method == "POST" or (method == "PATCH" and headers.get("Operation") == "append")
if is_append and data:
st, cur = echo.request("GET", url)
st, cur = chorus.request("GET", url)
if st == 0:
return "retry" # still offline
if st == 200 and data.decode("utf-8", "replace").strip("\n") in cur.decode("utf-8", "replace"):
return "ok" # already landed (or a duplicate we must not re-add)
st, body = echo.request(method, url, data=data, headers=headers)
st, body = chorus.request(method, url, data=data, headers=headers)
if st == 0 or 500 <= st < 600:
return "retry"
if 400 <= st < 500:
print(f"echo_queue: dropping a queued {method} {url} — permanent HTTP {st} on replay",
print(f"chorus_queue: dropping a queued {method} {url} — permanent HTTP {st} on replay",
file=sys.stderr)
return "drop"
return "ok"
@@ -173,17 +182,17 @@ def cache_get(path: str) -> bytes | None:
# ----------------------------------------------------------- integration seam
def safe_request(method: str, url: str, data=None, headers=None, idem_key: str | None = None):
"""Network-first wrapper around echo.request with offline queueing for writes.
"""Network-first wrapper around chorus.request with offline queueing for writes.
- success or a client error (4xx): return (status, body) as-is 4xx is a bug, fail loud.
- transport failure (status 0) or 5xx (after echo.request's own retry) on a MUTATING
- transport failure (status 0) or 5xx (after chorus.request's own retry) on a MUTATING
verb: enqueue the write and return (202, b"queued (offline)") so the caller reports it
as durably queued rather than failed.
- otherwise (e.g. a failing GET): return (status, body); the read-cache fallback lives
in cmd_load, which knows which reads are worth serving stale.
"""
method = method.upper()
status, body = echo.request(method, url, data=data, headers=headers)
status, body = chorus.request(method, url, data=data, headers=headers)
offline = status == 0 or 500 <= status < 600
if offline and method in MUTATING:
enqueue(method, url, data, headers or {}, idem_key)
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""echo_recall.py — H1: hybrid lexical (BM25) + graph recall. [v1.0 — wired]
"""chorus_recall.py — H1: hybrid lexical (BM25) + graph recall. [v1.0 — wired]
Replaces the keyword-only recall (search/simple + fixed one-hop) with a ranked, local
retriever that finds a note even when the query is phrased differently than the note
@@ -49,9 +49,9 @@ from collections import Counter, deque
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
import echo_index as idx_mod # noqa: E402
import echo_links as links # noqa: E402
import chorus # noqa: E402
import chorus_index as idx_mod # noqa: E402
import chorus_links as links # noqa: E402
RECALL_INDEX_PATH = "_agent/index/recall-index.json"
INDEX_SCHEMA = 2 # bumped: schema 2 adds per-doc meta (weight/updated/status)
@@ -73,7 +73,7 @@ _TIME_SERIES = [
]
KIND_WEIGHT = {"session": 0.5, "daily": 0.4, "rollup": 0.4}
STATUS_FACTOR = {"active": 1.1, "on-hold": 0.9, "archived": 0.75}
FRESH_HALF_LIFE = float(os.environ.get("ECHO_FRESH_HALF_LIFE", "90")) # days
FRESH_HALF_LIFE = float(os.environ.get("CHORUS_FRESH_HALF_LIFE", "90")) # days
FRESH_FLOOR = 0.6 # freshness multiplier never drops below this — demote, don't bury
_FM_UPDATED = re.compile(r"(?m)^updated:\s*[\"']?(\d{4}-\d{2}-\d{2})")
@@ -194,7 +194,7 @@ class Bm25Index:
def score(self, query: str, limit: int = 10,
today_s: str | None = None) -> list[tuple[str, float]]:
today_s = today_s or echo.today()
today_s = today_s or chorus.today()
scores: Counter[str] = Counter()
for term in set(tokenize(query)):
posting = self.postings.get(term)
@@ -230,10 +230,10 @@ class Bm25Index:
# ------------------------------------------------------------------- vault I/O
def _get(path: str) -> str | None:
status, body = echo.request("GET", echo.vault_url(path))
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 404:
return None
echo.check(status, body, f"recall get {path}")
chorus.check(status, body, f"recall get {path}")
return body.decode(errors="replace")
@@ -272,10 +272,10 @@ def _indexable(path: str) -> bool:
# ------------------------------------------------------------------- persistence
def load_index() -> Bm25Index:
status, body = echo.request("GET", echo.vault_url(RECALL_INDEX_PATH))
status, body = chorus.request("GET", chorus.vault_url(RECALL_INDEX_PATH))
if status == 404:
return Bm25Index()
echo.check(status, body, "recall-index load")
chorus.check(status, body, "recall-index load")
try:
return Bm25Index.from_json(json.loads(body))
except (json.JSONDecodeError, KeyError, TypeError):
@@ -284,16 +284,16 @@ def load_index() -> Bm25Index:
def save_index(ix: Bm25Index) -> None:
body = json.dumps(ix.to_json(), ensure_ascii=False).encode("utf-8")
status, b = echo.request("PUT", echo.vault_url(RECALL_INDEX_PATH), data=body,
status, b = chorus.request("PUT", chorus.vault_url(RECALL_INDEX_PATH), data=body,
headers={"Content-Type": "application/json"})
echo.check(status, b, "recall-index save")
chorus.check(status, b, "recall-index save")
def rebuild(prefetched: dict | None = None) -> Bm25Index:
"""Full rebuild from every indexable entity note. Called by sweep.py and as the
cold-cache path inside recall(). Saves and returns the index.
`prefetched` (a {path: text} map, e.g. from echo.read_many) lets sweep.py reuse the
`prefetched` (a {path: text} map, e.g. from chorus.read_many) lets sweep.py reuse the
content it already pulled instead of walking and re-fetching the whole vault again."""
ix = Bm25Index()
if prefetched is not None:
@@ -315,13 +315,13 @@ def update_note(path: str, text: str) -> None:
try:
if not _indexable(path):
return # only entity notes are part of the recall corpus
import echo_concurrency
with echo_concurrency.vault_lock():
import chorus_concurrency
with chorus_concurrency.vault_lock():
ix = load_index() # fresh read inside the lock
ix.add(path, text) # raw text: add() strips + extracts meta
save_index(ix)
except Exception as exc: # noqa: BLE001 — upkeep must never fail a capture
print(f"echo_recall: index update skipped ({exc})", file=sys.stderr)
print(f"chorus_recall: index update skipped ({exc})", file=sys.stderr)
# ------------------------------------------------------------------- graph fusion
@@ -342,14 +342,14 @@ def expand_graph(seeds, nmap: dict, base_scores: dict, max_hops: int = MAX_HOPS)
seen = set(seeds)
results: dict[str, tuple[float, str]] = {}
# Breadth-first by HOP so each frontier's note bodies can be fetched concurrently
# (echo.read_many) instead of one blocking GET per node. Scoring is unchanged: a
# (chorus.read_many) instead of one blocking GET per node. Scoring is unchanged: a
# neighbour keeps the max decayed score over the paths that reach it, and non-seed
# parents score 1.0 exactly as the serial deque version did.
frontier = list(seeds)
for hop in range(max_hops):
if not frontier:
break
texts = echo.read_many(frontier)
texts = chorus.read_many(frontier)
next_frontier: list[str] = []
for path in frontier:
text = texts.get(path)
@@ -423,7 +423,7 @@ def _brief(path: str, score: float | None = None, via: str | None = None) -> Non
# ------------------------------------------------------------------- entrypoint
def recall(query, limit: int = 8, as_json: bool = False) -> int:
q = " ".join(query) if isinstance(query, list) else query
today_s = echo.today()
today_s = chorus.today()
index = idx_mod.load()
nmap = idx_mod.name_map(index)
@@ -434,7 +434,7 @@ def recall(query, limit: int = 8, as_json: bool = False) -> int:
if ix.n_docs == 0:
ix = rebuild()
lexical = ix.score(q, limit=limit, today_s=today_s)
except echo.EchoError as exc:
except chorus.ChorusError as exc:
print(f"recall: BM25 index unavailable ({exc}); falling back to server search",
file=sys.stderr)
@@ -450,8 +450,8 @@ def recall(query, limit: int = 8, as_json: bool = False) -> int:
# --- resilience fallback: server search when lexical found nothing -------
if not hits:
try:
status, body = echo.request(
"POST", f"{echo.BASE}/search/simple/?query={urllib.parse.quote_plus(q)}")
status, body = chorus.request(
"POST", f"{chorus.BASE}/search/simple/?query={urllib.parse.quote_plus(q)}")
if status == 200:
for r in (json.loads(body) or [])[:limit]:
fn = r.get("filename") or r.get("path")
@@ -465,8 +465,8 @@ def recall(query, limit: int = 8, as_json: bool = False) -> int:
neighbours = expand_graph(hits, nmap, base, max_hops=MAX_HOPS)
if as_json:
import echo_output
texts = echo.read_many(hits + [p for p, _ in neighbours[: 2 * limit]])
import chorus_output
texts = chorus.read_many(hits + [p for p, _ in neighbours[: 2 * limit]])
primary = []
for p in hits:
if texts.get(p) is None:
@@ -479,7 +479,7 @@ def recall(query, limit: int = 8, as_json: bool = False) -> int:
continue
linked.append({**_doc_summary(p, texts[p]),
"score": round(sc, 3), "via": via})
env = echo_output.envelope("recall", {"query": q, "primary": primary,
env = chorus_output.envelope("recall", {"query": q, "primary": primary,
"linked": linked})
print(json.dumps(env, ensure_ascii=False))
return 0
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""echo_reflect.py — H5: automatic session-reflection capture. [v1.0 — wired]
"""chorus_reflect.py — H5: automatic session-reflection capture. [v1.0 — wired]
Today memory only accrues when the agent decides to write. This makes accrual the
default: at session end the model extracts durable items from the conversation and
@@ -28,7 +28,7 @@ PROPOSAL_SCHEMA (one per durable item the agent wants to remember):
"confidence": 0.0-1.0 # agent's own confidence; gates auto-suggest
}
CLI (echo.py reflect): dry-run previews; --apply writes matching bootstrap/migrate/sweep.
CLI (chorus.py reflect): dry-run previews; --apply writes matching bootstrap/migrate/sweep.
"""
from __future__ import annotations
@@ -36,8 +36,8 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
import echo_index as idx_mod # noqa: E402
import chorus # noqa: E402
import chorus_index as idx_mod # noqa: E402
MIN_CONFIDENCE = 0.6
@@ -84,7 +84,7 @@ def classify(proposals: list[dict]) -> list[dict]:
p["kind"], idx_mod.slugify(p["title"]),
date=p.get("date"), domain=p.get("domain", "business"))
p["_action"] = "create"
except echo.EchoError as exc:
except chorus.ChorusError as exc:
p["_action"], p["_path"] = "error", str(exc)
return proposals
@@ -100,7 +100,7 @@ def preview(proposals: list[dict]) -> str:
def apply(proposals: list[dict], confirm: bool = False) -> int:
"""Validate -> classify -> preview; with confirm=True, apply each via echo_ops.capture
"""Validate -> classify -> preview; with confirm=True, apply each via chorus_ops.capture
(which routes/indexes/links/logs, each self-lock-guarded). Without confirm it is a
dry-run that writes nothing the preview IS the confirmation step."""
valid, errors = validate(proposals)
@@ -121,13 +121,13 @@ def apply(proposals: list[dict], confirm: bool = False) -> int:
print("\nreflect: dry-run — re-run with --apply to write these to memory.")
return 0
import echo_ops # lazy: the apply path pulls in the capture/index/link stack
import chorus_ops # lazy: the apply path pulls in the capture/index/link stack
applied = gated = 0
for p in valid:
if p.get("_action") == "error":
continue
bodyfile = echo.temp_file((p.get("body") or "").encode()) if p.get("body") else None
rc = echo_ops.capture(
bodyfile = chorus.temp_file((p.get("body") or "").encode()) if p.get("body") else None
rc = chorus_ops.capture(
p.get("kind"), p["title"], bodyfile,
aliases=p.get("aliases") or [], sources=p.get("sources") or [],
tags=p.get("tags") or [],
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""echo_triage.py — one-tap inbox triage, built on the reflect pipeline.
"""chorus_triage.py — one-tap inbox triage, built on the reflect pipeline.
Triage used to be pure prose: the agent hand-routed each inbox line with individual
PATCH/PUT calls and hand-wrote the processing log which is why the inbox rots.
But the machinery it needs already exists in echo_reflect (validate -> classify
But the machinery it needs already exists in chorus_reflect (validate -> classify
against the entity index -> preview -> apply via capture). This module reuses it
and adds the two triage-specific pieces:
@@ -26,7 +26,7 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
import chorus # noqa: E402
INBOX_PATH = "inbox/captures/inbox.md"
LOG_DIR = "inbox/processing-log"
@@ -35,10 +35,10 @@ _CAPTURE_LINE = re.compile(r"^\s*-\s*(\d{4}-\d{2}-\d{2})\s*:?\s*(.*\S)\s*$")
def parse_inbox(text: str) -> list[dict]:
"""Dated capture bullets -> [{line, date, text, age_days}] (age from ECHO_TODAY)."""
"""Dated capture bullets -> [{line, date, text, age_days}] (age from CHORUS_TODAY)."""
import datetime as dt
try:
today = dt.date.fromisoformat(echo.today())
today = dt.date.fromisoformat(chorus.today())
except ValueError:
today = dt.date.today()
items = []
@@ -56,15 +56,15 @@ def parse_inbox(text: str) -> list[dict]:
def list_inbox(as_json: bool = False) -> int:
status, body = echo.request("GET", echo.vault_url(INBOX_PATH))
status, body = chorus.request("GET", chorus.vault_url(INBOX_PATH))
if status == 404:
items = []
else:
echo.check(status, body, f"triage list {INBOX_PATH}")
chorus.check(status, body, f"triage list {INBOX_PATH}")
items = parse_inbox(body.decode(errors="replace"))
if as_json:
import echo_output
env = echo_output.envelope("triage-list", {"path": INBOX_PATH, "items": items,
import chorus_output
env = chorus_output.envelope("triage-list", {"path": INBOX_PATH, "items": items,
"count": len(items)})
print(json.dumps(env, ensure_ascii=False))
return 0
@@ -76,43 +76,43 @@ def list_inbox(as_json: bool = False) -> int:
age = f"{it['age_days']}d" if it["age_days"] is not None else "?"
print(f" [{age:>4}] {it['date']}: {it['text']}")
print("\nBuild a proposals JSON (reflect schema + optional \"line\") and run "
"`echo.py triage <file>` to preview, `--apply` to route.")
"`chorus.py triage <file>` to preview, `--apply` to route.")
return 0
def apply(proposals: list[dict], confirm: bool = False) -> int:
"""Validate -> classify -> preview; with confirm, route each via capture AND write
the processing-log audit line. Mirrors echo_reflect.apply's contract exactly."""
import echo_reflect
valid, errors = echo_reflect.validate(proposals)
the processing-log audit line. Mirrors chorus_reflect.apply's contract exactly."""
import chorus_reflect
valid, errors = chorus_reflect.validate(proposals)
for e in errors:
print(f"skip: {e}", file=sys.stderr)
if not valid:
print("triage: no valid proposals to route.")
return 0
echo_reflect.classify(valid)
chorus_reflect.classify(valid)
counts = {a: sum(1 for p in valid if p.get("_action") == a)
for a in ("create", "update", "inbox", "error")}
print(f"triage: {len(valid)} proposal(s) — "
f"{counts['create']} new, {counts['update']} update, {counts['inbox']} stay-in-inbox"
+ (f", {counts['error']} error" if counts["error"] else ""))
print(echo_reflect.preview(valid))
print(chorus_reflect.preview(valid))
if not confirm:
print("\ntriage: dry-run — re-run with --apply to route these and log the moves.")
return 0
import echo_ops
today_s = echo.today()
import chorus_ops
today_s = chorus.today()
applied = gated = 0
for p in valid:
if p.get("_action") == "error":
continue
if p.get("_action") == "inbox":
continue # routing an inbox line back to the inbox is a no-op, not a move
bodyfile = echo.temp_file((p.get("body") or "").encode()) if p.get("body") else None
rc = echo_ops.capture(
bodyfile = chorus.temp_file((p.get("body") or "").encode()) if p.get("body") else None
rc = chorus_ops.capture(
p.get("kind"), p["title"], bodyfile,
aliases=p.get("aliases") or [], sources=p.get("sources") or [],
tags=p.get("tags") or [],
@@ -124,7 +124,7 @@ def apply(proposals: list[dict], confirm: bool = False) -> int:
continue
applied += 1
original = (p.get("line") or p["title"]).strip()
echo.cmd_append(f"{LOG_DIR}/{today_s}.md",
chorus.cmd_append(f"{LOG_DIR}/{today_s}.md",
f"- {original}{p.get('_path', '?')}")
print(f"triage: routed {applied}/{len(valid)} item(s); audit in {LOG_DIR}/{today_s}.md. "
"Originals kept in the inbox (deletion is explicit-only)."
@@ -1,16 +1,16 @@
#!/usr/bin/env python3
"""migrate.py — bring an existing ECHO vault up to the plugin's current schema.
"""migrate.py — bring an existing CHORUS vault up to the plugin's current schema.
Reads the marker's schema_version and applies each intervening migration in order.
Migrations are idempotent and additive; every destructive step (DELETE/move) is
gated behind --apply AND printed first. Default mode is a DRY-RUN plan.
Cross-platform: pure Python via echo.py.
Cross-platform: pure Python via chorus.py.
Usage:
migrate.py # print the migration plan (no changes)
migrate.py --apply # perform the migration (moves/deletes included)
Env: ECHO_BASE, ECHO_KEY (via echo.py).
Env: CHORUS_BASE, CHORUS_KEY (via chorus.py).
"""
from __future__ import annotations
@@ -23,26 +23,26 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import echo # noqa: E402
import chorus # noqa: E402
CURRENT_SCHEMA = 4
def get_text(path: str) -> str | None:
status, body = echo.request("GET", echo.vault_url(path))
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 404:
return None
echo.check(status, body, f"get {path}")
chorus.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))
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 404:
return []
echo.check(status, body, f"ls {path}")
chorus.check(status, body, f"ls {path}")
try:
payload = json.loads(body)
except json.JSONDecodeError:
@@ -51,15 +51,15 @@ def list_files(path: str) -> list[str]:
def put_text(path: str, text: str) -> None:
status, body = echo.request("PUT", echo.vault_url(path), data=text.encode(),
status, body = chorus.request("PUT", chorus.vault_url(path), data=text.encode(),
headers={"Content-Type": "text/markdown"})
echo.check(status, body, f"put {path}")
chorus.check(status, body, f"put {path}")
def delete(path: str) -> None:
status, body = echo.request("DELETE", echo.vault_url(path))
status, body = chorus.request("DELETE", chorus.vault_url(path))
if status != 404:
echo.check(status, body, f"delete {path}")
chorus.check(status, body, f"delete {path}")
def move(src: str, dst: str) -> None:
@@ -79,8 +79,17 @@ def do_or_show(apply: bool, desc: str, func=None) -> None:
print(f"migrate: PLAN {desc}")
def find_marker() -> str:
"""The vault's marker path: the CHORUS name, else a legacy pre-fork ECHO
marker (the rename itself is a future migration step)."""
for p in ("_agent/chorus-vault.md", "_agent/echo-vault.md"):
if get_text(p) is not None:
return p
return "_agent/chorus-vault.md"
def marker_schema() -> int:
marker = get_text("_agent/echo-vault.md")
marker = get_text(find_marker())
if marker is None:
print("migrate: marker missing — vault not bootstrapped. Run bootstrap.py, not migrate.py.")
raise SystemExit(3)
@@ -94,7 +103,7 @@ def marker_schema() -> int:
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Migrate ECHO vault schema")
parser = argparse.ArgumentParser(description="Migrate CHORUS vault schema")
parser.add_argument("--apply", action="store_true")
args = parser.parse_args(argv)
@@ -138,7 +147,7 @@ def main(argv: list[str] | None = None) -> int:
if get_text("_agent/index/README.md") is None:
do_or_show(args.apply, "create _agent/index/README.md",
lambda: put_text("_agent/index/README.md",
"# index\n\nMachine-maintained entity index. See the echo-memory plugin.\n"))
"# index\n\nMachine-maintained entity index. See the chorus-memory plugin.\n"))
print("migrate: [2->3] then run `sweep.py --apply` to build entities.json and symmetrize links.")
if start < 4:
@@ -146,8 +155,9 @@ def main(argv: list[str] | None = None) -> int:
print("migrate: [3->4] _agent/index/ already exists (schema 3); no moves needed.")
print("migrate: [3->4] then run `sweep.py --apply` to build _agent/index/recall-index.json.")
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)))
marker_path = find_marker()
do_or_show(args.apply, f"set {marker_path} schema_version -> {CURRENT_SCHEMA}",
lambda: chorus.cmd_fm(marker_path, "schema_version", str(CURRENT_SCHEMA)))
if args.apply:
print(f"migrate: migration complete -> schema {CURRENT_SCHEMA}. Run sweep.py --apply, then vault_lint.py.")
else:
@@ -1,5 +1,5 @@
{
"$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 vault path matches no route here, and is not retired, nothing should be written to it); check_routing.py consumes it to verify the human routing docs stay in sync with this manifest. Patterns are Python regexes matched against vault-root-relative paths (no leading slash, no /vault/ prefix).",
"$comment": "CANONICAL machine-readable routing manifest for the CHORUS 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 vault path matches no route here, and is not retired, nothing should be written to it); check_routing.py consumes it to verify the human routing docs stay in sync with this manifest. 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" },
@@ -30,7 +30,8 @@
{ "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-marker", "pattern": "^_agent/chorus-vault\\.md$", "method": "PUT", "trigger": "Bootstrap / schema migration only", "distinct_because": "Plugin-owned probe; never hand-edited" },
{ "id": "agent-marker-legacy", "pattern": "^_agent/echo-vault\\.md$", "method": "GET", "trigger": "Pre-fork ECHO vault adoption (read-only probe)", "distinct_because": "Legacy ECHO marker honored until the rename migration" },
{ "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" },
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""sweep.py — bring an existing ECHO vault up to the current feature spec.
"""sweep.py — bring an existing CHORUS vault up to the current feature spec.
After upgrading the plugin (entity index, cross-linking, recall), run this once to
backfill what older vaults don't have yet:
@@ -10,7 +10,7 @@ backfill what older vaults don't have yet:
meetings, areas, decisions, semantic/episodic memory, skills),
3. **backfill incomplete entity frontmatter** fill a missing/empty `status:` with
the kind default and seed a missing/empty `tags:` with the note's kind, per the
KIND_REQUIRED_FM/KIND_STATUS maps in echo_index (what `/echo-health` flags as
KIND_REQUIRED_FM/KIND_STATUS maps in chorus_index (what `/chorus-health` flags as
`incomplete-frontmatter`),
4. **symmetrize cross-links** for every `## Related` link A -> B, ensure the
reciprocal B -> A exists (it only adds the missing direction; it never invents
@@ -18,7 +18,7 @@ backfill what older vaults don't have yet:
5. stamp the marker `schema_version` to the current schema (4).
Dry-run by default; pass --apply to write. READ-ONLY without --apply.
Cross-platform: pure Python via echo.py.
Cross-platform: pure Python via chorus.py.
Usage: sweep.py [--apply]
"""
@@ -34,10 +34,10 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import echo # noqa: E402
import echo_index as idx_mod # noqa: E402
import echo_links as links # noqa: E402
import echo_recall # noqa: E402
import chorus # noqa: E402
import chorus_index as idx_mod # noqa: E402
import chorus_links as links # noqa: E402
import chorus_recall # noqa: E402
CURRENT_SCHEMA = 4
TEMPLATE_RE = re.compile(r"(^|/)(templates/|.*-template\.md$)")
@@ -72,10 +72,10 @@ def fm_populated(text: str, field: str) -> bool:
def get(path: str) -> str | None:
status, body = echo.request("GET", echo.vault_url(path))
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 404:
return None
echo.check(status, body, f"get {path}")
chorus.check(status, body, f"get {path}")
return body.decode(errors="replace")
@@ -103,13 +103,13 @@ def walk(prefix: str = ""):
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Bring an ECHO vault up to the current feature spec")
ap = argparse.ArgumentParser(description="Bring an CHORUS vault up to the current feature spec")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args(argv)
apply = args.apply
tag = "APPLY" if apply else "PLAN "
if get("_agent/echo-vault.md") is None:
if get("_agent/chorus-vault.md") is None and get("_agent/echo-vault.md") is None:
print("sweep: marker missing — vault not bootstrapped. Run bootstrap.py first.", file=sys.stderr)
return 3
@@ -119,8 +119,8 @@ def main(argv: list[str] | None = None) -> int:
if get("_agent/index/README.md") is None:
print(f"sweep: {tag} create _agent/index/README.md")
if apply:
echo.request("PUT", echo.vault_url("_agent/index/README.md"),
data=b"# index\n\nMachine-maintained entity index. See the echo-memory plugin.\n",
chorus.request("PUT", chorus.vault_url("_agent/index/README.md"),
data=b"# index\n\nMachine-maintained entity index. See the chorus-memory plugin.\n",
headers={"Content-Type": "text/markdown"})
all_files = [p for p in walk()
@@ -130,7 +130,7 @@ def main(argv: list[str] | None = None) -> int:
# Fetch every note's content ONCE, concurrently. All three passes below (index,
# recall, link symmetrize) read from this shared cache, so no file is fetched
# twice and link targets aren't re-fetched per reference.
texts = echo.read_many(all_files)
texts = chorus.read_many(all_files)
print(f"sweep: read {len(texts)} notes (concurrent, keep-alive)\n")
# ---- 2. (re)build entity index -------------------------------------------
@@ -163,15 +163,15 @@ def main(argv: list[str] | None = None) -> int:
# ---- 2b. (re)build the recall (BM25) index -------------------------------
if apply:
rix = echo_recall.rebuild(prefetched=texts)
rix = chorus_recall.rebuild(prefetched=texts)
print(f"sweep: {tag} recall index — {rix.n_docs} docs (BM25)")
else:
n_idx = sum(1 for p in all_files if echo_recall._indexable(p))
n_idx = sum(1 for p in all_files if chorus_recall._indexable(p))
print(f"sweep: {tag} recall index — would rebuild BM25 over {n_idx} corpus notes "
f"(entities + sessions/journal)")
# ---- 3. backfill incomplete entity frontmatter ----------------------------
# What /echo-health flags as incomplete-frontmatter, filled mechanically: a missing
# What /chorus-health flags as incomplete-frontmatter, filled mechanically: a missing
# or empty status gets the kind default; missing/empty tags get the kind as a
# baseline tag. cmd_fm is create-or-replace, so absent keys are inserted surgically.
fixes: list[tuple[str, str, str]] = []
@@ -194,7 +194,7 @@ def main(argv: list[str] | None = None) -> int:
print(f" ... and {len(fixes) - 40} more")
if apply:
for path, field, value in fixes:
echo.cmd_fm(path, field, value)
chorus.cmd_fm(path, field, value)
# ---- 4. symmetrize existing Related links --------------------------------
fileset = set(all_files)
@@ -232,12 +232,16 @@ def main(argv: list[str] | None = None) -> int:
links.add_one(tp, sp)
# ---- 5. stamp schema ------------------------------------------------------
marker = get("_agent/echo-vault.md") or ""
marker_path = "_agent/chorus-vault.md"
marker = get(marker_path)
if marker is None: # adopted pre-fork ECHO vault — stamp the marker it has
marker_path = "_agent/echo-vault.md"
marker = get(marker_path) or ""
cur = next((int(m) for m in re.findall(r"(?m)^schema_version:\s*(\d+)", marker)), 0)
if cur < CURRENT_SCHEMA:
print(f"sweep: {tag} schema_version {cur} -> {CURRENT_SCHEMA}")
if apply:
echo.cmd_fm("_agent/echo-vault.md", "schema_version", str(CURRENT_SCHEMA))
chorus.cmd_fm(marker_path, "schema_version", str(CURRENT_SCHEMA))
print(f"\nsweep: {'done — run vault_lint.py to confirm invariants' if apply else 'dry-run complete. Re-run with --apply to write.'}")
return 0
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Offline regression tests for the ECHO tooling. No network, no vault.
"""Offline regression tests for the CHORUS tooling. No network, no vault.
Run: python3 test_echo_client.py (or: pytest test_echo_client.py)
Run: python3 test_chorus_client.py (or: pytest test_chorus_client.py)
Covers echo.py body/scalar normalization, the routing-view consistency checker's
Covers chorus.py body/scalar normalization, the routing-view consistency checker's
helpers, and as the guard for improvement #4 — asserts the shipped docs are in
sync with routing.json.
"""
@@ -16,49 +16,49 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
import chorus # noqa: E402
import check_routing # noqa: E402
import echo_index # noqa: E402
import echo_links # noqa: E402
import chorus_index # noqa: E402
import chorus_links # noqa: E402
def test_heading_replace_body_is_newline_guarded() -> None:
assert echo.normalize_patch_body(b"- [x] done\n", "replace", "heading") == b"\n- [x] done\n"
assert chorus.normalize_patch_body(b"- [x] done\n", "replace", "heading") == b"\n- [x] done\n"
def test_heading_append_body_ends_with_newline() -> None:
assert echo.normalize_patch_body(b"- item", "append", "heading") == b"- item\n"
assert chorus.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"
assert json.loads(chorus.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"'
assert chorus.normalize_json_scalar('"2026-06-20"') == b'"2026-06-20"'
def test_read_many_dedups_and_maps_each_path() -> None:
# Concurrency helper contract, no network: monkeypatch the per-file fetch.
orig = echo.get_text
echo.get_text = lambda p: f"T:{p}"
orig = chorus.get_text
chorus.get_text = lambda p: f"T:{p}"
try:
assert echo.read_many(["a", "b", "a"]) == {"a": "T:a", "b": "T:b"}
assert chorus.read_many(["a", "b", "a"]) == {"a": "T:a", "b": "T:b"}
finally:
echo.get_text = orig
chorus.get_text = orig
def test_read_many_empty_returns_empty_dict() -> None:
assert echo.read_many([]) == {}
assert chorus.read_many([]) == {}
def test_read_many_tolerates_unreadable_file_as_none() -> None:
orig = echo.get_text
echo.get_text = lambda p: None if p == "bad" else f"T:{p}"
orig = chorus.get_text
chorus.get_text = lambda p: None if p == "bad" else f"T:{p}"
try:
assert echo.read_many(["ok", "bad"]) == {"ok": "T:ok", "bad": None}
assert chorus.read_many(["ok", "bad"]) == {"ok": "T:ok", "bad": None}
finally:
echo.get_text = orig
chorus.get_text = orig
def test_plugin_description_under_500_chars() -> None:
@@ -97,55 +97,55 @@ def test_docs_are_in_sync_with_routing_json() -> None:
def test_index_slugify_and_derive_path() -> None:
assert echo_index.slugify("Bob Smith!") == "bob-smith"
assert echo_index.derive_path("person", "bob-smith") == "resources/people/bob-smith.md"
assert echo_index.derive_path("project", "echo") == "projects/active/echo.md"
assert echo_index.derive_path("area", "ops", domain="business") == "areas/business/ops.md"
assert echo_index.derive_path("decision", "x", date="2026-01-02") == "decisions/by-date/2026-01-02-x.md"
assert chorus_index.slugify("Bob Smith!") == "bob-smith"
assert chorus_index.derive_path("person", "bob-smith") == "resources/people/bob-smith.md"
assert chorus_index.derive_path("project", "chorus") == "projects/active/chorus.md"
assert chorus_index.derive_path("area", "ops", domain="business") == "areas/business/ops.md"
assert chorus_index.derive_path("decision", "x", date="2026-01-02") == "decisions/by-date/2026-01-02-x.md"
def test_index_resolve_matches_alias_and_title() -> None:
index = {"entities": {"bob-smith": {"path": "resources/people/bob-smith.md",
"kind": "person", "title": "Bob Smith", "aliases": ["bob"]}}}
assert echo_index.resolve(index, "Bob Smith")[0] == "bob-smith"
assert echo_index.resolve(index, "bob")[0] == "bob-smith"
assert echo_index.resolve(index, "nobody")[0] is None
assert chorus_index.resolve(index, "Bob Smith")[0] == "bob-smith"
assert chorus_index.resolve(index, "bob")[0] == "bob-smith"
assert chorus_index.resolve(index, "nobody")[0] is None
def test_kind_for_path() -> None:
assert echo_index.kind_for_path("resources/people/x.md") == "person"
assert echo_index.kind_for_path("projects/on-hold/x.md") == "project"
assert echo_index.kind_for_path("journal/daily/2026-01-01.md") is None
assert chorus_index.kind_for_path("resources/people/x.md") == "person"
assert chorus_index.kind_for_path("projects/on-hold/x.md") == "project"
assert chorus_index.kind_for_path("journal/daily/2026-01-01.md") is None
def test_derive_aliases_produces_punctuation_variants() -> None:
al = echo_index.derive_aliases("ECHO Memory")
assert "echo-memory" in al and "echo memory" in al
assert echo_index.derive_aliases("") == []
al = chorus_index.derive_aliases("CHORUS Memory")
assert "chorus-memory" in al and "chorus memory" in al
assert chorus_index.derive_aliases("") == []
def test_upsert_folds_in_title_variants_and_drops_slug() -> None:
index = {"entities": {}}
e = echo_index.upsert(index, "echo-memory", "projects/active/echo-memory.md",
"project", "Echo Memory")
assert "echo memory" in e["aliases"] # space variant auto-derived
assert "echo-memory" not in e["aliases"] # equals the slug -> not stored redundantly
e = chorus_index.upsert(index, "chorus-memory", "projects/active/chorus-memory.md",
"project", "Chorus Memory")
assert "chorus memory" in e["aliases"] # space variant auto-derived
assert "chorus-memory" not in e["aliases"] # equals the slug -> not stored redundantly
def test_fuzzy_candidates_finds_shortened_name() -> None:
# The real bug: a project slugged "echo" must surface for the mention "echo memory".
index = {"entities": {"echo": {"path": "projects/active/echo.md", "kind": "project",
"title": "echo", "aliases": []}}}
cands = echo_index.fuzzy_candidates(index, "echo memory")
assert cands and cands[0][0] == "echo"
# The real bug: a project slugged "chorus" must surface for the mention "chorus memory".
index = {"entities": {"chorus": {"path": "projects/active/chorus.md", "kind": "project",
"title": "chorus", "aliases": []}}}
cands = chorus_index.fuzzy_candidates(index, "chorus memory")
assert cands and cands[0][0] == "chorus"
# exact resolve still misses (it's exact-only) — fuzzy is the safety net:
assert echo_index.resolve(index, "echo memory")[0] is None
assert chorus_index.resolve(index, "chorus memory")[0] is None
def test_fuzzy_candidates_ignores_common_and_short_tokens() -> None:
index = {"entities": {"data": {"path": "x.md", "kind": "concept",
"title": "data", "aliases": []}}}
assert echo_index.fuzzy_candidates(index, "data pipeline") == []
assert chorus_index.fuzzy_candidates(index, "data pipeline") == []
def test_gate_candidates_blocks_same_kind_rare_token() -> None:
@@ -154,7 +154,7 @@ def test_gate_candidates_blocks_same_kind_rare_token() -> None:
index = {"entities": {"bob-smith": {"path": "resources/people/bob-smith.md",
"kind": "person", "title": "Bob Smith",
"aliases": []}}}
gated = echo_index.gate_candidates(index, "Robert Smith", kind="person")
gated = chorus_index.gate_candidates(index, "Robert Smith", kind="person")
assert gated and gated[0][0] == "bob-smith"
@@ -164,54 +164,54 @@ def test_gate_candidates_skips_cross_kind() -> None:
index = {"entities": {"bob-smith": {"path": "resources/people/bob-smith.md",
"kind": "person", "title": "Bob Smith",
"aliases": []}}}
assert echo_index.gate_candidates(index, "Smith Consulting", kind="company") == []
assert chorus_index.gate_candidates(index, "Smith Consulting", kind="company") == []
# ...but the advisory candidate list still surfaces it:
assert echo_index.fuzzy_candidates(index, "Smith Consulting")
assert chorus_index.fuzzy_candidates(index, "Smith Consulting")
def test_gate_candidates_skips_single_vault_common_token() -> None:
# The live 1.5.0 false positive: "echo" appears in many entity names, so any title
# containing it scored 1.0 against every single-token "echo" entity. A lone shared
# The live 1.5.0 false positive: "chorus" appears in many entity names, so any title
# containing it scored 1.0 against every single-token "chorus" entity. A lone shared
# token only blocks when it is unique to that entity (df == 1).
ents = {
"echo": {"path": "projects/active/echo.md", "kind": "project",
"title": "echo", "aliases": []},
"echo-v-05": {"path": "projects/archived/echo-v.05.md", "kind": "project",
"title": "echo-v.05", "aliases": []},
"chorus": {"path": "projects/active/chorus.md", "kind": "project",
"title": "chorus", "aliases": []},
"chorus-v-05": {"path": "projects/archived/chorus-v.05.md", "kind": "project",
"title": "chorus-v.05", "aliases": []},
}
assert echo_index.gate_candidates({"entities": ents}, "echo handbook",
assert chorus_index.gate_candidates({"entities": ents}, "chorus handbook",
kind="project") == []
# multi-token overlap still blocks, even when the individual tokens are common:
ents["echo-memory-system"] = {"path": "projects/active/echo-memory-system.md",
"kind": "project", "title": "echo memory system",
ents["chorus-memory-system"] = {"path": "projects/active/chorus-memory-system.md",
"kind": "project", "title": "chorus memory system",
"aliases": []}
ents["echo-memory-notes"] = {"path": "resources/concepts/echo-memory-notes.md",
"kind": "concept", "title": "echo memory notes",
ents["chorus-memory-notes"] = {"path": "resources/concepts/chorus-memory-notes.md",
"kind": "concept", "title": "chorus memory notes",
"aliases": []}
gated = echo_index.gate_candidates({"entities": ents}, "echo memory platform",
gated = chorus_index.gate_candidates({"entities": ents}, "chorus memory platform",
kind="project")
assert [s for s, _, _ in gated] == ["echo-memory-system"]
assert [s for s, _, _ in gated] == ["chorus-memory-system"]
def test_aliases_in_frontmatter_flow_and_block() -> None:
flow = '---\ntype: project\naliases: ["echo memory", "echo plugin"]\n---\n# x\n'
assert echo_index.aliases_in_frontmatter(flow) == ["echo memory", "echo plugin"]
block = "---\ntype: project\naliases:\n - echo memory\n - echo plugin\n---\n# x\n"
assert echo_index.aliases_in_frontmatter(block) == ["echo memory", "echo plugin"]
assert echo_index.aliases_in_frontmatter("# no frontmatter\n") == []
flow = '---\ntype: project\naliases: ["chorus memory", "chorus plugin"]\n---\n# x\n'
assert chorus_index.aliases_in_frontmatter(flow) == ["chorus memory", "chorus plugin"]
block = "---\ntype: project\naliases:\n - chorus memory\n - chorus plugin\n---\n# x\n"
assert chorus_index.aliases_in_frontmatter(block) == ["chorus memory", "chorus plugin"]
assert chorus_index.aliases_in_frontmatter("# no frontmatter\n") == []
def test_links_add_related_is_idempotent_and_bidirectional_token() -> None:
text = "---\ntype: person\n---\n\n# Bob\n\n## Related\n"
new, changed = echo_links.add_related(text, "resources/companies/acme.md")
new, changed = chorus_links.add_related(text, "resources/companies/acme.md")
assert changed and "[[resources/companies/acme]]" in new
again, changed2 = echo_links.add_related(new, "resources/companies/acme.md")
again, changed2 = chorus_links.add_related(new, "resources/companies/acme.md")
assert not changed2 and again == new
def test_links_create_related_section_when_absent() -> None:
text = "---\ntype: concept\n---\n\n# Alpha\n\nbody\n"
new, changed = echo_links.add_related(text, "resources/concepts/beta.md")
new, changed = chorus_links.add_related(text, "resources/concepts/beta.md")
assert changed and "## Related" in new and "[[resources/concepts/beta]]" in new
@@ -28,7 +28,7 @@ def check(name: str, cond: bool, detail: str = "") -> None:
def main() -> int:
# H1 — BM25 core is implemented; assert it actually ranks a relevant doc first.
import echo_recall as r
import chorus_recall as r
ix = r.Bm25Index()
ix.add("notes/mqtt.md", "the mqtt broker config and tls certificate rotation")
ix.add("notes/payroll.md", "quarterly payroll run and employee deductions")
@@ -43,9 +43,9 @@ def main() -> int:
for n in ("recall", "rebuild", "update_note", "load_index", "save_index")))
# H2 — cache round-trips, enqueue dedups, flush is a no-op on an empty queue (offline-safe).
import echo_queue as q
import chorus_queue as q
with tempfile.TemporaryDirectory() as d:
os.environ["ECHO_STATE_DIR"] = d
os.environ["CHORUS_STATE_DIR"] = d
q.cache_put("a/b.md", b"hello")
check("H2 cache round-trip", q.cache_get("a/b.md") == b"hello")
q.enqueue("PUT", "http://x/vault/a.md", b"body", {}, idem_key="k1")
@@ -53,18 +53,18 @@ def main() -> int:
q.enqueue("PUT", "http://x/vault/a.md", b"body", {}, idem_key="k1")
check("H2 enqueue dedups by idem_key", len(q.pending()) == 1)
with tempfile.TemporaryDirectory() as d2:
os.environ["ECHO_STATE_DIR"] = d2
os.environ["CHORUS_STATE_DIR"] = d2
check("H2 flush() returns 0 on an empty queue (no network)", q.flush() == 0)
# H3 — lock CM + atomic index update are stubs.
import echo_concurrency as c
import chorus_concurrency as c
check("H3 auto_owner is stable", c.auto_owner() == c.auto_owner())
check("H3 vault_lock returns a context manager (no network until entered)",
hasattr(c.vault_lock(), "__enter__") and hasattr(c.vault_lock(), "__exit__"))
check("H3 atomic_index_update is wired", callable(c.atomic_index_update))
# H5 — proposal validation is pure (offline): keeps valid (incl. inbox), drops the rest.
import echo_reflect as rf
import chorus_reflect as rf
v, errs = rf.validate([
{"title": "Ok", "kind": "person", "confidence": 0.9},
{"title": "", "kind": "person"}, # missing title
@@ -78,14 +78,14 @@ def main() -> int:
# Config — owner/endpoint/key resolve from env -> machine-local config.json (no baked
# defaults). Env wins per field; the file fills the rest; missing required -> raises.
import echo_config as cfg
saved_env = {k: os.environ.get(k) for k in ("ECHO_KEY", "ECHO_BASE", "ECHO_OWNER", "ECHO_CONFIG")}
import chorus_config as cfg
saved_env = {k: os.environ.get(k) for k in ("CHORUS_KEY", "CHORUS_BASE", "CHORUS_OWNER", "CHORUS_CONFIG")}
try:
for k in saved_env:
os.environ.pop(k, None)
with tempfile.TemporaryDirectory() as d:
cfgfile = os.path.join(d, "config.json")
os.environ["ECHO_CONFIG"] = cfgfile
os.environ["CHORUS_CONFIG"] = cfgfile
# init writes the placeholder template only when absent.
p, created = cfg.init_template()
check("config init scaffolds the template", created and os.path.exists(cfgfile))
@@ -99,9 +99,9 @@ def main() -> int:
"endpoint": "https://obsidian.example.com", # trailing / stripped
"key": "file-key-xyz"})
# env overrides the file, per field.
os.environ["ECHO_KEY"] = "env-key-123"
check("env ECHO_KEY overrides config", cfg.resolve_key() == "env-key-123")
os.environ.pop("ECHO_KEY", None)
os.environ["CHORUS_KEY"] = "env-key-123"
check("env CHORUS_KEY overrides config", cfg.resolve_key() == "env-key-123")
os.environ.pop("CHORUS_KEY", None)
# missing required field raises a helpful error.
cfg.write_config("Owner Name", "", "")
try:
@@ -117,16 +117,16 @@ def main() -> int:
os.environ[k] = v
# M2 — slug collision + link-confidence helpers are implemented.
import echo_quality as qual
import chorus_quality as qual
check("M2 safe_slug disambiguates a collision",
qual.safe_slug({"foo"}, "foo") != "foo")
check("M2 short/common tokens are not confident links",
qual.is_confident_link("API", "we built an api") is False)
# M3 — doctor module exposes run(); M4 — output envelope is implemented.
import echo_doctor as doc
import chorus_doctor as doc
check("M3 doctor exposes run()", hasattr(doc, "run"))
import echo_output as out
import chorus_output as out
env = out.envelope("created", {"path": "p.md"})
check("M4 envelope shape", env.get("ok") is True and env.get("action") == "created")
@@ -1,18 +1,18 @@
#!/usr/bin/env python3
"""vault_lint.py — mechanically assert ECHO vault invariants. READ-ONLY.
"""vault_lint.py — mechanically assert CHORUS vault invariants. READ-ONLY.
Catches the recurring drift bugs prose rules can't enforce: folder<->status
mismatch, duplicate slugs, wikilinks leaking into frontmatter, duplicate
"## Agent Log" headings, stale active projects, aging inbox captures, impossible
dates, missing frontmatter, broken source_notes, scope drift, and paths that no
route in routing.json permits. Cross-platform: pure Python via echo.py.
route in routing.json permits. Cross-platform: pure Python via chorus.py.
Exit status: 0 = clean, 1 = violations found, 2 = vault unreachable,
3 = vault not bootstrapped (marker missing).
Config (env overrides):
ECHO_BASE, ECHO_KEY (via echo.py)
ECHO_TODAY (default machine date) pass the conversation's currentDate so
CHORUS_BASE, CHORUS_KEY (via chorus.py)
CHORUS_TODAY (default machine date) pass the conversation's currentDate so
stale/aging math uses the same clock the agent writes with.
STALE_DAYS (default 30) INBOX_DAYS (default 14) SCOPE_STALE_SESSIONS (default 3)
"""
@@ -29,11 +29,11 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import echo # noqa: E402
import echo_index as idx_mod # noqa: E402
import echo_links as links # noqa: E402
import chorus # noqa: E402
import chorus_index as idx_mod # noqa: E402
import chorus_links as links # noqa: E402
TODAY = dt.date.fromisoformat(os.environ.get("ECHO_TODAY") or dt.date.today().isoformat())
TODAY = dt.date.fromisoformat(os.environ.get("CHORUS_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"))
@@ -49,10 +49,10 @@ def flag(check: str, message: str) -> None:
def get(path: str) -> str | None:
status, body = echo.request("GET", echo.vault_url(path))
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 404:
return None
echo.check(status, body, f"get {path}")
chorus.check(status, body, f"get {path}")
return body.decode(errors="replace")
@@ -145,7 +145,7 @@ def route_matchers():
def main() -> int:
try:
if get("_agent/echo-vault.md") is None:
if get("_agent/chorus-vault.md") is None and 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:
@@ -163,7 +163,7 @@ def main() -> int:
md_set = set(md_files)
# Fetch every markdown note ONCE, concurrently; all per-note checks below read from
# this shared cache instead of issuing a fresh GET per file per section.
texts = echo.read_many(md_files)
texts = chorus.read_many(md_files)
# Path membership + retired-path detection
for path in all_files:
@@ -188,7 +188,7 @@ def main() -> int:
if fm and missing:
flag("missing-frontmatter", f"{path}: missing {', '.join(missing)}")
# Kind-scoped completeness: entity notes must classify (status + tags). The
# per-kind requirement map lives in echo_index (KIND_REQUIRED_FM) so capture's
# per-kind requirement map lives in chorus_index (KIND_REQUIRED_FM) so capture's
# defaults, this check, and sweep's backfill all read the same source of truth.
kind = idx_mod.kind_for_path(path)
if fm and kind:
@@ -250,7 +250,7 @@ def main() -> int:
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 — scope drift cannot be detected; add it (bootstrap.py) and switch scope via `echo.py scope set`")
"_agent/context/current-context.md: no scope_updated frontmatter — scope drift cannot be detected; add it (bootstrap.py) and switch scope via `chorus.py scope set`")
else:
since = [
path for path in all_files
@@ -259,7 +259,7 @@ def main() -> int:
]
if len(since) >= SCOPE_STALE_SESSIONS:
flag("scope-stale",
f"scope set {scope_updated}; {len(since)} session(s) logged since without a switch — confirm it still reflects current work (or run `echo.py scope set`)")
f"scope set {scope_updated}; {len(since)} session(s) logged since without a switch — confirm it still reflects current work (or run `chorus.py scope set`)")
# ---- Graph health: broken wikilinks, orphans, index drift ----------------
# (md_files / md_set were built up top; reuse the shared `texts` cache.)
Binary file not shown.
@@ -1,16 +0,0 @@
{
"name": "echo-memory",
"version": "1.5.1",
"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 Stedwell"
},
"license": "UNLICENSED",
"keywords": [
"memory",
"obsidian",
"notes",
"persistence",
"echo"
]
}
@@ -1,20 +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
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`.
@@ -1,18 +0,0 @@
---
description: Load ECHO memory — cold-start context read (profile, scope, latest session, today, inbox)
---
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
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.
-27
View File
@@ -1,27 +0,0 @@
{
"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
}
]
}
]
}
}
@@ -1,12 +0,0 @@
#!/bin/bash
export ECHO_KEY_LEGACY_OK=1
cd "$(dirname "$0")"
{
echo "[$(date +%T)] read-full"; python3 bench.py read-full -n 3 --quiet --json-out results/m-read-full.json
echo "[$(date +%T)] worker-sweep"; python3 bench.py worker-sweep -n 4 --quiet --json-out results/m-worker-sweep.json
echo "[$(date +%T)] subject-pull"; python3 bench.py subject-pull -n 4 --quiet --json-out results/m-subject-pull.json
echo "[$(date +%T)] index"; python3 bench.py index --quiet --json-out results/m-index.json
echo "[$(date +%T)] lint"; python3 bench.py lint --quiet --json-out results/m-lint.json
echo "[$(date +%T)] DONE"
} > results/run.log 2>&1
touch results/DONE
+5 -5
View File
@@ -1,4 +1,4 @@
# echo-memory eval — current-version metrics harness
# chorus-memory eval — current-version metrics harness
A reproducible, credential-free eval of the shipped plugin against the deterministic
mock REST API. `run_eval.py` measures the four things the plugin claims (results print
@@ -9,7 +9,7 @@ as a table and land in `results/latest.json`):
(per-word substring ranking over entity notes only). Precision@5 / recall@5 / MRR,
session/journal answerability, freshness top-1.
2. **Dedup** — duplicate notes created replaying a capture stream with the pre-write
gate ON (default) vs OFF (`ECHO_DUP_GATE=99` ≈ pre-1.5 warn-only), plus legitimate
gate ON (default) vs OFF (`CHORUS_DUP_GATE=99` ≈ pre-1.5 warn-only), plus legitimate
captures wrongly blocked (must be 0).
3. **Write safety** — silent write failures under fault injection (transient 503,
phantom PUT, PATCH to a missing heading, replayed append). Must be 0; must be loud.
@@ -29,7 +29,7 @@ python3 test_features.py # end-to-end features vs the mock (capture/reca
python3 test_reflect.py # reflection pipeline
python3 test_offline_queue.py # H2 outage queue + read cache
python3 test_patch_semantics.py # real PATCH semantics vs mock_olrapi_hifi.py (incl. fm create-or-replace)
# plus, in the plugin tree: scripts/test_echo_client.py (offline unit + routing-sync guard)
# plus, in the plugin tree: scripts/test_chorus_client.py (offline unit + routing-sync guard)
```
## Run it (historical A/B)
@@ -56,7 +56,7 @@ Results table prints to stdout and a machine-readable copy lands in `results/lat
- a `PATCH` to a missing heading → `400` (the silent-write-loss trigger).
- **`run_eval.py`** — for each scenario, runs both methods against a freshly reset +
re-seeded server (so faults are identical for both), then reads ground truth back
**independently** from the mock. The 0.7 side executes the *actual shipped `echo.py`*;
**independently** from the mock. The 0.7 side executes the *actual shipped `chorus.py`*;
the 0.6 side faithfully models the documented recipe (real HTTP, but no status check,
no retry, no verify, no dedupe).
@@ -106,7 +106,7 @@ effective tokens (assumed) 6723 -> 334
To measure real model behavior and true token counts:
1. Define the same six scenarios as natural-language tasks (e.g. "log a session note for X").
2. Run each twice — once with the 0.6 skill files, once with 0.7 — through the Agent SDK
against the **mock** server (point `ECHO_BASE` at it) so faults stay deterministic.
against the **mock** server (point `CHORUS_BASE` at it) so faults stay deterministic.
3. Record `usage.output_tokens` per task from the API and whether the vault ended correct
(same ground-truth read used here).
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""mock_olrapi.py — a deterministic mock of the Obsidian Local REST API surface the
echo-memory plugin uses, with controllable fault injection.
chorus-memory plugin uses, with controllable fault injection.
It reproduces the real API's observed behavior and quirks so the A/B eval can run
without credentials and without touching the live vault:
+29 -29
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""run_eval.py — current-version metrics for echo-memory (v1.5+).
"""run_eval.py — current-version metrics for chorus-memory (v1.5+).
Replaces the historical 0.6-vs-0.7 A/B harness (see git history for that version).
Measures the four things the plugin now claims, against the deterministic mock
@@ -11,7 +11,7 @@ REST API (mock_olrapi.py) — no credentials, no live vault:
Metrics: precision@5, recall@5, MRR, session/journal queries
answered, freshness top-1 correctness.
2. DEDUP — duplicate notes created replaying a capture stream with the
pre-write gate ON (default) vs OFF (ECHO_DUP_GATE=99, i.e. the
pre-write gate ON (default) vs OFF (CHORUS_DUP_GATE=99, i.e. the
pre-1.5 warn-only behavior). Also counts legitimate captures
wrongly blocked (must be 0).
3. WRITE SAFETY— silent write failures (claimed success but ground truth wrong)
@@ -40,8 +40,8 @@ import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
SCRIPTS = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts"
ECHO = SCRIPTS / "echo.py"
SCRIPTS = HERE.parent / "chorus-memory.plugin.src" / "skills" / "chorus-memory" / "scripts"
CHORUS = SCRIPTS / "chorus.py"
SWEEP = SCRIPTS / "sweep.py"
KEY = "test-key-not-a-real-secret"
TODAY = "2026-07-03"
@@ -76,8 +76,8 @@ class Harness:
return None if body == "<<MISSING>>" else body
def run(self, script, *args, env_extra=None, base=None):
env = dict(os.environ, ECHO_BASE=base or self.base, ECHO_KEY=KEY,
ECHO_VERIFY="1", ECHO_TODAY=TODAY, ECHO_STATE_DIR=self.state_dir,
env = dict(os.environ, CHORUS_BASE=base or self.base, CHORUS_KEY=KEY,
CHORUS_VERIFY="1", CHORUS_TODAY=TODAY, CHORUS_STATE_DIR=self.state_dir,
**(env_extra or {}))
return subprocess.run([sys.executable, str(script), *args],
capture_output=True, text=True, env=env)
@@ -179,7 +179,7 @@ def metrics_for(ranked_by_query):
def eval_retrieval(h):
h.reset()
h.seed("_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n")
h.seed("_agent/chorus-vault.md", "---\nschema_version: 4\n---\n# marker\n")
for path, text in {**ENTITY_CORPUS, **TIME_SERIES_CORPUS}.items():
h.seed(path, text)
r = h.run(SWEEP, "--apply")
@@ -188,7 +188,7 @@ def eval_retrieval(h):
current_ranked, baseline_ranked = [], []
fresh_top1_current = fresh_top1_baseline = None
for q, gold, ts_only in QUERIES:
r = h.run(ECHO, "recall", q, "--json", "--limit", "5")
r = h.run(CHORUS, "recall", q, "--json", "--limit", "5")
try:
primary = [hit["path"] for hit in json.loads(r.stdout).get("primary", [])]
except Exception: # noqa: BLE001
@@ -212,28 +212,28 @@ def eval_retrieval(h):
# Attempts referring to an EXISTING entity under another name; a create == duplicate.
DUP_ATTEMPTS = [
("Robert Smith", "person", "resources/people/robert-smith.md"),
("Echo Memory", "project", "projects/active/echo-memory.md"),
("Chorus Memory", "project", "projects/active/chorus-memory.md"),
("Acme Consulting", "company", "resources/companies/acme-consulting.md"),
]
# Legitimate NEW entities; a block == false positive (must create in both modes).
LEGIT_ATTEMPTS = [
("Echo Review Meeting", "meeting",
f"resources/meetings/{TODAY}-echo-review-meeting.md"), # cross-kind lookalike
("Chorus Review Meeting", "meeting",
f"resources/meetings/{TODAY}-chorus-review-meeting.md"), # cross-kind lookalike
("Zephyr", "concept", "resources/concepts/zephyr.md"), # unrelated
]
def _dedup_pass(h, gate_env):
h.reset()
h.seed("_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n")
for title, kind in [("Bob Smith", "person"), ("Echo", "project"), ("Acme", "company")]:
h.run(ECHO, "capture", title, "--kind", kind, env_extra=gate_env)
h.seed("_agent/chorus-vault.md", "---\nschema_version: 4\n---\n# marker\n")
for title, kind in [("Bob Smith", "person"), ("Chorus", "project"), ("Acme", "company")]:
h.run(CHORUS, "capture", title, "--kind", kind, env_extra=gate_env)
dupes = blocked_legit = 0
for title, kind, would_be in DUP_ATTEMPTS:
h.run(ECHO, "capture", title, "--kind", kind, env_extra=gate_env)
h.run(CHORUS, "capture", title, "--kind", kind, env_extra=gate_env)
dupes += int(h.ground(would_be) is not None)
for title, kind, path in LEGIT_ATTEMPTS:
h.run(ECHO, "capture", title, "--kind", kind, env_extra=gate_env)
h.run(CHORUS, "capture", title, "--kind", kind, env_extra=gate_env)
blocked_legit += int(h.ground(path) is None)
return {"duplicate_notes_created": dupes, "legitimate_captures_blocked": blocked_legit,
"referring_attempts": len(DUP_ATTEMPTS), "legit_attempts": len(LEGIT_ATTEMPTS)}
@@ -241,13 +241,13 @@ def _dedup_pass(h, gate_env):
def eval_dedup(h):
return {"gate ON (v1.5.1 default)": _dedup_pass(h, {}),
"gate OFF (pre-1.5, warn-only)": _dedup_pass(h, {"ECHO_DUP_GATE": "99"})}
"gate OFF (pre-1.5, warn-only)": _dedup_pass(h, {"CHORUS_DUP_GATE": "99"})}
# ------------------------------------------------------------- 3. WRITE SAFETY
def eval_write_safety(h):
h.reset()
h.seed("_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n")
h.seed("_agent/chorus-vault.md", "---\nschema_version: 4\n---\n# marker\n")
silent = loud = 0
ops = 0
@@ -259,7 +259,7 @@ def eval_write_safety(h):
# transient 503 on first write ("flaky" marker) -> client must retry and land it
ops += 1
r = h.run(ECHO, "put", "resources/concepts/flaky-note.md", bodyfile("# Flaky\nok\n"))
r = h.run(CHORUS, "put", "resources/concepts/flaky-note.md", bodyfile("# Flaky\nok\n"))
landed = h.ground("resources/concepts/flaky-note.md") is not None
if r.returncode == 0 and not landed:
silent += 1
@@ -268,7 +268,7 @@ def eval_write_safety(h):
# phantom PUT ("phantom" marker: 200 but not persisted) -> verify must fail LOUD
ops += 1
r = h.run(ECHO, "put", "resources/concepts/phantom-note.md", bodyfile("# Phantom\n"))
r = h.run(CHORUS, "put", "resources/concepts/phantom-note.md", bodyfile("# Phantom\n"))
landed = h.ground("resources/concepts/phantom-note.md") is not None
if r.returncode == 0 and not landed:
silent += 1
@@ -278,7 +278,7 @@ def eval_write_safety(h):
# PATCH to a missing heading -> 400 must be surfaced, nothing written
ops += 1
h.seed("resources/concepts/target.md", "# Target\n\n## Real\nx\n")
r = h.run(ECHO, "patch", "resources/concepts/target.md", "append", "heading",
r = h.run(CHORUS, "patch", "resources/concepts/target.md", "append", "heading",
"Target::Nope", bodyfile("lost?"))
if r.returncode == 0 and "lost?" not in (h.ground("resources/concepts/target.md") or ""):
silent += 1
@@ -287,8 +287,8 @@ def eval_write_safety(h):
# replayed append -> whole-line idempotency, no duplicate
ops += 1
h.run(ECHO, "append", "inbox/captures/inbox.md", f"- {TODAY}: replay me")
h.run(ECHO, "append", "inbox/captures/inbox.md", f"- {TODAY}: replay me")
h.run(CHORUS, "append", "inbox/captures/inbox.md", f"- {TODAY}: replay me")
h.run(CHORUS, "append", "inbox/captures/inbox.md", f"- {TODAY}: replay me")
n = (h.ground("inbox/captures/inbox.md") or "").count("replay me")
if n != 1:
silent += 1
@@ -300,15 +300,15 @@ def eval_write_safety(h):
# --------------------------------------------------------------- 4. DURABILITY
def eval_durability(h, dead_base):
h.reset()
h.seed("_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n")
h.seed("_agent/chorus-vault.md", "---\nschema_version: 4\n---\n# marker\n")
queued_ok = 0
for i in range(3):
r = h.run(ECHO, "append", "inbox/captures/inbox.md",
r = h.run(CHORUS, "append", "inbox/captures/inbox.md",
f"- {TODAY}: offline capture {i}", base=dead_base)
queued_ok += int(r.returncode == 0 and "queued" in r.stdout)
# vault returns: flush replays; a second flush must not duplicate
h.run(ECHO, "flush")
h.run(ECHO, "flush")
h.run(CHORUS, "flush")
h.run(CHORUS, "flush")
inbox = h.ground("inbox/captures/inbox.md") or ""
landed = sum(1 for i in range(3) if f"offline capture {i}" in inbox)
dupes = sum(inbox.count(f"offline capture {i}") - 1
@@ -325,7 +325,7 @@ def main():
base = f"http://127.0.0.1:{a.port}"
srv = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi.py"), "--port", str(a.port)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
state = tempfile.mkdtemp(prefix="echo-eval-state-")
state = tempfile.mkdtemp(prefix="chorus-eval-state-")
try:
for _ in range(50):
try:
@@ -345,7 +345,7 @@ def main():
out.parent.mkdir(exist_ok=True)
out.write_text(json.dumps(results, indent=2), encoding="utf-8")
print(f"\n== echo-memory eval — v{results['version']} ({results['date']}) ==\n")
print(f"\n== chorus-memory eval — v{results['version']} ({results['date']}) ==\n")
for section, data in results.items():
if section in ("version", "date"):
continue
+45 -45
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""test_features.py — end-to-end test of the v0.9 features against the mock OLRAPI.
Exercises the real shipped scripts (echo.py capture/resolve/recall/link + sweep.py)
Exercises the real shipped scripts (chorus.py capture/resolve/recall/link + sweep.py)
through subprocess, then reads ground truth back from the mock. No network, no creds,
no live vault.
@@ -11,8 +11,8 @@ import argparse, json, os, subprocess, sys, time, urllib.request, urllib.error
from pathlib import Path
HERE = Path(__file__).resolve().parent
SCRIPTS = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts"
ECHO = SCRIPTS / "echo.py"
SCRIPTS = HERE.parent / "chorus-memory.plugin.src" / "skills" / "chorus-memory" / "scripts"
CHORUS = SCRIPTS / "chorus.py"
SWEEP = SCRIPTS / "sweep.py"
KEY = "test-key-not-a-real-secret"
@@ -51,9 +51,9 @@ class Harness:
_, body = self.http("GET", f"{self.base}/__debug__?path={path}")
return None if body == "<<MISSING>>" else body
def echo(self, *args):
env = dict(os.environ, ECHO_BASE=self.base, ECHO_KEY=KEY, ECHO_VERIFY="1",
ECHO_TODAY="2026-06-21")
def chorus(self, *args):
env = dict(os.environ, CHORUS_BASE=self.base, CHORUS_KEY=KEY, CHORUS_VERIFY="1",
CHORUS_TODAY="2026-06-21")
return subprocess.run([sys.executable, str(args[0]), *args[1:]],
capture_output=True, text=True, env=env)
@@ -73,10 +73,10 @@ def main():
time.sleep(0.1)
h = Harness(base)
h.reset()
h.seed("_agent/echo-vault.md", "---\nschema_version: 2\n---\n# marker\n")
h.seed("_agent/chorus-vault.md", "---\nschema_version: 2\n---\n# marker\n")
# 1. capture a person (with an alias)
r = h.echo(ECHO, "capture", "Bob Smith", "--kind", "person", "--aliases", "bob,rs")
r = h.chorus(CHORUS, "capture", "Bob Smith", "--kind", "person", "--aliases", "bob,rs")
note = h.ground("resources/people/bob-smith.md")
check("capture person creates note", note is not None and "type: person" in note, r.stderr)
check("capture stamps agent_written", note and "agent_written: true" in note)
@@ -84,14 +84,14 @@ def main():
check("index records entity", idx and "bob-smith" in idx and '"bob"' in idx)
# 2. resolve by alias
r = h.echo(ECHO, "resolve", "bob")
r = h.chorus(CHORUS, "resolve", "bob")
out = json.loads(r.stdout) if r.stdout.strip().startswith("{") else {}
check("resolve alias -> path", out.get("match") and out.get("path") == "resources/people/bob-smith.md", r.stdout)
# 3. capture a company whose body mentions Bob Smith -> auto bidirectional link
r = subprocess.run([sys.executable, str(ECHO), "capture", "MPM", "--kind", "company", "-"],
r = subprocess.run([sys.executable, str(CHORUS), "capture", "MPM", "--kind", "company", "-"],
input="Bob Smith is the principal here.\n", capture_output=True, text=True,
env=dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_TODAY="2026-06-21"))
env=dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_TODAY="2026-06-21"))
mpm = h.ground("resources/companies/mpm.md")
bob = h.ground("resources/people/bob-smith.md")
check("auto-link forward (mpm -> bob)", mpm and "[[resources/people/bob-smith]]" in mpm, r.stdout + r.stderr)
@@ -99,32 +99,32 @@ def main():
# 4. recall an entity surfaces its linked neighborhood (search is mocked empty,
# so this exercises the index-resolve + 1-hop expansion path)
r = h.echo(ECHO, "recall", "MPM")
r = h.chorus(CHORUS, "recall", "MPM")
check("recall surfaces linked person", "resources/people/bob-smith.md" in r.stdout, r.stdout)
# 4b. BM25 finds a note by a BODY term. /search/simple is mocked empty, so a hit
# here can only come from the local BM25 recall index (proves H1 lexical layer).
r = h.echo(ECHO, "recall", "principal")
r = h.chorus(CHORUS, "recall", "principal")
check("recall finds note by body term (BM25)",
"resources/companies/mpm.md" in r.stdout, r.stdout)
# 5. explicit link between two fresh notes
h.echo(ECHO, "capture", "Alpha", "--kind", "concept")
h.echo(ECHO, "capture", "Beta", "--kind", "concept")
h.echo(ECHO, "link", "resources/concepts/alpha.md", "resources/concepts/beta.md")
h.chorus(CHORUS, "capture", "Alpha", "--kind", "concept")
h.chorus(CHORUS, "capture", "Beta", "--kind", "concept")
h.chorus(CHORUS, "link", "resources/concepts/alpha.md", "resources/concepts/beta.md")
alpha = h.ground("resources/concepts/alpha.md")
beta = h.ground("resources/concepts/beta.md")
check("link adds A->B", alpha and "[[resources/concepts/beta]]" in alpha)
check("link adds B->A", beta and "[[resources/concepts/alpha]]" in beta)
# 6. inbox capture (unknown home)
h.echo(ECHO, "capture", "stray thought", "--inbox")
h.chorus(CHORUS, "capture", "stray thought", "--inbox")
inbox = h.ground("inbox/captures/inbox.md")
check("inbox capture lands", inbox and "stray thought" in inbox)
# 7. sweep --apply rebuilds the index, symmetrizes, stamps schema 3
r = h.echo(SWEEP, "--apply")
marker = h.ground("_agent/echo-vault.md")
r = h.chorus(SWEEP, "--apply")
marker = h.ground("_agent/chorus-vault.md")
check("sweep runs clean", r.returncode == 0, r.stdout + r.stderr)
check("sweep stamps schema 4", marker and "schema_version=4" in marker.replace(": ", "="), marker)
idx2 = h.ground("_agent/index/entities.json")
@@ -140,8 +140,8 @@ def main():
'{"schema":1,"updated":"2026-06-21","entities":{"ext-entity":'
'{"path":"resources/concepts/ext-entity.md","kind":"concept","title":"Ext",'
'"aliases":[],"last_seen":"2026-06-21"}}}')
env = dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_TODAY="2026-06-21", PYTHONPATH=str(SCRIPTS))
snippet = ("import echo_index as ix, echo_concurrency as c; "
env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_TODAY="2026-06-21", PYTHONPATH=str(SCRIPTS))
snippet = ("import chorus_index as ix, chorus_concurrency as c; "
"c.atomic_index_update(lambda d: ix.upsert(d,'mine',"
"'resources/concepts/mine.md','concept','Mine'))")
subprocess.run([sys.executable, "-c", snippet], env=env, capture_output=True, text=True)
@@ -152,7 +152,7 @@ def main():
h.ground("_agent/locks/vault.lock") is None)
# 9. M4 — --dry-run previews and writes nothing; --json emits a parseable envelope.
r = h.echo(ECHO, "capture", "DryRun Co", "--kind", "company", "--dry-run", "--json")
r = h.chorus(CHORUS, "capture", "DryRun Co", "--kind", "company", "--dry-run", "--json")
try:
env = json.loads(r.stdout)
except Exception:
@@ -160,7 +160,7 @@ def main():
check("M4 --dry-run emits a dry-run JSON envelope",
env.get("action", "").startswith("dry-run") and env.get("path") == "resources/companies/dryrun-co.md", r.stdout)
check("M4 --dry-run writes nothing", h.ground("resources/companies/dryrun-co.md") is None)
r = h.echo(ECHO, "capture", "Json Co", "--kind", "company", "--json")
r = h.chorus(CHORUS, "capture", "Json Co", "--kind", "company", "--json")
try:
env = json.loads(r.stdout)
except Exception:
@@ -173,7 +173,7 @@ def main():
# ---------------- v1.5 features ----------------------------------------
# step 8 clobbered entities.json on purpose; rebuild the index from the notes
# so the v1.5 tests run against a coherent vault.
h.echo(SWEEP, "--apply")
h.chorus(SWEEP, "--apply")
# 10. frontmatter completeness at write time: entity notes are born classified.
bob2 = h.ground("resources/people/bob-smith.md") or ""
@@ -181,25 +181,25 @@ def main():
check("v1.5 capture seeds tags with the kind", "tags: [person]" in bob2, bob2)
# 11. update-capture preserves the FULL body (old code kept only line 1).
r = subprocess.run([sys.executable, str(ECHO), "capture", "Bob Smith", "-", "--kind", "person"],
r = subprocess.run([sys.executable, str(CHORUS), "capture", "Bob Smith", "-", "--kind", "person"],
input="met at expo\nsecond line survives\nthird line too\n",
capture_output=True, text=True,
env=dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_TODAY="2026-06-21"))
env=dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_TODAY="2026-06-21"))
bob3 = h.ground("resources/people/bob-smith.md") or ""
check("v1.5 update keeps the dated bullet", "- 2026-06-21: met at expo" in bob3, r.stdout + r.stderr)
check("v1.5 update keeps EVERY body line",
" second line survives" in bob3 and " third line too" in bob3, bob3)
# 12. duplicate gate: same-kind + distinctive-token candidate STOPS creation...
r = h.echo(ECHO, "capture", "Robert Smith", "--kind", "person")
r = h.chorus(CHORUS, "capture", "Robert Smith", "--kind", "person")
check("v1.5 duplicate gate stops the create (exit 76)", r.returncode == 76, str(r.returncode))
check("v1.5 gate wrote nothing", h.ground("resources/people/robert-smith.md") is None)
# ... --force overrides ...
r = h.echo(ECHO, "capture", "Robert Smith", "--kind", "person", "--force")
r = h.chorus(CHORUS, "capture", "Robert Smith", "--kind", "person", "--force")
check("v1.5 --force creates past the gate",
h.ground("resources/people/robert-smith.md") is not None, r.stdout + r.stderr)
# 12b (1.5.1). cross-kind lookalike does NOT gate — it creates with a warning.
r = h.echo(ECHO, "capture", "Smith Consulting", "--kind", "company")
r = h.chorus(CHORUS, "capture", "Smith Consulting", "--kind", "company")
check("v1.5.1 cross-kind lookalike is not gated",
r.returncode == 0 and h.ground("resources/companies/smith-consulting.md") is not None,
str(r.returncode) + r.stdout + r.stderr)
@@ -207,14 +207,14 @@ def main():
"similar existing" in (r.stdout + r.stderr), r.stdout + r.stderr)
# 12c (1.5.1). a single vault-common token does NOT gate: once "acme" appears in
# two entities, "Acme Tools" creates (with a warning) instead of blocking.
h.echo(ECHO, "capture", "Acme", "--kind", "company")
h.echo(ECHO, "capture", "Acme Group", "--kind", "company", "--force")
r = h.echo(ECHO, "capture", "Acme Tools", "--kind", "company")
h.chorus(CHORUS, "capture", "Acme", "--kind", "company")
h.chorus(CHORUS, "capture", "Acme Group", "--kind", "company", "--force")
r = h.chorus(CHORUS, "capture", "Acme Tools", "--kind", "company")
check("v1.5.1 single common token does not gate",
r.returncode == 0 and h.ground("resources/companies/acme-tools.md") is not None,
str(r.returncode) + r.stdout + r.stderr)
# ... and --merge-into routes the capture as an update to the canonical entity.
r = h.echo(ECHO, "capture", "Bobby The Builder", "--kind", "person", "--merge-into", "bob-smith")
r = h.chorus(CHORUS, "capture", "Bobby The Builder", "--kind", "person", "--merge-into", "bob-smith")
idx4 = h.ground("_agent/index/entities.json") or ""
check("v1.5 --merge-into updates the existing entity (mention learned as alias)",
r.returncode == 0 and "Bobby The Builder" in idx4, r.stdout + r.stderr)
@@ -225,8 +225,8 @@ def main():
h.seed("_agent/sessions/2026-06-15-1200-mpm-review.md",
"---\ntype: session-log\ncreated: 2026-06-15\nupdated: 2026-06-15\n---\n"
"# MPM review\n\nDiscussed the kerfuffle about invoicing.\n")
h.echo(SWEEP, "--apply")
r = h.echo(ECHO, "recall", "kerfuffle")
h.chorus(SWEEP, "--apply")
r = h.chorus(CHORUS, "recall", "kerfuffle")
check("v1.5 recall finds a session log by body term",
"_agent/sessions/2026-06-15-1200-mpm-review.md" in r.stdout, r.stdout)
rix2 = h.ground("_agent/index/recall-index.json") or ""
@@ -240,8 +240,8 @@ def main():
h.seed("resources/concepts/new-idea.md",
"---\ntype: concept\nstatus: active\ncreated: 2026-06-20\nupdated: 2026-06-20\n"
"tags: [concept]\n---\n# New Idea\n\nzeppelin research notes\n")
h.echo(SWEEP, "--apply")
r = h.echo(ECHO, "recall", "zeppelin", "--json")
h.chorus(SWEEP, "--apply")
r = h.chorus(CHORUS, "recall", "zeppelin", "--json")
try:
env2 = json.loads(r.stdout)
except Exception:
@@ -259,7 +259,7 @@ def main():
# 15. one-tap triage: structured list, then route with an audit trail.
h.seed("inbox/captures/inbox.md",
"- 2026-06-01: prefers uv over pip\n- 2026-06-20: try the new espresso place\n")
r = h.echo(ECHO, "triage", "--list", "--json")
r = h.chorus(CHORUS, "triage", "--list", "--json")
try:
tenv = json.loads(r.stdout)
except Exception:
@@ -272,7 +272,7 @@ def main():
"line": "- 2026-06-01: prefers uv over pip", "confidence": 0.9}]
pfile = HERE / "_triage_props.json"
pfile.write_text(json.dumps(props), encoding="utf-8")
r = h.echo(ECHO, "triage", str(pfile), "--apply")
r = h.chorus(CHORUS, "triage", str(pfile), "--apply")
pfile.unlink()
routed = h.ground("_agent/memory/semantic/prefers-uv-over-pip.md")
plog = h.ground("inbox/processing-log/2026-06-21.md") or ""
@@ -286,10 +286,10 @@ def main():
h.seed("resources/companies/driftco.md",
"---\ntype: company\ncreated: 2026-06-01\nupdated: 2026-06-01\ntags: []\n---\n# DriftCo\n")
LINT = SCRIPTS / "vault_lint.py"
r = h.echo(LINT)
r = h.chorus(LINT)
check("v1.5 lint flags missing status", "driftco.md: missing status" in r.stdout, r.stdout[-800:])
check("v1.5 lint flags empty tags", "driftco.md: empty tags" in r.stdout, r.stdout[-800:])
h.echo(SWEEP, "--apply")
h.chorus(SWEEP, "--apply")
drift = h.ground("resources/companies/driftco.md") or ""
# (the naive mock records frontmatter PATCHes as <fm:...> markers instead of
# editing the YAML; the hi-fi suite proves the real fm semantics)
@@ -304,10 +304,10 @@ def main():
transcript = tdir / "t.jsonl"
turns = [json.dumps({"type": "user", "message": {"content": f"user turn {i}"}}) for i in range(6)]
transcript.write_text("\n".join(turns), encoding="utf-8")
hook_env = dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_STATE_DIR=str(tdir))
hook_env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_STATE_DIR=str(tdir))
payload = json.dumps({"session_id": "s1", "transcript_path": str(transcript),
"stop_hook_active": False})
HOOK_STOP = SCRIPTS / "echo_hook_stop.py"
HOOK_STOP = SCRIPTS / "chorus_hook_stop.py"
r = subprocess.run([sys.executable, str(HOOK_STOP)], input=payload,
capture_output=True, text=True, env=hook_env)
try:
@@ -320,7 +320,7 @@ def main():
capture_output=True, text=True, env=hook_env)
check("v1.5 stop hook fires only once per session",
r.returncode == 0 and not r.stdout.strip(), r.stdout)
HOOK_START = SCRIPTS / "echo_hook_session_start.py"
HOOK_START = SCRIPTS / "chorus_hook_session_start.py"
r = subprocess.run([sys.executable, str(HOOK_START)],
input=json.dumps({"source": "startup"}),
capture_output=True, text=True, env=hook_env)
@@ -330,7 +330,7 @@ def main():
senv = {}
ctx = (senv.get("hookSpecificOutput") or {}).get("additionalContext", "")
check("v1.5 session-start hook injects the load output",
r.returncode == 0 and "marker" in ctx and "echo-vault.md" in ctx, r.stdout[:300])
r.returncode == 0 and "marker" in ctx and "chorus-vault.md" in ctx, r.stdout[:300])
r = subprocess.run([sys.executable, str(HOOK_START)],
input=json.dumps({"source": "resume"}),
capture_output=True, text=True, env=hook_env)
+14 -14
View File
@@ -6,7 +6,7 @@ Phase 2 — vault UP: `flush` replays them idempotently; the writes land.
Phase 3 — vault UP: `load` caches the orientation reads.
Phase 4 — vault DOWN: `load` serves the last-known-good cache and says OFFLINE.
Drives the real echo.py against eval/mock_olrapi.py. No creds, no live vault.
Drives the real chorus.py against eval/mock_olrapi.py. No creds, no live vault.
Run: python test_offline_queue.py [--port 8840]
"""
from __future__ import annotations
@@ -21,7 +21,7 @@ import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
ECHO = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts" / "echo.py"
CHORUS = HERE.parent / "chorus-memory.plugin.src" / "skills" / "chorus-memory" / "scripts" / "chorus.py"
KEY = "test-key-not-a-real-secret"
DEAD = "http://127.0.0.1:59998" # nothing listens here -> connection refused
@@ -58,10 +58,10 @@ def main():
mock_base = f"http://127.0.0.1:{a.port}"
state = tempfile.mkdtemp()
def echo(base, *args):
env = dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_STATE_DIR=state,
ECHO_VERIFY="0", ECHO_TODAY="2026-06-22")
return subprocess.run([sys.executable, str(ECHO), *args], capture_output=True, text=True, env=env)
def chorus(base, *args):
env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_STATE_DIR=state,
CHORUS_VERIFY="0", CHORUS_TODAY="2026-06-22")
return subprocess.run([sys.executable, str(CHORUS), *args], capture_output=True, text=True, env=env)
def start_mock():
p = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi.py"), "--port", str(a.port)],
@@ -80,10 +80,10 @@ def main():
srv = None
try:
# --- Phase 1: vault DOWN -> writes are queued, not lost ------------------
r = echo(DEAD, "put", "_agent/sessions/2026-06-22-1200-x.md", tmp("EVALMARK-session\n"))
r = chorus(DEAD, "put", "_agent/sessions/2026-06-22-1200-x.md", tmp("EVALMARK-session\n"))
check("offline put exits 0 (queued)", r.returncode == 0, r.stderr)
check("offline put reports queued", "queued (offline)" in r.stdout, r.stdout)
r = echo(DEAD, "append", "inbox/captures/inbox.md", "- 2026-06-22: EVALMARK-inbox")
r = chorus(DEAD, "append", "inbox/captures/inbox.md", "- 2026-06-22: EVALMARK-inbox")
check("offline append reports queued", "queued (offline)" in r.stdout, r.stdout)
outbox = Path(state) / "outbox.ndjson"
check("two writes are persisted to the outbox",
@@ -91,27 +91,27 @@ def main():
# --- Phase 2: vault UP -> flush replays them -----------------------------
srv = start_mock()
r = echo(mock_base, "flush")
r = chorus(mock_base, "flush")
check("flush replays the queue", "flushed 2" in r.stdout, r.stdout + r.stderr)
check("queued PUT landed in the vault", (ground("_agent/sessions/2026-06-22-1200-x.md") or "").find("EVALMARK-session") >= 0)
check("queued APPEND landed in the vault", "EVALMARK-inbox" in (ground("inbox/captures/inbox.md") or ""))
check("outbox is empty after a clean flush", not outbox.exists() or not outbox.read_text(encoding="utf-8").strip())
# idempotent replay: a second flush of the same content must not duplicate.
echo(DEAD, "append", "inbox/captures/inbox.md", "- 2026-06-22: EVALMARK-inbox") # re-queue same line
echo(mock_base, "flush")
chorus(DEAD, "append", "inbox/captures/inbox.md", "- 2026-06-22: EVALMARK-inbox") # re-queue same line
chorus(mock_base, "flush")
inbox = ground("inbox/captures/inbox.md") or ""
check("replay is idempotent (no duplicate line)", inbox.count("EVALMARK-inbox") == 1, inbox)
# --- Phase 3: vault UP -> load caches the orientation reads ---------------
http("PUT", f"{mock_base}/vault/_agent/echo-vault.md", "schema_version: 4\nEVALMARK-marker\n")
r = echo(mock_base, "load")
http("PUT", f"{mock_base}/vault/_agent/chorus-vault.md", "schema_version: 4\nEVALMARK-marker\n")
r = chorus(mock_base, "load")
check("online load shows marker", "EVALMARK-marker" in r.stdout, r.stdout)
check("load wrote a cache dir", (Path(state) / "cache").exists())
# --- Phase 4: vault DOWN -> load serves the cache ------------------------
srv.terminate(); srv.wait(timeout=5); srv = None
r = echo(DEAD, "load")
r = chorus(DEAD, "load")
check("offline load flags OFFLINE", "OFFLINE" in r.stdout, r.stdout)
check("offline load serves cached marker", "EVALMARK-marker" in r.stdout, r.stdout)
+13 -13
View File
@@ -3,7 +3,7 @@
These are exactly the behaviors the shipped naive mock cannot reproduce (it appends
everything at EOF), so without this the section-replace / section-append / missing-
heading / frontmatter-replace paths are untested. Drives the real echo.py against
heading / frontmatter-replace paths are untested. Drives the real chorus.py against
mock_olrapi_hifi.py.
Run: python test_patch_semantics.py [--port 8820]
@@ -20,7 +20,7 @@ import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
ECHO = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts" / "echo.py"
CHORUS = HERE.parent / "chorus-memory.plugin.src" / "skills" / "chorus-memory" / "scripts" / "chorus.py"
KEY = "test-key-not-a-real-secret"
failures = []
@@ -58,9 +58,9 @@ def main():
srv = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi_hifi.py"), "--port", str(a.port)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
def echo(*args):
env = dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_VERIFY="0")
return subprocess.run([sys.executable, str(ECHO), *args], capture_output=True, text=True, env=env)
def chorus(*args):
env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_VERIFY="0")
return subprocess.run([sys.executable, str(CHORUS), *args], capture_output=True, text=True, env=env)
def ground(path):
_, body = http("GET", f"{base}/__debug__?path={path}")
@@ -80,30 +80,30 @@ def main():
# replace: Scope body becomes new; Other untouched.
http("PUT", f"{base}/vault/{doc}", seed)
echo("patch", doc, "replace", "heading", "Doc::Scope", tmp("new scope"))
chorus("patch", doc, "replace", "heading", "Doc::Scope", tmp("new scope"))
g = ground(doc) or ""
check("heading replace swaps section body", "new scope" in g and "old scope" not in g, g)
check("heading replace leaves sibling section intact", "## Other\nkeep me" in g, g)
# append: lands INSIDE the section (before '## Other'), not at EOF.
echo("patch", doc, "append", "heading", "Doc::Scope", tmp("appended line"))
chorus("patch", doc, "append", "heading", "Doc::Scope", tmp("appended line"))
g = ground(doc) or ""
check("heading append inserts within the section (not EOF)",
"appended line" in g and g.index("appended line") < g.index("## Other"), g)
# prepend: lands at the TOP of the section body.
echo("patch", doc, "prepend", "heading", "Doc::Scope", tmp("first line"))
chorus("patch", doc, "prepend", "heading", "Doc::Scope", tmp("first line"))
g = ground(doc) or ""
check("heading prepend inserts at section top",
"first line" in g and g.index("first line") < g.index("new scope"), g)
# missing heading -> 400 -> echo.py exits non-zero (the silent-loss guard).
r = echo("patch", doc, "append", "heading", "Doc::Nope", tmp("lost?"))
# missing heading -> 400 -> chorus.py exits non-zero (the silent-loss guard).
r = chorus("patch", doc, "append", "heading", "Doc::Nope", tmp("lost?"))
check("missing heading fails loud (non-zero exit)", r.returncode != 0, r.stderr)
check("missing heading does not write", "lost?" not in (ground(doc) or ""))
# frontmatter replace on an existing field updates it.
echo("fm", doc, "updated", "2026-06-22")
chorus("fm", doc, "updated", "2026-06-22")
g = ground(doc) or ""
check("frontmatter replace updates existing field", "updated: 2026-06-22" in g, g)
@@ -112,7 +112,7 @@ def main():
# every other line is preserved. (The old contract failed loud on exactly the
# notes that needed repair.)
before = ground(doc) or ""
r = echo("fm", doc, "nonexistent_field", "x")
r = chorus("fm", doc, "nonexistent_field", "x")
check("fm creates a missing field (create-or-replace)", r.returncode == 0,
r.stderr or r.stdout)
g = ground(doc) or ""
@@ -121,7 +121,7 @@ def main():
all(line in g for line in before.splitlines() if line.strip()), g)
# raw `patch replace frontmatter` (no fallback) still fails loud on a missing key.
r = echo("patch", doc, "replace", "frontmatter", "still_missing", tmp('"x"'))
r = chorus("patch", doc, "replace", "frontmatter", "still_missing", tmp('"x"'))
check("raw frontmatter PATCH of a missing field fails loud", r.returncode != 0, r.stderr)
print(f"\n{len(failures)} failure(s)" if failures else "\nall PATCH-semantics tests passed")
+10 -10
View File
@@ -3,7 +3,7 @@
A dry-run previews and writes NOTHING; --apply routes each proposal through capture
(creating notes, the inbox line, and skipping low-confidence items). Drives the real
echo.py against eval/mock_olrapi.py. No creds, no live vault.
chorus.py against eval/mock_olrapi.py. No creds, no live vault.
Run: python test_reflect.py [--port 8850]
"""
@@ -20,7 +20,7 @@ import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
ECHO = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts" / "echo.py"
CHORUS = HERE.parent / "chorus-memory.plugin.src" / "skills" / "chorus-memory" / "scripts" / "chorus.py"
KEY = "test-key-not-a-real-secret"
failures = []
@@ -50,9 +50,9 @@ def main():
except Exception as e: # noqa: BLE001
return getattr(e, "code", 0), ""
def echo(*args, stdin=None):
env = dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_VERIFY="1", ECHO_TODAY="2026-06-22")
return subprocess.run([sys.executable, str(ECHO), *args], input=stdin,
def chorus(*args, stdin=None):
env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_VERIFY="1", CHORUS_TODAY="2026-06-22")
return subprocess.run([sys.executable, str(CHORUS), *args], input=stdin,
capture_output=True, text=True, env=env)
def ground(path):
@@ -65,10 +65,10 @@ def main():
urllib.request.urlopen(f"{base}/__debug__reset", data=b"", timeout=1); break
except Exception:
time.sleep(0.1)
http("PUT", f"{base}/vault/_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n")
http("PUT", f"{base}/vault/_agent/chorus-vault.md", "---\nschema_version: 4\n---\n# marker\n")
proposals = [
{"title": "Acme Corp", "kind": "company", "body": "A vendor ECHO integrates with.", "confidence": 0.9},
{"title": "Acme Corp", "kind": "company", "body": "A vendor CHORUS integrates with.", "confidence": 0.9},
{"title": "Use uv not pip", "kind": "semantic", "body": "Jason standardizes on uv.", "confidence": 0.95},
{"title": "half-formed idea", "inbox": True, "confidence": 0.9},
{"title": "Maybe relevant", "kind": "concept", "confidence": 0.2}, # below floor -> skipped
@@ -77,13 +77,13 @@ def main():
json.dump(proposals, pfile); pfile.close()
# dry-run: previews, writes nothing
r = echo("reflect", pfile.name)
r = chorus("reflect", pfile.name)
check("dry-run previews", "dry-run" in r.stdout and "Acme Corp" in r.stdout, r.stdout)
check("dry-run drops the low-confidence proposal", "Maybe relevant" not in r.stdout, r.stdout)
check("dry-run writes nothing", ground("resources/companies/acme-corp.md") is None)
# --apply: routes each through capture
r = echo("reflect", pfile.name, "--apply")
r = chorus("reflect", pfile.name, "--apply")
check("apply reports applied count", "applied" in r.stdout, r.stdout + r.stderr)
check("apply creates the company note",
(ground("resources/companies/acme-corp.md") or "").find("type: company") >= 0)
@@ -94,7 +94,7 @@ def main():
ground("resources/concepts/maybe-relevant.md") is None)
# stdin path also works (proposals piped, not a file)
r = echo("reflect", "-", stdin=json.dumps([{"title": "Piped Co", "kind": "company", "confidence": 0.9}]))
r = chorus("reflect", "-", stdin=json.dumps([{"title": "Piped Co", "kind": "company", "confidence": 0.9}]))
check("reflect reads proposals from stdin", "Piped Co" in r.stdout, r.stdout + r.stderr)
print(f"\n{len(failures)} failure(s)" if failures else "\nall reflect tests passed")