claude-attribution 1.8.0 → 1.9.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.
@@ -9,10 +9,12 @@ import { readFile, readdir, stat } from "fs/promises";
9
9
  import { calculateCostUsd } from "../pricing.ts";
10
10
  import { existsSync } from "fs";
11
11
  import { resolve, join, relative } from "path";
12
- import { homedir } from "os";
13
12
  import { execFile } from "child_process";
14
13
  import { promisify } from "util";
15
- import { parseTranscript, type TranscriptResult } from "./transcript.ts";
14
+ import { resolveCopilotSessionId } from "./copilot-session.ts";
15
+ import { resolveClaudeProjectDir } from "./claude-projects.ts";
16
+ import { parseLocalSession } from "./local-session.ts";
17
+ import { type TranscriptResult } from "./transcript.ts";
16
18
  import {
17
19
  listNotes,
18
20
  readNote,
@@ -127,27 +129,35 @@ async function resolveSessionId(repoRoot: string): Promise<string | null> {
127
129
  }
128
130
 
129
131
  // Strategy 2: most recently modified top-level session JSONL in ~/.claude/projects/<key>/
130
- const projectKey = repoRoot.replace(/\//g, "-");
131
- const transcriptDir = join(homedir(), ".claude", "projects", projectKey);
132
- if (!existsSync(transcriptDir)) return null;
133
- try {
134
- const files = await readdir(transcriptDir);
135
- const sessionFiles = files.filter(
136
- (f) => f.endsWith(".jsonl") && SESSION_ID_RE.test(f.slice(0, -6)),
137
- );
138
- if (sessionFiles.length === 0) return null;
139
- // Pick the most recently modified
140
- const withMtimes = await Promise.all(
141
- sessionFiles.map(async (f) => ({
142
- id: f.slice(0, -6),
143
- mtime: (await stat(join(transcriptDir, f))).mtimeMs,
144
- })),
145
- );
146
- withMtimes.sort((a, b) => b.mtime - a.mtime);
147
- return withMtimes[0]?.id ?? null;
148
- } catch {
149
- return null;
132
+ const transcriptDir = await resolveClaudeProjectDir(repoRoot);
133
+ if (transcriptDir && existsSync(transcriptDir)) {
134
+ try {
135
+ const files = await readdir(transcriptDir);
136
+ const sessionFiles = files.filter(
137
+ (f) => f.endsWith(".jsonl") && SESSION_ID_RE.test(f.slice(0, -6)),
138
+ );
139
+ if (sessionFiles.length > 0) {
140
+ const withMtimes = await Promise.all(
141
+ sessionFiles.map(async (f) => ({
142
+ id: f.slice(0, -6),
143
+ mtime: (await stat(join(transcriptDir, f))).mtimeMs,
144
+ })),
145
+ );
146
+ withMtimes.sort((a, b) => b.mtime - a.mtime);
147
+ return withMtimes[0]?.id ?? null;
148
+ }
149
+ } catch {
150
+ // Fall through to Copilot session-state fallback.
151
+ }
150
152
  }
153
+
154
+ const copilotSessionId = await resolveCopilotSessionId(repoRoot).catch(
155
+ () => null,
156
+ );
157
+ if (copilotSessionId && SESSION_ID_RE.test(copilotSessionId)) {
158
+ return copilotSessionId;
159
+ }
160
+ return null;
151
161
  }
152
162
 
153
163
  async function getBranchAttribution(
@@ -259,6 +269,15 @@ function transcriptFromAttribution(
259
269
  humanMinutes: sessionMetrics?.humanMinutes ?? 0,
260
270
  toolCounts: sessionMetrics?.toolCounts ?? {},
261
271
  skillNames: sessionMetrics?.skillNames ?? [],
272
+ agentCounts: sessionMetrics?.agentCounts ?? {},
273
+ provider:
274
+ result.assistantRuntime?.vendor === "copilot" ? "copilot" : "claude",
275
+ costMode:
276
+ result.assistantRuntime?.vendor === "copilot" ? "unavailable" : "estimated",
277
+ costDescription:
278
+ result.assistantRuntime?.vendor === "copilot"
279
+ ? "Copilot session data does not expose enough local billing data to estimate spend reliably."
280
+ : undefined,
262
281
  };
263
282
  }
264
283
 
@@ -347,7 +366,7 @@ export async function collectMetrics(
347
366
  id,
348
367
  sessionStart ?? undefined,
349
368
  ).catch(() => []),
350
- parseTranscript(id, root).catch(() => null),
369
+ parseLocalSession(id, root).catch(() => null),
351
370
  ]);
