@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
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "fs";
2
+ import { createHash } from "crypto";
2
3
  import { join, relative } from "path";
3
- import { fileIndexPath, configPath } from "../core/paths";
4
- import { atomicWriteJson, safeReadJson } from "../core/fs-utils";
4
+ import { configPath } from "../core/paths";
5
5
  import {
6
6
  scanProject,
7
7
  scanProjectWithStats,
@@ -10,45 +10,39 @@ import {
10
10
  } from "../core/scanner";
11
11
  import { extractDescription } from "../core/description";
12
12
  import { estimateTokens } from "../core/token-estimate";
13
- import {
14
- createEmptyIndex,
15
- isFileIndex,
16
- upsertEntry,
17
- checkStaleness,
18
- } from "../core/index-store";
19
- import type { FileIndex, FileIndexEntry } from "../types/file-index";
13
+ import { FileIndexRepo } from "../repositories/file-index-repo";
14
+ import type { FileIndexEntry } from "../types/file-index";
20
15
 
21
16
  function configRelativePath(cfgPath: string, cwd: string): string {
22
17
  const rel = relative(cwd, cfgPath);
23
18
  return rel.startsWith("..") ? cfgPath : rel;
24
19
  }
25
20
 
26
- function loadExistingIndex(indexPath: string): FileIndex {
27
- const raw = safeReadJson(indexPath);
28
- if (isFileIndex(raw)) return raw;
29
- if (raw !== null) {
30
- console.error("[mink] file-index.json is corrupt — starting fresh");
31
- }
32
- return createEmptyIndex();
21
+ // Truncated SHA-1 16 hex chars is plenty to distinguish content
22
+ // versions of the same file. Cheaper than cryptographic strength;
23
+ // we only use it for change detection, never as a security boundary.
24
+ function contentHashOf(content: string): string {
25
+ return createHash("sha1").update(content).digest("hex").slice(0, 16);
33
26
  }
34
27
 
35
28
  export function scan(cwd: string, options: { check: boolean }): void {
36
- const idxPath = fileIndexPath(cwd);
37
29
  const cfgPath = configPath(cwd);
38
30
  const config = loadConfig(cfgPath);
39
31
  const excludes = getExcludes(config);
40
- const maxFiles = config.maxFiles ?? 500;
32
+ // No default cap as of Phase 5 — per-row write cost is flat in SQLite.
33
+ // Users who still want a cap set `maxFiles` in config.json.
34
+ const maxFiles = config.maxFiles;
35
+ const repo = FileIndexRepo.for(cwd);
41
36
 
42
37
  if (options.check) {
43
- const existing = safeReadJson(idxPath);
44
- if (!isFileIndex(existing)) {
38
+ if (repo.totalFiles() === 0) {
45
39
  console.error("[mink] no index found — run mink scan first");
46
40
  process.exit(1);
47
41
  }
48
42
 
49
43
  const scanned = scanProject(cwd, excludes, maxFiles);
50
44
  const scannedPaths = scanned.map((f) => f.relativePath);
51
- const report = checkStaleness(existing, scannedPaths);
45
+ const report = repo.checkStaleness(scannedPaths);
52
46
 
53
47
  if (!report.isStale) {
54
48
  console.log("[mink] index is up to date");
@@ -70,45 +64,103 @@ export function scan(cwd: string, options: { check: boolean }): void {
70
64
  process.exit(1);
71
65
  }
72
66
 
73
- // Full scan
67
+ // Incremental scan — the actual 20k-file win.
68
+ // 1. Walk the tree (cheap; just stat + readdir).
69
+ // 2. Repo.staleSet(scanned) returns only paths whose mtime differs
70
+ // from what's stored (or never indexed at all). Everything else
71
+ // gets skipped — no readFileSync, no description extract, no
72
+ // token estimate.
73
+ // 3. For stale paths, read content + compute a content hash. If the
74
+ // stored content_hash matches, the file was just touched
75
+ // without an edit — skip the description/tokens re-extract and
76
+ // only refresh mtime/last_indexed. Otherwise do the full
77
+ // re-extract.
78
+ // 4. Bulk upsert in a single transaction, then prune orphans.
74
79
  const start = Date.now();
75
- const index = loadExistingIndex(idxPath);
76
80
 
77
81
  const stats = scanProjectWithStats(cwd, excludes, maxFiles);
78
82
  const scanned = stats.files;
79
83
 
80
- // Build new entries, preserving lifetime counters
81
- const newIndex = createEmptyIndex();
82
- newIndex.header.lifetimeHits = index.header.lifetimeHits;
83
- newIndex.header.lifetimeMisses = index.header.lifetimeMisses;
84
+ const stalePaths = new Set(repo.staleSet(scanned));
85
+ const batch: Array<{
86
+ entry: FileIndexEntry;
87
+ opts: { mtimeMs: number; contentHash: string | null; sizeBytes: number };
88
+ }> = [];
89
+ let touchOnlyCount = 0;
90
+ let extractedCount = 0;
84
91
 
85
92
  for (const file of scanned) {
93
+ if (!stalePaths.has(file.relativePath)) {
94
+ // mtime matches what we have — nothing to do for this file.
95
+ continue;
96
+ }
97
+
86
98
  const fullPath = join(cwd, file.relativePath);
87
99
  let content: string;
88
100
  try {
89
101
  content = readFileSync(fullPath, "utf-8");
90
102
  } catch {
91
- continue; // Skip unreadable files
103
+ continue; // Skip unreadable files (permissions, race condition)
104
+ }
105
+
106
+ const hash = contentHashOf(content);
107
+ const existing = repo.lookupEntry(file.relativePath);
108
+ const existingHash = existing ? repo.contentHashFor(file.relativePath) : null;
109
+
110
+ if (existing && existingHash === hash) {
111
+ // Touched but unchanged — bump mtime/last_indexed only, keep the
112
+ // stored description + token estimate.
113
+ batch.push({
114
+ entry: {
115
+ filePath: file.relativePath,
116
+ description: existing.description,
117
+ estimatedTokens: existing.estimatedTokens,
118
+ lastModified: new Date(file.mtimeMs).toISOString(),
119
+ lastIndexed: new Date().toISOString(),
120
+ },
121
+ opts: {
122
+ mtimeMs: Math.floor(file.mtimeMs),
123
+ contentHash: hash,
124
+ sizeBytes: content.length,
125
+ },
126
+ });
127
+ touchOnlyCount++;
128
+ continue;
92
129
  }
93
130
 
94
- const entry: FileIndexEntry = {
95
- filePath: file.relativePath,
96
- description: extractDescription(file.relativePath, content),
97
- estimatedTokens: estimateTokens(content, file.relativePath),
98
- lastModified: new Date(file.mtimeMs).toISOString(),
99
- lastIndexed: new Date().toISOString(),
100
- };
101
- upsertEntry(newIndex, entry);
131
+ // Full re-extract.
132
+ batch.push({
133
+ entry: {
134
+ filePath: file.relativePath,
135
+ description: extractDescription(file.relativePath, content),
136
+ estimatedTokens: estimateTokens(content, file.relativePath),
137
+ lastModified: new Date(file.mtimeMs).toISOString(),
138
+ lastIndexed: new Date().toISOString(),
139
+ },
140
+ opts: {
141
+ mtimeMs: Math.floor(file.mtimeMs),
142
+ contentHash: hash,
143
+ sizeBytes: content.length,
144
+ },
145
+ });
146
+ extractedCount++;
102
147
  }
103
148
 
104
- newIndex.header.lastScanTimestamp = new Date().toISOString();
149
+ // Single transaction — ~50x faster than per-file commits at 20k files.
150
+ if (batch.length > 0) repo.upsertMany(batch);
151
+
152
+ // Prune orphans: every entry whose file is no longer on disk.
153
+ const removed = repo.retainOnly(scanned.map((f) => f.relativePath));
105
154
 
106
- atomicWriteJson(idxPath, newIndex);
155
+ repo.setLastScanTimestamp(new Date().toISOString());
107
156
 
108
157
  const elapsed = Date.now() - start;
158
+ const indexed = repo.totalFiles();
159
+ const skipped = scanned.length - stalePaths.size;
160
+
109
161
  if (stats.truncated > 0) {
110
162
  console.log(
111
- `[mink] scanned ${stats.totalScanned} files; indexed ${newIndex.header.totalFiles} most recent in ${elapsed}ms`
163
+ `[mink] scanned ${stats.totalScanned} files; indexed ${indexed} most recent in ${elapsed}ms`
112
164
  );
113
165
  console.log(
114
166
  ` ${stats.truncated} files past maxFiles=${maxFiles} were not indexed`
@@ -116,9 +168,20 @@ export function scan(cwd: string, options: { check: boolean }): void {
116
168
  console.log(
117
169
  ` raise the cap by setting "maxFiles" in ${configRelativePath(cfgPath, cwd)}`
118
170
  );
171
+ return;
172
+ }
173
+
174
+ if (skipped === scanned.length && extractedCount === 0 && touchOnlyCount === 0 && removed === 0) {
175
+ console.log(`[mink] indexed ${indexed} files in ${elapsed}ms (no changes)`);
119
176
  } else {
177
+ const parts: string[] = [];
178
+ if (extractedCount > 0) parts.push(`${extractedCount} re-indexed`);
179
+ if (touchOnlyCount > 0) parts.push(`${touchOnlyCount} touch-only`);
180
+ if (removed > 0) parts.push(`${removed} pruned`);
181
+ if (skipped > 0) parts.push(`${skipped} unchanged`);
120
182
  console.log(
121
- `[mink] indexed ${newIndex.header.totalFiles} files in ${elapsed}ms`
183
+ `[mink] indexed ${indexed} files in ${elapsed}ms (${parts.join(", ")})`
122
184
  );
123
185
  }
124
186
  }
187
+
@@ -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
  }