@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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// node:sqlite implementation of the DbDriver interface.
|
|
2
|
+
// Selected at build time when MINK_RUNTIME === "node" and via runtime
|
|
3
|
+
// detection when running unbundled under Node.
|
|
4
|
+
|
|
5
|
+
import type { DbDriver, DriverModule, SqlParam, Statement } from "./driver";
|
|
6
|
+
|
|
7
|
+
// Suppress Node 22's `ExperimentalWarning: SQLite is an experimental feature`
|
|
8
|
+
// the first (and only) time the module is required. We do it inline rather
|
|
9
|
+
// than via NODE_NO_WARNINGS so users don't lose warnings from other modules.
|
|
10
|
+
const originalEmit = process.emit;
|
|
11
|
+
process.emit = function patchedEmit(
|
|
12
|
+
this: NodeJS.Process,
|
|
13
|
+
event: string | symbol,
|
|
14
|
+
...args: unknown[]
|
|
15
|
+
): boolean {
|
|
16
|
+
if (
|
|
17
|
+
event === "warning" &&
|
|
18
|
+
args[0] instanceof Error &&
|
|
19
|
+
(args[0] as Error & { name: string }).name === "ExperimentalWarning" &&
|
|
20
|
+
/sqlite/i.test((args[0] as Error).message)
|
|
21
|
+
) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
return (originalEmit as any).call(this, event, ...args);
|
|
26
|
+
} as typeof process.emit;
|
|
27
|
+
|
|
28
|
+
const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite");
|
|
29
|
+
|
|
30
|
+
class NodeStatement implements Statement {
|
|
31
|
+
// node:sqlite uses StatementSync from its types
|
|
32
|
+
constructor(private readonly stmt: import("node:sqlite").StatementSync) {}
|
|
33
|
+
|
|
34
|
+
run(...params: SqlParam[]): { changes: number | bigint; lastInsertRowid: number | bigint } {
|
|
35
|
+
const r = this.stmt.run(...(params as never[]));
|
|
36
|
+
return { changes: r.changes, lastInsertRowid: r.lastInsertRowid };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get(...params: SqlParam[]) {
|
|
40
|
+
const row = this.stmt.get(...(params as never[]));
|
|
41
|
+
return (row ?? undefined) as Record<string, unknown> | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
all(...params: SqlParam[]) {
|
|
45
|
+
return this.stmt.all(...(params as never[])) as Record<string, unknown>[];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class NodeDriver implements DbDriver {
|
|
50
|
+
readonly filename: string;
|
|
51
|
+
private readonly db: import("node:sqlite").DatabaseSync;
|
|
52
|
+
private readonly txnDepth = { value: 0 };
|
|
53
|
+
|
|
54
|
+
constructor(filename: string) {
|
|
55
|
+
this.filename = filename;
|
|
56
|
+
this.db = new DatabaseSync(filename);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
prepare(sql: string): Statement {
|
|
60
|
+
return new NodeStatement(this.db.prepare(sql));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
exec(sql: string): void {
|
|
64
|
+
this.db.exec(sql);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
transaction<T>(fn: () => T): T {
|
|
68
|
+
if (this.txnDepth.value > 0) {
|
|
69
|
+
const sp = `sp_${this.txnDepth.value}`;
|
|
70
|
+
this.db.exec(`SAVEPOINT ${sp}`);
|
|
71
|
+
this.txnDepth.value++;
|
|
72
|
+
try {
|
|
73
|
+
const result = fn();
|
|
74
|
+
this.db.exec(`RELEASE SAVEPOINT ${sp}`);
|
|
75
|
+
this.txnDepth.value--;
|
|
76
|
+
return result;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
this.db.exec(`ROLLBACK TO SAVEPOINT ${sp}`);
|
|
79
|
+
this.db.exec(`RELEASE SAVEPOINT ${sp}`);
|
|
80
|
+
this.txnDepth.value--;
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
85
|
+
this.txnDepth.value++;
|
|
86
|
+
try {
|
|
87
|
+
const result = fn();
|
|
88
|
+
this.db.exec("COMMIT");
|
|
89
|
+
this.txnDepth.value--;
|
|
90
|
+
return result;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
this.db.exec("ROLLBACK");
|
|
93
|
+
this.txnDepth.value--;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
close(): void {
|
|
99
|
+
this.db.close();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pragma(stmt: string): unknown {
|
|
103
|
+
return this.db.prepare(`PRAGMA ${stmt}`).all();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const open: DriverModule["open"] = (filename) => new NodeDriver(filename);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Build-time-selected SQLite driver adapter. The actual import of
|
|
2
|
+
// `bun:sqlite` or `node:sqlite` happens in the runtime-specific
|
|
3
|
+
// `driver.bun.ts` / `driver.node.ts` siblings; this file picks one based on
|
|
4
|
+
// the `MINK_RUNTIME` define injected by `bun build --define`. When neither
|
|
5
|
+
// define is set (e.g. running TypeScript directly under `bun test`), it
|
|
6
|
+
// falls back to `typeof Bun` detection.
|
|
7
|
+
//
|
|
8
|
+
// The adapter exposes a stable 5-method surface — `prepare`, `exec`,
|
|
9
|
+
// `transaction`, `close`, `pragma` — chosen as the minimal set that covers
|
|
10
|
+
// every call site in `src/repositories/`. Both backends' `Statement.run/get/all`
|
|
11
|
+
// methods are compatible enough that we pass them through unchanged.
|
|
12
|
+
|
|
13
|
+
declare const MINK_RUNTIME: string | undefined;
|
|
14
|
+
|
|
15
|
+
export type SqlParam = string | number | bigint | Uint8Array | null;
|
|
16
|
+
export type SqlRow = Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
export interface Statement {
|
|
19
|
+
run(...params: SqlParam[]): { changes: number | bigint; lastInsertRowid: number | bigint };
|
|
20
|
+
get(...params: SqlParam[]): SqlRow | undefined;
|
|
21
|
+
all(...params: SqlParam[]): SqlRow[];
|
|
22
|
+
iterate?(...params: SqlParam[]): IterableIterator<SqlRow>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DbDriver {
|
|
26
|
+
prepare(sql: string): Statement;
|
|
27
|
+
exec(sql: string): void;
|
|
28
|
+
transaction<T>(fn: () => T): T;
|
|
29
|
+
close(): void;
|
|
30
|
+
pragma(stmt: string): unknown;
|
|
31
|
+
readonly filename: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DriverModule {
|
|
35
|
+
open(filename: string): DbDriver;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pickRuntime(): "bun" | "node" {
|
|
39
|
+
// `MINK_RUNTIME` is replaced at bundle time. When running source directly
|
|
40
|
+
// (tests, `bun src/cli.ts`), the symbol is undefined and we fall back to
|
|
41
|
+
// feature detection.
|
|
42
|
+
try {
|
|
43
|
+
if (typeof MINK_RUNTIME !== "undefined") {
|
|
44
|
+
if (MINK_RUNTIME === "bun" || MINK_RUNTIME === "node") return MINK_RUNTIME;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// ReferenceError when the symbol is not declared — proceed to detect.
|
|
48
|
+
}
|
|
49
|
+
return typeof Bun !== "undefined" ? "bun" : "node";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let cached: DriverModule | undefined;
|
|
53
|
+
|
|
54
|
+
function loadDriver(): DriverModule {
|
|
55
|
+
if (cached) return cached;
|
|
56
|
+
const runtime = pickRuntime();
|
|
57
|
+
// Conditional `require` keeps the unused branch out of the active bundle
|
|
58
|
+
// when `bun build` does constant-folding on `pickRuntime()`'s result via
|
|
59
|
+
// the `MINK_RUNTIME` define. At runtime, only the matching branch ever
|
|
60
|
+
// executes, so the other module's `import 'bun:sqlite'` /
|
|
61
|
+
// `import 'node:sqlite'` is never evaluated.
|
|
62
|
+
if (runtime === "bun") {
|
|
63
|
+
cached = require("./driver.bun") as DriverModule;
|
|
64
|
+
} else {
|
|
65
|
+
cached = require("./driver.node") as DriverModule;
|
|
66
|
+
}
|
|
67
|
+
return cached;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function openDriver(filename: string): DbDriver {
|
|
71
|
+
return loadDriver().open(filename);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function currentRuntime(): "bun" | "node" {
|
|
75
|
+
return pickRuntime();
|
|
76
|
+
}
|
|
@@ -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;
|