Files
gbrain/src/core/cycle/patterns.ts
Ingmar KruschandGitHub dde1bd9353 fix(patterns): make reflections/patterns slug sub-paths configurable (#3389)
* fix(patterns): make reflections/patterns slug sub-paths configurable

gatherReflections()'s SQL WHERE clause and the pattern-page write slug
were hardcoded to wiki/personal/reflections/ and wiki/personal/patterns/
respectively. A prior fix (#2415/#2939) made the leading namespace root
configurable via dream.synthesize.output_root, but the personal/reflections
and personal/patterns sub-path segments stayed pinned literals, so brains
whose schema has no personal/ nesting (e.g. a flat meetings/ tree) could
not point the phase at their own compiled_truth source.

Adds two new config keys:
- dream.patterns.source_slug_prefix (default: <output_root>/personal/reflections)
- dream.patterns.output_slug_prefix (default: <output_root>/personal/patterns)

Both default to the exact literal the code previously hardcoded, so
existing installs see no behavior change. A custom output_slug_prefix is
also added to the subagent's put_page allow-list, since the filing-rules
JSON globs only remap the wiki/personal/patterns/* literal by output_root
and would otherwise reject writes to a differently-shaped output path.

Updated test/cycle-patterns.test.ts's scope-filter assertions to match;
added coverage for the two new config keys and the allow-list addition.

* fix(patterns): drain PGLite subagent job inline (no worker claims it)

runPhasePatterns submitted a subagent job via queue.add() and waited on
it via waitForCompletion, but on PGLite there is no separate Minions
worker process (the embedded data-dir holds an exclusive file lock;
'gbrain jobs work' refuses to start against it). synthesize.ts already
has runPgliteSubagentsInline to drive the claim -> run -> complete loop
inline for exactly this reason; patterns.ts never called it, so a real
(non-dry-run) invocation against a PGLite brain always hung until
subagentWaitTimeoutMs (default 35 min) with the job stuck in 'waiting'.

Exports runPgliteSubagentsInline from synthesize.ts (was test-only via
__testing) and calls it from patterns.ts with the same private
per-run childQueueName derivation synthesize.ts uses, so the inline
drain never claims unrelated 'default'-queue jobs a Postgres worker
owns.

Updated test/cycle-patterns-child-outcome.test.ts's #2782 regression
test: its premise (no worker running with a 1ms wait timeout, so the
job never completes and waitForCompletion genuinely times out) is
exactly the scenario this fix addresses. With the inline drain, a fake
ANTHROPIC_API_KEY test fixture now gets claimed and actually attempted,
failing fast and landing the job in 'dead' rather than staying
uncompleted until a timeout. The #2782 status-reflects-outcome contract
the test exists to pin is unchanged (any non-'complete' outcome with
zero writes still surfaces as status 'fail'); updated the expected
outcome/error code to match the outcome that now actually occurs.

* feat(think): surface usage/cost_usd in --json output

think's own cost was previously unsurfaced anywhere: not in this CLI's
own --json output, not in budget_ledger (nothing in src/core/think/*.ts
ever writes to it), and invisible to a wrapping caller's own token
accounting since the LLM call think makes is its own, separate API
call from anything the caller's session tracks.

runThink() already captured result.usage.{input_tokens,output_tokens}
from the underlying client.create() call but discarded it. Adds
usage/cost_usd to ThinkResult, populates usage on the real-LLM-call
path (undefined on the no-client/stub paths, matching how synthesisOk
already distinguishes those), and computes cost_usd in think.ts's CLI
handler via the existing canonicalLookup() pricing table (same pattern
brain-score-recommendations.ts's estimateAnthropicCost already uses).
Extracted the multiply-and-sum into a small exported computeThinkCostUsd
for direct unit testing. Also appends the cost to the human-readable
footer.

Verified live: gbrain think --json against a real anchor returned
usage:{input_tokens:3271,output_tokens:1490}, cost_usd:0.0536, matching
Opus pricing ($5/$25 per MTok) by hand calculation.
2026-07-27 14:11:36 -07:00

561 lines
24 KiB
TypeScript

/**
* Patterns phase (v0.23) — cross-session theme detection.
*
* Reads recent reflections (within `lookback_days`), runs a single Sonnet
* subagent to surface themes that recur across ≥`min_evidence` distinct
* reflections, and writes one pattern page per theme.
*
* MUST run after `extract` so the graph state (links, timeline) is fresh.
* Subagent put_page calls have ctx.remote=true; the trusted-workspace
* allow-list re-enables auto-link / auto-timeline for synth + pattern
* writes (operations.ts:trustedWorkspace branch).
*
* v1 behavior:
* - Single Sonnet subagent (no fan-out — one job per cycle is plenty).
* - Idempotent: if reflection set is below `min_evidence`, phase is skipped.
* - Pattern slug uses LLM's chosen topic-slug (subagent prompt instructs format).
* - Existing pattern pages are updated in place via put_page (idempotent
* ON CONFLICT semantics in importFromContent).
*/
import { join, dirname } from 'node:path';
import { mkdirSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import type { BrainEngine } from '../engine.ts';
import type { PhaseResult, PhaseError } from '../cycle.ts';
import { MinionQueue } from '../minions/queue.ts';
import { waitForCompletion, TimeoutError } from '../minions/wait-for-completion.ts';
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.
// runPgliteSubagentsInline is shared too: PGLite has no separate Minions
// worker process (the embedded data-dir holds an exclusive file lock), so a
// job submitted via queue.add() sits in 'waiting' forever unless something
// drives the claim -> run -> complete loop inline. synthesize.ts already
// does this for its own children; patterns.ts previously submitted and
// waited without ever draining, so every real (non-dry-run) invocation on a
// PGLite brain hung until subagentWaitTimeoutMs (default 35 min).
import { loadAllowedSlugPrefixes, loadOutputRoot, runPgliteSubagentsInline } from './synthesize.ts';
import { probeChatModel } from '../ai/gateway.ts';
import { normalizeModelId } from '../model-id.ts';
export interface PatternsPhaseOpts {
brainDir: string;
dryRun: boolean;
yieldDuringPhase?: () => Promise<void>;
/**
* issue #2860 — `gbrain dream --phase patterns --once`. Bypasses the
* `dream.patterns.enabled` gate for THIS call only; never reads or
* writes config.
*/
once?: boolean;
/**
* Absolute deadline (epoch ms) of the enclosing minion job, or null for
* direct callers (`gbrain dream`). When set, the subagent's job timeout
* and the wait timeout are clamped so the phase finishes (or times out)
* BEFORE the parent job's budget expires — a fixed 30/35-min default
* inside an interval-derived cycle budget dead-letters the whole cycle
* mid-phase and starves every tail phase (#2781).
*/
deadlineAtMs?: number | null;
}
/**
* Stop-margin reserved under the parent deadline when clamping subagent
* budgets. NOT a promise that tail phases complete — the cycle is allowed
* to go partial and resume next tick. This only guarantees the phase's
* wait returns and the handler unwinds cleanly before the worker's abort
* fires: wait poll interval (5s) + worker force-evict grace (30s) + lock
* and DB cleanup headroom.
*/
export const CYCLE_DEADLINE_RESERVE_MS = 60 * 1000;
/**
* Smallest remaining budget worth submitting a subagent for. Below this,
* the LLM call is near-certain to be killed mid-flight — wasted spend and
* a guaranteed-timeout child — so the phase skips honestly instead
* (`insufficient_cycle_budget`) and the next cycle retries with a fresh
* budget.
*/
export const MIN_PATTERNS_SUBAGENT_BUDGET_MS = 2 * 60 * 1000;
/**
* Clamp the configured subagent budgets to the remaining parent-job time.
* Both timeouts derive from the SAME absolute child deadline
* (`deadlineAtMs - reserve`) so the child job's kill switch and our wait
* agree. Returns null when the remaining budget is below the minimum —
* caller should skip the phase without submitting.
*/
export function clampSubagentBudgets(
config: { subagentTimeoutMs: number; subagentWaitTimeoutMs: number },
deadlineAtMs: number | null | undefined,
nowMs: number,
): { timeoutMs: number; waitTimeoutMs: number } | null {
if (deadlineAtMs == null) {
return { timeoutMs: config.subagentTimeoutMs, waitTimeoutMs: config.subagentWaitTimeoutMs };
}
const childBudgetMs = deadlineAtMs - CYCLE_DEADLINE_RESERVE_MS - nowMs;
if (childBudgetMs < MIN_PATTERNS_SUBAGENT_BUDGET_MS) return null;
return {
timeoutMs: Math.min(config.subagentTimeoutMs, childBudgetMs),
waitTimeoutMs: Math.min(config.subagentWaitTimeoutMs, childBudgetMs),
};
}
export async function runPhasePatterns(
engine: BrainEngine,
opts: PatternsPhaseOpts,
): Promise<PhaseResult> {
const start = Date.now();
try {
const config = await loadPatternsConfig(engine);
if (!config.enabled) {
if (!opts.once) {
return skipped('disabled', 'dream.patterns.enabled is false');
}
process.stderr.write(
'[dream] --once: dream.patterns.enabled is false but ' +
'--phase patterns --once forces this run (config untouched)\n',
);
}
// Gather reflections within lookback window.
const reflections = await gatherReflections(engine, config.lookbackDays, config.sourceSlugPrefix);
if (reflections.length < config.minEvidence) {
return skipped(
'insufficient_evidence',
`${reflections.length} reflections in last ${config.lookbackDays}d (need ≥${config.minEvidence})`,
);
}
if (opts.dryRun) {
return ok(`dry-run: would detect patterns over ${reflections.length} reflections`, {
reflections_considered: reflections.length,
patterns_written: 0,
dryRun: true,
});
}
// Submit one subagent for pattern detection. The subagent dispatches via
// the gateway model-tier resolver, so gate on "is the resolved model's
// provider reachable" rather than ANTHROPIC_API_KEY specifically — a
// hardcoded env gate misclassified non-Anthropic stacks (litellm,
// deepseek, openrouter, ...) as "no upstream" even though the subagent
// routes them through the gateway (agent.use_gateway_loop), and it missed
// Anthropic keys set via `gbrain config set anthropic_api_key`. Same
// probe semantics as think/index.ts + synthesize's makeJudgeClient:
// unknown provider/model or Anthropic-without-key skips cheaply; other
// providers' auth is checked lazily at dispatch and surfaces in the job
// outcome. (Takeover of PR #2279's intent by @brettdavies.)
const probe = probeChatModel(normalizeModelId(config.model));
if (!probe.ok) {
return skipped('no_provider', `pattern detection skipped: ${probe.detail}`);
}
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'));
}
// A configured dream.patterns.output_slug_prefix diverging from the
// default `${outputRoot}/personal/patterns` composition (e.g. a flat
// schema with no personal/ nesting) is not covered by the filing-rules
// globs above, which only remap the `wiki/personal/patterns/*` literal
// by outputRoot. Add it explicitly so the subagent's put_page allow-list
// actually grants write access to wherever it's configured to write.
const outputGlob = `${config.outputSlugPrefix}/*`;
if (!allowedSlugPrefixes.includes(outputGlob)) {
allowedSlugPrefixes.push(outputGlob);
}
// #2781: budget the subagent from the REMAINING parent-job time, not
// the fixed config default. Checked after the cheap gates (disabled /
// insufficient_evidence / no_provider) so a skip for budget reasons
// only fires when the phase would otherwise have submitted.
const budgets = clampSubagentBudgets(config, opts.deadlineAtMs, Date.now());
if (budgets === null) {
return skipped(
'insufficient_cycle_budget',
`remaining cycle budget under ${Math.round(MIN_PATTERNS_SUBAGENT_BUDGET_MS / 1000)}s ` +
`(reserve ${Math.round(CYCLE_DEADLINE_RESERVE_MS / 1000)}s); next cycle retries with a fresh budget`,
);
}
const queue = new MinionQueue(engine);
// PGLite children drain inline (no separate worker can open the embedded
// data-dir), so give this job a private per-run queue: the inline drain
// must never claim unrelated 'default'-queue jobs a Postgres worker owns.
// Mirrors synthesize.ts's childQueueName derivation exactly.
const childQueueName = engine.kind === 'pglite'
? `dream-inline-${Date.now()}-${randomUUID().slice(0, 8)}`
: 'default';
const data: SubagentHandlerData = {
prompt: buildPatternsPrompt(reflections, config.minEvidence, config.sourceSlugPrefix, config.outputSlugPrefix),
model: config.model,
max_turns: 30,
allowed_slug_prefixes: allowedSlugPrefixes,
};
const submitOpts: Partial<MinionJobInput> = {
max_stalled: 3,
timeout_ms: budgets.timeoutMs,
queue: childQueueName,
};
const job = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
allowProtectedSubmit: true,
});
// PGLite cannot run a separate Minions worker because the embedded DB
// holds an exclusive file lock. Drain this phase's private child queue
// inline so the parent observes the terminal state instead of polling
// waitForCompletion until subagentWaitTimeoutMs expires. No-op on
// Postgres (a real worker process claims the job there).
await runPgliteSubagentsInline(engine, queue, childQueueName, opts.yieldDuringPhase);
let outcome: string;
try {
const final = await waitForCompletion(queue, job.id, {
timeoutMs: budgets.waitTimeoutMs,
pollMs: 5 * 1000,
});
outcome = final.status;
} catch (e) {
if (e instanceof TimeoutError) {
outcome = 'timeout';
// The child's own timeout_ms clock starts at ITS claim, not at
// submit — a child that sat queued behind other work can outlive
// the parent deadline this wait was clamped to. Cancel it so the
// subagent can't keep spending/writing after the phase gave up
// (waiting child → cancelled immediately; active child → lock
// stripped, worker abort fires on next renew tick).
try { await queue.cancelJob(job.id); } catch { /* best-effort */ }
} else {
throw e;
}
}
if (opts.yieldDuringPhase) {
try { await opts.yieldDuringPhase(); } catch { /* best-effort */ }
}
// Collect refs the subagent wrote (codex finding #2 — query tool exec rows).
// v0.32.8: refs carry source_id so reverseWriteRefs targets the right
// (source, slug) row instead of the first DB match.
const writtenRefs = await collectChildPutPageSlugs(engine, [job.id]);
// Reverse-write to fs.
const reverseWriteCount = await reverseWriteRefs(engine, opts.brainDir, writtenRefs);
const details = {
reflections_considered: reflections.length,
patterns_written: writtenRefs.length,
reverse_write_count: reverseWriteCount,
child_outcome: outcome,
job_id: job.id,
};
// #2782: the phase status must reflect the child outcome. Pre-fix this
// returned status:ok even when the subagent timed out (e.g. no
// subagent-capable worker slot free for the whole wait window) and zero
// pattern pages were written — a silent no-op for days.
if (outcome !== 'complete') {
if (writtenRefs.length === 0) {
return {
phase: 'patterns',
status: 'fail',
duration_ms: 0,
summary: `pattern-detection subagent job ${job.id} ended '${outcome}'; nothing was written`,
details,
error: makeError(
outcome === 'timeout' ? 'Timeout' : 'InternalError',
`PATTERNS_CHILD_${outcome.toUpperCase()}`,
`subagent job ${job.id} outcome '${outcome}' with zero pattern pages written`,
outcome === 'timeout'
? 'A timeout with zero writes usually means no subagent-capable worker claimed the job. Check `gbrain jobs list` and worker capacity.'
: undefined,
),
};
}
// Partial: the child died/timed out but some pages landed first.
return {
phase: 'patterns',
status: 'warn',
duration_ms: 0,
summary: `${writtenRefs.length} pattern page(s) written but subagent job ${job.id} ended '${outcome}'`,
details,
};
}
return ok(`${writtenRefs.length} pattern page(s) written/updated (${outcome})`, details);
} catch (e) {
return failed(makeError('InternalError', 'PATTERNS_PHASE_FAIL',
e instanceof Error ? (e.message || 'patterns phase threw') : String(e)));
} finally {
void start;
}
}
// ── Config ────────────────────────────────────────────────────────────
interface PatternsConfig {
enabled: boolean;
lookbackDays: number;
minEvidence: number;
model: string;
/** #2415: shared output namespace (dream.synthesize.output_root, default 'wiki'). */
outputRoot: string;
/**
* Slug prefix `gatherReflections` reads from (SQL `LIKE` scope). Defaults
* to `${outputRoot}/personal/reflections`, matching pre-existing behavior.
* Config `dream.patterns.source_slug_prefix` overrides it for brains whose
* schema has no `personal/reflections/` convention (e.g. a flat
* `meetings/` tree) so the phase can read from wherever compiled_truth
* excerpts actually live.
*/
sourceSlugPrefix: string;
/**
* Slug prefix new pattern pages are written under. Defaults to
* `${outputRoot}/personal/patterns`, matching pre-existing behavior.
* Config `dream.patterns.output_slug_prefix` overrides it.
*/
outputSlugPrefix: 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`. */
subagentWaitTimeoutMs: number;
}
const DEFAULT_PATTERNS_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
const DEFAULT_PATTERNS_SUBAGENT_WAIT_TIMEOUT_MS = 35 * 60 * 1000;
async function getNumberConfig(engine: BrainEngine, key: string, fallback: number): Promise<number> {
const raw = await engine.getConfig(key);
if (raw === undefined || raw === null) return fallback;
const value = Number(raw);
return Number.isNaN(value) ? fallback : value;
}
/** Trims leading/trailing slashes from a config-supplied slug prefix; falls back to `fallback` when unset or empty after trimming. */
async function getSlugPrefixConfig(engine: BrainEngine, key: string, fallback: string): Promise<string> {
const raw = await engine.getConfig(key);
if (!raw) return fallback;
const trimmed = raw.trim().replace(/^\/+|\/+$/g, '');
return trimmed || fallback;
}
async function loadPatternsConfig(engine: BrainEngine): Promise<PatternsConfig> {
const enabledStr = await engine.getConfig('dream.patterns.enabled');
const enabled = enabledStr === null ? true : enabledStr === 'true';
const lookbackStr = await engine.getConfig('dream.patterns.lookback_days');
const minEvidenceStr = await engine.getConfig('dream.patterns.min_evidence');
// v0.28: unified model resolution
const { resolveModel } = await import('../model-config.ts');
const model = await resolveModel(engine, {
configKey: 'models.dream.patterns',
deprecatedConfigKey: 'dream.patterns.model',
tier: 'reasoning',
fallback: 'sonnet',
});
const outputRoot = await loadOutputRoot(engine);
return {
enabled,
lookbackDays: lookbackStr ? Math.max(1, parseInt(lookbackStr, 10) || 30) : 30,
minEvidence: minEvidenceStr ? Math.max(1, parseInt(minEvidenceStr, 10) || 3) : 3,
model,
outputRoot,
sourceSlugPrefix: await getSlugPrefixConfig(
engine, 'dream.patterns.source_slug_prefix', `${outputRoot}/personal/reflections`,
),
outputSlugPrefix: await getSlugPrefixConfig(
engine, 'dream.patterns.output_slug_prefix', `${outputRoot}/personal/patterns`,
),
subagentTimeoutMs: await getNumberConfig(
engine, 'dream.patterns.subagent_timeout_ms', DEFAULT_PATTERNS_SUBAGENT_TIMEOUT_MS,
),
subagentWaitTimeoutMs: await getNumberConfig(
engine, 'dream.patterns.subagent_wait_timeout_ms', DEFAULT_PATTERNS_SUBAGENT_WAIT_TIMEOUT_MS,
),
};
}
// ── Reflection gathering ─────────────────────────────────────────────
interface ReflectionRef {
slug: string;
title: string;
excerpt: string;
}
async function gatherReflections(
engine: BrainEngine,
lookbackDays: number,
sourceSlugPrefix = 'wiki/personal/reflections',
): Promise<ReflectionRef[]> {
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString();
// Reflections live under the configured source slug prefix (bound as a
// parameter; see PatternsConfig.sourceSlugPrefix / dream.patterns.source_slug_prefix).
const rows = await engine.executeRaw<{ slug: string; title: string | null; compiled_truth: string | null }>(
`SELECT slug, title, compiled_truth
FROM pages
WHERE slug LIKE $2
AND updated_at >= $1::timestamptz
ORDER BY updated_at DESC
LIMIT 100`,
[since, `${sourceSlugPrefix}/%`],
);
return rows.map(r => ({
slug: r.slug,
title: r.title ?? r.slug,
excerpt: (r.compiled_truth ?? '').slice(0, 600),
}));
}
// ── Prompt ────────────────────────────────────────────────────────────
function buildPatternsPrompt(
reflections: ReflectionRef[],
minEvidence: number,
sourceSlugPrefix = 'wiki/personal/reflections',
outputSlugPrefix = 'wiki/personal/patterns',
): string {
const today = new Date().toISOString().slice(0, 10);
const corpus = reflections
.map((r, i) => `### ${i + 1}. [[${r.slug}]] — ${r.title}\n${r.excerpt}`)
.join('\n\n---\n\n');
return `You are surfacing recurring themes across the user's recent reflections.
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 [[${sourceSlugPrefix}/...]] 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: \`${outputSlugPrefix}/<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 ${outputSlugPrefix}/.
CONTEXT
- Today: ${today}
- Reflections in scope: ${reflections.length}
REFLECTIONS
${corpus}
When done, briefly list the pattern slugs you wrote/updated in your final message.`;
}
// ── Provenance via put_page tool execution rows ─────────────────────
async function collectChildPutPageSlugs(
engine: BrainEngine,
childIds: number[],
): Promise<Array<{ slug: string; source_id: string }>> {
if (childIds.length === 0) return [];
// v0.32.8: subagent put_page tool schema doesn't expose source_id (subagents
// are scoped to a single source). Default to 'default' here; multi-source
// dream cycles are a v0.33 follow-up. The point of threading source_id is
// so reverseWriteRefs can pass it through getPage and pick the correct
// (source_id, slug) row instead of whatever the DB happens to return.
const rows = await engine.executeRaw<{ slug: string }>(
`SELECT DISTINCT
COALESCE(input->>'slug', (input #>> '{}')::jsonb->>'slug') AS slug
FROM subagent_tool_executions
WHERE job_id = ANY($1::int[])
AND tool_name = 'brain_put_page'
AND status = 'complete'
ORDER BY 1`,
[childIds],
);
return rows
.map(r => r.slug)
.filter((s): s is string => typeof s === 'string' && s.length > 0)
.map(slug => ({ slug, source_id: 'default' }));
}
// ── Reverse-write ────────────────────────────────────────────────────
import { validateSourceId } from '../utils.ts';
async function reverseWriteRefs(
engine: BrainEngine,
brainDir: string,
refs: Array<{ slug: string; source_id: string }>,
): Promise<number> {
let count = 0;
for (const { slug, source_id } of refs) {
// v0.32.8 F6: guard against malformed source_id (would let join() break
// out of brainDir). validateSourceId throws on `..`, `/`, etc.
validateSourceId(source_id);
const page = await engine.getPage(slug, { sourceId: source_id });
if (!page) continue;
const tags = await engine.getTags(slug, { sourceId: source_id });
try {
const md = renderPageToMarkdown(page, tags);
// v0.32.8 F6: non-default sources land under brainDir/.sources/<id>/<slug>.md
// so same-slug-different-source pages don't collide on disk. Default-source
// pages stay at brainDir/<slug>.md so single-source brains see no change.
// `.sources/` is a reserved prefix; walkBrainRepo skips dot-dirs.
const filePath = source_id === 'default'
? join(brainDir, `${slug}.md`)
: join(brainDir, '.sources', source_id, `${slug}.md`);
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, md, 'utf8');
count++;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[dream] reverse-write ${slug}@${source_id} failed: ${msg}\n`);
}
}
return count;
}
function renderPageToMarkdown(page: Page, tags: string[]): string {
const frontmatter = (page.frontmatter ?? {}) as Record<string, unknown>;
return serializeMarkdown(
frontmatter,
page.compiled_truth ?? '',
page.timeline ?? '',
{
type: (page.type as string) ?? 'note',
title: page.title ?? '',
tags,
},
);
}
// ── Status helpers ───────────────────────────────────────────────────
function ok(summary: string, details: Record<string, unknown> = {}): PhaseResult {
return { phase: 'patterns', status: 'ok', duration_ms: 0, summary, details };
}
function skipped(reason: string, summary: string): PhaseResult {
return {
phase: 'patterns',
status: 'skipped',
duration_ms: 0,
summary,
details: { reason },
};
}
function failed(error: PhaseError): PhaseResult {
return {
phase: 'patterns',
status: 'fail',
duration_ms: 0,
summary: 'patterns phase failed',
details: {},
error,
};
}
function makeError(cls: string, code: string, message: string, hint?: string): PhaseError {
return hint ? { class: cls, code, message, hint } : { class: cls, code, message };
}