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:
177
ingestion/internal/brain/tunnel_test.go
Normal file
177
ingestion/internal/brain/tunnel_test.go
Normal 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]]")
|
||||
}
|
||||
Reference in New Issue
Block a user