copilot-usage-studio 0.2.0 → 0.2.1

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +42 -13
  3. package/data/github-copilot-pricing.json +34 -6
  4. package/dist/copilot-usage-studio/browser/chunk-2SAR2Q6M.js +1 -0
  5. package/dist/copilot-usage-studio/browser/chunk-5JYQJM5G.js +1 -0
  6. package/dist/copilot-usage-studio/browser/chunk-7FKLWMFH.js +1 -0
  7. package/dist/copilot-usage-studio/browser/chunk-EWN3YOHT.js +1 -0
  8. package/dist/copilot-usage-studio/browser/{chunk-NXH37FKU.js → chunk-KS5W2HMH.js} +1 -1
  9. package/dist/copilot-usage-studio/browser/{chunk-TVSYR63W.js → chunk-OS2XPCVW.js} +2 -2
  10. package/dist/copilot-usage-studio/browser/{chunk-QQOFM3HF.js → chunk-U4H425LY.js} +1 -1
  11. package/dist/copilot-usage-studio/browser/chunk-VEKXIM47.js +1 -0
  12. package/dist/copilot-usage-studio/browser/chunk-WOIYAEKB.js +4 -0
  13. package/dist/copilot-usage-studio/browser/{chunk-J47KKACI.js → chunk-XHID4XVE.js} +1 -1
  14. package/dist/copilot-usage-studio/browser/index.html +2 -2
  15. package/dist/copilot-usage-studio/browser/main-OZIMQRXC.js +1 -0
  16. package/dist/copilot-usage-studio/browser/styles-7RDDQXQV.css +1 -0
  17. package/docs/local-deployment.md +67 -11
  18. package/docs/pricing.md +3 -3
  19. package/docs/scanner-api.md +28 -0
  20. package/lib/cli.mjs +15 -1
  21. package/lib/local-runtime.mjs +455 -19
  22. package/lib/scanner-worker.mjs +25 -0
  23. package/package.json +17 -1
  24. package/scripts/pricing-utils.mjs +5 -0
  25. package/scripts/scan-vscode-sessions.mjs +383 -1475
  26. package/scripts/scanner-customization-evidence.mjs +576 -0
  27. package/scripts/scanner-customization-inventory.mjs +1021 -0
  28. package/scripts/scanner-memory.mjs +229 -0
  29. package/scripts/scanner-session-parser.mjs +1237 -0
  30. package/scripts/scanner-traversal.mjs +203 -0
  31. package/scripts/scanner-workspace.mjs +209 -0
  32. package/dist/copilot-usage-studio/browser/chunk-CUKTUQ6W.js +0 -1
  33. package/dist/copilot-usage-studio/browser/chunk-EYAHOEVS.js +0 -1
  34. package/dist/copilot-usage-studio/browser/chunk-FXNLFSUB.js +0 -1
  35. package/dist/copilot-usage-studio/browser/chunk-HJNYITFH.js +0 -1
  36. package/dist/copilot-usage-studio/browser/chunk-KDAJN6DF.js +0 -4
  37. package/dist/copilot-usage-studio/browser/main-TL27ZSEQ.js +0 -1
  38. package/dist/copilot-usage-studio/browser/styles-GZOQ5QIH.css +0 -1
