@rigour-labs/core 5.3.3 → 5.5.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.
@@ -68,6 +68,31 @@ export declare function setComponentCache(componentName: string, commitSha: stri
68
68
  export declare function normalizeQuery(query: string): string;
69
69
  export declare function getSemanticQueryCache(query: string, commitSha: string, cwd?: string): Promise<SemanticQueryEntry | null>;
70
70
  export declare function setSemanticQueryCache(query: string, commitSha: string, entry: SemanticQueryEntry, cwd?: string): Promise<void>;
71
+ /** Jaccard similarity of normalized query tokens — used for quality-safe partial reuse. */
72
+ export declare function queryTokenOverlap(a: string, b: string): number;
73
+ /**
74
+ * Distinctive tokens (len >= 5) must mostly agree — blocks "task service" ≈ "payment service".
75
+ */
76
+ export declare function distinctiveTokenAgreement(a: string, b: string): number;
77
+ export interface RelatedSemanticHit {
78
+ entry: SemanticQueryEntry;
79
+ overlap: number;
80
+ sourceQuery: string;
81
+ }
82
+ /**
83
+ * Quality-safe partial semantic reuse: same commit only.
84
+ * Requires high Jaccard (>= 0.75) AND distinctive-token agreement (>= 0.67).
85
+ * Never crosses commits — avoids stale scopes after code moves.
86
+ * File existence is re-validated by the caller before serving.
87
+ */
88
+ export declare function findRelatedSemanticQueryCache(query: string, commitSha: string, cwd?: string, minOverlap?: number, minDistinctive?: number): Promise<RelatedSemanticHit | null>;
89
+ /**
90
+ * Drop cached scopes that point at files no longer on disk (quality guard).
91
+ */
92
+ export declare function filterExistingEditScope(editScope: string[], cwd: string): Promise<{
93
+ valid: string[];
94
+ missing: string[];
95
+ }>;
71
96
  /**
72
97
  * Layer 4: Task Checkpoint Cache
73
98
  */
@@ -8,7 +8,9 @@
8
8
  * 4. Task checkpoint cache (Subagent handoff packets)
9
9
  */
10
10
  import { createHash } from 'crypto';
11
- import { setContextCacheRecord, getContextCacheRecord, recordCheckpointMetric } from '../storage/index.js';
11
+ import fs from 'fs-extra';
12
+ import path from 'path';
13
+ import { setContextCacheRecord, getContextCacheRecord, listContextCacheRecords, recordCheckpointMetric } from '../storage/index.js';
12
14
  /**
13
15
  * Generate SHA-256 content hash.
14
16
  */
@@ -114,6 +116,82 @@ export async function setSemanticQueryCache(query, commitSha, entry, cwd) {
114
116
  payloadTokens,
115
117
  }, cwd);
116
118
  }
