copilot-usage-studio 0.2.1 → 0.2.3

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  The app is intended to run locally, near the VS Code data it reads. It should not require a hosted SaaS backend for the core workflow.
4
4
 
5
- Current release posture: early VS Code extension preview. Version `0.2.0` exists on npm as the development/fallback host, but the VS Code extension is the product path.
5
+ Current release posture: the VS Code Marketplace extension is the product path. The npm package remains a development and fallback host.
6
6
 
7
7
  Packaging foundation now available: the scanner exposes an in-memory Node API through `lib/scanner-api.mjs`. The existing scan command, local runtime, npm host, and VS Code extension preview use the same scanner/runtime core so behavior stays aligned across hosts.
8
8
 
@@ -94,10 +94,10 @@ GitHub Actions is the release control plane:
94
94
 
95
95
  - `.github/workflows/ci.yml` runs the full release gate and packages a VSIX artifact for every pushed branch, plus pull requests to `main`.
96
96
  - `.github/workflows/release.yml` runs automatically for version tags such as `v0.1.1`, with a manual existing-tag mode for repair and backfill.
97
- - The release workflow verifies that the tag matches `package.json`, runs the release gate, packages the VS Code extension VSIX, generates release notes from `CHANGELOG.md`, and creates the matching GitHub Release.
98
- - After maintainer smoke testing and Marketplace credentials are configured, public extension releases should be published to the VS Code Marketplace so users can install without manually downloading a VSIX.
97
+ - The release workflow verifies that the tag matches both package versions, runs the release gate, packages one VSIX, publishes that artifact to the VS Code Marketplace, and attaches the exact VSIX to the matching GitHub Release.
98
+ - Marketplace publication uses GitHub OIDC and Microsoft Entra workload identity federation. No Marketplace token or client secret is stored in GitHub.
99
99
  - A failed workflow can be rerun safely: an existing npm version is accepted only when its published `gitHead` matches the exact tagged commit. Conflicting or unverifiable versions are refused.
100
- - New versions must pass the full release gate before publication. An exact-commit backfill of an already-published historical version skips its old test suite and only repairs the missing GitHub Release.
100
+ - Every release and repair run passes the current release gate before publication.
101
101
 
102
102
  This keeps GitHub, release artifacts, release notes, and the source tag tied to the same commit. Ordinary pushes never publish.
103
103
 
@@ -120,10 +120,28 @@ Commit and push the workflow files first, then configure npm Trusted Publishing
120
120
  3. Set repository to `copilot-usage-studio`.
121
121
  4. Set workflow filename to `release.yml`.
122
122
  5. Allow the `npm publish` action.
123
- 6. Leave the environment blank unless the workflow is later changed to use a protected GitHub environment.
123
+ 6. Set the trusted-publisher environment to `marketplace`, matching the protected environment used by the release job.
124
124
 
125
125
  The workflow uses GitHub's OIDC identity, so no long-lived `NPM_TOKEN` repository secret is required. npm automatically adds provenance for a public package published from a public repository through Trusted Publishing.
126
126
 
