@jangmanj/pi-webtasks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts ADDED
@@ -0,0 +1,1445 @@
1
+ /**
2
+ * Session-bound research store.
3
+ *
4
+ * Owns the durable evidence the extension receives from Parallel: submission
5
+ * intent, provider identities, observations, result payloads, searchable
6
+ * evidence, citation links, and pending alerts.
7
+ *
8
+ * Invariants this module is responsible for:
9
+ *
10
+ * - Session ownership is an authorization rule, not a tag. Every read and
11
+ * write takes the caller's Pi session id and rows are filtered by it. A
12
+ * handle owned by another session behaves exactly like a missing handle, so
13
+ * cross-session probing learns nothing.
14
+ * - A paid submission is recorded before the network call (`beginRun`), keyed
15
+ * by the Pi tool invocation. A replay of the same invocation returns the
16
+ * existing run instead of submitting again. Different invocations remain
17
+ * separate investigations even when their text is identical.
18
+ * - Completion is one transaction: provider payload, evidence projections,
19
+ * citation links, run state, and the pending completion alert commit
20
+ * together or not at all. A failure leaves the run exactly as it was, so a
21
+ * storage or indexing failure can never look like a completed result.
22
+ * - Provider status, local evidence availability, and alert delivery are
23
+ * three separate facts (`runs.state`, `evidence` rows, `alerts.state`).
24
+ * - Alerts are persisted before delivery. Claiming an alert is not proof of
25
+ * delivery, and acknowledging one is not proof that a crash cannot replay
26
+ * it: the delivery path is at-least-once, never claimed to be exactly-once.
27
+ * - Search results are bounded and stable across pages. A page pins a snapshot
28
+ * of the highest evidence id, so evidence arriving mid-pagination cannot
29
+ * shift or repeat rows. A cursor also carries its own filters, so it alone
30
+ * continues the search it came from.
31
+ *
32
+ * Fresh start: this store opens `<base>/research.sqlite`. It never opens,
33
+ * migrates, or deletes the legacy `webtasks.sqlite`.
34
+ */
35
+
36
+ import { DatabaseSync, type StatementSync } from "node:sqlite";
37
+ import { chmod, mkdir } from "node:fs/promises";
38
+ import { dirname, join, resolve } from "node:path";
39
+ import { randomBytes } from "node:crypto";
40
+
41
+ const SCHEMA_VERSION = 2;
42
+
43
+ export type RunState =
44
+ | "intent"
45
+ | "submitting"
46
+ | "accepted"
47
+ | "pending"
48
+ | "completed"
49
+ | "failed"
50
+ | "cancelled"
51
+ | "action_required"
52
+ | "uncertain";
53
+
54
+ /**
55
+ * The run state vocabulary. Every list of states in this file's SQL and in its
56
+ * callers is one of these sets; nothing spells a state list inline.
57
+ *
58
+ * - Terminal: settlement commits it and nothing moves a run out of it.
59
+ * `uncertain` is not terminal (it is reconciled), and neither is
60
+ * `action_required` (it is alerted once and stays watched).
61
+ * - Open: every other state. A Task run in an open state is reconciled at
62
+ * session start and counted as research in progress.
63
+ * - Unconfirmed: Parallel has not confirmed the run exists. Any provider fact
64
+ * about a bound run confirms it, which makes it `accepted`.
65
+ * - In flight: recorded, and possibly sent, but never answered.
66
+ */
67
+ export const TERMINAL_RUN_STATES: ReadonlySet<RunState> = new Set<RunState>(["completed", "failed", "cancelled"]);
68
+ export const OPEN_RUN_STATES: ReadonlySet<RunState> = new Set<RunState>(
69
+ ["intent", "submitting", "accepted", "pending", "action_required", "uncertain"],
70
+ );
71
+ export const UNCONFIRMED_RUN_STATES: ReadonlySet<RunState> = new Set<RunState>(["intent", "submitting", "uncertain"]);
72
+ export const IN_FLIGHT_RUN_STATES: ReadonlySet<RunState> = new Set<RunState>(["intent", "submitting"]);
73
+
74
+ /** A state set as an SQL list. The values are the fixed literals above, never input. */
75
+ function sqlStates(states: ReadonlySet<RunState>): string {
76
+ return [...states].map(state => `'${state}'`).join(",");
77
+ }
78
+
79
+ /**
80
+ * What kind of received item this is. `report` and `structured` are provider
81
+ * outputs, `finding` is an intermediate progress result, `source` and `excerpt`
82
+ * are source text. The untouched provider payload lives on the run, not here.
83
+ */
84
+ export const EVIDENCE_KIND_NAMES = ["report", "structured", "finding", "source", "excerpt"] as const;
85
+ export type EvidenceKind = (typeof EVIDENCE_KIND_NAMES)[number];
86
+
87
+ /**
88
+ * Where the text came from, so a search hit can be read correctly:
89
+ * a provider claim is not the same thing as source text.
90
+ */
91
+ export type EvidenceOrigin = "provider_report" | "provider_structured" | "provider_finding" | "provider_source";
92
+
93
+ export type AlertKind = "progress" | "finding" | "completion" | "failure" | "action_required";
94
+ export type AlertState = "pending" | "claimed" | "delivered" | "acknowledged";
95
+
96
+ export type StoreErrorCode = "not_found" | "conflict" | "invalid" | "closed";
97
+
98
+ export class StoreError extends Error {
99
+ readonly code: StoreErrorCode;
100
+ constructor(code: StoreErrorCode, message: string) {
101
+ super(message);
102
+ this.name = "StoreError";
103
+ this.code = code;
104
+ }
105
+ }
106
+
107
+ export type RunKind = "task" | "search" | "extract" | "answer";
108
+
109
+ export type RunRecord = {
110
+ reference: string;
111
+ session_id: string;
112
+ kind: RunKind;
113
+ group_id?: string;
114
+ provider_run_id?: string;
115
+ /** Provider-side research context (Search/Extract `session_id`), distinct from the Pi session. */
116
+ provider_session_id?: string;
117
+ /** Provider interaction id, for continuing a Task or Responses conversation. */
118
+ provider_interaction_id?: string;
119
+ parent?: string;
120
+ intent_key: string;
121
+ state: RunState;
122
+ processor?: string;
123
+ intent: unknown;
124
+ summary?: string;
125
+ error?: string;
126
+ created_at: number;
127
+ updated_at: number;
128
+ terminal_at?: number;
129
+ /** When the provider reported the run as running; start of the progress timetable. */
130
+ running_at?: number;
131
+ /** Next scheduled progress update, in epoch milliseconds. */
132
+ next_alert_at?: number;
133
+ /** Progress interval derived from published processor latency. */
134
+ alert_interval_ms?: number;
135
+ };
136
+
137
+ export type EvidenceInput = {
138
+ kind: EvidenceKind;
139
+ origin: EvidenceOrigin;
140
+ body: string;
141
+ /**
142
+ * Local alias, unique within one completion batch, so citations can point at
143
+ * evidence inserted by the same transaction before handles are known. Never
144
+ * persisted and never exposed to another session.
145
+ */
146
+ ref?: string;
147
+ title?: string;
148
+ url?: string;
149
+ /** Provider location of this item, e.g. `output.content` or `output.sources[4]`. */
150
+ pointer?: string;
151
+ /** Untouched provider object for this item, preserved verbatim. */
152
+ payload?: unknown;
153
+ created_at?: number;
154
+ };
155
+
156
+ /** One evidence item a completion stored: its new handle and where it came from. Returned in input order. */
157
+ export type StoredEvidence = { handle: string; kind: EvidenceKind; title?: string; url?: string; pointer?: string };
158
+
159
+ export type CitationInput = {
160
+ /** Stored evidence handle, or a batch alias from this completion's evidence. */
161
+ evidence: string;
162
+ /** Stored evidence handle or batch alias for the source, when it is stored locally. */
163
+ source?: string;
164
+ claim?: string;
165
+ url?: string;
166
+ title?: string;
167
+ };
168
+
169
+ export type AlertInput = {
170
+ kind: AlertKind;
171
+ headline: string;
172
+ body?: string;
173
+ /** Stable coalescing key: one pending row per (session, key). */
174
+ dedup_key: string;
175
+ };
176
+
177
+ export type AlertRecord = {
178
+ alert_id: string;
179
+ reference: string;
180
+ kind: AlertKind;
181
+ headline: string;
182
+ body?: string;
183
+ state: AlertState;
184
+ attempts: number;
185
+ created_at: number;
186
+ updated_at: number;
187
+ };
188
+
189
+ export type SearchHit = {
190
+ handle: string;
191
+ reference: string;
192
+ kind: EvidenceKind;
193
+ origin: EvidenceOrigin;
194
+ title?: string;
195
+ url?: string;
196
+ pointer?: string;
197
+ /** Bounded highlight around the match; empty when browsing. */
198
+ snippet: string;
199
+ /** UTF-16 length of the stored body. Read the body with `readEvidence`. */
200
+ chars: number;
201
+ created_at: number;
202
+ };
203
+
204
+ export type SearchPage = {
205
+ hits: SearchHit[];
206
+ /** Highest evidence id covered by this page; new evidence is excluded until a new search. */
207
+ snapshot: number;
208
+ /** True when more rows exist beyond this page. */
209
+ more: boolean;
210
+ next_cursor?: string;
211
+ };
212
+
213
+ export type EvidenceSlice = {
214
+ handle: string;
215
+ reference: string;
216
+ kind: EvidenceKind;
217
+ origin: EvidenceOrigin;
218
+ title?: string;
219
+ url?: string;
220
+ pointer?: string;
221
+ /** UTF-16 offset of the first returned character. */
222
+ offset: number;
223
+ text: string;
224
+ total_chars: number;
225
+ truncated_before: boolean;
226
+ truncated_after: boolean;
227
+ /** Offset to pass for the next slice, or absent when the body ended. */
228
+ next_offset?: number;
229
+ created_at: number;
230
+ };
231
+
232
+ export type StoreStats = {
233
+ evidence: number;
234
+ chars: number;
235
+ runs: Partial<Record<RunState, number>>;
236
+ alerts: Partial<Record<AlertState, number>>;
237
+ };
238
+
239
+ export type StoreOptions = { now?: () => number };
240
+
241
+ const EVIDENCE_KINDS: ReadonlySet<string> = new Set<EvidenceKind>(EVIDENCE_KIND_NAMES);
242
+
243
+ /**
244
+ * Additive migrations for stores created by earlier builds of this store.
245
+ * A column must exist before the base schema runs, because the schema creates
246
+ * indexes that reference it. A genuinely fresh store has no tables yet and is
247
+ * created complete by the schema itself, so nothing is added here.
248
+ *
249
+ * Only additive changes belong here: the store never rewrites or drops data.
250
+ */
251
+ const COLUMN_MIGRATIONS: Record<string, Record<string, string>> = {
252
+ runs: {
253
+ kind: "TEXT NOT NULL DEFAULT 'task'",
254
+ provider_session_id: "TEXT",
255
+ provider_interaction_id: "TEXT",
256
+ running_at: "INTEGER",
257
+ next_alert_at: "INTEGER",
258
+ alert_interval_ms: "INTEGER",
259
+ },
260
+ };
261
+
262
+ function migrateColumns(db: DatabaseSync): void {
263
+ for (const [table, columns] of Object.entries(COLUMN_MIGRATIONS)) {
264
+ const existing = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
265
+ if (!existing.length) continue;
266
+ const names = new Set(existing.map(column => column.name));
267
+ for (const [name, definition] of Object.entries(columns)) {
268
+ if (!names.has(name)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
269
+ }
270
+ }
271
+ }
272
+
273
+ const SCHEMA = `
274
+ CREATE TABLE IF NOT EXISTS sessions (
275
+ session_id TEXT PRIMARY KEY,
276
+ created_at INTEGER NOT NULL,
277
+ updated_at INTEGER NOT NULL
278
+ );
279
+
280
+ CREATE TABLE IF NOT EXISTS task_groups (
281
+ session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
282
+ group_id TEXT,
283
+ state TEXT NOT NULL CHECK (state IN ('pending','ready','uncertain')),
284
+ event_cursor TEXT,
285
+ error TEXT,
286
+ created_at INTEGER NOT NULL,
287
+ updated_at INTEGER NOT NULL
288
+ );
289
+
290
+ CREATE TABLE IF NOT EXISTS runs (
291
+ reference TEXT PRIMARY KEY,
292
+ session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
293
+ kind TEXT NOT NULL DEFAULT 'task' CHECK (kind IN ('task','search','extract','answer')),
294
+ group_id TEXT,
295
+ provider_run_id TEXT,
296
+ provider_session_id TEXT,
297
+ parent TEXT REFERENCES runs(reference),
298
+ intent_key TEXT NOT NULL,
299
+ state TEXT NOT NULL CHECK (state IN ('intent','submitting','accepted','pending','completed','failed','cancelled','action_required','uncertain')),
300
+ processor TEXT,
301
+ intent TEXT NOT NULL,
302
+ summary TEXT,
303
+ raw_result TEXT,
304
+ error TEXT,
305
+ created_at INTEGER NOT NULL,
306
+ updated_at INTEGER NOT NULL,
307
+ terminal_at INTEGER,
308
+ provider_interaction_id TEXT,
309
+ running_at INTEGER,
310
+ next_alert_at INTEGER,
311
+ alert_interval_ms INTEGER
312
+ );
313
+ CREATE INDEX IF NOT EXISTS runs_context ON runs(session_id, provider_session_id);
314
+ CREATE UNIQUE INDEX IF NOT EXISTS runs_intent ON runs(session_id, intent_key);
315
+ CREATE UNIQUE INDEX IF NOT EXISTS runs_provider ON runs(session_id, provider_run_id) WHERE provider_run_id IS NOT NULL;
316
+ CREATE INDEX IF NOT EXISTS runs_session_state ON runs(session_id, state, created_at);
317
+
318
+ CREATE TABLE IF NOT EXISTS observations (
319
+ id INTEGER PRIMARY KEY,
320
+ session_id TEXT NOT NULL,
321
+ reference TEXT NOT NULL REFERENCES runs(reference) ON DELETE CASCADE,
322
+ source TEXT NOT NULL,
323
+ event_type TEXT NOT NULL,
324
+ provider_event_id TEXT,
325
+ dedup_key TEXT,
326
+ received_at INTEGER NOT NULL,
327
+ payload TEXT NOT NULL
328
+ );
329
+ CREATE UNIQUE INDEX IF NOT EXISTS observations_dedup ON observations(reference, dedup_key) WHERE dedup_key IS NOT NULL;
330
+ CREATE INDEX IF NOT EXISTS observations_run ON observations(reference, id);
331
+
332
+ CREATE TABLE IF NOT EXISTS evidence (
333
+ id INTEGER PRIMARY KEY,
334
+ handle TEXT NOT NULL UNIQUE,
335
+ session_id TEXT NOT NULL,
336
+ reference TEXT NOT NULL REFERENCES runs(reference) ON DELETE CASCADE,
337
+ kind TEXT NOT NULL,
338
+ origin TEXT NOT NULL,
339
+ title TEXT,
340
+ url TEXT,
341
+ pointer TEXT,
342
+ body TEXT NOT NULL,
343
+ payload TEXT,
344
+ created_at INTEGER NOT NULL
345
+ );
346
+ CREATE INDEX IF NOT EXISTS evidence_session ON evidence(session_id, id);
347
+ CREATE INDEX IF NOT EXISTS evidence_run ON evidence(reference, id);
348
+ CREATE INDEX IF NOT EXISTS evidence_kind ON evidence(session_id, kind, id);
349
+ CREATE INDEX IF NOT EXISTS evidence_url ON evidence(session_id, url) WHERE url IS NOT NULL;
350
+
351
+ CREATE VIRTUAL TABLE IF NOT EXISTS evidence_fts USING fts5(
352
+ title,
353
+ body,
354
+ content='evidence',
355
+ content_rowid='id',
356
+ tokenize='porter unicode61 remove_diacritics 2'
357
+ );
358
+
359
+ CREATE TABLE IF NOT EXISTS citations (
360
+ id INTEGER PRIMARY KEY,
361
+ session_id TEXT NOT NULL,
362
+ evidence_id INTEGER NOT NULL REFERENCES evidence(id) ON DELETE CASCADE,
363
+ source_id INTEGER REFERENCES evidence(id) ON DELETE SET NULL,
364
+ claim TEXT,
365
+ url TEXT,
366
+ title TEXT
367
+ );
368
+ CREATE INDEX IF NOT EXISTS citations_evidence ON citations(evidence_id);
369
+ CREATE INDEX IF NOT EXISTS citations_source ON citations(source_id);
370
+
371
+ CREATE TABLE IF NOT EXISTS alerts (
372
+ alert_id TEXT PRIMARY KEY,
373
+ session_id TEXT NOT NULL,
374
+ reference TEXT NOT NULL REFERENCES runs(reference) ON DELETE CASCADE,
375
+ kind TEXT NOT NULL,
376
+ headline TEXT NOT NULL,
377
+ body TEXT,
378
+ dedup_key TEXT NOT NULL,
379
+ state TEXT NOT NULL CHECK (state IN ('pending','claimed','delivered','acknowledged')),
380
+ attempts INTEGER NOT NULL DEFAULT 0,
381
+ created_at INTEGER NOT NULL,
382
+ updated_at INTEGER NOT NULL
383
+ );
384
+ CREATE UNIQUE INDEX IF NOT EXISTS alerts_dedup ON alerts(session_id, dedup_key);
385
+ CREATE INDEX IF NOT EXISTS alerts_open ON alerts(session_id, state, created_at);
386
+ `;
387
+
388
+ /**
389
+ * Switch to WAL. The switch needs an exclusive lock, and SQLite may refuse it
390
+ * with SQLITE_BUSY without consulting the busy handler, so concurrent first
391
+ * opens retry here. WAL persists in the file: only a brand-new store ever waits.
392
+ */
393
+ async function enableWal(db: DatabaseSync): Promise<void> {
394
+ const deadline = Date.now() + 5_000;
395
+ for (let delay = 5; ; delay = Math.min(delay * 2, 200)) {
396
+ try {
397
+ db.exec("PRAGMA journal_mode = WAL");
398
+ return;
399
+ } catch (err) {
400
+ if (!/locked|busy/i.test((err as Error).message) || Date.now() + delay > deadline) throw err;
401
+ await new Promise(resolveWait => setTimeout(resolveWait, delay));
402
+ }
403
+ }
404
+ }
405
+
406
+ function isLowSurrogate(code: number): boolean {
407
+ return code >= 0xdc00 && code <= 0xdfff;
408
+ }
409
+
410
+ function isHighSurrogate(code: number): boolean {
411
+ return code >= 0xd800 && code <= 0xdbff;
412
+ }
413
+
414
+ function newId(prefix: string): string {
415
+ return `${prefix}_${randomBytes(8).toString("hex")}`;
416
+ }
417
+
418
+ function nullable(value: string | null): string | undefined {
419
+ return value === null ? undefined : value;
420
+ }
421
+
422
+ /**
423
+ * Turn free text into a forgiving FTS5 query: every word becomes a quoted term,
424
+ * any of which may match. Never throws a syntax error on punctuation.
425
+ */
426
+ function ftsQuery(text: string): string | undefined {
427
+ const tokens = text.toLowerCase().match(/[\p{L}\p{N}]+/gu);
428
+ if (!tokens?.length) return undefined;
429
+ return tokens.slice(0, 32).map(token => `"${token}"`).join(" OR ");
430
+ }
431
+
432
+ /** A page cursor carries the search it continues, so a cursor alone is enough to fetch the next page. */
433
+ type CursorState = { v: 2; snapshot: number; offset: number; query?: string; reference?: string; kinds?: EvidenceKind[] };
434
+
435
+ function encodeCursor(state: CursorState): string {
436
+ return Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
437
+ }
438
+
439
+ function decodeCursor(cursor: string): CursorState {
440
+ let parsed: unknown;
441
+ try {
442
+ parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
443
+ } catch {
444
+ throw new StoreError("invalid", "Malformed search cursor");
445
+ }
446
+ const value = parsed as Partial<CursorState> | null;
447
+ if (
448
+ !value || value.v !== 2 || !Number.isSafeInteger(value.snapshot) || !Number.isSafeInteger(value.offset) || value.snapshot! < 0 || value.offset! < 0 ||
449
+ (value.query !== undefined && typeof value.query !== "string") ||
450
+ (value.reference !== undefined && typeof value.reference !== "string") ||
451
+ (value.kinds !== undefined && (!Array.isArray(value.kinds) || !value.kinds.every(kind => EVIDENCE_KINDS.has(kind))))
452
+ ) {
453
+ throw new StoreError("invalid", "Malformed search cursor");
454
+ }
455
+ return value as CursorState;
456
+ }
457
+
458
+ function clamp(value: number | undefined, fallback: number, min: number, max: number): number {
459
+ if (value === undefined || !Number.isFinite(value)) return fallback;
460
+ return Math.min(max, Math.max(min, Math.trunc(value)));
461
+ }
462
+
463
+ export class ResearchStore {
464
+ private readonly dir: string;
465
+ /** The store's clock: the only clock for run timestamps and the progress timetable. */
466
+ readonly now: () => number;
467
+ private db?: DatabaseSync;
468
+ private opening?: Promise<DatabaseSync>;
469
+ private statements = new Map<string, StatementSync>();
470
+
471
+ constructor(baseDir: string, options: StoreOptions = {}) {
472
+ this.dir = resolve(baseDir);
473
+ this.now = options.now ?? Date.now;
474
+ }
475
+
476
+ /** The fresh store's database file. The legacy `webtasks.sqlite` is never touched. */
477
+ get path(): string {
478
+ return join(this.dir, "research.sqlite");
479
+ }
480
+
481
+ async init(): Promise<this> {
482
+ await this.connect();
483
+ return this;
484
+ }
485
+
486
+ async close(): Promise<void> {
487
+ const db = this.db;
488
+ this.db = undefined;
489
+ this.statements.clear();
490
+ this.opening = undefined;
491
+ if (db) db.close();
492
+ }
493
+
494
+ private async connect(): Promise<DatabaseSync> {
495
+ if (this.db) return this.db;
496
+ if (!this.opening) {
497
+ this.opening = (async () => {
498
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
499
+ const db = new DatabaseSync(this.path);
500
+ try {
501
+ // First, so every later statement waits for a concurrent opener.
502
+ db.exec("PRAGMA busy_timeout = 5000");
503
+ await enableWal(db);
504
+ db.exec("PRAGMA synchronous = FULL"); // a committed row is on disk before we report it
505
+ db.exec("PRAGMA foreign_keys = ON");
506
+ const version = Number((db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version);
507
+ if (version > SCHEMA_VERSION) {
508
+ throw new StoreError("conflict", `research store schema ${version} is newer than this build (${SCHEMA_VERSION})`);
509
+ }
510
+ // A missing FTS table means the index was lost (crash, corruption, manual drop).
511
+ // Rebuilding it after creation keeps stored evidence searchable instead of
512
+ // silently invisible; it is a no-op cost on a genuinely fresh store.
513
+ const hadIndex = db.prepare(`SELECT 1 AS present FROM sqlite_master WHERE name = 'evidence_fts'`).get() !== undefined;
514
+ db.exec("BEGIN IMMEDIATE");
515
+ try {
516
+ migrateColumns(db);
517
+ db.exec(SCHEMA);
518
+ if (!hadIndex) db.exec(`INSERT INTO evidence_fts (evidence_fts) VALUES ('rebuild')`);
519
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
520
+ db.exec("COMMIT");
521
+ } catch (err) {
522
+ try { db.exec("ROLLBACK"); } catch { /* the open fails below */ }
523
+ throw err;
524
+ }
525
+ } catch (err) {
526
+ db.close();
527
+ throw err;
528
+ }
529
+ this.db = db;
530
+ // Private result content: the directory already restricts access; keep the file itself closed.
531
+ await chmod(this.path, 0o600).catch(() => undefined);
532
+ return db;
533
+ })().catch(err => {
534
+ this.opening = undefined;
535
+ throw err;
536
+ });
537
+ }
538
+ return this.opening;
539
+ }
540
+
541
+ private requireDb(): DatabaseSync {
542
+ if (!this.db) throw new StoreError("closed", "Stored research evidence is unavailable right now; the session may be restarting");
543
+ return this.db;
544
+ }
545
+
546
+ private stmt(sql: string): StatementSync {
547
+ const db = this.requireDb();
548
+ let cached = this.statements.get(sql);
549
+ if (!cached) {
550
+ cached = db.prepare(sql);
551
+ this.statements.set(sql, cached);
552
+ }
553
+ return cached;
554
+ }
555
+
556
+ private tx<T>(fn: () => T): T {
557
+ const db = this.requireDb();
558
+ db.exec("BEGIN IMMEDIATE");
559
+ try {
560
+ const value = fn();
561
+ db.exec("COMMIT");
562
+ return value;
563
+ } catch (err) {
564
+ try { db.exec("ROLLBACK"); } catch { /* the original error is the useful one */ }
565
+ throw err;
566
+ }
567
+ }
568
+
569
+ private touchSession(sessionId: string, now: number): void {
570
+ this.stmt(
571
+ `INSERT INTO sessions (session_id, created_at, updated_at) VALUES (?, ?, ?)
572
+ ON CONFLICT(session_id) DO UPDATE SET updated_at = excluded.updated_at`,
573
+ ).run(sessionId, now, now);
574
+ }
575
+
576
+ // ---------------------------------------------------------------- sessions
577
+
578
+ /** Record that this Pi session uses the store. Lookup-only callers may skip it. */
579
+ async registerSession(sessionId: string): Promise<void> {
580
+ await this.connect();
581
+ this.tx(() => this.touchSession(sessionId, this.now()));
582
+ }
583
+
584
+ /** Bind (or replace) this session's Parallel Task Group. */
585
+ async bindGroup(sessionId: string, groupId: string | undefined, state: "pending" | "ready" | "uncertain", error?: string): Promise<void> {
586
+ await this.connect();
587
+ const now = this.now();
588
+ this.tx(() => {
589
+ this.touchSession(sessionId, now);
590
+ this.stmt(
591
+ `INSERT INTO task_groups (session_id, group_id, state, error, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
592
+ ON CONFLICT(session_id) DO UPDATE SET
593
+ group_id = COALESCE(excluded.group_id, task_groups.group_id),
594
+ state = excluded.state,
595
+ error = excluded.error,
596
+ updated_at = excluded.updated_at`,
597
+ ).run(sessionId, groupId ?? null, state, error ?? null, now, now);
598
+ });
599
+ }
600
+
601
+ async groupForSession(sessionId: string): Promise<{ group_id?: string; state: string; event_cursor?: string; error?: string } | undefined> {
602
+ await this.connect();
603
+ const row = this.stmt(`SELECT group_id, state, event_cursor, error FROM task_groups WHERE session_id = ?`).get(sessionId) as
604
+ | { group_id: string | null; state: string; event_cursor: string | null; error: string | null }
605
+ | undefined;
606
+ if (!row) return undefined;
607
+ return { group_id: nullable(row.group_id), state: row.state, event_cursor: nullable(row.event_cursor), error: nullable(row.error) };
608
+ }
609
+
610
+ /**
611
+ * Advance the group event cursor. Callers must commit this only after the
612
+ * events it covers are durable, which is why it is a separate call.
613
+ */
614
+ async setGroupCursor(sessionId: string, cursor: string | undefined): Promise<void> {
615
+ await this.connect();
616
+ this.stmt(`UPDATE task_groups SET event_cursor = ?, updated_at = ? WHERE session_id = ?`).run(cursor ?? null, this.now(), sessionId);
617
+ }
618
+
619
+ // -------------------------------------------------------------------- runs
620
+
621
+ /**
622
+ * Persist submission intent before any paid call.
623
+ *
624
+ * `intentKey` is the Pi tool invocation id. Replaying the same invocation
625
+ * returns the existing run with `created: false`, so a crash or retry cannot
626
+ * silently buy a second Task API run.
627
+ */
628
+ async beginRun(input: {
629
+ sessionId: string;
630
+ intentKey: string;
631
+ intent: unknown;
632
+ kind?: RunKind;
633
+ processor?: string;
634
+ parent?: string;
635
+ groupId?: string;
636
+ state?: "intent" | "submitting";
637
+ }): Promise<{ reference: string; created: boolean }> {
638
+ await this.connect();
639
+ if (!input.intentKey) throw new StoreError("invalid", "A run needs an intent key");
640
+ const now = this.now();
641
+ return this.tx(() => {
642
+ this.touchSession(input.sessionId, now);
643
+ if (input.parent) {
644
+ const parent = this.stmt(`SELECT session_id FROM runs WHERE reference = ?`).get(input.parent) as { session_id: string } | undefined;
645
+ if (!parent || parent.session_id !== input.sessionId) {
646
+ throw new StoreError("invalid", `No research record ${input.parent} in this session`);
647
+ }
648
+ }
649
+ const existing = this.stmt(`SELECT reference FROM runs WHERE session_id = ? AND intent_key = ?`).get(input.sessionId, input.intentKey) as
650
+ | { reference: string }
651
+ | undefined;
652
+ if (existing) return { reference: existing.reference, created: false };
653
+
654
+ const reference = newId("run");
655
+ this.stmt(
656
+ `INSERT INTO runs (reference, session_id, kind, group_id, parent, intent_key, state, processor, intent, created_at, updated_at)
657
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
658
+ ).run(
659
+ reference, input.sessionId, input.kind ?? "task", input.groupId ?? null, input.parent ?? null, input.intentKey,
660
+ input.state ?? "intent", input.processor ?? null, JSON.stringify(input.intent ?? null), now, now,
661
+ );
662
+ return { reference, created: true };
663
+ });
664
+ }
665
+
666
+ /**
667
+ * Bind the provider run id that Parallel confirmed for this run. Idempotent
668
+ * for the same run.
669
+ *
670
+ * Confirmation is a fact about existence: an unconfirmed run becomes
671
+ * `accepted`, and any other state is kept. A terminal run is never reopened.
672
+ */
673
+ async bindProviderRun(input: {
674
+ sessionId: string;
675
+ reference: string;
676
+ providerRunId: string;
677
+ providerSessionId?: string;
678
+ interactionId?: string;
679
+ groupId?: string;
680
+ }): Promise<void> {
681
+ await this.connect();
682
+ const now = this.now();
683
+ this.tx(() => {
684
+ const run = this.requireRun(input.sessionId, input.reference);
685
+ if (run.provider_run_id && run.provider_run_id !== input.providerRunId) {
686
+ throw new StoreError("conflict", `Run ${input.reference} is already bound to a different provider run`);
687
+ }
688
+ this.stmt(
689
+ `UPDATE runs SET provider_run_id = ?, provider_session_id = COALESCE(?, provider_session_id),
690
+ provider_interaction_id = COALESCE(?, provider_interaction_id),
691
+ group_id = COALESCE(?, group_id),
692
+ state = CASE WHEN state IN (${sqlStates(UNCONFIRMED_RUN_STATES)}) THEN 'accepted' ELSE state END,
693
+ updated_at = ? WHERE reference = ?`,
694
+ ).run(
695
+ input.providerRunId, input.providerSessionId ?? null, input.interactionId ?? null,
696
+ input.groupId ?? null, now, input.reference,
697
+ );
698
+ });
699
+ }
700
+
701
+ /**
702
+ * A Task run is running: it becomes `pending` and its progress timetable runs.
703
+ * The first call starts the timetable with `intervalMs`; a run that comes back
704
+ * from `action_required` restarts its next alert one interval from now. The
705
+ * interval is guidance from published processor latency, never a deadline.
706
+ *
707
+ * A late "running" observation never reopens a terminal run. Returns whether
708
+ * the run changed.
709
+ */
710
+ async markRunning(sessionId: string, reference: string, intervalMs: number): Promise<boolean> {
711
+ await this.connect();
712
+ const now = this.now();
713
+ return this.tx(() => {
714
+ this.requireRun(sessionId, reference);
715
+ const info = this.stmt(
716
+ `UPDATE runs SET running_at = COALESCE(running_at, ?),
717
+ alert_interval_ms = COALESCE(alert_interval_ms, ?),
718
+ next_alert_at = CASE WHEN running_at IS NULL OR next_alert_at IS NULL OR state = 'action_required'
719
+ THEN ? + COALESCE(alert_interval_ms, ?) ELSE next_alert_at END,
720
+ state = 'pending', terminal_at = NULL, updated_at = ?
721
+ WHERE reference = ? AND session_id = ? AND state IN (${sqlStates(OPEN_RUN_STATES)})
722
+ AND NOT (state = 'pending' AND running_at IS NOT NULL AND next_alert_at IS NOT NULL)`,
723
+ ).run(now, intervalMs, now, intervalMs, now, reference, sessionId);
724
+ return Number(info.changes) > 0;
725
+ });
726
+ }
727
+
728
+ /**
729
+ * Move an open run between open states, and queue its alert in the same
730
+ * transaction. Nothing changes unless the run is in one of `from`, so a
731
+ * repeated observation neither repeats the move nor the alert. Returns whether
732
+ * the run moved.
733
+ */
734
+ async transitionRun(input: {
735
+ sessionId: string;
736
+ reference: string;
737
+ from: ReadonlySet<RunState>;
738
+ to: RunState;
739
+ error?: string;
740
+ alert?: AlertInput;
741
+ }): Promise<boolean> {
742
+ if (TERMINAL_RUN_STATES.has(input.to)) throw new StoreError("invalid", "A terminal state is committed by completeRun");
743
+ await this.connect();
744
+ const now = this.now();
745
+ return this.tx(() => {
746
+ const run = this.requireRun(input.sessionId, input.reference);
747
+ if (!input.from.has(run.state) || TERMINAL_RUN_STATES.has(run.state)) return false;
748
+ this.applyRunState(input.reference, input.to, now, undefined, input.error);
749
+ if (input.alert) this.upsertAlert(input.sessionId, input.reference, input.alert, now);
750
+ return true;
751
+ });
752
+ }
753
+
754
+ /** Running Task runs whose next progress alert is due by the store's clock. */
755
+ async runsNeedingProgress(sessionId: string): Promise<RunRecord[]> {
756
+ await this.connect();
757
+ const rows = this.stmt(
758
+ `SELECT * FROM runs WHERE session_id = ? AND kind = 'task' AND state = 'pending'
759
+ AND next_alert_at IS NOT NULL AND next_alert_at <= ? ORDER BY next_alert_at LIMIT 50`,
760
+ ).all(sessionId, this.now()) as Record<string, unknown>[];
761
+ return rows.map(row => this.toRun(row));
762
+ }
763
+
764
+ /**
765
+ * Queue one progress alert and advance the run's timetable by one interval,
766
+ * in one transaction: a failure leaves both undone, so the alert is neither
767
+ * lost nor queued twice. Returns false when the run is no longer running.
768
+ */
769
+ async queueProgress(sessionId: string, reference: string, alert: AlertInput): Promise<boolean> {
770
+ await this.connect();
771
+ const now = this.now();
772
+ return this.tx(() => {
773
+ const run = this.requireRun(sessionId, reference);
774
+ if (run.state !== "pending" || run.alert_interval_ms === undefined) return false;
775
+ this.upsertAlert(sessionId, reference, alert, now);
776
+ this.stmt(`UPDATE runs SET next_alert_at = ? WHERE reference = ?`).run(now + run.alert_interval_ms, reference);
777
+ return true;
778
+ });
779
+ }
780
+
781
+ /** Reading a run's evidence is real evidence that its alerts were received. */
782
+ async acknowledgeRunAlerts(sessionId: string, reference: string): Promise<number> {
783
+ await this.connect();
784
+ const info = this.stmt(
785
+ `UPDATE alerts SET state = 'acknowledged', updated_at = ? WHERE session_id = ? AND reference = ? AND state IN ('claimed','delivered')`,
786
+ ).run(this.now(), sessionId, reference);
787
+ return Number(info.changes);
788
+ }
789
+
790
+ /**
791
+ * Resolve a local Search/Extract receipt to its provider research context.
792
+ * Returns undefined when the run carries no provider session (for example a
793
+ * Task run), so a caller can never smuggle a foreign provider id in by hand.
794
+ */
795
+ async providerContext(sessionId: string, reference: string): Promise<string | undefined> {
796
+ await this.connect();
797
+ const row = this.stmt(`SELECT provider_session_id FROM runs WHERE reference = ? AND session_id = ?`).get(reference, sessionId) as
798
+ | { provider_session_id: string | null }
799
+ | undefined;
800
+ if (!row) throw new StoreError("not_found", `No research record ${reference} in this session`);
801
+ return nullable(row.provider_session_id);
802
+ }
803
+
804
+ /**
805
+ * Move a run to a new state. Terminal states are final: a completed run
806
+ * cannot be silently rewritten by a later, weaker event.
807
+ */
808
+ async setRunState(input: { sessionId: string; reference: string; state: RunState; summary?: string; error?: string }): Promise<void> {
809
+ await this.connect();
810
+ const now = this.now();
811
+ this.tx(() => {
812
+ const run = this.requireRun(input.sessionId, input.reference);
813
+ if (run.state === input.state) return;
814
+ if (TERMINAL_RUN_STATES.has(run.state)) {
815
+ throw new StoreError("conflict", `Run ${input.reference} is already ${run.state}`);
816
+ }
817
+ this.applyRunState(input.reference, input.state, now, input.summary, input.error);
818
+ });
819
+ }
820
+
821
+ private applyRunState(reference: string, state: RunState, now: number, summary?: string, error?: string): void {
822
+ this.stmt(
823
+ `UPDATE runs SET state = ?, summary = COALESCE(?, summary), error = ?, updated_at = ?, terminal_at = ? WHERE reference = ?`,
824
+ ).run(state, summary ?? null, error ?? null, now, TERMINAL_RUN_STATES.has(state) ? now : null, reference);
825
+ }
826
+
827
+ private requireRun(sessionId: string, reference: string): RunRecord {
828
+ const row = this.stmt(`SELECT * FROM runs WHERE reference = ? AND session_id = ?`).get(reference, sessionId) as Record<string, unknown> | undefined;
829
+ if (!row) throw new StoreError("not_found", `No research run ${reference} in this session`);
830
+ return this.toRun(row);
831
+ }
832
+
833
+ private toRun(row: Record<string, unknown>): RunRecord {
834
+ return {
835
+ reference: row.reference as string,
836
+ session_id: row.session_id as string,
837
+ kind: (row.kind as RunKind) ?? "task",
838
+ group_id: nullable(row.group_id as string | null),
839
+ provider_run_id: nullable(row.provider_run_id as string | null),
840
+ provider_session_id: nullable(row.provider_session_id as string | null),
841
+ parent: nullable(row.parent as string | null),
842
+ intent_key: row.intent_key as string,
843
+ state: row.state as RunState,
844
+ processor: nullable(row.processor as string | null),
845
+ intent: JSON.parse(row.intent as string),
846
+ summary: nullable(row.summary as string | null),
847
+ error: nullable(row.error as string | null),
848
+ created_at: row.created_at as number,
849
+ updated_at: row.updated_at as number,
850
+ terminal_at: row.terminal_at === null ? undefined : (row.terminal_at as number),
851
+ provider_interaction_id: nullable(row.provider_interaction_id as string | null),
852
+ running_at: row.running_at === null ? undefined : (row.running_at as number),
853
+ next_alert_at: row.next_alert_at === null ? undefined : (row.next_alert_at as number),
854
+ alert_interval_ms: row.alert_interval_ms === null ? undefined : (row.alert_interval_ms as number),
855
+ };
856
+ }
857
+
858
+ async run(sessionId: string, reference: string): Promise<RunRecord | undefined> {
859
+ await this.connect();
860
+ const row = this.stmt(`SELECT * FROM runs WHERE reference = ? AND session_id = ?`).get(reference, sessionId) as Record<string, unknown> | undefined;
861
+ return row ? this.toRun(row) : undefined;
862
+ }
863
+
864
+ async runByProviderId(sessionId: string, providerRunId: string): Promise<RunRecord | undefined> {
865
+ await this.connect();
866
+ const row = this.stmt(`SELECT * FROM runs WHERE provider_run_id = ? AND session_id = ?`).get(providerRunId, sessionId) as Record<string, unknown> | undefined;
867
+ return row ? this.toRun(row) : undefined;
868
+ }
869
+
870
+ async listRuns(sessionId: string, options: { states?: RunState[]; limit?: number } = {}): Promise<RunRecord[]> {
871
+ await this.connect();
872
+ const limit = clamp(options.limit, 50, 1, 500);
873
+ const states = options.states?.length ? options.states : undefined;
874
+ const rows = states
875
+ ? this.stmt(
876
+ `SELECT * FROM runs WHERE session_id = ? AND state IN (${states.map(() => "?").join(",")}) ORDER BY created_at DESC, reference LIMIT ?`,
877
+ ).all(sessionId, ...states, limit)
878
+ : this.stmt(`SELECT * FROM runs WHERE session_id = ? ORDER BY created_at DESC, reference LIMIT ?`).all(sessionId, limit);
879
+ return (rows as Record<string, unknown>[]).map(row => this.toRun(row));
880
+ }
881
+
882
+ /**
883
+ * Mark synchronous requests that were interrupted before their result was
884
+ * stored. A Search/Extract row left in `intent`/`submitting` cannot be
885
+ * trusted either way, so it becomes `uncertain` instead of looking unfinished
886
+ * forever. Nothing is resubmitted here; the agent sees the honest state.
887
+ *
888
+ * Task runs are excluded: their recovery is a provider reconciliation, not a
889
+ * local guess, and that is handled separately.
890
+ */
891
+ async reconcileInterrupted(sessionId: string, options: { olderThanMs?: number } = {}): Promise<number> {
892
+ await this.connect();
893
+ const now = this.now();
894
+ const cutoff = now - Math.max(0, options.olderThanMs ?? 60_000);
895
+ const info = this.stmt(
896
+ `UPDATE runs SET state = 'uncertain', error = COALESCE(error, 'interrupted before the result was stored'), updated_at = ?
897
+ WHERE session_id = ? AND state IN (${sqlStates(IN_FLIGHT_RUN_STATES)})
898
+ AND kind IN ('search','extract','answer') AND updated_at <= ?`,
899
+ ).run(now, sessionId, cutoff);
900
+ return Number(info.changes);
901
+ }
902
+
903
+ // ------------------------------------------------------------ observations
904
+
905
+ /**
906
+ * Store one received progress or provider event verbatim.
907
+ *
908
+ * `dedupKey` collapses a replay of the same event (reconnects replay recorded
909
+ * progress). Callers must derive it from stable provider data — run, type,
910
+ * timestamp, payload — and must not derive it from message text alone, which
911
+ * would merge genuinely distinct events.
912
+ */
913
+ async appendObservation(input: {
914
+ sessionId: string;
915
+ reference: string;
916
+ source: string;
917
+ eventType: string;
918
+ providerEventId?: string;
919
+ dedupKey?: string;
920
+ /** Replace the stored payload of a repeated key (latest-snapshot events such as statistics). */
921
+ replaceIfExists?: boolean;
922
+ payload: unknown;
923
+ }): Promise<{ id: number; duplicate: boolean }> {
924
+ await this.connect();
925
+ const now = this.now();
926
+ return this.tx(() => {
927
+ this.requireRun(input.sessionId, input.reference);
928
+ const info = this.stmt(
929
+ `INSERT OR IGNORE INTO observations (session_id, reference, source, event_type, provider_event_id, dedup_key, received_at, payload)
930
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
931
+ ).run(
932
+ input.sessionId, input.reference, input.source, input.eventType,
933
+ input.providerEventId ?? null, input.dedupKey ?? null, now, JSON.stringify(input.payload ?? null),
934
+ );
935
+ if (Number(info.changes) === 1) return { id: Number(info.lastInsertRowid), duplicate: false };
936
+ if (input.dedupKey === undefined) throw new StoreError("conflict", "Observation insert was ignored without a dedup key");
937
+ const existing = this.stmt(`SELECT id FROM observations WHERE reference = ? AND dedup_key = ?`).get(input.reference, input.dedupKey) as
938
+ | { id: number }
939
+ | undefined;
940
+ if (existing && input.replaceIfExists) {
941
+ this.stmt(`UPDATE observations SET payload = ?, received_at = ? WHERE id = ?`).run(JSON.stringify(input.payload ?? null), now, existing.id);
942
+ }
943
+ return { id: existing?.id ?? 0, duplicate: true };
944
+ });
945
+ }
946
+
947
+ /** Store one piece of evidence outside a completion (for example an intermediate finding). */
948
+ async appendEvidence(sessionId: string, reference: string, input: EvidenceInput): Promise<{ handle: string }> {
949
+ await this.connect();
950
+ const now = this.now();
951
+ return this.tx(() => {
952
+ this.requireRun(sessionId, reference);
953
+ return { handle: this.insertEvidence(sessionId, reference, input, now).handle };
954
+ });
955
+ }
956
+
957
+ async observations(sessionId: string, reference: string, options: { afterId?: number; limit?: number } = {}): Promise<
958
+ { id: number; source: string; eventType: string; providerEventId?: string; receivedAt: number; payload: unknown }[]
959
+ > {
960
+ await this.connect();
961
+ this.requireRun(sessionId, reference);
962
+ const limit = clamp(options.limit, 50, 1, 500);
963
+ const rows = this.stmt(
964
+ `SELECT id, source, event_type, provider_event_id, received_at, payload FROM observations
965
+ WHERE reference = ? AND session_id = ? AND id > ? ORDER BY id LIMIT ?`,
966
+ ).all(reference, sessionId, options.afterId ?? 0, limit) as Record<string, unknown>[];
967
+ return rows.map(row => ({
968
+ id: row.id as number,
969
+ source: row.source as string,
970
+ eventType: row.event_type as string,
971
+ providerEventId: nullable(row.provider_event_id as string | null),
972
+ receivedAt: row.received_at as number,
973
+ payload: JSON.parse(row.payload as string),
974
+ }));
975
+ }
976
+
977
+ /** The newest observations of one run, newest first, optionally of one event type. */
978
+ async latestObservations(sessionId: string, reference: string, options: { eventType?: string; limit?: number } = {}): Promise<
979
+ { id: number; eventType: string; payload: unknown }[]
980
+ > {
981
+ await this.connect();
982
+ const limit = clamp(options.limit, 50, 1, 500);
983
+ const rows = (options.eventType === undefined
984
+ ? this.stmt(`SELECT id, event_type, payload FROM observations WHERE reference = ? AND session_id = ? ORDER BY id DESC LIMIT ?`)
985
+ .all(reference, sessionId, limit)
986
+ : this.stmt(`SELECT id, event_type, payload FROM observations WHERE reference = ? AND session_id = ? AND event_type = ? ORDER BY id DESC LIMIT ?`)
987
+ .all(reference, sessionId, options.eventType, limit)) as Record<string, unknown>[];
988
+ return rows.map(row => ({ id: row.id as number, eventType: row.event_type as string, payload: JSON.parse(row.payload as string) }));
989
+ }
990
+
991
+ // --------------------------------------------------------------- evidence
992
+
993
+ private insertEvidence(sessionId: string, reference: string, input: EvidenceInput, now: number): { id: number; handle: string } {
994
+ if (!input.body) throw new StoreError("invalid", "Evidence needs a body");
995
+ if (!EVIDENCE_KINDS.has(input.kind)) throw new StoreError("invalid", `Unknown evidence kind ${input.kind}`);
996
+ const createdAt = input.created_at ?? now;
997
+ for (let attempt = 0; attempt < 5; attempt++) {
998
+ const handle = newId("ev");
999
+ try {
1000
+ const info = this.stmt(
1001
+ `INSERT INTO evidence (handle, session_id, reference, kind, origin, title, url, pointer, body, payload, created_at)
1002
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1003
+ ).run(
1004
+ handle, sessionId, reference, input.kind, input.origin, input.title ?? null, input.url ?? null,
1005
+ input.pointer ?? null, input.body, input.payload === undefined ? null : JSON.stringify(input.payload), createdAt,
1006
+ );
1007
+ const id = Number(info.lastInsertRowid);
1008
+ // Same transaction as the row: a body is never searchable before it is stored, or vice versa.
1009
+ this.stmt(`INSERT INTO evidence_fts (rowid, title, body) VALUES (?, ?, ?)`).run(id, input.title ?? "", input.body);
1010
+ return { id, handle };
1011
+ } catch (err) {
1012
+ if (String((err as { message?: string }).message).includes("UNIQUE") && attempt < 4) continue;
1013
+ throw err;
1014
+ }
1015
+ }
1016
+ throw new StoreError("conflict", "Could not allocate an evidence handle");
1017
+ }
1018
+
1019
+ private evidenceIdByHandle(sessionId: string, handle: string): number {
1020
+ const row = this.stmt(`SELECT id FROM evidence WHERE handle = ? AND session_id = ?`).get(handle, sessionId) as { id: number } | undefined;
1021
+ if (!row) throw new StoreError("not_found", `No evidence ${handle} in this session`);
1022
+ return row.id;
1023
+ }
1024
+
1025
+ /** Column names of one table, for migration checks and diagnostics. */
1026
+ async schemaColumns(table: "runs" | "evidence" | "alerts" | "observations" | "citations" | "task_groups" | "sessions"): Promise<string[]> {
1027
+ await this.connect();
1028
+ return (this.stmt(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(column => column.name);
1029
+ }
1030
+
1031
+ /** The store's schema version on disk. */
1032
+ async schemaVersion(): Promise<number> {
1033
+ await this.connect();
1034
+ return Number((this.stmt("PRAGMA user_version").get() as { user_version: number }).user_version);
1035
+ }
1036
+
1037
+ /**
1038
+ * Commit a terminal result: payload, evidence, citations, run state, and
1039
+ * pending alerts in one transaction.
1040
+ *
1041
+ * Returning `changed: false` means the same terminal state was already
1042
+ * committed, so a duplicated completion event is harmless. A different
1043
+ * terminal state is a conflict rather than a silent overwrite.
1044
+ */
1045
+ async completeRun(input: {
1046
+ sessionId: string;
1047
+ reference: string;
1048
+ state: "completed" | "failed" | "cancelled";
1049
+ evidence?: EvidenceInput[];
1050
+ citations?: CitationInput[];
1051
+ alerts?: AlertInput[];
1052
+ summary?: string;
1053
+ error?: string;
1054
+ /** Provider operation id for this request (run id, search id, extract id). */
1055
+ providerRunId?: string;
1056
+ /** Provider research context (Search/Extract session id). */
1057
+ providerSessionId?: string;
1058
+ /** Provider interaction id, for later continuation. */
1059
+ providerInteractionId?: string;
1060
+ /** Untouched provider result object, stored verbatim for later re-reading. */
1061
+ rawResult?: unknown;
1062
+ }): Promise<{ changed: boolean; evidence: StoredEvidence[]; alertIds: string[] }> {
1063
+ await this.connect();
1064
+ const now = this.now();
1065
+ return this.tx(() => {
1066
+ const run = this.requireRun(input.sessionId, input.reference);
1067
+ if (TERMINAL_RUN_STATES.has(run.state)) {
1068
+ if (run.state === input.state) return { changed: false, evidence: [], alertIds: [] };
1069
+ throw new StoreError("conflict", `Run ${input.reference} is already ${run.state}`);
1070
+ }
1071
+ if (input.state === "completed" && !(input.evidence ?? []).length && input.rawResult === undefined) {
1072
+ throw new StoreError("invalid", "A completed run needs evidence or a provider payload");
1073
+ }
1074
+
1075
+ const handles: (StoredEvidence & { id: number })[] = [];
1076
+ const aliases = new Map<string, number>();
1077
+ for (const item of input.evidence ?? []) {
1078
+ const stored = this.insertEvidence(input.sessionId, input.reference, item, now);
1079
+ if (item.ref !== undefined) {
1080
+ if (aliases.has(item.ref)) throw new StoreError("invalid", `Duplicate evidence alias ${item.ref}`);
1081
+ aliases.set(item.ref, stored.id);
1082
+ }
1083
+ handles.push({ ...stored, kind: item.kind, title: item.title, url: item.url, pointer: item.pointer });
1084
+ }
1085
+ const resolve = (handleOrAlias: string): number => aliases.get(handleOrAlias) ?? this.evidenceIdByHandle(input.sessionId, handleOrAlias);
1086
+ for (const citation of input.citations ?? []) {
1087
+ const evidenceId = resolve(citation.evidence);
1088
+ const sourceId = citation.source === undefined ? undefined : resolve(citation.source);
1089
+ this.stmt(
1090
+ `INSERT INTO citations (session_id, evidence_id, source_id, claim, url, title) VALUES (?, ?, ?, ?, ?, ?)`,
1091
+ ).run(input.sessionId, evidenceId, sourceId ?? null, citation.claim ?? null, citation.url ?? null, citation.title ?? null);
1092
+ }
1093
+
1094
+ this.stmt(
1095
+ `UPDATE runs SET raw_result = COALESCE(?, raw_result),
1096
+ provider_run_id = COALESCE(?, provider_run_id),
1097
+ provider_session_id = COALESCE(?, provider_session_id),
1098
+ provider_interaction_id = COALESCE(?, provider_interaction_id)
1099
+ WHERE reference = ?`,
1100
+ ).run(
1101
+ input.rawResult === undefined ? null : JSON.stringify(input.rawResult),
1102
+ input.providerRunId ?? null, input.providerSessionId ?? null, input.providerInteractionId ?? null, input.reference,
1103
+ );
1104
+ this.applyRunState(input.reference, input.state, now, input.summary, input.error);
1105
+
1106
+ const alertIds = (input.alerts ?? []).map(alert => this.upsertAlert(input.sessionId, input.reference, alert, now));
1107
+ return {
1108
+ changed: true,
1109
+ evidence: handles.map(({ id: _id, ...item }) => item),
1110
+ alertIds,
1111
+ };
1112
+ });
1113
+ }
1114
+
1115
+ async rawResult(sessionId: string, reference: string): Promise<unknown> {
1116
+ await this.connect();
1117
+ const row = this.stmt(`SELECT raw_result FROM runs WHERE reference = ? AND session_id = ?`).get(reference, sessionId) as
1118
+ | { raw_result: string | null }
1119
+ | undefined;
1120
+ if (!row) throw new StoreError("not_found", `No research run ${reference} in this session`);
1121
+ return row.raw_result === null ? undefined : JSON.parse(row.raw_result);
1122
+ }
1123
+
1124
+ async citations(sessionId: string, evidenceHandle: string): Promise<{ claim?: string; url?: string; title?: string; sourceHandle?: string }[]> {
1125
+ await this.connect();
1126
+ const evidenceId = this.evidenceIdByHandle(sessionId, evidenceHandle);
1127
+ const rows = this.stmt(
1128
+ `SELECT c.claim, c.url, c.title, s.handle AS source_handle
1129
+ FROM citations c LEFT JOIN evidence s ON s.id = c.source_id
1130
+ WHERE c.evidence_id = ? AND c.session_id = ? ORDER BY c.id`,
1131
+ ).all(evidenceId, sessionId) as Record<string, unknown>[];
1132
+ return rows.map(row => ({
1133
+ claim: nullable(row.claim as string | null),
1134
+ url: nullable(row.url as string | null),
1135
+ title: nullable(row.title as string | null),
1136
+ sourceHandle: nullable(row.source_handle as string | null),
1137
+ }));
1138
+ }
1139
+
1140
+ // ----------------------------------------------------------------- search
1141
+
1142
+ /**
1143
+ * Bounded evidence search. With `query`, ranks by FTS5 bm25; without it,
1144
+ * lists newest evidence first (used for a run's completion index).
1145
+ *
1146
+ * A page pins a snapshot of the highest evidence id. Evidence stored after
1147
+ * the first page cannot change that page sequence, so a cursor never repeats
1148
+ * or skips rows; a fresh search is the way to see new material.
1149
+ */
1150
+ async search(sessionId: string, options: { query?: string; reference?: string; kinds?: EvidenceKind[]; limit?: number; cursor?: string } = {}): Promise<SearchPage> {
1151
+ await this.connect();
1152
+ const limit = clamp(options.limit, 10, 1, 50);
1153
+ const cursor = options.cursor === undefined ? undefined : decodeCursor(options.cursor);
1154
+ // A cursor continues exactly the search that produced it; its filters win over anything passed beside it.
1155
+ const text = cursor ? cursor.query : options.query?.trim() || undefined;
1156
+ const reference = cursor ? cursor.reference : options.reference || undefined;
1157
+ let kinds: EvidenceKind[] | undefined = cursor ? cursor.kinds : undefined;
1158
+ if (!cursor && options.kinds?.length) {
1159
+ kinds = options.kinds.filter(kind => EVIDENCE_KINDS.has(kind));
1160
+ if (!kinds.length) throw new StoreError("invalid", "No known evidence kinds requested");
1161
+ }
1162
+ const snapshot = cursor?.snapshot ?? Number(
1163
+ (this.stmt(`SELECT COALESCE(MAX(id), 0) AS max FROM evidence WHERE session_id = ?`).get(sessionId) as { max: number }).max,
1164
+ );
1165
+ const offset = cursor?.offset ?? 0;
1166
+
1167
+ const filters: string[] = ["e.session_id = ?", "e.id <= ?"];
1168
+ const params: (string | number)[] = [sessionId, snapshot];
1169
+ if (reference) {
1170
+ filters.push("e.reference = ?");
1171
+ params.push(reference);
1172
+ }
1173
+ if (kinds?.length) {
1174
+ filters.push(`e.kind IN (${kinds.map(() => "?").join(",")})`);
1175
+ params.push(...kinds);
1176
+ }
1177
+
1178
+ const query = text ? ftsQuery(text) : undefined;
1179
+ if (text && !query) return { hits: [], snapshot, more: false };
1180
+
1181
+ let rows: Record<string, unknown>[];
1182
+ if (query) {
1183
+ rows = this.stmt(
1184
+ `SELECT e.id, e.handle, e.reference, e.kind, e.origin, e.title, e.url, e.pointer, LENGTH(e.body) AS chars, e.created_at,
1185
+ snippet(evidence_fts, 1, '[', ']', '…', 12) AS snippet, bm25(evidence_fts) AS score
1186
+ FROM evidence_fts JOIN evidence e ON e.id = evidence_fts.rowid
1187
+ WHERE evidence_fts MATCH ? AND ${filters.join(" AND ")}
1188
+ ORDER BY score ASC, e.id ASC LIMIT ? OFFSET ?`,
1189
+ ).all(query, ...params, limit + 1, offset) as Record<string, unknown>[];
1190
+ } else {
1191
+ rows = this.stmt(
1192
+ `SELECT e.id, e.handle, e.reference, e.kind, e.origin, e.title, e.url, e.pointer, LENGTH(e.body) AS chars, e.created_at,
1193
+ '' AS snippet
1194
+ FROM evidence e WHERE ${filters.join(" AND ")}
1195
+ ORDER BY e.id DESC LIMIT ? OFFSET ?`,
1196
+ ).all(...params, limit + 1, offset) as Record<string, unknown>[];
1197
+ }
1198
+
1199
+ const more = rows.length > limit;
1200
+ const page = rows.slice(0, limit);
1201
+ return {
1202
+ hits: page.map(row => ({
1203
+ handle: row.handle as string,
1204
+ reference: row.reference as string,
1205
+ kind: row.kind as EvidenceKind,
1206
+ origin: row.origin as EvidenceOrigin,
1207
+ title: nullable(row.title as string | null),
1208
+ url: nullable(row.url as string | null),
1209
+ pointer: nullable(row.pointer as string | null),
1210
+ snippet: (row.snippet as string) ?? "",
1211
+ chars: row.chars as number,
1212
+ created_at: row.created_at as number,
1213
+ })),
1214
+ snapshot,
1215
+ more,
1216
+ next_cursor: more ? encodeCursor({ v: 2, snapshot, offset: offset + limit, query: text, reference, kinds }) : undefined,
1217
+ };
1218
+ }
1219
+
1220
+ /**
1221
+ * Read one stored body by handle, in bounded slices.
1222
+ *
1223
+ * Offsets and lengths are UTF-16 code units, matching JavaScript string
1224
+ * indexing; a slice never splits a surrogate pair.
1225
+ */
1226
+ async readEvidence(sessionId: string, handle: string, options: { offset?: number; limit?: number } = {}): Promise<EvidenceSlice> {
1227
+ await this.connect();
1228
+ const row = this.stmt(
1229
+ `SELECT handle, reference, kind, origin, title, url, pointer, body, created_at
1230
+ FROM evidence WHERE handle = ? AND session_id = ?`,
1231
+ ).get(handle, sessionId) as Record<string, unknown> | undefined;
1232
+ if (!row) throw new StoreError("not_found", `No evidence ${handle} in this session`);
1233
+
1234
+ const body = row.body as string;
1235
+ const total = body.length;
1236
+ let start = clamp(options.offset, 0, 0, total);
1237
+ // An offset between the halves of a pair backs up to include the pair whole.
1238
+ if (start > 0 && start < total && isLowSurrogate(body.charCodeAt(start)) && isHighSurrogate(body.charCodeAt(start - 1))) start -= 1;
1239
+ let end = Math.min(total, start + clamp(options.limit, 4000, 1, 20000));
1240
+ if (end < total && isLowSurrogate(body.charCodeAt(end)) && isHighSurrogate(body.charCodeAt(end - 1))) end += 1;
1241
+
1242
+ const text = body.slice(start, end);
1243
+ const truncatedAfter = end < total;
1244
+ return {
1245
+ handle: row.handle as string,
1246
+ reference: row.reference as string,
1247
+ kind: row.kind as EvidenceKind,
1248
+ origin: row.origin as EvidenceOrigin,
1249
+ title: nullable(row.title as string | null),
1250
+ url: nullable(row.url as string | null),
1251
+ pointer: nullable(row.pointer as string | null),
1252
+ offset: start,
1253
+ text,
1254
+ total_chars: total,
1255
+ truncated_before: start > 0,
1256
+ truncated_after: truncatedAfter,
1257
+ next_offset: truncatedAfter ? end : undefined,
1258
+ created_at: row.created_at as number,
1259
+ };
1260
+ }
1261
+
1262
+ // ----------------------------------------------------------------- alerts
1263
+
1264
+ private upsertAlert(sessionId: string, reference: string, alert: AlertInput, now: number): string {
1265
+ if (!alert.dedup_key) throw new StoreError("invalid", "An alert needs a dedup key");
1266
+ const existing = this.stmt(`SELECT alert_id, state FROM alerts WHERE session_id = ? AND dedup_key = ?`).get(sessionId, alert.dedup_key) as
1267
+ | { alert_id: string; state: AlertState }
1268
+ | undefined;
1269
+ if (existing) {
1270
+ // Coalesce: refresh the content of an open alert, leave an acknowledged one alone.
1271
+ if (existing.state !== "acknowledged") {
1272
+ this.stmt(`UPDATE alerts SET kind = ?, headline = ?, body = ?, state = 'pending', updated_at = ? WHERE alert_id = ?`).run(
1273
+ alert.kind, alert.headline, alert.body ?? null, now, existing.alert_id,
1274
+ );
1275
+ }
1276
+ return existing.alert_id;
1277
+ }
1278
+ const alertId = newId("alert");
1279
+ this.stmt(
1280
+ `INSERT INTO alerts (alert_id, session_id, reference, kind, headline, body, dedup_key, state, attempts, created_at, updated_at)
1281
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
1282
+ ).run(alertId, sessionId, reference, alert.kind, alert.headline, alert.body ?? null, alert.dedup_key, now, now);
1283
+ return alertId;
1284
+ }
1285
+
1286
+ /** Add or coalesce an alert outside a completion (progress updates). */
1287
+ async enqueueAlert(sessionId: string, reference: string, alert: AlertInput): Promise<string> {
1288
+ await this.connect();
1289
+ const now = this.now();
1290
+ return this.tx(() => {
1291
+ this.requireRun(sessionId, reference);
1292
+ return this.upsertAlert(sessionId, reference, alert, now);
1293
+ });
1294
+ }
1295
+
1296
+ /**
1297
+ * Take the next alerts to hand to Pi. Rows left `claimed` by a crash are
1298
+ * picked up again; each claim bumps `attempts`.
1299
+ *
1300
+ * Terminal alerts come first. `delivered` rows are not claimed here: a live
1301
+ * runtime may still hold that follow-up. Call `requeueUnacknowledged` when a
1302
+ * new runtime takes over the session.
1303
+ */
1304
+ async claimAlerts(sessionId: string, options: { limit?: number } = {}): Promise<AlertRecord[]> {
1305
+ await this.connect();
1306
+ const limit = clamp(options.limit, 5, 1, 50);
1307
+ const now = this.now();
1308
+ return this.tx(() => {
1309
+ const rows = this.stmt(
1310
+ `SELECT * FROM alerts WHERE session_id = ? AND state IN ('pending','claimed')
1311
+ ORDER BY CASE kind WHEN 'failure' THEN 0 WHEN 'action_required' THEN 0 WHEN 'completion' THEN 1 ELSE 2 END,
1312
+ created_at ASC, alert_id ASC
1313
+ LIMIT ?`,
1314
+ ).all(sessionId, limit) as Record<string, unknown>[];
1315
+ for (const row of rows) {
1316
+ this.stmt(`UPDATE alerts SET state = 'claimed', attempts = attempts + 1, updated_at = ? WHERE alert_id = ?`).run(now, row.alert_id as string);
1317
+ }
1318
+ return rows.map(row => this.toAlert({ ...row, state: "claimed", attempts: Number(row.attempts) + 1, updated_at: now }));
1319
+ });
1320
+ }
1321
+
1322
+ /** Record that a handoff was attempted. Not proof of delivery; keep the row open. */
1323
+ async markAlertDelivered(sessionId: string, alertId: string): Promise<void> {
1324
+ await this.connect();
1325
+ this.stmt(
1326
+ `UPDATE alerts SET state = 'delivered', updated_at = ? WHERE alert_id = ? AND session_id = ? AND state = 'claimed'`,
1327
+ ).run(this.now(), alertId, sessionId);
1328
+ }
1329
+
1330
+ /** Clear an alert only after the delivery has been confirmed durable. */
1331
+ async acknowledgeAlert(sessionId: string, alertId: string): Promise<void> {
1332
+ await this.connect();
1333
+ this.stmt(
1334
+ `UPDATE alerts SET state = 'acknowledged', updated_at = ? WHERE alert_id = ? AND session_id = ? AND state IN ('claimed','delivered')`,
1335
+ ).run(this.now(), alertId, sessionId);
1336
+ }
1337
+
1338
+ /**
1339
+ * New runtime, same session: unacknowledged handoffs from the previous
1340
+ * runtime are eligible for delivery again. Duplicates are possible; silence
1341
+ * is not.
1342
+ */
1343
+ async requeueUnacknowledgedAlerts(sessionId: string): Promise<number> {
1344
+ await this.connect();
1345
+ const info = this.stmt(
1346
+ `UPDATE alerts SET state = 'pending', updated_at = ? WHERE session_id = ? AND state IN ('claimed','delivered')`,
1347
+ ).run(this.now(), sessionId);
1348
+ return Number(info.changes);
1349
+ }
1350
+
1351
+ async alerts(sessionId: string, options: { reference?: string; states?: AlertState[]; limit?: number } = {}): Promise<AlertRecord[]> {
1352
+ await this.connect();
1353
+ const limit = clamp(options.limit, 50, 1, 200);
1354
+ const filters = ["session_id = ?"];
1355
+ const params: (string | number)[] = [sessionId];
1356
+ if (options.reference) {
1357
+ filters.push("reference = ?");
1358
+ params.push(options.reference);
1359
+ }
1360
+ if (options.states?.length) {
1361
+ filters.push(`state IN (${options.states.map(() => "?").join(",")})`);
1362
+ params.push(...options.states);
1363
+ }
1364
+ const rows = this.stmt(
1365
+ `SELECT * FROM alerts WHERE ${filters.join(" AND ")} ORDER BY created_at DESC, alert_id LIMIT ?`,
1366
+ ).all(...params, limit) as Record<string, unknown>[];
1367
+ return rows.map(row => this.toAlert(row));
1368
+ }
1369
+
1370
+ private toAlert(row: Record<string, unknown>): AlertRecord {
1371
+ return {
1372
+ alert_id: row.alert_id as string,
1373
+ reference: row.reference as string,
1374
+ kind: row.kind as AlertKind,
1375
+ headline: row.headline as string,
1376
+ body: nullable(row.body as string | null),
1377
+ state: row.state as AlertState,
1378
+ attempts: Number(row.attempts),
1379
+ created_at: row.created_at as number,
1380
+ updated_at: row.updated_at as number,
1381
+ };
1382
+ }
1383
+
1384
+ // ------------------------------------------------------------------ misc
1385
+
1386
+ /**
1387
+ * One row per run of this session, with the number of sources it stored. Feeds
1388
+ * the footer status, so its counts survive a reload of the extension.
1389
+ */
1390
+ async runSummaries(sessionId: string): Promise<{ kind: RunKind; state: RunState; processor?: string; intent: unknown; sources: number }[]> {
1391
+ await this.connect();
1392
+ const rows = this.stmt(
1393
+ `SELECT r.kind, r.state, r.processor, r.intent,
1394
+ (SELECT COUNT(*) FROM evidence e WHERE e.reference = r.reference AND e.kind IN ('source','excerpt')) AS sources
1395
+ FROM runs r WHERE r.session_id = ?`,
1396
+ ).all(sessionId) as { kind: RunKind; state: RunState; processor: string | null; intent: string; sources: number }[];
1397
+ return rows.map(row => ({
1398
+ kind: row.kind,
1399
+ state: row.state,
1400
+ processor: nullable(row.processor),
1401
+ intent: JSON.parse(row.intent),
1402
+ sources: Number(row.sources),
1403
+ }));
1404
+ }
1405
+
1406
+ async stats(sessionId: string): Promise<StoreStats> {
1407
+ await this.connect();
1408
+ const evidence = this.stmt(`SELECT COUNT(*) AS n, COALESCE(SUM(LENGTH(body)), 0) AS chars FROM evidence WHERE session_id = ?`).get(sessionId) as
1409
+ | { n: number; chars: number }
1410
+ | undefined;
1411
+ const runs: Partial<Record<RunState, number>> = {};
1412
+ for (const row of this.stmt(`SELECT state, COUNT(*) AS n FROM runs WHERE session_id = ? GROUP BY state`).all(sessionId) as { state: RunState; n: number }[]) {
1413
+ runs[row.state] = row.n;
1414
+ }
1415
+ const alerts: Partial<Record<AlertState, number>> = {};
1416
+ for (const row of this.stmt(`SELECT state, COUNT(*) AS n FROM alerts WHERE session_id = ? GROUP BY state`).all(sessionId) as { state: AlertState; n: number }[]) {
1417
+ alerts[row.state] = row.n;
1418
+ }
1419
+ return { evidence: evidence?.n ?? 0, chars: evidence?.chars ?? 0, runs, alerts };
1420
+ }
1421
+
1422
+ /**
1423
+ * Delete one run and everything derived from it, keeping the search index in
1424
+ * step. Retention policy is still undecided; this exists so a later policy
1425
+ * has a correct primitive instead of ad-hoc SQL.
1426
+ */
1427
+ async deleteRun(sessionId: string, reference: string): Promise<{ evidence: number }> {
1428
+ await this.connect();
1429
+ return this.tx(() => {
1430
+ this.requireRun(sessionId, reference);
1431
+ const rows = this.stmt(`SELECT id, title, body FROM evidence WHERE reference = ? AND session_id = ?`).all(reference, sessionId) as
1432
+ { id: number; title: string | null; body: string }[];
1433
+ for (const row of rows) {
1434
+ this.stmt(`INSERT INTO evidence_fts (evidence_fts, rowid, title, body) VALUES ('delete', ?, ?, ?)`).run(row.id, row.title ?? "", row.body);
1435
+ }
1436
+ // Continuations of this run keep their own evidence; only the link to the deleted parent goes.
1437
+ this.stmt(`UPDATE runs SET parent = NULL WHERE parent = ? AND session_id = ?`).run(reference, sessionId);
1438
+ this.stmt(`DELETE FROM evidence WHERE reference = ? AND session_id = ?`).run(reference, sessionId);
1439
+ this.stmt(`DELETE FROM observations WHERE reference = ? AND session_id = ?`).run(reference, sessionId);
1440
+ this.stmt(`DELETE FROM alerts WHERE reference = ? AND session_id = ?`).run(reference, sessionId);
1441
+ this.stmt(`DELETE FROM runs WHERE reference = ? AND session_id = ?`).run(reference, sessionId);
1442
+ return { evidence: rows.length };
1443
+ });
1444
+ }
1445
+ }