@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.
- package/README.md +154 -3
- package/dist/auth/credentials.d.ts +7 -1
- package/dist/auth/credentials.js +71 -14
- package/dist/auth/exchange.d.ts +1 -1
- package/dist/auth/exchange.js +1 -1
- package/dist/auth/flow.d.ts +10 -1
- package/dist/auth/flow.js +38 -5
- package/dist/auth/keycloak.d.ts +1 -1
- package/dist/auth/keycloak.js +20 -1
- package/dist/cli.d.ts +7 -3
- package/dist/cli.js +87 -21
- package/dist/collect-cursor-payloads.d.ts +4 -3
- package/dist/collect-cursor-payloads.js +8 -5
- package/dist/cursor-checkpoints.d.ts +2 -2
- package/dist/cursor-payload-contract.d.ts +5 -5
- package/dist/cursor-payload-contract.js +7 -0
- package/dist/cursor-settings.d.ts +9 -4
- package/dist/cursor-settings.js +80 -10
- package/dist/cursor-store-audit.d.ts +2 -2
- package/dist/cursor-store-audit.js +22 -11
- package/dist/daily-stats-versions.d.ts +3 -1
- package/dist/daily-stats-versions.js +6 -7
- package/dist/health.d.ts +3 -1
- package/dist/health.js +13 -1
- package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
- package/dist/hooks/cursor-hooks-mapper.js +1 -1
- package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
- package/dist/hooks/cursor-hooks-reader.js +2 -2
- package/dist/install/cursor.d.ts +34 -0
- package/dist/install/cursor.js +193 -0
- package/dist/install/index.d.ts +6 -4
- package/dist/install/index.js +6 -1
- package/dist/lib/client.d.ts +7 -0
- package/dist/lib/client.js +17 -0
- package/dist/lib/config.js +7 -2
- package/dist/lib/project-resolver.d.ts +5 -4
- package/dist/lib/project-resolver.js +20 -8
- package/dist/lib/transport-security.d.ts +13 -0
- package/dist/lib/transport-security.js +47 -0
- package/dist/pricing.d.ts +9 -1
- package/dist/pricing.js +39 -8
- package/dist/readers/claude.d.ts +54 -6
- package/dist/readers/claude.js +158 -6
- package/dist/readers/cursor-sqlite.d.ts +23 -0
- package/dist/readers/cursor-sqlite.js +68 -0
- package/dist/readers/cursor.d.ts +11 -8
- package/dist/readers/cursor.js +149 -31
- package/dist/server.d.ts +20 -3
- package/dist/server.js +101 -67
- package/dist/state.js +7 -2
- package/dist/sync.d.ts +4 -2
- package/dist/sync.js +61 -46
- 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
|
|
23
|
-
export function
|
|
24
|
-
return
|
|
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
|
-
|
|
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
|
|
162
|
-
init
|
|
163
|
-
health
|
|
164
|
-
uninstall-hooks
|
|
165
|
-
verify-hooks
|
|
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
|
|
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
|
|
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
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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 {
|
|
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:
|
|
12
|
+
export declare function filterRecentCommitsByHashDedup(payloads: CursorPayload[], lastRecentCommitHashes?: string[]): CursorPayload[];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
10
|
-
export declare function validateCursorPayload(payload:
|
|
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:
|
|
17
|
-
export declare function printCursorDryRunValidationReport(payloads:
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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):
|
|
11
|
+
export declare function readCursorActiveModel(baseDir?: string): CursorActiveModelResolution;
|
package/dist/cursor-settings.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|