@rigour-labs/mcp 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/index.js CHANGED
@@ -29,6 +29,9 @@ import { handleReview } from './tools/review-handler.js';
29
29
  import { handleHooksCheck, handleHooksInit } from './tools/hooks-handler.js';
30
30
  import { handleCheckDeep, handleDeepStats } from './tools/deep-handlers.js';
31
31
  import { handleMcpGetSettings, handleMcpSetSettings } from './tools/mcp-settings-handler.js';
32
+ import { handleContextStats, handleTaskCost, handleCacheStats, handleContextExplain, handleContextScope } from './tools/context-handlers.js';
33
+ import { handleIndex } from './tools/index-handlers.js';
34
+ import { recordContextEvent, estimateTokenCount } from '@rigour-labs/core';
32
35
  // ─── Server Setup ─────────────────────────────────────────────────
33
36
  const server = new Server({ name: "rigour-mcp", version: "3.0.1" }, { capabilities: { tools: {}, prompts: {}, logging: {}, resources: {} } });
34
37
  // Bind server for logging notifications from handlers
@@ -38,15 +41,21 @@ bindServer(server);
38
41
  // Research shows agents degrade at 30+ tools (wrong picks, hallucinated args).
39
42
  // Power-user tools are still callable — they just aren't advertised in the tool list.
40
43
  const ESSENTIAL_TOOLS = new Set([
41
- 'rigour_check', // Run quality gates (BEFORE declaring done)
42
- 'rigour_check_pattern', // Check if code exists (BEFORE creating new code)
43
44
  'rigour_recall', // Load project memory (START of every task)
45
+ 'rigour_index', // Build/refresh pattern index (if stale)
46
+ 'rigour_context_scope', // Scoped file retrieval BEFORE reading files
47
+ 'rigour_check_pattern', // Check if code exists (BEFORE creating new code)
48
+ 'rigour_check', // Run quality gates (BEFORE declaring done)
44
49
  'rigour_remember', // Store conventions/decisions
45
50
  'rigour_explain', // Explain gate failures
46
51
  'rigour_get_fix_packet', // Get structured fix instructions on FAIL
47
52
  'rigour_review', // Review diffs
48
53
  'rigour_security_audit', // CVE check
49
54
  'rigour_forget', // Remove stored memory
55
+ 'rigour_context_stats', // Context retrieval efficiency & avoided tokens
56
+ 'rigour_task_cost', // Verified vs estimated avoided costs
57
+ 'rigour_cache_stats', // 4-layer cache statistics
58
+ 'rigour_context_explain', // Explain context inclusion/exclusion audit
50
59
  ]);
51
60
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
52
61
  tools: TOOL_DEFINITIONS.filter(t => ESSENTIAL_TOOLS.has(t.name)),
@@ -154,13 +163,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
154
163
  result = await handleAgentRegister(cwd, args.agentId, args.taskScope, requestId);
155
164
  break;
156
165
  case "rigour_checkpoint": {
157
- const { progressPct, filesChanged = [], summary, qualityScore } = args;
158
- result = await handleCheckpoint(cwd, progressPct, filesChanged, summary, qualityScore, requestId);
166
+ const { progressPct, filesChanged = [], summary, qualityScore, agentId, taskId } = args;
167
+ result = await handleCheckpoint(cwd, progressPct, filesChanged, summary, qualityScore, requestId, agentId, taskId);
159
168
  break;
160
169
  }
161
170
  case "rigour_handoff": {
162
- const { fromAgentId, toAgentId, taskDescription, filesInScope = [], context = '' } = args;
163
- result = await handleHandoff(cwd, fromAgentId, toAgentId, taskDescription, filesInScope, context, requestId);
171
+ const { fromAgentId, toAgentId, taskDescription, filesInScope = [], context = '', taskId } = args;
172
+ result = await handleHandoff(cwd, fromAgentId, toAgentId, taskDescription, filesInScope, context, requestId, taskId);
164
173
  break;
165
174
  }
166
175
  case "rigour_agent_deregister":
@@ -189,9 +198,51 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
189
198
  case "rigour_review":
190
199
  result = await handleReview(runner, cwd, args.diff, args.files);
191
200
  break;
