copilot-usage-studio 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -4
- package/README.md +206 -150
- 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-KS5W2HMH.js +1 -0
- package/dist/copilot-usage-studio/browser/chunk-OS2XPCVW.js +2 -0
- package/dist/copilot-usage-studio/browser/chunk-U4H425LY.js +1 -0
- 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-XHID4XVE.js +1 -0
- package/dist/copilot-usage-studio/browser/index.html +11 -11
- package/dist/copilot-usage-studio/browser/main-OZIMQRXC.js +1 -0
- package/dist/copilot-usage-studio/browser/styles-7RDDQXQV.css +1 -0
- package/docs/copilot-memory.md +91 -0
- package/docs/local-deployment.md +119 -13
- package/docs/pricing.md +3 -3
- package/docs/scanner-api.md +32 -3
- package/lib/cli.mjs +15 -1
- package/lib/local-runtime.mjs +530 -19
- package/lib/scanner-worker.mjs +25 -0
- package/package.json +95 -78
- package/scripts/pricing-utils.mjs +5 -0
- package/scripts/scan-vscode-sessions.mjs +418 -1204
- 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-C6VWIY5S.js +0 -1
- package/dist/copilot-usage-studio/browser/chunk-DLWQO3VR.js +0 -1
- package/dist/copilot-usage-studio/browser/chunk-F6TIG2GE.js +0 -4
- package/dist/copilot-usage-studio/browser/chunk-JIP7ONRZ.js +0 -1
- package/dist/copilot-usage-studio/browser/chunk-RNKEPBEU.js +0 -1
- package/dist/copilot-usage-studio/browser/chunk-Z3XIAKMM.js +0 -1
- package/dist/copilot-usage-studio/browser/main-C6XOJRSH.js +0 -2
- package/dist/copilot-usage-studio/browser/styles-GZOQ5QIH.css +0 -1
package/lib/local-runtime.mjs
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
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';
|
|
2
11
|
import { createServer } from 'node:http';
|
|
3
|
-
import { extname, join, normalize, resolve, sep } from 'node:path';
|
|
12
|
+
import { dirname, extname, join, normalize, resolve, sep } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
4
14
|
|
|
5
|
-
import {
|
|
15
|
+
import { writeSessionData } from './scanner-api.mjs';
|
|
6
16
|
|
|
7
17
|
const contentTypes = new Map([
|
|
8
18
|
['.css', 'text/css; charset=utf-8'],
|
|
@@ -18,6 +28,8 @@ const contentTypes = new Map([
|
|
|
18
28
|
['.woff2', 'font/woff2'],
|
|
19
29
|
]);
|
|
20
30
|
|
|
31
|
+
const scannerWorkerPath = fileURLToPath(new URL('./scanner-worker.mjs', import.meta.url));
|
|
32
|
+
|
|
21
33
|
export function createLocalRuntime(options = {}) {
|
|
22
34
|
const host = options.host ?? '127.0.0.1';
|
|
23
35
|
const port = Number(options.port ?? 4312);
|
|
@@ -27,9 +39,17 @@ export function createLocalRuntime(options = {}) {
|
|
|
27
39
|
: resolve(options.seedDataFile ?? 'public/data/sessions.json');
|
|
28
40
|
const staticDir = resolve(options.staticDir ?? 'dist/copilot-usage-studio/browser');
|
|
29
41
|
const scanOptions = options.scanOptions ?? {};
|
|
30
|
-
const scanner = options.scanner ??
|
|
42
|
+
const scanner = options.scanner ?? scanInChildProcess;
|
|
31
43
|
const writer = options.writer ?? writeSessionData;
|
|
44
|
+
const openPath = options.openPath ?? openLocalPath;
|
|
32
45
|
const logger = options.logger ?? console;
|
|
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;
|
|
33
53
|
|
|
34
54
|
let sessionData = readCachedSessionData(dataFile, logger)
|
|
35
55
|
?? (seedDataFile ? readCachedSessionData(seedDataFile, logger) : null);
|
|
@@ -39,6 +59,10 @@ export function createLocalRuntime(options = {}) {
|
|
|
39
59
|
let lastScanCompletedAt = '';
|
|
40
60
|
let lastScanDurationMs = 0;
|
|
41
61
|
let activeScan = null;
|
|
62
|
+
let activeScanController = null;
|
|
63
|
+
let activeScanId = 0;
|
|
64
|
+
let activeScanMode = '';
|
|
65
|
+
let scanSequence = 0;
|
|
42
66
|
|
|
43
67
|
function status() {
|
|
44
68
|
return {
|
|
@@ -46,60 +70,160 @@ export function createLocalRuntime(options = {}) {
|
|
|
46
70
|
scanning: Boolean(activeScan),
|
|
47
71
|
hasData: Boolean(sessionData),
|
|
48
72
|
sessionCount: sessionData?.sessions?.length ?? 0,
|
|
73
|
+
memoryCount: sessionData?.memories?.length ?? 0,
|
|
49
74
|
generatedAt: sessionData?.generatedAt ?? '',
|
|
50
75
|
lastScanStartedAt,
|
|
51
76
|
lastScanCompletedAt,
|
|
52
77
|
lastScanDurationMs,
|
|
53
78
|
lastError,
|
|
79
|
+
activeScanId,
|
|
80
|
+
activeScanMode,
|
|
81
|
+
scanProgress,
|
|
82
|
+
progressHistory: progressHistory.slice(-40),
|
|
83
|
+
workspaceProgress: [...workspaceProgress.values()],
|
|
84
|
+
logFile,
|
|
85
|
+
recentLogs: logEntries.slice(-12),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
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,
|
|
54
100
|
};
|
|
55
101
|
}
|
|
56
102
|
|
|
57
|
-
function refresh(reason = 'manual') {
|
|
103
|
+
function refresh(reason = 'manual', refreshOptions = {}) {
|
|
58
104
|
if (activeScan) {
|
|
105
|
+
emitLog('log', `Scan already running; reusing active ${reason} request.`);
|
|
59
106
|
return activeScan;
|
|
60
107
|
}
|
|
61
108
|
|
|
109
|
+
const scanMode = normalizeScanMode(refreshOptions.mode ?? 'quick');
|
|
110
|
+
const effectiveScanOptions = scanOptionsForMode(scanMode, scanOptions);
|
|
62
111
|
const started = Date.now();
|
|
112
|
+
const scanId = ++scanSequence;
|
|
113
|
+
activeScanId = scanId;
|
|
114
|
+
activeScanMode = scanMode;
|
|
115
|
+
activeScanController = new AbortController();
|
|
63
116
|
lastScanStartedAt = new Date(started).toISOString();
|
|
64
117
|
lastError = '';
|
|
65
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}).`);
|
|
66
126
|
|
|
67
127
|
activeScan = Promise.resolve()
|
|
68
|
-
.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
|
+
)
|
|
69
140
|
.then((nextSessionData) => {
|
|
70
|
-
|
|
71
|
-
|
|
141
|
+
const finalSessionData = sessionData
|
|
142
|
+
? scanMode === 'customizations'
|
|
143
|
+
? mergeCustomizationScan(sessionData, nextSessionData)
|
|
144
|
+
: preserveCustomizationSnapshot(sessionData, nextSessionData)
|
|
145
|
+
: nextSessionData;
|
|
146
|
+
writer(finalSessionData, dataFile);
|
|
147
|
+
sessionData = finalSessionData;
|
|
72
148
|
phase = 'ready';
|
|
73
149
|
lastScanCompletedAt = new Date().toISOString();
|
|
74
150
|
lastScanDurationMs = Date.now() - started;
|
|
75
|
-
|
|
76
|
-
|
|
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.`,
|
|
77
163
|
);
|
|
78
|
-
return
|
|
164
|
+
return finalSessionData;
|
|
79
165
|
})
|
|
80
166
|
.catch((error) => {
|
|
81
|
-
|
|
82
|
-
|
|
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');
|
|
83
171
|
lastScanCompletedAt = new Date().toISOString();
|
|
84
172
|
lastScanDurationMs = Date.now() - started;
|
|
85
|
-
|
|
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
|
+
}
|
|
86
181
|
throw error;
|
|
87
182
|
})
|
|
88
183
|
.finally(() => {
|
|
89
184
|
activeScan = null;
|
|
185
|
+
activeScanController = null;
|
|
186
|
+
activeScanId = 0;
|
|
187
|
+
activeScanMode = '';
|
|
90
188
|
});
|
|
91
189
|
|
|
92
190
|
return activeScan;
|
|
93
191
|
}
|
|
94
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
|
+
|
|
95
202
|
const server = createServer(async (request, response) => {
|
|
96
203
|
try {
|
|
97
204
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `${host}:${port}`}`);
|
|
98
205
|
|
|
206
|
+
if (request.method === 'OPTIONS') {
|
|
207
|
+
response.writeHead(204, corsHeaders());
|
|
208
|
+
response.end();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
99
212
|
if (request.method === 'GET' && url.pathname === '/api/status') {
|
|
100
213
|
return sendJson(response, 200, status());
|
|
101
214
|
}
|
|
102
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
|
+
|
|
103
227
|
if (request.method === 'GET' && url.pathname === '/api/sessions') {
|
|
104
228
|
await waitForFirstData();
|
|
105
229
|
return sessionData
|
|
@@ -116,20 +240,38 @@ export function createLocalRuntime(options = {}) {
|
|
|
116
240
|
|
|
117
241
|
if (request.method === 'POST' && url.pathname === '/api/scan') {
|
|
118
242
|
try {
|
|
119
|
-
const
|
|
243
|
+
const body = await readJsonBody(request);
|
|
244
|
+
const nextSessionData = await refresh('manual', body);
|
|
120
245
|
return sendJson(response, 200, { sessionData: nextSessionData, status: status() });
|
|
121
246
|
} catch {
|
|
122
247
|
return sendJson(response, 500, { error: lastError, status: status() });
|
|
123
248
|
}
|
|
124
249
|
}
|
|
125
250
|
|
|
251
|
+
if (request.method === 'POST' && url.pathname === '/api/scan/cancel') {
|
|
252
|
+
return sendJson(response, 200, { canceled: cancelScan(), status: status() });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const memoryAction = url.pathname.match(/^\/api\/memories\/([a-f0-9]{24})\/open$/);
|
|
256
|
+
if (request.method === 'POST' && memoryAction) {
|
|
257
|
+
const memory = sessionData?.memories?.find((candidate) => candidate.id === memoryAction[1]);
|
|
258
|
+
if (!memory) {
|
|
259
|
+
return sendJson(response, 404, { error: 'Memory file not found in the current scan.' });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const body = await readJsonBody(request);
|
|
263
|
+
const action = body?.action === 'reveal' ? 'reveal' : 'open';
|
|
264
|
+
await openPath(memory.sourcePath, action);
|
|
265
|
+
return sendJson(response, 200, { ok: true, action });
|
|
266
|
+
}
|
|
267
|
+
|
|
126
268
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
127
269
|
return sendJson(response, 405, { error: 'Method not allowed.' });
|
|
128
270
|
}
|
|
129
271
|
|
|
130
272
|
return serveStaticFile(request, response, staticDir, url.pathname);
|
|
131
273
|
} catch (error) {
|
|
132
|
-
|
|
274
|
+
emitLog('error', error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
133
275
|
return sendJson(response, 500, { error: 'Local runtime request failed.' });
|
|
134
276
|
}
|
|
135
277
|
});
|
|
@@ -144,27 +286,386 @@ export function createLocalRuntime(options = {}) {
|
|
|
144
286
|
server.listen(port, host, () => {
|
|
145
287
|
server.off('error', rejectListen);
|
|
146
288
|
const address = server.address();
|
|
147
|
-
|
|
289
|
+
emitLog(
|
|
290
|
+
'log',
|
|
291
|
+
backendOnly
|
|
292
|
+
? `Copilot Usage Studio backend API: http://${host}:${address.port}/ (used by the dev server)`
|
|
293
|
+
: `Copilot Usage Studio local app: http://${host}:${address.port}/`,
|
|
294
|
+
);
|
|
295
|
+
if (logFile) {
|
|
296
|
+
emitLog('log', `Runtime log file: ${logFile}`);
|
|
297
|
+
}
|
|
148
298
|
resolveListen(address);
|
|
149
299
|
|
|
150
300
|
if (options.scanOnStart !== false) {
|
|
151
|
-
void refresh('startup').catch(() => {});
|
|
301
|
+
void refresh('startup', { mode: options.startupScanMode ?? 'quick' }).catch(() => {});
|
|
152
302
|
}
|
|
153
303
|
});
|
|
154
304
|
}),
|
|
155
305
|
refresh,
|
|
306
|
+
cancelScan,
|
|
156
307
|
status,
|
|
308
|
+
diagnostics: diagnosticsReport,
|
|
157
309
|
};
|
|
158
310
|
|
|
159
311
|
async function waitForFirstData() {
|
|
160
312
|
if (!sessionData && activeScan) {
|
|
161
313
|
try {
|
|
162
|
-
await activeScan;
|
|
314
|
+
await waitForActiveScanOrTimeout(activeScan, firstDataWaitMs);
|
|
163
315
|
} catch {
|
|
164
316
|
// The response below reports the retained empty/error state.
|
|
165
317
|
}
|
|
166
318
|
}
|
|
167
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
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function readJsonBody(request) {
|
|
618
|
+
return new Promise((resolveBody, rejectBody) => {
|
|
619
|
+
const chunks = [];
|
|
620
|
+
let size = 0;
|
|
621
|
+
request.on('data', (chunk) => {
|
|
622
|
+
size += chunk.length;
|
|
623
|
+
if (size > 16 * 1024) {
|
|
624
|
+
rejectBody(new Error('Request body is too large.'));
|
|
625
|
+
request.destroy();
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
chunks.push(chunk);
|
|
629
|
+
});
|
|
630
|
+
request.on('end', () => {
|
|
631
|
+
if (!chunks.length) {
|
|
632
|
+
resolveBody({});
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
637
|
+
} catch {
|
|
638
|
+
rejectBody(new Error('Request body must be valid JSON.'));
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
request.on('error', rejectBody);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function openLocalPath(path, action) {
|
|
646
|
+
const target = resolve(path);
|
|
647
|
+
let command;
|
|
648
|
+
let args;
|
|
649
|
+
|
|
650
|
+
if (process.platform === 'win32') {
|
|
651
|
+
command = 'explorer.exe';
|
|
652
|
+
args = action === 'reveal' ? ['/select,', target] : [target];
|
|
653
|
+
} else if (process.platform === 'darwin') {
|
|
654
|
+
command = 'open';
|
|
655
|
+
args = action === 'reveal' ? ['-R', target] : [target];
|
|
656
|
+
} else {
|
|
657
|
+
command = 'xdg-open';
|
|
658
|
+
args = [action === 'reveal' ? dirname(target) : target];
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return new Promise((resolveOpen, rejectOpen) => {
|
|
662
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
663
|
+
child.once('error', rejectOpen);
|
|
664
|
+
child.once('spawn', () => {
|
|
665
|
+
child.unref();
|
|
666
|
+
resolveOpen();
|
|
667
|
+
});
|
|
668
|
+
});
|
|
168
669
|
}
|
|
169
670
|
|
|
170
671
|
function readCachedSessionData(dataFile, logger) {
|
|
@@ -184,6 +685,7 @@ function readCachedSessionData(dataFile, logger) {
|
|
|
184
685
|
function sendJson(response, statusCode, body) {
|
|
185
686
|
const payload = JSON.stringify(body);
|
|
186
687
|
response.writeHead(statusCode, {
|
|
688
|
+
...corsHeaders(),
|
|
187
689
|
'cache-control': 'no-store',
|
|
188
690
|
'content-length': Buffer.byteLength(payload),
|
|
189
691
|
'content-type': 'application/json; charset=utf-8',
|
|
@@ -191,6 +693,15 @@ function sendJson(response, statusCode, body) {
|
|
|
191
693
|
response.end(payload);
|
|
192
694
|
}
|
|
193
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
|
+
|
|
194
705
|
function serveStaticFile(request, response, staticDir, pathname) {
|
|
195
706
|
if (!existsSync(staticDir)) {
|
|
196
707
|
return sendJson(response, 404, {
|