@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,111 @@
1
+ // Dynamic Tools API — CRUD + invocation for runtime-created tools
2
+
3
+ import { Hono } from 'hono';
4
+ import { bodyLimit } from 'hono/body-limit';
5
+ import type { Env } from '../types.js';
6
+ import {
7
+ createDynamicTool,
8
+ getDynamicTool,
9
+ listDynamicTools,
10
+ updateDynamicTool,
11
+ retireDynamicTool,
12
+ executeDynamicTool,
13
+ invalidateToolCache,
14
+ } from '../kernel/dynamic-tools.js';
15
+ import { buildEdgeEnv } from '../edge-env.js';
16
+ import type { ToolExecutor, ToolStatus } from '../schema-enums.js';
17
+
18
+ const DYNAMIC_TOOLS_BODY_LIMIT = 100 * 1024;
19
+
20
+ const dynamicToolsRoutes = new Hono<{ Bindings: Env }>();
21
+
22
+ // GET /api/dynamic-tools — list active tools
23
+ dynamicToolsRoutes.get('/api/dynamic-tools', async (c) => {
24
+ const status = c.req.query('status');
25
+ const limit = Math.min(Number(c.req.query('limit') ?? 50), 100);
26
+ const tools = await listDynamicTools(c.env.DB, { status: status ?? undefined, limit });
27
+ return c.json({ tools, count: tools.length });
28
+ });
29
+
30
+ // POST /api/dynamic-tools — create a new tool
31
+ dynamicToolsRoutes.post('/api/dynamic-tools', bodyLimit({ maxSize: DYNAMIC_TOOLS_BODY_LIMIT }), async (c) => {
32
+ try {
33
+ const body = await c.req.json<{
34
+ name: string;
35
+ description: string;
36
+ input_schema?: string;
37
+ prompt_template: string;
38
+ executor?: ToolExecutor;
39
+ created_by?: string;
40
+ ttl_days?: number;
41
+ status?: 'active' | 'draft';
42
+ }>();
43
+
44
+ if (!body.name || !body.description || !body.prompt_template) {
45
+ return c.json({ error: 'name, description, and prompt_template are required' }, 400);
46
+ }
47
+
48
+ const id = await createDynamicTool(c.env.DB, body);
49
+ invalidateToolCache();
50
+ return c.json({ id, name: body.name, status: body.status ?? 'active' }, 201);
51
+ } catch (err) {
52
+ const msg = err instanceof Error ? err.message : String(err);
53
+ return c.json({ error: msg }, 400);
54
+ }
55
+ });
56
+
57
+ // GET /api/dynamic-tools/:id — get tool details
58
+ dynamicToolsRoutes.get('/api/dynamic-tools/:id', async (c) => {
59
+ const tool = await getDynamicTool(c.env.DB, c.req.param('id'));
60
+ if (!tool) return c.json({ error: 'Not found' }, 404);
61
+ return c.json(tool);
62
+ });
63
+
64
+ // PUT /api/dynamic-tools/:id — update a tool
65
+ dynamicToolsRoutes.put('/api/dynamic-tools/:id', async (c) => {
66
+ const id = c.req.param('id');
67
+ const tool = await getDynamicTool(c.env.DB, id);
68
+ if (!tool) return c.json({ error: 'Not found' }, 404);
69
+
70
+ const body = await c.req.json<{
71
+ description?: string;
72
+ prompt_template?: string;
73
+ executor?: ToolExecutor;
74
+ input_schema?: string;
75
+ status?: ToolStatus;
76
+ }>();
77
+
78
+ await updateDynamicTool(c.env.DB, tool.id, body);
79
+ invalidateToolCache();
80
+ return c.json({ updated: true, id: tool.id });
81
+ });
82
+
83
+ // DELETE /api/dynamic-tools/:id — retire a tool
84
+ dynamicToolsRoutes.delete('/api/dynamic-tools/:id', async (c) => {
85
+ const id = c.req.param('id');
86
+ const tool = await getDynamicTool(c.env.DB, id);
87
+ if (!tool) return c.json({ error: 'Not found' }, 404);
88
+
89
+ await retireDynamicTool(c.env.DB, tool.id);
90
+ invalidateToolCache();
91
+ return c.json({ retired: true, id: tool.id });
92
+ });
93
+
94
+ // POST /api/dynamic-tools/:id/invoke — execute a dynamic tool
95
+ dynamicToolsRoutes.post('/api/dynamic-tools/:id/invoke', bodyLimit({ maxSize: DYNAMIC_TOOLS_BODY_LIMIT }), async (c) => {
96
+ const tool = await getDynamicTool(c.env.DB, c.req.param('id'));
97
+ if (!tool) return c.json({ error: 'Not found' }, 404);
98
+ if (tool.status === 'draft') return c.json({ error: 'Tool is in draft status — activate it first' }, 400);
99
+
100
+ try {
101
+ const body = await c.req.json<{ inputs?: Record<string, unknown> }>();
102
+ const env = buildEdgeEnv(c.env);
103
+ const result = await executeDynamicTool(tool, body.inputs ?? {}, env);
104
+ return c.json(result);
105
+ } catch (err) {
106
+ const msg = err instanceof Error ? err.message : String(err);
107
+ return c.json({ error: msg }, 500);
108
+ }
109
+ });
110
+
111
+ export { dynamicToolsRoutes };
@@ -0,0 +1,192 @@
1
+ import { Hono } from 'hono';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ import type { Env } from '../types.js';
4
+ import { McpClient } from '../mcp-client.js';
5
+ import { addAgendaItem } from '../kernel/memory/agenda.js';
6
+ import { FEEDBACK_CATEGORIES, validateEnum } from '../schema-enums.js';
7
+
8
+ const FEEDBACK_BODY_LIMIT = 100 * 1024;
9
+
10
+ export const feedback = new Hono<{ Bindings: Env }>();
11
+
12
+ // CORS preflight for cross-origin feedback submissions (Client App UI → AEGIS)
13
+ feedback.options('/api/feedback', (c) => {
14
+ return new Response(null, {
15
+ status: 204,
16
+ headers: {
17
+ 'Access-Control-Allow-Origin': '*',
18
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
19
+ 'Access-Control-Allow-Headers': 'Content-Type',
20
+ 'Access-Control-Max-Age': '86400',
21
+ },
22
+ });
23
+ });
24
+
25
+ // Middleware: add CORS headers to all feedback responses
26
+ feedback.use('/api/feedback', async (c, next) => {
27
+ await next();
28
+ c.header('Access-Control-Allow-Origin', '*');
29
+ });
30
+
31
+ feedback.post('/api/feedback', bodyLimit({ maxSize: FEEDBACK_BODY_LIMIT }), async (c) => {
32
+ const body = await c.req.json<{ email?: string; category?: string; message?: string }>().catch(() => null);
33
+ if (!body?.message || body.message.length < 5 || body.message.length > 5000) {
34
+ return c.json({ error: 'message required (5-5000 chars)' }, 400);
35
+ }
36
+ const category = validateEnum(FEEDBACK_CATEGORIES, body.category, 'general');
37
+ const id = crypto.randomUUID();
38
+ const userAgent = c.req.header('User-Agent') ?? null;
39
+ await c.env.DB.prepare(
40
+ 'INSERT INTO feedback (id, email, category, message, source, user_agent) VALUES (?, ?, ?, ?, ?, ?)'
41
+ ).bind(id, body.email ?? null, category, body.message, 'web', userAgent).run();
42
+
43
+ // Notify operator via email
44
+ if (c.env.RESEND_API_KEY) {
45
+ const subject = `[Feedback] ${category}: ${body.message.slice(0, 60)}${body.message.length > 60 ? '…' : ''}`;
46
+ const html = `<p><strong>Category:</strong> ${category}</p>
47
+ <p><strong>From:</strong> ${body.email ?? 'anonymous'}</p>
48
+ <p><strong>Message:</strong></p><p>${body.message.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`;
49
+ try {
50
+ await fetch('https://api.resend.com/emails', {
51
+ method: 'POST',
52
+ headers: { Authorization: `Bearer ${c.env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({ from: 'AEGIS <agent@example.com>', to: 'admin@example.com', subject, html }),
54
+ });
55
+ } catch { /* best-effort */ }
56
+ }
57
+
58
+ // ─── BizOps CRM integration ────────────────────────────────
59
+ // Wire feedback to CRM contacts + interactions (mirrors voice funnel pattern)
60
+ // TarotScript triage-cast runs first for deterministic classification
61
+ const email = body.email;
62
+ const message = body.message;
63
+ if (email && c.env.BIZOPS && c.env.BIZOPS_TOKEN) {
64
+ const crmWork = async () => {
65
+ try {
66
+ // ─── TarotScript triage-cast (zero-inference classification) ──
67
+ let triage: { ticket_category?: string; urgency_level?: string; sentiment_signal?: string; complexity_tier?: string; needs_escalation?: boolean } = {};
68
+ if (c.env.TAROTSCRIPT) {
69
+ try {
70
+ const triageRes = await c.env.TAROTSCRIPT.fetch('https://internal/run', {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({
74
+ spreadType: 'triage-cast',
75
+ querent: {
76
+ id: email,
77
+ intention: message,
78
+ state: { source: 'human', channel: 'feedback_form', account_tier: 'free' },
79
+ },
80
+ }),
81
+ });
82
+ if (triageRes.ok) {
83
+ const triageData = await triageRes.json<{ facts?: typeof triage }>();
84
+ triage = triageData.facts ?? {};
85
+ console.log(`[feedback] triage-cast: category=${triage.ticket_category} urgency=${triage.urgency_level} sentiment=${triage.sentiment_signal} escalation=${triage.needs_escalation}`);
86
+ }
87
+ } catch (err) {
88
+ console.warn('[feedback] triage-cast failed:', err instanceof Error ? err.message : String(err));
89
+ }
90
+ }
91
+
92
+ const bizops = new McpClient({
93
+ url: 'https://your-bizops.example.com/mcp',
94
+ token: c.env.BIZOPS_TOKEN,
95
+ prefix: 'bizops',
96
+ fetcher: c.env.BIZOPS,
97
+ rpcPath: '/rpc',
98
+ });
99
+
100
+ const STACKBILT_ORG_ID = 'f876b6eb-332f-44a8-9683-342b2147d98b';
101
+
102
+ // Upsert contact
103
+ let contactId: string | undefined;
104
+ try {
105
+ const searchResult = await bizops.callTool('search_contacts', { query: email });
106
+ const parsed = JSON.parse(searchResult);
107
+ if (parsed.contacts?.length > 0) {
108
+ contactId = parsed.contacts[0].id;
109
+ }
110
+ } catch {
111
+ // Contact search failed — proceed to create
112
+ }
113
+
114
+ if (!contactId) {
115
+ try {
116
+ const name = email.split('@')[0] || 'Anonymous';
117
+ const createResult = await bizops.callTool('create_contact', {
118
+ org_id: STACKBILT_ORG_ID,
119
+ name,
120
+ email,
121
+ source: 'FEEDBACK',
122
+ });
123
+ const parsed = JSON.parse(createResult);
124
+ if (parsed.id) contactId = parsed.id;
125
+ } catch {
126
+ // Contact creation failed
127
+ }
128
+ }
129
+
130
+ // Log interaction — enriched with triage-cast classification
131
+ if (contactId) {
132
+ const triageCategory = triage.ticket_category ?? category;
133
+ const interactionBody = [
134
+ `**Category:** ${triageCategory}`,
135
+ triage.urgency_level ? `**Urgency:** ${triage.urgency_level}` : '',
136
+ triage.sentiment_signal ? `**Sentiment:** ${triage.sentiment_signal}` : '',
137
+ triage.complexity_tier ? `**Complexity:** ${triage.complexity_tier}` : '',
138
+ triage.needs_escalation ? '**⚠ ESCALATION FLAGGED**' : '',
139
+ `**Message:** ${message}`,
140
+ userAgent ? `**User-Agent:** ${userAgent}` : '',
141
+ ].filter(Boolean).join('\n');
142
+
143
+ await bizops.callTool('log_interaction', {
144
+ org_id: STACKBILT_ORG_ID,
145
+ contact_id: contactId,
146
+ type: 'FEEDBACK',
147
+ direction: 'INBOUND',
148
+ subject: `[${triageCategory}] ${message.slice(0, 80)}`,
149
+ body: interactionBody.slice(0, 5000),
150
+ channel: 'web_form',
151
+ metadata_json: JSON.stringify({
152
+ category: triageCategory,
153
+ triage,
154
+ user_agent: userAgent,
155
+ }),
156
+ });
157
+ }
158
+
159
+ // Create agenda item — priority informed by triage
160
+ const triagePriority = triage.needs_escalation || triage.urgency_level === 'critical'
161
+ ? 'high' as const
162
+ : (category === 'bug' || triage.urgency_level === 'high')
163
+ ? 'high' as const
164
+ : 'medium' as const;
165
+ await addAgendaItem(
166
+ c.env.DB,
167
+ `Follow up on ${triage.ticket_category ?? category} feedback from ${email}`,
168
+ JSON.stringify({ feedback_id: id, category: triage.ticket_category ?? category, urgency: triage.urgency_level, sentiment: triage.sentiment_signal, email, message_preview: message.slice(0, 200) }),
169
+ triagePriority,
170
+ );
171
+ } catch {
172
+ // Non-fatal — feedback already stored in D1 + email sent
173
+ }
174
+ };
175
+
176
+ if (c.executionCtx) {
177
+ c.executionCtx.waitUntil(crmWork());
178
+ } else {
179
+ await crmWork();
180
+ }
181
+ }
182
+
183
+ return c.json({ id, received: true });
184
+ });
185
+
186
+ feedback.get('/api/feedback', async (c) => {
187
+ const limit = Math.min(parseInt(c.req.query('limit') ?? '50', 10), 200);
188
+ const rows = await c.env.DB.prepare(
189
+ 'SELECT id, email, category, message, source, created_at FROM feedback ORDER BY created_at DESC LIMIT ?'
190
+ ).bind(limit).all();
191
+ return c.json({ feedback: rows.results });
192
+ });
@@ -0,0 +1,147 @@
1
+ import { Hono } from 'hono';
2
+ import type { Env } from '../types.js';
3
+ import { getAllProcedures } from '../kernel/memory/index.js';
4
+ import { VERSION } from '../version.js';
5
+ import { healthPage, type HealthData } from '../health-page.js';
6
+
7
+ /** Allow consuming apps to override the reported version (set by createAegisApp). */
8
+ let appVersion: string | undefined;
9
+ export function setAppVersion(v: string): void { appVersion = v; }
10
+
11
+ interface CostHealthEntry {
12
+ spend_usd: number;
13
+ monthly_budget: number;
14
+ threshold_tier: string;
15
+ projected_depletion_days: number | null;
16
+ burn_rate_per_hour: number;
17
+ }
18
+
19
+ async function loadCostHealth(db: D1Database): Promise<Record<string, CostHealthEntry> | null> {
20
+ // Tables are owned by cost-monitor scheduled task; may not exist in fresh installs.
21
+ const budgets = await db
22
+ .prepare('SELECT provider, monthly_budget, current_spend, threshold_tier, current_period_start FROM cost_budgets')
23
+ .all<{ provider: string; monthly_budget: number; current_spend: number; threshold_tier: string; current_period_start: string }>()
24
+ .catch(() => null);
25
+ if (!budgets || budgets.results.length === 0) return null;
26
+
27
+ const result: Record<string, CostHealthEntry> = {};
28
+ for (const b of budgets.results) {
29
+ // Latest snapshot = best burn-rate signal. Fall back to spend/hours_elapsed.
30
+ const snap = await db
31
+ .prepare(
32
+ 'SELECT burn_rate_per_hour FROM cost_snapshots WHERE provider = ?1 ORDER BY created_at DESC LIMIT 1'
33
+ )
34
+ .bind(b.provider)
35
+ .first<{ burn_rate_per_hour: number }>()
36
+ .catch(() => null);
37
+
38
+ let burn = snap?.burn_rate_per_hour ?? 0;
39
+ if (!burn && b.current_spend > 0) {
40
+ const hoursElapsed = Math.max(
41
+ 1,
42
+ (Date.now() - new Date(b.current_period_start + 'Z').getTime()) / 3_600_000
43
+ );
44
+ burn = b.current_spend / hoursElapsed;
45
+ }
46
+
47
+ let depletion: number | null = null;
48
+ if (b.monthly_budget > 0 && burn > 0) {
49
+ const remaining = b.monthly_budget - b.current_spend;
50
+ depletion = remaining <= 0 ? 0 : remaining / burn / 24;
51
+ }
52
+
53
+ result[b.provider] = {
54
+ spend_usd: Number(b.current_spend.toFixed(4)),
55
+ monthly_budget: b.monthly_budget,
56
+ threshold_tier: b.threshold_tier,
57
+ projected_depletion_days: depletion != null ? Number(depletion.toFixed(2)) : null,
58
+ burn_rate_per_hour: Number(burn.toFixed(6)),
59
+ };
60
+ }
61
+ return result;
62
+ }
63
+
64
+ export const health = new Hono<{ Bindings: Env }>();
65
+
66
+ health.get('/health', async (c) => {
67
+ const procedures = await getAllProcedures(c.env.DB);
68
+
69
+ // Last 24h task run stats
70
+ const taskStats = await c.env.DB.prepare(`
71
+ SELECT task_name,
72
+ COUNT(*) as runs,
73
+ SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) as ok,
74
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors,
75
+ MAX(created_at) as last_run
76
+ FROM task_runs
77
+ WHERE created_at > datetime('now', '-24 hours')
78
+ GROUP BY task_name
79
+ ORDER BY errors DESC, task_name
80
+ `).all<{ task_name: string; runs: number; ok: number; errors: number; last_run: string }>().catch(() => ({ results: [] }));
81
+
82
+ const kernel = {
83
+ learned: procedures.filter(p => p.status === 'learned').length,
84
+ learning: procedures.filter(p => p.status === 'learning').length,
85
+ degraded: procedures.filter(p => p.status === 'degraded').length,
86
+ broken: procedures.filter(p => p.status === 'broken').length,
87
+ };
88
+
89
+ // Docs sync staleness watermark
90
+ const lastDocSync = await c.env.DB.prepare(
91
+ "SELECT received_at FROM web_events WHERE event_id = 'last_docs_sync_at'"
92
+ ).first<{ received_at: string }>().catch(() => null);
93
+ const docsSyncAgeMs = lastDocSync ? Date.now() - new Date(lastDocSync.received_at + 'Z').getTime() : null;
94
+ const docsSyncAgeHours = docsSyncAgeMs != null ? Math.round(docsSyncAgeMs / 3_600_000) : null;
95
+ const docsSyncStatus = {
96
+ lastSyncAge: docsSyncAgeHours,
97
+ status: (docsSyncAgeHours == null || docsSyncAgeHours > 168 ? 'alert' : docsSyncAgeHours > 48 ? 'warn' : 'ok') as 'ok' | 'warn' | 'alert',
98
+ };
99
+
100
+ // JSON response for programmatic consumers (?format=json, explicit Accept, curl)
101
+ const accept = c.req.header('accept') ?? '';
102
+ const wantsJson = c.req.query('format') === 'json'
103
+ || (accept.includes('application/json') && !accept.includes('text/html'));
104
+
105
+ if (wantsJson) {
106
+ const costHealth = await loadCostHealth(c.env.DB);
107
+ return c.json({
108
+ status: 'ok',
109
+ service: 'aegis-web',
110
+ version: appVersion ?? VERSION,
111
+ mode: 'edge-native',
112
+ timestamp: new Date().toISOString(),
113
+ kernel,
114
+ tasks_24h: taskStats.results,
115
+ docs_sync_status: docsSyncStatus,
116
+ cost_health: costHealth,
117
+ });
118
+ }
119
+
120
+ // Gather extra stats for the HTML dashboard (non-sensitive counts only)
121
+ const [memoryRow, agendaRow, goalRow, uptimeRow] = await Promise.all([
122
+ // Memory count from Memory Worker (sole knowledge store)
123
+ c.env.MEMORY
124
+ ? c.env.MEMORY.health().then(h => ({ c: h.active_fragments })).catch(() => ({ c: 0 }))
125
+ : Promise.resolve({ c: 0 }),
126
+ c.env.DB.prepare("SELECT COUNT(*) as c FROM agent_agenda WHERE status = 'active'").first<{ c: number }>().catch(() => ({ c: 0 })),
127
+ c.env.DB.prepare("SELECT COUNT(*) as c FROM agent_goals WHERE status = 'active'").first<{ c: number }>().catch(() => ({ c: 0 })),
128
+ c.env.DB.prepare("SELECT MIN(created_at) as first_run FROM task_runs").first<{ first_run: string | null }>().catch(() => ({ first_run: null })),
129
+ ]);
130
+
131
+ const uptimeHours = uptimeRow?.first_run
132
+ ? Math.round((Date.now() - new Date(uptimeRow.first_run + 'Z').getTime()) / 3_600_000)
133
+ : 0;
134
+
135
+ const healthData: HealthData = {
136
+ version: appVersion ?? VERSION,
137
+ kernel,
138
+ tasks: taskStats.results,
139
+ memoryCount: memoryRow?.c ?? 0,
140
+ agendaCount: agendaRow?.c ?? 0,
141
+ goalCount: goalRow?.c ?? 0,
142
+ uptimeHours,
143
+ docsSyncStatus,
144
+ };
145
+
146
+ return c.html(healthPage(healthData));
147
+ });
@@ -0,0 +1,228 @@
1
+ import { Hono } from 'hono';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ import { createIntent, dispatch, dispatchStream } from '../kernel/dispatch.js';
4
+ import { askGroq } from '../groq.js';
5
+ import type { Env, MessageMetadata } from '../types.js';
6
+ import { buildEdgeEnv } from '../edge-env.js';
7
+
8
+ // 100 KB — generous for chat text, blocks payload abuse
9
+ const MESSAGE_BODY_LIMIT = 100 * 1024;
10
+
11
+ const messages = new Hono<{ Bindings: Env }>();
12
+
13
+ // ─── Gateway URL helpers ──────────────────────────────────
14
+
15
+ function groqBaseUrl(env: Env): string | undefined {
16
+ if (!env.AI_GATEWAY_ID) return undefined;
17
+ if (!env.CF_ACCOUNT_ID) return undefined;
18
+ return `https://gateway.ai.cloudflare.com/v1/${env.CF_ACCOUNT_ID}/${env.AI_GATEWAY_ID}/groq`;
19
+ }
20
+
21
+ // ─── Auto-generate conversation title (#21) ──────────────
22
+
23
+ async function generateConversationTitle(
24
+ db: D1Database,
25
+ conversationId: string,
26
+ firstMessage: string,
27
+ groqApiKey: string,
28
+ groqModel: string,
29
+ groqBase?: string,
30
+ ): Promise<void> {
31
+ try {
32
+ const title = await askGroq(
33
+ groqApiKey,
34
+ groqModel,
35
+ 'Generate a concise 3-6 word title for a conversation that starts with the following message. Return ONLY the title, no quotes, no punctuation at the end.',
36
+ firstMessage,
37
+ groqBase,
38
+ );
39
+ const cleaned = title.trim().slice(0, 100);
40
+ if (cleaned) {
41
+ await db.prepare('UPDATE conversations SET title = ? WHERE id = ?').bind(cleaned, conversationId).run();
42
+ }
43
+ } catch {
44
+ // Non-fatal — leaves first-message slice as title
45
+ }
46
+ }
47
+
48
+ // ─── Send Message (edge-native kernel) ───────────────────────
49
+ messages.post('/api/message', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async (c) => {
50
+ const body = await c.req.json<{ text: string; conversationId?: string }>();
51
+ if (!body.text?.trim()) {
52
+ return c.json({ error: 'text is required' }, 400);
53
+ }
54
+
55
+ const text = body.text.trim();
56
+ const conversationId = body.conversationId ?? crypto.randomUUID();
57
+ const userMessageId = crypto.randomUUID();
58
+ const eventId = crypto.randomUUID();
59
+
60
+ // Event dedup
61
+ const existing = await c.env.DB.prepare(
62
+ 'SELECT event_id FROM web_events WHERE event_id = ?'
63
+ ).bind(eventId).first();
64
+ if (existing) {
65
+ return c.json({ error: 'duplicate event' }, 409);
66
+ }
67
+ await c.env.DB.prepare(
68
+ 'INSERT INTO web_events (event_id) VALUES (?)'
69
+ ).bind(eventId).run();
70
+
71
+ // Ensure conversation exists
72
+ const convInsert = await c.env.DB.prepare(
73
+ 'INSERT OR IGNORE INTO conversations (id, title) VALUES (?, ?)'
74
+ ).bind(conversationId, text.slice(0, 100)).run();
75
+ const isNewConversation = (convInsert.meta.changes ?? 0) > 0;
76
+
77
+ // Save user message
78
+ await c.env.DB.prepare(
79
+ 'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
80
+ ).bind(userMessageId, conversationId, 'user', text).run();
81
+
82
+ // Fire-and-forget title generation for new conversations (#21)
83
+ if (isNewConversation && c.executionCtx) {
84
+ c.executionCtx.waitUntil(
85
+ generateConversationTitle(c.env.DB, conversationId, text, c.env.GROQ_API_KEY, c.env.GROQ_MODEL || 'llama-3.3-70b-versatile', groqBaseUrl(c.env)),
86
+ );
87
+ }
88
+
89
+ // Dispatch through edge kernel
90
+ const edgeEnv = buildEdgeEnv(c.env, c.executionCtx);
91
+ const intent = createIntent(conversationId, text);
92
+
93
+ try {
94
+ const result = await dispatch(intent, edgeEnv);
95
+
96
+ // Save assistant message
97
+ const assistantMessageId = crypto.randomUUID();
98
+ const metadata: MessageMetadata = {
99
+ classification: result.classification,
100
+ executor: result.executor,
101
+ procHit: result.procedureHit,
102
+ latencyMs: result.latency_ms,
103
+ cost: result.cost,
104
+ confidence: result.confidence,
105
+ reclassified: result.reclassified,
106
+ probeResult: result.probeResult,
107
+ };
108
+
109
+ await c.env.DB.prepare(
110
+ 'INSERT INTO messages (id, conversation_id, role, content, metadata) VALUES (?, ?, ?, ?, ?)'
111
+ ).bind(assistantMessageId, conversationId, 'assistant', result.text, JSON.stringify(metadata)).run();
112
+
113
+ await c.env.DB.prepare(
114
+ "UPDATE conversations SET updated_at = datetime('now') WHERE id = ?"
115
+ ).bind(conversationId).run();
116
+
117
+ return c.json({
118
+ conversationId,
119
+ message: {
120
+ id: assistantMessageId,
121
+ role: 'assistant',
122
+ content: result.text,
123
+ metadata,
124
+ },
125
+ });
126
+ } catch (err) {
127
+ const errMsg = err instanceof Error ? err.message : String(err);
128
+ const errMessageId = crypto.randomUUID();
129
+
130
+ await c.env.DB.prepare(
131
+ 'INSERT INTO messages (id, conversation_id, role, content, metadata) VALUES (?, ?, ?, ?, ?)'
132
+ ).bind(errMessageId, conversationId, 'assistant', `Error: ${errMsg}`, JSON.stringify({ error: true })).run();
133
+
134
+ console.error('Kernel error:', errMsg);
135
+ return c.json({
136
+ conversationId,
137
+ message: {
138
+ id: errMessageId,
139
+ role: 'assistant',
140
+ content: 'An error occurred processing your message',
141
+ metadata: { error: true },
142
+ },
143
+ }, 500);
144
+ }
145
+ });
146
+
147
+ // ─── Streaming Message (SSE) ─────────────────────────────────
148
+ messages.post('/api/message/stream', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async (c) => {
149
+ const body = await c.req.json<{ text: string; conversationId?: string }>();
150
+ if (!body.text?.trim()) {
151
+ return c.json({ error: 'text is required' }, 400);
152
+ }
153
+
154
+ const text = body.text.trim();
155
+ const conversationId = body.conversationId ?? crypto.randomUUID();
156
+ const userMessageId = crypto.randomUUID();
157
+ const eventId = crypto.randomUUID();
158
+
159
+ // Event dedup
160
+ const existing = await c.env.DB.prepare(
161
+ 'SELECT event_id FROM web_events WHERE event_id = ?'
162
+ ).bind(eventId).first();
163
+ if (existing) return c.json({ error: 'duplicate event' }, 409);
164
+ await c.env.DB.prepare('INSERT INTO web_events (event_id) VALUES (?)').bind(eventId).run();
165
+
166
+ // Ensure conversation + save user message
167
+ const convInsertStream = await c.env.DB.prepare('INSERT OR IGNORE INTO conversations (id, title) VALUES (?, ?)').bind(conversationId, text.slice(0, 100)).run();
168
+ const isNewConvStream = (convInsertStream.meta.changes ?? 0) > 0;
169
+ await c.env.DB.prepare('INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)').bind(userMessageId, conversationId, 'user', text).run();
170
+
171
+ // Fire-and-forget title generation for new conversations (#21)
172
+ if (isNewConvStream && c.executionCtx) {
173
+ c.executionCtx.waitUntil(
174
+ generateConversationTitle(c.env.DB, conversationId, text, c.env.GROQ_API_KEY, c.env.GROQ_MODEL || 'llama-3.3-70b-versatile', groqBaseUrl(c.env)),
175
+ );
176
+ }
177
+
178
+ const edgeEnv = buildEdgeEnv(c.env, c.executionCtx);
179
+ const intent = createIntent(conversationId, text);
180
+
181
+ const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
182
+ const writer = writable.getWriter();
183
+ const encoder = new TextEncoder();
184
+
185
+ const writeSSE = (data: unknown) => writer.write(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
186
+
187
+ // Run dispatch async — stream stays open until writer.close()
188
+ (async () => {
189
+ try {
190
+ await writeSSE({ type: 'start', conversationId });
191
+
192
+ const result = await dispatchStream(intent, edgeEnv, async (delta) => {
193
+ await writeSSE({ type: 'delta', text: delta });
194
+ });
195
+
196
+ // Persist assistant message
197
+ const assistantMessageId = crypto.randomUUID();
198
+ const metadata: MessageMetadata = {
199
+ classification: result.classification,
200
+ executor: result.executor,
201
+ procHit: result.procedureHit,
202
+ latencyMs: result.latency_ms,
203
+ cost: result.cost,
204
+ confidence: result.confidence,
205
+ reclassified: result.reclassified,
206
+ probeResult: result.probeResult,
207
+ };
208
+ await c.env.DB.prepare('INSERT INTO messages (id, conversation_id, role, content, metadata) VALUES (?, ?, ?, ?, ?)').bind(assistantMessageId, conversationId, 'assistant', result.text, JSON.stringify(metadata)).run();
209
+ await c.env.DB.prepare("UPDATE conversations SET updated_at = datetime('now') WHERE id = ?").bind(conversationId).run();
210
+
211
+ await writeSSE({ type: 'done', conversationId, metadata: { id: assistantMessageId, ...metadata } });
212
+ } catch (err) {
213
+ await writeSSE({ type: 'error', error: err instanceof Error ? err.message : String(err) });
214
+ } finally {
215
+ await writer.close();
216
+ }
217
+ })();
218
+
219
+ return new Response(readable, {
220
+ headers: {
221
+ 'Content-Type': 'text/event-stream',
222
+ 'Cache-Control': 'no-cache',
223
+ 'X-Accel-Buffering': 'no',
224
+ },
225
+ });
226
+ });
227
+
228
+ export { messages };