201
+ // Context Telemetry & Cost Tools
202
+ case "rigour_context_stats":
203
+ result = await handleContextStats(cwd, args.taskId);
204
+ break;
205
+ case "rigour_task_cost":
206
+ result = await handleTaskCost(cwd, args.taskId);
207
+ break;
208
+ case "rigour_cache_stats":
209
+ result = await handleCacheStats(cwd);
210
+ break;
211
+ case "rigour_context_explain":
212
+ result = await handleContextExplain(cwd, args.target, args.taskId);
213
+ break;
214
+ // Pattern index & scoped context
215
+ case "rigour_index": {
216
+ const { semantic, force, output } = args;
217
+ result = await handleIndex(cwd, { semantic, force, output });
218
+ break;
219
+ }
220
+ case "rigour_context_scope": {
221
+ const { query, limit } = args;
222
+ result = await handleContextScope(cwd, query, limit);
223
+ break;
224
+ }
192
225
  default:
193
226
  throw new Error(`Unknown tool: ${name}`);
194
227
  }
228
+ // ── Context Telemetry Recording (honest — from handler _telemetry metadata) ──
229
+ try {
230
+ const telemetry = result?._telemetry;
231
+ const resultText = Array.isArray(result?.content) ? result.content.map((c) => c.text || '').join('\n') : '';
232
+ const returnedTokens = telemetry?.returnedTokens ?? estimateTokenCount(resultText);
233
+ await recordContextEvent({
234
+ taskId: args?.taskId || args?.task_id,
235
+ agentId: args?.agentId || args?.agent_id,
236
+ toolName: name,
237
+ cacheStatus: telemetry?.cacheStatus ?? 'none',
238
+ candidateTokens: telemetry?.candidateTokens ?? returnedTokens,
239
+ returnedTokens,
240
+ deduplicatedTokens: telemetry?.deduplicatedTokens ?? 0,
241
+ }, cwd);
242
+ }
243
+ catch {
244
+ // non-blocking context telemetry logging
245
+ }
195
246
  // ── Prepend image DLP warning if image content was detected ──
