@jagit/hook-copilot 0.0.3 → 0.0.4

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,31 @@ 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
+ observations: number;
69
+ }
70
+ interface CopilotDebugUsage {
71
+ sessionId: string;
72
+ workspaceId: string;
73
+ model: string;
74
+ inputTokens: number;
75
+ cachedInputTokens: number;
76
+ outputTokens: number;
77
+ totalTokens: number;
78
+ sourcePath: string;
79
+ modelUsage: Record<string, ModelUsageBucket>;
80
+ }
81
+ export declare function inferWorkspaceIdBySession(sessionId: string, hookTimestamp?: string, baseDir?: string): WorkspaceCandidate | undefined;
82
+ export declare function resolveDebugUsageBySession(sessionId: string | undefined, hookTimestamp?: string, baseDir?: string): CopilotDebugUsage | undefined;
58
83
  /**
59
84
  * Build payload from a real VS Code Copilot agent Stop-hook stdin.
60
85
  *
@@ -63,10 +88,11 @@ export interface CopilotInfo {
63
88
  * VS Code Copilot transcript — Copilot uses seat-based billing and does not
64
89
  * expose per-call telemetry. These fields are reported as 0/null/"copilot".
65
90
  */
66
- export declare function buildPayloadFromStdin(stdin: CopilotStopStdin, read?: (path: string) => CopilotTranscriptEntry[]): AgentSessionPayload;
91
+ export declare function buildPayloadFromStdin(stdin: CopilotStopStdin, read?: (path: string) => CopilotTranscriptEntry[], resolveUsage?: (sessionId: string | undefined, hookTimestamp?: string) => CopilotDebugUsage | undefined): AgentSessionPayload;
67
92
  /**
68
93
  * Build payload in legacy mode (no stdin) — used when hook-copilot is invoked
69
94
  * via the old shell-wrapper pattern around the Copilot CLI. Token counts are
70
95
  * not available in this mode (seat-based billing has no per-call telemetry).
71
96
  */
72
97
  export declare function buildPayload(cwd: string | undefined, info?: CopilotInfo): AgentSessionPayload;
