circuit-mcp 2.0.1 → 2.3.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/src/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from 'readline';
2
2
 
3
- const CIRCUIT_API = 'https://rnb3tf4uk3.ap-southeast-2.awsapprunner.com';
3
+ const CIRCUIT_API = process.env.CIRCUIT_API_URL || 'https://api.withcircuit.com';
4
+ const API_TIMEOUT_MS = 30_000;
4
5
 
5
6
  /**
6
7
  * Start MCP server in stdio mode
@@ -13,7 +14,6 @@ export async function startMcpServer(token) {
13
14
  terminal: false
14
15
  });
15
16
 
16
- // Handle incoming JSON-RPC messages
17
17
  rl.on('line', async (line) => {
18
18
  try {
19
19
  const message = JSON.parse(line);
@@ -22,7 +22,6 @@ export async function startMcpServer(token) {
22
22
  console.log(JSON.stringify(response));
23
23
  }
24
24
  } catch (err) {
25
- // Send error response
26
25
  console.log(JSON.stringify({
27
26
  jsonrpc: '2.0',
28
27
  error: { code: -32700, message: 'Parse error' },
@@ -31,32 +30,45 @@ export async function startMcpServer(token) {
31
30
  }
32
31
  });
33
32
 
34
- // Keep process alive
35
33
  process.stdin.resume();
36
34
  }
37
35
 
38
36
  /**
39
- * Call Circuit MCP backend API
37
+ * Call Circuit backend API with timeout
40
38
  */
41
39
  async function callMcpApi(token, toolName, args = {}) {
42
- const response = await fetch(`${CIRCUIT_API}/mcp/call`, {
43
- method: 'POST',
44
- headers: {
45
- 'Authorization': `Bearer ${token}`,
46
- 'Content-Type': 'application/json'
47
- },
48
- body: JSON.stringify({
49
- tool: toolName,
50
- arguments: args
51
- })
52
- });
40
+ const controller = new AbortController();
41
+ const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
53
42
 
54
- if (!response.ok) {
55
- const text = await response.text();
56
- throw new Error(`API error ${response.status}: ${text}`);
57
- }
43
+ try {
44
+ const response = await fetch(`${CIRCUIT_API}/mcp/call`, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Authorization': `Bearer ${token}`,
48
+ 'Content-Type': 'application/json'
49
+ },
50
+ body: JSON.stringify({ tool: toolName, arguments: args }),
51
+ signal: controller.signal
52
+ });
53
+
54
+ if (response.status === 401) {
55
+ throw new Error('Token expired. Run: npx circuit-mcp auth');
56
+ }
58
57
 
59
- return response.json();
58
+ if (!response.ok) {
59
+ const text = await response.text();
60
+ throw new Error(`API error ${response.status}: ${text}`);
61
+ }
62
+
63
+ return response.json();
64
+ } catch (err) {
65
+ if (err.name === 'AbortError') {
66
+ throw new Error('Request timed out. Check your connection and try again.');
67
+ }
68
+ throw err;
69
+ } finally {
70
+ clearTimeout(timeout);
71
+ }
60
72
  }
61
73
 
