diff --git a/src/commands/jobs.ts b/src/commands/jobs.ts index ac5de27ca..64aaa6a83 100644 --- a/src/commands/jobs.ts +++ b/src/commands/jobs.ts @@ -1504,6 +1504,25 @@ export async function registerBuiltinHandlers( return result; }); + // v0.42.x (#2390) — Life Chronicle event extraction. NOT protected (bounded + // LLM spend per page; no shell). Enqueued by the put_page chronicle backstop + // and by `gbrain chronicle backfill`. Idempotent (content-addressed event + // slugs + projection upsert), so a retry re-runs to the same state. + worker.register('chronicle_extract', async (job) => { + const slug = typeof job.data.slug === 'string' ? job.data.slug : undefined; + if (!slug) throw new Error('chronicle_extract job requires data.slug'); + const sourceId = typeof job.data.sourceId === 'string' ? job.data.sourceId : undefined; + const { runChronicleExtract } = await import('../core/chronicle/extract-events.ts'); + const { chronicleTz } = await import('../core/chronicle/config.ts'); + const tz = await chronicleTz(engine); + return await runChronicleExtract(engine, { + slug, + sourceId, + tz, + signal: (job as { signal?: AbortSignal }).signal, + }); + }); + // v0.41.39 (#1700) — enrich. NOT in PROTECTED_JOB_NAMES: per-call cost is // bounded by data.maxCostUsd (default DEFAULT_MAX_COST_USD) and the handler // re-creates the BudgetTracker in its own process. BudgetExhausted is caught diff --git a/src/core/chronicle/backstop.ts b/src/core/chronicle/backstop.ts new file mode 100644 index 000000000..2a739ca1a --- /dev/null +++ b/src/core/chronicle/backstop.ts @@ -0,0 +1,38 @@ +// v0.42.x — Life Chronicle (#2390) put_page chronicle backstop (Phase A.3). +// Deterministic + fire-and-forget: it ONLY checks eligibility + the auto_chronicle +// flag and enqueues a `chronicle_extract` minion job. The LLM judgment runs off +// the write path in the job handler. Mirrors facts/backstop.ts (queue mode). +import type { BrainEngine } from '../engine.ts'; +import { isChronicleEligible } from './eligibility.ts'; +import { isAutoChronicleEnabled } from './config.ts'; + +export interface ChronicleBackstopResult { + enqueued: boolean; + skipped?: string; +} + +/** + * Eligibility + enqueue. The CALLER is responsible for the trust gate and for + * only invoking on a real import (status==='imported') — see the put_page hook, + * which skips on remote/untrusted and on skipped/unchanged rewrites so an edit + * that changes nothing never re-enqueues. + */ +export async function runChronicleBackstop( + page: { slug: string; type: string; compiled_truth?: string; frontmatter?: Record }, + ctx: { engine: BrainEngine; sourceId: string }, +): Promise { + const dreamGenerated = page.frontmatter?.dream_generated === true; + const elig = isChronicleEligible({ + type: page.type, slug: page.slug, body: page.compiled_truth, dreamGenerated, + }); + if (!elig.ok) return { enqueued: false, skipped: elig.reason }; + if (!(await isAutoChronicleEnabled(ctx.engine))) return { enqueued: false, skipped: 'auto_chronicle_off' }; + try { + const { MinionQueue } = await import('../minions/queue.ts'); + const queue = new MinionQueue(ctx.engine); + await queue.add('chronicle_extract', { slug: page.slug, sourceId: ctx.sourceId }); + return { enqueued: true }; + } catch { + return { enqueued: false, skipped: 'enqueue_error' }; + } +} diff --git a/src/core/chronicle/config.ts b/src/core/chronicle/config.ts new file mode 100644 index 000000000..ea28c0471 --- /dev/null +++ b/src/core/chronicle/config.ts @@ -0,0 +1,19 @@ +// v0.42.x — Life Chronicle (#2390) config flags. +import type { BrainEngine } from '../engine.ts'; + +/** + * Auto-emit is OFF by default (plan D5.5: spend posture — extraction spends LLM + * tokens per eligible write). Enable with `gbrain config set auto_chronicle true`. + * The eval-gated default-flip is the headline fast-follow (TODO T8). + */ +export async function isAutoChronicleEnabled(engine: BrainEngine): Promise { + const val = await engine.getConfig('auto_chronicle'); + if (val == null) return false; // default OFF + return ['true', '1', 'yes', 'on'].includes(val.trim().toLowerCase()); +} + +/** Pinned timezone for the when→date projection cast (plan: default UTC). */ +export async function chronicleTz(engine: BrainEngine): Promise { + const val = await engine.getConfig('chronicle.tz'); + return (val && val.trim()) || 'UTC'; +} diff --git a/src/core/chronicle/eligibility.ts b/src/core/chronicle/eligibility.ts new file mode 100644 index 000000000..fdf06292a --- /dev/null +++ b/src/core/chronicle/eligibility.ts @@ -0,0 +1,34 @@ +// v0.42.x — Life Chronicle (#2390) chronicle-extract eligibility. +// Mirrors src/core/facts/eligibility.ts. A page is chronicle-eligible (auto- +// emits timeline events) when it is conversation-shape, NOT dream-generated, +// and NOT a diary/event page. Diary interiority is NEVER mined into events +// (privacy/consent — plan D5.4); event pages are already the output (anti-loop). +import type { PageType } from '../types.ts'; + +export type ChronicleEligibility = { ok: true } | { ok: false; reason: string }; + +const ELIGIBLE_TYPES: PageType[] = ['meeting', 'conversation', 'calendar-event']; +// Directory rescue: a meetings/… page that frontmatter-typed itself 'note' is +// still conversation-shape. life/diary excluded explicitly below. +const RESCUE_SLUG_PREFIXES = ['meetings/', 'conversations/', 'cal/', 'calendar/'] as const; +const MIN_BODY_CHARS = 80; + +export function isChronicleEligible(input: { + type: PageType; + slug: string; + body?: string; + dreamGenerated?: boolean; +}): ChronicleEligibility { + const { type, slug } = input; + if (input.dreamGenerated === true) return { ok: false, reason: 'dream_generated' }; + // Diary: never extract events from private interiority. Event: anti-loop. + if (type === 'diary' || slug.startsWith('life/diary/')) return { ok: false, reason: 'diary_excluded' }; + if (type === 'event' || slug.startsWith('life/events/')) return { ok: false, reason: 'event_self' }; + if (slug.startsWith('wiki/agents/')) return { ok: false, reason: 'subagent_scratch' }; + const bodyOk = (input.body?.length ?? MIN_BODY_CHARS) >= MIN_BODY_CHARS; + if (!bodyOk) return { ok: false, reason: 'too_short' }; + const typeOk = ELIGIBLE_TYPES.includes(type); + const slugOk = RESCUE_SLUG_PREFIXES.some((p) => slug.startsWith(p)); + if (!typeOk && !slugOk) return { ok: false, reason: `kind:${type}` }; + return { ok: true }; +} diff --git a/src/core/chronicle/extract-events.ts b/src/core/chronicle/extract-events.ts new file mode 100644 index 000000000..2bcf85964 --- /dev/null +++ b/src/core/chronicle/extract-events.ts @@ -0,0 +1,211 @@ +// v0.42.x — Life Chronicle (#2390) event extractor (Phase A.3). +// +// Pipeline (mirrors facts/extract + facts/backstop, but emits EVENT pages + +// timeline projections instead of facts): +// deterministic when/who → judge (LLM, injectable) → PARSE BARRIER +// → write event pages (content-addressed, idempotent) → project to timeline +// +// The judge is injectable so the deterministic write path is testable without a +// real gateway. The default judge calls the chat gateway; when no gateway is +// configured it returns zero events (auto-emit is a no-op, never an error). +import type { BrainEngine } from '../engine.ts'; +import { computeContentHash } from '../ingestion/types.ts'; + +export interface ChronicleEventProposal { + when: string; // ISO datetime or YYYY-MM-DD + who: string[]; // entity slugs / names + what: string; // one-clause summary + where?: string | null; + kind: string; // meeting|call|commitment|decision|… (open vocab) +} +export interface ChronicleJudgeInput { + slug: string; + type: string; + title: string; + body: string; + effectiveDate: string | null; // depth page effective_date (deterministic when) + attendees: string[]; // deterministic who from frontmatter +} +export interface ChronicleJudgeResult { events: ChronicleEventProposal[] } +export type ChronicleJudge = (input: ChronicleJudgeInput) => Promise; + +export interface ChronicleExtractResult { + slug: string; + status: 'extracted' | 'no_events' | 'skipped'; + events_written: number; + reason?: string; +} + +const KIND_VOCAB = new Set([ + 'meeting', 'call', 'meal', 'solo', 'travel', 'work', + 'commitment', 'decision', 'intro', 'conflict', 'milestone', 'event', +]); + +function normalizeKind(k: string): string { + const n = (k || '').trim().toLowerCase(); + return KIND_VOCAB.has(n) ? n : 'event'; +} + +/** Parse a when string to a Date, or null when unparseable (for effective_date). */ +function safeDate(s: string): Date | null { + const d = new Date(s); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** Resolve a when value to a stable YYYY-MM-DD at the pinned timezone. */ +export function isoDay(when: string, tz: string): string { + if (/^\d{4}-\d{2}-\d{2}$/.test(when)) return when; + const d = new Date(when); + if (Number.isNaN(d.getTime())) return when.slice(0, 10); + if (tz === 'UTC') return d.toISOString().slice(0, 10); + try { + return new Intl.DateTimeFormat('en-CA', { + timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', + }).format(d); + } catch { + return d.toISOString().slice(0, 10); + } +} + +/** PARSE BARRIER: a proposal must fully validate before ANY DB write. */ +export function isValidProposal(e: unknown): e is ChronicleEventProposal { + if (!e || typeof e !== 'object') return false; + const o = e as Record; + return ( + typeof o.when === 'string' && o.when.length >= 4 && + typeof o.what === 'string' && o.what.trim().length > 0 && + Array.isArray(o.who) && o.who.every((w) => typeof w === 'string') && + typeof o.kind === 'string' + ); +} + +function collectAttendees(fm: Record): string[] { + const out = new Set(); + for (const key of ['attendees', 'people', 'who']) { + const v = fm[key]; + if (Array.isArray(v)) for (const x of v) if (typeof x === 'string' && x.trim()) out.add(x.trim()); + } + return [...out]; +} + +/** + * Run the chronicle extractor for one depth page. Idempotent: event slugs are + * content-addressed (re-run upserts the same pages) and the projection upserts + * on (event_page_id, date). A crash between writes re-runs to the same state. + */ +export async function runChronicleExtract( + engine: BrainEngine, + opts: { slug: string; sourceId?: string; judge?: ChronicleJudge; tz?: string; signal?: AbortSignal }, +): Promise { + const sourceId = opts.sourceId ?? 'default'; + const tz = opts.tz ?? 'UTC'; + const page = await engine.getPage(opts.slug, { sourceId }); + if (!page) return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'page_not_found' }; + + const fm = (page.frontmatter ?? {}) as Record; + const edRaw = page.effective_date as unknown; + const effectiveDate: string | null = + edRaw instanceof Date ? edRaw.toISOString() + : typeof edRaw === 'string' && edRaw ? edRaw + : typeof fm.date === 'string' ? fm.date + : null; + const attendees = collectAttendees(fm); + + const judge = opts.judge ?? defaultJudge(engine); + let result: ChronicleJudgeResult; + try { + result = await judge({ + slug: opts.slug, type: page.type, title: page.title, + body: page.compiled_truth ?? '', effectiveDate, attendees, + }); + } catch (e) { + if ((e as Error)?.name === 'AbortError') throw e; + return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'judge_error' }; + } + + 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. + if (!proposals.every(isValidProposal)) { + return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'malformed_proposal' }; + } + + let written = 0; + for (const ev of proposals) { + if (opts.signal?.aborted) { const e = new Error('aborted'); e.name = 'AbortError'; throw e; } + const who = ev.who.length ? ev.who : attendees; + const when = ev.when || effectiveDate || isoDay(new Date(0).toISOString(), tz); + const day = isoDay(when, tz); + const hash = computeContentHash(`${who.join(',')}|${ev.what}|${opts.slug}`).slice(0, 8); + const eventSlug = `life/events/${day}-${hash}`; + await engine.putPage(eventSlug, { + type: 'event', + title: ev.what.slice(0, 120), + compiled_truth: `${ev.what} — see [[${opts.slug}]].`, + frontmatter: { + type: 'event', + event: { + when, who, what: ev.what, where: ev.where ?? null, + kind: normalizeKind(ev.kind), depth: opts.slug, + }, + captured_via: 'life-chronicle:auto', + }, + effective_date: safeDate(when), + }, { sourceId }); + await engine.upsertEventProjection({ + depthSlug: opts.slug, eventSlug, date: day, summary: ev.what, sourceId, + }); + written++; + } + return { slug: opts.slug, status: 'extracted', events_written: written }; +} + +const JUDGE_SYSTEM = `You segment a meeting/transcript page into discrete timeline EVENTS. +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.`; + +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); + let text: string; + try { + const res = await chat({ + system: JUDGE_SYSTEM, + messages: [{ + role: 'user', + content: + `\n` + + `${input.title}\n\n${body}\n\n\n` + + `Known attendees: ${input.attendees.slice(0, 10).join(', ') || '(none)'}.\nExtract the events.`, + }], + maxTokens: 1500, + }); + if (res.stopReason === 'refusal' || res.stopReason === 'content_filter') return { events: [] }; + text = res.text; + } catch (err) { + if ((err as Error)?.name === 'AbortError') throw err; + return { events: [] }; + } + const parsed = parseJudgeJson(text); + return { events: parsed }; + }; +} + +/** Tolerant JSON-array extraction from a model response (mirrors facts parser). */ +export function parseJudgeJson(text: string): ChronicleEventProposal[] { + if (!text) return []; + 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 []; + try { + const arr = JSON.parse(s.slice(start, end + 1)); + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } +} diff --git a/src/core/engine.ts b/src/core/engine.ts index c4ea2db8c..3fde15da5 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -1392,6 +1392,13 @@ export interface BrainEngine { getSince(date: string, opts?: ChronicleTimelineOpts): Promise; /** Most recent date an entity appears (its own page or an event's `who`). */ getLastSeen(entitySlug: string, opts?: { asof?: string; sourceId?: string; sourceIds?: string[] }): Promise; + /** + * Upsert the date-index projection row for an event page: page_id = depth + * page, event_page_id = event page, keyed (event_page_id, date). Re-extraction + * with a changed summary UPDATEs (no duplicate). Returns projected=false when + * either slug is missing in the source. Idempotent. + */ + upsertEventProjection(opts: { depthSlug: string; eventSlug: string; date: string; summary: string; detail?: string; sourceId?: string }): Promise<{ projected: boolean }>; // Raw data /** diff --git a/src/core/minions/handler-timeouts.ts b/src/core/minions/handler-timeouts.ts index 365e51160..334e1f231 100644 --- a/src/core/minions/handler-timeouts.ts +++ b/src/core/minions/handler-timeouts.ts @@ -24,6 +24,7 @@ */ const THIRTY_MIN_MS = 30 * 60 * 1000; +const TEN_MIN_MS = 10 * 60 * 1000; /** * Default wall-clock budget (ms) for long-running handler types. A handler @@ -37,6 +38,10 @@ export const HANDLER_DEFAULT_TIMEOUT_MS: Readonly> = { // #2194 fix #3: brain-wide maintenance (embed-all/orphans/purge/…) can run // longer than a single source cycle; give it the same 30-min budget. 'autopilot-global-maintenance': THIRTY_MIN_MS, + // v0.42.x (#2390) — Life Chronicle: one page = one LLM extraction call + a + // few writes. Generous 10-min budget (vs the tight null-default) covers a + // slow gateway without the 30-min loop budget. + chronicle_extract: TEN_MIN_MS, }; /** diff --git a/src/core/operations.ts b/src/core/operations.ts index b385dcf3d..c576e4852 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -1034,6 +1034,34 @@ const put_page: Operation = { factsQueued = { skipped: 'backstop_error' }; } + // v0.42.x (#2390): Life Chronicle backstop. ONLY on a real import + // (status==='imported' — a skipped/unchanged rewrite still carries + // parsedPage, so gating on parsedPage alone would re-enqueue forever), + // behind the SAME trust gate as auto-link/timeline + the auto_chronicle + // flag. Enqueues a chronicle_extract job; never blocks the write. + let chronicleQueued: { queued: boolean } | { skipped: string } | undefined; + if (result.status !== 'imported') { + chronicleQueued = { skipped: 'not_imported' }; + } else if (ctx.remote !== false && !trustedWorkspace) { + chronicleQueued = { skipped: 'remote' }; + } else if (result.parsedPage) { + try { + const { runChronicleBackstop } = await import('./chronicle/backstop.ts'); + const r = await runChronicleBackstop( + { + slug, + type: result.parsedPage.type, + compiled_truth: result.parsedPage.compiled_truth, + frontmatter: result.parsedPage.frontmatter, + }, + { engine: ctx.engine, sourceId: ctx.sourceId ?? 'default' }, + ); + chronicleQueued = r.enqueued ? { queued: true } : { skipped: r.skipped ?? 'skipped' }; + } catch { + chronicleQueued = { skipped: 'backstop_error' }; + } + } + // Post-write validator lint (PR 2.5): feature-flag-gated, non-blocking. // When `writer.lint_on_put_page` is enabled, runs the BrainWriter's // validators on the freshly-written page and logs findings to @@ -1063,6 +1091,7 @@ const put_page: Operation = { ...(autoTimeline ? { auto_timeline: autoTimeline } : {}), ...(writerLint ? { writer_lint: writerLint } : {}), ...(factsQueued ? { facts_backstop: factsQueued } : {}), + ...(chronicleQueued ? { chronicle_backstop: chronicleQueued } : {}), ...(writeThrough ? { write_through: writeThrough } : {}), }; }, diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 4df8d1e5f..8aa18026b 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -3473,6 +3473,22 @@ export class PGLiteEngine implements BrainEngine { return finalizeLastSeen(entitySlug, row?.last_date ?? null, row?.last_event_slug ?? null, opts?.asof); } + async upsertEventProjection(opts: { depthSlug: string; eventSlug: string; date: string; summary: string; detail?: string; sourceId?: string }): Promise<{ projected: boolean }> { + const sourceId = opts.sourceId ?? 'default'; + const r = await this.db.query( + `INSERT INTO timeline_entries (page_id, date, source, summary, detail, event_page_id) + SELECT dp.id, $1::date, $2, $3, $4, ep.id + FROM pages dp, pages ep + WHERE dp.slug = $5 AND dp.source_id = $6 AND ep.slug = $7 AND ep.source_id = $6 + ON CONFLICT (event_page_id, date) WHERE event_page_id IS NOT NULL + DO UPDATE SET summary = EXCLUDED.summary, detail = EXCLUDED.detail, + page_id = EXCLUDED.page_id, source = EXCLUDED.source + RETURNING id`, + [opts.date, 'life-chronicle:event:' + opts.eventSlug, opts.summary, opts.detail ?? '', opts.depthSlug, sourceId, opts.eventSlug], + ); + return { projected: r.rows.length > 0 }; + } + // Raw data async putRawData( slug: string, diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 47dff9765..2a2e1c307 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -3528,6 +3528,22 @@ export class PostgresEngine implements BrainEngine { return finalizeLastSeen(entitySlug, row?.last_date ?? null, row?.last_event_slug ?? null, opts?.asof); } + async upsertEventProjection(opts: { depthSlug: string; eventSlug: string; date: string; summary: string; detail?: string; sourceId?: string }): Promise<{ projected: boolean }> { + const sql = this.sql; + const sourceId = opts.sourceId ?? 'default'; + const rows = await sql` + INSERT INTO timeline_entries (page_id, date, source, summary, detail, event_page_id) + SELECT dp.id, ${opts.date}::date, ${'life-chronicle:event:' + opts.eventSlug}, ${opts.summary}, ${opts.detail ?? ''}, ep.id + FROM pages dp, pages ep + WHERE dp.slug = ${opts.depthSlug} AND dp.source_id = ${sourceId} + AND ep.slug = ${opts.eventSlug} AND ep.source_id = ${sourceId} + ON CONFLICT (event_page_id, date) WHERE event_page_id IS NOT NULL + DO UPDATE SET summary = EXCLUDED.summary, detail = EXCLUDED.detail, + page_id = EXCLUDED.page_id, source = EXCLUDED.source + RETURNING id`; + return { projected: rows.length > 0 }; + } + // Raw data async putRawData( slug: string, diff --git a/test/chronicle-extract.test.ts b/test/chronicle-extract.test.ts new file mode 100644 index 000000000..671e292c8 --- /dev/null +++ b/test/chronicle-extract.test.ts @@ -0,0 +1,130 @@ +/** + * v0.42.x — Life Chronicle (#2390) auto-emit extractor (Phase A.3). + * PGLite in-memory. Covers eligibility, the extractor's parse barrier + + * idempotent writes (event pages + timeline projection), and the backstop's + * auto_chronicle gating + enqueue. The LLM judge is stubbed so the deterministic + * write path is tested without a gateway. + */ +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 { runChronicleBackstop } from '../src/core/chronicle/backstop.ts'; + +let engine: PGLiteEngine; +const LONG_BODY = 'A'.repeat(120); + +async function countEvents(): Promise { + const r = await engine.executeRaw<{ n: number }>(`SELECT count(*)::int AS n FROM pages WHERE type = 'event'`); + return Number(r[0].n); +} + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({ database_url: '' }); + await engine.initSchema(); +}); +afterAll(async () => { await engine.disconnect(); }); + +describe('isChronicleEligible', () => { + const body = LONG_BODY; + test('meeting is eligible', () => { + expect(isChronicleEligible({ type: 'meeting', slug: 'meetings/x', body }).ok).toBe(true); + }); + test('meetings/ slug rescues a note-typed page', () => { + expect(isChronicleEligible({ type: 'note', slug: 'meetings/x', body }).ok).toBe(true); + }); + test('diary is excluded (privacy)', () => { + expect(isChronicleEligible({ type: 'diary', slug: 'life/diary/x', body })).toEqual({ ok: false, reason: 'diary_excluded' }); + }); + test('event is excluded (anti-loop)', () => { + expect(isChronicleEligible({ type: 'event', slug: 'life/events/x', body })).toEqual({ ok: false, reason: 'event_self' }); + }); + test('dream-generated is excluded', () => { + expect(isChronicleEligible({ type: 'meeting', slug: 'meetings/x', body, dreamGenerated: true })).toEqual({ ok: false, reason: 'dream_generated' }); + }); + test('too-short body is excluded', () => { + expect(isChronicleEligible({ type: 'meeting', slug: 'meetings/x', body: 'hi' })).toEqual({ ok: false, reason: 'too_short' }); + }); + test('unrelated type is excluded', () => { + expect(isChronicleEligible({ type: 'concept', slug: 'wiki/concepts/x', body })).toEqual({ ok: false, reason: 'kind:concept' }); + }); +}); + +describe('runChronicleExtract', () => { + const oneEvent: ChronicleJudge = async () => ({ + events: [{ when: '2026-06-18T15:30:00Z', who: ['people/sarah-chen'], what: 'Sarah committed to Q3', kind: 'commitment' }], + }); + + beforeEach(async () => { + await engine.executeRaw('DELETE FROM timeline_entries'); + await engine.executeRaw(`DELETE FROM pages WHERE type = 'event' OR slug = 'meetings/2026-06-18-sync'`); + await engine.putPage('meetings/2026-06-18-sync', { + type: 'meeting', title: 'Weekly sync', + compiled_truth: LONG_BODY, + frontmatter: { attendees: ['people/sarah-chen'] }, + effective_date: new Date('2026-06-18T15:00:00Z'), + }); + }); + + test('writes an event page + timeline projection', async () => { + const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: oneEvent }); + expect(r.status).toBe('extracted'); + expect(r.events_written).toBe(1); + expect(await countEvents()).toBe(1); + const day = await engine.getTimelineForDate('2026-06-18', { sourceId: 'default' }); + expect(day.length).toBe(1); + expect(day[0].summary).toBe('Sarah committed to Q3'); + expect(day[0].page_slug).toBe('meetings/2026-06-18-sync'); // projection keyed to depth + expect(day[0].event_slug?.startsWith('life/events/2026-06-18-')).toBe(true); + expect(day[0].kind).toBe('commitment'); + }); + + test('is idempotent: running twice yields one event + one projection', async () => { + await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: oneEvent }); + await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: oneEvent }); + expect(await countEvents()).toBe(1); + const day = await engine.getTimelineForDate('2026-06-18', { sourceId: 'default' }); + expect(day.length).toBe(1); + }); + + test('parse barrier: a malformed proposal writes NOTHING', async () => { + const before = await countEvents(); + const bad: ChronicleJudge = async () => ({ events: [{ when: '2026-06-18', who: [], kind: 'x' } as never] }); + const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: bad }); + expect(r.status).toBe('skipped'); + expect(r.reason).toBe('malformed_proposal'); + expect(await countEvents()).toBe(before); // no partial write + }); + + test('no events → no_events status', async () => { + const none: ChronicleJudge = async () => ({ events: [] }); + const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: none }); + expect(r.status).toBe('no_events'); + }); +}); + +describe('runChronicleBackstop gating', () => { + beforeEach(async () => { + await engine.unsetConfig('auto_chronicle'); + await engine.putPage('meetings/bs', { type: 'meeting', title: 'bs', compiled_truth: LONG_BODY }); + }); + + test('skips when auto_chronicle is off (default)', async () => { + const r = await runChronicleBackstop({ slug: 'meetings/bs', type: 'meeting', compiled_truth: LONG_BODY }, { engine, sourceId: 'default' }); + expect(r).toEqual({ enqueued: false, skipped: 'auto_chronicle_off' }); + }); + + test('skips a diary page before consulting the flag', async () => { + const r = await runChronicleBackstop({ slug: 'life/diary/x', type: 'diary', compiled_truth: LONG_BODY }, { engine, sourceId: 'default' }); + expect(r).toEqual({ enqueued: false, skipped: 'diary_excluded' }); + }); + + test('enqueues a chronicle_extract job when enabled + eligible', async () => { + await engine.setConfig('auto_chronicle', 'true'); + const r = await runChronicleBackstop({ slug: 'meetings/bs', type: 'meeting', compiled_truth: LONG_BODY }, { engine, sourceId: 'default' }); + expect(r.enqueued).toBe(true); + const jobs = await engine.executeRaw<{ n: number }>(`SELECT count(*)::int AS n FROM minion_jobs WHERE name = 'chronicle_extract'`); + expect(Number(jobs[0].n)).toBeGreaterThanOrEqual(1); + }); +});