352
371
  return {
353
372
  sessionId: id,
@@ -447,6 +466,58 @@ function formatSessionLine(transcript: TranscriptResult): string {
447
466
  return parts.join(" · ");
448
467
  }
449
468
 
469
+ function renderModelUsage(
470
+ out: (line?: string) => void,
471
+ transcript: TranscriptResult,
472
+ ): void {
473
+ if (transcript.provider === "copilot") {
474
+ out("| Model | Calls | Known Tokens |");
475
+ out("|-------|-------|--------------|");
476
+ for (const model of transcript.byModel) {
477
+ out(
478
+ `| ${model.modelFull} | ${model.calls} | ${kFormat(model.outputTokens)} |`,
479
+ );
480
+ }
481
+ out(
482
+ `| **Total** | ${transcript.totals.totalCalls} | ${kFormat(transcript.totals.totalOutputTokens)} |`,
483
+ );
484
+ out();
485
+ out(
486
+ `**Estimated cost:** unavailable — ${transcript.costDescription ?? "Copilot session data does not expose enough local billing data to estimate spend reliably."}`,
487
+ );
488
+ out();
489
+ return;
490
+ }
491
+
492
+ out("| Model | Calls | Input | Output | Cache |");
493
+ out("|-------|-------|-------|--------|-------|");
494
+ for (const model of transcript.byModel) {
495
+ const cache = model.cacheCreationTokens + model.cacheReadTokens;
496
+ out(
497
+ `| ${model.modelShort} | ${model.calls} | ${kFormat(model.inputTokens)} | ${kFormat(model.outputTokens)} | ${kFormat(cache)} |`,
498
+ );
499
+ }
500
+ const { totals } = transcript;
501
+ const totalCache = totals.totalCacheCreationTokens + totals.totalCacheReadTokens;
502
+ out(
503
+ `| **Total** | ${totals.totalCalls} | ${kFormat(totals.totalInputTokens)} | ${kFormat(totals.totalOutputTokens)} | ${kFormat(totalCache)} |`,
504
+ );
505
+ out();
506
+ const cost = calculateCostUsd(
507
+ transcript.byModel.map((model) => ({
508
+ modelFull: model.modelFull,
509
+ modelShort: model.modelShort,
510
+ calls: model.calls,
511
+ inputTokens: model.inputTokens,
512
+ outputTokens: model.outputTokens,
513
+ cacheCreationTokens: model.cacheCreationTokens,
514
+ cacheReadTokens: model.cacheReadTokens,
515
+ })),
516
+ );
517
+ out(`**Estimated cost:** ~$${cost.toFixed(2)}`);
518
+ out();
519
+ }
520
+
450
521
  export function renderMetrics(data: MetricsData): string {
451
522
  const {
452
523
  repoRoot,
@@ -473,7 +544,7 @@ export function renderMetrics(data: MetricsData): string {
473
544
  ).values(),
474
545
  ];
475
546
 
476
- out("## Claude Code Metrics");
547
+ out("## AI Coding Metrics");
477
548
  out();
478
549
 
479
550
  // Headline: AI% (most important stat, shown first)
@@ -495,7 +566,7 @@ export function renderMetrics(data: MetricsData): string {
495
566
  ? Math.round((prTotal / minimapTotals.total) * 100)
496
567
  : 0;
497
568
  out(
498
- `**This PR:** ${prTotal} lines changed (${codebasePct}% of codebase) · ${prPctAi}% Claude edits · ${prAi} AI lines`,
569
+ `**This PR:** ${prTotal} lines changed (${codebasePct}% of codebase) · ${prPctAi}% AI edits · ${prAi} AI lines`,
499
570
  );
500
571
  }
501
572
  out();
@@ -532,39 +603,16 @@ export function renderMetrics(data: MetricsData): string {
532
603
 
533
604
  // Model usage table
534
605
  if (transcript) {
535
- out("| Model | Calls | Input | Output | Cache |");
536
- out("|-------|-------|-------|--------|-------|");
537
- for (const m of transcript.byModel) {
538
- const cache = m.cacheCreationTokens + m.cacheReadTokens;
539
- out(
540
- `| ${m.modelShort} | ${m.calls} | ${kFormat(m.inputTokens)} | ${kFormat(m.outputTokens)} | ${kFormat(cache)} |`,
541
- );
542
- }
543
- const { totals: t } = transcript;
544
- const totalCache = t.totalCacheCreationTokens + t.totalCacheReadTokens;
545
- out(
546
- `| **Total** | ${t.totalCalls} | ${kFormat(t.totalInputTokens)} | ${kFormat(t.totalOutputTokens)} | ${kFormat(totalCache)} |`,
547
- );
548
- out();
549
- const cost = calculateCostUsd(
550
- transcript.byModel.map((m) => ({
551
- modelFull: m.modelFull,
552
- modelShort: m.modelShort,
553
- calls: m.calls,
554
- inputTokens: m.inputTokens,
555
- outputTokens: m.outputTokens,
556
- cacheCreationTokens: m.cacheCreationTokens,
557
- cacheReadTokens: m.cacheReadTokens,
558
- })),
559
- );
560
- out(`**Estimated cost:** ~$${cost.toFixed(2)}`);
561
- out();
606
+ renderModelUsage(out, transcript);
562
607
  }
563
608
 
564
609
  // Multi-session rollup (shown when multiple Claude sessions contributed)
565
610
  if (allTranscripts.length > 1) {
566
611
  out(`### All Sessions on This Branch (${allTranscripts.length} sessions)`);
567
612
  out();
613
+ const hasCopilotSession = allTranscripts.some(
614
+ (transcriptItem) => transcriptItem.provider === "copilot",
615
+ );
568
616
  const agg = allTranscripts.reduce(
569
617
  (acc, t) => ({
570
618
  totalCalls: acc.totalCalls + t.totals.totalCalls,
@@ -590,11 +638,19 @@ export function renderMetrics(data: MetricsData): string {
590
638
  activeMinutes: 0,
591
639
  },
592
640
  );
593
- out("| API Calls | Input Tokens | Output Tokens | Cache Tokens |");
594
- out("|-----------|--------------|---------------|--------------|");
595
- out(
596
- `| ${agg.totalCalls} | ${kFormat(agg.totalInputTokens)} | ${kFormat(agg.totalOutputTokens)} | ${kFormat(agg.totalCacheTokens)} |`,
597
- );
641
+ if (hasCopilotSession) {
642
+ const knownTokens =
643
+ agg.totalInputTokens + agg.totalOutputTokens + agg.totalCacheTokens;
644
+ out("| API Calls | Known Tokens |");
645
+ out("|-----------|--------------|");
646
+ out(`| ${agg.totalCalls} | ${kFormat(knownTokens)} |`);
647
+ } else {
648
+ out("| API Calls | Input Tokens | Output Tokens | Cache Tokens |");
649
+ out("|-----------|--------------|---------------|--------------|");
650
+ out(
651
+ `| ${agg.totalCalls} | ${kFormat(agg.totalInputTokens)} | ${kFormat(agg.totalOutputTokens)} | ${kFormat(agg.totalCacheTokens)} |`,
652
+ );
653
+ }
598
654
  out();
599
655
  const aggSessionLine = [
600
656
  `${agg.humanPromptCount} prompt${agg.humanPromptCount === 1 ? "" : "s"}`,
@@ -0,0 +1,383 @@
1
+ import { existsSync } from "fs";
2
+ import { readdir, readFile, stat } from "fs/promises";
3
+ import { homedir } from "os";
4
+ import { join, resolve } from "path";
5
+ import type { TranscriptResult, ModelUsage } from "./transcript.ts";
6
+
7
+ interface CopilotSessionContext {
8
+ cwd?: string;
9
+ gitRoot?: string;
10
+ branch?: string;
11
+ }
12
+
13
+ interface CopilotEvent {
14
+ type?: string;
15
+ timestamp?: string;
16
+ data?: Record<string, unknown>;
17
+ }
18
+
19
+ interface TimedMessage {
20
+ type: "human" | "assistant";
21
+ ts: number;
22
+ }
23
+
24
+ function modelShort(full: string): ModelUsage["modelShort"] {
25
+ if (/opus/i.test(full)) return "Opus";
26
+ if (/sonnet/i.test(full)) return "Sonnet";
27
+ if (/haiku/i.test(full)) return "Haiku";
28
+ return "Unknown";
29
+ }
30
+
31
+ function copilotHomeDir(): string {
32
+ return process.env["COPILOT_HOME"] ?? join(process.env["HOME"] ?? homedir(), ".copilot");
33
+ }
34
+
35
+ function copilotSessionStateRoot(): string {
36
+ return join(copilotHomeDir(), "session-state");
37
+ }
38
+
39
+ function normalizeSessionPath(pathValue: unknown): string | null {
40
+ return typeof pathValue === "string" && pathValue.trim()
41
+ ? resolve(pathValue)
42
+ : null;
43
+ }
44
+
45
+ function markerWindow(events: CopilotEvent[]): { start: number | null; end: number | null } {
46
+ let start: number | null = null;
47
+ let end: number | null = null;
48
+
49
+ for (const event of events) {
50
+ if (event.type !== "user.message" || !event.timestamp) continue;
51
+ const content = (event.data?.["content"] ?? event.data?.["transformedContent"]) as
52
+ | string
53
+ | undefined;
54
+ if (!content) continue;
55
+ const ts = new Date(event.timestamp).getTime();
56
+ if (!isFinite(ts)) continue;
57
+ const normalized = content.trim().toLowerCase();
58
+ if (/^start work\b/.test(normalized)) {
59
+ start = ts;
60
+ end = null;
61
+ continue;
62
+ }
63
+ if (start !== null && end === null && /^create pr\b/.test(normalized)) {
64
+ end = ts;
65
+ break;
66
+ }
67
+ }
68
+
69
+ return { start, end };
70
+ }
71
+
72
+ function inWindow(
73
+ timestamp: string | undefined,
74
+ window: { start: number | null; end: number | null },
75
+ ): boolean {
76
+ if (!timestamp) return false;
77
+ const ts = new Date(timestamp).getTime();
78
+ if (!isFinite(ts)) return false;
79
+ if (window.start !== null && ts < window.start) return false;
80
+ if (window.end !== null && ts > window.end) return false;
81
+ return true;
82
+ }
83
+
84
+ function computeTimeBreakdown(messages: TimedMessage[]): {
85
+ totalMinutes: number;
86
+ aiMinutes: number;
87
+ humanMinutes: number;
88
+ } {
89
+ const sorted = [...messages].sort((a, b) => a.ts - b.ts);
90
+ const IDLE_MS = 900_000;
91
+
92
+ let aiMs = 0;
93
+ let humanMs = 0;
94
+
95
+ for (let i = 1; i < sorted.length; i++) {
96
+ const prev = sorted[i - 1]!;
97
+ const curr = sorted[i]!;
98
+ const gap = curr.ts - prev.ts;
99
+ if (gap >= IDLE_MS) continue;
100
+ if (prev.type === "human") {
101
+ aiMs += gap;
102
+ } else {
103
+ humanMs += gap;
104
+ }
105
+ }
106
+
107
+ return {
108
+ aiMinutes: Math.round(aiMs / 60_000),
109
+ humanMinutes: Math.round(humanMs / 60_000),
110
+ totalMinutes: Math.round((aiMs + humanMs) / 60_000),
111
+ };
112
+ }
113
+
114
+ async function readEvents(filePath: string): Promise<CopilotEvent[]> {
115
+ const raw = await readFile(filePath, "utf8");
116
+ const events: CopilotEvent[] = [];
117
+ for (const line of raw.split("\n")) {
118
+ const trimmed = line.trim();
119
+ if (!trimmed) continue;
120
+ try {
121
+ events.push(JSON.parse(trimmed) as CopilotEvent);
122
+ } catch {
123
+ // Skip malformed lines.
124
+ }
125
+ }
126
+ return events;
127
+ }
128
+
129
+ function sessionContext(events: CopilotEvent[]): CopilotSessionContext {
130
+ let context: CopilotSessionContext = {};
131
+ for (const event of events) {
132
+ if (event.type === "session.start") {
133
+ const startContext = event.data?.["context"];
134
+ if (typeof startContext === "object" && startContext !== null) {
135
+ const contextData = startContext as Record<string, unknown>;
136
+ context = {
137
+ cwd:
138
+ typeof contextData["cwd"] === "string"
139
+ ? contextData["cwd"]
140
+ : context.cwd,
141
+ gitRoot:
142
+ typeof contextData["gitRoot"] === "string"
143
+ ? contextData["gitRoot"]
144
+ : context.gitRoot,
145
+ branch:
146
+ typeof contextData["branch"] === "string"
147
+ ? contextData["branch"]
148
+ : context.branch,
149
+ };
150
+ }
151
+ }
152
+ if (event.type === "session.context_changed") {
153
+ context = {
154
+ cwd:
155
+ typeof event.data?.["cwd"] === "string"
156
+ ? (event.data["cwd"] as string)
157
+ : context.cwd,
158
+ gitRoot:
159
+ typeof event.data?.["gitRoot"] === "string"
160
+ ? (event.data["gitRoot"] as string)
161
+ : context.gitRoot,
162
+ branch:
163
+ typeof event.data?.["branch"] === "string"
164
+ ? (event.data["branch"] as string)
165
+ : context.branch,
166
+ };
167
+ }
168
+ }
169
+ return context;
170
+ }
171
+
172
+ async function sessionDirs(root = copilotSessionStateRoot()): Promise<string[]> {
173
+ if (!existsSync(root)) return [];
174
+ return (await readdir(root, { withFileTypes: true }))
175
+ .filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "events.jsonl")))
176
+ .map((entry) => join(root, entry.name));
177
+ }
178
+
179
+ export async function resolveCopilotSessionId(
180
+ repoRoot: string,
181
+ branch?: string | null,
182
+ root = copilotSessionStateRoot(),
183
+ ): Promise<string | null> {
184
+ const resolvedRepo = resolve(repoRoot);
185
+ const dirs = await sessionDirs(root);
186
+ const candidates: Array<{ id: string; score: number; mtime: number }> = [];
187
+
188
+ for (const dir of dirs) {
189
+ const events = await readEvents(join(dir, "events.jsonl"));
190
+ const context = sessionContext(events);
191
+ const sessionRepo =
192
+ normalizeSessionPath(context.gitRoot) ?? normalizeSessionPath(context.cwd);
193
+ if (sessionRepo !== resolvedRepo) continue;
194
+ const dirStat = await stat(join(dir, "events.jsonl"));
195
+ let score = 1;
196
+ if (branch && context.branch === branch) score += 2;
197
+ candidates.push({ id: dir.split("/").at(-1) ?? "", score, mtime: dirStat.mtimeMs });
198
+ }
199
+
200
+ candidates.sort((a, b) => b.score - a.score || b.mtime - a.mtime);
201
+ return candidates[0]?.id ?? null;
202
+ }
203
+
204
+ export async function parseCopilotSession(
205
+ sessionId: string,
206
+ repoRoot?: string,
207
+ root = copilotSessionStateRoot(),
208
+ ): Promise<TranscriptResult | null> {
209
+ const eventsPath = join(root, sessionId, "events.jsonl");
210
+ if (!existsSync(eventsPath)) return null;
211
+
212
+ const events = await readEvents(eventsPath);
213
+ const context = sessionContext(events);
214
+ if (repoRoot) {
215
+ const expectedRepo = resolve(repoRoot);
216
+ const actualRepo =
217
+ normalizeSessionPath(context.gitRoot) ?? normalizeSessionPath(context.cwd);
218
+ if (actualRepo && actualRepo !== expectedRepo) return null;
219
+ }
220
+
221
+ const window = markerWindow(events);
222
+ const toolCounts = new Map<string, number>();
223
+ const skillNames = new Set<string>();
224
+ const agentCounts = new Map<string, number>();
225
+ const timedMessages: TimedMessage[] = [];
226
+ let humanPromptCount = 0;
227
+ let mainModel: string | null = null;
228
+ const mainModelVotes = new Map<string, number>();
229
+ let mainAssistantCalls = 0;
230
+ let mainKnownTokens = 0;
231
+ const byModel = new Map<string, ModelUsage>();
232
+
233
+ for (const event of events) {
234
+ if (!inWindow(event.timestamp, window)) continue;
235
+
236
+ if (event.type === "user.message") {
237
+ humanPromptCount++;
238
+ const ts = new Date(event.timestamp ?? "").getTime();
239
+ if (isFinite(ts)) timedMessages.push({ type: "human", ts });
240
+ continue;
241
+ }
242
+
243
+ if (event.type === "assistant.turn_end") {
244
+ const ts = new Date(event.timestamp ?? "").getTime();
245
+ if (isFinite(ts)) timedMessages.push({ type: "assistant", ts });
246
+ continue;
247
+ }
248
+
249
+ if (event.type === "tool.execution_start") {
250
+ const toolName = typeof event.data?.["toolName"] === "string"
251
+ ? (event.data["toolName"] as string)
252
+ : null;
253
+ if (toolName) {
254
+ toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1);
255
+ if (
256
+ toolName === "skill" &&
257
+ typeof (event.data?.["arguments"] as Record<string, unknown> | undefined)?.["skill"] ===
258
+ "string"
259
+ ) {
260
+ skillNames.add(
261
+ ((event.data?.["arguments"] as Record<string, unknown>)["skill"] as string),
262
+ );
263
+ }
264
+ }
265
+ continue;
266
+ }
267
+
268
+ if (event.type === "subagent.started") {
269
+ const agentName =
270
+ (typeof event.data?.["agentName"] === "string"
271
+ ? (event.data["agentName"] as string)
272
+ : null) ??
273
+ (typeof event.data?.["agentDisplayName"] === "string"
274
+ ? (event.data["agentDisplayName"] as string)
275
+ : null);
276
+ if (agentName) {
277
+ agentCounts.set(agentName, (agentCounts.get(agentName) ?? 0) + 1);
278
+ }
279
+ continue;
280
+ }
281
+
282
+ if (event.type === "tool.execution_complete") {
283
+ const model = typeof event.data?.["model"] === "string"
284
+ ? (event.data["model"] as string)
285
+ : null;
286
+ if (model && !event.data?.["parentToolCallId"]) {
287
+ mainModelVotes.set(model, (mainModelVotes.get(model) ?? 0) + 1);
288
+ }
289
+ continue;
290
+ }
291
+
292
+ if (event.type === "assistant.message") {
293
+ const parentToolCallId = event.data?.["parentToolCallId"];
294
+ if (parentToolCallId) continue;
295
+ mainAssistantCalls++;
296
+ const outputTokens =
297
+ typeof event.data?.["outputTokens"] === "number"
298
+ ? (event.data["outputTokens"] as number)
299
+ : 0;
300
+ mainKnownTokens += outputTokens;
301
+ continue;
302
+ }
303
+
304
+ if (event.type === "subagent.completed") {
305
+ const model = typeof event.data?.["model"] === "string"
306
+ ? (event.data["model"] as string)
307
+ : null;
308
+ if (!model) continue;
309
+ const usage = byModel.get(model) ?? {
310
+ modelShort: modelShort(model),
311
+ modelFull: model,
312
+ calls: 0,
313
+ inputTokens: 0,
314
+ outputTokens: 0,
315
+ cacheCreationTokens: 0,
316
+ cacheReadTokens: 0,
317
+ };
318
+ usage.calls += 1;
319
+ if (typeof event.data?.["totalTokens"] === "number") {
320
+ usage.outputTokens += event.data["totalTokens"] as number;
321
+ }
322
+ byModel.set(model, usage);
323
+ }
324
+ }
325
+
326
+ mainModel =
327
+ [...mainModelVotes.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
328
+ if (mainModel) {
329
+ const usage = byModel.get(mainModel) ?? {
330
+ modelShort: modelShort(mainModel),
331
+ modelFull: mainModel,
332
+ calls: 0,
333
+ inputTokens: 0,
334
+ outputTokens: 0,
335
+ cacheCreationTokens: 0,
336
+ cacheReadTokens: 0,
337
+ };
338
+ usage.calls += mainAssistantCalls;
339
+ usage.outputTokens += mainKnownTokens;
340
+ byModel.set(mainModel, usage);
341
+ }
342
+
343
+ const byModelValues = [...byModel.values()].sort((a, b) =>
344
+ a.modelShort.localeCompare(b.modelShort),
345
+ );
346
+ const totals = byModelValues.reduce(
347
+ (acc, model) => ({
348
+ totalCalls: acc.totalCalls + model.calls,
349
+ totalInputTokens: acc.totalInputTokens + model.inputTokens,
350
+ totalOutputTokens: acc.totalOutputTokens + model.outputTokens,
351
+ totalCacheCreationTokens:
352
+ acc.totalCacheCreationTokens + model.cacheCreationTokens,
353
+ totalCacheReadTokens: acc.totalCacheReadTokens + model.cacheReadTokens,
354
+ }),
355
+ {
356
+ totalCalls: 0,
357
+ totalInputTokens: 0,
358
+ totalOutputTokens: 0,
359
+ totalCacheCreationTokens: 0,
360
+ totalCacheReadTokens: 0,
361
+ },
362
+ );
363
+
364
+ const { totalMinutes, aiMinutes, humanMinutes } =
365
+ computeTimeBreakdown(timedMessages);
366
+
367
+ return {
368
+ sessionId,
369
+ byModel: byModelValues,
370
+ totals,
371
+ humanPromptCount,
372
+ activeMinutes: totalMinutes,
373
+ aiMinutes,
374
+ humanMinutes,
375
+ toolCounts: Object.fromEntries(toolCounts),
376
+ skillNames: [...skillNames].sort((a, b) => a.localeCompare(b)),
377
+ agentCounts: Object.fromEntries(agentCounts),
378
+ provider: "copilot",
379
+ costMode: "unavailable",
380
+ costDescription:
381
+ "Copilot session data exposes timing, tool, and model signals, but not enough local billing data to estimate spend reliably.",
382
+ };
383
+ }
@@ -0,0 +1,12 @@
1
+ import { parseCopilotSession } from "./copilot-session.ts";
2
+ import { parseTranscript, type TranscriptResult } from "./transcript.ts";
3
+
4
+ export async function parseLocalSession(
5
+ sessionId: string,
6
+ repoRoot?: string,
7
+ ): Promise<TranscriptResult | null> {
8
+ return (
9
+ (await parseTranscript(sessionId, repoRoot).catch(() => null)) ??
10
+ (await parseCopilotSession(sessionId, repoRoot).catch(() => null))
11
+ );
12
+ }
@@ -53,6 +53,23 @@ export const BORING_TOOLS = new Set([
53
53
  "Skill",
54
54
  // Agent is tracked in agentCounts via agent-activity.jsonl
55
55
  "Agent",
56
+ // Copilot CLI internal / routine tools
57
+ "view",
58
+ "glob",
59
+ "rg",
60
+ "bash",
61
+ "write_bash",
62
+ "read_bash",
63
+ "stop_bash",
64
+ "report_intent",
65
+ "task",
66
+ "read_agent",
67
+ "ask_user",
68
+ "sql",
69
+ "apply_patch",
70
+ "list_bash",
71
+ "list_agents",
72
+ "skill",
56
73
  ]);
57
74
 
58
75
  export async function readJsonlForSession<
@@ -131,6 +148,11 @@ export function buildSessionMetrics(
131
148
  const type = entry.subagentType ?? "unknown";
132
149
  agentCounts.set(type, (agentCounts.get(type) ?? 0) + 1);
133
150
  }
151
+ if (agentCounts.size === 0 && transcript?.agentCounts) {
152
+ for (const [agent, count] of Object.entries(transcript.agentCounts)) {
153
+ agentCounts.set(agent, (agentCounts.get(agent) ?? 0) + count);
154
+ }
155
+ }
134
156
 
135
157
  return {
136
158
  toolCounts: countRecordFromMap(toolCounts),