@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.
- package/README.md +347 -0
- package/package.json +32 -0
- package/src/cli.ts +176 -0
- package/src/commands/bug-search.ts +32 -0
- package/src/commands/config.ts +109 -0
- package/src/commands/cron.ts +295 -0
- package/src/commands/daemon.ts +46 -0
- package/src/commands/dashboard.ts +21 -0
- package/src/commands/designqc.ts +160 -0
- package/src/commands/detect-waste.ts +81 -0
- package/src/commands/framework-advisor.ts +52 -0
- package/src/commands/init.ts +159 -0
- package/src/commands/post-read.ts +123 -0
- package/src/commands/post-write.ts +157 -0
- package/src/commands/pre-read.ts +109 -0
- package/src/commands/pre-write.ts +136 -0
- package/src/commands/reflect.ts +39 -0
- package/src/commands/restore.ts +31 -0
- package/src/commands/scan.ts +101 -0
- package/src/commands/session-start.ts +21 -0
- package/src/commands/session-stop.ts +115 -0
- package/src/commands/status.ts +152 -0
- package/src/commands/update.ts +121 -0
- package/src/core/action-log.ts +341 -0
- package/src/core/backup.ts +122 -0
- package/src/core/bug-memory.ts +223 -0
- package/src/core/cron-parser.ts +94 -0
- package/src/core/daemon.ts +152 -0
- package/src/core/dashboard-api.ts +280 -0
- package/src/core/dashboard-server.ts +580 -0
- package/src/core/description.ts +232 -0
- package/src/core/design-eval/capture.ts +269 -0
- package/src/core/design-eval/route-detect.ts +165 -0
- package/src/core/design-eval/server-detect.ts +91 -0
- package/src/core/framework-advisor/catalog.ts +360 -0
- package/src/core/framework-advisor/decision-tree.ts +287 -0
- package/src/core/framework-advisor/generate.ts +132 -0
- package/src/core/framework-advisor/migration-prompts.ts +502 -0
- package/src/core/framework-advisor/validate.ts +137 -0
- package/src/core/fs-utils.ts +30 -0
- package/src/core/global-config.ts +74 -0
- package/src/core/index-store.ts +72 -0
- package/src/core/learning-memory.ts +120 -0
- package/src/core/paths.ts +86 -0
- package/src/core/pattern-engine.ts +108 -0
- package/src/core/project-id.ts +19 -0
- package/src/core/project-registry.ts +64 -0
- package/src/core/reflection.ts +256 -0
- package/src/core/scanner.ts +99 -0
- package/src/core/scheduler.ts +352 -0
- package/src/core/seed.ts +239 -0
- package/src/core/session.ts +128 -0
- package/src/core/stdin.ts +13 -0
- package/src/core/task-registry.ts +202 -0
- package/src/core/token-estimate.ts +36 -0
- package/src/core/token-ledger.ts +185 -0
- package/src/core/waste-detection.ts +214 -0
- package/src/core/write-exclusions.ts +24 -0
- package/src/types/action-log.ts +20 -0
- package/src/types/backup.ts +6 -0
- package/src/types/bug-memory.ts +24 -0
- package/src/types/config.ts +59 -0
- package/src/types/dashboard.ts +104 -0
- package/src/types/design-eval.ts +64 -0
- package/src/types/file-index.ts +38 -0
- package/src/types/framework-advisor.ts +97 -0
- package/src/types/hook-input.ts +27 -0
- package/src/types/learning-memory.ts +36 -0
- package/src/types/scheduler.ts +82 -0
- package/src/types/session.ts +50 -0
- package/src/types/token-ledger.ts +43 -0
- 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
|
+
}
|