opencode-db-search 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -0
- package/opencode-db-search.ts +1384 -0
- package/package.json +51 -0
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const BIN = "opencode-db-search";
|
|
10
|
+
|
|
11
|
+
function die(msg: string): never {
|
|
12
|
+
console.error(`error: ${msg}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const tty = process.stdout.isTTY ?? false;
|
|
17
|
+
|
|
18
|
+
function ts(ms: number | null | undefined): string {
|
|
19
|
+
if (ms == null) return "—";
|
|
20
|
+
return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ago(ms: number | null | undefined): string {
|
|
24
|
+
if (ms == null) return "—";
|
|
25
|
+
const diff = Date.now() - ms;
|
|
26
|
+
if (diff < 60_000) return "just now";
|
|
27
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
28
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
29
|
+
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
33
|
+
|
|
34
|
+
/** Strip ANSI escape sequences, returning the plain string. */
|
|
35
|
+
function stripAnsi(s: string): string {
|
|
36
|
+
return s.replace(ANSI_RE, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Visible character count (excludes ANSI escapes). */
|
|
40
|
+
function visibleLen(s: string): number {
|
|
41
|
+
return stripAnsi(s).length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Truncate a string to n visible characters, preserving ANSI sequences.
|
|
46
|
+
* Appends "…" if truncated.
|
|
47
|
+
*/
|
|
48
|
+
function trunc(s: string, n: number): string {
|
|
49
|
+
if (n <= 0) return "";
|
|
50
|
+
// Fast path: no ANSI codes
|
|
51
|
+
if (!s.includes("\x1b")) {
|
|
52
|
+
if (s.length <= n) return s;
|
|
53
|
+
return s.slice(0, n - 1) + "…";
|
|
54
|
+
}
|
|
55
|
+
// Walk through the string, counting only visible characters
|
|
56
|
+
if (visibleLen(s) <= n) return s;
|
|
57
|
+
let visible = 0;
|
|
58
|
+
let i = 0;
|
|
59
|
+
let result = "";
|
|
60
|
+
// Use a sticky-like approach: set lastIndex and test at each position
|
|
61
|
+
const re = new RegExp(ANSI_RE.source, "g");
|
|
62
|
+
while (i < s.length && visible < n - 1) {
|
|
63
|
+
re.lastIndex = i;
|
|
64
|
+
const m = re.exec(s);
|
|
65
|
+
if (m && m.index === i) {
|
|
66
|
+
result += m[0];
|
|
67
|
+
i += m[0].length;
|
|
68
|
+
} else {
|
|
69
|
+
result += s[i];
|
|
70
|
+
visible++;
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Reset any open styles before the ellipsis
|
|
75
|
+
return `${result}\x1b[0m…`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function pad(s: string, n: number): string {
|
|
79
|
+
const len = visibleLen(s);
|
|
80
|
+
return len >= n ? s : s + " ".repeat(n - len);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Table Rendering ──────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const BOX = {
|
|
86
|
+
tl: "┌",
|
|
87
|
+
tr: "┐",
|
|
88
|
+
bl: "└",
|
|
89
|
+
br: "┘",
|
|
90
|
+
h: "─",
|
|
91
|
+
v: "│",
|
|
92
|
+
lm: "├",
|
|
93
|
+
rm: "┤",
|
|
94
|
+
tm: "┬",
|
|
95
|
+
bm: "┴",
|
|
96
|
+
x: "┼",
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
interface Column {
|
|
100
|
+
key: string;
|
|
101
|
+
label: string;
|
|
102
|
+
align?: "left" | "right";
|
|
103
|
+
/** Maximum width this column can grow to */
|
|
104
|
+
max?: number;
|
|
105
|
+
/** Minimum width */
|
|
106
|
+
min?: number;
|
|
107
|
+
/** If true, this column absorbs remaining terminal width */
|
|
108
|
+
flex?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function termWidth(): number {
|
|
112
|
+
return process.stdout.columns || 120;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Render a unicode box-drawing table with dynamic column widths.
|
|
117
|
+
*
|
|
118
|
+
* @param cols Column definitions
|
|
119
|
+
* @param rows Array of row objects (values are strings)
|
|
120
|
+
* @param opts Optional: { footer?: string }
|
|
121
|
+
*/
|
|
122
|
+
function table(
|
|
123
|
+
cols: Column[],
|
|
124
|
+
rows: Array<Record<string, string>>,
|
|
125
|
+
opts?: { footer?: string; emptyMsg?: string },
|
|
126
|
+
): void {
|
|
127
|
+
if (rows.length === 0) {
|
|
128
|
+
if (opts?.emptyMsg) console.log(opts.emptyMsg);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const tw = termWidth();
|
|
133
|
+
|
|
134
|
+
// Measure natural width per column (header label + all data values)
|
|
135
|
+
const natural: number[] = cols.map((c) => {
|
|
136
|
+
let w = c.label.length;
|
|
137
|
+
for (const r of rows) {
|
|
138
|
+
const len = visibleLen(r[c.key] || "");
|
|
139
|
+
if (len > w) w = len;
|
|
140
|
+
}
|
|
141
|
+
// Clamp to min/max
|
|
142
|
+
if (c.min && w < c.min) w = c.min;
|
|
143
|
+
if (c.max && w > c.max) w = c.max;
|
|
144
|
+
return w;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Total: content + padding (1 space each side) + borders (cols+1 border chars)
|
|
148
|
+
const fixedOverhead = cols.length + 1 + cols.length * 2; // borders + padding
|
|
149
|
+
const contentBudget = Math.max(cols.length, tw - fixedOverhead); // floor at 1 char/col
|
|
150
|
+
|
|
151
|
+
// First pass: assign non-flex columns their natural width
|
|
152
|
+
const widths: number[] = new Array(cols.length).fill(0);
|
|
153
|
+
let usedByFixed = 0;
|
|
154
|
+
const flexIndices: number[] = [];
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < cols.length; i++) {
|
|
157
|
+
if (cols[i].flex) {
|
|
158
|
+
flexIndices.push(i);
|
|
159
|
+
} else {
|
|
160
|
+
widths[i] = Math.max(1, Math.min(natural[i], contentBudget));
|
|
161
|
+
usedByFixed += widths[i];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Distribute remaining space to flex columns
|
|
166
|
+
const remaining = Math.max(0, contentBudget - usedByFixed);
|
|
167
|
+
if (flexIndices.length > 0) {
|
|
168
|
+
const totalNatural = flexIndices.reduce((s, i) => s + natural[i], 0);
|
|
169
|
+
let distributed = 0;
|
|
170
|
+
for (let fi = 0; fi < flexIndices.length; fi++) {
|
|
171
|
+
const i = flexIndices[fi];
|
|
172
|
+
const minW = cols[i].min || 4;
|
|
173
|
+
const maxW = cols[i].max;
|
|
174
|
+
if (fi === flexIndices.length - 1) {
|
|
175
|
+
widths[i] = Math.max(remaining - distributed, minW);
|
|
176
|
+
} else {
|
|
177
|
+
const share =
|
|
178
|
+
totalNatural > 0
|
|
179
|
+
? Math.floor((natural[i] / totalNatural) * remaining)
|
|
180
|
+
: Math.floor(remaining / flexIndices.length);
|
|
181
|
+
widths[i] = Math.max(share, minW);
|
|
182
|
+
}
|
|
183
|
+
if (maxW && widths[i] > maxW) widths[i] = maxW;
|
|
184
|
+
distributed += widths[i];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Final shrink pass: if total exceeds budget, shrink widest columns first.
|
|
189
|
+
// Two rounds: first respects min, then allows going below min (floor at 1).
|
|
190
|
+
let totalWidth = widths.reduce((a, b) => a + b, 0);
|
|
191
|
+
if (totalWidth > contentBudget) {
|
|
192
|
+
let excess = totalWidth - contentBudget;
|
|
193
|
+
const order = widths.map((w, i) => ({ w, i })).sort((a, b) => b.w - a.w);
|
|
194
|
+
// Round 1: shrink to min
|
|
195
|
+
for (const { i } of order) {
|
|
196
|
+
if (excess <= 0) break;
|
|
197
|
+
const floor = cols[i].min || 1;
|
|
198
|
+
const shrink = Math.min(excess, widths[i] - floor);
|
|
199
|
+
if (shrink > 0) {
|
|
200
|
+
widths[i] -= shrink;
|
|
201
|
+
excess -= shrink;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Round 2: if still over budget, shrink below min (floor at 1)
|
|
205
|
+
if (excess > 0) {
|
|
206
|
+
order.sort((a, b) => widths[b.i] - widths[a.i]);
|
|
207
|
+
for (const { i } of order) {
|
|
208
|
+
if (excess <= 0) break;
|
|
209
|
+
const shrink = Math.min(excess, widths[i] - 1);
|
|
210
|
+
if (shrink > 0) {
|
|
211
|
+
widths[i] -= shrink;
|
|
212
|
+
excess -= shrink;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Cell formatter: truncate + pad
|
|
219
|
+
function fmtCell(
|
|
220
|
+
value: string,
|
|
221
|
+
width: number,
|
|
222
|
+
align: "left" | "right",
|
|
223
|
+
): string {
|
|
224
|
+
const visible = visibleLen(value);
|
|
225
|
+
const display = visible > width ? trunc(value, width) : value;
|
|
226
|
+
const displayLen = visibleLen(display);
|
|
227
|
+
const gap = Math.max(0, width - displayLen);
|
|
228
|
+
return align === "right"
|
|
229
|
+
? " ".repeat(gap) + display
|
|
230
|
+
: display + " ".repeat(gap);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Build lines
|
|
234
|
+
function hline(left: string, mid: string, right: string): string {
|
|
235
|
+
return left + widths.map((w) => BOX.h.repeat(w + 2)).join(mid) + right;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Render
|
|
239
|
+
const dim = (s: string) => ansi("2", s);
|
|
240
|
+
|
|
241
|
+
console.log(dim(hline(BOX.tl, BOX.tm, BOX.tr)));
|
|
242
|
+
console.log(
|
|
243
|
+
dim(BOX.v) +
|
|
244
|
+
cols
|
|
245
|
+
.map(
|
|
246
|
+
(c, i) =>
|
|
247
|
+
` ${ansi("1", fmtCell(c.label, widths[i], c.align || "left"))} `,
|
|
248
|
+
)
|
|
249
|
+
.join(dim(BOX.v)) +
|
|
250
|
+
dim(BOX.v),
|
|
251
|
+
);
|
|
252
|
+
console.log(dim(hline(BOX.lm, BOX.x, BOX.rm)));
|
|
253
|
+
|
|
254
|
+
for (const r of rows) {
|
|
255
|
+
const cells = cols.map((c) => r[c.key] || "");
|
|
256
|
+
console.log(
|
|
257
|
+
dim(BOX.v) +
|
|
258
|
+
cells
|
|
259
|
+
.map((c, i) => ` ${fmtCell(c, widths[i], cols[i].align || "left")} `)
|
|
260
|
+
.join(dim(BOX.v)) +
|
|
261
|
+
dim(BOX.v),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
console.log(dim(hline(BOX.bl, BOX.bm, BOX.br)));
|
|
266
|
+
|
|
267
|
+
if (opts?.footer) {
|
|
268
|
+
console.log(dim(opts.footer));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Print a bold section heading. */
|
|
273
|
+
function heading(text: string): void {
|
|
274
|
+
console.log(ansi("1", text));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Render key-value pairs in a clean aligned format. */
|
|
278
|
+
function kvBlock(pairs: Array<[string, string]>): void {
|
|
279
|
+
if (pairs.length === 0) return;
|
|
280
|
+
const maxKey = Math.max(...pairs.map(([k]) => k.length));
|
|
281
|
+
for (const [k, v] of pairs) {
|
|
282
|
+
console.log(`${ansi("1", pad(k, maxKey))} ${v}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function size(bytes: number): string {
|
|
287
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
288
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
289
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function ansi(code: string, text: string): string {
|
|
293
|
+
return tty ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function highlight(text: string, query: string): string {
|
|
297
|
+
if (!query || !tty) return text;
|
|
298
|
+
const lower = text.toLowerCase();
|
|
299
|
+
const q = query.toLowerCase();
|
|
300
|
+
let result = "";
|
|
301
|
+
let i = 0;
|
|
302
|
+
while (i < text.length) {
|
|
303
|
+
const idx = lower.indexOf(q, i);
|
|
304
|
+
if (idx === -1) {
|
|
305
|
+
result += text.slice(i);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
result += text.slice(i, idx);
|
|
309
|
+
result += ansi("1;33", text.slice(idx, idx + query.length));
|
|
310
|
+
i = idx + query.length;
|
|
311
|
+
}
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function escapeLike(s: string): string {
|
|
316
|
+
return s.replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function partPreview(raw: string): string {
|
|
320
|
+
try {
|
|
321
|
+
const d = JSON.parse(raw);
|
|
322
|
+
if (d.type === "text" && (d.content || d.text))
|
|
323
|
+
return (d.content || d.text).replace(/\n/g, " ");
|
|
324
|
+
if (d.type === "tool") {
|
|
325
|
+
const title = d.state?.title || d.tool || "tool";
|
|
326
|
+
const out = d.state?.output
|
|
327
|
+
? `: ${d.state.output}`
|
|
328
|
+
: d.state?.input
|
|
329
|
+
? `: ${d.state.input}`
|
|
330
|
+
: "";
|
|
331
|
+
return `[tool:${title}]${out}`.replace(/\n/g, " ");
|
|
332
|
+
}
|
|
333
|
+
if (d.type === "reasoning" && (d.content || d.text))
|
|
334
|
+
return `[reasoning] ${(d.content || d.text).replace(/\n/g, " ")}`;
|
|
335
|
+
if (d.type === "snapshot") return "[snapshot]";
|
|
336
|
+
if (d.type === "subtask") return `[subtask] ${d.title || ""}`.trim();
|
|
337
|
+
if (d.type === "file") return `[file] ${d.path || ""}`.trim();
|
|
338
|
+
return `[${d.type || "unknown"}]`;
|
|
339
|
+
} catch {
|
|
340
|
+
return raw.slice(0, 80);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Extract a top-level field from a JSON string, returning fallback on failure. */
|
|
345
|
+
function jsonField(raw: string, field: string, fallback = "?"): string {
|
|
346
|
+
try {
|
|
347
|
+
const v = JSON.parse(raw)[field];
|
|
348
|
+
return v != null && v !== "" ? String(v) : fallback;
|
|
349
|
+
} catch {
|
|
350
|
+
return fallback;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function msgPreview(parts: Array<{ data: string }>): string {
|
|
355
|
+
for (const p of parts) {
|
|
356
|
+
const pv = partPreview(p.data);
|
|
357
|
+
if (pv && !pv.startsWith("[")) return pv;
|
|
358
|
+
}
|
|
359
|
+
for (const p of parts) {
|
|
360
|
+
const pv = partPreview(p.data);
|
|
361
|
+
if (pv) return pv;
|
|
362
|
+
}
|
|
363
|
+
return "";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Print data as formatted JSON. Used with `return void jsonOut(...)` for early return. */
|
|
367
|
+
function jsonOut(data: unknown): void {
|
|
368
|
+
console.log(JSON.stringify(data, null, 2));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Fetch parts for a message, ordered by id.
|
|
373
|
+
*/
|
|
374
|
+
function fetchParts(
|
|
375
|
+
db: Database,
|
|
376
|
+
messageId: string,
|
|
377
|
+
): Array<{ id: string; data: string; time_created: number }> {
|
|
378
|
+
return db
|
|
379
|
+
.query(
|
|
380
|
+
`SELECT id, data, time_created FROM part WHERE message_id = ? ORDER BY id ASC`,
|
|
381
|
+
)
|
|
382
|
+
.all(messageId) as any[];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Look up a session by exact ID. If not found, prints suggestions for
|
|
387
|
+
* prefix matches and returns null, or dies if no matches at all.
|
|
388
|
+
*/
|
|
389
|
+
function findSession(db: Database, id: string): any {
|
|
390
|
+
const row = db
|
|
391
|
+
.query(
|
|
392
|
+
`SELECT s.*, p.worktree, p.vcs, p.name as project_name
|
|
393
|
+
FROM session s
|
|
394
|
+
LEFT JOIN project p ON s.project_id = p.id
|
|
395
|
+
WHERE s.id = ?`,
|
|
396
|
+
)
|
|
397
|
+
.get(id);
|
|
398
|
+
if (row) return row;
|
|
399
|
+
|
|
400
|
+
// Suggest prefix matches
|
|
401
|
+
const similar = db
|
|
402
|
+
.query(`SELECT id, title FROM session WHERE id LIKE ? ESCAPE '\\' LIMIT 5`)
|
|
403
|
+
.all(`${escapeLike(id)}%`) as any[];
|
|
404
|
+
if (similar.length > 0) {
|
|
405
|
+
console.log(`Session "${id}" not found. Similar IDs:`);
|
|
406
|
+
for (const s of similar) console.log(` ${s.id} ${s.title}`);
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
die(`Session not found: ${id}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─── Query Building ───────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
class WhereBuilder {
|
|
415
|
+
private conditions: string[] = [];
|
|
416
|
+
private values: any[] = [];
|
|
417
|
+
|
|
418
|
+
add(condition: string, ...params: any[]): this {
|
|
419
|
+
this.conditions.push(condition);
|
|
420
|
+
this.values.push(...params);
|
|
421
|
+
return this;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
toSql(): string {
|
|
425
|
+
return this.conditions.length > 0
|
|
426
|
+
? `WHERE ${this.conditions.join(" AND ")}`
|
|
427
|
+
: "";
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
get params(): any[] {
|
|
431
|
+
return this.values;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const VALID_SQL_ALIAS = /^[a-z_][a-z0-9_]*$/i;
|
|
436
|
+
|
|
437
|
+
function sortOrder(
|
|
438
|
+
sort: string,
|
|
439
|
+
opts?: { alias?: string; extraClauses?: Record<string, string> },
|
|
440
|
+
): string {
|
|
441
|
+
const alias = opts?.alias || "";
|
|
442
|
+
if (alias && !VALID_SQL_ALIAS.test(alias)) die(`Invalid SQL alias: ${alias}`);
|
|
443
|
+
const a = alias ? `${alias}.` : "";
|
|
444
|
+
const extra = opts?.extraClauses || {};
|
|
445
|
+
// Only allow known sort keys — unknown values fall through to default
|
|
446
|
+
if (sort in extra) return extra[sort];
|
|
447
|
+
if (sort === "created") return `${a}time_created DESC`;
|
|
448
|
+
return `${a}time_updated DESC`; // default for "updated" or any unknown value
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ─── Row Types ────────────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/** Minimal session row returned by list/search queries. */
|
|
454
|
+
interface SessionRow {
|
|
455
|
+
id: string;
|
|
456
|
+
title: string;
|
|
457
|
+
directory: string;
|
|
458
|
+
project_id: string;
|
|
459
|
+
parent_id: string | null;
|
|
460
|
+
time_created: number;
|
|
461
|
+
time_updated: number;
|
|
462
|
+
time_archived: number | null;
|
|
463
|
+
version: string;
|
|
464
|
+
[key: string]: unknown; // allow extra columns from JOINs
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Row from COUNT/GROUP BY aggregations. */
|
|
468
|
+
interface CountRow {
|
|
469
|
+
count: number;
|
|
470
|
+
[key: string]: unknown;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── DB Resolution ────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
function dataDir(): string {
|
|
476
|
+
return path.join(
|
|
477
|
+
process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"),
|
|
478
|
+
"opencode",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function listDbs(): Array<{
|
|
483
|
+
name: string;
|
|
484
|
+
path: string;
|
|
485
|
+
size: number;
|
|
486
|
+
mtime: number;
|
|
487
|
+
}> {
|
|
488
|
+
const dir = dataDir();
|
|
489
|
+
if (!fs.existsSync(dir)) return [];
|
|
490
|
+
return fs
|
|
491
|
+
.readdirSync(dir)
|
|
492
|
+
.filter((f) => f.startsWith("opencode") && f.endsWith(".db"))
|
|
493
|
+
.map((f) => {
|
|
494
|
+
const full = path.join(dir, f);
|
|
495
|
+
const stat = fs.statSync(full);
|
|
496
|
+
return { name: f, path: full, size: stat.size, mtime: stat.mtimeMs };
|
|
497
|
+
})
|
|
498
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function channelFromName(name: string): string {
|
|
502
|
+
if (name === "opencode.db") return "latest/beta/prod";
|
|
503
|
+
const m = name.match(/^opencode-(.+)\.db$/);
|
|
504
|
+
return m ? m[1] : "unknown";
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function resolveDb(dbFlag?: string, channelFlag?: string): string {
|
|
508
|
+
if (dbFlag) {
|
|
509
|
+
if (!fs.existsSync(dbFlag)) die(`DB file not found: ${dbFlag}`);
|
|
510
|
+
return dbFlag;
|
|
511
|
+
}
|
|
512
|
+
const explicit = process.env.OPENCODE_DB;
|
|
513
|
+
if (explicit) {
|
|
514
|
+
if (explicit === ":memory:") return explicit;
|
|
515
|
+
const resolved = path.isAbsolute(explicit)
|
|
516
|
+
? explicit
|
|
517
|
+
: path.join(dataDir(), explicit);
|
|
518
|
+
if (!fs.existsSync(resolved)) die(`OPENCODE_DB not found: ${resolved}`);
|
|
519
|
+
return resolved;
|
|
520
|
+
}
|
|
521
|
+
if (channelFlag) {
|
|
522
|
+
const safe = channelFlag.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
523
|
+
const name = ["latest", "beta", "prod"].includes(channelFlag)
|
|
524
|
+
? "opencode.db"
|
|
525
|
+
: `opencode-${safe}.db`;
|
|
526
|
+
const full = path.join(dataDir(), name);
|
|
527
|
+
if (!fs.existsSync(full)) die(`Channel DB not found: ${full}`);
|
|
528
|
+
return full;
|
|
529
|
+
}
|
|
530
|
+
const dbs = listDbs(); // already sorted by mtime desc
|
|
531
|
+
if (dbs.length === 0) die(`No opencode DB found in ${dataDir()}`);
|
|
532
|
+
return dbs[0].path;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Open the DB using --db / --channel flags or auto-detect. */
|
|
536
|
+
function openDb(): Database {
|
|
537
|
+
const p = resolveDb(flag("db"), flag("channel"));
|
|
538
|
+
console.error(`DB: ${p}`);
|
|
539
|
+
return new Database(p, { readonly: true });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ─── Project ID Inference ─────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
function inferProject(): string | null {
|
|
545
|
+
try {
|
|
546
|
+
// Check cached project ID first (matches opencode's project.ts:159-227)
|
|
547
|
+
const gitDir = Bun.spawnSync(["git", "rev-parse", "--git-dir"]);
|
|
548
|
+
if (gitDir.exitCode === 0) {
|
|
549
|
+
const dir = gitDir.stdout.toString().trim();
|
|
550
|
+
// Handle worktrees: resolve common dir
|
|
551
|
+
const common = Bun.spawnSync(["git", "rev-parse", "--git-common-dir"]);
|
|
552
|
+
const root =
|
|
553
|
+
common.exitCode === 0 ? common.stdout.toString().trim() : dir;
|
|
554
|
+
const cached = path.join(root, "opencode");
|
|
555
|
+
if (fs.existsSync(cached)) {
|
|
556
|
+
const id = fs.readFileSync(cached, "utf-8").trim();
|
|
557
|
+
if (id) return id;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Fall back to root commit hash
|
|
561
|
+
const result = Bun.spawnSync([
|
|
562
|
+
"git",
|
|
563
|
+
"rev-list",
|
|
564
|
+
"--max-parents=0",
|
|
565
|
+
"HEAD",
|
|
566
|
+
]);
|
|
567
|
+
if (result.exitCode !== 0) return null;
|
|
568
|
+
const commits = result.stdout.toString().trim().split("\n").sort();
|
|
569
|
+
return commits[0] || null;
|
|
570
|
+
} catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ─── Arg Parsing ──────────────────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
/** Flags that never consume a following positional as their value. */
|
|
578
|
+
const BOOLEAN_FLAGS = new Set([
|
|
579
|
+
"all",
|
|
580
|
+
"archived",
|
|
581
|
+
"forks",
|
|
582
|
+
"parts",
|
|
583
|
+
"json",
|
|
584
|
+
"fork",
|
|
585
|
+
"dry-run",
|
|
586
|
+
"help",
|
|
587
|
+
]);
|
|
588
|
+
|
|
589
|
+
function parseArgs(argv: string[]): {
|
|
590
|
+
cmd: string;
|
|
591
|
+
pos: string[];
|
|
592
|
+
flags: Record<string, string | true>;
|
|
593
|
+
} {
|
|
594
|
+
const cmd = argv[0] || "help";
|
|
595
|
+
const pos: string[] = [];
|
|
596
|
+
const flags: Record<string, string | true> = {};
|
|
597
|
+
for (let i = 1; i < argv.length; i++) {
|
|
598
|
+
const a = argv[i];
|
|
599
|
+
if (a.startsWith("--")) {
|
|
600
|
+
const eq = a.indexOf("=");
|
|
601
|
+
if (eq !== -1) {
|
|
602
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
603
|
+
} else {
|
|
604
|
+
const key = a.slice(2);
|
|
605
|
+
const next = argv[i + 1];
|
|
606
|
+
if (BOOLEAN_FLAGS.has(key) || !next || next.startsWith("--")) {
|
|
607
|
+
flags[key] = true;
|
|
608
|
+
} else {
|
|
609
|
+
flags[key] = next;
|
|
610
|
+
i++;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
pos.push(a);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return { cmd, pos, flags };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ─── Subcommands ──────────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
function cmdDbs() {
|
|
623
|
+
const dir = dataDir();
|
|
624
|
+
const dbs = listDbs();
|
|
625
|
+
if (dbs.length === 0) {
|
|
626
|
+
console.log(`No opencode DBs found in ${dir}`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (bool("json"))
|
|
630
|
+
return void jsonOut(
|
|
631
|
+
dbs.map((d) => ({ ...d, channel: channelFromName(d.name) })),
|
|
632
|
+
);
|
|
633
|
+
console.log(`Data dir: ${dir}\n`);
|
|
634
|
+
table(
|
|
635
|
+
[
|
|
636
|
+
{ key: "file", label: "File" },
|
|
637
|
+
{ key: "size", label: "Size", align: "right" },
|
|
638
|
+
{ key: "modified", label: "Modified" },
|
|
639
|
+
{ key: "channel", label: "Channel" },
|
|
640
|
+
],
|
|
641
|
+
dbs.map((d) => ({
|
|
642
|
+
file: d.name,
|
|
643
|
+
size: size(d.size),
|
|
644
|
+
modified: ts(d.mtime),
|
|
645
|
+
channel: channelFromName(d.name),
|
|
646
|
+
})),
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function cmdProjects(db: Database) {
|
|
651
|
+
const rows = db
|
|
652
|
+
.query(
|
|
653
|
+
`SELECT p.*,
|
|
654
|
+
(SELECT COUNT(*) FROM session s WHERE s.project_id = p.id) as session_count,
|
|
655
|
+
(SELECT MAX(s.time_updated) FROM session s WHERE s.project_id = p.id) as last_session
|
|
656
|
+
FROM project p
|
|
657
|
+
ORDER BY last_session DESC NULLS LAST`,
|
|
658
|
+
)
|
|
659
|
+
.all() as any[];
|
|
660
|
+
|
|
661
|
+
if (bool("json")) return void jsonOut(rows);
|
|
662
|
+
table(
|
|
663
|
+
[
|
|
664
|
+
{ key: "id", label: "ID", max: 42 },
|
|
665
|
+
{ key: "worktree", label: "Worktree", flex: true, min: 12 },
|
|
666
|
+
{ key: "vcs", label: "VCS", max: 5 },
|
|
667
|
+
{ key: "sessions", label: "Sessions", align: "right", max: 10 },
|
|
668
|
+
{ key: "updated", label: "Updated", max: 12 },
|
|
669
|
+
],
|
|
670
|
+
rows.map((r: any) => ({
|
|
671
|
+
id: r.id,
|
|
672
|
+
worktree: r.worktree || "(none)",
|
|
673
|
+
vcs: r.vcs || "—",
|
|
674
|
+
sessions: String(r.session_count),
|
|
675
|
+
updated: ago(r.last_session),
|
|
676
|
+
})),
|
|
677
|
+
{ footer: `${rows.length} project(s)`, emptyMsg: "No projects found." },
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function cmdSessions(db: Database) {
|
|
682
|
+
const all = bool("all");
|
|
683
|
+
const archived = bool("archived");
|
|
684
|
+
const forks = bool("forks");
|
|
685
|
+
const limit = num("limit", 100);
|
|
686
|
+
const sort = flag("sort") || "updated";
|
|
687
|
+
const dir = flag("directory");
|
|
688
|
+
const proj = flag("project");
|
|
689
|
+
const since = flag("since");
|
|
690
|
+
const before = flag("before");
|
|
691
|
+
|
|
692
|
+
const w = new WhereBuilder();
|
|
693
|
+
|
|
694
|
+
if (!all) {
|
|
695
|
+
w.add("s.project_id = ?", proj || inferProject() || "global");
|
|
696
|
+
} else if (proj) {
|
|
697
|
+
w.add("s.project_id = ?", proj);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (!archived) w.add("s.time_archived IS NULL");
|
|
701
|
+
if (!forks) w.add("s.parent_id IS NULL");
|
|
702
|
+
if (dir) w.add("s.directory LIKE ? ESCAPE '\\'", `${escapeLike(dir)}%`);
|
|
703
|
+
if (since) {
|
|
704
|
+
const ms = new Date(since).getTime();
|
|
705
|
+
if (isNaN(ms)) die(`Invalid date for --since: ${since}`);
|
|
706
|
+
w.add("s.time_updated >= ?", ms);
|
|
707
|
+
}
|
|
708
|
+
if (before) {
|
|
709
|
+
const ms = new Date(before).getTime();
|
|
710
|
+
if (isNaN(ms)) die(`Invalid date for --before: ${before}`);
|
|
711
|
+
w.add("s.time_updated <= ?", ms);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const order = sortOrder(sort, {
|
|
715
|
+
alias: "s",
|
|
716
|
+
extraClauses: { title: "s.title ASC" },
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const rows = db
|
|
720
|
+
.query(
|
|
721
|
+
`SELECT s.id, s.title, s.directory, s.project_id, s.parent_id,
|
|
722
|
+
s.time_created, s.time_updated, s.time_archived, s.version,
|
|
723
|
+
(SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) as msg_count,
|
|
724
|
+
p.worktree
|
|
725
|
+
FROM session s
|
|
726
|
+
LEFT JOIN project p ON s.project_id = p.id
|
|
727
|
+
${w.toSql()}
|
|
728
|
+
ORDER BY ${order}
|
|
729
|
+
LIMIT ?`,
|
|
730
|
+
)
|
|
731
|
+
.all(...w.params, limit) as any[];
|
|
732
|
+
|
|
733
|
+
if (bool("json")) return void jsonOut(rows);
|
|
734
|
+
table(
|
|
735
|
+
[
|
|
736
|
+
{ key: "id", label: "ID", max: 32 },
|
|
737
|
+
{ key: "title", label: "Title", flex: true, min: 10 },
|
|
738
|
+
{ key: "directory", label: "Directory", flex: true, min: 10 },
|
|
739
|
+
{ key: "updated", label: "Updated", max: 12 },
|
|
740
|
+
{ key: "msgs", label: "Msgs", align: "right", max: 6 },
|
|
741
|
+
],
|
|
742
|
+
rows.map((r: any) => ({
|
|
743
|
+
id: r.id,
|
|
744
|
+
title: r.title || "(untitled)",
|
|
745
|
+
directory: r.directory || "",
|
|
746
|
+
updated: ago(r.time_updated),
|
|
747
|
+
msgs: String(r.msg_count),
|
|
748
|
+
})),
|
|
749
|
+
{ footer: `${rows.length} session(s)`, emptyMsg: "No sessions found." },
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function cmdMessages(db: Database) {
|
|
754
|
+
const sid = parsed.pos[0];
|
|
755
|
+
if (!sid) die(`Usage: ${BIN} messages <sessionID> [options]`);
|
|
756
|
+
|
|
757
|
+
const limit = num("limit", 20);
|
|
758
|
+
const offset = num("offset", 0);
|
|
759
|
+
const role = flag("role");
|
|
760
|
+
const showParts = bool("parts");
|
|
761
|
+
|
|
762
|
+
const w = new WhereBuilder();
|
|
763
|
+
w.add("m.session_id = ?", sid);
|
|
764
|
+
if (role) w.add("json_extract(m.data, '$.role') = ?", role);
|
|
765
|
+
|
|
766
|
+
const rows = db
|
|
767
|
+
.query(
|
|
768
|
+
`SELECT m.id, m.data, m.time_created
|
|
769
|
+
FROM message m
|
|
770
|
+
${w.toSql()}
|
|
771
|
+
ORDER BY m.time_created ASC, m.id ASC
|
|
772
|
+
LIMIT ? OFFSET ?`,
|
|
773
|
+
)
|
|
774
|
+
.all(...w.params, limit, offset) as any[];
|
|
775
|
+
|
|
776
|
+
if (bool("json")) {
|
|
777
|
+
const data = showParts
|
|
778
|
+
? rows.map((r: any) => ({ ...r, parts: fetchParts(db, r.id) }))
|
|
779
|
+
: rows;
|
|
780
|
+
return void jsonOut(data);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Prepare rows with parts for display
|
|
784
|
+
const prepared = rows.map((r: any) => {
|
|
785
|
+
const parts = fetchParts(db, r.id);
|
|
786
|
+
return {
|
|
787
|
+
...r,
|
|
788
|
+
parts,
|
|
789
|
+
role: jsonField(r.data, "role"),
|
|
790
|
+
preview: msgPreview(parts),
|
|
791
|
+
};
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
table(
|
|
795
|
+
[
|
|
796
|
+
{ key: "id", label: "ID", max: 32 },
|
|
797
|
+
{ key: "role", label: "Role", max: 10 },
|
|
798
|
+
{ key: "time", label: "Time", max: 20 },
|
|
799
|
+
{ key: "preview", label: "Preview", flex: true, min: 16 },
|
|
800
|
+
],
|
|
801
|
+
prepared.map((r: any) => ({
|
|
802
|
+
id: r.id,
|
|
803
|
+
role: r.role,
|
|
804
|
+
time: ts(r.time_created),
|
|
805
|
+
preview: r.preview,
|
|
806
|
+
})),
|
|
807
|
+
{ footer: `${rows.length} message(s)`, emptyMsg: "No messages found." },
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
if (showParts) {
|
|
811
|
+
console.log();
|
|
812
|
+
for (const r of prepared) {
|
|
813
|
+
if (r.parts.length === 0) continue;
|
|
814
|
+
console.log(ansi("1", `${r.id}`) + ansi("2", ` (${r.role})`));
|
|
815
|
+
for (const p of r.parts) {
|
|
816
|
+
console.log(
|
|
817
|
+
` ${ansi("36", pad(`[${jsonField(p.data, "type")}]`, 14))} ${trunc(partPreview(p.data), termWidth() - 18)}`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
console.log();
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function cmdParts(db: Database) {
|
|
826
|
+
const sid = parsed.pos[0];
|
|
827
|
+
const mid = flag("message");
|
|
828
|
+
if (!sid && !mid)
|
|
829
|
+
die(`Usage: ${BIN} parts <sessionID> [--message <messageID>] [options]`);
|
|
830
|
+
|
|
831
|
+
const limit = num("limit", 50);
|
|
832
|
+
const typeFilter = flag("type");
|
|
833
|
+
|
|
834
|
+
const w = new WhereBuilder();
|
|
835
|
+
if (sid) w.add("p.session_id = ?", sid);
|
|
836
|
+
if (mid) w.add("p.message_id = ?", mid);
|
|
837
|
+
if (typeFilter) w.add("json_extract(p.data, '$.type') = ?", typeFilter);
|
|
838
|
+
|
|
839
|
+
const rows = db
|
|
840
|
+
.query(
|
|
841
|
+
`SELECT p.id, p.message_id, p.session_id, p.data, p.time_created
|
|
842
|
+
FROM part p
|
|
843
|
+
${w.toSql()}
|
|
844
|
+
ORDER BY p.time_created ASC, p.id ASC
|
|
845
|
+
LIMIT ?`,
|
|
846
|
+
)
|
|
847
|
+
.all(...w.params, limit) as any[];
|
|
848
|
+
|
|
849
|
+
if (bool("json")) return void jsonOut(rows);
|
|
850
|
+
table(
|
|
851
|
+
[
|
|
852
|
+
{ key: "id", label: "ID", max: 32 },
|
|
853
|
+
{ key: "type", label: "Type", max: 14 },
|
|
854
|
+
{ key: "message", label: "Message", max: 32 },
|
|
855
|
+
{ key: "preview", label: "Preview", flex: true, min: 16 },
|
|
856
|
+
],
|
|
857
|
+
rows.map((r: any) => ({
|
|
858
|
+
id: r.id,
|
|
859
|
+
type: jsonField(r.data, "type"),
|
|
860
|
+
message: r.message_id,
|
|
861
|
+
preview: partPreview(r.data),
|
|
862
|
+
})),
|
|
863
|
+
{ footer: `${rows.length} part(s)`, emptyMsg: "No parts found." },
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function cmdSearch(db: Database) {
|
|
868
|
+
const query = parsed.pos[0];
|
|
869
|
+
if (!query) die(`Usage: ${BIN} search <query> [options]`);
|
|
870
|
+
|
|
871
|
+
const scope = flag("scope") || "all";
|
|
872
|
+
const proj = flag("project");
|
|
873
|
+
const sid = flag("session");
|
|
874
|
+
const limit = num("limit", 20);
|
|
875
|
+
const sort = flag("sort") || "updated";
|
|
876
|
+
const width = num("width", 80);
|
|
877
|
+
|
|
878
|
+
const order = sortOrder(sort, { alias: "s" });
|
|
879
|
+
const pattern = `%${escapeLike(query)}%`;
|
|
880
|
+
|
|
881
|
+
interface SearchResult {
|
|
882
|
+
session: string;
|
|
883
|
+
title: string;
|
|
884
|
+
directory: string;
|
|
885
|
+
match: string;
|
|
886
|
+
preview: string;
|
|
887
|
+
updated: number;
|
|
888
|
+
created: number;
|
|
889
|
+
}
|
|
890
|
+
const results: SearchResult[] = [];
|
|
891
|
+
|
|
892
|
+
/** Apply common project/session filters to a WhereBuilder. */
|
|
893
|
+
function addScopeFilters(w: WhereBuilder): void {
|
|
894
|
+
if (proj) w.add("s.project_id = ?", proj);
|
|
895
|
+
if (sid) w.add("s.id = ?", sid);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Title search
|
|
899
|
+
if (scope === "all" || scope === "title") {
|
|
900
|
+
const w = new WhereBuilder();
|
|
901
|
+
w.add("s.title LIKE ? ESCAPE '\\'", pattern);
|
|
902
|
+
addScopeFilters(w);
|
|
903
|
+
|
|
904
|
+
const rows = db
|
|
905
|
+
.query(
|
|
906
|
+
`SELECT s.id, s.title, s.directory, s.time_updated, s.time_created
|
|
907
|
+
FROM session s
|
|
908
|
+
${w.toSql()}
|
|
909
|
+
ORDER BY ${order}
|
|
910
|
+
LIMIT ?`,
|
|
911
|
+
)
|
|
912
|
+
.all(...w.params, limit) as any[];
|
|
913
|
+
|
|
914
|
+
for (const r of rows) {
|
|
915
|
+
results.push({
|
|
916
|
+
session: r.id,
|
|
917
|
+
title: r.title,
|
|
918
|
+
directory: r.directory,
|
|
919
|
+
match: "title",
|
|
920
|
+
preview: r.title,
|
|
921
|
+
updated: r.time_updated,
|
|
922
|
+
created: r.time_created,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Content search
|
|
928
|
+
if (scope === "all" || scope === "content") {
|
|
929
|
+
const w = new WhereBuilder();
|
|
930
|
+
w.add("json_extract(p.data, '$.type') IN ('text', 'tool', 'reasoning')");
|
|
931
|
+
w.add(
|
|
932
|
+
`(json_extract(p.data, '$.content') LIKE ? ESCAPE '\\'
|
|
933
|
+
OR json_extract(p.data, '$.text') LIKE ? ESCAPE '\\'
|
|
934
|
+
OR json_extract(p.data, '$.state.output') LIKE ? ESCAPE '\\'
|
|
935
|
+
OR json_extract(p.data, '$.state.input') LIKE ? ESCAPE '\\'
|
|
936
|
+
OR json_extract(p.data, '$.state.title') LIKE ? ESCAPE '\\')`,
|
|
937
|
+
pattern,
|
|
938
|
+
pattern,
|
|
939
|
+
pattern,
|
|
940
|
+
pattern,
|
|
941
|
+
pattern,
|
|
942
|
+
);
|
|
943
|
+
addScopeFilters(w);
|
|
944
|
+
|
|
945
|
+
const rows = db
|
|
946
|
+
.query(
|
|
947
|
+
`SELECT p.id as part_id, p.session_id, p.data as part_data, s.title, s.directory, s.time_updated, s.time_created
|
|
948
|
+
FROM part p
|
|
949
|
+
JOIN session s ON p.session_id = s.id
|
|
950
|
+
${w.toSql()}
|
|
951
|
+
ORDER BY ${order}
|
|
952
|
+
LIMIT ?`,
|
|
953
|
+
)
|
|
954
|
+
.all(...w.params, limit) as any[];
|
|
955
|
+
|
|
956
|
+
const radius = Math.max(20, Math.floor(width / 2));
|
|
957
|
+
for (const r of rows) {
|
|
958
|
+
const pv = partPreview(r.part_data);
|
|
959
|
+
const idx = pv.toLowerCase().indexOf(query.toLowerCase());
|
|
960
|
+
const start = Math.max(0, idx - radius);
|
|
961
|
+
const end = Math.min(pv.length, idx + query.length + radius);
|
|
962
|
+
const ctx =
|
|
963
|
+
(start > 0 ? "…" : "") +
|
|
964
|
+
pv.slice(start, end) +
|
|
965
|
+
(end < pv.length ? "…" : "");
|
|
966
|
+
results.push({
|
|
967
|
+
session: r.session_id,
|
|
968
|
+
title: r.title,
|
|
969
|
+
directory: r.directory,
|
|
970
|
+
match: "part",
|
|
971
|
+
preview: ctx || pv.slice(0, width),
|
|
972
|
+
updated: r.time_updated,
|
|
973
|
+
created: r.time_created,
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Sort merged results
|
|
979
|
+
results.sort((a, b) =>
|
|
980
|
+
sort === "created" ? b.created - a.created : b.updated - a.updated,
|
|
981
|
+
);
|
|
982
|
+
const final = results.slice(0, limit);
|
|
983
|
+
|
|
984
|
+
if (bool("json")) return void jsonOut(final);
|
|
985
|
+
if (final.length === 0) {
|
|
986
|
+
console.log(`No results for "${query}".`);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
for (const r of final) {
|
|
990
|
+
const dir = r.directory ? trunc(r.directory, 50) : "";
|
|
991
|
+
console.log(
|
|
992
|
+
`${ansi("36", r.session)} ${ansi("2", r.match)} ${trunc(r.title || "", 40)}`,
|
|
993
|
+
);
|
|
994
|
+
if (dir) console.log(` ${ansi("2", dir)}`);
|
|
995
|
+
console.log(` ${highlight(trunc(r.preview, width), query)}`);
|
|
996
|
+
console.log();
|
|
997
|
+
}
|
|
998
|
+
console.log(`${final.length} result(s)`);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function cmdInspect(db: Database) {
|
|
1002
|
+
const sid = parsed.pos[0];
|
|
1003
|
+
if (!sid) die(`Usage: ${BIN} inspect <sessionID>`);
|
|
1004
|
+
|
|
1005
|
+
const row = findSession(db, sid);
|
|
1006
|
+
if (!row) return;
|
|
1007
|
+
|
|
1008
|
+
const msgStats = db
|
|
1009
|
+
.query(
|
|
1010
|
+
`SELECT json_extract(data, '$.role') as role, COUNT(*) as count
|
|
1011
|
+
FROM message WHERE session_id = ?
|
|
1012
|
+
GROUP BY role`,
|
|
1013
|
+
)
|
|
1014
|
+
.all(sid) as any[];
|
|
1015
|
+
|
|
1016
|
+
const partStats = db
|
|
1017
|
+
.query(
|
|
1018
|
+
`SELECT json_extract(data, '$.type') as type, COUNT(*) as count
|
|
1019
|
+
FROM part WHERE session_id = ?
|
|
1020
|
+
GROUP BY type`,
|
|
1021
|
+
)
|
|
1022
|
+
.all(sid) as any[];
|
|
1023
|
+
|
|
1024
|
+
const todos = db
|
|
1025
|
+
.query(
|
|
1026
|
+
`SELECT content, status, priority FROM todo
|
|
1027
|
+
WHERE session_id = ?
|
|
1028
|
+
ORDER BY position ASC`,
|
|
1029
|
+
)
|
|
1030
|
+
.all(sid) as any[];
|
|
1031
|
+
|
|
1032
|
+
const children = db
|
|
1033
|
+
.query(
|
|
1034
|
+
`SELECT id, title, time_updated FROM session WHERE parent_id = ? ORDER BY time_created ASC`,
|
|
1035
|
+
)
|
|
1036
|
+
.all(sid) as any[];
|
|
1037
|
+
|
|
1038
|
+
// Token/cost aggregation from message data
|
|
1039
|
+
let totalInput = 0;
|
|
1040
|
+
let totalOutput = 0;
|
|
1041
|
+
let totalCache = 0;
|
|
1042
|
+
let totalCost = 0;
|
|
1043
|
+
const msgs = db
|
|
1044
|
+
.query(`SELECT data FROM message WHERE session_id = ?`)
|
|
1045
|
+
.all(sid) as any[];
|
|
1046
|
+
for (const m of msgs) {
|
|
1047
|
+
try {
|
|
1048
|
+
const d = JSON.parse(m.data);
|
|
1049
|
+
if (d.tokens) {
|
|
1050
|
+
totalInput += d.tokens.input || 0;
|
|
1051
|
+
totalOutput += d.tokens.output || 0;
|
|
1052
|
+
totalCache += d.tokens.cache?.read || 0;
|
|
1053
|
+
}
|
|
1054
|
+
if (d.cost) totalCost += d.cost;
|
|
1055
|
+
} catch {}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (bool("json"))
|
|
1059
|
+
return void jsonOut({
|
|
1060
|
+
session: row,
|
|
1061
|
+
messages: msgStats,
|
|
1062
|
+
parts: partStats,
|
|
1063
|
+
todos,
|
|
1064
|
+
children,
|
|
1065
|
+
tokens: {
|
|
1066
|
+
input: totalInput,
|
|
1067
|
+
output: totalOutput,
|
|
1068
|
+
cache_read: totalCache,
|
|
1069
|
+
},
|
|
1070
|
+
cost: totalCost,
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
const totalMsgs = msgStats.reduce((s: number, r: any) => s + r.count, 0);
|
|
1074
|
+
const totalParts = partStats.reduce((s: number, r: any) => s + r.count, 0);
|
|
1075
|
+
const msgBreakdown = msgStats
|
|
1076
|
+
.map((r: any) => `${r.count} ${r.role}`)
|
|
1077
|
+
.join(", ");
|
|
1078
|
+
const partBreakdown = partStats
|
|
1079
|
+
.map((r: any) => `${r.count} ${r.type}`)
|
|
1080
|
+
.join(", ");
|
|
1081
|
+
|
|
1082
|
+
heading("Session");
|
|
1083
|
+
kvBlock([
|
|
1084
|
+
["ID", row.id],
|
|
1085
|
+
["Title", row.title],
|
|
1086
|
+
["Project", `${row.project_id}`],
|
|
1087
|
+
["Worktree", row.worktree || "—"],
|
|
1088
|
+
["Directory", row.directory],
|
|
1089
|
+
["Created", ts(row.time_created)],
|
|
1090
|
+
["Updated", ts(row.time_updated)],
|
|
1091
|
+
["Archived", row.time_archived ? ts(row.time_archived) : "no"],
|
|
1092
|
+
["Parent", row.parent_id || "—"],
|
|
1093
|
+
["Version", row.version],
|
|
1094
|
+
]);
|
|
1095
|
+
|
|
1096
|
+
console.log();
|
|
1097
|
+
heading("Stats");
|
|
1098
|
+
const statPairs: Array<[string, string]> = [
|
|
1099
|
+
["Messages", `${totalMsgs} (${msgBreakdown})`],
|
|
1100
|
+
["Parts", `${totalParts} (${partBreakdown})`],
|
|
1101
|
+
];
|
|
1102
|
+
if (totalInput || totalOutput) {
|
|
1103
|
+
statPairs.push([
|
|
1104
|
+
"Tokens",
|
|
1105
|
+
`${totalInput.toLocaleString()} in / ${totalOutput.toLocaleString()} out / ${totalCache.toLocaleString()} cache`,
|
|
1106
|
+
]);
|
|
1107
|
+
}
|
|
1108
|
+
if (totalCost > 0) {
|
|
1109
|
+
statPairs.push(["Cost", `$${totalCost.toFixed(4)}`]);
|
|
1110
|
+
}
|
|
1111
|
+
kvBlock(statPairs);
|
|
1112
|
+
|
|
1113
|
+
if (todos.length > 0) {
|
|
1114
|
+
console.log();
|
|
1115
|
+
heading(`Todos (${todos.length})`);
|
|
1116
|
+
const statusIcon = (s: string) =>
|
|
1117
|
+
s === "completed"
|
|
1118
|
+
? ansi("32", "✓")
|
|
1119
|
+
: s === "in_progress"
|
|
1120
|
+
? ansi("33", "●")
|
|
1121
|
+
: s === "cancelled"
|
|
1122
|
+
? ansi("2", "✗")
|
|
1123
|
+
: "○";
|
|
1124
|
+
for (const t of todos) {
|
|
1125
|
+
console.log(
|
|
1126
|
+
` ${statusIcon(t.status)} ${t.content}${t.priority === "high" ? ansi("31", " !") : ""}`,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (children.length > 0) {
|
|
1132
|
+
console.log();
|
|
1133
|
+
heading(`Children (${children.length})`);
|
|
1134
|
+
table(
|
|
1135
|
+
[
|
|
1136
|
+
{ key: "id", label: "ID", max: 32 },
|
|
1137
|
+
{ key: "title", label: "Title", flex: true, min: 10 },
|
|
1138
|
+
{ key: "updated", label: "Updated", max: 12 },
|
|
1139
|
+
],
|
|
1140
|
+
children.map((c: any) => ({
|
|
1141
|
+
id: c.id,
|
|
1142
|
+
title: c.title || "(untitled)",
|
|
1143
|
+
updated: ago(c.time_updated),
|
|
1144
|
+
})),
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
1150
|
+
|
|
1151
|
+
function latestRootSession(
|
|
1152
|
+
db: Database,
|
|
1153
|
+
projectId: string,
|
|
1154
|
+
since?: number,
|
|
1155
|
+
): any {
|
|
1156
|
+
const timeClause = since != null ? "AND time_updated >= ?" : "";
|
|
1157
|
+
const params = since != null ? [projectId, since] : [projectId];
|
|
1158
|
+
return db
|
|
1159
|
+
.query(
|
|
1160
|
+
`SELECT id, title, directory, time_updated
|
|
1161
|
+
FROM session
|
|
1162
|
+
WHERE project_id = ? AND parent_id IS NULL ${timeClause}
|
|
1163
|
+
ORDER BY time_updated DESC LIMIT 1`,
|
|
1164
|
+
)
|
|
1165
|
+
.get(...params);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function cmdLatest(db: Database) {
|
|
1169
|
+
const projFlag = flag("project");
|
|
1170
|
+
const inferred = inferProject();
|
|
1171
|
+
const proj = projFlag || inferred || "global";
|
|
1172
|
+
const projSource = projFlag
|
|
1173
|
+
? "--project flag"
|
|
1174
|
+
: inferred
|
|
1175
|
+
? "inferred from cwd"
|
|
1176
|
+
: "global (not in a git repo)";
|
|
1177
|
+
const cutoff = Date.now() - THIRTY_DAYS_MS;
|
|
1178
|
+
|
|
1179
|
+
const tui = latestRootSession(db, proj, cutoff);
|
|
1180
|
+
const headless = latestRootSession(db, proj);
|
|
1181
|
+
|
|
1182
|
+
const totalRoots = db
|
|
1183
|
+
.query(
|
|
1184
|
+
`SELECT COUNT(*) as count FROM session WHERE project_id = ? AND parent_id IS NULL`,
|
|
1185
|
+
)
|
|
1186
|
+
.get(proj) as CountRow;
|
|
1187
|
+
|
|
1188
|
+
const excluded = db
|
|
1189
|
+
.query(
|
|
1190
|
+
`SELECT COUNT(*) as count FROM session
|
|
1191
|
+
WHERE project_id = ? AND parent_id IS NULL AND time_updated < ?`,
|
|
1192
|
+
)
|
|
1193
|
+
.get(proj, cutoff) as CountRow;
|
|
1194
|
+
|
|
1195
|
+
if (bool("json"))
|
|
1196
|
+
return void jsonOut({
|
|
1197
|
+
project: proj,
|
|
1198
|
+
tui,
|
|
1199
|
+
headless,
|
|
1200
|
+
totalRoots: totalRoots.count,
|
|
1201
|
+
excludedBy30d: excluded.count,
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
heading("Project");
|
|
1205
|
+
kvBlock([
|
|
1206
|
+
["ID", proj],
|
|
1207
|
+
["Source", projSource],
|
|
1208
|
+
]);
|
|
1209
|
+
|
|
1210
|
+
console.log();
|
|
1211
|
+
heading("TUI mode (-c)");
|
|
1212
|
+
if (tui) {
|
|
1213
|
+
kvBlock([
|
|
1214
|
+
["Session", tui.id],
|
|
1215
|
+
["Title", tui.title],
|
|
1216
|
+
["Updated", ts(tui.time_updated)],
|
|
1217
|
+
["Directory", tui.directory],
|
|
1218
|
+
]);
|
|
1219
|
+
} else {
|
|
1220
|
+
console.log(ansi("2", " No session found (none updated in last 30 days)"));
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
console.log();
|
|
1224
|
+
heading("Headless mode (run -c)");
|
|
1225
|
+
if (headless) {
|
|
1226
|
+
const same = tui && headless.id === tui.id;
|
|
1227
|
+
if (same) {
|
|
1228
|
+
console.log(ansi("2", " Same as TUI mode"));
|
|
1229
|
+
} else {
|
|
1230
|
+
kvBlock([
|
|
1231
|
+
["Session", headless.id],
|
|
1232
|
+
["Title", headless.title],
|
|
1233
|
+
["Updated", ts(headless.time_updated)],
|
|
1234
|
+
["Directory", headless.directory],
|
|
1235
|
+
]);
|
|
1236
|
+
}
|
|
1237
|
+
} else {
|
|
1238
|
+
console.log(ansi("2", " No session found"));
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
console.log();
|
|
1242
|
+
kvBlock([
|
|
1243
|
+
["Root sessions", String(totalRoots.count)],
|
|
1244
|
+
["Outside 30d window", String(excluded.count)],
|
|
1245
|
+
]);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
async function cmdLoad(db: Database) {
|
|
1249
|
+
const sid = parsed.pos[0];
|
|
1250
|
+
if (!sid) die(`Usage: ${BIN} load <sessionID> [--fork] [--dry-run]`);
|
|
1251
|
+
|
|
1252
|
+
const row = findSession(db, sid);
|
|
1253
|
+
if (!row) return;
|
|
1254
|
+
|
|
1255
|
+
const fork = bool("fork");
|
|
1256
|
+
const dry = bool("dry-run");
|
|
1257
|
+
|
|
1258
|
+
const args = ["opencode", "--session", sid];
|
|
1259
|
+
if (fork) args.push("--fork");
|
|
1260
|
+
|
|
1261
|
+
// Propagate DB path if non-default
|
|
1262
|
+
const dbPath = db.filename;
|
|
1263
|
+
const env: Record<string, string> = {};
|
|
1264
|
+
const defaultDb = path.join(dataDir(), "opencode.db");
|
|
1265
|
+
if (dbPath !== defaultDb) {
|
|
1266
|
+
env.OPENCODE_DB = dbPath;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const envPrefix = Object.entries(env)
|
|
1270
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
1271
|
+
.join(" ");
|
|
1272
|
+
const cmdStr = `${envPrefix ? envPrefix + " " : ""}${args.join(" ")}`;
|
|
1273
|
+
|
|
1274
|
+
if (dry) {
|
|
1275
|
+
kvBlock([
|
|
1276
|
+
["Directory", row.directory],
|
|
1277
|
+
["Command", cmdStr],
|
|
1278
|
+
]);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
console.log(`Opening session ${sid} in ${row.directory}...`);
|
|
1283
|
+
const proc = Bun.spawn(args, {
|
|
1284
|
+
cwd: row.directory,
|
|
1285
|
+
env: { ...process.env, ...env },
|
|
1286
|
+
stdin: "inherit",
|
|
1287
|
+
stdout: "inherit",
|
|
1288
|
+
stderr: "inherit",
|
|
1289
|
+
});
|
|
1290
|
+
process.exit(await proc.exited);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function cmdHelp() {
|
|
1294
|
+
console.log(`${ansi("1", BIN)} — search and inspect the opencode database
|
|
1295
|
+
|
|
1296
|
+
${ansi("1", "Usage:")} ${BIN} <command> [options]
|
|
1297
|
+
|
|
1298
|
+
${ansi("1", "Commands:")}
|
|
1299
|
+
dbs List available DB files
|
|
1300
|
+
projects List all projects
|
|
1301
|
+
sessions List sessions
|
|
1302
|
+
messages <sessionID> Show messages for a session
|
|
1303
|
+
parts <sessionID> Show parts for a session
|
|
1304
|
+
search <query> Search titles and content
|
|
1305
|
+
inspect <sessionID> Detailed session info
|
|
1306
|
+
latest Show what -c would pick
|
|
1307
|
+
load <sessionID> Open session in opencode
|
|
1308
|
+
|
|
1309
|
+
${ansi("1", "Global options:")}
|
|
1310
|
+
--db <path> Path to SQLite DB
|
|
1311
|
+
--channel <name> Channel for DB filename (local, latest, etc.)
|
|
1312
|
+
--json Output as JSON
|
|
1313
|
+
|
|
1314
|
+
Use --json with any command for machine-readable output.`);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
1318
|
+
|
|
1319
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
1320
|
+
|
|
1321
|
+
// Flag accessors — close over the singleton parsed.flags
|
|
1322
|
+
function flag(key: string): string | undefined {
|
|
1323
|
+
const v = parsed.flags[key];
|
|
1324
|
+
return typeof v === "string" ? v : undefined;
|
|
1325
|
+
}
|
|
1326
|
+
function bool(key: string): boolean {
|
|
1327
|
+
const v = parsed.flags[key];
|
|
1328
|
+
return v === true || v === "true";
|
|
1329
|
+
}
|
|
1330
|
+
function num(key: string, def: number): number {
|
|
1331
|
+
const v = flag(key);
|
|
1332
|
+
if (!v) return def;
|
|
1333
|
+
const n = parseInt(v, 10);
|
|
1334
|
+
return isNaN(n) ? def : n;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async function main() {
|
|
1338
|
+
switch (parsed.cmd) {
|
|
1339
|
+
case "dbs":
|
|
1340
|
+
cmdDbs();
|
|
1341
|
+
break;
|
|
1342
|
+
case "projects":
|
|
1343
|
+
cmdProjects(openDb());
|
|
1344
|
+
break;
|
|
1345
|
+
case "sessions":
|
|
1346
|
+
cmdSessions(openDb());
|
|
1347
|
+
break;
|
|
1348
|
+
case "messages":
|
|
1349
|
+
cmdMessages(openDb());
|
|
1350
|
+
break;
|
|
1351
|
+
case "parts":
|
|
1352
|
+
cmdParts(openDb());
|
|
1353
|
+
break;
|
|
1354
|
+
case "search":
|
|
1355
|
+
cmdSearch(openDb());
|
|
1356
|
+
break;
|
|
1357
|
+
case "inspect":
|
|
1358
|
+
cmdInspect(openDb());
|
|
1359
|
+
break;
|
|
1360
|
+
case "latest":
|
|
1361
|
+
cmdLatest(openDb());
|
|
1362
|
+
break;
|
|
1363
|
+
case "load":
|
|
1364
|
+
await cmdLoad(openDb());
|
|
1365
|
+
break;
|
|
1366
|
+
case "help":
|
|
1367
|
+
case "--help":
|
|
1368
|
+
case "-h":
|
|
1369
|
+
cmdHelp();
|
|
1370
|
+
break;
|
|
1371
|
+
default:
|
|
1372
|
+
die(`Unknown command: ${parsed.cmd}\nRun with --help for usage.`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
main().catch((e: any) => {
|
|
1377
|
+
if (e.message?.includes("database is locked")) {
|
|
1378
|
+
die("Database is locked. Close opencode first or use a different --db.");
|
|
1379
|
+
}
|
|
1380
|
+
if (e.code === "SQLITE_CANTOPEN") {
|
|
1381
|
+
die(`Cannot open database. Check path and permissions.`);
|
|
1382
|
+
}
|
|
1383
|
+
throw e;
|
|
1384
|
+
});
|