@stackbilt/aegis-core 0.6.5 → 0.8.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 (121) hide show
  1. package/cli/aegis.mjs +356 -0
  2. package/package.json +21 -3
  3. package/public/assets/index-CQHn03rW.css +1 -0
  4. package/public/assets/index-CTKpNJEr.js +74 -0
  5. package/public/index.html +14 -0
  6. package/schema.sql +4 -0
  7. package/src/adapters/voice/cloudflare-agent.ts +0 -0
  8. package/src/agent-routing.ts +38 -0
  9. package/src/assets.ts +6 -0
  10. package/src/auth.ts +14 -5
  11. package/src/bluesky.ts +0 -0
  12. package/src/claude-tools/content.ts +0 -0
  13. package/src/claude-tools/email.ts +0 -0
  14. package/src/claude.ts +133 -268
  15. package/src/codebeast.ts +0 -0
  16. package/src/composite.ts +49 -79
  17. package/src/content/column.ts +0 -0
  18. package/src/content/hero-image.ts +0 -0
  19. package/src/content/index.ts +0 -0
  20. package/src/content/journal.ts +0 -0
  21. package/src/content/roundtable.ts +0 -0
  22. package/src/contracts/agenda-item.contract.ts +0 -0
  23. package/src/contracts/cc-task.contract.ts +0 -0
  24. package/src/contracts/goal.contract.ts +0 -0
  25. package/src/contracts/memory-entry.contract.ts +0 -0
  26. package/src/core.ts +5 -0
  27. package/src/dashboard.ts +0 -0
  28. package/src/decision-docs.ts +0 -0
  29. package/src/dispatch.ts +0 -0
  30. package/src/durable-objects/chat-session-auth.ts +20 -0
  31. package/src/durable-objects/chat-session.ts +251 -0
  32. package/src/edge-env.ts +0 -0
  33. package/src/exports.ts +0 -0
  34. package/src/github-projects.ts +0 -0
  35. package/src/groq.ts +61 -113
  36. package/src/index.ts +11 -1
  37. package/src/kernel/argus-actions.ts +0 -0
  38. package/src/kernel/argus-correlation.ts +0 -0
  39. package/src/kernel/board.ts +0 -0
  40. package/src/kernel/classify-memory-topic.ts +0 -0
  41. package/src/kernel/disambiguation.ts +55 -0
  42. package/src/kernel/dispatch.ts +59 -44
  43. package/src/kernel/dynamic-tools.ts +30 -52
  44. package/src/kernel/executor-port.ts +0 -0
  45. package/src/kernel/executor-router.ts +0 -0
  46. package/src/kernel/executors/claude.ts +1 -0
  47. package/src/kernel/executors/direct.ts +14 -0
  48. package/src/kernel/executors/workers-ai.ts +5 -0
  49. package/src/kernel/grounding/fabrication-detector.ts +0 -0
  50. package/src/kernel/grounding/fanout.ts +0 -0
  51. package/src/kernel/grounding/semantic-sanhedrin.ts +0 -0
  52. package/src/kernel/grounding/verify.ts +0 -0
  53. package/src/kernel/grounding-layer.ts +0 -0
  54. package/src/kernel/insight-cache.ts +0 -0
  55. package/src/kernel/memory/episodic.ts +3 -1
  56. package/src/kernel/memory/insights.ts +0 -0
  57. package/src/kernel/memory-guardrails.ts +0 -0
  58. package/src/kernel/memory-service.ts +0 -0
  59. package/src/kernel/patterns.ts +0 -0
  60. package/src/kernel/port.ts +0 -0
  61. package/src/kernel/provider-factory.ts +0 -0
  62. package/src/kernel/resilience.ts +0 -0
  63. package/src/kernel/router.ts +33 -11
  64. package/src/kernel/scheduled/agent-dispatch.ts +0 -0
  65. package/src/kernel/scheduled/argus-analytics.ts +0 -0
  66. package/src/kernel/scheduled/argus-heartbeat.ts +0 -0
  67. package/src/kernel/scheduled/argus-notify.ts +0 -0
  68. package/src/kernel/scheduled/board-sync.ts +0 -0
  69. package/src/kernel/scheduled/ci-watcher.ts +0 -0
  70. package/src/kernel/scheduled/content-drip.ts +0 -0
  71. package/src/kernel/scheduled/content.ts +0 -0
  72. package/src/kernel/scheduled/conversation-facts.ts +9 -7
  73. package/src/kernel/scheduled/cost-report.ts +0 -0
  74. package/src/kernel/scheduled/dev-activity.ts +0 -0
  75. package/src/kernel/scheduled/digest.ts +30 -3
  76. package/src/kernel/scheduled/dreaming/agenda-triage.ts +0 -0
  77. package/src/kernel/scheduled/dreaming/facts.ts +0 -0
  78. package/src/kernel/scheduled/dreaming/index.ts +0 -0
  79. package/src/kernel/scheduled/dreaming/llm.ts +9 -5
  80. package/src/kernel/scheduled/dreaming/pattern-synthesis.ts +0 -0
  81. package/src/kernel/scheduled/dreaming/persona.ts +0 -0
  82. package/src/kernel/scheduled/dreaming/symbolic.ts +0 -0
  83. package/src/kernel/scheduled/dreaming/task-proposals.ts +0 -0
  84. package/src/kernel/scheduled/entropy.ts +0 -0
  85. package/src/kernel/scheduled/feed-watcher.ts +0 -0
  86. package/src/kernel/scheduled/inbox-processor.ts +0 -0
  87. package/src/kernel/scheduled/issue-proposer.ts +0 -0
  88. package/src/kernel/scheduled/issue-watcher.ts +0 -0
  89. package/src/kernel/scheduled/pr-automerge.ts +0 -0
  90. package/src/kernel/scheduled/product-health.ts +0 -0
  91. package/src/kernel/scheduled/self-improvement.ts +0 -0
  92. package/src/kernel/scheduled/social-engage.ts +12 -8
  93. package/src/kernel/scheduled/task-audit.ts +0 -0
  94. package/src/kernel/types.ts +6 -0
  95. package/src/landing.ts +0 -0
  96. package/src/lib/audit-chain/chain.ts +0 -0
  97. package/src/lib/audit-chain/types.ts +0 -0
  98. package/src/lib/observability/errors.ts +0 -0
  99. package/src/operator/config.ts +0 -0
  100. package/src/operator/persona.ts +0 -0
  101. package/src/operator/prompt-builder.ts +3 -0
  102. package/src/pulse.ts +0 -0
  103. package/src/routes/bluesky.ts +0 -0
  104. package/src/routes/chat-ws.ts +17 -0
  105. package/src/routes/codebeast.ts +0 -0
  106. package/src/routes/content.ts +0 -0
  107. package/src/routes/dynamic-tools.ts +0 -0
  108. package/src/routes/messages.ts +11 -6
  109. package/src/routes/observability.ts +0 -0
  110. package/src/routes/operator-logs.ts +0 -0
  111. package/src/routes/pages.ts +12 -1
  112. package/src/schema-enums.ts +0 -0
  113. package/src/task-intelligence.ts +0 -0
  114. package/src/types.ts +8 -0
  115. package/src/ui/index.html +13 -0
  116. package/src/ui/main.tsx +356 -0
  117. package/src/ui/styles.css +391 -0
  118. package/src/ui.ts +594 -2
  119. package/src/version.ts +3 -3
  120. package/src/wiki/client.ts +0 -0
  121. package/src/wiki/types.ts +0 -0
