feat: brain_update supersede verb + brain_get / brain_write read-after-write handle (#45) #46

Merged
mathias merged 3 commits from feat/brain-update-supersede into main 2026-06-22 08:59:28 +00:00
Owner

Implements #45 — the keystone supersede-by-slug write verb plus the read-after-write handle on the create path. Decisions in the issue were taken as settled; nothing relitigated.

What landed

brain_update — supersede-by-slug, whole-note body replace. Resolves target from wing/hall/slug (or a full path in slug/path). Missing target → clear error, no silent create. Rewrites frontmatter: stamps updated_at, supersedes (prior content hash), supersede_reason; preserves created_at/wing/hall/custom fields. Rebuilds the wing _index.md and re-tunnels cross-wing matches (idempotent, best-effort), re-indexes the graph. Returns {id, path, content_hash, superseded}.

brain_get — fetch by id or path (read-only). Returns {id, path, content_hash, frontmatter, body}.

brain_write — return contract extended {path}{id, path, content_hash}. path kept for backward compat.

All three registration sites updated: tools() descriptors, handleCall dispatch, server.go package doc.

id / content_hash choice

  • id = the brain-relative slash path — the de-facto handle today. brain_get(id) and brain_get(path) resolve identically, and the id returned by write/update round-trips through brain_get (covered by tests).
  • content_hash = lowercase hex sha256 of the full file bytes on disk after the write. brain_get recomputes from the file → identical hash. brain_update hashes the prior file before rewrite and stamps it as supersedes.

Step 0 — embedding re-index (the load-bearing finding)

No write handler re-embeds in-handler — not even brain_write. The MCP Server holds only the read side (s.vector VectorSearcher + s.embedder query embedder); it has no vectorstore.Store with Upsert/Delete. Embeddings are refreshed exclusively out-of-band by:

  1. vectorstore.StartSync — background ticker (cmd/server/main.go:301, default 5 min). mtime-driven: re-embeds any file whose mtime is newer than its oldest chunk's updated_at, deleting stale chunks first.
  2. POST /backfill-embeddings — manual full sync.

So "re-sync on update" = ensure the out-of-band Sync re-embeds the changed file, exactly as the dispatch anticipated. brain_update rewrites the note via os.WriteFile, advancing its mtime past the stored chunks' updated_at, so the next Sync pass re-embeds the new body and drops the stale chunk. I deliberately did not invent an in-handler embed call — that would fork a second mechanism diverging from brain_write.

This is satisfiable, not a blockerTestBrainUpdateReembedsNewContent drives the real vectorstore.Sync after an update and asserts the new body is re-embedded and the old chunk deleted (no stub of the re-index path).

Design note for review: semantic (vector) read-after-write has a ≤5 min window — the supersede is visible to BM25/lexical immediately and to brain_get synchronously, but pgvector retrieval lags until the next Sync tick. If synchronous semantic refresh is ever needed, the path is to wire a vectorstore.Store into the MCP Server and embed in-handler — a follow-up, out of scope for #45.

Acceptance criteria

  • brain_update supersedes: body replaced, updated_at/supersedes stamped, prior fields preserved
  • brain_update on non-existent target → clear error, no note created
  • Embedding re-synced after update (via mtime-driven Sync); query reflects new content — proven by a real Sync test
  • brain_update returns {id, path, content_hash}
  • brain_write return contract extended to {id, path, content_hash}
  • brain_get(id|path) returns the landed note (frontmatter + body)
  • Prior version recoverable from git (no sidecar/tombstone)
  • task check passes
  • Both tools registered in server.go (all three sites)
  • Tests: update happy path, update non-existent, embedding-reflects-new-content, brain_get by id, brain_get by path, brain_write handle, frontmatter editor, traversal guards

Notes

  • One unrelated commit (chore(context): re-sync derived adapters) — task check's context gate flagged pre-existing drift from the root AGENT.md rule-0 update; committing the regen is the documented remedy. Not a content change in this repo.
  • Not auto-merged, per dispatch — review + merge from claude.ai.

Closes #45

🤖 Generated with Claude Code

Implements #45 — the keystone supersede-by-slug write verb plus the read-after-write handle on the create path. Decisions in the issue were taken as settled; nothing relitigated. ## What landed **`brain_update`** — supersede-by-slug, whole-note body replace. Resolves target from `wing`/`hall`/`slug` (or a full path in `slug`/`path`). Missing target → clear error, **no silent create**. Rewrites frontmatter: stamps `updated_at`, `supersedes` (prior content hash), `supersede_reason`; preserves `created_at`/`wing`/`hall`/custom fields. Rebuilds the wing `_index.md` and re-tunnels cross-wing matches (idempotent, best-effort), re-indexes the graph. Returns `{id, path, content_hash, superseded}`. **`brain_get`** — fetch by `id` or `path` (read-only). Returns `{id, path, content_hash, frontmatter, body}`. **`brain_write`** — return contract extended `{path}` → `{id, path, content_hash}`. `path` kept for backward compat. All three registration sites updated: `tools()` descriptors, `handleCall` dispatch, `server.go` package doc. ## `id` / `content_hash` choice - **`id` = the brain-relative slash path** — the de-facto handle today. `brain_get(id)` and `brain_get(path)` resolve identically, and the `id` returned by write/update round-trips through `brain_get` (covered by tests). - **`content_hash` = lowercase hex sha256 of the full file bytes on disk** after the write. `brain_get` recomputes from the file → identical hash. `brain_update` hashes the prior file before rewrite and stamps it as `supersedes`. ## Step 0 — embedding re-index (the load-bearing finding) **No write handler re-embeds in-handler — not even `brain_write`.** The MCP `Server` holds only the read side (`s.vector` VectorSearcher + `s.embedder` query embedder); it has no `vectorstore.Store` with `Upsert`/`Delete`. Embeddings are refreshed exclusively out-of-band by: 1. `vectorstore.StartSync` — background ticker (`cmd/server/main.go:301`, default 5 min). **mtime-driven**: re-embeds any file whose mtime is newer than its oldest chunk's `updated_at`, deleting stale chunks first. 2. `POST /backfill-embeddings` — manual full sync. So "re-sync on update" = **ensure the out-of-band Sync re-embeds the changed file**, exactly as the dispatch anticipated. `brain_update` rewrites the note via `os.WriteFile`, advancing its mtime past the stored chunks' `updated_at`, so the next Sync pass re-embeds the new body and drops the stale chunk. I deliberately did **not** invent an in-handler embed call — that would fork a second mechanism diverging from `brain_write`. **This is satisfiable, not a blocker** — `TestBrainUpdateReembedsNewContent` drives the *real* `vectorstore.Sync` after an update and asserts the new body is re-embedded and the old chunk deleted (no stub of the re-index path). **Design note for review:** semantic (vector) read-after-write has a ≤5 min window — the supersede is visible to BM25/lexical immediately and to `brain_get` synchronously, but pgvector retrieval lags until the next Sync tick. If synchronous semantic refresh is ever needed, the path is to wire a `vectorstore.Store` into the MCP `Server` and embed in-handler — a follow-up, out of scope for #45. ## Acceptance criteria - [x] `brain_update` supersedes: body replaced, `updated_at`/`supersedes` stamped, prior fields preserved - [x] `brain_update` on non-existent target → clear error, no note created - [x] Embedding re-synced after update (via mtime-driven Sync); query reflects new content — proven by a real Sync test - [x] `brain_update` returns `{id, path, content_hash}` - [x] `brain_write` return contract extended to `{id, path, content_hash}` - [x] `brain_get(id|path)` returns the landed note (frontmatter + body) - [x] Prior version recoverable from git (no sidecar/tombstone) - [x] `task check` passes - [x] Both tools registered in `server.go` (all three sites) - [x] Tests: update happy path, update non-existent, embedding-reflects-new-content, brain_get by id, brain_get by path, brain_write handle, frontmatter editor, traversal guards ## Notes - One unrelated commit (`chore(context): re-sync derived adapters`) — `task check`'s context gate flagged pre-existing drift from the root `AGENT.md` rule-0 update; committing the regen is the documented remedy. Not a content change in this repo. - Not auto-merged, per dispatch — review + merge from claude.ai. Closes #45 🤖 Generated with [Claude Code](https://claude.com/claude-code)
mathias added 3 commits 2026-06-22 06:26:07 +00:00
Implements the api-layer half of #45. UpdateNote supersedes a note in
place (whole-note body replace, frontmatter re-stamp: updated_at,
supersedes=prior content hash, supersede_reason), preserving created_at,
wing, hall, and any custom fields. Never creates — a missing target is
an error so callers fall back to brain_write. ReadNote is the read-after-
write primitive (frontmatter + body + content_hash). ContentHash is the
sha256 handle that round-trips write/update → get.

Frontmatter is edited via a line-preserving ordered editor rather than a
yaml.v3 round-trip, which would reorder keys and strip comments — the
brain writes flat key:value frontmatter by hand.

Embeddings are not refreshed here: the rewritten file's mtime advances,
which the out-of-band vectorstore.Sync ticker uses to re-embed it — the
same mechanism brain_write relies on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the #45 verbs into the MCP surface (all three sites: tools()
descriptors, handleCall dispatch, package doc comment).

