ver 1.3 and 1.3.1
This commit is contained in:
@@ -1,31 +1,38 @@
|
||||
---
|
||||
name: echo-memory
|
||||
description: Use the ECHO Obsidian vault as Jason's persistent memory across Claude/CoWork sessions. Use whenever Jason asks to remember, save, note, log, or capture anything durable — facts, preferences, decisions, schedule changes, commitments — or asks what Claude knows about him, what was discussed or decided before, to check his notes, load his memory/profile/context, add to his inbox, or to track a new project. Trigger even without memory phrasing when the request implies recalling or persisting state across sessions ("pick up where we left off", "anything on X before my meeting?"). Also use proactively at the start of substantive work sessions to load context and at the end to log outcomes. Do NOT use for Bryan's goldbrain vault, Obsidian/REST-API development questions, "memory" meaning RAM, timed reminders, email or local-file lookups, generic second-brain advice, or notes Jason routes to a specific other file or app.
|
||||
description: Use the ECHO Obsidian vault as the operator's persistent memory across Claude/CoWork sessions. Use whenever the operator asks to remember, save, note, log, or capture anything durable — facts, preferences, decisions, schedule changes, commitments — or asks what Claude knows about them, what was discussed or decided before, to check their notes, load their memory/profile/context, add to their inbox, or to track a new project. Trigger even without memory phrasing when the request implies recalling or persisting state across sessions ("pick up where we left off", "anything on X before my meeting?"). Also use proactively at the start of substantive work sessions to load context and at the end to log outcomes. Do NOT use for a different owner's vault, Obsidian/REST-API development questions, "memory" meaning RAM, timed reminders, email or local-file lookups, generic second-brain advice, or notes the operator routes to a specific other file or app.
|
||||
---
|
||||
|
||||
# ECHO Memory
|
||||
|
||||
Use the **ECHO** Obsidian vault as persistent memory. Read context accumulated across sessions; write things the operator asks to be remembered.
|
||||
|
||||
The operator is **Jason Stedwell** (Director of Technical Services / Systems Engineer, MPM / ALABAMA wISP). Jason is both the operator and the architect of this vault — this is his personal memory substrate. Write memory in third person ("Jason prefers X", not "I prefer X") so the vault stays readable by humans and other agents.
|
||||
The vault belongs to a single **owner**, configured per machine (the `owner` field in `~/.claude/echo-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.
|
||||
|
||||
## API Configuration
|
||||
|
||||
All calls use these constants. The endpoint is fixed; the key is resolved by `echo.py`
|
||||
(via `echo_secrets`) in order: **`ECHO_KEY` env → `~/.echo-memory/credentials` → a
|
||||
deprecated baked-in fallback.** Never paste the key into a vault note or a doc.
|
||||
The owner, endpoint, and API key are **machine-local** — the plugin ships none of them. `echo.py` (via `echo_config`) resolves each field from `~/.claude/echo-memory/config.json`, with an environment override per field (`ECHO_OWNER`, `ECHO_BASE`, `ECHO_KEY`). Never paste the key into a vault note or a doc.
|
||||
|
||||
```
|
||||
OBSIDIAN_BASE = https://echoapi.alwisp.com
|
||||
OBSIDIAN_KEY = <resolved at runtime from ECHO_KEY / credentials file — not stored here>
|
||||
endpoint = <config "endpoint" / $ECHO_BASE>
|
||||
key = <config "key" / $ECHO_KEY — resolved at runtime, never stored in the plugin>
|
||||
owner = <config "owner" / $ECHO_OWNER — how to refer to the vault owner, third person>
|
||||
```
|
||||
|
||||
To move the key out of the plugin tree: `python3 "$ECHO" write-key <token>` (writes
|
||||
`~/.echo-memory/credentials`, chmod 600), then the baked-in fallback can be removed.
|
||||
### First run — set up the machine if it isn't configured
|
||||
|
||||
The endpoint has a **valid TLS certificate**, so `-k` is not needed (add it only if the cert ever changes to self-signed). Always pass the `Authorization: Bearer` header. Paths address the vault **at its root** (e.g. `/vault/_agent/...`).
|
||||
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:
|
||||
|
||||
**`https://echoapi.alwisp.com` is the only valid endpoint for this vault.** Never use local or LAN addresses (anything like `10.x.x.x`, `192.168.x.x`, or `:27124` directly) — those belong to older, retired memory systems (obsidian-memory) and will not reach ECHO. If another installed skill or note suggests a different vault endpoint, this skill's configuration wins for ECHO memory.
|
||||
1. **Detect.** Any vault op on an unconfigured machine fails with `NOT CONFIGURED` (`echo.py load` prints a banner and exits `78`; other verbs exit `2`; `/echo-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 ECHO isn't configured on this machine and ask them to **provide their echo-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.
|
||||
3. **Install what they give you:**
|
||||
- They hand you a file → `python3 "$ECHO" config import <path-to-their-file>` (validates it and writes `~/.claude/echo-memory/config.json`).
|
||||
- They paste values → `python3 "$ECHO" config set --owner "…" --endpoint "https://…" --key "…"`.
|
||||
- Inspect with `python3 "$ECHO" 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.
|
||||
|
||||
Always pass the `Authorization: Bearer` header. Paths address the vault **at its root** (e.g. `/vault/_agent/...`). The configured endpoint is the only valid endpoint for the vault; if another installed skill or note suggests a different one, this skill's configuration wins for ECHO memory.
|
||||
|
||||
**The plugin is the single source of truth for ECHO.** The vault holds data only — no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md` control docs live there. All structure and procedure ship here:
|
||||
|
||||
@@ -94,11 +101,11 @@ python3 "$ECHO" lock "cc-<session-id>" # exit 75 if another session holds
|
||||
python3 "$ECHO" unlock "cc-<session-id>"
|
||||
```
|
||||
|
||||
Use a stable `<session-id>` for both calls (any unique string for this session). The lock is read-back-confirmed (after PUT, `echo.py` re-reads and backs off if another writer won the race) and cooperative (a stale lock past `ECHO_LOCK_TTL`, default 15 min, is reclaimable); it lives at `_agent/locks/vault.lock`. It is a courtesy, not a hard mutex — if you can't take it, tell Jason another session may be active rather than racing it.
|
||||
Use a stable `<session-id>` for both calls (any unique string for this session). The lock is read-back-confirmed (after PUT, `echo.py` re-reads and backs off if another writer won the race) and cooperative (a stale lock past `ECHO_LOCK_TTL`, default 15 min, is reclaimable); it lives at `_agent/locks/vault.lock`. It is a courtesy, not a hard mutex — if you can't take it, tell the operator another session may be active rather than racing it.
|
||||
|
||||
### Slash commands
|
||||
|
||||
`/echo-load` (cold-start read), `/echo-save <text>` (route + persist via `capture`), `/echo-recall <query>` (recall a topic's neighbourhood), `/echo-triage` (drain the inbox), `/echo-health` (run the linter), `/echo-sweep` (bring the vault up to current spec). These are explicit entry points to the procedures below.
|
||||
`/echo-load` (cold-start read), `/echo-save <text>` (route + persist via `capture`), `/echo-recall <query>` (recall a topic's neighbourhood), `/echo-triage` (drain the inbox), `/echo-health` (run the linter), `/echo-sweep` (bring the vault up to current spec), `/echo-reflect` (extract→preview→apply durable items from the session), `/echo-doctor` (readiness check: config, reachability, auth, bootstrap/schema). These are explicit entry points to the procedures below.
|
||||
|
||||
## Operating Contract & Safety
|
||||
|
||||
@@ -113,7 +120,7 @@ Full contract (principles, agent role, memory model): `references/operating-cont
|
||||
|
||||
## When to Load Memory
|
||||
|
||||
Load at the start of any substantive conversation — anything beyond a single quick factual question. The signal: Jason is starting work, planning, asking for help with something that has state, or referencing prior discussions.
|
||||
Load at the start of any substantive conversation — anything beyond a single quick factual question. The signal: the operator is starting work, planning, asking for help with something that has state, or referencing prior discussions.
|
||||
|
||||
### Loading procedure
|
||||
|
||||
@@ -122,7 +129,7 @@ Load at the start of any substantive conversation — anything beyond a single q
|
||||
| # | GET | Notes |
|
||||
|---|-----|-------|
|
||||
| 1 | `/vault/_agent/echo-vault.md` | The bootstrap marker. 404 → vault not set up; follow `references/bootstrap.md`. 200 → check its `schema_version` and migrate if older. |
|
||||
| 2 | `/vault/_agent/memory/semantic/operator-preferences.md` | Jason's profile |
|
||||
| 2 | `/vault/_agent/memory/semantic/operator-preferences.md` | the operator's profile |
|
||||
| 3 | `/vault/_agent/context/current-context.md` | Active scope + Scope History |
|
||||
| 4 | `/vault/_agent/heartbeat/last-session.md` → then `/vault/_agent/sessions/` | **Read the heartbeat first** — a one-line pointer (`<session-log-path> @ <ISO-timestamp>`) written at the end of the previous session. It's an O(1) jump to the latest log, so you can skip or shortcut the full listing. Fall back to the `sessions/` listing only if the pointer is missing or looks stale; then pick the ~5 most recent by reverse lex sort (filenames `YYYY-MM-DD-HHMM-<slug>.md`, so lex == chrono) and read only the relevant ones. |
|
||||
| 5 | `/vault/journal/daily/YYYY-MM-DD.md` | Today's note; 404 is fine — it's created on first agent activity |
|
||||
@@ -133,9 +140,9 @@ Do not read every session log — older sessions are reachable via `POST /search
|
||||
**Reconcile at load (do this every cold start, after the batch returns).** The batch already fetched everything needed for a cheap self-check — run it before diving into the work so memory maintains itself instead of drifting:
|
||||
|
||||
1. **Inbox depth (Inbox Triage).** If `inbox/captures/inbox.md` (GET #6) holds dated capture lines older than ~7 days that were never routed, surface the count once and offer to triage — see **Inbox Triage** below. This is the load-time trigger that makes triage self-firing rather than something you only run when asked.
|
||||
2. **Scope drift (state it, don't just check it).** Scope is the most churn-prone state — Jason runs several sessions a day across different topics, so the recorded `## Scope` is frequently stale at load. **Silently working under a stale scope is the default failure mode.** To prevent it, at load read the active scope and its freshness in one call — `python3 "$ECHO" scope show` (prints `## Scope`, `scope_updated`, and how many sessions have been logged since) — and form a one-line judgment: *does this session's request match the recorded scope?* If it diverges, switch **before** doing the work via `python3 "$ECHO" scope set "<new scope>"` (see **Scope Switching**). If `scope show` reports several sessions logged since the last switch, treat the recorded scope as suspect and confirm with Jason rather than trusting it.
|
||||
2. **Scope drift (state it, don't just check it).** Scope is the most churn-prone state — the operator runs several sessions a day across different topics, so the recorded `## Scope` is frequently stale at load. **Silently working under a stale scope is the default failure mode.** To prevent it, at load read the active scope and its freshness in one call — `python3 "$ECHO" scope show` (prints `## Scope`, `scope_updated`, and how many sessions have been logged since) — and form a one-line judgment: *does this session's request match the recorded scope?* If it diverges, switch **before** doing the work via `python3 "$ECHO" scope set "<new scope>"` (see **Scope Switching**). If `scope show` reports several sessions logged since the last switch, treat the recorded scope as suspect and confirm with the operator rather than trusting it.
|
||||
|
||||
Keep the reconcile to a single short line to Jason (e.g. "3 inbox captures from last week are still un-routed — triage now?"); don't let it crowd out the actual request.
|
||||
Keep the reconcile to a single short line to the operator (e.g. "3 inbox captures from last week are still un-routed — triage now?"); don't let it crowd out the actual request.
|
||||
|
||||
**If a specific topic/project/person is in play**, pull its connected context in one call:
|
||||
|
||||
@@ -162,7 +169,7 @@ When routing accepted items, send each to its proper home:
|
||||
- Durable fact → PUT `_agent/memory/semantic/<slug>.md`
|
||||
- Person fact → PUT/PATCH `resources/people/<name>.md`
|
||||
|
||||
Then record the move in `inbox/processing-log/YYYY-MM-DD.md` (one line per item: `- <original line> → <destination path>`). Don't delete the original capture unless Jason explicitly asks — the processing log is the audit trail.
|
||||
Then record the move in `inbox/processing-log/YYYY-MM-DD.md` (one line per item: `- <original line> → <destination path>`). Don't delete the original capture unless the operator explicitly asks — the processing log is the audit trail.
|
||||
|
||||
## Project Lifecycle
|
||||
|
||||
@@ -181,14 +188,14 @@ Projects move through four folders under `projects/`. The folder name and the `s
|
||||
|
||||
## When to Write Memory
|
||||
|
||||
Write when Jason:
|
||||
Write when the operator:
|
||||
|
||||
- States a fact, preference, or commitment worth keeping ("I prefer X", "we use uv not pip", "standup is Tuesday at 10")
|
||||
- Makes a non-obvious decision worth recording
|
||||
- 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 Jason. 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`.
|
||||
|
||||
**Default write path: `capture`.** For a routed memory (a fact about a person/company/project/concept/etc.), prefer `python3 "$ECHO" 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.
|
||||
|
||||
@@ -204,9 +211,9 @@ python3 "$ECHO" search <slug> # fallback when the index isn't trusted
|
||||
If a match is found:
|
||||
- In a non-active project subfolder (`on-hold/`, `incubating/`, `archived/`): **promote/merge** — PUT the merged content to `projects/active/<slug>.md` with `status: active`, then DELETE the old location. Preserve the earliest `created:` date.
|
||||
- In the same folder you intended to write: **update in place** (PATCH or merged PUT). Never silently overwrite — fold the existing content in first.
|
||||
- Elsewhere (e.g. a stale duplicate under `resources/`): tell Jason and ask which should be canonical before writing.
|
||||
- Elsewhere (e.g. a stale duplicate under `resources/`): tell the operator and ask which should be canonical before writing.
|
||||
|
||||
**Search both the slug AND any human title** Jason used (e.g. slug `echo-memory` and title `ECHO plugin`). Slug-only searches miss notes filed under a different naming scheme. Two cheap `POST /search/simple/?query=...` calls beat one expensive cleanup pass later.
|
||||
**Search both the slug AND any human title** the operator used (e.g. slug `echo-memory` and title `ECHO plugin`). Slug-only searches miss notes filed under a different naming scheme. Two cheap `POST /search/simple/?query=...` calls beat one expensive cleanup pass later.
|
||||
|
||||
Only after the search comes back empty (or you've decided to merge) is it safe to create a new note. This rule prevents the most common duplication bug: a note exists in `on-hold/` but the agent only checked `active/` and created a parallel record.
|
||||
|
||||
@@ -244,7 +251,7 @@ Every PUT body needs the canonical YAML frontmatter (see `references/vault-layou
|
||||
|
||||
## Scope Switching (`current-context.md`)
|
||||
|
||||
`_agent/context/current-context.md` tracks a single active scope. Jason routinely shifts scope within a day (echo plugin → MPM brand → WISP docs), so this is high-churn — switch deliberately, every time the work changes topic.
|
||||
`_agent/context/current-context.md` tracks a single active scope. The operator routinely shifts scope within a day (one project → another → docs), so this is high-churn — switch deliberately, every time the work changes topic.
|
||||
|
||||
**Preferred — one command:**
|
||||
|
||||
@@ -278,7 +285,7 @@ A weekly/monthly rollup is a **light digest** — open threads across `projects/
|
||||
|
||||
## Vault Health (monthly)
|
||||
|
||||
On the first substantive session of a calendar month, run a quick health pass and write findings to `_agent/health/YYYY-MM-vault-health.md`. This is **agent self-maintenance, not a journal entry** — it lives under `_agent/` because it's about the vault's mechanical integrity, not Jason's work narrative. Don't auto-fix without asking.
|
||||
On the first substantive session of a calendar month, run a quick health pass and write findings to `_agent/health/YYYY-MM-vault-health.md`. This is **agent self-maintenance, not a journal entry** — it lives under `_agent/` because it's about the vault's mechanical integrity, not the operator's work narrative. Don't auto-fix without asking.
|
||||
|
||||
Run the bundled linter first — it mechanically checks the invariants below so you don't eyeball them. **Pass `ECHO_TODAY` = the conversation's `currentDate`** so stale/aging math uses the same clock you write with (not the runner's machine date):
|
||||
|
||||
@@ -309,7 +316,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/scripts/sweep.py" --apply #
|
||||
|
||||
Run it after `migrate.py` (which handles structural/schema changes) — or any time `/echo-health` reports index drift. It is idempotent; re-running is safe.
|
||||
|
||||
The pass is cheap and pays for itself by catching drift before it requires a reorg. Write the findings as a digest; act on them only with Jason's go-ahead.
|
||||
The pass is cheap and pays for itself by catching drift before it requires a reorg. Write the findings as a digest; act on them only with the operator's go-ahead.
|
||||
|
||||
**Performance.** Full-vault scripts (`sweep.py`, `vault_lint.py`, recall rebuild) are connection-pooled (keep-alive) and read the whole vault concurrently via `echo.read_many`, so they finish in about a second on a few-hundred-note vault instead of timing out on hundreds of serial TLS handshakes. Tune with `ECHO_WORKERS` (default 8, bulk-read concurrency) and `ECHO_TIMEOUT` (default 30s, per-request). For a vault large enough that even the concurrent pass nears the tool timeout, run the script with the harness's background-execution option rather than blocking the turn.
|
||||
|
||||
@@ -359,11 +366,11 @@ python3 "$ECHO" patch journal/daily/<currentDate>.md append heading "<currentDat
|
||||
|
||||
**Routing triggers — areas / meetings / skills:**
|
||||
|
||||
- **Area** (`areas/`): a standing responsibility Jason maintains with no finish line. Decision rule: has an end state → `projects/`; never "done" → `areas/`. Subfolder is the domain (`business`/`personal`/`learning`/`systems`); create it on first write. `type: area`.
|
||||
- **Area** (`areas/`): a standing responsibility the operator maintains with no finish line. Decision rule: has an end state → `projects/`; never "done" → `areas/`. Subfolder is the domain (`business`/`personal`/`learning`/`systems`); create it on first write. `type: area`.
|
||||
- **Meeting** (`resources/meetings/`): notes, recaps, or action items tied to a specific meeting or call. Mirror decisions to `decisions/by-date/` and commitments to the relevant project. `type: meeting`.
|
||||
- **Skill** (`_agent/skills/`): Jason authors, installs, or retires a skill/plugin and wants it tracked as a capability — distinct from `projects/`, which tracks the build effort. Active entries in `active/`, retired ones in `archived/`. `type: skill`.
|
||||
- **Skill** (`_agent/skills/`): the operator authors, installs, or retires a skill/plugin and wants it tracked as a capability — distinct from `projects/`, which tracks the build effort. Active entries in `active/`, retired ones in `archived/`. `type: skill`.
|
||||
|
||||
Never delete files unless Jason explicitly asks. Memory is append-friendly; deletion is destructive.
|
||||
Never delete files unless the operator explicitly asks. Memory is append-friendly; deletion is destructive.
|
||||
|
||||
## Session Logging
|
||||
|
||||
@@ -389,12 +396,12 @@ python3 "$ECHO" put _agent/heartbeat/last-session.md <bodyfile>
|
||||
|
||||
## Vault Unreachable
|
||||
|
||||
If the API returns a connection error, timeout, or `502`, tell Jason once that the memory vault is unreachable (a `502` usually means Obsidian/the REST plugin is not running on the backend), then proceed without memory. Do not retry repeatedly.
|
||||
If the API returns a connection error, timeout, or `502`, tell the operator once that the memory vault is unreachable (a `502` usually means Obsidian/the REST plugin is not running on the backend), then proceed without memory. Do not retry repeatedly.
|
||||
|
||||
## Style Rules
|
||||
|
||||
- Write in third person about Jason: "Jason prefers X", not "I prefer X".
|
||||
- Jason is both operator and architect here — unlike goldbrain (Bryan's vault, which Jason architected). Do not cross-write: Bryan's preferences belong in goldbrain, Jason's belong here.
|
||||
- Write in third person about the operator: "the operator prefers X", not "I prefer X".
|
||||
- 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).
|
||||
@@ -402,15 +409,15 @@ If the API returns a connection error, timeout, or `502`, tell Jason once that t
|
||||
- **`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]]`.
|
||||
- Use Obsidian wiki links (`[[note name]]`) freely in the note **body** for cross-references.
|
||||
- Keep entries short and focused. Fewer, sharper entries beat many noisy ones — Jason explicitly prefers concision.
|
||||
- About to write something large or sensitive? Show Jason the content first and confirm.
|
||||
- Keep entries short and focused. Fewer, sharper entries beat many noisy ones — the operator explicitly prefers concision.
|
||||
- About to write something large or sensitive? Show the operator the content first and confirm.
|
||||
|
||||
## operator-preferences.md — Rules vs Observations
|
||||
|
||||
`_agent/memory/semantic/operator-preferences.md` separates two kinds of content:
|
||||
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. These are timeless: "Jason prefers concise communication." Append here only when a rule is stable.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed: `- 2026-06-06: Jason chose X over Y because Z.` This is where new evidence goes by default.
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. These are timeless: "the operator prefers concise communication." Append here only when a rule is stable.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed: `- 2026-06-06: the operator chose X over Y because Z.` This is where new evidence goes by default.
|
||||
|
||||
During monthly Vault Health or when an observation stabilizes, promote it from `## Observations` into `## Fact / Pattern` (drop the date) and remove the duplicate from Observations. Trim Observations to the last ~30 entries when it grows past that — the rest live in session logs.
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
# ECHO Performance Test Suite — Plan
|
||||
|
||||
**Status:** plan only — nothing here is to be run yet.
|
||||
**Author:** drafted for Jason Stedwell, 2026-06-23.
|
||||
**Author:** drafted for the vault owner, 2026-06-23.
|
||||
**Targets the changes that landed in:** 1.1.0 (connection pooling + concurrent `read_many` + shared cache) and 1.2.0 (fuzzy `resolve`, alias derivation/learning, `capture` re-slug fix).
|
||||
**Scope:** a *performance / timing* harness. It complements — does not replace — the existing correctness tests (`test_echo_client.py`, `test_v1_scaffold.py`, the eval feature tests), which stay as the pass/fail gate for behaviour.
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. Put hard numbers on the three operations Jason called out — full endpoint read, full subject pulls, bulk get/put/append — so the 1.1.0 perf claims ("lint 0.90s / sweep 0.85s on 186 notes, ~4.5× from reuse, ~2× from concurrency") become a repeatable, checked-in measurement instead of a one-time observation.
|
||||
1. Put hard numbers on the three operations the operator called out — full endpoint read, full subject pulls, bulk get/put/append — so the 1.1.0 perf claims ("lint 0.90s / sweep 0.85s on 186 notes, ~4.5× from reuse, ~2× from concurrency") become a repeatable, checked-in measurement instead of a one-time observation.
|
||||
2. Establish **baselines + regression gates** so a future change that quietly reintroduces the pre-1.1.0 per-request TLS handshake (the bug that was timing out sessions) fails the suite instead of silently shipping.
|
||||
3. Keep every run **non-destructive to the production vault** — writes go to a disposable namespace and are cleaned up (per Jason's "prune test artifacts" rule).
|
||||
3. Keep every run **non-destructive to the production vault** — writes go to a disposable namespace and are cleaned up (per the operator's "prune test artifacts" rule).
|
||||
|
||||
## 2. Methodology (applies to every metric)
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
- **Environment capture:** record `ECHO_WORKERS`, `ECHO_TIMEOUT`, vault note count, git commit/plugin version, and wall-clock date in the results header so two runs are comparable.
|
||||
- **Output:** machine-readable JSON (`eval/perf/results/<date>-<commit>.json`) plus a short human table to stdout. JSON lets us diff runs over time and chart trend.
|
||||
- **Isolation:** all test writes live under a dedicated prefix, e.g. `_agent/_bench/<run-id>/...`, never in `projects/`, `resources/`, `inbox/`, or `journal/`. A `--cleanup` pass deletes the whole prefix at the end; a `--keep` flag preserves it for debugging. The advisory lock (`echo.py lock`) is taken for the write phases so a bench run can't race a real CoWork session.
|
||||
- **Network honesty:** the endpoint is the live `echoapi.alwisp.com`, so absolute numbers depend on WAN latency. Report **both** absolute ms and a relative ratio against the same run's single-GET baseline, so the suite stays meaningful regardless of where it's run from.
|
||||
- **Network honesty:** the endpoint is the live configured endpoint (`echo.BASE` / `$ECHO_BASE`), so absolute numbers depend on WAN latency. Report **both** absolute ms and a relative ratio against the same run's single-GET baseline, so the suite stays meaningful regardless of where it's run from.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core metrics (the three Jason asked for)
|
||||
## 3. Core metrics (the three the operator asked for)
|
||||
|
||||
### 3.1 Timed full endpoint read (full-vault bulk read)
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
### 3.3 Timed bulk get / put / append
|
||||
|
||||
**What it measures:** write-path throughput and the read-back/idempotency overhead Jason deliberately pays for.
|
||||
**What it measures:** write-path throughput and the read-back/idempotency overhead the operator deliberately pays for.
|
||||
|
||||
**Procedure (all in the `_agent/_bench/<run-id>/` namespace):**
|
||||
- **Bulk GET:** create K fixture notes once, then time reading them back — both serially and via `read_many` — to compare against §3.1 at a controlled, fixed K (e.g. 25/50/100) so the read curve is isolated from whatever the live vault happens to contain.
|
||||
|
||||
@@ -6,7 +6,7 @@ targeted. It reuses the live `echo` client module, so it measures the real poole
|
||||
concurrent code path — not a reimplementation.
|
||||
|
||||
This is a **manual / pre-release** tool, not a CI gate: it depends on the live
|
||||
endpoint (`echoapi.alwisp.com`) and WAN latency. Gates are **relative ratios**
|
||||
configured endpoint (`echo.BASE` / `$ECHO_BASE`) and WAN latency. Gates are **relative ratios**
|
||||
(portable), with absolute milliseconds kept as informational context.
|
||||
|
||||
See `PERF-TEST-PLAN.md` for the design rationale behind each metric.
|
||||
@@ -25,8 +25,8 @@ python3 bench.py resolve # 1.2.0 fuzzy correctness table + timin
|
||||
python3 bench.py soak --soak-seconds 120 # stability / leak watch
|
||||
```
|
||||
|
||||
Set the key first if the baked-in fallback warning bothers you:
|
||||
`python3 ../../scripts/echo.py write-key <token>` (or `export ECHO_KEY=...`).
|
||||
Configure this machine first (owner/endpoint/key):
|
||||
`python3 ../../scripts/echo.py config init` then edit the file, or `export ECHO_BASE=... ECHO_KEY=...`.
|
||||
|
||||
## Metrics
|
||||
|
||||
@@ -90,7 +90,7 @@ metric + gate above regression-guard this so it can't quietly revert to serial.
|
||||
sandbox vault so the replay doesn't write to production; that's deferred (the
|
||||
plan's synthetic-vault item). The metric still proves enqueue works and reports
|
||||
queue depth.
|
||||
- **Absolute ms depend on where you run from.** WAN latency to `echoapi.alwisp.com`
|
||||
dominates single-request times; that's why gates are ratios.
|
||||
- **Absolute ms depend on where you run from.** WAN latency to the configured
|
||||
endpoint dominates single-request times; that's why gates are ratios.
|
||||
- **Synthetic-vault scaling (100/500/1000 notes) is deferred** per decision — the
|
||||
suite benchmarks the real ~190-note vault today.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "Relative-ratio regression gates (Jason's chosen policy). Ratios are portable across machines/WAN; absolute ms in the results JSON stay informational. Tune these after the first real baseline run, then tighten. 'field' is a dotted path into a metric's result; for list items use the index (e.g. sweep.3.median_ms).",
|
||||
"_comment": "Relative-ratio regression gates (the maintainer's chosen policy). Ratios are portable across machines/WAN; absolute ms in the results JSON stay informational. Tune these after the first real baseline run, then tighten. 'field' is a dotted path into a metric's result; for list items use the index (e.g. sweep.3.median_ms).",
|
||||
"read-full": {
|
||||
"gates": [
|
||||
{ "field": "speedup_ratio", "op": ">=", "min": 1.8,
|
||||
|
||||
@@ -10,7 +10,7 @@ code path. Reads are non-destructive; write metrics live in a disposable
|
||||
namespace (_agent/_bench/<run-id>/) cleaned up at the end unless --keep.
|
||||
|
||||
This is a MANUAL / pre-release tool, not a CI gate — it depends on the live
|
||||
endpoint (echoapi.alwisp.com) and WAN latency.
|
||||
configured endpoint (echo.BASE / $ECHO_BASE) and WAN latency.
|
||||
|
||||
python3 bench.py <metric> [options]
|
||||
python3 bench.py all # the read-only safe set
|
||||
|
||||
@@ -11,8 +11,8 @@ from __future__ import annotations
|
||||
# --- §3.2 subject pulls -------------------------------------------------------
|
||||
# (label, query) pairs fed to `recall`. Chosen to span neighbourhood sizes — the
|
||||
# real cost driver — from a likely-leaf to a known hub. APTA and CapMetro are the
|
||||
# subjects Jason named; "echo" and "operator preferences" are dense hubs that
|
||||
# stress the 1-hop expansion (the read_many fan-out 1.1.0 accelerated).
|
||||
# subjects the maintainer configured; "echo" and "operator preferences" are dense hubs
|
||||
# that stress the 1-hop expansion (the read_many fan-out 1.1.0 accelerated).
|
||||
SUBJECTS: list[tuple[str, str]] = [
|
||||
("apta", "APTA"),
|
||||
("capmetro", "CapMetro"),
|
||||
@@ -32,13 +32,16 @@ SUBJECTS: list[tuple[str, str]] = [
|
||||
# to nothing and silently spawn a new note)
|
||||
# "miss" -> neither; genuinely unknown
|
||||
# Tune expect_slug to the live vault; unknowns are reported as INFO, not failures.
|
||||
# NOTE: the subject/slug values below are neutral placeholders. The maintainer
|
||||
# should point these fixtures at dense hubs that actually exist in their own
|
||||
# vault, otherwise the resolve cases will report INFO misses rather than hits.
|
||||
RESOLVE_CASES: list[dict] = [
|
||||
{"mention": "echo", "expect": "exact", "expect_slug": "echo"},
|
||||
{"mention": "echo memory", "expect": "candidates", "expect_slug": "echo"},
|
||||
{"mention": "ECHO plugin", "expect": "candidates", "expect_slug": "echo"},
|
||||
{"mention": "goldbrain", "expect": "exact", "expect_slug": "goldbrain"},
|
||||
{"mention": "jason stedwell", "expect": "exact", "expect_slug": "jason-stedwell"},
|
||||
{"mention": "jason", "expect": "candidates", "expect_slug": "jason-stedwell"},
|
||||
{"mention": "other-vault", "expect": "exact", "expect_slug": "other-vault"},
|
||||
{"mention": "example subject", "expect": "exact", "expect_slug": "example-subject"},
|
||||
{"mention": "operator", "expect": "candidates", "expect_slug": "example-subject"},
|
||||
{"mention": "zzqx nonexistent entity", "expect": "miss", "expect_slug": None},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:17:09",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
@@ -353,7 +353,7 @@
|
||||
"expect_slug": "echo",
|
||||
"resolved_slug": "echo",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
@@ -407,7 +407,7 @@
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"echo-skill-improvements",
|
||||
"2026-06-19-goldbrain-full-echo-architect"
|
||||
"2026-06-19-other-vault-full-echo-architect"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -456,8 +456,8 @@
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"alabama-wisp-brand-docs"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"example-isp-brand-docs"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -498,13 +498,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "goldbrain",
|
||||
"mention": "other-vault",
|
||||
"expect": "exact",
|
||||
"expect_slug": "goldbrain",
|
||||
"resolved_slug": "goldbrain",
|
||||
"expect_slug": "other-vault",
|
||||
"resolved_slug": "other-vault",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"goldbrain"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"other-vault"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -545,13 +545,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason stedwell",
|
||||
"mention": "example subject",
|
||||
"expect": "exact",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-stedwell",
|
||||
"jason-mcp-gateway"
|
||||
"example-subject",
|
||||
"operator-mcp-gateway"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -592,13 +592,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason",
|
||||
"mention": "operator",
|
||||
"expect": "candidates",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-mcp-gateway",
|
||||
"jason-stedwell"
|
||||
"operator-mcp-gateway",
|
||||
"example-subject"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:22:00",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:36",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:21",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:12:16",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T21:22:29",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:20:57",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:01",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:21:28",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:12:12",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:22:05",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:17:09",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:12:19",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
@@ -20,7 +20,7 @@
|
||||
"expect_slug": "echo",
|
||||
"resolved_slug": "echo",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
@@ -74,7 +74,7 @@
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"echo-skill-improvements",
|
||||
"2026-06-19-goldbrain-full-echo-architect"
|
||||
"2026-06-19-other-vault-full-echo-architect"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -123,8 +123,8 @@
|
||||
"echo",
|
||||
"echo-memory-codex-plugin",
|
||||
"echo-plugin-build",
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"alabama-wisp-brand-docs"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"example-isp-brand-docs"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
@@ -165,13 +165,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "goldbrain",
|
||||
"mention": "other-vault",
|
||||
"expect": "exact",
|
||||
"expect_slug": "goldbrain",
|
||||
"resolved_slug": "goldbrain",
|
||||
"expect_slug": "other-vault",
|
||||
"resolved_slug": "other-vault",
|
||||
"candidate_slugs": [
|
||||
"2026-06-19-goldbrain-full-echo-architect",
|
||||
"goldbrain"
|
||||
"2026-06-19-other-vault-full-echo-architect",
|
||||
"other-vault"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -212,13 +212,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason stedwell",
|
||||
"mention": "example subject",
|
||||
"expect": "exact",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-stedwell",
|
||||
"jason-mcp-gateway"
|
||||
"example-subject",
|
||||
"operator-mcp-gateway"
|
||||
],
|
||||
"verdict": "PASS",
|
||||
"resolve_timing": {
|
||||
@@ -259,13 +259,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"mention": "jason",
|
||||
"mention": "operator",
|
||||
"expect": "candidates",
|
||||
"expect_slug": "jason-stedwell",
|
||||
"resolved_slug": "jason-stedwell",
|
||||
"expect_slug": "example-subject",
|
||||
"resolved_slug": "example-subject",
|
||||
"candidate_slugs": [
|
||||
"jason-mcp-gateway",
|
||||
"jason-stedwell"
|
||||
"operator-mcp-gateway",
|
||||
"example-subject"
|
||||
],
|
||||
"verdict": "INFO",
|
||||
"resolve_timing": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:22:15",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T21:18:12",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:20:23",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"date": "2026-06-23T20:18:05",
|
||||
"endpoint": "https://echoapi.alwisp.com",
|
||||
"endpoint": "https://obsidian.example.com",
|
||||
"commit": "af16598",
|
||||
"echo_workers": 8,
|
||||
"echo_timeout": 30,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"subtitle": "Validation of the 1.1.0 concurrency and 1.2.0 entity-resolution releases",
|
||||
"reference": "echo-v.05 @ af16598",
|
||||
"status": "11/11 gate checks pass",
|
||||
"summary": "Full-vault reads run 7.1x faster concurrent than serial (11.4 s down to 1.6 s, 197 notes). Entity resolution is sub-millisecond. Profiling traced recall() latency to serial graph expansion (not BM25); the fix makes expansion concurrent (3.5-4.2x, identical results). All 11 gate checks pass. Run live against echoapi.alwisp.com on 2026-06-23.",
|
||||
"summary": "Full-vault reads run 7.1x faster concurrent than serial (11.4 s down to 1.6 s, 197 notes). Entity resolution is sub-millisecond. Profiling traced recall() latency to serial graph expansion (not BM25); the fix makes expansion concurrent (3.5-4.2x, identical results). All 11 gate checks pass. Run live against obsidian.example.com on 2026-06-23.",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Test Environment",
|
||||
@@ -11,7 +11,7 @@
|
||||
"rows": [
|
||||
{
|
||||
"label": "Endpoint",
|
||||
"value": "echoapi.alwisp.com (live)"
|
||||
"value": "obsidian.example.com (live)"
|
||||
},
|
||||
{
|
||||
"label": "Vault size",
|
||||
@@ -513,7 +513,7 @@
|
||||
"items": [
|
||||
"Concurrency delivers as designed. Eight workers cut full-vault reads 7.5x; the curve is near-linear to 8 workers and flattens after (8->16 returns only 1.24x). The default of 8 is the right setting for this vault and link.",
|
||||
"Keep-alive is confirmed live. A cold request costs 163 ms against a 50 ms warm median (3.25x). This is the direct measure that the 1.1.0 pooling fix removed the per-request TLS handshake that was timing out full-vault passes.",
|
||||
"Entity resolution is effectively free. resolve() returns in under 1 ms across exact, alias, and shortened mentions. The 1.2.0 alias work resolves shortened names (\"echo memory\", \"ECHO plugin\", \"jason\") straight to the canonical note, so the anti-duplicate guard holds without a fuzzy fallback.",
|
||||
"Entity resolution is effectively free. resolve() returns in under 1 ms across exact, alias, and shortened mentions. The 1.2.0 alias work resolves shortened names (\"echo memory\", \"ECHO plugin\", \"operator\") straight to the canonical note, so the anti-duplicate guard holds without a fuzzy fallback.",
|
||||
"recall() latency was traced to the graph layer, not BM25. The BM25 index is already persisted and incrementally maintained (load 500 ms, score 0.1 ms). The cost was expand_graph fetching each neighbour serially \u2014 3.0 s for 126 nodes. Fix: the graph BFS now fetches each hop concurrently via the existing read_many. expand_graph is 3.5-4.2x faster with byte-identical ranking and scores; end-to-end recall() dropped from 2.8-4.7 s to 2.1-3.0 s per subject.",
|
||||
"Full-vault maintenance scripts stay under the tool timeout. sweep.py runs in 4.6 s and vault_lint.py in 5.1 s on 197 notes. The original failure mode (passes exceeding the timeout and dropping the session) does not recur.",
|
||||
"Write integrity holds. PUT verifies via read-back at 115 ms; APPEND is whole-line idempotent, skipping at 51 ms with zero duplicate writes on re-run. The advisory lock fast-fails a contended acquire with the expected exit 75."
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ECHO — Obsidian Local REST API Reference
|
||||
|
||||
Server: `https://echoapi.alwisp.com` (reverse proxy → backend Obsidian Local REST API)
|
||||
Auth header: `Authorization: Bearer $ECHO_KEY` — `export ECHO_KEY=<token>` before running these recipes (`echo.py` resolves it automatically: `ECHO_KEY` → `~/.echo-memory/credentials` → deprecated baked-in fallback). The literal key is never stored in docs.
|
||||
The endpoint has a **valid TLS certificate** — `-k` is not required. Paths address the vault at its **root**.
|
||||
Server: the configured endpoint — the `endpoint` from `~/.claude/echo-memory/config.json` (overridable with `ECHO_BASE`), a reverse proxy → backend Obsidian Local REST API. Examples below use `$ECHO_BASE` (or the neutral placeholder `https://obsidian.example.com`).
|
||||
Auth header: `Authorization: Bearer $ECHO_KEY` — `export ECHO_KEY=<token>` before running these recipes, or let `echo.py` resolve it automatically (per-field, first wins: env override `ECHO_KEY` → the `key` in `~/.claude/echo-memory/config.json`, resolved via the `echo_config` module). The literal key is never stored in docs.
|
||||
A configured endpoint should present a **valid TLS certificate** — `-k` is not required. Paths address the vault at its **root**.
|
||||
|
||||
> **Prefer `scripts/echo.py` over the raw recipes below.** It wraps every verb with auth, status checking, retry, idempotent append, and frontmatter patches, and runs on any platform with a Python interpreter. The `curl` recipes here are the underlying mechanics and a **\*nix/bash fallback** (they use heredocs and `--data-binary`); on Windows, prefer `echo.py` or an equivalent. **If you call `curl` directly, check the HTTP status** — add `-o /dev/null -w "%{http_code}"` and branch on it. A `PATCH` to a non-existent heading returns `400 invalid-target` (errorCode 40080) and the write is *silently lost*; a bare `curl` that ignores status will report success anyway. `GET` returns `404` for a missing file. Treat any `>= 400` as a failed operation, surface it, and do not continue as if it succeeded.
|
||||
|
||||
@@ -29,7 +29,7 @@ For a vault large enough that even the concurrent pass approaches the tool timeo
|
||||
# Read any file by vault path
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/context/current-context.md"
|
||||
"$ECHO_BASE/vault/_agent/context/current-context.md"
|
||||
```
|
||||
|
||||
Returns raw file content (text/markdown). On 404, the file does not exist.
|
||||
@@ -38,7 +38,7 @@ Returns raw file content (text/markdown). On 404, the file does not exist.
|
||||
# Read a specific heading's content only
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
|
||||
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
|
||||
```
|
||||
|
||||
Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
|
||||
@@ -54,7 +54,7 @@ Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
|
||||
# List contents of a directory (trailing slash required)
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/sessions/"
|
||||
"$ECHO_BASE/vault/_agent/sessions/"
|
||||
```
|
||||
|
||||
Returns JSON: `{ "files": [...], "folders": [...] }`.
|
||||
@@ -74,7 +74,7 @@ curl -s -X POST \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_entry.md \
|
||||
"https://echoapi.alwisp.com/vault/inbox/captures/inbox.md"
|
||||
"$ECHO_BASE/vault/inbox/captures/inbox.md"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -105,7 +105,7 @@ curl -s -X PUT \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_file.md \
|
||||
"https://echoapi.alwisp.com/vault/_agent/sessions/2026-06-05-1430-my-session.md"
|
||||
"$ECHO_BASE/vault/_agent/sessions/2026-06-05-1430-my-session.md"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -120,7 +120,7 @@ curl -s -X PUT \
|
||||
|
||||
```bash
|
||||
cat > /tmp/obs_patch.md << 'OBSEOF'
|
||||
Jason prefers concise status updates — lead with the decision.
|
||||
The operator prefers concise status updates — lead with the decision.
|
||||
OBSEOF
|
||||
|
||||
curl -s -X PATCH \
|
||||
@@ -130,7 +130,7 @@ curl -s -X PATCH \
|
||||
-H "Target: Operator Preferences::Fact / Pattern" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_patch.md \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
```
|
||||
|
||||
### Discover heading / block / frontmatter targets
|
||||
@@ -141,7 +141,7 @@ When unsure of the exact heading path, GET the note with the document-map Accept
|
||||
curl -s \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
-H "Accept: application/vnd.olrapi.document-map+json" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
"$ECHO_BASE/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
```
|
||||
|
||||
Returns `{ "headings": [...], "blocks": [...], "frontmatterFields": [...] }`. Copy the heading string verbatim into `Target`.
|
||||
@@ -164,7 +164,7 @@ curl -s -X PATCH \
|
||||
-H "Target: updated" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '"2026-06-05"' \
|
||||
"https://echoapi.alwisp.com/vault/projects/active/vault-foundation.md"
|
||||
"$ECHO_BASE/vault/projects/active/vault-foundation.md"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -174,7 +174,7 @@ curl -s -X PATCH \
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/search/simple/?query=weekly+review"
|
||||
"$ECHO_BASE/search/simple/?query=weekly+review"
|
||||
```
|
||||
|
||||
Returns an array of `{ filename, score, matches: [{ context, match }] }`.
|
||||
@@ -186,7 +186,7 @@ Returns an array of `{ filename, score, matches: [{ context, match }] }`.
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Bearer $ECHO_KEY" \
|
||||
"https://echoapi.alwisp.com/vault/inbox/imports/old-note.md"
|
||||
"$ECHO_BASE/vault/inbox/imports/old-note.md"
|
||||
```
|
||||
|
||||
Only on explicit operator request. Deletion is destructive.
|
||||
|
||||
@@ -20,8 +20,8 @@ python3 "$SCRIPTS/migrate.py" --apply # perform the migration (moves/delet
|
||||
`bootstrap.py` writes through `echo.py`, so every step is status-checked and the marker is written last. The scripts are pure Python (only an interpreter required — no bash, no platform-specific `date`), so they run identically on Windows, macOS, and Linux. The manual steps below document what the script does (and serve as a fallback if the script can't run).
|
||||
|
||||
```
|
||||
AUTH="Authorization: Bearer $ECHO_KEY" # export ECHO_KEY first; echo.py resolves it automatically
|
||||
BASE="https://echoapi.alwisp.com"
|
||||
AUTH="Authorization: Bearer $ECHO_KEY" # export ECHO_KEY first; echo.py resolves it automatically via echo_config
|
||||
BASE="$ECHO_BASE" # the endpoint from ~/.claude/echo-memory/config.json (override: ECHO_BASE)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -138,5 +138,5 @@ Run the same steps 1–5, but GET-probe each path first and **only create what i
|
||||
When the marker's `schema_version` is older than the plugin's, apply the migration steps for each intervening version, then PATCH the marker's `schema_version` frontmatter to the new value.
|
||||
|
||||
- **0 → 1** (control-docs-in-plugin): the vault previously carried root control docs (`CLAUDE.md`, `BOOTSTRAP.md`, `STRUCTURE.md`, `index.md`). Back them up outside the vault, DELETE them, PUT the thin `scaffold/README.vault.md` over the old verbose `README.md`, write the `_agent/echo-vault.md` marker, and scrub now-dangling `[[CLAUDE]]`/`[[BOOTSTRAP]]`/`[[STRUCTURE]]`/`[[index]]` links from the `## Related` sections of `operator-preferences.md` and `current-context.md` (leave historical session logs alone). Confirm with the operator before deleting.
|
||||
- **1 → 2** (reviews-folded-into-journal): the `reviews/` tree is retired. (a) For each note under `reviews/weekly/` and `reviews/monthly/`, MOVE it into `journal/weekly/` (rename `YYYY-Www-review.md` → `YYYY-Www.md`) and `journal/monthly/` respectively, preserving the earliest `created:`. (b) Move any `reviews/monthly/YYYY-MM-vault-health.md` to `_agent/health/`. (c) Move `reviews/quarterly|annual/` artifacts to `journal/quarterly|annual/`. (d) Update inbound `[[reviews/...]]` wikilinks in `## Related` sections to the new paths. (e) DELETE the now-empty `reviews/` tree. Confirm with the operator before deleting; leave historical session logs alone. *(Jason's live vault was hand-migrated for the one existing `2026-W24` artifact on 2026-06-10; this step covers any vault bootstrapped under schema 1.)*
|
||||
- **1 → 2** (reviews-folded-into-journal): the `reviews/` tree is retired. (a) For each note under `reviews/weekly/` and `reviews/monthly/`, MOVE it into `journal/weekly/` (rename `YYYY-Www-review.md` → `YYYY-Www.md`) and `journal/monthly/` respectively, preserving the earliest `created:`. (b) Move any `reviews/monthly/YYYY-MM-vault-health.md` to `_agent/health/`. (c) Move `reviews/quarterly|annual/` artifacts to `journal/quarterly|annual/`. (d) Update inbound `[[reviews/...]]` wikilinks in `## Related` sections to the new paths. (e) DELETE the now-empty `reviews/` tree. Confirm with the operator before deleting; leave historical session logs alone. *(This step covers any vault bootstrapped under schema 1.)*
|
||||
- **2 → 3** (entity index + cross-linking): adds the `_agent/index/` folder for the machine-maintained entity registry. `migrate.py` creates the folder; the **data back-fill is `sweep.py`** — after migrating, run `python3 scripts/sweep.py --apply` to build `_agent/index/entities.json` from the notes already in the vault and to symmetrize existing `## Related` cross-links (it adds only the missing reciprocal direction, never invents links). `sweep.py` is idempotent and dry-run by default; re-run it any time `/echo-health` reports index drift.
|
||||
|
||||
@@ -21,7 +21,7 @@ Views of the same truth: `scripts/routing.json` is the **machine-readable canoni
|
||||
| `inbox/imports/<slug>.md` | Raw external material dropped in wholesale (export, paste, dump) | The raw artifact, unedited | Holds un-triaged *bulk*, vs `captures` which holds single lines | PUT |
|
||||
| `inbox/processing-log/YYYY-MM-DD.md` | An inbox item is routed to its real home | One line: `<original> → <destination path>` | Audit trail of moves — never holds memory itself, only the record of routing | POST |
|
||||
|
||||
Captures and imports are temporary by contract. Triage drains them into the homes below and logs the move; the original is left until Jason okays deletion.
|
||||
Captures and imports are temporary by contract. Triage drains them into the homes below and logs the move; the original is left until the operator okays deletion.
|
||||
|
||||
## journal/ — the one append-only time-series stream
|
||||
|
||||
@@ -53,7 +53,7 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
|
||||
|
||||
| Path | Trigger | What lands | Distinct because | Method |
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `areas/<domain>/<slug>.md` | Ongoing domain Jason maintains indefinitely (`<domain>`: business/personal/learning/systems) | Area note | No end state — the one thing that disqualifies it from `projects/` | PUT |
|
||||
| `areas/<domain>/<slug>.md` | Ongoing domain the operator maintains indefinitely (`<domain>`: business/personal/learning/systems) | Area note | No end state — the one thing that disqualifies it from `projects/` | PUT |
|
||||
|
||||
## resources/ — reference material about the world
|
||||
|
||||
@@ -61,7 +61,7 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `resources/people/<name>.md` | A fact about a specific person | Person note (kebab-case slug) | Keyed to a person, not a topic or event | PUT / PATCH |
|
||||
| `resources/companies/<slug>.md` | A fact about an organization (client, vendor, partner, employer) | Company note (kebab-case slug) | Keyed to an organization, not an individual (`people/`) or an external source (`references/`) | PUT / PATCH |
|
||||
| `resources/concepts/<slug>.md` | A reusable concept/idea Jason wants on file | Concept note | An idea, vs a `reference` which is an external source | PUT |
|
||||
| `resources/concepts/<slug>.md` | A reusable concept/idea the operator wants on file | Concept note | An idea, vs a `reference` which is an external source | PUT |
|
||||
| `resources/references/<slug>.md` | An external source/link worth keeping | Reference note | Points outward (a source), vs `concepts` (an idea) | PUT |
|
||||
| `resources/meetings/YYYY-MM-DD-<slug>.md` | Notes/recap tied to a specific meeting or call | Meeting note; mirror decisions to `decisions/by-date/`, commitments to the project | Event-anchored to a meeting, vs a project's ongoing thread | PUT |
|
||||
|
||||
@@ -80,15 +80,15 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `_agent/echo-vault.md` | Bootstrap / schema migration only | Marker: `schema_version`, bootstrap date | Plugin-owned probe; never hand-edited | GET / PUT |
|
||||
| `_agent/context/current-context.md` | Active scope changes; task focus shifts | `## Scope`, `## Scope History`, priorities | Single *live* scope pointer, vs episodic which is a *past* record | PATCH / PUT |
|
||||
| `_agent/memory/semantic/operator-preferences.md` | A preference/pattern about Jason | Append under `## Observations`; promote to `## Fact / Pattern` when stable | The one curated profile; distinct from ad-hoc semantic notes | PATCH |
|
||||
| `_agent/memory/semantic/<slug>.md` | A durable fact/pattern that isn't a Jason-preference | Semantic note | Timeless fact, vs episodic (time-stamped event) and working (transient) | PUT |
|
||||
| `_agent/memory/semantic/operator-preferences.md` | A preference/pattern about the operator | Append under `## Observations`; promote to `## Fact / Pattern` when stable | The one curated profile; distinct from ad-hoc semantic notes | PATCH |
|
||||
| `_agent/memory/semantic/<slug>.md` | A durable fact/pattern that isn't an operator-preference | Semantic note | Timeless fact, vs episodic (time-stamped event) and working (transient) | PUT |
|
||||
| `_agent/memory/episodic/<slug>.md` | A record of *what happened, when* | Episodic note | Anchored to an event in time; not a standing fact | PUT |
|
||||
| `_agent/memory/working/<slug>.md` | Short-lived state needed only for the current effort | Working note | Explicitly transient/time-boxed; safe to go stale | PUT |
|
||||
| `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md` | A substantive session ends (decisions/artifacts/commitments) | Session log (see template) | Per-session record; the unit loading Step 4 scans | PUT |
|
||||
| `_agent/health/YYYY-MM-vault-health.md` | First substantive session of a month (Vault Health pass) | Health-audit findings | Agent self-maintenance about vault integrity — NOT Jason's work narrative, so not in `journal/` | PUT |
|
||||
| `_agent/health/YYYY-MM-vault-health.md` | First substantive session of a month (Vault Health pass) | Health-audit findings | Agent self-maintenance about vault integrity — NOT the operator's work narrative, so not in `journal/` | PUT |
|
||||
| `_agent/heartbeat/last-session.md` | End of every session, after the session log is written | One line: `<session-log-path> @ <ISO-timestamp>` | O(1) orientation pointer read first at load (Step 4); overwritten, never grows | PUT |
|
||||
| `_agent/templates/` | Bootstrap only (seeded from plugin masters) | Canonical note templates | Holds templates, not memory; never a runtime routing target | PUT (seed) |
|
||||
| `_agent/skills/active/<slug>.md` | Jason authors/installs a skill and wants it catalogued | Skill capability entry | Catalogs a *capability*, vs `projects/` which tracks the *build effort* | PUT |
|
||||
| `_agent/skills/active/<slug>.md` | The operator authors/installs a skill and wants it catalogued | Skill capability entry | Catalogs a *capability*, vs `projects/` which tracks the *build effort* | PUT |
|
||||
| `_agent/skills/archived/<slug>.md` | A catalogued skill is retired | Skill entry (moved from `active/`) | Terminal state of the skill catalog | PUT |
|
||||
| `_agent/locks/vault.lock` | Advisory multi-writer lock acquire/release | One line: `<owner> @ <ISO-timestamp>` | Concurrency coordination, not memory; managed only by `echo.py lock/unlock` | PUT / DELETE |
|
||||
| `_agent/index/entities.json` | Rebuilt on every `capture` and on `sweep` | Slug→{path, kind, title, aliases, last_seen} registry | Machine-maintained routing/recall index, not a note; the one JSON in the vault | PUT |
|
||||
@@ -120,7 +120,7 @@ Listed so they are recognised as dead and never recreated. Any one of these appe
|
||||
## Routing decision tree (the calls that get mis-made)
|
||||
|
||||
1. **Destination unknown right now?** → `inbox/captures/`. Known? → route directly; never park a known fact in the inbox.
|
||||
2. **Is it about Jason's work over time?** → `journal/` (pick the grain by cadence). **About the vault's mechanical health?** → `_agent/health/`. These two look similar monthly but answer different questions.
|
||||
2. **Is it about the operator's work over time?** → `journal/` (pick the grain by cadence). **About the vault's mechanical health?** → `_agent/health/`. These two look similar monthly but answer different questions.
|
||||
3. **Does the effort have an end state?** → `projects/` (folder = `status:`). **No finish line?** → `areas/`.
|
||||
4. **A fact about the world:** timeless → `semantic/`; a thing that happened → `episodic/`; needed only for now → `working/`. A fact about a *person* → `resources/people/`; a fact about an *organization* → `resources/companies/`.
|
||||
5. **A decision:** always `decisions/by-date/`; mirror into a project only if one already exists.
|
||||
|
||||
@@ -111,14 +111,14 @@ not rewrite frontmatter — the append goes after existing content. To change
|
||||
|
||||
The profile analog. Canonical headings:
|
||||
|
||||
- `## Operator` — who Jason is (one paragraph)
|
||||
- `## Operator` — who the vault owner is (one paragraph)
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. Timeless.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed lines (`- 2026-06-06: ...`). Default landing zone for new evidence.
|
||||
- `## Evidence` — citations/links supporting the rules
|
||||
- `## Recommendation or Implication` — how the rules should shape behavior
|
||||
- `## Review Notes` — confidence / last review date
|
||||
|
||||
Append observed facts under `## Observations` by default. Promote to `## Fact / Pattern` (dropping the date) once a pattern stabilizes. "The operator" is Jason — he is both operator and architect of this vault.
|
||||
Append observed facts under `## Observations` by default. Promote to `## Fact / Pattern` (dropping the date) once a pattern stabilizes. "The operator" is the vault owner — refer to them generically and in the third person (the display name is the runtime `owner` config value).
|
||||
|
||||
### projects/active/\<slug\>.md
|
||||
|
||||
@@ -173,11 +173,11 @@ ADR-style: Context → Decision → Consequences. If the decision belongs to an
|
||||
|
||||
### people/\<name\>.md
|
||||
|
||||
`type: person`. Use lowercase kebab-case for the slug (e.g. `jason-stedwell.md`).
|
||||
`type: person`. Use lowercase kebab-case for the slug (e.g. `robert-smith.md`).
|
||||
|
||||
### companies/\<slug\>.md
|
||||
|
||||
`type: company`. An organization Jason works with — client, vendor, partner, or employer (e.g. `gillig.md`, `mpm.md`). Distinct from `people/` (individuals, who *belong to* companies) and `references/` (external sources). Lowercase kebab-case slug.
|
||||
`type: company`. An organization the vault owner works with — client, vendor, partner, or employer (e.g. `acme.md`). Distinct from `people/` (individuals, who *belong to* companies) and `references/` (external sources). Lowercase kebab-case slug.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,9 +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 (env overrides; defaults match the rest of the plugin):
|
||||
ECHO_BASE default https://echoapi.alwisp.com
|
||||
ECHO_KEY default the plugin bearer token (env overrides it)
|
||||
Config (owner/endpoint/key are machine-local — see echo_config; the plugin ships none):
|
||||
owner/endpoint/key resolved by echo_config from ~/.claude/echo-memory/config.json
|
||||
(env ECHO_OWNER / ECHO_BASE / ECHO_KEY override per field).
|
||||
Set up a machine with `echo.py config init` then edit, or
|
||||
`echo.py config set --owner ... --endpoint ... --key ...`.
|
||||
ECHO_VERIFY default 1 — read-back verify after a PUT
|
||||
ECHO_LOCK_TTL default 900 — seconds before an advisory lock is considered stale
|
||||
ECHO_TIMEOUT default 30 — per-request socket timeout (seconds)
|
||||
@@ -45,8 +47,9 @@ Usage:
|
||||
echo.py unlock <owner-id> # release advisory lock if owned by <owner-id>
|
||||
echo.py scope show # print active scope, its freshness, and sessions-since
|
||||
echo.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
|
||||
echo.py config [show|init|set|import] # machine-local owner/endpoint/key (config import <file> adopts a provided file)
|
||||
|
||||
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 2 usage · 1 other HTTP/transport error.
|
||||
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 78 config-required · 2 usage · 1 other HTTP/transport error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -73,15 +76,17 @@ for _stream in (sys.stdout, sys.stderr):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
BASE = os.environ.get("ECHO_BASE", "https://echoapi.alwisp.com").rstrip("/")
|
||||
|
||||
# Key resolution is centralized in echo_secrets: ECHO_KEY env -> ~/.echo-memory/credentials
|
||||
# -> the deprecated baked-in fallback below. Move the key out of the tree with
|
||||
# `echo.py write-key <token>`; remove DEFAULT_KEY before the 1.0 tag (see ROADMAP-1.0.md > M1).
|
||||
# Owner / endpoint / key are machine-local and resolved by echo_config from
|
||||
# ~/.claude/echo-memory/config.json (env ECHO_OWNER / ECHO_BASE / ECHO_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).
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import echo_secrets # noqa: E402
|
||||
DEFAULT_KEY = "241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab"
|
||||
KEY = echo_secrets.resolve_key(DEFAULT_KEY)
|
||||
import echo_config # noqa: E402
|
||||
_CFG = echo_config.load()
|
||||
BASE = _CFG["endpoint"]
|
||||
KEY = _CFG["key"]
|
||||
OWNER = _CFG["owner"]
|
||||
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
|
||||
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
|
||||
# Per-request socket timeout. A single stuck file fails fast instead of stalling a
|
||||
@@ -125,6 +130,19 @@ _local = threading.local()
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _require_config() -> None:
|
||||
"""Fail loudly (not with an opaque DNS/connection error) when this machine has no
|
||||
usable key file yet — the most common fresh-install state. Also catches an
|
||||
untouched `config init` template (placeholder endpoint/key)."""
|
||||
if not echo_config.is_configured(_CFG):
|
||||
raise EchoError(
|
||||
"echo: NOT CONFIGURED — no usable ECHO key file on this machine. Ask the "
|
||||
f"operator for their config (owner/endpoint/key) and install it at "
|
||||
f"{echo_config.config_path()}: `echo.py config import <file>`, or "
|
||||
"`echo.py config set --owner ... --endpoint ... --key ...` "
|
||||
"(or set ECHO_BASE / ECHO_KEY).", 2)
|
||||
|
||||
|
||||
def _new_connection() -> http.client.HTTPConnection:
|
||||
parts = urllib.parse.urlsplit(BASE)
|
||||
host = parts.hostname or parts.path
|
||||
@@ -157,6 +175,7 @@ def request(method: str, url: str, data: bytes | None = None,
|
||||
status 0 == transport failure (echo_queue relies on this to trigger offline queueing).
|
||||
Retries: a stale pooled connection reconnects immediately; 5xx and other transport
|
||||
errors get one bounded sleep-retry, same as before."""
|
||||
_require_config()
|
||||
merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
target = parts.path or "/"
|
||||
@@ -514,6 +533,15 @@ def cmd_scope(subcommand: str, text: str | None = None) -> int:
|
||||
def cmd_load() -> int:
|
||||
"""Cold-start orientation: the canonical 6 reads in one call. 404s on today's
|
||||
daily note and the inbox are normal (printed as absent, not errors)."""
|
||||
if not echo_config.is_configured(_CFG):
|
||||
print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.")
|
||||
print(f"Expected at: {echo_config.config_path()}")
|
||||
print("ACTION: ask the operator for their echo-memory config file (it holds the")
|
||||
print(" vault owner, endpoint, and API key), then install it with ONE of:")
|
||||
print(" python3 echo.py config import <path-to-their-file>")
|
||||
print(" python3 echo.py config set --owner \"…\" --endpoint \"https://…\" --key \"…\"")
|
||||
print("Then re-run load. (Memory is unavailable until configured.)")
|
||||
return 78 # distinct: configuration required
|
||||
targets = [
|
||||
("marker", "_agent/echo-vault.md"),
|
||||
("preferences", "_agent/memory/semantic/operator-preferences.md"),
|
||||
@@ -588,6 +616,44 @@ def cmd_load() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_config(args) -> int:
|
||||
"""show | init | set the machine-local owner/endpoint/key config."""
|
||||
path = echo_config.config_path()
|
||||
if args.action == "init":
|
||||
p, created = echo_config.init_template()
|
||||
print(f"ok: wrote config template to {p} — edit it with your owner/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)
|
||||
return 2
|
||||
try:
|
||||
p = echo_config.import_file(args.path)
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 2
|
||||
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)
|
||||
return 2
|
||||
p = echo_config.write_config(args.owner, args.endpoint, args.key)
|
||||
print(f"ok: updated {p} (chmod 600 best-effort)")
|
||||
return 0
|
||||
# show — never prints the key in the clear
|
||||
cfg = echo_config.load()
|
||||
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)):
|
||||
src = echo_config.source(field)
|
||||
print(f" {field:9} {shown or '(unset)'} [{src}]")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Validated cross-platform ECHO vault client")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
@@ -609,7 +675,10 @@ 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")
|
||||
sub.add_parser("write-key").add_argument("token") # M1: move the key to ~/.echo-memory/credentials
|
||||
p = sub.add_parser("config") # machine-local owner/endpoint/key (see echo_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 = sub.add_parser("scope")
|
||||
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
|
||||
p.add_argument("text", nargs="?")
|
||||
@@ -657,10 +726,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
else:
|
||||
print("ok: queue empty")
|
||||
return 0
|
||||
if args.cmd == "write-key":
|
||||
path = echo_secrets.write_key(args.token)
|
||||
print(f"ok: wrote credentials to {path} (chmod 600 best-effort); ECHO_KEY env still overrides")
|
||||
return 0
|
||||
if args.cmd == "config":
|
||||
return cmd_config(args)
|
||||
if args.cmd == "get":
|
||||
return cmd_get(args.path)
|
||||
if args.cmd == "map":
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""echo_config.py — resolve the vault owner, endpoint, and API key for ECHO.
|
||||
|
||||
The plugin ships NO owner, endpoint, or key — it is fully user-agnostic. On each
|
||||
machine, a machine-local JSON config supplies them:
|
||||
|
||||
~/.claude/echo-memory/config.json
|
||||
{
|
||||
"owner": "Full Name (how the agent refers to the vault owner, third person)",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>"
|
||||
}
|
||||
|
||||
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 echo.py config init` (writes a placeholder template when absent) and edit
|
||||
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"
|
||||
|
||||
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
|
||||
file path itself can be overridden with $ECHO_CONFIG. Pure stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
TEMPLATE = {
|
||||
"owner": "Full Name — how the agent should refer to the vault owner (third person)",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>",
|
||||
}
|
||||
|
||||
|
||||
def claude_dir() -> Path:
|
||||
"""The Claude config directory ($CLAUDE_CONFIG_DIR, else ~/.claude)."""
|
||||
return Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude"))
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
"""Absolute path to the machine-local config file."""
|
||||
override = os.environ.get("ECHO_CONFIG")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return claude_dir() / "echo-memory" / "config.json"
|
||||
|
||||
|
||||
def _from_file() -> dict:
|
||||
p = config_path()
|
||||
if not p.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
"""Return {owner, endpoint, key} with env overriding 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."""
|
||||
f = _from_file()
|
||||
endpoint = (os.environ.get("ECHO_BASE") or f.get("endpoint") or "").rstrip("/")
|
||||
return {
|
||||
"owner": os.environ.get("ECHO_OWNER") or f.get("owner") or "",
|
||||
"endpoint": endpoint,
|
||||
"key": os.environ.get("ECHO_KEY") or f.get("key") or "",
|
||||
}
|
||||
|
||||
|
||||
def _missing(field: str, env: str) -> RuntimeError:
|
||||
return RuntimeError(
|
||||
f"echo: no {field} configured. Set {env}, or create {config_path()} "
|
||||
f"(run `echo.py config init` to scaffold it, then edit). "
|
||||
f'Expected JSON keys: "owner", "endpoint", "key".'
|
||||
)
|
||||
|
||||
|
||||
def resolve_endpoint() -> str:
|
||||
ep = load()["endpoint"]
|
||||
if not ep:
|
||||
raise _missing("endpoint", "ECHO_BASE")
|
||||
return ep
|
||||
|
||||
|
||||
def resolve_key() -> str:
|
||||
k = load()["key"]
|
||||
if not k:
|
||||
raise _missing("key", "ECHO_KEY")
|
||||
return k
|
||||
|
||||
|
||||
def resolve_owner() -> str:
|
||||
"""The owner display name. Descriptive only, so an empty value is tolerated."""
|
||||
return load()["owner"]
|
||||
|
||||
|
||||
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."""
|
||||
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
|
||||
|
||||
|
||||
def source(field: str) -> str:
|
||||
"""Where a field is currently coming from — for `config`/`doctor` reporting."""
|
||||
env = {"owner": "ECHO_OWNER", "endpoint": "ECHO_BASE", "key": "ECHO_KEY"}[field]
|
||||
if os.environ.get(env):
|
||||
return f"{env} env"
|
||||
if config_path().exists() and _from_file().get(field):
|
||||
return "config file"
|
||||
return "unset"
|
||||
|
||||
|
||||
def template_text() -> str:
|
||||
return json.dumps(TEMPLATE, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _secure(p: Path) -> None:
|
||||
try:
|
||||
os.chmod(p, 0o600) # advisory on filesystems without POSIX mode bits (e.g. Windows)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def write_config(owner: 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", ""),
|
||||
"endpoint": (endpoint if endpoint is not None else cur.get("endpoint", "")).rstrip("/"),
|
||||
"key": key if key is not None else cur.get("key", ""),
|
||||
}
|
||||
p = config_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(merged, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
_secure(p)
|
||||
return p
|
||||
|
||||
|
||||
def import_file(src) -> Path:
|
||||
"""Adopt a config file someone was handed: validate it has a usable endpoint+key,
|
||||
then copy it into config_path(). The 'provide the file -> plugin installs it' path."""
|
||||
p = Path(src).expanduser()
|
||||
if not p.exists():
|
||||
raise RuntimeError(f"config import: file not found: {p}")
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
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"])
|
||||
|
||||
|
||||
def init_template() -> tuple[Path, bool]:
|
||||
"""Write the placeholder template to config_path() if absent.
|
||||
Returns (path, created) — created is False when a config already exists."""
|
||||
p = config_path()
|
||||
if p.exists():
|
||||
return p, False
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(template_text(), encoding="utf-8")
|
||||
_secure(p)
|
||||
return p, True
|
||||
@@ -13,7 +13,6 @@ Exit: 0 all green · 1 a check is red.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -31,13 +30,35 @@ def run() -> int:
|
||||
reds += 0 if ok else 1
|
||||
print(f" [{'OK ' if ok else 'RED'}] {label}" + (f" — {detail}" if detail else ""))
|
||||
|
||||
print(f"echo doctor — endpoint {echo.BASE}")
|
||||
import echo_config
|
||||
cfg = echo_config.load()
|
||||
print(f"echo doctor — endpoint {cfg['endpoint'] or '(not configured)'}")
|
||||
|
||||
# 1. interpreter
|
||||
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. reachability + auth + marker, in one GET of the bootstrap marker
|
||||
# 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.
|
||||
ep_real = bool(cfg["endpoint"]) and "your-obsidian-rest-endpoint" not in cfg["endpoint"]
|
||||
key_real = bool(cfg["key"]) and not cfg["key"].startswith("<")
|
||||
line(ep_real, "endpoint configured",
|
||||
f"[{echo_config.source('endpoint')}]" if ep_real
|
||||
else (f"placeholder — edit {echo_config.config_path()}" if cfg["endpoint"]
|
||||
else f"create {echo_config.config_path()} (run `echo.py config init`)"))
|
||||
line(key_real, "API key configured",
|
||||
f"[{echo_config.source('key')}]" if key_real
|
||||
else ("placeholder — not yet filled in" if cfg["key"] else "missing — see `echo.py config`"))
|
||||
line(bool(cfg["owner"]), "vault owner set",
|
||||
cfg["owner"] if cfg["owner"] else "optional, but recommended for third-person writes")
|
||||
if not echo_config.is_configured(cfg):
|
||||
print("\ndoctor: not configured — ask the operator for their key file and install it "
|
||||
"(`echo.py config import <file>`, or `config set --owner … --endpoint … --key …`), "
|
||||
"then re-run.")
|
||||
return 1
|
||||
|
||||
# 3. reachability + auth + marker, in one GET of the bootstrap marker
|
||||
status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
|
||||
if status == 0:
|
||||
line(False, "vault reachable", body.decode(errors="replace")[:120])
|
||||
@@ -54,13 +75,7 @@ def run() -> int:
|
||||
else:
|
||||
line(status < 400, "marker fetch", f"HTTP {status}")
|
||||
|
||||
# 3. key source (M1) + invariants pointer.
|
||||
import echo_secrets
|
||||
key_src = ("ECHO_KEY env" if os.environ.get("ECHO_KEY")
|
||||
else "credentials file" if (echo_secrets._state_dir() / "credentials").exists()
|
||||
else "baked-in fallback (deprecated — see API-KEY-SETUP.md)")
|
||||
line(os.environ.get("ECHO_KEY") is not None or (echo_secrets._state_dir() / "credentials").exists(),
|
||||
"API key source", key_src)
|
||||
# 4. invariants pointer.
|
||||
print(" [i] invariants — run `/echo-health` (vault_lint.py) for the full check")
|
||||
|
||||
print(f"\ndoctor: {'all green' if reds == 0 else f'{reds} issue(s) — see RED above'}")
|
||||
|
||||
@@ -21,7 +21,7 @@ import re
|
||||
MIN_NAME_LEN = 4
|
||||
_COMMON = frozenset(
|
||||
"api app data note task user team work code test main repo file plan goal item "
|
||||
"the and for with mpm".split()
|
||||
"the and for with from".split()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ PROPOSAL_SCHEMA (one per durable item the agent wants to remember):
|
||||
{
|
||||
"title": "Bob Smith", # required
|
||||
"kind": "person", # required unless "inbox": true
|
||||
"body": "Principal at MPM; ...", # markdown body (optional)
|
||||
"body": "Principal at Acme; ...", # markdown body (optional)
|
||||
"aliases": ["bob", "rs"], # optional
|
||||
"sources": ["_agent/sessions/..."], # optional backward links
|
||||
"date": "YYYY-MM-DD", # optional (meeting/decision kinds)
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""echo_secrets.py — M1: resolve the API bearer token without shipping it in source.
|
||||
[v1.0 SCAFFOLD — resolve_key() implemented; wiring into echo.py is the integration step]
|
||||
|
||||
Problem: the token is hardcoded at echo.py:70 and repeated through api-reference.md /
|
||||
README — committed to git and shipped inside every .plugin zip, rotatable only by a
|
||||
rebuild. The plugin's own safety rule is "never store secrets". This resolves the key
|
||||
from progressively less-trusted sources, with a loud warning if the legacy baked
|
||||
default is ever reached.
|
||||
|
||||
RESOLUTION ORDER (first hit wins):
|
||||
1. env ECHO_KEY — preferred; CI / per-session override.
|
||||
2. ECHO_STATE_DIR/credentials — local file, one `key=...` line, chmod 0600.
|
||||
3. legacy baked default (deprecated) — warns to stderr; remove before 1.0 ship.
|
||||
|
||||
Pure stdlib only (no `keyring`); the local file is the cross-platform stand-in for an
|
||||
OS keychain and keeps the secret OUT of the tracked tree.
|
||||
|
||||
INTEGRATION (do at merge time):
|
||||
echo.py: KEY = echo_secrets.resolve_key()
|
||||
docs: replace the literal token with `<ECHO_KEY>` placeholders
|
||||
rotate: document `echo write-key` (writes the credentials file) — see TODO below
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Kept ONLY so a fresh install still works during the migration window; delete for 1.0.
|
||||
_LEGACY_DEFAULT = "" # intentionally blank in the scaffold — do not re-introduce the literal
|
||||
|
||||
|
||||
def _state_dir() -> Path:
|
||||
return Path(os.environ.get("ECHO_STATE_DIR") or (Path.home() / ".echo-memory"))
|
||||
|
||||
|
||||
def _from_file() -> str | None:
|
||||
p = _state_dir() / "credentials"
|
||||
if not p.exists():
|
||||
return None
|
||||
for line in p.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("key=") or line.startswith("ECHO_KEY="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_key(legacy: str | None = None) -> str:
|
||||
"""Return the bearer token from the most trusted available source:
|
||||
ECHO_KEY env -> credentials file -> `legacy` baked-in fallback (with a loud, once-per-
|
||||
process warning that can be silenced via ECHO_KEY_LEGACY_OK=1). Raises if none exist."""
|
||||
env = os.environ.get("ECHO_KEY")
|
||||
if env:
|
||||
return env
|
||||
from_file = _from_file()
|
||||
if from_file:
|
||||
return from_file
|
||||
legacy = legacy or _LEGACY_DEFAULT
|
||||
if legacy:
|
||||
if os.environ.get("ECHO_KEY_LEGACY_OK") != "1":
|
||||
print("echo_secrets: WARNING — using the deprecated baked-in API key. Run "
|
||||
"`echo.py write-key <token>` (stores ~/.echo-memory/credentials) or set "
|
||||
"ECHO_KEY; set ECHO_KEY_LEGACY_OK=1 to silence.", file=sys.stderr)
|
||||
return legacy
|
||||
raise RuntimeError(
|
||||
"echo_secrets: no API key found. Set ECHO_KEY or create "
|
||||
f"{_state_dir() / 'credentials'} with a 'key=<token>' line."
|
||||
)
|
||||
|
||||
|
||||
def write_key(token: str) -> Path:
|
||||
"""Persist the token to the local credentials file (one `key=...` line, chmod 0600).
|
||||
Best-effort perms — Windows has no POSIX mode bits, so the chmod is advisory there."""
|
||||
d = _state_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
path = d / "credentials"
|
||||
path.write_text(f"key={token.strip()}\n", encoding="utf-8")
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass # platform without POSIX perms (e.g. some Windows filesystems)
|
||||
return path
|
||||
@@ -158,9 +158,9 @@ def test_aliases_in_frontmatter_flow_and_block() -> None:
|
||||
|
||||
def test_links_add_related_is_idempotent_and_bidirectional_token() -> None:
|
||||
text = "---\ntype: person\n---\n\n# Bob\n\n## Related\n"
|
||||
new, changed = echo_links.add_related(text, "resources/companies/mpm.md")
|
||||
assert changed and "[[resources/companies/mpm]]" in new
|
||||
again, changed2 = echo_links.add_related(new, "resources/companies/mpm.md")
|
||||
new, changed = echo_links.add_related(text, "resources/companies/acme.md")
|
||||
assert changed and "[[resources/companies/acme]]" in new
|
||||
again, changed2 = echo_links.add_related(new, "resources/companies/acme.md")
|
||||
assert not changed2 and again == new
|
||||
|
||||
|
||||
|
||||
@@ -76,19 +76,45 @@ def main() -> int:
|
||||
check("H5 classify/preview/apply wired",
|
||||
all(callable(getattr(rf, n, None)) for n in ("classify", "preview", "apply")))
|
||||
|
||||
# M1 — secret resolution order is implemented; env wins, then the credentials file.
|
||||
import echo_secrets as s
|
||||
os.environ["ECHO_KEY"] = "env-key-123"
|
||||
check("M1 env ECHO_KEY wins", s.resolve_key("legacy-fallback") == "env-key-123")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
os.environ["ECHO_STATE_DIR"] = d
|
||||
os.environ.pop("ECHO_KEY", None)
|
||||
s.write_key("file-key-xyz")
|
||||
check("M1 write_key -> credentials -> resolve_key reads it", s.resolve_key() == "file-key-xyz")
|
||||
os.environ["ECHO_KEY_LEGACY_OK"] = "1" # silence the warning for the legacy-path check
|
||||
os.environ["ECHO_STATE_DIR"] = os.path.join(d, "empty") # an empty dir: no creds file
|
||||
check("M1 legacy fallback when env+file absent", s.resolve_key("legacy-fallback") == "legacy-fallback")
|
||||
os.environ["ECHO_KEY"] = "env-key-123" # restore for any later checks
|
||||
# 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.
|
||||
import echo_config as cfg
|
||||
saved_env = {k: os.environ.get(k) for k in ("ECHO_KEY", "ECHO_BASE", "ECHO_OWNER", "ECHO_CONFIG")}
|
||||
try:
|
||||
for k in saved_env:
|
||||
os.environ.pop(k, None)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
cfgfile = os.path.join(d, "config.json")
|
||||
os.environ["ECHO_CONFIG"] = cfgfile
|
||||
# init writes the placeholder template only when absent.
|
||||
p, created = cfg.init_template()
|
||||
check("config init scaffolds the template", created and os.path.exists(cfgfile))
|
||||
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")
|
||||
loaded = cfg.load()
|
||||
check("config file round-trips owner/endpoint/key",
|
||||
loaded == {"owner": "Owner Name",
|
||||
"endpoint": "https://obsidian.example.com", # trailing / stripped
|
||||
"key": "file-key-xyz"})
|
||||
# env overrides the file, per field.
|
||||
os.environ["ECHO_KEY"] = "env-key-123"
|
||||
check("env ECHO_KEY overrides config", cfg.resolve_key() == "env-key-123")
|
||||
os.environ.pop("ECHO_KEY", None)
|
||||
# missing required field raises a helpful error.
|
||||
cfg.write_config("Owner Name", "", "")
|
||||
try:
|
||||
cfg.resolve_key()
|
||||
check("missing key raises", False)
|
||||
except RuntimeError:
|
||||
check("missing key raises", True)
|
||||
finally:
|
||||
for k, v in saved_env.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
# M2 — slug collision + link-confidence helpers are implemented.
|
||||
import echo_quality as qual
|
||||
|
||||
Reference in New Issue
Block a user