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,948 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/bin/check-deps.ts
5
+ import { spawnSync } from "child_process";
6
+ import { existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7
+ import { join as join3 } from "path";
8
+
9
+ // src/lib/db.ts
10
+ import { Database } from "bun:sqlite";
11
+ import { existsSync } from "fs";
12
+ import { homedir, platform } from "os";
13
+ import { join } from "path";
14
+
15
+ // src/lib/errors.ts
16
+ class SessionsError extends Error {
17
+ code;
18
+ hint;
19
+ constructor(code, message, hint) {
20
+ super(message);
21
+ this.code = code;
22
+ this.hint = hint;
23
+ this.name = "SessionsError";
24
+ }
25
+ }
26
+
27
+ // src/lib/db.ts
28
+ var ENV_VAR = "OPENCODE_SESSIONS_EXPLORER_DB";
29
+ function platformDefault() {
30
+ const home = homedir();
31
+ switch (platform()) {
32
+ case "darwin":
33
+ case "linux": {
34
+ const dataHome = process.env.XDG_DATA_HOME ?? join(home, ".local", "share");
35
+ return join(dataHome, "opencode", "opencode.db");
36
+ }
37
+ case "win32": {
38
+ const local = process.env.LOCALAPPDATA ?? join(home, "AppData", "Local");
39
+ return join(local, "opencode", "opencode.db");
40
+ }
41
+ default:
42
+ return null;
43
+ }
44
+ }
45
+ var cachedPath = null;
46
+ function locateDb() {
47
+ if (cachedPath)
48
+ return cachedPath;
49
+ const env = process.env[ENV_VAR];
50
+ if (env) {
51
+ if (!existsSync(env))
52
+ throw new SessionsError("DB_NOT_FOUND", `opencode-sessions-explorer: $${ENV_VAR} points to missing file: ${env}`);
53
+ cachedPath = env;
54
+ return env;
55
+ }
56
+ const def = platformDefault();
57
+ if (!def || !existsSync(def)) {
58
+ throw new SessionsError("DB_NOT_FOUND", `opencode-sessions-explorer: DB not found. Set $${ENV_VAR} or install OpenCode. Tried: ${def ?? `(no default for ${platform()})`}`);
59
+ }
60
+ cachedPath = def;
61
+ return def;
62
+ }
63
+ var _db = null;
64
+ var _stmts = new Map;
65
+ var STMT_CACHE_LIMIT = 256;
66
+ function db() {
67
+ if (_db)
68
+ return _db;
69
+ const path = locateDb();
70
+ _db = new Database(path, { readonly: true, create: false, safeIntegers: false });
71
+ _db.exec("PRAGMA query_only = 1;");
72
+ _db.exec("PRAGMA busy_timeout = 5000;");
73
+ _db.exec("PRAGMA temp_store = MEMORY;");
74
+ _db.exec("PRAGMA cache_size = -32000;");
75
+ return _db;
76
+ }
77
+ function stmt(sql) {
78
+ let s = _stmts.get(sql);
79
+ if (!s) {
80
+ if (_stmts.size >= STMT_CACHE_LIMIT) {
81
+ const oldest = _stmts.keys().next().value;
82
+ if (oldest)
83
+ _stmts.delete(oldest);
84
+ }
85
+ s = db().query(sql);
86
+ _stmts.set(sql, s);
87
+ }
88
+ return s;
89
+ }
90
+
91
+ // src/lib/schema.ts
92
+ var REQUIRED = {
93
+ session: ["id", "project_id", "parent_id", "directory", "title", "time_created", "time_updated", "time_archived", "agent", "model", "cost", "tokens_input", "tokens_output", "tokens_reasoning"],
94
+ message: ["id", "session_id", "time_created", "time_updated", "data"],
95
+ part: ["id", "message_id", "session_id", "time_created", "time_updated", "data"]
96
+ };
97
+ var _state = null;
98
+ var SCHEMA_TTL_MS = 5 * 60000;
99
+ function getSchemaState() {
100
+ if (_state && Date.now() - _state.cached_at < SCHEMA_TTL_MS)
101
+ return _state;
102
+ const warnings = [];
103
+ const hard = [];
104
+ let migrations_head = null;
105
+ try {
106
+ const row = db().query("SELECT name FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1").get();
107
+ migrations_head = row?.name ?? null;
108
+ if (!migrations_head)
109
+ warnings.push("__drizzle_migrations empty");
110
+ } catch (e) {
111
+ warnings.push(`__drizzle_migrations unreadable: ${e.message}`);
112
+ }
113
+ const tables = db().query("SELECT name FROM sqlite_master WHERE type='table'").all();
114
+ const tableSet = new Set(tables.map((t) => t.name));
115
+ for (const t of ["session", "message", "part"]) {
116
+ if (!tableSet.has(t))
117
+ hard.push(`missing table: ${t}`);
118
+ }
119
+ for (const [tbl, cols] of Object.entries(REQUIRED)) {
120
+ if (!tableSet.has(tbl))
121
+ continue;
122
+ const rows = db().query(`PRAGMA table_info(${tbl})`).all();
123
+ const colSet = new Set(rows.map((r) => r.name));
124
+ for (const c of cols) {
125
+ if (!colSet.has(c))
126
+ hard.push(`${tbl}.${c} missing`);
127
+ }
128
+ }
129
+ let json1_ok = false;
130
+ try {
131
+ const r = db().query(`SELECT json_extract('{"a":1}','$.a') AS v`).get();
132
+ json1_ok = r?.v === 1;
133
+ if (!json1_ok)
134
+ hard.push("json1 extension unavailable");
135
+ } catch (e) {
136
+ hard.push(`json1 unavailable: ${e.message}`);
137
+ }
138
+ const bt = db().query("PRAGMA busy_timeout").get();
139
+ const busy_timeout_ms = Number(bt?.timeout ?? 0);
140
+ if (busy_timeout_ms < 5000)
141
+ warnings.push(`busy_timeout=${busy_timeout_ms} <5000`);
142
+ const counts = {};
143
+ for (const t of ["session", "message", "part"]) {
144
+ if (!tableSet.has(t))
145
+ continue;
146
+ const r = db().query(`SELECT COUNT(*) AS n FROM ${t}`).get();
147
+ counts[t] = Number(r.n);
148
+ }
149
+ _state = {
150
+ migrations_head,
151
+ table_counts: counts,
152
+ json1_ok,
153
+ busy_timeout_ms,
154
+ drift_warnings: warnings,
155
+ hard_drift: hard,
156
+ cached_at: Date.now()
157
+ };
158
+ return _state;
159
+ }
160
+
161
+ // src/lib/decode.ts
162
+ function safeParse(jsonStr) {
163
+ try {
164
+ return JSON.parse(jsonStr);
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+ function decodePart(dataStr) {
170
+ const d = safeParse(dataStr);
171
+ if (!d || typeof d !== "object")
172
+ return { type: "unknown", raw_type: null };
173
+ const t = d.type ?? "";
174
+ switch (t) {
175
+ case "text":
176
+ return { type: "text", text: typeof d.text === "string" ? d.text : "" };
177
+ case "reasoning":
178
+ return { type: "reasoning", text: typeof d.text === "string" ? d.text : "" };
179
+ case "tool": {
180
+ const s = d.state ?? {};
181
+ const md = s.metadata ?? {};
182
+ const time = s.time ?? {};
183
+ const start = typeof time.start === "number" ? time.start : null;
184
+ const end = typeof time.end === "number" ? time.end : null;
185
+ return {
186
+ type: "tool",
187
+ tool: typeof d.tool === "string" ? d.tool : "",
188
+ callID: typeof d.callID === "string" ? d.callID : null,
189
+ status: ["pending", "running", "completed", "error"].includes(s.status) ? s.status : "unknown",
190
+ input: s.input ?? null,
191
+ output: typeof s.output === "string" ? s.output : null,
192
+ error: typeof s.error === "string" ? s.error : null,
193
+ outputPath: typeof md.outputPath === "string" ? md.outputPath : null,
194
+ truncated: md.truncated === true,
195
+ start,
196
+ end,
197
+ duration_ms: start != null && end != null ? end - start : null,
198
+ title: typeof s.title === "string" ? s.title : null
199
+ };
200
+ }
201
+ case "file":
202
+ return {
203
+ type: "file",
204
+ url: typeof d.url === "string" ? d.url : null,
205
+ filename: typeof d.filename === "string" ? d.filename : null,
206
+ mime: typeof d.mime === "string" ? d.mime : null,
207
+ sourcePath: typeof d.source?.path === "string" ? d.source.path : null
208
+ };
209
+ case "patch":
210
+ return {
211
+ type: "patch",
212
+ hash: typeof d.hash === "string" ? d.hash : null,
213
+ files: Array.isArray(d.files) ? d.files.filter((x) => typeof x === "string") : []
214
+ };
215
+ case "step-start":
216
+ return { type: "step-start", snapshot: typeof d.snapshot === "string" ? d.snapshot : null };
217
+ case "step-finish":
218
+ return {
219
+ type: "step-finish",
220
+ reason: typeof d.reason === "string" ? d.reason : null,
221
+ snapshot: typeof d.snapshot === "string" ? d.snapshot : null,
222
+ cost: typeof d.cost === "number" ? d.cost : null
223
+ };
224
+ case "compaction":
225
+ return { type: "compaction", auto: d.auto === true };
226
+ case "subtask":
227
+ return {
228
+ type: "subtask",
229
+ prompt: typeof d.prompt === "string" ? d.prompt : "",
230
+ description: typeof d.description === "string" ? d.description : null,
231
+ agent: typeof d.agent === "string" ? d.agent : null
232
+ };
233
+ default:
234
+ return { type: "unknown", raw_type: typeof t === "string" ? t : null };
235
+ }
236
+ }
237
+ function decodeModel(modelStr) {
238
+ if (!modelStr)
239
+ return { id: null, providerID: null, variant: null };
240
+ const d = safeParse(modelStr);
241
+ if (!d || typeof d !== "object")
242
+ return { id: null, providerID: null, variant: null };
243
+ return {
244
+ id: typeof d.id === "string" ? d.id : null,
245
+ providerID: typeof d.providerID === "string" ? d.providerID : null,
246
+ variant: typeof d.variant === "string" ? d.variant : null
247
+ };
248
+ }
249
+
250
+ // src/lib/export.ts
251
+ import { mkdirSync, existsSync as existsSync2, renameSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "fs";
252
+ import { join as join2 } from "path";
253
+ import { homedir as homedir2 } from "os";
254
+
255
+ // src/lib/channel.ts
256
+ var CHANNELS = [
257
+ "conversation",
258
+ "session-summary",
259
+ "tool-input-summary",
260
+ "tool-error",
261
+ "code-touch",
262
+ "tool-output",
263
+ "patch-summary",
264
+ "reasoning",
265
+ "file",
266
+ "raw"
267
+ ];
268
+ function normalizeError(s) {
269
+ 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();
270
+ }
271
+ function compactPath(path, baseDir) {
272
+ if (!baseDir || !path.startsWith(baseDir))
273
+ return { path, rel_path: null };
274
+ const rel = path.slice(baseDir.length).replace(/^\/+/, "");
275
+ return { path, rel_path: rel || "." };
276
+ }
277
+
278
+ // src/lib/export.ts
279
+ var DEFAULT_EXPORT_ROOT = join2(homedir2(), ".local/share/opencode-sessions-explorer");
280
+ var BODY_CAP_BYTES = 256 * 1024;
281
+ var SAFETY_PART_CAP_BYTES = 50 * 1024 * 1024;
282
+ var CHANNEL_COMPLETE_MARKER = ".channels_v1_complete";
283
+ var SEARCHABLE_TYPES = ["text", "reasoning", "tool", "file", "patch", "subtask"];
284
+ var PART_CHANNELS = CHANNELS.filter((c) => c !== "session-summary" && c !== "raw");
285
+ function exportRoot() {
286
+ return process.env.OPENCODE_SESSIONS_EXPLORER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT;
287
+ }
288
+ function ensureRoot(root = exportRoot()) {
289
+ mkdirSync(join2(root, "by-session"), { recursive: true });
290
+ return root;
291
+ }
292
+ function channelExportComplete(root = exportRoot()) {
293
+ return existsSync2(join2(root, CHANNEL_COMPLETE_MARKER));
294
+ }
295
+ function markChannelExportComplete(root = exportRoot()) {
296
+ const p = join2(root, CHANNEL_COMPLETE_MARKER);
297
+ const tmp = p + ".tmp";
298
+ writeFileSync(tmp, String(Date.now()));
299
+ renameSync(tmp, p);
300
+ }
301
+ var CURSOR_SCHEMA = "v2";
302
+ function getLastSync(root = exportRoot()) {
303
+ const p = join2(root, ".last_sync");
304
+ if (!existsSync2(p))
305
+ return null;
306
+ try {
307
+ const raw = readFileSync(p, "utf8").trim();
308
+ if (!raw)
309
+ return null;
310
+ if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
311
+ const [tsStr, id] = raw.slice(CURSOR_SCHEMA.length + 1).split(":");
312
+ const ts = Number(tsStr);
313
+ if (!Number.isFinite(ts) || !id)
314
+ return null;
315
+ return { ts, id };
316
+ }
317
+ return null;
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+ function setLastSync(c, root = exportRoot()) {
323
+ const p = join2(root, ".last_sync");
324
+ const tmp = p + ".tmp";
325
+ writeFileSync(tmp, `${CURSOR_SCHEMA} ${c.ts}:${c.id}`);
326
+ renameSync(tmp, p);
327
+ }
328
+ var sessionCache = new Map;
329
+ function getSession(id) {
330
+ const cached = sessionCache.get(id);
331
+ if (cached)
332
+ return cached;
333
+ const row = stmt(`
334
+ SELECT id, title, project_id, directory, agent, model, cost,
335
+ time_created, time_updated, time_archived, parent_id
336
+ FROM session WHERE id = ?`).get(id);
337
+ if (!row)
338
+ return null;
339
+ sessionCache.set(id, row);
340
+ return row;
341
+ }
342
+ function buildPartFile(partId, sessionId, messageId, data, archived) {
343
+ let decoded;
344
+ try {
345
+ decoded = decodePart(data);
346
+ } catch {
347
+ return null;
348
+ }
349
+ if (!SEARCHABLE_TYPES.includes(decoded.type))
350
+ return null;
351
+ const lines = [];
352
+ lines.push(`PART_ID: ${partId}`);
353
+ lines.push(`SESSION_ID: ${sessionId}`);
354
+ lines.push(`MESSAGE_ID: ${messageId}`);
355
+ lines.push(`TYPE: ${decoded.type}`);
356
+ lines.push(`ARCHIVED: ${archived}`);
357
+ let body = "";
358
+ switch (decoded.type) {
359
+ case "text":
360
+ body = decoded.text;
361
+ break;
362
+ case "reasoning":
363
+ body = decoded.text;
364
+ break;
365
+ case "tool": {
366
+ lines.push(`TOOL: ${decoded.tool}`);
367
+ lines.push(`STATUS: ${decoded.status}`);
368
+ if (decoded.start != null && decoded.end != null)
369
+ lines.push(`TIME: ${decoded.start} - ${decoded.end}`);
370
+ const parts = [];
371
+ try {
372
+ parts.push("INPUT: " + JSON.stringify(decoded.input));
373
+ } catch {
374
+ parts.push("INPUT: <unserializable>");
375
+ }
376
+ if (decoded.output)
377
+ parts.push(`OUTPUT:
378
+ ` + decoded.output);
379
+ if (decoded.error)
380
+ parts.push(`ERROR:
381
+ ` + decoded.error);
382
+ body = parts.join(`
383
+ `);
384
+ break;
385
+ }
386
+ case "file":
387
+ lines.push(`MIME: ${decoded.mime ?? "?"}`);
388
+ body = `FILENAME: ${decoded.filename ?? "?"}
389
+ URL: ${decoded.url ?? "?"}
390
+ SOURCE_PATH: ${decoded.sourcePath ?? "?"}`;
391
+ break;
392
+ case "patch":
393
+ lines.push(`HASH: ${decoded.hash ?? "?"}`);
394
+ lines.push(`FILES_COUNT: ${decoded.files.length}`);
395
+ body = `FILES:
396
+ ` + decoded.files.join(`
397
+ `);
398
+ break;
399
+ case "subtask":
400
+ lines.push(`AGENT: ${decoded.agent ?? "?"}`);
401
+ if (decoded.description)
402
+ body += `DESCRIPTION: ${decoded.description}
403
+ `;
404
+ body += `PROMPT:
405
+ ${decoded.prompt}`;
406
+ break;
407
+ default:
408
+ return null;
409
+ }
410
+ lines.push("---BODY---");
411
+ const enc = new TextEncoder;
412
+ let bodyBytes = enc.encode(body).length;
413
+ if (bodyBytes > BODY_CAP_BYTES) {
414
+ const truncMarker = `
415
+ \u2026[truncated; ${bodyBytes} bytes original; call get_part('${partId}') for full content]`;
416
+ const markerBytes = enc.encode(truncMarker).length;
417
+ const sliced = enc.encode(body).slice(0, Math.max(0, BODY_CAP_BYTES - markerBytes));
418
+ body = new TextDecoder("utf-8", { fatal: false }).decode(sliced).replace(/\uFFFD+$/, "") + truncMarker;
419
+ bodyBytes = enc.encode(body).length;
420
+ }
421
+ lines.push(body);
422
+ return { content: lines.join(`
423
+ `), type: decoded.type };
424
+ }
425
+ function buildChannelDocuments(partId, sessionId, messageId, data, archived, role, sessionDirectory) {
426
+ let decoded;
427
+ try {
428
+ decoded = decodePart(data);
429
+ } catch {
430
+ return [];
431
+ }
432
+ const docs = [];
433
+ const baseHeaders = [
434
+ `PART_ID: ${partId}`,
435
+ `SESSION_ID: ${sessionId}`,
436
+ `MESSAGE_ID: ${messageId}`,
437
+ `ROLE: ${role ?? "unknown"}`,
438
+ `TYPE: ${decoded.type}`,
439
+ `ARCHIVED: ${archived}`
440
+ ];
441
+ const emit = (channel, body, extra = []) => {
442
+ const trimmed = body.trim();
443
+ if (!trimmed)
444
+ return;
445
+ docs.push({ channel, content: [...baseHeaders, `CHANNEL: ${channel}`, ...extra, "---BODY---", capBody(trimmed, partId)].join(`
446
+ `) });
447
+ };
448
+ switch (decoded.type) {
449
+ case "text":
450
+ emit("conversation", decoded.text);
451
+ break;
452
+ case "reasoning":
453
+ emit("reasoning", decoded.text);
454
+ break;
455
+ case "subtask":
456
+ emit("conversation", `${decoded.description ? `DESCRIPTION: ${decoded.description}
457
+ ` : ""}PROMPT:
458
+ ${decoded.prompt}`, [`AGENT: ${decoded.agent ?? "?"}`]);
459
+ break;
460
+ case "tool": {
461
+ const extra = [`TOOL: ${decoded.tool}`, `STATUS: ${decoded.status}`];
462
+ const inputSummary = summarizeToolInput(decoded.tool, decoded.input, sessionDirectory);
463
+ emit("tool-input-summary", inputSummary || `${decoded.tool} ${decoded.status}`, extra);
464
+ if (decoded.status === "error" && decoded.error)
465
+ emit("tool-error", normalizeError(decoded.error), extra);
466
+ if (decoded.output)
467
+ emit("tool-output", decoded.output, extra);
468
+ const codeTouch = summarizeCodeTouch(decoded.tool, decoded.input, sessionDirectory);
469
+ if (codeTouch)
470
+ emit("code-touch", codeTouch, extra);
471
+ break;
472
+ }
473
+ case "patch": {
474
+ const body = decoded.files.map((f) => compactPath(f, sessionDirectory).rel_path ?? f).join(`
475
+ `);
476
+ emit("patch-summary", body, [`HASH: ${decoded.hash ?? "?"}`, `FILES_COUNT: ${decoded.files.length}`]);
477
+ emit("code-touch", body, [`SOURCE: patch`, `FILES_COUNT: ${decoded.files.length}`]);
478
+ break;
479
+ }
480
+ case "file":
481
+ emit("file", `FILENAME: ${decoded.filename ?? "?"}
482
+ URL: ${decoded.url ?? "?"}
483
+ SOURCE_PATH: ${decoded.sourcePath ?? "?"}`);
484
+ break;
485
+ }
486
+ return docs;
487
+ }
488
+ function capBody(body, partId) {
489
+ const enc = new TextEncoder;
490
+ const bytes = enc.encode(body).length;
491
+ if (bytes <= BODY_CAP_BYTES)
492
+ return body;
493
+ const marker = `
494
+ ...[truncated; ${bytes} bytes original; call get_part('${partId}') for full content]`;
495
+ const markerBytes = enc.encode(marker).length;
496
+ const sliced = enc.encode(body).slice(0, Math.max(0, BODY_CAP_BYTES - markerBytes));
497
+ return new TextDecoder("utf-8", { fatal: false }).decode(sliced).replace(/\uFFFD+$/, "") + marker;
498
+ }
499
+ function summarizeToolInput(tool, input, sessionDirectory) {
500
+ if (!input || typeof input !== "object")
501
+ return stringifySafe(input);
502
+ const obj = input;
503
+ const lines = [];
504
+ const add = (label, value) => {
505
+ if (typeof value === "string" && value.trim())
506
+ lines.push(`${label}: ${compactPath(value, sessionDirectory).rel_path ?? value}`);
507
+ else if (typeof value === "number" || typeof value === "boolean")
508
+ lines.push(`${label}: ${value}`);
509
+ };
510
+ lines.push(`TOOL: ${tool}`);
511
+ for (const key of ["command", "description", "filePath", "path", "url", "query", "pattern", "session_id", "message_id", "part_id", "issue_key", "pullNumber"]) {
512
+ add(key, obj[key]);
513
+ }
514
+ if (Array.isArray(obj.paths))
515
+ for (const p of obj.paths.slice(0, 20))
516
+ add("path", p);
517
+ if (Array.isArray(obj.files))
518
+ for (const p of obj.files.slice(0, 20))
519
+ add("file", p);
520
+ return lines.length > 1 ? lines.join(`
521
+ `) : stringifySafe(input);
522
+ }
523
+ function summarizeCodeTouch(tool, input, sessionDirectory) {
524
+ if (!input || typeof input !== "object")
525
+ return null;
526
+ const obj = input;
527
+ const paths = [];
528
+ for (const key of ["filePath", "path"]) {
529
+ if (typeof obj[key] === "string")
530
+ paths.push(obj[key]);
531
+ }
532
+ for (const key of ["paths", "files"]) {
533
+ if (Array.isArray(obj[key])) {
534
+ for (const p of obj[key])
535
+ if (typeof p === "string")
536
+ paths.push(p);
537
+ }
538
+ }
539
+ if (paths.length === 0)
540
+ return null;
541
+ return [`TOOL: ${tool}`, ...paths.slice(0, 50).map((p) => compactPath(p, sessionDirectory).rel_path ?? p)].join(`
542
+ `);
543
+ }
544
+ function stringifySafe(value) {
545
+ try {
546
+ return JSON.stringify(value);
547
+ } catch {
548
+ return "<unserializable>";
549
+ }
550
+ }
551
+ function safePartFilename(seq, partId) {
552
+ const safe = partId.replace(/[^A-Za-z0-9_-]/g, "_");
553
+ return `${String(seq).padStart(5, "0")}-${safe}.txt`;
554
+ }
555
+ function writeMeta(s, dir) {
556
+ const meta = {
557
+ id: s.id,
558
+ title: s.title,
559
+ project_id: s.project_id,
560
+ directory: s.directory,
561
+ agent: s.agent,
562
+ model: decodeModel(s.model),
563
+ cost: Number(s.cost ?? 0),
564
+ parent_id: s.parent_id,
565
+ time_created: s.time_created,
566
+ time_updated: s.time_updated,
567
+ archived: s.time_archived != null
568
+ };
569
+ const p = join2(dir, "meta.json");
570
+ const tmp = p + ".tmp";
571
+ writeFileSync(tmp, JSON.stringify(meta, null, 2));
572
+ renameSync(tmp, p);
573
+ }
574
+ function writePartFile(dir, filename, content) {
575
+ const p = join2(dir, filename);
576
+ const tmp = join2(dir, "." + filename + ".tmp");
577
+ writeFileSync(tmp, content);
578
+ renameSync(tmp, p);
579
+ const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(filename);
580
+ if (!m)
581
+ return;
582
+ const myPartId = m[2];
583
+ try {
584
+ for (const f of readdirSync(dir)) {
585
+ if (f === filename || !f.endsWith(".txt") || f.startsWith("."))
586
+ continue;
587
+ const fm = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
588
+ if (fm && fm[2] === myPartId) {
589
+ try {
590
+ unlinkSync(join2(dir, f));
591
+ } catch {}
592
+ }
593
+ }
594
+ } catch {}
595
+ }
596
+ function channelDir(root, channel, sessionId) {
597
+ return join2(root, "by-channel", channel, "by-session", sessionId);
598
+ }
599
+ function deleteChannelPartFiles(root, sessionId, partId) {
600
+ for (const ch of PART_CHANNELS) {
601
+ const dir = channelDir(root, ch, sessionId);
602
+ if (!existsSync2(dir))
603
+ continue;
604
+ try {
605
+ for (const f of readdirSync(dir)) {
606
+ if (f === `${partId}.txt` || f.endsWith(`-${partId}.txt`)) {
607
+ try {
608
+ unlinkSync(join2(dir, f));
609
+ } catch {}
610
+ }
611
+ }
612
+ } catch {}
613
+ }
614
+ }
615
+ function writeChannelFiles(root, sessionId, filename, partId, docs) {
616
+ deleteChannelPartFiles(root, sessionId, partId);
617
+ for (const doc of docs) {
618
+ const dir = channelDir(root, doc.channel, sessionId);
619
+ if (!existsSync2(dir))
620
+ mkdirSync(dir, { recursive: true });
621
+ writePartFile(dir, filename, doc.content);
622
+ }
623
+ }
624
+ function writeSessionSummaryChannel(s, dirRoot = exportRoot()) {
625
+ const dir = channelDir(dirRoot, "session-summary", s.id);
626
+ if (!existsSync2(dir))
627
+ mkdirSync(dir, { recursive: true });
628
+ const p = join2(dir, "summary.txt");
629
+ const tmp = p + ".tmp";
630
+ writeFileSync(tmp, buildSessionSummaryDocument(s));
631
+ renameSync(tmp, p);
632
+ }
633
+ function buildSessionSummaryDocument(s) {
634
+ const firstPrompt = firstUserPrompt(s.id, "ASC");
635
+ const lastPrompt = firstUserPrompt(s.id, "DESC");
636
+ const model = decodeModel(s.model);
637
+ const lines = [
638
+ `SESSION_ID: ${s.id}`,
639
+ `CHANNEL: session-summary`,
640
+ `TITLE: ${s.title}`,
641
+ `PROJECT_ID: ${s.project_id}`,
642
+ `DIRECTORY: ${s.directory}`,
643
+ `AGENT: ${s.agent ?? "unknown"}`,
644
+ `MODEL: ${model.id ?? "unknown"}`,
645
+ `ARCHIVED: ${s.time_archived != null}`,
646
+ `PARENT_ID: ${s.parent_id ?? ""}`,
647
+ "---BODY---",
648
+ `TITLE: ${s.title}`,
649
+ `DIRECTORY: ${s.directory}`,
650
+ firstPrompt ? `FIRST_USER_PROMPT:
651
+ ${firstPrompt}` : "FIRST_USER_PROMPT:",
652
+ lastPrompt && lastPrompt !== firstPrompt ? `LAST_USER_PROMPT:
653
+ ${lastPrompt}` : ""
654
+ ].filter(Boolean);
655
+ return lines.join(`
656
+ `);
657
+ }
658
+ function firstUserPrompt(sessionId, direction) {
659
+ const msg = stmt(`
660
+ SELECT id
661
+ FROM message
662
+ WHERE session_id = ? AND json_extract(data,'$.role') = 'user'
663
+ ORDER BY time_created ${direction}, id ${direction}
664
+ LIMIT 1`).get(sessionId);
665
+ if (!msg)
666
+ return null;
667
+ const rows = stmt(`
668
+ SELECT json_extract(data,'$.text') AS text
669
+ FROM part
670
+ WHERE message_id = ? AND json_extract(data,'$.type') = 'text'
671
+ ORDER BY time_created ASC, id ASC`).all(msg.id);
672
+ const joined = rows.map((r) => r.text ?? "").join(`
673
+ `).trim();
674
+ if (!joined)
675
+ return null;
676
+ return capBody(joined, "summary");
677
+ }
678
+ var fileIndexBySession = new Map;
679
+ function getFileIndex(sessionId, dir) {
680
+ let idx = fileIndexBySession.get(sessionId);
681
+ if (idx)
682
+ return idx;
683
+ idx = { nextSeq: 1, byPartId: new Map };
684
+ try {
685
+ const files = readdirSync(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
686
+ let max = 0;
687
+ for (const f of files) {
688
+ const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
689
+ if (m) {
690
+ const seq = Number(m[1]);
691
+ const partId = m[2];
692
+ idx.byPartId.set(partId, f);
693
+ if (seq > max)
694
+ max = seq;
695
+ } else if (/^prt_[A-Za-z0-9_-]+\.txt$/.test(f)) {
696
+ idx.byPartId.set(f.replace(/\.txt$/, ""), f);
697
+ }
698
+ }
699
+ idx.nextSeq = max + 1;
700
+ } catch {}
701
+ fileIndexBySession.set(sessionId, idx);
702
+ return idx;
703
+ }
704
+ async function runExport(opts = {}) {
705
+ const root = ensureRoot(opts.root ?? exportRoot());
706
+ const cursor = opts.fromCursor !== undefined ? opts.fromCursor : getLastSync(root);
707
+ const batchSize = opts.batchSize ?? 1000;
708
+ const start = Date.now();
709
+ const progress = { exported: 0, inserts: 0, updates: 0, skipped_nontext: 0, skipped_oversize: 0, failed: 0, last_cursor: cursor };
710
+ let where = "";
711
+ const params = [];
712
+ if (cursor) {
713
+ where = "WHERE (p.time_updated > ? OR (p.time_updated = ? AND p.id > ?))";
714
+ params.push(cursor.ts, cursor.ts, cursor.id);
715
+ }
716
+ let updates = 0;
717
+ let inserts = 0;
718
+ const touchedSessions = new Set;
719
+ while (true) {
720
+ if (opts.budgetMs && Date.now() - start > opts.budgetMs)
721
+ break;
722
+ const rows = stmt(`
723
+ SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated, p.data, LENGTH(p.data) AS data_bytes,
724
+ json_extract(m.data,'$.role') AS role
725
+ FROM part p
726
+ LEFT JOIN message m ON m.id = p.message_id
727
+ ${where}
728
+ ORDER BY p.time_updated ASC, p.id ASC
729
+ LIMIT ?`).all(...params, batchSize);
730
+ if (rows.length === 0)
731
+ break;
732
+ for (const r of rows) {
733
+ if (opts.budgetMs && Date.now() - start > opts.budgetMs)
734
+ break;
735
+ if (r.data_bytes > SAFETY_PART_CAP_BYTES) {
736
+ progress.skipped_oversize++;
737
+ } else {
738
+ try {
739
+ const s = getSession(r.session_id);
740
+ if (!s) {
741
+ progress.failed++;
742
+ continue;
743
+ }
744
+ const built = buildPartFile(r.id, r.session_id, r.message_id, r.data, s.time_archived != null);
745
+ if (!built) {
746
+ progress.skipped_nontext++;
747
+ continue;
748
+ }
749
+ const channelDocs = buildChannelDocuments(r.id, r.session_id, r.message_id, r.data, s.time_archived != null, r.role ?? null, s.directory);
750
+ const dir = join2(root, "by-session", r.session_id);
751
+ if (!existsSync2(dir)) {
752
+ mkdirSync(dir, { recursive: true });
753
+ writeMeta(s, dir);
754
+ }
755
+ const idx = getFileIndex(r.session_id, dir);
756
+ const existing = idx.byPartId.get(r.id);
757
+ if (existing) {
758
+ writePartFile(dir, existing, built.content);
759
+ updates++;
760
+ } else {
761
+ const seq = idx.nextSeq++;
762
+ const filename2 = safePartFilename(seq, r.id);
763
+ writePartFile(dir, filename2, built.content);
764
+ idx.byPartId.set(r.id, filename2);
765
+ inserts++;
766
+ }
767
+ const filename = idx.byPartId.get(r.id);
768
+ if (filename)
769
+ writeChannelFiles(root, r.session_id, filename, r.id, channelDocs);
770
+ touchedSessions.add(r.session_id);
771
+ progress.exported++;
772
+ } catch {
773
+ progress.failed++;
774
+ }
775
+ }
776
+ progress.last_cursor = { ts: r.time_updated, id: r.id };
777
+ }
778
+ const last = rows[rows.length - 1];
779
+ where = "WHERE (p.time_updated > ? OR (p.time_updated = ? AND p.id > ?))";
780
+ params.length = 0;
781
+ params.push(last.time_updated, last.time_updated, last.id);
782
+ if (opts.onProgress && progress.exported % 5000 === 0)
783
+ opts.onProgress(progress);
784
+ if (progress.last_cursor && progress.exported > 0 && progress.exported % 5000 === 0) {
785
+ setLastSync(progress.last_cursor, root);
786
+ }
787
+ }
788
+ for (const sid of touchedSessions) {
789
+ const s = getSession(sid);
790
+ if (s) {
791
+ const dir = join2(root, "by-session", sid);
792
+ const fresh = stmt(`
793
+ SELECT id, title, project_id, directory, agent, model, cost,
794
+ time_created, time_updated, time_archived, parent_id
795
+ FROM session WHERE id = ?`).get(sid);
796
+ if (fresh)
797
+ writeMeta(fresh, dir);
798
+ if (fresh)
799
+ writeSessionSummaryChannel(fresh, root);
800
+ }
801
+ }
802
+ if (progress.last_cursor)
803
+ setLastSync(progress.last_cursor, root);
804
+ progress.updates = updates;
805
+ progress.inserts = inserts;
806
+ return progress;
807
+ }
808
+
809
+ // src/lib/ck.ts
810
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
811
+ function defaultCkCandidates() {
812
+ const home = process.env.HOME ?? "";
813
+ const candidates = [
814
+ process.env.OPENCODE_SESSIONS_EXPLORER_CK_BIN,
815
+ home ? `${home}/.cargo/bin/ck` : null,
816
+ "/usr/local/bin/ck",
817
+ "/opt/homebrew/bin/ck",
818
+ "/usr/bin/ck"
819
+ ].filter((p) => !!p);
820
+ return candidates;
821
+ }
822
+ function locateCk() {
823
+ for (const c of defaultCkCandidates()) {
824
+ if (c.startsWith("/") && existsSync3(c))
825
+ return c;
826
+ }
827
+ return "ck";
828
+ }
829
+ function ckIndexPresent(root = exportRoot()) {
830
+ const manifestPath = `${root}/.ck/manifest.json`;
831
+ if (!existsSync3(manifestPath))
832
+ return { present: false, embedded_chunks: null };
833
+ try {
834
+ const m = JSON.parse(readFileSync2(manifestPath, "utf8"));
835
+ return { present: true, embedded_chunks: m?.totals?.embedded_chunks ?? null };
836
+ } catch {
837
+ return { present: true, embedded_chunks: null };
838
+ }
839
+ }
840
+
841
+ // src/bin/check-deps.ts
842
+ var json = process.argv.includes("--json");
843
+ var checks = [];
844
+ function pass(name, detail) {
845
+ checks.push({ name, status: "ok", detail });
846
+ }
847
+ function warn(name, detail, fix) {
848
+ checks.push({ name, status: "warn", detail, fix });
849
+ }
850
+ function fail(name, detail, fix) {
851
+ checks.push({ name, status: "fail", detail, fix });
852
+ }
853
+ var dbPath = null;
854
+ try {
855
+ dbPath = locateDb();
856
+ pass("OpenCode DB", `${dbPath} (${(statSync2(dbPath).size / 1024 / 1024).toFixed(1)} MB)`);
857
+ } catch (e) {
858
+ fail("OpenCode DB", e.message, "Set $OPENCODE_SESSIONS_EXPLORER_DB to the absolute path of opencode.db, or install OpenCode and run it at least once.");
859
+ }
860
+ if (dbPath) {
861
+ try {
862
+ const s = getSchemaState();
863
+ if (s.hard_drift.length > 0) {
864
+ fail("Schema", `hard drift: ${s.hard_drift.join("; ")}`, "Upgrade @opencode-ai/plugin or downgrade your OpenCode install to a compatible schema version.");
865
+ } else if (s.drift_warnings.length > 0) {
866
+ warn("Schema", `migration ${s.migrations_head} (soft warnings: ${s.drift_warnings.join("; ")})`);
867
+ } else {
868
+ pass("Schema", `migration ${s.migrations_head}; session=${s.table_counts.session} message=${s.table_counts.message} part=${s.table_counts.part}`);
869
+ }
870
+ if (s.json1_ok)
871
+ pass("SQLite json1", "available");
872
+ else
873
+ fail("SQLite json1", "extension missing", "bun:sqlite ships json1 by default; this should never happen.");
874
+ if (s.busy_timeout_ms >= 5000)
875
+ pass("busy_timeout", `${s.busy_timeout_ms} ms`);
876
+ else
877
+ warn("busy_timeout", `${s.busy_timeout_ms} ms (<5000)`, "Concurrent OpenCode writers may cause SQLITE_BUSY errors.");
878
+ } catch (e) {
879
+ fail("Schema", `probe failed: ${e.message}`);
880
+ }
881
+ }
882
+ var root = exportRoot();
883
+ if (existsSync4(root)) {
884
+ const bySession = join3(root, "by-session");
885
+ if (existsSync4(bySession)) {
886
+ const sessionDirs = readdirSync2(bySession).filter((f) => f.startsWith("ses_")).length;
887
+ pass("Export tree", `${root} (${sessionDirs} session dirs)`);
888
+ const byChannel = join3(root, "by-channel");
889
+ if (existsSync4(byChannel)) {
890
+ const channels = readdirSync2(byChannel).filter((f) => !f.startsWith(".")).length;
891
+ if (channelExportComplete(root))
892
+ pass("Channel views", `${channels} channel dirs (complete)`);
893
+ else
894
+ warn("Channel views", `${channels} channel dirs (partial)`, "Run `opencode-sessions-explorer-bulk-export --reset` once to backfill all curated recall channels.");
895
+ } else {
896
+ warn("Channel views", "not built", "Run `opencode-sessions-explorer-bulk-export --reset` once to backfill curated recall channels.");
897
+ }
898
+ } else {
899
+ warn("Export tree", `${root} exists but no by-session/ yet`, "Run `opencode-sessions-explorer-bulk-export` to populate.");
900
+ }
901
+ } else {
902
+ warn("Export tree", `${root} not yet built`, "Run `opencode-sessions-explorer-bulk-export` to populate. Text search will return empty until then.");
903
+ }
904
+ try {
905
+ const ckBin = locateCk();
906
+ const r = spawnSync(ckBin, ["--version"], { encoding: "utf8" });
907
+ if (r.status === 0) {
908
+ const ver = (r.stdout ?? "").trim();
909
+ pass("ck CLI", `${ckBin} (${ver})`);
910
+ if (existsSync4(root)) {
911
+ const idx = ckIndexPresent(root);
912
+ if (idx.present)
913
+ pass("ck index", idx.embedded_chunks != null ? `present (${idx.embedded_chunks} embedded chunks)` : "present");
914
+ else
915
+ warn("ck index", "not built", "For semantic search: `cd " + root + " && ck --index .` (slow, one-time, ~5h for ~150k parts)");
916
+ }
917
+ } else {
918
+ warn("ck CLI", `${ckBin} returned ${r.status}: ${(r.stderr ?? "").slice(0, 120)}`, "Reinstall via `cargo install ck-search`.");
919
+ }
920
+ } catch {
921
+ warn("ck CLI", "not found in $PATH or common locations", "Install with `cargo install ck-search` for search-text + grep-session tools. Other 16 tools work without ck.");
922
+ }
923
+ var toolOutputDir = (() => {
924
+ if (process.env.OPENCODE_SESSIONS_EXPLORER_TOOL_OUTPUT_DIR)
925
+ return process.env.OPENCODE_SESSIONS_EXPLORER_TOOL_OUTPUT_DIR;
926
+ const home = process.env.HOME ?? "";
927
+ return join3(home, ".local/share/opencode/tool-output");
928
+ })();
929
+ if (existsSync4(toolOutputDir))
930
+ pass("tool-output dir", toolOutputDir);
931
+ else
932
+ warn("tool-output dir", `${toolOutputDir} not yet created`, "Will be auto-created by OpenCode when needed.");
933
+ var okCount = checks.filter((c) => c.status === "ok").length;
934
+ var warnCount = checks.filter((c) => c.status === "warn").length;
935
+ var failCount = checks.filter((c) => c.status === "fail").length;
936
+ if (json) {
937
+ console.log(JSON.stringify({ checks, summary: { ok: okCount, warn: warnCount, fail: failCount } }, null, 2));
938
+ } else {
939
+ for (const c of checks) {
940
+ const sym = c.status === "ok" ? "\u2713" : c.status === "warn" ? "!" : "\u2717";
941
+ console.log(`${sym} ${c.name.padEnd(20)} ${c.detail}`);
942
+ if (c.fix)
943
+ console.log(` \u2192 ${c.fix}`);
944
+ }
945
+ console.log("");
946
+ console.log(`${okCount} OK \xB7 ${warnCount} warn \xB7 ${failCount} fail`);
947
+ }
948
+ process.exit(failCount > 0 ? 2 : warnCount > 0 ? 1 : 0);