@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.
- package/assets/commands/oc-doctor.md +17 -0
- package/bin/configure-tui.ts +1 -1
- package/bin/inspect.ts +2 -2
- package/package.json +1 -1
- package/src/config/index.ts +29 -0
- package/src/config/migrations.ts +196 -0
- package/src/config/v7.ts +45 -0
- package/src/config.ts +108 -24
- package/src/health/checks.ts +165 -0
- package/src/health/runner.ts +8 -2
- package/src/health/types.ts +1 -1
- package/src/index.ts +25 -2
- package/src/kernel/transaction.ts +48 -0
- package/src/kernel/types.ts +1 -2
- package/src/logging/domains.ts +39 -0
- package/src/logging/forensic-writer.ts +177 -0
- package/src/logging/index.ts +4 -0
- package/src/logging/logger.ts +44 -0
- package/src/logging/performance.ts +59 -0
- package/src/logging/rotation.ts +261 -0
- package/src/logging/types.ts +33 -0
- package/src/memory/capture-utils.ts +149 -0
- package/src/memory/capture.ts +16 -197
- package/src/memory/decay.ts +11 -2
- package/src/memory/injector.ts +4 -1
- package/src/memory/lessons.ts +85 -0
- package/src/memory/observations.ts +177 -0
- package/src/memory/preferences.ts +718 -0
- package/src/memory/projects.ts +83 -0
- package/src/memory/repository.ts +46 -1001
- package/src/memory/retrieval.ts +5 -1
- package/src/observability/context-display.ts +8 -0
- package/src/observability/event-handlers.ts +44 -6
- package/src/observability/forensic-log.ts +10 -2
- package/src/observability/forensic-schemas.ts +9 -1
- package/src/observability/log-reader.ts +20 -1
- package/src/orchestrator/error-context.ts +24 -0
- package/src/orchestrator/handlers/build-utils.ts +118 -0
- package/src/orchestrator/handlers/build.ts +13 -148
- package/src/orchestrator/handlers/retrospective.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +7 -2
- package/src/orchestrator/orchestration-logger.ts +46 -31
- package/src/orchestrator/progress.ts +63 -0
- package/src/review/memory.ts +11 -3
- package/src/review/parse-findings.ts +116 -0
- package/src/review/pipeline.ts +3 -107
- package/src/review/selection.ts +38 -4
- package/src/scoring/time-provider.ts +23 -0
- package/src/tools/configure.ts +1 -1
- package/src/tools/doctor.ts +2 -2
- package/src/tools/logs.ts +32 -6
- package/src/tools/orchestrate.ts +11 -9
- package/src/tools/replay.ts +42 -0
- package/src/tools/review.ts +8 -2
- package/src/tools/summary.ts +43 -0
- package/src/types/background.ts +51 -0
- package/src/types/mcp.ts +27 -0
- package/src/types/recovery.ts +39 -0
- package/src/types/routing.ts +39 -0
- package/src/utils/random.ts +33 -0
- package/src/ux/session-summary.ts +56 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preference repository operations.
|
|
3
|
+
* Handles preference records with evidence tracking and pruning.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Database } from "bun:sqlite";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { systemTimeProvider, type TimeProvider } from "../scoring/time-provider";
|
|
9
|
+
import { getMemoryDb } from "./database";
|
|
10
|
+
import { preferenceEvidenceSchema, preferenceRecordSchema, preferenceSchema } from "./schemas";
|
|
11
|
+
import type { Preference, PreferenceEvidence, PreferenceRecord } from "./types";
|
|
12
|
+
|
|
13
|
+
function resolveDb(db?: Database): Database {
|
|
14
|
+
return db ?? getMemoryDb();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function withWriteTransaction<T>(db: Database, callback: () => T): T {
|
|
18
|
+
const row = db.query("PRAGMA transaction_state").get() as { transaction_state?: string } | null;
|
|
19
|
+
if (row?.transaction_state === "TRANSACTION") {
|
|
20
|
+
return callback();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
db.run("BEGIN IMMEDIATE");
|
|
24
|
+
try {
|
|
25
|
+
const result = callback();
|
|
26
|
+
db.run("COMMIT");
|
|
27
|
+
return result;
|
|
28
|
+
} catch (error: unknown) {
|
|
29
|
+
try {
|
|
30
|
+
db.run("ROLLBACK");
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore rollback failures so the original error wins.
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildPlaceholders(count: number): string {
|
|
39
|
+
return Array.from({ length: count }, () => "?").join(", ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizePreferenceProjectId(
|
|
43
|
+
scope: PreferenceRecord["scope"],
|
|
44
|
+
projectId: string | null,
|
|
45
|
+
): string | null {
|
|
46
|
+
return scope === "project" ? projectId : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makePreferenceId(
|
|
50
|
+
key: string,
|
|
51
|
+
scope: PreferenceRecord["scope"],
|
|
52
|
+
projectId: string | null,
|
|
53
|
+
): string {
|
|
54
|
+
const normalizedProjectId = normalizePreferenceProjectId(scope, projectId) ?? "global";
|
|
55
|
+
return `pref-${createHash("sha1").update(`${scope}:${normalizedProjectId}:${key}`).digest("hex")}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeEvidenceId(preferenceId: string, statementHash: string): string {
|
|
59
|
+
return `evidence-${createHash("sha1").update(`${preferenceId}:${statementHash}`).digest("hex")}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeStatementHash(statement: string): string {
|
|
63
|
+
return createHash("sha1").update(statement).digest("hex");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function tableExists(db: Database, tableName: string): boolean {
|
|
67
|
+
const row = db
|
|
68
|
+
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
69
|
+
.get(tableName) as { name?: string } | null;
|
|
70
|
+
return row?.name === tableName;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface PreferenceRecordRow {
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly key: string;
|
|
76
|
+
readonly value: string;
|
|
77
|
+
readonly scope: string;
|
|
78
|
+
readonly project_id: string | null;
|
|
79
|
+
readonly status: string;
|
|
80
|
+
readonly confidence: number;
|
|
81
|
+
readonly source_session: string | null;
|
|
82
|
+
readonly created_at: string;
|
|
83
|
+
readonly last_updated: string;
|
|
84
|
+
readonly evidence_count?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface PreferenceEvidenceRow {
|
|
88
|
+
readonly id: string;
|
|
89
|
+
readonly preference_id: string;
|
|
90
|
+
readonly session_id: string | null;
|
|
91
|
+
readonly run_id: string | null;
|
|
92
|
+
readonly statement: string;
|
|
93
|
+
readonly statement_hash: string;
|
|
94
|
+
readonly confidence: number;
|
|
95
|
+
readonly confirmed: number;
|
|
96
|
+
readonly created_at: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function rowToPreferenceRecord(row: PreferenceRecordRow): PreferenceRecord {
|
|
100
|
+
return preferenceRecordSchema.parse({
|
|
101
|
+
id: row.id,
|
|
102
|
+
key: row.key,
|
|
103
|
+
value: row.value,
|
|
104
|
+
scope: row.scope,
|
|
105
|
+
projectId: row.project_id,
|
|
106
|
+
status: row.status,
|
|
107
|
+
confidence: row.confidence,
|
|
108
|
+
sourceSession: row.source_session,
|
|
109
|
+
createdAt: row.created_at,
|
|
110
|
+
lastUpdated: row.last_updated,
|
|
111
|
+
evidenceCount: row.evidence_count ?? 0,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function rowToPreferenceEvidence(row: PreferenceEvidenceRow): PreferenceEvidence {
|
|
116
|
+
return preferenceEvidenceSchema.parse({
|
|
117
|
+
id: row.id,
|
|
118
|
+
preferenceId: row.preference_id,
|
|
119
|
+
sessionId: row.session_id,
|
|
120
|
+
runId: row.run_id,
|
|
121
|
+
statement: row.statement,
|
|
122
|
+
statementHash: row.statement_hash,
|
|
123
|
+
confidence: row.confidence,
|
|
124
|
+
confirmed: row.confirmed === 1,
|
|
125
|
+
createdAt: row.created_at,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function recordToPreference(record: PreferenceRecord): Preference {
|
|
130
|
+
return preferenceSchema.parse({
|
|
131
|
+
id: record.id,
|
|
132
|
+
key: record.key,
|
|
133
|
+
value: record.value,
|
|
134
|
+
confidence: record.confidence,
|
|
135
|
+
scope: record.scope,
|
|
136
|
+
projectId: record.projectId,
|
|
137
|
+
status: record.status,
|
|
138
|
+
evidenceCount: record.evidenceCount,
|
|
139
|
+
sourceSession: record.sourceSession,
|
|
140
|
+
createdAt: record.createdAt,
|
|
141
|
+
lastUpdated: record.lastUpdated,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function syncCompatibilityPreference(record: PreferenceRecord, db: Database): void {
|
|
146
|
+
if (record.scope !== "global" || record.status !== "confirmed") {
|
|
147
|
+
db.run("DELETE FROM preferences WHERE id = ?", [record.id]);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
db.run(
|
|
152
|
+
`INSERT INTO preferences (id, key, value, confidence, source_session, created_at, last_updated)
|
|
153
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
154
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
155
|
+
id = excluded.id,
|
|
156
|
+
key = excluded.key,
|
|
157
|
+
value = excluded.value,
|
|
158
|
+
confidence = excluded.confidence,
|
|
159
|
+
source_session = excluded.source_session,
|
|
160
|
+
created_at = excluded.created_at,
|
|
161
|
+
last_updated = excluded.last_updated`,
|
|
162
|
+
[
|
|
163
|
+
record.id,
|
|
164
|
+
record.key,
|
|
165
|
+
record.value,
|
|
166
|
+
record.confidence,
|
|
167
|
+
record.sourceSession,
|
|
168
|
+
record.createdAt,
|
|
169
|
+
record.lastUpdated,
|
|
170
|
+
],
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function listPreferenceRecordsSql(baseWhere = ""): string {
|
|
175
|
+
return `SELECT
|
|
176
|
+
pr.*,
|
|
177
|
+
COALESCE(evidence_counts.evidence_count, 0) AS evidence_count
|
|
178
|
+
FROM preference_records pr
|
|
179
|
+
LEFT JOIN (
|
|
180
|
+
SELECT preference_id, COUNT(*) AS evidence_count
|
|
181
|
+
FROM preference_evidence
|
|
182
|
+
GROUP BY preference_id
|
|
183
|
+
) AS evidence_counts ON evidence_counts.preference_id = pr.id
|
|
184
|
+
${baseWhere}
|
|
185
|
+
ORDER BY pr.last_updated DESC, pr.key ASC, pr.id ASC`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isPreferenceStatusMatch(record: PreferenceRecord, status: PreferencePruneStatus): boolean {
|
|
189
|
+
if (status === "any") {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
if (status === "unconfirmed") {
|
|
193
|
+
return record.status !== "confirmed";
|
|
194
|
+
}
|
|
195
|
+
return record.status === status;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface UpsertPreferenceRecordInput {
|
|
199
|
+
readonly id?: string;
|
|
200
|
+
readonly key: string;
|
|
201
|
+
readonly value: string;
|
|
202
|
+
readonly scope?: PreferenceRecord["scope"];
|
|
203
|
+
readonly projectId?: string | null;
|
|
204
|
+
readonly status?: PreferenceRecord["status"];
|
|
205
|
+
readonly confidence?: number;
|
|
206
|
+
readonly sourceSession?: string | null;
|
|
207
|
+
readonly createdAt: string;
|
|
208
|
+
readonly lastUpdated: string;
|
|
209
|
+
readonly evidence?: readonly {
|
|
210
|
+
readonly sessionId?: string | null;
|
|
211
|
+
readonly runId?: string | null;
|
|
212
|
+
readonly statement: string;
|
|
213
|
+
readonly confidence?: number;
|
|
214
|
+
readonly confirmed?: boolean;
|
|
215
|
+
readonly createdAt?: string;
|
|
216
|
+
}[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface ListPreferenceRecordOptions {
|
|
220
|
+
readonly scope?: PreferenceRecord["scope"];
|
|
221
|
+
readonly projectId?: string | null;
|
|
222
|
+
readonly status?: PreferenceRecord["status"];
|
|
223
|
+
readonly onlyConfirmed?: boolean;
|
|
224
|
+
readonly limit?: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export type PreferenceUpsertInput = Omit<
|
|
228
|
+
Preference,
|
|
229
|
+
"scope" | "projectId" | "status" | "evidenceCount"
|
|
230
|
+
> &
|
|
231
|
+
Partial<Pick<Preference, "scope" | "projectId" | "status" | "evidenceCount">>;
|
|
232
|
+
|
|
233
|
+
export type PreferencePruneStatus = PreferenceRecord["status"] | "unconfirmed" | "any";
|
|
234
|
+
|
|
235
|
+
export interface PreferenceMutationResult {
|
|
236
|
+
readonly deletedPreferences: number;
|
|
237
|
+
readonly deletedEvidence: number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface PreferencePruneOptions {
|
|
241
|
+
readonly olderThanDays: number;
|
|
242
|
+
readonly scope?: PreferenceRecord["scope"];
|
|
243
|
+
readonly projectId?: string | null;
|
|
244
|
+
readonly status?: PreferencePruneStatus;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface PreferenceEvidencePruneOptions {
|
|
248
|
+
readonly olderThanDays: number;
|
|
249
|
+
readonly keepLatestPerPreference?: number;
|
|
250
|
+
readonly scope?: PreferenceRecord["scope"];
|
|
251
|
+
readonly projectId?: string | null;
|
|
252
|
+
readonly status?: PreferencePruneStatus;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function selectPrunablePreferenceRecords(
|
|
256
|
+
options: PreferencePruneOptions,
|
|
257
|
+
db: Database,
|
|
258
|
+
timeProvider: TimeProvider = systemTimeProvider,
|
|
259
|
+
): readonly PreferenceRecord[] {
|
|
260
|
+
const cutoff = new Date(
|
|
261
|
+
timeProvider.now() - Math.max(1, options.olderThanDays) * 24 * 60 * 60 * 1000,
|
|
262
|
+
).toISOString();
|
|
263
|
+
const status = options.status ?? "unconfirmed";
|
|
264
|
+
const records = listPreferenceRecords(
|
|
265
|
+
{
|
|
266
|
+
scope: options.scope,
|
|
267
|
+
projectId: options.projectId,
|
|
268
|
+
status:
|
|
269
|
+
status === "candidate" || status === "confirmed" || status === "rejected"
|
|
270
|
+
? status
|
|
271
|
+
: undefined,
|
|
272
|
+
},
|
|
273
|
+
db,
|
|
274
|
+
);
|
|
275
|
+
return Object.freeze(
|
|
276
|
+
records.filter(
|
|
277
|
+
(record) => isPreferenceStatusMatch(record, status) && record.lastUpdated < cutoff,
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function deletePreferenceRecordsByIds(
|
|
283
|
+
ids: readonly string[],
|
|
284
|
+
db: Database,
|
|
285
|
+
): PreferenceMutationResult {
|
|
286
|
+
if (ids.length === 0) {
|
|
287
|
+
return Object.freeze({ deletedPreferences: 0, deletedEvidence: 0 });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!tableExists(db, "preference_records")) {
|
|
291
|
+
const placeholders = buildPlaceholders(ids.length);
|
|
292
|
+
const deletedPreferences =
|
|
293
|
+
(
|
|
294
|
+
db
|
|
295
|
+
.query(`SELECT COUNT(*) AS cnt FROM preferences WHERE id IN (${placeholders})`)
|
|
296
|
+
.get(...ids) as { cnt?: number } | null
|
|
297
|
+
)?.cnt ?? 0;
|
|
298
|
+
if (deletedPreferences > 0) {
|
|
299
|
+
db.run(`DELETE FROM preferences WHERE id IN (${placeholders})`, [...ids]);
|
|
300
|
+
}
|
|
301
|
+
return Object.freeze({ deletedPreferences, deletedEvidence: 0 });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const placeholders = buildPlaceholders(ids.length);
|
|
305
|
+
const records = db
|
|
306
|
+
.query(`SELECT * FROM preference_records WHERE id IN (${placeholders})`)
|
|
307
|
+
.all(...ids) as PreferenceRecordRow[];
|
|
308
|
+
if (records.length === 0) {
|
|
309
|
+
return Object.freeze({ deletedPreferences: 0, deletedEvidence: 0 });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const deletedEvidence = tableExists(db, "preference_evidence")
|
|
313
|
+
? ((
|
|
314
|
+
db
|
|
315
|
+
.query(
|
|
316
|
+
`SELECT COUNT(*) AS cnt FROM preference_evidence WHERE preference_id IN (${placeholders})`,
|
|
317
|
+
)
|
|
318
|
+
.get(...ids) as { cnt?: number } | null
|
|
319
|
+
)?.cnt ?? 0)
|
|
320
|
+
: 0;
|
|
321
|
+
|
|
322
|
+
withWriteTransaction(db, () => {
|
|
323
|
+
for (const record of records) {
|
|
324
|
+
if (record.scope === "global") {
|
|
325
|
+
db.run("DELETE FROM preferences WHERE id = ? OR key = ?", [record.id, record.key]);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
db.run(`DELETE FROM preference_records WHERE id IN (${placeholders})`, [...ids]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return Object.freeze({
|
|
332
|
+
deletedPreferences: records.length,
|
|
333
|
+
deletedEvidence,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getAllPreferencesLegacy(db: Database): readonly Preference[] {
|
|
338
|
+
const rows = db
|
|
339
|
+
.query("SELECT * FROM preferences ORDER BY last_updated DESC, key ASC")
|
|
340
|
+
.all() as Array<Record<string, unknown>>;
|
|
341
|
+
return rows.map((row) =>
|
|
342
|
+
preferenceSchema.parse({
|
|
343
|
+
id: row.id as string,
|
|
344
|
+
key: row.key as string,
|
|
345
|
+
value: row.value as string,
|
|
346
|
+
confidence: row.confidence as number,
|
|
347
|
+
scope: "global",
|
|
348
|
+
projectId: null,
|
|
349
|
+
status: "confirmed",
|
|
350
|
+
evidenceCount: 0,
|
|
351
|
+
sourceSession: (row.source_session as string) ?? null,
|
|
352
|
+
createdAt: row.created_at as string,
|
|
353
|
+
lastUpdated: row.last_updated as string,
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function upsertPreferenceRecord(
|
|
359
|
+
input: UpsertPreferenceRecordInput,
|
|
360
|
+
db?: Database,
|
|
361
|
+
): PreferenceRecord {
|
|
362
|
+
const d = resolveDb(db);
|
|
363
|
+
const scope = input.scope ?? "global";
|
|
364
|
+
const normalizedProjectId = normalizePreferenceProjectId(scope, input.projectId ?? null);
|
|
365
|
+
if (scope === "project" && normalizedProjectId === null) {
|
|
366
|
+
throw new Error("project-scoped preferences require a projectId");
|
|
367
|
+
}
|
|
368
|
+
const validated = preferenceRecordSchema.parse({
|
|
369
|
+
id: input.id ?? makePreferenceId(input.key, scope, normalizedProjectId),
|
|
370
|
+
key: input.key,
|
|
371
|
+
value: input.value,
|
|
372
|
+
scope,
|
|
373
|
+
projectId: normalizedProjectId,
|
|
374
|
+
status: input.status ?? "confirmed",
|
|
375
|
+
confidence: input.confidence ?? 0.5,
|
|
376
|
+
sourceSession: input.sourceSession ?? null,
|
|
377
|
+
createdAt: input.createdAt,
|
|
378
|
+
lastUpdated: input.lastUpdated,
|
|
379
|
+
evidenceCount: input.evidence?.length ?? 0,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
d.run("BEGIN IMMEDIATE");
|
|
383
|
+
try {
|
|
384
|
+
d.run(
|
|
385
|
+
`INSERT INTO preference_records (
|
|
386
|
+
id,
|
|
387
|
+
key,
|
|
388
|
+
value,
|
|
389
|
+
scope,
|
|
390
|
+
project_id,
|
|
391
|
+
status,
|
|
392
|
+
confidence,
|
|
393
|
+
source_session,
|
|
394
|
+
created_at,
|
|
395
|
+
last_updated
|
|
396
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
397
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
398
|
+
key = excluded.key,
|
|
399
|
+
value = excluded.value,
|
|
400
|
+
scope = excluded.scope,
|
|
401
|
+
project_id = excluded.project_id,
|
|
402
|
+
status = excluded.status,
|
|
403
|
+
confidence = excluded.confidence,
|
|
404
|
+
source_session = excluded.source_session,
|
|
405
|
+
created_at = excluded.created_at,
|
|
406
|
+
last_updated = excluded.last_updated`,
|
|
407
|
+
[
|
|
408
|
+
validated.id,
|
|
409
|
+
validated.key,
|
|
410
|
+
validated.value,
|
|
411
|
+
validated.scope,
|
|
412
|
+
validated.projectId,
|
|
413
|
+
validated.status,
|
|
414
|
+
validated.confidence,
|
|
415
|
+
validated.sourceSession,
|
|
416
|
+
validated.createdAt,
|
|
417
|
+
validated.lastUpdated,
|
|
418
|
+
],
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
for (const evidence of input.evidence ?? []) {
|
|
422
|
+
const statementHash = makeStatementHash(evidence.statement);
|
|
423
|
+
const validatedEvidence = preferenceEvidenceSchema.parse({
|
|
424
|
+
id: makeEvidenceId(validated.id, statementHash),
|
|
425
|
+
preferenceId: validated.id,
|
|
426
|
+
sessionId: evidence.sessionId ?? validated.sourceSession,
|
|
427
|
+
runId: evidence.runId ?? null,
|
|
428
|
+
statement: evidence.statement,
|
|
429
|
+
statementHash,
|
|
430
|
+
confidence: evidence.confidence ?? validated.confidence,
|
|
431
|
+
confirmed: evidence.confirmed ?? validated.status === "confirmed",
|
|
432
|
+
createdAt: evidence.createdAt ?? validated.lastUpdated,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
d.run(
|
|
436
|
+
`INSERT INTO preference_evidence (
|
|
437
|
+
id,
|
|
438
|
+
preference_id,
|
|
439
|
+
session_id,
|
|
440
|
+
run_id,
|
|
441
|
+
statement,
|
|
442
|
+
statement_hash,
|
|
443
|
+
confidence,
|
|
444
|
+
confirmed,
|
|
445
|
+
created_at
|
|
446
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
447
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
448
|
+
session_id = excluded.session_id,
|
|
449
|
+
run_id = excluded.run_id,
|
|
450
|
+
statement = excluded.statement,
|
|
451
|
+
confidence = excluded.confidence,
|
|
452
|
+
confirmed = excluded.confirmed,
|
|
453
|
+
created_at = excluded.created_at`,
|
|
454
|
+
[
|
|
455
|
+
validatedEvidence.id,
|
|
456
|
+
validatedEvidence.preferenceId,
|
|
457
|
+
validatedEvidence.sessionId,
|
|
458
|
+
validatedEvidence.runId,
|
|
459
|
+
validatedEvidence.statement,
|
|
460
|
+
validatedEvidence.statementHash,
|
|
461
|
+
validatedEvidence.confidence,
|
|
462
|
+
validatedEvidence.confirmed ? 1 : 0,
|
|
463
|
+
validatedEvidence.createdAt,
|
|
464
|
+
],
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
syncCompatibilityPreference(validated, d);
|
|
469
|
+
d.run("COMMIT");
|
|
470
|
+
} catch (error: unknown) {
|
|
471
|
+
try {
|
|
472
|
+
d.run("ROLLBACK");
|
|
473
|
+
} catch {
|
|
474
|
+
// Ignore rollback failures so the original error wins.
|
|
475
|
+
}
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return getPreferenceRecordById(validated.id, d) ?? validated;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function getPreferenceRecordById(id: string, db?: Database): PreferenceRecord | null {
|
|
483
|
+
const d = resolveDb(db);
|
|
484
|
+
if (!tableExists(d, "preference_records")) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const row = d
|
|
489
|
+
.query(`${listPreferenceRecordsSql("WHERE pr.id = ?")} LIMIT 1`)
|
|
490
|
+
.get(id) as PreferenceRecordRow | null;
|
|
491
|
+
return row ? rowToPreferenceRecord(row) : null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function listPreferenceRecords(
|
|
495
|
+
options: ListPreferenceRecordOptions = {},
|
|
496
|
+
db?: Database,
|
|
497
|
+
): readonly PreferenceRecord[] {
|
|
498
|
+
const d = resolveDb(db);
|
|
499
|
+
if (!tableExists(d, "preference_records")) {
|
|
500
|
+
return getAllPreferencesLegacy(d).map((preference) =>
|
|
501
|
+
preferenceRecordSchema.parse({
|
|
502
|
+
...preference,
|
|
503
|
+
scope: preference.scope,
|
|
504
|
+
projectId: preference.projectId,
|
|
505
|
+
status: preference.status,
|
|
506
|
+
evidenceCount: preference.evidenceCount,
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const conditions: string[] = [];
|
|
512
|
+
const params: Array<string | number> = [];
|
|
513
|
+
|
|
514
|
+
if (options.scope) {
|
|
515
|
+
conditions.push("pr.scope = ?");
|
|
516
|
+
params.push(options.scope);
|
|
517
|
+
}
|
|
518
|
+
if (Object.hasOwn(options, "projectId")) {
|
|
519
|
+
if (options.projectId === null) {
|
|
520
|
+
conditions.push("pr.project_id IS NULL");
|
|
521
|
+
} else if (typeof options.projectId === "string") {
|
|
522
|
+
conditions.push("pr.project_id = ?");
|
|
523
|
+
params.push(options.projectId);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (options.onlyConfirmed === true) {
|
|
527
|
+
conditions.push("pr.status = 'confirmed'");
|
|
528
|
+
} else if (options.status) {
|
|
529
|
+
conditions.push("pr.status = ?");
|
|
530
|
+
params.push(options.status);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let sql = listPreferenceRecordsSql(
|
|
534
|
+
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
|
|
535
|
+
);
|
|
536
|
+
if (typeof options.limit === "number") {
|
|
537
|
+
sql = `${sql} LIMIT ?`;
|
|
538
|
+
params.push(Math.max(1, options.limit));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const rows = d.query(sql).all(...params) as PreferenceRecordRow[];
|
|
542
|
+
return Object.freeze(rows.map(rowToPreferenceRecord));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function listPreferenceEvidence(
|
|
546
|
+
preferenceId: string,
|
|
547
|
+
db?: Database,
|
|
548
|
+
): readonly PreferenceEvidence[] {
|
|
549
|
+
const d = resolveDb(db);
|
|
550
|
+
if (!tableExists(d, "preference_evidence")) {
|
|
551
|
+
return Object.freeze([]);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const rows = d
|
|
555
|
+
.query(
|
|
556
|
+
`SELECT *
|
|
557
|
+
FROM preference_evidence
|
|
558
|
+
WHERE preference_id = ?
|
|
559
|
+
ORDER BY created_at DESC, id DESC`,
|
|
560
|
+
)
|
|
561
|
+
.all(preferenceId) as PreferenceEvidenceRow[];
|
|
562
|
+
return Object.freeze(rows.map(rowToPreferenceEvidence));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function deletePreferenceRecord(id: string, db?: Database): PreferenceMutationResult {
|
|
566
|
+
return deletePreferenceRecordsByIds([id], resolveDb(db));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function deletePreferencesByKey(
|
|
570
|
+
key: string,
|
|
571
|
+
options: { readonly scope?: PreferenceRecord["scope"]; readonly projectId?: string | null } = {},
|
|
572
|
+
db?: Database,
|
|
573
|
+
): PreferenceMutationResult {
|
|
574
|
+
const d = resolveDb(db);
|
|
575
|
+
const records = listPreferenceRecords(
|
|
576
|
+
{
|
|
577
|
+
scope: options.scope,
|
|
578
|
+
projectId: options.projectId,
|
|
579
|
+
},
|
|
580
|
+
d,
|
|
581
|
+
).filter((record) => record.key === key);
|
|
582
|
+
return deletePreferenceRecordsByIds(
|
|
583
|
+
records.map((record) => record.id),
|
|
584
|
+
d,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function prunePreferences(
|
|
589
|
+
options: PreferencePruneOptions,
|
|
590
|
+
db?: Database,
|
|
591
|
+
timeProvider: TimeProvider = systemTimeProvider,
|
|
592
|
+
): PreferenceMutationResult {
|
|
593
|
+
const d = resolveDb(db);
|
|
594
|
+
const records = selectPrunablePreferenceRecords(options, d, timeProvider);
|
|
595
|
+
return deletePreferenceRecordsByIds(
|
|
596
|
+
records.map((record) => record.id),
|
|
597
|
+
d,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function prunePreferenceEvidence(
|
|
602
|
+
options: PreferenceEvidencePruneOptions,
|
|
603
|
+
db?: Database,
|
|
604
|
+
timeProvider: TimeProvider = systemTimeProvider,
|
|
605
|
+
): PreferenceMutationResult {
|
|
606
|
+
const d = resolveDb(db);
|
|
607
|
+
if (!tableExists(d, "preference_evidence") || !tableExists(d, "preference_records")) {
|
|
608
|
+
return Object.freeze({ deletedPreferences: 0, deletedEvidence: 0 });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const cutoff = new Date(
|
|
612
|
+
timeProvider.now() - Math.max(1, options.olderThanDays) * 24 * 60 * 60 * 1000,
|
|
613
|
+
).toISOString();
|
|
614
|
+
const keepLatestPerPreference = Math.max(0, options.keepLatestPerPreference ?? 1);
|
|
615
|
+
const status = options.status ?? "any";
|
|
616
|
+
const records = listPreferenceRecords(
|
|
617
|
+
{
|
|
618
|
+
scope: options.scope,
|
|
619
|
+
projectId: options.projectId,
|
|
620
|
+
status:
|
|
621
|
+
status === "candidate" || status === "confirmed" || status === "rejected"
|
|
622
|
+
? status
|
|
623
|
+
: undefined,
|
|
624
|
+
},
|
|
625
|
+
d,
|
|
626
|
+
).filter((record) => isPreferenceStatusMatch(record, status));
|
|
627
|
+
|
|
628
|
+
let deletedEvidence = 0;
|
|
629
|
+
withWriteTransaction(d, () => {
|
|
630
|
+
for (const record of records) {
|
|
631
|
+
const evidence = listPreferenceEvidence(record.id, d);
|
|
632
|
+
const removable = evidence
|
|
633
|
+
.slice(keepLatestPerPreference)
|
|
634
|
+
.filter((entry) => entry.createdAt < cutoff);
|
|
635
|
+
if (removable.length === 0) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const placeholders = buildPlaceholders(removable.length);
|
|
640
|
+
d.run(
|
|
641
|
+
`DELETE FROM preference_evidence WHERE id IN (${placeholders})`,
|
|
642
|
+
removable.map((entry) => entry.id),
|
|
643
|
+
);
|
|
644
|
+
deletedEvidence += removable.length;
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return Object.freeze({ deletedPreferences: 0, deletedEvidence });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function getConfirmedPreferencesForProject(
|
|
652
|
+
projectId: string,
|
|
653
|
+
db?: Database,
|
|
654
|
+
): readonly Preference[] {
|
|
655
|
+
const d = resolveDb(db);
|
|
656
|
+
const globalPrefs = listPreferenceRecords({ scope: "global", onlyConfirmed: true, limit: 5 }, d);
|
|
657
|
+
const projectPrefs = listPreferenceRecords(
|
|
658
|
+
{ scope: "project", projectId, onlyConfirmed: true, limit: 5 },
|
|
659
|
+
d,
|
|
660
|
+
);
|
|
661
|
+
return Object.freeze([...projectPrefs, ...globalPrefs].map(recordToPreference));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export function upsertPreference(pref: PreferenceUpsertInput, db?: Database): void {
|
|
665
|
+
const validated = preferenceSchema.parse({
|
|
666
|
+
scope: "global",
|
|
667
|
+
projectId: null,
|
|
668
|
+
status: "confirmed",
|
|
669
|
+
evidenceCount: 0,
|
|
670
|
+
...pref,
|
|
671
|
+
});
|
|
672
|
+
upsertPreferenceRecord(
|
|
673
|
+
{
|
|
674
|
+
id: validated.id,
|
|
675
|
+
key: validated.key,
|
|
676
|
+
value: validated.value,
|
|
677
|
+
scope: validated.scope,
|
|
678
|
+
projectId: validated.projectId,
|
|
679
|
+
status: validated.status,
|
|
680
|
+
confidence: validated.confidence,
|
|
681
|
+
sourceSession: validated.sourceSession,
|
|
682
|
+
createdAt: validated.createdAt,
|
|
683
|
+
lastUpdated: validated.lastUpdated,
|
|
684
|
+
evidence:
|
|
685
|
+
validated.sourceSession === null
|
|
686
|
+
? []
|
|
687
|
+
: [
|
|
688
|
+
{
|
|
689
|
+
sessionId: validated.sourceSession,
|
|
690
|
+
statement: `${validated.key}: ${validated.value}`,
|
|
691
|
+
confidence: validated.confidence,
|
|
692
|
+
confirmed: validated.status === "confirmed",
|
|
693
|
+
createdAt: validated.lastUpdated,
|
|
694
|
+
},
|
|
695
|
+
],
|
|
696
|
+
},
|
|
697
|
+
db,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export function getAllPreferences(db?: Database): readonly Preference[] {
|
|
702
|
+
const d = resolveDb(db);
|
|
703
|
+
if (!tableExists(d, "preference_records")) {
|
|
704
|
+
return getAllPreferencesLegacy(d);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const projected: Preference[] = [];
|
|
708
|
+
const seen = new Set<string>();
|
|
709
|
+
for (const record of listPreferenceRecords({ onlyConfirmed: true }, d)) {
|
|
710
|
+
const uniquenessKey = `${record.scope}:${record.projectId ?? "global"}:${record.key}`;
|
|
711
|
+
if (seen.has(uniquenessKey)) {
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
seen.add(uniquenessKey);
|
|
715
|
+
projected.push(recordToPreference(record));
|
|
716
|
+
}
|
|
717
|
+
return Object.freeze(projected);
|
|
718
|
+
}
|