@drewpayment/mink 0.1.0

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 (72) hide show
  1. package/README.md +347 -0
  2. package/package.json +32 -0
  3. package/src/cli.ts +176 -0
  4. package/src/commands/bug-search.ts +32 -0
  5. package/src/commands/config.ts +109 -0
  6. package/src/commands/cron.ts +295 -0
  7. package/src/commands/daemon.ts +46 -0
  8. package/src/commands/dashboard.ts +21 -0
  9. package/src/commands/designqc.ts +160 -0
  10. package/src/commands/detect-waste.ts +81 -0
  11. package/src/commands/framework-advisor.ts +52 -0
  12. package/src/commands/init.ts +159 -0
  13. package/src/commands/post-read.ts +123 -0
  14. package/src/commands/post-write.ts +157 -0
  15. package/src/commands/pre-read.ts +109 -0
  16. package/src/commands/pre-write.ts +136 -0
  17. package/src/commands/reflect.ts +39 -0
  18. package/src/commands/restore.ts +31 -0
  19. package/src/commands/scan.ts +101 -0
  20. package/src/commands/session-start.ts +21 -0
  21. package/src/commands/session-stop.ts +115 -0
  22. package/src/commands/status.ts +152 -0
  23. package/src/commands/update.ts +121 -0
  24. package/src/core/action-log.ts +341 -0
  25. package/src/core/backup.ts +122 -0
  26. package/src/core/bug-memory.ts +223 -0
  27. package/src/core/cron-parser.ts +94 -0
  28. package/src/core/daemon.ts +152 -0
  29. package/src/core/dashboard-api.ts +280 -0
  30. package/src/core/dashboard-server.ts +580 -0
  31. package/src/core/description.ts +232 -0
  32. package/src/core/design-eval/capture.ts +269 -0
  33. package/src/core/design-eval/route-detect.ts +165 -0
  34. package/src/core/design-eval/server-detect.ts +91 -0
  35. package/src/core/framework-advisor/catalog.ts +360 -0
  36. package/src/core/framework-advisor/decision-tree.ts +287 -0
  37. package/src/core/framework-advisor/generate.ts +132 -0
  38. package/src/core/framework-advisor/migration-prompts.ts +502 -0
  39. package/src/core/framework-advisor/validate.ts +137 -0
  40. package/src/core/fs-utils.ts +30 -0
  41. package/src/core/global-config.ts +74 -0
  42. package/src/core/index-store.ts +72 -0
  43. package/src/core/learning-memory.ts +120 -0
  44. package/src/core/paths.ts +86 -0
  45. package/src/core/pattern-engine.ts +108 -0
  46. package/src/core/project-id.ts +19 -0
  47. package/src/core/project-registry.ts +64 -0
  48. package/src/core/reflection.ts +256 -0
  49. package/src/core/scanner.ts +99 -0
  50. package/src/core/scheduler.ts +352 -0
  51. package/src/core/seed.ts +239 -0
  52. package/src/core/session.ts +128 -0
  53. package/src/core/stdin.ts +13 -0
  54. package/src/core/task-registry.ts +202 -0
  55. package/src/core/token-estimate.ts +36 -0
  56. package/src/core/token-ledger.ts +185 -0
  57. package/src/core/waste-detection.ts +214 -0
  58. package/src/core/write-exclusions.ts +24 -0
  59. package/src/types/action-log.ts +20 -0
  60. package/src/types/backup.ts +6 -0
  61. package/src/types/bug-memory.ts +24 -0
  62. package/src/types/config.ts +59 -0
  63. package/src/types/dashboard.ts +104 -0
  64. package/src/types/design-eval.ts +64 -0
  65. package/src/types/file-index.ts +38 -0
  66. package/src/types/framework-advisor.ts +97 -0
  67. package/src/types/hook-input.ts +27 -0
  68. package/src/types/learning-memory.ts +36 -0
  69. package/src/types/scheduler.ts +82 -0
  70. package/src/types/session.ts +50 -0
  71. package/src/types/token-ledger.ts +43 -0
  72. package/src/types/waste-detection.ts +21 -0
