package brain import ( "bufio" "errors" "fmt" "io/fs" "path" "path/filepath" "strings" "time" "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" ) // 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) { // Walked and read through the brain root, regular files only // (hyperguild#174, #157): this runs inside every brain_write, so a FIFO // must not block it and a symlinked note's title must not become a // candidate. bfs, err := brainfs.Open(brainDir) if err != nil { return nil, fmt.Errorf("open brain: %w", err) } defer func() { _ = bfs.Close() }() if _, err := bfs.Lstat("wiki"); errors.Is(err, fs.ErrNotExist) { return nil, nil } lowerContent := strings.ToLower(content) var out []TunnelCandidate var walkErr error err = bfs.WalkRegular("wiki", func(rel string) error { name := path.Base(rel) if !strings.HasSuffix(name, ".md") || name == "_index.md" { return nil } title, _ := readTitleAndCreatedIn(bfs, rel, strings.TrimSuffix(name, ".md")) title = strings.TrimSpace(title) if title == "" { return nil } // A one-word title has to match the prose case-exactly. Matched // case-insensitively, it links every entry that happens to use the // word: "WRONG" (wiki/telos/decisions/wrong.md) collected 185 inbound // See-also links and "Status" 136, making the two most-linked nodes in // the graph artifacts of the English language rather than references. // Multi-word titles keep the case-insensitive match — "risk tier // middleware" in prose is a real mention however it is capitalised, // and a three-word collision is not an accident. haystack, needle := lowerContent, strings.ToLower(title) if isSingleWord(title) { haystack, needle = content, title } idx := strings.Index(haystack, needle) if idx == -1 { return nil } out = append(out, TunnelCandidate{ TargetPath: rel, MatchedTerm: title, Exact: isWholeWord(haystack, idx, len(needle)), }) return nil }, func(rel string, err error) { // A wiki/ that is not a real directory yields no candidates, as // filepath.WalkDir did; other walk errors still abort. if rel == "wiki" && errors.Is(err, brainfs.ErrNotRegular) { return } if walkErr == nil { walkErr = fmt.Errorf("%s: %w", rel, err) } }) if err == nil { err = walkErr } if err != nil { return nil, err } return out, nil } // isSingleWord reports whether title is one whitespace-free token. func isSingleWord(title string) bool { return !strings.ContainsAny(title, " \t") } // 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 } // Appended through the brain root, never through a symlinked raw/ or // log file (hyperguild#158). bfs, err := brainfs.Open(brainDir) if err != nil { return fmt.Errorf("open brain: %w", err) } defer func() { _ = bfs.Close() }() stamp := time.Now().UTC().Format("2006-01-02") rel := "raw/tunnel-candidates-" + stamp + ".md" var buf strings.Builder if _, err := bfs.Lstat(rel); errors.Is(err, fs.ErrNotExist) { buf.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") } for _, c := range cs { fmt.Fprintf(&buf, "- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm) } return bfs.AppendRegular(rel, []byte(buf.String())) } // 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 { if err := checkNotePath(sourcePath); err != nil { return fmt.Errorf("source: %w", err) } if err := checkNotePath(targetPath); err != nil { return fmt.Errorf("target: %w", err) } 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) } // Both notes are read and rewritten through the brain root, regular // files only: brain_tunnel passes caller-supplied paths, and the brain is // a Syncthing folder that can hold symlinks. bfs, err := brainfs.Open(brainDir) if err != nil { return fmt.Errorf("open brain: %w", err) } defer func() { _ = bfs.Close() }() srcContent, err := bfs.ReadRegular(sourcePath) if err != nil { return fmt.Errorf("source note: %w", err) } tgtContent, err := bfs.ReadRegular(targetPath) if err != nil { return fmt.Errorf("target note: %w", err) } if out, changed, err := withSeeAlso(srcContent, wikilinkOf(targetPath)); err != nil { return fmt.Errorf("update source: %w", err) } else if changed { if err := bfs.WriteRegular(sourcePath, out); err != nil { return fmt.Errorf("update source: %w", err) } } if out, changed, err := withSeeAlso(tgtContent, wikilinkOf(sourcePath)); err != nil { return fmt.Errorf("update target: %w", err) } else if changed { if err := bfs.WriteRegular(targetPath, out); err != nil { return fmt.Errorf("update target: %w", err) } } return nil } // checkNotePath accepts only a clean, local wiki///.../.md // path: no "." or ".." segments, no empty segments, no leading slash. func checkNotePath(p string) error { if p == "" || path.Clean(p) != p || !filepath.IsLocal(p) || !strings.HasSuffix(p, ".md") { return fmt.Errorf("not a clean wiki note path: %q", p) } for _, part := range strings.Split(p, "/") { if part == "" || part == "." || part == ".." { return fmt.Errorf("not a clean wiki note path: %q", p) } } 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 } // withSeeAlso returns content with `- [[link]]` under its See also section, // creating the section if absent. changed is false when the link is already // present. func withSeeAlso(content []byte, link string) (out []byte, changed bool, err error) { wikilink := "[[" + link + "]]" if strings.Contains(string(content), wikilink) { return content, false, 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") return []byte(trimmed + "\n\n" + seeAlsoHeader + "\n\n" + bullet + "\n"), true, nil } // 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 nil, false, err } if !inserted { // section was the last thing in the file — just append bullet return []byte(strings.TrimRight(b.String(), "\n") + "\n" + bullet + "\n"), true, nil } return []byte(b.String()), true, nil }