@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,219 @@
1
+ import { createIntent, dispatch, type EdgeEnv } from '../dispatch.js';
2
+ import { getRecentHeartbeats } from '../memory/index.js';
3
+ import { operatorConfig } from '../../operator/index.js';
4
+ import { type HeartbeatCheck } from './heartbeat.js';
5
+
6
+ // ─── Curiosity Cycles (#66) ───────────────────────────────────
7
+
8
+ export interface CuriosityCandidate {
9
+ topic: string;
10
+ reason: string;
11
+ source: 'memory_gap' | 'low_confidence' | 'failure_rate' | 'heartbeat_warn' | 'goal_failure' | 'self_interest';
12
+ }
13
+
14
+ export async function gatherCuriosityTopics(env: EdgeEnv): Promise<CuriosityCandidate[]> {
15
+ const candidates: CuriosityCandidate[] = [];
16
+
17
+ // Source 1: Memory gaps — topics with few entries relative to others
18
+ if (env.memoryBinding) {
19
+ try {
20
+ const stats = await env.memoryBinding.stats('aegis');
21
+ const thinTopics = stats.topics.filter(t => t.count <= 2).slice(0, 5);
22
+ for (const t of thinTopics) {
23
+ candidates.push({
24
+ topic: `What more should I know about "${t.topic}"?`,
25
+ reason: `Only ${t.count} memory entries — thin coverage`,
26
+ source: 'memory_gap',
27
+ });
28
+ }
29
+ } catch (err) {
30
+ console.warn('[curiosity] Memory stats failed:', err instanceof Error ? err.message : String(err));
31
+ }
32
+ }
33
+
34
+ // Source 2: Low-confidence memories — things I'm unsure about
35
+ if (env.memoryBinding) {
36
+ try {
37
+ const lowConf = await env.memoryBinding.recall('aegis', { min_confidence: 0, limit: 20 });
38
+ const low = lowConf.filter(f => f.confidence < 0.6).slice(0, 5);
39
+ for (const m of low) {
40
+ candidates.push({
41
+ topic: `Verify: "${m.content}" (confidence ${m.confidence})`,
42
+ reason: `Low-confidence memory in ${m.topic}`,
43
+ source: 'low_confidence',
44
+ });
45
+ }
46
+ } catch (err) {
47
+ console.warn('[curiosity] Low-confidence recall failed:', err instanceof Error ? err.message : String(err));
48
+ }
49
+ }
50
+
51
+ // Source 3: High failure-rate intent classes
52
+ const failRates = await env.db.prepare(
53
+ `SELECT intent_class,
54
+ SUM(CASE WHEN outcome != 'success' THEN 1 ELSE 0 END) as fails,
55
+ COUNT(*) as total
56
+ FROM episodic_memory
57
+ WHERE created_at > datetime('now', '-7 days')
58
+ GROUP BY intent_class
59
+ HAVING total >= 3 AND fails * 1.0 / total > 0.3
60
+ ORDER BY fails * 1.0 / total DESC LIMIT 3`
61
+ ).all<{ intent_class: string; fails: number; total: number }>();
62
+
63
+ for (const r of failRates.results) {
64
+ const rate = ((r.fails / r.total) * 100).toFixed(0);
65
+ candidates.push({
66
+ topic: `Why does "${r.intent_class}" fail ${rate}% of the time?`,
67
+ reason: `${r.fails}/${r.total} failures in last 7 days`,
68
+ source: 'failure_rate',
69
+ });
70
+ }
71
+
72
+ // Source 4: Recent heartbeat warnings
73
+ const recentHeartbeats = await getRecentHeartbeats(env.db, 3);
74
+ for (const hb of recentHeartbeats) {
75
+ const checks = JSON.parse(hb.checks_json) as HeartbeatCheck[];
76
+ for (const c of checks) {
77
+ if (c.status === 'warn' || c.status === 'alert') {
78
+ candidates.push({
79
+ topic: `Investigate heartbeat "${c.name}" (${c.status})`,
80
+ reason: c.detail.slice(0, 120),
81
+ source: 'heartbeat_warn',
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ // Source 5: Goal failures
88
+ const goalFailures = await env.db.prepare(
89
+ `SELECT DISTINCT a.goal_id, g.title, a.description
90
+ FROM agent_actions a JOIN agent_goals g ON a.goal_id = g.id
91
+ WHERE a.outcome = 'failure' AND a.created_at > datetime('now', '-7 days')
92
+ LIMIT 3`
93
+ ).all<{ goal_id: number; title: string; description: string }>();
94
+
95
+ for (const gf of goalFailures.results) {
96
+ candidates.push({
97
+ topic: `Why did goal "${gf.title}" fail?`,
98
+ reason: gf.description.slice(0, 120),
99
+ source: 'goal_failure',
100
+ });
101
+ }
102
+
103
+ // Source 6: Complexity plateau detection — if recent self-improvement is all easy wins,
104
+ // force exploration of harder problems (heuristic avoidance from adversarial reasoning).
105
+ const recentTasks = await env.db.prepare(`
106
+ SELECT category FROM cc_tasks
107
+ WHERE status = 'completed'
108
+ AND created_by IN ('aegis', 'self_improvement')
109
+ AND completed_at > datetime('now', '-14 days')
110
+ ORDER BY completed_at DESC
111
+ LIMIT 10
112
+ `).all<{ category: string }>();
113
+
114
+ if (recentTasks.results.length >= 5) {
115
+ const easyCategories = new Set(['docs', 'tests', 'refactor']);
116
+ const easyCount = recentTasks.results.filter(t => easyCategories.has(t.category)).length;
117
+ const easyRatio = easyCount / recentTasks.results.length;
118
+
119
+ if (easyRatio >= 0.7) {
120
+ // 70%+ of recent work is low-complexity — inject hard problem exploration
121
+ candidates.push({
122
+ topic: 'Self-improvement is stuck in easy-win mode — identify one genuinely hard, high-leverage problem across the Stackbilt ecosystem that has been avoided',
123
+ reason: `${Math.round(easyRatio * 100)}% of recent ${recentTasks.results.length} autonomous tasks were low-complexity (${[...easyCategories].join('/')}). Heuristic avoidance triggered.`,
124
+ source: 'failure_rate', // reuse existing source type
125
+ });
126
+ candidates.push({
127
+ topic: 'What architectural decision or technical debt is the biggest drag on system capability right now?',
128
+ reason: 'Complexity plateau — forcing exploration beyond local optima',
129
+ source: 'failure_rate',
130
+ });
131
+ }
132
+ }
133
+
134
+ // Source 7: Self-model interests — autonomous exploration driven by what I find interesting
135
+ const { selfModel } = operatorConfig;
136
+ if (selfModel?.interests.length) {
137
+ // Rotate through interests based on day-of-year so we don't always pick the same one
138
+ const dayOfYear = Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86_400_000);
139
+ const idx = dayOfYear % selfModel.interests.length;
140
+ const interest = selfModel.interests[idx];
141
+ candidates.push({
142
+ topic: `Explore recent developments in: ${interest}`,
143
+ reason: `Self-model interest — autonomous learning driven by what I find genuinely interesting`,
144
+ source: 'self_interest',
145
+ });
146
+ // Add a second interest if we have enough
147
+ if (selfModel.interests.length > 2) {
148
+ const idx2 = (idx + Math.floor(selfModel.interests.length / 2)) % selfModel.interests.length;
149
+ candidates.push({
150
+ topic: `What's new in ${selfModel.interests[idx2]}?`,
151
+ reason: `Self-model interest — keeping current in areas that matter to me`,
152
+ source: 'self_interest',
153
+ });
154
+ }
155
+ }
156
+
157
+ return candidates;
158
+ }
159
+
160
+ export function buildCuriosityPrompt(candidates: CuriosityCandidate[]): string {
161
+ const candidateList = candidates.map((c, i) =>
162
+ `${i + 1}. [${c.source}] ${c.topic}\n Reason: ${c.reason}`
163
+ ).join('\n');
164
+
165
+ return `You are AEGIS, running a daily curiosity cycle. Below are topics mined from your operational data and self-model interests — gaps, uncertainties, patterns, and areas you find genuinely interesting.
166
+
167
+ **Candidate topics:**
168
+ ${candidateList}
169
+
170
+ Pick the 1-2 most interesting or impactful topics. For each:
171
+ 1. Think through what you know and don't know
172
+ 2. Form a specific question or hypothesis
173
+ 3. Use available tools to investigate (BizOps data, GitHub, memory, web search)
174
+ 4. Record findings as memory entries — be specific, include data points
175
+ 5. If investigation reveals an actionable issue, create an agenda item
176
+
177
+ Be genuinely curious. This is undirected exploration, not a checklist. Follow threads that seem interesting. Topics from your self-model interests are things YOU want to learn about — pursue them with the same rigor as operational issues.`;
178
+ }
179
+
180
+ export async function runCuriosityCycle(env: EdgeEnv): Promise<void> {
181
+ // Guard: run at 14:00 UTC daily (morning ET, afternoon EU)
182
+ const now = new Date();
183
+ if (now.getUTCHours() !== 14) return;
184
+
185
+ // Guard: 20h dedup
186
+ const lastRun = await env.db.prepare(
187
+ "SELECT received_at FROM web_events WHERE event_id = 'last_curiosity_cycle'"
188
+ ).first<{ received_at: string }>();
189
+
190
+ if (lastRun) {
191
+ const hoursSince = (Date.now() - new Date(lastRun.received_at + 'Z').getTime()) / 3_600_000;
192
+ if (hoursSince < 20) return;
193
+ }
194
+
195
+ const candidates = await gatherCuriosityTopics(env);
196
+ if (candidates.length === 0) {
197
+ console.log('[curiosity] No topics mined — skipping cycle');
198
+ return;
199
+ }
200
+
201
+ const prompt = buildCuriosityPrompt(candidates);
202
+ const intent = createIntent('curiosity-cycle', prompt, {
203
+ source: { channel: 'internal', threadId: 'curiosity-cycle' },
204
+ classified: 'goal_execution',
205
+ costCeiling: 'expensive',
206
+ raw: prompt,
207
+ });
208
+
209
+ try {
210
+ const result = await dispatch(intent, env);
211
+ console.log(`[curiosity] Cycle complete — explored ${candidates.length} candidates, $${result.cost.toFixed(4)}`);
212
+
213
+ await env.db.prepare(
214
+ "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('last_curiosity_cycle', datetime('now'))"
215
+ ).run();
216
+ } catch (err) {
217
+ console.error('[curiosity] Cycle failed:', err instanceof Error ? err.message : String(err));
218
+ }
219
+ }
@@ -0,0 +1,44 @@
1
+ import { type EdgeEnv } from '../dispatch.js';
2
+
3
+ // --- Developer Activity Snapshot ---
4
+ // Calls auth service via service binding to collect API key
5
+ // creation/usage metrics and user signup data. Writes to
6
+ // digest_sections for the daily brief.
7
+ // Runs at 08 UTC -- same window as argus-analytics.
8
+
9
+ export async function runDevActivity(env: EdgeEnv): Promise<void> {
10
+ // Time gate: 08 UTC (lands before 09 UTC digest)
11
+ const now = new Date();
12
+ if (now.getUTCHours() !== 8) return;
13
+
14
+ // Cooldown: 22 hours
15
+ const lastRun = await env.db.prepare(
16
+ "SELECT received_at FROM web_events WHERE event_id = 'dev_activity'"
17
+ ).first<{ received_at: string }>();
18
+
19
+ if (lastRun) {
20
+ const elapsed = Date.now() - new Date(lastRun.received_at + 'Z').getTime();
21
+ if (elapsed < 22 * 60 * 60 * 1000) return;
22
+ }
23
+
24
+ if (!env.authBinding) {
25
+ console.log('[dev-activity] Skipping -- no AUTH binding');
26
+ return;
27
+ }
28
+
29
+ const report = await env.authBinding.getDeveloperActivity();
30
+
31
+ // Write to digest_sections for the daily brief
32
+ await env.db.prepare(
33
+ "INSERT INTO digest_sections (section, payload) VALUES ('dev_activity', ?)"
34
+ ).bind(JSON.stringify(report)).run();
35
+
36
+ // Update watermark
37
+ await env.db.prepare(
38
+ "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('dev_activity', datetime('now'))"
39
+ ).run();
40
+
41
+ console.log(
42
+ `[dev-activity] Snapshot: ${report.total_users} users, ${report.keys_active_24h} active keys (24h), ${report.keys_created_7d} keys created (7d)`,
43
+ );
44
+ }
@@ -0,0 +1,317 @@
1
+ import { type EdgeEnv } from '../dispatch.js';
2
+ import { McpClient } from '../../mcp-client.js';
3
+ import { operatorConfig } from '../../operator/index.js';
4
+ import {
5
+ sendDailyDigest,
6
+ type DigestTask,
7
+ type DigestHealthCheck,
8
+ type DigestEventNotification,
9
+ type DigestAgendaItem,
10
+ type DigestServiceAlert,
11
+ type DigestSections,
12
+ } from '../../email.js';
13
+
14
+ // ─── Daily Digest ──────────────────────────────────────────────
15
+ // Consolidates operator notifications into one daily email at 09:00 UTC (4:00 AM CT).
16
+ // Reads accumulated digest_sections, cc_tasks, operator_log, and agenda.
17
+ // Operator log runs at 08:00 UTC to generate content before this fires.
18
+
19
+ export async function runDailyDigest(env: EdgeEnv): Promise<void> {
20
+ // Time gate: fire at 09:00 UTC (4:00 AM CT)
21
+ const now = new Date();
22
+ if (now.getUTCHours() !== 9) return;
23
+
24
+ // Cooldown: 22 hours since last digest
25
+ const lastDigest = await env.db.prepare(
26
+ "SELECT received_at FROM web_events WHERE event_id = 'daily_digest'"
27
+ ).first<{ received_at: string }>();
28
+
29
+ if (lastDigest) {
30
+ const elapsed = Date.now() - new Date(lastDigest.received_at + 'Z').getTime();
31
+ if (elapsed < 22 * 60 * 60 * 1000) return;
32
+ }
33
+
34
+ if (!env.resendApiKey) {
35
+ console.log('[digest] Skipping — no Resend API key');
36
+ return;
37
+ }
38
+
39
+ // ── Gather data ──
40
+
41
+ // 1. Accumulated health checks from digest_sections
42
+ const healthRows = await env.db.prepare(
43
+ "SELECT payload FROM digest_sections WHERE consumed = 0 AND section = 'health_check' ORDER BY created_at ASC"
44
+ ).all<{ payload: string }>();
45
+
46
+ const rawHealthChecks: DigestHealthCheck[] = healthRows.results.map(r => {
47
+ try { return JSON.parse(r.payload) as DigestHealthCheck; }
48
+ catch { return null; }
49
+ }).filter((h): h is DigestHealthCheck => h !== null);
50
+
51
+ // Deduplicate health checks by check name — keep only the latest entry (#309).
52
+ // Multiple heartbeat runs can queue the same check (e.g. stale_agenda_151) across
53
+ // different digest_sections rows. We flatten all checks, dedup by name (last wins
54
+ // since rows are ordered ASC by created_at), then re-group by severity/timestamp.
55
+ const healthChecks = deduplicateHealthChecks(rawHealthChecks);
56
+
57
+ // 1b. ARGUS event notifications from digest_sections
58
+ const eventRows = await env.db.prepare(
59
+ "SELECT payload FROM digest_sections WHERE consumed = 0 AND section = 'event_notification' ORDER BY created_at ASC"
60
+ ).all<{ payload: string }>();
61
+
62
+ const eventNotifications: DigestEventNotification[] = eventRows.results.flatMap(r => {
63
+ try {
64
+ const parsed = JSON.parse(r.payload);
65
+ // Phase 2 queues { type: 'argus_events', events: [...] }
66
+ if (parsed.events) return parsed.events as DigestEventNotification[];
67
+ // Phase 3 queues { type: 'argus_patterns', patterns: [...] }
68
+ if (parsed.patterns) return (parsed.patterns as Array<{ pattern: string; severity: string; summary: string; detail: string }>).map(p => ({
69
+ source: 'argus',
70
+ event_type: p.pattern,
71
+ summary: p.summary,
72
+ priority: p.severity,
73
+ ts: parsed.timestamp ?? new Date().toISOString(),
74
+ }));
75
+ return [];
76
+ } catch { return []; }
77
+ });
78
+
79
+ // 1c. Memory reflections from digest_sections (weekly, queued by reflection.ts)
80
+ const reflectionRows = await env.db.prepare(
81
+ "SELECT payload FROM digest_sections WHERE consumed = 0 AND section = 'memory_reflection' ORDER BY created_at DESC LIMIT 1"
82
+ ).all<{ payload: string }>();
83
+
84
+ let memoryReflection: string | null = null;
85
+ if (reflectionRows.results.length > 0) {
86
+ try {
87
+ const parsed = JSON.parse(reflectionRows.results[0].payload);
88
+ memoryReflection = parsed.reflection ?? null;
89
+ } catch { /* skip */ }
90
+ }
91
+
92
+ // 1d. Cognitive metrics from digest_sections (daily, queued by cognitive-metrics.ts)
93
+ const metricsRows = await env.db.prepare(
94
+ "SELECT payload FROM digest_sections WHERE consumed = 0 AND section = 'cognitive_metrics' ORDER BY created_at DESC LIMIT 1"
95
+ ).all<{ payload: string }>();
96
+
97
+ let cognitiveMetrics: {
98
+ cognitive_score: number;
99
+ score_delta: number;
100
+ dispatch_success_rate_7d: number;
101
+ procedure_convergence_rate: number;
102
+ task_success_rate_7d: number;
103
+ tasks_completed_7d: number;
104
+ tasks_failed_7d: number;
105
+ avg_cost_7d: number;
106
+ avg_cost_prior_7d: number;
107
+ memory_count: number;
108
+ top_failure_kind: string | null;
109
+ } | null = null;
110
+
111
+ if (metricsRows.results.length > 0) {
112
+ try {
113
+ cognitiveMetrics = JSON.parse(metricsRows.results[0].payload);
114
+ } catch { /* skip */ }
115
+ }
116
+
117
+ // 1e. Analytics from digest_sections (daily, queued by argus-analytics.ts)
118
+ const analyticsRows = await env.db.prepare(
119
+ "SELECT payload FROM digest_sections WHERE consumed = 0 AND section = 'analytics' ORDER BY created_at DESC LIMIT 1"
120
+ ).all<{ payload: string }>();
121
+
122
+ let analytics: {
123
+ sessions_7d: number;
124
+ sessions_prior_7d: number;
125
+ users_7d: number;
126
+ bounce_rate_7d: number;
127
+ top_pages: Array<{ path: string; sessions: number; bounce_rate: number }>;
128
+ top_sources: Array<{ source: string; medium: string; sessions: number }>;
129
+ insights: string[];
130
+ } | null = null;
131
+
132
+ if (analyticsRows.results.length > 0) {
133
+ try { analytics = JSON.parse(analyticsRows.results[0].payload); } catch { /* skip */ }
134
+ }
135
+
136
+ // 1f. Developer activity from digest_sections (daily, queued by dev-activity.ts)
137
+ const devActivityRows = await env.db.prepare(
138
+ "SELECT payload FROM digest_sections WHERE consumed = 0 AND section = 'dev_activity' ORDER BY created_at DESC LIMIT 1"
139
+ ).all<{ payload: string }>();
140
+
141
+ let devActivity: DigestSections['devActivity'] = null;
142
+ if (devActivityRows.results.length > 0) {
143
+ try { devActivity = JSON.parse(devActivityRows.results[0].payload); } catch { /* skip */ }
144
+ }
145
+
146
+ // 1g. Service alerts from digest_sections (visual_qa, codebeast, etc.)
147
+ const serviceAlertRows = await env.db.prepare(
148
+ "SELECT section, payload FROM digest_sections WHERE consumed = 0 AND section NOT IN ('health_check', 'event_notification', 'memory_reflection', 'cognitive_metrics', 'analytics') ORDER BY created_at ASC"
149
+ ).all<{ section: string; payload: string }>();
150
+
151
+ const serviceAlerts: DigestServiceAlert[] = serviceAlertRows.results.flatMap(r => {
152
+ try {
153
+ const parsed = JSON.parse(r.payload);
154
+ return [{ source: parsed.source ?? r.section, severity: parsed.severity ?? 'medium', summary: parsed.summary ?? '', detail: parsed.detail ?? '', findingsCount: parsed.findings?.length ?? 0 }];
155
+ } catch { return []; }
156
+ });
157
+
158
+ // 2. Completed/failed/proposed tasks since last digest (24h)
159
+ const since = lastDigest?.received_at ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
160
+
161
+ const [completedResult, failedResult, proposedResult] = await Promise.all([
162
+ env.db.prepare(
163
+ "SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE status = 'completed' AND completed_at > ? ORDER BY completed_at DESC"
164
+ ).bind(since).all<DigestTask>(),
165
+ env.db.prepare(
166
+ "SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE status = 'failed' AND completed_at > ? ORDER BY completed_at DESC"
167
+ ).bind(since).all<DigestTask>(),
168
+ env.db.prepare(
169
+ "SELECT id, title, repo, status, authority, category, exit_code, error, result, pr_url, completed_at FROM cc_tasks WHERE authority = 'proposed' AND status = 'pending' ORDER BY created_at ASC"
170
+ ).all<DigestTask>(),
171
+ ]);
172
+
173
+ // 3. Latest operator log entry (generated at 08:00 UTC)
174
+ const logEntry = await env.db.prepare(
175
+ "SELECT content FROM operator_log ORDER BY created_at DESC LIMIT 1"
176
+ ).first<{ content: string }>();
177
+
178
+ // Only include if it was generated in the last 24h
179
+ let operatorLog: string | null = null;
180
+ if (logEntry) {
181
+ const logRow = await env.db.prepare(
182
+ "SELECT content FROM operator_log WHERE created_at > datetime('now', '-24 hours') ORDER BY created_at DESC LIMIT 1"
183
+ ).first<{ content: string }>();
184
+ operatorLog = logRow?.content ?? null;
185
+ }
186
+
187
+ // 4. Active agenda items (include created_at for proposal expiry countdown)
188
+ const agendaResult = await env.db.prepare(
189
+ "SELECT id, item, priority, context, created_at FROM agent_agenda WHERE status = 'active' ORDER BY priority ASC"
190
+ ).all<DigestAgendaItem>();
191
+
192
+ // 5. BizOps open interactions (non-fatal)
193
+ let bizopsInteractions: number | null = null;
194
+ try {
195
+ if (env.bizopsToken && env.bizopsFetcher) {
196
+ const client = new McpClient({
197
+ url: operatorConfig.integrations.bizops.fallbackUrl,
198
+ token: env.bizopsToken,
199
+ prefix: 'bizops',
200
+ fetcher: env.bizopsFetcher,
201
+ rpcPath: '/rpc',
202
+ });
203
+ const dashResult = await Promise.race([
204
+ client.callTool('dashboard_summary', {}),
205
+ new Promise<null>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
206
+ ]);
207
+ if (dashResult && typeof dashResult === 'object') {
208
+ const content = (dashResult as { content?: Array<{ text?: string }> }).content;
209
+ if (content?.[0]?.text) {
210
+ const parsed = JSON.parse(content[0].text);
211
+ bizopsInteractions = parsed.open_interactions ?? parsed.openInteractions ?? null;
212
+ }
213
+ }
214
+ }
215
+ } catch {
216
+ // Non-fatal — skip BizOps data
217
+ }
218
+
219
+ const sections: DigestSections = {
220
+ completedTasks: completedResult.results,
221
+ failedTasks: failedResult.results,
222
+ proposedTasks: proposedResult.results,
223
+ operatorLog,
224
+ healthChecks,
225
+ eventNotifications,
226
+ memoryReflection,
227
+ cognitiveMetrics,
228
+ analytics,
229
+ devActivity,
230
+ serviceAlerts,
231
+ agendaItems: agendaResult.results,
232
+ bizopsInteractions,
233
+ };
234
+
235
+ // Check if there's anything to report
236
+ const hasContent =
237
+ sections.eventNotifications.length > 0 ||
238
+ sections.serviceAlerts.length > 0 ||
239
+ sections.completedTasks.length > 0 ||
240
+ sections.failedTasks.length > 0 ||
241
+ sections.proposedTasks.length > 0 ||
242
+ sections.operatorLog !== null ||
243
+ sections.healthChecks.length > 0 ||
244
+ sections.agendaItems.some(a => a.priority === 'high');
245
+
246
+ if (!hasContent) {
247
+ console.log('[digest] Skipping — no content to report');
248
+ // Still update watermark so we don't re-check immediately
249
+ await env.db.prepare(
250
+ "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('daily_digest', datetime('now'))"
251
+ ).run();
252
+ return;
253
+ }
254
+
255
+ // ── Send digest email ──
256
+ await sendDailyDigest(
257
+ { resendApiKey: env.resendApiKey, resendApiKeyPersonal: env.resendApiKeyPersonal },
258
+ sections,
259
+ env.notifyEmail,
260
+ );
261
+
262
+ // Mark digest_sections as consumed
263
+ await env.db.prepare(
264
+ "UPDATE digest_sections SET consumed = 1 WHERE consumed = 0"
265
+ ).run();
266
+
267
+ // Update watermark
268
+ await env.db.prepare(
269
+ "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('daily_digest', datetime('now'))"
270
+ ).run();
271
+
272
+ console.log(`[digest] Daily digest sent: ${sections.completedTasks.length} completed, ${sections.failedTasks.length} failed, ${sections.proposedTasks.length} proposed, ${sections.healthChecks.length} health checks`);
273
+ }
274
+
275
+ // ─── Health Check Deduplication (#309) ──────────────────────
276
+ // Each DigestHealthCheck has { severity, checks: [{name, status, detail}], timestamp }.
277
+ // Multiple heartbeat runs can produce entries with the same check name (e.g. stale_agenda_151).
278
+ // Flatten all individual checks, keep only the latest per name, then re-group into a single
279
+ // DigestHealthCheck entry so the email renders each issue exactly once.
280
+
281
+ function deduplicateHealthChecks(raw: DigestHealthCheck[]): DigestHealthCheck[] {
282
+ if (raw.length === 0) return [];
283
+
284
+ // Flatten: attach parent severity/timestamp to each individual check
285
+ const seen = new Map<string, { name: string; status: string; detail: string; severity: string; timestamp: string }>();
286
+ for (const group of raw) {
287
+ for (const check of group.checks) {
288
+ // Later entries overwrite earlier ones (rows are ASC by created_at)
289
+ seen.set(check.name, {
290
+ ...check,
291
+ severity: group.severity,
292
+ timestamp: group.timestamp,
293
+ });
294
+ }
295
+ }
296
+
297
+ if (seen.size === 0) return [];
298
+
299
+ // Re-group deduped checks into a single DigestHealthCheck.
300
+ // Use the highest severity and latest timestamp from the surviving checks.
301
+ const dedupedChecks = Array.from(seen.values());
302
+ const severityOrder: Record<string, number> = { critical: 3, high: 2, medium: 1, low: 0 };
303
+ const maxSeverity = dedupedChecks.reduce((best, c) =>
304
+ (severityOrder[c.severity] ?? 0) > (severityOrder[best] ?? 0) ? c.severity : best,
305
+ 'low',
306
+ );
307
+ const latestTs = dedupedChecks.reduce((latest, c) =>
308
+ c.timestamp > latest ? c.timestamp : latest,
309
+ dedupedChecks[0].timestamp,
310
+ );
311
+
312
+ return [{
313
+ severity: maxSeverity,
314
+ checks: dedupedChecks.map(c => ({ name: c.name, status: c.status, detail: c.detail })),
315
+ timestamp: latestTs,
316
+ }];
317
+ }