ver 1.4.0 - baked in values
This commit is contained in:
@@ -18,3 +18,9 @@ eval/results/*.json
|
|||||||
|
|
||||||
# ECHO config: the filled-in key file is yours to distribute, never commit it
|
# ECHO config: the filled-in key file is yours to distribute, never commit it
|
||||||
/echo-memory.config.json
|
/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/
|
||||||
|
|||||||
@@ -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 <config.json> [--label <name>]` 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.
|
||||||
@@ -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`.
|
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 <file>` 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.
|
- **Set up a machine:** `echo.py config import <file>` 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.
|
- **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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ dev cruft. The version is read from the manifest, so the output is named automat
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python build.py # build echo-memory-<version>.plugin + refresh echo-memory.plugin
|
python build.py # build echo-memory-<version>.plugin + refresh echo-memory.plugin
|
||||||
python build.py --strip-key # ALSO blank echo.py's DEFAULT_KEY -> a token-free artifact
|
# (token-free: ships no credentials; user configures at runtime)
|
||||||
# (requires ECHO_KEY or ~/.echo-memory/credentials 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 --no-pointer # don't update the echo-memory.plugin "current" pointer
|
||||||
python build.py --outdir dist # write artifacts somewhere other than the repo root
|
python build.py --outdir dist # write artifacts somewhere other than the repo root
|
||||||
|
|
||||||
@@ -20,6 +25,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -35,7 +41,8 @@ for _stream in (sys.stdout, sys.stderr):
|
|||||||
REPO = Path(__file__).resolve().parent
|
REPO = Path(__file__).resolve().parent
|
||||||
SRC = REPO / "echo-memory.plugin.src"
|
SRC = REPO / "echo-memory.plugin.src"
|
||||||
MANIFEST = "/".join([".claude-plugin", "plugin.json"])
|
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_DIRS = {"__pycache__", ".git"}
|
||||||
EXCLUDE_NAMES = {".DS_Store"}
|
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
|
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)
|
# (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 = <anything>` assignment line."""
|
||||||
|
return re.compile(r'^(' + re.escape(const) + r'\s*=\s*).*$', re.M)
|
||||||
|
|
||||||
|
|
||||||
def included_files() -> list[Path]:
|
def included_files() -> list[Path]:
|
||||||
@@ -60,24 +73,33 @@ def included_files() -> list[Path]:
|
|||||||
return out
|
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()
|
data = path.read_bytes()
|
||||||
if strip_key and arcname == ECHO_PY:
|
if arcname != CONFIG_PY or (bake is None and not strip_key):
|
||||||
text = data.decode("utf-8")
|
return data
|
||||||
new, n = _KEY_RE.subn(r'\1\3', text) # collapse the hex value to ""
|
text = data.decode("utf-8")
|
||||||
if n:
|
for field, const in _CONSTS.items():
|
||||||
text = new.replace('DEFAULT_KEY = ""',
|
repl = json.dumps(bake[field]) if bake is not None else '""'
|
||||||
'DEFAULT_KEY = "" # stripped at build time — set ECHO_KEY or run write-key')
|
# function replacement so backslashes in repl (e.g. \uXXXX) stay literal
|
||||||
data = text.encode("utf-8")
|
text, n = _const_re(const).subn(lambda m, r=repl: m.group(1) + r, text, count=1)
|
||||||
return data
|
if n != 1:
|
||||||
|
raise RuntimeError(f"build: could not find `{const} = ...` in {arcname}")
|
||||||
|
return text.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def token_present(path: Path) -> bool:
|
def token_present(path: Path) -> bool:
|
||||||
m = _KEY_RE.search(path.read_text(encoding="utf-8"))
|
"""True if any DEFAULT_* constant in echo_config.py holds a non-empty value."""
|
||||||
return bool(m and m.group(2))
|
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():
|
if out.exists():
|
||||||
out.unlink()
|
out.unlink()
|
||||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
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 = zipfile.ZipInfo(arc, date_time=FIXED_DATE)
|
||||||
info.compress_type = zipfile.ZIP_DEFLATED
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
info.external_attr = 0o644 << 16
|
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
|
return out.stat().st_size
|
||||||
|
|
||||||
|
|
||||||
|
def bake_values(args) -> dict:
|
||||||
|
"""Resolve owner/endpoint/key for --bake-key from --owner/--endpoint/--key,
|
||||||
|
else --from <config.json>, 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 <config.json>, 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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
ap = argparse.ArgumentParser(description="Build the echo-memory .plugin artifact")
|
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",
|
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",
|
ap.add_argument("--no-pointer", action="store_true",
|
||||||
help="do not refresh the echo-memory.plugin 'current' pointer")
|
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)
|
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():
|
if not SRC.is_dir():
|
||||||
print(f"build: source tree not found at {SRC}", file=sys.stderr)
|
print(f"build: source tree not found at {SRC}", file=sys.stderr)
|
||||||
return 1
|
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_path = SRC / ".claude-plugin" / "plugin.json"
|
||||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
version = manifest["version"]
|
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)
|
print(f"build: {MANIFEST} missing from the archive root — aborting", file=sys.stderr)
|
||||||
return 1
|
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)
|
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")
|
targets.append(outdir / "echo-memory.plugin")
|
||||||
|
|
||||||
for out in targets:
|
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)")
|
print(f"built {out.name} ({len(files)} entries, {size:,} bytes)")
|
||||||
|
|
||||||
echo_src = SRC / "skills" / "echo-memory" / "scripts" / "echo.py"
|
if bake:
|
||||||
if args.strip_key:
|
who = bake.get("owner") or "(no owner)"
|
||||||
print("note: --strip-key set — artifact is token-free; runtime needs ECHO_KEY or a credentials file.")
|
print(f"note: --bake-key baked credentials for {who} into {CONFIG_PY}.")
|
||||||
elif token_present(echo_src):
|
print("WARNING: this artifact carries a vault key — deliver it directly to that one "
|
||||||
print("WARNING: the baked-in DEFAULT_KEY is present in this artifact — do not share it. "
|
"user and NEVER commit, push, or publish it (dist/ is gitignored).")
|
||||||
"Set ECHO_KEY everywhere (API-KEY-SETUP.md), then build with --strip-key.")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<!-- Squircle background: Deep Navy -> Royal -> Signal -->
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#25335C"/>
|
||||||
|
<stop offset="0.55" stop-color="#204C84"/>
|
||||||
|
<stop offset="1" stop-color="#1A6E96"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Orb glow: teal core -->
|
||||||
|
<radialGradient id="orb" cx="0.42" cy="0.40" r="0.70">
|
||||||
|
<stop offset="0" stop-color="#7FF0EC"/>
|
||||||
|
<stop offset="0.35" stop-color="#1FC9C4"/>
|
||||||
|
<stop offset="1" stop-color="#009B98"/>
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Vault enclosure subtle gradient -->
|
||||||
|
<linearGradient id="vault" x1="106" y1="160" x2="406" y2="406" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#2A86AE"/>
|
||||||
|
<stop offset="1" stop-color="#155D80"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="soft" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="14"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="softer" x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="26"/>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<clipPath id="squircle">
|
||||||
|
<path d="M256 8
|
||||||
|
C 120 8, 8 120, 8 256
|
||||||
|
C 8 392, 120 504, 256 504
|
||||||
|
C 392 504, 504 392, 504 256
|
||||||
|
C 504 120, 392 8, 256 8 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<g clip-path="url(#squircle)">
|
||||||
|
<rect x="0" y="0" width="512" height="512" fill="url(#bg)"/>
|
||||||
|
<!-- ambient teal glow from the orb spilling into the navy -->
|
||||||
|
<circle cx="256" cy="262" r="150" fill="#009B98" opacity="0.18" filter="url(#softer)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Concentric ripple rings (echo / memory) -->
|
||||||
|
<g fill="none" stroke="#3FE0DB" stroke-linecap="round">
|
||||||
|
<circle cx="256" cy="262" r="96" stroke-width="3" opacity="0.55"/>
|
||||||
|
<circle cx="256" cy="262" r="120" stroke-width="2.5" opacity="0.32"/>
|
||||||
|
<circle cx="256" cy="262" r="142" stroke-width="2" opacity="0.18"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Vault / open container: ring open at the top, cradling the orb -->
|
||||||
|
<path d="M 370.9 165.6 A 150 150 0 1 1 141.1 165.6"
|
||||||
|
fill="none" stroke="url(#vault)" stroke-width="26" stroke-linecap="round"/>
|
||||||
|
<!-- inner highlight on the vault -->
|
||||||
|
<path d="M 370.9 165.6 A 150 150 0 1 1 141.1 165.6"
|
||||||
|
fill="none" stroke="#5BC7E6" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
|
||||||
|
|
||||||
|
<!-- Orb glow halo -->
|
||||||
|
<circle cx="256" cy="262" r="74" fill="#00C2BE" opacity="0.55" filter="url(#soft)"/>
|
||||||
|
<!-- Orb -->
|
||||||
|
<circle cx="256" cy="262" r="62" fill="url(#orb)"/>
|
||||||
|
<!-- Orb specular highlight -->
|
||||||
|
<ellipse cx="236" cy="240" rx="22" ry="16" fill="#EAFFFE" opacity="0.45"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "echo-memory",
|
"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.",
|
"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": {
|
"author": {
|
||||||
"name": "Jason Stedwell"
|
"name": "Jason Stedwell"
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -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 ...`.
|
it, or `python3 echo.py config set --owner ... --endpoint ... --key ...`.
|
||||||
|
|
||||||
Per-field resolution (first hit wins):
|
Per-field resolution (first hit wins):
|
||||||
owner env ECHO_OWNER -> config "owner"
|
owner env ECHO_OWNER -> config "owner" -> baked DEFAULT_OWNER
|
||||||
endpoint env ECHO_BASE -> config "endpoint"
|
endpoint env ECHO_BASE -> config "endpoint" -> baked DEFAULT_BASE
|
||||||
key env ECHO_KEY -> config "key"
|
key env ECHO_KEY -> config "key" -> baked DEFAULT_KEY
|
||||||
|
|
||||||
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
|
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
|
||||||
file path itself can be overridden with $ECHO_CONFIG. Pure stdlib only.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -30,6 +39,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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 = {
|
TEMPLATE = {
|
||||||
"owner": "Full Name — how the agent should refer to the vault owner (third person)",
|
"owner": "Full Name — how the agent should refer to the vault owner (third person)",
|
||||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
"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
|
which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable
|
||||||
even when nothing is configured yet."""
|
even when nothing is configured yet."""
|
||||||
f = _from_file()
|
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 {
|
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,
|
"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"
|
return f"{env} env"
|
||||||
if config_path().exists() and _from_file().get(field):
|
if config_path().exists() and _from_file().get(field):
|
||||||
return "config file"
|
return "config file"
|
||||||
|
baked = {"owner": DEFAULT_OWNER, "endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field]
|
||||||
|
if baked:
|
||||||
|
return "baked-in default"
|
||||||
return "unset"
|
return "unset"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user