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,15 +1,34 @@
1
- import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
2
  import { homedir, platform } from 'node:os';
4
- import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
3
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
4
  import { pathToFileURL } from 'node:url';
6
5
  import {
7
- costBreakdownUsdForTokens,
8
- costUsdForTokens,
9
- modelKey,
10
6
  normalizeModel,
11
- pricingModelForModel,
12
7
  } from './pricing-utils.mjs';
8
+ import {
9
+ attachMemoryRecalls as attachMemoryRecallsCore,
10
+ createMemoryScanner,
11
+ } from './scanner-memory.mjs';
12
+ import {
13
+ customizationEvidenceFromDebugLogs as customizationEvidenceFromDebugLogsCore,
14
+ mergeCustomizationRecords,
15
+ statusRank,
16
+ } from './scanner-customization-evidence.mjs';
17
+ import { createCustomizationInventoryScanner } from './scanner-customization-inventory.mjs';
18
+ import { createSessionParser } from './scanner-session-parser.mjs';
19
+ import {
20
+ defaultCodeUserDirs,
21
+ listDebugLogFiles as traverseDebugLogFiles,
22
+ listDirs as traverseDirs,
23
+ listFiles as traverseFiles,
24
+ listFilesRecursive as traverseFilesRecursive,
25
+ uniqueResolvedRoots,
26
+ userDirForRoot,
27
+ workspaceDirsForRoot,
28
+ } from './scanner-traversal.mjs';
29
+ import { parseWorkspace as parseWorkspaceEntry } from './scanner-workspace.mjs';
30
+
31
+ export { defaultCodeUserDirs } from './scanner-traversal.mjs';
13
32
 
14
33
  const sessionDataSchemaVersion = 1;
