claude-agent-ledger 0.4.1 → 0.7.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/README.md +77 -89
- package/bin/agent-ledger.ts +243 -56
- package/package.json +15 -4
- package/src/aggregator.ts +173 -71
- package/src/explain.ts +209 -0
- package/src/parser.ts +110 -32
- package/src/pricing.ts +45 -20
- package/src/tree.ts +107 -0
- package/src/types.ts +26 -8
- package/.github/ISSUE_TEMPLATE/bug.md +0 -35
- package/.github/ISSUE_TEMPLATE/model-pricing.md +0 -29
- package/.github/pull_request_template.md +0 -19
- package/.github/workflows/ci.yml +0 -19
- package/CHANGELOG.md +0 -105
- package/CONTRIBUTING.md +0 -42
- package/bun.lock +0 -24
- package/test/aggregator.test.ts +0 -75
- package/test/fixtures/projects/proj-a/session-abc/subagents/agent-deadbeef.jsonl +0 -2
- package/test/fixtures/projects/proj-a/session-abc/subagents/agent-deadbeef.meta.json +0 -1
- package/test/fixtures/projects/proj-a/session-abc.jsonl +0 -2
- package/test/fixtures/sample.jsonl +0 -6
- package/test/parser.test.ts +0 -55
- package/test/pricing.test.ts +0 -144
- package/tsconfig.json +0 -20
package/src/parser.ts
CHANGED
|
@@ -5,6 +5,9 @@ import type { SessionTurn, TokenUsage } from './types.ts';
|
|
|
5
5
|
|
|
6
6
|
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
7
7
|
|
|
8
|
+
// Limit concurrent file reads so we don't exhaust fd on huge setups.
|
|
9
|
+
const CONCURRENCY = 8;
|
|
10
|
+
|
|
8
11
|
export interface ParseOptions {
|
|
9
12
|
from?: Date;
|
|
10
13
|
to?: Date;
|
|
@@ -15,16 +18,9 @@ interface LogFile {
|
|
|
15
18
|
path: string;
|
|
16
19
|
projectId: string;
|
|
17
20
|
subagentType: string | undefined;
|
|
21
|
+
parentSessionId?: string | undefined; // for subagent logs, the parent session
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
// Walk the projects directory and yield every JSONL log.
|
|
21
|
-
// Real Claude Code layout (as of 2026-04):
|
|
22
|
-
// ~/.claude/projects/<encoded-project>/<session-uuid>.jsonl (main session)
|
|
23
|
-
// ~/.claude/projects/<encoded-project>/<session-uuid>/subagents/agent-<hash>.jsonl (subagent)
|
|
24
|
-
// ~/.claude/projects/<encoded-project>/<session-uuid>/subagents/agent-<hash>.meta.json
|
|
25
|
-
//
|
|
26
|
-
// The .meta.json next to each subagent .jsonl carries { "agentType": "...", "description": "..." }.
|
|
27
|
-
// We read it to attribute the subagent by its real name (e.g. "Reality Checker") rather than an opaque hash.
|
|
28
24
|
export async function* findSessionLogs(
|
|
29
25
|
projectsDir: string = CLAUDE_PROJECTS_DIR,
|
|
30
26
|
): AsyncGenerator<LogFile> {
|
|
@@ -34,11 +30,22 @@ export async function* findSessionLogs(
|
|
|
34
30
|
} catch {
|
|
35
31
|
return;
|
|
36
32
|
}
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
// Parallelize the project-level walks; collect LogFiles via streaming queue.
|
|
34
|
+
const walkers = projects.map((project) =>
|
|
35
|
+
collectWalk(join(projectsDir, project), project),
|
|
36
|
+
);
|
|
37
|
+
const settled = await Promise.all(walkers);
|
|
38
|
+
for (const list of settled) {
|
|
39
|
+
for (const lf of list) yield lf;
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
async function collectWalk(dir: string, projectId: string): Promise<LogFile[]> {
|
|
44
|
+
const out: LogFile[] = [];
|
|
45
|
+
for await (const lf of walkForJsonl(dir, projectId)) out.push(lf);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<LogFile> {
|
|
43
50
|
let entries: string[] = [];
|
|
44
51
|
try {
|
|
@@ -46,6 +53,9 @@ async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<Log
|
|
|
46
53
|
} catch {
|
|
47
54
|
return;
|
|
48
55
|
}
|
|
56
|
+
// If this directory IS named 'subagents', its parent session is one level up.
|
|
57
|
+
// e.g. .../<session-uuid>/subagents/agent-XXX.jsonl → parent = <session-uuid>
|
|
58
|
+
const parentSession = basename(dir) === 'subagents' ? basename(dirname(dir)) : undefined;
|
|
49
59
|
for (const name of entries) {
|
|
50
60
|
if (name.startsWith('.') || name === 'memory') continue;
|
|
51
61
|
const full = join(dir, name);
|
|
@@ -60,13 +70,18 @@ async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<Log
|
|
|
60
70
|
continue;
|
|
61
71
|
}
|
|
62
72
|
if (name.endsWith('.jsonl')) {
|
|
63
|
-
|
|
73
|
+
const subagentType = await resolveSubagentType(full);
|
|
74
|
+
yield {
|
|
75
|
+
path: full,
|
|
76
|
+
projectId,
|
|
77
|
+
subagentType,
|
|
78
|
+
parentSessionId: parentSession,
|
|
79
|
+
};
|
|
64
80
|
}
|
|
65
81
|
}
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
async function resolveSubagentType(jsonlPath: string): Promise<string | undefined> {
|
|
69
|
-
// Subagent logs live under `.../subagents/agent-<hash>.jsonl` with a sibling `.meta.json`.
|
|
70
85
|
if (basename(dirname(jsonlPath)) !== 'subagents') return undefined;
|
|
71
86
|
const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json');
|
|
72
87
|
try {
|
|
@@ -78,26 +93,51 @@ async function resolveSubagentType(jsonlPath: string): Promise<string | undefine
|
|
|
78
93
|
}
|
|
79
94
|
}
|
|
80
95
|
|
|
81
|
-
//
|
|
96
|
+
// Streaming line reader — does NOT buffer the whole file.
|
|
97
|
+
// Handles partial lines across chunk boundaries. Skips malformed JSON.
|
|
82
98
|
export async function* parseFile(
|
|
83
99
|
path: string,
|
|
84
100
|
subagentType?: string,
|
|
85
101
|
projectId?: string,
|
|
102
|
+
parentSessionId?: string,
|
|
86
103
|
): AsyncGenerator<SessionTurn> {
|
|
87
|
-
const file = Bun.file(path);
|
|
88
|
-
const text = await file.text();
|
|
89
104
|
const sessionId = basename(path).replace('.jsonl', '');
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
const stream = Bun.file(path).stream();
|
|
106
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
107
|
+
let buffer = '';
|
|
108
|
+
for await (const chunk of stream) {
|
|
109
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
110
|
+
let nl: number;
|
|
111
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
112
|
+
const line = buffer.slice(0, nl);
|
|
113
|
+
buffer = buffer.slice(nl + 1);
|
|
114
|
+
if (!line.trim()) continue;
|
|
115
|
+
const turn = tryParseLine(line, sessionId, subagentType, projectId, parentSessionId);
|
|
116
|
+
if (turn) yield turn;
|
|
98
117
|
}
|
|
99
|
-
yield extractTurn(obj, sessionId, subagentType, projectId);
|
|
100
118
|
}
|
|
119
|
+
// Flush any trailing partial line.
|
|
120
|
+
buffer += decoder.decode();
|
|
121
|
+
if (buffer.trim()) {
|
|
122
|
+
const turn = tryParseLine(buffer, sessionId, subagentType, projectId, parentSessionId);
|
|
123
|
+
if (turn) yield turn;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function tryParseLine(
|
|
128
|
+
line: string,
|
|
129
|
+
sessionId: string,
|
|
130
|
+
subagentType: string | undefined,
|
|
131
|
+
projectId: string | undefined,
|
|
132
|
+
parentSessionId: string | undefined,
|
|
133
|
+
): SessionTurn | undefined {
|
|
134
|
+
let obj: Record<string, unknown>;
|
|
135
|
+
try {
|
|
136
|
+
obj = JSON.parse(line);
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return extractTurn(obj, sessionId, subagentType, projectId, parentSessionId);
|
|
101
141
|
}
|
|
102
142
|
|
|
103
143
|
function extractTurn(
|
|
@@ -105,14 +145,29 @@ function extractTurn(
|
|
|
105
145
|
sessionId: string,
|
|
106
146
|
subagentType: string | undefined,
|
|
107
147
|
projectId: string | undefined,
|
|
148
|
+
parentSessionId: string | undefined,
|
|
108
149
|
): SessionTurn {
|
|
109
150
|
const msg = (obj.message ?? {}) as Record<string, unknown>;
|
|
110
151
|
const usage = (msg.usage ?? undefined) as TokenUsage | undefined;
|
|
111
152
|
const model = (msg.model ?? undefined) as string | undefined;
|
|
112
|
-
// If the raw turn carries its own subagentType (our fixture format), prefer it;
|
|
113
|
-
// otherwise fall back to the directory-derived one.
|
|
114
153
|
const perTurnSubagent = ((obj.subagentType as string) ?? undefined) || undefined;
|
|
115
154
|
const perTurnProject = ((obj.projectId as string) ?? undefined) || undefined;
|
|
155
|
+
const perTurnParent = ((obj.parentSessionId as string) ?? undefined) || undefined;
|
|
156
|
+
|
|
157
|
+
// Extract tool_use blocks from the assistant message content (if any).
|
|
158
|
+
// Used by the --by tool aggregator and subagent-graph attribution.
|
|
159
|
+
const toolUses: string[] = [];
|
|
160
|
+
const content = msg.content;
|
|
161
|
+
if (Array.isArray(content)) {
|
|
162
|
+
for (const block of content) {
|
|
163
|
+
if (block && typeof block === 'object') {
|
|
164
|
+
const b = block as Record<string, unknown>;
|
|
165
|
+
if (b.type === 'tool_use' && typeof b.name === 'string') {
|
|
166
|
+
toolUses.push(b.name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
116
171
|
|
|
117
172
|
return {
|
|
118
173
|
type: ((obj.type as string) ?? 'assistant') as SessionTurn['type'],
|
|
@@ -120,20 +175,43 @@ function extractTurn(
|
|
|
120
175
|
sessionId,
|
|
121
176
|
projectId: perTurnProject ?? projectId,
|
|
122
177
|
subagentType: perTurnSubagent ?? subagentType,
|
|
178
|
+
parentSessionId: perTurnParent ?? parentSessionId,
|
|
123
179
|
model,
|
|
124
180
|
usage,
|
|
181
|
+
toolUses: toolUses.length > 0 ? toolUses : undefined,
|
|
125
182
|
raw: obj,
|
|
126
183
|
};
|
|
127
184
|
}
|
|
128
185
|
|
|
129
|
-
//
|
|
186
|
+
// Parallelized, streaming. Does not materialize all turns — caller iterates
|
|
187
|
+
// once and dispatches to aggregators. Reads up to CONCURRENCY files at a time.
|
|
130
188
|
export async function* parseAll(opts: ParseOptions = {}): AsyncGenerator<SessionTurn> {
|
|
131
189
|
const dir = opts.projectsDir ?? CLAUDE_PROJECTS_DIR;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
190
|
+
const files: LogFile[] = [];
|
|
191
|
+
for await (const f of findSessionLogs(dir)) files.push(f);
|
|
192
|
+
|
|
193
|
+
// Fan out file reads in batches of CONCURRENCY, yield turns in arrival order.
|
|
194
|
+
for (let i = 0; i < files.length; i += CONCURRENCY) {
|
|
195
|
+
const batch = files.slice(i, i + CONCURRENCY);
|
|
196
|
+
const streams = batch.map((f) =>
|
|
197
|
+
collectBatch(parseFile(f.path, f.subagentType, f.projectId, f.parentSessionId), opts),
|
|
198
|
+
);
|
|
199
|
+
const results = await Promise.all(streams);
|
|
200
|
+
for (const turns of results) {
|
|
201
|
+
for (const t of turns) yield t;
|
|
137
202
|
}
|
|
138
203
|
}
|
|
139
204
|
}
|
|
205
|
+
|
|
206
|
+
async function collectBatch(
|
|
207
|
+
gen: AsyncGenerator<SessionTurn>,
|
|
208
|
+
opts: ParseOptions,
|
|
209
|
+
): Promise<SessionTurn[]> {
|
|
210
|
+
const out: SessionTurn[] = [];
|
|
211
|
+
for await (const t of gen) {
|
|
212
|
+
if (opts.from && new Date(t.timestamp) < opts.from) continue;
|
|
213
|
+
if (opts.to && new Date(t.timestamp) > opts.to) continue;
|
|
214
|
+
out.push(t);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
package/src/pricing.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CostBreakdown, ModelId, TokenUsage } from './types.ts';
|
|
2
2
|
|
|
3
3
|
// Prices are per 1M tokens, USD. Keep in sync with
|
|
4
|
-
// https://
|
|
4
|
+
// https://platform.claude.com/docs/en/about-claude/pricing as of 2026-07-23.
|
|
5
5
|
//
|
|
6
6
|
// Prompt caching (Anthropic):
|
|
7
7
|
// cache_creation 5-min TTL: 1.25× base input rate
|
|
@@ -10,15 +10,31 @@ import type { CostBreakdown, ModelId, TokenUsage } from './types.ts';
|
|
|
10
10
|
//
|
|
11
11
|
// We include both current and prior-generation model IDs because real logs span
|
|
12
12
|
// version bumps (Claude Code does not auto-rewrite historical logs when you upgrade).
|
|
13
|
+
const SONNET_5_STANDARD_PRICE_START = new Date('2026-09-01T00:00:00Z');
|
|
14
|
+
const SONNET_5_INTRO_RATE = { input: 2, output: 10 };
|
|
15
|
+
const SONNET_5_STANDARD_RATE = { input: 3, output: 15 };
|
|
16
|
+
|
|
13
17
|
const PRICING: Record<string, { input: number; output: number }> = {
|
|
14
|
-
// Current generation (2026-
|
|
15
|
-
'claude-
|
|
18
|
+
// Current generation (2026-07)
|
|
19
|
+
'claude-fable-5': { input: 10, output: 50 },
|
|
20
|
+
'claude-mythos-5': { input: 10, output: 50 },
|
|
21
|
+
'claude-opus-4-8': { input: 5, output: 25 },
|
|
22
|
+
'claude-opus-4-7': { input: 5, output: 25 },
|
|
23
|
+
'claude-opus-4-6': { input: 5, output: 25 },
|
|
24
|
+
'claude-opus-4-5': { input: 5, output: 25 },
|
|
25
|
+
// resolveModel applies the introductory rate through 2026-08-31.
|
|
26
|
+
'claude-sonnet-5': SONNET_5_STANDARD_RATE,
|
|
16
27
|
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
17
|
-
'claude-haiku-4-5-20251001': { input: 0.8, output: 4 },
|
|
18
|
-
// Prior generation (still appears in logs written before model upgrades)
|
|
19
|
-
'claude-opus-4-6': { input: 15, output: 75 },
|
|
20
|
-
'claude-opus-4-5': { input: 15, output: 75 },
|
|
21
28
|
'claude-sonnet-4-5': { input: 3, output: 15 },
|
|
29
|
+
'claude-haiku-4-5-20251001': { input: 1, output: 5 },
|
|
30
|
+
'claude-haiku-4-5': { input: 1, output: 5 },
|
|
31
|
+
// Prior generation (still appears in logs written before model upgrades)
|
|
32
|
+
'claude-opus-4-1-20250805': { input: 15, output: 75 },
|
|
33
|
+
'claude-opus-4-20250514': { input: 15, output: 75 },
|
|
34
|
+
'claude-sonnet-4-20250514': { input: 3, output: 15 },
|
|
35
|
+
'claude-haiku-3-5-20241022': { input: 0.8, output: 4 },
|
|
36
|
+
'claude-3-haiku-20240307': { input: 0.25, output: 1.25 },
|
|
37
|
+
// Kept for the aliases observed in older Claude Code logs.
|
|
22
38
|
'claude-haiku-4-0': { input: 0.8, output: 4 },
|
|
23
39
|
};
|
|
24
40
|
|
|
@@ -30,25 +46,30 @@ const CACHE_5M_MULTIPLIER = 1.25;
|
|
|
30
46
|
const CACHE_1H_MULTIPLIER = 2.0;
|
|
31
47
|
const CACHE_READ_MULTIPLIER = 0.1;
|
|
32
48
|
|
|
33
|
-
// Anthropic server-tool pricing (
|
|
49
|
+
// Anthropic server-tool pricing (official pricing docs as of 2026-07-23):
|
|
34
50
|
// Web search: $10 per 1,000 requests = $0.01 / request
|
|
35
|
-
// Web fetch:
|
|
51
|
+
// Web fetch: no per-request charge; fetched content still incurs token costs
|
|
36
52
|
const WEB_SEARCH_PRICE_PER_REQUEST = 0.01;
|
|
37
|
-
const WEB_FETCH_PRICE_PER_REQUEST = 0
|
|
53
|
+
const WEB_FETCH_PRICE_PER_REQUEST = 0;
|
|
38
54
|
|
|
39
|
-
function resolveModel(model: ModelId | undefined): { input: number; output: number } {
|
|
55
|
+
function resolveModel(model: ModelId | undefined, asOf: Date): { input: number; output: number } {
|
|
40
56
|
if (!model) return { input: 0, output: 0 };
|
|
41
57
|
if (FREE_MODELS.has(model)) return { input: 0, output: 0 };
|
|
58
|
+
if (model === 'claude-sonnet-5' || model.startsWith('claude-sonnet-5-')) {
|
|
59
|
+
return asOf < SONNET_5_STANDARD_PRICE_START
|
|
60
|
+
? SONNET_5_INTRO_RATE
|
|
61
|
+
: SONNET_5_STANDARD_RATE;
|
|
62
|
+
}
|
|
42
63
|
const exact = PRICING[model];
|
|
43
64
|
if (exact) return exact;
|
|
44
|
-
//
|
|
45
|
-
//
|
|
65
|
+
// A provider/date-suffixed ID can inherit a verified base model's rate.
|
|
66
|
+
// Unknown future model families deliberately return zero instead of silently
|
|
67
|
+
// inventing a price; the CLI warns when it sees one.
|
|
46
68
|
for (const key of Object.keys(PRICING)) {
|
|
47
|
-
if (model.startsWith(key))
|
|
69
|
+
if (model.startsWith(`${key}-`)) {
|
|
70
|
+
return PRICING[key] as { input: number; output: number };
|
|
71
|
+
}
|
|
48
72
|
}
|
|
49
|
-
if (model.startsWith('claude-opus-')) return { input: 15, output: 75 };
|
|
50
|
-
if (model.startsWith('claude-sonnet-')) return { input: 3, output: 15 };
|
|
51
|
-
if (model.startsWith('claude-haiku-')) return { input: 0.8, output: 4 };
|
|
52
73
|
return { input: 0, output: 0 };
|
|
53
74
|
}
|
|
54
75
|
|
|
@@ -70,8 +91,12 @@ function splitCacheCreation(usage: TokenUsage): CacheBreakdown {
|
|
|
70
91
|
};
|
|
71
92
|
}
|
|
72
93
|
|
|
73
|
-
export function costFor(
|
|
74
|
-
|
|
94
|
+
export function costFor(
|
|
95
|
+
usage: TokenUsage,
|
|
96
|
+
model: ModelId | undefined,
|
|
97
|
+
asOf: Date = new Date(),
|
|
98
|
+
): CostBreakdown {
|
|
99
|
+
const rate = resolveModel(model, asOf);
|
|
75
100
|
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
76
101
|
const { fiveMin, oneHour } = splitCacheCreation(usage);
|
|
77
102
|
|
|
@@ -110,5 +135,5 @@ export function isKnownModel(model: ModelId | undefined): boolean {
|
|
|
110
135
|
if (!model) return false;
|
|
111
136
|
if (FREE_MODELS.has(model)) return true;
|
|
112
137
|
if (PRICING[model]) return true;
|
|
113
|
-
return Object.keys(PRICING).some((key) => model.startsWith(key));
|
|
138
|
+
return Object.keys(PRICING).some((key) => model.startsWith(`${key}-`));
|
|
114
139
|
}
|
package/src/tree.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// ASCII tree renderer for subagent graph (the MOAT feature).
|
|
2
|
+
// Helicone/Langfuse observe API calls; only Claude Code knows that
|
|
3
|
+
// orchestrator → 3×researcher + 6×swift-dev. We render that tree.
|
|
4
|
+
|
|
5
|
+
import type { SubagentTreeNode } from './types.ts';
|
|
6
|
+
|
|
7
|
+
const fmtUsd = (n: number): string => {
|
|
8
|
+
if (n < 0.01) return '$' + n.toFixed(4);
|
|
9
|
+
return '$' + n.toFixed(2);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const dim = (s: string): string =>
|
|
13
|
+
process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[2m${s}\x1b[0m`;
|
|
14
|
+
|
|
15
|
+
const bold = (s: string): string =>
|
|
16
|
+
process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[1m${s}\x1b[0m`;
|
|
17
|
+
|
|
18
|
+
const amber = (s: string): string =>
|
|
19
|
+
process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[38;5;215m${s}\x1b[0m`;
|
|
20
|
+
|
|
21
|
+
export function formatTree(roots: SubagentTreeNode[], totalCost: number): string {
|
|
22
|
+
if (roots.length === 0) return dim('No subagent graphs found in this period.');
|
|
23
|
+
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
lines.push(
|
|
26
|
+
bold('Subagent graphs') +
|
|
27
|
+
dim(' · top ') +
|
|
28
|
+
roots.length +
|
|
29
|
+
dim(' orchestrators by total cost · grand-total ') +
|
|
30
|
+
amber(fmtUsd(totalCost)),
|
|
31
|
+
);
|
|
32
|
+
lines.push('');
|
|
33
|
+
for (const root of roots) {
|
|
34
|
+
renderNode(root, '', true, lines, totalCost, true);
|
|
35
|
+
lines.push('');
|
|
36
|
+
}
|
|
37
|
+
return lines.join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderNode(
|
|
41
|
+
node: SubagentTreeNode,
|
|
42
|
+
prefix: string,
|
|
43
|
+
isLast: boolean,
|
|
44
|
+
lines: string[],
|
|
45
|
+
grandTotal: number,
|
|
46
|
+
isRoot: boolean,
|
|
47
|
+
): void {
|
|
48
|
+
const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
|
|
49
|
+
const sessionShort = dim(node.sessionId.slice(0, 8));
|
|
50
|
+
const pct = grandTotal > 0 ? ((node.totalCost / grandTotal) * 100).toFixed(1) + '%' : '—';
|
|
51
|
+
const turns = dim(`${node.turnCount} turns`);
|
|
52
|
+
|
|
53
|
+
if (isRoot) {
|
|
54
|
+
const childCount = countDescendants(node);
|
|
55
|
+
const childInfo = childCount > 0 ? dim(` (${childCount} children)`) : '';
|
|
56
|
+
lines.push(
|
|
57
|
+
`${bold(node.label)} ${sessionShort}${childInfo} ${amber(fmtUsd(node.totalCost))} ${dim(`(${pct})`)}`,
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
const selfNote =
|
|
61
|
+
node.children.length > 0
|
|
62
|
+
? dim(` self ${fmtUsd(node.selfCost)}`)
|
|
63
|
+
: '';
|
|
64
|
+
lines.push(
|
|
65
|
+
`${dim(prefix)}${connector}${node.label} ${sessionShort} ${amber(fmtUsd(node.totalCost))}${selfNote} ${turns}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const childPrefix = prefix + (isRoot ? '' : isLast ? ' ' : '│ ');
|
|
70
|
+
node.children.forEach((child, idx) => {
|
|
71
|
+
const last = idx === node.children.length - 1;
|
|
72
|
+
renderNode(child, childPrefix, last, lines, grandTotal, false);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function countDescendants(node: SubagentTreeNode): number {
|
|
77
|
+
let n = node.children.length;
|
|
78
|
+
for (const c of node.children) n += countDescendants(c);
|
|
79
|
+
return n;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Markdown variant — same data, GH-friendly nested list.
|
|
83
|
+
export function formatTreeMarkdown(roots: SubagentTreeNode[], totalCost: number): string {
|
|
84
|
+
const lines: string[] = ['# Subagent graphs', ''];
|
|
85
|
+
lines.push(`Top ${roots.length} orchestrators by total cost. Grand total: **${fmtUsd(totalCost)}**.`);
|
|
86
|
+
lines.push('');
|
|
87
|
+
for (const root of roots) {
|
|
88
|
+
const childCount = countDescendants(root);
|
|
89
|
+
lines.push(
|
|
90
|
+
`## ${root.label} \`${root.sessionId.slice(0, 8)}\` — ${fmtUsd(root.totalCost)} (${childCount} children, ${root.turnCount} turns)`,
|
|
91
|
+
);
|
|
92
|
+
mdNode(root, lines, 0);
|
|
93
|
+
lines.push('');
|
|
94
|
+
}
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function mdNode(node: SubagentTreeNode, lines: string[], depth: number): void {
|
|
99
|
+
for (const child of node.children) {
|
|
100
|
+
const indent = ' '.repeat(depth);
|
|
101
|
+
const self = child.children.length > 0 ? ` _self ${fmtUsd(child.selfCost)}_` : '';
|
|
102
|
+
lines.push(
|
|
103
|
+
`${indent}- **${child.label}** \`${child.sessionId.slice(0, 8)}\` — ${fmtUsd(child.totalCost)} (${child.turnCount} turns)${self}`,
|
|
104
|
+
);
|
|
105
|
+
mdNode(child, lines, depth + 1);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// Known model ids — kept narrow for documentation. Use `string` at the boundary.
|
|
2
|
+
export const KNOWN_MODELS = [
|
|
3
|
+
'claude-opus-4-7',
|
|
4
|
+
'claude-sonnet-4-6',
|
|
5
|
+
'claude-haiku-4-5-20251001',
|
|
6
|
+
'claude-opus-4-6',
|
|
7
|
+
'claude-opus-4-5',
|
|
8
|
+
'claude-sonnet-4-5',
|
|
9
|
+
'claude-haiku-4-0',
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export type ModelId = string;
|
|
6
13
|
|
|
7
14
|
export type TurnType = 'user' | 'assistant' | 'summary' | 'system';
|
|
8
15
|
|
|
@@ -19,12 +26,9 @@ export interface ServerToolUse {
|
|
|
19
26
|
export interface TokenUsage {
|
|
20
27
|
input_tokens: number;
|
|
21
28
|
output_tokens: number;
|
|
22
|
-
// Legacy flat counter. Real logs also carry `cache_creation` with TTL split; if present,
|
|
23
|
-
// prefer that for accurate pricing.
|
|
24
29
|
cache_creation_input_tokens?: number;
|
|
25
30
|
cache_read_input_tokens?: number;
|
|
26
31
|
cache_creation?: CacheCreationDetail;
|
|
27
|
-
// Anthropic server-side tool use (web_search, web_fetch). Priced per request, not per token.
|
|
28
32
|
server_tool_use?: ServerToolUse;
|
|
29
33
|
}
|
|
30
34
|
|
|
@@ -34,8 +38,10 @@ export interface SessionTurn {
|
|
|
34
38
|
sessionId: string;
|
|
35
39
|
projectId?: string | undefined;
|
|
36
40
|
subagentType?: string | undefined;
|
|
41
|
+
parentSessionId?: string | undefined;
|
|
37
42
|
model?: ModelId | undefined;
|
|
38
43
|
usage?: TokenUsage | undefined;
|
|
44
|
+
toolUses?: string[] | undefined; // tool names invoked in this assistant turn
|
|
39
45
|
raw: unknown;
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -67,3 +73,15 @@ export interface LedgerReport {
|
|
|
67
73
|
rows: AggregatedRow[];
|
|
68
74
|
total: AggregatedRow;
|
|
69
75
|
}
|
|
76
|
+
|
|
77
|
+
// Subagent-graph node — the MOAT feature.
|
|
78
|
+
// Represents one parent session's tree-rooted cost attribution:
|
|
79
|
+
// the parent's own cost + all child subagent costs, recursively.
|
|
80
|
+
export interface SubagentTreeNode {
|
|
81
|
+
sessionId: string;
|
|
82
|
+
label: string; // subagentType or '(orchestrator)'
|
|
83
|
+
selfCost: number;
|
|
84
|
+
totalCost: number; // self + children
|
|
85
|
+
children: SubagentTreeNode[];
|
|
86
|
+
turnCount: number;
|
|
87
|
+
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: Bug report
|
|
3
|
-
about: Report incorrect output, crashes, or wrong attribution
|
|
4
|
-
title: ''
|
|
5
|
-
labels: bug
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
## What happened
|
|
9
|
-
|
|
10
|
-
<!-- What did agent-ledger do? What did you expect it to do instead? -->
|
|
11
|
-
|
|
12
|
-
## How to reproduce
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
agent-ledger <your-exact-command>
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
## Output (redact sensitive paths / subagent names)
|
|
19
|
-
|
|
20
|
-
```
|
|
21
|
-
<paste the output here>
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## Your setup
|
|
25
|
-
|
|
26
|
-
- OS: <!-- macOS 15.3 / Ubuntu 22.04 / ... -->
|
|
27
|
-
- Bun version: <!-- `bun --version` -->
|
|
28
|
-
- agent-ledger version: <!-- `agent-ledger --version` (pre-alpha: use commit SHA) -->
|
|
29
|
-
- Claude Code version: <!-- `claude --version` -->
|
|
30
|
-
|
|
31
|
-
## If you're on pay-as-you-go, the calibration ask
|
|
32
|
-
|
|
33
|
-
If you compare `agent-ledger month` against a real Anthropic invoice and
|
|
34
|
-
they differ by > 10%, I want to hear about it. Even one line: "agent-ledger
|
|
35
|
-
says $X, invoice says $Y" is incredibly useful. Thanks.
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: Model pricing gap
|
|
3
|
-
about: A model ID shows $0 in your ledger — we're missing it from the price table
|
|
4
|
-
title: 'pricing: add <model-id>'
|
|
5
|
-
labels: pricing
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
## Model ID
|
|
9
|
-
|
|
10
|
-
<!-- e.g. claude-opus-4-8-20260301 -->
|
|
11
|
-
|
|
12
|
-
## Anthropic's published rates
|
|
13
|
-
|
|
14
|
-
- Input: $<X> / 1M tokens
|
|
15
|
-
- Output: $<Y> / 1M tokens
|
|
16
|
-
- Link to Anthropic pricing page (with date):
|
|
17
|
-
|
|
18
|
-
## Where you saw it
|
|
19
|
-
|
|
20
|
-
Sample from your log (redact the content, keep the model field):
|
|
21
|
-
|
|
22
|
-
```json
|
|
23
|
-
{"message":{"model":"<model-id>", "usage":{"input_tokens":..., "output_tokens":...}}}
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
## Optional: PR
|
|
27
|
-
|
|
28
|
-
If you want to open the PR, the pricing table lives in `src/pricing.ts`. Add a
|
|
29
|
-
matching test in `test/pricing.test.ts`.
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
## What this PR does
|
|
2
|
-
|
|
3
|
-
<!-- One or two sentences. -->
|
|
4
|
-
|
|
5
|
-
## Checklist
|
|
6
|
-
|
|
7
|
-
- [ ] `bun run typecheck` passes
|
|
8
|
-
- [ ] `bun test` passes
|
|
9
|
-
- [ ] If you touched `src/pricing.ts`, there's a matching test in
|
|
10
|
-
`test/pricing.test.ts`
|
|
11
|
-
- [ ] If you touched `src/parser.ts`, the change doesn't break the nested
|
|
12
|
-
`subagents/` walk (see `test/parser.test.ts`)
|
|
13
|
-
- [ ] No runtime dependencies added (this tool intentionally has none)
|
|
14
|
-
|
|
15
|
-
## Log snippet (if relevant)
|
|
16
|
-
|
|
17
|
-
<!-- If the change is about handling some real log shape, include a redacted
|
|
18
|
-
example of what it looks like. Helps me reason about whether other tools
|
|
19
|
-
produce the same shape. -->
|
package/.github/workflows/ci.yml
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
pull_request:
|
|
7
|
-
branches: [main]
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
test:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
steps:
|
|
13
|
-
- uses: actions/checkout@v4
|
|
14
|
-
- uses: oven-sh/setup-bun@v2
|
|
15
|
-
with:
|
|
16
|
-
bun-version: latest
|
|
17
|
-
- run: bun install --frozen-lockfile
|
|
18
|
-
- run: bun run typecheck
|
|
19
|
-
- run: bun test
|