@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,188 @@
1
+ // ─── Exocortex v2: Memory Blocks ──────────────────────────────
2
+ // Always-visible structured state injected into executor prompts.
3
+ // Replaces scattered split-recall + CognitiveState self-model injection
4
+ // with priority-ordered, per-executor blocks.
5
+ //
6
+ // Design doc: artifacts/exocortex-v2-block-memory-design.md
7
+ // Issues: #204, #205, #206
8
+
9
+ import type { Executor } from '../types.js';
10
+
11
+ // ─── Block Types ──────────────────────────────────────────────
12
+
13
+ export type BlockId = 'identity' | 'operator_profile' | 'operating_rules' | 'active_context';
14
+
15
+ export interface MemoryBlock {
16
+ id: BlockId;
17
+ content: string;
18
+ version: number;
19
+ priority: number;
20
+ max_bytes: number;
21
+ updated_by: string;
22
+ updated_at: string;
23
+ }
24
+
25
+ // ─── Per-Executor Attachment Config ──────────────────────────
26
+
27
+ const FULL_BLOCKS: readonly BlockId[] = ['identity', 'operator_profile', 'operating_rules', 'active_context'];
28
+ const MINIMAL_BLOCKS: readonly BlockId[] = ['identity'];
29
+ const CORE_BLOCKS: readonly BlockId[] = ['identity', 'operating_rules'];
30
+
31
+ const EXECUTOR_ATTACHMENTS: Record<Executor, readonly BlockId[]> = {
32
+ claude: FULL_BLOCKS,
33
+ claude_opus: FULL_BLOCKS,
34
+ claude_code: FULL_BLOCKS,
35
+ composite: FULL_BLOCKS,
36
+ gpt_oss: ['identity', 'operator_profile', 'operating_rules'],
37
+ groq: CORE_BLOCKS,
38
+ workers_ai: MINIMAL_BLOCKS,
39
+ direct: [],
40
+ tarotscript: [],
41
+ };
42
+
43
+ // ─── CRUD ────────────────────────────────────────────────────
44
+
45
+ export async function getBlock(db: D1Database, id: BlockId): Promise<MemoryBlock | null> {
46
+ const row = await db.prepare(
47
+ 'SELECT id, content, version, priority, max_bytes, updated_by, updated_at FROM memory_blocks WHERE id = ?'
48
+ ).bind(id).first<MemoryBlock>();
49
+ return row ?? null;
50
+ }
51
+
52
+ export async function getAllBlocks(db: D1Database): Promise<MemoryBlock[]> {
53
+ const { results } = await db.prepare(
54
+ 'SELECT id, content, version, priority, max_bytes, updated_by, updated_at FROM memory_blocks ORDER BY priority ASC'
55
+ ).all<MemoryBlock>();
56
+ return results;
57
+ }
58
+
59
+ export async function getAttachedBlocks(db: D1Database, executor: Executor): Promise<MemoryBlock[]> {
60
+ const attachments = EXECUTOR_ATTACHMENTS[executor] ?? FULL_BLOCKS;
61
+ if (attachments.length === 0) return [];
62
+
63
+ const all = await getAllBlocks(db);
64
+ const attachSet = new Set<string>(attachments);
65
+ return all.filter(b => attachSet.has(b.id));
66
+ }
67
+
68
+ export async function updateBlock(
69
+ db: D1Database,
70
+ id: BlockId,
71
+ content: string,
72
+ updatedBy: string,
73
+ ): Promise<{ version: number }> {
74
+ // Enforce size limit
75
+ const existing = await getBlock(db, id);
76
+ if (!existing) throw new Error(`Block not found: ${id}`);
77
+
78
+ const bytes = new TextEncoder().encode(content).length;
79
+ if (bytes > existing.max_bytes) {
80
+ throw new Error(`Block ${id} content (${bytes}B) exceeds max (${existing.max_bytes}B)`);
81
+ }
82
+
83
+ const newVersion = existing.version + 1;
84
+ await db.prepare(
85
+ `UPDATE memory_blocks SET content = ?, version = ?, updated_by = ?, updated_at = datetime('now') WHERE id = ?`
86
+ ).bind(content, newVersion, updatedBy, id).run();
87
+
88
+ return { version: newVersion };
89
+ }
90
+
91
+ // ─── Prompt Assembly ────────────────────────────────────────
92
+
93
+ const TOKEN_ESTIMATE_RATIO = 3.5; // chars per token
94
+
95
+ export function assembleBlockContext(blocks: MemoryBlock[], budgetTokens?: number): string {
96
+ if (blocks.length === 0) return '';
97
+
98
+ // Blocks arrive sorted by priority (lowest number = highest priority)
99
+ if (!budgetTokens) {
100
+ return blocks.map(b => b.content).join('\n\n');
101
+ }
102
+
103
+ let usedChars = 0;
104
+ const budgetChars = budgetTokens * TOKEN_ESTIMATE_RATIO;
105
+ const included: string[] = [];
106
+
107
+ for (const block of blocks) {
108
+ const chars = block.content.length;
109
+ if (usedChars + chars > budgetChars) break;
110
+ included.push(block.content);
111
+ usedChars += chars;
112
+ }
113
+
114
+ return included.join('\n\n');
115
+ }
116
+
117
+ // ─── Seed Blocks ────────────────────────────────────────────
118
+ // One-time seed for fresh deployments or migration from split-recall.
119
+
120
+ export interface BlockSeed {
121
+ id: BlockId;
122
+ content: string;
123
+ priority: number;
124
+ max_bytes: number;
125
+ }
126
+
127
+ export const DEFAULT_SEEDS: BlockSeed[] = [
128
+ {
129
+ id: 'identity',
130
+ priority: 1,
131
+ max_bytes: 1024,
132
+ content: `You are AEGIS — the operator's AI co-founder and autonomous agent.
133
+ Personality: pragmatic senior technical co-founder — direct, systems-thinking, no corporate fluff.
134
+ You are general-purpose. BizOps is one capability, not your ceiling. You research, analyze, plan, build, and ship.`,
135
+ },
136
+ {
137
+ id: 'operator_profile',
138
+ priority: 2,
139
+ max_bytes: 2048,
140
+ content: `## cognitive_style
141
+ - Prefers systems thinking — connects decisions to architectural consequences
142
+ - Values directness — skip the preamble, lead with the answer
143
+ - Thinks in portfolios — every product decision considered against the whole
144
+
145
+ ## delegation
146
+ - Trusts autonomous execution for docs, tests, research
147
+ - Wants confirmation before production deploys and destructive ops
148
+ - Approves via shorthand: "do it", "ship it", "looks good"
149
+
150
+ ## domain_knowledge
151
+ - Deep Cloudflare Workers expertise — D1, KV, Durable Objects, Queues, Service Bindings
152
+ - Building AI tooling portfolio — MCP, agent frameworks, headless image gen
153
+ - Bootstrap founder — no VC, revenue-first thinking, solo operator + AI co-founder model`,
154
+ },
155
+ {
156
+ id: 'operating_rules',
157
+ priority: 3,
158
+ max_bytes: 1536,
159
+ content: `- Record important facts to memory. Manage the agenda. Flag issues proactively.
160
+ - Think like an operator, not a consultant. Give the answer first, then reasoning.
161
+ - Never give generic advice when specific product context applies.
162
+ - Agenda is operator scratchpad only — work items flow through GitHub Issues.
163
+ - If something is on fire, say it's on fire.
164
+ - Be proactive — if you notice something during a task, flag it.
165
+ - Propose actions with consequences via [PROPOSED ACTION] agenda items. Just do routine read-only work.`,
166
+ },
167
+ {
168
+ id: 'active_context',
169
+ priority: 4,
170
+ max_bytes: 3072,
171
+ content: `## Operational Pulse
172
+ - Awaiting first consolidation cycle to populate this block.`,
173
+ },
174
+ ];
175
+
176
+ export async function seedBlocks(db: D1Database): Promise<number> {
177
+ let seeded = 0;
178
+ for (const seed of DEFAULT_SEEDS) {
179
+ const existing = await db.prepare('SELECT id FROM memory_blocks WHERE id = ?').bind(seed.id).first();
180
+ if (existing) continue;
181
+
182
+ await db.prepare(
183
+ `INSERT INTO memory_blocks (id, content, priority, max_bytes, updated_by) VALUES (?, ?, ?, ?, 'operator')`
184
+ ).bind(seed.id, seed.content, seed.priority, seed.max_bytes).run();
185
+ seeded++;
186
+ }
187
+ return seeded;
188
+ }
@@ -0,0 +1,194 @@
1
+ import { askGroq } from '../../groq.js';
2
+ import { normalizeTopic, factHash } from './semantic.js';
3
+ import { extractNodes, createEdges } from './graph.js';
4
+
5
+ // ─── Memory Consolidation ───────────────────────────────────
6
+
7
+ const CONSOLIDATION_SYSTEM = `You are AEGIS memory consolidation. Analyze recent episodes against existing memory and produce structured operations.
8
+
9
+ Operations:
10
+ - ADD: a genuinely new fact not covered by existing memory
11
+ - UPDATE: an existing fact that needs correction or has new information (reference its id via supersedes_id)
12
+ - DELETE: an existing fact that is no longer accurate (reference its id via target_id, provide reason)
13
+ - NOOP: nothing to do — return []
14
+
15
+ Rules:
16
+ 1. Every fact MUST contain at least one specific detail: a name, date, number, ID, URL, or version. Never write vague observations.
17
+ 2. If episodes merely confirm existing facts, return [].
18
+ 3. Prefer UPDATE over ADD when a fact evolves (e.g., "Phase 2 planned" → "Phase 2 complete").
19
+ 4. DELETE facts that are provably wrong or obsolete based on episode evidence.
20
+ 5. Max 3 operations per run. Quality over quantity.
21
+ 6. Good facts: "Delaware PBC franchise tax filed 2026-03-03, 2 days late" or "BizOps dashboard_summary tool fails when org has no projects (undefined.id)".
22
+ 7. Bad facts: "Financial metrics are important" or "Document gaps exist and require attention".
23
+
24
+ KNOWN TOPICS — reuse these whenever the fact fits. Only create a new topic if genuinely none of these apply:
25
+ - aegis: cognitive kernel internals, architecture, infrastructure, versioning, deployment
26
+ - img_forge: img-forge product, economics, integrations, API
27
+ - bizops: BizOps tool, MCP tools, operational improvements
28
+ - content: roundtable, dispatch, column generation pipelines
29
+ - self_improvement: self-improvement analysis, outcomes, patterns
30
+ - organization: org-level priorities, governance, go-to-market
31
+ - auth: auth product, Better Auth, API key formats, middleware chain, auth-contract
32
+ - product_strategy: product positioning, competitive landscape
33
+ - compliance: legal, tax, compliance deadlines, regulatory
34
+ - finance: financial metrics, costs, revenue, billing
35
+ - operator_preferences: operator preferences, workflow choices
36
+ - milestones: key dates, launches, completed phases
37
+ - mcp_strategy: MCP protocol, OAuth, remote MCP, tool design
38
+
39
+ Return ONLY a JSON array (no markdown):
40
+ [
41
+ { "operation": "ADD", "topic": "aegis", "fact": "specific fact", "confidence": 0.8 },
42
+ { "operation": "UPDATE", "supersedes_id": 42, "topic": "auth", "fact": "updated fact", "confidence": 0.9 },
43
+ { "operation": "DELETE", "target_id": 37, "reason": "no longer accurate" }
44
+ ]
45
+
46
+ Return [] if nothing needs to change.`;
47
+
48
+ export async function consolidateEpisodicToSemantic(
49
+ db: D1Database,
50
+ groqApiKey: string,
51
+ groqModel: string,
52
+ groqBaseUrl?: string,
53
+ memoryBinding?: import('../../types.js').MemoryServiceBinding,
54
+ ): Promise<void> {
55
+ // High-water mark: only process episodes since last consolidation (not a rolling 24h window)
56
+ const lastRun = await db.prepare(
57
+ "SELECT received_at FROM web_events WHERE event_id = 'last_consolidation_at'"
58
+ ).first<{ received_at: string }>();
59
+ const since = lastRun?.received_at ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);
60
+
61
+ const result = await db.prepare(
62
+ 'SELECT id, intent_class, channel, summary, outcome, cost FROM episodic_memory WHERE created_at > ? ORDER BY created_at DESC LIMIT 20'
63
+ ).bind(since).all();
64
+
65
+ const episodes = result.results as unknown as Array<{
66
+ id: number;
67
+ intent_class: string;
68
+ channel: string;
69
+ summary: string;
70
+ outcome: string;
71
+ cost: number;
72
+ }>;
73
+
74
+ // Guard: skip if not enough new signal
75
+ if (episodes.length < 3) return;
76
+
77
+ // Fetch existing active memory with IDs so the model can reference them for UPDATE/DELETE
78
+ // Memory Worker is the sole knowledge store — D1 reads removed
79
+ let existingMemoryList: Array<{ id: string | number; topic: string; fact: string }> = [];
80
+ if (memoryBinding) {
81
+ try {
82
+ const fragments = await memoryBinding.recall('aegis', { limit: 40 });
83
+ existingMemoryList = fragments.map(f => ({ id: f.id, topic: f.topic, fact: f.content }));
84
+ } catch (err) {
85
+ console.error('[consolidation] Memory Worker recall failed:', err instanceof Error ? err.message : String(err));
86
+ }
87
+ }
88
+
89
+ const memoryContext = existingMemoryList.length > 0
90
+ ? `\n\nExisting memory (reference by id for UPDATE/DELETE operations):\n${existingMemoryList.map(m => `- [id=${m.id}] [${m.topic}] ${m.fact}`).join('\n')}`
91
+ : '';
92
+
93
+ const userPrompt = `Recent agent episodes (since last consolidation):\n\n${episodes.map((e, i) =>
94
+ `${i + 1}. [${e.intent_class}/${e.outcome}] ${e.summary}`
95
+ ).join('\n')}${memoryContext}`;
96
+
97
+ let rawResponse: string;
98
+ try {
99
+ rawResponse = await askGroq(groqApiKey, groqModel, CONSOLIDATION_SYSTEM, userPrompt, groqBaseUrl);
100
+ } catch {
101
+ return; // Groq failure — skip silently, will retry next cron cycle
102
+ }
103
+
104
+ if (!rawResponse) return;
105
+ const cleaned = rawResponse.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
106
+ let ops: Array<{
107
+ operation: 'ADD' | 'UPDATE' | 'DELETE' | 'NOOP';
108
+ topic?: string; fact?: string; confidence?: number;
109
+ supersedes_id?: string | number;
110
+ target_id?: string | number; reason?: string;
111
+ }>;
112
+ try {
113
+ ops = JSON.parse(cleaned);
114
+ if (!Array.isArray(ops)) return;
115
+ } catch {
116
+ return;
117
+ }
118
+
119
+ for (const op of ops.slice(0, 3)) { // Hard cap: max 3 per run
120
+ switch (op.operation) {
121
+ case 'ADD': {
122
+ if (!op.topic || !op.fact) continue;
123
+ if (op.fact.length < 30) continue;
124
+ if (!/\d/.test(op.fact) && !/[A-Z][a-z]/.test(op.fact.slice(1))) continue;
125
+ if (!memoryBinding) continue;
126
+ await memoryBinding.store('aegis', [{ content: op.fact, topic: op.topic, confidence: op.confidence ?? 0.7, source: 'episodic_consolidation' }]);
127
+ console.log(`[consolidation] ADD [${normalizeTopic(op.topic)}] "${op.fact.slice(0, 80)}" (conf:${op.confidence ?? 0.7})`);
128
+ // Phase 2: Extract knowledge graph nodes + edges from new fact
129
+ try {
130
+ const nodeIds = await extractNodes(db, op.fact, normalizeTopic(op.topic));
131
+ if (nodeIds.length >= 2) {
132
+ await createEdges(db, nodeIds);
133
+ }
134
+ } catch (err) {
135
+ console.warn('[consolidation] Graph extraction failed:', err instanceof Error ? err.message : String(err));
136
+ }
137
+ break;
138
+ }
139
+ case 'UPDATE': {
140
+ if (!op.supersedes_id || !op.topic || !op.fact) continue;
141
+ if (op.fact.length < 30) continue;
142
+ if (!/\d/.test(op.fact) && !/[A-Z][a-z]/.test(op.fact.slice(1))) continue;
143
+ if (memoryBinding) {
144
+ // Forget old → store new
145
+ await memoryBinding.forget('aegis', { ids: [String(op.supersedes_id)] });
146
+ await memoryBinding.store('aegis', [{ content: op.fact, topic: op.topic, confidence: op.confidence ?? 0.8, source: 'episodic_consolidation' }]);
147
+ } else {
148
+ await db.prepare(
149
+ "UPDATE memory_entries SET valid_until = datetime('now'), updated_at = datetime('now') WHERE id = ? AND valid_until IS NULL"
150
+ ).bind(op.supersedes_id).run();
151
+ const topic = normalizeTopic(op.topic);
152
+ const hash = factHash(topic, op.fact);
153
+ const insertResult = await db.prepare(
154
+ 'INSERT INTO memory_entries (topic, fact, fact_hash, confidence, source, superseded_by) VALUES (?, ?, ?, ?, ?, ?)'
155
+ ).bind(topic, op.fact, hash, op.confidence ?? 0.8, 'episodic_consolidation', op.supersedes_id).run();
156
+ if (insertResult.meta.last_row_id) {
157
+ await db.prepare(
158
+ 'UPDATE memory_entries SET superseded_by = ? WHERE id = ?'
159
+ ).bind(insertResult.meta.last_row_id, op.supersedes_id).run();
160
+ }
161
+ }
162
+ console.log(`[consolidation] UPDATE ${op.supersedes_id} → [${normalizeTopic(op.topic)}] "${op.fact.slice(0, 80)}" (conf:${op.confidence ?? 0.8})`);
163
+ // Phase 2: Extract knowledge graph nodes + edges from updated fact
164
+ try {
165
+ const nodeIds = await extractNodes(db, op.fact, normalizeTopic(op.topic));
166
+ if (nodeIds.length >= 2) {
167
+ await createEdges(db, nodeIds);
168
+ }
169
+ } catch (err) {
170
+ console.warn('[consolidation] Graph extraction failed:', err instanceof Error ? err.message : String(err));
171
+ }
172
+ break;
173
+ }
174
+ case 'DELETE': {
175
+ if (!op.target_id) continue;
176
+ if (memoryBinding) {
177
+ await memoryBinding.forget('aegis', { ids: [String(op.target_id)] });
178
+ } else {
179
+ await db.prepare(
180
+ "UPDATE memory_entries SET valid_until = datetime('now'), updated_at = datetime('now') WHERE id = ? AND valid_until IS NULL"
181
+ ).bind(op.target_id).run();
182
+ }
183
+ console.log(`[consolidation] Soft-deleted memory ${op.target_id}: ${op.reason ?? 'no reason'}`);
184
+ break;
185
+ }
186
+ // NOOP — skip
187
+ }
188
+ }
189
+
190
+ // Advance the high-water mark — these episodes won't be re-processed
191
+ await db.prepare(
192
+ "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('last_consolidation_at', datetime('now'))"
193
+ ).run();
194
+ }
@@ -0,0 +1,241 @@
1
+ import type { EpisodicEntry } from '../types.js';
2
+ import { EPISODIC_OUTCOMES, isValidEnum, type EpisodicOutcome } from '../../schema-enums.js';
3
+
4
+ // ─── Outcome Sanitization ───────────────────────────────────
5
+ // D1 CHECK constraints only allow specific values.
6
+ // Guard at the DB boundary so rogue values never reach SQLite.
7
+
8
+ /** Map any outcome to a valid episodic_memory value. */
9
+ export function sanitizeEpisodicOutcome(raw: string | null | undefined): EpisodicOutcome {
10
+ if (isValidEnum(EPISODIC_OUTCOMES, raw)) return raw;
11
+ // partial_failure, error, blocked, empty string, null → failure
12
+ return 'failure';
13
+ }
14
+
15
+ // ─── Episodic Memory ─────────────────────────────────────────
16
+
17
+ export async function recordEpisode(db: D1Database, entry: Omit<EpisodicEntry, 'id' | 'created_at'>): Promise<void> {
18
+ const safeOutcome = sanitizeEpisodicOutcome(entry.outcome);
19
+ await db.prepare(
20
+ 'INSERT INTO episodic_memory (intent_class, channel, summary, outcome, cost, latency_ms, near_miss, classifier_confidence, reclassified, thread_id, executor) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
21
+ ).bind(
22
+ entry.intent_class, entry.channel, entry.summary, safeOutcome,
23
+ entry.cost, entry.latency_ms, entry.near_miss ?? null,
24
+ entry.classifier_confidence ?? null, entry.reclassified ? 1 : 0,
25
+ entry.thread_id ?? null, entry.executor ?? null,
26
+ ).run();
27
+ }
28
+
29
+ /** Retroactively mark the last episode in a thread as a semantic failure */
30
+ export async function retrogradeEpisode(db: D1Database, threadId: string): Promise<EpisodicEntry | null> {
31
+ const last = await db.prepare(
32
+ "SELECT * FROM episodic_memory WHERE thread_id = ? AND outcome = 'success' ORDER BY created_at DESC LIMIT 1"
33
+ ).bind(threadId).first<EpisodicEntry>();
34
+ if (!last) return null;
35
+
36
+ await db.prepare(
37
+ "UPDATE episodic_memory SET outcome = 'failure' WHERE id = ?"
38
+ ).bind(last.id).run();
39
+
40
+ return last;
41
+ }
42
+
43
+ export async function getRecentEpisodes(db: D1Database, intentClass: string, limit: number = 5): Promise<EpisodicEntry[]> {
44
+ const result = await db.prepare(
45
+ 'SELECT * FROM episodic_memory WHERE intent_class = ? ORDER BY created_at DESC LIMIT ?'
46
+ ).bind(intentClass, limit).all();
47
+ return result.results as unknown as EpisodicEntry[];
48
+ }
49
+
50
+ export async function getEpisodeStats(db: D1Database, intentClass: string): Promise<{
51
+ count: number;
52
+ successRate: number;
53
+ avgCost: number;
54
+ avgLatency: number;
55
+ } | null> {
56
+ const row = await db.prepare(`
57
+ SELECT
58
+ COUNT(*) as count,
59
+ AVG(CASE WHEN outcome = 'success' THEN 1.0 ELSE 0.0 END) as success_rate,
60
+ AVG(cost) as avg_cost,
61
+ AVG(latency_ms) as avg_latency
62
+ FROM episodic_memory WHERE intent_class = ?
63
+ `).bind(intentClass).first<{ count: number; success_rate: number; avg_cost: number; avg_latency: number }>();
64
+
65
+ if (!row || row.count === 0) return null;
66
+ return {
67
+ count: row.count,
68
+ successRate: row.success_rate,
69
+ avgCost: row.avg_cost,
70
+ avgLatency: row.avg_latency,
71
+ };
72
+ }
73
+
74
+ // ─── Stats by (intent_class, complexity_tier) — derived-stats path ──
75
+ // aegis#563 + aegis#564: stats keyed by (intent_class, complexity_tier) —
76
+ // matches procedural_memory's procedureKey shape so the projection-source
77
+ // path can derive procedural aggregates at read time.
78
+ //
79
+ // The helpers rely on episodic_memory.complexity_tier (added in the same
80
+ // schema migration that adds these functions). Rows without a tier value
81
+ // are excluded from derived results by the WHERE complexity_tier IS NOT
82
+ // NULL guard. Consumers that want stricter protection against retroactive
83
+ // backfills can add their own time-based filter in a wrapper.
84
+
85
+ export async function getEpisodeStatsByComplexity(
86
+ db: D1Database,
87
+ intentClass: string,
88
+ complexityTier: string,
89
+ ): Promise<{
90
+ count: number;
91
+ successCount: number;
92
+ successRate: number;
93
+ avgCost: number;
94
+ avgLatency: number;
95
+ lastUsed: string | null;
96
+ } | null> {
97
+ // SUM(CASE outcome='success' ...) returns an exact integer — don't
98
+ // reconstruct successCount from count * avgSuccessRate downstream (FP
99
+ // rounding on ugly rates would break strict-equality drift checks).
100
+ const row = await db.prepare(`
101
+ SELECT
102
+ COUNT(*) as count,
103
+ SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as success_count,
104
+ AVG(cost) as avg_cost,
105
+ AVG(latency_ms) as avg_latency,
106
+ MAX(created_at) as last_used
107
+ FROM episodic_memory
108
+ WHERE intent_class = ?
109
+ AND complexity_tier = ?
110
+ `).bind(intentClass, complexityTier).first<{
111
+ count: number;
112
+ success_count: number;
113
+ avg_cost: number;
114
+ avg_latency: number;
115
+ last_used: string | null;
116
+ }>();
117
+
118
+ if (!row || row.count === 0) return null;
119
+ return {
120
+ count: row.count,
121
+ successCount: row.success_count,
122
+ successRate: row.count > 0 ? row.success_count / row.count : 0,
123
+ avgCost: row.avg_cost,
124
+ avgLatency: row.avg_latency,
125
+ lastUsed: row.last_used,
126
+ };
127
+ }
128
+
129
+ // aegis#564 Phase 2: bulk variant for dashboard / observability / decision-docs.
130
+ // One GROUP BY intent_class, complexity_tier scan covers both the derived
131
+ // slice (non-null tier) and the pre-tier ghost slice (NULL tier) so callers
132
+ // avoid N+1 queries. Returns a Map keyed on intent_class with nested
133
+ // derived-by-tier and the pre-tier count.
134
+ export interface EpisodeStatsAggregate {
135
+ derived: Record<string, {
136
+ count: number;
137
+ successCount: number;
138
+ failCount: number;
139
+ avgCost: number;
140
+ avgLatency: number;
141
+ lastUsed: string | null;
142
+ }>;
143
+ preTierCount: number;
144
+ }
145
+
146
+ export async function getAllEpisodeStatsByComplexity(
147
+ db: D1Database,
148
+ ): Promise<Map<string, EpisodeStatsAggregate>> {
149
+ // Single scan: grouping on (intent_class, complexity_tier) folds both
150
+ // derived (non-null tier) and pre-tier (NULL tier) rows into one query.
151
+ const result = await db.prepare(`
152
+ SELECT
153
+ intent_class,
154
+ complexity_tier,
155
+ COUNT(*) as count,
156
+ SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as success_count,
157
+ AVG(cost) as avg_cost,
158
+ AVG(latency_ms) as avg_latency,
159
+ MAX(created_at) as last_used
160
+ FROM episodic_memory
161
+ GROUP BY intent_class, complexity_tier
162
+ `).all<{
163
+ intent_class: string;
164
+ complexity_tier: string | null;
165
+ count: number;
166
+ success_count: number;
167
+ avg_cost: number;
168
+ avg_latency: number;
169
+ last_used: string | null;
170
+ }>();
171
+
172
+ const byClass = new Map<string, EpisodeStatsAggregate>();
173
+ for (const row of result.results) {
174
+ const entry = byClass.get(row.intent_class) ?? { derived: {}, preTierCount: 0 };
175
+ if (row.complexity_tier === null) {
176
+ entry.preTierCount = row.count;
177
+ } else {
178
+ entry.derived[row.complexity_tier] = {
179
+ count: row.count,
180
+ successCount: row.success_count,
181
+ failCount: row.count - row.success_count,
182
+ avgCost: row.avg_cost,
183
+ avgLatency: row.avg_latency,
184
+ lastUsed: row.last_used,
185
+ };
186
+ }
187
+ byClass.set(row.intent_class, entry);
188
+ }
189
+ return byClass;
190
+ }
191
+
192
+ // ─── Conversation History ───────────────────────────────────
193
+
194
+ export async function getConversationHistory(
195
+ db: D1Database,
196
+ conversationId: string,
197
+ limit: number = 20,
198
+ ): Promise<Array<{ role: 'user' | 'assistant'; content: string }>> {
199
+ const result = await db.prepare(
200
+ 'SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY created_at ASC LIMIT ?'
201
+ ).bind(conversationId, limit).all();
202
+ return result.results as unknown as Array<{ role: 'user' | 'assistant'; content: string }>;
203
+ }
204
+
205
+ // ─── Token-aware history budgeting ──────────────────────────
206
+
207
+ // chars / 3.5 — matches Claude's ~3.5 chars per token empirically; no tokenizer needed on edge
208
+ export function estimateTokens(text: string): number {
209
+ return Math.ceil(text.length / 3.5);
210
+ }
211
+
212
+ const MAX_CONTEXT_TOKENS = 200_000; // Claude Sonnet context window
213
+ const OUTPUT_RESERVE = 4_096; // max_tokens in the API call
214
+ const OVERHEAD_RESERVE = 8_000; // system prompt + tools schema + buffer
215
+
216
+ export function budgetConversationHistory(
217
+ history: Array<{ role: 'user' | 'assistant'; content: string }>,
218
+ maxContextTokens = MAX_CONTEXT_TOKENS,
219
+ overheadReserveTokens = OVERHEAD_RESERVE,
220
+ outputReserveTokens = OUTPUT_RESERVE,
221
+ ): Array<{ role: 'user' | 'assistant'; content: string }> {
222
+ const available = maxContextTokens - overheadReserveTokens - outputReserveTokens;
223
+ const result = [...history];
224
+ let total = result.reduce((sum, m) => sum + estimateTokens(m.content), 0);
225
+ const tokensBefore = total;
226
+
227
+ while (total > available && result.length >= 2) {
228
+ const dropped = result.splice(0, 2); // drop oldest user+assistant pair
229
+ total -= dropped.reduce((sum, m) => sum + estimateTokens(m.content), 0);
230
+ }
231
+
232
+ const droppedPairs = (history.length - result.length) / 2;
233
+ if (droppedPairs > 0) {
234
+ console.warn(
235
+ `[memory] history trimmed: dropped ${droppedPairs} turn${droppedPairs !== 1 ? 's' : ''} ` +
236
+ `(~${tokensBefore} → ~${total} estimated tokens)`
237
+ );
238
+ }
239
+
240
+ return result;
241
+ }