package brain import ( "errors" "fmt" "io/fs" "log/slog" "path" "regexp" "sort" "strings" "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" ) // wikilinkRE matches [[target]] and [[target|Display Text]]. var wikilinkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]*))?\]\]`) // fencedCodeRE and inlineCodeRE mark the regions where a [[...]] is a // syntax example rather than a link. Same exclusion brain-gardener's audit // applies on the read side, so the write gate and the audit agree on what // counts as a link at all. var ( fencedCodeRE = regexp.MustCompile("(?s)```.*?```") inlineCodeRE = regexp.MustCompile("`[^`\n]*`") ) // StripUnresolvedLinks unwraps every [[wikilink]] in content whose target // resolves to no entry under knowledge/ or wiki/, leaving the link's display // text (or its target) as plain prose. It returns the rewritten content and // the sorted, de-duplicated targets it unwrapped. // // WHY UNWRAP RATHER THAN REJECT THE WRITE // Agents write [[...]] around anything that feels related — a skill name, a // repo, an agent-local memory file — and none of those are brain entries. // Every one of those becomes a permanent [high] broken-link finding that no // deterministic Fixer can repair, because there is no target to repoint to: // the brain-audit digest grew from 19 to 63 high findings between 2026-08-26 // and 2026-09-16, and 53 of the 63 were exactly this. Rejecting the write // instead would push the failure onto claudewatcher's unattended ingest path, // where a refused write is a lost learning. Unwrapping keeps the author's // words, keeps the write, and keeps the graph honest. // // An empty corpus (no knowledge/ or wiki/ yet, or a misconfigured brainDir) // returns content unchanged: nothing resolving is a bootstrap state, not // evidence that every target was invented. func StripUnresolvedLinks(brainDir, content string) (string, []string, error) { index, err := entryIndex(brainDir) if err != nil { return "", nil, err } if len(index) == 0 { return content, nil, nil } code := codeSpans(content) stripped := map[string]struct{}{} var b strings.Builder last := 0 for _, m := range wikilinkRE.FindAllStringSubmatchIndex(content, -1) { start, end := m[0], m[1] if overlaps(start, end, code) { continue } target := strings.TrimSpace(content[m[2]:m[3]]) if _, ok := index[linkKey(target)]; ok { continue } display := target if m[4] >= 0 { if d := strings.TrimSpace(content[m[4]:m[5]]); d != "" { display = d } } b.WriteString(content[last:start]) b.WriteString(display) last = end stripped[target] = struct{}{} } if last == 0 { return content, nil, nil } b.WriteString(content[last:]) targets := make([]string, 0, len(stripped)) for t := range stripped { targets = append(targets, t) } sort.Strings(targets) return b.String(), targets, nil } // linkedTrees are the two trees that hold linkable entries — the same pair // package notes admits under ScopeNotes. Duplicated rather than imported // because that list is unexported there and this is a read-only mirror. var linkedTrees = []string{"knowledge", "wiki"} // entryIndex maps every form a wikilink may name an entry by — its bare slug // and its path form (wiki///.md -> wing/hall/slug) — to a // present marker. Mirrors brain-gardener's resolver so an accepted link is // one the audit will also resolve. func entryIndex(brainDir string) (map[string]struct{}, error) { index := map[string]struct{}{} // Walked through the brain root (hyperguild#157): only regular files // count as entries, so a symlinked .md name cannot make a link resolve. bfs, err := brainfs.Open(brainDir) if err != nil { return nil, fmt.Errorf("open brain: %w", err) } defer func() { _ = bfs.Close() }() for _, dir := range linkedTrees { if _, err := bfs.Lstat(dir); errors.Is(err, fs.ErrNotExist) { continue } var walkErr error err := bfs.WalkRegular(dir, func(rel string) error { if !strings.HasSuffix(rel, ".md") { return nil } index[strings.TrimSuffix(path.Base(rel), ".md")] = struct{}{} index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{} return nil }, func(rel string, err error) { // A linked tree that is not a real directory (a symlink, a // file) is skipped, as filepath.WalkDir did: this runs before // every brain_write, and an error here loses the learning. if rel == dir && errors.Is(err, brainfs.ErrNotRegular) { slog.Warn("link check: skipping a linked tree that is not a real directory", "dir", dir) return } if walkErr == nil { walkErr = fmt.Errorf("%s: %w", rel, err) } }) if err == nil { err = walkErr } if err != nil { return nil, fmt.Errorf("walk %s: %w", dir, err) } } return index, nil } // linkKey normalises a link target for index lookup: an exact path form is // tried as written, and anything else falls back to its final segment. func linkKey(target string) string { return strings.TrimSuffix(target, ".md") } // codeSpans returns the byte ranges of fenced blocks and inline code spans. func codeSpans(content string) [][2]int { var spans [][2]int for _, m := range fencedCodeRE.FindAllStringIndex(content, -1) { spans = append(spans, [2]int{m[0], m[1]}) } for _, m := range inlineCodeRE.FindAllStringIndex(content, -1) { spans = append(spans, [2]int{m[0], m[1]}) } return spans } // overlaps reports whether [start,end) intersects any span. func overlaps(start, end int, spans [][2]int) bool { for _, s := range spans { if start < s[1] && end > s[0] { return true } } return false }