98
+ 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,233 @@ 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
+ function resolveModelFromObject(obj, fallbackModel) {
97
+ const keys = ["model", "modelName", "model_name", "resolvedModel", "deployment", "engine"];
98
+ for (const key of keys) {
99
+ const value = obj[key];
100
+ if (typeof value === "string" && value.trim().length > 0) {
101
+ return value.trim();
102
+ }
103
+ }
104
+ return fallbackModel;
105
+ }
106
+ function extractTokensFromObject(obj) {
107
+ let inputTokens = 0;
108
+ let cachedInputTokens = 0;
109
+ let outputTokens = 0;
110
+ let totalTokens = 0;
111
+ let foundAny = false;
112
+ for (const [rawKey, rawValue] of Object.entries(obj)) {
113
+ const key = normalizeKey(rawKey);
114
+ const value = toFiniteNumber(rawValue);
115
+ if (value === null) {
116
+ continue;
117
+ }
118
+ if (["inputtokens", "prompttokens", "requesttokens"].includes(key)) {
119
+ inputTokens += value;
120
+ foundAny = true;
121
+ continue;
122
+ }
123
+ if (["cachedinputtokens", "cachedtokens", "cachereadinputtokens", "promptcachehitstokens"].includes(key)) {
124
+ cachedInputTokens += value;
125
+ foundAny = true;
126
+ continue;
127
+ }
128
+ if (["outputtokens", "completiontokens", "responsetokens"].includes(key)) {
129
+ outputTokens += value;
130
+ foundAny = true;
131
+ continue;
132
+ }
133
+ if (["totaltokens", "alltokens"].includes(key)) {
134
+ totalTokens += value;
135
+ foundAny = true;
136
+ continue;
137
+ }
138
+ }
139
+ if (!foundAny) {
140
+ return null;
141
+ }
142
+ if (totalTokens === 0) {
143
+ totalTokens = inputTokens + outputTokens;
144
+ }
145
+ return { inputTokens, cachedInputTokens, outputTokens, totalTokens };
146
+ }
147
+ function collectModelUsage(value, usageByModel, currentModel = "copilot") {
148
+ if (Array.isArray(value)) {
149
+ for (const item of value) {
150
+ collectModelUsage(item, usageByModel, currentModel);
151
+ }
152
+ return;
153
+ }
154
+ if (!isRecord(value)) {
155
+ return;
156
+ }
157
+ const resolvedModel = resolveModelFromObject(value, currentModel);
158
+ const tokens = extractTokensFromObject(value);
159
+ if (tokens) {
160
+ const existing = usageByModel.get(resolvedModel) ?? {
161
+ inputTokens: 0,
162
+ cachedInputTokens: 0,
163
+ outputTokens: 0,
164
+ totalTokens: 0,
165
+ observations: 0,
166
+ };
167
+ existing.inputTokens += tokens.inputTokens;
168
+ existing.cachedInputTokens += tokens.cachedInputTokens;
169
+ existing.outputTokens += tokens.outputTokens;
170
+ existing.totalTokens += tokens.totalTokens;
171
+ existing.observations += 1;
172
+ usageByModel.set(resolvedModel, existing);
173
+ }
174
+ for (const child of Object.values(value)) {
175
+ collectModelUsage(child, usageByModel, resolvedModel);
176
+ }
177
+ }
178
+ export function resolveDebugUsageBySession(sessionId, hookTimestamp, baseDir = WORKSPACE_STORAGE_DIR) {
179
+ if (!sessionId) {
180
+ return undefined;
181
+ }
182
+ const workspace = inferWorkspaceIdBySession(sessionId, hookTimestamp, baseDir);
183
+ if (!workspace) {
184
+ return undefined;
185
+ }
186
+ const mainJsonlPath = join(workspace.debugLogsDir, sessionId, "main.jsonl");
187
+ if (!existsSync(mainJsonlPath)) {
188
+ return undefined;
189
+ }
190
+ const usageByModel = new Map();
191
+ const lines = readFileSync(mainJsonlPath, "utf-8").split("\n");
192
+ for (const line of lines) {
193
+ const trimmed = line.trim();
194
+ if (!trimmed) {
195
+ continue;
196
+ }
197
+ try {
198
+ const parsed = JSON.parse(trimmed);
199
+ collectModelUsage(parsed, usageByModel, "copilot");
200
+ }
201
+ catch {
202
+ // Ignore malformed lines to keep reporting resilient.
203
+ }
204
+ }
205
+ if (usageByModel.size === 0) {
206
+ return {
207
+ sessionId,
208
+ workspaceId: workspace.workspaceId,
209
+ model: "copilot",
210
+ inputTokens: 0,
211
+ cachedInputTokens: 0,
212
+ outputTokens: 0,
213
+ totalTokens: 0,
214
+ sourcePath: mainJsonlPath,
215
+ modelUsage: {},
216
+ };
217
+ }
218
+ let dominantModel = "copilot";
219
+ let dominantTotal = -1;
220
+ let dominantObs = -1;
221
+ let inputTokens = 0;
222
+ let cachedInputTokens = 0;
223
+ let outputTokens = 0;
224
+ let totalTokens = 0;
225
+ for (const [model, usage] of usageByModel.entries()) {
226
+ inputTokens += usage.inputTokens;
227
+ cachedInputTokens += usage.cachedInputTokens;
228
+ outputTokens += usage.outputTokens;
229
+ totalTokens += usage.totalTokens;
230
+ if (usage.totalTokens > dominantTotal || (usage.totalTokens === dominantTotal && usage.observations > dominantObs)) {
231
+ dominantModel = model;
232
+ dominantTotal = usage.totalTokens;
233
+ dominantObs = usage.observations;
234
+ }
235
+ }
236
+ const modelUsage = Object.fromEntries(usageByModel.entries());
237
+ return {
238
+ sessionId,
239
+ workspaceId: workspace.workspaceId,
240
+ model: dominantModel,
241
+ inputTokens,
242
+ cachedInputTokens,
243
+ outputTokens,
244
+ totalTokens,
245
+ sourcePath: mainJsonlPath,
246
+ modelUsage,
247
+ };
248
+ }
19
249
  /**
20
250
  * Count tool calls from assistant.message entries.
21
251
  * Each assistant.message with at least one toolRequest counts as one tool-call turn;
@@ -55,7 +285,7 @@ function extractStartTime(entries) {
55
285
  * VS Code Copilot transcript — Copilot uses seat-based billing and does not
56
286
  * expose per-call telemetry. These fields are reported as 0/null/"copilot".
57
287
  */
58
- export function buildPayloadFromStdin(stdin, read = readTranscript) {
288
+ export function buildPayloadFromStdin(stdin, read = readTranscript, resolveUsage = resolveDebugUsageBySession) {
59
289
  const entries = stdin.transcript_path ? (() => {
60
290
  try {
61
291
  return read(stdin.transcript_path);
@@ -66,21 +296,29 @@ export function buildPayloadFromStdin(stdin, read = readTranscript) {
66
296
  })() : [];
67
297
  const toolCallCount = countToolCalls(entries);
68
298
  const startedAt = extractStartTime(entries) ?? stdin.timestamp ?? new Date().toISOString();
299
+ const usage = resolveUsage(stdin.session_id, stdin.timestamp);
69
300
  return {
70
301
  tool: "copilot",
71
302
  // session_id is optional per VS Code spec; synthesize a fallback if absent
72
303
  sessionId: stdin.session_id ?? `copilot-${Date.now()}-${process.pid}`,
73
304
  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,
305
+ // Model/tokens are inferred from debug logs by session_id; falls back safely.
306
+ model: usage?.model ?? "copilot",
307
+ inputTokens: usage?.inputTokens ?? 0,
308
+ cachedInputTokens: usage?.cachedInputTokens ?? 0,
79
309
  cacheCreationInputTokens: 0,
80
- outputTokens: 0,
310
+ outputTokens: usage?.outputTokens ?? 0,
81
311
  costUsd: null,
82
312
  toolCallCount,
83
313
  startedAt,
314
+ rawPayload: usage ? {
315
+ source: "copilot-debug-logs",
316
+ workspaceId: usage.workspaceId,
317
+ debugLogPath: usage.sourcePath,
318
+ debugSessionId: usage.sessionId,
319
+ totalTokens: usage.totalTokens,
320
+ modelUsage: usage.modelUsage,
321
+ } : undefined,
84
322
  };
85
323
  }
86
324
  /**
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.4",
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.4"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/node": "^25.9.3",