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