@@ -0,0 +1,341 @@
1
+ import { readFileSync } from "fs";
2
+ import { safeAppendText, atomicWriteText } from "./fs-utils";
3
+ import type { SessionSummary } from "../types/session";
4
+ import type {
5
+ ActionLogEntry,
6
+ ConsolidationConfig,
7
+ ParsedSession,
8
+ } from "../types/action-log";
9
+
10
+ // ── Path Truncation ─────────────────────────────────────────────────────────
11
+
12
+ export function truncatePath(filePath: string, maxLen: number = 60): string {
13
+ if (filePath.length <= maxLen) return filePath;
14
+ return "..." + filePath.slice(-(maxLen - 3));
15
+ }
16
+
17
+ // ── Formatting (pure, no I/O) ───────────────────────────────────────────────
18
+
19
+ export function formatTime(isoTimestamp: string): string {
20
+ const d = new Date(isoTimestamp);
21
+ const hh = String(d.getUTCHours()).padStart(2, "0");
22
+ const mm = String(d.getUTCMinutes()).padStart(2, "0");
23
+ return `${hh}:${mm}`;
24
+ }
25
+
26
+ function escapeCell(text: string): string {
27
+ return text.replace(/\|/g, "\\|");
28
+ }
29
+
30
+ export function formatRow(entry: ActionLogEntry): string {
31
+ return `| ${entry.time} | ${entry.action} | ${escapeCell(entry.files)} | ${escapeCell(entry.outcome)} | ${entry.tokens} |\n`;
32
+ }
33
+
34
+ export function formatSessionHeader(isoTimestamp: string): string {
35
+ const d = new Date(isoTimestamp);
36
+ const date = d.toISOString().slice(0, 10);
37
+ const time = formatTime(isoTimestamp);
38
+ const startRow = formatRow({
39
+ time,
40
+ action: "Session start",
41
+ files: "\u2014",
42
+ outcome: "\u2014",
43
+ tokens: "\u2014",
44
+ });
45
+ return (
46
+ `\n### Session \u2014 ${date} ${time}\n\n` +
47
+ `| Time | Action | File(s) | Outcome | ~Tokens |\n` +
48
+ `| --- | --- | --- | --- | --- |\n` +
49
+ startRow
50
+ );
51
+ }
52
+
53
+ export function formatReadRow(
54
+ isoTimestamp: string,
55
+ filePath: string,
56
+ indexHit: boolean,
57
+ estimatedTokens: number
58
+ ): string {
59
+ return formatRow({
60
+ time: formatTime(isoTimestamp),
61
+ action: "Read",
62
+ files: truncatePath(filePath),
63
+ outcome: indexHit ? "index hit" : "index miss",
64
+ tokens: `~${estimatedTokens}`,
65
+ });
66
+ }
67
+
68
+ export function formatWriteRow(
69
+ isoTimestamp: string,
70
+ filePath: string,
71
+ action: "create" | "edit",
72
+ description: string,
73
+ estimatedTokens: number
74
+ ): string {
75
+ return formatRow({
76
+ time: formatTime(isoTimestamp),
77
+ action: action === "create" ? "Create" : "Edit",
78
+ files: truncatePath(filePath),
79
+ outcome: description || "\u2014",
80
+ tokens: `~${estimatedTokens}`,
81
+ });
82
+ }
83
+
84
+ export function formatSessionEndRow(summary: SessionSummary): string {
85
+ const filesSet = new Set<string>();
86
+ for (const r of summary.reads) filesSet.add(r.filePath);
87
+ for (const w of summary.writes) filesSet.add(w.filePath);
88
+
89
+ return formatRow({
90
+ time: formatTime(summary.endTimestamp),
91
+ action: "Session end",
92
+ files: "\u2014",
93
+ outcome: `${summary.totals.writeCount} writes across ${filesSet.size} files | ~${summary.totals.estimatedTokens} tok total`,
94
+ tokens: "\u2014",
95
+ });
96
+ }
97
+
98
+ export function formatConsolidatedLine(
99
+ date: string,
100
+ readCount: number,
101
+ writeCount: number,
102
+ estimatedTokens: number,
103
+ keyFiles: string[]
104
+ ): string {
105
+ const files = keyFiles.slice(0, 5).join(", ");
106
+ return `> **${date}** \u2014 ${readCount} reads | ${writeCount} writes | ~${estimatedTokens} tokens | key files: ${files}\n`;
107
+ }
108
+
109
+ // ── I/O Operations ──────────────────────────────────────────────────────────
110
+
111
+ export function appendToLog(logPath: string, text: string): void {
112
+ try {
113
+ safeAppendText(logPath, text);
114
+ } catch {
115
+ // Retry once
116
+ try {
117
+ safeAppendText(logPath, text);
118
+ } catch {
119
+ console.warn(
120
+ `[mink] Warning: failed to append to action log at ${logPath}`
121
+ );
122
+ }
123
+ }
124
+ }
125
+
126
+ export function safeReadLog(logPath: string): string {
127
+ try {
128
+ return readFileSync(logPath, "utf-8");
129
+ } catch {
130
+ return "";
131
+ }
132
+ }
133
+
134
+ // ── Consolidation ───────────────────────────────────────────────────────────
135
+
136
+ const SESSION_HEADER_RE = /^### Session \u2014 (\d{4}-\d{2}-\d{2}) \d{2}:\d{2}$/gm;
137
+
138
+ export function parseLogSessions(content: string): ParsedSession[] {
139
+ const sessions: ParsedSession[] = [];
140
+ const headerMatches: Array<{ index: number; date: string }> = [];
141
+
142
+ let match: RegExpExecArray | null;
143
+ SESSION_HEADER_RE.lastIndex = 0;
144
+ while ((match = SESSION_HEADER_RE.exec(content)) !== null) {
145
+ headerMatches.push({ index: match.index, date: match[1] });
146
+ }
147
+
148
+ for (let i = 0; i < headerMatches.length; i++) {
149
+ const startIndex = headerMatches[i].index;
150
+ const endIndex =
151
+ i + 1 < headerMatches.length ? headerMatches[i + 1].index : content.length;
152
+ const sessionContent = content.slice(startIndex, endIndex);
153
+
154
+ // Count data rows: lines starting with "| " that are not header or separator
155
+ const lines = sessionContent.split("\n");
156
+ let entryCount = 0;
157
+ for (const line of lines) {
158
+ if (
159
+ line.startsWith("| ") &&
160
+ !line.startsWith("| Time") &&
161
+ !line.startsWith("| ---")
162
+ ) {
163
+ entryCount++;
164
+ }
165
+ }
166
+
167
+ sessions.push({
168
+ startIndex,
169
+ endIndex,
170
+ date: headerMatches[i].date,
171
+ entryCount,
172
+ content: sessionContent,
173
+ });
174
+ }
175
+
176
+ return sessions;
177
+ }
178
+
179
+ export function identifySessionsToConsolidate(
180
+ sessions: ParsedSession[],
181
+ config: ConsolidationConfig,
182
+ now: Date = new Date()
183
+ ): number[] {
184
+ // Check total entry count
185
+ let totalEntries = 0;
186
+ for (const s of sessions) totalEntries += s.entryCount;
187
+
188
+ if (totalEntries <= config.maxEntries) return [];
189
+
190
+ const cutoff = new Date(now);
191
+ cutoff.setDate(cutoff.getDate() - config.retentionDays);
192
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
193
+
194
+ const indices: number[] = [];
195
+ for (let i = 0; i < sessions.length; i++) {
196
+ if (sessions[i].date < cutoffStr) {
197
+ indices.push(i);
198
+ }
199
+ }
200
+
201
+ return indices;
202
+ }
203
+
204
+ function extractSessionStats(
205
+ session: ParsedSession
206
+ ): { readCount: number; writeCount: number; estimatedTokens: number; keyFiles: string[] } {
207
+ let readCount = 0;
208
+ let writeCount = 0;
209
+ let estimatedTokens = 0;
210
+ const fileSet = new Set<string>();
211
+
212
+ const lines = session.content.split("\n");
213
+ for (const line of lines) {
214
+ if (
215
+ !line.startsWith("| ") ||
216
+ line.startsWith("| Time") ||
217
+ line.startsWith("| ---")
218
+ ) {
219
+ continue;
220
+ }
221
+
222
+ // Parse table row: | time | action | file | outcome | ~tokens |
223
+ const cells = line
224
+ .split("|")
225
+ .slice(1, -1)
226
+ .map((c) => c.trim());
227
+ if (cells.length < 5) continue;
228
+
229
+ const action = cells[1];
230
+ const file = cells[2];
231
+ const tokenStr = cells[4];
232
+
233
+ if (action === "Read") {
234
+ readCount++;
235
+ if (file !== "\u2014") fileSet.add(file);
236
+ } else if (action === "Create" || action === "Edit") {
237
+ writeCount++;
238
+ if (file !== "\u2014") fileSet.add(file);
239
+ }
240
+
241
+ if (tokenStr.startsWith("~")) {
242
+ const n = parseInt(tokenStr.slice(1), 10);
243
+ if (!isNaN(n)) estimatedTokens += n;
244
+ }
245
+ }
246
+
247
+ return {
248
+ readCount,
249
+ writeCount,
250
+ estimatedTokens,
251
+ keyFiles: [...fileSet].slice(0, 5),
252
+ };
253
+ }
254
+
255
+ export function consolidateLog(
256
+ logPath: string,
257
+ config: ConsolidationConfig = { maxEntries: 200, retentionDays: 7 },
258
+ now?: Date
259
+ ): void {
260
+ const content = safeReadLog(logPath);
261
+ if (!content) return;
262
+
263
+ const sessions = parseLogSessions(content);
264
+ if (sessions.length === 0) return;
265
+
266
+ const toConsolidate = identifySessionsToConsolidate(sessions, config, now);
267
+ if (toConsolidate.length === 0) return;
268
+
269
+ const consolidateSet = new Set(toConsolidate);
270
+ const parts: string[] = [];
271
+
272
+ for (let i = 0; i < sessions.length; i++) {
273
+ if (consolidateSet.has(i)) {
274
+ const stats = extractSessionStats(sessions[i]);
275
+ parts.push(
276
+ formatConsolidatedLine(
277
+ sessions[i].date,
278
+ stats.readCount,
279
+ stats.writeCount,
280
+ stats.estimatedTokens,
281
+ stats.keyFiles
282
+ )
283
+ );
284
+ } else {
285
+ parts.push(sessions[i].content);
286
+ }
287
+ }
288
+
289
+ atomicWriteText(logPath, parts.join(""));
290
+ }
291
+
292
+ // ── Factory ─────────────────────────────────────────────────────────────────
293
+
294
+ export interface ActionLogWriter {
295
+ appendSessionHeader(isoTimestamp: string): void;
296
+ appendReadEntry(
297
+ isoTimestamp: string,
298
+ filePath: string,
299
+ indexHit: boolean,
300
+ estimatedTokens: number
301
+ ): void;
302
+ appendWriteEntry(
303
+ isoTimestamp: string,
304
+ filePath: string,
305
+ action: "create" | "edit",
306
+ description: string,
307
+ estimatedTokens: number
308
+ ): void;
309
+ appendSessionEnd(summary: SessionSummary): void;
310
+ }
311
+
312
+ export function createActionLogWriter(logPath: string): ActionLogWriter {
313
+ return {
314
+ appendSessionHeader(isoTimestamp: string): void {
315
+ appendToLog(logPath, formatSessionHeader(isoTimestamp));
316
+ },
317
+
318
+ appendReadEntry(
319
+ isoTimestamp: string,
320
+ filePath: string,
321
+ indexHit: boolean,
322
+ estimatedTokens: number
323
+ ): void {
324
+ appendToLog(logPath, formatReadRow(isoTimestamp, filePath, indexHit, estimatedTokens));
325
+ },
326
+
327
+ appendWriteEntry(
328
+ isoTimestamp: string,
329
+ filePath: string,
330
+ action: "create" | "edit",
331
+ description: string,
332
+ estimatedTokens: number
333
+ ): void {
334
+ appendToLog(logPath, formatWriteRow(isoTimestamp, filePath, action, description, estimatedTokens));
335
+ },
336
+
337
+ appendSessionEnd(summary: SessionSummary): void {
338
+ appendToLog(logPath, formatSessionEndRow(summary));
339
+ },
340
+ };
341
+ }
@@ -0,0 +1,122 @@
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ existsSync,
7
+ statSync,
8
+ } from "fs";
9
+ import { join } from "path";
10
+ import { projectDir, backupDirPath } from "./paths";
11
+ import type { BackupInfo } from "../types/backup";
12
+
13
+ function formatTimestamp(date: Date): string {
14
+ const y = date.getFullYear();
15
+ const mo = String(date.getMonth() + 1).padStart(2, "0");
16
+ const d = String(date.getDate()).padStart(2, "0");
17
+ const h = String(date.getHours()).padStart(2, "0");
18
+ const mi = String(date.getMinutes()).padStart(2, "0");
19
+ const s = String(date.getSeconds()).padStart(2, "0");
20
+ const ms = String(date.getMilliseconds()).padStart(3, "0");
21
+ return `${y}${mo}${d}-${h}${mi}${s}${ms}`;
22
+ }
23
+
24
+ function copyDirectoryFiles(
25
+ srcDir: string,
26
+ destDir: string,
27
+ excludeDirs: string[]
28
+ ): void {
29
+ mkdirSync(destDir, { recursive: true });
30
+ const entries = readdirSync(srcDir, { withFileTypes: true });
31
+ for (const entry of entries) {
32
+ if (entry.isDirectory()) {
33
+ if (excludeDirs.includes(entry.name)) continue;
34
+ copyDirectoryFiles(
35
+ join(srcDir, entry.name),
36
+ join(destDir, entry.name),
37
+ excludeDirs
38
+ );
39
+ } else if (entry.isFile()) {
40
+ writeFileSync(
41
+ join(destDir, entry.name),
42
+ readFileSync(join(srcDir, entry.name))
43
+ );
44
+ }
45
+ }
46
+ }
47
+
48
+ export function createBackup(cwd: string): string {
49
+ const base = `backup-${formatTimestamp(new Date())}`;
50
+ const dir = backupDirPath(cwd);
51
+ let name = base;
52
+ let suffix = 1;
53
+ while (existsSync(join(dir, name))) {
54
+ name = `${base}-${suffix}`;
55
+ suffix++;
56
+ }
57
+ const src = projectDir(cwd);
58
+ const dest = join(dir, name);
59
+ copyDirectoryFiles(src, dest, ["backups"]);
60
+ return name;
61
+ }
62
+
63
+ export function listBackups(cwd: string): BackupInfo[] {
64
+ const dir = backupDirPath(cwd);
65
+ if (!existsSync(dir)) return [];
66
+
67
+ const entries = readdirSync(dir, { withFileTypes: true });
68
+ const backups: BackupInfo[] = [];
69
+
70
+ for (const entry of entries) {
71
+ if (!entry.isDirectory() || !entry.name.startsWith("backup-")) continue;
72
+
73
+ const backupPath = join(dir, entry.name);
74
+ const match = entry.name.match(
75
+ /^backup-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})(\d{3})?(?:-\d+)?$/
76
+ );
77
+ let timestamp: Date;
78
+ if (match) {
79
+ timestamp = new Date(
80
+ parseInt(match[1]),
81
+ parseInt(match[2]) - 1,
82
+ parseInt(match[3]),
83
+ parseInt(match[4]),
84
+ parseInt(match[5]),
85
+ parseInt(match[6]),
86
+ match[7] ? parseInt(match[7]) : 0
87
+ );
88
+ } else {
89
+ timestamp = statSync(backupPath).mtime;
90
+ }
91
+
92
+ let fileCount = 0;
93
+ try {
94
+ fileCount = readdirSync(backupPath).length;
95
+ } catch {
96
+ // ignore
97
+ }
98
+
99
+ backups.push({ name: entry.name, timestamp, path: backupPath, fileCount });
100
+ }
101
+
102
+ backups.sort((a, b) => {
103
+ const timeDiff = b.timestamp.getTime() - a.timestamp.getTime();
104
+ if (timeDiff !== 0) return timeDiff;
105
+ // Same timestamp — sort by name descending so suffixed names come first
106
+ return b.name.localeCompare(a.name);
107
+ });
108
+ return backups;
109
+ }
110
+
111
+ export function restoreBackup(cwd: string, backupName: string): void {
112
+ const backupPath = join(backupDirPath(cwd), backupName);
113
+ if (!existsSync(backupPath)) {
114
+ throw new Error(`backup not found: ${backupName}`);
115
+ }
116
+
117
+ // Create a safety backup before restoring
118
+ createBackup(cwd);
119
+
120
+ // Copy files from backup to project dir, excluding backups/
121
+ copyDirectoryFiles(backupPath, projectDir(cwd), ["backups"]);
122
+ }
@@ -0,0 +1,223 @@
1
+ import { safeReadJson, atomicWriteJson } from "./fs-utils";
2
+ import type { BugEntry, BugMemory, SimilarityMatch } from "../types/bug-memory";
3
+
4
+ export function createEmptyBugMemory(): BugMemory {
5
+ return { entries: [], nextId: 1 };
6
+ }
7
+
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
+ export function isBugMemory(value: unknown): value is BugMemory {
19
+ if (value === null || typeof value !== "object") return false;
20
+ const obj = value as Record<string, unknown>;
21
+ return Array.isArray(obj.entries) && typeof obj.nextId === "number";
22
+ }
23
+
24
+ export function generateBugId(nextId: number): string {
25
+ return `bug-${String(nextId).padStart(3, "0")}`;
26
+ }
27
+
28
+ export function findDuplicate(
29
+ memory: BugMemory,
30
+ errorMessage: string,
31
+ filePath: string
32
+ ): BugEntry | null {
33
+ return (
34
+ memory.entries.find(
35
+ (e) => e.errorMessage === errorMessage && e.filePath === filePath
36
+ ) ?? null
37
+ );
38
+ }
39
+
40
+ export function addBugEntry(
41
+ memory: BugMemory,
42
+ fields: Omit<BugEntry, "id" | "createdAt" | "lastSeenAt" | "occurrenceCount">
43
+ ): BugMemory {
44
+ const existing = findDuplicate(memory, fields.errorMessage, fields.filePath);
45
+ if (existing) {
46
+ return updateOccurrence(memory, existing.id);
47
+ }
48
+
49
+ const now = new Date().toISOString();
50
+ const entry: BugEntry = {
51
+ id: generateBugId(memory.nextId),
52
+ createdAt: now,
53
+ lastSeenAt: now,
54
+ occurrenceCount: 1,
55
+ ...fields,
56
+ };
57
+
58
+ return {
59
+ entries: [...memory.entries, entry],
60
+ nextId: memory.nextId + 1,
61
+ };
62
+ }
63
+
64
+ export function updateOccurrence(memory: BugMemory, id: string): BugMemory {
65
+ const now = new Date().toISOString();
66
+ return {
67
+ ...memory,
68
+ entries: memory.entries.map((e) =>
69
+ e.id === id
70
+ ? { ...e, occurrenceCount: e.occurrenceCount + 1, lastSeenAt: now }
71
+ : e
72
+ ),
73
+ };
74
+ }
75
+
76
+ // --- Similarity scoring ---
77
+
78
+ function tokenize(text: string): Set<string> {
79
+ return new Set(
80
+ text
81
+ .toLowerCase()
82
+ .split(/\W+/)
83
+ .filter((w) => w.length > 0)
84
+ );
85
+ }
86
+
87
+ function jaccard(a: Set<string>, b: Set<string>): number {
88
+ if (a.size === 0 && b.size === 0) return 0;
89
+ let intersection = 0;
90
+ for (const word of a) {
91
+ if (b.has(word)) intersection++;
92
+ }
93
+ const union = a.size + b.size - intersection;
94
+ return union === 0 ? 0 : intersection / union;
95
+ }
96
+
97
+ export function computeSimilarity(
98
+ query: string,
99
+ entry: BugEntry
100
+ ): SimilarityMatch {
101
+ const matchReasons: string[] = [];
102
+ let score = 0;
103
+
104
+ // 1. Exact substring match on error message
105
+ if (
106
+ entry.errorMessage.length > 0 &&
107
+ entry.errorMessage.includes(query)
108
+ ) {
109
+ score += 1.0;
110
+ matchReasons.push("exact_error_match");
111
+ }
112
+
113
+ // 2. Word overlap (Jaccard) across searchable fields
114
+ const queryTokens = tokenize(query);
115
+
116
+ const fields: [string, string][] = [
117
+ ["error_message", entry.errorMessage],
118
+ ["root_cause", entry.rootCause],
119
+ ["fix", entry.fixDescription],
120
+ ["tags", entry.tags.join(" ")],
121
+ ];
122
+
123
+ for (const [fieldName, fieldValue] of fields) {
124
+ const fieldTokens = tokenize(fieldValue);
125
+ const j = jaccard(queryTokens, fieldTokens);
126
+ if (j > 0) {
127
+ score += j * 0.5;
128
+ matchReasons.push(fieldName);
129
+ }
130
+ }
131
+
132
+ return { entry, score, matchReasons };
133
+ }
134
+
135
+ function hasFilePathMatch(entry: BugEntry, filePath?: string): boolean {
136
+ if (!filePath) return false;
137
+ return entry.filePath === filePath;
138
+ }
139
+
140
+ function hasTagOverlap(entry: BugEntry, query: string): boolean {
141
+ const queryTokens = tokenize(query);
142
+ return entry.tags.some((tag) => queryTokens.has(tag.toLowerCase()));
143
+ }
144
+
145
+ export function searchBugs(
146
+ memory: BugMemory,
147
+ query: string,
148
+ options?: { filePath?: string }
149
+ ): SimilarityMatch[] {
150
+ if (memory.entries.length === 0 || query.trim().length === 0) return [];
151
+
152
+ const results: SimilarityMatch[] = [];
153
+
154
+ for (const entry of memory.entries) {
155
+ const match = computeSimilarity(query, entry);
156
+
157
+ // False positive guard: require file-path match OR tag overlap
158
+ const fileMatch = hasFilePathMatch(entry, options?.filePath);
159
+ const tagMatch = hasTagOverlap(entry, query);
160
+ if (!fileMatch && !tagMatch && match.score <= 0.3) continue;
161
+
162
+ // Boost same-file matches
163
+ if (fileMatch) {
164
+ match.score += 0.2;
165
+ if (!match.matchReasons.includes("file_path")) {
166
+ match.matchReasons.push("file_path");
167
+ }
168
+ }
169
+
170
+ if (match.score > 0.3) {
171
+ results.push(match);
172
+ }
173
+ }
174
+
175
+ return results.sort((a, b) => b.score - a.score);
176
+ }
177
+
178
+ export function lookupBugsForFile(
179
+ memory: BugMemory,
180
+ filePath: string
181
+ ): BugEntry[] {
182
+ return memory.entries
183
+ .filter((e) => e.filePath === filePath)
184
+ .sort(
185
+ (a, b) =>
186
+ new Date(b.lastSeenAt).getTime() - new Date(a.lastSeenAt).getTime()
187
+ );
188
+ }
189
+
190
+ export function formatBugSummary(entries: BugEntry[]): string | null {
191
+ if (entries.length === 0) return null;
192
+
193
+ const lines: string[] = ["[mink] Known bugs for this file:"];
194
+ const shown = entries.slice(0, 3);
195
+
196
+ for (const e of shown) {
197
+ lines.push(` ${e.id}: ${e.errorMessage}`);
198
+ lines.push(` Root cause: ${e.rootCause}`);
199
+ lines.push(` Fix: ${e.fixDescription}`);
200
+ if (e.occurrenceCount > 1) {
201
+ lines.push(` Seen ${e.occurrenceCount} times (last: ${e.lastSeenAt})`);
202
+ }
203
+ }
204
+
205
+ if (entries.length > 3) {
206
+ lines.push(` ... and ${entries.length - 3} more`);
207
+ }
208
+
209
+ return lines.join("\n");
210
+ }
211
+
212
+ export function hasBugForFileInSession(
213
+ memory: BugMemory,
214
+ filePath: string,
215
+ sessionStartTimestamp: string
216
+ ): boolean {
217
+ const sessionStart = new Date(sessionStartTimestamp).getTime();
218
+ return memory.entries.some(
219
+ (e) =>
220
+ e.filePath === filePath &&
221
+ new Date(e.createdAt).getTime() >= sessionStart
222
+ );
223
+ }