@jagit/hook-copilot 0.0.2 → 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
@@ -14,24 +14,40 @@ export interface CopilotStopStdin {
14
14
  */
15
15
  stop_hook_active?: boolean;
16
16
  }
17
- export interface CopilotTranscriptEntry {
17
+ export interface CopilotTranscriptSessionStart {
18
+ type: "session.start";
19
+ timestamp?: string;
20
+ data: {
21
+ sessionId?: string;
22
+ version?: number;
23
+ producer?: string;
24
+ copilotVersion?: string;
25
+ vscodeVersion?: string;
26
+ startTime?: string;
27
+ };
28
+ }
29
+ export interface CopilotTranscriptToolRequest {
30
+ toolCallId?: string;
31
+ name?: string;
32
+ arguments?: string;
18
33
  type?: string;
34
+ }
35
+ export interface CopilotTranscriptAssistantMessage {
36
+ type: "assistant.message";
19
37
  timestamp?: string;
20
- message?: {
21
- role?: string;
22
- model?: string;
23
- content?: unknown;
24
- usage?: {
25
- input_tokens?: number;
26
- cache_read_input_tokens?: number;
27
- cache_creation_input_tokens?: number;
28
- output_tokens?: number;
29
- inputTokens?: number;
30
- cachedInputTokens?: number;
31
- outputTokens?: number;
32
- };
38
+ id?: string;
39
+ data: {
40
+ messageId?: string;
41
+ content?: string;
42
+ /** Tool calls made in this assistant turn */
43
+ toolRequests?: CopilotTranscriptToolRequest[];
33
44
  };
34
45
  }
46
+ export type CopilotTranscriptEntry = CopilotTranscriptSessionStart | CopilotTranscriptAssistantMessage | {
47
+ type: string;
48
+ timestamp?: string;
49
+ data?: unknown;
50
+ };
35
51
  export interface CopilotInfo {
36
52
  model?: string;
37
53
  inputTokens?: number;
@@ -39,14 +55,44 @@ export interface CopilotInfo {
39
55
  cachedInputTokens?: number;
40
56
  toolCallCount?: number | null;
41
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;
42
83
  /**
43
84
  * Build payload from a real VS Code Copilot agent Stop-hook stdin.
44
- * Reads the transcript to aggregate token usage and detect the model.
85
+ *
86
+ * Reads the transcript to count tool calls and extract the session start time.
87
+ * Token usage (input/cached/output) and model name are NOT available in the
88
+ * VS Code Copilot transcript — Copilot uses seat-based billing and does not
89
+ * expose per-call telemetry. These fields are reported as 0/null/"copilot".
45
90
  */
46
- 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;
47
92
  /**
48
93
  * Build payload in legacy mode (no stdin) — used when hook-copilot is invoked
49
94
  * via the old shell-wrapper pattern around the Copilot CLI. Token counts are
50
95
  * not available in this mode (seat-based billing has no per-call telemetry).
51
96
  */
52
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")
@@ -12,25 +15,277 @@ function readTranscript(path) {
12
15
  return JSON.parse(l);
13
16
  }
14
17
  catch {
15
- return {};
18
+ return { type: "__parse_error__" };
16
19
  }
17
20
  });
18
21
  }
19
- function hasToolUse(content) {
20
- return Array.isArray(content) && content.some((b) => b?.type === "tool_use");
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
+ }
249
+ /**
250
+ * Count tool calls from assistant.message entries.
251
+ * Each assistant.message with at least one toolRequest counts as one tool-call turn;
252
+ * we sum the total number of individual tool requests across all turns.
253
+ */
254
+ function countToolCalls(entries) {
255
+ let count = 0;
256
+ for (const e of entries) {
257
+ if (e.type !== "assistant.message")
258
+ continue;
259
+ const msg = e;
260
+ count += msg.data?.toolRequests?.length ?? 0;
261
+ }
262
+ return count;
263
+ }
264
+ /**
265
+ * Extract the earliest timestamp from the transcript.
266
+ * Prefers session.start data.startTime, then falls back to the first entry timestamp.
267
+ */
268
+ function extractStartTime(entries) {
269
+ for (const e of entries) {
270
+ if (e.type === "session.start") {
271
+ const s = e;
272
+ if (s.data?.startTime)
273
+ return s.data.startTime;
274
+ }
275
+ }
276
+ // Fall back to the first entry with a timestamp
277
+ return entries.find((e) => e.timestamp)?.timestamp;
21
278
  }
22
279
  // ─── Payload builders ─────────────────────────────────────────────────────────
23
280
  /**
24
281
  * Build payload from a real VS Code Copilot agent Stop-hook stdin.
25
- * Reads the transcript to aggregate token usage and detect the model.
282
+ *
283
+ * Reads the transcript to count tool calls and extract the session start time.
284
+ * Token usage (input/cached/output) and model name are NOT available in the
285
+ * VS Code Copilot transcript — Copilot uses seat-based billing and does not
286
+ * expose per-call telemetry. These fields are reported as 0/null/"copilot".
26
287
  */
27
- export function buildPayloadFromStdin(stdin, read = readTranscript) {
28
- let inputTokens = 0;
29
- let cachedInputTokens = 0;
30
- let cacheCreationInputTokens = 0;
31
- let outputTokens = 0;
32
- let toolCallCount = 0;
33
- let model = "copilot";
288
+ export function buildPayloadFromStdin(stdin, read = readTranscript, resolveUsage = resolveDebugUsageBySession) {
34
289
  const entries = stdin.transcript_path ? (() => {
35
290
  try {
36
291
  return read(stdin.transcript_path);
@@ -39,36 +294,31 @@ export function buildPayloadFromStdin(stdin, read = readTranscript) {
39
294
  return [];
40
295
  }
41
296
  })() : [];
42
- for (const e of entries) {
43
- if (e.message?.role !== "assistant")
44
- continue;
45
- if (e.message.model)
46
- model = e.message.model;
47
- if (hasToolUse(e.message.content))
48
- toolCallCount += 1;
49
- const u = e.message.usage;
50
- if (u) {
51
- // Prefer snake_case (Claude-compatible), fall back to camelCase (OpenAI-style)
52
- inputTokens += u.input_tokens ?? u.inputTokens ?? 0;
53
- cachedInputTokens += u.cache_read_input_tokens ?? u.cachedInputTokens ?? 0;
54
- cacheCreationInputTokens += u.cache_creation_input_tokens ?? 0;
55
- outputTokens += u.output_tokens ?? u.outputTokens ?? 0;
56
- }
57
- }
58
- const startedAt = entries.find((e) => e.timestamp)?.timestamp ?? stdin.timestamp ?? new Date().toISOString();
297
+ const toolCallCount = countToolCalls(entries);
298
+ const startedAt = extractStartTime(entries) ?? stdin.timestamp ?? new Date().toISOString();
299
+ const usage = resolveUsage(stdin.session_id, stdin.timestamp);
59
300
  return {
60
301
  tool: "copilot",
61
302
  // session_id is optional per VS Code spec; synthesize a fallback if absent
62
303
  sessionId: stdin.session_id ?? `copilot-${Date.now()}-${process.pid}`,
63
304
  gitUsername: resolveGitUsername(stdin.cwd),
64
- model,
65
- inputTokens,
66
- cachedInputTokens,
67
- cacheCreationInputTokens,
68
- outputTokens,
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,
309
+ cacheCreationInputTokens: 0,
310
+ outputTokens: usage?.outputTokens ?? 0,
69
311
  costUsd: null,
70
312
  toolCallCount,
71
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,
72
322
  };
73
323
  }
74
324
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jagit/hook-copilot",
3
- "version": "0.0.2",
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.2"
12
+ "@jagit/agent-reporter": "0.0.4"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/node": "^25.9.3",