agentel 0.2.6 → 0.2.8

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/src/importers.js CHANGED
@@ -7,7 +7,7 @@ const path = require("path");
7
7
  const zlib = require("zlib");
8
8
  const { execFileSync, spawnSync } = require("child_process");
9
9
  const { fileURLToPath } = require("url");
10
- const { archiveRoot, deleteSessionArchive, listSessions, readTranscript, writeSession, stableSessionId, toIso } = require("./archive");
10
+ const { archiveRoot, computeSessionUsage, deleteSessionArchive, listSessions, readTranscript, writeSession, stableSessionId, toIso } = require("./archive");
11
11
  const { fingerprintPrefix, parserVersionForSource } = require("./parser-versions");
12
12
  const { canonicalRepo } = require("./repo");
13
13
  const { ensureDir, paths, readJson, writeJson } = require("./paths");
@@ -23,6 +23,7 @@ const { manualImportInstructionResult } = require("./web-export-instructions");
23
23
 
24
24
  const WEB_TOKEN_ESTIMATE_CHARS = 4;
25
25
  const WEB_CHAT_TOKEN_ESTIMATION_METHOD = "web-message-parts-chars-v1";
26
+ const EXPORT_ZIP_ENTRY_MAX_BUFFER = 1024 * 1024 * 512;
26
27
  const OPENCODE_SOURCE_KINDS = new Set(["cli", "desktop", "web"]);
27
28
  const OPENCODE_SESSION_ID_RE = /\bses_[A-Za-z0-9]+\b/g;
28
29
 
