@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.
- package/dashboard/out/404.html +1 -1
- package/dashboard/out/action-log.html +1 -1
- package/dashboard/out/action-log.txt +1 -1
- package/dashboard/out/activity.html +1 -1
- package/dashboard/out/activity.txt +1 -1
- package/dashboard/out/bugs.html +1 -1
- package/dashboard/out/bugs.txt +1 -1
- package/dashboard/out/capture.html +1 -1
- package/dashboard/out/capture.txt +1 -1
- package/dashboard/out/config.html +1 -1
- package/dashboard/out/config.txt +1 -1
- package/dashboard/out/daemon.html +1 -1
- package/dashboard/out/daemon.txt +1 -1
- package/dashboard/out/design.html +1 -1
- package/dashboard/out/design.txt +1 -1
- package/dashboard/out/discord.html +1 -1
- package/dashboard/out/discord.txt +1 -1
- package/dashboard/out/file-index.html +1 -1
- package/dashboard/out/file-index.txt +1 -1
- package/dashboard/out/index.html +1 -1
- package/dashboard/out/index.txt +1 -1
- package/dashboard/out/insights.html +1 -1
- package/dashboard/out/insights.txt +1 -1
- package/dashboard/out/learning.html +1 -1
- package/dashboard/out/learning.txt +1 -1
- package/dashboard/out/overview.html +1 -1
- package/dashboard/out/overview.txt +1 -1
- package/dashboard/out/scheduler.html +1 -1
- package/dashboard/out/scheduler.txt +1 -1
- package/dashboard/out/sync.html +1 -1
- package/dashboard/out/sync.txt +1 -1
- package/dashboard/out/tokens.html +1 -1
- package/dashboard/out/tokens.txt +1 -1
- package/dashboard/out/waste.html +1 -1
- package/dashboard/out/waste.txt +1 -1
- package/dashboard/out/wiki.html +1 -1
- package/dashboard/out/wiki.txt +1 -1
- package/dist/cli.bun.js +90615 -0
- package/dist/cli.js +27 -92464
- package/dist/cli.node.js +93945 -0
- package/package.json +13 -2
- package/scripts/build.mjs +50 -0
- package/scripts/cli-shim.mjs +39 -0
- package/src/commands/bug-search.ts +2 -4
- package/src/commands/detect-waste.ts +24 -32
- package/src/commands/post-read.ts +10 -11
- package/src/commands/post-write.ts +13 -19
- package/src/commands/pre-read.ts +19 -24
- package/src/commands/scan.ts +103 -40
- package/src/commands/status.ts +45 -26
- package/src/core/bug-memory.ts +32 -34
- package/src/core/dashboard-api.ts +44 -22
- package/src/core/index-store.ts +23 -0
- package/src/core/paths.ts +7 -0
- package/src/core/scanner.ts +8 -4
- package/src/core/state-aggregator.ts +64 -7
- package/src/core/state-counters.ts +11 -31
- package/src/core/sync-merge-drivers.ts +164 -1
- package/src/core/sync.ts +9 -0
- package/src/core/token-ledger.ts +50 -4
- package/src/repositories/bug-memory-repo.ts +268 -0
- package/src/repositories/counters-repo.ts +88 -0
- package/src/repositories/file-index-repo.ts +238 -0
- package/src/repositories/token-ledger-repo.ts +412 -0
- package/src/storage/db.ts +121 -0
- package/src/storage/driver.bun.ts +99 -0
- package/src/storage/driver.node.ts +107 -0
- package/src/storage/driver.ts +76 -0
- package/src/storage/migrate-json.ts +415 -0
- package/src/storage/schema.ts +207 -0
- package/src/types/file-index.ts +9 -0
- /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 7bx94K8a7-O53mwi7UoEu}/_buildManifest.js +0 -0
- /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 7bx94K8a7-O53mwi7UoEu}/_ssgManifest.js +0 -0
package/src/commands/scan.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
|
+
import { createHash } from "crypto";
|
|
2
3
|
import { join, relative } from "path";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 ${
|
|
183
|
+
`[mink] indexed ${indexed} files in ${elapsed}ms (${parts.join(", ")})`
|
|
122
184
|
);
|
|
123
185
|
}
|
|
124
186
|
}
|
|
187
|
+
|
package/src/commands/status.ts
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "fs";
|
|
2
2
|
import {
|
|
3
3
|
sessionPath,
|
|
4
|
-
|
|
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 {
|
|
13
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
85
|
-
const misses = counters.fileIndexMisses
|
|
86
|
-
const
|
|
87
|
-
const ratio =
|
|
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: ${
|
|
90
|
-
console.log(` Last scan: ${
|
|
91
|
-
console.log(` Hit/miss ratio: ${ratio}${
|
|
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");
|
package/src/core/bug-memory.ts
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 {
|
package/src/core/index-store.ts
CHANGED
|
@@ -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
|
}
|
package/src/core/scanner.ts
CHANGED
|
@@ -21,7 +21,10 @@ export const DEFAULT_EXCLUDES: string[] = [
|
|
|
21
21
|
".mink",
|
|
22
22
|
];
|
|
23
23
|
|
|
24
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
116
|
+
maxFiles: number | undefined = NO_CAP
|
|
113
117
|
): ScannedFile[] {
|
|
114
118
|
return scanProjectWithStats(projectRoot, excludes, maxFiles).files;
|
|
115
119
|
}
|