119
+ function queryTokenSet(query) {
120
+ return new Set(normalizeQuery(query).split(' ').filter((t) => t.length > 1));
121
+ }
122
+ /** Jaccard similarity of normalized query tokens — used for quality-safe partial reuse. */
123
+ export function queryTokenOverlap(a, b) {
124
+ const left = queryTokenSet(a);
125
+ const right = queryTokenSet(b);
126
+ if (left.size === 0 || right.size === 0)
127
+ return 0;
128
+ let inter = 0;
129
+ for (const t of left) {
130
+ if (right.has(t))
131
+ inter++;
132
+ }
133
+ const union = left.size + right.size - inter;
134
+ return union === 0 ? 0 : inter / union;
135
+ }
136
+ /**
137
+ * Distinctive tokens (len >= 5) must mostly agree — blocks "task service" ≈ "payment service".
138
+ */
139
+ export function distinctiveTokenAgreement(a, b) {
140
+ const left = [...queryTokenSet(a)].filter((t) => t.length >= 5);
141
+ const right = new Set([...queryTokenSet(b)].filter((t) => t.length >= 5));
142
+ if (left.length === 0 || right.size === 0)
143
+ return 0;
144
+ const hits = left.filter((t) => right.has(t)).length;
145
+ return hits / Math.max(left.length, right.size);
146
+ }
147
+ /**
148
+ * Quality-safe partial semantic reuse: same commit only.
149
+ * Requires high Jaccard (>= 0.75) AND distinctive-token agreement (>= 0.67).
150
+ * Never crosses commits — avoids stale scopes after code moves.
151
+ * File existence is re-validated by the caller before serving.
152
+ */
153
+ export async function findRelatedSemanticQueryCache(query, commitSha, cwd, minOverlap = 0.75, minDistinctive = 0.67) {
154
+ const records = await listContextCacheRecords({ cacheType: 'semantic', commitSha, limit: 80 }, cwd);
155
+ let best = null;
156
+ for (const record of records) {
157
+ try {
158
+ const entry = JSON.parse(record.payloadJson);
159
+ const sourceQuery = entry.query || '';
160
+ const overlap = queryTokenOverlap(query, sourceQuery);
161
+ const distinctive = distinctiveTokenAgreement(query, sourceQuery);
162
+ if (overlap < minOverlap || distinctive < minDistinctive)
163
+ continue;
164
+ if (normalizeQuery(query) === normalizeQuery(sourceQuery))
165
+ continue; // exact path uses getSemanticQueryCache
166
+ if (!best || overlap > best.overlap) {
167
+ best = { entry, overlap, sourceQuery };
168
+ }
169
+ }
170
+ catch {
171
+ // skip bad payloads
172
+ }
173
+ }
174
+ return best;
175
+ }
176
+ /**
177
+ * Drop cached scopes that point at files no longer on disk (quality guard).
178
+ */
179
+ export async function filterExistingEditScope(editScope, cwd) {
180
+ const valid = [];
181
+ const missing = [];
182
+ for (const file of editScope) {
183
+ try {
184
+ if (await fs.pathExists(path.join(cwd, file)))
185
+ valid.push(file);
186
+ else
187
+ missing.push(file);
188
+ }
189
+ catch {
190
+ missing.push(file);
191
+ }
192
+ }
193
+ return { valid, missing };
194
+ }
117
195
  /**
118
196
  * Layer 4: Task Checkpoint Cache
119
197
  */
@@ -8,7 +8,7 @@ vi.mock('./db.js', () => ({
8
8
  DB_PATH: '/tmp/rigour-test.db',
9
9
  RIGOUR_DIR: '/tmp/.rigour',
10
10
  }));
