@aixle/insights 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/dist/auth/credentials.d.ts +23 -0
  4. package/dist/auth/credentials.js +174 -0
  5. package/dist/auth/exchange.d.ts +25 -0
  6. package/dist/auth/exchange.js +87 -0
  7. package/dist/auth/flow.d.ts +24 -0
  8. package/dist/auth/flow.js +66 -0
  9. package/dist/auth/keycloak.d.ts +35 -0
  10. package/dist/auth/keycloak.js +170 -0
  11. package/dist/cli.d.ts +51 -0
  12. package/dist/cli.js +426 -0
  13. package/dist/client.d.ts +28 -0
  14. package/dist/client.js +102 -0
  15. package/dist/collect-cursor-payloads.d.ts +57 -0
  16. package/dist/collect-cursor-payloads.js +134 -0
  17. package/dist/credentials.d.ts +2 -0
  18. package/dist/credentials.js +1 -0
  19. package/dist/cursor-checkpoints.d.ts +12 -0
  20. package/dist/cursor-checkpoints.js +28 -0
  21. package/dist/cursor-config.d.ts +5 -0
  22. package/dist/cursor-config.js +34 -0
  23. package/dist/cursor-payload-contract.d.ts +17 -0
  24. package/dist/cursor-payload-contract.js +258 -0
  25. package/dist/cursor-settings.d.ts +6 -0
  26. package/dist/cursor-settings.js +38 -0
  27. package/dist/cursor-store-audit.d.ts +48 -0
  28. package/dist/cursor-store-audit.js +155 -0
  29. package/dist/daily-stats-versions.d.ts +31 -0
  30. package/dist/daily-stats-versions.js +170 -0
  31. package/dist/health.d.ts +31 -0
  32. package/dist/health.js +195 -0
  33. package/dist/hooks/cursor-hooks-mapper.d.ts +22 -0
  34. package/dist/hooks/cursor-hooks-mapper.js +84 -0
  35. package/dist/hooks/cursor-hooks-reader.d.ts +30 -0
  36. package/dist/hooks/cursor-hooks-reader.js +117 -0
  37. package/dist/hooks/hook-forwarder.mjs +110 -0
  38. package/dist/hooks/hooks-config.d.ts +92 -0
  39. package/dist/hooks/hooks-config.js +235 -0
  40. package/dist/install/claude.d.ts +37 -0
  41. package/dist/install/claude.js +144 -0
  42. package/dist/install/index.d.ts +8 -0
  43. package/dist/install/index.js +11 -0
  44. package/dist/lib/args.d.ts +26 -0
  45. package/dist/lib/args.js +17 -0
  46. package/dist/lib/client.d.ts +33 -0
  47. package/dist/lib/client.js +52 -0
  48. package/dist/lib/config.d.ts +26 -0
  49. package/dist/lib/config.js +39 -0
  50. package/dist/lib/index.d.ts +4 -0
  51. package/dist/lib/index.js +4 -0
  52. package/dist/lib/project-resolver.d.ts +48 -0
  53. package/dist/lib/project-resolver.js +203 -0
  54. package/dist/lock.d.ts +9 -0
  55. package/dist/lock.js +84 -0
  56. package/dist/log.d.ts +14 -0
  57. package/dist/log.js +81 -0
  58. package/dist/pricing.d.ts +40 -0
  59. package/dist/pricing.js +149 -0
  60. package/dist/readers/claude.d.ts +83 -0
  61. package/dist/readers/claude.js +317 -0
  62. package/dist/readers/cursor.d.ts +134 -0
  63. package/dist/readers/cursor.js +900 -0
  64. package/dist/risk-scanner.d.ts +8 -0
  65. package/dist/risk-scanner.js +59 -0
  66. package/dist/server.d.ts +14 -0
  67. package/dist/server.js +234 -0
  68. package/dist/state.d.ts +69 -0
  69. package/dist/state.js +155 -0
  70. package/dist/sync.d.ts +74 -0
  71. package/dist/sync.js +679 -0
  72. package/package.json +66 -0
