copilot-metrics 0.1.2 → 0.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 - 2026-05-31
4
+
5
+ ### Changed
6
+
7
+ - `setup` now performs setup by default: VS Code settings are merged into user settings and Copilot hooks are installed for the selected scope. `--print` keeps the old print-only behavior.
8
+ - `report label <id>` now includes a per-model breakdown by default while `report labels` remains accumulated by label.
9
+ - Human reports rename `Credits`/`Status` to clearer `AI Credits est.` and `Usage status` wording.
10
+
11
+ ### Fixed
12
+
13
+ - `hooks --surface both` now installs both Copilot CLI and VS Code hook event names.
14
+ - Report auto-import skips already imported Copilot session-state files and imported JSONL lines instead of reparsing all historical session files on each report.
15
+ - Existing config files are upgraded with the Copilot session-state source instead of being left stale.
16
+
17
+ ## 0.1.3 - 2026-05-31
18
+
19
+ ### Fixed
20
+
21
+ - Hook install now avoids embedding transient `npx` cache shims, which can break when their shebang points at a removed Node install. Generated hooks use a stable `npx -y copilot-metrics@<version>` package invocation for those cache paths.
22
+
3
23
  ## 0.1.2 - 2026-05-31
4
24
 
5
25
  ### Fixed
package/README.md CHANGED
@@ -9,8 +9,8 @@ Costs are estimates, not official billing records. GitHub billing remains the so
9
9
  From npm:
10
10
 
11
11
  ```bash
12
- npx copilot-metrics@0.1.2 --help
13
- npx copilot-metrics@0.1.2 init
12
+ npx copilot-metrics@0.1.4 --help
13
+ npx copilot-metrics@0.1.4 init
14
14
  ```
15
15
 
16
16
  From this checkout:
@@ -38,27 +38,35 @@ export COPILOT_METRICS_HOME=/path/to/copilot-metrics-data
38
38
  Useful commands:
39
39
 
40
40
  ```bash
41
- npx copilot-metrics@0.1.2 init
42
- npx copilot-metrics@0.1.2 paths --json
41
+ npx copilot-metrics@0.1.4 init
42
+ npx copilot-metrics@0.1.4 paths --json
43
43
  ```
44
44
 
45
+ `init` only creates the central data directory and local config. It does not modify editor or hook settings. `setup` performs integration setup for the current machine/workspace.
46
+
45
47
  ## Configure Telemetry
46
48
 
47
49
  For Copilot CLI, `init` plus hooks are enough for local token reporting. Reports import Copilot's local session-state `events.jsonl` files and extract shutdown usage totals without requiring telemetry environment variables.
48
50
 
49
- Print VS Code Insiders Copilot Chat OpenTelemetry settings:
51
+ Install VS Code Copilot Chat OpenTelemetry settings:
50
52
 
51
53
  ```bash
52
- npx copilot-metrics@0.1.2 setup vscode
54
+ npx copilot-metrics@0.1.4 setup vscode
53
55
  ```
54
56
 
55
- Print Copilot CLI OpenTelemetry environment exports:
57
+ Install Copilot CLI hooks for the current workspace:
56
58
 
57
59
  ```bash
58
- npx copilot-metrics@0.1.2 setup copilot-cli
60
+ npx copilot-metrics@0.1.4 setup copilot-cli
59
61
  ```
60
62
 
61
- This is optional. Use it only when you also want Copilot CLI OTel JSONL output.
63
+ Or set up both VS Code settings and workspace hooks in one command:
64
+
65
+ ```bash
66
+ npx copilot-metrics@0.1.4 setup
67
+ ```
68
+
69
+ Use `setup vscode --print` or `setup copilot-cli --print` to print the settings/optional environment exports without writing files. Copilot CLI OTel exports are optional because CLI token usage is read from local session-state files.
62
70
 
63
71
  Content capture is disabled by default. Do not enable richer prompt capture unless you explicitly accept the privacy tradeoff.
64
72
 
@@ -67,14 +75,14 @@ Content capture is disabled by default. Do not enable richer prompt capture unle
67
75
  Preview repo-local hook config. The default `--surface both` emits the Copilot CLI lower camel case hook format:
68
76
 
69
77
  ```bash
70
- npx copilot-metrics@0.1.2 hooks preview --scope local --surface both
78
+ npx copilot-metrics@0.1.4 hooks preview --scope local --surface both
71
79
  ```
72
80
 
73
81
  Install repo-local or user-global hook config:
74
82
 
75
83
  ```bash
76
- npx copilot-metrics@0.1.2 hooks install --scope local --surface both
77
- npx copilot-metrics@0.1.2 hooks install --scope global --surface both
84
+ npx copilot-metrics@0.1.4 hooks install --scope local --surface both
85
+ npx copilot-metrics@0.1.4 hooks install --scope global --surface both
78
86
  ```
79
87
 
80
88
  Local install writes `.github/hooks/copilot-metrics.json`. Global install updates `~/.copilot/settings.json` idempotently, replacing prior `copilot-metrics` hook entries while preserving other settings and hooks. Use `--surface vscode` for VS Code-only PascalCase events or `--surface copilot-cli` for CLI-native lower camel case events. The hook logger writes redacted JSONL metadata to the central data directory. It extracts Jira-style labels such as `DEMO-12345` from safe metadata and does not store full prompt text by default.