127
+ ### One-Time VS Code Marketplace Setup
128
+
129
+ 1. In Microsoft Entra ID, create an app registration for Marketplace publishing. Record its application (client) ID and directory (tenant) ID. Do not create a client secret.
130
+ 2. Add a federated credential to that app registration using the GitHub Actions scenario:
131
+ - organization: `fortunes-guardian`
132
+ - repository: `copilot-usage-studio`
133
+ - entity type: **Environment**
134
+ - environment: `marketplace`
135
+ 3. In GitHub, create an environment named `marketplace`. Restrict deployment to protected version tags when practical.
136
+ 4. Add environment variables `AZURE_CLIENT_ID` and `AZURE_TENANT_ID`. These identifiers are not credentials; GitHub's short-lived OIDC assertion is the credential.
137
+ 5. After this workflow is merged to `main`, run **Marketplace Identity Check** from GitHub Actions.
138
+ 6. Copy the profile ID from the workflow summary. In the Visual Studio Marketplace publisher management page, add that identity ID as a member of publisher `fortunes-guardian` with the **Contributor** role.
139
+ 7. Rerun **Marketplace Identity Check** if publisher membership or federation changes.
140
+
141
+ The release job uses `azure/login` with GitHub OIDC, then publishes with `vsce --azure-credential`. No PAT, client secret, or certificate is created. The workflow publishes the already packaged `tmp/*.vsix`, and that exact file is uploaded to the GitHub Release.
142
+
143
+ References: [VS Code secure automated publishing](https://code.visualstudio.com/api/working-with-extensions/publishing-extension#secure-automated-publishing-to-visual-studio-marketplace) and [GitHub OIDC with Azure](https://docs.github.com/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-azure).
144
+
127
145
  ### Publishing a Version
128
146
 
129
147
  Start from an up-to-date, clean `main` branch. Choose the semantic-version bump that matches the change:
@@ -138,20 +156,21 @@ npm version patch
138
156
  git push origin main --follow-tags
139
157
  ```
140
158
 
141
- `npm version patch` updates both `package.json` and `package-lock.json`, creates a version commit, and creates an annotated `vX.Y.Z` tag. Use `npm version minor` for a backward-compatible feature release or `npm version major` for a breaking release.
159
+ `npm version patch` updates `package.json` and `package-lock.json`; the repository's `version` lifecycle hook synchronizes `vscode-extension/package.json` before npm creates the version commit and annotated `vX.Y.Z` tag. Use `npm version minor` for a backward-compatible feature release or `npm version major` for a breaking release.
142
160
 
143
161
  After the tag is pushed:
144
162
 
145
163
  1. Watch the **Release** workflow in GitHub Actions.
146
- 2. Confirm the new version appears on npm.
147
- 3. Confirm GitHub created a Release for the same tag.
148
- 4. Confirm the release notes read well and include the curated highlights.
149
- 5. Download the attached VSIX if the extension should be tested or shared.
150
- 6. Run `npx copilot-usage-studio@X.Y.Z --version` from a clean directory as a final smoke test.
164
+ 2. Confirm the new version appears in the VS Code Marketplace.
165
+ 3. Confirm the new version appears on npm.
166
+ 4. Confirm GitHub created a Release for the same tag and attached the same-version VSIX.
167
+ 5. Confirm the release notes read well and include the curated highlights.
168
+ 6. Install or update from the Marketplace and run one final extension smoke test.
169
+ 7. Run `npx copilot-usage-studio@X.Y.Z --version` from a clean directory as a fallback-host smoke test.
151
170
 
152
171
  If CI or release validation fails, fix the issue on `main` and publish a new version. Do not move or reuse a public release tag, and do not overwrite an npm version; both are immutable release coordinates.
153
172
 
154
- The workflow also has a manual **Run workflow** action with a required existing tag. Use this to repair or backfill the GitHub Release for an exact tag. For the already-published first version, ensure `v0.1.0` exists on GitHub, then run the workflow once with `v0.1.0`; it will verify npm's published `gitHead`, skip the historical release gate and npm publication, and create the missing GitHub Release.
173
+ The workflow also has a manual **Run workflow** action with a required existing tag. Use it to repair a release from that immutable tag. Duplicate npm and Marketplace publication is skipped safely, while the release gate reruns and the GitHub Release notes/artifact are repaired.
155
174
 
156
175
  ### Manual Emergency Fallback
157
176
 
@@ -286,7 +305,7 @@ npm run vscode:package
286
305
  Install it locally:
287
306
 
288
307
  ```bash
289
- code --install-extension tmp/copilot-usage-studio-vscode-0.2.0.vsix --force
308
+ code --install-extension tmp/copilot-usage-studio-vscode-X.Y.Z.vsix --force
290
309
  ```
291
310
 
292
311
  The extension exposes the full app inside VS Code: Usage, Sessions, Memory, Customizations preview, Compare, Insights, and Prices. The npm/browser host remains useful for development and fallback testing, but the extension is the primary product surface.
package/docs/pricing.md CHANGED
@@ -169,6 +169,8 @@ The user should be able to inspect the cost inputs directly. If a session looks
169
169
  - cached input or cache-write accounting
170
170
  - a model fallback because the raw model id was not in the known GitHub table
171
171
 
172
+ Some VS Code logs can contain internal raw model ids that are not published as GitHub pricing rows. For example, a raw id such as `oswe-vscode-prime` stays visible in the UI and is estimated with the configured fallback pricing row. The app should not silently rename that id unless a reliable pricing alias is added.
173
+
172
174
  The `GitHub prices` view therefore shows every rate row the app knows about, where it came from, and whether any imported session currently uses it.
173
175
 
174
176
  The same view also shows the selected Business/Enterprise AI-credit allowance and how the current imported sessions compare with it.
@@ -81,6 +81,18 @@ Next decomposition targets:
81
81
  - pricing/token normalization
82
82
  - SQLite/state enrichment helpers
83
83
 
84
+ ## Incremental scans
85
+
86
+ The runtime passes the previous snapshot's `generatedAt` value as `incrementalSince` during a normal quick refresh. The scanner still discovers known VS Code storage entries, but it parses only debug-session `main.jsonl` files and chat snapshots modified after that cursor. The runtime merges changed sessions by stable session ID and retains unchanged sessions.
87
+
88
+ This provides two explicit modes: quick/incremental for normal startup, visibility refresh, and `Refresh Data`; full for recovery/debugging through `Copilot Usage Studio: Full Rescan`.
89
+
90
+ Incremental scans intentionally do not infer deletions. A full rescan rebuilds the snapshot and is the recovery path for deleted or externally rewritten sources. Memory roots remain indexed during quick scans because memory files are small and can be independently added or removed.
91
+
92
+ Customization analysis has its own cache boundary. Inventory records include a SHA-256 content fingerprint. If all current fingerprints match the previous analysis, only changed session logs are checked and prior evidence is retained. Any customization content change invalidates that shortcut.
93
+
94
+ The current delta cursor is timestamp-based because VS Code does not publish a stable scanner API or change journal for Agent Debug Logs. The full-rescan command exists specifically to recover from timestamp anomalies or external file restoration.
95
+
84
96
  ## Local Runtime Host
85
97
 
86
98
  `lib/local-runtime.mjs` is the first shared API host. It keeps the last valid `SessionData` snapshot in memory, serves it while scans run, and persists successful refreshes.
@@ -62,6 +62,7 @@ export function createLocalRuntime(options = {}) {
62
62
  let activeScanController = null;
63
63
  let activeScanId = 0;
64
64
  let activeScanMode = '';
65
+ let lastScanMode = '';
65
66
  let scanSequence = 0;
66
67
 
67
68
  function status() {
@@ -78,6 +79,7 @@ export function createLocalRuntime(options = {}) {
78
79
  lastError,
79
80
  activeScanId,
80
81
  activeScanMode,
82
+ lastScanMode,
81
83
  scanProgress,
82
84
  progressHistory: progressHistory.slice(-40),
83
85
  workspaceProgress: [...workspaceProgress.values()],
@@ -107,7 +109,7 @@ export function createLocalRuntime(options = {}) {
107
109
  }
108
110
 
109
111
  const scanMode = normalizeScanMode(refreshOptions.mode ?? 'quick');
110
- const effectiveScanOptions = scanOptionsForMode(scanMode, scanOptions);
112
+ const effectiveScanOptions = scanOptionsForMode(scanMode, scanOptions, sessionData);
111
113
  const started = Date.now();
112
114
  const scanId = ++scanSequence;
113
115
  activeScanId = scanId;
@@ -138,28 +140,28 @@ export function createLocalRuntime(options = {}) {
138
140
  }),
139
141
  )
140
142
  .then((nextSessionData) => {
143
+ const changedSessionCount = nextSessionData.sessions?.length ?? 0;
141
144
  const finalSessionData = sessionData
142
145
  ? scanMode === 'customizations'
143
146
  ? mergeCustomizationScan(sessionData, nextSessionData)
144
- : preserveCustomizationSnapshot(sessionData, nextSessionData)
147
+ : scanMode === 'quick'
148
+ ? mergeIncrementalScan(sessionData, nextSessionData)
149
+ : preserveCustomizationSnapshot(sessionData, nextSessionData)
145
150
  : nextSessionData;
146
151
  writer(finalSessionData, dataFile);
147
152
  sessionData = finalSessionData;
148
153
  phase = 'ready';
149
154
  lastScanCompletedAt = new Date().toISOString();
150
155
  lastScanDurationMs = Date.now() - started;
156
+ lastScanMode = scanMode;
151
157
  scanProgress = rememberProgress({
152
158
  stage: 'complete',
153
- message: scanMode === 'customizations'
154
- ? `Customization usage check complete.`
155
- : `Scan #${scanId} imported ${finalSessionData.sessions.length} sessions.`,
159
+ message: scanResultMessage(scanMode, changedSessionCount, finalSessionData.sessions.length),
156
160
  sessions: finalSessionData.sessions.length,
157
161
  }, scanId, reason, lastScanCompletedAt);
