@aixle/insights 0.1.0

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/dist/auth/credentials.d.ts +23 -0
  4. package/dist/auth/credentials.js +174 -0
  5. package/dist/auth/exchange.d.ts +25 -0
  6. package/dist/auth/exchange.js +87 -0
  7. package/dist/auth/flow.d.ts +24 -0
  8. package/dist/auth/flow.js +66 -0
  9. package/dist/auth/keycloak.d.ts +35 -0
  10. package/dist/auth/keycloak.js +170 -0
  11. package/dist/cli.d.ts +51 -0
  12. package/dist/cli.js +426 -0
  13. package/dist/client.d.ts +28 -0
  14. package/dist/client.js +102 -0
  15. package/dist/collect-cursor-payloads.d.ts +57 -0
  16. package/dist/collect-cursor-payloads.js +134 -0
  17. package/dist/credentials.d.ts +2 -0
  18. package/dist/credentials.js +1 -0
  19. package/dist/cursor-checkpoints.d.ts +12 -0
  20. package/dist/cursor-checkpoints.js +28 -0
  21. package/dist/cursor-config.d.ts +5 -0
  22. package/dist/cursor-config.js +34 -0
  23. package/dist/cursor-payload-contract.d.ts +17 -0
  24. package/dist/cursor-payload-contract.js +258 -0
  25. package/dist/cursor-settings.d.ts +6 -0
  26. package/dist/cursor-settings.js +38 -0
  27. package/dist/cursor-store-audit.d.ts +48 -0
  28. package/dist/cursor-store-audit.js +155 -0
  29. package/dist/daily-stats-versions.d.ts +31 -0
  30. package/dist/daily-stats-versions.js +170 -0
  31. package/dist/health.d.ts +31 -0
  32. package/dist/health.js +195 -0
  33. package/dist/hooks/cursor-hooks-mapper.d.ts +22 -0
  34. package/dist/hooks/cursor-hooks-mapper.js +84 -0
  35. package/dist/hooks/cursor-hooks-reader.d.ts +30 -0
  36. package/dist/hooks/cursor-hooks-reader.js +117 -0
  37. package/dist/hooks/hook-forwarder.mjs +110 -0
  38. package/dist/hooks/hooks-config.d.ts +92 -0
  39. package/dist/hooks/hooks-config.js +235 -0
  40. package/dist/install/claude.d.ts +37 -0
  41. package/dist/install/claude.js +144 -0
  42. package/dist/install/index.d.ts +8 -0
  43. package/dist/install/index.js +11 -0
  44. package/dist/lib/args.d.ts +26 -0
  45. package/dist/lib/args.js +17 -0
  46. package/dist/lib/client.d.ts +33 -0
  47. package/dist/lib/client.js +52 -0
  48. package/dist/lib/config.d.ts +26 -0
  49. package/dist/lib/config.js +39 -0
  50. package/dist/lib/index.d.ts +4 -0
  51. package/dist/lib/index.js +4 -0
  52. package/dist/lib/project-resolver.d.ts +48 -0
  53. package/dist/lib/project-resolver.js +203 -0
  54. package/dist/lock.d.ts +9 -0
  55. package/dist/lock.js +84 -0
  56. package/dist/log.d.ts +14 -0
  57. package/dist/log.js +81 -0
  58. package/dist/pricing.d.ts +40 -0
  59. package/dist/pricing.js +149 -0
  60. package/dist/readers/claude.d.ts +83 -0
  61. package/dist/readers/claude.js +317 -0
  62. package/dist/readers/cursor.d.ts +134 -0
  63. package/dist/readers/cursor.js +900 -0
  64. package/dist/risk-scanner.d.ts +8 -0
  65. package/dist/risk-scanner.js +59 -0
  66. package/dist/server.d.ts +14 -0
  67. package/dist/server.js +234 -0
  68. package/dist/state.d.ts +69 -0
  69. package/dist/state.js +155 -0
  70. package/dist/sync.d.ts +74 -0
  71. package/dist/sync.js +679 -0
  72. package/package.json +66 -0