62
74
  /**
@@ -72,14 +84,8 @@ async function handleMessage(message, token) {
72
84
  id,
73
85
  result: {
74
86
  protocolVersion: '2024-11-05',
75
- serverInfo: {
76
- name: 'circuit-mcp',
77
- version: '2.0.0'
78
- },
79
- capabilities: {
80
- tools: {},
81
- resources: {}
82
- }
87
+ serverInfo: { name: 'circuit-mcp', version: '2.2.0' },
88
+ capabilities: { tools: {}, resources: {} }
83
89
  }
84
90
  };
85
91
 
@@ -87,142 +93,16 @@ async function handleMessage(message, token) {
87
93
  return null;
88
94
 
89
95
  case 'tools/list':
90
- return {
91
- jsonrpc: '2.0',
92
- id,
93
- result: {
94
- tools: [
95
- {
96
- name: 'circuit.priorities',
97
- description: 'What should I work on? Get ranked priorities with confidence indicators, trends, and memory context.',
98
- inputSchema: {
99
- type: 'object',
100
- properties: {
101
- lens: {
102
- type: 'string',
103
- description: "Focus lens: 'volume', 'urgency', 'revenue', 'retention', 'delight', 'feature'",
104
- enum: ['volume', 'urgency', 'revenue', 'retention', 'delight', 'feature'],
105
- default: 'volume'
106
- },
107
- segment: {
108
- type: 'string',
109
- description: "Filter by customer segment: 'enterprise', 'smb', 'all'",
110
- default: 'all'
111
- },
112
- limit: {
113
- type: 'number',
114
- description: 'Number of priorities (default: 5, max: 20)',
115
- default: 5
116
- },
117
- category: {
118
- type: 'string',
119
- description: "Filter by category: 'Bug', 'Feature', 'Friction', 'Complaint', 'Praise'",
120
- enum: ['Bug', 'Feature', 'Friction', 'Complaint', 'Praise']
121
- }
122
- }
123
- }
124
- },
125
- {
126
- name: 'circuit.brief',
127
- description: 'Get the full engineering spec for a priority. Includes brief content, customer context, version history, and related memory (previous ships, outcomes).',
128
- inputSchema: {
129
- type: 'object',
130
- properties: {
131
- priority_id: {
132
- type: 'string',
133
- description: 'The priority ID'
134
- },
135
- build_id: {
136
- type: 'string',
137
- description: 'The build ID directly (alternative to priority_id)'
138
- },
139
- include_history: {
140
- type: 'boolean',
141
- description: 'Include version history and related memory',
142
- default: true
143
- }
144
- }
145
- }
146
- },
147
- {
148
- name: 'circuit.act',
149
- description: 'Take an action in Circuit. Ship a brief, start building, correct a classification, or submit new feedback.',
150
- inputSchema: {
151
- type: 'object',
152
- properties: {
153
- action: {
154
- type: 'string',
155
- description: "Action to take: 'build' (start building), 'ship' (mark shipped), 'correct' (fix classification), 'submit' (add feedback)",
156
- enum: ['build', 'ship', 'correct', 'submit']
157
- },
158
- brief_id: {
159
- type: 'string',
160
- description: "Brief ID (for 'build' and 'ship' actions)"
161
- },
162
- priority_id: {
163
- type: 'string',
164
- description: "Priority ID (for 'correct' action)"
165
- },
166
- correction_type: {
167
- type: 'string',
168
- description: "What to correct (for 'correct' action): 'category'",
169
- enum: ['category']
170
- },
171
- original: {
172
- type: 'string',
173
- description: 'Original value being corrected'
174
- },
175
- corrected: {
176
- type: 'string',
177
- description: 'New corrected value'
178
- },
179
- feedback: {
180
- type: 'string',
181
- description: "Feedback text (for 'submit' action)"
182
- },
183
- source: {
184
- type: 'string',
185
- description: "Feedback source (for 'submit' action)",
186
- default: 'mcp'
187
- }
188
- },
189
- required: ['action']
190
- }
191
- },
192
- {
193
- name: 'circuit.ask',
194
- description: 'Ask anything about your feedback data. Searches across feedback, priorities, briefs, and help articles using semantic search.',
195
- inputSchema: {
196
- type: 'object',
197
- properties: {
198
- question: {
199
- type: 'string',
200
- description: "Natural language question (e.g., 'What are enterprise customers complaining about?', 'How do briefs work?')"
201
- }
202
- },
203
- required: ['question']
204
- }
205
- }
206
- ]
207
- }
208
- };
96
+ return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
209
97
 
210
98
  case 'tools/call':
211
99
  return await handleToolCall(id, params, token);
212
100
 
213
101
  case 'resources/list':
214
- return {
215
- jsonrpc: '2.0',
216
- id,
217
- result: { resources: [] }
218
- };
102
+ return { jsonrpc: '2.0', id, result: { resources: [] } };
219
103
 
220
104
  case 'ping':
221
- return {
222
- jsonrpc: '2.0',
223
- id,
224
- result: {}
225
- };
105
+ return { jsonrpc: '2.0', id, result: {} };
226
106
 
227
107
  default:
228
108
  return {
@@ -233,96 +113,264 @@ async function handleMessage(message, token) {
233
113
  }
234
114
  }
235
115
 
236
- /**
237
- * Format priorities response
238
- */
239
- function formatPriorities(data) {
240
- if (data.message) {
241
- return data.message;
116
+ // ─────────────────────────────────────────────────────────────
117
+ // Tool Definitions
118
+ // ─────────────────────────────────────────────────────────────
119
+
120
+ const TOOLS = [
121
+ {
122
+ name: 'circuit.priorities',
123
+ description: 'What should I work on? Ranked customer priorities with trend data, confidence indicators, and pattern matching. Set weekly: true to get the weekly digest instead.',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {
127
+ weekly: {
128
+ type: 'boolean',
129
+ description: 'Set to true to get the weekly digest: priority movements, new entries, dropped items, and volume spikes compared to last week'
130
+ },
131
+ lens: {
132
+ type: 'string',
133
+ description: "How to rank: 'volume' (most users), 'urgency' (bugs & quality), 'revenue' (revenue impact), 'retention' (negative sentiment), 'delight' (positive sentiment), 'feature' (new feature requests)",
134
+ enum: ['volume', 'urgency', 'revenue', 'retention', 'delight', 'feature'],
135
+ default: 'volume'
136
+ },
137
+ segment: {
138
+ type: 'string',
139
+ description: "Customer segment filter: 'enterprise', 'smb', 'all'",
140
+ default: 'all'
141
+ },
142
+ limit: {
143
+ type: 'number',
144
+ description: 'Number of priorities to return (default: 5, max: 20)',
145
+ default: 5
146
+ },
147
+ category: {
148
+ type: 'string',
149
+ description: "Filter by feedback category",
150
+ enum: ['Bug', 'Feature', 'Improvement', 'Praise']
151
+ }
152
+ }
153
+ }
154
+ },
155
+ {
156
+ name: 'circuit.spec',
157
+ description: 'Full engineering spec for a priority. 5 sections: What to Build, Why It Matters, Customer Voice, Files to Touch, Done When. Set batch: true to export multiple specs as markdown.',
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ priority_id: {
162
+ type: 'string',
163
+ description: 'The priority ID (from circuit.priorities)'
164
+ },
165
+ spec_id: {
166
+ type: 'string',
167
+ description: 'The spec ID directly (alternative to priority_id)'
168
+ },
169
+ include_history: {
170
+ type: 'boolean',
171
+ description: 'Include version history and related ship memory',
172
+ default: true
173
+ },
174
+ batch: {
175
+ type: 'boolean',
176
+ description: 'Set to true to export multiple specs as markdown for sprint planning'
177
+ },
178
+ spec_ids: {
179
+ type: 'array',
180
+ items: { type: 'string' },
181
+ description: 'Specific spec IDs to export (batch mode). If empty, exports recent specs.'
182
+ },
183
+ status: {
184
+ type: 'string',
185
+ description: "Filter by status (batch mode): 'ready', 'building', 'shipped'",
186
+ enum: ['ready', 'building', 'shipped', 'done']
187
+ },
188
+ limit: {
189
+ type: 'number',
190
+ description: 'Number of specs to export in batch mode (default: 10, max: 50)',
191
+ default: 10
192
+ }
193
+ }
194
+ }
195
+ },
196
+ {
197
+ name: 'circuit.act',
198
+ description: "Take action. Start building, ship it, share back with customers, assign, correct a classification, submit feedback, or submit a transcript.",
199
+ inputSchema: {
200
+ type: 'object',
201
+ properties: {
202
+ action: {
203
+ type: 'string',
204
+ description: "'build' (start building), 'ship' (mark shipped), 'share' (notify customers via email/widget), 'assign' (assign to team member), 'correct' (fix classification), 'submit' (add feedback), 'transcript' (submit a transcript)",
205
+ enum: ['build', 'ship', 'share', 'assign', 'correct', 'submit', 'transcript']
206
+ },
207
+ spec_id: {
208
+ type: 'string',
209
+ description: "Spec ID (for build, ship, share, assign)"
210
+ },
211
+ channel: {
212
+ type: 'string',
213
+ description: "Notification channel for share: 'email', 'widget', 'both', 'skip'",
214
+ enum: ['email', 'widget', 'both', 'skip']
215
+ },
216
+ message: {
217
+ type: 'string',
218
+ description: "Custom message to include in share notifications (optional)"
219
+ },
220
+ assigned_to: {
221
+ type: 'string',
222
+ description: "Team member email or user ID (for assign). Empty to unassign."
223
+ },
224
+ priority_id: {
225
+ type: 'string',
226
+ description: "Priority ID (for correct)"
227
+ },
228
+ correction_type: {
229
+ type: 'string',
230
+ description: "What to correct: 'category'",
231
+ enum: ['category']
232
+ },
233
+ original: {
234
+ type: 'string',
235
+ description: 'Original value being corrected'
236
+ },
237
+ corrected: {
238
+ type: 'string',
239
+ description: 'New corrected value'
240
+ },
241
+ feedback: {
242
+ type: 'string',
243
+ description: "Feedback text (for submit)"
244
+ },
245
+ source: {
246
+ type: 'string',
247
+ description: "Feedback source (for submit)",
248
+ default: 'mcp'
249
+ },
250
+ text: {
251
+ type: 'string',
252
+ description: 'Full transcript text, min 50 chars (for transcript)'
253
+ },
254
+ title: {
255
+ type: 'string',
256
+ description: "Transcript title, e.g. 'Sales call with Acme Corp' (for transcript)"
257
+ },
258
+ type: {
259
+ type: 'string',
260
+ description: "Transcript type (for transcript)",
261
+ enum: ['interview', 'sales_call', 'support', 'other']
262
+ },
263
+ customer_name: {
264
+ type: 'string',
265
+ description: 'Customer name (for transcript, optional)'
266
+ },
267
+ customer_email: {
268
+ type: 'string',
269
+ description: 'Customer email (for transcript, optional)'
270
+ },
271
+ revenue_band: {
272
+ type: 'string',
273
+ description: "Customer segment (for transcript): 'enterprise', 'paid', 'free'",
274
+ enum: ['enterprise', 'paid', 'free']
275
+ }
276
+ },
277
+ required: ['action']
278
+ }
279
+ },
280
+ {
281
+ name: 'circuit.ask',
282
+ description: 'Search across all your feedback data. Semantic search over feedback, priorities, briefs, and help articles. Also returns your shipping patterns and behavioral insights.',
283
+ inputSchema: {
284
+ type: 'object',
285
+ properties: {
286
+ question: {
287
+ type: 'string',
288
+ description: "Natural language question, e.g. 'What are enterprise customers complaining about?'"
289
+ }
290
+ },
291
+ required: ['question']
292
+ }
242
293
  }
