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.
- package/README.md +67 -37
- package/package.json +2 -2
- package/src/__tests__/claude-projects.test.ts +42 -0
- package/src/__tests__/copilot-session.test.ts +141 -0
- package/src/__tests__/differ.test.ts +14 -2
- package/src/__tests__/git-notes.test.ts +28 -0
- package/src/__tests__/installed-repos.test.ts +2 -1
- package/src/__tests__/integration-helpers.ts +12 -1
- package/src/__tests__/integration.test.ts +140 -1
- package/src/__tests__/runtime.test.ts +12 -1
- package/src/__tests__/transcript.test.ts +61 -0
- package/src/attribution/commit.ts +68 -39
- package/src/attribution/differ.ts +76 -20
- package/src/attribution/git-notes.ts +26 -0
- package/src/attribution/runtime.ts +8 -7
- package/src/metrics/claude-projects.ts +48 -0
- package/src/metrics/collect.ts +113 -57
- package/src/metrics/copilot-session.ts +383 -0
- package/src/metrics/local-session.ts +12 -0
- package/src/metrics/session-metrics.ts +22 -0
- package/src/metrics/transcript.ts +56 -17
- package/src/setup/installed-repos.ts +4 -4
- package/src/setup/uninstall.ts +7 -2
package/src/metrics/collect.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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("##
|
|
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}%
|
|
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
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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),
|