@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,214 @@
1
+ import { createIntent, dispatch, type EdgeEnv } from '../dispatch.js';
2
+ import { getActiveGoals, touchGoal, recordGoalAction, getGoalActions, downgradeGoalAuthority, type AgentGoal } from '../memory/index.js';
3
+
4
+ // ─── Autonomous Goal Loop (#14, #28) ─────────────────────────
5
+
6
+ // Standing Orders: structured context for goal execution.
7
+ // Stored in agent_goals.context_json. Defines scope, guardrails,
8
+ // and escalation rules so goals operate with clear boundaries.
9
+ export interface GoalContext {
10
+ scope?: string[]; // What this goal IS authorized to do
11
+ guardrails?: string[]; // What this goal must NOT do
12
+ escalation?: string[]; // When to stop and flag for human attention
13
+ triggers?: string[]; // Additional trigger conditions beyond schedule
14
+ deadline?: string; // ISO date — auto-complete goal after this date
15
+ }
16
+
17
+ // Coerce string fields to string[] — context_json in the DB may have been written
18
+ // with a plain string instead of an array, which passes the optional-chain length
19
+ // guard but causes .map() to throw at runtime (aegis#585).
20
+ function normalizeContext(raw: GoalContext): GoalContext {
21
+ const toArr = (v: unknown): string[] | undefined => {
22
+ if (!v) return undefined;
23
+ if (Array.isArray(v)) return v.map(String);
24
+ if (typeof v === 'string') return [v];
25
+ return undefined;
26
+ };
27
+ return {
28
+ ...raw,
29
+ scope: toArr(raw.scope),
30
+ guardrails: toArr(raw.guardrails),
31
+ escalation: toArr(raw.escalation),
32
+ triggers: toArr(raw.triggers),
33
+ };
34
+ }
35
+
36
+ function formatStandingOrders(ctx: GoalContext): string {
37
+ const sections: string[] = [];
38
+
39
+ if (ctx.scope?.length) {
40
+ sections.push(`Scope (what you MAY do):\n${ctx.scope.map(s => ` - ${s}`).join('\n')}`);
41
+ }
42
+ if (ctx.guardrails?.length) {
43
+ sections.push(`Guardrails (what you must NOT do):\n${ctx.guardrails.map(g => ` - ${g}`).join('\n')}`);
44
+ }
45
+ if (ctx.escalation?.length) {
46
+ sections.push(`Escalation (stop and flag if):\n${ctx.escalation.map(e => ` - ${e}`).join('\n')}`);
47
+ }
48
+ if (ctx.triggers?.length) {
49
+ sections.push(`Additional triggers:\n${ctx.triggers.map(t => ` - ${t}`).join('\n')}`);
50
+ }
51
+
52
+ return sections.length > 0
53
+ ? `\nStanding Orders:\n${sections.join('\n')}`
54
+ : '';
55
+ }
56
+
57
+ export const MAX_GOALS_PER_CYCLE = 3;
58
+
59
+ // Approved tools for auto_low execution — reversible, low-risk actions only
60
+ // NOTE: Only list tools that ACTUALLY EXIST. Phantom tools cause silent correctness failures
61
+ // where error messages pass the >50 char check and get recorded as "success".
62
+ export const AUTO_LOW_APPROVED_TOOLS: ReadonlySet<string> = new Set([
63
+ 'record_memory_entry',
64
+ 'resolve_agenda_item',
65
+ 'add_agenda_item',
66
+ ]);
67
+
68
+ export const AUTO_LOW_MAX_ACTIONS_PER_RUN = 3;
69
+ export const AUTO_LOW_FAILURE_THRESHOLD = 2; // consecutive failures before downgrade
70
+
71
+ export async function runGoalLoop(env: EdgeEnv): Promise<void> {
72
+ const goals = await getActiveGoals(env.db);
73
+ if (goals.length === 0) return;
74
+
75
+ const nowMs = Date.now();
76
+ const seen = new Set<string | number>();
77
+ // Parse next_run_at to Date for correct comparison — D1 stores 'YYYY-MM-DD HH:MM:SS'
78
+ // while JS toISOString() uses 'YYYY-MM-DDTHH:MM:SS.sssZ'. Lexicographic comparison
79
+ // breaks because space (ASCII 32) < T (ASCII 84), making every goal appear overdue.
80
+ const due = goals.filter(g => {
81
+ if (g.next_run_at === null) return true;
82
+ const nextRunMs = new Date(g.next_run_at + 'Z').getTime();
83
+ return nextRunMs <= nowMs;
84
+ })
85
+ .filter(g => { if (seen.has(g.id)) return false; seen.add(g.id); return true; })
86
+ .sort((a, b) => {
87
+ const aMs = a.next_run_at ? new Date(a.next_run_at + 'Z').getTime() : 0;
88
+ const bMs = b.next_run_at ? new Date(b.next_run_at + 'Z').getTime() : 0;
89
+ return aMs - bMs;
90
+ })
91
+ .slice(0, MAX_GOALS_PER_CYCLE);
92
+ if (due.length === 0) return;
93
+
94
+ console.log(`[goals] ${due.length} goal(s) due for execution (capped at ${MAX_GOALS_PER_CYCLE})`);
95
+ for (const goal of due) {
96
+ // Check consecutive failures for auto_low goals before running
97
+ if (goal.authority_level === 'auto_low') {
98
+ const recentActions = await getGoalActions(env.db, goal.id, AUTO_LOW_FAILURE_THRESHOLD);
99
+ const consecutiveFailures = recentActions.filter(a => a.outcome === 'failure').length;
100
+ if (consecutiveFailures >= AUTO_LOW_FAILURE_THRESHOLD) {
101
+ await downgradeGoalAuthority(env.db, goal.id, `${consecutiveFailures} consecutive failures`);
102
+ goal.authority_level = 'propose'; // reflect downgrade for this run
103
+ }
104
+ }
105
+ await runSingleGoal(env, goal);
106
+ }
107
+ }
108
+
109
+ export async function runSingleGoal(env: EdgeEnv, goal: AgentGoal): Promise<void> {
110
+ // Parse standing orders from context_json
111
+ let goalContext: GoalContext | null = null;
112
+ if (goal.context_json) {
113
+ try {
114
+ goalContext = normalizeContext(JSON.parse(goal.context_json) as GoalContext);
115
+ } catch {
116
+ console.warn(`[goals] Failed to parse context_json for "${goal.title}"`);
117
+ }
118
+ }
119
+
120
+ // Check deadline — auto-complete expired goals
121
+ if (goalContext?.deadline) {
122
+ const deadlineMs = new Date(goalContext.deadline).getTime();
123
+ if (!isNaN(deadlineMs) && Date.now() > deadlineMs) {
124
+ console.log(`[goals] Goal "${goal.title}" past deadline (${goalContext.deadline}), marking completed`);
125
+ await env.db.prepare("UPDATE agent_goals SET status = 'completed', completed_at = datetime('now') WHERE id = ?").bind(goal.id).run();
126
+ return;
127
+ }
128
+ }
129
+
130
+ const isAutoLow = goal.authority_level === 'auto_low';
131
+
132
+ const authorityInstructions = isAutoLow
133
+ ? `Authority: auto_low — you may DIRECTLY execute these approved actions without human approval:
134
+ - record_memory_entry (record observations)
135
+ - resolve_agenda_item (close stale agenda items)
136
+ - add_agenda_item (create new agenda items)
137
+ - Read-only BizOps tools (compliance_items, compliance_status, document_status, dashboard_summary)
138
+
139
+ For any action NOT in this list, create a [PROPOSED ACTION] agenda item instead.
140
+ Maximum ${AUTO_LOW_MAX_ACTIONS_PER_RUN} autonomous actions per run.`
141
+ : 'Authority: propose — create [PROPOSED ACTION] agenda items, do not execute autonomously.';
142
+
143
+ const standingOrders = goalContext ? formatStandingOrders(goalContext) : '';
144
+
145
+ const prompt = `You are running the autonomous goal loop. Evaluate the following goal and take appropriate action.
146
+
147
+ **Goal #${goal.id}: ${goal.title}**
148
+ ${goal.description ? `Description: ${goal.description}` : ''}
149
+ ${authorityInstructions}${standingOrders}
150
+ Run count: ${goal.run_count}
151
+
152
+ Procedure:
153
+ 1. Use BizOps tools to check current state relevant to this goal
154
+ 2. Determine if action is needed
155
+ 3. ${isAutoLow
156
+ ? 'If yes and action is in approved list: execute it directly. Otherwise: create [PROPOSED ACTION] agenda item.'
157
+ : 'If yes: call add_agenda_item with "[PROPOSED ACTION]" prefix and full reasoning in the context field'}
158
+ 4. If no: call record_memory_entry noting what was checked and that no action is needed
159
+ 5. ${isAutoLow ? `Maximum ${AUTO_LOW_MAX_ACTIONS_PER_RUN} actions per run.` : 'One proposed action max per run unless multiple urgent issues exist'}
160
+
161
+ Be concise. This is a scheduled check, not a full analysis.`;
162
+
163
+ try {
164
+ const intent = createIntent(`goal-${goal.id}`, prompt, {
165
+ source: { channel: 'internal', threadId: `goal-${goal.id}` },
166
+ classified: 'goal_execution',
167
+ costCeiling: 'expensive',
168
+ raw: prompt,
169
+ });
170
+
171
+ const result = await dispatch(intent, env);
172
+ const partialMeta = result.meta as { partialFailure?: boolean; failedSubtasks?: number; subtasksPlanned?: number } | undefined;
173
+ const isPartial = partialMeta?.partialFailure;
174
+ // Partial failure is only a true failure if ALL subtasks failed or the response is empty.
175
+ // If some subtasks succeeded and returned data, treat as success (the data is usable).
176
+ // BUT: error messages are NOT usable data — check for known failure patterns.
177
+ const errorPatterns = ['Tool unavailable', 'Method not found', 'MCP RPC error', 'Unknown tool', 'Memory Worker binding unavailable', 'Memory write failed', 'Cannot read properties of undefined'];
178
+ const looksLikeError = errorPatterns.some(p => result.text.includes(p));
179
+ // Clean result patterns indicate the goal achieved its purpose even if subtasks errored
180
+ const cleanPatterns = ['no overdue', 'all clear', 'compliance posture is clean', 'no deadlines within', 'no action needed', 'recording the check', 'recording clean'];
181
+ const looksClean = cleanPatterns.some(p => result.text.toLowerCase().includes(p));
182
+ const hasUsableData = result.text.length > 50 && (!looksLikeError || looksClean);
183
+ const allFailed = isPartial && (partialMeta?.failedSubtasks ?? 0) >= (partialMeta?.subtasksPlanned ?? 1);
184
+ // A clean result overrides partial failure — the goal loop got the answer it needed
185
+ const goalOutcome = (!looksClean && (allFailed || !hasUsableData)) ? 'failure' : 'success';
186
+ const desc = isPartial
187
+ ? `${result.text.slice(0, 400)} [partial: ${partialMeta.failedSubtasks} subtask(s) failed]`
188
+ : result.text.slice(0, 500);
189
+ await recordGoalAction(env.db, goal.id, 'executed', desc, goalOutcome, {
190
+ autoExecuted: isAutoLow,
191
+ authorityLevel: goal.authority_level,
192
+ });
193
+ console.log(`[goals] goal "${goal.title}" ${goalOutcome} (${goal.authority_level}) — ${result.text.slice(0, 100)}`);
194
+ } catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err);
196
+ try {
197
+ await recordGoalAction(env.db, goal.id, 'skipped', `Error: ${msg}`, 'failure', {
198
+ autoExecuted: false,
199
+ authorityLevel: goal.authority_level,
200
+ });
201
+ } catch { /* don't let recording failure prevent touchGoal */ }
202
+ console.error(`[goals] goal "${goal.title}" failed:`, msg);
203
+
204
+ // Failure downgrade: auto_low goal that fails reverts to propose
205
+ if (isAutoLow) {
206
+ await downgradeGoalAuthority(env.db, goal.id, `Execution failure: ${msg.slice(0, 200)}`);
207
+ }
208
+ } finally {
209
+ // Always advance schedule — prevents runaway re-execution on persistent errors (#83)
210
+ await touchGoal(env.db, goal.id, goal.schedule_hours).catch(err =>
211
+ console.error(`[goals] touchGoal failed for "${goal.title}":`, err instanceof Error ? err.message : String(err))
212
+ );
213
+ }
214
+ }
@@ -0,0 +1,41 @@
1
+ // Task Governance Limits — safety checks before auto-creating cc_tasks
2
+ // Prevents runaway task creation by enforcing caps on active AEGIS tasks.
3
+
4
+ /**
5
+ * Check whether AEGIS is allowed to create another task.
6
+ * Returns { allowed: true } or { allowed: false, reason: string }.
7
+ */
8
+ export async function checkTaskGovernanceLimits(
9
+ db: D1Database,
10
+ opts: { repo: string; title: string; category: string },
11
+ ): Promise<{ allowed: true } | { allowed: false; reason: string }> {
12
+ // Per-repo cap: max 5 active AEGIS tasks per repo
13
+ const repoPending = await db.prepare(
14
+ `SELECT COUNT(*) as c FROM cc_tasks WHERE status IN ('pending', 'running') AND created_by LIKE 'aegis%' AND repo = ?`
15
+ ).bind(opts.repo).first<{ c: number }>();
16
+
17
+ if (repoPending && repoPending.c >= 5) {
18
+ return { allowed: false, reason: `Per-repo cap reached (${repoPending.c}/5 active AEGIS tasks for ${opts.repo})` };
19
+ }
20
+
21
+ // Global active cap: max 20 pending/running AEGIS tasks across all repos
22
+ // Completed tasks don't block new work — cap is self-regulating via consumption
23
+ const activeCount = await db.prepare(
24
+ `SELECT COUNT(*) as c FROM cc_tasks WHERE status IN ('pending', 'running') AND created_by LIKE 'aegis%'`
25
+ ).first<{ c: number }>();
26
+
27
+ if (activeCount && activeCount.c >= 20) {
28
+ return { allowed: false, reason: `Active task cap reached (${activeCount.c}/20 pending/running AEGIS tasks)` };
29
+ }
30
+
31
+ // Duplicate title detection: prevent creating tasks with identical titles that are active or completed
32
+ const duplicate = await db.prepare(
33
+ `SELECT COUNT(*) as c FROM cc_tasks WHERE title = ? AND status IN ('pending', 'running', 'completed')`
34
+ ).bind(opts.title).first<{ c: number }>();
35
+
36
+ if (duplicate && duplicate.c > 0) {
37
+ return { allowed: false, reason: `Duplicate task: "${opts.title}" is already pending` };
38
+ }
39
+
40
+ return { allowed: true };
41
+ }
@@ -0,0 +1,220 @@
1
+ import { createIntent, dispatch, type EdgeEnv } from '../dispatch.js';
2
+ import { getActiveAgendaItems, getRecentHeartbeats, PROPOSED_ACTION_PREFIX } from '../memory/index.js';
3
+ import { type StaleHighItem } from './escalation.js';
4
+
5
+ export const ALERT_SEVERITIES = new Set(['medium', 'high', 'critical']);
6
+
7
+ /** Hours between heartbeat runs (directive: reduce from 1h to 6h) */
8
+ const HEARTBEAT_CADENCE_HOURS = 6;
9
+
10
+ /** Consecutive medium runs before severity decays to LOW (directive: 10+ → downgrade) */
11
+ const CHRONIC_MEDIUM_THRESHOLD = 10;
12
+
13
+ /** Once decayed to LOW, only re-surface weekly (runs at 6h cadence = 28 runs/week) */
14
+ const LOW_RESURFACE_INTERVAL = 28;
15
+
16
+ export type CheckStatus = 'ok' | 'warn' | 'alert';
17
+ export interface HeartbeatCheck { name: string; status: CheckStatus; detail: string }
18
+
19
+ export async function runHeartbeat(env: EdgeEnv, staleHighItems: StaleHighItem[] = []): Promise<void> {
20
+ // Cadence gate: only run every 6 hours (0, 6, 12, 18 UTC)
21
+ // Skip at 12 UTC — daily digest covers that slot
22
+ const hour = new Date().getUTCHours();
23
+ if (hour % HEARTBEAT_CADENCE_HOURS !== 0 || hour === 12) return;
24
+
25
+ // Load prior heartbeat history for trend context (#6)
26
+ const priorRuns = await getRecentHeartbeats(env.db, 12);
27
+ const priorChecksMap = new Map<string, CheckStatus[]>();
28
+ for (const run of priorRuns) {
29
+ const checks = JSON.parse(run.checks_json) as HeartbeatCheck[];
30
+ for (const c of checks) {
31
+ const history = priorChecksMap.get(c.name) ?? [];
32
+ history.push(c.status);
33
+ priorChecksMap.set(c.name, history);
34
+ }
35
+ }
36
+
37
+ const intent = createIntent('scheduled', 'heartbeat check', {
38
+ source: { channel: 'internal', threadId: 'scheduled' },
39
+ classified: 'heartbeat',
40
+ costCeiling: 'free',
41
+ });
42
+ const result = await dispatch(intent, env);
43
+ const meta = result.meta as { actionable?: boolean; severity?: string; checks?: unknown[] } | undefined;
44
+
45
+ const actionable = meta?.actionable ?? false;
46
+ const severity = meta?.severity ?? 'none';
47
+ const checks = (meta?.checks ?? []) as HeartbeatCheck[];
48
+
49
+ const checksJson = JSON.stringify(checks);
50
+
51
+ await env.db.prepare(
52
+ 'INSERT INTO heartbeat_results (actionable, severity, summary, checks_json) VALUES (?, ?, ?, ?)'
53
+ ).bind(
54
+ actionable ? 1 : 0,
55
+ severity,
56
+ result.text.slice(0, 500),
57
+ checksJson,
58
+ ).run();
59
+
60
+ // Payload dedup: if checks are identical to the last run, suppress all email/digest writes.
61
+ // Only recording the result above (for trend history) and agenda sync still proceed.
62
+ const lastRun = priorRuns[0];
63
+ if (lastRun && lastRun.checks_json === checksJson) {
64
+ console.log('[heartbeat] Payload identical to previous run — suppressing email/digest');
65
+ return;
66
+ }
67
+
68
+ // Classify each check as new / persisting / escalated / resolved (#6)
69
+ // emailableChecks: new + escalated (triggers alert email)
70
+ // agendaChecks: new + escalated + persisting (synced to agenda, but no re-email for persisters)
71
+ const { emailableChecks, agendaChecks, resolved } = classifyCheckDeltas(checks, priorChecksMap);
72
+
73
+ // Per-check email cooldown (24h) — prevents re-alerting when checks flap due to
74
+ // transient BizOps timeouts or data fluctuations resetting the history window
75
+ const ALERT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
76
+ const dedupedEmailChecks: HeartbeatCheck[] = [];
77
+ for (const check of emailableChecks) {
78
+ const eventKey = `heartbeat_alert_${check.name}`;
79
+ const lastAlert = await env.db.prepare(
80
+ "SELECT received_at FROM web_events WHERE event_id = ?"
81
+ ).bind(eventKey).first<{ received_at: string }>();
82
+ if (lastAlert && (Date.now() - new Date(lastAlert.received_at + 'Z').getTime()) < ALERT_COOLDOWN_MS) {
83
+ continue; // already alerted within 24h
84
+ }
85
+ dedupedEmailChecks.push(check);
86
+ }
87
+
88
+ await syncChecksToAgenda(env.db, agendaChecks);
89
+
90
+ // Auto-resolve agenda items for checks that cleared
91
+ await resolveAgendaForChecks(env.db, resolved);
92
+
93
+ // Inject stale escalation items as heartbeat checks so they appear in the same email
94
+ if (staleHighItems.length > 0) {
95
+ for (const s of staleHighItems) {
96
+ const alreadyTracked = dedupedEmailChecks.some(c => c.name === `stale_agenda_${s.id}`);
97
+ if (!alreadyTracked) {
98
+ dedupedEmailChecks.push({
99
+ name: `stale_agenda_${s.id}`,
100
+ status: 'warn',
101
+ detail: `${s.item.slice(0, 120)} (${Math.floor(s.ageDays)}d stale)`,
102
+ });
103
+ }
104
+ }
105
+ }
106
+
107
+ // Ensure severity covers escalation items even if LLM heartbeat was clean
108
+ const effectiveSeverity = dedupedEmailChecks.length > 0 && !ALERT_SEVERITIES.has(severity)
109
+ ? 'medium'
110
+ : severity;
111
+
112
+ // All heartbeat alerts — write to digest_sections for daily digest consolidation.
113
+ // No standalone emails. Critical ARGUS events (CI failures, payment issues) handle
114
+ // real-time alerting; heartbeat checks are not time-sensitive enough for inbox spam.
115
+ if (dedupedEmailChecks.length > 0 && ALERT_SEVERITIES.has(effectiveSeverity)) {
116
+ const payload = JSON.stringify({
117
+ severity: effectiveSeverity,
118
+ checks: dedupedEmailChecks,
119
+ timestamp: new Date().toISOString(),
120
+ });
121
+ await env.db.prepare(
122
+ "INSERT INTO digest_sections (section, payload) VALUES ('health_check', ?)"
123
+ ).bind(payload).run();
124
+ // Record per-check cooldown timestamps
125
+ for (const check of dedupedEmailChecks) {
126
+ const eventKey = `heartbeat_alert_${check.name}`;
127
+ await env.db.prepare(
128
+ "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES (?, datetime('now'))"
129
+ ).bind(eventKey).run();
130
+ }
131
+ }
132
+ }
133
+
134
+ export function classifyCheckDeltas(
135
+ current: HeartbeatCheck[],
136
+ priorMap: Map<string, CheckStatus[]>,
137
+ ): { emailableChecks: HeartbeatCheck[]; agendaChecks: HeartbeatCheck[]; resolved: string[] } {
138
+ const emailableChecks: HeartbeatCheck[] = []; // new + escalated → triggers email
139
+ const agendaChecks: HeartbeatCheck[] = []; // new + escalated + persisting → agenda sync
140
+ const resolved: string[] = [];
141
+
142
+ for (const check of current) {
143
+ const prior = priorMap.get(check.name) ?? [];
144
+ const lastStatus = prior[0]; // most recent prior run
145
+
146
+ if (check.status === 'ok') {
147
+ // Was previously warn/alert → now ok = resolved
148
+ if (lastStatus && lastStatus !== 'ok') resolved.push(check.name);
149
+ continue;
150
+ }
151
+
152
+ if (!lastStatus || lastStatus === 'ok') {
153
+ // New issue — email + agenda
154
+ emailableChecks.push(check);
155
+ agendaChecks.push(check);
156
+ } else if (check.status === 'alert' && lastStatus === 'warn') {
157
+ // Escalated warn→alert — email + agenda
158
+ emailableChecks.push(check);
159
+ agendaChecks.push(check);
160
+ } else {
161
+ // Persisting — check for chronic medium decay before deciding re-surface cadence
162
+ const allPrior = [lastStatus, ...prior.slice(1)];
163
+ const consecutivePersist = allPrior.filter(s => s !== 'ok').length;
164
+
165
+ // Severity decay: medium items persisting 10+ runs are accepted risks.
166
+ // Downgrade to LOW and only re-surface weekly (~28 runs at 6h cadence).
167
+ if (check.status === 'warn' && consecutivePersist >= CHRONIC_MEDIUM_THRESHOLD) {
168
+ if (consecutivePersist > 0 && consecutivePersist % LOW_RESURFACE_INTERVAL === 0) {
169
+ agendaChecks.push({ ...check, detail: `${check.detail} (known risk, weekly check-in — ${consecutivePersist + 1} runs)` });
170
+ }
171
+ // Otherwise fully suppress — operator is aware
172
+ } else if (consecutivePersist > 0 && consecutivePersist % 12 === 0) {
173
+ // Normal persisting items re-surface every 12 runs (~3 days at 6h cadence)
174
+ agendaChecks.push({ ...check, detail: `${check.detail} (persisting ${consecutivePersist + 1} runs)` });
175
+ }
176
+ // Otherwise suppress entirely — already on agenda
177
+ }
178
+ }
179
+
180
+ return { emailableChecks, agendaChecks, resolved };
181
+ }
182
+
183
+ export async function syncChecksToAgenda(db: D1Database, checks: HeartbeatCheck[]): Promise<void> {
184
+ for (const check of checks) {
185
+ const existing = await db.prepare(
186
+ "SELECT id FROM agent_agenda WHERE status = 'active' AND item LIKE ?"
187
+ ).bind(`%${check.name}%`).first();
188
+ if (existing) continue;
189
+
190
+ // Respect dismissed items — don't re-create if dismissed within the last 7 days.
191
+ // This prevents agenda churn where a known structural condition (e.g. low separation
192
+ // scores) gets re-created every heartbeat cycle after the operator dismisses it.
193
+ const recentlyDismissed = await db.prepare(
194
+ "SELECT id FROM agent_agenda WHERE status = 'dismissed' AND item LIKE ? AND resolved_at > datetime('now', '-7 days')"
195
+ ).bind(`%${check.name}%`).first();
196
+ if (recentlyDismissed) continue;
197
+
198
+ // Alert-level checks become proposed actions — AEGIS has a specific recommended step.
199
+ // Warn-level checks are regular agenda items (flag only, no clear action yet).
200
+ const isProposed = check.status === 'alert';
201
+ const item = isProposed
202
+ ? `${PROPOSED_ACTION_PREFIX} ${check.name}: ${check.detail}`
203
+ : `${check.name}: ${check.detail}`;
204
+ const context = isProposed
205
+ ? `Auto-detected by heartbeat. Review in BizOps and resolve the underlying issue, then mark this item done.`
206
+ : 'Auto-detected by heartbeat';
207
+
208
+ await db.prepare(
209
+ 'INSERT INTO agent_agenda (item, context, priority) VALUES (?, ?, ?)'
210
+ ).bind(item, context, isProposed ? 'high' : 'medium').run();
211
+ }
212
+ }
213
+
214
+ export async function resolveAgendaForChecks(db: D1Database, checkNames: string[]): Promise<void> {
215
+ for (const name of checkNames) {
216
+ await db.prepare(
217
+ "UPDATE agent_agenda SET status = 'done', resolved_at = datetime('now') WHERE status = 'active' AND item LIKE ?"
218
+ ).bind(`%${name}%`).run();
219
+ }
220
+ }
@@ -0,0 +1,174 @@
1
+ // --- Agent Inbox Processor ---
2
+ // Runs every hour in the heartbeat phase. Reads AEGIS's unread
3
+ // inbox messages, processes them by type, acks, and responds.
4
+ // Zero LLM cost -- all processing is D1 reads/writes + memory lookups.
5
+ // For context_request: pull relevant memory and post a reply.
6
+ // For task_proposal: create agenda item for operator approval.
7
+ // For alert: escalate to agenda.
8
+ // For status_update/message: ack and optionally record to memory.
9
+
10
+ import { type EdgeEnv } from '../dispatch.js';
11
+ import { searchMemoryByKeywords, recordMemory } from '../memory-adapter.js';
12
+ import { addAgendaItem } from '../memory/index.js';
13
+
14
+ interface InboxMessage {
15
+ id: string;
16
+ sender: string;
17
+ recipient: string;
18
+ channel: string;
19
+ msg_type: string;
20
+ subject: string;
21
+ body: string;
22
+ created_at: string;
23
+ }
24
+
25
+ async function ackMessage(db: D1Database, id: string): Promise<void> {
26
+ await db.prepare("UPDATE agent_inbox SET acked_at = datetime('now') WHERE id = ?").bind(id).run();
27
+ }
28
+
29
+ async function postReply(db: D1Database, sender: string, recipient: string, channel: string, msgType: string, subject: string, body: string): Promise<void> {
30
+ const id = crypto.randomUUID();
31
+ await db.prepare(`
32
+ INSERT INTO agent_inbox (id, sender, recipient, channel, msg_type, subject, body)
33
+ VALUES (?, ?, ?, ?, ?, ?, ?)
34
+ `).bind(id, sender, recipient, channel, msgType, subject, body).run();
35
+ }
36
+
37
+ async function handleContextRequest(msg: InboxMessage, env: EdgeEnv): Promise<void> {
38
+ // Extract keywords from subject + body for memory search
39
+ const searchTerms = `${msg.subject} ${msg.body}`.slice(0, 500);
40
+
41
+ // Search semantic memory (requires Memory Worker binding)
42
+ const memories = env.memoryBinding
43
+ ? await searchMemoryByKeywords(env.memoryBinding, searchTerms, 15)
44
+ : [];
45
+
46
+ // Also check episodic memory for recent conversations on the topic
47
+ const episodes = await env.db.prepare(`
48
+ SELECT intent, classification, outcome, created_at
49
+ FROM episodic_memory
50
+ WHERE intent LIKE ? OR classification LIKE ?
51
+ ORDER BY created_at DESC LIMIT 10
52
+ `).bind(`%${msg.subject.split(' ')[0]}%`, `%${msg.subject.split(' ')[0]}%`).all();
53
+
54
+ // Build response
55
+ const memorySection = memories.length > 0
56
+ ? memories.map(m => `- [${m.topic}] ${m.fact} (confidence: ${m.confidence})`).join('\n')
57
+ : '(no relevant memories found)';
58
+
59
+ const episodeSection = (episodes.results?.length ?? 0) > 0
60
+ ? (episodes.results as Array<{ intent: string; classification: string; outcome: string; created_at: string }>)
61
+ .map(e => `- [${e.created_at}] ${e.classification}: ${e.intent?.slice(0, 100)}`)
62
+ .join('\n')
63
+ : '(no recent episodes)';
64
+
65
+ const response = `## Context: ${msg.subject}
66
+
67
+ ### Semantic Memory (${memories.length} entries)
68
+ ${memorySection}
69
+
70
+ ### Recent Episodes (${episodes.results?.length ?? 0})
71
+ ${episodeSection}
72
+
73
+ ---
74
+ _Auto-generated by AEGIS inbox processor in response to context_request from ${msg.sender}_`;
75
+
76
+ await postReply(env.db, 'aegis', msg.sender, msg.channel, 'message', `Re: ${msg.subject}`, response);
77
+ await ackMessage(env.db, msg.id);
78
+ console.log(`[inbox] Replied to context_request from ${msg.sender}: ${msg.subject}`);
79
+ }
80
+
81
+ async function handleTaskProposal(msg: InboxMessage, env: EdgeEnv): Promise<void> {
82
+ // Create agenda item for operator approval
83
+ await addAgendaItem(
84
+ env.db,
85
+ `[${msg.sender.toUpperCase()}] Task proposal: ${msg.subject}`,
86
+ `From ${msg.sender} via agent inbox:\n\n${msg.body}`,
87
+ 'medium',
88
+ );
89
+
90
+ await postReply(env.db, 'aegis', msg.sender, msg.channel, 'message',
91
+ `Re: ${msg.subject}`,
92
+ `Task proposal received and added to operator agenda for review. Will update when the operator responds.`);
93
+
94
+ await ackMessage(env.db, msg.id);
95
+ console.log(`[inbox] Task proposal from ${msg.sender} -> agenda: ${msg.subject}`);
96
+ }
97
+
98
+ async function handleAlert(msg: InboxMessage, env: EdgeEnv): Promise<void> {
99
+ // Alerts go directly to high-priority agenda
100
+ await addAgendaItem(
101
+ env.db,
102
+ `[ALERT:${msg.sender.toUpperCase()}] ${msg.subject}`,
103
+ `Agent alert from ${msg.sender}:\n\n${msg.body}`,
104
+ 'high',
105
+ );
106
+
107
+ await ackMessage(env.db, msg.id);
108
+ console.log(`[inbox] Alert from ${msg.sender} escalated to agenda: ${msg.subject}`);
109
+ }
110
+
111
+ async function handleStatusUpdate(msg: InboxMessage, env: EdgeEnv): Promise<void> {
112
+ // Record significant status updates to memory
113
+ if (env.memoryBinding) {
114
+ await recordMemory(env.memoryBinding, 'aegis', `[${msg.sender}] ${msg.subject}: ${msg.body.slice(0, 500)}`, 0.7, 'agent_inbox');
115
+ }
116
+ await ackMessage(env.db, msg.id);
117
+ console.log(`[inbox] Status update from ${msg.sender} recorded: ${msg.subject}`);
118
+ }
119
+
120
+ async function handleMessage(msg: InboxMessage, env: EdgeEnv): Promise<void> {
121
+ // Generic messages -- ack and log
122
+ await ackMessage(env.db, msg.id);
123
+ console.log(`[inbox] Message from ${msg.sender} acked: ${msg.subject}`);
124
+ }
125
+
126
+ export async function runInboxProcessor(env: EdgeEnv): Promise<void> {
127
+ // Read unread messages for AEGIS
128
+ const result = await env.db.prepare(`
129
+ SELECT id, sender, recipient, channel, msg_type, subject, body, created_at
130
+ FROM agent_inbox
131
+ WHERE (recipient = 'aegis' OR recipient = 'all')
132
+ AND acked_at IS NULL
133
+ ORDER BY created_at ASC
134
+ LIMIT 20
135
+ `).all<InboxMessage>();
136
+
137
+ const messages = result.results ?? [];
138
+ if (messages.length === 0) return;
139
+
140
+ console.log(`[inbox] Processing ${messages.length} unread message(s)`);
141
+
142
+ for (const msg of messages) {
143
+ // Don't process our own broadcasts
144
+ if (msg.sender === 'aegis' && msg.recipient === 'all') {
145
+ await ackMessage(env.db, msg.id);
146
+ continue;
147
+ }
148
+
149
+ try {
150
+ switch (msg.msg_type) {
151
+ case 'context_request':
152
+ await handleContextRequest(msg, env);
153
+ break;
154
+ case 'task_proposal':
155
+ await handleTaskProposal(msg, env);
156
+ break;
157
+ case 'alert':
158
+ await handleAlert(msg, env);
159
+ break;
160
+ case 'status_update':
161
+ await handleStatusUpdate(msg, env);
162
+ break;
163
+ case 'message':
164
+ default:
165
+ await handleMessage(msg, env);
166
+ break;
167
+ }
168
+ } catch (err) {
169
+ console.error(`[inbox] Error processing message ${msg.id} from ${msg.sender}:`, err instanceof Error ? err.message : String(err));
170
+ // Ack anyway to prevent infinite reprocessing
171
+ await ackMessage(env.db, msg.id);
172
+ }
173
+ }
174
+ }