@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/LICENSE +661 -0
- package/README.md +125 -0
- package/openclaw-plugin-sdk.md +38 -0
- package/openclaw.plugin.json +34 -0
- package/package.json +20 -0
- package/skills/betterclaw/SKILL.md +31 -0
- package/src/context.ts +219 -0
- package/src/events.ts +67 -0
- package/src/filter.ts +93 -0
- package/src/index.ts +251 -0
- package/src/judgment.ts +145 -0
- package/src/patterns.ts +248 -0
- package/src/pipeline.ts +169 -0
- package/src/tools/get-context.ts +37 -0
- package/src/triggers.ts +225 -0
- package/src/types.ts +129 -0
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
|
+
};
|
package/src/judgment.ts
ADDED
|
@@ -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
|
+
}
|
package/src/patterns.ts
ADDED
|
@@ -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
|
+
}
|