294
+ ];
243
295
 
244
- if (!data.priorities || data.priorities.length === 0) {
245
- return 'No priorities found. Upload feedback to Circuit to get started.';
296
+ // ─────────────────────────────────────────────────────────────
297
+ // Response Formatting
298
+ // ─────────────────────────────────────────────────────────────
299
+
300
+ function formatPriorities(data) {
301
+ // Weekly digest mode — detected by presence of movements/new_entries/headline
302
+ if (data.headline !== undefined || data.movements !== undefined) {
303
+ return formatWeeklyDigest(data);
246
304
  }
247
305
 
306
+ if (data.message) return data.message;
307
+ if (!data.priorities?.length) return 'No priorities found. Upload feedback to get started.';
308
+
248
309
  const lines = [];
249
310
 
250
311
  if (data.memory_applied) {
251
- lines.push(`*Memory active: ${data.ships_count} ships tracked, segment: ${data.segment_affinity || 'mixed'}*\n`);
312
+ lines.push(`*${data.ships_count} ships tracked · segment: ${data.segment_affinity || 'mixed'}*\n`);
252
313
  }
253
314
 
254
315
  for (const p of data.priorities) {
255
- let trendText = '';
256
- if (p.trend === 'up') {
257
- trendText = ` ↑${p.trend_percent ? ` ${p.trend_percent}%` : ''}`;
258
- } else if (p.trend === 'down') {
259
- trendText = ` ↓${p.trend_percent ? ` ${p.trend_percent}%` : ''}`;
260
- }
261
-
262
316
  lines.push(`**#${p.rank}. ${p.theme}**`);
263
317
 
264
- const badges = [];
265
- badges.push(p.category || 'Other');
266
- badges.push(`${p.volume} users`);
267
- if (trendText) badges.push(trendText.trim());
318
+ const meta = [p.category || 'Other', `${p.volume} users`];
319
+ if (p.trend === 'up') meta.push(`↑${p.trend_percent ? ` ${p.trend_percent}%` : ''}`);
320
+ if (p.trend === 'down') meta.push(`↓${p.trend_percent ? ` ${p.trend_percent}%` : ''}`);
268
321
  if (p.brief_status && p.brief_status !== 'no_brief') {
269
- const statusLabel = { ready: 'Ready', building: 'Building', shipped: 'Shipped' }[p.brief_status];
270
- if (statusLabel) badges.push(statusLabel);
271
- }
272
- if (p.matches_pattern) badges.push('matches pattern');
273
- if (p.version) badges.push(p.version);
274
-
275
- lines.push(badges.join(' · '));
276
-
277
- if (p.key_quote) {
278
- lines.push(`> "${p.key_quote}"`);
322
+ const label = { ready: 'Ready', building: 'Building', shipped: 'Shipped' }[p.brief_status];
323
+ if (label) meta.push(label);
279
324
  }
325
+ if (p.matches_pattern) meta.push('matches pattern');
326
+ if (p.version) meta.push(p.version);
327
+ lines.push(meta.join(' · '));
280
328
 
281
- lines.push(`priority_id: \`${p.priority_id}\`${p.build_id ? ` · build_id: \`${p.build_id}\`` : ''}`);
329
+ if (p.key_quote) lines.push(`> "${p.key_quote}"`);
330
+ lines.push(`priority_id: \`${p.priority_id}\`${p.build_id ? ` · spec_id: \`${p.build_id}\`` : ''}`);
282
331
  lines.push('');
283
332
  }
284
333
 
285
334
  lines.push('---');
286
- lines.push('Use `circuit.brief` with a priority_id to see the full engineering spec.');
335
+ lines.push('Use `circuit.spec` with a priority_id to get the full spec.');
287
336
 
288
337
  return lines.join('\n');
289
338
  }
290
339
 
291
- /**
292
- * Format brief response
293
- */
294
- function formatBrief(data) {
340
+ function formatSpec(data) {
341
+ // Batch export mode — detected by presence of markdown/count
342
+ if (data.markdown !== undefined) {
343
+ return formatBatchExport(data);
344
+ }
345
+
295
346
  if (data.error) {
296
347
  if (data.error === 'no_brief' && data.priority) {
297
348
  const p = data.priority;
298
- let output = `# ${p.theme || 'Priority'}\n\n`;
299
- output += `${p.category || 'Other'} · ${p.volume || 0} users\n\n`;
300
- output += `**No brief generated yet.**\n\n`;
301
- if (data.suggestion) {
302
- output += `${data.suggestion}\n`;
303
- }
304
- return output;
349
+ return `# ${p.theme || 'Priority'}\n\n${p.category || 'Other'} · ${p.volume || 0} users\n\n**No brief generated yet.**\n\n${data.suggestion || ''}`;
305
350
  }
306
351
  return `Error: ${data.message || data.error}`;
307
352
  }
