@stackbilt/aegis-core 0.1.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.
Files changed (148) hide show
  1. package/package.json +96 -0
  2. package/schema.sql +586 -0
  3. package/src/adapters/voice/cloudflare-agent.ts +34 -0
  4. package/src/auth.ts +124 -0
  5. package/src/bluesky.ts +464 -0
  6. package/src/claude-tools/content.ts +188 -0
  7. package/src/claude-tools/email.ts +69 -0
  8. package/src/claude-tools/github.ts +440 -0
  9. package/src/claude-tools/goals.ts +116 -0
  10. package/src/claude-tools/index.ts +353 -0
  11. package/src/claude-tools/web.ts +59 -0
  12. package/src/claude.ts +406 -0
  13. package/src/codebeast.ts +200 -0
  14. package/src/composite.ts +715 -0
  15. package/src/content/column.ts +80 -0
  16. package/src/content/hero-image.ts +47 -0
  17. package/src/content/index.ts +27 -0
  18. package/src/content/journal.ts +91 -0
  19. package/src/content/roundtable.ts +163 -0
  20. package/src/core.ts +309 -0
  21. package/src/dashboard.ts +620 -0
  22. package/src/decision-docs.ts +284 -0
  23. package/src/dispatch.ts +13 -0
  24. package/src/edge-env.ts +58 -0
  25. package/src/email.ts +850 -0
  26. package/src/exports.ts +156 -0
  27. package/src/github-projects.ts +312 -0
  28. package/src/github.ts +670 -0
  29. package/src/groq.ts +247 -0
  30. package/src/health-page.ts +578 -0
  31. package/src/index.ts +89 -0
  32. package/src/kernel/argus-actions.ts +397 -0
  33. package/src/kernel/argus-correlation.ts +639 -0
  34. package/src/kernel/board.ts +91 -0
  35. package/src/kernel/briefing.ts +177 -0
  36. package/src/kernel/classify-memory-topic.ts +166 -0
  37. package/src/kernel/cognition.ts +377 -0
  38. package/src/kernel/court-cards.ts +163 -0
  39. package/src/kernel/dispatch.ts +587 -0
  40. package/src/kernel/domain.ts +50 -0
  41. package/src/kernel/dynamic-tools.ts +322 -0
  42. package/src/kernel/executor-port.ts +45 -0
  43. package/src/kernel/executors/claude.ts +73 -0
  44. package/src/kernel/executors/direct.ts +237 -0
  45. package/src/kernel/executors/groq.ts +18 -0
  46. package/src/kernel/executors/index.ts +87 -0
  47. package/src/kernel/executors/tarotscript.ts +104 -0
  48. package/src/kernel/executors/workers-ai.ts +54 -0
  49. package/src/kernel/insight-cache.ts +76 -0
  50. package/src/kernel/memory/agenda.ts +200 -0
  51. package/src/kernel/memory/blocks.ts +188 -0
  52. package/src/kernel/memory/consolidation.ts +194 -0
  53. package/src/kernel/memory/episodic.ts +241 -0
  54. package/src/kernel/memory/goals.ts +156 -0
  55. package/src/kernel/memory/graph.ts +290 -0
  56. package/src/kernel/memory/index.ts +11 -0
  57. package/src/kernel/memory/insights.ts +316 -0
  58. package/src/kernel/memory/procedural.ts +467 -0
  59. package/src/kernel/memory/pruning.ts +67 -0
  60. package/src/kernel/memory/recall.ts +367 -0
  61. package/src/kernel/memory/semantic.ts +315 -0
  62. package/src/kernel/memory/synthesis.ts +161 -0
  63. package/src/kernel/memory-adapter.ts +369 -0
  64. package/src/kernel/memory-guardrails.ts +76 -0
  65. package/src/kernel/port.ts +23 -0
  66. package/src/kernel/resilience.ts +322 -0
  67. package/src/kernel/router.ts +471 -0
  68. package/src/kernel/scheduled/agent-dispatch.ts +252 -0
  69. package/src/kernel/scheduled/argus-analytics.ts +247 -0
  70. package/src/kernel/scheduled/argus-heartbeat.ts +320 -0
  71. package/src/kernel/scheduled/argus-notify.ts +348 -0
  72. package/src/kernel/scheduled/board-sync.ts +110 -0
  73. package/src/kernel/scheduled/ci-watcher.ts +125 -0
  74. package/src/kernel/scheduled/cognitive-metrics.ts +377 -0
  75. package/src/kernel/scheduled/consolidation.ts +229 -0
  76. package/src/kernel/scheduled/content-drip.ts +47 -0
  77. package/src/kernel/scheduled/content.ts +6 -0
  78. package/src/kernel/scheduled/conversation-facts.ts +204 -0
  79. package/src/kernel/scheduled/cost-report.ts +84 -0
  80. package/src/kernel/scheduled/curiosity.ts +219 -0
  81. package/src/kernel/scheduled/dev-activity.ts +44 -0
  82. package/src/kernel/scheduled/digest.ts +317 -0
  83. package/src/kernel/scheduled/dreaming/agenda-triage.ts +115 -0
  84. package/src/kernel/scheduled/dreaming/facts.ts +239 -0
  85. package/src/kernel/scheduled/dreaming/index.ts +8 -0
  86. package/src/kernel/scheduled/dreaming/llm.ts +33 -0
  87. package/src/kernel/scheduled/dreaming/pattern-synthesis.ts +124 -0
  88. package/src/kernel/scheduled/dreaming/persona.ts +75 -0
  89. package/src/kernel/scheduled/dreaming/symbolic.ts +31 -0
  90. package/src/kernel/scheduled/dreaming/task-proposals.ts +80 -0
  91. package/src/kernel/scheduled/dreaming.ts +66 -0
  92. package/src/kernel/scheduled/entropy.ts +149 -0
  93. package/src/kernel/scheduled/escalation.ts +192 -0
  94. package/src/kernel/scheduled/feed-watcher.ts +206 -0
  95. package/src/kernel/scheduled/goals.ts +214 -0
  96. package/src/kernel/scheduled/governance.ts +41 -0
  97. package/src/kernel/scheduled/heartbeat.ts +220 -0
  98. package/src/kernel/scheduled/inbox-processor.ts +174 -0
  99. package/src/kernel/scheduled/index.ts +245 -0
  100. package/src/kernel/scheduled/issue-proposer.ts +478 -0
  101. package/src/kernel/scheduled/issue-watcher.ts +128 -0
  102. package/src/kernel/scheduled/pr-automerge.ts +213 -0
  103. package/src/kernel/scheduled/product-health.ts +107 -0
  104. package/src/kernel/scheduled/reflection.ts +373 -0
  105. package/src/kernel/scheduled/self-improvement.ts +114 -0
  106. package/src/kernel/scheduled/social-engage.ts +175 -0
  107. package/src/kernel/scheduled/task-audit.ts +60 -0
  108. package/src/kernel/symbolic.ts +156 -0
  109. package/src/kernel/types.ts +145 -0
  110. package/src/landing.ts +1190 -0
  111. package/src/lib/audit-chain/chain.ts +28 -0
  112. package/src/lib/audit-chain/types.ts +12 -0
  113. package/src/lib/observability/errors.ts +55 -0
  114. package/src/markdown.ts +164 -0
  115. package/src/mcp/handlers.ts +647 -0
  116. package/src/mcp/server.ts +184 -0
  117. package/src/mcp/tools.ts +316 -0
  118. package/src/mcp-client.ts +275 -0
  119. package/src/mcp-server.ts +2 -0
  120. package/src/operator/config.example.ts +60 -0
  121. package/src/operator/config.ts +60 -0
  122. package/src/operator/index.ts +46 -0
  123. package/src/operator/persona.example.ts +34 -0
  124. package/src/operator/persona.ts +34 -0
  125. package/src/operator/prompt-builder.ts +190 -0
  126. package/src/operator/types.ts +43 -0
  127. package/src/pulse.ts +1179 -0
  128. package/src/routes/bluesky.ts +116 -0
  129. package/src/routes/cc-tasks.ts +328 -0
  130. package/src/routes/codebeast.ts +1 -0
  131. package/src/routes/content.ts +194 -0
  132. package/src/routes/conversations.ts +25 -0
  133. package/src/routes/dynamic-tools.ts +111 -0
  134. package/src/routes/feedback.ts +192 -0
  135. package/src/routes/health.ts +147 -0
  136. package/src/routes/messages.ts +228 -0
  137. package/src/routes/observability.ts +82 -0
  138. package/src/routes/operator-logs.ts +42 -0
  139. package/src/routes/pages.ts +96 -0
  140. package/src/routes/sessions.ts +54 -0
  141. package/src/sanitize.ts +73 -0
  142. package/src/schema-enums.ts +155 -0
  143. package/src/search.ts +112 -0
  144. package/src/task-intelligence.ts +497 -0
  145. package/src/types.ts +194 -0
  146. package/src/ui.ts +5 -0
  147. package/src/version.ts +3 -0
  148. package/src/workers-ai-chat.ts +333 -0
