reconvo 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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/cli.js +1818 -0
- package/package.json +38 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1818 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
|
|
19
|
+
// src/db/index.ts
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join, dirname } from "node:path";
|
|
22
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
23
|
+
import duckdb from "duckdb";
|
|
24
|
+
function ensureDir() {
|
|
25
|
+
const dir = dirname(INDEX_PATH);
|
|
26
|
+
if (!existsSync(dir))
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
function getDb() {
|
|
30
|
+
if (!_db) {
|
|
31
|
+
ensureDir();
|
|
32
|
+
_db = new duckdb.Database(INDEX_PATH);
|
|
33
|
+
}
|
|
34
|
+
return _db;
|
|
35
|
+
}
|
|
36
|
+
function getConn() {
|
|
37
|
+
if (!_conn) {
|
|
38
|
+
_conn = getDb().connect();
|
|
39
|
+
}
|
|
40
|
+
return _conn;
|
|
41
|
+
}
|
|
42
|
+
function query(sql, ...params) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const conn = getConn();
|
|
45
|
+
const cb = (err, rows) => {
|
|
46
|
+
if (err)
|
|
47
|
+
reject(err);
|
|
48
|
+
else
|
|
49
|
+
resolve(rows ?? []);
|
|
50
|
+
};
|
|
51
|
+
if (params.length > 0) {
|
|
52
|
+
conn.all(sql, ...params, cb);
|
|
53
|
+
} else {
|
|
54
|
+
conn.all(sql, cb);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function exec(sql) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const conn = getConn();
|
|
61
|
+
conn.exec(sql, (err) => {
|
|
62
|
+
if (err)
|
|
63
|
+
reject(err);
|
|
64
|
+
else
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async function ensureSchema() {
|
|
70
|
+
await exec(`
|
|
71
|
+
CREATE TABLE IF NOT EXISTS source_file (
|
|
72
|
+
path VARCHAR PRIMARY KEY,
|
|
73
|
+
source VARCHAR NOT NULL, -- 'claude-code' or 'opencode'
|
|
74
|
+
mtime_ms BIGINT NOT NULL,
|
|
75
|
+
indexed_at TIMESTAMP DEFAULT current_timestamp
|
|
76
|
+
)
|
|
77
|
+
`);
|
|
78
|
+
await exec(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS session (
|
|
80
|
+
id VARCHAR PRIMARY KEY,
|
|
81
|
+
source VARCHAR NOT NULL,
|
|
82
|
+
directory VARCHAR NOT NULL,
|
|
83
|
+
branch VARCHAR,
|
|
84
|
+
title VARCHAR,
|
|
85
|
+
parent_id VARCHAR,
|
|
86
|
+
started_at BIGINT,
|
|
87
|
+
last_at BIGINT,
|
|
88
|
+
message_count INTEGER DEFAULT 0
|
|
89
|
+
)
|
|
90
|
+
`);
|
|
91
|
+
await exec(`
|
|
92
|
+
CREATE TABLE IF NOT EXISTS message (
|
|
93
|
+
session_id VARCHAR NOT NULL,
|
|
94
|
+
role VARCHAR NOT NULL,
|
|
95
|
+
content VARCHAR NOT NULL,
|
|
96
|
+
timestamp_ms BIGINT,
|
|
97
|
+
position INTEGER NOT NULL,
|
|
98
|
+
model VARCHAR,
|
|
99
|
+
output_tokens BIGINT DEFAULT 0,
|
|
100
|
+
cache_read BIGINT DEFAULT 0,
|
|
101
|
+
cache_write BIGINT DEFAULT 0
|
|
102
|
+
)
|
|
103
|
+
`);
|
|
104
|
+
await exec(`CREATE INDEX IF NOT EXISTS idx_message_session ON message(session_id)`);
|
|
105
|
+
await exec(`CREATE INDEX IF NOT EXISTS idx_session_directory ON session(directory)`);
|
|
106
|
+
await exec(`CREATE INDEX IF NOT EXISTS idx_session_last ON session(last_at DESC)`);
|
|
107
|
+
}
|
|
108
|
+
async function getFileMtime(path) {
|
|
109
|
+
const rows = await query(`SELECT mtime_ms FROM source_file WHERE path = ?`, path);
|
|
110
|
+
return rows.length > 0 ? Number(rows[0].mtime_ms) : null;
|
|
111
|
+
}
|
|
112
|
+
async function setFileMtime(path, source, mtimeMs) {
|
|
113
|
+
const escaped = path.replace(/'/g, "''");
|
|
114
|
+
await exec(`DELETE FROM source_file WHERE path = '${escaped}'`);
|
|
115
|
+
await exec(`INSERT INTO source_file (path, source, mtime_ms, indexed_at) VALUES ('${escaped}', '${source}', ${mtimeMs}, current_timestamp)`);
|
|
116
|
+
}
|
|
117
|
+
async function upsertSession(s) {
|
|
118
|
+
await exec(`DELETE FROM session WHERE id = '${s.id}'`);
|
|
119
|
+
await exec(`
|
|
120
|
+
INSERT INTO session (id, source, directory, branch, title, parent_id, started_at, last_at, message_count)
|
|
121
|
+
VALUES (
|
|
122
|
+
'${s.id}',
|
|
123
|
+
'${s.source}',
|
|
124
|
+
'${s.directory.replace(/'/g, "''")}',
|
|
125
|
+
${s.branch ? `'${s.branch.replace(/'/g, "''")}'` : "NULL"},
|
|
126
|
+
'${(s.title ?? "").replace(/\x00/g, "").replace(/'/g, "''")}',
|
|
127
|
+
${s.parentId ? `'${s.parentId}'` : "NULL"},
|
|
128
|
+
${s.startedAt},
|
|
129
|
+
${s.lastAt},
|
|
130
|
+
${s.messageCount}
|
|
131
|
+
)
|
|
132
|
+
`);
|
|
133
|
+
}
|
|
134
|
+
async function replaceMessages(sessionId, messages) {
|
|
135
|
+
await exec(`DELETE FROM message WHERE session_id = '${sessionId}'`);
|
|
136
|
+
for (const m of messages) {
|
|
137
|
+
const content = m.content.replace(/\x00/g, "").replace(/'/g, "''");
|
|
138
|
+
const title = m.model ? `'${m.model}'` : "NULL";
|
|
139
|
+
try {
|
|
140
|
+
await exec(`INSERT INTO message (session_id, role, content, timestamp_ms, position, model, output_tokens, cache_read, cache_write) ` + `VALUES ('${sessionId}', '${m.role}', '${content}', ${m.timestampMs}, ${m.position}, ${title}, ${m.outputTokens ?? 0}, ${m.cacheRead ?? 0}, ${m.cacheWrite ?? 0})`);
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
var INDEX_DIR, INDEX_PATH, _db = null, _conn = null;
|
|
145
|
+
var init_db = __esm(() => {
|
|
146
|
+
INDEX_DIR = join(homedir(), ".local", "share", "reconvo");
|
|
147
|
+
INDEX_PATH = process.env.RECONVO_INDEX ?? join(INDEX_DIR, "index.duckdb");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// src/util/ansi.ts
|
|
151
|
+
function write(s) {
|
|
152
|
+
process.stdout.write(s);
|
|
153
|
+
}
|
|
154
|
+
var ESC2 = "\x1B", CSI2, ansi2, TREE;
|
|
155
|
+
var init_ansi = __esm(() => {
|
|
156
|
+
CSI2 = `${ESC2}[`;
|
|
157
|
+
ansi2 = {
|
|
158
|
+
clear: `${CSI2}2J${CSI2}H`,
|
|
159
|
+
cursorHide: `${CSI2}?25l`,
|
|
160
|
+
cursorShow: `${CSI2}?25h`,
|
|
161
|
+
altScreen: `${CSI2}?1049h`,
|
|
162
|
+
mainScreen: `${CSI2}?1049l`,
|
|
163
|
+
moveTo: (row, col) => `${CSI2}${row};${col}H`,
|
|
164
|
+
eraseLine: `${CSI2}2K`,
|
|
165
|
+
bold: `${CSI2}1m`,
|
|
166
|
+
dim: `${CSI2}2m`,
|
|
167
|
+
italic: `${CSI2}3m`,
|
|
168
|
+
inverse: `${CSI2}7m`,
|
|
169
|
+
reset: `${CSI2}0m`,
|
|
170
|
+
faint: `${CSI2}90m`,
|
|
171
|
+
cyan: `${CSI2}36m`,
|
|
172
|
+
yellow: `${CSI2}33m`
|
|
173
|
+
};
|
|
174
|
+
TREE = { pipe: "│", tee: "├", corner: "└", dash: "─", bullet: "●", dot: "○" };
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// src/db/queries.ts
|
|
178
|
+
function esc2(s) {
|
|
179
|
+
return s.replace(/\\/g, "\\\\").replace(/'/g, "''").replace(/\x00/g, "");
|
|
180
|
+
}
|
|
181
|
+
async function listSessions2(opts) {
|
|
182
|
+
const limit = opts?.limit ?? 50;
|
|
183
|
+
const conditions = [];
|
|
184
|
+
if (opts?.source) {
|
|
185
|
+
conditions.push(`source = '${esc2(opts.source)}'`);
|
|
186
|
+
}
|
|
187
|
+
if (opts?.scopePaths?.length) {
|
|
188
|
+
const conds = opts.scopePaths.map((p) => `directory LIKE '${esc2(p)}%'`).join(" OR ");
|
|
189
|
+
conditions.push(`(${conds})`);
|
|
190
|
+
}
|
|
191
|
+
if (opts?.sinceMs) {
|
|
192
|
+
conditions.push(`last_at >= ${opts.sinceMs}`);
|
|
193
|
+
}
|
|
194
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
195
|
+
const rows = await query(`
|
|
196
|
+
SELECT id, source, directory, branch, title, parent_id, started_at, last_at, message_count
|
|
197
|
+
FROM session
|
|
198
|
+
${where}
|
|
199
|
+
ORDER BY last_at DESC
|
|
200
|
+
LIMIT ${limit}
|
|
201
|
+
`);
|
|
202
|
+
return rows.map((r) => ({
|
|
203
|
+
id: r.id,
|
|
204
|
+
source: r.source,
|
|
205
|
+
directory: r.directory,
|
|
206
|
+
branch: r.branch,
|
|
207
|
+
title: r.title ?? "(no title)",
|
|
208
|
+
parentId: r.parent_id ?? null,
|
|
209
|
+
startedAt: Number(r.started_at),
|
|
210
|
+
lastAt: Number(r.last_at),
|
|
211
|
+
messageCount: Number(r.message_count)
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
async function readMessages2(sessionPrefix, opts) {
|
|
215
|
+
let from = opts?.from;
|
|
216
|
+
let to = opts?.to;
|
|
217
|
+
if (opts?.around !== undefined) {
|
|
218
|
+
const radius = opts?.radius ?? 3;
|
|
219
|
+
from = Math.max(0, opts.around - radius);
|
|
220
|
+
to = opts.around + radius + 1;
|
|
221
|
+
}
|
|
222
|
+
const conditions = [`session_id LIKE '${esc2(sessionPrefix)}%'`];
|
|
223
|
+
if (from !== undefined)
|
|
224
|
+
conditions.push(`position >= ${from}`);
|
|
225
|
+
if (to !== undefined)
|
|
226
|
+
conditions.push(`position < ${to}`);
|
|
227
|
+
if (opts?.role === "user" || opts?.role === "assistant") {
|
|
228
|
+
conditions.push(`role = '${opts.role}'`);
|
|
229
|
+
}
|
|
230
|
+
const rows = await query(`
|
|
231
|
+
SELECT session_id, role, content, timestamp_ms, position
|
|
232
|
+
FROM message
|
|
233
|
+
WHERE ${conditions.join(" AND ")}
|
|
234
|
+
ORDER BY position ASC
|
|
235
|
+
`);
|
|
236
|
+
return rows.map((r) => ({
|
|
237
|
+
sessionId: r.session_id,
|
|
238
|
+
role: r.role,
|
|
239
|
+
content: r.content,
|
|
240
|
+
timestamp: Number(r.timestamp_ms),
|
|
241
|
+
position: Number(r.position)
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
var init_queries = __esm(() => {
|
|
245
|
+
init_db();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// src/browse/tree.ts
|
|
249
|
+
function makeSessionNode(s, depth = 0) {
|
|
250
|
+
return { kind: "session", session: s, children: [], depth };
|
|
251
|
+
}
|
|
252
|
+
async function loadTree(scopePaths) {
|
|
253
|
+
const allSessions = await listSessions2({ scopePaths, limit: 500 });
|
|
254
|
+
const byDir = new Map;
|
|
255
|
+
for (const s of allSessions) {
|
|
256
|
+
const existing = byDir.get(s.directory);
|
|
257
|
+
if (existing)
|
|
258
|
+
existing.push(s);
|
|
259
|
+
else
|
|
260
|
+
byDir.set(s.directory, [s]);
|
|
261
|
+
}
|
|
262
|
+
const projects = [];
|
|
263
|
+
for (const [dir, sessions] of byDir) {
|
|
264
|
+
const name = dir.split("/").pop() ?? dir;
|
|
265
|
+
const branch = sessions.find((s) => s.branch)?.branch ?? null;
|
|
266
|
+
const sessionNodes = sessions.map((s) => makeSessionNode(s));
|
|
267
|
+
projects.push({
|
|
268
|
+
kind: "project",
|
|
269
|
+
directory: dir,
|
|
270
|
+
name,
|
|
271
|
+
branch,
|
|
272
|
+
sessions: sessionNodes
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
projects.sort((a, b) => {
|
|
276
|
+
const aLast = Math.max(...a.sessions.map((s) => s.session.lastAt));
|
|
277
|
+
const bLast = Math.max(...b.sessions.map((s) => s.session.lastAt));
|
|
278
|
+
return bLast - aLast;
|
|
279
|
+
});
|
|
280
|
+
const treeRows = buildRows(projects);
|
|
281
|
+
const lineageProjects = buildLineageProjects(byDir);
|
|
282
|
+
const lineageRows = buildRows(lineageProjects);
|
|
283
|
+
const flatRows = treeRows.filter((r) => r.node.kind === "session").sort((a, b) => {
|
|
284
|
+
const aTime = a.node.session.lastAt;
|
|
285
|
+
const bTime = b.node.session.lastAt;
|
|
286
|
+
return bTime - aTime;
|
|
287
|
+
});
|
|
288
|
+
return { projects, treeRows, lineageRows, flatRows };
|
|
289
|
+
}
|
|
290
|
+
function buildLineageProjects(byDir) {
|
|
291
|
+
const projects = [];
|
|
292
|
+
for (const [dir, sessions] of byDir) {
|
|
293
|
+
let buildNode = function(s, depth) {
|
|
294
|
+
const node = makeSessionNode(s, depth);
|
|
295
|
+
const kids = childrenOf.get(s.id) ?? [];
|
|
296
|
+
node.children = kids.sort((a, b) => a.startedAt - b.startedAt).map((k) => buildNode(k, depth + 1));
|
|
297
|
+
return node;
|
|
298
|
+
};
|
|
299
|
+
const name = dir.split("/").pop() ?? dir;
|
|
300
|
+
const branch = sessions.find((s) => s.branch)?.branch ?? null;
|
|
301
|
+
const byId = new Map;
|
|
302
|
+
const childrenOf = new Map;
|
|
303
|
+
for (const s of sessions) {
|
|
304
|
+
byId.set(s.id, s);
|
|
305
|
+
if (s.parentId) {
|
|
306
|
+
const siblings = childrenOf.get(s.parentId);
|
|
307
|
+
if (siblings)
|
|
308
|
+
siblings.push(s);
|
|
309
|
+
else
|
|
310
|
+
childrenOf.set(s.parentId, [s]);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const roots = sessions.filter((s) => !s.parentId || !byId.has(s.parentId));
|
|
314
|
+
const rootNodes = roots.sort((a, b) => b.lastAt - a.lastAt).map((s) => buildNode(s, 0));
|
|
315
|
+
projects.push({
|
|
316
|
+
kind: "project",
|
|
317
|
+
directory: dir,
|
|
318
|
+
name,
|
|
319
|
+
branch,
|
|
320
|
+
sessions: rootNodes
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
projects.sort((a, b) => {
|
|
324
|
+
const aLast = Math.max(...a.sessions.map((s) => s.session.lastAt));
|
|
325
|
+
const bLast = Math.max(...b.sessions.map((s) => s.session.lastAt));
|
|
326
|
+
return bLast - aLast;
|
|
327
|
+
});
|
|
328
|
+
return projects;
|
|
329
|
+
}
|
|
330
|
+
function buildRows(projects) {
|
|
331
|
+
const rows = [];
|
|
332
|
+
for (let pi = 0;pi < projects.length; pi++) {
|
|
333
|
+
let addSessions = function(nodes, isLastProj) {
|
|
334
|
+
for (let si = 0;si < nodes.length; si++) {
|
|
335
|
+
const node = nodes[si];
|
|
336
|
+
const isLastSibling = si === nodes.length - 1 && node.children.length === 0;
|
|
337
|
+
rows.push({
|
|
338
|
+
node,
|
|
339
|
+
projectIdx: pi,
|
|
340
|
+
isLast: isLastSibling,
|
|
341
|
+
isLastProject: isLastProj
|
|
342
|
+
});
|
|
343
|
+
if (node.children.length > 0) {
|
|
344
|
+
addSessions(node.children, isLastProj);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
const proj = projects[pi];
|
|
349
|
+
const isLastProject = pi === projects.length - 1;
|
|
350
|
+
rows.push({
|
|
351
|
+
node: proj,
|
|
352
|
+
projectIdx: pi,
|
|
353
|
+
isLast: false,
|
|
354
|
+
isLastProject
|
|
355
|
+
});
|
|
356
|
+
addSessions(proj.sessions, isLastProject);
|
|
357
|
+
}
|
|
358
|
+
return rows;
|
|
359
|
+
}
|
|
360
|
+
var init_tree = __esm(() => {
|
|
361
|
+
init_queries();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// src/util/fmt.ts
|
|
365
|
+
function ago(ms) {
|
|
366
|
+
if (!ms)
|
|
367
|
+
return "";
|
|
368
|
+
const diff = Date.now() - ms;
|
|
369
|
+
const mins = Math.floor(diff / 60000);
|
|
370
|
+
if (mins < 60)
|
|
371
|
+
return `${mins}m`;
|
|
372
|
+
const hours = Math.floor(mins / 60);
|
|
373
|
+
if (hours < 24)
|
|
374
|
+
return `${hours}h`;
|
|
375
|
+
return `${Math.floor(hours / 24)}d`;
|
|
376
|
+
}
|
|
377
|
+
function clockTime(ms) {
|
|
378
|
+
if (!ms)
|
|
379
|
+
return "";
|
|
380
|
+
const d = new Date(ms);
|
|
381
|
+
const now = new Date;
|
|
382
|
+
const time = timeFmt2.format(d);
|
|
383
|
+
if (d.toDateString() === now.toDateString())
|
|
384
|
+
return time;
|
|
385
|
+
if (d.getFullYear() === now.getFullYear())
|
|
386
|
+
return `${dateFmt2.format(d)} ${time}`;
|
|
387
|
+
return `${yearFmt2.format(d)} ${time}`;
|
|
388
|
+
}
|
|
389
|
+
function visibleLength(s) {
|
|
390
|
+
return s.replace(ANSI_RE, "").length;
|
|
391
|
+
}
|
|
392
|
+
function truncate(s, max) {
|
|
393
|
+
if (max <= 0)
|
|
394
|
+
return "";
|
|
395
|
+
if (visibleLength(s) <= max)
|
|
396
|
+
return s;
|
|
397
|
+
let visible = 0;
|
|
398
|
+
let i = 0;
|
|
399
|
+
while (i < s.length && visible < max - 1) {
|
|
400
|
+
if (s[i] === "\x1B") {
|
|
401
|
+
const end = s.indexOf("m", i);
|
|
402
|
+
i = end >= 0 ? end + 1 : i + 1;
|
|
403
|
+
} else {
|
|
404
|
+
visible++;
|
|
405
|
+
i++;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return `${s.slice(0, i)}${ansi2.reset}…`;
|
|
409
|
+
}
|
|
410
|
+
var ANSI_RE, timeFmt2, dateFmt2, yearFmt2;
|
|
411
|
+
var init_fmt = __esm(() => {
|
|
412
|
+
init_ansi();
|
|
413
|
+
ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
414
|
+
timeFmt2 = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
|
|
415
|
+
dateFmt2 = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" });
|
|
416
|
+
yearFmt2 = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "2-digit" });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// src/util/clipboard.ts
|
|
420
|
+
async function copyToClipboard(text) {
|
|
421
|
+
const candidates = process.platform === "darwin" ? [["pbcopy"]] : process.platform === "win32" ? [["clip"]] : [["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
|
|
422
|
+
for (const [cmd, ...args] of candidates) {
|
|
423
|
+
if (!Bun.which(cmd))
|
|
424
|
+
continue;
|
|
425
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
426
|
+
stdin: new Response(text),
|
|
427
|
+
stdout: "ignore",
|
|
428
|
+
stderr: "ignore"
|
|
429
|
+
});
|
|
430
|
+
const code = await proc.exited;
|
|
431
|
+
return code === 0;
|
|
432
|
+
}
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/browse/tui.ts
|
|
437
|
+
var exports_tui = {};
|
|
438
|
+
__export(exports_tui, {
|
|
439
|
+
browse: () => browse
|
|
440
|
+
});
|
|
441
|
+
function renderTree(state) {
|
|
442
|
+
const { rows, cursor, scroll, termWidth, termHeight } = state;
|
|
443
|
+
const treeWidth = Math.min(Math.floor(termWidth * 0.5), 60);
|
|
444
|
+
const previewWidth = termWidth - treeWidth - 3;
|
|
445
|
+
const visibleRows = termHeight - 2;
|
|
446
|
+
write(ansi2.cursorHide);
|
|
447
|
+
write(ansi2.moveTo(1, 1));
|
|
448
|
+
write(ansi2.eraseLine);
|
|
449
|
+
const modeLabel = state.viewMode;
|
|
450
|
+
write(`${ansi2.bold}reconvo${ansi2.reset}${ansi2.dim} browse ${modeLabel} ${rows.length} items${ansi2.reset}`);
|
|
451
|
+
for (let vi = 0;vi < visibleRows; vi++) {
|
|
452
|
+
const ri = vi + scroll;
|
|
453
|
+
const row = rows[ri];
|
|
454
|
+
write(ansi2.moveTo(vi + 2, 1));
|
|
455
|
+
write(ansi2.eraseLine);
|
|
456
|
+
if (!row)
|
|
457
|
+
continue;
|
|
458
|
+
const isSelected = ri === cursor;
|
|
459
|
+
if (state.viewMode === "recent") {
|
|
460
|
+
const s = row.node.session;
|
|
461
|
+
const ageStr = ago(s.lastAt).padStart(4);
|
|
462
|
+
const projName = state.sessionProject.get(s.id) ?? "";
|
|
463
|
+
const srcTag = s.source === "opencode" ? `${ansi2.dim}oc${ansi2.reset} ` : "";
|
|
464
|
+
const contentWidth = treeWidth - 5 - (s.source === "opencode" ? 3 : 0);
|
|
465
|
+
const projMax = Math.min(projName.length, Math.max(8, contentWidth - s.title.length - 1));
|
|
466
|
+
const projStr = projName.length > projMax ? projName.slice(0, projMax - 1) + "…" : projName;
|
|
467
|
+
const titleWidth = contentWidth - projStr.length - 1;
|
|
468
|
+
const title = truncate(s.title, titleWidth);
|
|
469
|
+
const gap = " ".repeat(Math.max(1, contentWidth - visibleLength(title) - projStr.length));
|
|
470
|
+
write(`${srcTag}${isSelected ? ansi2.inverse : ""}${title}${isSelected ? ansi2.reset : ""} ${ansi2.dim}${gap}${projStr} ${ageStr}${ansi2.reset}`);
|
|
471
|
+
} else if (row.node.kind === "project") {
|
|
472
|
+
const proj = row.node;
|
|
473
|
+
const collapsed = state.collapsed.has(row.projectIdx);
|
|
474
|
+
const icon = collapsed ? TREE.dot : TREE.bullet;
|
|
475
|
+
const count = proj.sessions.length;
|
|
476
|
+
const branchLabel = proj.branch ? ` ${ansi2.dim}${proj.branch}${ansi2.reset}` : "";
|
|
477
|
+
const countStr = ` (${count})`;
|
|
478
|
+
write(`${ansi2.bold}${icon} ${isSelected ? ansi2.inverse : ""}${proj.name}${isSelected ? ansi2.reset : ""}${ansi2.reset}${branchLabel}${ansi2.dim}${countStr}${ansi2.reset}`);
|
|
479
|
+
} else {
|
|
480
|
+
const sn = row.node;
|
|
481
|
+
const s = sn.session;
|
|
482
|
+
const isLastProj = row.isLastProject;
|
|
483
|
+
const treePipe = isLastProj ? " " : TREE.pipe;
|
|
484
|
+
const branch = row.isLast ? TREE.corner : TREE.tee;
|
|
485
|
+
const ageStr = ago(s.lastAt);
|
|
486
|
+
const srcTag = s.source === "opencode" ? "oc " : "";
|
|
487
|
+
const forkIndent = sn.depth > 0 ? " ".repeat(sn.depth) : "";
|
|
488
|
+
const forkPrefix = sn.depth > 0 ? `${ansi2.dim}${TREE.corner}${TREE.dash}${ansi2.reset}` : "";
|
|
489
|
+
const prefixWidth = 6 + forkIndent.length + (sn.depth > 0 ? 2 : 0);
|
|
490
|
+
const ageWidth = ageStr.length + 1;
|
|
491
|
+
const titleWidth = treeWidth - prefixWidth - ageWidth - srcTag.length;
|
|
492
|
+
const title = truncate(s.title, titleWidth);
|
|
493
|
+
const pad = " ".repeat(Math.max(1, titleWidth - visibleLength(title)));
|
|
494
|
+
write(` ${ansi2.dim}${treePipe}${ansi2.reset} ${forkIndent}${ansi2.dim}${forkPrefix || `${branch}${TREE.dash}`}${ansi2.reset} ${srcTag}${isSelected ? ansi2.inverse : ""}${title}${isSelected ? ansi2.reset : ""}${ansi2.dim}${pad}${ageStr}${ansi2.reset}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
for (let vi = 0;vi < visibleRows; vi++) {
|
|
498
|
+
write(ansi2.moveTo(vi + 2, treeWidth + 1));
|
|
499
|
+
write(`${ansi2.dim}│${ansi2.reset}`);
|
|
500
|
+
}
|
|
501
|
+
for (let vi = 0;vi < visibleRows; vi++) {
|
|
502
|
+
write(ansi2.moveTo(vi + 2, treeWidth + 3));
|
|
503
|
+
const line = state.previewLines[vi] ?? "";
|
|
504
|
+
write(truncate(line, previewWidth));
|
|
505
|
+
}
|
|
506
|
+
write(ansi2.moveTo(termHeight, 1));
|
|
507
|
+
write(ansi2.eraseLine);
|
|
508
|
+
if (state.filterMode) {
|
|
509
|
+
write(`${ansi2.inverse} / ${ansi2.reset} ${state.filter}█`);
|
|
510
|
+
} else if (state.statusMessage) {
|
|
511
|
+
write(`${ansi2.dim}${state.statusMessage}${ansi2.reset}`);
|
|
512
|
+
} else if (state.filter) {
|
|
513
|
+
write(`${ansi2.dim}filter: ${state.filter} (esc to clear)${ansi2.reset}`);
|
|
514
|
+
} else {
|
|
515
|
+
write(`${ansi2.dim}j/k navigate tab view c copy id / filter q quit${ansi2.reset}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
async function updatePreview(state) {
|
|
519
|
+
const row = state.rows[state.cursor];
|
|
520
|
+
if (!row) {
|
|
521
|
+
state.previewLines = [];
|
|
522
|
+
state.previewSessionId = null;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const previewWidth = state.termWidth - Math.min(Math.floor(state.termWidth * 0.5), 60) - 5;
|
|
526
|
+
const rule = `${ansi2.faint}${"─".repeat(previewWidth)}${ansi2.reset}`;
|
|
527
|
+
if (row.node.kind === "project") {
|
|
528
|
+
const proj = row.node;
|
|
529
|
+
state.previewSessionId = null;
|
|
530
|
+
const lbl2 = (s2) => `${ansi2.dim}${s2.padEnd(10)}${ansi2.reset}`;
|
|
531
|
+
state.previewLines = [
|
|
532
|
+
`${ansi2.bold}${proj.name}${ansi2.reset}`,
|
|
533
|
+
"",
|
|
534
|
+
`${lbl2("path")}${proj.directory}`,
|
|
535
|
+
...proj.branch ? [`${lbl2("branch")}${proj.branch}`] : [],
|
|
536
|
+
`${lbl2("sessions")}${proj.sessions.length}`,
|
|
537
|
+
"",
|
|
538
|
+
rule,
|
|
539
|
+
"",
|
|
540
|
+
...proj.sessions.map((s2) => ` ${ansi2.dim}${TREE.tee}${TREE.dash}${ansi2.reset} ${s2.session.title}`)
|
|
541
|
+
];
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const s = row.node.session;
|
|
545
|
+
if (state.previewSessionId === s.id)
|
|
546
|
+
return;
|
|
547
|
+
state.previewSessionId = s.id;
|
|
548
|
+
const lbl = (label) => `${ansi2.dim}${label.padEnd(10)}${ansi2.reset}`;
|
|
549
|
+
const header = [
|
|
550
|
+
`${ansi2.bold}${s.title}${ansi2.reset}`,
|
|
551
|
+
"",
|
|
552
|
+
`${lbl("id")}${s.id}`,
|
|
553
|
+
`${lbl("source")}${s.source}`,
|
|
554
|
+
`${lbl("dir")}${s.directory}`,
|
|
555
|
+
...s.branch ? [`${lbl("branch")}${s.branch}`] : [],
|
|
556
|
+
`${lbl("messages")}${s.messageCount}`,
|
|
557
|
+
`${lbl("time")}${clockTime(s.startedAt)} → ${clockTime(s.lastAt)}`,
|
|
558
|
+
"",
|
|
559
|
+
rule,
|
|
560
|
+
""
|
|
561
|
+
];
|
|
562
|
+
try {
|
|
563
|
+
const messages = await readMessages2(s.id);
|
|
564
|
+
const HEAD = 2;
|
|
565
|
+
const TAIL = 5;
|
|
566
|
+
const renderMsg = (m) => {
|
|
567
|
+
const label = m.role === "user" ? `${ansi2.bold}user${ansi2.reset}` : `${ansi2.bold}${ansi2.dim}assistant${ansi2.reset}`;
|
|
568
|
+
const ts = clockTime(m.timestamp);
|
|
569
|
+
const out = [`${label} ${ansi2.dim}${ts}${ansi2.reset}`];
|
|
570
|
+
const lines = m.content.split(`
|
|
571
|
+
`);
|
|
572
|
+
for (const line of lines.slice(0, 8)) {
|
|
573
|
+
out.push(truncate(line, previewWidth));
|
|
574
|
+
}
|
|
575
|
+
if (lines.length > 8) {
|
|
576
|
+
out.push(`${ansi2.dim} ... (${lines.length - 8} more lines)${ansi2.reset}`);
|
|
577
|
+
}
|
|
578
|
+
out.push("");
|
|
579
|
+
return out;
|
|
580
|
+
};
|
|
581
|
+
const chunks = [];
|
|
582
|
+
if (messages.length <= HEAD + TAIL) {
|
|
583
|
+
for (const m of messages)
|
|
584
|
+
chunks.push(...renderMsg(m));
|
|
585
|
+
} else {
|
|
586
|
+
for (const m of messages.slice(0, HEAD))
|
|
587
|
+
chunks.push(...renderMsg(m));
|
|
588
|
+
chunks.push(`${ansi2.dim} ... ${messages.length - HEAD - TAIL} more messages${ansi2.reset}`);
|
|
589
|
+
chunks.push("");
|
|
590
|
+
for (const m of messages.slice(-TAIL))
|
|
591
|
+
chunks.push(...renderMsg(m));
|
|
592
|
+
}
|
|
593
|
+
state.previewLines = [...header, ...chunks];
|
|
594
|
+
} catch {
|
|
595
|
+
state.previewLines = [...header, "(failed to load messages)"];
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function applyFilter(state) {
|
|
599
|
+
if (state.viewMode === "recent") {
|
|
600
|
+
if (!state.filter) {
|
|
601
|
+
state.rows = state.flatRows;
|
|
602
|
+
} else {
|
|
603
|
+
const q2 = state.filter.toLowerCase();
|
|
604
|
+
state.rows = state.flatRows.filter((row) => {
|
|
605
|
+
const s = row.node.session;
|
|
606
|
+
const proj = state.sessionProject.get(s.id) ?? "";
|
|
607
|
+
return s.title.toLowerCase().includes(q2) || s.directory.toLowerCase().includes(q2) || proj.toLowerCase().includes(q2);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
state.cursor = Math.min(state.cursor, Math.max(0, state.rows.length - 1));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const baseRows = state.viewMode === "lineage" ? state.lineageRows : state.allRows;
|
|
614
|
+
if (!state.filter) {
|
|
615
|
+
state.rows = baseRows;
|
|
616
|
+
rebuildVisibleRows(state);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const q = state.filter.toLowerCase();
|
|
620
|
+
const matchedProjIndices = new Set;
|
|
621
|
+
for (const row of baseRows) {
|
|
622
|
+
if (row.node.kind === "session") {
|
|
623
|
+
const s = row.node.session;
|
|
624
|
+
if (s.title.toLowerCase().includes(q) || s.directory.toLowerCase().includes(q)) {
|
|
625
|
+
matchedProjIndices.add(row.projectIdx);
|
|
626
|
+
}
|
|
627
|
+
} else {
|
|
628
|
+
const proj = row.node;
|
|
629
|
+
if (proj.name.toLowerCase().includes(q) || proj.directory.toLowerCase().includes(q)) {
|
|
630
|
+
matchedProjIndices.add(row.projectIdx);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
state.rows = baseRows.filter((row) => matchedProjIndices.has(row.projectIdx));
|
|
635
|
+
state.cursor = Math.min(state.cursor, Math.max(0, state.rows.length - 1));
|
|
636
|
+
}
|
|
637
|
+
function rebuildVisibleRows(state) {
|
|
638
|
+
const baseForMode = state.viewMode === "lineage" ? state.lineageRows : state.allRows;
|
|
639
|
+
const base = state.filter ? state.rows : baseForMode;
|
|
640
|
+
state.rows = base.filter((row) => {
|
|
641
|
+
if (row.node.kind === "project")
|
|
642
|
+
return true;
|
|
643
|
+
return !state.collapsed.has(row.projectIdx);
|
|
644
|
+
});
|
|
645
|
+
state.cursor = Math.min(state.cursor, Math.max(0, state.rows.length - 1));
|
|
646
|
+
}
|
|
647
|
+
function handleKey(key, state) {
|
|
648
|
+
const str = key.toString();
|
|
649
|
+
if (state.filterMode) {
|
|
650
|
+
if (str === "\r" || str === `
|
|
651
|
+
`) {
|
|
652
|
+
state.filterMode = false;
|
|
653
|
+
return "continue";
|
|
654
|
+
}
|
|
655
|
+
if (str === "\x1B") {
|
|
656
|
+
state.filter = "";
|
|
657
|
+
state.filterMode = false;
|
|
658
|
+
applyFilter(state);
|
|
659
|
+
rebuildVisibleRows(state);
|
|
660
|
+
return "continue";
|
|
661
|
+
}
|
|
662
|
+
if (str === "" || str === "\b") {
|
|
663
|
+
state.filter = state.filter.slice(0, -1);
|
|
664
|
+
applyFilter(state);
|
|
665
|
+
rebuildVisibleRows(state);
|
|
666
|
+
return "continue";
|
|
667
|
+
}
|
|
668
|
+
if (str.length === 1 && str >= " ") {
|
|
669
|
+
state.filter += str;
|
|
670
|
+
applyFilter(state);
|
|
671
|
+
rebuildVisibleRows(state);
|
|
672
|
+
return "continue";
|
|
673
|
+
}
|
|
674
|
+
return "continue";
|
|
675
|
+
}
|
|
676
|
+
const visibleRows = state.termHeight - 2;
|
|
677
|
+
switch (str) {
|
|
678
|
+
case "q":
|
|
679
|
+
case "\x03":
|
|
680
|
+
return "quit";
|
|
681
|
+
case "j":
|
|
682
|
+
case `${CSI2}B`:
|
|
683
|
+
if (state.cursor < state.rows.length - 1) {
|
|
684
|
+
state.cursor++;
|
|
685
|
+
if (state.cursor >= state.scroll + visibleRows) {
|
|
686
|
+
state.scroll = state.cursor - visibleRows + 1;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return "continue";
|
|
690
|
+
case "k":
|
|
691
|
+
case `${CSI2}A`:
|
|
692
|
+
if (state.cursor > 0) {
|
|
693
|
+
state.cursor--;
|
|
694
|
+
if (state.cursor < state.scroll) {
|
|
695
|
+
state.scroll = state.cursor;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return "continue";
|
|
699
|
+
case "l":
|
|
700
|
+
case `${CSI2}C`: {
|
|
701
|
+
const row = state.rows[state.cursor];
|
|
702
|
+
if (row?.node.kind === "project") {
|
|
703
|
+
state.collapsed.delete(row.projectIdx);
|
|
704
|
+
rebuildVisibleRows(state);
|
|
705
|
+
}
|
|
706
|
+
return "continue";
|
|
707
|
+
}
|
|
708
|
+
case "h":
|
|
709
|
+
case `${CSI2}D`: {
|
|
710
|
+
const row = state.rows[state.cursor];
|
|
711
|
+
if (row?.node.kind === "project") {
|
|
712
|
+
state.collapsed.add(row.projectIdx);
|
|
713
|
+
rebuildVisibleRows(state);
|
|
714
|
+
} else if (row?.node.kind === "session") {
|
|
715
|
+
const projRow = state.rows.findIndex((r) => r.node.kind === "project" && r.projectIdx === row.projectIdx);
|
|
716
|
+
if (projRow >= 0) {
|
|
717
|
+
state.cursor = projRow;
|
|
718
|
+
if (state.cursor < state.scroll)
|
|
719
|
+
state.scroll = state.cursor;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return "continue";
|
|
723
|
+
}
|
|
724
|
+
case "\t": {
|
|
725
|
+
const idx = VIEW_MODES.indexOf(state.viewMode);
|
|
726
|
+
state.viewMode = VIEW_MODES[(idx + 1) % VIEW_MODES.length];
|
|
727
|
+
state.cursor = 0;
|
|
728
|
+
state.scroll = 0;
|
|
729
|
+
state.previewSessionId = null;
|
|
730
|
+
if (state.viewMode === "recent") {
|
|
731
|
+
state.rows = state.flatRows;
|
|
732
|
+
} else if (state.viewMode === "lineage") {
|
|
733
|
+
state.rows = state.lineageRows;
|
|
734
|
+
rebuildVisibleRows(state);
|
|
735
|
+
} else {
|
|
736
|
+
state.rows = state.allRows;
|
|
737
|
+
applyFilter(state);
|
|
738
|
+
rebuildVisibleRows(state);
|
|
739
|
+
}
|
|
740
|
+
return "continue";
|
|
741
|
+
}
|
|
742
|
+
case "/":
|
|
743
|
+
state.filterMode = true;
|
|
744
|
+
return "continue";
|
|
745
|
+
case "\x1B":
|
|
746
|
+
if (state.filter) {
|
|
747
|
+
state.filter = "";
|
|
748
|
+
state.filterMode = false;
|
|
749
|
+
applyFilter(state);
|
|
750
|
+
rebuildVisibleRows(state);
|
|
751
|
+
return "continue";
|
|
752
|
+
}
|
|
753
|
+
return "quit";
|
|
754
|
+
case "g":
|
|
755
|
+
state.cursor = 0;
|
|
756
|
+
state.scroll = 0;
|
|
757
|
+
return "continue";
|
|
758
|
+
case "G":
|
|
759
|
+
state.cursor = state.rows.length - 1;
|
|
760
|
+
state.scroll = Math.max(0, state.cursor - visibleRows + 1);
|
|
761
|
+
return "continue";
|
|
762
|
+
case "c":
|
|
763
|
+
case "\r":
|
|
764
|
+
case `
|
|
765
|
+
`: {
|
|
766
|
+
const row = state.rows[state.cursor];
|
|
767
|
+
if (row?.node.kind === "session")
|
|
768
|
+
return "copy";
|
|
769
|
+
return "continue";
|
|
770
|
+
}
|
|
771
|
+
default:
|
|
772
|
+
return "continue";
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
async function browse(scopePaths) {
|
|
776
|
+
const { projects, treeRows: allRows, lineageRows, flatRows } = await loadTree(scopePaths);
|
|
777
|
+
if (allRows.length === 0) {
|
|
778
|
+
console.log("No sessions found.");
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const sessionProject = new Map;
|
|
782
|
+
for (const proj of projects) {
|
|
783
|
+
for (const s of proj.sessions) {
|
|
784
|
+
sessionProject.set(s.session.id, proj.name);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const [width, height] = process.stdout.getWindowSize?.() ?? [80, 24];
|
|
788
|
+
const state = {
|
|
789
|
+
rows: flatRows,
|
|
790
|
+
allRows,
|
|
791
|
+
lineageRows,
|
|
792
|
+
flatRows,
|
|
793
|
+
viewMode: "recent",
|
|
794
|
+
cursor: 0,
|
|
795
|
+
scroll: 0,
|
|
796
|
+
filter: "",
|
|
797
|
+
filterMode: false,
|
|
798
|
+
previewSessionId: null,
|
|
799
|
+
previewLines: [],
|
|
800
|
+
termWidth: width,
|
|
801
|
+
termHeight: height,
|
|
802
|
+
collapsed: new Set,
|
|
803
|
+
sessionProject,
|
|
804
|
+
statusMessage: null
|
|
805
|
+
};
|
|
806
|
+
await updatePreview(state);
|
|
807
|
+
if (!process.stdin.isTTY) {
|
|
808
|
+
console.error("browse requires an interactive terminal");
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
write(ansi2.altScreen);
|
|
812
|
+
write(ansi2.clear);
|
|
813
|
+
process.stdin.setRawMode(true);
|
|
814
|
+
const cleanup = () => {
|
|
815
|
+
write(ansi2.cursorShow);
|
|
816
|
+
write(ansi2.mainScreen);
|
|
817
|
+
process.stdin.setRawMode(false);
|
|
818
|
+
};
|
|
819
|
+
process.stdout.on("resize", () => {
|
|
820
|
+
const [w, h] = process.stdout.getWindowSize?.() ?? [80, 24];
|
|
821
|
+
state.termWidth = w;
|
|
822
|
+
state.termHeight = h;
|
|
823
|
+
write(ansi2.clear);
|
|
824
|
+
renderTree(state);
|
|
825
|
+
});
|
|
826
|
+
renderTree(state);
|
|
827
|
+
for await (const chunk of process.stdin) {
|
|
828
|
+
const action = handleKey(chunk, state);
|
|
829
|
+
if (action === "quit") {
|
|
830
|
+
cleanup();
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (action === "copy") {
|
|
834
|
+
const row = state.rows[state.cursor];
|
|
835
|
+
if (row?.node.kind === "session") {
|
|
836
|
+
const sessionId = row.node.session.id;
|
|
837
|
+
const ok = await copyToClipboard(sessionId);
|
|
838
|
+
state.statusMessage = ok ? `copied: ${sessionId}` : "clipboard unavailable";
|
|
839
|
+
renderTree(state);
|
|
840
|
+
setTimeout(() => {
|
|
841
|
+
state.statusMessage = null;
|
|
842
|
+
renderTree(state);
|
|
843
|
+
}, 2000);
|
|
844
|
+
}
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
await updatePreview(state);
|
|
848
|
+
renderTree(state);
|
|
849
|
+
}
|
|
850
|
+
cleanup();
|
|
851
|
+
}
|
|
852
|
+
var VIEW_MODES;
|
|
853
|
+
var init_tui = __esm(() => {
|
|
854
|
+
init_queries();
|
|
855
|
+
init_tree();
|
|
856
|
+
init_ansi();
|
|
857
|
+
init_fmt();
|
|
858
|
+
VIEW_MODES = ["recent", "tree", "lineage"];
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// src/db/indexer.ts
|
|
862
|
+
init_db();
|
|
863
|
+
import { homedir as homedir2 } from "node:os";
|
|
864
|
+
import { join as join2 } from "node:path";
|
|
865
|
+
import { existsSync as existsSync2, statSync as statSync2, readdirSync as readdirSync2 } from "node:fs";
|
|
866
|
+
import duckdb2 from "duckdb";
|
|
867
|
+
var CLAUDE_DIR = join2(homedir2(), ".claude", "projects");
|
|
868
|
+
var OPENCODE_DB = process.env.OPENCODE_DB ?? join2(homedir2(), ".local", "share", "opencode", "opencode.db");
|
|
869
|
+
function discoverJsonlFiles() {
|
|
870
|
+
if (!existsSync2(CLAUDE_DIR))
|
|
871
|
+
return [];
|
|
872
|
+
const results = [];
|
|
873
|
+
for (const entry of readdirSync2(CLAUDE_DIR, { withFileTypes: true })) {
|
|
874
|
+
if (!entry.isDirectory())
|
|
875
|
+
continue;
|
|
876
|
+
const dir = join2(CLAUDE_DIR, entry.name);
|
|
877
|
+
for (const file of readdirSync2(dir)) {
|
|
878
|
+
if (!file.endsWith(".jsonl"))
|
|
879
|
+
continue;
|
|
880
|
+
const filePath = join2(dir, file);
|
|
881
|
+
const stat = statSync2(filePath);
|
|
882
|
+
results.push({ path: filePath, slug: entry.name, mtimeMs: stat.mtimeMs });
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return results;
|
|
886
|
+
}
|
|
887
|
+
function slugToPath(slug) {
|
|
888
|
+
return slug.replace(/-/g, "/");
|
|
889
|
+
}
|
|
890
|
+
async function indexJsonlFile(filePath, slug) {
|
|
891
|
+
const directory = slugToPath(slug);
|
|
892
|
+
const escapedPath = filePath.replace(/'/g, "''");
|
|
893
|
+
let tempDb = null;
|
|
894
|
+
let tempConn = null;
|
|
895
|
+
try {
|
|
896
|
+
tempDb = new duckdb2.Database(":memory:");
|
|
897
|
+
tempConn = tempDb.connect();
|
|
898
|
+
const rows = await new Promise((resolve, reject) => {
|
|
899
|
+
tempConn.all(`
|
|
900
|
+
WITH raw AS (
|
|
901
|
+
SELECT
|
|
902
|
+
json_extract_string(column0, '$.type') as msg_type,
|
|
903
|
+
json_extract_string(column0, '$.sessionId') as session_id,
|
|
904
|
+
json_extract_string(column0, '$.timestamp') as ts,
|
|
905
|
+
json_extract_string(column0, '$.gitBranch') as branch,
|
|
906
|
+
json_extract_string(column0, '$.message.role') as role,
|
|
907
|
+
json_extract_string(column0, '$.message.content') as content,
|
|
908
|
+
json_extract_string(column0, '$.message.model') as model,
|
|
909
|
+
CAST(json_extract(column0, '$.message.usage.output_tokens') AS BIGINT) as output_tokens,
|
|
910
|
+
CAST(json_extract(column0, '$.message.usage.cache_read_input_tokens') AS BIGINT) as cache_read,
|
|
911
|
+
CAST(json_extract(column0, '$.message.usage.cache_creation_input_tokens') AS BIGINT) as cache_write
|
|
912
|
+
FROM read_csv('${escapedPath}',
|
|
913
|
+
delim=chr(0), header=false, ignore_errors=true, max_line_size=10000000)
|
|
914
|
+
)
|
|
915
|
+
SELECT * FROM raw
|
|
916
|
+
WHERE msg_type IN ('user', 'assistant')
|
|
917
|
+
AND content IS NOT NULL
|
|
918
|
+
AND length(content) > 2
|
|
919
|
+
AND left(content, 1) != '['
|
|
920
|
+
AND left(content, 1) != '{'
|
|
921
|
+
ORDER BY ts ASC
|
|
922
|
+
`, (err, rows2) => {
|
|
923
|
+
if (err)
|
|
924
|
+
reject(err);
|
|
925
|
+
else
|
|
926
|
+
resolve(rows2 ?? []);
|
|
927
|
+
});
|
|
928
|
+
});
|
|
929
|
+
if (rows.length === 0)
|
|
930
|
+
return 0;
|
|
931
|
+
const sessionMap = new Map;
|
|
932
|
+
for (const r of rows) {
|
|
933
|
+
const sid = r.session_id;
|
|
934
|
+
if (!sid)
|
|
935
|
+
continue;
|
|
936
|
+
const existing = sessionMap.get(sid);
|
|
937
|
+
if (existing)
|
|
938
|
+
existing.push(r);
|
|
939
|
+
else
|
|
940
|
+
sessionMap.set(sid, [r]);
|
|
941
|
+
}
|
|
942
|
+
for (const [sessionId, msgs] of sessionMap) {
|
|
943
|
+
const firstUser = msgs.find((m) => m.role === "user");
|
|
944
|
+
const branch = msgs.find((m) => m.branch)?.branch ?? null;
|
|
945
|
+
const timestamps = msgs.map((m) => new Date(m.ts).getTime()).filter((t) => !isNaN(t));
|
|
946
|
+
const startedAt = Math.min(...timestamps);
|
|
947
|
+
const lastAt = Math.max(...timestamps);
|
|
948
|
+
const userMsgCount = msgs.filter((m) => m.role === "user").length;
|
|
949
|
+
await upsertSession({
|
|
950
|
+
id: sessionId,
|
|
951
|
+
source: "claude-code",
|
|
952
|
+
directory,
|
|
953
|
+
branch,
|
|
954
|
+
title: firstUser ? firstUser.content.replace(/\n/g, " ").replace(/\s+/g, " ").trim().slice(0, 200) : "(no title)",
|
|
955
|
+
startedAt,
|
|
956
|
+
lastAt,
|
|
957
|
+
messageCount: userMsgCount
|
|
958
|
+
});
|
|
959
|
+
const indexedMsgs = msgs.map((m, i) => ({
|
|
960
|
+
role: m.role,
|
|
961
|
+
content: m.content,
|
|
962
|
+
timestampMs: new Date(m.ts).getTime(),
|
|
963
|
+
position: i,
|
|
964
|
+
model: m.model,
|
|
965
|
+
outputTokens: Number(m.output_tokens ?? 0),
|
|
966
|
+
cacheRead: Number(m.cache_read ?? 0),
|
|
967
|
+
cacheWrite: Number(m.cache_write ?? 0)
|
|
968
|
+
}));
|
|
969
|
+
await replaceMessages(sessionId, indexedMsgs);
|
|
970
|
+
}
|
|
971
|
+
return sessionMap.size;
|
|
972
|
+
} finally {
|
|
973
|
+
if (tempDb)
|
|
974
|
+
tempDb.close();
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
async function indexOpenCode() {
|
|
978
|
+
if (!existsSync2(OPENCODE_DB))
|
|
979
|
+
return 0;
|
|
980
|
+
const stat = statSync2(OPENCODE_DB);
|
|
981
|
+
const currentMtime = stat.mtimeMs;
|
|
982
|
+
const trackedMtime = await getFileMtime(OPENCODE_DB);
|
|
983
|
+
if (trackedMtime !== null && Math.abs(currentMtime - trackedMtime) < 1000) {
|
|
984
|
+
return 0;
|
|
985
|
+
}
|
|
986
|
+
let tempDb = null;
|
|
987
|
+
let tempConn = null;
|
|
988
|
+
try {
|
|
989
|
+
tempDb = new duckdb2.Database(":memory:");
|
|
990
|
+
tempConn = tempDb.connect();
|
|
991
|
+
await new Promise((resolve, reject) => {
|
|
992
|
+
tempConn.exec("INSTALL sqlite_scanner; LOAD sqlite_scanner", (err) => {
|
|
993
|
+
if (err)
|
|
994
|
+
reject(err);
|
|
995
|
+
else
|
|
996
|
+
resolve();
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
const escapedPath = OPENCODE_DB.replace(/'/g, "''");
|
|
1000
|
+
await new Promise((resolve, reject) => {
|
|
1001
|
+
tempConn.exec(`ATTACH '${escapedPath}' AS oc (TYPE sqlite, READ_ONLY)`, (err) => {
|
|
1002
|
+
if (err)
|
|
1003
|
+
reject(err);
|
|
1004
|
+
else
|
|
1005
|
+
resolve();
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
const sessions = await new Promise((resolve, reject) => {
|
|
1009
|
+
tempConn.all(`
|
|
1010
|
+
SELECT id, parent_id, directory, COALESCE(title, slug) as title, slug,
|
|
1011
|
+
time_created, time_updated
|
|
1012
|
+
FROM oc.session
|
|
1013
|
+
WHERE time_archived IS NULL
|
|
1014
|
+
ORDER BY time_updated DESC
|
|
1015
|
+
`, (err, rows) => {
|
|
1016
|
+
if (err)
|
|
1017
|
+
reject(err);
|
|
1018
|
+
else
|
|
1019
|
+
resolve(rows ?? []);
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
let count = 0;
|
|
1023
|
+
for (const s of sessions) {
|
|
1024
|
+
const parts = await new Promise((resolve, reject) => {
|
|
1025
|
+
tempConn.all(`
|
|
1026
|
+
SELECT
|
|
1027
|
+
json_extract_string(p.data, '$.text') as text,
|
|
1028
|
+
json_extract_string(m.data, '$.role') as role,
|
|
1029
|
+
m.time_created as mtime
|
|
1030
|
+
FROM oc.part p
|
|
1031
|
+
JOIN oc.message m ON p.message_id = m.id
|
|
1032
|
+
WHERE p.session_id = '${s.id}'
|
|
1033
|
+
AND json_extract_string(p.data, '$.type') = 'text'
|
|
1034
|
+
AND json_extract_string(p.data, '$.text') IS NOT NULL
|
|
1035
|
+
AND length(json_extract_string(p.data, '$.text')) > 0
|
|
1036
|
+
ORDER BY m.time_created ASC, p.time_created ASC
|
|
1037
|
+
`, (err, rows) => {
|
|
1038
|
+
if (err)
|
|
1039
|
+
reject(err);
|
|
1040
|
+
else
|
|
1041
|
+
resolve(rows ?? []);
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
if (parts.length === 0)
|
|
1045
|
+
continue;
|
|
1046
|
+
const userCount = parts.filter((p) => p.role === "user").length;
|
|
1047
|
+
await upsertSession({
|
|
1048
|
+
id: s.id,
|
|
1049
|
+
source: "opencode",
|
|
1050
|
+
directory: s.directory,
|
|
1051
|
+
branch: null,
|
|
1052
|
+
title: s.title || s.slug || "(no title)",
|
|
1053
|
+
parentId: s.parent_id ?? null,
|
|
1054
|
+
startedAt: Number(s.time_created),
|
|
1055
|
+
lastAt: Number(s.time_updated),
|
|
1056
|
+
messageCount: userCount
|
|
1057
|
+
});
|
|
1058
|
+
const messages = parts.map((p, i) => ({
|
|
1059
|
+
role: p.role,
|
|
1060
|
+
content: p.text,
|
|
1061
|
+
timestampMs: Number(p.mtime),
|
|
1062
|
+
position: i
|
|
1063
|
+
}));
|
|
1064
|
+
await replaceMessages(s.id, messages);
|
|
1065
|
+
count++;
|
|
1066
|
+
}
|
|
1067
|
+
await setFileMtime(OPENCODE_DB, "opencode", currentMtime);
|
|
1068
|
+
return count;
|
|
1069
|
+
} catch (e) {
|
|
1070
|
+
return 0;
|
|
1071
|
+
} finally {
|
|
1072
|
+
if (tempDb)
|
|
1073
|
+
tempDb.close();
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async function runIndex(opts) {
|
|
1077
|
+
const start = Date.now();
|
|
1078
|
+
await ensureSchema();
|
|
1079
|
+
const force = opts?.force ?? false;
|
|
1080
|
+
const verbose = opts?.verbose ?? false;
|
|
1081
|
+
const jsonlFiles = discoverJsonlFiles();
|
|
1082
|
+
let filesChecked = jsonlFiles.length;
|
|
1083
|
+
let filesIndexed = 0;
|
|
1084
|
+
let sessionsIndexed = 0;
|
|
1085
|
+
for (const file of jsonlFiles) {
|
|
1086
|
+
const trackedMtime = await getFileMtime(file.path);
|
|
1087
|
+
if (!force && trackedMtime !== null && Math.abs(file.mtimeMs - trackedMtime) < 1000) {
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
if (verbose) {
|
|
1091
|
+
const name = file.path.split("/").pop();
|
|
1092
|
+
process.stderr.write(`indexing ${name}...
|
|
1093
|
+
`);
|
|
1094
|
+
}
|
|
1095
|
+
try {
|
|
1096
|
+
const count = await indexJsonlFile(file.path, file.slug);
|
|
1097
|
+
await setFileMtime(file.path, "claude-code", file.mtimeMs);
|
|
1098
|
+
filesIndexed++;
|
|
1099
|
+
sessionsIndexed += count;
|
|
1100
|
+
} catch (e) {
|
|
1101
|
+
if (verbose) {
|
|
1102
|
+
process.stderr.write(` error: ${e}
|
|
1103
|
+
`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const trackedFiles = await query(`SELECT path, source FROM source_file WHERE source = 'claude-code'`);
|
|
1108
|
+
const currentPaths = new Set(jsonlFiles.map((f) => f.path));
|
|
1109
|
+
for (const tracked of trackedFiles) {
|
|
1110
|
+
if (!currentPaths.has(tracked.path)) {
|
|
1111
|
+
if (verbose)
|
|
1112
|
+
process.stderr.write(`removing stale: ${tracked.path}
|
|
1113
|
+
`);
|
|
1114
|
+
await exec(`DELETE FROM source_file WHERE path = '${tracked.path.replace(/'/g, "''")}'`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
filesChecked++;
|
|
1118
|
+
try {
|
|
1119
|
+
const ocCount = await indexOpenCode();
|
|
1120
|
+
if (ocCount > 0) {
|
|
1121
|
+
filesIndexed++;
|
|
1122
|
+
sessionsIndexed += ocCount;
|
|
1123
|
+
if (verbose)
|
|
1124
|
+
process.stderr.write(`indexed ${ocCount} opencode sessions
|
|
1125
|
+
`);
|
|
1126
|
+
}
|
|
1127
|
+
} catch (e) {
|
|
1128
|
+
if (verbose)
|
|
1129
|
+
process.stderr.write(`opencode error: ${e}
|
|
1130
|
+
`);
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
filesChecked,
|
|
1134
|
+
filesIndexed,
|
|
1135
|
+
sessionsIndexed,
|
|
1136
|
+
elapsed: Date.now() - start
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
async function needsIndex() {
|
|
1140
|
+
try {
|
|
1141
|
+
await ensureSchema();
|
|
1142
|
+
} catch {
|
|
1143
|
+
return true;
|
|
1144
|
+
}
|
|
1145
|
+
const rows = await query(`SELECT count(*) as cnt FROM session`);
|
|
1146
|
+
if (Number(rows[0]?.cnt ?? 0) === 0)
|
|
1147
|
+
return true;
|
|
1148
|
+
const jsonlFiles = discoverJsonlFiles();
|
|
1149
|
+
for (const file of jsonlFiles.slice(0, 5)) {
|
|
1150
|
+
const tracked = await getFileMtime(file.path);
|
|
1151
|
+
if (tracked === null || Math.abs(file.mtimeMs - tracked) >= 1000)
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
return false;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// src/db/index.ts
|
|
1158
|
+
import { homedir as homedir3 } from "node:os";
|
|
1159
|
+
import { join as join3, dirname as dirname2 } from "node:path";
|
|
1160
|
+
import duckdb3 from "duckdb";
|
|
1161
|
+
var INDEX_DIR2 = join3(homedir3(), ".local", "share", "reconvo");
|
|
1162
|
+
var INDEX_PATH2 = process.env.RECONVO_INDEX ?? join3(INDEX_DIR2, "index.duckdb");
|
|
1163
|
+
function getIndexPath() {
|
|
1164
|
+
return INDEX_PATH2;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// src/db/queries.ts
|
|
1168
|
+
init_db();
|
|
1169
|
+
function esc(s) {
|
|
1170
|
+
return s.replace(/\\/g, "\\\\").replace(/'/g, "''").replace(/\x00/g, "");
|
|
1171
|
+
}
|
|
1172
|
+
async function listSessions(opts) {
|
|
1173
|
+
const limit = opts?.limit ?? 50;
|
|
1174
|
+
const conditions = [];
|
|
1175
|
+
if (opts?.source) {
|
|
1176
|
+
conditions.push(`source = '${esc(opts.source)}'`);
|
|
1177
|
+
}
|
|
1178
|
+
if (opts?.scopePaths?.length) {
|
|
1179
|
+
const conds = opts.scopePaths.map((p) => `directory LIKE '${esc(p)}%'`).join(" OR ");
|
|
1180
|
+
conditions.push(`(${conds})`);
|
|
1181
|
+
}
|
|
1182
|
+
if (opts?.sinceMs) {
|
|
1183
|
+
conditions.push(`last_at >= ${opts.sinceMs}`);
|
|
1184
|
+
}
|
|
1185
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1186
|
+
const rows = await query(`
|
|
1187
|
+
SELECT id, source, directory, branch, title, parent_id, started_at, last_at, message_count
|
|
1188
|
+
FROM session
|
|
1189
|
+
${where}
|
|
1190
|
+
ORDER BY last_at DESC
|
|
1191
|
+
LIMIT ${limit}
|
|
1192
|
+
`);
|
|
1193
|
+
return rows.map((r) => ({
|
|
1194
|
+
id: r.id,
|
|
1195
|
+
source: r.source,
|
|
1196
|
+
directory: r.directory,
|
|
1197
|
+
branch: r.branch,
|
|
1198
|
+
title: r.title ?? "(no title)",
|
|
1199
|
+
parentId: r.parent_id ?? null,
|
|
1200
|
+
startedAt: Number(r.started_at),
|
|
1201
|
+
lastAt: Number(r.last_at),
|
|
1202
|
+
messageCount: Number(r.message_count)
|
|
1203
|
+
}));
|
|
1204
|
+
}
|
|
1205
|
+
async function searchSessions(keywords, opts) {
|
|
1206
|
+
const limit = opts?.limit ?? 20;
|
|
1207
|
+
const kwConds = keywords.map((w) => `m.content ILIKE '%${esc(w)}%'`).join(" OR ");
|
|
1208
|
+
const conditions = [`(${kwConds})`];
|
|
1209
|
+
if (opts?.source) {
|
|
1210
|
+
conditions.push(`s.source = '${esc(opts.source)}'`);
|
|
1211
|
+
}
|
|
1212
|
+
if (opts?.scopePaths?.length) {
|
|
1213
|
+
const conds = opts.scopePaths.map((p) => `s.directory LIKE '${esc(p)}%'`).join(" OR ");
|
|
1214
|
+
conditions.push(`(${conds})`);
|
|
1215
|
+
}
|
|
1216
|
+
if (opts?.sinceMs) {
|
|
1217
|
+
conditions.push(`s.last_at >= ${opts.sinceMs}`);
|
|
1218
|
+
}
|
|
1219
|
+
const where = conditions.join(" AND ");
|
|
1220
|
+
const rows = await query(`
|
|
1221
|
+
WITH hits AS (
|
|
1222
|
+
SELECT
|
|
1223
|
+
m.session_id, m.content, m.role, m.timestamp_ms, m.position,
|
|
1224
|
+
ROW_NUMBER() OVER (PARTITION BY m.session_id ORDER BY m.timestamp_ms) as rn,
|
|
1225
|
+
COUNT(*) OVER (PARTITION BY m.session_id) as total_hits
|
|
1226
|
+
FROM message m
|
|
1227
|
+
JOIN session s ON m.session_id = s.id
|
|
1228
|
+
WHERE ${where}
|
|
1229
|
+
)
|
|
1230
|
+
SELECT
|
|
1231
|
+
s.id as session_id, s.source, s.directory, s.branch, s.title,
|
|
1232
|
+
s.started_at, s.last_at, s.message_count,
|
|
1233
|
+
h.content as hit_content, h.role as hit_role,
|
|
1234
|
+
h.timestamp_ms as hit_ts, h.position as hit_position,
|
|
1235
|
+
h.total_hits
|
|
1236
|
+
FROM hits h
|
|
1237
|
+
JOIN session s ON h.session_id = s.id
|
|
1238
|
+
WHERE h.rn = 1
|
|
1239
|
+
ORDER BY h.total_hits DESC, s.last_at DESC
|
|
1240
|
+
LIMIT ${limit}
|
|
1241
|
+
`);
|
|
1242
|
+
return rows.map((r) => ({
|
|
1243
|
+
session: {
|
|
1244
|
+
id: r.session_id,
|
|
1245
|
+
source: r.source,
|
|
1246
|
+
directory: r.directory,
|
|
1247
|
+
branch: r.branch,
|
|
1248
|
+
title: r.title ?? "(no title)",
|
|
1249
|
+
parentId: null,
|
|
1250
|
+
startedAt: Number(r.started_at),
|
|
1251
|
+
lastAt: Number(r.last_at),
|
|
1252
|
+
messageCount: Number(r.message_count)
|
|
1253
|
+
},
|
|
1254
|
+
snippet: (r.hit_content ?? "").slice(0, 300),
|
|
1255
|
+
position: Number(r.hit_position),
|
|
1256
|
+
role: r.hit_role ?? "user",
|
|
1257
|
+
timestamp: Number(r.hit_ts)
|
|
1258
|
+
}));
|
|
1259
|
+
}
|
|
1260
|
+
async function readMessages(sessionPrefix, opts) {
|
|
1261
|
+
let from = opts?.from;
|
|
1262
|
+
let to = opts?.to;
|
|
1263
|
+
if (opts?.around !== undefined) {
|
|
1264
|
+
const radius = opts?.radius ?? 3;
|
|
1265
|
+
from = Math.max(0, opts.around - radius);
|
|
1266
|
+
to = opts.around + radius + 1;
|
|
1267
|
+
}
|
|
1268
|
+
const conditions = [`session_id LIKE '${esc(sessionPrefix)}%'`];
|
|
1269
|
+
if (from !== undefined)
|
|
1270
|
+
conditions.push(`position >= ${from}`);
|
|
1271
|
+
if (to !== undefined)
|
|
1272
|
+
conditions.push(`position < ${to}`);
|
|
1273
|
+
if (opts?.role === "user" || opts?.role === "assistant") {
|
|
1274
|
+
conditions.push(`role = '${opts.role}'`);
|
|
1275
|
+
}
|
|
1276
|
+
const rows = await query(`
|
|
1277
|
+
SELECT session_id, role, content, timestamp_ms, position
|
|
1278
|
+
FROM message
|
|
1279
|
+
WHERE ${conditions.join(" AND ")}
|
|
1280
|
+
ORDER BY position ASC
|
|
1281
|
+
`);
|
|
1282
|
+
return rows.map((r) => ({
|
|
1283
|
+
sessionId: r.session_id,
|
|
1284
|
+
role: r.role,
|
|
1285
|
+
content: r.content,
|
|
1286
|
+
timestamp: Number(r.timestamp_ms),
|
|
1287
|
+
position: Number(r.position)
|
|
1288
|
+
}));
|
|
1289
|
+
}
|
|
1290
|
+
async function skimSession(sessionPrefix, head = 3, tail = 3, role) {
|
|
1291
|
+
const all = await readMessages(sessionPrefix, { role });
|
|
1292
|
+
if (all.length <= head + tail) {
|
|
1293
|
+
return { head: all, tail: [], skipped: 0, total: all.length };
|
|
1294
|
+
}
|
|
1295
|
+
return {
|
|
1296
|
+
head: all.slice(0, head),
|
|
1297
|
+
tail: all.slice(-tail),
|
|
1298
|
+
skipped: all.length - head - tail,
|
|
1299
|
+
total: all.length
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
async function getStats(scopePaths) {
|
|
1303
|
+
let scopeWhere = "";
|
|
1304
|
+
if (scopePaths?.length) {
|
|
1305
|
+
const conds = scopePaths.map((p) => `s.directory LIKE '${esc(p)}%'`).join(" OR ");
|
|
1306
|
+
scopeWhere = `AND (${conds})`;
|
|
1307
|
+
}
|
|
1308
|
+
const models = await query(`
|
|
1309
|
+
SELECT
|
|
1310
|
+
m.model,
|
|
1311
|
+
count(*) as turns,
|
|
1312
|
+
COALESCE(sum(m.output_tokens), 0) as output_tokens,
|
|
1313
|
+
COALESCE(sum(m.cache_read), 0) as cache_read,
|
|
1314
|
+
COALESCE(sum(m.cache_write), 0) as cache_write
|
|
1315
|
+
FROM message m
|
|
1316
|
+
JOIN session s ON m.session_id = s.id
|
|
1317
|
+
WHERE m.model IS NOT NULL
|
|
1318
|
+
AND m.role = 'assistant'
|
|
1319
|
+
${scopeWhere}
|
|
1320
|
+
GROUP BY m.model
|
|
1321
|
+
ORDER BY count(*) DESC
|
|
1322
|
+
`);
|
|
1323
|
+
const daily = await query(`
|
|
1324
|
+
SELECT
|
|
1325
|
+
strftime(to_timestamp(m.timestamp_ms / 1000), '%Y-%m-%d') as day,
|
|
1326
|
+
count(DISTINCT m.session_id) as sessions,
|
|
1327
|
+
count(*) FILTER (WHERE m.role = 'user') as user_msgs,
|
|
1328
|
+
count(*) FILTER (WHERE m.role = 'assistant') as asst_turns
|
|
1329
|
+
FROM message m
|
|
1330
|
+
JOIN session s ON m.session_id = s.id
|
|
1331
|
+
WHERE 1=1 ${scopeWhere}
|
|
1332
|
+
GROUP BY day
|
|
1333
|
+
ORDER BY day
|
|
1334
|
+
`);
|
|
1335
|
+
return {
|
|
1336
|
+
models: models.map((m) => ({
|
|
1337
|
+
model: m.model,
|
|
1338
|
+
turns: Number(m.turns),
|
|
1339
|
+
outputTokens: Number(m.output_tokens),
|
|
1340
|
+
cacheRead: Number(m.cache_read),
|
|
1341
|
+
cacheWrite: Number(m.cache_write)
|
|
1342
|
+
})),
|
|
1343
|
+
daily: daily.map((d) => ({
|
|
1344
|
+
day: d.day,
|
|
1345
|
+
sessions: Number(d.sessions),
|
|
1346
|
+
userMsgs: Number(d.user_msgs),
|
|
1347
|
+
assistantTurns: Number(d.asst_turns)
|
|
1348
|
+
}))
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
async function searchByFile(filePath, opts) {
|
|
1352
|
+
const limit = opts?.limit ?? 20;
|
|
1353
|
+
const conditions = [`m.content ILIKE '%${esc(filePath)}%'`];
|
|
1354
|
+
if (opts?.source)
|
|
1355
|
+
conditions.push(`s.source = '${esc(opts.source)}'`);
|
|
1356
|
+
if (opts?.scopePaths?.length) {
|
|
1357
|
+
const conds = opts.scopePaths.map((p) => `s.directory LIKE '${esc(p)}%'`).join(" OR ");
|
|
1358
|
+
conditions.push(`(${conds})`);
|
|
1359
|
+
}
|
|
1360
|
+
if (opts?.sinceMs)
|
|
1361
|
+
conditions.push(`s.last_at >= ${opts.sinceMs}`);
|
|
1362
|
+
const rows = await query(`
|
|
1363
|
+
SELECT DISTINCT s.id, s.source, s.directory, s.branch, s.title, s.parent_id,
|
|
1364
|
+
s.started_at, s.last_at, s.message_count
|
|
1365
|
+
FROM session s
|
|
1366
|
+
JOIN message m ON m.session_id = s.id
|
|
1367
|
+
WHERE ${conditions.join(" AND ")}
|
|
1368
|
+
ORDER BY s.last_at DESC
|
|
1369
|
+
LIMIT ${limit}
|
|
1370
|
+
`);
|
|
1371
|
+
return rows.map((r) => ({
|
|
1372
|
+
id: r.id,
|
|
1373
|
+
source: r.source,
|
|
1374
|
+
directory: r.directory,
|
|
1375
|
+
branch: r.branch,
|
|
1376
|
+
title: r.title ?? "(no title)",
|
|
1377
|
+
parentId: r.parent_id ?? null,
|
|
1378
|
+
startedAt: Number(r.started_at),
|
|
1379
|
+
lastAt: Number(r.last_at),
|
|
1380
|
+
messageCount: Number(r.message_count)
|
|
1381
|
+
}));
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// src/context/git.ts
|
|
1385
|
+
import { dirname as dirname3, resolve } from "node:path";
|
|
1386
|
+
async function run(cmd) {
|
|
1387
|
+
try {
|
|
1388
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" });
|
|
1389
|
+
const text = await new Response(proc.stdout).text();
|
|
1390
|
+
return await proc.exited === 0 ? text.trim() : null;
|
|
1391
|
+
} catch {
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
async function detect(cwd = process.cwd()) {
|
|
1396
|
+
const root = await run(["git", "-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
1397
|
+
if (!root)
|
|
1398
|
+
return null;
|
|
1399
|
+
const branch = await run(["git", "-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
1400
|
+
let commonDir = await run(["git", "-C", cwd, "rev-parse", "--git-common-dir"]);
|
|
1401
|
+
if (commonDir && !commonDir.startsWith("/"))
|
|
1402
|
+
commonDir = resolve(root, commonDir);
|
|
1403
|
+
if (!commonDir)
|
|
1404
|
+
commonDir = root;
|
|
1405
|
+
const raw = await run(["git", "-C", cwd, "worktree", "list", "--porcelain"]);
|
|
1406
|
+
const siblings = raw ? raw.split(`
|
|
1407
|
+
`).filter((l) => l.startsWith("worktree ")).map((l) => l.slice("worktree ".length)) : [root];
|
|
1408
|
+
return {
|
|
1409
|
+
root,
|
|
1410
|
+
branch: branch === "HEAD" ? null : branch,
|
|
1411
|
+
commonDir,
|
|
1412
|
+
familyDir: dirname3(siblings[0] ?? root),
|
|
1413
|
+
siblings: siblings.length > 0 ? siblings : [root]
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
function scope(ctx) {
|
|
1417
|
+
return [...new Set([...ctx.siblings, ctx.familyDir])];
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// src/util/ansi.ts
|
|
1421
|
+
var ESC = "\x1B";
|
|
1422
|
+
var CSI = `${ESC}[`;
|
|
1423
|
+
var ansi = {
|
|
1424
|
+
clear: `${CSI}2J${CSI}H`,
|
|
1425
|
+
cursorHide: `${CSI}?25l`,
|
|
1426
|
+
cursorShow: `${CSI}?25h`,
|
|
1427
|
+
altScreen: `${CSI}?1049h`,
|
|
1428
|
+
mainScreen: `${CSI}?1049l`,
|
|
1429
|
+
moveTo: (row, col) => `${CSI}${row};${col}H`,
|
|
1430
|
+
eraseLine: `${CSI}2K`,
|
|
1431
|
+
bold: `${CSI}1m`,
|
|
1432
|
+
dim: `${CSI}2m`,
|
|
1433
|
+
italic: `${CSI}3m`,
|
|
1434
|
+
inverse: `${CSI}7m`,
|
|
1435
|
+
reset: `${CSI}0m`,
|
|
1436
|
+
faint: `${CSI}90m`,
|
|
1437
|
+
cyan: `${CSI}36m`,
|
|
1438
|
+
yellow: `${CSI}33m`
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
// src/util/fmt.ts
|
|
1442
|
+
init_ansi();
|
|
1443
|
+
function agoLong(ms) {
|
|
1444
|
+
const diff = Date.now() - ms;
|
|
1445
|
+
const mins = Math.floor(diff / 60000);
|
|
1446
|
+
if (mins < 60)
|
|
1447
|
+
return `${mins}m ago`;
|
|
1448
|
+
const hours = Math.floor(mins / 60);
|
|
1449
|
+
if (hours < 24)
|
|
1450
|
+
return `${hours}h ago`;
|
|
1451
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
1452
|
+
}
|
|
1453
|
+
var timeFmt = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
|
|
1454
|
+
var dateFmt = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" });
|
|
1455
|
+
var yearFmt = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "2-digit" });
|
|
1456
|
+
function truncatePlain(s, max) {
|
|
1457
|
+
if (max <= 0)
|
|
1458
|
+
return "";
|
|
1459
|
+
return s.length <= max ? s : `${s.slice(0, max - 3)}...`;
|
|
1460
|
+
}
|
|
1461
|
+
function col(s, width) {
|
|
1462
|
+
const t = truncatePlain(s, width);
|
|
1463
|
+
return t + " ".repeat(Math.max(0, width - t.length));
|
|
1464
|
+
}
|
|
1465
|
+
function parseSince(val) {
|
|
1466
|
+
const now = Date.now();
|
|
1467
|
+
const lower = val.toLowerCase().trim();
|
|
1468
|
+
if (lower === "today") {
|
|
1469
|
+
const d2 = new Date;
|
|
1470
|
+
d2.setHours(0, 0, 0, 0);
|
|
1471
|
+
return d2.getTime();
|
|
1472
|
+
}
|
|
1473
|
+
if (lower === "yesterday") {
|
|
1474
|
+
const d2 = new Date;
|
|
1475
|
+
d2.setHours(0, 0, 0, 0);
|
|
1476
|
+
d2.setDate(d2.getDate() - 1);
|
|
1477
|
+
return d2.getTime();
|
|
1478
|
+
}
|
|
1479
|
+
const rel = lower.match(/^(\d+)([mhdw])$/);
|
|
1480
|
+
if (rel) {
|
|
1481
|
+
const n = parseInt(rel[1], 10);
|
|
1482
|
+
const unit = rel[2];
|
|
1483
|
+
const ms = unit === "m" ? n * 60000 : unit === "h" ? n * 3600000 : unit === "d" ? n * 86400000 : n * 604800000;
|
|
1484
|
+
return now - ms;
|
|
1485
|
+
}
|
|
1486
|
+
const d = new Date(lower);
|
|
1487
|
+
if (!isNaN(d.getTime()))
|
|
1488
|
+
return d.getTime();
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// src/cli.ts
|
|
1493
|
+
var DIM = ansi.dim;
|
|
1494
|
+
var BOLD = ansi.bold;
|
|
1495
|
+
var RESET = ansi.reset;
|
|
1496
|
+
var CYAN = ansi.cyan;
|
|
1497
|
+
|
|
1498
|
+
class UsageError extends Error {
|
|
1499
|
+
}
|
|
1500
|
+
function fail(msg) {
|
|
1501
|
+
throw new UsageError(msg);
|
|
1502
|
+
}
|
|
1503
|
+
async function getScope(allFlag) {
|
|
1504
|
+
if (allFlag)
|
|
1505
|
+
return;
|
|
1506
|
+
const ctx = await detect();
|
|
1507
|
+
if (!ctx)
|
|
1508
|
+
return;
|
|
1509
|
+
return scope(ctx);
|
|
1510
|
+
}
|
|
1511
|
+
function parseSource(args) {
|
|
1512
|
+
const idx = args.indexOf("--source");
|
|
1513
|
+
if (idx === -1 || idx + 1 >= args.length)
|
|
1514
|
+
return;
|
|
1515
|
+
const val = args[idx + 1];
|
|
1516
|
+
if (val === "claude" || val === "claude-code")
|
|
1517
|
+
return "claude-code";
|
|
1518
|
+
if (val === "opencode" || val === "oc")
|
|
1519
|
+
return "opencode";
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
function stripFlags(args) {
|
|
1523
|
+
const result = [];
|
|
1524
|
+
let skip = false;
|
|
1525
|
+
for (const arg of args) {
|
|
1526
|
+
if (skip) {
|
|
1527
|
+
skip = false;
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
if (arg === "--source" || arg === "--from" || arg === "--to" || arg === "--around" || arg === "--radius" || arg === "--head" || arg === "--tail" || arg === "--since" || arg === "--role") {
|
|
1531
|
+
skip = true;
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
if (!arg.startsWith("--"))
|
|
1535
|
+
result.push(arg);
|
|
1536
|
+
}
|
|
1537
|
+
return result;
|
|
1538
|
+
}
|
|
1539
|
+
function flagStr(args, name) {
|
|
1540
|
+
const idx = args.indexOf(name);
|
|
1541
|
+
if (idx === -1 || idx + 1 >= args.length)
|
|
1542
|
+
return;
|
|
1543
|
+
return args[idx + 1];
|
|
1544
|
+
}
|
|
1545
|
+
function flagVal(args, name) {
|
|
1546
|
+
const idx = args.indexOf(name);
|
|
1547
|
+
if (idx === -1 || idx + 1 >= args.length)
|
|
1548
|
+
return;
|
|
1549
|
+
const n = parseInt(args[idx + 1], 10);
|
|
1550
|
+
return Number.isNaN(n) ? undefined : n;
|
|
1551
|
+
}
|
|
1552
|
+
async function ensureIndexed(verbose = false) {
|
|
1553
|
+
const needs = await needsIndex();
|
|
1554
|
+
if (needs) {
|
|
1555
|
+
if (verbose)
|
|
1556
|
+
process.stderr.write(`Updating index...
|
|
1557
|
+
`);
|
|
1558
|
+
const stats = await runIndex({ verbose });
|
|
1559
|
+
if (verbose || stats.filesIndexed > 0) {
|
|
1560
|
+
process.stderr.write(`Indexed ${stats.sessionsIndexed} sessions from ${stats.filesIndexed} files in ${(stats.elapsed / 1000).toFixed(1)}s
|
|
1561
|
+
`);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
async function cmdIndex(args) {
|
|
1566
|
+
const force = args.includes("--force");
|
|
1567
|
+
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
1568
|
+
const stats = await runIndex({ force, verbose });
|
|
1569
|
+
console.log(`Indexed ${stats.sessionsIndexed} sessions from ${stats.filesIndexed} files ` + `(${stats.filesChecked} checked) in ${(stats.elapsed / 1000).toFixed(1)}s`);
|
|
1570
|
+
console.log(`Index: ${getIndexPath()}`);
|
|
1571
|
+
}
|
|
1572
|
+
function getSince(args) {
|
|
1573
|
+
const raw = flagStr(args, "--since");
|
|
1574
|
+
if (!raw)
|
|
1575
|
+
return;
|
|
1576
|
+
const ms = parseSince(raw);
|
|
1577
|
+
if (ms === undefined) {
|
|
1578
|
+
fail(`Invalid --since value: ${raw}
|
|
1579
|
+
Examples: 2h, 3d, 1w, today, yesterday, 2026-03-10`);
|
|
1580
|
+
}
|
|
1581
|
+
return ms;
|
|
1582
|
+
}
|
|
1583
|
+
async function cmdSessions(args) {
|
|
1584
|
+
const jsonOut = args.includes("--json");
|
|
1585
|
+
const allFlag = args.includes("--all");
|
|
1586
|
+
const source = parseSource(args);
|
|
1587
|
+
const sinceMs = getSince(args);
|
|
1588
|
+
const scopePaths = await getScope(allFlag);
|
|
1589
|
+
const results = await listSessions({ source, scopePaths, sinceMs });
|
|
1590
|
+
if (jsonOut) {
|
|
1591
|
+
console.log(JSON.stringify(results, null, 2));
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
if (results.length === 0) {
|
|
1595
|
+
console.log("No sessions found.");
|
|
1596
|
+
if (!allFlag)
|
|
1597
|
+
console.log(`${DIM}(try --all to search all projects)${RESET}`);
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
for (const s of results) {
|
|
1601
|
+
const age = agoLong(s.lastAt);
|
|
1602
|
+
const dirName = s.directory.split("/").pop() ?? s.directory;
|
|
1603
|
+
const src = s.source === "opencode" ? `${DIM}oc${RESET} ` : "";
|
|
1604
|
+
const branch = s.branch ? `${DIM}${s.branch}${RESET} ` : "";
|
|
1605
|
+
console.log(`${src}${col(dirName, 18)} ${branch}${col(s.title, 40)} ${DIM}${col(age, 8)} ${s.messageCount} msgs${RESET} ${DIM}${s.id.slice(0, 8)}${RESET}`);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
async function cmdSearch(args) {
|
|
1609
|
+
const jsonOut = args.includes("--json");
|
|
1610
|
+
const allFlag = args.includes("--all");
|
|
1611
|
+
const source = parseSource(args);
|
|
1612
|
+
const keywords = stripFlags(args);
|
|
1613
|
+
if (keywords.length === 0) {
|
|
1614
|
+
fail("Usage: reconvo search <query> [--all] [--source claude|opencode] [--json]");
|
|
1615
|
+
}
|
|
1616
|
+
const sinceMs = getSince(args);
|
|
1617
|
+
const scopePaths = await getScope(allFlag);
|
|
1618
|
+
const results = await searchSessions(keywords, { source, scopePaths, sinceMs });
|
|
1619
|
+
if (jsonOut) {
|
|
1620
|
+
console.log(JSON.stringify(results, null, 2));
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
if (results.length === 0) {
|
|
1624
|
+
console.log(`No results for "${keywords.join(" ")}"`);
|
|
1625
|
+
if (!allFlag)
|
|
1626
|
+
console.log(`${DIM}(try --all to search all projects)${RESET}`);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
for (const hit of results) {
|
|
1630
|
+
const s = hit.session;
|
|
1631
|
+
const dirName = s.directory.split("/").pop() ?? s.directory;
|
|
1632
|
+
const age = agoLong(s.lastAt);
|
|
1633
|
+
const src = s.source === "opencode" ? `${DIM}oc${RESET} ` : "";
|
|
1634
|
+
const roleColor = hit.role === "user" ? CYAN : DIM;
|
|
1635
|
+
console.log(`${src}${BOLD}${dirName}${RESET} ${DIM}${age} ${s.id.slice(0, 8)}${RESET}`);
|
|
1636
|
+
console.log(` ${roleColor}${hit.role}${RESET}: ${truncatePlain(hit.snippet.replace(/\s+/g, " "), 120)}`);
|
|
1637
|
+
console.log();
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
async function cmdRead(args) {
|
|
1641
|
+
const jsonOut = args.includes("--json");
|
|
1642
|
+
const positional = stripFlags(args);
|
|
1643
|
+
const sessionId = positional[0];
|
|
1644
|
+
if (!sessionId) {
|
|
1645
|
+
fail("Usage: reconvo read <session-id> [--from N] [--to M] [--around N] [--radius R] [--role user|assistant] [--json]");
|
|
1646
|
+
}
|
|
1647
|
+
const messages = await readMessages(sessionId, {
|
|
1648
|
+
from: flagVal(args, "--from"),
|
|
1649
|
+
to: flagVal(args, "--to"),
|
|
1650
|
+
around: flagVal(args, "--around"),
|
|
1651
|
+
radius: flagVal(args, "--radius"),
|
|
1652
|
+
role: flagStr(args, "--role")
|
|
1653
|
+
});
|
|
1654
|
+
if (jsonOut) {
|
|
1655
|
+
console.log(JSON.stringify(messages, null, 2));
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
if (messages.length === 0) {
|
|
1659
|
+
fail(`No messages found for session: ${sessionId}`);
|
|
1660
|
+
}
|
|
1661
|
+
for (const m of messages) {
|
|
1662
|
+
const roleColor = m.role === "user" ? CYAN : DIM;
|
|
1663
|
+
const age = agoLong(m.timestamp);
|
|
1664
|
+
console.log(`${DIM}[${m.position}]${RESET} ${roleColor}${m.role}${RESET} ${DIM}${age}${RESET}`);
|
|
1665
|
+
console.log(m.content);
|
|
1666
|
+
console.log();
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
async function cmdSkim(args) {
|
|
1670
|
+
const jsonOut = args.includes("--json");
|
|
1671
|
+
const positional = stripFlags(args);
|
|
1672
|
+
const sessionId = positional[0];
|
|
1673
|
+
if (!sessionId) {
|
|
1674
|
+
fail("Usage: reconvo skim <session-id> [--head N] [--tail N] [--role user|assistant] [--json]");
|
|
1675
|
+
}
|
|
1676
|
+
const result = await skimSession(sessionId, flagVal(args, "--head"), flagVal(args, "--tail"), flagStr(args, "--role"));
|
|
1677
|
+
if (jsonOut) {
|
|
1678
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
for (const m of result.head) {
|
|
1682
|
+
const roleColor = m.role === "user" ? CYAN : DIM;
|
|
1683
|
+
console.log(`${DIM}[${m.position}]${RESET} ${roleColor}${m.role}${RESET}`);
|
|
1684
|
+
console.log(m.content);
|
|
1685
|
+
console.log();
|
|
1686
|
+
}
|
|
1687
|
+
if (result.skipped > 0) {
|
|
1688
|
+
console.log(`${DIM}... ${result.skipped} messages skipped ...${RESET}`);
|
|
1689
|
+
console.log();
|
|
1690
|
+
}
|
|
1691
|
+
for (const m of result.tail) {
|
|
1692
|
+
const roleColor = m.role === "user" ? CYAN : DIM;
|
|
1693
|
+
console.log(`${DIM}[${m.position}]${RESET} ${roleColor}${m.role}${RESET}`);
|
|
1694
|
+
console.log(m.content);
|
|
1695
|
+
console.log();
|
|
1696
|
+
}
|
|
1697
|
+
console.log(`${DIM}${result.total} total messages${RESET}`);
|
|
1698
|
+
}
|
|
1699
|
+
async function cmdStats(args) {
|
|
1700
|
+
const allFlag = args.includes("--all");
|
|
1701
|
+
const scopePaths = await getScope(allFlag);
|
|
1702
|
+
const result = await getStats(scopePaths);
|
|
1703
|
+
console.log(`${BOLD}Model Usage${RESET}`);
|
|
1704
|
+
for (const m of result.models) {
|
|
1705
|
+
console.log(` ${col(m.model, 30)} ${col(String(m.turns) + " turns", 12)} ${DIM}out: ${(m.outputTokens / 1e6).toFixed(1)}M cache_r: ${(m.cacheRead / 1e9).toFixed(1)}B cache_w: ${(m.cacheWrite / 1e6).toFixed(1)}M${RESET}`);
|
|
1706
|
+
}
|
|
1707
|
+
console.log();
|
|
1708
|
+
console.log(`${BOLD}Daily Activity${RESET}`);
|
|
1709
|
+
for (const d of result.daily.slice(-14)) {
|
|
1710
|
+
console.log(` ${col(d.day, 12)} ${col(d.sessions + " sessions", 14)} ${DIM}${d.userMsgs} user ${d.assistantTurns} asst${RESET}`);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
async function cmdFiles(args) {
|
|
1714
|
+
const allFlag = args.includes("--all");
|
|
1715
|
+
const source = parseSource(args);
|
|
1716
|
+
const positional = stripFlags(args);
|
|
1717
|
+
const filePath = positional[0];
|
|
1718
|
+
if (!filePath) {
|
|
1719
|
+
fail("Usage: reconvo files <path> [--all] [--source claude|opencode]");
|
|
1720
|
+
}
|
|
1721
|
+
const sinceMs = getSince(args);
|
|
1722
|
+
const scopePaths = await getScope(allFlag);
|
|
1723
|
+
const results = await searchByFile(filePath, { source, scopePaths, sinceMs });
|
|
1724
|
+
if (results.length === 0) {
|
|
1725
|
+
console.log(`No sessions found touching "${filePath}"`);
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
for (const s of results) {
|
|
1729
|
+
const age = agoLong(s.lastAt);
|
|
1730
|
+
const dirName = s.directory.split("/").pop() ?? s.directory;
|
|
1731
|
+
console.log(`${col(dirName, 18)} ${col(s.title, 40)} ${DIM}${age} ${s.id.slice(0, 8)}${RESET}`);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
function cmdHelp() {
|
|
1735
|
+
console.log(`${BOLD}reconvo${RESET} \u2014 recall conversation across Claude Code and OpenCode
|
|
1736
|
+
|
|
1737
|
+
${BOLD}Commands:${RESET}
|
|
1738
|
+
index Build/update search index (incremental)
|
|
1739
|
+
sessions List sessions (most recent first)
|
|
1740
|
+
search <query> Search conversations by keyword
|
|
1741
|
+
read <id> Read messages from a session
|
|
1742
|
+
skim <id> Head + tail preview of a session
|
|
1743
|
+
stats Usage dashboard
|
|
1744
|
+
files <path> Find sessions that touched a file
|
|
1745
|
+
browse Interactive TUI navigator
|
|
1746
|
+
help Show this help
|
|
1747
|
+
|
|
1748
|
+
${BOLD}Index flags:${RESET}
|
|
1749
|
+
--force Force full re-index
|
|
1750
|
+
--verbose, -v Show progress
|
|
1751
|
+
|
|
1752
|
+
${BOLD}Read/skim flags:${RESET}
|
|
1753
|
+
--from N Start at message position N
|
|
1754
|
+
--to M End at message position M
|
|
1755
|
+
--around N Center on position N
|
|
1756
|
+
--radius R Context radius (default: 3)
|
|
1757
|
+
--role <role> Filter: user, assistant
|
|
1758
|
+
|
|
1759
|
+
${BOLD}General flags:${RESET}
|
|
1760
|
+
--all Search all projects (ignore git context)
|
|
1761
|
+
--source <src> Filter: claude, opencode
|
|
1762
|
+
--since <expr> Time filter: 2h, 3d, 1w, today, yesterday, 2026-03-10
|
|
1763
|
+
--json JSON output
|
|
1764
|
+
|
|
1765
|
+
${BOLD}Context:${RESET}
|
|
1766
|
+
Inside a git repo, results are scoped to that project.
|
|
1767
|
+
Use --all to search everything.
|
|
1768
|
+
Index auto-updates on first query if source files changed.
|
|
1769
|
+
`);
|
|
1770
|
+
}
|
|
1771
|
+
var exitCode = 0;
|
|
1772
|
+
var args = process.argv.slice(2);
|
|
1773
|
+
var cmd = args[0];
|
|
1774
|
+
var cmdArgs = args.slice(1);
|
|
1775
|
+
try {
|
|
1776
|
+
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
1777
|
+
cmdHelp();
|
|
1778
|
+
} else if (cmd === "index") {
|
|
1779
|
+
await cmdIndex(cmdArgs);
|
|
1780
|
+
} else {
|
|
1781
|
+
await ensureIndexed(cmdArgs.includes("--verbose") || cmdArgs.includes("-v"));
|
|
1782
|
+
if (!cmd || cmd === "browse") {
|
|
1783
|
+
const allFlag = cmdArgs.includes("--all");
|
|
1784
|
+
const scopePaths = await getScope(allFlag);
|
|
1785
|
+
const { browse: browse2 } = await Promise.resolve().then(() => (init_tui(), exports_tui));
|
|
1786
|
+
await browse2(scopePaths);
|
|
1787
|
+
} else if (cmd === "sessions") {
|
|
1788
|
+
await cmdSessions(cmdArgs);
|
|
1789
|
+
} else if (cmd === "search") {
|
|
1790
|
+
await cmdSearch(cmdArgs);
|
|
1791
|
+
} else if (cmd === "read") {
|
|
1792
|
+
await cmdRead(cmdArgs);
|
|
1793
|
+
} else if (cmd === "skim") {
|
|
1794
|
+
await cmdSkim(cmdArgs);
|
|
1795
|
+
} else if (cmd === "stats") {
|
|
1796
|
+
await cmdStats(cmdArgs);
|
|
1797
|
+
} else if (cmd === "files") {
|
|
1798
|
+
await cmdFiles(cmdArgs);
|
|
1799
|
+
} else {
|
|
1800
|
+
fail(`Unknown command: ${cmd}
|
|
1801
|
+
Run 'reconvo help' for usage.`);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
} catch (e) {
|
|
1805
|
+
exitCode = 1;
|
|
1806
|
+
if (e instanceof UsageError) {
|
|
1807
|
+
console.error(e.message);
|
|
1808
|
+
} else {
|
|
1809
|
+
console.error(`reconvo: ${e?.message ?? String(e)}`);
|
|
1810
|
+
if (process.env.DEBUG)
|
|
1811
|
+
console.error(e?.stack);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
if (process.stdout.writableNeedDrain) {
|
|
1815
|
+
process.stdout.once("drain", () => process.exit(exitCode));
|
|
1816
|
+
} else {
|
|
1817
|
+
process.exit(exitCode);
|
|
1818
|
+
}
|