agentel 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/archive.js CHANGED
@@ -4,11 +4,12 @@ const crypto = require("crypto");
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
6
  const { loadConfig } = require("./config");
7
- const { normalizeSessionEvents } = require("./canonical-events");
7
+ const { memoryPathInfo, normalizeSessionEvents } = require("./canonical-events");
8
8
  const { parserVersionForSource } = require("./parser-versions");
9
9
  const { canonicalRepo } = require("./repo");
10
10
  const { loadRedactionConfig, loadEnvValues, mergeSummaries, redactText, styleRedactionMarkersForMarkdown } = require("./redaction");
11
11
  const { ensureBaseDirs, ensureDir, paths, readJson, writeJson } = require("./paths");
12
+ const sessionStore = require("./session-store");
12
13
 
13
14
  const SHARED_RAW_SOURCE_CACHE = new Map();
14
15
  const ESTIMATED_TOKEN_CHARS = 4;
@@ -21,12 +22,20 @@ const CURSOR_ESTIMATED_TOKEN_RATES = {
21
22
  unknown: { inputPerAssistantTurn: 6900, outputPerAssistantTurn: 270 },
22
23
  default: { inputPerAssistantTurn: 8400, outputPerAssistantTurn: 230 }
23
24
  };
25
+ const ARCHIVE_SCHEMA_VERSION = 6;
26
+ const SESSION_LIST_INDEX_VERSION = 1;
27
+ const OUTPUT_TOKEN_WORK_VERSION = 1;
28
+ const SESSION_OUTCOMES_VERSION = 2;
24
29
 
25
30
  function archiveRoot(env = process.env) {
26
31
  const cfg = loadConfig(env);
27
32
  return path.join(cfg.storage.root || paths(env).data, "agentlog");
28
33
  }
29
34
 