package/src/claude.ts ADDED
@@ -0,0 +1,406 @@
1
+ // Edge-native Claude executor — Anthropic Messages API with tool loop
2
+ // Replaces the Agent SDK query() for edge deployment
3
+
4
+ import { McpClient } from './mcp-client.js';
5
+ import { budgetConversationHistory } from './kernel/memory/index.js';
6
+ import {
7
+ buildContext,
8
+ handleInProcessTool,
9
+ resolveMcpTool,
10
+ getModelCostRates,
11
+ type ClaudeConfig,
12
+ type ContentBlock,
13
+ type Message,
14
+ type ApiResponse,
15
+ } from './claude-tools/index.js';
16
+
17
+ // Re-export for external consumers
18
+ export { buildContext, handleInProcessTool, resolveMcpTool, type ClaudeConfig } from './claude-tools/index.js';
19
+
20
+ // ─── Anthropic SSE streaming ─────────────────────────────────
21
+
22
+ type StreamBlockState = {
23
+ type: 'text' | 'tool_use';
24
+ text: string;
25
+ id: string;
26
+ name: string;
27
+ inputJson: string;
28
+ };
29
+
30
+ async function* parseAnthropicSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
31
+ const reader = body.getReader();
32
+ const decoder = new TextDecoder();
33
+ let buffer = '';
34
+
35
+ try {
36
+ while (true) {
37
+ const { done, value } = await reader.read();
38
+ if (done) break;
39
+ buffer += decoder.decode(value, { stream: true });
40
+
41
+ let newlineIdx: number;
42
+ while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
43
+ const line = buffer.slice(0, newlineIdx).trim();
44
+ buffer = buffer.slice(newlineIdx + 1);
45
+
46
+ if (!line.startsWith('data: ')) continue;
47
+ const payload = line.slice(6).trim();
48
+ if (payload === '[DONE]') return;
49
+
50
+ try {
51
+ yield JSON.parse(payload);
52
+ } catch {
53
+ // Malformed chunk — skip
54
+ }
55
+ }
56
+ }
57
+ } finally {
58
+ reader.releaseLock();
59
+ }
60
+ }
61
+
62
+ async function callAnthropicStream(
63
+ apiKey: string,
64
+ model: string,
65
+ system: string,
66
+ messages: Message[],
67
+ tools: unknown[],
68
+ onDelta: (text: string) => void,
69
+ baseUrl?: string,
70
+ ): Promise<{ content: ContentBlock[]; stopReason: string; inputTokens: number; outputTokens: number }> {
71
+ const url = `${baseUrl || 'https://api.anthropic.com'}/v1/messages`;
72
+ const response = await fetch(url, {
73
+ method: 'POST',
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ 'x-api-key': apiKey,
77
+ 'anthropic-version': '2023-06-01',
78
+ },
79
+ body: JSON.stringify({
80
+ model,
81
+ max_tokens: 4096,
82
+ system,
83
+ tools,
84
+ messages,
85
+ stream: true,
86
+ }),
87
+ });
88
+
89
+ if (!response.ok) {
90
+ const errText = await response.text();
91
+ throw new Error(`Anthropic streaming error ${response.status}: ${errText}`);
92
+ }
93
+
94
+ if (!response.body) throw new Error('No response body for streaming');
95
+
96
+ const content: ContentBlock[] = [];
97
+ let currentBlock: StreamBlockState | null = null;
98
+ let stopReason = 'end_turn';
99
+ let inputTokens = 0;
100
+ let outputTokens = 0;
101
+
102
+ for await (const event of parseAnthropicSSE(response.body)) {
103
+ const type = event.type as string;
104
+
105
+ if (type === 'content_block_start') {
106
+ const cb = event.content_block as Record<string, unknown>;
107
+ currentBlock = {
108
+ type: ((cb.type as string) ?? 'text') as 'text' | 'tool_use',
109
+ text: (cb.text as string) ?? '',
110
+ id: (cb.id as string) ?? '',
111
+ name: (cb.name as string) ?? '',
112
+ inputJson: '',
113
+ };
114
+ } else if (type === 'content_block_delta') {
115
+ const delta = event.delta as Record<string, unknown>;
116
+ if (delta.type === 'text_delta' && currentBlock) {
117
+ currentBlock.text += (delta.text as string) ?? '';
118
+ onDelta((delta.text as string) ?? '');
119
+ } else if (delta.type === 'input_json_delta' && currentBlock) {
120
+ currentBlock.inputJson += (delta.partial_json as string) ?? '';
121
+ }
122
+ } else if (type === 'content_block_stop' && currentBlock) {
123
+ if (currentBlock.type === 'text') {
124
+ content.push({ type: 'text', text: currentBlock.text });
125
+ } else {
126
+ let input: Record<string, unknown> = {};
127
+ try { input = JSON.parse(currentBlock.inputJson || '{}'); } catch { /* empty input */ }
128
+ content.push({ type: 'tool_use', id: currentBlock.id, name: currentBlock.name, input });
129
+ }
130
+ currentBlock = null;
131
+ } else if (type === 'message_delta') {
132
+ const md = event.delta as Record<string, unknown> | undefined;
133
+ if (md?.stop_reason) stopReason = md.stop_reason as string;
134
+ } else if (type === 'message_start') {
135
+ const msg = event.message as Record<string, unknown> | undefined;
136
+ const usage = msg?.usage as Record<string, number> | undefined;
137
+ if (usage) inputTokens = usage.input_tokens ?? 0;
138
+ } else if (type === 'message_delta') {
139
+ const usage = event.usage as Record<string, number> | undefined;
140
+ if (usage) outputTokens = usage.output_tokens ?? 0;
141
+ }
142
+ }
143
+
144
+ // Flush any remaining block
145
+ if (currentBlock) {
146
+ if (currentBlock.type === 'text') {
147
+ content.push({ type: 'text', text: currentBlock.text });
148
+ } else {
149
+ let input: Record<string, unknown> = {};
150
+ try { input = JSON.parse(currentBlock.inputJson || '{}'); } catch { /* empty input */ }
151
+ content.push({ type: 'tool_use', id: currentBlock.id, name: currentBlock.name, input });
152
+ }
153
+ }
154
+
155
+ return { content, stopReason, inputTokens, outputTokens };
156
+ }
157
+
158
+ // ─── MCP Tool Health Tracker ─────────────────────────────────
159
+ // Tracks per-tool call outcomes so the heartbeat can surface degradation.
160
+
161
+ export interface McpToolStats {
162
+ calls: number;
163
+ failures: number; // threw or timed out
164
+ degraded: number; // returned '(no output)' — data loss without crash
165
+ lastFailure?: string; // error message (sanitised — no tokens/URLs)
166
+ lastDegradedAt?: number;
167
+ lastSuccessAt?: number;
168
+ }
169
+
170
+ const mcpToolHealth = new Map<string, McpToolStats>();
171
+
172
+ function sanitizeErrorForLog(msg: string): string {
173
+ // Strip anything that looks like a token, key, or full URL with credentials
174
+ return msg
175
+ .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
176
+ .replace(/[A-Za-z0-9_-]{32,}/g, '[REDACTED]')
177
+ .replace(/https?:\/\/[^\s)]+/g, '[URL]')
178
+ .slice(0, 200);
179
+ }
180
+
181
+ function recordMcpOutcome(tool: string, outcome: 'success' | 'degraded' | 'failure', errMsg?: string): void {
182
+ const stats = mcpToolHealth.get(tool) ?? { calls: 0, failures: 0, degraded: 0 };
183
+ stats.calls++;
184
+ if (outcome === 'failure') {
185
+ stats.failures++;
186
+ stats.lastFailure = errMsg ? sanitizeErrorForLog(errMsg) : 'unknown';
187
+ } else if (outcome === 'degraded') {
188
+ stats.degraded++;
189
+ stats.lastDegradedAt = Date.now();
190
+ } else {
191
+ stats.lastSuccessAt = Date.now();
192
+ }
193
+ mcpToolHealth.set(tool, stats);
194
+ }
195
+
196
+ /** Snapshot of MCP tool health for heartbeat consumption. Resets counters after read. */
197
+ export function drainMcpToolHealth(): Map<string, McpToolStats> {
198
+ const snapshot = new Map(mcpToolHealth);
199
+ mcpToolHealth.clear();
200
+ return snapshot;
201
+ }
202
+
203
+ // ─── MCP retry helper (#3) ───────────────────────────────────
204
+ // Wraps mcpClient.callTool with exponential backoff + 15s per-call timeout.
205
+ // On final failure returns a structured error string so Claude can reason about it.
206
+ export async function callMcpWithRetry(
207
+ client: McpClient,
208
+ name: string,
209
+ args: Record<string, unknown>,
210
+ retries = 2,
211
+ delayMs = 500,
212
+ ): Promise<string> {
213
+ for (let attempt = 0; attempt <= retries; attempt++) {
214
+ try {
215
+ const result = await Promise.race([
216
+ client.callTool(name, args),
217
+ new Promise<never>((_, rej) =>
218
+ setTimeout(() => rej(new Error(`MCP tool timeout after 15s: ${name}`)), 15_000)
219
+ ),
220
+ ]);
221
+ if (result === '(no output)') {
222
+ recordMcpOutcome(name, 'degraded');
223
+ } else {
224
+ recordMcpOutcome(name, 'success');
225
+ }
226
+ return result;
227
+ } catch (err) {
228
+ if (attempt === retries) {
229
+ const msg = err instanceof Error ? err.message : String(err);
230
+ recordMcpOutcome(name, 'failure', msg);
231
+ return `Tool unavailable (${name}): ${msg}`;
232
+ }
233
+ await new Promise(r => setTimeout(r, delayMs * (attempt + 1)));
234
+ }
235
+ }
236
+ recordMcpOutcome(name, 'failure', 'exhausted retries');
237
+ return `Tool unavailable: ${name}`;
238
+ }
239
+
240
+ export async function executeClaudeChat(
241
+ config: ClaudeConfig,
242
+ userText: string,
243
+ ): Promise<{ text: string; cost: number }> {
244
+ config.userQuery = userText;
245
+ const { systemPrompt, tools, conversationHistory } = await buildContext(config, config.roundtableDb);
246
+ const anthropicBase = config.baseUrl || 'https://api.anthropic.com';
247
+ const anthropicConfig = { apiKey: config.apiKey, model: config.model, baseUrl: anthropicBase };
248
+
249
+ const messages: Message[] = [
250
+ ...budgetConversationHistory(conversationHistory),
251
+ { role: 'user', content: userText },
252
+ ];
253
+
254
+ let totalCost = 0;
255
+ const MAX_TOOL_ROUNDS = 10;
256
+
257
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
258
+ const response = await fetch(`${anthropicBase}/v1/messages`, {
259
+ method: 'POST',
260
+ headers: {
261
+ 'Content-Type': 'application/json',
262
+ 'x-api-key': config.apiKey,
263
+ 'anthropic-version': '2023-06-01',
264
+ },
265
+ body: JSON.stringify({
266
+ model: config.model,
267
+ max_tokens: 4096,
268
+ system: systemPrompt,
269
+ tools,
270
+ messages,
271
+ }),
272
+ });
273
+
274
+ if (!response.ok) {
275
+ const errText = await response.text();
276
+ throw new Error(`Anthropic API error ${response.status}: ${errText}`);
277
+ }
278
+
279
+ const data = await response.json<ApiResponse>();
280
+
281
+ const rates = getModelCostRates(config.model);
282
+ totalCost += (data.usage.input_tokens * rates.input + data.usage.output_tokens * rates.output) / 1_000_000;
283
+
284
+ // Check if we're done (no tool use)
285
+ if (data.stop_reason === 'end_turn' || data.stop_reason === 'max_tokens') {
286
+ const textBlocks = data.content.filter(b => b.type === 'text');
287
+ return {
288
+ text: textBlocks.map(b => b.text ?? '').join('') || '(no response)',
289
+ cost: totalCost,
290
+ };
291
+ }
292
+
293
+ // Handle tool use
294
+ if (data.stop_reason === 'tool_use') {
295
+ // Add assistant message with all content blocks
296
+ messages.push({ role: 'assistant', content: data.content });
297
+
298
+ // Process each tool use
299
+ const toolResults: ContentBlock[] = [];
300
+
301
+ for (const block of data.content) {
302
+ if (block.type !== 'tool_use' || !block.id || !block.name) continue;
303
+
304
+ let result: string;
305
+ const inProcess = await handleInProcessTool(config.db, block.name, block.input ?? {}, config.githubToken, config.githubRepo, config.braveApiKey, config.roundtableDb, anthropicConfig, config.memoryBinding, config.resendApiKeys);
306
+ if (inProcess !== null) {
307
+ result = inProcess;
308
+ } else {
309
+ const resolved = resolveMcpTool(block.name, config.mcpClient, config.mcpRegistry);
310
+ if (resolved) {
311
+ result = await callMcpWithRetry(resolved.client, resolved.mcpName, block.input ?? {});
312
+ } else {
313
+ result = `Unknown tool: ${block.name}`;
314
+ }
315
+ }
316
+
317
+ toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result } as unknown as ContentBlock);
318
+ }
319
+
320
+ messages.push({ role: 'user', content: toolResults });
321
+ continue;
322
+ }
323
+
324
+ const textBlocks = data.content.filter(b => b.type === 'text');
325
+ return { text: textBlocks.map(b => b.text ?? '').join('') || '(no response)', cost: totalCost };
326
+ }
327
+
328
+ return { text: '(reached maximum tool rounds)', cost: totalCost };
329
+ }
330
+
331
+ // ─── Streaming variant ───────────────────────────────────────
332
+ // Tool-use rounds are non-streaming (fast); only the final end_turn text streams.
333
+ // onDelta is buffered per round and flushed only on end_turn, so intermediate
334
+ // "I'll check the dashboard..." text never leaks to the UI.
335
+
336
+ export async function executeClaudeChatStream(
337
+ config: ClaudeConfig,
338
+ userText: string,
339
+ onDelta: (text: string) => void,
340
+ ): Promise<{ text: string; cost: number }> {
341
+ config.userQuery = userText;
342
+ const { systemPrompt, tools, conversationHistory } = await buildContext(config, config.roundtableDb);
343
+ const anthropicBaseStream = config.baseUrl || 'https://api.anthropic.com';
344
+ const anthropicConfigStream = { apiKey: config.apiKey, model: config.model, baseUrl: anthropicBaseStream };
345
+
346
+ const messages: Message[] = [...budgetConversationHistory(conversationHistory), { role: 'user', content: userText }];
347
+
348
+ let totalCost = 0;
349
+ const MAX_TOOL_ROUNDS = 10;
350
+
351
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
352
+ // Buffer deltas — only flush to onDelta if this round ends with end_turn
353
+ const roundBuffer: string[] = [];
354
+
355
+ const { content, stopReason, inputTokens, outputTokens } = await callAnthropicStream(
356
+ config.apiKey, config.model, systemPrompt, messages, tools,
357
+ (delta) => roundBuffer.push(delta),
358
+ config.baseUrl,
359
+ );
360
+
361
+ const rates = getModelCostRates(config.model);
362
+ totalCost += (inputTokens * rates.input + outputTokens * rates.output) / 1_000_000;
363
+
364
+ if (stopReason === 'end_turn' || stopReason === 'max_tokens') {
365
+ // Flush buffered deltas to caller now that we know it's the final round
366
+ for (const delta of roundBuffer) onDelta(delta);
367
+ const text = content.filter(b => b.type === 'text').map(b => b.text ?? '').join('') || '(no response)';
368
+ return { text, cost: totalCost };
369
+ }
370
+
371
+ if (stopReason === 'tool_use') {
372
+ // Discard roundBuffer — tool-use intermediate text stays invisible
373
+ messages.push({ role: 'assistant', content });
374
+ const toolResults: ContentBlock[] = [];
375
+
376
+ for (const block of content) {
377
+ if (block.type !== 'tool_use' || !block.id || !block.name) continue;
378
+ let result: string;
379
+
380
+ const inProcess = await handleInProcessTool(config.db, block.name, block.input ?? {}, config.githubToken, config.githubRepo, config.braveApiKey, config.roundtableDb, anthropicConfigStream, config.memoryBinding);
381
+ if (inProcess !== null) {
382
+ result = inProcess;
383
+ } else {
384
+ const resolved = resolveMcpTool(block.name, config.mcpClient, config.mcpRegistry);
385
+ if (resolved) {
386
+ result = await callMcpWithRetry(resolved.client, resolved.mcpName, block.input ?? {});
387
+ } else {
388
+ result = `Unknown tool: ${block.name}`;
389
+ }
390
+ }
391
+
392
+ toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result } as unknown as ContentBlock);
393
+ }
394
+
395
+ messages.push({ role: 'user', content: toolResults });
396
+ continue;
397
+ }
398
+
399
+ // Unexpected stop reason — flush whatever we have
400
+ for (const delta of roundBuffer) onDelta(delta);
401
+ const text = content.filter(b => b.type === 'text').map(b => b.text ?? '').join('') || '(no response)';
402
+ return { text, cost: totalCost };
403
+ }
404
+
405
+ return { text: '(reached maximum tool rounds)', cost: totalCost };
406
+ }
@@ -0,0 +1,200 @@
1
+ import { Hono } from 'hono';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ import type { Env } from './types.js';
4
+
5
+ const DEFAULT_BODY_LIMIT = 100 * 1024;
6
+
7
+ export const codebeast = new Hono<{ Bindings: Env }>();
8
+
9
+ type FindingSeverity = 'HIGH' | 'MID' | 'LOW' | 'INFO';
10
+ type FindingCategory = 'SECURITY' | 'LOGIC' | 'STYLE' | 'DEPENDENCY' | 'BOUNDARY';
11
+
12
+ interface Finding {
13
+ finding_id?: string;
14
+ repo?: string;
15
+ file_path?: string;
16
+ line_start?: number;
17
+ line_end?: number;
18
+ severity?: FindingSeverity;
19
+ category?: FindingCategory;
20
+ title?: string;
21
+ description?: string;
22
+ commit_sha?: string;
23
+ branch?: string;
24
+ detected_at?: string;
25
+ }
26
+
27
+ interface FixStatus {
28
+ fix_id?: string;
29
+ finding_id?: string;
30
+ repo?: string;
31
+ status?: 'COMPLETED' | 'FAILED';
32
+ outcome_summary?: string;
33
+ completed_at?: string;
34
+ }
35
+
36
+ function derivePriority(severity: FindingSeverity): 'high' | 'medium' | 'low' {
37
+ if (severity === 'HIGH') return 'high';
38
+ if (severity === 'MID') return 'medium';
39
+ return 'low';
40
+ }
41
+
42
+ async function queueDigestNotification(db: D1Database, findings: Finding[]): Promise<void> {
43
+ const alertable = findings.filter((finding) => finding.severity === 'HIGH' || finding.severity === 'MID');
44
+ if (alertable.length === 0) return;
45
+
46
+ const summary = alertable
47
+ .map((finding) => `[${finding.severity}] ${finding.repo}: ${finding.title}`)
48
+ .join('\n');
49
+
50
+ // Best-effort only. Some OSS deployments may not enable digest persistence.
51
+ await db.prepare(
52
+ "INSERT INTO digest_sections (section, payload) VALUES ('codebeast_findings', ?)"
53
+ ).bind(JSON.stringify({
54
+ count: alertable.length,
55
+ summary,
56
+ timestamp: new Date().toISOString(),
57
+ })).run().catch(() => {});
58
+ }
59
+
60
+ // ─── POST /api/v1/codebeast/bridge/findings ──────────────────
61
+
62
+ codebeast.post('/api/v1/codebeast/bridge/findings', bodyLimit({ maxSize: DEFAULT_BODY_LIMIT }), async (c) => {
63
+ let body: { findings?: Finding[] };
64
+ try {
65
+ body = await c.req.json();
66
+ } catch {
67
+ return c.json({ error: 'Invalid JSON' }, 400);
68
+ }
69
+
70
+ if (!Array.isArray(body.findings) || body.findings.length === 0) {
71
+ return c.json({ error: 'findings array is required and must be non-empty' }, 400);
72
+ }
73
+
74
+ const results: Array<{ finding_id: string; stored: boolean; error?: string }> = [];
75
+
76
+ for (const finding of body.findings) {
77
+ if (!finding.finding_id || !finding.repo || !finding.title || !finding.severity) {
78
+ results.push({
79
+ finding_id: finding.finding_id ?? 'unknown',
80
+ stored: false,
81
+ error: 'missing required fields',
82
+ });
83
+ continue;
84
+ }
85
+
86
+ try {
87
+ await c.env.DB.prepare(`
88
+ INSERT OR IGNORE INTO codebeast_findings
89
+ (finding_id, repo, file_path, line_start, line_end, severity, category, title, description, commit_sha, branch, priority, detected_at)
90
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
91
+ `).bind(
92
+ finding.finding_id,
93
+ finding.repo,
94
+ finding.file_path ?? '',
95
+ finding.line_start ?? 0,
96
+ finding.line_end ?? 0,
97
+ finding.severity,
98
+ finding.category ?? 'LOGIC',
99
+ finding.title,
100
+ finding.description ?? '',
101
+ finding.commit_sha ?? '',
102
+ finding.branch ?? 'main',
103
+ derivePriority(finding.severity),
104
+ finding.detected_at ?? new Date().toISOString(),
105
+ ).run();
106
+
107
+ results.push({ finding_id: finding.finding_id, stored: true });
108
+ } catch (err) {
109
+ console.error(`[codebeast] Failed to store finding ${finding.finding_id}:`, err);
110
+ results.push({ finding_id: finding.finding_id, stored: false, error: 'db write failed' });
111
+ }
112
+ }
113
+
114
+ await queueDigestNotification(c.env.DB, body.findings);
115
+
116
+ const stored = results.filter((result) => result.stored).length;
117
+ console.log(`[codebeast] Bridge: ${stored}/${body.findings.length} findings stored`);
118
+
119
+ return c.json({ received: body.findings.length, stored, results }, 201);
120
+ });
121
+
122
+ // ─── POST /api/v1/codebeast/bridge/fix-status ────────────────
123
+
124
+ codebeast.post('/api/v1/codebeast/bridge/fix-status', bodyLimit({ maxSize: DEFAULT_BODY_LIMIT }), async (c) => {
125
+ let body: FixStatus;
126
+ try {
127
+ body = await c.req.json();
128
+ } catch {
129
+ return c.json({ error: 'Invalid JSON' }, 400);
130
+ }
131
+
132
+ if (!body.finding_id || !body.status) {
133
+ return c.json({ error: 'finding_id and status are required' }, 400);
134
+ }
135
+
136
+ const finding = await c.env.DB.prepare(
137
+ 'SELECT finding_id, title, severity, status FROM codebeast_findings WHERE finding_id = ?'
138
+ ).bind(body.finding_id).first<{ finding_id: string; title: string; severity: string; status: string }>();
139
+
140
+ if (!finding) {
141
+ return c.json({ error: `Finding ${body.finding_id} not found` }, 404);
142
+ }
143
+
144
+ if (body.status === 'COMPLETED') {
145
+ await c.env.DB.prepare(`
146
+ UPDATE codebeast_findings
147
+ SET status = 'resolved', fix_id = ?, outcome_summary = ?, resolved_at = ?
148
+ WHERE finding_id = ?
149
+ `).bind(
150
+ body.fix_id ?? null,
151
+ body.outcome_summary ?? '',
152
+ body.completed_at ?? new Date().toISOString(),
153
+ body.finding_id,
154
+ ).run();
155
+
156
+ console.log(`[codebeast] Finding ${body.finding_id} resolved: ${body.outcome_summary?.slice(0, 80)}`);
157
+ } else {
158
+ await c.env.DB.prepare(`
159
+ UPDATE codebeast_findings
160
+ SET fix_attempts = fix_attempts + 1, last_fix_error = ?, updated_at = datetime('now')
161
+ WHERE finding_id = ?
162
+ `).bind(
163
+ body.outcome_summary ?? 'Fix failed (no details)',
164
+ body.finding_id,
165
+ ).run();
166
+
167
+ console.log(`[codebeast] Fix failed for ${body.finding_id}: ${body.outcome_summary?.slice(0, 80)}`);
168
+ }
169
+
170
+ return c.json({ finding_id: body.finding_id, status: body.status, updated: true });
171
+ });
172
+
173
+ // ─── GET /api/v1/codebeast/findings ──────────────────────────
174
+
175
+ codebeast.get('/api/v1/codebeast/findings', async (c) => {
176
+ const status = c.req.query('status');
177
+ const severity = c.req.query('severity');
178
+ const repo = c.req.query('repo');
179
+
180
+ let sql = 'SELECT * FROM codebeast_findings WHERE 1=1';
181
+ const bindings: string[] = [];
182
+
183
+ if (status) {
184
+ sql += ' AND status = ?';
185
+ bindings.push(status);
186
+ }
187
+ if (severity) {
188
+ sql += ' AND severity = ?';
189
+ bindings.push(severity);
190
+ }
191
+ if (repo) {
192
+ sql += ' AND repo = ?';
193
+ bindings.push(repo);
194
+ }
195
+
196
+ sql += ' ORDER BY detected_at DESC LIMIT 100';
197
+
198
+ const result = await c.env.DB.prepare(sql).bind(...bindings).all();
199
+ return c.json({ findings: result.results, count: result.results.length });
200
+ });