copilot-usage-studio 0.1.0 → 0.2.1

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