// 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 "" }