@aixle/insights 0.2.0 → 0.2.2-staging
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/README.md +38 -9
- package/dist/auth/credentials.d.ts +7 -1
- package/dist/auth/credentials.js +71 -14
- package/dist/auth/exchange.d.ts +1 -1
- package/dist/auth/exchange.js +1 -1
- package/dist/auth/flow.d.ts +8 -1
- package/dist/auth/flow.js +27 -5
- package/dist/auth/keycloak.d.ts +1 -1
- package/dist/auth/keycloak.js +20 -1
- package/dist/cli.d.ts +5 -3
- package/dist/cli.js +69 -21
- package/dist/collect-cursor-payloads.d.ts +4 -3
- package/dist/collect-cursor-payloads.js +8 -5
- package/dist/cursor-checkpoints.d.ts +2 -2
- package/dist/cursor-payload-contract.d.ts +5 -5
- package/dist/cursor-payload-contract.js +6 -0
- package/dist/cursor-settings.d.ts +9 -4
- package/dist/cursor-settings.js +80 -10
- package/dist/health.d.ts +3 -1
- package/dist/health.js +13 -1
- package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
- package/dist/hooks/cursor-hooks-mapper.js +1 -1
- package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
- package/dist/hooks/cursor-hooks-reader.js +10 -3
- package/dist/install/cursor.d.ts +34 -0
- package/dist/install/cursor.js +193 -0
- package/dist/install/index.d.ts +6 -4
- package/dist/install/index.js +6 -1
- package/dist/lib/client.d.ts +7 -0
- package/dist/lib/client.js +17 -0
- package/dist/lib/config.js +7 -2
- package/dist/lib/project-resolver.d.ts +5 -4
- package/dist/lib/project-resolver.js +57 -11
- package/dist/lib/repo-path-safety.d.ts +35 -0
- package/dist/lib/repo-path-safety.js +102 -0
- package/dist/lib/spawn-arg-safety.d.ts +25 -0
- package/dist/lib/spawn-arg-safety.js +49 -0
- package/dist/lib/transport-security.d.ts +1 -0
- package/dist/lib/transport-security.js +1 -1
- package/dist/readers/claude.d.ts +54 -6
- package/dist/readers/claude.js +154 -2
- package/dist/readers/cursor.d.ts +10 -7
- package/dist/readers/cursor.js +113 -17
- package/dist/risk-scanner.js +7 -0
- package/dist/server.d.ts +20 -3
- package/dist/server.js +101 -67
- package/dist/state.js +7 -2
- package/dist/sync.d.ts +13 -2
- package/dist/sync.js +86 -58
- package/package.json +6 -2
package/dist/sync.js
CHANGED
|
@@ -11,6 +11,7 @@ import { postEvent, postEvents } from "./client.js";
|
|
|
11
11
|
import { getCostWarning } from "./pricing.js";
|
|
12
12
|
import { acquireSyncLock } from "./lock.js";
|
|
13
13
|
import { getGitRemoteForPath, lookupProjectByRemote, } from "./lib/index.js";
|
|
14
|
+
import { isRepoPathWithinRoot, normalizeRepoPathCandidate, } from "./lib/repo-path-safety.js";
|
|
14
15
|
import { mcpLog } from "./log.js";
|
|
15
16
|
/** Prefix for Claude Code session keys in shared MCP state. */
|
|
16
17
|
export const CLAUDE_STATE_PREFIX = "claude_code:";
|
|
@@ -102,9 +103,9 @@ function explicitProjectId(projectId, projectIdSource) {
|
|
|
102
103
|
? projectId
|
|
103
104
|
: undefined;
|
|
104
105
|
}
|
|
105
|
-
async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache) {
|
|
106
|
-
const normalized = repoPath
|
|
107
|
-
if (
|
|
106
|
+
async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache, allowInsecureHttp = false) {
|
|
107
|
+
const normalized = normalizeRepoPathCandidate(repoPath);
|
|
108
|
+
if (normalized === null)
|
|
108
109
|
return null;
|
|
109
110
|
if (cache.has(normalized))
|
|
110
111
|
return cache.get(normalized) ?? null;
|
|
@@ -113,25 +114,35 @@ async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose,
|
|
|
113
114
|
cache.set(normalized, null);
|
|
114
115
|
return null;
|
|
115
116
|
}
|
|
116
|
-
const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose);
|
|
117
|
+
const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose, allowInsecureHttp);
|
|
117
118
|
const projectId = result && typeof result === "object" && "project_id" in result ? result.project_id : null;
|
|
118
119
|
cache.set(normalized, projectId);
|
|
119
120
|
return projectId;
|
|
120
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The repo path for a Cursor payload, normalized and safe to hand to project
|
|
124
|
+
* resolution. Returns an absolute, `..`-collapsed path or undefined.
|
|
125
|
+
*
|
|
126
|
+
* `workspace_folder` comes from the workspace's own `workspace.json` and
|
|
127
|
+
* `workspace` from a composer `uri.fsPath` or the `state.vscdb` path — all
|
|
128
|
+
* untrusted. When the preferred field is unusable we fall through to the other
|
|
129
|
+
* rather than giving up. See DB90DV-547.
|
|
130
|
+
*/
|
|
121
131
|
export function cursorRepoPathFromPayload(payload) {
|
|
122
132
|
const metadata = payload.metadata;
|
|
123
133
|
if (!metadata)
|
|
124
134
|
return undefined;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
for (const candidate of [metadata.workspace_folder, metadata.workspace]) {
|
|
136
|
+
if (typeof candidate !== "string")
|
|
137
|
+
continue;
|
|
138
|
+
const normalized = normalizeRepoPathCandidate(candidate);
|
|
139
|
+
if (normalized !== null)
|
|
140
|
+
return normalized;
|
|
130
141
|
}
|
|
131
142
|
return undefined;
|
|
132
143
|
}
|
|
133
144
|
async function runClaudeSlice(options) {
|
|
134
|
-
const { token, host, dryRun, verbose, projectId, pricing } = options;
|
|
145
|
+
const { token, host, dryRun, verbose, projectId, pricing, allowInsecureHttp = false } = options;
|
|
135
146
|
const appDir = options.appDir ?? getAppDir();
|
|
136
147
|
const backoffKey = credentialStateKey(host, token);
|
|
137
148
|
const errors = [];
|
|
@@ -194,14 +205,16 @@ async function runClaudeSlice(options) {
|
|
|
194
205
|
mcpLog.info("sync_noise_skip", { tool: "claude_code", reason: "local_command_noise" }, false);
|
|
195
206
|
continue;
|
|
196
207
|
}
|
|
197
|
-
// When scopeDir is set, skip turns from other directories.
|
|
208
|
+
// When scopeDir is set, skip turns from other directories. `turn.cwd` is an
|
|
209
|
+
// arbitrary string from a transcript JSONL, so a plain prefix match would
|
|
210
|
+
// accept `<scopeDir>/../../elsewhere` (DB90DV-547).
|
|
198
211
|
if (scopeDir) {
|
|
199
|
-
const cwd = turn.cwd
|
|
200
|
-
const inScope = cwd && (cwd
|
|
212
|
+
const cwd = normalizeRepoPathCandidate(turn.cwd);
|
|
213
|
+
const inScope = cwd !== null && isRepoPathWithinRoot(cwd, scopeDir);
|
|
201
214
|
if (!inScope) {
|
|
202
215
|
totalSkipped++;
|
|
203
216
|
if (verbose) {
|
|
204
|
-
console.log(`[verbose] Skipping Claude turn ${turn.turnId} — cwd=${cwd ?? "(none)"} not under scopeDir=${scopeDir}`);
|
|
217
|
+
console.log(`[verbose] Skipping Claude turn ${turn.turnId} — cwd=${turn.cwd ?? "(none)"} not under scopeDir=${scopeDir}`);
|
|
205
218
|
}
|
|
206
219
|
continue;
|
|
207
220
|
}
|
|
@@ -222,13 +235,16 @@ async function runClaudeSlice(options) {
|
|
|
222
235
|
// effectively one network call per unique cwd per sync.
|
|
223
236
|
const resolvedProjectId = scopeDir
|
|
224
237
|
? (projectId ??
|
|
225
|
-
(await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache)) ??
|
|
238
|
+
(await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp)) ??
|
|
226
239
|
undefined)
|
|
227
240
|
: (explicitProject ??
|
|
228
|
-
(await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache)) ??
|
|
241
|
+
(await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp)) ??
|
|
229
242
|
undefined);
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
243
|
+
const payloads = mapClaudeTranscriptTurn(turn, { projectId: resolvedProjectId, pricing });
|
|
244
|
+
if (!payloads?.length)
|
|
245
|
+
continue;
|
|
246
|
+
const parentPayload = payloads[0];
|
|
247
|
+
if (verbose && parentPayload?.cost_usd === null) {
|
|
232
248
|
if (!turn.model) {
|
|
233
249
|
if (turn.tokensIn > 0 || turn.tokensOut > 0) {
|
|
234
250
|
console.warn(`[warn] Claude turn ${turn.turnId} has usage but no model — cost_usd will be null`);
|
|
@@ -241,45 +257,53 @@ async function runClaudeSlice(options) {
|
|
|
241
257
|
}
|
|
242
258
|
}
|
|
243
259
|
if (dryRun) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
260
|
+
for (const payload of payloads) {
|
|
261
|
+
console.log(`[dry-run] Would send Claude ${payload.event_type} ${payload.metadata.session_id}:`);
|
|
262
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
263
|
+
}
|
|
264
|
+
totalSent += payloads.length;
|
|
247
265
|
continue;
|
|
248
266
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
let allOk = true;
|
|
268
|
+
for (const payload of payloads) {
|
|
269
|
+
if (verbose) {
|
|
270
|
+
console.log(`[verbose] Sending Claude ${payload.event_type} ${payload.metadata.session_id}`);
|
|
271
|
+
}
|
|
272
|
+
const ok = await postEvent(payload, host, token, {
|
|
273
|
+
on429: (retryAfter, quotaExceeded) => {
|
|
274
|
+
const currentBackoff = backoffUntilByCredential.get(backoffKey);
|
|
275
|
+
const nextBackoff = new Date(Math.max(currentBackoff?.getTime() ?? 0, Date.now() + retryAfter * 1000));
|
|
276
|
+
backoffUntilByCredential.set(backoffKey, nextBackoff);
|
|
277
|
+
shouldStopForBackoff = true;
|
|
278
|
+
const reason = quotaExceeded ? "Monthly quota exceeded" : "Rate limited";
|
|
279
|
+
mcpLog.warn("sync_rate_limit_pause", {
|
|
280
|
+
tool: "claude_code",
|
|
281
|
+
retry_until: nextBackoff.toISOString(),
|
|
282
|
+
quota_exceeded: quotaExceeded,
|
|
283
|
+
}, true);
|
|
284
|
+
console.warn(`[aixle-insights] ${reason}. Pausing until ${nextBackoff.toISOString()}.`);
|
|
285
|
+
// Persist backoff to state so it survives process restarts
|
|
286
|
+
state = { ...state, rate_limited_until: nextBackoff.toISOString() };
|
|
287
|
+
writeState(state, appDir, host, token);
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
if (!ok) {
|
|
291
|
+
allOk = false;
|
|
292
|
+
totalFailed++;
|
|
293
|
+
errors.push(`Failed to post Claude ${payload.event_type} for turn ${turn.turnId}`);
|
|
294
|
+
mcpLog.error("sync_ingest_final_failure", { tool: "claude_code", session_id: turn.turnId }, true);
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (allOk) {
|
|
299
|
+
totalSent += payloads.length;
|
|
271
300
|
state = markSessionSent(state, sKey, turn.fileSize);
|
|
272
301
|
writeState(state, appDir, host, token);
|
|
273
|
-
totalSent++;
|
|
274
302
|
}
|
|
275
|
-
else {
|
|
276
|
-
|
|
277
|
-
errors.push(`Failed to post Claude turn ${turn.turnId}`);
|
|
278
|
-
mcpLog.error("sync_ingest_final_failure", { tool: "claude_code", session_id: turn.turnId }, true);
|
|
279
|
-
if (shouldStopForBackoff) {
|
|
280
|
-
break;
|
|
281
|
-
}
|
|
303
|
+
else if (shouldStopForBackoff) {
|
|
304
|
+
break;
|
|
282
305
|
}
|
|
306
|
+
// Non-backoff failure: continue to next turn (failed turn will retry next sync)
|
|
283
307
|
}
|
|
284
308
|
const result = { sent: totalSent, failed: totalFailed, skipped: totalSkipped };
|
|
285
309
|
const currentBackoff = backoffUntilByCredential.get(backoffKey);
|
|
@@ -290,7 +314,7 @@ async function runClaudeSlice(options) {
|
|
|
290
314
|
return result;
|
|
291
315
|
}
|
|
292
316
|
async function runCursorSlice(params) {
|
|
293
|
-
const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
|
|
317
|
+
const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, allowInsecureHttp = false, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
|
|
294
318
|
const backoffKey = credentialStateKey(host, token);
|
|
295
319
|
// Read state first so we can restore a persisted rate-limit backoff from a prior process.
|
|
296
320
|
const stateBefore = readState(appDir, host, token);
|
|
@@ -331,6 +355,7 @@ async function runCursorSlice(params) {
|
|
|
331
355
|
host,
|
|
332
356
|
token,
|
|
333
357
|
projectLookupToken: lookupToken,
|
|
358
|
+
allowInsecureHttp,
|
|
334
359
|
verbose,
|
|
335
360
|
cursorBaseDir,
|
|
336
361
|
cursorTranscriptProjectDirs,
|
|
@@ -341,11 +366,11 @@ async function runCursorSlice(params) {
|
|
|
341
366
|
const inScope = [];
|
|
342
367
|
for (const payload of group.payloads) {
|
|
343
368
|
const ws = cursorRepoPathFromPayload(payload);
|
|
344
|
-
if (ws && (ws
|
|
369
|
+
if (ws && isRepoPathWithinRoot(ws, scopeDir)) {
|
|
345
370
|
// Same fallback as Claude: when the pre-resolved projectId is null, do a
|
|
346
371
|
// per-payload lookup from the payload's workspace. Cache dedupes by path.
|
|
347
372
|
const resolved = projectId ??
|
|
348
|
-
(await resolveProjectIdForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache));
|
|
373
|
+
(await resolveProjectIdForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp));
|
|
349
374
|
if (resolved)
|
|
350
375
|
payload.project_id = resolved;
|
|
351
376
|
else
|
|
@@ -365,7 +390,7 @@ async function runCursorSlice(params) {
|
|
|
365
390
|
continue;
|
|
366
391
|
for (const payload of group.payloads) {
|
|
367
392
|
const repoPath = cursorRepoPathFromPayload(payload);
|
|
368
|
-
const resolvedProjectId = await resolveProjectIdForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache);
|
|
393
|
+
const resolvedProjectId = await resolveProjectIdForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp);
|
|
369
394
|
if (resolvedProjectId)
|
|
370
395
|
payload.project_id = resolvedProjectId;
|
|
371
396
|
else
|
|
@@ -438,7 +463,7 @@ async function runCursorSlice(params) {
|
|
|
438
463
|
totalSkipped++;
|
|
439
464
|
continue;
|
|
440
465
|
}
|
|
441
|
-
const ok = await postEvent(payload, host, token, { on429 });
|
|
466
|
+
const ok = await postEvent(payload, host, token, { on429, allowInsecureHttp });
|
|
442
467
|
if (ok) {
|
|
443
468
|
totalSent++;
|
|
444
469
|
const turnId = payload.metadata.session_id;
|
|
@@ -455,7 +480,7 @@ async function runCursorSlice(params) {
|
|
|
455
480
|
}
|
|
456
481
|
continue;
|
|
457
482
|
}
|
|
458
|
-
const batchResult = await postEvents(group.payloads, host, token, { on429 });
|
|
483
|
+
const batchResult = await postEvents(group.payloads, host, token, { on429, allowInsecureHttp });
|
|
459
484
|
totalSent += batchResult.sent;
|
|
460
485
|
totalFailed += batchResult.failed;
|
|
461
486
|
if (batchResult.failed > 0) {
|
|
@@ -501,10 +526,11 @@ async function runCursorSlice(params) {
|
|
|
501
526
|
state: stateMut,
|
|
502
527
|
host,
|
|
503
528
|
token,
|
|
529
|
+
allowInsecureHttp,
|
|
504
530
|
on429,
|
|
505
531
|
resolveProjectId: explicitProject
|
|
506
532
|
? undefined
|
|
507
|
-
: (workspace) => resolveProjectIdForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache),
|
|
533
|
+
: (workspace) => resolveProjectIdForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp),
|
|
508
534
|
verbose,
|
|
509
535
|
});
|
|
510
536
|
totalSent += hooksResult.sent;
|
|
@@ -590,6 +616,7 @@ export async function syncTelemetryTools(options) {
|
|
|
590
616
|
dryRun,
|
|
591
617
|
verbose,
|
|
592
618
|
projectId,
|
|
619
|
+
allowInsecureHttp: credentials.insecureHttpAllowed === true,
|
|
593
620
|
pricing,
|
|
594
621
|
appDir,
|
|
595
622
|
transcriptBaseDirs: options.transcriptBaseDirs,
|
|
@@ -606,6 +633,7 @@ export async function syncTelemetryTools(options) {
|
|
|
606
633
|
projectId,
|
|
607
634
|
projectIdSource,
|
|
608
635
|
projectLookupToken,
|
|
636
|
+
allowInsecureHttp: credentials.insecureHttpAllowed === true,
|
|
609
637
|
appDir,
|
|
610
638
|
cursorBaseDir: options.cursorBaseDir,
|
|
611
639
|
cursorTranscriptProjectDirs: options.cursorTranscriptProjectDirs,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aixle/insights",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2-staging",
|
|
4
4
|
"description": "stdio MCP server for AI coding-assistant telemetry — Claude transcript sync + Cursor SQLite ingest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsc && node -e \"const{mkdirSync,copyFileSync}=require('fs');mkdirSync('dist/hooks',{recursive:true});copyFileSync('src/hooks/hook-forwarder.mjs','dist/hooks/hook-forwarder.mjs');\"",
|
|
45
45
|
"test": "vitest run",
|
|
46
|
+
"lint": "eslint . --max-warnings 0",
|
|
46
47
|
"verify:cursor-dry-run": "tsx scripts/verify-cursor-dry-run.ts",
|
|
47
48
|
"audit:local-stores": "tsx scripts/audit-local-stores.ts",
|
|
48
49
|
"dev": "tsx src/cli.ts",
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
"dependencies": {
|
|
52
53
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
53
54
|
"better-sqlite3": "^12.9.0",
|
|
54
|
-
"glob": "^
|
|
55
|
+
"glob": "^13.0.6",
|
|
55
56
|
"zod": "^3.23.0 || ^4.0.0"
|
|
56
57
|
},
|
|
57
58
|
"optionalDependencies": {
|
|
@@ -60,6 +61,9 @@
|
|
|
60
61
|
"devDependencies": {
|
|
61
62
|
"@types/better-sqlite3": "^7.6.8",
|
|
62
63
|
"@types/node": "^24",
|
|
64
|
+
"@typescript-eslint/parser": "^8.65.0",
|
|
65
|
+
"eslint": "^10.8.0",
|
|
66
|
+
"eslint-plugin-security": "^4.0.1",
|
|
63
67
|
"tsx": "^4.7.0",
|
|
64
68
|
"typescript": "^5.3.3",
|
|
65
69
|
"vitest": "^4.1.0"
|