@@ -81,10 +82,22 @@ function importJsonlProvider(provider, roots, since, options = {}, env = process
81
82
  const files =
82
83
  provider === "claude_code" ? claudeFiles(env) : provider === "claude_sdk" ? claudeSdkFiles(env) : jsonlFiles(roots);
83
84
  const claudeCodeMetadata = provider === "claude_code" ? claudeCodeSessionMetadataByCliSessionId(env) : new Map();
85
+ const claudeSubagentCache = provider === "claude_code" ? new Map() : null;
84
86
 
85
87
  const candidates = files
86
- .map((file) => ({ file, stat: safeStat(file) }))
87
- .filter((item) => item.stat && (!since || item.stat.mtime >= since))
88
+ .map((file) => {
89
+ const stat = safeStat(file);
90
+ const claudeSubagentRunSourceFiles = provider === "claude_code"
91
+ ? claudeSubagentRunSourceFilesForSession(claudeSessionIdFromFilename(file), file, env)
92
+ : [];
93
+ return {
94
+ file,
95
+ stat,
96
+ claudeSubagentRunSourceFiles,
97
+ importMtimeMs: latestFileMtimeMs([file, ...claudeSubagentRunSourceFiles], stat)
98
+ };
99
+ })
100
+ .filter((item) => item.stat && (!since || item.importMtimeMs >= since.getTime()))
88
101
  .sort((a, b) => a.file.localeCompare(b.file));
89
102
 
90
103
  const summary = {
@@ -100,7 +113,11 @@ function importJsonlProvider(provider, roots, since, options = {}, env = process
100
113
  for (let index = 0; index < candidates.length; index++) {
101
114
  const item = candidates[index];
102
115
  const sourceType = jsonlProviderSourceType(provider);
103
- const baseFingerprint = `${fingerprintPrefix(sourceType)}:${fileFingerprint(item.file, item.stat)}`;
116
+ const subagentRunFingerprint = provider === "claude_code" ? filesFingerprint(item.claudeSubagentRunSourceFiles) : "";
117
+ const baseFingerprint = [
118
+ `${fingerprintPrefix(sourceType)}:${fileFingerprint(item.file, item.stat)}`,
119
+ subagentRunFingerprint ? `claude-subagent-runs:${subagentRunFingerprint}` : ""
120
+ ].filter(Boolean).join(":");
104
121
  const preliminaryMetadata = provider === "claude_code"
105
122
  ? claudeCodeMetadata.get(claudeSessionIdFromFilename(item.file)) || null
106
123
  : null;
@@ -130,6 +147,12 @@ function importJsonlProvider(provider, roots, since, options = {}, env = process
130
147
  const cwd = parsed.cwd || sessionMetadata?.cwd || "";
131
148
  const scopeCanonical = cwd ? "" : uncategorizedScope(provider);
132
149
  const repo = repoInfoForImport(provider, cwd, sessionMetadata);
150
+ const claudeSubagents = provider === "claude_code"
151
+ ? claudeSubagentImportContext(repoCwdForImport(provider, cwd, sessionMetadata), env, claudeSubagentCache)
152
+ : null;
153
+ const claudeSubagentRuns = provider === "claude_code"
154
+ ? claudeSubagentRunImportContext(sessionId, item.file, env, item.claudeSubagentRunSourceFiles)
155
+ : null;
133
156
  if (options.repos && options.repos.length && (!repo || !options.repos.includes(repo.key))) {
134
157
  summary.skipped++;
135
158
  reportProgress(options, summary, index + 1, item.file);
@@ -156,18 +179,48 @@ function importJsonlProvider(provider, roots, since, options = {}, env = process
156
179
  startedAt: parsed.startedAt,
157
180
  endedAt: parsed.endedAt,
158
181
  sourcePath: item.file,
159
- sourceFiles: [item.file, sessionMetadata?.sourcePath || "", ...auxiliaryFiles].filter(Boolean),
182
+ sourceFiles: [
183
+ item.file,
184
+ sessionMetadata?.sourcePath || "",
185
+ ...auxiliaryFiles,
186
+ ...(claudeSubagents?.sourceFiles || []),
187
+ ...(claudeSubagentRuns?.sourceFiles || [])
188
+ ].filter(Boolean),
160
189
  sourceType,
161
190
  title: jsonlSessionTitleForImport(parsed, sessionMetadata),
162
- sessionSummary: mergeSessionSummaries(claudeCodeSidecarSessionSummary(sessionMetadata), parsed.sessionSummary)
191
+ sessionSummary: mergeSessionSummaries(
192
+ claudeCodeSidecarSessionSummary(sessionMetadata),
193
+ parsed.sessionSummary,
194
+ claudeSubagents?.sessionSummary,
195
+ claudeSubagentRuns?.sessionSummary
196
+ )
163
197
  },
164
198
  env
165
199
  );
166
200
  state.files[fingerprint] = { sessionId, at: new Date().toISOString() };
167
201
  state.sessions[sessionId] = { provider, sourcePath: item.file, fingerprint, auxiliary: auxiliaryFiles.length || undefined, at: new Date().toISOString() };
168
202
  archived.add(archiveSessionKey(provider, sessionId));
203
+ for (const subagentSession of claudeSubagentRuns?.sessions || []) {
204
+ writeSession(
205
+ {
206
+ ...subagentSession,
207
+ repoInfo: repo || undefined,
208
+ scopeCanonical,
209
+ sourceType
210
+ },
211
+ env
212
+ );
213
+ state.sessions[subagentSession.sessionId] = {
214
+ provider,
215
+ sourcePath: subagentSession.sourcePath,
216
+ fingerprint: `${fingerprintPrefix(sourceType)}:${filesFingerprint(subagentSession.sourceFiles || [subagentSession.sourcePath])}`,
217
+ parentSessionId: sessionId,
218
+ at: new Date().toISOString()
219
+ };
220
+ archived.add(archiveSessionKey(provider, subagentSession.sessionId));
221
+ }
169
222
  }
170
- summary.imported++;
223
+ summary.imported += 1 + (claudeSubagentRuns?.sessions?.length || 0);
171
224
  reportProgress(options, summary, index + 1, item.file);
172
225
  }
173
226
 
@@ -187,6 +240,46 @@ function codexThreadSourceType(thread) {
187
240
  return "codex-cli-history";
188
241
  }
189
242
 
243
+ function finalizeCodexParsedThread(thread, parsed) {
244
+ const result = { ...(parsed || {}) };
245
+ result.messages = Array.isArray(parsed?.messages) ? [...parsed.messages] : [];
246
+ result.messages.push(...codexSupplementaryMessages(thread, result.endedAt || thread?.updatedAt));
247
+ result.messages = dedupeAdjacentMessages(result.messages)
248
+ .sort((a, b) => String(a.timestamp || "").localeCompare(String(b.timestamp || "")));
249
+ result.startedAt = result.messages[0]?.timestamp || result.startedAt;
250
+ result.endedAt = result.messages[result.messages.length - 1]?.timestamp || result.endedAt;
251
+ result.sessionSummary = mergeSessionSummaries(result.sessionSummary, codexThreadStateSessionSummary(thread));
252
+ return result;
253
+ }
254
+
255
+ function codexThreadStateSessionSummary(thread) {
256
+ const rawTotalTokens = Number(thread?.tokensUsed);
257
+ const totalTokens = Number.isFinite(rawTotalTokens) && rawTotalTokens > 0 ? rawTotalTokens : 0;
258
+ const model = firstString(thread?.model);
259
+ if (!totalTokens && !model) return null;
260
+ return {
261
+ usage: totalTokens ? { totalTokens, authoritativeTotalTokens: true, source: "codex-state-tokens-used" } : undefined,
262
+ modelUsage: model ? [{ model, source: "codex-state" }] : undefined
263
+ };
264
+ }
265
+
266
+ function codexSubagentChildrenByParent(threads) {
267
+ const byParent = new Map();
268
+ for (const thread of threads || []) {
269
+ if (!thread?.parentThreadId) continue;
270
+ const children = byParent.get(thread.parentThreadId) || [];
271
+ children.push(thread);
272
+ byParent.set(thread.parentThreadId, children);
273
+ }
274
+ for (const children of byParent.values()) {
275
+ children.sort((a, b) => {
276
+ const time = String(a.createdAt || a.updatedAt || "").localeCompare(String(b.createdAt || b.updatedAt || ""));
277
+ return time || String(a.id || "").localeCompare(String(b.id || ""));
278
+ });
279
+ }
280
+ return byParent;
281
+ }
282
+
190
283
  function importClaudeDesktopProvider(provider, since, options = {}, env = process.env) {
191
284
  const state = loadImportState(env);
192
285
  const archived = archivedSessionKeys(env);
@@ -268,6 +361,7 @@ function importCodexProvider(provider, since, options = {}, env = process.env) {
268
361
  if (options.codexSource) return thread.source === options.codexSource;
269
362
  return ["cli", "vscode"].includes(thread.source);
270
363
  });
364
+ const codexSubagentChildren = codexSubagentChildrenByParent(threads);
271
365
  if (!threads.length && options.codexSource && options.codexSource !== "cli") {
272
366
  return { provider, discovered: 0, candidates: 0, imported: 0, skipped: 0, errors: [], details: {} };
273
367
  }
@@ -277,7 +371,7 @@ function importCodexProvider(provider, since, options = {}, env = process.env) {
277
371
  const archived = archivedSessionKeys(env);
278
372
  const candidates = threads
279
373
  .filter((thread) => thread.rolloutPath)
280
- .filter((thread) => !since || new Date(thread.updatedAt || thread.createdAt) >= since)
374
+ .filter((thread) => !since || new Date(codexThreadImportUpdatedAt(thread, codexSubagentChildren.get(thread.id) || [])) >= since)
281
375
  .sort((a, b) => a.rolloutPath.localeCompare(b.rolloutPath));
282
376
  const summary = {
283
377
  provider,
@@ -299,7 +393,13 @@ function importCodexProvider(provider, since, options = {}, env = process.env) {
299
393
  continue;
300
394
  }
301
395
  const sourceType = codexThreadSourceType(thread);
302
- const fingerprint = `${fingerprintPrefix(sourceType)}:${fileFingerprint(thread.rolloutPath, stat)}:${codexSupplementFingerprint(thread)}`;
396
+ const children = codexSubagentChildren.get(thread.id) || [];
397
+ const fingerprint = [
398
+ `${fingerprintPrefix(sourceType)}:${fileFingerprint(thread.rolloutPath, stat)}`,
399
+ codexSupplementFingerprint(thread),
400
+ codexThreadMetadataFingerprint(thread),
401
+ codexSubagentRunsFingerprint(children)
402
+ ].filter(Boolean).join(":");
303
403
  if (alreadyImported(state, thread.id, fingerprint, archived, provider)) {
304
404
  summary.skipped++;
305
405
  reportProgress(options, summary, index + 1, thread.rolloutPath);
@@ -313,10 +413,7 @@ function importCodexProvider(provider, since, options = {}, env = process.env) {
313
413
  reportProgress(options, summary, index + 1, thread.rolloutPath);
314
414
  continue;
315
415
  }
316
- parsed.messages.push(...codexSupplementaryMessages(thread, parsed.endedAt || thread.updatedAt));
317
- parsed.messages = dedupeAdjacentMessages(parsed.messages).sort((a, b) => String(a.timestamp || "").localeCompare(String(b.timestamp || "")));
318
- parsed.startedAt = parsed.messages[0]?.timestamp || parsed.startedAt;
319
- parsed.endedAt = parsed.messages[parsed.messages.length - 1]?.timestamp || parsed.endedAt;
416
+ parsed = finalizeCodexParsedThread(thread, parsed);
320
417
  if (!parsed.messages.length) {
321
418
  state.files[fingerprint] = { skipped: true, reason: "no messages", at: new Date().toISOString() };
322
419
  summary.skipped++;
@@ -338,6 +435,8 @@ function importCodexProvider(provider, since, options = {}, env = process.env) {
338
435
  reportProgress(options, summary, index + 1, thread.rolloutPath);
339
436
  continue;
340
437
  }
438
+ const codexSubagentRuns = codexSubagentRunImportContext(thread, children, env);
439
+ const codexSubagentRun = codexSubagentSessionSummary(thread, parsed, env);
341
440
  if (!options.dryRun) {
342
441
  writeSession(
343
442
  {
@@ -350,9 +449,12 @@ function importCodexProvider(provider, since, options = {}, env = process.env) {
350
449
  startedAt: parsed.startedAt || thread.createdAt,
351
450
  endedAt: parsed.endedAt || thread.updatedAt,
352
451
  sourcePath: thread.rolloutPath,
353
- sourceFiles: codexSourceFiles(thread, env),
452
+ sourceFiles: codexSourceFiles(thread, env, children),
354
453
  sourceType,
355
- title: thread.title || parsed.title
454
+ title: codexSessionTitleForImport(thread, parsed),
455
+ sessionSummary: mergeSessionSummaries(parsed.sessionSummary, codexSubagentRuns?.sessionSummary, codexSubagentRun?.sessionSummary),
456
+ conversationKind: thread.isCodexSubagent ? "codex_subagent" : undefined,
457
+ parentComposerId: thread.parentThreadId || undefined
356
458
  },
357
459
  env
358
460
  );
@@ -616,7 +718,11 @@ function parseAgentJsonl(file, provider) {
616
718
  event.payload?.session_id,
617
719
  event.sessionId
618
720
  );
619
- if (!title) {
721
+ const codexThreadTitle = provider === "codex" ? codexThreadNameFromEvent(event) : "";
722
+ if (codexThreadTitle) {
723
+ title = codexThreadTitle;
724
+ titleSource = "thread-name";
725
+ } else if (!title) {
620
726
  const sourceTitle = firstString(event.title, event.conversation_title, event.payload?.title);
621
727
  const aiTitle = firstString(event.aiTitle, event.ai_title);
622
728
  if (sourceTitle) {
@@ -953,18 +1059,48 @@ function claudeWorktreeParentRepo(provider, cwd) {
953
1059
  }
954
1060
 
955
1061
  function inferredJsonlSessionTitle(provider, messages) {
956
- if (!isClaudeJsonlProvider(provider)) return "";
1062
+ if (!isClaudeJsonlProvider(provider) && provider !== "codex") return "";
957
1063
  const firstUser = (messages || []).find((message) => message.role === "user" && !message.metadata?.providerGenerated);
958
1064
  return titleFromPrompt(firstUser?.content);
959
1065
  }
960
1066
 
961
1067
  function titleFromPrompt(value) {
962
- const cleaned = firstLine(value).replace(/\s+/g, " ").trim();
1068
+ const cleaned = cleanPromptTitleLine(promptTitleLine(value));
963
1069
  if (!cleaned) return "";
964
1070
  const max = 96;
965
1071
  return cleaned.length > max ? `${cleaned.slice(0, max - 1).trimEnd()}…` : cleaned;
966
1072
  }
967
1073
 
1074
+ function promptTitleLine(value) {
1075
+ const lines = String(value || "")
1076
+ .split(/\r?\n/)
1077
+ .map((line) => line.trim())
1078
+ .filter(Boolean);
1079
+ if (!lines.length) return "";
1080
+ if (isAgentlogRecallSkillLine(lines[0]) && lines.length > 1) {
1081
+ const candidate = lines.slice(1).find((line) => cleanPromptTitleLine(line) && !lowSignalPromptTitleLine(line));
1082
+ if (candidate) return candidate;
1083
+ }
1084
+ return lines[0];
1085
+ }
1086
+
1087
+ function cleanPromptTitleLine(value) {
1088
+ return String(value || "")
1089
+ .replace(/\[\$[^\]\n]+\]\([^)]+\)\s*/g, "")
1090
+ .replace(/\[([^\]\n]+)\]\([^)]+\)/g, "$1")
1091
+ .replace(/\s+/g, " ")
1092
+ .trim();
1093
+ }
1094
+
1095
+ function isAgentlogRecallSkillLine(value) {
1096
+ return /^\[\$agentlog-recall\]\([^)]+\)/i.test(String(value || "").trim());
1097
+ }
1098
+
1099
+ function lowSignalPromptTitleLine(value) {
1100
+ const cleaned = cleanPromptTitleLine(value);
1101
+ return /^#/.test(cleaned) || /^<[^>]+>/.test(cleaned) || /^['"]?\/[^'"]+['"]?$/.test(cleaned);
1102
+ }
1103
+
968
1104
  function isClaudeJsonlProvider(provider) {
969
1105
  return provider === "claude_code" || provider === "claude_sdk";
970
1106
  }
@@ -1230,21 +1366,37 @@ function applyCodexTokenCount(event, provider, messages, context) {
1230
1366
  if (String(payload.type || event.type || "").toLowerCase() !== "token_count") return false;
1231
1367
  const usage = codexTokenUsage(payload);
1232
1368
  if (!usage) return true;
1233
- const previous = context.tokenUsage || { input: 0, output: 0 };
1234
- const inputDelta = previous.input || previous.output ? Math.max(0, usage.input - previous.input) : usage.input;
1235
- const outputDelta = previous.input || previous.output ? Math.max(0, usage.output - previous.output) : usage.output;
1369
+ const previous = context.tokenUsage || { input: 0, output: 0, cachedInput: 0, reasoningOutput: 0, total: 0 };
1370
+ const hasPrevious = codexTokenUsageHasProgress(previous);
1371
+ const inputDelta = hasPrevious ? Math.max(0, usage.input - previous.input) : usage.input;
1372
+ const outputDelta = hasPrevious ? Math.max(0, usage.output - previous.output) : usage.output;
1373
+ const cacheInputDelta = hasPrevious ? Math.max(0, usage.cachedInput - previous.cachedInput) : usage.cachedInput;
1374
+ const reasoningOutputDelta = hasPrevious ? Math.max(0, usage.reasoningOutput - previous.reasoningOutput) : usage.reasoningOutput;
1375
+ const totalDelta = usage.total
1376
+ ? (hasPrevious ? Math.max(0, usage.total - previous.total) : usage.total)
1377
+ : inputDelta + outputDelta;
1236
1378
  context.tokenUsage = usage;
1237
1379
  const target = [...messages].reverse().find((message) => message.role === "assistant");
1238
1380
  if (!target) return true;
1381
+ const existingUsage = target.metadata?.usage && typeof target.metadata.usage === "object" ? target.metadata.usage : {};
1382
+ const freshInputDelta = Math.max(0, inputDelta - cacheInputDelta);
1383
+ const nextUsage = compactMetadata({
1384
+ ...existingUsage,
1385
+ inputTokens: addTokenNumbers(existingUsage.inputTokens, freshInputDelta),
1386
+ outputTokens: addTokenNumbers(existingUsage.outputTokens, outputDelta),
1387
+ cacheInputTokens: addTokenNumbers(existingUsage.cacheInputTokens, cacheInputDelta),
1388
+ reasoningOutputTokens: addTokenNumbers(existingUsage.reasoningOutputTokens, reasoningOutputDelta),
1389
+ reasoningOutputTokensIncludedInOutput: reasoningOutputDelta || existingUsage.reasoningOutputTokensIncludedInOutput ? true : undefined,
1390
+ totalTokens: addTokenNumbers(existingUsage.totalTokens, totalDelta || freshInputDelta + cacheInputDelta + outputDelta),
1391
+ totalInputTokens: usage.input,
1392
+ totalOutputTokens: usage.output,
1393
+ totalCacheInputTokens: usage.cachedInput || undefined,
1394
+ totalReasoningOutputTokens: usage.reasoningOutput || undefined
1395
+ });
1239
1396
  target.metadata = {
1240
1397
  ...(target.metadata || {}),
1241
1398
  provider,
1242
- usage: {
1243
- inputTokens: inputDelta,
1244
- outputTokens: outputDelta,
1245
- totalInputTokens: usage.input,
1246
- totalOutputTokens: usage.output
1247
- }
1399
+ usage: nextUsage
1248
1400
  };
1249
1401
  return true;
1250
1402
  }
@@ -1260,11 +1412,50 @@ function codexTokenUsage(payload) {
1260
1412
  for (const item of candidates) {
1261
1413
  const input = Number(item?.input_tokens ?? item?.inputTokens ?? item?.prompt_tokens ?? item?.promptTokens);
1262
1414
  const output = Number(item?.output_tokens ?? item?.outputTokens ?? item?.completion_tokens ?? item?.completionTokens);
1263
- if (Number.isFinite(input) && Number.isFinite(output)) return { input, output };
1415
+ if (Number.isFinite(input) && Number.isFinite(output)) {
1416
+ const cachedInput = Number(
1417
+ item?.cached_input_tokens ??
1418
+ item?.cachedInputTokens ??
1419
+ item?.cache_read_input_tokens ??
1420
+ item?.cacheReadInputTokens ??
1421
+ item?.cacheInputTokens ??
1422
+ item?.prompt_tokens_details?.cached_tokens ??
1423
+ item?.promptTokensDetails?.cachedTokens
1424
+ );
1425
+ const reasoningOutput = Number(
1426
+ item?.reasoning_output_tokens ??
1427
+ item?.reasoningOutputTokens ??
1428
+ item?.reasoning_tokens ??
1429
+ item?.reasoningTokens ??
1430
+ item?.completion_tokens_details?.reasoning_tokens ??
1431
+ item?.completionTokensDetails?.reasoningTokens ??
1432
+ item?.output_tokens_details?.reasoning_tokens ??
1433
+ item?.outputTokensDetails?.reasoningTokens
1434
+ );
1435
+ const total = Number(item?.total_tokens ?? item?.totalTokens ?? item?.totalTokenCount ?? item?.total_token_count ?? item?.total);
1436
+ return {
1437
+ input,
1438
+ output,
1439
+ cachedInput: Number.isFinite(cachedInput) && cachedInput > 0 ? cachedInput : 0,
1440
+ reasoningOutput: Number.isFinite(reasoningOutput) && reasoningOutput > 0 ? reasoningOutput : 0,
1441
+ total: Number.isFinite(total) && total > 0 ? total : input + output
1442
+ };
1443
+ }
1264
1444
  }
1265
1445
  return null;
1266
1446
  }
1267
1447
 
1448
+ function codexTokenUsageHasProgress(usage) {
1449
+ return Boolean((usage?.input || 0) || (usage?.output || 0) || (usage?.cachedInput || 0) || (usage?.reasoningOutput || 0) || (usage?.total || 0));
1450
+ }
1451
+
1452
+ function addTokenNumbers(left, right) {
1453
+ const a = Number(left || 0);
1454
+ const b = Number(right || 0);
1455
+ const sum = (Number.isFinite(a) && a > 0 ? a : 0) + (Number.isFinite(b) && b > 0 ? b : 0);
1456
+ return sum || undefined;
1457
+ }
1458
+
1268
1459
  function extractCodexSpecialMessage(event, provider, context = {}) {
1269
1460
  if (provider !== "codex") return null;
1270
1461
  const item = event?.payload || event?.item || event?.data || event;
@@ -1764,7 +1955,17 @@ function extractText(value, depth = 0) {
1764
1955
 
1765
1956
  function importWebChat(providerInput, file, options = {}, env = process.env) {
1766
1957
  const provider = canonicalWebProvider(providerInput);
1767
- const source = readExportBundle(file);
1958
+ reportWebImportProgress(options, provider, {
1959
+ current: 0,
1960
+ total: 0,
1961
+ message: "reading export"
1962
+ });
1963
+ const source = readExportBundle(file, provider);
1964
+ reportWebImportProgress(options, provider, {
1965
+ current: 0,
1966
+ total: source.entries.length || 0,
1967
+ message: source.entries.length ? `parsed ${source.entries.length} export files` : "parsed export"
1968
+ });
1768
1969
  const sourceAccountId = options.accountId || inferWebSourceAccountId(provider, source);
1769
1970
  let accountId = options.accountId || sourceAccountId;
1770
1971
  const existing = accountId ? getWebAccount(provider, accountId, env) : null;
@@ -1782,6 +1983,11 @@ function importWebChat(providerInput, file, options = {}, env = process.env) {
1782
1983
  displayName: options.displayName || existing?.displayName || inferredUsername,
1783
1984
  sourceAccountId
1784
1985
  }, env);
1986
+ reportWebImportProgress(options, provider, {
1987
+ current: 0,
1988
+ total: source.entries.length || 0,
1989
+ message: "normalizing conversations"
1990
+ });
1785
1991
  const normalized = normalizeWebConversations(provider, source, account);
1786
1992
  const conversations = normalized.conversations;
1787
1993
  const summary = {
@@ -1797,19 +2003,39 @@ function importWebChat(providerInput, file, options = {}, env = process.env) {
1797
2003
  };
1798
2004
  const state = loadImportState(env);
1799
2005
  const archived = archivedSessionKeys(env);
1800
- const sharedRaw = options.dryRun ? null : ensureSharedWebExportRaw(provider, source, account, env);
2006
+ reportWebImportProgress(options, provider, {
2007
+ current: 0,
2008
+ total: conversations.length,
2009
+ message: `found ${conversations.length} conversations`
2010
+ });
2011
+ const sharedRaw = options.dryRun ? null : ensureSharedWebExportRaw(provider, source, account, env, options);
1801
2012
 
1802
- for (const conversation of conversations) {
2013
+ conversations.forEach((conversation, index) => {
2014
+ const current = index + 1;
1803
2015
  if (!conversation.messages.length) {
1804
2016
  summary.skipped++;
1805
- continue;
2017
+ reportWebImportProgress(options, provider, {
2018
+ current,
2019
+ total: conversations.length,
2020
+ imported: summary.imported,
2021
+ skipped: summary.skipped,
2022
+ errors: summary.errors.length
2023
+ });
2024
+ return;
1806
2025
  }
1807
2026
  const sourceType = conversation.sourceType || webConversationSourceType(provider, conversation);
1808
2027
  const sessionId = webConversationSessionId(provider, account.accountId, conversation.id);
1809
2028
  const fingerprint = webConversationFingerprint(sourceType, account.accountId, conversation);
1810
2029
  if (alreadyImported(state, sessionId, fingerprint, archived, provider)) {
1811
2030
  summary.skipped++;
1812
- continue;
2031
+ reportWebImportProgress(options, provider, {
2032
+ current,
2033
+ total: conversations.length,
2034
+ imported: summary.imported,
2035
+ skipped: summary.skipped,
2036
+ errors: summary.errors.length
2037
+ });
2038
+ return;
1813
2039
  }
1814
2040
  const scopeCanonical = webConversationScope(provider, account.accountId, conversation.projectPath);
1815
2041
  const displayPath = webConversationDisplayPath(account.displayName, conversation.projectPath);
@@ -1855,12 +2081,36 @@ function importWebChat(providerInput, file, options = {}, env = process.env) {
1855
2081
  archived.add(archiveSessionKey(provider, sessionId));
1856
2082
  }
1857
2083
  summary.imported++;
1858
- }
2084
+ reportWebImportProgress(options, provider, {
2085
+ current,
2086
+ total: conversations.length,
2087
+ imported: summary.imported,
2088
+ skipped: summary.skipped,
2089
+ errors: summary.errors.length
2090
+ });
2091
+ });
1859
2092
 
1860
2093
  if (!options.dryRun) saveImportState(state, env);
2094
+ reportWebImportProgress(options, provider, {
2095
+ current: conversations.length,
2096
+ total: conversations.length,
2097
+ imported: summary.imported,
2098
+ skipped: summary.skipped,
2099
+ errors: summary.errors.length,
2100
+ message: `${options.dryRun ? "dry run complete" : "import complete"}: imported=${summary.imported} skipped=${summary.skipped}`
2101
+ });
1861
2102
  return summary;
1862
2103
  }
1863
2104
 
2105
+ function reportWebImportProgress(options, provider, event) {
2106
+ if (typeof options.onProgress !== "function") return;
2107
+ options.onProgress({
2108
+ kind: "import",
2109
+ provider: providerLabelForWeb(provider),
2110
+ ...event
2111
+ });
2112
+ }
2113
+
1864
2114
  function importWindsurfTrajectoryExport(target, options = {}, env = process.env) {
1865
2115
  const since = parseSince(options.since || "all");
1866
2116
  return importStructuredProvider(
@@ -1880,7 +2130,8 @@ function readExportJson(file) {
1880
2130
  return bundle.entries.map((entry) => entry.data);
1881
2131
  }
1882
2132
 
1883
- function readExportBundle(file) {
2133
+ function readExportBundle(file, provider = "") {
2134
+ if (Array.isArray(file)) return readExportBundleList(file, provider);
1884
2135
  const resolved = path.resolve(file);
1885
2136
  let stat;
1886
2137
  try {
@@ -1888,31 +2139,96 @@ function readExportBundle(file) {
1888
2139
  } catch (error) {
1889
2140
  throw exportAccessError(resolved, error);
1890
2141
  }
1891
- const entries = stat.isDirectory() ? readExportFolder(resolved) : readExportFile(resolved);
2142
+ const bundle = stat.isDirectory()
2143
+ ? readExportFolder(resolved, provider)
2144
+ : { entries: readExportFile(resolved, provider), rawFiles: [resolved] };
2145
+ const entries = bundle.entries;
1892
2146
  if (!entries.length) {
1893
- throw new Error(`${file} did not contain readable JSON, JSONL, or NDJSON export files. If this is an external path such as Downloads, Desktop, Documents, iCloud Drive, or an attached volume, make sure the terminal app running agentlog has permission to read it, or import the original .zip file.`);
2147
+ throw new Error(`${compactExportPath(file)} did not contain readable JSON, JSONL, NDJSON, or recognized nested export ZIP files. If this is an external path such as Downloads, Desktop, Documents, iCloud Drive, or an attached volume, make sure the terminal app running agentlog has permission to read it, unzip the relevant conversation part ZIPs, or copy the export into a readable folder and rerun.`);
1894
2148
  }
1895
2149
  return {
1896
2150
  root: resolved,
1897
2151
  kind: stat.isDirectory() ? "folder" : path.extname(resolved).toLowerCase() === ".zip" ? "zip" : "json",
1898
2152
  entries,
2153
+ rawFiles: bundle.rawFiles || [],
2154
+ fingerprint: hashId(entries.map((entry) => `${entry.name}:${entry.sha256}`).sort().join("\n"))
2155
+ };
2156
+ }
2157
+
2158
+ function readExportBundleList(files, provider = "") {
2159
+ const resolved = files.map((file) => path.resolve(file)).filter(Boolean);
2160
+ if (!resolved.length) throw new Error("no export paths provided");
2161
+ const bundles = resolved.map((file) => readExportBundle(file, provider));
2162
+ const entries = [];
2163
+ const rawFiles = [];
2164
+ for (const bundle of bundles) {
2165
+ const prefix = exportBundleMergePrefix(bundle.root, resolved);
2166
+ for (const entry of bundle.entries || []) entries.push(prefixExportEntry(entry, prefix));
2167
+ for (const rawFile of bundle.rawFiles || []) rawFiles.push(prefixExportRawFile(rawFile, prefix));
2168
+ }
2169
+ return {
2170
+ root: resolved.join(path.delimiter),
2171
+ kind: "multi",
2172
+ entries: entries.sort((a, b) => a.name.localeCompare(b.name)),
2173
+ rawFiles: rawFiles.sort((a, b) => a.name.localeCompare(b.name)),
1899
2174
  fingerprint: hashId(entries.map((entry) => `${entry.name}:${entry.sha256}`).sort().join("\n"))
1900
2175
  };
1901
2176
  }
1902
2177
 
1903
- function readExportFolder(root) {
2178
+ function exportBundleMergePrefix(root, roots) {
2179
+ if (roots.length <= 1) return "";
2180
+ const base = safeArchiveRelativePath(path.basename(root)) || hashId(root);
2181
+ return `${base}/`;
2182
+ }
2183
+
2184
+ function prefixExportEntry(entry, prefix) {
2185
+ if (!prefix) return entry;
2186
+ return {
2187
+ ...entry,
2188
+ name: `${prefix}${entry.name}`
2189
+ };
2190
+ }
2191
+
2192
+ function prefixExportRawFile(rawFile, prefix) {
2193
+ if (!prefix) return rawFile;
2194
+ return {
2195
+ ...rawFile,
2196
+ name: `${prefix}${rawFile.name}`
2197
+ };
2198
+ }
2199
+
2200
+ function readExportFolder(root, provider = "") {
1904
2201
  const entries = [];
2202
+ const rawFiles = [];
1905
2203
  collectExportFiles(root, (file) => {
1906
- if (!isExportDataFile(file)) return;
2204
+ const relativeName = path.relative(root, file).split(path.sep).join("/");
2205
+ if (!ignoredExportRawFile(relativeName)) rawFiles.push(exportRawFile(root, file, relativeName));
2206
+ if (nestedExportZipEntryName(relativeName, provider)) {
2207
+ const zipPrefix = `${relativeName.replace(/\.zip$/i, "")}/`;
2208
+ entries.push(...readZipExportEntries(file, provider, {
2209
+ entryPrefix: zipPrefix,
2210
+ sourcePrefix: `${file}#`
2211
+ }));
2212
+ return;
2213
+ }
2214
+ const namedJson = isExportEntryName(file);
2215
+ if (!namedJson && !looksLikeJsonPayload(file)) return;
1907
2216
  let text;
1908
2217
  try {
1909
2218
  text = readExportText(file);
1910
2219
  } catch (error) {
1911
2220
  throw exportAccessError(root, error, file);
1912
2221
  }
1913
- entries.push(exportEntry(path.relative(root, file).split(path.sep).join("/"), text, file));
2222
+ try {
2223
+ entries.push(exportEntry(relativeName, text, file));
2224
+ } catch (error) {
2225
+ if (namedJson) throw error;
2226
+ }
1914
2227
  });
1915
- return entries.sort((a, b) => a.name.localeCompare(b.name));
2228
+ return {
2229
+ entries: entries.sort((a, b) => a.name.localeCompare(b.name)),
2230
+ rawFiles: rawFiles.sort((a, b) => a.name.localeCompare(b.name))
2231
+ };
1916
2232
  }
1917
2233
 
1918
2234
  function collectExportFiles(root, visit) {
@@ -1934,26 +2250,88 @@ function collectExportFiles(root, visit) {
1934
2250
 
1935
2251
  function exportAccessError(root, error, target = root) {
1936
2252
  const code = error?.code ? ` (${error.code})` : "";
1937
- const message = error?.message || String(error || "unknown error");
2253
+ const message = compactExportPath(error?.message || String(error || "unknown error"));
1938
2254
  const hint = "Grant Full Disk Access to Terminal/iTerm or the Node process running agentlog, import the original .zip file, or copy the export into a readable folder and rerun.";
1939
- const wrapped = new Error(`Cannot read web export path ${target}${code}: ${message}. ${hint}`);
2255
+ const wrapped = new Error(`Cannot read web export path ${compactExportPath(target)}${code}: ${message}. ${hint}`);
1940
2256
  wrapped.code = "AGENTLOG_WEB_EXPORT_ACCESS";
1941
2257
  wrapped.causeCode = error?.code || "";
1942
2258
  return wrapped;
1943
2259
  }
1944
2260
 
1945
- function readExportFile(file) {
2261
+ function compactExportPath(value) {
2262
+ let text = String(value || "");
2263
+ const home = os.homedir();
2264
+ if (home) {
2265
+ const escaped = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2266
+ text = text.replace(new RegExp(escaped, "g"), "~");
2267
+ }
2268
+ return text.replace(/\/Users\/[^/\s'"]+/g, "~");
2269
+ }
2270
+
2271
+ function readExportFile(file, provider = "") {
1946
2272
  if (path.extname(file).toLowerCase() !== ".zip") {
1947
2273
  return [exportEntry(path.basename(file), readExportText(file), file)];
1948
2274
  }
2275
+ return readZipExportEntries(file, provider);
2276
+ }
2277
+
2278
+ function readZipExportEntries(file, provider = "", context = {}) {
1949
2279
  const list = spawnSync("unzip", ["-Z1", file], { encoding: "utf8" });
1950
2280
  if (list.status !== 0) throw new Error("reading zip exports requires the `unzip` command");
1951
- const names = list.stdout.split(/\r?\n/).filter(isExportEntryName);
1952
- return names.map((name) => {
1953
- const content = spawnSync("unzip", ["-p", file, name], { encoding: "utf8", maxBuffer: 1024 * 1024 * 200 });
1954
- if (content.status !== 0) throw new Error(`failed to read ${name} from zip`);
1955
- return exportEntry(name, content.stdout, `${file}#${name}`);
1956
- }).sort((a, b) => a.name.localeCompare(b.name));
2281
+ const names = list.stdout.split(/\r?\n/).filter(Boolean);
2282
+ const entryPrefix = context.entryPrefix || "";
2283
+ const sourcePrefix = context.sourcePrefix || `${file}#`;
2284
+ const depth = context.depth || 0;
2285
+ const entries = [];
2286
+ for (const name of names.filter(isExportEntryName)) {
2287
+ const content = readZipEntryText(file, name);
2288
+ entries.push(exportEntry(`${entryPrefix}${name}`, content, `${sourcePrefix}${name}`));
2289
+ }
2290
+ if (depth < 2) {
2291
+ for (const name of names.filter((entry) => nestedExportZipEntryName(entry, provider))) {
2292
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentlog-nested-export-"));
2293
+ const tempZip = path.join(tempDir, safeArchiveRelativePath(path.basename(name)) || "nested.zip");
2294
+ try {
2295
+ extractZipEntryToFile(file, name, tempZip);
2296
+ const nestedPrefix = `${entryPrefix}${name.replace(/\.zip$/i, "")}/`;
2297
+ entries.push(...readZipExportEntries(tempZip, provider, {
2298
+ entryPrefix: nestedPrefix,
2299
+ sourcePrefix: `${sourcePrefix}${name}#`,
2300
+ depth: depth + 1
2301
+ }));
2302
+ } finally {
2303
+ fs.rmSync(tempDir, { recursive: true, force: true });
2304
+ }
2305
+ }
2306
+ }
2307
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
2308
+ }
2309
+
2310
+ function readZipEntryText(file, name) {
2311
+ const content = spawnSync("unzip", ["-p", file, name], { encoding: "utf8", maxBuffer: EXPORT_ZIP_ENTRY_MAX_BUFFER });
2312
+ if (content.status !== 0) throw new Error(`failed to read ${name} from zip`);
2313
+ return content.stdout;
2314
+ }
2315
+
2316
+ function extractZipEntryToFile(file, name, target) {
2317
+ ensureDir(path.dirname(target));
2318
+ const fd = fs.openSync(target, "w", 0o600);
2319
+ try {
2320
+ const result = spawnSync("unzip", ["-p", file, name], { stdio: ["ignore", fd, "pipe"] });
2321
+ if (result.status !== 0) {
2322
+ const message = result.stderr ? result.stderr.toString("utf8").trim() : "";
2323
+ throw new Error(`failed to extract ${name} from zip${message ? `: ${message}` : ""}`);
2324
+ }
2325
+ } finally {
2326
+ fs.closeSync(fd);
2327
+ }
2328
+ }
2329
+
2330
+ function nestedExportZipEntryName(name, provider = "") {
2331
+ const text = String(name || "");
2332
+ if (!/\.zip$/i.test(text) || ignoredExportRawFile(text)) return false;
2333
+ if (provider === "chatgpt") return openAiConversationExportPath(text);
2334
+ return /(^|\/)Conversations__[^/]*\.zip$/i.test(text);
1957
2335
  }
1958
2336
 
1959
2337
  function exportEntry(name, text, sourcePath) {
@@ -1969,6 +2347,16 @@ function exportEntry(name, text, sourcePath) {
1969
2347
  };
1970
2348
  }
1971
2349
 
2350
+ function exportRawFile(root, file, name = path.relative(root, file).split(path.sep).join("/")) {
2351
+ const stat = safeStat(file);
2352
+ return {
2353
+ name,
2354
+ sourcePath: file,
2355
+ size: stat?.size || 0,
2356
+ mtime: stat?.mtimeMs ? new Date(stat.mtimeMs).toISOString() : ""
2357
+ };
2358
+ }
2359
+
1972
2360
  function readExportText(file) {
1973
2361
  const buffer = fs.readFileSync(file);
1974
2362
  const bytes = file.toLowerCase().endsWith(".gz") ? zlib.gunzipSync(buffer) : buffer;
@@ -1999,11 +2387,6 @@ function parseJsonLines(text, name = "", options = {}) {
1999
2387
  return rows;
2000
2388
  }
2001
2389
 
2002
- function isExportDataFile(file) {
2003
- if (isExportEntryName(file)) return true;
2004
- return looksLikeJsonPayload(file);
2005
- }
2006
-
2007
2390
  function isExportEntryName(name) {
2008
2391
  const lower = String(name || "").toLowerCase();
2009
2392
  return /\.(json|jsonl|ndjson)(\.gz)?$/.test(lower);
@@ -2031,6 +2414,12 @@ function looksLikeJsonPayload(file) {
2031
2414
  }
2032
2415
  }
2033
2416
 
2417
+ function ignoredExportRawFile(name) {
2418
+ return String(name || "")
2419
+ .split(/[\\/]+/)
2420
+ .some((part) => part === ".DS_Store" || part === "__MACOSX" || part.startsWith("._"));
2421
+ }
2422
+
2034
2423
  function normalizeWebConversations(provider, source, account) {
2035
2424
  if (provider === "chatgpt") return { conversations: normalizeChatGptExport(source, account) };
2036
2425
  return { conversations: normalizeClaudeWebExport(source, account) };
@@ -2041,7 +2430,7 @@ function normalizeChatGptExport(source) {
2041
2430
  return entries.flatMap((entry) => chatgptRawConversations(entry.data).map((conversation, index) => {
2042
2431
  const id = firstString(conversation.id, conversation.uuid, conversation.conversation_id) || `chatgpt-${hashId(`${entry.name}:${index}`)}`;
2043
2432
  const title = firstString(conversation.title, conversation.name, conversation.summary) || "ChatGPT conversation";
2044
- const messages = chatgptMessages(conversation).filter((message) => message.content.trim());
2433
+ const messages = chatgptMessages(conversation).filter(chatgptMessageHasDisplayContent);
2045
2434
  const sorted = sortConversationMessages(messages);
2046
2435
  return {
2047
2436
  id,
@@ -2053,34 +2442,41 @@ function normalizeChatGptExport(source) {
2053
2442
  projectPath: "",
2054
2443
  entryPath: entry.name,
2055
2444
  sourceType: "chatgpt-export",
2056
- kind: "conversation"
2445
+ kind: "conversation",
2446
+ sessionSummary: chatgptSessionSummary(conversation)
2057
2447
  };
2058
2448
  }));
2059
2449
  }
2060
2450
 
2061
2451
  function chatConversationEntries(source) {
2062
- const preferred = source.entries.filter((entry) => /(^|\/)conversations\.json$/i.test(entry.name));
2063
- return preferred.length ? preferred : source.entries.filter((entry) => Array.isArray(entry.data) || Array.isArray(entry.data?.conversations));
2452
+ const preferred = source.entries.filter((entry) => /(^|\/)conversations(?:-\d+)?\.json$/i.test(entry.name));
2453
+ return preferred.length ? preferred : source.entries.filter((entry) => chatgptRawConversations(entry.data).length);
2064
2454
  }
2065
2455
 
2066
2456
  function chatgptRawConversations(data) {
2067
- if (Array.isArray(data)) return data;
2068
- if (Array.isArray(data?.conversations)) return data.conversations;
2457
+ if (Array.isArray(data)) return data.filter(chatgptLooksLikeConversation);
2458
+ if (Array.isArray(data?.conversations)) return data.conversations.filter(chatgptLooksLikeConversation);
2069
2459
  if (data?.mapping || Array.isArray(data?.messages)) return [data];
2070
2460
  return [];
2071
2461
  }
2072
2462
 
2463
+ function chatgptLooksLikeConversation(value) {
2464
+ return Boolean(value && typeof value === "object" && (value.mapping || Array.isArray(value.messages) || value.conversation_id || value.current_node));
2465
+ }
2466
+
2073
2467
  function chatgptMessages(conversation) {
2074
2468
  if (conversation.mapping && typeof conversation.mapping === "object") {
2075
2469
  const nodes = chatgptMainPathNodes(conversation);
2076
2470
  return nodes.map((node) => node && node.message).filter(Boolean).map((message) => {
2077
2471
  const role = normalizeEventRole(message.author?.role) || "unknown";
2078
- const content = extractChatGptContent(message.content);
2472
+ const content = extractChatGptContent(message);
2473
+ const toolCall = chatgptToolCallFromMessage(message, content);
2474
+ const toolResult = role === "tool" ? chatgptToolResultFromMessage(message, content) : null;
2079
2475
  return {
2080
2476
  role,
2081
- content,
2477
+ content: toolCall ? "" : content,
2082
2478
  timestamp: toIso(message.create_time || message.update_time),
2083
- metadata: chatgptMessageMetadata(message, role, content)
2479
+ metadata: chatgptMessageMetadata(message, role, content, { toolCall, toolResult })
2084
2480
  };
2085
2481
  });
2086
2482
  }
@@ -2104,22 +2500,266 @@ function chatgptMainPathNodes(conversation) {
2104
2500
  );
2105
2501
  }
2106
2502
 
2107
- function extractChatGptContent(content) {
2108
- if (!content) return "";
2109
- if (Array.isArray(content.parts)) return extractText(content.parts);
2110
- if (Array.isArray(content.text)) return extractText(content.text);
2111
- if (typeof content.text === "string") return content.text;
2112
- return extractText(content);
2503
+ function extractChatGptContent(message) {
2504
+ const content = message?.content || {};
2505
+ const parts = [];
2506
+ if (Array.isArray(content.parts)) {
2507
+ for (const part of content.parts) {
2508
+ const text = chatgptContentPartText(part);
2509
+ if (text) parts.push(text);
2510
+ }
2511
+ } else if (Array.isArray(content.text)) {
2512
+ parts.push(extractText(content.text));
2513
+ } else if (typeof content.text === "string") {
2514
+ parts.push(content.text);
2515
+ } else {
2516
+ parts.push(extractText(content));
2517
+ }
2518
+ return parts.filter((part) => String(part || "").trim()).join("\n").trim();
2519
+ }
2520
+
2521
+ function chatgptContentPartText(part) {
2522
+ if (typeof part === "string") return part;
2523
+ if (!part || typeof part !== "object") return extractText(part);
2524
+ const text = extractText(part);
2525
+ if (text) return text;
2526
+ const contentType = firstString(part.content_type, part.type, "asset");
2527
+ const pointer = firstString(part.asset_pointer, part.assetPointer, part.file_id, part.fileId);
2528
+ if (pointer || /image|file|asset/i.test(contentType)) {
2529
+ return "";
2530
+ }
2531
+ return "";
2113
2532
  }
2114
2533
 
2115
- function chatgptMessageMetadata(message, role, content) {
2534
+ function chatgptMessageMetadata(message, role, content, options = {}) {
2535
+ const assetPointers = chatgptAssetPointers(message);
2536
+ const attachments = chatgptNormalizedAttachments(message);
2116
2537
  return webWithUsage({
2117
2538
  source: "chatgpt-export",
2118
2539
  messageId: message.id || undefined,
2119
- model: firstString(message.metadata?.model_slug, message.metadata?.model, message.metadata?.default_model_slug) || undefined
2540
+ model: firstString(message.metadata?.model_slug, message.metadata?.model, message.metadata?.default_model_slug) || undefined,
2541
+ contentType: firstString(message.content?.content_type) || undefined,
2542
+ channel: firstString(message.channel) || undefined,
2543
+ recipient: firstString(message.recipient) || undefined,
2544
+ status: firstString(message.status) || undefined,
2545
+ attachments: chatgptAttachAssetPointers(attachments, assetPointers),
2546
+ assetPointers,
2547
+ toolCalls: options.toolCall ? [options.toolCall] : undefined,
2548
+ toolResult: options.toolResult || undefined
2120
2549
  }, webMessageUsage(message, role, { inputText: content, outputText: content }));
2121
2550
  }
2122
2551
 
2552
+ function chatgptSessionSummary(conversation) {
2553
+ const summary = firstString(conversation.summary);
2554
+ if (!summary) return undefined;
2555
+ return {
2556
+ summary,
2557
+ source: "chatgpt-export",
2558
+ summaryKind: "conversation_summary"
2559
+ };
2560
+ }
2561
+
2562
+ function chatgptMessageHasDisplayContent(message) {
2563
+ if (!message || typeof message !== "object") return false;
2564
+ if (String(message.content || "").trim()) return true;
2565
+ const metadata = message.metadata || {};
2566
+ return Boolean(
2567
+ (Array.isArray(metadata.attachments) && metadata.attachments.length) ||
2568
+ (Array.isArray(metadata.assetPointers) && metadata.assetPointers.length) ||
2569
+ (Array.isArray(metadata.toolCalls) && metadata.toolCalls.length) ||
2570
+ metadata.toolResult
2571
+ );
2572
+ }
2573
+
2574
+ function chatgptToolCallFromMessage(message, content) {
2575
+ const recipient = firstString(message?.recipient);
2576
+ if (!recipient || recipient === "all" || recipient === "assistant") return null;
2577
+ const rawInput = String(content || "").trim();
2578
+ if (!rawInput) return null;
2579
+ const args = parseToolArgsValue(rawInput);
2580
+ const name = chatgptToolName(recipient, args);
2581
+ const summary = summarizeToolArguments(args) || rawInput.slice(0, 240);
2582
+ return compactObject({
2583
+ provider: "chatgpt",
2584
+ name,
2585
+ displayName: toolDisplayName(name),
2586
+ category: chatgptToolCategory(recipient, name),
2587
+ rawCategory: "chatgpt_tool_call",
2588
+ title: toolDisplayName(name),
2589
+ status: "tool_call",
2590
+ argument: summary,
2591
+ rawInputSummary: summary,
2592
+ inputPreview: chatgptToolInputPreview(args, rawInput),
2593
+ arguments: args && typeof args === "object" && !Array.isArray(args) ? args : undefined
2594
+ });
2595
+ }
2596
+
2597
+ function chatgptToolName(recipient, args) {
2598
+ const base = String(recipient || "tool").trim();
2599
+ if (base === "web.run" && args && typeof args === "object" && !Array.isArray(args)) {
2600
+ if (args.search_query || args.searchQuery) return "web.search";
2601
+ if (args.open) return "web.open";
2602
+ if (args.image_query || args.imageQuery) return "web.image_search";
2603
+ if (args.finance) return "web.finance";
2604
+ if (args.weather) return "web.weather";
2605
+ if (args.sports) return "web.sports";
2606
+ if (args.time) return "web.time";
2607
+ }
2608
+ return base || "tool";
2609
+ }
2610
+
2611
+ function chatgptToolCategory(recipient, name) {
2612
+ const text = `${recipient || ""} ${name || ""}`.toLowerCase();
2613
+ if (text.includes("web.")) return "web";
2614
+ return "";
2615
+ }
2616
+
2617
+ function chatgptToolInputPreview(args, rawInput) {
2618
+ if (!args || typeof args !== "object") return rawInput.slice(0, 2000);
2619
+ try {
2620
+ return JSON.stringify(args, null, 2).slice(0, 2000);
2621
+ } catch {
2622
+ return rawInput.slice(0, 2000);
2623
+ }
2624
+ }
2625
+
2626
+ function chatgptToolResultFromMessage(message, content) {
2627
+ const text = String(content || "").trim();
2628
+ if (!text) return null;
2629
+ const parsedFile = chatgptParsedFileToolResult(text);
2630
+ if (parsedFile) return parsedFile;
2631
+ if (/^All the files uploaded by the user have been fully loaded\./i.test(text)) {
2632
+ return compactObject({
2633
+ provider: "chatgpt",
2634
+ kind: "Uploaded files loaded",
2635
+ title: "Uploaded files loaded",
2636
+ category: "read",
2637
+ categoryLabel: "Files",
2638
+ rawCategory: "chatgpt_file_tool",
2639
+ summary: firstLine(text),
2640
+ output: text,
2641
+ lineCount: text.split("\n").length,
2642
+ collapsed: false,
2643
+ status: "completed"
2644
+ });
2645
+ }
2646
+ return null;
2647
+ }
2648
+
2649
+ function chatgptParsedFileToolResult(text) {
2650
+ if (!/<PARSED TEXT FOR PAGE:\s*\d+\s*\/\s*\d+>/i.test(text)) return null;
2651
+ const pageMatches = [...text.matchAll(/<PARSED TEXT FOR PAGE:\s*(\d+)\s*\/\s*(\d+)>/gi)];
2652
+ const pageCount = pageMatches.reduce((max, match) => Math.max(max, Number(match[2]) || 0), 0);
2653
+ const citationMatch = text.match(/\uE200filecite\uE202([^\uE201]+)\uE201/i);
2654
+ const citation = citationMatch ? chatgptCitationText(citationMatch[1]) : "";
2655
+ const detail = [citation, pageCount ? `${pageCount} page${pageCount === 1 ? "" : "s"}` : ""].filter(Boolean).join(" · ");
2656
+ return compactObject({
2657
+ provider: "chatgpt",
2658
+ kind: "Parsed uploaded file",
2659
+ title: "Parsed uploaded file",
2660
+ category: "read",
2661
+ categoryLabel: "Files",
2662
+ rawCategory: "chatgpt_file_tool",
2663
+ summary: detail ? `Parsed uploaded file text · ${detail}` : "Parsed uploaded file text",
2664
+ output: text,
2665
+ lineCount: text.split("\n").length,
2666
+ collapsed: true,
2667
+ status: "completed"
2668
+ });
2669
+ }
2670
+
2671
+ function chatgptCitationText(value) {
2672
+ return String(value || "").split("\uE202").map((part) => part.trim()).filter(Boolean).join(" ");
2673
+ }
2674
+
2675
+ function chatgptNormalizedAttachments(message) {
2676
+ const attachments = Array.isArray(message?.metadata?.attachments) ? message.metadata.attachments : [];
2677
+ const normalized = attachments.map((attachment) => {
2678
+ if (!attachment || typeof attachment !== "object") return null;
2679
+ return compactObject({
2680
+ id: firstString(attachment.id, attachment.file_id, attachment.fileId),
2681
+ name: firstString(attachment.name, attachment.filename, attachment.file_name),
2682
+ mimeType: firstString(attachment.mime_type, attachment.mimeType),
2683
+ size: finiteNumber(attachment.size, attachment.size_bytes, attachment.sizeBytes),
2684
+ width: finiteNumber(attachment.width),
2685
+ height: finiteNumber(attachment.height),
2686
+ source: firstString(attachment.source),
2687
+ libraryFileId: firstString(attachment.library_file_id, attachment.libraryFileId)
2688
+ });
2689
+ }).filter((attachment) => attachment && Object.keys(attachment).length);
2690
+ return normalized.length ? normalized : undefined;
2691
+ }
2692
+
2693
+ function chatgptAssetPointers(message) {
2694
+ const parts = Array.isArray(message?.content?.parts) ? message.content.parts : [];
2695
+ const pointers = parts
2696
+ .filter((part) => part && typeof part === "object" && (part.asset_pointer || part.assetPointer))
2697
+ .map((part) => compactObject({
2698
+ assetPointer: firstString(part.asset_pointer, part.assetPointer),
2699
+ contentType: firstString(part.content_type, part.type),
2700
+ mimeType: firstString(part.mime_type, part.mimeType),
2701
+ width: finiteNumber(part.width),
2702
+ height: finiteNumber(part.height),
2703
+ size: finiteNumber(part.size_bytes, part.sizeBytes)
2704
+ }))
2705
+ .filter((pointer) => Object.keys(pointer).length);
2706
+ return pointers.length ? pointers : undefined;
2707
+ }
2708
+
2709
+ function chatgptAttachAssetPointers(attachments, assetPointers) {
2710
+ if (!Array.isArray(attachments) || !attachments.length) return undefined;
2711
+ if (!Array.isArray(assetPointers) || !assetPointers.length) return attachments;
2712
+ return attachments.map((attachment, index) => {
2713
+ const pointer = chatgptAssetPointerForAttachment(attachment, assetPointers, index, attachments.length);
2714
+ if (!pointer) return attachment;
2715
+ return compactObject({
2716
+ ...attachment,
2717
+ assetPointer: pointer.assetPointer,
2718
+ pointerContentType: pointer.contentType,
2719
+ pointerMimeType: pointer.mimeType
2720
+ });
2721
+ });
2722
+ }
2723
+
2724
+ function chatgptAssetPointerForAttachment(attachment, assetPointers, index, attachmentCount) {
2725
+ const id = normalizeAttachmentToken(attachment?.id || "");
2726
+ if (id) {
2727
+ const match = assetPointers.find((pointer) => normalizeAttachmentToken(pointer.assetPointer || "").endsWith(id));
2728
+ if (match) return match;
2729
+ }
2730
+ if (attachmentCount === assetPointers.length) return assetPointers[index] || null;
2731
+ if (attachmentCount === 1 && assetPointers.length === 1) return assetPointers[0];
2732
+ return null;
2733
+ }
2734
+
2735
+ function normalizeAttachmentToken(value) {
2736
+ return String(value || "")
2737
+ .trim()
2738
+ .toLowerCase()
2739
+ .replace(/^file-service:\/\//, "")
2740
+ .replace(/^sandbox:\/\//, "")
2741
+ .replace(/^attachment:\/\//, "")
2742
+ .replace(/[?#].*$/, "")
2743
+ .split(/[\\/]+/)
2744
+ .filter(Boolean)
2745
+ .pop() || "";
2746
+ }
2747
+
2748
+ function compactObject(input) {
2749
+ const output = {};
2750
+ for (const [key, value] of Object.entries(input || {})) {
2751
+ if (value === undefined || value === null || value === "") continue;
2752
+ if (Array.isArray(value) && !value.length) continue;
2753
+ output[key] = value;
2754
+ }
2755
+ return output;
2756
+ }
2757
+
2758
+ function finiteNumber(...values) {
2759
+ const value = numericValue(...values);
2760
+ return Number.isFinite(value) ? value : undefined;
2761
+ }
2762
+
2123
2763
  function webWithUsage(metadata, usage) {
2124
2764
  if (!usage) return metadata;
2125
2765
  return { ...metadata, usage };
@@ -2711,12 +3351,19 @@ function webConversationSourcePath(source, conversation) {
2711
3351
  }
2712
3352
 
2713
3353
  function inferWebSourceAccountId(provider, source) {
3354
+ const accountMetadataName = provider === "chatgpt"
3355
+ ? /(user|account|profile)/
3356
+ : /(user|account|profile|organization|export|memories)/;
2714
3357
  for (const entry of source.entries) {
2715
3358
  const name = entry.name.toLowerCase();
2716
- if (!/(user|account|profile|organization|export|memories)/.test(name)) continue;
3359
+ if (!accountMetadataName.test(name) || /(^|\/)conversations(?:-\d+)?\.json$/i.test(name)) continue;
2717
3360
  const id = webAccountIdFromData(entry.data);
2718
3361
  if (id) return id;
2719
3362
  }
3363
+ if (provider === "chatgpt") {
3364
+ const id = openAiConversationExportAccountId(source);
3365
+ if (id) return id;
3366
+ }
2720
3367
  if (provider === "claude_web") {
2721
3368
  const match = path.basename(source.root).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
2722
3369
  if (match) return match[0].toLowerCase();
@@ -2724,6 +3371,19 @@ function inferWebSourceAccountId(provider, source) {
2724
3371
  return "";
2725
3372
  }
2726
3373
 
3374
+ function openAiConversationExportAccountId(source) {
3375
+ const values = [
3376
+ source.root,
3377
+ ...(Array.isArray(source.entries) ? source.entries.map((entry) => entry.name) : []),
3378
+ ...(Array.isArray(source.rawFiles) ? source.rawFiles.map((file) => file.name) : [])
3379
+ ];
3380
+ for (const value of values) {
3381
+ const match = String(value || "").match(/Conversations__([A-Za-z0-9]+)-chatgpt-\d+/i);
3382
+ if (match) return match[1];
3383
+ }
3384
+ return "";
3385
+ }
3386
+
2727
3387
  function inferWebUsername(provider, source) {
2728
3388
  for (const entry of source.entries) {
2729
3389
  const name = entry.name.toLowerCase();
@@ -2768,10 +3428,15 @@ function providerLabelForWeb(provider) {
2768
3428
  return provider === "chatgpt" ? "ChatGPT" : "Claude.ai";
2769
3429
  }
2770
3430
 
2771
- function ensureSharedWebExportRaw(provider, source, account, env = process.env) {
3431
+ function ensureSharedWebExportRaw(provider, source, account, env = process.env, options = {}) {
2772
3432
  const root = path.join(archiveRoot(env), "raw", "web-exports", provider, account.accountId, source.fingerprint);
2773
3433
  const manifestPath = path.join(root, "manifest.json");
2774
3434
  if (fs.existsSync(manifestPath)) {
3435
+ reportWebImportProgress(options, provider, {
3436
+ current: 1,
3437
+ total: 1,
3438
+ message: "raw export already preserved"
3439
+ });
2775
3440
  return { root, manifestPath, sha256: source.fingerprint };
2776
3441
  }
2777
3442
  ensureDir(root);
@@ -2779,6 +3444,11 @@ function ensureSharedWebExportRaw(provider, source, account, env = process.env)
2779
3444
  const records = [];
2780
3445
  let containerPath = "";
2781
3446
  if (source.kind === "zip" || source.kind === "json") {
3447
+ reportWebImportProgress(options, provider, {
3448
+ current: 0,
3449
+ total: 1,
3450
+ message: "preserving raw export"
3451
+ });
2782
3452
  const extension = source.kind === "zip" ? ".zip" : path.extname(source.root) || ".json";
2783
3453
  containerPath = path.join(root, `source${extension}`);
2784
3454
  fs.copyFileSync(source.root, containerPath);
@@ -2789,22 +3459,55 @@ function ensureSharedWebExportRaw(provider, source, account, env = process.env)
2789
3459
  size: stat?.size || 0,
2790
3460
  sha256: fileSha256(containerPath)
2791
3461
  });
3462
+ reportWebImportProgress(options, provider, {
3463
+ current: 1,
3464
+ total: 1,
3465
+ message: "preserved raw export"
3466
+ });
2792
3467
  }
2793
- for (const entry of source.entries) {
2794
- let archivedPath = containerPath;
2795
- if (source.kind === "folder" && fs.existsSync(entry.sourcePath)) {
2796
- archivedPath = path.join(root, "entries", safeArchiveRelativePath(entry.name));
3468
+ const rawFiles = webExportRawFilesForProvider(source, provider);
3469
+ const entryBySourcePath = new Map(source.entries.map((entry) => [path.resolve(entry.sourcePath), entry]));
3470
+ const copiedRaw = new Set();
3471
+ if (source.kind === "folder" || source.kind === "multi") {
3472
+ let rawIndex = 0;
3473
+ for (const rawFile of rawFiles) {
3474
+ rawIndex++;
3475
+ if (!rawFile?.sourcePath || !fs.existsSync(rawFile.sourcePath)) continue;
3476
+ const resolved = path.resolve(rawFile.sourcePath);
3477
+ if (copiedRaw.has(resolved)) continue;
3478
+ copiedRaw.add(resolved);
3479
+ const archivedPath = path.join(root, "entries", safeArchiveRelativePath(rawFile.name));
2797
3480
  ensureDir(path.dirname(archivedPath));
2798
- fs.copyFileSync(entry.sourcePath, archivedPath);
3481
+ fs.copyFileSync(rawFile.sourcePath, archivedPath);
3482
+ const stat = safeStat(archivedPath);
3483
+ const parsedEntry = entryBySourcePath.get(resolved);
3484
+ records.push({
3485
+ entryPath: rawFile.name,
3486
+ originalPath: rawFile.sourcePath,
3487
+ archivedPath,
3488
+ size: stat?.size || rawFile.size || 0,
3489
+ mtime: rawFile.mtime || (stat?.mtimeMs ? new Date(stat.mtimeMs).toISOString() : undefined),
3490
+ sha256: parsedEntry?.sha256 || fileSha256(archivedPath),
3491
+ parsed: Boolean(parsedEntry) || undefined
3492
+ });
3493
+ reportWebImportProgress(options, provider, {
3494
+ current: rawIndex,
3495
+ total: rawFiles.length,
3496
+ message: `preserving raw export files: ${rawIndex}/${rawFiles.length}`
3497
+ });
3498
+ }
3499
+ } else {
3500
+ for (const entry of source.entries) {
3501
+ records.push({
3502
+ entryPath: entry.name,
3503
+ originalPath: entry.sourcePath,
3504
+ archivedPath: containerPath,
3505
+ containerPath: containerPath || undefined,
3506
+ size: entry.size,
3507
+ sha256: entry.sha256,
3508
+ parsed: true
3509
+ });
2799
3510
  }
2800
- records.push({
2801
- entryPath: entry.name,
2802
- originalPath: entry.sourcePath,
2803
- archivedPath,
2804
- containerPath: containerPath || undefined,
2805
- size: entry.size,
2806
- sha256: entry.sha256
2807
- });
2808
3511
  }
2809
3512
  writeJson(manifestPath, {
2810
3513
  version: 1,
@@ -2820,6 +3523,20 @@ function ensureSharedWebExportRaw(provider, source, account, env = process.env)
2820
3523
  return { root, manifestPath, sha256: source.fingerprint };
2821
3524
  }
2822
3525
 
3526
+ function webExportRawFilesForProvider(source, provider) {
3527
+ const rawFiles = Array.isArray(source.rawFiles) ? source.rawFiles : [];
3528
+ if (!rawFiles.length) return [];
3529
+ if (provider !== "chatgpt") return rawFiles;
3530
+ const hasOpenAiConversationParts = rawFiles.some((file) => openAiConversationExportPath(file.name));
3531
+ if (!hasOpenAiConversationParts) return rawFiles;
3532
+ return rawFiles.filter((file) => openAiConversationExportPath(file.name));
3533
+ }
3534
+
3535
+ function openAiConversationExportPath(name) {
3536
+ const text = String(name || "");
3537
+ return /(^|\/)Conversations__[^/]*chatgpt[^/]*(?:\/|$)/i.test(text) || /(^|\/)Conversations__[^/]*chatgpt[^/]*\.zip$/i.test(text);
3538
+ }
3539
+
2823
3540
  function webRawReference(sharedRaw, conversation) {
2824
3541
  return {
2825
3542
  filename: "web-export-reference",
@@ -3191,6 +3908,450 @@ function claudeFileHistoryFiles(sessionId, env = process.env) {
3191
3908
  return files;
3192
3909
  }
3193
3910
 
3911
+ function claudeSubagentImportContext(cwd, env = process.env, cache = null) {
3912
+ const key = `${claudeSubagentUserRoot(env)}\0${path.resolve(String(cwd || ""))}`;
3913
+ if (cache?.has(key)) return cache.get(key);
3914
+ const definitions = readClaudeSubagentDefinitions(cwd, env);
3915
+ const effective = effectiveClaudeSubagentDefinitions(definitions);
3916
+ const sourceFiles = definitions.map((definition) => definition.sourcePath).filter(Boolean);
3917
+ const projectCount = effective.filter((definition) => definition.scope === "project").length;
3918
+ const userCount = effective.filter((definition) => definition.scope === "user").length;
3919
+ const names = effective.map((definition) => definition.name).sort((a, b) => a.localeCompare(b));
3920
+ const shadowedNames = shadowedClaudeSubagentNames(definitions);
3921
+ const sessionSummary = effective.length
3922
+ ? {
3923
+ claudeSubagents: compactMetadata({
3924
+ count: effective.length,
3925
+ parsedCount: definitions.length !== effective.length ? definitions.length : undefined,
3926
+ projectCount: projectCount || undefined,
3927
+ userCount: userCount || undefined,
3928
+ shadowedNames: shadowedNames.length ? shadowedNames : undefined,
3929
+ names,
3930
+ definitions: effective.map(claudeSubagentDefinitionSummary)
3931
+ })
3932
+ }
3933
+ : null;
3934
+ const result = { sessionSummary, sourceFiles };
3935
+ if (cache) cache.set(key, result);
3936
+ return result;
3937
+ }
3938
+
3939
+ function readClaudeSubagentDefinitions(cwd, env = process.env) {
3940
+ return claudeSubagentDefinitionFiles(cwd, env)
3941
+ .map((entry) => parseClaudeSubagentDefinitionFile(entry.file, entry))
3942
+ .filter(Boolean)
3943
+ .sort((a, b) => {
3944
+ const scope = subagentScopePriority(a.scope) - subagentScopePriority(b.scope);
3945
+ return scope || a.name.localeCompare(b.name) || a.sourcePath.localeCompare(b.sourcePath);
3946
+ });
3947
+ }
3948
+
3949
+ function claudeSubagentDefinitionFiles(cwd, env = process.env) {
3950
+ const roots = [];
3951
+ const userRoot = claudeSubagentUserRoot(env);
3952
+ if (safeStat(userRoot)?.isDirectory()) roots.push({ root: userRoot, scope: "user" });
3953
+ const projectRoot = findClaudeProjectSubagentRoot(cwd);
3954
+ if (projectRoot && path.resolve(projectRoot) !== path.resolve(userRoot)) {
3955
+ roots.push({ root: projectRoot, scope: "project" });
3956
+ }
3957
+ const seen = new Set();
3958
+ const files = [];
3959
+ for (const root of roots) {
3960
+ collectFiles(root.root, (file) => {
3961
+ if (!/\.md$/i.test(file)) return;
3962
+ const resolved = path.resolve(file);
3963
+ if (seen.has(resolved)) return;
3964
+ seen.add(resolved);
3965
+ files.push({ ...root, file: resolved });
3966
+ });
3967
+ }
3968
+ return files.sort((a, b) => {
3969
+ const scope = subagentScopePriority(a.scope) - subagentScopePriority(b.scope);
3970
+ return scope || a.file.localeCompare(b.file);
3971
+ });
3972
+ }
3973
+
3974
+ function parseClaudeSubagentDefinitionFile(file, context = {}) {
3975
+ let text;
3976
+ try {
3977
+ text = fs.readFileSync(file, "utf8");
3978
+ } catch {
3979
+ return null;
3980
+ }
3981
+ const document = splitMarkdownFrontmatter(text);
3982
+ if (!document.frontmatter) return null;
3983
+ const metadata = parseSimpleYamlFrontmatter(document.frontmatter);
3984
+ const name = firstString(metadata.name, path.basename(file, path.extname(file)));
3985
+ if (!name) return null;
3986
+ const sourceRoot = context.root || path.dirname(file);
3987
+ const body = String(document.body || "").trim();
3988
+ const stat = safeStat(file);
3989
+ return compactMetadata({
3990
+ name,
3991
+ description: firstString(metadata.description),
3992
+ tools: normalizeClaudeSubagentTools(metadata.tools),
3993
+ model: firstString(metadata.model),
3994
+ scope: context.scope || "unknown",
3995
+ sourcePath: path.resolve(file),
3996
+ relativePath: relativePathWithin(sourceRoot, file),
3997
+ frontmatterKeys: Object.keys(metadata).sort((a, b) => a.localeCompare(b)),
3998
+ instructionPreview: previewString(body, 600),
3999
+ instructionLineCount: body ? body.split(/\r?\n/).length : undefined,
4000
+ mtime: stat?.mtimeMs ? new Date(stat.mtimeMs).toISOString() : undefined
4001
+ });
4002
+ }
4003
+
4004
+ function effectiveClaudeSubagentDefinitions(definitions) {
4005
+ const byName = new Map();
4006
+ for (const definition of definitions) {
4007
+ const key = definition.name.toLowerCase();
4008
+ const existing = byName.get(key);
4009
+ if (!existing || subagentScopePriority(definition.scope) >= subagentScopePriority(existing.scope)) {
4010
+ byName.set(key, definition);
4011
+ }
4012
+ }
4013
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
4014
+ }
4015
+
4016
+ function shadowedClaudeSubagentNames(definitions) {
4017
+ const seen = new Map();
4018
+ const shadowed = new Set();
4019
+ for (const definition of definitions) {
4020
+ const key = definition.name.toLowerCase();
4021
+ if (seen.has(key)) shadowed.add(definition.name);
4022
+ seen.set(key, definition);
4023
+ }
4024
+ return [...shadowed].sort((a, b) => a.localeCompare(b));
4025
+ }
4026
+
4027
+ function claudeSubagentDefinitionSummary(definition) {
4028
+ return compactMetadata({
4029
+ name: definition.name,
4030
+ description: definition.description,
4031
+ tools: definition.tools,
4032
+ model: definition.model,
4033
+ scope: definition.scope,
4034
+ sourcePath: definition.sourcePath,
4035
+ relativePath: definition.relativePath,
4036
+ instructionPreview: definition.instructionPreview,
4037
+ instructionLineCount: definition.instructionLineCount
4038
+ });
4039
+ }
4040
+
4041
+ function claudeSubagentRunImportContext(parentSessionId, conversationFile, env = process.env, sourceFiles = null) {
4042
+ const files = Array.isArray(sourceFiles)
4043
+ ? sourceFiles
4044
+ : claudeSubagentRunSourceFilesForSession(parentSessionId, conversationFile, env);
4045
+ const transcriptFiles = files.filter((file) => /\.jsonl$/i.test(file));
4046
+ const sourceRoot = conversationFile ? path.dirname(conversationFile) : "";
4047
+ const sessions = [];
4048
+ const runs = [];
4049
+ for (const file of transcriptFiles) {
4050
+ const parsed = parseClaudeSubagentRunFile(file, parentSessionId, sourceRoot);
4051
+ if (!parsed) continue;
4052
+ sessions.push(parsed.session);
4053
+ runs.push(parsed.summary);
4054
+ }
4055
+ const sourceFileList = [...new Set(files)].sort((a, b) => a.localeCompare(b));
4056
+ return {
4057
+ sourceFiles: sourceFileList,
4058
+ sessions,
4059
+ sessionSummary: runs.length
4060
+ ? {
4061
+ claudeSubagentRuns: compactMetadata({
4062
+ count: runs.length,
4063
+ agentIds: [...new Set(runs.map((run) => run.agentId).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
4064
+ agentTypes: [...new Set(runs.map((run) => run.agentType).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
4065
+ messageCount: runs.reduce((sum, run) => sum + (run.messageCount || 0), 0),
4066
+ userMessageCount: runs.reduce((sum, run) => sum + (run.userMessageCount || 0), 0),
4067
+ assistantMessageCount: runs.reduce((sum, run) => sum + (run.assistantMessageCount || 0), 0),
4068
+ toolCallCount: runs.reduce((sum, run) => sum + (run.toolCallCount || 0), 0),
4069
+ toolResultCount: runs.reduce((sum, run) => sum + (run.toolResultCount || 0), 0),
4070
+ runs
4071
+ })
4072
+ }
4073
+ : null
4074
+ };
4075
+ }
4076
+
4077
+ function parseClaudeSubagentRunFile(file, parentSessionId, sourceRoot = "") {
4078
+ let parsed;
4079
+ try {
4080
+ parsed = parseAgentJsonl(file, "claude_code");
4081
+ } catch {
4082
+ return null;
4083
+ }
4084
+ if (!parsed.messages.length) return null;
4085
+ const meta = readClaudeSubagentRunMeta(file);
4086
+ const agentId = claudeSubagentRunAgentId(parsed, file);
4087
+ const agentType = firstString(meta?.agentType, meta?.type, meta?.name);
4088
+ const models = sessionMessageModels(parsed.messages);
4089
+ const usage = computeSessionUsage(parsed.messages);
4090
+ const promptPreview = previewString(firstClaudeSubagentUserPrompt(parsed.messages), 360);
4091
+ const resultPreview = previewString(lastClaudeSubagentAssistantText(parsed.messages), 360);
4092
+ const toolCallCount = parsed.messages.reduce((sum, message) => sum + (Array.isArray(message.metadata?.toolCalls) ? message.metadata.toolCalls.length : 0), 0);
4093
+ const toolResultCount = parsed.messages.reduce((sum, message) => sum + (message.metadata?.toolResult ? 1 : 0), 0);
4094
+ const sourceFiles = claudeSubagentRunSourceFilesForTranscript(file);
4095
+ const sessionId = `claude-subagent-${hashId(`${parentSessionId}:${agentId}:${file}`)}`;
4096
+ const title = claudeSubagentRunTitle(agentType, parsed.title, promptPreview, agentId);
4097
+ const relativePath = relativePathWithin(sourceRoot || path.dirname(file), file);
4098
+ const summary = compactMetadata({
4099
+ sessionId,
4100
+ parentSessionId,
4101
+ agentId,
4102
+ agentType,
4103
+ title,
4104
+ startedAt: parsed.startedAt,
4105
+ endedAt: parsed.endedAt,
4106
+ messageCount: parsed.messages.length,
4107
+ userMessageCount: parsed.messages.filter((message) => message.role === "user").length,
4108
+ assistantMessageCount: parsed.messages.filter((message) => message.role === "assistant").length,
4109
+ toolCallCount,
4110
+ toolResultCount,
4111
+ models,
4112
+ usage,
4113
+ promptPreview,
4114
+ resultPreview,
4115
+ sourcePath: file,
4116
+ relativePath
4117
+ });
4118
+ return {
4119
+ summary,
4120
+ session: {
4121
+ provider: "claude_code",
4122
+ sessionId,
4123
+ cwd: parsed.cwd,
4124
+ messages: parsed.messages,
4125
+ startedAt: parsed.startedAt,
4126
+ endedAt: parsed.endedAt,
4127
+ sourcePath: file,
4128
+ sourceFiles,
4129
+ title,
4130
+ conversationKind: "claude_subagent",
4131
+ parentComposerId: parentSessionId,
4132
+ sessionSummary: mergeSessionSummaries(parsed.sessionSummary, {
4133
+ claudeSubagentRun: compactMetadata({
4134
+ parentSessionId,
4135
+ agentId,
4136
+ agentType,
4137
+ title,
4138
+ promptPreview,
4139
+ resultPreview,
4140
+ sourcePath: file,
4141
+ relativePath
4142
+ })
4143
+ })
4144
+ }
4145
+ };
4146
+ }
4147
+
4148
+ function claudeSubagentRunSourceFilesForSession(parentSessionId, conversationFile, env = process.env) {
4149
+ const files = [];
4150
+ for (const dir of claudeSubagentRunDirs(parentSessionId, conversationFile, env)) {
4151
+ let entries;
4152
+ try {
4153
+ entries = fs.readdirSync(dir, { withFileTypes: true });
4154
+ } catch (error) {
4155
+ if (error.code === "ENOENT" || error.code === "ENOTDIR" || error.code === "EACCES") continue;
4156
+ throw error;
4157
+ }
4158
+ for (const entry of entries) {
4159
+ if (!entry.isFile()) continue;
4160
+ if (!/\.jsonl$/i.test(entry.name) && !/\.meta\.json$/i.test(entry.name)) continue;
4161
+ files.push(path.join(dir, entry.name));
4162
+ }
4163
+ }
4164
+ return [...new Set(files)].sort((a, b) => a.localeCompare(b));
4165
+ }
4166
+
4167
+ function claudeSubagentRunDirs(parentSessionId, conversationFile, env = process.env) {
4168
+ if (!parentSessionId || !conversationFile) return [];
4169
+ const dirs = [
4170
+ path.join(path.dirname(conversationFile), String(parentSessionId), "subagents"),
4171
+ path.join(path.dirname(conversationFile), "subagents")
4172
+ ];
4173
+ const root = claudeRoots(env)[0];
4174
+ const directParent = path.join(root, path.basename(path.dirname(conversationFile)), String(parentSessionId), "subagents");
4175
+ dirs.push(directParent);
4176
+ const seen = new Set();
4177
+ return dirs.filter((dir) => {
4178
+ const resolved = path.resolve(dir);
4179
+ if (seen.has(resolved)) return false;
4180
+ seen.add(resolved);
4181
+ return safeStat(resolved)?.isDirectory();
4182
+ });
4183
+ }
4184
+
4185
+ function claudeSubagentRunSourceFilesForTranscript(file) {
4186
+ const files = [file];
4187
+ const meta = claudeSubagentRunMetaFile(file);
4188
+ if (safeStat(meta)?.isFile()) files.push(meta);
4189
+ return files;
4190
+ }
4191
+
4192
+ function claudeSubagentRunMetaFile(file) {
4193
+ return String(file || "").replace(/\.jsonl$/i, ".meta.json");
4194
+ }
4195
+
4196
+ function readClaudeSubagentRunMeta(file) {
4197
+ const metaFile = claudeSubagentRunMetaFile(file);
4198
+ try {
4199
+ return readJson(metaFile, null);
4200
+ } catch {
4201
+ return null;
4202
+ }
4203
+ }
4204
+
4205
+ function claudeSubagentRunAgentId(parsed, file) {
4206
+ const agentIds = parsed?.sessionSummary?.claudeJsonl?.agentIds;
4207
+ if (Array.isArray(agentIds) && agentIds.length) return agentIds[0];
4208
+ if (typeof agentIds === "string" && agentIds.trim()) return agentIds.trim();
4209
+ return path.basename(String(file || ""), path.extname(String(file || ""))).replace(/^agent-/, "");
4210
+ }
4211
+
4212
+ function claudeSubagentRunTitle(agentType, parsedTitle, promptPreview, agentId) {
4213
+ const promptTitle = titleFromPrompt(promptPreview) || titleFromPrompt(parsedTitle) || firstString(parsedTitle);
4214
+ const prefix = firstString(agentType);
4215
+ const title = prefix && promptTitle ? `${prefix}: ${promptTitle}` : firstString(promptTitle, prefix, agentId, "Claude subagent");
4216
+ return titleFromPrompt(title) || title;
4217
+ }
4218
+
4219
+ function firstClaudeSubagentUserPrompt(messages) {
4220
+ return (messages || []).find((message) => message.role === "user" && String(message.content || "").trim())?.content || "";
4221
+ }
4222
+
4223
+ function lastClaudeSubagentAssistantText(messages) {
4224
+ return [...(messages || [])].reverse().find((message) => message.role === "assistant" && String(message.content || "").trim())?.content || "";
4225
+ }
4226
+
4227
+ function sessionMessageModels(messages) {
4228
+ return [...new Set((messages || []).map((message) => message.metadata?.model).filter((model) => typeof model === "string" && model.trim()).map((model) => model.trim()))]
4229
+ .sort((a, b) => a.localeCompare(b));
4230
+ }
4231
+
4232
+ function claudeSubagentUserRoot(env = process.env) {
4233
+ const home = env && env.HOME ? env.HOME : os.homedir();
4234
+ return path.join(home, ".claude", "agents");
4235
+ }
4236
+
4237
+ function findClaudeProjectSubagentRoot(cwd) {
4238
+ if (!cwd) return "";
4239
+ let dir = path.resolve(cwd);
4240
+ if (!safeStat(dir)?.isDirectory()) return "";
4241
+ for (;;) {
4242
+ const candidate = path.join(dir, ".claude", "agents");
4243
+ if (safeStat(candidate)?.isDirectory()) return candidate;
4244
+ const parent = path.dirname(dir);
4245
+ if (parent === dir || fs.existsSync(path.join(dir, ".git"))) return "";
4246
+ dir = parent;
4247
+ }
4248
+ }
4249
+
4250
+ function subagentScopePriority(scope) {
4251
+ if (scope === "project") return 2;
4252
+ if (scope === "user") return 1;
4253
+ return 0;
4254
+ }
4255
+
4256
+ function splitMarkdownFrontmatter(text) {
4257
+ const normalized = String(text || "").replace(/^\uFEFF/, "");
4258
+ const match = normalized.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/);
4259
+ if (!match) return { frontmatter: "", body: normalized };
4260
+ return { frontmatter: match[1], body: match[2] || "" };
4261
+ }
4262
+
4263
+ function parseSimpleYamlFrontmatter(text) {
4264
+ const lines = String(text || "").split(/\r?\n/);
4265
+ const result = {};
4266
+ for (let index = 0; index < lines.length; index++) {
4267
+ const line = lines[index];
4268
+ if (!line.trim() || /^\s*#/.test(line)) continue;
4269
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
4270
+ if (!match) continue;
4271
+ const key = match[1];
4272
+ const raw = match[2] || "";
4273
+ if (/^[>|]/.test(raw.trim())) {
4274
+ const block = [];
4275
+ while (index + 1 < lines.length && (/^(?:\s+|$)/.test(lines[index + 1]))) {
4276
+ index++;
4277
+ block.push(lines[index].replace(/^\s{2}/, ""));
4278
+ }
4279
+ result[key] = raw.trim().startsWith(">") ? block.join(" ").replace(/\s+/g, " ").trim() : block.join("\n").trim();
4280
+ } else if (!raw.trim()) {
4281
+ const values = [];
4282
+ while (index + 1 < lines.length) {
4283
+ const item = lines[index + 1].match(/^\s*-\s*(.*)$/);
4284
+ if (!item) break;
4285
+ index++;
4286
+ values.push(parseYamlScalar(item[1]));
4287
+ }
4288
+ result[key] = values.length ? values : "";
4289
+ } else {
4290
+ result[key] = parseYamlScalar(raw);
4291
+ }
4292
+ }
4293
+ return result;
4294
+ }
4295
+
4296
+ function parseYamlScalar(value) {
4297
+ const text = stripYamlComment(String(value || "").trim());
4298
+ if (!text) return "";
4299
+ if (text.startsWith("[") && text.endsWith("]")) {
4300
+ return text
4301
+ .slice(1, -1)
4302
+ .split(",")
4303
+ .map((item) => stripYamlString(item.trim()))
4304
+ .filter(Boolean);
4305
+ }
4306
+ return stripYamlString(text);
4307
+ }
4308
+
4309
+ function stripYamlComment(value) {
4310
+ let quote = "";
4311
+ for (let index = 0; index < value.length; index++) {
4312
+ const char = value[index];
4313
+ if ((char === '"' || char === "'") && value[index - 1] !== "\\") {
4314
+ quote = quote === char ? "" : quote || char;
4315
+ }
4316
+ if (char === "#" && !quote && (index === 0 || /\s/.test(value[index - 1]))) {
4317
+ return value.slice(0, index).trimEnd();
4318
+ }
4319
+ }
4320
+ return value;
4321
+ }
4322
+
4323
+ function stripYamlString(value) {
4324
+ const text = String(value || "").trim();
4325
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
4326
+ return text.slice(1, -1);
4327
+ }
4328
+ return text;
4329
+ }
4330
+
4331
+ function normalizeClaudeSubagentTools(value) {
4332
+ const values = Array.isArray(value)
4333
+ ? value
4334
+ : typeof value === "string"
4335
+ ? value.split(",")
4336
+ : [];
4337
+ return values
4338
+ .map((item) => String(item || "").trim())
4339
+ .filter(Boolean)
4340
+ .filter((item) => !/^none$/i.test(item))
4341
+ .sort((a, b) => a.localeCompare(b));
4342
+ }
4343
+
4344
+ function relativePathWithin(root, file) {
4345
+ const relative = path.relative(root || path.dirname(file), file);
4346
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : path.basename(file);
4347
+ }
4348
+
4349
+ function previewString(value, max = 600) {
4350
+ const text = String(value || "").replace(/\s+/g, " ").trim();
4351
+ if (!text) return "";
4352
+ return text.length > max ? `${text.slice(0, max - 3).trimEnd()}...` : text;
4353
+ }
4354
+
3194
4355
  function jsonlFiles(roots) {
3195
4356
  const files = [];
3196
4357
  for (const root of roots) {
@@ -3324,59 +4485,196 @@ function readInitialLines(file, maxLines, maxBytes = 1024 * 1024) {
3324
4485
  function readCodexThreads(env = process.env) {
3325
4486
  const db = codexStateDb(env);
3326
4487
  if (!fs.existsSync(db)) return [];
4488
+ const sessionIndex = readCodexSessionIndex(env);
4489
+ const threadColumns = sqliteTableColumns(db, "threads");
3327
4490
  const hasStage1Outputs = sqliteTableExists(db, "stage1_outputs");
3328
- const query = hasStage1Outputs
3329
- ? [
3330
- "select t.id, t.rollout_path, t.created_at, t.updated_at, t.source, t.cwd, t.title,",
3331
- "s.raw_memory, s.rollout_summary, s.generated_at as summary_generated_at,",
3332
- "s.source_updated_at as summary_source_updated_at, s.rollout_slug",
3333
- "from threads t",
3334
- "left join stage1_outputs s on s.thread_id = t.id",
3335
- "where t.rollout_path != ''",
3336
- "order by t.updated_at desc"
3337
- ].join(" ")
3338
- : [
3339
- "select id, rollout_path, created_at, updated_at, source, cwd, title",
3340
- "from threads",
3341
- "where rollout_path != ''",
3342
- "order by updated_at desc"
3343
- ].join(" ");
4491
+ const hasSpawnEdges = sqliteTableExists(db, "thread_spawn_edges");
4492
+ const select = [
4493
+ "t.id",
4494
+ "t.rollout_path",
4495
+ "t.created_at",
4496
+ "t.updated_at",
4497
+ "t.source",
4498
+ "t.cwd",
4499
+ "t.title",
4500
+ sqliteSelectMaybe(threadColumns, "t", "created_at_ms"),
4501
+ sqliteSelectMaybe(threadColumns, "t", "updated_at_ms"),
4502
+ sqliteSelectMaybe(threadColumns, "t", "agent_nickname"),
4503
+ sqliteSelectMaybe(threadColumns, "t", "agent_role"),
4504
+ sqliteSelectMaybe(threadColumns, "t", "agent_path"),
4505
+ sqliteSelectMaybe(threadColumns, "t", "thread_source"),
4506
+ sqliteSelectMaybe(threadColumns, "t", "tokens_used"),
4507
+ sqliteSelectMaybe(threadColumns, "t", "model"),
4508
+ sqliteSelectMaybe(threadColumns, "t", "model_provider", "model_provider"),
4509
+ hasStage1Outputs ? "s.raw_memory" : "null as raw_memory",
4510
+ hasStage1Outputs ? "s.rollout_summary" : "null as rollout_summary",
4511
+ hasStage1Outputs ? "s.generated_at as summary_generated_at" : "null as summary_generated_at",
4512
+ hasStage1Outputs ? "s.source_updated_at as summary_source_updated_at" : "null as summary_source_updated_at",
4513
+ hasStage1Outputs ? "s.rollout_slug" : "null as rollout_slug",
4514
+ hasSpawnEdges ? "e.parent_thread_id as spawn_parent_thread_id" : "null as spawn_parent_thread_id",
4515
+ hasSpawnEdges ? "e.status as spawn_status" : "null as spawn_status"
4516
+ ];
4517
+ const joins = [
4518
+ hasStage1Outputs ? "left join stage1_outputs s on s.thread_id = t.id" : "",
4519
+ hasSpawnEdges ? "left join thread_spawn_edges e on e.child_thread_id = t.id" : ""
4520
+ ].filter(Boolean).join(" ");
4521
+ const query = [
4522
+ `select ${select.join(", ")}`,
4523
+ "from threads t",
4524
+ joins,
4525
+ "where t.rollout_path != ''",
4526
+ "order by t.updated_at desc"
4527
+ ].filter(Boolean).join(" ");
3344
4528
  const result = spawnSync("sqlite3", [db, "-json", query], { argv0: "agentlog-sqlite", encoding: "utf8", maxBuffer: 1024 * 1024 * 50 });
3345
4529
  if (result.status !== 0 || !result.stdout.trim()) return [];
3346
4530
  try {
3347
- return JSON.parse(result.stdout).map((row) => ({
3348
- id: row.id,
3349
- rolloutPath: row.rollout_path,
3350
- createdAt: toIso(row.created_at),
3351
- updatedAt: toIso(row.updated_at),
3352
- source: row.source,
3353
- cwd: row.cwd,
3354
- title: row.title,
3355
- rawMemory: row.raw_memory || "",
3356
- rolloutSummary: row.rollout_summary || "",
3357
- summaryGeneratedAt: toIso(row.summary_generated_at),
3358
- summarySourceUpdatedAt: toIso(row.summary_source_updated_at),
3359
- rolloutSlug: row.rollout_slug || ""
3360
- }));
4531
+ const rows = JSON.parse(result.stdout).map((row) => {
4532
+ const sourceInfo = codexThreadSourceInfo(row.source);
4533
+ return {
4534
+ id: row.id,
4535
+ rolloutPath: row.rollout_path,
4536
+ createdAt: toIso(row.created_at_ms || row.created_at),
4537
+ updatedAt: toIso(row.updated_at_ms || row.updated_at),
4538
+ source: row.source,
4539
+ rawSource: row.source,
4540
+ threadSource: row.thread_source || "",
4541
+ tokensUsed: numberValue(row.tokens_used),
4542
+ model: firstString(row.model),
4543
+ modelProvider: firstString(row.model_provider),
4544
+ cwd: row.cwd,
4545
+ stateTitle: row.title,
4546
+ rawMemory: row.raw_memory || "",
4547
+ rolloutSummary: row.rollout_summary || "",
4548
+ summaryGeneratedAt: toIso(row.summary_generated_at),
4549
+ summarySourceUpdatedAt: toIso(row.summary_source_updated_at),
4550
+ rolloutSlug: row.rollout_slug || "",
4551
+ parentThreadId: firstString(row.spawn_parent_thread_id, sourceInfo.parentThreadId),
4552
+ spawnStatus: firstString(row.spawn_status, sourceInfo.status),
4553
+ agentNickname: firstString(row.agent_nickname, sourceInfo.agentNickname),
4554
+ agentRole: firstString(row.agent_role, sourceInfo.agentRole),
4555
+ agentPath: firstString(row.agent_path, sourceInfo.agentPath),
4556
+ subagentDepth: numberValue(sourceInfo.depth),
4557
+ isCodexSubagent: Boolean(row.spawn_parent_thread_id || sourceInfo.isSubagent || row.agent_nickname || row.agent_role || row.agent_path)
4558
+ };
4559
+ });
4560
+ return resolveCodexThreadSources(rows).map((thread) => applyCodexSessionIndexTitle({ ...thread, title: thread.stateTitle || "" }, sessionIndex));
3361
4561
  } catch {
3362
4562
  return [];
3363
4563
  }
3364
4564
  }
3365
4565
 
4566
+ function resolveCodexThreadSources(threads) {
4567
+ const byId = new Map((threads || []).map((thread) => [thread.id, thread]));
4568
+ const resolved = new Map();
4569
+ const sourceForThread = (thread, seen = new Set()) => {
4570
+ if (!thread) return "cli";
4571
+ const cached = resolved.get(thread.id);
4572
+ if (cached) return cached;
4573
+ const direct = codexSourceKind(thread.rawSource || thread.source) || codexSourceKind(thread.threadSource);
4574
+ if (direct) {
4575
+ resolved.set(thread.id, direct);
4576
+ return direct;
4577
+ }
4578
+ if (thread.parentThreadId && !seen.has(thread.id)) {
4579
+ seen.add(thread.id);
4580
+ const parentSource = sourceForThread(byId.get(thread.parentThreadId), seen);
4581
+ resolved.set(thread.id, parentSource);
4582
+ return parentSource;
4583
+ }
4584
+ resolved.set(thread.id, "cli");
4585
+ return "cli";
4586
+ };
4587
+ return (threads || []).map((thread) => ({
4588
+ ...thread,
4589
+ source: sourceForThread(thread),
4590
+ isCodexSubagent: Boolean(thread.isCodexSubagent || thread.parentThreadId)
4591
+ }));
4592
+ }
4593
+
4594
+ function codexSourceKind(value) {
4595
+ const text = String(value || "").trim();
4596
+ return ["cli", "vscode", "exec"].includes(text) ? text : "";
4597
+ }
4598
+
4599
+ function codexThreadSourceInfo(value) {
4600
+ let parsed = null;
4601
+ try {
4602
+ parsed = typeof value === "string" && value.trim().startsWith("{") ? JSON.parse(value) : null;
4603
+ } catch {
4604
+ parsed = null;
4605
+ }
4606
+ const spawn = parsed?.subagent?.thread_spawn || parsed?.thread_spawn || null;
4607
+ if (!spawn || typeof spawn !== "object") return {};
4608
+ return {
4609
+ isSubagent: true,
4610
+ parentThreadId: firstString(spawn.parent_thread_id, spawn.parentThreadId, spawn.parent_id, spawn.parentId),
4611
+ agentNickname: firstString(spawn.agent_nickname, spawn.agentNickname, spawn.nickname),
4612
+ agentRole: firstString(spawn.agent_role, spawn.agentRole, spawn.role),
4613
+ agentPath: firstString(spawn.agent_path, spawn.agentPath, spawn.path),
4614
+ status: firstString(spawn.status),
4615
+ depth: spawn.depth
4616
+ };
4617
+ }
4618
+
4619
+ function applyCodexSessionIndexTitle(thread, sessionIndex) {
4620
+ if (!thread) return thread;
4621
+ const indexedTitle = sessionIndex.get(thread.id);
4622
+ if (!indexedTitle?.title) return thread;
4623
+ return {
4624
+ ...thread,
4625
+ title: indexedTitle.title,
4626
+ titleSource: "session-index",
4627
+ sessionIndexPath: indexedTitle.path,
4628
+ sessionIndexUpdatedAt: indexedTitle.updatedAt,
4629
+ sessionIndexTitle: indexedTitle.title
4630
+ };
4631
+ }
4632
+
4633
+ function readCodexSessionIndex(env = process.env) {
4634
+ const file = codexSessionIndexPath(env);
4635
+ if (!fs.existsSync(file)) return new Map();
4636
+ const byId = new Map();
4637
+ let lines = [];
4638
+ try {
4639
+ lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
4640
+ } catch {
4641
+ return byId;
4642
+ }
4643
+ for (const line of lines) {
4644
+ if (!line.trim()) continue;
4645
+ let entry;
4646
+ try {
4647
+ entry = JSON.parse(line);
4648
+ } catch {
4649
+ continue;
4650
+ }
4651
+ const id = firstString(entry.id, entry.thread_id, entry.threadId, entry.session_id, entry.sessionId);
4652
+ const title = titleFromPrompt(firstString(entry.thread_name, entry.threadName, entry.title, entry.name));
4653
+ if (!id || !title) continue;
4654
+ const updatedAt = toIso(entry.updated_at || entry.updatedAt || entry.time || entry.timestamp);
4655
+ const previous = byId.get(id);
4656
+ if (!previous || String(updatedAt || "").localeCompare(String(previous.updatedAt || "")) >= 0) {
4657
+ byId.set(id, { title, updatedAt, path: file });
4658
+ }
4659
+ }
4660
+ return byId;
4661
+ }
4662
+
3366
4663
  function readCodexSessionEntries(env = process.env) {
4664
+ const sessionIndex = readCodexSessionIndex(env);
3367
4665
  const indexed = readCodexThreads(env).map((thread) => ({ ...thread, indexed: true }));
3368
4666
  const seen = new Set(indexed.map((thread) => normalizeSourcePath(thread.rolloutPath)));
3369
4667
  const discovered = [];
3370
4668
  for (const file of codexRolloutFiles(env)) {
3371
4669
  const key = normalizeSourcePath(file);
3372
4670
  if (seen.has(key)) continue;
3373
- const thread = codexRolloutFileThread(file);
4671
+ const thread = applyCodexSessionIndexTitle(codexRolloutFileThread(file), sessionIndex);
3374
4672
  if (thread) {
3375
4673
  discovered.push(thread);
3376
4674
  seen.add(key);
3377
4675
  }
3378
4676
  }
3379
- return indexed.concat(discovered).sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
4677
+ return indexed.concat(discovered).sort((a, b) => String(codexThreadImportUpdatedAt(b)).localeCompare(String(codexThreadImportUpdatedAt(a))));
3380
4678
  }
3381
4679
 
3382
4680
  function codexRolloutFiles(env = process.env) {
@@ -3448,18 +4746,174 @@ function readCodexRolloutInfo(file) {
3448
4746
  info.cwd ||= firstString(payload.cwd, event.cwd);
3449
4747
  }
3450
4748
  info.cwd ||= firstString(event.cwd, payload.cwd);
3451
- info.title ||= firstUserTextFromCodexEvent(event).replace(/\s+/g, " ").slice(0, 100);
3452
- if (info.id && info.cwd && info.title && lastTimestamp) break;
4749
+ const threadTitle = codexThreadNameFromEvent(event);
4750
+ if (threadTitle) {
4751
+ info.title = threadTitle;
4752
+ info.titleSource = "thread-name";
4753
+ } else if (!info.title) {
4754
+ const promptTitle = titleFromPrompt(firstUserTextFromCodexEvent(event));
4755
+ if (promptTitle) {
4756
+ info.title = promptTitle;
4757
+ info.titleSource = "prompt";
4758
+ }
4759
+ }
4760
+ if (info.id && info.cwd && info.title && lastTimestamp && info.titleSource === "thread-name") break;
3453
4761
  }
3454
4762
  if (lastTimestamp) info.updatedAt = lastTimestamp;
3455
4763
  return info;
3456
4764
  }
3457
4765
 
4766
+ function codexSessionTitleForImport(thread, parsed) {
4767
+ const indexedTitle = firstString(thread?.sessionIndexTitle);
4768
+ if (indexedTitle) return indexedTitle;
4769
+ const parsedTitle = firstString(parsed?.title);
4770
+ if (parsedTitle && (parsed?.titleSource === "thread-name" || codexPromptLikeThreadTitle(thread?.title))) return parsedTitle;
4771
+ return firstString(thread?.title, parsedTitle);
4772
+ }
4773
+
4774
+ function codexSubagentRunImportContext(parentThread, childThreads = [], env = process.env) {
4775
+ if (!parentThread?.id || !Array.isArray(childThreads) || !childThreads.length) return null;
4776
+ const runs = [];
4777
+ for (const childThread of childThreads) {
4778
+ const parsed = parseCodexSubagentThread(childThread);
4779
+ const summary = codexSubagentRunSummary(parentThread, childThread, parsed, env);
4780
+ if (summary) runs.push(summary);
4781
+ }
4782
+ runs.sort((a, b) => {
4783
+ const time = String(a.startedAt || a.endedAt || "").localeCompare(String(b.startedAt || b.endedAt || ""));
4784
+ return time || String(a.sessionId || "").localeCompare(String(b.sessionId || ""));
4785
+ });
4786
+ if (!runs.length) return null;
4787
+ return {
4788
+ sessionSummary: {
4789
+ codexSubagentRuns: compactMetadata({
4790
+ count: runs.length,
4791
+ agentIds: [...new Set(runs.map((run) => run.agentId).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
4792
+ agentNicknames: [...new Set(runs.map((run) => run.agentNickname).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
4793
+ agentRoles: [...new Set(runs.map((run) => run.agentRole).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
4794
+ statuses: [...new Set(runs.map((run) => run.status).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
4795
+ messageCount: runs.reduce((sum, run) => sum + (run.messageCount || 0), 0),
4796
+ userMessageCount: runs.reduce((sum, run) => sum + (run.userMessageCount || 0), 0),
4797
+ assistantMessageCount: runs.reduce((sum, run) => sum + (run.assistantMessageCount || 0), 0),
4798
+ toolCallCount: runs.reduce((sum, run) => sum + (run.toolCallCount || 0), 0),
4799
+ toolResultCount: runs.reduce((sum, run) => sum + (run.toolResultCount || 0), 0),
4800
+ runs
4801
+ })
4802
+ }
4803
+ };
4804
+ }
4805
+
4806
+ function codexSubagentSessionSummary(thread, parsed, env = process.env) {
4807
+ if (!thread?.isCodexSubagent && !thread?.parentThreadId) return null;
4808
+ const summary = codexSubagentRunSummary(null, thread, parsed, env);
4809
+ return summary ? { sessionSummary: { codexSubagentRun: summary } } : null;
4810
+ }
4811
+
4812
+ function parseCodexSubagentThread(thread) {
4813
+ if (!thread?.rolloutPath) return null;
4814
+ try {
4815
+ return finalizeCodexParsedThread(thread, parseAgentJsonl(thread.rolloutPath, "codex"));
4816
+ } catch {
4817
+ return null;
4818
+ }
4819
+ }
4820
+
4821
+ function codexSubagentRunSummary(parentThread, childThread, parsed, env = process.env) {
4822
+ if (!childThread?.id && !parsed?.sessionId) return null;
4823
+ const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
4824
+ const sessionId = parsed?.sessionId || childThread.id;
4825
+ const parentSessionId = firstString(parentThread?.id, childThread.parentThreadId);
4826
+ const promptPreview = previewString(firstCodexSubagentUserPrompt(messages) || childThread.title, 360);
4827
+ const resultPreview = previewString(lastCodexSubagentAssistantText(messages), 360);
4828
+ const toolCallCount = messages.reduce((sum, message) => sum + (Array.isArray(message.metadata?.toolCalls) ? message.metadata.toolCalls.length : 0), 0);
4829
+ const toolResultCount = messages.reduce((sum, message) => sum + (message.metadata?.toolResult ? 1 : 0), 0);
4830
+ const usage = messages.length ? computeSessionUsage(messages) : null;
4831
+ const sourceRoot = codexHome(env);
4832
+ return compactMetadata({
4833
+ sessionId,
4834
+ parentSessionId,
4835
+ agentId: childThread.id || sessionId,
4836
+ agentNickname: childThread.agentNickname,
4837
+ agentRole: childThread.agentRole,
4838
+ agentType: childThread.agentRole,
4839
+ agentPath: childThread.agentPath,
4840
+ status: childThread.spawnStatus,
4841
+ depth: childThread.subagentDepth,
4842
+ provider: "codex",
4843
+ providerLabel: "Codex",
4844
+ title: codexSubagentRunTitle(childThread, parsed?.title, promptPreview),
4845
+ startedAt: parsed?.startedAt || childThread.createdAt,
4846
+ endedAt: parsed?.endedAt || childThread.updatedAt,
4847
+ messageCount: messages.length || undefined,
4848
+ userMessageCount: messages.filter((message) => message.role === "user").length || undefined,
4849
+ assistantMessageCount: messages.filter((message) => message.role === "assistant").length || undefined,
4850
+ toolCallCount: toolCallCount || undefined,
4851
+ toolResultCount: toolResultCount || undefined,
4852
+ models: sessionMessageModels(messages),
4853
+ usage,
4854
+ promptPreview,
4855
+ resultPreview,
4856
+ sourcePath: childThread.rolloutPath,
4857
+ relativePath: childThread.rolloutPath ? relativePathWithin(sourceRoot, childThread.rolloutPath) : ""
4858
+ });
4859
+ }
4860
+
4861
+ function codexSubagentRunTitle(thread, parsedTitle, promptPreview) {
4862
+ const prefix = firstString(thread?.agentNickname, thread?.agentRole);
4863
+ const promptTitle = titleFromPrompt(firstString(parsedTitle, thread?.title, promptPreview));
4864
+ if (prefix && promptTitle) return `${prefix}: ${promptTitle}`;
4865
+ return firstString(promptTitle, prefix, thread?.id, "Codex subagent");
4866
+ }
4867
+
4868
+ function firstCodexSubagentUserPrompt(messages) {
4869
+ return (messages || []).find((message) => message.role === "user" && String(message.content || "").trim())?.content || "";
4870
+ }
4871
+
4872
+ function lastCodexSubagentAssistantText(messages) {
4873
+ return [...(messages || [])].reverse().find((message) => message.role === "assistant" && String(message.content || "").trim())?.content || "";
4874
+ }
4875
+
4876
+ function codexThreadImportUpdatedAt(thread, childThreads = []) {
4877
+ return latestIso(thread?.updatedAt, thread?.sessionIndexUpdatedAt, thread?.createdAt, ...(childThreads || []).map((child) => codexThreadImportUpdatedAt(child)));
4878
+ }
4879
+
4880
+ function latestIso(...values) {
4881
+ let latest = 0;
4882
+ for (const value of values) {
4883
+ const iso = toIso(value);
4884
+ if (!iso) continue;
4885
+ const time = new Date(iso).getTime();
4886
+ if (Number.isFinite(time) && time > latest) latest = time;
4887
+ }
4888
+ return latest ? new Date(latest).toISOString() : "";
4889
+ }
4890
+
4891
+ function codexPromptLikeThreadTitle(value) {
4892
+ const text = String(value || "").trim();
4893
+ return text.length > 160 || /^# Files mentioned by the user:/m.test(text) || /\[\$[^\]\n]+\]\([^)]+SKILL\.md\)/i.test(text);
4894
+ }
4895
+
4896
+ function codexThreadNameFromEvent(event) {
4897
+ const item = event?.payload || event?.item || event?.data || event;
4898
+ const type = String(item?.type || event?.type || item?.kind || "").toLowerCase();
4899
+ if (!["thread_name_updated", "thread_title_updated", "conversation_title_updated"].includes(type)) return "";
4900
+ return titleFromPrompt(firstString(item.thread_name, item.threadName, item.title, item.name));
4901
+ }
4902
+
3458
4903
  function firstUserTextFromCodexEvent(event) {
3459
4904
  const payload = event.payload || event;
3460
- if (payload.type === "message" && payload.role === "user") return extractText(payload.content);
3461
- if (payload.type === "user_message") return firstString(payload.message, payload.text, extractText(payload.content));
3462
- return "";
4905
+ let content = "";
4906
+ if (payload.type === "message" && payload.role === "user") content = extractText(payload.content);
4907
+ else if (payload.type === "user_message") content = firstString(payload.message, payload.text, extractText(payload.content));
4908
+ if (!content) return "";
4909
+ const classification = providerGeneratedContext(content, "codex", event);
4910
+ if (!classification) return content;
4911
+ return classification.kind === "attachment_context" ? codexAttachmentRequestText(content) : "";
4912
+ }
4913
+
4914
+ function codexAttachmentRequestText(value) {
4915
+ const match = String(value || "").match(/(?:^|\n)## My request for Codex:\s*\n([\s\S]*)$/i);
4916
+ return match ? match[1].trim() : "";
3463
4917
  }
3464
4918
 
3465
4919
  function codexTimestampFromFilename(file) {
@@ -3513,12 +4967,20 @@ function codexStateDb(env = process.env) {
3513
4967
  return env.CODEX_STATE_DB || path.join(codexHome(env), "state_5.sqlite");
3514
4968
  }
3515
4969
 
4970
+ function codexSessionIndexPath(env = process.env) {
4971
+ return env.CODEX_SESSION_INDEX || path.join(codexHome(env), "session_index.jsonl");
4972
+ }
4973
+
3516
4974
  function codexHome(env = process.env) {
3517
4975
  return env.CODEX_HOME || path.join(os.homedir(), ".codex");
3518
4976
  }
3519
4977
 
3520
- function codexSourceFiles(thread, env = process.env) {
3521
- return [thread.rolloutPath, thread.indexed ? codexStateDb(env) : ""].filter(Boolean);
4978
+ function codexSourceFiles(thread, env = process.env, childThreads = []) {
4979
+ const files = [thread.rolloutPath, thread.indexed ? codexStateDb(env) : "", thread.sessionIndexPath || ""].filter(Boolean);
4980
+ for (const child of childThreads || []) {
4981
+ files.push(...codexSourceFiles(child, env));
4982
+ }
4983
+ return [...new Set(files)].sort((a, b) => a.localeCompare(b));
3522
4984
  }
3523
4985
 
3524
4986
  function codexSupplementaryMessages(thread, fallbackTimestamp = "") {
@@ -3551,20 +5013,59 @@ function codexSupplementFingerprint(thread) {
3551
5013
  thread.rolloutSummary || "",
3552
5014
  thread.summaryGeneratedAt || "",
3553
5015
  thread.summarySourceUpdatedAt || "",
3554
- thread.rolloutSlug || ""
5016
+ thread.rolloutSlug || "",
5017
+ thread.sessionIndexTitle || "",
5018
+ thread.sessionIndexUpdatedAt || ""
3555
5019
  ].join("\0");
3556
5020
  if (!payload.replace(/\0/g, "")) return "summaries:none";
3557
5021
  const hash = crypto.createHash("sha256").update(payload).digest("hex").slice(0, 24);
3558
5022
  return `summaries:${hash}`;
3559
5023
  }
3560
5024
 
5025
+ function codexThreadMetadataFingerprint(thread) {
5026
+ const payload = [
5027
+ thread?.rawSource || "",
5028
+ thread?.threadSource || "",
5029
+ thread?.parentThreadId || "",
5030
+ thread?.spawnStatus || "",
5031
+ thread?.agentNickname || "",
5032
+ thread?.agentRole || "",
5033
+ thread?.agentPath || "",
5034
+ thread?.subagentDepth || "",
5035
+ thread?.tokensUsed || ""
5036
+ ].join("\0");
5037
+ if (!payload.replace(/\0/g, "")) return "";
5038
+ const hash = crypto.createHash("sha256").update(payload).digest("hex").slice(0, 24);
5039
+ return `thread-meta:${hash}`;
5040
+ }
5041
+
5042
+ function codexSubagentRunsFingerprint(childThreads = []) {
5043
+ const payload = (childThreads || [])
5044
+ .map((thread) => [
5045
+ thread.id || "",
5046
+ thread.rolloutPath || "",
5047
+ thread.parentThreadId || "",
5048
+ thread.spawnStatus || "",
5049
+ thread.agentNickname || "",
5050
+ thread.agentRole || "",
5051
+ thread.agentPath || "",
5052
+ thread.tokensUsed || "",
5053
+ thread.rolloutPath ? fileFingerprint(thread.rolloutPath, safeStat(thread.rolloutPath)) : ""
5054
+ ].join("\t"))
5055
+ .join("\n");
5056
+ if (!payload) return "";
5057
+ const hash = crypto.createHash("sha256").update(payload).digest("hex").slice(0, 24);
5058
+ return `codex-subagent-runs:${hash}`;
5059
+ }
5060
+
3561
5061
  function summarizeCodexSources(threads) {
3562
5062
  const cli = threads.filter((thread) => thread.source === "cli").length;
3563
5063
  const desktop = threads.filter((thread) => thread.source === "vscode").length;
3564
5064
  const exec = threads.filter((thread) => thread.source === "exec").length;
3565
5065
  const summaries = threads.filter((thread) => thread.rawMemory || thread.rolloutSummary).length;
3566
5066
  const archived = threads.filter((thread) => thread.sourceDetail === "archived_sessions").length;
3567
- return { cli, desktop, ...(exec ? { exec } : {}), ...(archived ? { archived } : {}), ...(summaries ? { summaries } : {}) };
5067
+ const subagents = threads.filter((thread) => thread.isCodexSubagent).length;
5068
+ return { cli, desktop, ...(exec ? { exec } : {}), ...(subagents ? { subagents } : {}), ...(archived ? { archived } : {}), ...(summaries ? { summaries } : {}) };
3568
5069
  }
3569
5070
 
3570
5071
  function readClaudeDesktopSessions(options = {}, env = process.env) {
@@ -7356,10 +8857,10 @@ function readOpenCodeSqliteSessionsFromDb(dbPath, options = {}, env = process.en
7356
8857
  if (!sqliteTableExists(dbPath, "session") || !sqliteTableExists(dbPath, "message") || !sqliteTableExists(dbPath, "part")) return [];
7357
8858
  const sessionRows = readOpenCodeSqliteSessionRows(dbPath, options);
7358
8859
  if (!sessionRows.length) return [];
7359
- const classifications = openCodeSqliteSessionClassifications(sessionRows, dbPath, env, options);
7360
8860
  const sessionIds = sessionRows.map((row) => row.id).filter(Boolean);
7361
8861
  const messageRows = sortOpenCodeSqliteRows(readOpenCodeSqliteMessageRows(dbPath, sessionIds), ["session_id", "time_created", "id"]);
7362
8862
  const partRows = sortOpenCodeSqliteRows(readOpenCodeSqlitePartRows(dbPath, sessionIds), ["session_id", "message_id", "time_created", "id"]);
8863
+ const classifications = openCodeSqliteSessionClassifications(sessionRows, dbPath, env, options, messageRows);
7363
8864
  const messagesBySession = groupRowsBy(messageRows, "session_id");
7364
8865
  const partsByMessage = groupRowsBy(partRows, "message_id");
7365
8866
  const storageRoot = path.join(path.dirname(dbPath), "storage");
@@ -7450,9 +8951,10 @@ function readOpenCodeSqliteSessionRows(dbPath, options = {}) {
7450
8951
  return readSqliteJson(dbPath, queryParts.join(" "), "OpenCode SQLite sessions");
7451
8952
  }
7452
8953
 
7453
- function openCodeSqliteSessionClassifications(sessionRows, dbPath, env = process.env, options = {}) {
8954
+ function openCodeSqliteSessionClassifications(sessionRows, dbPath, env = process.env, options = {}, messageRows = []) {
7454
8955
  const baseSourceType = openCodeSqliteSourceType(dbPath, env, options);
7455
8956
  const rowsById = new Map((sessionRows || []).map((row) => [String(row.id || ""), row]).filter(([id]) => id));
8957
+ const messagesBySession = groupRowsBy(messageRows, "session_id");
7456
8958
  const desktopHints = openCodeDesktopSessionHints(env);
7457
8959
  const sourceTypes = new Map();
7458
8960
  const hintFiles = new Map();
@@ -7477,7 +8979,7 @@ function openCodeSqliteSessionClassifications(sessionRows, dbPath, env = process
7477
8979
  return parentSourceType;
7478
8980
  }
7479
8981
  }
7480
- const sourceType = openCodeSqliteRowSourceType(row, dbPath, env, options, baseSourceType);
8982
+ const sourceType = openCodeSqliteRowSourceType(row, dbPath, env, options, baseSourceType, messagesBySession.get(id) || []);
7481
8983
  sourceTypes.set(id, sourceType);
7482
8984
  return sourceType;
7483
8985
  };
@@ -7485,10 +8987,10 @@ function openCodeSqliteSessionClassifications(sessionRows, dbPath, env = process
7485
8987
  return { sourceTypes, hintFiles };
7486
8988
  }
7487
8989
 
7488
- function openCodeSqliteRowSourceType(row, dbPath, env = process.env, options = {}, baseSourceType = openCodeSqliteSourceType(dbPath, env, options)) {
8990
+ function openCodeSqliteRowSourceType(row, dbPath, env = process.env, options = {}, baseSourceType = openCodeSqliteSourceType(dbPath, env, options), messageRows = []) {
7489
8991
  if (baseSourceType === "opencode-desktop-sqlite-history") return baseSourceType;
7490
8992
  if (!pathInsideAny(dbPath, openCodeCliDataRoots(env)) && !OPENCODE_SOURCE_KINDS.has(options.openCodeKind)) return baseSourceType;
7491
- if (openCodeSqliteRowHasCliMetadata(row)) return "opencode-cli-sqlite-history";
8993
+ if (openCodeSqliteRowHasCliMetadata(row) || openCodeSqliteMessagesHaveCliMetadata(messageRows)) return "opencode-cli-sqlite-history";
7492
8994
  if (openCodeSqliteRowLooksWeb(row, dbPath, env)) return "opencode-web-sqlite-history";
7493
8995
  return baseSourceType;
7494
8996
  }
@@ -7497,6 +8999,13 @@ function openCodeSqliteRowHasCliMetadata(row) {
7497
8999
  return Boolean(firstString(row?.agent, row?.model));
7498
9000
  }
7499
9001
 
9002
+ function openCodeSqliteMessagesHaveCliMetadata(rows = []) {
9003
+ return rows.some((row) => {
9004
+ const data = parseJsonObject(row?.data);
9005
+ return Boolean(firstString(data.agent, data.mode, data.path?.cwd, data.path?.root));
9006
+ });
9007
+ }
9008
+
7500
9009
  function openCodeSqliteRowLooksWeb(row, dbPath, env = process.env) {
7501
9010
  const version = firstString(row?.version);
7502
9011
  if (!version || version === "local") return false;
@@ -7560,7 +9069,7 @@ function readOpenCodeSqliteMessageRows(dbPath, sessionIds = []) {
7560
9069
  "session_id",
7561
9070
  sqliteSelectMaybe(columns, "message", "time_created"),
7562
9071
  sqliteSelectMaybe(columns, "message", "time_updated"),
7563
- sqliteSelectMaybe(columns, "message", "data")
9072
+ openCodeSqliteMessageDataSelect(dbPath, columns)
7564
9073
  ];
7565
9074
  return readOpenCodeSqliteRowsForSessionIds(
7566
9075
  dbPath,
@@ -7571,6 +9080,20 @@ function readOpenCodeSqliteMessageRows(dbPath, sessionIds = []) {
7571
9080
  );
7572
9081
  }
7573
9082
 
9083
+ function openCodeSqliteMessageDataSelect(dbPath, columns) {
9084
+ if (!columns.has("data")) return "null as data";
9085
+ if (!sqliteJsonFunctionsAvailable(dbPath)) return "message.data";
9086
+ return "case when json_valid(message.data) then json_remove(message.data, '$.summary') else message.data end as data";
9087
+ }
9088
+
9089
+ function sqliteJsonFunctionsAvailable(dbPath) {
9090
+ try {
9091
+ return readSqliteJson(dbPath, "select json_valid('{\"ok\":true}') as ok", "SQLite JSON function check")[0]?.ok === 1;
9092
+ } catch {
9093
+ return false;
9094
+ }
9095
+ }
9096
+
7574
9097
  function readOpenCodeSqlitePartRows(dbPath, sessionIds = []) {
7575
9098
  const columns = sqliteTableColumns(dbPath, "part");
7576
9099
  if (!columns.has("id") || !columns.has("message_id") || !columns.has("session_id")) return [];
@@ -9525,6 +11048,22 @@ function fileFingerprint(file, stat = safeStat(file)) {
9525
11048
  return `${file}:${stat?.size || 0}:${Math.floor(stat?.mtimeMs || 0)}`;
9526
11049
  }
9527
11050
 
11051
+ function filesFingerprint(files) {
11052
+ return (files || [])
11053
+ .map((file) => fileFingerprint(file, safeStat(file)))
11054
+ .sort((a, b) => a.localeCompare(b))
11055
+ .join("|");
11056
+ }
11057
+
11058
+ function latestFileMtimeMs(files, primaryStat = null) {
11059
+ let latest = primaryStat?.mtimeMs || 0;
11060
+ for (const file of files || []) {
11061
+ const stat = safeStat(file);
11062
+ if (stat?.mtimeMs && stat.mtimeMs > latest) latest = stat.mtimeMs;
11063
+ }
11064
+ return latest;
11065
+ }
11066
+
9528
11067
  function fileSha256(file) {
9529
11068
  return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
9530
11069
  }
@@ -9648,6 +11187,7 @@ module.exports = {
9648
11187
  importWebChat,
9649
11188
  normalizeEventRole,
9650
11189
  parseClaudeDesktopSessionFile,
11190
+ parseClaudeSubagentDefinitionFile,
9651
11191
  parseAgentJsonl,
9652
11192
  parseJsonlHistoryFile,
9653
11193
  parseSince,
@@ -9662,6 +11202,7 @@ module.exports = {
9662
11202
  readAiderSessions,
9663
11203
  readAntigravitySessions,
9664
11204
  readClineSessions,
11205
+ readClaudeSubagentDefinitions,
9665
11206
  readDevinSessionsFromDb,
9666
11207
  readGeminiCliSessions,
9667
11208
  readOpenCodeSessions,