@drewpayment/mink 0.11.0 → 0.12.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 → cli.node.js} +2227 -758
- package/package.json +14 -4
- package/scripts/build.mjs +47 -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 → 9ElzGFcXpcjLq-QSQslWY}/_buildManifest.js +0 -0
- /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 9ElzGFcXpcjLq-QSQslWY}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// One-shot JSON → SQLite importer. Runs the first time `openProjectDb(cwd)`
|
|
2
|
+
// is called on a project that already has on-disk JSON state. Idempotent:
|
|
3
|
+
// once `meta.migrated_from_json_at` is set, every subsequent call returns
|
|
4
|
+
// without touching the DB or filesystem.
|
|
5
|
+
//
|
|
6
|
+
// The importer runs inside a single transaction. Sources are:
|
|
7
|
+
// - Every `state/{deviceId}/` shard (token-ledger.json, bug-memory.json,
|
|
8
|
+
// token-ledger-archive.json) — device_id taken from the directory name.
|
|
9
|
+
// - The legacy root JSONs at `{projectDir}/file-index.json`,
|
|
10
|
+
// `bug-memory.json`, `token-ledger.json`, `token-ledger-archive.json`
|
|
11
|
+
// — these are pre-sync-v2 state, attributed to device_id="legacy".
|
|
12
|
+
// - `.mink-state-counters.json` for per-device hit/miss counters.
|
|
13
|
+
//
|
|
14
|
+
// Conflict resolution per store matches the existing aggregator semantics in
|
|
15
|
+
// `src/core/state-aggregator.ts`:
|
|
16
|
+
// file_index — keep row with newer `last_modified` (lex sort on ISO).
|
|
17
|
+
// bug_memory — max(occurrence_count), latest last_seen_at, oldest
|
|
18
|
+
// created_at, union of tags + related ids.
|
|
19
|
+
// ledger_sessions — keyed by session_id; first writer wins (shards never
|
|
20
|
+
// overlap session ids in production).
|
|
21
|
+
// ledger_lifetime — summed per device_id.
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, renameSync, statSync, mkdirSync } from "fs";
|
|
24
|
+
import { dirname, join } from "path";
|
|
25
|
+
import { projectDir, fileIndexCountersPath } from "../core/paths";
|
|
26
|
+
import type { DbDriver } from "./driver";
|
|
27
|
+
import { readMeta, writeMeta } from "./schema";
|
|
28
|
+
|
|
29
|
+
const LEGACY_DEVICE_ID = "legacy";
|
|
30
|
+
|
|
31
|
+
interface JsonFileIndexEntry {
|
|
32
|
+
filePath: string;
|
|
33
|
+
description: string;
|
|
34
|
+
estimatedTokens: number;
|
|
35
|
+
lastModified: string;
|
|
36
|
+
lastIndexed: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface JsonFileIndex {
|
|
40
|
+
header: { lastScanTimestamp?: string; totalFiles?: number };
|
|
41
|
+
entries: Record<string, JsonFileIndexEntry>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface JsonBugEntry {
|
|
45
|
+
id: string;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
lastSeenAt: string;
|
|
48
|
+
errorMessage: string;
|
|
49
|
+
filePath: string;
|
|
50
|
+
lineNumber?: number;
|
|
51
|
+
rootCause: string;
|
|
52
|
+
fixDescription: string;
|
|
53
|
+
tags: string[];
|
|
54
|
+
occurrenceCount: number;
|
|
55
|
+
relatedBugIds: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface JsonBugMemory {
|
|
59
|
+
entries: JsonBugEntry[];
|
|
60
|
+
nextId: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface JsonLedgerTotals {
|
|
64
|
+
readCount: number;
|
|
65
|
+
writeCount: number;
|
|
66
|
+
estimatedTokens: number;
|
|
67
|
+
repeatedReads: number;
|
|
68
|
+
fileIndexHits: number;
|
|
69
|
+
fileIndexMisses: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface JsonLedgerSession {
|
|
73
|
+
sessionId: string;
|
|
74
|
+
startTimestamp: string;
|
|
75
|
+
endTimestamp: string;
|
|
76
|
+
reads: Array<{ filePath: string; estimatedTokens: number; readCount: number }>;
|
|
77
|
+
writes: Array<{ filePath: string; estimatedTokens: number; action: string }>;
|
|
78
|
+
totals: JsonLedgerTotals;
|
|
79
|
+
estimatedSavings: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface JsonTokenLedger {
|
|
83
|
+
lifetime: {
|
|
84
|
+
totalTokens: number;
|
|
85
|
+
totalReads: number;
|
|
86
|
+
totalWrites: number;
|
|
87
|
+
totalSessions: number;
|
|
88
|
+
totalFileIndexHits: number;
|
|
89
|
+
totalFileIndexMisses: number;
|
|
90
|
+
totalRepeatedReads: number;
|
|
91
|
+
totalEstimatedSavings: number;
|
|
92
|
+
};
|
|
93
|
+
sessions: JsonLedgerSession[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function safeReadJson<T>(path: string): T | null {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function listDeviceShards(projDir: string): string[] {
|
|
105
|
+
const stateDir = join(projDir, "state");
|
|
106
|
+
if (!existsSync(stateDir)) return [];
|
|
107
|
+
try {
|
|
108
|
+
return readdirSync(stateDir).filter((name) => {
|
|
109
|
+
try {
|
|
110
|
+
return statSync(join(stateDir, name)).isDirectory();
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function moveSourceToBackup(srcPath: string, backupRoot: string, deviceId: string): void {
|
|
121
|
+
if (!existsSync(srcPath)) return;
|
|
122
|
+
const filename = srcPath.substring(srcPath.lastIndexOf("/") + 1);
|
|
123
|
+
const destDir = join(backupRoot, deviceId);
|
|
124
|
+
mkdirSync(destDir, { recursive: true });
|
|
125
|
+
const dest = join(destDir, filename);
|
|
126
|
+
try {
|
|
127
|
+
renameSync(srcPath, dest);
|
|
128
|
+
} catch {
|
|
129
|
+
// If rename fails (e.g. cross-device), give up — the importer is
|
|
130
|
+
// already done; leaving the JSON in place is harmless because the
|
|
131
|
+
// meta marker prevents re-import.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function importFileIndex(
|
|
136
|
+
db: DbDriver,
|
|
137
|
+
index: JsonFileIndex,
|
|
138
|
+
deviceId: string,
|
|
139
|
+
now: string
|
|
140
|
+
): void {
|
|
141
|
+
const stmt = db.prepare(`
|
|
142
|
+
INSERT INTO file_index
|
|
143
|
+
(file_path, description, estimated_tokens, last_modified, last_indexed, mtime_ms, content_hash, size_bytes, device_id)
|
|
144
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?)
|
|
145
|
+
ON CONFLICT(file_path) DO UPDATE SET
|
|
146
|
+
description = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.description ELSE file_index.description END,
|
|
147
|
+
estimated_tokens = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.estimated_tokens ELSE file_index.estimated_tokens END,
|
|
148
|
+
last_indexed = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.last_indexed ELSE file_index.last_indexed END,
|
|
149
|
+
last_modified = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.last_modified ELSE file_index.last_modified END,
|
|
150
|
+
device_id = CASE WHEN excluded.last_modified > file_index.last_modified THEN excluded.device_id ELSE file_index.device_id END
|
|
151
|
+
`);
|
|
152
|
+
for (const [filePath, e] of Object.entries(index.entries ?? {})) {
|
|
153
|
+
// mtime_ms is 0 in the import — Phase 5's incremental scanner refills
|
|
154
|
+
// it the first time `mink scan` runs. The 0 sentinel forces re-extract
|
|
155
|
+
// on first scan, which is the safe default.
|
|
156
|
+
stmt.run(
|
|
157
|
+
filePath,
|
|
158
|
+
String(e.description ?? ""),
|
|
159
|
+
Number(e.estimatedTokens ?? 0),
|
|
160
|
+
String(e.lastModified ?? now),
|
|
161
|
+
String(e.lastIndexed ?? now),
|
|
162
|
+
0,
|
|
163
|
+
deviceId
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function importBugMemory(db: DbDriver, mem: JsonBugMemory, deviceId: string): void {
|
|
169
|
+
// Bug insert merge: keep oldest createdAt, latest lastSeenAt,
|
|
170
|
+
// max(occurrence_count). Tags + related are accumulated via separate
|
|
171
|
+
// tables so duplicates are skipped by the PRIMARY KEY constraint.
|
|
172
|
+
const upsertBug = db.prepare(`
|
|
173
|
+
INSERT INTO bug_memory
|
|
174
|
+
(id, created_at, last_seen_at, error_message, file_path, line_number, root_cause, fix_description, occurrence_count, device_id)
|
|
175
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
176
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
177
|
+
created_at = CASE WHEN excluded.created_at < bug_memory.created_at THEN excluded.created_at ELSE bug_memory.created_at END,
|
|
178
|
+
last_seen_at = CASE WHEN excluded.last_seen_at > bug_memory.last_seen_at THEN excluded.last_seen_at ELSE bug_memory.last_seen_at END,
|
|
179
|
+
occurrence_count = MAX(bug_memory.occurrence_count, excluded.occurrence_count),
|
|
180
|
+
error_message = bug_memory.error_message,
|
|
181
|
+
file_path = bug_memory.file_path,
|
|
182
|
+
root_cause = bug_memory.root_cause,
|
|
183
|
+
fix_description = bug_memory.fix_description
|
|
184
|
+
`);
|
|
185
|
+
const insertTag = db.prepare(
|
|
186
|
+
"INSERT OR IGNORE INTO bug_tags (bug_id, tag) VALUES (?, ?)"
|
|
187
|
+
);
|
|
188
|
+
const insertRelated = db.prepare(
|
|
189
|
+
"INSERT OR IGNORE INTO bug_related (bug_id, related_bug_id) VALUES (?, ?)"
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const now = new Date().toISOString();
|
|
193
|
+
for (const e of mem.entries ?? []) {
|
|
194
|
+
// Skip entries without a stable id — there's nothing to merge against
|
|
195
|
+
// and tag/related rows would have no parent.
|
|
196
|
+
if (!e || typeof e.id !== "string" || e.id.length === 0) continue;
|
|
197
|
+
upsertBug.run(
|
|
198
|
+
e.id,
|
|
199
|
+
e.createdAt ?? now,
|
|
200
|
+
e.lastSeenAt ?? e.createdAt ?? now,
|
|
201
|
+
e.errorMessage ?? "",
|
|
202
|
+
e.filePath ?? "",
|
|
203
|
+
e.lineNumber ?? null,
|
|
204
|
+
e.rootCause ?? "",
|
|
205
|
+
e.fixDescription ?? "",
|
|
206
|
+
Number(e.occurrenceCount ?? 1),
|
|
207
|
+
deviceId
|
|
208
|
+
);
|
|
209
|
+
for (const tag of e.tags ?? []) {
|
|
210
|
+
insertTag.run(e.id, tag);
|
|
211
|
+
}
|
|
212
|
+
for (const rel of e.relatedBugIds ?? []) {
|
|
213
|
+
insertRelated.run(e.id, rel);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function importTokenLedger(
|
|
219
|
+
db: DbDriver,
|
|
220
|
+
ledger: JsonTokenLedger,
|
|
221
|
+
deviceId: string,
|
|
222
|
+
archived: 0 | 1
|
|
223
|
+
): void {
|
|
224
|
+
// Lifetime is summed per device, NOT recomputed from sessions — the
|
|
225
|
+
// archive flow drops sessions but retains their contributions to
|
|
226
|
+
// lifetime, so deriving lifetime from active sessions would lose history.
|
|
227
|
+
const insertSession = db.prepare(`
|
|
228
|
+
INSERT OR IGNORE INTO ledger_sessions (
|
|
229
|
+
session_id, device_id, start_timestamp, end_timestamp,
|
|
230
|
+
read_count, write_count, estimated_tokens, repeated_reads,
|
|
231
|
+
file_index_hits, file_index_misses, estimated_savings, archived
|
|
232
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
233
|
+
`);
|
|
234
|
+
const insertRead = db.prepare(
|
|
235
|
+
"INSERT INTO ledger_reads (session_id, file_path, estimated_tokens, read_count) VALUES (?, ?, ?, ?)"
|
|
236
|
+
);
|
|
237
|
+
const insertWrite = db.prepare(
|
|
238
|
+
"INSERT INTO ledger_writes (session_id, file_path, estimated_tokens, action) VALUES (?, ?, ?, ?)"
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
for (const s of ledger.sessions ?? []) {
|
|
242
|
+
insertSession.run(
|
|
243
|
+
s.sessionId,
|
|
244
|
+
deviceId,
|
|
245
|
+
s.startTimestamp,
|
|
246
|
+
s.endTimestamp,
|
|
247
|
+
s.totals?.readCount ?? 0,
|
|
248
|
+
s.totals?.writeCount ?? 0,
|
|
249
|
+
s.totals?.estimatedTokens ?? 0,
|
|
250
|
+
s.totals?.repeatedReads ?? 0,
|
|
251
|
+
s.totals?.fileIndexHits ?? 0,
|
|
252
|
+
s.totals?.fileIndexMisses ?? 0,
|
|
253
|
+
s.estimatedSavings ?? 0,
|
|
254
|
+
archived
|
|
255
|
+
);
|
|
256
|
+
// INSERT OR IGNORE may have skipped this session; only insert child
|
|
257
|
+
// rows when the session is new. Cheap check via session_id existence.
|
|
258
|
+
const exists = db
|
|
259
|
+
.prepare("SELECT 1 FROM ledger_reads WHERE session_id = ? LIMIT 1")
|
|
260
|
+
.get(s.sessionId);
|
|
261
|
+
if (!exists) {
|
|
262
|
+
for (const r of s.reads ?? []) {
|
|
263
|
+
insertRead.run(s.sessionId, r.filePath, r.estimatedTokens, r.readCount);
|
|
264
|
+
}
|
|
265
|
+
for (const w of s.writes ?? []) {
|
|
266
|
+
insertWrite.run(s.sessionId, w.filePath, w.estimatedTokens, w.action);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Sum lifetime counters per device.
|
|
272
|
+
const lt = ledger.lifetime;
|
|
273
|
+
if (lt) {
|
|
274
|
+
db.prepare(`
|
|
275
|
+
INSERT INTO ledger_lifetime (
|
|
276
|
+
device_id, total_tokens, total_reads, total_writes, total_sessions,
|
|
277
|
+
total_file_index_hits, total_file_index_misses, total_repeated_reads, total_estimated_savings
|
|
278
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
279
|
+
ON CONFLICT(device_id) DO UPDATE SET
|
|
280
|
+
total_tokens = ledger_lifetime.total_tokens + excluded.total_tokens,
|
|
281
|
+
total_reads = ledger_lifetime.total_reads + excluded.total_reads,
|
|
282
|
+
total_writes = ledger_lifetime.total_writes + excluded.total_writes,
|
|
283
|
+
total_sessions = ledger_lifetime.total_sessions + excluded.total_sessions,
|
|
284
|
+
total_file_index_hits = ledger_lifetime.total_file_index_hits + excluded.total_file_index_hits,
|
|
285
|
+
total_file_index_misses = ledger_lifetime.total_file_index_misses + excluded.total_file_index_misses,
|
|
286
|
+
total_repeated_reads = ledger_lifetime.total_repeated_reads + excluded.total_repeated_reads,
|
|
287
|
+
total_estimated_savings = ledger_lifetime.total_estimated_savings + excluded.total_estimated_savings
|
|
288
|
+
`).run(
|
|
289
|
+
deviceId,
|
|
290
|
+
lt.totalTokens ?? 0,
|
|
291
|
+
lt.totalReads ?? 0,
|
|
292
|
+
lt.totalWrites ?? 0,
|
|
293
|
+
lt.totalSessions ?? 0,
|
|
294
|
+
lt.totalFileIndexHits ?? 0,
|
|
295
|
+
lt.totalFileIndexMisses ?? 0,
|
|
296
|
+
lt.totalRepeatedReads ?? 0,
|
|
297
|
+
lt.totalEstimatedSavings ?? 0
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function importArchive(
|
|
303
|
+
db: DbDriver,
|
|
304
|
+
archived: JsonLedgerSession[],
|
|
305
|
+
deviceId: string
|
|
306
|
+
): void {
|
|
307
|
+
// Archive shape on disk is just an array of sessions (no lifetime block).
|
|
308
|
+
importTokenLedger(db, { lifetime: undefined as never, sessions: archived }, deviceId, 1);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function importCounters(db: DbDriver, projDir: string): void {
|
|
312
|
+
const path = fileIndexCountersPath(projDir.endsWith("/") ? projDir.slice(0, -1) : projDir);
|
|
313
|
+
// fileIndexCountersPath takes cwd but joins under projectDir(cwd). We
|
|
314
|
+
// already have projDir, so reconstruct directly.
|
|
315
|
+
const direct = join(projDir, ".mink-state-counters.json");
|
|
316
|
+
const counters = safeReadJson<Record<string, { hits?: number; misses?: number }>>(
|
|
317
|
+
existsSync(direct) ? direct : path
|
|
318
|
+
);
|
|
319
|
+
if (!counters) return;
|
|
320
|
+
const stmt = db.prepare(`
|
|
321
|
+
INSERT INTO counters (device_id, file_index_hits, file_index_misses)
|
|
322
|
+
VALUES (?, ?, ?)
|
|
323
|
+
ON CONFLICT(device_id) DO UPDATE SET
|
|
324
|
+
file_index_hits = counters.file_index_hits + excluded.file_index_hits,
|
|
325
|
+
file_index_misses = counters.file_index_misses + excluded.file_index_misses
|
|
326
|
+
`);
|
|
327
|
+
for (const [deviceId, v] of Object.entries(counters)) {
|
|
328
|
+
stmt.run(deviceId, Number(v.hits ?? 0), Number(v.misses ?? 0));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Anchor on the projectDir for a given cwd (computed once by the caller, to
|
|
333
|
+
// match the dir the DB itself lives in).
|
|
334
|
+
export function migrateJsonIfNeeded(db: DbDriver, cwd: string): void {
|
|
335
|
+
if (readMeta(db, "migrated_from_json_at") !== null) return;
|
|
336
|
+
|
|
337
|
+
const projDir = projectDir(cwd);
|
|
338
|
+
if (!existsSync(projDir)) {
|
|
339
|
+
// Brand-new project. Mark as migrated so the importer never runs.
|
|
340
|
+
writeMeta(db, "migrated_from_json_at", new Date().toISOString());
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const now = new Date().toISOString();
|
|
345
|
+
const backupRoot = join(projDir, "legacy-backup");
|
|
346
|
+
|
|
347
|
+
type Source = {
|
|
348
|
+
deviceId: string;
|
|
349
|
+
fileIndex?: string;
|
|
350
|
+
bugMemory?: string;
|
|
351
|
+
ledger?: string;
|
|
352
|
+
archive?: string;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const sources: Source[] = [];
|
|
356
|
+
|
|
357
|
+
// Per-device shards.
|
|
358
|
+
for (const id of listDeviceShards(projDir)) {
|
|
359
|
+
const shardDir = join(projDir, "state", id);
|
|
360
|
+
sources.push({
|
|
361
|
+
deviceId: id,
|
|
362
|
+
bugMemory: join(shardDir, "bug-memory.json"),
|
|
363
|
+
ledger: join(shardDir, "token-ledger.json"),
|
|
364
|
+
archive: join(shardDir, "token-ledger-archive.json"),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Legacy root JSONs — pre-sync-v2 state lives directly under projDir.
|
|
369
|
+
sources.push({
|
|
370
|
+
deviceId: LEGACY_DEVICE_ID,
|
|
371
|
+
fileIndex: join(projDir, "file-index.json"),
|
|
372
|
+
bugMemory: join(projDir, "bug-memory.json"),
|
|
373
|
+
ledger: join(projDir, "token-ledger.json"),
|
|
374
|
+
archive: join(projDir, "token-ledger-archive.json"),
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
db.transaction(() => {
|
|
378
|
+
for (const src of sources) {
|
|
379
|
+
if (src.fileIndex && existsSync(src.fileIndex)) {
|
|
380
|
+
const idx = safeReadJson<JsonFileIndex>(src.fileIndex);
|
|
381
|
+
if (idx) importFileIndex(db, idx, src.deviceId, now);
|
|
382
|
+
}
|
|
383
|
+
if (src.bugMemory && existsSync(src.bugMemory)) {
|
|
384
|
+
const mem = safeReadJson<JsonBugMemory>(src.bugMemory);
|
|
385
|
+
if (mem) importBugMemory(db, mem, src.deviceId);
|
|
386
|
+
}
|
|
387
|
+
if (src.ledger && existsSync(src.ledger)) {
|
|
388
|
+
const led = safeReadJson<JsonTokenLedger>(src.ledger);
|
|
389
|
+
if (led) importTokenLedger(db, led, src.deviceId, 0);
|
|
390
|
+
}
|
|
391
|
+
if (src.archive && existsSync(src.archive)) {
|
|
392
|
+
const arch = safeReadJson<JsonLedgerSession[]>(src.archive);
|
|
393
|
+
if (arch && Array.isArray(arch)) importArchive(db, arch, src.deviceId);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
importCounters(db, projDir);
|
|
398
|
+
writeMeta(db, "migrated_from_json_at", now);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Move all successfully-imported sources to legacy-backup. As of
|
|
402
|
+
// Phase 4 every JSON store has a SQLite replacement, so we don't
|
|
403
|
+
// need to leave anything in place for the legacy aggregators.
|
|
404
|
+
for (const src of sources) {
|
|
405
|
+
if (src.fileIndex) moveSourceToBackup(src.fileIndex, backupRoot, src.deviceId);
|
|
406
|
+
if (src.bugMemory) moveSourceToBackup(src.bugMemory, backupRoot, src.deviceId);
|
|
407
|
+
if (src.ledger) moveSourceToBackup(src.ledger, backupRoot, src.deviceId);
|
|
408
|
+
if (src.archive) moveSourceToBackup(src.archive, backupRoot, src.deviceId);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Avoid the unused-import warning under strict typecheck. (`dirname` is
|
|
413
|
+
// referenced indirectly via mkdirSync; keep this footer if no direct usage
|
|
414
|
+
// remains.)
|
|
415
|
+
void dirname;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Initial schema for `mink.db`. One database per project, lives at
|
|
2
|
+
// `~/.mink/projects/{projectId}/mink.db`. WAL mode is set in
|
|
3
|
+
// `db.ts:openProjectDb`, not here, so the schema text alone is safe to load
|
|
4
|
+
// in pure-test contexts that use an in-memory connection.
|
|
5
|
+
//
|
|
6
|
+
// Conventions:
|
|
7
|
+
// - Every timestamp is ISO-8601 in TEXT. SQLite has no native datetime type,
|
|
8
|
+
// ISO sorts lexicographically, and the JSON state files already use ISO.
|
|
9
|
+
// - Every row carries `device_id` so the multi-device sync merge driver
|
|
10
|
+
// (Phase 2/3) can reconcile origin without re-reading shard directories.
|
|
11
|
+
// - Foreign keys are enforced (PRAGMA foreign_keys = ON in db.ts).
|
|
12
|
+
// - `meta(key, value)` holds versioning + migration markers. Keep it small;
|
|
13
|
+
// per-store counters live in `counters` and `ledger_lifetime`.
|
|
14
|
+
|
|
15
|
+
export const SCHEMA_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
export const INITIAL_SCHEMA = `
|
|
18
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
19
|
+
key TEXT PRIMARY KEY,
|
|
20
|
+
value TEXT NOT NULL
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS file_index (
|
|
24
|
+
file_path TEXT PRIMARY KEY,
|
|
25
|
+
description TEXT NOT NULL,
|
|
26
|
+
estimated_tokens INTEGER NOT NULL,
|
|
27
|
+
last_modified TEXT NOT NULL,
|
|
28
|
+
last_indexed TEXT NOT NULL,
|
|
29
|
+
mtime_ms INTEGER NOT NULL,
|
|
30
|
+
content_hash TEXT,
|
|
31
|
+
size_bytes INTEGER,
|
|
32
|
+
device_id TEXT NOT NULL
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_file_index_mtime ON file_index(mtime_ms);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_file_index_indexed ON file_index(last_indexed);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS bug_memory (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
last_seen_at TEXT NOT NULL,
|
|
41
|
+
error_message TEXT NOT NULL,
|
|
42
|
+
file_path TEXT NOT NULL,
|
|
43
|
+
line_number INTEGER,
|
|
44
|
+
root_cause TEXT NOT NULL,
|
|
45
|
+
fix_description TEXT NOT NULL,
|
|
46
|
+
occurrence_count INTEGER NOT NULL DEFAULT 1,
|
|
47
|
+
device_id TEXT NOT NULL
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_bug_memory_file ON bug_memory(file_path);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_bug_memory_seen ON bug_memory(last_seen_at);
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS bug_tags (
|
|
53
|
+
bug_id TEXT NOT NULL REFERENCES bug_memory(id) ON DELETE CASCADE,
|
|
54
|
+
tag TEXT NOT NULL,
|
|
55
|
+
PRIMARY KEY (bug_id, tag)
|
|
56
|
+
);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_bug_tags_tag ON bug_tags(tag);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS bug_related (
|
|
60
|
+
bug_id TEXT NOT NULL REFERENCES bug_memory(id) ON DELETE CASCADE,
|
|
61
|
+
related_bug_id TEXT NOT NULL,
|
|
62
|
+
PRIMARY KEY (bug_id, related_bug_id)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS bug_memory_fts USING fts5(
|
|
66
|
+
bug_id UNINDEXED,
|
|
67
|
+
error_message,
|
|
68
|
+
root_cause,
|
|
69
|
+
fix_description,
|
|
70
|
+
tags,
|
|
71
|
+
tokenize = 'porter unicode61'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
-- Mirror bug_memory + bug_tags into bug_memory_fts. Tag aggregation is done
|
|
75
|
+
-- in a single subquery inside each trigger so multi-tag bugs get one FTS row
|
|
76
|
+
-- with the full tag string.
|
|
77
|
+
CREATE TRIGGER IF NOT EXISTS trg_bug_memory_ai AFTER INSERT ON bug_memory BEGIN
|
|
78
|
+
INSERT INTO bug_memory_fts (bug_id, error_message, root_cause, fix_description, tags)
|
|
79
|
+
VALUES (
|
|
80
|
+
NEW.id,
|
|
81
|
+
NEW.error_message,
|
|
82
|
+
NEW.root_cause,
|
|
83
|
+
NEW.fix_description,
|
|
84
|
+
COALESCE((SELECT GROUP_CONCAT(tag, ' ') FROM bug_tags WHERE bug_id = NEW.id), '')
|
|
85
|
+
);
|
|
86
|
+
END;
|
|
87
|
+
|
|
88
|
+
CREATE TRIGGER IF NOT EXISTS trg_bug_memory_ad AFTER DELETE ON bug_memory BEGIN
|
|
89
|
+
DELETE FROM bug_memory_fts WHERE bug_id = OLD.id;
|
|
90
|
+
END;
|
|
91
|
+
|
|
92
|
+
CREATE TRIGGER IF NOT EXISTS trg_bug_memory_au AFTER UPDATE ON bug_memory BEGIN
|
|
93
|
+
DELETE FROM bug_memory_fts WHERE bug_id = OLD.id;
|
|
94
|
+
INSERT INTO bug_memory_fts (bug_id, error_message, root_cause, fix_description, tags)
|
|
95
|
+
VALUES (
|
|
96
|
+
NEW.id,
|
|
97
|
+
NEW.error_message,
|
|
98
|
+
NEW.root_cause,
|
|
99
|
+
NEW.fix_description,
|
|
100
|
+
COALESCE((SELECT GROUP_CONCAT(tag, ' ') FROM bug_tags WHERE bug_id = NEW.id), '')
|
|
101
|
+
);
|
|
102
|
+
END;
|
|
103
|
+
|
|
104
|
+
CREATE TRIGGER IF NOT EXISTS trg_bug_tags_ai AFTER INSERT ON bug_tags BEGIN
|
|
105
|
+
DELETE FROM bug_memory_fts WHERE bug_id = NEW.bug_id;
|
|
106
|
+
INSERT INTO bug_memory_fts (bug_id, error_message, root_cause, fix_description, tags)
|
|
107
|
+
SELECT id, error_message, root_cause, fix_description,
|
|
108
|
+
COALESCE((SELECT GROUP_CONCAT(tag, ' ') FROM bug_tags WHERE bug_id = NEW.bug_id), '')
|
|
109
|
+
FROM bug_memory WHERE id = NEW.bug_id;
|
|
110
|
+
END;
|
|
111
|
+
|
|
112
|
+
CREATE TRIGGER IF NOT EXISTS trg_bug_tags_ad AFTER DELETE ON bug_tags BEGIN
|
|
113
|
+
DELETE FROM bug_memory_fts WHERE bug_id = OLD.bug_id;
|
|
114
|
+
INSERT INTO bug_memory_fts (bug_id, error_message, root_cause, fix_description, tags)
|
|
115
|
+
SELECT id, error_message, root_cause, fix_description,
|
|
116
|
+
COALESCE((SELECT GROUP_CONCAT(tag, ' ') FROM bug_tags WHERE bug_id = OLD.bug_id), '')
|
|
117
|
+
FROM bug_memory WHERE id = OLD.bug_id;
|
|
118
|
+
END;
|
|
119
|
+
|
|
120
|
+
CREATE TABLE IF NOT EXISTS ledger_lifetime (
|
|
121
|
+
device_id TEXT PRIMARY KEY,
|
|
122
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
123
|
+
total_reads INTEGER NOT NULL DEFAULT 0,
|
|
124
|
+
total_writes INTEGER NOT NULL DEFAULT 0,
|
|
125
|
+
total_sessions INTEGER NOT NULL DEFAULT 0,
|
|
126
|
+
total_file_index_hits INTEGER NOT NULL DEFAULT 0,
|
|
127
|
+
total_file_index_misses INTEGER NOT NULL DEFAULT 0,
|
|
128
|
+
total_repeated_reads INTEGER NOT NULL DEFAULT 0,
|
|
129
|
+
total_estimated_savings INTEGER NOT NULL DEFAULT 0
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
CREATE TABLE IF NOT EXISTS ledger_sessions (
|
|
133
|
+
session_id TEXT PRIMARY KEY,
|
|
134
|
+
device_id TEXT NOT NULL,
|
|
135
|
+
start_timestamp TEXT NOT NULL,
|
|
136
|
+
end_timestamp TEXT NOT NULL,
|
|
137
|
+
read_count INTEGER NOT NULL DEFAULT 0,
|
|
138
|
+
write_count INTEGER NOT NULL DEFAULT 0,
|
|
139
|
+
estimated_tokens INTEGER NOT NULL DEFAULT 0,
|
|
140
|
+
repeated_reads INTEGER NOT NULL DEFAULT 0,
|
|
141
|
+
file_index_hits INTEGER NOT NULL DEFAULT 0,
|
|
142
|
+
file_index_misses INTEGER NOT NULL DEFAULT 0,
|
|
143
|
+
estimated_savings INTEGER NOT NULL DEFAULT 0,
|
|
144
|
+
archived INTEGER NOT NULL DEFAULT 0
|
|
145
|
+
);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_sessions_start ON ledger_sessions(start_timestamp);
|
|
147
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_sessions_device ON ledger_sessions(device_id);
|
|
148
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_sessions_archived ON ledger_sessions(archived);
|
|
149
|
+
|
|
150
|
+
CREATE TABLE IF NOT EXISTS ledger_reads (
|
|
151
|
+
session_id TEXT NOT NULL REFERENCES ledger_sessions(session_id) ON DELETE CASCADE,
|
|
152
|
+
file_path TEXT NOT NULL,
|
|
153
|
+
estimated_tokens INTEGER NOT NULL DEFAULT 0,
|
|
154
|
+
read_count INTEGER NOT NULL DEFAULT 0
|
|
155
|
+
);
|
|
156
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_reads_session ON ledger_reads(session_id);
|
|
157
|
+
|
|
158
|
+
CREATE TABLE IF NOT EXISTS ledger_writes (
|
|
159
|
+
session_id TEXT NOT NULL REFERENCES ledger_sessions(session_id) ON DELETE CASCADE,
|
|
160
|
+
file_path TEXT NOT NULL,
|
|
161
|
+
estimated_tokens INTEGER NOT NULL DEFAULT 0,
|
|
162
|
+
action TEXT NOT NULL
|
|
163
|
+
);
|
|
164
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_writes_session ON ledger_writes(session_id);
|
|
165
|
+
|
|
166
|
+
CREATE TABLE IF NOT EXISTS waste_flags (
|
|
167
|
+
pattern TEXT NOT NULL,
|
|
168
|
+
detected_at TEXT NOT NULL,
|
|
169
|
+
details TEXT,
|
|
170
|
+
device_id TEXT NOT NULL,
|
|
171
|
+
PRIMARY KEY (pattern, detected_at, device_id)
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
CREATE TABLE IF NOT EXISTS counters (
|
|
175
|
+
device_id TEXT PRIMARY KEY,
|
|
176
|
+
file_index_hits INTEGER NOT NULL DEFAULT 0,
|
|
177
|
+
file_index_misses INTEGER NOT NULL DEFAULT 0
|
|
178
|
+
);
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
export interface DriverForSchema {
|
|
182
|
+
exec(sql: string): void;
|
|
183
|
+
prepare(sql: string): {
|
|
184
|
+
run(...params: unknown[]): { changes: number | bigint; lastInsertRowid: number | bigint };
|
|
185
|
+
get(...params: unknown[]): Record<string, unknown> | undefined;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function applySchema(db: DriverForSchema): void {
|
|
190
|
+
db.exec(INITIAL_SCHEMA);
|
|
191
|
+
db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
|
|
192
|
+
"schema_version",
|
|
193
|
+
String(SCHEMA_VERSION)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function readMeta(db: DriverForSchema, key: string): string | null {
|
|
198
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
199
|
+
if (!row) return null;
|
|
200
|
+
return String((row as Record<string, unknown>).value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function writeMeta(db: DriverForSchema, key: string, value: string): void {
|
|
204
|
+
db.prepare(
|
|
205
|
+
"INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
206
|
+
).run(key, value);
|
|
207
|
+
}
|
package/src/types/file-index.ts
CHANGED
|
@@ -36,3 +36,12 @@ export interface ScannedFile {
|
|
|
36
36
|
relativePath: string;
|
|
37
37
|
mtimeMs: number;
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
// Minimal contract analyzers in the hook hot path depend on. Both the
|
|
41
|
+
// SQLite-backed FileIndexRepo and the in-memory adapter built from a
|
|
42
|
+
// FileIndex object implement this. Restricting the analyzer signatures to
|
|
43
|
+
// this surface keeps "load the whole index just to satisfy a type" off
|
|
44
|
+
// the critical path for projects with 20k+ files.
|
|
45
|
+
export interface IndexLookup {
|
|
46
|
+
lookupEntry(filePath: string): FileIndexEntry | null;
|
|
47
|
+
}
|
|
File without changes
|
/package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 9ElzGFcXpcjLq-QSQslWY}/_ssgManifest.js
RENAMED
|
File without changes
|