agentel 0.2.6 → 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/README.md +260 -79
- package/docs/code-reference.md +130 -42
- package/docs/history-source-handling.md +685 -153
- package/docs/release.md +35 -8
- package/npm-shrinkwrap.json +478 -0
- package/package.json +20 -4
- package/scripts/postinstall.js +156 -0
- package/src/archive.js +1342 -50
- package/src/canonical-events.js +346 -35
- package/src/cli.js +8835 -843
- package/src/collector.js +42 -4
- package/src/config.js +26 -4
- package/src/diffs.js +156 -0
- package/src/doctor.js +48 -5
- package/src/importers/claude.js +51 -4
- package/src/importers/copilot.js +385 -0
- package/src/importers/cursor-recovery.js +22 -0
- package/src/importers/factory.js +396 -0
- package/src/importers/gemini.js +41 -1
- package/src/importers/grok.js +367 -0
- package/src/importers/pi.js +422 -0
- package/src/importers/providers.js +64 -5
- package/src/importers.js +6429 -747
- package/src/mcp.js +1 -0
- package/src/memory-sources.js +671 -0
- package/src/memory-store.js +0 -0
- package/src/parser-versions.js +13 -0
- package/src/pricing.js +84 -0
- package/src/search.js +641 -215
- package/src/session-store.js +405 -0
- package/src/source-watch.js +293 -0
- package/src/sources.js +60 -11
- package/src/supervisor.js +197 -9
- package/src/sync.js +6 -0
- package/src/unavailable-sources.js +358 -0
- package/src/web-export-instructions.js +6 -4
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:
|
|
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
|
-
|
|
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
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
233
|
-
if (!
|
|
234
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -452,6 +610,8 @@ function hasStructuredMetadata(metadata) {
|
|
|
452
610
|
(Array.isArray(metadata?.toolCalls) && metadata.toolCalls.length) ||
|
|
453
611
|
metadata?.toolResult ||
|
|
454
612
|
metadata?.eventType ||
|
|
613
|
+
(Array.isArray(metadata?.attachments) && metadata.attachments.length) ||
|
|
614
|
+
(Array.isArray(metadata?.assetPointers) && metadata.assetPointers.length) ||
|
|
455
615
|
metadata?.parserVersion
|
|
456
616
|
);
|
|
457
617
|
}
|
|
@@ -528,7 +688,8 @@ function decodeSegment(value) {
|
|
|
528
688
|
|
|
529
689
|
const _sessionsIndex = {
|
|
530
690
|
byPath: new Map(),
|
|
531
|
-
byId: new Map()
|
|
691
|
+
byId: new Map(),
|
|
692
|
+
completeRootKey: ""
|
|
532
693
|
};
|
|
533
694
|
|
|
534
695
|
function hydrateSessionMetadata(file, metadata) {
|
|
@@ -541,10 +702,59 @@ function hydrateSessionMetadata(file, metadata) {
|
|
|
541
702
|
return { ...metadata, conversationPath, metadataPath: file, transcriptPath, eventPath, viewPath, rawPath };
|
|
542
703
|
}
|
|
543
704
|
|
|
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;
|
|
711
|
+
const _snapshotCache = {
|
|
712
|
+
rootKey: "",
|
|
713
|
+
snapshot: null,
|
|
714
|
+
takenAtMs: 0
|
|
715
|
+
};
|
|
716
|
+
|
|
544
717
|
function listSessions(env = process.env) {
|
|
718
|
+
return listSessionsSnapshot(env).sessions;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function cachedListSessionsSnapshot(env = process.env) {
|
|
722
|
+
const rootKey = path.join(archiveRoot(env), "sessions");
|
|
723
|
+
const now = Date.now();
|
|
724
|
+
if (
|
|
725
|
+
_snapshotCache.snapshot &&
|
|
726
|
+
_snapshotCache.rootKey === rootKey &&
|
|
727
|
+
now - _snapshotCache.takenAtMs < SNAPSHOT_CACHE_TTL_MS
|
|
728
|
+
) {
|
|
729
|
+
return _snapshotCache.snapshot;
|
|
730
|
+
}
|
|
731
|
+
const snapshot = listSessionsSnapshot(env);
|
|
732
|
+
_snapshotCache.rootKey = rootKey;
|
|
733
|
+
_snapshotCache.snapshot = snapshot;
|
|
734
|
+
_snapshotCache.takenAtMs = now;
|
|
735
|
+
return snapshot;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function invalidateSessionsSnapshotCache() {
|
|
739
|
+
_snapshotCache.rootKey = "";
|
|
740
|
+
_snapshotCache.snapshot = null;
|
|
741
|
+
_snapshotCache.takenAtMs = 0;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function listSessionsSnapshot(env = process.env) {
|
|
745
|
+
return readSessionListIndex(env) || rebuildSessionListIndex(env);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Walk the archive once and return both the hydrated session list and a
|
|
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.
|
|
753
|
+
*/
|
|
754
|
+
function rebuildSessionListIndex(env = process.env) {
|
|
545
755
|
const root = path.join(archiveRoot(env), "sessions");
|
|
546
756
|
const seen = new Set();
|
|
547
|
-
const
|
|
757
|
+
const entries = [];
|
|
548
758
|
walk(root, (file) => {
|
|
549
759
|
if (!file.endsWith(".metadata.json")) return;
|
|
550
760
|
seen.add(file);
|
|
@@ -552,15 +762,14 @@ function listSessions(env = process.env) {
|
|
|
552
762
|
try { stat = fs.statSync(file); } catch { return; }
|
|
553
763
|
const cached = _sessionsIndex.byPath.get(file);
|
|
554
764
|
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
555
|
-
|
|
765
|
+
entries.push(sessionListEntry(cached.session, file, stat));
|
|
556
766
|
return;
|
|
557
767
|
}
|
|
558
768
|
const metadata = readJson(file, null);
|
|
559
769
|
if (!metadata) return;
|
|
560
770
|
const session = hydrateSessionMetadata(file, metadata);
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
sessions.push(session);
|
|
771
|
+
rememberSessionIndexEntry(sessionListEntry(session, file, stat));
|
|
772
|
+
entries.push(sessionListEntry(session, file, stat));
|
|
564
773
|
});
|
|
565
774
|
for (const key of [..._sessionsIndex.byPath.keys()]) {
|
|
566
775
|
if (!seen.has(key)) {
|
|
@@ -569,7 +778,341 @@ function listSessions(env = process.env) {
|
|
|
569
778
|
_sessionsIndex.byPath.delete(key);
|
|
570
779
|
}
|
|
571
780
|
}
|
|
572
|
-
|
|
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);
|
|
573
1116
|
}
|
|
574
1117
|
|
|
575
1118
|
function findSessionById(sessionId, env = process.env) {
|
|
@@ -603,8 +1146,13 @@ function computeSessionUsage(messages) {
|
|
|
603
1146
|
let inputTokens = 0;
|
|
604
1147
|
let outputTokens = 0;
|
|
605
1148
|
let cacheInputTokens = 0;
|
|
1149
|
+
let cacheCreationInputTokens = 0;
|
|
1150
|
+
let cacheReadInputTokens = 0;
|
|
1151
|
+
let countedCacheInputTokens = 0;
|
|
606
1152
|
let reasoningOutputTokens = 0;
|
|
1153
|
+
let countedReasoningOutputTokens = 0;
|
|
607
1154
|
let toolUsePromptTokens = 0;
|
|
1155
|
+
let costUsd = 0;
|
|
608
1156
|
let maxUndifferentiatedTotalTokens = 0;
|
|
609
1157
|
let totalInputTokens = 0;
|
|
610
1158
|
let totalOutputTokens = 0;
|
|
@@ -638,8 +1186,24 @@ function computeSessionUsage(messages) {
|
|
|
638
1186
|
any = true;
|
|
639
1187
|
}
|
|
640
1188
|
if (summary.cacheInputTokens > 0) cacheInputTokens += summary.cacheInputTokens;
|
|
1189
|
+
if (summary.cacheCreationInputTokens > 0) cacheCreationInputTokens += summary.cacheCreationInputTokens;
|
|
1190
|
+
if (summary.cacheReadInputTokens > 0) cacheReadInputTokens += summary.cacheReadInputTokens;
|
|
1191
|
+
if (summary.countedCacheInputTokens > 0) countedCacheInputTokens += summary.countedCacheInputTokens;
|
|
641
1192
|
if (summary.reasoningOutputTokens > 0) reasoningOutputTokens += summary.reasoningOutputTokens;
|
|
1193
|
+
if (summary.countedReasoningOutputTokens > 0) countedReasoningOutputTokens += summary.countedReasoningOutputTokens;
|
|
642
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;
|
|
643
1207
|
if (!summary.hasSplitTokens && !summary.hasExtraTokens && summary.hasTotalTokens) {
|
|
644
1208
|
maxUndifferentiatedTotalTokens = Math.max(maxUndifferentiatedTotalTokens, summary.totalTokens);
|
|
645
1209
|
}
|
|
@@ -648,17 +1212,17 @@ function computeSessionUsage(messages) {
|
|
|
648
1212
|
}
|
|
649
1213
|
if (Number.isFinite(totalInputTokens) && totalInputTokens > 0) any = true;
|
|
650
1214
|
if (Number.isFinite(totalOutputTokens) && totalOutputTokens > 0) any = true;
|
|
651
|
-
if (reasoningOutputTokens > 0 || toolUsePromptTokens > 0) any = true;
|
|
1215
|
+
if (cacheInputTokens > 0 || reasoningOutputTokens > 0 || toolUsePromptTokens > 0) any = true;
|
|
652
1216
|
if (!any && maxUndifferentiatedTotalTokens > 0) {
|
|
653
1217
|
inputTokens = maxUndifferentiatedTotalTokens;
|
|
654
1218
|
any = true;
|
|
655
1219
|
}
|
|
656
1220
|
const cumulativeTokens = totalInputTokens + totalOutputTokens;
|
|
657
|
-
const splitTotalTokens = inputTokens + outputTokens +
|
|
1221
|
+
const splitTotalTokens = inputTokens + outputTokens + countedCacheInputTokens + countedReasoningOutputTokens + toolUsePromptTokens;
|
|
658
1222
|
const finalTotalTokens = splitTotalTokens || cumulativeTokens || maxUndifferentiatedTotalTokens;
|
|
659
1223
|
if (!any && finalTotalTokens === 0) return null;
|
|
660
1224
|
const result = {
|
|
661
|
-
inputTokens: inputTokens || totalInputTokens || (!outputTokens && !reasoningOutputTokens && !toolUsePromptTokens && maxUndifferentiatedTotalTokens ? maxUndifferentiatedTotalTokens : 0),
|
|
1225
|
+
inputTokens: inputTokens || totalInputTokens || (!outputTokens && !cacheInputTokens && !reasoningOutputTokens && !toolUsePromptTokens && maxUndifferentiatedTotalTokens ? maxUndifferentiatedTotalTokens : 0),
|
|
662
1226
|
outputTokens: outputTokens || totalOutputTokens,
|
|
663
1227
|
cacheInputTokens,
|
|
664
1228
|
reasoningOutputTokens,
|
|
@@ -667,13 +1231,411 @@ function computeSessionUsage(messages) {
|
|
|
667
1231
|
totalInputTokens: totalInputTokens || inputTokens,
|
|
668
1232
|
totalOutputTokens: totalOutputTokens || outputTokens
|
|
669
1233
|
};
|
|
1234
|
+
if (cacheCreationInputTokens > 0) result.cacheCreationInputTokens = cacheCreationInputTokens;
|
|
1235
|
+
if (cacheReadInputTokens > 0) result.cacheReadInputTokens = cacheReadInputTokens;
|
|
1236
|
+
if (reasoningOutputTokens > 0 && countedReasoningOutputTokens === 0) result.reasoningOutputTokensIncludedInOutput = true;
|
|
1237
|
+
if (cacheInputTokens > 0 && countedCacheInputTokens === 0) result.cacheInputTokensIncludedInInput = true;
|
|
670
1238
|
if (estimatedUsageCount > 0 && actualUsageCount === 0) {
|
|
671
1239
|
result.estimated = true;
|
|
672
1240
|
if (estimationMethods.size === 1) result.estimationMethod = Array.from(estimationMethods)[0];
|
|
673
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
|
+
}
|
|
674
1248
|
return result;
|
|
675
1249
|
}
|
|
676
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
|
+
|
|
677
1639
|
function estimateSessionUsage(messages, options = {}) {
|
|
678
1640
|
const provider = String(options.provider || "").toLowerCase();
|
|
679
1641
|
if (provider === "cursor") return estimateCursorSessionUsage(messages, options);
|
|
@@ -804,6 +1766,7 @@ function messageTokenEstimate(message) {
|
|
|
804
1766
|
function usageTokenSummary(usage) {
|
|
805
1767
|
if (!usage || typeof usage !== "object") return emptyUsageTokenSummary();
|
|
806
1768
|
const estimated = usage.estimated === true || usage.estimated === "true";
|
|
1769
|
+
const authoritativeTotalTokens = usage.authoritativeTotalTokens === true || usage.authoritative_total_tokens === true;
|
|
807
1770
|
const estimationMethod = typeof usage.estimationMethod === "string" && usage.estimationMethod.trim()
|
|
808
1771
|
? usage.estimationMethod.trim()
|
|
809
1772
|
: typeof usage.estimation_method === "string" && usage.estimation_method.trim()
|
|
@@ -860,36 +1823,59 @@ function usageTokenSummary(usage) {
|
|
|
860
1823
|
usage.total_token_count,
|
|
861
1824
|
usage.total
|
|
862
1825
|
));
|
|
863
|
-
const
|
|
864
|
-
|
|
865
|
-
firstUsageNumber(usage.cacheCreationInputTokens, usage.cache_creation_input_tokens),
|
|
1826
|
+
const cacheCreationInputTokens = positiveTokenNumber(firstUsageNumber(usage.cacheCreationInputTokens, usage.cache_creation_input_tokens));
|
|
1827
|
+
const cacheReadInputTokens = sumPositiveTokenNumbers(
|
|
866
1828
|
firstUsageNumber(usage.cacheReadInputTokens, usage.cache_read_input_tokens),
|
|
867
|
-
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(
|
|
868
1833
|
firstUsageNumber(usage.cachedContentTokenCount, usage.cached_content_token_count),
|
|
869
1834
|
firstUsageNumber(usage.cachedTokens, usage.cached_tokens, usage.cacheTokens, usage.cache_tokens, usage.cached)
|
|
870
1835
|
);
|
|
1836
|
+
const cacheInputTokens = explicitCacheInputTokens || (cacheCreationInputTokens + cacheReadInputTokens + otherCacheInputTokens);
|
|
1837
|
+
const cacheInputTokensIncludedInInput =
|
|
1838
|
+
usage.cacheInputTokensIncludedInInput === true ||
|
|
1839
|
+
usage.cache_input_tokens_included_in_input === true ||
|
|
1840
|
+
usage.cacheReadTokensIncludedInInput === true ||
|
|
1841
|
+
usage.cache_read_tokens_included_in_input === true;
|
|
1842
|
+
const countedCacheInputTokens = cacheInputTokensIncludedInInput ? 0 : cacheInputTokens;
|
|
871
1843
|
const reasoningOutputTokens = sumPositiveTokenNumbers(
|
|
872
1844
|
firstUsageNumber(usage.reasoningOutputTokens, usage.reasoning_output_tokens),
|
|
873
1845
|
firstUsageNumber(usage.thoughtsTokens, usage.thoughts_tokens, usage.thoughtsTokenCount, usage.thoughts_token_count),
|
|
874
1846
|
firstUsageNumber(usage.reasoningTokens, usage.reasoning_tokens, usage.reasoningTokenCount, usage.reasoning_token_count)
|
|
875
1847
|
);
|
|
1848
|
+
const reasoningOutputTokensIncludedInOutput =
|
|
1849
|
+
usage.reasoningOutputTokensIncludedInOutput === true ||
|
|
1850
|
+
usage.reasoning_output_tokens_included_in_output === true ||
|
|
1851
|
+
usage.reasoningTokensIncludedInOutput === true ||
|
|
1852
|
+
usage.reasoning_tokens_included_in_output === true;
|
|
1853
|
+
const countedReasoningOutputTokens = reasoningOutputTokensIncludedInOutput ? 0 : reasoningOutputTokens;
|
|
876
1854
|
const toolUsePromptTokens = sumPositiveTokenNumbers(
|
|
877
1855
|
firstUsageNumber(usage.toolUsePromptTokens, usage.tool_use_prompt_tokens, usage.toolUsePromptTokenCount, usage.tool_use_prompt_token_count)
|
|
878
1856
|
);
|
|
879
|
-
const extraTokens =
|
|
1857
|
+
const extraTokens = countedCacheInputTokens + countedReasoningOutputTokens + toolUsePromptTokens;
|
|
880
1858
|
const splitTokens = inputTokens + outputTokens;
|
|
881
1859
|
const cumulativeTokens = totalInputTokens + totalOutputTokens;
|
|
882
1860
|
const categoryTokens = splitTokens + extraTokens;
|
|
883
|
-
const totalTokens =
|
|
884
|
-
?
|
|
885
|
-
:
|
|
1861
|
+
const totalTokens = authoritativeTotalTokens && explicitTotalTokens
|
|
1862
|
+
? explicitTotalTokens
|
|
1863
|
+
: explicitTotalTokens || categoryTokens
|
|
1864
|
+
? Math.max(explicitTotalTokens, categoryTokens)
|
|
1865
|
+
: cumulativeTokens;
|
|
886
1866
|
return {
|
|
887
1867
|
inputTokens,
|
|
888
1868
|
outputTokens,
|
|
889
1869
|
totalInputTokens,
|
|
890
1870
|
totalOutputTokens,
|
|
891
1871
|
cacheInputTokens,
|
|
1872
|
+
cacheCreationInputTokens,
|
|
1873
|
+
cacheReadInputTokens,
|
|
1874
|
+
countedCacheInputTokens,
|
|
1875
|
+
cacheInputTokensIncludedInInput,
|
|
892
1876
|
reasoningOutputTokens,
|
|
1877
|
+
countedReasoningOutputTokens,
|
|
1878
|
+
reasoningOutputTokensIncludedInOutput,
|
|
893
1879
|
toolUsePromptTokens,
|
|
894
1880
|
extraTokens,
|
|
895
1881
|
totalTokens,
|
|
@@ -898,6 +1884,7 @@ function usageTokenSummary(usage) {
|
|
|
898
1884
|
hasExtraTokens: extraTokens > 0,
|
|
899
1885
|
hasTotalTokens: explicitTotalTokens > 0,
|
|
900
1886
|
hasCumulativeTokens: cumulativeTokens > 0,
|
|
1887
|
+
authoritativeTotalTokens,
|
|
901
1888
|
estimated,
|
|
902
1889
|
estimationMethod
|
|
903
1890
|
};
|
|
@@ -910,7 +1897,13 @@ function emptyUsageTokenSummary() {
|
|
|
910
1897
|
totalInputTokens: 0,
|
|
911
1898
|
totalOutputTokens: 0,
|
|
912
1899
|
cacheInputTokens: 0,
|
|
1900
|
+
cacheCreationInputTokens: 0,
|
|
1901
|
+
cacheReadInputTokens: 0,
|
|
1902
|
+
countedCacheInputTokens: 0,
|
|
1903
|
+
cacheInputTokensIncludedInInput: false,
|
|
913
1904
|
reasoningOutputTokens: 0,
|
|
1905
|
+
countedReasoningOutputTokens: 0,
|
|
1906
|
+
reasoningOutputTokensIncludedInOutput: false,
|
|
914
1907
|
toolUsePromptTokens: 0,
|
|
915
1908
|
extraTokens: 0,
|
|
916
1909
|
totalTokens: 0,
|
|
@@ -919,6 +1912,7 @@ function emptyUsageTokenSummary() {
|
|
|
919
1912
|
hasExtraTokens: false,
|
|
920
1913
|
hasTotalTokens: false,
|
|
921
1914
|
hasCumulativeTokens: false,
|
|
1915
|
+
authoritativeTotalTokens: false,
|
|
922
1916
|
estimated: false,
|
|
923
1917
|
estimationMethod: ""
|
|
924
1918
|
};
|
|
@@ -938,6 +1932,11 @@ function positiveTokenNumber(value) {
|
|
|
938
1932
|
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
939
1933
|
}
|
|
940
1934
|
|
|
1935
|
+
function positiveCostNumber(value) {
|
|
1936
|
+
const number = Number(value);
|
|
1937
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
941
1940
|
function sumPositiveTokenNumbers(...values) {
|
|
942
1941
|
return values.reduce((sum, value) => sum + positiveTokenNumber(value), 0);
|
|
943
1942
|
}
|
|
@@ -951,9 +1950,15 @@ function sessionSummaryUsage(sessionSummary) {
|
|
|
951
1950
|
firstUsageNumber(usage.cacheInputTokens, usage.cache_input_tokens),
|
|
952
1951
|
firstUsageNumber(usage.cacheReadTokens, usage.cache_read_tokens)
|
|
953
1952
|
);
|
|
954
|
-
const
|
|
1953
|
+
const cacheInputTokensIncludedInInput =
|
|
1954
|
+
usage.cacheInputTokensIncludedInInput === true ||
|
|
1955
|
+
usage.cache_input_tokens_included_in_input === true ||
|
|
1956
|
+
usage.cacheReadTokensIncludedInInput === true ||
|
|
1957
|
+
usage.cache_read_tokens_included_in_input === true;
|
|
1958
|
+
const countedCacheInputTokens = cacheInputTokensIncludedInInput ? 0 : cacheInputTokens;
|
|
1959
|
+
const totalTokens = positiveTokenNumber(firstUsageNumber(usage.totalTokens, usage.total_tokens, usage.totalTokenCount, usage.total_token_count)) || inputTokens + outputTokens + countedCacheInputTokens;
|
|
955
1960
|
if (!inputTokens && !outputTokens && !cacheInputTokens && !totalTokens) return null;
|
|
956
|
-
|
|
1961
|
+
const result = {
|
|
957
1962
|
inputTokens,
|
|
958
1963
|
outputTokens,
|
|
959
1964
|
cacheInputTokens,
|
|
@@ -963,28 +1968,58 @@ function sessionSummaryUsage(sessionSummary) {
|
|
|
963
1968
|
totalInputTokens: inputTokens,
|
|
964
1969
|
totalOutputTokens: outputTokens
|
|
965
1970
|
};
|
|
1971
|
+
if (usage.authoritativeTotalTokens === true || usage.authoritative_total_tokens === true) result.authoritativeTotalTokens = true;
|
|
1972
|
+
if (cacheInputTokensIncludedInInput) result.cacheInputTokensIncludedInInput = true;
|
|
1973
|
+
return result;
|
|
966
1974
|
}
|
|
967
1975
|
|
|
968
1976
|
function mergeSessionUsage(primary, secondary) {
|
|
969
1977
|
if (!primary && !secondary) return null;
|
|
970
1978
|
if (!primary) return secondary;
|
|
971
1979
|
if (!secondary) return primary;
|
|
1980
|
+
const totalTokens = secondary.authoritativeTotalTokens
|
|
1981
|
+
? (secondary.totalTokens || 0)
|
|
1982
|
+
: primary.authoritativeTotalTokens
|
|
1983
|
+
? (primary.totalTokens || 0)
|
|
1984
|
+
: Math.max(primary.totalTokens || 0, secondary.totalTokens || 0);
|
|
972
1985
|
const result = {
|
|
973
1986
|
inputTokens: primary.inputTokens || secondary.inputTokens || 0,
|
|
974
1987
|
outputTokens: primary.outputTokens || secondary.outputTokens || 0,
|
|
975
1988
|
cacheInputTokens: primary.cacheInputTokens || secondary.cacheInputTokens || 0,
|
|
976
1989
|
reasoningOutputTokens: primary.reasoningOutputTokens || secondary.reasoningOutputTokens || 0,
|
|
977
1990
|
toolUsePromptTokens: primary.toolUsePromptTokens || secondary.toolUsePromptTokens || 0,
|
|
978
|
-
totalTokens
|
|
1991
|
+
totalTokens,
|
|
979
1992
|
totalInputTokens: primary.totalInputTokens || secondary.totalInputTokens || primary.inputTokens || secondary.inputTokens || 0,
|
|
980
1993
|
totalOutputTokens: primary.totalOutputTokens || secondary.totalOutputTokens || primary.outputTokens || secondary.outputTokens || 0
|
|
981
1994
|
};
|
|
1995
|
+
if (primary.authoritativeTotalTokens || secondary.authoritativeTotalTokens) {
|
|
1996
|
+
result.authoritativeTotalTokens = true;
|
|
1997
|
+
}
|
|
1998
|
+
if (primary.cacheInputTokensIncludedInInput || secondary.cacheInputTokensIncludedInInput) {
|
|
1999
|
+
result.cacheInputTokensIncludedInInput = true;
|
|
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
|
+
}
|
|
2007
|
+
if (primary.reasoningOutputTokensIncludedInOutput || secondary.reasoningOutputTokensIncludedInOutput) {
|
|
2008
|
+
result.reasoningOutputTokensIncludedInOutput = true;
|
|
2009
|
+
}
|
|
982
2010
|
if (primary.estimated && secondary.estimated) {
|
|
983
2011
|
result.estimated = true;
|
|
984
2012
|
if (primary.estimationMethod && primary.estimationMethod === secondary.estimationMethod) {
|
|
985
2013
|
result.estimationMethod = primary.estimationMethod;
|
|
986
2014
|
}
|
|
987
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
|
+
}
|
|
988
2023
|
return result;
|
|
989
2024
|
}
|
|
990
2025
|
|
|
@@ -1047,7 +2082,7 @@ function readEvents(sessionOrPath) {
|
|
|
1047
2082
|
function ensureConversationMarkdown(session, env = process.env) {
|
|
1048
2083
|
const conversationPath = session.conversationPath || session.metadataPath?.replace(/\.metadata\.json$/, ".conversation.md");
|
|
1049
2084
|
if (!conversationPath) return "";
|
|
1050
|
-
if (fs.existsSync(conversationPath)) return conversationPath;
|
|
2085
|
+
if (fs.existsSync(conversationPath) && !conversationMarkdownNeedsRefresh(conversationPath, session)) return conversationPath;
|
|
1051
2086
|
const messages = readTranscript(session.transcriptPath);
|
|
1052
2087
|
if (!messages.length) return "";
|
|
1053
2088
|
const events = readEvents(session);
|
|
@@ -1060,7 +2095,25 @@ function ensureConversationMarkdown(session, env = process.env) {
|
|
|
1060
2095
|
return conversationPath;
|
|
1061
2096
|
}
|
|
1062
2097
|
|
|
1063
|
-
|
|
2098
|
+
function conversationMarkdownNeedsRefresh(file, session) {
|
|
2099
|
+
let text = "";
|
|
2100
|
+
try {
|
|
2101
|
+
text = fs.readFileSync(file, "utf8");
|
|
2102
|
+
} catch {
|
|
2103
|
+
return false;
|
|
2104
|
+
}
|
|
2105
|
+
if (!conversationRenderVersionMatches(text)) return true;
|
|
2106
|
+
if (session?.provider !== "chatgpt" && session?.sourceType !== "chatgpt-export") return false;
|
|
2107
|
+
return /\uE200[A-Za-z_]*cite\uE202[^\uE201]+\uE201/.test(text);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
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
|
+
}
|
|
1064
2117
|
|
|
1065
2118
|
function sessionViewPathFromMetadata(metadataPath) {
|
|
1066
2119
|
if (!metadataPath) return "";
|
|
@@ -1104,6 +2157,51 @@ function displayTranscriptMessages(messages, time) {
|
|
|
1104
2157
|
return messages.map((message) => ({ ...message, timestamp: "" }));
|
|
1105
2158
|
}
|
|
1106
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
|
+
|
|
1107
2205
|
function cursorRawTimeMatchesSourceMtime(session, timestamp) {
|
|
1108
2206
|
const rawFiles = Array.isArray(session?.rawFiles) ? session.rawFiles : [];
|
|
1109
2207
|
return rawFiles.some((file) => {
|
|
@@ -1146,7 +2244,7 @@ function sessionHistoryTime(session) {
|
|
|
1146
2244
|
|
|
1147
2245
|
function computeSessionView(session, transcriptMessages, canonicalEvents) {
|
|
1148
2246
|
const time = sessionHistoryTime(session);
|
|
1149
|
-
const display = displayTranscriptMessages(dedupeTranscriptMessages(transcriptMessages || []), time);
|
|
2247
|
+
const display = compactViewTranscriptMessages(displayTranscriptMessages(dedupeTranscriptMessages(transcriptMessages || []), time));
|
|
1150
2248
|
return {
|
|
1151
2249
|
view_schema_version: VIEW_SCHEMA_VERSION,
|
|
1152
2250
|
session_id: session?.sessionId || "",
|
|
@@ -1155,7 +2253,7 @@ function computeSessionView(session, transcriptMessages, canonicalEvents) {
|
|
|
1155
2253
|
time_status: time.status || "",
|
|
1156
2254
|
session_summary: session?.sessionSummary || undefined,
|
|
1157
2255
|
transcript_messages: display,
|
|
1158
|
-
canonical_events:
|
|
2256
|
+
canonical_events: compactViewCanonicalEvents(canonicalEvents)
|
|
1159
2257
|
};
|
|
1160
2258
|
}
|
|
1161
2259
|
|
|
@@ -1195,6 +2293,44 @@ function renderConversationMarkdown(session, messages, events = []) {
|
|
|
1195
2293
|
const title = session.title || `${session.provider || "agent"} session ${session.sessionId}`;
|
|
1196
2294
|
const time = sessionHistoryTime(session);
|
|
1197
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
|
+
}
|
|
1198
2334
|
const hasRedactions =
|
|
1199
2335
|
displayMessages.some((message) => /\[REDACTED(?::|\s+)[^\]\n]+\]/.test(JSON.stringify(message))) ||
|
|
1200
2336
|
events.some((event) => /\[REDACTED(?::|\s+)[^\]\n]+\]/.test(JSON.stringify(event)));
|
|
@@ -1202,6 +2338,9 @@ function renderConversationMarkdown(session, messages, events = []) {
|
|
|
1202
2338
|
"---",
|
|
1203
2339
|
`session_id: ${yamlString(session.sessionId)}`,
|
|
1204
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 || "")}`,
|
|
1205
2344
|
`repo: ${yamlString(session.repoCanonical || "")}`,
|
|
1206
2345
|
`scope: ${yamlString(session.scopeCanonical || "")}`,
|
|
1207
2346
|
`cwd: ${yamlString(session.cwd || "")}`,
|
|
@@ -1215,6 +2354,7 @@ function renderConversationMarkdown(session, messages, events = []) {
|
|
|
1215
2354
|
`conversation_kind: ${yamlString(session.conversationKind || "")}`,
|
|
1216
2355
|
`time_status: ${yamlString(time.status || session.timeStatus || "")}`,
|
|
1217
2356
|
`archive_schema_version: ${yamlString(session.archiveSchemaVersion || 1)}`,
|
|
2357
|
+
`conversation_render_version: ${yamlString(CONVERSATION_RENDER_VERSION)}`,
|
|
1218
2358
|
`event_count: ${yamlString(events.length || session.eventCount || 0)}`,
|
|
1219
2359
|
`parser_versions: ${yamlString(JSON.stringify(session.parserVersions || {}))}`,
|
|
1220
2360
|
"---",
|
|
@@ -1234,24 +2374,168 @@ function renderConversationMarkdown(session, messages, events = []) {
|
|
|
1234
2374
|
for (const message of displayMessages) {
|
|
1235
2375
|
body.push(`## ${roleLabel(message.role)} - ${message.timestamp || "unknown time"}`);
|
|
1236
2376
|
body.push("");
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
2377
|
+
const suppressMemoryToolContent = String(message.role || "").toLowerCase() === "tool" && memoryEventsByMessageIndex.has(message.index);
|
|
2378
|
+
const content = suppressMemoryToolContent ? "" : styleRedactionMarkersForMarkdown(styleChatGptCitationMarkersForMarkdown(String(message.content || "").trim()));
|
|
2379
|
+
if (content) {
|
|
2380
|
+
body.push(content);
|
|
2381
|
+
body.push("");
|
|
2382
|
+
}
|
|
2383
|
+
const attachmentMarkdown = renderMessageAttachmentsMarkdown(message);
|
|
2384
|
+
if (attachmentMarkdown) {
|
|
2385
|
+
body.push(attachmentMarkdown);
|
|
2386
|
+
body.push("");
|
|
2387
|
+
}
|
|
2388
|
+
for (const event of toolCallsByMessageIndex.get(message.index) || []) {
|
|
1240
2389
|
body.push(`### Tool Call - ${event.occurredAt || message.timestamp || "unknown time"}`);
|
|
1241
2390
|
body.push("");
|
|
1242
|
-
body.push(styleRedactionMarkersForMarkdown(String(event.body?.text || event.indexed?.summary || "").trim()));
|
|
2391
|
+
body.push(styleRedactionMarkersForMarkdown(styleChatGptCitationMarkersForMarkdown(String(event.body?.text || event.indexed?.summary || "").trim())));
|
|
1243
2392
|
body.push("");
|
|
1244
2393
|
}
|
|
1245
|
-
for (const event of
|
|
2394
|
+
for (const event of toolResultsByMessageIndex.get(message.index) || []) {
|
|
1246
2395
|
body.push(`### Tool Result - ${event.occurredAt || message.timestamp || "unknown time"}`);
|
|
1247
2396
|
body.push("");
|
|
1248
|
-
body.push(styleRedactionMarkersForMarkdown(String(event.indexed?.summary || event.body?.text || "").trim()));
|
|
2397
|
+
body.push(styleRedactionMarkersForMarkdown(styleChatGptCitationMarkersForMarkdown(String(event.indexed?.summary || event.body?.text || "").trim())));
|
|
2398
|
+
body.push("");
|
|
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))));
|
|
1249
2404
|
body.push("");
|
|
1250
2405
|
}
|
|
1251
2406
|
}
|
|
1252
2407
|
return `${frontmatter.concat(body).join("\n").trimEnd()}\n`;
|
|
1253
2408
|
}
|
|
1254
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
|
+
|
|
2498
|
+
function styleChatGptCitationMarkersForMarkdown(value) {
|
|
2499
|
+
return String(value || "").replace(/\uE200([A-Za-z_]*cite)\uE202([^\uE201]+)\uE201/g, (_, kind, ref) => {
|
|
2500
|
+
const label = /file/i.test(String(kind || "")) ? "file citation" : "citation";
|
|
2501
|
+
const text = chatGptCitationParts(ref).join(" ");
|
|
2502
|
+
return text ? `[${label}: ${text}]` : `[${label}]`;
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
function chatGptCitationParts(ref) {
|
|
2507
|
+
return String(ref || "").split("\uE202").map((part) => part.trim()).filter(Boolean);
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function renderMessageAttachmentsMarkdown(message) {
|
|
2511
|
+
const attachments = Array.isArray(message?.metadata?.attachments) ? message.metadata.attachments : [];
|
|
2512
|
+
const assetPointers = Array.isArray(message?.metadata?.assetPointers) ? message.metadata.assetPointers : [];
|
|
2513
|
+
if (!attachments.length && !assetPointers.length) return "";
|
|
2514
|
+
const lines = ["### Attachments", ""];
|
|
2515
|
+
for (const attachment of attachments) {
|
|
2516
|
+
const name = markdownLine(attachment?.name || attachment?.id || "attachment");
|
|
2517
|
+
const details = [
|
|
2518
|
+
attachment?.mimeType,
|
|
2519
|
+
attachment?.width && attachment?.height ? `${attachment.width}x${attachment.height}` : "",
|
|
2520
|
+
attachment?.size ? `${attachment.size} bytes` : "",
|
|
2521
|
+
attachment?.assetPointer ? `asset ${attachment.assetPointer}` : ""
|
|
2522
|
+
].filter(Boolean).join(", ");
|
|
2523
|
+
lines.push(`- ${name}${details ? ` (${markdownLine(details)})` : ""}`);
|
|
2524
|
+
}
|
|
2525
|
+
const attachedPointers = new Set(attachments.map((attachment) => attachment?.assetPointer).filter(Boolean));
|
|
2526
|
+
for (const pointer of assetPointers) {
|
|
2527
|
+
if (!pointer?.assetPointer || attachedPointers.has(pointer.assetPointer)) continue;
|
|
2528
|
+
const details = [
|
|
2529
|
+
pointer.contentType,
|
|
2530
|
+
pointer.mimeType,
|
|
2531
|
+
pointer.width && pointer.height ? `${pointer.width}x${pointer.height}` : "",
|
|
2532
|
+
pointer.size ? `${pointer.size} bytes` : ""
|
|
2533
|
+
].filter(Boolean).join(", ");
|
|
2534
|
+
lines.push(`- ${markdownLine(pointer.assetPointer)}${details ? ` (${markdownLine(details)})` : ""}`);
|
|
2535
|
+
}
|
|
2536
|
+
return lines.join("\n");
|
|
2537
|
+
}
|
|
2538
|
+
|
|
1255
2539
|
function normalizePrecomputedEvents(events, session) {
|
|
1256
2540
|
return events
|
|
1257
2541
|
.filter((event) => event && typeof event === "object")
|
|
@@ -1315,7 +2599,8 @@ function touchManifest(session, pathsForSession, env = process.env) {
|
|
|
1315
2599
|
transcriptPath: pathsForSession.transcriptPath,
|
|
1316
2600
|
eventPath: pathsForSession.eventPath,
|
|
1317
2601
|
viewPath: pathsForSession.viewPath || "",
|
|
1318
|
-
rawPath: pathsForSession.rawPath || ""
|
|
2602
|
+
rawPath: pathsForSession.rawPath || "",
|
|
2603
|
+
artifactsPath: pathsForSession.artifactsPath || ""
|
|
1319
2604
|
});
|
|
1320
2605
|
writeJson(manifestPath, { sessions: next.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt))) });
|
|
1321
2606
|
}
|
|
@@ -1356,6 +2641,8 @@ function walk(dir, visit) {
|
|
|
1356
2641
|
|
|
1357
2642
|
module.exports = {
|
|
1358
2643
|
archiveRoot,
|
|
2644
|
+
collectSessionToolUsage,
|
|
2645
|
+
mcpServerFromToolUsage,
|
|
1359
2646
|
collectSessionModels,
|
|
1360
2647
|
computeSessionUsage,
|
|
1361
2648
|
computeSessionView,
|
|
@@ -1370,12 +2657,17 @@ module.exports = {
|
|
|
1370
2657
|
ensureConversationMarkdown,
|
|
1371
2658
|
ensureSessionView,
|
|
1372
2659
|
findSessionById,
|
|
2660
|
+
invalidateSessionsSnapshotCache,
|
|
2661
|
+
isWebChatProvider,
|
|
1373
2662
|
listSessions,
|
|
2663
|
+
listSessionsSnapshot,
|
|
2664
|
+
cachedListSessionsSnapshot,
|
|
1374
2665
|
normalizeMessages,
|
|
1375
2666
|
objectPathForSession,
|
|
1376
2667
|
readEvents,
|
|
1377
2668
|
readTranscript,
|
|
1378
2669
|
renderConversationMarkdown,
|
|
2670
|
+
rebuildSessionListIndex,
|
|
1379
2671
|
revealSession,
|
|
1380
2672
|
sessionHistoryTime,
|
|
1381
2673
|
sessionViewPathForSession,
|