agent-tracer 0.2.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/LICENSE +21 -0
- package/README.md +100 -0
- package/bin/agent-trace-daemon.js +482 -0
- package/lib/db.js +173 -0
- package/lib/parser.js +279 -0
- package/lib/session-store.js +631 -0
- package/package.json +40 -0
- package/public/index.html +2812 -0
package/lib/db.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* SQLite database setup, schema, migrations, and persist helpers.
|
|
4
|
+
* Call createDb(dbPath) once at startup; pass the returned object to createStore().
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const Database = require('better-sqlite3');
|
|
10
|
+
|
|
11
|
+
function createDb(dbPath) {
|
|
12
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
13
|
+
const db = new Database(dbPath);
|
|
14
|
+
|
|
15
|
+
// ── Migrations (safe to re-run on every boot) ──────────────────────────────
|
|
16
|
+
try { db.exec(`ALTER TABLE sessions ADD COLUMN cwd TEXT`); } catch {}
|
|
17
|
+
try { db.exec(`ALTER TABLE sessions ADD COLUMN permission_mode TEXT`); } catch {}
|
|
18
|
+
try { db.exec(`ALTER TABLE sessions ADD COLUMN entrypoint TEXT`); } catch {}
|
|
19
|
+
try { db.exec(`ALTER TABLE sessions ADD COLUMN version TEXT`); } catch {}
|
|
20
|
+
try { db.exec(`ALTER TABLE sessions ADD COLUMN git_branch TEXT`); } catch {}
|
|
21
|
+
try { db.exec(`ALTER TABLE sessions ADD COLUMN package_json TEXT`); } catch {}
|
|
22
|
+
|
|
23
|
+
// ── Schema ────────────────────────────────────────────────────────────────
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
parent_id TEXT,
|
|
28
|
+
label TEXT NOT NULL,
|
|
29
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
30
|
+
started_at INTEGER NOT NULL,
|
|
31
|
+
ended_at INTEGER,
|
|
32
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
33
|
+
tokens_in INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
tokens_out INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
cache_read INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
last_text TEXT NOT NULL DEFAULT '',
|
|
37
|
+
cwd TEXT,
|
|
38
|
+
permission_mode TEXT,
|
|
39
|
+
entrypoint TEXT,
|
|
40
|
+
version TEXT,
|
|
41
|
+
git_branch TEXT,
|
|
42
|
+
package_json TEXT
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS tool_calls (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
48
|
+
name TEXT NOT NULL,
|
|
49
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
50
|
+
input_json TEXT,
|
|
51
|
+
done INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
started_at INTEGER NOT NULL,
|
|
53
|
+
duration_ms INTEGER
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS compactions (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
59
|
+
timestamp INTEGER NOT NULL,
|
|
60
|
+
tokens_before INTEGER,
|
|
61
|
+
tokens_after INTEGER,
|
|
62
|
+
summary TEXT
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_id);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id);
|
|
68
|
+
`);
|
|
69
|
+
|
|
70
|
+
// ── Prepared statements ───────────────────────────────────────────────────
|
|
71
|
+
const stmts = {
|
|
72
|
+
upsertSession: db.prepare(`
|
|
73
|
+
INSERT INTO sessions(id, parent_id, label, status, started_at, ended_at, cost_usd,
|
|
74
|
+
tokens_in, tokens_out, cache_read, last_text, cwd, permission_mode, entrypoint,
|
|
75
|
+
version, git_branch, package_json)
|
|
76
|
+
VALUES(@id, @parent_id, @label, @status, @started_at, @ended_at, @cost_usd,
|
|
77
|
+
@tokens_in, @tokens_out, @cache_read, @last_text, @cwd, @permission_mode, @entrypoint,
|
|
78
|
+
@version, @git_branch, @package_json)
|
|
79
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
80
|
+
label=excluded.label, status=excluded.status, ended_at=excluded.ended_at,
|
|
81
|
+
cost_usd=excluded.cost_usd, tokens_in=excluded.tokens_in, tokens_out=excluded.tokens_out,
|
|
82
|
+
cache_read=excluded.cache_read, last_text=excluded.last_text, cwd=excluded.cwd,
|
|
83
|
+
permission_mode=excluded.permission_mode, entrypoint=excluded.entrypoint,
|
|
84
|
+
version=excluded.version, git_branch=excluded.git_branch,
|
|
85
|
+
package_json=COALESCE(excluded.package_json, package_json)
|
|
86
|
+
`),
|
|
87
|
+
|
|
88
|
+
upsertTool: db.prepare(`
|
|
89
|
+
INSERT INTO tool_calls(id, session_id, name, summary, input_json, done, started_at, duration_ms)
|
|
90
|
+
VALUES(@id, @session_id, @name, @summary, @input_json, @done, @started_at, @duration_ms)
|
|
91
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
92
|
+
done=excluded.done, duration_ms=excluded.duration_ms, summary=excluded.summary
|
|
93
|
+
`),
|
|
94
|
+
|
|
95
|
+
upsertCompaction: db.prepare(`
|
|
96
|
+
INSERT INTO compactions(id, session_id, timestamp, tokens_before, tokens_after, summary)
|
|
97
|
+
VALUES(@id, @session_id, @timestamp, @tokens_before, @tokens_after, @summary)
|
|
98
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
99
|
+
tokens_after=excluded.tokens_after, summary=excluded.summary
|
|
100
|
+
`),
|
|
101
|
+
|
|
102
|
+
listRootSessions: db.prepare(`
|
|
103
|
+
SELECT id, label, status, started_at, ended_at, cost_usd,
|
|
104
|
+
tokens_in, tokens_out, cache_read, cwd, permission_mode
|
|
105
|
+
FROM sessions WHERE parent_id IS NULL
|
|
106
|
+
ORDER BY started_at DESC LIMIT 100
|
|
107
|
+
`),
|
|
108
|
+
|
|
109
|
+
getSession: db.prepare(`
|
|
110
|
+
SELECT id, parent_id, label, status, started_at, ended_at, cost_usd,
|
|
111
|
+
tokens_in, tokens_out, cache_read, last_text, cwd, permission_mode,
|
|
112
|
+
entrypoint, version, git_branch, package_json
|
|
113
|
+
FROM sessions WHERE id = ?
|
|
114
|
+
`),
|
|
115
|
+
|
|
116
|
+
getToolCalls: db.prepare(`SELECT * FROM tool_calls WHERE session_id = ? ORDER BY started_at`),
|
|
117
|
+
getCompactions: db.prepare(`SELECT * FROM compactions WHERE session_id = ? ORDER BY timestamp`),
|
|
118
|
+
getChildren: db.prepare(`SELECT id FROM sessions WHERE parent_id = ? ORDER BY started_at`),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ── Persist helpers ───────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function persistSession(node) {
|
|
124
|
+
if (node.isSidechain) return; // virtual nodes — never persisted
|
|
125
|
+
stmts.upsertSession.run({
|
|
126
|
+
id: node.sessionId,
|
|
127
|
+
parent_id: node.parentSessionId || null,
|
|
128
|
+
label: node.label,
|
|
129
|
+
status: node.status,
|
|
130
|
+
started_at: node.startedAt,
|
|
131
|
+
ended_at: node.endedAt || null,
|
|
132
|
+
cost_usd: node.costUsd,
|
|
133
|
+
tokens_in: node.tokens.input,
|
|
134
|
+
tokens_out: node.tokens.output,
|
|
135
|
+
cache_read: node.tokens.cacheRead,
|
|
136
|
+
last_text: node.lastText || '',
|
|
137
|
+
cwd: node.cwd || null,
|
|
138
|
+
permission_mode: node.permissionMode || null,
|
|
139
|
+
entrypoint: node.entrypoint || null,
|
|
140
|
+
version: node.version || null,
|
|
141
|
+
git_branch: node.gitBranch || null,
|
|
142
|
+
package_json: node.packageJson ? JSON.stringify(node.packageJson) : null,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function persistTool(tc) {
|
|
147
|
+
stmts.upsertTool.run({
|
|
148
|
+
id: tc.id,
|
|
149
|
+
session_id: tc.sessionId,
|
|
150
|
+
name: tc.name,
|
|
151
|
+
summary: tc.summary,
|
|
152
|
+
input_json: tc.input ? JSON.stringify(tc.input) : null,
|
|
153
|
+
done: tc.done ? 1 : 0,
|
|
154
|
+
started_at: tc.startedAt,
|
|
155
|
+
duration_ms: tc.durationMs || null,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function persistCompaction(c, sessionId) {
|
|
160
|
+
stmts.upsertCompaction.run({
|
|
161
|
+
id: c.id,
|
|
162
|
+
session_id: sessionId,
|
|
163
|
+
timestamp: c.timestamp,
|
|
164
|
+
tokens_before: c.tokensBefore || null,
|
|
165
|
+
tokens_after: c.tokensAfter || null,
|
|
166
|
+
summary: c.summary || null,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { db, stmts, persistSession, persistTool, persistCompaction };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { createDb };
|
package/lib/parser.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Pure parsing helpers — no side effects, fully testable, no DB dependency.
|
|
4
|
+
* Used by agent-trace-daemon.js and the test suite.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
12
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
|
|
14
|
+
// ── Security helpers ──────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function isValidSessionId(id) {
|
|
17
|
+
return typeof id === 'string' && UUID_RE.test(id);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Returns a canonicalised project directory for a cwd, or null if unsafe. */
|
|
21
|
+
function safeCwdToProjectDir(cwd) {
|
|
22
|
+
if (!cwd || typeof cwd !== 'string') return null;
|
|
23
|
+
if (cwd.includes('..')) return null;
|
|
24
|
+
if (!path.isAbsolute(cwd)) return null;
|
|
25
|
+
try {
|
|
26
|
+
const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
27
|
+
const encoded = parts.map(p => encodeURIComponent(p).replace(/%2F/g, '-')).join('-');
|
|
28
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', `-${encoded}`);
|
|
29
|
+
return projectDir;
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns true if the given file path is safe to access (within allowed dirs). */
|
|
34
|
+
function safeFilePath(p) {
|
|
35
|
+
if (!p || typeof p !== 'string') return false;
|
|
36
|
+
try {
|
|
37
|
+
const resolved = path.resolve(p);
|
|
38
|
+
const home = os.homedir();
|
|
39
|
+
return resolved.startsWith(path.join(home, '.claude')) ||
|
|
40
|
+
resolved.startsWith(path.join(home, 'Library', 'Application Support', 'Claude'));
|
|
41
|
+
} catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Tool input summarizer ─────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function summarize(name, input) {
|
|
47
|
+
if (!input) return '';
|
|
48
|
+
try {
|
|
49
|
+
switch (name) {
|
|
50
|
+
case 'Read': case 'Write': case 'Edit': return input.file_path || '';
|
|
51
|
+
case 'Glob': return input.pattern || '';
|
|
52
|
+
case 'Grep': return `"${(input.pattern||'').slice(0, 60)}"`;
|
|
53
|
+
case 'Bash': return (input.command||'').slice(0, 80);
|
|
54
|
+
case 'Agent': case 'Task': return input.description || input.prompt || '';
|
|
55
|
+
case 'WebSearch': return `"${(input.query||'').slice(0, 60)}"`;
|
|
56
|
+
case 'WebFetch': return input.url || '';
|
|
57
|
+
default: return JSON.stringify(input).slice(0, 80);
|
|
58
|
+
}
|
|
59
|
+
} catch { return ''; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Transcript file discovery ─────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function findTranscript(sessionId, cwd) {
|
|
65
|
+
if (!isValidSessionId(sessionId)) return null;
|
|
66
|
+
if (cwd) {
|
|
67
|
+
const projectDir = safeCwdToProjectDir(cwd);
|
|
68
|
+
if (projectDir) {
|
|
69
|
+
const p = path.join(projectDir, `${sessionId}.jsonl`);
|
|
70
|
+
if (safeFilePath(p) && fs.existsSync(p)) return p;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
for (const proj of fs.readdirSync(PROJECTS_DIR)) {
|
|
75
|
+
const p = path.join(PROJECTS_DIR, proj, `${sessionId}.jsonl`);
|
|
76
|
+
if (safeFilePath(p) && fs.existsSync(p)) return p;
|
|
77
|
+
}
|
|
78
|
+
} catch {}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Pricing table ─────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const PRICING = {
|
|
85
|
+
// Claude 4.x Sonnet
|
|
86
|
+
'claude-sonnet-4-6': { input: 3.0, output: 15.0, cacheRead: 0.30, cacheWrite: 3.75 },
|
|
87
|
+
'claude-sonnet-4-5': { input: 3.0, output: 15.0, cacheRead: 0.30, cacheWrite: 3.75 },
|
|
88
|
+
'claude-sonnet-4-20250514': { input: 3.0, output: 15.0, cacheRead: 0.30, cacheWrite: 3.75 },
|
|
89
|
+
// Claude 4.x Opus
|
|
90
|
+
'claude-opus-4-6': { input: 5.0, output: 25.0, cacheRead: 0.50, cacheWrite: 6.25 },
|
|
91
|
+
'claude-opus-4-5': { input: 5.0, output: 25.0, cacheRead: 0.50, cacheWrite: 6.25 },
|
|
92
|
+
'claude-opus-4-1': { input: 15.0, output: 75.0, cacheRead: 1.50, cacheWrite: 18.75 },
|
|
93
|
+
'claude-opus-4-20250514': { input: 15.0, output: 75.0, cacheRead: 1.50, cacheWrite: 18.75 },
|
|
94
|
+
'claude-opus-3': { input: 15.0, output: 75.0, cacheRead: 1.50, cacheWrite: 18.75 },
|
|
95
|
+
// Haiku
|
|
96
|
+
'claude-haiku-4-5': { input: 1.0, output: 5.0, cacheRead: 0.10, cacheWrite: 1.25 },
|
|
97
|
+
'claude-haiku-4-5-20251001': { input: 1.0, output: 5.0, cacheRead: 0.10, cacheWrite: 1.25 },
|
|
98
|
+
'claude-haiku-3-5': { input: 0.80, output: 4.0, cacheRead: 0.08, cacheWrite: 1.0 },
|
|
99
|
+
'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.30 },
|
|
100
|
+
};
|
|
101
|
+
const DEFAULT_PRICE = { input: 3.0, output: 15.0, cacheRead: 0.30, cacheWrite: 3.75 };
|
|
102
|
+
|
|
103
|
+
// ── Transcript parsers ────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** Extract session metadata from first 100 lines of transcript. */
|
|
106
|
+
function parseTranscriptMeta(filePath) {
|
|
107
|
+
const meta = { permissionMode: null, entrypoint: null, version: null, gitBranch: null, model: null, label: null };
|
|
108
|
+
try {
|
|
109
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n').slice(0, 100);
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
if (!line.trim()) continue;
|
|
112
|
+
try {
|
|
113
|
+
const obj = JSON.parse(line);
|
|
114
|
+
if (obj.type === 'permission-mode' && obj.permissionMode) meta.permissionMode = obj.permissionMode;
|
|
115
|
+
if (obj.permissionMode && obj.subtype === 'init') meta.permissionMode = obj.permissionMode;
|
|
116
|
+
if (obj.entrypoint) meta.entrypoint = obj.entrypoint;
|
|
117
|
+
if (obj.version) meta.version = obj.version;
|
|
118
|
+
if (obj.gitBranch) meta.gitBranch = obj.gitBranch;
|
|
119
|
+
if (obj.message?.model) meta.model = obj.message.model;
|
|
120
|
+
if (!meta.label && obj.type === 'user' && obj.message?.content) {
|
|
121
|
+
const c = obj.message.content;
|
|
122
|
+
const text = typeof c === 'string' ? c
|
|
123
|
+
: (Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('') : '');
|
|
124
|
+
if (text && !text.startsWith('<') && !text.startsWith('/') && text.length > 5) {
|
|
125
|
+
meta.label = text.replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
return meta;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse compact_boundary events from a transcript.
|
|
136
|
+
* The summary is taken from the user message whose parentUuid matches the boundary uuid.
|
|
137
|
+
*/
|
|
138
|
+
function parseTranscriptCompactions(filePath) {
|
|
139
|
+
const results = [];
|
|
140
|
+
try {
|
|
141
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
142
|
+
const compactMap = new Map();
|
|
143
|
+
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
if (!line.trim()) continue;
|
|
146
|
+
try {
|
|
147
|
+
const obj = JSON.parse(line);
|
|
148
|
+
if (obj.type === 'system' && obj.subtype === 'compact_boundary') {
|
|
149
|
+
const meta = obj.compactMetadata || {};
|
|
150
|
+
const sid = obj.sessionId || '';
|
|
151
|
+
const entry = {
|
|
152
|
+
id: `compact-${sid}-${obj.uuid || Date.now()}`,
|
|
153
|
+
uuid: obj.uuid,
|
|
154
|
+
timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(),
|
|
155
|
+
summary: '',
|
|
156
|
+
tokensBefore: meta.preTokens || null,
|
|
157
|
+
tokensAfter: meta.postTokens || null,
|
|
158
|
+
};
|
|
159
|
+
compactMap.set(obj.uuid, entry);
|
|
160
|
+
results.push(entry);
|
|
161
|
+
}
|
|
162
|
+
if (obj.type === 'user' && obj.parentUuid && compactMap.has(obj.parentUuid)) {
|
|
163
|
+
const entry = compactMap.get(obj.parentUuid);
|
|
164
|
+
const content = obj.message?.content;
|
|
165
|
+
if (typeof content === 'string' && content.length > 10) {
|
|
166
|
+
entry.summary = content;
|
|
167
|
+
} else if (Array.isArray(content)) {
|
|
168
|
+
for (const block of content) {
|
|
169
|
+
if (block.type === 'text' && block.text?.length > 10) { entry.summary = block.text; break; }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
} catch {}
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Sum token usage from a single transcript file. Returns { inputTokens, outputTokens, cacheReadTokens, costUsd }. */
|
|
180
|
+
function parseTranscriptStats(filePath) {
|
|
181
|
+
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
|
|
182
|
+
let model = null;
|
|
183
|
+
try {
|
|
184
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
if (!line.trim()) continue;
|
|
187
|
+
try {
|
|
188
|
+
const obj = JSON.parse(line);
|
|
189
|
+
const msg = obj.message;
|
|
190
|
+
if (!msg) continue;
|
|
191
|
+
if (msg.model && !model) model = msg.model;
|
|
192
|
+
const u = msg.usage;
|
|
193
|
+
if (!u) continue;
|
|
194
|
+
inputTokens += u.input_tokens || 0;
|
|
195
|
+
outputTokens += u.output_tokens || 0;
|
|
196
|
+
cacheReadTokens += u.cache_read_input_tokens || 0;
|
|
197
|
+
cacheWriteTokens += (u.cache_creation?.ephemeral_5m_input_tokens || 0)
|
|
198
|
+
+ (u.cache_creation?.ephemeral_1h_input_tokens || 0)
|
|
199
|
+
+ (u.cache_creation_input_tokens || 0);
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
} catch {}
|
|
203
|
+
const price = (model && PRICING[model]) ? PRICING[model] : DEFAULT_PRICE;
|
|
204
|
+
const costUsd = (inputTokens * price.input / 1e6)
|
|
205
|
+
+ (outputTokens * price.output / 1e6)
|
|
206
|
+
+ (cacheReadTokens * price.cacheRead / 1e6)
|
|
207
|
+
+ (cacheWriteTokens * price.cacheWrite / 1e6);
|
|
208
|
+
return { inputTokens, outputTokens, cacheReadTokens, costUsd: Math.round(costUsd * 1e6) / 1e6 };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Find child session UUIDs referenced in Agent/Task tool results inside a transcript. */
|
|
212
|
+
function findChildSessionIds(filePath, rootSessionId) {
|
|
213
|
+
const childIds = new Set();
|
|
214
|
+
const projectDir = path.dirname(filePath);
|
|
215
|
+
const agentToolUseIds = new Set();
|
|
216
|
+
try {
|
|
217
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
218
|
+
for (const line of lines) {
|
|
219
|
+
if (!line.trim()) continue;
|
|
220
|
+
try {
|
|
221
|
+
const obj = JSON.parse(line);
|
|
222
|
+
for (const block of (obj.message?.content || [])) {
|
|
223
|
+
if (block.type === 'tool_use' && (block.name === 'Agent' || block.name === 'Task')) {
|
|
224
|
+
agentToolUseIds.add(block.id);
|
|
225
|
+
}
|
|
226
|
+
if (block.type === 'tool_result' && agentToolUseIds.has(block.tool_use_id)) {
|
|
227
|
+
const text = typeof block.content === 'string'
|
|
228
|
+
? block.content : JSON.stringify(block.content || '');
|
|
229
|
+
const uuids = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g) || [];
|
|
230
|
+
for (const uuid of uuids) {
|
|
231
|
+
if (uuid === rootSessionId) continue;
|
|
232
|
+
if (fs.existsSync(path.join(projectDir, `${uuid}.jsonl`))) childIds.add(uuid);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
} catch {}
|
|
239
|
+
return [...childIds];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Recursively sum token stats for a session and all subagents referenced from its transcript.
|
|
244
|
+
* Root nodes get the full tree total — use root-only aggregation in the UI to avoid double-counting.
|
|
245
|
+
*/
|
|
246
|
+
function parseFullSessionStats(filePath, rootSessionId, visited = new Set(), depth = 0) {
|
|
247
|
+
if (visited.has(filePath) || depth > 50) return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0 };
|
|
248
|
+
visited.add(filePath);
|
|
249
|
+
|
|
250
|
+
const stats = parseTranscriptStats(filePath);
|
|
251
|
+
const childIds = findChildSessionIds(filePath, rootSessionId);
|
|
252
|
+
|
|
253
|
+
for (const childId of childIds) {
|
|
254
|
+
const childPath = path.join(path.dirname(filePath), `${childId}.jsonl`);
|
|
255
|
+
const childStats = parseFullSessionStats(childPath, childId, visited, depth + 1);
|
|
256
|
+
stats.inputTokens += childStats.inputTokens;
|
|
257
|
+
stats.outputTokens += childStats.outputTokens;
|
|
258
|
+
stats.cacheReadTokens += childStats.cacheReadTokens;
|
|
259
|
+
stats.costUsd += childStats.costUsd;
|
|
260
|
+
}
|
|
261
|
+
stats.costUsd = Math.round(stats.costUsd * 1e6) / 1e6;
|
|
262
|
+
return stats;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
PROJECTS_DIR,
|
|
267
|
+
isValidSessionId,
|
|
268
|
+
safeCwdToProjectDir,
|
|
269
|
+
safeFilePath,
|
|
270
|
+
summarize,
|
|
271
|
+
findTranscript,
|
|
272
|
+
PRICING,
|
|
273
|
+
DEFAULT_PRICE,
|
|
274
|
+
parseTranscriptMeta,
|
|
275
|
+
parseTranscriptCompactions,
|
|
276
|
+
parseTranscriptStats,
|
|
277
|
+
findChildSessionIds,
|
|
278
|
+
parseFullSessionStats,
|
|
279
|
+
};
|