- brain_update: supersede-by-slug or full path; rebuilds wing _index and
  re-tunnels cross-wing matches against the new body (idempotent,
  best-effort), re-indexes the graph, returns {id, path, content_hash,
  superseded}.
- brain_get: fetch by id or path (both are the brain-relative handle);
  returns {id, path, content_hash, frontmatter, body}.
- brain_write: return contract extended from {path} to {id, path,
  content_hash} — path kept for backward compat — so the create path
  also yields a stable handle.

id == relPath; content_hash == sha256 of the file bytes. Tests cover the
supersede happy path, missing-target error + no-create, get by id/path,
write handle, and an end-to-end re-embed test that drives the real
vectorstore.Sync re-index after an update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(context): re-sync derived adapters after root rule-0 update
CI / Lint / Test / Vet (pull_request) Successful in 13s
CI / Mirror to GitHub (pull_request) Has been skipped
f04b03e07e
context-sync regenerated the adapters from the updated root AGENT.md
(rule 0 pre-task ritual + TDD constraint). The committed adapters had
drifted; this is the documented `task check` remedy, not a content
change in this repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Step 0 — embedding re-index trace (do-this-first finding)

Traced how a written note's embeddings get (re)built before writing any re-sync code, as instructed.

Finding: there is no in-handler embedding re-index anywhere — not in brain_update, not in brain_write, not in brain_ingest. The MCP Server only holds the read side: s.vector (search.VectorSearcher) and s.embedder (query-time search.Embedder). It has no vectorstore.Store (the Upsert/Delete interface), so it cannot embed in-handler without new wiring.

