StripUnresolvedLinks runs before every brain_write and brain_update. Any walk error below a linked tree's root (e.g. one unreadable subdirectory) made it return an error, and the write was refused: the learning was lost. Already true on main. It now logs a warning and returns the content unchanged: a partial index cannot tell an invented link from one into the unreadable part, so it unwraps nothing (never inventing a resolution) and the note is written with its links as given. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
5.9 KiB
Go
178 lines
5.9 KiB
Go
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 {
|
|
// A partial index cannot tell an invented link from one into the
|
|
// part it could not read, so unwrap nothing, and do not refuse the
|
|
// write either: this runs before every brain_write, and a refused
|
|
// write is a lost learning (hyperguild#178).
|
|
slog.Warn("link check skipped: cannot index the brain", "err", err)
|
|
return content, nil, nil
|
|
}
|
|
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{}{}
|
|
// 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
|
|
}
|