hyperguild#79's caller audit (done in a prior session) found the removal was safe: - The promote/pending flow (#38/#81, ingestion/internal/api/promote.go) operates exclusively on brainDir/raw/ -> brainDir/wiki/<wing>/<hall>/. It never touches brainDir/knowledge/, so removing the knowledge/ write path does not break it. - No live caller uses domain/type or the knowledge/ path: checked hyperguild itself, mathias/skills, mathias/agentsquad, mathias/dispatch, mathias/cad, mathias/cad-atlas. - internal/skills/brain (a skill.go + handlers.go pair) defined a third, even-more-legacy brain_write shape (type/domain/filename, no wing/hall) for an old "supervisor" MCP server whose cmd/supervisor/main.go no longer exists. Nothing outside its own test imported it; deleted entirely as it encodes the exact legacy shape this issue removes. Changes: - ingestion/internal/mcp/handlers.go: brain_write's tool schema and brainWriteArgs drop type/domain; wing+hall are now marked required in the schema. - ingestion/internal/api/handler.go: WriteNote has one behaviour — wing+ hall required, routing to wiki/<wing>/<hall>/. writeLegacyNote and the knowledge/-path branch are deleted. Omitting wing or hall now returns "wing and hall are required" (400 over HTTP, a tool error over MCP) — the only sane failure mode left once there is no other valid destination. - ingestion/internal/capture/ports.go, internal/brainstore/store.go: drop Type/Domain from capture.Note and its WriteNoteOptions construction. pipeline/*.go's unrelated "domain" field (a different, legitimate concept for brain_ingest_raw) is untouched. - internal/skills/brain/: deleted (orphaned legacy tool schema, dead since cmd/supervisor was removed). Tests: added TestWriteNote_RequiresWingAndHall (api), TestBrainWriteRequiresWingAndHall + TestBrainWriteIgnoresLegacyTypeAndDomainParams (mcp) for the new single behaviour; removed tests that only exercised the deleted legacy path (TestWrite_IncludesFrontmatterWhenTypeProvided, TestWriteNote_HallRouteExistingTypeWinsOverOptsType, TestBrainWriteRejectsTraversal, TestBrainWriteAcceptsDoubleDotInName — the latter two's filename-sanitisation behaviour is covered independently by internal/brain/path_test.go's TestNotePath_SanitisesWingAndSlug / TestSanitise, which is unaffected by this change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
140 lines
4.8 KiB
Go
140 lines
4.8 KiB
Go
// 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) {
|
|
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
|
if err != nil {
|
|
return capture.Ref{}, err
|
|
}
|
|
n.Content = content
|
|
|
|
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
|
Content: n.Content,
|
|
Filename: n.Filename,
|
|
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, Unlinked: unlinked}, 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) {
|
|
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
|
if err != nil {
|
|
return capture.Ref{}, err
|
|
}
|
|
n.Content = content
|
|
|
|
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, Unlinked: unlinked}, 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 ""
|
|
}
|