Embeddings are refreshed strictly out-of-band, two ways:

  1. vectorstore.StartSync — background ticker, wired at ingestion/cmd/server/main.go:301, default 5 min. It is mtime-driven (vectorstore/sync.go): for each file it compares the file mtime against the oldest updated_at among that file's chunk rows; if the file is newer it deletes all old chunks and re-embeds from scratch (handles shrink/orphan rows cleanly).
  2. POST /backfill-embeddings — synchronous full sync over wiki/ + knowledge/.

Consequence for the contract: "re-sync on update" means ensure the out-of-band Sync re-embeds the changed file — exactly the alternative the dispatch flagged. brain_update rewrites the note with os.WriteFile, so its mtime advances past the stored chunks' updated_at, and the next Sync pass re-embeds the new body + drops the stale chunk. This matches brain_write precisely. I did not add a divergent in-handler embed call.

Not a blocker. The acceptance criterion "a query reflects the new content, not the old" is verified against the real mechanism in TestBrainUpdateReembedsNewContent: write → Sync (embeds OLD) → brain_updateSync (re-embeds NEW, deletes old chunk), asserted with a recording embedder. No stub of the re-index path.

One design question for you (not blocking #45): semantic/vector retrieval has a ≤5 min staleness window after a supersede (BM25 + brain_get are immediate). If the session-retro skill needs vector read-after-write within seconds, the fix is to wire a vectorstore.Store into the MCP Server and embed the single changed file in-handler. I scoped that out as a follow-up — flagging per your "stop and raise it before building the rest" instruction. Everything here works against the existing architecture as-is.

## Step 0 — embedding re-index trace (do-this-first finding) Traced how a written note's embeddings get (re)built **before** writing any re-sync code, as instructed. **Finding: there is no in-handler embedding re-index anywhere — not in `brain_update`, not in `brain_write`, not in `brain_ingest`.** The MCP `Server` only holds the read side: `s.vector` (`search.VectorSearcher`) and `s.embedder` (query-time `search.Embedder`). It has no `vectorstore.Store` (the `Upsert`/`Delete` interface), so it *cannot* embed in-handler without new wiring. Embeddings are refreshed strictly out-of-band, two ways: 1. **`vectorstore.StartSync`** — background ticker, wired at `ingestion/cmd/server/main.go:301`, default 5 min. It is **mtime-driven** (`vectorstore/sync.go`): for each file it compares the file mtime against the *oldest* `updated_at` among that file's chunk rows; if the file is newer it deletes all old chunks and re-embeds from scratch (handles shrink/orphan rows cleanly). 2. **`POST /backfill-embeddings`** — synchronous full sync over `wiki/` + `knowledge/`. **Consequence for the contract:** "re-sync on update" means *ensure the out-of-band Sync re-embeds the changed file* — exactly the alternative the dispatch flagged. `brain_update` rewrites the note with `os.WriteFile`, so its mtime advances past the stored chunks' `updated_at`, and the next Sync pass re-embeds the new body + drops the stale chunk. This matches `brain_write` precisely. I did **not** add a divergent in-handler embed call. **Not a blocker.** The acceptance criterion "a query reflects the new content, not the old" is verified against the real mechanism in `TestBrainUpdateReembedsNewContent`: write → `Sync` (embeds OLD) → `brain_update` → `Sync` (re-embeds NEW, deletes old chunk), asserted with a recording embedder. No stub of the re-index path. **One design question for you** (not blocking #45): semantic/vector retrieval has a ≤5 min staleness window after a supersede (BM25 + `brain_get` are immediate). If the session-retro skill needs *vector* read-after-write within seconds, the fix is to wire a `vectorstore.Store` into the MCP `Server` and embed the single changed file in-handler. I scoped that out as a follow-up — flagging per your "stop and raise it before building the rest" instruction. Everything here works against the existing architecture as-is.
mathias merged commit 63df6d3283 into main 2026-06-22 08:59:28 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/hyperguild#46