@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.
Files changed (50) hide show
  1. package/README.md +38 -9
  2. package/dist/auth/credentials.d.ts +7 -1
  3. package/dist/auth/credentials.js +71 -14
  4. package/dist/auth/exchange.d.ts +1 -1
  5. package/dist/auth/exchange.js +1 -1
  6. package/dist/auth/flow.d.ts +8 -1
  7. package/dist/auth/flow.js +27 -5
  8. package/dist/auth/keycloak.d.ts +1 -1
  9. package/dist/auth/keycloak.js +20 -1
  10. package/dist/cli.d.ts +5 -3
  11. package/dist/cli.js +69 -21
  12. package/dist/collect-cursor-payloads.d.ts +4 -3
  13. package/dist/collect-cursor-payloads.js +8 -5
  14. package/dist/cursor-checkpoints.d.ts +2 -2
  15. package/dist/cursor-payload-contract.d.ts +5 -5
  16. package/dist/cursor-payload-contract.js +6 -0
  17. package/dist/cursor-settings.d.ts +9 -4
  18. package/dist/cursor-settings.js +80 -10
  19. package/dist/health.d.ts +3 -1
  20. package/dist/health.js +13 -1
  21. package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
  22. package/dist/hooks/cursor-hooks-mapper.js +1 -1
  23. package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
  24. package/dist/hooks/cursor-hooks-reader.js +10 -3
  25. package/dist/install/cursor.d.ts +34 -0
  26. package/dist/install/cursor.js +193 -0
  27. package/dist/install/index.d.ts +6 -4
  28. package/dist/install/index.js +6 -1
  29. package/dist/lib/client.d.ts +7 -0
  30. package/dist/lib/client.js +17 -0
  31. package/dist/lib/config.js +7 -2
  32. package/dist/lib/project-resolver.d.ts +5 -4
  33. package/dist/lib/project-resolver.js +57 -11
  34. package/dist/lib/repo-path-safety.d.ts +35 -0
  35. package/dist/lib/repo-path-safety.js +102 -0
  36. package/dist/lib/spawn-arg-safety.d.ts +25 -0
  37. package/dist/lib/spawn-arg-safety.js +49 -0
  38. package/dist/lib/transport-security.d.ts +1 -0
  39. package/dist/lib/transport-security.js +1 -1
  40. package/dist/readers/claude.d.ts +54 -6
  41. package/dist/readers/claude.js +154 -2
  42. package/dist/readers/cursor.d.ts +10 -7
  43. package/dist/readers/cursor.js +113 -17
  44. package/dist/risk-scanner.js +7 -0
  45. package/dist/server.d.ts +20 -3
  46. package/dist/server.js +101 -67
  47. package/dist/state.js +7 -2
  48. package/dist/sync.d.ts +13 -2
  49. package/dist/sync.js +86 -58
  50. package/package.json +6 -2
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { DEFAULT_PRICING, mergePricing } from "./pricing.js";
11
11
  import { resolveCursorPricing } from "./cursor-config.js";
12
12
  import { buildHealthSnapshot, formatHealthForCli } from "./health.js";
13
13
  import { installClaudeUserMcp } from "./install/claude.js";
14
+ import { installCursorUserMcp, uninstallCursorUserMcp } from "./install/cursor.js";
14
15
  import { installHooksConfig, uninstallHooksConfig, verifyHooksConfig, FORWARDER_FILENAME } from "./hooks/hooks-config.js";
15
16
  import { evaluateTransportSecurity } from "./lib/transport-security.js";
16
17
  import { join } from "node:path";
@@ -20,9 +21,9 @@ const GLOBAL_FLAGS = new Set(["--help", "-h", "--once", "--full"]);
20
21
  const INIT_VALUE_FLAGS = new Set(["--host", "--keycloak-url", "--tool-name", "--organization-id"]);
21
22
  const INIT_BOOLEAN_FLAGS = new Set(["--force", "--hooks", "--insecure"]);
