DetectTunnels (inside every brain_write/brain_update via AutoTunnel) walks with brainfs.WalkRegular and reads titles with ReadRegular: a FIFO under wiki/ no longer hangs the write, and a symlinked note's title no longer becomes a tunnel candidate. A wiki/ that is not a real directory yields no candidates; other walk errors still abort. Fixes #174. Refs #157 (last reader slice). Adversarial review: BLOCKED, PASS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Mathias <mthbqv@gmail.com>
354 lines
11 KiB
Go
354 lines
11 KiB
Go
package brain
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"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-<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 {
|
|
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/<wing>/<hall>/.../<slug>.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/<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
|
|
}
|
|
|
|
// 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
|
|
}
|