@jagit/hook-copilot 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Reports GitHub Copilot agent session usage to JaGit. Supports two modes:
4
4
 
5
- 1. **VS Code agent hook** (recommended) — hooks into the VS Code Copilot agent `Stop` lifecycle event, receiving a structured JSON payload from stdin including `session_id`, `cwd`, and `transcript_path`. Token counts and model name are parsed from the session transcript.
5
+ 1. **VS Code agent hook** (recommended) — hooks into the VS Code Copilot agent `Stop` lifecycle event, receiving a structured JSON payload from stdin including `session_id`, `cwd`, and `transcript_path`. Token counts and model name are read from Copilot debug logs (`main.jsonl`) by mapping `session_id` to the debug-log session folder.
6
6
  2. **Legacy shell wrapper** — wraps the Copilot CLI binary and fires after each invocation. No per-call telemetry is available under seat-based billing, so token counts are always zero.
7
7
 
8
8
  ## Setup — VS Code Agent Hook (Recommended)
@@ -52,6 +52,7 @@ Identity defaults to `git config user.email`; override with `JAGIT_GIT_USERNAME`
52
52
 
53
53
  ## Notes
54
54
 
55
- - In **VS Code agent hook mode**, tokens and model are read from the session transcript at `transcript_path`. Both snake_case (`input_tokens`) and camelCase (`inputTokens`) transcript formats are supported.
55
+ - In **VS Code agent hook mode**, tool-call count and start time are read from `transcript_path`; model/token usage is read from `~/.config/Code/User/workspaceStorage/<workspace-id>/GitHub.copilot-chat/debug-logs/<session-id>/main.jsonl`.
56
+ - Workspace detection prefers a workspace that contains the incoming `session_id`. If no exact session match is found, it falls back to the most recently updated workspace debug-log directory near the Stop-hook timestamp.
56
57
  - In **legacy shell wrapper mode**, token counts are always zero (`costUsd: null`) since the Copilot CLI exposes no per-invocation telemetry under seat-based billing.
57
58
  - `costUsd` is always `null` — there is no per-session USD cost to report.
package/dist/index.d.ts CHANGED
@@ -55,6 +55,38 @@ export interface CopilotInfo {
55
55
  cachedInputTokens?: number;
56
56
  toolCallCount?: number | null;
57
57
  }
58
+ interface WorkspaceCandidate {
59
+ workspaceId: string;
60
+ debugLogsDir: string;
61
+ updatedAtMs: number;
62
+ }
63
+ interface ModelUsageBucket {
64
+ inputTokens: number;
65
+ cachedInputTokens: number;
66
+ outputTokens: number;
67
+ totalTokens: number;
68
+ costUsd: number;
69
+ observations: number;
70
+ }
71
+ interface CopilotDebugUsage {
72
+ sessionId: string;
73
+ workspaceId: string;
74
+ model: string;
75
+ inputTokens: number;
76
+ cachedInputTokens: number;
77
+ outputTokens: number;
78
+ totalTokens: number;
79
+ costUsd: number | null;
80
+ sourcePath: string;
81
+ modelUsage: Record<string, ModelUsageBucket>;
82
+ }
83
+ export declare function inferWorkspaceIdBySession(sessionId: string, hookTimestamp?: string, baseDir?: string): WorkspaceCandidate | undefined;
84
+ interface TranscriptPathLocation {
85
+ workspaceId: string;
86
+ sessionId: string;
87
+ }
88
+ export declare function parseTranscriptPathLocation(transcriptPath: string | undefined): TranscriptPathLocation | undefined;
89
+ export declare function resolveDebugUsageBySession(sessionId: string | undefined, hookTimestamp?: string, baseDir?: string, workspaceIdFromTranscript?: string): CopilotDebugUsage | undefined;
58
90
  /**
59
91
  * Build payload from a real VS Code Copilot agent Stop-hook stdin.
60
92
  *
@@ -63,10 +95,11 @@ export interface CopilotInfo {
63
95
  * VS Code Copilot transcript — Copilot uses seat-based billing and does not
64
96
  * expose per-call telemetry. These fields are reported as 0/null/"copilot".
65
97
  */
