opencode-sessions-explorer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,798 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/bin/dedupe-export.ts
5
+ import { readdirSync as readdirSync2, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
6
+ import { join as join3 } from "path";
7
+
8
+ // src/lib/db.ts
9
+ import { Database } from "bun:sqlite";
10
+ import { existsSync } from "fs";
11
+ import { homedir, platform } from "os";
12
+ import { join } from "path";
13
+
14
+ // src/lib/errors.ts
15
+ class SessionsError extends Error {
16
+ code;
17
+ hint;
18
+ constructor(code, message, hint) {
19
+ super(message);
20
+ this.code = code;
21
+ this.hint = hint;
22
+ this.name = "SessionsError";
23
+ }
24
+ }
25
+
26
+ // src/lib/db.ts
27
+ var ENV_VAR = "OPENCODE_SESSIONS_EXPLORER_DB";
28
+ function platformDefault() {
29
+ const home = homedir();
30
+ switch (platform()) {
31
+ case "darwin":
32
+ case "linux": {
33
+ const dataHome = process.env.XDG_DATA_HOME ?? join(home, ".local", "share");
34
+ return join(dataHome, "opencode", "opencode.db");
35
+ }
36
+ case "win32": {
37
+ const local = process.env.LOCALAPPDATA ?? join(home, "AppData", "Local");
38
+ return join(local, "opencode", "opencode.db");
39
+ }
40
+ default:
41
+ return null;
42
+ }
43
+ }
44
+ var cachedPath = null;
45
+ function locateDb() {
46
+ if (cachedPath)
47
+ return cachedPath;
48
+ const env = process.env[ENV_VAR];
49
+ if (env) {
50
+ if (!existsSync(env))
51
+ throw new SessionsError("DB_NOT_FOUND", `opencode-sessions-explorer: $${ENV_VAR} points to missing file: ${env}`);
52
+ cachedPath = env;
53
+ return env;
54
+ }
55
+ const def = platformDefault();
56
+ if (!def || !existsSync(def)) {
57
+ throw new SessionsError("DB_NOT_FOUND", `opencode-sessions-explorer: DB not found. Set $${ENV_VAR} or install OpenCode. Tried: ${def ?? `(no default for ${platform()})`}`);
58
+ }
59
+ cachedPath = def;
60
+ return def;
61
+ }
62
+ var _db = null;
63
+ var _stmts = new Map;
64
+ var STMT_CACHE_LIMIT = 256;
65
+ function db() {
66
+ if (_db)
67
+ return _db;
68
+ const path = locateDb();
69
+ _db = new Database(path, { readonly: true, create: false, safeIntegers: false });
70
+ _db.exec("PRAGMA query_only = 1;");
71
+ _db.exec("PRAGMA busy_timeout = 5000;");
72
+ _db.exec("PRAGMA temp_store = MEMORY;");
73
+ _db.exec("PRAGMA cache_size = -32000;");
74
+ return _db;
75
+ }
76
+ function stmt(sql) {
77
+ let s = _stmts.get(sql);
78
+ if (!s) {
79
+ if (_stmts.size >= STMT_CACHE_LIMIT) {
80
+ const oldest = _stmts.keys().next().value;
81
+ if (oldest)
82
+ _stmts.delete(oldest);
83
+ }
84
+ s = db().query(sql);
85
+ _stmts.set(sql, s);
86
+ }
87
+ return s;
88
+ }
89
+
90
+ // src/lib/decode.ts
91
+ function safeParse(jsonStr) {
92
+ try {
93
+ return JSON.parse(jsonStr);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+ function decodePart(dataStr) {
99
+ const d = safeParse(dataStr);
100
+ if (!d || typeof d !== "object")
101
+ return { type: "unknown", raw_type: null };
102
+ const t = d.type ?? "";
103
+ switch (t) {
104
+ case "text":
105
+ return { type: "text", text: typeof d.text === "string" ? d.text : "" };
106
+ case "reasoning":
107
+ return { type: "reasoning", text: typeof d.text === "string" ? d.text : "" };
108
+ case "tool": {
109
+ const s = d.state ?? {};
110
+ const md = s.metadata ?? {};
111
+ const time = s.time ?? {};
112
+ const start = typeof time.start === "number" ? time.start : null;
113
+ const end = typeof time.end === "number" ? time.end : null;
114
+ return {
115
+ type: "tool",
116
+ tool: typeof d.tool === "string" ? d.tool : "",
117
+ callID: typeof d.callID === "string" ? d.callID : null,
118
+ status: ["pending", "running", "completed", "error"].includes(s.status) ? s.status : "unknown",
119
+ input: s.input ?? null,
120
+ output: typeof s.output === "string" ? s.output : null,
121
+ error: typeof s.error === "string" ? s.error : null,
122
+ outputPath: typeof md.outputPath === "string" ? md.outputPath : null,
123
+ truncated: md.truncated === true,
124
+ start,
125
+ end,
126
+ duration_ms: start != null && end != null ? end - start : null,
127
+ title: typeof s.title === "string" ? s.title : null
128
+ };
129
+ }
130
+ case "file":
131
+ return {
132
+ type: "file",
133
+ url: typeof d.url === "string" ? d.url : null,
134
+ filename: typeof d.filename === "string" ? d.filename : null,
135
+ mime: typeof d.mime === "string" ? d.mime : null,
136
+ sourcePath: typeof d.source?.path === "string" ? d.source.path : null
137
+ };
138
+ case "patch":
139
+ return {
140
+ type: "patch",
141
+ hash: typeof d.hash === "string" ? d.hash : null,
142
+ files: Array.isArray(d.files) ? d.files.filter((x) => typeof x === "string") : []
143
+ };
144
+ case "step-start":
145
+ return { type: "step-start", snapshot: typeof d.snapshot === "string" ? d.snapshot : null };
146
+ case "step-finish":
147
+ return {
148
+ type: "step-finish",
149
+ reason: typeof d.reason === "string" ? d.reason : null,
150
+ snapshot: typeof d.snapshot === "string" ? d.snapshot : null,
151
+ cost: typeof d.cost === "number" ? d.cost : null
152
+ };
153
+ case "compaction":
154
+ return { type: "compaction", auto: d.auto === true };
155
+ case "subtask":
156
+ return {
157
+ type: "subtask",
158
+ prompt: typeof d.prompt === "string" ? d.prompt : "",
159
+ description: typeof d.description === "string" ? d.description : null,
160
+ agent: typeof d.agent === "string" ? d.agent : null
161
+ };
162
+ default:
163
+ return { type: "unknown", raw_type: typeof t === "string" ? t : null };
164
+ }
165
+ }
166
+ function decodeModel(modelStr) {
167
+ if (!modelStr)
168
+ return { id: null, providerID: null, variant: null };
169
+ const d = safeParse(modelStr);
170
+ if (!d || typeof d !== "object")
171
+ return { id: null, providerID: null, variant: null };
172
+ return {
173
+ id: typeof d.id === "string" ? d.id : null,
174
+ providerID: typeof d.providerID === "string" ? d.providerID : null,
175
+ variant: typeof d.variant === "string" ? d.variant : null
176
+ };
177
+ }
178
+
179
+ // src/lib/export.ts
180
+ import { mkdirSync, existsSync as existsSync2, renameSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "fs";
181
+ import { join as join2 } from "path";
182
+ import { homedir as homedir2 } from "os";
183
+
184
+ // src/lib/channel.ts
185
+ var CHANNELS = [
186
+ "conversation",
187
+ "session-summary",
188
+ "tool-input-summary",
189
+ "tool-error",
190
+ "code-touch",
191
+ "tool-output",
192
+ "patch-summary",
193
+ "reasoning",
194
+ "file",
195
+ "raw"
196
+ ];
197
+ function normalizeError(s) {
198
+ return s.replace(/https?:\/\/\S+/g, "<url>").replace(/(?:\/[\w .@-]+){2,}/g, "<path>").replace(/\b[A-Z][A-Z0-9_]+-\d+\b/g, "<jira>").replace(/\b(?:0x)?[0-9a-f]{8,}\b/gi, "<id>").replace(/\b(line|column|col)[:= ]+\d+\b/gi, "$1:<n>").replace(/:\d+:\d+/g, ":<n>:<n>").replace(/\s+/g, " ").trim();
199
+ }
200
+ function compactPath(path, baseDir) {
201
+ if (!baseDir || !path.startsWith(baseDir))
202
+ return { path, rel_path: null };
203
+ const rel = path.slice(baseDir.length).replace(/^\/+/, "");
204
+ return { path, rel_path: rel || "." };
205
+ }
206
+
207
+ // src/lib/export.ts
208
+ var DEFAULT_EXPORT_ROOT = join2(homedir2(), ".local/share/opencode-sessions-explorer");
209
+ var BODY_CAP_BYTES = 256 * 1024;
210
+ var SAFETY_PART_CAP_BYTES = 50 * 1024 * 1024;
211
+ var CHANNEL_COMPLETE_MARKER = ".channels_v1_complete";
212
+ var SEARCHABLE_TYPES = ["text", "reasoning", "tool", "file", "patch", "subtask"];
213
+ var PART_CHANNELS = CHANNELS.filter((c) => c !== "session-summary" && c !== "raw");
214
+ function exportRoot() {
215
+ return process.env.OPENCODE_SESSIONS_EXPLORER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT;
216
+ }
217
+ function ensureRoot(root = exportRoot()) {
218
+ mkdirSync(join2(root, "by-session"), { recursive: true });
219
+ return root;
220
+ }
221
+ function channelExportComplete(root = exportRoot()) {
222
+ return existsSync2(join2(root, CHANNEL_COMPLETE_MARKER));
223
+ }
224
+ function markChannelExportComplete(root = exportRoot()) {
225
+ const p = join2(root, CHANNEL_COMPLETE_MARKER);
226
+ const tmp = p + ".tmp";
227
+ writeFileSync(tmp, String(Date.now()));
228
+ renameSync(tmp, p);
229
+ }
230
+ var CURSOR_SCHEMA = "v2";
231
+ function getLastSync(root = exportRoot()) {
232
+ const p = join2(root, ".last_sync");
233
+ if (!existsSync2(p))
234
+ return null;
235
+ try {
236
+ const raw = readFileSync(p, "utf8").trim();
237
+ if (!raw)
238
+ return null;
239
+ if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
240
+ const [tsStr, id] = raw.slice(CURSOR_SCHEMA.length + 1).split(":");
241
+ const ts = Number(tsStr);
242
+ if (!Number.isFinite(ts) || !id)
243
+ return null;
244
+ return { ts, id };
245
+ }
246
+ return null;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+ function setLastSync(c, root = exportRoot()) {
252
+ const p = join2(root, ".last_sync");
253
+ const tmp = p + ".tmp";
254
+ writeFileSync(tmp, `${CURSOR_SCHEMA} ${c.ts}:${c.id}`);
255
+ renameSync(tmp, p);
256
+ }
257
+ var sessionCache = new Map;
258
+ function getSession(id) {
259
+ const cached = sessionCache.get(id);
260
+ if (cached)
261
+ return cached;
262
+ const row = stmt(`
263
+ SELECT id, title, project_id, directory, agent, model, cost,
264
+ time_created, time_updated, time_archived, parent_id
265
+ FROM session WHERE id = ?`).get(id);
266
+ if (!row)
267
+ return null;
268
+ sessionCache.set(id, row);
269
+ return row;
270
+ }
271
+ function buildPartFile(partId, sessionId, messageId, data, archived) {
272
+ let decoded;
273
+ try {
274
+ decoded = decodePart(data);
275
+ } catch {
276
+ return null;
277
+ }
278
+ if (!SEARCHABLE_TYPES.includes(decoded.type))
279
+ return null;
280
+ const lines = [];
281
+ lines.push(`PART_ID: ${partId}`);
282
+ lines.push(`SESSION_ID: ${sessionId}`);
283
+ lines.push(`MESSAGE_ID: ${messageId}`);
284
+ lines.push(`TYPE: ${decoded.type}`);
285
+ lines.push(`ARCHIVED: ${archived}`);
286
+ let body = "";
287
+ switch (decoded.type) {
288
+ case "text":
289
+ body = decoded.text;
290
+ break;
291
+ case "reasoning":
292
+ body = decoded.text;
293
+ break;
294
+ case "tool": {
295
+ lines.push(`TOOL: ${decoded.tool}`);
296
+ lines.push(`STATUS: ${decoded.status}`);
297
+ if (decoded.start != null && decoded.end != null)
298
+ lines.push(`TIME: ${decoded.start} - ${decoded.end}`);
299
+ const parts = [];
300
+ try {
301
+ parts.push("INPUT: " + JSON.stringify(decoded.input));
302
+ } catch {
303
+ parts.push("INPUT: <unserializable>");
304
+ }
305
+ if (decoded.output)
306
+ parts.push(`OUTPUT:
307
+ ` + decoded.output);
308
+ if (decoded.error)
309
+ parts.push(`ERROR:
310
+ ` + decoded.error);
311
+ body = parts.join(`
312
+ `);
313
+ break;
314
+ }
315
+ case "file":
316
+ lines.push(`MIME: ${decoded.mime ?? "?"}`);
317
+ body = `FILENAME: ${decoded.filename ?? "?"}
318
+ URL: ${decoded.url ?? "?"}
319
+ SOURCE_PATH: ${decoded.sourcePath ?? "?"}`;
320
+ break;
321
+ case "patch":
322
+ lines.push(`HASH: ${decoded.hash ?? "?"}`);
323
+ lines.push(`FILES_COUNT: ${decoded.files.length}`);
324
+ body = `FILES:
325
+ ` + decoded.files.join(`
326
+ `);
327
+ break;
328
+ case "subtask":
329
+ lines.push(`AGENT: ${decoded.agent ?? "?"}`);
330
+ if (decoded.description)
331
+ body += `DESCRIPTION: ${decoded.description}
332
+ `;
333
+ body += `PROMPT:
334
+ ${decoded.prompt}`;
335
+ break;
336
+ default:
337
+ return null;
338
+ }
339
+ lines.push("---BODY---");
340
+ const enc = new TextEncoder;
341
+ let bodyBytes = enc.encode(body).length;
342
+ if (bodyBytes > BODY_CAP_BYTES) {
343
+ const truncMarker = `
344
+ \u2026[truncated; ${bodyBytes} bytes original; call get_part('${partId}') for full content]`;
345
+ const markerBytes = enc.encode(truncMarker).length;
346
+ const sliced = enc.encode(body).slice(0, Math.max(0, BODY_CAP_BYTES - markerBytes));
347
+ body = new TextDecoder("utf-8", { fatal: false }).decode(sliced).replace(/\uFFFD+$/, "") + truncMarker;
348
+ bodyBytes = enc.encode(body).length;
349
+ }
350
+ lines.push(body);
351
+ return { content: lines.join(`
352
+ `), type: decoded.type };
353
+ }
354
+ function buildChannelDocuments(partId, sessionId, messageId, data, archived, role, sessionDirectory) {
355
+ let decoded;
356
+ try {
357
+ decoded = decodePart(data);
358
+ } catch {
359
+ return [];
360
+ }
361
+ const docs = [];
362
+ const baseHeaders = [
363
+ `PART_ID: ${partId}`,
364
+ `SESSION_ID: ${sessionId}`,
365
+ `MESSAGE_ID: ${messageId}`,
366
+ `ROLE: ${role ?? "unknown"}`,
367
+ `TYPE: ${decoded.type}`,
368
+ `ARCHIVED: ${archived}`
369
+ ];
370
+ const emit = (channel, body, extra = []) => {
371
+ const trimmed = body.trim();
372
+ if (!trimmed)
373
+ return;
374
+ docs.push({ channel, content: [...baseHeaders, `CHANNEL: ${channel}`, ...extra, "---BODY---", capBody(trimmed, partId)].join(`
375
+ `) });
376
+ };
377
+ switch (decoded.type) {
378
+ case "text":
379
+ emit("conversation", decoded.text);
380
+ break;
381
+ case "reasoning":
382
+ emit("reasoning", decoded.text);
383
+ break;
384
+ case "subtask":
385
+ emit("conversation", `${decoded.description ? `DESCRIPTION: ${decoded.description}
386
+ ` : ""}PROMPT:
387
+ ${decoded.prompt}`, [`AGENT: ${decoded.agent ?? "?"}`]);
388
+ break;
389
+ case "tool": {
390
+ const extra = [`TOOL: ${decoded.tool}`, `STATUS: ${decoded.status}`];
391
+ const inputSummary = summarizeToolInput(decoded.tool, decoded.input, sessionDirectory);
392
+ emit("tool-input-summary", inputSummary || `${decoded.tool} ${decoded.status}`, extra);
393
+ if (decoded.status === "error" && decoded.error)
394
+ emit("tool-error", normalizeError(decoded.error), extra);
395
+ if (decoded.output)
396
+ emit("tool-output", decoded.output, extra);
397
+ const codeTouch = summarizeCodeTouch(decoded.tool, decoded.input, sessionDirectory);
398
+ if (codeTouch)
399
+ emit("code-touch", codeTouch, extra);
400
+ break;
401
+ }
402
+ case "patch": {
403
+ const body = decoded.files.map((f) => compactPath(f, sessionDirectory).rel_path ?? f).join(`
404
+ `);
405
+ emit("patch-summary", body, [`HASH: ${decoded.hash ?? "?"}`, `FILES_COUNT: ${decoded.files.length}`]);
406
+ emit("code-touch", body, [`SOURCE: patch`, `FILES_COUNT: ${decoded.files.length}`]);
407
+ break;
408
+ }
409
+ case "file":
410
+ emit("file", `FILENAME: ${decoded.filename ?? "?"}
411
+ URL: ${decoded.url ?? "?"}
412
+ SOURCE_PATH: ${decoded.sourcePath ?? "?"}`);
413
+ break;
414
+ }
415
+ return docs;
416
+ }
417
+ function capBody(body, partId) {
418
+ const enc = new TextEncoder;
419
+ const bytes = enc.encode(body).length;
420
+ if (bytes <= BODY_CAP_BYTES)
421
+ return body;
422
+ const marker = `
423
+ ...[truncated; ${bytes} bytes original; call get_part('${partId}') for full content]`;
424
+ const markerBytes = enc.encode(marker).length;
425
+ const sliced = enc.encode(body).slice(0, Math.max(0, BODY_CAP_BYTES - markerBytes));
426
+ return new TextDecoder("utf-8", { fatal: false }).decode(sliced).replace(/\uFFFD+$/, "") + marker;
427
+ }
428
+ function summarizeToolInput(tool, input, sessionDirectory) {
429
+ if (!input || typeof input !== "object")
430
+ return stringifySafe(input);
431
+ const obj = input;
432
+ const lines = [];
433
+ const add = (label, value) => {
434
+ if (typeof value === "string" && value.trim())
435
+ lines.push(`${label}: ${compactPath(value, sessionDirectory).rel_path ?? value}`);
436
+ else if (typeof value === "number" || typeof value === "boolean")
437
+ lines.push(`${label}: ${value}`);
438
+ };
439
+ lines.push(`TOOL: ${tool}`);
440
+ for (const key of ["command", "description", "filePath", "path", "url", "query", "pattern", "session_id", "message_id", "part_id", "issue_key", "pullNumber"]) {
441
+ add(key, obj[key]);
442
+ }
443
+ if (Array.isArray(obj.paths))
444
+ for (const p of obj.paths.slice(0, 20))
445
+ add("path", p);
446
+ if (Array.isArray(obj.files))
447
+ for (const p of obj.files.slice(0, 20))
448
+ add("file", p);
449
+ return lines.length > 1 ? lines.join(`
450
+ `) : stringifySafe(input);
451
+ }
452
+ function summarizeCodeTouch(tool, input, sessionDirectory) {
453
+ if (!input || typeof input !== "object")
454
+ return null;
455
+ const obj = input;
456
+ const paths = [];
457
+ for (const key of ["filePath", "path"]) {
458
+ if (typeof obj[key] === "string")
459
+ paths.push(obj[key]);
460
+ }
461
+ for (const key of ["paths", "files"]) {
462
+ if (Array.isArray(obj[key])) {
463
+ for (const p of obj[key])
464
+ if (typeof p === "string")
465
+ paths.push(p);
466
+ }
467
+ }
468
+ if (paths.length === 0)
469
+ return null;
470
+ return [`TOOL: ${tool}`, ...paths.slice(0, 50).map((p) => compactPath(p, sessionDirectory).rel_path ?? p)].join(`
471
+ `);
472
+ }
473
+ function stringifySafe(value) {
474
+ try {
475
+ return JSON.stringify(value);
476
+ } catch {
477
+ return "<unserializable>";
478
+ }
479
+ }
480
+ function safePartFilename(seq, partId) {
481
+ const safe = partId.replace(/[^A-Za-z0-9_-]/g, "_");
482
+ return `${String(seq).padStart(5, "0")}-${safe}.txt`;
483
+ }
484
+ function writeMeta(s, dir) {
485
+ const meta = {
486
+ id: s.id,
487
+ title: s.title,
488
+ project_id: s.project_id,
489
+ directory: s.directory,
490
+ agent: s.agent,
491
+ model: decodeModel(s.model),
492
+ cost: Number(s.cost ?? 0),
493
+ parent_id: s.parent_id,
494
+ time_created: s.time_created,
495
+ time_updated: s.time_updated,
496
+ archived: s.time_archived != null
497
+ };
498
+ const p = join2(dir, "meta.json");
499
+ const tmp = p + ".tmp";
500
+ writeFileSync(tmp, JSON.stringify(meta, null, 2));
501
+ renameSync(tmp, p);
502
+ }
503
+ function writePartFile(dir, filename, content) {
504
+ const p = join2(dir, filename);
505
+ const tmp = join2(dir, "." + filename + ".tmp");
506
+ writeFileSync(tmp, content);
507
+ renameSync(tmp, p);
508
+ const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(filename);
509
+ if (!m)
510
+ return;
511
+ const myPartId = m[2];
512
+ try {
513
+ for (const f of readdirSync(dir)) {
514
+ if (f === filename || !f.endsWith(".txt") || f.startsWith("."))
515
+ continue;
516
+ const fm = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
517
+ if (fm && fm[2] === myPartId) {
518
+ try {
519
+ unlinkSync(join2(dir, f));
520
+ } catch {}
521
+ }
522
+ }
523
+ } catch {}
524
+ }
525
+ function channelDir(root, channel, sessionId) {
526
+ return join2(root, "by-channel", channel, "by-session", sessionId);
527
+ }
528
+ function deleteChannelPartFiles(root, sessionId, partId) {
529
+ for (const ch of PART_CHANNELS) {
530
+ const dir = channelDir(root, ch, sessionId);
531
+ if (!existsSync2(dir))
532
+ continue;
533
+ try {
534
+ for (const f of readdirSync(dir)) {
535
+ if (f === `${partId}.txt` || f.endsWith(`-${partId}.txt`)) {
536
+ try {
537
+ unlinkSync(join2(dir, f));
538
+ } catch {}
539
+ }
540
+ }
541
+ } catch {}
542
+ }
543
+ }
544
+ function writeChannelFiles(root, sessionId, filename, partId, docs) {
545
+ deleteChannelPartFiles(root, sessionId, partId);
546
+ for (const doc of docs) {
547
+ const dir = channelDir(root, doc.channel, sessionId);
548
+ if (!existsSync2(dir))
549
+ mkdirSync(dir, { recursive: true });
550
+ writePartFile(dir, filename, doc.content);
551
+ }
552
+ }
553
+ function writeSessionSummaryChannel(s, dirRoot = exportRoot()) {
554
+ const dir = channelDir(dirRoot, "session-summary", s.id);
555
+ if (!existsSync2(dir))
556
+ mkdirSync(dir, { recursive: true });
557
+ const p = join2(dir, "summary.txt");
558
+ const tmp = p + ".tmp";
559
+ writeFileSync(tmp, buildSessionSummaryDocument(s));
560
+ renameSync(tmp, p);
561
+ }
562
+ function buildSessionSummaryDocument(s) {
563
+ const firstPrompt = firstUserPrompt(s.id, "ASC");
564
+ const lastPrompt = firstUserPrompt(s.id, "DESC");
565
+ const model = decodeModel(s.model);
566
+ const lines = [
567
+ `SESSION_ID: ${s.id}`,
568
+ `CHANNEL: session-summary`,
569
+ `TITLE: ${s.title}`,
570
+ `PROJECT_ID: ${s.project_id}`,
571
+ `DIRECTORY: ${s.directory}`,
572
+ `AGENT: ${s.agent ?? "unknown"}`,
573
+ `MODEL: ${model.id ?? "unknown"}`,
574
+ `ARCHIVED: ${s.time_archived != null}`,
575
+ `PARENT_ID: ${s.parent_id ?? ""}`,
576
+ "---BODY---",
577
+ `TITLE: ${s.title}`,
578
+ `DIRECTORY: ${s.directory}`,
579
+ firstPrompt ? `FIRST_USER_PROMPT:
580
+ ${firstPrompt}` : "FIRST_USER_PROMPT:",
581
+ lastPrompt && lastPrompt !== firstPrompt ? `LAST_USER_PROMPT:
582
+ ${lastPrompt}` : ""
583
+ ].filter(Boolean);
584
+ return lines.join(`
585
+ `);
586
+ }
587
+ function firstUserPrompt(sessionId, direction) {
588
+ const msg = stmt(`
589
+ SELECT id
590
+ FROM message
591
+ WHERE session_id = ? AND json_extract(data,'$.role') = 'user'
592
+ ORDER BY time_created ${direction}, id ${direction}
593
+ LIMIT 1`).get(sessionId);
594
+ if (!msg)
595
+ return null;
596
+ const rows = stmt(`
597
+ SELECT json_extract(data,'$.text') AS text
598
+ FROM part
599
+ WHERE message_id = ? AND json_extract(data,'$.type') = 'text'
600
+ ORDER BY time_created ASC, id ASC`).all(msg.id);
601
+ const joined = rows.map((r) => r.text ?? "").join(`
602
+ `).trim();
603
+ if (!joined)
604
+ return null;
605
+ return capBody(joined, "summary");
606
+ }
607
+ var fileIndexBySession = new Map;
608
+ function getFileIndex(sessionId, dir) {
609
+ let idx = fileIndexBySession.get(sessionId);
610
+ if (idx)
611
+ return idx;
612
+ idx = { nextSeq: 1, byPartId: new Map };
613
+ try {
614
+ const files = readdirSync(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
615
+ let max = 0;
616
+ for (const f of files) {
617
+ const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
618
+ if (m) {
619
+ const seq = Number(m[1]);
620
+ const partId = m[2];
621
+ idx.byPartId.set(partId, f);
622
+ if (seq > max)
623
+ max = seq;
624
+ } else if (/^prt_[A-Za-z0-9_-]+\.txt$/.test(f)) {
625
+ idx.byPartId.set(f.replace(/\.txt$/, ""), f);
626
+ }
627
+ }
628
+ idx.nextSeq = max + 1;
629
+ } catch {}
630
+ fileIndexBySession.set(sessionId, idx);
631
+ return idx;
632
+ }
633
+ async function runExport(opts = {}) {
634
+ const root = ensureRoot(opts.root ?? exportRoot());
635
+ const cursor = opts.fromCursor !== undefined ? opts.fromCursor : getLastSync(root);
636
+ const batchSize = opts.batchSize ?? 1000;
637
+ const start = Date.now();
638
+ const progress = { exported: 0, inserts: 0, updates: 0, skipped_nontext: 0, skipped_oversize: 0, failed: 0, last_cursor: cursor };
639
+ let where = "";
640
+ const params = [];
641
+ if (cursor) {
642
+ where = "WHERE (p.time_updated > ? OR (p.time_updated = ? AND p.id > ?))";
643
+ params.push(cursor.ts, cursor.ts, cursor.id);
644
+ }
645
+ let updates = 0;
646
+ let inserts = 0;
647
+ const touchedSessions = new Set;
648
+ while (true) {
649
+ if (opts.budgetMs && Date.now() - start > opts.budgetMs)
650
+ break;
651
+ const rows = stmt(`
652
+ SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated, p.data, LENGTH(p.data) AS data_bytes,
653
+ json_extract(m.data,'$.role') AS role
654
+ FROM part p
655
+ LEFT JOIN message m ON m.id = p.message_id
656
+ ${where}
657
+ ORDER BY p.time_updated ASC, p.id ASC
658
+ LIMIT ?`).all(...params, batchSize);
659
+ if (rows.length === 0)
660
+ break;
661
+ for (const r of rows) {
662
+ if (opts.budgetMs && Date.now() - start > opts.budgetMs)
663
+ break;
664
+ if (r.data_bytes > SAFETY_PART_CAP_BYTES) {
665
+ progress.skipped_oversize++;
666
+ } else {
667
+ try {
668
+ const s = getSession(r.session_id);
669
+ if (!s) {
670
+ progress.failed++;
671
+ continue;
672
+ }
673
+ const built = buildPartFile(r.id, r.session_id, r.message_id, r.data, s.time_archived != null);
674
+ if (!built) {
675
+ progress.skipped_nontext++;
676
+ continue;
677
+ }
678
+ const channelDocs = buildChannelDocuments(r.id, r.session_id, r.message_id, r.data, s.time_archived != null, r.role ?? null, s.directory);
679
+ const dir = join2(root, "by-session", r.session_id);
680
+ if (!existsSync2(dir)) {
681
+ mkdirSync(dir, { recursive: true });
682
+ writeMeta(s, dir);
683
+ }
684
+ const idx = getFileIndex(r.session_id, dir);
685
+ const existing = idx.byPartId.get(r.id);
686
+ if (existing) {
687
+ writePartFile(dir, existing, built.content);
688
+ updates++;
689
+ } else {
690
+ const seq = idx.nextSeq++;
691
+ const filename2 = safePartFilename(seq, r.id);
692
+ writePartFile(dir, filename2, built.content);
693
+ idx.byPartId.set(r.id, filename2);
694
+ inserts++;
695
+ }
696
+ const filename = idx.byPartId.get(r.id);
697
+ if (filename)
698
+ writeChannelFiles(root, r.session_id, filename, r.id, channelDocs);
699
+ touchedSessions.add(r.session_id);
700
+ progress.exported++;
701
+ } catch {
702
+ progress.failed++;
703
+ }
704
+ }
705
+ progress.last_cursor = { ts: r.time_updated, id: r.id };
706
+ }
707
+ const last = rows[rows.length - 1];
708
+ where = "WHERE (p.time_updated > ? OR (p.time_updated = ? AND p.id > ?))";
709
+ params.length = 0;
710
+ params.push(last.time_updated, last.time_updated, last.id);
711
+ if (opts.onProgress && progress.exported % 5000 === 0)
712
+ opts.onProgress(progress);
713
+ if (progress.last_cursor && progress.exported > 0 && progress.exported % 5000 === 0) {
714
+ setLastSync(progress.last_cursor, root);
715
+ }
716
+ }
717
+ for (const sid of touchedSessions) {
718
+ const s = getSession(sid);
719
+ if (s) {
720
+ const dir = join2(root, "by-session", sid);
721
+ const fresh = stmt(`
722
+ SELECT id, title, project_id, directory, agent, model, cost,
723
+ time_created, time_updated, time_archived, parent_id
724
+ FROM session WHERE id = ?`).get(sid);
725
+ if (fresh)
726
+ writeMeta(fresh, dir);
727
+ if (fresh)
728
+ writeSessionSummaryChannel(fresh, root);
729
+ }
730
+ }
731
+ if (progress.last_cursor)
732
+ setLastSync(progress.last_cursor, root);
733
+ progress.updates = updates;
734
+ progress.inserts = inserts;
735
+ return progress;
736
+ }
737
+
738
+ // src/bin/dedupe-export.ts
739
+ var argv = process.argv.slice(2);
740
+ var apply = argv.includes("--apply");
741
+ var root = join3(exportRoot(), "by-session");
742
+ console.log(`[dedupe-export] root: ${root}`);
743
+ console.log(`[dedupe-export] mode: ${apply ? "APPLY (will delete)" : "DRY-RUN (just report)"}`);
744
+ var sessions = readdirSync2(root).filter((f) => f.startsWith("ses_"));
745
+ console.log(`[dedupe-export] scanning ${sessions.length} session dirs...`);
746
+ var totalDups = 0;
747
+ var totalBytesFreed = 0;
748
+ var dirsAffected = 0;
749
+ for (const ses of sessions) {
750
+ const dir = join3(root, ses);
751
+ let files;
752
+ try {
753
+ files = readdirSync2(dir);
754
+ } catch {
755
+ continue;
756
+ }
757
+ const byPartId = new Map;
758
+ for (const f of files) {
759
+ const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
760
+ if (!m)
761
+ continue;
762
+ const seq = Number(m[1]);
763
+ const partId = m[2];
764
+ if (!byPartId.has(partId))
765
+ byPartId.set(partId, []);
766
+ byPartId.get(partId).push({ seq, filename: f });
767
+ }
768
+ let dirDups = 0;
769
+ for (const [partId, variants] of byPartId) {
770
+ if (variants.length <= 1)
771
+ continue;
772
+ variants.sort((a, b) => a.seq - b.seq);
773
+ const keep = variants[0];
774
+ const deletables = variants.slice(1);
775
+ for (const d of deletables) {
776
+ const path = join3(dir, d.filename);
777
+ const sz = statSync2(path).size;
778
+ totalBytesFreed += sz;
779
+ totalDups++;
780
+ dirDups++;
781
+ if (apply) {
782
+ try {
783
+ unlinkSync2(path);
784
+ } catch (e) {
785
+ console.error(` failed to delete ${path}: ${e.message}`);
786
+ }
787
+ }
788
+ }
789
+ }
790
+ if (dirDups > 0)
791
+ dirsAffected++;
792
+ }
793
+ console.log(`[dedupe-export] DONE`);
794
+ console.log(`[dedupe-export] duplicate files ${apply ? "deleted" : "to delete"}: ${totalDups}`);
795
+ console.log(`[dedupe-export] sessions affected: ${dirsAffected}`);
796
+ console.log(`[dedupe-export] disk ${apply ? "freed" : "to free"}: ${(totalBytesFreed / 1024 / 1024).toFixed(1)} MB`);
797
+ if (!apply)
798
+ console.log(`[dedupe-export] re-run with --apply to actually delete.`);