22
23
  /** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
23
- export const DB90_ORGANIZATION_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
24
- export function isValidDb90OrganizationUuid(value) {
25
- return DB90_ORGANIZATION_UUID_PATTERN.test(value.trim());
24
+ export const ORGANIZATION_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
25
+ export function isValidOrganizationUuid(value) {
26
+ return ORGANIZATION_UUID_PATTERN.test(value.trim());
26
27
  }
27
28
  function takeFlagValue(argv, name) {
28
29
  const eqForm = argv.find((a) => a.startsWith(`${name}=`));
@@ -147,7 +148,7 @@ export function parseArgs(argv) {
147
148
  if (raw === "serve") {
148
149
  return { command: "run", help, once, full: full || undefined };
149
150
  }
150
- if (raw === "uninstall-hooks" || raw === "verify-hooks") {
151
+ if (raw === "uninstall-hooks" || raw === "verify-hooks" || raw === "uninstall-cursor-mcp") {
151
152
  return { command: raw, help: false, once: false };
152
153
  }
153
154
  return { command: "help", help: true, once: false };
@@ -160,11 +161,12 @@ Usage:
160
161
  aixle-insights [command] [options]
161
162
 
162
163
  Commands:
163
- run Start the MCP stdio server (default — used by Claude Code).
164
- init Keycloak device login once, then persist DB90 ingest credentials (keychain or file).
165
- health Multi-line diagnostic (credentials, sync, log path, state files).
166
- uninstall-hooks Remove DB90 from ~/.cursor/hooks.json and restore backup (if any).
167
- verify-hooks Print hooks install status and queue depth as JSON.
164
+ run Start the MCP stdio server (default — used by Claude Code).
165
+ init Keycloak device login once, then persist DB90 ingest credentials (keychain or file).
166
+ health Multi-line diagnostic (credentials, sync, log path, state files).
167
+ uninstall-hooks Remove DB90 from ~/.cursor/hooks.json and restore backup (if any).
168
+ verify-hooks Print hooks install status and queue depth as JSON.
169
+ uninstall-cursor-mcp Remove aixle-insights from ~/.cursor/mcp.json and restore backup (if any).
168
170
 
169
171
  Options:
170
172
  --once With 'run': perform a multi-tool sync then exit (no MCP server).
@@ -176,7 +178,8 @@ init options:
176
178
  --keycloak-url <issuer> Keycloak realm issuer (default: env KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER)
177
179
  --tool-name <name> Optional: mint only \`claude_code\`, only \`cursor\`, or omit to mint BOTH.
178
180
  --organization-id <uuid> Optional: scope MCP token exchange to this org (overrides env DB90_ORGANIZATION_ID).
179
- --force Replace an existing user "aixle-insights" MCP entry in ~/.claude.json if it differs.
181
+ --force Re-run the device flow even if valid credentials already exist, and
182
+ replace an existing user "aixle-insights" MCP entry in ~/.claude.json if it differs.
180
183
  --hooks (opt-in) Install Cursor hook forwarder for per-turn model attribution.
181
184
  Requires Cursor restart. Run 'aixle-insights uninstall-hooks' to remove.
182
185
  --insecure Allow remote http:// hosts for trusted non-production test endpoints only.
@@ -191,7 +194,7 @@ Credentials:
191
194
  Note: Omitting --tool-name provisions separate ingest tokens for Claude Code + Cursor behind a single Keycloak login.
192
195
  `);
193
196
  }
194
- function defaultDb90Host() {
197
+ function defaultApiHost() {
195
198
  const v = process.env["DB90_API_URL"]?.trim();
196
199
  if (v)
197
200
  return v.replace(/\/$/, "");
@@ -211,12 +214,13 @@ export async function runInit(cliArgs, deps) {
211
214
  defaultKeycloakIssuer,
212
215
  getAppDir,
213
216
  installClaudeUserMcp,
217
+ installCursorUserMcp,
214
218
  log: console.log,
215
219
  error: console.error,
216
220
  ...deps,
217
221
  };
218
- const db90Host = (cliArgs.host ?? defaultDb90Host()).replace(/\/$/, "");
219
- const transportSecurity = evaluateTransportSecurity(db90Host, {
222
+ const apiHost = (cliArgs.host ?? defaultApiHost()).replace(/\/$/, "");
223
+ const transportSecurity = evaluateTransportSecurity(apiHost, {
220
224
  allowInsecureHttp: cliArgs.insecure === true,
221
225
  label: "DB90 API host",
222
226
  });
@@ -228,10 +232,6 @@ export async function runInit(cliArgs, deps) {
228
232
  runtime.error(`Warning: ${transportSecurity.warning}`);
229
233
  }
230
234
  const kcIssuer = (cliArgs.keycloakUrl ?? runtime.defaultKeycloakIssuer()).trim();
231
- if (!kcIssuer) {
232
- runtime.error("Error: Keycloak issuer is not configured. Pass --keycloak-url or set KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER.");
233
- return 1;
234
- }
235
235
  if (cliArgs.toolName !== undefined && !["claude_code", "cursor"].includes(cliArgs.toolName)) {
236
236
  runtime.error("Error: --tool-name must be one of: claude_code, cursor.");
237
237
  return 1;
@@ -244,12 +244,12 @@ export async function runInit(cliArgs, deps) {
244
244
  const fromFlag = cliArgs.organizationId?.trim();
245
245
  const fromEnv = process.env["DB90_ORGANIZATION_ID"]?.trim();
246
246
  const exchangeOrganizationId = fromFlag || fromEnv;
247
- if (exchangeOrganizationId && !isValidDb90OrganizationUuid(exchangeOrganizationId)) {
247
+ if (exchangeOrganizationId && !isValidOrganizationUuid(exchangeOrganizationId)) {
248
248
  runtime.error("Error: --organization-id / DB90_ORGANIZATION_ID must be a valid UUID (RFC 4122, version 1–5, variant per DB90 API).");
249
249
  return 1;
250
250
  }
251
251
  const result = await runtime.loginAndPersistCredentials({
252
- db90Host,
252
+ apiHost,
253
253
  keycloakIssuer: kcIssuer,
254
254
  tools: provisionTools.length > 1 ? provisionTools : undefined,
255
255
  toolName: provisionTools.length === 1
@@ -261,6 +261,7 @@ export async function runInit(cliArgs, deps) {
261
261
  appDir: runtime.getAppDir(),
262
262
  exchangeOrganizationId: exchangeOrganizationId || undefined,
263
263
  allowInsecureHttp: cliArgs.insecure === true,
264
+ force: cliArgs.force === true,
264
265
  onSecurityWarning: (message) => {
265
266
  runtime.error(`Warning: ${message}`);
266
267
  },
@@ -272,7 +273,12 @@ export async function runInit(cliArgs, deps) {
272
273
  runtime.error(`Auth failed: ${result.error}`);
273
274
  return 1;
274
275
  }
275
- runtime.log(`Credentials saved (organization ${result.organizationId}).`);
276
+ if ("alreadyAuthenticated" in result) {
277
+ runtime.log(`Already authenticated (organization ${result.organizationId ?? "unknown"}). Pass --force to re-authenticate.`);
278
+ }
279
+ else {
280
+ runtime.log(`Credentials saved (organization ${result.organizationId}).`);
281
+ }
276
282
  if (cliArgs.hooks) {
277
283
  const appDir = runtime.getAppDir();
278
284
  const thisFile = nodeFileURLToPath(import.meta.url);
@@ -318,6 +324,28 @@ export async function runInit(cliArgs, deps) {
318
324
  else {
319
325
  runtime.log("Skipped Claude Code MCP auto-install (--tool-name cursor only).");
320
326
  }
327
+ const shouldInstallCursor = provisionTools.includes("cursor");
328
+ if (shouldInstallCursor) {
329
+ const cursorInstallResult = runtime.installCursorUserMcp({ force: cliArgs.force === true });
330
+ switch (cursorInstallResult.kind) {
331
+ case "already-configured":
332
+ runtime.log("Cursor MCP: aixle-insights server is already configured for your user.");
333
+ break;
334
+ case "installed":
335
+ runtime.log("Cursor MCP: added aixle-insights server to ~/.cursor/mcp.json.");
336
+ break;
337
+ case "requires-force":
338
+ runtime.error(cursorInstallResult.detail);
339
+ return 1;
340
+ case "error":
341
+ runtime.error(`Cursor MCP install failed: ${cursorInstallResult.message}`);
342
+ return 1;
343
+ }
344
+ runtime.log("Restart Cursor to activate the aixle-insights MCP server.");
345
+ }
346
+ else {
347
+ runtime.log("Skipped Cursor MCP auto-install (--tool-name claude_code only).");
348
+ }
321
349
  return 0;
322
350
  }
323
351
  export async function runOnce(deps, options) {
@@ -334,6 +362,8 @@ export async function runOnce(deps, options) {
334
362
  };
335
363
  const creds = await runtime.loadCredentials();
336
364
  if (!creds || !credentialsHaveAnyToken(creds)) {
365
+ // File-only: runOnce prints a user-facing error below, so mirroring this
366
+ // structured warn would just duplicate the message on stderr.
337
367
  mcpLog.warn("credential_validation_failed", { source: "cli_once", reason: "missing_credentials" }, false);
338
368
  runtime.error("Error: no Aixle Insights credentials. Run `aixle-insights init` first (dual-tool auth is the default).");
339
369
  return 1;
@@ -348,7 +378,7 @@ export async function runOnce(deps, options) {
348
378
  let projectId = null;
349
379
  let projectIdSource = "none";
350
380
  if (lookupToken) {
351
- const resolution = await runtime.resolveProjectId(undefined, undefined, creds.host, lookupToken, false);
381
+ const resolution = await runtime.resolveProjectId(undefined, undefined, creds.host, lookupToken, false, creds.insecureHttpAllowed === true);
352
382
  projectId = resolution.projectId;
353
383
  projectIdSource = resolution.source;
354
384
  mcpLog.info("project_attribution_resolved", { project_id: resolution.projectId, source: resolution.source }, false);
@@ -408,6 +438,24 @@ async function main() {
408
438
  }
409
439
  return;
410
440
  }
441
+ case "uninstall-cursor-mcp": {
442
+ const result = uninstallCursorUserMcp();
443
+ switch (result.kind) {
444
+ case "restored":
445
+ console.log(`Cursor MCP entry removed; ~/.cursor/mcp.json restored from ${result.backupPath}.`);
446
+ break;
447
+ case "removed":
448
+ console.log("Cursor MCP entry removed from ~/.cursor/mcp.json.");
449
+ break;
450
+ case "noop":
451
+ console.log("No aixle-insights entry found in ~/.cursor/mcp.json — nothing to uninstall.");
452
+ break;
453
+ case "error":
454
+ console.error(`Cursor MCP uninstall failed: ${result.message}`);
455
+ process.exit(1);
456
+ }
457
+ return;
458
+ }
411
459
  case "verify-hooks": {
412
460
  const report = verifyHooksConfig(getAppDir());
413
461
  console.log(JSON.stringify(report, null, 2));
@@ -1,10 +1,10 @@
1
1
  import type { ProjectResolution } from "./lib/index.js";
2
2
  import type { State } from "./state.js";
3
- import { type CursorDb90Payload, type CursorTranscriptTurn, type PricingConfig } from "./readers/cursor.js";
3
+ import { type CursorPayload, type CursorTranscriptTurn, type PricingConfig } from "./readers/cursor.js";
4
4
  export interface CursorSliceGroup {
5
5
  key: string;
6
6
  label: string;
7
- payloads: CursorDb90Payload[];
7
+ payloads: CursorPayload[];
8
8
  }
9
9
  export interface PrepareCursorSliceGroupsOptions {
10
10
  stateBefore: State;
@@ -14,6 +14,7 @@ export interface PrepareCursorSliceGroupsOptions {
14
14
  host?: string;
15
15
  token?: string;
16
16
  projectLookupToken?: string | null;
17
+ allowInsecureHttp?: boolean;
17
18
  verbose?: boolean;
18
19
  cursorBaseDir?: string;
19
20
  cursorTranscriptProjectDirs?: string[];
@@ -46,7 +47,7 @@ export interface CollectLocalCursorPayloadsOptions {
46
47
  stateBefore?: State;
47
48
  }
48
49
  export interface CollectedCursorPayloads {
49
- payloads: CursorDb90Payload[];
50
+ payloads: CursorPayload[];
50
51
  counts: PreparedCursorSliceGroups["counts"];
51
52
  }
52
53
  /**
@@ -6,7 +6,7 @@ import { readEvents as readCursorEvents, readDailyStatsWithDedupe, readRecentCom
6
6
  * Read local Cursor stores, apply watermarks/dedupe, and group payloads for sync posting.
7
7
  */
