From 0ac165cca37c31cd1aa7935c270a0fcf6d7a9864 Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 22 Jun 2026 23:21:43 +0200 Subject: [PATCH] feat(brainstore): shared BrainStore impl; re-point MCP handlers (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the #45 write/update/get logic + the wiki upkeep that must accompany a write (wing _index rebuild, cross-wing auto-tunnel, graph re-index) into a single concrete brainstore.Store implementing capture.BrainStore. The MCP brain_write/brain_update/brain_get handlers are re-pointed at it, so there is one implementation, not two — the DRY payoff #51 is named for. capture and MCP now share the exact same brain write path and read-after-write contract. The Server gains a *brainstore.Store, constructed in NewServer and given the graph store in WithGraph. Embedding refresh stays out-of-band (mtime-driven vectorstore.Sync), unchanged. Existing MCP brain_update/ brain_get/brain_write tests pass unmodified — behaviour and the {id, path, content_hash} response contract are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/internal/brainstore/store.go | 129 ++++++++++++++++++++ ingestion/internal/brainstore/store_test.go | 85 +++++++++++++ ingestion/internal/mcp/handlers.go | 81 ++++-------- ingestion/internal/mcp/server.go | 12 +- 4 files changed, 248 insertions(+), 59 deletions(-) create mode 100644 ingestion/internal/brainstore/store.go create mode 100644 ingestion/internal/brainstore/store_test.go diff --git a/ingestion/internal/brainstore/store.go b/ingestion/internal/brainstore/store.go new file mode 100644 index 0000000..3666217 --- /dev/null +++ b/ingestion/internal/brainstore/store.go @@ -0,0 +1,129 @@ +// Package brainstore is the concrete BrainStore: the single shared +// implementation of the #45 write/update/get verbs, used by BOTH the MCP +// handlers and the capture use-case so there is one implementation, not +// two (the Clean-Architecture / DRY payoff of #51). +// +// It composes the file-level primitives in package api (WriteNote, +// UpdateNote, ReadNote — the read-after-write contract) with the wiki +// upkeep that must accompany a write: wing _index rebuild, cross-wing +// auto-tunnel, and graph re-index. Embedding refresh is intentionally +// out-of-band (mtime-driven vectorstore.Sync) and not triggered here — +// see the brain note on out-of-band sync. +package brainstore + +import ( + "context" + "log/slog" + "strings" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" + "github.com/mathiasbq/hyperguild/ingestion/internal/brain" + "github.com/mathiasbq/hyperguild/ingestion/internal/capture" + "github.com/mathiasbq/hyperguild/ingestion/internal/graphsync" +) + +// Store implements capture.BrainStore against a brain directory on disk, +// optionally re-indexing each write into the knowledge graph. +type Store struct { + brainDir string + graph graphsync.Store // nil = graph re-index disabled +} + +// New constructs a Store bound to brainDir with graph indexing disabled. +func New(brainDir string) *Store { + return &Store{brainDir: brainDir} +} + +// WithGraph enables graph re-index on every write/update. nil disables it. +func (s *Store) WithGraph(g graphsync.Store) *Store { + s.graph = g + return s +} + +// Write creates a brain note and returns its read-after-write handle. +func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) { + relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{ + Content: n.Content, + Filename: n.Filename, + Type: n.Type, + Domain: n.Domain, + Wing: n.Wing, + Hall: n.Hall, + }) + if err != nil { + return capture.Ref{}, err + } + s.wikiUpkeep(relPath, n.Wing, n.Content) + s.indexInGraph(ctx, "brain_write", relPath) + + _, _, hash, _ := api.ReadNote(s.brainDir, relPath) + return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, nil +} + +// Update supersedes an existing note in place. slug may be a bare slug +// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it +// contains a slash). It never creates — a missing target is an error. +func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) { + opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason} + if strings.Contains(slug, "/") { + opts.Path = slug + } else { + opts.Wing, opts.Hall, opts.Slug = n.Wing, n.Hall, slug + } + + relPath, hash, _, err := api.UpdateNote(s.brainDir, opts) + if err != nil { + return capture.Ref{}, err + } + if wing := wingFromRelPath(relPath); wing != "" { + s.wikiUpkeep(relPath, wing, n.Content) + } + s.indexInGraph(ctx, "brain_update", relPath) + + return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, nil +} + +// Get fetches a note by id/path — the read-after-write confirmation +// primitive (a direct fetch, never a semantic query). +func (s *Store) Get(_ context.Context, id string) (capture.StoredNote, error) { + fm, body, hash, err := api.ReadNote(s.brainDir, id) + if err != nil { + return capture.StoredNote{}, err + } + return capture.StoredNote{ID: id, Path: id, ContentHash: hash, Frontmatter: fm, Body: body}, nil +} + +// wikiUpkeep rebuilds the wing _index and re-tunnels cross-wing matches +// when a note lands in the structured wiki. Both are best-effort: the +// note is already written, so a failure here is logged, not propagated. +func (s *Store) wikiUpkeep(relPath, wing, content string) { + if wing == "" { + return + } + if err := brain.BuildWingIndex(s.brainDir, wing); err != nil { + slog.Warn("brainstore: auto-index failed", "wing", wing, "err", err) + } + if err := brain.AutoTunnel(s.brainDir, relPath, content); err != nil { + slog.Warn("brainstore: auto-tunnel failed", "src", relPath, "err", err) + } +} + +// indexInGraph re-indexes a written doc into the graph, best-effort. +func (s *Store) indexInGraph(ctx context.Context, op, relPath string) { + if s.graph == nil || relPath == "" { + return + } + if err := graphsync.IndexDoc(ctx, s.graph, s.brainDir, relPath); err != nil { + slog.Warn(op+": graph index failed", "path", relPath, "err", err) + } +} + +// wingFromRelPath extracts the wing from a structured wiki path +// (wiki///.md). Returns "" for legacy/non-wiki paths. +func wingFromRelPath(relPath string) string { + parts := strings.Split(relPath, "/") + if len(parts) >= 4 && parts[0] == "wiki" { + return parts[1] + } + return "" +} diff --git a/ingestion/internal/brainstore/store_test.go b/ingestion/internal/brainstore/store_test.go new file mode 100644 index 0000000..e2a444b --- /dev/null +++ b/ingestion/internal/brainstore/store_test.go @@ -0,0 +1,85 @@ +package brainstore_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/brainstore" + "github.com/mathiasbq/hyperguild/ingestion/internal/capture" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStoreWriteReturnsHandle(t *testing.T) { + dir := t.TempDir() + s := brainstore.New(dir) + + ref, err := s.Write(context.Background(), capture.Note{ + Content: "# X\n\nbody\n", Filename: "x", Wing: "a", Hall: "facts", + }) + require.NoError(t, err) + assert.Equal(t, "wiki/a/facts/x.md", ref.Path) + assert.Equal(t, ref.Path, ref.ID) + assert.NotEmpty(t, ref.ContentHash) + assert.False(t, ref.Superseded) + + _, err = os.Stat(filepath.Join(dir, "wiki/a/facts/x.md")) + require.NoError(t, err) +} + +func TestStoreUpdateSupersedes(t *testing.T) { + dir := t.TempDir() + s := brainstore.New(dir) + _, err := s.Write(context.Background(), capture.Note{ + Content: "old\n", Filename: "n", Wing: "a", Hall: "facts", + }) + require.NoError(t, err) + + ref, err := s.Update(context.Background(), "n", capture.Note{ + Content: "new\n", Wing: "a", Hall: "facts", Reason: "changed", + }) + require.NoError(t, err) + assert.True(t, ref.Superseded) + assert.Equal(t, "wiki/a/facts/n.md", ref.Path) + + got, _ := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md")) + assert.Contains(t, string(got), "new") + assert.Contains(t, string(got), "supersede_reason: changed") +} + +func TestStoreUpdateByFullPath(t *testing.T) { + dir := t.TempDir() + s := brainstore.New(dir) + _, err := s.Write(context.Background(), capture.Note{Content: "old\n", Filename: "n", Wing: "a", Hall: "facts"}) + require.NoError(t, err) + + ref, err := s.Update(context.Background(), "wiki/a/facts/n.md", capture.Note{Content: "fresh\n"}) + require.NoError(t, err) + assert.Equal(t, "wiki/a/facts/n.md", ref.Path) +} + +func TestStoreUpdateMissingErrors(t *testing.T) { + dir := t.TempDir() + s := brainstore.New(dir) + _, err := s.Update(context.Background(), "ghost", capture.Note{Content: "x\n", Wing: "a", Hall: "facts"}) + require.Error(t, err) + _, statErr := os.Stat(filepath.Join(dir, "wiki/a/facts/ghost.md")) + assert.True(t, os.IsNotExist(statErr), "update must not create") +} + +func TestStoreGetRoundTripsHash(t *testing.T) { + dir := t.TempDir() + s := brainstore.New(dir) + ref, err := s.Write(context.Background(), capture.Note{ + Content: "# Body\n\ntext\n", Filename: "n", Wing: "a", Hall: "facts", + }) + require.NoError(t, err) + + note, err := s.Get(context.Background(), ref.ID) + require.NoError(t, err) + assert.Equal(t, ref.ContentHash, note.ContentHash, "write→get hash round-trips") + assert.Equal(t, "a", note.Frontmatter["wing"]) + assert.Contains(t, note.Body, "# Body") +} diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 8966b9b..12a9193 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/brain" + "github.com/mathiasbq/hyperguild/ingestion/internal/capture" "github.com/mathiasbq/hyperguild/ingestion/internal/extract" "github.com/mathiasbq/hyperguild/ingestion/internal/graphsync" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" @@ -219,7 +219,11 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("parse args: %w", err) } - relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{ + // Delegate to the shared BrainStore so write+index+tunnel+graph live in + // one implementation (capture uses the same store). The read-after-write + // handle {id, path, content_hash} comes back from the store; path is kept + // for backward compatibility. + ref, err := s.store.Write(ctx, capture.Note{ Content: a.Content, Filename: a.Filename, Type: a.Type, @@ -230,22 +234,7 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw if err != nil { return nil, err } - // Auto-regenerate the wing _index.md when the write landed in the - // structured wiki, and auto-tunnel cross-wing matches. Both are - // best-effort: the note is already written. - if a.Wing != "" && a.Hall != "" { - if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil { - slog.Warn("brain_write: auto-index failed", "wing", a.Wing, "err", err) - } - if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil { - slog.Warn("brain_write: auto-tunnel failed", "src", relPath, "err", err) - } - } - s.indexInGraph(ctx, "brain_write", relPath) - // Read-after-write handle: id == relPath, content_hash == sha256 of - // the bytes just written. path is kept for backward compatibility. - _, _, hash, _ := api.ReadNote(s.brainDir, relPath) - return json.Marshal(map[string]string{"id": relPath, "path": relPath, "content_hash": hash}) + return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash}) } type brainUpdateArgs struct { @@ -274,50 +263,26 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra return nil, fmt.Errorf("content is required") } - opts := api.UpdateNoteOptions{Content: a.Content, Reason: a.Reason} - switch { - case a.Path != "": - opts.Path = a.Path - case strings.Contains(a.Slug, "/"): - // slug carries a full path (issue #45: "slug ... OR full path"). - opts.Path = a.Slug - default: - opts.Wing, opts.Hall, opts.Slug = a.Wing, a.Hall, a.Slug + // path takes precedence over slug; the store treats any slug containing + // a slash as a full brain-relative path (issue #45: "slug ... OR path"). + slug := a.Slug + if a.Path != "" { + slug = a.Path } - - relPath, hash, _, err := api.UpdateNote(s.brainDir, opts) + ref, err := s.store.Update(ctx, slug, capture.Note{ + Content: a.Content, + Wing: a.Wing, + Hall: a.Hall, + Reason: a.Reason, + }) if err != nil { return nil, err } - - // Best-effort wiki upkeep, mirroring brain_write: rebuild the wing - // _index and re-tunnel cross-wing matches against the new body. Both - // are idempotent and never block — the note is already superseded. - if wing := wingFromRelPath(relPath); wing != "" { - if err := brain.BuildWingIndex(s.brainDir, wing); err != nil { - slog.Warn("brain_update: auto-index failed", "wing", wing, "err", err) - } - if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil { - slog.Warn("brain_update: auto-tunnel failed", "src", relPath, "err", err) - } - } - s.indexInGraph(ctx, "brain_update", relPath) - return json.Marshal(map[string]any{ - "id": relPath, "path": relPath, "content_hash": hash, "superseded": true, + "id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded, }) } -// wingFromRelPath extracts the wing segment from a structured wiki path -// (wiki///.md). Returns "" for legacy/non-wiki paths. -func wingFromRelPath(relPath string) string { - parts := strings.Split(relPath, "/") - if len(parts) >= 4 && parts[0] == "wiki" { - return parts[1] - } - return "" -} - type brainGetArgs struct { ID string `json:"id,omitempty"` Path string `json:"path,omitempty"` @@ -327,7 +292,7 @@ type brainGetArgs struct { // path — the de-facto handle). Read-only; the create-path read-after- // write primitive that lets callers confirm a write landed without a // lexical re-query. -func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMessage, error) { +func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { var a brainGetArgs if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("parse args: %w", err) @@ -339,13 +304,13 @@ func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMess if target == "" { return nil, fmt.Errorf("id or path is required") } - fm, body, hash, err := api.ReadNote(s.brainDir, target) + note, err := s.store.Get(ctx, target) if err != nil { return nil, err } return json.Marshal(map[string]any{ - "id": target, "path": target, "content_hash": hash, - "frontmatter": fm, "body": body, + "id": note.ID, "path": note.Path, "content_hash": note.ContentHash, + "frontmatter": note.Frontmatter, "body": note.Body, }) } diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index 0f9051d..b072db5 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -10,6 +10,7 @@ import ( "fmt" "net/http" + "github.com/mathiasbq/hyperguild/ingestion/internal/brainstore" "github.com/mathiasbq/hyperguild/ingestion/internal/graphstore" "github.com/mathiasbq/hyperguild/ingestion/internal/graphsync" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" @@ -46,6 +47,7 @@ type Server struct { vector search.VectorSearcher // nil = BM25-only retrieval embedder search.Embedder // nil = BM25-only retrieval graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled + store *brainstore.Store // shared brain write/update/get impl (also used by capture) } // NewServer constructs a Server bound to brainDir. pipelineCfg supplies the @@ -56,7 +58,13 @@ func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.Compl if pipelineCfg != nil { cfg = *pipelineCfg } - return &Server{brainDir: brainDir, pipeline: cfg, llm: llm, answerLLM: answerLLM} + return &Server{ + brainDir: brainDir, + pipeline: cfg, + llm: llm, + answerLLM: answerLLM, + store: brainstore.New(brainDir), + } } // WithReranker installs an opt-in cross-encoder reranker. When set, @@ -84,9 +92,11 @@ func (s *Server) WithHybridRetrieval(v search.VectorSearcher, e search.Embedder) func (s *Server) WithGraph(g *graphstore.PGStore) *Server { if g == nil { s.graph = nil + s.store.WithGraph(nil) return s } s.graph = g + s.store.WithGraph(g) return s }