@drewpayment/mink 0.11.0 → 0.12.0-beta.2

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 (73) 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 +27 -92464
  40. package/dist/cli.node.js +93945 -0
  41. package/package.json +13 -2
  42. package/scripts/build.mjs +50 -0
  43. package/scripts/cli-shim.mjs +39 -0
  44. package/src/commands/bug-search.ts +2 -4
  45. package/src/commands/detect-waste.ts +24 -32
  46. package/src/commands/post-read.ts +10 -11
  47. package/src/commands/post-write.ts +13 -19
  48. package/src/commands/pre-read.ts +19 -24
  49. package/src/commands/scan.ts +103 -40
  50. package/src/commands/status.ts +45 -26
  51. package/src/core/bug-memory.ts +32 -34
  52. package/src/core/dashboard-api.ts +44 -22
  53. package/src/core/index-store.ts +23 -0
  54. package/src/core/paths.ts +7 -0
  55. package/src/core/scanner.ts +8 -4
  56. package/src/core/state-aggregator.ts +64 -7
  57. package/src/core/state-counters.ts +11 -31
  58. package/src/core/sync-merge-drivers.ts +164 -1
  59. package/src/core/sync.ts +9 -0
  60. package/src/core/token-ledger.ts +50 -4
  61. package/src/repositories/bug-memory-repo.ts +268 -0
  62. package/src/repositories/counters-repo.ts +88 -0
  63. package/src/repositories/file-index-repo.ts +238 -0
  64. package/src/repositories/token-ledger-repo.ts +412 -0
  65. package/src/storage/db.ts +121 -0
  66. package/src/storage/driver.bun.ts +99 -0
  67. package/src/storage/driver.node.ts +107 -0
  68. package/src/storage/driver.ts +76 -0
  69. package/src/storage/migrate-json.ts +415 -0
  70. package/src/storage/schema.ts +207 -0
  71. package/src/types/file-index.ts +9 -0
  72. /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 7bx94K8a7-O53mwi7UoEu}/_buildManifest.js +0 -0
  73. /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 7bx94K8a7-O53mwi7UoEu}/_ssgManifest.js +0 -0
@@ -75,14 +75,29 @@ function addLifetime(target: LifetimeCounters, source: LifetimeCounters): void {
75
75
  }
76
76
 
