@abnersajr/claude-timeline 1.0.0 → 1.0.1
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/dist/cli.js +1025 -46
- package/dist/db-reader.d.ts.map +1 -1
- package/dist/{pricing-DTmya3JY.mjs → pricing-5MZ5_SQc.mjs} +1 -1
- package/dist/{pricing-DTmya3JY.mjs.map → pricing-5MZ5_SQc.mjs.map} +1 -1
- package/dist/pricing-B9Z0E171.mjs +2 -0
- package/dist/server.cjs +69 -2
- package/dist/web/assets/{index-Dr0FGYfS.js → index-BbY4gr3z.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/db-reader-BrPRGqww.mjs +0 -1028
- package/dist/db-reader-BrPRGqww.mjs.map +0 -1
- package/dist/db-reader-CPXmkt55.mjs +0 -2
- package/dist/pricing-B-rwfwDB.mjs +0 -2
|
@@ -1,1028 +0,0 @@
|
|
|
1
|
-
import { i as getPricing, n as calculateTurnCost, s as normalizeModelName, t as calculateSessionCost } from "./pricing-DTmya3JY.mjs";
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
-
import { basename, dirname, join } from "node:path";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import Database from "better-sqlite3";
|
|
6
|
-
//#region src/classifier.ts
|
|
7
|
-
/** Entry types that are always noise */
|
|
8
|
-
const NOISE_TYPES = new Set([
|
|
9
|
-
"system",
|
|
10
|
-
"summary",
|
|
11
|
-
"file-history-snapshot",
|
|
12
|
-
"queue-operation",
|
|
13
|
-
"attachment",
|
|
14
|
-
"last-prompt",
|
|
15
|
-
"permission-mode",
|
|
16
|
-
"ai-title"
|
|
17
|
-
]);
|
|
18
|
-
/** Hard noise tags that should be filtered out entirely */
|
|
19
|
-
const HARD_NOISE_TAGS = ["<local-command-caveat>", "<system-reminder>"];
|
|
20
|
-
/** Command output tags that map to system category */
|
|
21
|
-
const COMMAND_OUTPUT_TAGS = ["<local-command-stdout>", "<local-command-stderr>"];
|
|
22
|
-
/** Check if content string starts with any of the given tags */
|
|
23
|
-
function startsWithTag(content, tags) {
|
|
24
|
-
for (const tag of tags) if (content.startsWith(tag)) return true;
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
/** Check if the content array has at least one text or image block */
|
|
28
|
-
function hasTextOrImageContent(content) {
|
|
29
|
-
if (typeof content === "string") return content.length > 0;
|
|
30
|
-
return content.some((block) => block.type === "text" || block.type === "image");
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Check if an array content is tool_result-only (no text/image blocks).
|
|
34
|
-
* These are tool execution results coming back from the CLI — they represent
|
|
35
|
-
* assistant context, not actual user-typed input.
|
|
36
|
-
*/
|
|
37
|
-
function isToolResultOnly(content) {
|
|
38
|
-
if (typeof content === "string") return false;
|
|
39
|
-
return content.length > 0 && content.every((block) => block.type === "tool_result");
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Hard noise: system/summary/file-history-snapshot/queue-operation/attachment/last-prompt/permission-mode types,
|
|
43
|
-
* sidechain, synthetic assistant, hard noise tags, interruptions.
|
|
44
|
-
*/
|
|
45
|
-
function isHardNoise(record) {
|
|
46
|
-
const type = record.type;
|
|
47
|
-
if (NOISE_TYPES.has(type)) return true;
|
|
48
|
-
if (record.isSidechain) return true;
|
|
49
|
-
const message = record.message;
|
|
50
|
-
if (type === "assistant" && message?.model === "<synthetic>") return true;
|
|
51
|
-
if (type === "user" && message?.content !== void 0) {
|
|
52
|
-
const { content } = message;
|
|
53
|
-
if (typeof content === "string") {
|
|
54
|
-
if (startsWithTag(content, HARD_NOISE_TAGS)) return true;
|
|
55
|
-
if (content === "[Request interrupted by user]") return true;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
/** Compact messages are marked by isCompactSummary flag */
|
|
61
|
-
function isCompactMessage(record) {
|
|
62
|
-
return record.isCompactSummary === true;
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* System messages: user-type messages that contain command output
|
|
66
|
-
* (local-command-stdout/stderr). These arrive as type="user" in JSONL
|
|
67
|
-
* but represent command output, not user input.
|
|
68
|
-
*/
|
|
69
|
-
function isSystemMessage(record) {
|
|
70
|
-
if (record.type !== "user") return false;
|
|
71
|
-
const content = record.message?.content;
|
|
72
|
-
if (typeof content !== "string") return false;
|
|
73
|
-
return startsWithTag(content, COMMAND_OUTPUT_TAGS);
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* User messages: type=user, isMeta=false, has text/image content
|
|
77
|
-
* (not just tool_result blocks). Meta messages (tool results) are
|
|
78
|
-
* classified as assistant because they represent assistant context.
|
|
79
|
-
* Tool-result-only records (isMeta=null, content is array of tool_result)
|
|
80
|
-
* are also classified as assistant — they're CLI tool outputs, not user input.
|
|
81
|
-
*/
|
|
82
|
-
function isUserMessage(record) {
|
|
83
|
-
if (record.type !== "user") return false;
|
|
84
|
-
if (record.isMeta) return false;
|
|
85
|
-
const content = record.message?.content;
|
|
86
|
-
if (content === void 0) return false;
|
|
87
|
-
if (typeof content === "string") return true;
|
|
88
|
-
if (isToolResultOnly(content)) return false;
|
|
89
|
-
return hasTextOrImageContent(content);
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Classify a single JSONL record into a category using the priority cascade:
|
|
93
|
-
* 1. hardNoise — noise types, sidechain, synthetic, hard noise tags, interruptions
|
|
94
|
-
* 2. compact — isCompactSummary === true
|
|
95
|
-
* 3. system — user messages with command output (local-command-stdout/stderr)
|
|
96
|
-
* 4. user — type=user, not meta, has text/image content
|
|
97
|
-
* 5. assistant — everything else (catch-all)
|
|
98
|
-
*/
|
|
99
|
-
function classifyMessage(record) {
|
|
100
|
-
if (isHardNoise(record)) return "hardNoise";
|
|
101
|
-
if (isCompactMessage(record)) return "compact";
|
|
102
|
-
if (isSystemMessage(record)) return "system";
|
|
103
|
-
if (isUserMessage(record)) return "user";
|
|
104
|
-
return "assistant";
|
|
105
|
-
}
|
|
106
|
-
//#endregion
|
|
107
|
-
//#region src/dedup.ts
|
|
108
|
-
/**
|
|
109
|
-
* Deduplicate streaming assistant entries by requestId.
|
|
110
|
-
*
|
|
111
|
-
* Claude Code writes multiple JSONL entries per API response during streaming:
|
|
112
|
-
* - Streaming duplicates: same requestId with incrementally increasing output_tokens
|
|
113
|
-
* - Content blocks: same requestId with identical output_tokens (thinking/text/tool_use)
|
|
114
|
-
*
|
|
115
|
-
* Strategy:
|
|
116
|
-
* 1. For streaming duplicates (strictly increasing tokens): keep only the last entry
|
|
117
|
-
* 2. For content blocks (equal tokens): MERGE into one record by concatenating content arrays
|
|
118
|
-
*
|
|
119
|
-
* Entries without a requestId (user, system, tool results) pass through unchanged.
|
|
120
|
-
*/
|
|
121
|
-
function deduplicateByRequestId(records) {
|
|
122
|
-
const mergedByRequestId = /* @__PURE__ */ new Map();
|
|
123
|
-
const hasStrictIncrease = /* @__PURE__ */ new Set();
|
|
124
|
-
for (let i = 0; i < records.length; i++) {
|
|
125
|
-
const rid = records[i].requestId;
|
|
126
|
-
if (!rid) continue;
|
|
127
|
-
const outputTokens = records[i].message?.usage?.output_tokens ?? 0;
|
|
128
|
-
const existing = mergedByRequestId.get(rid);
|
|
129
|
-
if (existing) {
|
|
130
|
-
if (outputTokens > existing.outputTokens) {
|
|
131
|
-
hasStrictIncrease.add(rid);
|
|
132
|
-
mergedByRequestId.set(rid, {
|
|
133
|
-
index: i,
|
|
134
|
-
outputTokens,
|
|
135
|
-
merged: records[i]
|
|
136
|
-
});
|
|
137
|
-
} else if (outputTokens === existing.outputTokens) existing.merged = mergeContentBlocks(existing.merged, records[i]);
|
|
138
|
-
} else mergedByRequestId.set(rid, {
|
|
139
|
-
index: i,
|
|
140
|
-
outputTokens,
|
|
141
|
-
merged: records[i]
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
if (mergedByRequestId.size === 0) return records;
|
|
145
|
-
const requestIdIndices = /* @__PURE__ */ new Map();
|
|
146
|
-
for (let i = 0; i < records.length; i++) {
|
|
147
|
-
const rid = records[i].requestId;
|
|
148
|
-
if (!rid) continue;
|
|
149
|
-
let indices = requestIdIndices.get(rid);
|
|
150
|
-
if (!indices) {
|
|
151
|
-
indices = /* @__PURE__ */ new Set();
|
|
152
|
-
requestIdIndices.set(rid, indices);
|
|
153
|
-
}
|
|
154
|
-
indices.add(i);
|
|
155
|
-
}
|
|
156
|
-
const result = [];
|
|
157
|
-
const emittedRequestIds = /* @__PURE__ */ new Set();
|
|
158
|
-
for (let i = 0; i < records.length; i++) {
|
|
159
|
-
const rid = records[i].requestId;
|
|
160
|
-
if (!rid) {
|
|
161
|
-
result.push(records[i]);
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
const merged = mergedByRequestId.get(rid);
|
|
165
|
-
if (!merged) continue;
|
|
166
|
-
if (!emittedRequestIds.has(rid)) {
|
|
167
|
-
result.push(merged.merged);
|
|
168
|
-
emittedRequestIds.add(rid);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return result;
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* Merge content blocks from two records with the same requestId.
|
|
175
|
-
* Concatenates the content arrays, keeping all unique content types.
|
|
176
|
-
*/
|
|
177
|
-
function mergeContentBlocks(existing, incoming) {
|
|
178
|
-
const existingContent = existing.message?.content;
|
|
179
|
-
const incomingContent = incoming.message?.content;
|
|
180
|
-
if (!Array.isArray(existingContent) || !Array.isArray(incomingContent)) return existing;
|
|
181
|
-
const mergedContent = [...existingContent, ...incomingContent];
|
|
182
|
-
return {
|
|
183
|
-
...existing,
|
|
184
|
-
message: existing.message ? {
|
|
185
|
-
...existing.message,
|
|
186
|
-
content: mergedContent
|
|
187
|
-
} : existing.message
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
//#endregion
|
|
191
|
-
//#region src/utils.ts
|
|
192
|
-
/**
|
|
193
|
-
* Get the path to usage.db
|
|
194
|
-
* Priority: customPath > CLAUDE_CONFIG_DIR env > ~/.claude
|
|
195
|
-
*/
|
|
196
|
-
function getDbPath(customPath) {
|
|
197
|
-
if (customPath) return customPath;
|
|
198
|
-
return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "usage.db");
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Get the path to the projects directory
|
|
202
|
-
* Priority: customPath > CLAUDE_CONFIG_DIR env > ~/.claude
|
|
203
|
-
*/
|
|
204
|
-
function getProjectsDir(customPath) {
|
|
205
|
-
if (customPath) return customPath;
|
|
206
|
-
return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "projects");
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* Encode project name for directory lookup
|
|
210
|
-
* Replaces all '/' with '-' (e.g., '/Users/test' → '-Users-test')
|
|
211
|
-
*/
|
|
212
|
-
function encodeProjectName(projectName) {
|
|
213
|
-
return projectName.replaceAll("/", "-");
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Resolve the path to a session's JSONL file
|
|
217
|
-
* Tries multiple encodings to handle DB storing project_name with or without leading '/'
|
|
218
|
-
*/
|
|
219
|
-
function resolveSessionJsonlPath(session, projectsDir) {
|
|
220
|
-
const candidates = [];
|
|
221
|
-
candidates.push(encodeProjectName(session.projectName));
|
|
222
|
-
if (!session.projectName.startsWith("/")) candidates.push(encodeProjectName(`/${session.projectName}`));
|
|
223
|
-
if (session.projectName.startsWith("/")) candidates.push(encodeProjectName(session.projectName.slice(1)));
|
|
224
|
-
candidates.push(encodeURIComponent(session.projectName));
|
|
225
|
-
for (const encoded of candidates) {
|
|
226
|
-
const filePath = join(projectsDir, encoded, `${session.sessionId}.jsonl`);
|
|
227
|
-
if (existsSync(filePath)) return filePath;
|
|
228
|
-
}
|
|
229
|
-
return null;
|
|
230
|
-
}
|
|
231
|
-
//#endregion
|
|
232
|
-
//#region src/subagent-locator.ts
|
|
233
|
-
/**
|
|
234
|
-
* List subagent files for a session.
|
|
235
|
-
* Scans two directory structures:
|
|
236
|
-
* - New nested: {projectsDir}/{project}/{session}/subagents/agent-{id}.jsonl
|
|
237
|
-
* - Legacy flat: {projectsDir}/{project}/agent-{id}.jsonl (filtered by sessionId)
|
|
238
|
-
*
|
|
239
|
-
* Returns NEW structure files first, then legacy flat files.
|
|
240
|
-
*/
|
|
241
|
-
function listSubagentFiles(projectsDir, projectName, sessionId) {
|
|
242
|
-
const encodedProject = encodeProjectName(projectName);
|
|
243
|
-
const allFiles = [];
|
|
244
|
-
const candidates = [encodedProject];
|
|
245
|
-
if (!encodedProject.startsWith("-")) candidates.push(`-${encodedProject}`);
|
|
246
|
-
for (const projectDirName of candidates) {
|
|
247
|
-
const newSubagentsDir = join(projectsDir, projectDirName, sessionId, "subagents");
|
|
248
|
-
if (existsSync(newSubagentsDir)) try {
|
|
249
|
-
const entries = readdirSync(newSubagentsDir);
|
|
250
|
-
for (const entry of entries) if (entry.startsWith("agent-") && entry.endsWith(".jsonl")) {
|
|
251
|
-
const agentId = extractAgentId(entry);
|
|
252
|
-
if (agentId) allFiles.push({
|
|
253
|
-
filePath: join(newSubagentsDir, entry),
|
|
254
|
-
agentId,
|
|
255
|
-
isNewStructure: true
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
} catch {}
|
|
259
|
-
const projectDir = join(projectsDir, projectDirName);
|
|
260
|
-
if (existsSync(projectDir)) try {
|
|
261
|
-
const entries = readdirSync(projectDir);
|
|
262
|
-
for (const entry of entries) if (entry.startsWith("agent-") && entry.endsWith(".jsonl")) {
|
|
263
|
-
const agentId = extractAgentId(entry);
|
|
264
|
-
if (!agentId) continue;
|
|
265
|
-
if (isCompactAgent(agentId)) continue;
|
|
266
|
-
const filePath = join(projectDir, entry);
|
|
267
|
-
if (subagentBelongsToSession(filePath, sessionId)) allFiles.push({
|
|
268
|
-
filePath,
|
|
269
|
-
agentId,
|
|
270
|
-
isNewStructure: false
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
} catch {}
|
|
274
|
-
}
|
|
275
|
-
return allFiles;
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Extract agent ID from filename.
|
|
279
|
-
* e.g., "agent-abc123.jsonl" → "abc123"
|
|
280
|
-
*/
|
|
281
|
-
function extractAgentId(filename) {
|
|
282
|
-
const match = basename(filename).match(/^agent-([^.]+)\.jsonl$/);
|
|
283
|
-
return match ? match[1] : null;
|
|
284
|
-
}
|
|
285
|
-
/**
|
|
286
|
-
* Check if agent ID belongs to a compact agent (starts with "acompact").
|
|
287
|
-
*/
|
|
288
|
-
function isCompactAgent(agentId) {
|
|
289
|
-
return agentId.startsWith("acompact");
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Check if a legacy subagent file belongs to a specific session.
|
|
293
|
-
* Reads the first line to check the sessionId field.
|
|
294
|
-
*/
|
|
295
|
-
function subagentBelongsToSession(filePath, sessionId) {
|
|
296
|
-
try {
|
|
297
|
-
const content = readFileSync(filePath, "utf-8");
|
|
298
|
-
const firstNewline = content.indexOf("\n");
|
|
299
|
-
const firstLine = firstNewline > 0 ? content.slice(0, firstNewline) : content;
|
|
300
|
-
if (!firstLine.trim()) return false;
|
|
301
|
-
return JSON.parse(firstLine).sessionId === sessionId;
|
|
302
|
-
} catch {
|
|
303
|
-
return false;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
//#endregion
|
|
307
|
-
//#region src/tool-extraction.ts
|
|
308
|
-
/**
|
|
309
|
-
* Extract tool calls from assistant message content.
|
|
310
|
-
* Filters tool_use blocks and identifies Task tools specially.
|
|
311
|
-
*/
|
|
312
|
-
function extractToolCalls(content, timestamp) {
|
|
313
|
-
if (typeof content === "string") return [];
|
|
314
|
-
if (!Array.isArray(content)) return [];
|
|
315
|
-
const calls = [];
|
|
316
|
-
for (const block of content) {
|
|
317
|
-
if (block.type !== "tool_use") continue;
|
|
318
|
-
const toolUseId = String(block.id ?? block.toolUseId ?? "");
|
|
319
|
-
const name = String(block.name ?? "");
|
|
320
|
-
const input = block.input ?? {};
|
|
321
|
-
const isTask = name === "Task";
|
|
322
|
-
let taskDescription;
|
|
323
|
-
let taskSubagentType;
|
|
324
|
-
if (isTask && input) {
|
|
325
|
-
taskDescription = typeof input.description === "string" ? input.description : void 0;
|
|
326
|
-
taskSubagentType = typeof input.subagent_type === "string" ? input.subagent_type : void 0;
|
|
327
|
-
}
|
|
328
|
-
calls.push({
|
|
329
|
-
toolUseId,
|
|
330
|
-
name,
|
|
331
|
-
input,
|
|
332
|
-
timestamp,
|
|
333
|
-
isTask,
|
|
334
|
-
taskDescription,
|
|
335
|
-
taskSubagentType
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
return calls;
|
|
339
|
-
}
|
|
340
|
-
/**
|
|
341
|
-
* Extract tool results from user message content.
|
|
342
|
-
* Filters tool_result blocks from content array.
|
|
343
|
-
*/
|
|
344
|
-
function extractToolResults(content) {
|
|
345
|
-
if (typeof content === "string") return [];
|
|
346
|
-
if (!Array.isArray(content)) return [];
|
|
347
|
-
const results = [];
|
|
348
|
-
for (const block of content) {
|
|
349
|
-
if (block.type !== "tool_result") continue;
|
|
350
|
-
results.push({
|
|
351
|
-
toolUseId: String(block.tool_use_id ?? block.toolUseId ?? ""),
|
|
352
|
-
content: block.content,
|
|
353
|
-
isError: Boolean(block.is_error) || Boolean(block.isError)
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
return results;
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* Link tool results to tool calls by toolUseId.
|
|
360
|
-
* Sets result string and isError flag on matched calls.
|
|
361
|
-
* Returns a new array (does not mutate input).
|
|
362
|
-
*/
|
|
363
|
-
function linkToolResults(calls, results) {
|
|
364
|
-
const resultMap = /* @__PURE__ */ new Map();
|
|
365
|
-
for (const result of results) resultMap.set(result.toolUseId, result);
|
|
366
|
-
return calls.map((call) => {
|
|
367
|
-
const result = resultMap.get(call.toolUseId);
|
|
368
|
-
if (!result) return call;
|
|
369
|
-
return {
|
|
370
|
-
...call,
|
|
371
|
-
result: formatToolResult(result.content),
|
|
372
|
-
isError: result.isError ?? call.isError
|
|
373
|
-
};
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
/**
|
|
377
|
-
* Format tool result content into a readable string.
|
|
378
|
-
* Handles:
|
|
379
|
-
* - stdout/stderr (command execution results)
|
|
380
|
-
* - questions/answers (interactive prompts)
|
|
381
|
-
* - generic JSON (everything else)
|
|
382
|
-
*/
|
|
383
|
-
function formatToolResult(content) {
|
|
384
|
-
if (content === null || content === void 0) return "";
|
|
385
|
-
if (typeof content === "string") return content;
|
|
386
|
-
if (typeof content === "object" && !Array.isArray(content)) {
|
|
387
|
-
const obj = content;
|
|
388
|
-
if ("stdout" in obj) {
|
|
389
|
-
let result = String(obj.stdout ?? "");
|
|
390
|
-
if (obj.stderr) result += `\n[stderr]: ${obj.stderr}`;
|
|
391
|
-
return result;
|
|
392
|
-
}
|
|
393
|
-
if ("questions" in obj) return JSON.stringify({
|
|
394
|
-
questions: obj.questions,
|
|
395
|
-
answers: obj.answers
|
|
396
|
-
});
|
|
397
|
-
return JSON.stringify(content);
|
|
398
|
-
}
|
|
399
|
-
return JSON.stringify(content);
|
|
400
|
-
}
|
|
401
|
-
//#endregion
|
|
402
|
-
//#region src/subagent-resolver.ts
|
|
403
|
-
/**
|
|
404
|
-
* Parallel detection window in milliseconds.
|
|
405
|
-
* Subagents starting within this window are considered parallel.
|
|
406
|
-
*/
|
|
407
|
-
const PARALLEL_WINDOW_MS = 100;
|
|
408
|
-
/**
|
|
409
|
-
* Resolve subagents from discovered files.
|
|
410
|
-
* Links subagents to parent Task calls with 3-phase linking:
|
|
411
|
-
* Phase 1: agentId matching (from Task tool result JSON)
|
|
412
|
-
* Phase 2: description matching (fuzzy match on taskDescription)
|
|
413
|
-
* Phase 3: positional fallback (unmatched Task calls in order)
|
|
414
|
-
*
|
|
415
|
-
* Also detects parallel execution and aggregates tokens.
|
|
416
|
-
*/
|
|
417
|
-
function resolveSubagents(subagentFiles, parentToolCalls) {
|
|
418
|
-
const parsed = [];
|
|
419
|
-
for (const file of subagentFiles) {
|
|
420
|
-
const result = parseSubagentFile(file.filePath);
|
|
421
|
-
if (!result) continue;
|
|
422
|
-
if (isWarmupAgent(result.records)) continue;
|
|
423
|
-
if (file.agentId.startsWith("acompact")) continue;
|
|
424
|
-
parsed.push({
|
|
425
|
-
file,
|
|
426
|
-
result
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
const unmatchedTaskCalls = [...parentToolCalls.filter((tc) => tc.isTask || tc.name === "Agent")];
|
|
430
|
-
const subagents = [];
|
|
431
|
-
for (const { file, result } of parsed) {
|
|
432
|
-
let parentTaskId = "";
|
|
433
|
-
for (let i = 0; i < unmatchedTaskCalls.length; i++) {
|
|
434
|
-
const tc = unmatchedTaskCalls[i];
|
|
435
|
-
if (tc.result) try {
|
|
436
|
-
if (JSON.parse(tc.result).agentId === file.agentId) {
|
|
437
|
-
parentTaskId = tc.toolUseId;
|
|
438
|
-
unmatchedTaskCalls.splice(i, 1);
|
|
439
|
-
break;
|
|
440
|
-
}
|
|
441
|
-
} catch {}
|
|
442
|
-
}
|
|
443
|
-
if (!parentTaskId && result.description) for (let i = 0; i < unmatchedTaskCalls.length; i++) {
|
|
444
|
-
const tc = unmatchedTaskCalls[i];
|
|
445
|
-
if (tc.taskDescription) {
|
|
446
|
-
const desc = result.description.toLowerCase();
|
|
447
|
-
const taskDesc = tc.taskDescription.toLowerCase();
|
|
448
|
-
if (desc.includes(taskDesc) || taskDesc.includes(desc)) {
|
|
449
|
-
parentTaskId = tc.toolUseId;
|
|
450
|
-
unmatchedTaskCalls.splice(i, 1);
|
|
451
|
-
break;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
if (!parentTaskId && unmatchedTaskCalls.length > 0) parentTaskId = unmatchedTaskCalls.shift().toolUseId;
|
|
456
|
-
subagents.push({
|
|
457
|
-
id: file.agentId,
|
|
458
|
-
parentTaskId,
|
|
459
|
-
description: result.description,
|
|
460
|
-
startTime: result.records.length > 0 ? findStartTime(result.records) : "",
|
|
461
|
-
endTime: result.records.length > 0 ? findEndTime(result.records) : "",
|
|
462
|
-
turnCount: result.records.filter((r) => r.type === "assistant").length,
|
|
463
|
-
status: "completed",
|
|
464
|
-
isParallel: false,
|
|
465
|
-
model: result.model,
|
|
466
|
-
agentType: result.agentType,
|
|
467
|
-
totalTokens: result.totalTokens,
|
|
468
|
-
totalCost: computeSubagentCost(result.totalTokens, result.model),
|
|
469
|
-
messages: result.messages,
|
|
470
|
-
toolCalls: result.toolCalls
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
detectParallelExecution(subagents);
|
|
474
|
-
return subagents.sort((a, b) => {
|
|
475
|
-
if (!a.startTime) return 1;
|
|
476
|
-
if (!b.startTime) return -1;
|
|
477
|
-
return a.startTime.localeCompare(b.startTime);
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
/**
|
|
481
|
-
* Parse a subagent JSONL file into structured data.
|
|
482
|
-
* Returns null if file doesn't exist or is empty.
|
|
483
|
-
*/
|
|
484
|
-
function parseSubagentFile(filePath) {
|
|
485
|
-
if (!existsSync(filePath)) return null;
|
|
486
|
-
try {
|
|
487
|
-
const lines = readFileSync(filePath, "utf-8").split("\n").filter((line) => line.trim().length > 0);
|
|
488
|
-
if (lines.length === 0) return null;
|
|
489
|
-
const records = [];
|
|
490
|
-
let malformedCount = 0;
|
|
491
|
-
for (const line of lines) try {
|
|
492
|
-
const entry = JSON.parse(line);
|
|
493
|
-
records.push(entry);
|
|
494
|
-
} catch {
|
|
495
|
-
malformedCount++;
|
|
496
|
-
}
|
|
497
|
-
if (records.length === 0) return null;
|
|
498
|
-
for (const record of records) if (record.message?.usage?.cache_creation) {
|
|
499
|
-
const cc = record.message.usage.cache_creation;
|
|
500
|
-
record.message.usage.cacheCreation5mTokens = cc.ephemeral_5m_input_tokens ?? 0;
|
|
501
|
-
record.message.usage.cacheCreation1hTokens = cc.ephemeral_1h_input_tokens ?? 0;
|
|
502
|
-
}
|
|
503
|
-
const deduped = deduplicateByRequestId(records);
|
|
504
|
-
const toolCalls = [];
|
|
505
|
-
const assistantToolCallIndices = /* @__PURE__ */ new Map();
|
|
506
|
-
for (const record of deduped) {
|
|
507
|
-
if (record.type === "assistant" && record.message?.content) {
|
|
508
|
-
const newCalls = extractToolCalls(record.message.content, record.timestamp);
|
|
509
|
-
const startIdx = toolCalls.length;
|
|
510
|
-
toolCalls.push(...newCalls);
|
|
511
|
-
if (newCalls.length > 0 && record.uuid) {
|
|
512
|
-
const indices = Array.from({ length: newCalls.length }, (_, i) => startIdx + i);
|
|
513
|
-
assistantToolCallIndices.set(record.uuid, indices);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
if (record.type === "user" && record.isMeta && record.message?.content) {
|
|
517
|
-
const results = extractToolResults(record.message.content);
|
|
518
|
-
if (results.length > 0) {
|
|
519
|
-
const updatedCalls = linkToolResults(toolCalls, results);
|
|
520
|
-
for (let i = 0; i < updatedCalls.length; i++) if (updatedCalls[i].result !== toolCalls[i].result) toolCalls[i] = updatedCalls[i];
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
if (record.toolUseResult && record.parentUuid) {
|
|
524
|
-
const indices = assistantToolCallIndices.get(record.parentUuid);
|
|
525
|
-
if (indices) for (const idx of indices) toolCalls[idx].result = JSON.stringify(record.toolUseResult);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
const messages = deduped.map((r) => ({
|
|
529
|
-
type: r.type ?? "assistant",
|
|
530
|
-
timestamp: r.timestamp,
|
|
531
|
-
content: normalizeContent(r.message?.content ?? [])
|
|
532
|
-
}));
|
|
533
|
-
let model;
|
|
534
|
-
for (const record of deduped) if (record.type === "assistant" && record.message?.model) {
|
|
535
|
-
model = record.message.model;
|
|
536
|
-
break;
|
|
537
|
-
}
|
|
538
|
-
const totalTokens = aggregateTokens(deduped);
|
|
539
|
-
let description = "";
|
|
540
|
-
const firstAssistant = deduped.find((r) => r.type === "assistant" && r.message?.content);
|
|
541
|
-
if (firstAssistant?.message?.content && Array.isArray(firstAssistant.message.content)) {
|
|
542
|
-
const textBlock = firstAssistant.message.content.find((b) => b.type === "text");
|
|
543
|
-
if (textBlock && textBlock.text) description = String(textBlock.text).slice(0, 200);
|
|
544
|
-
}
|
|
545
|
-
const metaPath = filePath.replace(/\.jsonl$/, ".meta.json");
|
|
546
|
-
let agentType;
|
|
547
|
-
if (existsSync(metaPath)) try {
|
|
548
|
-
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
549
|
-
agentType = meta.agentType;
|
|
550
|
-
if (meta.description) description = meta.description;
|
|
551
|
-
} catch {}
|
|
552
|
-
return {
|
|
553
|
-
records: deduped,
|
|
554
|
-
messages,
|
|
555
|
-
toolCalls,
|
|
556
|
-
description,
|
|
557
|
-
agentType,
|
|
558
|
-
model,
|
|
559
|
-
totalTokens
|
|
560
|
-
};
|
|
561
|
-
} catch {
|
|
562
|
-
return null;
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
/**
|
|
566
|
-
* Check if a subagent is a warmup agent.
|
|
567
|
-
* Warmup agents have first user message === "Warmup".
|
|
568
|
-
*/
|
|
569
|
-
function isWarmupAgent(records) {
|
|
570
|
-
const firstUser = records.find((r) => r.type === "user");
|
|
571
|
-
if (!firstUser) return false;
|
|
572
|
-
const content = firstUser.message?.content;
|
|
573
|
-
return typeof content === "string" && content === "Warmup";
|
|
574
|
-
}
|
|
575
|
-
/**
|
|
576
|
-
* Find the earliest timestamp in records.
|
|
577
|
-
*/
|
|
578
|
-
function findStartTime(records) {
|
|
579
|
-
const timestamps = records.filter((r) => r.timestamp).map((r) => new Date(r.timestamp ?? "").getTime()).filter((t) => !Number.isNaN(t));
|
|
580
|
-
if (timestamps.length === 0) return "";
|
|
581
|
-
return new Date(Math.min(...timestamps)).toISOString();
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
584
|
-
* Find the latest timestamp in records.
|
|
585
|
-
*/
|
|
586
|
-
function findEndTime(records) {
|
|
587
|
-
const timestamps = records.filter((r) => r.timestamp).map((r) => new Date(r.timestamp ?? "").getTime()).filter((t) => !Number.isNaN(t));
|
|
588
|
-
if (timestamps.length === 0) return "";
|
|
589
|
-
return new Date(Math.max(...timestamps)).toISOString();
|
|
590
|
-
}
|
|
591
|
-
/**
|
|
592
|
-
* Detect parallel execution among subagents.
|
|
593
|
-
* Subagents are considered parallel if their time ranges overlap
|
|
594
|
-
* within a 100ms window.
|
|
595
|
-
*/
|
|
596
|
-
function detectParallelExecution(subagents) {
|
|
597
|
-
for (let i = 0; i < subagents.length; i++) for (let j = i + 1; j < subagents.length; j++) {
|
|
598
|
-
const a = subagents[i];
|
|
599
|
-
const b = subagents[j];
|
|
600
|
-
if (!a.startTime || !b.startTime || !a.endTime || !b.endTime) continue;
|
|
601
|
-
const aStart = new Date(a.startTime).getTime();
|
|
602
|
-
const aEnd = new Date(a.endTime).getTime();
|
|
603
|
-
const bStart = new Date(b.startTime).getTime();
|
|
604
|
-
if (aStart <= new Date(b.endTime).getTime() + PARALLEL_WINDOW_MS && bStart <= aEnd + PARALLEL_WINDOW_MS) {
|
|
605
|
-
a.isParallel = true;
|
|
606
|
-
b.isParallel = true;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
/**
|
|
611
|
-
* Aggregate token usage across records with request-id dedup.
|
|
612
|
-
* Same logic as dedup.ts: only the last entry per requestId counts.
|
|
613
|
-
*/
|
|
614
|
-
function aggregateTokens(records) {
|
|
615
|
-
const totals = {
|
|
616
|
-
inputTokens: 0,
|
|
617
|
-
outputTokens: 0,
|
|
618
|
-
cacheReadTokens: 0,
|
|
619
|
-
cacheCreation5mTokens: 0,
|
|
620
|
-
cacheCreation1hTokens: 0
|
|
621
|
-
};
|
|
622
|
-
const bestByRequestId = /* @__PURE__ */ new Map();
|
|
623
|
-
for (const record of records) {
|
|
624
|
-
const usage = record.message?.usage;
|
|
625
|
-
if (!usage) continue;
|
|
626
|
-
const tokens = {
|
|
627
|
-
inputTokens: usage.input_tokens ?? 0,
|
|
628
|
-
outputTokens: usage.output_tokens ?? 0,
|
|
629
|
-
cacheReadTokens: usage.cache_read_input_tokens ?? 0,
|
|
630
|
-
cacheCreation5mTokens: usage.cacheCreation5mTokens ?? 0,
|
|
631
|
-
cacheCreation1hTokens: usage.cacheCreation1hTokens ?? 0
|
|
632
|
-
};
|
|
633
|
-
const rid = record.requestId;
|
|
634
|
-
if (!rid) {
|
|
635
|
-
totals.inputTokens += tokens.inputTokens;
|
|
636
|
-
totals.outputTokens += tokens.outputTokens;
|
|
637
|
-
totals.cacheReadTokens += tokens.cacheReadTokens;
|
|
638
|
-
totals.cacheCreation5mTokens += tokens.cacheCreation5mTokens;
|
|
639
|
-
totals.cacheCreation1hTokens += tokens.cacheCreation1hTokens;
|
|
640
|
-
continue;
|
|
641
|
-
}
|
|
642
|
-
const existing = bestByRequestId.get(rid);
|
|
643
|
-
if (!existing || tokens.outputTokens > existing.outputTokens) bestByRequestId.set(rid, {
|
|
644
|
-
outputTokens: tokens.outputTokens,
|
|
645
|
-
usage: tokens
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
for (const { usage } of bestByRequestId.values()) {
|
|
649
|
-
totals.inputTokens += usage.inputTokens;
|
|
650
|
-
totals.outputTokens += usage.outputTokens;
|
|
651
|
-
totals.cacheReadTokens += usage.cacheReadTokens;
|
|
652
|
-
totals.cacheCreation5mTokens += usage.cacheCreation5mTokens;
|
|
653
|
-
totals.cacheCreation1hTokens += usage.cacheCreation1hTokens;
|
|
654
|
-
}
|
|
655
|
-
return totals;
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* Compute total cost for a subagent from aggregated tokens and model.
|
|
659
|
-
* Uses a single synthetic Turn to calculate cost via the shared pricing logic.
|
|
660
|
-
*/
|
|
661
|
-
function computeSubagentCost(tokens, model) {
|
|
662
|
-
const rate = getPricing(normalizeModelName(model ?? ""));
|
|
663
|
-
return calculateTurnCost({
|
|
664
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
665
|
-
tokenUsage: tokens,
|
|
666
|
-
model,
|
|
667
|
-
messages: [],
|
|
668
|
-
toolCalls: [],
|
|
669
|
-
cacheWriteType: "none",
|
|
670
|
-
cacheReadType: "unknown",
|
|
671
|
-
cacheCreationTokensThisTurn: 0
|
|
672
|
-
}, rate).totalCost;
|
|
673
|
-
}
|
|
674
|
-
/**
|
|
675
|
-
* Normalize content blocks to MessageContent[].
|
|
676
|
-
*/
|
|
677
|
-
function normalizeContent(content) {
|
|
678
|
-
if (typeof content === "string") return [{
|
|
679
|
-
type: "text",
|
|
680
|
-
text: content
|
|
681
|
-
}];
|
|
682
|
-
if (!Array.isArray(content)) return [];
|
|
683
|
-
return content.map((block) => {
|
|
684
|
-
const type = block.type;
|
|
685
|
-
if (type === "text") return {
|
|
686
|
-
type: "text",
|
|
687
|
-
text: String(block.text ?? "")
|
|
688
|
-
};
|
|
689
|
-
if (type === "tool_use") return {
|
|
690
|
-
type: "tool_use",
|
|
691
|
-
name: String(block.name ?? ""),
|
|
692
|
-
input: block.input ?? {},
|
|
693
|
-
toolUseId: String(block.id ?? block.toolUseId ?? "")
|
|
694
|
-
};
|
|
695
|
-
if (type === "tool_result") return {
|
|
696
|
-
type: "tool_result",
|
|
697
|
-
toolUseId: String(block.tool_use_id ?? block.toolUseId ?? ""),
|
|
698
|
-
content: block.content ?? "",
|
|
699
|
-
isError: block.is_error ?? block.isError
|
|
700
|
-
};
|
|
701
|
-
return {
|
|
702
|
-
type: "text",
|
|
703
|
-
text: JSON.stringify(block)
|
|
704
|
-
};
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
//#endregion
|
|
708
|
-
//#region src/db-reader.ts
|
|
709
|
-
/**
|
|
710
|
-
* Compute active duration by summing gaps between consecutive timestamps
|
|
711
|
-
* that are below a threshold (5 minutes). Large gaps represent idle/closed
|
|
712
|
-
* sessions and are excluded.
|
|
713
|
-
*/
|
|
714
|
-
function computeActiveDurationMs(timestamps, thresholdMs = 300 * 1e3) {
|
|
715
|
-
if (timestamps.length < 2) return 0;
|
|
716
|
-
let activeMs = 0;
|
|
717
|
-
for (let i = 1; i < timestamps.length; i++) {
|
|
718
|
-
const gap = new Date(timestamps[i]).getTime() - new Date(timestamps[i - 1]).getTime();
|
|
719
|
-
if (gap > 0 && gap < thresholdMs) activeMs += gap;
|
|
720
|
-
}
|
|
721
|
-
return activeMs;
|
|
722
|
-
}
|
|
723
|
-
/** Error when SQLite DB cannot be opened */
|
|
724
|
-
var DbOpenError = class extends Error {
|
|
725
|
-
code = 3;
|
|
726
|
-
constructor(message) {
|
|
727
|
-
super(message);
|
|
728
|
-
this.name = "DbOpenError";
|
|
729
|
-
}
|
|
730
|
-
};
|
|
731
|
-
/** Error when session_id not found in DB */
|
|
732
|
-
var SessionNotFoundError = class extends Error {
|
|
733
|
-
code = 2;
|
|
734
|
-
constructor(sessionId) {
|
|
735
|
-
super(`Session not found: ${sessionId}`);
|
|
736
|
-
this.name = "SessionNotFoundError";
|
|
737
|
-
}
|
|
738
|
-
};
|
|
739
|
-
/**
|
|
740
|
-
* Get session metadata from SQLite DB
|
|
741
|
-
*/
|
|
742
|
-
function getSession(dbPath, sessionId) {
|
|
743
|
-
let db;
|
|
744
|
-
try {
|
|
745
|
-
db = new Database(dbPath, { readonly: true });
|
|
746
|
-
} catch (_err) {
|
|
747
|
-
throw new DbOpenError(`Failed to open database: ${dbPath}`);
|
|
748
|
-
}
|
|
749
|
-
try {
|
|
750
|
-
const row = db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
751
|
-
if (!row) throw new SessionNotFoundError(sessionId);
|
|
752
|
-
const model = row.model || getModelForSession(dbPath, sessionId);
|
|
753
|
-
const cwdRow = db.prepare("SELECT cwd, COUNT(*) as cnt FROM turns WHERE session_id = ? AND cwd IS NOT NULL GROUP BY cwd ORDER BY cnt DESC LIMIT 1").get(sessionId);
|
|
754
|
-
const totalTokens = {
|
|
755
|
-
inputTokens: row.total_input_tokens,
|
|
756
|
-
outputTokens: row.total_output_tokens,
|
|
757
|
-
cacheReadTokens: row.total_cache_read,
|
|
758
|
-
cacheCreation5mTokens: row.total_cache_creation,
|
|
759
|
-
cacheCreation1hTokens: 0
|
|
760
|
-
};
|
|
761
|
-
return {
|
|
762
|
-
sessionId: row.session_id,
|
|
763
|
-
projectName: row.project_name,
|
|
764
|
-
model,
|
|
765
|
-
workingDirectory: cwdRow?.cwd ?? "",
|
|
766
|
-
turnCount: row.turn_count,
|
|
767
|
-
totalTokens,
|
|
768
|
-
startTime: row.first_timestamp,
|
|
769
|
-
endTime: row.last_timestamp,
|
|
770
|
-
isOngoing: false
|
|
771
|
-
};
|
|
772
|
-
} finally {
|
|
773
|
-
db.close();
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
/**
|
|
777
|
-
* Get all turns for a session from SQLite DB
|
|
778
|
-
*/
|
|
779
|
-
function getTurns(dbPath, sessionId) {
|
|
780
|
-
let db;
|
|
781
|
-
try {
|
|
782
|
-
db = new Database(dbPath, { readonly: true });
|
|
783
|
-
} catch (_err) {
|
|
784
|
-
throw new DbOpenError(`Failed to open database: ${dbPath}`);
|
|
785
|
-
}
|
|
786
|
-
try {
|
|
787
|
-
return db.prepare("SELECT * FROM turns WHERE session_id = ? ORDER BY timestamp ASC").all(sessionId).map((row) => ({
|
|
788
|
-
timestamp: row.timestamp,
|
|
789
|
-
tokenUsage: {
|
|
790
|
-
inputTokens: row.input_tokens,
|
|
791
|
-
outputTokens: row.output_tokens,
|
|
792
|
-
cacheReadTokens: row.cache_read_tokens,
|
|
793
|
-
cacheCreation5mTokens: row.cache_creation_tokens,
|
|
794
|
-
cacheCreation1hTokens: 0
|
|
795
|
-
},
|
|
796
|
-
toolName: row.tool_name ?? void 0,
|
|
797
|
-
cwd: row.cwd ?? void 0,
|
|
798
|
-
messages: [],
|
|
799
|
-
toolCalls: [],
|
|
800
|
-
cacheWriteType: row.cache_creation_tokens > 0 ? "5m" : "none",
|
|
801
|
-
cacheReadType: "unknown",
|
|
802
|
-
cacheCreationTokensThisTurn: row.cache_creation_tokens
|
|
803
|
-
}));
|
|
804
|
-
} finally {
|
|
805
|
-
db.close();
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
/**
|
|
809
|
-
* Get the model for a session (from first turn)
|
|
810
|
-
* Falls back to 'claude-sonnet-4-6' if not found
|
|
811
|
-
*/
|
|
812
|
-
function getModelForSession(dbPath, sessionId) {
|
|
813
|
-
let db;
|
|
814
|
-
try {
|
|
815
|
-
db = new Database(dbPath, { readonly: true });
|
|
816
|
-
} catch (_err) {
|
|
817
|
-
throw new DbOpenError(`Failed to open database: ${dbPath}`);
|
|
818
|
-
}
|
|
819
|
-
try {
|
|
820
|
-
return db.prepare("SELECT model FROM turns WHERE session_id = ? ORDER BY timestamp ASC LIMIT 1").get(sessionId)?.model ?? "claude-sonnet-4-6";
|
|
821
|
-
} finally {
|
|
822
|
-
db.close();
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
/**
|
|
826
|
-
* List all sessions from the DB, ordered by most recent first.
|
|
827
|
-
*/
|
|
828
|
-
function listSessions(dbPath, limit = 20) {
|
|
829
|
-
let db;
|
|
830
|
-
try {
|
|
831
|
-
db = new Database(dbPath, { readonly: true });
|
|
832
|
-
} catch (_err) {
|
|
833
|
-
throw new DbOpenError(`Failed to open database: ${dbPath}`);
|
|
834
|
-
}
|
|
835
|
-
try {
|
|
836
|
-
return db.prepare(`SELECT session_id, project_name, model, turn_count, first_timestamp, last_timestamp,
|
|
837
|
-
total_input_tokens, total_output_tokens, total_cache_read, total_cache_creation
|
|
838
|
-
FROM sessions ORDER BY last_timestamp DESC LIMIT ?`).all(limit).map((row) => {
|
|
839
|
-
const model = row.model || "claude-sonnet-4-6";
|
|
840
|
-
const session = {
|
|
841
|
-
sessionId: row.session_id,
|
|
842
|
-
projectName: row.project_name,
|
|
843
|
-
model,
|
|
844
|
-
workingDirectory: "",
|
|
845
|
-
turnCount: row.turn_count,
|
|
846
|
-
totalTokens: {
|
|
847
|
-
inputTokens: row.total_input_tokens,
|
|
848
|
-
outputTokens: row.total_output_tokens,
|
|
849
|
-
cacheReadTokens: row.total_cache_read,
|
|
850
|
-
cacheCreation5mTokens: row.total_cache_creation,
|
|
851
|
-
cacheCreation1hTokens: 0
|
|
852
|
-
},
|
|
853
|
-
startTime: row.first_timestamp,
|
|
854
|
-
endTime: row.last_timestamp,
|
|
855
|
-
isOngoing: false
|
|
856
|
-
};
|
|
857
|
-
const pricing = calculateSessionCost(session, [{
|
|
858
|
-
timestamp: row.last_timestamp,
|
|
859
|
-
tokenUsage: session.totalTokens,
|
|
860
|
-
messages: [],
|
|
861
|
-
toolCalls: [],
|
|
862
|
-
cacheWriteType: row.total_cache_creation > 0 ? "5m" : "none",
|
|
863
|
-
cacheReadType: "unknown",
|
|
864
|
-
cacheCreationTokensThisTurn: row.total_cache_creation
|
|
865
|
-
}]);
|
|
866
|
-
return {
|
|
867
|
-
sessionId: row.session_id,
|
|
868
|
-
projectName: row.project_name,
|
|
869
|
-
model,
|
|
870
|
-
turnCount: row.turn_count,
|
|
871
|
-
lastTimestamp: row.last_timestamp,
|
|
872
|
-
totalCostEstimate: pricing.totalCost,
|
|
873
|
-
hasThinking: false
|
|
874
|
-
};
|
|
875
|
-
});
|
|
876
|
-
} finally {
|
|
877
|
-
db.close();
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
/**
|
|
881
|
-
* Get the set of session IDs that exist in the SQLite DB.
|
|
882
|
-
*/
|
|
883
|
-
function getExistingSessionIds(dbPath) {
|
|
884
|
-
try {
|
|
885
|
-
const db = new Database(dbPath, { readonly: true });
|
|
886
|
-
try {
|
|
887
|
-
const rows = db.prepare("SELECT session_id FROM sessions").all();
|
|
888
|
-
return new Set(rows.map((r) => r.session_id));
|
|
889
|
-
} finally {
|
|
890
|
-
db.close();
|
|
891
|
-
}
|
|
892
|
-
} catch {
|
|
893
|
-
return /* @__PURE__ */ new Set();
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
/**
|
|
897
|
-
* Parse a JSONL file header to extract session summary metadata.
|
|
898
|
-
* Reads the file incrementally — stops after finding enough data.
|
|
899
|
-
*/
|
|
900
|
-
function parseJsonlSummary(filePath, sessionId, projectName) {
|
|
901
|
-
try {
|
|
902
|
-
const lines = readFileSync(filePath, "utf-8").split("\n").filter((l) => l.trim().length > 0);
|
|
903
|
-
if (lines.length === 0) return null;
|
|
904
|
-
const allRecords = [];
|
|
905
|
-
for (const line of lines) try {
|
|
906
|
-
allRecords.push(JSON.parse(line));
|
|
907
|
-
} catch {
|
|
908
|
-
continue;
|
|
909
|
-
}
|
|
910
|
-
const records = deduplicateByRequestId(allRecords.filter((r) => classifyMessage(r) !== "hardNoise"));
|
|
911
|
-
let model = "claude-sonnet-4-6";
|
|
912
|
-
let turnCount = 0;
|
|
913
|
-
let lastTimestamp = "";
|
|
914
|
-
let totalInput = 0;
|
|
915
|
-
let totalOutput = 0;
|
|
916
|
-
let totalCacheRead = 0;
|
|
917
|
-
let totalCacheCreation5m = 0;
|
|
918
|
-
let totalCacheCreation1h = 0;
|
|
919
|
-
let hasThinking = false;
|
|
920
|
-
let lastFileTimestamp = "";
|
|
921
|
-
const allTimestamps = [];
|
|
922
|
-
for (const record of records) {
|
|
923
|
-
const category = classifyMessage(record);
|
|
924
|
-
const msg = record.message;
|
|
925
|
-
const ts = record.timestamp;
|
|
926
|
-
if (ts) lastFileTimestamp = ts;
|
|
927
|
-
const hasContent = totalInput + totalOutput + totalCacheRead + totalCacheCreation5m + totalCacheCreation1h > 0 || msg?.usage != null || typeof msg?.content === "string" && msg.content.length > 0 || Array.isArray(msg?.content ?? []) && (msg?.content ?? []).length > 0;
|
|
928
|
-
if (ts && hasContent) {
|
|
929
|
-
lastTimestamp = ts;
|
|
930
|
-
allTimestamps.push(ts);
|
|
931
|
-
}
|
|
932
|
-
if (record.type === "assistant" && msg?.model) model = msg.model;
|
|
933
|
-
if (category === "assistant" || category === "user") turnCount++;
|
|
934
|
-
const usage = msg?.usage;
|
|
935
|
-
if (usage) {
|
|
936
|
-
totalInput += usage.input_tokens ?? 0;
|
|
937
|
-
totalOutput += usage.output_tokens ?? 0;
|
|
938
|
-
totalCacheRead += usage.cache_read_input_tokens ?? 0;
|
|
939
|
-
const cc = usage.cache_creation;
|
|
940
|
-
totalCacheCreation5m += usage.cacheCreation5mTokens ?? cc?.ephemeral_5m_input_tokens ?? 0;
|
|
941
|
-
totalCacheCreation1h += usage.cacheCreation1hTokens ?? cc?.ephemeral_1h_input_tokens ?? 0;
|
|
942
|
-
}
|
|
943
|
-
if (!hasThinking && record.type === "assistant" && Array.isArray(msg?.content)) hasThinking = msg.content.some((b) => b.type === "thinking");
|
|
944
|
-
}
|
|
945
|
-
const session = {
|
|
946
|
-
sessionId,
|
|
947
|
-
projectName,
|
|
948
|
-
model,
|
|
949
|
-
workingDirectory: "",
|
|
950
|
-
turnCount,
|
|
951
|
-
totalTokens: {
|
|
952
|
-
inputTokens: totalInput,
|
|
953
|
-
outputTokens: totalOutput,
|
|
954
|
-
cacheReadTokens: totalCacheRead,
|
|
955
|
-
cacheCreation5mTokens: totalCacheCreation5m,
|
|
956
|
-
cacheCreation1hTokens: totalCacheCreation1h
|
|
957
|
-
},
|
|
958
|
-
startTime: "",
|
|
959
|
-
endTime: lastTimestamp || lastFileTimestamp || (/* @__PURE__ */ new Date()).toISOString(),
|
|
960
|
-
isOngoing: false
|
|
961
|
-
};
|
|
962
|
-
const pricing = calculateSessionCost(session, totalInput > 0 || totalOutput > 0 ? [{
|
|
963
|
-
timestamp: lastTimestamp || lastFileTimestamp || (/* @__PURE__ */ new Date()).toISOString(),
|
|
964
|
-
tokenUsage: session.totalTokens,
|
|
965
|
-
messages: [],
|
|
966
|
-
toolCalls: [],
|
|
967
|
-
cacheWriteType: totalCacheCreation5m > 0 ? "5m" : totalCacheCreation1h > 0 ? "1h" : "none",
|
|
968
|
-
cacheReadType: "unknown",
|
|
969
|
-
cacheCreationTokensThisTurn: totalCacheCreation5m + totalCacheCreation1h
|
|
970
|
-
}] : []);
|
|
971
|
-
let agentCost = 0;
|
|
972
|
-
try {
|
|
973
|
-
const agentFiles = listSubagentFiles(join(dirname(filePath), ".."), projectName, sessionId);
|
|
974
|
-
if (agentFiles.length > 0) agentCost = resolveSubagents(agentFiles, []).reduce((sum, s) => sum + (s.totalCost ?? 0), 0);
|
|
975
|
-
} catch {}
|
|
976
|
-
return {
|
|
977
|
-
sessionId,
|
|
978
|
-
projectName,
|
|
979
|
-
model,
|
|
980
|
-
turnCount,
|
|
981
|
-
lastTimestamp: lastTimestamp || lastFileTimestamp || (/* @__PURE__ */ new Date()).toISOString(),
|
|
982
|
-
totalCostEstimate: pricing.totalCost + agentCost,
|
|
983
|
-
hasThinking,
|
|
984
|
-
activeDurationMs: computeActiveDurationMs(allTimestamps)
|
|
985
|
-
};
|
|
986
|
-
} catch {
|
|
987
|
-
return null;
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
/**
|
|
991
|
-
* List sessions discovered from JSONL files on disk.
|
|
992
|
-
* Skips sessions that already exist in the SQLite DB.
|
|
993
|
-
* Skips subagent files (agent-*.jsonl).
|
|
994
|
-
*/
|
|
995
|
-
function listJsonlSessions(projectsDir, dbPath, limit = 100) {
|
|
996
|
-
const existingIds = getExistingSessionIds(dbPath);
|
|
997
|
-
const results = [];
|
|
998
|
-
if (!existsSync(projectsDir)) return results;
|
|
999
|
-
try {
|
|
1000
|
-
const projectDirs = readdirSync(projectsDir);
|
|
1001
|
-
for (const dirName of projectDirs) {
|
|
1002
|
-
const projectDir = join(projectsDir, dirName);
|
|
1003
|
-
try {
|
|
1004
|
-
if (!statSync(projectDir).isDirectory()) continue;
|
|
1005
|
-
} catch {
|
|
1006
|
-
continue;
|
|
1007
|
-
}
|
|
1008
|
-
const projectName = dirName.startsWith("-") ? dirName.slice(1) : dirName;
|
|
1009
|
-
try {
|
|
1010
|
-
const files = readdirSync(projectDir);
|
|
1011
|
-
for (const file of files) {
|
|
1012
|
-
if (!file.endsWith(".jsonl")) continue;
|
|
1013
|
-
if (file.startsWith("agent-")) continue;
|
|
1014
|
-
const sessionId = file.replace(".jsonl", "");
|
|
1015
|
-
if (existingIds.has(sessionId)) continue;
|
|
1016
|
-
const summary = parseJsonlSummary(join(projectDir, file), sessionId, projectName);
|
|
1017
|
-
if (summary) results.push(summary);
|
|
1018
|
-
}
|
|
1019
|
-
} catch {}
|
|
1020
|
-
}
|
|
1021
|
-
} catch {}
|
|
1022
|
-
results.sort((a, b) => b.lastTimestamp.localeCompare(a.lastTimestamp));
|
|
1023
|
-
return results.slice(0, limit);
|
|
1024
|
-
}
|
|
1025
|
-
//#endregion
|
|
1026
|
-
export { deduplicateByRequestId as _, getTurns as a, resolveSubagents as c, formatToolResult as d, linkToolResults as f, resolveSessionJsonlPath as g, getProjectsDir as h, getSession as i, extractToolCalls as l, getDbPath as m, SessionNotFoundError as n, listJsonlSessions as o, listSubagentFiles as p, getModelForSession as r, listSessions as s, DbOpenError as t, extractToolResults as u, classifyMessage as v };
|
|
1027
|
-
|
|
1028
|
-
//# sourceMappingURL=db-reader-BrPRGqww.mjs.map
|