package/src/composite.ts CHANGED
@@ -7,9 +7,10 @@
7
7
  // Claude Sonnet: $3/$15 per MTok (synthesize)
8
8
  // Expected total: $0.01-0.03 per composite query
9
9
 
10
+ import type { LLMMessage, ToolResult as LLMToolResult } from '@stackbilt/llm-providers';
10
11
  import { askGroqJson } from './groq.js';
11
12
  import { buildContext, handleInProcessTool, callMcpWithRetry, resolveMcpTool } from './claude.js';
12
- import { toOpenAiTools, extractText, extractToolCalls, extractUsage, type AiChatResponse } from './workers-ai-chat.js';
13
+ import { toOpenAiTools } from './workers-ai-chat.js';
13
14
  import { McpClient, McpRegistry } from './mcp-client.js';
14
15
  import { operatorConfig } from './operator/index.js';
15
16
  import { buildPersonaPreamble } from './operator/prompt-builder.js';
@@ -17,6 +18,7 @@ import { getCognitiveState, formatCognitiveContext } from './kernel/cognition.js
17
18
  import { getAttachedBlocks, assembleBlockContext } from './kernel/memory/blocks.js';
18
19
  import { getConversationHistory, budgetConversationHistory } from './kernel/memory/index.js';
19
20
  import { classifyCourtCard, type CourtCard, type CourtCardProfile } from './kernel/court-cards.js';