35
+ function sessionListIndexPath(env = process.env) {
36
+ return path.join(archiveRoot(env), "indexes", "sessions", "sessions.json");
37
+ }
38
+
30
39
  function objectPathForSession(session, env = process.env) {
31
40
  const started = new Date(session.startedAt || Date.now());
32
41
  const year = String(started.getUTCFullYear()).padStart(4, "0");
@@ -107,9 +116,10 @@ function writeSession(input, env = process.env) {
107
116
 
108
117
  const session = {
109
118
  version: 1,
110
- archiveSchemaVersion: 2,
119
+ archiveSchemaVersion: ARCHIVE_SCHEMA_VERSION,
111
120
  sessionId,
112
121
  provider,
122
+ device: archiveDeviceMetadata(cfg.device),
113
123
  scopeCanonical: input.scopeCanonical || "",
114
124
  repoCanonical,
115
125
  repoSource: input.scopeCanonical ? "scope" : repoInfo.source,
@@ -168,6 +178,19 @@ function writeSession(input, env = process.env) {
168
178
  }
169
179
  );
170
180
  session.eventCount = events.length;
181
+ const toolUsage = collectSessionToolUsage(events);
182
+ if (toolUsage.callCount) session.toolUsage = toolUsage;
183
+ const outputTokenWork = computeOutputTokenWork(archivedMessages);
184
+ if (outputTokenWork && outputTokenWork.totalTokens) session.outputTokenWork = outputTokenWork;
185
+ const outcomes = collectSessionOutcomes(events);
186
+ if (outcomes && (outcomes.editToolCalls || outcomes.filesTouched || outcomes.knowledgeCaptures || outcomes.memoryReads || outcomes.memoryWrites || outcomes.memoryLoads)) session.outcomes = outcomes;
187
+ const artifactsPath = path.join(dir, `session=${sessionId}.artifacts`);
188
+ const artifacts = captureSessionArtifacts(events, artifactsPath, cfg);
189
+ if (artifacts.length) {
190
+ session.artifactsPath = artifactsPath;
191
+ session.artifacts = artifacts;
192
+ session.artifactCount = artifacts.length;
193
+ }
171
194
  removeStaleSessionCopies(session, metadataPath, env, { replaceSourcePathCopies: input.replaceSourcePathCopies !== false });
172
195
  const rawFiles = copyRawFiles(input, rawPath, env);
173
196
  if (rawFiles.length) {
@@ -206,44 +229,166 @@ function writeSession(input, env = process.env) {
206
229
  );
207
230
  }
208
231
 
209
- touchManifest(session, { conversationPath, metadataPath, transcriptPath, eventPath, viewPath, rawPath: session.rawPath || "" }, env);
210
- return { session, conversationPath, metadataPath, transcriptPath, eventPath, viewPath, rawPath: session.rawPath || "" };
232
+ touchManifest(session, { conversationPath, metadataPath, transcriptPath, eventPath, viewPath, rawPath: session.rawPath || "", artifactsPath: session.artifactsPath || "" }, env);
233
+ updateSessionListIndexAfterWrite(session, metadataPath, env, {
234
+ replaceSourcePathCopies: input.replaceSourcePathCopies !== false
235
+ });
236
+ invalidateSessionsSnapshotCache();
237
+ return { session, conversationPath, metadataPath, transcriptPath, eventPath, viewPath, rawPath: session.rawPath || "", artifactsPath: session.artifactsPath || "" };
238
+ }
239
+
240
+ function archiveDeviceMetadata(device = {}) {
241
+ return {
242
+ id: String(device.id || ""),
243
+ name: String(device.name || ""),
244
+ slug: String(device.slug || "")
245
+ };
211
246
  }
212
247
 
213
248
  function removeStaleSessionCopies(session, metadataPath, env = process.env, options = {}) {
214
249
  const root = path.join(archiveRoot(env), "sessions");
215
250
  const replaceSourcePathCopies = options.replaceSourcePathCopies !== false;
251
+ if (_sessionsIndex.completeRootKey === root) {
252
+ for (const [file, cached] of [..._sessionsIndex.byPath.entries()]) {
253
+ if (path.resolve(file) === path.resolve(metadataPath)) continue;
254
+ const metadata = cached?.session;
255
+ if (!staleSessionMatches(metadata, session, replaceSourcePathCopies)) continue;
256
+ removeSessionArchiveFiles(metadata, file);
257
+ forgetSessionIndexPath(file);
258
+ }
259
+ return;
260
+ }
261
+ _sessionsIndex.byPath.clear();
262
+ _sessionsIndex.byId.clear();
216
263
  walk(root, (file) => {
217
264
  if (!file.endsWith(".metadata.json") || path.resolve(file) === path.resolve(metadataPath)) return;
265
+ const stat = safeStat(file);
266
+ if (!stat) return;
218
267
  const metadata = readJson(file, null);
219
- if (!metadata || metadata.provider !== session.provider) return;
220
- const sameSession = metadata.sessionId === session.sessionId;
221
- const sameSource = replaceSourcePathCopies && session.sourcePath && metadata.sourcePath === session.sourcePath;
222
- if (!sameSession && !sameSource) return;
223
- safeUnlink(metadata.conversationPath || file.replace(/\.metadata\.json$/, ".conversation.md"));
224
- safeUnlink(metadata.transcriptPath || file.replace(/\.metadata\.json$/, ".transcript.jsonl"));
225
- safeUnlink(metadata.eventPath || file.replace(/\.metadata\.json$/, ".events.jsonl"));
226
- safeUnlink(metadata.viewPath || file.replace(/\.metadata\.json$/, ".view.json"));
227
- safeRm(metadata.rawPath || file.replace(/\.metadata\.json$/, ".raw"));
228
- safeUnlink(file);
268
+ if (!metadata) return;
269
+ const hydrated = hydrateSessionMetadata(file, metadata);
270
+ if (!staleSessionMatches(hydrated, session, replaceSourcePathCopies)) {
271
+ rememberSessionIndexEntry(sessionListEntry(hydrated, file, stat));
272
+ return;
273
+ }
274
+ removeSessionArchiveFiles(hydrated, file);
229
275
  });
276
+ _sessionsIndex.completeRootKey = root;
230
277
  }
231
278
 
232
- function deleteSessionArchive(session, env = process.env) {
233
- if (!session || typeof session !== "object") return false;
234
- const metadataPath = session.metadataPath || "";
279
+ function staleSessionMatches(metadata, session, replaceSourcePathCopies) {
280
+ if (!metadata || metadata.provider !== session.provider) return false;
281
+ if (metadata.sessionId === session.sessionId) return true;
282
+ return Boolean(replaceSourcePathCopies && session.sourcePath && metadata.sourcePath === session.sourcePath);
283
+ }
284
+
285
+ function removeSessionArchiveFiles(session, metadataPath) {
235
286
  safeUnlink(session.conversationPath || metadataPath.replace(/\.metadata\.json$/, ".conversation.md"));
236
287
  safeUnlink(session.transcriptPath || metadataPath.replace(/\.metadata\.json$/, ".transcript.jsonl"));
237
288
  safeUnlink(session.eventPath || metadataPath.replace(/\.metadata\.json$/, ".events.jsonl"));
238
289
  safeUnlink(session.viewPath || metadataPath.replace(/\.metadata\.json$/, ".view.json"));
239
290
  safeRm(session.rawPath || metadataPath.replace(/\.metadata\.json$/, ".raw"));
291
+ safeRm(session.artifactsPath || metadataPath.replace(/\.metadata\.json$/, ".artifacts"));
240
292
  safeUnlink(metadataPath);
293
+ }
294
+
295
+ function deleteSessionArchive(session, env = process.env) {
296
+ if (!session || typeof session !== "object") return false;
297
+ const metadataPath = session.metadataPath || "";
298
+ removeSessionArchiveFiles(session, metadataPath);
241
299
  if (session.provider && session.sessionId) {
242
300
  safeUnlink(path.join(paths(env).revealCache, session.provider, `${session.sessionId}.jsonl`));
243
301
  }
302
+ forgetSessionIndexPath(metadataPath);
303
+ removeSessionFromListIndex(session, env);
304
+ invalidateSessionsSnapshotCache();
244
305
  return true;
245
306
  }
246
307
 
308
+ const ARTIFACT_PATH_RE = /(?:\/private)?\/(?:tmp|var\/folders)\/[A-Za-z0-9@%+=:,.~_/-]+\.(?:png|jpe?g|gif|webp|svg|pdf)\b/gi;
309
+
310
+ function collectArtifactPathRefs(events) {
311
+ const refs = new Set();
312
+ for (const event of Array.isArray(events) ? events : []) {
313
+ const body = event?.body;
314
+ if (!body) continue;
315
+ const text = JSON.stringify(body);
316
+ if (!text || (!text.includes("/tmp/") && !text.includes("/var/folders/"))) continue;
317
+ let match;
318
+ ARTIFACT_PATH_RE.lastIndex = 0;
319
+ while ((match = ARTIFACT_PATH_RE.exec(text))) refs.add(match[0]);
320
+ }
321
+ return [...refs];
322
+ }
323
+
324
+ /**
325
+ * Copy ephemeral files referenced by tool calls (screenshots and PDFs in
326
+ * temp directories) into the session archive before the OS deletes them.
327
+ * Files are content-addressed (sha256-prefixed names) because agents reuse
328
+ * paths like /tmp/screenshot.png across calls and sessions with different
329
+ * content. Previously captured artifacts are kept across re-archives even
330
+ * when the source files are gone or the setting is later disabled.
331
+ */
332
+ function captureSessionArtifacts(events, artifactsPath, cfg) {
333
+ const settings = cfg?.artifacts || {};
334
+ const manifestFile = path.join(artifactsPath, "artifacts.json");
335
+ const previous = readJson(manifestFile, null);
336
+ const entries = new Map();
337
+ let sessionBytes = 0;
338
+ if (previous && Array.isArray(previous.artifacts)) {
339
+ for (const item of previous.artifacts) {
340
+ if (!item || typeof item.name !== "string" || !item.name) continue;
341
+ entries.set(item.name, item);
342
+ sessionBytes += Number(item.bytes) || 0;
343
+ }
344
+ }
345
+ if (!settings.enabled) return [...entries.values()];
346
+ const maxFileBytes = Number(settings.maxFileBytes) > 0 ? Number(settings.maxFileBytes) : 25 * 1024 * 1024;
347
+ const maxSessionBytes = Number(settings.maxSessionBytes) > 0 ? Number(settings.maxSessionBytes) : 100 * 1024 * 1024;
348
+ const knownPaths = new Set([...entries.values()].map((item) => `${item.originalPath}:${item.sha256}`));
349
+ let added = false;
350
+ for (const ref of collectArtifactPathRefs(events)) {
351
+ const stat = safeStat(ref);
352
+ if (!stat || !stat.isFile() || stat.size <= 0 || stat.size > maxFileBytes) continue;
353
+ if (sessionBytes + stat.size > maxSessionBytes) continue;
354
+ let content;
355
+ try {
356
+ content = fs.readFileSync(ref);
357
+ } catch {
358
+ continue;
359
+ }
360
+ const sha256 = crypto.createHash("sha256").update(content).digest("hex");
361
+ if (knownPaths.has(`${ref}:${sha256}`)) continue;
362
+ const name = `${sha256.slice(0, 16)}${path.extname(ref).toLowerCase()}`;
363
+ const existing = entries.get(name);
364
+ if (existing) {
365
+ knownPaths.add(`${ref}:${sha256}`);
366
+ continue;
367
+ }
368
+ try {
369
+ ensureDir(artifactsPath);
370
+ fs.writeFileSync(path.join(artifactsPath, name), content, { mode: 0o600 });
371
+ } catch {
372
+ continue;
373
+ }
374
+ entries.set(name, {
375
+ name,
376
+ originalPath: ref,
377
+ bytes: content.length,
378
+ sha256,
379
+ capturedAt: new Date().toISOString()
380
+ });
381
+ knownPaths.add(`${ref}:${sha256}`);
382
+ sessionBytes += content.length;
383
+ added = true;
384
+ }
385
+ if (added || (entries.size && !previous)) {
386
+ ensureDir(artifactsPath);
387
+ writeJson(manifestFile, { version: 1, artifacts: [...entries.values()] });
388
+ }
389
+ return [...entries.values()];
390
+ }
391
+
247
392
  function copyRawFiles(input, rawPath, env = process.env) {
248
393
  const files = rawSourceFiles(input);
249
394
  const references = rawReferences(input);
@@ -406,7 +551,20 @@ function safeRawFilename(value) {
406
551
  }
407
552
 
408
553
  function fileSha256(file) {
409
- return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
554
+ // Hash in fixed-size chunks; raw session sources can be hundreds of MB and
555
+ // readFileSync would hold the whole file in memory just to hash it.
556
+ const hash = crypto.createHash("sha256");
557
+ const fd = fs.openSync(file, "r");
558
+ try {
559
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
560
+ let bytes = 0;
561
+ while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
562
+ hash.update(buffer.subarray(0, bytes));
563
+ }
564
+ } finally {
565
+ fs.closeSync(fd);
566
+ }
567
+ return hash.digest("hex");
410
568
  }
411
569
 
412
570
  function safeUnlink(file) {
@@ -530,7 +688,8 @@ function decodeSegment(value) {
530
688
 
531
689
  const _sessionsIndex = {
532
690
  byPath: new Map(),
533
- byId: new Map()
691
+ byId: new Map(),
692
+ completeRootKey: ""
534
693
  };
535
694
 
536
695
  function hydrateSessionMetadata(file, metadata) {
@@ -543,12 +702,12 @@ function hydrateSessionMetadata(file, metadata) {
543
702
  return { ...metadata, conversationPath, metadataPath: file, transcriptPath, eventPath, viewPath, rawPath };
544
703
  }
545
704
 
546
- // TTL cache for the archive walk. The web viewer typically fires several
547
- // list-derived endpoints (/api/recent, /api/tree, /api/stats) within a single
548
- // page load; caching the snapshot for a short window lets them share one
549
- // walk without anyone seeing >1 second of staleness. Tests/imports/CLI paths
550
- // can invalidate explicitly via `invalidateSessionsSnapshotCache()`.
551
- const SNAPSHOT_CACHE_TTL_MS = 1000;
705
+ // TTL cache for the archive snapshot. The web viewer fires several
706
+ // list-derived endpoints while users move between history/stats/projects; keep
707
+ // the hydrated metadata snapshot warm long enough for that browsing session to
708
+ // feel instant. Import/write paths in this process invalidate explicitly via
709
+ // `invalidateSessionsSnapshotCache()`.
710
+ const SNAPSHOT_CACHE_TTL_MS = 15000;
552
711
  const _snapshotCache = {
553
712
  rootKey: "",
554
713
  snapshot: null,
@@ -582,48 +741,35 @@ function invalidateSessionsSnapshotCache() {
582
741
  _snapshotCache.takenAtMs = 0;
583
742
  }
584
743
 
744
+ function listSessionsSnapshot(env = process.env) {
745
+ return readSessionListIndex(env) || rebuildSessionListIndex(env);
746
+ }
747
+
585
748
  /**
586
749
  * Walk the archive once and return both the hydrated session list and a
587
- * fingerprint summarizing the snapshot. Callers that need an HTTP etag (recent,
588
- * tree, repo-sessions, stats) can use the fingerprint without re-walking; the
589
- * fingerprint changes iff some session's metadata mtime/size changed or the
590
- * count changed (added/removed sessions).
591
- *
592
- * The fingerprint deliberately summarizes via XOR over per-file mtime/size
593
- * values rather than concatenating sorted file paths — XOR is order-independent
594
- * and folds the whole archive into a single 16-byte hex string in O(N) time
595
- * with no allocation.
750
+ * fingerprint summarizing the snapshot. The result is persisted as a derived
751
+ * session-list index so steady-state web reads can avoid using the filesystem
752
+ * as a query engine.
596
753
  */
597
- function listSessionsSnapshot(env = process.env) {
754
+ function rebuildSessionListIndex(env = process.env) {
598
755
  const root = path.join(archiveRoot(env), "sessions");
599
756
  const seen = new Set();
600
- const sessions = [];
601
- let count = 0;
602
- // 64-bit XOR accumulators kept as JS BigInt so we never overflow. Slightly
603
- // slower than Number arithmetic but the cost is dwarfed by fs.statSync.
604
- let mtimeXor = 0n;
605
- let sizeXor = 0n;
606
- let maxMtimeMs = 0;
757
+ const entries = [];
607
758
  walk(root, (file) => {
608
759
  if (!file.endsWith(".metadata.json")) return;
609
760
  seen.add(file);
610
761
  let stat;
611
762
  try { stat = fs.statSync(file); } catch { return; }
612
- count += 1;
613
- mtimeXor ^= BigInt(Math.trunc(stat.mtimeMs));
614
- sizeXor ^= BigInt(stat.size);
615
- if (stat.mtimeMs > maxMtimeMs) maxMtimeMs = stat.mtimeMs;
616
763
  const cached = _sessionsIndex.byPath.get(file);
617
764
  if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
618
- sessions.push(cached.session);
765
+ entries.push(sessionListEntry(cached.session, file, stat));
619
766
  return;
620
767
  }
621
768
  const metadata = readJson(file, null);
622
769
  if (!metadata) return;
623
770
  const session = hydrateSessionMetadata(file, metadata);
624
- _sessionsIndex.byPath.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, session });
625
- if (session.sessionId) _sessionsIndex.byId.set(session.sessionId, file);
626
- sessions.push(session);
771
+ rememberSessionIndexEntry(sessionListEntry(session, file, stat));
772
+ entries.push(sessionListEntry(session, file, stat));
627
773
  });
628
774
  for (const key of [..._sessionsIndex.byPath.keys()]) {
629
775
  if (!seen.has(key)) {
@@ -632,9 +778,341 @@ function listSessionsSnapshot(env = process.env) {
632
778
  _sessionsIndex.byPath.delete(key);
633
779
  }
634
780
  }
635
- sessions.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
636
- const fingerprint = `${count.toString(36)}-${mtimeXor.toString(16)}-${sizeXor.toString(16)}`;
637
- return { sessions, fingerprint, count, maxMtimeMs };
781
+ _sessionsIndex.completeRootKey = root;
782
+ const snapshot = snapshotFromSessionListEntries(entries);
783
+ writeSessionListIndex(snapshot, env);
784
+ return snapshot;
785
+ }
786
+
787
+ function readSessionListIndex(env = process.env) {
788
+ const root = archiveRoot(env);
789
+ // Fast path: SQLite store. Indexed reads, sub-millisecond. Stat-validate
790
+ // each row against the on-disk metadata.json file — if anything drifted
791
+ // (mtime/size differ, or the file is gone) we treat the cache as stale
792
+ // and let the caller rebuild from a filesystem walk. The stat-check is
793
+ // bounded I/O (~50-80ms for 4k files) and only runs on cold reads outside
794
+ // the snapshot TTL.
795
+ const fromSqlite = sessionStore.readAllSessions(root);
796
+ if (fromSqlite && fromSqlite.length) {
797
+ const entries = fromSqlite.map(normalizeSessionListEntry).filter(Boolean);
798
+ if (entries.length && sessionListEntriesMatchDisk(entries) && sessionListEntriesCoverManifest(entries, root)) {
799
+ const fingerprint = sessionListFingerprint(entries);
800
+ replaceSessionMemoryIndex(entries, path.join(root, "sessions"));
801
+ return snapshotFromSessionListEntries(entries, { fingerprint, fromIndex: true });
802
+ }
803
+ return null;
804
+ }
805
+ // One-time migration: legacy JSON index. If present and valid, hydrate the
806
+ // SQLite store from it so subsequent reads take the fast path. The JSON
807
+ // file is left in place but no longer written.
808
+ const file = sessionListIndexPath(env);
809
+ let payload = null;
810
+ try {
811
+ payload = readJson(file, null);
812
+ } catch {
813
+ return null;
814
+ }
815
+ if (!payload || payload.version !== SESSION_LIST_INDEX_VERSION || payload.archiveRoot !== root) return null;
816
+ const entries = Array.isArray(payload.entries) ? payload.entries.map(normalizeSessionListEntry).filter(Boolean) : [];
817
+ if (entries.length !== Number(payload.count || 0)) return null;
818
+ const fingerprint = sessionListFingerprint(entries);
819
+ if (payload.fingerprint && payload.fingerprint !== fingerprint) return null;
820
+ if (!sessionListEntriesMatchDisk(entries) || !sessionListEntriesCoverManifest(entries, root)) return null;
821
+ // Best-effort migrate; if SQLite is unavailable we still serve from memory.
822
+ sessionStore.replaceAllSessions(entries, root);
823
+ replaceSessionMemoryIndex(entries, path.join(root, "sessions"));
824
+ return snapshotFromSessionListEntries(entries, {
825
+ fingerprint,
826
+ generatedAt: payload.generatedAt || "",
827
+ fromIndex: true
828
+ });
829
+ }
830
+
831
+ function writeSessionListIndex(snapshot, env = process.env) {
832
+ const entries = Array.isArray(snapshot?.entries) ? snapshot.entries.map(normalizeSessionListEntry).filter(Boolean) : [];
833
+ const next = snapshotFromSessionListEntries(entries);
834
+ const root = archiveRoot(env);
835
+ // Bulk replace in the SQLite store. Used after filesystem rebuild.
836
+ if (sessionStore.replaceAllSessions(next.entries, root)) return true;
837
+ // Fallback: write the legacy JSON file so a setup without better-sqlite3
838
+ // still has a persistent session index.
839
+ try {
840
+ writeJson(sessionListIndexPath(env), {
841
+ version: SESSION_LIST_INDEX_VERSION,
842
+ archiveRoot: root,
843
+ generatedAt: new Date().toISOString(),
844
+ fingerprint: next.fingerprint,
845
+ count: next.count,
846
+ maxMtimeMs: next.maxMtimeMs,
847
+ entries: next.entries
848
+ }, { pretty: false });
849
+ return true;
850
+ } catch {
851
+ return false;
852
+ }
853
+ }
854
+
855
+ function snapshotFromSessionListEntries(entries, options = {}) {
856
+ const normalized = (Array.isArray(entries) ? entries : []).map(normalizeSessionListEntry).filter(Boolean);
857
+ normalized.sort((a, b) => String(b.session.startedAt).localeCompare(String(a.session.startedAt)));
858
+ const sessions = normalized.map((entry) => entry.session);
859
+ return {
860
+ sessions,
861
+ entries: normalized,
862
+ fingerprint: options.fingerprint || sessionListFingerprint(normalized),
863
+ count: normalized.length,
864
+ maxMtimeMs: normalized.reduce((max, entry) => Math.max(max, entry.mtimeMs || 0), 0),
865
+ generatedAt: options.generatedAt || "",
866
+ fromIndex: Boolean(options.fromIndex)
867
+ };
868
+ }
869
+
870
+ /**
871
+ * Returns true iff every entry's stored (mtime, size) still matches the
872
+ * current on-disk metadata.json. Out-of-band file edits (manual changes,
873
+ * file restores from backup) invalidate via mtime/size drift. Missing
874
+ * files also invalidate (the rebuild path will drop them).
875
+ *
876
+ * Intentionally returns false on the first mismatch — the cache is only
877
+ * useful if it's wholly accurate; partial freshness still requires a walk.
878
+ */
879
+ function sessionListEntriesMatchDisk(entries) {
880
+ for (const entry of entries) {
881
+ if (!entry?.metadataPath) return false;
882
+ const stat = safeStat(entry.metadataPath);
883
+ if (!stat) return false;
884
+ if (Math.trunc(stat.mtimeMs) !== Math.trunc(entry.mtimeMs)) return false;
885
+ if (Number(stat.size) !== Number(entry.size)) return false;
886
+ }
887
+ return true;
888
+ }
889
+
890
+ function sessionListEntriesCoverManifest(entries, root) {
891
+ const manifest = readJson(path.join(root, "sessions", "manifest.json"), null);
892
+ if (!manifest || !Array.isArray(manifest.sessions) || !manifest.sessions.length) return true;
893
+
894
+ const indexedPaths = new Set(
895
+ entries
896
+ .map((entry) => entry?.metadataPath || "")
897
+ .filter(Boolean)
898
+ .map((file) => path.resolve(file))
899
+ );
900
+ let liveManifestPathCount = 0;
901
+ for (const item of manifest.sessions) {
902
+ const metadataPath = item?.metadataPath || "";
903
+ if (!metadataPath || !safeStat(metadataPath)) continue;
904
+ liveManifestPathCount += 1;
905
+ if (!indexedPaths.has(path.resolve(metadataPath))) return false;
906
+ }
907
+ return liveManifestPathCount <= entries.length;
908
+ }
909
+
910
+ function sessionListFingerprint(entries) {
911
+ const rows = (Array.isArray(entries) ? entries : []).map(normalizeSessionListEntry).filter(Boolean);
912
+ if (!rows.length) return "0-0-0";
913
+ let mtimeXor = 0n;
914
+ let sizeXor = 0n;
915
+ const hash = crypto.createHash("sha1");
916
+ for (const entry of [...rows].sort((a, b) => a.metadataPath.localeCompare(b.metadataPath))) {
917
+ const mtime = Math.trunc(entry.mtimeMs || 0);
918
+ const size = Math.trunc(entry.size || 0);
919
+ mtimeXor ^= BigInt(mtime);
920
+ sizeXor ^= BigInt(size);
921
+ hash.update(entry.metadataPath).update("\0").update(String(mtime)).update("\0").update(String(size)).update("\0");
922
+ }
923
+ return `${rows.length.toString(36)}-${mtimeXor.toString(16)}-${sizeXor.toString(16)}-${hash.digest("base64url").slice(0, 16)}`;
924
+ }
925
+
926
+ function sessionListEntry(session, metadataPath, stat) {
927
+ return normalizeSessionListEntry({
928
+ metadataPath,
929
+ mtimeMs: stat?.mtimeMs || 0,
930
+ size: stat?.size || 0,
931
+ session
932
+ });
933
+ }
934
+
935
+ function normalizeSessionListEntry(entry) {
936
+ if (!entry || typeof entry !== "object" || !entry.session || typeof entry.session !== "object") return null;
937
+ const metadataPath = entry.metadataPath || entry.session.metadataPath || "";
938
+ if (!metadataPath) return null;
939
+ const session = hydrateSessionListIndexMetadata(metadataPath, entry.session);
940
+ return {
941
+ metadataPath,
942
+ mtimeMs: Number(entry.mtimeMs || 0),
943
+ size: Number(entry.size || 0),
944
+ session
945
+ };
946
+ }
947
+
948
+ function hydrateSessionListIndexMetadata(file, metadata) {
949
+ return {
950
+ ...metadata,
951
+ conversationPath: metadata.conversationPath || file.replace(/\.metadata\.json$/, ".conversation.md"),
952
+ metadataPath: file,
953
+ transcriptPath: metadata.transcriptPath || file.replace(/\.metadata\.json$/, ".transcript.jsonl"),
954
+ eventPath: metadata.eventPath || file.replace(/\.metadata\.json$/, ".events.jsonl"),
955
+ viewPath: metadata.viewPath || file.replace(/\.metadata\.json$/, ".view.json"),
956
+ rawPath: metadata.rawPath || ""
957
+ };
958
+ }
959
+
960
+ function rememberSessionIndexEntry(entry) {
961
+ const normalized = normalizeSessionListEntry(entry);
962
+ if (!normalized) return;
963
+ _sessionsIndex.byPath.set(normalized.metadataPath, {
964
+ mtimeMs: normalized.mtimeMs,
965
+ size: normalized.size,
966
+ session: normalized.session
967
+ });
968
+ if (normalized.session.sessionId) _sessionsIndex.byId.set(normalized.session.sessionId, normalized.metadataPath);
969
+ }
970
+
971
+ function replaceSessionMemoryIndex(entries, rootKey = "") {
972
+ _sessionsIndex.byPath.clear();
973
+ _sessionsIndex.byId.clear();
974
+ _sessionsIndex.completeRootKey = rootKey;
975
+ for (const entry of entries) rememberSessionIndexEntry(entry);
976
+ }
977
+
978
+ function forgetSessionIndexPath(metadataPath) {
979
+ if (!metadataPath) return;
980
+ const existing = _sessionsIndex.byPath.get(metadataPath);
981
+ if (existing?.session?.sessionId) _sessionsIndex.byId.delete(existing.session.sessionId);
982
+ _sessionsIndex.byPath.delete(metadataPath);
983
+ }
984
+
985
+ function updateSessionListIndexAfterWrite(session, metadataPath, env = process.env, options = {}) {
986
+ const stat = safeStat(metadataPath);
987
+ if (!stat) return;
988
+ const sourcePath = session.sourcePath || "";
989
+ const replaceSourcePathCopies = options.replaceSourcePathCopies !== false;
990
+ const resolvedMetadataPath = path.resolve(metadataPath);
991
+ const supersededSessionIds = new Set();
992
+ const supersededMetadataPaths = new Set();
993
+ for (const [file, cached] of [..._sessionsIndex.byPath.entries()]) {
994
+ const existing = cached?.session || {};
995
+ if (path.resolve(file) === resolvedMetadataPath) {
996
+ forgetSessionIndexPath(file);
997
+ // When the same metadata file is being rewritten under a *different*
998
+ // session id (e.g. a re-import that recomputed stableSessionId after
999
+ // a parser change), the prior row keyed on existing.sessionId would
1000
+ // otherwise stay orphaned in SQLite — the upsert below inserts under
1001
+ // the new id and the per-metadata-path delete is skipped because the
1002
+ // path matches the incoming entry. Capture the old session id here
1003
+ // so the SQLite delete loop evicts it explicitly.
1004
+ if (existing.sessionId && existing.sessionId !== session.sessionId) {
1005
+ supersededSessionIds.add(existing.sessionId);
1006
+ }
1007
+ supersededMetadataPaths.add(file);
1008
+ continue;
1009
+ }
1010
+ if (existing.sessionId && existing.sessionId === session.sessionId) {
1011
+ forgetSessionIndexPath(file);
1012
+ if (existing.sessionId) supersededSessionIds.add(existing.sessionId);
1013
+ supersededMetadataPaths.add(file);
1014
+ continue;
1015
+ }
1016
+ if (replaceSourcePathCopies && sourcePath && existing.provider === session.provider && existing.sourcePath === sourcePath) {
1017
+ forgetSessionIndexPath(file);
1018
+ if (existing.sessionId) supersededSessionIds.add(existing.sessionId);
1019
+ supersededMetadataPaths.add(file);
1020
+ }
1021
+ }
1022
+ const nextEntry = sessionListEntry(session, metadataPath, stat);
1023
+ rememberSessionIndexEntry(nextEntry);
1024
+ // Fast path: incremental SQLite upsert + targeted deletes for superseded
1025
+ // rows. Avoids reading the whole index just to add one row. Includes a
1026
+ // SQL-level delete for (provider, sourcePath) since the in-memory loop
1027
+ // above only knows about entries currently cached in this process — rows
1028
+ // from prior process lifetimes still live in the SQLite store and would
1029
+ // otherwise survive.
1030
+ const root = archiveRoot(env);
1031
+ if (sessionStore.storeAvailable(root)) {
1032
+ for (const id of supersededSessionIds) {
1033
+ if (id && id !== session.sessionId) sessionStore.deleteSession({ sessionId: id }, root);
1034
+ }
1035
+ for (const file of supersededMetadataPaths) {
1036
+ if (file && path.resolve(file) !== resolvedMetadataPath) sessionStore.deleteSession({ metadataPath: file }, root);
1037
+ }
1038
+ if (replaceSourcePathCopies && sourcePath) {
1039
+ sessionStore.deleteSameSourceSessions({
1040
+ provider: session.provider || "",
1041
+ sourcePath,
1042
+ keepSessionId: session.sessionId || ""
1043
+ }, root);
1044
+ }
1045
+ sessionStore.upsertSession(nextEntry, root);
1046
+ return;
1047
+ }
1048
+ // Legacy JSON fallback for setups without better-sqlite3.
1049
+ const indexPath = sessionListIndexPath(env);
1050
+ if (!fs.existsSync(indexPath)) return;
1051
+ let payload = null;
1052
+ try {
1053
+ payload = readJson(indexPath, null);
1054
+ } catch {
1055
+ return;
1056
+ }
1057
+ if (
1058
+ !payload ||
1059
+ payload.version !== SESSION_LIST_INDEX_VERSION ||
1060
+ payload.archiveRoot !== archiveRoot(env) ||
1061
+ !Array.isArray(payload.entries)
1062
+ ) {
1063
+ return;
1064
+ }
1065
+ const entries = payload.entries
1066
+ .map(normalizeSessionListEntry)
1067
+ .filter(Boolean)
1068
+ .filter((entry) => {
1069
+ const existing = entry.session || {};
1070
+ if (path.resolve(entry.metadataPath) === resolvedMetadataPath) return false;
1071
+ if (existing.sessionId && existing.sessionId === session.sessionId) return false;
1072
+ if (replaceSourcePathCopies && sourcePath && existing.provider === session.provider && existing.sourcePath === sourcePath) return false;
1073
+ return true;
1074
+ });
1075
+ entries.push(nextEntry);
1076
+ writeSessionListIndex({ entries }, env);
1077
+ }
1078
+
1079
+ function removeSessionFromListIndex(session, env = process.env) {
1080
+ const root = archiveRoot(env);
1081
+ // Fast path: targeted SQLite delete.
1082
+ if (sessionStore.storeAvailable(root)) {
1083
+ sessionStore.deleteSession({
1084
+ sessionId: session.sessionId || "",
1085
+ metadataPath: session.metadataPath || ""
1086
+ }, root);
1087
+ return;
1088
+ }
1089
+ const indexPath = sessionListIndexPath(env);
1090
+ if (!fs.existsSync(indexPath)) return;
1091
+ let payload = null;
1092
+ try {
1093
+ payload = readJson(indexPath, null);
1094
+ } catch {
1095
+ return;
1096
+ }
1097
+ if (
1098
+ !payload ||
1099
+ payload.version !== SESSION_LIST_INDEX_VERSION ||
1100
+ payload.archiveRoot !== archiveRoot(env) ||
1101
+ !Array.isArray(payload.entries)
1102
+ ) {
1103
+ return;
1104
+ }
1105
+ const metadataPath = session.metadataPath || "";
1106
+ const resolvedMetadataPath = metadataPath ? path.resolve(metadataPath) : "";
1107
+ const entries = payload.entries
1108
+ .map(normalizeSessionListEntry)
1109
+ .filter(Boolean)
1110
+ .filter((entry) => {
1111
+ if (resolvedMetadataPath && path.resolve(entry.metadataPath) === resolvedMetadataPath) return false;
1112
+ if (session.sessionId && entry.session.sessionId === session.sessionId) return false;
1113
+ return true;
1114
+ });
1115
+ writeSessionListIndex({ entries }, env);
638
1116
  }
639
1117
 
640
1118
  function findSessionById(sessionId, env = process.env) {
@@ -668,10 +1146,13 @@ function computeSessionUsage(messages) {
668
1146
  let inputTokens = 0;
669
1147
  let outputTokens = 0;
670
1148
  let cacheInputTokens = 0;
1149
+ let cacheCreationInputTokens = 0;
1150
+ let cacheReadInputTokens = 0;
671
1151
  let countedCacheInputTokens = 0;
672
1152
  let reasoningOutputTokens = 0;
673
1153
  let countedReasoningOutputTokens = 0;
674
1154
  let toolUsePromptTokens = 0;
1155
+ let costUsd = 0;
675
1156
  let maxUndifferentiatedTotalTokens = 0;
676
1157
  let totalInputTokens = 0;
677
1158
  let totalOutputTokens = 0;
@@ -705,10 +1186,24 @@ function computeSessionUsage(messages) {
705
1186
  any = true;
706
1187
  }
707
1188
  if (summary.cacheInputTokens > 0) cacheInputTokens += summary.cacheInputTokens;
1189
+ if (summary.cacheCreationInputTokens > 0) cacheCreationInputTokens += summary.cacheCreationInputTokens;
1190
+ if (summary.cacheReadInputTokens > 0) cacheReadInputTokens += summary.cacheReadInputTokens;
708
1191
  if (summary.countedCacheInputTokens > 0) countedCacheInputTokens += summary.countedCacheInputTokens;
709
1192
  if (summary.reasoningOutputTokens > 0) reasoningOutputTokens += summary.reasoningOutputTokens;
710
1193
  if (summary.countedReasoningOutputTokens > 0) countedReasoningOutputTokens += summary.countedReasoningOutputTokens;
711
1194
  if (summary.toolUsePromptTokens > 0) toolUsePromptTokens += summary.toolUsePromptTokens;
1195
+ const messageCostUsd = positiveCostNumber(firstUsageNumber(
1196
+ usage.costUsd,
1197
+ usage.costUSD,
1198
+ usage.cost_usd,
1199
+ usage.totalCostUsd,
1200
+ usage.total_cost_usd,
1201
+ message.metadata?.costUsd,
1202
+ message.metadata?.costUSD,
1203
+ message.metadata?.cost_usd,
1204
+ message.metadata?.cost
1205
+ ));
1206
+ if (messageCostUsd > 0) costUsd += messageCostUsd;
712
1207
  if (!summary.hasSplitTokens && !summary.hasExtraTokens && summary.hasTotalTokens) {
713
1208
  maxUndifferentiatedTotalTokens = Math.max(maxUndifferentiatedTotalTokens, summary.totalTokens);
714
1209
  }
@@ -736,15 +1231,411 @@ function computeSessionUsage(messages) {
736
1231
  totalInputTokens: totalInputTokens || inputTokens,
737
1232
  totalOutputTokens: totalOutputTokens || outputTokens
738
1233
  };
1234
+ if (cacheCreationInputTokens > 0) result.cacheCreationInputTokens = cacheCreationInputTokens;
1235
+ if (cacheReadInputTokens > 0) result.cacheReadInputTokens = cacheReadInputTokens;
739
1236
  if (reasoningOutputTokens > 0 && countedReasoningOutputTokens === 0) result.reasoningOutputTokensIncludedInOutput = true;
740
1237
  if (cacheInputTokens > 0 && countedCacheInputTokens === 0) result.cacheInputTokensIncludedInInput = true;
741
1238
  if (estimatedUsageCount > 0 && actualUsageCount === 0) {
742
1239
  result.estimated = true;
743
1240
  if (estimationMethods.size === 1) result.estimationMethod = Array.from(estimationMethods)[0];
744
1241
  }
1242
+ if (costUsd > 0) {
1243
+ result.costUsd = Number(costUsd.toFixed(8));
1244
+ result.currency = "USD";
1245
+ result.costSource = "provider-usage";
1246
+ result.costEstimated = false;
1247
+ }
745
1248
  return result;
746
1249
  }
747
1250
 
1251
+ function computeOutputTokenWork(messages) {
1252
+ let textTokens = 0;
1253
+ let toolUseTokens = 0;
1254
+ let reasoningTokens = 0;
1255
+ let unknownTokens = 0;
1256
+ let messageCount = 0;
1257
+ let mixedMessageCount = 0;
1258
+ let estimatedUsageCount = 0;
1259
+ let actualUsageCount = 0;
1260
+ const seenUsageRequests = new Set();
1261
+ for (const message of messages || []) {
1262
+ if (String(message?.role || "").toLowerCase() !== "assistant") continue;
1263
+ const usage = message?.metadata?.usage;
1264
+ if (!usage || typeof usage !== "object") continue;
1265
+ const requestId = String(message.metadata?.requestId || "").trim();
1266
+ if (requestId) {
1267
+ if (seenUsageRequests.has(requestId)) continue;
1268
+ seenUsageRequests.add(requestId);
1269
+ }
1270
+ const summary = usageTokenSummary(usage);
1271
+ if (!summary.hasAny) continue;
1272
+ const output = positiveTokenNumber(summary.outputTokens);
1273
+ const reasoning = positiveTokenNumber(summary.reasoningOutputTokens);
1274
+ if (output <= 0 && reasoning <= 0) continue;
1275
+ messageCount++;
1276
+ if (summary.estimated) estimatedUsageCount++;
1277
+ else actualUsageCount++;
1278
+
1279
+ if (reasoning > 0) reasoningTokens += reasoning;
1280
+ const nonReasoningOutput = Math.max(0, output - (summary.reasoningOutputTokensIncludedInOutput ? reasoning : 0));
1281
+ if (nonReasoningOutput <= 0) continue;
1282
+ const kind = outputTokenWorkKind(message);
1283
+ if (kind === "text") textTokens += nonReasoningOutput;
1284
+ else if (kind === "toolUse") toolUseTokens += nonReasoningOutput;
1285
+ else if (kind === "reasoning") reasoningTokens += nonReasoningOutput;
1286
+ else {
1287
+ unknownTokens += nonReasoningOutput;
1288
+ if (kind === "mixed") mixedMessageCount++;
1289
+ }
1290
+ }
1291
+ const totalTokens = textTokens + toolUseTokens + reasoningTokens + unknownTokens;
1292
+ if (!totalTokens) return null;
1293
+ const knownTokens = textTokens + toolUseTokens + reasoningTokens;
1294
+ const result = {
1295
+ version: OUTPUT_TOKEN_WORK_VERSION,
1296
+ textTokens,
1297
+ toolUseTokens,
1298
+ reasoningTokens,
1299
+ unknownTokens,
1300
+ totalTokens,
1301
+ knownTokens,
1302
+ coverage: Number((knownTokens / totalTokens).toFixed(4)),
1303
+ messageCount
1304
+ };
1305
+ if (mixedMessageCount) result.mixedMessageCount = mixedMessageCount;
1306
+ if (estimatedUsageCount > 0 && actualUsageCount === 0) result.estimated = true;
1307
+ return result;
1308
+ }
1309
+
1310
+ function outputTokenWorkKind(message) {
1311
+ const metadata = message?.metadata || {};
1312
+ const summaryKind = String(metadata.summaryKind || metadata.summary_kind || "").toLowerCase();
1313
+ if (metadata.supplementary && /^(thinking|reasoning)$/.test(summaryKind)) return "reasoning";
1314
+ if (metadata.supplementary) return "unknown";
1315
+ const hasText = String(message?.content || "").trim().length > 0;
1316
+ const hasTools = Array.isArray(metadata.toolCalls) && metadata.toolCalls.length > 0;
1317
+ if (hasText && hasTools) return "mixed";
1318
+ if (hasTools) return "toolUse";
1319
+ if (hasText) return "text";
1320
+ return "unknown";
1321
+ }
1322
+
1323
+ function collectSessionOutcomes(events) {
1324
+ let editToolCalls = 0;
1325
+ let knowledgeCaptures = 0;
1326
+ let memoryReads = 0;
1327
+ let memoryWrites = 0;
1328
+ let memoryLoads = 0;
1329
+ const touchedFiles = new Set();
1330
+ const editCallsByEventId = new Map();
1331
+ for (const event of Array.isArray(events) ? events : []) {
1332
+ const kind = String(event?.kind || "");
1333
+ if (kind === "memory.read") {
1334
+ memoryReads++;
1335
+ continue;
1336
+ }
1337
+ if (kind === "memory.write") {
1338
+ memoryWrites++;
1339
+ continue;
1340
+ }
1341
+ if (kind === "memory.loaded") {
1342
+ memoryLoads++;
1343
+ continue;
1344
+ }
1345
+ if (kind === "tool.called") {
1346
+ const call = event?.body?.toolCall || {};
1347
+ const indexed = event?.indexed || {};
1348
+ const rawName = firstStringValue(indexed.toolName, call.name, call.displayName, indexed.title, "tool");
1349
+ const category = normalizeToolUsageCategory(firstStringValue(indexed.toolCategory, call.category, call.rawCategory), rawName);
1350
+ if (category !== "edit") continue;
1351
+ editToolCalls++;
1352
+ const paths = collectToolCallPathCandidates(call, indexed);
1353
+ let capturedKnowledge = false;
1354
+ for (const candidate of paths) {
1355
+ const normalized = normalizeOutcomePath(candidate);
1356
+ if (!normalized) continue;
1357
+ touchedFiles.add(normalized);
1358
+ if (isKnowledgeCapturePath(normalized)) capturedKnowledge = true;
1359
+ }
1360
+ if (event.eventId) editCallsByEventId.set(event.eventId, { capturedKnowledge });
1361
+ continue;
1362
+ }
1363
+ if (kind === "tool.completed") {
1364
+ const parentEventId = String(event.parentEventId || "");
1365
+ const pending = parentEventId ? editCallsByEventId.get(parentEventId) : null;
1366
+ if (!pending || !pending.capturedKnowledge) continue;
1367
+ const status = String(event?.indexed?.status || event?.body?.toolResult?.status || "").toLowerCase();
1368
+ if (status.includes("error") || status.includes("fail")) continue;
1369
+ knowledgeCaptures++;
1370
+ }
1371
+ }
1372
+ return {
1373
+ version: SESSION_OUTCOMES_VERSION,
1374
+ editToolCalls,
1375
+ filesTouched: touchedFiles.size,
1376
+ knowledgeCaptures,
1377
+ memoryReads,
1378
+ memoryWrites,
1379
+ memoryLoads
1380
+ };
1381
+ }
1382
+
1383
+ const OUTCOME_PATH_ARGUMENT_KEYS = new Set([
1384
+ "path",
1385
+ "paths",
1386
+ "file",
1387
+ "files",
1388
+ "filename",
1389
+ "filenames",
1390
+ "file_path",
1391
+ "filepath",
1392
+ "relative_path",
1393
+ "relativeworkspacepath",
1394
+ "relative_workspace_path",
1395
+ "target",
1396
+ "target_path",
1397
+ "source_path",
1398
+ "destination_path"
1399
+ ]);
1400
+
1401
+ function collectToolCallPathCandidates(call, indexed = {}) {
1402
+ const paths = [];
1403
+ appendOutcomePath(paths, call?.target);
1404
+ appendOutcomePath(paths, indexed?.target);
1405
+ collectPathValues(call?.arguments, "", 0, paths);
1406
+ collectPathValues(call?.input, "", 0, paths);
1407
+ return Array.from(new Set(paths.map(normalizeOutcomePath).filter(Boolean)));
1408
+ }
1409
+
1410
+ function collectPathValues(value, key = "", depth = 0, out = []) {
1411
+ if (value == null || depth > 5) return out;
1412
+ if (typeof value === "string") {
1413
+ if (isOutcomePathKey(key)) appendOutcomePath(out, value);
1414
+ return out;
1415
+ }
1416
+ if (Array.isArray(value)) {
1417
+ for (const item of value) collectPathValues(item, key, depth + 1, out);
1418
+ return out;
1419
+ }
1420
+ if (typeof value !== "object") return out;
1421
+ for (const [childKey, childValue] of Object.entries(value)) {
1422
+ collectPathValues(childValue, childKey, depth + 1, out);
1423
+ }
1424
+ return out;
1425
+ }
1426
+
1427
+ function isOutcomePathKey(key) {
1428
+ const normalized = String(key || "").replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toLowerCase();
1429
+ return OUTCOME_PATH_ARGUMENT_KEYS.has(normalized);
1430
+ }
1431
+
1432
+ function appendOutcomePath(out, value) {
1433
+ if (typeof value !== "string") return;
1434
+ const normalized = normalizeOutcomePath(value);
1435
+ if (normalized) out.push(normalized);
1436
+ }
1437
+
1438
+ function normalizeOutcomePath(value) {
1439
+ const text = String(value || "").trim();
1440
+ if (!text || text.length > 4096) return "";
1441
+ return text.replace(/\\/g, "/").replace(/\/+/g, "/");
1442
+ }
1443
+
1444
+ function isKnowledgeCapturePath(value) {
1445
+ const normalized = normalizeOutcomePath(value).toLowerCase();
1446
+ if (!normalized) return false;
1447
+ if (memoryPathInfo(normalized)) return true;
1448
+ const base = path.basename(normalized);
1449
+ if (["agents.md", "claude.md", "gemini.md", "skill.md", ".cursorrules", ".windsurfrules", "rules.md", "memory.md"].includes(base)) return true;
1450
+ if (/\/\.(agents|codex|claude)\/skills\//.test(normalized)) return true;
1451
+ if (/\/\.cursor\/rules\//.test(normalized) || /\/\.windsurf\/rules\//.test(normalized)) return true;
1452
+ if (/\/skills\/[^/]+\/skill\.md$/.test(normalized)) return true;
1453
+ if (/\b(adr|adrs|decision|decisions|plan|plans|planning|rfc|rfcs|roadmap|project-rules)\b/.test(normalized) && /\.(md|mdx|rst|txt|adoc|org)$/i.test(base)) return true;
1454
+ return false;
1455
+ }
1456
+
1457
+ function collectSessionToolUsage(events) {
1458
+ const byTool = new Map();
1459
+ const byCategory = new Map();
1460
+ const bySkill = new Map();
1461
+ const byMcpServer = new Map();
1462
+ const skillByCallId = new Map();
1463
+ let callCount = 0;
1464
+ let completedCount = 0;
1465
+ let errorCount = 0;
1466
+ for (const event of Array.isArray(events) ? events : []) {
1467
+ const kind = String(event?.kind || "");
1468
+ if (kind !== "tool.called" && kind !== "tool.completed") continue;
1469
+ const call = event?.body?.toolCall || {};
1470
+ const result = event?.body?.toolResult || {};
1471
+ const indexed = event?.indexed || {};
1472
+ const rawName = firstStringValue(
1473
+ indexed.toolName,
1474
+ call.name,
1475
+ call.displayName,
1476
+ result.name,
1477
+ result.kind,
1478
+ indexed.title,
1479
+ "tool"
1480
+ );
1481
+ const toolName = normalizeToolUsageName(rawName);
1482
+ const toolLabel = firstStringValue(call.displayName, indexed.title, result.kind, rawName, toolName);
1483
+ const category = normalizeToolUsageCategory(firstStringValue(indexed.toolCategory, call.category, result.category, call.rawCategory, result.rawCategory), toolName);
1484
+ const status = String(indexed.status || call.status || result.status || "").toLowerCase();
1485
+ const callId = firstStringValue(call.id, result.id);
1486
+ const mcpServer = mcpServerFromToolUsage(toolName, category);
1487
+ if (kind === "tool.called") {
1488
+ callCount++;
1489
+ const tool = byTool.get(toolName) || { tool_name: toolName, tool_label: toolLabel, tool_category: category, calls: 0, completed: 0, errors: 0 };
1490
+ tool.calls += 1;
1491
+ tool.tool_label = tool.tool_label || toolLabel;
1492
+ tool.tool_category = tool.tool_category || category;
1493
+ byTool.set(toolName, tool);
1494
+ const categoryEntry = byCategory.get(category) || { tool_category: category, calls: 0, completed: 0, errors: 0 };
1495
+ categoryEntry.calls += 1;
1496
+ byCategory.set(category, categoryEntry);
1497
+ const skill = skillFromToolCall(toolName, call);
1498
+ if (skill) {
1499
+ if (callId) skillByCallId.set(callId, skill);
1500
+ const skillEntry = bySkill.get(skill.skill_name) || { skill_name: skill.skill_name, skill_label: skill.skill_label, calls: 0, completed: 0, errors: 0 };
1501
+ skillEntry.calls += 1;
1502
+ bySkill.set(skill.skill_name, skillEntry);
1503
+ }
1504
+ if (mcpServer) {
1505
+ const serverEntry = byMcpServer.get(mcpServer.server_name) || { server_name: mcpServer.server_name, server_label: mcpServer.server_label, calls: 0, completed: 0, errors: 0 };
1506
+ serverEntry.calls += 1;
1507
+ byMcpServer.set(mcpServer.server_name, serverEntry);
1508
+ }
1509
+ } else if (kind === "tool.completed") {
1510
+ completedCount++;
1511
+ const failed = status.includes("error") || status.includes("fail");
1512
+ if (failed) errorCount++;
1513
+ const tool = byTool.get(toolName) || { tool_name: toolName, tool_label: toolLabel, tool_category: category, calls: 0, completed: 0, errors: 0 };
1514
+ tool.completed += 1;
1515
+ if (failed) tool.errors += 1;
1516
+ byTool.set(toolName, tool);
1517
+ const categoryEntry = byCategory.get(category) || { tool_category: category, calls: 0, completed: 0, errors: 0 };
1518
+ categoryEntry.completed += 1;
1519
+ if (failed) categoryEntry.errors += 1;
1520
+ byCategory.set(category, categoryEntry);
1521
+ const skill = callId ? skillByCallId.get(callId) : null;
1522
+ if (skill) {
1523
+ const skillEntry = bySkill.get(skill.skill_name) || { skill_name: skill.skill_name, skill_label: skill.skill_label, calls: 0, completed: 0, errors: 0 };
1524
+ skillEntry.completed += 1;
1525
+ if (failed) skillEntry.errors += 1;
1526
+ bySkill.set(skill.skill_name, skillEntry);
1527
+ }
1528
+ if (mcpServer) {
1529
+ const serverEntry = byMcpServer.get(mcpServer.server_name) || { server_name: mcpServer.server_name, server_label: mcpServer.server_label, calls: 0, completed: 0, errors: 0 };
1530
+ serverEntry.completed += 1;
1531
+ if (failed) serverEntry.errors += 1;
1532
+ byMcpServer.set(mcpServer.server_name, serverEntry);
1533
+ }
1534
+ }
1535
+ }
1536
+ return {
1537
+ callCount,
1538
+ completedCount,
1539
+ errorCount,
1540
+ byTool: Array.from(byTool.values()).sort((a, b) => b.calls - a.calls || String(a.tool_label).localeCompare(String(b.tool_label))),
1541
+ byCategory: Array.from(byCategory.values()).sort((a, b) => b.calls - a.calls || String(a.tool_category).localeCompare(String(b.tool_category))),
1542
+ bySkill: Array.from(bySkill.values()).sort((a, b) => b.calls - a.calls || String(a.skill_label).localeCompare(String(b.skill_label))),
1543
+ // Some providers only record MCP results (claude.ai connectors, older
1544
+ // Codex apps), so treat completions as a floor for call counts.
1545
+ byMcpServer: Array.from(byMcpServer.values())
1546
+ .map((entry) => ({ ...entry, calls: Math.max(entry.calls, entry.completed) }))
1547
+ .sort((a, b) => b.calls - a.calls || String(a.server_label).localeCompare(String(b.server_label)))
1548
+ };
1549
+ }
1550
+
1551
+ function firstStringValue(...values) {
1552
+ for (const value of values) {
1553
+ if (typeof value !== "string") continue;
1554
+ const text = value.trim();
1555
+ if (text) return text;
1556
+ }
1557
+ return "";
1558
+ }
1559
+
1560
+ function normalizeToolUsageName(value) {
1561
+ return String(value || "tool").trim().toLowerCase().replace(/[^a-z0-9._:-]+/g, "_").replace(/^_+|_+$/g, "") || "tool";
1562
+ }
1563
+
1564
+ const SKILL_TOOL_NAMES = new Set(["skill", "load_skill", "read_skill", "slashcommand", "slash_command"]);
1565
+ // Matches skill loads done through file reads (Codex shells out to read
1566
+ // <skills root>/<name>/SKILL.md; other agents use Read-style tools).
1567
+ const SKILL_FILE_PATH_RE = /[\/\\]skills[\/\\]([A-Za-z0-9._-]+)[\/\\]SKILL\.md/i;
1568
+
1569
+ function skillFromToolCall(toolName, call) {
1570
+ if (SKILL_TOOL_NAMES.has(toolName)) {
1571
+ const args = call?.arguments && typeof call.arguments === "object" && !Array.isArray(call.arguments) ? call.arguments : {};
1572
+ const raw = firstStringValue(args.skill, args.skill_name, args.skillName, args.name, args.command).replace(/^\/+/, "").trim();
1573
+ const named = normalizedSkillUsage(raw);
1574
+ if (named) return named;
1575
+ }
1576
+ const candidates = [call?.argument, call?.rawInputSummary, call?.inputPreview, call?.target];
1577
+ const args = call?.arguments;
1578
+ if (args && typeof args === "object" && !Array.isArray(args)) {
1579
+ for (const value of Object.values(args)) {
1580
+ if (typeof value === "string") candidates.push(value);
1581
+ }
1582
+ }
1583
+ for (const text of candidates) {
1584
+ if (typeof text !== "string" || !text.includes("SKILL.md")) continue;
1585
+ const match = SKILL_FILE_PATH_RE.exec(text);
1586
+ if (match) return normalizedSkillUsage(match[1]);
1587
+ }
1588
+ return null;
1589
+ }
1590
+
1591
+ function normalizedSkillUsage(raw) {
1592
+ const label = String(raw || "").trim();
1593
+ if (!label) return null;
1594
+ const name = label.toLowerCase().replace(/[^a-z0-9._:-]+/g, "_").replace(/^_+|_+$/g, "");
1595
+ if (!name) return null;
1596
+ return { skill_name: name, skill_label: label };
1597
+ }
1598
+
1599
+ const MCP_GENERIC_TOOL_NAMES = new Set([
1600
+ "mcp",
1601
+ "mcp_tool",
1602
+ "mcp_tool_call",
1603
+ "call_mcp_tool",
1604
+ "list_mcp_resources",
1605
+ "list_mcp_resource_templates",
1606
+ "read_mcp_resource"
1607
+ ]);
1608
+
1609
+ // Connector prefixes used by hosted MCP tool names like "github_fetch_file"
1610
+ // (claude.ai connectors, Codex apps) where the server name is not encoded.
1611
+ const MCP_CONNECTOR_PREFIX_RE = /^(github|gmail|gcal|gdrive|google_calendar|google_drive|slack|linear|notion|asana|jira|figma|stripe|vercel|supabase|sentry|atlassian)_./;
1612
+
1613
+ function mcpServerFromToolUsage(toolName, category) {
1614
+ if (/^mcp__/.test(toolName)) {
1615
+ const server = toolName.split("__").filter(Boolean)[1] || "";
1616
+ return server ? { server_name: server, server_label: server } : null;
1617
+ }
1618
+ if (category !== "mcp") return null;
1619
+ if (!toolName || MCP_GENERIC_TOOL_NAMES.has(toolName) || /^mcp[_-]*$/.test(toolName)) return null;
1620
+ const prefix = MCP_CONNECTOR_PREFIX_RE.exec(toolName);
1621
+ if (prefix) return { server_name: prefix[1], server_label: prefix[1] };
1622
+ return { server_name: toolName, server_label: toolName };
1623
+ }
1624
+
1625
+ function normalizeToolUsageCategory(category, name) {
1626
+ const raw = String(category || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
1627
+ if (raw) return raw;
1628
+ const key = normalizeToolUsageName(name);
1629
+ if (key.includes("search") || key.includes("grep") || key.includes("glob")) return "search";
1630
+ if (key.includes("web") || key.includes("browser") || key.includes("url")) return "web";
1631
+ if (key.includes("read") || key.includes("list")) return "read";
1632
+ if (key.includes("edit") || key.includes("write") || key.includes("patch")) return "edit";
1633
+ if (key.includes("bash") || key.includes("shell") || key.includes("command") || key.includes("exec")) return "shell";
1634
+ if (key.includes("task") || key.includes("todo") || key.includes("agent")) return "task";
1635
+ if (key.includes("mcp")) return "mcp";
1636
+ return "tool";
1637
+ }
1638
+
748
1639
  function estimateSessionUsage(messages, options = {}) {
749
1640
  const provider = String(options.provider || "").toLowerCase();
750
1641
  if (provider === "cursor") return estimateCursorSessionUsage(messages, options);
@@ -932,14 +1823,17 @@ function usageTokenSummary(usage) {
932
1823
  usage.total_token_count,
933
1824
  usage.total
934
1825
  ));
935
- const cacheInputTokens = sumPositiveTokenNumbers(
936
- firstUsageNumber(usage.cacheInputTokens, usage.cache_input_tokens),
937
- firstUsageNumber(usage.cacheCreationInputTokens, usage.cache_creation_input_tokens),
1826
+ const cacheCreationInputTokens = positiveTokenNumber(firstUsageNumber(usage.cacheCreationInputTokens, usage.cache_creation_input_tokens));
1827
+ const cacheReadInputTokens = sumPositiveTokenNumbers(
938
1828
  firstUsageNumber(usage.cacheReadInputTokens, usage.cache_read_input_tokens),
939
- firstUsageNumber(usage.cacheReadTokens, usage.cache_read_tokens),
1829
+ firstUsageNumber(usage.cacheReadTokens, usage.cache_read_tokens)
1830
+ );
1831
+ const explicitCacheInputTokens = positiveTokenNumber(firstUsageNumber(usage.cacheInputTokens, usage.cache_input_tokens));
1832
+ const otherCacheInputTokens = sumPositiveTokenNumbers(
940
1833
  firstUsageNumber(usage.cachedContentTokenCount, usage.cached_content_token_count),
941
1834
  firstUsageNumber(usage.cachedTokens, usage.cached_tokens, usage.cacheTokens, usage.cache_tokens, usage.cached)
942
1835
  );
1836
+ const cacheInputTokens = explicitCacheInputTokens || (cacheCreationInputTokens + cacheReadInputTokens + otherCacheInputTokens);
943
1837
  const cacheInputTokensIncludedInInput =
944
1838
  usage.cacheInputTokensIncludedInInput === true ||
945
1839
  usage.cache_input_tokens_included_in_input === true ||
@@ -975,6 +1869,8 @@ function usageTokenSummary(usage) {
975
1869
  totalInputTokens,
976
1870
  totalOutputTokens,
977
1871
  cacheInputTokens,
1872
+ cacheCreationInputTokens,
1873
+ cacheReadInputTokens,
978
1874
  countedCacheInputTokens,
979
1875
  cacheInputTokensIncludedInInput,
980
1876
  reasoningOutputTokens,
@@ -1001,6 +1897,8 @@ function emptyUsageTokenSummary() {
1001
1897
  totalInputTokens: 0,
1002
1898
  totalOutputTokens: 0,
1003
1899
  cacheInputTokens: 0,
1900
+ cacheCreationInputTokens: 0,
1901
+ cacheReadInputTokens: 0,
1004
1902
  countedCacheInputTokens: 0,
1005
1903
  cacheInputTokensIncludedInInput: false,
1006
1904
  reasoningOutputTokens: 0,
@@ -1034,6 +1932,11 @@ function positiveTokenNumber(value) {
1034
1932
  return Number.isFinite(number) && number > 0 ? number : 0;
1035
1933
  }
1036
1934
 
1935
+ function positiveCostNumber(value) {
1936
+ const number = Number(value);
1937
+ return Number.isFinite(number) && number > 0 ? number : 0;
1938
+ }
1939
+
1037
1940
  function sumPositiveTokenNumbers(...values) {
1038
1941
  return values.reduce((sum, value) => sum + positiveTokenNumber(value), 0);
1039
1942
  }
@@ -1095,6 +1998,12 @@ function mergeSessionUsage(primary, secondary) {
1095
1998
  if (primary.cacheInputTokensIncludedInInput || secondary.cacheInputTokensIncludedInInput) {
1096
1999
  result.cacheInputTokensIncludedInInput = true;
1097
2000
  }
2001
+ if (primary.cacheCreationInputTokens || secondary.cacheCreationInputTokens) {
2002
+ result.cacheCreationInputTokens = primary.cacheCreationInputTokens || secondary.cacheCreationInputTokens || 0;
2003
+ }
2004
+ if (primary.cacheReadInputTokens || secondary.cacheReadInputTokens) {
2005
+ result.cacheReadInputTokens = primary.cacheReadInputTokens || secondary.cacheReadInputTokens || 0;
2006
+ }
1098
2007
  if (primary.reasoningOutputTokensIncludedInOutput || secondary.reasoningOutputTokensIncludedInOutput) {
1099
2008
  result.reasoningOutputTokensIncludedInOutput = true;
1100
2009
  }
@@ -1104,6 +2013,13 @@ function mergeSessionUsage(primary, secondary) {
1104
2013
  result.estimationMethod = primary.estimationMethod;
1105
2014
  }
1106
2015
  }
2016
+ const costUsd = positiveCostNumber(primary.costUsd) || positiveCostNumber(secondary.costUsd);
2017
+ if (costUsd) {
2018
+ result.costUsd = costUsd;
2019
+ result.currency = primary.currency || secondary.currency || "USD";
2020
+ result.costSource = primary.costSource || secondary.costSource || "provider-usage";
2021
+ result.costEstimated = Boolean(primary.costEstimated && secondary.costEstimated);
2022
+ }
1107
2023
  return result;
1108
2024
  }
1109
2025
 
@@ -1180,17 +2096,24 @@ function ensureConversationMarkdown(session, env = process.env) {
1180
2096
  }
1181
2097
 
1182
2098
  function conversationMarkdownNeedsRefresh(file, session) {
1183
- if (session?.provider !== "chatgpt" && session?.sourceType !== "chatgpt-export") return false;
1184
2099
  let text = "";
1185
2100
  try {
1186
2101
  text = fs.readFileSync(file, "utf8");
1187
2102
  } catch {
1188
2103
  return false;
1189
2104
  }
2105
+ if (!conversationRenderVersionMatches(text)) return true;
2106
+ if (session?.provider !== "chatgpt" && session?.sourceType !== "chatgpt-export") return false;
1190
2107
  return /\uE200[A-Za-z_]*cite\uE202[^\uE201]+\uE201/.test(text);
1191
2108
  }
1192
2109
 
1193
- const VIEW_SCHEMA_VERSION = 4;
2110
+ const VIEW_SCHEMA_VERSION = 9;
2111
+ const CONVERSATION_RENDER_VERSION = 2;
2112
+
2113
+ function conversationRenderVersionMatches(text) {
2114
+ const match = /^conversation_render_version:\s*"?([^"\n]+)"?\s*$/m.exec(String(text || ""));
2115
+ return Boolean(match && Number(match[1]) === CONVERSATION_RENDER_VERSION);
2116
+ }
1194
2117
 
1195
2118
  function sessionViewPathFromMetadata(metadataPath) {
1196
2119
  if (!metadataPath) return "";
@@ -1234,6 +2157,51 @@ function displayTranscriptMessages(messages, time) {
1234
2157
  return messages.map((message) => ({ ...message, timestamp: "" }));
1235
2158
  }
1236
2159
 
2160
+ function compactViewTranscriptMessages(messages) {
2161
+ return (messages || []).map(compactViewTranscriptMessage);
2162
+ }
2163
+
2164
+ function compactViewTranscriptMessage(message) {
2165
+ if (!message || typeof message !== "object") return message;
2166
+ const metadata = compactViewMetadata(message.metadata);
2167
+ if (metadata === message.metadata) return message;
2168
+ return { ...message, metadata };
2169
+ }
2170
+
2171
+ function compactViewMetadata(metadata) {
2172
+ if (!metadata || typeof metadata !== "object") return metadata;
2173
+ let next = null;
2174
+ if (metadata.toolResult && typeof metadata.toolResult === "object") {
2175
+ next = { ...metadata, toolResult: compactViewToolResult(metadata.toolResult) };
2176
+ }
2177
+ return next || metadata;
2178
+ }
2179
+
2180
+ function compactViewToolResult(result) {
2181
+ if (!result || typeof result !== "object") return result;
2182
+ const next = { ...result };
2183
+ delete next.output;
2184
+ delete next.text;
2185
+ delete next.content;
2186
+ return next;
2187
+ }
2188
+
2189
+ function compactViewCanonicalEvents(events) {
2190
+ return (Array.isArray(events) ? events : []).map(compactViewCanonicalEvent);
2191
+ }
2192
+
2193
+ function compactViewCanonicalEvent(event) {
2194
+ if (!event || typeof event !== "object") return event;
2195
+ const body = event.body && typeof event.body === "object" ? event.body : null;
2196
+ if (!body) return event;
2197
+ const nextBody = { ...body };
2198
+ if (typeof nextBody.text === "string") nextBody.text = "";
2199
+ if (nextBody.toolResult && typeof nextBody.toolResult === "object") {
2200
+ nextBody.toolResult = compactViewToolResult(nextBody.toolResult);
2201
+ }
2202
+ return { ...event, body: nextBody };
2203
+ }
2204
+
1237
2205
  function cursorRawTimeMatchesSourceMtime(session, timestamp) {
1238
2206
  const rawFiles = Array.isArray(session?.rawFiles) ? session.rawFiles : [];
1239
2207
  return rawFiles.some((file) => {
@@ -1276,7 +2244,7 @@ function sessionHistoryTime(session) {
1276
2244
 
1277
2245
  function computeSessionView(session, transcriptMessages, canonicalEvents) {
1278
2246
  const time = sessionHistoryTime(session);
1279
- const display = displayTranscriptMessages(dedupeTranscriptMessages(transcriptMessages || []), time);
2247
+ const display = compactViewTranscriptMessages(displayTranscriptMessages(dedupeTranscriptMessages(transcriptMessages || []), time));
1280
2248
  return {
1281
2249
  view_schema_version: VIEW_SCHEMA_VERSION,
1282
2250
  session_id: session?.sessionId || "",
@@ -1285,7 +2253,7 @@ function computeSessionView(session, transcriptMessages, canonicalEvents) {
1285
2253
  time_status: time.status || "",
1286
2254
  session_summary: session?.sessionSummary || undefined,
1287
2255
  transcript_messages: display,
1288
- canonical_events: Array.isArray(canonicalEvents) ? canonicalEvents : []
2256
+ canonical_events: compactViewCanonicalEvents(canonicalEvents)
1289
2257
  };
1290
2258
  }
1291
2259
 
@@ -1325,6 +2293,44 @@ function renderConversationMarkdown(session, messages, events = []) {
1325
2293
  const title = session.title || `${session.provider || "agent"} session ${session.sessionId}`;
1326
2294
  const time = sessionHistoryTime(session);
1327
2295
  const displayMessages = displayTranscriptMessages(messages, time);
2296
+ // Index events by messageIndex once so the per-message loop below is O(events)
2297
+ // total instead of O(messages * events) — for huge sessions the naive filter
2298
+ // per message is billions of comparisons.
2299
+ const toolCallsByMessageIndex = new Map();
2300
+ const toolResultsByMessageIndex = new Map();
2301
+ const memoryEventsByMessageIndex = new Map();
2302
+ const eventsById = new Map();
2303
+ for (const event of events) {
2304
+ if (!event || typeof event !== "object") continue;
2305
+ if (event.eventId) eventsById.set(event.eventId, event);
2306
+ }
2307
+ const memoryToolCallEventIds = new Set();
2308
+ const memoryToolResultEventIds = new Set();
2309
+ for (const event of events) {
2310
+ if (!event || typeof event !== "object") continue;
2311
+ if (isMemoryEventKind(event.kind)) {
2312
+ const list = memoryEventsByMessageIndex.get(event.messageIndex);
2313
+ if (list) list.push(event);
2314
+ else memoryEventsByMessageIndex.set(event.messageIndex, [event]);
2315
+ if (event.parentEventId) {
2316
+ memoryToolResultEventIds.add(event.parentEventId);
2317
+ const resultEvent = eventsById.get(event.parentEventId);
2318
+ if (resultEvent?.parentEventId) memoryToolCallEventIds.add(resultEvent.parentEventId);
2319
+ }
2320
+ }
2321
+ }
2322
+ for (const event of events) {
2323
+ if (!event || typeof event !== "object") continue;
2324
+ const bucket = event.kind === "tool.called"
2325
+ ? memoryToolCallEventIds.has(event.eventId) ? null : toolCallsByMessageIndex
2326
+ : event.kind === "tool.completed"
2327
+ ? memoryToolResultEventIds.has(event.eventId) ? null : toolResultsByMessageIndex
2328
+ : null;
2329
+ if (!bucket) continue;
2330
+ const list = bucket.get(event.messageIndex);
2331
+ if (list) list.push(event);
2332
+ else bucket.set(event.messageIndex, [event]);
2333
+ }
1328
2334
  const hasRedactions =
1329
2335
  displayMessages.some((message) => /\[REDACTED(?::|\s+)[^\]\n]+\]/.test(JSON.stringify(message))) ||
1330
2336
  events.some((event) => /\[REDACTED(?::|\s+)[^\]\n]+\]/.test(JSON.stringify(event)));
@@ -1332,6 +2338,9 @@ function renderConversationMarkdown(session, messages, events = []) {
1332
2338
  "---",
1333
2339
  `session_id: ${yamlString(session.sessionId)}`,
1334
2340
  `provider: ${yamlString(session.provider)}`,
2341
+ `device_name: ${yamlString(session.device?.name || "")}`,
2342
+ `device_slug: ${yamlString(session.device?.slug || "")}`,
2343
+ `device_id: ${yamlString(session.device?.id || "")}`,
1335
2344
  `repo: ${yamlString(session.repoCanonical || "")}`,
1336
2345
  `scope: ${yamlString(session.scopeCanonical || "")}`,
1337
2346
  `cwd: ${yamlString(session.cwd || "")}`,
@@ -1345,6 +2354,7 @@ function renderConversationMarkdown(session, messages, events = []) {
1345
2354
  `conversation_kind: ${yamlString(session.conversationKind || "")}`,
1346
2355
  `time_status: ${yamlString(time.status || session.timeStatus || "")}`,
1347
2356
  `archive_schema_version: ${yamlString(session.archiveSchemaVersion || 1)}`,
2357
+ `conversation_render_version: ${yamlString(CONVERSATION_RENDER_VERSION)}`,
1348
2358
  `event_count: ${yamlString(events.length || session.eventCount || 0)}`,
1349
2359
  `parser_versions: ${yamlString(JSON.stringify(session.parserVersions || {}))}`,
1350
2360
  "---",
@@ -1364,7 +2374,8 @@ function renderConversationMarkdown(session, messages, events = []) {
1364
2374
  for (const message of displayMessages) {
1365
2375
  body.push(`## ${roleLabel(message.role)} - ${message.timestamp || "unknown time"}`);
1366
2376
  body.push("");
1367
- const content = styleRedactionMarkersForMarkdown(styleChatGptCitationMarkersForMarkdown(String(message.content || "").trim()));
2377
+ const suppressMemoryToolContent = String(message.role || "").toLowerCase() === "tool" && memoryEventsByMessageIndex.has(message.index);
2378
+ const content = suppressMemoryToolContent ? "" : styleRedactionMarkersForMarkdown(styleChatGptCitationMarkersForMarkdown(String(message.content || "").trim()));
1368
2379
  if (content) {
1369
2380
  body.push(content);
1370
2381
  body.push("");
@@ -1374,22 +2385,116 @@ function renderConversationMarkdown(session, messages, events = []) {
1374
2385
  body.push(attachmentMarkdown);
1375
2386
  body.push("");
1376
2387
  }
1377
- for (const event of events.filter((item) => item.messageIndex === message.index && item.kind === "tool.called")) {
2388
+ for (const event of toolCallsByMessageIndex.get(message.index) || []) {
1378
2389
  body.push(`### Tool Call - ${event.occurredAt || message.timestamp || "unknown time"}`);
1379
2390
  body.push("");
1380
2391
  body.push(styleRedactionMarkersForMarkdown(styleChatGptCitationMarkersForMarkdown(String(event.body?.text || event.indexed?.summary || "").trim())));
1381
2392
  body.push("");
1382
2393
  }
1383
- for (const event of events.filter((item) => item.messageIndex === message.index && item.kind === "tool.completed")) {
2394
+ for (const event of toolResultsByMessageIndex.get(message.index) || []) {
1384
2395
  body.push(`### Tool Result - ${event.occurredAt || message.timestamp || "unknown time"}`);
1385
2396
  body.push("");
1386
2397
  body.push(styleRedactionMarkersForMarkdown(styleChatGptCitationMarkersForMarkdown(String(event.indexed?.summary || event.body?.text || "").trim())));
1387
2398
  body.push("");
1388
2399
  }
2400
+ for (const event of memoryEventsByMessageIndex.get(message.index) || []) {
2401
+ body.push(`### ${memoryMarkdownHeading(event)} - ${event.occurredAt || message.timestamp || "unknown time"}`);
2402
+ body.push("");
2403
+ body.push(styleRedactionMarkersForMarkdown(styleChatGptCitationMarkersForMarkdown(memoryMarkdownText(event, eventsById))));
2404
+ body.push("");
2405
+ }
1389
2406
  }
1390
2407
  return `${frontmatter.concat(body).join("\n").trimEnd()}\n`;
1391
2408
  }
1392
2409
 
2410
+ function isMemoryEventKind(kind) {
2411
+ return kind === "memory.read" || kind === "memory.write" || kind === "memory.loaded";
2412
+ }
2413
+
2414
+ function memoryMarkdownHeading(event) {
2415
+ const action = String(event?.indexed?.memoryAction || event?.body?.memory?.action || "").toLowerCase();
2416
+ if (action === "read") return "Memory Read";
2417
+ if (action === "write") return "Memory Write";
2418
+ return "Memory Loaded";
2419
+ }
2420
+
2421
+ function memoryMarkdownText(event, eventsById = new Map()) {
2422
+ const memory = event?.body?.memory || {};
2423
+ const pathValue = String(event?.indexed?.memoryPath || memory.path || memory.name || "memory").trim();
2424
+ const details = [];
2425
+ if (pathValue) details.push(`Path: ${pathValue}`);
2426
+ if (event?.indexed?.memorySource || memory.source) details.push(`Source: ${event.indexed?.memorySource || memory.source}`);
2427
+ if (event?.indexed?.memoryScope || memory.scope) details.push(`Scope: ${event.indexed?.memoryScope || memory.scope}`);
2428
+ const diff = memoryMarkdownDiff(event, eventsById);
2429
+ const text = details.length ? details.join("\n") : String(event?.body?.text || event?.indexed?.summary || "").trim();
2430
+ return [text, diff].filter(Boolean).join("\n\n");
2431
+ }
2432
+
2433
+ function memoryMarkdownDiff(event, eventsById = new Map()) {
2434
+ const completedEvent = event?.parentEventId ? eventsById.get(event.parentEventId) : null;
2435
+ const callEvent = completedEvent?.parentEventId ? eventsById.get(completedEvent.parentEventId) : null;
2436
+ const result = completedEvent?.body?.toolResult || {};
2437
+ const call = callEvent?.body?.toolCall || {};
2438
+ const hunks = firstStructuredPatch(result.structuredPatch, call.structuredPatch, call.arguments?.structuredPatch);
2439
+ if (hunks.length) return fencedMarkdown("diff", structuredPatchToDiffText(hunks));
2440
+ const editDiff = editArgumentsToDiffText(call.arguments);
2441
+ if (editDiff) return fencedMarkdown("diff", editDiff);
2442
+ return "";
2443
+ }
2444
+
2445
+ function firstStructuredPatch(...values) {
2446
+ for (const value of values) {
2447
+ if (Array.isArray(value) && value.length) return value;
2448
+ }
2449
+ return [];
2450
+ }
2451
+
2452
+ function structuredPatchToDiffText(hunks) {
2453
+ const lines = [];
2454
+ let previousFile = "";
2455
+ for (const hunk of hunks) {
2456
+ const file = String(hunk?.file || "").trim();
2457
+ if (file && file !== previousFile) {
2458
+ lines.push(`--- ${file}`, `+++ ${file}`);
2459
+ previousFile = file;
2460
+ }
2461
+ const oldStart = Number(hunk?.oldStart) || 1;
2462
+ const newStart = Number(hunk?.newStart) || 1;
2463
+ lines.push(`@@ -${oldStart} +${newStart} @@`);
2464
+ for (const raw of Array.isArray(hunk?.lines) ? hunk.lines : []) lines.push(String(raw ?? ""));
2465
+ }
2466
+ return lines.join("\n").trim();
2467
+ }
2468
+
2469
+ function editArgumentsToDiffText(args) {
2470
+ if (!args || typeof args !== "object" || Array.isArray(args)) return "";
2471
+ const edits = Array.isArray(args.edits) && args.edits.length ? args.edits : [args];
2472
+ const blocks = [];
2473
+ for (const edit of edits) {
2474
+ const oldString = typeof edit?.old_string === "string" ? edit.old_string : "";
2475
+ const newString = typeof edit?.new_string === "string" ? edit.new_string : "";
2476
+ if (!oldString && !newString) continue;
2477
+ blocks.push(simpleDiffText(oldString, newString));
2478
+ }
2479
+ if (blocks.length) return blocks.join("\n");
2480
+ const content = typeof args.content === "string" ? args.content : "";
2481
+ if (!content) return "";
2482
+ return content.split("\n").map((line) => `+${line}`).join("\n");
2483
+ }
2484
+
2485
+ function simpleDiffText(oldString, newString) {
2486
+ const lines = [];
2487
+ for (const line of String(oldString || "").split("\n")) lines.push(`-${line}`);
2488
+ for (const line of String(newString || "").split("\n")) lines.push(`+${line}`);
2489
+ return lines.join("\n").trim();
2490
+ }
2491
+
2492
+ function fencedMarkdown(language, value) {
2493
+ const text = String(value || "").trim();
2494
+ if (!text) return "";
2495
+ return `\`\`\`${language}\n${text.replace(/```/g, "`\u200b``")}\n\`\`\``;
2496
+ }
2497
+
1393
2498
  function styleChatGptCitationMarkersForMarkdown(value) {
1394
2499
  return String(value || "").replace(/\uE200([A-Za-z_]*cite)\uE202([^\uE201]+)\uE201/g, (_, kind, ref) => {
1395
2500
  const label = /file/i.test(String(kind || "")) ? "file citation" : "citation";
@@ -1494,7 +2599,8 @@ function touchManifest(session, pathsForSession, env = process.env) {
1494
2599
  transcriptPath: pathsForSession.transcriptPath,
1495
2600
  eventPath: pathsForSession.eventPath,
1496
2601
  viewPath: pathsForSession.viewPath || "",
1497
- rawPath: pathsForSession.rawPath || ""
2602
+ rawPath: pathsForSession.rawPath || "",
2603
+ artifactsPath: pathsForSession.artifactsPath || ""
1498
2604
  });
1499
2605
  writeJson(manifestPath, { sessions: next.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt))) });
1500
2606
  }
@@ -1535,6 +2641,8 @@ function walk(dir, visit) {
1535
2641
 
1536
2642
  module.exports = {
1537
2643
  archiveRoot,
2644
+ collectSessionToolUsage,
2645
+ mcpServerFromToolUsage,
1538
2646
  collectSessionModels,
1539
2647
  computeSessionUsage,
1540
2648
  computeSessionView,
@@ -1559,6 +2667,7 @@ module.exports = {
1559
2667
  readEvents,
1560
2668
  readTranscript,
1561
2669
  renderConversationMarkdown,
2670
+ rebuildSessionListIndex,
1562
2671
  revealSession,
1563
2672
  sessionHistoryTime,
1564
2673
  sessionViewPathForSession,