copilot-metrics 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -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
- if (!fs.existsSync(paths.configJson)) {
47
- writePrivateFile(paths.configJson, `${JSON.stringify({
48
- version: 1,
49
- dataHome: paths.home,
50
- contentCapture: false,
51
- telemetry: {
52
- vscode: paths.vscodeOtelJsonl,
53
- 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,
54
74
  },
55
- sources: {
56
- vscode: {
57
- telemetry: paths.vscodeOtelJsonl,
58
- hooks: paths.hookEventsJsonl,
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
- labelExtractors: [],
67
- }, 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;
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 === '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;
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,
@@ -141,6 +141,7 @@ function lastInsertId(db) {
141
141
  }
142
142
 
143
143
  function insertLabelEvidence(db, importedAt, evidenceRows) {
144
+ if (!evidenceRows.length) return;
144
145
  runPrepared(
145
146
  db,
146
147
  `INSERT INTO label_evidence (
@@ -183,6 +184,16 @@ async function existingRawFingerprints(dbPath, source, sourceFile, fingerprints)
183
184
  return existing;
184
185
  }
185
186
 
187
+ async function importedLineHighWater(dbPath, source, sourceFile) {
188
+ await initStore(dbPath);
189
+ const rows = await queryRows(
190
+ dbPath,
191
+ 'SELECT COALESCE(MAX(line), 0) AS line FROM raw_records WHERE source = ? AND source_file = ?',
192
+ [source, sourceFile],
193
+ );
194
+ return Number(rows[0]?.line || 0);
195
+ }
196
+
186
197
  async function insertImport(dbPath, source, sourceFile, rawRecords, usageRecords, hookEvents, warnings) {
187
198
  await initStore(dbPath);
188
199
  const db = await openDatabase(dbPath);
@@ -273,6 +284,7 @@ async function insertImport(dbPath, source, sourceFile, rawRecords, usageRecords
273
284
  repo: event.repo,
274
285
  branch: event.branch,
275
286
  cwd: event.cwd,
287
+ timestamp: event.timestamp,
276
288
  });
277
289
  }
278
290
  }
@@ -297,6 +309,188 @@ async function insertImport(dbPath, source, sourceFile, rawRecords, usageRecords
297
309
  persistDatabase(dbPath, db);
298
310
  }
299
311
 