158
162
  emitLog(
159
163
  'log',
160
- scanMode === 'customizations'
161
- ? `Customization usage check #${scanId} (${reason}) completed in ${lastScanDurationMs}ms.`
162
- : `Local data refresh #${scanId} (${reason}) imported ${finalSessionData.sessions.length} sessions in ${lastScanDurationMs}ms.`,
164
+ `${scanResultMessage(scanMode, changedSessionCount, finalSessionData.sessions.length)} (${lastScanDurationMs}ms).`,
163
165
  );
164
166
  return finalSessionData;
165
167
  })
@@ -398,12 +400,20 @@ function summarizeScanOptions(scanOptions = {}) {
398
400
  };
399
401
  }
400
402
 
403
+ function scanResultMessage(mode, changedSessions, totalSessions) {
404
+ if (mode === 'customizations') return 'Customization analysis complete';
405
+ if (mode === 'full') return `Full rescan complete: ${totalSessions} sessions imported`;
406
+ return changedSessions
407
+ ? `${changedSessions} session${changedSessions === 1 ? '' : 's'} added or updated`
408
+ : 'Copilot data is up to date';
409
+ }
410
+
401
411
  function normalizeScanMode(mode) {
402
412
  return ['quick', 'full', 'customizations'].includes(String(mode)) ? String(mode) : 'quick';
403
413
  }