308
353
 
309
354
  const spec = data.spec_content || '';
310
- let output = '';
311
-
312
355
  const title = data.title || 'Engineering Brief';
313
- output += `# ${title}\n\n`;
356
+ const status = { ready: 'Ready', building: 'Building', shipped: 'Shipped' }[data.status] || data.status;
357
+ const version = data.version_badge ? ` · ${data.version_badge}` : '';
314
358
 
315
- const statusText = { ready: 'Ready', building: 'Building', shipped: 'Shipped' }[data.status] || data.status;
316
- const versionBadge = data.version_badge ? ` · ${data.version_badge}` : '';
317
- output += `Status: ${statusText}${versionBadge}\n`;
359
+ let output = `# ${title}\n\nStatus: ${status}${version}\n`;
318
360
 
319
361
  if (data.customer_context) {
320
362
  const ctx = data.customer_context;
321
363
  output += `${ctx.category} · ${ctx.volume} users · ${ctx.paying_percent}% paying\n`;
322
364
  }
365
+
366
+ if (data.effort) {
367
+ const e = data.effort;
368
+ output += `Effort: ${e.files} file${e.files !== 1 ? 's' : ''} · ${e.label}\n`;
369
+ }
370
+
323
371
  output += '\n';
324
372
 
325
- let cleanSpec = spec
373
+ output += spec
326
374
  .replace(/<what_to_build>/gi, '## WHAT TO BUILD\n')
