@drewpayment/mink 0.11.0-beta.4 → 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.
Files changed (71) hide show
  1. package/dashboard/out/404.html +1 -1
  2. package/dashboard/out/action-log.html +1 -1
  3. package/dashboard/out/action-log.txt +1 -1
  4. package/dashboard/out/activity.html +1 -1
  5. package/dashboard/out/activity.txt +1 -1
  6. package/dashboard/out/bugs.html +1 -1
  7. package/dashboard/out/bugs.txt +1 -1
  8. package/dashboard/out/capture.html +1 -1
  9. package/dashboard/out/capture.txt +1 -1
  10. package/dashboard/out/config.html +1 -1
  11. package/dashboard/out/config.txt +1 -1
  12. package/dashboard/out/daemon.html +1 -1
  13. package/dashboard/out/daemon.txt +1 -1
  14. package/dashboard/out/design.html +1 -1
  15. package/dashboard/out/design.txt +1 -1
  16. package/dashboard/out/discord.html +1 -1
  17. package/dashboard/out/discord.txt +1 -1
  18. package/dashboard/out/file-index.html +1 -1
  19. package/dashboard/out/file-index.txt +1 -1
  20. package/dashboard/out/index.html +1 -1
  21. package/dashboard/out/index.txt +1 -1
  22. package/dashboard/out/insights.html +1 -1
  23. package/dashboard/out/insights.txt +1 -1
  24. package/dashboard/out/learning.html +1 -1
  25. package/dashboard/out/learning.txt +1 -1
  26. package/dashboard/out/overview.html +1 -1
  27. package/dashboard/out/overview.txt +1 -1
  28. package/dashboard/out/scheduler.html +1 -1
  29. package/dashboard/out/scheduler.txt +1 -1
  30. package/dashboard/out/sync.html +1 -1
  31. package/dashboard/out/sync.txt +1 -1
  32. package/dashboard/out/tokens.html +1 -1
  33. package/dashboard/out/tokens.txt +1 -1
  34. package/dashboard/out/waste.html +1 -1
  35. package/dashboard/out/waste.txt +1 -1
  36. package/dashboard/out/wiki.html +1 -1
  37. package/dashboard/out/wiki.txt +1 -1
  38. package/dist/cli.bun.js +90615 -0
  39. package/dist/{cli.js → cli.node.js} +2227 -758
  40. package/package.json +14 -4
  41. package/scripts/build.mjs +47 -0
  42. package/src/commands/bug-search.ts +2 -4
  43. package/src/commands/detect-waste.ts +24 -32
  44. package/src/commands/post-read.ts +10 -11
  45. package/src/commands/post-write.ts +13 -19
  46. package/src/commands/pre-read.ts +19 -24
  47. package/src/commands/scan.ts +103 -40
  48. package/src/commands/status.ts +45 -26
  49. package/src/core/bug-memory.ts +32 -34
  50. package/src/core/dashboard-api.ts +44 -22
  51. package/src/core/index-store.ts +23 -0
  52. package/src/core/paths.ts +7 -0
  53. package/src/core/scanner.ts +8 -4
  54. package/src/core/state-aggregator.ts +64 -7
  55. package/src/core/state-counters.ts +11 -31
  56. package/src/core/sync-merge-drivers.ts +164 -1
  57. package/src/core/sync.ts +9 -0
  58. package/src/core/token-ledger.ts +50 -4
  59. package/src/repositories/bug-memory-repo.ts +268 -0
  60. package/src/repositories/counters-repo.ts +88 -0
  61. package/src/repositories/file-index-repo.ts +238 -0
  62. package/src/repositories/token-ledger-repo.ts +412 -0
  63. package/src/storage/db.ts +121 -0
  64. package/src/storage/driver.bun.ts +99 -0
  65. package/src/storage/driver.node.ts +107 -0
  66. package/src/storage/driver.ts +76 -0
  67. package/src/storage/migrate-json.ts +415 -0
  68. package/src/storage/schema.ts +207 -0
  69. package/src/types/file-index.ts +9 -0
  70. /package/dashboard/out/_next/static/{fci7mSuW5y3ri6IlmLojm → 9ElzGFcXpcjLq-QSQslWY}/_buildManifest.js +0 -0
  71. /package/dashboard/out/_next/static/{fci7mSuW5y3ri6IlmLojm → 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;
@@ -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
- // Backward compat: callers that pass `(projectDir)` or
167
- // `(projectDir, threshold)` still work and write to the legacy path. New
168
- // callers pass `(projectDir, deviceId, threshold?)` to write into the
169
- // per-device shard at projectDir/state/<deviceId>/...
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
+ }