8
8
  export async function prepareCursorSliceGroups(options) {
9
- const { stateBefore, fullScan = false, projectId = null, projectIdSource, host, token, projectLookupToken, verbose = false, cursorBaseDir, cursorTranscriptProjectDirs, cursorPricing = DEFAULT_CURSOR_PRICING, } = options;
9
+ const { stateBefore, fullScan = false, projectId = null, projectIdSource, host, token, projectLookupToken, allowInsecureHttp, verbose = false, cursorBaseDir, cursorTranscriptProjectDirs, cursorPricing = DEFAULT_CURSOR_PRICING, } = options;
10
10
  const useCommitHashDedup = !fullScan;
11
11
  const eventsSince = fullScan
12
12
  ? null
@@ -22,7 +22,9 @@ export async function prepareCursorSliceGroups(options) {
22
22
  console.log("[verbose][cursor] Full scan — ignoring saved watermarks and commit hash dedupe");
23
23
  }
24
24
  const baseDir = cursorBaseDir;
25
- const activeModel = readCursorActiveModel(baseDir) ?? undefined;
25
+ const activeModelResolution = readCursorActiveModel(baseDir);
26
+ const activeModel = activeModelResolution.model ?? undefined;
27
+ const modelResolution = activeModelResolution.source;
26
28
  const transcriptTurns = await readCursorTranscriptSessions(baseDir, cursorTranscriptProjectDirs, verbose);
27
29
  const rawEvents = readCursorEvents(eventsSince, baseDir, verbose);
28
30
  const { raw: dailyStatsRaw, deduped: dailyStats } = readDailyStatsWithDedupe(dailyStatsSince, baseDir, verbose);
@@ -41,7 +43,7 @@ export async function prepareCursorSliceGroups(options) {
41
43
  return known.contentHash !== turn.contentHash;
42
44
  return known.fileSize !== turn.fileSize;
43
45
  })
