@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,18 +1,14 @@
1
1
  import { existsSync, readFileSync, statSync } from "fs";
2
2
  import {
3
3
  sessionPath,
4
- fileIndexPath,
4
+ projectDbPath,
5
5
  configPath,
6
6
  learningMemoryPath,
7
- tokenLedgerPath,
8
- bugMemoryPath,
9
7
  actionLogPath,
10
8
  } from "../core/paths";
11
9
  import { safeReadJson } from "../core/fs-utils";
12
- import { isFileIndex } from "../core/index-store";
13
- import { loadLedger } from "../core/token-ledger";
14
- import { parseLearningMemory, totalEntryCount } from "../core/learning-memory";
15
- import { loadBugMemory } from "../core/bug-memory";
10
+ import { FileIndexRepo } from "../repositories/file-index-repo";
11
+ import { openProjectDb } from "../storage/db";
16
12
  import {
17
13
  aggregateTokenLedger,
18
14
  aggregateBugMemory,
@@ -20,6 +16,7 @@ import {
20
16
  } from "../core/state-aggregator";
21
17
  import { loadCounters } from "../core/state-counters";
22
18
  import { getDaemonStatus } from "../core/daemon";
19
+ import { totalEntryCount } from "../core/learning-memory";
23
20
 
24
21
  interface FileCheck {
25
22
  name: string;
@@ -45,18 +42,42 @@ function checkTextFile(name: string, filePath: string): FileCheck {
45
42
  }
46
43
  }
47
44
 
45
+ function checkDbFile(name: string, filePath: string): FileCheck {
46
+ if (!existsSync(filePath)) return { name, path: filePath, status: "missing" };
47
+ try {
48
+ const header = readFileSync(filePath).slice(0, 16).toString("utf-8");
49
+ if (!header.startsWith("SQLite format 3")) {
50
+ return { name, path: filePath, status: "corrupt" };
51
+ }
52
+ return { name, path: filePath, status: "ok" };
53
+ } catch {
54
+ return { name, path: filePath, status: "corrupt" };
55
+ }
56
+ }
57
+
48
58
  export function status(cwd: string): void {
49
59
  console.log("[mink] project status");
50
60
  console.log();
51
61
 
52
- // Section 1: State directory integrity
62
+ // Open the project DB up-front so the lazy JSON → SQLite migration
63
+ // runs before checkDbFile inspects mink.db. Otherwise the integrity
64
+ // section would always report "missing" on a project that has only
65
+ // legacy JSON state.
66
+ try {
67
+ openProjectDb(cwd);
68
+ } catch {
69
+ // Migration failures are non-fatal — report the DB as corrupt below.
70
+ }
71
+
72
+ // Section 1: State directory integrity. file-index, bug-memory, and
73
+ // token-ledger all live inside mink.db. The remaining JSON/MD files
74
+ // are intentionally separate — session is per-process, config is
75
+ // user-editable, learning-memory and action-log are human-readable.
53
76
  const checks: FileCheck[] = [
54
77
  checkJsonFile("session.json", sessionPath(cwd)),
55
- checkJsonFile("file-index.json", fileIndexPath(cwd), isFileIndex),
78
+ checkDbFile("mink.db", projectDbPath(cwd)),
56
79
  checkJsonFile("config.json", configPath(cwd)),
57
80
  checkTextFile("learning-memory.md", learningMemoryPath(cwd)),
58
- checkJsonFile("token-ledger.json", tokenLedgerPath(cwd)),
59
- checkJsonFile("bug-memory.json", bugMemoryPath(cwd)),
60
81
  checkTextFile("action-log.md", actionLogPath(cwd)),
61
82
  ];
62
83
 
@@ -73,24 +94,22 @@ export function status(cwd: string): void {
73
94
  }
74
95
  console.log();
75
96
 
76
- // Section 2: File index
97
+ // Section 2: File index — sourced from mink.db.
77
98
  try {
78
- const raw = safeReadJson(fileIndexPath(cwd));
79
- if (raw && isFileIndex(raw)) {
80
- const h = raw.header;
81
- // Hit/miss counters live in the per-device counter file, fall back to
82
- // legacy header counters for unmigrated repos.
99
+ const repo = FileIndexRepo.for(cwd);
100
+ const total = repo.totalFiles();
101
+ if (total === 0) {
102
+ console.log(" File index: not available");
103
+ } else {
83
104
  const counters = loadCounters(cwd);
84
- const hits = counters.fileIndexHits || h.lifetimeHits;
85
- const misses = counters.fileIndexMisses || h.lifetimeMisses;
86
- const total = hits + misses;
87
- const ratio = total > 0 ? ((hits / total) * 100).toFixed(1) : "N/A";
105
+ const hits = counters.fileIndexHits;
106
+ const misses = counters.fileIndexMisses;
107
+ const totalLookups = hits + misses;
108
+ const ratio = totalLookups > 0 ? ((hits / totalLookups) * 100).toFixed(1) : "N/A";
88
109
  console.log(" File index:");
89
- console.log(` Files: ${h.totalFiles}`);
90
- console.log(` Last scan: ${h.lastScanTimestamp || "never"}`);
91
- console.log(` Hit/miss ratio: ${ratio}${total > 0 ? "%" : ""} (${hits} hits, ${misses} misses)`);
92
- } else {
93
- console.log(" File index: not available");
110
+ console.log(` Files: ${total}`);
111
+ console.log(` Last scan: ${repo.getLastScanTimestamp() || "never"}`);
112
+ console.log(` Hit/miss ratio: ${ratio}${totalLookups > 0 ? "%" : ""} (${hits} hits, ${misses} misses)`);
94
113
  }
95
114
  } catch {
96
115
  console.log(" File index: error reading");
@@ -1,20 +1,17 @@
1
- import { safeReadJson, atomicWriteJson } from "./fs-utils";
1
+ // Wrapper over the SQLite bug-memory storage layer. The function-based
2
+ // API below stays compatible with existing unit tests (which build
3
+ // in-memory BugMemory objects), while the repo-aware paths route through
4
+ // BugMemoryRepo so 20k+ bug histories stay searchable in milliseconds.
5
+ //
6
+ // FTS5 is the search backbone — see BugMemoryRepo.searchBugs for the
7
+ // query semantics, score normalization, and false-positive guards.
8
+
2
9
  import type { BugEntry, BugMemory, SimilarityMatch } from "../types/bug-memory";
3
10
 
4
11
  export function createEmptyBugMemory(): BugMemory {
5
12
  return { entries: [], nextId: 1 };
6
13
  }
7
14
 
8
- export function loadBugMemory(path: string): BugMemory {
9
- const raw = safeReadJson(path);
10
- if (raw !== null && isBugMemory(raw)) return raw;
11
- return createEmptyBugMemory();
12
- }
13
-
14
- export function saveBugMemory(path: string, memory: BugMemory): void {
15
- atomicWriteJson(path, memory);
16
- }
17
-
18
15
  export function isBugMemory(value: unknown): value is BugMemory {
19
16
  if (value === null || typeof value !== "object") return false;
20
17
  const obj = value as Record<string, unknown>;
@@ -25,6 +22,8 @@ export function generateBugId(nextId: number): string {
25
22
  return `bug-${String(nextId).padStart(3, "0")}`;
26
23
  }
27
24
 
25
+ // ── In-memory operations (used by unit tests and the JSON migration importer) ──
26
+
28
27
  export function findDuplicate(
29
28
  memory: BugMemory,
30
29
  errorMessage: string,
@@ -42,9 +41,7 @@ export function addBugEntry(
42
41
  fields: Omit<BugEntry, "id" | "createdAt" | "lastSeenAt" | "occurrenceCount">
43
42
  ): BugMemory {
44
43
  const existing = findDuplicate(memory, fields.errorMessage, fields.filePath);
45
- if (existing) {
46
- return updateOccurrence(memory, existing.id);
47
- }
44
+ if (existing) return updateOccurrence(memory, existing.id);
48
45
 
49
46
  const now = new Date().toISOString();
50
47
  const entry: BugEntry = {
@@ -73,14 +70,11 @@ export function updateOccurrence(memory: BugMemory, id: string): BugMemory {
73
70
  };
74
71
  }
75
72
 
76
- // --- Similarity scoring ---
73
+ // ── Similarity scoring (in-memory) ────────────────────────────────────────
77
74
 
78
75
  function tokenize(text: string): Set<string> {
79
76
  return new Set(
80
- text
81
- .toLowerCase()
82
- .split(/\W+/)
83
- .filter((w) => w.length > 0)
77
+ text.toLowerCase().split(/\W+/).filter((w) => w.length > 0)
84
78
  );
85
79
  }
86
80
 
@@ -101,18 +95,12 @@ export function computeSimilarity(
101
95
  const matchReasons: string[] = [];
102
96
  let score = 0;
103
97
 
104
- // 1. Exact substring match on error message
105
- if (
106
- entry.errorMessage.length > 0 &&
107
- entry.errorMessage.includes(query)
108
- ) {
98
+ if (entry.errorMessage.length > 0 && entry.errorMessage.includes(query)) {
109
99
  score += 1.0;
110
100
  matchReasons.push("exact_error_match");
111
101
  }
112
102
 
113
- // 2. Word overlap (Jaccard) across searchable fields
114
103
  const queryTokens = tokenize(query);
115
-
116
104
  const fields: [string, string][] = [
117
105
  ["error_message", entry.errorMessage],
118
106
  ["root_cause", entry.rootCause],
@@ -142,6 +130,8 @@ function hasTagOverlap(entry: BugEntry, query: string): boolean {
142
130
  return entry.tags.some((tag) => queryTokens.has(tag.toLowerCase()));
143
131
  }
144
132
 
133
+ // Pure-memory search — kept for unit tests that don't open a DB. Production
134
+ // call sites use BugMemoryRepo.searchBugs which goes through FTS5.
145
135
  export function searchBugs(
146
136
  memory: BugMemory,
147
137
  query: string,
@@ -153,23 +143,16 @@ export function searchBugs(
153
143
 
154
144
  for (const entry of memory.entries) {
155
145
  const match = computeSimilarity(query, entry);
156
-
157
- // False positive guard: require file-path match OR tag overlap
158
146
  const fileMatch = hasFilePathMatch(entry, options?.filePath);
159
147
  const tagMatch = hasTagOverlap(entry, query);
160
148
  if (!fileMatch && !tagMatch && match.score <= 0.3) continue;
161
-
162
- // Boost same-file matches
163
149
  if (fileMatch) {
164
150
  match.score += 0.2;
165
151
  if (!match.matchReasons.includes("file_path")) {
166
152
  match.matchReasons.push("file_path");
167
153
  }
168
154
  }
169
-
170
- if (match.score > 0.3) {
171
- results.push(match);
172
- }
155
+ if (match.score > 0.3) results.push(match);
173
156
  }
174
157
 
175
158
  return results.sort((a, b) => b.score - a.score);
@@ -221,3 +204,18 @@ export function hasBugForFileInSession(
221
204
  new Date(e.createdAt).getTime() >= sessionStart
222
205
  );
223
206
  }
207
+
208
+ // ── JSON shim — kept so the migration importer + state-aggregator can still
209
+ // read legacy files during the rollout window. New writes go through the repo.
210
+
211
+ import { safeReadJson, atomicWriteJson } from "./fs-utils";
212
+
213
+ export function loadBugMemory(path: string): BugMemory {
214
+ const raw = safeReadJson(path);
215
+ if (raw !== null && isBugMemory(raw)) return raw;
216
+ return createEmptyBugMemory();
217
+ }
218
+
219
+ export function saveBugMemory(path: string, memory: BugMemory): void {
220
+ atomicWriteJson(path, memory);
221
+ }
@@ -1,9 +1,7 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import {
3
3
  projectDir,
4
- fileIndexPath,
5
- tokenLedgerPath,
6
- bugMemoryPath,
4
+ projectDbPath,
7
5
  actionLogPath,
8
6
  learningMemoryPath,
9
7
  projectMetaPath,
@@ -13,7 +11,8 @@ import {
13
11
  designReportPath,
14
12
  } from "./paths";
15
13
  import { safeReadJson } from "./fs-utils";
16
- import { isFileIndex } from "./index-store";
14
+ import { FileIndexRepo } from "../repositories/file-index-repo";
15
+ import { CountersRepo } from "../repositories/counters-repo";
17
16
  import { loadLedger } from "./token-ledger";
18
17
  import { parseLearningMemory } from "./learning-memory";
19
18
  import { loadBugMemory } from "./bug-memory";
@@ -125,6 +124,18 @@ function checkTextFile(name: string, filePath: string): FileStatus {
125
124
  }
126
125
  }
127
126
 
127
+ function checkDbFile(name: string, filePath: string): FileStatus {
128
+ if (!existsSync(filePath)) return { name, status: "missing" };
129
+ try {
130
+ const header = readFileSync(filePath).slice(0, 16).toString("utf-8");
131
+ return header.startsWith("SQLite format 3")
132
+ ? { name, status: "ok" }
133
+ : { name, status: "corrupt" };
134
+ } catch {
135
+ return { name, status: "corrupt" };
136
+ }
137
+ }
138
+
128
139
  // ── Panel Loaders ──────────────────────────────────────────────────────────
129
140
 
130
141
  export function loadOverview(cwd: string): OverviewPayload {
@@ -163,14 +174,13 @@ export function loadOverview(cwd: string): OverviewPayload {
163
174
  estimatedSavings: ledger.lifetime.totalEstimatedSavings,
164
175
  };
165
176
 
166
- // State file health
177
+ // State file health. mink.db replaced file-index.json in Phase 2; the
178
+ // other JSON checks remain until Phases 3 (bug-memory) and 4 (ledger).
167
179
  const stateFiles: FileStatus[] = [
168
180
  checkJsonFile("session.json", sessionPath(cwd)),
169
- checkJsonFile("file-index.json", fileIndexPath(cwd), isFileIndex),
181
+ checkDbFile("mink.db", projectDbPath(cwd)),
170
182
  checkJsonFile("config.json", configPath(cwd)),
171
183
  checkTextFile("learning-memory.md", learningMemoryPath(cwd)),
172
- checkJsonFile("token-ledger.json", tokenLedgerPath(cwd)),
173
- checkJsonFile("bug-memory.json", bugMemoryPath(cwd)),
174
184
  checkTextFile("action-log.md", actionLogPath(cwd)),
175
185
  checkJsonFile("scheduler-manifest.json", schedulerManifestPath(cwd)),
176
186
  ];
@@ -188,21 +198,33 @@ export function loadTokenLedgerPanel(cwd: string): TokenLedgerPayload {
188
198
  }
189
199
 
190
200
  export function loadFileIndexPanel(cwd: string): FileIndexPayload {
191
- const raw = safeReadJson(fileIndexPath(cwd));
192
- if (raw && isFileIndex(raw)) {
193
- const index = raw as FileIndex;
194
- const entries: FileIndexEntry[] = Object.values(index.entries);
195
- return { header: index.header, entries };
201
+ // file_index + per-device counters now live in mink.db; we synthesize
202
+ // the FileIndexPayload shape the dashboard expects so the frontend
203
+ // doesn't need to change in this phase.
204
+ try {
205
+ const repo = FileIndexRepo.for(cwd);
206
+ const entries: FileIndexEntry[] = repo.listAll();
207
+ const totals = CountersRepo.for(cwd).totals();
208
+ return {
209
+ header: {
210
+ lastScanTimestamp: repo.getLastScanTimestamp(),
211
+ totalFiles: repo.totalFiles(),
212
+ lifetimeHits: totals.hits,
213
+ lifetimeMisses: totals.misses,
214
+ },
215
+ entries,
216
+ };
217
+ } catch {
218
+ return {
219
+ header: {
220
+ lastScanTimestamp: "",
221
+ totalFiles: 0,
222
+ lifetimeHits: 0,
223
+ lifetimeMisses: 0,
224
+ },
225
+ entries: [],
226
+ };
196
227
  }
197
- return {
198
- header: {
199
- lastScanTimestamp: "",
200
- totalFiles: 0,
201
- lifetimeHits: 0,
202
- lifetimeMisses: 0,
203
- },
204
- entries: [],
205
- };
206
228
  }
207
229
 
208
230
  export function loadSchedulerPanel(cwd: string): SchedulerPayload {
@@ -1,6 +1,19 @@
1
+ // Wrapper over the file-index storage layer. Hooks and `mink scan` write
2
+ // through to the SQLite repository at `src/repositories/file-index-repo.ts`;
3
+ // the in-memory `FileIndex` helpers below stay around so unit tests can
4
+ // build fixtures without spinning up a DB. The on-disk file-index.json is
5
+ // no longer the source of truth — Phase 1's migrator moves any existing
6
+ // JSON into the DB on first open.
7
+ //
8
+ // Callers that need lookup-only access should prefer the IndexLookup
9
+ // interface (see src/types/file-index.ts) — it's the minimal surface the
10
+ // hook hot path depends on and is satisfied by both FileIndexRepo and the
11
+ // in-memory adapter below.
12
+
1
13
  import type {
2
14
  FileIndex,
3
15
  FileIndexEntry,
16
+ IndexLookup,
4
17
  StalenessReport,
5
18
  } from "../types/file-index";
6
19
 
@@ -70,3 +83,13 @@ export function checkStaleness(
70
83
  isStale: missingFromIndex.length > 0 || orphanedEntries.length > 0,
71
84
  };
72
85
  }
86
+
87
+ // Adapter: lets analyzers that have an in-memory FileIndex (typically
88
+ // unit tests) pass it to functions expecting the IndexLookup contract
89
+ // without duplicating the lookup logic.
90
+ export function indexAsLookup(index: FileIndex | null): IndexLookup | null {
91
+ if (index === null) return null;
92
+ return {
93
+ lookupEntry: (filePath: string) => lookupEntry(index, filePath),
94
+ };
95
+ }
package/src/core/paths.ts CHANGED
@@ -68,6 +68,13 @@ export function bugMemoryPath(cwd: string): string {
68
68
  return join(projectDir(cwd), "bug-memory.json");
69
69
  }
70
70
 
71
+ // SQLite-backed state introduced in spec 17 (file index + bug memory +
72
+ // token ledger). The legacy JSON path helpers above remain valid for
73
+ // backup / rollback during the migration window.
74
+ export function projectDbPath(cwd: string): string {
75
+ return join(projectDir(cwd), "mink.db");
76
+ }
77
+
71
78
  export function actionLogPath(cwd: string): string {
72
79
  return join(projectDir(cwd), "action-log.md");
73
80
  }
@@ -21,7 +21,10 @@ export const DEFAULT_EXCLUDES: string[] = [
21
21
  ".mink",
22
22
  ];
23
23
 
24
- const DEFAULT_MAX_FILES = 500;
24
+ // Phase 5 of the SQLite migration drops the legacy default cap; callers
25
+ // that want one set `maxFiles` explicitly. SQLite's per-row write cost is
26
+ // flat in index size, so capping by default no longer pays off.
27
+ const NO_CAP = Number.POSITIVE_INFINITY;
25
28
 
26
29
  function matchesPattern(name: string, pattern: string): boolean {
27
30
  if (pattern.includes("*")) {
@@ -96,20 +99,21 @@ export interface ScanStats {
96
99
  export function scanProjectWithStats(
97
100
  projectRoot: string,
98
101
  excludes: string[],
99
- maxFiles: number = DEFAULT_MAX_FILES
102
+ maxFiles: number | undefined = NO_CAP
100
103
  ): ScanStats {
101
104
  const results: ScannedFile[] = [];
102
105
  walkDirectory(projectRoot, projectRoot, excludes, results);
103
106
  results.sort((a, b) => b.mtimeMs - a.mtimeMs);
104
107
  const totalScanned = results.length;
105
- const files = results.slice(0, maxFiles);
108
+ const cap = maxFiles ?? NO_CAP;
109
+ const files = Number.isFinite(cap) ? results.slice(0, cap) : results;
106
110
  return { files, totalScanned, truncated: totalScanned - files.length };
107
111
  }
108
112
 
109
113
  export function scanProject(
110
114
  projectRoot: string,
111
115
  excludes: string[],
112
- maxFiles: number = DEFAULT_MAX_FILES
116
+ maxFiles: number | undefined = NO_CAP
113
117
  ): ScannedFile[] {
114
118
  return scanProjectWithStats(projectRoot, excludes, maxFiles).files;
115
119
  }
@@ -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
  }