Every brain write accepted [[targets]] that resolve to nothing — skill names, repo names, agent-local memory-file slugs — and each one became a permanent [high] broken-link finding. The brain-audit digest grew 19 -> 63 high findings between 2026-08-26 and 2026-09-16; 53 of the 63 were exactly this, and none are fixable by brain-gardener's BrokenLinkFixer, which only repoints edit-distance-1 renames. There is no target to repoint to. StripUnresolvedLinks resolves each link against knowledge/ + wiki/ using brain-gardener's own resolver semantics (bare slug or wing/hall/slug path form, code spans excluded) and unwraps the misses to plain text, reporting them as Ref.Unlinked / the MCP `unlinked` field. Unwrap rather than reject: a refused write on claudewatcher's unattended ingest path is a lost learning, and the author's words are worth keeping even when the link is not. An empty corpus is a no-op, so a misconfigured brainDir cannot strip every link in a note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
5.0 KiB
Go
158 lines
5.0 KiB
Go
package brain
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// 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/<wing>/<hall>/<slug>.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{}{}
|
|
for _, dir := range linkedTrees {
|
|
root := filepath.Join(brainDir, dir)
|
|
if _, err := os.Stat(root); os.IsNotExist(err) {
|
|
continue
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("stat %s: %w", dir, err)
|
|
}
|
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(brainDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
index[strings.TrimSuffix(filepath.Base(rel), ".md")] = struct{}{}
|
|
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
|
|
return nil
|
|
})
|
|
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
|
|
}
|