@avocadostudio-ai/orchestrator-core 0.3.2 → 0.3.3

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.
Files changed (69) hide show
  1. package/dist/chat/anthropic-planner.d.ts +8 -0
  2. package/dist/chat/anthropic-planner.js +166 -12
  3. package/dist/chat/chat-pipeline-translation.d.ts +13 -0
  4. package/dist/chat/chat-pipeline-translation.js +109 -45
  5. package/dist/chat/chat-pipeline.d.ts +1 -1
  6. package/dist/chat/chat-pipeline.js +296 -53
  7. package/dist/chat/gemini-planner.d.ts +2 -0
  8. package/dist/chat/gemini-planner.js +2 -1
  9. package/dist/chat/planner-types.d.ts +15 -0
  10. package/dist/chat/planner-types.js +2 -2
  11. package/dist/chat/planner.d.ts +12 -0
  12. package/dist/chat/planner.js +16 -2
  13. package/dist/chat/translation-chunking.d.ts +124 -0
  14. package/dist/chat/translation-chunking.js +371 -0
  15. package/dist/checks/field-walk.d.ts +25 -0
  16. package/dist/checks/field-walk.js +152 -0
  17. package/dist/checks/index.d.ts +5 -0
  18. package/dist/checks/index.js +4 -0
  19. package/dist/checks/page-weight.d.ts +22 -0
  20. package/dist/checks/page-weight.js +200 -0
  21. package/dist/checks/rules-draft.d.ts +2 -0
  22. package/dist/checks/rules-draft.js +375 -0
  23. package/dist/checks/run-checks.d.ts +32 -0
  24. package/dist/checks/run-checks.js +152 -0
  25. package/dist/checks/session-runner.d.ts +19 -0
  26. package/dist/checks/session-runner.js +95 -0
  27. package/dist/checks/types.d.ts +65 -0
  28. package/dist/checks/types.js +1 -0
  29. package/dist/durable/durable-store-singleton.d.ts +37 -0
  30. package/dist/durable/durable-store-singleton.js +179 -0
  31. package/dist/durable/finding-impact.d.ts +30 -0
  32. package/dist/durable/finding-impact.js +53 -0
  33. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  34. package/dist/durable/in-memory-durable-store.js +363 -0
  35. package/dist/durable/index.d.ts +5 -0
  36. package/dist/durable/index.js +4 -0
  37. package/dist/durable/pending-plan-store.d.ts +28 -0
  38. package/dist/durable/pending-plan-store.js +156 -0
  39. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  40. package/dist/durable/sqlite-durable-store.js +631 -0
  41. package/dist/durable/types.d.ts +265 -0
  42. package/dist/durable/types.js +1 -0
  43. package/dist/handler/create-orchestrator.d.ts +4 -0
  44. package/dist/handler/create-orchestrator.js +67 -4
  45. package/dist/http/audio-actions.d.ts +1 -1
  46. package/dist/http/checks-actions.d.ts +39 -0
  47. package/dist/http/checks-actions.js +122 -0
  48. package/dist/http/history-actions.d.ts +1 -1
  49. package/dist/http/image-generate-actions.d.ts +2 -2
  50. package/dist/http/ops-actions.d.ts +2 -2
  51. package/dist/http/publish-actions.d.ts +4 -4
  52. package/dist/http/restore-actions.d.ts +3 -3
  53. package/dist/http/screenshot-actions.d.ts +2 -2
  54. package/dist/http/session-actions.d.ts +1 -1
  55. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  56. package/dist/http/unsplash-actions.d.ts +2 -2
  57. package/dist/http/variations-actions.d.ts +2 -2
  58. package/dist/index.d.ts +7 -0
  59. package/dist/index.js +27 -0
  60. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  61. package/dist/nlp/deterministic-planner-context.js +33 -7
  62. package/dist/nlp/plan-normalizer.js +54 -6
  63. package/dist/ops/destructive-action-gate.js +7 -2
  64. package/dist/ops/ops-engine.d.ts +12 -1
  65. package/dist/ops/ops-engine.js +41 -14
  66. package/dist/publish/publish-target-registry.js +1 -1
  67. package/dist/publish/publish-target.d.ts +1 -1
  68. package/dist/state/session-state.js +8 -1
  69. package/package.json +3 -3
