Files
gbrain/src/core/link-extraction.ts
T
87840341ea v0.31.7 fix-wave: doctor stops crying wolf — 5 community PRs (#798 + #788 + #536 + #376 + #128 adapted) (#804)
* fix: merge resolver entries from all files (RESOLVER.md + AGENTS.md)

OpenClaw deployments typically have AGENTS.md at the workspace root as the
real skill dispatcher (200+ entries), while gbrain skillpacks install a
thin skills/RESOLVER.md (~40 entries). The previous first-match-wins policy
meant check-resolvable only saw the thin RESOLVER.md, reporting 187 skills
as 'unreachable' when they were fully routed in AGENTS.md.

Now: check-resolvable collects entries from ALL resolver files across both
the skills directory and its parent. Entries are deduped by skillPath
(first occurrence wins). The combined content is also passed to the
routing-eval (Check 5) so routing fixtures see the full trigger index.

New function findAllResolverFiles() in resolver-filenames.ts returns all
matching files instead of just the first. findResolverFile() is unchanged
(backward-compatible for callers that need a single path).

Before: 37/224 reachable (our deployment)
After:  200/224 reachable (remaining 24 are genuine gaps)

Tests: 8 new (findAllResolverFiles + checkResolvable merge behavior)

* fix: graph_coverage skipped when brain has 0 entity pages

Closes #530.

`graph_coverage` measures `link_coverage` (fraction of entity pages with
inbound links) and `timeline_coverage` (fraction with timeline entries).
Both formulas divide by entity-page count.

For markdown-only brains (journals, wikis, notes — Karpathy's original
LLM Wiki use case) the entity count is 0, so coverage is structurally
undefined. The check still reported 'warn: 0%' under that condition,
which:
1. Brain owners cannot satisfy without indexing code/entities
2. Doctor's hint references stale commands (`link-extract` /
   `timeline-extract` were renamed to `extract` in v0.22)
3. Adds noise to compliance/health automation gating on doctor exit

Fix: detect entity-page count via SQL. If 0, mark check 'ok' with explanation.
Otherwise keep existing logic but update hint to current `gbrain extract all`.

Tested on Nous AGaaS production wiki: 2533 markdown pages, 100% embedded,
6086 wikilinks, 1964 timeline entries — 0 entity pages — graph_coverage
correctly clears.

* fix(doctor): deprecate stale link-extract / timeline-extract verb names

The graph_coverage hint and the link-extraction.ts header comment
still referenced `gbrain link-extract` / `gbrain timeline-extract`,
which were consolidated into `gbrain extract <links|timeline|all>` in
v0.16. Following the consolidation in #536's resolution (which fixed
the doctor hint to `gbrain extract all`), this commit removes the last
stale reference in `src/core/link-extraction.ts`'s header comment.

Originally PR #376 by @FUSED-ID. The doctor.ts portion of #376 is
absorbed by #536's richer warn message; this commit lands #376's
`link-extraction.ts` portion only.

Co-Authored-By: Leon-Gerard Vandenberg <FUSED-ID@users.noreply.github.com>

* test(doctor): pin canonical `gbrain extract all` hint, ban stale verbs

IRON-RULE regression guard for PR #376 + #536's graph_coverage hint
fix (locked in v0.31.7 eng-review). The removed verbs `gbrain
link-extract` and `gbrain timeline-extract` were consolidated into
`gbrain extract <links|timeline|all>` in v0.16 but the hint kept
suggesting them for ~30 releases. Pin the user-facing copy at the
source-string level so a future edit can't silently re-regress.

Structural assertion in the existing `doctor command` describe block,
matching the file's existing `frontmatter_integrity` / `rls_event_trigger`
pattern. No DB-fixture infrastructure needed.

* fix: sync RESOLVER.md triggers with v0.25.1 skill frontmatter

`gbrain doctor` reported 36 routing-miss/ambiguous warnings against the
v0.25.1 wave skills (book-mirror, article-enrichment, strategic-reading,
concept-synthesis, perplexity-research, archive-crawler, academic-verify,
brain-pdf, voice-note-ingest). Each skill's frontmatter declared 4-5
triggers, but only the first ever made it into RESOLVER.md's hand-curated
rows. The structural matcher couldn't find any specific phrase for
realistic user intents, so requests fell through to broader parents
(`ingest`, `enrich`, `data-research`).

Pulled the missing triggers from each skill's `triggers:` frontmatter
into the matching RESOLVER.md row. Converted media-ingest's prose row
to quoted triggers so the matcher actually sees them. Added
`"summarize this book"` to media-ingest (covers a book-mirror
disambiguation fixture). Marked article-enrichment + perplexity-research
fixtures with `ambiguous_with` for the parent skills they intentionally
chain with — RESOLVER.md's preamble explicitly documents that skills are
designed to chain, so this is acknowledging the truth, not papering over
a bug.

Result: 36 routing warnings → 0. resolver-test/check-resolvable/
routing-eval suite: 140/0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(doctor): find skills/ on every deployment shape (read-path-only)

Adapts the install-path resolution from PR #128 (TheAndersMadsen) into
the existing 5-tier autoDetectSkillsDir architecture. Two new code paths,
read-path-only by design:

1. Tier-0 $GBRAIN_SKILLS_DIR explicit operator override on the SHARED
   autoDetectSkillsDir. Safe for both read and write paths because the
   operator explicitly set the var — opt-in retargeting is fine.

2. New autoDetectSkillsDirReadOnly() function for READ-ONLY callers
   (gbrain doctor, check-resolvable, routing-eval). Wraps the shared
   detect; on null, walks up from fileURLToPath(import.meta.url) gated
   by isGbrainRepoRoot() so unrelated repos along the install path
   can't false-positive.

The split is the architectural fix for a write-path regression risk
codex outside-voice review surfaced (eng-review D5): adding the
install-path fallback to the SHARED resolver would let `gbrain skillpack
install` from `~` silently target the bundled gbrain repo's skills/
instead of the user's actual workspace. Three write-path call sites stay
on the original autoDetectSkillsDir; three read-path call sites switch
to the new readOnly variant.

Closes the install-path footgun for hosted-CLI installs:
`bun install -g github:garrytan/gbrain && cd ~ && gbrain doctor` now
finds the bundled skills/ instead of warning "Could not find skills
directory."

Test surface: 8 new cases in test/repo-root.test.ts covering tier-0
valid/invalid/precedence, install-path walk, isGbrainRepoRoot gate
(via primary-success-no-drift assertion), AUTO_DETECT_HINT updates,
and the D5 regression guard that pins the read-path/write-path split.

Co-Authored-By: Anders Madsen <TheAndersMadsen@users.noreply.github.com>

* docs(changelog): expand v0.31.7 entry for full 5-PR doctor wave

Promotes headline from "doctor stops crying wolf about unreachable
skills on OpenClaw" to the assembled wave's narrative: every doctor
false-positive class on disk today, plus the install-path footgun
that bit every hosted-CLI user.

Numbers-that-matter table expanded to 6 rows covering all 5 PRs.
Itemized-changes section grouped by sub-wave: resolver merge,
RESOLVER.md trigger sync, graph_coverage zero-entity, stale verb
hint fix, install-path resolver. Contributors named explicitly:
@mayazbay, @psperera, @FUSED-ID, @TheAndersMadsen. "For contributors"
section flags the new SkillsDirSource variants and the read-path /
write-path split as the canonical pattern for future fallback
additions.