@@ -84,11 +92,11 @@ Local install writes `.github/hooks/copilot-metrics.json`. Global install update
84
92
  Initialize the local SQLite store and import JSONL files manually:
85
93
 
86
94
  ```bash
87
- npx copilot-metrics@0.1.2 store init
88
- npx copilot-metrics@0.1.2 import --source vscode --file ~/.local/share/copilot-metrics/telemetry/vscode-copilot-otel.jsonl
89
- npx copilot-metrics@0.1.2 import --source copilot-cli --file ~/.local/share/copilot-metrics/telemetry/copilot-cli-otel.jsonl
90
- npx copilot-metrics@0.1.2 import --source copilot-session --file ~/.copilot/session-state/<session-id>/events.jsonl
91
- npx copilot-metrics@0.1.2 import --source hooks --file ~/.local/share/copilot-metrics/hooks/copilot-hooks.jsonl
95
+ npx copilot-metrics@0.1.4 store init
96
+ npx copilot-metrics@0.1.4 import --source vscode --file ~/.local/share/copilot-metrics/telemetry/vscode-copilot-otel.jsonl
97
+ npx copilot-metrics@0.1.4 import --source copilot-cli --file ~/.local/share/copilot-metrics/telemetry/copilot-cli-otel.jsonl
98
+ npx copilot-metrics@0.1.4 import --source copilot-session --file ~/.copilot/session-state/<session-id>/events.jsonl
99
+ npx copilot-metrics@0.1.4 import --source hooks --file ~/.local/share/copilot-metrics/hooks/copilot-hooks.jsonl
92
100
  ```
93
101
 
94
102
  Imports persist raw records, normalized LLM usage records, hook events, label evidence, and import warnings. Re-importing the same JSONL rows is idempotent. For Copilot session-state files, only shutdown usage rows are persisted; prompt-bearing session events are used in memory for label extraction and context and are not stored as raw records.
@@ -98,21 +106,25 @@ Imports persist raw records, normalized LLM usage records, hook events, label ev
98
106
  Run local reports from the SQLite store:
99
107
 
100
108
  ```bash
101
- npx copilot-metrics@0.1.2 report labels
102
- npx copilot-metrics@0.1.2 report label DEMO-12345
103
- npx copilot-metrics@0.1.2 report label DEMO-12345 --detail
104
- npx copilot-metrics@0.1.2 report models
105
- npx copilot-metrics@0.1.2 report repos
106
- npx copilot-metrics@0.1.2 report unattributed
109
+ npx copilot-metrics@0.1.4 report labels
110
+ npx copilot-metrics@0.1.4 report label DEMO-12345
111
+ npx copilot-metrics@0.1.4 report label DEMO-12345 --detail
112
+ npx copilot-metrics@0.1.4 report models
113
+ npx copilot-metrics@0.1.4 report repos
114
+ npx copilot-metrics@0.1.4 report unattributed
107
115
  ```
108
116
 
109
117
  Every report supports `--json`:
110
118
 
111
119
  ```bash
112
- npx copilot-metrics@0.1.2 report labels --json
120
+ npx copilot-metrics@0.1.4 report labels --json
113
121
  ```
114
122
 
115
- Report commands automatically import newly appended configured VS Code OTel, optional Copilot CLI OTel, Copilot CLI session-state, and hook JSONL files before querying. Label reports include input, output, cache read, cache creation, and reasoning token totals. Labels seen only in hooks remain visible as hook-only evidence with zero usage records, so attribution hints do not imply token-bearing usage.
123
+ Report commands automatically import newly appended configured VS Code OTel, optional Copilot CLI OTel, Copilot CLI session-state, and hook JSONL files before querying. Repeated reports skip already imported session-state files and already imported JSONL lines.
124
+
125
+ `report labels` shows accumulated totals per label. `report label <id>` shows the selected label summary plus a per-model breakdown by default. Label reports include input, output, cache read, cache creation, and reasoning token totals. Labels seen only in hooks remain visible as `evidence-only` with zero usage records, so attribution hints do not imply token-bearing usage.
126
+
127
+ `AI Credits est.` is a local estimate derived from the pricing table. The project treats 1 AI Credit as $0.01 for estimates; GitHub billing remains the source of truth.
116
128
 
117
129
  ## Attribution Model
118
130
 
@@ -175,7 +187,7 @@ The manual prompt performs one harmless tool call so Copilot CLI hook execution
175
187
  ## Current Limits
176
188
 
177
189
  - Costs are estimates, not official billing records.
178
- - Official GitHub usage report reconciliation is not included in `0.1.2`.
179
- - Local OTLP collector mode is not included in `0.1.2`.
180
- - Richer prompt/content capture and redaction controls are not included in `0.1.2`.
190
+ - Official GitHub usage report reconciliation is not included in `0.1.4`.
191
+ - Local OTLP collector mode is not included in `0.1.4`.
192
+ - Richer prompt/content capture and redaction controls are not included in `0.1.4`.
181
193
  - Dashboard views are deferred until the CLI/query model proves useful.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-metrics",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Local-first Copilot usage telemetry setup and reporting tools.",
5
5
  "type": "commonjs",
6
6
  "homepage": "https://github.com/nnexai/copilot-metrics#readme",
