opencode-sessions-explorer 0.1.2 → 0.1.4

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,1519 @@
1
+ // @bun
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+
17
+ // src/lib/db.ts
18
+ import { Database } from "bun:sqlite";
19
+ import { existsSync } from "fs";
20
+ import { homedir, platform } from "os";
21
+ import { join } from "path";
22
+
23
+ // src/lib/errors.ts
24
+ class SessionsError extends Error {
25
+ code;
26
+ hint;
27
+ constructor(code, message, hint) {
28
+ super(message);
29
+ this.code = code;
30
+ this.hint = hint;
31
+ this.name = "SessionsError";
32
+ }
33
+ }
34
+ function asStructured(e) {
35
+ if (e instanceof SessionsError) {
36
+ return { code: e.code, message: e.message, ...e.hint ? { hint: e.hint } : {} };
37
+ }
38
+ if (e instanceof Error) {
39
+ return { code: "INTERNAL", message: e.message };
40
+ }
41
+ return { code: "INTERNAL", message: String(e) };
42
+ }
43
+
44
+ // src/lib/db.ts
45
+ var ENV_VAR = "OPENCODE_SESSIONS_EXPLORER_DB";
46
+ function platformDefault() {
47
+ const home = homedir();
48
+ switch (platform()) {
49
+ case "darwin":
50
+ case "linux": {
51
+ const dataHome = process.env.XDG_DATA_HOME ?? join(home, ".local", "share");
52
+ return join(dataHome, "opencode", "opencode.db");
53
+ }
54
+ case "win32": {
55
+ const local = process.env.LOCALAPPDATA ?? join(home, "AppData", "Local");
56
+ return join(local, "opencode", "opencode.db");
57
+ }
58
+ default:
59
+ return null;
60
+ }
61
+ }
62
+ var cachedPath = null;
63
+ function locateDb() {
64
+ if (cachedPath)
65
+ return cachedPath;
66
+ const env = process.env[ENV_VAR];
67
+ if (env) {
68
+ if (!existsSync(env))
69
+ throw new SessionsError("DB_NOT_FOUND", `opencode-sessions-explorer: $${ENV_VAR} points to missing file: ${env}`);
70
+ cachedPath = env;
71
+ return env;
72
+ }
73
+ const def = platformDefault();
74
+ if (!def || !existsSync(def)) {
75
+ throw new SessionsError("DB_NOT_FOUND", `opencode-sessions-explorer: DB not found. Set $${ENV_VAR} or install OpenCode. Tried: ${def ?? `(no default for ${platform()})`}`);
76
+ }
77
+ cachedPath = def;
78
+ return def;
79
+ }
80
+ var _db = null;
81
+ var _stmts = new Map;
82
+ var STMT_CACHE_LIMIT = 256;
83
+ function db() {
84
+ if (_db)
85
+ return _db;
86
+ const path = locateDb();
87
+ _db = new Database(path, { readonly: true, create: false, safeIntegers: false });
88
+ _db.exec("PRAGMA query_only = 1;");
89
+ _db.exec("PRAGMA busy_timeout = 5000;");
90
+ _db.exec("PRAGMA temp_store = MEMORY;");
91
+ _db.exec("PRAGMA cache_size = -32000;");
92
+ return _db;
93
+ }
94
+ function stmt(sql) {
95
+ let s = _stmts.get(sql);
96
+ if (!s) {
97
+ if (_stmts.size >= STMT_CACHE_LIMIT) {
98
+ const oldest = _stmts.keys().next().value;
99
+ if (oldest)
100
+ _stmts.delete(oldest);
101
+ }
102
+ s = db().query(sql);
103
+ _stmts.set(sql, s);
104
+ }
105
+ return s;
106
+ }
107
+
108
+ // src/lib/decode.ts
109
+ function safeParse(jsonStr) {
110
+ try {
111
+ return JSON.parse(jsonStr);
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+ function decodeMessage(dataStr) {
117
+ const d = safeParse(dataStr) ?? {};
118
+ const role = d.role === "user" || d.role === "assistant" || d.role === "system" ? d.role : "unknown";
119
+ const tokens = d.tokens ? {
120
+ input: Number(d.tokens.input ?? 0),
121
+ output: Number(d.tokens.output ?? 0),
122
+ reasoning: Number(d.tokens.reasoning ?? 0),
123
+ cache_read: Number(d.tokens.cache?.read ?? d.tokens.cache_read ?? 0),
124
+ cache_write: Number(d.tokens.cache?.write ?? d.tokens.cache_write ?? 0)
125
+ } : null;
126
+ return {
127
+ role,
128
+ agent: d.agent ?? null,
129
+ providerID: d.model?.providerID ?? d.providerID ?? null,
130
+ modelID: d.model?.modelID ?? d.modelID ?? null,
131
+ parentID: d.parentID ?? null,
132
+ cost: typeof d.cost === "number" ? d.cost : null,
133
+ tokens
134
+ };
135
+ }
136
+ function decodePart(dataStr) {
137
+ const d = safeParse(dataStr);
138
+ if (!d || typeof d !== "object")
139
+ return { type: "unknown", raw_type: null };
140
+ const t = d.type ?? "";
141
+ switch (t) {
142
+ case "text":
143
+ return { type: "text", text: typeof d.text === "string" ? d.text : "" };
144
+ case "reasoning":
145
+ return { type: "reasoning", text: typeof d.text === "string" ? d.text : "" };
146
+ case "tool": {
147
+ const s = d.state ?? {};
148
+ const md = s.metadata ?? {};
149
+ const time = s.time ?? {};
150
+ const start = typeof time.start === "number" ? time.start : null;
151
+ const end = typeof time.end === "number" ? time.end : null;
152
+ return {
153
+ type: "tool",
154
+ tool: typeof d.tool === "string" ? d.tool : "",
155
+ callID: typeof d.callID === "string" ? d.callID : null,
156
+ status: ["pending", "running", "completed", "error"].includes(s.status) ? s.status : "unknown",
157
+ input: s.input ?? null,
158
+ output: typeof s.output === "string" ? s.output : null,
159
+ error: typeof s.error === "string" ? s.error : null,
160
+ outputPath: typeof md.outputPath === "string" ? md.outputPath : null,
161
+ truncated: md.truncated === true,
162
+ start,
163
+ end,
164
+ duration_ms: start != null && end != null ? end - start : null,
165
+ title: typeof s.title === "string" ? s.title : null
166
+ };
167
+ }
168
+ case "file":
169
+ return {
170
+ type: "file",
171
+ url: typeof d.url === "string" ? d.url : null,
172
+ filename: typeof d.filename === "string" ? d.filename : null,
173
+ mime: typeof d.mime === "string" ? d.mime : null,
174
+ sourcePath: typeof d.source?.path === "string" ? d.source.path : null
175
+ };
176
+ case "patch":
177
+ return {
178
+ type: "patch",
179
+ hash: typeof d.hash === "string" ? d.hash : null,
180
+ files: Array.isArray(d.files) ? d.files.filter((x) => typeof x === "string") : []
181
+ };
182
+ case "step-start":
183
+ return { type: "step-start", snapshot: typeof d.snapshot === "string" ? d.snapshot : null };
184
+ case "step-finish":
185
+ return {
186
+ type: "step-finish",
187
+ reason: typeof d.reason === "string" ? d.reason : null,
188
+ snapshot: typeof d.snapshot === "string" ? d.snapshot : null,
189
+ cost: typeof d.cost === "number" ? d.cost : null
190
+ };
191
+ case "compaction":
192
+ return { type: "compaction", auto: d.auto === true };
193
+ case "subtask":
194
+ return {
195
+ type: "subtask",
196
+ prompt: typeof d.prompt === "string" ? d.prompt : "",
197
+ description: typeof d.description === "string" ? d.description : null,
198
+ agent: typeof d.agent === "string" ? d.agent : null
199
+ };
200
+ default:
201
+ return { type: "unknown", raw_type: typeof t === "string" ? t : null };
202
+ }
203
+ }
204
+ function decodeModel(modelStr) {
205
+ if (!modelStr)
206
+ return { id: null, providerID: null, variant: null };
207
+ const d = safeParse(modelStr);
208
+ if (!d || typeof d !== "object")
209
+ return { id: null, providerID: null, variant: null };
210
+ return {
211
+ id: typeof d.id === "string" ? d.id : null,
212
+ providerID: typeof d.providerID === "string" ? d.providerID : null,
213
+ variant: typeof d.variant === "string" ? d.variant : null
214
+ };
215
+ }
216
+
217
+ // src/lib/export.ts
218
+ import { mkdirSync, existsSync as existsSync5, renameSync as renameSync2, writeFileSync as writeFileSync3, unlinkSync as unlinkSync3, readdirSync as readdirSync2 } from "fs";
219
+ import { join as join5 } from "path";
220
+ import { homedir as homedir2 } from "os";
221
+
222
+ // src/lib/channel.ts
223
+ var CHANNELS = [
224
+ "conversation",
225
+ "session-summary",
226
+ "tool-input-summary",
227
+ "tool-error",
228
+ "code-touch",
229
+ "tool-output",
230
+ "patch-summary",
231
+ "reasoning",
232
+ "file",
233
+ "raw"
234
+ ];
235
+ var DEFAULT_RECALL_CHANNELS = ["conversation", "session-summary"];
236
+ function channelsForSurface(surface, q = "") {
237
+ const inferred = inferSurface(q, surface);
238
+ switch (inferred) {
239
+ case "debug_trace":
240
+ return ["conversation", "session-summary", "tool-error", "tool-input-summary"];
241
+ case "tool_audit":
242
+ return ["tool-input-summary", "tool-error"];
243
+ case "code":
244
+ return ["conversation", "session-summary", "code-touch", "patch-summary", "tool-input-summary"];
245
+ case "forensics":
246
+ return ["raw"];
247
+ case "recall":
248
+ default:
249
+ return DEFAULT_RECALL_CHANNELS;
250
+ }
251
+ }
252
+ function inferSurface(q, explicit = "recall") {
253
+ if (explicit !== "recall")
254
+ return explicit;
255
+ const s = q.toLowerCase();
256
+ if (/\b(error|exception|stack trace|failed|failure|crash|timeout|logs?|stderr|stdout)\b/.test(s))
257
+ return "debug_trace";
258
+ if (/\b(tool calls?|bash|command|grep|read tool|edit tool|apply_patch|mcp|jira tool|github tool)\b/.test(s))
259
+ return "tool_audit";
260
+ if (/\b(file|path|class|function|symbol|diff|patch|edited|wrote|changed|src\/|\.kt\b|\.ts\b|\.tsx\b|\.js\b|\.py\b)\b/.test(s))
261
+ return "code";
262
+ return "recall";
263
+ }
264
+ function channelWeight(channel) {
265
+ switch (channel) {
266
+ case "session-summary":
267
+ return 7;
268
+ case "conversation":
269
+ return 6;
270
+ case "tool-error":
271
+ return 5;
272
+ case "code-touch":
273
+ return 4;
274
+ case "patch-summary":
275
+ return 3;
276
+ case "tool-input-summary":
277
+ return 3;
278
+ case "file":
279
+ return 2;
280
+ case "tool-output":
281
+ return 1;
282
+ case "reasoning":
283
+ return 0;
284
+ case "raw":
285
+ return 0;
286
+ }
287
+ }
288
+ function normalizeForDedupe(s) {
289
+ return s.toLowerCase().replace(/\s+/g, " ").replace(/[\u27e6\u27e7]/g, "").trim();
290
+ }
291
+ function normalizePrompt(s) {
292
+ return s.toLowerCase().replace(/\b[A-Z][A-Z0-9_]+-\d+\b/gi, "<jira>").replace(/https?:\/\/\S+/g, "<url>").replace(/(?:\/[\w .@-]+){2,}/g, "<path>").replace(/\b[0-9a-f]{12,}\b/gi, "<id>").replace(/\bses_[A-Za-z0-9_-]+\b/g, "<session>").replace(/\bmsg_[A-Za-z0-9_-]+\b/g, "<message>").replace(/\bprt_[A-Za-z0-9_-]+\b/g, "<part>").replace(/\s+/g, " ").trim();
293
+ }
294
+ function normalizeError(s) {
295
+ 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();
296
+ }
297
+ function compactPath(path, baseDir) {
298
+ if (!baseDir || !path.startsWith(baseDir))
299
+ return { path, rel_path: null };
300
+ const rel = path.slice(baseDir.length).replace(/^\/+/, "");
301
+ return { path, rel_path: rel || "." };
302
+ }
303
+ function looksLikeExactIdentifier(q) {
304
+ return /\b(?:[A-Z][A-Z0-9_]+-\d+|ses_[A-Za-z0-9_-]+|msg_[A-Za-z0-9_-]+|prt_[A-Za-z0-9_-]+)\b/.test(q) || /https?:\/\/\S+/.test(q) || /(?:\/[\w .@-]+){2,}/.test(q);
305
+ }
306
+
307
+ // src/lib/export-constants.ts
308
+ var SEARCHABLE_TYPES = ["text", "reasoning", "tool", "file", "patch", "subtask"];
309
+
310
+ // src/lib/export-lock.ts
311
+ import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
312
+ import { hostname } from "os";
313
+ import { join as join2 } from "path";
314
+ import { randomUUID } from "crypto";
315
+ var LOCK_FILE = ".export.lock";
316
+ var DEFAULT_STALE_MS = 2 * 60 * 1000;
317
+ var HEARTBEAT_MS = 15000;
318
+ function acquireExportLock(root, staleMs = DEFAULT_STALE_MS) {
319
+ const path = join2(root, LOCK_FILE);
320
+ const first = tryCreateLock(path);
321
+ if (first)
322
+ return first;
323
+ const stale = staleCandidate(path, staleMs);
324
+ if (!stale)
325
+ return null;
326
+ if (!removeStaleLock(path, stale, staleMs))
327
+ return tryCreateLock(path);
328
+ return tryCreateLock(path);
329
+ }
330
+ function tryCreateLock(path) {
331
+ let fd = null;
332
+ try {
333
+ const token = randomUUID();
334
+ const now = Date.now();
335
+ const record = { token, pid: process.pid, hostname: hostname(), created_at: now, updated_at: now };
336
+ fd = openSync(path, "wx");
337
+ writeFileSync(fd, JSON.stringify(record));
338
+ closeSync(fd);
339
+ fd = null;
340
+ let lastHeartbeat = now;
341
+ return {
342
+ token,
343
+ release: () => releaseLock(path, token),
344
+ heartbeat: () => {
345
+ const current = Date.now();
346
+ if (current - lastHeartbeat < HEARTBEAT_MS)
347
+ return;
348
+ lastHeartbeat = current;
349
+ heartbeatLock(path, token, current);
350
+ }
351
+ };
352
+ } catch {
353
+ if (fd != null)
354
+ try {
355
+ closeSync(fd);
356
+ } catch {}
357
+ return null;
358
+ }
359
+ }
360
+ function staleCandidate(path, staleMs) {
361
+ try {
362
+ const stat = statSync(path);
363
+ const now = Date.now();
364
+ if (now - stat.mtimeMs <= staleMs)
365
+ return null;
366
+ const parsed = readLockRecord(path);
367
+ if (!parsed)
368
+ return { token: null, updatedAt: null, mtimeMs: stat.mtimeMs };
369
+ if (now - parsed.updated_at <= staleMs)
370
+ return null;
371
+ if (isLiveLocalOwner(parsed))
372
+ return null;
373
+ return { token: parsed.token, updatedAt: parsed.updated_at, mtimeMs: stat.mtimeMs };
374
+ } catch {
375
+ return { token: null, updatedAt: null, mtimeMs: 0 };
376
+ }
377
+ }
378
+ function removeStaleLock(path, stale, staleMs) {
379
+ try {
380
+ const stat = statSync(path);
381
+ if (stat.mtimeMs !== stale.mtimeMs)
382
+ return false;
383
+ if (stale.token) {
384
+ const current = readLockRecord(path);
385
+ if (current?.token !== stale.token)
386
+ return false;
387
+ if (current.updated_at !== stale.updatedAt)
388
+ return false;
389
+ const now = Date.now();
390
+ if (now - current.updated_at <= staleMs)
391
+ return false;
392
+ if (now - stat.mtimeMs <= staleMs)
393
+ return false;
394
+ if (isLiveLocalOwner(current))
395
+ return false;
396
+ } else {
397
+ if (Date.now() - stat.mtimeMs <= staleMs)
398
+ return false;
399
+ }
400
+ unlinkSync(path);
401
+ return true;
402
+ } catch {
403
+ return false;
404
+ }
405
+ }
406
+ function releaseLock(path, token) {
407
+ try {
408
+ const current = readLockRecord(path);
409
+ if (current?.token === token)
410
+ unlinkSync(path);
411
+ } catch {}
412
+ }
413
+ function heartbeatLock(path, token, now) {
414
+ try {
415
+ const current = readLockRecord(path);
416
+ if (current?.token !== token)
417
+ return;
418
+ writeFileSync(path, JSON.stringify({ ...current, updated_at: now }));
419
+ } catch {}
420
+ }
421
+ function readLockRecord(path) {
422
+ try {
423
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
424
+ if (typeof parsed.token !== "string")
425
+ return null;
426
+ if (typeof parsed.pid !== "number" || !Number.isFinite(parsed.pid))
427
+ return null;
428
+ if (typeof parsed.hostname !== "string")
429
+ return null;
430
+ if (typeof parsed.created_at !== "number" || !Number.isFinite(parsed.created_at))
431
+ return null;
432
+ const updated = typeof parsed.updated_at === "number" && Number.isFinite(parsed.updated_at) ? parsed.updated_at : parsed.created_at;
433
+ return { token: parsed.token, pid: parsed.pid, hostname: parsed.hostname, created_at: parsed.created_at, updated_at: updated };
434
+ } catch {
435
+ return null;
436
+ }
437
+ }
438
+ function isLiveLocalOwner(record) {
439
+ if (record.hostname !== hostname())
440
+ return false;
441
+ if (!Number.isSafeInteger(record.pid) || record.pid <= 0)
442
+ return false;
443
+ try {
444
+ process.kill(record.pid, 0);
445
+ return true;
446
+ } catch (error) {
447
+ return error?.code === "EPERM";
448
+ }
449
+ }
450
+
451
+ // src/lib/export-tombstones.ts
452
+ import { existsSync as existsSync2, readdirSync, rmSync, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
453
+ import { join as join3 } from "path";
454
+ var PART_FILE_RE = /^(?:\d{5}-)?(prt_[A-Za-z0-9_-]+)\.txt$/;
455
+ function reconcileTombstones(root, heartbeat = () => {}) {
456
+ const progress = {
457
+ scanned_sessions: 0,
458
+ removed_sessions: 0,
459
+ removed_parts: 0,
460
+ removed_channel_sessions: 0
461
+ };
462
+ const bySession = join3(root, "by-session");
463
+ const sessions = loadSessionIds();
464
+ if (existsSync2(bySession)) {
465
+ for (const entry of safeReadDir(bySession)) {
466
+ const dir = join3(bySession, entry);
467
+ if (!isDirectory(dir))
468
+ continue;
469
+ if (!sessions.has(entry)) {
470
+ rmSync(dir, { recursive: true, force: true });
471
+ removeChannelSessionDirs(root, entry);
472
+ progress.removed_sessions++;
473
+ heartbeat();
474
+ continue;
475
+ }
476
+ progress.scanned_sessions++;
477
+ progress.removed_parts += removeOrphanPartFiles(root, entry, dir, heartbeat);
478
+ heartbeat();
479
+ }
480
+ }
481
+ progress.removed_channel_sessions += removeOrphanChannelSessions(root, sessions, heartbeat);
482
+ return progress;
483
+ }
484
+ function removeOrphanPartFiles(root, sessionId, dir, heartbeat) {
485
+ const livePartIds = loadSearchablePartIds(sessionId);
486
+ let removed = 0;
487
+ for (const file of safeReadDir(dir)) {
488
+ heartbeat();
489
+ const partId = partIdFromFile(file);
490
+ if (!partId || livePartIds.has(partId))
491
+ continue;
492
+ try {
493
+ unlinkSync2(join3(dir, file));
494
+ removeChannelPartFiles(root, sessionId, partId);
495
+ removed++;
496
+ } catch {}
497
+ }
498
+ return removed;
499
+ }
500
+ function removeOrphanChannelSessions(root, sessions, heartbeat) {
501
+ let removed = 0;
502
+ for (const channel of CHANNELS) {
503
+ const base = join3(root, "by-channel", channel, "by-session");
504
+ if (!existsSync2(base))
505
+ continue;
506
+ for (const sessionId of safeReadDir(base)) {
507
+ heartbeat();
508
+ const dir = join3(base, sessionId);
509
+ if (!isDirectory(dir) || sessions.has(sessionId))
510
+ continue;
511
+ rmSync(dir, { recursive: true, force: true });
512
+ removed++;
513
+ }
514
+ }
515
+ return removed;
516
+ }
517
+ function removeChannelSessionDirs(root, sessionId) {
518
+ for (const channel of CHANNELS) {
519
+ rmSync(channelDir(root, channel, sessionId), { recursive: true, force: true });
520
+ }
521
+ }
522
+ function removeChannelPartFiles(root, sessionId, partId) {
523
+ for (const channel of CHANNELS) {
524
+ const dir = channelDir(root, channel, sessionId);
525
+ if (!existsSync2(dir))
526
+ continue;
527
+ for (const file of safeReadDir(dir)) {
528
+ if (file === `${partId}.txt` || file.endsWith(`-${partId}.txt`)) {
529
+ try {
530
+ unlinkSync2(join3(dir, file));
531
+ } catch {}
532
+ }
533
+ }
534
+ }
535
+ }
536
+ function channelDir(root, channel, sessionId) {
537
+ return join3(root, "by-channel", channel, "by-session", sessionId);
538
+ }
539
+ function loadSessionIds() {
540
+ const rows = stmt(`SELECT id FROM session`).all();
541
+ return new Set(rows.map((row) => row.id));
542
+ }
543
+ function loadSearchablePartIds(sessionId) {
544
+ const placeholders = SEARCHABLE_TYPES.map(() => "?").join(",");
545
+ const rows = stmt(`
546
+ SELECT id
547
+ FROM part
548
+ WHERE session_id = ?
549
+ AND json_extract(data,'$.type') IN (${placeholders})
550
+ ORDER BY id ASC`).all(sessionId, ...SEARCHABLE_TYPES);
551
+ return new Set(rows.map((row) => row.id));
552
+ }
553
+ function safeReadDir(dir) {
554
+ try {
555
+ return readdirSync(dir);
556
+ } catch {
557
+ return [];
558
+ }
559
+ }
560
+ function isDirectory(path) {
561
+ try {
562
+ return statSync2(path).isDirectory();
563
+ } catch {
564
+ return false;
565
+ }
566
+ }
567
+ function partIdFromFile(file) {
568
+ const match = PART_FILE_RE.exec(file);
569
+ return match ? match[1] : null;
570
+ }
571
+
572
+ // src/lib/export-background.ts
573
+ import { existsSync as existsSync3 } from "fs";
574
+ import { fileURLToPath } from "url";
575
+ var DEFAULT_MIN_INTERVAL_MS = 5 * 60 * 1000;
576
+ var inFlight = false;
577
+ var lastStartedAt = 0;
578
+ function scheduleBackgroundReconcile(opts) {
579
+ const now = Date.now();
580
+ const minIntervalMs = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
581
+ if (inFlight)
582
+ return { scheduled: false, reason: "already_running" };
583
+ if (now - lastStartedAt < minIntervalMs)
584
+ return { scheduled: false, reason: "throttled" };
585
+ const url = resolveWorkerUrl();
586
+ try {
587
+ const worker = new Worker(url, { type: "module" });
588
+ inFlight = true;
589
+ lastStartedAt = now;
590
+ const cleanup = () => {
591
+ inFlight = false;
592
+ worker.terminate();
593
+ };
594
+ worker.addEventListener("message", cleanup, { once: true });
595
+ worker.addEventListener("error", cleanup, { once: true });
596
+ const maybeUnref = worker;
597
+ maybeUnref.unref?.();
598
+ const request = { root: opts.root, batchSize: opts.batchSize ?? 2000 };
599
+ worker.postMessage(request);
600
+ return { scheduled: true };
601
+ } catch {
602
+ inFlight = false;
603
+ return { scheduled: false, reason: "worker_unavailable" };
604
+ }
605
+ }
606
+ function resolveWorkerUrl() {
607
+ const js = new URL("./export-reconcile-worker.js", import.meta.url);
608
+ if (existsSync3(fileURLToPath(js)))
609
+ return js;
610
+ const ts = new URL("./export-reconcile-worker.ts", import.meta.url);
611
+ if (existsSync3(fileURLToPath(ts)))
612
+ return ts;
613
+ const bundled = new URL("./lib/export-reconcile-worker.js", import.meta.url);
614
+ if (existsSync3(fileURLToPath(bundled)))
615
+ return bundled;
616
+ return js;
617
+ }
618
+
619
+ // src/lib/export-state.ts
620
+ import { existsSync as existsSync4, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
621
+ import { join as join4 } from "path";
622
+ var CURSOR_SCHEMA = "v3";
623
+ var LAST_SYNC_FILE = ".last_sync";
624
+ function freshSyncState(migratedFrom, legacyCursor = null) {
625
+ return {
626
+ schema: CURSOR_SCHEMA,
627
+ insert_cursor: { id: "" },
628
+ session_cursor: null,
629
+ session_dirty_hints: {},
630
+ reconcile_watermark: null,
631
+ failed_parts: {},
632
+ dead_letters: {},
633
+ last_reconcile_at: null,
634
+ legacy_cursor: legacyCursor,
635
+ migrated_from: migratedFrom
636
+ };
637
+ }
638
+ function getSyncState(root) {
639
+ const p = join4(root, LAST_SYNC_FILE);
640
+ if (!existsSync4(p))
641
+ return freshSyncState();
642
+ try {
643
+ return parseSyncState(readFileSync2(p, "utf8"));
644
+ } catch {
645
+ return freshSyncState("unreadable");
646
+ }
647
+ }
648
+ function setSyncState(state, root) {
649
+ const p = join4(root, LAST_SYNC_FILE);
650
+ const tmp = p + ".tmp";
651
+ writeFileSync2(tmp, `${CURSOR_SCHEMA} ${JSON.stringify(normalizeSyncState(state))}`);
652
+ renameSync(tmp, p);
653
+ }
654
+ function getLastSync(root) {
655
+ const p = join4(root, LAST_SYNC_FILE);
656
+ if (!existsSync4(p))
657
+ return null;
658
+ const state = getSyncState(root);
659
+ if (state.legacy_cursor)
660
+ return state.legacy_cursor;
661
+ if (!state.insert_cursor.id)
662
+ return null;
663
+ return { ts: 0, id: state.insert_cursor.id };
664
+ }
665
+ function parseSyncState(rawInput) {
666
+ const raw = rawInput.trim();
667
+ if (!raw)
668
+ return freshSyncState("empty");
669
+ if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
670
+ const parsed = JSON.parse(raw.slice(CURSOR_SCHEMA.length + 1));
671
+ return normalizeSyncState(parsed);
672
+ }
673
+ if (raw.startsWith("{")) {
674
+ return normalizeSyncState(JSON.parse(raw));
675
+ }
676
+ if (raw.startsWith("v2 ")) {
677
+ return freshSyncState("v2", parseLegacyCursor(raw.slice(3)));
678
+ }
679
+ const legacy = parseLegacyCursor(raw);
680
+ return freshSyncState(legacy ? "v1" : "unknown", legacy);
681
+ }
682
+ function normalizeSyncState(input) {
683
+ if (!isRecord(input))
684
+ return freshSyncState("invalid");
685
+ const state = freshSyncState(asString(input.migrated_from) ?? undefined, cursorOrNull(input.legacy_cursor));
686
+ const insert = isRecord(input.insert_cursor) ? input.insert_cursor : null;
687
+ state.insert_cursor.id = asString(insert?.id) ?? "";
688
+ state.session_cursor = cursorOrNull(input.session_cursor);
689
+ state.session_dirty_hints = dirtyHints(input.session_dirty_hints);
690
+ state.reconcile_watermark = reconcileWatermark(input.reconcile_watermark);
691
+ state.failed_parts = failedParts(input.failed_parts);
692
+ state.dead_letters = failedParts(input.dead_letters);
693
+ state.last_reconcile_at = finiteOrNull(input.last_reconcile_at);
694
+ return state;
695
+ }
696
+ function parseLegacyCursor(raw) {
697
+ const idx = raw.indexOf(":");
698
+ if (idx <= 0)
699
+ return null;
700
+ const ts = Number(raw.slice(0, idx));
701
+ const id = raw.slice(idx + 1);
702
+ if (!Number.isFinite(ts) || !id)
703
+ return null;
704
+ return { ts, id };
705
+ }
706
+ function cursorOrNull(value) {
707
+ if (!isRecord(value))
708
+ return null;
709
+ const ts = finiteOrNull(value.ts);
710
+ const id = asString(value.id);
711
+ if (ts == null || !id)
712
+ return null;
713
+ return { ts, id };
714
+ }
715
+ function dirtyHints(value) {
716
+ if (!isRecord(value))
717
+ return {};
718
+ const out = {};
719
+ for (const [id, raw] of Object.entries(value)) {
720
+ if (!id)
721
+ continue;
722
+ if (typeof raw === "number" && Number.isFinite(raw)) {
723
+ out[id] = { time_updated: raw, part_cursor: null };
724
+ continue;
725
+ }
726
+ if (!isRecord(raw))
727
+ continue;
728
+ const timeUpdated = finiteOrNull(raw.time_updated);
729
+ if (timeUpdated == null)
730
+ continue;
731
+ out[id] = { time_updated: timeUpdated, part_cursor: asString(raw.part_cursor) };
732
+ }
733
+ return out;
734
+ }
735
+ function reconcileWatermark(value) {
736
+ if (!isRecord(value))
737
+ return null;
738
+ const at = finiteOrNull(value.at);
739
+ if (at == null)
740
+ return null;
741
+ return {
742
+ part_id: asString(value.part_id),
743
+ session_id: asString(value.session_id),
744
+ at
745
+ };
746
+ }
747
+ function failedParts(value) {
748
+ if (!isRecord(value))
749
+ return {};
750
+ const out = {};
751
+ for (const [id, raw] of Object.entries(value)) {
752
+ if (!id || !isRecord(raw))
753
+ continue;
754
+ const attempts = finiteOrNull(raw.attempts);
755
+ const firstFailedAt = finiteOrNull(raw.first_failed_at);
756
+ const lastFailedAt = finiteOrNull(raw.last_failed_at);
757
+ if (attempts == null || firstFailedAt == null || lastFailedAt == null)
758
+ continue;
759
+ out[id] = {
760
+ id,
761
+ attempts,
762
+ first_failed_at: firstFailedAt,
763
+ last_failed_at: lastFailedAt,
764
+ last_error: asString(raw.last_error) ?? "unknown export failure"
765
+ };
766
+ }
767
+ return out;
768
+ }
769
+ function finiteOrNull(value) {
770
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
771
+ }
772
+ function asString(value) {
773
+ return typeof value === "string" && value.length > 0 ? value : null;
774
+ }
775
+ function isRecord(value) {
776
+ return typeof value === "object" && value !== null && !Array.isArray(value);
777
+ }
778
+
779
+ // src/lib/export.ts
780
+ var DEFAULT_EXPORT_ROOT = join5(homedir2(), ".local/share/opencode-sessions-explorer");
781
+ var BODY_CAP_BYTES = 256 * 1024;
782
+ var SAFETY_PART_CAP_BYTES = 50 * 1024 * 1024;
783
+ var CHANNEL_COMPLETE_MARKER = ".channels_v1_complete";
784
+ var INSERT_REWIND_MS = 3 * 60 * 1000;
785
+ var INSERT_REWIND_MAX_ROWS = 512;
786
+ var MAX_FAILED_ATTEMPTS = 5;
787
+ var PART_CHANNELS = CHANNELS.filter((c) => c !== "session-summary" && c !== "raw");
788
+ function exportRoot() {
789
+ return process.env.OPENCODE_SESSIONS_EXPLORER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT;
790
+ }
791
+ function getSyncState2(root = exportRoot()) {
792
+ return getSyncState(root);
793
+ }
794
+ function setSyncState2(state, root = exportRoot()) {
795
+ setSyncState(state, root);
796
+ }
797
+ function getLastSync2(root = exportRoot()) {
798
+ return getLastSync(root);
799
+ }
800
+ function ensureRoot(root = exportRoot()) {
801
+ mkdirSync(join5(root, "by-session"), { recursive: true });
802
+ return root;
803
+ }
804
+ function channelExportComplete(root = exportRoot()) {
805
+ return existsSync5(join5(root, CHANNEL_COMPLETE_MARKER));
806
+ }
807
+ var sessionCache = new Map;
808
+ function getSession(id) {
809
+ const cached = sessionCache.get(id);
810
+ if (cached)
811
+ return cached;
812
+ const row = stmt(`
813
+ SELECT id, title, project_id, directory, agent, model, cost,
814
+ time_created, time_updated, time_archived, parent_id
815
+ FROM session WHERE id = ?`).get(id);
816
+ if (!row)
817
+ return null;
818
+ sessionCache.set(id, row);
819
+ return row;
820
+ }
821
+ function buildPartFile(partId, sessionId, messageId, data, archived) {
822
+ let decoded;
823
+ try {
824
+ decoded = decodePart(data);
825
+ } catch {
826
+ return null;
827
+ }
828
+ if (!SEARCHABLE_TYPES.includes(decoded.type))
829
+ return null;
830
+ const lines = [];
831
+ lines.push(`PART_ID: ${partId}`);
832
+ lines.push(`SESSION_ID: ${sessionId}`);
833
+ lines.push(`MESSAGE_ID: ${messageId}`);
834
+ lines.push(`TYPE: ${decoded.type}`);
835
+ lines.push(`ARCHIVED: ${archived}`);
836
+ let body = "";
837
+ switch (decoded.type) {
838
+ case "text":
839
+ body = decoded.text;
840
+ break;
841
+ case "reasoning":
842
+ body = decoded.text;
843
+ break;
844
+ case "tool": {
845
+ lines.push(`TOOL: ${decoded.tool}`);
846
+ lines.push(`STATUS: ${decoded.status}`);
847
+ if (decoded.start != null && decoded.end != null)
848
+ lines.push(`TIME: ${decoded.start} - ${decoded.end}`);
849
+ const parts = [];
850
+ try {
851
+ parts.push("INPUT: " + JSON.stringify(decoded.input));
852
+ } catch {
853
+ parts.push("INPUT: <unserializable>");
854
+ }
855
+ if (decoded.output)
856
+ parts.push(`OUTPUT:
857
+ ` + decoded.output);
858
+ if (decoded.error)
859
+ parts.push(`ERROR:
860
+ ` + decoded.error);
861
+ body = parts.join(`
862
+ `);
863
+ break;
864
+ }
865
+ case "file":
866
+ lines.push(`MIME: ${decoded.mime ?? "?"}`);
867
+ body = `FILENAME: ${decoded.filename ?? "?"}
868
+ URL: ${decoded.url ?? "?"}
869
+ SOURCE_PATH: ${decoded.sourcePath ?? "?"}`;
870
+ break;
871
+ case "patch":
872
+ lines.push(`HASH: ${decoded.hash ?? "?"}`);
873
+ lines.push(`FILES_COUNT: ${decoded.files.length}`);
874
+ body = `FILES:
875
+ ` + decoded.files.join(`
876
+ `);
877
+ break;
878
+ case "subtask":
879
+ lines.push(`AGENT: ${decoded.agent ?? "?"}`);
880
+ if (decoded.description)
881
+ body += `DESCRIPTION: ${decoded.description}
882
+ `;
883
+ body += `PROMPT:
884
+ ${decoded.prompt}`;
885
+ break;
886
+ default:
887
+ return null;
888
+ }
889
+ lines.push("---BODY---");
890
+ const enc = new TextEncoder;
891
+ let bodyBytes = enc.encode(body).length;
892
+ if (bodyBytes > BODY_CAP_BYTES) {
893
+ const truncMarker = `
894
+ \u2026[truncated; ${bodyBytes} bytes original; call get_part('${partId}') for full content]`;
895
+ const markerBytes = enc.encode(truncMarker).length;
896
+ const sliced = enc.encode(body).slice(0, Math.max(0, BODY_CAP_BYTES - markerBytes));
897
+ body = new TextDecoder("utf-8", { fatal: false }).decode(sliced).replace(/\uFFFD+$/, "") + truncMarker;
898
+ bodyBytes = enc.encode(body).length;
899
+ }
900
+ lines.push(body);
901
+ return { content: lines.join(`
902
+ `), type: decoded.type };
903
+ }
904
+ function buildChannelDocuments(partId, sessionId, messageId, data, archived, role, sessionDirectory) {
905
+ let decoded;
906
+ try {
907
+ decoded = decodePart(data);
908
+ } catch {
909
+ return [];
910
+ }
911
+ const docs = [];
912
+ const baseHeaders = [
913
+ `PART_ID: ${partId}`,
914
+ `SESSION_ID: ${sessionId}`,
915
+ `MESSAGE_ID: ${messageId}`,
916
+ `ROLE: ${role ?? "unknown"}`,
917
+ `TYPE: ${decoded.type}`,
918
+ `ARCHIVED: ${archived}`
919
+ ];
920
+ const emit = (channel, body, extra = []) => {
921
+ const trimmed = body.trim();
922
+ if (!trimmed)
923
+ return;
924
+ docs.push({ channel, content: [...baseHeaders, `CHANNEL: ${channel}`, ...extra, "---BODY---", capBody(trimmed, partId)].join(`
925
+ `) });
926
+ };
927
+ switch (decoded.type) {
928
+ case "text":
929
+ emit("conversation", decoded.text);
930
+ break;
931
+ case "reasoning":
932
+ emit("reasoning", decoded.text);
933
+ break;
934
+ case "subtask":
935
+ emit("conversation", `${decoded.description ? `DESCRIPTION: ${decoded.description}
936
+ ` : ""}PROMPT:
937
+ ${decoded.prompt}`, [`AGENT: ${decoded.agent ?? "?"}`]);
938
+ break;
939
+ case "tool": {
940
+ const extra = [`TOOL: ${decoded.tool}`, `STATUS: ${decoded.status}`];
941
+ const inputSummary = summarizeToolInput(decoded.tool, decoded.input, sessionDirectory);
942
+ emit("tool-input-summary", inputSummary || `${decoded.tool} ${decoded.status}`, extra);
943
+ if (decoded.status === "error" && decoded.error)
944
+ emit("tool-error", normalizeError(decoded.error), extra);
945
+ if (decoded.output)
946
+ emit("tool-output", decoded.output, extra);
947
+ const codeTouch = summarizeCodeTouch(decoded.tool, decoded.input, sessionDirectory);
948
+ if (codeTouch)
949
+ emit("code-touch", codeTouch, extra);
950
+ break;
951
+ }
952
+ case "patch": {
953
+ const body = decoded.files.map((f) => compactPath(f, sessionDirectory).rel_path ?? f).join(`
954
+ `);
955
+ emit("patch-summary", body, [`HASH: ${decoded.hash ?? "?"}`, `FILES_COUNT: ${decoded.files.length}`]);
956
+ emit("code-touch", body, [`SOURCE: patch`, `FILES_COUNT: ${decoded.files.length}`]);
957
+ break;
958
+ }
959
+ case "file":
960
+ emit("file", `FILENAME: ${decoded.filename ?? "?"}
961
+ URL: ${decoded.url ?? "?"}
962
+ SOURCE_PATH: ${decoded.sourcePath ?? "?"}`);
963
+ break;
964
+ }
965
+ return docs;
966
+ }
967
+ function capBody(body, partId) {
968
+ const enc = new TextEncoder;
969
+ const bytes = enc.encode(body).length;
970
+ if (bytes <= BODY_CAP_BYTES)
971
+ return body;
972
+ const marker = `
973
+ ...[truncated; ${bytes} bytes original; call get_part('${partId}') for full content]`;
974
+ const markerBytes = enc.encode(marker).length;
975
+ const sliced = enc.encode(body).slice(0, Math.max(0, BODY_CAP_BYTES - markerBytes));
976
+ return new TextDecoder("utf-8", { fatal: false }).decode(sliced).replace(/\uFFFD+$/, "") + marker;
977
+ }
978
+ function summarizeToolInput(tool, input, sessionDirectory) {
979
+ if (!input || typeof input !== "object")
980
+ return stringifySafe(input);
981
+ const obj = input;
982
+ const lines = [];
983
+ const add = (label, value) => {
984
+ if (typeof value === "string" && value.trim())
985
+ lines.push(`${label}: ${compactPath(value, sessionDirectory).rel_path ?? value}`);
986
+ else if (typeof value === "number" || typeof value === "boolean")
987
+ lines.push(`${label}: ${value}`);
988
+ };
989
+ lines.push(`TOOL: ${tool}`);
990
+ for (const key of ["command", "description", "filePath", "path", "url", "query", "pattern", "session_id", "message_id", "part_id", "issue_key", "pullNumber"]) {
991
+ add(key, obj[key]);
992
+ }
993
+ if (Array.isArray(obj.paths))
994
+ for (const p of obj.paths.slice(0, 20))
995
+ add("path", p);
996
+ if (Array.isArray(obj.files))
997
+ for (const p of obj.files.slice(0, 20))
998
+ add("file", p);
999
+ return lines.length > 1 ? lines.join(`
1000
+ `) : stringifySafe(input);
1001
+ }
1002
+ function summarizeCodeTouch(tool, input, sessionDirectory) {
1003
+ if (!input || typeof input !== "object")
1004
+ return null;
1005
+ const obj = input;
1006
+ const paths = [];
1007
+ for (const key of ["filePath", "path"]) {
1008
+ if (typeof obj[key] === "string")
1009
+ paths.push(obj[key]);
1010
+ }
1011
+ for (const key of ["paths", "files"]) {
1012
+ if (Array.isArray(obj[key])) {
1013
+ for (const p of obj[key])
1014
+ if (typeof p === "string")
1015
+ paths.push(p);
1016
+ }
1017
+ }
1018
+ if (paths.length === 0)
1019
+ return null;
1020
+ return [`TOOL: ${tool}`, ...paths.slice(0, 50).map((p) => compactPath(p, sessionDirectory).rel_path ?? p)].join(`
1021
+ `);
1022
+ }
1023
+ function stringifySafe(value) {
1024
+ try {
1025
+ return JSON.stringify(value);
1026
+ } catch {
1027
+ return "<unserializable>";
1028
+ }
1029
+ }
1030
+ function safePartFilename(seq, partId) {
1031
+ const safe = partId.replace(/[^A-Za-z0-9_-]/g, "_");
1032
+ return `${String(seq).padStart(5, "0")}-${safe}.txt`;
1033
+ }
1034
+ function writeMeta(s, dir) {
1035
+ const meta = {
1036
+ id: s.id,
1037
+ title: s.title,
1038
+ project_id: s.project_id,
1039
+ directory: s.directory,
1040
+ agent: s.agent,
1041
+ model: decodeModel(s.model),
1042
+ cost: Number(s.cost ?? 0),
1043
+ parent_id: s.parent_id,
1044
+ time_created: s.time_created,
1045
+ time_updated: s.time_updated,
1046
+ archived: s.time_archived != null
1047
+ };
1048
+ const p = join5(dir, "meta.json");
1049
+ const tmp = p + ".tmp";
1050
+ writeFileSync3(tmp, JSON.stringify(meta, null, 2));
1051
+ renameSync2(tmp, p);
1052
+ }
1053
+ function writePartFile(dir, filename, content) {
1054
+ const p = join5(dir, filename);
1055
+ const tmp = join5(dir, "." + filename + ".tmp");
1056
+ writeFileSync3(tmp, content);
1057
+ renameSync2(tmp, p);
1058
+ const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(filename);
1059
+ if (!m)
1060
+ return;
1061
+ const myPartId = m[2];
1062
+ try {
1063
+ for (const f of readdirSync2(dir)) {
1064
+ if (f === filename || !f.endsWith(".txt") || f.startsWith("."))
1065
+ continue;
1066
+ const fm = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
1067
+ if (fm && fm[2] === myPartId) {
1068
+ try {
1069
+ unlinkSync3(join5(dir, f));
1070
+ } catch {}
1071
+ }
1072
+ }
1073
+ } catch {}
1074
+ }
1075
+ function channelDir2(root, channel, sessionId) {
1076
+ return join5(root, "by-channel", channel, "by-session", sessionId);
1077
+ }
1078
+ function deleteChannelPartFiles(root, sessionId, partId) {
1079
+ for (const ch of PART_CHANNELS) {
1080
+ const dir = channelDir2(root, ch, sessionId);
1081
+ if (!existsSync5(dir))
1082
+ continue;
1083
+ try {
1084
+ for (const f of readdirSync2(dir)) {
1085
+ if (f === `${partId}.txt` || f.endsWith(`-${partId}.txt`)) {
1086
+ try {
1087
+ unlinkSync3(join5(dir, f));
1088
+ } catch {}
1089
+ }
1090
+ }
1091
+ } catch {}
1092
+ }
1093
+ }
1094
+ function writeChannelFiles(root, sessionId, filename, partId, docs) {
1095
+ deleteChannelPartFiles(root, sessionId, partId);
1096
+ for (const doc of docs) {
1097
+ const dir = channelDir2(root, doc.channel, sessionId);
1098
+ if (!existsSync5(dir))
1099
+ mkdirSync(dir, { recursive: true });
1100
+ writePartFile(dir, filename, doc.content);
1101
+ }
1102
+ }
1103
+ function writeSessionSummaryChannel(s, dirRoot = exportRoot()) {
1104
+ const dir = channelDir2(dirRoot, "session-summary", s.id);
1105
+ if (!existsSync5(dir))
1106
+ mkdirSync(dir, { recursive: true });
1107
+ const p = join5(dir, "summary.txt");
1108
+ const tmp = p + ".tmp";
1109
+ writeFileSync3(tmp, buildSessionSummaryDocument(s));
1110
+ renameSync2(tmp, p);
1111
+ }
1112
+ function buildSessionSummaryDocument(s) {
1113
+ const firstPrompt = firstUserPrompt(s.id, "ASC");
1114
+ const lastPrompt = firstUserPrompt(s.id, "DESC");
1115
+ const model = decodeModel(s.model);
1116
+ const lines = [
1117
+ `SESSION_ID: ${s.id}`,
1118
+ `CHANNEL: session-summary`,
1119
+ `TITLE: ${s.title}`,
1120
+ `PROJECT_ID: ${s.project_id}`,
1121
+ `DIRECTORY: ${s.directory}`,
1122
+ `AGENT: ${s.agent ?? "unknown"}`,
1123
+ `MODEL: ${model.id ?? "unknown"}`,
1124
+ `ARCHIVED: ${s.time_archived != null}`,
1125
+ `PARENT_ID: ${s.parent_id ?? ""}`,
1126
+ "---BODY---",
1127
+ `TITLE: ${s.title}`,
1128
+ `DIRECTORY: ${s.directory}`,
1129
+ firstPrompt ? `FIRST_USER_PROMPT:
1130
+ ${firstPrompt}` : "FIRST_USER_PROMPT:",
1131
+ lastPrompt && lastPrompt !== firstPrompt ? `LAST_USER_PROMPT:
1132
+ ${lastPrompt}` : ""
1133
+ ].filter(Boolean);
1134
+ return lines.join(`
1135
+ `);
1136
+ }
1137
+ function firstUserPrompt(sessionId, direction) {
1138
+ const msg = stmt(`
1139
+ SELECT id
1140
+ FROM message
1141
+ WHERE session_id = ? AND json_extract(data,'$.role') = 'user'
1142
+ ORDER BY time_created ${direction}, id ${direction}
1143
+ LIMIT 1`).get(sessionId);
1144
+ if (!msg)
1145
+ return null;
1146
+ const rows = stmt(`
1147
+ SELECT json_extract(data,'$.text') AS text
1148
+ FROM part
1149
+ WHERE message_id = ? AND json_extract(data,'$.type') = 'text'
1150
+ ORDER BY time_created ASC, id ASC`).all(msg.id);
1151
+ const joined = rows.map((r) => r.text ?? "").join(`
1152
+ `).trim();
1153
+ if (!joined)
1154
+ return null;
1155
+ return capBody(joined, "summary");
1156
+ }
1157
+ var fileIndexBySession = new Map;
1158
+ function getFileIndex(sessionId, dir) {
1159
+ let idx = fileIndexBySession.get(sessionId);
1160
+ if (idx)
1161
+ return idx;
1162
+ idx = { nextSeq: 1, byPartId: new Map };
1163
+ try {
1164
+ const files = readdirSync2(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
1165
+ let max = 0;
1166
+ for (const f of files) {
1167
+ const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
1168
+ if (m) {
1169
+ const seq = Number(m[1]);
1170
+ const partId = m[2];
1171
+ idx.byPartId.set(partId, f);
1172
+ if (seq > max)
1173
+ max = seq;
1174
+ } else if (/^prt_[A-Za-z0-9_-]+\.txt$/.test(f)) {
1175
+ idx.byPartId.set(f.replace(/\.txt$/, ""), f);
1176
+ }
1177
+ }
1178
+ idx.nextSeq = max + 1;
1179
+ } catch {}
1180
+ fileIndexBySession.set(sessionId, idx);
1181
+ return idx;
1182
+ }
1183
+ async function runExport(opts = {}) {
1184
+ const root = ensureRoot(opts.root ?? exportRoot());
1185
+ const batchSize = opts.batchSize ?? 1000;
1186
+ const progress = emptyProgress(getLastSync2(root));
1187
+ const lock = acquireExportLock(root);
1188
+ if (!lock) {
1189
+ progress.lock_skipped = true;
1190
+ return progress;
1191
+ }
1192
+ try {
1193
+ const state = getSyncState2(root);
1194
+ applyCursorOverride(state, opts.fromCursor);
1195
+ const start = Date.now();
1196
+ const touchedSessions = new Set;
1197
+ retryFailedParts(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
1198
+ runInsertFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
1199
+ runSessionDirtyFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
1200
+ refreshTouchedSessions(root, touchedSessions);
1201
+ if (!opts.budgetMs) {
1202
+ lock.heartbeat();
1203
+ const tombstones = reconcileTombstones(root, lock.heartbeat);
1204
+ applyTombstoneProgress(progress, tombstones);
1205
+ state.last_reconcile_at = Date.now();
1206
+ state.reconcile_watermark = {
1207
+ part_id: state.insert_cursor.id || null,
1208
+ session_id: state.session_cursor?.id ?? null,
1209
+ at: state.last_reconcile_at
1210
+ };
1211
+ }
1212
+ progress.last_cursor = state.legacy_cursor;
1213
+ setSyncState2(state, root);
1214
+ } finally {
1215
+ lock.release();
1216
+ }
1217
+ if (opts.budgetMs && !opts.skipBackgroundReconcile) {
1218
+ scheduleBackgroundReconcile({ root });
1219
+ }
1220
+ return progress;
1221
+ }
1222
+ function emptyProgress(cursor) {
1223
+ return {
1224
+ exported: 0,
1225
+ inserts: 0,
1226
+ updates: 0,
1227
+ skipped_nontext: 0,
1228
+ skipped_oversize: 0,
1229
+ failed: 0,
1230
+ retried: 0,
1231
+ dead_lettered: 0,
1232
+ tombstones_removed_parts: 0,
1233
+ tombstones_removed_sessions: 0,
1234
+ lock_skipped: false,
1235
+ last_cursor: cursor
1236
+ };
1237
+ }
1238
+ function applyCursorOverride(state, cursor) {
1239
+ if (cursor === undefined)
1240
+ return;
1241
+ state.legacy_cursor = cursor;
1242
+ state.insert_cursor.id = cursor?.id ?? "";
1243
+ state.session_cursor = cursor && cursor.ts > 0 ? cursor : null;
1244
+ state.session_dirty_hints = {};
1245
+ }
1246
+ function retryFailedParts(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
1247
+ const ids = Object.keys(state.failed_parts).sort().slice(0, batchSize);
1248
+ for (const id of ids) {
1249
+ if (timeExceeded(start, budgetMs))
1250
+ break;
1251
+ progress.retried++;
1252
+ const row = loadPartById(id);
1253
+ if (!row) {
1254
+ clearPartFailure(state, id);
1255
+ continue;
1256
+ }
1257
+ exportPartRow(root, state, row, progress, touchedSessions);
1258
+ reportProgress(progress, onProgress, heartbeat);
1259
+ }
1260
+ }
1261
+ function runInsertFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
1262
+ const recentSafeRows = [];
1263
+ let scanCursor = state.insert_cursor.id;
1264
+ while (!timeExceeded(start, budgetMs)) {
1265
+ const rows = loadPartRowsAfterId(scanCursor, batchSize);
1266
+ if (rows.length === 0)
1267
+ break;
1268
+ for (const row of rows) {
1269
+ if (timeExceeded(start, budgetMs))
1270
+ break;
1271
+ scanCursor = row.id;
1272
+ const safe = exportPartRow(root, state, row, progress, touchedSessions);
1273
+ if (safe)
1274
+ rememberSafeRow(recentSafeRows, row);
1275
+ reportProgress(progress, onProgress, heartbeat);
1276
+ }
1277
+ if (rows.length < batchSize)
1278
+ break;
1279
+ }
1280
+ if (recentSafeRows.length > 0) {
1281
+ state.insert_cursor.id = chooseInsertCursor(state.insert_cursor.id, recentSafeRows, budgetMs !== undefined);
1282
+ }
1283
+ }
1284
+ function runSessionDirtyFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
1285
+ scanDirtySessionHints(state, start, budgetMs, Math.min(batchSize, 500));
1286
+ for (const [sessionId, hint] of sortedDirtyHints(state.session_dirty_hints)) {
1287
+ while (!timeExceeded(start, budgetMs)) {
1288
+ const rows = loadSessionPartRows(sessionId, hint.part_cursor, batchSize);
1289
+ if (rows.length === 0) {
1290
+ delete state.session_dirty_hints[sessionId];
1291
+ break;
1292
+ }
1293
+ for (const row of rows) {
1294
+ if (timeExceeded(start, budgetMs))
1295
+ break;
1296
+ hint.part_cursor = row.id;
1297
+ exportPartRow(root, state, row, progress, touchedSessions);
1298
+ reportProgress(progress, onProgress, heartbeat);
1299
+ }
1300
+ if (rows.length < batchSize) {
1301
+ delete state.session_dirty_hints[sessionId];
1302
+ break;
1303
+ }
1304
+ }
1305
+ if (timeExceeded(start, budgetMs))
1306
+ break;
1307
+ }
1308
+ }
1309
+ function scanDirtySessionHints(state, start, budgetMs, limit) {
1310
+ while (!timeExceeded(start, budgetMs)) {
1311
+ const rows = loadDirtySessionsAfter(state.session_cursor, limit);
1312
+ if (rows.length === 0)
1313
+ break;
1314
+ for (const row of rows) {
1315
+ state.session_dirty_hints[row.id] = { time_updated: row.time_updated, part_cursor: null };
1316
+ state.session_cursor = { ts: row.time_updated, id: row.id };
1317
+ }
1318
+ if (rows.length < limit)
1319
+ break;
1320
+ }
1321
+ }
1322
+ function exportPartRow(root, state, row, progress, touchedSessions) {
1323
+ if (row.data_bytes > SAFETY_PART_CAP_BYTES) {
1324
+ removeExistingPartExport(root, row.session_id, row.id);
1325
+ progress.skipped_oversize++;
1326
+ markSafeCursor(state, progress, row);
1327
+ clearPartFailure(state, row.id);
1328
+ return true;
1329
+ }
1330
+ try {
1331
+ const session = getSession(row.session_id);
1332
+ if (!session)
1333
+ throw new Error(`missing session ${row.session_id}`);
1334
+ const archived = session.time_archived != null;
1335
+ const built = buildPartFile(row.id, row.session_id, row.message_id, row.data, archived);
1336
+ if (!built) {
1337
+ removeExistingPartExport(root, row.session_id, row.id);
1338
+ progress.skipped_nontext++;
1339
+ markSafeCursor(state, progress, row);
1340
+ clearPartFailure(state, row.id);
1341
+ return true;
1342
+ }
1343
+ const channelDocs = buildChannelDocuments(row.id, row.session_id, row.message_id, row.data, archived, row.role, session.directory);
1344
+ const dir = join5(root, "by-session", row.session_id);
1345
+ if (!existsSync5(dir)) {
1346
+ mkdirSync(dir, { recursive: true });
1347
+ writeMeta(session, dir);
1348
+ }
1349
+ const idx = getFileIndex(row.session_id, dir);
1350
+ const existing = idx.byPartId.get(row.id);
1351
+ if (existing) {
1352
+ writePartFile(dir, existing, built.content);
1353
+ progress.updates++;
1354
+ } else {
1355
+ const filename2 = safePartFilename(idx.nextSeq++, row.id);
1356
+ writePartFile(dir, filename2, built.content);
1357
+ idx.byPartId.set(row.id, filename2);
1358
+ progress.inserts++;
1359
+ }
1360
+ const filename = idx.byPartId.get(row.id);
1361
+ if (filename)
1362
+ writeChannelFiles(root, row.session_id, filename, row.id, channelDocs);
1363
+ touchedSessions.add(row.session_id);
1364
+ progress.exported++;
1365
+ markSafeCursor(state, progress, row);
1366
+ clearPartFailure(state, row.id);
1367
+ return true;
1368
+ } catch (error) {
1369
+ progress.failed++;
1370
+ markPartFailure(state, row.id, errorMessage(error), progress);
1371
+ return false;
1372
+ }
1373
+ }
1374
+ function removeExistingPartExport(root, sessionId, partId) {
1375
+ const dir = join5(root, "by-session", sessionId);
1376
+ try {
1377
+ if (existsSync5(dir)) {
1378
+ const idx = getFileIndex(sessionId, dir);
1379
+ const existing = idx.byPartId.get(partId);
1380
+ if (existing)
1381
+ unlinkSync3(join5(dir, existing));
1382
+ idx.byPartId.delete(partId);
1383
+ }
1384
+ deleteChannelPartFiles(root, sessionId, partId);
1385
+ } catch {}
1386
+ }
1387
+ function markSafeCursor(state, progress, row) {
1388
+ const cursor = { ts: row.time_updated, id: row.id };
1389
+ state.legacy_cursor = cursor;
1390
+ progress.last_cursor = cursor;
1391
+ }
1392
+ function markPartFailure(state, partId, message, progress) {
1393
+ if (state.dead_letters[partId])
1394
+ return;
1395
+ const now = Date.now();
1396
+ const existing = state.failed_parts[partId];
1397
+ const failure = {
1398
+ id: partId,
1399
+ attempts: (existing?.attempts ?? 0) + 1,
1400
+ first_failed_at: existing?.first_failed_at ?? now,
1401
+ last_failed_at: now,
1402
+ last_error: message
1403
+ };
1404
+ if (failure.attempts >= MAX_FAILED_ATTEMPTS) {
1405
+ state.dead_letters[partId] = failure;
1406
+ delete state.failed_parts[partId];
1407
+ progress.dead_lettered++;
1408
+ } else {
1409
+ state.failed_parts[partId] = failure;
1410
+ }
1411
+ }
1412
+ function clearPartFailure(state, partId) {
1413
+ delete state.failed_parts[partId];
1414
+ }
1415
+ function rememberSafeRow(rows, row) {
1416
+ rows.push(row);
1417
+ const maxRows = INSERT_REWIND_MAX_ROWS * 4;
1418
+ if (rows.length > maxRows)
1419
+ rows.splice(0, rows.length - maxRows);
1420
+ }
1421
+ function chooseInsertCursor(previousId, rows, useRewind) {
1422
+ const last = rows[rows.length - 1];
1423
+ if (!last || !useRewind || rows.length <= INSERT_REWIND_MAX_ROWS)
1424
+ return last?.id ?? previousId;
1425
+ const maxCreated = rows.reduce((max, row) => Math.max(max, row.time_created), 0);
1426
+ const cutoff = maxCreated - INSERT_REWIND_MS;
1427
+ const timeIndex = rows.findIndex((row) => row.time_created >= cutoff);
1428
+ const rewindIndex = Math.max(timeIndex <= 0 ? rows.length - INSERT_REWIND_MAX_ROWS : timeIndex - 1, rows.length - INSERT_REWIND_MAX_ROWS);
1429
+ return rows[Math.max(0, rewindIndex)]?.id ?? last.id;
1430
+ }
1431
+ function sortedDirtyHints(hints) {
1432
+ return Object.entries(hints).sort((a, b) => a[1].time_updated - b[1].time_updated || a[0].localeCompare(b[0]));
1433
+ }
1434
+ function refreshTouchedSessions(root, touchedSessions) {
1435
+ for (const sid of touchedSessions) {
1436
+ const dir = join5(root, "by-session", sid);
1437
+ const fresh = stmt(`
1438
+ SELECT id, title, project_id, directory, agent, model, cost,
1439
+ time_created, time_updated, time_archived, parent_id
1440
+ FROM session WHERE id = ?`).get(sid);
1441
+ if (fresh) {
1442
+ writeMeta(fresh, dir);
1443
+ writeSessionSummaryChannel(fresh, root);
1444
+ }
1445
+ }
1446
+ }
1447
+ function applyTombstoneProgress(progress, tombstones) {
1448
+ progress.tombstones_removed_parts = tombstones.removed_parts;
1449
+ progress.tombstones_removed_sessions = tombstones.removed_sessions;
1450
+ }
1451
+ function loadPartById(partId) {
1452
+ return stmt(partSelectSql("WHERE p.id = ?")).get(partId);
1453
+ }
1454
+ function loadPartRowsAfterId(afterId, limit) {
1455
+ if (!afterId)
1456
+ return stmt(`${partSelectSql("")} ORDER BY p.id ASC LIMIT ?`).all(limit);
1457
+ return stmt(`${partSelectSql("WHERE p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(afterId, limit);
1458
+ }
1459
+ function loadSessionPartRows(sessionId, afterId, limit) {
1460
+ if (!afterId) {
1461
+ return stmt(`${partSelectSql("WHERE p.session_id = ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, limit);
1462
+ }
1463
+ return stmt(`${partSelectSql("WHERE p.session_id = ? AND p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, afterId, limit);
1464
+ }
1465
+ function loadDirtySessionsAfter(cursor, limit) {
1466
+ if (!cursor) {
1467
+ return stmt(`SELECT id, time_updated FROM session ORDER BY time_updated ASC, id ASC LIMIT ?`).all(limit);
1468
+ }
1469
+ return stmt(`
1470
+ SELECT id, time_updated
1471
+ FROM session
1472
+ WHERE (time_updated > ? OR (time_updated = ? AND id > ?))
1473
+ ORDER BY time_updated ASC, id ASC
1474
+ LIMIT ?`).all(cursor.ts, cursor.ts, cursor.id, limit);
1475
+ }
1476
+ function partSelectSql(where) {
1477
+ return `
1478
+ SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated,
1479
+ p.data, LENGTH(p.data) AS data_bytes,
1480
+ json_extract(m.data,'$.role') AS role
1481
+ FROM part p
1482
+ LEFT JOIN message m ON m.id = p.message_id
1483
+ ${where}`;
1484
+ }
1485
+ function timeExceeded(start, budgetMs) {
1486
+ return budgetMs !== undefined && Date.now() - start > budgetMs;
1487
+ }
1488
+ function reportProgress(progress, onProgress, heartbeat) {
1489
+ heartbeat();
1490
+ if (!onProgress)
1491
+ return;
1492
+ const processed = progress.exported + progress.skipped_nontext + progress.skipped_oversize + progress.failed;
1493
+ if (processed > 0 && processed % 5000 === 0)
1494
+ onProgress(progress);
1495
+ }
1496
+ function errorMessage(error) {
1497
+ return error instanceof Error ? error.message : String(error);
1498
+ }
1499
+
1500
+ // src/lib/export-reconcile-worker.ts
1501
+ var scope = globalThis;
1502
+ scope.onmessage = (event) => {
1503
+ reconcile(event.data);
1504
+ };
1505
+ async function reconcile(request) {
1506
+ try {
1507
+ const progress = await runExport({
1508
+ root: request.root,
1509
+ batchSize: request.batchSize ?? 2000,
1510
+ skipBackgroundReconcile: true
1511
+ });
1512
+ scope.postMessage({ ok: true, progress });
1513
+ } catch (error) {
1514
+ scope.postMessage({
1515
+ ok: false,
1516
+ error: error instanceof Error ? error.message : String(error)
1517
+ });
1518
+ }
1519
+ }