@better_openclaw/betterclaw 1.4.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/src/index.ts ADDED
@@ -0,0 +1,251 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { PluginConfig } from "./types.js";
3
+ import { ContextManager } from "./context.js";
4
+ import { createGetContextTool } from "./tools/get-context.js";
5
+ import { EventLog } from "./events.js";
6
+ import { RulesEngine } from "./filter.js";
7
+ import { JudgmentLayer } from "./judgment.js";
8
+ import { PatternEngine } from "./patterns.js";
9
+ import { ProactiveEngine } from "./triggers.js";
10
+ import { processEvent } from "./pipeline.js";
11
+ import type { PipelineDeps } from "./pipeline.js";
12
+
13
+ export type { PluginConfig } from "./types.js";
14
+
15
+ const DEFAULT_CONFIG: PluginConfig = {
16
+ llmModel: "openai/gpt-4o-mini",
17
+ pushBudgetPerDay: 10,
18
+ patternWindowDays: 14,
19
+ proactiveEnabled: true,
20
+ };
21
+
22
+ function resolveConfig(raw: Record<string, unknown> | undefined): PluginConfig {
23
+ return {
24
+ llmModel:
25
+ typeof raw?.llmModel === "string" && raw.llmModel.trim()
26
+ ? raw.llmModel.trim()
27
+ : DEFAULT_CONFIG.llmModel,
28
+ pushBudgetPerDay:
29
+ typeof raw?.pushBudgetPerDay === "number" && raw.pushBudgetPerDay > 0
30
+ ? raw.pushBudgetPerDay
31
+ : DEFAULT_CONFIG.pushBudgetPerDay,
32
+ patternWindowDays:
33
+ typeof raw?.patternWindowDays === "number" && raw.patternWindowDays > 0
34
+ ? raw.patternWindowDays
35
+ : DEFAULT_CONFIG.patternWindowDays,
36
+ proactiveEnabled:
37
+ typeof raw?.proactiveEnabled === "boolean"
38
+ ? raw.proactiveEnabled
39
+ : DEFAULT_CONFIG.proactiveEnabled,
40
+ };
41
+ }
42
+
43
+ export default {
44
+ id: "betterclaw",
45
+ name: "BetterClaw Context",
46
+
47
+ register(api: OpenClawPluginApi) {
48
+ const config = resolveConfig(api.pluginConfig as Record<string, unknown> | undefined);
49
+ const stateDir = api.runtime.state.resolveStateDir();
50
+
51
+ api.logger.info(`betterclaw plugin loaded (model=${config.llmModel}, budget=${config.pushBudgetPerDay})`);
52
+
53
+ // Context manager (load synchronously — file read deferred to first access)
54
+ const ctxManager = new ContextManager(stateDir);
55
+
56
+ // Event log, rules engine, judgment layer
57
+ const eventLog = new EventLog(stateDir);
58
+ const rules = new RulesEngine(config.pushBudgetPerDay);
59
+ const judgment = new JudgmentLayer(api, config);
60
+
61
+ // Pipeline dependencies
62
+ const pipelineDeps: PipelineDeps = {
63
+ api,
64
+ config,
65
+ context: ctxManager,
66
+ events: eventLog,
67
+ rules,
68
+ judgment,
69
+ };
70
+
71
+ // Track whether async init has completed
72
+ let initialized = false;
73
+ const initPromise = (async () => {
74
+ try {
75
+ await ctxManager.load();
76
+ const recentEvents = await eventLog.readSince(Date.now() / 1000 - 86400);
77
+ rules.restoreCooldowns(
78
+ recentEvents
79
+ .filter((e) => e.decision === "push")
80
+ .map((e) => ({ subscriptionId: e.event.subscriptionId, firedAt: e.event.firedAt })),
81
+ );
82
+ initialized = true;
83
+ api.logger.info("betterclaw: async init complete");
84
+ } catch (err) {
85
+ api.logger.error(`betterclaw: init failed: ${err instanceof Error ? err.message : String(err)}`);
86
+ }
87
+ })();
88
+
89
+ // Ping health check
90
+ api.registerGatewayMethod("betterclaw.ping", ({ respond }) => {
91
+ respond(true, { ok: true, version: "1.0.0", initialized });
92
+ });
93
+
94
+ // Context RPC — returns activity, trends, and recent decisions for iOS Context tab
95
+ api.registerGatewayMethod("betterclaw.context", async ({ respond }) => {
96
+ if (!initialized) await initPromise;
97
+
98
+ const state = ctxManager.get();
99
+ const patterns = await ctxManager.readPatterns();
100
+ const recentEntries = await eventLog.readRecent(20);
101
+
102
+ const activity = {
103
+ currentZone: state.activity.currentZone,
104
+ zoneEnteredAt: state.activity.zoneEnteredAt,
105
+ lastTransition: state.activity.lastTransition,
106
+ isStationary: state.activity.isStationary,
107
+ stationarySince: state.activity.stationarySince,
108
+ };
109
+
110
+ const trends = patterns
111
+ ? {
112
+ stepsAvg7d: patterns.healthTrends.stepsAvg7d,
113
+ stepsTrend: patterns.healthTrends.stepsTrend,
114
+ sleepAvg7d: patterns.healthTrends.sleepAvg7d,
115
+ sleepTrend: patterns.healthTrends.sleepTrend,
116
+ restingHrAvg7d: patterns.healthTrends.restingHrAvg7d,
117
+ restingHrTrend: patterns.healthTrends.restingHrTrend,
118
+ eventsPerDay7d: patterns.eventStats.eventsPerDay7d,
119
+ pushesPerDay7d: patterns.eventStats.pushesPerDay7d,
120
+ dropRate7d: patterns.eventStats.dropRate7d,
121
+ }
122
+ : null;
123
+
124
+ const decisions = recentEntries.map((e) => ({
125
+ source: e.event.source,
126
+ title: e.event.subscriptionId,
127
+ decision: e.decision,
128
+ reason: e.reason,
129
+ timestamp: e.timestamp,
130
+ }));
131
+
132
+ const meta = {
133
+ pushesToday: state.meta.pushesToday,
134
+ pushBudgetPerDay: config.pushBudgetPerDay,
135
+ eventsToday: state.meta.eventsToday,
136
+ };
137
+
138
+ const routines = patterns
139
+ ? {
140
+ weekday: patterns.locationRoutines.weekday,
141
+ weekend: patterns.locationRoutines.weekend,
142
+ }
143
+ : null;
144
+
145
+ respond(true, { activity, trends, decisions, meta, routines });
146
+ });
147
+
148
+ // Snapshot RPC — bulk-apply device state for Smart Mode catch-up
149
+ api.registerGatewayMethod("betterclaw.snapshot", async ({ params, respond }) => {
150
+ if (!initialized) await initPromise;
151
+
152
+ const snapshot = params as {
153
+ battery?: { level: number; state: string; isLowPowerMode: boolean };
154
+ location?: { latitude: number; longitude: number };
155
+ health?: {
156
+ stepsToday?: number; distanceMeters?: number; heartRateAvg?: number;
157
+ restingHeartRate?: number; hrv?: number; activeEnergyKcal?: number;
158
+ sleepDurationSeconds?: number;
159
+ };
160
+ geofence?: { type: string; zoneName: string; latitude: number; longitude: number };
161
+ };
162
+
163
+ ctxManager.applySnapshot(snapshot);
164
+ await ctxManager.save();
165
+ respond(true, { applied: true });
166
+ });
167
+
168
+ // Agent tool
169
+ api.registerTool(createGetContextTool(ctxManager), { optional: true });
170
+
171
+ // Auto-reply command
172
+ api.registerCommand({
173
+ name: "bc",
174
+ description: "Show current BetterClaw device context snapshot",
175
+ handler: () => {
176
+ const state = ctxManager.get();
177
+ const battery = state.device.battery;
178
+ const loc = state.device.location;
179
+ const health = state.device.health;
180
+ const activity = state.activity;
181
+
182
+ const lines: string[] = [];
183
+ if (battery) {
184
+ lines.push(`Battery: ${Math.round(battery.level * 100)}% (${battery.state})`);
185
+ }
186
+ if (loc) {
187
+ lines.push(`Location: ${loc.label ?? `${loc.latitude.toFixed(4)}, ${loc.longitude.toFixed(4)}`}`);
188
+ }
189
+ if (activity.currentZone) {
190
+ const since = activity.zoneEnteredAt
191
+ ? ` since ${new Date(activity.zoneEnteredAt * 1000).toLocaleTimeString()}`
192
+ : "";
193
+ lines.push(`Zone: ${activity.currentZone}${since}`);
194
+ }
195
+ if (health?.stepsToday) {
196
+ lines.push(`Steps: ${Math.round(health.stepsToday).toLocaleString()}`);
197
+ }
198
+ lines.push(`Events today: ${state.meta.eventsToday} | Pushes: ${state.meta.pushesToday}`);
199
+
200
+ return { text: lines.join("\n") };
201
+ },
202
+ });
203
+
204
+ // Event intake RPC
205
+ api.registerGatewayMethod("betterclaw.event", async ({ params, respond }) => {
206
+ try {
207
+ // Wait for init if still pending
208
+ if (!initialized) await initPromise;
209
+
210
+ const event = {
211
+ subscriptionId: typeof params?.subscriptionId === "string" ? params.subscriptionId : "",
212
+ source: typeof params?.source === "string" ? params.source : "",
213
+ data: (params?.data && typeof params.data === "object" ? params.data : {}) as Record<string, number>,
214
+ metadata: (params?.metadata && typeof params.metadata === "object"
215
+ ? params.metadata
216
+ : undefined) as Record<string, string> | undefined,
217
+ firedAt: typeof params?.firedAt === "number" ? params.firedAt : Date.now() / 1000,
218
+ };
219
+
220
+ if (!event.subscriptionId || !event.source) {
221
+ respond(false, undefined, { code: "INVALID_PARAMS", message: "subscriptionId and source required" });
222
+ return;
223
+ }
224
+
225
+ respond(true, { accepted: true });
226
+ await processEvent(pipelineDeps, event);
227
+ } catch (err) {
228
+ api.logger.error(`betterclaw.event handler error: ${err instanceof Error ? err.message : String(err)}`);
229
+ }
230
+ });
231
+
232
+ // Pattern engine + proactive engine
233
+ const patternEngine = new PatternEngine(ctxManager, eventLog, config.patternWindowDays);
234
+ const proactiveEngine = new ProactiveEngine(ctxManager, api, config);
235
+
236
+ // Background service
237
+ api.registerService({
238
+ id: "betterclaw-engine",
239
+ start: () => {
240
+ patternEngine.startSchedule();
241
+ proactiveEngine.startSchedule();
242
+ api.logger.info("betterclaw: background services started");
243
+ },
244
+ stop: () => {
245
+ patternEngine.stopSchedule();
246
+ proactiveEngine.stopSchedule();
247
+ api.logger.info("betterclaw: background services stopped");
248
+ },
249
+ });
250
+ },
251
+ };
@@ -0,0 +1,145 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
5
+ import type { DeviceContext, DeviceEvent } from "./types.js";
6
+
7
+ interface JudgmentResult {
8
+ push: boolean;
9
+ reason: string;
10
+ }
11
+
12
+ type RunEmbeddedPiAgentFn = (opts: Record<string, unknown>) => Promise<{ payloads?: unknown[] }>;
13
+
14
+ let _runFn: RunEmbeddedPiAgentFn | null = null;
15
+
16
+ async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
17
+ if (_runFn) return _runFn;
18
+ // Dynamic import from OpenClaw internals (same pattern as llm-task plugin)
19
+ const mod = await import("../../../src/agents/pi-embedded.js").catch(() =>
20
+ import("openclaw/agents/pi-embedded"),
21
+ );
22
+ if (typeof (mod as any).runEmbeddedPiAgent !== "function") {
23
+ throw new Error("runEmbeddedPiAgent not available");
24
+ }
25
+ _runFn = (mod as any).runEmbeddedPiAgent as RunEmbeddedPiAgentFn;
26
+ return _runFn;
27
+ }
28
+
29
+ export function buildPrompt(event: DeviceEvent, context: DeviceContext, pushesToday: number, budget: number): string {
30
+ // Strip raw coordinates from context for privacy
31
+ const sanitizedContext = {
32
+ ...context,
33
+ device: {
34
+ ...context.device,
35
+ location: context.device.location
36
+ ? {
37
+ label: context.device.location.label ?? "Unknown",
38
+ updatedAt: context.device.location.updatedAt,
39
+ }
40
+ : null,
41
+ },
42
+ };
43
+
44
+ return [
45
+ "You are an event triage system for a personal AI assistant.",
46
+ "Given the device context and a new event, decide: should the AI assistant be told about this?",
47
+ "",
48
+ "Respond with ONLY valid JSON: {\"push\": true/false, \"reason\": \"one sentence\"}",
49
+ "",
50
+ `Context: ${JSON.stringify(sanitizedContext)}`,
51
+ `Event: ${JSON.stringify(event)}`,
52
+ `Pushes today: ${pushesToday} of ~${budget} budget`,
53
+ `Time: ${new Date().toISOString()}`,
54
+ ].join("\n");
55
+ }
56
+
57
+ function extractText(payloads: unknown[]): string {
58
+ for (const p of payloads) {
59
+ if (typeof p === "string") return p;
60
+ if (p && typeof p === "object" && "text" in p && typeof (p as any).text === "string") {
61
+ return (p as any).text;
62
+ }
63
+ if (p && typeof p === "object" && "content" in p && Array.isArray((p as any).content)) {
64
+ for (const c of (p as any).content) {
65
+ if (c && typeof c.text === "string") return c.text;
66
+ }
67
+ }
68
+ }
69
+ return "";
70
+ }
71
+
72
+ export class JudgmentLayer {
73
+ private api: OpenClawPluginApi;
74
+ private config: { llmModel: string; pushBudgetPerDay: number };
75
+
76
+ constructor(api: OpenClawPluginApi, config: { llmModel: string; pushBudgetPerDay: number }) {
77
+ this.api = api;
78
+ this.config = config;
79
+ }
80
+
81
+ async evaluate(event: DeviceEvent, context: DeviceContext): Promise<JudgmentResult> {
82
+ const prompt = buildPrompt(event, context, context.meta.pushesToday, this.config.pushBudgetPerDay);
83
+
84
+ const [provider, ...modelParts] = this.config.llmModel.split("/");
85
+ const model = modelParts.join("/");
86
+
87
+ if (!provider || !model) {
88
+ this.api.logger.warn("betterclaw: invalid llmModel config, defaulting to push");
89
+ return { push: true, reason: "llm model not configured — fail open" };
90
+ }
91
+
92
+ let tmpDir: string | null = null;
93
+ try {
94
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "betterclaw-judgment-"));
95
+ const sessionId = `betterclaw-judgment-${Date.now()}`;
96
+ const sessionFile = path.join(tmpDir, "session.json");
97
+
98
+ const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent();
99
+
100
+ const result = await runEmbeddedPiAgent({
101
+ sessionId,
102
+ sessionFile,
103
+ workspaceDir: (this.api as any).config?.agents?.defaults?.workspace ?? process.cwd(),
104
+ config: (this.api as any).config,
105
+ prompt,
106
+ timeoutMs: 15_000,
107
+ runId: `betterclaw-judgment-${Date.now()}`,
108
+ provider,
109
+ model,
110
+ disableTools: true,
111
+ });
112
+
113
+ const text = extractText(result.payloads ?? []);
114
+ if (!text) {
115
+ this.api.logger.warn("betterclaw: LLM returned empty output, defaulting to push");
116
+ return { push: true, reason: "llm returned empty — fail open" };
117
+ }
118
+
119
+ // Strip code fences if present
120
+ const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
121
+
122
+ try {
123
+ const parsed = JSON.parse(cleaned) as { push?: boolean; reason?: string };
124
+ return {
125
+ push: parsed.push === true,
126
+ reason: typeof parsed.reason === "string" ? parsed.reason : "no reason given",
127
+ };
128
+ } catch {
129
+ this.api.logger.warn(`betterclaw: LLM returned invalid JSON: ${text.slice(0, 200)}`);
130
+ return { push: true, reason: "llm returned invalid json — fail open" };
131
+ }
132
+ } catch (err) {
133
+ this.api.logger.error(`betterclaw: judgment call failed: ${err instanceof Error ? err.message : String(err)}`);
134
+ return { push: true, reason: "llm call failed — fail open" };
135
+ } finally {
136
+ if (tmpDir) {
137
+ try {
138
+ await fs.rm(tmpDir, { recursive: true, force: true });
139
+ } catch {
140
+ // ignore
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,248 @@
1
+ import type { ContextManager } from "./context.js";
2
+ import type { EventLog } from "./events.js";
3
+ import type { EventLogEntry, Patterns } from "./types.js";
4
+
5
+ const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
6
+
7
+ export class PatternEngine {
8
+ private context: ContextManager;
9
+ private events: EventLog;
10
+ private windowDays: number;
11
+ private interval: ReturnType<typeof setInterval> | null = null;
12
+
13
+ constructor(context: ContextManager, events: EventLog, windowDays: number) {
14
+ this.context = context;
15
+ this.events = events;
16
+ this.windowDays = windowDays;
17
+ }
18
+
19
+ startSchedule(): void {
20
+ // Run immediately, then every 6 hours
21
+ void this.compute().catch(() => {});
22
+ this.interval = setInterval(() => {
23
+ void this.compute().catch(() => {});
24
+ }, SIX_HOURS_MS);
25
+ this.interval.unref?.();
26
+ }
27
+
28
+ stopSchedule(): void {
29
+ if (this.interval) {
30
+ clearInterval(this.interval);
31
+ this.interval = null;
32
+ }
33
+ }
34
+
35
+ async compute(): Promise<Patterns> {
36
+ const windowStart = Date.now() / 1000 - this.windowDays * 86400;
37
+ const entries = await this.events.readSince(windowStart);
38
+
39
+ const existing = (await this.context.readPatterns()) ?? emptyPatterns();
40
+
41
+ const patterns: Patterns = {
42
+ locationRoutines: computeLocationRoutines(entries),
43
+ healthTrends: computeHealthTrends(entries),
44
+ batteryPatterns: computeBatteryPatterns(entries),
45
+ eventStats: computeEventStats(entries),
46
+ triggerCooldowns: existing.triggerCooldowns,
47
+ computedAt: Date.now() / 1000,
48
+ };
49
+
50
+ await this.context.writePatterns(patterns);
51
+
52
+ // Rotate event log if needed
53
+ await this.events.rotate();
54
+
55
+ return patterns;
56
+ }
57
+ }
58
+
59
+ export function emptyPatterns(): Patterns {
60
+ return {
61
+ locationRoutines: { weekday: [], weekend: [] },
62
+ healthTrends: {
63
+ stepsAvg7d: null,
64
+ stepsAvg30d: null,
65
+ stepsTrend: null,
66
+ sleepAvg7d: null,
67
+ sleepTrend: null,
68
+ restingHrAvg7d: null,
69
+ restingHrTrend: null,
70
+ },
71
+ batteryPatterns: {
72
+ avgDrainPerHour: null,
73
+ typicalChargeTime: null,
74
+ lowBatteryFrequency: null,
75
+ },
76
+ eventStats: {
77
+ eventsPerDay7d: 0,
78
+ pushesPerDay7d: 0,
79
+ dropRate7d: 0,
80
+ topSources: [],
81
+ },
82
+ triggerCooldowns: {},
83
+ computedAt: 0,
84
+ };
85
+ }
86
+
87
+ function computeLocationRoutines(entries: EventLogEntry[]) {
88
+ const geofenceEvents = entries.filter((e) => e.event.source === "geofence.triggered");
89
+
90
+ const weekdayZones = new Map<string, { arrives: number[]; leaves: number[] }>();
91
+ const weekendZones = new Map<string, { arrives: number[]; leaves: number[] }>();
92
+
93
+ for (const entry of geofenceEvents) {
94
+ const date = new Date(entry.event.firedAt * 1000);
95
+ const isWeekend = date.getDay() === 0 || date.getDay() === 6;
96
+ const hour = date.getHours() + date.getMinutes() / 60;
97
+ const type = entry.event.data.type === 1 ? "enter" : "exit";
98
+ const zones = isWeekend ? weekendZones : weekdayZones;
99
+
100
+ const zone = entry.event.metadata?.zoneName ?? "Unknown";
101
+ if (!zones.has(zone)) zones.set(zone, { arrives: [], leaves: [] });
102
+ const z = zones.get(zone)!;
103
+
104
+ if (type === "enter") z.arrives.push(hour);
105
+ else z.leaves.push(hour);
106
+ }
107
+
108
+ const formatRoutines = (zones: Map<string, { arrives: number[]; leaves: number[] }>) =>
109
+ Array.from(zones.entries()).map(([zone, data]) => ({
110
+ zone,
111
+ typicalArrive: data.arrives.length > 0 ? formatHour(median(data.arrives)) : null,
112
+ typicalLeave: data.leaves.length > 0 ? formatHour(median(data.leaves)) : null,
113
+ }));
114
+
115
+ return {
116
+ weekday: formatRoutines(weekdayZones),
117
+ weekend: formatRoutines(weekendZones),
118
+ };
119
+ }
120
+
121
+ function computeHealthTrends(entries: EventLogEntry[]) {
122
+ const healthEvents = entries.filter((e) => e.event.source.startsWith("health"));
123
+ const now = Date.now() / 1000;
124
+
125
+ const last7d = healthEvents.filter((e) => e.event.firedAt >= now - 7 * 86400);
126
+ const last30d = healthEvents;
127
+
128
+ const stepsValues7d = last7d
129
+ .map((e) => e.event.data.stepsToday)
130
+ .filter((v): v is number => v != null);
131
+ const stepsValues30d = last30d
132
+ .map((e) => e.event.data.stepsToday)
133
+ .filter((v): v is number => v != null);
134
+ const sleepValues7d = last7d
135
+ .map((e) => e.event.data.sleepDurationSeconds)
136
+ .filter((v): v is number => v != null);
137
+ const sleepValues30d = last30d
138
+ .map((e) => e.event.data.sleepDurationSeconds)
139
+ .filter((v): v is number => v != null);
140
+ const rhrValues7d = last7d
141
+ .map((e) => e.event.data.restingHeartRate)
142
+ .filter((v): v is number => v != null);
143
+ const rhrValues30d = last30d
144
+ .map((e) => e.event.data.restingHeartRate)
145
+ .filter((v): v is number => v != null);
146
+
147
+ const stepsAvg7d = average(stepsValues7d);
148
+ const stepsAvg30d = average(stepsValues30d);
149
+ const sleepAvg7d = average(sleepValues7d);
150
+ const sleepAvg30d = average(sleepValues30d);
151
+ const rhrAvg7d = average(rhrValues7d);
152
+ const rhrAvg30d = average(rhrValues30d);
153
+
154
+ return {
155
+ stepsAvg7d,
156
+ stepsAvg30d,
157
+ stepsTrend: computeTrend(stepsAvg7d, stepsAvg30d),
158
+ sleepAvg7d,
159
+ sleepTrend: computeTrend(sleepAvg7d, sleepAvg30d),
160
+ restingHrAvg7d: rhrAvg7d,
161
+ restingHrTrend: computeInverseTrend(rhrAvg7d, rhrAvg30d),
162
+ };
163
+ }
164
+
165
+ function computeBatteryPatterns(entries: EventLogEntry[]) {
166
+ const lowBatteryEvents = entries.filter(
167
+ (e) =>
168
+ e.event.subscriptionId === "default.battery-low" ||
169
+ e.event.subscriptionId === "default.battery-critical",
170
+ );
171
+
172
+ const daySpan =
173
+ entries.length > 0
174
+ ? Math.max(1, (entries[entries.length - 1].timestamp - entries[0].timestamp) / 86400)
175
+ : 1;
176
+
177
+ return {
178
+ avgDrainPerHour: null,
179
+ typicalChargeTime: null,
180
+ lowBatteryFrequency: lowBatteryEvents.length / daySpan,
181
+ };
182
+ }
183
+
184
+ function computeEventStats(entries: EventLogEntry[]) {
185
+ const now = Date.now() / 1000;
186
+ const last7d = entries.filter((e) => e.timestamp >= now - 7 * 86400);
187
+
188
+ const days = Math.max(1, 7);
189
+ const pushes = last7d.filter((e) => e.decision === "push");
190
+ const drops = last7d.filter((e) => e.decision === "drop");
191
+
192
+ const sourceCounts = new Map<string, number>();
193
+ for (const e of last7d) {
194
+ sourceCounts.set(e.event.source, (sourceCounts.get(e.event.source) ?? 0) + 1);
195
+ }
196
+ const topSources = Array.from(sourceCounts.entries())
197
+ .sort((a, b) => b[1] - a[1])
198
+ .slice(0, 5)
199
+ .map(([source]) => source);
200
+
201
+ return {
202
+ eventsPerDay7d: last7d.length / days,
203
+ pushesPerDay7d: pushes.length / days,
204
+ dropRate7d: last7d.length > 0 ? drops.length / last7d.length : 0,
205
+ topSources,
206
+ };
207
+ }
208
+
209
+ // -- Helpers --
210
+
211
+ function average(values: number[]): number | null {
212
+ if (values.length === 0) return null;
213
+ return values.reduce((a, b) => a + b, 0) / values.length;
214
+ }
215
+
216
+ function median(values: number[]): number {
217
+ const sorted = [...values].sort((a, b) => a - b);
218
+ const mid = Math.floor(sorted.length / 2);
219
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
220
+ }
221
+
222
+ function formatHour(h: number): string {
223
+ const hours = Math.floor(h);
224
+ const mins = Math.round((h - hours) * 60);
225
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
226
+ }
227
+
228
+ function computeTrend(
229
+ recent: number | null,
230
+ baseline: number | null,
231
+ ): "improving" | "stable" | "declining" | null {
232
+ if (recent == null || baseline == null) return null;
233
+ const ratio = recent / baseline;
234
+ if (ratio > 1.1) return "improving";
235
+ if (ratio < 0.9) return "declining";
236
+ return "stable";
237
+ }
238
+
239
+ function computeInverseTrend(
240
+ recent: number | null,
241
+ baseline: number | null,
242
+ ): "improving" | "stable" | "declining" | null {
243
+ if (recent == null || baseline == null) return null;
244
+ const ratio = recent / baseline;
245
+ if (ratio < 0.9) return "improving";
246
+ if (ratio > 1.1) return "declining";
247
+ return "stable";
248
+ }