@kodrunhq/opencode-autopilot 1.16.0 → 1.18.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 (61) hide show
  1. package/assets/commands/oc-doctor.md +17 -0
  2. package/bin/configure-tui.ts +1 -1
  3. package/bin/inspect.ts +2 -2
  4. package/package.json +1 -1
  5. package/src/config/index.ts +29 -0
  6. package/src/config/migrations.ts +196 -0
  7. package/src/config/v7.ts +45 -0
  8. package/src/config.ts +108 -24
  9. package/src/health/checks.ts +165 -0
  10. package/src/health/runner.ts +8 -2
  11. package/src/health/types.ts +1 -1
  12. package/src/index.ts +25 -2
  13. package/src/kernel/transaction.ts +48 -0
  14. package/src/kernel/types.ts +1 -2
  15. package/src/logging/domains.ts +39 -0
  16. package/src/logging/forensic-writer.ts +177 -0
  17. package/src/logging/index.ts +4 -0
  18. package/src/logging/logger.ts +44 -0
  19. package/src/logging/performance.ts +59 -0
  20. package/src/logging/rotation.ts +261 -0
  21. package/src/logging/types.ts +33 -0
  22. package/src/memory/capture-utils.ts +149 -0
  23. package/src/memory/capture.ts +16 -197
  24. package/src/memory/decay.ts +11 -2
  25. package/src/memory/injector.ts +4 -1
  26. package/src/memory/lessons.ts +85 -0
  27. package/src/memory/observations.ts +177 -0
  28. package/src/memory/preferences.ts +718 -0
  29. package/src/memory/projects.ts +83 -0
  30. package/src/memory/repository.ts +46 -1001
  31. package/src/memory/retrieval.ts +5 -1
  32. package/src/observability/context-display.ts +8 -0
  33. package/src/observability/event-handlers.ts +44 -6
  34. package/src/observability/forensic-log.ts +10 -2
  35. package/src/observability/forensic-schemas.ts +9 -1
  36. package/src/observability/log-reader.ts +20 -1
  37. package/src/orchestrator/error-context.ts +24 -0
  38. package/src/orchestrator/handlers/build-utils.ts +118 -0
  39. package/src/orchestrator/handlers/build.ts +13 -148
  40. package/src/orchestrator/handlers/retrospective.ts +0 -1
  41. package/src/orchestrator/lesson-memory.ts +7 -2
  42. package/src/orchestrator/orchestration-logger.ts +46 -31
  43. package/src/orchestrator/progress.ts +63 -0
  44. package/src/review/memory.ts +11 -3
  45. package/src/review/parse-findings.ts +116 -0
  46. package/src/review/pipeline.ts +3 -107
  47. package/src/review/selection.ts +38 -4
  48. package/src/scoring/time-provider.ts +23 -0
  49. package/src/tools/configure.ts +1 -1
  50. package/src/tools/doctor.ts +2 -2
  51. package/src/tools/logs.ts +32 -6
  52. package/src/tools/orchestrate.ts +11 -9
  53. package/src/tools/replay.ts +42 -0
  54. package/src/tools/review.ts +8 -2
  55. package/src/tools/summary.ts +43 -0
  56. package/src/types/background.ts +51 -0
  57. package/src/types/mcp.ts +27 -0
  58. package/src/types/recovery.ts +39 -0
  59. package/src/types/routing.ts +39 -0
  60. package/src/utils/random.ts +33 -0
  61. package/src/ux/session-summary.ts +56 -0
