agent-optic 0.3.0 → 0.4.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/examples/annotate-commits.ts +35 -16
- package/examples/branch-report.ts +1 -6
- package/examples/commit-tracker.ts +22 -31
- package/examples/cost-per-feature.ts +3 -8
- package/examples/git-helpers.ts +66 -0
- package/examples/match-git-commits.ts +13 -9
- package/examples/model-costs.ts +8 -12
- package/package.json +2 -1
- package/src/index.ts +4 -1
- package/src/parsers/session-detail.ts +4 -0
- package/src/pricing.ts +66 -4
- package/src/readers/copilot-session-reader.ts +432 -0
- package/src/readers/history-reader.ts +7 -0
- package/src/readers/session-reader.ts +8 -0
- package/src/types/provider.ts +1 -0
- package/src/utils/paths.ts +15 -0
- package/src/utils/providers.ts +36 -0
|
@@ -2,35 +2,40 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* annotate-commits.ts — Write AI cost data as git notes on each commit in .ai-usage.jsonl
|
|
4
4
|
*
|
|
5
|
+
* Uses the refs/notes/ai namespace (compatible with git-ai tooling).
|
|
6
|
+
* Each note has a human-readable summary line followed by a machine-readable JSON section.
|
|
7
|
+
*
|
|
5
8
|
* Usage:
|
|
6
9
|
* bun examples/annotate-commits.ts [path-to-repo] # default: cwd
|
|
7
10
|
* bun examples/annotate-commits.ts --push # also push notes to origin
|
|
8
11
|
*
|
|
9
|
-
* After running, git log --show-notes displays AI cost inline:
|
|
12
|
+
* After running, git log --show-notes=ai displays AI cost inline:
|
|
10
13
|
*
|
|
11
14
|
* commit ad7ac31...
|
|
12
15
|
* Fix messageCount: also exclude toolUseResult carriers
|
|
13
16
|
*
|
|
14
|
-
* Notes:
|
|
17
|
+
* Notes (ai):
|
|
15
18
|
* AI: $2.71 | out: 21K | cache: 5.8M | sessions: 9 | claude-sonnet-4-6
|
|
19
|
+
* ---
|
|
20
|
+
* {"schema":"agent-optic/1.0","cost_usd":2.71,...}
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
import { join } from "node:path";
|
|
19
24
|
import { existsSync } from "node:fs";
|
|
25
|
+
import { fmtTokens } from "./git-helpers.js";
|
|
26
|
+
|
|
27
|
+
const NOTES_REF = "refs/notes/ai";
|
|
20
28
|
|
|
21
29
|
interface UsageRecord {
|
|
22
30
|
commit: string;
|
|
31
|
+
branch?: string;
|
|
23
32
|
tokens: { input: number; output: number; cache_read: number; cache_write: number };
|
|
24
33
|
cost_usd: number;
|
|
25
34
|
models: string[];
|
|
26
35
|
session_ids: string[];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + "B";
|
|
31
|
-
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
32
|
-
if (n >= 1_000) return (n / 1_000).toFixed(0) + "K";
|
|
33
|
-
return String(n);
|
|
36
|
+
messages?: number;
|
|
37
|
+
files_changed?: number;
|
|
38
|
+
ai_tool?: string;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
// ── CLI args ──────────────────────────────────────────────────────────
|
|
@@ -68,15 +73,29 @@ let skipped = 0;
|
|
|
68
73
|
for (const r of records) {
|
|
69
74
|
const model = r.models[0] ?? "unknown";
|
|
70
75
|
|
|
71
|
-
const
|
|
76
|
+
const summary = [
|
|
72
77
|
`AI: $${r.cost_usd.toFixed(2)}`,
|
|
73
|
-
`out: ${
|
|
74
|
-
`cache: ${
|
|
78
|
+
`out: ${fmtTokens(r.tokens.output)}`,
|
|
79
|
+
`cache: ${fmtTokens(r.tokens.cache_read)}`,
|
|
75
80
|
`sessions: ${r.session_ids.length}`,
|
|
76
81
|
model,
|
|
77
82
|
].join(" | ");
|
|
78
83
|
|
|
79
|
-
const
|
|
84
|
+
const meta = JSON.stringify({
|
|
85
|
+
schema: "agent-optic/1.0",
|
|
86
|
+
sessions: r.session_ids,
|
|
87
|
+
tokens: r.tokens,
|
|
88
|
+
cost_usd: r.cost_usd,
|
|
89
|
+
models: r.models,
|
|
90
|
+
...(r.branch ? { branch: r.branch } : {}),
|
|
91
|
+
...(r.messages !== undefined ? { messages: r.messages } : {}),
|
|
92
|
+
...(r.files_changed !== undefined ? { files_changed: r.files_changed } : {}),
|
|
93
|
+
...(r.ai_tool ? { ai_tool: r.ai_tool } : {}),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const note = `${summary}\n---\n${meta}`;
|
|
97
|
+
|
|
98
|
+
const proc = Bun.spawn(["git", "notes", "--ref", NOTES_REF, "add", "-f", "-m", note, r.commit], {
|
|
80
99
|
cwd: repoPath,
|
|
81
100
|
stdout: "pipe",
|
|
82
101
|
stderr: "pipe",
|
|
@@ -96,13 +115,13 @@ for (const r of records) {
|
|
|
96
115
|
}
|
|
97
116
|
|
|
98
117
|
console.log(`${annotated} commits annotated, ${skipped} skipped (not in this repo)`);
|
|
99
|
-
console.log(`\nView with: git log --show-notes`);
|
|
118
|
+
console.log(`\nView with: git log --show-notes=ai`);
|
|
100
119
|
|
|
101
120
|
// ── Push notes ────────────────────────────────────────────────────────
|
|
102
121
|
|
|
103
122
|
if (push) {
|
|
104
123
|
console.log("\nPushing notes to origin...");
|
|
105
|
-
const proc = Bun.spawn(["git", "push", "origin",
|
|
124
|
+
const proc = Bun.spawn(["git", "push", "origin", `${NOTES_REF}:${NOTES_REF}`], {
|
|
106
125
|
cwd: repoPath,
|
|
107
126
|
stdout: "pipe",
|
|
108
127
|
stderr: "pipe",
|
|
@@ -112,7 +131,7 @@ if (push) {
|
|
|
112
131
|
const err = await new Response(proc.stderr).text();
|
|
113
132
|
if (exitCode === 0) {
|
|
114
133
|
console.log("Notes pushed. Others can fetch with:");
|
|
115
|
-
console.log(
|
|
134
|
+
console.log(` git fetch origin ${NOTES_REF}:${NOTES_REF}`);
|
|
116
135
|
} else {
|
|
117
136
|
console.error("Push failed:", err.trim() || out.trim());
|
|
118
137
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { basename, resolve } from "node:path";
|
|
13
|
+
import { fmtTokens } from "./git-helpers.js";
|
|
13
14
|
|
|
14
15
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
15
16
|
|
|
@@ -51,12 +52,6 @@ interface BranchStats {
|
|
|
51
52
|
|
|
52
53
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
53
54
|
|
|
54
|
-
function fmtTokens(n: number): string {
|
|
55
|
-
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
56
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
57
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
|
58
|
-
return String(n);
|
|
59
|
-
}
|
|
60
55
|
|
|
61
56
|
function fmtCost(usd: number): string {
|
|
62
57
|
if (usd >= 100) return `$${usd.toFixed(0)}`;
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* a local assistant session (within a configurable time window).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { createHistory, estimateCost, projectName, type SessionMeta } from "../src/index.js";
|
|
15
|
+
import { createHistory, estimateCost, projectName, detectAgentFromCommit, type SessionMeta } from "../src/index.js";
|
|
16
|
+
import { resolveCommitBranches, findMatchingSessions } from "./git-helpers.js";
|
|
16
17
|
import { resolve, join } from "node:path";
|
|
17
18
|
import { existsSync } from "node:fs";
|
|
18
19
|
import { homedir } from "node:os";
|
|
@@ -115,16 +116,18 @@ interface CommitInfo {
|
|
|
115
116
|
timestamp: number;
|
|
116
117
|
branch: string;
|
|
117
118
|
author: string;
|
|
119
|
+
authorEmail: string;
|
|
118
120
|
message: string;
|
|
119
121
|
filesChanged: number;
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
async function getCommitInfo(): Promise<CommitInfo> {
|
|
123
|
-
const [hash, timestampStr, branch, author, message, statText] = await Promise.all([
|
|
125
|
+
const [hash, timestampStr, branch, author, authorEmail, message, statText] = await Promise.all([
|
|
124
126
|
git("rev-parse", "HEAD"),
|
|
125
127
|
git("log", "-1", "--format=%at"),
|
|
126
128
|
git("rev-parse", "--abbrev-ref", "HEAD"),
|
|
127
129
|
git("log", "-1", "--format=%an"),
|
|
130
|
+
git("log", "-1", "--format=%ae"),
|
|
128
131
|
git("log", "-1", "--format=%s"),
|
|
129
132
|
git("diff", "--stat", "HEAD~1..HEAD").catch(() => git("diff", "--stat", "--root", "HEAD")),
|
|
130
133
|
]);
|
|
@@ -137,6 +140,7 @@ async function getCommitInfo(): Promise<CommitInfo> {
|
|
|
137
140
|
timestamp: parseInt(timestampStr) * 1000,
|
|
138
141
|
branch,
|
|
139
142
|
author,
|
|
143
|
+
authorEmail,
|
|
140
144
|
message,
|
|
141
145
|
filesChanged,
|
|
142
146
|
};
|
|
@@ -148,13 +152,6 @@ function isProjectMatch(session: SessionMeta, repoRoot: string, repoName: string
|
|
|
148
152
|
return sp === rp || sp.startsWith(rp + "/") || session.projectName?.toLowerCase() === repoName.toLowerCase();
|
|
149
153
|
}
|
|
150
154
|
|
|
151
|
-
function findMatchingSessions(commitTimestamp: number, sessions: SessionMeta[]): SessionMeta[] {
|
|
152
|
-
const windowMs = WINDOW_MINUTES * 60 * 1000;
|
|
153
|
-
return sessions.filter((s) => {
|
|
154
|
-
return s.timeRange.start <= commitTimestamp + windowMs && s.timeRange.end >= commitTimestamp - windowMs;
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
155
|
/**
|
|
159
156
|
* Resolve the exact Claude session active at commit time.
|
|
160
157
|
*
|
|
@@ -287,7 +284,8 @@ async function run() {
|
|
|
287
284
|
const costUsd = Math.max(0, fullCost - prior.cost);
|
|
288
285
|
const messages = Math.max(0, fullMessages - prior.messages);
|
|
289
286
|
|
|
290
|
-
const
|
|
287
|
+
const agentTool = detectAgentFromCommit(commit.authorEmail, commit.author);
|
|
288
|
+
const record: Record<string, unknown> = {
|
|
291
289
|
commit: commit.hash,
|
|
292
290
|
timestamp: new Date(commit.timestamp).toISOString(),
|
|
293
291
|
branch: commit.branch,
|
|
@@ -300,6 +298,7 @@ async function run() {
|
|
|
300
298
|
files_changed: commit.filesChanged,
|
|
301
299
|
_session_snapshot: { cost: fullCost, tokens: fullTokens, messages: fullMessages },
|
|
302
300
|
};
|
|
301
|
+
if (agentTool) record.ai_tool = agentTool;
|
|
303
302
|
|
|
304
303
|
await Bun.write(trackingPath, existingContent + JSON.stringify(record) + "\n");
|
|
305
304
|
}
|
|
@@ -308,7 +307,7 @@ async function run() {
|
|
|
308
307
|
|
|
309
308
|
async function getCommitHistory(opts: { from?: string; to?: string } = {}): Promise<CommitInfo[]> {
|
|
310
309
|
const since = opts.from ?? new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);
|
|
311
|
-
const args = ["log", "--all", `--since=${since}`, "--format=%H\t%an\t%aI\t%at\t%s", "--shortstat"];
|
|
310
|
+
const args = ["log", "--all", `--since=${since}`, "--format=%H\t%an\t%ae\t%aI\t%at\t%s", "--shortstat"];
|
|
312
311
|
if (opts.to) args.push(`--until=${opts.to}`);
|
|
313
312
|
|
|
314
313
|
const proc = Bun.spawn(["git", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
@@ -324,9 +323,9 @@ async function getCommitHistory(opts: { from?: string; to?: string } = {}): Prom
|
|
|
324
323
|
if (!line || !line.includes("\t")) continue;
|
|
325
324
|
|
|
326
325
|
const parts = line.split("\t");
|
|
327
|
-
if (parts.length <
|
|
326
|
+
if (parts.length < 6) continue;
|
|
328
327
|
|
|
329
|
-
const [hash, author, , timestamp, message] = parts;
|
|
328
|
+
const [hash, author, authorEmail, , timestamp, message] = parts;
|
|
330
329
|
|
|
331
330
|
let filesChanged = 0;
|
|
332
331
|
// --shortstat puts a blank line between format and stat lines
|
|
@@ -346,6 +345,7 @@ async function getCommitHistory(opts: { from?: string; to?: string } = {}): Prom
|
|
|
346
345
|
timestamp: parseInt(timestamp) * 1000,
|
|
347
346
|
branch: "unknown",
|
|
348
347
|
author,
|
|
348
|
+
authorEmail,
|
|
349
349
|
message,
|
|
350
350
|
filesChanged,
|
|
351
351
|
});
|
|
@@ -353,21 +353,9 @@ async function getCommitHistory(opts: { from?: string; to?: string } = {}): Prom
|
|
|
353
353
|
|
|
354
354
|
// Resolve per-commit branch in one batched git name-rev call (~18ms for 34 commits).
|
|
355
355
|
// git log --format=%D only decorates ~9% of commits (branch tips); name-rev covers 100%.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
await nr.exited;
|
|
360
|
-
const refMap = new Map<string, string>();
|
|
361
|
-
for (const line of nrText.trim().split("\n")) {
|
|
362
|
-
const [h, ref] = line.trim().split(/\s+/);
|
|
363
|
-
if (h && ref) {
|
|
364
|
-
const base = ref.split(/[~^]/)[0]; // strip ~1, ^2 ancestor notation
|
|
365
|
-
refMap.set(h, base.replace(/^remotes\/origin\//, "").replace(/^remotes\//, ""));
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
for (let i = 0; i < commits.length; i++) {
|
|
369
|
-
commits[i].branch = refMap.get(fullHashes[i]) ?? "unknown";
|
|
370
|
-
}
|
|
356
|
+
const refMap = await resolveCommitBranches(fullHashes);
|
|
357
|
+
for (let i = 0; i < commits.length; i++) {
|
|
358
|
+
commits[i].branch = refMap.get(fullHashes[i]) ?? "unknown";
|
|
371
359
|
}
|
|
372
360
|
|
|
373
361
|
return commits;
|
|
@@ -411,7 +399,7 @@ async function init(opts: { from?: string; to?: string } = {}) {
|
|
|
411
399
|
const commitMatches = new Map<string, SessionMeta[]>();
|
|
412
400
|
for (const commit of commits) {
|
|
413
401
|
if (existingHashes.has(commit.hash)) continue;
|
|
414
|
-
const matched = findMatchingSessions(commit.timestamp, projectSessions);
|
|
402
|
+
const matched = findMatchingSessions(commit.timestamp, commit.branch, projectSessions, WINDOW_MINUTES * 60 * 1000);
|
|
415
403
|
if (matched.length === 0) continue;
|
|
416
404
|
commitMatches.set(commit.hash, matched);
|
|
417
405
|
for (const s of matched) {
|
|
@@ -454,7 +442,8 @@ async function init(opts: { from?: string; to?: string } = {}) {
|
|
|
454
442
|
return sum + estimateCost(s) / share;
|
|
455
443
|
}, 0);
|
|
456
444
|
|
|
457
|
-
|
|
445
|
+
const agentTool = detectAgentFromCommit(commit.authorEmail, commit.author);
|
|
446
|
+
const initRecord: Record<string, unknown> = {
|
|
458
447
|
commit: commit.hash,
|
|
459
448
|
timestamp: new Date(commit.timestamp).toISOString(),
|
|
460
449
|
branch: commit.branch,
|
|
@@ -465,7 +454,9 @@ async function init(opts: { from?: string; to?: string } = {}) {
|
|
|
465
454
|
models: [...models],
|
|
466
455
|
messages,
|
|
467
456
|
files_changed: commit.filesChanged,
|
|
468
|
-
}
|
|
457
|
+
};
|
|
458
|
+
if (agentTool) initRecord.ai_tool = agentTool;
|
|
459
|
+
newRecords.push(JSON.stringify(initRecord));
|
|
469
460
|
}
|
|
470
461
|
|
|
471
462
|
// Append all new records at once
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { createHistory, estimateCost, type SessionMeta } from "../src/index.js";
|
|
13
|
+
import { fmtTokens } from "./git-helpers.js";
|
|
13
14
|
|
|
14
15
|
const args = process.argv.slice(2);
|
|
15
16
|
function getArg(name: string): string | undefined {
|
|
@@ -173,7 +174,7 @@ async function main() {
|
|
|
173
174
|
console.log(
|
|
174
175
|
(" " + f.branch.slice(0, 30)).padEnd(35),
|
|
175
176
|
String(f.sessions).padStart(10),
|
|
176
|
-
|
|
177
|
+
fmtTokens(tokens).padStart(12),
|
|
177
178
|
`$${f.cost.toFixed(2)}`.padStart(12),
|
|
178
179
|
String(f.commits).padStart(10),
|
|
179
180
|
);
|
|
@@ -185,7 +186,7 @@ async function main() {
|
|
|
185
186
|
console.log(
|
|
186
187
|
" (no branch)".padEnd(35),
|
|
187
188
|
String(pg.unmatched.length).padStart(10),
|
|
188
|
-
|
|
189
|
+
fmtTokens(tokens).padStart(12),
|
|
189
190
|
`$${unmatchedCost.toFixed(2)}`.padStart(12),
|
|
190
191
|
"-".padStart(10),
|
|
191
192
|
);
|
|
@@ -223,10 +224,4 @@ async function main() {
|
|
|
223
224
|
);
|
|
224
225
|
}
|
|
225
226
|
|
|
226
|
-
function formatTokens(n: number): string {
|
|
227
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
228
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
229
|
-
return String(n);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
227
|
main().catch(console.error);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-helpers.ts — Shared git utilities for agent-optic examples.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SessionMeta } from "../src/index.js";
|
|
6
|
+
|
|
7
|
+
/** Format a token count as a human-readable string (e.g. 21000 → "21K"). */
|
|
8
|
+
export function fmtTokens(n: number): string {
|
|
9
|
+
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
10
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
11
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
|
12
|
+
return String(n);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse git name-rev output into a commit-hash → branch-name map.
|
|
17
|
+
* Resolves branch for every commit hash, covering 100% (vs ~9% from --format=%D).
|
|
18
|
+
* Returns "unknown" for any hash that couldn't be resolved.
|
|
19
|
+
*/
|
|
20
|
+
export async function resolveCommitBranches(
|
|
21
|
+
hashes: string[],
|
|
22
|
+
cwd?: string,
|
|
23
|
+
): Promise<Map<string, string>> {
|
|
24
|
+
const refMap = new Map<string, string>();
|
|
25
|
+
if (hashes.length === 0) return refMap;
|
|
26
|
+
|
|
27
|
+
const nr = Bun.spawn(
|
|
28
|
+
["git", "name-rev", "--always", "--exclude=HEAD", ...hashes],
|
|
29
|
+
{ cwd, stdout: "pipe", stderr: "pipe" },
|
|
30
|
+
);
|
|
31
|
+
const nrText = await new Response(nr.stdout).text();
|
|
32
|
+
await nr.exited;
|
|
33
|
+
|
|
34
|
+
for (const line of nrText.trim().split("\n")) {
|
|
35
|
+
const [h, ref] = line.trim().split(/\s+/);
|
|
36
|
+
if (h && ref) {
|
|
37
|
+
const base = ref.split(/[~^]/)[0];
|
|
38
|
+
refMap.set(h, base.replace(/^remotes\/origin\//, "").replace(/^remotes\//, ""));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return refMap;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Find sessions active around a commit's timestamp.
|
|
46
|
+
* Prefers sessions on the same branch when branch info is available —
|
|
47
|
+
* eliminates false matches when multiple sessions run concurrently on different branches.
|
|
48
|
+
* Falls back to the full time-window set if no branch match is found.
|
|
49
|
+
*/
|
|
50
|
+
export function findMatchingSessions(
|
|
51
|
+
commitTimestamp: number,
|
|
52
|
+
commitBranch: string,
|
|
53
|
+
sessions: SessionMeta[],
|
|
54
|
+
windowMs: number,
|
|
55
|
+
): SessionMeta[] {
|
|
56
|
+
const byTime = sessions.filter(
|
|
57
|
+
(s) => s.timeRange.start <= commitTimestamp + windowMs && s.timeRange.end >= commitTimestamp - windowMs,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (commitBranch && commitBranch !== "unknown") {
|
|
61
|
+
const byBranch = byTime.filter((s) => s.gitBranch === commitBranch);
|
|
62
|
+
if (byBranch.length > 0) return byBranch;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return byTime;
|
|
66
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createHistory, estimateCost, projectName, type SessionMeta } from "../src/index.js";
|
|
14
|
+
import { resolveCommitBranches, findMatchingSessions } from "./git-helpers.js";
|
|
14
15
|
import { resolve } from "node:path";
|
|
15
16
|
|
|
16
17
|
const args = process.argv.slice(2);
|
|
@@ -29,6 +30,7 @@ interface GitCommit {
|
|
|
29
30
|
author: string;
|
|
30
31
|
date: string;
|
|
31
32
|
timestamp: number;
|
|
33
|
+
branch: string;
|
|
32
34
|
message: string;
|
|
33
35
|
filesChanged: number;
|
|
34
36
|
}
|
|
@@ -43,6 +45,7 @@ async function getGitCommits(): Promise<GitCommit[]> {
|
|
|
43
45
|
await proc.exited;
|
|
44
46
|
|
|
45
47
|
const commits: GitCommit[] = [];
|
|
48
|
+
const fullHashes: string[] = [];
|
|
46
49
|
const lines = text.trim().split("\n");
|
|
47
50
|
|
|
48
51
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -66,24 +69,25 @@ async function getGitCommits(): Promise<GitCommit[]> {
|
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
fullHashes.push(hash);
|
|
69
73
|
commits.push({
|
|
70
74
|
hash: hash.slice(0, 8),
|
|
71
75
|
author,
|
|
72
76
|
date,
|
|
73
77
|
timestamp: parseInt(timestamp) * 1000,
|
|
78
|
+
branch: "unknown",
|
|
74
79
|
message: message.slice(0, 60),
|
|
75
80
|
filesChanged,
|
|
76
81
|
});
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
// git name-rev covers 100% of commits (vs ~9% from --format=%D)
|
|
85
|
+
const refMap = await resolveCommitBranches(fullHashes, repoPath);
|
|
86
|
+
for (let i = 0; i < commits.length; i++) {
|
|
87
|
+
commits[i].branch = refMap.get(fullHashes[i]) ?? "unknown";
|
|
88
|
+
}
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
const windowMs = windowMinutes * 60 * 1000;
|
|
84
|
-
return sessions.filter((s) => {
|
|
85
|
-
return s.timeRange.start <= commit.timestamp + windowMs && s.timeRange.end >= commit.timestamp - windowMs;
|
|
86
|
-
});
|
|
90
|
+
return commits;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
async function main() {
|
|
@@ -116,7 +120,7 @@ async function main() {
|
|
|
116
120
|
|
|
117
121
|
const sessionCommitCount = new Map<string, number>();
|
|
118
122
|
for (const commit of commits) {
|
|
119
|
-
for (const s of findMatchingSessions(commit, sessions)) {
|
|
123
|
+
for (const s of findMatchingSessions(commit.timestamp, commit.branch, sessions, windowMinutes * 60 * 1000)) {
|
|
120
124
|
sessionCommitCount.set(s.sessionId, (sessionCommitCount.get(s.sessionId) ?? 0) + 1);
|
|
121
125
|
}
|
|
122
126
|
}
|
|
@@ -138,7 +142,7 @@ async function main() {
|
|
|
138
142
|
let matchedCommits = 0;
|
|
139
143
|
|
|
140
144
|
for (const commit of commits) {
|
|
141
|
-
const matched = findMatchingSessions(commit, sessions);
|
|
145
|
+
const matched = findMatchingSessions(commit.timestamp, commit.branch, sessions, windowMinutes * 60 * 1000);
|
|
142
146
|
|
|
143
147
|
const cost = matched.reduce((sum, s) => {
|
|
144
148
|
const numCommits = sessionCommitCount.get(s.sessionId) ?? 1;
|
package/examples/model-costs.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { createHistory, estimateCost, getModelPricing, type SessionMeta } from "../src/index.js";
|
|
12
|
+
import { fmtTokens } from "./git-helpers.js";
|
|
12
13
|
|
|
13
14
|
const args = process.argv.slice(2);
|
|
14
15
|
function getArg(name: string, fallback: string): string {
|
|
@@ -19,11 +20,6 @@ function getArg(name: string, fallback: string): string {
|
|
|
19
20
|
const from = getArg("--from", new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10));
|
|
20
21
|
const to = getArg("--to", new Date().toISOString().slice(0, 10));
|
|
21
22
|
|
|
22
|
-
function formatTokens(n: number): string {
|
|
23
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
24
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
25
|
-
return String(n);
|
|
26
|
-
}
|
|
27
23
|
|
|
28
24
|
function shortModel(model: string): string {
|
|
29
25
|
if (model.includes("opus")) return model.replace(/claude-/, "").slice(0, 20);
|
|
@@ -92,10 +88,10 @@ async function main() {
|
|
|
92
88
|
console.log(
|
|
93
89
|
shortModel(s.model).padEnd(24),
|
|
94
90
|
String(s.sessions).padStart(10),
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
fmtTokens(s.inputTokens).padStart(10),
|
|
92
|
+
fmtTokens(s.outputTokens).padStart(10),
|
|
93
|
+
fmtTokens(s.cacheWriteTokens).padStart(10),
|
|
94
|
+
fmtTokens(s.cacheReadTokens).padStart(10),
|
|
99
95
|
`$${s.cost.toFixed(2)}`.padStart(12),
|
|
100
96
|
);
|
|
101
97
|
}
|
|
@@ -104,8 +100,8 @@ async function main() {
|
|
|
104
100
|
console.log(
|
|
105
101
|
"TOTAL".padEnd(24),
|
|
106
102
|
String(totalSessions).padStart(10),
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
fmtTokens(totalInput).padStart(10),
|
|
104
|
+
fmtTokens(totalOutput).padStart(10),
|
|
109
105
|
"".padStart(10),
|
|
110
106
|
"".padStart(10),
|
|
111
107
|
`$${totalCost.toFixed(2)}`.padStart(12),
|
|
@@ -123,7 +119,7 @@ async function main() {
|
|
|
123
119
|
const avgCost = s.cost / s.sessions;
|
|
124
120
|
const avgTokens = (s.inputTokens + s.outputTokens) / s.sessions;
|
|
125
121
|
console.log(
|
|
126
|
-
` ${shortModel(s.model).padEnd(24)} $${avgCost.toFixed(3)}/session ${
|
|
122
|
+
` ${shortModel(s.model).padEnd(24)} $${avgCost.toFixed(3)}/session ${fmtTokens(avgTokens)} tokens/session`,
|
|
127
123
|
);
|
|
128
124
|
}
|
|
129
125
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-optic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Zero-dependency, local-first library for reading AI assistant session data from provider home directories",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"codex",
|
|
25
25
|
"cursor",
|
|
26
26
|
"windsurf",
|
|
27
|
+
"copilot",
|
|
27
28
|
"session-history",
|
|
28
29
|
"developer-tools",
|
|
29
30
|
"productivity"
|
package/src/index.ts
CHANGED
|
@@ -38,4 +38,7 @@ export { toLocalDate, today } from "./utils/dates.js";
|
|
|
38
38
|
|
|
39
39
|
// Pricing
|
|
40
40
|
export type { ModelPricing } from "./pricing.js";
|
|
41
|
-
export { MODEL_PRICING, getModelPricing, estimateCost, setPricing } from "./pricing.js";
|
|
41
|
+
export { MODEL_PRICING, getModelPricing, normalizeModelName, estimateCost, setPricing } from "./pricing.js";
|
|
42
|
+
|
|
43
|
+
// Provider utilities
|
|
44
|
+
export { detectAgentFromCommit, AGENT_COMMIT_EMAILS, AGENT_COMMIT_USERNAMES } from "./utils/providers.js";
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
parseCodexToolArguments,
|
|
15
15
|
} from "../readers/codex-rollout-reader.js";
|
|
16
16
|
import { parsePiSessionDetail } from "../readers/pi-session-reader.js";
|
|
17
|
+
import { parseCopilotSessionDetail } from "../readers/copilot-session-reader.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Parse a full session JSONL file into a SessionDetail.
|
|
@@ -29,6 +30,9 @@ export async function parseSessionDetail(
|
|
|
29
30
|
if (normalized === "pi") {
|
|
30
31
|
return parsePiSessionDetail(session, paths.sessionsDir, privacy);
|
|
31
32
|
}
|
|
33
|
+
if (normalized === "copilot") {
|
|
34
|
+
return parseCopilotSessionDetail(session, paths.sessionsDir, privacy);
|
|
35
|
+
}
|
|
32
36
|
if (normalized === "codex") {
|
|
33
37
|
return parseCodexSessionDetail(session, paths.sessionsDir, privacy);
|
|
34
38
|
}
|
package/src/pricing.ts
CHANGED
|
@@ -10,15 +10,20 @@ export interface ModelPricing {
|
|
|
10
10
|
|
|
11
11
|
/** Default model pricing (USD per million tokens). */
|
|
12
12
|
export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
13
|
-
// Opus 4.
|
|
13
|
+
// Opus 4.6 ($5/$25)
|
|
14
14
|
"claude-opus-4-6": { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
|
|
15
|
+
|
|
16
|
+
// Sonnet 4.6 ($3/$15)
|
|
17
|
+
"claude-sonnet-4-6": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
18
|
+
|
|
19
|
+
// Opus 4.5 ($5/$25)
|
|
15
20
|
"claude-opus-4-5-20250514": { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
|
|
16
21
|
|
|
17
22
|
// Opus 4.0–4.1 ($15/$75)
|
|
18
23
|
"claude-opus-4-1-20250514": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
19
24
|
"claude-opus-4-0-20250514": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
20
25
|
|
|
21
|
-
// Sonnet ($3/$15)
|
|
26
|
+
// Sonnet 4.5 ($3/$15)
|
|
22
27
|
"claude-sonnet-4-5-20250929": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
23
28
|
"claude-sonnet-4-5-20250514": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
24
29
|
"claude-sonnet-4-0-20250514": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
@@ -37,16 +42,73 @@ export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
|
37
42
|
const FALLBACK_PRICING: ModelPricing = { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
|
|
38
43
|
|
|
39
44
|
let activePricing: Record<string, ModelPricing> = MODEL_PRICING;
|
|
45
|
+
// Pre-normalized key map; rebuilt when activePricing changes
|
|
46
|
+
let normalizedPricingKeys: Map<string, ModelPricing> | null = null;
|
|
47
|
+
|
|
48
|
+
function getNormalizedPricingKeys(): Map<string, ModelPricing> {
|
|
49
|
+
if (!normalizedPricingKeys) {
|
|
50
|
+
normalizedPricingKeys = new Map(
|
|
51
|
+
Object.entries(activePricing).map(([k, v]) => [normalizeModelName(k), v]),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return normalizedPricingKeys;
|
|
55
|
+
}
|
|
40
56
|
|
|
41
57
|
/** Override or extend the pricing table. Merges with built-in defaults. */
|
|
42
58
|
export function setPricing(overrides: Record<string, ModelPricing>): void {
|
|
43
59
|
activePricing = { ...MODEL_PRICING, ...overrides };
|
|
60
|
+
normalizedPricingKeys = null;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Normalize a model name for pricing lookup.
|
|
65
|
+
* Strips provider prefixes, date suffixes, and qualifiers so that
|
|
66
|
+
* "anthropic/claude-sonnet-4-6-20260101:thinking" resolves to "claude-sonnet-4-6".
|
|
67
|
+
* Normalization approach inspired by agentlytics.
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeModelName(model: string): string {
|
|
70
|
+
let name = model.trim().toLowerCase();
|
|
71
|
+
// Strip provider prefixes: anthropic/, anthropic., aws/, bedrock/, us.anthropic.
|
|
72
|
+
name = name.replace(/^(?:anthropic|aws|bedrock)[./]|^us\.anthropic[./]/, "");
|
|
73
|
+
// Strip qualifier suffixes: :thinking, :preview, :latest, -preview, -latest, -fast, -turbo
|
|
74
|
+
name = name.replace(/(?::(?:thinking|preview|latest)|-(?:preview|latest|fast|turbo))$/, "");
|
|
75
|
+
// Strip 8-digit date suffix (YYYYMMDD)
|
|
76
|
+
name = name.replace(/-\d{8}$/, "");
|
|
77
|
+
// Normalize dots in version numbers to dashes (e.g. "claude-opus-4.6" → "claude-opus-4-6")
|
|
78
|
+
name = name.replace(/(\w)\.(\d)/g, "$1-$2");
|
|
79
|
+
return name;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Look up pricing for a model, falling back to Sonnet rates.
|
|
84
|
+
* Resolution order:
|
|
85
|
+
* 1. Exact match
|
|
86
|
+
* 2. Normalized exact match (strip prefix/date/qualifiers)
|
|
87
|
+
* 3. Longest normalized prefix match (future model variants)
|
|
88
|
+
*/
|
|
47
89
|
export function getModelPricing(model?: string): ModelPricing {
|
|
48
90
|
if (!model) return FALLBACK_PRICING;
|
|
49
|
-
|
|
91
|
+
|
|
92
|
+
// 1. Exact match
|
|
93
|
+
if (activePricing[model]) return activePricing[model];
|
|
94
|
+
|
|
95
|
+
// 2. Normalized exact match
|
|
96
|
+
const norm = normalizeModelName(model);
|
|
97
|
+
const normMap = getNormalizedPricingKeys();
|
|
98
|
+
const exact = normMap.get(norm);
|
|
99
|
+
if (exact) return exact;
|
|
100
|
+
|
|
101
|
+
// 3. Longest normalized prefix match
|
|
102
|
+
// e.g. incoming "claude-sonnet-4-5" matches table key "claude-sonnet-4-5-20250929"
|
|
103
|
+
let best: ModelPricing | undefined;
|
|
104
|
+
let bestLen = 0;
|
|
105
|
+
for (const [normKey, pricing] of normMap) {
|
|
106
|
+
if (norm.startsWith(normKey) && normKey.length > bestLen) {
|
|
107
|
+
best = pricing;
|
|
108
|
+
bestLen = normKey.length;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return best ?? FALLBACK_PRICING;
|
|
50
112
|
}
|
|
51
113
|
|
|
52
114
|
/** Estimate USD cost of a session based on token counts and model. */
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { PrivacyConfig } from "../types/privacy.js";
|
|
3
|
+
import type { SessionDetail, SessionInfo, SessionMeta, ToolCallSummary } from "../types/session.js";
|
|
4
|
+
import type { ContentBlock, TranscriptEntry } from "../types/transcript.js";
|
|
5
|
+
import { projectName } from "../utils/paths.js";
|
|
6
|
+
import { toLocalDate } from "../utils/dates.js";
|
|
7
|
+
import { isProjectExcluded, redactString, filterTranscriptEntry } from "../privacy/redact.js";
|
|
8
|
+
import { categorizeToolName, toolDisplayName } from "../parsers/tool-categories.js";
|
|
9
|
+
|
|
10
|
+
// Copilot CLI session layout:
|
|
11
|
+
// ~/.copilot/session-state/{uuid}/workspace.yaml — always present, metadata
|
|
12
|
+
// ~/.copilot/session-state/{uuid}/events.jsonl — present only when session had interactions
|
|
13
|
+
//
|
|
14
|
+
// workspace.yaml keys: id, cwd, branch, summary, created_at, updated_at, git_root, repository
|
|
15
|
+
|
|
16
|
+
/** Parse simple flat YAML (key: value lines). No library required. */
|
|
17
|
+
function parseSimpleYaml(text: string): Record<string, string> {
|
|
18
|
+
const result: Record<string, string> = {};
|
|
19
|
+
for (const line of text.split("\n")) {
|
|
20
|
+
const colonIdx = line.indexOf(":");
|
|
21
|
+
if (colonIdx === -1) continue;
|
|
22
|
+
const key = line.slice(0, colonIdx).trim();
|
|
23
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
24
|
+
if (key && value) result[key] = value;
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function eventsPath(sessionsDir: string, sessionId: string): string {
|
|
30
|
+
return join(sessionsDir, sessionId, "events.jsonl");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function workspacePath(sessionsDir: string, sessionId: string): string {
|
|
34
|
+
return join(sessionsDir, sessionId, "workspace.yaml");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function accumulateCopilotTokens(
|
|
38
|
+
metrics: unknown,
|
|
39
|
+
target: { totalInputTokens: number; totalOutputTokens: number; cacheReadInputTokens: number; cacheCreationInputTokens: number },
|
|
40
|
+
): void {
|
|
41
|
+
if (!metrics || typeof metrics !== "object") return;
|
|
42
|
+
for (const modelStats of Object.values(metrics) as any[]) {
|
|
43
|
+
const usage = modelStats?.usage;
|
|
44
|
+
if (!usage) continue;
|
|
45
|
+
target.totalInputTokens += Number(usage.inputTokens ?? 0);
|
|
46
|
+
target.totalOutputTokens += Number(usage.outputTokens ?? 0);
|
|
47
|
+
target.cacheReadInputTokens += Number(usage.cacheReadTokens ?? 0);
|
|
48
|
+
target.cacheCreationInputTokens += Number(usage.cacheWriteTokens ?? 0);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readCopilotBranch(session: SessionInfo, sessionsDir: string): Promise<string | undefined> {
|
|
53
|
+
if ((session as SessionMeta).gitBranch) return (session as SessionMeta).gitBranch;
|
|
54
|
+
const wsFile = Bun.file(workspacePath(sessionsDir, session.sessionId));
|
|
55
|
+
if (!(await wsFile.exists())) return undefined;
|
|
56
|
+
try {
|
|
57
|
+
const ws = parseSimpleYaml(await wsFile.text());
|
|
58
|
+
if (ws.branch && ws.branch !== "HEAD") return ws.branch;
|
|
59
|
+
} catch {}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseTs(ts: unknown): number {
|
|
64
|
+
if (typeof ts === "number") return ts;
|
|
65
|
+
if (typeof ts === "string") {
|
|
66
|
+
const n = new Date(ts).getTime();
|
|
67
|
+
return isNaN(n) ? 0 : n;
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read all Copilot CLI sessions by scanning session-state/ (no history.jsonl). */
|
|
73
|
+
export async function readCopilotHistory(
|
|
74
|
+
sessionsDir: string,
|
|
75
|
+
from: string,
|
|
76
|
+
to: string,
|
|
77
|
+
privacy: PrivacyConfig,
|
|
78
|
+
): Promise<SessionInfo[]> {
|
|
79
|
+
const sessions: SessionInfo[] = [];
|
|
80
|
+
const glob = new Bun.Glob("*/workspace.yaml");
|
|
81
|
+
|
|
82
|
+
for await (const relPath of glob.scan({ cwd: sessionsDir, absolute: false })) {
|
|
83
|
+
const sessionId = relPath.split("/")[0];
|
|
84
|
+
const wsFile = Bun.file(join(sessionsDir, relPath));
|
|
85
|
+
if (!(await wsFile.exists())) continue;
|
|
86
|
+
|
|
87
|
+
let ws: Record<string, string>;
|
|
88
|
+
try {
|
|
89
|
+
ws = parseSimpleYaml(await wsFile.text());
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const cwd = ws.cwd;
|
|
95
|
+
const branch = ws.branch;
|
|
96
|
+
const startTime = parseTs(ws.created_at);
|
|
97
|
+
if (!cwd || !startTime) continue;
|
|
98
|
+
|
|
99
|
+
const startDate = toLocalDate(startTime);
|
|
100
|
+
if (startDate < from || startDate > to) continue;
|
|
101
|
+
if (isProjectExcluded(cwd, privacy)) continue;
|
|
102
|
+
|
|
103
|
+
// Try to get first user prompt from events.jsonl (best-effort, skip if absent)
|
|
104
|
+
let firstPrompt: string | undefined;
|
|
105
|
+
let endTime = startTime;
|
|
106
|
+
const evFile = Bun.file(eventsPath(sessionsDir, sessionId));
|
|
107
|
+
if (await evFile.exists()) {
|
|
108
|
+
try {
|
|
109
|
+
const text = await evFile.text();
|
|
110
|
+
for (const line of text.split("\n")) {
|
|
111
|
+
if (!line.trim()) continue;
|
|
112
|
+
let entry: any;
|
|
113
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
114
|
+
|
|
115
|
+
const ts = parseTs(entry.timestamp);
|
|
116
|
+
if (ts > endTime) endTime = ts;
|
|
117
|
+
|
|
118
|
+
if (entry.type === "user.message" && !firstPrompt) {
|
|
119
|
+
const content = entry.data?.content;
|
|
120
|
+
if (typeof content === "string" && content.trim()) firstPrompt = content;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// events.jsonl unreadable — use workspace summary as fallback
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Fall back to workspace summary for sessions without events
|
|
129
|
+
if (!firstPrompt && ws.summary) firstPrompt = ws.summary;
|
|
130
|
+
|
|
131
|
+
const prompt = firstPrompt
|
|
132
|
+
? privacy.redactPrompts
|
|
133
|
+
? "[redacted]"
|
|
134
|
+
: privacy.redactPatterns.length > 0
|
|
135
|
+
? redactString(firstPrompt, privacy)
|
|
136
|
+
: firstPrompt
|
|
137
|
+
: "(no prompt)";
|
|
138
|
+
|
|
139
|
+
const session: SessionInfo = {
|
|
140
|
+
sessionId,
|
|
141
|
+
project: cwd,
|
|
142
|
+
projectName: projectName(cwd),
|
|
143
|
+
prompts: [prompt],
|
|
144
|
+
promptTimestamps: [startTime],
|
|
145
|
+
timeRange: { start: startTime, end: endTime },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (branch && branch !== "HEAD") {
|
|
149
|
+
(session as SessionMeta).gitBranch = branch;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
sessions.push(session);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
sessions.sort((a, b) => a.timeRange.start - b.timeRange.start);
|
|
156
|
+
return sessions;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Peek Copilot session metadata (model, tokens, branch). */
|
|
160
|
+
export async function peekCopilotSession(
|
|
161
|
+
session: SessionInfo,
|
|
162
|
+
sessionsDir: string,
|
|
163
|
+
): Promise<SessionMeta> {
|
|
164
|
+
const meta: SessionMeta = {
|
|
165
|
+
...session,
|
|
166
|
+
totalInputTokens: 0,
|
|
167
|
+
totalOutputTokens: 0,
|
|
168
|
+
cacheCreationInputTokens: 0,
|
|
169
|
+
cacheReadInputTokens: 0,
|
|
170
|
+
messageCount: 0,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
meta.gitBranch = await readCopilotBranch(session, sessionsDir);
|
|
174
|
+
|
|
175
|
+
const file = Bun.file(eventsPath(sessionsDir, session.sessionId));
|
|
176
|
+
if (!(await file.exists())) return meta;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const text = await file.text();
|
|
180
|
+
for (const line of text.split("\n")) {
|
|
181
|
+
if (!line.trim()) continue;
|
|
182
|
+
let entry: any;
|
|
183
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
184
|
+
|
|
185
|
+
if (entry.type === "session.model_change" && !meta.model) {
|
|
186
|
+
const model = entry.data?.newModel;
|
|
187
|
+
if (typeof model === "string") meta.model = model;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (entry.type === "user.message") meta.messageCount++;
|
|
191
|
+
if (entry.type === "assistant.message") meta.messageCount++;
|
|
192
|
+
|
|
193
|
+
// session.shutdown carries accurate cumulative token totals per model
|
|
194
|
+
if (entry.type === "session.shutdown") {
|
|
195
|
+
accumulateCopilotTokens(entry.data?.modelMetrics, meta);
|
|
196
|
+
if (!meta.model) {
|
|
197
|
+
const current = entry.data?.currentModel;
|
|
198
|
+
if (typeof current === "string") meta.model = current;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// file unreadable
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return meta;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Parse full Copilot session detail. */
|
|
210
|
+
export async function parseCopilotSessionDetail(
|
|
211
|
+
session: SessionInfo,
|
|
212
|
+
sessionsDir: string,
|
|
213
|
+
privacy: PrivacyConfig,
|
|
214
|
+
): Promise<SessionDetail> {
|
|
215
|
+
const detail: SessionDetail = {
|
|
216
|
+
...session,
|
|
217
|
+
totalInputTokens: 0,
|
|
218
|
+
totalOutputTokens: 0,
|
|
219
|
+
cacheCreationInputTokens: 0,
|
|
220
|
+
cacheReadInputTokens: 0,
|
|
221
|
+
messageCount: 0,
|
|
222
|
+
assistantSummaries: [],
|
|
223
|
+
toolCalls: [],
|
|
224
|
+
filesReferenced: [],
|
|
225
|
+
planReferenced: false,
|
|
226
|
+
thinkingBlockCount: 0,
|
|
227
|
+
hasSidechains: false,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
detail.gitBranch = await readCopilotBranch(session, sessionsDir);
|
|
231
|
+
|
|
232
|
+
const file = Bun.file(eventsPath(sessionsDir, session.sessionId));
|
|
233
|
+
if (!(await file.exists())) return detail;
|
|
234
|
+
|
|
235
|
+
const toolCallSet = new Map<string, ToolCallSummary>();
|
|
236
|
+
const fileSet = new Set<string>();
|
|
237
|
+
let model: string | undefined;
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const text = await file.text();
|
|
241
|
+
for (const line of text.split("\n")) {
|
|
242
|
+
if (!line.trim()) continue;
|
|
243
|
+
let entry: any;
|
|
244
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
245
|
+
|
|
246
|
+
if (entry.type === "session.model_change" && !model) {
|
|
247
|
+
const m = entry.data?.newModel;
|
|
248
|
+
if (typeof m === "string") model = m;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (entry.type === "session.shutdown") {
|
|
252
|
+
accumulateCopilotTokens(entry.data?.modelMetrics, detail);
|
|
253
|
+
if (!model) {
|
|
254
|
+
const current = entry.data?.currentModel;
|
|
255
|
+
if (typeof current === "string") model = current;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (entry.type === "user.message") {
|
|
260
|
+
detail.messageCount++;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (entry.type === "assistant.message") {
|
|
264
|
+
detail.messageCount++;
|
|
265
|
+
|
|
266
|
+
// Count thinking block
|
|
267
|
+
if (typeof entry.data?.reasoningText === "string" && entry.data.reasoningText) {
|
|
268
|
+
detail.thinkingBlockCount++;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const textContent = entry.data?.content;
|
|
272
|
+
if (typeof textContent === "string" && textContent.length > 20) {
|
|
273
|
+
const redacted =
|
|
274
|
+
privacy.redactPatterns.length > 0 || privacy.redactHomeDir
|
|
275
|
+
? redactString(textContent, privacy)
|
|
276
|
+
: textContent;
|
|
277
|
+
detail.assistantSummaries.push(
|
|
278
|
+
redacted.slice(0, 200) + (redacted.length > 200 ? "..." : ""),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const toolRequests = entry.data?.toolRequests;
|
|
283
|
+
if (Array.isArray(toolRequests)) {
|
|
284
|
+
for (const req of toolRequests) {
|
|
285
|
+
const name = req.name ?? req.toolName;
|
|
286
|
+
if (typeof name !== "string") continue;
|
|
287
|
+
const input =
|
|
288
|
+
req.arguments && typeof req.arguments === "object"
|
|
289
|
+
? req.arguments
|
|
290
|
+
: undefined;
|
|
291
|
+
const displayName = toolDisplayName(name, input);
|
|
292
|
+
toolCallSet.set(displayName, {
|
|
293
|
+
name,
|
|
294
|
+
displayName,
|
|
295
|
+
category: categorizeToolName(name),
|
|
296
|
+
target: extractToolTarget(name, input),
|
|
297
|
+
});
|
|
298
|
+
const fp = extractFilePath(input);
|
|
299
|
+
if (fp) fileSet.add(fp);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
// file unreadable
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
detail.toolCalls = [...toolCallSet.values()];
|
|
309
|
+
detail.filesReferenced = [...fileSet];
|
|
310
|
+
detail.model = model;
|
|
311
|
+
detail.assistantSummaries = detail.assistantSummaries.slice(0, 10);
|
|
312
|
+
return detail;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Stream Copilot transcript entries with privacy filtering. */
|
|
316
|
+
export async function* streamCopilotTranscript(
|
|
317
|
+
sessionId: string,
|
|
318
|
+
sessionsDir: string,
|
|
319
|
+
privacy: PrivacyConfig,
|
|
320
|
+
): AsyncGenerator<TranscriptEntry> {
|
|
321
|
+
const file = Bun.file(eventsPath(sessionsDir, sessionId));
|
|
322
|
+
if (!(await file.exists())) return;
|
|
323
|
+
|
|
324
|
+
let currentModel: string | undefined;
|
|
325
|
+
|
|
326
|
+
const text = await file.text();
|
|
327
|
+
for (const line of text.split("\n")) {
|
|
328
|
+
if (!line.trim()) continue;
|
|
329
|
+
let raw: any;
|
|
330
|
+
try { raw = JSON.parse(line); } catch { continue; }
|
|
331
|
+
|
|
332
|
+
if (raw.type === "session.model_change") {
|
|
333
|
+
const m = raw.data?.newModel;
|
|
334
|
+
if (typeof m === "string") currentModel = m;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let mapped: TranscriptEntry | null = null;
|
|
339
|
+
const ts = parseTs(raw.timestamp);
|
|
340
|
+
const tsIso = ts ? new Date(ts).toISOString() : undefined;
|
|
341
|
+
|
|
342
|
+
if (raw.type === "user.message") {
|
|
343
|
+
const content = raw.data?.content;
|
|
344
|
+
mapped = {
|
|
345
|
+
timestamp: tsIso,
|
|
346
|
+
message: {
|
|
347
|
+
role: "user",
|
|
348
|
+
content: typeof content === "string" ? content : "",
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
} else if (raw.type === "assistant.message") {
|
|
352
|
+
const blocks: ContentBlock[] = [];
|
|
353
|
+
|
|
354
|
+
if (typeof raw.data?.reasoningText === "string" && raw.data.reasoningText) {
|
|
355
|
+
blocks.push({ type: "thinking", thinking: raw.data.reasoningText });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const textContent = raw.data?.content;
|
|
359
|
+
if (typeof textContent === "string" && textContent) {
|
|
360
|
+
blocks.push({ type: "text", text: textContent });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const toolRequests = raw.data?.toolRequests;
|
|
364
|
+
if (Array.isArray(toolRequests)) {
|
|
365
|
+
for (const req of toolRequests) {
|
|
366
|
+
const name = req.name ?? req.toolName;
|
|
367
|
+
if (typeof name === "string") {
|
|
368
|
+
blocks.push({
|
|
369
|
+
type: "tool_use",
|
|
370
|
+
name,
|
|
371
|
+
id: req.toolCallId,
|
|
372
|
+
input:
|
|
373
|
+
req.arguments && typeof req.arguments === "object"
|
|
374
|
+
? req.arguments
|
|
375
|
+
: undefined,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
mapped = {
|
|
382
|
+
timestamp: tsIso,
|
|
383
|
+
message: {
|
|
384
|
+
role: "assistant",
|
|
385
|
+
model: currentModel,
|
|
386
|
+
content: blocks,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
} else if (raw.type === "tool.execution_complete") {
|
|
390
|
+
const result = raw.data?.result;
|
|
391
|
+
const output =
|
|
392
|
+
typeof result?.content === "string"
|
|
393
|
+
? result.content
|
|
394
|
+
: typeof result?.detailedContent === "string"
|
|
395
|
+
? result.detailedContent
|
|
396
|
+
: undefined;
|
|
397
|
+
mapped = {
|
|
398
|
+
timestamp: tsIso,
|
|
399
|
+
toolUseResult: output,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!mapped) continue;
|
|
404
|
+
const filtered = filterTranscriptEntry(mapped, privacy);
|
|
405
|
+
if (filtered) yield filtered;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function extractFilePath(input: Record<string, unknown> | undefined): string | undefined {
|
|
410
|
+
if (!input) return undefined;
|
|
411
|
+
for (const key of ["file_path", "path", "target_file", "filename"]) {
|
|
412
|
+
const value = input[key];
|
|
413
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function extractToolTarget(
|
|
419
|
+
_name: string,
|
|
420
|
+
input: Record<string, unknown> | undefined,
|
|
421
|
+
): string | undefined {
|
|
422
|
+
const fp = extractFilePath(input);
|
|
423
|
+
if (fp) return fp;
|
|
424
|
+
if (!input) return undefined;
|
|
425
|
+
for (const key of ["command", "pattern", "query"]) {
|
|
426
|
+
const value = input[key];
|
|
427
|
+
if (typeof value === "string" && value.length > 0) {
|
|
428
|
+
return key === "command" ? value.split(" ")[0] : value;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
@@ -8,6 +8,7 @@ import { canonicalProvider } from "../utils/providers.js";
|
|
|
8
8
|
import { isProjectExcluded, redactString } from "../privacy/redact.js";
|
|
9
9
|
import { readCodexSessionHeader } from "./codex-rollout-reader.js";
|
|
10
10
|
import { readPiHistory } from "./pi-session-reader.js";
|
|
11
|
+
import { readCopilotHistory } from "./copilot-session-reader.js";
|
|
11
12
|
|
|
12
13
|
interface ClaudeHistoryEntry {
|
|
13
14
|
display: string;
|
|
@@ -44,6 +45,12 @@ export async function readHistory(
|
|
|
44
45
|
from, to, privacy,
|
|
45
46
|
);
|
|
46
47
|
}
|
|
48
|
+
if (provider === "copilot") {
|
|
49
|
+
return readCopilotHistory(
|
|
50
|
+
options?.sessionsDir ?? join(dirname(historyFile), "session-state"),
|
|
51
|
+
from, to, privacy,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
47
54
|
if (provider === "codex") {
|
|
48
55
|
return readCodexHistory(
|
|
49
56
|
historyFile,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
parseCodexToolArguments,
|
|
13
13
|
} from "./codex-rollout-reader.js";
|
|
14
14
|
import { peekPiSession, streamPiTranscript } from "./pi-session-reader.js";
|
|
15
|
+
import { peekCopilotSession, streamCopilotTranscript } from "./copilot-session-reader.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Peek session metadata from a session JSONL file.
|
|
@@ -28,6 +29,9 @@ export async function peekSession(
|
|
|
28
29
|
if (normalized === "pi") {
|
|
29
30
|
return peekPiSession(session, paths.sessionsDir);
|
|
30
31
|
}
|
|
32
|
+
if (normalized === "copilot") {
|
|
33
|
+
return peekCopilotSession(session, paths.sessionsDir);
|
|
34
|
+
}
|
|
31
35
|
if (normalized === "codex") {
|
|
32
36
|
return peekCodexSession(session, paths.sessionsDir);
|
|
33
37
|
}
|
|
@@ -181,6 +185,10 @@ export async function* streamTranscript(
|
|
|
181
185
|
yield* streamPiTranscript(sessionId, paths.sessionsDir, privacy);
|
|
182
186
|
return;
|
|
183
187
|
}
|
|
188
|
+
if (normalized === "copilot") {
|
|
189
|
+
yield* streamCopilotTranscript(sessionId, paths.sessionsDir, privacy);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
184
192
|
if (normalized === "codex") {
|
|
185
193
|
yield* streamCodexTranscript(sessionId, paths.sessionsDir, privacy);
|
|
186
194
|
return;
|
package/src/types/provider.ts
CHANGED
package/src/utils/paths.ts
CHANGED
|
@@ -54,6 +54,21 @@ export function providerPaths(config?: {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
if (provider === "copilot") {
|
|
58
|
+
return {
|
|
59
|
+
base,
|
|
60
|
+
historyFile: join(base, "history.jsonl"), // Copilot has no history.jsonl — unused
|
|
61
|
+
projectsDir: join(base, "session-state"),
|
|
62
|
+
sessionsDir: join(base, "session-state"),
|
|
63
|
+
globalStateFile: join(base, "global-state.json"),
|
|
64
|
+
tasksDir: join(base, "tasks"),
|
|
65
|
+
plansDir: join(base, "plans"),
|
|
66
|
+
todosDir: join(base, "todos"),
|
|
67
|
+
skillsDir: join(base, "skills"),
|
|
68
|
+
statsCache: join(base, "stats-cache.json"),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
return {
|
|
58
73
|
base,
|
|
59
74
|
historyFile: join(base, "history.jsonl"),
|
package/src/utils/providers.ts
CHANGED
|
@@ -9,6 +9,7 @@ const PROVIDER_HOME_DIR: Record<Provider, string> = {
|
|
|
9
9
|
cursor: ".cursor",
|
|
10
10
|
windsurf: ".windsurf",
|
|
11
11
|
pi: ".pi",
|
|
12
|
+
copilot: ".copilot",
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
export const DEFAULT_PROVIDER: Provider = "claude";
|
|
@@ -25,3 +26,38 @@ export function canonicalProvider(provider: Provider): Exclude<Provider, "openai
|
|
|
25
26
|
if (provider === "openai") return "codex";
|
|
26
27
|
return provider;
|
|
27
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Known AI agent commit emails and usernames.
|
|
32
|
+
* Used to attribute commits from fully-automated agents (Cursor, Copilot SWE, Devin)
|
|
33
|
+
* that lack a local session file. Sourced from git-ai's agent_detection.rs.
|
|
34
|
+
*/
|
|
35
|
+
export const AGENT_COMMIT_EMAILS: Record<string, string> = {
|
|
36
|
+
"cursoragent@cursor.com": "cursor",
|
|
37
|
+
"198982749+copilot@users.noreply.github.com": "github-copilot",
|
|
38
|
+
"158243242+devin-ai-integration[bot]@users.noreply.github.com": "devin",
|
|
39
|
+
"noreply@anthropic.com": "claude",
|
|
40
|
+
"noreply@openai.com": "codex",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const AGENT_COMMIT_USERNAMES: Record<string, string> = {
|
|
44
|
+
"copilot-swe-agent[bot]": "github-copilot",
|
|
45
|
+
"devin-ai-integration[bot]": "devin",
|
|
46
|
+
"cursor[bot]": "cursor",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Detect the AI tool that authored a commit based on its git author email or username.
|
|
51
|
+
* Returns the tool name (e.g. "cursor", "github-copilot") or undefined if not a known agent.
|
|
52
|
+
*/
|
|
53
|
+
export function detectAgentFromCommit(email?: string, username?: string): string | undefined {
|
|
54
|
+
if (email) {
|
|
55
|
+
const byEmail = AGENT_COMMIT_EMAILS[email.toLowerCase()];
|
|
56
|
+
if (byEmail) return byEmail;
|
|
57
|
+
}
|
|
58
|
+
if (username) {
|
|
59
|
+
const byUsername = AGENT_COMMIT_USERNAMES[username.toLowerCase()];
|
|
60
|
+
if (byUsername) return byUsername;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|