15
34
  const pricingData = JSON.parse(
@@ -42,8 +61,20 @@ function createDiagnostics() {
42
61
  scannedMemoryRoots: 0,
43
62
  importedMemories: 0,
44
63
  importedPlans: 0,
64
+ scannedCustomizationRoots: 0,
65
+ scannedCustomizationLocations: [],
66
+ customizationEvidenceScannedSessions: 0,
67
+ customizationEvidenceModelCalls: 0,
68
+ customizationEvidenceTextParts: 0,
69
+ customizationEvidenceMatchedCustomizations: 0,
70
+ customizationEvidenceCapReason: '',
71
+ importedCustomizations: 0,
72
+ workspaceScans: [],
73
+ skippedSystemCustomizations: 0,
45
74
  skippedOversizedMemories: 0,
46
75
  skippedUnreadableMemories: 0,
76
+ skippedOversizedCustomizations: 0,
77
+ skippedUnreadableCustomizations: 0,
47
78
  skippedEmptyDebugLogs: 0,
48
79
  skippedChatSnapshotsWithoutRequests: 0,
49
80
  skippedDuplicateChatSnapshots: 0,
@@ -51,24 +82,6 @@ function createDiagnostics() {
51
82
  };
52
83
  }
53
84
 
54
- export function defaultCodeUserDirs() {
55
- const home = homedir();
56
-
57
- if (platform() === 'win32') {
58
- const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
59
- return [join(appData, 'Code', 'User'), join(appData, 'Code - Insiders', 'User')];
60
- }
61
-
62
- if (platform() === 'darwin') {
63
- return [
64
- join(home, 'Library', 'Application Support', 'Code', 'User'),
65
- join(home, 'Library', 'Application Support', 'Code - Insiders', 'User'),
66
- ];
67
- }
68
-
69
- return [join(home, '.config', 'Code', 'User'), join(home, '.config', 'Code - Insiders', 'User')];
70
- }
71
-
72
85
  function safeJson(text) {
73
86
  try {
74
87
  return JSON.parse(text);
@@ -249,1077 +262,185 @@ function readJsonl(file) {
249
262
  .filter(Boolean);
250
263
  }
251
264
 
252
- function listDirs(dir) {
253
- if (!existsSync(dir)) {
254
- return [];
255
- }
256
-
257
- return readdirSync(dir)
258
- .map((entry) => join(dir, entry))
259
- .filter((path) => statSync(path).isDirectory());
260
- }
261
-
262
- function listFiles(dir, suffix) {
263
- if (!existsSync(dir)) {
264
- return [];
265
- }
266
-
267
- return readdirSync(dir)
268
- .map((entry) => join(dir, entry))
269
- .filter((path) => statSync(path).isFile() && path.endsWith(suffix));
270
- }
265
+ function stripJsonComments(text) {
266
+ let output = '';
267
+ let inString = false;
268
+ let escaped = false;
269
+ let inLineComment = false;
270
+ let inBlockComment = false;
271
271
 
272
- function listDebugLogFiles(root) {
273
- if (!existsSync(root)) {
274
- return [];
275
- }
272
+ for (let index = 0; index < text.length; index += 1) {
273
+ const character = text[index];
274
+ const next = text[index + 1];
276
275
 
277
- const files = [];
278
- const pending = [root];
279
-
280
- while (pending.length) {
281
- const current = pending.pop();
282
- for (const entry of readdirSync(current, { withFileTypes: true })) {
283
- const path = join(current, entry.name);
284
- if (entry.isDirectory()) {
285
- pending.push(path);
286
- } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
287
- files.push(path);
276
+ if (inLineComment) {
277
+ if (character === '\n' || character === '\r') {
278
+ inLineComment = false;
279
+ output += character;
288
280
  }
281
+ continue;
289
282
  }
290
- }
291
283
 
292
- return files.sort();
293
- }
294
-
295
- function listFilesRecursive(root, predicate, limit = memoryFileLimit) {
296
- if (!existsSync(root)) {
297
- return [];
298
- }
299
-
300
- const files = [];
301
- const pending = [root];
302
-
303
- while (pending.length && files.length < limit) {
304
- const current = pending.pop();
305
- let entries = [];
306
-
307
- try {
308
- entries = readdirSync(current, { withFileTypes: true });
309
- } catch (error) {
310
- diagnostics.skippedUnreadableMemories += 1;
311
- diagnostics.warnings.push(`${current}: memory directory skipped: ${error.message}`);
284
+ if (inBlockComment) {
285
+ if (character === '*' && next === '/') {
286
+ inBlockComment = false;
287
+ index += 1;
288
+ }
312
289
  continue;
313
290
  }
314
291
 
315
- for (const entry of entries) {
316
- const path = join(current, entry.name);
317
- if (entry.isDirectory()) {
318
- pending.push(path);
319
- } else if (entry.isFile() && predicate(path)) {
320
- files.push(path);
321
- if (files.length >= limit) {
322
- diagnostics.warnings.push(`${root}: memory scan capped at ${limit} files.`);
323
- break;
324
- }
292
+ if (inString) {
293
+ output += character;
294
+ if (escaped) {
295
+ escaped = false;
296
+ } else if (character === '\\') {
297
+ escaped = true;
298
+ } else if (character === '"') {
299
+ inString = false;
325
300
  }
301
+ continue;
326
302
  }
327
- }
328
303
 
329
- return files;
330
- }
304
+ if (character === '"') {
305
+ inString = true;
306
+ output += character;
307
+ continue;
308
+ }
331
309
 
332
- function decodeMemorySessionId(value) {
333
- try {
334
- const decoded = Buffer.from(String(value), 'base64').toString('utf8');
335
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(decoded)
336
- ? decoded
337
- : '';
338
- } catch {
339
- return '';
340
- }
341
- }
310
+ if (character === '/' && next === '/') {
311
+ inLineComment = true;
312
+ index += 1;
313
+ continue;
314
+ }
342
315
 
343
- function memoryTitle(content, file) {
344
- const heading = String(content)
345
- .split(/\r?\n/)
346
- .map((line) => line.match(/^#{1,3}\s+(.+?)\s*#*$/)?.[1]?.trim())
347
- .find(Boolean);
316
+ if (character === '/' && next === '*') {
317
+ inBlockComment = true;
318
+ index += 1;
319
+ continue;
320
+ }
348
321
 
349
- if (heading) {
350
- return heading.slice(0, 160);
322
+ output += character;
351
323
  }
352
324
 
353
- return basename(file, extname(file))
354
- .replace(/[-_]+/g, ' ')
355
- .replace(/\b\w/g, (character) => character.toUpperCase())
356
- .slice(0, 160);
325
+ return output;
357
326
  }
358
327
 
359
- function memoryExcerpt(content) {
360
- return String(content)
361
- .replace(/^#{1,6}\s+/gm, '')
362
- .replace(/[`*_>~-]+/g, ' ')
363
- .replace(/\s+/g, ' ')
364
- .trim()
365
- .slice(0, 280);
366
- }
328
+ function readJsoncFile(file) {
329
+ if (!existsSync(file)) {
330
+ return {};
331
+ }
367
332
 
368
- function memoryFromFile(file, root, source, workspace) {
369
333
  try {
370
- const stats = statSync(file);
371
- if (stats.size > memoryFileSizeLimit) {
372
- diagnostics.skippedOversizedMemories += 1;
373
- diagnostics.warnings.push(`${file}: memory skipped because it exceeds 1 MiB.`);
374
- return null;
375
- }
376
-
377
- const content = readFileSync(file, 'utf8');
378
- const relativePath = relative(root, file);
379
- const segments = relativePath.split(sep).filter(Boolean);
380
- const sessionId = source === 'workspace' ? decodeMemorySessionId(segments[0]) : '';
381
- const kind = basename(file).toLowerCase() === 'plan.md' ? 'plan' : 'memory';
382
- const scope = source === 'global'
383
- ? 'global'
384
- : segments[0]?.toLowerCase() === 'repo'
385
- ? 'repository'
386
- : sessionId
387
- ? 'session'
388
- : 'workspace';
389
-
390
- return {
391
- id: createHash('sha256').update(resolve(file)).digest('hex').slice(0, 24),
392
- kind,
393
- scope,
394
- title: memoryTitle(content, file),
395
- excerpt: memoryExcerpt(content),
396
- content,
397
- workspace: source === 'global' ? '' : workspace,
398
- sessionId,
399
- sourcePath: resolve(file),
400
- relativePath,
401
- createdAt: stats.birthtimeMs > 0 ? stats.birthtime.toISOString() : '',
402
- modifiedAt: stats.mtime.toISOString(),
403
- sizeBytes: stats.size,
404
- characterCount: content.length,
405
- lineCount: content ? content.split(/\r?\n/).length : 0,
406
- };
334
+ const json = stripJsonComments(readFileSync(file, 'utf8')).replace(/,\s*([}\]])/g, '$1');
335
+ return safeJson(json) ?? {};
407
336
  } catch (error) {
408
- diagnostics.skippedUnreadableMemories += 1;
409
- diagnostics.warnings.push(`${file}: memory skipped: ${error.message}`);
410
- return null;
337
+ diagnostics.warnings.push(`${file}: settings file skipped: ${error.message}`);
338
+ return {};
411
339
  }
412
340
  }
413
341
 
414
- function memoriesFromRoot(root, source, workspace = '') {
415
- if (!existsSync(root)) {
416
- return [];
417
- }
418
-
419
- diagnostics.scannedMemoryRoots += 1;
420
- const memories = listFilesRecursive(root, (file) => extname(file).toLowerCase() === '.md')
421
- .map((file) => memoryFromFile(file, root, source, workspace))
422
- .filter(Boolean);
423
-
424
- diagnostics.importedMemories += memories.length;
425
- diagnostics.importedPlans += memories.filter((memory) => memory.kind === 'plan').length;
426
- return memories;
342
+ function listDirs(dir) {
343
+ return traverseDirs(dir, traversalOptions());
427
344
  }
428
345
 
429
- function normalizeMemoryVirtualPath(value) {
430
- const path = String(value ?? '').trim().replace(/\\/g, '/');
431
- return path.startsWith('/') ? path : `/${path}`;
346
+ function listFiles(dir, suffix) {
347
+ return traverseFiles(dir, suffix, traversalOptions());
432
348
  }
433
349
 
434
- function virtualPathForMemory(memory) {
435
- const segments = String(memory.relativePath ?? '').split(/[\\/]+/).filter(Boolean);
436
-
437
- if (memory.scope === 'session') {
438
- return normalizeMemoryVirtualPath(`/memories/session/${segments.slice(1).join('/')}`);
439
- }
440
-
441
- return normalizeMemoryVirtualPath(`/memories/${segments.join('/')}`);
350
+ function listDebugLogFiles(root) {
351
+ return traverseDebugLogFiles(root, traversalOptions());
442
352
  }
443
353
 
444
- export function memoryRecallsFromDebugLog(sessionDir, workspace = '') {
445
- const sessionId = basename(sessionDir);
446
- const recalls = [];
447
-
448
- for (const file of listDebugLogFiles(sessionDir)) {
449
- const events = readJsonl(file);
450
- let modelCallNumber = 0;
451
- const modelCallNumbers = new Map();
452
-
453
- events.forEach((event, index) => {
454
- if (event.type === 'llm_request') {
455
- modelCallNumber += 1;
456
- modelCallNumbers.set(index, modelCallNumber);
457
- }
458
- });
459
-
460
- events.forEach((event, index) => {
461
- if (event.type !== 'tool_call' || event.name !== 'memory') {
462
- return;
463
- }
464
-
465
- const args = safeJson(event.attrs?.args) ?? {};
466
- if (args.command !== 'view' || !args.path) {
467
- return;
468
- }
469
-
470
- const nextModelIndex = events.findIndex(
471
- (candidate, candidateIndex) => candidateIndex > index && candidate.type === 'llm_request',
472
- );
473
- const nextModel = nextModelIndex >= 0 ? events[nextModelIndex] : null;
474
- const tokenFields = nextModel ? llmTokenFields(nextModel) : null;
475
- const sourceLog = basename(file);
476
-
477
- recalls.push({
478
- id: createHash('sha256')
479
- .update(`${resolve(file)}:${index}:${args.path}`)
480
- .digest('hex')
481
- .slice(0, 24),
482
- sessionId,
483
- workspace,
484
- virtualPath: normalizeMemoryVirtualPath(args.path),
485
- timestamp: timestampForEvent(event),
486
- sourceLog,
487
- returnedCharacterCount: String(event.attrs?.result ?? '').length,
488
- ...(nextModel
489
- ? {
490
- followingModelCall: {
491
- number: modelCallNumbers.get(nextModelIndex) ?? 0,
492
- model: normalizeModel(nextModel.attrs?.model, pricing),
493
- inputTokens: tokenFields.inputTokens,
494
- cachedInputTokens: tokenFields.cachedInputTokens,
495
- outputTokens: tokenFields.outputTokens,
496
- },
497
- }
498
- : {}),
499
- });
500
- });
501
- }
502
-
503
- return recalls.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
354
+ function listFilesRecursive(root, predicate, limit = memoryFileLimit, options = {}) {
355
+ return traverseFilesRecursive(root, predicate, limit, traversalOptions(options));
504
356
  }
505
357
 
506
- export function attachMemoryRecalls(memories, sessions) {
507
- const recalls = sessions.flatMap((session) => session.memoryRecalls ?? []);
508
-
509
- return memories.map((memory) => {
510
- const virtualPath = virtualPathForMemory(memory);
511
- const matchingRecalls = recalls.filter((recall) => {
512
- if (recall.virtualPath !== virtualPath) {
513
- return false;
514
- }
515
- if (memory.scope === 'session') {
516
- return memory.sessionId === recall.sessionId;
517
- }
518
- if (memory.scope === 'repository' || memory.scope === 'workspace') {
519
- return memory.workspace === recall.workspace;
358
+ function traversalOptions(options = {}) {
359
+ return {
360
+ ...options,
361
+ onWarning: (message) => diagnostics.warnings.push(message),
362
+ onUnreadable: options.onUnreadable ?? (() => {
363
+ if (options.label === 'customization') {
364
+ diagnostics.skippedUnreadableCustomizations += 1;
365
+ } else {
366
+ diagnostics.skippedUnreadableMemories += 1;
520
367
  }
521
- return memory.scope === 'global';
522
- });
368
+ }),
369
+ };
370
+ }
523
371
 
524
- return matchingRecalls.length ? { ...memory, recalls: matchingRecalls } : memory;
372
+ function memoryScanner() {
373
+ return createMemoryScanner({
374
+ diagnostics: () => diagnostics,
375
+ listDebugLogFiles,
376
+ listFilesRecursive,
377
+ llmTokenFields,
378
+ memoryFileLimit,
379
+ memoryFileSizeLimit,
380
+ normalizeModel: (model) => normalizeModel(model, pricing),
381
+ readJsonl,
382
+ safeJson,
383
+ timestampForEvent,
525
384
  });
526
385
  }
527
386
 
528
- function costUsd(model, tokens) {
529
- return costUsdForTokens(model, tokens, pricing, fallbackPricingModel);
387
+ function memoriesFromRoot(root, source, workspace = '') {
388
+ return memoryScanner().memoriesFromRoot(root, source, workspace);
530
389
  }
531
390
 
532
- function costBreakdownUsd(model, tokens) {
533
- return costBreakdownUsdForTokens(model, tokens, pricing, fallbackPricingModel);
391
+ export function memoryRecallsFromDebugLog(sessionDir, workspace = '') {
392
+ return memoryScanner().memoryRecallsFromDebugLog(sessionDir, workspace);
534
393
  }
535
394
 
536
- function transcriptAvailability(workspaceDir, sessionId) {
537
- const sourcePath = join(workspaceDir, 'GitHub.copilot-chat', 'transcripts', `${sessionId}.jsonl`);
538
-
539
- if (!existsSync(sourcePath)) {
540
- return {
541
- available: false,
542
- sourcePath: '',
543
- eventCount: 0,
544
- };
545
- }
546
-
547
- const eventCount = readJsonl(sourcePath).length;
548
-
549
- return {
550
- available: true,
551
- sourcePath,
552
- eventCount,
553
- };
395
+ export function attachMemoryRecalls(memories, sessions) {
396
+ return attachMemoryRecallsCore(memories, sessions);
554
397
  }
555
398
 
556
- function numericAttr(attrs, names) {
557
- for (const name of names) {
558
- const value = Number(attrs?.[name] ?? 0);
559
- if (Number.isFinite(value) && value > 0) {
560
- return value;
561
- }
562
- }
563
-
564
- return 0;
399
+ function sessionParser() {
400
+ return createSessionParser({
401
+ diagnostics: () => diagnostics,
402
+ fallbackPricingModel,
403
+ listFiles,
404
+ memoryRecallsFromDebugLog,
405
+ pricing,
406
+ readJsonl,
407
+ safeJson,
408
+ traceEventLimit,
409
+ usdToEur: () => usdToEur,
410
+ workspaceName,
411
+ });
565
412
  }
566
413
 
567
414
  export function llmTokenFields(event) {
568
- const inputTokens = Number(event.attrs?.inputTokens ?? 0);
569
- const outputTokens = Number(event.attrs?.outputTokens ?? 0);
570
- const rawCachedInputTokens = numericAttr(event.attrs, [
571
- 'cachedTokens',
572
- 'cachedInputTokens',
573
- 'cacheReadTokens',
574
- ]);
575
- const cachedInputTokens = Math.min(inputTokens, rawCachedInputTokens);
576
- const cacheWriteTokens = numericAttr(event.attrs, ['cacheWriteTokens', 'cachedWriteTokens']);
577
- const billableInputTokens = Math.max(0, inputTokens - cachedInputTokens);
578
-
579
- return {
580
- inputTokens,
581
- billableInputTokens,
582
- rawCachedInputTokens,
583
- cachedInputTokens,
584
- cacheWriteTokens,
585
- outputTokens,
586
- };
415
+ return sessionParser().llmTokenFields(event);
587
416
  }
588
417
 
589
418
  export function cacheTokenAuditFromLlmRequests(llmRequests) {
590
- return llmRequests.reduce(
591
- (audit, event) => {
592
- const tokenFields = llmTokenFields(event);
593
-
594
- audit.modelCalls += 1;
595
- audit.rawInputTokens += tokenFields.inputTokens;
596
- audit.normalInputTokens += tokenFields.billableInputTokens;
597
- audit.cachedInputTokens += tokenFields.cachedInputTokens;
598
- audit.cacheWriteTokens += tokenFields.cacheWriteTokens;
599
- audit.outputTokens += tokenFields.outputTokens;
600
-
601
- if (tokenFields.rawCachedInputTokens > 0) {
602
- audit.callsWithCachedTokens += 1;
603
- }
604
-
605
- if (tokenFields.rawCachedInputTokens > tokenFields.inputTokens) {
606
- audit.invalidCachedTokenSplits += 1;
607
- }
608
-
609
- const rawInputShare =
610
- tokenFields.inputTokens > 0 ? tokenFields.cachedInputTokens / tokenFields.inputTokens : 0;
611
- audit.maxCachedInputShare = Math.max(audit.maxCachedInputShare, rawInputShare);
612
-
613
- return audit;
614
- },
615
- {
616
- modelCalls: 0,
617
- callsWithCachedTokens: 0,
618
- invalidCachedTokenSplits: 0,
619
- rawInputTokens: 0,
620
- normalInputTokens: 0,
621
- cachedInputTokens: 0,
622
- cacheWriteTokens: 0,
623
- outputTokens: 0,
624
- maxCachedInputShare: 0,
625
- },
626
- );
419
+ return sessionParser().cacheTokenAuditFromLlmRequests(llmRequests);
627
420
  }
628
421
 
629
422
  export function mergeCacheTokenAudits(audits) {
630
- return audits.reduce(
631
- (total, audit) => ({
632
- modelCalls: total.modelCalls + audit.modelCalls,
633
- callsWithCachedTokens: total.callsWithCachedTokens + audit.callsWithCachedTokens,
634
- invalidCachedTokenSplits: total.invalidCachedTokenSplits + audit.invalidCachedTokenSplits,
635
- rawInputTokens: total.rawInputTokens + audit.rawInputTokens,
636
- normalInputTokens: total.normalInputTokens + audit.normalInputTokens,
637
- cachedInputTokens: total.cachedInputTokens + audit.cachedInputTokens,
638
- cacheWriteTokens: total.cacheWriteTokens + audit.cacheWriteTokens,
639
- outputTokens: total.outputTokens + audit.outputTokens,
640
- maxCachedInputShare: Math.max(total.maxCachedInputShare, audit.maxCachedInputShare),
641
- }),
642
- {
643
- modelCalls: 0,
644
- callsWithCachedTokens: 0,
645
- invalidCachedTokenSplits: 0,
646
- rawInputTokens: 0,
647
- normalInputTokens: 0,
648
- cachedInputTokens: 0,
649
- cacheWriteTokens: 0,
650
- outputTokens: 0,
651
- maxCachedInputShare: 0,
652
- },
653
- );
423
+ return sessionParser().mergeCacheTokenAudits(audits);
654
424
  }
655
425
 
656
426
  export function eventModelCostFields(rawModel, tokenFields) {
657
- const normalizedModel = normalizeModel(rawModel, pricing);
658
- const pricingModel = pricingModelForModel(normalizedModel, pricing, fallbackPricingModel);
659
- const tokens = {
660
- input: tokenFields.billableInputTokens,
661
- cachedInput: tokenFields.cachedInputTokens,
662
- cacheWrite: tokenFields.cacheWriteTokens,
663
- output: tokenFields.outputTokens,
664
- };
665
- const costBreakdown = costBreakdownUsd(pricingModel, tokens);
666
-
667
- return {
668
- model: normalizedModel,
669
- rawModel:
670
- String(rawModel ?? '')
671
- .replace(/^copilot\//i, '')
672
- .trim() || 'unknown',
673
- pricingModel,
674
- pricingTier: costBreakdown.tier,
675
- totalTokens: tokenFields.inputTokens + tokenFields.outputTokens + tokenFields.cacheWriteTokens,
676
- estimatedCost: { usd: costBreakdown.total, eur: costBreakdown.total * usdToEur },
677
- };
678
- }
679
-
680
- function parseMaybeJson(value) {
681
- if (typeof value !== 'string') {
682
- return value ?? null;
683
- }
684
-
685
- return safeJson(value) ?? value;
686
- }
687
-
688
- function charLength(value) {
689
- if (value === undefined || value === null) {
690
- return 0;
691
- }
692
-
693
- return typeof value === 'string' ? value.length : JSON.stringify(value).length;
694
- }
695
-
696
- function parseContentFile(file) {
697
- const envelope = safeJson(readFileSync(file, 'utf8'));
698
- const content = parseMaybeJson(envelope?.content ?? envelope);
699
-
700
- return {
701
- file,
702
- content,
703
- chars: charLength(content),
704
- };
705
- }
706
-
707
- function modelCapabilityIndex(sessionDir) {
708
- const file = join(sessionDir, 'models.json');
709
- if (!existsSync(file)) {
710
- return new Map();
711
- }
712
-
713
- const parsed = safeJson(readFileSync(file, 'utf8'));
714
- const models = Array.isArray(parsed) ? parsed : [];
715
- const index = new Map();
716
-
717
- for (const model of models) {
718
- const keys = [model?.id, model?.name, model?.version, model?.capabilities?.family]
719
- .filter(Boolean)
720
- .map(modelKey);
721
-
722
- for (const key of keys) {
723
- if (key) {
724
- index.set(key, model);
725
- }
726
- }
727
- }
728
-
729
- return index;
730
- }
731
-
732
- function modelCapabilityFor(rawModel, capabilityIndex) {
733
- const key = modelKey(rawModel);
734
-
735
- return (
736
- capabilityIndex.get(key) ??
737
- [...capabilityIndex.entries()].find(([candidate]) => key.includes(candidate))?.[1] ??
738
- null
739
- );
740
- }
741
-
742
- function modelLimitSummaries(sessionDir, llmRequests) {
743
- const capabilityIndex = modelCapabilityIndex(sessionDir);
744
- if (!capabilityIndex.size || !llmRequests.length) {
745
- return [];
746
- }
747
-
748
- const byModel = new Map();
749
-
750
- for (const event of llmRequests) {
751
- const rawModel = String(event.attrs?.model ?? '')
752
- .replace(/^copilot\//i, '')
753
- .trim();
754
- const displayModel = normalizeModel(rawModel, pricing);
755
- const capability = modelCapabilityFor(rawModel || displayModel, capabilityIndex);
756
- const limits = capability?.capabilities?.limits ?? {};
757
- const supports = capability?.capabilities?.supports ?? {};
758
- const current = byModel.get(displayModel) ?? {
759
- model: displayModel,
760
- rawModels: new Set(),
761
- modelId: capability?.id ?? rawModel,
762
- vendor: capability?.vendor ?? '',
763
- tokenizer: capability?.capabilities?.tokenizer ?? '',
764
- contextWindowTokens: Number(limits.max_context_window_tokens ?? 0) || 0,
765
- promptLimitTokens: Number(limits.max_prompt_tokens ?? 0) || 0,
766
- outputLimitTokens: Number(limits.max_output_tokens ?? 0) || 0,
767
- supportedReasoningEfforts: Array.isArray(supports.reasoning_effort)
768
- ? supports.reasoning_effort
769
- : [],
770
- supportedEndpoints: Array.isArray(capability?.supported_endpoints)
771
- ? capability.supported_endpoints
772
- : [],
773
- modelPickerEnabled: Boolean(capability?.model_picker_enabled),
774
- isChatDefault: Boolean(capability?.is_chat_default),
775
- isChatFallback: Boolean(capability?.is_chat_fallback),
776
- modelCalls: 0,
777
- largestRawInputTokens: 0,
778
- totalRawInputTokens: 0,
779
- largestOutputTokens: 0,
780
- };
781
-
782
- current.rawModels.add(rawModel || 'unknown');
783
- current.modelCalls += 1;
784
- current.largestRawInputTokens = Math.max(
785
- current.largestRawInputTokens,
786
- Number(event.attrs?.inputTokens ?? 0),
787
- );
788
- current.totalRawInputTokens += Number(event.attrs?.inputTokens ?? 0);
789
- current.largestOutputTokens = Math.max(
790
- current.largestOutputTokens,
791
- Number(event.attrs?.outputTokens ?? 0),
792
- );
793
- byModel.set(displayModel, current);
794
- }
795
-
796
- return [...byModel.values()].map((summary) => ({
797
- ...summary,
798
- rawModels: [...summary.rawModels],
799
- promptLimitShare:
800
- summary.promptLimitTokens > 0
801
- ? summary.largestRawInputTokens / summary.promptLimitTokens
802
- : null,
803
- contextWindowShare:
804
- summary.contextWindowTokens > 0
805
- ? summary.largestRawInputTokens / summary.contextWindowTokens
806
- : null,
807
- repeatedInputFactor:
808
- summary.largestRawInputTokens > 0
809
- ? summary.totalRawInputTokens / summary.largestRawInputTokens
810
- : 0,
811
- }));
812
- }
813
-
814
- function toolName(tool) {
815
- return String(tool?.function?.name ?? tool?.name ?? tool?.toolName ?? tool?.id ?? 'unknown_tool');
816
- }
817
-
818
- function toolSchemaSize(tool) {
819
- const descriptionChars = charLength(tool?.function?.description ?? tool?.description);
820
- const parameterChars = charLength(
821
- tool?.function?.parameters ?? tool?.parameters ?? tool?.input_schema,
822
- );
823
-
824
- return {
825
- name: toolName(tool),
826
- descriptionChars,
827
- parameterChars,
828
- totalChars: charLength(tool),
829
- };
830
- }
831
-
832
- function requestOptions(event) {
833
- const parsed = parseMaybeJson(event.attrs?.requestOptions);
834
- return parsed && typeof parsed === 'object' ? parsed : {};
835
- }
836
-
837
- function reasoningEffort(event) {
838
- return String(requestOptions(event)?.reasoning?.effort ?? '').trim();
839
- }
840
-
841
- function textVerbosity(event) {
842
- return String(requestOptions(event)?.text?.verbosity ?? '').trim();
843
- }
844
-
845
- function requestShapeMetadata(event) {
846
- const shape = parseMaybeJson(event.attrs?.requestShape);
847
-
848
- if (!shape || typeof shape !== 'object') {
849
- return null;
850
- }
851
-
852
- return {
853
- api: shape.api ? String(shape.api) : '',
854
- inputItemCount: Number(shape.inputItemCount ?? 0),
855
- inputItemTypes: Array.isArray(shape.inputItemTypes)
856
- ? shape.inputItemTypes.filter(Boolean).map(String)
857
- : [],
858
- hasPreviousResponseId: Boolean(shape.hasPreviousResponseId),
859
- };
860
- }
861
-
862
- function requestShapeSummary(event) {
863
- const shape = requestShapeMetadata(event);
864
- if (!shape) {
865
- return '';
866
- }
867
-
868
- const parts = [
869
- shape.api ? `api: ${shape.api}` : '',
870
- shape.inputItemCount
871
- ? `${shape.inputItemCount.toLocaleString()} input item${shape.inputItemCount === 1 ? '' : 's'}`
872
- : '',
873
- shape.inputItemTypes.length ? `types: ${shape.inputItemTypes.join(', ')}` : '',
874
- shape.hasPreviousResponseId ? 'continues previous response' : '',
875
- ].filter(Boolean);
876
-
877
- return parts.join(' · ');
878
- }
879
-
880
- function countedValues(values) {
881
- const counts = new Map();
882
-
883
- for (const value of values.filter(Boolean)) {
884
- counts.set(value, (counts.get(value) ?? 0) + 1);
885
- }
886
-
887
- return [...counts.entries()]
888
- .map(([value, count]) => ({ value, count }))
889
- .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
890
- }
891
-
892
- function toolPayloadSummary(toolEvents) {
893
- const byName = new Map();
894
-
895
- for (const event of toolEvents) {
896
- const name = String(
897
- event.data?.toolName ?? event.attrs?.toolName ?? event.name ?? event.type ?? 'tool',
898
- );
899
- const current = byName.get(name) ?? { name, calls: 0, argsChars: 0, resultChars: 0 };
900
- current.calls += 1;
901
- current.argsChars += charLength(
902
- event.attrs?.args ??
903
- event.attrs?.arguments ??
904
- event.attrs?.input ??
905
- event.data?.args ??
906
- event.data?.arguments ??
907
- event.data?.input,
908
- );
909
- current.resultChars += charLength(
910
- event.attrs?.result ??
911
- event.attrs?.output ??
912
- event.attrs?.stdout ??
913
- event.data?.result ??
914
- event.data?.output ??
915
- event.data?.stdout,
916
- );
917
- byName.set(name, current);
918
- }
919
-
920
- return [...byName.values()]
921
- .sort(
922
- (a, b) =>
923
- b.resultChars + b.argsChars - (a.resultChars + a.argsChars) || a.name.localeCompare(b.name),
924
- )
925
- .slice(0, 12);
926
- }
927
-
928
- function requestPayloadSummary(sessionDir, llmRequests, toolEvents) {
929
- const systemPromptFiles = [
930
- ...new Set(llmRequests.map((event) => event.attrs?.systemPromptFile).filter(Boolean)),
931
- ];
932
- const toolsFiles = [
933
- ...new Set(llmRequests.map((event) => event.attrs?.toolsFile).filter(Boolean)),
934
- ];
935
- const systemPrompts = systemPromptFiles
936
- .map((file) => join(sessionDir, file))
937
- .filter(existsSync)
938
- .map(parseContentFile);
939
- const toolFileSummaries = toolsFiles
940
- .map((file) => join(sessionDir, file))
941
- .filter(existsSync)
942
- .map(parseContentFile);
943
- const tools = toolFileSummaries.flatMap((summary) =>
944
- Array.isArray(summary.content) ? summary.content : [],
945
- );
946
- const toolSchemas = tools.map(toolSchemaSize);
947
- const mcpToolNames = toolSchemas
948
- .map((tool) => tool.name)
949
- .filter((name) => name.startsWith('mcp_'));
950
- const reasoningEfforts = countedValues(llmRequests.map(reasoningEffort)).map(
951
- ({ value, count }) => ({
952
- effort: value,
953
- count,
954
- }),
955
- );
956
- const subagentLogCount = listFiles(sessionDir, '.jsonl').filter((file) =>
957
- basename(file).startsWith('runSubagent-'),
958
- ).length;
959
-
960
- return {
961
- systemPromptFiles: systemPrompts.length,
962
- systemPromptChars: systemPrompts.reduce((sum, summary) => sum + summary.chars, 0),
963
- toolSchemaFiles: toolFileSummaries.length,
964
- toolSchemaChars: toolFileSummaries.reduce((sum, summary) => sum + summary.chars, 0),
965
- toolCount: toolSchemas.length,
966
- mcpToolCount: mcpToolNames.length,
967
- mcpToolNames: [...new Set(mcpToolNames)].sort(),
968
- largestToolSchemas: toolSchemas.sort((a, b) => b.totalChars - a.totalChars).slice(0, 8),
969
- modelCallsWithSystemPromptFile: llmRequests.filter((event) => event.attrs?.systemPromptFile)
970
- .length,
971
- modelCallsWithToolsFile: llmRequests.filter((event) => event.attrs?.toolsFile).length,
972
- reasoningEfforts,
973
- toolResultCharsByName: toolPayloadSummary(toolEvents),
974
- subagentLogCount,
975
- };
976
- }
977
-
978
- function contentSummaryFromCache(sessionDir, cache, file) {
979
- if (!file) {
980
- return null;
981
- }
982
-
983
- if (cache.has(file)) {
984
- return cache.get(file);
985
- }
986
-
987
- const path = join(sessionDir, file);
988
- const summary = existsSync(path) ? parseContentFile(path) : null;
989
- cache.set(file, summary);
990
- return summary;
991
- }
992
-
993
- function modelCallSetupPayloadFactory(sessionDir) {
994
- const cache = new Map();
995
-
996
- return (event) => {
997
- if (event.type !== 'llm_request') {
998
- return null;
999
- }
1000
-
1001
- const systemPromptFile = String(event.attrs?.systemPromptFile ?? '').trim();
1002
- const toolsFile = String(event.attrs?.toolsFile ?? '').trim();
1003
-
1004
- if (!systemPromptFile && !toolsFile) {
1005
- return null;
1006
- }
1007
-
1008
- const systemPrompt = contentSummaryFromCache(sessionDir, cache, systemPromptFile);
1009
- const toolsSummary = contentSummaryFromCache(sessionDir, cache, toolsFile);
1010
- const tools = Array.isArray(toolsSummary?.content) ? toolsSummary.content : [];
1011
- const toolSchemas = tools.map(toolSchemaSize);
1012
- const mcpToolNames = toolSchemas
1013
- .map((tool) => tool.name)
1014
- .filter((name) => name.startsWith('mcp_'));
1015
-
1016
- return {
1017
- systemPromptFile,
1018
- systemPromptChars: systemPrompt?.chars ?? 0,
1019
- toolsFile,
1020
- toolSchemaChars: toolsSummary?.chars ?? 0,
1021
- toolCount: toolSchemas.length,
1022
- mcpToolCount: mcpToolNames.length,
1023
- mcpToolNames: [...new Set(mcpToolNames)].sort(),
1024
- largestToolSchemas: toolSchemas.sort((a, b) => b.totalChars - a.totalChars).slice(0, 5),
1025
- };
1026
- };
1027
- }
1028
-
1029
- function debugEvidence(llmRequests, agentResponses) {
1030
- const inputSeries = llmRequests.map((event) => Number(event.attrs?.inputTokens ?? 0));
1031
- const outputCaps = [
1032
- ...new Set(
1033
- llmRequests.map((event) => Number(event.attrs?.maxTokens ?? 0)).filter((value) => value > 0),
1034
- ),
1035
- ].sort((a, b) => a - b);
1036
- const maxInputTokens = Math.max(0, ...inputSeries);
1037
- const maxRequestTokens = Math.max(0, ...outputCaps);
1038
- const reasoningEvents = agentResponses.filter((event) =>
1039
- String(event.attrs?.reasoning ?? '').trim(),
1040
- ).length;
1041
- const efforts = countedValues(llmRequests.map(reasoningEffort));
1042
- const primaryEffort = efforts[0]?.value ?? '';
1043
-
1044
- return {
1045
- reasoning: {
1046
- visible: reasoningEvents > 0 || Boolean(primaryEffort),
1047
- level: primaryEffort,
1048
- events: reasoningEvents,
1049
- source: primaryEffort
1050
- ? 'llm_request.attrs.requestOptions.reasoning.effort'
1051
- : reasoningEvents > 0
1052
- ? 'agent_response.attrs.reasoning'
1053
- : '',
1054
- help: primaryEffort
1055
- ? 'VS Code Agent Debug Logs expose the request reasoning effort in llm_request.attrs.requestOptions.reasoning.effort.'
1056
- : reasoningEvents > 0
1057
- ? 'VS Code debug logs include reasoning text on agent_response events, but no request reasoning effort was imported.'
1058
- : 'No reasoning text field was present on imported agent_response events.',
1059
- },
1060
- context: {
1061
- maxInputTokens,
1062
- maxRequestTokens,
1063
- outputCaps,
1064
- requestCapShare: maxRequestTokens > 0 ? maxInputTokens / maxRequestTokens : null,
1065
- source:
1066
- maxRequestTokens > 0
1067
- ? 'llm_request.attrs.inputTokens and attrs.maxTokens'
1068
- : 'llm_request.attrs.inputTokens',
1069
- help:
1070
- maxRequestTokens > 0
1071
- ? 'Compares the largest observed input token count with the request maxTokens field present in VS Code debug logs. This is an observed pressure signal, not a provider context-window guarantee.'
1072
- : 'Largest observed model input token count. The log did not include a request cap to compare against.',
1073
- },
1074
- };
427
+ return sessionParser().eventModelCostFields(rawModel, tokenFields);
1075
428
  }
1076
429
 
1077
430
  export function modelBreakdownFromLlmRequests(llmRequests) {
1078
- const byModel = new Map();
1079
-
1080
- for (const event of llmRequests) {
1081
- const rawModel = String(event.attrs?.model ?? 'unknown')
1082
- .replace(/^copilot\//i, '')
1083
- .trim();
1084
- const displayModel = normalizeModel(rawModel, pricing);
1085
- const current = byModel.get(displayModel) ?? {
1086
- model: displayModel,
1087
- rawModels: new Set(),
1088
- turns: 0,
1089
- tokens: { input: 0, cachedInput: 0, cacheWrite: 0, output: 0 },
1090
- cost: { usd: 0, eur: 0 },
1091
- costBreakdown: { inputUsd: 0, cachedInputUsd: 0, cacheWriteUsd: 0, outputUsd: 0 },
1092
- pricingTiers: new Set(),
1093
- pricingModel: pricingModelForModel(displayModel, pricing, fallbackPricingModel),
1094
- };
1095
-
1096
- current.rawModels.add(rawModel || 'unknown');
1097
- current.turns += 1;
1098
- const tokenFields = llmTokenFields(event);
1099
- current.tokens.input += tokenFields.billableInputTokens;
1100
- current.tokens.cachedInput += tokenFields.cachedInputTokens;
1101
- current.tokens.cacheWrite += tokenFields.cacheWriteTokens;
1102
- current.tokens.output += tokenFields.outputTokens;
1103
- const callCost = costBreakdownUsd(current.pricingModel, {
1104
- input: tokenFields.billableInputTokens,
1105
- cachedInput: tokenFields.cachedInputTokens,
1106
- cacheWrite: tokenFields.cacheWriteTokens,
1107
- output: tokenFields.outputTokens,
1108
- });
1109
- current.costBreakdown.inputUsd += callCost.input;
1110
- current.costBreakdown.cachedInputUsd += callCost.cachedInput;
1111
- current.costBreakdown.cacheWriteUsd += callCost.cacheWrite;
1112
- current.costBreakdown.outputUsd += callCost.output;
1113
- current.pricingTiers.add(callCost.tier);
1114
- byModel.set(displayModel, current);
1115
- }
1116
-
1117
- return [...byModel.values()].map((entry) => {
1118
- const usd =
1119
- entry.costBreakdown.inputUsd +
1120
- entry.costBreakdown.cachedInputUsd +
1121
- entry.costBreakdown.cacheWriteUsd +
1122
- entry.costBreakdown.outputUsd;
1123
- return {
1124
- ...entry,
1125
- rawModels: [...entry.rawModels],
1126
- pricingTiers: [...entry.pricingTiers],
1127
- cost: { usd, eur: usd * usdToEur },
1128
- };
1129
- });
1130
- }
1131
-
1132
- function sessionModelLabel(modelBreakdown, fallbackModel) {
1133
- if (modelBreakdown.length === 1) {
1134
- return modelBreakdown[0].model;
1135
- }
1136
-
1137
- if (modelBreakdown.length > 1) {
1138
- return `Mixed (${modelBreakdown.map((entry) => entry.model).join(', ')})`;
1139
- }
1140
-
1141
- return normalizeModel(fallbackModel, pricing);
1142
- }
1143
-
1144
- function estimateTokens(text) {
1145
- const compact = String(text ?? '').trim();
1146
- if (!compact) {
1147
- return 0;
1148
- }
1149
-
1150
- return Math.max(1, Math.round(Math.max(compact.split(/\s+/).length * 1.35, compact.length / 4)));
431
+ return sessionParser().modelBreakdownFromLlmRequests(llmRequests);
1151
432
  }
1152
433
 
1153
434
  function timestampForEvent(event) {
1154
- return event?.timestamp ?? new Date(Number(event?.ts ?? 0)).toISOString();
435
+ return sessionParser().timestampForEvent(event);
1155
436
  }
1156
437
 
1157
- function eventDetail(event) {
1158
- if (event.type === 'llm_request') {
1159
- return `${event.attrs?.model ?? 'model'}: ${Number(event.attrs?.inputTokens ?? 0).toLocaleString()} raw in / ${Number(
1160
- event.attrs?.outputTokens ?? 0,
1161
- ).toLocaleString()} out`;
1162
- }
1163
-
1164
- if (String(event.type ?? '').includes('tool')) {
1165
- return String(event.data?.toolName ?? event.attrs?.toolName ?? event.name ?? event.type);
1166
- }
1167
-
1168
- if (event.type === 'user_message') {
1169
- return String(event.attrs?.content ?? '').slice(0, 140);
1170
- }
1171
-
1172
- if (event.type === 'agent_response') {
1173
- return parseAssistantResponse(event.attrs?.response).slice(0, 140);
1174
- }
1175
-
1176
- return String(event.attrs?.details ?? event.name ?? event.type ?? '').slice(0, 140);
1177
- }
1178
-
1179
- function boundedText(value, maxLength = 260) {
1180
- const compact = String(value ?? '')
1181
- .replace(/\s+/g, ' ')
1182
- .trim();
1183
- return compact.length > maxLength ? `${compact.slice(0, maxLength - 1)}...` : compact;
1184
- }
1185
-
1186
- function summaryValue(value) {
1187
- if (value === undefined || value === null || value === '') {
1188
- return '';
1189
- }
1190
-
1191
- if (typeof value === 'string') {
1192
- return boundedText(value);
1193
- }
1194
-
1195
- if (typeof value === 'number' || typeof value === 'boolean') {
1196
- return String(value);
1197
- }
1198
-
1199
- return boundedText(JSON.stringify(compactObject(value)));
1200
- }
1201
-
1202
- function sourceEstimatedCost(event) {
1203
- return summaryValue(event.attrs?.estimatedCost);
1204
- }
1205
-
1206
- function sourceUsageFromNanoAiu(event) {
1207
- const nanoAiu = Number(event.attrs?.copilotUsageNanoAiu ?? 0);
1208
- if (!Number.isFinite(nanoAiu) || nanoAiu <= 0) {
1209
- return null;
1210
- }
1211
-
1212
- const credits = nanoAiu / 1_000_000_000;
1213
-
1214
- return {
1215
- nanoAiu,
1216
- credits,
1217
- usd: credits * 0.01,
1218
- modelCalls: 1,
1219
- };
1220
- }
1221
-
1222
- function sourceUsageSummary(llmRequests) {
1223
- const usages = llmRequests.map(sourceUsageFromNanoAiu).filter(Boolean);
1224
- const nanoAiu = usages.reduce((sum, usage) => sum + usage.nanoAiu, 0);
1225
- const credits = nanoAiu / 1_000_000_000;
1226
-
1227
- return {
1228
- nanoAiu,
1229
- credits,
1230
- usd: credits * 0.01,
1231
- modelCalls: usages.length,
1232
- };
1233
- }
1234
-
1235
- function compactObject(value) {
1236
- if (!value || typeof value !== 'object') {
1237
- return value;
1238
- }
1239
-
1240
- const result = {};
1241
- const skip = new Set(['content', 'response', 'result', 'output', 'stdout', 'stderr']);
1242
-
1243
- for (const [key, nestedValue] of Object.entries(value)) {
1244
- if (skip.has(key)) {
1245
- continue;
1246
- }
1247
-
1248
- if (nestedValue === undefined || nestedValue === null) {
1249
- continue;
1250
- }
1251
-
1252
- result[key] =
1253
- typeof nestedValue === 'object'
1254
- ? boundedText(JSON.stringify(nestedValue), 120)
1255
- : boundedText(nestedValue, 120);
1256
-
1257
- if (Object.keys(result).length >= 6) {
1258
- break;
1259
- }
1260
- }
1261
-
1262
- return result;
1263
- }
1264
-
1265
- function eventAttributeSummary(event) {
1266
- const attrs = event.attrs ?? {};
1267
- const data = event.data ?? {};
1268
- const candidates = [
1269
- ['category', attrs.category],
1270
- ['source', attrs.source],
1271
- ['model', attrs.model],
1272
- ['debugName', attrs.debugName],
1273
- ['inputTokens', attrs.inputTokens],
1274
- ['cachedTokens', attrs.cachedTokens ?? attrs.cachedInputTokens ?? attrs.cacheReadTokens],
1275
- ['cacheWriteTokens', attrs.cacheWriteTokens ?? attrs.cachedWriteTokens],
1276
- ['outputTokens', attrs.outputTokens],
1277
- ['sourceEstimatedCost', attrs.estimatedCost],
1278
- ['copilotUsageNanoAiu', attrs.copilotUsageNanoAiu],
1279
- ['reasoningEffort', event.type === 'llm_request' ? reasoningEffort(event) : undefined],
1280
- ['textVerbosity', event.type === 'llm_request' ? textVerbosity(event) : undefined],
1281
- ['requestShape', event.type === 'llm_request' ? requestShapeSummary(event) : undefined],
1282
- ['maxTokens', attrs.maxTokens],
1283
- ['ttft', attrs.ttft],
1284
- ['systemPromptFile', attrs.systemPromptFile],
1285
- ['toolsFile', attrs.toolsFile],
1286
- ['vscodeVersion', attrs.vscodeVersion],
1287
- ['copilotVersion', attrs.copilotVersion],
1288
- ['responseId', attrs.responseId],
1289
- ['logVersion', event.v],
1290
- ['toolName', data.toolName ?? attrs.toolName],
1291
- ['details', attrs.details],
1292
- ['content', attrs.content],
1293
- [
1294
- 'response',
1295
- event.type === 'agent_response' ? parseAssistantResponse(attrs.response) : undefined,
1296
- ],
1297
- ['data', data && Object.keys(data).length ? data : undefined],
1298
- ];
1299
-
1300
- const fields = [];
1301
- const seen = new Set();
1302
-
1303
- for (const [label, value] of candidates) {
1304
- const summarized = summaryValue(value);
1305
-
1306
- if (!summarized || seen.has(label)) {
1307
- continue;
1308
- }
1309
-
1310
- fields.push({ label, value: summarized });
1311
- seen.add(label);
1312
-
1313
- if (fields.length >= 8) {
1314
- break;
1315
- }
1316
- }
1317
-
1318
- return fields;
438
+ export function sessionFromDebugLog(sessionDir, workspaceDir) {
439
+ return sessionParser().sessionFromDebugLog(sessionDir, workspaceDir);
1319
440
  }
1320
441
 
1321
- function capTraceEvents(events) {
1322
- return events.slice(0, traceEventLimit);
442
+ export function sessionFromChatSnapshot(file, workspaceDir) {
443
+ return sessionParser().sessionFromChatSnapshot(file, workspaceDir);
1323
444
  }
1324
445
 
1325
446
  function workspaceName(workspaceDir) {
@@ -1329,397 +450,42 @@ function workspaceName(workspaceDir) {
1329
450
  return folder ? basename(folder) : basename(workspaceDir);
1330
451
  }
1331
452
 
1332
- function parseAssistantResponse(raw) {
1333
- const parsed = typeof raw === 'string' ? safeJson(raw) : raw;
1334
- if (!Array.isArray(parsed)) {
1335
- return String(raw ?? '');
1336
- }
1337
-
1338
- return parsed
1339
- .flatMap((message) => message?.parts ?? [])
1340
- .map((part) => {
1341
- const content =
1342
- typeof part?.content === 'string'
1343
- ? (safeJson(part.content) ?? part.content)
1344
- : part?.content;
1345
- return typeof content === 'object' && content?.text ? content.text : String(content ?? '');
1346
- })
1347
- .filter(Boolean)
1348
- .join('\n');
1349
- }
1350
-
1351
- export function sessionFromDebugLog(sessionDir, workspaceDir) {
1352
- const main = readJsonl(join(sessionDir, 'main.jsonl'));
1353
- if (!main.length) {
1354
- diagnostics.skippedEmptyDebugLogs += 1;
1355
- return null;
1356
- }
1357
-
1358
- const sid = basename(sessionDir);
1359
- const userMessages = main.filter((event) => event.type === 'user_message');
1360
- const llmRequests = main.filter((event) => event.type === 'llm_request');
1361
- const assistantEvents = main.filter(
1362
- (event) => event.type === 'agent_response' || event.type === 'assistant.message',
1363
- );
1364
- const toolEvents = main.filter((event) => String(event.type ?? '').includes('tool'));
1365
- const errorEvents = main.filter((event) => event.status && event.status !== 'ok');
1366
-
1367
- if (!userMessages.length && !llmRequests.length && !assistantEvents.length) {
1368
- diagnostics.skippedEmptyDebugLogs += 1;
1369
- return null;
453
+ function workspaceFolderPath(workspaceDir) {
454
+ const workspaceJson = join(workspaceDir, 'workspace.json');
455
+ const raw = existsSync(workspaceJson) ? safeJson(readFileSync(workspaceJson, 'utf8')) : null;
456
+ if (!raw?.folder) {
457
+ return '';
1370
458
  }
1371
459
 
1372
- const firstUserMessage = userMessages[0]?.attrs?.content ?? 'Untitled Copilot session';
1373
- const modelBreakdown = modelBreakdownFromLlmRequests(llmRequests);
1374
- const model = sessionModelLabel(
1375
- modelBreakdown,
1376
- llmRequests.find((event) => event.attrs?.model)?.attrs?.model,
1377
- );
1378
- const input = llmRequests.reduce(
1379
- (sum, event) => sum + llmTokenFields(event).billableInputTokens,
1380
- 0,
1381
- );
1382
- const cachedInput = llmRequests.reduce(
1383
- (sum, event) => sum + llmTokenFields(event).cachedInputTokens,
1384
- 0,
1385
- );
1386
- const cacheWrite = llmRequests.reduce(
1387
- (sum, event) => sum + llmTokenFields(event).cacheWriteTokens,
1388
- 0,
1389
- );
1390
- const output = llmRequests.reduce((sum, event) => sum + llmTokenFields(event).outputTokens, 0);
1391
- const tokens = { input, cachedInput, cacheWrite, output };
1392
- const usd = modelBreakdown.length
1393
- ? modelBreakdown.reduce((sum, entry) => sum + entry.cost.usd, 0)
1394
- : costUsd(model, tokens);
1395
- const startEvent = main.find((event) => event.type === 'session_start') ?? main[0];
1396
- const debugLogRuntime = {
1397
- logVersion: Number(startEvent?.v ?? 0) || 0,
1398
- vscodeVersion: String(startEvent?.attrs?.vscodeVersion ?? '').trim(),
1399
- copilotVersion: String(startEvent?.attrs?.copilotVersion ?? '').trim(),
1400
- };
1401
- const lastEvent = main[main.length - 1];
1402
- const startedAt =
1403
- startEvent?.timestamp ??
1404
- new Date(
1405
- Number(startEvent?.ts ?? statSync(join(sessionDir, 'main.jsonl')).mtimeMs),
1406
- ).toISOString();
1407
- const endedAt =
1408
- lastEvent?.timestamp ??
1409
- new Date(
1410
- Number(lastEvent?.ts ?? statSync(join(sessionDir, 'main.jsonl')).mtimeMs),
1411
- ).toISOString();
1412
- const evidence = debugEvidence(llmRequests, assistantEvents, main);
1413
- const payload = requestPayloadSummary(sessionDir, llmRequests, toolEvents);
1414
- const modelLimits = modelLimitSummaries(sessionDir, llmRequests);
1415
- const cacheTokenAudit = cacheTokenAuditFromLlmRequests(llmRequests);
1416
- const sourceUsage = sourceUsageSummary(llmRequests);
1417
- const setupPayloadForEvent = modelCallSetupPayloadFactory(sessionDir);
1418
- const transcript = transcriptAvailability(workspaceDir, sid);
1419
- const memoryRecalls = memoryRecallsFromDebugLog(sessionDir, workspaceName(workspaceDir));
1420
-
1421
- if (transcript.available) {
1422
- diagnostics.debugLogSessionsWithTranscripts += 1;
1423
- diagnostics.transcriptEventsAvailable += transcript.eventCount;
460
+ try {
461
+ const value = String(raw.folder);
462
+ if (!value.startsWith('file:')) {
463
+ return '';
464
+ }
465
+ const folder = decodeURIComponent(value.replace(/^file:\/+/, ''));
466
+ const resolved = platform() === 'win32' ? folder.replace(/^\//, '') : `/${folder.replace(/^\/+/, '')}`;
467
+ return isAbsolute(resolved) && existsSync(resolved) ? resolved : '';
468
+ } catch {
469
+ return '';
1424
470
  }
471
+ }
1425
472
 
1426
- if (cacheTokenAudit.invalidCachedTokenSplits > 0) {
1427
- diagnostics.warnings.push(
1428
- `${sessionDir}: ${cacheTokenAudit.invalidCachedTokenSplits} model call(s) reported cachedTokens greater than inputTokens; cached input was clamped for pricing safety`,
1429
- );
473
+ function normalizedWorkspaceFolderScope(workspaceFolders) {
474
+ if (!Array.isArray(workspaceFolders)) {
475
+ return new Set();
1430
476
  }
1431
-
1432
- const turns = [
1433
- ...userMessages.map((event) => ({
1434
- role: 'user',
1435
- text: String(event.attrs?.content ?? ''),
1436
- tokens: estimateTokens(event.attrs?.content),
1437
- })),
1438
- ...assistantEvents.map((event) => {
1439
- const text =
1440
- event.type === 'assistant.message'
1441
- ? event.data?.content
1442
- : parseAssistantResponse(event.attrs?.response);
1443
- return { role: 'assistant', text: String(text ?? ''), tokens: estimateTokens(text) };
1444
- }),
1445
- ].filter((turn) => turn.text.trim());
1446
-
1447
- return {
1448
- id: sid,
1449
- sourceKind: 'vscode-copilot-debug-log',
1450
- tokenSource: llmRequests.length
1451
- ? 'llm_request_token_totals'
1452
- : 'debug-log-visible-text-estimate',
1453
- sessionType: 'Local',
1454
- location: 'Chat Panel',
1455
- status: 'Idle',
1456
- title: firstUserMessage.slice(0, 80),
1457
- firstPrompt: firstUserMessage.slice(0, 240),
1458
- workspace: workspaceName(workspaceDir),
1459
- sourcePath: sessionDir,
1460
- ...(debugLogRuntime.logVersion ||
1461
- debugLogRuntime.vscodeVersion ||
1462
- debugLogRuntime.copilotVersion
1463
- ? { debugLogRuntime }
1464
- : {}),
1465
- model,
1466
- modelBreakdown,
1467
- startedAt,
1468
- endedAt,
1469
- tags: ['debug-log', llmRequests.length ? 'llm-request-token-totals' : 'estimated-visible-text'],
1470
- toolsUsed: [
1471
- ...new Set(
1472
- toolEvents
1473
- .map((event) => event.data?.toolName ?? event.attrs?.toolName ?? event.name)
1474
- .filter(Boolean),
1475
- ),
1476
- ],
1477
- tokens,
1478
- cost: { usd, eur: usd * usdToEur },
1479
- confidence: llmRequests.length ? 'exact' : 'estimated',
1480
- traceSummary: {
1481
- modelTurns: llmRequests.length,
1482
- toolCalls: toolEvents.length,
1483
- totalTokens: input + cachedInput + cacheWrite + output,
1484
- errors: errorEvents.length,
1485
- totalEvents: main.length,
1486
- reasoningEvents: evidence.reasoning.events,
1487
- maxInputTokens: evidence.context.maxInputTokens,
1488
- maxRequestTokens: evidence.context.maxRequestTokens,
1489
- reasoningEfforts: payload.reasoningEfforts,
1490
- },
1491
- cacheTokenAudit,
1492
- ...(sourceUsage.modelCalls > 0 ? { sourceUsage } : {}),
1493
- transcript,
1494
- advancedSignals: evidence,
1495
- requestPayload: payload,
1496
- modelLimits,
1497
- ...(memoryRecalls.length ? { memoryRecalls } : {}),
1498
- traceEvents: capTraceEvents(
1499
- main.map((event, index) => {
1500
- const tokenFields =
1501
- event.type === 'llm_request'
1502
- ? llmTokenFields(event)
1503
- : {
1504
- inputTokens: 0,
1505
- billableInputTokens: 0,
1506
- cachedInputTokens: 0,
1507
- cacheWriteTokens: 0,
1508
- outputTokens: 0,
1509
- };
1510
-
1511
- const setupPayload = setupPayloadForEvent(event);
1512
- const sourceUsage = sourceUsageFromNanoAiu(event);
1513
- const requestShape = event.type === 'llm_request' ? requestShapeMetadata(event) : null;
1514
-
1515
- return {
1516
- index,
1517
- timestamp: timestampForEvent(event),
1518
- type: String(event.type ?? 'unknown'),
1519
- name: String(event.name ?? event.type ?? 'unknown'),
1520
- status: String(event.status ?? 'unknown'),
1521
- detail: eventDetail(event),
1522
- attributes: eventAttributeSummary(event),
1523
- inputTokens: tokenFields.inputTokens,
1524
- cachedInputTokens: tokenFields.cachedInputTokens,
1525
- cacheWriteTokens: tokenFields.cacheWriteTokens,
1526
- outputTokens: tokenFields.outputTokens,
1527
- ttftMs: event.type === 'llm_request' ? Number(event.attrs?.ttft ?? 0) : 0,
1528
- maxTokens: event.type === 'llm_request' ? Number(event.attrs?.maxTokens ?? 0) : 0,
1529
- hasReasoning:
1530
- event.type === 'agent_response' && Boolean(String(event.attrs?.reasoning ?? '').trim()),
1531
- reasoningEffort: event.type === 'llm_request' ? reasoningEffort(event) : '',
1532
- ...(requestShape ? { requestShape } : {}),
1533
- ...(event.type === 'llm_request'
1534
- ? eventModelCostFields(event.attrs?.model, tokenFields)
1535
- : {}),
1536
- ...(event.type === 'llm_request' && sourceEstimatedCost(event)
1537
- ? { sourceEstimatedCost: sourceEstimatedCost(event) }
1538
- : {}),
1539
- ...(sourceUsage ? { sourceUsage } : {}),
1540
- ...(setupPayload ? { setupPayload } : {}),
1541
- };
1542
- }),
1543
- ),
1544
- turns: turns.slice(0, 60),
1545
- };
477
+ return new Set(workspaceFolders.map(normalizeWorkspacePath).filter(Boolean));
1546
478
  }
1547
479
 
1548
- export function sessionFromChatSnapshot(file, workspaceDir) {
1549
- const records = readJsonl(file);
1550
- const snapshot =
1551
- records.find((record) => record.kind === 0 && record.v?.requests)?.v ??
1552
- records[0]?.v ??
1553
- records[0];
1554
- const requests = snapshot?.requests ?? [];
1555
-
1556
- if (!requests.length) {
1557
- diagnostics.skippedChatSnapshotsWithoutRequests += 1;
1558
- return null;
480
+ function normalizeWorkspacePath(path) {
481
+ if (!path) {
482
+ return '';
483
+ }
484
+ try {
485
+ return resolve(String(path)).toLowerCase();
486
+ } catch {
487
+ return '';
1559
488
  }
1560
-
1561
- const firstRequest = requests[0];
1562
- const firstPrompt =
1563
- firstRequest?.message?.text ?? snapshot?.customTitle ?? 'Untitled Copilot session';
1564
- const model = normalizeModel(
1565
- firstRequest?.modelId ?? firstRequest?.inputState?.selectedModel?.identifier,
1566
- pricing,
1567
- );
1568
- const output = requests.reduce((sum, request) => sum + Number(request.completionTokens ?? 0), 0);
1569
- const input = requests.reduce(
1570
- (sum, request) => sum + estimateTokens(request?.message?.text ?? ''),
1571
- 0,
1572
- );
1573
- const tokens = { input, cachedInput: 0, cacheWrite: 0, output };
1574
- const usd = costUsd(model, tokens);
1575
- const pricingModel = pricingModelForModel(model, pricing, fallbackPricingModel);
1576
- const startedAt = new Date(Number(snapshot.creationDate ?? statSync(file).mtimeMs)).toISOString();
1577
- const endedAt = statSync(file).mtime.toISOString();
1578
-
1579
- return {
1580
- id: basename(file, '.jsonl'),
1581
- sourceKind: 'vscode-chat-session-snapshot',
1582
- tokenSource: 'chat-snapshot-output-plus-visible-input-estimate',
1583
- sessionType: 'Local',
1584
- location: 'Chat Panel',
1585
- status: 'Idle',
1586
- title: String(snapshot.customTitle ?? firstPrompt).slice(0, 80),
1587
- firstPrompt: String(firstPrompt).slice(0, 240),
1588
- workspace: workspaceName(workspaceDir),
1589
- sourcePath: file,
1590
- model,
1591
- modelBreakdown: [
1592
- {
1593
- model,
1594
- rawModels: [
1595
- String(
1596
- firstRequest?.modelId ?? firstRequest?.inputState?.selectedModel?.identifier ?? model,
1597
- ),
1598
- ],
1599
- turns: requests.length,
1600
- tokens,
1601
- cost: { usd, eur: usd * usdToEur },
1602
- pricingModel,
1603
- },
1604
- ],
1605
- startedAt,
1606
- endedAt,
1607
- tags: ['chat-session', 'estimated-input'],
1608
- toolsUsed: [],
1609
- tokens,
1610
- cost: { usd, eur: usd * usdToEur },
1611
- confidence: 'estimated',
1612
- traceSummary: {
1613
- modelTurns: requests.length,
1614
- toolCalls: 0,
1615
- totalTokens: input + output,
1616
- errors: 0,
1617
- totalEvents: records.length,
1618
- reasoningEvents: 0,
1619
- maxInputTokens: input,
1620
- maxRequestTokens: 0,
1621
- },
1622
- cacheTokenAudit: {
1623
- modelCalls: 0,
1624
- callsWithCachedTokens: 0,
1625
- invalidCachedTokenSplits: 0,
1626
- rawInputTokens: 0,
1627
- normalInputTokens: 0,
1628
- cachedInputTokens: 0,
1629
- cacheWriteTokens: 0,
1630
- outputTokens: 0,
1631
- maxCachedInputShare: 0,
1632
- },
1633
- transcript: {
1634
- available: false,
1635
- sourcePath: '',
1636
- eventCount: 0,
1637
- },
1638
- advancedSignals: {
1639
- reasoning: {
1640
- visible: false,
1641
- level: '',
1642
- events: 0,
1643
- source: '',
1644
- help: 'Chat snapshots do not expose agent_response reasoning text or a reasoning-level field.',
1645
- },
1646
- context: {
1647
- maxInputTokens: input,
1648
- maxRequestTokens: 0,
1649
- outputCaps: [],
1650
- requestCapShare: null,
1651
- source: 'estimated visible chat text',
1652
- help: 'Chat snapshots only provide visible text context here, so context pressure is not reliable for cost debugging.',
1653
- },
1654
- },
1655
- traceEvents: capTraceEvents(
1656
- requests.flatMap((request, index) => {
1657
- const rawRequestModel =
1658
- request?.modelId ?? request?.inputState?.selectedModel?.identifier ?? model;
1659
- const userInputTokens = estimateTokens(request?.message?.text ?? '');
1660
- const assistantOutputTokens = Number(request.completionTokens ?? 0);
1661
-
1662
- return [
1663
- {
1664
- index: index * 2,
1665
- timestamp: startedAt,
1666
- type: 'user_message',
1667
- name: 'user_message',
1668
- status: 'ok',
1669
- detail: String(request?.message?.text ?? '').slice(0, 140),
1670
- inputTokens: userInputTokens,
1671
- cachedInputTokens: 0,
1672
- cacheWriteTokens: 0,
1673
- outputTokens: 0,
1674
- ...eventModelCostFields(rawRequestModel, {
1675
- inputTokens: userInputTokens,
1676
- billableInputTokens: userInputTokens,
1677
- cachedInputTokens: 0,
1678
- cacheWriteTokens: 0,
1679
- outputTokens: 0,
1680
- }),
1681
- },
1682
- {
1683
- index: index * 2 + 1,
1684
- timestamp: endedAt,
1685
- type: 'assistant_response',
1686
- name: 'assistant_response',
1687
- status: 'ok',
1688
- detail: `${assistantOutputTokens.toLocaleString()} completion tokens`,
1689
- inputTokens: 0,
1690
- cachedInputTokens: 0,
1691
- cacheWriteTokens: 0,
1692
- outputTokens: assistantOutputTokens,
1693
- ...eventModelCostFields(rawRequestModel, {
1694
- inputTokens: 0,
1695
- billableInputTokens: 0,
1696
- cachedInputTokens: 0,
1697
- cacheWriteTokens: 0,
1698
- outputTokens: assistantOutputTokens,
1699
- }),
1700
- },
1701
- ];
1702
- }),
1703
- ),
1704
- turns: requests
1705
- .flatMap((request) => [
1706
- {
1707
- role: 'user',
1708
- text: String(request?.message?.text ?? ''),
1709
- tokens: estimateTokens(request?.message?.text),
1710
- },
1711
- {
1712
- role: 'assistant',
1713
- text: (request?.response ?? [])
1714
- .map((part) => part.value ?? part.generatedTitle ?? part.kind ?? '')
1715
- .filter(Boolean)
1716
- .join('\n'),
1717
- tokens: Number(request.completionTokens ?? 0),
1718
- },
1719
- ])
1720
- .filter((turn) => turn.text.trim())
1721
- .slice(0, 60),
1722
- };
1723
489
  }
1724
490
 
1725
491
  function enrichSessionFromWorkspaceState(session, stateBySessionId) {
@@ -1767,66 +533,61 @@ function enrichSessionFromWorkspaceState(session, stateBySessionId) {
1767
533
  };
1768
534
  }
1769
535
 
1770
- function parseWorkspace(workspaceDir) {
1771
- diagnostics.scannedWorkspaces += 1;
1772
- const stateBySessionId = readWorkspaceState(workspaceDir);
1773
- const workspace = workspaceName(workspaceDir);
1774
- const debugRoot = join(workspaceDir, 'GitHub.copilot-chat', 'debug-logs');
1775
- const debugSessions = listDirs(debugRoot)
1776
- .map((sessionDir) => sessionFromDebugLog(sessionDir, workspaceDir))
1777
- .filter(Boolean)
1778
- .map((session) => enrichSessionFromWorkspaceState(session, stateBySessionId));
1779
- const debugIds = new Set(debugSessions.map((session) => session.id));
1780
- diagnostics.importedDebugLogSessions += debugSessions.length;
1781
-
1782
- const chatSessions = listFiles(join(workspaceDir, 'chatSessions'), '.jsonl')
1783
- .map((file) => {
1784
- const session = sessionFromChatSnapshot(file, workspaceDir);
1785
- if (session && debugIds.has(session.id)) {
1786
- diagnostics.skippedDuplicateChatSnapshots += 1;
1787
- return null;
1788
- }
1789
- return session ? enrichSessionFromWorkspaceState(session, stateBySessionId) : null;
1790
- })
1791
- .filter(Boolean);
1792
- diagnostics.importedChatSnapshotSessions += chatSessions.length;
536
+ function customizationInventoryScanner() {
537
+ return createCustomizationInventoryScanner({
538
+ diagnostics: () => diagnostics,
539
+ listDirs,
540
+ listFilesRecursive,
541
+ readJsonl,
542
+ workspaceFolderPath,
543
+ workspaceName,
544
+ });
545
+ }
1793
546
 
1794
- return {
1795
- sessions: [...debugSessions, ...chatSessions],
1796
- memories: memoriesFromRoot(
1797
- join(workspaceDir, 'GitHub.copilot-chat', 'memory-tool', 'memories'),
1798
- 'workspace',
1799
- workspace,
1800
- ),
1801
- };
547
+ function customizationsFromWorkspace(workspaceDir, options = {}) {
548
+ return customizationInventoryScanner().customizationsFromWorkspace(workspaceDir, options);
1802
549
  }
1803
550
 
1804
- function workspaceDirsFromUserDir(userDir) {
1805
- const workspaceStorage = join(userDir, 'workspaceStorage');
1806
- return listDirs(workspaceStorage);
551
+ function customizationsFromDiscoveryFolders(debugRoot, workspace, options = {}) {
552
+ return customizationInventoryScanner().customizationsFromDiscoveryFolders(debugRoot, workspace, options);
1807
553
  }
1808
554
 
1809
- function workspaceDirsForRoot(root) {
1810
- if (existsSync(join(root, 'workspace.json'))) {
1811
- return [root];
1812
- }
1813
- if (basename(root) === 'workspaceStorage') {
1814
- return listDirs(root);
1815
- }
1816
- return workspaceDirsFromUserDir(root);
555
+ function customizationsFromDebugReferences(debugRoot, bases, workspace, options = {}) {
556
+ return customizationInventoryScanner().customizationsFromDebugReferences(debugRoot, bases, workspace, options);
1817
557
  }
1818
558
 
1819
- function userDirForRoot(root) {
1820
- if (existsSync(join(root, 'workspaceStorage'))) {
1821
- return root;
1822
- }
1823
- if (basename(root) === 'workspaceStorage') {
1824
- return dirname(root);
1825
- }
1826
- if (basename(dirname(root)) === 'workspaceStorage') {
1827
- return dirname(dirname(root));
1828
- }
1829
- return null;
559
+ function customizationEvidenceFromDebugLogs(
560
+ debugRoot,
561
+ customizations,
562
+ workspace = '',
563
+ workspaceDir = '',
564
+ onProgress = () => {},
565
+ evidenceOptions = {},
566
+ ) {
567
+ return customizationEvidenceFromDebugLogsCore(debugRoot, customizations, workspace, workspaceDir, onProgress, {
568
+ ...evidenceOptions,
569
+ diagnostics,
570
+ listDirs,
571
+ readJsonl,
572
+ });
573
+ }
574
+
575
+ function workspaceScannerDependencies() {
576
+ return {
577
+ customizationsFromDebugReferences,
578
+ customizationsFromDiscoveryFolders,
579
+ customizationsFromWorkspace,
580
+ customizationEvidenceFromDebugLogs,
581
+ diagnostics,
582
+ enrichSessionFromWorkspaceState,
583
+ listDirs,
584
+ listFiles,
585
+ memoriesFromRoot,
586
+ readWorkspaceState,
587
+ sessionFromChatSnapshot,
588
+ sessionFromDebugLog,
589
+ workspaceName,
590
+ };
1830
591
  }
1831
592
 
1832
593
  /**
@@ -1843,7 +604,7 @@ export async function scanVsCodeSessions(options = {}) {
1843
604
  if (!Array.isArray(configuredRoots)) {
1844
605
  throw new TypeError('roots must be an array of VS Code user-data or workspace-storage paths.');
1845
606
  }
1846
- const roots = [...new Set(configuredRoots.map((root) => resolve(String(root))))];
607
+ const roots = uniqueResolvedRoots(configuredRoots);
1847
608
  const conversionRate = Number(options.usdToEur ?? process.env.USD_TO_EUR ?? 1);
1848
609
  if (!Number.isFinite(conversionRate) || conversionRate <= 0) {
1849
610
  throw new TypeError('usdToEur must be a positive number.');
@@ -1860,11 +621,73 @@ export async function scanVsCodeSessions(options = {}) {
1860
621
  diagnostics = createDiagnostics();
1861
622
  diagnostics.scannedRoots = roots;
1862
623
  usdToEur = conversionRate;
624
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
625
+ const workspaceOptions = {
626
+ includeCustomizations: options.includeCustomizations !== false,
627
+ includeSystemCustomizations: options.includeSystemCustomizations === true,
628
+ customizationDiscovery: options.customizationDiscovery ?? null,
629
+ customizationEvidence: options.customizationEvidence ?? {},
630
+ incrementalSince: options.incrementalSince ?? '',
631
+ };
1863
632
 
1864
633
  try {
634
+ onProgress({
635
+ stage: 'roots',
636
+ message: `Scanning ${roots.length} VS Code root${roots.length === 1 ? '' : 's'}.`,
637
+ roots,
638
+ });
1865
639
  DatabaseSync = options.sqlite === false ? null : await loadSqliteSupport();
1866
- const workspaceDirs = [...new Set(roots.flatMap(workspaceDirsForRoot))];
1867
- const workspaceResults = workspaceDirs.map(parseWorkspace);
640
+ let workspaceDirs = [
641
+ ...new Set(roots.flatMap((root) => workspaceDirsForRoot(root, traversalOptions()))),
642
+ ];
643
+ const workspaceFolderScope = normalizedWorkspaceFolderScope(options.workspaceFolders);
644
+ if (workspaceFolderScope.size) {
645
+ const scopedWorkspaceDirs = dedupeWorkspaceDirsByFolder(
646
+ workspaceDirs.filter((workspaceDir) =>
647
+ workspaceFolderScope.has(normalizeWorkspacePath(workspaceFolderPath(workspaceDir))),
648
+ ),
649
+ );
650
+ onProgress({
651
+ stage: 'workspace-scope',
652
+ message: `Current workspace scope matched ${scopedWorkspaceDirs.length} VS Code storage entr${scopedWorkspaceDirs.length === 1 ? 'y' : 'ies'}.`,
653
+ total: scopedWorkspaceDirs.length,
654
+ });
655
+ workspaceDirs = scopedWorkspaceDirs;
656
+ } else if (options.requireWorkspaceFolders === true) {
657
+ onProgress({
658
+ stage: 'workspace-scope',
659
+ message: 'No current VS Code workspace folder was provided; skipping broad customization evidence scan.',
660
+ total: 0,
661
+ });
662
+ workspaceDirs = [];
663
+ }
664
+ onProgress({
665
+ stage: 'workspaces',
666
+ message: `Found ${workspaceDirs.length} VS Code storage entr${workspaceDirs.length === 1 ? 'y' : 'ies'}.`,
667
+ total: workspaceDirs.length,
668
+ });
669
+ const workspaceResults = [];
670
+ for (const [index, workspaceDir] of workspaceDirs.entries()) {
671
+ if (index > 0 && index % 50 === 0) {
672
+ onProgress({
673
+ stage: 'workspace-queue',
674
+ message: `Checked ${index}/${workspaceDirs.length} VS Code storage entries.`,
675
+ index,
676
+ total: workspaceDirs.length,
677
+ });
678
+ }
679
+ workspaceResults.push(parseWorkspaceEntry(
680
+ workspaceDir,
681
+ {
682
+ ...workspaceOptions,
683
+ workspaceIndex: index + 1,
684
+ workspaceTotal: workspaceDirs.length,
685
+ },
686
+ onProgress,
687
+ workspaceScannerDependencies(),
688
+ ));
689
+ await yieldToRuntime();
690
+ }
1868
691
  const sessions = workspaceResults
1869
692
  .flatMap((result) => result.sessions)
1870
693
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
@@ -1875,6 +698,11 @@ export async function scanVsCodeSessions(options = {}) {
1875
698
  .map((userDir) => join(userDir, 'globalStorage', 'github.copilot-chat', 'memory-tool', 'memories')),
1876
699
  )];
1877
700
  const memoryMap = new Map();
701
+ onProgress({
702
+ stage: 'memories',
703
+ message: `Indexing memories from ${globalMemoryRoots.length} global root${globalMemoryRoots.length === 1 ? '' : 's'} and VS Code storage.`,
704
+ total: globalMemoryRoots.length,
705
+ });
1878
706
  for (const memory of [
1879
707
  ...workspaceResults.flatMap((result) => result.memories),
1880
708
  ...globalMemoryRoots.flatMap((root) => memoriesFromRoot(root, 'global')),
@@ -1884,6 +712,19 @@ export async function scanVsCodeSessions(options = {}) {
1884
712
  const memories = attachMemoryRecalls([...memoryMap.values()], sessions).sort((a, b) =>
1885
713
  b.modifiedAt.localeCompare(a.modifiedAt),
1886
714
  );
715
+ const customizationMap = new Map();
716
+ for (const customization of workspaceResults.flatMap((result) => result.customizations)) {
717
+ customizationMap.set(
718
+ customization.id,
719
+ mergeCustomizationRecords(customizationMap.get(customization.id), customization),
720
+ );
721
+ }
722
+ const customizations = [...customizationMap.values()].sort(
723
+ (a, b) =>
724
+ statusRank(b.evidenceStatus) - statusRank(a.evidenceStatus) ||
725
+ b.modifiedAt.localeCompare(a.modifiedAt),
726
+ );
727
+ diagnostics.importedCustomizations = customizations.length;
1887
728
  const seenIds = new Set();
1888
729
  for (const session of sessions) {
1889
730
  if (seenIds.has(session.id)) {
@@ -1892,6 +733,13 @@ export async function scanVsCodeSessions(options = {}) {
1892
733
  seenIds.add(session.id);
1893
734
  }
1894
735
 
736
+ onProgress({
737
+ stage: 'complete',
738
+ message: `Scan complete: imported ${sessions.length} session${sessions.length === 1 ? '' : 's'}.`,
739
+ sessions: sessions.length,
740
+ workspaces: workspaceDirs.length,
741
+ });
742
+
1895
743
  return {
1896
744
  schemaVersion: sessionDataSchemaVersion,
1897
745
  generatedAt,
@@ -1901,11 +749,15 @@ export async function scanVsCodeSessions(options = {}) {
1901
749
  ingestion: {
1902
750
  ...diagnostics,
1903
751
  importedSessions: sessions.length,
752
+ scanKind: options.incrementalSince ? 'incremental' : 'full',
753
+ incrementalSince: options.incrementalSince ?? '',
754
+ customizationEvidenceAnalyzedAt: options.includeCustomizations !== false ? generatedAt : '',
1904
755
  cacheTokenAudit: mergeCacheTokenAudits(
1905
756
  sessions.map((session) => session.cacheTokenAudit).filter(Boolean),
1906
757
  ),
1907
758
  },
1908
759
  memories,
760
+ customizations,
1909
761
  sessions,
1910
762
  };
1911
763
  } finally {
@@ -1916,6 +768,26 @@ export async function scanVsCodeSessions(options = {}) {
1916
768
  }
1917
769
  }
1918
770
 
771
+ function yieldToRuntime() {
772
+ return new Promise((resolveYield) => setImmediate(resolveYield));
773
+ }
774
+
775
+ function dedupeWorkspaceDirsByFolder(workspaceDirs) {
776
+ const seen = new Set();
777
+ const deduped = [];
778
+ for (const workspaceDir of workspaceDirs) {
779
+ const folder = normalizeWorkspacePath(workspaceFolderPath(workspaceDir));
780
+ const key = folder || normalizeWorkspacePath(workspaceDir);
781
+ if (seen.has(key)) {
782
+ diagnostics.warnings.push(`Duplicate VS Code storage entry skipped for current workspace: ${workspaceDir}`);
783
+ continue;
784
+ }
785
+ seen.add(key);
786
+ deduped.push(workspaceDir);
787
+ }
788
+ return deduped;
789
+ }
790
+
1919
791
  export function writeSessionData(sessionData, outputFile = 'public/data/sessions.json') {
1920
792
  const resolvedOutputFile = resolve(outputFile);
1921
793
  mkdirSync(dirname(resolvedOutputFile), { recursive: true });
@@ -1923,16 +795,56 @@ export function writeSessionData(sessionData, outputFile = 'public/data/sessions
1923
795
  return resolvedOutputFile;
1924
796
  }
1925
797
 
1926
- export async function runScannerCli(args = process.argv.slice(2), logger = console) {
1927
- const outputFile = args[0] ?? 'public/data/sessions.json';
1928
- const roots = args.slice(1);
1929
- const sessionData = await scanVsCodeSessions(roots.length ? { roots } : {});
1930
- const resolvedOutputFile = writeSessionData(sessionData, outputFile);
798
+ export async function runScannerCli(args = process.argv.slice(2), logger = console, dependencies = {}) {
799
+ const { outputFile, roots } = parseScannerCliArgs(args);
800
+ const scanner = dependencies.scanner ?? scanVsCodeSessions;
801
+ const writer = dependencies.writer ?? writeSessionData;
802
+ const sessionData = await scanner(roots.length ? { roots } : {});
803
+ const resolvedOutputFile = writer(sessionData, outputFile);
1931
804
 
1932
805
  logger.log(`Wrote ${sessionData.sessions.length} sessions to ${resolvedOutputFile}`);
1933
806
  return sessionData;
1934
807
  }
1935
808
 
809
+ function parseScannerCliArgs(args) {
810
+ let outputFile = '';
811
+ const roots = [];
812
+
813
+ for (let index = 0; index < args.length; index += 1) {
814
+ const argument = args[index];
815
+ const [flag, inlineValue] = argument.split('=', 2);
816
+ if (flag === '--output' || flag === '--root') {
817
+ const value = inlineValue ?? args[index + 1];
818
+ if (!value) {
819
+ throw new Error(`${flag} requires a value.`);
820
+ }
821
+ if (inlineValue === undefined) {
822
+ index += 1;
823
+ }
824
+ if (flag === '--output') {
825
+ outputFile = value;
826
+ } else {
827
+ roots.push(value);
828
+ }
829
+ continue;
830
+ }
831
+
832
+ if (argument.startsWith('-')) {
833
+ throw new Error(`Unknown option: ${argument}`);
834
+ }
835
+ if (!outputFile) {
836
+ outputFile = argument;
837
+ } else {
838
+ roots.push(argument);
839
+ }
840
+ }
841
+
842
+ return {
843
+ outputFile: outputFile || 'public/data/sessions.json',
844
+ roots,
845
+ };
846
+ }
847
+
1936
848
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1937
849
  await runScannerCli();
1938
850
  }