77
77
  export function aggregateTokenLedgerAt(projDir: string): TokenLedger {
78
+ // Phase 4: token_ledger lives in mink.db. The legacy JSON aggregation
79
+ // is preserved as a fallback for unit tests and pre-migration projects.
80
+ const dbPath = join(projDir, "mink.db");
81
+ if (existsSync(dbPath)) {
82
+ try {
83
+ const { openDriver } = require("../storage/driver") as typeof import("../storage/driver");
84
+ const { applySchema } = require("../storage/schema") as typeof import("../storage/schema");
85
+ const { TokenLedgerRepo } = require("../repositories/token-ledger-repo") as typeof import("../repositories/token-ledger-repo");
86
+ const db = openDriver(dbPath);
87
+ try {
88
+ applySchema(db);
89
+ return new TokenLedgerRepo(db).snapshot();
90
+ } finally {
91
+ db.close();
92
+ }
93
+ } catch {
94
+ // Fall through to JSON aggregation if the DB read fails.
95
+ }
96
+ }
97
+
78
98
  const merged = createEmptyLedger();
79
99
  const seenSessions = new Set<string>();
80
100
 
81
- // Sum lifetime counters from every source (each shard + legacy). Lifetime
82
- // persists across archive cycles, so deriving from active sessions alone
83
- // would lose archived totals. Migration atomically moves legacy → shard
84
- // (`git mv`), so a session never lives in both simultaneously and lifetime
85
- // counters do not double-count in production.
86
101
  const sources = [
87
102
  ...listDeviceShardsAt(projDir).map((id) =>
88
103
  shardPath(projDir, id, "token-ledger.json")
@@ -90,8 +105,6 @@ export function aggregateTokenLedgerAt(projDir: string): TokenLedger {
90
105
  join(projDir, "token-ledger.json"),
91
106
  ];
92
107
 
93
- // Track waste-flags across sources, deduped by (pattern, detectedAt) so
94
- // each device's flags remain visible without spamming duplicates.
95
108
  const seenFlagKeys = new Set<string>();
96
109
  const wasteFlags: NonNullable<TokenLedger["wasteFlags"]> = [];
97
110
 
@@ -131,6 +144,25 @@ export function aggregateTokenLedger(cwd: string): TokenLedger {
131
144
  export function aggregateTokenLedgerArchiveAt(
132
145
  projDir: string
133
146
  ): LedgerSession[] {
147
+ // Phase 4: archive is `archived = 1` in ledger_sessions.
148
+ const dbPath = join(projDir, "mink.db");
149
+ if (existsSync(dbPath)) {
150
+ try {
151
+ const { openDriver } = require("../storage/driver") as typeof import("../storage/driver");
152
+ const { applySchema } = require("../storage/schema") as typeof import("../storage/schema");
153
+ const { TokenLedgerRepo } = require("../repositories/token-ledger-repo") as typeof import("../repositories/token-ledger-repo");
154
+ const db = openDriver(dbPath);
155
+ try {
156
+ applySchema(db);
157
+ return new TokenLedgerRepo(db).archivedSessions();
158
+ } finally {
159
+ db.close();
160
+ }
161
+ } catch {
162
+ // Fall through to JSON aggregation.
163
+ }
164
+ }
165
+
134
166
  const seen = new Set<string>();
135
167
  const archived: LedgerSession[] = [];
136
168
 
@@ -161,6 +193,31 @@ export function aggregateTokenLedgerArchive(cwd: string): LedgerSession[] {
161
193
  // ── Bug memory ─────────────────────────────────────────────────────────────
162
194
 
163
195
  export function aggregateBugMemoryAt(projDir: string): BugMemory {
196
+ // Phase 3 of the SQLite migration: bug_memory lives in mink.db. The
197
+ // legacy JSON aggregation below is preserved as a fallback for tests /
198
+ // pre-migration projects, but new call sites should read from
199
+ // BugMemoryRepo directly.
200
+ const dbPath = join(projDir, "mink.db");
201
+ if (existsSync(dbPath)) {
202
+ try {
203
+ // Use a fresh handle so we don't disturb the per-process cache used
204
+ // by hook commands. Lazy-require to keep state-aggregator free of
205
+ // a hard storage-layer dependency for tests that mock paths.
206
+ const { openDriver } = require("../storage/driver") as typeof import("../storage/driver");
207
+ const { applySchema } = require("../storage/schema") as typeof import("../storage/schema");
208
+ const { BugMemoryRepo } = require("../repositories/bug-memory-repo") as typeof import("../repositories/bug-memory-repo");
209
+ const db = openDriver(dbPath);
210
+ try {
211
+ applySchema(db); // tolerate older DBs missing newer tables
212
+ return new BugMemoryRepo(db).snapshot();
213
+ } finally {
214
+ db.close();
215
+ }
216
+ } catch {
217
+ // Fall through to JSON aggregation if the DB read fails.
218
+ }
219
+ }
220
+
164
221
  const byId = new Map<string, BugEntry>();
165
222
  let maxNextId = 1;
166
223
 
@@ -1,46 +1,26 @@
1
- import { atomicWriteJson, safeReadJson } from "./fs-utils";
2
- import { fileIndexCountersPath } from "./paths";
1
+ // Wrapper over the per-device counters table. The legacy implementation
2
+ // kept these in projects/<id>/.mink-state-counters.json; Phase 1's
3
+ // importer copies that file's contents into the `counters` table the
4
+ // first time the project DB opens, and the file is moved to
5
+ // legacy-backup/. Both APIs (totals and per-device) remain available so
6
+ // the dashboard and `mink status` keep their existing surface.
3
7
 
4
- // Per-device telemetry counters. Lives at projects/<id>/.mink-state-counters.json
5
- // and is gitignored so each device's counts never collide. Aggregated views
6
- // (dashboard, status) sum across devices via aggregateStateCounters().
8
+ import { CountersRepo } from "../repositories/counters-repo";
7
9
 
8
10
  export interface StateCounters {
9
11
  fileIndexHits: number;
10
12
  fileIndexMisses: number;
11
13
  }
12
14
 
13
- function emptyCounters(): StateCounters {
14
- return { fileIndexHits: 0, fileIndexMisses: 0 };
15
- }
16
-
17
- function isStateCounters(value: unknown): value is StateCounters {
18
- if (value === null || typeof value !== "object") return false;
19
- const obj = value as Record<string, unknown>;
20
- return (
21
- typeof obj.fileIndexHits === "number" &&
22
- typeof obj.fileIndexMisses === "number"
23
- );
24
- }
25
-
26
15
  export function loadCounters(cwd: string): StateCounters {
27
- const raw = safeReadJson(fileIndexCountersPath(cwd));
28
- if (raw !== null && isStateCounters(raw)) return raw;
29
- return emptyCounters();
30
- }
31
-
32
- export function saveCounters(cwd: string, counters: StateCounters): void {
33
- atomicWriteJson(fileIndexCountersPath(cwd), counters);
16
+ const t = CountersRepo.for(cwd).totals();
17
+ return { fileIndexHits: t.hits, fileIndexMisses: t.misses };
34
18
  }
35
19
 
36
20
  export function incrementFileIndexHit(cwd: string): void {
37
- const c = loadCounters(cwd);
38
- c.fileIndexHits++;
39
- saveCounters(cwd, c);
21
+ CountersRepo.for(cwd).incrementHit();
40
22
  }
41
23
 
42
24
  export function incrementFileIndexMiss(cwd: string): void {
43
- const c = loadCounters(cwd);
44
- c.fileIndexMisses++;
45
- saveCounters(cwd, c);
25
+ CountersRepo.for(cwd).incrementMiss();
46
26
  }
@@ -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;