forked from jason/echo
Phase 2: identity plumbing — group/member config, author: attribution
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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chorus-memory",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"version": "2.0.0-alpha.2",
|
||||
"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"
|
||||
|
||||
@@ -20,26 +20,29 @@ Architected by **Jason Stedwell**.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin ships **no** owner, endpoint, or key. On each machine it installs on, provide a machine-local JSON config:
|
||||
The plugin ships **no** group, member, endpoint, or key. On each machine it installs on, provide a machine-local JSON 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)",
|
||||
"group": "Group Name — the team/group sharing this vault",
|
||||
"member": "<your-member-id — kebab-case slug, e.g. jason>",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>"
|
||||
}
|
||||
```
|
||||
|
||||
`member` is **required** — it is stamped as `author:` on every agent write, so the shared vault always knows which member's session wrote what.
|
||||
|
||||
Set it up on a fresh install by copying the bundled template and filling it in, or via the client:
|
||||
|
||||
```bash
|
||||
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 set --group "…" --member "your-id" --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: `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.
|
||||
Per field, an environment variable overrides the file: `CHORUS_GROUP`, `CHORUS_MEMBER`, `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)
|
||||
|
||||
|
||||
@@ -7,18 +7,19 @@ description: Use the CHORUS Obsidian vault as the operator's persistent memory a
|
||||
|
||||
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/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.
|
||||
The vault is shared **group memory**: one vault, many members. Each machine is configured with the **group** (who shares the vault) and this machine's **member id** (who is writing — stamped as `author:` on every agent write), in `~/.claude/chorus-memory/config.json`. Write memory in third person about people ("Jason prefers X", not "I prefer X") so the vault stays readable by every member and agent.
|
||||
|
||||
## API Configuration
|
||||
|
||||
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.
|
||||
The group, member, 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_GROUP`, `CHORUS_MEMBER`, `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.
|
||||
Exception — **per-member baked builds:** a `build.py --bake-key` artifact carries the group/member/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" / $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>
|
||||
member = <config "member" / $CHORUS_MEMBER — REQUIRED: this member's kebab-case id, stamped as author: on writes>
|
||||
group = <config "group" / $CHORUS_GROUP — display name for the group sharing the vault>
|
||||
```
|
||||
|
||||
### First run — set up the machine if it isn't configured
|
||||
@@ -26,10 +27,10 @@ owner = <config "owner" / $CHORUS_OWNER — how to refer to the vault owner,
|
||||
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` (`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.
|
||||
2. **Ask the member** — 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 `group`, their `member` id, the `endpoint`, and the API `key`), or to paste those values. Don't invent or guess them — especially not the member id, which attributes every write — and don't proceed with memory until it's set.
|
||||
3. **Install what they give you:**
|
||||
- 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 "…"`.
|
||||
- They paste values → `python3 "$CHORUS" config set --group "…" --member "their-id" --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.
|
||||
@@ -88,7 +89,7 @@ python3 "$CHORUS" link <pathA> <pathB> [--json] # add reciprocal [[Relate
|
||||
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.
|
||||
- **`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/author/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 `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.
|
||||
@@ -214,7 +215,7 @@ Write when the operator:
|
||||
- Says "remember that", "save this", "log this", "add to memory", "note that"
|
||||
- Finishes a meaningful working session future sessions should pick up
|
||||
|
||||
Write in third person about the operator. Every note carries the canonical frontmatter (see below). Agent-generated notes set `agent_written: true`.
|
||||
Write in third person about the operator. Every note carries the canonical frontmatter (see below). Agent-generated notes set `agent_written: true` and `author: <member>` (your configured member id).
|
||||
|
||||
**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.
|
||||
|
||||
@@ -269,7 +270,7 @@ python3 "$CHORUS" fm <path> status active
|
||||
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`.
|
||||
Every PUT body needs the canonical YAML frontmatter (see `references/vault-layout.md`); set `agent_written: true`, `author: <your-member-id>`, 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`.
|
||||
|
||||
## Scope Switching (`current-context.md`)
|
||||
|
||||
@@ -431,7 +432,7 @@ If the API returns a connection error, timeout, or `502`, tell the operator once
|
||||
- This vault holds only its own owner's memory. Do not cross-write between distinct vaults/owners — another person's vault and preferences belong in their own vault, not here.
|
||||
- **Anchor relative dates on the conversation's `currentDate`** before writing. "Today" → `currentDate`. "Thursday" / "next week" → resolve to an absolute `YYYY-MM-DD`. Never guess from training-data knowledge of the current year.
|
||||
- Every memory file has canonical YAML frontmatter — see `references/vault-layout.md`.
|
||||
- Set `agent_written: true` on agent-generated notes and list `source_notes` (plain relative paths, not links).
|
||||
- Set `agent_written: true` and `author: <your-member-id>` on agent-generated notes and list `source_notes` (plain relative paths, not links).
|
||||
- **`created:` is the earliest known date the entity was tracked anywhere in the vault, not "today".** When merging notes (e.g. promoting `on-hold/` → `active/`), preserve the earliest `created:` and only bump `updated:`.
|
||||
- **`source_notes` lists the note(s) that *triggered* or *supplied content for* this one** — e.g. the session log that produced a project update, or the daily note where a captured fact originated. It is a *backward* link to inputs. Forward links (this note → other notes it references) belong in the `## Related` section in the note body, never in frontmatter.
|
||||
- **Never put `[[wikilinks]]` in frontmatter** — YAML parses them as nested lists and the links break in Obsidian's reading view. Put all cross-references in a `## Related` section in the note **body** as a bulleted list of `[[links]]`.
|
||||
|
||||
@@ -73,6 +73,9 @@ tags: [] # REQUIRED (non-empty) on entity notes — capture seeds the ki
|
||||
aliases: [] # optional — other names this entity is called (Obsidian-native). Folded
|
||||
# into the entity index so a shortened/expanded name resolves to this note.
|
||||
agent_written: false
|
||||
author: # member id of the session that wrote this note — REQUIRED when
|
||||
# agent_written is true (capture stamps the configured member;
|
||||
# /chorus-health flags missing-author)
|
||||
source_notes: [] # plain relative paths as strings — NEVER [[wikilinks]]
|
||||
---
|
||||
```
|
||||
@@ -80,9 +83,12 @@ 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 `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
|
||||
not rewrite frontmatter — the append goes after existing content. To change
|
||||
`updated:` or `status:`, use PATCH with `Target-Type: frontmatter`.
|
||||
agent-managed content from human-authored content. `author:` records **which
|
||||
member's session** wrote the note — CHORUS is group memory, so every agent write
|
||||
is attributed (`capture` stamps it from the machine's configured `member`; when
|
||||
writing a note by hand with PUT, set it to your member id). When appending with
|
||||
POST, do not rewrite frontmatter — the append goes after existing content. To
|
||||
change `updated:` or `status:`, use PATCH with `Target-Type: frontmatter`.
|
||||
|
||||
**Frontmatter field semantics:**
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ created: {{DATE}}
|
||||
updated: {{DATE}}
|
||||
tags: [agent, context]
|
||||
agent_written: true
|
||||
author: {{MEMBER}}
|
||||
source_notes: []
|
||||
scope:
|
||||
scope_updated: {{DATE}}
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ created: {{DATE}}
|
||||
updated: {{DATE}}
|
||||
tags: [agent, semantic-memory, operator]
|
||||
agent_written: true
|
||||
author: {{MEMBER}}
|
||||
source_notes: []
|
||||
confidence: low
|
||||
last_reviewed: {{DATE}}
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [agent, context]
|
||||
agent_written: true
|
||||
author:
|
||||
source_notes: []
|
||||
scope:
|
||||
refresh_strategy: on-demand
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [agent, semantic-memory]
|
||||
agent_written: true
|
||||
author:
|
||||
source_notes: []
|
||||
confidence: high
|
||||
last_reviewed: {{date:YYYY-MM-DD}}
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DDTHH:mm}}
|
||||
updated: {{date:YYYY-MM-DDTHH:mm}}
|
||||
tags: [agent, session]
|
||||
agent_written: true
|
||||
author:
|
||||
source_notes: []
|
||||
session_date: {{date:YYYY-MM-DD}}
|
||||
client: claude-code
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DDTHH:mm}}
|
||||
updated: {{date:YYYY-MM-DDTHH:mm}}
|
||||
tags: [agent, working-memory]
|
||||
agent_written: true
|
||||
author:
|
||||
source_notes: []
|
||||
expires_after: 48h
|
||||
---
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [decision]
|
||||
agent_written: true
|
||||
author:
|
||||
source_notes: []
|
||||
decision_date: {{date:YYYY-MM-DD}}
|
||||
impact: medium
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [daily, journal]
|
||||
agent_written: false
|
||||
author:
|
||||
source_notes: []
|
||||
---
|
||||
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ created: {{date:gggg-[W]WW}}
|
||||
updated: {{date:gggg-[W]WW}}
|
||||
tags: [review, weekly]
|
||||
agent_written: true
|
||||
author:
|
||||
source_notes: []
|
||||
period: weekly
|
||||
---
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [project]
|
||||
agent_written: false
|
||||
author:
|
||||
source_notes: []
|
||||
owner:
|
||||
review_cycle: weekly
|
||||
|
||||
@@ -59,7 +59,9 @@ 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}}", chorus.today())
|
||||
text = (source.read_text(encoding="utf-8")
|
||||
.replace("{{DATE}}", chorus.today())
|
||||
.replace("{{MEMBER}}", chorus.MEMBER or ""))
|
||||
put_text(path, text)
|
||||
print(f"bootstrap: seeded {path}")
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ reused across requests), and `read_many` fans bulk GETs across a thread pool —
|
||||
full-vault scripts (sweep, lint, recall rebuild) make a handful of warm round-trips
|
||||
instead of hundreds of fresh TLS handshakes, and no longer blow past tool timeouts.
|
||||
|
||||
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).
|
||||
Config (group/member/endpoint/key are machine-local — see chorus_config; the plugin ships none):
|
||||
group/member/endpoint/key resolved by chorus_config from ~/.claude/chorus-memory/config.json
|
||||
(env CHORUS_GROUP / CHORUS_MEMBER / 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.py config set --group ... --member ... --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)
|
||||
@@ -48,7 +48,7 @@ Usage:
|
||||
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)
|
||||
chorus.py config [show|init|set|import] # machine-local group/member/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.
|
||||
@@ -80,7 +80,7 @@ for _stream in (sys.stdout, sys.stderr):
|
||||
pass
|
||||
|
||||
# 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).
|
||||
# ~/.claude/chorus-memory/config.json (env CHORUS_GROUP / CHORUS_MEMBER / 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).
|
||||
@@ -89,7 +89,8 @@ import chorus_config # noqa: E402
|
||||
_CFG = chorus_config.load()
|
||||
BASE = _CFG["endpoint"]
|
||||
KEY = _CFG["key"]
|
||||
OWNER = _CFG["owner"]
|
||||
GROUP = _CFG["group"]
|
||||
MEMBER = _CFG["member"] # this machine's member id — stamped as author: on writes
|
||||
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
|
||||
@@ -141,11 +142,11 @@ def _require_config() -> None:
|
||||
untouched `config init` template (placeholder endpoint/key)."""
|
||||
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 "
|
||||
"chorus: NOT CONFIGURED — no usable CHORUS config on this machine. Ask the "
|
||||
f"member for their config (group/member/endpoint/key) and install it at "
|
||||
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)
|
||||
"`chorus.py config set --group ... --member ... --endpoint ... --key ...` "
|
||||
"(or set CHORUS_BASE / CHORUS_KEY / CHORUS_MEMBER).", 2)
|
||||
|
||||
|
||||
def _new_connection() -> http.client.HTTPConnection:
|
||||
@@ -613,10 +614,10 @@ def cmd_load() -> int:
|
||||
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("ACTION: ask the member for their chorus-memory config file (it holds the")
|
||||
print(" group, member id, endpoint, and API key), then install it with ONE of:")
|
||||
print(" python3 chorus.py config import <path-to-their-file>")
|
||||
print(" python3 chorus.py config set --owner \"…\" --endpoint \"https://…\" --key \"…\"")
|
||||
print(" python3 chorus.py config set --group \"…\" --member \"…\" --endpoint \"https://…\" --key \"…\"")
|
||||
print("Then re-run load. (Memory is unavailable until configured.)")
|
||||
return 78 # distinct: configuration required
|
||||
targets = [
|
||||
@@ -694,17 +695,17 @@ def cmd_load() -> int:
|
||||
|
||||
|
||||
def cmd_config(args) -> int:
|
||||
"""show | init | set the machine-local owner/endpoint/key config."""
|
||||
"""show | init | set the machine-local group/member/endpoint/key config."""
|
||||
path = chorus_config.config_path()
|
||||
if args.action == "init":
|
||||
p, created = chorus_config.init_template()
|
||||
print(f"ok: wrote config template to {p} — edit it with your owner/endpoint/key"
|
||||
print(f"ok: wrote config template to {p} — edit it with your group/member/endpoint/key"
|
||||
if created else f"config already exists at {p} (left unchanged)")
|
||||
return 0
|
||||
if args.action == "import":
|
||||
if not args.path:
|
||||
print("config import: pass the path to a config file "
|
||||
"(JSON with owner/endpoint/key)", file=sys.stderr)
|
||||
"(JSON with group/member/endpoint/key)", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
p = chorus_config.import_file(args.path)
|
||||
@@ -714,10 +715,15 @@ def cmd_config(args) -> int:
|
||||
print(f"ok: imported config to {p} (chmod 600 best-effort)")
|
||||
return 0
|
||||
if args.action == "set":
|
||||
if args.owner is None and args.endpoint is None and args.key is None:
|
||||
print("config set: pass at least one of --owner / --endpoint / --key", file=sys.stderr)
|
||||
if args.group is None and args.member is None and args.endpoint is None and args.key is None:
|
||||
print("config set: pass at least one of --group / --member / --endpoint / --key",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
p = chorus_config.write_config(args.owner, args.endpoint, args.key)
|
||||
if args.member is not None and not chorus_config.member_valid(args.member):
|
||||
print(f"config set: --member must be a kebab-case slug "
|
||||
f"(^[a-z0-9][a-z0-9-]{{0,31}}$), got: {args.member!r}", file=sys.stderr)
|
||||
return 2
|
||||
p = chorus_config.write_config(args.group, args.member, args.endpoint, args.key)
|
||||
print(f"ok: updated {p} (chmod 600 best-effort)")
|
||||
return 0
|
||||
# show — never prints the key in the clear
|
||||
@@ -725,7 +731,8 @@ def cmd_config(args) -> int:
|
||||
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)):
|
||||
for field, shown in (("group", cfg["group"]), ("member", cfg["member"]),
|
||||
("endpoint", cfg["endpoint"]), ("key", redacted)):
|
||||
src = chorus_config.source(field)
|
||||
print(f" {field:9} {shown or '(unset)'} [{src}]")
|
||||
return 0
|
||||
@@ -752,10 +759,11 @@ 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 chorus_config)
|
||||
p = sub.add_parser("config") # machine-local group/member/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")
|
||||
p.add_argument("--group"); p.add_argument("--member")
|
||||
p.add_argument("--endpoint"); p.add_argument("--key")
|
||||
p = sub.add_parser("scope")
|
||||
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
|
||||
p.add_argument("text", nargs="?")
|
||||
|
||||
@@ -31,10 +31,13 @@ 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
|
||||
"""A stable-per-process owner id: member + client tag + pid, so lock contention
|
||||
is attributable to a person, not just a process. Avoids Math.random/time-based
|
||||
ids; pid is unique enough for cooperative locking and is reproducible in tests."""
|
||||
tag = os.environ.get("CHORUS_CLIENT", "cc")
|
||||
return f"{tag}-{os.getpid()}"
|
||||
member = getattr(chorus, "MEMBER", "") or ""
|
||||
prefix = f"{member}-" if member else ""
|
||||
return f"{prefix}{tag}-{os.getpid()}"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""chorus_config.py — resolve the vault owner, endpoint, and API key for CHORUS.
|
||||
"""chorus_config.py — resolve the group, member, 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:
|
||||
CHORUS is GROUP memory: one shared vault, many members. Every machine therefore
|
||||
supplies four values — who the group is, who *this* member is, and how to reach
|
||||
the vault. The plugin ships NONE of them. On each machine, a machine-local JSON
|
||||
config supplies them:
|
||||
|
||||
~/.claude/chorus-memory/config.json
|
||||
{
|
||||
"owner": "Full Name (how the agent refers to the vault owner, third person)",
|
||||
"group": "Display name for the group/team that shares the vault",
|
||||
"member": "this-members-id (kebab-case slug, e.g. \"jason\" — stamped as author: on writes)",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>"
|
||||
}
|
||||
@@ -14,48 +17,58 @@ 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 chorus.py config init` (writes a placeholder template when absent) and edit
|
||||
it, or `python3 chorus.py config set --owner ... --endpoint ... --key ...`.
|
||||
it, or `python3 chorus.py config set --group ... --member ... --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 CHORUS_* env var or a leftover/placeholder ~/.claude config.json can
|
||||
never override or break it.
|
||||
If the artifact carries a baked endpoint AND key AND member (a per-member 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-member plugin "just works" with no
|
||||
setup, 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 CHORUS_OWNER -> config "owner"
|
||||
endpoint env CHORUS_BASE -> config "endpoint"
|
||||
key env CHORUS_KEY -> config "key"
|
||||
group env CHORUS_GROUP -> config "group"
|
||||
member env CHORUS_MEMBER -> config "member"
|
||||
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 $CHORUS_CONFIG. Pure stdlib only.
|
||||
|
||||
MEMBER IDs are kebab-case slugs (^[a-z0-9][a-z0-9-]{0,31}$): they are stamped into
|
||||
`author:` frontmatter on every agent write and (from schema 5) become path segments
|
||||
(`_agent/members/<member>/…`), so they must be filesystem- and wikilink-safe.
|
||||
|
||||
BAKED CREDENTIALS (the default tier): the DEFAULT_* constants below are EMPTY in
|
||||
source — the committed plugin ships zero credentials. `build.py --bake-key`
|
||||
substitutes a specific user's owner/endpoint/key into them at package time,
|
||||
producing a per-user artifact that needs no config file. This is how the plugin
|
||||
substitutes one member's group/member/endpoint/key into them at package time,
|
||||
producing a per-member artifact that needs no config file. This is how the plugin
|
||||
works in the CoWork sandbox, where the user's ~/.claude is not bridged: the plugin
|
||||
itself (mounted read-only every session) carries the values. A baked artifact is
|
||||
secret-bearing — deliver it directly to that one user, NEVER commit or publish it.
|
||||
secret-bearing — deliver it directly to that one member, NEVER commit or publish it.
|
||||
On a normal host the empty defaults mean the env/config-file path takes over.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Build-time injection targets — MUST stay empty string literals in source so the
|
||||
# committed tree ships no secret. `build.py --bake-key` rewrites these lines.
|
||||
DEFAULT_OWNER = ""
|
||||
DEFAULT_GROUP = ""
|
||||
DEFAULT_MEMBER = ""
|
||||
DEFAULT_BASE = ""
|
||||
DEFAULT_KEY = ""
|
||||
|
||||
MEMBER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
|
||||
|
||||
TEMPLATE = {
|
||||
"owner": "Full Name — how the agent should refer to the vault owner (third person)",
|
||||
"group": "Group Name — the team/group sharing this vault",
|
||||
"member": "<your-member-id — kebab-case slug, e.g. jason>",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>",
|
||||
}
|
||||
@@ -86,31 +99,34 @@ def _from_file() -> dict:
|
||||
|
||||
|
||||
def baked_complete() -> bool:
|
||||
"""True when the artifact carries a usable baked endpoint AND key — i.e. a
|
||||
per-user `--bake-key` build. When True, the baked values win unconditionally."""
|
||||
return bool(DEFAULT_BASE and DEFAULT_KEY)
|
||||
"""True when the artifact carries a usable baked endpoint AND key AND member —
|
||||
i.e. a per-member `--bake-key` build. When True, the baked values win
|
||||
unconditionally."""
|
||||
return bool(DEFAULT_BASE and DEFAULT_KEY and DEFAULT_MEMBER)
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
"""Return {owner, endpoint, key}. A complete baked set wins unconditionally;
|
||||
otherwise env overrides the file, missing -> "".
|
||||
"""Return {group, member, endpoint, key}. A complete baked set wins
|
||||
unconditionally; otherwise env overrides the file, missing -> "".
|
||||
|
||||
Never raises — callers that REQUIRE a field use resolve_endpoint()/resolve_key(),
|
||||
which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable
|
||||
even when nothing is configured yet."""
|
||||
Never raises — callers that REQUIRE a field use resolve_endpoint()/resolve_key()/
|
||||
resolve_member(), which raise a helpful error. This keeps `--help`, `config`, and
|
||||
`doctor` usable even when nothing is configured yet."""
|
||||
f = _from_file()
|
||||
if baked_complete():
|
||||
# Per-user artifact: baked creds are authoritative and must not be shadowed
|
||||
# by a stale env var or a leftover/placeholder config file. Owner is purely
|
||||
# descriptive, so it may still fall back when not baked.
|
||||
# Per-member artifact: baked values are authoritative and must not be
|
||||
# shadowed by a stale env var or a leftover/placeholder config file. Group
|
||||
# is purely descriptive, so it may still fall back when not baked.
|
||||
return {
|
||||
"owner": DEFAULT_OWNER or os.environ.get("CHORUS_OWNER") or f.get("owner") or "",
|
||||
"group": DEFAULT_GROUP or os.environ.get("CHORUS_GROUP") or f.get("group") or "",
|
||||
"member": DEFAULT_MEMBER,
|
||||
"endpoint": DEFAULT_BASE.rstrip("/"),
|
||||
"key": DEFAULT_KEY,
|
||||
}
|
||||
endpoint = (os.environ.get("CHORUS_BASE") or f.get("endpoint") or "").rstrip("/")
|
||||
return {
|
||||
"owner": os.environ.get("CHORUS_OWNER") or f.get("owner") or "",
|
||||
"group": os.environ.get("CHORUS_GROUP") or f.get("group") or "",
|
||||
"member": os.environ.get("CHORUS_MEMBER") or f.get("member") or "",
|
||||
"endpoint": endpoint,
|
||||
"key": os.environ.get("CHORUS_KEY") or f.get("key") or "",
|
||||
}
|
||||
@@ -120,7 +136,7 @@ def _missing(field: str, env: str) -> RuntimeError:
|
||||
return RuntimeError(
|
||||
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".'
|
||||
f'Expected JSON keys: "group", "member", "endpoint", "key".'
|
||||
)
|
||||
|
||||
|
||||
@@ -138,32 +154,49 @@ def resolve_key() -> str:
|
||||
return k
|
||||
|
||||
|
||||
def resolve_owner() -> str:
|
||||
"""The owner display name. Descriptive only, so an empty value is tolerated."""
|
||||
return load()["owner"]
|
||||
def resolve_member() -> str:
|
||||
"""This machine's member id — required for attribution (author: stamping)."""
|
||||
m = load()["member"]
|
||||
if not member_valid(m):
|
||||
raise _missing("member", "CHORUS_MEMBER")
|
||||
return m
|
||||
|
||||
|
||||
def resolve_group() -> str:
|
||||
"""The group display name. Descriptive only, so an empty value is tolerated."""
|
||||
return load()["group"]
|
||||
|
||||
|
||||
def member_valid(member: str) -> bool:
|
||||
"""True when the member id is a usable kebab-case slug (not empty, not the
|
||||
template placeholder). Member ids become author: stamps and path segments."""
|
||||
return bool(member) and bool(MEMBER_RE.match(member))
|
||||
|
||||
|
||||
def is_configured(cfg: dict | None = None) -> bool:
|
||||
"""True only when endpoint AND key are present and NOT the untouched template
|
||||
placeholders — so a freshly `config init`-ed (but unedited) file reads as not yet
|
||||
configured, which is what triggers the first-run "ask for the key file" prompt."""
|
||||
"""True only when endpoint, key, AND member are present and NOT the untouched
|
||||
template placeholders — so a freshly `config init`-ed (but unedited) file reads
|
||||
as not yet configured, which is what triggers the first-run "ask for the config
|
||||
file" prompt. Member is required: group memory without attribution is broken."""
|
||||
c = cfg if cfg is not None else load()
|
||||
ep, key = c.get("endpoint", ""), c.get("key", "")
|
||||
if not ep or not key:
|
||||
return False
|
||||
if "your-obsidian-rest-endpoint" in ep or key.startswith("<"):
|
||||
return False
|
||||
return True
|
||||
return member_valid(c.get("member", ""))
|
||||
|
||||
|
||||
def source(field: str) -> str:
|
||||
"""Where a field is currently coming from — for `config`/`doctor` reporting."""
|
||||
baked = {"owner": DEFAULT_OWNER, "endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field]
|
||||
# A complete baked set wins unconditionally (see load()): endpoint/key always
|
||||
# come from the artifact, and owner too whenever it was baked.
|
||||
if baked_complete() and (field in ("endpoint", "key") or baked):
|
||||
baked = {"group": DEFAULT_GROUP, "member": DEFAULT_MEMBER,
|
||||
"endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field]
|
||||
# A complete baked set wins unconditionally (see load()): member/endpoint/key
|
||||
# always come from the artifact, and group too whenever it was baked.
|
||||
if baked_complete() and (field in ("member", "endpoint", "key") or baked):
|
||||
return "baked-in (plugin)"
|
||||
env = {"owner": "CHORUS_OWNER", "endpoint": "CHORUS_BASE", "key": "CHORUS_KEY"}[field]
|
||||
env = {"group": "CHORUS_GROUP", "member": "CHORUS_MEMBER",
|
||||
"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):
|
||||
@@ -184,11 +217,12 @@ def _secure(p: Path) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def write_config(owner: str, endpoint: str, key: str) -> Path:
|
||||
def write_config(group: str, member: str, endpoint: str, key: str) -> Path:
|
||||
"""Persist a filled-in config (merging over any existing values), chmod 0600."""
|
||||
cur = _from_file()
|
||||
merged = {
|
||||
"owner": owner if owner is not None else cur.get("owner", ""),
|
||||
"group": group if group is not None else cur.get("group", ""),
|
||||
"member": member if member is not None else cur.get("member", ""),
|
||||
"endpoint": (endpoint if endpoint is not None else cur.get("endpoint", "")).rstrip("/"),
|
||||
"key": key if key is not None else cur.get("key", ""),
|
||||
}
|
||||
@@ -211,9 +245,10 @@ def import_file(src) -> Path:
|
||||
raise RuntimeError(f"config import: {p} is not valid JSON ({exc})")
|
||||
if not isinstance(data, dict) or not is_configured(data):
|
||||
raise RuntimeError(
|
||||
'config import: file must be JSON with a real "endpoint" and "key" '
|
||||
'(and ideally "owner") — not the template placeholders.')
|
||||
return write_config(data.get("owner", ""), data["endpoint"], data["key"])
|
||||
'config import: file must be JSON with a real "endpoint", "key", and '
|
||||
'"member" (kebab-case slug; and ideally "group") — not the template '
|
||||
'placeholders.')
|
||||
return write_config(data.get("group", ""), data["member"], data["endpoint"], data["key"])
|
||||
|
||||
|
||||
def init_template() -> tuple[Path, bool]:
|
||||
|
||||
@@ -38,11 +38,13 @@ def run() -> int:
|
||||
line(sys.version_info >= MIN_PY, f"python >= {MIN_PY[0]}.{MIN_PY[1]}",
|
||||
f"running {sys.version_info.major}.{sys.version_info.minor}")
|
||||
|
||||
# 2. machine-local config (owner/endpoint/key). Without a usable endpoint+key
|
||||
# nothing else can run, so report it clearly before touching the network.
|
||||
# A still-placeholder `config init` template counts as not configured.
|
||||
# 2. machine-local config (group/member/endpoint/key). Without a usable
|
||||
# endpoint+key+member nothing else can run, so report it clearly before
|
||||
# touching the network. A still-placeholder `config init` template counts
|
||||
# as not configured.
|
||||
ep_real = bool(cfg["endpoint"]) and "your-obsidian-rest-endpoint" not in cfg["endpoint"]
|
||||
key_real = bool(cfg["key"]) and not cfg["key"].startswith("<")
|
||||
member_real = chorus_config.member_valid(cfg["member"])
|
||||
line(ep_real, "endpoint configured",
|
||||
f"[{chorus_config.source('endpoint')}]" if ep_real
|
||||
else (f"placeholder — edit {chorus_config.config_path()}" if cfg["endpoint"]
|
||||
@@ -50,12 +52,16 @@ def run() -> int:
|
||||
line(key_real, "API key configured",
|
||||
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")
|
||||
line(member_real, "member id configured",
|
||||
f"{cfg['member']} [{chorus_config.source('member')}]" if member_real
|
||||
else (f"invalid — must be a kebab-case slug, got {cfg['member']!r}" if cfg["member"]
|
||||
else "missing — required for attribution (author: stamping)"))
|
||||
line(bool(cfg["group"]), "group name set",
|
||||
cfg["group"] if cfg["group"] else "optional, but recommended (describes who shares the vault)")
|
||||
if not chorus_config.is_configured(cfg):
|
||||
print("\ndoctor: not configured — ask the operator for their key file and install it "
|
||||
"(`chorus.py config import <file>`, or `config set --owner … --endpoint … --key …`), "
|
||||
"then re-run.")
|
||||
print("\ndoctor: not configured — ask the member for their config file and install it "
|
||||
"(`chorus.py config import <file>`, or "
|
||||
"`config set --group … --member … --endpoint … --key …`), then re-run.")
|
||||
return 1
|
||||
|
||||
# 3. reachability + auth + marker, in one GET of the bootstrap marker
|
||||
|
||||
@@ -47,7 +47,7 @@ def main() -> int:
|
||||
if rc == 78:
|
||||
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 "
|
||||
"(group/member/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:
|
||||
|
||||
@@ -126,7 +126,13 @@ def _build_note(fm_type: str, status_v: str, today_s: str, title: str,
|
||||
if aliases:
|
||||
# Obsidian-native, durable home for aliases; sweep folds these back into the index.
|
||||
fm.append("aliases: [" + ", ".join(json.dumps(a) for a in aliases) + "]")
|
||||
fm += ["agent_written: true", f"source_notes: {src}", "---", "", f"# {title}", ""]
|
||||
fm.append("agent_written: true")
|
||||
# Group attribution: every agent-written note records WHICH member's session
|
||||
# created it. chorus.MEMBER is required config (is_configured), so this is
|
||||
# only absent in unconfigured/mock edge cases.
|
||||
if chorus.MEMBER:
|
||||
fm.append(f"author: {chorus.MEMBER}")
|
||||
fm += [f"source_notes: {src}", "---", "", f"# {title}", ""]
|
||||
out = "\n".join(fm)
|
||||
if body_text.strip():
|
||||
out += body_text.strip() + "\n"
|
||||
|
||||
@@ -63,6 +63,16 @@ def _key(method: str, url: str, data: bytes | None) -> str:
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _member() -> str:
|
||||
"""The configured member id, for outbox attribution. Never raises — the queue
|
||||
must keep working (that's its whole job) even half-configured."""
|
||||
try:
|
||||
import chorus_config
|
||||
return chorus_config.load().get("member", "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- outbox
|
||||
def enqueue(method: str, url: str, data: bytes | None, headers: dict, idem_key: str | None = None) -> None:
|
||||
"""Append one intended write to the outbox (NDJSON; base64 body for binary safety).
|
||||
@@ -75,6 +85,7 @@ def enqueue(method: str, url: str, data: bytes | None, headers: dict, idem_key:
|
||||
"method": method.upper(), "url": url,
|
||||
"body_b64": base64.b64encode(data).decode() if data else None,
|
||||
"headers": headers or {}, "idem_key": idem_key,
|
||||
"member": _member(), # audit: whose session queued this write
|
||||
# No wall-clock: replay order is file order, not a timestamp (keeps this testable).
|
||||
}
|
||||
with outbox_path().open("a", encoding="utf-8") as fh:
|
||||
|
||||
@@ -76,10 +76,12 @@ def main() -> int:
|
||||
check("H5 classify/preview/apply wired",
|
||||
all(callable(getattr(rf, n, None)) for n in ("classify", "preview", "apply")))
|
||||
|
||||
# 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.
|
||||
# Config — group/member/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 chorus_config as cfg
|
||||
saved_env = {k: os.environ.get(k) for k in ("CHORUS_KEY", "CHORUS_BASE", "CHORUS_OWNER", "CHORUS_CONFIG")}
|
||||
saved_env = {k: os.environ.get(k) for k in
|
||||
("CHORUS_KEY", "CHORUS_BASE", "CHORUS_GROUP", "CHORUS_MEMBER", "CHORUS_CONFIG")}
|
||||
try:
|
||||
for k in saved_env:
|
||||
os.environ.pop(k, None)
|
||||
@@ -92,23 +94,40 @@ def main() -> int:
|
||||
p2, created2 = cfg.init_template()
|
||||
check("config init is idempotent (no clobber)", created2 is False)
|
||||
# write_config persists a filled-in config; load() reads it back.
|
||||
cfg.write_config("Owner Name", "https://obsidian.example.com/", "file-key-xyz")
|
||||
cfg.write_config("Group Name", "test-member", "https://obsidian.example.com/", "file-key-xyz")
|
||||
loaded = cfg.load()
|
||||
check("config file round-trips owner/endpoint/key",
|
||||
loaded == {"owner": "Owner Name",
|
||||
check("config file round-trips group/member/endpoint/key",
|
||||
loaded == {"group": "Group Name",
|
||||
"member": "test-member",
|
||||
"endpoint": "https://obsidian.example.com", # trailing / stripped
|
||||
"key": "file-key-xyz"})
|
||||
check("configured with valid member", cfg.is_configured(loaded))
|
||||
# env overrides the file, per field.
|
||||
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)
|
||||
os.environ["CHORUS_MEMBER"] = "env-member"
|
||||
check("env CHORUS_MEMBER overrides config", cfg.resolve_member() == "env-member")
|
||||
os.environ.pop("CHORUS_MEMBER", None)
|
||||
# member validation: bad slugs and placeholders are not configured.
|
||||
check("member_valid rejects bad slugs",
|
||||
not cfg.member_valid("Jason S") and not cfg.member_valid("<your-member-id>")
|
||||
and cfg.member_valid("jason"))
|
||||
cfg.write_config("Group Name", "Not A Slug", "https://obsidian.example.com/", "k")
|
||||
check("invalid member -> not configured", not cfg.is_configured(cfg.load()))
|
||||
# missing required field raises a helpful error.
|
||||
cfg.write_config("Owner Name", "", "")
|
||||
cfg.write_config("Group Name", "test-member", "", "")
|
||||
try:
|
||||
cfg.resolve_key()
|
||||
check("missing key raises", False)
|
||||
except RuntimeError:
|
||||
check("missing key raises", True)
|
||||
cfg.write_config("Group Name", "", "https://obsidian.example.com/", "k")
|
||||
try:
|
||||
cfg.resolve_member()
|
||||
check("missing member raises", False)
|
||||
except RuntimeError:
|
||||
check("missing member raises", True)
|
||||
finally:
|
||||
for k, v in saved_env.items():
|
||||
if v is None:
|
||||
|
||||
@@ -196,6 +196,13 @@ def main() -> int:
|
||||
if not fm_field_populated(raw, fm, field):
|
||||
what = f"missing {field}" if field not in fm else f"empty {field}"
|
||||
flag("incomplete-frontmatter", f"{path}: {what}")
|
||||
# Group attribution: every agent-written note must say WHICH member's
|
||||
# session wrote it (capture stamps `author:` from the configured member).
|
||||
# The bootstrap marker is plugin-owned, not member content — exempt.
|
||||
if fm and path != "_agent/chorus-vault.md" \
|
||||
and str(fm.get("agent_written", "")).strip().lower() == "true" \
|
||||
and not str(fm.get("author", "")).strip():
|
||||
flag("missing-author", f"{path}: agent_written note has no author")
|
||||
created, updated = parse_date(fm.get("created")), parse_date(fm.get("updated"))
|
||||
if created and updated and updated < created:
|
||||
flag("date-order", f"{path}: updated {updated} is before created {created}")
|
||||
@@ -333,6 +340,7 @@ def main() -> int:
|
||||
"retired-path": "Write to a retired/dead path",
|
||||
"missing-frontmatter": "Missing required frontmatter field",
|
||||
"incomplete-frontmatter": "Incomplete entity frontmatter (status/tags) — sweep.py --apply backfills",
|
||||
"missing-author": "agent_written note lacks author: (member attribution)",
|
||||
"date-order": "updated earlier than created",
|
||||
"future-date": "updated date is in the future",
|
||||
"source-notes-wikilink": "Wikilink in source_notes (must be plain paths)",
|
||||
|
||||
Reference in New Issue
Block a user