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) }