* chore(v0.31.7): bump version + regenerate llms + fix CLI regression-gate

Wraps up the v0.31.7 doctor-fix wave:

- VERSION + package.json: 0.31.1.1-fixwave -> 0.31.7
- llms-full.txt: regenerated against the expanded v0.31.7 CHANGELOG
  entry (committed bundle drift caught by test/build-llms.test.ts)
- test/check-resolvable-cli.test.ts: update the REGRESSION-GATE for
  empty-cwd no_skills_dir error to reflect v0.31.7's intentional
  behavior change. The install-path fallback in autoDetectSkillsDirReadOnly
  now finds the bundled skills/ from any cwd inside the gbrain repo,
  so the test asserts source: 'install_path' instead of error: 'no_skills_dir'.
  This is the wave's headline capability ("doctor finds itself on every
  deployment shape") rather than a regression.

Pre-existing flake unrelated to this wave: BrainRegistry — lazy init >
empty/null/undefined id routes to host fails on machines that have
~/.gbrain/config.json present (the test assumes test env has none).
Reproduces on master before this wave landed; not a v0.31.7 regression.
Filed for follow-up in next maintainer hygiene sweep.

* fix(doctor): close write-path leak in --fix + sync routing-eval merge

Codex adversarial review of v0.31.7 caught a HIGH that the eng review
missed (D6 lock during /ship): the read-path-only architecture for the
install-path fallback is leaky because TWO of the three "read-only"
callers (doctor, check-resolvable) actually have write modes via --fix
that call autoFixDryViolations() and writeFileSync to SKILL.md files.
A user running `cd ~ && gbrain doctor --fix` with no skills/RESOLVER.md
up the cwd tree would resolve via the install-path fallback to the
bundled gbrain repo and silently rewrite the install-tree skills —
exactly the regression D5's split was supposed to prevent.

Fix: when --fix is requested and the resolved skills dir came from the
install-path source, refuse with a clear error pointing at GBRAIN_SKILLS_DIR
/ OPENCLAW_WORKSPACE / --skills-dir as explicit overrides. The read parts
of doctor and check-resolvable continue to benefit from the install-path
fallback (the v0.31.7 capability headline); only --fix is gated.

Plus a MEDIUM consistency fix codex flagged: routing-eval was still
single-file-only while check-resolvable does multi-file merge across
skills/RESOLVER.md + ../AGENTS.md. On OpenClaw layouts this caused
routing-eval and check-resolvable to disagree on what's routable.
routing-eval now uses the same findAllResolverFiles + content-merge
pattern as check-resolvable, so all three commands see the same
trigger index.

Test coverage: D6 regression guard in test/check-resolvable-cli.test.ts
spawning a real subprocess from an empty tempdir (no env, no cwd
fallback) and asserting --fix refuses with the correct stderr message.

Co-Authored-By: Codex (outside-voice review) <noreply@openai.com>

* docs(changelog): note D6 --fix gate + routing-eval merge in v0.31.7 entry

* docs: post-ship sync for v0.31.7

CLAUDE.md updates only. CHANGELOG.md was already authored by /ship and was left untouched.

- src/core/repo-root.ts annotation: read-path/write-path split, tier-0 GBRAIN_SKILLS_DIR override, autoDetectSkillsDirReadOnly install-path fallback, D6 --fix safety gate.
- src/commands/check-resolvable.ts annotation: multi-file resolver merge across skills dir + parent (37/224 -> 200/224 reachable on the reference OpenClaw layout), install-path read-only fallback, D6 --fix gate.
- src/commands/routing-eval.ts annotation: same multi-file merge as check-resolvable; v0.25.1 RESOLVER.md trigger sync.
- src/commands/doctor.ts annotation: switched to autoDetectSkillsDirReadOnly so 'cd ~ && gbrain doctor' finds bundled skills via install-path fallback; --fix D6 install-path refuse-write gate; graph_coverage zero-entity short-circuit + canonical 'gbrain extract all' hint with regression-test pin.
- Test inventory: replaced bare regression-v0_16_4 line with explicit test/repo-root.test.ts entry (20 cases - 12 existing + 8 new D3/D5) and new test/resolver-merge.test.ts entry (8 cases).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(llms): regenerate after CLAUDE.md sync for v0.31.7

* ci(test): quarantine *.serial.test.ts files from test-shard

CI's test-shard.sh was including *.serial.test.ts files in the parallel
shard runs, which broke voyage-multimodal.test.ts: 18 of its 22 tests
failed in CI shard 2 because eval-takes-quality-runner.serial.test.ts
ran before it in the same bun-test process and leaked its mock.module()
substitution of src/core/ai/gateway.ts. The leaked mock omitted
embedMultimodal and resetGateway, so voyage-multimodal saw `undefined
is not a function` everywhere it touched the gateway.

Locally `bun run test` (run-unit-parallel.sh → run-unit-shard.sh)
already excludes *.serial.test.ts and runs them via `bun run test:serial`
in their own pass with --max-concurrency=1. Master ran green there;
only CI's matrix shards exposed the leak. The runner.serial test file's
own header comment explicitly calls out this exact cross-file mock
leak — the quarantine was the design, CI just wasn't honoring it.

Three changes:

1. scripts/test-shard.sh — exclude *.serial.test.ts and *.slow.test.ts
   from the find expression, mirroring scripts/run-unit-shard.sh.

2. .github/workflows/test.yml — add a `test-serial` sibling job that
   runs `bun run test:serial`. Keeps serial tests gating CI without
   merging them back into the parallel shards.

3. test/scripts/test-shard.test.ts — regression test pinning the three
   exclusion clauses (serial, slow, e2e) so a future refactor that
   drops one of them fails loud rather than silently re-introducing
   the cross-file mock leak.

