feat(brain): cross-wing tunnels — bidirectional wikilinks + auto-detect
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s

Adds the `brain_tunnel` MCP tool and auto-tunnel behaviour for
`brain_write`, so concepts that appear in multiple wings become
navigable from any of them.

New surface in package brain:
- WriteTunnel(brainDir, src, tgt) — appends a `## See also` bidirectional
  wikilink between two notes in different wings. Idempotent (link not
  duplicated on re-call) and reuses an existing See also section.
- DetectTunnels(brainDir, content) — walks brain/wiki/, returns
  TunnelCandidates for notes whose title appears in content. Tags
  whole-word case-insensitive hits as Exact=true and substring-only hits
  as Exact=false.
- AutoTunnel(brainDir, src, content) — wraps DetectTunnels: writes
  cross-wing exact matches, stages fuzzy matches into
  brain/raw/tunnel-candidates-<YYYY-MM-DD>.md for human review.

MCP wiring:
- `brain_tunnel` tool: explicit manual link (source, target).
- `brain_write` with wing+hall now triggers AutoTunnel on the new
  content. Failures are logged and never abort the primary write.

readTitleAndCreated also humanises the slug fallback (hyphens → spaces)
so titleless notes participate in content matching.

Closes hyperguild#16.

Tests: idempotency, same-wing rejection, missing-note rejection,
See-also reuse, exact/fuzzy detection, slug fallback, MCP tool happy
path, auto-tunnel hook (cross-wing exact → linked; same-wing → skipped;
fuzzy → candidates file).
This commit is contained in:
Mathias
2026-05-18 21:32:49 +02:00
parent 61b6247df9
commit ddd07ae7eb
7 changed files with 588 additions and 6 deletions

View File

@@ -150,7 +150,7 @@ func readTitleAndCreated(path, slug string) (string, string) {
} }
} }
if title == "" { if title == "" {
title = slug title = strings.ReplaceAll(slug, "-", " ")
} }
if created == "" { if created == "" {
if info, err := os.Stat(path); err == nil { if info, err := os.Stat(path); err == nil {

View File

@@ -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-<YYYY-MM-DD>.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-<YYYY-MM-DD>.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/<wing>/<hall>/<slug>.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/<wing>/<hall>/<slug>.md" into "<wing>/<hall>/<slug>"
// 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/<wing>/<hall>/<slug>.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)
}

View File

@@ -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]]")
}

View File

@@ -60,6 +60,14 @@ func (s *Server) tools() []map[string]any {
"hall": enum("optional memory type (requires wing)", halls...), "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", "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.", "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 return nil, err
} }
// Auto-regenerate the wing _index.md when the write landed in the // Auto-regenerate the wing _index.md when the write landed in the
// structured wiki. A failure here is best-effort — log and move on, // structured wiki, and auto-tunnel cross-wing matches. Both are
// since the note itself is already written. // best-effort: the note is already written.
if a.Wing != "" && a.Hall != "" { if a.Wing != "" && a.Hall != "" {
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil { if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
slog.Warn("brain_write: auto-index failed", "wing", a.Wing, "err", err) 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}) 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 { type brainIndexArgs struct {
Wing string `json:"wing,omitempty"` Wing string `json:"wing,omitempty"`
} }

View File

@@ -122,6 +122,92 @@ func TestBrainQueryWingScope(t *testing.T) {
assert.NotContains(t, text, "wiki/other/facts/y.md") 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) { func TestBrainWriteRejectsTraversal(t *testing.T) {
brainDir := t.TempDir() brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil, nil) srv := mcp.NewServer(brainDir, nil, nil, nil)

View File

@@ -1,6 +1,6 @@
// Package mcp implements an MCP HTTP handler for the ingestion service. // Package mcp implements an MCP HTTP handler for the ingestion service.
// Exposed tools: brain_query, brain_write, brain_index, brain_ingest, // Exposed tools: brain_query, brain_write, brain_index, brain_tunnel,
// brain_ingest_raw, brain_answer, brain_classify, session_log. // brain_ingest, brain_ingest_raw, brain_answer, brain_classify, session_log.
package mcp package mcp
import ( import (
@@ -139,6 +139,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
return s.brainWrite(ctx, args) return s.brainWrite(ctx, args)
case "brain_index": case "brain_index":
return s.brainIndex(ctx, args) return s.brainIndex(ctx, args)
case "brain_tunnel":
return s.brainTunnel(ctx, args)
case "brain_ingest_raw": case "brain_ingest_raw":
return s.brainIngestRaw(ctx, args) return s.brainIngestRaw(ctx, args)
case "brain_ingest": case "brain_ingest":

View File

@@ -55,7 +55,8 @@ func TestServerToolsList(t *testing.T) {
names = append(names, t.(map[string]any)["name"].(string)) names = append(names, t.(map[string]any)["name"].(string))
} }
assert.ElementsMatch(t, []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", "brain_answer", "brain_classify", "session_log",
}, names) }, names)
} }