copilot-usage-studio 0.2.0 → 0.2.2

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 +30 -2
  2. package/README.md +46 -13
  3. package/data/github-copilot-pricing.json +34 -6
  4. package/dist/copilot-usage-studio/browser/chunk-23NLHIOR.js +1 -0
  5. package/dist/copilot-usage-studio/browser/chunk-5JYQJM5G.js +1 -0
  6. package/dist/copilot-usage-studio/browser/chunk-7I2HDBC6.js +1 -0
  7. package/dist/copilot-usage-studio/browser/{chunk-TVSYR63W.js → chunk-OS2XPCVW.js} +2 -2
  8. package/dist/copilot-usage-studio/browser/{chunk-J47KKACI.js → chunk-QMNOUQ2X.js} +1 -1
  9. package/dist/copilot-usage-studio/browser/{chunk-NXH37FKU.js → chunk-RTOXDOOI.js} +1 -1
  10. package/dist/copilot-usage-studio/browser/chunk-TICIZRSM.js +1 -0
  11. package/dist/copilot-usage-studio/browser/chunk-WOIYAEKB.js +4 -0
  12. package/dist/copilot-usage-studio/browser/{chunk-QQOFM3HF.js → chunk-WWU6QMJC.js} +1 -1
  13. package/dist/copilot-usage-studio/browser/chunk-XMVGLQQH.js +1 -0
  14. package/dist/copilot-usage-studio/browser/index.html +2 -2
  15. package/dist/copilot-usage-studio/browser/main-XFPP45VE.js +1 -0
  16. package/dist/copilot-usage-studio/browser/styles-7RDDQXQV.css +1 -0
  17. package/docs/local-deployment.md +92 -17
  18. package/docs/pricing.md +5 -3
  19. package/docs/scanner-api.md +40 -0
  20. package/lib/cli.mjs +15 -1
  21. package/lib/local-runtime.mjs +499 -19
  22. package/lib/scanner-worker.mjs +25 -0
  23. package/package.json +20 -2
  24. package/scripts/pricing-utils.mjs +5 -0
  25. package/scripts/scan-vscode-sessions.mjs +387 -1475
  26. package/scripts/scanner-customization-evidence.mjs +613 -0
  27. package/scripts/scanner-customization-inventory.mjs +1022 -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 +237 -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,11 @@ 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 lastScanMode = '';
66
+ let scanSequence = 0;
45
67
 