44
- .map((turn) => mapCursorTranscriptTurn(turn, projectIdOpt, cursorPricing, activeModel))
46
+ .map((turn) => mapCursorTranscriptTurn(turn, projectIdOpt, cursorPricing, activeModel, modelResolution))
45
47
  .sort((a, b) => a.occurred_at.localeCompare(b.occurred_at));
46
48
  const skippedTranscriptCount = transcriptTurns.length - transcriptPayloads.length;
47
49
  const transcriptModeEnabled = transcriptTurnsById.size > 0;
@@ -49,7 +51,7 @@ export async function prepareCursorSliceGroups(options) {
49
51
  .map(({ row, workspacePath }) => mapCursorEvent(row, workspacePath, projectIdOpt, cursorPricing))
50
52
  .filter((e) => e !== null);
51
53
  const allMappedFromStats = dailyStats
52
- .flatMap((entry) => mapDailyStats(entry, projectIdOpt, cursorPricing, activeModel));
54
+ .flatMap((entry) => mapDailyStats(entry, projectIdOpt, cursorPricing, activeModel, modelResolution));
53
55
  // When transcripts are present they cover the same chat activity — suppress the daily aggregates
54
56
  // to prevent double-counting. Logged at info level in sync.ts via counts.suppressedComposer.
55
57
  const mappedFromEvents = allMappedFromEvents.filter((payload) => !transcriptModeEnabled || payload.event_type !== "chat");
@@ -59,7 +61,7 @@ export async function prepareCursorSliceGroups(options) {
59
61
  allMappedFromStats.filter((p) => p.event_type === "chat").length
60
62
  : 0;
61
63
  let mappedFromCommits = recentCommitSnapshots
62
- .map((snapshot) => mapRecentCommit(snapshot, projectIdOpt, cursorPricing, activeModel))
64
+ .map((snapshot) => mapRecentCommit(snapshot, projectIdOpt, cursorPricing, activeModel, modelResolution))
63
65
  .filter((payload) => payload !== null)
64
66
  .sort((a, b) => a.occurred_at.localeCompare(b.occurred_at));
65
67
  if (useCommitHashDedup) {
@@ -72,6 +74,7 @@ export async function prepareCursorSliceGroups(options) {
72
74
  projectIdSource,
73
75
  host,
74
76
  token: lookupToken,
77
+ allowInsecureHttp,
75
78
  verbose,
76
79
  });
77
80
  }
@@ -1,5 +1,5 @@
1
1
  import type { State } from "./state.js";
2
- import type { CursorDb90Payload } from "./readers/cursor.js";
2
+ import type { CursorPayload } from "./readers/cursor.js";
3
3
  /** Cursor SQLite watermark checkpoints — never collide with Claude `claude_code:*` session keys. */
4
4
  export declare const CURSOR_WATERMARK_KEY: "cursor:watermark";
5
5
  export declare const CURSOR_EVENTS_WATERMARK_KEY: "cursor:events_watermark";
@@ -9,4 +9,4 @@ export declare const CURSOR_TRANSCRIPT_TURN_PREFIX: "cursor:transcript_turn:";
9
9
  export declare function cursorTranscriptTurnStateKey(turnId: string): string;
10
10
  export declare function cursorWatermarkDate(state: Pick<State, "sessions">, ...keys: string[]): Date | null;
11
11
  /** Skip recent-commit payloads whose hash was already successfully POSTed. */
12
- export declare function filterRecentCommitsByHashDedup(payloads: CursorDb90Payload[], lastRecentCommitHashes?: string[]): CursorDb90Payload[];
12
+ export declare function filterRecentCommitsByHashDedup(payloads: CursorPayload[], lastRecentCommitHashes?: string[]): CursorPayload[];
@@ -1,4 +1,4 @@
1
- import type { CursorDb90Payload } from "./readers/cursor.js";
1
+ import type { CursorPayload } from "./readers/cursor.js";
2
2
  /** Ingest paths for Cursor payloads emitted by telemetry-mcp. */
3
3
  export type CursorIngestPath = "daily_tab" | "daily_composer" | "legacy_request" | "recent_commit" | "mcp_transcript" | "cursor_hook";
4
4
  export interface PayloadValidationResult {
@@ -6,12 +6,12 @@ export interface PayloadValidationResult {
6
6
  errors: string[];
7
7
  path: CursorIngestPath | "unknown";
8
8
  }
9
- export declare function inferIngestPath(payload: CursorDb90Payload): CursorIngestPath | "unknown";
10
- export declare function validateCursorPayload(payload: CursorDb90Payload): PayloadValidationResult;
9
+ export declare function inferIngestPath(payload: CursorPayload): CursorIngestPath | "unknown";
10
+ export declare function validateCursorPayload(payload: CursorPayload): PayloadValidationResult;
11
11
  export interface DryRunMatrixRow {
12
12
  path: CursorIngestPath | "unknown";
13
13
  count: number;
14
14
  sample_occurred_at: string | null;
15
15
  }
16
- export declare function summarizeDryRunMatrix(payloads: CursorDb90Payload[]): DryRunMatrixRow[];
17
- export declare function printCursorDryRunValidationReport(payloads: CursorDb90Payload[]): boolean;
16
+ export declare function summarizeDryRunMatrix(payloads: CursorPayload[]): DryRunMatrixRow[];
17
+ export declare function printCursorDryRunValidationReport(payloads: CursorPayload[]): boolean;
@@ -19,6 +19,7 @@ const METADATA_BASE_KEYS = new Set([
19
19
  "cost_model",
20
20
  "scannable",
21
21
  "risk_level",
22
+ "model_resolution",
22
23
  ]);
23
24
  const METADATA_COMMIT_KEYS = new Set([
24
25
  ...METADATA_BASE_KEYS,
@@ -42,6 +43,7 @@ const METADATA_TRANSCRIPT_KEYS = new Set([
42
43
  "composer_name",
43
44
  "prompt_text",
44
45
  "assistant_text",
46
+ "model_resolution",
45
47
  ]);
46
48
  const METADATA_HOOK_KEYS = new Set([
47
49
  "cursor_session_id",
@@ -136,6 +138,10 @@ export function validateCursorPayload(payload) {
136
138
  if (typeof meta.workspace !== "string" || meta.workspace.length === 0) {
137
139
  errors.push("metadata.workspace must be a non-empty string");
138
140
  }
141
+ if (meta.model_resolution !== undefined &&
142
+ !["settings_json", "state_vscdb", "unresolved"].includes(meta.model_resolution)) {
143
+ errors.push('metadata.model_resolution must be "settings_json", "state_vscdb", or "unresolved" when present');
144
+ }
139
145
  if (path !== "mcp_transcript" && path !== "cursor_hook") {
140
146
  if (meta.workspace_scope !== "global" && meta.workspace_scope !== "workspace") {
141
147
  errors.push('metadata.workspace_scope must be "global" or "workspace"');
@@ -1,6 +1,11 @@
1
+ export type CursorModelResolutionSource = "settings_json" | "state_vscdb" | "unresolved";
2
+ export interface CursorActiveModelResolution {
3
+ model: string | null;
4
+ source: CursorModelResolutionSource;
5
+ }
1
6
  /**
2
- * Best-effort: read active model name from Cursor's settings.json.
3
- * Returns null on any error (file absent, unreadable, no matching key).
4
- * Never throws.
7
+ * Resolves Cursor's active model, chaining settings.json (pre-1.6 location) then
8
+ * state.vscdb (1.6+ location). Reports which source supplied the model, or "unresolved"
9
+ * when neither has it, so downstream payload metadata can record where the tool looked.
5
10
  */
6
- export declare function readCursorActiveModel(baseDir?: string): string | null;
11
+ export declare function readCursorActiveModel(baseDir?: string): CursorActiveModelResolution;
@@ -1,22 +1,15 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { cursorUserDir } from "./readers/cursor.js";
4
- // Keys are tried in specificity order — Cursor-namespaced keys first, then
5
- // generic fallbacks. "model" is last to avoid capturing unrelated workspace
6
- // settings that happen to have a "model" key.
4
+ import { openCursorSqliteReadonly } from "./readers/cursor-sqlite.js";
7
5
  const SETTINGS_MODEL_KEYS = [
8
6
  "cursor.aiModel",
9
7
  "aiModel",
10
8
  "cursor.general.preferredModel",
11
9
  "model",
12
10
  ];
13
- /**
14
- * Best-effort: read active model name from Cursor's settings.json.
15
- * Returns null on any error (file absent, unreadable, no matching key).
16
- * Never throws.
17
- */
18
- export function readCursorActiveModel(baseDir) {
19
- const settingsPath = join(baseDir ?? cursorUserDir(), "settings.json");
11
+ function readModelFromSettingsJson(dir) {
12
+ const settingsPath = join(dir, "settings.json");
20
13
  try {
21
14
  if (!existsSync(settingsPath))
22
15
  return null;
@@ -36,3 +29,80 @@ export function readCursorActiveModel(baseDir) {
36
29
  return null;
37
30
  }
38
31
  }
32
+ function safeParseJson(raw) {
33
+ try {
34
+ return JSON.parse(raw);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function isNonEmptyString(v) {
41
+ return typeof v === "string" && v.trim().length > 0;
42
+ }
43
+ /**
44
+ * Reads the active model from Cursor's global state.vscdb (Cursor 1.6+), where settings.json
45
+ * no longer carries it. The model lives in the cursorDiskKV table (verified against a real
46
+ * Cursor install, 2026-07-10 — NOT ItemTable.aiSettings/featureModelConfigs, which don't exist
47
+ * on current Cursor versions), one row per conversation keyed composerData:<composerId>. The
48
+ * table is UNIQUE ON CONFLICT REPLACE, so every update to a composer re-inserts it with a fresh
49
+ * (higher) rowid — ORDER BY rowid DESC LIMIT 1 gives the most recently touched composer without
50
+ * needing to parse and rank every row's timestamp. Opens the DB only via openCursorSqliteReadonly
51
+ * (read-only, root-contained) — never a raw new Database() call.
52
+ */
53
+ function readCursorActiveModelFromStateDb(baseDir) {
54
+ const dbPath = join(baseDir, "globalStorage", "state.vscdb");
55
+ const opened = openCursorSqliteReadonly(dbPath, { rootDir: baseDir });
56
+ if (!opened.ok)
57
+ return null;
58
+ const { db } = opened;
59
+ try {
60
+ const hasTable = db
61
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
62
+ .get();
63
+ if (!hasTable)
64
+ return null;
65
+ const row = db
66
+ .prepare("SELECT value FROM cursorDiskKV WHERE key LIKE 'composerData:%' ORDER BY rowid DESC LIMIT 1")
67
+ .get();
68
+ if (!row)
69
+ return null;
70
+ const parsed = safeParseJson(row.value);
71
+ if (typeof parsed !== "object" || parsed === null)
72
+ return null;
73
+ const modelConfig = parsed.modelConfig;
74
+ if (typeof modelConfig !== "object" || modelConfig === null)
75
+ return null;
76
+ const mc = modelConfig;
77
+ if (isNonEmptyString(mc.modelName))
78
+ return mc.modelName.trim();
79
+ const selectedModels = mc.selectedModels;
80
+ if (Array.isArray(selectedModels) && selectedModels.length > 0) {
81
+ const first = selectedModels[0];
82
+ if (isNonEmptyString(first?.modelId))
83
+ return first.modelId.trim();
84
+ }
85
+ return null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ finally {
91
+ db.close();
92
+ }
93
+ }
94
+ /**
95
+ * Resolves Cursor's active model, chaining settings.json (pre-1.6 location) then
96
+ * state.vscdb (1.6+ location). Reports which source supplied the model, or "unresolved"
97
+ * when neither has it, so downstream payload metadata can record where the tool looked.
98
+ */
99
+ export function readCursorActiveModel(baseDir) {
100
+ const dir = baseDir ?? cursorUserDir();
101
+ const fromSettings = readModelFromSettingsJson(dir);
102
+ if (fromSettings)
103
+ return { model: fromSettings, source: "settings_json" };
104
+ const fromStateDb = readCursorActiveModelFromStateDb(dir);
105
+ if (fromStateDb)
106
+ return { model: fromStateDb, source: "state_vscdb" };
107
+ return { model: null, source: "unresolved" };
108
+ }
package/dist/health.d.ts CHANGED
@@ -16,6 +16,8 @@ export interface HealthSnapshot {
16
16
  hooks_queue_depth: number;
17
17
  /** Best-effort merge of credential-scoped `mcp_operator` (latest `last_sync_at`). */
18
18
  persisted: McpOperatorState | null;
19
+ /** True only when buildHealthSnapshot caught an unexpected error building the snapshot. */
20
+ snapshot_failed?: boolean;
19
21
  /** In-process telemetry (same fields as `getSyncTelemetry`). */
20
22
  process: {
21
23
  last_sync_at: string | null;
@@ -26,6 +28,6 @@ export interface HealthSnapshot {
26
28
  /** Pick the freshest persisted operator block by `last_sync_at` lexicographic (ISO-safe). */
27
29
  export declare function mergePersistedOperators(snapshots: McpOperatorState[]): McpOperatorState | null;
28
30
  export declare function buildHealthSnapshot(): Promise<HealthSnapshot>;
29
- /** MCP `db90_status` JSON — single source shared with CLI health. */
31
+ /** MCP `aixle_insights_status` JSON — single source shared with CLI health. */
30
32
  export declare function healthSnapshotToStatusPayload(snapshot: HealthSnapshot): Record<string, unknown>;
31
33
  export declare function formatHealthForCli(snapshot: HealthSnapshot): string;
package/dist/health.js CHANGED
@@ -122,6 +122,7 @@ export async function buildHealthSnapshot() {
122
122
  hooks_installed: hooksReport.hooks_json_installed,
123
123
  hooks_queue_depth: hooksReport.queue_depth,
124
124
  persisted: null,
125
+ snapshot_failed: true,
125
126
  process: {
126
127
  last_sync_at: telemetry.lastSyncAt,
127
128
  last_result: telemetry.lastResult,
@@ -133,18 +134,25 @@ export async function buildHealthSnapshot() {
133
134
  };
134
135
  }
135
136
  }
136
- /** MCP `db90_status` JSON — single source shared with CLI health. */
137
+ /** MCP `aixle_insights_status` JSON — single source shared with CLI health. */
137
138
  export function healthSnapshotToStatusPayload(snapshot) {
138
139
  const proc = snapshot.process;
139
140
  const pers = snapshot.persisted;
140
141
  const lastSyncAt = proc.last_sync_at ?? pers?.last_sync_at ?? null;
141
142
  const lastResult = proc.last_result ?? snapshotToResult(pers?.last_result ?? null);
142
143
  const errors = mergeErrors(proc.recent_errors, pers?.recent_errors ?? []);
144
+ const needsInit = !snapshot.authenticated && snapshot.snapshot_failed !== true;
145
+ const onboardingMessage = needsInit
146
+ ? "aixle-insights is installed but not connected. Run `aixle-insights init` to start tracking — " +
147
+ "activity since install is not lost; the first sync after init backfills all of it."
148
+ : null;
143
149
  return {
144
150
  authenticated: snapshot.authenticated,
145
151
  configured: snapshot.configured,
146
152
  host: snapshot.host,
147
153
  ingest_tools: snapshot.ingest_tools,
154
+ needs_init: needsInit,
155
+ onboarding_message: onboardingMessage,
148
156
  last_sync_at: lastSyncAt,
149
157
  last_result: lastResult,
150
158
  sessions_synced: lastResult?.sent ?? 0,
@@ -191,5 +199,9 @@ export function formatHealthForCli(snapshot) {
191
199
  else {
192
200
  lines.push(" (none)");
193
201
  }
202
+ if (typeof sta["onboarding_message"] === "string") {
203
+ lines.push("");
204
+ lines.push(sta["onboarding_message"]);
205
+ }
194
206
  return lines.join("\n");
195
207
  }
@@ -1,5 +1,5 @@
1
1
  import type { HookLogEvent } from "./hooks-config.js";
2
- import type { CursorDb90Payload } from "../readers/cursor.js";
2
+ import type { CursorPayload } from "../readers/cursor.js";
3
3
  export { HookLogEvent };
4
4
  /** State key prefix for hook event dedup in state.sessions. */
5
5
  export declare const CURSOR_HOOK_STATE_PREFIX: "cursor:hook:";
@@ -16,7 +16,7 @@ export declare function shouldIngestHookEvent(event: HookLogEvent, verbose?: boo
16
16
  /** Warn-only version gate — Cursor builds can have unusual version strings. */
17
17
  export declare function warnOnCursorVersion(event: HookLogEvent, verbose: boolean): void;
18
18
  /**
19
- * Map a validated hook event to the CursorDb90Payload contract.
19
+ * Map a validated hook event to the CursorPayload contract.
20
20
  * Call shouldIngestHookEvent() before this — it does not re-validate.
21
21
  */
22
- export declare function mapHookEventToPayload(event: HookLogEvent, projectId?: string | null): CursorDb90Payload;
22
+ export declare function mapHookEventToPayload(event: HookLogEvent, projectId?: string | null): CursorPayload;
@@ -47,7 +47,7 @@ export function warnOnCursorVersion(event, verbose) {
47
47
  }
48
48
  }
49
49
  /**
50
- * Map a validated hook event to the CursorDb90Payload contract.
50
+ * Map a validated hook event to the CursorPayload contract.
51
51
  * Call shouldIngestHookEvent() before this — it does not re-validate.
52
52
  */
53
53
  export function mapHookEventToPayload(event, projectId) {
@@ -8,6 +8,8 @@ export interface ProcessHooksQueueParams {
8
8
  state: State;
9
9
  host: string;
10
10
  token: string;
11
+ /** Mirrors StoredCredentials.insecureHttpAllowed — set when `init --insecure` was used for this host. */
12
+ allowInsecureHttp?: boolean;
11
13
  /** Called when a 429 is received. */
12
14
  on429: (retryAfter: number, quotaExceeded: boolean) => void;
13
15
  /** If true, skip events already in state.sessions. */
@@ -6,9 +6,16 @@ import { markSessionSent } from "../state.js";
6
6
  import { postEvent } from "../client.js";
7
7
  import { mcpLog } from "../log.js";
8
8
  import { shouldIngestHookEvent, mapHookEventToPayload, hookDedupeKey, warnOnCursorVersion, CURSOR_HOOK_STATE_PREFIX, } from "./cursor-hooks-mapper.js";
9
+ import { isRepoPathWithinRoot, normalizeRepoPathCandidate } from "../lib/repo-path-safety.js";
9
10
  export { CURSOR_HOOK_STATE_PREFIX };
11
+ /**
12
+ * `workspace` is `workspace_roots[0]` from the on-disk hooks queue — an
13
+ * arbitrary JSON string. A plain prefix match would accept
14
+ * `<scopeDir>/../../elsewhere` (DB90DV-547).
15
+ */
10
16
  function isUnderScopeDir(workspace, scopeDir) {
11
- return workspace === scopeDir || workspace.startsWith(scopeDir + "/");
17
+ const normalized = normalizeRepoPathCandidate(workspace);
18
+ return normalized !== null && isRepoPathWithinRoot(normalized, scopeDir);
12
19
  }
13
20
  /**
14
21
  * The forwarder redacts the home directory to "~" in workspace_roots for
@@ -47,7 +54,7 @@ function rewriteQueueKeepingLines(queuePath, allLines, keepIndices) {
47
54
  * Partial failure: lines that succeeded are removed; lines that failed stay.
48
55
  */
49
56
  export async function processHooksQueue(params) {
50
- const { queuePath, scopeDir, host, token, on429, skipSeen = true, resolveProjectId, verbose = false, } = params;
57
+ const { queuePath, scopeDir, host, token, allowInsecureHttp = false, on429, skipSeen = true, resolveProjectId, verbose = false, } = params;
51
58
  if (!existsSync(queuePath)) {
52
59
  return { sent: 0, failed: 0, skipped: 0, state: params.state };
53
60
  }
@@ -97,7 +104,7 @@ export async function processHooksQueue(params) {
97
104
  projectId = await resolveProjectId(workspace);
98
105
  }
99
106
  const payload = mapHookEventToPayload(event, projectId);
100
- const ok = await postEvent(payload, host, token, { on429 });
107
+ const ok = await postEvent(payload, host, token, { on429, allowInsecureHttp });
101
108
  if (ok) {
102
109
  totalSent++;
103
110
  stateMut = markSessionSent(stateMut, dedupeKey, 0);
@@ -0,0 +1,34 @@
1
+ import { type InstallResult } from "./claude.js";
2
+ export interface InstallCursorUserMcpOptions {
3
+ /** Tests: full path to the Cursor MCP config file (default ~/.cursor/mcp.json). */
4
+ cursorConfigPath?: string;
5
+ force?: boolean;
6
+ }
7
+ export type UninstallResult = {
8
+ kind: "restored";
9
+ backupPath: string;
10
+ } | {
11
+ kind: "removed";
12
+ } | {
13
+ kind: "noop";
14
+ } | {
15
+ kind: "error";
16
+ message: string;
17
+ };
18
+ export declare function defaultCursorUserConfigPath(): string;
19
+ /**
20
+ * Merges top-level `mcpServers.aixle-insights` into ~/.cursor/mcp.json (or overridden path).
21
+ * Preserves all other keys and MCP server entries. Backs up the existing file
22
+ * (once) before the first write, and removes legacy "db90"/"insights" keys so
23
+ * Cursor does not spawn duplicate aixle-insights servers.
24
+ */
25
+ export declare function installCursorUserMcp(options?: InstallCursorUserMcpOptions): InstallResult;
26
+ /**
27
+ * Removes the aixle-insights entry from ~/.cursor/mcp.json without disturbing any
28
+ * other server entry — including ones the user or Cursor added after install.
29
+ * Works on the CURRENT file (never a full-file revert to the backup). If a
30
+ * pre-install backup exists, the user's ORIGINAL aixle-insights entry (if any)
31
+ * is restored from it; otherwise our key is simply removed. The backup file is
32
+ * cleaned up either way.
33
+ */
34
+ export declare function uninstallCursorUserMcp(options?: InstallCursorUserMcpOptions): UninstallResult;