forked from jason/echo
33a2d73a22
One shared vault, per-member namespaces for all churn-prone state:
- _agent/members/<member>/{preferences,current-context,heartbeat,inbox}.md
+ _agent/sessions/<member>/ session logs; shared group-profile.md
(with auto-rostered ## Members) and inbox/captures/group.md.
- Self-onboarding: load detects a bootstrapped vault lacking THIS
member's namespace and seeds it (bootstrap.member_bootstrap()).
- load = 8 reads (marker, group profile, my prefs/context/heartbeat,
shared daily, my inbox, group inbox); heartbeat fallback lists my
sessions dir; scope show/set is per member.
- Member-tagged shared writes: daily Agent Log lines and entity-update
dated blocks are '- YYYY-MM-DD [member]: …'; capture --inbox targets
my inbox; triage lists both inboxes and writes attributed audit lines.
- routing.json: member-anchors / inbox-group / member-segmented session
routes; single-user anchor paths retired (schema 5). vault_lint runs
aging-inbox + scope-drift per member. migrate.py 4->5 for alpha vaults.
- Scaffold: member-* seeds ({{MEMBER}}-stamped), group-profile/group-inbox
seeds, marker schema 5. Docs (SKILL, vault-layout, routing-map,
bootstrap) synced; manifest -> 2.0.0-alpha.3; rebuilt plugin (81 files).
Verified: all suites green (25/25 unit, scaffold, routing-sync 36 routes,
4 mock e2e, run_eval unchanged) + 19-check two-member smoke: bootstrap ->
self-onboard -> interleaved tagged daily log -> cross-member update
attribution -> independent scopes -> mine/group triage -> lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
258 lines
14 KiB
Markdown
258 lines
14 KiB
Markdown
# 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.py` loads `OWNER` from config but never
|
||
actually consumes it — attribution today is purely doc-driven ("write in third
|
||
person about Jason"). Adding a `member` identity breaks nothing.
|
||
- **Routing is data-driven.** `routing.json` is the single source of truth for
|
||
"what may be written where"; `vault_lint.py` and `check_routing.py` enforce it.
|
||
Per-member paths are new route rows, not new code paths.
|
||
- **The vault is self-describing.** Bootstrap/migrate/sweep + `schema_version`
|
||
in 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 `member` field:
|
||
`{ "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's `echoapi.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_MEMBER` etc.) > 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, to
|
||
`KIND_REQUIRED_FM` for agent-written kinds, linted by `vault_lint`, and
|
||
backfilled by `sweep --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 with `author:`), 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_log`
|
||
keeps 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 shared
|
||
`inbox/captures/group.md` for 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's* `preferences.md`. Processing-log lines carry the member id.
|
||
- `resources/people/<member>.md` continues to exist for every member — the
|
||
public "who is this person" note, distinct from their private-ish
|
||
`preferences.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 `author` of the existing candidate.
|
||
|
||
### 2.5 Recall / reflection
|
||
|
||
- Corpus unchanged (shared graph is the point). `recall` gains an optional
|
||
`--author <member>` filter and surfaces `author:` in hits.
|
||
- `reflect` proposals route preference items to the current member's
|
||
`preferences.md`; everything else routes to shared homes with `author:`
|
||
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) — DONE
|
||
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)
|
||
|
||
1. **Journal rollups — shared.** One group rollup per period
|
||
(`journal/weekly/YYYY-Www.md` etc.) with member-attributed bullets inside.
|
||
No per-member rollup trees.
|
||
2. **Preference privacy — group-visible, documented.** Member `preferences.md`
|
||
files 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.
|
||
3. **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.
|
||
4. **Commands — `/chorus-*`.** Full-prefix names
|
||
(`/chorus-load`, `/chorus-save`, …), consistent with the ECHO pattern and
|
||
collision-free if both plugins are installed.
|
||
5. **Inboxes — per-member + shared group inbox.** Each member gets
|
||
`_agent/members/<id>/inbox.md`; a shared `inbox/captures/group.md` holds
|
||
unowned items (member-prefixed lines, idempotent appends), routed by
|
||
whoever triages first. Both are probed at load and covered by triage.
|
||
6. **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.
|