66
- export declare function buildPayloadFromStdin(stdin: CopilotStopStdin, read?: (path: string) => CopilotTranscriptEntry[]): AgentSessionPayload;
98
+ export declare function buildPayloadFromStdin(stdin: CopilotStopStdin, read?: (path: string) => CopilotTranscriptEntry[], resolveUsage?: (sessionId: string | undefined, hookTimestamp?: string, baseDir?: string, workspaceIdFromTranscript?: string) => CopilotDebugUsage | undefined): AgentSessionPayload;
67
99
  /**
68
100
  * Build payload in legacy mode (no stdin) — used when hook-copilot is invoked
69
101
  * via the old shell-wrapper pattern around the Copilot CLI. Token counts are
70
102
  * not available in this mode (seat-based billing has no per-call telemetry).
71
103
  */
72
104
  export declare function buildPayload(cwd: string | undefined, info?: CopilotInfo): AgentSessionPayload;
105
+ export {};
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, realpathSync } from "node:fs";
2
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import { resolveGitUsername, reportSession } from "@jagit/agent-reporter";
7
+ const WORKSPACE_STORAGE_DIR = join(homedir(), ".config", "Code", "User", "workspaceStorage");
5
8
  // ─── Helpers ─────────────────────────────────────────────────────────────────
6
9
  function readTranscript(path) {
7
10
  return readFileSync(path, "utf-8")
@@ -16,6 +19,274 @@ function readTranscript(path) {
16
19
  }
17
20
  });
18
21
  }
