@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,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
|
+
}
|