@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.
- package/dist/context/cache-engine.d.ts +25 -0
- package/dist/context/cache-engine.js +79 -1
- package/dist/context/cache-engine.test.js +37 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/services/context-telemetry-service.d.ts +13 -3
- package/dist/services/context-telemetry-service.js +100 -34
- package/dist/services/context-telemetry-service.test.js +39 -0
- package/dist/services/cursor-usage-client.d.ts +19 -0
- package/dist/services/cursor-usage-client.js +73 -0
- package/dist/services/cursor-usage-normalizer.d.ts +24 -0
- package/dist/services/cursor-usage-normalizer.js +102 -0
- package/dist/services/cursor-usage-sync.test.d.ts +1 -0
- package/dist/services/cursor-usage-sync.test.js +173 -0
- package/dist/services/model-pricing.d.ts +36 -0
- package/dist/services/model-pricing.js +137 -0
- package/dist/services/model-pricing.test.d.ts +1 -0
- package/dist/services/model-pricing.test.js +44 -0
- package/dist/settings.d.ts +15 -2
- package/dist/settings.js +56 -6
- package/dist/storage/context-telemetry.d.ts +9 -0
- package/dist/storage/context-telemetry.js +61 -0
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.js +1 -1
- package/package.json +6 -6
|
@@ -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
|
|
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
|
|
111
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
89
|
+
observedCostUsd += u.observedCostUsd || 0;
|
|
95
90
|
if (u.source && u.source !== 'estimated') {
|
|
96
91
|
primarySource = u.source;
|
|
97
92
|
}
|
|
98
93
|
}
|
|
99
|
-
const
|
|
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
|
|
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(
|
|
112
|
+
costUsd: parseFloat(actualCostUsd.toFixed(4)),
|
|
107
113
|
source: primarySource,
|
|
108
|
-
isEstimated:
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
297
|
-
*
|
|
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(
|
|
300
|
-
const
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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;
|