fix(dream,chronicle): synthesize/concepts output family — source scope, output root, retrieval reach, durable provenance, honest judge failures (#1586 #2415 #2163 #2569 #2606) (#2939)

Five verified-open fixes to the dream/synthesize output family:

- #1586: thread the cycle's resolved sourceId (cycleSourceId) through
  runPhaseSynthesize -> SubagentHandlerData.source_id -> subagent tool
  OperationContext, and stamp collected refs + summary page with the same
  source, so synthesized pages stop landing in 'default'.
- #2415: new config knob dream.synthesize.output_root (default 'wiki',
  zero behavior change unless set) drives the synthesize prompt slug
  templates, the patterns reflection lookup + prompt, and remaps the
  filing-rule allow-list globs. Registered in KNOWN_CONFIG_KEYS.
- #2163: synthesize_concepts writes concept pages through
  importFromContent (put_page's parse->chunk->embed pipeline) instead of
  bare engine.putPage, so concepts/ pages are chunked + embedded and
  reachable by retrieval.
- #2569: stampDreamProvenance persists dream_generated + dream_cycle_date
  into pages.frontmatter (JSONB merge via executeRawJsonb) at write time,
  so generated pages are DB-queryable and put_page write-through can't
  erase the marker.
- #2606: chronicle judge detects stopReason 'length' truncation and
  no-JSON-array parse failures as distinct skipped reasons
  (judge_truncated / judge_parse_failed) instead of a false terminal
  no_events; output cap raised to 4000 and configurable via
  chronicle.judge_max_tokens.

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
This commit is contained in:
Time Attakc
2026-07-17 15:01:27 -07:00
committed by GitHub
co-authored by Sinabina Claude Fable 5 Garry Tan
parent 42375bded5
commit 1833d95896
16 changed files with 519 additions and 78 deletions
File diff suppressed because one or more lines are too long
+50 -8
View File
@@ -26,7 +26,17 @@ export interface ChronicleJudgeInput {
effectiveDate: string | null; // depth page effective_date (deterministic when)
attendees: string[]; // deterministic who from frontmatter
}
export interface ChronicleJudgeResult { events: ChronicleEventProposal[] }
export interface ChronicleJudgeResult {
events: ChronicleEventProposal[];
/**
* #2606 — distinct judge-failure signal so an unusable response is never
* recorded as a legitimate `no_events`:
* - 'truncated': the model hit the output-token cap (stopReason 'length');
* the JSON array was cut mid-stream and must not be parsed as complete.
* - 'parse_failed': the model returned text but no valid JSON array.
*/
failure?: 'truncated' | 'parse_failed';
}
export type ChronicleJudge = (input: ChronicleJudgeInput) => Promise<ChronicleJudgeResult>;
export interface ChronicleExtractResult {
@@ -126,6 +136,12 @@ export async function runChronicleExtract(
return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'judge_error' };
}
// #2606: a truncated or unparseable judge response is a FAILURE, not an
// empty page. Record it as a distinct skipped reason so operators (and
// retries) can tell it apart from a genuine no_events.
if (result?.failure) {
return { slug: opts.slug, status: 'skipped', events_written: 0, reason: `judge_${result.failure}` };
}
const proposals = Array.isArray(result?.events) ? result.events : [];
if (proposals.length === 0) return { slug: opts.slug, status: 'no_events', events_written: 0 };
// PARSE BARRIER — reject the WHOLE batch on any malformed proposal; no partial writes.
@@ -167,11 +183,25 @@ const JUDGE_SYSTEM = `You segment a meeting/transcript page into discrete timeli
Return ONLY a JSON array. Each element: {"when": ISO datetime or YYYY-MM-DD, "who": [entity slugs/names], "what": one-clause summary, "where": optional string, "kind": one of meeting|call|meal|solo|travel|work|commitment|decision|intro|conflict|milestone|event}.
Prefer the page's known date for "when" when the text gives no explicit time. Use the provided attendee slugs for "who" when the text does not name participants. No prose, no markdown — just the JSON array.`;
/**
* #2606: default output-token cap for the judge. Raised from the original
* 1500 (which event-dense pages overflowed, silently truncating the JSON
* array). Override via `chronicle.judge_max_tokens`.
*/
const DEFAULT_JUDGE_MAX_TOKENS = 4000;
function defaultJudge(engine: BrainEngine): ChronicleJudge {
return async (input) => {
const { isAvailable, chat } = await import('../ai/gateway.ts');
if (!isAvailable('chat')) return { events: [] };
const body = (input.body || '').slice(0, 12_000);
// #2606: configurable cap so event-dense pages have headroom.
let maxTokens = DEFAULT_JUDGE_MAX_TOKENS;
const capRaw = await engine.getConfig('chronicle.judge_max_tokens').catch(() => null);
if (capRaw) {
const n = parseInt(capRaw, 10);
if (Number.isFinite(n) && n > 0) maxTokens = n;
}
let text: string;
try {
const res = await chat({
@@ -183,32 +213,44 @@ function defaultJudge(engine: BrainEngine): ChronicleJudge {
`${input.title}\n\n${body}\n</page>\n\n` +
`Known attendees: ${input.attendees.slice(0, 10).join(', ') || '(none)'}.\nExtract the events.`,
}],
maxTokens: 1500,
maxTokens,
});
if (res.stopReason === 'refusal' || res.stopReason === 'content_filter') return { events: [] };
// #2606: output hit the token cap — the JSON array is cut mid-stream.
// Do NOT feed it to the parser as if complete; surface the truncation.
if (res.stopReason === 'length') return { events: [], failure: 'truncated' };
text = res.text;
} catch (err) {
if ((err as Error)?.name === 'AbortError') throw err;
return { events: [] };
}
const parsed = parseJudgeJson(text);
// #2606: non-empty model text with no parseable JSON array is a parse
// failure, distinct from the model legitimately answering `[]`.
if (parsed === null) return { events: [], failure: 'parse_failed' };
return { events: parsed };
};
}
/** Tolerant JSON-array extraction from a model response (mirrors facts parser). */
export function parseJudgeJson(text: string): ChronicleEventProposal[] {
if (!text) return [];
/**
* Tolerant JSON-array extraction from a model response (mirrors facts parser).
*
* #2606: returns `null` on parse FAILURE (empty text, no `[...]` found,
* JSON.parse throw, non-array result) so callers can distinguish "the model
* said no events" (a legitimate `[]`) from "the response was unusable".
*/
export function parseJudgeJson(text: string): ChronicleEventProposal[] | null {
if (!text) return null;
let s = text.trim();
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) s = fence[1].trim();
const start = s.indexOf('[');
const end = s.lastIndexOf(']');
if (start === -1 || end === -1 || end < start) return [];
if (start === -1 || end === -1 || end < start) return null;
try {
const arr = JSON.parse(s.slice(start, end + 1));
return Array.isArray(arr) ? arr : [];
return Array.isArray(arr) ? arr : null;
} catch {
return [];
return null;
}
}
+6
View File
@@ -915,6 +915,8 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'dream.synthesize.verdict_model',
'dream.synthesize.max_prompt_tokens',
'dream.synthesize.max_chunks_per_transcript',
// #2415: top-level namespace for synthesize/patterns output (default 'wiki').
'dream.synthesize.output_root',
'dream.synthesize.subagent_timeout_ms',
'dream.synthesize.subagent_wait_timeout_ms',
'dream.patterns.lookback_days',
@@ -971,6 +973,10 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
// operator had to discover --force by reading source. Same class as the
// spend-controls registration above.
'auto_chronicle',
// #2606: chronicle judge output-token cap (default 4000). Event-dense
// pages overflowed the old hardcoded 1500 and were misrecorded as
// no_events; the cap is now configurable and truncation is surfaced.
'chronicle.judge_max_tokens',
// Takes bootstrap (v0.41.18.0, A12). The onboard remediation's two-gate
// consent reads this key, and enabling it is the documented path to
// `gbrain takes extract --from-pages` — same unregistered-key class.
+3
View File
@@ -1682,6 +1682,9 @@ export async function runCycle(
from: opts.synthFrom,
to: opts.synthTo,
bypassDreamGuard: opts.synthBypassDreamGuard,
// #1586: scope synthesized writes to the cycle's resolved source
// (explicit --source wins, else derived from the checkout dir).
sourceId: cycleSourceId,
}));
result.duration_ms = duration_ms;
phaseResults.push(result);
+19 -31
View File
@@ -19,7 +19,7 @@
*/
import { join, dirname } from 'node:path';
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import type { BrainEngine } from '../engine.ts';
import type { PhaseResult, PhaseError } from '../cycle.ts';
import { MinionQueue } from '../minions/queue.ts';
@@ -27,6 +27,9 @@ import { waitForCompletion, TimeoutError } from '../minions/wait-for-completion.
import type { MinionJobInput, SubagentHandlerData } from '../minions/types.ts';
import { serializeMarkdown } from '../markdown.ts';
import type { Page, PageType } from '../types.ts';
// #2415: allow-list + output-root resolution shared with the synthesize
// phase — both phases must agree on the configured namespace.
import { loadAllowedSlugPrefixes, loadOutputRoot } from './synthesize.ts';
import { probeChatModel } from '../ai/gateway.ts';
import { normalizeModelId } from '../model-id.ts';
@@ -49,7 +52,7 @@ export async function runPhasePatterns(
}
// Gather reflections within lookback window.
const reflections = await gatherReflections(engine, config.lookbackDays);
const reflections = await gatherReflections(engine, config.lookbackDays, config.outputRoot);
if (reflections.length < config.minEvidence) {
return skipped(
'insufficient_evidence',
@@ -81,7 +84,7 @@ export async function runPhasePatterns(
return skipped('no_provider', `pattern detection skipped: ${probe.detail}`);
}
const allowedSlugPrefixes = await loadAllowedSlugPrefixes();
const allowedSlugPrefixes = await loadAllowedSlugPrefixes(config.outputRoot);
if (allowedSlugPrefixes.length === 0) {
return failed(makeError('InternalError', 'NO_ALLOWLIST',
'skills/_brain-filing-rules.json missing dream_synthesize_paths.globs'));
@@ -89,7 +92,7 @@ export async function runPhasePatterns(
const queue = new MinionQueue(engine);
const data: SubagentHandlerData = {
prompt: buildPatternsPrompt(reflections, config.minEvidence),
prompt: buildPatternsPrompt(reflections, config.minEvidence, config.outputRoot),
model: config.model,
max_turns: 30,
allowed_slug_prefixes: allowedSlugPrefixes,
@@ -182,6 +185,8 @@ interface PatternsConfig {
lookbackDays: number;
minEvidence: number;
model: string;
/** #2415: shared output namespace (dream.synthesize.output_root, default 'wiki'). */
outputRoot: string;
/** #1594-family: subagent job timeout, config `dream.patterns.subagent_timeout_ms`. */
subagentTimeoutMs: number;
/** #1594-family: waitForCompletion timeout, config `dream.patterns.subagent_wait_timeout_ms`. */
@@ -216,6 +221,7 @@ async function loadPatternsConfig(engine: BrainEngine): Promise<PatternsConfig>
lookbackDays: lookbackStr ? Math.max(1, parseInt(lookbackStr, 10) || 30) : 30,
minEvidence: minEvidenceStr ? Math.max(1, parseInt(minEvidenceStr, 10) || 3) : 3,
model,
outputRoot: await loadOutputRoot(engine),
subagentTimeoutMs: await getNumberConfig(
engine, 'dream.patterns.subagent_timeout_ms', DEFAULT_PATTERNS_SUBAGENT_TIMEOUT_MS,
),
@@ -236,16 +242,19 @@ interface ReflectionRef {
async function gatherReflections(
engine: BrainEngine,
lookbackDays: number,
outputRoot = 'wiki',
): Promise<ReflectionRef[]> {
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString();
// #2415: reflections live under the configured output root (bound as a
// parameter; outputRoot is slug-grammar-validated by loadOutputRoot).
const rows = await engine.executeRaw<{ slug: string; title: string | null; compiled_truth: string | null }>(
`SELECT slug, title, compiled_truth
FROM pages
WHERE slug LIKE 'wiki/personal/reflections/%'
WHERE slug LIKE $2
AND updated_at >= $1::timestamptz
ORDER BY updated_at DESC
LIMIT 100`,
[since],
[since, `${outputRoot}/personal/reflections/%`],
);
return rows.map(r => ({
slug: r.slug,
@@ -256,7 +265,7 @@ async function gatherReflections(
// ── Prompt ────────────────────────────────────────────────────────────
function buildPatternsPrompt(reflections: ReflectionRef[], minEvidence: number): string {
function buildPatternsPrompt(reflections: ReflectionRef[], minEvidence: number, outputRoot = 'wiki'): string {
const today = new Date().toISOString().slice(0, 10);
const corpus = reflections
.map((r, i) => `### ${i + 1}. [[${r.slug}]] — ${r.title}\n${r.excerpt}`)
@@ -266,15 +275,15 @@ function buildPatternsPrompt(reflections: ReflectionRef[], minEvidence: number):
OUTPUT POLICY
- Only name a pattern if it appears in at least ${minEvidence} DISTINCT reflections.
- Each pattern page MUST cite the reflections that constitute its evidence (use [[wiki/personal/reflections/...]] wikilinks).
- Each pattern page MUST cite the reflections that constitute its evidence (use [[${outputRoot}/personal/reflections/...]] wikilinks).
- Use \`search\` to check whether a similar pattern page already exists; if yes, update it (use the same slug). If no, create a new one.
- Pattern slug format: \`wiki/personal/patterns/<topic-slug>\` (lowercase alphanumeric + hyphens; no underscores, no extension, no date).
- Pattern slug format: \`${outputRoot}/personal/patterns/<topic-slug>\` (lowercase alphanumeric + hyphens; no underscores, no extension, no date).
- A "pattern" is a recurring theme, anxiety, decision pattern, relationship dynamic, or self-knowledge motif. NOT a single insight. NOT a list of unrelated topics.
DO NOT WRITE
- A "patterns from today" digest (that's the dream-cycle-summaries page; not your job).
- Patterns with <${minEvidence} reflections cited.
- Anything outside wiki/personal/patterns/.
- Anything outside ${outputRoot}/personal/patterns/.
CONTEXT
- Today: ${today}
@@ -365,27 +374,6 @@ function renderPageToMarkdown(page: Page, tags: string[]): string {
);
}
// ── Allow-list (shared with synthesize.ts) ───────────────────────────
async function loadAllowedSlugPrefixes(): Promise<string[]> {
const candidates = [
join(process.cwd(), 'skills', '_brain-filing-rules.json'),
join(__dirname, '..', '..', '..', 'skills', '_brain-filing-rules.json'),
];
for (const path of candidates) {
if (!existsSync(path)) continue;
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON.parse(raw) as { dream_synthesize_paths?: { globs?: unknown } };
const globs = parsed?.dream_synthesize_paths?.globs;
if (Array.isArray(globs) && globs.every(g => typeof g === 'string')) {
return globs as string[];
}
} catch { /* try next */ }
}
return [];
}
// ── Status helpers ───────────────────────────────────────────────────
function ok(summary: string, details: Record<string, unknown> = {}): PhaseResult {
+18 -8
View File
@@ -23,7 +23,13 @@ import type { PhaseResult } from '../cycle.ts';
import type { ProgressReporter } from '../progress.ts';
import { writeReceipt } from '../extract/receipt-writer.ts';
import { upsertExtractRollup } from '../extract/rollup-writer.ts';
import { chat as gatewayChat } from '../ai/gateway.ts';
import { chat as gatewayChat, isAvailable } from '../ai/gateway.ts';
// #2163: concept pages route through importFromContent (the same
// parse→chunk→embed pipeline put_page uses) instead of a bare engine.putPage,
// so they land in the retrieval surface (content_chunks + embeddings) where
// source-boost's 1.3× 'concepts/' weighting can actually reach them.
import { importFromContent } from '../import-file.ts';
import { serializeMarkdown } from '../markdown.ts';
const DEFAULT_BUDGET_USD = 1.5;
const TIER_T1_MIN = 10;
@@ -216,19 +222,23 @@ export async function runPhaseSynthesizeConcepts(
if (!opts.dryRun) {
const title = group.conceptSlug.split('/').pop() ?? group.conceptSlug;
await engine.putPage(`concepts/${title}`, {
title: title.replace(/-/g, ' '),
type: 'concept',
compiled_truth: narrative,
frontmatter: {
type: 'concept',
// #2163: serialize to markdown and import via the canonical pipeline so
// the page is chunked (+ embedded when a provider is configured) —
// mirrors put_page's isAvailable('embedding') → noEmbed gate.
const md = serializeMarkdown(
{
tier: group.tier,
mention_count: group.atomTitles.length,
composite_score: group.atomTitles.length,
synthesized_at: new Date().toISOString(),
synthesized_by: 'synthesize_concepts-v0.41',
},
timeline: '',
narrative,
'',
{ type: 'concept', title: title.replace(/-/g, ' '), tags: [] },
);
await importFromContent(engine, `concepts/${title}`, md, {
noEmbed: !isAvailable('embedding'),
});
}
conceptsWritten++;
+119 -21
View File
@@ -244,6 +244,14 @@ export interface SynthesizePhaseOpts {
* the synthesize loop. Caller must opt in explicitly.
*/
bypassDreamGuard?: boolean;
/**
* #1586: the cycle's resolved brain source (cycleSourceId from cycle.ts —
* explicit --source wins, else derived from the checkout dir). Threaded to
* every subagent child as `source_id` so put_page writes land in this
* source, and stamped onto collected refs so reverse-writes read the
* correct (source_id, slug) row. Unset → legacy 'default'.
*/
sourceId?: string;
}
export async function runPhaseSynthesize(
@@ -399,7 +407,7 @@ export async function runPhaseSynthesize(
// Fan-out: submit one subagent per worth-processing transcript (or one
// per chunk for transcripts that exceed the model's per-prompt budget).
const allowedSlugPrefixes = await loadAllowedSlugPrefixes();
const allowedSlugPrefixes = await loadAllowedSlugPrefixes(config.outputRoot);
if (allowedSlugPrefixes.length === 0) {
return failed(makeError('InternalError', 'NO_ALLOWLIST',
'skills/_brain-filing-rules.json missing dream_synthesize_paths.globs'));
@@ -462,10 +470,13 @@ export async function runPhaseSynthesize(
: config.model;
for (let i = 0; i < chunks.length; i++) {
const childData: SubagentHandlerData = {
prompt: buildSynthesisPrompt(t, chunks[i], i, chunks.length, priorContradictionsBlock),
prompt: buildSynthesisPrompt(t, chunks[i], i, chunks.length, priorContradictionsBlock, config.outputRoot),
model: subagentModel,
max_turns: 30,
allowed_slug_prefixes: allowedSlugPrefixes,
// #1586: scope every child tool call to the cycle's resolved source
// so put_page writes land there instead of the hardcoded 'default'.
...(opts.sourceId ? { source_id: opts.sourceId } : {}),
};
// Idempotency key parity:
// - single-chunk → legacy `dream:synth:<filePath>:<hash16>` (byte-
@@ -524,20 +535,29 @@ export async function runPhaseSynthesize(
// bare-hash slugs to `<hash6>-c<idx>` so chunked siblings can't collide
// even if Sonnet drops the chunk suffix.
// v0.32.8: refs carry source_id so reverseWriteRefs picks the correct
// (source, slug) row (currently always 'default' from subagent put_page).
const writtenRefs = await collectChildPutPageSlugs(engine, childIds, chunkInfo);
// (source, slug) row. #1586: refs are stamped with the cycle's resolved
// source (children write there via SubagentHandlerData.source_id).
const cycleSourceId = opts.sourceId ?? 'default';
const writtenRefs = await collectChildPutPageSlugs(engine, childIds, chunkInfo, cycleSourceId);
const summaryDate = opts.date ?? today();
// #2569: persist the dream-output identity marker into the DB frontmatter
// of every child-written page BEFORE reverse-rendering, so generated pages
// are queryable (`frontmatter->>'dream_generated'`) and a later put_page
// write-through (which re-renders from the DB row) can't erase the stamp.
await stampDreamProvenance(engine, writtenRefs, summaryDate);
// Dual-write: reverse-render each DB row → markdown file.
const reverseWriteCount = await reverseWriteRefs(engine, opts.brainDir, writtenRefs);
const reverseWriteCount = await reverseWriteRefs(engine, opts.brainDir, writtenRefs, cycleSourceId);
// Summary index page (deterministic; orchestrator-written via direct
// engine.putPage so no allow-list path needed).
const summaryDate = opts.date ?? today();
const summarySlug = `dream-cycle-summaries/${summaryDate}`;
// Back-compat: writeSummaryPage takes string[] for display; map refs back to slugs.
const writtenSlugs = writtenRefs.map(r => r.slug);
if (SUMMARY_SLUG_RE.test(summarySlug)) {
await writeSummaryPage(engine, opts.brainDir, summarySlug, summaryDate, writtenSlugs, childOutcomes);
await writeSummaryPage(engine, opts.brainDir, summarySlug, summaryDate, writtenSlugs, childOutcomes, cycleSourceId);
}
// Write completion timestamp ON SUCCESS only.
@@ -595,10 +615,29 @@ interface SynthConfig {
* `dream.synthesize.max_chunks_per_transcript`.
*/
maxChunksPerTranscript: number;
/**
* #2415: top-level namespace for synthesized output (reflections, originals,
* patterns). Config key `dream.synthesize.output_root`; default 'wiki' —
* zero behavior change unless set. No trailing slash. Must satisfy the slug
* grammar; invalid values fall back to 'wiki' with a stderr warning.
*/
outputRoot: string;
subagentTimeoutMs: number;
subagentWaitTimeoutMs: number;
}
/** #2415: shared output-root resolution (synthesize + patterns phases). */
export async function loadOutputRoot(engine: BrainEngine): Promise<string> {
const raw = await engine.getConfig('dream.synthesize.output_root');
if (!raw) return 'wiki';
const trimmed = raw.trim().replace(/^\/+|\/+$/g, '');
if (SUMMARY_SLUG_RE.test(trimmed)) return trimmed;
process.stderr.write(
`[dream] dream.synthesize.output_root "${raw}" is not a valid slug prefix; falling back to "wiki".\n`,
);
return 'wiki';
}
async function loadSynthConfig(engine: BrainEngine): Promise<SynthConfig> {
const enabledRaw = await engine.getConfig('dream.synthesize.enabled');
const corpusDir = await engine.getConfig('dream.synthesize.session_corpus_dir');
@@ -672,6 +711,7 @@ async function loadSynthConfig(engine: BrainEngine): Promise<SynthConfig> {
cooldownHours: cooldownHoursStr ? Math.max(0, parseInt(cooldownHoursStr, 10) || 12) : 12,
maxPromptTokens,
maxChunksPerTranscript,
outputRoot: await loadOutputRoot(engine),
subagentTimeoutMs,
subagentWaitTimeoutMs,
};
@@ -704,7 +744,13 @@ async function checkCooldown(
// ── Allow-list source of truth ───────────────────────────────────────
async function loadAllowedSlugPrefixes(): Promise<string[]> {
/**
* #2415: `outputRoot` remaps the canonical `wiki/`-rooted globs to the
* configured namespace (e.g. `notes/personal/reflections/*`). Default 'wiki'
* returns the globs verbatim. Shared by the patterns phase (imported there —
* the two phases must enforce the same allow-list).
*/
export async function loadAllowedSlugPrefixes(outputRoot = 'wiki'): Promise<string[]> {
// Search a few known locations relative to the binary / repo. The first
// hit wins; if none found, return [].
const candidates = [
@@ -718,7 +764,10 @@ async function loadAllowedSlugPrefixes(): Promise<string[]> {
const parsed = JSON.parse(raw) as { dream_synthesize_paths?: { globs?: unknown } };
const globs = parsed?.dream_synthesize_paths?.globs;
if (Array.isArray(globs) && globs.every(g => typeof g === 'string')) {
return globs as string[];
if (outputRoot === 'wiki') return globs as string[];
return (globs as string[]).map(g =>
g.startsWith('wiki/') ? `${outputRoot}/${g.slice('wiki/'.length)}` : g,
);
}
} catch { /* try next */ }
}
@@ -966,6 +1015,7 @@ function buildSynthesisPrompt(
chunkIdx: number,
chunkTotal: number,
priorContradictionsBlock = '',
outputRoot = 'wiki',
): string {
const dateHint = t.inferredDate ?? today();
const baseSlugSegment = sanitizeForSlug(t.basename) || `session-${dateHint}`;
@@ -994,10 +1044,10 @@ OUTPUT POLICY (ALL of these are required)
TASKS
A. Reflections (self-knowledge, pattern recognition, emotional processing):
slug: \`wiki/personal/reflections/${dateHint}-<topic-slug>-${hashSuffix}\`
slug: \`${outputRoot}/personal/reflections/${dateHint}-<topic-slug>-${hashSuffix}\`
B. Originals (new ideas, frames, theses, mental models):
slug: \`wiki/originals/ideas/${dateHint}-<idea-slug>-${hashSuffix}\`
slug: \`${outputRoot}/originals/ideas/${dateHint}-<idea-slug>-${hashSuffix}\`
C. People mentions: search first; if a page exists, do not put_page over it (the orchestrator handles people enrichment via timeline entries — your job is the reflection/original synthesis, NOT modifying existing person pages).
@@ -1038,6 +1088,7 @@ async function collectChildPutPageSlugs(
engine: BrainEngine,
childIds: number[],
chunkInfo: Map<number, { idx: number; hash6: string }>,
sourceId = 'default',
): Promise<Array<{ slug: string; source_id: string }>> {
if (childIds.length === 0) return [];
// Raw fetch — NO SELECT DISTINCT. Preserves per-child slug duplicates so
@@ -1047,10 +1098,10 @@ async function collectChildPutPageSlugs(
//
// v0.32.8: returns Array<{slug, source_id}> instead of string[]. Subagent
// put_page tool schema doesn't expose source_id (subagents are scoped to
// a single source); default to 'default' for the current dream-cycle
// product behavior. Threading the source_id through reverseWriteRefs
// guarantees getPage targets the correct (source, slug) row instead of
// the first DB match.
// a single source). #1586: the orchestrator scopes each child to the
// cycle's resolved source via SubagentHandlerData.source_id, and stamps
// the SAME source here so reverseWriteRefs / provenance reads target the
// correct (source_id, slug) row. Unset → legacy 'default'.
const rows = await engine.executeRaw<{ job_id: number; slug: string }>(
`SELECT job_id,
COALESCE(input->>'slug', (input #>> '{}')::jsonb->>'slug') AS slug
@@ -1066,7 +1117,7 @@ async function collectChildPutPageSlugs(
const ci = chunkInfo.get(r.job_id);
rewritten.add(ci ? rewriteChunkedSlug(r.slug, ci.hash6, ci.idx) : r.slug);
}
return Array.from(rewritten).sort().map(slug => ({ slug, source_id: 'default' }));
return Array.from(rewritten).sort().map(slug => ({ slug, source_id: sourceId }));
}
/**
@@ -1095,12 +1146,52 @@ async function hasLegacySingleChunkCompletion(
return rows.length > 0;
}
// ── Dream-provenance DB stamp (#2569) ────────────────────────────────
/**
* Persist the dream-output identity marker (`dream_generated: true` +
* `dream_cycle_date`) into the `pages.frontmatter` JSONB row for every page
* a synthesize child wrote. Render-time `frontmatterOverrides` alone only
* reach the markdown FILE — the DB row stayed unstamped, so DB consumers
* couldn't enumerate generated pages and a later put_page write-through
* (which re-renders from the DB row) silently erased the marker.
*
* Plain UPDATE through executeRawJsonb (raw object bound to $3::jsonb —
* never JSON.stringify into a ::jsonb cast; engine-parity safe, no new
* engine method). Best-effort per row: a stamp failure never kills the
* phase (the render-time override still covers the file).
*/
async function stampDreamProvenance(
engine: BrainEngine,
refs: Array<{ slug: string; source_id: string }>,
cycleDate: string,
): Promise<void> {
if (refs.length === 0) return;
const { executeRawJsonb } = await import('../sql-query.ts');
for (const { slug, source_id } of refs) {
try {
await executeRawJsonb(
engine,
`UPDATE pages
SET frontmatter = COALESCE(frontmatter, '{}'::jsonb) || $3::jsonb
WHERE slug = $1 AND source_id = $2`,
[slug, source_id],
[{ dream_generated: true, dream_cycle_date: cycleDate }],
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[dream] provenance stamp ${slug}@${source_id} failed: ${msg}\n`);
}
}
}
// ── Reverse-write DB rows → markdown files ───────────────────────────
async function reverseWriteRefs(
engine: BrainEngine,
brainDir: string,
refs: Array<{ slug: string; source_id: string }>,
nativeSourceId = 'default',
): Promise<number> {
let count = 0;
for (const { slug, source_id } of refs) {
@@ -1111,10 +1202,11 @@ async function reverseWriteRefs(
const tags = await engine.getTags(slug, { sourceId: source_id });
try {
const md = renderPageToMarkdown(page, tags);
// v0.32.8 F6: non-default sources land at brainDir/.sources/<id>/<slug>.md
// so same-slug-different-source pages don't collide. Default-source
// pages stay at brainDir/<slug>.md so single-source brains see no change.
const filePath = source_id === 'default'
// v0.32.8 F6: foreign-source pages land at brainDir/.sources/<id>/<slug>.md
// so same-slug-different-source pages don't collide. Pages belonging to
// the cycle's own source (#1586: brainDir IS that source's checkout —
// legacy 'default' when unscoped) stay at brainDir/<slug>.md.
const filePath = source_id === nativeSourceId
? join(brainDir, `${slug}.md`)
: join(brainDir, '.sources', source_id, `${slug}.md`);
mkdirSync(dirname(filePath), { recursive: true });
@@ -1161,6 +1253,7 @@ async function writeSummaryPage(
summaryDate: string,
writtenSlugs: string[],
childOutcomes: Array<{ jobId: number; status: string }>,
sourceId = 'default',
): Promise<void> {
const completed = childOutcomes.filter(c => c.status === 'completed').length;
const failed = childOutcomes.length - completed;
@@ -1198,13 +1291,15 @@ async function writeSummaryPage(
// unnecessarily; we go straight to the engine.
const { parseMarkdown } = await import('../markdown.ts');
const parsed = parseMarkdown(fullMarkdown);
// #1586: summary lands in the cycle's resolved source too — otherwise the
// children live in the named source while the index drifts to 'default'.
await engine.putPage(summarySlug, {
type: parsed.type,
title: parsed.title,
compiled_truth: parsed.compiled_truth,
timeline: parsed.timeline,
frontmatter: parsed.frontmatter,
});
}, { sourceId });
// Also write to disk (orchestrator dual-write).
try {
@@ -1269,4 +1364,7 @@ function makeError(cls: string, code: string, message: string, hint?: string): P
// double-encoded jsonb regression). Not part of the runtime contract.
export const __testing = {
collectChildPutPageSlugs,
buildSynthesisPrompt,
stampDreamProvenance,
reverseWriteRefs,
};
+2
View File
@@ -272,6 +272,8 @@ export function makeSubagentHandler(deps: SubagentDeps) {
config,
brainId: data.brain_id,
allowedSlugPrefixes: data.allowed_slug_prefixes,
// #1586: cycle-resolved source scope for tool-call OperationContexts.
sourceId: data.source_id,
});
const toolDefs = data.allowed_tools && data.allowed_tools.length > 0
? filterAllowedTools(registry, data.allowed_tools)
+17 -1
View File
@@ -27,6 +27,7 @@ import type { GBrainConfig } from '../../config.ts';
import { operations } from '../../operations.ts';
import type { Operation, OperationContext } from '../../operations.ts';
import { paramDefToSchema } from '../../../mcp/tool-defs.ts';
import { validateSourceId } from '../../utils.ts';
import type { ToolCtx, ToolDef } from '../types.ts';
/**
@@ -201,6 +202,13 @@ export interface BuildBrainToolsOpts {
* SubagentHandlerData.allowed_slug_prefixes via the handler.
*/
allowedSlugPrefixes?: readonly string[];
/**
* Brain source every tool-call OperationContext is scoped to (#1586).
* Trusted (flows from SubagentHandlerData.source_id, which only
* PROTECTED_JOB_NAMES-gated submitters can set); validated at build time.
* Unset → legacy 'default'.
*/
sourceId?: string;
}
interface OpContextDeps {
@@ -211,6 +219,7 @@ interface OpContextDeps {
signal?: AbortSignal;
brainId?: string;
allowedSlugPrefixes?: readonly string[];
sourceId?: string;
}
function buildOpContext(deps: OpContextDeps): OperationContext {
@@ -224,7 +233,8 @@ function buildOpContext(deps: OpContextDeps): OperationContext {
},
dryRun: false,
remote: true, // match MCP trust boundary for auto-link skip
sourceId: 'default', // v0.34 D4: required; subagent tools default to host source
// #1586: cycle-resolved source when provided; legacy host default else.
sourceId: deps.sourceId ?? 'default',
jobId: deps.jobId,
subagentId: deps.subagentId,
viaSubagent: true, // FAIL-CLOSED: put_page etc. enforce namespace
@@ -248,6 +258,11 @@ export function buildBrainTools(opts: BuildBrainToolsOpts): ToolDef[] {
op => BRAIN_TOOL_ALLOWLIST.has(op.name) && filter.has(op.name),
);
// #1586: fail fast on a malformed source id before any tool executes
// (defense-in-depth — the seam is trusted, but the value round-trips
// through the job payload).
if (opts.sourceId !== undefined) validateSourceId(opts.sourceId);
return picked.map<ToolDef>(op => {
const schema = op.name === 'put_page'
? namespacedPutPageSchema(op, opts.subagentId, opts.allowedSlugPrefixes)
@@ -277,6 +292,7 @@ export function buildBrainTools(opts: BuildBrainToolsOpts): ToolDef[] {
signal: ctx.signal,
brainId: opts.brainId,
allowedSlugPrefixes: opts.allowedSlugPrefixes,
sourceId: opts.sourceId,
});
const params = (input && typeof input === 'object') ? input as Record<string, unknown> : {};
return op.handler(opCtx, params);
+11
View File
@@ -455,6 +455,17 @@ export interface SubagentHandlerData {
* and direct CLI submitters set it.
*/
allowed_slug_prefixes?: string[];
/**
* Brain source the subagent's tool calls are scoped to (#1586).
*
* When set, every tool-call `OperationContext.sourceId` uses this value
* instead of the legacy 'default', so put_page writes land in the cycle's
* resolved source. Same trust story as `allowed_slug_prefixes`:
* PROTECTED_JOB_NAMES gates subagent submission, so only cycle.ts and
* direct CLI submitters can set it. Validated via `validateSourceId` at
* tool-registry build time.
*/
source_id?: string;
/**
* v0.41 Approach C: opt out of the auto-generated tool-usage preamble
* that `buildSystemPrompt()` splices into `system`. Default behavior
+35
View File
@@ -146,6 +146,41 @@ describe('buildBrainTools', () => {
),
).rejects.toBeInstanceOf(OperationError);
});
// #1586: sourceId threads through buildBrainTools → buildOpContext →
// put_page → importFromContent, so subagent writes land in the cycle's
// resolved source instead of the hardcoded 'default'.
test('execute() on put_page writes to the configured sourceId (#1586)', async () => {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
VALUES ('mybrain', 'My Brain', '/tmp/mybrain', '{}'::jsonb, false, now())
ON CONFLICT (id) DO NOTHING`,
);
const tools = buildBrainTools({
subagentId: 42,
engine,
config,
allowedSlugPrefixes: ['wiki/personal/reflections/*'],
sourceId: 'mybrain',
});
const putPage = tools.find(t => t.name === 'brain_put_page');
const ctx: ToolCtx = { engine, jobId: 1, remote: true };
await putPage!.execute(
{ slug: 'wiki/personal/reflections/2026-07-17-scoped', content: '---\ntitle: Scoped\n---\nbody' },
ctx,
);
const rows = await engine.executeRaw<{ source_id: string }>(
`SELECT source_id FROM pages WHERE slug = 'wiki/personal/reflections/2026-07-17-scoped'`,
);
expect(rows.length).toBe(1);
expect(rows[0].source_id).toBe('mybrain');
});
test('buildBrainTools rejects a malformed sourceId at build time (#1586)', () => {
expect(() =>
buildBrainTools({ subagentId: 1, engine, config, sourceId: '../evil' }),
).toThrow();
});
});
describe('filterAllowedTools', () => {
+39 -1
View File
@@ -8,7 +8,7 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { isChronicleEligible } from '../src/core/chronicle/eligibility.ts';
import { runChronicleExtract, type ChronicleJudge } from '../src/core/chronicle/extract-events.ts';
import { runChronicleExtract, parseJudgeJson, type ChronicleJudge } from '../src/core/chronicle/extract-events.ts';
import { runChronicleBackstop } from '../src/core/chronicle/backstop.ts';
let engine: PGLiteEngine;
@@ -111,6 +111,44 @@ describe('runChronicleExtract', () => {
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: none });
expect(r.status).toBe('no_events');
});
// #2606: a truncated or unparseable judge response must NOT be recorded as
// a legitimate no_events — it gets a distinct skipped reason.
test('truncated judge output → skipped/judge_truncated, not no_events (#2606)', async () => {
const truncated: ChronicleJudge = async () => ({ events: [], failure: 'truncated' });
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: truncated });
expect(r.status).toBe('skipped');
expect(r.reason).toBe('judge_truncated');
expect(await countEvents()).toBe(0);
});
test('unparseable judge output → skipped/judge_parse_failed (#2606)', async () => {
const parseFailed: ChronicleJudge = async () => ({ events: [], failure: 'parse_failed' });
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: parseFailed });
expect(r.status).toBe('skipped');
expect(r.reason).toBe('judge_parse_failed');
});
});
describe('parseJudgeJson failure signalling (#2606)', () => {
test('a legitimate empty array parses to []', () => {
expect(parseJudgeJson('[]')).toEqual([]);
expect(parseJudgeJson('```json\n[]\n```')).toEqual([]);
});
test('a valid array round-trips', () => {
const arr = parseJudgeJson('[{"when":"2026-06-18","who":[],"what":"x","kind":"meeting"}]');
expect(Array.isArray(arr)).toBe(true);
expect(arr!.length).toBe(1);
});
test('empty / no-array / truncated / non-array responses return null', () => {
expect(parseJudgeJson('')).toBeNull();
expect(parseJudgeJson('I found no events worth extracting.')).toBeNull();
// Truncated mid-array (the maxTokens-cap shape from the issue).
expect(parseJudgeJson('[{"when":"2026-06-18","who":["a"],"what":"long ev')).toBeNull();
expect(parseJudgeJson('{"events": 1}')).toBeNull();
});
});
describe('runChronicleBackstop gating', () => {
+112
View File
@@ -0,0 +1,112 @@
/**
* #2415 — configurable dream output namespace (`dream.synthesize.output_root`).
*
* The synthesize + patterns phases previously hardcoded `wiki/` in the
* subagent prompt slug templates, the patterns reflection lookup, and the
* trusted-workspace allow-list loaded from skills/_brain-filing-rules.json.
* This suite pins:
* - default 'wiki' → byte-identical prompt + verbatim filing-rule globs
* (zero behavior change unless the key is set);
* - a custom root remaps prompt slug templates and the allow-list globs;
* - loadOutputRoot validates against the slug grammar (bad values fall
* back to 'wiki');
* - the patterns phase gathers reflections under the configured root.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { __testing, loadAllowedSlugPrefixes, loadOutputRoot } from '../src/core/cycle/synthesize.ts';
import { runPhasePatterns } from '../src/core/cycle/patterns.ts';
import type { DiscoveredTranscript } from '../src/core/cycle/transcript-discovery.ts';
const { buildSynthesisPrompt } = __testing;
const transcript: DiscoveredTranscript = {
filePath: '/tmp/t.txt',
basename: 't',
content: 'User: hello world',
contentHash: 'abcdef0123456789',
inferredDate: '2026-07-17',
} as DiscoveredTranscript;
describe('#2415: buildSynthesisPrompt output root', () => {
test('defaults to wiki/ slug templates', () => {
const prompt = buildSynthesisPrompt(transcript, 'chunk', 0, 1);
expect(prompt).toContain('wiki/personal/reflections/2026-07-17-');
expect(prompt).toContain('wiki/originals/ideas/2026-07-17-');
});
test('custom root replaces wiki/ in both slug templates', () => {
const prompt = buildSynthesisPrompt(transcript, 'chunk', 0, 1, '', 'notes');
expect(prompt).toContain('notes/personal/reflections/2026-07-17-');
expect(prompt).toContain('notes/originals/ideas/2026-07-17-');
expect(prompt).not.toContain('wiki/personal/reflections/');
expect(prompt).not.toContain('wiki/originals/ideas/');
});
});
describe('#2415: loadAllowedSlugPrefixes remap', () => {
// Runs from the repo root, so skills/_brain-filing-rules.json resolves.
test("default 'wiki' returns the filing-rule globs verbatim", async () => {
const globs = await loadAllowedSlugPrefixes();
expect(globs).toContain('wiki/personal/reflections/*');
expect(globs).toContain('dream-cycle-summaries/*');
});
test('custom root remaps only wiki/-rooted globs', async () => {
const globs = await loadAllowedSlugPrefixes('notes');
expect(globs).toContain('notes/personal/reflections/*');
expect(globs).toContain('notes/originals/*');
expect(globs).toContain('notes/personal/patterns/*');
// Non-wiki globs pass through untouched.
expect(globs).toContain('dream-cycle-summaries/*');
expect(globs.some(g => g.startsWith('wiki/'))).toBe(false);
});
});
describe('#2415: loadOutputRoot validation + patterns gather scope', () => {
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
test('unset → wiki; trailing slash trimmed; invalid → wiki fallback', async () => {
expect(await loadOutputRoot(engine)).toBe('wiki');
await engine.setConfig('dream.synthesize.output_root', 'notes/');
expect(await loadOutputRoot(engine)).toBe('notes');
await engine.setConfig('dream.synthesize.output_root', '../escape');
expect(await loadOutputRoot(engine)).toBe('wiki');
await engine.setConfig('dream.synthesize.output_root', 'Bad_Root');
expect(await loadOutputRoot(engine)).toBe('wiki');
});
test('patterns phase gathers reflections under the configured root', async () => {
await engine.setConfig('dream.synthesize.output_root', 'notes');
for (let i = 0; i < 3; i++) {
await engine.putPage(`notes/personal/reflections/2026-07-17-r${i}`, {
type: 'note',
title: `R${i}`,
compiled_truth: `reflection ${i}`,
timeline: '',
frontmatter: {},
});
}
// A wiki/-rooted reflection must NOT be counted under the custom root.
await engine.putPage('wiki/personal/reflections/2026-07-17-old', {
type: 'note',
title: 'Old',
compiled_truth: 'legacy reflection',
timeline: '',
frontmatter: {},
});
const result = await runPhasePatterns(engine, { brainDir: '/tmp', dryRun: true });
expect(result.status).toBe('ok');
expect(result.details?.reflections_considered).toBe(3);
});
});
+6 -2
View File
@@ -74,8 +74,12 @@ describe('patterns phase wiring', () => {
});
describe('patterns scope filter', () => {
test('filters reflections by slug LIKE wiki/personal/reflections/%', () => {
expect(patternsSrc).toContain("slug LIKE 'wiki/personal/reflections/%'");
test('filters reflections by slug LIKE <output_root>/personal/reflections/%', () => {
// #2415: the namespace root is configurable (dream.synthesize.output_root,
// default 'wiki') and bound as a parameter — the scope filter itself and
// the reflections sub-path stay pinned.
expect(patternsSrc).toContain('slug LIKE $2');
expect(patternsSrc).toContain('/personal/reflections/%');
});
test('orders by updated_at DESC for recency-bias', () => {
+49 -1
View File
@@ -21,7 +21,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { __testing } from '../src/core/cycle/synthesize.ts';
const { collectChildPutPageSlugs } = __testing;
const { collectChildPutPageSlugs, stampDreamProvenance } = __testing;
let engine: PGLiteEngine;
@@ -103,4 +103,52 @@ describe('C6: collectChildPutPageSlugs survives double-encoded jsonb (#745)', ()
// Function silently drops rows whose slug resolves to null/empty.
expect(refs.map((r: { slug: string }) => r.slug)).not.toContain('no-slug');
});
// #1586: refs are stamped with the cycle's resolved source, not a
// hardcoded 'default'.
test('stamps refs with the provided cycle sourceId (#1586)', async () => {
const refs = await collectChildPutPageSlugs(engine as any, [1001], new Map(), 'mybrain');
expect(refs.length).toBeGreaterThan(0);
for (const r of refs) expect(r.source_id).toBe('mybrain');
});
test('defaults to source_id=default when no sourceId is passed (legacy)', async () => {
const refs = await collectChildPutPageSlugs(engine as any, [1001], new Map());
expect(refs.length).toBeGreaterThan(0);
for (const r of refs) expect(r.source_id).toBe('default');
});
});
describe('#2569: stampDreamProvenance persists the marker into DB frontmatter', () => {
test('merges dream_generated + dream_cycle_date into pages.frontmatter', async () => {
await engine.putPage('wiki/originals/ideas/2026-07-17-stamp-me-abc123', {
type: 'note',
title: 'Stamp me',
compiled_truth: 'body',
timeline: '',
frontmatter: { keep_me: 'yes' },
});
await stampDreamProvenance(
engine as any,
[{ slug: 'wiki/originals/ideas/2026-07-17-stamp-me-abc123', source_id: 'default' }],
'2026-07-17',
);
const rows = await engine.executeRaw<{ fm: Record<string, unknown> }>(
`SELECT frontmatter AS fm FROM pages WHERE slug = 'wiki/originals/ideas/2026-07-17-stamp-me-abc123'`,
);
expect(rows.length).toBe(1);
const fm = rows[0].fm as Record<string, unknown>;
// The stamp lands as real JSONB values (queryable via ->>), not a
// double-encoded string scalar.
expect(fm.dream_generated).toBe(true);
expect(fm.dream_cycle_date).toBe('2026-07-17');
// Merge, not replace: pre-existing frontmatter keys survive.
expect(fm.keep_me).toBe('yes');
});
test('is idempotent and never throws for a missing page', async () => {
const refs = [{ slug: 'wiki/originals/ideas/does-not-exist', source_id: 'default' }];
await stampDreamProvenance(engine as any, refs, '2026-07-17'); // no throw
await stampDreamProvenance(engine as any, refs, '2026-07-17'); // idempotent
});
});
@@ -316,4 +316,32 @@ describe('v0.41 T6: runPhaseSynthesizeConcepts via stubbed chat', () => {
);
expect(rows[0].compiled_truth).toContain('Custom synthesized narrative');
});
// #2163: concept pages must enter the retrieval surface. The write routes
// through importFromContent (the same parse→chunk pipeline put_page uses),
// so content_chunks rows exist and source-boost's 1.3× 'concepts/' weight
// has something to boost. (Embeddings are skipped in this env — no
// provider — but chunks + search_vector land regardless.)
test('concept pages are chunked (#2163)', async () => {
const atoms = Array.from({ length: 12 }, (_, i) => ({
slug: `c${i}`,
title: `Chunk atom ${i}`,
body: `Chunky body ${i}.`,
concept_refs: ['chunked-concept'],
}));
const chat = stubChat('A concept narrative long enough to produce at least one chunk.');
await runPhaseSynthesizeConcepts(engine, { _atoms: atoms, _chat: chat });
const rows = await engine.executeRaw<{ n: number }>(
`SELECT count(*)::int AS n
FROM content_chunks c JOIN pages p ON p.id = c.page_id
WHERE p.slug = 'concepts/chunked-concept'`,
);
expect(Number(rows[0].n)).toBeGreaterThan(0);
// Page metadata survives the importFromContent round-trip.
const page = await engine.executeRaw<{ type: string; fm: Record<string, unknown> }>(
`SELECT type, frontmatter AS fm FROM pages WHERE slug = 'concepts/chunked-concept'`,
);
expect(page[0].type).toBe('concept');
expect((page[0].fm as Record<string, unknown>).tier).toBe('T1');
});
});