v0.41.29.0 feat(conversation-parser): bold-name-no-time builtin + fix(orphans): source-scoped orphan_ratio (supersedes #1613) (#1620)

* feat(conversation-parser): add bold-name-no-time builtin (Circleback/Granola/Zoom, no timestamp)

The 14th built-in pattern parses `**Speaker:** text` transcripts with NO
per-line timestamp — the shape Circleback / Granola / Zoom emit. Every prior
builtin required a time anchor, so this shape matched nothing: a production
brain had 104 conversation pages + 3,423 eligible pages silently extracting
zero facts. Messages anchor at T00:00:00Z of the frontmatter date (no
fabricated wall-clock; line order preserves sequence), same convention as
irc-classic.

Hardening beyond the original community proposal:
- regex `/^\*\*(?!\[)(.+?):\*\*\s*(.*)$/`: the colon-inside-bold (NOT
  declaration order) is what prevents shadowing bold-paren-time; the `(?!\[)`
  lookahead rejects telegram-bracket `**[18:37] Name:**` so disabling
  telegram-bracket yields an honest no_match instead of speaker="[18:37] Name".
- new optional PatternEntry.score_full_body: `**Label:** text` is a common
  prose idiom, so a notes page with bold labels clustered in its first 10
  lines scored 0.3 on the head pass (NOT < SCORING_HEAD_TRIGGER_THRESHOLD, so
  the full-body fallback never fired) and cleared the 0.05 floor. parse.ts now
  recomputes the winner's score over the full body before the floor, so such a
  page drops to its true low density and stays no_match.
- scrubbed pre-existing real names from bold-paren-time test_positive samples
  (privacy rule).

Fixtures use placeholder names only. Pinned by new bold-name-no-time +
clustered-head no_match cases in parse.test.ts and the eval corpus.

Co-Authored-By: garrytan-agents <noreply@github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(orphans): scope orphan_ratio + find_orphans by source; fix total_linkable denominator

`gbrain doctor --source <id>` and `gbrain orphans --source <id>` now scope
the orphan scan to that source instead of reporting brain-wide. Three fixes:

- findOrphanPages(opts?: { sourceId?, sourceIds? }) on both engines scopes the
  CANDIDATE set (scalar `= $1` or federated `= ANY($1::text[])`). Inbound links
  from ANY source still count, so a page in source X linked FROM source Y is
  reachable and NOT an orphan of X (the deliberate, less-surprising definition).
- corrected the total_linkable denominator in findOrphans: it now enumerates
  all live pages (scoped) and subtracts every excluded-by-slug page, not just
  excluded orphans. The old `total - excludedOrphans` left excluded NON-orphan
  pages (templates/, scratch/) with inbound links in the denominator, inflating
  it and suppressing warnings. Changes orphan_ratio output for every brain, in
  the accurate direction.
- the find_orphans MCP op threads sourceScopeOpts(ctx), closing a cross-source
  read leak where a source-bound OAuth client saw brain-wide orphans (v0.34.1
  source-isolation class).

doctor uses an explicit `--source` flag parse (NOT resolveSourceWithTier, which
would scope bare invocations to a default), and under explicit --source reports
the ratio with a low-scale caveat below 100 entity pages instead of a vacuous
"ok". Thin-client doctor --source orphan_ratio deferred (TODOS.md).

Pinned by test/orphans-source-scope.test.ts (PGLite: scoping, cross-source
inbound, denominator, find_orphans op scope) + a Postgres↔PGLite parity case
in test/e2e/engine-parity.test.ts (scalar + federated binding).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: v0.41.29.0 — bold-name-no-time + orphan source scoping

VERSION + package.json → 0.41.29.0; CHANGELOG entry; CLAUDE.md conversation-parser
(13→14 patterns) + orphans source-scoping notes; regenerated llms bundles; TODOS
for thin-client doctor --source + check-test-real-names widening.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents <noreply@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-29 07:06:08 -07:00
committed by GitHub
co-authored by garrytan-agents Claude Opus 4.8
parent ffac8ce0f4
commit 041d89babe
21 changed files with 780 additions and 34 deletions
+131
View File
@@ -2,6 +2,137 @@
All notable changes to GBrain will be documented in this file.
## [0.41.29.0] - 2026-05-29
**Your meeting transcripts that look like `**Garry Tan:** ...` now actually
parse — the kind Circleback, Granola, and Zoom export with no timestamp on
each line. And `gbrain doctor --source <id>` finally scopes its orphan check
to that one source instead of your whole brain.**
Two fixes in one release.
**The transcript fix.** gbrain knows 13 chat formats, and every single one
needed a per-line timestamp to recognize a speaker. Modern meeting tools
don't put a time on every line — they emit plain `**Speaker Name:** what
they said`. With no time anchor, gbrain matched nothing: a real production
brain had 104 conversation pages plus 3,423 other eligible pages that
silently parsed to zero messages and extracted zero facts. The 14th built-in
pattern, `bold-name-no-time`, handles that shape. Each message anchors at
00:00:00 of the page's date (no fake wall-clock times are invented; line
order preserves the sequence). Nothing you do — it just works the next time
your cycle runs.
How to check it's recognized:
```
gbrain conversation-parser list-builtins # bold-name-no-time is now listed
gbrain conversation-parser scan <slug> # see how one page parses
```
One thing we were careful about: that `**Label:** text` shape is also a
common way to write notes (`**Owner:** Alice`, `**Action:** ship it`). A
notes page is NOT a conversation, and gbrain won't treat it as one — even
when a few bold labels sit at the very top of the page. We score the whole
document's density, not just the first few lines, so a mostly-prose page
with scattered bold labels stays unparsed instead of turning into fake
"messages" that pollute your facts.
**The orphan-check fix.** "Orphan ratio" is the fraction of your pages that
nothing links to — a health signal for `gbrain doctor`. On a brain with
multiple sources, `gbrain doctor --source dept-x` ignored the flag and
reported a brain-wide number. Now it scopes to the source you asked about,
and so does `gbrain orphans --source dept-x`:
```
gbrain doctor --source dept-x # orphan ratio for dept-x only
gbrain orphans --source dept-x # the orphan list for dept-x only
```
A page in dept-x that's linked from another source counts as reachable (not
an orphan) — the cross-source link still saves it.
While we were in there we fixed two more things you'll feel:
- The orphan ratio was reading lower than reality because junk pages
(templates, scratch, archives) that happen to be linked were padding the
denominator. The ratio is now honest, which means warnings that were
staying quiet will start showing up. This changes the number on every
brain, in the right direction.
- An agent connected over MCP and scoped to one source used to get the
orphan list for your *whole* brain. Now `find_orphans` respects the
agent's source scope, closing a cross-source read leak.
### To take advantage of v0.41.29.0
`gbrain upgrade` handles this — there's no schema migration and no manual
step. After upgrading:
1. **Conversation parsing** is automatic. Your next `gbrain dream` /
`gbrain extract conversation-facts` run will pick up `**Speaker:** text`
pages. To preview a specific page:
```bash
gbrain conversation-parser scan <slug>
```
2. **Per-source orphan checks** work immediately:
```bash
gbrain doctor --source <id>
gbrain orphans --source <id>
```
3. **If `gbrain doctor` shows a higher orphan ratio than before,** that's the
denominator fix surfacing real orphans that were previously hidden. Run
`gbrain extract links --by-mention` to auto-link entity mentions, then
`gbrain orphans` for the list.
4. **If anything looks wrong,** file an issue at
https://github.com/garrytan/gbrain/issues with `gbrain doctor` output.
### Itemized changes
**Conversation parser**
- New 14th built-in pattern `bold-name-no-time` in
`src/core/conversation-parser/builtins.ts` (regex `/^\*\*(?!\[)(.+?):\*\*\s*(.*)$/`):
parses `**Speaker:** text` with no per-line timestamp (Circleback / Granola
/ Zoom), anchored at `T00:00:00Z` of the frontmatter date. Declared after
the timestamped bold patterns; the colon-inside-bold regex (not declaration
order) is what prevents it from shadowing `bold-paren-time`. The `(?!\[)`
lookahead keeps it from mis-capturing telegram-bracket `**[18:37] Name:**`
lines as `speaker="[18:37] Name"`.
- New optional `PatternEntry.score_full_body` field + a full-body acceptance
recompute in `parse.ts`: broad patterns are scored on whole-document
density before the acceptance floor, so a prose notes page with bold labels
clustered in its first 10 lines no longer mis-parses as a conversation.
- Fixture coverage: `test/fixtures/conversation-formats/bold-name-no-time.jsonl`
+ entries in `all.jsonl` and a clustered-head adversarial fixture in
`adversarial.jsonl` (all placeholder names).
**Orphan source scoping**
- `BrainEngine.findOrphanPages(opts?: { sourceId?, sourceIds? })` (both
engines): scopes the candidate set to one source (scalar) or a federated
set (`= ANY(...)`). Inbound links from any source still count, so a
cross-source-linked page is correctly not an orphan.
- `gbrain orphans --source <id>` and `gbrain doctor --source <id>` (orphan_ratio
check) honor an explicit source. Bare invocations stay brain-wide.
- Corrected the `total_linkable` denominator in `findOrphans` so excluded
pages (templates/, scratch/, etc.) that have inbound links no longer
inflate it and suppress warnings. Changes orphan_ratio output for every
brain (in the accurate direction).
- The `find_orphans` MCP op now scopes by the caller's source
(`sourceScopeOpts(ctx)`), closing a cross-source read leak for source-bound
OAuth clients.
- Under explicit `--source`, `orphan_ratio` reports the ratio with a
low-scale caveat below 100 entity pages instead of returning a vacuous
"ok" that could hide a fully-orphaned small source.
**Tests**
- `test/orphans-source-scope.test.ts` (PGLite): scoping, cross-source-inbound,
the denominator fix, and the `find_orphans` op-handler source scope.
- `test/e2e/engine-parity.test.ts`: Postgres↔PGLite parity for
`findOrphanPages` scalar + federated scoping.
- New `bold-name-no-time` + clustered-head regression cases in
`test/conversation-parser/parse.test.ts`.
Incorporates the original `bold-name-no-time` pattern proposed in a community
PR; names scrubbed to placeholders per the project privacy rule.
## [0.41.28.0] - 2026-05-27
**Your `gbrain dream` cycle stops losing rows when the database connection
+2 -2
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -1,5 +1,15 @@
# TODOS
## v0.41.29.0 orphan source-scoping follow-ups (v0.42+)
Filed from the v0.41.29.0 wave (bold-name-no-time pattern + orphan_ratio
source scoping). The Codex outside-voice review (F8) flagged two surfaces
the wave deliberately scoped out.
- [ ] **v0.42+: thin-client `gbrain doctor --source` orphan_ratio scoping.** v0.41.29.0 scopes `orphan_ratio` to `--source` on the LOCAL doctor path (`buildChecks` in `src/commands/doctor.ts`) and closes the `find_orphans` MCP read leak via `sourceScopeOpts(ctx)`. The thin-client / remote doctor path (`src/core/doctor-remote.ts` `runRemoteDoctor`) is a separate code path that does not thread `--source`, so `gbrain doctor --source x` against a remote `gbrain serve --http` brain still reports brain-wide orphan_ratio. Thread the explicit `--source` into the remote doctor request + have the server-side check honor it. Priority: P3 (most users run doctor locally).
- [ ] **v0.42+: widen `check-test-real-names.sh` BANNED_NAMES to catch real-name reintroduction in tests + src.** v0.41.29.0 scrubbed pre-existing real names (`Garry Tan`, `Alex Graveley`) from `bold-paren-time`'s `test_positive` (and the new `bold-name-no-time` samples), but no automated guard caught them: `check-test-real-names.sh` only scans `test/**` and its BANNED_NAMES list doesn't include `garry tan`; `check-fixture-privacy.sh` only scans `test/fixtures/conversation-formats/`. Add `garry tan` / `garrytan` (and consider extending the scan to `src/core/conversation-parser/builtins.ts` test samples) so future reintroductions fail CI. Priority: P3 (hardening).
## v0.41.28.0 #1570 instrument-then-fix follow-ups (v0.41.28+ / v0.42+)
Filed from the v0.41.28.0 plan-eng-review after the codex outside-voice
+1 -1
View File
@@ -1 +1 @@
0.41.28.0
0.41.29.0
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -141,5 +141,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.41.28.0"
"version": "0.41.29.0"
}
+36 -6
View File
@@ -3080,6 +3080,19 @@ export async function buildChecks(
// invoked later in the function.
const scope: 'all' | 'brain' = args.includes('--scope=brain') ? 'brain' : 'all';
// v0.41.29.0: explicit `--source <id>` scopes the `orphan_ratio` check to one
// source. EXPLICIT-ONLY by design — a raw flag parse, NOT resolveSourceWithTier.
// The tier resolver would pick a default source when `--source` is absent and
// silently scope a bare `gbrain doctor` to one source; we want bare doctor to
// stay brain-wide. Only `orphan_ratio` consumes this for now (other checks
// staying brain-wide is a separate, larger change — see TODOS.md).
let orphanRatioSourceId: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--source' && i + 1 < args.length) {
orphanRatioSourceId = args[++i] || undefined;
}
}
const checks: Check[] = [];
let autoFixReport: AutoFixReport | null = null;
@@ -4543,22 +4556,39 @@ export async function buildChecks(
// show high orphan ratio; not actionable signal).
// Warn at >0.5; fail at >0.8. Both states recommend
// `gbrain extract links --by-mention` as the fix.
// v0.41.29.0: explicit `--source <id>` scopes this check to one source
// (orphanRatioSourceId, parsed at the top of buildChecks). The entity-count
// gate + getOrphansData both scope to it; messages name the source. Bare
// doctor (no --source) stays brain-wide.
progress.heartbeat('orphan_ratio');
try {
const { getOrphansData } = await import('./orphans.ts');
const srcId = orphanRatioSourceId;
const inSource = srcId ? ` in source '${srcId}'` : '';
const entityCount = (await engine.executeRaw<{ count: number }>(
"SELECT COUNT(*)::int AS count FROM pages WHERE type IN ('entity', 'person', 'company', 'organization') AND deleted_at IS NULL",
`SELECT COUNT(*)::int AS count FROM pages WHERE type IN ('entity', 'person', 'company', 'organization') AND deleted_at IS NULL${srcId ? ' AND source_id = $1' : ''}`,
srcId ? [srcId] : [],
))[0]?.count ?? 0;
if (entityCount < 100) {
// Brain-wide (no --source): <100 entities is vacuous — small brains
// naturally show a high orphan ratio; not actionable signal. Skip.
if (entityCount < 100 && !srcId) {
checks.push({
name: 'orphan_ratio',
status: 'ok',
message: `Vacuous: ${entityCount} entity pages (<100). Orphan ratio not meaningful at this scale.`,
});
} else {
const data = await getOrphansData(engine, { includePseudo: false });
// F7 (Codex): under EXPLICIT --source, an operator deliberately asked
// about one source — answer it even below 100 entities, with a
// low-scale caveat, instead of swallowing a real per-source failure
// (e.g. 80 fully-orphaned entity pages) behind a vacuous "ok".
const data = await getOrphansData(engine, { includePseudo: false, sourceId: srcId });
const ratio = data.total_linkable > 0 ? data.total_orphans / data.total_linkable : 0;
const pct = (ratio * 100).toFixed(0);
const caveat =
entityCount < 100
? ` — low scale (${entityCount} entity pages <100), interpret with caution`
: '';
const hint =
'Run: gbrain extract links --by-mention (auto-links entity mentions in body text). ' +
'Run gbrain orphans for the list.';
@@ -4566,19 +4596,19 @@ export async function buildChecks(
checks.push({
name: 'orphan_ratio',
status: 'fail',
message: `Orphan ratio ${pct}% (${data.total_orphans}/${data.total_linkable} linkable pages have no inbound links). ${hint}`,
message: `Orphan ratio ${pct}%${inSource} (${data.total_orphans}/${data.total_linkable} linkable pages have no inbound links)${caveat}. ${hint}`,
});
} else if (ratio > 0.5) {
checks.push({
name: 'orphan_ratio',
status: 'warn',
message: `Orphan ratio ${pct}% (${data.total_orphans}/${data.total_linkable} linkable pages have no inbound links). ${hint}`,
message: `Orphan ratio ${pct}%${inSource} (${data.total_orphans}/${data.total_linkable} linkable pages have no inbound links)${caveat}. ${hint}`,
});
} else {
checks.push({
name: 'orphan_ratio',
status: 'ok',
message: `Orphan ratio ${pct}% (${data.total_orphans}/${data.total_linkable} linkable pages)`,
message: `Orphan ratio ${pct}%${inSource} (${data.total_orphans}/${data.total_linkable} linkable pages)${caveat}`,
});
}
}
+53 -8
View File
@@ -127,9 +127,16 @@ export async function queryOrphanPages(
*/
export async function findOrphans(
engine: BrainEngine,
opts: { includePseudo?: boolean } = {},
opts: { includePseudo?: boolean; sourceId?: string; sourceIds?: string[] } = {},
): Promise<OrphanResult> {
const includePseudo = !!opts.includePseudo;
// v0.41.29.0: `sourceId` (scalar, from `--source` + single-source MCP
// clients) or `sourceIds` (federated, from `allowedSources` MCP clients)
// scopes the candidate set. `sourceIds` wins when both set (mirrors
// sourceScopeOpts precedence).
const sourceId = opts.sourceId;
const sourceIds =
opts.sourceIds && opts.sourceIds.length > 0 ? opts.sourceIds : undefined;
// The NOT EXISTS anti-join over pages × links can take seconds on 50K-page
// brains. Heartbeat every second so agents see the scan is alive. Keyset
// pagination was considered and rejected: without an index on
@@ -140,16 +147,40 @@ export async function findOrphans(
const stopHb = startHeartbeat(progress, 'scanning pages for missing inbound links…');
let allOrphans: { slug: string; title: string; domain: string | null }[];
let total: number;
let excludedAll: number;
try {
allOrphans = await engine.findOrphanPages();
// Count total pages in DB for the summary line
const stats = await engine.getStats();
total = stats.page_count;
allOrphans = await engine.findOrphanPages(
sourceIds ? { sourceIds } : sourceId ? { sourceId } : undefined,
);
// v0.41.29.0 (Codex F6): correct the `total_linkable` denominator.
// Enumerate ALL live pages (scoped) and count excluded-by-slug across
// the WHOLE set — not just among orphans. The old
// `total - excludedOrphans` left excluded NON-orphan pages (e.g. a
// `test/` page that HAS inbound links) in the denominator, inflating
// total_linkable and suppressing orphan warnings. `getAllSlugs` is NOT
// used here because it does not filter soft-deleted rows; `total` must
// match `findOrphanPages`'s `deleted_at IS NULL` candidate universe.
let scopeClause = '';
const liveParams: unknown[] = [];
if (sourceIds) {
liveParams.push(sourceIds);
scopeClause = ` AND source_id = ANY($${liveParams.length}::text[])`;
} else if (sourceId) {
liveParams.push(sourceId);
scopeClause = ` AND source_id = $${liveParams.length}`;
}
const liveRows = await engine.executeRaw<{ slug: string }>(
`SELECT slug FROM pages WHERE deleted_at IS NULL${scopeClause}`,
liveParams,
);
total = liveRows.length;
excludedAll = includePseudo
? 0
: liveRows.reduce((n, r) => n + (shouldExclude(r.slug) ? 1 : 0), 0);
} finally {
stopHb();
progress.finish();
}
const _totalPages = allOrphans.length; // pages with no inbound links (preserved for ref)
const filtered = includePseudo
? allOrphans
@@ -166,7 +197,10 @@ export async function findOrphans(
return {
orphans,
total_orphans: orphans.length,
total_linkable: filtered.length + (total - allOrphans.length),
// v0.41.29.0 (Codex F6): denominator = live pages minus ALL excluded
// pages (orphan or not), so excluded pages with inbound links no longer
// inflate it.
total_linkable: total - excludedAll,
total_pages: total,
excluded,
};
@@ -224,6 +258,16 @@ export async function runOrphans(engine: BrainEngine, args: string[]) {
const json = args.includes('--json');
const count = args.includes('--count');
const includePseudo = args.includes('--include-pseudo');
// v0.41.29.0: explicit `--source <id>` scopes the orphan scan to one
// source. Omitted → brain-wide (unchanged). Raw explicit-flag parse on
// purpose — NOT resolveSourceWithTier, which would pick a default source
// when the flag is absent and silently scope a bare `gbrain orphans`.
let sourceId: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--source' && i + 1 < args.length) {
sourceId = args[++i] || undefined;
}
}
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: gbrain orphans [options]
@@ -234,6 +278,7 @@ Options:
--json Output as JSON (for agent consumption)
--count Output just the number of orphans
--include-pseudo Include auto-generated and pseudo pages in results
--source <id> Scope the scan to one brain source (default: brain-wide)
--help, -h Show this help
Output (default): grouped by domain, sorted alphabetically within each group
@@ -242,7 +287,7 @@ Summary line: N orphans out of M linkable pages (K total; K-M excluded)
return;
}
const result = await findOrphans(engine, { includePseudo });
const result = await findOrphans(engine, { includePseudo, sourceId });
if (count) {
console.log(String(result.total_orphans));
+73 -4
View File
@@ -1,7 +1,7 @@
/**
* v0.41.16.0 — Built-in conversation parser pattern registry.
*
* Twelve hand-vetted patterns covering the chat-export formats this
* Fourteen hand-vetted patterns covering the chat-export formats this
* codebase is most likely to encounter. Each pattern's regex was
* derived from a public format reference (source_doc field) so future
* maintainers can verify against the wild shape.
@@ -50,7 +50,7 @@ export function cleanSpeaker(raw: string, override?: RegExp): string {
return stripped || raw.trim();
}
/** The 12 hand-vetted built-in patterns. */
/** The 14 hand-vetted built-in patterns. */
export const BUILTIN_PATTERNS: readonly PatternEntry[] = [
// -------------------------------------------------------------------
// INLINE-DATE patterns (date in every line; less ambiguous; tried first).
@@ -158,9 +158,9 @@ export const BUILTIN_PATTERNS: readonly PatternEntry[] = [
multi_line: false,
quick_reject: /^\*\*/,
test_positive: [
'**Garry Tan** (00:00): hello world',
'**Alice Example** (00:00): hello world',
'**Participant 2** (02:22): response here',
'**Alex Graveley** (15:09): Thats exactly right.',
'**Bob Example** (15:09): Thats exactly right.',
'**Participant 1** (00:00:00): hello world with seconds',
'**Participant 2** (01:23:45): mid-meeting line',
],
@@ -178,6 +178,75 @@ export const BUILTIN_PATTERNS: readonly PatternEntry[] = [
'OpenClaw meeting-ingestion pipeline reformat of Circleback transcripts (see your OpenClaw skills/meeting-ingestion/SKILL.md)',
},
{
// Modern meeting-transcription tools (Circleback, Granola, Zoom)
// emit `**Speaker Name:** message text` with NO per-line
// timestamp. Every other built-in requires a time anchor, so this
// shape scored ~0.002 (one stray line in a long page) and fell
// below SCORING_MIN_ACCEPTANCE — parsing to zero messages and
// extracting zero conversation-facts. This additive pattern fixes
// that: speaker is captured inside the bold markers (`**Name:**`),
// there is no time capture, and date_source='frontmatter' with
// hour_group undefined routes through parse.ts's no-time branch
// (same convention as irc-classic) — every message anchors at
// 00:00:00 of the page's frontmatter date. No wall-clock time is
// fabricated; intra-day ordering is preserved by line order.
//
// NON-SHADOW GUARANTEE (read carefully — the safety is in the
// REGEX, not the declaration order). parse.ts scores every
// candidate independently; declaration index is ONLY the
// tie-break. This pattern cannot steal `**Name** (time):` from
// bold-paren-time because its regex requires the colon INSIDE the
// bold markers (`**Name:**`), which the paren-time shape (colon
// OUTSIDE: `**Name** (time):`) never has. The `(?!\[)` lookahead
// additionally rejects telegram-bracket's `**[18:37] Name:**`
// shape so that disabling telegram-bracket yields an honest
// no_match instead of capturing speaker="[18:37] Name" at midnight.
//
// BROAD-REGEX GUARD (score_full_body): `**Label:** text` is a
// common prose idiom (`**Note:**`, `**Owner:**`). A notes page
// with a few bold labels clustered in its first 10 lines would
// score 0.3 on the head pass, skip the rescore, and clear the
// 0.05 floor. score_full_body forces full-body density scoring
// before acceptance so such a page falls to no_match.
id: 'bold-name-no-time',
origin: 'builtin',
// Matches: **Speaker Name:** message text (colon INSIDE bold,
// speaker must not start with `[` — see lookahead rationale above).
regex: /^\*\*(?!\[)(.+?):\*\*\s*(.*)$/,
captures: {
speaker_group: 1,
text_group: 2,
},
date_source: 'frontmatter',
time_format: '24h',
timezone_policy: 'utc_assumed_with_warn',
multi_line: false,
quick_reject: /^\*\*/,
score_full_body: true,
test_positive: [
'**Alice Example:** Okay, start on.',
'**Participant 2:** he tried to reset it remotely the other night.',
'**Bob Example:** That is exactly right.',
],
test_negative: [
// bold-paren-time shape (colon OUTSIDE bold) MUST fall through:
'**Alice** (00:00): text',
// imessage-slack shape MUST fall through:
'**Alice Example** (2024-03-15 9:00 AM): iMessage shape',
// Bold but no colon at all:
'**Alice** hello world',
// No bold markers:
'Alice: plain no bold',
// telegram-bracket shape (timestamp INSIDE bold) MUST NOT match
// — the `(?!\[)` lookahead rejects it so disabling
// telegram-bracket yields no_match, not speaker="[18:37] Alice":
'**[18:37] \u{1f464} Alice:** hello',
],
source_doc:
'Circleback / Granola / Zoom meeting-transcript export shape: `**Speaker:** text` with no per-line timestamp',
},
{
id: 'telegram-text-export',
origin: 'builtin',
+17
View File
@@ -479,6 +479,7 @@ export function parseConversation(
// 10 head lines matched"; chat-only pages still score 1.0 and skip
// the fallback. Re-score every candidate against the full body,
// pre-splitting ONCE to avoid 12 redundant body splits.
let fullBodyScored = false;
if (scored[0].score < SCORING_HEAD_TRIGGER_THRESHOLD) {
const allLines = getNonBlankLines(body);
scored = candidates.map((entry) => ({
@@ -487,6 +488,7 @@ export function parseConversation(
priority: priorityOf(entry.id),
}));
sortScored(scored);
fullBodyScored = true;
// NOTE: patterns_scored stays as scored.length (= candidate
// count, typically 12) even when the fallback runs — the
// diagnostic reports "candidates considered" not "scoring
@@ -496,6 +498,21 @@ export function parseConversation(
const top = scored[0];
const patternsScored = scored.length;
// v0.41.29.0 (Codex F1): broad no-time patterns (`bold-name-no-time`,
// regex matches any `**Label:** text`) can win the HEAD pass on a
// prose notes page whose first 10 lines happen to hold 3+ bold labels
// (head score 0.3, NOT < SCORING_HEAD_TRIGGER_THRESHOLD, so the
// full-body fallback above never ran). 0.3 clears the 0.05 floor and
// the page mis-parses as a conversation. When the winner is flagged
// `score_full_body` and we have NOT already scored full-body, recompute
// its score over the whole document so the floor judges true density.
// We do NOT re-pick the winner: the head pick is already the best
// candidate; this only tightens its acceptance (a real transcript
// stays ~1.0; a 3/200-label notes page drops to ~0.015 → no_match).
if (top.entry.score_full_body && !fullBodyScored) {
top.score = scoreFromLines(getNonBlankLines(body), top.entry);
}
// Minimum acceptance floor (closes Codex P1 #2): an essay with
// one stray `**Name** (date time):` line scores ~1/300 ≈ 0.003 —
// below the 5% floor we stay no_match instead of returning a
+13
View File
@@ -170,6 +170,19 @@ export interface PatternEntry {
* uses `DEFAULT_SPEAKER_CLEAN`.
*/
speaker_clean?: RegExp;
/**
* v0.41.29.0: when true, the orchestrator recomputes this pattern's
* acceptance score over the FULL body (not just the head window)
* before applying SCORING_MIN_ACCEPTANCE. Set on broad no-time
* patterns (`bold-name-no-time`) whose regex matches a common prose
* idiom (`**Label:** text`): without it, a notes page with a few bold
* labels CLUSTERED in its first 10 lines scores 0.3 on the head pass,
* skips the `< SCORING_HEAD_TRIGGER_THRESHOLD` rescore, and clears the
* 0.05 floor — mis-parsing prose as a conversation. Full-body scoring
* drops that page below the floor (3/200 ≈ 0.015 → no_match) while a
* real transcript stays ~1.0.
*/
score_full_body?: boolean;
/** D7: module-load validation — known-positive sample lines. */
test_positive: string[];
/** D7: module-load validation — known-negative sample lines. */
+13 -2
View File
@@ -1168,12 +1168,23 @@ export interface BrainEngine {
*/
getSalienceScores(refs: Array<{slug: string; source_id: string}>): Promise<Map<string, number>>;
/**
* Return every page with no inbound links (from any source).
* Return every page with no inbound links.
* Domain comes from the frontmatter `domain` field (null if unset).
* The caller filters pseudo-pages + derives display domain.
* Used by `gbrain orphans` and `runCycle`'s orphan sweep phase.
*
* v0.41.29.0: scopes the CANDIDATE set to one source (`sourceId`, from
* `gbrain doctor/orphans --source` + single-source MCP clients) or a
* federated set (`sourceIds`, from `allowedSources` MCP clients). The
* inbound-link side is NOT scoped — a page in source X linked FROM
* source Y is genuinely reachable, so it is not an orphan of X. Omit
* `opts` for the brain-wide behavior (unchanged). When both are set,
* `sourceIds` wins (mirrors `sourceScopeOpts` precedence).
*/
findOrphanPages(): Promise<Array<{ slug: string; title: string; domain: string | null }>>;
findOrphanPages(opts?: {
sourceId?: string;
sourceIds?: string[];
}): Promise<Array<{ slug: string; title: string; domain: string | null }>>;
// Tags
/**
+10 -1
View File
@@ -2756,7 +2756,16 @@ const find_orphans: Operation = {
scope: 'read',
handler: async (ctx, p) => {
const { findOrphans } = await import('../commands/orphans.ts');
return findOrphans(ctx.engine, { includePseudo: (p.include_pseudo as boolean) || false });
// v0.41.29.0 (Codex F8): scope by the caller's source (ctx.sourceId /
// ctx.auth.allowedSources) via the canonical sourceScopeOpts ladder.
// Pre-fix, find_orphans returned brain-wide orphans regardless of a
// source-bound OAuth client's scope — a read leak in the v0.34.1
// source-isolation class. Local CLI callers route through `gbrain
// orphans --source` instead (ctx.remote === false → empty scope here).
return findOrphans(ctx.engine, {
includePseudo: (p.include_pseudo as boolean) || false,
...sourceScopeOpts(ctx),
});
},
cliHints: { name: 'orphans', hidden: true },
};
+22 -2
View File
@@ -2791,12 +2791,30 @@ export class PGLiteEngine implements BrainEngine {
return out;
}
async findOrphanPages(): Promise<Array<{ slug: string; title: string; domain: string | null }>> {
async findOrphanPages(opts?: {
sourceId?: string;
sourceIds?: string[];
}): Promise<Array<{ slug: string; title: string; domain: string | null }>> {
// Soft-delete filter on BOTH sides:
// - candidate: p.deleted_at IS NULL — soft-deleted pages aren't orphan candidates
// - link source: src.deleted_at IS NULL — links FROM soft-deleted pages don't count as inbound
// Without the link-source filter, a live page can hide from orphan results purely
// because a soft-deleted page links to it. v0.26.5 invariant; codex C11.
//
// v0.41.29.0: scope ONLY the candidate side (`p.source_id`) when opts.sourceId
// is set. The inbound-link NOT EXISTS deliberately counts links from ANY source:
// a page in source X linked FROM source Y is reachable, so NOT an orphan of X.
// Do NOT add `src.source_id = p.source_id` here — that is the stricter
// intra-source-only definition we deliberately reject.
let sourceFilter = '';
const params: unknown[] = [];
if (opts?.sourceIds && opts.sourceIds.length > 0) {
params.push(opts.sourceIds);
sourceFilter = `AND p.source_id = ANY($${params.length}::text[])`;
} else if (opts?.sourceId) {
params.push(opts.sourceId);
sourceFilter = `AND p.source_id = $${params.length}`;
}
const { rows } = await this.db.query(
`SELECT
p.slug,
@@ -2804,6 +2822,7 @@ export class PGLiteEngine implements BrainEngine {
p.frontmatter->>'domain' AS domain
FROM pages p
WHERE p.deleted_at IS NULL
${sourceFilter}
AND NOT EXISTS (
SELECT 1
FROM links l
@@ -2811,7 +2830,8 @@ export class PGLiteEngine implements BrainEngine {
WHERE l.to_page_id = p.id
AND src.deleted_at IS NULL
)
ORDER BY p.slug`
ORDER BY p.slug`,
params
);
return rows as Array<{ slug: string; title: string; domain: string | null }>;
}
+17 -1
View File
@@ -2857,13 +2857,28 @@ export class PostgresEngine implements BrainEngine {
return out;
}
async findOrphanPages(): Promise<Array<{ slug: string; title: string; domain: string | null }>> {
async findOrphanPages(opts?: {
sourceId?: string;
sourceIds?: string[];
}): Promise<Array<{ slug: string; title: string; domain: string | null }>> {
const sql = this.sql;
// Soft-delete filter on BOTH sides:
// - candidate: p.deleted_at IS NULL — soft-deleted pages aren't orphan candidates
// - link source: src.deleted_at IS NULL — links FROM soft-deleted pages don't count as inbound
// Without the link-source filter, a live page can hide from orphan results purely
// because a soft-deleted page links to it. v0.26.5 invariant; codex C11.
//
// v0.41.29.0: scope ONLY the candidate side (`p.source_id`) when opts.sourceId
// is set. The inbound-link NOT EXISTS deliberately counts links from ANY source:
// a page in source X linked FROM source Y is reachable, so NOT an orphan of X.
// Do NOT add `src.source_id = p.source_id` here — that would be the stricter
// intra-source-only definition we deliberately reject.
const sourceFilter =
opts?.sourceIds && opts.sourceIds.length > 0
? sql`AND p.source_id = ANY(${opts.sourceIds}::text[])`
: opts?.sourceId
? sql`AND p.source_id = ${opts.sourceId}`
: sql``;
const rows = await sql`
SELECT
p.slug,
@@ -2871,6 +2886,7 @@ export class PostgresEngine implements BrainEngine {
p.frontmatter->>'domain' AS domain
FROM pages p
WHERE p.deleted_at IS NULL
${sourceFilter}
AND NOT EXISTS (
SELECT 1
FROM links l
+111 -4
View File
@@ -403,21 +403,21 @@ describe('scorePatternFull — full-body scoring (v0.41.18+ Codex P1 #1)', () =>
describe('bold-paren-time pattern (Circleback meeting transcripts)', () => {
test('matches **Speaker** (HH:MM): text with frontmatter date', () => {
const body = [
'**Garry Tan** (00:00): Hey, can you hear me?',
'**Alice Example** (00:00): Hey, can you hear me?',
'**Participant 2** (02:22): Yeah, just joined.',
'**Garry Tan** (15:09): That makes sense.',
'**Alice Example** (15:09): That makes sense.',
].join('\n');
const r = parseConversation(body, { fallbackDate: '2026-03-19' });
expect(r.phase).toBe('regex_match');
expect(r.matched_pattern_id).toBe('bold-paren-time');
expect(r.messages).toHaveLength(3);
expect(r.messages[0]).toEqual({
speaker: 'Garry Tan',
speaker: 'Alice Example',
timestamp: '2026-03-19T00:00:00Z',
text: 'Hey, can you hear me?',
});
expect(r.messages[2]).toEqual({
speaker: 'Garry Tan',
speaker: 'Alice Example',
timestamp: '2026-03-19T15:09:00Z',
text: 'That makes sense.',
});
@@ -478,6 +478,113 @@ describe('bold-paren-time pattern (Circleback meeting transcripts)', () => {
});
});
// ---------------------------------------------------------------------------
// bold-name-no-time pattern (Circleback / Granola / Zoom transcripts with NO
// per-line timestamp — `**Speaker:** text`). Additive pattern; the colon
// inside the bold markers + the `(?!\[)` lookahead are what keep it from
// shadowing bold-paren-time / telegram-bracket (NOT declaration order).
// ---------------------------------------------------------------------------
describe('bold-name-no-time pattern (Circleback/Granola/Zoom, no timestamp)', () => {
test('parses **Speaker:** text transcript with frontmatter date anchor', () => {
const body = [
'**Alice Example:** Okay, start on. And then weirdly like zoom doesnt...',
'**Participant 2:** he tried to reset it remotely the other night. Let me ask him.',
'**Alice Example:** I mean its really just like we need to get zoom to fix this.',
'**Participant 2:** Okay, let me.',
].join('\n');
const r = parseConversation(body, { fallbackDate: '2026-05-28' });
expect(r.phase).toBe('regex_match');
expect(r.matched_pattern_id).toBe('bold-name-no-time');
expect(r.messages).toHaveLength(4);
expect(r.messages[0]).toEqual({
speaker: 'Alice Example',
timestamp: '2026-05-28T00:00:00Z',
text: 'Okay, start on. And then weirdly like zoom doesnt...',
});
expect(r.messages[1].speaker).toBe('Participant 2');
expect(r.messages[1].text).toBe(
'he tried to reset it remotely the other night. Let me ask him.',
);
expect(r.messages[3]).toEqual({
speaker: 'Participant 2',
timestamp: '2026-05-28T00:00:00Z',
text: 'Okay, let me.',
});
// No-time pattern anchors at 00:00:00 of the frontmatter date
// (same convention as irc-classic). No wall-clock time fabricated.
});
test('scores above the 0.05 floor on a pure bold-name transcript (epoch default)', () => {
const body = [
'**Alice Example:** line one',
'**Participant 2:** line two',
'**Alice Example:** line three',
].join('\n');
const r = parseConversation(body);
expect(r.phase).toBe('regex_match');
expect(r.matched_pattern_id).toBe('bold-name-no-time');
expect(r.messages).toHaveLength(3);
// No fallbackDate → epoch default, still anchors at 00:00:00.
expect(r.messages[0].timestamp).toBe('1970-01-01T00:00:00Z');
});
// REGRESSION: must NOT shadow bold-paren-time. A `**Name** (00:00): text`
// line has its colon OUTSIDE the bold markers, so it must still parse via
// bold-paren-time (the safety is the regex, not declaration order).
test('REGRESSION: **Speaker** (HH:MM): text still matches bold-paren-time', () => {
const body = [
'**Alice Example** (00:00): Hey, can you hear me?',
'**Participant 2** (02:22): Yeah, just joined.',
].join('\n');
const r = parseConversation(body, { fallbackDate: '2026-03-19' });
expect(r.phase).toBe('regex_match');
expect(r.matched_pattern_id).toBe('bold-paren-time');
expect(r.matched_pattern_id).not.toBe('bold-name-no-time');
expect(r.messages).toHaveLength(2);
expect(r.messages[0].timestamp).toBe('2026-03-19T00:00:00Z');
});
// REGRESSION: a pure telegram-bracket transcript has the colon inside the
// bold markers BUT the speaker starts with `[`, so the `(?!\[)` lookahead
// rejects it AND telegram-bracket (lower declaration index) wins anyway.
test('REGRESSION: telegram-bracket transcript still matches telegram-bracket', () => {
const body = [
'**[18:37] \u{1f464} Alice:** one',
'**[18:38] \u{1f464} Bob:** two',
].join('\n');
const r = parseConversation(body, { fallbackDate: '2024-03-15' });
expect(r.matched_pattern_id).toBe('telegram-bracket');
});
// F1 (Codex): the broad regex matches any `**Label:** text`. A prose notes
// page with bold labels CLUSTERED in its first 10 lines scores 0.3 on the
// head pass (3/10) — NOT < SCORING_HEAD_TRIGGER_THRESHOLD (0.3), so the
// full-body fallback never fires — and that 0.3 clears the 0.05 floor.
// Without score_full_body this page would mis-parse as a conversation.
// score_full_body recomputes the winner over the FULL body: 3 labels /
// 83 lines ≈ 0.036 < 0.05 → no_match. This is the exact bug Codex caught
// and the test the naive "0.05 floor protects us" assumption would fail.
test('F1: prose with bold labels clustered in the head returns no_match', () => {
const lines = [
'**Attendees:** Alice Example, Bob Example, Participant 2',
'**Date:** 2026-05-28',
'**Goal:** decide on the Q3 roadmap and unblock the vendor migration',
];
// 80 plain prose lines (no bold-label shape) → 83 total, only 3 match.
// First 10 lines = 3 labels + 7 prose → head score 0.3 (skips rescore).
// Full body = 3/83 ≈ 0.036 < 0.05 floor.
for (let i = 0; i < 80; i++) {
lines.push(
`This is an ordinary prose sentence number ${i} describing the meeting in detail.`,
);
}
const body = lines.join('\n');
const r = parseConversation(body, { fallbackDate: '2026-05-28' });
expect(r.phase).toBe('no_match');
});
});
// ---------------------------------------------------------------------------
// parseConversation — full-body fallback (v0.41.18+ #1533 + Codex P1 #1, #2, #8)
// ---------------------------------------------------------------------------
+49
View File
@@ -363,4 +363,53 @@ describeBoth('Engine parity — Postgres vs PGLite', () => {
expect(pgMap.get('wiki/rsp-missing.md')).toBeUndefined();
expect(pgliteMap.get('wiki/rsp-missing.md')).toBeUndefined();
});
// v0.41.29.0 — findOrphanPages source scoping parity. Real Postgres
// coverage for the postgres.js `sql` scalar fragment + `= ANY(${arr}::text[])`
// array binding (a documented footgun class — the jsonb double-encode saga).
// PGLite logic is pinned in test/orphans-source-scope.test.ts; this asserts
// the Postgres SQL produces the same scoped sets. Cross-source inbound
// (src-b → src-a) must NOT make the target an orphan of src-a (A2).
test('v0.41.29.0 findOrphanPages source scoping parity (scalar + federated)', async () => {
const srcSql = `INSERT INTO sources (id, name, config) VALUES ($1, $1, '{}'::jsonb) ON CONFLICT DO NOTHING`;
const pageSql = `
INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter)
VALUES ($1, $2, 'person', 't', 'b', '', '{}'::jsonb)
ON CONFLICT (source_id, slug) DO NOTHING
`;
for (const eng of [pgEngine, pgliteEngine]) {
await eng.executeRaw(srcSql, ['orphan-src-a']);
await eng.executeRaw(srcSql, ['orphan-src-b']);
await eng.executeRaw(pageSql, ['orphan-src-a', 'people/op-orphan-a']);
await eng.executeRaw(pageSql, ['orphan-src-a', 'people/op-target-a']);
await eng.executeRaw(pageSql, ['orphan-src-b', 'people/op-linker-b']);
// Cross-source inbound: src-b page → src-a target (A2).
await eng.addLink(
'people/op-linker-b', 'people/op-target-a', '', 'mentions', 'markdown',
undefined, undefined, { fromSourceId: 'orphan-src-b', toSourceId: 'orphan-src-a' },
);
}
const scoped = async (eng: BrainEngine, opts: { sourceId?: string; sourceIds?: string[] }) =>
(await eng.findOrphanPages(opts)).map(r => r.slug).filter(s => s.startsWith('people/op-')).sort();
// Scalar scope to src-a: op-orphan-a is an orphan; op-target-a is saved
// by the cross-source inbound (A2). Parity on both engines.
const pgA = await scoped(pgEngine, { sourceId: 'orphan-src-a' });
const pgliteA = await scoped(pgliteEngine, { sourceId: 'orphan-src-a' });
expect(pgA).toEqual(['people/op-orphan-a']);
expect(pgliteA).toEqual(pgA);
// Scalar scope to src-b.
const pgB = await scoped(pgEngine, { sourceId: 'orphan-src-b' });
const pgliteB = await scoped(pgliteEngine, { sourceId: 'orphan-src-b' });
expect(pgB).toEqual(['people/op-linker-b']);
expect(pgliteB).toEqual(pgB);
// Federated array scope (= ANY binding) → union.
const pgFed = await scoped(pgEngine, { sourceIds: ['orphan-src-a', 'orphan-src-b'] });
const pgliteFed = await scoped(pgliteEngine, { sourceIds: ['orphan-src-a', 'orphan-src-b'] });
expect(pgFed).toEqual(['people/op-linker-b', 'people/op-orphan-a']);
expect(pgliteFed).toEqual(pgFed);
});
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
{"fixture_id":"bold-name-no-time-001","pattern":"bold-name-no-time","frontmatter":{"date":"2026-05-28"},"body":"**Alice Example:** Okay, start on. And then weirdly like zoom doesnt work.\n**Bob Example:** he tried to reset it remotely the other night. Let me ask him.\n**Alice Example:** I mean its really just like we need to get zoom to fix this.\n**Bob Example:** Okay, let me.","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
{"fixture_id":"bold-name-no-time-002","pattern":"bold-name-no-time","frontmatter":{"date":"2026-05-28"},"body":"**Alice Example:** can you hear me now\n**Participant 2:** yeah loud and clear\n**Alice Example:** great, let us start the review","expected_messages":3,"expected_participants":["Alice Example","Participant 2"]}
+213
View File
@@ -0,0 +1,213 @@
/**
* v0.41.29.0 — orphan source-scoping regression suite.
*
* Covers three Codex-flagged behaviors of the `--source` / find_orphans
* source-scoping wave, all at the layer the bugs live (engine +
* getOrphansData + the find_orphans op handler):
*
* - A2: candidate-only scoping. A page in source X linked FROM source Y
* is reachable, so it is NOT an orphan of X (cross-source inbound counts).
* - F6: total_linkable denominator. Excluded NON-orphan pages (e.g. a
* `templates/` page that HAS inbound links) must NOT inflate the
* denominator. Pre-fix the formula `total - excludedOrphans` left them in.
* - F8: the find_orphans MCP op handler scopes by ctx.sourceId AND
* ctx.auth.allowedSources (the v0.34.1 source-isolation read leak).
*
* Runs against PGLite in-memory (both engines share the SQL surface; the
* Postgres path is pinned separately in test/e2e/multi-source-bug-class).
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { findOrphans } from '../src/commands/orphans.ts';
let engine: PGLiteEngine;
const page = (title: string) => ({
type: 'person' as const,
title,
compiled_truth: `${title} body.`,
timeline: '',
frontmatter: {},
});
// Every test in this file is READ-ONLY (findOrphanPages / getOrphansData /
// find_orphans op handler). Seed ONCE in beforeAll rather than reset+reseed
// per test — the canonical R3/R4 pattern (engine in beforeAll, disconnect in
// afterAll) is satisfied, and this cuts the file from ~6min to seconds.
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
await resetPgliteState(engine);
await engine.executeRaw(
`INSERT INTO sources (id, name, config) VALUES ('src-b', 'src-b', '{}'::jsonb) ON CONFLICT DO NOTHING`,
);
// --- default source ---
// alice-orphan: no inbound → orphan.
await engine.putPage('people/alice-orphan', page('Alice Orphan'), { sourceId: 'default' });
// bob-linked: inbound from alice-orphan → NOT orphan.
await engine.putPage('people/bob-linked', page('Bob Linked'), { sourceId: 'default' });
// cross-target: inbound ONLY from a src-b page → A2: NOT orphan of default.
await engine.putPage('people/cross-target', page('Cross Target'), { sourceId: 'default' });
// templates/junk-linked: excluded prefix, HAS inbound → not orphan, and must
// be excluded from total_linkable (F6).
await engine.putPage('templates/junk-linked', page('Junk Template'), { sourceId: 'default' });
// --- src-b source ---
// zara-orphan: no inbound → orphan.
await engine.putPage('people/zara-orphan', page('Zara Orphan'), { sourceId: 'src-b' });
// src-b-linker: links to default's cross-target; itself has no inbound → orphan.
await engine.putPage('people/src-b-linker', page('Src-B Linker'), { sourceId: 'src-b' });
// --- links ---
await engine.addLink(
'people/alice-orphan', 'people/bob-linked', '', 'mentions', 'markdown',
undefined, undefined, { fromSourceId: 'default', toSourceId: 'default' },
);
await engine.addLink(
'people/alice-orphan', 'templates/junk-linked', '', 'mentions', 'markdown',
undefined, undefined, { fromSourceId: 'default', toSourceId: 'default' },
);
// Cross-source inbound: src-b page → default page (A2).
await engine.addLink(
'people/src-b-linker', 'people/cross-target', '', 'mentions', 'markdown',
undefined, undefined, { fromSourceId: 'src-b', toSourceId: 'default' },
);
});
afterAll(async () => {
await engine.disconnect();
});
describe('findOrphanPages — source scoping (A2)', () => {
test('scoped to default: alice-orphan is an orphan; cross-source-linked page is NOT', async () => {
const rows = await engine.findOrphanPages({ sourceId: 'default' });
const slugs = rows.map(r => r.slug);
expect(slugs).toContain('people/alice-orphan');
// A2: cross-target has inbound from src-b → reachable → NOT an orphan.
expect(slugs).not.toContain('people/cross-target');
// bob-linked + templates/junk-linked have intra-source inbound.
expect(slugs).not.toContain('people/bob-linked');
expect(slugs).not.toContain('templates/junk-linked');
// src-b pages must not appear in a default-scoped scan.
expect(slugs).not.toContain('people/zara-orphan');
expect(slugs).not.toContain('people/src-b-linker');
});
test('scoped to src-b returns a different orphan set', async () => {
const rows = await engine.findOrphanPages({ sourceId: 'src-b' });
const slugs = rows.map(r => r.slug).sort();
expect(slugs).toEqual(['people/src-b-linker', 'people/zara-orphan']);
});
test('unscoped (brain-wide) returns the union', async () => {
const rows = await engine.findOrphanPages();
const slugs = rows.map(r => r.slug).sort();
expect(slugs).toEqual([
'people/alice-orphan',
'people/src-b-linker',
'people/zara-orphan',
]);
});
test('federated sourceIds scopes to the union of the given sources', async () => {
const rows = await engine.findOrphanPages({ sourceIds: ['default', 'src-b'] });
const slugs = rows.map(r => r.slug).sort();
expect(slugs).toEqual([
'people/alice-orphan',
'people/src-b-linker',
'people/zara-orphan',
]);
// Single-element federated array behaves like the scalar scope.
const onlyB = await engine.findOrphanPages({ sourceIds: ['src-b'] });
expect(onlyB.map(r => r.slug).sort()).toEqual([
'people/src-b-linker',
'people/zara-orphan',
]);
});
});
describe('getOrphansData — scoped totals + F6 denominator', () => {
test('default scope: counts differ from brain-wide; excluded non-orphan drops from total_linkable (F6)', async () => {
const data = await findOrphans(engine, { includePseudo: false, sourceId: 'default' });
// 4 live default pages; 1 orphan (alice-orphan).
expect(data.total_pages).toBe(4);
expect(data.total_orphans).toBe(1);
expect(data.orphans.map(o => o.slug)).toEqual(['people/alice-orphan']);
// F6: templates/junk-linked is an EXCLUDED page that HAS an inbound link.
// It must NOT count in total_linkable. NEW denominator = total(4) -
// excludedAll(1) = 3. The pre-fix formula (total - excludedOrphans) left
// the excluded non-orphan in and returned 4.
expect(data.total_linkable).toBe(3);
expect(data.total_linkable).toBe(data.total_pages - 1);
});
test('src-b scope: distinct from default', async () => {
const data = await findOrphans(engine, { includePseudo: false, sourceId: 'src-b' });
expect(data.total_pages).toBe(2);
expect(data.total_orphans).toBe(2);
expect(data.total_linkable).toBe(2); // no excluded pages in src-b
});
test('brain-wide F6: excluded non-orphan drops from total_linkable', async () => {
const data = await findOrphans(engine, { includePseudo: false });
expect(data.total_pages).toBe(6);
expect(data.total_orphans).toBe(3);
// 6 live pages - 1 excluded (templates/junk-linked) = 5.
expect(data.total_linkable).toBe(5);
});
test('includePseudo keeps excluded pages in the denominator', async () => {
const data = await findOrphans(engine, { includePseudo: true, sourceId: 'default' });
// No exclusion → denominator is the full live set.
expect(data.total_linkable).toBe(4);
});
});
describe('find_orphans MCP op — F8 source-isolation scope', () => {
test('ctx.sourceId scopes results to that source only', async () => {
const { operations } = await import('../src/core/operations.ts');
const op = operations.find(o => o.name === 'find_orphans');
expect(op).toBeDefined();
const ctx = {
engine,
config: { engine: 'pglite' as const },
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: false,
remote: true,
sourceId: 'src-b',
};
const result = (await op!.handler(ctx as any, {})) as { orphans: { slug: string }[] };
const slugs = result.orphans.map(o => o.slug).sort();
expect(slugs).toEqual(['people/src-b-linker', 'people/zara-orphan']);
// Leak guard: default's orphan must NOT appear.
expect(slugs).not.toContain('people/alice-orphan');
});
test('ctx.auth.allowedSources (federated) scopes to the allowed set', async () => {
const { operations } = await import('../src/core/operations.ts');
const op = operations.find(o => o.name === 'find_orphans');
const ctx = {
engine,
config: { engine: 'pglite' as const },
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: false,
remote: true,
sourceId: 'default', // scalar would scope to default-only...
auth: {
token: 'test',
clientId: 'test',
scopes: ['read'],
sourceId: 'default',
allowedSources: ['src-b'], // ...but the federated array wins.
},
};
const result = (await op!.handler(ctx as any, {})) as { orphans: { slug: string }[] };
const slugs = result.orphans.map(o => o.slug).sort();
expect(slugs).toEqual(['people/src-b-linker', 'people/zara-orphan']);
expect(slugs).not.toContain('people/alice-orphan');
});
});