copilot-metrics 0.1.3 → 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 +14 -0
- package/README.md +40 -28
- package/package.json +1 -1
- package/src/cli.js +56 -12
- package/src/ingest.js +21 -4
- package/src/jsonl.js +6 -4
- package/src/reports.js +88 -32
- package/src/setup.js +103 -21
- package/src/sqlite-store.js +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.1.3 - 2026-05-31
|
|
4
18
|
|
|
5
19
|
### 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.
|
|
13
|
-
npx copilot-metrics@0.1.
|
|
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.
|
|
42
|
-
npx copilot-metrics@0.1.
|
|
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
|
-
|
|
51
|
+
Install VS Code Copilot Chat OpenTelemetry settings:
|
|
50
52
|
|
|
51
53
|
```bash
|
|
52
|
-
npx copilot-metrics@0.1.
|
|
54
|
+
npx copilot-metrics@0.1.4 setup vscode
|
|
53
55
|
```
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
Install Copilot CLI hooks for the current workspace:
|
|
56
58
|
|
|
57
59
|
```bash
|
|
58
|
-
npx copilot-metrics@0.1.
|
|
60
|
+
npx copilot-metrics@0.1.4 setup copilot-cli
|
|
59
61
|
```
|
|
60
62
|
|
|
61
|
-
|
|
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.
|
|
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.
|
|
77
|
-
npx copilot-metrics@0.1.
|
|
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.
|
|
88
|
-
npx copilot-metrics@0.1.
|
|
89
|
-
npx copilot-metrics@0.1.
|
|
90
|
-
npx copilot-metrics@0.1.
|
|
91
|
-
npx copilot-metrics@0.1.
|
|
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.
|
|
102
|
-
npx copilot-metrics@0.1.
|
|
103
|
-
npx copilot-metrics@0.1.
|
|
104
|
-
npx copilot-metrics@0.1.
|
|
105
|
-
npx copilot-metrics@0.1.
|
|
106
|
-
npx copilot-metrics@0.1.
|
|
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.
|
|
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.
|
|
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.
|
|
179
|
-
- Local OTLP collector mode is not included in `0.1.
|
|
180
|
-
- Richer prompt/content capture and redaction controls are not included in `0.1.
|
|
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
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
|
-
|
|
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
|
|
74
|
-
copilot-metrics setup
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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({
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
12
|
+
const lineNumber = index + 1;
|
|
13
|
+
if (lineNumber <= afterLine || !line.trim()) return;
|
|
12
14
|
try {
|
|
13
|
-
records.push({ 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:
|
|
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', '
|
|
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
|
|
223
|
+
usageStatus(row),
|
|
188
224
|
row.evidence_count,
|
|
189
225
|
row.last_seen || '',
|
|
190
226
|
]),
|
|
191
227
|
),
|
|
192
228
|
'',
|
|
193
|
-
`
|
|
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
|
|
233
|
+
function formatLabelSummary(summary) {
|
|
198
234
|
if (!summary) return 'No usage found for label.';
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
return
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
@@ -34,6 +34,22 @@ function writePrivateFile(file, data) {
|
|
|
34
34
|
fs.writeFileSync(file, data, { mode: 0o600 });
|
|
35
35
|
}
|
|
36
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
|
+
|
|
37
53
|
function shellQuote(value) {
|
|
38
54
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
39
55
|
}
|
|
@@ -43,29 +59,45 @@ function ensureDataDirs(paths) {
|
|
|
43
59
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
44
60
|
}
|
|
45
61
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
54
74
|
},
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
},
|
|
60
|
-
copilotCli: {
|
|
61
|
-
telemetry: paths.copilotCliOtelJsonl,
|
|
62
|
-
hooks: paths.hookEventsJsonl,
|
|
63
|
-
sessions: paths.copilotSessionStateDir,
|
|
64
|
-
},
|
|
75
|
+
copilotCli: {
|
|
76
|
+
telemetry: paths.copilotCliOtelJsonl,
|
|
77
|
+
hooks: paths.hookEventsJsonl,
|
|
78
|
+
sessions: paths.copilotSessionStateDir,
|
|
65
79
|
},
|
|
66
|
-
|
|
67
|
-
|
|
80
|
+
},
|
|
81
|
+
labelExtractors: [],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (!fs.existsSync(paths.configJson)) {
|
|
85
|
+
writePrivateFile(paths.configJson, `${JSON.stringify(defaultConfig, null, 2)}\n`);
|
|
86
|
+
return;
|
|
68
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`);
|
|
69
101
|
}
|
|
70
102
|
|
|
71
103
|
function vscodeSettings(paths) {
|
|
@@ -77,6 +109,44 @@ function vscodeSettings(paths) {
|
|
|
77
109
|
};
|
|
78
110
|
}
|
|
79
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
|
+
|
|
80
150
|
function copilotCliEnvironment(paths) {
|
|
81
151
|
return {
|
|
82
152
|
COPILOT_OTEL_ENABLED: 'true',
|
|
@@ -110,7 +180,8 @@ function isEphemeralPackageShim(command) {
|
|
|
110
180
|
}
|
|
111
181
|
|
|
112
182
|
function hookEventsForSurface(surface) {
|
|
113
|
-
if (surface === '
|
|
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;
|
|
114
185
|
if (surface === 'vscode') return VSCODE_HOOK_EVENTS;
|
|
115
186
|
throw new Error(`Unknown hook surface "${surface}". Use "both", "copilot-cli", or "vscode".`);
|
|
116
187
|
}
|
|
@@ -188,11 +259,20 @@ function installHook(paths, options = {}) {
|
|
|
188
259
|
function setupSnapshot(options = {}) {
|
|
189
260
|
const paths = resolvePaths(options);
|
|
190
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;
|
|
191
269
|
return {
|
|
192
270
|
paths,
|
|
193
271
|
vscode: vscodeSettings(paths),
|
|
272
|
+
vscodeInstalled,
|
|
194
273
|
copilotCli: copilotCliEnvironment(paths),
|
|
195
274
|
hooks: hookConfig(paths, options),
|
|
275
|
+
hooksInstalled,
|
|
196
276
|
};
|
|
197
277
|
}
|
|
198
278
|
|
|
@@ -201,7 +281,9 @@ module.exports = {
|
|
|
201
281
|
COPILOT_CLI_HOOK_EVENTS,
|
|
202
282
|
VSCODE_HOOK_EVENTS,
|
|
203
283
|
ensureDataDirs,
|
|
284
|
+
defaultVscodeSettingsTargets,
|
|
204
285
|
vscodeSettings,
|
|
286
|
+
installVscodeSettings,
|
|
205
287
|
copilotCliEnvironment,
|
|
206
288
|
shellExports,
|
|
207
289
|
shellQuote,
|
package/src/sqlite-store.js
CHANGED
|
@@ -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,
|