46
68
  function status() {
47
69
  return {
@@ -55,55 +77,155 @@ export function createLocalRuntime(options = {}) {
55
77
  lastScanCompletedAt,
56
78
  lastScanDurationMs,
57
79
  lastError,
80
+ activeScanId,
81
+ activeScanMode,
82
+ lastScanMode,
83
+ scanProgress,
84
+ progressHistory: progressHistory.slice(-40),
85
+ workspaceProgress: [...workspaceProgress.values()],
86
+ logFile,
87
+ recentLogs: logEntries.slice(-12),
88
+ };
89
+ }
90
+
91
+ function diagnosticsReport() {
92
+ return {
93
+ status: status(),
94
+ dataFile,
95
+ staticDir,
96
+ seedDataFile,
97
+ logFile,
98
+ scanOptions: summarizeScanOptions(scanOptions),
99
+ progressHistory,
100
+ workspaceProgress: [...workspaceProgress.values()],
101
+ logs: logEntries,
58
102
  };
59
103
  }
60
104
 
61
- function refresh(reason = 'manual') {
105
+ function refresh(reason = 'manual', refreshOptions = {}) {
62
106
  if (activeScan) {
107
+ emitLog('log', `Scan already running; reusing active ${reason} request.`);
63
108
  return activeScan;
64
109
  }
65
110
 
111
+ const scanMode = normalizeScanMode(refreshOptions.mode ?? 'quick');
112
+ const effectiveScanOptions = scanOptionsForMode(scanMode, scanOptions, sessionData);
66
113
  const started = Date.now();
114
+ const scanId = ++scanSequence;
115
+ activeScanId = scanId;
116
+ activeScanMode = scanMode;
117
+ activeScanController = new AbortController();
67
118
  lastScanStartedAt = new Date(started).toISOString();
68
119
  lastError = '';
69
120
  phase = 'scanning';
121
+ progressHistory.length = 0;
122
+ workspaceProgress.clear();
123
+ scanProgress = rememberProgress({
124
+ stage: 'starting',
125
+ message: `Starting scan #${scanId} (${reason}).`,
126
+ }, scanId, reason, lastScanStartedAt);
127
+ emitLog('log', `Starting local data refresh #${scanId} (${reason}).`);
70
128
 
71
129
  activeScan = Promise.resolve()
72
- .then(() => scanner(scanOptions))
130
+ .then(() =>
131
+ scanner({
132
+ ...effectiveScanOptions,
133
+ signal: activeScanController.signal,
134
+ onProgress: (progress) => {
135
+ scanProgress = rememberProgress(progress, scanId, reason);
136
+ if (progress?.message) {
137
+ emitLog('log', progress.message);
138
+ }
139
+ },
140
+ }),
141
+ )
73
142
  .then((nextSessionData) => {
74
- writer(nextSessionData, dataFile);
75
- sessionData = nextSessionData;
143
+ const changedSessionCount = nextSessionData.sessions?.length ?? 0;
144
+ const finalSessionData = sessionData
145
+ ? scanMode === 'customizations'
146
+ ? mergeCustomizationScan(sessionData, nextSessionData)
147
+ : scanMode === 'quick'
148
+ ? mergeIncrementalScan(sessionData, nextSessionData)
149
+ : preserveCustomizationSnapshot(sessionData, nextSessionData)
150
+ : nextSessionData;
151
+ writer(finalSessionData, dataFile);
152
+ sessionData = finalSessionData;
76
153
  phase = 'ready';
77
154
  lastScanCompletedAt = new Date().toISOString();
78
155
  lastScanDurationMs = Date.now() - started;
79
- logger.log(
80
- `Local data refresh (${reason}) imported ${nextSessionData.sessions.length} sessions in ${lastScanDurationMs}ms.`,
156
+ lastScanMode = scanMode;
157
+ scanProgress = rememberProgress({
158
+ stage: 'complete',
159
+ message: scanResultMessage(scanMode, changedSessionCount, finalSessionData.sessions.length),
160
+ sessions: finalSessionData.sessions.length,
161
+ }, scanId, reason, lastScanCompletedAt);
162
+ emitLog(
163
+ 'log',
164
+ `${scanResultMessage(scanMode, changedSessionCount, finalSessionData.sessions.length)} (${lastScanDurationMs}ms).`,
81
165
  );
82
- return nextSessionData;
166
+ return finalSessionData;
83
167
  })
84
168
  .catch((error) => {
85
- lastError = error instanceof Error ? error.message : String(error);
86
- phase = sessionData ? 'ready' : 'error';
169
+ const message = error instanceof Error ? error.message : String(error);
170
+ const stopped = isScanStoppedError(error);
171
+ lastError = stopped ? 'Scan stopped by user.' : message;
172
+ phase = stopped ? (sessionData ? 'ready' : 'stopped') : (sessionData ? 'ready' : 'error');
87
173
  lastScanCompletedAt = new Date().toISOString();
88
174
  lastScanDurationMs = Date.now() - started;
89
- logger.error(`Local data refresh failed: ${lastError}`);
175
+ scanProgress = rememberProgress({
176
+ stage: stopped ? 'stopped' : 'failed',
177
+ message: stopped ? 'Scan stopped. Existing data was kept.' : lastError,
178
+ }, scanId, reason, lastScanCompletedAt);
179
+ emitLog(stopped ? 'warn' : 'error', `Local data refresh #${scanId} (${reason}) ${stopped ? 'stopped' : 'failed'}: ${lastError}`);
180
+ if (!stopped && error instanceof Error && error.stack) {
181
+ emitLog('error', error.stack);
182
+ }
90
183
  throw error;
91
184
  })
92
185
  .finally(() => {
93
186
  activeScan = null;
187
+ activeScanController = null;
188
+ activeScanId = 0;
189
+ activeScanMode = '';
94
190
  });
95
191
 
96
192
  return activeScan;
97
193
  }
98
194
 
195
+ function cancelScan() {
196
+ if (!activeScan || !activeScanController) {
197
+ return false;
198
+ }
199
+ activeScanController.abort();
200
+ emitLog('warn', 'Local data refresh was stopped by the user.');
201
+ return true;
202
+ }
203
+
99
204
  const server = createServer(async (request, response) => {
100
205
  try {
101
206
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `${host}:${port}`}`);
102
207
 
208
+ if (request.method === 'OPTIONS') {
209
+ response.writeHead(204, corsHeaders());
210
+ response.end();
211
+ return;
212
+ }
213
+
103
214
  if (request.method === 'GET' && url.pathname === '/api/status') {
104
215
  return sendJson(response, 200, status());
105
216
  }
106
217
 
218
+ if (request.method === 'GET' && url.pathname === '/api/diagnostics') {
219
+ return sendJson(response, 200, diagnosticsReport());
220
+ }
221
+
222
+ if (request.method === 'GET' && url.pathname === '/api/logs') {
223
+ return sendJson(response, 200, {
224
+ logFile,
225
+ entries: logEntries,
226
+ });
227
+ }
228
+
107
229
  if (request.method === 'GET' && url.pathname === '/api/sessions') {
108
230
  await waitForFirstData();
109
231
  return sessionData
@@ -120,13 +242,18 @@ export function createLocalRuntime(options = {}) {
120
242
 
121
243
  if (request.method === 'POST' && url.pathname === '/api/scan') {
122
244
  try {
123
- const nextSessionData = await refresh('manual');
245
+ const body = await readJsonBody(request);
246
+ const nextSessionData = await refresh('manual', body);
124
247
  return sendJson(response, 200, { sessionData: nextSessionData, status: status() });
125
248
  } catch {
126
249
  return sendJson(response, 500, { error: lastError, status: status() });
127
250
  }
128
251
  }
129
252
 
253
+ if (request.method === 'POST' && url.pathname === '/api/scan/cancel') {
254
+ return sendJson(response, 200, { canceled: cancelScan(), status: status() });
255
+ }
256
+
130
257
  const memoryAction = url.pathname.match(/^\/api\/memories\/([a-f0-9]{24})\/open$/);
131
258
  if (request.method === 'POST' && memoryAction) {
132
259
  const memory = sessionData?.memories?.find((candidate) => candidate.id === memoryAction[1]);
@@ -146,7 +273,7 @@ export function createLocalRuntime(options = {}) {
146
273
 
147
274
  return serveStaticFile(request, response, staticDir, url.pathname);
148
275
  } catch (error) {
149
- logger.error(error);
276
+ emitLog('error', error instanceof Error ? (error.stack ?? error.message) : String(error));
150
277
  return sendJson(response, 500, { error: 'Local runtime request failed.' });
151
278
  }
152
279
  });
@@ -161,31 +288,374 @@ export function createLocalRuntime(options = {}) {
161
288
  server.listen(port, host, () => {
162
289
  server.off('error', rejectListen);
163
290
  const address = server.address();
164
- logger.log(
291
+ emitLog(
292
+ 'log',
165
293
  backendOnly
166
294
  ? `Copilot Usage Studio backend API: http://${host}:${address.port}/ (used by the dev server)`
167
295
  : `Copilot Usage Studio local app: http://${host}:${address.port}/`,
168
296
  );
297
+ if (logFile) {
298
+ emitLog('log', `Runtime log file: ${logFile}`);
299
+ }
169
300
  resolveListen(address);
170
301
 
171
302
  if (options.scanOnStart !== false) {
172
- void refresh('startup').catch(() => {});
303
+ void refresh('startup', { mode: options.startupScanMode ?? 'quick' }).catch(() => {});
173
304
  }
174
305
  });
175
306
  }),
