@kodrunhq/opencode-autopilot 1.15.1 → 1.16.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.
Files changed (64) hide show
  1. package/README.md +14 -0
  2. package/bin/cli.ts +5 -0
  3. package/bin/inspect.ts +337 -0
  4. package/package.json +1 -1
  5. package/src/agents/autopilot.ts +7 -15
  6. package/src/agents/index.ts +54 -21
  7. package/src/health/checks.ts +108 -4
  8. package/src/health/runner.ts +3 -0
  9. package/src/index.ts +105 -12
  10. package/src/inspect/formatters.ts +225 -0
  11. package/src/inspect/repository.ts +882 -0
  12. package/src/kernel/database.ts +45 -0
  13. package/src/kernel/migrations.ts +62 -0
  14. package/src/kernel/repository.ts +571 -0
  15. package/src/kernel/schema.ts +122 -0
  16. package/src/kernel/types.ts +66 -0
  17. package/src/memory/capture.ts +221 -25
  18. package/src/memory/database.ts +74 -12
  19. package/src/memory/index.ts +17 -1
  20. package/src/memory/project-key.ts +6 -0
  21. package/src/memory/repository.ts +833 -42
  22. package/src/memory/retrieval.ts +83 -169
  23. package/src/memory/schemas.ts +39 -7
  24. package/src/memory/types.ts +4 -0
  25. package/src/observability/event-handlers.ts +28 -17
  26. package/src/observability/event-store.ts +29 -1
  27. package/src/observability/forensic-log.ts +159 -0
  28. package/src/observability/forensic-schemas.ts +69 -0
  29. package/src/observability/forensic-types.ts +10 -0
  30. package/src/observability/index.ts +21 -27
  31. package/src/observability/log-reader.ts +142 -111
  32. package/src/observability/log-writer.ts +41 -83
  33. package/src/observability/retention.ts +2 -2
  34. package/src/observability/session-logger.ts +36 -57
  35. package/src/observability/summary-generator.ts +31 -19
  36. package/src/observability/types.ts +12 -24
  37. package/src/orchestrator/contracts/invariants.ts +14 -0
  38. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  39. package/src/orchestrator/fallback/event-handler.ts +47 -3
  40. package/src/orchestrator/handlers/architect.ts +2 -1
  41. package/src/orchestrator/handlers/build.ts +55 -97
  42. package/src/orchestrator/handlers/retrospective.ts +2 -1
  43. package/src/orchestrator/handlers/types.ts +0 -1
  44. package/src/orchestrator/lesson-memory.ts +29 -9
  45. package/src/orchestrator/orchestration-logger.ts +37 -23
  46. package/src/orchestrator/phase.ts +8 -4
  47. package/src/orchestrator/state.ts +79 -17
  48. package/src/projects/database.ts +47 -0
  49. package/src/projects/repository.ts +264 -0
  50. package/src/projects/resolve.ts +301 -0
  51. package/src/projects/schemas.ts +30 -0
  52. package/src/projects/types.ts +12 -0
  53. package/src/review/memory.ts +29 -9
  54. package/src/tools/doctor.ts +40 -5
  55. package/src/tools/forensics.ts +7 -12
  56. package/src/tools/logs.ts +6 -5
  57. package/src/tools/memory-preferences.ts +157 -0
  58. package/src/tools/memory-status.ts +17 -96
  59. package/src/tools/orchestrate.ts +97 -81
  60. package/src/tools/pipeline-report.ts +3 -2
  61. package/src/tools/quick.ts +2 -2
  62. package/src/tools/review.ts +39 -6
  63. package/src/tools/session-stats.ts +3 -2
  64. package/src/utils/paths.ts +20 -1
@@ -1,20 +1,126 @@
1
1
  import type { Database } from "bun:sqlite";
2
+ import { createHash } from "node:crypto";
3
+ import { lessonMemorySchema } from "../orchestrator/lesson-schemas";
4
+ import type { Lesson } from "../orchestrator/lesson-types";
2
5
  import { OBSERVATION_TYPES } from "./constants";
3
6
  import { getMemoryDb } from "./database";
4
- import { observationSchema, preferenceSchema, projectSchema } from "./schemas";
5
- import type { Observation, ObservationType, Preference, Project } from "./types";
7
+ import {
8
+ observationSchema,
9
+ preferenceEvidenceSchema,
10
+ preferenceRecordSchema,
11
+ preferenceSchema,
12
+ projectSchema,
13
+ } from "./schemas";
14
+ import type {
15
+ Observation,
16
+ ObservationType,
17
+ Preference,
18
+ PreferenceEvidence,
19
+ PreferenceRecord,
20
+ Project,
21
+ } from "./types";
22
+
23
+ interface PreferenceRecordRow {
24
+ readonly id: string;
25
+ readonly key: string;
26
+ readonly value: string;
27
+ readonly scope: string;
28
+ readonly project_id: string | null;
29
+ readonly status: string;
30
+ readonly confidence: number;
31
+ readonly source_session: string | null;
32
+ readonly created_at: string;
33
+ readonly last_updated: string;
34
+ readonly evidence_count?: number;
35
+ }
36
+
37
+ interface PreferenceEvidenceRow {
38
+ readonly id: string;
39
+ readonly preference_id: string;
40
+ readonly session_id: string | null;
41
+ readonly run_id: string | null;
42
+ readonly statement: string;
43
+ readonly statement_hash: string;
44
+ readonly confidence: number;
45
+ readonly confirmed: number;
46
+ readonly created_at: string;
47
+ }
48
+
49
+ interface ProjectLessonRow {
50
+ readonly content: string;
51
+ readonly domain: Lesson["domain"];
52
+ readonly extracted_at: string;
53
+ readonly source_phase: Lesson["sourcePhase"];
54
+ readonly last_updated_at: string | null;
55
+ }
6
56
 
