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.
@@ -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
- function fmt(n: number): string {
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 note = [
76
+ const summary = [
72
77
  `AI: $${r.cost_usd.toFixed(2)}`,
73
- `out: ${fmt(r.tokens.output)}`,
74
- `cache: ${fmt(r.tokens.cache_read)}`,
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 proc = Bun.spawn(["git", "notes", "add", "-f", "-m", note, r.commit], {
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", "refs/notes/commits"], {
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(" git fetch origin refs/notes/commits:refs/notes/commits");
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 record = {
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 < 5) continue;
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
- if (fullHashes.length > 0) {
357
- const nr = Bun.spawn(["git", "name-rev", "--always", "--exclude=HEAD", ...fullHashes], { stdout: "pipe", stderr: "pipe" });
358
- const nrText = await new Response(nr.stdout).text();
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
- newRecords.push(JSON.stringify({
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
- formatTokens(tokens).padStart(12),
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
- formatTokens(tokens).padStart(12),
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
- return commits;
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
- function findMatchingSessions(commit: GitCommit, sessions: SessionMeta[]): SessionMeta[] {
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;
@@ -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
- formatTokens(s.inputTokens).padStart(10),
96
- formatTokens(s.outputTokens).padStart(10),
97
- formatTokens(s.cacheWriteTokens).padStart(10),
98
- formatTokens(s.cacheReadTokens).padStart(10),
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
- formatTokens(totalInput).padStart(10),
108
- formatTokens(totalOutput).padStart(10),
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 ${formatTokens(avgTokens)} tokens/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.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.5+ ($5/$25)
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
- /** Look up pricing for a model, falling back to Sonnet rates. */
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
- return activePricing[model] ?? FALLBACK_PRICING;
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;
@@ -5,6 +5,7 @@ export const SUPPORTED_PROVIDERS = [
5
5
  "cursor",
6
6
  "windsurf",
7
7
  "pi",
8
+ "copilot",
8
9
  ] as const;
9
10
 
10
11
  export type Provider = (typeof SUPPORTED_PROVIDERS)[number];
@@ -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"),
@@ -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
+ }