litmus-cli 1.4.2 → 1.4.3
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/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +56 -28
- package/dist/commands/doctor.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/backfill-tools.d.ts +126 -0
- package/dist/lib/backfill-tools.d.ts.map +1 -0
- package/dist/lib/backfill-tools.js +787 -0
- package/dist/lib/backfill-tools.js.map +1 -0
- package/dist/lib/backfill.d.ts +20 -2
- package/dist/lib/backfill.d.ts.map +1 -1
- package/dist/lib/backfill.js +14 -8
- package/dist/lib/backfill.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
import { execFileSync } from "child_process";
|
|
6
|
+
import { createRequire } from "module";
|
|
7
|
+
import { MAX_PROMPT_LENGTH, MAX_RESPONSE_LENGTH, MAX_TRANSCRIPT_BYTES, fitSerialized, sessionMatchesAssessment, truncate, windowEvents, } from "./backfill.js";
|
|
8
|
+
function emptyScan(source, tool) {
|
|
9
|
+
return { source, tool, notes: [], sessions: [], scannedFiles: 0, skippedOversized: [], unreadable: [] };
|
|
10
|
+
}
|
|
11
|
+
function promptEvent(source, tool, ts, text, sessionId, approx = false) {
|
|
12
|
+
const event = {
|
|
13
|
+
ts,
|
|
14
|
+
type: "ai_prompt",
|
|
15
|
+
tool,
|
|
16
|
+
prompt: truncate(text, MAX_PROMPT_LENGTH),
|
|
17
|
+
backfilled: true,
|
|
18
|
+
backfillSource: source,
|
|
19
|
+
};
|
|
20
|
+
if (sessionId)
|
|
21
|
+
event.sessionId = sessionId;
|
|
22
|
+
if (approx)
|
|
23
|
+
event.tsApproximate = true;
|
|
24
|
+
return fitSerialized(event);
|
|
25
|
+
}
|
|
26
|
+
function responseEvent(source, tool, ts, text, sessionId, approx = false) {
|
|
27
|
+
const event = {
|
|
28
|
+
ts,
|
|
29
|
+
type: "ai_response",
|
|
30
|
+
tool,
|
|
31
|
+
text: truncate(text, MAX_RESPONSE_LENGTH),
|
|
32
|
+
backfilled: true,
|
|
33
|
+
backfillSource: source,
|
|
34
|
+
};
|
|
35
|
+
if (sessionId)
|
|
36
|
+
event.sessionId = sessionId;
|
|
37
|
+
if (approx)
|
|
38
|
+
event.tsApproximate = true;
|
|
39
|
+
return fitSerialized(event);
|
|
40
|
+
}
|
|
41
|
+
function asRecord(v) {
|
|
42
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
43
|
+
}
|
|
44
|
+
function str(v) {
|
|
45
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
46
|
+
}
|
|
47
|
+
function listDirs(dir) {
|
|
48
|
+
try {
|
|
49
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function listFiles(dir) {
|
|
56
|
+
try {
|
|
57
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isFile()).map((d) => d.name);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Read a file within the transcript size cap, recording the reason it was skipped. */
|
|
64
|
+
function readCapped(file, scan) {
|
|
65
|
+
let stat;
|
|
66
|
+
try {
|
|
67
|
+
stat = fs.statSync(file);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
scan.unreadable.push(file);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (stat.size > MAX_TRANSCRIPT_BYTES) {
|
|
74
|
+
scan.skippedOversized.push(file);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return fs.readFileSync(file, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
scan.unreadable.push(file);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function mtimeIso(file) {
|
|
86
|
+
try {
|
|
87
|
+
return fs.statSync(file).mtime.toISOString();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return new Date(0).toISOString();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function pushSession(scan, file, label, sessionId, events) {
|
|
94
|
+
if (events.length === 0)
|
|
95
|
+
return;
|
|
96
|
+
scan.sessions.push({
|
|
97
|
+
file,
|
|
98
|
+
label,
|
|
99
|
+
tool: scan.tool,
|
|
100
|
+
sessionId,
|
|
101
|
+
prompts: events.filter((e) => e.type === "ai_prompt").length,
|
|
102
|
+
responses: events.filter((e) => e.type === "ai_response").length,
|
|
103
|
+
events,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// ── Gemini CLI ────────────────────────────────────────────────────
|
|
107
|
+
export function defaultGeminiDir() {
|
|
108
|
+
return path.join(os.homedir(), ".gemini");
|
|
109
|
+
}
|
|
110
|
+
/** Project ids the Gemini registry attributes to roots inside the assessment. */
|
|
111
|
+
function geminiProjectIdsInScope(geminiDir, assessmentRoot) {
|
|
112
|
+
const ids = new Set();
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(geminiDir, "projects.json"), "utf8"));
|
|
115
|
+
const projects = asRecord(asRecord(parsed)?.projects);
|
|
116
|
+
for (const [root, entry] of Object.entries(projects ?? {})) {
|
|
117
|
+
const id = str(entry) ?? str(asRecord(entry)?.shortId) ?? str(asRecord(entry)?.id);
|
|
118
|
+
if (id && sessionMatchesAssessment([root], assessmentRoot))
|
|
119
|
+
ids.add(id);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch { /* no registry — legacy hash dirs below still apply */ }
|
|
123
|
+
// Legacy layout: the temp dir was keyed by sha256 of the resolved root.
|
|
124
|
+
const candidates = [path.resolve(assessmentRoot)];
|
|
125
|
+
try {
|
|
126
|
+
candidates.push(fs.realpathSync(assessmentRoot));
|
|
127
|
+
}
|
|
128
|
+
catch { /* deleted/unmaterialized root — resolve-only is fine */ }
|
|
129
|
+
for (const p of candidates) {
|
|
130
|
+
ids.add(crypto.createHash("sha256").update(p).digest("hex"));
|
|
131
|
+
}
|
|
132
|
+
return ids;
|
|
133
|
+
}
|
|
134
|
+
/** One chats/ file: either a JSON object with `messages`, or JSONL (metadata record first). */
|
|
135
|
+
export function parseGeminiChatFile(raw, isJsonl, fallbackTs) {
|
|
136
|
+
let sessionId = null;
|
|
137
|
+
const messages = [];
|
|
138
|
+
if (isJsonl) {
|
|
139
|
+
for (const line of raw.split("\n")) {
|
|
140
|
+
const trimmed = line.trim();
|
|
141
|
+
if (!trimmed)
|
|
142
|
+
continue;
|
|
143
|
+
let rec;
|
|
144
|
+
try {
|
|
145
|
+
rec = asRecord(JSON.parse(trimmed));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!rec || "$set" in rec)
|
|
151
|
+
continue;
|
|
152
|
+
if (!sessionId && str(rec.sessionId) && !rec.type) {
|
|
153
|
+
sessionId = str(rec.sessionId); // leading metadata record
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (str(rec.type) && rec.content !== undefined)
|
|
157
|
+
messages.push(rec);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
try {
|
|
162
|
+
const obj = asRecord(JSON.parse(raw));
|
|
163
|
+
sessionId = str(obj?.sessionId);
|
|
164
|
+
for (const m of Array.isArray(obj?.messages) ? obj.messages : []) {
|
|
165
|
+
const rec = asRecord(m);
|
|
166
|
+
if (rec)
|
|
167
|
+
messages.push(rec);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch { /* corrupt file — nothing extractable */ }
|
|
171
|
+
}
|
|
172
|
+
const events = [];
|
|
173
|
+
for (const msg of messages) {
|
|
174
|
+
const ts = str(msg.timestamp) ?? fallbackTs;
|
|
175
|
+
const content = str(msg.content);
|
|
176
|
+
if (!content)
|
|
177
|
+
continue; // synthetic/binary records carry no text
|
|
178
|
+
if (msg.type === "user")
|
|
179
|
+
events.push(promptEvent("gemini_transcript", "gemini", ts, content, sessionId));
|
|
180
|
+
else if (msg.type === "gemini")
|
|
181
|
+
events.push(responseEvent("gemini_transcript", "gemini", ts, content, sessionId));
|
|
182
|
+
}
|
|
183
|
+
return { sessionId, events };
|
|
184
|
+
}
|
|
185
|
+
export function collectGeminiSessions(opts) {
|
|
186
|
+
const scan = emptyScan("gemini_transcript", "gemini");
|
|
187
|
+
const tmpDir = path.join(opts.geminiDir, "tmp");
|
|
188
|
+
const available = new Set(listDirs(tmpDir));
|
|
189
|
+
if (available.size === 0)
|
|
190
|
+
return scan;
|
|
191
|
+
const inScope = opts.allSessions ? available : geminiProjectIdsInScope(opts.geminiDir, opts.assessmentRoot);
|
|
192
|
+
for (const id of inScope) {
|
|
193
|
+
if (!available.has(id))
|
|
194
|
+
continue;
|
|
195
|
+
const projectDir = path.join(tmpDir, id);
|
|
196
|
+
const chatSessionIds = new Set();
|
|
197
|
+
const chatsDir = path.join(projectDir, "chats");
|
|
198
|
+
for (const name of listFiles(chatsDir)) {
|
|
199
|
+
const isJsonl = name.endsWith(".jsonl");
|
|
200
|
+
if (!isJsonl && !name.endsWith(".json"))
|
|
201
|
+
continue;
|
|
202
|
+
const file = path.join(chatsDir, name);
|
|
203
|
+
scan.scannedFiles++;
|
|
204
|
+
const raw = readCapped(file, scan);
|
|
205
|
+
if (raw === null)
|
|
206
|
+
continue;
|
|
207
|
+
const parsed = parseGeminiChatFile(raw, isJsonl, mtimeIso(file));
|
|
208
|
+
if (parsed.sessionId)
|
|
209
|
+
chatSessionIds.add(parsed.sessionId);
|
|
210
|
+
pushSession(scan, file, path.relative(opts.geminiDir, file), parsed.sessionId, windowEvents(parsed.events, opts.sinceMs));
|
|
211
|
+
}
|
|
212
|
+
// logs.json is prompts-only; it covers sessions that predate chat
|
|
213
|
+
// recording (or had it disabled). Sessions already read from chats/
|
|
214
|
+
// are skipped so one prompt cannot arrive twice from the same store.
|
|
215
|
+
const logsFile = path.join(projectDir, "logs.json");
|
|
216
|
+
if (fs.existsSync(logsFile)) {
|
|
217
|
+
scan.scannedFiles++;
|
|
218
|
+
const raw = readCapped(logsFile, scan);
|
|
219
|
+
if (raw !== null) {
|
|
220
|
+
const bySession = new Map();
|
|
221
|
+
try {
|
|
222
|
+
for (const entry of JSON.parse(raw)) {
|
|
223
|
+
const rec = asRecord(entry);
|
|
224
|
+
if (!rec || rec.type !== "user")
|
|
225
|
+
continue;
|
|
226
|
+
const message = str(rec.message);
|
|
227
|
+
const sessionId = str(rec.sessionId);
|
|
228
|
+
if (!message || !sessionId || chatSessionIds.has(sessionId))
|
|
229
|
+
continue;
|
|
230
|
+
const ts = str(rec.timestamp) ?? mtimeIso(logsFile);
|
|
231
|
+
const list = bySession.get(sessionId) ?? [];
|
|
232
|
+
list.push(promptEvent("gemini_transcript", "gemini", ts, message, sessionId));
|
|
233
|
+
bySession.set(sessionId, list);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
scan.unreadable.push(logsFile);
|
|
238
|
+
}
|
|
239
|
+
for (const [sessionId, events] of bySession) {
|
|
240
|
+
pushSession(scan, logsFile, `${path.relative(opts.geminiDir, logsFile)}#${sessionId.slice(0, 8)}`, sessionId, windowEvents(events, opts.sinceMs));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return scan;
|
|
246
|
+
}
|
|
247
|
+
// ── Copilot CLI ───────────────────────────────────────────────────
|
|
248
|
+
export function defaultCopilotDir() {
|
|
249
|
+
return path.join(os.homedir(), ".copilot");
|
|
250
|
+
}
|
|
251
|
+
export function parseCopilotCliEvents(raw, fallbackTs) {
|
|
252
|
+
let sessionId = null;
|
|
253
|
+
const cwds = [];
|
|
254
|
+
const events = [];
|
|
255
|
+
for (const line of raw.split("\n")) {
|
|
256
|
+
const trimmed = line.trim();
|
|
257
|
+
if (!trimmed)
|
|
258
|
+
continue;
|
|
259
|
+
let rec;
|
|
260
|
+
try {
|
|
261
|
+
rec = asRecord(JSON.parse(trimmed));
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (!rec)
|
|
267
|
+
continue;
|
|
268
|
+
const data = asRecord(rec.data);
|
|
269
|
+
const ts = str(rec.timestamp) ?? fallbackTs;
|
|
270
|
+
if (rec.type === "session.start" && data) {
|
|
271
|
+
sessionId = str(data.sessionId) ?? sessionId;
|
|
272
|
+
const context = asRecord(data.context);
|
|
273
|
+
for (const key of ["cwd", "gitRoot"]) {
|
|
274
|
+
const p = str(context?.[key]);
|
|
275
|
+
if (p && !cwds.includes(p))
|
|
276
|
+
cwds.push(p);
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// `data.content` is the text as typed/produced; `transformedContent` is
|
|
281
|
+
// the wrapper-injected variant and is deliberately ignored.
|
|
282
|
+
if (rec.type === "user.message" && data) {
|
|
283
|
+
const content = str(data.content);
|
|
284
|
+
if (content)
|
|
285
|
+
events.push(promptEvent("copilot_cli_session", "copilot", ts, content, sessionId));
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (rec.type === "assistant.message" && data) {
|
|
289
|
+
const content = str(data.content);
|
|
290
|
+
if (content)
|
|
291
|
+
events.push(responseEvent("copilot_cli_session", "copilot", ts, content, sessionId));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { sessionId, cwds, events };
|
|
295
|
+
}
|
|
296
|
+
/** Fallback cwd source when a session's events.jsonl lacks session.start. */
|
|
297
|
+
function copilotWorkspaceYamlCwds(sessionDir) {
|
|
298
|
+
const cwds = [];
|
|
299
|
+
try {
|
|
300
|
+
const raw = fs.readFileSync(path.join(sessionDir, "workspace.yaml"), "utf8");
|
|
301
|
+
for (const key of ["cwd", "git_root"]) {
|
|
302
|
+
const m = raw.match(new RegExp(`^${key}:\\s*['"]?([^'"\\n]+)['"]?\\s*$`, "m"));
|
|
303
|
+
if (m && m[1] && !cwds.includes(m[1]))
|
|
304
|
+
cwds.push(m[1]);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch { /* no workspace.yaml — session stays unattributed */ }
|
|
308
|
+
return cwds;
|
|
309
|
+
}
|
|
310
|
+
export function collectCopilotCliSessions(opts) {
|
|
311
|
+
const scan = emptyScan("copilot_cli_session", "copilot");
|
|
312
|
+
const stateDir = path.join(opts.copilotDir, "session-state");
|
|
313
|
+
for (const name of listDirs(stateDir)) {
|
|
314
|
+
const sessionDir = path.join(stateDir, name);
|
|
315
|
+
const file = path.join(sessionDir, "events.jsonl");
|
|
316
|
+
if (!fs.existsSync(file))
|
|
317
|
+
continue;
|
|
318
|
+
scan.scannedFiles++;
|
|
319
|
+
const raw = readCapped(file, scan);
|
|
320
|
+
if (raw === null)
|
|
321
|
+
continue;
|
|
322
|
+
const parsed = parseCopilotCliEvents(raw, mtimeIso(file));
|
|
323
|
+
if (parsed.events.length === 0)
|
|
324
|
+
continue;
|
|
325
|
+
const cwds = parsed.cwds.length > 0 ? parsed.cwds : copilotWorkspaceYamlCwds(sessionDir);
|
|
326
|
+
if (!opts.allSessions && !sessionMatchesAssessment(cwds, opts.assessmentRoot))
|
|
327
|
+
continue;
|
|
328
|
+
pushSession(scan, file, path.relative(opts.copilotDir, file), parsed.sessionId ?? name, windowEvents(parsed.events, opts.sinceMs));
|
|
329
|
+
}
|
|
330
|
+
return scan;
|
|
331
|
+
}
|
|
332
|
+
// ── VS Code Copilot Chat ──────────────────────────────────────────
|
|
333
|
+
/** workspaceStorage roots for VS Code stable + insiders on this platform. */
|
|
334
|
+
export function defaultVsCodeStorageDirs() {
|
|
335
|
+
const home = os.homedir();
|
|
336
|
+
const bases = [];
|
|
337
|
+
if (process.platform === "darwin") {
|
|
338
|
+
bases.push(path.join(home, "Library", "Application Support"));
|
|
339
|
+
}
|
|
340
|
+
else if (process.platform === "win32") {
|
|
341
|
+
if (process.env.APPDATA)
|
|
342
|
+
bases.push(process.env.APPDATA);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
bases.push(path.join(home, ".config"));
|
|
346
|
+
}
|
|
347
|
+
return bases.flatMap((base) => [
|
|
348
|
+
path.join(base, "Code", "User", "workspaceStorage"),
|
|
349
|
+
path.join(base, "Code - Insiders", "User", "workspaceStorage"),
|
|
350
|
+
]);
|
|
351
|
+
}
|
|
352
|
+
/** file:// workspace URI → local path; null for remote (ssh/devcontainer) URIs. */
|
|
353
|
+
export function fileUriToPath(uri) {
|
|
354
|
+
if (!uri.startsWith("file://"))
|
|
355
|
+
return null;
|
|
356
|
+
let p;
|
|
357
|
+
try {
|
|
358
|
+
p = decodeURIComponent(uri.slice("file://".length));
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
// Windows drive URIs arrive as file:///c:/...
|
|
364
|
+
if (process.platform === "win32" && /^\/[a-zA-Z]:/.test(p))
|
|
365
|
+
p = p.slice(1);
|
|
366
|
+
return p || null;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Replay a chat session append-log into its final state. Entry kinds:
|
|
370
|
+
* 0 = full initial state, 1 = set value at path `k`, 2 = append to the array
|
|
371
|
+
* at path `k`. Unknown kinds are ignored (additive format churn must not
|
|
372
|
+
* break recovery of the parts we understand).
|
|
373
|
+
*/
|
|
374
|
+
export function replayChatSessionLog(raw) {
|
|
375
|
+
let state = null;
|
|
376
|
+
const setPath = (root, keys, value, append) => {
|
|
377
|
+
let node = root;
|
|
378
|
+
for (let i = 0; i < keys.length - (append ? 0 : 1); i++) {
|
|
379
|
+
const key = keys[i];
|
|
380
|
+
const parent = node;
|
|
381
|
+
const idx = typeof key === "number" ? key : String(key);
|
|
382
|
+
let next = Array.isArray(node) ? node[key] : parent[idx];
|
|
383
|
+
if (next === undefined || next === null || typeof next !== "object") {
|
|
384
|
+
const following = i + 1 < keys.length ? keys[i + 1] : null;
|
|
385
|
+
next = typeof following === "number" ? [] : {};
|
|
386
|
+
if (Array.isArray(node))
|
|
387
|
+
node[key] = next;
|
|
388
|
+
else
|
|
389
|
+
parent[idx] = next;
|
|
390
|
+
}
|
|
391
|
+
node = next;
|
|
392
|
+
}
|
|
393
|
+
if (append) {
|
|
394
|
+
if (!Array.isArray(node))
|
|
395
|
+
return; // path exists but is not an array — malformed entry
|
|
396
|
+
if (Array.isArray(value))
|
|
397
|
+
node.push(...value);
|
|
398
|
+
else
|
|
399
|
+
node.push(value);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const last = keys[keys.length - 1];
|
|
403
|
+
if (Array.isArray(node))
|
|
404
|
+
node[last] = value;
|
|
405
|
+
else
|
|
406
|
+
node[String(last)] = value;
|
|
407
|
+
};
|
|
408
|
+
for (const line of raw.split("\n")) {
|
|
409
|
+
const trimmed = line.trim();
|
|
410
|
+
if (!trimmed)
|
|
411
|
+
continue;
|
|
412
|
+
let entry;
|
|
413
|
+
try {
|
|
414
|
+
entry = asRecord(JSON.parse(trimmed));
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (!entry)
|
|
420
|
+
continue;
|
|
421
|
+
if (entry.kind === 0) {
|
|
422
|
+
state = asRecord(entry.v) ?? state;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (!state || !Array.isArray(entry.k))
|
|
426
|
+
continue;
|
|
427
|
+
if (entry.kind === 1)
|
|
428
|
+
setPath(state, entry.k, entry.v, false);
|
|
429
|
+
else if (entry.kind === 2)
|
|
430
|
+
setPath(state, entry.k, entry.v, true);
|
|
431
|
+
}
|
|
432
|
+
return state;
|
|
433
|
+
}
|
|
434
|
+
function epochToIso(v, fallback) {
|
|
435
|
+
return typeof v === "number" && isFinite(v) && v > 0 ? new Date(v).toISOString() : fallback;
|
|
436
|
+
}
|
|
437
|
+
export function extractChatSessionEvents(state, fallbackTs) {
|
|
438
|
+
const sessionId = str(state.sessionId);
|
|
439
|
+
const events = [];
|
|
440
|
+
for (const item of Array.isArray(state.requests) ? state.requests : []) {
|
|
441
|
+
const req = asRecord(item);
|
|
442
|
+
if (!req)
|
|
443
|
+
continue;
|
|
444
|
+
const ts = epochToIso(req.timestamp, fallbackTs);
|
|
445
|
+
const text = str(asRecord(req.message)?.text);
|
|
446
|
+
if (text)
|
|
447
|
+
events.push(promptEvent("copilot_chat_session", "copilot", ts, text, sessionId));
|
|
448
|
+
const parts = [];
|
|
449
|
+
for (const respItem of Array.isArray(req.response) ? req.response : []) {
|
|
450
|
+
const rec = asRecord(respItem);
|
|
451
|
+
if (!rec)
|
|
452
|
+
continue;
|
|
453
|
+
if (rec.kind === "markdownContent") {
|
|
454
|
+
const value = str(asRecord(rec.content)?.value) ?? str(rec.value);
|
|
455
|
+
if (value)
|
|
456
|
+
parts.push(value);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
// Assistant markdown most commonly serializes as a bare IMarkdownString
|
|
460
|
+
// ({value, isTrusted, supportThemeIcons, ...}) with NO kind field —
|
|
461
|
+
// every kinded part (thinking, toolInvocationSerialized, references)
|
|
462
|
+
// is something other than the reply text and is skipped.
|
|
463
|
+
if (rec.kind === undefined) {
|
|
464
|
+
const value = str(rec.value);
|
|
465
|
+
const markdownMarkers = "isTrusted" in rec || "supportThemeIcons" in rec || "supportHtml" in rec;
|
|
466
|
+
if (value && (markdownMarkers || Object.keys(rec).length === 1))
|
|
467
|
+
parts.push(value);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (parts.length > 0) {
|
|
471
|
+
events.push(responseEvent("copilot_chat_session", "copilot", epochToIso(req.responseTimestamp, ts), parts.join("\n\n"), sessionId));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { sessionId, events };
|
|
475
|
+
}
|
|
476
|
+
export function collectCopilotChatSessions(opts) {
|
|
477
|
+
const scan = emptyScan("copilot_chat_session", "copilot");
|
|
478
|
+
for (const storageDir of opts.storageDirs) {
|
|
479
|
+
for (const hash of listDirs(storageDir)) {
|
|
480
|
+
const wsDir = path.join(storageDir, hash);
|
|
481
|
+
let folder = null;
|
|
482
|
+
try {
|
|
483
|
+
const ws = asRecord(JSON.parse(fs.readFileSync(path.join(wsDir, "workspace.json"), "utf8")));
|
|
484
|
+
folder = fileUriToPath(str(ws?.folder) ?? "");
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
continue; // no workspace.json — not a workspace dir
|
|
488
|
+
}
|
|
489
|
+
if (!folder)
|
|
490
|
+
continue; // remote workspace: sessions live on the remote host
|
|
491
|
+
if (!opts.allSessions && !sessionMatchesAssessment([folder], opts.assessmentRoot))
|
|
492
|
+
continue;
|
|
493
|
+
const chatDir = path.join(wsDir, "chatSessions");
|
|
494
|
+
for (const name of listFiles(chatDir)) {
|
|
495
|
+
if (!name.endsWith(".jsonl") && !name.endsWith(".json"))
|
|
496
|
+
continue;
|
|
497
|
+
const file = path.join(chatDir, name);
|
|
498
|
+
scan.scannedFiles++;
|
|
499
|
+
const raw = readCapped(file, scan);
|
|
500
|
+
if (raw === null)
|
|
501
|
+
continue;
|
|
502
|
+
const state = name.endsWith(".jsonl")
|
|
503
|
+
? replayChatSessionLog(raw)
|
|
504
|
+
: (() => { try {
|
|
505
|
+
return asRecord(JSON.parse(raw));
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
return null;
|
|
509
|
+
} })();
|
|
510
|
+
if (!state) {
|
|
511
|
+
scan.unreadable.push(file);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const parsed = extractChatSessionEvents(state, mtimeIso(file));
|
|
515
|
+
pushSession(scan, file, path.relative(storageDir, file), parsed.sessionId, windowEvents(parsed.events, opts.sinceMs));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return scan;
|
|
520
|
+
}
|
|
521
|
+
// ── Cursor ────────────────────────────────────────────────────────
|
|
522
|
+
export function defaultCursorUserDir() {
|
|
523
|
+
const home = os.homedir();
|
|
524
|
+
if (process.platform === "darwin")
|
|
525
|
+
return path.join(home, "Library", "Application Support", "Cursor", "User");
|
|
526
|
+
if (process.platform === "win32")
|
|
527
|
+
return process.env.APPDATA ? path.join(process.env.APPDATA, "Cursor", "User") : null;
|
|
528
|
+
return path.join(home, ".config", "Cursor", "User");
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Read-only SQLite rows via the system `sqlite3` binary (`-readonly -json`),
|
|
532
|
+
* falling back to `node:sqlite` where the runtime ships it. Returns null when
|
|
533
|
+
* no reader exists or the database cannot be read — the caller reports it.
|
|
534
|
+
*/
|
|
535
|
+
export const systemSqliteReader = (dbPath, sql) => {
|
|
536
|
+
try {
|
|
537
|
+
const out = execFileSync("sqlite3", ["-readonly", "-json", dbPath, sql], {
|
|
538
|
+
encoding: "utf8",
|
|
539
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
540
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
541
|
+
});
|
|
542
|
+
const trimmed = out.trim();
|
|
543
|
+
if (!trimmed)
|
|
544
|
+
return [];
|
|
545
|
+
const rows = JSON.parse(trimmed);
|
|
546
|
+
return Array.isArray(rows) ? rows : null;
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
const noBinary = err?.code === "ENOENT";
|
|
550
|
+
if (!noBinary)
|
|
551
|
+
return null; // binary exists but the read failed
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
const require = createRequire(import.meta.url);
|
|
555
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
556
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
557
|
+
try {
|
|
558
|
+
return db.prepare(sql).all();
|
|
559
|
+
}
|
|
560
|
+
finally {
|
|
561
|
+
db.close();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
function sqlQuote(v) {
|
|
569
|
+
return `'${v.replace(/'/g, "''")}'`;
|
|
570
|
+
}
|
|
571
|
+
function chunk(items, size) {
|
|
572
|
+
const out = [];
|
|
573
|
+
for (let i = 0; i < items.length; i += size)
|
|
574
|
+
out.push(items.slice(i, i + size));
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
/** Composer ids a workspace's own store names (its allComposers + selection history). */
|
|
578
|
+
export function cursorComposerIdsFromWorkspaceValue(value) {
|
|
579
|
+
const ids = new Set();
|
|
580
|
+
try {
|
|
581
|
+
const data = asRecord(JSON.parse(value));
|
|
582
|
+
if (!data)
|
|
583
|
+
return [];
|
|
584
|
+
for (const entry of Array.isArray(data.allComposers) ? data.allComposers : []) {
|
|
585
|
+
const id = str(asRecord(entry)?.composerId);
|
|
586
|
+
if (id)
|
|
587
|
+
ids.add(id);
|
|
588
|
+
}
|
|
589
|
+
for (const key of ["selectedComposerIds", "lastFocusedComposerIds"]) {
|
|
590
|
+
for (const id of Array.isArray(data[key]) ? data[key] : []) {
|
|
591
|
+
if (str(id))
|
|
592
|
+
ids.add(id);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch { /* malformed workspace record — contributes nothing */ }
|
|
597
|
+
return [...ids];
|
|
598
|
+
}
|
|
599
|
+
export function collectCursorSessions(opts) {
|
|
600
|
+
const scan = emptyScan("cursor_composer", "cursor");
|
|
601
|
+
if (!opts.cursorUserDir)
|
|
602
|
+
return scan;
|
|
603
|
+
const globalDb = path.join(opts.cursorUserDir, "globalStorage", "state.vscdb");
|
|
604
|
+
if (!fs.existsSync(globalDb))
|
|
605
|
+
return scan;
|
|
606
|
+
const readSqlite = opts.readSqlite ?? systemSqliteReader;
|
|
607
|
+
// Workspace side: which composers belong to folders inside the assessment.
|
|
608
|
+
const composerIds = new Set();
|
|
609
|
+
let anyWorkspaceRead = false;
|
|
610
|
+
const wsRoot = path.join(opts.cursorUserDir, "workspaceStorage");
|
|
611
|
+
for (const hash of listDirs(wsRoot)) {
|
|
612
|
+
const wsDir = path.join(wsRoot, hash);
|
|
613
|
+
let folder = null;
|
|
614
|
+
try {
|
|
615
|
+
const ws = asRecord(JSON.parse(fs.readFileSync(path.join(wsDir, "workspace.json"), "utf8")));
|
|
616
|
+
folder = fileUriToPath(str(ws?.folder) ?? "");
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (!folder)
|
|
622
|
+
continue;
|
|
623
|
+
if (!opts.allSessions && !sessionMatchesAssessment([folder], opts.assessmentRoot))
|
|
624
|
+
continue;
|
|
625
|
+
const wsDb = path.join(wsDir, "state.vscdb");
|
|
626
|
+
if (!fs.existsSync(wsDb))
|
|
627
|
+
continue;
|
|
628
|
+
const rows = readSqlite(wsDb, "SELECT value FROM ItemTable WHERE key = 'composer.composerData'");
|
|
629
|
+
if (rows === null) {
|
|
630
|
+
scan.unreadable.push(wsDb);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
anyWorkspaceRead = true;
|
|
634
|
+
for (const row of rows) {
|
|
635
|
+
const value = str(row.value);
|
|
636
|
+
if (value)
|
|
637
|
+
for (const id of cursorComposerIdsFromWorkspaceValue(value))
|
|
638
|
+
composerIds.add(id);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (composerIds.size === 0) {
|
|
642
|
+
if (!anyWorkspaceRead && scan.unreadable.length > 0) {
|
|
643
|
+
scan.notes.push("Cursor history exists but could not be read (no usable SQLite reader on this machine)");
|
|
644
|
+
}
|
|
645
|
+
return scan;
|
|
646
|
+
}
|
|
647
|
+
const fallbackTs = mtimeIso(globalDb);
|
|
648
|
+
for (const idBatch of chunk([...composerIds], 40)) {
|
|
649
|
+
const keys = idBatch.map((id) => sqlQuote(`composerData:${id}`)).join(", ");
|
|
650
|
+
const rows = readSqlite(globalDb, `SELECT key, value FROM cursorDiskKV WHERE key IN (${keys})`);
|
|
651
|
+
if (rows === null) {
|
|
652
|
+
scan.unreadable.push(globalDb);
|
|
653
|
+
scan.notes.push("Cursor history exists but could not be read (no usable SQLite reader on this machine)");
|
|
654
|
+
return scan;
|
|
655
|
+
}
|
|
656
|
+
for (const row of rows) {
|
|
657
|
+
scan.scannedFiles++;
|
|
658
|
+
const composerId = str(row.key)?.slice("composerData:".length) ?? "";
|
|
659
|
+
const composer = asRecord((() => { try {
|
|
660
|
+
return JSON.parse(str(row.value) ?? "");
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return null;
|
|
664
|
+
} })());
|
|
665
|
+
if (!composerId || !composer)
|
|
666
|
+
continue;
|
|
667
|
+
const createdAtMs = typeof composer.createdAt === "number" ? composer.createdAt : Date.parse(fallbackTs);
|
|
668
|
+
// Bubbles carry no timestamps: the whole conversation is windowed by its
|
|
669
|
+
// session's creation time, and events are stamped createdAt + index ms.
|
|
670
|
+
if (opts.sinceMs !== null && createdAtMs < opts.sinceMs)
|
|
671
|
+
continue;
|
|
672
|
+
// Conversation order: fullConversationHeadersOnly (bubble store), or a
|
|
673
|
+
// legacy inline `conversation` array of the same bubble shapes.
|
|
674
|
+
const headers = (Array.isArray(composer.fullConversationHeadersOnly) ? composer.fullConversationHeadersOnly : []);
|
|
675
|
+
let bubbles;
|
|
676
|
+
if (headers.length > 0) {
|
|
677
|
+
const bubbleById = new Map();
|
|
678
|
+
const bubbleIds = headers.map((h) => str(asRecord(h)?.bubbleId)).filter((v) => v !== null);
|
|
679
|
+
let bubbleReadFailed = false;
|
|
680
|
+
for (const bidBatch of chunk(bubbleIds, 40)) {
|
|
681
|
+
const bubbleKeys = bidBatch.map((bid) => sqlQuote(`bubbleId:${composerId}:${bid}`)).join(", ");
|
|
682
|
+
const bubbleRows = readSqlite(globalDb, `SELECT key, value FROM cursorDiskKV WHERE key IN (${bubbleKeys})`);
|
|
683
|
+
if (bubbleRows === null) {
|
|
684
|
+
bubbleReadFailed = true;
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
for (const bubbleRow of bubbleRows) {
|
|
688
|
+
const bid = str(bubbleRow.key)?.split(":")[2];
|
|
689
|
+
const bubble = asRecord((() => { try {
|
|
690
|
+
return JSON.parse(str(bubbleRow.value) ?? "");
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
return null;
|
|
694
|
+
} })());
|
|
695
|
+
if (bid && bubble)
|
|
696
|
+
bubbleById.set(bid, bubble);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (bubbleReadFailed) {
|
|
700
|
+
// The composerData batch read fine, so a reader exists — this is a
|
|
701
|
+
// failed read on the same store mid-run. A partially-read
|
|
702
|
+
// conversation would misrepresent the session, so skip it WHOLE,
|
|
703
|
+
// report it, and let the remaining composers still try.
|
|
704
|
+
if (!scan.unreadable.includes(globalDb))
|
|
705
|
+
scan.unreadable.push(globalDb);
|
|
706
|
+
const note = "some Cursor conversations could not be read and were skipped";
|
|
707
|
+
if (!scan.notes.includes(note))
|
|
708
|
+
scan.notes.push(note);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
// A healthy store keeps headers and bubble rows consistent (measured:
|
|
712
|
+
// zero gaps across a large real store), so a hole here means pruned
|
|
713
|
+
// or corrupt rows. Recover the messages that DO exist — discarding a
|
|
714
|
+
// conversation's surviving prompts because sibling rows are gone
|
|
715
|
+
// would be under-capture — and disclose the gap, same as every other
|
|
716
|
+
// store's partially-readable case.
|
|
717
|
+
if (bubbleIds.some((bid) => !bubbleById.has(bid))) {
|
|
718
|
+
const note = "some Cursor conversations are missing messages in the store; recovered what exists";
|
|
719
|
+
if (!scan.notes.includes(note))
|
|
720
|
+
scan.notes.push(note);
|
|
721
|
+
}
|
|
722
|
+
bubbles = bubbleIds.map((bid) => bubbleById.get(bid)).filter((b) => b !== undefined);
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
bubbles = (Array.isArray(composer.conversation) ? composer.conversation : [])
|
|
726
|
+
.map((b) => asRecord(b))
|
|
727
|
+
.filter((b) => b !== null);
|
|
728
|
+
}
|
|
729
|
+
const events = [];
|
|
730
|
+
for (const bubble of bubbles) {
|
|
731
|
+
const text = str(bubble.text);
|
|
732
|
+
if (!text)
|
|
733
|
+
continue; // context-only bubbles (attachments, tool results) carry no typed text
|
|
734
|
+
const ts = new Date(createdAtMs + events.length).toISOString();
|
|
735
|
+
if (bubble.type === 1)
|
|
736
|
+
events.push(promptEvent("cursor_composer", "cursor", ts, text, composerId, true));
|
|
737
|
+
else if (bubble.type === 2)
|
|
738
|
+
events.push(responseEvent("cursor_composer", "cursor", ts, text, composerId, true));
|
|
739
|
+
}
|
|
740
|
+
pushSession(scan, globalDb, `cursor composer ${composerId.slice(0, 8)}`, composerId, events);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return scan;
|
|
744
|
+
}
|
|
745
|
+
// ── Orchestrator ──────────────────────────────────────────────────
|
|
746
|
+
export function collectToolBackfillSources(opts) {
|
|
747
|
+
const scans = [];
|
|
748
|
+
const run = (collect, source, tool) => {
|
|
749
|
+
try {
|
|
750
|
+
scans.push(collect());
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
// A store must never be able to abort the whole recovery; an
|
|
754
|
+
// unexpectedly-shaped one reports itself and the rest still run.
|
|
755
|
+
const scan = emptyScan(source, tool);
|
|
756
|
+
scan.notes.push("store scan failed unexpectedly — nothing recovered from it");
|
|
757
|
+
scans.push(scan);
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
run(() => collectGeminiSessions({
|
|
761
|
+
geminiDir: opts.geminiDir ?? defaultGeminiDir(),
|
|
762
|
+
assessmentRoot: opts.assessmentRoot,
|
|
763
|
+
allSessions: opts.allSessions,
|
|
764
|
+
sinceMs: opts.sinceMs,
|
|
765
|
+
}), "gemini_transcript", "gemini");
|
|
766
|
+
run(() => collectCopilotCliSessions({
|
|
767
|
+
copilotDir: opts.copilotDir ?? defaultCopilotDir(),
|
|
768
|
+
assessmentRoot: opts.assessmentRoot,
|
|
769
|
+
allSessions: opts.allSessions,
|
|
770
|
+
sinceMs: opts.sinceMs,
|
|
771
|
+
}), "copilot_cli_session", "copilot");
|
|
772
|
+
run(() => collectCopilotChatSessions({
|
|
773
|
+
storageDirs: opts.vscodeStorageDirs ?? defaultVsCodeStorageDirs(),
|
|
774
|
+
assessmentRoot: opts.assessmentRoot,
|
|
775
|
+
allSessions: opts.allSessions,
|
|
776
|
+
sinceMs: opts.sinceMs,
|
|
777
|
+
}), "copilot_chat_session", "copilot");
|
|
778
|
+
run(() => collectCursorSessions({
|
|
779
|
+
cursorUserDir: opts.cursorUserDir === undefined ? defaultCursorUserDir() : opts.cursorUserDir,
|
|
780
|
+
assessmentRoot: opts.assessmentRoot,
|
|
781
|
+
allSessions: opts.allSessions,
|
|
782
|
+
sinceMs: opts.sinceMs,
|
|
783
|
+
readSqlite: opts.readSqlite,
|
|
784
|
+
}), "cursor_composer", "cursor");
|
|
785
|
+
return scans;
|
|
786
|
+
}
|
|
787
|
+
//# sourceMappingURL=backfill-tools.js.map
|