404
414
 
405
- function scanOptionsForMode(mode, baseScanOptions = {}) {
406
- if (mode === 'full' || mode === 'customizations') {
415
+ function scanOptionsForMode(mode, baseScanOptions = {}, previousData = null) {
416
+ if (mode === 'customizations') {
407
417
  const { customizationWorkspaceFolders, ...scanOptions } = baseScanOptions;
408
418
  if (mode === 'customizations' && Array.isArray(customizationWorkspaceFolders)) {
409
419
  return {
@@ -411,11 +421,14 @@ function scanOptionsForMode(mode, baseScanOptions = {}) {
411
421
  includeCustomizations: true,
412
422
  requireWorkspaceFolders: true,
413
423
  workspaceFolders: customizationWorkspaceFolders,
424
+ incrementalSince: previousData?.ingestion?.customizationEvidenceAnalyzedAt ?? '',
414
425
  customizationEvidence: {
415
426
  maxSessions: 0,
416
427
  maxModelCalls: 0,
417
428
  maxElapsedMs: 0,
418
429
  maxPartChars: 250_000,
430
+ previousEvidence: previousData?.customizations ?? [],
431
+ incrementalSince: previousData?.ingestion?.customizationEvidenceAnalyzedAt ?? '',
419
432
  ...(scanOptions.customizationEvidence ?? {}),
420
433
  },
421
434
  };
@@ -434,12 +447,40 @@ function scanOptionsForMode(mode, baseScanOptions = {}) {
434
447
  };
435
448
  }
436
449
 
450
+ if (mode === 'full') {
451
+ return {
452
+ ...baseScanOptions,
453
+ includeCustomizations: false,
454
+ incrementalSince: '',
455
+ };
456
+ }
457
+
437
458
  return {
438
459
  ...baseScanOptions,
439
460
  includeCustomizations: false,
461
+ incrementalSince: previousData?.generatedAt ?? '',
440
462
  };
441
463
  }
442
464
 
465
+ function mergeIncrementalScan(previousData, deltaData) {
466
+ const changedSessions = deltaData.sessions ?? [];
467
+ const changedIds = new Set(changedSessions.map((session) => session.id));
468
+ const sessions = [
469
+ ...changedSessions,
470
+ ...(previousData.sessions ?? []).filter((session) => !changedIds.has(session.id)),
471
+ ]
472
+ .sort((a, b) => String(b.startedAt ?? '').localeCompare(String(a.startedAt ?? '')));
473
+ return preserveCustomizationSnapshot(previousData, {
474
+ ...deltaData,
475
+ ingestion: {
476
+ ...deltaData.ingestion,
477
+ importedSessions: sessions.length,
478
+ incrementalSessionsImported: changedSessions.length,
479
+ },
480
+ sessions,
481
+ });
482
+ }
483
+
443
484
  function mergeCustomizationScan(previousData, currentWorkspaceData) {
444
485
  const scannedWorkspaces = new Set(
445
486
  (currentWorkspaceData.ingestion?.workspaceScans ?? [])
@@ -502,6 +543,9 @@ function preserveCustomizationSnapshot(previousData, nextData) {
502
543
  customizationEvidenceScannedSessions:
503
544
  nextData.ingestion?.customizationEvidenceScannedSessions ??
504
545
  previousData.ingestion?.customizationEvidenceScannedSessions,
546
+ customizationEvidenceAnalyzedAt:
547
+ nextData.ingestion?.customizationEvidenceAnalyzedAt ||
548
+ previousData.ingestion?.customizationEvidenceAnalyzedAt,
505
549
  customizationEvidenceModelCalls:
506
550
  nextData.ingestion?.customizationEvidenceModelCalls ??
507
551
  previousData.ingestion?.customizationEvidenceModelCalls,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-usage-studio",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Local VS Code GitHub Copilot usage, cost, and session insights.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,13 +46,15 @@
46
46
  "schema:accept": "node scripts/audit-vscode-schema.mjs --accept",
47
47
  "vscode:typecheck": "tsc -p vscode-extension/tsconfig.json --noEmit",
48
48
  "vscode:compile": "npm run vscode:typecheck && node scripts/build-vscode-extension.mjs --extension-only",
49
+ "verify:versions": "node scripts/verify-release-versions.mjs",
49
50
  "vscode:build": "npm run build && npm run vscode:typecheck && node scripts/build-vscode-extension.mjs",
50
51
  "vscode:verify": "npm run vscode:build && node scripts/verify-vscode-extension.mjs",
51
52
  "vscode:vsix:dry-run": "npm run vscode:verify && cd vscode-extension && vsce ls --tree",
52
53
  "vscode:package": "npm run vscode:verify && node scripts/package-vscode-extension.mjs",
53
54
  "verify:package": "node scripts/verify-package.mjs",
54
55
  "release:notes": "node scripts/generate-release-notes.mjs",
55
- "release:check": "npm run test:scripts && npm test -- --watch=false && npm run build && npm run verify:package",
56
+ "version": "node scripts/sync-release-version.mjs",
57
+ "release:check": "npm run verify:versions && npm run test:scripts && npm test -- --watch=false && npm run build && npm run verify:package",
56
58
  "prepack": "npm run build"
57
59
  },
58
60
  "exports": {
@@ -627,6 +627,7 @@ export async function scanVsCodeSessions(options = {}) {
627
627
  includeSystemCustomizations: options.includeSystemCustomizations === true,
628
628
  customizationDiscovery: options.customizationDiscovery ?? null,
629
629
  customizationEvidence: options.customizationEvidence ?? {},
630
+ incrementalSince: options.incrementalSince ?? '',
630
631
  };
631
632
 
632
633
  try {
@@ -748,6 +749,9 @@ export async function scanVsCodeSessions(options = {}) {
748
749
  ingestion: {
749
750
  ...diagnostics,
750
751
  importedSessions: sessions.length,
752
+ scanKind: options.incrementalSince ? 'incremental' : 'full',
753
+ incrementalSince: options.incrementalSince ?? '',
754
+ customizationEvidenceAnalyzedAt: options.includeCustomizations !== false ? generatedAt : '',
751
755
  cacheTokenAudit: mergeCacheTokenAudits(
752
756
  sessions.map((session) => session.cacheTokenAudit).filter(Boolean),
753
757
  ),
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { basename, isAbsolute, join, resolve, sep } from 'node:path';
3
3
 
4
4
  function safeJson(text) {
@@ -144,7 +144,13 @@ function matchedChunkPreview(chunks) {
144
144
  }
145
145
 
146
146
  function hasStrongContentMatch(chunks) {
147
- return chunks.length > 0;
147
+ const unique = [...new Set(chunks.map(normalizeMatchText).filter(Boolean))];
148
+ const totalCharacters = unique.reduce((sum, chunk) => sum + chunk.length, 0);
149
+ const totalWords = unique.reduce((sum, chunk) => sum + normalizedWordCount(chunk), 0);
150
+ return (
151
+ unique.some((chunk) => chunk.length >= 120 && normalizedWordCount(chunk) >= 16) ||
152
+ (unique.length >= 2 && totalCharacters >= 140 && totalWords >= 20)
153
+ );
148
154
  }
149
155
 
150
156
  function extractPayloadText(value, depth = 0) {
@@ -311,6 +317,30 @@ function recordCustomizationMatch(state, match) {
311
317
  state.matches = state.matches.slice(0, 60);
312
318
  }
313
319
 
320
+ function initialMatchState(customization, fileNameCounts, previous) {
321
+ const matches = previous?.matches ?? [];
322
+ return {
323
+ rank: statusRank(previous?.evidenceStatus),
324
+ matches: [...matches],
325
+ chunks: customizationChunks(customization._content),
326
+ terms: customizationTerms(customization),
327
+ readTerms: readTermsForCustomization(customization, fileNameCounts),
328
+ readSessions: new Set(
329
+ matches.filter((match) => statusRank(match.status) >= 2).map((match) => match.sessionId),
330
+ ),
331
+ };
332
+ }
333
+
334
+ function sourceChangedSince(path, since) {
335
+ const sinceMs = Date.parse(String(since ?? ''));
336
+ if (!Number.isFinite(sinceMs)) return true;
337
+ try {
338
+ return statSync(path).mtimeMs > sinceMs;
339
+ } catch {
340
+ return true;
341
+ }
342
+ }
343
+
314
344
  export function customizationEvidenceFromDebugLogs(
315
345
  debugRoot,
316
346
  customizations,
@@ -343,25 +373,32 @@ export function customizationEvidenceFromDebugLogs(
343
373
  }
344
374
 
345
375
  const fileNameCounts = basenameCounts(customizations);
376
+ const previousById = new Map((context.previousEvidence ?? []).map((item) => [item.id, item]));
377
+ const canIncrement = Boolean(context.incrementalSince) && customizations.every((customization) =>
378
+ previousById.get(customization.id)?.contentHash === customization.contentHash);
379
+
346
380
  const matchState = new Map(
347
381
  customizations.map((customization) => [
348
382
  customization.id,
349
- {
350
- rank: 0,
351
- matches: [],
352
- chunks: customizationChunks(customization._content),
353
- terms: customizationTerms(customization),
354
- readTerms: readTermsForCustomization(customization, fileNameCounts),
355
- readSessions: new Set(),
356
- },
383
+ initialMatchState(
384
+ customization,
385
+ fileNameCounts,
386
+ canIncrement ? previousById.get(customization.id) : null,
387
+ ),
357
388
  ]),
358
389
  );
359
390
  const chunkMatchers = buildCustomizationChunkMatchers(matchState);
360
391
 
361
392
  const allSessionDirs = listDirs(debugRoot);
362
- const sessionDirs = Number.isFinite(maxSessions) ? allSessionDirs.slice(0, maxSessions) : allSessionDirs;
363
- if (allSessionDirs.length > sessionDirs.length) {
364
- const sessionLimitReason = `limited to ${sessionDirs.length}/${allSessionDirs.length} debug-log folders`;
393
+ const candidateSessionDirs = canIncrement
394
+ ? allSessionDirs.filter((sessionDir) =>
395
+ sourceChangedSince(join(sessionDir, 'main.jsonl'), context.incrementalSince))
396
+ : allSessionDirs;
397
+ const sessionDirs = Number.isFinite(maxSessions)
398
+ ? candidateSessionDirs.slice(0, maxSessions)
399
+ : candidateSessionDirs;
400
+ if (candidateSessionDirs.length > sessionDirs.length) {
401
+ const sessionLimitReason = `limited to ${sessionDirs.length}/${candidateSessionDirs.length} changed debug-log folders`;
365
402
  diagnostics.warnings?.push?.(
366
403
  `Customization evidence scan for ${workspace || workspaceDir || debugRoot} ${sessionLimitReason}.`,
367
404
  );
@@ -343,6 +343,7 @@ function parseSimpleFrontmatter(content) {
343
343
  sourcePath: resolve(file),
344
344
  relativePath,
345
345
  createdAt: stats.birthtimeMs > 0 ? stats.birthtime.toISOString() : '',
346
+ contentHash: createHash('sha256').update(content).digest('hex'),
346
347
  modifiedAt: stats.mtime.toISOString(),
347
348
  sizeBytes: stats.size,
348
349
  characterCount: content.length,
@@ -1,5 +1,5 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
3
 
4
4
  export function parseWorkspace(workspaceDir, options = {}, onProgress = () => {}, dependencies = {}) {
5
5
  const {
@@ -64,15 +64,25 @@ export function parseWorkspace(workspaceDir, options = {}, onProgress = () => {}
64
64
  });
65
65
  };
66
66
  const debugRoot = join(workspaceDir, 'GitHub.copilot-chat', 'debug-logs');
67
- const debugSessionDirs = listDirs(debugRoot);
68
- const chatSessionFiles = listFiles(join(workspaceDir, 'chatSessions'), '.jsonl');
67
+ const allDebugSessionDirs = listDirs(debugRoot);
68
+ const allChatSessionFiles = listFiles(join(workspaceDir, 'chatSessions'), '.jsonl');
69
+ const incrementalSinceMs = timestampMs(options.incrementalSince);
70
+ const debugSessionDirs = incrementalSinceMs
71
+ ? allDebugSessionDirs.filter((sessionDir) =>
72
+ sourceChangedSince(join(sessionDir, 'main.jsonl'), incrementalSinceMs))
73
+ : allDebugSessionDirs;
74
+ const chatSessionFiles = incrementalSinceMs
75
+ ? allChatSessionFiles.filter((file) => sourceChangedSince(file, incrementalSinceMs))
76
+ : allChatSessionFiles;
69
77
  const memoryRoot = join(workspaceDir, 'GitHub.copilot-chat', 'memory-tool', 'memories');
70
78
  const hasMemoryRoot = existsSync(memoryRoot);
71
- workspaceScan.debugLogFolders = debugSessionDirs.length;
72
- workspaceScan.chatSnapshots = chatSessionFiles.length;
79
+ workspaceScan.debugLogFolders = allDebugSessionDirs.length;
80
+ workspaceScan.chatSnapshots = allChatSessionFiles.length;
81
+ workspaceScan.changedDebugLogFolders = debugSessionDirs.length;
82
+ workspaceScan.changedChatSnapshots = chatSessionFiles.length;
73
83
  workspaceScan.hasMemoryRoot = hasMemoryRoot;
74
84
 
75
- if (!debugSessionDirs.length && !chatSessionFiles.length && !hasMemoryRoot) {
85
+ if (!allDebugSessionDirs.length && !allChatSessionFiles.length && !hasMemoryRoot) {
76
86
  workspaceScan.completedAt = new Date().toISOString();
77
87
  workspaceScan.durationMs = Date.now() - workspaceStartedAt;
78
88
  workspaceScan.lastStage = 'empty';
@@ -84,8 +94,10 @@ export function parseWorkspace(workspaceDir, options = {}, onProgress = () => {}
84
94
  }
85
95
 
86
96
  progress('workspace', `Checking VS Code storage entry for ${workspace}.`, {
87
- debugLogFolders: debugSessionDirs.length,
88
- chatSnapshots: chatSessionFiles.length,
97
+ debugLogFolders: allDebugSessionDirs.length,
98
+ chatSnapshots: allChatSessionFiles.length,
99
+ changedDebugLogFolders: debugSessionDirs.length,
100
+ changedChatSnapshots: chatSessionFiles.length,
89
101
  hasMemoryRoot,
90
102
  });
91
103
  progress('workspace-state', `Reading workspace metadata for ${workspace}.`);
@@ -146,7 +158,7 @@ export function parseWorkspace(workspaceDir, options = {}, onProgress = () => {}
146
158
  debugSessions.push(enrichSessionFromWorkspaceState(session, stateBySessionId));
147
159
  }
148
160
  }
149
- const debugIds = new Set(debugSessions.map((session) => session.id));
161
+ const debugIds = new Set(allDebugSessionDirs.map((sessionDir) => basename(sessionDir)));
150
162
  diagnostics.importedDebugLogSessions += debugSessions.length;
151
163
 
152
164
  if (chatSessionFiles.length) {
@@ -186,6 +198,22 @@ export function parseWorkspace(workspaceDir, options = {}, onProgress = () => {}
186
198
  };
187
199
  }
188
200
 
201
+
202
+ function timestampMs(value) {
203
+ if (!value) {
204
+ return 0;
205
+ }
206
+ const parsed = Date.parse(String(value));
207
+ return Number.isFinite(parsed) ? parsed : 0;
208
+ }
209
+
210
+ function sourceChangedSince(path, sinceMs) {
211
+ try {
212
+ return statSync(path).mtimeMs > sinceMs;
213
+ } catch {
214
+ return true;
215
+ }
216
+ }
189
217
  function assertWorkspaceDependencies(dependencies) {
190
218
  for (const name of [
191
219
  'customizationsFromDebugReferences',