@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.
@@ -0,0 +1,169 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { ContextManager } from "./context.js";
3
+ import type { EventLog } from "./events.js";
4
+ import type { RulesEngine } from "./filter.js";
5
+ import type { JudgmentLayer } from "./judgment.js";
6
+ import type { DeviceEvent, DeviceContext, PluginConfig } from "./types.js";
7
+
8
+ export interface PipelineDeps {
9
+ api: OpenClawPluginApi;
10
+ config: PluginConfig;
11
+ context: ContextManager;
12
+ events: EventLog;
13
+ rules: RulesEngine;
14
+ judgment: JudgmentLayer;
15
+ }
16
+
17
+ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Promise<void> {
18
+ const { api, config, context, events, rules, judgment } = deps;
19
+
20
+ // Always update context
21
+ context.updateFromEvent(event);
22
+
23
+ // Run rules engine
24
+ let decision = rules.evaluate(event, context.get());
25
+
26
+ // If ambiguous, run LLM judgment
27
+ if (decision.action === "ambiguous") {
28
+ const result = await judgment.evaluate(event, context.get());
29
+ decision = result.push
30
+ ? { action: "push" as const, reason: `llm: ${result.reason}` }
31
+ : { action: "drop" as const, reason: `llm: ${result.reason}` };
32
+ }
33
+
34
+ // Log the event + decision
35
+ await events.append({
36
+ event,
37
+ decision: decision.action === "push" ? "push" : decision.action === "defer" ? "defer" : "drop",
38
+ reason: decision.reason,
39
+ timestamp: Date.now() / 1000,
40
+ });
41
+
42
+ // If push, inject into agent session
43
+ if (decision.action === "push") {
44
+ rules.recordFired(event.subscriptionId, event.firedAt);
45
+ context.recordPush();
46
+
47
+ // If the iOS tunnel already pushed this event directly to the agent
48
+ // session (always-push events like geofence/battery-critical), skip
49
+ // delivery to avoid double-sending. Context was already updated above.
50
+ if (event.data._alreadyPushed === 1.0) {
51
+ api.logger.info(`betterclaw: skipped push for ${event.subscriptionId} (already pushed by tunnel)`);
52
+ } else {
53
+ const message = formatEnrichedMessage(event, context);
54
+
55
+ try {
56
+ await api.runtime.subagent.run({
57
+ sessionKey: "main",
58
+ message,
59
+ deliver: true,
60
+ idempotencyKey: `event-${event.subscriptionId}-${Math.floor(event.firedAt)}`,
61
+ });
62
+
63
+ api.logger.info(`betterclaw: pushed event ${event.subscriptionId} to agent`);
64
+ } catch (err) {
65
+ api.logger.error(
66
+ `betterclaw: failed to push to agent: ${err instanceof Error ? err.message : String(err)}`,
67
+ );
68
+ }
69
+ }
70
+ } else {
71
+ api.logger.info(`betterclaw: ${decision.action} event ${event.subscriptionId}: ${decision.reason}`);
72
+ }
73
+
74
+ // Persist context
75
+ await context.save();
76
+ }
77
+
78
+ function formatEnrichedMessage(event: DeviceEvent, context: ContextManager): string {
79
+ const state = context.get();
80
+ const body = formatEventBody(event);
81
+ const contextSummary = formatContextSummary(state);
82
+
83
+ const prefix =
84
+ event.data._debugFired === 1.0
85
+ ? "[DEBUG test event fired manually from BetterClaw iOS debug menu — not a real device event. You MUST respond to confirm the pipeline is working.]"
86
+ : "[BetterClaw device event — processed by context plugin]";
87
+
88
+ return `${prefix}\n\n${body}\n\nCurrent context: ${contextSummary}`;
89
+ }
90
+
91
+ function formatEventBody(event: DeviceEvent): string {
92
+ const data = event.data;
93
+ const id = event.subscriptionId;
94
+
95
+ switch (id) {
96
+ case "default.battery-low": {
97
+ const level = data.level != null ? Math.round(data.level * 100) : "?";
98
+ return `🔋 Battery at ${level}% (threshold: <20%)`;
99
+ }
100
+ case "default.battery-critical": {
101
+ const level = data.level != null ? Math.round(data.level * 100) : "?";
102
+ return `đŸĒĢ Battery at ${level}% (threshold: <10%)`;
103
+ }
104
+ case "default.daily-health": {
105
+ const parts: string[] = [];
106
+ if (data.stepsToday != null) parts.push(`Steps: ${Math.round(data.stepsToday).toLocaleString()}`);
107
+ if (data.distanceMeters != null) parts.push(`Distance: ${(data.distanceMeters / 1000).toFixed(1)}km`);
108
+ if (data.heartRateAvg != null) parts.push(`Avg HR: ${Math.round(data.heartRateAvg)}bpm`);
109
+ if (data.sleepDurationSeconds != null) {
110
+ const h = Math.floor(data.sleepDurationSeconds / 3600);
111
+ const m = Math.floor((data.sleepDurationSeconds % 3600) / 60);
112
+ parts.push(`Sleep: ${h}h ${m}m`);
113
+ }
114
+ const summary = parts.length ? parts.join(" | ") : "No data";
115
+ return `đŸĨ Daily health summary — ${summary}`;
116
+ }
117
+ default: {
118
+ if (event.source === "geofence.triggered") {
119
+ const type = data.type === 1 ? "enter" : "exit";
120
+ const emoji = type === "enter" ? "📍" : "đŸšļ";
121
+ const zone = event.metadata?.zoneName;
122
+ return zone
123
+ ? `${emoji} Geofence ${type}: ${zone}`
124
+ : `${emoji} Geofence ${type}`;
125
+ }
126
+ if (event.source.startsWith("health")) {
127
+ const pairs = Object.entries(data)
128
+ .filter(([k]) => k !== "_debugFired" && k !== "updatedAt")
129
+ .map(([k, v]) => `${k}: ${v}`)
130
+ .join(", ");
131
+ return `đŸĨ Health event — ${pairs}`;
132
+ }
133
+ const pairs = Object.entries(data)
134
+ .filter(([k]) => k !== "_debugFired")
135
+ .map(([k, v]) => `${k}: ${v}`)
136
+ .join(", ");
137
+ return `📡 ${event.source} — ${pairs}`;
138
+ }
139
+ }
140
+ }
141
+
142
+ function formatContextSummary(state: DeviceContext): string {
143
+ const parts: string[] = [];
144
+
145
+ if (state.activity.currentZone) {
146
+ const since = state.activity.zoneEnteredAt
147
+ ? `since ${formatDuration(Date.now() / 1000 - state.activity.zoneEnteredAt)}`
148
+ : "";
149
+ parts.push(`At ${state.activity.currentZone} ${since}`.trim());
150
+ }
151
+
152
+ if (state.device.health?.stepsToday) {
153
+ parts.push(`${Math.round(state.device.health.stepsToday).toLocaleString()} steps today`);
154
+ }
155
+
156
+ if (state.device.battery) {
157
+ parts.push(`Battery ${Math.round(state.device.battery.level * 100)}% (${state.device.battery.state})`);
158
+ }
159
+
160
+ return parts.length ? parts.join(". ") + "." : "No context available.";
161
+ }
162
+
163
+ function formatDuration(seconds: number): string {
164
+ if (seconds < 60) return "<1m";
165
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
166
+ const h = Math.floor(seconds / 3600);
167
+ const m = Math.round((seconds % 3600) / 60);
168
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
169
+ }
@@ -0,0 +1,37 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { ContextManager } from "../context.js";
3
+
4
+ export function createGetContextTool(ctx: ContextManager) {
5
+ return {
6
+ name: "get_context",
7
+ label: "Get Device Context",
8
+ description:
9
+ "Get the current physical context of the user's iPhone — battery, location, health metrics, activity zone, patterns, and trends. Call this when you need to know about the user's physical state.",
10
+ parameters: Type.Object({
11
+ include: Type.Optional(
12
+ Type.Array(Type.String(), {
13
+ description: "Sections to include. Omit for all. Options: device, activity, patterns, meta",
14
+ }),
15
+ ),
16
+ }),
17
+ async execute(_id: string, params: Record<string, unknown>) {
18
+ const sections =
19
+ Array.isArray(params.include) && params.include.every((s) => typeof s === "string")
20
+ ? (params.include as string[])
21
+ : ["device", "activity", "patterns", "meta"];
22
+
23
+ const state = ctx.get();
24
+ const patterns = await ctx.readPatterns();
25
+
26
+ const result: Record<string, unknown> = {};
27
+ if (sections.includes("device")) result.device = state.device;
28
+ if (sections.includes("activity")) result.activity = state.activity;
29
+ if (sections.includes("patterns") && patterns) result.patterns = patterns;
30
+ if (sections.includes("meta")) result.meta = state.meta;
31
+
32
+ return {
33
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
34
+ };
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,225 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { ContextManager } from "./context.js";
3
+ import type { DeviceContext, Patterns, PluginConfig } from "./types.js";
4
+
5
+ const ONE_HOUR_MS = 60 * 60 * 1000;
6
+
7
+ interface TriggerResult {
8
+ id: string;
9
+ message: string;
10
+ priority: "low" | "normal" | "high";
11
+ }
12
+
13
+ type TriggerCheck = (ctx: DeviceContext, patterns: Patterns) => TriggerResult | null;
14
+
15
+ const TRIGGER_COOLDOWNS: Record<string, number> = {
16
+ "low-battery-away": 4 * 3600,
17
+ "unusual-inactivity": 6 * 3600,
18
+ "sleep-deficit": 24 * 3600,
19
+ "routine-deviation": 4 * 3600,
20
+ "health-weekly-digest": 7 * 86400,
21
+ };
22
+
23
+ const triggers: Array<{ id: string; schedule: "hourly" | "daily" | "weekly"; check: TriggerCheck }> = [
24
+ {
25
+ id: "low-battery-away",
26
+ schedule: "hourly",
27
+ check: (ctx, patterns) => {
28
+ const battery = ctx.device.battery;
29
+ if (!battery || battery.level >= 0.3) return null;
30
+ if (ctx.activity.currentZone === "Home") return null;
31
+
32
+ const drain = patterns.batteryPatterns.avgDrainPerHour ?? 0.04;
33
+ const hoursRemaining = drain > 0 ? Math.round(battery.level / drain) : 0;
34
+
35
+ return {
36
+ id: "low-battery-away",
37
+ message: `🔋 Battery at ${Math.round(battery.level * 100)}%, draining ~${Math.round(drain * 100)}%/hr. You're away from home — estimated ${hoursRemaining}h remaining. Consider charging.`,
38
+ priority: battery.level < 0.15 ? "high" : "normal",
39
+ };
40
+ },
41
+ },
42
+ {
43
+ id: "unusual-inactivity",
44
+ schedule: "hourly",
45
+ check: (ctx, patterns) => {
46
+ const hour = new Date().getHours();
47
+ if (hour < 12) return null;
48
+
49
+ const steps = ctx.device.health?.stepsToday;
50
+ const avg = patterns.healthTrends.stepsAvg7d;
51
+ if (steps == null || avg == null) return null;
52
+
53
+ const expectedByNow = avg * (hour / 24);
54
+ if (steps >= expectedByNow * 0.5) return null;
55
+
56
+ return {
57
+ id: "unusual-inactivity",
58
+ message: `đŸšļ It's ${hour}:00 and you've done ${Math.round(steps).toLocaleString()} steps (usually ~${Math.round(expectedByNow).toLocaleString()} by now). Everything okay?`,
59
+ priority: "low",
60
+ };
61
+ },
62
+ },
63
+ {
64
+ id: "sleep-deficit",
65
+ schedule: "daily",
66
+ check: (ctx, patterns) => {
67
+ const hour = new Date().getHours();
68
+ if (hour < 7 || hour > 10) return null;
69
+
70
+ const sleep = ctx.device.health?.sleepDurationSeconds;
71
+ const avg = patterns.healthTrends.sleepAvg7d;
72
+ if (sleep == null || avg == null) return null;
73
+
74
+ const deficit = avg - sleep;
75
+ if (deficit < 3600) return null;
76
+
77
+ const sleepH = Math.floor(sleep / 3600);
78
+ const sleepM = Math.round((sleep % 3600) / 60);
79
+ const avgH = Math.floor(avg / 3600);
80
+ const avgM = Math.round((avg % 3600) / 60);
81
+
82
+ return {
83
+ id: "sleep-deficit",
84
+ message: `😴 You slept ${sleepH}h${sleepM}m last night (your average is ${avgH}h${avgM}m). Might want to take it easy today.`,
85
+ priority: "low",
86
+ };
87
+ },
88
+ },
89
+ {
90
+ id: "routine-deviation",
91
+ schedule: "hourly",
92
+ check: (ctx, patterns) => {
93
+ const now = new Date();
94
+ const day = now.getDay();
95
+ const isWeekday = day >= 1 && day <= 5;
96
+ if (!isWeekday) return null;
97
+
98
+ const hour = now.getHours() + now.getMinutes() / 60;
99
+ const routines = patterns.locationRoutines.weekday;
100
+
101
+ for (const routine of routines) {
102
+ if (!routine.typicalLeave) continue;
103
+ const [h, m] = routine.typicalLeave.split(":").map(Number);
104
+ const typicalLeaveHour = h + m / 60;
105
+
106
+ if (
107
+ ctx.activity.currentZone === routine.zone &&
108
+ hour > typicalLeaveHour + 1.5
109
+ ) {
110
+ return {
111
+ id: "routine-deviation",
112
+ message: `📅 It's ${now.getHours()}:${String(now.getMinutes()).padStart(2, "0")} on ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day]} and you haven't left ${routine.zone} (usually leave at ${routine.typicalLeave}). Just noting in case.`,
113
+ priority: "low",
114
+ };
115
+ }
116
+ }
117
+
118
+ return null;
119
+ },
120
+ },
121
+ {
122
+ id: "health-weekly-digest",
123
+ schedule: "weekly",
124
+ check: (ctx, patterns) => {
125
+ if (new Date().getDay() !== 0) return null;
126
+ const hour = new Date().getHours();
127
+ if (hour < 9 || hour > 11) return null;
128
+
129
+ const trends = patterns.healthTrends;
130
+ const stats = patterns.eventStats;
131
+
132
+ const parts: string[] = [];
133
+ if (trends.stepsAvg7d != null) {
134
+ const trend = trends.stepsTrend ? ` (${trends.stepsTrend})` : "";
135
+ parts.push(`Avg steps: ${Math.round(trends.stepsAvg7d).toLocaleString()}/day${trend}`);
136
+ }
137
+ if (trends.sleepAvg7d != null) {
138
+ const h = Math.floor(trends.sleepAvg7d / 3600);
139
+ const m = Math.round((trends.sleepAvg7d % 3600) / 60);
140
+ parts.push(`Avg sleep: ${h}h${m}m`);
141
+ }
142
+ if (trends.restingHrAvg7d != null) {
143
+ parts.push(`Resting HR: ${Math.round(trends.restingHrAvg7d)}bpm`);
144
+ }
145
+ parts.push(`Events: ${stats.eventsPerDay7d.toFixed(1)}/day, ${Math.round(stats.dropRate7d * 100)}% filtered`);
146
+
147
+ return {
148
+ id: "health-weekly-digest",
149
+ message: `📊 Weekly health digest\n\n${parts.join("\n")}`,
150
+ priority: "low",
151
+ };
152
+ },
153
+ },
154
+ ];
155
+
156
+ export class ProactiveEngine {
157
+ private context: ContextManager;
158
+ private api: OpenClawPluginApi;
159
+ private config: PluginConfig;
160
+ private interval: ReturnType<typeof setInterval> | null = null;
161
+
162
+ constructor(context: ContextManager, api: OpenClawPluginApi, config: PluginConfig) {
163
+ this.context = context;
164
+ this.api = api;
165
+ this.config = config;
166
+ }
167
+
168
+ startSchedule(): void {
169
+ if (!this.config.proactiveEnabled) return;
170
+
171
+ this.interval = setInterval(() => {
172
+ void this.checkAll().catch((err) => {
173
+ this.api.logger.error(`betterclaw: proactive check failed: ${err}`);
174
+ });
175
+ }, ONE_HOUR_MS);
176
+ this.interval.unref?.();
177
+
178
+ // Run initial check after 5 minutes (let context populate)
179
+ setTimeout(() => {
180
+ void this.checkAll().catch(() => {});
181
+ }, 5 * 60 * 1000).unref?.();
182
+ }
183
+
184
+ stopSchedule(): void {
185
+ if (this.interval) {
186
+ clearInterval(this.interval);
187
+ this.interval = null;
188
+ }
189
+ }
190
+
191
+ async checkAll(): Promise<void> {
192
+ const ctx = this.context.get();
193
+ const patterns = (await this.context.readPatterns()) ?? (await import("./patterns.js")).emptyPatterns();
194
+
195
+ for (const trigger of triggers) {
196
+ const lastFired = patterns.triggerCooldowns[trigger.id] ?? 0;
197
+ const cooldown = TRIGGER_COOLDOWNS[trigger.id] ?? 3600;
198
+ if (Date.now() / 1000 - lastFired < cooldown) continue;
199
+
200
+ const result = trigger.check(ctx, patterns);
201
+ if (!result) continue;
202
+
203
+ this.api.logger.info(`betterclaw: proactive trigger fired: ${trigger.id}`);
204
+
205
+ // Write cooldown BEFORE push to prevent runaway retries on failure
206
+ patterns.triggerCooldowns[trigger.id] = Date.now() / 1000;
207
+ await this.context.writePatterns(patterns);
208
+
209
+ const message = `[BetterClaw proactive insight — combined signal analysis]\n\n${result.message}`;
210
+
211
+ try {
212
+ await this.api.runtime.subagent.run({
213
+ sessionKey: "main",
214
+ message,
215
+ deliver: true,
216
+ idempotencyKey: `trigger-${trigger.id}-${Math.floor(Date.now() / 1000)}`,
217
+ });
218
+ } catch (err) {
219
+ this.api.logger.error(
220
+ `betterclaw: trigger push failed: ${err instanceof Error ? err.message : String(err)}`,
221
+ );
222
+ }
223
+ }
224
+ }
225
+ }
package/src/types.ts ADDED
@@ -0,0 +1,129 @@
1
+ // -- Incoming event from iOS --
2
+
3
+ export interface DeviceEvent {
4
+ subscriptionId: string;
5
+ source: string;
6
+ data: Record<string, number>;
7
+ metadata?: Record<string, string>;
8
+ firedAt: number;
9
+ }
10
+
11
+ // -- Context state (context.json) --
12
+
13
+ export interface BatteryState {
14
+ level: number;
15
+ state: string;
16
+ isLowPowerMode: boolean;
17
+ updatedAt: number;
18
+ }
19
+
20
+ export interface LocationState {
21
+ latitude: number;
22
+ longitude: number;
23
+ horizontalAccuracy: number;
24
+ label: string | null;
25
+ updatedAt: number;
26
+ }
27
+
28
+ export interface HealthState {
29
+ stepsToday: number | null;
30
+ distanceMeters: number | null;
31
+ heartRateAvg: number | null;
32
+ restingHeartRate: number | null;
33
+ hrv: number | null;
34
+ activeEnergyKcal: number | null;
35
+ sleepDurationSeconds: number | null;
36
+ updatedAt: number;
37
+ }
38
+
39
+ export interface ActivityState {
40
+ currentZone: string | null;
41
+ zoneEnteredAt: number | null;
42
+ lastTransition: {
43
+ from: string | null;
44
+ to: string | null;
45
+ at: number;
46
+ } | null;
47
+ isStationary: boolean;
48
+ stationarySince: number | null;
49
+ }
50
+
51
+ export interface ContextMeta {
52
+ lastEventAt: number;
53
+ eventsToday: number;
54
+ lastAgentPushAt: number;
55
+ pushesToday: number;
56
+ }
57
+
58
+ export interface DeviceContext {
59
+ device: {
60
+ battery: BatteryState | null;
61
+ location: LocationState | null;
62
+ health: HealthState | null;
63
+ };
64
+ activity: ActivityState;
65
+ meta: ContextMeta;
66
+ }
67
+
68
+ // -- Filter decisions --
69
+
70
+ export type FilterDecision =
71
+ | { action: "push"; reason: string }
72
+ | { action: "drop"; reason: string }
73
+ | { action: "defer"; reason: string }
74
+ | { action: "ambiguous"; reason: string };
75
+
76
+ // -- Event log entry --
77
+
78
+ export interface EventLogEntry {
79
+ event: DeviceEvent;
80
+ decision: "push" | "drop" | "defer";
81
+ reason: string;
82
+ timestamp: number;
83
+ }
84
+
85
+ // -- Patterns --
86
+
87
+ export interface LocationRoutine {
88
+ zone: string;
89
+ typicalLeave: string | null;
90
+ typicalArrive: string | null;
91
+ }
92
+
93
+ export interface Patterns {
94
+ locationRoutines: {
95
+ weekday: LocationRoutine[];
96
+ weekend: LocationRoutine[];
97
+ };
98
+ healthTrends: {
99
+ stepsAvg7d: number | null;
100
+ stepsAvg30d: number | null;
101
+ stepsTrend: "improving" | "stable" | "declining" | null;
102
+ sleepAvg7d: number | null;
103
+ sleepTrend: "improving" | "stable" | "declining" | null;
104
+ restingHrAvg7d: number | null;
105
+ restingHrTrend: "improving" | "stable" | "declining" | null;
106
+ };
107
+ batteryPatterns: {
108
+ avgDrainPerHour: number | null;
109
+ typicalChargeTime: string | null;
110
+ lowBatteryFrequency: number | null;
111
+ };
112
+ eventStats: {
113
+ eventsPerDay7d: number;
114
+ pushesPerDay7d: number;
115
+ dropRate7d: number;
116
+ topSources: string[];
117
+ };
118
+ triggerCooldowns: Record<string, number>;
119
+ computedAt: number;
120
+ }
121
+
122
+ // -- Plugin config --
123
+
124
+ export interface PluginConfig {
125
+ llmModel: string;
126
+ pushBudgetPerDay: number;
127
+ patternWindowDays: number;
128
+ proactiveEnabled: boolean;
129
+ }