Config schema {owner,endpoint,key} -> {group, member, endpoint, key}:
- member (kebab-case slug, validated) is REQUIRED — who this machine
writes as; group is the descriptive team name. CHORUS_GROUP/
CHORUS_MEMBER env, config set --group/--member, doctor + config show
both with sources, NOT-CONFIGURED (78) without a valid member.
- capture stamps author: <member> on every note (_build_note); bootstrap
gains {{MEMBER}} substitution and stamps the seeded anchors; all 8
scaffold templates + canonical frontmatter docs gain author:.
- New missing-author lint check: agent_written notes must carry author:
(bootstrap marker exempt — plugin-owned).
- Lock owner is now <member>-<client>-<pid> (auto_owner); offline-queue
records carry a member field for audit.
- build.py --bake-key bakes group/member/endpoint/key (--group/--member;
member required + slug-validated) for per-member artifacts.
- Manifest -> 2.0.0-alpha.2; rebuilt chorus-memory.plugin.
Verified: 25/25 unit (+config/member checks), scaffold suite (+6 new
member/config assertions), routing-sync, 4 mock e2e (+capture-stamps-
author), run_eval metrics unchanged, +8 phase-2 smoke checks green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 KiB
CHORUS — Group-Based Memory: Conversion Plan
Fork of: jason/echo (ECHO v1.5.1, schema 4) → jason/chorus
Goal: convert the single-operator ECHO memory plugin into CHORUS — one shared
Obsidian vault serving a group of members, with per-member identity,
attribution, and isolation of the churn-prone per-person state, while keeping the
shared knowledge graph (projects, areas, resources, decisions) communal.
Naming metaphor: ECHO is one voice reflected back; CHORUS is many voices in one performance.
1. What the review found
ECHO is ~4,800 LOC of pure-stdlib Python (18 scripts) + a large doc surface (SKILL.md ~456 lines, 6 references, 8 commands, 15 scaffold seeds). The architecture is already favorable for this conversion:
- Identity is barely wired in.
echo.pyloadsOWNERfrom config but never actually consumes it — attribution today is purely doc-driven ("write in third person about Jason"). Adding amemberidentity breaks nothing. - Routing is data-driven.
routing.jsonis the single source of truth for "what may be written where";vault_lint.pyandcheck_routing.pyenforce it. Per-member paths are new route rows, not new code paths. - The vault is self-describing. Bootstrap/migrate/sweep +
schema_versionin the marker give us a clean upgrade lever (schema 4 → 5).
The hard part is not identity plumbing — it's the five shared single-writer artifacts that make no sense (or clobber) with multiple concurrent users:
| Artifact | Today | Problem in a group |
|---|---|---|
_agent/memory/semantic/operator-preferences.md |
One profile | Whose preferences? |
_agent/context/current-context.md |
One active scope for the vault | Members work on different things simultaneously |
_agent/heartbeat/last-session.md |
One "last session" pointer (PUT overwrite) | Race: lost updates; meaningless shared |
inbox/captures/inbox.md |
One shared inbox (POST append) | Append races; triage can't tell whose capture |
journal/daily/YYYY-MM-DD.md ## Agent Log |
One shared heading | Duplicate-heading races; unattributed lines |
Also single-user by design: session filenames carry no author
(YYYY-MM-DD-HHMM-<slug>.md — two members can collide on the same minute),
frontmatter has no author: field anywhere, the offline queue is per-machine
with no member attribution, and the advisory lock owner is a session id, not a
person. Concurrency is advisory-only — vault_lock() warns and proceeds if
it can't acquire (echo_concurrency.py:78–81), which is acceptable for one person
on two machines but not for N people.
2. Target design
2.1 Identity model
- Config gains a
memberfield:{ "group": "...", "member": "jason", "endpoint": "https://chorusapi.mpm.to", "key": "..." }. Deployment endpoint:https://chorusapi.mpm.to— the CHORUS vault gets its own Obsidian + Local REST API instance exposed there (separate from ECHO'sechoapi.alwisp.com; the personal ECHO vault is untouched). The endpoint stays machine-local/baked config, never in source, same as ECHO.owner→group. Resolution order unchanged: baked > env (CHORUS_MEMBERetc.) > config file. Baked per-user builds (build.py --bake-key --label alice) already exist and become the primary onboarding mechanism: one artifact per member, member id baked in. - Every agent-written note gets
author: <member>stamped in frontmatter (echo_ops._build_note), added to the canonical frontmatter block, toKIND_REQUIRED_FMfor agent-written kinds, linted byvault_lint, and backfilled bysweep --apply(existing notes → the migrating member). - Lock owner and offline-queue entries carry the member id
(
auto_owner()→f"{member}-{client}-{pid}"), so contention and replay are attributable.
Auth reality check: the Obsidian Local REST API has one bearer key per
vault. Phase 1–4 ship with a shared key and honor-system attribution (fine for
a trusted team). Real per-member credentials/revocation would need a thin auth
proxy in front of chorusapi.mpm.to that maps per-member tokens → the vault
key and can inject/verify the member id. That's deliberately out of scope
here but the config shape (member + per-member baked artifacts) is designed so
the proxy slots in later without another migration.
2.2 Vault layout changes (schema 5)
Per-member state moves under a member namespace; shared knowledge stays where it is.
_agent/
├── chorus-vault.md ← renamed marker (schema_version: 5, managed_by: chorus-memory-plugin)
├── members/
│ └── <member>/
│ ├── preferences.md ← was operator-preferences.md (one per member)
│ ├── current-context.md ← per-member scope + Scope History
│ ├── heartbeat.md ← per-member last-session pointer
│ └── inbox.md ← per-member quick-capture inbox
├── memory/
│ └── semantic/
│ └── group-profile.md ← NEW: shared facts about the group itself
├── sessions/
│ └── <member>/YYYY-MM-DD-HHMM-<slug>.md ← member-namespaced session logs
└── (context/, memory/, health/, index/, locks/, templates/, skills/ unchanged — shared)
- Shared and unchanged:
projects/,areas/,resources/,decisions/,journal/, semantic/episodic/working memory (now withauthor:), the entity index, recall index, health notes. - Daily Agent Log stays one shared heading (it's the group's timeline) but
every line is member-prefixed:
- HH:MM [alice] did X.ensure_daily_logkeeps its GET→POST-heading→PATCH flow; idempotent whole-line append plus the member prefix makes interleaving safe and attributed. (Per-member daily notes were considered and rejected — the whole point of a group vault is one shared timeline.) - Inbox: per-member capture files (
_agent/members/<id>/inbox.md) kill the append race and make triage member-aware, plus one sharedinbox/captures/group.mdfor unowned "someone should look at this" items (member-prefixed lines; routed by whoever triages first). Triage covers both: accepted items route to shared homes; preference-type items route to that member'spreferences.md. Processing-log lines carry the member id. resources/people/<member>.mdcontinues to exist for every member — the public "who is this person" note, distinct from their private-ishpreferences.md(how the agent should behave for them).
2.3 Cold-start load (per member)
chorus.py load reads: marker → group-profile → my preferences → my
current-context → my heartbeat/last-session (fallback: my sessions dir) →
today's shared daily note → my inbox depth → group inbox depth. Optional --all-members flag
for a group-wide orientation (e.g., "what has the team been doing"). Reconcile
(inbox aging, scope drift) runs against the member's own state only.
2.4 Concurrency posture
Namespacing removes ~all of the contention (each member's churn-prone files are theirs alone). What remains shared-write:
- Entity + recall indexes — keep
vault_lock+atomic_index_update, but flip the lock to required (required=True) on index writes: block/retry briefly instead of warn-and-proceed. TTL reclaim unchanged. - Daily note / shared notes — idempotent member-prefixed appends; PATCH to headings is last-writer-wins per line, which is acceptable for log lines.
- Duplicate gate — unchanged and global (dedup across the group is the
feature: two members shouldn't create parallel notes for the same client).
Gate messages name the
authorof the existing candidate.
2.5 Recall / reflection
- Corpus unchanged (shared graph is the point).
recallgains an optional--author <member>filter and surfacesauthor:in hits. reflectproposals route preference items to the current member'spreferences.md; everything else routes to shared homes withauthor:stamped. The Stop-hook nudge state stays per-machine (already is).
3. Renaming map (echo → chorus)
| Category | From | To |
|---|---|---|
| Plugin | echo-memory (plugin.json, skill dir, SKILL.md name) |
chorus-memory |
| Commands | /echo-{load,save,recall,reflect,triage,sweep,health,doctor} |
/chorus-* |
| Scripts | echo*.py (16 modules + imports everywhere) |
chorus*.py |
| Exception | EchoError |
ChorusError |
| Env vars | ECHO_OWNER/BASE/KEY/CONFIG/TODAY/VERIFY/LOCK_TTL/TIMEOUT/WORKERS/STATE_DIR/CLIENT/DUP_GATE/REFLECT_MIN_TURNS/FRESH_HALF_LIFE |
CHORUS_* (+ new CHORUS_MEMBER) |
| Config file | ~/.claude/echo-memory/config.json |
~/.claude/chorus-memory/config.json |
| State dir | ~/.echo-memory/ |
~/.chorus-memory/ |
| Marker | _agent/echo-vault.md (hardcoded in 8 files + routing.json) |
_agent/chorus-vault.md |
| Hooks | hooks.json paths ×2 incl. CoWork fallback glob |
updated paths |
| Docs/branding | README, SKILL.md, references, scaffold seeds, icons | rewrite pass (cosmetic but large) |
operator-preferences.md is renamed as part of the layout change
(§2.2), not the branding pass.
4. Phased plan
Phase 0 — Baseline (½ day)
Repo hygiene on the fork: strip stale versioned .plugin artifacts from the
tree (move to Gitea releases per ROADMAP-2.0), confirm eval/ suites green
as-is, tag the fork point.
Phase 1 — Mechanical rename (1–2 days) — DONE
Everything in §3. All existing tests pass under the new names. No behavior
change. Deliverable: installable chorus-memory plugin that is
ECHO-with-a-new-name. (Decided post-Phase-1: CHORUS is a clean break —
fresh vaults on fresh machines for new users, so no ECHO back-compat: legacy
ECHO_* env vars, echo-memory configs, ~/.echo-memory state, and
_agent/echo-vault.md markers are all ignored. Shims briefly existed and were
removed.)
Phase 2 — Identity plumbing (1–2 days) — DONE
member in config (+ CHORUS_MEMBER, baked-tier support, config set --member
with slug validation; owner → group; member REQUIRED for is_configured),
MEMBER global in chorus.py, author: stamping in _build_note + bootstrap
{{MEMBER}} anchor stamping + templates, canonical frontmatter docs + a
missing-author lint check (agent_written notes must carry author:; sweep
does NOT backfill — attribution can't be guessed), member-aware lock owner
(<member>-<client>-<pid>) and queue records, doctor reports member/group.
Vault layout untouched. Deliverable: every new write is attributed;
single-user behavior otherwise identical.
Phase 3 — Per-member namespacing (3–5 days — the core)
Schema 5. New LEAVES + seeds (members/<id>/…, group-profile.md),
member-parameterized bootstrap (bootstraps your member subtree on first load
if the vault exists but your namespace doesn't — new members self-onboard),
cmd_load/cmd_scope/heartbeat/inbox/triage/reflect retargeted to member
paths, session logs under sessions/<member>/, member-prefixed Agent Log
lines, routing.json route rows (with <member> pattern segment) + retired
old paths, lint updates, migrate.py 4→5 (for any alpha CHORUS vaults stood
up between phases — there is no ECHO-vault adoption path; CHORUS vaults start
fresh). Docs: vault-layout.md, routing-map.md, bootstrap.md rewritten.
Deliverable: a real multi-member vault, bootstrapped from empty.
Phase 4 — Shared-write hardening (1–2 days)
Required lock on index writes with bounded retry/backoff, gate messages carry
author, recall --author, load --all-members, concurrency docs
(operating-contract.md) rewritten for N writers.
Phase 5 — Docs, onboarding & eval (2–3 days)
SKILL.md full rewrite (operator → member voice; drop the "single owner"
safety rule, replace with group rules: don't edit another member's
preferences.md except via their session; shared notes are fair game),
8 command docs, README. Onboarding runbook: per-member baked builds. Extend
eval/: two-member mock scenarios (concurrent capture, interleaved daily-log
appends, index race, cross-member dedup gate, member-scoped load) + rerun the
retrieval/durability metrics. New icon.
Total: roughly 9–15 working days, shippable at every phase boundary (1 = rename, 2 = attribution, 3 = groups).
5. Decisions (settled with Jason, 2026-07-21)
- Journal rollups — shared. One group rollup per period
(
journal/weekly/YYYY-Www.mdetc.) with member-attributed bullets inside. No per-member rollup trees. - Preference privacy — group-visible, documented. Member
preferences.mdfiles live in the shared vault and are readable by every member (one vault, one key — no read ACLs possible). SKILL.md documents this explicitly; anything a member considers private belongs in their own personal ECHO vault, not CHORUS. - Auth proxy — out of scope; shared key first. Phases 1–5 ship with the
shared vault key and honor-system attribution. The per-member token proxy
(namespace write ACL, server-side audit, per-member revocation) is a later
standalone project; the config shape (
member+ per-member baked artifacts) already accommodates it with zero plugin changes. - Commands —
/chorus-*. Full-prefix names (/chorus-load,/chorus-save, …), consistent with the ECHO pattern and collision-free if both plugins are installed. - Inboxes — per-member + shared group inbox. Each member gets
_agent/members/<id>/inbox.md; a sharedinbox/captures/group.mdholds unowned items (member-prefixed lines, idempotent appends), routed by whoever triages first. Both are probed at load and covered by triage. - Clean break from ECHO (added post-Phase-1). CHORUS vaults are fresh
vaults on fresh machines for new users — no ECHO adoption path, no legacy
ECHO_*env/config/state/marker compatibility. Jason's personal ECHO vault continues unchanged on its own plugin and endpoint. If ECHO history is ever wanted in CHORUS, it would be a one-off import exercise, out of scope.