cc-os/openspec/changes/archive/2026-06-05-add-memory-plugin/design.md

104 lines
14 KiB
Markdown
Raw Permalink Normal View History

## Context
The vault knowledge graph (Graphify over `~/Documents/SecondBrain`), the extraction model (`qwen2.5-coder:7b`), and the episodic layer (memsearch) are built and validated. What does not yet exist is the runtime integration layer: Claude Code sessions do not automatically know the vault exists, do not inject context at startup, do not update the graph when vault notes are edited, and do not record activity at session end.
This design specifies the global Claude Code plugin that wires all of that together. It is installed once at the user level (`~/.claude/`) and inherited by every project. The design rests on ADR-007 (lazy freshness — event-driven hooks, no daemon), ADR-008 (markdown-as-truth, graphs are disposable), ADR-009 (global plugin), ADR-013 (build-first / validate against fixture set before full vault), and ADR-014 (graph connectivity comes from authored hub notes + wikilinks, not auto-clustering; facet-tag-to-edge question is open).
Current constraints:
- Graphify v0.8.31 anchored; the `reasoning_effort:"none"` patch to the installed `llm.py` is required for extraction and is lost on `pip upgrade` — fragile.
- `GRAPHIFY_OLLAMA_NUM_CTX` does NOT propagate through Graphify's `/v1` endpoint; context must be baked via a Modelfile variant (finding from `graphify-ollama-setup` change).
- Vault location: `~/Documents/SecondBrain` (ADR-012). Project graph convention: `<project-root>/graphify-out/graph.json`.
- Rebuild stamp: `~/.cache/graphify/vault-rebuild.stamp`; default threshold 7 days.
## Goals / Non-Goals
**Goals:**
- Register three lifecycle hooks in the global Claude Code settings: SessionStart (staleness check + context injection), PostToolUse (graph update on vault write/edit), SessionEnd (episodic journal).
- Deliver three skills that carry behavioral conventions to the model: `memory-query`, `memory-write`, `memory-reorganize`.
- Provide a configurable plugin config block (vault path, Graphify output dir, Ollama model, `num_ctx`, stale threshold, env vars).
- Validate the end-to-end hook-and-skills path against the fixture set (ADR-013) before the full vault is live.
**Non-Goals:**
- Vault migration scaffolding (hub notes + wikilinks) — a first-class prerequisite per ADR-014, but not a deliverable of this change. The plugin is designed assuming this scaffolding will exist; without it, retrieval is degraded but the plugin still functions.
- Per-project code-graph indexing (Step 2e) — separate, later change.
- Bulk vault migration (Step 5) — separate, later change.
- Memsearch installation or configuration — already installed; this plugin adds hooks that complement it.
- Vault sync mechanism (git / Syncthing) — separate, later concern.
## Decisions
**Hook input/output mechanism: stdin JSON in, `additionalContext` out.**
Hooks receive input as JSON on stdin (not as env vars or CLI args). The hook reads fields with `jq`: `jq -r '.session_id'`, `jq -r '.tool_input.file_path'`, `jq -r '.cwd'`, `jq -r '.source'`, etc. SessionStart injects context by writing JSON to stdout with an `additionalContext` field and exiting 0 — this is the confirmed Claude Code mechanism for hook-to-model context injection. (Source: Claude Code hooks documentation.)
**Hook implementation: thin shell wrappers over Graphify CLI.**
The hooks are shell scripts registered in `~/.claude/settings.json`. They call Graphify CLI directly via the Bash tool path (not the MCP server) — simpler, no process to manage, all logic in the tool we already have. The MCP server remains an optional query path the AI can use during a session; it is not required for hooks.
*Alternative considered:* Python wrapper scripts with richer logic (retry, logging). Rejected — the hooks need to stay thin and debuggable. If a hook fails it should fail visibly, not silently. A one-line `graphify update --file "$FILE"` is auditable; a 50-line Python script is not.
**SessionStart injects god-nodes via `GRAPH_REPORT.md`-equivalent query, not by reading `graph.json` raw.**
The hook reads the pre-computed god-node list (top N most-connected nodes by degree) via `graphify query --budget N` or by reading `GRAPH_REPORT.md` if it exists at the output dir. It does NOT dump `graph.json` into context — that would blow the token budget. The god-node list is a compact, human-readable "map of what's known."
*Alternative considered:* Injecting the full vault listing from `ls`. Rejected — too much noise, no semantic structure, does not scale.
**SessionStart also injects `convention/*` note summaries (not full content).**
The hook runs `graphify query "convention"` (or equivalent tag-filtered query) to pull `convention/*` node summaries. Only the `summary` frontmatter field is included in injection, not the full note text. This respects progressive-disclosure and keeps context lean.
*Tag-query note:* Whether `convention/` tags create graph edges is an open question (ADR-014). The hook uses the `graphify query "convention"` semantic path as the primary mechanism; if that proves insufficient, a fallback `grep -r "^tags:.*convention" ~/Documents/SecondBrain` to collect frontmatter summaries is viable and simpler. The design does NOT assume tag-based graph traversal works — both paths are planned.
**PostToolUse hook targets vault writes only.**
The hook fires on `Write` and `Edit` tool calls, but ONLY when the target file path matches `~/Documents/SecondBrain/**/*.md`. Project-repo writes are ignored. This constraint is enforced in the hook's guard clause before any Graphify call.
**SessionEnd hook appends to a daily journal note (vault, not project repo).**
The journal note lives at `~/Documents/SecondBrain/journal/YYYY-MM-DD.md` and is append-only. The hook collects vault notes touched in the session (from the PostToolUse hook's side-effect log or from Claude's session memory) and writes a timestamped entry: project name, notes touched, brief session intent. This is the input memsearch indexes for episodic recall.
**Stale-rebuild stamp approach — rebuild is DETACHED, not inline.**
The staleness check reads `~/.cache/graphify/vault-rebuild.stamp` (a file whose mtime or content is the last `--force` rebuild timestamp). If older than N days (default 7, configurable), SessionStart spawns `graphify extract <vault-path> --backend ollama --model <configured-model> --force` as a **detached background process** (e.g. `nohup graphify ... >> <logfile> 2>&1 &` or `setsid`) and returns immediately. The hook MUST NOT block waiting for the rebuild — SessionStart is a blocking hook (the session does not start until the hook returns), so a multi-minute inline rebuild would freeze the user. (Source: Claude Code hooks documentation — SessionStart has no async option and must return promptly; target sub-second for the synchronous path.)
A lock file (e.g. `~/.cache/graphify/vault-rebuild.lock`) prevents concurrent sessions from spawning duplicate rebuilds. The background process touches the stamp on successful completion. The rebuilt graph is not visible to the session that spawned the rebuild; it is available to the next session. The current session proceeds with the existing (possibly stale) graph — this is an accepted tradeoff.
*Ghost-node note:* `graphify update --file` merges but does not prune deleted nodes. The periodic `--force` rebuild (triggered by the stamp check) is the only way to clear ghost nodes. 7-day default balances freshness against extraction cost (~38K tokens per full vault run at current vault size).
**Three skills, not one.**
`memory-query`, `memory-write`, `memory-reorganize` are separate skills loaded on demand rather than a single monolithic `memory` skill. This keeps each skill's context footprint small — a session doing only writes doesn't pull in reorganize guidance, and vice versa.
**memory-query skill must NOT assume tag-based graph traversal (ADR-014 open question).**
The skill teaches two distinct retrieval paths: (1) graph traversal via `graphify query` / `path` / `explain` (connection-based, follows edges from explicit references and wikilinks), and (2) tag-attribute filtering (treat as a separate query mechanism, not graph traversal). The skill notes explicitly that whether shared frontmatter facet tags create graph edges is unverified — use both paths but do not conflate them.
**Config lives in the plugin's settings block, not in CLAUDE.md.**
Vault path, output dir, model, num_ctx, stale threshold, env vars — all go in the plugin's `settings.json` contribution (or a `.env`-style config file the hooks source). Per-project CLAUDE.md holds only pointers/tags, not the system config.
## Risks / Trade-offs
- **`reasoning_effort:"none"` patch lost on `pip upgrade`** → Treat installed Graphify as pinned; document the patch and the pinned version in the plugin README. Track upstream issue tracker for an official flag. Mitigation: the plugin's setup instructions include a post-install verification step that confirms the patch is present.
- **`GRAPHIFY_OLLAMA_NUM_CTX` env-var does not propagate** → Bake context into the Ollama model variant via Modelfile (as discovered in `graphify-ollama-setup`). The plugin config should reference the Modelfile-baked variant name (e.g. `qwen25-coder-7b-16k`), not the base model name.
- **SessionStart hook latency — RESOLVED** → SessionStart is a blocking hook with no async option (Claude Code hooks documentation). The heavy `--force` rebuild is now **detached as a background process** (lock-guarded to prevent duplication). The synchronous path (read stamp, spawn background if stale, inject context) must be sub-second. Tradeoff: the session that triggers a rebuild sees the existing graph; next session sees the rebuilt one. This is accepted; the 7-day threshold makes rebuilds rare.
- **Convention-injection path uncertainty** → Whether `graphify query "convention"` reliably surfaces `convention/*` notes depends on the facet-tag-to-edge question (ADR-014 open). Fallback: shell grep over vault frontmatter for `convention/` tags + read `summary:` field directly. Design both paths; switch based on empirical testing during fixture validation.
- **Journal note accumulation** → Daily journal notes grow over time; memsearch handles indexing, but old notes are not pruned. Mitigation: note in the plugin README that journal notes are part of the vault and subject to normal vault governance (reorg skill can consolidate old journals).
- **Vault migration scaffolding is a prerequisite, not guaranteed** → If hub notes + wikilinks are not yet authored for the fixture set, retrieval quality will be degraded (ADR-014: no authored structure → no graph connectivity). The plugin is still installable and hooks still fire; only retrieval utility is reduced. This is the expected state at initial install; the migration scaffolding change is the unlock.
## Migration Plan
All steps are local and non-destructive:
1. **Create plugin directory structure**: `~/.claude/plugins/memory/` with hooks/ and skills/ subdirectories.
2. **Write hook scripts**: `session-start.sh`, `post-tool-use-write.sh`, `session-end.sh`. Each is a thin shell wrapper; test each script standalone before registering.
3. **Register hooks** in `~/.claude/settings.json` global config: SessionStart → `session-start.sh`, PostToolUse (Write + Edit) → `post-tool-use-write.sh`, SessionEnd → `session-end.sh`.
4. **Write skill files**: `memory-query.md`, `memory-write.md`, `memory-reorganize.md` in `~/.claude/plugins/memory/skills/`. Register in plugin manifest.
5. **Write plugin config** (`~/.claude/plugins/memory/config.yaml` or equivalent): vault path, output dir, model, num_ctx, stale threshold, env vars.
6. **Validate against fixture set** (ADR-013): start a session with the fixture project; confirm SessionStart injects god-nodes + conventions; write a test vault note and confirm PostToolUse fires `graphify update`; end session and confirm journal note appended. Use the fixture set, not the full vault, for this first pass.
Rollback: deregister hooks from `settings.json`; plugin directory can remain. No vault content is mutated by the hooks (extraction is read-only over notes; journal is append-only to a new file).
## Open Questions
1. ~~**Does Claude Code support async/backgrounded SessionStart hooks?**~~ **RESOLVED (2026-06-05):** SessionStart is a blocking hook with no async option (Claude Code hooks documentation). The rebuild is now detached as a background process. See the stale-rebuild decision above.
2. **Convention-injection path**: which mechanism surfaces `convention/*` note summaries most reliably — `graphify query "convention"` or frontmatter grep + `summary:` field read? Decide by testing both against the fixture set during step 6.
3. **Journal note format**: what level of detail is useful for memsearch episodic recall? Minimum: project name + note paths touched + date. Optional: session intent, decisions made. Settle during fixture validation.
4. **Project graph path injection**: SessionStart should inject `--graph <project-root>/graphify-out/graph.json` if a project graph exists at that path. The hook reads `.cwd` from stdin JSON (confirmed available — Claude Code hooks documentation), which provides the working directory. A `git rev-parse --show-toplevel` from that cwd or a `$CLAUDE_PROJECT_DIR` env var convention can determine project root. Mechanism available; exact convention to settle during implementation.
5. **Facet-tag-to-edge question** (ADR-014 deferred): before designing `memory-query`'s tag-filter path, verify empirically whether shared facet tags create graph edges in the fixture graph. One test: build a fixture graph with two notes sharing `tool/graphify`, inspect `graph.json` for an edge between them.
**RESOLVED (2026-06-05) — hook input/output mechanisms:**
- **Session ID availability**: `session_id` is available in hook stdin JSON (`jq -r '.session_id'`) for all hooks that receive it (PostToolUse, SessionEnd). Use for per-session temp file naming. No session-id env var exists; stdin is the only source. (Claude Code hooks documentation.)
- **PostToolUse file-path access**: The hook reads `.tool_input.file_path` from stdin JSON (`jq -r '.tool_input.file_path'`). Also available: `.tool_name` for filtering by tool type. The vault-path guard is done inside the hook after reading this field.
- **Context injection mechanism**: SessionStart injects text into the model context by writing `{"additionalContext": "..."}` JSON to stdout and exiting 0. All prior references to "inject via context" or hand-waved mechanisms now refer to this confirmed mechanism. (Claude Code hooks documentation.)