11
- import { getStaticCache, setStaticCache, getComponentCache, setComponentCache, getSemanticQueryCache, setSemanticQueryCache, getTaskCheckpointCache, setTaskCheckpointCache, hashContent, normalizeQuery } from './cache-engine.js';
11
+ import { getStaticCache, setStaticCache, getComponentCache, setComponentCache, getSemanticQueryCache, setSemanticQueryCache, findRelatedSemanticQueryCache, queryTokenOverlap, distinctiveTokenAgreement, filterExistingEditScope, getTaskCheckpointCache, setTaskCheckpointCache, hashContent, normalizeQuery } from './cache-engine.js';
12
12
  describe('4-Layer Context Cache Engine', () => {
13
13
  const testCwd = path.join(os.tmpdir(), `rigour-cache-test-${Date.now()}`);
14
14
  beforeEach(async () => {
@@ -89,6 +89,42 @@ describe('4-Layer Context Cache Engine', () => {
89
89
  expect(retrieved?.taskId).toBe('CTP-142');
90
90
  expect(retrieved?.decisions).toContain('Added priority field');
91
91
  });
92
+ it('Layer 3: finds related semantic queries at same commit for partial reuse', async () => {
93
+ expect(queryTokenOverlap('task priority persistence service', 'task priority persistence service layer')).toBeGreaterThan(0.7);
94
+ expect(distinctiveTokenAgreement('task service', 'payment service')).toBeLessThan(0.67);
95
+ await setSemanticQueryCache('task priority persistence service', 'commit-rel', {
96
+ query: 'task priority persistence service',
97
+ resolvedOwner: 'task-team',
98
+ editScope: ['services/task.ts'],
99
+ validationScope: ['npm test'],
100
+ evidence: ['related'],
101
+ commitSha: 'commit-rel',
102
+ confidence: 0.9,
103
+ }, testCwd);
104
+ const related = await findRelatedSemanticQueryCache('task priority persistence service layer', 'commit-rel', testCwd);
105
+ expect(related).not.toBeNull();
106
+ expect(related?.entry.editScope).toContain('services/task.ts');
107
+ // Same generic stem, different distinctive noun → reject
108
+ await setSemanticQueryCache('payment service billing', 'commit-rel', {
109
+ query: 'payment service billing',
110
+ resolvedOwner: 'pay',
111
+ editScope: ['services/pay.ts'],
112
+ validationScope: [],
113
+ evidence: [],
114
+ commitSha: 'commit-rel',
115
+ confidence: 0.9,
116
+ }, testCwd);
117
+ const crossDomain = await findRelatedSemanticQueryCache('task service billing', 'commit-rel', testCwd);
118
+ expect(crossDomain).toBeNull();
119
+ const otherCommit = await findRelatedSemanticQueryCache('task priority persistence service layer', 'commit-other', testCwd);
120
+ expect(otherCommit).toBeNull();
121
+ });
122
+ it('filters missing edit-scope files for quality', async () => {
123
+ await fs.writeFile(path.join(testCwd, 'alive.ts'), 'export const ok = 1;\n');
124
+ const result = await filterExistingEditScope(['alive.ts', 'gone.ts'], testCwd);
125
+ expect(result.valid).toEqual(['alive.ts']);
126
+ expect(result.missing).toEqual(['gone.ts']);
127
+ });
92
128
  it('Layer 1: invalidates static cache when file content changes', async () => {
93
129
  const originalContent = 'export function processTask() { return true; }';
94
130
  const entry = {
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export * from './utils/logger.js';
12
12
  export { FileScanner } from './utils/scanner.js';
13
13
  export * from './services/score-history.js';
14
14
  export * from './hooks/index.js';
15
- export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey, getCursorApiKey, updateCursorApiKey } from './settings.js';
15
+ export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey, getCursorApiKey, isCursorApiKeyConfigured, getCursorApiKeyHint, updateCursorApiKey, removeCursorApiKey, } from './settings.js';
16
16
  export type { RigourSettings, ResolvedDeepOptions, CLIDeepOptions } from './settings.js';
17
17
  export { DeepAnalysisGate } from './gates/deep-analysis.js';
18
18
  export { createProvider } from './inference/index.js';
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { FileScanner } from './utils/scanner.js';
13
13
  export * from './services/score-history.js';
14
14
  export * from './hooks/index.js';
15
15
  // Settings Module (Global user config at ~/.rigour/settings.json)
16
- export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey, getCursorApiKey, updateCursorApiKey } from './settings.js';
16
+ export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey, getCursorApiKey, isCursorApiKeyConfigured, getCursorApiKeyHint, updateCursorApiKey, removeCursorApiKey, } from './settings.js';
17
17
  // Deep Analysis Pipeline (v4.0+)
18
18
  export { DeepAnalysisGate } from './gates/deep-analysis.js';
19
19
  export { createProvider } from './inference/index.js';
@@ -3,6 +3,7 @@
3
3
  * Calculates exact observed usage, avoided tokens, cache performance, and cost breakdowns.
4
4
  * Supports CSV/JSON Cursor usage imports.
5
5
  */
6
+ import { type CursorUsageSyncOptions } from './cursor-usage-client.js';
6
7
  /** Heuristic token counter (~4 chars per token for code/text) */
7
8
  export declare function estimateTokenCount(text: string): number;
8
9
  /** Default session token budgets */
@@ -26,11 +27,14 @@ export interface TaskCostStats {
26
27
  costUsd: number;
27
28
  source: string;
28
29
  isEstimated: boolean;
30
+ pricingBasis?: string;
29
31
  };
30
32
  estimated: {
31
33
  potentialContextAvoided: number;
32
34
  estimatedCostAvoidedUsd: number;
33
35
  isEstimated: boolean;
36
+ inputPricePerMillionUsd?: number;
37
+ pricingBasis?: string;
34
38
  };
35
39
  }
36
40
  export interface CachePerformanceStats {
@@ -106,11 +110,17 @@ export declare function importCursorUsageCsv(csvContent: string, cwd?: string):
106
110
  * Import Cursor usage data from JSON format (e.g. from Cursor Admin API).
107
111
  */
108
112
  export declare function importCursorUsageJson(jsonData: unknown, cwd?: string): Promise<number>;
113
+ export interface CursorAdminSyncResult {
114
+ importedCount: number;
115
+ totalEvents: number;
116
+ }
109
117
  /**
110
- * Fetch Cursor usage from Admin API (stub — requires API key in settings).
111
- * Returns number of records imported, or 0 if unavailable.
118
+ * Fetch Cursor usage from Admin API and import token-based events.
119
+ * Costs are reported in cents by Cursor (`chargedCents`) and converted to USD.
112
120
  */
113
- export declare function fetchCursorUsageFromAdminApi(cwd?: string): Promise<number>;
121
+ export declare function fetchCursorUsageFromAdminApi(cwdOrOptions?: string | CursorUsageSyncOptions): Promise<number>;
122
+ export declare function syncCursorUsageFromAdminApi(cwdOrOptions?: string | CursorUsageSyncOptions): Promise<CursorAdminSyncResult>;
123
+ export declare function countCursorAdminImportedEvents(cwd?: string): Promise<number>;
114
124
  /**
115
125
  * Summarize agent scope overlap and governance rule overhead for Studio.
116
126
  */
@@ -8,20 +8,15 @@ import fs from 'fs-extra';
8
8
  import path from 'path';
9
9
  import { getContextEvents, getModelUsages, getCheckpointMetrics, recordModelUsage, } from '../storage/index.js';
10
10
  import { getCursorApiKey } from '../settings.js';
11
+ import { estimateAvoidedContextCostUsd, estimateTokenCostUsd, computeWeightedInputPricePerMillion, } from './model-pricing.js';
12
+ import { fetchCursorUsagePages } from './cursor-usage-client.js';
13
+ import { CURSOR_ADMIN_API_SOURCE, centsToUsd, isCursorAdminApiEvent, normalizeCursorUsageEvent } from './cursor-usage-normalizer.js';
11
14
  /** Heuristic token counter (~4 chars per token for code/text) */
12
15
  export function estimateTokenCount(text) {
13
16
  if (!text)
14
17
  return 0;
15
18
  return Math.ceil(text.length / 4);
16
19
  }
17
- /** Default Model Input Rates (per million tokens) */
18
- const DEFAULT_MODEL_PRICES = {
19
- 'claude-3-5-sonnet': 3.0,
20
- 'gpt-4o': 2.5,
21
- 'gpt-4o-mini': 0.15,
22
- 'o1-preview': 15.0,
23
- 'default': 3.0,
24
- };
25
20
  /** Default session token budgets */
26
21
  export const DEFAULT_SESSION_SOFT_LIMIT = 160_000;
27
22
  export const DEFAULT_SESSION_HARD_LIMIT = 200_000;
@@ -85,32 +80,46 @@ export async function getTaskCostStats(taskId, cwd) {
85
80
  const stats = await getTaskContextStats(taskId, cwd);
86
81
  let actualInputTokens = 0;
87
82
  let actualOutputTokens = 0;
88
- let actualCostUsd = 0;
83
+ let observedCostUsd = 0;
89
84
  let primarySource = 'estimated';
90
85
  const hasObservedUsage = usages.length > 0;
91
86
  for (const u of usages) {
92
87
  actualInputTokens += u.inputTokens || 0;
93
88
  actualOutputTokens += u.outputTokens || 0;
94
- actualCostUsd += u.observedCostUsd || 0;
89
+ observedCostUsd += u.observedCostUsd || 0;
95
90
  if (u.source && u.source !== 'estimated') {
96
91
  primarySource = u.source;
97
92
  }
98
93
  }
99
- const pricePerM = DEFAULT_MODEL_PRICES['claude-3-5-sonnet'] || 3.0;
94
+ const hasObservedCost = observedCostUsd > 0;
95
+ let actualCostUsd = observedCostUsd;
96
+ let actualIsEstimated = !hasObservedUsage;
97
+ let actualPricingBasis;
98
+ if (!hasObservedCost && hasObservedUsage) {
99
+ actualCostUsd = usages.reduce((sum, usage) => sum + estimateTokenCostUsd(usage.inputTokens || 0, usage.outputTokens || 0, usage.model), 0);
100
+ actualIsEstimated = true;
101
+ actualPricingBasis = computeWeightedInputPricePerMillion(usages).pricingBasis;
102
+ }
103
+ else if (hasObservedCost) {
104
+ actualPricingBasis = 'observed-cursor-charged-cents';
105
+ }
100
106
  const totalPotentialAvoided = stats.potentialAvoidedTokens + stats.checkpointReplayAvoided;
101
- const estimatedCostAvoidedUsd = parseFloat(((totalPotentialAvoided / 1_000_000) * pricePerM).toFixed(2));
107
+ const avoidedPricing = estimateAvoidedContextCostUsd(totalPotentialAvoided, usages);
102
108
  return {
103
109
  actual: {
104
110
  inputTokens: actualInputTokens,
105
111
  outputTokens: actualOutputTokens,
106
- costUsd: parseFloat(actualCostUsd.toFixed(2)),
112
+ costUsd: parseFloat(actualCostUsd.toFixed(4)),
107
113
  source: primarySource,
108
- isEstimated: !hasObservedUsage,
114
+ isEstimated: actualIsEstimated,
115
+ pricingBasis: actualPricingBasis,
109
116
  },
110
117
  estimated: {
111
118
  potentialContextAvoided: totalPotentialAvoided,
112
- estimatedCostAvoidedUsd,
119
+ estimatedCostAvoidedUsd: avoidedPricing.costUsd,
113
120
  isEstimated: true,
121
+ inputPricePerMillionUsd: avoidedPricing.pricing.inputPricePerMillionUsd,
122
+ pricingBasis: avoidedPricing.pricing.pricingBasis,
114
123
  },
115
124
  };
116
125
  }
@@ -274,38 +283,95 @@ export async function importCursorUsageJson(jsonData, cwd) {
274
283
  const records = Array.isArray(jsonData)
275
284
  ? jsonData
276
285
  : (payload.records || payload.usage || [jsonData]);
286
+ const existingUsages = await getModelUsages(undefined, cwd);
287
+ const seenIds = new Set(existingUsages.map(u => u.id).filter(Boolean));
277
288
  let importedCount = 0;
278
289
  for (const r of records) {
290
+ const normalized = isCursorAdminApiEvent(r) ? normalizeCursorUsageEvent(r) : null;
291
+ if (normalized) {
292
+ if (seenIds.has(normalized.id))
293
+ continue;
294
+ seenIds.add(normalized.id);
295
+ await recordModelUsage(normalized.usage, cwd);
296
+ importedCount++;
297
+ continue;
298
+ }
299
+ const row = r;
300
+ const id = row.id
301
+ ?? `json-${hashCsvRow(JSON.stringify(row))}`;
302
+ if (seenIds.has(id))
303
+ continue;
304
+ seenIds.add(id);
305
+ const tokenUsage = row.tokenUsage;
306
+ const chargedCents = Number(row.chargedCents ?? tokenUsage?.totalCents ?? 0);
307
+ const observedFromRow = Number(row.observedCostUsd ?? row.cost_usd ?? row.cost ?? 0);
308
+ const observedCostUsd = observedFromRow > 0
309
+ ? observedFromRow
310
+ : chargedCents > 0
311
+ ? centsToUsd(chargedCents)
312
+ : 0;
279
313
  await recordModelUsage({
280
- taskId: (r.taskId || r.task_id || 'cursor-imported'),
281
- sessionId: (r.sessionId || r.session_id),
282
- agentId: (r.agentId || r.agent_id),
283
- provider: (r.provider || 'cursor'),
284
- model: (r.model || 'claude-3-5-sonnet'),
285
- inputTokens: (r.inputTokens || r.input_tokens || 0),
286
- outputTokens: (r.outputTokens || r.output_tokens || 0),
287
- cachedInputTokens: (r.cachedInputTokens || r.cached_input_tokens || 0),
288
- observedCostUsd: (r.observedCostUsd || r.cost_usd || r.cost || 0),
289
- source: 'cursor-admin-api',
314
+ id,
315
+ taskId: (row.taskId || row.task_id || 'cursor-imported'),
316
+ sessionId: (row.sessionId || row.session_id),
317
+ agentId: (row.agentId || row.agent_id),
318
+ provider: (row.provider || 'cursor'),
319
+ model: (row.model || 'claude-3-5-sonnet'),
320
+ inputTokens: (row.inputTokens || row.input_tokens || tokenUsage?.inputTokens || 0),
321
+ outputTokens: (row.outputTokens || row.output_tokens || tokenUsage?.outputTokens || 0),
322
+ cachedInputTokens: (row.cachedInputTokens || row.cached_input_tokens || row.cacheReadTokens || tokenUsage?.cacheReadTokens || 0),
323
+ cacheWriteTokens: (row.cacheWriteTokens || row.cache_write_tokens || tokenUsage?.cacheWriteTokens || 0),
324
+ observedCostUsd,
325
+ source: row.source || 'cursor-admin-api',
326
+ createdAt: row.createdAt ? Number(row.createdAt) : undefined,
290
327
  }, cwd);
291
328
  importedCount++;
292
329
  }
293
330
  return importedCount;
294
331
  }
332
+ async function persistCursorUsageEvents(events, cwd) {
333
+ const existingUsages = await getModelUsages(undefined, cwd);
334
+ const seenIds = new Set(existingUsages.map(u => u.id).filter(Boolean));
335
+ let importedCount = 0;
336
+ for (const event of events) {
337
+ if (seenIds.has(event.id))
338
+ continue;
339
+ seenIds.add(event.id);
340
+ await recordModelUsage(event.usage, cwd);
341
+ importedCount++;
342
+ }
343
+ return importedCount;
344
+ }
295
345
  /**
296
- * Fetch Cursor usage from Admin API (stub — requires API key in settings).
297
- * Returns number of records imported, or 0 if unavailable.
346
+ * Fetch Cursor usage from Admin API and import token-based events.
347
+ * Costs are reported in cents by Cursor (`chargedCents`) and converted to USD.
298
348
  */
299
- export async function fetchCursorUsageFromAdminApi(cwd) {
300
- const apiKey = getCursorApiKey();
349
+ export async function fetchCursorUsageFromAdminApi(cwdOrOptions) {
350
+ const options = typeof cwdOrOptions === 'string'
351
+ ? { cwd: cwdOrOptions }
352
+ : (cwdOrOptions ?? {});
353
+ const apiKey = options.apiKey ?? getCursorApiKey();
301
354
  if (!apiKey) {
302
355
  return 0;
303
356
  }
304
- // Stub: Cursor Admin API integration pending official endpoint documentation.
305
- // When available, fetch usage records and delegate to importCursorUsageJson.
306
- void apiKey;
307
- void cwd;
308
- return 0;
357
+ const events = await fetchCursorUsagePages(options);
358
+ return persistCursorUsageEvents(events, options.cwd);
359
+ }
360
+ export async function syncCursorUsageFromAdminApi(cwdOrOptions) {
361
+ const options = typeof cwdOrOptions === 'string'
362
+ ? { cwd: cwdOrOptions }
363
+ : (cwdOrOptions ?? {});
364
+ const apiKey = options.apiKey ?? getCursorApiKey();
365
+ if (!apiKey) {
366
+ return { importedCount: 0, totalEvents: 0 };
367
+ }
368
+ const events = await fetchCursorUsagePages(options);
369
+ const importedCount = await persistCursorUsageEvents(events, options.cwd);
370
+ return { importedCount, totalEvents: events.length };
371
+ }
372
+ export async function countCursorAdminImportedEvents(cwd) {
373
+ const usages = await getModelUsages(undefined, cwd);
374
+ return usages.filter(u => u.source === CURSOR_ADMIN_API_SOURCE).length;
309
375
  }
310
376
  /**
311
377
  * Summarize agent scope overlap and governance rule overhead for Studio.
@@ -95,6 +95,20 @@ describe('context-telemetry-service', () => {
95
95
  const cost = await getTaskCostStats(jsonTaskId, testCwd);
96
96
  expect(cost.actual.inputTokens).toBe(2500);
97
97
  });
98
+ it('imports Admin API JSON events with chargedCents and dedupes against sync IDs', async () => {
99
+ const adminEvent = {
100
+ timestamp: '1750979225854',
101
+ conversationId: 'conv-dedupe',
102
+ model: 'composer-2.5-standard',
103
+ chargedCents: 42.5,
104
+ tokenUsage: { inputTokens: 100, outputTokens: 50 },
105
+ };
106
+ expect(await importCursorUsageJson([adminEvent], testCwd)).toBe(1);
107
+ expect(await importCursorUsageJson([adminEvent], testCwd)).toBe(0);
108
+ const cost = await getTaskCostStats('conv-dedupe', testCwd);
109
+ expect(cost.actual.costUsd).toBeCloseTo(0.425, 3);
110
+ expect(cost.actual.isEstimated).toBe(false);
111
+ });
98
112
  it('summarizes agent scope overlap from session file', async () => {
99
113
  await fs.writeJson(path.join(testCwd, '.rigour', 'agent-session.json'), {
100
114
  agents: [
@@ -127,4 +141,29 @@ describe('context-telemetry-service', () => {
127
141
  expect(summary.compressionRatio).toBe(20);
128
142
  expect(summary.replayTokensAvoided).toBe(9500);
129
143
  });
144
+ it('marks observed cost from usages and estimates avoided cost by model', async () => {
145
+ const jsonTaskId = `${taskId}-composer-${Math.random().toString(36).slice(2)}`;
146
+ await importCursorUsageJson([{
147
+ id: `json-composer-${jsonTaskId}`,
148
+ taskId: jsonTaskId,
149
+ inputTokens: 2_000_000,
150
+ outputTokens: 0,
151
+ observedCostUsd: 1.25,
152
+ model: 'composer-2.5-standard',
153
+ source: 'cursor-admin-api',
154
+ }], testCwd);
155
+ await recordContextEvent({
156
+ taskId: jsonTaskId,
157
+ toolName: 'rigour_context_scope',
158
+ cacheStatus: 'miss',
159
+ candidateTokens: 3_000_000,
160
+ returnedTokens: 1_000_000,
161
+ }, testCwd);
162
+ const cost = await getTaskCostStats(jsonTaskId, testCwd);
163
+ expect(cost.actual.isEstimated).toBe(false);
164
+ expect(cost.actual.costUsd).toBe(1.25);
165
+ expect(cost.estimated.isEstimated).toBe(true);
166
+ expect(cost.estimated.inputPricePerMillionUsd).toBe(0.5);
167
+ expect(cost.estimated.estimatedCostAvoidedUsd).toBeGreaterThan(0);
168
+ });
130
169
  });
@@ -0,0 +1,19 @@
1
+ import { type NormalizedCursorUsageEvent } from './cursor-usage-normalizer.js';
2
+ export declare const CURSOR_FILTERED_USAGE_URL = "https://api.cursor.com/teams/filtered-usage-events";
3
+ export declare const DEFAULT_CURSOR_SYNC_DAYS = 30;
4
+ export declare const DEFAULT_CURSOR_PAGE_SIZE = 100;
5
+ export declare const MAX_CURSOR_SYNC_PAGES = 50;
6
+ export interface CursorUsageSyncOptions {
7
+ cwd?: string;
8
+ apiKey?: string;
9
+ startDate?: number;
10
+ endDate?: number;
11
+ pageSize?: number;
12
+ maxPages?: number;
13
+ fetchImpl?: typeof fetch;
14
+ }
15
+ export declare class CursorUsageApiError extends Error {
16
+ readonly status: number;
17
+ constructor(status: number, message: string);
18
+ }
19
+ export declare function fetchCursorUsagePages(options?: CursorUsageSyncOptions): Promise<NormalizedCursorUsageEvent[]>;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Cursor Admin API client for filtered usage events.
3
+ */
4
+ import { getCursorApiKey } from '../settings.js';
5
+ import { normalizeCursorUsageEvent, parseCursorUsageApiResponse, } from './cursor-usage-normalizer.js';
6
+ export const CURSOR_FILTERED_USAGE_URL = 'https://api.cursor.com/teams/filtered-usage-events';
7
+ export const DEFAULT_CURSOR_SYNC_DAYS = 30;
8
+ export const DEFAULT_CURSOR_PAGE_SIZE = 100;
9
+ export const MAX_CURSOR_SYNC_PAGES = 50;
10
+ export class CursorUsageApiError extends Error {
11
+ status;
12
+ constructor(status, message) {
13
+ super(message);
14
+ this.name = 'CursorUsageApiError';
15
+ this.status = status;
16
+ }
17
+ }
18
+ function buildBasicAuthHeader(apiKey) {
19
+ const token = Buffer.from(`${apiKey}:`, 'utf8').toString('base64');
20
+ return `Basic ${token}`;
21
+ }
22
+ function defaultDateRange() {
23
+ const endDate = Date.now();
24
+ const startDate = endDate - DEFAULT_CURSOR_SYNC_DAYS * 24 * 60 * 60 * 1000;
25
+ return { startDate, endDate };
26
+ }
27
+ export async function fetchCursorUsagePages(options = {}) {
28
+ const apiKey = options.apiKey ?? getCursorApiKey();
29
+ if (!apiKey) {
30
+ return [];
31
+ }
32
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
33
+ if (!fetchImpl) {
34
+ throw new CursorUsageApiError(0, 'Fetch is not available in this environment');
35
+ }
36
+ const range = defaultDateRange();
37
+ const startDate = options.startDate ?? range.startDate;
38
+ const endDate = options.endDate ?? range.endDate;
39
+ const pageSize = options.pageSize ?? DEFAULT_CURSOR_PAGE_SIZE;
40
+ const maxPages = options.maxPages ?? MAX_CURSOR_SYNC_PAGES;
41
+ const events = [];
42
+ let page = 1;
43
+ let hasNextPage = true;
44
+ while (hasNextPage && page <= maxPages) {
45
+ const response = await fetchImpl(CURSOR_FILTERED_USAGE_URL, {
46
+ method: 'POST',
47
+ headers: {
48
+ 'Content-Type': 'application/json',
49
+ Authorization: buildBasicAuthHeader(apiKey),
50
+ },
51
+ body: JSON.stringify({ startDate, endDate, page, pageSize }),
52
+ });
53
+ if (!response.ok) {
54
+ const detail = await response.text().catch(() => '');
55
+ const safeDetail = detail.replace(apiKey, '[REDACTED]').slice(0, 200);
56
+ throw new CursorUsageApiError(response.status, `Cursor Admin API request failed (${response.status}). ${safeDetail || 'Check API key scope and date range.'}`);
57
+ }
58
+ const json = await response.json();
59
+ const parsed = parseCursorUsageApiResponse(json);
60
+ for (const rawEvent of parsed.usageEvents ?? []) {
61
+ const normalized = normalizeCursorUsageEvent(rawEvent);
62
+ if (normalized) {
63
+ events.push(normalized);
64
+ }
65
+ }
66
+ hasNextPage = Boolean(parsed.pagination?.hasNextPage);
67
+ page += 1;
68
+ }
69
+ if (hasNextPage) {
70
+ throw new CursorUsageApiError(413, `Cursor usage sync truncated after ${maxPages} pages. Narrow the date range or contact support.`);
71
+ }
72
+ return events;
73
+ }
@@ -0,0 +1,24 @@
1
+ import type { ModelUsage } from '../storage/context-telemetry.js';
2
+ export declare const CURSOR_ADMIN_API_SOURCE = "cursor-admin-api";
3
+ export interface NormalizedCursorUsageEvent {
4
+ id: string;
5
+ usage: ModelUsage;
6
+ }
7
+ export interface CursorUsageApiResponse {
8
+ totalUsageEventsCount?: number;
9
+ usageEvents?: unknown[];
10
+ pagination?: {
11
+ hasNextPage?: boolean;
12
+ currentPage?: number;
13
+ numPages?: number;
14
+ pageSize?: number;
15
+ };
16
+ }
17
+ export declare function isCursorAdminApiEvent(value: unknown): value is Record<string, unknown>;
18
+ /**
19
+ * Cursor reports costs in cents (`chargedCents`, `tokenUsage.totalCents`).
20
+ */
21
+ export declare function centsToUsd(cents: number): number;
22
+ export declare function buildCursorEventFingerprint(event: Record<string, unknown>): string;
23
+ export declare function normalizeCursorUsageEvent(raw: unknown): NormalizedCursorUsageEvent | null;
24
+ export declare function parseCursorUsageApiResponse(body: unknown): CursorUsageApiResponse;