@@ -1,9 +1,18 @@
1
- import { spawn } from 'node:child_process';
2
- import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
1
+ import { fork, spawn } from 'node:child_process';
2
+ import {
3
+ appendFileSync,
4
+ createReadStream,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
3
11
  import { createServer } from 'node:http';
4
12
  import { dirname, extname, join, normalize, resolve, sep } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
5
14
 
6
- import { scanVsCodeSessions, writeSessionData } from './scanner-api.mjs';
15
+ import { writeSessionData } from './scanner-api.mjs';
7
16
 
8
17
  const contentTypes = new Map([
9
18
  ['.css', 'text/css; charset=utf-8'],
@@ -19,6 +28,8 @@ const contentTypes = new Map([
19
28
  ['.woff2', 'font/woff2'],
20
29
  ]);
21
30
 
31
+ const scannerWorkerPath = fileURLToPath(new URL('./scanner-worker.mjs', import.meta.url));
32
+
22
33
  export function createLocalRuntime(options = {}) {
23
34
  const host = options.host ?? '127.0.0.1';
24
35
  const port = Number(options.port ?? 4312);
@@ -28,11 +39,17 @@ export function createLocalRuntime(options = {}) {
28
39
  : resolve(options.seedDataFile ?? 'public/data/sessions.json');
29
40
  const staticDir = resolve(options.staticDir ?? 'dist/copilot-usage-studio/browser');
30
41
  const scanOptions = options.scanOptions ?? {};
31
- const scanner = options.scanner ?? scanVsCodeSessions;
42
+ const scanner = options.scanner ?? scanInChildProcess;
32
43
  const writer = options.writer ?? writeSessionData;
33
44
  const openPath = options.openPath ?? openLocalPath;
34
45
  const logger = options.logger ?? console;
35
46
  const backendOnly = options.backendOnly === true;
47
+ const firstDataWaitMs = Math.max(0, Number(options.firstDataWaitMs ?? 5000));
48
+ const logFile = options.logFile === null ? '' : resolve(options.logFile ?? join(dirname(dataFile), 'runtime.log'));
49
+ const logEntries = [];
50
+ const progressHistory = [];
51
+ const workspaceProgress = new Map();
52
+ let scanProgress = null;
36
53
 
37
54
  let sessionData = readCachedSessionData(dataFile, logger)
38
55
  ?? (seedDataFile ? readCachedSessionData(seedDataFile, logger) : null);
@@ -42,6 +59,10 @@ export function createLocalRuntime(options = {}) {
42
59
  let lastScanCompletedAt = '';
43
60
  let lastScanDurationMs = 0;
44
61
  let activeScan = null;
62
+ let activeScanController = null;
63
+ let activeScanId = 0;
64
+ let activeScanMode = '';
65
+ let scanSequence = 0;
45
66
 
46
67
  function status() {
47
68
  return {
@@ -55,55 +76,154 @@ export function createLocalRuntime(options = {}) {
55
76
  lastScanCompletedAt,
56
77
  lastScanDurationMs,
57
78
  lastError,
79
+ activeScanId,
80
+ activeScanMode,
81
+ scanProgress,
82
+ progressHistory: progressHistory.slice(-40),
83
+ workspaceProgress: [...workspaceProgress.values()],
84
+ logFile,
85
+ recentLogs: logEntries.slice(-12),
58
86
  };
59
87
  }
60
88
 
61
- function refresh(reason = 'manual') {
89
+ function diagnosticsReport() {
90
+ return {
91
+ status: status(),
92
+ dataFile,
93
+ staticDir,
94
+ seedDataFile,
95
+ logFile,
96
+ scanOptions: summarizeScanOptions(scanOptions),
97
+ progressHistory,
98
+ workspaceProgress: [...workspaceProgress.values()],
99
+ logs: logEntries,
100
+ };
101
+ }
102
+
103
+ function refresh(reason = 'manual', refreshOptions = {}) {
62
104
  if (activeScan) {
105
+ emitLog('log', `Scan already running; reusing active ${reason} request.`);
63
106
  return activeScan;
64
107
  }
65
108
 
109
+ const scanMode = normalizeScanMode(refreshOptions.mode ?? 'quick');
110
+ const effectiveScanOptions = scanOptionsForMode(scanMode, scanOptions);
66
111
  const started = Date.now();
112
+ const scanId = ++scanSequence;
113
+ activeScanId = scanId;
114
+ activeScanMode = scanMode;
115
+ activeScanController = new AbortController();
67
116
  lastScanStartedAt = new Date(started).toISOString();
68
117
  lastError = '';
69
118
  phase = 'scanning';
119
+ progressHistory.length = 0;
120
+ workspaceProgress.clear();
121
+ scanProgress = rememberProgress({
122
+ stage: 'starting',
123
+ message: `Starting scan #${scanId} (${reason}).`,
124
+ }, scanId, reason, lastScanStartedAt);
125
+ emitLog('log', `Starting local data refresh #${scanId} (${reason}).`);
70
126
 
71
127
  activeScan = Promise.resolve()
72
- .then(() => scanner(scanOptions))
128
+ .then(() =>
129
+ scanner({
130
+ ...effectiveScanOptions,
131
+ signal: activeScanController.signal,
132
+ onProgress: (progress) => {
133
+ scanProgress = rememberProgress(progress, scanId, reason);
134
+ if (progress?.message) {
135
+ emitLog('log', progress.message);
136
+ }
137
+ },
138
+ }),
139
+ )
73
140
  .then((nextSessionData) => {
74
- writer(nextSessionData, dataFile);
75
- sessionData = nextSessionData;
141
+ const finalSessionData = sessionData
142
+ ? scanMode === 'customizations'
143
+ ? mergeCustomizationScan(sessionData, nextSessionData)
144
+ : preserveCustomizationSnapshot(sessionData, nextSessionData)
145
+ : nextSessionData;
146
+ writer(finalSessionData, dataFile);
147
+ sessionData = finalSessionData;
76
148
  phase = 'ready';
77
149
  lastScanCompletedAt = new Date().toISOString();
78
150
  lastScanDurationMs = Date.now() - started;
79
- logger.log(
80
- `Local data refresh (${reason}) imported ${nextSessionData.sessions.length} sessions in ${lastScanDurationMs}ms.`,
151
+ scanProgress = rememberProgress({
152
+ stage: 'complete',
153
+ message: scanMode === 'customizations'
154
+ ? `Customization usage check complete.`
155
+ : `Scan #${scanId} imported ${finalSessionData.sessions.length} sessions.`,
156
+ sessions: finalSessionData.sessions.length,
157
+ }, scanId, reason, lastScanCompletedAt);
158
+ emitLog(
159
+ 'log',
160
+ scanMode === 'customizations'
161
+ ? `Customization usage check #${scanId} (${reason}) completed in ${lastScanDurationMs}ms.`
162
+ : `Local data refresh #${scanId} (${reason}) imported ${finalSessionData.sessions.length} sessions in ${lastScanDurationMs}ms.`,
81
163
  );
82
- return nextSessionData;
164
+ return finalSessionData;
83
165
  })
84
166
  .catch((error) => {
85
- lastError = error instanceof Error ? error.message : String(error);
86
- phase = sessionData ? 'ready' : 'error';
167
+ const message = error instanceof Error ? error.message : String(error);
168
+ const stopped = isScanStoppedError(error);
169
+ lastError = stopped ? 'Scan stopped by user.' : message;
170
+ phase = stopped ? (sessionData ? 'ready' : 'stopped') : (sessionData ? 'ready' : 'error');
87
171
  lastScanCompletedAt = new Date().toISOString();
88
172
  lastScanDurationMs = Date.now() - started;
89
- logger.error(`Local data refresh failed: ${lastError}`);
173
+ scanProgress = rememberProgress({
174
+ stage: stopped ? 'stopped' : 'failed',
175
+ message: stopped ? 'Scan stopped. Existing data was kept.' : lastError,
176
+ }, scanId, reason, lastScanCompletedAt);
177
+ emitLog(stopped ? 'warn' : 'error', `Local data refresh #${scanId} (${reason}) ${stopped ? 'stopped' : 'failed'}: ${lastError}`);
178
+ if (!stopped && error instanceof Error && error.stack) {
179
+ emitLog('error', error.stack);
180
+ }
90
181
  throw error;
91
182
  })
92
183
  .finally(() => {
93
184
  activeScan = null;
185
+ activeScanController = null;
186
+ activeScanId = 0;
187
+ activeScanMode = '';
94
188
  });
95
189
 
96
190
  return activeScan;
97
191
  }
98
192
 
193
+ function cancelScan() {
194
+ if (!activeScan || !activeScanController) {
195
+ return false;
196
+ }
197
+ activeScanController.abort();
198
+ emitLog('warn', 'Local data refresh was stopped by the user.');
199
+ return true;
200
+ }
201
+
99
202
  const server = createServer(async (request, response) => {
100
203
  try {
101
204
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `${host}:${port}`}`);
102
205
 
206
+ if (request.method === 'OPTIONS') {
207
+ response.writeHead(204, corsHeaders());
208
+ response.end();
209
+ return;
210
+ }
211
+
103
212
  if (request.method === 'GET' && url.pathname === '/api/status') {
104
213
  return sendJson(response, 200, status());
105
214
  }
106
215
 
216
+ if (request.method === 'GET' && url.pathname === '/api/diagnostics') {
217
+ return sendJson(response, 200, diagnosticsReport());
218
+ }
219
+
220
+ if (request.method === 'GET' && url.pathname === '/api/logs') {
221
+ return sendJson(response, 200, {
222
+ logFile,
223
+ entries: logEntries,
224
+ });
225
+ }
226
+
107
227
  if (request.method === 'GET' && url.pathname === '/api/sessions') {
108
228
  await waitForFirstData();
109
229
  return sessionData
@@ -120,13 +240,18 @@ export function createLocalRuntime(options = {}) {
120
240
 
121
241
  if (request.method === 'POST' && url.pathname === '/api/scan') {
122
242
  try {
123
- const nextSessionData = await refresh('manual');
243
+ const body = await readJsonBody(request);
244
+ const nextSessionData = await refresh('manual', body);
124
245
  return sendJson(response, 200, { sessionData: nextSessionData, status: status() });
125
246
  } catch {
126
247
  return sendJson(response, 500, { error: lastError, status: status() });
127
248
  }
128
249
  }
129
250
 
251
+ if (request.method === 'POST' && url.pathname === '/api/scan/cancel') {
252
+ return sendJson(response, 200, { canceled: cancelScan(), status: status() });
253
+ }
254
+
130
255
  const memoryAction = url.pathname.match(/^\/api\/memories\/([a-f0-9]{24})\/open$/);
131
256
  if (request.method === 'POST' && memoryAction) {
132
257
  const memory = sessionData?.memories?.find((candidate) => candidate.id === memoryAction[1]);
@@ -146,7 +271,7 @@ export function createLocalRuntime(options = {}) {
146
271
 
147
272
  return serveStaticFile(request, response, staticDir, url.pathname);
148
273
  } catch (error) {
149
- logger.error(error);
274
+ emitLog('error', error instanceof Error ? (error.stack ?? error.message) : String(error));
150
275
  return sendJson(response, 500, { error: 'Local runtime request failed.' });
151
276
  }
152
277
  });
@@ -161,31 +286,332 @@ export function createLocalRuntime(options = {}) {
161
286
  server.listen(port, host, () => {
162
287
  server.off('error', rejectListen);
163
288
  const address = server.address();
164
- logger.log(
289
+ emitLog(
290
+ 'log',
165
291
  backendOnly
166
292
  ? `Copilot Usage Studio backend API: http://${host}:${address.port}/ (used by the dev server)`
167
293
  : `Copilot Usage Studio local app: http://${host}:${address.port}/`,
168
294
  );
295
+ if (logFile) {
296
+ emitLog('log', `Runtime log file: ${logFile}`);
297
+ }
169
298
  resolveListen(address);
170
299
 
171
300
  if (options.scanOnStart !== false) {
172
- void refresh('startup').catch(() => {});
301
+ void refresh('startup', { mode: options.startupScanMode ?? 'quick' }).catch(() => {});
173
302
  }
174
303
  });
175
304
  }),
176
305
  refresh,
306
+ cancelScan,
177
307
  status,
308
+ diagnostics: diagnosticsReport,
178
309
  };
179
310
 
180
311
  async function waitForFirstData() {
181
312
  if (!sessionData && activeScan) {
182
313
  try {
183
- await activeScan;
314
+ await waitForActiveScanOrTimeout(activeScan, firstDataWaitMs);
184
315
  } catch {
185
316
  // The response below reports the retained empty/error state.
186
317
  }
187
318
  }
188
319
  }
320
+
321
+ function emitLog(level, message) {
322
+ const entry = {
323
+ at: new Date().toISOString(),
324
+ level,
325
+ message: String(message),
326
+ };
327
+ logEntries.push(entry);
328
+ if (logEntries.length > 200) {
329
+ logEntries.splice(0, logEntries.length - 200);
330
+ }
331
+
332
+ if (logFile) {
333
+ try {
334
+ mkdirSync(dirname(logFile), { recursive: true });
335
+ if (!existsSync(logFile)) {
336
+ writeFileSync(logFile, '', 'utf8');
337
+ }
338
+ appendFileSync(logFile, `${entry.at} ${entry.level.toUpperCase()} ${entry.message}\n`, 'utf8');
339
+ } catch {
340
+ // Logging must never prevent the local runtime from starting.
341
+ }
342
+ }
343
+
344
+ const sink = level === 'error' ? logger.error : level === 'warn' ? logger.warn : logger.log;
345
+ if (typeof sink === 'function') {
346
+ sink.call(logger, entry.message);
347
+ }
348
+ }
349
+
350
+ function rememberProgress(progress = {}, scanId, reason, updatedAt = new Date().toISOString()) {
351
+ const event = {
352
+ ...progress,
353
+ scanId,
354
+ reason,
355
+ updatedAt,
356
+ };
357
+ progressHistory.push(event);
358
+ if (progressHistory.length > 200) {
359
+ progressHistory.splice(0, progressHistory.length - 200);
360
+ }
361
+ if (event.workspace || event.workspaceDir) {
362
+ const key = event.workspaceDir || event.workspace;
363
+ const existing = workspaceProgress.get(key) ?? {};
364
+ workspaceProgress.set(key, {
365
+ ...existing,
366
+ workspace: event.workspace ?? existing.workspace ?? '',
367
+ workspaceDir: event.workspaceDir ?? existing.workspaceDir ?? '',
368
+ workspaceIndex: event.workspaceIndex ?? existing.workspaceIndex ?? null,
369
+ workspaceTotal: event.workspaceTotal ?? existing.workspaceTotal ?? null,
370
+ lastStage: event.stage ?? existing.lastStage ?? '',
371
+ message: event.message ?? existing.message ?? '',
372
+ elapsedMs: event.elapsedMs ?? existing.elapsedMs ?? 0,
373
+ updatedAt,
374
+ debugLogFolders: event.debugLogFolders ?? existing.debugLogFolders ?? null,
375
+ chatSnapshots: event.chatSnapshots ?? existing.chatSnapshots ?? null,
376
+ hasMemoryRoot: event.hasMemoryRoot ?? existing.hasMemoryRoot ?? null,
377
+ customizationInventory: event.customizationInventory ?? existing.customizationInventory ?? null,
378
+ total: event.total ?? existing.total ?? null,
379
+ index: event.index ?? existing.index ?? null,
380
+ sessions: event.sessions ?? existing.sessions ?? null,
381
+ memories: event.memories ?? existing.memories ?? null,
382
+ customizations: event.customizations ?? existing.customizations ?? null,
383
+ completed: event.stage === 'workspace-complete' || existing.completed === true,
384
+ });
385
+ }
386
+ return event;
387
+ }
388
+ }
389
+
390
+ function summarizeScanOptions(scanOptions = {}) {
391
+ return {
392
+ roots: Array.isArray(scanOptions.roots) ? scanOptions.roots.map(String) : [],
393
+ customizationWorkspaceFolders: Array.isArray(scanOptions.customizationWorkspaceFolders)
394
+ ? scanOptions.customizationWorkspaceFolders.map(String)
395
+ : [],
396
+ includeCustomizations: scanOptions.includeCustomizations !== false,
397
+ sqlite: scanOptions.sqlite !== false,
398
+ };
399
+ }
400
+
401
+ function normalizeScanMode(mode) {
402
+ return ['quick', 'full', 'customizations'].includes(String(mode)) ? String(mode) : 'quick';
403
+ }
404
+
405
+ function scanOptionsForMode(mode, baseScanOptions = {}) {
406
+ if (mode === 'full' || mode === 'customizations') {
407
+ const { customizationWorkspaceFolders, ...scanOptions } = baseScanOptions;
408
+ if (mode === 'customizations' && Array.isArray(customizationWorkspaceFolders)) {
409
+ return {
410
+ ...scanOptions,
411
+ includeCustomizations: true,
412
+ requireWorkspaceFolders: true,
413
+ workspaceFolders: customizationWorkspaceFolders,
414
+ customizationEvidence: {
415
+ maxSessions: 0,
416
+ maxModelCalls: 0,
417
+ maxElapsedMs: 0,
418
+ maxPartChars: 250_000,
419
+ ...(scanOptions.customizationEvidence ?? {}),
420
+ },
421
+ };
422
+ }
423
+
424
+ return {
425
+ ...scanOptions,
426
+ includeCustomizations: true,
427
+ customizationEvidence: {
428
+ maxSessions: 60,
429
+ maxModelCalls: 500,
430
+ maxElapsedMs: 90_000,
431
+ maxPartChars: 250_000,
432
+ ...(scanOptions.customizationEvidence ?? {}),
433
+ },
434
+ };
435
+ }
436
+
437
+ return {
438
+ ...baseScanOptions,
439
+ includeCustomizations: false,
440
+ };
441
+ }
442
+
443
+ function mergeCustomizationScan(previousData, currentWorkspaceData) {
444
+ const scannedWorkspaces = new Set(
445
+ (currentWorkspaceData.ingestion?.workspaceScans ?? [])
446
+ .map((scan) => scan.workspace)
447
+ .filter(Boolean),
448
+ );
449
+ const sessionsById = new Map((previousData.sessions ?? []).map((session) => [session.id, session]));
450
+ for (const session of currentWorkspaceData.sessions ?? []) {
451
+ sessionsById.set(session.id, session);
452
+ }
453
+ const memoriesById = new Map((previousData.memories ?? []).map((memory) => [memory.id, memory]));
454
+ for (const memory of currentWorkspaceData.memories ?? []) {
455
+ memoriesById.set(memory.id, memory);
456
+ }
457
+ const retainedCustomizations = (previousData.customizations ?? []).filter(
458
+ (customization) => !scannedWorkspaces.has(customization.workspace),
459
+ );
460
+ const customizationsById = new Map(retainedCustomizations.map((customization) => [customization.id, customization]));
461
+ for (const customization of currentWorkspaceData.customizations ?? []) {
462
+ customizationsById.set(customization.id, customization);
463
+ }
464
+ const sessions = [...sessionsById.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
465
+ const memories = [...memoriesById.values()].sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
466
+ const customizations = [...customizationsById.values()].sort((a, b) =>
467
+ b.modifiedAt.localeCompare(a.modifiedAt),
468
+ );
469
+
470
+ return {
471
+ ...previousData,
472
+ generatedAt: currentWorkspaceData.generatedAt,
473
+ ingestion: {
474
+ ...previousData.ingestion,
475
+ ...currentWorkspaceData.ingestion,
476
+ importedSessions: sessions.length,
477
+ importedCustomizations: customizations.length,
478
+ },
479
+ sessions,
480
+ memories,
481
+ customizations,
482
+ };
483
+ }
484
+
485
+ function preserveCustomizationSnapshot(previousData, nextData) {
486
+ const hasPreviousCustomizations = Array.isArray(previousData.customizations) && previousData.customizations.length > 0;
487
+ const nextHasCustomizations = Array.isArray(nextData.customizations) && nextData.customizations.length > 0;
488
+ if (!hasPreviousCustomizations || nextHasCustomizations) {
489
+ return nextData;
490
+ }
491
+
492
+ return {
493
+ ...nextData,
494
+ ingestion: {
495
+ ...nextData.ingestion,
496
+ scannedCustomizationLocations:
497
+ nextData.ingestion?.scannedCustomizationLocations ??
498
+ previousData.ingestion?.scannedCustomizationLocations,
499
+ importedCustomizations:
500
+ nextData.ingestion?.importedCustomizations ??
501
+ previousData.ingestion?.importedCustomizations,
502
+ customizationEvidenceScannedSessions:
503
+ nextData.ingestion?.customizationEvidenceScannedSessions ??
504
+ previousData.ingestion?.customizationEvidenceScannedSessions,
505
+ customizationEvidenceModelCalls:
506
+ nextData.ingestion?.customizationEvidenceModelCalls ??
507
+ previousData.ingestion?.customizationEvidenceModelCalls,
508
+ customizationEvidenceTextParts:
509
+ nextData.ingestion?.customizationEvidenceTextParts ??
510
+ previousData.ingestion?.customizationEvidenceTextParts,
511
+ customizationEvidenceMatchedCustomizations:
512
+ nextData.ingestion?.customizationEvidenceMatchedCustomizations ??
513
+ previousData.ingestion?.customizationEvidenceMatchedCustomizations,
514
+ },
515
+ customizations: previousData.customizations,
516
+ };
517
+ }
518
+
519
+ function waitForActiveScanOrTimeout(activeScan, timeoutMs) {
520
+ if (!timeoutMs) {
521
+ return Promise.resolve();
522
+ }
523
+
524
+ return Promise.race([
525
+ activeScan,
526
+ new Promise((resolveTimeout) => setTimeout(resolveTimeout, timeoutMs)),
527
+ ]);
528
+ }
529
+
530
+ function isScanStoppedError(error) {
531
+ return /scan stopped by user/i.test(error instanceof Error ? error.message : String(error));
532
+ }
533
+
534
+ function scanInChildProcess(options = {}) {
535
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
536
+ const signal = options.signal;
537
+ const scanOptions = { ...options };
538
+ delete scanOptions.onProgress;
539
+ delete scanOptions.signal;
540
+
541
+ return new Promise((resolveScan, rejectScan) => {
542
+ const child = fork(scannerWorkerPath, {
543
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
544
+ windowsHide: true,
545
+ });
546
+ let settled = false;
547
+ let stderr = '';
548
+
549
+ const abort = () => {
550
+ if (settled) {
551
+ return;
552
+ }
553
+ settled = true;
554
+ child.kill();
555
+ rejectScan(new Error('Scan stopped by user.'));
556
+ };
557
+
558
+ if (signal?.aborted) {
559
+ abort();
560
+ return;
561
+ }
562
+ signal?.addEventListener?.('abort', abort, { once: true });
563
+
564
+ child.stderr?.on('data', (chunk) => {
565
+ stderr = `${stderr}${chunk.toString()}`.slice(-8000);
566
+ });
567
+
568
+ child.on('message', (message) => {
569
+ if (!message || typeof message !== 'object') {
570
+ return;
571
+ }
572
+ if (message.type === 'progress') {
573
+ onProgress(message.event ?? {});
574
+ return;
575
+ }
576
+ if (message.type === 'result') {
577
+ settled = true;
578
+ signal?.removeEventListener?.('abort', abort);
579
+ resolveScan(message.sessionData);
580
+ child.kill();
581
+ return;
582
+ }
583
+ if (message.type === 'error') {
584
+ settled = true;
585
+ signal?.removeEventListener?.('abort', abort);
586
+ const error = new Error(message.error?.message ?? 'Scanner worker failed.');
587
+ error.stack = message.error?.stack || error.stack;
588
+ rejectScan(error);
589
+ child.kill();
590
+ }
591
+ });
592
+
593
+ child.once('error', (error) => {
594
+ if (!settled) {
595
+ settled = true;
596
+ signal?.removeEventListener?.('abort', abort);
597
+ rejectScan(error);
598
+ }
599
+ });
600
+
601
+ child.once('exit', (code, exitSignal) => {
602
+ if (!settled) {
603
+ settled = true;
604
+ options.signal?.removeEventListener?.('abort', abort);
605
+ rejectScan(
606
+ new Error(
607
+ `Scanner worker exited before returning data (${exitSignal ?? `code ${code}`}).${stderr ? `\n${stderr}` : ''}`,
608
+ ),
609
+ );
610
+ }
611
+ });
612
+
613
+ child.send({ type: 'scan', options: scanOptions });
614
+ });
189
615
  }
190
616
 
191
617
  function readJsonBody(request) {
@@ -259,6 +685,7 @@ function readCachedSessionData(dataFile, logger) {
259
685
  function sendJson(response, statusCode, body) {
260
686
  const payload = JSON.stringify(body);
261
687
  response.writeHead(statusCode, {
688
+ ...corsHeaders(),
262
689
  'cache-control': 'no-store',
263
690
  'content-length': Buffer.byteLength(payload),
264
691
  'content-type': 'application/json; charset=utf-8',
@@ -266,6 +693,15 @@ function sendJson(response, statusCode, body) {
266
693
  response.end(payload);
267
694
  }
268
695
 
696
+ function corsHeaders() {
697
+ return {
698
+ 'access-control-allow-headers': 'content-type',
699
+ 'access-control-allow-methods': 'GET,POST,OPTIONS',
700
+ 'access-control-allow-origin': '*',
701
+ 'access-control-max-age': '600',
702
+ };
703
+ }
704
+
269
705
  function serveStaticFile(request, response, staticDir, pathname) {
270
706
  if (!existsSync(staticDir)) {
271
707
  return sendJson(response, 404, {
@@ -0,0 +1,25 @@
1
+ import { scanVsCodeSessions } from './scanner-api.mjs';
2
+
3
+ process.on('message', async (message) => {
4
+ if (!message || typeof message !== 'object' || message.type !== 'scan') {
5
+ return;
6
+ }
7
+
8
+ try {
9
+ const sessionData = await scanVsCodeSessions({
10
+ ...(message.options ?? {}),
11
+ onProgress: (event) => {
12
+ process.send?.({ type: 'progress', event });
13
+ },
14
+ });
15
+ process.send?.({ type: 'result', sessionData });
16
+ } catch (error) {
17
+ process.send?.({
18
+ type: 'error',
19
+ error: {
20
+ message: error instanceof Error ? error.message : String(error),
21
+ stack: error instanceof Error ? error.stack : '',
22
+ },
23
+ });
24
+ }
25
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-usage-studio",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Local VS Code GitHub Copilot usage, cost, and session insights.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,12 @@
17
17
  "docs/scanner-api.md",
18
18
  "lib/",
19
19
  "scripts/pricing-utils.mjs",
20
+ "scripts/scanner-customization-evidence.mjs",
21
+ "scripts/scanner-customization-inventory.mjs",
22
+ "scripts/scanner-memory.mjs",
23
+ "scripts/scanner-session-parser.mjs",
24
+ "scripts/scanner-traversal.mjs",
25
+ "scripts/scanner-workspace.mjs",
20
26
  "scripts/scan-vscode-sessions.mjs"
21
27
  ],
22
28
  "scripts": {
@@ -38,7 +44,14 @@
38
44
  "verify:data": "node scripts/verify-session-data.mjs",
39
45
  "schema:audit": "node scripts/audit-vscode-schema.mjs",
40
46
  "schema:accept": "node scripts/audit-vscode-schema.mjs --accept",
47
+ "vscode:typecheck": "tsc -p vscode-extension/tsconfig.json --noEmit",
48
+ "vscode:compile": "npm run vscode:typecheck && node scripts/build-vscode-extension.mjs --extension-only",
49
+ "vscode:build": "npm run build && npm run vscode:typecheck && node scripts/build-vscode-extension.mjs",
50
+ "vscode:verify": "npm run vscode:build && node scripts/verify-vscode-extension.mjs",
51
+ "vscode:vsix:dry-run": "npm run vscode:verify && cd vscode-extension && vsce ls --tree",
52
+ "vscode:package": "npm run vscode:verify && node scripts/package-vscode-extension.mjs",
41
53
  "verify:package": "node scripts/verify-package.mjs",
54
+ "release:notes": "node scripts/generate-release-notes.mjs",
42
55
  "release:check": "npm run test:scripts && npm test -- --watch=false && npm run build && npm run verify:package",
43
56
  "prepack": "npm run build"
44
57
  },
@@ -77,6 +90,9 @@
77
90
  "@angular/forms": "^21.2.0",
78
91
  "@angular/platform-browser": "^21.2.0",
79
92
  "@angular/router": "^21.2.0",
93
+ "@types/node": "^26.0.0",
94
+ "@types/vscode": "^1.125.0",
95
+ "@vscode/vsce": "^3.9.2",
80
96
  "jsdom": "^28.0.0",
81
97
  "prettier": "^3.8.1",
82
98
  "rxjs": "~7.8.0",
@@ -13,10 +13,15 @@ export function normalizeModel(model, pricing) {
13
13
  .trim();
14
14
  const key = modelKey(raw);
15
15
  const knownModels = Object.keys(pricing);
16
+ const knownAliases = knownModels.flatMap((name) =>
17
+ (pricing[name].aliases ?? []).map((alias) => ({ name, aliasKey: modelKey(alias) })),
18
+ );
16
19
 
17
20
  return (
18
21
  knownModels.find((name) => modelKey(name) === key) ??
22
+ knownAliases.find(({ aliasKey }) => aliasKey === key)?.name ??
19
23
  knownModels.find((name) => key.includes(modelKey(name))) ??
24
+ knownAliases.find(({ aliasKey }) => key.includes(aliasKey))?.name ??
20
25
  (raw || 'Unknown model')
21
26
  );
22
27
  }