diff --git a/ingestion/internal/brain/index.go b/ingestion/internal/brain/index.go index 7f67e7b..3317bf7 100644 --- a/ingestion/internal/brain/index.go +++ b/ingestion/internal/brain/index.go @@ -150,7 +150,7 @@ func readTitleAndCreated(path, slug string) (string, string) { } } if title == "" { - title = slug + title = strings.ReplaceAll(slug, "-", " ") } if created == "" { if info, err := os.Stat(path); err == nil { diff --git a/ingestion/internal/brain/tunnel.go b/ingestion/internal/brain/tunnel.go new file mode 100644 index 0000000..424ad9c --- /dev/null +++ b/ingestion/internal/brain/tunnel.go @@ -0,0 +1,286 @@ +package brain + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// seeAlsoHeader is the markdown heading used to group cross-wing links. +const seeAlsoHeader = "## See also" + +// TunnelCandidate is a cross-wing match surfaced by DetectTunnels. It is +// not yet a written link — the caller decides whether confidence is high +// enough to commit it via WriteTunnel. +type TunnelCandidate struct { + // TargetPath is the candidate note's path relative to brainDir + // (forward-slashed), e.g. "wiki/hyperguild/decisions/routing.md". + TargetPath string + // MatchedTerm is the title that matched in the source content. + MatchedTerm string + // Exact is true when the match was a case-insensitive whole-token + // hit on the target's frontmatter title. Fuzzy matches (substring + // only) are flagged Exact=false and should not be auto-written. + Exact bool +} + +// DetectTunnels scans brain/wiki/ for notes whose title appears in +// content. Returns one TunnelCandidate per matching note. Exact is true +// when content contains the title as a whole-word case-insensitive +// token; false when only a substring matched (caller treats these as +// fuzzy and should not auto-write them). +// +// A note's title is read from YAML frontmatter `title:`; failing that, +// the filename slug (sans `.md`, hyphens → spaces) is used. +func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) { + wikiDir := filepath.Join(brainDir, "wiki") + if _, err := os.Stat(wikiDir); os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("stat wiki: %w", err) + } + + lowerContent := strings.ToLower(content) + + var out []TunnelCandidate + err := filepath.WalkDir(wikiDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".md") || d.Name() == "_index.md" { + return nil + } + title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md")) + needle := strings.ToLower(strings.TrimSpace(title)) + if needle == "" { + return nil + } + idx := strings.Index(lowerContent, needle) + if idx == -1 { + return nil + } + rel, err := filepath.Rel(brainDir, path) + if err != nil { + return err + } + out = append(out, TunnelCandidate{ + TargetPath: filepath.ToSlash(rel), + MatchedTerm: title, + Exact: isWholeWord(lowerContent, idx, len(needle)), + }) + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// isWholeWord reports whether the substring at [idx, idx+n) in s is +// bounded by non-alphanumeric characters (or string edges). +func isWholeWord(s string, idx, n int) bool { + left := idx == 0 || !isWordByte(s[idx-1]) + right := idx+n == len(s) || !isWordByte(s[idx+n]) + return left && right +} + +func isWordByte(b byte) bool { + return (b >= 'a' && b <= 'z') || + (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') +} + +// AutoTunnel runs DetectTunnels against content and, for each +// candidate, either writes a bidirectional tunnel (when the match is +// exact and in a different wing) or stages it for human review in +// brain/raw/tunnel-candidates-.md. +// +// sourcePath is the note that originated the content — used to skip +// self-matches and same-wing tunnels. Errors writing individual +// tunnels are recorded into the candidates file but never abort the +// rest of the scan; the caller's primary write has already succeeded +// and auto-linking is best-effort. +func AutoTunnel(brainDir, sourcePath, content string) error { + srcWing, err := wingOf(sourcePath) + if err != nil { + return err + } + candidates, err := DetectTunnels(brainDir, content) + if err != nil { + return err + } + + var fuzzy []TunnelCandidate + for _, c := range candidates { + if c.TargetPath == sourcePath { + continue + } + tgtWing, err := wingOf(c.TargetPath) + if err != nil || tgtWing == srcWing { + continue + } + if !c.Exact { + fuzzy = append(fuzzy, c) + continue + } + if err := WriteTunnel(brainDir, sourcePath, c.TargetPath); err != nil { + fuzzy = append(fuzzy, c) + } + } + return logFuzzyCandidates(brainDir, sourcePath, fuzzy) +} + +// logFuzzyCandidates appends one row per candidate to +// brain/raw/tunnel-candidates-.md, creating the file with a +// header on first write of the day. No-op when the candidate list is empty. +func logFuzzyCandidates(brainDir, sourcePath string, cs []TunnelCandidate) error { + if len(cs) == 0 { + return nil + } + rawDir := filepath.Join(brainDir, "raw") + if err := os.MkdirAll(rawDir, 0o755); err != nil { + return err + } + stamp := time.Now().UTC().Format("2006-01-02") + path := filepath.Join(rawDir, "tunnel-candidates-"+stamp+".md") + existed := fileExists(path) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + if !existed { + if _, err := f.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n"); err != nil { + return err + } + } + for _, c := range cs { + line := fmt.Sprintf("- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm) + if _, err := f.WriteString(line); err != nil { + return err + } + } + return nil +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// WriteTunnel appends a bidirectional wikilink between sourcePath and +// targetPath under a `## See also` section in each note. Paths are +// relative to brainDir (forward-slashed), e.g. wiki///.md. +// +// Idempotent: re-calling with the same pair does not duplicate links or +// section headers. Rejects same-wing pairs (a tunnel is by definition +// cross-wing) and missing notes. +func WriteTunnel(brainDir, sourcePath, targetPath string) error { + srcWing, err := wingOf(sourcePath) + if err != nil { + return fmt.Errorf("source: %w", err) + } + tgtWing, err := wingOf(targetPath) + if err != nil { + return fmt.Errorf("target: %w", err) + } + if srcWing == tgtWing { + return fmt.Errorf("tunnel must cross wings; got both in %q", srcWing) + } + + srcFull := filepath.Join(brainDir, filepath.FromSlash(sourcePath)) + tgtFull := filepath.Join(brainDir, filepath.FromSlash(targetPath)) + if _, err := os.Stat(srcFull); err != nil { + return fmt.Errorf("source note: %w", err) + } + if _, err := os.Stat(tgtFull); err != nil { + return fmt.Errorf("target note: %w", err) + } + + if err := appendSeeAlso(srcFull, wikilinkOf(targetPath)); err != nil { + return fmt.Errorf("update source: %w", err) + } + if err := appendSeeAlso(tgtFull, wikilinkOf(sourcePath)); err != nil { + return fmt.Errorf("update target: %w", err) + } + return nil +} + +// wikilinkOf turns "wiki///.md" into "//" +// for use inside `[[...]]`. +func wikilinkOf(relPath string) string { + p := strings.TrimSuffix(relPath, ".md") + p = strings.TrimPrefix(p, "wiki/") + return p +} + +// wingOf extracts the wing segment from a relative wiki path +// "wiki///.md". +func wingOf(relPath string) (string, error) { + parts := strings.Split(relPath, "/") + if len(parts) < 4 || parts[0] != "wiki" { + return "", fmt.Errorf("not a wiki path: %q", relPath) + } + if parts[1] == "" { + return "", fmt.Errorf("empty wing in path: %q", relPath) + } + return parts[1], nil +} + +// appendSeeAlso inserts `- [[link]]` under the file's See also section, +// creating the section if absent. No-op when the link is already present. +func appendSeeAlso(filePath, link string) error { + content, err := os.ReadFile(filePath) + if err != nil { + return err + } + wikilink := "[[" + link + "]]" + if strings.Contains(string(content), wikilink) { + return nil + } + + bullet := "- " + wikilink + + if !strings.Contains(string(content), seeAlsoHeader) { + // No section yet — append a fresh one. Always emit a trailing + // newline so subsequent appends don't merge into the previous line. + trimmed := strings.TrimRight(string(content), "\n") + out := trimmed + "\n\n" + seeAlsoHeader + "\n\n" + bullet + "\n" + return os.WriteFile(filePath, []byte(out), 0o644) + } + + // Section exists — splice the bullet in just before the next `## ` + // heading (or EOF). Reading the file line-by-line keeps this robust + // against arbitrary section ordering. + var b strings.Builder + scanner := bufio.NewScanner(strings.NewReader(string(content))) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + inSeeAlso, inserted := false, false + for scanner.Scan() { + line := scanner.Text() + if !inserted && inSeeAlso && strings.HasPrefix(line, "## ") && + strings.TrimSpace(line) != seeAlsoHeader { + b.WriteString(bullet) + b.WriteByte('\n') + b.WriteByte('\n') + inserted = true + } + if strings.TrimSpace(line) == seeAlsoHeader { + inSeeAlso = true + } + b.WriteString(line) + b.WriteByte('\n') + } + if err := scanner.Err(); err != nil { + return err + } + if !inserted { + // section was the last thing in the file — just append bullet + out := strings.TrimRight(b.String(), "\n") + "\n" + bullet + "\n" + return os.WriteFile(filePath, []byte(out), 0o644) + } + return os.WriteFile(filePath, []byte(b.String()), 0o644) +} diff --git a/ingestion/internal/brain/tunnel_test.go b/ingestion/internal/brain/tunnel_test.go new file mode 100644 index 0000000..2051982 --- /dev/null +++ b/ingestion/internal/brain/tunnel_test.go @@ -0,0 +1,177 @@ +package brain_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/brain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedNote writes a minimal markdown note at brainDir/relPath with the given body. +func seedNote(t *testing.T, brainDir, relPath, body string) { + t.Helper() + full := filepath.Join(brainDir, relPath) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(body), 0o644)) +} + +func TestWriteTunnel_AppendsBidirectionalLinks(t *testing.T) { + dir := t.TempDir() + seedNote(t, dir, "wiki/jepa-fx/decisions/val-vol.md", + "---\nwing: jepa-fx\nhall: decisions\n---\n# Val Vol R2\n\nbody.\n") + seedNote(t, dir, "wiki/hyperguild/decisions/routing.md", + "---\nwing: hyperguild\nhall: decisions\n---\n# Routing\n\nbody.\n") + + err := brain.WriteTunnel(dir, + "wiki/jepa-fx/decisions/val-vol.md", + "wiki/hyperguild/decisions/routing.md", + ) + require.NoError(t, err) + + src, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/decisions/val-vol.md")) + require.NoError(t, err) + assert.Contains(t, string(src), "## See also") + assert.Contains(t, string(src), "[[hyperguild/decisions/routing]]") + + tgt, err := os.ReadFile(filepath.Join(dir, "wiki/hyperguild/decisions/routing.md")) + require.NoError(t, err) + assert.Contains(t, string(tgt), "## See also") + assert.Contains(t, string(tgt), "[[jepa-fx/decisions/val-vol]]") +} + +func TestWriteTunnel_Idempotent(t *testing.T) { + dir := t.TempDir() + seedNote(t, dir, "wiki/a/facts/x.md", "# X\n\nbody.\n") + seedNote(t, dir, "wiki/b/facts/y.md", "# Y\n\nbody.\n") + + for i := 0; i < 3; i++ { + require.NoError(t, brain.WriteTunnel(dir, + "wiki/a/facts/x.md", "wiki/b/facts/y.md")) + } + + src, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/x.md")) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(string(src), "[[b/facts/y]]"), + "link should appear exactly once after 3 calls") + assert.Equal(t, 1, strings.Count(string(src), "## See also")) + + tgt, err := os.ReadFile(filepath.Join(dir, "wiki/b/facts/y.md")) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(string(tgt), "[[a/facts/x]]")) + assert.Equal(t, 1, strings.Count(string(tgt), "## See also")) +} + +func TestWriteTunnel_RejectsSameWing(t *testing.T) { + dir := t.TempDir() + seedNote(t, dir, "wiki/jepa-fx/facts/x.md", "x") + seedNote(t, dir, "wiki/jepa-fx/facts/y.md", "y") + err := brain.WriteTunnel(dir, + "wiki/jepa-fx/facts/x.md", "wiki/jepa-fx/facts/y.md") + require.Error(t, err) + assert.Contains(t, err.Error(), "cross wings") +} + +func TestWriteTunnel_RejectsMissingNote(t *testing.T) { + dir := t.TempDir() + seedNote(t, dir, "wiki/a/facts/x.md", "x") + err := brain.WriteTunnel(dir, + "wiki/a/facts/x.md", "wiki/b/facts/ghost.md") + require.Error(t, err) +} + +func TestDetectTunnels_ExactTitleMatch(t *testing.T) { + dir := t.TempDir() + seedNote(t, dir, "wiki/jepa-fx/decisions/val-vol.md", + "---\nwing: jepa-fx\nhall: decisions\ntitle: Val Vol R2\n---\nbody.\n") + seedNote(t, dir, "wiki/jepa-fx/facts/lejpa.md", + "---\nwing: jepa-fx\nhall: facts\ntitle: LeJPA Architecture\n---\nbody.\n") + + candidates, err := brain.DetectTunnels(dir, + "We need to revisit Val Vol R2 in light of new tier data.") + require.NoError(t, err) + + require.Len(t, candidates, 1) + assert.Equal(t, "wiki/jepa-fx/decisions/val-vol.md", candidates[0].TargetPath) + assert.Equal(t, "Val Vol R2", candidates[0].MatchedTerm) + assert.True(t, candidates[0].Exact) +} + +func TestDetectTunnels_FuzzyMatch(t *testing.T) { + dir := t.TempDir() + seedNote(t, dir, "wiki/x/facts/routing.md", + "---\ntitle: Routing\n---\nbody.\n") + + // Substring of title appears in content, but not as a whole word. + candidates, err := brain.DetectTunnels(dir, "rerouting handles failover") + require.NoError(t, err) + require.Len(t, candidates, 1) + assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy") +} + +func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) { + dir := t.TempDir() + seedNote(t, dir, "wiki/x/facts/widget-flags.md", "# widget flags\n\nbody.\n") + + candidates, err := brain.DetectTunnels(dir, + "Documented Widget Flags after the deploy issue.") + require.NoError(t, err) + require.Len(t, candidates, 1) + assert.True(t, candidates[0].Exact) + assert.Equal(t, "widget flags", candidates[0].MatchedTerm) +} + +func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) { + dir := t.TempDir() + // Existing note in a different wing whose title is "Routing". + seedNote(t, dir, "wiki/other/facts/routing.md", + "---\nwing: other\nhall: facts\ntitle: Routing\n---\nbody.\n") + // Source note in another wing whose body mentions "rerouting" (substring match only). + seedNote(t, dir, "wiki/jepa-fx/facts/new.md", + "---\nwing: jepa-fx\nhall: facts\n---\nrerouting traffic\n") + + require.NoError(t, brain.AutoTunnel(dir, + "wiki/jepa-fx/facts/new.md", "rerouting traffic")) + + // Source must not get auto-linked (fuzzy). + got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md")) + require.NoError(t, err) + assert.NotContains(t, string(got), "[[other/facts/routing]]") + + // Candidates file must list the pair. + matches, err := filepath.Glob(filepath.Join(dir, "raw", "tunnel-candidates-*.md")) + require.NoError(t, err) + require.Len(t, matches, 1) + body, err := os.ReadFile(matches[0]) + require.NoError(t, err) + assert.Contains(t, string(body), "wiki/jepa-fx/facts/new.md") + assert.Contains(t, string(body), "wiki/other/facts/routing.md") + assert.Contains(t, string(body), "Routing") +} + +func TestDetectTunnels_EmptyWiki(t *testing.T) { + dir := t.TempDir() + cs, err := brain.DetectTunnels(dir, "anything") + require.NoError(t, err) + assert.Empty(t, cs) +} + +func TestWriteTunnel_AppendsToExistingSeeAlso(t *testing.T) { + dir := t.TempDir() + seedNote(t, dir, "wiki/a/facts/x.md", + "# X\n\nbody.\n\n## See also\n\n- [[a/facts/old]]\n") + seedNote(t, dir, "wiki/b/facts/y.md", "# Y\n\nbody.\n") + + require.NoError(t, brain.WriteTunnel(dir, + "wiki/a/facts/x.md", "wiki/b/facts/y.md")) + + src, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/x.md")) + require.NoError(t, err) + s := string(src) + assert.Equal(t, 1, strings.Count(s, "## See also"), "should reuse existing section") + assert.Contains(t, s, "[[a/facts/old]]") + assert.Contains(t, s, "[[b/facts/y]]") +} diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 70cd920..9630827 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -60,6 +60,14 @@ func (s *Server) tools() []map[string]any { "hall": enum("optional memory type (requires wing)", halls...), }), }, + { + "name": "brain_tunnel", + "description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.", + "inputSchema": schema([]string{"source", "target"}, map[string]any{ + "source": str("path of source note relative to brain dir, e.g. wiki/jepa-fx/decisions/val-vol.md"), + "target": str("path of target note (must be in a different wing)"), + }), + }, { "name": "brain_index", "description": "Regenerate _index.md (Map of Content) for one or all wings under brain/wiki/. Auto-called after brain_write with wing+hall.", @@ -174,16 +182,38 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw return nil, err } // Auto-regenerate the wing _index.md when the write landed in the - // structured wiki. A failure here is best-effort — log and move on, - // since the note itself is already written. + // 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) + } } return json.Marshal(map[string]string{"path": relPath}) } +type brainTunnelArgs struct { + Source string `json:"source"` + Target string `json:"target"` +} + +func (s *Server) brainTunnel(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a brainTunnelArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if a.Source == "" || a.Target == "" { + return nil, fmt.Errorf("source and target are required") + } + if err := brain.WriteTunnel(s.brainDir, a.Source, a.Target); err != nil { + return nil, fmt.Errorf("tunnel: %w", err) + } + return json.Marshal(map[string]string{"status": "ok"}) +} + type brainIndexArgs struct { Wing string `json:"wing,omitempty"` } diff --git a/ingestion/internal/mcp/handlers_test.go b/ingestion/internal/mcp/handlers_test.go index b3da72d..9da1fca 100644 --- a/ingestion/internal/mcp/handlers_test.go +++ b/ingestion/internal/mcp/handlers_test.go @@ -122,6 +122,92 @@ func TestBrainQueryWingScope(t *testing.T) { assert.NotContains(t, text, "wiki/other/facts/y.md") } +func TestBrainWriteAutoTunnelsOnExactMatch(t *testing.T) { + brainDir := t.TempDir() + // Seed a pre-existing note in wing "other". + existing := filepath.Join(brainDir, "wiki/other/facts/widget.md") + require.NoError(t, os.MkdirAll(filepath.Dir(existing), 0o755)) + require.NoError(t, os.WriteFile(existing, + []byte("---\nwing: other\nhall: facts\ntitle: Widget\n---\nbody.\n"), 0o644)) + + srv := mcp.NewServer(brainDir, nil, nil, nil) + + // Write a new note in a *different* wing whose content references "Widget". + resp := toolCall(t, srv, "brain_write", map[string]any{ + "content": "# Notes\n\nThis note discusses the Widget concept.\n", + "filename": "notes", + "wing": "jepa-fx", + "hall": "facts", + }) + require.Nil(t, resp["error"]) + + newNote := filepath.Join(brainDir, "wiki/jepa-fx/facts/notes.md") + got, err := os.ReadFile(newNote) + require.NoError(t, err) + assert.Contains(t, string(got), "[[other/facts/widget]]", "new note should link to existing") + + gotTgt, err := os.ReadFile(existing) + require.NoError(t, err) + assert.Contains(t, string(gotTgt), "[[jepa-fx/facts/notes]]", "existing note should backlink") +} + +func TestBrainWriteAutoTunnelSkipsSameWing(t *testing.T) { + brainDir := t.TempDir() + existing := filepath.Join(brainDir, "wiki/jepa-fx/facts/widget.md") + require.NoError(t, os.MkdirAll(filepath.Dir(existing), 0o755)) + require.NoError(t, os.WriteFile(existing, + []byte("---\nwing: jepa-fx\nhall: facts\ntitle: Widget\n---\nbody.\n"), 0o644)) + + srv := mcp.NewServer(brainDir, nil, nil, nil) + resp := toolCall(t, srv, "brain_write", map[string]any{ + "content": "Same wing reference to Widget here.\n", + "filename": "notes", + "wing": "jepa-fx", + "hall": "facts", + }) + require.Nil(t, resp["error"]) + + newNote := filepath.Join(brainDir, "wiki/jepa-fx/facts/notes.md") + got, err := os.ReadFile(newNote) + require.NoError(t, err) + assert.NotContains(t, string(got), "[[jepa-fx/facts/widget]]", "same-wing match must not auto-tunnel") +} + +func TestBrainTunnelLinksTwoNotes(t *testing.T) { + brainDir := t.TempDir() + for _, p := range []struct{ rel, body string }{ + {"wiki/jepa-fx/decisions/val-vol.md", "---\nwing: jepa-fx\nhall: decisions\n---\n# Val Vol\n"}, + {"wiki/hyperguild/decisions/routing.md", "---\nwing: hyperguild\nhall: decisions\n---\n# Routing\n"}, + } { + full := filepath.Join(brainDir, p.rel) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644)) + } + srv := mcp.NewServer(brainDir, nil, nil, nil) + resp := toolCall(t, srv, "brain_tunnel", map[string]any{ + "source": "wiki/jepa-fx/decisions/val-vol.md", + "target": "wiki/hyperguild/decisions/routing.md", + }) + require.Nil(t, resp["error"]) + + src, err := os.ReadFile(filepath.Join(brainDir, "wiki/jepa-fx/decisions/val-vol.md")) + require.NoError(t, err) + assert.Contains(t, string(src), "[[hyperguild/decisions/routing]]") + tgt, err := os.ReadFile(filepath.Join(brainDir, "wiki/hyperguild/decisions/routing.md")) + require.NoError(t, err) + assert.Contains(t, string(tgt), "[[jepa-fx/decisions/val-vol]]") +} + +func TestBrainTunnelRejectsMissing(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + resp := toolCall(t, srv, "brain_tunnel", map[string]any{ + "source": "wiki/a/facts/ghost.md", + "target": "wiki/b/facts/ghost.md", + }) + require.NotNil(t, resp["error"]) +} + func TestBrainWriteRejectsTraversal(t *testing.T) { brainDir := t.TempDir() srv := mcp.NewServer(brainDir, nil, nil, nil) diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index d60f602..fd011b0 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -1,6 +1,6 @@ // Package mcp implements an MCP HTTP handler for the ingestion service. -// Exposed tools: brain_query, brain_write, brain_index, brain_ingest, -// brain_ingest_raw, brain_answer, brain_classify, session_log. +// Exposed tools: brain_query, brain_write, brain_index, brain_tunnel, +// brain_ingest, brain_ingest_raw, brain_answer, brain_classify, session_log. package mcp import ( @@ -139,6 +139,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa return s.brainWrite(ctx, args) case "brain_index": return s.brainIndex(ctx, args) + case "brain_tunnel": + return s.brainTunnel(ctx, args) case "brain_ingest_raw": return s.brainIngestRaw(ctx, args) case "brain_ingest": diff --git a/ingestion/internal/mcp/server_test.go b/ingestion/internal/mcp/server_test.go index 678cdba..80800cb 100644 --- a/ingestion/internal/mcp/server_test.go +++ b/ingestion/internal/mcp/server_test.go @@ -55,7 +55,8 @@ func TestServerToolsList(t *testing.T) { names = append(names, t.(map[string]any)["name"].(string)) } assert.ElementsMatch(t, []string{ - "brain_query", "brain_write", "brain_index", "brain_ingest_raw", "brain_ingest", + "brain_query", "brain_write", "brain_index", "brain_tunnel", + "brain_ingest_raw", "brain_ingest", "brain_answer", "brain_classify", "session_log", }, names) }