package/dist/client.js ADDED
@@ -0,0 +1,102 @@
1
+ import { postEvent as sdkPostEvent, } from "./lib/index.js";
2
+ import { mcpLog } from "./log.js";
3
+ /** Delays after each failed attempt (not 429): initial try, then wait 1s, 4s, 16s before retries. */
4
+ const TRANSIENT_RETRY_DELAYS_MS = [1000, 4000, 16_000];
5
+ const RETRY_DELAY_LABELS = ["1s", "4s", "16s"];
6
+ /** Optional zero-delay retries in Vitest (real `setTimeout` does not play well with `vi.useFakeTimers`). */
7
+ let ingestRetryWaitOverride;
8
+ /** @internal */
9
+ export function setIngestRetryWaitOverrideForTests(fn) {
10
+ ingestRetryWaitOverride = fn;
11
+ }
12
+ function defaultWait(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+ /**
16
+ * Single-event POST with intra-sync retries for transient failures (5xx / network).
17
+ * Does not retry 429: the SDK invokes `on429` and returns false immediately.
18
+ */
19
+ export async function postEvent(payload, host, token, options = {}) {
20
+ const wait = options.waitMs ?? ingestRetryWaitOverride ?? defaultWait;
21
+ const logRetries = options.logTransientRetries !== false;
22
+ for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt++) {
23
+ let was429 = false;
24
+ let httpStatus = null;
25
+ let networkFailed = false;
26
+ const merged = {
27
+ ...options,
28
+ onHttpError: (status, statusText, body) => {
29
+ httpStatus = status;
30
+ if (options.onHttpError) {
31
+ options.onHttpError(status, statusText, body);
32
+ }
33
+ else {
34
+ console.error(`Failed to post event: HTTP ${status} ${statusText}${body ? ` — ${body}` : ""}`);
35
+ }
36
+ },
37
+ onNetworkError: (err) => {
38
+ networkFailed = true;
39
+ if (options.onNetworkError) {
40
+ options.onNetworkError(err);
41
+ }
42
+ else {
43
+ console.error(`Network error posting event: ${err instanceof Error ? err.message : String(err)}`);
44
+ }
45
+ },
46
+ on429: (retryAfter, quotaExceeded) => {
47
+ was429 = true;
48
+ options.on429?.(retryAfter, quotaExceeded);
49
+ },
50
+ };
51
+ const ok = await sdkPostEvent(payload, host, token, merged);
52
+ if (ok)
53
+ return true;
54
+ if (was429)
55
+ return false;
56
+ if (!networkFailed && (httpStatus === null || httpStatus < 500))
57
+ return false;
58
+ if (attempt >= TRANSIENT_RETRY_DELAYS_MS.length)
59
+ return false;
60
+ const delayMs = TRANSIENT_RETRY_DELAYS_MS[attempt];
61
+ const delayLabel = RETRY_DELAY_LABELS[attempt];
62
+ if (logRetries) {
63
+ mcpLog.warn("ingest_transient_retry", {
64
+ attempt: attempt + 1,
65
+ delay: delayLabel,
66
+ delay_ms: delayMs,
67
+ occurred_at: payload.occurred_at,
68
+ }, true);
69
+ }
70
+ await wait(delayMs);
71
+ }
72
+ return false;
73
+ }
74
+ /**
75
+ * Batch POST with sent/failed aggregation plus max `occurred_at` watermarking
76
+ * (used by Cursor multi-event sync loops).
77
+ */
78
+ export async function postEvents(events, host, token, options = {}) {
79
+ if (events.length === 0)
80
+ return { sent: 0, failed: 0, lastSentAt: null };
81
+ const outcomes = await Promise.allSettled(events.map((event) => postEvent(event, host, token, options).then((ok) => ({ event, ok }))));
82
+ let sent = 0;
83
+ let failed = 0;
84
+ let lastSentAt = null;
85
+ for (const outcome of outcomes) {
86
+ if (outcome.status === "rejected") {
87
+ failed++;
88
+ continue;
89
+ }
90
+ if (outcome.value.ok) {
91
+ sent++;
92
+ const t = outcome.value.event.occurred_at;
93
+ if (typeof t === "string" && (lastSentAt === null || t > lastSentAt)) {
94
+ lastSentAt = t;
95
+ }
96
+ }
97
+ else {
98
+ failed++;
99
+ }
100
+ }
101
+ return { sent, failed, lastSentAt };
102
+ }
@@ -0,0 +1,57 @@
1
+ import type { ProjectResolution } from "./lib/index.js";
2
+ import type { State } from "./state.js";
3
+ import { type CursorDb90Payload, type CursorTranscriptTurn, type PricingConfig } from "./readers/cursor.js";
4
+ export interface CursorSliceGroup {
5
+ key: string;
6
+ label: string;
7
+ payloads: CursorDb90Payload[];
8
+ }
9
+ export interface PrepareCursorSliceGroupsOptions {
10
+ stateBefore: State;
11
+ fullScan?: boolean;
12
+ projectId?: string | null;
13
+ projectIdSource?: ProjectResolution["source"];
14
+ host?: string;
15
+ token?: string;
16
+ projectLookupToken?: string | null;
17
+ verbose?: boolean;
18
+ cursorBaseDir?: string;
19
+ cursorTranscriptProjectDirs?: string[];
20
+ cursorPricing?: PricingConfig;
21
+ }
22
+ export interface PreparedCursorSliceGroups {
23
+ groups: CursorSliceGroup[];
24
+ skippedTranscriptCount: number;
25
+ totalPayloadCount: number;
26
+ transcriptTurnsById: Map<string, CursorTranscriptTurn>;
27
+ counts: {
28
+ legacy: number;
29
+ dailyStatsEntriesRaw: number;
30
+ dailyStatsEntries: number;
31
+ recentCommitSnapshots: number;
32
+ transcriptTurns: number;
33
+ transcriptPayloads: number;
34
+ /** Chat events filtered from events+stats because transcript mode covers them. */
35
+ suppressedComposer: number;
36
+ };
37
+ }
38
+ export interface CollectLocalCursorPayloadsOptions {
39
+ /** Ignore watermarks and commit hash dedupe (default true for verify scripts). */
40
+ fullScan?: boolean;
41
+ projectId?: string | null;
42
+ verbose?: boolean;
43
+ cursorBaseDir?: string;
44
+ cursorTranscriptProjectDirs?: string[];
45
+ cursorPricing?: PricingConfig;
46
+ stateBefore?: State;
47
+ }
48
+ export interface CollectedCursorPayloads {
49
+ payloads: CursorDb90Payload[];
50
+ counts: PreparedCursorSliceGroups["counts"];
51
+ }
52
+ /**
53
+ * Read local Cursor stores, apply watermarks/dedupe, and group payloads for sync posting.
54
+ */
55
+ export declare function prepareCursorSliceGroups(options: PrepareCursorSliceGroupsOptions): Promise<PreparedCursorSliceGroups>;
56
+ /** Flat payload list for dry-run verification scripts (no POST). */
57
+ export declare function collectLocalCursorPayloads(options?: CollectLocalCursorPayloadsOptions): Promise<CollectedCursorPayloads>;
@@ -0,0 +1,134 @@
1
+ import { enrichCommitProjectAttribution } from "./lib/index.js";
2
+ import { CURSOR_DAILY_STATS_WATERMARK_KEY, CURSOR_EVENTS_WATERMARK_KEY, CURSOR_RECENT_COMMIT_WATERMARK_KEY, CURSOR_TRANSCRIPT_TURN_PREFIX, CURSOR_WATERMARK_KEY, cursorTranscriptTurnStateKey, cursorWatermarkDate, filterRecentCommitsByHashDedup, } from "./cursor-checkpoints.js";
3
+ import { readCursorActiveModel } from "./cursor-settings.js";
4
+ import { readEvents as readCursorEvents, readDailyStatsWithDedupe, readRecentCommitSnapshots, readCursorTranscriptSessions, mapEvent as mapCursorEvent, mapTranscriptTurn as mapCursorTranscriptTurn, mapDailyStats, mapRecentCommit, DEFAULT_CURSOR_PRICING, } from "./readers/cursor.js";
5
+ /**
6
+ * Read local Cursor stores, apply watermarks/dedupe, and group payloads for sync posting.
7
+ */
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;
10
+ const useCommitHashDedup = !fullScan;
11
+ const eventsSince = fullScan
12
+ ? null
13
+ : cursorWatermarkDate(stateBefore, CURSOR_EVENTS_WATERMARK_KEY, CURSOR_WATERMARK_KEY);
14
+ const dailyStatsSince = fullScan
15
+ ? null
16
+ : cursorWatermarkDate(stateBefore, CURSOR_DAILY_STATS_WATERMARK_KEY, CURSOR_WATERMARK_KEY);
17
+ const recentCommitSince = fullScan
18
+ ? null
19
+ : cursorWatermarkDate(stateBefore, CURSOR_RECENT_COMMIT_WATERMARK_KEY, CURSOR_WATERMARK_KEY);
20
+ const commitReadSince = useCommitHashDedup ? null : recentCommitSince;
21
+ if (verbose && fullScan) {
22
+ console.log("[verbose][cursor] Full scan — ignoring saved watermarks and commit hash dedupe");
23
+ }
24
+ const baseDir = cursorBaseDir;
25
+ const activeModel = readCursorActiveModel(baseDir) ?? undefined;
26
+ const transcriptTurns = await readCursorTranscriptSessions(baseDir, cursorTranscriptProjectDirs, verbose);
27
+ const rawEvents = readCursorEvents(eventsSince, baseDir, verbose);
28
+ const { raw: dailyStatsRaw, deduped: dailyStats } = readDailyStatsWithDedupe(dailyStatsSince, baseDir, verbose);
29
+ const recentCommitSnapshots = readRecentCommitSnapshots(commitReadSince, baseDir, verbose);
30
+ const projectIdOpt = projectId ?? undefined;
31
+ const transcriptTurnsById = new Map(transcriptTurns.map((turn) => [turn.turnId, turn]));
32
+ const transcriptPayloads = [...transcriptTurnsById.values()]
33
+ .filter((turn) => {
34
+ if (fullScan)
35
+ return true;
36
+ const known = stateBefore.sessions[cursorTranscriptTurnStateKey(turn.turnId)];
37
+ if (!known)
38
+ return true;
39
+ // Prefer content hash comparison when both sides have it (fileSize is a fallback)
40
+ if (known.contentHash && turn.contentHash)
41
+ return known.contentHash !== turn.contentHash;
42
+ return known.fileSize !== turn.fileSize;
43
+ })
44
+ .map((turn) => mapCursorTranscriptTurn(turn, projectIdOpt, cursorPricing, activeModel))
45
+ .sort((a, b) => a.occurred_at.localeCompare(b.occurred_at));
46
+ const skippedTranscriptCount = transcriptTurns.length - transcriptPayloads.length;
47
+ const transcriptModeEnabled = transcriptTurnsById.size > 0;
48
+ const allMappedFromEvents = rawEvents
49
+ .map(({ row, workspacePath }) => mapCursorEvent(row, workspacePath, projectIdOpt, cursorPricing))
50
+ .filter((e) => e !== null);
51
+ const allMappedFromStats = dailyStats
52
+ .flatMap((entry) => mapDailyStats(entry, projectIdOpt, cursorPricing, activeModel));
53
+ // When transcripts are present they cover the same chat activity — suppress the daily aggregates
54
+ // to prevent double-counting. Logged at info level in sync.ts via counts.suppressedComposer.
55
+ const mappedFromEvents = allMappedFromEvents.filter((payload) => !transcriptModeEnabled || payload.event_type !== "chat");
56
+ const mappedFromStats = allMappedFromStats.filter((payload) => !transcriptModeEnabled || payload.event_type !== "chat");
57
+ const suppressedComposer = transcriptModeEnabled
58
+ ? allMappedFromEvents.filter((p) => p.event_type === "chat").length +
59
+ allMappedFromStats.filter((p) => p.event_type === "chat").length
60
+ : 0;
61
+ let mappedFromCommits = recentCommitSnapshots
62
+ .map((snapshot) => mapRecentCommit(snapshot, projectIdOpt, cursorPricing, activeModel))
63
+ .filter((payload) => payload !== null)
64
+ .sort((a, b) => a.occurred_at.localeCompare(b.occurred_at));
65
+ if (useCommitHashDedup) {
66
+ mappedFromCommits = filterRecentCommitsByHashDedup(mappedFromCommits, stateBefore.lastRecentCommitHashes);
67
+ }
68
+ if (host) {
69
+ const lookupToken = projectLookupToken ?? token;
70
+ if (lookupToken) {
71
+ await enrichCommitProjectAttribution(mappedFromCommits, {
72
+ projectIdSource,
73
+ host,
74
+ token: lookupToken,
75
+ verbose,
76
+ });
77
+ }
78
+ }
79
+ const groups = [
80
+ {
81
+ key: CURSOR_TRANSCRIPT_TURN_PREFIX,
82
+ label: "transcripts",
83
+ payloads: transcriptPayloads,
84
+ },
85
+ {
86
+ key: CURSOR_EVENTS_WATERMARK_KEY,
87
+ label: "events",
88
+ payloads: mappedFromEvents.sort((a, b) => a.occurred_at.localeCompare(b.occurred_at)),
89
+ },
90
+ {
91
+ key: CURSOR_DAILY_STATS_WATERMARK_KEY,
92
+ label: "daily_stats",
93
+ payloads: mappedFromStats.sort((a, b) => a.occurred_at.localeCompare(b.occurred_at)),
94
+ },
95
+ {
96
+ key: CURSOR_RECENT_COMMIT_WATERMARK_KEY,
97
+ label: "recent_commit",
98
+ payloads: mappedFromCommits,
99
+ },
100
+ ];
101
+ const totalPayloadCount = groups.reduce((sum, group) => sum + group.payloads.length, 0);
102
+ return {
103
+ groups,
104
+ skippedTranscriptCount,
105
+ totalPayloadCount,
106
+ transcriptTurnsById,
107
+ counts: {
108
+ legacy: mappedFromEvents.length,
109
+ dailyStatsEntriesRaw: dailyStatsRaw.length,
110
+ dailyStatsEntries: dailyStats.length,
111
+ recentCommitSnapshots: recentCommitSnapshots.length,
112
+ transcriptTurns: transcriptTurns.length,
113
+ transcriptPayloads: transcriptPayloads.length,
114
+ suppressedComposer,
115
+ },
116
+ };
117
+ }
118
+ /** Flat payload list for dry-run verification scripts (no POST). */
119
+ export async function collectLocalCursorPayloads(options = {}) {
120
+ const { fullScan = true, projectId = null, verbose = false, cursorBaseDir, cursorTranscriptProjectDirs, cursorPricing, stateBefore = { version: 1, sessions: {} }, } = options;
121
+ const prepared = await prepareCursorSliceGroups({
122
+ stateBefore,
123
+ fullScan,
124
+ projectId,
125
+ verbose,
126
+ cursorBaseDir,
127
+ cursorTranscriptProjectDirs,
128
+ cursorPricing,
129
+ });
130
+ return {
131
+ payloads: prepared.groups.flatMap((group) => group.payloads),
132
+ counts: prepared.counts,
133
+ };
134
+ }
@@ -0,0 +1,2 @@
1
+ export type { StoredCredentials, TelemetryToolId } from "./auth/credentials.js";
2
+ export { loadCredentials, saveCredentials, saveStoredCredentials, clearCredentials, loadCredentialsFromFileOnly, credentialsHaveAnyToken, pickProjectLookupToken, } from "./auth/credentials.js";
@@ -0,0 +1 @@
1
+ export { loadCredentials, saveCredentials, saveStoredCredentials, clearCredentials, loadCredentialsFromFileOnly, credentialsHaveAnyToken, pickProjectLookupToken, } from "./auth/credentials.js";
@@ -0,0 +1,12 @@
1
+ import type { State } from "./state.js";
2
+ import type { CursorDb90Payload } from "./readers/cursor.js";
3
+ /** Cursor SQLite watermark checkpoints — never collide with Claude `claude_code:*` session keys. */
4
+ export declare const CURSOR_WATERMARK_KEY: "cursor:watermark";
5
+ export declare const CURSOR_EVENTS_WATERMARK_KEY: "cursor:events_watermark";
6
+ export declare const CURSOR_DAILY_STATS_WATERMARK_KEY: "cursor:daily_stats_watermark";
7
+ export declare const CURSOR_RECENT_COMMIT_WATERMARK_KEY: "cursor:recent_commit_watermark";
8
+ export declare const CURSOR_TRANSCRIPT_TURN_PREFIX: "cursor:transcript_turn:";
9
+ export declare function cursorTranscriptTurnStateKey(turnId: string): string;
10
+ export declare function cursorWatermarkDate(state: Pick<State, "sessions">, ...keys: string[]): Date | null;
11
+ /** Skip recent-commit payloads whose hash was already successfully POSTed. */
12
+ export declare function filterRecentCommitsByHashDedup(payloads: CursorDb90Payload[], lastRecentCommitHashes?: string[]): CursorDb90Payload[];
@@ -0,0 +1,28 @@
1
+ /** Cursor SQLite watermark checkpoints — never collide with Claude `claude_code:*` session keys. */
2
+ export const CURSOR_WATERMARK_KEY = "cursor:watermark";
3
+ export const CURSOR_EVENTS_WATERMARK_KEY = "cursor:events_watermark";
4
+ export const CURSOR_DAILY_STATS_WATERMARK_KEY = "cursor:daily_stats_watermark";
5
+ export const CURSOR_RECENT_COMMIT_WATERMARK_KEY = "cursor:recent_commit_watermark";
6
+ export const CURSOR_TRANSCRIPT_TURN_PREFIX = "cursor:transcript_turn:";
7
+ export function cursorTranscriptTurnStateKey(turnId) {
8
+ return `${CURSOR_TRANSCRIPT_TURN_PREFIX}${turnId}`;
9
+ }
10
+ export function cursorWatermarkDate(state, ...keys) {
11
+ const rec = keys
12
+ .map((key) => state.sessions[key])
13
+ .find((value) => value !== undefined);
14
+ if (!rec)
15
+ return null;
16
+ const d = new Date(rec.sentAt);
17
+ return isNaN(d.getTime()) ? null : d;
18
+ }
19
+ /** Skip recent-commit payloads whose hash was already successfully POSTed. */
20
+ export function filterRecentCommitsByHashDedup(payloads, lastRecentCommitHashes) {
21
+ if (!lastRecentCommitHashes || lastRecentCommitHashes.length === 0)
22
+ return payloads;
23
+ const seen = new Set(lastRecentCommitHashes);
24
+ return payloads.filter((p) => {
25
+ const hash = p.metadata.commit_hash;
26
+ return !hash || !seen.has(hash);
27
+ });
28
+ }
@@ -0,0 +1,5 @@
1
+ import { type PricingConfig } from "./readers/cursor.js";
2
+ export declare function parseCursorPricing(raw: Record<string, unknown>): Partial<PricingConfig> | undefined;
3
+ /** Load optional Cursor line-cost overrides from `~/.aixle-insights/config.json`. */
4
+ export declare function loadCursorConfig(appDir?: string): Partial<PricingConfig>;
5
+ export declare function resolveCursorPricing(overrides?: Partial<PricingConfig>, appDir?: string): PricingConfig;
@@ -0,0 +1,34 @@
1
+ import { loadBaseConfig } from "./lib/index.js";
2
+ import { getAppDir } from "./state.js";
3
+ import { DEFAULT_CURSOR_PRICING, } from "./readers/cursor.js";
4
+ export function parseCursorPricing(raw) {
5
+ const rawPricing = typeof raw.pricing === "object" && raw.pricing !== null
6
+ ? raw.pricing
7
+ : null;
8
+ if (!rawPricing)
9
+ return undefined;
10
+ const pricing = {};
11
+ for (const key of [
12
+ "tokens_per_line",
13
+ "completion_output_per_mtok",
14
+ "chat_input_per_mtok",
15
+ "chat_output_per_mtok",
16
+ ]) {
17
+ const value = rawPricing[key];
18
+ if (value == null || value === "" || typeof value === "boolean")
19
+ continue;
20
+ const num = Number(value);
21
+ if (!Number.isNaN(num) && num >= 0)
22
+ pricing[key] = num;
23
+ }
24
+ return Object.keys(pricing).length > 0 ? pricing : undefined;
25
+ }
26
+ /** Load optional Cursor line-cost overrides from `~/.aixle-insights/config.json`. */
27
+ export function loadCursorConfig(appDir) {
28
+ return loadBaseConfig(appDir ?? getAppDir(), parseCursorPricing)
29
+ .pricing ?? {};
30
+ }
31
+ export function resolveCursorPricing(overrides, appDir) {
32
+ const fromFile = loadCursorConfig(appDir);
33
+ return { ...DEFAULT_CURSOR_PRICING, ...fromFile, ...overrides };
34
+ }
@@ -0,0 +1,17 @@
1
+ import type { CursorDb90Payload } from "./readers/cursor.js";
2
+ /** Ingest paths for Cursor payloads emitted by telemetry-mcp. */
3
+ export type CursorIngestPath = "daily_tab" | "daily_composer" | "legacy_request" | "recent_commit" | "mcp_transcript" | "cursor_hook";
4
+ export interface PayloadValidationResult {
5
+ ok: boolean;
6
+ errors: string[];
7
+ path: CursorIngestPath | "unknown";
8
+ }
9
+ export declare function inferIngestPath(payload: CursorDb90Payload): CursorIngestPath | "unknown";
10
+ export declare function validateCursorPayload(payload: CursorDb90Payload): PayloadValidationResult;
11
+ export interface DryRunMatrixRow {
12
+ path: CursorIngestPath | "unknown";
13
+ count: number;
14
+ sample_occurred_at: string | null;
15
+ }
16
+ export declare function summarizeDryRunMatrix(payloads: CursorDb90Payload[]): DryRunMatrixRow[];
17
+ export declare function printCursorDryRunValidationReport(payloads: CursorDb90Payload[]): boolean;
@@ -0,0 +1,258 @@
1
+ import { HOOK_COST_MODEL } from "./readers/cursor.js";
2
+ const TOP_LEVEL_KEYS = new Set([
3
+ "tool_name",
4
+ "event_type",
5
+ "model",
6
+ "tokens_in",
7
+ "tokens_out",
8
+ "cost_usd",
9
+ "occurred_at",
10
+ "project_id",
11
+ "metadata",
12
+ ]);
13
+ const METADATA_BASE_KEYS = new Set([
14
+ "cursor_session_id",
15
+ "workspace",
16
+ "workspace_scope",
17
+ "workspace_folder",
18
+ "cost_model",
19
+ "scannable",
20
+ "risk_level",
21
+ ]);
22
+ const METADATA_COMMIT_KEYS = new Set([
23
+ ...METADATA_BASE_KEYS,
24
+ "source",
25
+ "commit_hash",
26
+ "commit_message",
27
+ "repo_name",
28
+ "branch_name",
29
+ "ai_percentage",
30
+ ]);
31
+ const METADATA_TRANSCRIPT_KEYS = new Set([
32
+ "session_id",
33
+ "cursor_session_id",
34
+ "workspace",
35
+ "cost_model",
36
+ "scannable",
37
+ "risk_level",
38
+ "risk_categories",
39
+ "risk_score",
40
+ "transcript_source",
41
+ "composer_name",
42
+ "prompt_text",
43
+ "assistant_text",
44
+ ]);
45
+ const METADATA_HOOK_KEYS = new Set([
46
+ "cursor_session_id",
47
+ "workspace",
48
+ "workspace_scope",
49
+ "cost_model",
50
+ "scannable",
51
+ "risk_level",
52
+ "ingest_source",
53
+ "hook_event_name",
54
+ "generation_id",
55
+ "hook_tool_name",
56
+ "duration_ms",
57
+ "session_id",
58
+ ]);
59
+ const EVENT_TYPES = new Set(["completion", "chat", "commit"]);
60
+ // Priority: cursor_hook (cost_model discriminant) > transcript_source > event_type/source > session-based legacy path
61
+ // IMPORTANT: cursor_hook must be checked first — hook payloads set cursor_session_id (conversation_id)
62
+ // which would otherwise misclassify as "legacy_request".
63
+ export function inferIngestPath(payload) {
64
+ if (payload.metadata.cost_model === HOOK_COST_MODEL) {
65
+ return "cursor_hook";
66
+ }
67
+ if (payload.metadata.transcript_source === "agent_transcript") {
68
+ return "mcp_transcript";
69
+ }
70
+ if (payload.event_type === "commit" || payload.metadata.source === "recent_commit") {
71
+ return "recent_commit";
72
+ }
73
+ if (payload.metadata.cursor_session_id !== null) {
74
+ return "legacy_request";
75
+ }
76
+ if (payload.event_type === "completion")
77
+ return "daily_tab";
78
+ if (payload.event_type === "chat")
79
+ return "daily_composer";
80
+ return "unknown";
81
+ }
82
+ function unexpectedKeys(obj, allowed, label) {
83
+ const errors = [];
84
+ for (const key of Object.keys(obj)) {
85
+ if (!allowed.has(key))
86
+ errors.push(`${label}: unexpected key "${key}"`);
87
+ }
88
+ return errors;
89
+ }
90
+ function isNonNegativeNumber(value, field) {
91
+ if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
92
+ return `${field} must be a non-negative number`;
93
+ }
94
+ return null;
95
+ }
96
+ export function validateCursorPayload(payload) {
97
+ const errors = [];
98
+ const path = inferIngestPath(payload);
99
+ errors.push(...unexpectedKeys(payload, TOP_LEVEL_KEYS, "payload"));
100
+ if (payload.tool_name !== "cursor")
101
+ errors.push('tool_name must be "cursor"');
102
+ if (!EVENT_TYPES.has(payload.event_type)) {
103
+ errors.push(`event_type must be one of ${[...EVENT_TYPES].join(", ")}`);
104
+ }
105
+ if (typeof payload.model !== "string" || payload.model.length === 0) {
106
+ errors.push("model must be a non-empty string");
107
+ }
108
+ for (const field of ["tokens_in", "tokens_out", "cost_usd"]) {
109
+ const err = isNonNegativeNumber(payload[field], field);
110
+ if (err)
111
+ errors.push(err);
112
+ }
113
+ if (typeof payload.occurred_at !== "string" || Number.isNaN(Date.parse(payload.occurred_at))) {
114
+ errors.push("occurred_at must be a valid ISO-8601 string");
115
+ }
116
+ if (payload.project_id !== undefined && typeof payload.project_id !== "string") {
117
+ errors.push("project_id must be a string when present");
118
+ }
119
+ const meta = payload.metadata;
120
+ if (typeof meta !== "object" || meta === null) {
121
+ errors.push("metadata must be an object");
122
+ }
123
+ else {
124
+ const allowedMeta = path === "recent_commit"
125
+ ? METADATA_COMMIT_KEYS
126
+ : path === "mcp_transcript"
127
+ ? METADATA_TRANSCRIPT_KEYS
128
+ : path === "cursor_hook"
129
+ ? METADATA_HOOK_KEYS
130
+ : METADATA_BASE_KEYS;
131
+ errors.push(...unexpectedKeys(meta, allowedMeta, "metadata"));
132
+ if (meta.cursor_session_id !== null && typeof meta.cursor_session_id !== "string") {
133
+ errors.push("metadata.cursor_session_id must be string or null");
134
+ }
135
+ if (typeof meta.workspace !== "string" || meta.workspace.length === 0) {
136
+ errors.push("metadata.workspace must be a non-empty string");
137
+ }
138
+ if (path !== "mcp_transcript" && path !== "cursor_hook") {
139
+ if (meta.workspace_scope !== "global" && meta.workspace_scope !== "workspace") {
140
+ errors.push('metadata.workspace_scope must be "global" or "workspace"');
141
+ }
142
+ if (meta.workspace_folder !== undefined && typeof meta.workspace_folder !== "string") {
143
+ errors.push("metadata.workspace_folder must be a string when present");
144
+ }
145
+ if (meta.workspace_scope === "global" && meta.workspace_folder !== undefined) {
146
+ errors.push("metadata.workspace_folder must be omitted when workspace_scope is global");
147
+ }
148
+ }
149
+ if (path === "cursor_hook") {
150
+ if (meta.cost_model !== HOOK_COST_MODEL) {
151
+ errors.push(`metadata.cost_model must be "${HOOK_COST_MODEL}" for hook ingest`);
152
+ }
153
+ if (meta.ingest_source !== "cursor_hook") {
154
+ errors.push('metadata.ingest_source must be "cursor_hook"');
155
+ }
156
+ if (typeof meta.session_id !== "string" || meta.session_id.length === 0) {
157
+ errors.push("metadata.session_id must be a non-empty string for hook ingest");
158
+ }
159
+ }
160
+ else if (path === "legacy_request") {
161
+ if (meta.cost_model !== "token_count") {
162
+ errors.push('metadata.cost_model must be "token_count" for legacy cursor.db ingest');
163
+ }
164
+ }
165
+ else if (path === "mcp_transcript") {
166
+ if (meta.cost_model !== "estimated_transcript_text") {
167
+ errors.push('metadata.cost_model must be "estimated_transcript_text" for transcript ingest');
168
+ }
169
+ if (meta.transcript_source !== "agent_transcript") {
170
+ errors.push('metadata.transcript_source must be "agent_transcript"');
171
+ }
172
+ if (meta.scannable !== true) {
173
+ errors.push("metadata.scannable must be true for transcript ingest");
174
+ }
175
+ if (typeof meta.session_id !== "string" || meta.session_id.length === 0) {
176
+ errors.push("metadata.session_id must be a non-empty string for transcript ingest");
177
+ }
178
+ }
179
+ else if (meta.cost_model !== "estimated_line_count") {
180
+ errors.push('metadata.cost_model must be "estimated_line_count" for line-based cursor ingest');
181
+ }
182
+ if (path !== "mcp_transcript" && path !== "cursor_hook" && meta.scannable !== false) {
183
+ errors.push("metadata.scannable must be false");
184
+ }
185
+ if (path === "cursor_hook" && meta.scannable !== false) {
186
+ errors.push("metadata.scannable must be false for hook ingest");
187
+ }
188
+ if (path !== "mcp_transcript" && meta.risk_level !== "none") {
189
+ errors.push('metadata.risk_level must be "none"');
190
+ }
191
+ if (path === "recent_commit") {
192
+ if (meta.source !== "recent_commit") {
193
+ errors.push('metadata.source must be "recent_commit" for commit path');
194
+ }
195
+ if (payload.event_type !== "commit") {
196
+ errors.push('event_type must be "commit" when metadata.source is recent_commit');
197
+ }
198
+ }
199
+ }
200
+ return { ok: errors.length === 0, errors, path };
201
+ }
202
+ export function summarizeDryRunMatrix(payloads) {
203
+ const byPath = new Map();
204
+ for (const p of payloads) {
205
+ const ingestPath = inferIngestPath(p);
206
+ const list = byPath.get(ingestPath) ?? [];
207
+ list.push(p);
208
+ byPath.set(ingestPath, list);
209
+ }
210
+ const order = [
211
+ "daily_tab",
212
+ "daily_composer",
213
+ "legacy_request",
214
+ "recent_commit",
215
+ "mcp_transcript",
216
+ "cursor_hook",
217
+ "unknown",
218
+ ];
219
+ return order
220
+ .filter((path) => (byPath.get(path)?.length ?? 0) > 0)
221
+ .map((path) => {
222
+ const list = byPath.get(path);
223
+ return {
224
+ path,
225
+ count: list.length,
226
+ sample_occurred_at: list[0]?.occurred_at ?? null,
227
+ };
228
+ });
229
+ }
230
+ export function printCursorDryRunValidationReport(payloads) {
231
+ let allOk = true;
232
+ for (let i = 0; i < payloads.length; i++) {
233
+ const result = validateCursorPayload(payloads[i]);
234
+ if (!result.ok) {
235
+ allOk = false;
236
+ console.error(`[dry-run][cursor] Payload #${i + 1} (${result.path}) contract errors:`);
237
+ for (const err of result.errors)
238
+ console.error(` - ${err}`);
239
+ }
240
+ }
241
+ const matrix = summarizeDryRunMatrix(payloads);
242
+ console.log("[dry-run][cursor] Ingest path matrix:");
243
+ for (const row of matrix) {
244
+ console.log(` ${row.path}: ${row.count} event(s)` +
245
+ (row.sample_occurred_at ? ` (e.g. ${row.sample_occurred_at})` : ""));
246
+ }
247
+ const expectedPaths = ["daily_tab", "daily_composer", "recent_commit"];
248
+ const seen = new Set(matrix.map((r) => r.path));
249
+ for (const path of expectedPaths) {
250
+ if (!seen.has(path)) {
251
+ console.warn(`[dry-run][cursor] No payloads for path "${path}" — OK if Cursor had no activity there`);
252
+ }
253
+ }
254
+ if (allOk) {
255
+ console.log("[dry-run][cursor] All payloads match the cursor ingest contract (DATA-CURSOR.md §3.5 + MCP transcripts).");
256
+ }
257
+ return allOk;
258
+ }
@@ -0,0 +1,6 @@
1
+ /**
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.
5
+ */
6
+ export declare function readCursorActiveModel(baseDir?: string): string | null;