mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix-wave: dream.* DB merge + batch retry + extract_atoms idempotency + ze-switch env-gate + doctor check Closes PRs #1414, #1416, #1421 (rebuilt from designs by @garrytan-agents with structural improvements from /plan-eng-review + codex outside-voice). Three production reliability fixes in one wave: 1. dream.* DB-config merge (closes PR #1416 silent-config gap) - loadConfigWithEngine() sparse-merge extends with 7 dream.* keys - File > DB > defaults precedence (no GBRAIN_DREAM_* env vars) - extract-atoms switches to loadConfigWithEngine() so DB-plane keys reach it 2. Batch retry on transient connection drops (closes PR #1416 ~30%-loss bug) - withRetry() pure primitive exported from src/commands/extract.ts - 6 flush() sites snapshot-before-clear with onRetry callback - Reuses isRetryableConnError from src/core/retry-matcher.ts - retry-matcher extended with GBrainError{problem:'No database connection'} 3. extract_atoms source-hash idempotency + page-based discovery (closes #1414) - One raw SQL with NOT EXISTS subquery replaces 6 listPages + N atom checks - sourceId threaded through every putPage call (codex caught real bug) - NULL content_hash filter + dream_generated exclusion + transcript-side idempotency - cycle.ts passes union of syncPagesAffected + synthesizeWrittenSlugs 4. ze-switch pre-apply + pre-resume env-override gate (closes PR #1421) - Gate fires FIRST in apply AND resume; zero setConfig calls on refusal - ASCII warning box (no Unicode per repo D10) - --ignore-env-override escape hatch for power users - ApplyResult extended with refused variant 5. doctor embedding_env_override check (defense-in-depth for #1421) - Cross-surface parity: buildChecks() + doctorReportRemote() - Uses Check.details (not Check.issues per codex schema review) Co-Authored-By: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.10.0) Adds 61 new tests across 5 new files pinning the fix-wave contracts: - test/extract-batch-retry.test.ts (16 cases) — withRetry primitive + snapshot contract - test/extract-atoms-page-discovery.test.ts (17 cases) — discovery SQL + dual-source idempotency - test/ze-switch-env-override.test.ts (17 cases) — env-gate apply + resume + ZERO-setConfig assertion - test/doctor-embedding-env-override.test.ts (7 cases) — cross-surface parity - test/e2e/extract-atoms-discovery-sql.test.ts (4 cases) — real-Postgres parity for raw SQL Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): pin gateway to 1536-dim in 2 PGLite tests that hardcode 1536-vector inserts CI shards 1 + 4 failed persistently (not flake — confirmed via retry) after the v0.41.6.0 merge with this error: error: expected 1280 dimensions, not 1536 file: "vector.c", routine: "CheckExpectedDim" Two test files insert 1536-dim Float32Array vectors into `content_chunks.embedding` / `facts.embedding`, but v0.41.5.0 flipped `DEFAULT_EMBEDDING_DIMENSIONS` from 1536 to 1280 (ZE Matryoshka default). On a fresh CI bun process where no prior test pre-configured the gateway, `initSchema()` sizes the vector column at vector(1280) and the inserts throw. Locally this is hidden when an earlier test file in the shard happens to have called `configureGateway({embedding_dimensions: 1536})` — that state leaks forward through bun's shared process. The v0.41.6.0 LPT shard re-balancing reordered files so these two ran cold, surfacing the latent bug. Fix follows the canonical hermetic pattern from test/consolidate-valid-until.test.ts:23-34: pin the gateway to 1536d in beforeAll, reset in afterAll. Test is now isolated from shard ordering. test/search-types-filter.test.ts — shard 1 fail test/operations-find-trajectory.test.ts — shard 4 (6 fails) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: empty commit to trigger CI * chore: trigger CI again * chore: renumber v0.41.10.0 -> v0.41.10.1 Per request — version slot moved to .1 micro tier to leave .0 available for unrelated wave landing on master. --------- Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1288 lines
52 KiB
TypeScript
1288 lines
52 KiB
TypeScript
/**
|
||
* gbrain extract — Extract links and timeline entries from brain content.
|
||
*
|
||
* Two data sources:
|
||
* --source fs (default): walk markdown files on disk
|
||
* --source db : iterate pages from the engine (works for brains
|
||
* with no local checkout, e.g. live MCP servers)
|
||
*
|
||
* Subcommands:
|
||
* gbrain extract links [--source fs|db] [--dir <brain>] [--dry-run] [--json] [--type T] [--since DATE]
|
||
* gbrain extract timeline [--source fs|db] [--dir <brain>] [--dry-run] [--json] [--type T] [--since DATE]
|
||
* gbrain extract all [--source fs|db] [--dir <brain>] [--dry-run] [--json] [--type T] [--since DATE]
|
||
*
|
||
* The DB-source path uses the v0.10.3 graph extractor (typed link inference,
|
||
* within-page dedup, snapshot iteration so concurrent writes don't corrupt
|
||
* pagination). FS-source preserves the original v0.10.1 walker behavior.
|
||
*/
|
||
|
||
import { readFileSync, readdirSync, lstatSync, existsSync } from 'fs';
|
||
import { join, relative, dirname } from 'path';
|
||
import type { BrainEngine, LinkBatchInput, TimelineBatchInput } from '../core/engine.ts';
|
||
import type { PageType } from '../core/types.ts';
|
||
import { parseMarkdown } from '../core/markdown.ts';
|
||
import {
|
||
extractPageLinks, parseTimelineEntries, inferLinkType, makeResolver,
|
||
extractFrontmatterLinks,
|
||
type UnresolvedFrontmatterRef,
|
||
} from '../core/link-extraction.ts';
|
||
import { createProgress } from '../core/progress.ts';
|
||
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
||
import { pathToSlug, pruneDir, isSyncable } from '../core/sync.ts';
|
||
import { isRetryableConnError } from '../core/retry-matcher.ts';
|
||
import { buildGazetteer, findMentionedEntities } from '../core/by-mention.ts';
|
||
|
||
// Batch size for addLinksBatch / addTimelineEntriesBatch.
|
||
// Postgres bind-parameter limit is 65535. Links use 4 cols/row → 16K hard ceiling;
|
||
// timeline uses 5 cols/row → 13K hard ceiling. 100 is conservative on round-trip
|
||
// count but safe at any future schema width and keeps per-batch error blast radius
|
||
// small (a malformed row aborts at most 100, not thousands).
|
||
const BATCH_SIZE = 100;
|
||
|
||
// v0.41.2.1 — batch-flush retry primitive (closes PR #1416's ~30% batch-loss
|
||
// bug). PgBouncer transaction-mode poolers recycle backend connections between
|
||
// queries; the next query through a stale handle throws a retryable connection
|
||
// error. Single 500ms-delay retry catches the recycle without amplifying real
|
||
// outages (second failure propagates). Non-retryable errors (constraint
|
||
// violations, etc.) propagate immediately so log-and-continue semantics are
|
||
// preserved.
|
||
//
|
||
// Pure primitive: callers compose `onRetry` for stderr UI; retry classification
|
||
// uses the canonical `isRetryableConnError` from src/core/retry-matcher.ts so
|
||
// PgBouncer/auth-race/tcp-reset shapes don't drift across the codebase.
|
||
|
||
export interface WithRetryOpts {
|
||
onRetry?: (attempt: number, err: unknown) => void;
|
||
delayMs?: number; // default 500
|
||
}
|
||
|
||
export async function withRetry<T>(fn: () => Promise<T>, opts: WithRetryOpts = {}): Promise<T> {
|
||
try {
|
||
return await fn();
|
||
} catch (firstErr) {
|
||
if (!isRetryableConnError(firstErr)) throw firstErr;
|
||
opts.onRetry?.(1, firstErr);
|
||
await new Promise((r) => setTimeout(r, opts.delayMs ?? 500));
|
||
return await fn(); // single retry — second failure propagates
|
||
}
|
||
}
|
||
|
||
export function logBatchRetry(
|
||
label: string,
|
||
snapshotLen: number,
|
||
err: unknown,
|
||
jsonMode: boolean,
|
||
): void {
|
||
if (jsonMode) return;
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
console.error(
|
||
`[${label}] connection blip, retrying ${snapshotLen} rows in 500ms (${msg})`,
|
||
);
|
||
}
|
||
|
||
// --- Types ---
|
||
|
||
export interface ExtractedLink {
|
||
from_slug: string;
|
||
to_slug: string;
|
||
link_type: string;
|
||
context: string;
|
||
}
|
||
|
||
export interface ExtractedTimelineEntry {
|
||
slug: string;
|
||
date: string;
|
||
source: string;
|
||
summary: string;
|
||
detail?: string;
|
||
}
|
||
|
||
interface ExtractResult {
|
||
links_created: number;
|
||
timeline_entries_created: number;
|
||
pages_processed: number;
|
||
}
|
||
|
||
// --- Shared walker ---
|
||
|
||
export function walkMarkdownFiles(dir: string): { path: string; relPath: string }[] {
|
||
// Descent-time pruning + emit-time isSyncable filter (closes #923, #202).
|
||
// Pre-fix, this walker had only an ad-hoc dot-prefix exclusion and didn't
|
||
// call isSyncable at all — so it descended into `node_modules/`, emitted
|
||
// markdown files from there, AND ignored the canonical exclusion list
|
||
// (`.raw/`, `ops/`, README.md, etc.). Now: pruneDir skips entire vendor
|
||
// subtrees before recursion (saving IO), and isSyncable filters the emit
|
||
// set against the canonical markdown-strategy rules.
|
||
const files: { path: string; relPath: string }[] = [];
|
||
function walk(d: string) {
|
||
for (const entry of readdirSync(d)) {
|
||
const full = join(d, entry);
|
||
try {
|
||
const st = lstatSync(full);
|
||
if (st.isDirectory()) {
|
||
// v0.37.7.0 #1169: pass parentDir so pruneDir can detect git
|
||
// submodule pointers (`.git` as a file inside the candidate).
|
||
if (!pruneDir(entry, d)) continue;
|
||
walk(full);
|
||
} else if (entry.endsWith('.md') && !entry.startsWith('_')) {
|
||
const rel = relative(dir, full);
|
||
if (!isSyncable(rel, { strategy: 'markdown' })) continue;
|
||
files.push({ path: full, relPath: rel });
|
||
}
|
||
} catch { /* skip unreadable */ }
|
||
}
|
||
}
|
||
walk(dir);
|
||
return files;
|
||
}
|
||
|
||
// --- Link extraction ---
|
||
|
||
/**
|
||
* Extract markdown links to .md files (relative paths only).
|
||
*
|
||
* Handles two syntaxes:
|
||
* 1. Standard markdown: [text](relative/path.md)
|
||
* 2. Wikilinks: [[relative/path]] or [[relative/path|Display Text]]
|
||
*
|
||
* Both are resolved relative to the file that contains them. External URLs
|
||
* (containing ://) are always skipped. For wikilinks, the .md suffix is added
|
||
* if absent and section anchors (#heading) are stripped.
|
||
*/
|
||
export function extractMarkdownLinks(content: string): { name: string; relTarget: string }[] {
|
||
const results: { name: string; relTarget: string }[] = [];
|
||
|
||
const mdPattern = /\[([^\]]+)\]\(([^)]+\.md)\)/g;
|
||
let match;
|
||
while ((match = mdPattern.exec(content)) !== null) {
|
||
const target = match[2];
|
||
if (target.includes('://')) continue;
|
||
results.push({ name: match[1], relTarget: target });
|
||
}
|
||
|
||
const wikiPattern = /\[\[([^|\]]+?)(?:\|[^\]]*?)?\]\]/g;
|
||
while ((match = wikiPattern.exec(content)) !== null) {
|
||
const rawPath = match[1].trim();
|
||
if (rawPath.includes('://')) continue;
|
||
const hashIdx = rawPath.indexOf('#');
|
||
const pagePath = hashIdx >= 0 ? rawPath.slice(0, hashIdx) : rawPath;
|
||
if (!pagePath) continue;
|
||
const relTarget = pagePath.endsWith('.md') ? pagePath : pagePath + '.md';
|
||
const pipeIdx = match[0].indexOf('|');
|
||
const displayName = pipeIdx >= 0 ? match[0].slice(pipeIdx + 1, -2).trim() : rawPath;
|
||
results.push({ name: displayName, relTarget });
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Resolve a wikilink target to a canonical slug, given the directory of the
|
||
* containing page and the set of all known slugs in the brain.
|
||
*
|
||
* Wiki KBs often use inconsistent relative depths. Authors omit one or more
|
||
* leading `../` because they think in "wiki-root-relative" terms. Resolution
|
||
* order (first match wins):
|
||
* 1. Standard `join(fileDir, relTarget)` — exact relative path as written
|
||
* 2. Ancestor search — strip leading path components from fileDir, retry
|
||
*
|
||
* Returns null when no matching slug is found (dangling link).
|
||
*/
|
||
export function resolveSlug(fileDir: string, relTarget: string, allSlugs: Set<string>): string | null {
|
||
const targetNoExt = relTarget.endsWith('.md') ? relTarget.slice(0, -3) : relTarget;
|
||
|
||
const s1 = join(fileDir, targetNoExt);
|
||
if (allSlugs.has(s1)) return s1;
|
||
|
||
const parts = fileDir.split('/').filter(Boolean);
|
||
for (let strip = 1; strip <= parts.length; strip++) {
|
||
const ancestor = parts.slice(0, parts.length - strip).join('/');
|
||
const candidate = ancestor ? join(ancestor, targetNoExt) : targetNoExt;
|
||
if (allSlugs.has(candidate)) return candidate;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Directory-based link-type inference for the fs-source path.
|
||
*
|
||
* FS-source operates without a BrainEngine. We have paths, not pages. This
|
||
* helper looks at source + target directories and returns a type aligned
|
||
* with the canonical `inferLinkType` in link-extraction.ts (calibrated
|
||
* verb-based inference for db-source).
|
||
*
|
||
* v0.13: aligned type names with link-extraction.ts (was: 'mention' →
|
||
* 'mentions', 'attendee' → 'attended'). Diverged historically; the v0_13_0
|
||
* migration normalizes any legacy rows on existing brains.
|
||
*/
|
||
function inferTypeByDir(fromDir: string, toDir: string, frontmatter?: Record<string, unknown>): string {
|
||
const from = fromDir.split('/')[0];
|
||
const to = toDir.split('/')[0];
|
||
if (from === 'people' && to === 'companies') {
|
||
if (Array.isArray(frontmatter?.founded)) return 'founded';
|
||
return 'works_at';
|
||
}
|
||
if (from === 'people' && to === 'deals') return 'involved_in';
|
||
if (from === 'deals' && to === 'companies') return 'deal_for';
|
||
if (from === 'meetings' && to === 'people') return 'attended';
|
||
return 'mentions';
|
||
}
|
||
|
||
/** Parse frontmatter using the project's gray-matter-based parser */
|
||
function parseFrontmatterFromContent(content: string, relPath: string): Record<string, unknown> {
|
||
try {
|
||
const parsed = parseMarkdown(content, relPath);
|
||
return parsed.frontmatter;
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Full link extraction from a single markdown file (FS-source path).
|
||
*
|
||
* Async (v0.13): uses the canonical `extractFrontmatterLinks` via a
|
||
* synthetic resolver backed by the pre-loaded `allSlugs` Set. No DB,
|
||
* no fuzzy match — FS-source resolves only when the dir-hint + slugify
|
||
* of the frontmatter value hits an actual file path. That mirrors the
|
||
* fs path's existing "exact match against disk" behavior.
|
||
*/
|
||
export async function extractLinksFromFile(
|
||
content: string, relPath: string, allSlugs: Set<string>,
|
||
opts?: { includeFrontmatter?: boolean },
|
||
): Promise<ExtractedLink[]> {
|
||
const links: ExtractedLink[] = [];
|
||
const slug = pathToSlug(relPath);
|
||
const fileDir = dirname(relPath);
|
||
const fm = parseFrontmatterFromContent(content, relPath);
|
||
|
||
for (const { name, relTarget } of extractMarkdownLinks(content)) {
|
||
const resolved = resolveSlug(fileDir, relTarget, allSlugs);
|
||
if (resolved !== null) {
|
||
links.push({
|
||
from_slug: slug, to_slug: resolved,
|
||
link_type: inferTypeByDir(fileDir, dirname(resolved), fm),
|
||
context: `markdown link: [${name}]`,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (opts?.includeFrontmatter) {
|
||
// Synthetic sync-ish resolver: only does step 1 (already a slug) and
|
||
// step 2 (dir-hint + slugify), backed by the Set of all known slugs.
|
||
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s-]/g, '').trim().replace(/\s+/g, '-');
|
||
const fsResolver = {
|
||
async resolve(name: string, dirHint?: string | string[]): Promise<string | null> {
|
||
if (!name) return null;
|
||
const trimmed = name.trim();
|
||
if (/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(trimmed) && allSlugs.has(trimmed)) {
|
||
return trimmed;
|
||
}
|
||
const hints = Array.isArray(dirHint) ? dirHint : (dirHint ? [dirHint] : []);
|
||
for (const hint of hints) {
|
||
if (!hint) continue;
|
||
const candidate = `${hint}/${slugify(trimmed)}`;
|
||
if (allSlugs.has(candidate)) return candidate;
|
||
}
|
||
return null;
|
||
},
|
||
};
|
||
// Guess the page type from its directory for field-map filtering.
|
||
const topDir = slug.split('/')[0];
|
||
const pageType = topDir === 'people' ? 'person'
|
||
: topDir === 'companies' ? 'company'
|
||
: topDir === 'deals' || topDir === 'deal' ? 'deal'
|
||
: topDir === 'meetings' ? 'meeting'
|
||
: 'concept';
|
||
const fm = parseFrontmatterFromContent(content, relPath);
|
||
const fmLinks = await extractFrontmatterLinks(slug, pageType as never, fm, fsResolver);
|
||
for (const c of fmLinks.candidates) {
|
||
links.push({
|
||
from_slug: c.fromSlug ?? slug,
|
||
to_slug: c.targetSlug,
|
||
link_type: c.linkType,
|
||
context: c.context,
|
||
});
|
||
}
|
||
}
|
||
|
||
return links;
|
||
}
|
||
|
||
// --- Timeline extraction ---
|
||
|
||
/** Extract timeline entries from markdown content */
|
||
export function extractTimelineFromContent(content: string, slug: string): ExtractedTimelineEntry[] {
|
||
const entries: ExtractedTimelineEntry[] = [];
|
||
|
||
// Format 1: Bullet — - **YYYY-MM-DD** | Source — Summary
|
||
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)\s*[—–-]\s*(.+)$/gm;
|
||
let match;
|
||
while ((match = bulletPattern.exec(content)) !== null) {
|
||
entries.push({ slug, date: match[1], source: match[2].trim(), summary: match[3].trim() });
|
||
}
|
||
|
||
// Format 2: Header — ### YYYY-MM-DD — Title
|
||
const headerPattern = /^###\s+(\d{4}-\d{2}-\d{2})\s*[—–-]\s*(.+)$/gm;
|
||
while ((match = headerPattern.exec(content)) !== null) {
|
||
const afterIdx = match.index + match[0].length;
|
||
const nextHeader = content.indexOf('\n### ', afterIdx);
|
||
const nextSection = content.indexOf('\n## ', afterIdx);
|
||
const endIdx = Math.min(
|
||
nextHeader >= 0 ? nextHeader : content.length,
|
||
nextSection >= 0 ? nextSection : content.length,
|
||
);
|
||
const detail = content.slice(afterIdx, endIdx).trim();
|
||
entries.push({ slug, date: match[1], source: 'markdown', summary: match[2].trim(), detail: detail || undefined });
|
||
}
|
||
|
||
return entries;
|
||
}
|
||
|
||
// --- Main command ---
|
||
|
||
export interface ExtractOpts {
|
||
/** What to extract: 'links' (wiki-style refs), 'timeline' (date entries), or 'all'. */
|
||
mode: 'links' | 'timeline' | 'all';
|
||
/** Brain directory to walk. */
|
||
dir: string;
|
||
/** Report what would change without writing. */
|
||
dryRun?: boolean;
|
||
/** Emit JSON (progress to stderr, result to stdout) instead of human text. */
|
||
jsonMode?: boolean;
|
||
/**
|
||
* Incremental mode: only extract from these specific slugs.
|
||
* When provided, skips the full directory walk and reads only the
|
||
* files corresponding to these slugs. Massive perf win on large brains.
|
||
* Pass undefined or omit for a full walk (CLI / first-run path).
|
||
*/
|
||
slugs?: string[];
|
||
}
|
||
|
||
/**
|
||
* Library-level extract. Throws on error; prints nothing unless jsonMode or
|
||
* explicit output is warranted. Safe to call from Minions handlers because it
|
||
* never calls process.exit — a bad mode or missing dir throws through, which
|
||
* the handler wrapper turns into a failed job (NOT a killed worker).
|
||
*/
|
||
export async function runExtractCore(engine: BrainEngine, opts: ExtractOpts): Promise<ExtractResult> {
|
||
if (!['links', 'timeline', 'all'].includes(opts.mode)) {
|
||
throw new Error(`Invalid extract mode "${opts.mode}". Allowed: links, timeline, all.`);
|
||
}
|
||
if (!existsSync(opts.dir)) {
|
||
throw new Error(`Directory not found: ${opts.dir}`);
|
||
}
|
||
|
||
const dryRun = !!opts.dryRun;
|
||
const jsonMode = !!opts.jsonMode;
|
||
const result: ExtractResult = { links_created: 0, timeline_entries_created: 0, pages_processed: 0 };
|
||
|
||
// Incremental path: if specific slugs provided, only extract from those files.
|
||
// This is the cycle path — sync tells us what changed, we only re-extract those.
|
||
if (opts.slugs !== undefined) {
|
||
if (opts.slugs.length === 0) {
|
||
// Nothing changed — skip entirely.
|
||
return result;
|
||
}
|
||
const r = await extractForSlugs(engine, opts.dir, opts.slugs, opts.mode, dryRun, jsonMode);
|
||
result.links_created = r.links_created;
|
||
result.timeline_entries_created = r.timeline_created;
|
||
result.pages_processed = r.pages;
|
||
return result;
|
||
}
|
||
|
||
// Full walk path: CLI `gbrain extract` or first-run.
|
||
if (opts.mode === 'links' || opts.mode === 'all') {
|
||
const r = await extractLinksFromDir(engine, opts.dir, dryRun, jsonMode);
|
||
result.links_created = r.created;
|
||
result.pages_processed = r.pages;
|
||
}
|
||
if (opts.mode === 'timeline' || opts.mode === 'all') {
|
||
const r = await extractTimelineFromDir(engine, opts.dir, dryRun, jsonMode);
|
||
result.timeline_entries_created = r.created;
|
||
result.pages_processed = Math.max(result.pages_processed, r.pages);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
export async function runExtract(engine: BrainEngine, args: string[]) {
|
||
const subcommand = args[0];
|
||
const dirIdx = args.indexOf('--dir');
|
||
const explicitDir = dirIdx >= 0 && dirIdx + 1 < args.length;
|
||
// When --dir is not passed, resolve from the configured brain source
|
||
// BEFORE falling back to '.' (the prior default). The bare `.` default was
|
||
// a footgun: a user who runs `gbrain extract links` from anywhere outside
|
||
// their brain dir (e.g., a project checkout with a node_modules tree) had
|
||
// the recursive walker grab tens of thousands of unrelated .md files,
|
||
// attempt to extract links between them, then write 0 rows because the
|
||
// synthetic from_slugs don't match any pages row. The output ("created 0
|
||
// links from 28989 pages") looks like a no-op, but it walked 28K junk files
|
||
// first. Resolving from sources(local_path) makes the no-arg invocation
|
||
// match what `gbrain sync` already does, and keeps cwd-cwd usage available
|
||
// via explicit `--dir .`.
|
||
let brainDir = explicitDir ? args[dirIdx + 1] : '.';
|
||
const sourceIdx = args.indexOf('--source');
|
||
const source = (sourceIdx >= 0 && sourceIdx + 1 < args.length) ? args[sourceIdx + 1] : 'fs';
|
||
// v0.37.7.0 #1204: --source-id <id> scopes extraction to one brain
|
||
// source. Separate flag from --source (fs|db) which is the
|
||
// data-source axis. When unset, walks all sources together as today.
|
||
const sourceIdIdx = args.indexOf('--source-id');
|
||
const sourceIdFilter = (sourceIdIdx >= 0 && sourceIdIdx + 1 < args.length) ? args[sourceIdIdx + 1] : undefined;
|
||
const typeIdx = args.indexOf('--type');
|
||
const typeFilter = (typeIdx >= 0 && typeIdx + 1 < args.length) ? (args[typeIdx + 1] as string) : undefined;
|
||
const sinceIdx = args.indexOf('--since');
|
||
const since = (sinceIdx >= 0 && sinceIdx + 1 < args.length) ? args[sinceIdx + 1] : undefined;
|
||
const dryRun = args.includes('--dry-run');
|
||
const jsonMode = args.includes('--json');
|
||
// --include-frontmatter: v0.13 flag. Default OFF for back-compat. The
|
||
// v0_13_0 migration orchestrator runs this once under the hood; users
|
||
// opt in for subsequent runs.
|
||
const includeFrontmatter = args.includes('--include-frontmatter');
|
||
// v0.42.0.0 Part B: --by-mention auto-link body-text entity mentions
|
||
// via the gazetteer pass. Mode dispatch — when set, run ONLY the
|
||
// mention pass (skip default link extract). DB-source only per D7;
|
||
// FS-source is rejected with a paste-ready fix-hint below.
|
||
const byMention = args.includes('--by-mention');
|
||
|
||
// Validate --since upfront. Without this, an invalid date like
|
||
// `--since yesterday` produces NaN which silently passes the filter check
|
||
// (Number.isFinite(NaN) === false), so the user thinks they ran an
|
||
// incremental extract but actually reprocessed the whole brain.
|
||
if (since !== undefined) {
|
||
const sinceMs = new Date(since).getTime();
|
||
if (!Number.isFinite(sinceMs)) {
|
||
console.error(`Invalid --since date: "${since}". Must be a parseable date (e.g., "2026-01-15" or full ISO timestamp).`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
if (!subcommand || !['links', 'timeline', 'all'].includes(subcommand)) {
|
||
console.error('Usage: gbrain extract <links|timeline|all> [--source fs|db] [--source-id <id>] [--dir <brain-dir>] [--dry-run] [--json] [--type T] [--since DATE]');
|
||
process.exit(1);
|
||
}
|
||
|
||
if (source !== 'fs' && source !== 'db') {
|
||
console.error(`Invalid --source: ${source}. Must be 'fs' or 'db'.`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// v0.42.0.0 D7: --by-mention requires DB-source. Gazetteer construction
|
||
// needs the engine; mixing FS-walk with DB-gazetteer is incoherent
|
||
// (you'd scan files on disk for mentions of entities that may not exist
|
||
// in any synced page). Fail loud with a paste-ready fix-hint.
|
||
if (byMention && source === 'fs') {
|
||
console.error(
|
||
`--by-mention requires --source db (currently --source fs). The mention scanner ` +
|
||
`needs the engine to build the entity gazetteer. Re-run as:\n\n` +
|
||
` gbrain extract ${subcommand} --by-mention --source db` +
|
||
(sourceIdFilter ? ` --source-id ${sourceIdFilter}` : '') +
|
||
(since ? ` --since ${since}` : '') +
|
||
(dryRun ? ' --dry-run' : '') + '\n',
|
||
);
|
||
process.exit(2);
|
||
}
|
||
if (byMention && subcommand === 'timeline') {
|
||
console.error(
|
||
`--by-mention is a links-pass only; it does not apply to timeline extraction. ` +
|
||
`Re-run as 'gbrain extract links --by-mention' or 'gbrain extract all --by-mention'.`,
|
||
);
|
||
process.exit(2);
|
||
}
|
||
|
||
// FS source needs a brain dir. When --dir wasn't passed, resolve from
|
||
// sources(local_path) — same path `gbrain sync` uses — instead of
|
||
// silently walking cwd. See the brainDir comment above for the footgun.
|
||
if (source === 'fs' && !explicitDir) {
|
||
const { getDefaultSourcePath } = await import('../core/source-resolver.ts');
|
||
const configured = await getDefaultSourcePath(engine);
|
||
if (configured) {
|
||
brainDir = configured;
|
||
} else {
|
||
console.error(
|
||
`No brain directory configured. Pass --dir <path> explicitly, or use --source db ` +
|
||
`to extract from already-synced pages. To register a brain dir as the default, ` +
|
||
`run: gbrain sources add default --path <brain-dir>`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// DB source ignores --dir.
|
||
if (source === 'fs' && !existsSync(brainDir)) {
|
||
console.error(`Directory not found: ${brainDir}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
let result: ExtractResult;
|
||
try {
|
||
if (source === 'db') {
|
||
// DB source: walk pages from the engine. The unified runExtractCore
|
||
// is fs-only; we keep the dual codepath here so Minions handlers
|
||
// can opt in via mode + source.
|
||
result = { links_created: 0, timeline_entries_created: 0, pages_processed: 0 };
|
||
// v0.42.0.0: --by-mention is a mode dispatch. When set, run ONLY
|
||
// the mention pass and skip the default link/frontmatter extract.
|
||
// The two passes write different link_source values ('mentions' vs
|
||
// 'markdown'/'frontmatter') so they don't conflict, but mixing them
|
||
// in a single CLI invocation is surprising — keep the surfaces
|
||
// separate.
|
||
if (byMention) {
|
||
const r = await extractMentionsFromDb(engine, dryRun, jsonMode, typeFilter, since, { sourceIdFilter });
|
||
result.links_created = r.created;
|
||
result.pages_processed = r.pages;
|
||
} else {
|
||
if (subcommand === 'links' || subcommand === 'all') {
|
||
const r = await extractLinksFromDB(engine, dryRun, jsonMode, typeFilter, since, { includeFrontmatter, sourceIdFilter });
|
||
result.links_created = r.created;
|
||
result.pages_processed = r.pages;
|
||
}
|
||
if (subcommand === 'timeline' || subcommand === 'all') {
|
||
const r = await extractTimelineFromDB(engine, dryRun, jsonMode, typeFilter, since, { sourceIdFilter });
|
||
result.timeline_entries_created = r.created;
|
||
result.pages_processed = Math.max(result.pages_processed, r.pages);
|
||
}
|
||
}
|
||
} else {
|
||
result = await runExtractCore(engine, {
|
||
mode: subcommand as 'links' | 'timeline' | 'all',
|
||
dir: brainDir,
|
||
dryRun,
|
||
jsonMode,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error(e instanceof Error ? e.message : String(e));
|
||
process.exit(1);
|
||
}
|
||
|
||
if (jsonMode) {
|
||
console.log(JSON.stringify(result, null, 2));
|
||
} else if (!dryRun) {
|
||
console.log(`\nDone: ${result.links_created} links, ${result.timeline_entries_created} timeline entries from ${result.pages_processed} pages`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Incremental extract: process only the specified slugs.
|
||
*
|
||
* Instead of walking 54K+ files, reads only the files that sync says changed.
|
||
* Still needs the full slug set for link resolution (resolveSlug needs to know
|
||
* all valid targets), but that's a single readdir, not 54K readFileSync calls.
|
||
*
|
||
* Combines links + timeline extraction in a single pass over each file —
|
||
* the full-walk path reads every file TWICE (once for links, once for timeline).
|
||
*/
|
||
async function extractForSlugs(
|
||
engine: BrainEngine,
|
||
brainDir: string,
|
||
slugs: string[],
|
||
mode: 'links' | 'timeline' | 'all',
|
||
dryRun: boolean,
|
||
jsonMode: boolean,
|
||
): Promise<{ links_created: number; timeline_created: number; pages: number }> {
|
||
// Build the full slug set for link resolution (fast: just readdir, no file reads)
|
||
const allFiles = walkMarkdownFiles(brainDir);
|
||
const allSlugs = new Set(allFiles.map(f => pathToSlug(f.relPath)));
|
||
|
||
const doLinks = mode === 'links' || mode === 'all';
|
||
const doTimeline = mode === 'timeline' || mode === 'all';
|
||
|
||
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||
progress.start('extract.incremental', slugs.length);
|
||
|
||
let linksCreated = 0;
|
||
let timelineCreated = 0;
|
||
let pagesProcessed = 0;
|
||
|
||
const linkBatch: LinkBatchInput[] = [];
|
||
const timelineBatch: TimelineBatchInput[] = [];
|
||
|
||
async function flushLinks() {
|
||
if (linkBatch.length === 0) return;
|
||
// Snapshot BEFORE clear so a producer pushing during the 500ms retry
|
||
// delay can't lose items on the second attempt. Error messages read
|
||
// snapshot.length (batch.length is 0 by the time the catch fires).
|
||
const snapshot = linkBatch.slice();
|
||
linkBatch.length = 0;
|
||
try {
|
||
linksCreated += await withRetry(
|
||
() => engine.addLinksBatch(snapshot), // gbrain-allow-direct-insert: gbrain extract command — canonical link reconciliation from markdown body
|
||
{ onRetry: (_a, err) => logBatchRetry('extract.links_inc', snapshot.length, err, jsonMode) },
|
||
);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
if (!jsonMode) console.error(` link batch error (${snapshot.length} rows lost): ${msg}`);
|
||
}
|
||
}
|
||
|
||
async function flushTimeline() {
|
||
if (timelineBatch.length === 0) return;
|
||
const snapshot = timelineBatch.slice();
|
||
timelineBatch.length = 0;
|
||
try {
|
||
timelineCreated += await withRetry(
|
||
() => engine.addTimelineEntriesBatch(snapshot),
|
||
{ onRetry: (_a, err) => logBatchRetry('extract.timeline_inc', snapshot.length, err, jsonMode) },
|
||
);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
if (!jsonMode) console.error(` timeline batch error (${snapshot.length} rows lost): ${msg}`);
|
||
}
|
||
}
|
||
|
||
for (const slug of slugs) {
|
||
const relPath = slug + '.md';
|
||
const fullPath = join(brainDir, relPath);
|
||
|
||
try {
|
||
if (!existsSync(fullPath)) continue; // deleted file — sync already handled removal
|
||
const content = readFileSync(fullPath, 'utf-8');
|
||
|
||
// Links
|
||
if (doLinks) {
|
||
const links = await extractLinksFromFile(content, relPath, allSlugs);
|
||
for (const link of links) {
|
||
if (dryRun) {
|
||
if (!jsonMode) console.log(` ${link.from_slug} → ${link.to_slug} (${link.link_type})`);
|
||
linksCreated++;
|
||
} else {
|
||
linkBatch.push(link);
|
||
if (linkBatch.length >= BATCH_SIZE) await flushLinks();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Timeline
|
||
if (doTimeline) {
|
||
const entries = extractTimelineFromContent(content, slug);
|
||
for (const entry of entries) {
|
||
if (dryRun) {
|
||
if (!jsonMode) console.log(` ${entry.slug}: ${entry.date} — ${entry.summary}`);
|
||
timelineCreated++;
|
||
} else {
|
||
timelineBatch.push({ slug: entry.slug, date: entry.date, source: entry.source, summary: entry.summary, detail: entry.detail });
|
||
if (timelineBatch.length >= BATCH_SIZE) await flushTimeline();
|
||
}
|
||
}
|
||
}
|
||
|
||
pagesProcessed++;
|
||
} catch { /* skip unreadable */ }
|
||
progress.tick(1);
|
||
}
|
||
|
||
await flushLinks();
|
||
await flushTimeline();
|
||
progress.finish();
|
||
|
||
if (!jsonMode) {
|
||
const label = dryRun ? '(dry run) would create' : 'created';
|
||
console.log(`Incremental extract: ${label} ${linksCreated} link(s), ${timelineCreated} timeline entries from ${pagesProcessed}/${slugs.length} page(s)`);
|
||
}
|
||
|
||
return { links_created: linksCreated, timeline_created: timelineCreated, pages: pagesProcessed };
|
||
}
|
||
|
||
async function extractLinksFromDir(
|
||
engine: BrainEngine, brainDir: string, dryRun: boolean, jsonMode: boolean,
|
||
): Promise<{ created: number; pages: number }> {
|
||
const files = walkMarkdownFiles(brainDir);
|
||
const allSlugs = new Set(files.map(f => pathToSlug(f.relPath)));
|
||
|
||
// Progress stream on stderr (separate from the action-events --json writes
|
||
// to stdout, which tests grep for). Rate-gated; respects global --quiet /
|
||
// --progress-json flags.
|
||
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||
progress.start('extract.links_fs', files.length);
|
||
|
||
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
|
||
// Without this, the same link extracted from N files would print N times in --dry-run.
|
||
const dryRunSeen = dryRun ? new Set<string>() : null;
|
||
|
||
let created = 0;
|
||
const batch: LinkBatchInput[] = [];
|
||
async function flush() {
|
||
if (batch.length === 0) return;
|
||
const snapshot = batch.slice();
|
||
batch.length = 0;
|
||
try {
|
||
created += await withRetry(
|
||
() => engine.addLinksBatch(snapshot), // gbrain-allow-direct-insert: gbrain extract command — canonical link reconciliation from markdown body
|
||
{ onRetry: (_a, err) => logBatchRetry('extract.links_fs', snapshot.length, err, jsonMode) },
|
||
);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
if (jsonMode) {
|
||
process.stderr.write(JSON.stringify({ event: 'batch_error', size: snapshot.length, error: msg }) + '\n');
|
||
} else {
|
||
console.error(` batch error (${snapshot.length} link rows lost): ${msg}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < files.length; i++) {
|
||
try {
|
||
const content = readFileSync(files[i].path, 'utf-8');
|
||
const links = await extractLinksFromFile(content, files[i].relPath, allSlugs);
|
||
for (const link of links) {
|
||
if (dryRunSeen) {
|
||
const key = `${link.from_slug}::${link.to_slug}::${link.link_type}`;
|
||
if (dryRunSeen.has(key)) continue;
|
||
dryRunSeen.add(key);
|
||
if (!jsonMode) console.log(` ${link.from_slug} → ${link.to_slug} (${link.link_type})`);
|
||
created++;
|
||
} else {
|
||
batch.push(link);
|
||
if (batch.length >= BATCH_SIZE) await flush();
|
||
}
|
||
}
|
||
} catch { /* skip unreadable */ }
|
||
progress.tick(1);
|
||
}
|
||
await flush();
|
||
progress.finish();
|
||
|
||
if (!jsonMode) {
|
||
const label = dryRun ? '(dry run) would create' : 'created';
|
||
console.log(`Links: ${label} ${created} from ${files.length} pages`);
|
||
}
|
||
return { created, pages: files.length };
|
||
}
|
||
|
||
async function extractTimelineFromDir(
|
||
engine: BrainEngine, brainDir: string, dryRun: boolean, jsonMode: boolean,
|
||
): Promise<{ created: number; pages: number }> {
|
||
const files = walkMarkdownFiles(brainDir);
|
||
|
||
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||
progress.start('extract.timeline_fs', files.length);
|
||
|
||
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
|
||
const dryRunSeen = dryRun ? new Set<string>() : null;
|
||
|
||
let created = 0;
|
||
const batch: TimelineBatchInput[] = [];
|
||
async function flush() {
|
||
if (batch.length === 0) return;
|
||
const snapshot = batch.slice();
|
||
batch.length = 0;
|
||
try {
|
||
created += await withRetry(
|
||
() => engine.addTimelineEntriesBatch(snapshot),
|
||
{ onRetry: (_a, err) => logBatchRetry('extract.timeline_fs', snapshot.length, err, jsonMode) },
|
||
);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
if (jsonMode) {
|
||
process.stderr.write(JSON.stringify({ event: 'batch_error', size: snapshot.length, error: msg }) + '\n');
|
||
} else {
|
||
console.error(` batch error (${snapshot.length} timeline rows lost): ${msg}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < files.length; i++) {
|
||
try {
|
||
const content = readFileSync(files[i].path, 'utf-8');
|
||
const slug = pathToSlug(files[i].relPath);
|
||
for (const entry of extractTimelineFromContent(content, slug)) {
|
||
if (dryRunSeen) {
|
||
const key = `${entry.slug}::${entry.date}::${entry.summary}`;
|
||
if (dryRunSeen.has(key)) continue;
|
||
dryRunSeen.add(key);
|
||
if (!jsonMode) console.log(` ${entry.slug}: ${entry.date} — ${entry.summary}`);
|
||
created++;
|
||
} else {
|
||
batch.push({ slug: entry.slug, date: entry.date, source: entry.source, summary: entry.summary, detail: entry.detail });
|
||
if (batch.length >= BATCH_SIZE) await flush();
|
||
}
|
||
}
|
||
} catch { /* skip unreadable */ }
|
||
progress.tick(1);
|
||
}
|
||
await flush();
|
||
progress.finish();
|
||
|
||
if (!jsonMode) {
|
||
const label = dryRun ? '(dry run) would create' : 'created';
|
||
console.log(`Timeline: ${label} ${created} entries from ${files.length} pages`);
|
||
}
|
||
return { created, pages: files.length };
|
||
}
|
||
|
||
// --- Sync integration hooks ---
|
||
|
||
export async function extractLinksForSlugs(
|
||
engine: BrainEngine,
|
||
repoPath: string,
|
||
slugs: string[],
|
||
opts?: { sourceId?: string },
|
||
): Promise<number> {
|
||
const allFiles = walkMarkdownFiles(repoPath);
|
||
const allSlugs = new Set(allFiles.map(f => pathToSlug(f.relPath)));
|
||
// v0.18.0+ multi-source: post-sync extract reconciles same-source edges.
|
||
// Markdown→markdown links within one repo always live in the caller's
|
||
// sourceId. Cross-source extraction (rare) would need a per-repo source
|
||
// manifest; not in this PR's scope.
|
||
const linkOpts = opts?.sourceId
|
||
? { fromSourceId: opts.sourceId, toSourceId: opts.sourceId, originSourceId: opts.sourceId }
|
||
: undefined;
|
||
let created = 0;
|
||
for (const slug of slugs) {
|
||
const filePath = join(repoPath, slug + '.md');
|
||
if (!existsSync(filePath)) continue;
|
||
try {
|
||
const content = readFileSync(filePath, 'utf-8');
|
||
for (const link of await extractLinksFromFile(content, slug + '.md', allSlugs)) {
|
||
try { await engine.addLink(link.from_slug, link.to_slug, link.context, link.link_type, undefined, undefined, undefined, linkOpts); created++; } catch { /* skip */ } // gbrain-allow-direct-insert: gbrain extract single-row fallback when batch path declines a row
|
||
}
|
||
} catch { /* skip */ }
|
||
}
|
||
return created;
|
||
}
|
||
|
||
export async function extractTimelineForSlugs(
|
||
engine: BrainEngine,
|
||
repoPath: string,
|
||
slugs: string[],
|
||
opts?: { sourceId?: string },
|
||
): Promise<number> {
|
||
// v0.18.0+ multi-source: source-qualify so timeline rows don't fan out
|
||
// across every source containing the slug (the addTimelineEntry's
|
||
// INSERT...SELECT-from-pages fan-out was Data R1's HIGH 2).
|
||
const entryOpts = opts?.sourceId ? { sourceId: opts.sourceId } : undefined;
|
||
let created = 0;
|
||
for (const slug of slugs) {
|
||
const filePath = join(repoPath, slug + '.md');
|
||
if (!existsSync(filePath)) continue;
|
||
try {
|
||
const content = readFileSync(filePath, 'utf-8');
|
||
for (const entry of extractTimelineFromContent(content, slug)) {
|
||
try { await engine.addTimelineEntry(entry.slug, { date: entry.date, source: entry.source, summary: entry.summary, detail: entry.detail }, entryOpts); created++; } catch { /* skip */ } // gbrain-allow-direct-insert: gbrain extract single-row fallback for timeline entries
|
||
}
|
||
} catch { /* skip */ }
|
||
}
|
||
return created;
|
||
}
|
||
|
||
// ─── DB-source extractors (v0.10.3 graph layer) ────────────────────────────
|
||
//
|
||
// Iterate pages from engine.getAllSlugs() and engine.getPage() instead of
|
||
// walking files on disk. Mutation-immune (snapshot) and works for brains with
|
||
// no local checkout (e.g. live MCP servers). Uses the typed link inference and
|
||
// timeline parser from src/core/link-extraction.ts.
|
||
|
||
async function extractLinksFromDB(
|
||
engine: BrainEngine,
|
||
dryRun: boolean,
|
||
jsonMode: boolean,
|
||
typeFilter: PageType | undefined,
|
||
since: string | undefined,
|
||
opts?: { includeFrontmatter?: boolean; sourceIdFilter?: string },
|
||
): Promise<{ created: number; pages: number; unresolved: UnresolvedFrontmatterRef[] }> {
|
||
const includeFrontmatter = opts?.includeFrontmatter ?? false;
|
||
const sourceIdFilter = opts?.sourceIdFilter;
|
||
// Batch resolver: pg_trgm + exact only, NO search fallback. Dodges the
|
||
// N-thousand API call trap on 46K-page brains. Resolver has a per-run
|
||
// cache so duplicate names (same person appearing on many pages) resolve
|
||
// once, not once per mention.
|
||
const resolver = makeResolver(engine, { mode: 'batch' });
|
||
const unresolved: UnresolvedFrontmatterRef[] = [];
|
||
const nullResolver = {
|
||
resolve: async () => null as string | null,
|
||
};
|
||
// v0.32.8: listAllPageRefs enumerates (slug, source_id) so we can thread
|
||
// sourceId to getPage AND build a cross-source resolution map for link
|
||
// disambiguation. Pre-fix used getAllSlugs() which collapsed
|
||
// same-slug-different-source pages into one entry.
|
||
//
|
||
// v0.37.7.0 #1204: when --source-id <id> is passed, filter the walk
|
||
// to just that source so federated brain users can scope extraction
|
||
// explicitly. The resolution map still sees all sources so
|
||
// cross-source wikilinks (qualified like `[[other-src:slug]]`) can
|
||
// resolve — the filter is on WHICH pages we extract FROM, not what
|
||
// we can resolve TO.
|
||
const allRefs = sourceIdFilter
|
||
? (await engine.listAllPageRefs()).filter(r => r.source_id === sourceIdFilter)
|
||
: await engine.listAllPageRefs();
|
||
const fullRefsForResolver = sourceIdFilter
|
||
? await engine.listAllPageRefs()
|
||
: allRefs;
|
||
// For backward-compat checks (`allSlugs.has(...)` calls below), we still
|
||
// need a flat slug set. ALSO a per-slug → [sources] map for F10 resolution.
|
||
//
|
||
// v0.37.7.0: the resolver maps are built from `fullRefsForResolver`
|
||
// (not `allRefs`) so cross-source wikilinks resolve correctly even
|
||
// when --source-id scopes the extract walk. Without this, a scoped
|
||
// extract would fail to resolve qualified links to pages outside the
|
||
// scoped source.
|
||
const allSlugs = new Set<string>();
|
||
const slugToSources = new Map<string, string[]>();
|
||
for (const ref of fullRefsForResolver) {
|
||
allSlugs.add(ref.slug);
|
||
const list = slugToSources.get(ref.slug) ?? [];
|
||
list.push(ref.source_id);
|
||
slugToSources.set(ref.slug, list);
|
||
}
|
||
let processed = 0, created = 0;
|
||
|
||
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||
progress.start('extract.links_db', allRefs.length);
|
||
|
||
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
|
||
const dryRunSeen = dryRun ? new Set<string>() : null;
|
||
|
||
const batch: LinkBatchInput[] = [];
|
||
async function flush() {
|
||
if (batch.length === 0) return;
|
||
const snapshot = batch.slice();
|
||
batch.length = 0;
|
||
try {
|
||
created += await withRetry(
|
||
() => engine.addLinksBatch(snapshot), // gbrain-allow-direct-insert: gbrain extract command — canonical link reconciliation from markdown body
|
||
{ onRetry: (_a, err) => logBatchRetry('extract.links_db', snapshot.length, err, jsonMode) },
|
||
);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
if (jsonMode) {
|
||
process.stderr.write(JSON.stringify({ event: 'batch_error', size: snapshot.length, error: msg }) + '\n');
|
||
} else {
|
||
console.error(` batch error (${snapshot.length} link rows lost): ${msg}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const { slug, source_id } of allRefs) {
|
||
const page = await engine.getPage(slug, { sourceId: source_id });
|
||
if (!page) continue;
|
||
if (typeFilter && page.type !== typeFilter) continue;
|
||
if (since) {
|
||
const updatedMs = new Date(page.updated_at).getTime();
|
||
const sinceMs = new Date(since).getTime();
|
||
if (Number.isFinite(sinceMs) && updatedMs <= sinceMs) continue;
|
||
}
|
||
|
||
const fullContent = page.compiled_truth + '\n' + page.timeline;
|
||
// --include-frontmatter default OFF in v0.13 (codex tension 5, back-compat).
|
||
// Migration orchestrator explicitly enables it for the one-time backfill;
|
||
// user-invoked `gbrain extract links` stays outgoing-only.
|
||
const activeResolver = includeFrontmatter ? resolver : nullResolver;
|
||
const extracted = await extractPageLinks(
|
||
slug, fullContent, page.frontmatter, page.type, activeResolver,
|
||
);
|
||
unresolved.push(...extracted.unresolved);
|
||
|
||
for (const c of extracted.candidates) {
|
||
// Validate BOTH endpoints exist. Incoming frontmatter edges have
|
||
// fromSlug !== the page being processed; we need that page to exist
|
||
// too or the JOIN drops the row anyway.
|
||
const fromSlug = c.fromSlug ?? slug;
|
||
if (!allSlugs.has(c.targetSlug)) continue;
|
||
if (!allSlugs.has(fromSlug)) continue;
|
||
|
||
// v0.32.8 F10: cross-source link resolution.
|
||
// from_source_id = origin page's source_id (this loop's source_id, or
|
||
// the candidate's fromSlug source if it lives in a different source).
|
||
// to_source_id = priority: origin's source > 'default' > skip (don't
|
||
// silently push a wrong-source edge).
|
||
const fromSources = slugToSources.get(fromSlug) ?? [];
|
||
const fromSourceId = fromSources.includes(source_id) ? source_id
|
||
: (fromSources.includes('default') ? 'default' : fromSources[0]);
|
||
const targetSources = slugToSources.get(c.targetSlug) ?? [];
|
||
let toSourceId: string;
|
||
if (targetSources.includes(fromSourceId)) {
|
||
toSourceId = fromSourceId;
|
||
} else if (targetSources.includes('default')) {
|
||
toSourceId = 'default';
|
||
} else {
|
||
// Target exists ONLY in non-origin/non-default sources. Skip — don't
|
||
// silently push a wrong-source edge. Tracking this as an unresolved
|
||
// ref would require expanding UnresolvedFrontmatterRef; for v0.32.8
|
||
// a quiet skip is the conservative choice (matches existing
|
||
// "target missing" semantics where allSlugs.has() returns false).
|
||
continue;
|
||
}
|
||
|
||
if (dryRunSeen) {
|
||
const key = `${fromSourceId}::${fromSlug}::${toSourceId}::${c.targetSlug}::${c.linkType}::${c.linkSource ?? 'markdown'}`;
|
||
if (dryRunSeen.has(key)) continue;
|
||
dryRunSeen.add(key);
|
||
if (jsonMode) {
|
||
process.stdout.write(JSON.stringify({
|
||
action: 'add_link', from: fromSlug, from_source_id: fromSourceId,
|
||
to: c.targetSlug, to_source_id: toSourceId,
|
||
type: c.linkType, context: c.context, link_source: c.linkSource,
|
||
}) + '\n');
|
||
} else {
|
||
console.log(` ${fromSlug} → ${c.targetSlug} (${c.linkType})${c.linkSource === 'frontmatter' ? ' [fm]' : ''}`);
|
||
}
|
||
created++;
|
||
} else {
|
||
batch.push({
|
||
from_slug: fromSlug,
|
||
to_slug: c.targetSlug,
|
||
link_type: c.linkType,
|
||
context: c.context,
|
||
link_source: c.linkSource,
|
||
origin_slug: c.originSlug,
|
||
origin_field: c.originField,
|
||
// v0.32.8 F4: thread source ids so the batch JOIN doesn't fan out
|
||
// across sources. Default source_id='default' for back-compat with
|
||
// pre-v0.32.8 callers (the engine still accepts undefined).
|
||
from_source_id: fromSourceId,
|
||
to_source_id: toSourceId,
|
||
origin_source_id: source_id,
|
||
});
|
||
if (batch.length >= BATCH_SIZE) await flush();
|
||
}
|
||
}
|
||
processed++;
|
||
progress.tick(1);
|
||
}
|
||
await flush();
|
||
progress.finish();
|
||
|
||
if (!jsonMode) {
|
||
const label = dryRun ? '(dry run) would create' : 'created';
|
||
console.log(`Links: ${label} ${created} from ${processed} pages (db source)`);
|
||
if (includeFrontmatter && unresolved.length > 0) {
|
||
// Top-20 preview of unresolvable frontmatter names so the user can
|
||
// see where the graph has holes (codex tension 6.4).
|
||
console.log(`Unresolved frontmatter refs: ${unresolved.length} total`);
|
||
const bucket = new Map<string, number>();
|
||
for (const u of unresolved) {
|
||
const key = `${u.field}:${u.name}`;
|
||
bucket.set(key, (bucket.get(key) || 0) + 1);
|
||
}
|
||
const top = Array.from(bucket.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20);
|
||
for (const [key, count] of top) {
|
||
console.log(` ${count}× ${key}`);
|
||
}
|
||
}
|
||
}
|
||
return { created, pages: processed, unresolved };
|
||
}
|
||
|
||
async function extractTimelineFromDB(
|
||
engine: BrainEngine,
|
||
dryRun: boolean,
|
||
jsonMode: boolean,
|
||
typeFilter: PageType | undefined,
|
||
since: string | undefined,
|
||
opts?: { sourceIdFilter?: string },
|
||
): Promise<{ created: number; pages: number }> {
|
||
// v0.32.8: listAllPageRefs enumerates (slug, source_id) pairs so we can
|
||
// thread sourceId to getPage and addTimelineEntriesBatch. Pre-fix used
|
||
// getAllSlugs() which collapsed same-slug-different-source pages.
|
||
//
|
||
// v0.37.7.0 #1204: when sourceIdFilter is set, scope the walk to one
|
||
// source so federated brain users can extract per-source.
|
||
const sourceIdFilter = opts?.sourceIdFilter;
|
||
const allRefs = sourceIdFilter
|
||
? (await engine.listAllPageRefs()).filter(r => r.source_id === sourceIdFilter)
|
||
: await engine.listAllPageRefs();
|
||
let processed = 0, created = 0;
|
||
|
||
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||
progress.start('extract.timeline_db', allRefs.length);
|
||
|
||
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
|
||
const dryRunSeen = dryRun ? new Set<string>() : null;
|
||
|
||
const batch: TimelineBatchInput[] = [];
|
||
async function flush() {
|
||
if (batch.length === 0) return;
|
||
const snapshot = batch.slice();
|
||
batch.length = 0;
|
||
try {
|
||
created += await withRetry(
|
||
() => engine.addTimelineEntriesBatch(snapshot),
|
||
{ onRetry: (_a, err) => logBatchRetry('extract.timeline_db', snapshot.length, err, jsonMode) },
|
||
);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
if (jsonMode) {
|
||
process.stderr.write(JSON.stringify({ event: 'batch_error', size: snapshot.length, error: msg }) + '\n');
|
||
} else {
|
||
console.error(` batch error (${snapshot.length} timeline rows lost): ${msg}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const { slug, source_id } of allRefs) {
|
||
const page = await engine.getPage(slug, { sourceId: source_id });
|
||
if (!page) continue;
|
||
if (typeFilter && page.type !== typeFilter) continue;
|
||
if (since) {
|
||
const updatedMs = new Date(page.updated_at).getTime();
|
||
const sinceMs = new Date(since).getTime();
|
||
if (Number.isFinite(sinceMs) && updatedMs <= sinceMs) continue;
|
||
}
|
||
|
||
const fullContent = page.compiled_truth + '\n' + page.timeline;
|
||
const entries = parseTimelineEntries(fullContent);
|
||
|
||
for (const entry of entries) {
|
||
if (dryRunSeen) {
|
||
const key = `${source_id}::${slug}::${entry.date}::${entry.summary}`;
|
||
if (dryRunSeen.has(key)) continue;
|
||
dryRunSeen.add(key);
|
||
if (jsonMode) {
|
||
process.stdout.write(JSON.stringify({
|
||
action: 'add_timeline', slug, source_id, date: entry.date,
|
||
summary: entry.summary, ...(entry.detail ? { detail: entry.detail } : {}),
|
||
}) + '\n');
|
||
} else {
|
||
console.log(` ${slug}: ${entry.date} — ${entry.summary}`);
|
||
}
|
||
created++;
|
||
} else {
|
||
// v0.32.8 F4: thread source_id so the JOIN matches the right page
|
||
// when two sources share the same slug.
|
||
batch.push({ slug, date: entry.date, summary: entry.summary, detail: entry.detail || '', source_id });
|
||
if (batch.length >= BATCH_SIZE) await flush();
|
||
}
|
||
}
|
||
processed++;
|
||
progress.tick(1);
|
||
}
|
||
await flush();
|
||
progress.finish();
|
||
|
||
if (!jsonMode) {
|
||
const label = dryRun ? '(dry run) would create' : 'created';
|
||
console.log(`Timeline: ${label} ${created} entries from ${processed} pages (db source)`);
|
||
}
|
||
return { created, pages: processed };
|
||
}
|
||
|
||
/**
|
||
* v0.42.0.0 Part B (migration #1 of #1409) — auto-link body-text entity
|
||
* mentions to known entity pages.
|
||
*
|
||
* Walks every page (respecting --source-id / --type / --since filters),
|
||
* scans `compiled_truth || '\n\n' || COALESCE(timeline, '')` per D3
|
||
* against the gazetteer built via `buildGazetteer`, and writes one link
|
||
* per (from_page, to_page) pair with `link_source='mentions'`. The
|
||
* mention link_source is filtered OUT of backlink-count per D12 so
|
||
* search ranking semantics are preserved.
|
||
*
|
||
* Source isolation: mentions cross-source pages are deliberately
|
||
* suppressed by `findMentionedEntities`'s cross-source guard. Page in
|
||
* source A mentions entity in source B → no link created. v1
|
||
* conservative posture; relaxable in a future wave.
|
||
*/
|
||
async function extractMentionsFromDb(
|
||
engine: BrainEngine,
|
||
dryRun: boolean,
|
||
jsonMode: boolean,
|
||
typeFilter: PageType | undefined,
|
||
since: string | undefined,
|
||
opts?: { sourceIdFilter?: string },
|
||
): Promise<{ created: number; pages: number }> {
|
||
const sourceIdFilter = opts?.sourceIdFilter;
|
||
|
||
// Build gazetteer once per run. Skip everything if there are no
|
||
// linkable entities — vacuous truth, no mentions to find.
|
||
const gazetteer = await buildGazetteer(engine);
|
||
if (gazetteer.size === 0) {
|
||
if (jsonMode) {
|
||
process.stdout.write(JSON.stringify({ event: 'no_gazetteer', message: 'no linkable entity pages found; nothing to scan' }) + '\n');
|
||
} else {
|
||
console.log('No linkable entity pages found in this brain (need pages with type IN person/company/organization/entity).');
|
||
}
|
||
return { created: 0, pages: 0 };
|
||
}
|
||
|
||
const allRefs = sourceIdFilter
|
||
? (await engine.listAllPageRefs()).filter(r => r.source_id === sourceIdFilter)
|
||
: await engine.listAllPageRefs();
|
||
|
||
let processed = 0;
|
||
let created = 0;
|
||
const batch: LinkBatchInput[] = [];
|
||
|
||
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||
progress.start('extract.by_mention.scan', allRefs.length);
|
||
|
||
async function flush() {
|
||
if (batch.length === 0) return;
|
||
try {
|
||
created += await engine.addLinksBatch(batch); // gbrain-allow-direct-insert: gbrain extract --by-mention — canonical auto-link write from body-text mention scan
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
if (jsonMode) {
|
||
process.stderr.write(JSON.stringify({ event: 'batch_error', size: batch.length, error: msg }) + '\n');
|
||
} else {
|
||
console.error(` batch error (${batch.length} link rows lost): ${msg}`);
|
||
}
|
||
} finally {
|
||
batch.length = 0;
|
||
}
|
||
}
|
||
|
||
const sinceMs = since ? new Date(since).getTime() : null;
|
||
|
||
for (const { slug, source_id } of allRefs) {
|
||
const page = await engine.getPage(slug, { sourceId: source_id });
|
||
if (!page) continue;
|
||
if (typeFilter && page.type !== typeFilter) continue;
|
||
if (sinceMs !== null) {
|
||
const updatedMs = new Date(page.updated_at).getTime();
|
||
if (Number.isFinite(updatedMs) && updatedMs <= sinceMs) continue;
|
||
}
|
||
processed++;
|
||
progress.tick();
|
||
|
||
// D3: scan both columns joined with a paragraph separator so an
|
||
// end-of-compiled token doesn't accidentally merge with a
|
||
// start-of-timeline token into a false phrase match.
|
||
const body = page.compiled_truth + '\n\n' + (page.timeline ?? '');
|
||
if (!body.trim()) continue;
|
||
|
||
const mentions = findMentionedEntities(body, gazetteer, {
|
||
fromSlug: slug,
|
||
fromSourceId: source_id,
|
||
});
|
||
|
||
if (mentions.length === 0) continue;
|
||
|
||
for (const m of mentions) {
|
||
if (dryRun) {
|
||
if (jsonMode) {
|
||
process.stdout.write(JSON.stringify({
|
||
action: 'add_link', from: slug, from_source_id: source_id,
|
||
to: m.slug, to_source_id: m.source_id,
|
||
type: 'mentions', context: m.name, link_source: 'mentions',
|
||
}) + '\n');
|
||
} else {
|
||
console.log(` ${slug} → ${m.slug} (mentions: "${m.name}")`);
|
||
}
|
||
created++;
|
||
} else {
|
||
batch.push({
|
||
from_slug: slug,
|
||
to_slug: m.slug,
|
||
link_type: 'mentions',
|
||
link_source: 'mentions',
|
||
context: m.name,
|
||
from_source_id: source_id,
|
||
to_source_id: m.source_id,
|
||
});
|
||
if (batch.length >= BATCH_SIZE) await flush();
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!dryRun) await flush();
|
||
progress.finish();
|
||
|
||
if (!jsonMode) {
|
||
const label = dryRun ? '(dry run) would create' : 'created';
|
||
console.log(`Mentions: ${label} ${created} links from ${processed} pages against gazetteer of ${gazetteer.size} first-token buckets`);
|
||
}
|
||
return { created, pages: processed };
|
||
}
|