@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
@@ -0,0 +1,322 @@
1
+ // ─── Dynamic Tools — runtime tool creation + execution ──────
2
+ // Tools are prompt templates stored in D1, executed via LLM.
3
+ // No eval(). No code execution. Just parameterized prompts.
4
+
5
+ import { type EdgeEnv } from './dispatch.js';
6
+ import type { ToolExecutor, ToolStatus } from '../schema-enums.js';
7
+
8
+ // ─── Types ──────────────────────────────────────────────────
9
+
10
+ export interface DynamicTool {
11
+ id: string;
12
+ name: string;
13
+ description: string;
14
+ input_schema: string;
15
+ prompt_template: string;
16
+ executor: string;
17
+ created_by: string;
18
+ status: ToolStatus;
19
+ ttl_days: number | null;
20
+ use_count: number;
21
+ last_used_at: string | null;
22
+ avg_latency_ms: number;
23
+ avg_cost: number;
24
+ created_at: string;
25
+ updated_at: string;
26
+ expires_at: string | null;
27
+ }
28
+
29
+ export interface CreateToolOpts {
30
+ name: string;
31
+ description: string;
32
+ input_schema?: string;
33
+ prompt_template: string;
34
+ executor?: ToolExecutor;
35
+ created_by?: string;
36
+ ttl_days?: number;
37
+ status?: 'active' | 'draft';
38
+ }
39
+
40
+ export interface ToolInvocationResult {
41
+ text: string;
42
+ cost: number;
43
+ latency_ms: number;
44
+ executor: string;
45
+ }
46
+
47
+ // ─── Reserved names (cannot conflict with built-in tools) ────
48
+
49
+ const RESERVED_PREFIXES = ['aegis_', 'mcp_', 'bizops_'];
50
+
51
+ function isReservedName(name: string): boolean {
52
+ return RESERVED_PREFIXES.some(p => name.startsWith(p));
53
+ }
54
+
55
+ // ─── CRUD ───────────────────────────────────────────────────
56
+
57
+ export async function createDynamicTool(db: D1Database, opts: CreateToolOpts): Promise<string> {
58
+ if (isReservedName(opts.name)) {
59
+ throw new Error(`Tool name "${opts.name}" conflicts with reserved prefix`);
60
+ }
61
+ if (!/^[a-z][a-z0-9_]{1,48}$/.test(opts.name)) {
62
+ throw new Error('Tool name must be snake_case, 2-49 chars, start with letter');
63
+ }
64
+
65
+ const id = crypto.randomUUID();
66
+ const executor = opts.executor ?? 'gpt_oss';
67
+ const status = opts.status ?? 'active';
68
+ const expiresAt = opts.ttl_days
69
+ ? new Date(Date.now() + opts.ttl_days * 24 * 60 * 60 * 1000).toISOString()
70
+ : null;
71
+
72
+ await db.prepare(`
73
+ INSERT INTO dynamic_tools (id, name, description, input_schema, prompt_template, executor, created_by, status, ttl_days, expires_at)
74
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
75
+ `).bind(
76
+ id,
77
+ opts.name,
78
+ opts.description,
79
+ opts.input_schema ?? '{}',
80
+ opts.prompt_template,
81
+ executor,
82
+ opts.created_by ?? 'operator',
83
+ status,
84
+ opts.ttl_days ?? null,
85
+ expiresAt,
86
+ ).run();
87
+
88
+ return id;
89
+ }
90
+
91
+ export async function getDynamicTool(db: D1Database, nameOrId: string): Promise<DynamicTool | null> {
92
+ return db.prepare(
93
+ 'SELECT * FROM dynamic_tools WHERE (name = ? OR id = ?) AND status != ?'
94
+ ).bind(nameOrId, nameOrId, 'retired').first<DynamicTool>();
95
+ }
96
+
97
+ export async function listDynamicTools(
98
+ db: D1Database,
99
+ opts?: { status?: string; limit?: number },
100
+ ): Promise<DynamicTool[]> {
101
+ const limit = Math.min(opts?.limit ?? 50, 100);
102
+ if (opts?.status) {
103
+ const result = await db.prepare(
104
+ 'SELECT * FROM dynamic_tools WHERE status = ? ORDER BY use_count DESC, created_at DESC LIMIT ?'
105
+ ).bind(opts.status, limit).all<DynamicTool>();
106
+ return result.results;
107
+ }
108
+ const result = await db.prepare(
109
+ "SELECT * FROM dynamic_tools WHERE status IN ('active', 'promoted') ORDER BY use_count DESC, created_at DESC LIMIT ?"
110
+ ).bind(limit).all<DynamicTool>();
111
+ return result.results;
112
+ }
113
+
114
+ export async function updateDynamicTool(
115
+ db: D1Database,
116
+ id: string,
117
+ updates: Partial<Pick<DynamicTool, 'description' | 'prompt_template' | 'executor' | 'input_schema' | 'status'>>,
118
+ ): Promise<void> {
119
+ const sets: string[] = [];
120
+ const binds: unknown[] = [];
121
+
122
+ for (const [key, value] of Object.entries(updates)) {
123
+ if (value !== undefined) {
124
+ sets.push(`${key} = ?`);
125
+ binds.push(value);
126
+ }
127
+ }
128
+ if (sets.length === 0) return;
129
+
130
+ sets.push("updated_at = datetime('now')");
131
+ binds.push(id);
132
+
133
+ await db.prepare(
134
+ `UPDATE dynamic_tools SET ${sets.join(', ')} WHERE id = ?`
135
+ ).bind(...binds).run();
136
+ }
137
+
138
+ export async function retireDynamicTool(db: D1Database, id: string): Promise<void> {
139
+ await db.prepare(
140
+ "UPDATE dynamic_tools SET status = 'retired', updated_at = datetime('now') WHERE id = ?"
141
+ ).bind(id).run();
142
+ }
143
+
144
+ // ─── Prompt Rendering ───────────────────────────────────────
145
+
146
+ export function renderPrompt(template: string, inputs: Record<string, unknown>): string {
147
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
148
+ const value = inputs[key];
149
+ if (value === undefined) return match; // Leave unresolved placeholders as-is
150
+ return String(value);
151
+ });
152
+ }
153
+
154
+ // ─── Execution ──────────────────────────────────────────────
155
+
156
+ export async function executeDynamicTool(
157
+ tool: DynamicTool,
158
+ inputs: Record<string, unknown>,
159
+ env: EdgeEnv,
160
+ ): Promise<ToolInvocationResult> {
161
+ const rendered = renderPrompt(tool.prompt_template, inputs);
162
+ const start = Date.now();
163
+ let text = '';
164
+ let cost = 0;
165
+
166
+ if (tool.executor === 'workers_ai' && env.ai) {
167
+ // Workers AI — free inference
168
+ const result = await env.ai.run('@cf/meta/llama-3.1-8b-instruct' as Parameters<Ai['run']>[0], {
169
+ messages: [
170
+ { role: 'system', content: 'You are a focused tool. Answer precisely. No preamble.' },
171
+ { role: 'user', content: rendered },
172
+ ],
173
+ max_tokens: 1024,
174
+ }) as { response?: string };
175
+ text = result.response ?? '';
176
+ cost = 0;
177
+ } else if (tool.executor === 'groq' && env.groqApiKey) {
178
+ // Groq — fast, cheap
179
+ const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
180
+ method: 'POST',
181
+ headers: {
182
+ 'Authorization': `Bearer ${env.groqApiKey}`,
183
+ 'Content-Type': 'application/json',
184
+ },
185
+ body: JSON.stringify({
186
+ model: env.groqModel ?? 'llama-3.3-70b-versatile',
187
+ messages: [
188
+ { role: 'system', content: 'You are a focused tool. Answer precisely. No preamble.' },
189
+ { role: 'user', content: rendered },
190
+ ],
191
+ max_tokens: 1024,
192
+ }),
193
+ signal: AbortSignal.timeout(15_000),
194
+ });
195
+ if (!res.ok) throw new Error(`Groq error: ${res.status}`);
196
+ const data = await res.json() as { choices: Array<{ message: { content: string } }> };
197
+ text = data.choices[0]?.message?.content ?? '';
198
+ cost = 0.001; // ~$0.001 per Groq call
199
+ } else {
200
+ // Default: Workers AI fallback (always available on CF Workers)
201
+ if (env.ai) {
202
+ const result = await env.ai.run('@cf/meta/llama-3.1-8b-instruct' as Parameters<Ai['run']>[0], {
203
+ messages: [
204
+ { role: 'system', content: 'You are a focused tool. Answer precisely. No preamble.' },
205
+ { role: 'user', content: rendered },
206
+ ],
207
+ max_tokens: 1024,
208
+ }) as { response?: string };
209
+ text = result.response ?? '';
210
+ cost = 0;
211
+ } else {
212
+ throw new Error(`Executor "${tool.executor}" not available — no API key or binding`);
213
+ }
214
+ }
215
+
216
+ const latencyMs = Date.now() - start;
217
+
218
+ // Update usage stats (running average for latency/cost)
219
+ const newCount = tool.use_count + 1;
220
+ const newAvgLatency = ((tool.avg_latency_ms * tool.use_count) + latencyMs) / newCount;
221
+ const newAvgCost = ((tool.avg_cost * tool.use_count) + cost) / newCount;
222
+
223
+ await env.db.prepare(`
224
+ UPDATE dynamic_tools
225
+ SET use_count = ?, last_used_at = datetime('now'), avg_latency_ms = ?, avg_cost = ?, updated_at = datetime('now')
226
+ WHERE id = ?
227
+ `).bind(newCount, newAvgLatency, newAvgCost, tool.id).run();
228
+
229
+ return { text, cost, latency_ms: latencyMs, executor: tool.executor };
230
+ }
231
+
232
+ // ─── Lifecycle (GC + Promotion) ─────────────────────────────
233
+
234
+ const MAX_ACTIVE_TOOLS = 50;
235
+ const PROMOTION_THRESHOLD = 20; // use_count to auto-promote
236
+ const UNUSED_EXPIRY_DAYS = 30; // retire if never used after 30d
237
+
238
+ export async function garbageCollectTools(db: D1Database): Promise<{ expired: number; unused: number }> {
239
+ // 1. Expire tools past TTL
240
+ const expiredResult = await db.prepare(`
241
+ UPDATE dynamic_tools SET status = 'retired', updated_at = datetime('now')
242
+ WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at < datetime('now')
243
+ `).run();
244
+
245
+ // 2. Retire tools never used after 30 days
246
+ const unusedResult = await db.prepare(`
247
+ UPDATE dynamic_tools SET status = 'retired', updated_at = datetime('now')
248
+ WHERE status = 'active' AND use_count = 0 AND created_by != 'operator'
249
+ AND created_at < datetime('now', '-${UNUSED_EXPIRY_DAYS} days')
250
+ `).run();
251
+
252
+ // 3. Enforce ceiling — retire lowest-use active tools over cap
253
+ const activeCount = await db.prepare(
254
+ "SELECT COUNT(*) as cnt FROM dynamic_tools WHERE status IN ('active', 'promoted')"
255
+ ).first<{ cnt: number }>();
256
+
257
+ let overflowRetired = 0;
258
+ if (activeCount && activeCount.cnt > MAX_ACTIVE_TOOLS) {
259
+ const excess = activeCount.cnt - MAX_ACTIVE_TOOLS;
260
+ await db.prepare(`
261
+ UPDATE dynamic_tools SET status = 'retired', updated_at = datetime('now')
262
+ WHERE id IN (
263
+ SELECT id FROM dynamic_tools
264
+ WHERE status = 'active' AND created_by != 'operator'
265
+ ORDER BY use_count ASC, created_at ASC
266
+ LIMIT ?
267
+ )
268
+ `).bind(excess).run();
269
+ overflowRetired = excess;
270
+ }
271
+
272
+ return {
273
+ expired: expiredResult.meta.changes,
274
+ unused: unusedResult.meta.changes + overflowRetired,
275
+ };
276
+ }
277
+
278
+ export async function promoteHighUsageTools(db: D1Database): Promise<number> {
279
+ const result = await db.prepare(`
280
+ UPDATE dynamic_tools SET status = 'promoted', updated_at = datetime('now')
281
+ WHERE status = 'active' AND use_count >= ?
282
+ `).bind(PROMOTION_THRESHOLD).run();
283
+
284
+ return result.meta.changes;
285
+ }
286
+
287
+ // ─── Tool Definition Formatters (for Claude tool loop) ──────
288
+
289
+ export function toChatToolDef(tool: DynamicTool): { name: string; description: string; input_schema: Record<string, unknown> } {
290
+ let schema: Record<string, unknown>;
291
+ try {
292
+ schema = JSON.parse(tool.input_schema);
293
+ } catch {
294
+ schema = { type: 'object', properties: {} };
295
+ }
296
+ // Ensure it has a type
297
+ if (!schema.type) schema.type = 'object';
298
+
299
+ return {
300
+ name: `dt_${tool.name}`,
301
+ description: `[Dynamic Tool] ${tool.description}`,
302
+ input_schema: schema,
303
+ };
304
+ }
305
+
306
+ // ─── Cache ──────────────────────────────────────────────────
307
+
308
+ let toolCache: { tools: DynamicTool[]; expiresAt: number } | null = null;
309
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
310
+
311
+ export async function getCachedActiveTools(db: D1Database): Promise<DynamicTool[]> {
312
+ if (toolCache && Date.now() < toolCache.expiresAt) {
313
+ return toolCache.tools;
314
+ }
315
+ const tools = await listDynamicTools(db);
316
+ toolCache = { tools, expiresAt: Date.now() + CACHE_TTL_MS };
317
+ return tools;
318
+ }
319
+
320
+ export function invalidateToolCache(): void {
321
+ toolCache = null;
322
+ }
@@ -0,0 +1,45 @@
1
+ import { executeClaudeStream } from './executors/index.js';
2
+ import { createIntent } from './dispatch.js';
3
+ import type { EdgeEnv } from './dispatch.js';
4
+ import type { AegisExecutorPort, AegisTurnInput, AegisTurnEvent } from './port.js';
5
+
6
+ // Bridges the callback-style executeClaudeStream to the AsyncIterable<AegisTurnEvent>
7
+ // contract required by AegisExecutorPort. The kernel's execution loop is untouched.
8
+ export class KernelExecutorPort implements AegisExecutorPort {
9
+ constructor(private readonly env: EdgeEnv) {}
10
+
11
+ async *dispatch(input: AegisTurnInput): AsyncIterable<AegisTurnEvent> {
12
+ const intent = createIntent(input.sessionId, input.text);
13
+
14
+ // Push-queue bridge: callback fires synchronously from within the stream loop;
15
+ // the generator drains it between awaits so no events are dropped.
16
+ const queue: Array<AegisTurnEvent | null> = [];
17
+ let notify: (() => void) | null = null;
18
+
19
+ const push = (event: AegisTurnEvent | null) => {
20
+ queue.push(event);
21
+ const n = notify;
22
+ notify = null;
23
+ n?.();
24
+ };
25
+
26
+ executeClaudeStream(intent, this.env, (text) => push({ type: 'text.delta', text }))
27
+ .then(() => push(null))
28
+ .catch((err) => {
29
+ push({ type: 'warning', message: err instanceof Error ? err.message : String(err) });
30
+ push(null);
31
+ });
32
+
33
+ while (true) {
34
+ if (queue.length === 0) {
35
+ await new Promise<void>((r) => { notify = r; });
36
+ }
37
+ const item = queue.shift()!;
38
+ if (item === null) {
39
+ yield { type: 'done' };
40
+ return;
41
+ }
42
+ yield item;
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,73 @@
1
+ import { executeClaudeChat, executeClaudeChatStream } from '../../claude.js';
2
+ import { McpClient, McpRegistry } from '../../mcp-client.js';
3
+ import { operatorConfig } from '../../operator/index.js';
4
+ import type { KernelIntent } from '../types.js';
5
+ import type { EdgeEnv } from '../dispatch.js';
6
+ import { buildMcpRegistry } from './index.js';
7
+
8
+ function buildClaudeConfig(env: EdgeEnv, intent: KernelIntent, model: string, registry: McpRegistry) {
9
+ const mcpClient = new McpClient({
10
+ url: operatorConfig.integrations.bizops.fallbackUrl,
11
+ token: env.bizopsToken,
12
+ prefix: 'bizops',
13
+ fetcher: env.bizopsFetcher,
14
+ rpcPath: '/rpc',
15
+ });
16
+
17
+ return {
18
+ apiKey: env.anthropicApiKey,
19
+ model,
20
+ baseUrl: env.anthropicBaseUrl,
21
+ mcpClient,
22
+ mcpRegistry: registry,
23
+ db: env.db,
24
+ channel: 'web' as const,
25
+ conversationId: intent.source.threadId,
26
+ githubToken: env.githubToken,
27
+ githubRepo: env.githubRepo,
28
+ braveApiKey: env.braveApiKey,
29
+ roundtableDb: env.roundtableDb,
30
+ memoryBinding: env.memoryBinding,
31
+ };
32
+ }
33
+
34
+ export async function executeClaude(
35
+ intent: KernelIntent,
36
+ env: EdgeEnv,
37
+ ): Promise<{ text: string; cost: number }> {
38
+ const registry = buildMcpRegistry(env);
39
+ return executeClaudeChat(buildClaudeConfig(env, intent, env.claudeModel, registry), intent.raw);
40
+ }
41
+
42
+ export async function executeClaudeOpus(
43
+ intent: KernelIntent,
44
+ env: EdgeEnv,
45
+ ): Promise<{ text: string; cost: number }> {
46
+ const registry = buildMcpRegistry(env);
47
+ return executeClaudeChat(buildClaudeConfig(env, intent, env.opusModel, registry), intent.raw);
48
+ }
49
+
50
+ export async function executeClaudeStream(
51
+ intent: KernelIntent,
52
+ env: EdgeEnv,
53
+ onDelta: (text: string) => void,
54
+ ): Promise<{ text: string; cost: number }> {
55
+ const registry = buildMcpRegistry(env);
56
+ const mcpClient = new McpClient({
57
+ url: operatorConfig.integrations.bizops.fallbackUrl,
58
+ token: env.bizopsToken,
59
+ prefix: 'bizops',
60
+ fetcher: env.bizopsFetcher,
61
+ rpcPath: '/rpc',
62
+ });
63
+
64
+ return executeClaudeChatStream(
65
+ {
66
+ ...buildClaudeConfig(env, intent, intent.classified === 'claude_opus' ? env.opusModel : env.claudeModel, registry),
67
+ mcpClient,
68
+ resendApiKeys: { resendApiKey: env.resendApiKey, resendApiKeyPersonal: env.resendApiKeyPersonal },
69
+ },
70
+ intent.raw,
71
+ onDelta,
72
+ );
73
+ }
@@ -0,0 +1,237 @@
1
+ import { askGroq } from '../../groq.js';
2
+ import { drainMcpToolHealth } from '../../claude.js';
3
+ import { McpClient } from '../../mcp-client.js';
4
+ import { operatorConfig } from '../../operator/index.js';
5
+ import type { KernelIntent } from '../types.js';
6
+ import type { EdgeEnv } from '../dispatch.js';
7
+
8
+ // ─── Heartbeat Triage ────────────────────────────────────────
9
+
10
+ const CF_OBSERVABILITY_MCP_URL = 'https://observability.mcp.cloudflare.com/mcp';
11
+
12
+ const HEARTBEAT_TRIAGE_SYSTEM = `You are AEGIS, a business operations monitoring agent. Evaluate the dashboard data and return a JSON triage verdict.
13
+
14
+ Evaluate these checks:
15
+ 1. overdue_compliance — Any overdue compliance items. alert if any exist: high (1-2), critical (3+).
16
+ 2. upcoming_deadlines — Compliance items due within 7 days. warn if any: medium severity.
17
+ 3. runway_status — Monthly net burn. alert if net < -$500 (high), warn if net < $0 (medium).
18
+ 4. document_gaps — Missing/needed documents. ONLY warn if the dashboard explicitly lists a document as HIGH or CRITICAL priority AND status is "missing" or "needed". Advisory, low-priority, informational, or "nice-to-have" gaps MUST be reported as "ok". When in doubt, report "ok". The compliance goal loop handles deadline tracking separately — the heartbeat should NOT duplicate that work.
19
+ 5. separation_scores — Always report as "ok". Corporate veil separation scores 30-70 are normal during solo-founder bootstrapping. Do NOT warn or alert on separation scores.
20
+ 6. upcoming_deadlines — Compliance items due within 7 days. ONLY warn if the dashboard data shows a specific item with a specific deadline within 7 days. If the dashboard shows no upcoming deadlines or says "none", report as "ok" — do NOT warn about the absence of deadlines.
21
+ 7. worker_errors — CF Worker error rate. alert if > 5% (high), warn if > 1% (medium).
22
+ 8. worker_latency — Worker p99 latency. alert if > 10s (high), warn if > 3s (medium).
23
+ 9. worker_volume — Unusual request volume drop. warn if > 50% below typical (medium).
24
+
25
+ If Cloudflare metrics are not available, skip worker_* checks.
26
+
27
+ Return ONLY valid JSON matching this schema (no markdown, no explanation):
28
+ {
29
+ "actionable": boolean,
30
+ "severity": "none" | "low" | "medium" | "high" | "critical",
31
+ "summary": "one-line human summary",
32
+ "checks": [
33
+ { "name": "check_name", "status": "ok" | "warn" | "alert", "detail": "brief detail" }
34
+ ]
35
+ }
36
+
37
+ Overall severity = highest individual check severity. actionable = true if any check is warn or alert.
38
+ If dashboard is empty or has no issues, return actionable: false, severity: "none".`;
39
+
40
+ function parseHeartbeatVerdict(raw: string): { actionable: boolean; severity: string; summary: string; checks: unknown[] } {
41
+ if (!raw) return { actionable: false, severity: 'info', summary: 'No response', checks: [] };
42
+ const cleaned = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
43
+ try {
44
+ const parsed = JSON.parse(cleaned);
45
+ return {
46
+ actionable: Boolean(parsed.actionable),
47
+ severity: parsed.severity ?? 'none',
48
+ summary: parsed.summary ?? 'No summary',
49
+ checks: Array.isArray(parsed.checks) ? parsed.checks : [],
50
+ };
51
+ } catch {
52
+ return {
53
+ actionable: false,
54
+ severity: 'none',
55
+ summary: `Parse error: ${cleaned.slice(0, 100)}`,
56
+ checks: [{ name: 'parse_error', status: 'warn', detail: 'Could not parse Groq response' }],
57
+ };
58
+ }
59
+ }
60
+
61
+ async function fetchCfObservability(env: EdgeEnv): Promise<string | null> {
62
+ if (!env.cfAnalyticsToken || !operatorConfig.integrations.cfObservability.enabled) return null;
63
+
64
+ try {
65
+ const cfClient = new McpClient({
66
+ url: CF_OBSERVABILITY_MCP_URL,
67
+ token: env.cfAnalyticsToken,
68
+ prefix: 'cf_obs',
69
+ });
70
+
71
+ const result = await Promise.race([
72
+ cfClient.callTool('query_worker_observability', {
73
+ query: 'Show error rates, p99 latency, and request volume for all workers in the last hour',
74
+ }),
75
+ new Promise<never>((_, rej) =>
76
+ setTimeout(() => rej(new Error('CF observability timeout')), 10_000),
77
+ ),
78
+ ]);
79
+
80
+ return typeof result === 'string' ? result : JSON.stringify(result);
81
+ } catch (err) {
82
+ console.warn(`[heartbeat] CF observability fetch failed: ${err instanceof Error ? err.message : String(err)}`);
83
+ return null;
84
+ }
85
+ }
86
+
87
+ // ─── Executor ────────────────────────────────────────────────
88
+
89
+ export async function executeDirect(
90
+ intent: KernelIntent,
91
+ env: EdgeEnv,
92
+ ): Promise<{ text: string; cost: number; meta?: unknown }> {
93
+ if (intent.classified === 'heartbeat') {
94
+ // Edge heartbeat: call BizOps dashboard + CF infrastructure in parallel, triage with structured JSON
95
+ const mcpClient = new McpClient({
96
+ url: operatorConfig.integrations.bizops.fallbackUrl,
97
+ token: env.bizopsToken,
98
+ prefix: 'bizops',
99
+ fetcher: env.bizopsFetcher,
100
+ rpcPath: '/rpc',
101
+ });
102
+
103
+ try {
104
+ const [dashboard, cfMetrics] = await Promise.all([
105
+ mcpClient.callTool('dashboard_summary', {}),
106
+ fetchCfObservability(env),
107
+ ]);
108
+
109
+ // Track whether the dashboard call itself was degraded
110
+ const dashboardDegraded = dashboard === '(no output)';
111
+
112
+ let userPrompt = `Evaluate this BizOps dashboard snapshot:\n\n${typeof dashboard === 'string' ? dashboard : JSON.stringify(dashboard, null, 2)}`;
113
+ if (cfMetrics) {
114
+ userPrompt += `\n\n--- Cloudflare Infrastructure Metrics ---\n${cfMetrics}`;
115
+ }
116
+ const groqResponse = await askGroq(
117
+ env.groqApiKey,
118
+ env.groqModel,
119
+ HEARTBEAT_TRIAGE_SYSTEM,
120
+ userPrompt,
121
+ env.groqBaseUrl,
122
+ );
123
+ const verdict = parseHeartbeatVerdict(groqResponse);
124
+
125
+ // ─── Suppress bootstrapping noise ───
126
+ // During solo-founder bootstrapping, certain checks produce false positives:
127
+ // - separation_scores: shared operator across orgs is structural, not a risk
128
+ // - document_gaps at warn level: advisory gaps aren't actionable
129
+ // Only let through genuine alerts (score <30 or critical docs missing).
130
+ // Groq may use variant names (document_gap, missing_documents, doc_gaps) — match loosely.
131
+ verdict.checks = (verdict.checks as Array<{ name: string; status: string; detail: string }>).filter(c => {
132
+ // Separation scores: suppress entirely (prompt says skip, but LLM may still emit)
133
+ if (c.name === 'separation_scores' || c.name.includes('separation')) return false;
134
+ // Document gaps: only surface alerts (critical missing docs), suppress warns (advisory gaps)
135
+ const isDocGapCheck = c.name === 'document_gaps' || c.name.includes('document') || c.name.includes('doc_gap') || c.name.includes('missing_doc');
136
+ if (isDocGapCheck && c.status === 'warn') return false;
137
+ // Upcoming deadlines at warn with "no items" / "none" in detail — Groq sometimes emits
138
+ // a warn-level upcoming_deadlines check even when there's nothing due
139
+ if (c.name === 'upcoming_deadlines' && c.status === 'warn' && /\b(no |none|0 item|no upcoming)\b/i.test(c.detail)) return false;
140
+ return true;
141
+ });
142
+ // Recalculate actionable/severity after suppression
143
+ const activeChecks = (verdict.checks as Array<{ name: string; status: string }>).filter(c => c.status !== 'ok');
144
+ if (activeChecks.length === 0) {
145
+ verdict.actionable = false;
146
+ verdict.severity = 'none';
147
+ }
148
+
149
+ // ─── Docs sync staleness check (direct DB, no LLM) ───
150
+ const DOCS_STALE_THRESHOLD_MS = 48 * 60 * 60 * 1000; // 48 hours
151
+ try {
152
+ const lastSync = await env.db.prepare(
153
+ "SELECT received_at FROM web_events WHERE event_id = 'last_docs_sync_at'"
154
+ ).first<{ received_at: string }>();
155
+ if (!lastSync) {
156
+ // No watermark yet — seed it now so future checks have a baseline.
157
+ // The self-improvement scheduled job will update it on actual sync.
158
+ await env.db.prepare(
159
+ "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('last_docs_sync_at', datetime('now'))"
160
+ ).run();
161
+ verdict.checks.push({ name: 'docs_sync_staleness', status: 'ok', detail: 'Watermark initialized — tracking starts now' });
162
+ } else {
163
+ const lastSyncMs = new Date(lastSync.received_at + 'Z').getTime();
164
+ const syncAge = Date.now() - lastSyncMs;
165
+ if (syncAge > DOCS_STALE_THRESHOLD_MS) {
166
+ const ageDays = Math.round(syncAge / 86_400_000);
167
+ const status = ageDays > 7 ? 'alert' : 'warn';
168
+ verdict.checks.push({
169
+ name: 'docs_sync_staleness',
170
+ status,
171
+ detail: `Last docs sync was ${ageDays}d ago — drift likely accumulating`,
172
+ });
173
+ verdict.actionable = true;
174
+ if (verdict.severity === 'none') verdict.severity = 'medium';
175
+ } else {
176
+ verdict.checks.push({ name: 'docs_sync_staleness', status: 'ok', detail: `Synced ${Math.round(syncAge / 3_600_000)}h ago` });
177
+ }
178
+ }
179
+ } catch (err) {
180
+ console.warn(`[heartbeat] docs sync staleness check failed: ${err instanceof Error ? err.message : String(err)}`);
181
+ }
182
+
183
+ // Append MCP tool health checks from the inter-heartbeat window
184
+ const mcpHealth = drainMcpToolHealth();
185
+ if (dashboardDegraded) {
186
+ verdict.checks.push({
187
+ name: 'mcp_tool_health',
188
+ status: 'alert',
189
+ detail: 'BizOps dashboard_summary returned (no output) — heartbeat operating on stale data',
190
+ });
191
+ verdict.actionable = true;
192
+ if (verdict.severity === 'none') verdict.severity = 'medium';
193
+ }
194
+ for (const [tool, stats] of mcpHealth) {
195
+ const failRate = stats.calls > 0 ? (stats.failures + stats.degraded) / stats.calls : 0;
196
+ if (failRate > 0) {
197
+ const status = failRate >= 0.5 ? 'alert' : 'warn';
198
+ const detail = `${stats.failures} failures, ${stats.degraded} degraded out of ${stats.calls} calls`
199
+ + (stats.lastFailure ? ` — last error: ${stats.lastFailure}` : '');
200
+ verdict.checks.push({ name: `mcp_tool:${tool}`, status, detail });
201
+ if (status === 'alert') verdict.actionable = true;
202
+ }
203
+ }
204
+
205
+ return {
206
+ text: verdict.summary,
207
+ cost: 0.002,
208
+ meta: { actionable: verdict.actionable, severity: verdict.severity, checks: verdict.checks, source: 'edge_heartbeat' },
209
+ };
210
+ } catch (err) {
211
+ return {
212
+ text: `Heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
213
+ cost: 0,
214
+ meta: { actionable: false, severity: 'none', checks: [], source: 'edge_heartbeat' },
215
+ };
216
+ }
217
+ }
218
+
219
+ throw new Error(`No direct handler for pattern: ${intent.classified}`);
220
+ }
221
+
222
+ export async function executeCodeTask(
223
+ intent: KernelIntent,
224
+ env: EdgeEnv,
225
+ ): Promise<{ text: string; cost: number }> {
226
+ // code_task classified requests often involve repo queries (list files, read issues)
227
+ // that the Claude executor can handle with its GitHub in-process tools.
228
+ // Fall through to Claude if GitHub tools are available; only punt if not.
229
+ if (env.githubToken && env.githubRepo) {
230
+ const { executeClaude } = await import('./claude.js');
231
+ return executeClaude(intent, env);
232
+ }
233
+ return {
234
+ text: 'I can\'t execute code tasks from the web interface — that requires local filesystem access. If you need me to write or modify code, connect via the CLI on your development machine. I can still help you think through the approach, review logic, or plan the implementation here.',
235
+ cost: 0,
236
+ };
237
+ }