@luswt/parse-session 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +18 -0
  2. package/parse-session.mjs +446 -0
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@luswt/parse-session",
3
+ "version": "1.0.0",
4
+ "description": "Export AI agent sessions (opencode) from SQLite database to JSON",
5
+ "bin": {
6
+ "parse-session": "./parse-session.mjs"
7
+ },
8
+ "files": [
9
+ "parse-session.mjs"
10
+ ],
11
+ "dependencies": {
12
+ "sql.js": "^1.14.1"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "license": "MIT"
18
+ }
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env node
2
+ import init from "sql.js";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { execSync } from "child_process";
6
+
7
+ const DB_PATH =
8
+ process.env.OPENCODE_DB_PATH ||
9
+ path.join(
10
+ process.env.USERPROFILE || process.env.HOME,
11
+ ".local",
12
+ "share",
13
+ "opencode",
14
+ "opencode.db"
15
+ );
16
+
17
+ function parseRows(result) {
18
+ if (!result || !result.length || !result[0].values) return [];
19
+ const cols = result[0].columns;
20
+ return result[0].values.map((row) => {
21
+ const obj = {};
22
+ cols.forEach((c, i) => (obj[c] = row[i]));
23
+ return obj;
24
+ });
25
+ }
26
+
27
+ function getModelDisplayName(modelId) {
28
+ if (!modelId) return null;
29
+ return modelId.split("/").pop();
30
+ }
31
+
32
+ function formatDuration(ms) {
33
+ if (ms < 1000) return `${ms}ms`;
34
+ const s = Math.round(ms / 1000);
35
+ if (s < 60) return `${s}s`;
36
+ const m = Math.floor(s / 60);
37
+ const rem = s % 60;
38
+ if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`;
39
+ const h = Math.floor(m / 60);
40
+ return `${h}h ${m % 60}m`;
41
+ }
42
+
43
+ function tryGitDiffNumstat(oldRef, newRef, workDir) {
44
+ try {
45
+ const out = execSync(
46
+ `git diff-tree --numstat -r ${oldRef} ${newRef}`,
47
+ { cwd: workDir, encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
48
+ );
49
+ return out.trim();
50
+ } catch {}
51
+ try {
52
+ const out = execSync(
53
+ `git diff --numstat ${oldRef} ${newRef}`,
54
+ { cwd: workDir, encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
55
+ );
56
+ return out.trim();
57
+ } catch {}
58
+ return null;
59
+ }
60
+
61
+ function parseNumstat(output, workDir) {
62
+ const changes = [];
63
+ for (const line of output.split("\n")) {
64
+ if (!line.trim()) continue;
65
+ const parts = line.split("\t");
66
+ if (parts.length < 3) continue;
67
+ const [addStr, delStr, filePath] = parts;
68
+ if (!filePath) continue;
69
+ const additions = addStr === "-" ? 0 : parseInt(addStr, 10) || 0;
70
+ const deletions = delStr === "-" ? 0 : parseInt(delStr, 10) || 0;
71
+ let type = "modified";
72
+ if (additions > 0 && deletions === 0) type = "added";
73
+ else if (additions === 0 && deletions > 0) type = "deleted";
74
+ let clean = filePath.replace(/^"?(.+)"?$/, "$1");
75
+ const rel = path.relative(workDir, clean).replace(/\\/g, "/");
76
+ if (rel.startsWith("node_modules")) continue;
77
+ changes.push({ path: rel, type, additions, deletions });
78
+ }
79
+ return changes;
80
+ }
81
+
82
+ function buildChangesFromEdits(parsedParts, workDir) {
83
+ const fileStats = new Map();
84
+ const writePaths = new Set();
85
+
86
+ for (const pp of parsedParts) {
87
+ if (pp.parsed.type !== "tool") continue;
88
+ const tool = pp.parsed.tool;
89
+ const input = pp.parsed.state?.input;
90
+ if (!input) continue;
91
+
92
+ if (tool === "edit" && input.filePath) {
93
+ const rel = path.relative(workDir, input.filePath).replace(/\\/g, "/");
94
+ if (rel.startsWith("node_modules")) continue;
95
+ if (!fileStats.has(rel)) fileStats.set(rel, { additions: 0, deletions: 0, hasOld: false, hasNew: false });
96
+ const st = fileStats.get(rel);
97
+ const oldLines = (input.oldString || "").split("\n").length;
98
+ const newLines = (input.newString || "").split("\n").length;
99
+ st.additions += newLines;
100
+ st.deletions += oldLines;
101
+ st.hasOld = st.hasOld || input.oldString?.length > 0;
102
+ st.hasNew = st.hasNew || input.newString?.length > 0;
103
+ }
104
+
105
+ if (tool === "write" && input.filePath) {
106
+ const rel = path.relative(workDir, input.filePath).replace(/\\/g, "/");
107
+ if (rel.startsWith("node_modules")) continue;
108
+ writePaths.add(rel);
109
+ if (!fileStats.has(rel)) fileStats.set(rel, { additions: 0, deletions: 0, hasOld: false, hasNew: false });
110
+ const st = fileStats.get(rel);
111
+ const lines = (input.content || "").split("\n").length;
112
+ st.additions += lines;
113
+ st.hasNew = true;
114
+ }
115
+ }
116
+
117
+ return [...fileStats.entries()].map(([filePath, st]) => {
118
+ let type = "modified";
119
+ if (!st.hasOld && st.hasNew) type = "added";
120
+ else if (st.hasOld && !st.hasNew) type = "deleted";
121
+ else if (writePaths.has(filePath) && !st.hasOld) type = "added";
122
+ return { path: filePath, type, additions: st.additions, deletions: st.deletions };
123
+ });
124
+ }
125
+
126
+ function normalizePath(p) {
127
+ return path.resolve(p).replace(/\\/g, "/").toLowerCase();
128
+ }
129
+
130
+ function buildChangesFromPatches(session, patches) {
131
+ const allFiles = new Map();
132
+ for (const p of patches) {
133
+ if (!p.files) continue;
134
+ for (const f of p.files) {
135
+ const rel = path.relative(session.directory, f).replace(/\\/g, "/");
136
+ if (rel.startsWith("node_modules")) continue;
137
+ if (!allFiles.has(rel)) allFiles.set(rel, { additions: 0, deletions: 0 });
138
+ }
139
+ }
140
+ const totalFiles = allFiles.size || session.summary_files || 1;
141
+ const avgAdd = Math.round((session.summary_additions || 0) / totalFiles);
142
+ const avgDel = Math.round((session.summary_deletions || 0) / totalFiles);
143
+ if (allFiles.size === 0) {
144
+ if (session.summary_files > 0) {
145
+ return [
146
+ {
147
+ path: "(unknown)",
148
+ type: "modified",
149
+ additions: session.summary_additions || 0,
150
+ deletions: session.summary_deletions || 0,
151
+ },
152
+ ];
153
+ }
154
+ return [];
155
+ }
156
+ return [...allFiles.entries()].map(([filePath]) => ({
157
+ path: filePath,
158
+ type: avgAdd > 0 && avgDel === 0 ? "added" : avgAdd === 0 && avgDel > 0 ? "deleted" : "modified",
159
+ additions: avgAdd,
160
+ deletions: avgDel,
161
+ }));
162
+ }
163
+
164
+ async function getSessionInfo(sessionId) {
165
+ const SQL = await init();
166
+ const buf = fs.readFileSync(DB_PATH);
167
+ const db = new SQL.Database(buf);
168
+
169
+ try {
170
+ const sessionRows = parseRows(
171
+ db.exec(`SELECT * FROM session WHERE id = '${sessionId}'`)
172
+ );
173
+ if (!sessionRows.length) {
174
+ throw new Error(`Session "${sessionId}" not found`);
175
+ }
176
+ const session = sessionRows[0];
177
+
178
+ const messages = parseRows(
179
+ db.exec(
180
+ `SELECT id, time_created, data FROM message WHERE session_id = '${sessionId}' ORDER BY time_created`
181
+ )
182
+ );
183
+
184
+ const parts = parseRows(
185
+ db.exec(
186
+ `SELECT id, message_id, time_created, data FROM part WHERE session_id = '${sessionId}' ORDER BY time_created`
187
+ )
188
+ );
189
+
190
+ const parsedParts = parts.map((p) => ({
191
+ ...p,
192
+ parsed: JSON.parse(p.data),
193
+ }));
194
+
195
+ let tokensIn = 0;
196
+ let tokensOut = 0;
197
+ let totalTokens = 0;
198
+ let costUSD = 0;
199
+ let modelId = null;
200
+ let startTime = null;
201
+ let endTime = null;
202
+ let lastAssistantText = null;
203
+
204
+ for (const msg of messages) {
205
+ const d = JSON.parse(msg.data);
206
+ if (d.role === "assistant") {
207
+ tokensIn += d.tokens?.input || 0;
208
+ tokensOut += d.tokens?.output || 0;
209
+ totalTokens += (d.tokens?.input || 0) + (d.tokens?.output || 0);
210
+ costUSD += d.cost || 0;
211
+ if (!modelId && d.modelID) modelId = d.modelID;
212
+ if (d.time?.completed) {
213
+ if (!endTime || d.time.completed > endTime) endTime = d.time.completed;
214
+ }
215
+ }
216
+ if (d.time?.created) {
217
+ if (!startTime || d.time.created < startTime) startTime = d.time.created;
218
+ }
219
+ }
220
+
221
+ for (let i = parsedParts.length - 1; i >= 0; i--) {
222
+ const pp = parsedParts[i];
223
+ if (pp.parsed.type === "text") {
224
+ const parentMsg = messages.find((m) => m.id === pp.message_id);
225
+ if (parentMsg) {
226
+ const md = JSON.parse(parentMsg.data);
227
+ if (md.role === "assistant") {
228
+ lastAssistantText = pp.parsed.text;
229
+ break;
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ const durationMs =
236
+ endTime && startTime
237
+ ? endTime - startTime
238
+ : session.time_updated - session.time_created;
239
+
240
+ const modelRaw = session.model ? JSON.parse(session.model).id : modelId;
241
+ const modelName = getModelDisplayName(modelRaw);
242
+
243
+ const date = new Date(session.time_created).toISOString();
244
+ const durationMin = Math.ceil(durationMs / 60000);
245
+ const gitDiffUrl = session.share_url || null;
246
+
247
+ const patches = parsedParts.filter((p) => p.parsed.type === "patch").map((p) => p.parsed);
248
+
249
+ const stepStarts = parsedParts
250
+ .filter((p) => p.parsed.type === "step-start" && p.parsed.snapshot)
251
+ .map((p) => p.parsed.snapshot);
252
+
253
+ const stepFinishes = parsedParts
254
+ .filter((p) => p.parsed.type === "step-finish" && p.parsed.snapshot)
255
+ .map((p) => p.parsed.snapshot);
256
+
257
+ let changes = [];
258
+ const workDir = session.directory;
259
+
260
+ if (workDir && fs.existsSync(path.join(workDir, ".git"))) {
261
+ const initialSnapshot = stepStarts[0] || null;
262
+ const finalSnapshot = stepFinishes[stepFinishes.length - 1] || null;
263
+
264
+ if (initialSnapshot && finalSnapshot && initialSnapshot !== finalSnapshot) {
265
+ const diffOut = tryGitDiffNumstat(initialSnapshot, finalSnapshot, workDir);
266
+ if (diffOut) {
267
+ changes = parseNumstat(diffOut, workDir);
268
+ }
269
+ }
270
+
271
+ if (changes.length === 0 && initialSnapshot) {
272
+ const diffOut = tryGitDiffNumstat(initialSnapshot, "HEAD", workDir);
273
+ if (diffOut) {
274
+ changes = parseNumstat(diffOut, workDir);
275
+ }
276
+ }
277
+
278
+ if (changes.length === 0 && finalSnapshot) {
279
+ try {
280
+ const headTree = execSync("git rev-parse HEAD^{tree}", {
281
+ cwd: workDir,
282
+ encoding: "utf-8",
283
+ timeout: 5000,
284
+ stdio: ["pipe", "pipe", "pipe"],
285
+ }).trim();
286
+ if (headTree !== finalSnapshot) {
287
+ const diffOut = tryGitDiffNumstat(finalSnapshot, headTree, workDir);
288
+ if (diffOut) changes = parseNumstat(diffOut, workDir);
289
+ }
290
+ } catch {}
291
+ }
292
+
293
+ if (changes.length === 0 && patches.length > 0) {
294
+ const patchFiles = new Set();
295
+ for (const p of patches) {
296
+ if (!p.files) continue;
297
+ for (const f of p.files) {
298
+ const rel = path.relative(workDir, f).replace(/\\/g, "/");
299
+ if (!rel.startsWith("node_modules")) patchFiles.add(rel);
300
+ }
301
+ }
302
+ if (patchFiles.size > 0) {
303
+ try {
304
+ const diffOut = execSync(
305
+ `git diff --numstat HEAD -- ${[...patchFiles].map((f) => `"${f}"`).join(" ")}`,
306
+ { cwd: workDir, encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
307
+ ).trim();
308
+ if (diffOut) {
309
+ changes = parseNumstat(diffOut, workDir);
310
+ }
311
+ } catch {}
312
+ }
313
+ }
314
+ }
315
+
316
+ if (changes.length === 0 && parsedParts.length > 0) {
317
+ changes = buildChangesFromEdits(parsedParts, workDir);
318
+ }
319
+
320
+ if (changes.length === 0) {
321
+ changes = buildChangesFromPatches(session, patches);
322
+ }
323
+
324
+ const summary = session.title || "";
325
+
326
+ return {
327
+ model: modelName,
328
+ summary,
329
+ date,
330
+ duration: formatDuration(durationMs),
331
+ durationMinutes: durationMin,
332
+ tokensIn,
333
+ tokensOut,
334
+ totalTokens,
335
+ costUSD: Math.round(costUSD * 1000000) / 1000000,
336
+ gitDiffUrl,
337
+ changes,
338
+ };
339
+ } finally {
340
+ db.close();
341
+ }
342
+ }
343
+
344
+ async function listSessions(limit = 20) {
345
+ const SQL = await init();
346
+ const buf = fs.readFileSync(DB_PATH);
347
+ const db = new SQL.Database(buf);
348
+ try {
349
+ const rows = parseRows(
350
+ db.exec(
351
+ `SELECT id, title, time_created FROM session ORDER BY time_updated DESC LIMIT ${limit}`
352
+ )
353
+ );
354
+ return rows.map((r) => ({
355
+ id: r.id,
356
+ title: r.title,
357
+ date: new Date(r.time_created).toISOString(),
358
+ }));
359
+ } finally {
360
+ db.close();
361
+ }
362
+ }
363
+
364
+ async function findSessionsByDirectory(cwd) {
365
+ const SQL = await init();
366
+ const buf = fs.readFileSync(DB_PATH);
367
+ const db = new SQL.Database(buf);
368
+ try {
369
+ const rows = parseRows(
370
+ db.exec(
371
+ `SELECT id, directory, title, time_created, parent_id FROM session WHERE parent_id IS NULL ORDER BY time_updated DESC`
372
+ )
373
+ );
374
+ const normCwd = normalizePath(cwd);
375
+ return rows.filter((r) => {
376
+ const normDir = normalizePath(r.directory);
377
+ return normDir === normCwd || normCwd.startsWith(normDir + "/");
378
+ });
379
+ } finally {
380
+ db.close();
381
+ }
382
+ }
383
+
384
+ async function exportProjectSessions(agent) {
385
+ const cwd = process.cwd();
386
+ const sessions = await findSessionsByDirectory(cwd);
387
+
388
+ if (sessions.length === 0) {
389
+ console.log(`No sessions found for directory: ${cwd}`);
390
+ process.exit(0);
391
+ }
392
+
393
+ const exportDir = path.join(cwd, `export-${agent}`);
394
+ if (!fs.existsSync(exportDir)) fs.mkdirSync(exportDir, { recursive: true });
395
+
396
+ console.log(`Found ${sessions.length} session(s) for: ${cwd}\n`);
397
+
398
+ for (const s of sessions) {
399
+ try {
400
+ const info = await getSessionInfo(s.id);
401
+ const outPath = path.join(exportDir, `${s.id}.json`);
402
+ fs.writeFileSync(outPath, JSON.stringify(info, null, 2), "utf-8");
403
+ console.log(` ${s.id} ${s.title || "(untitled)"} -> export-${agent}/${s.id}.json`);
404
+ } catch (err) {
405
+ console.error(` ${s.id} ERROR: ${err.message}`);
406
+ }
407
+ }
408
+
409
+ console.log(`\nExported to: ${exportDir}`);
410
+ }
411
+
412
+ const args = process.argv.slice(2);
413
+ let agent = null;
414
+ let sessionId = null;
415
+
416
+ for (const arg of args) {
417
+ if (arg.startsWith("--agent=")) {
418
+ agent = arg.split("=")[1];
419
+ } else if (arg.startsWith("ses_")) {
420
+ sessionId = arg;
421
+ } else {
422
+ agent = arg;
423
+ }
424
+ }
425
+
426
+ if (agent) {
427
+ await exportProjectSessions(agent);
428
+ } else if (!sessionId) {
429
+ const sessions = await listSessions();
430
+ console.log("Usage: node parse-session.mjs <session_id>");
431
+ console.log(" node parse-session.mjs <agent>");
432
+ console.log(" node parse-session.mjs --agent=<agent>\n");
433
+ console.log("Agents: opencode\n");
434
+ console.log("Recent sessions:");
435
+ for (const s of sessions) {
436
+ console.log(` ${s.id} ${s.title} (${s.date})`);
437
+ }
438
+ process.exit(0);
439
+ } else {
440
+ const info = await getSessionInfo(sessionId);
441
+ const exportsDir = path.join(process.cwd(), "exports");
442
+ if (!fs.existsSync(exportsDir)) fs.mkdirSync(exportsDir, { recursive: true });
443
+ const outPath = path.join(exportsDir, `${sessionId}.json`);
444
+ fs.writeFileSync(outPath, JSON.stringify(info, null, 2), "utf-8");
445
+ console.log(`Saved: ${outPath}`);
446
+ }