@@ -0,0 +1,631 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { IMPACT_FALLBACK_SQL, fallbackImpact } from "./finding-impact.js";
3
+ /*
4
+ * SQLite implementation of `DurableStore`.
5
+ *
6
+ * It takes an already-open database rather than a file path, so it shares the
7
+ * single `.data/orchestrator.db` connection `SqliteStore` owns. Two writers on
8
+ * one file is a WAL problem nobody needs, and these tables want to be in the
9
+ * same backup and the same `VACUUM INTO` snapshot as everything else.
10
+ *
11
+ * Every statement here is row-addressed. Nothing truncates a table to write one
12
+ * record — which is the entire difference between this store and the snapshot
13
+ * path it sits beside, and the reason a network-backed implementation of the
14
+ * same interface is viable later.
15
+ *
16
+ * The schema is `CREATE TABLE IF NOT EXISTS` only. There is no migration runner
17
+ * in this repo (`ensureSchemaVersion` writes a version and does nothing else),
18
+ * so a new table is safe and a changed one is not. The single exception is
19
+ * `ensureColumns()` below, which adds a *nullable* column to a table that
20
+ * already exists — the one migration shape that needs no runner. A column added
21
+ * to the CREATE TABLE text alone would never reach an existing database, so any
22
+ * addition has to appear in both places.
23
+ */
24
+ const SCHEMA = `
25
+ CREATE TABLE IF NOT EXISTS findings (
26
+ id TEXT PRIMARY KEY,
27
+ fingerprint TEXT NOT NULL,
28
+ scope_key TEXT NOT NULL,
29
+ slug TEXT NOT NULL,
30
+ rule_id TEXT NOT NULL,
31
+ agent TEXT NOT NULL,
32
+ severity TEXT NOT NULL CHECK (severity IN ('error','warning','info')),
33
+ impact REAL,
34
+ status TEXT NOT NULL CHECK (status IN ('open','snoozed','dismissed','fixed')),
35
+ title TEXT NOT NULL,
36
+ detail TEXT,
37
+ evidence TEXT,
38
+ proposed_ops TEXT,
39
+ last_run_id TEXT NOT NULL,
40
+ first_seen_at INTEGER NOT NULL,
41
+ last_seen_at INTEGER NOT NULL,
42
+ resolved_at INTEGER,
43
+ snoozed_until INTEGER
44
+ );
45
+ -- Per scope, not global. A natural fingerprint such as seo.description-missing::/
46
+ -- is identical across sites; a global unique index makes one tenant's finding
47
+ -- overwrite another's, and the second site then sees nothing at all.
48
+ CREATE UNIQUE INDEX IF NOT EXISTS findings_by_scope_fingerprint ON findings(scope_key, fingerprint);
49
+ CREATE INDEX IF NOT EXISTS findings_by_scope ON findings(scope_key, status);
50
+ CREATE INDEX IF NOT EXISTS findings_by_slug ON findings(scope_key, slug);
51
+
52
+ CREATE TABLE IF NOT EXISTS check_runs (
53
+ id TEXT PRIMARY KEY,
54
+ scope_key TEXT NOT NULL,
55
+ agent TEXT NOT NULL,
56
+ trigger_kind TEXT NOT NULL,
57
+ started_at INTEGER NOT NULL,
58
+ finished_at INTEGER,
59
+ pages_scanned INTEGER NOT NULL DEFAULT 0,
60
+ findings_opened INTEGER NOT NULL DEFAULT 0,
61
+ findings_closed INTEGER NOT NULL DEFAULT 0,
62
+ cost_usd REAL NOT NULL DEFAULT 0,
63
+ error TEXT
64
+ );
65
+ CREATE INDEX IF NOT EXISTS check_runs_by_scope ON check_runs(scope_key, started_at DESC);
66
+
67
+ CREATE TABLE IF NOT EXISTS memory_records (
68
+ id TEXT PRIMARY KEY,
69
+ scope TEXT NOT NULL CHECK (scope IN ('site','session','page')),
70
+ scope_key TEXT NOT NULL,
71
+ kind TEXT NOT NULL CHECK (kind IN ('fact','preference','decision','glossary')),
72
+ key TEXT NOT NULL,
73
+ value TEXT NOT NULL,
74
+ source TEXT NOT NULL CHECK (source IN ('user','inferred','agent','import')),
75
+ source_ref TEXT,
76
+ confidence REAL NOT NULL DEFAULT 1,
77
+ status TEXT NOT NULL CHECK (status IN ('active','superseded','rejected')),
78
+ supersedes_id TEXT,
79
+ created_at INTEGER NOT NULL,
80
+ last_used_at INTEGER,
81
+ use_count INTEGER NOT NULL DEFAULT 0
82
+ );
83
+ CREATE INDEX IF NOT EXISTS memory_by_scope ON memory_records(scope_key, status, kind);
84
+ -- One active record per key. A new value supersedes rather than appends;
85
+ -- memory that only grows is memory that contradicts itself.
86
+ CREATE UNIQUE INDEX IF NOT EXISTS memory_active_key
87
+ ON memory_records(scope, scope_key, kind, key) WHERE status = 'active';
88
+
89
+ CREATE TABLE IF NOT EXISTS corrections (
90
+ id TEXT PRIMARY KEY,
91
+ trace_id TEXT NOT NULL,
92
+ scope_key TEXT NOT NULL,
93
+ slug TEXT,
94
+ request TEXT NOT NULL,
95
+ proposed TEXT NOT NULL,
96
+ outcome TEXT NOT NULL CHECK (outcome IN ('accepted','edited','rejected','undone')),
97
+ applied TEXT,
98
+ at INTEGER NOT NULL
99
+ );
100
+ CREATE INDEX IF NOT EXISTS corrections_by_scope ON corrections(scope_key, at DESC);
101
+
102
+ CREATE TABLE IF NOT EXISTS proposals (
103
+ id TEXT PRIMARY KEY,
104
+ scope_key TEXT NOT NULL,
105
+ origin TEXT NOT NULL,
106
+ summary TEXT NOT NULL,
107
+ ops TEXT NOT NULL,
108
+ slugs TEXT NOT NULL,
109
+ finding_id TEXT,
110
+ payload TEXT,
111
+ status TEXT NOT NULL CHECK (status IN ('pending','approved','discarded','expired')),
112
+ created_at INTEGER NOT NULL,
113
+ expires_at INTEGER,
114
+ resolved_at INTEGER
115
+ );
116
+ CREATE INDEX IF NOT EXISTS proposals_by_scope ON proposals(scope_key, status, created_at DESC);
117
+ `;
118
+ /*
119
+ * Retention. Every other collection in this repo is capped — `HISTORY_DEPTH_CAP`,
120
+ * `VERSION_LOG_CAP`, `CHAT_HISTORY_CAP` — and these were not. A `plan_ready`
121
+ * turn writes a whole `PendingApprovalPlan` as a `payload` blob, and the file it
122
+ * lands in is copied entire into up to 14 rolling `VACUUM INTO` backups, so an
123
+ * unbounded proposals table is paid for fifteen times over.
124
+ *
125
+ * Findings are deliberately not capped: they are reconciled, so the table's size
126
+ * tracks the number of real problems rather than the number of runs.
127
+ */
128
+ export const PROPOSAL_CAP = 200;
129
+ export const CORRECTION_CAP = 500;
130
+ export const CHECK_RUN_CAP = 200;
131
+ /**
132
+ * How long a snooze lasts.
133
+ *
134
+ * Seven days, because the button competes with Dismiss and has to mean
135
+ * something different from it: long enough to clear a finding out of the way
136
+ * for the week somebody is shipping a campaign, short enough that "I'll get to
137
+ * it" does not quietly become "never". Exported so both implementations, and
138
+ * the tests that hold them to the same behaviour, derive it from one number.
139
+ */
140
+ export const SNOOZE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
141
+ function parseJson(raw, fallback) {
142
+ if (!raw)
143
+ return fallback;
144
+ try {
145
+ return JSON.parse(raw);
146
+ }
147
+ catch {
148
+ return fallback;
149
+ }
150
+ }
151
+ function toFinding(row) {
152
+ const evidence = parseJson(row.evidence, null);
153
+ const proposedOps = parseJson(row.proposed_ops, null);
154
+ return {
155
+ id: row.id,
156
+ fingerprint: row.fingerprint,
157
+ scopeKey: row.scope_key,
158
+ slug: row.slug,
159
+ ruleId: row.rule_id,
160
+ agent: row.agent,
161
+ severity: row.severity,
162
+ impact: row.impact ?? fallbackImpact(row.severity),
163
+ status: row.status,
164
+ title: row.title,
165
+ ...(row.detail ? { detail: row.detail } : {}),
166
+ ...(evidence ? { evidence } : {}),
167
+ ...(proposedOps ? { proposedOps } : {}),
168
+ lastRunId: row.last_run_id,
169
+ firstSeenAt: row.first_seen_at,
170
+ lastSeenAt: row.last_seen_at,
171
+ ...(row.resolved_at != null ? { resolvedAt: row.resolved_at } : {}),
172
+ ...(row.snoozed_until != null ? { snoozedUntil: row.snoozed_until } : {})
173
+ };
174
+ }
175
+ function toCheckRun(row) {
176
+ return {
177
+ id: row.id,
178
+ scopeKey: row.scope_key,
179
+ agent: row.agent,
180
+ trigger: row.trigger_kind,
181
+ startedAt: row.started_at,
182
+ ...(row.finished_at != null ? { finishedAt: row.finished_at } : {}),
183
+ pagesScanned: row.pages_scanned,
184
+ findingsOpened: row.findings_opened,
185
+ findingsClosed: row.findings_closed,
186
+ costUsd: row.cost_usd,
187
+ ...(row.error ? { error: row.error } : {})
188
+ };
189
+ }
190
+ function toMemory(row) {
191
+ return {
192
+ id: row.id,
193
+ scope: row.scope,
194
+ scopeKey: row.scope_key,
195
+ kind: row.kind,
196
+ key: row.key,
197
+ value: row.value,
198
+ source: row.source,
199
+ ...(row.source_ref ? { sourceRef: row.source_ref } : {}),
200
+ confidence: row.confidence,
201
+ status: row.status,
202
+ ...(row.supersedes_id ? { supersedesId: row.supersedes_id } : {}),
203
+ createdAt: row.created_at,
204
+ ...(row.last_used_at != null ? { lastUsedAt: row.last_used_at } : {}),
205
+ useCount: row.use_count
206
+ };
207
+ }
208
+ function toCorrection(row) {
209
+ const applied = parseJson(row.applied, null);
210
+ return {
211
+ id: row.id,
212
+ traceId: row.trace_id,
213
+ scopeKey: row.scope_key,
214
+ ...(row.slug ? { slug: row.slug } : {}),
215
+ request: row.request,
216
+ proposed: parseJson(row.proposed, []),
217
+ outcome: row.outcome,
218
+ ...(applied ? { applied } : {}),
219
+ at: row.at
220
+ };
221
+ }
222
+ function toProposal(row) {
223
+ return {
224
+ id: row.id,
225
+ scopeKey: row.scope_key,
226
+ origin: row.origin,
227
+ summary: row.summary,
228
+ ops: parseJson(row.ops, []),
229
+ slugs: parseJson(row.slugs, []),
230
+ ...(row.finding_id ? { findingId: row.finding_id } : {}),
231
+ ...(row.payload ? { payload: parseJson(row.payload, {}) } : {}),
232
+ status: row.status,
233
+ createdAt: row.created_at,
234
+ ...(row.expires_at != null ? { expiresAt: row.expires_at } : {}),
235
+ ...(row.resolved_at != null ? { resolvedAt: row.resolved_at } : {})
236
+ };
237
+ }
238
+ export class SqliteDurableStore {
239
+ db;
240
+ now;
241
+ constructor(db, options = {}) {
242
+ this.db = db;
243
+ this.now = options.now ?? Date.now;
244
+ this.db.exec(SCHEMA);
245
+ this.ensureColumns();
246
+ }
247
+ /*
248
+ * The one thing `CREATE TABLE IF NOT EXISTS` cannot do: reach a table that
249
+ * already exists without a column this build needs. There is no migration
250
+ * runner in the repo, and a `payload` column added to `proposals` after the
251
+ * table shipped would make every insert fail with "no such column" — swallowed
252
+ * by a best-effort caller and surfaced as something unrelated.
253
+ *
254
+ * `ALTER TABLE ... ADD COLUMN` is cheap, idempotent once guarded, and the only
255
+ * migration shape this store needs. Anything beyond adding a nullable column
256
+ * still requires a real runner.
257
+ */
258
+ ensureColumns() {
259
+ const expected = [
260
+ { table: "proposals", column: "payload", type: "TEXT" },
261
+ { table: "findings", column: "snoozed_until", type: "INTEGER" },
262
+ // Nullable on purpose: rows written before impact existed keep NULL and
263
+ // are read through `IMPACT_FALLBACK_SQL` until the next run scores them.
264
+ { table: "findings", column: "impact", type: "REAL" }
265
+ ];
266
+ for (const { table, column, type } of expected) {
267
+ const columns = this.db
268
+ .prepare(`PRAGMA table_info(${table})`)
269
+ .all()
270
+ .map((c) => c.name);
271
+ if (columns.length === 0 || columns.includes(column))
272
+ continue;
273
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
274
+ }
275
+ }
276
+ trim(table, scopeKey, cap) {
277
+ const orderColumn = table === "corrections" ? "at" : table === "proposals" ? "created_at" : "started_at";
278
+ this.db
279
+ .prepare(`DELETE FROM ${table} WHERE id IN (
280
+ SELECT id FROM ${table} WHERE scope_key = ?
281
+ ORDER BY ${orderColumn} DESC LIMIT -1 OFFSET ?
282
+ )`)
283
+ .run(scopeKey, cap);
284
+ }
285
+ // Findings ---------------------------------------------------------------
286
+ async recordFindings(runId, findings) {
287
+ if (findings.length === 0)
288
+ return { opened: 0, updated: 0 };
289
+ const at = this.now();
290
+ const selectByFingerprint = this.db.prepare("SELECT * FROM findings WHERE scope_key = ? AND fingerprint = ?");
291
+ const insert = this.db.prepare(`INSERT INTO findings
292
+ (id, fingerprint, scope_key, slug, rule_id, agent, severity, impact, status, title, detail,
293
+ evidence, proposed_ops, last_run_id, first_seen_at, last_seen_at, resolved_at)
294
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, NULL)`);
295
+ // Note what this UPDATE does not touch: `status` and `first_seen_at`. A
296
+ // dismissal has to survive every subsequent run, and "how long has this been
297
+ // wrong" is the only interesting thing about a finding that keeps recurring.
298
+ //
299
+ // `impact` *is* touched, and has to be: it derives from the site's link
300
+ // graph and nav, so a page that joins the nav today must lift the findings
301
+ // that have been sitting on it since Tuesday.
302
+ const update = this.db.prepare(`UPDATE findings
303
+ SET slug = ?, rule_id = ?, agent = ?, severity = ?, impact = ?, title = ?, detail = ?,
304
+ evidence = ?, proposed_ops = ?, last_run_id = ?, last_seen_at = ?
305
+ WHERE id = ?`);
306
+ const reopen = this.db.prepare("UPDATE findings SET status = 'open', resolved_at = NULL WHERE id = ?");
307
+ let opened = 0;
308
+ let updated = 0;
309
+ this.db.transaction(() => {
310
+ for (const f of findings) {
311
+ const existing = selectByFingerprint.get(f.scopeKey, f.fingerprint);
312
+ const evidence = f.evidence ? JSON.stringify(f.evidence) : null;
313
+ const ops = f.proposedOps ? JSON.stringify(f.proposedOps) : null;
314
+ if (!existing) {
315
+ insert.run(randomUUID(), f.fingerprint, f.scopeKey, f.slug, f.ruleId, f.agent, f.severity, f.impact ?? null, f.title, f.detail ?? null, evidence, ops, runId, at, at);
316
+ opened += 1;
317
+ continue;
318
+ }
319
+ update.run(f.slug, f.ruleId, f.agent, f.severity, f.impact ?? null, f.title, f.detail ?? null, evidence, ops, runId, at, existing.id);
320
+ if (existing.status === "fixed") {
321
+ reopen.run(existing.id);
322
+ opened += 1;
323
+ }
324
+ else {
325
+ updated += 1;
326
+ }
327
+ }
328
+ })();
329
+ return { opened, updated };
330
+ }
331
+ async reconcileFindings(args) {
332
+ // A run over two pages must not close findings on the other forty-three.
333
+ if (args.slugs.length === 0)
334
+ return { closed: 0 };
335
+ const at = args.at ?? this.now();
336
+ const placeholders = args.slugs.map(() => "?").join(", ");
337
+ const agentClause = args.agent ? " AND agent = ?" : "";
338
+ // `snoozed` closes too: a snooze defers the report, not the problem, so a
339
+ // finding deferred on Monday and fixed on Tuesday must not resurface on
340
+ // Friday as an open finding about a page that is fine.
341
+ const stmt = this.db.prepare(`UPDATE findings
342
+ SET status = 'fixed', resolved_at = ?, snoozed_until = NULL
343
+ WHERE scope_key = ?
344
+ AND status IN ('open', 'snoozed')
345
+ AND last_run_id != ?
346
+ AND slug IN (${placeholders})${agentClause}`);
347
+ const params = [at, args.scopeKey, args.runId, ...args.slugs];
348
+ if (args.agent)
349
+ params.push(args.agent);
350
+ const result = stmt.run(...params);
351
+ return { closed: result.changes };
352
+ }
353
+ async listFindings(query) {
354
+ const clauses = ["scope_key = ?"];
355
+ const params = [query.scopeKey];
356
+ if (query.slug) {
357
+ clauses.push("slug = ?");
358
+ params.push(query.slug);
359
+ }
360
+ if (query.agent) {
361
+ clauses.push("agent = ?");
362
+ params.push(query.agent);
363
+ }
364
+ if (query.severity) {
365
+ clauses.push("severity = ?");
366
+ params.push(query.severity);
367
+ }
368
+ if (query.status) {
369
+ const statuses = Array.isArray(query.status) ? query.status : [query.status];
370
+ if (statuses.length === 0)
371
+ return [];
372
+ clauses.push(`status IN (${statuses.map(() => "?").join(", ")})`);
373
+ params.push(...statuses);
374
+ }
375
+ const limit = Math.max(1, Math.min(query.limit ?? 500, 5000));
376
+ const rows = this.db
377
+ .prepare(
378
+ // Impact leads, severity breaks its ties. Ordering by severity first
379
+ // put every info finding below every warning wherever it sat, which is
380
+ // how a list whose largest single source was a test page came to be
381
+ // sorted the way it was.
382
+ `SELECT * FROM findings WHERE ${clauses.join(" AND ")}
383
+ ORDER BY
384
+ ${IMPACT_FALLBACK_SQL} DESC,
385
+ CASE severity WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
386
+ last_seen_at DESC
387
+ LIMIT ?`)
388
+ .all(...params, limit);
389
+ return rows.map(toFinding);
390
+ }
391
+ async getFinding(id) {
392
+ const row = this.db.prepare("SELECT * FROM findings WHERE id = ?").get(id);
393
+ return row ? toFinding(row) : null;
394
+ }
395
+ async setFindingStatus(id, status, at) {
396
+ const when = at ?? this.now();
397
+ // A snoozed finding is deferred, not resolved — `resolvedAt` on one would
398
+ // read as "this was dealt with" in every ledger that looks at the column.
399
+ const resolved = status === "open" || status === "snoozed" ? null : when;
400
+ const snoozedUntil = status === "snoozed" ? when + SNOOZE_WINDOW_MS : null;
401
+ this.db
402
+ .prepare("UPDATE findings SET status = ?, resolved_at = ?, snoozed_until = ? WHERE id = ?")
403
+ .run(status, resolved, snoozedUntil, id);
404
+ }
405
+ async wakeSnoozedFindings(scopeKey, now) {
406
+ const at = now ?? this.now();
407
+ const result = this.db
408
+ .prepare(`UPDATE findings SET status = 'open', snoozed_until = NULL, resolved_at = NULL
409
+ WHERE scope_key = ? AND status = 'snoozed'
410
+ AND snoozed_until IS NOT NULL AND snoozed_until <= ?`)
411
+ .run(scopeKey, at);
412
+ return { woken: result.changes };
413
+ }
414
+ // Check runs -------------------------------------------------------------
415
+ async startCheckRun(run) {
416
+ this.db
417
+ .prepare(`INSERT INTO check_runs (id, scope_key, agent, trigger_kind, started_at)
418
+ VALUES (?, ?, ?, ?, ?)`)
419
+ .run(run.id, run.scopeKey, run.agent, run.trigger, run.startedAt);
420
+ this.trim("check_runs", run.scopeKey, CHECK_RUN_CAP);
421
+ return {
422
+ ...run,
423
+ pagesScanned: 0,
424
+ findingsOpened: 0,
425
+ findingsClosed: 0,
426
+ costUsd: 0
427
+ };
428
+ }
429
+ async finishCheckRun(id, patch) {
430
+ const sets = [];
431
+ const params = [];
432
+ const map = [
433
+ ["finishedAt", "finished_at"],
434
+ ["pagesScanned", "pages_scanned"],
435
+ ["findingsOpened", "findings_opened"],
436
+ ["findingsClosed", "findings_closed"],
437
+ ["costUsd", "cost_usd"],
438
+ ["error", "error"]
439
+ ];
440
+ for (const [key, column] of map) {
441
+ const value = patch[key];
442
+ if (value === undefined)
443
+ continue;
444
+ sets.push(`${column} = ?`);
445
+ params.push(value);
446
+ }
447
+ if (sets.length === 0)
448
+ return;
449
+ params.push(id);
450
+ this.db.prepare(`UPDATE check_runs SET ${sets.join(", ")} WHERE id = ?`).run(...params);
451
+ }
452
+ async listCheckRuns(scopeKey, limit = 50) {
453
+ const rows = this.db
454
+ .prepare("SELECT * FROM check_runs WHERE scope_key = ? ORDER BY started_at DESC LIMIT ?")
455
+ .all(scopeKey, Math.max(1, Math.min(limit, 1000)));
456
+ return rows.map(toCheckRun);
457
+ }
458
+ // Memory -----------------------------------------------------------------
459
+ async putMemory(input, at) {
460
+ const createdAt = at ?? this.now();
461
+ const id = randomUUID();
462
+ const confidence = input.confidence ?? 1;
463
+ const record = this.db.transaction(() => {
464
+ const previous = this.db
465
+ .prepare(`SELECT * FROM memory_records
466
+ WHERE scope = ? AND scope_key = ? AND kind = ? AND key = ? AND status = 'active'`)
467
+ .get(input.scope, input.scopeKey, input.kind, input.key);
468
+ if (previous) {
469
+ this.db.prepare("UPDATE memory_records SET status = 'superseded' WHERE id = ?").run(previous.id);
470
+ }
471
+ this.db
472
+ .prepare(`INSERT INTO memory_records
473
+ (id, scope, scope_key, kind, key, value, source, source_ref, confidence,
474
+ status, supersedes_id, created_at, last_used_at, use_count)
475
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, NULL, 0)`)
476
+ .run(id, input.scope, input.scopeKey, input.kind, input.key, input.value, input.source, input.sourceRef ?? null, confidence, previous?.id ?? null, createdAt);
477
+ return this.db
478
+ .prepare("SELECT * FROM memory_records WHERE id = ?")
479
+ .get(id);
480
+ })();
481
+ if (!record)
482
+ throw new Error("putMemory: insert did not produce a row");
483
+ return toMemory(record);
484
+ }
485
+ async listMemory(query = {}) {
486
+ const clauses = [];
487
+ const params = [];
488
+ if (query.scopeKey) {
489
+ clauses.push("scope_key = ?");
490
+ params.push(query.scopeKey);
491
+ }
492
+ if (query.scope) {
493
+ clauses.push("scope = ?");
494
+ params.push(query.scope);
495
+ }
496
+ if (query.kind) {
497
+ const kinds = Array.isArray(query.kind) ? query.kind : [query.kind];
498
+ if (kinds.length === 0)
499
+ return [];
500
+ clauses.push(`kind IN (${kinds.map(() => "?").join(", ")})`);
501
+ params.push(...kinds);
502
+ }
503
+ // Default to active only: a superseded record exists for the audit trail,
504
+ // not to be handed back to a planner as though it were still true.
505
+ clauses.push("status = ?");
506
+ params.push(query.status ?? "active");
507
+ const limit = Math.max(1, Math.min(query.limit ?? 500, 5000));
508
+ const rows = this.db
509
+ .prepare(`SELECT * FROM memory_records
510
+ ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
511
+ ORDER BY created_at DESC LIMIT ?`)
512
+ .all(...params, limit);
513
+ return rows.map(toMemory);
514
+ }
515
+ async touchMemory(ids, at) {
516
+ if (ids.length === 0)
517
+ return;
518
+ const when = at ?? this.now();
519
+ const placeholders = ids.map(() => "?").join(", ");
520
+ this.db
521
+ .prepare(`UPDATE memory_records
522
+ SET last_used_at = ?, use_count = use_count + 1
523
+ WHERE id IN (${placeholders})`)
524
+ .run(when, ...ids);
525
+ }
526
+ async setMemoryStatus(id, status) {
527
+ // Reactivating has to demote whatever is active for that key. Writing
528
+ // `status` blind hits the `memory_active_key` partial unique index and
529
+ // throws from a setter no caller expects to throw — and the in-memory
530
+ // store, with no index to stop it, would instead end up handing a planner
531
+ // two contradicting facts for one key.
532
+ this.db.transaction(() => {
533
+ if (status === "active") {
534
+ const target = this.db
535
+ .prepare("SELECT * FROM memory_records WHERE id = ?")
536
+ .get(id);
537
+ if (!target)
538
+ return;
539
+ this.db
540
+ .prepare(`UPDATE memory_records SET status = 'superseded'
541
+ WHERE scope = ? AND scope_key = ? AND kind = ? AND key = ?
542
+ AND status = 'active' AND id != ?`)
543
+ .run(target.scope, target.scope_key, target.kind, target.key, id);
544
+ }
545
+ this.db.prepare("UPDATE memory_records SET status = ? WHERE id = ?").run(status, id);
546
+ })();
547
+ }
548
+ // Corrections ------------------------------------------------------------
549
+ async recordCorrection(input, at) {
550
+ const record = { ...input, id: randomUUID(), at: at ?? this.now() };
551
+ this.db
552
+ .prepare(`INSERT INTO corrections (id, trace_id, scope_key, slug, request, proposed, outcome, applied, at)
553
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
554
+ .run(record.id, record.traceId, record.scopeKey, record.slug ?? null, record.request, JSON.stringify(record.proposed), record.outcome, record.applied ? JSON.stringify(record.applied) : null, record.at);
555
+ this.trim("corrections", record.scopeKey, CORRECTION_CAP);
556
+ return record;
557
+ }
558
+ async listCorrections(query = {}) {
559
+ const clauses = [];
560
+ const params = [];
561
+ if (query.scopeKey) {
562
+ clauses.push("scope_key = ?");
563
+ params.push(query.scopeKey);
564
+ }
565
+ if (query.outcome) {
566
+ clauses.push("outcome = ?");
567
+ params.push(query.outcome);
568
+ }
569
+ if (query.since != null) {
570
+ clauses.push("at >= ?");
571
+ params.push(query.since);
572
+ }
573
+ const limit = Math.max(1, Math.min(query.limit ?? 200, 5000));
574
+ const rows = this.db
575
+ .prepare(`SELECT * FROM corrections
576
+ ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
577
+ ORDER BY at DESC LIMIT ?`)
578
+ .all(...params, limit);
579
+ return rows.map(toCorrection);
580
+ }
581
+ // Proposals --------------------------------------------------------------
582
+ async putProposal(input) {
583
+ this.db
584
+ .prepare(`INSERT INTO proposals
585
+ (id, scope_key, origin, summary, ops, slugs, finding_id, payload, status, created_at, expires_at, resolved_at)
586
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, NULL)
587
+ ON CONFLICT(id) DO UPDATE SET
588
+ origin = excluded.origin,
589
+ summary = excluded.summary,
590
+ ops = excluded.ops,
591
+ slugs = excluded.slugs,
592
+ finding_id = excluded.finding_id,
593
+ payload = excluded.payload,
594
+ expires_at = excluded.expires_at`)
595
+ .run(input.id, input.scopeKey, input.origin, input.summary, JSON.stringify(input.ops), JSON.stringify(input.slugs), input.findingId ?? null, input.payload ? JSON.stringify(input.payload) : null, input.createdAt, input.expiresAt ?? null);
596
+ this.trim("proposals", input.scopeKey, PROPOSAL_CAP);
597
+ const record = await this.getProposal(input.id);
598
+ if (!record)
599
+ throw new Error("putProposal: insert did not produce a row");
600
+ return record;
601
+ }
602
+ async getProposal(id) {
603
+ const row = this.db.prepare("SELECT * FROM proposals WHERE id = ?").get(id);
604
+ return row ? toProposal(row) : null;
605
+ }
606
+ async listProposals(query) {
607
+ const clauses = ["scope_key = ?"];
608
+ const params = [query.scopeKey];
609
+ if (query.status) {
610
+ clauses.push("status = ?");
611
+ params.push(query.status);
612
+ }
613
+ const limit = Math.max(1, Math.min(query.limit ?? 100, 1000));
614
+ const rows = this.db
615
+ .prepare(`SELECT * FROM proposals WHERE ${clauses.join(" AND ")} ORDER BY created_at DESC LIMIT ?`)
616
+ .all(...params, limit);
617
+ return rows.map(toProposal);
618
+ }
619
+ async setProposalStatus(id, status, at) {
620
+ const resolved = status === "pending" ? null : (at ?? this.now());
621
+ this.db.prepare("UPDATE proposals SET status = ?, resolved_at = ? WHERE id = ?").run(status, resolved, id);
622
+ }
623
+ async expireProposals(now) {
624
+ const at = now ?? this.now();
625
+ const result = this.db
626
+ .prepare(`UPDATE proposals SET status = 'expired', resolved_at = ?
627
+ WHERE status = 'pending' AND expires_at IS NOT NULL AND expires_at <= ?`)
628
+ .run(at, at);
629
+ return { expired: result.changes };
630
+ }
631
+ }