22
+ function normalizeKey(key) {
23
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
24
+ }
25
+ function toFiniteNumber(value) {
26
+ if (typeof value === "number" && Number.isFinite(value)) {
27
+ return value;
28
+ }
29
+ if (typeof value === "string" && value.trim().length > 0) {
30
+ const parsed = Number(value);
31
+ if (Number.isFinite(parsed)) {
32
+ return parsed;
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+ function isRecord(value) {
38
+ return typeof value === "object" && value !== null && !Array.isArray(value);
39
+ }
40
+ function listWorkspaceCandidates(baseDir) {
41
+ if (!existsSync(baseDir)) {
42
+ return [];
43
+ }
44
+ const entries = readdirSync(baseDir, { withFileTypes: true });
45
+ const workspaces = [];
46
+ for (const entry of entries) {
47
+ if (!entry.isDirectory()) {
48
+ continue;
49
+ }
50
+ const debugLogsDir = join(baseDir, entry.name, "GitHub.copilot-chat", "debug-logs");
51
+ if (!existsSync(debugLogsDir)) {
52
+ continue;
53
+ }
54
+ let updatedAtMs = 0;
55
+ try {
56
+ updatedAtMs = statSync(debugLogsDir).mtimeMs;
57
+ }
58
+ catch {
59
+ updatedAtMs = 0;
60
+ }
61
+ workspaces.push({
62
+ workspaceId: entry.name,
63
+ debugLogsDir,
64
+ updatedAtMs,
65
+ });
66
+ }
67
+ return workspaces;
68
+ }
69
+ function rankWorkspacesByRecency(workspaces, hookTimestamp) {
70
+ if (!hookTimestamp) {
71
+ return [...workspaces].sort((a, b) => b.updatedAtMs - a.updatedAtMs);
72
+ }
73
+ const hookMs = Date.parse(hookTimestamp);
74
+ if (Number.isNaN(hookMs)) {
75
+ return [...workspaces].sort((a, b) => b.updatedAtMs - a.updatedAtMs);
76
+ }
77
+ return [...workspaces].sort((a, b) => {
78
+ const aDelta = Math.abs(a.updatedAtMs - hookMs);
79
+ const bDelta = Math.abs(b.updatedAtMs - hookMs);
80
+ if (aDelta !== bDelta) {
81
+ return aDelta - bDelta;
82
+ }
83
+ return b.updatedAtMs - a.updatedAtMs;
84
+ });
85
+ }
86
+ export function inferWorkspaceIdBySession(sessionId, hookTimestamp, baseDir = WORKSPACE_STORAGE_DIR) {
87
+ const candidates = rankWorkspacesByRecency(listWorkspaceCandidates(baseDir), hookTimestamp);
88
+ for (const candidate of candidates) {
89
+ const sessionDir = join(candidate.debugLogsDir, sessionId);
90
+ if (existsSync(sessionDir)) {
91
+ return candidate;
92
+ }
93
+ }
94
+ return candidates[0];
95
+ }
96
+ export function parseTranscriptPathLocation(transcriptPath) {
97
+ if (!transcriptPath) {
98
+ return undefined;
99
+ }
100
+ const normalized = transcriptPath.replace(/\\/g, "/");
101
+ const match = normalized.match(/\/workspaceStorage\/([^/]+)\/GitHub\.copilot-chat\/transcripts\/([^/]+)\.jsonl$/);
102
+ if (!match) {
103
+ return undefined;
104
+ }
105
+ return {
106
+ workspaceId: match[1],
107
+ sessionId: match[2],
108
+ };
109
+ }
110
+ function buildMainLogPath(baseDir, workspaceId, sessionId) {
111
+ return join(baseDir, workspaceId, "GitHub.copilot-chat", "debug-logs", sessionId, "main.jsonl");
112
+ }
113
+ function resolveModelFromObject(obj, fallbackModel) {
114
+ const keys = ["model", "modelName", "model_name", "resolvedModel", "deployment", "engine"];
115
+ for (const key of keys) {
116
+ const value = obj[key];
117
+ if (typeof value === "string" && value.trim().length > 0) {
118
+ return value.trim();
119
+ }
120
+ }
121
+ return fallbackModel;
122
+ }
123
+ function extractTokensFromObject(obj) {
124
+ let inputTokens = 0;
125
+ let cachedInputTokens = 0;
126
+ let outputTokens = 0;
127
+ let totalTokens = 0;
128
+ let costUsd = 0;
129
+ let foundAny = false;
130
+ for (const [rawKey, rawValue] of Object.entries(obj)) {
131
+ const key = normalizeKey(rawKey);
132
+ const value = toFiniteNumber(rawValue);
133
+ if (value === null) {
134
+ continue;
135
+ }
136
+ if (["inputtokens", "prompttokens", "requesttokens"].includes(key)) {
137
+ inputTokens += value;
138
+ foundAny = true;
139
+ continue;
140
+ }
141
+ if (["cachedinputtokens", "cachedtokens", "cachereadinputtokens", "promptcachehitstokens"].includes(key)) {
142
+ cachedInputTokens += value;
143
+ foundAny = true;
144
+ continue;
145
+ }
146
+ if (["outputtokens", "completiontokens", "responsetokens"].includes(key)) {
147
+ outputTokens += value;
148
+ foundAny = true;
149
+ continue;
150
+ }
151
+ if (["totaltokens", "alltokens"].includes(key)) {
152
+ totalTokens += value;
153
+ foundAny = true;
154
+ continue;
155
+ }
156
+ if (["costusd", "cost", "usd", "usdcost"].includes(key)) {
157
+ costUsd += value;
158
+ foundAny = true;
159
+ continue;
160
+ }
161
+ }
162
+ if (!foundAny) {
163
+ return null;
164
+ }
165
+ if (totalTokens === 0) {
166
+ totalTokens = inputTokens + outputTokens;
167
+ }
168
+ return { inputTokens, cachedInputTokens, outputTokens, totalTokens, costUsd };
169
+ }
170
+ function collectModelUsage(value, usageByModel, currentModel = "copilot") {
171
+ if (Array.isArray(value)) {
172
+ for (const item of value) {
173
+ collectModelUsage(item, usageByModel, currentModel);
174
+ }
175
+ return;
176
+ }
177
+ if (!isRecord(value)) {
178
+ return;
179
+ }
180
+ const resolvedModel = resolveModelFromObject(value, currentModel);
181
+ const tokens = extractTokensFromObject(value);
182
+ if (tokens) {
183
+ const existing = usageByModel.get(resolvedModel) ?? {
184
+ inputTokens: 0,
185
+ cachedInputTokens: 0,
186
+ outputTokens: 0,
187
+ totalTokens: 0,
188
+ costUsd: 0,
189
+ observations: 0,
190
+ };
191
+ existing.inputTokens += tokens.inputTokens;
192
+ existing.cachedInputTokens += tokens.cachedInputTokens;
193
+ existing.outputTokens += tokens.outputTokens;
194
+ existing.totalTokens += tokens.totalTokens;
195
+ existing.costUsd += tokens.costUsd;
196
+ existing.observations += 1;
197
+ usageByModel.set(resolvedModel, existing);
198
+ }
199
+ for (const child of Object.values(value)) {
200
+ collectModelUsage(child, usageByModel, resolvedModel);
201
+ }
202
+ }
203
+ export function resolveDebugUsageBySession(sessionId, hookTimestamp, baseDir = WORKSPACE_STORAGE_DIR, workspaceIdFromTranscript) {
204
+ if (!sessionId) {
205
+ return undefined;
206
+ }
207
+ let workspaceId = workspaceIdFromTranscript;
208
+ let mainJsonlPath;
209
+ if (workspaceId) {
210
+ const pathFromTranscript = buildMainLogPath(baseDir, workspaceId, sessionId);
211
+ if (existsSync(pathFromTranscript)) {
212
+ mainJsonlPath = pathFromTranscript;
213
+ }
214
+ }
215
+ if (!mainJsonlPath) {
216
+ const workspace = inferWorkspaceIdBySession(sessionId, hookTimestamp, baseDir);
217
+ if (!workspace) {
218
+ return undefined;
219
+ }
220
+ workspaceId = workspace.workspaceId;
221
+ const fallbackPath = buildMainLogPath(baseDir, workspace.workspaceId, sessionId);
222
+ if (!existsSync(fallbackPath)) {
223
+ return undefined;
224
+ }
225
+ mainJsonlPath = fallbackPath;
226
+ }
227
+ const usageByModel = new Map();
228
+ const lines = readFileSync(mainJsonlPath, "utf-8").split("\n");
229
+ for (const line of lines) {
230
+ const trimmed = line.trim();
231
+ if (!trimmed) {
232
+ continue;
233
+ }
234
+ try {
235
+ const parsed = JSON.parse(trimmed);
236
+ collectModelUsage(parsed, usageByModel, "copilot");
237
+ }
238
+ catch {
239
+ // Ignore malformed lines to keep reporting resilient.
240
+ }
241
+ }
242
+ if (usageByModel.size === 0) {
243
+ return {
244
+ sessionId,
245
+ workspaceId: workspaceId ?? "unknown",
246
+ model: "copilot",
247
+ inputTokens: 0,
248
+ cachedInputTokens: 0,
249
+ outputTokens: 0,
250
+ totalTokens: 0,
251
+ costUsd: null,
252
+ sourcePath: mainJsonlPath,
253
+ modelUsage: {},
254
+ };
255
+ }
256
+ let dominantModel = "copilot";
257
+ let dominantTotal = -1;
258
+ let dominantObs = -1;
259
+ let inputTokens = 0;
260
+ let cachedInputTokens = 0;
261
+ let outputTokens = 0;
262
+ let totalTokens = 0;
263
+ let totalCostUsd = 0;
264
+ for (const [model, usage] of usageByModel.entries()) {
265
+ inputTokens += usage.inputTokens;
266
+ cachedInputTokens += usage.cachedInputTokens;
267
+ outputTokens += usage.outputTokens;
268
+ totalTokens += usage.totalTokens;
269
+ totalCostUsd += usage.costUsd;
270
+ if (usage.totalTokens > dominantTotal || (usage.totalTokens === dominantTotal && usage.observations > dominantObs)) {
271
+ dominantModel = model;
272
+ dominantTotal = usage.totalTokens;
273
+ dominantObs = usage.observations;
274
+ }
275
+ }
276
+ const modelUsage = Object.fromEntries(usageByModel.entries());
277
+ return {
278
+ sessionId,
279
+ workspaceId: workspaceId ?? "unknown",
280
+ model: dominantModel,
281
+ inputTokens,
282
+ cachedInputTokens,
283
+ outputTokens,
284
+ totalTokens,
285
+ costUsd: totalCostUsd > 0 ? totalCostUsd : null,
286
+ sourcePath: mainJsonlPath,
287
+ modelUsage,
288
+ };
289
+ }
19
290
  /**
20
291
  * Count tool calls from assistant.message entries.
21
292
  * Each assistant.message with at least one toolRequest counts as one tool-call turn;
@@ -55,7 +326,7 @@ function extractStartTime(entries) {
55
326
  * VS Code Copilot transcript — Copilot uses seat-based billing and does not
56
327
  * expose per-call telemetry. These fields are reported as 0/null/"copilot".
57
328
  */
58
- export function buildPayloadFromStdin(stdin, read = readTranscript) {
329
+ export function buildPayloadFromStdin(stdin, read = readTranscript, resolveUsage = resolveDebugUsageBySession) {
59
330
  const entries = stdin.transcript_path ? (() => {
60
331
  try {
61
332
  return read(stdin.transcript_path);
@@ -66,21 +337,31 @@ export function buildPayloadFromStdin(stdin, read = readTranscript) {
66
337
  })() : [];
67
338
  const toolCallCount = countToolCalls(entries);
68
339
  const startedAt = extractStartTime(entries) ?? stdin.timestamp ?? new Date().toISOString();
340
+ const parsedLocation = parseTranscriptPathLocation(stdin.transcript_path);
341
+ const sessionId = parsedLocation?.sessionId ?? stdin.session_id;
342
+ const usage = resolveUsage(sessionId, stdin.timestamp, WORKSPACE_STORAGE_DIR, parsedLocation?.workspaceId);
69
343
  return {
70
344
  tool: "copilot",
71
345
  // session_id is optional per VS Code spec; synthesize a fallback if absent
72
- sessionId: stdin.session_id ?? `copilot-${Date.now()}-${process.pid}`,
346
+ sessionId: sessionId ?? `copilot-${Date.now()}-${process.pid}`,
73
347
  gitUsername: resolveGitUsername(stdin.cwd),
74
- // Model name is not exposed in the Copilot hook transcript (seat-based billing)
75
- model: "copilot",
76
- // Token usage is not available in the Copilot hook transcript
77
- inputTokens: 0,
78
- cachedInputTokens: 0,
348
+ // Model/tokens are inferred from debug logs by session_id; falls back safely.
349
+ model: usage?.model ?? "copilot",
350
+ inputTokens: usage?.inputTokens ?? 0,
351
+ cachedInputTokens: usage?.cachedInputTokens ?? 0,
79
352
  cacheCreationInputTokens: 0,
80
- outputTokens: 0,
81
- costUsd: null,
353
+ outputTokens: usage?.outputTokens ?? 0,
354
+ costUsd: usage?.costUsd ?? null,
82
355
  toolCallCount,
83
356
  startedAt,
357
+ rawPayload: usage ? {
358
+ source: "copilot-debug-logs",
359
+ workspaceId: usage.workspaceId,
360
+ debugLogPath: usage.sourcePath,
361
+ debugSessionId: usage.sessionId,
362
+ totalTokens: usage.totalTokens,
363
+ modelUsage: usage.modelUsage,
364
+ } : undefined,
84
365
  };
85
366
  }
86
367
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jagit/hook-copilot",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "jagit-hook-copilot": "dist/index.js"
@@ -9,7 +9,7 @@
9
9
  "dist"
10
10
  ],
11
11
  "dependencies": {
12
- "@jagit/agent-reporter": "0.0.3"
12
+ "@jagit/agent-reporter": "0.0.5"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/node": "^25.9.3",