@aixle/insights 0.1.1 → 0.2.1-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 (53) hide show
  1. package/README.md +154 -3
  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 +10 -1
  7. package/dist/auth/flow.js +38 -5
  8. package/dist/auth/keycloak.d.ts +1 -1
  9. package/dist/auth/keycloak.js +20 -1
  10. package/dist/cli.d.ts +7 -3
  11. package/dist/cli.js +87 -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 +7 -0
  17. package/dist/cursor-settings.d.ts +9 -4
  18. package/dist/cursor-settings.js +80 -10
  19. package/dist/cursor-store-audit.d.ts +2 -2
  20. package/dist/cursor-store-audit.js +22 -11
  21. package/dist/daily-stats-versions.d.ts +3 -1
  22. package/dist/daily-stats-versions.js +6 -7
  23. package/dist/health.d.ts +3 -1
  24. package/dist/health.js +13 -1
  25. package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
  26. package/dist/hooks/cursor-hooks-mapper.js +1 -1
  27. package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
  28. package/dist/hooks/cursor-hooks-reader.js +2 -2
  29. package/dist/install/cursor.d.ts +34 -0
  30. package/dist/install/cursor.js +193 -0
  31. package/dist/install/index.d.ts +6 -4
  32. package/dist/install/index.js +6 -1
  33. package/dist/lib/client.d.ts +7 -0
  34. package/dist/lib/client.js +17 -0
  35. package/dist/lib/config.js +7 -2
  36. package/dist/lib/project-resolver.d.ts +5 -4
  37. package/dist/lib/project-resolver.js +20 -8
  38. package/dist/lib/transport-security.d.ts +13 -0
  39. package/dist/lib/transport-security.js +47 -0
  40. package/dist/pricing.d.ts +9 -1
  41. package/dist/pricing.js +39 -8
  42. package/dist/readers/claude.d.ts +54 -6
  43. package/dist/readers/claude.js +158 -6
  44. package/dist/readers/cursor-sqlite.d.ts +23 -0
  45. package/dist/readers/cursor-sqlite.js +68 -0
  46. package/dist/readers/cursor.d.ts +11 -8
  47. package/dist/readers/cursor.js +149 -31
  48. package/dist/server.d.ts +20 -3
  49. package/dist/server.js +101 -67
  50. package/dist/state.js +7 -2
  51. package/dist/sync.d.ts +4 -2
  52. package/dist/sync.js +61 -46
  53. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -11,17 +11,19 @@ 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";
16
+ import { evaluateTransportSecurity } from "./lib/transport-security.js";
15
17
  import { join } from "node:path";
16
18
  import { fileURLToPath as nodeFileURLToPath } from "node:url";
17
19
  import { mcpLog } from "./log.js";
18
20
  const GLOBAL_FLAGS = new Set(["--help", "-h", "--once", "--full"]);
19
21
  const INIT_VALUE_FLAGS = new Set(["--host", "--keycloak-url", "--tool-name", "--organization-id"]);