package/src/cli.js CHANGED
@@ -4,6 +4,7 @@ const { resolvePaths } = require('./paths');
4
4
  const {
5
5
  ensureDataDirs,
6
6
  vscodeSettings,
7
+ installVscodeSettings,
7
8
  copilotCliEnvironment,
8
9
  shellExports,
9
10
  hookConfig,
@@ -17,12 +18,13 @@ const { MODEL_PRICES, PRICING_VERSION } = require('./pricing');
17
18
  const {
18
19
  labelOverview,
19
20
  labelSummary,
21
+ labelModelBreakdown,
20
22
  labelDetails,
21
23
  modelReport,
22
24
  repoReport,
23
25
  unattributedReport,
24
26
  formatLabels,
25
- formatLabelSummary,
27
+ formatLabelReport,
26
28
  formatModels,
27
29
  formatRepos,
28
30
  formatUnattributed,
@@ -70,8 +72,9 @@ function helpText() {
70
72
  Usage:
71
73
  copilot-metrics init [--json]
72
74
  copilot-metrics paths [--json]
73
- copilot-metrics setup vscode [--json]
74
- copilot-metrics setup copilot-cli [--json]
75
+ copilot-metrics setup [all] [--scope local|global] [--json]
76
+ copilot-metrics setup vscode [--settings-file <path>] [--json]
77
+ copilot-metrics setup copilot-cli [--scope local|global] [--json]
75
78
  copilot-metrics hooks preview [--scope local|global] [--surface both|vscode|copilot-cli] [--json]
76
79
  copilot-metrics hooks install [--scope local|global] [--surface both|vscode|copilot-cli] [--json]
77
80
  copilot-metrics hook-log --event <name>
@@ -111,6 +114,13 @@ function formatVscode(settings) {
111
114
  ].join('\n');
112
115
  }
113
116
 
117
+ function formatVscodeInstall(results) {
118
+ return [
119
+ `Installed VS Code Copilot telemetry settings: ${results.map((result) => result.target).join(', ')}`,
120
+ 'Content capture is disabled.',
121
+ ].join('\n');
122
+ }
123
+
114
124
  function formatCopilotCli(env) {
115
125
  return [
116
126
  'Export these variables before running Copilot CLI:',
@@ -122,6 +132,13 @@ function formatCopilotCli(env) {
122
132
  ].join('\n');
123
133
  }
124
134
 
135
+ function formatCopilotCliSetup(result) {
136
+ return [
137
+ `Installed ${result.scope} Copilot hook config: ${result.target}`,
138
+ 'Copilot CLI token usage is imported from local session-state; OTel exports are optional.',
139
+ ].join('\n');
140
+ }
141
+
125
142
  function telemetryDiagnostics(importResults) {
126
143
  const hookResult = importResults.find((result) => result.source === 'hooks' && result.raw_records > 0);
127
144
  if (!hookResult) return [];
@@ -189,23 +206,49 @@ async function main(args, io) {
189
206
 
190
207
  if (command === 'setup') {
191
208
  if (subcommand === 'vscode') {
192
- const settings = vscodeSettings(paths);
193
- writeOutput(io.stdout, json ? settings : formatVscode(settings), json);
209
+ ensureDataDirs(paths);
210
+ if (flags.print === true || flags.dryRun === true) {
211
+ const settings = vscodeSettings(paths);
212
+ writeOutput(io.stdout, json ? settings : formatVscode(settings), json);
213
+ return;
214
+ }
215
+ const results = installVscodeSettings(paths, { env: io.env, target: flags.settingsFile });
216
+ writeOutput(io.stdout, json ? { installed: results } : formatVscodeInstall(results), json);
194
217
  return;
195
218
  }
196
219
  if (subcommand === 'copilot-cli') {
197
- const env = copilotCliEnvironment(paths);
198
- writeOutput(io.stdout, json ? env : formatCopilotCli(env), json);
220
+ ensureDataDirs(paths);
221
+ if (flags.print === true || flags.dryRun === true) {
222
+ const env = copilotCliEnvironment(paths);
223
+ writeOutput(io.stdout, json ? env : formatCopilotCli(env), json);
224
+ return;
225
+ }
226
+ const result = installHook(paths, {
227
+ cwd: io.cwd,
228
+ scope: flags.scope || 'local',
229
+ surface: 'copilot-cli',
230
+ command: io.commandPath,
231
+ });
232
+ writeOutput(io.stdout, json ? { installed: result } : formatCopilotCliSetup({ ...result, scope: flags.scope || 'local' }), json);
199
233
  return;
200
234
  }
201
235
  if (!subcommand || subcommand === 'all') {
202
- const snapshot = setupSnapshot({ env: io.env, cwd: io.cwd, home: flags.home, command: io.commandPath });
236
+ const snapshot = setupSnapshot({
237
+ env: io.env,
238
+ cwd: io.cwd,
239
+ home: flags.home,
240
+ command: io.commandPath,
241
+ install: flags.print !== true && flags.dryRun !== true,
242
+ scope: flags.scope || 'local',
243
+ surface: flags.surface || 'both',
244
+ target: flags.settingsFile,
245
+ });
203
246
  writeOutput(io.stdout, json ? snapshot : [
204
247
  formatPaths(snapshot.paths),
205
248
  '',
206
- formatVscode(snapshot.vscode),
249
+ snapshot.vscodeInstalled.length ? formatVscodeInstall(snapshot.vscodeInstalled) : formatVscode(snapshot.vscode),
207
250
  '',
208
- formatCopilotCli(snapshot.copilotCli),
251
+ snapshot.hooksInstalled ? formatCopilotCliSetup({ ...snapshot.hooksInstalled, scope: flags.scope || 'local' }) : formatCopilotCli(snapshot.copilotCli),
209
252
  ].join('\n'), json);
210
253
  return;
211
254
  }
@@ -301,12 +344,13 @@ async function main(args, io) {
301
344
  const label = rest[2];
302
345
  if (!label) throw new Error('report label requires <id>');
303
346
  const summary = await labelSummary(paths.usageDb, label);
347
+ const models = await labelModelBreakdown(paths.usageDb, label);
304
348
  if (flags.detail === true) {
305
349
  const details = await labelDetails(paths.usageDb, label);
306
- writeOutput(io.stdout, json ? { label: summary, details, diagnostics } : appendDiagnostics(formatLabelSummary(summary, details), diagnostics), json);
350
+ writeOutput(io.stdout, json ? { label: summary, models, details, diagnostics } : appendDiagnostics(formatLabelReport(summary, models, details), diagnostics), json);
307
351
  return;
308
352
  }
309
- writeOutput(io.stdout, json ? { label: summary, diagnostics } : appendDiagnostics(formatLabelSummary(summary), diagnostics), json);
353
+ writeOutput(io.stdout, json ? { label: summary, models, diagnostics } : appendDiagnostics(formatLabelReport(summary, models), diagnostics), json);
310
354
  return;
311
355
  }
312
356
  if (subcommand === 'models') {
package/src/ingest.js CHANGED
@@ -6,7 +6,7 @@ const path = require('node:path');
6
6
  const { readJsonl } = require('./jsonl');
7
7
  const { normalizePayload, normalizeHookEvent, normalizeCopilotSessionEvents } = require('./otel');
8
8
  const { estimateCost, PRICING_VERSION } = require('./pricing');
9
- const { existingRawFingerprints, insertImport } = require('./sqlite-store');
9
+ const { existingRawFingerprints, importedLineHighWater, insertImport } = require('./sqlite-store');
10
10
  const { attachUsageLabelEvidence, attachHookLabelEvidence } = require('./labels');
11
11
  const { loadConfiguredExtractors } = require('./label-extractors');
12
12
 
@@ -44,9 +44,26 @@ function isCopilotSessionUsageRecord(record) {
44
44
 
45
45
  async function ingestFile(options) {
46
46
  const { dbPath, file, source } = options;
47
- const parsed = readJsonl(file);
48
- const warnings = [...parsed.warnings];
49
47
  const sourceFile = path.resolve(file);
48
+ const highWaterLine = await importedLineHighWater(dbPath, source, sourceFile);
49
+ if (source === 'copilot-session' && highWaterLine > 0) {
50
+ return {
51
+ source,
52
+ file,
53
+ dbPath,
54
+ raw_records: 0,
55
+ new_raw_records: 0,
56
+ skipped_existing_records: highWaterLine,
57
+ usage_records: 0,
58
+ hook_events: 0,
59
+ label_evidence: 0,
60
+ warnings: [],
61
+ estimate_label: `estimate:${PRICING_VERSION}`,
62
+ };
63
+ }
64
+ const needsSessionContext = source === 'copilot-session' && highWaterLine === 0;
65
+ const parsed = readJsonl(file, { afterLine: needsSessionContext ? 0 : highWaterLine });
66
+ const warnings = [...parsed.warnings];
50
67
  const parsedRecords = parsed.records.map((record) => ({
51
68
  ...record,
52
69
  raw_fingerprint: rawFingerprint(source, sourceFile, record),
@@ -98,7 +115,7 @@ async function ingestFile(options) {
98
115
  dbPath,
99
116
  raw_records: importableRecords.length,
100
117
  new_raw_records: newRecords.length,
101
- skipped_existing_records: importableRecords.length - newRecords.length,
118
+ skipped_existing_records: highWaterLine,
102
119
  usage_records: enrichedUsage.length,
103
120
  hook_events: enrichedHooks.length,
104
121
  label_evidence: enrichedUsage.reduce((sum, usage) => sum + (usage.label_evidence || []).length, 0)
package/src/jsonl.js CHANGED
@@ -2,19 +2,21 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
 
5
- function readJsonl(file) {
5
+ function readJsonl(file, options = {}) {
6
6
  const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
7
7
  const records = [];
8
8
  const warnings = [];
9
+ const afterLine = Number(options.afterLine || 0);
9
10
 
10
11
  text.split(/\r?\n/).forEach((line, index) => {
11
- if (!line.trim()) return;
12
+ const lineNumber = index + 1;
13
+ if (lineNumber <= afterLine || !line.trim()) return;
12
14
  try {
13
- records.push({ line: index + 1, value: JSON.parse(line) });
15
+ records.push({ line: lineNumber, value: JSON.parse(line) });
14
16
  } catch (error) {
15
17
  warnings.push({
16
18
  code: 'malformed_jsonl',
17
- line: index + 1,
19
+ line: lineNumber,
18
20
  message: error.message,
19
21
  });
20
22
  }
package/src/reports.js CHANGED
@@ -19,6 +19,10 @@ function formatCredits(value) {
19
19
  return n(value).toFixed(6);
20
20
  }
21
21
 
22
+ function usageStatus(row) {
23
+ return n(row.usage_records) > 0 ? 'usage' : 'evidence-only';
24
+ }
25
+
22
26
  function table(headers, rows) {
23
27
  const widths = headers.map((header, index) => Math.max(
24
28
  header.length,
@@ -48,6 +52,10 @@ SELECT
48
52
  WHEN (SELECT COUNT(DISTINCT usage_record_id) FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL) = 0 THEN 'hook-only'
49
53
  ELSE 'token-bearing'
50
54
  END AS token_status,
55
+ CASE
56
+ WHEN (SELECT COUNT(DISTINCT usage_record_id) FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL) = 0 THEN 'evidence-only'
57
+ ELSE 'usage'
58
+ END AS usage_status,
51
59
  (SELECT MIN(COALESCE(ur.timestamp, le.timestamp, le.imported_at)) FROM label_evidence le LEFT JOIN usage_records ur ON ur.id = le.usage_record_id WHERE le.label = labels.label) AS first_seen,
52
60
  (SELECT MAX(COALESCE(ur.timestamp, le.timestamp, le.imported_at)) FROM label_evidence le LEFT JOIN usage_records ur ON ur.id = le.usage_record_id WHERE le.label = labels.label) AS last_seen,
53
61
  (SELECT MAX(estimate_label) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)) AS estimate_label
@@ -73,6 +81,10 @@ SELECT
73
81
  WHEN (SELECT COUNT(DISTINCT usage_record_id) FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL) = 0 THEN 'hook-only'
74
82
  ELSE 'token-bearing'
75
83
  END AS token_status,
84
+ CASE
85
+ WHEN (SELECT COUNT(DISTINCT usage_record_id) FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL) = 0 THEN 'evidence-only'
86
+ ELSE 'usage'
87
+ END AS usage_status,
76
88
  (SELECT MIN(COALESCE(ur.timestamp, le.timestamp, le.imported_at)) FROM label_evidence le LEFT JOIN usage_records ur ON ur.id = le.usage_record_id WHERE le.label = labels.label) AS first_seen,
77
89
  (SELECT MAX(COALESCE(ur.timestamp, le.timestamp, le.imported_at)) FROM label_evidence le LEFT JOIN usage_records ur ON ur.id = le.usage_record_id WHERE le.label = labels.label) AS last_seen,
78
90
  (SELECT MAX(estimate_label) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)) AS estimate_label
@@ -80,6 +92,30 @@ FROM (SELECT DISTINCT label FROM label_evidence WHERE label = ?) labels`, [canon
80
92
  return rows[0] || null;
81
93
  }
82
94
 
95
+ async function labelModelBreakdown(dbPath, label) {
96
+ await initStore(dbPath);
97
+ return queryRows(dbPath, `
98
+ SELECT
99
+ COALESCE(ur.resolved_model, ur.requested_model, 'unknown') AS model,
100
+ COUNT(DISTINCT ur.id) AS usage_records,
101
+ COUNT(DISTINCT ur.session_id) AS sessions,
102
+ SUM(ur.input_tokens) AS input_tokens,
103
+ SUM(ur.output_tokens) AS output_tokens,
104
+ SUM(ur.cache_read_tokens) AS cache_read_tokens,
105
+ SUM(ur.cache_creation_tokens) AS cache_creation_tokens,
106
+ SUM(ur.reasoning_tokens) AS reasoning_tokens,
107
+ SUM(COALESCE(ur.estimated_ai_credits, 0)) AS estimated_ai_credits,
108
+ MAX(ur.estimate_label) AS estimate_label
109
+ FROM usage_records ur
110
+ WHERE ur.id IN (
111
+ SELECT DISTINCT usage_record_id
112
+ FROM label_evidence
113
+ WHERE label = ? AND usage_record_id IS NOT NULL
114
+ )
115
+ GROUP BY COALESCE(ur.resolved_model, ur.requested_model, 'unknown')
116
+ ORDER BY estimated_ai_credits DESC, model`, [canonicalLabel(label)]);
117
+ }
118
+
83
119
  async function labelDetails(dbPath, label) {
84
120
  await initStore(dbPath);
85
121
  return queryRows(dbPath, `
@@ -173,7 +209,7 @@ ORDER BY ur.timestamp, ur.id`);
173
209
  function formatLabels(rows) {
174
210
  return [
175
211
  table(
176
- ['Label', 'Sessions', 'Usage', 'Input', 'Output', 'Cache read', 'Cache create', 'Reasoning', 'Credits', 'Status', 'Evidence', 'Last seen'],
212
+ ['Label', 'Sessions', 'Usage', 'Input', 'Output', 'Cache read', 'Cache create', 'Reasoning', 'AI Credits est.', 'Usage status', 'Evidence', 'Last seen'],
177
213
  rows.map((row) => [
178
214
  row.label,
179
215
  row.sessions,
@@ -184,39 +220,57 @@ function formatLabels(rows) {
184
220
  formatNumber(row.cache_creation_tokens),
185
221
  formatNumber(row.reasoning_tokens),
186
222
  formatCredits(row.estimated_ai_credits),
187
- row.token_status,
223
+ usageStatus(row),
188
224
  row.evidence_count,
189
225
  row.last_seen || '',
190
226
  ]),
191
227
  ),
192
228
  '',
193
- `Costs are estimates (${estimateLabel(rows)}).`,
229
+ `AI Credits are estimates (${estimateLabel(rows)}). 1 AI Credit is treated as $0.01 for local estimates.`,
194
230
  ].join('\n');
195
231
  }
196
232
 
197
- function formatLabelSummary(summary, details = null) {
233
+ function formatLabelSummary(summary) {
198
234
  if (!summary) return 'No usage found for label.';
199
- const lines = [
200
- table(
201
- ['Label', 'Sessions', 'Usage', 'Input', 'Output', 'Cache read', 'Cache create', 'Reasoning', 'Credits', 'Status', 'Evidence'],
202
- [[
203
- summary.label,
204
- summary.sessions,
205
- summary.usage_records,
206
- formatNumber(summary.input_tokens),
207
- formatNumber(summary.output_tokens),
208
- formatNumber(summary.cache_read_tokens),
209
- formatNumber(summary.cache_creation_tokens),
210
- formatNumber(summary.reasoning_tokens),
211
- formatCredits(summary.estimated_ai_credits),
212
- summary.token_status,
213
- summary.evidence_count,
214
- ]],
215
- ),
216
- ];
235
+ return table(
236
+ ['Label', 'Sessions', 'Usage', 'Input', 'Output', 'Cache read', 'Cache create', 'Reasoning', 'AI Credits est.', 'Usage status', 'Evidence'],
237
+ [[
238
+ summary.label,
239
+ summary.sessions,
240
+ summary.usage_records,
241
+ formatNumber(summary.input_tokens),
242
+ formatNumber(summary.output_tokens),
243
+ formatNumber(summary.cache_read_tokens),
244
+ formatNumber(summary.cache_creation_tokens),
245
+ formatNumber(summary.reasoning_tokens),
246
+ formatCredits(summary.estimated_ai_credits),
247
+ usageStatus(summary),
248
+ summary.evidence_count,
249
+ ]],
250
+ );
251
+ }
252
+
253
+ function formatLabelReport(summary, models, details = null) {
254
+ const output = [formatLabelSummary(summary)];
255
+ if (models && models.length > 0) {
256
+ output.push('', table(
257
+ ['Model', 'Sessions', 'Usage', 'Input', 'Output', 'Cache read', 'Cache create', 'Reasoning', 'AI Credits est.'],
258
+ models.map((row) => [
259
+ row.model,
260
+ row.sessions,
261
+ row.usage_records,
262
+ formatNumber(row.input_tokens),
263
+ formatNumber(row.output_tokens),
264
+ formatNumber(row.cache_read_tokens),
265
+ formatNumber(row.cache_creation_tokens),
266
+ formatNumber(row.reasoning_tokens),
267
+ formatCredits(row.estimated_ai_credits),
268
+ ]),
269
+ ));
270
+ }
217
271
  if (details) {
218
- lines.push('', table(
219
- ['Source', 'Field', 'Session', 'Model', 'Input', 'Output', 'Cache read', 'Cache create', 'Reasoning', 'Credits', 'Value'],
272
+ output.push('', table(
273
+ ['Source', 'Field', 'Session', 'Model', 'Input', 'Output', 'Cache read', 'Cache create', 'Reasoning', 'AI Credits est.', 'Value'],
220
274
  details.map((row) => [
221
275
  row.source_type,
222
276
  row.source_field,
@@ -232,52 +286,54 @@ function formatLabelSummary(summary, details = null) {
232
286
  ]),
233
287
  ));
234
288
  }
235
- lines.push('', `Costs are estimates (${summary.estimate_label || 'estimate:unknown'}).`);
236
- return lines.join('\n');
289
+ output.push('', `AI Credits are estimates (${summary?.estimate_label || estimateLabel(models || [])}). 1 AI Credit is treated as $0.01 for local estimates.`);
290
+ return output.join('\n');
237
291
  }
238
292
 
239
293
  function formatModels(rows) {
240
294
  return [
241
295
  table(
242
- ['Model', 'Records', 'Input', 'Output', 'Credits'],
296
+ ['Model', 'Records', 'Input', 'Output', 'AI Credits est.'],
243
297
  rows.map((row) => [row.model, row.usage_records, formatNumber(row.input_tokens), formatNumber(row.output_tokens), formatCredits(row.estimated_ai_credits)]),
244
298
  ),
245
299
  '',
246
- `Costs are estimates (${estimateLabel(rows)}).`,
300
+ `AI Credits are estimates (${estimateLabel(rows)}). 1 AI Credit is treated as $0.01 for local estimates.`,
247
301
  ].join('\n');
248
302
  }
249
303
 
250
304
  function formatRepos(rows) {
251
305
  return [
252
306
  table(
253
- ['Repo', 'CWD', 'Sessions', 'Input', 'Output', 'Credits'],
307
+ ['Repo', 'CWD', 'Sessions', 'Input', 'Output', 'AI Credits est.'],
254
308
  rows.map((row) => [row.repo, row.cwd, row.sessions, formatNumber(row.input_tokens), formatNumber(row.output_tokens), formatCredits(row.estimated_ai_credits)]),
255
309
  ),
256
310
  '',
257
- `Costs are estimates (${estimateLabel(rows)}).`,
311
+ `AI Credits are estimates (${estimateLabel(rows)}). 1 AI Credit is treated as $0.01 for local estimates.`,
258
312
  ].join('\n');
259
313
  }
260
314
 
261
315
  function formatUnattributed(rows) {
262
316
  return [
263
317
  table(
264
- ['ID', 'Source', 'Session', 'Repo', 'Branch', 'CWD', 'Model', 'Credits'],
318
+ ['ID', 'Source', 'Session', 'Repo', 'Branch', 'CWD', 'Model', 'AI Credits est.'],
265
319
  rows.map((row) => [row.id, row.source, row.session_id || '', row.repo || '', row.branch || '', row.cwd || '', row.resolved_model || '', formatCredits(row.estimated_ai_credits)]),
266
320
  ),
267
321
  '',
268
- `Costs are estimates (${estimateLabel(rows)}).`,
322
+ `AI Credits are estimates (${estimateLabel(rows)}). 1 AI Credit is treated as $0.01 for local estimates.`,
269
323
  ].join('\n');
270
324
  }
271
325
 
272
326
  module.exports = {
273
327
  labelOverview,
274
328
  labelSummary,
329
+ labelModelBreakdown,
275
330
  labelDetails,
276
331
  modelReport,
277
332
  repoReport,
278
333
  unattributedReport,
279
334
  formatLabels,
280
335
  formatLabelSummary,
336
+ formatLabelReport,
281
337
  formatModels,
282
338
  formatRepos,
283
339
  formatUnattributed,
package/src/setup.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { resolvePaths } = require('./paths');
6
+ const { version: PACKAGE_VERSION } = require('../package.json');
6
7
 
7
8
  const HOOK_SURFACES = ['both', 'copilot-cli', 'vscode'];
8
9
 
@@ -33,6 +34,22 @@ function writePrivateFile(file, data) {
33
34
  fs.writeFileSync(file, data, { mode: 0o600 });
34
35
  }
35
36
 
37
+ function readJsonFile(file) {
38
+ if (!fs.existsSync(file)) return {};
39
+ const text = fs.readFileSync(file, 'utf8');
40
+ try {
41
+ return JSON.parse(text);
42
+ } catch {
43
+ return JSON.parse(stripJsonComments(text).replace(/,\s*([}\]])/g, '$1'));
44
+ }
45
+ }
46
+
47
+ function stripJsonComments(text) {
48
+ return text
49
+ .replace(/\/\*[\s\S]*?\*\//g, '')
50
+ .replace(/(^|[^:])\/\/.*$/gm, '$1');
51
+ }
52
+
36
53
  function shellQuote(value) {
37
54
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
38
55
  }
@@ -42,29 +59,45 @@ function ensureDataDirs(paths) {
42
59
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
43
60
  }
44
61
 
45
- if (!fs.existsSync(paths.configJson)) {
46
- writePrivateFile(paths.configJson, `${JSON.stringify({
47
- version: 1,
48
- dataHome: paths.home,
49
- contentCapture: false,
50
- telemetry: {
51
- vscode: paths.vscodeOtelJsonl,
52
- copilotCli: paths.copilotCliOtelJsonl,
62
+ const defaultConfig = {
63
+ version: 1,
64
+ dataHome: paths.home,
65
+ contentCapture: false,
66
+ telemetry: {
67
+ vscode: paths.vscodeOtelJsonl,
68
+ copilotCli: paths.copilotCliOtelJsonl,
69
+ },
70
+ sources: {
71
+ vscode: {
72
+ telemetry: paths.vscodeOtelJsonl,
73
+ hooks: paths.hookEventsJsonl,
53
74
  },
54
- sources: {
55
- vscode: {
56
- telemetry: paths.vscodeOtelJsonl,
57
- hooks: paths.hookEventsJsonl,
58
- },
59
- copilotCli: {
60
- telemetry: paths.copilotCliOtelJsonl,
61
- hooks: paths.hookEventsJsonl,
62
- sessions: paths.copilotSessionStateDir,
63
- },
75
+ copilotCli: {
76
+ telemetry: paths.copilotCliOtelJsonl,
77
+ hooks: paths.hookEventsJsonl,
78
+ sessions: paths.copilotSessionStateDir,
64
79
  },
65
- labelExtractors: [],
66
- }, null, 2)}\n`);
80
+ },
81
+ labelExtractors: [],
82
+ };
83
+
84
+ if (!fs.existsSync(paths.configJson)) {
85
+ writePrivateFile(paths.configJson, `${JSON.stringify(defaultConfig, null, 2)}\n`);
86
+ return;
67
87
  }
88
+
89
+ const current = readJsonFile(paths.configJson);
90
+ const next = {
91
+ ...defaultConfig,
92
+ ...current,
93
+ telemetry: { ...defaultConfig.telemetry, ...(current.telemetry || {}) },
94
+ sources: {
95
+ vscode: { ...defaultConfig.sources.vscode, ...(current.sources?.vscode || {}) },
96
+ copilotCli: { ...defaultConfig.sources.copilotCli, ...(current.sources?.copilotCli || {}) },
97
+ },
98
+ labelExtractors: current.labelExtractors || defaultConfig.labelExtractors,
99
+ };
100
+ writePrivateFile(paths.configJson, `${JSON.stringify(next, null, 2)}\n`);
68
101
  }
69
102
 
70
103
  function vscodeSettings(paths) {
@@ -76,6 +109,44 @@ function vscodeSettings(paths) {
76
109
  };
77
110
  }
78
111
 
112
+ function defaultVscodeSettingsTargets(options = {}) {
113
+ const env = options.env || process.env;
114
+ const home = env.HOME || process.env.HOME;
115
+ if (!home) return [];
116
+ if (process.platform === 'darwin') {
117
+ return [
118
+ path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
119
+ path.join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'settings.json'),
120
+ ];
121
+ }
122
+ if (process.platform === 'win32') {
123
+ const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
124
+ return [
125
+ path.join(appData, 'Code', 'User', 'settings.json'),
126
+ path.join(appData, 'Code - Insiders', 'User', 'settings.json'),
127
+ ];
128
+ }
129
+ return [
130
+ path.join(home, '.config', 'Code', 'User', 'settings.json'),
131
+ path.join(home, '.config', 'Code - Insiders', 'User', 'settings.json'),
132
+ ];
133
+ }
134
+
135
+ function installVscodeSettings(paths, options = {}) {
136
+ const settings = vscodeSettings(paths);
137
+ const explicitTarget = options.target;
138
+ const candidates = explicitTarget ? [explicitTarget] : defaultVscodeSettingsTargets(options);
139
+ const existingTargets = candidates.filter((target) => fs.existsSync(target));
140
+ const targets = existingTargets.length > 0 ? existingTargets : candidates.slice(0, 1);
141
+ const results = [];
142
+ for (const target of targets) {
143
+ const current = readJsonFile(target);
144
+ writePrivateFile(target, `${JSON.stringify({ ...current, ...settings }, null, 2)}\n`);
145
+ results.push({ target, settings });
146
+ }
147
+ return results;
148
+ }
149
+
79
150
  function copilotCliEnvironment(paths) {
80
151
  return {
81
152
  COPILOT_OTEL_ENABLED: 'true',
@@ -96,12 +167,21 @@ function packageBinCommand(cwd) {
96
167
  }
97
168
 
98
169
  function commandInvocation(command) {
170
+ if (isEphemeralPackageShim(command)) {
171
+ return `npx -y copilot-metrics@${PACKAGE_VERSION}`;
172
+ }
99
173
  const quoted = shellQuote(command);
100
174
  return command.endsWith('.js') ? `node ${quoted}` : quoted;
101
175
  }
102
176
 
177
+ function isEphemeralPackageShim(command) {
178
+ const normalized = String(command || '').replace(/\\/g, '/');
179
+ return normalized.includes('/.npm/_npx/') || normalized.endsWith('/node_modules/.bin/copilot-metrics');
180
+ }
181
+
103
182
  function hookEventsForSurface(surface) {
104
- if (surface === 'copilot-cli' || surface === 'both') return COPILOT_CLI_HOOK_EVENTS;
183
+ if (surface === 'both') return Array.from(new Set([...COPILOT_CLI_HOOK_EVENTS, ...VSCODE_HOOK_EVENTS]));
184
+ if (surface === 'copilot-cli') return COPILOT_CLI_HOOK_EVENTS;
105
185
  if (surface === 'vscode') return VSCODE_HOOK_EVENTS;
106
186
  throw new Error(`Unknown hook surface "${surface}". Use "both", "copilot-cli", or "vscode".`);
107
187
  }
@@ -179,11 +259,20 @@ function installHook(paths, options = {}) {
179
259
  function setupSnapshot(options = {}) {
180
260
  const paths = resolvePaths(options);
181
261
  ensureDataDirs(paths);
262
+ const vscodeInstalled = options.install === true ? installVscodeSettings(paths, options) : [];
263
+ const hooksInstalled = options.install === true ? installHook(paths, {
264
+ cwd: options.cwd,
265
+ scope: options.scope || 'local',
266
+ surface: options.surface || 'both',
267
+ command: options.command,
268
+ }) : null;
182
269
  return {
183
270
  paths,
184
271
  vscode: vscodeSettings(paths),
272
+ vscodeInstalled,
185
273
  copilotCli: copilotCliEnvironment(paths),
186
274
  hooks: hookConfig(paths, options),
275
+ hooksInstalled,
187
276
  };
188
277
  }
189
278
 
@@ -192,7 +281,9 @@ module.exports = {
192
281
  COPILOT_CLI_HOOK_EVENTS,
193
282
  VSCODE_HOOK_EVENTS,
194
283
  ensureDataDirs,
284
+ defaultVscodeSettingsTargets,
195
285
  vscodeSettings,
286
+ installVscodeSettings,
196
287
  copilotCliEnvironment,
197
288
  shellExports,
198
289
  shellQuote,
@@ -183,6 +183,16 @@ async function existingRawFingerprints(dbPath, source, sourceFile, fingerprints)
183
183
  return existing;
184
184
  }
185
185
 
186
+ async function importedLineHighWater(dbPath, source, sourceFile) {
187
+ await initStore(dbPath);
188
+ const rows = await queryRows(
189
+ dbPath,
190
+ 'SELECT COALESCE(MAX(line), 0) AS line FROM raw_records WHERE source = ? AND source_file = ?',
191
+ [source, sourceFile],
192
+ );
193
+ return Number(rows[0]?.line || 0);
194
+ }
195
+
186
196
  async function insertImport(dbPath, source, sourceFile, rawRecords, usageRecords, hookEvents, warnings) {
187
197
  await initStore(dbPath);
188
198
  const db = await openDatabase(dbPath);
@@ -320,6 +330,7 @@ async function queryRows(dbPath, sql, params = []) {
320
330
 
321
331
  module.exports = {
322
332
  existingRawFingerprints,
333
+ importedLineHighWater,
323
334
  initStore,
324
335
  insertImport,
325
336
  queryOne,