feat(brainstore): shared BrainStore impl; re-point MCP handlers (#51)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<wing>/<hall>/<slug>.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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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/<wing>/<hall>/<slug>.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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user