7
57
  /** Resolve optional db parameter to singleton fallback. */
8
58
  function resolveDb(db?: Database): Database {
9
59
  return db ?? getMemoryDb();
10
60
  }
11
61
 
62
+ function withWriteTransaction<T>(db: Database, callback: () => T): T {
63
+ const row = db.query("PRAGMA transaction_state").get() as { transaction_state?: string } | null;
64
+ if (row?.transaction_state === "TRANSACTION") {
65
+ return callback();
66
+ }
67
+
68
+ db.run("BEGIN IMMEDIATE");
69
+ try {
70
+ const result = callback();
71
+ db.run("COMMIT");
72
+ return result;
73
+ } catch (error: unknown) {
74
+ try {
75
+ db.run("ROLLBACK");
76
+ } catch {
77
+ // Ignore rollback failures so the original error wins.
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ function buildPlaceholders(count: number): string {
84
+ return Array.from({ length: count }, () => "?").join(", ");
85
+ }
86
+
87
+ function normalizePreferenceProjectId(
88
+ scope: PreferenceRecord["scope"],
89
+ projectId: string | null,
90
+ ): string | null {
91
+ return scope === "project" ? projectId : null;
92
+ }
93
+
94
+ function makePreferenceId(
95
+ key: string,
96
+ scope: PreferenceRecord["scope"],
97
+ projectId: string | null,
98
+ ): string {
99
+ const normalizedProjectId = normalizePreferenceProjectId(scope, projectId) ?? "global";
100
+ return `pref-${createHash("sha1").update(`${scope}:${normalizedProjectId}:${key}`).digest("hex")}`;
101
+ }
102
+
103
+ function makeEvidenceId(preferenceId: string, statementHash: string): string {
104
+ return `evidence-${createHash("sha1").update(`${preferenceId}:${statementHash}`).digest("hex")}`;
105
+ }
106
+
107
+ function makeStatementHash(statement: string): string {
108
+ return createHash("sha1").update(statement).digest("hex");
109
+ }
110
+
111
+ function tableExists(db: Database, tableName: string): boolean {
112
+ const row = db
113
+ .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
114
+ .get(tableName) as { name?: string } | null;
115
+ return row?.name === tableName;
116
+ }
117
+
12
118
  /** Validate observation type at runtime. */
13
119
  function parseObservationType(value: unknown): ObservationType {
14
120
  if (typeof value === "string" && (OBSERVATION_TYPES as readonly string[]).includes(value)) {
15
121
  return value as ObservationType;
16
122
  }
17
- return "context"; // safe fallback for corrupt/unknown types
123
+ return "context";
18
124
  }
19
125
 
20
126
  /** Map a snake_case DB row to camelCase Observation. */
@@ -39,21 +145,132 @@ function rowToProject(row: Record<string, unknown>): Project {
39
145
  id: row.id as string,
40
146
  path: row.path as string,
41
147
  name: row.name as string,
148
+ firstSeenAt: ((row.first_seen_at as string) ?? (row.last_updated as string)) as string,
42
149
  lastUpdated: row.last_updated as string,
43
150
  };
44
151
  }
45
152
 
46
- /** Map a snake_case DB row to camelCase Preference. */
47
- function rowToPreference(row: Record<string, unknown>): Preference {
48
- return {
49
- id: row.id as string,
50
- key: row.key as string,
51
- value: row.value as string,
52
- confidence: row.confidence as number,
53
- sourceSession: (row.source_session as string) ?? null,
54
- createdAt: row.created_at as string,
55
- lastUpdated: row.last_updated as string,
56
- };
153
+ function rowToPreferenceRecord(row: PreferenceRecordRow): PreferenceRecord {
154
+ return preferenceRecordSchema.parse({
155
+ id: row.id,
156
+ key: row.key,
157
+ value: row.value,
158
+ scope: row.scope,
159
+ projectId: row.project_id,
160
+ status: row.status,
161
+ confidence: row.confidence,
162
+ sourceSession: row.source_session,
163
+ createdAt: row.created_at,
164
+ lastUpdated: row.last_updated,
165
+ evidenceCount: row.evidence_count ?? 0,
166
+ });
167
+ }
168
+
169
+ function rowToPreferenceEvidence(row: PreferenceEvidenceRow): PreferenceEvidence {
170
+ return preferenceEvidenceSchema.parse({
171
+ id: row.id,
172
+ preferenceId: row.preference_id,
173
+ sessionId: row.session_id,
174
+ runId: row.run_id,
175
+ statement: row.statement,
176
+ statementHash: row.statement_hash,
177
+ confidence: row.confidence,
178
+ confirmed: row.confirmed === 1,
179
+ createdAt: row.created_at,
180
+ });
181
+ }
182
+
183
+ /** Map a normalized preference record to the compatibility shape. */
184
+ function recordToPreference(record: PreferenceRecord): Preference {
185
+ return preferenceSchema.parse({
186
+ id: record.id,
187
+ key: record.key,
188
+ value: record.value,
189
+ confidence: record.confidence,
190
+ scope: record.scope,
191
+ projectId: record.projectId,
192
+ status: record.status,
193
+ evidenceCount: record.evidenceCount,
194
+ sourceSession: record.sourceSession,
195
+ createdAt: record.createdAt,
196
+ lastUpdated: record.lastUpdated,
197
+ });
198
+ }
199
+
200
+ function syncCompatibilityPreference(record: PreferenceRecord, db: Database): void {
201
+ if (record.scope !== "global" || record.status !== "confirmed") {
202
+ db.run("DELETE FROM preferences WHERE id = ?", [record.id]);
203
+ return;
204
+ }
205
+
206
+ db.run(
207
+ `INSERT INTO preferences (id, key, value, confidence, source_session, created_at, last_updated)
208
+ VALUES (?, ?, ?, ?, ?, ?, ?)
209
+ ON CONFLICT(key) DO UPDATE SET
210
+ id = excluded.id,
211
+ key = excluded.key,
212
+ value = excluded.value,
213
+ confidence = excluded.confidence,
214
+ source_session = excluded.source_session,
215
+ created_at = excluded.created_at,
216
+ last_updated = excluded.last_updated`,
217
+ [
218
+ record.id,
219
+ record.key,
220
+ record.value,
221
+ record.confidence,
222
+ record.sourceSession,
223
+ record.createdAt,
224
+ record.lastUpdated,
225
+ ],
226
+ );
227
+ }
228
+
229
+ function listPreferenceRecordsSql(baseWhere = ""): string {
230
+ return `SELECT
231
+ pr.*,
232
+ COALESCE(evidence_counts.evidence_count, 0) AS evidence_count
233
+ FROM preference_records pr
234
+ LEFT JOIN (
235
+ SELECT preference_id, COUNT(*) AS evidence_count
236
+ FROM preference_evidence
237
+ GROUP BY preference_id
238
+ ) AS evidence_counts ON evidence_counts.preference_id = pr.id
239
+ ${baseWhere}
240
+ ORDER BY pr.last_updated DESC, pr.key ASC, pr.id ASC`;
241
+ }
242
+
243
+ function listLegacyLessons(projectId: string, db: Database): readonly Lesson[] {
244
+ if (!tableExists(db, "project_lesson_memory")) {
245
+ return Object.freeze([]);
246
+ }
247
+
248
+ const row = db
249
+ .query("SELECT state_json FROM project_lesson_memory WHERE project_id = ?")
250
+ .get(projectId) as { state_json?: string } | null;
251
+ if (row?.state_json === undefined) {
252
+ return Object.freeze([]);
253
+ }
254
+
255
+ try {
256
+ const parsed = lessonMemorySchema.parse(JSON.parse(row.state_json));
257
+ return Object.freeze(parsed.lessons);
258
+ } catch {
259
+ return Object.freeze([]);
260
+ }
261
+ }
262
+
263
+ function buildLessonsFromRows(rows: readonly ProjectLessonRow[]): readonly Lesson[] {
264
+ return Object.freeze(
265
+ rows.map((row) =>
266
+ Object.freeze({
267
+ content: row.content,
268
+ domain: row.domain,
269
+ extractedAt: row.extracted_at,
270
+ sourcePhase: row.source_phase,
271
+ }),
272
+ ),
273
+ );
57
274
  }
58
275
 
59
276
  /**
@@ -97,8 +314,6 @@ export function searchObservations(
97
314
  const d = resolveDb(db);
98
315
 
99
316
  const projectFilter = projectId === null ? "AND o.project_id IS NULL" : "AND o.project_id = ?";
100
-
101
- // Sanitize FTS5 query — wrap in double-quotes to prevent operator injection
102
317
  const safeFtsQuery = `"${query.replace(/"/g, '""')}"`;
103
318
  const params: Array<string | number> =
104
319
  projectId === null ? [safeFtsQuery, limit] : [safeFtsQuery, projectId, limit];
@@ -127,12 +342,29 @@ export function searchObservations(
127
342
  export function upsertProject(project: Project, db?: Database): void {
128
343
  const validated = projectSchema.parse(project);
129
344
  const d = resolveDb(db);
130
- d.run(`INSERT OR REPLACE INTO projects (id, path, name, last_updated) VALUES (?, ?, ?, ?)`, [
131
- validated.id,
132
- validated.path,
133
- validated.name,
345
+ const firstSeenAt = validated.firstSeenAt ?? validated.lastUpdated;
346
+ d.run(
347
+ `INSERT INTO projects (id, path, name, first_seen_at, last_updated)
348
+ VALUES (?, ?, ?, ?, ?)
349
+ ON CONFLICT(id) DO UPDATE SET
350
+ path = excluded.path,
351
+ name = excluded.name,
352
+ first_seen_at = COALESCE(projects.first_seen_at, excluded.first_seen_at),
353
+ last_updated = excluded.last_updated`,
354
+ [validated.id, validated.path, validated.name, firstSeenAt, validated.lastUpdated],
355
+ );
356
+ d.run("UPDATE project_paths SET is_current = 0, last_updated = ? WHERE project_id = ?", [
134
357
  validated.lastUpdated,
358
+ validated.id,
135
359
  ]);
360
+ d.run(
361
+ `INSERT INTO project_paths (project_id, path, first_seen_at, last_updated, is_current)
362
+ VALUES (?, ?, ?, ?, 1)
363
+ ON CONFLICT(project_id, path) DO UPDATE SET
364
+ last_updated = excluded.last_updated,
365
+ is_current = 1`,
366
+ [validated.id, validated.path, firstSeenAt, validated.lastUpdated],
367
+ );
136
368
  }
137
369
 
138
370
  /**
@@ -140,10 +372,21 @@ export function upsertProject(project: Project, db?: Database): void {
140
372
  */
141
373
  export function getProjectByPath(path: string, db?: Database): Project | null {
142
374
  const d = resolveDb(db);
143
- const row = d.query("SELECT * FROM projects WHERE path = ?").get(path) as Record<
144
- string,
145
- unknown
146
- > | null;
375
+ const row = d
376
+ .query(
377
+ `SELECT p.*
378
+ FROM projects p
379
+ WHERE p.path = ?
380
+ UNION ALL
381
+ SELECT p.*
382
+ FROM project_paths pp
383
+ JOIN projects p ON p.id = pp.project_id
384
+ WHERE pp.path = ?
385
+ AND NOT EXISTS (SELECT 1 FROM projects p2 WHERE p2.path = ?)
386
+ ORDER BY last_updated DESC
387
+ LIMIT 1`,
388
+ )
389
+ .get(path, path, path) as Record<string, unknown> | null;
147
390
  return row ? rowToProject(row) : null;
148
391
  }
149
392
 
@@ -167,34 +410,582 @@ export function getObservationsByProject(
167
410
  return rows.map(rowToObservation);
168
411
  }
169
412
 
413
+ export interface UpsertPreferenceRecordInput {
414
+ readonly id?: string;
415
+ readonly key: string;
416
+ readonly value: string;
417
+ readonly scope?: PreferenceRecord["scope"];
418
+ readonly projectId?: string | null;
419
+ readonly status?: PreferenceRecord["status"];
420
+ readonly confidence?: number;
421
+ readonly sourceSession?: string | null;
422
+ readonly createdAt: string;
423
+ readonly lastUpdated: string;
424
+ readonly evidence?: readonly {
425
+ readonly sessionId?: string | null;
426
+ readonly runId?: string | null;
427
+ readonly statement: string;
428
+ readonly confidence?: number;
429
+ readonly confirmed?: boolean;
430
+ readonly createdAt?: string;
431
+ }[];
432
+ }
433
+
434
+ export interface ListPreferenceRecordOptions {
435
+ readonly scope?: PreferenceRecord["scope"];
436
+ readonly projectId?: string | null;
437
+ readonly status?: PreferenceRecord["status"];
438
+ readonly onlyConfirmed?: boolean;
439
+ readonly limit?: number;
440
+ }
441
+
442
+ export type PreferenceUpsertInput = Omit<
443
+ Preference,
444
+ "scope" | "projectId" | "status" | "evidenceCount"
445
+ > &
446
+ Partial<Pick<Preference, "scope" | "projectId" | "status" | "evidenceCount">>;
447
+
448
+ export type PreferencePruneStatus = PreferenceRecord["status"] | "unconfirmed" | "any";
449
+
450
+ export interface PreferenceMutationResult {
451
+ readonly deletedPreferences: number;
452
+ readonly deletedEvidence: number;
453
+ }
454
+
455
+ export interface PreferencePruneOptions {
456
+ readonly olderThanDays: number;
457
+ readonly scope?: PreferenceRecord["scope"];
458
+ readonly projectId?: string | null;
459
+ readonly status?: PreferencePruneStatus;
460
+ }
461
+
462
+ export interface PreferenceEvidencePruneOptions {
463
+ readonly olderThanDays: number;
464
+ readonly keepLatestPerPreference?: number;
465
+ readonly scope?: PreferenceRecord["scope"];
466
+ readonly projectId?: string | null;
467
+ readonly status?: PreferencePruneStatus;
468
+ }
469
+
470
+ function isPreferenceStatusMatch(record: PreferenceRecord, status: PreferencePruneStatus): boolean {
471
+ if (status === "any") {
472
+ return true;
473
+ }
474
+ if (status === "unconfirmed") {
475
+ return record.status !== "confirmed";
476
+ }
477
+ return record.status === status;
478
+ }
479
+
170
480
  /**
171
- * Create or replace a preference by its id.
481
+ * Create or replace a structured preference record and its supporting evidence.
172
482
  */
173
- export function upsertPreference(pref: Preference, db?: Database): void {
174
- const validated = preferenceSchema.parse(pref);
483
+ export function upsertPreferenceRecord(
484
+ input: UpsertPreferenceRecordInput,
485
+ db?: Database,
486
+ ): PreferenceRecord {
175
487
  const d = resolveDb(db);
176
- d.run(
177
- `INSERT OR REPLACE INTO preferences (id, key, value, confidence, source_session, created_at, last_updated)
178
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
179
- [
180
- validated.id,
181
- validated.key,
182
- validated.value,
183
- validated.confidence,
184
- validated.sourceSession,
185
- validated.createdAt,
186
- validated.lastUpdated,
187
- ],
488
+ const scope = input.scope ?? "global";
489
+ const normalizedProjectId = normalizePreferenceProjectId(scope, input.projectId ?? null);
490
+ if (scope === "project" && normalizedProjectId === null) {
491
+ throw new Error("project-scoped preferences require a projectId");
492
+ }
493
+ const validated = preferenceRecordSchema.parse({
494
+ id: input.id ?? makePreferenceId(input.key, scope, normalizedProjectId),
495
+ key: input.key,
496
+ value: input.value,
497
+ scope,
498
+ projectId: normalizedProjectId,
499
+ status: input.status ?? "confirmed",
500
+ confidence: input.confidence ?? 0.5,
501
+ sourceSession: input.sourceSession ?? null,
502
+ createdAt: input.createdAt,
503
+ lastUpdated: input.lastUpdated,
504
+ evidenceCount: input.evidence?.length ?? 0,
505
+ });
506
+
507
+ d.run("BEGIN IMMEDIATE");
508
+ try {
509
+ d.run(
510
+ `INSERT INTO preference_records (
511
+ id,
512
+ key,
513
+ value,
514
+ scope,
515
+ project_id,
516
+ status,
517
+ confidence,
518
+ source_session,
519
+ created_at,
520
+ last_updated
521
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
522
+ ON CONFLICT(id) DO UPDATE SET
523
+ key = excluded.key,
524
+ value = excluded.value,
525
+ scope = excluded.scope,
526
+ project_id = excluded.project_id,
527
+ status = excluded.status,
528
+ confidence = excluded.confidence,
529
+ source_session = excluded.source_session,
530
+ created_at = excluded.created_at,
531
+ last_updated = excluded.last_updated`,
532
+ [
533
+ validated.id,
534
+ validated.key,
535
+ validated.value,
536
+ validated.scope,
537
+ validated.projectId,
538
+ validated.status,
539
+ validated.confidence,
540
+ validated.sourceSession,
541
+ validated.createdAt,
542
+ validated.lastUpdated,
543
+ ],
544
+ );
545
+
546
+ for (const evidence of input.evidence ?? []) {
547
+ const statementHash = makeStatementHash(evidence.statement);
548
+ const validatedEvidence = preferenceEvidenceSchema.parse({
549
+ id: makeEvidenceId(validated.id, statementHash),
550
+ preferenceId: validated.id,
551
+ sessionId: evidence.sessionId ?? validated.sourceSession,
552
+ runId: evidence.runId ?? null,
553
+ statement: evidence.statement,
554
+ statementHash,
555
+ confidence: evidence.confidence ?? validated.confidence,
556
+ confirmed: evidence.confirmed ?? validated.status === "confirmed",
557
+ createdAt: evidence.createdAt ?? validated.lastUpdated,
558
+ });
559
+
560
+ d.run(
561
+ `INSERT INTO preference_evidence (
562
+ id,
563
+ preference_id,
564
+ session_id,
565
+ run_id,
566
+ statement,
567
+ statement_hash,
568
+ confidence,
569
+ confirmed,
570
+ created_at
571
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
572
+ ON CONFLICT(id) DO UPDATE SET
573
+ session_id = excluded.session_id,
574
+ run_id = excluded.run_id,
575
+ statement = excluded.statement,
576
+ confidence = excluded.confidence,
577
+ confirmed = excluded.confirmed,
578
+ created_at = excluded.created_at`,
579
+ [
580
+ validatedEvidence.id,
581
+ validatedEvidence.preferenceId,
582
+ validatedEvidence.sessionId,
583
+ validatedEvidence.runId,
584
+ validatedEvidence.statement,
585
+ validatedEvidence.statementHash,
586
+ validatedEvidence.confidence,
587
+ validatedEvidence.confirmed ? 1 : 0,
588
+ validatedEvidence.createdAt,
589
+ ],
590
+ );
591
+ }
592
+
593
+ syncCompatibilityPreference(validated, d);
594
+ d.run("COMMIT");
595
+ } catch (error: unknown) {
596
+ try {
597
+ d.run("ROLLBACK");
598
+ } catch {
599
+ // Ignore rollback failures so the original error wins.
600
+ }
601
+ throw error;
602
+ }
603
+
604
+ return getPreferenceRecordById(validated.id, d) ?? validated;
605
+ }
606
+
607
+ export function getPreferenceRecordById(id: string, db?: Database): PreferenceRecord | null {
608
+ const d = resolveDb(db);
609
+ if (!tableExists(d, "preference_records")) {
610
+ return null;
611
+ }
612
+
613
+ const row = d
614
+ .query(`${listPreferenceRecordsSql("WHERE pr.id = ?")} LIMIT 1`)
615
+ .get(id) as PreferenceRecordRow | null;
616
+ return row ? rowToPreferenceRecord(row) : null;
617
+ }
618
+
619
+ export function listPreferenceRecords(
620
+ options: ListPreferenceRecordOptions = {},
621
+ db?: Database,
622
+ ): readonly PreferenceRecord[] {
623
+ const d = resolveDb(db);
624
+ if (!tableExists(d, "preference_records")) {
625
+ return getAllPreferences(d).map((preference) =>
626
+ preferenceRecordSchema.parse({
627
+ ...preference,
628
+ scope: preference.scope,
629
+ projectId: preference.projectId,
630
+ status: preference.status,
631
+ evidenceCount: preference.evidenceCount,
632
+ }),
633
+ );
634
+ }
635
+
636
+ const conditions: string[] = [];
637
+ const params: Array<string | number> = [];
638
+
639
+ if (options.scope) {
640
+ conditions.push("pr.scope = ?");
641
+ params.push(options.scope);
642
+ }
643
+ if (Object.hasOwn(options, "projectId")) {
644
+ if (options.projectId === null) {
645
+ conditions.push("pr.project_id IS NULL");
646
+ } else if (typeof options.projectId === "string") {
647
+ conditions.push("pr.project_id = ?");
648
+ params.push(options.projectId);
649
+ }
650
+ }
651
+ if (options.onlyConfirmed === true) {
652
+ conditions.push("pr.status = 'confirmed'");
653
+ } else if (options.status) {
654
+ conditions.push("pr.status = ?");
655
+ params.push(options.status);
656
+ }
657
+
658
+ let sql = listPreferenceRecordsSql(
659
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
660
+ );
661
+ if (typeof options.limit === "number") {
662
+ sql = `${sql} LIMIT ?`;
663
+ params.push(Math.max(1, options.limit));
664
+ }
665
+
666
+ const rows = d.query(sql).all(...params) as PreferenceRecordRow[];
667
+ return Object.freeze(rows.map(rowToPreferenceRecord));
668
+ }
669
+
670
+ export function listPreferenceEvidence(
671
+ preferenceId: string,
672
+ db?: Database,
673
+ ): readonly PreferenceEvidence[] {
674
+ const d = resolveDb(db);
675
+ if (!tableExists(d, "preference_evidence")) {
676
+ return Object.freeze([]);
677
+ }
678
+
679
+ const rows = d
680
+ .query(
681
+ `SELECT *
682
+ FROM preference_evidence
683
+ WHERE preference_id = ?
684
+ ORDER BY created_at DESC, id DESC`,
685
+ )
686
+ .all(preferenceId) as PreferenceEvidenceRow[];
687
+ return Object.freeze(rows.map(rowToPreferenceEvidence));
688
+ }
689
+
690
+ function selectPrunablePreferenceRecords(
691
+ options: PreferencePruneOptions,
692
+ db: Database,
693
+ ): readonly PreferenceRecord[] {
694
+ const cutoff = new Date(
695
+ Date.now() - Math.max(1, options.olderThanDays) * 24 * 60 * 60 * 1000,
696
+ ).toISOString();
697
+ const status = options.status ?? "unconfirmed";
698
+ const records = listPreferenceRecords(
699
+ {
700
+ scope: options.scope,
701
+ projectId: options.projectId,
702
+ status:
703
+ status === "candidate" || status === "confirmed" || status === "rejected"
704
+ ? status
705
+ : undefined,
706
+ },
707
+ db,
708
+ );
709
+ return Object.freeze(
710
+ records.filter(
711
+ (record) => isPreferenceStatusMatch(record, status) && record.lastUpdated < cutoff,
712
+ ),
713
+ );
714
+ }
715
+
716
+ function deletePreferenceRecordsByIds(
717
+ ids: readonly string[],
718
+ db: Database,
719
+ ): PreferenceMutationResult {
720
+ if (ids.length === 0) {
721
+ return Object.freeze({ deletedPreferences: 0, deletedEvidence: 0 });
722
+ }
723
+
724
+ if (!tableExists(db, "preference_records")) {
725
+ const placeholders = buildPlaceholders(ids.length);
726
+ const deletedPreferences =
727
+ (
728
+ db
729
+ .query(`SELECT COUNT(*) AS cnt FROM preferences WHERE id IN (${placeholders})`)
730
+ .get(...ids) as { cnt?: number } | null
731
+ )?.cnt ?? 0;
732
+ if (deletedPreferences > 0) {
733
+ db.run(`DELETE FROM preferences WHERE id IN (${placeholders})`, [...ids]);
734
+ }
735
+ return Object.freeze({ deletedPreferences, deletedEvidence: 0 });
736
+ }
737
+
738
+ const placeholders = buildPlaceholders(ids.length);
739
+ const records = db
740
+ .query(`SELECT * FROM preference_records WHERE id IN (${placeholders})`)
741
+ .all(...ids) as PreferenceRecordRow[];
742
+ if (records.length === 0) {
743
+ return Object.freeze({ deletedPreferences: 0, deletedEvidence: 0 });
744
+ }
745
+
746
+ const deletedEvidence = tableExists(db, "preference_evidence")
747
+ ? ((
748
+ db
749
+ .query(
750
+ `SELECT COUNT(*) AS cnt FROM preference_evidence WHERE preference_id IN (${placeholders})`,
751
+ )
752
+ .get(...ids) as { cnt?: number } | null
753
+ )?.cnt ?? 0)
754
+ : 0;
755
+
756
+ withWriteTransaction(db, () => {
757
+ for (const record of records) {
758
+ if (record.scope === "global") {
759
+ db.run("DELETE FROM preferences WHERE id = ? OR key = ?", [record.id, record.key]);
760
+ }
761
+ }
762
+ db.run(`DELETE FROM preference_records WHERE id IN (${placeholders})`, [...ids]);
763
+ });
764
+
765
+ return Object.freeze({
766
+ deletedPreferences: records.length,
767
+ deletedEvidence,
768
+ });
769
+ }
770
+
771
+ export function deletePreferenceRecord(id: string, db?: Database): PreferenceMutationResult {
772
+ return deletePreferenceRecordsByIds([id], resolveDb(db));
773
+ }
774
+
775
+ export function deletePreferencesByKey(
776
+ key: string,
777
+ options: { readonly scope?: PreferenceRecord["scope"]; readonly projectId?: string | null } = {},
778
+ db?: Database,
779
+ ): PreferenceMutationResult {
780
+ const d = resolveDb(db);
781
+ const records = listPreferenceRecords(
782
+ {
783
+ scope: options.scope,
784
+ projectId: options.projectId,
785
+ },
786
+ d,
787
+ ).filter((record) => record.key === key);
788
+ return deletePreferenceRecordsByIds(
789
+ records.map((record) => record.id),
790
+ d,
791
+ );
792
+ }
793
+
794
+ export function prunePreferences(
795
+ options: PreferencePruneOptions,
796
+ db?: Database,
797
+ ): PreferenceMutationResult {
798
+ const d = resolveDb(db);
799
+ const records = selectPrunablePreferenceRecords(options, d);
800
+ return deletePreferenceRecordsByIds(
801
+ records.map((record) => record.id),
802
+ d,
803
+ );
804
+ }
805
+
806
+ export function prunePreferenceEvidence(
807
+ options: PreferenceEvidencePruneOptions,
808
+ db?: Database,
809
+ ): PreferenceMutationResult {
810
+ const d = resolveDb(db);
811
+ if (!tableExists(d, "preference_evidence") || !tableExists(d, "preference_records")) {
812
+ return Object.freeze({ deletedPreferences: 0, deletedEvidence: 0 });
813
+ }
814
+
815
+ const cutoff = new Date(
816
+ Date.now() - Math.max(1, options.olderThanDays) * 24 * 60 * 60 * 1000,
817
+ ).toISOString();
818
+ const keepLatestPerPreference = Math.max(0, options.keepLatestPerPreference ?? 1);
819
+ const status = options.status ?? "any";
820
+ const records = listPreferenceRecords(
821
+ {
822
+ scope: options.scope,
823
+ projectId: options.projectId,
824
+ status:
825
+ status === "candidate" || status === "confirmed" || status === "rejected"
826
+ ? status
827
+ : undefined,
828
+ },
829
+ d,
830
+ ).filter((record) => isPreferenceStatusMatch(record, status));
831
+
832
+ let deletedEvidence = 0;
833
+ withWriteTransaction(d, () => {
834
+ for (const record of records) {
835
+ const evidence = listPreferenceEvidence(record.id, d);
836
+ const removable = evidence
837
+ .slice(keepLatestPerPreference)
838
+ .filter((entry) => entry.createdAt < cutoff);
839
+ if (removable.length === 0) {
840
+ continue;
841
+ }
842
+
843
+ const placeholders = buildPlaceholders(removable.length);
844
+ d.run(
845
+ `DELETE FROM preference_evidence WHERE id IN (${placeholders})`,
846
+ removable.map((entry) => entry.id),
847
+ );
848
+ deletedEvidence += removable.length;
849
+ }
850
+ });
851
+
852
+ return Object.freeze({ deletedPreferences: 0, deletedEvidence });
853
+ }
854
+
855
+ export function listRelevantLessons(
856
+ projectId: string,
857
+ limit = 5,
858
+ db?: Database,
859
+ ): readonly Lesson[] {
860
+ const d = resolveDb(db);
861
+ if (tableExists(d, "project_lessons")) {
862
+ const rows = d
863
+ .query(
864
+ `SELECT content, domain, extracted_at, source_phase, last_updated_at
865
+ FROM project_lessons
866
+ WHERE project_id = ?
867
+ ORDER BY extracted_at DESC, lesson_id DESC
868
+ LIMIT ?`,
869
+ )
870
+ .all(projectId, limit) as ProjectLessonRow[];
871
+ if (rows.length > 0) {
872
+ return buildLessonsFromRows(rows);
873
+ }
874
+ }
875
+
876
+ return listLegacyLessons(projectId, d).slice(0, limit);
877
+ }
878
+
879
+ /**
880
+ * Create or replace a preference by its id.
881
+ * Compatibility wrapper that stores a confirmed global preference record.
882
+ */
883
+ export function upsertPreference(pref: PreferenceUpsertInput, db?: Database): void {
884
+ const validated = preferenceSchema.parse({
885
+ scope: "global",
886
+ projectId: null,
887
+ status: "confirmed",
888
+ evidenceCount: 0,
889
+ ...pref,
890
+ });
891
+ upsertPreferenceRecord(
892
+ {
893
+ id: validated.id,
894
+ key: validated.key,
895
+ value: validated.value,
896
+ scope: validated.scope,
897
+ projectId: validated.projectId,
898
+ status: validated.status,
899
+ confidence: validated.confidence,
900
+ sourceSession: validated.sourceSession,
901
+ createdAt: validated.createdAt,
902
+ lastUpdated: validated.lastUpdated,
903
+ evidence:
904
+ validated.sourceSession === null
905
+ ? []
906
+ : [
907
+ {
908
+ sessionId: validated.sourceSession,
909
+ statement: `${validated.key}: ${validated.value}`,
910
+ confidence: validated.confidence,
911
+ confirmed: validated.status === "confirmed",
912
+ createdAt: validated.lastUpdated,
913
+ },
914
+ ],
915
+ },
916
+ db,
188
917
  );
189
918
  }
190
919
 
191
920
  /**
192
- * Get all preferences.
921
+ * Get all compatibility preferences.
193
922
  */
194
923
  export function getAllPreferences(db?: Database): readonly Preference[] {
195
924
  const d = resolveDb(db);
196
- const rows = d.query("SELECT * FROM preferences").all() as Array<Record<string, unknown>>;
197
- return rows.map(rowToPreference);
925
+ if (!tableExists(d, "preference_records")) {
926
+ const rows = d
927
+ .query("SELECT * FROM preferences ORDER BY last_updated DESC, key ASC")
928
+ .all() as Array<Record<string, unknown>>;
929
+ return rows.map((row) =>
930
+ preferenceSchema.parse({
931
+ id: row.id as string,
932
+ key: row.key as string,
933
+ value: row.value as string,
934
+ confidence: row.confidence as number,
935
+ scope: "global",
936
+ projectId: null,
937
+ status: "confirmed",
938
+ evidenceCount: 0,
939
+ sourceSession: (row.source_session as string) ?? null,
940
+ createdAt: row.created_at as string,
941
+ lastUpdated: row.last_updated as string,
942
+ }),
943
+ );
944
+ }
945
+
946
+ const projected: Preference[] = [];
947
+ const seen = new Set<string>();
948
+ for (const record of listPreferenceRecords({ onlyConfirmed: true }, d)) {
949
+ const uniquenessKey = `${record.scope}:${record.projectId ?? "global"}:${record.key}`;
950
+ if (seen.has(uniquenessKey)) {
951
+ continue;
952
+ }
953
+ seen.add(uniquenessKey);
954
+ projected.push(recordToPreference(record));
955
+ }
956
+ return Object.freeze(projected);
957
+ }
958
+
959
+ export function getConfirmedPreferencesForProject(
960
+ projectId: string,
961
+ db?: Database,
962
+ ): readonly Preference[] {
963
+ const d = resolveDb(db);
964
+ const globalPrefs = listPreferenceRecords({ scope: "global", onlyConfirmed: true, limit: 5 }, d);
965
+ const projectPrefs = listPreferenceRecords(
966
+ { scope: "project", projectId, onlyConfirmed: true, limit: 5 },
967
+ d,
968
+ );
969
+ return Object.freeze([...projectPrefs, ...globalPrefs].map(recordToPreference));
970
+ }
971
+
972
+ export function getRecentFailureObservations(
973
+ projectId: string,
974
+ limit = 5,
975
+ db?: Database,
976
+ ): readonly Observation[] {
977
+ const d = resolveDb(db);
978
+ const rows = d
979
+ .query(
980
+ `SELECT *
981
+ FROM observations
982
+ WHERE project_id = ?
983
+ AND type = 'error'
984
+ ORDER BY created_at DESC, id DESC
985
+ LIMIT ?`,
986
+ )
987
+ .all(projectId, limit) as Array<Record<string, unknown>>;
988
+ return Object.freeze(rows.map(rowToObservation));
198
989
  }
199
990
 
200
991
  /**