@@ -1,17 +1,49 @@
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";
5
- import { OBSERVATION_TYPES } from "./constants";
6
- import { getMemoryDb } from "./database";
7
- import {
8
- observationSchema,
9
- preferenceEvidenceSchema,
10
- preferenceRecordSchema,
11
- preferenceSchema,
12
- projectSchema,
13
- } from "./schemas";
14
- import type {
1
+ /**
2
+ * Memory repository - unified exports for observations, preferences, projects, and lessons.
3
+ *
4
+ * This module re-exports all public functions from focused sub-modules:
5
+ * - observations.ts: Observation CRUD and search
6
+ * - preferences.ts: Preference records with evidence tracking
7
+ * - projects.ts: Project metadata and path resolution
8
+ * - lessons.ts: Extracted lessons retrieval
9
+ *
10
+ * @module
11
+ */
12
+
13
+ export { listRelevantLessons } from "./lessons";
14
+ // Re-export all public functions from sub-modules
15
+ export {
16
+ deleteObservation,
17
+ getObservationsByProject,
18
+ getRecentFailureObservations,
19
+ insertObservation,
20
+ searchObservations,
21
+ updateAccessCount,
22
+ } from "./observations";
23
+ export {
24
+ deletePreferenceRecord,
25
+ deletePreferencesByKey,
26
+ getAllPreferences,
27
+ getConfirmedPreferencesForProject,
28
+ getPreferenceRecordById,
29
+ type ListPreferenceRecordOptions,
30
+ listPreferenceEvidence,
31
+ listPreferenceRecords,
32
+ type PreferenceEvidencePruneOptions,
33
+ type PreferenceMutationResult,
34
+ type PreferencePruneOptions,
35
+ type PreferencePruneStatus,
36
+ type PreferenceUpsertInput,
37
+ prunePreferenceEvidence,
38
+ prunePreferences,
39
+ type UpsertPreferenceRecordInput,
40
+ upsertPreference,
41
+ upsertPreferenceRecord,
42
+ } from "./preferences";
43
+ export { getProjectByPath, upsertProject } from "./projects";
44
+
45
+ // Re-export types for convenience
46
+ export type {
15
47
  Observation,
16
48
  ObservationType,
17
49
  Preference,
@@ -19,990 +51,3 @@ import type {
19
51
  PreferenceRecord,
20
52
  Project,
21
53
  } 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
- }
56
-
57
- /** Resolve optional db parameter to singleton fallback. */
58
- function resolveDb(db?: Database): Database {
59
- return db ?? getMemoryDb();
60
- }
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
-
118
- /** Validate observation type at runtime. */
119
- function parseObservationType(value: unknown): ObservationType {
120
- if (typeof value === "string" && (OBSERVATION_TYPES as readonly string[]).includes(value)) {
121
- return value as ObservationType;
122
- }
123
- return "context";
124
- }
125
-
126
- /** Map a snake_case DB row to camelCase Observation. */
127
- function rowToObservation(row: Record<string, unknown>): Observation {
128
- return {
129
- id: row.id as number,
130
- projectId: (row.project_id as string) ?? null,
131
- sessionId: row.session_id as string,
132
- type: parseObservationType(row.type),
133
- content: row.content as string,
134
- summary: row.summary as string,
135
- confidence: row.confidence as number,
136
- accessCount: row.access_count as number,
137
- createdAt: row.created_at as string,
138
- lastAccessed: row.last_accessed as string,
139
- };
140
- }
141
-
142
- /** Map a snake_case DB row to camelCase Project. */
143
- function rowToProject(row: Record<string, unknown>): Project {
144
- return {
145
- id: row.id as string,
146
- path: row.path as string,
147
- name: row.name as string,
148
- firstSeenAt: ((row.first_seen_at as string) ?? (row.last_updated as string)) as string,
149
- lastUpdated: row.last_updated as string,
150
- };
151
- }
152
-
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
- );
274
- }
275
-
276
- /**
277
- * Insert an observation. Validates via Zod before writing.
278
- * Returns the observation with the generated id.
279
- */
280
- export function insertObservation(obs: Omit<Observation, "id">, db?: Database): Observation {
281
- const validated = observationSchema.omit({ id: true }).parse(obs);
282
- const d = resolveDb(db);
283
-
284
- d.run(
285
- `INSERT INTO observations (project_id, session_id, type, content, summary, confidence, access_count, created_at, last_accessed)
286
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
287
- [
288
- validated.projectId,
289
- validated.sessionId,
290
- validated.type,
291
- validated.content,
292
- validated.summary,
293
- validated.confidence,
294
- validated.accessCount,
295
- validated.createdAt,
296
- validated.lastAccessed,
297
- ],
298
- );
299
-
300
- const row = d.query("SELECT last_insert_rowid() as id").get() as { id: number };
301
- return { ...validated, id: row.id };
302
- }
303
-
304
- /**
305
- * Search observations using FTS5 MATCH with BM25 ranking.
306
- * Filters by projectId (null for user-level observations).
307
- */
308
- export function searchObservations(
309
- query: string,
310
- projectId: string | null,
311
- limit = 20,
312
- db?: Database,
313
- ): Array<Observation & { ftsRank: number }> {
314
- const d = resolveDb(db);
315
-
316
- const projectFilter = projectId === null ? "AND o.project_id IS NULL" : "AND o.project_id = ?";
317
- const safeFtsQuery = `"${query.replace(/"/g, '""')}"`;
318
- const params: Array<string | number> =
319
- projectId === null ? [safeFtsQuery, limit] : [safeFtsQuery, projectId, limit];
320
-
321
- const rows = d
322
- .query(
323
- `SELECT o.*, bm25(observations_fts) as fts_rank
324
- FROM observations_fts f
325
- JOIN observations o ON o.id = f.rowid
326
- WHERE observations_fts MATCH ?
327
- ${projectFilter}
328
- ORDER BY fts_rank
329
- LIMIT ?`,
330
- )
331
- .all(...params) as Array<Record<string, unknown>>;
332
-
333
- return rows.map((row) => ({
334
- ...rowToObservation(row),
335
- ftsRank: row.fts_rank as number,
336
- }));
337
- }
338
-
339
- /**
340
- * Create or replace a project record.
341
- */
342
- export function upsertProject(project: Project, db?: Database): void {
343
- const validated = projectSchema.parse(project);
344
- const d = resolveDb(db);
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 = ?", [
357
- validated.lastUpdated,
358
- validated.id,
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
- );
368
- }
369
-
370
- /**
371
- * Get a project by its filesystem path. Returns null if not found.
372
- */
373
- export function getProjectByPath(path: string, db?: Database): Project | null {
374
- const d = resolveDb(db);
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;
390
- return row ? rowToProject(row) : null;
391
- }
392
-
393
- /**
394
- * Get observations filtered by project_id, ordered by created_at DESC.
395
- */
396
- export function getObservationsByProject(
397
- projectId: string | null,
398
- limit = 50,
399
- db?: Database,
400
- ): readonly Observation[] {
401
- const d = resolveDb(db);
402
-
403
- const whereClause = projectId === null ? "WHERE project_id IS NULL" : "WHERE project_id = ?";
404
- const params: Array<string | number> = projectId === null ? [limit] : [projectId, limit];
405
-
406
- const rows = d
407
- .query(`SELECT * FROM observations ${whereClause} ORDER BY created_at DESC LIMIT ?`)
408
- .all(...params) as Array<Record<string, unknown>>;
409
-
410
- return rows.map(rowToObservation);
411
- }
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
-
480
- /**
481
- * Create or replace a structured preference record and its supporting evidence.
482
- */
483
- export function upsertPreferenceRecord(
484
- input: UpsertPreferenceRecordInput,
485
- db?: Database,
486
- ): PreferenceRecord {
487
- const d = resolveDb(db);
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,
917
- );
918
- }
919
-
920
- /**
921
- * Get all compatibility preferences.
922
- */
923
- export function getAllPreferences(db?: Database): readonly Preference[] {
924
- const d = resolveDb(db);
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));
989
- }
990
-
991
- /**
992
- * Delete an observation by id.
993
- */
994
- export function deleteObservation(id: number, db?: Database): void {
995
- const d = resolveDb(db);
996
- d.run("DELETE FROM observations WHERE id = ?", [id]);
997
- }
998
-
999
- /**
1000
- * Increment access_count and update last_accessed for an observation.
1001
- */
1002
- export function updateAccessCount(id: number, db?: Database): void {
1003
- const d = resolveDb(db);
1004
- d.run("UPDATE observations SET access_count = access_count + 1, last_accessed = ? WHERE id = ?", [
1005
- new Date().toISOString(),
1006
- id,
1007
- ]);
1008
- }