196
247
  if (hasImageContent && Array.isArray(result.content)) {
197
248
  result.content.unshift({
@@ -1,14 +1,6 @@
1
- type ToolResult = {
2
- content: {
3
- type: string;
4
- text: string;
5
- }[];
6
- isError?: boolean;
7
- _shouldContinue?: boolean;
8
- };
1
+ import { type ToolResult } from '../utils/context-telemetry.js';
9
2
  export declare function handleAgentRegister(cwd: string, agentId: string, taskScope: string[], requestId: string): Promise<ToolResult>;
10
- export declare function handleCheckpoint(cwd: string, progressPct: number, filesChanged: string[], summary: string, qualityScore: number, requestId: string): Promise<ToolResult>;
11
- export declare function handleHandoff(cwd: string, fromAgentId: string, toAgentId: string, taskDescription: string, filesInScope: string[], context: string, requestId: string): Promise<ToolResult>;
3
+ export declare function handleCheckpoint(cwd: string, progressPct: number, filesChanged: string[], summary: string, qualityScore: number, requestId: string, agentId?: string, taskId?: string): Promise<ToolResult>;
4
+ export declare function handleHandoff(cwd: string, fromAgentId: string, toAgentId: string, taskDescription: string, filesInScope: string[], context: string, requestId: string, taskId?: string): Promise<ToolResult>;
12
5
  export declare function handleAgentDeregister(cwd: string, agentId: string, requestId: string): Promise<ToolResult>;
13
6
  export declare function handleHandoffAccept(cwd: string, handoffId: string, agentId: string, requestId: string): Promise<ToolResult>;
14
- export {};
@@ -9,6 +9,11 @@
9
9
  import fs from "fs-extra";
10
10
  import path from "path";
11
11
  import { logStudioEvent } from '../utils/config.js';
12
+ import { setTaskCheckpointCache, getTaskCheckpointCache, estimateTokenCount, } from '@rigour-labs/core';
13
+ import { PatternIndexer, savePatternIndex, loadPatternIndex, getDefaultIndexPath, } from '@rigour-labs/core/pattern-index';
14
+ import { buildTelemetryMeta, getWorkspaceCommitSha } from '../utils/context-telemetry.js';
15
+ import { appendContextFooter } from '../utils/context-footer.js';
16
+ const HANDOFF_CONTEXT_TOKEN_LIMIT = 2000;
12
17
  function parseJsonOrNull(raw) {
13
18
  try {
14
19
  return JSON.parse(raw);
@@ -17,6 +22,64 @@ function parseJsonOrNull(raw) {
17
22
  return null;
18
23
  }
19
24
  }
25
+ function globMatchesFile(filePath, glob) {
26
+ const normalized = filePath.replace(/\\/g, '/');
27
+ const pattern = glob
28
+ .replace(/\\/g, '/')
29
+ .replace(/\./g, '\\.')
30
+ .replace(/\*\*/g, '§§')
31
+ .replace(/\*/g, '[^/]*')
32
+ .replace(/§§/g, '.*');
33
+ if (new RegExp(`^${pattern}$`).test(normalized))
34
+ return true;
35
+ const prefix = glob.replace('/**', '').replace('/*', '').replace(/\*/g, '');
36
+ return normalized.startsWith(prefix);
37
+ }
38
+ async function refreshIndexForFiles(cwd, filesChanged) {
39
+ if (filesChanged.length === 0)
40
+ return null;
41
+ const indexPath = getDefaultIndexPath(cwd);
42
+ const existingIndex = await loadPatternIndex(indexPath);
43
+ if (!existingIndex) {
44
+ return 'Index not found — run rigour_index to enable incremental updates.';
45
+ }
46
+ try {
47
+ const indexer = new PatternIndexer(cwd, { useEmbeddings: existingIndex.patterns.some(p => p.embedding?.length) });
48
+ const updated = await indexer.updateIndex(existingIndex);
49
+ await savePatternIndex(updated, indexPath);
50
+ return `Index refreshed for ${filesChanged.length} changed file(s) — ${updated.stats.totalPatterns} patterns total.`;
51
+ }
52
+ catch (error) {
53
+ return `Index refresh failed: ${error.message}`;
54
+ }
55
+ }
56
+ async function validateTaskScopeAgainstIndex(cwd, taskScope) {
57
+ const index = await loadPatternIndex(getDefaultIndexPath(cwd));
58
+ if (!index) {
59
+ return { matchedFiles: [], unmatchedGlobs: taskScope, suggestions: ['Run rigour_index first to bind scope to indexed files.'] };
60
+ }
61
+ const indexedFiles = index.files.map(f => f.path);
62
+ const matchedFiles = [];
63
+ const unmatchedGlobs = [];
64
+ for (const glob of taskScope) {
65
+ const matches = indexedFiles.filter(f => globMatchesFile(f, glob));
66
+ if (matches.length > 0) {
67
+ matchedFiles.push(...matches);
68
+ }
69
+ else {
70
+ unmatchedGlobs.push(glob);
71
+ }
72
+ }
73
+ const uniqueMatched = Array.from(new Set(matchedFiles));
74
+ const suggestions = [];
75
+ if (uniqueMatched.length > 0 && uniqueMatched.length <= 8) {
76
+ suggestions.push(`Minimal scope: ${uniqueMatched.slice(0, 8).join(', ')}`);
77
+ }
78
+ if (unmatchedGlobs.length > 0) {
79
+ suggestions.push(`Unmatched globs (not in index): ${unmatchedGlobs.join(', ')}`);
80
+ }
81
+ return { matchedFiles: uniqueMatched, unmatchedGlobs, suggestions };
82
+ }
20
83
  // ─── Agent Register ───────────────────────────────────────────────
21
84
  export async function handleAgentRegister(cwd, agentId, taskScope, requestId) {
22
85
  const sessionPath = path.join(cwd, '.rigour', 'agent-session.json');
@@ -27,12 +90,17 @@ export async function handleAgentRegister(cwd, agentId, taskScope, requestId) {
27
90
  session = parsed;
28
91
  }
29
92
  }
93
+ const indexBinding = await validateTaskScopeAgainstIndex(cwd, taskScope);
30
94
  const existingIdx = session.agents.findIndex((a) => a.agentId === agentId);
31
95
  if (existingIdx >= 0) {
32
96
  session.agents[existingIdx] = {
33
97
  agentId, taskScope,
34
98
  registeredAt: session.agents[existingIdx].registeredAt,
35
99
  lastCheckpoint: new Date().toISOString(),
100
+ indexBinding: {
101
+ matchedFiles: indexBinding.matchedFiles.length,
102
+ unmatchedGlobs: indexBinding.unmatchedGlobs,
103
+ },
36
104
  };
37
105
  }
38
106
  else {
@@ -40,6 +108,10 @@ export async function handleAgentRegister(cwd, agentId, taskScope, requestId) {
40
108
  agentId, taskScope,
41
109
  registeredAt: new Date().toISOString(),
42
110
  lastCheckpoint: new Date().toISOString(),
111
+ indexBinding: {
112
+ matchedFiles: indexBinding.matchedFiles.length,
113
+ unmatchedGlobs: indexBinding.unmatchedGlobs,
114
+ },
43
115
  });
44
116
  }
45
117
  // Scope conflict detection
@@ -55,17 +127,34 @@ export async function handleAgentRegister(cwd, agentId, taskScope, requestId) {
55
127
  }
56
128
  await fs.ensureDir(path.join(cwd, '.rigour'));
57
129
  await fs.writeFile(sessionPath, JSON.stringify(session, null, 2));
58
- await logStudioEvent(cwd, { type: "agent_registered", requestId, agentId, taskScope, conflicts });
130
+ await logStudioEvent(cwd, { type: "agent_registered", requestId, agentId, taskScope, conflicts, indexBinding });
59
131
  let text = `✅ AGENT REGISTERED: "${agentId}" claimed scope: ${taskScope.join(', ')}\n\n`;
60
132
  text += `Active agents in session: ${session.agents.length}\n`;
133
+ if (indexBinding.matchedFiles.length > 0) {
134
+ text += `\n📎 Index binding: ${indexBinding.matchedFiles.length} indexed file(s) matched.\n`;
135
+ if (indexBinding.suggestions.length > 0) {
136
+ text += indexBinding.suggestions.map(s => ` - ${s}`).join('\n') + '\n';
137
+ }
138
+ }
139
+ else if (indexBinding.suggestions.length > 0) {
140
+ text += `\n⚠️ ${indexBinding.suggestions.join(' ')}\n`;
141
+ }
61
142
  if (conflicts.length > 0) {
62
143
  text += `\n⚠️ SCOPE CONFLICTS DETECTED:\n${conflicts.map(c => ` - ${c}`).join('\n')}\n`;
63
144
  text += `\nConsider coordinating with other agents or narrowing your scope.`;
64
145
  }
65
- return { content: [{ type: "text", text }] };
146
+ const telemetry = buildTelemetryMeta({
147
+ candidateText: taskScope.join(' '),
148
+ returnedText: text,
149
+ cacheStatus: 'none',
150
+ });
151
+ return {
152
+ content: [{ type: "text", text: appendContextFooter(text, telemetry, 'rigour_recall then rigour_context_scope') }],
153
+ _telemetry: telemetry,
154
+ };
66
155
  }
67
156
  // ─── Checkpoint ───────────────────────────────────────────────────
68
- export async function handleCheckpoint(cwd, progressPct, filesChanged, summary, qualityScore, requestId) {
157
+ export async function handleCheckpoint(cwd, progressPct, filesChanged, summary, qualityScore, requestId, agentId = 'default-agent', taskId = 'default-task') {
69
158
  const checkpointPath = path.join(cwd, '.rigour', 'checkpoint-session.json');
70
159
  let session = {
71
160
  sessionId: `chk-session-${Date.now()}`,
@@ -92,6 +181,8 @@ export async function handleCheckpoint(cwd, progressPct, filesChanged, summary,
92
181
  warnings.push(`Drift detected: quality dropped from avg ${avgRecent.toFixed(0)}% to ${qualityScore}%`);
93
182
  }
94
183
  }
184
+ const rawStateText = JSON.stringify(session);
185
+ const phase = `progress-${progressPct}`;
95
186
  session.checkpoints.push({
96
187
  checkpointId,
97
188
  timestamp: new Date().toISOString(),
@@ -99,42 +190,111 @@ export async function handleCheckpoint(cwd, progressPct, filesChanged, summary,
99
190
  });
100
191
  await fs.ensureDir(path.join(cwd, '.rigour'));
101
192
  await fs.writeFile(checkpointPath, JSON.stringify(session, null, 2));
102
- await logStudioEvent(cwd, { type: "checkpoint_recorded", requestId, checkpointId, progressPct, qualityScore, warnings });
193
+ await setTaskCheckpointCache({
194
+ taskId,
195
+ agentId,
196
+ phase,
197
+ component: filesChanged[0] ? path.dirname(filesChanged[0]) : 'workspace',
198
+ changedFiles: filesChanged,
199
+ decisions: [summary],
200
+ validation: ['rigour_check'],
201
+ remainingWork: [`Continue from ${progressPct}%`],
202
+ risks: warnings,
203
+ }, estimateTokenCount(rawStateText), cwd);
204
+ let indexNote = null;
205
+ if (filesChanged.length > 0) {
206
+ indexNote = await refreshIndexForFiles(cwd, filesChanged);
207
+ }
208
+ await logStudioEvent(cwd, { type: "checkpoint_recorded", requestId, checkpointId, progressPct, qualityScore, warnings, filesChanged });
103
209
  let text = `📍 CHECKPOINT RECORDED: ${checkpointId}\n\n`;
104
210
  text += `Progress: ${progressPct}% | Quality: ${qualityScore}%\n`;
105
211
  text += `Summary: ${summary}\n`;
106
212
  text += `Total checkpoints: ${session.checkpoints.length}\n`;
213
+ if (indexNote)
214
+ text += `\n${indexNote}\n`;
107
215
  if (warnings.length > 0) {
108
216
  text += `\n⚠️ WARNINGS:\n${warnings.map(w => ` - ${w}`).join('\n')}\n`;
109
217
  if (qualityScore < 80)
110
218
  text += `\n⛔ QUALITY BELOW THRESHOLD: Consider pausing and reviewing recent work.`;
111
219
  }
112
- const result = { content: [{ type: "text", text }] };
220
+ const packet = await getTaskCheckpointCache(taskId, agentId, phase, cwd);
221
+ const returnedText = packet ? JSON.stringify(packet) : text;
222
+ const telemetry = buildTelemetryMeta({
223
+ candidateText: rawStateText,
224
+ returnedText,
225
+ cacheStatus: 'miss',
226
+ deduplicatedTokens: Math.max(0, estimateTokenCount(rawStateText) - estimateTokenCount(returnedText)),
227
+ });
228
+ const result = {
229
+ content: [{ type: "text", text: appendContextFooter(text, telemetry, 'rigour_handoff with compact context') }],
230
+ _telemetry: telemetry,
231
+ };
113
232
  result._shouldContinue = qualityScore >= 80;
114
233
  return result;
115
234
  }
116
235
  // ─── Handoff ──────────────────────────────────────────────────────
117
- export async function handleHandoff(cwd, fromAgentId, toAgentId, taskDescription, filesInScope, context, requestId) {
236
+ export async function handleHandoff(cwd, fromAgentId, toAgentId, taskDescription, filesInScope, context, requestId, taskId = 'default-task') {
237
+ const contextTokens = estimateTokenCount(context);
238
+ if (contextTokens > HANDOFF_CONTEXT_TOKEN_LIMIT) {
239
+ const text = `🛑 HANDOFF REJECTED: context is ~${contextTokens} tokens (limit: ${HANDOFF_CONTEXT_TOKEN_LIMIT}).\n\n`
240
+ + `Call rigour_checkpoint first to compress state into a checkpoint packet, then hand off with a brief summary only.\n`
241
+ + `Subagents should receive checkpoint packets — not parent transcripts.`;
242
+ return {
243
+ content: [{ type: 'text', text }],
244
+ isError: true,
245
+ _telemetry: buildTelemetryMeta({
246
+ candidateText: context,
247
+ returnedText: text,
248
+ cacheStatus: 'none',
249
+ }),
250
+ };
251
+ }
118
252
  const handoffId = `handoff-${Date.now()}`;
119
253
  const handoffPath = path.join(cwd, '.rigour', 'handoffs.jsonl');
254
+ const commitSha = await getWorkspaceCommitSha(cwd);
255
+ const checkpointPacket = await getTaskCheckpointCache(taskId, fromAgentId, `progress-100`, cwd)
256
+ ?? await getTaskCheckpointCache(taskId, fromAgentId, `progress-75`, cwd);
257
+ const compactContext = checkpointPacket
258
+ ? JSON.stringify({
259
+ decisions: checkpointPacket.decisions,
260
+ changedFiles: checkpointPacket.changedFiles,
261
+ remainingWork: checkpointPacket.remainingWork,
262
+ risks: checkpointPacket.risks,
263
+ brief: context.slice(0, 500),
264
+ })
265
+ : context;
120
266
  const handoff = {
121
267
  handoffId,
122
268
  timestamp: new Date().toISOString(),
123
- fromAgentId, toAgentId, taskDescription, filesInScope, context,
269
+ fromAgentId, toAgentId, taskDescription, filesInScope,
270
+ context: compactContext,
271
+ checkpointBound: !!checkpointPacket,
272
+ commitSha,
124
273
  status: 'pending',
125
274
  };
126
275
  await fs.ensureDir(path.join(cwd, '.rigour'));
127
276
  await fs.appendFile(handoffPath, JSON.stringify(handoff) + '\n');
128
- await logStudioEvent(cwd, { type: "handoff_initiated", requestId, handoffId, fromAgentId, toAgentId, taskDescription });
277
+ await logStudioEvent(cwd, { type: "handoff_initiated", requestId, handoffId, fromAgentId, toAgentId, taskDescription, contextTokens });
129
278
  let text = `🤝 HANDOFF INITIATED: ${handoffId}\n\n`;
130
279
  text += `From: ${fromAgentId} → To: ${toAgentId}\n`;
131
280
  text += `Task: ${taskDescription}\n`;
132
281
  if (filesInScope.length > 0)
133
282
  text += `Files in scope: ${filesInScope.join(', ')}\n`;
134
- if (context)
135
- text += `Context: ${context}\n`;
283
+ if (compactContext)
284
+ text += `Context: ${compactContext}\n`;
285
+ if (checkpointPacket)
286
+ text += `\n✅ Checkpoint packet attached (compressed handoff).\n`;
136
287
  text += `\nThe receiving agent should call rigour_agent_register to claim this scope.`;
137
- return { content: [{ type: "text", text }] };
288
+ const telemetry = buildTelemetryMeta({
289
+ candidateText: context || taskDescription,
290
+ returnedText: compactContext || text,
291
+ cacheStatus: checkpointPacket ? 'partial-hit' : 'miss',
292
+ deduplicatedTokens: Math.max(0, contextTokens - estimateTokenCount(compactContext)),
293
+ });
294
+ return {
295
+ content: [{ type: "text", text: appendContextFooter(text, telemetry, 'rigour_handoff_accept') }],
296
+ _telemetry: telemetry,
297
+ };
138
298
  }
139
299
  // ─── Agent Deregister ─────────────────────────────────────────────
140
300
  export async function handleAgentDeregister(cwd, agentId, requestId) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Handlers for Context Telemetry & Cost Efficiency MCP Tools
3
+ *
4
+ * Handlers for:
5
+ * - rigour_context_stats
6
+ * - rigour_task_cost
7
+ * - rigour_cache_stats
8
+ * - rigour_context_explain
9
+ * - rigour_context_scope
10
+ */
11
+ import { type ToolResult } from '../utils/context-telemetry.js';
12
+ export declare function handleContextStats(cwd: string, taskId?: string): Promise<ToolResult>;
13
+ export declare function handleTaskCost(cwd: string, taskId?: string): Promise<ToolResult>;
14
+ export declare function handleCacheStats(cwd: string): Promise<ToolResult>;
15
+ export declare function handleContextExplain(cwd: string, target: string, taskId?: string): Promise<ToolResult>;
16
+ export declare function handleContextScope(cwd: string, query: string, limit?: number): Promise<ToolResult>;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Handlers for Context Telemetry & Cost Efficiency MCP Tools
3
+ *
4
+ * Handlers for:
5
+ * - rigour_context_stats
6
+ * - rigour_task_cost
7
+ * - rigour_cache_stats
8
+ * - rigour_context_explain
9
+ * - rigour_context_scope
10
+ */
11
+ import fs from 'fs-extra';
12
+ import path from 'path';
13
+ import { getTaskContextStats, getTaskCostStats, getCacheStats, explainContext, getSemanticQueryCache, setSemanticQueryCache, estimateTokenCount, } from '@rigour-labs/core';
14
+ import { loadPatternIndex, getDefaultIndexPath, generateEmbedding, semanticSearch, } from '@rigour-labs/core/pattern-index';
15
+ import { buildTelemetryMeta, getWorkspaceCommitSha } from '../utils/context-telemetry.js';
16
+ import { appendContextFooter } from '../utils/context-footer.js';
17
+ export async function handleContextStats(cwd, taskId) {
18
+ try {
19
+ const stats = await getTaskContextStats(taskId, cwd);
20
+ const jsonText = JSON.stringify(stats, null, 2);
21
+ return {
22
+ content: [{ type: "text", text: jsonText }],
23
+ _telemetry: buildTelemetryMeta({
24
+ candidateText: jsonText,
25
+ returnedText: jsonText,
26
+ cacheStatus: 'none',
27
+ }),
28
+ };
29
+ }
30
+ catch (e) {
31
+ return {
32
+ content: [{ type: "text", text: `RIGOUR ERROR: ${e.message}` }],
33
+ isError: true,
34
+ };
35
+ }
36
+ }
37
+ export async function handleTaskCost(cwd, taskId) {
38
+ try {
39
+ const costStats = await getTaskCostStats(taskId, cwd);
40
+ const jsonText = JSON.stringify(costStats, null, 2);
41
+ return {
42
+ content: [{ type: "text", text: jsonText }],
43
+ _telemetry: buildTelemetryMeta({
44
+ candidateText: jsonText,
45
+ returnedText: jsonText,
46
+ cacheStatus: 'none',
47
+ }),
48
+ };
49
+ }
50
+ catch (e) {
51
+ return {
52
+ content: [{ type: "text", text: `RIGOUR ERROR: ${e.message}` }],
53
+ isError: true,
54
+ };
55
+ }
56
+ }
57
+ export async function handleCacheStats(cwd) {
58
+ try {
59
+ const stats = await getCacheStats(cwd);
60
+ const jsonText = JSON.stringify(stats, null, 2);
61
+ return {
62
+ content: [{ type: "text", text: jsonText }],
63
+ _telemetry: buildTelemetryMeta({
64
+ candidateText: jsonText,
65
+ returnedText: jsonText,
66
+ cacheStatus: 'none',
67
+ }),
68
+ };
69
+ }
70
+ catch (e) {
71
+ return {
72
+ content: [{ type: "text", text: `RIGOUR ERROR: ${e.message}` }],
73
+ isError: true,
74
+ };
75
+ }
76
+ }
77
+ export async function handleContextExplain(cwd, target, taskId) {
78
+ try {
79
+ const explanation = await explainContext(target, taskId, cwd);
80
+ const jsonText = JSON.stringify(explanation, null, 2);
81
+ return {
82
+ content: [{ type: "text", text: jsonText }],
83
+ _telemetry: buildTelemetryMeta({
84
+ candidateText: jsonText,
85
+ returnedText: jsonText,
86
+ cacheStatus: explanation.servedFromCache ? explanation.cacheStatus : 'miss',
87
+ }),
88
+ };
89
+ }
90
+ catch (e) {
91
+ return {
92
+ content: [{ type: "text", text: `RIGOUR ERROR: ${e.message}` }],
93
+ isError: true,
94
+ };
95
+ }
96
+ }
97
+ function textMatchPatterns(query, patterns, limit) {
98
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
99
+ const scored = patterns.map(p => {
100
+ const haystack = `${p.name} ${p.type} ${p.description} ${p.file} ${p.keywords?.join(' ') ?? ''}`.toLowerCase();
101
+ const score = terms.reduce((acc, term) => acc + (haystack.includes(term) ? 1 : 0), 0);
102
+ return { pattern: p, score };
103
+ });
104
+ return scored
105
+ .filter(s => s.score > 0)
106
+ .sort((a, b) => b.score - a.score)
107
+ .slice(0, limit)
108
+ .map(s => s.pattern);
109
+ }
110
+ export async function handleContextScope(cwd, query, limit = 10) {
111
+ const commitSha = await getWorkspaceCommitSha(cwd);
112
+ const indexPath = getDefaultIndexPath(cwd);
113
+ const fullIndexCandidate = `Full repository scan for: ${query}`;
114
+ const cached = await getSemanticQueryCache(query, commitSha, cwd);
115
+ if (cached) {
116
+ const cachedText = JSON.stringify(cached, null, 2);
117
+ const telemetry = buildTelemetryMeta({
118
+ candidateText: fullIndexCandidate,
119
+ returnedText: cachedText,
120
+ cacheStatus: 'semantic-hit',
121
+ });
122
+ const text = `CONTEXT SCOPE (cached)\n\n${cachedText}`;
123
+ return {
124
+ content: [{ type: 'text', text: appendContextFooter(text, telemetry, 'rigour_check_pattern before creating code') }],
125
+ _telemetry: telemetry,
126
+ };
127
+ }
128
+ const index = await loadPatternIndex(indexPath);
129
+ if (!index) {
130
+ const text = '⚠️ Pattern index not found. Call rigour_index first to enable scoped context retrieval.\n\n'
131
+ + 'Without an index, agents tend to read entire directories — wasting tokens.';
132
+ return {
133
+ content: [{ type: 'text', text }],
134
+ _telemetry: buildTelemetryMeta({
135
+ candidateText: fullIndexCandidate,
136
+ returnedText: text,
137
+ cacheStatus: 'miss',
138
+ }),
139
+ };
140
+ }
141
+ let matched = [];
142
+ const hasEmbeddings = index.patterns.some(p => p.embedding && p.embedding.length > 0);
143
+ if (hasEmbeddings) {
144
+ try {
145
+ const queryVector = await generateEmbedding(query);
146
+ const similarities = semanticSearch(queryVector, index.patterns);
147
+ matched = index.patterns
148
+ .map((p, i) => ({ pattern: p, similarity: similarities[i] ?? 0 }))
149
+ .filter(r => r.similarity > 0.3)
150
+ .sort((a, b) => b.similarity - a.similarity)
151
+ .slice(0, limit)
152
+ .map(r => r.pattern);
153
+ }
154
+ catch {
155
+ matched = textMatchPatterns(query, index.patterns, limit);
156
+ }
157
+ }
158
+ else {
159
+ matched = textMatchPatterns(query, index.patterns, limit);
160
+ }
161
+ const editScope = Array.from(new Set(matched.map(p => p.file)));
162
+ const allFiles = Array.from(new Set(index.patterns.map(p => p.file)));
163
+ const skipScope = allFiles
164
+ .filter(f => !editScope.includes(f))
165
+ .slice(0, Math.max(0, 20 - editScope.length))
166
+ .map(f => ({ file: f, reason: 'Outside semantic match for query' }));
167
+ const signatures = matched.map(p => `- ${p.file}:${p.line} ${p.type} ${p.name} — ${p.signature || p.description || '(no signature)'}`);
168
+ let fullFileTokens = 0;
169
+ for (const file of editScope) {
170
+ try {
171
+ const content = await fs.readFile(path.join(cwd, file), 'utf-8');
172
+ fullFileTokens += estimateTokenCount(content);
173
+ }
174
+ catch {
175
+ fullFileTokens += 500;
176
+ }
177
+ }
178
+ const scopePayload = {
179
+ query,
180
+ editScope,
181
+ skipScope,
182
+ signatures,
183
+ indexHealth: {
184
+ totalPatterns: index.stats.totalPatterns,
185
+ totalFiles: index.stats.totalFiles,
186
+ lastUpdated: index.lastUpdated,
187
+ semanticEnabled: hasEmbeddings,
188
+ },
189
+ estimatedTokensSaved: Math.max(0, fullFileTokens - estimateTokenCount(signatures.join('\n'))),
190
+ recommendation: editScope.length > 0
191
+ ? `Read ONLY these ${editScope.length} file(s). Do NOT scan the whole repo.`
192
+ : 'No strong matches — refine query or run rigour_index --semantic',
193
+ };
194
+ await setSemanticQueryCache(query, commitSha, {
195
+ query,
196
+ resolvedOwner: editScope[0] ? path.dirname(editScope[0]) : 'unknown',
197
+ editScope,
198
+ validationScope: editScope.map(f => `review ${f}`),
199
+ evidence: signatures.slice(0, 5),
200
+ commitSha,
201
+ confidence: matched.length > 0 ? 0.85 : 0.3,
202
+ }, cwd);
203
+ const resultText = JSON.stringify(scopePayload, null, 2);
204
+ const telemetry = buildTelemetryMeta({
205
+ candidateText: fullIndexCandidate,
206
+ returnedText: resultText,
207
+ cacheStatus: 'miss',
208
+ deduplicatedTokens: scopePayload.estimatedTokensSaved,
209
+ });
210
+ return {
211
+ content: [{
212
+ type: 'text',
213
+ text: appendContextFooter(`CONTEXT SCOPE\n\n${resultText}`, telemetry, 'rigour_check_pattern before creating code'),
214
+ }],
215
+ _telemetry: telemetry,
216
+ };
217
+ }