claude-simple 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/README.md +225 -0
- package/client.js +139 -0
- package/dist/addon-fit.min.js +2 -0
- package/dist/app.0.1.0.min.js +825 -0
- package/dist/highlight.min.css +10 -0
- package/dist/highlight.min.js +1244 -0
- package/dist/index.html +16 -0
- package/dist/marked.min.js +6 -0
- package/dist/style.0.1.0.min.css +1050 -0
- package/dist/xterm.min.css +209 -0
- package/dist/xterm.min.js +2 -0
- package/gateway.js +349 -0
- package/package.json +41 -0
- package/public/addon-fit.min.js +2 -0
- package/public/agent-panel.js +1079 -0
- package/public/api.js +78 -0
- package/public/app.js +95 -0
- package/public/base.css +4 -0
- package/public/cache.js +27 -0
- package/public/chat.js +1087 -0
- package/public/files.js +476 -0
- package/public/git.js +130 -0
- package/public/index.html +61 -0
- package/public/lib.js +2 -0
- package/public/router.js +71 -0
- package/public/settings.js +90 -0
- package/public/shell/boot.js +159 -0
- package/public/shell/dashboard.js +269 -0
- package/public/shell/hub.js +192 -0
- package/public/shell/layout.js +195 -0
- package/public/shell/notes.js +323 -0
- package/public/shell/session-context.js +19 -0
- package/public/shell/session-list.js +442 -0
- package/public/shell/session-nav.js +299 -0
- package/public/shell/templates.js +342 -0
- package/public/shell/util.js +63 -0
- package/public/shell.js +589 -0
- package/public/state.js +362 -0
- package/public/style.css +1048 -0
- package/public/terminal.js +177 -0
- package/public/xterm.min.css +209 -0
- package/public/xterm.min.js +2 -0
- package/server.js +12 -0
- package/src/agents/claude.js +58 -0
- package/src/agents/codex.js +284 -0
- package/src/agents/grok.js +318 -0
- package/src/agents/meta-agent.js +1952 -0
- package/src/agents/models.js +339 -0
- package/src/agents/sessions.js +1508 -0
- package/src/app.js +1282 -0
|
@@ -0,0 +1,1508 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import fsSync from 'fs';
|
|
3
|
+
import readline from 'readline';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
|
|
8
|
+
// ─── Claude ───────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
async function loadClaudeHistoryNames(configDirs) {
|
|
11
|
+
const map = new Map();
|
|
12
|
+
await Promise.all(configDirs.map(async dir => {
|
|
13
|
+
try {
|
|
14
|
+
const stream = fsSync.createReadStream(path.join(dir, 'history.jsonl'));
|
|
15
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
16
|
+
for await (const line of rl) {
|
|
17
|
+
try {
|
|
18
|
+
const d = JSON.parse(line);
|
|
19
|
+
if (d.sessionId && d.display) map.set(d.sessionId, d.display);
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
} catch {}
|
|
23
|
+
}));
|
|
24
|
+
return map;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readClaudeSessionMeta(filePath) {
|
|
28
|
+
let sessionId = null;
|
|
29
|
+
let cwd = null;
|
|
30
|
+
let totalTokens = 0;
|
|
31
|
+
let turnCount = 0;
|
|
32
|
+
try {
|
|
33
|
+
const stream = fsSync.createReadStream(filePath);
|
|
34
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
35
|
+
for await (const line of rl) {
|
|
36
|
+
try {
|
|
37
|
+
const d = JSON.parse(line);
|
|
38
|
+
if (!sessionId && d.sessionId) sessionId = d.sessionId;
|
|
39
|
+
if (!cwd && d.cwd) cwd = d.cwd;
|
|
40
|
+
// Count user turns (exclude tool-result only / meta noise when possible)
|
|
41
|
+
if (d.type === 'user') {
|
|
42
|
+
const content = d.message?.content;
|
|
43
|
+
const isToolResult = Array.isArray(content)
|
|
44
|
+
&& content.length > 0
|
|
45
|
+
&& content.every(p => p?.type === 'tool_result');
|
|
46
|
+
if (!isToolResult) turnCount += 1;
|
|
47
|
+
}
|
|
48
|
+
const u = d.message?.usage;
|
|
49
|
+
if (u) {
|
|
50
|
+
totalTokens += (u.input_tokens ?? 0) + (u.output_tokens ?? 0);
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
return { sessionId, cwd, totalTokens, turnCount };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readClaudeFirstMessage(filePath) {
|
|
59
|
+
const SKIP = ['<', 'Caveat:', 'This session', '[Request'];
|
|
60
|
+
try {
|
|
61
|
+
const stream = fsSync.createReadStream(filePath);
|
|
62
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
63
|
+
for await (const line of rl) {
|
|
64
|
+
try {
|
|
65
|
+
const d = JSON.parse(line);
|
|
66
|
+
if (d.type === 'user' && Array.isArray(d.message?.content)) {
|
|
67
|
+
for (const part of d.message.content) {
|
|
68
|
+
if (part.type === 'text' && part.text?.trim()) {
|
|
69
|
+
const text = part.text.trim();
|
|
70
|
+
if (!SKIP.some(p => text.startsWith(p))) {
|
|
71
|
+
rl.close(); stream.destroy();
|
|
72
|
+
return text.slice(0, 100);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function scanClaudeConfigDir(configDir, nameMap, filterCwd) {
|
|
84
|
+
const projectsDir = path.join(configDir, 'projects');
|
|
85
|
+
const sessions = [];
|
|
86
|
+
let projectDirs;
|
|
87
|
+
try {
|
|
88
|
+
projectDirs = (await fs.readdir(projectsDir, { withFileTypes: true })).filter(e => e.isDirectory());
|
|
89
|
+
} catch { return []; }
|
|
90
|
+
|
|
91
|
+
await Promise.all(projectDirs.map(async dirEntry => {
|
|
92
|
+
const projectDir = path.join(projectsDir, dirEntry.name);
|
|
93
|
+
let entries;
|
|
94
|
+
try { entries = await fs.readdir(projectDir, { withFileTypes: true }); }
|
|
95
|
+
catch { return; }
|
|
96
|
+
|
|
97
|
+
const hasMemory = entries.some(e =>
|
|
98
|
+
(e.isFile() && e.name === 'MEMORY.md') || (e.isDirectory() && e.name === 'memory')
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const jsonlFiles = entries.filter(e =>
|
|
102
|
+
e.isFile() && e.name.endsWith('.jsonl') && !e.name.startsWith('agent-')
|
|
103
|
+
);
|
|
104
|
+
await Promise.all(jsonlFiles.map(async fileEntry => {
|
|
105
|
+
const filePath = path.join(projectDir, fileEntry.name);
|
|
106
|
+
try {
|
|
107
|
+
const [stat, meta] = await Promise.all([fs.stat(filePath), readClaudeSessionMeta(filePath)]);
|
|
108
|
+
if (filterCwd && meta.cwd !== filterCwd) return;
|
|
109
|
+
const sessionId = meta.sessionId || fileEntry.name.replace('.jsonl', '');
|
|
110
|
+
const firstMsg = await readClaudeFirstMessage(filePath);
|
|
111
|
+
sessions.push({
|
|
112
|
+
sessionId,
|
|
113
|
+
agent: 'claude',
|
|
114
|
+
cwd: meta.cwd || null,
|
|
115
|
+
configDir,
|
|
116
|
+
display: nameMap.get(sessionId) || firstMsg || 'Session',
|
|
117
|
+
updatedAt: stat.mtimeMs,
|
|
118
|
+
hasMemory,
|
|
119
|
+
totalTokens: meta.totalTokens || 0,
|
|
120
|
+
turnCount: meta.turnCount || 0,
|
|
121
|
+
});
|
|
122
|
+
} catch {}
|
|
123
|
+
}));
|
|
124
|
+
}));
|
|
125
|
+
return sessions;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function scanClaudeSessions(configDirs, filterCwd = null) {
|
|
129
|
+
const nameMap = await loadClaudeHistoryNames(configDirs);
|
|
130
|
+
const results = await Promise.all(configDirs.map(d => scanClaudeConfigDir(d, nameMap, filterCwd)));
|
|
131
|
+
return results.flat();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// sessionId → absolute jsonl path (avoids re-scanning projects on every history load)
|
|
135
|
+
const claudeFileCache = new Map();
|
|
136
|
+
|
|
137
|
+
export async function findClaudeSessionFile(sessionId, configDirs) {
|
|
138
|
+
if (!configDirs?.length) return null;
|
|
139
|
+
const cached = claudeFileCache.get(sessionId);
|
|
140
|
+
if (cached) {
|
|
141
|
+
try { await fs.access(cached); return cached; }
|
|
142
|
+
catch { claudeFileCache.delete(sessionId); }
|
|
143
|
+
}
|
|
144
|
+
for (const configDir of configDirs) {
|
|
145
|
+
const projectsDir = path.join(configDir, 'projects');
|
|
146
|
+
let projectDirs;
|
|
147
|
+
try { projectDirs = await fs.readdir(projectsDir); }
|
|
148
|
+
catch { continue; }
|
|
149
|
+
for (const dir of projectDirs) {
|
|
150
|
+
const candidate = path.join(projectsDir, dir, `${sessionId}.jsonl`);
|
|
151
|
+
try {
|
|
152
|
+
await fs.access(candidate);
|
|
153
|
+
claudeFileCache.set(sessionId, candidate);
|
|
154
|
+
return candidate;
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const INTERNAL_PREFIXES = ['<command-', '<local-command', '<system-reminder', 'Caveat:', 'This session is being continued', '[Request interrupted'];
|
|
162
|
+
|
|
163
|
+
function isInternal(text) {
|
|
164
|
+
return INTERNAL_PREFIXES.some(p => text.startsWith(p));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function parseClaudeMessages(filePath) {
|
|
168
|
+
const messages = [];
|
|
169
|
+
let pendingUsage = null;
|
|
170
|
+
|
|
171
|
+
function flushUsage() {
|
|
172
|
+
if (!pendingUsage) return;
|
|
173
|
+
const u = pendingUsage;
|
|
174
|
+
pendingUsage = null;
|
|
175
|
+
if (u.input_tokens || u.output_tokens) messages.push({ type: 'token_usage', usage: u });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const stream = fsSync.createReadStream(filePath);
|
|
180
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
181
|
+
for await (const line of rl) {
|
|
182
|
+
let d;
|
|
183
|
+
try { d = JSON.parse(line); } catch { continue; }
|
|
184
|
+
|
|
185
|
+
if (d.type === 'user' && d.message?.content) {
|
|
186
|
+
flushUsage();
|
|
187
|
+
const content = d.message.content;
|
|
188
|
+
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
|
|
189
|
+
for (const part of parts) {
|
|
190
|
+
if (part.type === 'text' && part.text?.trim() && !isInternal(part.text.trim())) {
|
|
191
|
+
messages.push({ role: 'user', type: 'text', content: part.text.trim(), ts: d.timestamp });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (d.type === 'assistant' && Array.isArray(d.message?.content)) {
|
|
198
|
+
const u = d.message.usage;
|
|
199
|
+
if (u) {
|
|
200
|
+
if (!pendingUsage) pendingUsage = { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
|
|
201
|
+
pendingUsage.input_tokens += u.input_tokens ?? 0;
|
|
202
|
+
pendingUsage.output_tokens += u.output_tokens ?? 0;
|
|
203
|
+
pendingUsage.cache_creation_input_tokens += u.cache_creation_input_tokens ?? 0;
|
|
204
|
+
pendingUsage.cache_read_input_tokens += u.cache_read_input_tokens ?? 0;
|
|
205
|
+
}
|
|
206
|
+
for (const part of d.message.content) {
|
|
207
|
+
if (part.type === 'text' && part.text?.trim()) {
|
|
208
|
+
messages.push({ role: 'assistant', type: 'text', content: part.text.trim(), ts: d.timestamp });
|
|
209
|
+
} else if (part.type === 'tool_use') {
|
|
210
|
+
messages.push({ role: 'tool', type: 'tool_use', name: part.name, input: part.input ?? {}, ts: d.timestamp });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
flushUsage();
|
|
216
|
+
} catch {}
|
|
217
|
+
return messages;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Codex ────────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
async function loadCodexIndex(codexHome) {
|
|
223
|
+
const map = new Map();
|
|
224
|
+
try {
|
|
225
|
+
const stream = fsSync.createReadStream(path.join(codexHome, 'session_index.jsonl'));
|
|
226
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
227
|
+
for await (const line of rl) {
|
|
228
|
+
try {
|
|
229
|
+
const d = JSON.parse(line);
|
|
230
|
+
if (d.id) map.set(d.id, d);
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
} catch {}
|
|
234
|
+
return map;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function findCodexRollouts(codexHome) {
|
|
238
|
+
const sessionsRoot = path.join(codexHome, 'sessions');
|
|
239
|
+
const files = [];
|
|
240
|
+
async function walk(dir, depth = 0) {
|
|
241
|
+
if (depth > 5) return;
|
|
242
|
+
let entries;
|
|
243
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); }
|
|
244
|
+
catch { return; }
|
|
245
|
+
for (const e of entries) {
|
|
246
|
+
const p = path.join(dir, e.name);
|
|
247
|
+
if (e.isDirectory()) await walk(p, depth + 1);
|
|
248
|
+
else if (e.isFile() && e.name.endsWith('.jsonl') && e.name.startsWith('rollout-')) {
|
|
249
|
+
files.push(p);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
await walk(sessionsRoot);
|
|
254
|
+
return files;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function readCodexMeta(filePath) {
|
|
258
|
+
let id = null, cwd = null, ts = null, turnCount = 0;
|
|
259
|
+
try {
|
|
260
|
+
// Full scan: meta is early; turnCount needs whole file
|
|
261
|
+
const stream = fsSync.createReadStream(filePath);
|
|
262
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
263
|
+
for await (const line of rl) {
|
|
264
|
+
try {
|
|
265
|
+
const d = JSON.parse(line);
|
|
266
|
+
if (d.type === 'session_meta' && d.payload) {
|
|
267
|
+
id = d.payload.id || id;
|
|
268
|
+
cwd = d.payload.cwd || cwd;
|
|
269
|
+
ts = d.payload.timestamp || d.timestamp || ts;
|
|
270
|
+
}
|
|
271
|
+
// Codex user turns appear in a few event shapes across versions
|
|
272
|
+
if (d.type === 'event_msg' && d.payload?.type === 'user_message') turnCount += 1;
|
|
273
|
+
else if (d.type === 'response_item' && d.payload?.role === 'user') turnCount += 1;
|
|
274
|
+
else if (d.type === 'turn_context' && d.payload?.turn_index != null) {
|
|
275
|
+
// last turn_index + 1 is a better total; bump max
|
|
276
|
+
const idx = Number(d.payload.turn_index) + 1;
|
|
277
|
+
if (idx > turnCount) turnCount = idx;
|
|
278
|
+
}
|
|
279
|
+
} catch {}
|
|
280
|
+
}
|
|
281
|
+
} catch {}
|
|
282
|
+
// Fallback: parse id from filename rollout-...-<uuid>.jsonl
|
|
283
|
+
if (!id) {
|
|
284
|
+
const m = path.basename(filePath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
|
285
|
+
if (m) id = m[1];
|
|
286
|
+
}
|
|
287
|
+
return { id, cwd, ts, turnCount };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function scanCodexSessions(codexHome, filterCwd = null) {
|
|
291
|
+
const home = codexHome || path.join(os.homedir(), '.codex');
|
|
292
|
+
const index = await loadCodexIndex(home);
|
|
293
|
+
const rollouts = await findCodexRollouts(home);
|
|
294
|
+
const sessions = [];
|
|
295
|
+
|
|
296
|
+
await Promise.all(rollouts.map(async filePath => {
|
|
297
|
+
try {
|
|
298
|
+
const [stat, meta] = await Promise.all([fs.stat(filePath), readCodexMeta(filePath)]);
|
|
299
|
+
if (!meta.id) return;
|
|
300
|
+
if (filterCwd && meta.cwd && meta.cwd !== filterCwd) return;
|
|
301
|
+
const idx = index.get(meta.id);
|
|
302
|
+
sessions.push({
|
|
303
|
+
sessionId: meta.id,
|
|
304
|
+
agent: 'codex',
|
|
305
|
+
cwd: meta.cwd || null,
|
|
306
|
+
configDir: home,
|
|
307
|
+
display: idx?.thread_name || meta.id.slice(0, 8),
|
|
308
|
+
updatedAt: stat.mtimeMs,
|
|
309
|
+
hasMemory: false,
|
|
310
|
+
filePath,
|
|
311
|
+
turnCount: meta.turnCount || 0,
|
|
312
|
+
});
|
|
313
|
+
} catch {}
|
|
314
|
+
}));
|
|
315
|
+
return sessions;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function findCodexSessionFile(sessionId, codexHome) {
|
|
319
|
+
const home = codexHome || path.join(os.homedir(), '.codex');
|
|
320
|
+
const rollouts = await findCodexRollouts(home);
|
|
321
|
+
for (const f of rollouts) {
|
|
322
|
+
if (f.includes(sessionId)) return f;
|
|
323
|
+
}
|
|
324
|
+
// slower path: read meta
|
|
325
|
+
for (const f of rollouts) {
|
|
326
|
+
const meta = await readCodexMeta(f);
|
|
327
|
+
if (meta.id === sessionId) return f;
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function parseCodexMessages(filePath) {
|
|
333
|
+
const messages = [];
|
|
334
|
+
// Codex writes the same assistant turn twice:
|
|
335
|
+
// response_item (message/assistant) AND event_msg (agent_message)
|
|
336
|
+
// Prefer response_item; only use event_msg when that text wasn't already seen.
|
|
337
|
+
const seenAssistant = new Set();
|
|
338
|
+
|
|
339
|
+
const assistantKey = (text, ts) => {
|
|
340
|
+
const t = String(text || '').trim();
|
|
341
|
+
if (!t) return '';
|
|
342
|
+
// Same second + same body is the dual-write case (identical timestamps in UI)
|
|
343
|
+
const tsPart = ts != null ? String(ts) : '';
|
|
344
|
+
return `${tsPart}\n${t}`;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const pushAssistant = (text, ts) => {
|
|
348
|
+
const t = String(text || '').trim();
|
|
349
|
+
if (!t) return;
|
|
350
|
+
const key = assistantKey(t, ts);
|
|
351
|
+
if (seenAssistant.has(key)) return;
|
|
352
|
+
// Also skip pure text duplicates even if ts differs slightly
|
|
353
|
+
if (seenAssistant.has(t)) return;
|
|
354
|
+
seenAssistant.add(key);
|
|
355
|
+
seenAssistant.add(t);
|
|
356
|
+
messages.push({ role: 'assistant', type: 'text', content: t, ts });
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const stream = fsSync.createReadStream(filePath);
|
|
361
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
362
|
+
for await (const line of rl) {
|
|
363
|
+
let d;
|
|
364
|
+
try { d = JSON.parse(line); } catch { continue; }
|
|
365
|
+
|
|
366
|
+
if (d.type === 'response_item' && d.payload) {
|
|
367
|
+
const p = d.payload;
|
|
368
|
+
if (p.type === 'message' && p.role === 'user') {
|
|
369
|
+
const texts = (p.content || [])
|
|
370
|
+
.filter(c => c.type === 'input_text' || c.type === 'text')
|
|
371
|
+
.map(c => c.text || '')
|
|
372
|
+
.filter(Boolean);
|
|
373
|
+
const text = texts.join('\n').trim();
|
|
374
|
+
// skip huge injected system/env blobs
|
|
375
|
+
if (text && !text.startsWith('# AGENTS.md') && !text.startsWith('<environment_context')
|
|
376
|
+
&& !text.startsWith('<permissions') && text.length < 2000) {
|
|
377
|
+
messages.push({ role: 'user', type: 'text', content: text, ts: d.timestamp });
|
|
378
|
+
}
|
|
379
|
+
} else if (p.type === 'message' && p.role === 'assistant') {
|
|
380
|
+
const texts = (p.content || [])
|
|
381
|
+
.filter(c => c.type === 'output_text' || c.type === 'text')
|
|
382
|
+
.map(c => c.text || '')
|
|
383
|
+
.filter(Boolean);
|
|
384
|
+
pushAssistant(texts.join('\n'), d.timestamp);
|
|
385
|
+
} else if (p.type === 'function_call') {
|
|
386
|
+
let input = p.arguments;
|
|
387
|
+
try { input = typeof input === 'string' ? JSON.parse(input) : input; } catch {}
|
|
388
|
+
messages.push({ role: 'tool', type: 'tool_use', name: p.name || 'function', input: input ?? {}, ts: d.timestamp });
|
|
389
|
+
} else if (p.type === 'command_execution' || p.type === 'custom_tool_call') {
|
|
390
|
+
messages.push({
|
|
391
|
+
role: 'tool',
|
|
392
|
+
type: 'tool_use',
|
|
393
|
+
name: p.type === 'command_execution' ? 'Bash' : (p.name || 'tool'),
|
|
394
|
+
input: { command: p.command || p.input || '' },
|
|
395
|
+
ts: d.timestamp,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Secondary channel — skip if response_item already recorded the same body
|
|
401
|
+
if (d.type === 'event_msg' && d.payload?.type === 'agent_message') {
|
|
402
|
+
const text = d.payload.message || d.payload.text;
|
|
403
|
+
pushAssistant(text, d.timestamp);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} catch {}
|
|
407
|
+
return messages;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ─── Grok ─────────────────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
export async function scanGrokSessions(grokHome, filterCwd = null) {
|
|
413
|
+
const home = grokHome || path.join(os.homedir(), '.grok');
|
|
414
|
+
const sessionsRoot = path.join(home, 'sessions');
|
|
415
|
+
const sessions = [];
|
|
416
|
+
|
|
417
|
+
let projectDirs;
|
|
418
|
+
try {
|
|
419
|
+
projectDirs = (await fs.readdir(sessionsRoot, { withFileTypes: true })).filter(e => e.isDirectory());
|
|
420
|
+
} catch { return []; }
|
|
421
|
+
|
|
422
|
+
await Promise.all(projectDirs.map(async dirEntry => {
|
|
423
|
+
// dir name is encodeURIComponent(cwd)
|
|
424
|
+
let cwd;
|
|
425
|
+
try { cwd = decodeURIComponent(dirEntry.name); } catch { cwd = dirEntry.name; }
|
|
426
|
+
if (filterCwd && cwd !== filterCwd) return;
|
|
427
|
+
|
|
428
|
+
const projectDir = path.join(sessionsRoot, dirEntry.name);
|
|
429
|
+
let sessionDirs;
|
|
430
|
+
try {
|
|
431
|
+
sessionDirs = (await fs.readdir(projectDir, { withFileTypes: true })).filter(e => e.isDirectory());
|
|
432
|
+
} catch { return; }
|
|
433
|
+
|
|
434
|
+
await Promise.all(sessionDirs.map(async sEntry => {
|
|
435
|
+
const sessionId = sEntry.name;
|
|
436
|
+
if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return;
|
|
437
|
+
const sessionDir = path.join(projectDir, sessionId);
|
|
438
|
+
let display = sessionId.slice(0, 8);
|
|
439
|
+
let updatedAt = 0;
|
|
440
|
+
let hasMemory = false;
|
|
441
|
+
try {
|
|
442
|
+
const summary = JSON.parse(await fs.readFile(path.join(sessionDir, 'summary.json'), 'utf8'));
|
|
443
|
+
display = summary.session_summary || summary.info?.title || display;
|
|
444
|
+
if (summary.updated_at) updatedAt = Date.parse(summary.updated_at) || 0;
|
|
445
|
+
if (summary.info?.cwd) cwd = summary.info.cwd;
|
|
446
|
+
} catch {}
|
|
447
|
+
try {
|
|
448
|
+
const st = await fs.stat(path.join(sessionDir, 'chat_history.jsonl'));
|
|
449
|
+
if (!updatedAt) updatedAt = st.mtimeMs;
|
|
450
|
+
else updatedAt = Math.max(updatedAt, st.mtimeMs);
|
|
451
|
+
} catch {
|
|
452
|
+
try {
|
|
453
|
+
const st = await fs.stat(sessionDir);
|
|
454
|
+
if (!updatedAt) updatedAt = st.mtimeMs;
|
|
455
|
+
} catch { return; }
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
await fs.access(path.join(sessionDir, 'memory'));
|
|
459
|
+
hasMemory = true;
|
|
460
|
+
} catch {}
|
|
461
|
+
|
|
462
|
+
let totalTokens = 0;
|
|
463
|
+
try {
|
|
464
|
+
const sig = JSON.parse(await fs.readFile(path.join(sessionDir, 'signals.json'), 'utf8'));
|
|
465
|
+
totalTokens = sig.contextTokensUsed ?? 0;
|
|
466
|
+
} catch {}
|
|
467
|
+
|
|
468
|
+
let turnCount = 0;
|
|
469
|
+
try {
|
|
470
|
+
const stream = fsSync.createReadStream(path.join(sessionDir, 'chat_history.jsonl'));
|
|
471
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
472
|
+
for await (const line of rl) {
|
|
473
|
+
try {
|
|
474
|
+
const d = JSON.parse(line);
|
|
475
|
+
if (d.role === 'user' || d.type === 'user') turnCount += 1;
|
|
476
|
+
} catch {}
|
|
477
|
+
}
|
|
478
|
+
} catch {}
|
|
479
|
+
|
|
480
|
+
sessions.push({
|
|
481
|
+
sessionId,
|
|
482
|
+
agent: 'grok',
|
|
483
|
+
cwd: cwd || null,
|
|
484
|
+
configDir: home,
|
|
485
|
+
display: String(display).slice(0, 120),
|
|
486
|
+
updatedAt,
|
|
487
|
+
hasMemory,
|
|
488
|
+
sessionDir,
|
|
489
|
+
totalTokens,
|
|
490
|
+
turnCount,
|
|
491
|
+
});
|
|
492
|
+
}));
|
|
493
|
+
}));
|
|
494
|
+
|
|
495
|
+
return sessions;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function findGrokSessionDir(sessionId, grokHome) {
|
|
499
|
+
const home = grokHome || path.join(os.homedir(), '.grok');
|
|
500
|
+
const sessionsRoot = path.join(home, 'sessions');
|
|
501
|
+
let projectDirs;
|
|
502
|
+
try {
|
|
503
|
+
projectDirs = await fs.readdir(sessionsRoot);
|
|
504
|
+
} catch { return null; }
|
|
505
|
+
for (const dir of projectDirs) {
|
|
506
|
+
const candidate = path.join(sessionsRoot, dir, sessionId);
|
|
507
|
+
try {
|
|
508
|
+
const st = await fs.stat(candidate);
|
|
509
|
+
if (st.isDirectory()) return candidate;
|
|
510
|
+
} catch {}
|
|
511
|
+
}
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Grok stores content as either a plain string or an array of blocks:
|
|
517
|
+
* [{ "type": "text", "text": "<user_query>\n...\n</user_query>" }]
|
|
518
|
+
* Real user prompts are wrapped in <user_query>; env/context injections use
|
|
519
|
+
* <user_info>, <git_status>, <system-reminder>, etc. and should be hidden.
|
|
520
|
+
*/
|
|
521
|
+
function extractGrokText(content) {
|
|
522
|
+
if (content == null) return '';
|
|
523
|
+
if (typeof content === 'string') return content;
|
|
524
|
+
if (Array.isArray(content)) {
|
|
525
|
+
return content
|
|
526
|
+
.map(part => {
|
|
527
|
+
if (typeof part === 'string') return part;
|
|
528
|
+
if (part && typeof part === 'object') {
|
|
529
|
+
if (typeof part.text === 'string') return part.text;
|
|
530
|
+
if (typeof part.content === 'string') return part.content;
|
|
531
|
+
}
|
|
532
|
+
return '';
|
|
533
|
+
})
|
|
534
|
+
.filter(Boolean)
|
|
535
|
+
.join('\n');
|
|
536
|
+
}
|
|
537
|
+
if (typeof content === 'object' && typeof content.text === 'string') return content.text;
|
|
538
|
+
return '';
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function unwrapGrokUserQuery(text) {
|
|
542
|
+
if (!text) return text;
|
|
543
|
+
// Prefer the <user_query> body when present
|
|
544
|
+
const m = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i);
|
|
545
|
+
if (m) return m[1].trim();
|
|
546
|
+
return text.trim();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function isGrokInternalUserText(text) {
|
|
550
|
+
if (!text) return true;
|
|
551
|
+
const t = text.trim();
|
|
552
|
+
// Whole-message system injections (often the entire content)
|
|
553
|
+
if (/^<(user_info|git_status|system-reminder|system_reminder|agent_skills|available_skills|mcp_server|environment_context|permissions)\b/i.test(t)) {
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
// Messages that are ONLY those tags (no real user_query)
|
|
557
|
+
if (/^<[^>]+>[\s\S]*<\/[^>]+>$/.test(t) && !/<user_query>/i.test(t)) {
|
|
558
|
+
// Allow short non-system tags; block known wrappers
|
|
559
|
+
if (/<\/?(user_info|git_status|system-reminder|system_reminder|agent_skills|available_skills)\b/i.test(t)) {
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function parseGrokToolInput(raw) {
|
|
567
|
+
if (raw == null) return {};
|
|
568
|
+
if (typeof raw === 'object') return raw;
|
|
569
|
+
if (typeof raw === 'string') {
|
|
570
|
+
try { return JSON.parse(raw); } catch { return { input: raw }; }
|
|
571
|
+
}
|
|
572
|
+
return {};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function toMs(raw) {
|
|
576
|
+
if (raw == null || raw === '') return null;
|
|
577
|
+
if (typeof raw === 'number') return raw < 1e12 ? raw * 1000 : raw;
|
|
578
|
+
const ms = Date.parse(raw);
|
|
579
|
+
return Number.isNaN(ms) ? null : ms;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Grok `updates.jsonl` lines look like:
|
|
584
|
+
* { "timestamp": 1784518536, "method": "session/update",
|
|
585
|
+
* "params": { "update": { "sessionUpdate": "user_message_chunk", ... } } }
|
|
586
|
+
* `timestamp` is Unix **seconds** (not ms).
|
|
587
|
+
*/
|
|
588
|
+
async function loadGrokUpdates(sessionDir) {
|
|
589
|
+
const updatesPath = path.join(sessionDir, 'updates.jsonl');
|
|
590
|
+
const users = []; // { ts, text }
|
|
591
|
+
const assistants = []; // { ts }
|
|
592
|
+
const tools = []; // { ts, name }
|
|
593
|
+
const turnUsages = []; // { ts, usage }
|
|
594
|
+
let pendingAsst = null;
|
|
595
|
+
|
|
596
|
+
const flushAsst = () => {
|
|
597
|
+
if (pendingAsst) { assistants.push(pendingAsst); pendingAsst = null; }
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
const stream = fsSync.createReadStream(updatesPath);
|
|
602
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
603
|
+
for await (const line of rl) {
|
|
604
|
+
let d;
|
|
605
|
+
try { d = JSON.parse(line); } catch { continue; }
|
|
606
|
+
|
|
607
|
+
// Prefer explicit `timestamp` field (Unix seconds)
|
|
608
|
+
const ts = toMs(d.timestamp ?? d.ts);
|
|
609
|
+
if (ts == null) continue;
|
|
610
|
+
|
|
611
|
+
const update = d.params?.update || d.update || {};
|
|
612
|
+
const kind = update.sessionUpdate || update.type || '';
|
|
613
|
+
|
|
614
|
+
if (kind === 'user_message_chunk') {
|
|
615
|
+
flushAsst();
|
|
616
|
+
const text = update.content?.text
|
|
617
|
+
|| (typeof update.content === 'string' ? update.content : '')
|
|
618
|
+
|| extractGrokText(update.content)
|
|
619
|
+
|| '';
|
|
620
|
+
users.push({ ts, text: text.trim() });
|
|
621
|
+
} else if (kind === 'agent_message_chunk') {
|
|
622
|
+
if (!pendingAsst) pendingAsst = { ts };
|
|
623
|
+
} else if (kind === 'tool_call') {
|
|
624
|
+
flushAsst();
|
|
625
|
+
tools.push({
|
|
626
|
+
ts,
|
|
627
|
+
name: update.title || update.toolName || update.name || null,
|
|
628
|
+
});
|
|
629
|
+
} else if (kind === 'turn_completed') {
|
|
630
|
+
flushAsst();
|
|
631
|
+
if (update.usage) {
|
|
632
|
+
turnUsages.push({ ts, usage: normalizeGrokUsage(update.usage) });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
flushAsst();
|
|
637
|
+
} catch {}
|
|
638
|
+
|
|
639
|
+
return { users, assistants, tools, turnUsages };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Grok usage uses camelCase; normalize to snake_case used by the UI. */
|
|
643
|
+
function normalizeGrokUsage(u = {}) {
|
|
644
|
+
const costTicks = u.costUsdTicks ?? u.cost_usd_ticks;
|
|
645
|
+
const costUSD = u.costUSD ?? u.cost_usd
|
|
646
|
+
?? (typeof costTicks === 'number' ? costTicks / 1e10 : undefined);
|
|
647
|
+
return {
|
|
648
|
+
input_tokens: u.inputTokens ?? u.input_tokens ?? 0,
|
|
649
|
+
output_tokens: u.outputTokens ?? u.output_tokens ?? 0,
|
|
650
|
+
cache_read_input_tokens: u.cachedReadTokens ?? u.cache_read_input_tokens ?? 0,
|
|
651
|
+
cache_creation_input_tokens: u.cacheCreationInputTokens ?? u.cache_creation_input_tokens ?? 0,
|
|
652
|
+
total_tokens: u.totalTokens ?? u.total_tokens ?? 0,
|
|
653
|
+
reasoning_tokens: u.reasoningTokens ?? u.reasoning_tokens ?? 0,
|
|
654
|
+
costUSD,
|
|
655
|
+
num_turns: u.numTurns ?? u.num_turns,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function applyGrokTimestamps(messages, { users, assistants, tools }) {
|
|
660
|
+
let ui = 0, ai = 0, ti = 0;
|
|
661
|
+
|
|
662
|
+
for (const m of messages) {
|
|
663
|
+
if (m.role === 'user' && m.type === 'text') {
|
|
664
|
+
// Prefer content match so filtered synthetic users don't shift the index
|
|
665
|
+
let hit = -1;
|
|
666
|
+
const needle = (m.content || '').trim();
|
|
667
|
+
for (let j = ui; j < users.length; j++) {
|
|
668
|
+
const t = (users[j].text || '').trim();
|
|
669
|
+
if (!t) continue;
|
|
670
|
+
if (t === needle || needle.includes(t.slice(0, 48)) || t.includes(needle.slice(0, 48))) {
|
|
671
|
+
hit = j;
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (hit < 0 && ui < users.length) hit = ui;
|
|
676
|
+
if (hit >= 0) {
|
|
677
|
+
m.ts = users[hit].ts;
|
|
678
|
+
ui = hit + 1;
|
|
679
|
+
}
|
|
680
|
+
} else if (m.role === 'assistant' && m.type === 'text') {
|
|
681
|
+
if (ai < assistants.length) m.ts = assistants[ai++].ts;
|
|
682
|
+
} else if (m.type === 'tool_use') {
|
|
683
|
+
if (ti < tools.length) m.ts = tools[ti++].ts;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Insert per-turn token_usage rows after each user turn. */
|
|
689
|
+
function insertTurnUsages(messages, turnUsages) {
|
|
690
|
+
if (!turnUsages?.length) return messages;
|
|
691
|
+
const userIdx = [];
|
|
692
|
+
messages.forEach((m, i) => {
|
|
693
|
+
if (m.role === 'user' && m.type === 'text') userIdx.push(i);
|
|
694
|
+
});
|
|
695
|
+
// splice from the end so earlier indices stay valid
|
|
696
|
+
for (let i = turnUsages.length - 1; i >= 0; i--) {
|
|
697
|
+
const start = userIdx[i];
|
|
698
|
+
if (start == null) continue;
|
|
699
|
+
const end = userIdx[i + 1] != null ? userIdx[i + 1] : messages.length;
|
|
700
|
+
messages.splice(end, 0, {
|
|
701
|
+
type: 'token_usage',
|
|
702
|
+
usage: turnUsages[i].usage,
|
|
703
|
+
ts: turnUsages[i].ts,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
return messages;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function loadGrokContext(sessionDir) {
|
|
710
|
+
const context = {
|
|
711
|
+
tokensUsed: null,
|
|
712
|
+
windowTokens: null,
|
|
713
|
+
usagePercent: null,
|
|
714
|
+
model: null,
|
|
715
|
+
turnCount: null,
|
|
716
|
+
toolCallCount: null,
|
|
717
|
+
sessionDurationSeconds: null,
|
|
718
|
+
lastTurn: null, // last turn_completed usage from updates.jsonl
|
|
719
|
+
};
|
|
720
|
+
try {
|
|
721
|
+
const sig = JSON.parse(await fs.readFile(path.join(sessionDir, 'signals.json'), 'utf8'));
|
|
722
|
+
context.tokensUsed = sig.contextTokensUsed ?? null;
|
|
723
|
+
context.windowTokens = sig.contextWindowTokens ?? null;
|
|
724
|
+
context.usagePercent = sig.contextWindowUsage ?? null;
|
|
725
|
+
context.model = sig.primaryModelId ?? (sig.modelsUsed && sig.modelsUsed[0]) ?? null;
|
|
726
|
+
context.turnCount = sig.turnCount ?? null;
|
|
727
|
+
context.toolCallCount = sig.toolCallCount ?? null;
|
|
728
|
+
context.sessionDurationSeconds = sig.sessionDurationSeconds ?? null;
|
|
729
|
+
} catch {}
|
|
730
|
+
try {
|
|
731
|
+
const summary = JSON.parse(await fs.readFile(path.join(sessionDir, 'summary.json'), 'utf8'));
|
|
732
|
+
if (!context.model) context.model = summary.current_model_id || null;
|
|
733
|
+
context.createdAt = summary.created_at || null;
|
|
734
|
+
context.updatedAt = summary.updated_at || summary.last_active_at || null;
|
|
735
|
+
context.title = summary.session_summary || summary.generated_title || null;
|
|
736
|
+
} catch {}
|
|
737
|
+
// Last completed turn usage (input/output/cache/cost)
|
|
738
|
+
try {
|
|
739
|
+
const updates = await loadGrokUpdates(sessionDir);
|
|
740
|
+
if (updates.turnUsages?.length) {
|
|
741
|
+
const last = updates.turnUsages[updates.turnUsages.length - 1];
|
|
742
|
+
context.lastTurn = { ...last.usage, ts: last.ts };
|
|
743
|
+
// Aggregate totals across turns when available
|
|
744
|
+
const sum = { input_tokens: 0, output_tokens: 0, total_tokens: 0, cache_read_input_tokens: 0, costUSD: 0 };
|
|
745
|
+
for (const t of updates.turnUsages) {
|
|
746
|
+
sum.input_tokens += t.usage.input_tokens || 0;
|
|
747
|
+
sum.output_tokens += t.usage.output_tokens || 0;
|
|
748
|
+
sum.total_tokens += t.usage.total_tokens || 0;
|
|
749
|
+
sum.cache_read_input_tokens += t.usage.cache_read_input_tokens || 0;
|
|
750
|
+
sum.costUSD += Number(t.usage.costUSD) || 0;
|
|
751
|
+
}
|
|
752
|
+
context.sessionTotals = sum;
|
|
753
|
+
context.completedTurns = updates.turnUsages.length;
|
|
754
|
+
}
|
|
755
|
+
} catch {}
|
|
756
|
+
return context;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Public helper: load usage/context for any agent session. */
|
|
760
|
+
export async function loadSessionUsage(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
|
|
761
|
+
const a = (agent || 'claude').toLowerCase();
|
|
762
|
+
if (a === 'grok') {
|
|
763
|
+
const dir = await findGrokSessionDir(sessionId, grokHome);
|
|
764
|
+
if (!dir) return null;
|
|
765
|
+
const context = await loadGrokContext(dir);
|
|
766
|
+
return { agent: 'grok', sessionId, context };
|
|
767
|
+
}
|
|
768
|
+
if (a === 'codex') {
|
|
769
|
+
const file = await findCodexSessionFile(sessionId, codexHome);
|
|
770
|
+
if (!file) return null;
|
|
771
|
+
// Codex has no signals.json equivalent here — return file meta only
|
|
772
|
+
try {
|
|
773
|
+
const st = await fs.stat(file);
|
|
774
|
+
return {
|
|
775
|
+
agent: 'codex',
|
|
776
|
+
sessionId,
|
|
777
|
+
context: {
|
|
778
|
+
model: null,
|
|
779
|
+
tokensUsed: null,
|
|
780
|
+
windowTokens: null,
|
|
781
|
+
usagePercent: null,
|
|
782
|
+
updatedAt: new Date(st.mtimeMs).toISOString(),
|
|
783
|
+
note: 'Codex per-session context window not exposed; see last-turn bar after a reply.',
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
} catch {
|
|
787
|
+
return { agent: 'codex', sessionId, context: null };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
// Claude — best-effort from session jsonl last usage
|
|
791
|
+
const file = await findClaudeSessionFile(sessionId, claudeConfigDirs || []);
|
|
792
|
+
if (!file) return null;
|
|
793
|
+
let lastUsage = null;
|
|
794
|
+
try {
|
|
795
|
+
const stream = fsSync.createReadStream(file);
|
|
796
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
797
|
+
for await (const line of rl) {
|
|
798
|
+
let d;
|
|
799
|
+
try { d = JSON.parse(line); } catch { continue; }
|
|
800
|
+
const u = d.message?.usage || d.usage;
|
|
801
|
+
if (u && (u.input_tokens || u.output_tokens)) {
|
|
802
|
+
lastUsage = {
|
|
803
|
+
input_tokens: u.input_tokens ?? 0,
|
|
804
|
+
output_tokens: u.output_tokens ?? 0,
|
|
805
|
+
cache_read_input_tokens: u.cache_read_input_tokens ?? 0,
|
|
806
|
+
cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,
|
|
807
|
+
total_tokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
} catch {}
|
|
812
|
+
return {
|
|
813
|
+
agent: 'claude',
|
|
814
|
+
sessionId,
|
|
815
|
+
context: {
|
|
816
|
+
model: null,
|
|
817
|
+
lastTurn: lastUsage,
|
|
818
|
+
note: lastUsage ? null : 'No usage records in this Claude session yet.',
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export async function parseGrokMessages(sessionDir) {
|
|
824
|
+
const messages = [];
|
|
825
|
+
const histPath = path.join(sessionDir, 'chat_history.jsonl');
|
|
826
|
+
try {
|
|
827
|
+
const stream = fsSync.createReadStream(histPath);
|
|
828
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
829
|
+
for await (const line of rl) {
|
|
830
|
+
let d;
|
|
831
|
+
try { d = JSON.parse(line); } catch { continue; }
|
|
832
|
+
|
|
833
|
+
// Skip system prompts, reasoning blobs, tool results noise in chat view
|
|
834
|
+
if (d.type === 'system' || d.type === 'reasoning' || d.type === 'tool_result') continue;
|
|
835
|
+
if (d.synthetic_reason) continue;
|
|
836
|
+
|
|
837
|
+
// chat_history rows rarely have timestamps; will be filled from updates.jsonl
|
|
838
|
+
let ts = toMs(d.timestamp ?? d.ts ?? d.created_at);
|
|
839
|
+
|
|
840
|
+
if (d.type === 'user') {
|
|
841
|
+
const raw = extractGrokText(d.content);
|
|
842
|
+
if (!raw.trim()) continue;
|
|
843
|
+
if (isGrokInternalUserText(raw)) continue;
|
|
844
|
+
const text = unwrapGrokUserQuery(raw);
|
|
845
|
+
if (!text || isGrokInternalUserText(text)) continue;
|
|
846
|
+
messages.push({ role: 'user', type: 'text', content: text, ts });
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (d.type === 'assistant') {
|
|
851
|
+
const text = extractGrokText(d.content).trim();
|
|
852
|
+
if (text) {
|
|
853
|
+
messages.push({ role: 'assistant', type: 'text', content: text, ts });
|
|
854
|
+
}
|
|
855
|
+
if (Array.isArray(d.tool_calls)) {
|
|
856
|
+
for (const tc of d.tool_calls) {
|
|
857
|
+
const name = tc.name || tc.function?.name || 'tool';
|
|
858
|
+
const input = parseGrokToolInput(tc.arguments ?? tc.input ?? tc.function?.arguments);
|
|
859
|
+
messages.push({ role: 'tool', type: 'tool_use', name, input, ts });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (d.type === 'tool_use' || d.type === 'tool_call') {
|
|
866
|
+
messages.push({
|
|
867
|
+
role: 'tool',
|
|
868
|
+
type: 'tool_use',
|
|
869
|
+
name: d.name || 'tool',
|
|
870
|
+
input: parseGrokToolInput(d.input ?? d.arguments),
|
|
871
|
+
ts,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
} catch {}
|
|
876
|
+
|
|
877
|
+
// timestamps + per-turn usage from updates.jsonl (`timestamp` = Unix seconds)
|
|
878
|
+
try {
|
|
879
|
+
const updates = await loadGrokUpdates(sessionDir);
|
|
880
|
+
applyGrokTimestamps(messages, updates);
|
|
881
|
+
insertTurnUsages(messages, updates.turnUsages);
|
|
882
|
+
} catch {}
|
|
883
|
+
|
|
884
|
+
return messages;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export async function loadGrokSessionBundle(sessionDir) {
|
|
888
|
+
const messages = await parseGrokMessages(sessionDir);
|
|
889
|
+
const context = await loadGrokContext(sessionDir);
|
|
890
|
+
return { messages, context };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ─── Unified ──────────────────────────────────────────────────────────────────
|
|
894
|
+
|
|
895
|
+
/** Normalize cwd so /private/tmp and /tmp (etc.) group together on macOS. */
|
|
896
|
+
export function normalizeCwd(cwd) {
|
|
897
|
+
if (!cwd) return null;
|
|
898
|
+
let p = cwd;
|
|
899
|
+
if (p.startsWith('/private/')) p = p.slice('/private'.length) || '/';
|
|
900
|
+
// strip trailing slash except root
|
|
901
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
902
|
+
return p;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
export function projectNameFromCwd(cwd) {
|
|
906
|
+
if (!cwd) return '(no path)';
|
|
907
|
+
const parts = cwd.split('/').filter(Boolean);
|
|
908
|
+
return parts[parts.length - 1] || cwd;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function publicSession(s) {
|
|
912
|
+
const cwd = normalizeCwd(s.cwd) || s.cwd || null;
|
|
913
|
+
return {
|
|
914
|
+
sessionId: s.sessionId,
|
|
915
|
+
agent: s.agent || 'claude',
|
|
916
|
+
cwd,
|
|
917
|
+
configDir: s.configDir || null,
|
|
918
|
+
display: (s.display || s.sessionId?.slice(0, 8) || 'Session').toString().slice(0, 160),
|
|
919
|
+
updatedAt: s.updatedAt || 0,
|
|
920
|
+
hasMemory: Boolean(s.hasMemory),
|
|
921
|
+
projectName: projectNameFromCwd(cwd),
|
|
922
|
+
totalTokens: s.totalTokens || 0,
|
|
923
|
+
turnCount: Number(s.turnCount) || 0,
|
|
924
|
+
// keep for deep search only when needed — not sent if stripped below
|
|
925
|
+
_filePath: s.filePath || null,
|
|
926
|
+
_sessionDir: s.sessionDir || null,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
export async function scanAllSessions({ claudeConfigDirs, codexHome, grokHome, filterCwd, agents }) {
|
|
931
|
+
const want = new Set(agents || ['claude', 'codex', 'grok']);
|
|
932
|
+
const tasks = [];
|
|
933
|
+
if (want.has('claude')) tasks.push(scanClaudeSessions(claudeConfigDirs, filterCwd));
|
|
934
|
+
if (want.has('codex')) tasks.push(scanCodexSessions(codexHome, filterCwd));
|
|
935
|
+
if (want.has('grok')) tasks.push(scanGrokSessions(grokHome, filterCwd));
|
|
936
|
+
|
|
937
|
+
const results = await Promise.all(tasks);
|
|
938
|
+
const seen = new Set();
|
|
939
|
+
const sessions = results.flat()
|
|
940
|
+
.map(publicSession)
|
|
941
|
+
.filter(s => {
|
|
942
|
+
const key = `${s.agent}:${s.sessionId}`;
|
|
943
|
+
if (seen.has(key)) return false;
|
|
944
|
+
seen.add(key);
|
|
945
|
+
return true;
|
|
946
|
+
});
|
|
947
|
+
return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Strip internal-only fields before sending to clients. */
|
|
951
|
+
export function toClientSession(s) {
|
|
952
|
+
const { _filePath, _sessionDir, ...rest } = s;
|
|
953
|
+
return rest;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Search / filter sessions for project-management views.
|
|
958
|
+
* - metadata match: cwd, projectName, display, agent
|
|
959
|
+
* - optional deep content: peek recent session files for keyword (bounded)
|
|
960
|
+
*/
|
|
961
|
+
export async function searchActivity(sessions, {
|
|
962
|
+
q = '',
|
|
963
|
+
days = 0,
|
|
964
|
+
agent = null,
|
|
965
|
+
limit = 80,
|
|
966
|
+
deep = true,
|
|
967
|
+
} = {}) {
|
|
968
|
+
const query = (q || '').trim().toLowerCase();
|
|
969
|
+
const now = Date.now();
|
|
970
|
+
const since = days > 0 ? now - days * 86400000 : 0;
|
|
971
|
+
|
|
972
|
+
let list = sessions.filter(s => {
|
|
973
|
+
if (agent && s.agent !== agent) return false;
|
|
974
|
+
if (since && (s.updatedAt || 0) < since) return false;
|
|
975
|
+
return true;
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
// Metadata filter first
|
|
979
|
+
if (query) {
|
|
980
|
+
const metaHit = [];
|
|
981
|
+
const needDeep = [];
|
|
982
|
+
for (const s of list) {
|
|
983
|
+
const hay = [
|
|
984
|
+
s.display, s.cwd, s.projectName, s.agent, s.sessionId,
|
|
985
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
986
|
+
if (hay.includes(query)) metaHit.push({ ...s, match: 'meta', snippet: s.display });
|
|
987
|
+
else needDeep.push(s);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (deep && needDeep.length) {
|
|
991
|
+
// Only deep-scan the most recent candidates to stay fast
|
|
992
|
+
const candidates = needDeep
|
|
993
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
994
|
+
.slice(0, 60);
|
|
995
|
+
const deepHits = await Promise.all(candidates.map(async s => {
|
|
996
|
+
const snippet = await peekSessionText(s, query);
|
|
997
|
+
if (!snippet) return null;
|
|
998
|
+
return { ...s, match: 'content', snippet: snippet.slice(0, 180) };
|
|
999
|
+
}));
|
|
1000
|
+
list = [...metaHit, ...deepHits.filter(Boolean)];
|
|
1001
|
+
} else {
|
|
1002
|
+
list = metaHit;
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
list = list.map(s => ({ ...s, match: 'recent', snippet: s.display }));
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
list.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1009
|
+
return list.slice(0, limit).map(s => {
|
|
1010
|
+
const client = toClientSession(s);
|
|
1011
|
+
return {
|
|
1012
|
+
...client,
|
|
1013
|
+
match: s.match,
|
|
1014
|
+
snippet: s.snippet || s.display,
|
|
1015
|
+
};
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Read a short text sample from a session file looking for `query`. */
|
|
1020
|
+
async function peekSessionText(s, query) {
|
|
1021
|
+
try {
|
|
1022
|
+
if (s.agent === 'grok') {
|
|
1023
|
+
const dir = s._sessionDir || await findGrokSessionDir(s.sessionId);
|
|
1024
|
+
if (!dir) return null;
|
|
1025
|
+
const hist = path.join(dir, 'chat_history.jsonl');
|
|
1026
|
+
return await scanFileForQuery(hist, query, {
|
|
1027
|
+
maxLines: 120,
|
|
1028
|
+
extract: (d) => {
|
|
1029
|
+
if (d.synthetic_reason || d.type === 'system' || d.type === 'reasoning') return null;
|
|
1030
|
+
if (d.type === 'user' && d.content) {
|
|
1031
|
+
const raw = extractGrokText(d.content);
|
|
1032
|
+
if (isGrokInternalUserText(raw)) return null;
|
|
1033
|
+
return unwrapGrokUserQuery(raw);
|
|
1034
|
+
}
|
|
1035
|
+
if (d.type === 'assistant' && d.content) {
|
|
1036
|
+
return extractGrokText(d.content) || null;
|
|
1037
|
+
}
|
|
1038
|
+
return null;
|
|
1039
|
+
},
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
if (s.agent === 'codex') {
|
|
1043
|
+
const file = s._filePath || await findCodexSessionFile(s.sessionId);
|
|
1044
|
+
if (!file) return null;
|
|
1045
|
+
return await scanFileForQuery(file, query, {
|
|
1046
|
+
maxLines: 80,
|
|
1047
|
+
extract: (d) => {
|
|
1048
|
+
if (d.type === 'response_item' && d.payload?.type === 'message') {
|
|
1049
|
+
const p = d.payload;
|
|
1050
|
+
if (p.role !== 'user' && p.role !== 'assistant') return null;
|
|
1051
|
+
const texts = (p.content || [])
|
|
1052
|
+
.map(c => c.text || '')
|
|
1053
|
+
.filter(Boolean);
|
|
1054
|
+
const text = texts.join(' ').trim();
|
|
1055
|
+
if (!text || text.startsWith('# AGENTS.md') || text.startsWith('<')) return null;
|
|
1056
|
+
if (text.length > 2000) return null;
|
|
1057
|
+
return text;
|
|
1058
|
+
}
|
|
1059
|
+
return null;
|
|
1060
|
+
},
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
// claude
|
|
1064
|
+
const file = await findClaudeSessionFile(s.sessionId, null);
|
|
1065
|
+
// findClaudeSessionFile needs config dirs — skip if we don't have path
|
|
1066
|
+
if (!file && !s.configDir) return null;
|
|
1067
|
+
const f = file || path.join(s.configDir, 'projects'); // fallback won't work well
|
|
1068
|
+
// Use load path via find with single configDir if present
|
|
1069
|
+
const claudeFile = s.configDir
|
|
1070
|
+
? await findClaudeSessionFile(s.sessionId, [s.configDir])
|
|
1071
|
+
: file;
|
|
1072
|
+
if (!claudeFile) return null;
|
|
1073
|
+
return await scanFileForQuery(claudeFile, query, {
|
|
1074
|
+
maxLines: 100,
|
|
1075
|
+
extract: (d) => {
|
|
1076
|
+
if (d.type === 'user' && d.message?.content) {
|
|
1077
|
+
const parts = Array.isArray(d.message.content) ? d.message.content : [];
|
|
1078
|
+
for (const part of parts) {
|
|
1079
|
+
if (part.type === 'text' && part.text?.trim() && !isInternal(part.text.trim())) {
|
|
1080
|
+
return part.text.trim();
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (d.type === 'assistant' && Array.isArray(d.message?.content)) {
|
|
1085
|
+
for (const part of d.message.content) {
|
|
1086
|
+
if (part.type === 'text' && part.text?.trim()) return part.text.trim();
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
},
|
|
1091
|
+
});
|
|
1092
|
+
} catch {
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
async function scanFileForQuery(filePath, query, { maxLines = 80, extract }) {
|
|
1098
|
+
if (!filePath || !query) return null;
|
|
1099
|
+
try {
|
|
1100
|
+
const stream = fsSync.createReadStream(filePath, { encoding: 'utf8' });
|
|
1101
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
1102
|
+
let n = 0;
|
|
1103
|
+
let lastUser = null;
|
|
1104
|
+
for await (const line of rl) {
|
|
1105
|
+
n++;
|
|
1106
|
+
if (n > maxLines * 3) break; // hard cap
|
|
1107
|
+
let d;
|
|
1108
|
+
try { d = JSON.parse(line); } catch { continue; }
|
|
1109
|
+
const text = extract(d);
|
|
1110
|
+
if (!text) continue;
|
|
1111
|
+
if (text.toLowerCase().includes(query)) {
|
|
1112
|
+
rl.close(); stream.destroy();
|
|
1113
|
+
// highlight-ish snippet around match
|
|
1114
|
+
const idx = text.toLowerCase().indexOf(query);
|
|
1115
|
+
const start = Math.max(0, idx - 40);
|
|
1116
|
+
const end = Math.min(text.length, idx + query.length + 80);
|
|
1117
|
+
return (start > 0 ? '…' : '') + text.slice(start, end).replace(/\s+/g, ' ') + (end < text.length ? '…' : '');
|
|
1118
|
+
}
|
|
1119
|
+
if (!lastUser && text.length < 300) lastUser = text;
|
|
1120
|
+
if (n >= maxLines && lastUser) break;
|
|
1121
|
+
}
|
|
1122
|
+
} catch {}
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** Group sessions into projects for the projects API. */
|
|
1127
|
+
export function groupByProject(sessions) {
|
|
1128
|
+
const map = new Map();
|
|
1129
|
+
for (const s of sessions) {
|
|
1130
|
+
const key = s.cwd || '(no path)';
|
|
1131
|
+
if (!map.has(key)) {
|
|
1132
|
+
map.set(key, {
|
|
1133
|
+
cwd: s.cwd || null,
|
|
1134
|
+
projectName: s.projectName || projectNameFromCwd(s.cwd),
|
|
1135
|
+
updatedAt: 0,
|
|
1136
|
+
sessionCount: 0,
|
|
1137
|
+
agents: {},
|
|
1138
|
+
latest: null,
|
|
1139
|
+
sessions: [],
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
const g = map.get(key);
|
|
1143
|
+
g.sessionCount++;
|
|
1144
|
+
g.agents[s.agent] = (g.agents[s.agent] || 0) + 1;
|
|
1145
|
+
if ((s.updatedAt || 0) > g.updatedAt) {
|
|
1146
|
+
g.updatedAt = s.updatedAt;
|
|
1147
|
+
g.latest = {
|
|
1148
|
+
sessionId: s.sessionId,
|
|
1149
|
+
agent: s.agent,
|
|
1150
|
+
display: s.display,
|
|
1151
|
+
updatedAt: s.updatedAt,
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
g.sessions.push(s);
|
|
1155
|
+
}
|
|
1156
|
+
return [...map.values()]
|
|
1157
|
+
.map(g => ({
|
|
1158
|
+
...g,
|
|
1159
|
+
sessions: g.sessions.sort((a, b) => b.updatedAt - a.updatedAt),
|
|
1160
|
+
}))
|
|
1161
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Load session history. Returns { messages, context } or null.
|
|
1166
|
+
* `context` carries window / token usage when the agent exposes it (Grok signals.json).
|
|
1167
|
+
*/
|
|
1168
|
+
export async function loadSessionMessages(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
|
|
1169
|
+
const a = agent || 'claude';
|
|
1170
|
+
if (a === 'codex') {
|
|
1171
|
+
const file = await findCodexSessionFile(sessionId, codexHome);
|
|
1172
|
+
if (!file) return null;
|
|
1173
|
+
return { messages: await parseCodexMessages(file), context: null, agent: 'codex' };
|
|
1174
|
+
}
|
|
1175
|
+
if (a === 'grok') {
|
|
1176
|
+
const dir = await findGrokSessionDir(sessionId, grokHome);
|
|
1177
|
+
if (!dir) return null;
|
|
1178
|
+
const bundle = await loadGrokSessionBundle(dir);
|
|
1179
|
+
return { ...bundle, agent: 'grok' };
|
|
1180
|
+
}
|
|
1181
|
+
const file = await findClaudeSessionFile(sessionId, claudeConfigDirs);
|
|
1182
|
+
if (!file) return null;
|
|
1183
|
+
return { messages: await parseClaudeMessages(file), context: null, agent: 'claude' };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
export async function loadSessionMemory(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
|
|
1187
|
+
if (agent === 'grok') {
|
|
1188
|
+
const dir = await findGrokSessionDir(sessionId, grokHome);
|
|
1189
|
+
if (!dir) return null;
|
|
1190
|
+
// Grok may store memory differently; best-effort empty
|
|
1191
|
+
return { index: null, files: [] };
|
|
1192
|
+
}
|
|
1193
|
+
if (agent === 'codex') {
|
|
1194
|
+
return { index: null, files: [] };
|
|
1195
|
+
}
|
|
1196
|
+
const file = await findClaudeSessionFile(sessionId, claudeConfigDirs);
|
|
1197
|
+
if (!file) return null;
|
|
1198
|
+
return readProjectMemory(path.dirname(file));
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
export async function readProjectMemory(projectDir) {
|
|
1202
|
+
const memoryDir = path.join(projectDir, 'memory');
|
|
1203
|
+
let index = null;
|
|
1204
|
+
try { index = await fs.readFile(path.join(projectDir, 'MEMORY.md'), 'utf8'); }
|
|
1205
|
+
catch {}
|
|
1206
|
+
|
|
1207
|
+
const files = [];
|
|
1208
|
+
let entries = [];
|
|
1209
|
+
try { entries = await fs.readdir(memoryDir, { withFileTypes: true }); }
|
|
1210
|
+
catch {}
|
|
1211
|
+
await Promise.all(
|
|
1212
|
+
entries
|
|
1213
|
+
.filter(e => e.isFile() && e.name.endsWith('.md'))
|
|
1214
|
+
.map(async e => {
|
|
1215
|
+
try {
|
|
1216
|
+
const content = await fs.readFile(path.join(memoryDir, e.name), 'utf8');
|
|
1217
|
+
const stat = await fs.stat(path.join(memoryDir, e.name));
|
|
1218
|
+
files.push({ name: e.name, content, updatedAt: stat.mtimeMs });
|
|
1219
|
+
} catch {}
|
|
1220
|
+
})
|
|
1221
|
+
);
|
|
1222
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
1223
|
+
return { index, files };
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
export async function getSessionMeta(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
|
|
1227
|
+
const a = (agent || 'claude').toLowerCase();
|
|
1228
|
+
let cwd = null;
|
|
1229
|
+
let display = null;
|
|
1230
|
+
if (a === 'grok') {
|
|
1231
|
+
const dir = await findGrokSessionDir(sessionId, grokHome);
|
|
1232
|
+
if (dir) {
|
|
1233
|
+
try {
|
|
1234
|
+
const summary = JSON.parse(await fs.readFile(path.join(dir, 'summary.json'), 'utf8'));
|
|
1235
|
+
cwd = summary.info?.cwd || null;
|
|
1236
|
+
display = summary.session_summary || summary.info?.title || null;
|
|
1237
|
+
} catch {}
|
|
1238
|
+
}
|
|
1239
|
+
} else if (a === 'codex') {
|
|
1240
|
+
const file = await findCodexSessionFile(sessionId, codexHome);
|
|
1241
|
+
if (file) {
|
|
1242
|
+
const meta = await readCodexMeta(file);
|
|
1243
|
+
cwd = meta.cwd;
|
|
1244
|
+
const index = await loadCodexIndex(codexHome || path.join(os.homedir(), '.codex'));
|
|
1245
|
+
const idx = index.get(sessionId);
|
|
1246
|
+
display = idx?.thread_name || null;
|
|
1247
|
+
}
|
|
1248
|
+
} else {
|
|
1249
|
+
// claude
|
|
1250
|
+
const file = await findClaudeSessionFile(sessionId, claudeConfigDirs);
|
|
1251
|
+
if (file) {
|
|
1252
|
+
const meta = await readClaudeSessionMeta(file);
|
|
1253
|
+
cwd = meta.cwd;
|
|
1254
|
+
const nameMap = await loadClaudeHistoryNames(claudeConfigDirs);
|
|
1255
|
+
display = nameMap.get(sessionId) || await readClaudeFirstMessage(file);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return { cwd, display };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export async function convertSession(sourceSessionId, sourceAgent, targetAgent, { claudeConfigDirs, codexHome, grokHome }) {
|
|
1262
|
+
// 1. Get meta and messages from source session
|
|
1263
|
+
const meta = await getSessionMeta(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome });
|
|
1264
|
+
const cwd = meta.cwd || null;
|
|
1265
|
+
const display = meta.display || 'Converted Session';
|
|
1266
|
+
|
|
1267
|
+
const bundle = await loadSessionMessages(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome });
|
|
1268
|
+
const messages = bundle.messages || [];
|
|
1269
|
+
|
|
1270
|
+
// 2. Generate target session ID
|
|
1271
|
+
const targetSessionId = crypto.randomUUID();
|
|
1272
|
+
|
|
1273
|
+
// 3. Serialize messages to the target agent
|
|
1274
|
+
const ta = targetAgent.toLowerCase();
|
|
1275
|
+
|
|
1276
|
+
if (ta === 'claude') {
|
|
1277
|
+
const configDir = claudeConfigDirs?.[0] || path.join(os.homedir(), '.claude');
|
|
1278
|
+
const projectsDir = path.join(configDir, 'projects');
|
|
1279
|
+
|
|
1280
|
+
// Attempt to find existing project folder with matching cwd
|
|
1281
|
+
let projectDirName = null;
|
|
1282
|
+
try {
|
|
1283
|
+
const projectDirs = (await fs.readdir(projectsDir, { withFileTypes: true })).filter(e => e.isDirectory());
|
|
1284
|
+
for (const dirEntry of projectDirs) {
|
|
1285
|
+
const candidateProjDir = path.join(projectsDir, dirEntry.name);
|
|
1286
|
+
const entries = await fs.readdir(candidateProjDir);
|
|
1287
|
+
const jsonlFiles = entries.filter(e => e.endsWith('.jsonl') && !e.startsWith('agent-'));
|
|
1288
|
+
for (const file of jsonlFiles) {
|
|
1289
|
+
const m = await readClaudeSessionMeta(path.join(candidateProjDir, file));
|
|
1290
|
+
if (m.cwd === cwd) {
|
|
1291
|
+
projectDirName = dirEntry.name;
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
if (projectDirName) break;
|
|
1296
|
+
}
|
|
1297
|
+
} catch {}
|
|
1298
|
+
|
|
1299
|
+
if (!projectDirName) {
|
|
1300
|
+
if (cwd) {
|
|
1301
|
+
projectDirName = '-' + cwd.replace(/^\//, '').replace(/\//g, '-');
|
|
1302
|
+
} else {
|
|
1303
|
+
projectDirName = 'default-project';
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const targetDir = path.join(projectsDir, projectDirName);
|
|
1308
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
1309
|
+
|
|
1310
|
+
const targetFile = path.join(targetDir, `${targetSessionId}.jsonl`);
|
|
1311
|
+
const lines = [];
|
|
1312
|
+
let parentUuid = null;
|
|
1313
|
+
let lastPromptText = '';
|
|
1314
|
+
let lastLeafUuid = null;
|
|
1315
|
+
|
|
1316
|
+
for (const msg of messages) {
|
|
1317
|
+
const ts = msg.ts ? new Date(msg.ts).toISOString() : new Date().toISOString();
|
|
1318
|
+
const currentUuid = crypto.randomUUID();
|
|
1319
|
+
|
|
1320
|
+
if (msg.role === 'user') {
|
|
1321
|
+
lastPromptText = msg.content || '';
|
|
1322
|
+
lines.push(JSON.stringify({
|
|
1323
|
+
parentUuid,
|
|
1324
|
+
isSidechain: false,
|
|
1325
|
+
promptId: crypto.randomUUID(),
|
|
1326
|
+
type: 'user',
|
|
1327
|
+
message: { role: 'user', content: msg.content },
|
|
1328
|
+
uuid: currentUuid,
|
|
1329
|
+
timestamp: ts,
|
|
1330
|
+
permissionMode: 'default',
|
|
1331
|
+
promptSource: 'sdk',
|
|
1332
|
+
userType: 'external',
|
|
1333
|
+
entrypoint: 'sdk-cli',
|
|
1334
|
+
cwd,
|
|
1335
|
+
sessionId: targetSessionId,
|
|
1336
|
+
version: '2.1.177',
|
|
1337
|
+
gitBranch: 'main'
|
|
1338
|
+
}));
|
|
1339
|
+
parentUuid = currentUuid;
|
|
1340
|
+
} else if (msg.role === 'assistant') {
|
|
1341
|
+
lines.push(JSON.stringify({
|
|
1342
|
+
parentUuid,
|
|
1343
|
+
isSidechain: false,
|
|
1344
|
+
message: {
|
|
1345
|
+
id: crypto.randomBytes(16).toString('hex'),
|
|
1346
|
+
type: 'message',
|
|
1347
|
+
role: 'assistant',
|
|
1348
|
+
content: [{ type: 'text', text: msg.content || '' }],
|
|
1349
|
+
model: 'claude-3-5-sonnet',
|
|
1350
|
+
stop_reason: 'end_turn',
|
|
1351
|
+
stop_sequence: null,
|
|
1352
|
+
usage: { input_tokens: 0, output_tokens: 0 }
|
|
1353
|
+
},
|
|
1354
|
+
type: 'assistant',
|
|
1355
|
+
uuid: currentUuid,
|
|
1356
|
+
timestamp: ts,
|
|
1357
|
+
userType: 'external',
|
|
1358
|
+
entrypoint: 'sdk-cli',
|
|
1359
|
+
cwd,
|
|
1360
|
+
sessionId: targetSessionId,
|
|
1361
|
+
version: '2.1.177',
|
|
1362
|
+
gitBranch: 'main'
|
|
1363
|
+
}));
|
|
1364
|
+
parentUuid = currentUuid;
|
|
1365
|
+
lastLeafUuid = currentUuid;
|
|
1366
|
+
} else if (msg.role === 'tool' && msg.type === 'tool_use') {
|
|
1367
|
+
lines.push(JSON.stringify({
|
|
1368
|
+
parentUuid,
|
|
1369
|
+
isSidechain: false,
|
|
1370
|
+
message: {
|
|
1371
|
+
id: crypto.randomBytes(16).toString('hex'),
|
|
1372
|
+
type: 'message',
|
|
1373
|
+
role: 'assistant',
|
|
1374
|
+
content: [{ type: 'tool_use', name: msg.name, input: msg.input || {} }],
|
|
1375
|
+
model: 'claude-3-5-sonnet',
|
|
1376
|
+
stop_reason: 'end_turn',
|
|
1377
|
+
stop_sequence: null,
|
|
1378
|
+
usage: { input_tokens: 0, output_tokens: 0 }
|
|
1379
|
+
},
|
|
1380
|
+
type: 'assistant',
|
|
1381
|
+
uuid: currentUuid,
|
|
1382
|
+
timestamp: ts,
|
|
1383
|
+
userType: 'external',
|
|
1384
|
+
entrypoint: 'sdk-cli',
|
|
1385
|
+
cwd,
|
|
1386
|
+
sessionId: targetSessionId,
|
|
1387
|
+
version: '2.1.177',
|
|
1388
|
+
gitBranch: 'main'
|
|
1389
|
+
}));
|
|
1390
|
+
parentUuid = currentUuid;
|
|
1391
|
+
lastLeafUuid = currentUuid;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (lastPromptText && lastLeafUuid) {
|
|
1396
|
+
lines.push(JSON.stringify({
|
|
1397
|
+
type: 'last-prompt',
|
|
1398
|
+
lastPrompt: lastPromptText,
|
|
1399
|
+
leafUuid: lastLeafUuid,
|
|
1400
|
+
sessionId: targetSessionId
|
|
1401
|
+
}));
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
await fs.writeFile(targetFile, lines.join('\n') + '\n', 'utf8');
|
|
1405
|
+
|
|
1406
|
+
// Also save history mapping
|
|
1407
|
+
try {
|
|
1408
|
+
const historyFile = path.join(configDir, 'history.jsonl');
|
|
1409
|
+
await fs.appendFile(historyFile, JSON.stringify({
|
|
1410
|
+
display,
|
|
1411
|
+
pastedContents: {},
|
|
1412
|
+
timestamp: Date.now(),
|
|
1413
|
+
project: cwd,
|
|
1414
|
+
sessionId: targetSessionId
|
|
1415
|
+
}) + '\n', 'utf8');
|
|
1416
|
+
} catch {}
|
|
1417
|
+
|
|
1418
|
+
} else if (ta === 'codex') {
|
|
1419
|
+
const home = codexHome || path.join(os.homedir(), '.codex');
|
|
1420
|
+
const targetDir = path.join(home, 'sessions');
|
|
1421
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
1422
|
+
|
|
1423
|
+
const targetFile = path.join(targetDir, `rollout-converted-${targetSessionId}.jsonl`);
|
|
1424
|
+
const lines = [];
|
|
1425
|
+
const baseTs = Date.now();
|
|
1426
|
+
lines.push(JSON.stringify({
|
|
1427
|
+
type: 'session_meta',
|
|
1428
|
+
timestamp: baseTs,
|
|
1429
|
+
payload: { id: targetSessionId, cwd }
|
|
1430
|
+
}));
|
|
1431
|
+
|
|
1432
|
+
for (const msg of messages) {
|
|
1433
|
+
const ts = msg.ts || Date.now();
|
|
1434
|
+
if (msg.role === 'user') {
|
|
1435
|
+
lines.push(JSON.stringify({
|
|
1436
|
+
type: 'response_item',
|
|
1437
|
+
timestamp: ts,
|
|
1438
|
+
payload: { type: 'message', role: 'user', content: [{ type: 'text', text: msg.content }] }
|
|
1439
|
+
}));
|
|
1440
|
+
} else if (msg.role === 'assistant') {
|
|
1441
|
+
lines.push(JSON.stringify({
|
|
1442
|
+
type: 'response_item',
|
|
1443
|
+
timestamp: ts,
|
|
1444
|
+
payload: { type: 'message', role: 'assistant', content: [{ type: 'text', text: msg.content }] }
|
|
1445
|
+
}));
|
|
1446
|
+
} else if (msg.role === 'tool' && msg.type === 'tool_use') {
|
|
1447
|
+
lines.push(JSON.stringify({
|
|
1448
|
+
type: 'response_item',
|
|
1449
|
+
timestamp: ts,
|
|
1450
|
+
payload: { type: 'function_call', name: msg.name, arguments: JSON.stringify(msg.input || {}) }
|
|
1451
|
+
}));
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
await fs.writeFile(targetFile, lines.join('\n') + '\n', 'utf8');
|
|
1455
|
+
|
|
1456
|
+
// Write index
|
|
1457
|
+
try {
|
|
1458
|
+
const indexFile = path.join(home, 'session_index.jsonl');
|
|
1459
|
+
await fs.appendFile(indexFile, JSON.stringify({ id: targetSessionId, thread_name: display }) + '\n', 'utf8');
|
|
1460
|
+
} catch {}
|
|
1461
|
+
|
|
1462
|
+
} else if (ta === 'grok') {
|
|
1463
|
+
const home = grokHome || path.join(os.homedir(), '.grok');
|
|
1464
|
+
const folderName = cwd ? encodeURIComponent(cwd) : 'default';
|
|
1465
|
+
const targetDir = path.join(home, 'sessions', folderName, targetSessionId);
|
|
1466
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
1467
|
+
|
|
1468
|
+
const targetFile = path.join(targetDir, 'chat_history.jsonl');
|
|
1469
|
+
const lines = [];
|
|
1470
|
+
for (const msg of messages) {
|
|
1471
|
+
const ts = msg.ts || Date.now();
|
|
1472
|
+
if (msg.role === 'user') {
|
|
1473
|
+
lines.push(JSON.stringify({
|
|
1474
|
+
type: 'user',
|
|
1475
|
+
content: msg.content,
|
|
1476
|
+
timestamp: ts
|
|
1477
|
+
}));
|
|
1478
|
+
} else if (msg.role === 'assistant') {
|
|
1479
|
+
lines.push(JSON.stringify({
|
|
1480
|
+
type: 'assistant',
|
|
1481
|
+
content: msg.content,
|
|
1482
|
+
timestamp: ts
|
|
1483
|
+
}));
|
|
1484
|
+
} else if (msg.role === 'tool' && msg.type === 'tool_use') {
|
|
1485
|
+
lines.push(JSON.stringify({
|
|
1486
|
+
type: 'assistant',
|
|
1487
|
+
content: '',
|
|
1488
|
+
tool_calls: [{ name: msg.name, arguments: JSON.stringify(msg.input || {}) }],
|
|
1489
|
+
timestamp: ts
|
|
1490
|
+
}));
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
await fs.writeFile(targetFile, lines.join('\n') + '\n', 'utf8');
|
|
1494
|
+
|
|
1495
|
+
const summaryFile = path.join(targetDir, 'summary.json');
|
|
1496
|
+
await fs.writeFile(summaryFile, JSON.stringify({
|
|
1497
|
+
session_summary: display,
|
|
1498
|
+
info: {
|
|
1499
|
+
cwd,
|
|
1500
|
+
title: display
|
|
1501
|
+
},
|
|
1502
|
+
updated_at: new Date().toISOString()
|
|
1503
|
+
}, null, 2), 'utf8');
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
return { targetSessionId };
|
|
1507
|
+
}
|
|
1508
|
+
|