176
307
  refresh,
308
+ cancelScan,
177
309
  status,
310
+ diagnostics: diagnosticsReport,
178
311
  };
179
312
 
180
313
  async function waitForFirstData() {
181
314
  if (!sessionData && activeScan) {
182
315
  try {
183
- await activeScan;
316
+ await waitForActiveScanOrTimeout(activeScan, firstDataWaitMs);
184
317
  } catch {
185
318
  // The response below reports the retained empty/error state.
186
319
  }
187
320
  }
188
321
  }
322
+
323
+ function emitLog(level, message) {
324
+ const entry = {
325
+ at: new Date().toISOString(),
326
+ level,
327
+ message: String(message),
328
+ };
329
+ logEntries.push(entry);
330
+ if (logEntries.length > 200) {
331
+ logEntries.splice(0, logEntries.length - 200);
332
+ }
333
+
334
+ if (logFile) {
335
+ try {
336
+ mkdirSync(dirname(logFile), { recursive: true });
337
+ if (!existsSync(logFile)) {
338
+ writeFileSync(logFile, '', 'utf8');
339
+ }
340
+ appendFileSync(logFile, `${entry.at} ${entry.level.toUpperCase()} ${entry.message}\n`, 'utf8');
341
+ } catch {
342
+ // Logging must never prevent the local runtime from starting.
343
+ }
344
+ }
345
+
346
+ const sink = level === 'error' ? logger.error : level === 'warn' ? logger.warn : logger.log;
347
+ if (typeof sink === 'function') {
348
+ sink.call(logger, entry.message);
349
+ }
350
+ }
351
+
352
+ function rememberProgress(progress = {}, scanId, reason, updatedAt = new Date().toISOString()) {
353
+ const event = {
354
+ ...progress,
355
+ scanId,
356
+ reason,
357
+ updatedAt,
358
+ };
359
+ progressHistory.push(event);
360
+ if (progressHistory.length > 200) {
361
+ progressHistory.splice(0, progressHistory.length - 200);
362
+ }
363
+ if (event.workspace || event.workspaceDir) {
364
+ const key = event.workspaceDir || event.workspace;
365
+ const existing = workspaceProgress.get(key) ?? {};
366
+ workspaceProgress.set(key, {
367
+ ...existing,
368
+ workspace: event.workspace ?? existing.workspace ?? '',
369
+ workspaceDir: event.workspaceDir ?? existing.workspaceDir ?? '',
370
+ workspaceIndex: event.workspaceIndex ?? existing.workspaceIndex ?? null,
371
+ workspaceTotal: event.workspaceTotal ?? existing.workspaceTotal ?? null,
372
+ lastStage: event.stage ?? existing.lastStage ?? '',
373
+ message: event.message ?? existing.message ?? '',
374
+ elapsedMs: event.elapsedMs ?? existing.elapsedMs ?? 0,
375
+ updatedAt,
376
+ debugLogFolders: event.debugLogFolders ?? existing.debugLogFolders ?? null,
377
+ chatSnapshots: event.chatSnapshots ?? existing.chatSnapshots ?? null,
378
+ hasMemoryRoot: event.hasMemoryRoot ?? existing.hasMemoryRoot ?? null,
379
+ customizationInventory: event.customizationInventory ?? existing.customizationInventory ?? null,
380
+ total: event.total ?? existing.total ?? null,
381
+ index: event.index ?? existing.index ?? null,
382
+ sessions: event.sessions ?? existing.sessions ?? null,
383
+ memories: event.memories ?? existing.memories ?? null,
384
+ customizations: event.customizations ?? existing.customizations ?? null,
385
+ completed: event.stage === 'workspace-complete' || existing.completed === true,
386
+ });
387
+ }
388
+ return event;
389
+ }
390
+ }
391
+
392
+ function summarizeScanOptions(scanOptions = {}) {
393
+ return {
394
+ roots: Array.isArray(scanOptions.roots) ? scanOptions.roots.map(String) : [],
395
+ customizationWorkspaceFolders: Array.isArray(scanOptions.customizationWorkspaceFolders)
396
+ ? scanOptions.customizationWorkspaceFolders.map(String)
397
+ : [],
398
+ includeCustomizations: scanOptions.includeCustomizations !== false,
399
+ sqlite: scanOptions.sqlite !== false,
400
+ };
401
+ }
402
+
403
+ function scanResultMessage(mode, changedSessions, totalSessions) {
404
+ if (mode === 'customizations') return 'Customization analysis complete';
405
+ if (mode === 'full') return `Full rescan complete: ${totalSessions} sessions imported`;
406
+ return changedSessions
407
+ ? `${changedSessions} session${changedSessions === 1 ? '' : 's'} added or updated`
408
+ : 'Copilot data is up to date';
409
+ }
410
+
411
+ function normalizeScanMode(mode) {
412
+ return ['quick', 'full', 'customizations'].includes(String(mode)) ? String(mode) : 'quick';
413
+ }
414
+
415
+ function scanOptionsForMode(mode, baseScanOptions = {}, previousData = null) {
416
+ if (mode === 'customizations') {
417
+ const { customizationWorkspaceFolders, ...scanOptions } = baseScanOptions;
418
+ if (mode === 'customizations' && Array.isArray(customizationWorkspaceFolders)) {
419
+ return {
420
+ ...scanOptions,
421
+ includeCustomizations: true,
422
+ requireWorkspaceFolders: true,
423
+ workspaceFolders: customizationWorkspaceFolders,
424
+ incrementalSince: previousData?.ingestion?.customizationEvidenceAnalyzedAt ?? '',
425
+ customizationEvidence: {
426
+ maxSessions: 0,
427
+ maxModelCalls: 0,
428
+ maxElapsedMs: 0,
429
+ maxPartChars: 250_000,
430
+ previousEvidence: previousData?.customizations ?? [],
431
+ incrementalSince: previousData?.ingestion?.customizationEvidenceAnalyzedAt ?? '',
432
+ ...(scanOptions.customizationEvidence ?? {}),
433
+ },
434
+ };
435
+ }
436
+
437
+ return {
438
+ ...scanOptions,
439
+ includeCustomizations: true,
440
+ customizationEvidence: {
441
+ maxSessions: 60,
442
+ maxModelCalls: 500,
443
+ maxElapsedMs: 90_000,
444
+ maxPartChars: 250_000,
445
+ ...(scanOptions.customizationEvidence ?? {}),
446
+ },
447
+ };
448
+ }
449
+
450
+ if (mode === 'full') {
451
+ return {
452
+ ...baseScanOptions,
453
+ includeCustomizations: false,
454
+ incrementalSince: '',
455
+ };
456
+ }
457
+
458
+ return {
459
+ ...baseScanOptions,
460
+ includeCustomizations: false,
461
+ incrementalSince: previousData?.generatedAt ?? '',
462
+ };
463
+ }
464
+
465
+ function mergeIncrementalScan(previousData, deltaData) {
466
+ const changedSessions = deltaData.sessions ?? [];
467
+ const changedIds = new Set(changedSessions.map((session) => session.id));
468
+ const sessions = [
469
+ ...changedSessions,
470
+ ...(previousData.sessions ?? []).filter((session) => !changedIds.has(session.id)),
471
+ ]
472
+ .sort((a, b) => String(b.startedAt ?? '').localeCompare(String(a.startedAt ?? '')));
473
+ return preserveCustomizationSnapshot(previousData, {
474
+ ...deltaData,
475
+ ingestion: {
476
+ ...deltaData.ingestion,
477
+ importedSessions: sessions.length,
478
+ incrementalSessionsImported: changedSessions.length,
479
+ },
480
+ sessions,
481
+ });
482
+ }
483
+
484
+ function mergeCustomizationScan(previousData, currentWorkspaceData) {
485
+ const scannedWorkspaces = new Set(
486
+ (currentWorkspaceData.ingestion?.workspaceScans ?? [])
487
+ .map((scan) => scan.workspace)
488
+ .filter(Boolean),
489
+ );
490
+ const sessionsById = new Map((previousData.sessions ?? []).map((session) => [session.id, session]));
491
+ for (const session of currentWorkspaceData.sessions ?? []) {
492
+ sessionsById.set(session.id, session);
493
+ }
494
+ const memoriesById = new Map((previousData.memories ?? []).map((memory) => [memory.id, memory]));
495
+ for (const memory of currentWorkspaceData.memories ?? []) {
496
+ memoriesById.set(memory.id, memory);
497
+ }
498
+ const retainedCustomizations = (previousData.customizations ?? []).filter(
499
+ (customization) => !scannedWorkspaces.has(customization.workspace),
500
+ );
501
+ const customizationsById = new Map(retainedCustomizations.map((customization) => [customization.id, customization]));
502
+ for (const customization of currentWorkspaceData.customizations ?? []) {
503
+ customizationsById.set(customization.id, customization);
504
+ }
505
+ const sessions = [...sessionsById.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
506
+ const memories = [...memoriesById.values()].sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
507
+ const customizations = [...customizationsById.values()].sort((a, b) =>
508
+ b.modifiedAt.localeCompare(a.modifiedAt),
509
+ );
510
+
511
+ return {
512
+ ...previousData,
513
+ generatedAt: currentWorkspaceData.generatedAt,
514
+ ingestion: {
515
+ ...previousData.ingestion,
516
+ ...currentWorkspaceData.ingestion,
517
+ importedSessions: sessions.length,
518
+ importedCustomizations: customizations.length,
519
+ },
520
+ sessions,
521
+ memories,
522
+ customizations,
523
+ };
524
+ }
525
+
526
+ function preserveCustomizationSnapshot(previousData, nextData) {
527
+ const hasPreviousCustomizations = Array.isArray(previousData.customizations) && previousData.customizations.length > 0;
528
+ const nextHasCustomizations = Array.isArray(nextData.customizations) && nextData.customizations.length > 0;
529
+ if (!hasPreviousCustomizations || nextHasCustomizations) {
530
+ return nextData;
531
+ }
532
+
533
+ return {
534
+ ...nextData,
535
+ ingestion: {
536
+ ...nextData.ingestion,
537
+ scannedCustomizationLocations:
538
+ nextData.ingestion?.scannedCustomizationLocations ??
539
+ previousData.ingestion?.scannedCustomizationLocations,
540
+ importedCustomizations:
541
+ nextData.ingestion?.importedCustomizations ??
542
+ previousData.ingestion?.importedCustomizations,
543
+ customizationEvidenceScannedSessions:
544
+ nextData.ingestion?.customizationEvidenceScannedSessions ??
545
+ previousData.ingestion?.customizationEvidenceScannedSessions,
546
+ customizationEvidenceAnalyzedAt:
547
+ nextData.ingestion?.customizationEvidenceAnalyzedAt ||
548
+ previousData.ingestion?.customizationEvidenceAnalyzedAt,
549
+ customizationEvidenceModelCalls:
550
+ nextData.ingestion?.customizationEvidenceModelCalls ??
551
+ previousData.ingestion?.customizationEvidenceModelCalls,
552
+ customizationEvidenceTextParts:
553
+ nextData.ingestion?.customizationEvidenceTextParts ??
554
+ previousData.ingestion?.customizationEvidenceTextParts,
555
+ customizationEvidenceMatchedCustomizations:
556
+ nextData.ingestion?.customizationEvidenceMatchedCustomizations ??
557
+ previousData.ingestion?.customizationEvidenceMatchedCustomizations,
558
+ },
559
+ customizations: previousData.customizations,
560
+ };
561
+ }
562
+
563
+ function waitForActiveScanOrTimeout(activeScan, timeoutMs) {
564
+ if (!timeoutMs) {
565
+ return Promise.resolve();
566
+ }
567
+
568
+ return Promise.race([
569
+ activeScan,
570
+ new Promise((resolveTimeout) => setTimeout(resolveTimeout, timeoutMs)),
571
+ ]);
572
+ }
573
+
574
+ function isScanStoppedError(error) {
575
+ return /scan stopped by user/i.test(error instanceof Error ? error.message : String(error));
576
+ }
577
+
578
+ function scanInChildProcess(options = {}) {
579
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
580
+ const signal = options.signal;
581
+ const scanOptions = { ...options };
582
+ delete scanOptions.onProgress;
583
+ delete scanOptions.signal;
584
+
585
+ return new Promise((resolveScan, rejectScan) => {
586
+ const child = fork(scannerWorkerPath, {
587
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
588
+ windowsHide: true,
589
+ });
590
+ let settled = false;
591
+ let stderr = '';
592
+
593
+ const abort = () => {
594
+ if (settled) {
595
+ return;
596
+ }
597
+ settled = true;
598
+ child.kill();
599
+ rejectScan(new Error('Scan stopped by user.'));
600
+ };
601
+
602
+ if (signal?.aborted) {
603
+ abort();
604
+ return;
605
+ }
606
+ signal?.addEventListener?.('abort', abort, { once: true });
607
+
608
+ child.stderr?.on('data', (chunk) => {
609
+ stderr = `${stderr}${chunk.toString()}`.slice(-8000);
610
+ });
611
+
612
+ child.on('message', (message) => {
613
+ if (!message || typeof message !== 'object') {
614
+ return;
615
+ }
616
+ if (message.type === 'progress') {
617
+ onProgress(message.event ?? {});
618
+ return;
619
+ }
620
+ if (message.type === 'result') {
621
+ settled = true;
622
+ signal?.removeEventListener?.('abort', abort);
623
+ resolveScan(message.sessionData);
624
+ child.kill();
625
+ return;
626
+ }
627
+ if (message.type === 'error') {
628
+ settled = true;
629
+ signal?.removeEventListener?.('abort', abort);
630
+ const error = new Error(message.error?.message ?? 'Scanner worker failed.');
631
+ error.stack = message.error?.stack || error.stack;
632
+ rejectScan(error);
633
+ child.kill();
634
+ }
635
+ });
636
+
637
+ child.once('error', (error) => {
638
+ if (!settled) {
639
+ settled = true;
640
+ signal?.removeEventListener?.('abort', abort);
641
+ rejectScan(error);
642
+ }
643
+ });
644
+
645
+ child.once('exit', (code, exitSignal) => {
646
+ if (!settled) {
647
+ settled = true;
648
+ options.signal?.removeEventListener?.('abort', abort);
649
+ rejectScan(
650
+ new Error(
651
+ `Scanner worker exited before returning data (${exitSignal ?? `code ${code}`}).${stderr ? `\n${stderr}` : ''}`,
652
+ ),
653
+ );
654
+ }
655
+ });
656
+
657
+ child.send({ type: 'scan', options: scanOptions });
658
+ });
189
659
  }
190
660
 
191
661
  function readJsonBody(request) {
@@ -259,6 +729,7 @@ function readCachedSessionData(dataFile, logger) {
259
729
  function sendJson(response, statusCode, body) {
260
730
  const payload = JSON.stringify(body);
261
731
  response.writeHead(statusCode, {
732
+ ...corsHeaders(),
262
733
  'cache-control': 'no-store',
263
734
  'content-length': Buffer.byteLength(payload),
264
735
  'content-type': 'application/json; charset=utf-8',
@@ -266,6 +737,15 @@ function sendJson(response, statusCode, body) {
266
737
  response.end(payload);
267
738
  }
268
739
 
740
+ function corsHeaders() {
741
+ return {
742
+ 'access-control-allow-headers': 'content-type',
743
+ 'access-control-allow-methods': 'GET,POST,OPTIONS',
744
+ 'access-control-allow-origin': '*',
745
+ 'access-control-max-age': '600',
746
+ };
747
+ }
748
+
269
749
  function serveStaticFile(request, response, staticDir, pathname) {
270
750
  if (!existsSync(staticDir)) {
271
751
  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
+ });