@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.
- package/package.json +96 -0
- package/schema.sql +586 -0
- package/src/adapters/voice/cloudflare-agent.ts +34 -0
- package/src/auth.ts +124 -0
- package/src/bluesky.ts +464 -0
- package/src/claude-tools/content.ts +188 -0
- package/src/claude-tools/email.ts +69 -0
- package/src/claude-tools/github.ts +440 -0
- package/src/claude-tools/goals.ts +116 -0
- package/src/claude-tools/index.ts +353 -0
- package/src/claude-tools/web.ts +59 -0
- package/src/claude.ts +406 -0
- package/src/codebeast.ts +200 -0
- package/src/composite.ts +715 -0
- package/src/content/column.ts +80 -0
- package/src/content/hero-image.ts +47 -0
- package/src/content/index.ts +27 -0
- package/src/content/journal.ts +91 -0
- package/src/content/roundtable.ts +163 -0
- package/src/core.ts +309 -0
- package/src/dashboard.ts +620 -0
- package/src/decision-docs.ts +284 -0
- package/src/dispatch.ts +13 -0
- package/src/edge-env.ts +58 -0
- package/src/email.ts +850 -0
- package/src/exports.ts +156 -0
- package/src/github-projects.ts +312 -0
- package/src/github.ts +670 -0
- package/src/groq.ts +247 -0
- package/src/health-page.ts +578 -0
- package/src/index.ts +89 -0
- package/src/kernel/argus-actions.ts +397 -0
- package/src/kernel/argus-correlation.ts +639 -0
- package/src/kernel/board.ts +91 -0
- package/src/kernel/briefing.ts +177 -0
- package/src/kernel/classify-memory-topic.ts +166 -0
- package/src/kernel/cognition.ts +377 -0
- package/src/kernel/court-cards.ts +163 -0
- package/src/kernel/dispatch.ts +587 -0
- package/src/kernel/domain.ts +50 -0
- package/src/kernel/dynamic-tools.ts +322 -0
- package/src/kernel/executor-port.ts +45 -0
- package/src/kernel/executors/claude.ts +73 -0
- package/src/kernel/executors/direct.ts +237 -0
- package/src/kernel/executors/groq.ts +18 -0
- package/src/kernel/executors/index.ts +87 -0
- package/src/kernel/executors/tarotscript.ts +104 -0
- package/src/kernel/executors/workers-ai.ts +54 -0
- package/src/kernel/insight-cache.ts +76 -0
- package/src/kernel/memory/agenda.ts +200 -0
- package/src/kernel/memory/blocks.ts +188 -0
- package/src/kernel/memory/consolidation.ts +194 -0
- package/src/kernel/memory/episodic.ts +241 -0
- package/src/kernel/memory/goals.ts +156 -0
- package/src/kernel/memory/graph.ts +290 -0
- package/src/kernel/memory/index.ts +11 -0
- package/src/kernel/memory/insights.ts +316 -0
- package/src/kernel/memory/procedural.ts +467 -0
- package/src/kernel/memory/pruning.ts +67 -0
- package/src/kernel/memory/recall.ts +367 -0
- package/src/kernel/memory/semantic.ts +315 -0
- package/src/kernel/memory/synthesis.ts +161 -0
- package/src/kernel/memory-adapter.ts +369 -0
- package/src/kernel/memory-guardrails.ts +76 -0
- package/src/kernel/port.ts +23 -0
- package/src/kernel/resilience.ts +322 -0
- package/src/kernel/router.ts +471 -0
- package/src/kernel/scheduled/agent-dispatch.ts +252 -0
- package/src/kernel/scheduled/argus-analytics.ts +247 -0
- package/src/kernel/scheduled/argus-heartbeat.ts +320 -0
- package/src/kernel/scheduled/argus-notify.ts +348 -0
- package/src/kernel/scheduled/board-sync.ts +110 -0
- package/src/kernel/scheduled/ci-watcher.ts +125 -0
- package/src/kernel/scheduled/cognitive-metrics.ts +377 -0
- package/src/kernel/scheduled/consolidation.ts +229 -0
- package/src/kernel/scheduled/content-drip.ts +47 -0
- package/src/kernel/scheduled/content.ts +6 -0
- package/src/kernel/scheduled/conversation-facts.ts +204 -0
- package/src/kernel/scheduled/cost-report.ts +84 -0
- package/src/kernel/scheduled/curiosity.ts +219 -0
- package/src/kernel/scheduled/dev-activity.ts +44 -0
- package/src/kernel/scheduled/digest.ts +317 -0
- package/src/kernel/scheduled/dreaming/agenda-triage.ts +115 -0
- package/src/kernel/scheduled/dreaming/facts.ts +239 -0
- package/src/kernel/scheduled/dreaming/index.ts +8 -0
- package/src/kernel/scheduled/dreaming/llm.ts +33 -0
- package/src/kernel/scheduled/dreaming/pattern-synthesis.ts +124 -0
- package/src/kernel/scheduled/dreaming/persona.ts +75 -0
- package/src/kernel/scheduled/dreaming/symbolic.ts +31 -0
- package/src/kernel/scheduled/dreaming/task-proposals.ts +80 -0
- package/src/kernel/scheduled/dreaming.ts +66 -0
- package/src/kernel/scheduled/entropy.ts +149 -0
- package/src/kernel/scheduled/escalation.ts +192 -0
- package/src/kernel/scheduled/feed-watcher.ts +206 -0
- package/src/kernel/scheduled/goals.ts +214 -0
- package/src/kernel/scheduled/governance.ts +41 -0
- package/src/kernel/scheduled/heartbeat.ts +220 -0
- package/src/kernel/scheduled/inbox-processor.ts +174 -0
- package/src/kernel/scheduled/index.ts +245 -0
- package/src/kernel/scheduled/issue-proposer.ts +478 -0
- package/src/kernel/scheduled/issue-watcher.ts +128 -0
- package/src/kernel/scheduled/pr-automerge.ts +213 -0
- package/src/kernel/scheduled/product-health.ts +107 -0
- package/src/kernel/scheduled/reflection.ts +373 -0
- package/src/kernel/scheduled/self-improvement.ts +114 -0
- package/src/kernel/scheduled/social-engage.ts +175 -0
- package/src/kernel/scheduled/task-audit.ts +60 -0
- package/src/kernel/symbolic.ts +156 -0
- package/src/kernel/types.ts +145 -0
- package/src/landing.ts +1190 -0
- package/src/lib/audit-chain/chain.ts +28 -0
- package/src/lib/audit-chain/types.ts +12 -0
- package/src/lib/observability/errors.ts +55 -0
- package/src/markdown.ts +164 -0
- package/src/mcp/handlers.ts +647 -0
- package/src/mcp/server.ts +184 -0
- package/src/mcp/tools.ts +316 -0
- package/src/mcp-client.ts +275 -0
- package/src/mcp-server.ts +2 -0
- package/src/operator/config.example.ts +60 -0
- package/src/operator/config.ts +60 -0
- package/src/operator/index.ts +46 -0
- package/src/operator/persona.example.ts +34 -0
- package/src/operator/persona.ts +34 -0
- package/src/operator/prompt-builder.ts +190 -0
- package/src/operator/types.ts +43 -0
- package/src/pulse.ts +1179 -0
- package/src/routes/bluesky.ts +116 -0
- package/src/routes/cc-tasks.ts +328 -0
- package/src/routes/codebeast.ts +1 -0
- package/src/routes/content.ts +194 -0
- package/src/routes/conversations.ts +25 -0
- package/src/routes/dynamic-tools.ts +111 -0
- package/src/routes/feedback.ts +192 -0
- package/src/routes/health.ts +147 -0
- package/src/routes/messages.ts +228 -0
- package/src/routes/observability.ts +82 -0
- package/src/routes/operator-logs.ts +42 -0
- package/src/routes/pages.ts +96 -0
- package/src/routes/sessions.ts +54 -0
- package/src/sanitize.ts +73 -0
- package/src/schema-enums.ts +155 -0
- package/src/search.ts +112 -0
- package/src/task-intelligence.ts +497 -0
- package/src/types.ts +194 -0
- package/src/ui.ts +5 -0
- package/src/version.ts +3 -0
- 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
|
+
}
|