feat(chronicle): auto-emit extractor — backstop + chronicle_extract job (#2390)

Life Chronicle Phase A.3. A put_page backstop (gated on status==='imported',
the auto-link/timeline trust gate, and the default-OFF auto_chronicle flag;
diary + event pages never eligible) enqueues a chronicle_extract minion job.
The job runs the extractor off the write path: deterministic when/who, an
injectable LLM judge (default = chat gateway; no-op when no gateway), an
all-or-nothing parse barrier (a malformed proposal writes NOTHING), then
content-addressed event pages + a timeline projection via the new dual-engine
upsertEventProjection (idempotent — re-run yields one event + one projection).

New: src/core/chronicle/{eligibility,config,extract-events,backstop}.ts,
engine.upsertEventProjection (both engines), the jobs.ts handler + a 10-min
timeout. 14 unit tests (eligibility, idempotency, parse barrier, backstop
gating + enqueue) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-24 07:58:24 -07:00
co-authored by Claude Opus 4.8
parent 09536fec51
commit 1d04a23c5b
11 changed files with 524 additions and 0 deletions
+19
View File
@@ -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
+38
View File
@@ -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<string, unknown> },
ctx: { engine: BrainEngine; sourceId: string },
): Promise<ChronicleBackstopResult> {
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' };
}
}
+19
View File
@@ -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<boolean> {
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<string> {
const val = await engine.getConfig('chronicle.tz');
return (val && val.trim()) || 'UTC';
}
+34
View File
@@ -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 };
}
+211
View File
@@ -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<ChronicleJudgeResult>;
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<string, unknown>;
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, unknown>): string[] {
const out = new Set<string>();
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<ChronicleExtractResult> {
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<string, unknown>;
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:
`<page slug="${input.slug}" type="${input.type}" date="${input.effectiveDate ?? ''}">\n` +
`${input.title}\n\n${body}\n</page>\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 [];
}
}
+7
View File
@@ -1392,6 +1392,13 @@ export interface BrainEngine {
getSince(date: string, opts?: ChronicleTimelineOpts): Promise<ChronicleTimelineRow[]>;
/** 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<LastSeenResult>;
/**
* 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
/**
+5
View File
@@ -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<Record<string, number>> = {
// #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,
};
/**
+29
View File
@@ -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 } : {}),
};
},
+16
View File
@@ -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,
+16
View File
@@ -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,
+130
View File
@@ -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<number> {
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);
});
});