20
- const INIT_BOOLEAN_FLAGS = new Set(["--force", "--hooks"]);
22
+ const INIT_BOOLEAN_FLAGS = new Set(["--force", "--hooks", "--insecure"]);
21
23
  /** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
22
- 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;
23
- export function isValidDb90OrganizationUuid(value) {
24
- 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());
25
27
  }
26
28
  function takeFlagValue(argv, name) {
27
29
  const eqForm = argv.find((a) => a.startsWith(`${name}=`));
@@ -112,7 +114,8 @@ export function parseArgs(argv) {
112
114
  const organizationId = takeFlagValue(args, "--organization-id");
113
115
  const force = args.includes("--force");
114
116
  const hooks = args.includes("--hooks");
115
- return { command: "init", help, once: false, host, keycloakUrl, toolName, organizationId, force, hooks };
117
+ const insecure = args.includes("--insecure");
118
+ return { command: "init", help, once: false, host, keycloakUrl, toolName, organizationId, force, hooks, insecure };
116
119
  }
117
120
  const nonInitBad = args.filter((a) => {
118
121
  if (!a.startsWith("--") && a !== "-h")
@@ -145,7 +148,7 @@ export function parseArgs(argv) {
145
148
  if (raw === "serve") {
146
149
  return { command: "run", help, once, full: full || undefined };
147
150
  }
148
- if (raw === "uninstall-hooks" || raw === "verify-hooks") {
151
+ if (raw === "uninstall-hooks" || raw === "verify-hooks" || raw === "uninstall-cursor-mcp") {
149
152
  return { command: raw, help: false, once: false };
150
153
  }
151
154
  return { command: "help", help: true, once: false };
@@ -158,11 +161,12 @@ Usage:
158
161
  aixle-insights [command] [options]
159
162
 
160
163
  Commands:
161
- run Start the MCP stdio server (default — used by Claude Code).
162
- init Keycloak device login once, then persist DB90 ingest credentials (keychain or file).
163
- health Multi-line diagnostic (credentials, sync, log path, state files).
164
- uninstall-hooks Remove DB90 from ~/.cursor/hooks.json and restore backup (if any).
165
- 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).
166
170
 
167
171
  Options:
168
172
  --once With 'run': perform a multi-tool sync then exit (no MCP server).
@@ -174,9 +178,11 @@ init options:
174
178
  --keycloak-url <issuer> Keycloak realm issuer (default: env KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER)
175
179
  --tool-name <name> Optional: mint only \`claude_code\`, only \`cursor\`, or omit to mint BOTH.
176
180
  --organization-id <uuid> Optional: scope MCP token exchange to this org (overrides env DB90_ORGANIZATION_ID).
177
- --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.
178
183
  --hooks (opt-in) Install Cursor hook forwarder for per-turn model attribution.
179
184
  Requires Cursor restart. Run 'aixle-insights uninstall-hooks' to remove.
185
+ --insecure Allow remote http:// hosts for trusted non-production test endpoints only.
180
186
 
181
187
  Multi-org:
182
188
  Set \`DB90_ORGANIZATION_ID\` to a UUID, or pass \`--organization-id\` on \`init\`, so ingest tokens are minted for that membership instead of the default (oldest) org.
@@ -188,7 +194,7 @@ Credentials:
188
194
  Note: Omitting --tool-name provisions separate ingest tokens for Claude Code + Cursor behind a single Keycloak login.
189
195
  `);
190
196
  }
191
- function defaultDb90Host() {
197
+ function defaultApiHost() {
192
198
  const v = process.env["DB90_API_URL"]?.trim();
193
199
  if (v)
194
200
  return v.replace(/\/$/, "");
@@ -208,16 +214,24 @@ export async function runInit(cliArgs, deps) {
208
214
  defaultKeycloakIssuer,
209
215
  getAppDir,
210
216
  installClaudeUserMcp,
217
+ installCursorUserMcp,
211
218
  log: console.log,
212
219
  error: console.error,
213
220
  ...deps,
214
221
  };
215
- const db90Host = (cliArgs.host ?? defaultDb90Host()).replace(/\/$/, "");
216
- const kcIssuer = (cliArgs.keycloakUrl ?? runtime.defaultKeycloakIssuer()).trim();
217
- if (!kcIssuer) {
218
- runtime.error("Error: Keycloak issuer is not configured. Pass --keycloak-url or set KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER.");
222
+ const apiHost = (cliArgs.host ?? defaultApiHost()).replace(/\/$/, "");
223
+ const transportSecurity = evaluateTransportSecurity(apiHost, {
224
+ allowInsecureHttp: cliArgs.insecure === true,
225
+ label: "DB90 API host",
226
+ });
227
+ if (!transportSecurity.ok) {
228
+ runtime.error(`Error: ${transportSecurity.error}`);
219
229
  return 1;
220
230
  }
231
+ if (transportSecurity.warning) {
232
+ runtime.error(`Warning: ${transportSecurity.warning}`);
233
+ }
234
+ const kcIssuer = (cliArgs.keycloakUrl ?? runtime.defaultKeycloakIssuer()).trim();
221
235
  if (cliArgs.toolName !== undefined && !["claude_code", "cursor"].includes(cliArgs.toolName)) {
222
236
  runtime.error("Error: --tool-name must be one of: claude_code, cursor.");
223
237
  return 1;
@@ -230,12 +244,12 @@ export async function runInit(cliArgs, deps) {
230
244
  const fromFlag = cliArgs.organizationId?.trim();
231
245
  const fromEnv = process.env["DB90_ORGANIZATION_ID"]?.trim();
232
246
  const exchangeOrganizationId = fromFlag || fromEnv;
233
- if (exchangeOrganizationId && !isValidDb90OrganizationUuid(exchangeOrganizationId)) {
247
+ if (exchangeOrganizationId && !isValidOrganizationUuid(exchangeOrganizationId)) {
234
248
  runtime.error("Error: --organization-id / DB90_ORGANIZATION_ID must be a valid UUID (RFC 4122, version 1–5, variant per DB90 API).");
235
249
  return 1;
236
250
  }
237
251
  const result = await runtime.loginAndPersistCredentials({
238
- db90Host,
252
+ apiHost,
239
253
  keycloakIssuer: kcIssuer,
240
254
  tools: provisionTools.length > 1 ? provisionTools : undefined,
241
255
  toolName: provisionTools.length === 1
@@ -246,6 +260,11 @@ export async function runInit(cliArgs, deps) {
246
260
  deviceLabel: "aixle-insights CLI init",
247
261
  appDir: runtime.getAppDir(),
248
262
  exchangeOrganizationId: exchangeOrganizationId || undefined,
263
+ allowInsecureHttp: cliArgs.insecure === true,
264
+ force: cliArgs.force === true,
265
+ onSecurityWarning: (message) => {
266
+ runtime.error(`Warning: ${message}`);
267
+ },
249
268
  onVisitInstructions: (uri, code) => {
250
269
  runtime.log(`Visit ${uri} and enter code ${code}`);
251
270
  },
@@ -254,7 +273,12 @@ export async function runInit(cliArgs, deps) {
254
273
  runtime.error(`Auth failed: ${result.error}`);
255
274
  return 1;
256
275
  }
257
- 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
+ }
258
282
  if (cliArgs.hooks) {
259
283
  const appDir = runtime.getAppDir();
260
284
  const thisFile = nodeFileURLToPath(import.meta.url);
@@ -300,6 +324,28 @@ export async function runInit(cliArgs, deps) {
300
324
  else {
301
325
  runtime.log("Skipped Claude Code MCP auto-install (--tool-name cursor only).");
302
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
+ }
303
349
  return 0;
304
350
  }
305
351
  export async function runOnce(deps, options) {
@@ -316,6 +362,8 @@ export async function runOnce(deps, options) {
316
362
  };
317
363
  const creds = await runtime.loadCredentials();
318
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.
319
367
  mcpLog.warn("credential_validation_failed", { source: "cli_once", reason: "missing_credentials" }, false);
320
368
  runtime.error("Error: no Aixle Insights credentials. Run `aixle-insights init` first (dual-tool auth is the default).");
321
369
  return 1;
@@ -330,7 +378,7 @@ export async function runOnce(deps, options) {
330
378
  let projectId = null;
331
379
  let projectIdSource = "none";
332
380
  if (lookupToken) {
333
- 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);
334
382
  projectId = resolution.projectId;
335
383
  projectIdSource = resolution.source;
336
384
  mcpLog.info("project_attribution_resolved", { project_id: resolution.projectId, source: resolution.source }, false);
@@ -390,6 +438,24 @@ async function main() {
390
438
  }
391
439
  return;
392
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
+ }
393
459
  case "verify-hooks": {
394
460
  const report = verifyHooksConfig(getAppDir());
395
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;
@@ -11,6 +11,7 @@ const TOP_LEVEL_KEYS = new Set([
11
11
  "metadata",
12
12
  ]);
13
13
  const METADATA_BASE_KEYS = new Set([
14
+ "session_id",
14
15
  "cursor_session_id",
15
16
  "workspace",
16
17
  "workspace_scope",
@@ -18,6 +19,7 @@ const METADATA_BASE_KEYS = new Set([
18
19
  "cost_model",
19
20
  "scannable",
20
21
  "risk_level",
22
+ "model_resolution",
21
23
  ]);
22
24
  const METADATA_COMMIT_KEYS = new Set([
23
25
  ...METADATA_BASE_KEYS,
@@ -41,6 +43,7 @@ const METADATA_TRANSCRIPT_KEYS = new Set([
41
43
  "composer_name",
42
44
  "prompt_text",
43
45
  "assistant_text",
46
+ "model_resolution",
44
47
  ]);
45
48
  const METADATA_HOOK_KEYS = new Set([
46
49
  "cursor_session_id",
@@ -135,6 +138,10 @@ export function validateCursorPayload(payload) {
135
138
  if (typeof meta.workspace !== "string" || meta.workspace.length === 0) {
136
139
  errors.push("metadata.workspace must be a non-empty string");
137
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
+ }
138
145
  if (path !== "mcp_transcript" && path !== "cursor_hook") {
139
146
  if (meta.workspace_scope !== "global" && meta.workspace_scope !== "workspace") {
140
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
+ }
@@ -39,8 +39,8 @@ export interface CursorStoreAuditReport {
39
39
  daily_stats_version_note: string;
40
40
  }
41
41
  export declare function redactCursorPath(p: string): string;
42
- export declare function auditStateVscdbFile(dbPath: string): StateVscdbAuditEntry;
43
- export declare function auditLegacyCursorDbFile(dbPath: string): LegacyDbAuditEntry;
42
+ export declare function auditStateVscdbFile(dbPath: string, rootDir?: string): StateVscdbAuditEntry;
43
+ export declare function auditLegacyCursorDbFile(dbPath: string, rootDir?: string): LegacyDbAuditEntry;
44
44
  /**
45
45
  * CUR-V07 — inventory local Cursor stores (state.vscdb vs legacy cursor.db).
46
46
  * Does not read disk outside Cursor's User directory unless `baseDir` is passed (tests).
@@ -1,9 +1,9 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import Database from "better-sqlite3";
5
4
  import { discoverDailyStatsVersionsInDb, mergeDailyStatsVersionDiscoveries, } from "./daily-stats-versions.js";
6
5
  import { cursorUserDir, findCursorDbs, findStateVscDbs, isGlobalStateDbPath, probeCursorGlobalStateDb, } from "./readers/cursor.js";
6
+ import { openCursorSqliteReadonly, resolveCursorSqlitePath } from "./readers/cursor-sqlite.js";
7
7
  const LEGACY_TABLE = "CursorRequestFeedback";
8
8
  const STATE_TABLE = "ItemTable";
9
9
  const RECENT_COMMIT_KEY = "aiCodeTracking.recentCommit";
@@ -16,7 +16,7 @@ function tableExists(db, tableName) {
16
16
  .get(tableName);
17
17
  return row !== undefined;
18
18
  }
19
- export function auditStateVscdbFile(dbPath) {
19
+ export function auditStateVscdbFile(dbPath, rootDir) {
20
20
  const entry = {
21
21
  db_path_redacted: redactCursorPath(dbPath),
22
22
  exists: existsSync(dbPath),
@@ -27,7 +27,10 @@ export function auditStateVscdbFile(dbPath) {
27
27
  return entry;
28
28
  let db = null;
29
29
  try {
30
- db = new Database(dbPath, { readonly: true });
30
+ const opened = openCursorSqliteReadonly(dbPath, { rootDir });
31
+ if (!opened.ok)
32
+ return entry;
33
+ db = opened.db;
31
34
  const ds = db
32
35
  .prepare(`SELECT count(*) AS c FROM ${STATE_TABLE} WHERE key LIKE 'aiCodeTracking.dailyStats%'`)
33
36
  .get();
@@ -45,18 +48,25 @@ export function auditStateVscdbFile(dbPath) {
45
48
  }
46
49
  return entry;
47
50
  }
48
- export function auditLegacyCursorDbFile(dbPath) {
51
+ export function auditLegacyCursorDbFile(dbPath, rootDir) {
49
52
  const entry = {
50
53
  db_path_redacted: redactCursorPath(dbPath),
51
- file_bytes: existsSync(dbPath) ? statSync(dbPath).size : 0,
54
+ file_bytes: 0,
52
55
  has_feedback_table: false,
53
56
  feedback_row_count: 0,
54
57
  };
55
58
  if (!existsSync(dbPath))
56
59
  return entry;
60
+ const resolved = resolveCursorSqlitePath(dbPath, { rootDir });
61
+ if (!resolved.ok)
62
+ return entry;
63
+ entry.file_bytes = statSync(resolved.path).size;
57
64
  let db = null;
58
65
  try {
59
- db = new Database(dbPath, { readonly: true });
66
+ const opened = openCursorSqliteReadonly(resolved.path, { rootDir });
67
+ if (!opened.ok)
68
+ return entry;
69
+ db = opened.db;
60
70
  if (!tableExists(db, LEGACY_TABLE))
61
71
  return entry;
62
72
  entry.has_feedback_table = true;
@@ -105,19 +115,20 @@ function pathCIngestNote(verdict, legacyCount, totalRows) {
105
115
  * Does not read disk outside Cursor's User directory unless `baseDir` is passed (tests).
106
116
  */