327
375
  .replace(/<\/what_to_build>/gi, '\n')
328
376
  .replace(/<why_it_matters>/gi, '## WHY IT MATTERS\n')
@@ -334,39 +382,31 @@ function formatBrief(data) {
334
382
  .replace(/<done_when>/gi, '## DONE WHEN\n')
335
383
  .replace(/<\/done_when>/gi, '\n');
336
384
 
337
- output += cleanSpec;
338
-
339
- if (data.related_memory && data.related_memory.length > 0) {
385
+ if (data.related_memory?.length > 0) {
340
386
  output += '\n## WHAT CIRCUIT REMEMBERS\n\n';
341
387
  for (const mem of data.related_memory) {
342
- if (mem.type === 'ship') {
343
- output += `- Shipped: ${mem.theme || mem.summary || 'Related feature'}\n`;
344
- } else if (mem.type === 'correction') {
345
- output += `- Correction: ${mem.summary || 'Classification adjusted'}\n`;
346
- } else {
347
- output += `- ${mem.summary || JSON.stringify(mem)}\n`;
348
- }
388
+ output += `- ${mem.summary || mem.theme || JSON.stringify(mem)}\n`;
349
389
  }
350
390
  }
351
391
 
352
- output += `\n---\n`;
353
- output += `build_id: \`${data.build_id}\``;
392
+ output += `\n---\nspec_id: \`${data.build_id}\``;
354
393
 
355
394
  return output;
356
395
  }
357
396
 
358
- /**
359
- * Format act response
360
- */
361
397
  function formatAct(data) {
362
- if (data.error) {
363
- return `Error: ${data.error}`;
364
- }
398
+ if (data.error) return `Error: ${data.error}`;
365
399
 
366
400
  if (data.success) {
367
401
  let output = data.message;
368
402
  if (data.memory_created) {
369
- output += '\nShip memory recorded Circuit will remember this for future briefs.';
403
+ output += '\nShip memory recorded. Circuit will remember this for future specs.';
404
+ }
405
+ if (data.transcript_id) {
406
+ output += `\n\ntranscript_id: \`${data.transcript_id}\``;
407
+ }
408
+ if (data.spec_id && data.action === 'share') {
409
+ output += `\n\nspec_id: \`${data.spec_id}\``;
370
410
  }
371
411
  return output;
372
412
  }
@@ -374,17 +414,12 @@ function formatAct(data) {
374
414
  return JSON.stringify(data, null, 2);
375
415
  }
376
416
 
377
- /**
378
- * Format ask response
379
- */
380
417
  function formatAsk(data) {
381
- if (data.message) {
382
- return data.message;
383
- }
418
+ if (data.message) return data.message;
384
419
 
385
420
  const lines = [];
386
421
 
387
- if (data.help_articles && data.help_articles.length > 0) {
422
+ if (data.help_articles?.length > 0) {
388
423
  lines.push('**Help Articles:**');
389
424
  for (const a of data.help_articles) {
390
425
  lines.push(`- **${a.title}**: ${a.content}`);
@@ -392,15 +427,15 @@ function formatAsk(data) {
392
427
  lines.push('');
393
428
  }
394
429
 
395
- if (data.priorities && data.priorities.length > 0) {
430
+ if (data.priorities?.length > 0) {
396
431
  lines.push('**Related Priorities:**');
397
432
  for (const p of data.priorities) {
398
- lines.push(`- ${p.theme} (${p.category}, ${p.volume} users) — priority_id: \`${p.id}\``);
433
+ lines.push(`- ${p.theme} (${p.category}, ${p.volume} users) — \`${p.id}\``);
399
434
  }
400
435
  lines.push('');
401
436
  }
402
437
 
403
- if (data.feedback && data.feedback.length > 0) {
438
+ if (data.feedback?.length > 0) {
404
439
  lines.push('**Related Feedback:**');
405
440
  for (const f of data.feedback) {
406
441
  lines.push(`- [${f.source}] "${f.text}"`);
@@ -415,7 +450,7 @@ function formatAsk(data) {
415
450
  lines.push(`- Segment: ${pat.segment_affinity}`);
416
451
  if (pat.top_categories) {
417
452
  const cats = Object.entries(pat.top_categories).map(([k, v]) => `${k} (${Math.round(v * 100)}%)`).join(', ');
418
- lines.push(`- Top categories: ${cats}`);
453
+ lines.push(`- Categories: ${cats}`);
419
454
  }
420
455
  lines.push('');
421
456
  }
@@ -424,45 +459,95 @@ function formatAsk(data) {
424
459
  return `No results found for "${data.question}". Try rephrasing.`;
425
460
  }
426
461
 
427
- lines.push(`---\n${data.total} results found`);
462
+ lines.push(`---\n${data.total} results`);
463
+ return lines.join('\n');
464
+ }
465
+
466
+ // Internal helper: format weekly digest data (called from formatPriorities when weekly: true)
467
+ function formatWeeklyDigest(data) {
468
+ if (data.error) return `Error: ${data.error}`;
469
+
470
+ const lines = [];
471
+
472
+ lines.push(`**${data.headline || 'Weekly Digest'}**\n`);
473
+
474
+ if (data.movements?.length > 0) {
475
+ lines.push('**Movements:**');
476
+ for (const m of data.movements) {
477
+ const arrow = m.direction === 'up' ? '↑' : '↓';
478
+ lines.push(`- ${arrow} ${m.title} (#${m.from_rank} → #${m.to_rank})`);
479
+ }
480
+ lines.push('');
481
+ }
482
+
483
+ if (data.new_entries?.length > 0) {
484
+ lines.push('**New This Week:**');
485
+ for (const n of data.new_entries) {
486
+ lines.push(`- ${n.title} (rank #${n.rank}, ${n.mentions} mentions)`);
487
+ }
488
+ lines.push('');
489
+ }
490
+
491
+ if (data.volume_spikes?.length > 0) {
492
+ lines.push('**Volume Spikes:**');
493
+ for (const v of data.volume_spikes) {
494
+ lines.push(`- ${v.title} (+${v.percent_increase}%)`);
495
+ }
496
+ lines.push('');
497
+ }
498
+
499
+ if (data.dropped?.length > 0) {
500
+ lines.push('**Dropped Off:**');
501
+ for (const d of data.dropped) {
502
+ lines.push(`- ${d.title}${d.reason ? ` (${d.reason})` : ''}`);
503
+ }
504
+ lines.push('');
505
+ }
506
+
507
+ if (!data.has_previous) {
508
+ lines.push('*First week — baseline captured. Changes will appear next week.*');
509
+ }
510
+
511
+ if (lines.length <= 1) {
512
+ return data.headline || 'No changes this week.';
513
+ }
428
514
 
429
515
  return lines.join('\n');
430
516
  }
431
517
 
432
- /**
433
- * Handle tool calls
434
- */
518
+ // Internal helper: format batch export data (called from formatSpec when batch: true)
519
+ function formatBatchExport(data) {
520
+ if (data.error) return `Error: ${data.error}${data.suggestion ? `\n${data.suggestion}` : ''}`;
521
+
522
+ let output = `**${data.count} brief${data.count !== 1 ? 's' : ''} exported**\n\n`;
523
+ output += data.markdown;
524
+ return output;
525
+ }
526
+
527
+ // ─────────────────────────────────────────────────────────────
528
+ // Tool Call Handler
529
+ // ─────────────────────────────────────────────────────────────
530
+
435
531
  async function handleToolCall(id, params, token) {
436
532
  const { name, arguments: args } = params;
437
533
 
438
534
  try {
439
535
  const result = await callMcpApi(token, name, args || {});
440
536
 
441
- let formattedText;
442
-
443
- switch (name) {
444
- case 'circuit.priorities':
445
- formattedText = formatPriorities(result);
446
- break;
447
- case 'circuit.brief':
448
- formattedText = formatBrief(result);
449
- break;
450
- case 'circuit.act':
451
- formattedText = formatAct(result);
452
- break;
453
- case 'circuit.ask':
454
- formattedText = formatAsk(result);
455
- break;
456
- default:
457
- formattedText = JSON.stringify(result, null, 2);
458
- }
537
+ const formatters = {
538
+ 'circuit.priorities': formatPriorities,
539
+ 'circuit.spec': formatSpec,
540
+ 'circuit.act': formatAct,
541
+ 'circuit.ask': formatAsk,
542
+ };
543
+
544
+ const formatter = formatters[name] || ((d) => JSON.stringify(d, null, 2));
545
+ const text = formatter(result);
459
546
 
460
547
  return {
461
548
  jsonrpc: '2.0',
462
549
  id,
463
- result: {
464
- content: [{ type: 'text', text: formattedText }]
465
- }
550
+ result: { content: [{ type: 'text', text }] }
466
551
  };
467
552
 
468
553
  } catch (err) {