@drewpayment/mink 0.11.0 → 0.12.0-beta.1
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/dashboard/out/404.html +1 -1
- package/dashboard/out/action-log.html +1 -1
- package/dashboard/out/action-log.txt +1 -1
- package/dashboard/out/activity.html +1 -1
- package/dashboard/out/activity.txt +1 -1
- package/dashboard/out/bugs.html +1 -1
- package/dashboard/out/bugs.txt +1 -1
- package/dashboard/out/capture.html +1 -1
- package/dashboard/out/capture.txt +1 -1
- package/dashboard/out/config.html +1 -1
- package/dashboard/out/config.txt +1 -1
- package/dashboard/out/daemon.html +1 -1
- package/dashboard/out/daemon.txt +1 -1
- package/dashboard/out/design.html +1 -1
- package/dashboard/out/design.txt +1 -1
- package/dashboard/out/discord.html +1 -1
- package/dashboard/out/discord.txt +1 -1
- package/dashboard/out/file-index.html +1 -1
- package/dashboard/out/file-index.txt +1 -1
- package/dashboard/out/index.html +1 -1
- package/dashboard/out/index.txt +1 -1
- package/dashboard/out/insights.html +1 -1
- package/dashboard/out/insights.txt +1 -1
- package/dashboard/out/learning.html +1 -1
- package/dashboard/out/learning.txt +1 -1
- package/dashboard/out/overview.html +1 -1
- package/dashboard/out/overview.txt +1 -1
- package/dashboard/out/scheduler.html +1 -1
- package/dashboard/out/scheduler.txt +1 -1
- package/dashboard/out/sync.html +1 -1
- package/dashboard/out/sync.txt +1 -1
- package/dashboard/out/tokens.html +1 -1
- package/dashboard/out/tokens.txt +1 -1
- package/dashboard/out/waste.html +1 -1
- package/dashboard/out/waste.txt +1 -1
- package/dashboard/out/wiki.html +1 -1
- package/dashboard/out/wiki.txt +1 -1
- package/dist/cli.bun.js +90615 -0
- package/dist/{cli.js → cli.node.js} +2227 -758
- package/package.json +14 -4
- package/scripts/build.mjs +47 -0
- package/src/commands/bug-search.ts +2 -4
- package/src/commands/detect-waste.ts +24 -32
- package/src/commands/post-read.ts +10 -11
- package/src/commands/post-write.ts +13 -19
- package/src/commands/pre-read.ts +19 -24
- package/src/commands/scan.ts +103 -40
- package/src/commands/status.ts +45 -26
- package/src/core/bug-memory.ts +32 -34
- package/src/core/dashboard-api.ts +44 -22
- package/src/core/index-store.ts +23 -0
- package/src/core/paths.ts +7 -0
- package/src/core/scanner.ts +8 -4
- package/src/core/state-aggregator.ts +64 -7
- package/src/core/state-counters.ts +11 -31
- package/src/core/sync-merge-drivers.ts +164 -1
- package/src/core/sync.ts +9 -0
- package/src/core/token-ledger.ts +50 -4
- package/src/repositories/bug-memory-repo.ts +268 -0
- package/src/repositories/counters-repo.ts +88 -0
- package/src/repositories/file-index-repo.ts +238 -0
- package/src/repositories/token-ledger-repo.ts +412 -0
- package/src/storage/db.ts +121 -0
- package/src/storage/driver.bun.ts +99 -0
- package/src/storage/driver.node.ts +107 -0
- package/src/storage/driver.ts +76 -0
- package/src/storage/migrate-json.ts +415 -0
- package/src/storage/schema.ts +207 -0
- package/src/types/file-index.ts +9 -0
- /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 9ElzGFcXpcjLq-QSQslWY}/_buildManifest.js +0 -0
- /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 9ElzGFcXpcjLq-QSQslWY}/_ssgManifest.js +0 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, appendFileSync } from "fs";
|
|
1
|
+
import { readFileSync, writeFileSync, appendFileSync, copyFileSync, unlinkSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { minkRoot } from "./paths";
|
|
4
4
|
import { parseLearningMemory, serializeLearningMemory } from "./learning-memory";
|
|
5
|
+
import { openDriver } from "../storage/driver";
|
|
6
|
+
import { applySchema } from "../storage/schema";
|
|
5
7
|
import type { LearningMemory, SectionName } from "../types/learning-memory";
|
|
6
8
|
import type { FileIndex, FileIndexEntry } from "../types/file-index";
|
|
7
9
|
import type { DeviceInfo, DeviceRegistry } from "../types/config";
|
|
@@ -220,6 +222,161 @@ export function mergeDevicesDriver(args: DriverArgs): void {
|
|
|
220
222
|
}
|
|
221
223
|
}
|
|
222
224
|
|
|
225
|
+
// ── mink-db-merge: projects/*/mink.db ──────────────────────────────────────
|
|
226
|
+
// Two-DB reconciliation: open `ours.db` for read/write, ATTACH `theirs.db`
|
|
227
|
+
// as a read-only schema, replay rows via INSERT OR ... ON CONFLICT using
|
|
228
|
+
// the same per-store conflict rules the JSON aggregator uses today.
|
|
229
|
+
// Sessions / counters / ledger_lifetime are append-merge keyed by device.
|
|
230
|
+
// Falls back to "keep ours" on any failure so a merge driver never blocks
|
|
231
|
+
// the rebase.
|
|
232
|
+
|
|
233
|
+
interface DbHandle {
|
|
234
|
+
exec(sql: string): void;
|
|
235
|
+
prepare(sql: string): { run(...args: unknown[]): unknown };
|
|
236
|
+
close(): void;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function attachAndReplay(ours: DbHandle, theirsPath: string): void {
|
|
240
|
+
// ATTACH requires the path to be a literal string parameter; bind it
|
|
241
|
+
// with the prepare binding to avoid SQL injection from a weird filename.
|
|
242
|
+
ours.prepare("ATTACH DATABASE ? AS theirs").run(theirsPath);
|
|
243
|
+
try {
|
|
244
|
+
ours.exec(`
|
|
245
|
+
-- file_index: keep the side with the newer last_modified.
|
|
246
|
+
INSERT INTO file_index
|
|
247
|
+
(file_path, description, estimated_tokens, last_modified, last_indexed,
|
|
248
|
+
mtime_ms, content_hash, size_bytes, device_id)
|
|
249
|
+
SELECT file_path, description, estimated_tokens, last_modified, last_indexed,
|
|
250
|
+
mtime_ms, content_hash, size_bytes, device_id
|
|
251
|
+
FROM theirs.file_index
|
|
252
|
+
WHERE TRUE
|
|
253
|
+
ON CONFLICT(file_path) DO UPDATE SET
|
|
254
|
+
description = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.description ELSE file_index.description END,
|
|
255
|
+
estimated_tokens = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.estimated_tokens ELSE file_index.estimated_tokens END,
|
|
256
|
+
last_indexed = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.last_indexed ELSE file_index.last_indexed END,
|
|
257
|
+
last_modified = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.last_modified ELSE file_index.last_modified END,
|
|
258
|
+
mtime_ms = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.mtime_ms ELSE file_index.mtime_ms END,
|
|
259
|
+
content_hash = COALESCE(excluded.content_hash, file_index.content_hash),
|
|
260
|
+
size_bytes = COALESCE(excluded.size_bytes, file_index.size_bytes),
|
|
261
|
+
device_id = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.device_id ELSE file_index.device_id END;
|
|
262
|
+
|
|
263
|
+
-- bug_memory: oldest createdAt + latest lastSeenAt + max occurrence_count.
|
|
264
|
+
INSERT INTO bug_memory
|
|
265
|
+
(id, created_at, last_seen_at, error_message, file_path, line_number,
|
|
266
|
+
root_cause, fix_description, occurrence_count, device_id)
|
|
267
|
+
SELECT id, created_at, last_seen_at, error_message, file_path, line_number,
|
|
268
|
+
root_cause, fix_description, occurrence_count, device_id
|
|
269
|
+
FROM theirs.bug_memory
|
|
270
|
+
WHERE TRUE
|
|
271
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
272
|
+
created_at = CASE WHEN excluded.created_at < bug_memory.created_at THEN excluded.created_at ELSE bug_memory.created_at END,
|
|
273
|
+
last_seen_at = CASE WHEN excluded.last_seen_at > bug_memory.last_seen_at THEN excluded.last_seen_at ELSE bug_memory.last_seen_at END,
|
|
274
|
+
occurrence_count = MAX(bug_memory.occurrence_count, excluded.occurrence_count);
|
|
275
|
+
|
|
276
|
+
INSERT OR IGNORE INTO bug_tags (bug_id, tag) SELECT bug_id, tag FROM theirs.bug_tags;
|
|
277
|
+
INSERT OR IGNORE INTO bug_related (bug_id, related_bug_id) SELECT bug_id, related_bug_id FROM theirs.bug_related;
|
|
278
|
+
|
|
279
|
+
-- Ledger sessions are insert-only and device-isolated, so first writer
|
|
280
|
+
-- wins is correct (shards never overlap session_id in production).
|
|
281
|
+
INSERT OR IGNORE INTO ledger_sessions
|
|
282
|
+
(session_id, device_id, start_timestamp, end_timestamp, read_count,
|
|
283
|
+
write_count, estimated_tokens, repeated_reads, file_index_hits,
|
|
284
|
+
file_index_misses, estimated_savings, archived)
|
|
285
|
+
SELECT session_id, device_id, start_timestamp, end_timestamp, read_count,
|
|
286
|
+
write_count, estimated_tokens, repeated_reads, file_index_hits,
|
|
287
|
+
file_index_misses, estimated_savings, archived
|
|
288
|
+
FROM theirs.ledger_sessions;
|
|
289
|
+
|
|
290
|
+
INSERT INTO ledger_reads (session_id, file_path, estimated_tokens, read_count)
|
|
291
|
+
SELECT session_id, file_path, estimated_tokens, read_count
|
|
292
|
+
FROM theirs.ledger_reads
|
|
293
|
+
WHERE session_id IN (SELECT session_id FROM theirs.ledger_sessions
|
|
294
|
+
WHERE session_id NOT IN (SELECT session_id FROM ledger_reads));
|
|
295
|
+
|
|
296
|
+
INSERT INTO ledger_writes (session_id, file_path, estimated_tokens, action)
|
|
297
|
+
SELECT session_id, file_path, estimated_tokens, action
|
|
298
|
+
FROM theirs.ledger_writes
|
|
299
|
+
WHERE session_id IN (SELECT session_id FROM theirs.ledger_sessions
|
|
300
|
+
WHERE session_id NOT IN (SELECT session_id FROM ledger_writes));
|
|
301
|
+
|
|
302
|
+
-- Per-device lifetime sums and counters: take the MAX so concurrent
|
|
303
|
+
-- increments on different devices don't double-count when the same
|
|
304
|
+
-- device's row is shared (it shouldn't be, but MAX is a safe upper
|
|
305
|
+
-- bound under concurrent shard mutation).
|
|
306
|
+
INSERT INTO ledger_lifetime
|
|
307
|
+
(device_id, total_tokens, total_reads, total_writes, total_sessions,
|
|
308
|
+
total_file_index_hits, total_file_index_misses, total_repeated_reads,
|
|
309
|
+
total_estimated_savings)
|
|
310
|
+
SELECT device_id, total_tokens, total_reads, total_writes, total_sessions,
|
|
311
|
+
total_file_index_hits, total_file_index_misses, total_repeated_reads,
|
|
312
|
+
total_estimated_savings
|
|
313
|
+
FROM theirs.ledger_lifetime
|
|
314
|
+
WHERE TRUE
|
|
315
|
+
ON CONFLICT(device_id) DO UPDATE SET
|
|
316
|
+
total_tokens = MAX(ledger_lifetime.total_tokens, excluded.total_tokens),
|
|
317
|
+
total_reads = MAX(ledger_lifetime.total_reads, excluded.total_reads),
|
|
318
|
+
total_writes = MAX(ledger_lifetime.total_writes, excluded.total_writes),
|
|
319
|
+
total_sessions = MAX(ledger_lifetime.total_sessions, excluded.total_sessions),
|
|
320
|
+
total_file_index_hits = MAX(ledger_lifetime.total_file_index_hits, excluded.total_file_index_hits),
|
|
321
|
+
total_file_index_misses = MAX(ledger_lifetime.total_file_index_misses, excluded.total_file_index_misses),
|
|
322
|
+
total_repeated_reads = MAX(ledger_lifetime.total_repeated_reads, excluded.total_repeated_reads),
|
|
323
|
+
total_estimated_savings = MAX(ledger_lifetime.total_estimated_savings, excluded.total_estimated_savings);
|
|
324
|
+
|
|
325
|
+
INSERT INTO counters (device_id, file_index_hits, file_index_misses)
|
|
326
|
+
SELECT device_id, file_index_hits, file_index_misses
|
|
327
|
+
FROM theirs.counters
|
|
328
|
+
WHERE TRUE
|
|
329
|
+
ON CONFLICT(device_id) DO UPDATE SET
|
|
330
|
+
file_index_hits = MAX(counters.file_index_hits, excluded.file_index_hits),
|
|
331
|
+
file_index_misses = MAX(counters.file_index_misses, excluded.file_index_misses);
|
|
332
|
+
|
|
333
|
+
INSERT OR IGNORE INTO waste_flags (pattern, detected_at, details, device_id)
|
|
334
|
+
SELECT pattern, detected_at, details, device_id FROM theirs.waste_flags;
|
|
335
|
+
`);
|
|
336
|
+
} finally {
|
|
337
|
+
ours.exec("DETACH DATABASE theirs");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function mergeDbDriver(args: DriverArgs): void {
|
|
342
|
+
// Run the merge in a side-DB so a crash mid-replay never leaves ours in
|
|
343
|
+
// a half-merged state. We copy ours -> a temp file, replay theirs into
|
|
344
|
+
// the copy, then atomically replace ours via rename. Sidecar WAL/SHM
|
|
345
|
+
// files in the temp location are cleaned up before rename.
|
|
346
|
+
const tmp = `${args.oursPath}.merge-${process.pid}-${Date.now()}.tmp`;
|
|
347
|
+
let ours: ReturnType<typeof openDriver> | null = null;
|
|
348
|
+
try {
|
|
349
|
+
copyFileSync(args.oursPath, tmp);
|
|
350
|
+
ours = openDriver(tmp);
|
|
351
|
+
ours.exec("PRAGMA journal_mode = WAL");
|
|
352
|
+
ours.exec("PRAGMA foreign_keys = ON");
|
|
353
|
+
applySchema(ours); // tolerate `theirs` carrying tables we don't yet have
|
|
354
|
+
|
|
355
|
+
attachAndReplay(ours, args.theirsPath);
|
|
356
|
+
|
|
357
|
+
ours.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
358
|
+
ours.close();
|
|
359
|
+
ours = null;
|
|
360
|
+
|
|
361
|
+
// Clean up WAL/SHM sidecars left by the temp DB so the rename target
|
|
362
|
+
// is a single self-contained file. SQLite truncates the WAL above; we
|
|
363
|
+
// remove the empty sidecar entries explicitly.
|
|
364
|
+
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
|
365
|
+
try { unlinkSync(`${tmp}${suffix}`); } catch { /* not present */ }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Atomic replace.
|
|
369
|
+
copyFileSync(tmp, args.oursPath);
|
|
370
|
+
try { unlinkSync(tmp); } catch { /* best effort */ }
|
|
371
|
+
} catch (err) {
|
|
372
|
+
logWarning("mink-db-merge", args, err);
|
|
373
|
+
try { if (ours) ours.close(); } catch { /* ignore */ }
|
|
374
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
375
|
+
// Fall back to ours by doing nothing — the original args.oursPath is
|
|
376
|
+
// untouched.
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
223
380
|
// ── Dispatcher ─────────────────────────────────────────────────────────────
|
|
224
381
|
|
|
225
382
|
export function runMergeDriver(
|
|
@@ -232,8 +389,14 @@ export function runMergeDriver(
|
|
|
232
389
|
const args: DriverArgs = { basePath, oursPath, theirsPath, filePath };
|
|
233
390
|
switch (name) {
|
|
234
391
|
case "mink-json-union":
|
|
392
|
+
// Legacy driver — still registered for projects pre-Phase 2 of the
|
|
393
|
+
// SQLite migration where file-index.json may resurface during sync
|
|
394
|
+
// of legacy-backup contents. New projects don't use it.
|
|
235
395
|
mergeJsonUnion(args);
|
|
236
396
|
return 0;
|
|
397
|
+
case "mink-db-merge":
|
|
398
|
+
mergeDbDriver(args);
|
|
399
|
+
return 0;
|
|
237
400
|
case "mink-learning-memory":
|
|
238
401
|
mergeLearningMemoryDriver(args);
|
|
239
402
|
return 0;
|
package/src/core/sync.ts
CHANGED
|
@@ -65,6 +65,12 @@ projects/*/scheduler-manifest.json
|
|
|
65
65
|
projects/*/design-captures/
|
|
66
66
|
projects/*/.mink-state-counters.json
|
|
67
67
|
|
|
68
|
+
# SQLite write-ahead log + shared-memory sidecars — local-only, must not
|
|
69
|
+
# travel with the main mink.db (WAL is checkpointed before push).
|
|
70
|
+
projects/*/mink.db-wal
|
|
71
|
+
projects/*/mink.db-shm
|
|
72
|
+
projects/*/mink.db-journal
|
|
73
|
+
|
|
68
74
|
# Wiki derived/regenerable pages — each device rebuilds locally
|
|
69
75
|
wiki/_index.md
|
|
70
76
|
wiki/.mink-index.json
|
|
@@ -74,6 +80,8 @@ wiki/projects/*/architecture.md
|
|
|
74
80
|
|
|
75
81
|
const GITATTRIBUTES_CONTENTS = `# Sync v2 — merge drivers eliminate conflicts on shared files.
|
|
76
82
|
# Drivers are registered in .git/config by ensureMergeDriversRegistered().
|
|
83
|
+
projects/*/mink.db merge=mink-db-merge
|
|
84
|
+
projects/*/mink.db binary
|
|
77
85
|
projects/*/file-index.json merge=mink-json-union
|
|
78
86
|
projects/*/learning-memory.*.md merge=union
|
|
79
87
|
projects/*/learning-memory.md merge=mink-learning-memory
|
|
@@ -120,6 +128,7 @@ export function ensureGitAttributes(): void {
|
|
|
120
128
|
|
|
121
129
|
const MERGE_DRIVERS = [
|
|
122
130
|
"mink-json-union",
|
|
131
|
+
"mink-db-merge",
|
|
123
132
|
"mink-learning-memory",
|
|
124
133
|
"mink-devices",
|
|
125
134
|
] as const;
|
package/src/core/token-ledger.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
1
2
|
import { join } from "path";
|
|
2
3
|
import type { TokenLedger, LedgerSession, LifetimeCounters } from "../types/token-ledger";
|
|
3
4
|
import type { SessionFinalizer, SessionSummary } from "../types/session";
|
|
@@ -158,15 +159,60 @@ export function saveArchive(archivePath: string, newlyArchived: LedgerSession[])
|
|
|
158
159
|
|
|
159
160
|
// ── Task 6: Ledger Finalizer Factory ─────────────────────────────────────────
|
|
160
161
|
|
|
162
|
+
// Phase 4 of the SQLite migration: every projectDir that has a mink.db
|
|
163
|
+
// uses TokenLedgerRepo. Legacy JSON paths still get a finalizer for
|
|
164
|
+
// pre-migration projects (and for unit tests that don't open a DB).
|
|
161
165
|
export function createLedgerFinalizer(
|
|
162
166
|
projectDir: string,
|
|
163
167
|
deviceIdOrThreshold?: string | number,
|
|
164
168
|
archiveThreshold: number = 1000
|
|
165
169
|
): SessionFinalizer {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
+
const dbPath = join(projectDir, "mink.db");
|
|
171
|
+
|
|
172
|
+
if (existsSync(dbPath)) {
|
|
173
|
+
// Route through the SQLite repo. We open a fresh handle so this
|
|
174
|
+
// module doesn't depend on the per-process cache used by hooks.
|
|
175
|
+
const { openDriver } = require("../storage/driver") as typeof import("../storage/driver");
|
|
176
|
+
const { applySchema } = require("../storage/schema") as typeof import("../storage/schema");
|
|
177
|
+
const { TokenLedgerRepo } = require("../repositories/token-ledger-repo") as typeof import("../repositories/token-ledger-repo");
|
|
178
|
+
|
|
179
|
+
const deviceId = typeof deviceIdOrThreshold === "string" ? deviceIdOrThreshold : undefined;
|
|
180
|
+
const threshold = typeof deviceIdOrThreshold === "number"
|
|
181
|
+
? deviceIdOrThreshold
|
|
182
|
+
: archiveThreshold;
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
appendSession(summary: SessionSummary): void {
|
|
186
|
+
const db = openDriver(dbPath);
|
|
187
|
+
try {
|
|
188
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
189
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
190
|
+
applySchema(db);
|
|
191
|
+
const repo = new TokenLedgerRepo(db);
|
|
192
|
+
repo.appendSession(summary, deviceId);
|
|
193
|
+
repo.archive(threshold);
|
|
194
|
+
} finally {
|
|
195
|
+
db.close();
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
updateSession(summary: SessionSummary): void {
|
|
200
|
+
const db = openDriver(dbPath);
|
|
201
|
+
try {
|
|
202
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
203
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
204
|
+
applySchema(db);
|
|
205
|
+
new TokenLedgerRepo(db).updateSession(summary, deviceId);
|
|
206
|
+
} finally {
|
|
207
|
+
db.close();
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Legacy JSON fallback ────────────────────────────────────────────
|
|
214
|
+
// Tests + pre-migration projects continue to write to disk. The
|
|
215
|
+
// `(projectDir)` and `(projectDir, threshold)` signatures still work.
|
|
170
216
|
let ledgerPath: string;
|
|
171
217
|
let archivePath: string;
|
|
172
218
|
let threshold: number;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Bug-memory repository. Wraps the bug_memory + bug_tags + bug_related +
|
|
2
|
+
// bug_memory_fts tables in mink.db. The CLI surface (loadBugMemory,
|
|
3
|
+
// findDuplicate, lookupBugsForFile, searchBugs, hasBugForFileInSession)
|
|
4
|
+
// is preserved by the thin wrapper in src/core/bug-memory.ts; this file
|
|
5
|
+
// is where the SQLite queries live.
|
|
6
|
+
//
|
|
7
|
+
// Search uses FTS5 (porter+unicode61 tokenization) so the per-query cost
|
|
8
|
+
// stays sublinear in bug count. The score-vs-false-positive guards from
|
|
9
|
+
// the v1 Jaccard implementation are preserved: a 0.3 score threshold,
|
|
10
|
+
// file-path or tag-overlap match required when score is borderline,
|
|
11
|
+
// same-file matches get a 0.2 boost.
|
|
12
|
+
|
|
13
|
+
import { randomUUID } from "crypto";
|
|
14
|
+
import type { DbDriver } from "../storage/driver";
|
|
15
|
+
import type { BugEntry, BugMemory, SimilarityMatch } from "../types/bug-memory";
|
|
16
|
+
import { openProjectDb } from "../storage/db";
|
|
17
|
+
import { getOrCreateDeviceId } from "../core/device";
|
|
18
|
+
|
|
19
|
+
interface BugRow {
|
|
20
|
+
id: string;
|
|
21
|
+
created_at: string;
|
|
22
|
+
last_seen_at: string;
|
|
23
|
+
error_message: string;
|
|
24
|
+
file_path: string;
|
|
25
|
+
line_number: number | null;
|
|
26
|
+
root_cause: string;
|
|
27
|
+
fix_description: string;
|
|
28
|
+
occurrence_count: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tokenize(text: string): Set<string> {
|
|
32
|
+
return new Set(
|
|
33
|
+
text.toLowerCase().split(/\W+/).filter((w) => w.length > 0)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class BugMemoryRepo {
|
|
38
|
+
constructor(private readonly db: DbDriver) {}
|
|
39
|
+
|
|
40
|
+
static for(cwd: string): BugMemoryRepo {
|
|
41
|
+
return new BugMemoryRepo(openProjectDb(cwd));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Insert / upsert ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
// Detect an exact-text duplicate of (errorMessage, filePath). Mirrors
|
|
47
|
+
// the v1 `findDuplicate` semantics — same (errorMessage, filePath)
|
|
48
|
+
// pair counts as a re-occurrence of the same bug.
|
|
49
|
+
findDuplicate(errorMessage: string, filePath: string): BugEntry | null {
|
|
50
|
+
const row = this.db
|
|
51
|
+
.prepare(
|
|
52
|
+
"SELECT * FROM bug_memory WHERE error_message = ? AND file_path = ? LIMIT 1"
|
|
53
|
+
)
|
|
54
|
+
.get(errorMessage, filePath);
|
|
55
|
+
if (!row) return null;
|
|
56
|
+
return this.hydrate(row as unknown as BugRow);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
add(
|
|
60
|
+
fields: Omit<BugEntry, "id" | "createdAt" | "lastSeenAt" | "occurrenceCount">
|
|
61
|
+
): BugEntry {
|
|
62
|
+
const existing = this.findDuplicate(fields.errorMessage, fields.filePath);
|
|
63
|
+
if (existing) {
|
|
64
|
+
this.incrementOccurrence(existing.id);
|
|
65
|
+
return this.lookup(existing.id) ?? existing;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const id = `bug-${randomUUID().slice(0, 8)}`;
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
const deviceId = getOrCreateDeviceId();
|
|
71
|
+
|
|
72
|
+
this.db.transaction(() => {
|
|
73
|
+
this.db.prepare(`
|
|
74
|
+
INSERT INTO bug_memory
|
|
75
|
+
(id, created_at, last_seen_at, error_message, file_path, line_number,
|
|
76
|
+
root_cause, fix_description, occurrence_count, device_id)
|
|
77
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
|
|
78
|
+
`).run(
|
|
79
|
+
id, now, now,
|
|
80
|
+
fields.errorMessage, fields.filePath, fields.lineNumber ?? null,
|
|
81
|
+
fields.rootCause, fields.fixDescription, deviceId
|
|
82
|
+
);
|
|
83
|
+
const insertTag = this.db.prepare(
|
|
84
|
+
"INSERT OR IGNORE INTO bug_tags (bug_id, tag) VALUES (?, ?)"
|
|
85
|
+
);
|
|
86
|
+
for (const tag of fields.tags ?? []) insertTag.run(id, tag);
|
|
87
|
+
const insertRelated = this.db.prepare(
|
|
88
|
+
"INSERT OR IGNORE INTO bug_related (bug_id, related_bug_id) VALUES (?, ?)"
|
|
89
|
+
);
|
|
90
|
+
for (const rel of fields.relatedBugIds ?? []) insertRelated.run(id, rel);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return this.lookup(id)!;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
incrementOccurrence(id: string): void {
|
|
97
|
+
const now = new Date().toISOString();
|
|
98
|
+
this.db.prepare(
|
|
99
|
+
"UPDATE bug_memory SET occurrence_count = occurrence_count + 1, last_seen_at = ? WHERE id = ?"
|
|
100
|
+
).run(now, id);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Read ───────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
lookup(id: string): BugEntry | null {
|
|
106
|
+
const row = this.db
|
|
107
|
+
.prepare("SELECT * FROM bug_memory WHERE id = ?")
|
|
108
|
+
.get(id);
|
|
109
|
+
if (!row) return null;
|
|
110
|
+
return this.hydrate(row as unknown as BugRow);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
lookupForFile(filePath: string): BugEntry[] {
|
|
114
|
+
const rows = this.db
|
|
115
|
+
.prepare(
|
|
116
|
+
"SELECT * FROM bug_memory WHERE file_path = ? ORDER BY last_seen_at DESC"
|
|
117
|
+
)
|
|
118
|
+
.all(filePath);
|
|
119
|
+
return (rows as unknown as BugRow[]).map((r) => this.hydrate(r));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
listAll(): BugEntry[] {
|
|
123
|
+
const rows = this.db
|
|
124
|
+
.prepare("SELECT * FROM bug_memory ORDER BY last_seen_at DESC")
|
|
125
|
+
.all();
|
|
126
|
+
return (rows as unknown as BugRow[]).map((r) => this.hydrate(r));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
count(): number {
|
|
130
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM bug_memory").get();
|
|
131
|
+
return Number((row as { n: number }).n);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
hasBugForFileInSession(filePath: string, sessionStartIso: string): boolean {
|
|
135
|
+
const row = this.db
|
|
136
|
+
.prepare(
|
|
137
|
+
"SELECT 1 FROM bug_memory WHERE file_path = ? AND created_at >= ? LIMIT 1"
|
|
138
|
+
)
|
|
139
|
+
.get(filePath, sessionStartIso);
|
|
140
|
+
return row !== undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Search (FTS5) ──────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
// Preserves the v1 contract: scores in (0, 1+) range, 0.3 threshold,
|
|
146
|
+
// file-path/tag boost. FTS5's bm25 returns negative scores (lower =
|
|
147
|
+
// better), so we normalize via `1 / (1 + abs(bm25))` to land in (0, 1].
|
|
148
|
+
// The boost for same-file matches stays at +0.2 and the same false-
|
|
149
|
+
// positive guards (require file-path or tag overlap when borderline)
|
|
150
|
+
// apply.
|
|
151
|
+
searchBugs(
|
|
152
|
+
query: string,
|
|
153
|
+
options?: { filePath?: string }
|
|
154
|
+
): SimilarityMatch[] {
|
|
155
|
+
if (query.trim().length === 0) return [];
|
|
156
|
+
|
|
157
|
+
// FTS5 MATCH requires escaped phrase quoting for queries with
|
|
158
|
+
// punctuation. Build a phrase query if the input has anything
|
|
159
|
+
// weirder than alphanum + spaces.
|
|
160
|
+
const ftsQuery = buildFtsQuery(query);
|
|
161
|
+
if (ftsQuery === null) return [];
|
|
162
|
+
|
|
163
|
+
type FtsRow = { bug_id: string; bm25: number };
|
|
164
|
+
let ftsRows: FtsRow[] = [];
|
|
165
|
+
try {
|
|
166
|
+
ftsRows = this.db
|
|
167
|
+
.prepare(
|
|
168
|
+
"SELECT bug_id, bm25(bug_memory_fts) AS bm25 FROM bug_memory_fts WHERE bug_memory_fts MATCH ? ORDER BY bm25"
|
|
169
|
+
)
|
|
170
|
+
.all(ftsQuery) as unknown as FtsRow[];
|
|
171
|
+
} catch {
|
|
172
|
+
// FTS query parse error — fall back silently to no matches.
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const queryTokens = tokenize(query);
|
|
177
|
+
const results: SimilarityMatch[] = [];
|
|
178
|
+
|
|
179
|
+
for (const row of ftsRows) {
|
|
180
|
+
const entry = this.lookup(row.bug_id);
|
|
181
|
+
if (!entry) continue;
|
|
182
|
+
|
|
183
|
+
// bm25 is negative; smaller magnitude == better match.
|
|
184
|
+
const ftsScore = 1 / (1 + Math.abs(row.bm25));
|
|
185
|
+
const matchReasons: string[] = ["fts"];
|
|
186
|
+
|
|
187
|
+
// Exact substring boost (matches v1 behavior).
|
|
188
|
+
let score = ftsScore;
|
|
189
|
+
if (entry.errorMessage.length > 0 && entry.errorMessage.includes(query)) {
|
|
190
|
+
score += 1.0;
|
|
191
|
+
matchReasons.unshift("exact_error_match");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const fileMatch = options?.filePath ? entry.filePath === options.filePath : false;
|
|
195
|
+
const tagMatch = entry.tags.some((tag) => queryTokens.has(tag.toLowerCase()));
|
|
196
|
+
|
|
197
|
+
// Same false-positive guard as v1: when the score is borderline
|
|
198
|
+
// (<= 0.3), only keep matches that also satisfy file-path or
|
|
199
|
+
// tag-overlap.
|
|
200
|
+
if (score <= 0.3 && !fileMatch && !tagMatch) continue;
|
|
201
|
+
|
|
202
|
+
if (fileMatch) {
|
|
203
|
+
score += 0.2;
|
|
204
|
+
matchReasons.push("file_path");
|
|
205
|
+
}
|
|
206
|
+
if (tagMatch) matchReasons.push("tags");
|
|
207
|
+
|
|
208
|
+
if (score > 0.3) {
|
|
209
|
+
results.push({ entry, score, matchReasons });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return results.sort((a, b) => b.score - a.score);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
private hydrate(row: BugRow): BugEntry {
|
|
219
|
+
const tags = this.db
|
|
220
|
+
.prepare("SELECT tag FROM bug_tags WHERE bug_id = ? ORDER BY tag")
|
|
221
|
+
.all(row.id)
|
|
222
|
+
.map((r) => (r as { tag: string }).tag);
|
|
223
|
+
const relatedBugIds = this.db
|
|
224
|
+
.prepare(
|
|
225
|
+
"SELECT related_bug_id FROM bug_related WHERE bug_id = ? ORDER BY related_bug_id"
|
|
226
|
+
)
|
|
227
|
+
.all(row.id)
|
|
228
|
+
.map((r) => (r as { related_bug_id: string }).related_bug_id);
|
|
229
|
+
return {
|
|
230
|
+
id: row.id,
|
|
231
|
+
createdAt: row.created_at,
|
|
232
|
+
lastSeenAt: row.last_seen_at,
|
|
233
|
+
errorMessage: row.error_message,
|
|
234
|
+
filePath: row.file_path,
|
|
235
|
+
lineNumber: row.line_number ?? undefined,
|
|
236
|
+
rootCause: row.root_cause,
|
|
237
|
+
fixDescription: row.fix_description,
|
|
238
|
+
tags,
|
|
239
|
+
occurrenceCount: row.occurrence_count,
|
|
240
|
+
relatedBugIds,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Convert the entire repo to the legacy BugMemory snapshot shape. Used
|
|
245
|
+
// by callers (dashboard, status) that still expect `{ entries, nextId }`.
|
|
246
|
+
snapshot(): BugMemory {
|
|
247
|
+
return {
|
|
248
|
+
entries: this.listAll(),
|
|
249
|
+
// nextId was only used by the in-memory generator; new ids come
|
|
250
|
+
// from randomUUID, so any value > current count is safe.
|
|
251
|
+
nextId: this.count() + 1,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Build an FTS5 query string from arbitrary user input. FTS5's grammar
|
|
257
|
+
// treats colons, parens, quotes, etc. as operators — we phrase-quote the
|
|
258
|
+
// whole query to avoid syntax errors. Returns null for inputs that have
|
|
259
|
+
// no searchable tokens.
|
|
260
|
+
function buildFtsQuery(raw: string): string | null {
|
|
261
|
+
const trimmed = raw.trim();
|
|
262
|
+
if (trimmed.length === 0) return null;
|
|
263
|
+
// Drop characters that can't appear inside FTS5 phrase quotes.
|
|
264
|
+
const safe = trimmed.replace(/"/g, " ").trim();
|
|
265
|
+
if (safe.length === 0) return null;
|
|
266
|
+
// Quote so punctuation/colons/parens don't become operators.
|
|
267
|
+
return `"${safe}"`;
|
|
268
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Per-device hit/miss counters for the file index. Replaces the legacy
|
|
2
|
+
// `.mink-state-counters.json` file (read by the dashboard and by
|
|
3
|
+
// `mink status`) with a SQLite-backed table that's queryable per device
|
|
4
|
+
// or aggregated across all devices in a single SQL statement.
|
|
5
|
+
|
|
6
|
+
import type { DbDriver } from "../storage/driver";
|
|
7
|
+
import { openProjectDb } from "../storage/db";
|
|
8
|
+
import { getOrCreateDeviceId } from "../core/device";
|
|
9
|
+
|
|
10
|
+
const INCREMENT_HIT = `
|
|
11
|
+
INSERT INTO counters (device_id, file_index_hits, file_index_misses)
|
|
12
|
+
VALUES (?, 1, 0)
|
|
13
|
+
ON CONFLICT(device_id) DO UPDATE SET
|
|
14
|
+
file_index_hits = counters.file_index_hits + 1
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const INCREMENT_MISS = `
|
|
18
|
+
INSERT INTO counters (device_id, file_index_hits, file_index_misses)
|
|
19
|
+
VALUES (?, 0, 1)
|
|
20
|
+
ON CONFLICT(device_id) DO UPDATE SET
|
|
21
|
+
file_index_misses = counters.file_index_misses + 1
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
export class CountersRepo {
|
|
25
|
+
constructor(private readonly db: DbDriver) {}
|
|
26
|
+
|
|
27
|
+
static for(cwd: string): CountersRepo {
|
|
28
|
+
return new CountersRepo(openProjectDb(cwd));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
incrementHit(deviceId: string = getOrCreateDeviceId()): void {
|
|
32
|
+
this.db.prepare(INCREMENT_HIT).run(deviceId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
incrementMiss(deviceId: string = getOrCreateDeviceId()): void {
|
|
36
|
+
this.db.prepare(INCREMENT_MISS).run(deviceId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Returns this device's hit + miss counts (zero for either if no row
|
|
40
|
+
// exists yet). The dashboard and `mink status` show per-device totals,
|
|
41
|
+
// but callers that want a project-wide view use totals().
|
|
42
|
+
forDevice(deviceId: string = getOrCreateDeviceId()): { hits: number; misses: number } {
|
|
43
|
+
const row = this.db
|
|
44
|
+
.prepare(
|
|
45
|
+
"SELECT file_index_hits, file_index_misses FROM counters WHERE device_id = ?"
|
|
46
|
+
)
|
|
47
|
+
.get(deviceId);
|
|
48
|
+
if (!row) return { hits: 0, misses: 0 };
|
|
49
|
+
return {
|
|
50
|
+
hits: Number((row as { file_index_hits: number }).file_index_hits),
|
|
51
|
+
misses: Number((row as { file_index_misses: number }).file_index_misses),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
totals(): { hits: number; misses: number } {
|
|
56
|
+
const row = this.db
|
|
57
|
+
.prepare(
|
|
58
|
+
"SELECT COALESCE(SUM(file_index_hits), 0) AS h, COALESCE(SUM(file_index_misses), 0) AS m FROM counters"
|
|
59
|
+
)
|
|
60
|
+
.get();
|
|
61
|
+
if (!row) return { hits: 0, misses: 0 };
|
|
62
|
+
return {
|
|
63
|
+
hits: Number((row as { h: number }).h),
|
|
64
|
+
misses: Number((row as { m: number }).m),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
perDevice(): Record<string, { hits: number; misses: number }> {
|
|
69
|
+
const rows = this.db
|
|
70
|
+
.prepare(
|
|
71
|
+
"SELECT device_id, file_index_hits, file_index_misses FROM counters"
|
|
72
|
+
)
|
|
73
|
+
.all();
|
|
74
|
+
const out: Record<string, { hits: number; misses: number }> = {};
|
|
75
|
+
for (const r of rows) {
|
|
76
|
+
const row = r as {
|
|
77
|
+
device_id: string;
|
|
78
|
+
file_index_hits: number;
|
|
79
|
+
file_index_misses: number;
|
|
80
|
+
};
|
|
81
|
+
out[row.device_id] = {
|
|
82
|
+
hits: Number(row.file_index_hits),
|
|
83
|
+
misses: Number(row.file_index_misses),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
}
|