feat(brain): cross-wing tunnels — bidirectional wikilinks + auto-detect
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:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user