package/dist/sync.js ADDED
@@ -0,0 +1,679 @@
1
+ import { join } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { processHooksQueue } from "./hooks/cursor-hooks-reader.js";
4
+ import { readState, writeState, markSessionSent, getAppDir, stateKey as credentialStateKey, migrateLegacyState, withMcpOperator, } from "./state.js";
5
+ import { findTranscriptFiles, parseTranscriptFile, mapTranscriptTurn as mapClaudeTranscriptTurn, isClaudeNoiseTranscriptTurn, } from "./readers/claude.js";
6
+ import { prepareCursorSliceGroups } from "./collect-cursor-payloads.js";
7
+ import { CURSOR_RECENT_COMMIT_WATERMARK_KEY, cursorTranscriptTurnStateKey, } from "./cursor-checkpoints.js";
8
+ import { printCursorDryRunValidationReport } from "./cursor-payload-contract.js";
9
+ import { resolveCursorPricing } from "./cursor-config.js";
10
+ import { postEvent, postEvents } from "./client.js";
11
+ import { getCostWarning } from "./pricing.js";
12
+ import { acquireSyncLock } from "./lock.js";
13
+ import { getGitRemoteForPath, lookupProjectByRemote, } from "./lib/index.js";
14
+ import { mcpLog } from "./log.js";
15
+ /** Prefix for Claude Code session keys in shared MCP state. */
16
+ export const CLAUDE_STATE_PREFIX = "claude_code:";
17
+ export { CURSOR_WATERMARK_KEY, CURSOR_EVENTS_WATERMARK_KEY, CURSOR_DAILY_STATS_WATERMARK_KEY, CURSOR_RECENT_COMMIT_WATERMARK_KEY, CURSOR_TRANSCRIPT_TURN_PREFIX, cursorTranscriptTurnStateKey, filterRecentCommitsByHashDedup, } from "./cursor-checkpoints.js";
18
+ export function sessionStateKey(sessionId) {
19
+ return `${CLAUDE_STATE_PREFIX}${sessionId}`;
20
+ }
21
+ const backoffUntilByCredential = new Map();
22
+ /** Clears in-memory rate-limit backoff (test hook). */
23
+ export function resetBackoffStateForTests() {
24
+ backoffUntilByCredential.clear();
25
+ }
26
+ let lastSyncAt = null;
27
+ let lastSyncResult = null;
28
+ let recentErrors = [];
29
+ function syncResultToSnapshot(r) {
30
+ return {
31
+ sent: r.sent,
32
+ failed: r.failed,
33
+ skipped: r.skipped,
34
+ locked: r.locked,
35
+ rate_limited_until: r.rateLimitedUntil ?? null,
36
+ errors: r.errors,
37
+ };
38
+ }
39
+ function persistOperatorState(appDir, host, credentials, result, errors, syncTimestamp) {
40
+ const seen = new Set();
41
+ for (const tok of Object.values(credentials.accounts)) {
42
+ if (typeof tok !== "string" || !tok.length || seen.has(tok))
43
+ continue;
44
+ seen.add(tok);
45
+ const st = readState(appDir, host, tok);
46
+ let nextOp;
47
+ if (result.locked) {
48
+ const prev = st.mcp_operator;
49
+ const lockMsg = "Sync skipped — another process holds the lock";
50
+ nextOp = {
51
+ last_sync_at: prev?.last_sync_at ?? null,
52
+ last_result: prev?.last_result ?? null,
53
+ recent_errors: [...(prev?.recent_errors ?? []), lockMsg].slice(-20),
54
+ };
55
+ }
56
+ else {
57
+ nextOp = {
58
+ last_sync_at: syncTimestamp,
59
+ last_result: syncResultToSnapshot(result),
60
+ recent_errors: errors.length > 0 || result.failed > 0 ? errors.slice(-20) : [],
61
+ };
62
+ }
63
+ writeState(withMcpOperator(st, nextOp), appDir, host, tok);
64
+ }
65
+ }
66
+ export function getSyncTelemetry() {
67
+ return { lastSyncAt, lastResult: lastSyncResult, recentErrors };
68
+ }
69
+ function recordTelemetry(result, errors, persistCtx) {
70
+ const syncTimestamp = new Date().toISOString();
71
+ if (!result.locked) {
72
+ lastSyncAt = syncTimestamp;
73
+ lastSyncResult = result;
74
+ }
75
+ if (result.locked) {
76
+ recentErrors = [...recentErrors, "Sync skipped — another process holds the lock"].slice(-20);
77
+ }
78
+ else if (errors.length > 0 || result.failed > 0) {
79
+ recentErrors = errors.slice(-20);
80
+ }
81
+ else {
82
+ recentErrors = [];
83
+ }
84
+ if (persistCtx) {
85
+ persistOperatorState(persistCtx.appDir, persistCtx.host, persistCtx.credentials, result, errors, syncTimestamp);
86
+ }
87
+ }
88
+ function mergeCredentialRateLimit(backoffKeys) {
89
+ let furthest = null;
90
+ for (const bk of backoffKeys) {
91
+ const u = backoffUntilByCredential.get(bk);
92
+ if (u && new Date() < u && (!furthest || u > furthest))
93
+ furthest = u;
94
+ }
95
+ return furthest?.toISOString() ?? null;
96
+ }
97
+ function dedupeTools(tools) {
98
+ return [...new Set(tools)];
99
+ }
100
+ function explicitProjectId(projectId, projectIdSource) {
101
+ return projectId && (projectIdSource === "flag" || projectIdSource === "config")
102
+ ? projectId
103
+ : undefined;
104
+ }
105
+ async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache) {
106
+ const normalized = repoPath?.trim();
107
+ if (!normalized)
108
+ return null;
109
+ if (cache.has(normalized))
110
+ return cache.get(normalized) ?? null;
111
+ const canonicalRemote = getGitRemoteForPath(normalized, verbose);
112
+ if (!canonicalRemote) {
113
+ cache.set(normalized, null);
114
+ return null;
115
+ }
116
+ const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose);
117
+ const projectId = result && typeof result === "object" && "project_id" in result ? result.project_id : null;
118
+ cache.set(normalized, projectId);
119
+ return projectId;
120
+ }
121
+ export function cursorRepoPathFromPayload(payload) {
122
+ const metadata = payload.metadata;
123
+ if (!metadata)
124
+ return undefined;
125
+ if (typeof metadata.workspace_folder === "string" && metadata.workspace_folder.length > 0) {
126
+ return metadata.workspace_folder;
127
+ }
128
+ if (typeof metadata.workspace === "string" && metadata.workspace.length > 0) {
129
+ return metadata.workspace;
130
+ }
131
+ return undefined;
132
+ }
133
+ async function runClaudeSlice(options) {
134
+ const { token, host, dryRun, verbose, projectId, pricing } = options;
135
+ const appDir = options.appDir ?? getAppDir();
136
+ const backoffKey = credentialStateKey(host, token);
137
+ const errors = [];
138
+ // Read state first so we can restore a persisted rate-limit backoff from a prior process.
139
+ let state = readState(appDir, host, token);
140
+ const persistedRateLimit = state.rate_limited_until ? new Date(state.rate_limited_until) : null;
141
+ if (persistedRateLimit && !Number.isNaN(persistedRateLimit.getTime())) {
142
+ const existing = backoffUntilByCredential.get(backoffKey);
143
+ if (!existing || persistedRateLimit > existing) {
144
+ backoffUntilByCredential.set(backoffKey, persistedRateLimit);
145
+ }
146
+ }
147
+ const backoffUntil = backoffUntilByCredential.get(backoffKey) ?? null;
148
+ if (backoffUntil && new Date() < backoffUntil) {
149
+ const until = backoffUntil.toISOString();
150
+ mcpLog.info("sync_rate_limit_skip", { tool: "claude_code", until }, verbose);
151
+ if (verbose) {
152
+ console.log(`[verbose] Rate limited — skipping until ${until}`);
153
+ }
154
+ else {
155
+ console.warn(`[aixle-insights] Rate limited — skipping until ${until}`);
156
+ }
157
+ return {
158
+ sent: 0,
159
+ failed: 0,
160
+ skipped: 0,
161
+ rateLimitedUntil: until,
162
+ };
163
+ }
164
+ const files = findTranscriptFiles(options.transcriptBaseDirs);
165
+ if (verbose) {
166
+ console.log(`[verbose] Found ${files.length} transcript file(s)`);
167
+ mcpLog.info("sync_claude_transcripts", { files_found: files.length }, false);
168
+ }
169
+ const allFileTurns = await Promise.all(files.map((f) => parseTranscriptFile(f, verbose)));
170
+ const bestTurns = new Map();
171
+ for (const fileTurns of allFileTurns) {
172
+ for (const turn of fileTurns) {
173
+ const existing = bestTurns.get(turn.turnId);
174
+ if (!existing || turn.fileSize > existing.fileSize) {
175
+ bestTurns.set(turn.turnId, turn);
176
+ }
177
+ }
178
+ }
179
+ const { scopeDir } = options;
180
+ let totalSent = 0;
181
+ let totalFailed = 0;
182
+ let totalSkipped = 0;
183
+ let shouldStopForBackoff = false;
184
+ const explicitProject = explicitProjectId(projectId, options.projectIdSource);
185
+ const projectLookupCache = new Map();
186
+ for (const turn of bestTurns.values()) {
187
+ // Defense-in-depth: parseTranscriptFile already drops noise turns before returning.
188
+ // This guard only fires if the parser's filter is weakened in a future change.
189
+ if (isClaudeNoiseTranscriptTurn(turn)) {
190
+ totalSkipped++;
191
+ if (verbose) {
192
+ console.log(`[verbose] Skipping Claude noise turn ${turn.turnId} — local-command/meta`);
193
+ }
194
+ mcpLog.info("sync_noise_skip", { tool: "claude_code", reason: "local_command_noise" }, false);
195
+ continue;
196
+ }
197
+ // When scopeDir is set, skip turns from other directories.
198
+ if (scopeDir) {
199
+ const cwd = turn.cwd?.trim();
200
+ const inScope = cwd && (cwd === scopeDir || cwd.startsWith(scopeDir + "/"));
201
+ if (!inScope) {
202
+ totalSkipped++;
203
+ if (verbose) {
204
+ console.log(`[verbose] Skipping Claude turn ${turn.turnId} — cwd=${cwd ?? "(none)"} not under scopeDir=${scopeDir}`);
205
+ }
206
+ continue;
207
+ }
208
+ }
209
+ const sKey = sessionStateKey(turn.turnId);
210
+ const known = state.sessions[sKey];
211
+ if (known) {
212
+ totalSkipped++;
213
+ if (verbose) {
214
+ console.log(`[verbose] Skipping already-synced Claude turn ${turn.turnId}`);
215
+ }
216
+ mcpLog.info("sync_checkpoint_skip", { tool: "claude_code", reason: "existing_turn_checkpoint", session_id: turn.turnId }, false);
217
+ continue;
218
+ }
219
+ // When scoped to a directory, prefer the pre-resolved projectId. If it's null
220
+ // (e.g. MCP server launched from a non-git cwd, or stuck null in module cache),
221
+ // fall back to per-turn cwd lookup. The lookup cache dedupes by path so this is
222
+ // effectively one network call per unique cwd per sync.
223
+ const resolvedProjectId = scopeDir
224
+ ? (projectId ??
225
+ (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache)) ??
226
+ undefined)
227
+ : (explicitProject ??
228
+ (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache)) ??
229
+ undefined);
230
+ const payload = mapClaudeTranscriptTurn(turn, { projectId: resolvedProjectId, pricing });
231
+ if (verbose && payload.cost_usd === null) {
232
+ if (!turn.model) {
233
+ if (turn.tokensIn > 0 || turn.tokensOut > 0) {
234
+ console.warn(`[warn] Claude turn ${turn.turnId} has usage but no model — cost_usd will be null`);
235
+ }
236
+ }
237
+ else {
238
+ const warning = getCostWarning(turn.model, pricing);
239
+ if (warning)
240
+ console.warn(`[warn] ${warning}`);
241
+ }
242
+ }
243
+ if (dryRun) {
244
+ console.log(`[dry-run] Would send Claude turn ${turn.turnId}:`);
245
+ console.log(JSON.stringify(payload, null, 2));
246
+ totalSent++;
247
+ continue;
248
+ }
249
+ if (verbose) {
250
+ console.log(`[verbose] Sending Claude turn ${turn.turnId} (${turn.tokensIn + turn.tokensOut} tokens)`);
251
+ }
252
+ const ok = await postEvent(payload, host, token, {
253
+ on429: (retryAfter, quotaExceeded) => {
254
+ const currentBackoff = backoffUntilByCredential.get(backoffKey);
255
+ const nextBackoff = new Date(Math.max(currentBackoff?.getTime() ?? 0, Date.now() + retryAfter * 1000));
256
+ backoffUntilByCredential.set(backoffKey, nextBackoff);
257
+ shouldStopForBackoff = true;
258
+ const reason = quotaExceeded ? "Monthly quota exceeded" : "Rate limited";
259
+ mcpLog.warn("sync_rate_limit_pause", {
260
+ tool: "claude_code",
261
+ retry_until: nextBackoff.toISOString(),
262
+ quota_exceeded: quotaExceeded,
263
+ }, true);
264
+ console.warn(`[aixle-insights] ${reason}. Pausing until ${nextBackoff.toISOString()}.`);
265
+ // Persist backoff to state so it survives process restarts
266
+ state = { ...state, rate_limited_until: nextBackoff.toISOString() };
267
+ writeState(state, appDir, host, token);
268
+ },
269
+ });
270
+ if (ok) {
271
+ state = markSessionSent(state, sKey, turn.fileSize);
272
+ writeState(state, appDir, host, token);
273
+ totalSent++;
274
+ }
275
+ else {
276
+ totalFailed++;
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
+ }
282
+ }
283
+ }
284
+ const result = { sent: totalSent, failed: totalFailed, skipped: totalSkipped };
285
+ const currentBackoff = backoffUntilByCredential.get(backoffKey);
286
+ if (currentBackoff && new Date() < currentBackoff)
287
+ result.rateLimitedUntil = currentBackoff.toISOString();
288
+ if (errors.length > 0)
289
+ result.errors = errors;
290
+ return result;
291
+ }
292
+ async function runCursorSlice(params) {
293
+ const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
294
+ const backoffKey = credentialStateKey(host, token);
295
+ // Read state first so we can restore a persisted rate-limit backoff from a prior process.
296
+ const stateBefore = readState(appDir, host, token);
297
+ const persistedRateLimit = stateBefore.rate_limited_until
298
+ ? new Date(stateBefore.rate_limited_until)
299
+ : null;
300
+ if (persistedRateLimit && !Number.isNaN(persistedRateLimit.getTime())) {
301
+ const existing = backoffUntilByCredential.get(backoffKey);
302
+ if (!existing || persistedRateLimit > existing) {
303
+ backoffUntilByCredential.set(backoffKey, persistedRateLimit);
304
+ }
305
+ }
306
+ const backoffUntil = backoffUntilByCredential.get(backoffKey) ?? null;
307
+ if (backoffUntil && new Date() < backoffUntil) {
308
+ const until = backoffUntil.toISOString();
309
+ mcpLog.info("sync_rate_limit_skip", { tool: "cursor", until }, verbose);
310
+ if (verbose) {
311
+ console.log(`[verbose][cursor] Rate limited — skipping until ${until}`);
312
+ }
313
+ else {
314
+ console.warn(`[aixle-insights][cursor] Rate limited — skipping until ${until}`);
315
+ }
316
+ return {
317
+ sent: 0,
318
+ failed: 0,
319
+ skipped: 0,
320
+ rateLimitedUntil: until,
321
+ };
322
+ }
323
+ const explicitProject = explicitProjectId(projectId, projectIdSource);
324
+ const projectLookupCache = new Map();
325
+ const lookupToken = projectLookupToken ?? token;
326
+ let { groups, skippedTranscriptCount, transcriptTurnsById, counts } = await prepareCursorSliceGroups({
327
+ stateBefore,
328
+ fullScan,
329
+ projectId: explicitProject ?? projectId,
330
+ projectIdSource,
331
+ host,
332
+ token,
333
+ projectLookupToken: lookupToken,
334
+ verbose,
335
+ cursorBaseDir,
336
+ cursorTranscriptProjectDirs,
337
+ cursorPricing,
338
+ });
339
+ if (scopeDir) {
340
+ for (const group of groups) {
341
+ const inScope = [];
342
+ for (const payload of group.payloads) {
343
+ const ws = cursorRepoPathFromPayload(payload);
344
+ if (ws && (ws === scopeDir || ws.startsWith(scopeDir + "/"))) {
345
+ // Same fallback as Claude: when the pre-resolved projectId is null, do a
346
+ // per-payload lookup from the payload's workspace. Cache dedupes by path.
347
+ const resolved = projectId ??
348
+ (await resolveProjectIdForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache));
349
+ if (resolved)
350
+ payload.project_id = resolved;
351
+ else
352
+ delete payload.project_id;
353
+ inScope.push(payload);
354
+ }
355
+ else if (verbose) {
356
+ console.log(`[verbose][cursor] Skipping payload — workspace=${ws ?? "(none)"} not under scopeDir=${scopeDir}`);
357
+ }
358
+ }
359
+ group.payloads = inScope;
360
+ }
361
+ }
362
+ else if (!explicitProject) {
363
+ for (const group of groups) {
364
+ if (group.key === CURSOR_RECENT_COMMIT_WATERMARK_KEY)
365
+ continue;
366
+ for (const payload of group.payloads) {
367
+ const repoPath = cursorRepoPathFromPayload(payload);
368
+ const resolvedProjectId = await resolveProjectIdForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache);
369
+ if (resolvedProjectId)
370
+ payload.project_id = resolvedProjectId;
371
+ else
372
+ delete payload.project_id;
373
+ }
374
+ }
375
+ }
376
+ const totalPayloadCount = groups.reduce((sum, group) => sum + group.payloads.length, 0);
377
+ if (totalPayloadCount === 0 && !existsSync(join(appDir, "hooks-queue.ndjson"))) {
378
+ return { sent: 0, failed: 0, skipped: 0 };
379
+ }
380
+ if (dryRun) {
381
+ console.log(`[dry-run][cursor] Would send ${totalPayloadCount} event(s)`);
382
+ console.log(`[dry-run][cursor] Note: cost_usd values are estimates (see cost_model in metadata).`);
383
+ for (const group of groups) {
384
+ for (const ev of group.payloads) {
385
+ console.log(JSON.stringify(ev, null, 2));
386
+ }
387
+ }
388
+ const allPayloads = groups.flatMap((group) => group.payloads);
389
+ const contractOk = printCursorDryRunValidationReport(allPayloads);
390
+ return {
391
+ sent: totalPayloadCount,
392
+ failed: 0,
393
+ skipped: 0,
394
+ validationFailed: !contractOk,
395
+ };
396
+ }
397
+ if (skippedTranscriptCount > 0 && verbose) {
398
+ mcpLog.info("cursor_transcript_dedupe_skip", { count: skippedTranscriptCount }, false);
399
+ }
400
+ if (counts.suppressedComposer > 0) {
401
+ mcpLog.info("cursor_composer_suppressed", { reason: "transcript_mode", count: counts.suppressedComposer }, verbose);
402
+ if (verbose) {
403
+ console.log(`[verbose][cursor] ${counts.suppressedComposer} daily_composer event(s) suppressed — transcripts take precedence`);
404
+ }
405
+ }
406
+ let shouldStopForBackoff = false;
407
+ let stateMut = stateBefore;
408
+ let totalSent = 0;
409
+ let totalFailed = 0;
410
+ let totalSkipped = skippedTranscriptCount;
411
+ const errors = [];
412
+ const on429 = (retryAfter, quotaExceeded) => {
413
+ const currentBackoff = backoffUntilByCredential.get(backoffKey);
414
+ const nextBackoff = new Date(Math.max(currentBackoff?.getTime() ?? 0, Date.now() + retryAfter * 1000));
415
+ backoffUntilByCredential.set(backoffKey, nextBackoff);
416
+ shouldStopForBackoff = true;
417
+ const reason = quotaExceeded ? "Monthly quota exceeded" : "Rate limited";
418
+ mcpLog.warn("sync_rate_limit_pause", {
419
+ tool: "cursor",
420
+ retry_until: nextBackoff.toISOString(),
421
+ quota_exceeded: quotaExceeded,
422
+ }, true);
423
+ console.warn(`[aixle-insights][cursor] ${reason}. Pausing until ${nextBackoff.toISOString()}.`);
424
+ // Persist backoff to state so it survives process restarts
425
+ stateMut = { ...stateMut, rate_limited_until: nextBackoff.toISOString() };
426
+ writeState(stateMut, appDir, host, token);
427
+ };
428
+ for (const group of groups) {
429
+ if (group.payloads.length === 0)
430
+ continue;
431
+ if (shouldStopForBackoff) {
432
+ totalSkipped += group.payloads.length;
433
+ continue;
434
+ }
435
+ if (group.label === "transcripts") {
436
+ for (const payload of group.payloads) {
437
+ if (shouldStopForBackoff) {
438
+ totalSkipped++;
439
+ continue;
440
+ }
441
+ const ok = await postEvent(payload, host, token, { on429 });
442
+ if (ok) {
443
+ totalSent++;
444
+ const turnId = payload.metadata.session_id;
445
+ const sourceTurn = typeof turnId === "string" ? transcriptTurnsById.get(turnId) : undefined;
446
+ if (sourceTurn) {
447
+ stateMut = markSessionSent(stateMut, cursorTranscriptTurnStateKey(sourceTurn.turnId), sourceTurn.fileSize, sourceTurn.contentHash);
448
+ writeState(stateMut, appDir, host, token);
449
+ }
450
+ continue;
451
+ }
452
+ totalFailed++;
453
+ errors.push(`Cursor sync (${group.label}): failed to post ${payload.occurred_at}`);
454
+ mcpLog.error("sync_ingest_final_failure", { tool: "cursor", group: group.label, occurred_at: payload.occurred_at }, true);
455
+ }
456
+ continue;
457
+ }
458
+ const batchResult = await postEvents(group.payloads, host, token, { on429 });
459
+ totalSent += batchResult.sent;
460
+ totalFailed += batchResult.failed;
461
+ if (batchResult.failed > 0) {
462
+ errors.push(`Cursor sync (${group.label}): ${batchResult.failed} event(s) failed to post`);
463
+ mcpLog.error("sync_ingest_batch_failures", { tool: "cursor", group: group.label, failed: batchResult.failed, sent: batchResult.sent }, true);
464
+ if (batchResult.sent > 0) {
465
+ // Partial failure: watermark advances past the succeeded events; failed events are not retried.
466
+ // This is best-effort delivery — events are sent in timestamp order so the window is bounded.
467
+ mcpLog.warn("sync_batch_partial_failure_events_dropped", { tool: "cursor", group: group.label, sent: batchResult.sent, failed: batchResult.failed }, true);
468
+ console.warn(`[aixle-insights][cursor] Partial batch failure in ${group.label}: ` +
469
+ `${batchResult.sent} sent, ${batchResult.failed} dropped (watermark advances past sent events).`);
470
+ }
471
+ }
472
+ if (batchResult.lastSentAt !== null) {
473
+ stateMut = {
474
+ ...stateMut,
475
+ sessions: {
476
+ ...stateMut.sessions,
477
+ [group.key]: { fileSize: 0, sentAt: batchResult.lastSentAt },
478
+ },
479
+ };
480
+ // Only persist hashes when the entire batch succeeded. On partial failure,
481
+ // failed commits must remain retryable — saving their hashes here would
482
+ // prevent filterRecentCommitsByHashDedup from re-sending them on the next sync.
483
+ if (group.key === CURSOR_RECENT_COMMIT_WATERMARK_KEY && batchResult.sent > 0 && batchResult.failed === 0) {
484
+ const sentHashes = group.payloads
485
+ .map((payload) => payload.metadata.commit_hash)
486
+ .filter((hash) => typeof hash === "string" && hash.length > 0);
487
+ if (sentHashes.length > 0) {
488
+ stateMut = { ...stateMut, lastRecentCommitHashes: sentHashes };
489
+ }
490
+ }
491
+ writeState(stateMut, appDir, host, token);
492
+ }
493
+ }
494
+ // Process cursor hooks queue if present (opt-in; no-op when hooks not installed)
495
+ if (!shouldStopForBackoff) {
496
+ const hooksQueuePath = join(appDir, "hooks-queue.ndjson");
497
+ try {
498
+ const hooksResult = await processHooksQueue({
499
+ queuePath: hooksQueuePath,
500
+ scopeDir,
501
+ state: stateMut,
502
+ host,
503
+ token,
504
+ on429,
505
+ resolveProjectId: explicitProject
506
+ ? undefined
507
+ : (workspace) => resolveProjectIdForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache),
508
+ verbose,
509
+ });
510
+ totalSent += hooksResult.sent;
511
+ totalFailed += hooksResult.failed;
512
+ totalSkipped += hooksResult.skipped;
513
+ stateMut = hooksResult.state;
514
+ if (hooksResult.sent > 0 || hooksResult.failed > 0) {
515
+ writeState(stateMut, appDir, host, token);
516
+ }
517
+ }
518
+ catch (err) {
519
+ const msg = err instanceof Error ? err.message : String(err);
520
+ errors.push(`cursor-hooks queue processing failed: ${msg}`);
521
+ mcpLog.error("sync_hooks_queue_error", { error: msg }, true);
522
+ }
523
+ }
524
+ const slice = {
525
+ sent: totalSent,
526
+ failed: totalFailed,
527
+ skipped: totalSkipped,
528
+ };
529
+ const currentBackoff = backoffUntilByCredential.get(backoffKey);
530
+ if (currentBackoff && new Date() < currentBackoff) {
531
+ slice.rateLimitedUntil = currentBackoff.toISOString();
532
+ }
533
+ if (errors.length > 0 || shouldStopForBackoff) {
534
+ if (shouldStopForBackoff) {
535
+ errors.push("Cursor sync paused due to rate limiting");
536
+ }
537
+ slice.errors = errors;
538
+ }
539
+ return slice;
540
+ }
541
+ /**
542
+ * Parallel multi-tool cycle under the global advisory ingest lock (`state.lock`).
543
+ */
544
+ export async function syncTelemetryTools(options) {
545
+ const appDir = options.appDir ?? getAppDir();
546
+ const lock = acquireSyncLock(appDir);
547
+ if (!lock.acquired) {
548
+ const lockedResult = { sent: 0, failed: 0, skipped: 0, locked: true };
549
+ recordTelemetry(lockedResult, []);
550
+ mcpLog.warn("sync_lock_skip", { reason: "advisory_lock_held", app_dir: appDir }, true);
551
+ return lockedResult;
552
+ }
553
+ try {
554
+ const { credentials, dryRun, verbose, projectId, projectIdSource, projectLookupToken, pricing, scopeDir } = options;
555
+ const host = credentials.host;
556
+ const appDirResolved = options.appDir ?? getAppDir();
557
+ const cursorPricing = options.cursorPricing ?? resolveCursorPricing(undefined, appDirResolved);
558
+ const requested = dedupeTools(options.tools ?? ["claude_code", "cursor"]);
559
+ const missingRequested = requested.filter((t) => !credentials.accounts[t]);
560
+ if (missingRequested.length > 0) {
561
+ const errors = [`Missing credentials for requested tool(s): ${missingRequested.join(", ")}`];
562
+ const failedResult = { sent: 0, failed: missingRequested.length, skipped: 0, errors };
563
+ mcpLog.warn("sync_tool_validation_failed", { missing_tools: missingRequested, requested_tools: requested }, true);
564
+ recordTelemetry(failedResult, errors, dryRun ? undefined : { appDir, host, credentials });
565
+ return failedResult;
566
+ }
567
+ const withToken = requested.filter((t) => !!credentials.accounts[t]);
568
+ if (withToken.length === 0) {
569
+ const zero = { sent: 0, failed: 0, skipped: 0 };
570
+ recordTelemetry(zero, [], dryRun ? undefined : { appDir, host, credentials });
571
+ return zero;
572
+ }
573
+ mcpLog.info("sync_cycle_start", { ingest_tools: withToken, requested_tools: requested, dry_run: dryRun }, false);
574
+ const uniqueTokens = new Set();
575
+ for (const t of withToken) {
576
+ const tok = credentials.accounts[t];
577
+ if (tok && uniqueTokens.has(tok))
578
+ continue;
579
+ if (tok)
580
+ migrateLegacyState(appDir, host, tok);
581
+ if (tok)
582
+ uniqueTokens.add(tok);
583
+ }
584
+ const tasks = [];
585
+ const sharedCredentialToken = uniqueTokens.size < withToken.length;
586
+ if (withToken.includes("claude_code")) {
587
+ const task = () => runClaudeSlice({
588
+ token: credentials.accounts.claude_code,
589
+ host,
590
+ dryRun,
591
+ verbose,
592
+ projectId,
593
+ pricing,
594
+ appDir,
595
+ transcriptBaseDirs: options.transcriptBaseDirs,
596
+ scopeDir,
597
+ }).then((r) => ({ ...r, tag: "claude_code" }));
598
+ tasks.push(task);
599
+ }
600
+ if (withToken.includes("cursor")) {
601
+ const task = () => runCursorSlice({
602
+ token: credentials.accounts.cursor,
603
+ host,
604
+ dryRun,
605
+ verbose,
606
+ projectId,
607
+ projectIdSource,
608
+ projectLookupToken,
609
+ appDir,
610
+ cursorBaseDir: options.cursorBaseDir,
611
+ cursorTranscriptProjectDirs: options.cursorTranscriptProjectDirs,
612
+ scopeDir,
613
+ fullScan: options.fullScan,
614
+ cursorPricing,
615
+ }).then((r) => ({ ...r, tag: "cursor" }));
616
+ tasks.push(task);
617
+ }
618
+ const wrappedTasks = tasks.map((runTask) => () => runTask().catch((err) => (err instanceof Error ? err : new Error(String(err)))));
619
+ const outcomes = sharedCredentialToken
620
+ ? await (async () => {
621
+ const sequential = [];
622
+ for (const task of wrappedTasks) {
623
+ sequential.push(await task());
624
+ }
625
+ return sequential;
626
+ })()
627
+ : await Promise.all(wrappedTasks.map((task) => task()));
628
+ let sent = 0;
629
+ let failed = 0;
630
+ let skipped = 0;
631
+ let validationFailed = false;
632
+ const errorsAcc = [];
633
+ const backoffKeysForMerge = new Set();
634
+ for (const outcome of outcomes) {
635
+ if (outcome instanceof Error) {
636
+ failed++;
637
+ errorsAcc.push(outcome.message);
638
+ continue;
639
+ }
640
+ sent += outcome.sent;
641
+ failed += outcome.failed;
642
+ skipped += outcome.skipped;
643
+ if (outcome.validationFailed)
644
+ validationFailed = true;
645
+ const tok = outcome.tag === "cursor" ? credentials.accounts.cursor : credentials.accounts.claude_code;
646
+ if (tok)
647
+ backoffKeysForMerge.add(credentialStateKey(host, tok));
648
+ if (outcome.errors)
649
+ errorsAcc.push(...outcome.errors);
650
+ }
651
+ const merged = { sent, failed, skipped };
652
+ if (validationFailed)
653
+ merged.validationFailed = true;
654
+ const rl = mergeCredentialRateLimit(backoffKeysForMerge);
655
+ if (rl)
656
+ merged.rateLimitedUntil = rl;
657
+ if (errorsAcc.length > 0)
658
+ merged.errors = errorsAcc;
659
+ recordTelemetry(merged, errorsAcc, dryRun ? undefined : { appDir, host, credentials });
660
+ mcpLog.info("sync_cycle_end", { sent: merged.sent, failed: merged.failed, skipped: merged.skipped }, false);
661
+ return merged;
662
+ }
663
+ finally {
664
+ lock.release();
665
+ }
666
+ }
667
+ /** Claude-only sync helper (delegates into `syncTelemetryTools`). */
668
+ export async function syncOnce(options) {
669
+ return syncTelemetryTools({
670
+ credentials: { host: options.host, accounts: { claude_code: options.token } },
671
+ dryRun: options.dryRun,
672
+ verbose: options.verbose,
673
+ projectId: options.projectId,
674
+ pricing: options.pricing,
675
+ appDir: options.appDir,
676
+ transcriptBaseDirs: options.transcriptBaseDirs,
677
+ tools: ["claude_code"],
678
+ });
679
+ }