diff --git a/.DS_Store b/.DS_Store index a855b12..dbfb291 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 656b3d8..2e0212e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ eval/results/*.json # ECHO config: the filled-in key file is yours to distribute, never commit it /echo-memory.config.json + +# Built plugin artifacts. A --bake-key build embeds a user's vault key, so NO +# .plugin artifact is ever committed — they are delivered out-of-band per user. +# (Matches files ending in .plugin, not the echo-memory.plugin.src/ source tree.) +*.plugin +/dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4b8597c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +## 1.4.0 + +### Added — per-user baked-key builds (CoWork zero-touch) + +The plugin is the only thing reliably mounted into a CoWork session (the sandbox's +`~/.claude` is synthetic, not your real one), so the config now *can* travel inside +the artifact. `echo_config` gains a lowest-priority fallback tier — the +`DEFAULT_OWNER` / `DEFAULT_BASE` / `DEFAULT_KEY` constants — **empty in source**, so +the committed tree still ships zero credentials. + +- `build.py --bake-key --from [--label ]` substitutes a single + user's owner/endpoint/key into those constants, producing a per-user artifact that + needs **no config file and no per-session paste**. Values may also come from + `--owner/--endpoint/--key` or `ECHO_OWNER/ECHO_BASE/ECHO_KEY`. +- Baked builds default to `dist/` (gitignored) and never touch the shared + `echo-memory.plugin` pointer. +- `build.py --strip-key` now force-blanks the same constants (guaranteed token-free). +- `config show` / `doctor` report `[baked-in default]` when a field resolves from the + baked tier. + +### Changed — artifact hygiene + +- `.gitignore` now excludes `*.plugin` and `dist/`; previously-tracked `.plugin` + artifacts were untracked. A baked artifact carries a vault key and must be + **delivered directly to that one user, never committed, pushed, or published.** + +### Resolution order (unchanged for hosts) + +`env` → `$ECHO_CONFIG` file → canonical `~/.claude/echo-memory/config.json` → +**baked `DEFAULT_*`**. On a normal desktop the empty defaults mean behaviour is +identical to 1.3.x; the baked tier only matters in per-user artifacts. diff --git a/README.md b/README.md index d52d9ed..def2212 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# echo-memory — v1.3.1 +# echo-memory — v1.4.0 Persistent memory for Claude / CoWork sessions via the **ECHO** Obsidian vault, driven over the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api). The skill makes direct REST calls through a bundled validated client (`scripts/echo.py`). The whole toolchain is **pure Python** (stdlib only), so it runs identically on Windows, macOS, and Linux — no bash, no platform-specific `date`. @@ -33,6 +33,19 @@ The plugin ships **no** owner, endpoint, or key — it is user-agnostic. Each ma - **Set up a machine:** `echo.py config import ` adopts a config you've been handed; `echo.py config set --owner … --endpoint … --key …` writes one from values; `echo.py config init` scaffolds a blank template to edit; `echo.py config` prints the resolved config (key redacted) and where each field came from. - **First run:** an unconfigured machine — or one left on the placeholder template — reports `NOT CONFIGURED` (`echo.py load` prints a banner and exits `78`; other verbs exit `2`; `/echo-doctor` flags it red), and the skill asks the operator for the key file before doing any memory work. The config is **never committed** and **never written into a vault note**; the key stays out of the plugin tree entirely. +### Per-user baked-key builds (v1.4.0) — for CoWork + +The CoWork sandbox does **not** bridge your real `~/.claude` (the mounted `.claude` is the sandbox's own synthetic view), so a config file on your machine can't be read there. The only thing reliably mounted into every session is the **plugin itself**. So for a small set of known users, bake each user's credentials directly into a per-user artifact: + +```bash +python build.py --bake-key --from alice-config.json --label alice +# -> dist/echo-memory-1.4.0-alice.plugin (carries Alice's vault key) +``` + +Resolution gains a lowest-priority **baked** tier (`DEFAULT_OWNER/BASE/KEY` in `echo_config.py`) that is **empty in source** — the committed tree still ships zero credentials, and host behaviour is unchanged (env/config-file still win). `build.py --bake-key` fills those constants from a `config.json` (or `--owner/--endpoint/--key`, or `ECHO_*` env). The end user just installs the artifact — **no config file, no per-session paste**, works on desktop and in every CoWork session. + +> **A baked artifact contains a vault bearer token.** Baked builds land in `dist/` (gitignored) and never touch the shared `echo-memory.plugin` pointer. Deliver each one **directly** to its single user; **never commit, push, or publish it.** Use `--strip-key` (or just a clean source build) for a token-free artifact. Rotation = rebuild + reinstall. + Vault paths are addressed **at the root** (e.g. `GET /vault/_agent/context/current-context.md`). Prefer `scripts/echo.py` over raw `curl`: it injects auth, checks HTTP status (a failed write exits non-zero instead of looking like success), retries transient 5xx, verifies PUTs, and does idempotent appends. --- diff --git a/build.py b/build.py index ff17494..6919dba 100644 --- a/build.py +++ b/build.py @@ -7,8 +7,13 @@ dev cruft. The version is read from the manifest, so the output is named automat Usage: python build.py # build echo-memory-.plugin + refresh echo-memory.plugin - python build.py --strip-key # ALSO blank echo.py's DEFAULT_KEY -> a token-free artifact - # (requires ECHO_KEY or ~/.echo-memory/credentials at runtime) + # (token-free: ships no credentials; user configures at runtime) + python build.py --bake-key --from coworker.json [--label alice] + # SECRET-BEARING per-user artifact: bake owner/endpoint/key from a + # config.json (or --owner/--endpoint/--key, or ECHO_* env) into the + # echo_config DEFAULT_* constants. Defaults to dist/ and skips the + # shared pointer. Deliver directly to that one user — NEVER commit it. + python build.py --strip-key # force-blank the DEFAULT_* constants -> guaranteed token-free artifact python build.py --no-pointer # don't update the echo-memory.plugin "current" pointer python build.py --outdir dist # write artifacts somewhere other than the repo root @@ -20,6 +25,7 @@ from __future__ import annotations import argparse import json +import os import re import sys import zipfile @@ -35,7 +41,8 @@ for _stream in (sys.stdout, sys.stderr): REPO = Path(__file__).resolve().parent SRC = REPO / "echo-memory.plugin.src" MANIFEST = "/".join([".claude-plugin", "plugin.json"]) -ECHO_PY = "/".join(["skills", "echo-memory", "scripts", "echo.py"]) +# The baked DEFAULT_* constants live in echo_config.py (resolution lowest tier). +CONFIG_PY = "/".join(["skills", "echo-memory", "scripts", "echo_config.py"]) EXCLUDE_DIRS = {"__pycache__", ".git"} EXCLUDE_NAMES = {".DS_Store"} @@ -44,7 +51,13 @@ FIXED_DATE = (2026, 1, 1, 0, 0, 0) # stable timestamp for reproducible archives MAX_DESCRIPTION = 500 # plugin-marketplace cap: plugin.json "description" must be UNDER this # (it has silently regressed past the limit before — fail the build now) -_KEY_RE = re.compile(r'(DEFAULT_KEY\s*=\s*")([0-9a-fA-F]{16,})(")') +# field name -> the constant assigned in echo_config.py +_CONSTS = {"owner": "DEFAULT_OWNER", "endpoint": "DEFAULT_BASE", "key": "DEFAULT_KEY"} + + +def _const_re(const: str) -> re.Pattern: + """Match a whole `CONST = ` assignment line.""" + return re.compile(r'^(' + re.escape(const) + r'\s*=\s*).*$', re.M) def included_files() -> list[Path]: @@ -60,24 +73,33 @@ def included_files() -> list[Path]: return out -def file_bytes(path: Path, arcname: str, strip_key: bool) -> bytes: +def file_bytes(path: Path, arcname: str, bake: dict | None, strip_key: bool) -> bytes: + """Return the bytes to archive. For echo_config.py, optionally rewrite the + DEFAULT_* constants — inject `bake` values, or blank them with --strip-key.""" data = path.read_bytes() - if strip_key and arcname == ECHO_PY: - text = data.decode("utf-8") - new, n = _KEY_RE.subn(r'\1\3', text) # collapse the hex value to "" - if n: - text = new.replace('DEFAULT_KEY = ""', - 'DEFAULT_KEY = "" # stripped at build time — set ECHO_KEY or run write-key') - data = text.encode("utf-8") - return data + if arcname != CONFIG_PY or (bake is None and not strip_key): + return data + text = data.decode("utf-8") + for field, const in _CONSTS.items(): + repl = json.dumps(bake[field]) if bake is not None else '""' + # function replacement so backslashes in repl (e.g. \uXXXX) stay literal + text, n = _const_re(const).subn(lambda m, r=repl: m.group(1) + r, text, count=1) + if n != 1: + raise RuntimeError(f"build: could not find `{const} = ...` in {arcname}") + return text.encode("utf-8") def token_present(path: Path) -> bool: - m = _KEY_RE.search(path.read_text(encoding="utf-8")) - return bool(m and m.group(2)) + """True if any DEFAULT_* constant in echo_config.py holds a non-empty value.""" + text = path.read_text(encoding="utf-8") + for const in _CONSTS.values(): + m = re.search(r'^' + re.escape(const) + r'\s*=\s*"(.*)"\s*$', text, re.M) + if m and m.group(1): + return True + return False -def build(out: Path, files: list[Path], strip_key: bool) -> int: +def build(out: Path, files: list[Path], bake: dict | None, strip_key: bool) -> int: if out.exists(): out.unlink() with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: @@ -86,23 +108,73 @@ def build(out: Path, files: list[Path], strip_key: bool) -> int: info = zipfile.ZipInfo(arc, date_time=FIXED_DATE) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 - z.writestr(info, file_bytes(p, arc, strip_key)) + z.writestr(info, file_bytes(p, arc, bake, strip_key)) return out.stat().st_size +def bake_values(args) -> dict: + """Resolve owner/endpoint/key for --bake-key from --owner/--endpoint/--key, + else --from , else ECHO_OWNER/ECHO_BASE/ECHO_KEY env.""" + src = {} + if args.from_file: + fp = Path(args.from_file).expanduser() + if not fp.exists(): + raise RuntimeError(f"build: --from file not found: {fp}") + try: + loaded = json.loads(fp.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise RuntimeError(f"build: --from file is not valid JSON ({exc})") + if isinstance(loaded, dict): + src = loaded + vals = { + "owner": args.owner or src.get("owner") or os.environ.get("ECHO_OWNER") or "", + "endpoint": (args.endpoint or src.get("endpoint") or os.environ.get("ECHO_BASE") or "").rstrip("/"), + "key": args.key or src.get("key") or os.environ.get("ECHO_KEY") or "", + } + missing = [f for f in ("endpoint", "key") if not vals[f]] + if missing: + raise RuntimeError( + "build: --bake-key needs a real endpoint and key — missing " + + ", ".join(missing) + ". Provide --from , the --owner/" + "--endpoint/--key flags, or set ECHO_OWNER/ECHO_BASE/ECHO_KEY.") + if "your-obsidian-rest-endpoint" in vals["endpoint"] or vals["key"].startswith("<"): + raise RuntimeError("build: --bake-key got the template placeholders, not real values.") + return vals + + def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="Build the echo-memory .plugin artifact") + ap.add_argument("--bake-key", action="store_true", + help="bake a user's owner/endpoint/key into the artifact (secret-bearing — never commit)") + ap.add_argument("--from", dest="from_file", metavar="FILE", + help="config.json to read owner/endpoint/key from when baking") + ap.add_argument("--owner", help="baked owner (overrides --from / env)") + ap.add_argument("--endpoint", help="baked endpoint (overrides --from / env)") + ap.add_argument("--key", help="baked key (overrides --from / env)") + ap.add_argument("--label", help="suffix for the artifact name, e.g. --label alice") ap.add_argument("--strip-key", action="store_true", - help="blank echo.py DEFAULT_KEY so no token ships in the artifact") + help="force-blank the DEFAULT_* constants so no token ships in the artifact") ap.add_argument("--no-pointer", action="store_true", help="do not refresh the echo-memory.plugin 'current' pointer") - ap.add_argument("--outdir", default=str(REPO), help="output directory (default: repo root)") + ap.add_argument("--outdir", help="output directory (default: repo root; dist/ when --bake-key)") args = ap.parse_args(argv) + if args.bake_key and args.strip_key: + print("build: --bake-key and --strip-key are mutually exclusive", file=sys.stderr) + return 2 + if not SRC.is_dir(): print(f"build: source tree not found at {SRC}", file=sys.stderr) return 1 + bake = None + if args.bake_key: + try: + bake = bake_values(args) + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 2 + manifest_path = SRC / ".claude-plugin" / "plugin.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) version = manifest["version"] @@ -118,22 +190,31 @@ def main(argv: list[str] | None = None) -> int: print(f"build: {MANIFEST} missing from the archive root — aborting", file=sys.stderr) return 1 - outdir = Path(args.outdir) + # Baked artifacts are secret-bearing: default them into dist/ (gitignored) and + # never touch the shared, committable echo-memory.plugin pointer. + default_outdir = (REPO / "dist") if bake else REPO + outdir = Path(args.outdir) if args.outdir else default_outdir outdir.mkdir(parents=True, exist_ok=True) - targets = [outdir / f"echo-memory-{version}.plugin"] - if not args.no_pointer: + + label = f"-{args.label}" if args.label else "" + targets = [outdir / f"echo-memory-{version}{label}.plugin"] + if not args.no_pointer and not bake: targets.append(outdir / "echo-memory.plugin") for out in targets: - size = build(out, files, args.strip_key) + size = build(out, files, bake, args.strip_key) print(f"built {out.name} ({len(files)} entries, {size:,} bytes)") - echo_src = SRC / "skills" / "echo-memory" / "scripts" / "echo.py" - if args.strip_key: - print("note: --strip-key set — artifact is token-free; runtime needs ECHO_KEY or a credentials file.") - elif token_present(echo_src): - print("WARNING: the baked-in DEFAULT_KEY is present in this artifact — do not share it. " - "Set ECHO_KEY everywhere (API-KEY-SETUP.md), then build with --strip-key.") + if bake: + who = bake.get("owner") or "(no owner)" + print(f"note: --bake-key baked credentials for {who} into {CONFIG_PY}.") + print("WARNING: this artifact carries a vault key — deliver it directly to that one " + "user and NEVER commit, push, or publish it (dist/ is gitignored).") + elif args.strip_key: + print("note: --strip-key set — artifact is token-free; user configures owner/endpoint/key at runtime.") + elif token_present(SRC / "skills" / "echo-memory" / "scripts" / "echo_config.py"): + print("WARNING: a DEFAULT_* constant in echo_config.py is non-empty — this artifact carries a " + "secret. Do not commit it. Build from a clean source tree, or use --strip-key.") return 0 diff --git a/echo-icon-1024.png b/echo-icon-1024.png new file mode 100644 index 0000000..535293a Binary files /dev/null and b/echo-icon-1024.png differ diff --git a/echo-icon-512.png b/echo-icon-512.png new file mode 100644 index 0000000..712528d Binary files /dev/null and b/echo-icon-512.png differ diff --git a/echo-icon-64.png b/echo-icon-64.png new file mode 100644 index 0000000..834a685 Binary files /dev/null and b/echo-icon-64.png differ diff --git a/echo-icon.svg b/echo-icon.svg new file mode 100644 index 0000000..4dee94a --- /dev/null +++ b/echo-icon.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/echo-memory-0.6.0.plugin b/echo-memory-0.6.0.plugin deleted file mode 100644 index 89a661e..0000000 Binary files a/echo-memory-0.6.0.plugin and /dev/null differ diff --git a/echo-memory-0.7.0.plugin b/echo-memory-0.7.0.plugin deleted file mode 100644 index 54b3557..0000000 Binary files a/echo-memory-0.7.0.plugin and /dev/null differ diff --git a/echo-memory-0.7.1.plugin b/echo-memory-0.7.1.plugin deleted file mode 100644 index d3810b8..0000000 Binary files a/echo-memory-0.7.1.plugin and /dev/null differ diff --git a/echo-memory-0.8.0.plugin b/echo-memory-0.8.0.plugin deleted file mode 100644 index f60a53a..0000000 Binary files a/echo-memory-0.8.0.plugin and /dev/null differ diff --git a/echo-memory-0.9.0.plugin b/echo-memory-0.9.0.plugin deleted file mode 100644 index 183632f..0000000 Binary files a/echo-memory-0.9.0.plugin and /dev/null differ diff --git a/echo-memory-1.0.0.plugin b/echo-memory-1.0.0.plugin deleted file mode 100644 index 8dc7552..0000000 Binary files a/echo-memory-1.0.0.plugin and /dev/null differ diff --git a/echo-memory-1.2.0.plugin b/echo-memory-1.2.0.plugin deleted file mode 100644 index b8f9b63..0000000 Binary files a/echo-memory-1.2.0.plugin and /dev/null differ diff --git a/echo-memory-1.3.0.plugin b/echo-memory-1.3.0.plugin deleted file mode 100644 index 6534606..0000000 Binary files a/echo-memory-1.3.0.plugin and /dev/null differ diff --git a/echo-memory-1.3.1.plugin b/echo-memory-1.3.1.plugin deleted file mode 100644 index 1320fa3..0000000 Binary files a/echo-memory-1.3.1.plugin and /dev/null differ diff --git a/echo-memory.plugin b/echo-memory.plugin deleted file mode 100644 index 1320fa3..0000000 Binary files a/echo-memory.plugin and /dev/null differ diff --git a/echo-memory.plugin.src/.DS_Store b/echo-memory.plugin.src/.DS_Store index fb7979a..8a58d05 100644 Binary files a/echo-memory.plugin.src/.DS_Store and b/echo-memory.plugin.src/.DS_Store differ diff --git a/echo-memory.plugin.src/.claude-plugin/plugin.json b/echo-memory.plugin.src/.claude-plugin/plugin.json index b93bbe4..707ae64 100644 --- a/echo-memory.plugin.src/.claude-plugin/plugin.json +++ b/echo-memory.plugin.src/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "echo-memory", - "version": "1.3.1", + "version": "1.4.0", "description": "Persistent memory via the ECHO Obsidian vault over the Obsidian Local REST API. A cross-platform, connection-pooled Python client: one-call capture/resolve/recall/link over an entity index, hybrid BM25 + graph recall, auto-linking, an offline write-ahead queue, and lock-guarded concurrency. Linter-enforced routing manifest plus /echo-load|save|recall|triage|health|sweep|reflect|doctor commands. Crosslinks notes across Claude and CoWork sessions.", "author": { "name": "Jason Stedwell" diff --git a/echo-memory.plugin.src/skills/.DS_Store b/echo-memory.plugin.src/skills/.DS_Store index b4d198c..509b4fa 100644 Binary files a/echo-memory.plugin.src/skills/.DS_Store and b/echo-memory.plugin.src/skills/.DS_Store differ diff --git a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_config.py b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_config.py index fea3218..77c26fc 100644 --- a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_config.py +++ b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_config.py @@ -17,12 +17,21 @@ committed, and is never written into a vault note. To create it, run it, or `python3 echo.py config set --owner ... --endpoint ... --key ...`. Per-field resolution (first hit wins): - owner env ECHO_OWNER -> config "owner" - endpoint env ECHO_BASE -> config "endpoint" - key env ECHO_KEY -> config "key" + owner env ECHO_OWNER -> config "owner" -> baked DEFAULT_OWNER + endpoint env ECHO_BASE -> config "endpoint" -> baked DEFAULT_BASE + key env ECHO_KEY -> config "key" -> baked DEFAULT_KEY The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config file path itself can be overridden with $ECHO_CONFIG. Pure stdlib only. + +BAKED FALLBACK (the lowest 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 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. On a normal host +the empty defaults mean behaviour is unchanged (env/config file still win). """ from __future__ import annotations @@ -30,6 +39,12 @@ import json import os 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_BASE = "" +DEFAULT_KEY = "" + TEMPLATE = { "owner": "Full Name — how the agent should refer to the vault owner (third person)", "endpoint": "https://your-obsidian-rest-endpoint.example.com", @@ -68,11 +83,11 @@ def load() -> dict: which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable even when nothing is configured yet.""" f = _from_file() - endpoint = (os.environ.get("ECHO_BASE") or f.get("endpoint") or "").rstrip("/") + endpoint = (os.environ.get("ECHO_BASE") or f.get("endpoint") or DEFAULT_BASE or "").rstrip("/") return { - "owner": os.environ.get("ECHO_OWNER") or f.get("owner") or "", + "owner": os.environ.get("ECHO_OWNER") or f.get("owner") or DEFAULT_OWNER or "", "endpoint": endpoint, - "key": os.environ.get("ECHO_KEY") or f.get("key") or "", + "key": os.environ.get("ECHO_KEY") or f.get("key") or DEFAULT_KEY or "", } @@ -123,6 +138,9 @@ def source(field: str) -> str: return f"{env} env" if config_path().exists() and _from_file().get(field): return "config file" + baked = {"owner": DEFAULT_OWNER, "endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field] + if baked: + return "baked-in default" return "unset"