21
+ import { buildLLMProviderFactory } from './kernel/provider-factory.js';
20
22
  import type { KernelIntent } from './kernel/types.js';
21
23
  import type { EdgeEnv } from './kernel/dispatch.js';
22
24
 
@@ -43,10 +45,6 @@ interface SubtaskResult {
43
45
  // ─── Cost rates ─────────────────────────────────────────────
44
46
 
45
47
  const GROQ_GPT_OSS_RATES = { input: 0.15, output: 0.60 };
46
- const CF_GPT_OSS_RATES = { input: 0.35, output: 0.75 };
47
- const CLAUDE_SONNET_RATES = { input: 3, output: 15 };
48
- const CLAUDE_OPUS_RATES = { input: 15, output: 75 };
49
-
50
48
  // ─── Phase 1: Orchestrate ───────────────────────────────────
51
49
 
52
50
  const ORCHESTRATOR_SYSTEM = `You are a task decomposition engine. Given a user query and a list of available tools, decompose the query into subtasks that can be executed independently.
@@ -160,13 +158,7 @@ function validateDagIntent(userQuery: string, dag: ExecutionDAG): boolean {
160
158
  return true;
161
159
  }
162
160
 
163
- // ─── Phase 2: Gather (CF Workers AI tool loop) ─────────────
164
-
165
- type ChatMessage =
166
- | { role: 'system'; content: string }
167
- | { role: 'user'; content: string }
168
- | { role: 'assistant'; content: string; tool_calls?: Array<{ id: string; type: string; function: { name: string; arguments: string } }> }
169
- | { role: 'tool'; tool_call_id: string; content: string };
161
+ // ─── Phase 2: Gather (provider-backed Workers AI tool loop) ──
170
162
 
171
163
  const MAX_GATHER_ROUNDS = 6;
172
164
 
@@ -210,7 +202,6 @@ async function synthesize(
210
202
  courtCard?: CourtCardProfile,
211
203
  ): Promise<{ text: string; cost: number }> {
212
204
  const model = useOpus ? env.opusModel : env.claudeModel;
213
- const rates = useOpus ? CLAUDE_OPUS_RATES : CLAUDE_SONNET_RATES;
214
205
 
215
206
  const subtaskSummary = subtaskResults.map(r => {
216
207
  // Include raw gathered data so synthesis can recover structured values the analysis step may have dropped
@@ -233,38 +224,17 @@ async function synthesize(
233
224
  }
234
225
  } catch { /* non-fatal — synthesize without cognitive context */ }
235
226
 
236
- const response = await fetch(`${env.anthropicBaseUrl}/v1/messages`, {
237
- method: 'POST',
238
- headers: {
239
- 'Content-Type': 'application/json',
240
- 'x-api-key': env.anthropicApiKey,
241
- 'anthropic-version': '2023-06-01',
242
- },
243
- body: JSON.stringify({
244
- model,
245
- max_tokens: 4096,
246
- system: `${buildPersonaPreamble()} Synthesize the analyzed subtask results into a coherent, actionable answer. Speak as AEGIS — the co-founder who knows the business inside-out. Be thorough but concise. Reference specific products, numbers, and context from the portfolio below. Never give generic consultant advice; give the answer a co-founder would give.${courtCard ? ` ${courtCard.synthesisVoice}` : ''}${contextSuffix}`,
247
- messages: [{
248
- role: 'user',
249
- content: `Original query: ${intent.raw}\n\nSynthesis instruction: ${synthesisInstruction}\n\nSubtask results:\n${subtaskSummary}`,
250
- }],
251
- }),
227
+ const result = await buildLLMProviderFactory(env).generateResponse({
228
+ model,
229
+ maxTokens: 4096,
230
+ systemPrompt: `${buildPersonaPreamble()} Synthesize the analyzed subtask results into a coherent, actionable answer. Speak as AEGIS — the co-founder who knows the business inside-out. Be thorough but concise. Reference specific products, numbers, and context from the portfolio below. Never give generic consultant advice; give the answer a co-founder would give.${courtCard ? ` ${courtCard.synthesisVoice}` : ''}${contextSuffix}`,
231
+ messages: [{
232
+ role: 'user',
233
+ content: `Original query: ${intent.raw}\n\nSynthesis instruction: ${synthesisInstruction}\n\nSubtask results:\n${subtaskSummary}`,
234
+ }],
252
235
  });
253
236
 
254
- if (!response.ok) {
255
- const err = await response.text();
256
- throw new Error(`Anthropic API error ${response.status}: ${err}`);
257
- }
258
-
259
- const data = await response.json<{
260
- content: Array<{ type: string; text?: string }>;
261
- usage: { input_tokens: number; output_tokens: number };
262
- }>();
263
-
264
- const text = data.content.filter(b => b.type === 'text').map(b => b.text ?? '').join('');
265
- const cost = (data.usage.input_tokens * rates.input + data.usage.output_tokens * rates.output) / 1_000_000;
266
-
267
- return { text: text || '(no synthesis)', cost };
237
+ return { text: result.message || '(no synthesis)', cost: result.usage.cost };
268
238
  }
269
239
 
270
240
  // ─── Groq synthesis fallback ────────────────────────────────
@@ -363,7 +333,10 @@ export async function executeComposite(
363
333
  githubRepo: env.githubRepo,
364
334
  braveApiKey: env.braveApiKey,
365
335
  roundtableDb: env.roundtableDb,
336
+ memoryBinding: env.memoryBinding,
337
+ resendApiKeys: { resendApiKey: env.resendApiKey, resendApiKeyPersonal: env.resendApiKeyPersonal },
366
338
  userQuery: intent.raw,
339
+ edgeEnv: env,
367
340
  }, env.roundtableDb);
368
341
 
369
342
  // Load conversation history for context continuity
@@ -602,7 +575,7 @@ async function gatherSubtaskInstrumented(
602
575
  ? `Original request: ${originalQuery}\n\nYour subtask: ${subtask.description}\n\nIMPORTANT: Use exact identifiers (UUIDs, IDs, enum values) from the original request when calling tools.`
603
576
  : subtask.description;
604
577
 
605
- const messages: ChatMessage[] = [
578
+ const messages: LLMMessage[] = [
606
579
  { role: 'system', content: `${systemPrompt}\n\nFocus: ${subtask.description}\nGather the data needed and return your findings.` },
607
580
  { role: 'user', content: userContent },
608
581
  ];
@@ -613,29 +586,26 @@ async function gatherSubtaskInstrumented(
613
586
  // Tool loop — up to MAX_GATHER_ROUNDS
614
587
  for (let round = 0; round < MAX_GATHER_ROUNDS; round++) {
615
588
  subrequestCount += 1; // 1 AI call per round
616
- const result = await env.ai.run(env.gptOssModel as Parameters<Ai['run']>[0], {
617
- messages,
618
- ...(openAiTools.length > 0 ? { tools: openAiTools } : {}),
619
- max_tokens: 2048,
589
+ const result = await buildLLMProviderFactory(env).generateResponse({
590
+ messages: [...messages],
591
+ model: env.gptOssModel,
592
+ ...(openAiTools.length > 0 ? { tools: openAiTools as Parameters<ReturnType<typeof buildLLMProviderFactory>['generateResponse']>[0]['tools'] } : {}),
593
+ maxTokens: 2048,
620
594
  temperature: 0.2,
621
- top_p: 0.9,
622
- frequency_penalty: 0.3,
623
- } as Record<string, unknown>) as AiChatResponse;
624
-
625
- const usage = extractUsage(result);
626
- if (usage) {
627
- totalCost += (usage.prompt_tokens * CF_GPT_OSS_RATES.input
628
- + usage.completion_tokens * CF_GPT_OSS_RATES.output) / 1_000_000;
629
- }
595
+ topP: 0.9,
596
+ frequencyPenalty: 0.3,
597
+ });
630
598
 
631
- const toolCalls = extractToolCalls(result);
632
- const responseText = extractText(result);
599
+ totalCost += result.usage.cost;
600
+ const toolCalls = result.toolCalls ?? [];
601
+ const responseText = result.message;
633
602
 
634
603
  if (toolCalls.length === 0) {
635
604
  return { gathered: responseText ?? '(no data gathered)', cost: totalCost, subrequestCount };
636
605
  }
637
606
 
638
- messages.push({ role: 'assistant', content: responseText ?? '', tool_calls: toolCalls });
607
+ messages.push({ role: 'assistant', content: responseText ?? '', toolCalls });
608
+ const toolResults: LLMToolResult[] = [];
639
609
 
640
610
  for (const call of toolCalls) {
641
611
  subrequestCount += 1; // 1 subrequest per tool call (external fetch or DB query)
@@ -650,6 +620,7 @@ async function gatherSubtaskInstrumented(
650
620
  { apiKey: env.anthropicApiKey, model: env.claudeModel, baseUrl: env.anthropicBaseUrl },
651
621
  env.memoryBinding,
652
622
  { resendApiKey: env.resendApiKey, resendApiKeyPersonal: env.resendApiKeyPersonal },
623
+ env,
653
624
  );
654
625
 
655
626
  if (inProcess !== null) {
@@ -663,26 +634,29 @@ async function gatherSubtaskInstrumented(
663
634
  }
664
635
  }
665
636
 
666
- messages.push({ role: 'tool', tool_call_id: call.id, content: toolResult });
637
+ toolResults.push({ id: call.id, output: toolResult });
667
638
  }
639
+ messages.push({ role: 'user', content: '', toolResults });
668
640
  }
669
641
 
670
642
  // Force summary if tool rounds exhausted
671
643
  // Condense messages: strip tool_calls metadata and truncate tool results
672
644
  // to prevent context overflow when sending to GPT-OSS without tools definition
673
- const condensedGather: ChatMessage[] = [messages[0]]; // system
645
+ const condensedGather: LLMMessage[] = [messages[0]]; // system
674
646
  const gatherFindings: string[] = [];
675
647
  for (let i = 1; i < messages.length; i++) {
676
648
  const msg = messages[i];
677
- if (msg.role === 'user' && !('tool_call_id' in msg)) {
678
- condensedGather.push(msg);
649
+ if (msg.role === 'user' && !msg.toolResults) {
650
+ condensedGather.push({ role: 'user', content: msg.content });
679
651
  } else if (msg.role === 'assistant' && msg.content) {
680
652
  gatherFindings.push(msg.content);
681
- } else if (msg.role === 'tool') {
682
- const truncated = msg.content.length > 2000
683
- ? msg.content.slice(0, 2000) + '... [truncated]'
684
- : msg.content;
685
- gatherFindings.push(truncated);
653
+ } else if (msg.toolResults) {
654
+ for (const toolResult of msg.toolResults) {
655
+ const truncated = toolResult.output.length > 2000
656
+ ? toolResult.output.slice(0, 2000) + '... [truncated]'
657
+ : toolResult.output;
658
+ gatherFindings.push(truncated);
659
+ }
686
660
  }
687
661
  }
688
662
  if (gatherFindings.length > 0) {
@@ -699,17 +673,13 @@ async function gatherSubtaskInstrumented(
699
673
  condensedGather.push({ role: 'user', content: 'Summarize all data gathered so far. Return the raw findings.' });
700
674
 
701
675
  subrequestCount += 1;
702
- const summaryResult = await env.ai.run(env.gptOssModel as Parameters<Ai['run']>[0], {
676
+ const summaryResult = await buildLLMProviderFactory(env).generateResponse({
703
677
  messages: condensedGather,
704
- max_tokens: 2048,
678
+ model: env.gptOssModel,
679
+ maxTokens: 2048,
705
680
  temperature: 0.2,
706
- } as Record<string, unknown>) as AiChatResponse;
707
-
708
- const summaryUsage = extractUsage(summaryResult);
709
- if (summaryUsage) {
710
- totalCost += (summaryUsage.prompt_tokens * CF_GPT_OSS_RATES.input
711
- + summaryUsage.completion_tokens * CF_GPT_OSS_RATES.output) / 1_000_000;
712
- }
681
+ });
713
682
 
714
- return { gathered: extractText(summaryResult) ?? '(gather exhausted)', cost: totalCost, subrequestCount };
683
+ totalCost += summaryResult.usage.cost;
684
+ return { gathered: summaryResult.message ?? '(gather exhausted)', cost: totalCost, subrequestCount };
715
685
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/src/core.ts CHANGED
@@ -27,6 +27,7 @@ import { observability } from './routes/observability.js';
27
27
  import { pages } from './routes/pages.js';
28
28
  import { ccTasks } from './routes/cc-tasks.js';
29
29
  import { messages } from './routes/messages.js';
30
+ import { chatWs } from './routes/chat-ws.js';
30
31
  import { dynamicToolsRoutes } from './routes/dynamic-tools.js';
31
32
 
32
33
  // ─── Scheduled Task Plugin ──────────────────────────────────
@@ -213,6 +214,7 @@ export function createAegisApp(config: AegisAppConfig): AegisApp {
213
214
  app.route('/', pages);
214
215
  app.route('/', ccTasks);
215
216
  app.route('/', messages);
217
+ app.route('/', chatWs);
216
218
  app.route('/', dynamicToolsRoutes);
217
219
 
218
220
  // ── Extension routes ──
@@ -268,6 +270,7 @@ export function createAegisApp(config: AegisAppConfig): AegisApp {
268
270
  pages,
269
271
  ccTasks,
270
272
  messages,
273
+ chatWs,
271
274
  dynamicTools: dynamicToolsRoutes,
272
275
  };
273
276
 
@@ -288,6 +291,8 @@ export type {
288
291
  MessageMetadata,
289
292
  } from './types.js';
290
293
 
294
+ export { ChatSession } from './durable-objects/chat-session.js';
295
+
291
296
  export type { EdgeEnv } from './kernel/dispatch.js';
292
297
 
293
298
  export type {
package/src/dashboard.ts CHANGED
File without changes
File without changes
package/src/dispatch.ts CHANGED
File without changes
@@ -0,0 +1,20 @@
1
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2
+
3
+ export type ConversationOwnership = 'owned' | 'not_owned' | 'not_found';
4
+
5
+ export function isValidConversationId(id: string): boolean {
6
+ return UUID_RE.test(id);
7
+ }
8
+
9
+ export async function verifyConversationOwnership(
10
+ db: D1Database,
11
+ conversationId: string,
12
+ userId: string,
13
+ ): Promise<ConversationOwnership> {
14
+ const row = await db.prepare(
15
+ 'SELECT user_id FROM conversations WHERE id = ?',
16
+ ).bind(conversationId).first<{ user_id: string | null }>();
17
+
18
+ if (!row) return 'not_found';
19
+ return (row.user_id ?? 'operator') === userId ? 'owned' : 'not_owned';
20
+ }
@@ -0,0 +1,251 @@
1
+ import type { Env, MessageMetadata } from '../types.js';
2
+ import { buildEdgeEnv } from '../edge-env.js';
3
+ import { createIntent, dispatchStream } from '../kernel/dispatch.js';
4
+ import type { DispatchResult, Executor } from '../kernel/types.js';
5
+ import { isValidConversationId, verifyConversationOwnership } from './chat-session-auth.js';
6
+ import { z } from 'zod';
7
+
8
+ const ExecutorSchema = z.enum([
9
+ 'claude',
10
+ 'groq',
11
+ 'workers_ai',
12
+ 'claude_opus',
13
+ 'gpt_oss',
14
+ 'composite',
15
+ ]);
16
+
17
+ const MessageFrameSchema = z.object({
18
+ type: z.literal('message'),
19
+ text: z.string().trim().min(1),
20
+ conversationId: z.string().optional(),
21
+ eventId: z.string().trim().min(1).optional(),
22
+ executor: ExecutorSchema.optional(),
23
+ }).passthrough();
24
+
25
+ type StoredMessage = {
26
+ id: string;
27
+ role: 'user' | 'assistant';
28
+ content: string;
29
+ metadata: string | null;
30
+ created_at: string;
31
+ };
32
+
33
+ export class ChatSession implements DurableObject {
34
+ constructor(
35
+ private readonly state: DurableObjectState,
36
+ private readonly env: Env,
37
+ ) {}
38
+
39
+ async fetch(request: Request): Promise<Response> {
40
+ if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
41
+ return new Response('Expected WebSocket upgrade', { status: 426 });
42
+ }
43
+
44
+ const pair = new WebSocketPair();
45
+ const client = pair[0];
46
+ const server = pair[1];
47
+ const url = new URL(request.url);
48
+ const userId = this.userId();
49
+ const conversationId = url.searchParams.get('conversationId');
50
+
51
+ this.state.acceptWebSocket(server, ['aegis-chat']);
52
+ this.state.waitUntil(this.sendHistory(server, conversationId, userId));
53
+
54
+ const headers = new Headers();
55
+ if (request.headers.get('Sec-WebSocket-Protocol')?.split(',').map((p) => p.trim()).includes('aegis-chat')) {
56
+ headers.set('Sec-WebSocket-Protocol', 'aegis-chat');
57
+ }
58
+
59
+ return new Response(null, {
60
+ status: 101,
61
+ webSocket: client,
62
+ headers,
63
+ });
64
+ }
65
+
66
+ async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
67
+ let payload: unknown;
68
+ try {
69
+ payload = JSON.parse(typeof message === 'string' ? message : new TextDecoder().decode(message));
70
+ } catch {
71
+ this.send(ws, { type: 'error', error: 'Invalid JSON frame' });
72
+ return;
73
+ }
74
+
75
+ if (!isRecord(payload) || payload.type !== 'message') return;
76
+
77
+ const parsed = MessageFrameSchema.safeParse(payload);
78
+ if (!parsed.success) {
79
+ this.send(ws, { type: 'error', error: 'Invalid message frame' });
80
+ return;
81
+ }
82
+
83
+ const eventId = parsed.data.eventId ?? crypto.randomUUID();
84
+ const eventClaimed = await this.claimEvent(eventId);
85
+ if (!eventClaimed) {
86
+ this.send(ws, { type: 'error', error: 'duplicate event' });
87
+ return;
88
+ }
89
+
90
+ const text = parsed.data.text;
91
+ const userId = this.userId();
92
+ const conversationId = await this.resolveConversationId(ws, parsed.data.conversationId, userId, text);
93
+ if (!conversationId) return;
94
+
95
+ const userMessageId = crypto.randomUUID();
96
+ await this.env.DB.prepare(
97
+ 'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)',
98
+ ).bind(userMessageId, conversationId, 'user', text).run();
99
+
100
+ this.send(ws, { type: 'start', conversationId });
101
+
102
+ try {
103
+ const edgeEnv = buildEdgeEnv(this.env);
104
+ const intent = createIntent(conversationId, text, {
105
+ forcedExecutor: parsed.data.executor as Executor | undefined,
106
+ });
107
+ const result = await dispatchStream(intent, edgeEnv, (delta) => {
108
+ this.send(ws, { type: 'delta', text: delta });
109
+ });
110
+
111
+ const assistantMessageId = crypto.randomUUID();
112
+ const metadata = buildMessageMetadata(result);
113
+
114
+ await this.env.DB.prepare(
115
+ 'INSERT INTO messages (id, conversation_id, role, content, metadata) VALUES (?, ?, ?, ?, ?)',
116
+ ).bind(assistantMessageId, conversationId, 'assistant', result.text, JSON.stringify(metadata)).run();
117
+
118
+ await this.env.DB.prepare(
119
+ "UPDATE conversations SET updated_at = datetime('now') WHERE id = ?",
120
+ ).bind(conversationId).run();
121
+
122
+ this.send(ws, { type: 'done', conversationId, metadata: { id: assistantMessageId, ...metadata } });
123
+ } catch (err) {
124
+ const error = err instanceof Error ? err.message : String(err);
125
+ this.send(ws, { type: 'error', error });
126
+ }
127
+ }
128
+
129
+ private async resolveConversationId(
130
+ ws: WebSocket,
131
+ rawConversationId: unknown,
132
+ userId: string,
133
+ firstMessage: string,
134
+ ): Promise<string | null> {
135
+ if (rawConversationId !== undefined && typeof rawConversationId !== 'string') {
136
+ this.send(ws, { type: 'error', error: 'conversationId must be a UUID string' });
137
+ return null;
138
+ }
139
+
140
+ const conversationId = rawConversationId ?? crypto.randomUUID();
141
+ if (!isValidConversationId(conversationId)) {
142
+ this.send(ws, { type: 'error', error: 'conversationId must be a UUID string' });
143
+ return null;
144
+ }
145
+
146
+ const ownership = await verifyConversationOwnership(this.env.DB, conversationId, userId);
147
+ if (ownership === 'not_owned') {
148
+ this.send(ws, { type: 'error', error: 'conversation not found' });
149
+ return null;
150
+ }
151
+ if (ownership === 'owned') return conversationId;
152
+
153
+ await this.env.DB.prepare(
154
+ 'INSERT INTO conversations (id, title, user_id) VALUES (?, ?, ?)',
155
+ ).bind(conversationId, firstMessage.slice(0, 100), userId).run();
156
+ return conversationId;
157
+ }
158
+
159
+ private async claimEvent(eventId: string): Promise<boolean> {
160
+ const existing = await this.env.DB.prepare(
161
+ 'SELECT event_id FROM web_events WHERE event_id = ?',
162
+ ).bind(eventId).first();
163
+ if (existing) return false;
164
+
165
+ await this.env.DB.prepare(
166
+ 'INSERT INTO web_events (event_id) VALUES (?)',
167
+ ).bind(eventId).run();
168
+ return true;
169
+ }
170
+
171
+ private async sendHistory(ws: WebSocket, conversationId: string | null, userId: string): Promise<void> {
172
+ if (!conversationId) {
173
+ this.send(ws, { type: 'history', conversationId: null, messages: [] });
174
+ return;
175
+ }
176
+ if (!isValidConversationId(conversationId)) {
177
+ this.send(ws, { type: 'error', error: 'conversationId must be a UUID string' });
178
+ return;
179
+ }
180
+
181
+ const ownership = await verifyConversationOwnership(this.env.DB, conversationId, userId);
182
+ if (ownership === 'not_owned') {
183
+ this.send(ws, { type: 'error', error: 'conversation not found' });
184
+ return;
185
+ }
186
+ if (ownership === 'not_found') {
187
+ this.send(ws, { type: 'history', conversationId, messages: [] });
188
+ return;
189
+ }
190
+
191
+ const rows = await this.env.DB.prepare(
192
+ `SELECT m.id, m.role, m.content, m.metadata, m.created_at
193
+ FROM messages m
194
+ JOIN conversations c ON c.id = m.conversation_id
195
+ WHERE m.conversation_id = ? AND c.user_id = ?
196
+ ORDER BY m.created_at ASC`,
197
+ ).bind(conversationId, userId).all<StoredMessage>();
198
+
199
+ this.send(ws, {
200
+ type: 'history',
201
+ conversationId,
202
+ messages: rows.results.map((message) => ({
203
+ ...message,
204
+ metadata: parseMetadata(message.metadata),
205
+ })),
206
+ });
207
+ }
208
+
209
+ private userId(): string {
210
+ return this.state.id.name ?? 'operator';
211
+ }
212
+
213
+ private send(ws: WebSocket, frame: Record<string, unknown>): void {
214
+ try {
215
+ ws.send(JSON.stringify(frame));
216
+ } catch {
217
+ // Ignore writes after the client disconnects.
218
+ }
219
+ }
220
+ }
221
+
222
+ function buildMessageMetadata(result: DispatchResult): MessageMetadata {
223
+ return {
224
+ classification: result.classification,
225
+ executor: result.executor,
226
+ procHit: result.procedureHit,
227
+ latencyMs: result.latency_ms,
228
+ cost: result.cost,
229
+ confidence: result.confidence,
230
+ reclassified: result.reclassified,
231
+ probeResult: result.probeResult,
232
+ grounded: result.grounded,
233
+ sources: result.sources,
234
+ unknowns: result.unknowns,
235
+ searched: result.searched,
236
+ unverifiedClaims: result.unverified_claims,
237
+ };
238
+ }
239
+
240
+ function parseMetadata(value: string | null): MessageMetadata | null {
241
+ if (!value) return null;
242
+ try {
243
+ return JSON.parse(value) as MessageMetadata;
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ function isRecord(value: unknown): value is Record<string, unknown> {
250
+ return typeof value === 'object' && value !== null;
251
+ }
package/src/edge-env.ts CHANGED
File without changes
package/src/exports.ts CHANGED
File without changes
File without changes