107
117
  export function auditCursorLocalStores(baseDir) {
108
- const sqlite_probe_ok = probeCursorGlobalStateDb(false);
118
+ const rootDir = baseDir ?? cursorUserDir();
119
+ const sqlite_probe_ok = probeCursorGlobalStateDb(false, baseDir);
109
120
  const statePaths = findStateVscDbs(baseDir);
110
121
  const globalPath = statePaths.find((p) => isGlobalStateDbPath(p)) ??
111
122
  join(baseDir ?? cursorUserDir(), "globalStorage", "state.vscdb");
112
- const global = auditStateVscdbFile(globalPath);
123
+ const global = auditStateVscdbFile(globalPath, rootDir);
113
124
  const workspacePaths = statePaths.filter((p) => !isGlobalStateDbPath(p));
114
- const workspaceAudits = workspacePaths.map(auditStateVscdbFile);
125
+ const workspaceAudits = workspacePaths.map((p) => auditStateVscdbFile(p, rootDir));
115
126
  const versionDiscoveries = statePaths
116
127
  .filter((p) => existsSync(p))
117
- .map(discoverDailyStatsVersionsInDb);
128
+ .map((p) => discoverDailyStatsVersionsInDb(p, { rootDir }));
118
129
  const daily_stats_versions = mergeDailyStatsVersionDiscoveries(versionDiscoveries);
119
130
  const legacyPaths = findCursorDbs(baseDir);
120
- const legacyEntries = legacyPaths.map(auditLegacyCursorDbFile);
131
+ const legacyEntries = legacyPaths.map((p) => auditLegacyCursorDbFile(p, rootDir));
121
132
  const withFeedbackTable = legacyEntries.filter((e) => e.has_feedback_table).length;
122
133
  const totalFeedbackRows = legacyEntries.reduce((sum, e) => sum + e.feedback_row_count, 0);
123
134
  let path_c_verdict;
@@ -26,6 +26,8 @@ export declare function isVersionNewerThanV1_5(version: string): boolean;
26
26
  /**
27
27
  * Read all `aiCodeTracking.dailyStats%` keys from one `state.vscdb` file.
28
28
  */
29
- export declare function discoverDailyStatsVersionsInDb(dbPath: string): DailyStatsVersionDiscovery;
29
+ export declare function discoverDailyStatsVersionsInDb(dbPath: string, options?: {
30
+ rootDir?: string;
31
+ }): DailyStatsVersionDiscovery;
30
32
  /** Merge discoveries from global + workspace `state.vscdb` files (dedupe sample keys only). */
31
33
  export declare function mergeDailyStatsVersionDiscoveries(discoveries: DailyStatsVersionDiscovery[]): DailyStatsVersionDiscovery;
@@ -1,8 +1,4 @@
1
- /**
2
- * CUR-V11 — discover `aiCodeTracking.dailyStats` version prefixes on disk.
3
- * Keys look like: aiCodeTracking.dailyStats.v1.5.2026-05-20
4
- */
5
- import Database from "better-sqlite3";
1
+ import { openCursorSqliteReadonly } from "./readers/cursor-sqlite.js";
6
2
  const STATE_TABLE = "ItemTable";
7
3
  const DAILY_STATS_LIKE = "aiCodeTracking.dailyStats%";
8
4
  /** Full key shape for a dated dailyStats row. */
@@ -70,12 +66,15 @@ function mergeBuckets(target, discovery) {
70
66
  /**
71
67
  * Read all `aiCodeTracking.dailyStats%` keys from one `state.vscdb` file.
72
68
  */
73
- export function discoverDailyStatsVersionsInDb(dbPath) {
69
+ export function discoverDailyStatsVersionsInDb(dbPath, options = {}) {
74
70
  const byVersion = new Map();
75
71
  const unmatched = [];
76
72
  let db = null;
77
73
  try {
78
- db = new Database(dbPath, { readonly: true });
74
+ const opened = openCursorSqliteReadonly(dbPath, { rootDir: options.rootDir });
75
+ if (!opened.ok)
76
+ return emptyDiscovery();
77
+ db = opened.db;
79
78
  const table = db
80
79
  .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
81
80
  .get(STATE_TABLE);