hyperguild#125, found in #111's review as a non-blocking observation,
decided now rather than left implicit: UpdateNote's merge loop applied
every field from the caller's leading frontmatter fragment onto the
restamped note, including updated_at/supersedes/supersede_reason.
updated_at and supersedes were already safe -- both get unconditionally
re-set right after the loop regardless of what the loop did with them
-- but supersede_reason is only re-set when opts.Reason is non-empty, so
a caller could set it via its own fragment and have it survive even
with the Reason option left empty, bypassing the one intended entry
point for that field.
Decision: these three fields are this function's own output, not
caller-settable, full stop -- skip all three in the merge loop rather
than lean on the other two happening to be safe via call order. Makes
the invariant explicit at the one place it could be silently broken by
a future reordering, instead of implicit in which fm.set calls happen
to run after the loop.
Flip-tested: reverting the skip to just `l.key == ""` lets the caller's
supersede_reason survive with Reason empty -- the new test catches it.
Full module suite green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds brain_reject (RejectNote + MCP tool) and fixes the watcher to skip raw/rejected/, preventing declined notes from being silently re-ingested and republished.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
Removes brain_write legacy domain/type/knowledge-path params (ADR-0012) and migrates the previously-missed cmd/hyperguild CLI caller (hyperguild brain write) from <type> <slug> to <wing> <hall> <slug>, fixing a 100%% regression the first review round caught. Adds TestWrite_CLIPayloadRoundTrips exercising the real Handler.Write over HTTP to pin the wire contract.
Closes#79.
Co-authored-by: Mathias <mthbqv@gmail.com>
The BrainStore is not the only door. POST /write — the HTTP path CLAUDE.md
hands to curl and scripts — calls api.WriteNote directly, so the strip
shipped one commit ago covered brain_write and capture while leaving the raw
door ungated. A gate one door wide is not a gate.
The BrainStore keeps its own call so it can still report Ref.Unlinked to the
MCP caller; running the strip twice is a no-op on content the primitive has
already cleaned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First half of brain#21: half_life (volatile|seasonal|durable), applies
(local|homelab|universal), claim_provenance (asserted|inferred).
writeHallNote already passed unknown frontmatter keys through verbatim, so
preservation was free — a test now pins it so a refactor of the merge loop
cannot silently drop the fields. What was missing is validation, and it
fails closed: a value outside the enum is rejected (a lint skips what it
cannot parse, so the note would read as classified while nothing acts on
it); an envelope field without claim_provenance is rejected (a value a
model inferred must never be indistinguishable from one a human asserted,
or a single classifier pass launders guesses into apparent judgement
across the corpus); provenance alone is rejected too.
Validation runs before MkdirAll, so a rejected write leaves nothing behind.
The field is `applies`, not `scope`: scope: has been in use since brain#5
for glossary terms (homelab-core, project:<repo>), a disjoint value space
on the same key. The shipped schema keeps the name.
Notes with none of these fields are byte-identical to before. Bulk
backfill stays out of scope.
Schema documented in brain/CLAUDE.md via brain_edit_root, which produced
the hyperguild#97 audit record for the rewrite.
Refs brain#21, brain#19 (superseded), brain#5, hyperguild#97.
Co-authored-by: Mathias <mthbqv@gmail.com>
opts.Content can already carry its own "---\n...\n---" block (e.g. from
an upstream extraction/promotion step). writeHallNote used to prepend a
second block unconditionally, making a standard frontmatter parser blind
to everything the first block declared (title, tags, ...).
splitFrontmatter now pulls the existing block's fields out first;
wing/hall/created_at are always fresh-injected, and type/domain/
source_type prefer the note's own existing value over the opts
fallback. Remaining existing fields are carried through verbatim into
the single merged block.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Roq1ajWKR5f1hG5Df9wC6A
Wires the sovereign docmark MCP server (git.d-ma.be/mathias/docmark) as the
converter for formats ingestion previously couldn't handle at all -- DOCX,
XLSX, PPTX, PNG/JPG. A single self-contained tools/call POST (docmark runs
stateless_http, no session handshake needed) does the conversion.
- internal/extract/docmark.go: extractViaDocmark(path) -- reads DOCMARK_URL +
DOCMARK_BEARER_TOKEN from env, base64-encodes the file, calls docmark's
convert_to_markdown tool, surfaces tool/HTTP errors with context.
- internal/extract/extract.go: routes .docx/.xlsx/.pptx/.png/.jpg/.jpeg to it.
- internal/api/handler.go: isSupportedExtension() replaces the static
supportedExtensions map -- the new extensions are gated on DOCMARK_URL being
set, checked per-request. When DOCMARK_URL is unset (e.g. this repo's
current deployed state), behavior is byte-for-byte unchanged from today:
same 400/silent-skip as before. Zero risk until the env var is actually
wired in infra.
TDD throughout: docmark.go tested via httptest mock (success, not-configured,
tool-error, HTTP-error, all 6 new extensions route correctly); handler-level
test proves the DOCMARK_URL gate (docx 400 when unset, 200 when a working
mock is configured). Full task check green (fmt/vet/govulncheck/all tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
writeRequest and WriteNoteOptions have matching fields again now that
both carry SourceType, so the explicit field-by-field literal added
in ee1204d is flagged as a redundant conversion. Back to the plain
type conversion. Caught by task check (golangci-lint), not run
locally before the previous push -- plain go test passed, task check
(which adds -race and golangci-lint) didn't.
The unsourced-check fix (source_type: internal) only covered
claudewatcher's writes. 41 residual findings on the audit's next run
were manually-authored hall=facts entries written via brain_write/
capture -- also first-party observations, just a different write path,
with no way to mark them internal short of hand-editing every entry.
writeHallNote now defaults source_type to internal whenever hall ==
"facts" and the caller didn't set it explicitly. An explicit
source_type: external (now threaded through the /write HTTP endpoint)
opts a genuinely citation-needing entry back out.
brain-gardener#5: the audit's unsourced check was flagging 59 raw
claudewatcher session dumps as high-severity hallucination risk,
because it can't distinguish a first-party observation (a session
transcript) from an external claim needing citation.
Adds WriteNoteOptions.SourceType, emitted as source_type: <value> in
the wing/hall frontmatter route. claudeSink (the claudewatcher sink)
now always sets source_type: internal on the notes it writes.
Closes the human curation loop: list brain/raw/ notes awaiting review
(oldest-first, with excerpt) and promote one into brain/wiki/<wing>/<hall>/.
PromoteNote rewrites frontmatter (sets wing/hall/promoted_at, preserves
created_at + custom fields via the existing frontmatter editor), rebuilds
the wing _index, and runs auto-tunnel. Atomic from the caller's view:
hall/wing/slug/collision validation happens before any fs change, and the
source is deleted only after the destination write succeeds (write-then-
delete, never move) — a collision or write failure leaves raw/ intact.
Default slug = filename minus the YYYY-MM-DD- prefix.
Also exposes GET /pending + POST /promote for shell scripts (bad
hall/collision/missing-source → 400, not 500).
Scope note: operates on raw/ (the retrospective-skill review queue) per
this issue's spec. The knowledge/ legacy pile is #22's bulk-migration
concern, not this ongoing queue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 nomic-embed-text (iguana ollama) + pgvector on the shared
postgres18 into brain_query / brain_answer via Reciprocal Rank Fusion.
Pure BM25 stays the default; setting BRAIN_PG_DSN and BRAIN_EMBED_URL
together opts in. Setting one without the other is misconfiguration →
exit 1.
New packages:
- internal/embed
Client.Embed(ctx, text) → []float32 via POST {URL}/api/embed.
Defaults to nomic-embed-text:latest (768 dim). nil-on-empty-URL so
callers gate on a single nil check.
- internal/vectorstore
PGStore wraps a pgxpool against postgres18. Init creates
brain_embeddings(path PK, vector(768), updated_at) + HNSW cosine
index idempotently. Upsert / Delete / Search / KnownPaths.
Sync(brainDir, store, embedder) diffs brain/wiki/ against the store
and upserts new files / deletes removed ones; StartSync runs it on
a ticker (default 300s). Integration tests gated by BRAIN_PG_TEST_DSN.
- scripts/brain-embeddings-init.sql
One-time DBA setup: brain DB, brain_app role, vector extension,
GRANTs. Idempotent.
Search layer:
- search.QueryOptions gains Vector + Embedder fields.
- QueryContext is the cancellable variant; Query stays for callers.
- When both are set, BM25 (top-N) and pgvector (top-4N) candidates
merge via Reciprocal Rank Fusion (k=60, Cormack et al. 2009 — no
tuning knob, robust to scale differences between rankers).
- Vector-only hits are hydrated from disk so callers see uniform
Result records (path, title, excerpt, wing, hall, score).
- Wing/hall filters still apply to vector candidates via path-prefix.
- On embedder/vector errors the search falls back to BM25 — embedding
outage degrades quality but doesn't take the brain offline.
MCP wiring:
- mcp.Server.WithHybridRetrieval(v, e) opt-in setter, same shape as
WithReranker.
- brainQuery and brainAnswer pass the wired vector/embedder through
to search.QueryContext.
REST:
- POST /backfill-embeddings drives Sync synchronously. Returns
{added, deleted, errors[]}. 503 when feature is unconfigured.
cmd/server/main.go:
- BRAIN_PG_DSN + BRAIN_EMBED_URL together enable hybrid; one alone
→ exit 1.
- vectorAdapter bridges *PGStore (returns []Hit) to
search.VectorSearcher (which takes []VectorHit) without either
package importing the other.
- BRAIN_EMBED_SYNC_INTERVAL (default 300s) controls the background
Sync ticker.
Backend pivot from Qdrant to pgvector recorded in DECISIONS.md
2026-05-18 (supersedes 2026-04-08): postgres18 already runs in
databases/ ns, Qdrant was never deployed, one engine beats two.
Dependency: github.com/jackc/pgx/v5 — modern, native pgvector via
parametric vector literals.
Tests:
- embed.Client: empty-URL nil, request shape, dimension, upstream
error propagation, empty-text rejection.
- vectorstore.PGStore: dimension validation (unit); upsert/search/
KnownPaths (integration, BRAIN_PG_TEST_DSN-gated).
- vectorstore.Sync: adds new files, skips known, deletes
disappeared, skips _index.md, no-op when nil, collects embedder
errors.
- search.Query: hybrid promotes vector-only hits via RRF; falls
back to BM25 on embedder error.
Closes hyperguild#8.
Adds a two-dimensional address (wing, hall) to brain notes. A wing is a
topic domain (e.g. jepa-fx, hyperguild); a hall is one of a closed
vocabulary of memory types (facts, decisions, failures, hypotheses,
sources). Notes route to brain/wiki/<wing>/<hall>/<slug>.md with
wing/hall/created_at YAML frontmatter, making the directory a valid
Obsidian vault.
Changes:
- new package ingestion/internal/brain (NotePath, ValidHalls, Sanitise,
BuildWingIndex, BuildAllWingIndexes)
- api.WriteNote refactored to WriteNoteOptions; wing+hall routes to
brain/wiki/, otherwise falls back to brain/knowledge/ (legacy)
- search.Query → QueryOptions with optional Wing/Hall filtering; Result
carries wing/hall extracted from frontmatter or path segments
- MCP tools brain_write and brain_query gain optional wing/hall params
(hall enum-validated); new brain_index tool regenerates _index.md MOC
- POST /index REST endpoint mirrors brain_index
- brain_write auto-rebuilds the wing's _index.md after a wing+hall write
- scripts/migrate-brain-halls.sh migrates flat brain/wiki/{concepts,entities}/
into the new layout (dry-run by default, --commit applies)
All existing tests pass; new tests cover wing/hall write routing, scope
filtering, invalid hall rejection, _index.md generation, and migration
script paths.
Closes hyperguild#1.
Adds a new HTTP GET handler at the ingestion pod that walks
brain/sessions/*.jsonl, filters by skill name and timestamp window
(default 7d, accepts Nh and Nd), normalizes legacy status vocabulary
(ok->pass, error->fail, skipped->skip), and returns aggregated counts
plus pass_rate.
Pass rate is null when pass+fail == 0, distinguishing 'no data' from
'always passes'. Plan 6 routing pod will check for null before
making decisions.
Route registration in cmd/server/main.go lands in a follow-up commit.
api.WriteNote captures the file-write logic that was previously inline
in Handler.Write. The existing HTTP endpoint now delegates to it; the
new MCP brain_write tool reuses the same function. Path-traversal
guard is strengthened to explicitly reject filenames containing path
separators or "..", so the rejection is surfaced before filepath.Base
strips the suspicious component (the previous defense-in-depth prefix
check became unreachable for these inputs after Base normalisation).
HTTP error code for caller-input errors shifts from 500 to 400, which
is semantically correct and not exercised by any existing test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Allows callers to provide pre-structured RawPage data directly, bypassing the
LLM extraction step. The pipeline still handles slug computation, frontmatter,
link canonicalization, source back-references, and dedup — only the extraction
is skipped. Useful when a more capable model or manual curation produces the
structured data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wires pipeline.Run into the HTTP layer so callers can ingest raw text
or files/directories without touching the filesystem directly. Rewrites
main.go to parse LLM and watcher env vars and build pipeline.Config.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
brain_write with a custom filename omitted the .md extension, causing
search to skip the file (search.go filters on HasSuffix .md).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Empty or whitespace-only queries would silently pass through to search,
returning meaningless results. Also removed the Domain field from
queryRequest — it was accepted but silently ignored since search.Query
has no domain parameter, which would confuse callers.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements POST /query (BM25 search via internal/search) and POST /write
(raw file persistence to brain/raw/) as an api.Handler struct. Filename
is auto-generated when absent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>