@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,80 @@
1
+ // Phase 1b: Task Proposal Processing — routes dreaming-extracted task
2
+ // proposals into cc_tasks with governance checks.
3
+
4
+ import { checkTaskGovernanceLimits } from '../governance.js';
5
+ import { AUTO_SAFE_CATEGORIES, PROPOSED_CATEGORIES } from '../../../schema-enums.js';
6
+
7
+ const VALID_CATEGORIES = new Set([...AUTO_SAFE_CATEGORIES, ...PROPOSED_CATEGORIES]);
8
+
9
+ // Valid repos the taskrunner can resolve — must match aliases in taskrunner.sh
10
+ // Customize this set for your org's repos
11
+ const VALID_TASK_REPOS = new Set([
12
+ 'aegis',
13
+ // Add your repo directory names here
14
+ ]);
15
+
16
+ const MIN_PROMPT_LENGTH = 80;
17
+
18
+ export async function processTaskProposals(
19
+ db: D1Database,
20
+ proposedTasks?: Array<{ title: string; repo: string; prompt: string; category: string; rationale: string }>,
21
+ ): Promise<number> {
22
+ if (!proposedTasks || proposedTasks.length === 0) return 0;
23
+
24
+ let tasksCreated = 0;
25
+ for (const task of proposedTasks.slice(0, 3)) {
26
+ if (!task.title || !task.repo || !task.prompt || !task.category) {
27
+ console.warn(`[dreaming:tasks] Skipping task with missing fields: ${task.title ?? '(no title)'}`);
28
+ continue;
29
+ }
30
+
31
+ const repoNormalized = task.repo.trim().toLowerCase();
32
+ if (!VALID_TASK_REPOS.has(repoNormalized)) {
33
+ console.warn(`[dreaming:tasks] Skipping task with unknown repo '${task.repo}': ${task.title}`);
34
+ continue;
35
+ }
36
+
37
+ if (task.prompt.trim().length < MIN_PROMPT_LENGTH) {
38
+ console.warn(`[dreaming:tasks] Skipping task with prompt too short (${task.prompt.length} chars): ${task.title}`);
39
+ continue;
40
+ }
41
+
42
+ const category = task.category.toLowerCase().trim();
43
+
44
+ if (category === 'deploy') {
45
+ console.log(`[dreaming:tasks] Skipping deploy task: ${task.title}`);
46
+ continue;
47
+ }
48
+
49
+ if (!VALID_CATEGORIES.has(category as any)) {
50
+ console.warn(`[dreaming:tasks] Skipping task with invalid category '${category}': ${task.title}`);
51
+ continue;
52
+ }
53
+
54
+ const authority = 'auto_safe';
55
+
56
+ const governance = await checkTaskGovernanceLimits(db, { repo: task.repo.trim(), title: task.title.trim(), category: category as string });
57
+ if (!governance.allowed) {
58
+ console.log(`[dreaming:tasks] Governance blocked '${task.title}': ${governance.reason}`);
59
+ continue;
60
+ }
61
+
62
+ const id = crypto.randomUUID();
63
+ try {
64
+ await db.prepare(`
65
+ INSERT INTO cc_tasks (id, title, repo, prompt, completion_signal, status, priority, max_turns, created_by, authority, category)
66
+ VALUES (?, ?, ?, ?, 'TASK_COMPLETE', 'pending', 50, 25, 'aegis', ?, ?)
67
+ `).bind(id, task.title.trim(), task.repo.trim(), task.prompt.trim(), authority, category).run();
68
+
69
+ tasksCreated++;
70
+ console.log(`[dreaming:tasks] Created ${authority} task: ${task.title} (${category}, ${task.repo}) → ${id}`);
71
+ } catch (err) {
72
+ console.warn(`[dreaming:tasks] Failed to insert task '${task.title}':`, err instanceof Error ? err.message : String(err));
73
+ }
74
+ }
75
+
76
+ if (tasksCreated > 0) {
77
+ console.log(`[dreaming:tasks] Proposed ${tasksCreated} task(s) from dreaming cycle`);
78
+ }
79
+ return tasksCreated;
80
+ }
@@ -0,0 +1,66 @@
1
+ // Dreaming Cycle — nightly reflection over conversation threads.
2
+ // Thin orchestrator that sequences 5 independent phase modules.
3
+ //
4
+ // Phase 1: Fact extraction (conversation analysis → memory)
5
+ // Phase 1b: Task proposals (extracted tasks → cc_tasks queue)
6
+ // Phase 2: Agenda triage (promote work items → GitHub issues)
7
+ // Phase 3: Persona extraction (behavioral patterns → memory)
8
+ // Phase 4: Pattern synthesis / PRISM (cross-topic connections → meta_insight)
9
+ // Phase 5: Symbolic reflection (TarotScript SingleDraw → memory)
10
+
11
+ import type { EdgeEnv } from '../dispatch.js';
12
+ import { fetchConversationThreads, extractFacts, processFacts } from './dreaming/facts.js';
13
+ import { processTaskProposals } from './dreaming/task-proposals.js';
14
+ import { triageAgendaToIssues } from './dreaming/agenda-triage.js';
15
+ import { extractPersonaDimensions } from './dreaming/persona.js';
16
+ import { runPatternSynthesis } from './dreaming/pattern-synthesis.js';
17
+ import { runSymbolicReflection } from './dreaming/symbolic.js';
18
+ import { createDynamicTool, listDynamicTools, invalidateToolCache } from '../dynamic-tools.js';
19
+
20
+ export async function runDreamingCycle(env: EdgeEnv): Promise<void> {
21
+ // Guard: only run once per day
22
+ const lastRun = await env.db.prepare(
23
+ "SELECT received_at FROM web_events WHERE event_id = 'last_dreaming_at'"
24
+ ).first<{ received_at: string }>();
25
+
26
+ if (lastRun) {
27
+ const hoursSince = (Date.now() - new Date(lastRun.received_at + 'Z').getTime()) / (1000 * 60 * 60);
28
+ if (hoursSince < 20) return;
29
+ }
30
+
31
+ // Load conversation threads + CC sessions from past 24h
32
+ const threadContents = await fetchConversationThreads(env);
33
+
34
+ if (threadContents.length === 0) {
35
+ await advanceWatermark(env.db);
36
+ return;
37
+ }
38
+
39
+ // Phase 1: Fact extraction (blocking — phases 1b, 2 depend on result)
40
+ const result = await extractFacts(env, threadContents);
41
+ if (!result) {
42
+ await advanceWatermark(env.db);
43
+ return;
44
+ }
45
+
46
+ await processFacts(env, result);
47
+
48
+ // Phase 1b + 2: Sequential (depend on extraction result)
49
+ await processTaskProposals(env.db, result.proposed_tasks);
50
+ await triageAgendaToIssues(env);
51
+
52
+ // Phase 3 + 4 + 5: Independent — run in parallel
53
+ await Promise.all([
54
+ extractPersonaDimensions(env, threadContents),
55
+ runPatternSynthesis(env),
56
+ runSymbolicReflection(env),
57
+ ]);
58
+
59
+ await advanceWatermark(env.db);
60
+ }
61
+
62
+ async function advanceWatermark(db: D1Database): Promise<void> {
63
+ await db.prepare(
64
+ "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('last_dreaming_at', datetime('now'))"
65
+ ).run();
66
+ }
@@ -0,0 +1,149 @@
1
+ // Entropy Detection — ghost tasks, stale agenda, dormant goals
2
+ // Inspired by command_desk. Runs in heartbeat phase (hourly).
3
+ // Writes findings to digest_sections for the morning co-founder brief.
4
+
5
+ import { type EdgeEnv } from '../dispatch.js';
6
+
7
+ const AGENDA_STALE_DAYS = 7; // Active agenda items untouched >7d
8
+ const TASK_STALE_DAYS = 7; // Pending cc_tasks untouched >7d
9
+ const GOAL_DORMANT_DAYS = 14; // Active goals with no runs in >14d
10
+
11
+ export interface EntropyReport {
12
+ score: number; // 0-100 — percentage of active items that are stale
13
+ ghostAgendaItems: Array<{ id: number; item: string; daysSinceCreated: number }>;
14
+ ghostTasks: Array<{ id: string; title: string; repo: string; daysSinceCreated: number }>;
15
+ dormantGoals: Array<{ id: string; title: string; daysSinceLastRun: number }>;
16
+ summary: string;
17
+ }
18
+
19
+ export async function runEntropyDetection(env: EdgeEnv): Promise<void> {
20
+ // Time gate: run every 6 hours (00, 06, 12, 18 UTC)
21
+ const hour = new Date().getUTCHours();
22
+ if (hour % 6 !== 0) return;
23
+
24
+ const report = await detectEntropy(env);
25
+
26
+ // Only write to digest if there's something worth reporting
27
+ if (report.ghostAgendaItems.length === 0 && report.ghostTasks.length === 0 && report.dormantGoals.length === 0) {
28
+ console.log(`[entropy] Clean — score ${report.score}, no stale items`);
29
+ return;
30
+ }
31
+
32
+ // Queue as service alert so the digest picks it up without template changes.
33
+ // severity scales with score: >50% = high, >25% = medium, else low
34
+ const severity = report.score > 50 ? 'high' : report.score > 25 ? 'medium' : 'low';
35
+
36
+ const details: string[] = [];
37
+ for (const a of report.ghostAgendaItems.slice(0, 5)) {
38
+ details.push(`agenda #${a.id}: "${a.item}" (${a.daysSinceCreated}d)`);
39
+ }
40
+ for (const t of report.ghostTasks.slice(0, 5)) {
41
+ details.push(`task ${t.id.slice(0, 8)}: "${t.title}" in ${t.repo} (${t.daysSinceCreated}d)`);
42
+ }
43
+ for (const g of report.dormantGoals.slice(0, 5)) {
44
+ details.push(`goal "${g.title}" (${g.daysSinceLastRun}d since last run)`);
45
+ }
46
+
47
+ await env.db.prepare(`
48
+ INSERT INTO digest_sections (id, section, payload, consumed, created_at)
49
+ VALUES (?, 'entropy', ?, 0, datetime('now'))
50
+ `).bind(
51
+ `entropy-${Date.now()}`,
52
+ JSON.stringify({
53
+ source: 'entropy',
54
+ severity,
55
+ summary: report.summary,
56
+ detail: details.join(' · '),
57
+ findings: details,
58
+ score: report.score,
59
+ }),
60
+ ).run();
61
+
62
+ console.log(`[entropy] Score ${report.score} — ${report.ghostAgendaItems.length} ghost agenda, ${report.ghostTasks.length} ghost tasks, ${report.dormantGoals.length} dormant goals`);
63
+ }
64
+
65
+ export async function detectEntropy(env: EdgeEnv): Promise<EntropyReport> {
66
+ const now = Date.now();
67
+
68
+ // 1. Ghost agenda items — active but created >7d ago with no resolution
69
+ const agendaRows = await env.db.prepare(`
70
+ SELECT id, item, created_at
71
+ FROM agent_agenda
72
+ WHERE status = 'active'
73
+ AND created_at < datetime('now', '-${AGENDA_STALE_DAYS} days')
74
+ ORDER BY created_at ASC
75
+ `).all<{ id: number; item: string; created_at: string }>();
76
+
77
+ const ghostAgendaItems = agendaRows.results.map(r => ({
78
+ id: r.id,
79
+ item: r.item.slice(0, 100),
80
+ daysSinceCreated: Math.floor((now - new Date(r.created_at + 'Z').getTime()) / (1000 * 60 * 60 * 24)),
81
+ }));
82
+
83
+ // 2. Ghost cc_tasks — pending but created >7d ago, never started
84
+ const taskRows = await env.db.prepare(`
85
+ SELECT id, title, repo, created_at
86
+ FROM cc_tasks
87
+ WHERE status = 'pending'
88
+ AND created_at < datetime('now', '-${TASK_STALE_DAYS} days')
89
+ ORDER BY created_at ASC
90
+ `).all<{ id: string; title: string; repo: string; created_at: string }>();
91
+
92
+ const ghostTasks = taskRows.results.map(r => ({
93
+ id: r.id,
94
+ title: r.title.slice(0, 100),
95
+ repo: r.repo,
96
+ daysSinceCreated: Math.floor((now - new Date(r.created_at + 'Z').getTime()) / (1000 * 60 * 60 * 24)),
97
+ }));
98
+
99
+ // 3. Dormant goals — active but no run in >14d
100
+ const goalRows = await env.db.prepare(`
101
+ SELECT id, title, last_run_at
102
+ FROM agent_goals
103
+ WHERE status = 'active'
104
+ AND (last_run_at IS NULL OR last_run_at < datetime('now', '-${GOAL_DORMANT_DAYS} days'))
105
+ `).all<{ id: string; title: string; last_run_at: string | null }>();
106
+
107
+ const dormantGoals = goalRows.results.map(r => ({
108
+ id: r.id,
109
+ title: r.title.slice(0, 100),
110
+ daysSinceLastRun: r.last_run_at
111
+ ? Math.floor((now - new Date(r.last_run_at + 'Z').getTime()) / (1000 * 60 * 60 * 24))
112
+ : 999,
113
+ }));
114
+
115
+ // 4. Entropy score — percentage of active items that are stale
116
+ const totalActiveAgenda = await env.db.prepare(
117
+ "SELECT COUNT(*) as cnt FROM agent_agenda WHERE status = 'active'"
118
+ ).first<{ cnt: number }>();
119
+
120
+ const totalPendingTasks = await env.db.prepare(
121
+ "SELECT COUNT(*) as cnt FROM cc_tasks WHERE status = 'pending'"
122
+ ).first<{ cnt: number }>();
123
+
124
+ const totalActiveGoals = await env.db.prepare(
125
+ "SELECT COUNT(*) as cnt FROM agent_goals WHERE status = 'active'"
126
+ ).first<{ cnt: number }>();
127
+
128
+ const totalActive = (totalActiveAgenda?.cnt ?? 0) + (totalPendingTasks?.cnt ?? 0) + (totalActiveGoals?.cnt ?? 0);
129
+ const totalStale = ghostAgendaItems.length + ghostTasks.length + dormantGoals.length;
130
+ const score = totalActive > 0 ? Math.round((totalStale / totalActive) * 100) : 0;
131
+
132
+ // 5. Build human summary
133
+ const parts: string[] = [];
134
+ if (ghostAgendaItems.length > 0) {
135
+ parts.push(`${ghostAgendaItems.length} agenda item${ghostAgendaItems.length > 1 ? 's' : ''} stale >${AGENDA_STALE_DAYS}d`);
136
+ }
137
+ if (ghostTasks.length > 0) {
138
+ parts.push(`${ghostTasks.length} queued task${ghostTasks.length > 1 ? 's' : ''} untouched >${TASK_STALE_DAYS}d`);
139
+ }
140
+ if (dormantGoals.length > 0) {
141
+ parts.push(`${dormantGoals.length} goal${dormantGoals.length > 1 ? 's' : ''} dormant >${GOAL_DORMANT_DAYS}d`);
142
+ }
143
+
144
+ const summary = parts.length > 0
145
+ ? `Entropy ${score}% — ${parts.join(', ')}.`
146
+ : `Entropy ${score}% — all clear.`;
147
+
148
+ return { score, ghostAgendaItems, ghostTasks, dormantGoals, summary };
149
+ }
@@ -0,0 +1,192 @@
1
+ import { type EdgeEnv } from '../dispatch.js';
2
+ import { getActiveAgendaItems, PROPOSED_ACTION_PREFIX } from '../memory/index.js';
3
+ import { listIssues } from '../../github.js';
4
+
5
+ // ─── Stale Proposed Action Expiry ─────────────────────────────
6
+ // Proposed actions from self-improvement, dreaming, and goal loops
7
+ // lose context relevance quickly. Auto-dismiss after 7 days if
8
+ // the operator hasn't approved or rejected them.
9
+ const PROPOSAL_EXPIRY_DAYS = 7;
10
+
11
+ // ─── Stale GitHub Issue Ref Cleanup (#81) ─────────────────────
12
+
13
+ export const ISSUE_REF_PATTERN = /#(\d+)/g;
14
+
15
+ const MAX_REPOS = 5; // Cap repo-specific API calls to stay within subrequest budget
16
+
17
+ export async function resolveStaleIssueRefs(
18
+ db: D1Database,
19
+ items: Array<{ id: number; item: string; context: string | null }>,
20
+ githubToken: string,
21
+ githubRepo: string,
22
+ ): Promise<void> {
23
+ // Extract unique issue numbers referenced in agenda items
24
+ const issueRefs = new Map<number, number[]>(); // issueNumber → agendaItemIds
25
+ for (const item of items) {
26
+ const matches = item.item.matchAll(ISSUE_REF_PATTERN);
27
+ for (const match of matches) {
28
+ const issueNum = parseInt(match[1], 10);
29
+ if (issueNum > 0 && issueNum < 10000) {
30
+ const ids = issueRefs.get(issueNum) ?? [];
31
+ ids.push(item.id);
32
+ issueRefs.set(issueNum, ids);
33
+ }
34
+ }
35
+ }
36
+
37
+ if (issueRefs.size === 0) return;
38
+
39
+ // Build repo → issueNumbers mapping from cc_tasks linkages
40
+ // issueNumber → repo (specific repo from cc_tasks, or default)
41
+ const issueToRepo = new Map<number, string>();
42
+ try {
43
+ const issueNumbers = [...issueRefs.keys()];
44
+ // D1 doesn't support WHERE IN with bind params for dynamic lists, batch in chunks
45
+ const placeholders = issueNumbers.map(() => '?').join(',');
46
+ const linked = await db.prepare(
47
+ `SELECT DISTINCT github_issue_number, github_issue_repo FROM cc_tasks
48
+ WHERE github_issue_repo IS NOT NULL
49
+ AND github_issue_number IN (${placeholders})`
50
+ ).bind(...issueNumbers).all<{ github_issue_number: number; github_issue_repo: string }>();
51
+
52
+ for (const row of linked.results ?? []) {
53
+ issueToRepo.set(row.github_issue_number, row.github_issue_repo);
54
+ }
55
+ } catch (err) {
56
+ // Non-fatal — fall back to default repo for all
57
+ console.warn(`[escalation] cc_tasks linkage query failed:`, err instanceof Error ? err.message : String(err));
58
+ }
59
+
60
+ // Group issues by repo (linked repo or default)
61
+ const repoToIssues = new Map<string, number[]>();
62
+ for (const issueNum of issueRefs.keys()) {
63
+ const repo = issueToRepo.get(issueNum) ?? githubRepo;
64
+ const nums = repoToIssues.get(repo) ?? [];
65
+ nums.push(issueNum);
66
+ repoToIssues.set(repo, nums);
67
+ }
68
+
69
+ // Fetch open issues per distinct repo (capped to MAX_REPOS)
70
+ try {
71
+ const repos = [...repoToIssues.keys()].slice(0, MAX_REPOS);
72
+ const openByRepo = new Map<string, Set<number>>();
73
+
74
+ // Fetch all repos in parallel
75
+ const results = await Promise.allSettled(
76
+ repos.map(async (repo) => {
77
+ const openIssues = await listIssues(githubToken, repo, 'open');
78
+ return { repo, numbers: new Set(openIssues.map(i => i.number)) };
79
+ })
80
+ );
81
+
82
+ for (const result of results) {
83
+ if (result.status === 'fulfilled') {
84
+ openByRepo.set(result.value.repo, result.value.numbers);
85
+ } else {
86
+ console.warn(`[escalation] Failed to fetch issues for a repo:`, result.reason instanceof Error ? result.reason.message : String(result.reason));
87
+ }
88
+ }
89
+
90
+ let resolved = 0;
91
+ for (const [issueNum, agendaIds] of issueRefs) {
92
+ const repo = issueToRepo.get(issueNum) ?? githubRepo;
93
+ const openNumbers = openByRepo.get(repo);
94
+ // Skip if we couldn't fetch this repo's issues
95
+ if (!openNumbers) continue;
96
+
97
+ if (!openNumbers.has(issueNum)) {
98
+ // Issue is closed — auto-resolve the agenda items
99
+ for (const agendaId of agendaIds) {
100
+ await db.prepare(
101
+ "UPDATE agent_agenda SET status = 'done', resolved_at = datetime('now'), context = COALESCE(context, '') || ? WHERE id = ? AND status = 'active'"
102
+ ).bind(` [auto-resolved: GitHub ${repo}#${issueNum} is closed]`, agendaId).run();
103
+ resolved++;
104
+ }
105
+ }
106
+ }
107
+
108
+ if (resolved > 0) {
109
+ console.log(`[escalation] Auto-resolved ${resolved} agenda item(s) referencing closed GitHub issues across ${openByRepo.size} repo(s)`);
110
+ }
111
+ } catch (err) {
112
+ // Non-fatal — don't block escalation if GitHub is unreachable
113
+ console.warn(`[escalation] Stale issue ref check failed:`, err instanceof Error ? err.message : String(err));
114
+ }
115
+ }
116
+
117
+ // ─── Stale Agenda Escalation (#61) ────────────────────────────
118
+
119
+ export const ESCALATION_THRESHOLDS: Record<string, { days: number; newPriority: string }> = {
120
+ low: { days: 7, newPriority: 'medium' },
121
+ medium: { days: 3, newPriority: 'high' },
122
+ };
123
+ export const STALE_HIGH_ALERT_DAYS = 1;
124
+
125
+ export type StaleHighItem = { id: number; item: string; context: string | null; ageDays: number };
126
+
127
+ /**
128
+ * Run agenda escalation: bump stale priorities and return stale high-priority items.
129
+ * Does NOT send its own email — stale items are returned to the caller (heartbeat)
130
+ * which folds them into a single consolidated health report.
131
+ */
132
+ export async function runAgendaEscalation(env: EdgeEnv): Promise<StaleHighItem[]> {
133
+ const items = await getActiveAgendaItems(env.db);
134
+ if (items.length === 0) return [];
135
+
136
+ // Auto-resolve agenda items that reference closed GitHub issues (#81)
137
+ if (env.githubToken && env.githubRepo) {
138
+ await resolveStaleIssueRefs(env.db, items, env.githubToken, env.githubRepo);
139
+ }
140
+
141
+ // Auto-dismiss stale proposed actions (>7 days without operator review)
142
+ const now = Date.now();
143
+ let expired = 0;
144
+ for (const item of items) {
145
+ if (!item.item.startsWith(PROPOSED_ACTION_PREFIX)) continue;
146
+ const ts = item.created_at.endsWith('Z') ? item.created_at : item.created_at + 'Z';
147
+ const ageDays = (now - new Date(ts).getTime()) / 86_400_000;
148
+ if (ageDays > PROPOSAL_EXPIRY_DAYS) {
149
+ await env.db.prepare(
150
+ "UPDATE agent_agenda SET status = 'dismissed', resolved_at = datetime('now'), context = COALESCE(context, '') || ? WHERE id = ? AND status = 'active'"
151
+ ).bind(` [auto-expired: ${Math.floor(ageDays)}d without review]`, item.id).run();
152
+ expired++;
153
+ }
154
+ }
155
+ if (expired > 0) {
156
+ console.log(`[escalation] Auto-expired ${expired} stale proposed action(s) (>${PROPOSAL_EXPIRY_DAYS}d)`);
157
+ }
158
+
159
+ // Re-fetch after expiry so escalation loop doesn't process expired items
160
+ const activeItems = items.filter(i => {
161
+ if (!i.item.startsWith(PROPOSED_ACTION_PREFIX)) return true;
162
+ const ts = i.created_at.endsWith('Z') ? i.created_at : i.created_at + 'Z';
163
+ return (now - new Date(ts).getTime()) / 86_400_000 <= PROPOSAL_EXPIRY_DAYS;
164
+ });
165
+
166
+ let escalated = 0;
167
+ const staleHighItems: StaleHighItem[] = [];
168
+
169
+ for (const item of activeItems) {
170
+ const ts = item.created_at.endsWith('Z') ? item.created_at : item.created_at + 'Z';
171
+ const ageDays = (now - new Date(ts).getTime()) / 86_400_000;
172
+ const threshold = ESCALATION_THRESHOLDS[item.priority];
173
+
174
+ if (threshold && ageDays > threshold.days) {
175
+ const date = new Date().toISOString().slice(0, 10);
176
+ const annotation = ` [escalated: ${item.priority}\u2192${threshold.newPriority} ${date}]`;
177
+ await env.db.prepare(
178
+ "UPDATE agent_agenda SET priority = ?, context = COALESCE(context, '') || ? WHERE id = ?"
179
+ ).bind(threshold.newPriority, annotation, item.id).run();
180
+ console.log(`[escalation] Agenda #${item.id} escalated: ${item.priority} \u2192 ${threshold.newPriority} (${ageDays.toFixed(1)}d stale)`);
181
+ escalated++;
182
+ } else if (item.priority === 'high' && ageDays > STALE_HIGH_ALERT_DAYS) {
183
+ staleHighItems.push({ id: item.id, item: item.item, context: item.context, ageDays });
184
+ }
185
+ }
186
+
187
+ if (escalated > 0) {
188
+ console.log(`[escalation] ${escalated} agenda item(s) escalated this cycle`);
189
+ }
190
+
191
+ return staleHighItems;
192
+ }
@@ -0,0 +1,206 @@
1
+ import { type EdgeEnv } from '../dispatch.js';
2
+ import { recordMemory } from '../memory-adapter.js';
3
+
4
+ // --- Feed Watcher ---
5
+ // Polls RSS/Atom feeds every 6 hours, stores new entries in D1,
6
+ // and records summaries to semantic memory for the dreaming cycle.
7
+ //
8
+ // Feeds are stored in `watched_feeds` table. New entries land in
9
+ // `feed_entries` and get recorded to memory under topic 'feed_intel'.
10
+
11
+ const FEED_POLL_HOURS = 6;
12
+ const MAX_NEW_ENTRIES_PER_FEED = 5;
13
+ const MAX_MEMORY_RECORDS_PER_RUN = 10;
14
+
15
+ interface FeedRow {
16
+ id: string;
17
+ url: string;
18
+ title: string | null;
19
+ category: string;
20
+ last_entry_id: string | null;
21
+ }
22
+
23
+ interface ParsedEntry {
24
+ id: string;
25
+ title: string;
26
+ link: string;
27
+ summary: string;
28
+ published: string | null;
29
+ }
30
+
31
+ // --- Lightweight RSS/Atom parser (no dependencies) ---
32
+
33
+ function extractTag(xml: string, tag: string): string {
34
+ // Match <tag>...</tag> or <tag ...>...</tag>
35
+ const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
36
+ const m = xml.match(re);
37
+ return m ? m[1].trim().replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1') : '';
38
+ }
39
+
40
+ function extractAttr(xml: string, tag: string, attr: string): string {
41
+ const re = new RegExp(`<${tag}[^>]*\\s${attr}=["']([^"']+)["']`, 'i');
42
+ const m = xml.match(re);
43
+ return m ? m[1] : '';
44
+ }
45
+
46
+ function parseEntries(xml: string): ParsedEntry[] {
47
+ const entries: ParsedEntry[] = [];
48
+
49
+ // Try Atom <entry> first, then RSS <item>
50
+ const isAtom = xml.includes('<feed') || xml.includes('<entry');
51
+ const tagName = isAtom ? 'entry' : 'item';
52
+ const re = new RegExp(`<${tagName}[^>]*>[\\s\\S]*?<\\/${tagName}>`, 'gi');
53
+ const items = xml.match(re) || [];
54
+
55
+ for (const item of items.slice(0, MAX_NEW_ENTRIES_PER_FEED * 2)) {
56
+ const title = extractTag(item, 'title');
57
+ if (!title) continue;
58
+
59
+ let link = '';
60
+ if (isAtom) {
61
+ // Atom: <link href="..." rel="alternate" />
62
+ link = extractAttr(item, 'link', 'href') || extractTag(item, 'link');
63
+ } else {
64
+ link = extractTag(item, 'link');
65
+ }
66
+
67
+ const id = isAtom
68
+ ? extractTag(item, 'id') || link
69
+ : extractTag(item, 'guid') || link;
70
+
71
+ const summary = extractTag(item, 'summary')
72
+ || extractTag(item, 'description')
73
+ || extractTag(item, 'content');
74
+
75
+ const published = extractTag(item, 'published')
76
+ || extractTag(item, 'pubDate')
77
+ || extractTag(item, 'updated')
78
+ || null;
79
+
80
+ if (id) {
81
+ entries.push({
82
+ id,
83
+ title,
84
+ link,
85
+ summary: summary.replace(/<[^>]+>/g, '').slice(0, 500), // strip HTML, cap length
86
+ published,
87
+ });
88
+ }
89
+ }
90
+
91
+ return entries;
92
+ }
93
+
94
+ // --- Main ---
95
+
96
+ export async function runFeedWatcher(env: EdgeEnv): Promise<void> {
97
+ // Time gate: every 6 hours (0, 6, 12, 18 UTC)
98
+ const hour = new Date().getUTCHours();
99
+ if (hour % FEED_POLL_HOURS !== 0) return;
100
+
101
+ // Cooldown: 5 hours since last run
102
+ const lastRun = await env.db.prepare(
103
+ "SELECT received_at FROM web_events WHERE event_id = 'feed_watcher'"
104
+ ).first<{ received_at: string }>();
105
+
106
+ if (lastRun) {
107
+ const elapsed = Date.now() - new Date(lastRun.received_at + 'Z').getTime();
108
+ if (elapsed < 5 * 60 * 60 * 1000) return;
109
+ }
110
+
111
+ // Fetch enabled feeds
112
+ const feeds = await env.db.prepare(
113
+ 'SELECT id, url, title, category, last_entry_id FROM watched_feeds WHERE enabled = 1'
114
+ ).all<FeedRow>();
115
+
116
+ if (!feeds.results?.length) {
117
+ console.log('[feed-watcher] No feeds configured -- skipping');
118
+ return;
119
+ }
120
+
121
+ let totalNew = 0;
122
+ let memoryRecords = 0;
123
+
124
+ for (const feed of feeds.results) {
125
+ try {
126
+ const resp = await fetch(feed.url, {
127
+ headers: { 'User-Agent': 'AEGIS-FeedWatcher/1.0' },
128
+ signal: AbortSignal.timeout(10_000),
129
+ });
130
+
131
+ if (!resp.ok) {
132
+ console.warn(`[feed-watcher] ${feed.url} returned ${resp.status}`);
133
+ continue;
134
+ }
135
+
136
+ const xml = await resp.text();
137
+ const entries = parseEntries(xml);
138
+
139
+ if (!entries.length) continue;
140
+
141
+ // Find new entries (not yet in DB)
142
+ let newCount = 0;
143
+ for (const entry of entries) {
144
+ if (newCount >= MAX_NEW_ENTRIES_PER_FEED) break;
145
+
146
+ // Skip if already ingested
147
+ const existing = await env.db.prepare(
148
+ 'SELECT 1 FROM feed_entries WHERE feed_id = ? AND entry_id = ?'
149
+ ).bind(feed.id, entry.id).first();
150
+
151
+ if (existing) continue;
152
+
153
+ // Insert new entry
154
+ await env.db.prepare(
155
+ `INSERT INTO feed_entries (feed_id, entry_id, title, link, summary, published_at)
156
+ VALUES (?, ?, ?, ?, ?, ?)`
157
+ ).bind(feed.id, entry.id, entry.title, entry.link, entry.summary, entry.published).run();
158
+
159
+ newCount++;
160
+ totalNew++;
161
+
162
+ // Record to memory if binding available
163
+ if (env.memoryBinding && memoryRecords < MAX_MEMORY_RECORDS_PER_RUN) {
164
+ try {
165
+ const fact = `[${feed.category}] ${feed.title || 'Feed'}: "${entry.title}"${entry.summary ? ` -- ${entry.summary.slice(0, 200)}` : ''}${entry.link ? ` (${entry.link})` : ''}`;
166
+ await recordMemory(
167
+ env.memoryBinding,
168
+ 'feed_intel',
169
+ fact,
170
+ 0.75,
171
+ 'feed_watcher',
172
+ );
173
+ await env.db.prepare(
174
+ 'UPDATE feed_entries SET recorded_to_memory = 1 WHERE feed_id = ? AND entry_id = ?'
175
+ ).bind(feed.id, entry.id).run();
176
+ memoryRecords++;
177
+ } catch (err) {
178
+ console.warn('[feed-watcher] Memory record failed:', err instanceof Error ? err.message : String(err));
179
+ }
180
+ }
181
+ }
182
+
183
+ // Update watermark
184
+ if (entries.length > 0) {
185
+ await env.db.prepare(
186
+ "UPDATE watched_feeds SET last_fetched_at = datetime('now'), last_entry_id = ? WHERE id = ?"
187
+ ).bind(entries[0].id, feed.id).run();
188
+ }
189
+
190
+ if (newCount > 0) {
191
+ console.log(`[feed-watcher] ${feed.title || feed.url}: ${newCount} new entries`);
192
+ }
193
+ } catch (err) {
194
+ console.warn(`[feed-watcher] Error fetching ${feed.url}:`, err instanceof Error ? err.message : String(err));
195
+ }
196
+ }
197
+
198
+ // Update watermark
199
+ await env.db.prepare(
200
+ "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('feed_watcher', datetime('now'))"
201
+ ).run();
202
+
203
+ if (totalNew > 0) {
204
+ console.log(`[feed-watcher] Done: ${totalNew} new entries, ${memoryRecords} recorded to memory`);
205
+ }
206
+ }