@rigour-labs/core 5.2.9 → 5.3.1
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 +75 -0
- package/dist/context/cache-engine.js +155 -0
- package/dist/context/cache-engine.test.d.ts +1 -0
- package/dist/context/cache-engine.test.js +107 -0
- package/dist/context/context-session.d.ts +25 -0
- package/dist/context/context-session.js +78 -0
- package/dist/context/index-bridge.d.ts +24 -0
- package/dist/context/index-bridge.js +114 -0
- package/dist/hooks/dlp-confidence.d.ts +16 -0
- package/dist/hooks/dlp-confidence.js +302 -0
- package/dist/hooks/dlp-confidence.test.d.ts +1 -0
- package/dist/hooks/dlp-confidence.test.js +51 -0
- package/dist/hooks/dlp-feedback.d.ts +48 -0
- package/dist/hooks/dlp-feedback.js +174 -0
- package/dist/hooks/dlp-feedback.test.d.ts +1 -0
- package/dist/hooks/dlp-feedback.test.js +39 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/input-validator.d.ts +8 -0
- package/dist/hooks/input-validator.js +125 -82
- package/dist/hooks/input-validator.test.js +92 -34
- package/dist/index.d.ts +6 -1
- package/dist/index.js +7 -1
- package/dist/services/context-telemetry-service.d.ts +121 -0
- package/dist/services/context-telemetry-service.js +375 -0
- package/dist/services/context-telemetry-service.test.d.ts +1 -0
- package/dist/services/context-telemetry-service.test.js +130 -0
- package/dist/settings.d.ts +11 -0
- package/dist/settings.js +18 -0
- package/dist/storage/context-telemetry.d.ts +83 -0
- package/dist/storage/context-telemetry.js +313 -0
- package/dist/storage/context-telemetry.test.d.ts +1 -0
- package/dist/storage/context-telemetry.test.js +80 -0
- package/dist/storage/db.js +140 -2
- package/dist/storage/index.d.ts +2 -0
- package/dist/storage/index.js +1 -0
- package/package.json +82 -82
- package/LICENSE +0 -21
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-Level Context Telemetry Service
|
|
3
|
+
* Calculates exact observed usage, avoided tokens, cache performance, and cost breakdowns.
|
|
4
|
+
* Supports CSV/JSON Cursor usage imports.
|
|
5
|
+
*/
|
|
6
|
+
/** Heuristic token counter (~4 chars per token for code/text) */
|
|
7
|
+
export declare function estimateTokenCount(text: string): number;
|
|
8
|
+
/** Default session token budgets */
|
|
9
|
+
export declare const DEFAULT_SESSION_SOFT_LIMIT = 160000;
|
|
10
|
+
export declare const DEFAULT_SESSION_HARD_LIMIT = 200000;
|
|
11
|
+
export interface TaskContextStats {
|
|
12
|
+
taskId: string;
|
|
13
|
+
retrievals: number;
|
|
14
|
+
candidateTokens: number;
|
|
15
|
+
returnedTokens: number;
|
|
16
|
+
potentialAvoidedTokens: number;
|
|
17
|
+
cacheHitRate: number;
|
|
18
|
+
repeatedReadsPrevented: number;
|
|
19
|
+
checkpointReplayAvoided: number;
|
|
20
|
+
isEstimated: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface TaskCostStats {
|
|
23
|
+
actual: {
|
|
24
|
+
inputTokens: number;
|
|
25
|
+
outputTokens: number;
|
|
26
|
+
costUsd: number;
|
|
27
|
+
source: string;
|
|
28
|
+
isEstimated: boolean;
|
|
29
|
+
};
|
|
30
|
+
estimated: {
|
|
31
|
+
potentialContextAvoided: number;
|
|
32
|
+
estimatedCostAvoidedUsd: number;
|
|
33
|
+
isEstimated: boolean;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export interface CachePerformanceStats {
|
|
37
|
+
exactCacheHits: number;
|
|
38
|
+
semanticCacheHits: number;
|
|
39
|
+
partialCacheHits: number;
|
|
40
|
+
cacheMisses: number;
|
|
41
|
+
hitRate: number;
|
|
42
|
+
tokensServedFromCache: number;
|
|
43
|
+
recomputationAvoided: number;
|
|
44
|
+
isEstimated: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface SessionBudgetStatus {
|
|
47
|
+
sessionId: string;
|
|
48
|
+
usedTokens: number;
|
|
49
|
+
softLimit: number;
|
|
50
|
+
hardLimit: number;
|
|
51
|
+
softLimitReached: boolean;
|
|
52
|
+
hardLimitReached: boolean;
|
|
53
|
+
remainingTokens: number;
|
|
54
|
+
isEstimated: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface ContextExplainResult {
|
|
57
|
+
fileOrService: string;
|
|
58
|
+
status: 'included' | 'excluded';
|
|
59
|
+
reason: string;
|
|
60
|
+
servedFromCache: boolean;
|
|
61
|
+
cacheStatus: string;
|
|
62
|
+
invalidationReason?: string;
|
|
63
|
+
priorAgentRequests: string[];
|
|
64
|
+
}
|
|
65
|
+
export interface ContextScopeSummary {
|
|
66
|
+
overlappingScopesResolved: number;
|
|
67
|
+
overlappingScopePairs: string[];
|
|
68
|
+
alwaysOnRuleTokens: number;
|
|
69
|
+
unusedToolsFiltered: number;
|
|
70
|
+
activeAgentCount: number;
|
|
71
|
+
}
|
|
72
|
+
export interface CheckpointSummary {
|
|
73
|
+
rawStateTokens: number;
|
|
74
|
+
checkpointTokens: number;
|
|
75
|
+
compressionRatio: number;
|
|
76
|
+
checkpointCount: number;
|
|
77
|
+
replayTokensAvoided: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Generate task-level context telemetry summary.
|
|
81
|
+
*/
|
|
82
|
+
export declare function getTaskContextStats(taskId?: string, cwd?: string): Promise<TaskContextStats>;
|
|
83
|
+
/**
|
|
84
|
+
* Generate task-level cost summary (actual vs estimated avoided).
|
|
85
|
+
*/
|
|
86
|
+
export declare function getTaskCostStats(taskId?: string, cwd?: string): Promise<TaskCostStats>;
|
|
87
|
+
/**
|
|
88
|
+
* Aggregate cache performance across events.
|
|
89
|
+
*/
|
|
90
|
+
export declare function getCacheStats(cwd?: string): Promise<CachePerformanceStats>;
|
|
91
|
+
/**
|
|
92
|
+
* Report session token budget usage against soft and hard limits.
|
|
93
|
+
*/
|
|
94
|
+
export declare function getSessionBudgetStatus(sessionId: string, cwd?: string, softLimit?: number, hardLimit?: number): Promise<SessionBudgetStatus>;
|
|
95
|
+
/**
|
|
96
|
+
* Explain context inclusion/exclusion and cache status for a given target file or query.
|
|
97
|
+
*/
|
|
98
|
+
export declare function explainContext(target: string, taskId?: string, cwd?: string): Promise<ContextExplainResult>;
|
|
99
|
+
/**
|
|
100
|
+
* Import Cursor usage data from CSV format.
|
|
101
|
+
* Expects CSV format with columns: Date,Model,Input Tokens,Output Tokens,Cost
|
|
102
|
+
* Deduplicates rows by content hash before insert.
|
|
103
|
+
*/
|
|
104
|
+
export declare function importCursorUsageCsv(csvContent: string, cwd?: string): Promise<number>;
|
|
105
|
+
/**
|
|
106
|
+
* Import Cursor usage data from JSON format (e.g. from Cursor Admin API).
|
|
107
|
+
*/
|
|
108
|
+
export declare function importCursorUsageJson(jsonData: unknown, cwd?: string): Promise<number>;
|
|
109
|
+
/**
|
|
110
|
+
* Fetch Cursor usage from Admin API (stub — requires API key in settings).
|
|
111
|
+
* Returns number of records imported, or 0 if unavailable.
|
|
112
|
+
*/
|
|
113
|
+
export declare function fetchCursorUsageFromAdminApi(cwd?: string): Promise<number>;
|
|
114
|
+
/**
|
|
115
|
+
* Summarize agent scope overlap and governance rule overhead for Studio.
|
|
116
|
+
*/
|
|
117
|
+
export declare function getContextScopeSummary(cwd?: string): Promise<ContextScopeSummary>;
|
|
118
|
+
/**
|
|
119
|
+
* Aggregate checkpoint compression metrics for Studio.
|
|
120
|
+
*/
|
|
121
|
+
export declare function getCheckpointSummary(taskId?: string, cwd?: string): Promise<CheckpointSummary>;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-Level Context Telemetry Service
|
|
3
|
+
* Calculates exact observed usage, avoided tokens, cache performance, and cost breakdowns.
|
|
4
|
+
* Supports CSV/JSON Cursor usage imports.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
import fs from 'fs-extra';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { getContextEvents, getModelUsages, getCheckpointMetrics, recordModelUsage, } from '../storage/index.js';
|
|
10
|
+
import { getCursorApiKey } from '../settings.js';
|
|
11
|
+
/** Heuristic token counter (~4 chars per token for code/text) */
|
|
12
|
+
export function estimateTokenCount(text) {
|
|
13
|
+
if (!text)
|
|
14
|
+
return 0;
|
|
15
|
+
return Math.ceil(text.length / 4);
|
|
16
|
+
}
|
|
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
|
+
/** Default session token budgets */
|
|
26
|
+
export const DEFAULT_SESSION_SOFT_LIMIT = 160_000;
|
|
27
|
+
export const DEFAULT_SESSION_HARD_LIMIT = 200_000;
|
|
28
|
+
const GOVERNANCE_RULE_FILES = ['AGENTS.md', 'CLAUDE.md', '.cursorrules', '.windsurfrules', '.clinerules'];
|
|
29
|
+
function scopesOverlap(scope1, scope2) {
|
|
30
|
+
const overlapping = [];
|
|
31
|
+
for (const s1 of scope1) {
|
|
32
|
+
for (const s2 of scope2) {
|
|
33
|
+
if (s1 === s2 || s1.startsWith(s2.replace('**', '')) || s2.startsWith(s1.replace('**', ''))) {
|
|
34
|
+
overlapping.push(`${s1} ↔ ${s2}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return overlapping;
|
|
39
|
+
}
|
|
40
|
+
function hashCsvRow(line) {
|
|
41
|
+
return createHash('sha256').update(line.trim()).digest('hex').slice(0, 16);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Generate task-level context telemetry summary.
|
|
45
|
+
*/
|
|
46
|
+
export async function getTaskContextStats(taskId, cwd) {
|
|
47
|
+
const events = await getContextEvents(taskId, cwd);
|
|
48
|
+
const checkpoints = await getCheckpointMetrics(taskId, cwd);
|
|
49
|
+
const retrievals = events.length;
|
|
50
|
+
let candidateTokens = 0;
|
|
51
|
+
let returnedTokens = 0;
|
|
52
|
+
let cacheHits = 0;
|
|
53
|
+
let repeatedReads = 0;
|
|
54
|
+
for (const e of events) {
|
|
55
|
+
candidateTokens += e.candidateTokens || 0;
|
|
56
|
+
returnedTokens += e.returnedTokens || 0;
|
|
57
|
+
if (e.cacheStatus && e.cacheStatus !== 'miss' && e.cacheStatus !== 'none') {
|
|
58
|
+
cacheHits++;
|
|
59
|
+
}
|
|
60
|
+
const deduped = e.deduplicatedTokens ?? 0;
|
|
61
|
+
if (deduped > 0) {
|
|
62
|
+
repeatedReads += Math.ceil(deduped / 500);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const potentialAvoidedTokens = Math.max(0, candidateTokens - returnedTokens);
|
|
66
|
+
const cacheHitRate = retrievals > 0 ? parseFloat((cacheHits / retrievals).toFixed(2)) : 0;
|
|
67
|
+
const checkpointReplayAvoided = checkpoints.reduce((acc, c) => acc + (c.replayTokensAvoided || 0), 0);
|
|
68
|
+
return {
|
|
69
|
+
taskId: taskId || 'global',
|
|
70
|
+
retrievals,
|
|
71
|
+
candidateTokens,
|
|
72
|
+
returnedTokens,
|
|
73
|
+
potentialAvoidedTokens,
|
|
74
|
+
cacheHitRate,
|
|
75
|
+
repeatedReadsPrevented: repeatedReads,
|
|
76
|
+
checkpointReplayAvoided,
|
|
77
|
+
isEstimated: retrievals === 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Generate task-level cost summary (actual vs estimated avoided).
|
|
82
|
+
*/
|
|
83
|
+
export async function getTaskCostStats(taskId, cwd) {
|
|
84
|
+
const usages = await getModelUsages(taskId, cwd);
|
|
85
|
+
const stats = await getTaskContextStats(taskId, cwd);
|
|
86
|
+
let actualInputTokens = 0;
|
|
87
|
+
let actualOutputTokens = 0;
|
|
88
|
+
let actualCostUsd = 0;
|
|
89
|
+
let primarySource = 'estimated';
|
|
90
|
+
const hasObservedUsage = usages.length > 0;
|
|
91
|
+
for (const u of usages) {
|
|
92
|
+
actualInputTokens += u.inputTokens || 0;
|
|
93
|
+
actualOutputTokens += u.outputTokens || 0;
|
|
94
|
+
actualCostUsd += u.observedCostUsd || 0;
|
|
95
|
+
if (u.source && u.source !== 'estimated') {
|
|
96
|
+
primarySource = u.source;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const pricePerM = DEFAULT_MODEL_PRICES['claude-3-5-sonnet'] || 3.0;
|
|
100
|
+
const totalPotentialAvoided = stats.potentialAvoidedTokens + stats.checkpointReplayAvoided;
|
|
101
|
+
const estimatedCostAvoidedUsd = parseFloat(((totalPotentialAvoided / 1_000_000) * pricePerM).toFixed(2));
|
|
102
|
+
return {
|
|
103
|
+
actual: {
|
|
104
|
+
inputTokens: actualInputTokens,
|
|
105
|
+
outputTokens: actualOutputTokens,
|
|
106
|
+
costUsd: parseFloat(actualCostUsd.toFixed(2)),
|
|
107
|
+
source: primarySource,
|
|
108
|
+
isEstimated: !hasObservedUsage,
|
|
109
|
+
},
|
|
110
|
+
estimated: {
|
|
111
|
+
potentialContextAvoided: totalPotentialAvoided,
|
|
112
|
+
estimatedCostAvoidedUsd,
|
|
113
|
+
isEstimated: true,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Aggregate cache performance across events.
|
|
119
|
+
*/
|
|
120
|
+
export async function getCacheStats(cwd) {
|
|
121
|
+
const events = await getContextEvents(undefined, cwd);
|
|
122
|
+
let exactHits = 0;
|
|
123
|
+
let semanticHits = 0;
|
|
124
|
+
let partialHits = 0;
|
|
125
|
+
let misses = 0;
|
|
126
|
+
let tokensServed = 0;
|
|
127
|
+
for (const e of events) {
|
|
128
|
+
if (e.cacheStatus === 'exact-hit') {
|
|
129
|
+
exactHits++;
|
|
130
|
+
tokensServed += e.returnedTokens || 0;
|
|
131
|
+
}
|
|
132
|
+
else if (e.cacheStatus === 'semantic-hit') {
|
|
133
|
+
semanticHits++;
|
|
134
|
+
tokensServed += e.returnedTokens || 0;
|
|
135
|
+
}
|
|
136
|
+
else if (e.cacheStatus === 'partial-hit') {
|
|
137
|
+
partialHits++;
|
|
138
|
+
tokensServed += e.returnedTokens || 0;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
misses++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const totalHits = exactHits + semanticHits + partialHits;
|
|
145
|
+
const totalCalls = totalHits + misses;
|
|
146
|
+
const hitRate = totalCalls > 0 ? parseFloat((totalHits / totalCalls).toFixed(2)) : 0;
|
|
147
|
+
return {
|
|
148
|
+
exactCacheHits: exactHits,
|
|
149
|
+
semanticCacheHits: semanticHits,
|
|
150
|
+
partialCacheHits: partialHits,
|
|
151
|
+
cacheMisses: misses,
|
|
152
|
+
hitRate,
|
|
153
|
+
tokensServedFromCache: tokensServed,
|
|
154
|
+
recomputationAvoided: Math.max(0, tokensServed - 1000),
|
|
155
|
+
isEstimated: events.length === 0,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Report session token budget usage against soft and hard limits.
|
|
160
|
+
*/
|
|
161
|
+
export async function getSessionBudgetStatus(sessionId, cwd, softLimit = DEFAULT_SESSION_SOFT_LIMIT, hardLimit = DEFAULT_SESSION_HARD_LIMIT) {
|
|
162
|
+
const events = await getContextEvents(undefined, cwd);
|
|
163
|
+
const usages = await getModelUsages(undefined, cwd);
|
|
164
|
+
const sessionEvents = events.filter(e => e.sessionId === sessionId);
|
|
165
|
+
const sessionUsages = usages.filter(u => u.sessionId === sessionId);
|
|
166
|
+
let contextTokens = 0;
|
|
167
|
+
for (const e of sessionEvents) {
|
|
168
|
+
contextTokens += e.returnedTokens || 0;
|
|
169
|
+
}
|
|
170
|
+
let modelTokens = 0;
|
|
171
|
+
let hasObserved = sessionUsages.length > 0;
|
|
172
|
+
for (const u of sessionUsages) {
|
|
173
|
+
modelTokens += (u.inputTokens || 0) + (u.outputTokens || 0);
|
|
174
|
+
}
|
|
175
|
+
const usedTokens = modelTokens > 0 ? modelTokens : contextTokens;
|
|
176
|
+
const softLimitReached = usedTokens >= softLimit;
|
|
177
|
+
const hardLimitReached = usedTokens >= hardLimit;
|
|
178
|
+
return {
|
|
179
|
+
sessionId,
|
|
180
|
+
usedTokens,
|
|
181
|
+
softLimit,
|
|
182
|
+
hardLimit,
|
|
183
|
+
softLimitReached,
|
|
184
|
+
hardLimitReached,
|
|
185
|
+
remainingTokens: Math.max(0, hardLimit - usedTokens),
|
|
186
|
+
isEstimated: !hasObserved && sessionEvents.length > 0,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Explain context inclusion/exclusion and cache status for a given target file or query.
|
|
191
|
+
*/
|
|
192
|
+
export async function explainContext(target, taskId, cwd) {
|
|
193
|
+
const events = await getContextEvents(taskId, cwd);
|
|
194
|
+
const matchingEvents = events.filter(e => e.queryHash?.includes(target) || e.toolName.includes(target) || target === 'all');
|
|
195
|
+
const priorAgentRequests = events
|
|
196
|
+
.filter(e => e.agentId)
|
|
197
|
+
.map(e => `${e.agentId} called ${e.toolName} (${e.cacheStatus})`);
|
|
198
|
+
if (matchingEvents.length > 0) {
|
|
199
|
+
const lastEvt = matchingEvents[0];
|
|
200
|
+
const isHit = lastEvt.cacheStatus !== 'miss' && lastEvt.cacheStatus !== 'none';
|
|
201
|
+
const returnedFiles = lastEvt.returnedFiles ?? 0;
|
|
202
|
+
const returnedTokens = lastEvt.returnedTokens ?? 0;
|
|
203
|
+
return {
|
|
204
|
+
fileOrService: target,
|
|
205
|
+
status: returnedFiles > 0 || returnedTokens > 0 ? 'included' : 'excluded',
|
|
206
|
+
reason: isHit
|
|
207
|
+
? `Served from ${lastEvt.cacheStatus} cache (reduced ${lastEvt.candidateTokens - returnedTokens} tokens)`
|
|
208
|
+
: 'Computed dynamically because no active cache entry matched',
|
|
209
|
+
servedFromCache: isHit,
|
|
210
|
+
cacheStatus: lastEvt.cacheStatus,
|
|
211
|
+
invalidationReason: isHit ? undefined : 'Source file or component contract was modified',
|
|
212
|
+
priorAgentRequests: Array.from(new Set(priorAgentRequests)).slice(0, 5),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
fileOrService: target,
|
|
217
|
+
status: 'excluded',
|
|
218
|
+
reason: 'Target was outside of defined task scope and AST dependency subgraph',
|
|
219
|
+
servedFromCache: false,
|
|
220
|
+
cacheStatus: 'miss',
|
|
221
|
+
invalidationReason: 'Not requested in current session scope',
|
|
222
|
+
priorAgentRequests: Array.from(new Set(priorAgentRequests)).slice(0, 5),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Import Cursor usage data from CSV format.
|
|
227
|
+
* Expects CSV format with columns: Date,Model,Input Tokens,Output Tokens,Cost
|
|
228
|
+
* Deduplicates rows by content hash before insert.
|
|
229
|
+
*/
|
|
230
|
+
export async function importCursorUsageCsv(csvContent, cwd) {
|
|
231
|
+
const lines = csvContent.trim().split('\n').filter(Boolean);
|
|
232
|
+
if (lines.length <= 1)
|
|
233
|
+
return 0;
|
|
234
|
+
const headers = lines[0].toLowerCase().split(',').map(h => h.trim().replace(/^"/, '').replace(/"$/, ''));
|
|
235
|
+
const inputIdx = headers.findIndex(h => h.includes('input'));
|
|
236
|
+
const outputIdx = headers.findIndex(h => h.includes('output'));
|
|
237
|
+
const costIdx = headers.findIndex(h => h.includes('cost') || h.includes('spend'));
|
|
238
|
+
const modelIdx = headers.findIndex(h => h.includes('model'));
|
|
239
|
+
const existingUsages = await getModelUsages(undefined, cwd);
|
|
240
|
+
const seenHashes = new Set(existingUsages
|
|
241
|
+
.filter(u => u.id?.startsWith('csv-'))
|
|
242
|
+
.map(u => u.id.slice(4)));
|
|
243
|
+
let importedCount = 0;
|
|
244
|
+
for (let i = 1; i < lines.length; i++) {
|
|
245
|
+
const line = lines[i];
|
|
246
|
+
const rowHash = hashCsvRow(line);
|
|
247
|
+
if (seenHashes.has(rowHash))
|
|
248
|
+
continue;
|
|
249
|
+
seenHashes.add(rowHash);
|
|
250
|
+
const cols = line.split(',').map(c => c.trim().replace(/^"/, '').replace(/"$/, ''));
|
|
251
|
+
const inputTokens = inputIdx >= 0 ? parseInt(cols[inputIdx], 10) || 0 : 0;
|
|
252
|
+
const outputTokens = outputIdx >= 0 ? parseInt(cols[outputIdx], 10) || 0 : 0;
|
|
253
|
+
const observedCostUsd = costIdx >= 0 ? parseFloat(cols[costIdx]) || 0 : 0;
|
|
254
|
+
const model = modelIdx >= 0 ? cols[modelIdx] : 'cursor-model';
|
|
255
|
+
await recordModelUsage({
|
|
256
|
+
id: `csv-${rowHash}`,
|
|
257
|
+
taskId: 'cursor-imported',
|
|
258
|
+
provider: 'cursor',
|
|
259
|
+
model,
|
|
260
|
+
inputTokens,
|
|
261
|
+
outputTokens,
|
|
262
|
+
observedCostUsd,
|
|
263
|
+
source: 'cursor-dashboard-csv',
|
|
264
|
+
}, cwd);
|
|
265
|
+
importedCount++;
|
|
266
|
+
}
|
|
267
|
+
return importedCount;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Import Cursor usage data from JSON format (e.g. from Cursor Admin API).
|
|
271
|
+
*/
|
|
272
|
+
export async function importCursorUsageJson(jsonData, cwd) {
|
|
273
|
+
const payload = jsonData;
|
|
274
|
+
const records = Array.isArray(jsonData)
|
|
275
|
+
? jsonData
|
|
276
|
+
: (payload.records || payload.usage || [jsonData]);
|
|
277
|
+
let importedCount = 0;
|
|
278
|
+
for (const r of records) {
|
|
279
|
+
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',
|
|
290
|
+
}, cwd);
|
|
291
|
+
importedCount++;
|
|
292
|
+
}
|
|
293
|
+
return importedCount;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Fetch Cursor usage from Admin API (stub — requires API key in settings).
|
|
297
|
+
* Returns number of records imported, or 0 if unavailable.
|
|
298
|
+
*/
|
|
299
|
+
export async function fetchCursorUsageFromAdminApi(cwd) {
|
|
300
|
+
const apiKey = getCursorApiKey();
|
|
301
|
+
if (!apiKey) {
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
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;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Summarize agent scope overlap and governance rule overhead for Studio.
|
|
312
|
+
*/
|
|
313
|
+
export async function getContextScopeSummary(cwd) {
|
|
314
|
+
const projectRoot = cwd || process.cwd();
|
|
315
|
+
const sessionPath = path.join(projectRoot, '.rigour', 'agent-session.json');
|
|
316
|
+
let overlappingScopesResolved = 0;
|
|
317
|
+
const overlappingScopePairs = [];
|
|
318
|
+
let activeAgentCount = 0;
|
|
319
|
+
if (await fs.pathExists(sessionPath)) {
|
|
320
|
+
const session = await fs.readJson(sessionPath);
|
|
321
|
+
const agents = session.agents || [];
|
|
322
|
+
activeAgentCount = agents.length;
|
|
323
|
+
for (let i = 0; i < agents.length; i++) {
|
|
324
|
+
for (let j = i + 1; j < agents.length; j++) {
|
|
325
|
+
const overlaps = scopesOverlap(agents[i].taskScope || [], agents[j].taskScope || []);
|
|
326
|
+
if (overlaps.length > 0) {
|
|
327
|
+
overlappingScopesResolved += overlaps.length;
|
|
328
|
+
overlappingScopePairs.push(...overlaps);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
let alwaysOnRuleTokens = 0;
|
|
334
|
+
for (const fileName of GOVERNANCE_RULE_FILES) {
|
|
335
|
+
const filePath = path.join(projectRoot, fileName);
|
|
336
|
+
if (await fs.pathExists(filePath)) {
|
|
337
|
+
alwaysOnRuleTokens += estimateTokenCount(await fs.readFile(filePath, 'utf8'));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const events = await getContextEvents(undefined, cwd);
|
|
341
|
+
const unusedToolsFiltered = events.filter(e => e.toolName.includes('filtered')).length;
|
|
342
|
+
return {
|
|
343
|
+
overlappingScopesResolved,
|
|
344
|
+
overlappingScopePairs: Array.from(new Set(overlappingScopePairs)),
|
|
345
|
+
alwaysOnRuleTokens,
|
|
346
|
+
unusedToolsFiltered,
|
|
347
|
+
activeAgentCount,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Aggregate checkpoint compression metrics for Studio.
|
|
352
|
+
*/
|
|
353
|
+
export async function getCheckpointSummary(taskId, cwd) {
|
|
354
|
+
const metrics = await getCheckpointMetrics(taskId, cwd);
|
|
355
|
+
if (metrics.length === 0) {
|
|
356
|
+
return {
|
|
357
|
+
rawStateTokens: 0,
|
|
358
|
+
checkpointTokens: 0,
|
|
359
|
+
compressionRatio: 0,
|
|
360
|
+
checkpointCount: 0,
|
|
361
|
+
replayTokensAvoided: 0,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const rawStateTokens = metrics.reduce((acc, m) => acc + (m.rawStateTokens || 0), 0);
|
|
365
|
+
const checkpointTokens = metrics.reduce((acc, m) => acc + (m.checkpointTokens || 0), 0);
|
|
366
|
+
const replayTokensAvoided = metrics.reduce((acc, m) => acc + (m.replayTokensAvoided || 0), 0);
|
|
367
|
+
const compressionRatio = checkpointTokens > 0 ? Math.round(rawStateTokens / checkpointTokens) : 0;
|
|
368
|
+
return {
|
|
369
|
+
rawStateTokens,
|
|
370
|
+
checkpointTokens,
|
|
371
|
+
compressionRatio,
|
|
372
|
+
checkpointCount: metrics.length,
|
|
373
|
+
replayTokensAvoided,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
vi.mock('../storage/db.js', () => ({
|
|
6
|
+
isSQLiteAvailable: () => false,
|
|
7
|
+
openDatabase: async () => null,
|
|
8
|
+
DB_PATH: '/tmp/rigour-test.db',
|
|
9
|
+
RIGOUR_DIR: '/tmp/.rigour',
|
|
10
|
+
}));
|
|
11
|
+
import { getTaskContextStats, getTaskCostStats, getCacheStats, importCursorUsageCsv, importCursorUsageJson, getContextScopeSummary, getCheckpointSummary, estimateTokenCount, } from './context-telemetry-service.js';
|
|
12
|
+
import { recordContextEvent, recordCheckpointMetric } from '../storage/context-telemetry.js';
|
|
13
|
+
describe('context-telemetry-service', () => {
|
|
14
|
+
const testCwd = path.join(os.tmpdir(), `rigour-ctx-service-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
15
|
+
const taskId = `task-${Date.now()}`;
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
await fs.ensureDir(path.join(testCwd, '.rigour'));
|
|
18
|
+
});
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await fs.remove(testCwd);
|
|
21
|
+
});
|
|
22
|
+
it('returns zeroed stats when no telemetry exists for task', async () => {
|
|
23
|
+
const stats = await getTaskContextStats(taskId, testCwd);
|
|
24
|
+
expect(stats.retrievals).toBe(0);
|
|
25
|
+
expect(stats.candidateTokens).toBe(0);
|
|
26
|
+
expect(stats.potentialAvoidedTokens).toBe(0);
|
|
27
|
+
expect(stats.isEstimated).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it('calculates avoided context from recorded events', async () => {
|
|
30
|
+
await recordContextEvent({
|
|
31
|
+
taskId,
|
|
32
|
+
toolName: 'rigour_check_pattern',
|
|
33
|
+
cacheStatus: 'exact-hit',
|
|
34
|
+
candidateTokens: 10000,
|
|
35
|
+
returnedTokens: 2000,
|
|
36
|
+
deduplicatedTokens: 3000,
|
|
37
|
+
}, testCwd);
|
|
38
|
+
const stats = await getTaskContextStats(taskId, testCwd);
|
|
39
|
+
expect(stats.retrievals).toBe(1);
|
|
40
|
+
expect(stats.potentialAvoidedTokens).toBe(8000);
|
|
41
|
+
expect(stats.repeatedReadsPrevented).toBeGreaterThan(0);
|
|
42
|
+
expect(stats.isEstimated).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
it('aggregates cache performance from events', async () => {
|
|
45
|
+
const cacheTaskId = `${taskId}-cache`;
|
|
46
|
+
await recordContextEvent({
|
|
47
|
+
taskId: cacheTaskId,
|
|
48
|
+
toolName: 'rigour_recall',
|
|
49
|
+
cacheStatus: 'exact-hit',
|
|
50
|
+
candidateTokens: 1000,
|
|
51
|
+
returnedTokens: 200,
|
|
52
|
+
}, testCwd);
|
|
53
|
+
await recordContextEvent({
|
|
54
|
+
taskId: cacheTaskId,
|
|
55
|
+
toolName: 'rigour_context_explain',
|
|
56
|
+
cacheStatus: 'miss',
|
|
57
|
+
candidateTokens: 500,
|
|
58
|
+
returnedTokens: 500,
|
|
59
|
+
}, testCwd);
|
|
60
|
+
const events = await import('../storage/context-telemetry.js').then(m => m.getContextEvents(cacheTaskId, testCwd));
|
|
61
|
+
const cache = await getCacheStats(testCwd);
|
|
62
|
+
const taskHits = events.filter(e => e.cacheStatus === 'exact-hit').length;
|
|
63
|
+
const taskMisses = events.filter(e => e.cacheStatus === 'miss').length;
|
|
64
|
+
expect(taskHits).toBe(1);
|
|
65
|
+
expect(taskMisses).toBe(1);
|
|
66
|
+
expect(cache.hitRate).toBe(0.5);
|
|
67
|
+
expect(cache.isEstimated).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
it('imports CSV usage and marks cost stats as observed', async () => {
|
|
70
|
+
const csv = `Date,Model,Input Tokens,Output Tokens,Cost
|
|
71
|
+
2026-08-05,claude-3-5-sonnet,510000,62000,4.83`;
|
|
72
|
+
const imported = await importCursorUsageCsv(csv, testCwd);
|
|
73
|
+
expect(imported).toBe(1);
|
|
74
|
+
const cost = await getTaskCostStats('cursor-imported', testCwd);
|
|
75
|
+
expect(cost.actual.inputTokens).toBe(510000);
|
|
76
|
+
expect(cost.actual.costUsd).toBe(4.83);
|
|
77
|
+
expect(cost.actual.isEstimated).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
it('deduplicates CSV rows on re-import', async () => {
|
|
80
|
+
const csv = `Date,Model,Input Tokens,Output Tokens,Cost
|
|
81
|
+
2026-08-05,claude-3-5-sonnet,1000,100,1.00`;
|
|
82
|
+
expect(await importCursorUsageCsv(csv, testCwd)).toBe(1);
|
|
83
|
+
expect(await importCursorUsageCsv(csv, testCwd)).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
it('imports JSON usage records', async () => {
|
|
86
|
+
const jsonTaskId = `${taskId}-json-${Math.random().toString(36).slice(2)}`;
|
|
87
|
+
const imported = await importCursorUsageJson([{
|
|
88
|
+
taskId: jsonTaskId,
|
|
89
|
+
inputTokens: 2500,
|
|
90
|
+
outputTokens: 300,
|
|
91
|
+
observedCostUsd: 0.42,
|
|
92
|
+
model: 'gpt-4o',
|
|
93
|
+
}], testCwd);
|
|
94
|
+
expect(imported).toBe(1);
|
|
95
|
+
const cost = await getTaskCostStats(jsonTaskId, testCwd);
|
|
96
|
+
expect(cost.actual.inputTokens).toBe(2500);
|
|
97
|
+
});
|
|
98
|
+
it('summarizes agent scope overlap from session file', async () => {
|
|
99
|
+
await fs.writeJson(path.join(testCwd, '.rigour', 'agent-session.json'), {
|
|
100
|
+
agents: [
|
|
101
|
+
{ agentId: 'agent-a', taskScope: ['src/api/**'] },
|
|
102
|
+
{ agentId: 'agent-b', taskScope: ['src/api/**', 'tests/**'] },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
const scope = await getContextScopeSummary(testCwd);
|
|
106
|
+
expect(scope.activeAgentCount).toBe(2);
|
|
107
|
+
expect(scope.overlappingScopesResolved).toBeGreaterThan(0);
|
|
108
|
+
});
|
|
109
|
+
it('estimates governance rule token overhead from AGENTS.md', async () => {
|
|
110
|
+
await fs.writeFile(path.join(testCwd, 'AGENTS.md'), '# Agent rules\n'.repeat(40));
|
|
111
|
+
const scope = await getContextScopeSummary(testCwd);
|
|
112
|
+
expect(scope.alwaysOnRuleTokens).toBe(estimateTokenCount('# Agent rules\n'.repeat(40)));
|
|
113
|
+
});
|
|
114
|
+
it('aggregates checkpoint compression metrics', async () => {
|
|
115
|
+
const cpTaskId = `${taskId}-cp`;
|
|
116
|
+
await recordCheckpointMetric({
|
|
117
|
+
checkpointId: `checkpoint:${cpTaskId}:a1:phase`,
|
|
118
|
+
taskId: cpTaskId,
|
|
119
|
+
agentId: 'a1',
|
|
120
|
+
rawStateTokens: 10000,
|
|
121
|
+
checkpointTokens: 500,
|
|
122
|
+
replayTokensAvoided: 9500,
|
|
123
|
+
}, testCwd);
|
|
124
|
+
const summary = await getCheckpointSummary(cpTaskId, testCwd);
|
|
125
|
+
expect(summary.checkpointCount).toBe(1);
|
|
126
|
+
expect(summary.rawStateTokens).toBe(10000);
|
|
127
|
+
expect(summary.compressionRatio).toBe(20);
|
|
128
|
+
expect(summary.replayTokensAvoided).toBe(9500);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/dist/settings.d.ts
CHANGED
|
@@ -33,6 +33,9 @@ export interface RigourSettings {
|
|
|
33
33
|
colorOutput?: boolean;
|
|
34
34
|
verboseOutput?: boolean;
|
|
35
35
|
};
|
|
36
|
+
cursor?: {
|
|
37
|
+
apiKey?: string;
|
|
38
|
+
};
|
|
36
39
|
}
|
|
37
40
|
/**
|
|
38
41
|
* Resolved deep options from CLI flags merged with settings.json
|
|
@@ -102,3 +105,11 @@ export declare function updateProviderKey(provider: string, apiKey: string): voi
|
|
|
102
105
|
* Remove a provider key from settings
|
|
103
106
|
*/
|
|
104
107
|
export declare function removeProviderKey(provider: string): void;
|
|
108
|
+
/**
|
|
109
|
+
* Get Cursor Admin API key from settings
|
|
110
|
+
*/
|
|
111
|
+
export declare function getCursorApiKey(): string | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* Update Cursor Admin API key in settings
|
|
114
|
+
*/
|
|
115
|
+
export declare function updateCursorApiKey(apiKey: string): void;
|
package/dist/settings.js
CHANGED
|
@@ -184,3 +184,21 @@ export function removeProviderKey(provider) {
|
|
|
184
184
|
saveSettings(settings);
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Get Cursor Admin API key from settings
|
|
189
|
+
*/
|
|
190
|
+
export function getCursorApiKey() {
|
|
191
|
+
const settings = loadSettings();
|
|
192
|
+
return settings.cursor?.apiKey;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Update Cursor Admin API key in settings
|
|
196
|
+
*/
|
|
197
|
+
export function updateCursorApiKey(apiKey) {
|
|
198
|
+
const settings = loadSettings();
|
|
199
|
+
if (!settings.cursor) {
|
|
200
|
+
settings.cursor = {};
|
|
201
|
+
}
|
|
202
|
+
settings.cursor.apiKey = apiKey;
|
|
203
|
+
saveSettings(settings);
|
|
204
|
+
}
|