312
+ async function attachVscodeChatLabelEvidence(dbPath, mappings) {
313
+ await initStore(dbPath);
314
+ if (!mappings.length) {
315
+ return { matched_usage_records: 0, label_evidence: 0 };
316
+ }
317
+
318
+ const db = await openDatabase(dbPath);
319
+ const importedAt = new Date().toISOString();
320
+ let matchedUsageRecords = 0;
321
+ let labelEvidence = 0;
322
+
323
+ const usageStatement = db.prepare(`
324
+ SELECT id, session_id, repo, branch, cwd, timestamp
325
+ FROM usage_records
326
+ WHERE source = 'vscode' AND span_id = ?
327
+ `);
328
+ const existingStatement = db.prepare(`
329
+ SELECT 1
330
+ FROM label_evidence
331
+ WHERE label = ?
332
+ AND source_type = 'usage'
333
+ AND source_field = 'vscode_chat_response'
334
+ AND source_value = ?
335
+ AND usage_record_id = ?
336
+ LIMIT 1
337
+ `);
338
+ const insertStatement = db.prepare(`
339
+ INSERT INTO label_evidence (
340
+ imported_at, label, source_type, source_field, source_value, confidence,
341
+ usage_record_id, hook_event_id, session_id, repo, branch, cwd, timestamp
342
+ ) VALUES (?, ?, 'usage', 'vscode_chat_response', ?, ?, ?, NULL, ?, ?, ?, ?, ?)
343
+ `);
344
+
345
+ db.run('BEGIN');
346
+ try {
347
+ for (const mapping of mappings) {
348
+ usageStatement.bind([mapping.responseId]);
349
+ const usageRows = [];
350
+ while (usageStatement.step()) usageRows.push(usageStatement.getAsObject());
351
+ usageStatement.reset();
352
+ matchedUsageRecords += usageRows.length;
353
+
354
+ for (const usage of usageRows) {
355
+ for (const evidence of mapping.label_evidence || []) {
356
+ existingStatement.bind([evidence.label, mapping.responseId, usage.id]);
357
+ const exists = existingStatement.step();
358
+ existingStatement.reset();
359
+ if (exists) continue;
360
+
361
+ insertStatement.run([
362
+ importedAt,
363
+ evidence.label,
364
+ mapping.responseId,
365
+ evidence.confidence || 0.95,
366
+ usage.id,
367
+ mapping.sessionId || usage.session_id || null,
368
+ usage.repo || null,
369
+ usage.branch || null,
370
+ usage.cwd || null,
371
+ usage.timestamp || null,
372
+ ]);
373
+ labelEvidence += 1;
374
+ }
375
+ }
376
+ }
377
+ db.run('COMMIT');
378
+ } catch (error) {
379
+ db.run('ROLLBACK');
380
+ throw error;
381
+ } finally {
382
+ usageStatement.free();
383
+ existingStatement.free();
384
+ insertStatement.free();
385
+ }
386
+
387
+ persistDatabase(dbPath, db);
388
+ return { matched_usage_records: matchedUsageRecords, label_evidence: labelEvidence };
389
+ }
390
+
391
+ async function vscodeRawRecordsNeedingResponseBackfill(dbPath, sourceFile) {
392
+ await initStore(dbPath);
393
+ return queryRows(dbPath, `
394
+ SELECT rr.line, rr.payload_json
395
+ FROM raw_records rr
396
+ WHERE rr.source = 'vscode'
397
+ AND rr.source_file = ?
398
+ AND EXISTS (
399
+ SELECT 1
400
+ FROM usage_records ur
401
+ WHERE ur.source = 'vscode'
402
+ AND ur.raw_line = rr.line
403
+ AND ur.span_id IS NULL
404
+ )
405
+ `, [sourceFile]);
406
+ }
407
+
408
+ async function updateVscodeUsageResponseIds(dbPath, updates) {
409
+ await initStore(dbPath);
410
+ if (!updates.length) return 0;
411
+
412
+ const db = await openDatabase(dbPath);
413
+ const statement = db.prepare(`
414
+ UPDATE usage_records
415
+ SET span_id = ?,
416
+ session_id = COALESCE(session_id, ?),
417
+ timestamp = COALESCE(timestamp, ?)
418
+ WHERE source = 'vscode'
419
+ AND raw_line = ?
420
+ AND span_id IS NULL
421
+ AND input_tokens = ?
422
+ AND output_tokens = ?
423
+ AND cache_read_tokens = ?
424
+ AND cache_creation_tokens = ?
425
+ AND reasoning_tokens = ?
426
+ AND COALESCE(resolved_model, requested_model, '') = COALESCE(?, '')
427
+ `);
428
+ let updated = 0;
429
+ db.run('BEGIN');
430
+ try {
431
+ for (const update of updates) {
432
+ statement.run([
433
+ update.span_id,
434
+ update.session_id || null,
435
+ update.timestamp || null,
436
+ update.raw_line,
437
+ update.input_tokens,
438
+ update.output_tokens,
439
+ update.cache_read_tokens,
440
+ update.cache_creation_tokens,
441
+ update.reasoning_tokens,
442
+ update.resolved_model || update.requested_model || '',
443
+ ]);
444
+ updated += typeof db.getRowsModified === 'function' ? db.getRowsModified() : 0;
445
+ }
446
+ db.run('COMMIT');
447
+ } catch (error) {
448
+ db.run('ROLLBACK');
449
+ throw error;
450
+ } finally {
451
+ statement.free();
452
+ }
453
+
454
+ persistDatabase(dbPath, db);
455
+ return updated;
456
+ }
457
+
458
+ async function updateUsageCostEstimates(dbPath, updates) {
459
+ await initStore(dbPath);
460
+ if (!updates.length) return 0;
461
+
462
+ const db = await openDatabase(dbPath);
463
+ const statement = db.prepare(`
464
+ UPDATE usage_records
465
+ SET estimated_usd = ?,
466
+ estimated_ai_credits = ?,
467
+ warnings_json = ?
468
+ WHERE id = ?
469
+ `);
470
+ let updated = 0;
471
+ db.run('BEGIN');
472
+ try {
473
+ for (const update of updates) {
474
+ statement.run([
475
+ update.estimated_usd,
476
+ update.estimated_ai_credits,
477
+ JSON.stringify(update.warnings || []),
478
+ update.id,
479
+ ]);
480
+ updated += typeof db.getRowsModified === 'function' ? db.getRowsModified() : 0;
481
+ }
482
+ db.run('COMMIT');
483
+ } catch (error) {
484
+ db.run('ROLLBACK');
485
+ throw error;
486
+ } finally {
487
+ statement.free();
488
+ }
489
+
490
+ persistDatabase(dbPath, db);
491
+ return updated;
492
+ }
493
+
300
494
  async function queryOne(dbPath, sql) {
301
495
  const db = await openDatabase(dbPath);
302
496
  const result = db.exec(sql);
@@ -319,9 +513,14 @@ async function queryRows(dbPath, sql, params = []) {
319
513
  }
320
514
 
321
515
  module.exports = {
516
+ attachVscodeChatLabelEvidence,
322
517
  existingRawFingerprints,
518
+ importedLineHighWater,
323
519
  initStore,
324
520
  insertImport,
325
521
  queryOne,
326
522
  queryRows,
523
+ updateUsageCostEstimates,
524
+ updateVscodeUsageResponseIds,
525
+ vscodeRawRecordsNeedingResponseBackfill,
327
526
  };