grok-telegram-bot 2.3.0 → 2.4.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.
Files changed (68) hide show
  1. package/.env.example +26 -0
  2. package/CHANGELOG.md +55 -0
  3. package/package.json +1 -1
  4. package/scripts/analyze-jsonl.ts +33 -0
  5. package/scripts/delayed-restart.ps1 +29 -0
  6. package/scripts/probe-exit-response-shape.py +77 -0
  7. package/scripts/probe-plan-exit.py +60 -0
  8. package/scripts/probe-plan-exit2.py +48 -0
  9. package/scripts/probe-plan-fields.py +41 -0
  10. package/scripts/probe-plan-fields2.py +58 -0
  11. package/scripts/probe-plan-response-path.py +48 -0
  12. package/scripts/sample-claude-tooluse.ts +21 -0
  13. package/scripts/sample-kiro-events.ts +31 -0
  14. package/scripts/smoke-exit-plan.ts +274 -0
  15. package/scripts/smoke-exit-shapes.ts +252 -0
  16. package/scripts/smoke-import.mjs +82 -0
  17. package/scripts/smoke-import.ts +73 -0
  18. package/src/app/accounts.ts +84 -0
  19. package/src/app/instance-lock.ts +6 -0
  20. package/src/app/types.ts +19 -2
  21. package/src/app/updater.ts +17 -6
  22. package/src/app/usage.ts +204 -7
  23. package/src/bot/account-rotator.ts +71 -2
  24. package/src/bot/bot.ts +36 -0
  25. package/src/bot/chat-controller.ts +35 -0
  26. package/src/bot/commands.ts +2 -0
  27. package/src/bot/complexity-gate.ts +69 -0
  28. package/src/bot/deps.ts +19 -0
  29. package/src/bot/handlers/accounts.ts +55 -5
  30. package/src/bot/handlers/import-session.ts +290 -0
  31. package/src/bot/handlers/menu.ts +17 -38
  32. package/src/bot/handlers/message.ts +1 -0
  33. package/src/bot/handlers/running.ts +35 -5
  34. package/src/bot/handlers/session-card.ts +12 -0
  35. package/src/bot/handlers/sessions.ts +14 -3
  36. package/src/bot/handlers/usage.ts +118 -16
  37. package/src/bot/menu/keyboard.ts +5 -4
  38. package/src/bot/menu/status-panel.ts +19 -6
  39. package/src/bot/prompt-content.ts +4 -0
  40. package/src/bot/reauth-controller.ts +2 -2
  41. package/src/bot/session-fork.ts +11 -0
  42. package/src/bot/session-runtime.ts +831 -64
  43. package/src/bot/suggestions.ts +429 -0
  44. package/src/config.ts +41 -0
  45. package/src/grok/client.ts +106 -20
  46. package/src/grok/plan-approval.ts +72 -0
  47. package/src/grok/session-log.ts +16 -0
  48. package/src/grok/types.ts +21 -2
  49. package/src/import/build-import.ts +132 -0
  50. package/src/import/history-readers.ts +681 -0
  51. package/src/import/list-running.ts +100 -0
  52. package/src/import/sources.ts +78 -0
  53. package/src/index.ts +179 -24
  54. package/src/render/diff.ts +11 -2
  55. package/src/render/file-summary.ts +31 -1
  56. package/src/render/markdown.ts +293 -35
  57. package/src/render/plan.ts +127 -0
  58. package/src/render/session-comment.ts +261 -0
  59. package/src/render/tool-call-detail.ts +400 -19
  60. package/src/render/tool-call-merge.ts +115 -0
  61. package/src/render/tool-call.ts +405 -142
  62. package/src/render/truncate.ts +85 -0
  63. package/src/service/windows.ts +14 -2
  64. package/src/sessions/history.ts +57 -0
  65. package/src/sessions/store.ts +3 -0
  66. package/src/sessions/types.ts +5 -0
  67. package/src/stream/streamer.ts +73 -9
  68. package/src/tasks/runner.ts +4 -3
