@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,252 @@
|
|
|
1
|
+
// --- Agent Dispatch -- Proactive Inter-Agent Coordination ---
|
|
2
|
+
// Runs hourly in the heartbeat phase. Scans recent events, task
|
|
3
|
+
// completions, and repo activity, then inboxes the right agent
|
|
4
|
+
// without human intervention.
|
|
5
|
+
//
|
|
6
|
+
// AEGIS is the orchestrator. Agents are specialists:
|
|
7
|
+
// CodeBeast -- adversarial code review
|
|
8
|
+
// MARA -- colony governance, multi-agent coordination
|
|
9
|
+
// Sera -- symbolic classification
|
|
10
|
+
//
|
|
11
|
+
// This module replaces the operator as the manual message bus.
|
|
12
|
+
|
|
13
|
+
import { type EdgeEnv } from '../dispatch.js';
|
|
14
|
+
|
|
15
|
+
// --- Agent Capability Registry ---
|
|
16
|
+
|
|
17
|
+
interface AgentCapability {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
capabilities: string[];
|
|
21
|
+
inboxRecipient: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const AGENTS: AgentCapability[] = [
|
|
25
|
+
{
|
|
26
|
+
id: 'codebeast',
|
|
27
|
+
name: 'CodeBeast',
|
|
28
|
+
capabilities: ['code_review', 'security_audit', 'adversarial_review', 'fix_suggestion'],
|
|
29
|
+
inboxRecipient: 'codebeast',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'mara',
|
|
33
|
+
name: 'MARA',
|
|
34
|
+
capabilities: ['governance', 'colony_coordination', 'agent_lifecycle', 'resource_allocation'],
|
|
35
|
+
inboxRecipient: 'mara',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'sera',
|
|
39
|
+
name: 'Sera',
|
|
40
|
+
capabilities: ['classification', 'symbolic_computation', 'tarotscript', 'intent_analysis'],
|
|
41
|
+
inboxRecipient: 'sera',
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// --- Inbox Helper ---
|
|
46
|
+
|
|
47
|
+
async function sendInboxMessage(
|
|
48
|
+
db: D1Database,
|
|
49
|
+
recipient: string,
|
|
50
|
+
msgType: string,
|
|
51
|
+
subject: string,
|
|
52
|
+
body: string,
|
|
53
|
+
channel = 'ops',
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const id = crypto.randomUUID();
|
|
56
|
+
await db.prepare(`
|
|
57
|
+
INSERT INTO agent_inbox (id, sender, recipient, channel, msg_type, subject, body)
|
|
58
|
+
VALUES (?, 'aegis', ?, ?, ?, ?, ?)
|
|
59
|
+
`).bind(id, recipient, channel, msgType, subject, body).run();
|
|
60
|
+
console.log(`[agent-dispatch] Sent ${msgType} to ${recipient}: ${subject}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Watermark Helper ---
|
|
64
|
+
|
|
65
|
+
async function getWatermark(db: D1Database, key: string): Promise<string | null> {
|
|
66
|
+
const row = await db.prepare(
|
|
67
|
+
"SELECT received_at FROM web_events WHERE event_id = ? LIMIT 1"
|
|
68
|
+
).bind(`dispatch_watermark:${key}`).first<{ received_at: string }>();
|
|
69
|
+
return row?.received_at ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function setWatermark(db: D1Database, key: string): Promise<void> {
|
|
73
|
+
await db.prepare(
|
|
74
|
+
"INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES (?, datetime('now'))"
|
|
75
|
+
).bind(`dispatch_watermark:${key}`).run();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Trigger: Merged PRs -> CodeBeast Review ---
|
|
79
|
+
// When PRs merge on watched repos, auto-request CodeBeast review.
|
|
80
|
+
|
|
81
|
+
async function dispatchMergedPrReviews(db: D1Database): Promise<number> {
|
|
82
|
+
const watermark = await getWatermark(db, 'pr_reviews');
|
|
83
|
+
const since = watermark || "datetime('now', '-2 hours')";
|
|
84
|
+
|
|
85
|
+
// Find recently merged PRs from ARGUS events
|
|
86
|
+
const events = await db.prepare(`
|
|
87
|
+
SELECT payload, ts FROM events
|
|
88
|
+
WHERE source = 'github'
|
|
89
|
+
AND event_type = 'pull_request.merged'
|
|
90
|
+
AND ts > ?
|
|
91
|
+
ORDER BY ts ASC
|
|
92
|
+
LIMIT 10
|
|
93
|
+
`).bind(since).all<{ payload: string; ts: string }>();
|
|
94
|
+
|
|
95
|
+
let dispatched = 0;
|
|
96
|
+
for (const event of events.results ?? []) {
|
|
97
|
+
try {
|
|
98
|
+
const payload = JSON.parse(event.payload);
|
|
99
|
+
const pr = payload.pull_request ?? payload;
|
|
100
|
+
const repo = (payload.repository?.full_name as string) ?? '';
|
|
101
|
+
const prNumber = pr.number ?? 0;
|
|
102
|
+
const title = pr.title ?? '';
|
|
103
|
+
const additions = pr.additions ?? 0;
|
|
104
|
+
const deletions = pr.deletions ?? 0;
|
|
105
|
+
const totalLoc = additions + deletions;
|
|
106
|
+
|
|
107
|
+
// Skip tiny PRs (< 10 LOC) and auto-branch PRs from taskrunner
|
|
108
|
+
if (totalLoc < 10) continue;
|
|
109
|
+
if (title.startsWith('[auto]')) continue;
|
|
110
|
+
|
|
111
|
+
// Dedup: check if we already requested review for this PR
|
|
112
|
+
const existingMsg = await db.prepare(
|
|
113
|
+
"SELECT 1 FROM agent_inbox WHERE sender = 'aegis' AND recipient = 'codebeast' AND subject LIKE ? LIMIT 1"
|
|
114
|
+
).bind(`%${repo}#${prNumber}%`).first();
|
|
115
|
+
if (existingMsg) continue;
|
|
116
|
+
|
|
117
|
+
await sendInboxMessage(
|
|
118
|
+
db, 'codebeast', 'context_request',
|
|
119
|
+
`Review: ${repo}#${prNumber} -- ${title}`,
|
|
120
|
+
`PR merged: ${title}\nRepo: ${repo}\nPR: #${prNumber}\nLOC: +${additions}/-${deletions}\n\nPlease run adversarial review on this merge. Flag security issues, correctness problems, and test gaps. Post findings back via inbox as alert (critical) or task_proposal (suggested fix).`,
|
|
121
|
+
'code-review',
|
|
122
|
+
);
|
|
123
|
+
dispatched++;
|
|
124
|
+
} catch {
|
|
125
|
+
// Skip malformed events
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (dispatched > 0 || (events.results?.length ?? 0) > 0) {
|
|
130
|
+
await setWatermark(db, 'pr_reviews');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return dispatched;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- Trigger: Completed Tasks -> Status Updates ---
|
|
137
|
+
// When cc_tasks complete, notify relevant agents if the work
|
|
138
|
+
// touches their domain.
|
|
139
|
+
|
|
140
|
+
async function dispatchTaskCompletions(db: D1Database): Promise<number> {
|
|
141
|
+
const watermark = await getWatermark(db, 'task_completions');
|
|
142
|
+
const since = watermark || "datetime('now', '-2 hours')";
|
|
143
|
+
|
|
144
|
+
const tasks = await db.prepare(`
|
|
145
|
+
SELECT id, title, repo, category, result, exit_code
|
|
146
|
+
FROM cc_tasks
|
|
147
|
+
WHERE status = 'completed'
|
|
148
|
+
AND completed_at > ?
|
|
149
|
+
AND exit_code = 0
|
|
150
|
+
ORDER BY completed_at ASC
|
|
151
|
+
LIMIT 10
|
|
152
|
+
`).bind(since).all<{
|
|
153
|
+
id: string; title: string; repo: string; category: string;
|
|
154
|
+
result: string | null; exit_code: number;
|
|
155
|
+
}>();
|
|
156
|
+
|
|
157
|
+
let dispatched = 0;
|
|
158
|
+
for (const task of tasks.results ?? []) {
|
|
159
|
+
// Route to agent based on repo/category
|
|
160
|
+
const repo = task.repo.toLowerCase();
|
|
161
|
+
|
|
162
|
+
// TarotScript work -> notify Sera
|
|
163
|
+
if (repo.includes('tarotscript') || repo.includes('tarot')) {
|
|
164
|
+
await sendInboxMessage(
|
|
165
|
+
db, 'sera', 'status_update',
|
|
166
|
+
`Task completed: ${task.title.slice(0, 100)}`,
|
|
167
|
+
`Repo: ${task.repo}\nCategory: ${task.category}\nResult: ${(task.result ?? '').slice(0, 500)}`,
|
|
168
|
+
);
|
|
169
|
+
dispatched++;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Colony OS work -> notify MARA
|
|
173
|
+
if (repo.includes('colony') || repo.includes('colonyos')) {
|
|
174
|
+
await sendInboxMessage(
|
|
175
|
+
db, 'mara', 'status_update',
|
|
176
|
+
`Task completed: ${task.title.slice(0, 100)}`,
|
|
177
|
+
`Repo: ${task.repo}\nCategory: ${task.category}\nResult: ${(task.result ?? '').slice(0, 500)}`,
|
|
178
|
+
);
|
|
179
|
+
dispatched++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (dispatched > 0 || (tasks.results?.length ?? 0) > 0) {
|
|
184
|
+
await setWatermark(db, 'task_completions');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return dispatched;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- Trigger: Failed Tasks -> Alert Relevant Agent ---
|
|
191
|
+
// When tasks fail repeatedly on the same repo, alert the agent
|
|
192
|
+
// that owns that domain.
|
|
193
|
+
|
|
194
|
+
async function dispatchTaskFailures(db: D1Database): Promise<number> {
|
|
195
|
+
// Find repos with 2+ failures in last 24h
|
|
196
|
+
const failClusters = await db.prepare(`
|
|
197
|
+
SELECT repo, COUNT(*) as fail_count, GROUP_CONCAT(title, ' | ') as titles
|
|
198
|
+
FROM cc_tasks
|
|
199
|
+
WHERE status = 'failed'
|
|
200
|
+
AND completed_at > datetime('now', '-24 hours')
|
|
201
|
+
GROUP BY repo
|
|
202
|
+
HAVING COUNT(*) >= 2
|
|
203
|
+
LIMIT 5
|
|
204
|
+
`).all<{ repo: string; fail_count: number; titles: string }>();
|
|
205
|
+
|
|
206
|
+
let dispatched = 0;
|
|
207
|
+
for (const cluster of failClusters.results ?? []) {
|
|
208
|
+
// Dedup: don't re-alert for same cluster
|
|
209
|
+
const existing = await db.prepare(
|
|
210
|
+
"SELECT 1 FROM agent_inbox WHERE sender = 'aegis' AND subject LIKE ? AND created_at > datetime('now', '-24 hours') LIMIT 1"
|
|
211
|
+
).bind(`%${cluster.repo}%failure cluster%`).first();
|
|
212
|
+
if (existing) continue;
|
|
213
|
+
|
|
214
|
+
// Route to CodeBeast for code-related failure analysis
|
|
215
|
+
await sendInboxMessage(
|
|
216
|
+
db, 'codebeast', 'alert',
|
|
217
|
+
`${cluster.repo}: failure cluster (${cluster.fail_count} tasks)`,
|
|
218
|
+
`${cluster.fail_count} tasks failed in ${cluster.repo} in the last 24h.\n\nTitles:\n${cluster.titles.split(' | ').map(t => `- ${t}`).join('\n')}\n\nInvestigate common root cause. Check for environment issues, missing dependencies, or systematic test failures.`,
|
|
219
|
+
'code-review',
|
|
220
|
+
);
|
|
221
|
+
dispatched++;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return dispatched;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Main Entry Point ---
|
|
228
|
+
|
|
229
|
+
export async function runAgentDispatch(env: EdgeEnv): Promise<void> {
|
|
230
|
+
if (!env.githubToken) {
|
|
231
|
+
console.log('[agent-dispatch] Skipped: missing githubToken');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let totalDispatched = 0;
|
|
236
|
+
|
|
237
|
+
// 1. Merged PRs -> CodeBeast review
|
|
238
|
+
const prReviews = await dispatchMergedPrReviews(env.db);
|
|
239
|
+
totalDispatched += prReviews;
|
|
240
|
+
|
|
241
|
+
// 2. Completed tasks -> relevant agent notifications
|
|
242
|
+
const taskNotifs = await dispatchTaskCompletions(env.db);
|
|
243
|
+
totalDispatched += taskNotifs;
|
|
244
|
+
|
|
245
|
+
// 3. Failure clusters -> CodeBeast alert
|
|
246
|
+
const failAlerts = await dispatchTaskFailures(env.db);
|
|
247
|
+
totalDispatched += failAlerts;
|
|
248
|
+
|
|
249
|
+
if (totalDispatched > 0) {
|
|
250
|
+
console.log(`[agent-dispatch] Dispatched ${totalDispatched} messages (PR reviews: ${prReviews}, task notifs: ${taskNotifs}, fail alerts: ${failAlerts})`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// ARGUS Analytics: GA4 insight extraction
|
|
2
|
+
// Queries Google Analytics Data API daily, stores findings for the digest.
|
|
3
|
+
// Surfaces: traffic trends, top pages, traffic sources, bounce anomalies.
|
|
4
|
+
// Zero inference — pure API queries and threshold logic.
|
|
5
|
+
|
|
6
|
+
import { type EdgeEnv } from '../dispatch.js';
|
|
7
|
+
|
|
8
|
+
// ─── GA4 OAuth2 ──────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
interface GACredentials {
|
|
11
|
+
client_id: string;
|
|
12
|
+
client_secret: string;
|
|
13
|
+
refresh_token: string;
|
|
14
|
+
property_id: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function getAccessToken(creds: GACredentials): Promise<string> {
|
|
18
|
+
const res = await fetch('https://oauth2.googleapis.com/token', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
21
|
+
body: new URLSearchParams({
|
|
22
|
+
refresh_token: creds.refresh_token,
|
|
23
|
+
client_id: creds.client_id,
|
|
24
|
+
client_secret: creds.client_secret,
|
|
25
|
+
grant_type: 'refresh_token',
|
|
26
|
+
}),
|
|
27
|
+
signal: AbortSignal.timeout(10_000),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const err = await res.text();
|
|
32
|
+
throw new Error(`GA token refresh failed: ${res.status} ${err}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const data = await res.json<{ access_token: string }>();
|
|
36
|
+
return data.access_token;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── GA4 Data API ────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface GAReport {
|
|
42
|
+
rows?: Array<{
|
|
43
|
+
dimensionValues: Array<{ value: string }>;
|
|
44
|
+
metricValues: Array<{ value: string }>;
|
|
45
|
+
}>;
|
|
46
|
+
rowCount?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function runReport(
|
|
50
|
+
accessToken: string,
|
|
51
|
+
propertyId: string,
|
|
52
|
+
body: Record<string, unknown>,
|
|
53
|
+
): Promise<GAReport> {
|
|
54
|
+
const res = await fetch(
|
|
55
|
+
`https://analyticsdata.googleapis.com/v1beta/properties/${propertyId}:runReport`,
|
|
56
|
+
{
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify(body),
|
|
63
|
+
signal: AbortSignal.timeout(15_000),
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const err = await res.text();
|
|
69
|
+
throw new Error(`GA4 report failed: ${res.status} ${err}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return res.json<GAReport>();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Analytics Snapshot ──────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface AnalyticsSnapshot {
|
|
78
|
+
// Traffic overview
|
|
79
|
+
sessions_7d: number;
|
|
80
|
+
sessions_prior_7d: number;
|
|
81
|
+
users_7d: number;
|
|
82
|
+
bounce_rate_7d: number;
|
|
83
|
+
|
|
84
|
+
// Top pages
|
|
85
|
+
top_pages: Array<{ path: string; sessions: number; bounce_rate: number }>;
|
|
86
|
+
|
|
87
|
+
// Traffic sources
|
|
88
|
+
top_sources: Array<{ source: string; medium: string; sessions: number }>;
|
|
89
|
+
|
|
90
|
+
// Insights (derived)
|
|
91
|
+
insights: string[];
|
|
92
|
+
|
|
93
|
+
computed_at: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Main ────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export async function runArgusAnalytics(env: EdgeEnv): Promise<void> {
|
|
99
|
+
// Daily gate — run at 08 UTC (before digest at 09)
|
|
100
|
+
const hour = new Date().getUTCHours();
|
|
101
|
+
if (hour !== 8) return;
|
|
102
|
+
|
|
103
|
+
// Cooldown: 22 hours
|
|
104
|
+
const lastRun = await env.db.prepare(
|
|
105
|
+
"SELECT received_at FROM web_events WHERE event_id = 'argus_analytics'"
|
|
106
|
+
).first<{ received_at: string }>();
|
|
107
|
+
|
|
108
|
+
if (lastRun) {
|
|
109
|
+
const elapsed = Date.now() - new Date(lastRun.received_at + 'Z').getTime();
|
|
110
|
+
if (elapsed < 22 * 60 * 60 * 1000) return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Parse credentials from env (JSON: { client_id, client_secret, refresh_token, property_id })
|
|
114
|
+
const rawCreds = env.gaCredentials;
|
|
115
|
+
if (!rawCreds) {
|
|
116
|
+
console.log('[argus-analytics] Skipping — no GA_CREDENTIALS');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let creds: GACredentials;
|
|
121
|
+
try {
|
|
122
|
+
creds = JSON.parse(rawCreds);
|
|
123
|
+
} catch {
|
|
124
|
+
console.error('[argus-analytics] Failed to parse GA_CREDENTIALS');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const accessToken = await getAccessToken(creds);
|
|
129
|
+
|
|
130
|
+
// Run reports in parallel
|
|
131
|
+
const [trafficCurrent, trafficPrior, topPages, sources] = await Promise.all([
|
|
132
|
+
// Total sessions + users + bounce rate (last 7 days)
|
|
133
|
+
runReport(accessToken, creds.property_id, {
|
|
134
|
+
dateRanges: [{ startDate: '7daysAgo', endDate: 'today' }],
|
|
135
|
+
metrics: [
|
|
136
|
+
{ name: 'sessions' },
|
|
137
|
+
{ name: 'activeUsers' },
|
|
138
|
+
{ name: 'bounceRate' },
|
|
139
|
+
],
|
|
140
|
+
}),
|
|
141
|
+
|
|
142
|
+
// Prior 7 days for comparison
|
|
143
|
+
runReport(accessToken, creds.property_id, {
|
|
144
|
+
dateRanges: [{ startDate: '14daysAgo', endDate: '8daysAgo' }],
|
|
145
|
+
metrics: [{ name: 'sessions' }],
|
|
146
|
+
}),
|
|
147
|
+
|
|
148
|
+
// Top pages by sessions
|
|
149
|
+
runReport(accessToken, creds.property_id, {
|
|
150
|
+
dateRanges: [{ startDate: '7daysAgo', endDate: 'today' }],
|
|
151
|
+
dimensions: [{ name: 'pagePath' }],
|
|
152
|
+
metrics: [{ name: 'sessions' }, { name: 'bounceRate' }],
|
|
153
|
+
limit: 10,
|
|
154
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
155
|
+
}),
|
|
156
|
+
|
|
157
|
+
// Traffic sources
|
|
158
|
+
runReport(accessToken, creds.property_id, {
|
|
159
|
+
dateRanges: [{ startDate: '7daysAgo', endDate: 'today' }],
|
|
160
|
+
dimensions: [{ name: 'sessionSource' }, { name: 'sessionMedium' }],
|
|
161
|
+
metrics: [{ name: 'sessions' }],
|
|
162
|
+
limit: 10,
|
|
163
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
164
|
+
}),
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
// Parse results
|
|
168
|
+
const currentRow = trafficCurrent.rows?.[0];
|
|
169
|
+
const sessions_7d = parseInt(currentRow?.metricValues[0].value ?? '0');
|
|
170
|
+
const users_7d = parseInt(currentRow?.metricValues[1].value ?? '0');
|
|
171
|
+
const bounce_rate_7d = parseFloat(currentRow?.metricValues[2].value ?? '0');
|
|
172
|
+
|
|
173
|
+
const priorRow = trafficPrior.rows?.[0];
|
|
174
|
+
const sessions_prior_7d = parseInt(priorRow?.metricValues[0].value ?? '0');
|
|
175
|
+
|
|
176
|
+
const top_pages = (topPages.rows ?? []).map(row => ({
|
|
177
|
+
path: row.dimensionValues[0].value,
|
|
178
|
+
sessions: parseInt(row.metricValues[0].value),
|
|
179
|
+
bounce_rate: parseFloat(row.metricValues[1].value),
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
const top_sources = (sources.rows ?? []).map(row => ({
|
|
183
|
+
source: row.dimensionValues[0].value,
|
|
184
|
+
medium: row.dimensionValues[1].value,
|
|
185
|
+
sessions: parseInt(row.metricValues[0].value),
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
// Generate insights
|
|
189
|
+
const insights: string[] = [];
|
|
190
|
+
|
|
191
|
+
// Traffic trend
|
|
192
|
+
if (sessions_prior_7d > 0) {
|
|
193
|
+
const delta = ((sessions_7d - sessions_prior_7d) / sessions_prior_7d * 100);
|
|
194
|
+
if (delta > 50) {
|
|
195
|
+
insights.push(`Traffic up ${Math.round(delta)}% week-over-week (${sessions_prior_7d} → ${sessions_7d} sessions). Find out what's driving it.`);
|
|
196
|
+
} else if (delta < -30) {
|
|
197
|
+
insights.push(`Traffic down ${Math.round(Math.abs(delta))}% week-over-week (${sessions_prior_7d} → ${sessions_7d} sessions). Check if something broke or if last week was an anomaly.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Bounce rate
|
|
202
|
+
if (bounce_rate_7d > 0.8 && sessions_7d > 10) {
|
|
203
|
+
insights.push(`Overall bounce rate is ${(bounce_rate_7d * 100).toFixed(0)}% — more than 4 out of 5 visitors leave immediately. Landing page isn't converting.`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// High-bounce pages with traffic
|
|
207
|
+
for (const page of top_pages) {
|
|
208
|
+
if (page.bounce_rate > 0.9 && page.sessions >= 5) {
|
|
209
|
+
insights.push(`${page.path} has ${(page.bounce_rate * 100).toFixed(0)}% bounce rate with ${page.sessions} sessions. Users are landing and leaving.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Zero-traffic detection (have pages but nobody's visiting)
|
|
214
|
+
if (sessions_7d < 5) {
|
|
215
|
+
insights.push(`Only ${sessions_7d} total sessions this week. No meaningful traffic. Distribution is the bottleneck, not product.`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// New traffic source spike
|
|
219
|
+
for (const src of top_sources) {
|
|
220
|
+
if (src.sessions >= 10 && src.source !== '(direct)' && src.source !== '(not set)') {
|
|
221
|
+
insights.push(`${src.sessions} sessions from ${src.source} (${src.medium}). Worth investigating — is this organic or a mention?`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const snapshot: AnalyticsSnapshot = {
|
|
226
|
+
sessions_7d,
|
|
227
|
+
sessions_prior_7d,
|
|
228
|
+
users_7d,
|
|
229
|
+
bounce_rate_7d,
|
|
230
|
+
top_pages,
|
|
231
|
+
top_sources,
|
|
232
|
+
insights,
|
|
233
|
+
computed_at: new Date().toISOString(),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Store for digest
|
|
237
|
+
await env.db.prepare(
|
|
238
|
+
"INSERT INTO digest_sections (section, payload) VALUES ('analytics', ?)"
|
|
239
|
+
).bind(JSON.stringify(snapshot)).run();
|
|
240
|
+
|
|
241
|
+
// Update watermark
|
|
242
|
+
await env.db.prepare(
|
|
243
|
+
"INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('argus_analytics', datetime('now'))"
|
|
244
|
+
).run();
|
|
245
|
+
|
|
246
|
+
console.log(`[argus-analytics] ${sessions_7d} sessions (${sessions_prior_7d} prior), ${users_7d} users, ${(bounce_rate_7d * 100).toFixed(0)}% bounce, ${insights.length} insights`);
|
|
247
|
+
}
|