Verified locally:
- shard 2 reproduction: 18 voyage-multimodal failures → 0 (1 unrelated
  env-dependent perf flake remains, won't fail on CI)
- bun run test:serial: 189/190 pass (1 unrelated env-dependent
  BrainRegistry flake from ~/.gbrain/config.json presence)
- typecheck + check:test-isolation clean

* ci(test): rephrase mock-module comment to satisfy R2 lint

The verify gate's check:test-isolation flagged test/scripts/test-shard.test.ts
because the JSDoc comment contained the literal string 'mock.module()'
which matches R2's grep regex 'mock\.module[[:space:]]*\('. The file
itself doesn't use mock.module — it just describes why the linter rule
exists in human-readable prose.

Rephrased to avoid the trailing parens. The regex requires the open
paren, so 'bun's module-mocking primitive' instead of 'mock.module()'
is invisible to the linter while preserving meaning for the next
maintainer who reads the test.

* docs(claude): tighten version-consistency rules + add merge recovery procedure

After several merges from master where VERSION + package.json +
CHANGELOG.md drifted out of sync (each merge hit conflicts on those
three files; auto-merge sometimes resolved silently in the wrong
direction), CLAUDE.md gets an explicit drift-recovery checklist + a
3-line paste-ready audit command anyone can run.

Three additions to the existing "Version locations" section:

1. **Mandatory audit command** — three echo lines that print VERSION,
   package.json version, and the top CHANGELOG header. All three MUST
   match the wave's `MAJOR.MINOR.PATCH.MICRO`. Designed for paste-after-
   every-merge use.

2. **Merge-conflict recovery procedure** — exact sed/echo patterns for
   resolving VERSION + package.json + CHANGELOG conflicts, in the order
   to apply them. Names the anti-pattern (mixing `git checkout --ours`
   on the trio) that's bitten us before.

3. **Pre-push gate** — re-run the audit before `git push` of any merge
   commit. /ship Step 12 catches drift but only if you actually run
   /ship; manual pushes skip the check.

Confirmed consistent at d361482a, 7e8f6960, 65a5994a (every merge
commit on this branch). The doc gap was the rules being too loose,
not the rules being wrong — this beefs up the procedural side so the
next merge can't silently desync.

* docs(llms): regenerate after CLAUDE.md edit + tighten the rule

CI failed on the build-llms generator test because CLAUDE.md edited
in fe050ae0 (version-consistency procedure) shipped without a
matching `bun run build:llms` regen. The committed llms-full.txt was
77 lines short of fresh generator output, and test/build-llms.test.ts
caught the drift in CI shard 1.

Two changes:

1. llms.txt + llms-full.txt — regenerated to match current CLAUDE.md.

2. CLAUDE.md — strengthened the "Auto-derived" entry for llms.txt /
   llms-full.txt with explicit "every CLAUDE.md edit chases with
   `bun run build:llms` in the same commit" wording. Notes that
   `verify` doesn't run the build-llms test, only the full unit
   suite does, so a clean typecheck is NOT enough to know you can
   push after touching CLAUDE.md.

This is now the third time this has bitten the wave. The previous
"Auto-derived" entry said the right thing but was buried in a list;
elevating it to imperative voice with a count of past regressions
should make the next CLAUDE.md edit hard to land without the chaser.

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Madi Ayazbay <madia@Mac.localdomain>
Co-authored-by: Leon-Gerard Vandenberg <FUSED-ID@users.noreply.github.com>
Co-authored-by: psperera <pperera@mac.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Anders Madsen <TheAndersMadsen@users.noreply.github.com>
Co-authored-by: Codex (outside-voice review) <noreply@openai.com>
2026-05-10 13:20:07 -07:00

936 lines
42 KiB
TypeScript

/**
* Shared link/timeline extraction utilities.
*
* Used by:
* - src/commands/extract.ts (batch DB + FS extraction — `gbrain extract links|timeline|all`)
* - src/commands/backlinks.ts (filesystem walk, legacy)
* - src/core/operations.ts put_page (auto-link post-hook)
*
* All functions are PURE (no DB access). The DB lives in the engine; these
* utilities turn page content into candidates that callers persist via engine
* methods. Auto-link config is the one impure helper (reads engine.getConfig).
*/
import type { BrainEngine } from './engine.ts';
import type { PageType } from './types.ts';
// ─── Entity references ──────────────────────────────────────────
export interface EntityRef {
/** Display name from the markdown link, e.g. "Alice Chen". */
name: string;
/** Resolved page slug, e.g. "people/alice-chen". */
slug: string;
/** Top-level directory ("people" | "companies" | etc.). */
dir: string;
/**
* v0.17.0: source id when the link was qualified as `[[source:slug]]`.
* `null` means unqualified — the caller resolves via local-first fallback
* at extraction time. Mirrors links.resolution_type:
* - sourceId set → 'qualified'
* - sourceId null → 'unqualified'
*/
sourceId?: string | null;
}
/** v0.17.0: how a link's target source was pinned at extraction time. */
export type LinkResolutionType = 'qualified' | 'unqualified';
/**
* Directory prefix whitelist. These are the top-level slug dirs the extractor
* recognizes as entity references. Upstream canonical + our extensions:
* - Gbrain canonical: people, companies, meetings, concepts, deal, civic, project, source, media, yc, projects
* - Our domain extensions: tech, finance, personal, openclaw (domain-organized wikis)
* - Our entity prefix: entities (we kept some legacy entities/projects/ pages)
*/
const DIR_PATTERN = '(?:people|companies|meetings|concepts|deal|civic|project|projects|source|media|yc|tech|finance|personal|openclaw|entities)';
/**
* Match `[Name](path)` markdown links pointing to entity directories.
* Accepts both filesystem-relative format (`[Name](../people/slug.md)`)
* AND engine-slug format (`[Name](people/slug)`).
*
* Captures: name, slug (dir/name, possibly deeper).
*
* The regex permits an optional `../` prefix (any number) and an optional
* `.md` suffix so the same function works for both filesystem and DB content.
*/
const ENTITY_REF_RE = new RegExp(
`\\[([^\\]]+)\\]\\((?:\\.\\.\\/)*(${DIR_PATTERN}\\/[^)\\s]+?)(?:\\.md)?\\)`,
'g',
);
/**
* Match Obsidian-style `[[path]]` or `[[path|Display Text]]` wikilinks.
* Captures: slug (dir/...), displayName (optional).
*
* Same dir whitelist as ENTITY_REF_RE. Strips trailing `.md`, strips section
* anchors (`#heading`), skips external URLs. Wiki KBs use this format almost
* exclusively so missing it leaves the graph empty.
*/
const WIKILINK_RE = new RegExp(
`\\[\\[(${DIR_PATTERN}\\/[^|\\]#]+?)(?:#[^|\\]]*?)?(?:\\|([^\\]]+?))?\\]\\]`,
'g',
);
/**
* v0.17.0: qualified wikilink `[[source-id:dir/slug]]` or
* `[[source-id:dir/slug|Display Text]]`. The source-id segment pins the
* target to a specific sources(id) row, overriding the local-first
* fallback used by unqualified `[[slug]]` references.
*
* Captures: sourceId, slug (dir/...), displayName (optional).
*
* Matched BEFORE WIKILINK_RE so `[[wiki:topics/ai]]` isn't mis-parsed by
* the unqualified regex (the source prefix would not satisfy DIR_PATTERN
* anyway, but the two-pass approach keeps intent crystal-clear).
*/
const QUALIFIED_WIKILINK_RE = new RegExp(
`\\[\\[([a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?):(${DIR_PATTERN}\\/[^|\\]#]+?)(?:#[^|\\]]*?)?(?:\\|([^\\]]+?))?\\]\\]`,
'g',
);
/**
* Strip fenced code blocks (```...```) and inline code (`...`) from markdown,
* replacing them with whitespace of equivalent length. Preserves byte offsets
* for any caller that cares about positions; for our extractors this is just
* defense-in-depth — slugs inside code are not real entity references.
*/
function stripCodeBlocks(content: string): string {
let out = '';
let i = 0;
while (i < content.length) {
// Fenced block: ``` (optional language) ... ```
if (content.startsWith('```', i)) {
const end = content.indexOf('```', i + 3);
if (end === -1) { out += ' '.repeat(content.length - i); break; }
out += ' '.repeat(end + 3 - i);
i = end + 3;
continue;
}
// Inline code: `...` (single backtick, no newline inside)
if (content[i] === '`') {
const end = content.indexOf('`', i + 1);
if (end === -1 || content.slice(i + 1, end).includes('\n')) {
out += content[i];
i++;
continue;
}
out += ' '.repeat(end + 1 - i);
i = end + 1;
continue;
}
out += content[i];
i++;
}
return out;
}
/**
* A code-reference found in markdown prose. Created by extractCodeRefs and
* consumed by importFromFile's tail to build doc↔impl edges (v0.19.0 E1).
*/
export interface CodeRef {
/** Raw matched path (e.g. 'src/core/sync.ts'). */
path: string;
/** Optional line number from 'src/foo.ts:42'. */
line?: number;
/** Index in the source string. */
index: number;
}
// v0.19.0 E1 — markdown guides that cite 'src/core/sync.ts:42' create an
// edge to the code page that imported that file. Regex is anchored against
// the common gbrain repo layout directories so arbitrary prose like
// "in foo/bar.js" doesn't generate false positives.
//
// The extension list is aligned with detectCodeLanguage in chunkers/code.ts.
// Paths NOT matching these extensions are ignored because they wouldn't
// have a code page to edge to anyway.
const CODE_REF_REGEX = /\b((?:src|lib|app|test|tests|scripts|docs|packages|internal|cmd|examples)\/[\w\-./]+\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|go|rs|java|cs|cpp|cc|hpp|c|h|php|swift|kt|scala|lua|ex|exs|elm|ml|dart|zig|sol|sh|bash|css|html|vue|json|yaml|yml|toml))(?::(\d+))?\b/g;
/**
* Extract code-path references (e.g. 'src/core/sync.ts:42') from markdown
* prose. Deduped by path.
*/
/**
* v0.27.1 (cherry-3): path-proximity auto-link candidate finder for image
* ingest. Given an image slug like `originals/photos/2026-05-04-foo.jpg`,
* proposes candidate sibling slugs for an `image_of` edge:
* 1. `originals/meetings/2026-05-04-foo.md` (parallel directory + same basename)
* 2. `<parent>/foo.md` (same directory + sibling basename minus extension)
*
* Returns slug candidates in priority order. Caller (importImageFile) checks
* which candidates exist as pages and emits the edge for the first match.
*/
export function imageOfCandidates(imageSlug: string): string[] {
const lower = imageSlug.toLowerCase();
const lastSlash = lower.lastIndexOf('/');
if (lastSlash < 0) return [];
const dir = lower.slice(0, lastSlash);
const file = lower.slice(lastSlash + 1);
// Strip image extension from basename to get a stable identifier.
const base = file.replace(/\.(png|jpg|jpeg|gif|webp|heic|heif|avif)$/i, '');
if (!base) return [];
const out: string[] = [];
// Heuristic 1: parallel directory swap. originals/photos/X → originals/meetings/X
const dirParts = dir.split('/');
const PHOTO_DIRS = new Set(['photos', 'images', 'screenshots', 'media']);
const SIBLING_DIRS = ['meetings', 'notes', 'daily', 'people', 'companies', 'deals', 'projects'];
for (let i = 0; i < dirParts.length; i++) {
if (PHOTO_DIRS.has(dirParts[i])) {
for (const sib of SIBLING_DIRS) {
const swapped = [...dirParts];
swapped[i] = sib;
out.push(`${swapped.join('/')}/${base}`);
}
}
}
// Heuristic 2: same directory, basename without ext as a markdown page.
out.push(`${dir}/${base}`);
// Deduplicate, drop the imageSlug itself if it accidentally roundtrips.
const seen = new Set<string>();
return out.filter(s => {
if (s === lower) return false;
if (seen.has(s)) return false;
seen.add(s);
return true;
});
}
export function extractCodeRefs(content: string): CodeRef[] {
const seen = new Set<string>();
const refs: CodeRef[] = [];
let match: RegExpExecArray | null;
// Using a fresh regex object per call to avoid lastIndex state leaking
// across invocations.
const re = new RegExp(CODE_REF_REGEX.source, 'g');
while ((match = re.exec(content)) !== null) {
const path = match[1]!;
if (seen.has(path)) continue;
seen.add(path);
const line = match[2] ? parseInt(match[2], 10) : undefined;
refs.push({ path, line, index: match.index });
}
return refs;
}
/**
* Extract `[Name](path-to-people-or-company)` references from arbitrary content.
* Both filesystem-relative paths (with `../` and `.md`) and bare engine-style
* slugs (`people/slug`) are matched. Returns one EntityRef per match (no dedup
* here; caller dedups). Slugs appearing inside fenced or inline code blocks
* are excluded — those are typically code samples, not real entity references.
*/
export function extractEntityRefs(content: string): EntityRef[] {
const stripped = stripCodeBlocks(content);
const refs: EntityRef[] = [];
let match: RegExpExecArray | null;
// 1. Markdown links: [Name](path)
// Markdown links have no source-qualification syntax — they're
// always unqualified. Omit sourceId so the shape stays compatible
// with pre-v0.17 consumers doing strict equality.
const mdPattern = new RegExp(ENTITY_REF_RE.source, ENTITY_REF_RE.flags);
while ((match = mdPattern.exec(stripped)) !== null) {
const name = match[1];
const fullPath = match[2];
const slug = fullPath;
const dir = fullPath.split('/')[0];
refs.push({ name, slug, dir });
}
// 2a. v0.17.0 qualified wikilinks: [[source-id:path]] or [[source-id:path|Display]]
// Must run BEFORE the unqualified pass or we'd double-emit. We also
// mask out the matched spans so pass 2b can't grab them.
const qualifiedRanges: Array<[number, number]> = [];
const qualPattern = new RegExp(QUALIFIED_WIKILINK_RE.source, QUALIFIED_WIKILINK_RE.flags);
while ((match = qualPattern.exec(stripped)) !== null) {
const sourceId = match[1];
let slug = match[2].trim();
if (!slug) continue;
if (slug.includes('://')) continue;
if (slug.endsWith('.md')) slug = slug.slice(0, -3);
const displayName = (match[3] || slug).trim();
const dir = slug.split('/')[0];
refs.push({ name: displayName, slug, dir, sourceId });
qualifiedRanges.push([match.index, match.index + match[0].length]);
}
// 2b. Unqualified Obsidian wikilinks: [[path]] or [[path|Display Text]]
// Same shape rule: omit sourceId when unqualified.
const unmasked = maskRanges(stripped, qualifiedRanges);
const wikiPattern = new RegExp(WIKILINK_RE.source, WIKILINK_RE.flags);
while ((match = wikiPattern.exec(unmasked)) !== null) {
let slug = match[1].trim();
if (!slug) continue;
if (slug.includes('://')) continue;
if (slug.endsWith('.md')) slug = slug.slice(0, -3);
const displayName = (match[2] || slug).trim();
const dir = slug.split('/')[0];
refs.push({ name: displayName, slug, dir });
}
return refs;
}
/**
* Replace the byte ranges with spaces, preserving offsets. Used by
* extractEntityRefs to prevent the unqualified wikilink regex from
* matching inside a qualified wikilink span.
*/
function maskRanges(content: string, ranges: Array<[number, number]>): string {
if (ranges.length === 0) return content;
const chars = content.split('');
for (const [s, e] of ranges) {
for (let i = s; i < e && i < chars.length; i++) chars[i] = ' ';
}
return chars.join('');
}
// ─── Link candidates (richer than EntityRef) ────────────────────
export interface LinkCandidate {
/**
* Source page slug for the edge. When omitted, callers default to
* "the page being written" (operations.ts runAutoLink) or "the page
* currently being processed" (extract.ts). Explicitly set when
* frontmatter emits an incoming edge — e.g. a company page's
* `key_people: [pedro-franceschi]` produces a candidate whose
* fromSlug is `people/pedro-franceschi`, not the company.
*/
fromSlug?: string;
/** Target page slug (no .md, no ../). */
targetSlug: string;
/** Inferred relationship type. */
linkType: string;
/** Surrounding text (up to ~80 chars) used for inference + storage. */
context: string;
/**
* Provenance (v0.13+). Defaults to 'markdown' on older call sites;
* frontmatter-derived candidates set 'frontmatter'; user-created edges
* via explicit API pass 'manual'.
*/
linkSource?: string;
/**
* Origin-page slug. Only populated for link_source='frontmatter' so
* reconciliation can scope cleanups to edges THIS page's frontmatter
* created (never touching edges other pages authored).
*/
originSlug?: string;
/** Frontmatter field name (e.g. 'key_people'), for debug + unresolved report. */
originField?: string;
}
/**
* Result of extractPageLinks. `candidates` includes markdown refs + bare
* slug refs + frontmatter-derived edges (v0.13). `unresolved` lists
* frontmatter names that did not resolve to any page — surfaced in the
* put_page auto_links response and the extract summary so users know
* where the graph has holes.
*/
export interface PageLinksResult {
candidates: LinkCandidate[];
unresolved: UnresolvedFrontmatterRef[];
}
/**
* Extract all link candidates from a page.
*
* Sources:
* 1. Markdown entity refs in compiled_truth + timeline (extractEntityRefs).
* 2. Bare slug references in text (people/slug, companies/slug).
* 3. Frontmatter fields → typed graph edges (v0.13: company, investors,
* attendees, key_people, etc.). See FRONTMATTER_LINK_MAP.
*
* ASYNC (v0.13): frontmatter extraction resolves display names to slugs
* via the supplied resolver, which may hit the DB. Pre-v0.13 callers
* that don't care about frontmatter can pass a resolver that always
* returns null; only markdown/bare-slug candidates are emitted.
*
* Within-page dedup: multiple mentions of the same (fromSlug, targetSlug,
* linkType) tuple collapse to one candidate. First occurrence wins.
*/
export async function extractPageLinks(
slug: string,
content: string,
frontmatter: Record<string, unknown>,
pageType: PageType,
resolver: SlugResolver,
): Promise<PageLinksResult> {
const candidates: LinkCandidate[] = [];
// 1. Markdown entity refs.
for (const ref of extractEntityRefs(content)) {
const idx = content.indexOf(ref.name);
// Wider context window (240 chars vs original 80) catches verbs that
// appear at sentence-or-paragraph distance from the slug — common in
// narrative prose where a partner's investment verbs appear once and
// then portfolio companies are listed in subsequent sentences.
const context = idx >= 0 ? excerpt(content, idx, 240) : ref.name;
candidates.push({
targetSlug: ref.slug,
linkType: inferLinkType(pageType, context, content, ref.slug),
context,
linkSource: 'markdown',
});
}
// 2. Bare slug references (e.g. "see people/alice-chen for context").
// Limited to the same entity directories ENTITY_REF_RE covers.
// Code blocks are stripped first — slugs in code samples are not real refs.
const strippedContent = stripCodeBlocks(content);
const bareRe = new RegExp(
`\\b(${DIR_PATTERN}\\/[a-z0-9][a-z0-9/-]*[a-z0-9])\\b`,
'g',
);
let m: RegExpExecArray | null;
while ((m = bareRe.exec(strippedContent)) !== null) {
// Skip matches that are part of a markdown link (already handled above).
const charBefore = m.index > 0 ? strippedContent[m.index - 1] : '';
if (charBefore === '/' || charBefore === '(') continue;
const context = excerpt(strippedContent, m.index, 240);
candidates.push({
targetSlug: m[1],
linkType: inferLinkType(pageType, context, content, m[1]),
context,
linkSource: 'markdown',
});
}
// 3. Frontmatter-derived edges (v0.13). Includes the legacy `source:`
// field along with the full field map.
const fm = await extractFrontmatterLinks(slug, pageType, frontmatter, resolver);
candidates.push(...fm.candidates);
// Within-page dedup: same (fromSlug, targetSlug, linkType, linkSource)
// collapses to one entry. First occurrence wins.
const seen = new Set<string>();
const result: LinkCandidate[] = [];
for (const c of candidates) {
const key = `${c.fromSlug ?? ''}\u0000${c.targetSlug}\u0000${c.linkType}\u0000${c.linkSource ?? ''}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(c);
}
return { candidates: result, unresolved: fm.unresolved };
}
/** Excerpt a window of `width` chars around `idx`, collapsed to one line. */
function excerpt(s: string, idx: number, width: number): string {
const half = Math.floor(width / 2);
const start = Math.max(0, idx - half);
const end = Math.min(s.length, idx + half);
return s.slice(start, end).replace(/\s+/g, ' ').trim();
}
// ─── Relationship type inference (deterministic, zero LLM) ──────
// ─── Type-inference patterns ────────────────────────────────────
//
// Calibrated against the BrainBench rich-prose corpus (240 pages of
// LLM-generated narrative). The templated 80-page benchmark hit 94.4% type
// accuracy, but rich prose dropped to 70.7% before this round of tuning —
// LLMs use far more verb forms than the original regexes covered.
//
// Key issues fixed:
// - INVESTED_RE missed "led the seed", "led the Series A", "early investor",
// "invests in" (present), "investing in" (gerund), "portfolio company".
// - ADVISES_RE matched generic "board member" / "sits on the board" which
// also describes investors holding board seats. Tightened to require
// explicit "advisor"/"advise" rooting.
// Employment context: position + at/of, or explicit work verbs.
//
// v0.10.5 additions (drive works_at 58% → >85% on rich prose):
// - Role-prefixed engineer patterns: "senior engineer at", "staff engineer at",
// "principal engineer at", "lead engineer at". Current "engineer at" only
// hits if the word "engineer" is immediately adjacent; prose often uses
// rank-qualified forms.
// - Generic role patterns: "backend engineer at", "frontend engineer at",
// "ML engineer at", "data engineer at", "full-stack engineer at".
// - Broader role verbs: "manages engineering at", "running product at",
// "leads the [team] at", "heads up engineering at".
// - Possessive time: "his time at", "her time at", "their time at", "my time at".
// - Role noun forms: "role at", "tenure as", "stint as", "position at".
// - Promoted/staff-engineer forms: "promoted to (staff|senior|principal) engineer at".
const WORKS_AT_RE = /\b(?:CEO of|CTO of|COO of|CFO of|CMO of|CRO of|VP at|VP of|VPs? Engineering|VPs? Product|works at|worked at|working at|employed by|employed at|joined as|joined the team|engineer at|engineer for|director at|director of|head of|heads up .{0,20} at|leads engineering|leads product|leads the .{0,20} (?:team|org) at|manages engineering at|manages product at|running (?:engineering|product|design) at|currently at|previously at|previously worked at|spent .* (?:years|months) at|stint at|stint as|tenure at|tenure as|role at|position at|(?:senior|staff|principal|lead|backend|frontend|full-?stack|ML|data|security) engineer at|promoted to (?:senior|staff|principal|lead) .{0,20} at|(?:his|her|their|my) time at)\b/i;
// Investment context. Order patterns from most-specific to least to keep
// regex efficient. Includes funding-round verbs ("led the seed", "led X's
// Series A"), narrative verbs ("invests in", "investing in"), historical
// ("early investor in", "first check"), and portfolio framing ("portfolio
// company", "portfolio includes").
const INVESTED_RE = /\b(?:invested in|invests in|investing in|invest in|investment in|investments in|backed by|funding from|funded by|raised from|led the (?:seed|Series|round|investment|round)|led .{0,30}(?:Series [A-Z]|seed|round|investment)|participated in (?:the )?(?:seed|Series|round)|wrote (?:a |the )?check|first check|early investor|portfolio (?:company|includes)|board seat (?:at|in|on)|term sheet for)\b/i;
// Founded patterns. Includes the noun-form "founder of" / "founders include"
// because that's how real prose identifies founders ("Carol Wilson is the
// founder of Anchor"). Diagnosed via BrainBench rich-corpus misses.
const FOUNDED_RE = /\b(?:founded|co-?founded|started the company|incorporated|founder of|founders? (?:include|are)|the founder|is a co-?founder|is one of the founders)\b/i;
// Advise context: must be rooted in "advisor"/"advise" (investors also sit on
// boards). Keep "board advisor" / "advisory board" but drop generic "board
// member" / "sits on the board" which over-matches.
//
// v0.10.5 additions (drive advises 41% → >85% on rich prose):
// - Advisory capacity phrasings: "in an advisory capacity", "advisory engagement",
// "advisory partnership", "advisory contract", "advisory relationship".
// - "as an advisor" form: joined/serves/brought on "as an advisor" / "as a
// security advisor" / "as a technical advisor" / "as an industry advisor".
// - "consults for / consulting role": advisor-adjacent verbs that appear in
// narratives where the direct "advises" verb isn't used.
// - Advisor-qualified: "strategic advisor to|at", "technical advisor to|at",
// "security advisor to|at", "product advisor to|at", "industry advisor".
const ADVISES_RE = /\b(?:advises|advised|advisor (?:to|at|for|of)|advisory (?:board|role|position|capacity|engagement|partnership|contract|relationship|work)|board advisor|on .{0,20} advisory board|joined .{0,20} advisory board|in an? advisory (?:capacity|role|position)|as an? (?:advisor|security advisor|technical advisor|strategic advisor|industry advisor|product advisor|board advisor|senior advisor)|(?:strategic|technical|security|product|industry|senior|board) advisor (?:to|at|for|of)|consults for|consulting role (?:at|with))\b/i;
// Page-role detection: if the source page describes a partner/investor at
// page level, that's a strong prior for outbound company refs being
// invested_in even when per-edge context lacks explicit investment verbs.
const PARTNER_ROLE_RE = /\b(?:partner at|partner of|venture partner|VC partner|invested early|investor at|investor in|portfolio|venture capital|early-stage investor|seed investor|fund [A-Z]|invests across|backs companies)\b/i;
// Advisor role prior: fires when the page-level description indicates the
// person IS an advisor (not just mentions advising). Broadened in v0.10.5
// from "full-time/professional/advises multiple" to catch any page that
// self-identifies the subject as an advisor.
const ADVISOR_ROLE_RE = /\b(?:full-time advisor|professional advisor|advises (?:multiple|several|various)|is an? (?:advisor|security advisor|technical advisor|strategic advisor|industry advisor|product advisor|senior advisor)|took on advisory roles|(?:her|his|their) advisory (?:work|role|engagement|portfolio)|serves as (?:an )?advisor)\b/i;
// Employee role prior (new in v0.10.5): fires when the page-level description
// indicates the person IS an employee (senior/staff/lead engineer, director,
// head, etc.) at some company. Biases outbound company refs on that page
// toward works_at when per-edge verbs are absent (e.g. possessive phrasings
// "her work on Delta's pipeline..." where the verb "works" doesn't appear
// near the slug).
//
// Scope: only fires for person-page → company-page links. Companies' own
// pages mentioning their employees use the page-role layer differently.
const EMPLOYEE_ROLE_RE = /\b(?:is an? (?:senior|staff|principal|lead|backend|frontend|full-?stack|ML|data|security|DevOps|platform)? ?engineer at|is an? (?:senior|staff|principal|lead)? ?(?:developer|designer|product manager|engineering manager|director|VP) (?:at|of)|holds? the (?:CTO|CEO|CFO|COO|CMO|CRO|VP) (?:role|position|seat|title) at|is the (?:CTO|CEO|CFO|COO|CMO|CRO) of|employee at|on the team at|works on .{0,30} at)\b/i;
/**
* Infer link_type from page context. Deterministic regex heuristics, no LLM.
*
* Two layers of inference:
* 1. Per-edge: ~240 char window around the slug mention. Looks for explicit
* verbs (FOUNDED_RE, INVESTED_RE, ADVISES_RE, WORKS_AT_RE).
* 2. Page-role prior: when per-edge inference falls through to 'mentions',
* check if the SOURCE page describes the author as a partner/investor.
* If yes, bias outbound company refs toward 'invested_in'.
*
* Precedence: founded > invested_in > advises > works_at > role prior > mentions.
*
* The role-prior layer is what closes the gap on partner bios where the prose
* lists portfolio companies without repeating the investment verb each time
* ("Her current board seats reflect her portfolio: [Co A], [Co B], [Co C]").
*/
export function inferLinkType(pageType: PageType, context: string, globalContext?: string, targetSlug?: string): string {
if (pageType === 'media') {
return 'mentions';
}
// v0.27.1: image pages link to their text sibling via 'image_of' (the
// image is OF that meeting/note). Set explicitly by the import-image
// path-proximity helper, not by markdown extraction — but the type is
// declared here so graph-query knows the edge name.
if ((pageType as string) === 'image') return 'image_of';
if ((pageType as string) === 'meeting') return 'attended';
// Per-edge verb rules.
if (FOUNDED_RE.test(context)) return 'founded';
if (INVESTED_RE.test(context)) return 'invested_in';
if (ADVISES_RE.test(context)) return 'advises';
if (WORKS_AT_RE.test(context)) return 'works_at';
// Page-role prior: only fires for person -> company links. Concept pages
// about VC topics naturally contain "venture capital" in their text, but
// their company refs are mentions, not investments. Partner pages mentioning
// other people (co-investors, friends) should also stay as mentions.
//
// Precedence within priors: investor > advisor > employee. Investors often
// also sit on boards ("board seat at portfolio company") which a naive
// employee/advisor match would mis-classify; keep investor first so those
// phrasings resolve correctly.
if (pageType === 'person' && globalContext && targetSlug?.startsWith('companies/')) {
if (PARTNER_ROLE_RE.test(globalContext)) return 'invested_in';
if (ADVISOR_ROLE_RE.test(globalContext)) return 'advises';
if (EMPLOYEE_ROLE_RE.test(globalContext)) return 'works_at';
}
return 'mentions';
}
// ─── Frontmatter link extraction (v0.13) ────────────────────────
//
// YAML frontmatter on entity pages carries rich relationship data:
//
// company: "Stripe" # person page
// companies: [Stripe, Plaid] # person page (alias of company)
// key_people: [Patrick Collison, John] # company page (incoming works_at)
// investors: [{name: Sequoia}, Benchmark] # deal page (incoming invested_in)
// attendees: [Pedro, Garry] # meeting page (incoming attended)
//
// Each maps to a typed graph edge. The mapping lives here (one source of
// truth) so the three entry points — operations.ts auto-link, extract.ts
// fs source, extract.ts db source — emit identical edges for the same
// frontmatter. This is the point of the v0.13 rewrite.
//
// DIRECTION: "incoming" means the page being written is the TO side;
// the FROM side is the resolved frontmatter value. E.g. `key_people:
// [Pedro]` on company/stripe emits `people/pedro -> companies/stripe
// type=works_at`, preserving subject-of-verb semantics for graph reads.
//
// MULTI-DIR HINTS: investors can be companies, funds, or people. The
// resolver tries each hint in order and takes the first match.
export interface FrontmatterFieldMapping {
/** Field name(s). Multiple entries are aliases (e.g. company + companies). */
fields: string[];
/**
* Only applies when page.type matches. Omitted = any page type. String
* (not PageType) because some page types like 'meeting' exist in the
* pages table without being in the TypeScript PageType enum.
*/
pageType?: string;
/** Edge link_type. */
type: string;
/** 'outgoing' = page→target. 'incoming' = target→page (subject of verb = from). */
direction: 'outgoing' | 'incoming';
/**
* Target directory hints for slug resolution. Single string or ordered
* array; resolver tries each. E.g. investors → ['companies', 'funds', 'people'].
*/
dirHint: string | string[];
}
/**
* Canonical field → (type, direction, dir-hint) map. Consulted by
* extractFrontmatterLinks for every YAML field on every written page.
*
* NOT normalization: kept as a flat array so duplicate field names with
* different pageType filters coexist cleanly (vs an object-literal which
* would last-write-wins on key collision).
*/
export const FRONTMATTER_LINK_MAP: FrontmatterFieldMapping[] = [
// Person pages → companies
{ fields: ['company', 'companies'], pageType: 'person', type: 'works_at', direction: 'outgoing', dirHint: 'companies' },
{ fields: ['founded'], pageType: 'person', type: 'founded', direction: 'outgoing', dirHint: 'companies' },
// Company pages (incoming relationships — subject of the verb lives elsewhere)
{ fields: ['key_people'], pageType: 'company', type: 'works_at', direction: 'incoming', dirHint: 'people' },
{ fields: ['partner'], pageType: 'company', type: 'yc_partner', direction: 'incoming', dirHint: 'people' },
{ fields: ['investors'], pageType: 'company', type: 'invested_in', direction: 'incoming',
dirHint: ['companies', 'funds', 'people'] },
// Deal pages (all incoming — deals are the object)
{ fields: ['investors'], pageType: 'deal', type: 'invested_in', direction: 'incoming',
dirHint: ['companies', 'funds', 'people'] },
{ fields: ['lead'], pageType: 'deal', type: 'led_round', direction: 'incoming',
dirHint: ['companies', 'funds', 'people'] },
// Meeting pages
{ fields: ['attendees'], pageType: 'meeting', type: 'attended', direction: 'incoming', dirHint: 'people' },
// Any page type
{ fields: ['sources'], type: 'discussed_in', direction: 'incoming', dirHint: ['source', 'media'] },
{ fields: ['source'], type: 'source', direction: 'outgoing', dirHint: '' /* already slug-shaped */ },
{ fields: ['related', 'see_also'], type: 'related_to', direction: 'outgoing', dirHint: '' },
];
// ─── Slug resolver ──────────────────────────────────────────────
export interface SlugResolver {
/**
* Resolve a display name to a canonical slug.
* Returns null when no match meets confidence threshold — callers should
* skip (not write a dead link) and the unresolved name goes into the
* extract/put_page summary so the user can see the gap.
*/
resolve(name: string, dirHint?: string | string[]): Promise<string | null>;
}
/**
* Create a resolver scoped to a single extract run or single put_page call.
*
* mode: 'batch' (migration / gbrain extract) — pg_trgm only, NO search
* fallback. On a 46K-page brain this avoids N-thousand OpenAI embedding
* calls + Anthropic Haiku expansion calls (see operations-query-hidden-haiku
* learning) and keeps the backfill deterministic + under a wall-clock budget.
*
* mode: 'live' (put_page auto-link) — can afford the (rare, bounded) search
* fallback for names that don't fuzzy-match. Still passes expand=false to
* dodge Haiku.
*
* cache: per-resolver instance. Same name → same slug lookup every call.
* Callers never need to dedupe names themselves.
*/
export function makeResolver(
engine: BrainEngine,
opts: { mode: 'batch' | 'live' } = { mode: 'live' },
): SlugResolver {
const cache = new Map<string, string | null>();
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s-]/g, '').trim().replace(/\s+/g, '-');
return {
async resolve(name: string, dirHint?: string | string[]): Promise<string | null> {
if (!name || typeof name !== 'string') return null;
const trimmed = name.trim();
if (!trimmed) return null;
const cacheKey = `${trimmed}\u0000${Array.isArray(dirHint) ? dirHint.join(',') : (dirHint || '')}`;
if (cache.has(cacheKey)) return cache.get(cacheKey)!;
const hints = Array.isArray(dirHint) ? dirHint : (dirHint ? [dirHint] : []);
// Step 1: already a slug? (dir/name shape, lowercase, hyphenated)
if (/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(trimmed)) {
const page = await engine.getPage(trimmed);
if (page) {
cache.set(cacheKey, trimmed);
return trimmed;
}
}
// Step 2: dir-hint + slugify → exact getPage
const slugified = norm(trimmed);
for (const hint of hints) {
if (!hint) continue;
const candidate = `${hint}/${slugified}`;
const page = await engine.getPage(candidate);
if (page) {
cache.set(cacheKey, candidate);
return candidate;
}
}
// Step 3: pg_trgm fuzzy title match — both modes. Tries each hint in
// order; first hint with a ≥0.55 similarity match wins. If no hints,
// try the whole pages table.
const searchHints = hints.length > 0 ? hints : [undefined];
for (const hint of searchHints) {
const match = await engine.findByTitleFuzzy(trimmed, hint, 0.55);
if (match) {
cache.set(cacheKey, match.slug);
return match.slug;
}
}
// Step 4: live-mode ONLY — fall back to hybrid search. expand: false
// is MANDATORY (see operations-query-hidden-haiku learning). Batch
// mode skips this step entirely to keep migration deterministic.
if (opts.mode === 'live') {
try {
const results = await engine.searchKeyword(trimmed, { limit: 3 });
if (results.length > 0 && results[0].score >= 0.8) {
// Filter by dir hint if provided.
const top = hints.length > 0
? results.find(r => hints.some(h => r.slug.startsWith(`${h}/`)))
: results[0];
if (top) {
cache.set(cacheKey, top.slug);
return top.slug;
}
}
} catch { /* search errors are non-fatal; fall through to null */ }
}
// Null = unresolvable. Caller records for the unresolved report.
cache.set(cacheKey, null);
return null;
},
};
}
// ─── Frontmatter extractor ──────────────────────────────────────
export interface UnresolvedFrontmatterRef {
/** The frontmatter field name. */
field: string;
/** The name that did not resolve. */
name: string;
}
export interface FrontmatterExtractResult {
candidates: LinkCandidate[];
unresolved: UnresolvedFrontmatterRef[];
}
/**
* Extract typed graph edges from YAML frontmatter. Async because the
* resolver may need to query the DB for fuzzy matches.
*
* Arrays of strings: each entry resolved independently.
* Arrays of objects: uses the `name` or `slug` property (codex tension 6.3).
* Non-string / non-object entries: silently skipped (log-only).
*/
export async function extractFrontmatterLinks(
slug: string,
pageType: PageType,
frontmatter: Record<string, unknown>,
resolver: SlugResolver,
): Promise<FrontmatterExtractResult> {
const candidates: LinkCandidate[] = [];
const unresolved: UnresolvedFrontmatterRef[] = [];
for (const mapping of FRONTMATTER_LINK_MAP) {
if (mapping.pageType && mapping.pageType !== pageType) continue;
for (const field of mapping.fields) {
const value = frontmatter[field];
if (value == null) continue;
const entries = Array.isArray(value) ? value : [value];
for (const entry of entries) {
// Extract the name to resolve. Strings pass through; objects use
// the `name` / `slug` / `title` field in that preference order.
let name: string | null = null;
let contextExtra = '';
if (typeof entry === 'string') {
name = entry;
} else if (entry && typeof entry === 'object') {
const obj = entry as Record<string, unknown>;
const n = obj.name ?? obj.slug ?? obj.title;
if (typeof n === 'string') {
name = n;
// Carry interesting object fields (role, title) into the context.
const extras: string[] = [];
if (typeof obj.role === 'string') extras.push(obj.role);
if (typeof obj.title === 'string' && obj.title !== n) extras.push(obj.title);
if (extras.length > 0) contextExtra = ` (${extras.join(', ')})`;
}
}
if (!name) continue; // skip numbers, nulls, malformed objects
const resolved = await resolver.resolve(name, mapping.dirHint);
if (!resolved) {
unresolved.push({ field, name });
continue;
}
// Outgoing: page → resolved. Incoming: resolved → page.
const fromSlug = mapping.direction === 'outgoing' ? slug : resolved;
const toSlug = mapping.direction === 'outgoing' ? resolved : slug;
// Context enrichment (review Finding 7): readable in backlink panels
// and search snippets instead of bare `frontmatter.key_people`.
const context = `frontmatter.${field}: ${name}${contextExtra}`;
candidates.push({
fromSlug,
targetSlug: toSlug,
linkType: mapping.type,
context,
linkSource: 'frontmatter',
originSlug: slug, // the page whose frontmatter created this edge
originField: field,
});
}
}
}
return { candidates, unresolved };
}
// ─── Timeline parsing ───────────────────────────────────────────
export interface TimelineCandidate {
/** ISO date YYYY-MM-DD. */
date: string;
/** First-line summary. */
summary: string;
/** Optional detail (subsequent lines until next entry/heading). */
detail: string;
}
// Match: `- **YYYY-MM-DD** | summary` or `- **YYYY-MM-DD** -- summary`
// or `- **YYYY-MM-DD** - summary` or just `**YYYY-MM-DD** | summary`.
const TIMELINE_LINE_RE = /^\s*-?\s*\*\*(\d{4}-\d{2}-\d{2})\*\*\s*[|\-–—]+\s*(.+?)\s*$/;
/**
* Parse timeline entries from content. Looks at:
* - The full content (most pages have a top-level "## Timeline" heading).
* - Free-form `- **DATE** | text` lines anywhere.
*
* Skips dates that don't represent valid calendar dates (e.g. 2026-13-45).
* Multi-line entries: a date line followed by indented or blank-then-text
* lines until the next date line or section heading.
*/
export function parseTimelineEntries(content: string): TimelineCandidate[] {
const result: TimelineCandidate[] = [];
const lines = content.split('\n');
let i = 0;
while (i < lines.length) {
const m = TIMELINE_LINE_RE.exec(lines[i]);
if (!m) {
i++;
continue;
}
const date = m[1];
const summary = m[2].trim();
if (!isValidDate(date) || summary.length === 0) {
i++;
continue;
}
// Collect optional detail lines (indented, until next date or heading).
const detailLines: string[] = [];
let j = i + 1;
while (j < lines.length) {
const next = lines[j];
if (TIMELINE_LINE_RE.test(next)) break;
if (/^#{1,6}\s/.test(next)) break;
if (next.trim().length === 0 && detailLines.length === 0) {
// skip leading blank line; if we hit a blank after detail content
// and still no new entry, treat detail as ended.
j++;
continue;
}
if (next.trim().length === 0 && detailLines.length > 0) break;
// Indented continuation lines are detail; flush-left non-list lines too.
if (/^\s+/.test(next) || (!next.startsWith('-') && !next.startsWith('*') && !next.startsWith('#'))) {
detailLines.push(next.trim());
j++;
continue;
}
break;
}
result.push({ date, summary, detail: detailLines.join(' ').trim() });
i = j;
}
return result;
}
/** Validate date string represents a real calendar date in ISO YYYY-MM-DD form. */
function isValidDate(s: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
const [y, mo, d] = s.split('-').map(Number);
if (mo < 1 || mo > 12) return false;
if (d < 1 || d > 31) return false;
// Use Date object as final check (catches 2026-02-30 etc.)
const dt = new Date(Date.UTC(y, mo - 1, d));
return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
}
// ─── Auto-link config ───────────────────────────────────────────
/**
* Read the auto_link config flag. Defaults to TRUE (auto-link is on by default).
*
* Accepts as falsy: 'false', '0', 'no', 'off' (case-insensitive, whitespace-trimmed).
* Anything else (including null, '', 'true', '1', 'yes', garbage) -> true.
*
* The config is stored as a string via engine.setConfig/getConfig.
*/
export async function isAutoLinkEnabled(engine: BrainEngine): Promise<boolean> {
const val = await engine.getConfig('auto_link');
if (val == null) return true;
const normalized = val.trim().toLowerCase();
return !['false', '0', 'no', 'off'].includes(normalized);
}
/**
* Read the auto_timeline config flag. Defaults to TRUE (on by default).
* Same truthiness rules as isAutoLinkEnabled. Controls whether put_page
* parses timeline entries from freshly-written content and inserts them
* via addTimelineEntriesBatch.
*/
export async function isAutoTimelineEnabled(engine: BrainEngine): Promise<boolean> {
const val = await engine.getConfig('auto_timeline');
if (val == null) return true;
const normalized = val.trim().toLowerCase();
return !['false', '0', 'no', 'off'].includes(normalized);
}