@@ -0,0 +1,681 @@
1
+ /**
2
+ * Multi-format full-history readers for foreign sessions.
3
+ * Unlike the UI tail parsers, these read the *entire* conversation so an
4
+ * import loses nothing.
5
+ */
6
+ import {
7
+ closeSync,
8
+ existsSync,
9
+ openSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ readSync,
13
+ statSync,
14
+ } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { createLogger } from "../logger.js";
17
+ import type { HistoryEntry, HistoryRole } from "../sessions/types.js";
18
+ import type { HistoryFormat } from "./sources.js";
19
+
20
+ const log = createLogger("import:history");
21
+
22
+ /** Hard cap on raw log bytes we will load into memory (20 MB). */
23
+ const MAX_FILE_BYTES = 20 * 1024 * 1024;
24
+ /** Cap a single tool result / json blob so one grep dump can't blow the import. */
25
+ const TOOL_BLOB_MAX = 24_000;
26
+
27
+ export interface ForeignSessionMeta {
28
+ sessionId: string;
29
+ cwd: string;
30
+ title: string;
31
+ projectName?: string;
32
+ historyBytes: number;
33
+ /** Absolute path of the primary history file/dir, if known. */
34
+ historyPath?: string;
35
+ updatedAt?: string;
36
+ active?: boolean;
37
+ }
38
+
39
+ /** Read the full conversation history for a foreign session. */
40
+ export function readForeignHistory(
41
+ format: HistoryFormat,
42
+ sessionsRoot: string,
43
+ sessionId: string,
44
+ ): HistoryEntry[] {
45
+ switch (format) {
46
+ case "acp-jsonl":
47
+ return readAcpJsonl(join(sessionsRoot, `${sessionId}.jsonl`));
48
+ case "codex-rollout":
49
+ return readCodexRollout(resolveCodexRollout(sessionsRoot, sessionId));
50
+ case "opencode-storage":
51
+ return readOpencodeStorage(sessionsRoot, sessionId);
52
+ default:
53
+ return [];
54
+ }
55
+ }
56
+
57
+ /** Locate meta + history path for a foreign session id. */
58
+ export function resolveForeignMeta(
59
+ format: HistoryFormat,
60
+ sessionsRoot: string,
61
+ sessionId: string,
62
+ fallback?: { cwd?: string; projectName?: string },
63
+ ): ForeignSessionMeta {
64
+ const base: ForeignSessionMeta = {
65
+ sessionId,
66
+ cwd: fallback?.cwd || "",
67
+ title: "(untitled)",
68
+ projectName: fallback?.projectName,
69
+ historyBytes: 0,
70
+ };
71
+
72
+ if (format === "acp-jsonl") {
73
+ const metaPath = join(sessionsRoot, `${sessionId}.json`);
74
+ const jsonl = join(sessionsRoot, `${sessionId}.jsonl`);
75
+ base.historyPath = jsonl;
76
+ try {
77
+ const raw = JSON.parse(readFileSync(metaPath, "utf-8")) as {
78
+ cwd?: string;
79
+ title?: string;
80
+ updated_at?: string;
81
+ };
82
+ if (raw.cwd) base.cwd = raw.cwd;
83
+ if (raw.title?.trim()) base.title = raw.title.trim();
84
+ if (raw.updated_at) base.updatedAt = raw.updated_at;
85
+ } catch {
86
+ /* meta optional */
87
+ }
88
+ try {
89
+ base.historyBytes = statSync(jsonl).size;
90
+ if (!base.updatedAt) base.updatedAt = statSync(jsonl).mtime.toISOString();
91
+ } catch {
92
+ /* no history */
93
+ }
94
+ if (!base.title || base.title === "(untitled)") {
95
+ const first = firstUserText(readAcpJsonl(jsonl, 64 * 1024));
96
+ if (first) base.title = trunc(first, 80);
97
+ }
98
+ return base;
99
+ }
100
+
101
+ if (format === "codex-rollout") {
102
+ const path = resolveCodexRollout(sessionsRoot, sessionId);
103
+ base.historyPath = path;
104
+ if (path) {
105
+ try {
106
+ const st = statSync(path);
107
+ base.historyBytes = st.size;
108
+ base.updatedAt = st.mtime.toISOString();
109
+ } catch {
110
+ /* ignore */
111
+ }
112
+ const head = readHeadText(path, 64 * 1024);
113
+ const cwd = extractCodexCwd(head);
114
+ if (cwd) base.cwd = cwd;
115
+ const first = firstUserText(readCodexRollout(path));
116
+ if (first) base.title = trunc(first, 80);
117
+ }
118
+ return base;
119
+ }
120
+
121
+ // opencode-storage
122
+ const metaPath = findOpencodeSessionMeta(sessionsRoot, sessionId);
123
+ base.historyPath = metaPath;
124
+ if (metaPath) {
125
+ try {
126
+ const raw = JSON.parse(readFileSync(metaPath, "utf-8")) as {
127
+ directory?: string;
128
+ title?: string;
129
+ time?: { updated?: number; created?: number };
130
+ };
131
+ if (raw.directory) base.cwd = raw.directory;
132
+ if (raw.title?.trim()) base.title = raw.title.trim();
133
+ const ts = raw.time?.updated ?? raw.time?.created;
134
+ if (ts) base.updatedAt = new Date(ts).toISOString();
135
+ } catch {
136
+ /* ignore */
137
+ }
138
+ }
139
+ const msgDir = join(sessionsRoot, "storage", "message", sessionId);
140
+ if (existsSync(msgDir)) {
141
+ try {
142
+ let bytes = 0;
143
+ for (const f of readdirSync(msgDir)) {
144
+ if (!f.endsWith(".json")) continue;
145
+ try {
146
+ bytes += statSync(join(msgDir, f)).size;
147
+ } catch {
148
+ /* skip */
149
+ }
150
+ }
151
+ base.historyBytes = bytes;
152
+ } catch {
153
+ /* ignore */
154
+ }
155
+ }
156
+ return base;
157
+ }
158
+
159
+ // ── ACP-compatible .jsonl (Kiro, Claude bot, Grok) ───────────────────────────
160
+ // Full-fidelity import parser — keeps thinking, tool calls, and tool results
161
+ // (the UI history parser intentionally drops most of that noise).
162
+
163
+ interface AcpContentBlock {
164
+ kind?: string;
165
+ data?: unknown;
166
+ text?: unknown;
167
+ }
168
+
169
+ interface AcpEvent {
170
+ kind?: string;
171
+ data?: {
172
+ content?: AcpContentBlock[];
173
+ results?: unknown;
174
+ meta?: { timestamp?: number };
175
+ name?: string;
176
+ tool_name?: string;
177
+ message_id?: string;
178
+ };
179
+ }
180
+
181
+ function readAcpJsonl(path: string, maxBytes = MAX_FILE_BYTES): HistoryEntry[] {
182
+ const text = readFileCapped(path, maxBytes);
183
+ if (!text) return [];
184
+ const entries: HistoryEntry[] = [];
185
+ for (const line of text.split("\n")) {
186
+ for (const e of parseImportAcpLine(line)) entries.push(e);
187
+ }
188
+ return entries;
189
+ }
190
+
191
+ /** One source line may expand to multiple entries (text + tools + results). */
192
+ function parseImportAcpLine(line: string): HistoryEntry[] {
193
+ const trimmed = line.trim();
194
+ if (!trimmed) return [];
195
+ let ev: AcpEvent;
196
+ try {
197
+ ev = JSON.parse(trimmed) as AcpEvent;
198
+ } catch {
199
+ return [];
200
+ }
201
+ const kind = ev.kind || "";
202
+ const ts = ev.data?.meta?.timestamp;
203
+ const content = Array.isArray(ev.data?.content) ? ev.data!.content! : [];
204
+
205
+ if (kind === "Prompt" || kind === "UserMessage") {
206
+ const text = extractAcpRichText(content, { includeThinking: false, includeTools: false });
207
+ if (!text.trim()) return [];
208
+ return [{ role: "user", text, timestamp: ts }];
209
+ }
210
+
211
+ if (kind === "AssistantMessage" || kind === "Response") {
212
+ const text = extractAcpRichText(content, { includeThinking: true, includeTools: true });
213
+ if (!text.trim()) return [];
214
+ return [{ role: "assistant", text, timestamp: ts }];
215
+ }
216
+
217
+ if (kind === "ToolUse" || kind === "ToolUseResults" || kind === "ToolResults") {
218
+ const text = extractAcpToolPayload(ev);
219
+ if (!text.trim()) return [];
220
+ const tool = ev.data?.tool_name || ev.data?.name || "tool";
221
+ return [{ role: "tool", text, tool, timestamp: ts }];
222
+ }
223
+
224
+ return [];
225
+ }
226
+
227
+ function extractAcpRichText(
228
+ content: AcpContentBlock[],
229
+ opts: { includeThinking: boolean; includeTools: boolean },
230
+ ): string {
231
+ const parts: string[] = [];
232
+ for (const block of content) {
233
+ const k = block.kind || "";
234
+ if (k === "text") {
235
+ const t = blockText(block);
236
+ if (t) parts.push(t);
237
+ } else if (k === "thinking" && opts.includeThinking) {
238
+ const t = blockText(block) || nestedText(block.data);
239
+ if (t) parts.push(`[thinking] ${t}`);
240
+ } else if ((k === "toolUse" || k === "tool_use") && opts.includeTools) {
241
+ parts.push(formatToolUse(block.data));
242
+ } else if (k === "toolResult" || k === "tool_result") {
243
+ parts.push(formatToolResult(block.data));
244
+ } else if (typeof block.text === "string" && block.text.trim()) {
245
+ parts.push(block.text.trim());
246
+ }
247
+ }
248
+ return parts.join("\n").trim();
249
+ }
250
+
251
+ function extractAcpToolPayload(ev: AcpEvent): string {
252
+ const parts: string[] = [];
253
+ const toolName = ev.data?.tool_name || ev.data?.name || "tool";
254
+ const content = Array.isArray(ev.data?.content) ? ev.data!.content! : [];
255
+ for (const block of content) {
256
+ if (block.kind === "toolResult" || block.kind === "tool_result") {
257
+ parts.push(formatToolResult(block.data));
258
+ } else if (block.kind === "toolUse" || block.kind === "tool_use") {
259
+ parts.push(formatToolUse(block.data));
260
+ } else {
261
+ const t = blockText(block) || nestedText(block.data);
262
+ if (t) parts.push(t);
263
+ }
264
+ }
265
+ if (ev.data?.results !== undefined) {
266
+ parts.push(`results: ${safeJson(ev.data.results, TOOL_BLOB_MAX)}`);
267
+ }
268
+ // Claude bot often logs ToolUse with only tool_name + empty text content.
269
+ if (parts.length === 0) {
270
+ return `[tool:${toolName}]`;
271
+ }
272
+ // Prefer prefixing the tool name when body has no explicit call label.
273
+ if (!parts.some((p) => p.includes("[tool"))) {
274
+ return `[tool:${toolName}] ${parts.join("\n")}`.trim();
275
+ }
276
+ return parts.join("\n").trim();
277
+ }
278
+
279
+ function formatToolUse(data: unknown): string {
280
+ if (!data || typeof data !== "object") return "[tool]";
281
+ const d = data as { name?: string; toolUseId?: string; input?: unknown; arguments?: unknown };
282
+ const name = d.name || "tool";
283
+ const input = d.input ?? d.arguments;
284
+ const argText = input !== undefined ? safeJson(input, 8_000) : "";
285
+ return argText ? `[tool_call:${name}] ${argText}` : `[tool_call:${name}]`;
286
+ }
287
+
288
+ function formatToolResult(data: unknown): string {
289
+ if (!data || typeof data !== "object") return "[tool_result]";
290
+ const d = data as {
291
+ toolUseId?: string;
292
+ status?: string;
293
+ name?: string;
294
+ content?: unknown;
295
+ };
296
+ const name = d.name || "tool";
297
+ const status = d.status ? ` status=${d.status}` : "";
298
+ const body = extractToolResultBody(d.content);
299
+ return `[tool_result:${name}${status}] ${body}`.trim();
300
+ }
301
+
302
+ function extractToolResultBody(content: unknown): string {
303
+ if (content === undefined || content === null) return "";
304
+ if (typeof content === "string") return trunc(content, TOOL_BLOB_MAX);
305
+ if (!Array.isArray(content)) return safeJson(content, TOOL_BLOB_MAX);
306
+ const parts: string[] = [];
307
+ for (const block of content) {
308
+ if (typeof block === "string") {
309
+ parts.push(block);
310
+ continue;
311
+ }
312
+ if (!block || typeof block !== "object") continue;
313
+ const b = block as AcpContentBlock;
314
+ if (b.kind === "text") {
315
+ const t = blockText(b);
316
+ if (t) parts.push(t);
317
+ } else if (b.kind === "json") {
318
+ parts.push(safeJson(b.data, TOOL_BLOB_MAX));
319
+ } else if (b.kind === "resource" || b.kind === "resource_link") {
320
+ parts.push(safeJson(b.data, 4_000));
321
+ } else {
322
+ const t = blockText(b) || nestedText(b.data);
323
+ if (t) parts.push(t);
324
+ else parts.push(safeJson(b, 4_000));
325
+ }
326
+ }
327
+ return trunc(parts.join("\n"), TOOL_BLOB_MAX);
328
+ }
329
+
330
+ function blockText(block: AcpContentBlock): string {
331
+ if (typeof block.data === "string") return block.data.trim();
332
+ if (block.data && typeof block.data === "object") {
333
+ const d = block.data as { text?: unknown };
334
+ if (typeof d.text === "string") return d.text.trim();
335
+ }
336
+ if (typeof block.text === "string") return block.text.trim();
337
+ return "";
338
+ }
339
+
340
+ function nestedText(data: unknown): string {
341
+ if (typeof data === "string") return data.trim();
342
+ if (data && typeof data === "object" && typeof (data as { text?: unknown }).text === "string") {
343
+ return ((data as { text: string }).text || "").trim();
344
+ }
345
+ return "";
346
+ }
347
+
348
+ // ── Codex rollout ────────────────────────────────────────────────────────────
349
+
350
+ const ROLLOUT_ID_RE =
351
+ /rollout-.*?-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
352
+
353
+ function resolveCodexRollout(sessionsRoot: string, sessionId: string): string | undefined {
354
+ if (!existsSync(sessionsRoot)) return undefined;
355
+ const needle = sessionId.toLowerCase();
356
+ try {
357
+ const names = readdirSync(sessionsRoot, { recursive: true }) as string[];
358
+ for (const name of names) {
359
+ const rel = String(name);
360
+ if (!rel.endsWith(".jsonl") || !/rollout-/i.test(rel)) continue;
361
+ const full = join(sessionsRoot, rel);
362
+ const m = ROLLOUT_ID_RE.exec(full.replace(/\\/g, "/"));
363
+ if (m && m[1]!.toLowerCase() === needle) return full;
364
+ if (full.toLowerCase().includes(needle)) return full;
365
+ }
366
+ } catch (e) {
367
+ log.warn("codex scan failed:", (e as Error).message);
368
+ }
369
+ return undefined;
370
+ }
371
+
372
+ function readCodexRollout(path: string | undefined): HistoryEntry[] {
373
+ if (!path) return [];
374
+ const text = readFileCapped(path, MAX_FILE_BYTES);
375
+ if (!text) return [];
376
+ const entries: HistoryEntry[] = [];
377
+ for (const line of text.split("\n")) {
378
+ const e = parseCodexLine(line);
379
+ if (e) entries.push(e);
380
+ }
381
+ return entries;
382
+ }
383
+
384
+ function parseCodexLine(line: string): HistoryEntry | undefined {
385
+ const trimmed = line.trim();
386
+ if (!trimmed) return undefined;
387
+ let ev: { timestamp?: string; type?: string; payload?: Record<string, unknown> };
388
+ try {
389
+ ev = JSON.parse(trimmed) as typeof ev;
390
+ } catch {
391
+ return undefined;
392
+ }
393
+ if (ev.type !== "response_item" || !ev.payload || typeof ev.payload !== "object") return undefined;
394
+ const p = ev.payload;
395
+ const itemType = String(p.type ?? "");
396
+ const ts = ev.timestamp ? Date.parse(ev.timestamp) : undefined;
397
+ const timestamp = Number.isFinite(ts) ? ts : undefined;
398
+
399
+ if (itemType === "message") {
400
+ const role = codexRole(String(p.role ?? ""));
401
+ if (!role) return undefined;
402
+ const text = extractCodexText(p.content);
403
+ if (!text.trim()) return undefined;
404
+ return { role, text, timestamp };
405
+ }
406
+ if (itemType === "function_call" || itemType === "local_shell_call" || itemType === "custom_tool_call") {
407
+ const tool =
408
+ (typeof p.name === "string" && p.name) ||
409
+ (typeof p.tool_name === "string" && p.tool_name) ||
410
+ (itemType === "local_shell_call" ? "shell" : "tool");
411
+ // Include arguments / output when present so tool work isn't lost.
412
+ const detail = extractCodexToolDetail(p);
413
+ return { role: "tool", text: detail || `(${tool})`, tool, timestamp };
414
+ }
415
+ if (itemType === "function_call_output") {
416
+ const text = extractCodexText(p.content ?? p.output ?? p.text);
417
+ if (!text.trim()) return undefined;
418
+ return { role: "tool", text: trunc(text, 20_000), tool: "tool_result", timestamp };
419
+ }
420
+ return undefined;
421
+ }
422
+
423
+ function codexRole(role: string): HistoryRole | undefined {
424
+ if (role === "user") return "user";
425
+ if (role === "assistant") return "assistant";
426
+ if (role === "system" || role === "developer") return "system";
427
+ return undefined;
428
+ }
429
+
430
+ function extractCodexText(content: unknown): string {
431
+ if (typeof content === "string") return content.trim();
432
+ if (!Array.isArray(content)) return "";
433
+ const parts: string[] = [];
434
+ for (const block of content) {
435
+ if (typeof block === "string") parts.push(block);
436
+ else if (block && typeof block === "object") {
437
+ const b = block as { text?: unknown; type?: string };
438
+ if (typeof b.text === "string") parts.push(b.text);
439
+ }
440
+ }
441
+ return parts.join("").trim();
442
+ }
443
+
444
+ function extractCodexToolDetail(p: Record<string, unknown>): string {
445
+ const name =
446
+ (typeof p.name === "string" && p.name) ||
447
+ (typeof p.tool_name === "string" && p.tool_name) ||
448
+ "tool";
449
+ const args = p.arguments ?? p.params ?? p.command;
450
+ let argText = "";
451
+ if (typeof args === "string") argText = args;
452
+ else if (args && typeof args === "object") {
453
+ try {
454
+ argText = JSON.stringify(args);
455
+ } catch {
456
+ argText = String(args);
457
+ }
458
+ }
459
+ if (argText.length > 8_000) argText = argText.slice(0, 8_000) + " …";
460
+ return argText ? `${name}: ${argText}` : `(${name})`;
461
+ }
462
+
463
+ function extractCodexCwd(head: string): string {
464
+ for (const line of head.split("\n")) {
465
+ try {
466
+ const obj = JSON.parse(line) as { type?: string; payload?: { cwd?: string }; cwd?: string };
467
+ if (obj.type && obj.type !== "session_meta") continue;
468
+ const cwd = obj.payload?.cwd || obj.cwd;
469
+ if (cwd) return cwd;
470
+ } catch {
471
+ continue;
472
+ }
473
+ }
474
+ return "";
475
+ }
476
+
477
+ // ── OpenCode storage (message + part JSON trees) ─────────────────────────────
478
+
479
+ function findOpencodeSessionMeta(opencodeRoot: string, sessionId: string): string | undefined {
480
+ const sessionDir = join(opencodeRoot, "storage", "session");
481
+ if (!existsSync(sessionDir)) return undefined;
482
+ try {
483
+ const names = readdirSync(sessionDir, { recursive: true }) as string[];
484
+ for (const name of names) {
485
+ const rel = String(name);
486
+ if (!rel.endsWith(`${sessionId}.json`) && !rel.endsWith(`${sessionId}.json`.replace(/\\/g, "/"))) {
487
+ // Accept any path ending with the session file name.
488
+ if (!rel.replace(/\\/g, "/").endsWith(`/${sessionId}.json`) && !rel.endsWith(`${sessionId}.json`)) {
489
+ continue;
490
+ }
491
+ }
492
+ return join(sessionDir, rel);
493
+ }
494
+ } catch (e) {
495
+ log.warn("opencode meta scan failed:", (e as Error).message);
496
+ }
497
+ // Fallback: walk project buckets.
498
+ try {
499
+ for (const bucket of readdirSync(sessionDir)) {
500
+ const candidate = join(sessionDir, bucket, `${sessionId}.json`);
501
+ if (existsSync(candidate)) return candidate;
502
+ }
503
+ } catch {
504
+ /* ignore */
505
+ }
506
+ return undefined;
507
+ }
508
+
509
+ function readOpencodeStorage(opencodeRoot: string, sessionId: string): HistoryEntry[] {
510
+ const msgDir = join(opencodeRoot, "storage", "message", sessionId);
511
+ if (!existsSync(msgDir)) return [];
512
+ let files: string[];
513
+ try {
514
+ files = readdirSync(msgDir).filter((f) => f.endsWith(".json"));
515
+ } catch {
516
+ return [];
517
+ }
518
+ // Message ids encode time; lexicographic order matches creation order for ses_/msg_ ids.
519
+ files.sort();
520
+ const entries: HistoryEntry[] = [];
521
+ for (const file of files) {
522
+ const msgPath = join(msgDir, file);
523
+ let msg: {
524
+ id?: string;
525
+ role?: string;
526
+ time?: { created?: number };
527
+ };
528
+ try {
529
+ msg = JSON.parse(readFileSync(msgPath, "utf-8")) as typeof msg;
530
+ } catch {
531
+ continue;
532
+ }
533
+ const role = ocRole(msg.role);
534
+ if (!role) continue;
535
+ const msgId = msg.id || file.replace(/\.json$/, "");
536
+ const text = readOpencodeParts(opencodeRoot, msgId);
537
+ if (!text.trim() && role === "tool") {
538
+ entries.push({ role: "tool", text: "(tool)", tool: "tool", timestamp: msg.time?.created });
539
+ continue;
540
+ }
541
+ if (!text.trim()) continue;
542
+ entries.push({
543
+ role,
544
+ text,
545
+ tool: role === "tool" ? "tool" : undefined,
546
+ timestamp: msg.time?.created,
547
+ });
548
+ }
549
+ return entries;
550
+ }
551
+
552
+ function readOpencodeParts(opencodeRoot: string, messageId: string): string {
553
+ const partDir = join(opencodeRoot, "storage", "part", messageId);
554
+ if (!existsSync(partDir)) return "";
555
+ let files: string[];
556
+ try {
557
+ files = readdirSync(partDir).filter((f) => f.endsWith(".json"));
558
+ } catch {
559
+ return "";
560
+ }
561
+ files.sort();
562
+ const parts: string[] = [];
563
+ for (const file of files) {
564
+ try {
565
+ const raw = JSON.parse(readFileSync(join(partDir, file), "utf-8")) as {
566
+ type?: string;
567
+ text?: string;
568
+ name?: string;
569
+ tool?: string;
570
+ state?: { status?: string; output?: unknown; input?: unknown; error?: unknown };
571
+ output?: unknown;
572
+ input?: unknown;
573
+ };
574
+ if (raw.type === "text" && typeof raw.text === "string") {
575
+ parts.push(raw.text);
576
+ } else if (raw.type === "reasoning" && typeof raw.text === "string") {
577
+ // Keep reasoning — "nothing should be lost".
578
+ parts.push(`[reasoning] ${raw.text}`);
579
+ } else if (raw.type === "tool" || raw.type === "tool-invocation" || raw.type === "tool-result") {
580
+ const name = raw.name || raw.tool || "tool";
581
+ const bits: string[] = [`[tool:${name}]`];
582
+ const input = raw.input ?? raw.state?.input;
583
+ const output = raw.output ?? raw.state?.output;
584
+ if (input !== undefined) bits.push(`input: ${safeJson(input, 6_000)}`);
585
+ if (output !== undefined) bits.push(`output: ${safeJson(output, 12_000)}`);
586
+ if (raw.state?.error !== undefined) bits.push(`error: ${safeJson(raw.state.error, 2_000)}`);
587
+ parts.push(bits.join("\n"));
588
+ } else if (typeof raw.text === "string" && raw.text.trim()) {
589
+ parts.push(raw.text);
590
+ }
591
+ } catch {
592
+ /* skip bad part */
593
+ }
594
+ }
595
+ return parts.join("\n").trim();
596
+ }
597
+
598
+ function ocRole(role?: string): HistoryRole | undefined {
599
+ switch (role) {
600
+ case "user":
601
+ return "user";
602
+ case "assistant":
603
+ return "assistant";
604
+ case "system":
605
+ return "system";
606
+ case "tool":
607
+ return "tool";
608
+ default:
609
+ return undefined;
610
+ }
611
+ }
612
+
613
+ // ── shared helpers ───────────────────────────────────────────────────────────
614
+
615
+ function readFileCapped(path: string, maxBytes: number): string {
616
+ let size: number;
617
+ try {
618
+ size = statSync(path).size;
619
+ } catch {
620
+ return "";
621
+ }
622
+ if (size === 0) return "";
623
+ // Prefer the tail if the file is huge so the most recent context survives.
624
+ const length = Math.min(size, maxBytes);
625
+ const start = size > maxBytes ? size - maxBytes : 0;
626
+ const fd = openSync(path, "r");
627
+ try {
628
+ const buf = Buffer.alloc(length);
629
+ readSync(fd, buf, 0, length, start);
630
+ let text = buf.toString("utf-8");
631
+ if (start > 0) {
632
+ const nl = text.indexOf("\n");
633
+ if (nl !== -1) text = text.slice(nl + 1);
634
+ text = `[…earlier history truncated at ${(start / 1024 / 1024).toFixed(1)} MB…]\n` + text;
635
+ }
636
+ return text;
637
+ } finally {
638
+ closeSync(fd);
639
+ }
640
+ }
641
+
642
+ function readHeadText(path: string, maxBytes: number): string {
643
+ let size: number;
644
+ try {
645
+ size = statSync(path).size;
646
+ } catch {
647
+ return "";
648
+ }
649
+ if (size === 0) return "";
650
+ const length = Math.min(size, maxBytes);
651
+ const fd = openSync(path, "r");
652
+ try {
653
+ const buf = Buffer.alloc(length);
654
+ readSync(fd, buf, 0, length, 0);
655
+ return buf.toString("utf-8");
656
+ } finally {
657
+ closeSync(fd);
658
+ }
659
+ }
660
+
661
+ function firstUserText(entries: HistoryEntry[]): string {
662
+ for (const e of entries) {
663
+ if (e.role === "user" && e.text.trim()) return e.text.trim();
664
+ }
665
+ return "";
666
+ }
667
+
668
+ function trunc(s: string, n: number): string {
669
+ const one = s.replace(/\s+/g, " ").trim();
670
+ return one.length > n ? one.slice(0, n - 1) + "…" : one;
671
+ }
672
+
673
+ function safeJson(v: unknown, max: number): string {
674
+ let s: string;
675
+ try {
676
+ s = typeof v === "string" ? v : JSON.stringify(v, null, 0);
677
+ } catch {
678
+ s = String(v);
679
+ }
680
+ return s.length > max ? s.slice(0, max) + " …" : s;
681
+ }