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