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:
@@ -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