@better_openclaw/betterclaw 1.4.0 → 2.0.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/openclaw.plugin.json +33 -12
- package/package.json +1 -1
- package/skills/betterclaw/SKILL.md +11 -10
- package/src/cli.ts +29 -0
- package/src/context.ts +56 -8
- package/src/events.ts +7 -2
- package/src/filter.ts +5 -4
- package/src/index.ts +252 -99
- package/src/learner.ts +205 -0
- package/src/patterns.ts +29 -12
- package/src/pipeline.ts +116 -44
- package/src/reactions.ts +57 -0
- package/src/tools/get-context.ts +28 -5
- package/src/triage.ts +134 -0
- package/src/triggers.ts +7 -0
- package/src/types.ts +38 -3
- package/src/judgment.ts +0 -145
package/openclaw.plugin.json
CHANGED
|
@@ -1,34 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "betterclaw",
|
|
3
|
-
"name": "BetterClaw
|
|
4
|
-
"description": "Intelligent context
|
|
5
|
-
"version": "
|
|
3
|
+
"name": "BetterClaw",
|
|
4
|
+
"description": "Intelligent device context, event filtering, and proactive insights for BetterClaw iOS",
|
|
5
|
+
"version": "2.0.0",
|
|
6
|
+
"main": "dist/index.js",
|
|
6
7
|
"skills": ["./skills/betterclaw"],
|
|
7
8
|
"configSchema": {
|
|
8
9
|
"type": "object",
|
|
9
|
-
"additionalProperties": false,
|
|
10
10
|
"properties": {
|
|
11
|
+
"triageModel": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"default": "openai/gpt-4o-mini",
|
|
14
|
+
"description": "Model for per-event triage (accepts llmModel as deprecated alias)"
|
|
15
|
+
},
|
|
11
16
|
"llmModel": {
|
|
12
17
|
"type": "string",
|
|
13
|
-
"
|
|
18
|
+
"description": "Deprecated — use triageModel instead"
|
|
19
|
+
},
|
|
20
|
+
"triageApiBase": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Optional base URL for OpenAI-compatible triage endpoint (e.g. http://localhost:11434/v1 for Ollama)"
|
|
14
23
|
},
|
|
15
24
|
"pushBudgetPerDay": {
|
|
16
25
|
"type": "number",
|
|
17
|
-
"default": 10
|
|
26
|
+
"default": 10,
|
|
27
|
+
"description": "Max events forwarded to agent per day"
|
|
18
28
|
},
|
|
19
29
|
"patternWindowDays": {
|
|
20
30
|
"type": "number",
|
|
21
|
-
"default": 14
|
|
31
|
+
"default": 14,
|
|
32
|
+
"description": "Days of event history for pattern computation"
|
|
22
33
|
},
|
|
23
34
|
"proactiveEnabled": {
|
|
24
35
|
"type": "boolean",
|
|
25
|
-
"default": true
|
|
36
|
+
"default": true,
|
|
37
|
+
"description": "Enable proactive combined-signal insights"
|
|
38
|
+
},
|
|
39
|
+
"analysisHour": {
|
|
40
|
+
"type": "number",
|
|
41
|
+
"default": 5,
|
|
42
|
+
"minimum": 0,
|
|
43
|
+
"maximum": 23,
|
|
44
|
+
"description": "Hour (system timezone) for daily analysis run"
|
|
26
45
|
}
|
|
27
|
-
}
|
|
46
|
+
},
|
|
47
|
+
"additionalProperties": false
|
|
28
48
|
},
|
|
29
49
|
"uiHints": {
|
|
30
|
-
"
|
|
31
|
-
"pushBudgetPerDay": { "label": "
|
|
32
|
-
"proactiveEnabled": { "label": "
|
|
50
|
+
"triageModel": { "label": "Triage Model", "placeholder": "openai/gpt-4o-mini" },
|
|
51
|
+
"pushBudgetPerDay": { "label": "Push Budget / Day", "type": "number" },
|
|
52
|
+
"proactiveEnabled": { "label": "Proactive Insights", "type": "boolean" },
|
|
53
|
+
"analysisHour": { "label": "Analysis Hour (0-23)", "type": "number" }
|
|
33
54
|
}
|
|
34
55
|
}
|
package/package.json
CHANGED
|
@@ -9,23 +9,24 @@ You have access to the user's physical device state via BetterClaw (iOS companio
|
|
|
9
9
|
|
|
10
10
|
## Capabilities
|
|
11
11
|
|
|
12
|
-
- **
|
|
12
|
+
- **Sensors**: battery level/state, GPS location, health metrics (steps, heart rate, HRV, sleep, distance, energy)
|
|
13
13
|
- **Geofence events**: enter/exit named zones (Home, Office, etc.)
|
|
14
14
|
- **Patterns**: location routines, health trends (7d/30d), battery drain rate
|
|
15
15
|
- **Tool**: `get_context` — call anytime to read the full current device snapshot and derived patterns
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## Adaptive Behavior
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Check `get_context().smartMode` and `get_context().tier` to understand your data flow:
|
|
20
20
|
|
|
21
|
-
- **Device events
|
|
22
|
-
- **
|
|
21
|
+
- **Smart mode ON**: Device events are being pushed to you automatically, pre-filtered for relevance. If you receive one, it's worth acknowledging.
|
|
22
|
+
- **Smart mode OFF**: No events are pushed automatically. Call `get_context` when the user asks about their physical state (battery, location, health, activity) or when physical context would improve your response.
|
|
23
|
+
|
|
24
|
+
Check `get_context().triageProfile.summary` for a description of the user's notification preferences.
|
|
23
25
|
|
|
24
26
|
## Guidelines
|
|
25
27
|
|
|
26
|
-
-
|
|
27
|
-
- Use `get_context` proactively when physical context would improve your response (weather questions, schedule planning, health discussions).
|
|
28
|
+
- Use `get_context` when physical context would improve your response — don't rely on stale data.
|
|
28
29
|
- Don't parrot raw data. Synthesize naturally: "You're running low and away from home" not "Battery: 0.15, location label: null".
|
|
29
|
-
- Proactive insights are observations, not commands. Use your judgment about whether to relay them
|
|
30
|
-
-
|
|
31
|
-
-
|
|
30
|
+
- Proactive insights are observations, not commands. Use your judgment about whether to relay them.
|
|
31
|
+
- Respond with `no_reply` for routine events that don't need user attention.
|
|
32
|
+
- Check timestamps (`updatedAt`, `computedAt`) to assess data freshness.
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const BETTERCLAW_COMMANDS = [
|
|
2
|
+
"clipboard.write",
|
|
3
|
+
"device.battery",
|
|
4
|
+
"geofence.add",
|
|
5
|
+
"geofence.list",
|
|
6
|
+
"geofence.remove",
|
|
7
|
+
"health.distance",
|
|
8
|
+
"health.heartrate",
|
|
9
|
+
"health.hrv",
|
|
10
|
+
"health.restinghr",
|
|
11
|
+
"health.sleep",
|
|
12
|
+
"health.steps",
|
|
13
|
+
"health.summary",
|
|
14
|
+
"health.workouts",
|
|
15
|
+
"location.get",
|
|
16
|
+
"shortcuts.run",
|
|
17
|
+
"subscribe.add",
|
|
18
|
+
"subscribe.list",
|
|
19
|
+
"subscribe.pause",
|
|
20
|
+
"subscribe.remove",
|
|
21
|
+
"subscribe.resume",
|
|
22
|
+
"system.capabilities",
|
|
23
|
+
"system.notify",
|
|
24
|
+
].sort();
|
|
25
|
+
|
|
26
|
+
export function mergeAllowCommands(existing: string[], toAdd: string[]): string[] {
|
|
27
|
+
const set = new Set([...existing, ...toAdd]);
|
|
28
|
+
return [...set].sort();
|
|
29
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type { DeviceContext, DeviceEvent, Patterns } from "./types.js";
|
|
3
|
+
import type { DeviceConfig, DeviceContext, DeviceEvent, Patterns, RuntimeState } from "./types.js";
|
|
4
4
|
|
|
5
5
|
const CONTEXT_FILE = "context.json";
|
|
6
6
|
const PATTERNS_FILE = "patterns.json";
|
|
@@ -9,6 +9,9 @@ export class ContextManager {
|
|
|
9
9
|
private contextPath: string;
|
|
10
10
|
private patternsPath: string;
|
|
11
11
|
private context: DeviceContext;
|
|
12
|
+
private runtimeState: RuntimeState = { tier: "free", smartMode: false };
|
|
13
|
+
private timestamps: Record<string, number> = {};
|
|
14
|
+
private deviceConfig: DeviceConfig = {};
|
|
12
15
|
|
|
13
16
|
constructor(stateDir: string) {
|
|
14
17
|
this.contextPath = path.join(stateDir, CONTEXT_FILE);
|
|
@@ -38,9 +41,20 @@ export class ContextManager {
|
|
|
38
41
|
async load(): Promise<void> {
|
|
39
42
|
try {
|
|
40
43
|
const raw = await fs.readFile(this.contextPath, "utf8");
|
|
41
|
-
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
this.timestamps = parsed._timestamps ?? {};
|
|
46
|
+
delete parsed._timestamps;
|
|
47
|
+
this.context = parsed as DeviceContext;
|
|
42
48
|
} catch {
|
|
43
49
|
this.context = ContextManager.empty();
|
|
50
|
+
this.timestamps = {};
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const configPath = path.join(path.dirname(this.contextPath), "device-config.json");
|
|
54
|
+
const rawConfig = await fs.readFile(configPath, "utf8");
|
|
55
|
+
this.deviceConfig = JSON.parse(rawConfig) as DeviceConfig;
|
|
56
|
+
} catch {
|
|
57
|
+
this.deviceConfig = {};
|
|
44
58
|
}
|
|
45
59
|
}
|
|
46
60
|
|
|
@@ -48,18 +62,27 @@ export class ContextManager {
|
|
|
48
62
|
return this.context;
|
|
49
63
|
}
|
|
50
64
|
|
|
65
|
+
getTimestamp(field: string): number | undefined {
|
|
66
|
+
return this.timestamps[field];
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
async save(): Promise<void> {
|
|
52
70
|
await fs.mkdir(path.dirname(this.contextPath), { recursive: true });
|
|
53
|
-
|
|
71
|
+
const data = { ...this.context, _timestamps: this.timestamps };
|
|
72
|
+
await fs.writeFile(this.contextPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
73
|
+
const configPath = path.join(path.dirname(this.contextPath), "device-config.json");
|
|
74
|
+
await fs.writeFile(configPath, JSON.stringify(this.deviceConfig, null, 2) + "\n", "utf8");
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
updateFromEvent(event: DeviceEvent): void {
|
|
57
78
|
const now = event.firedAt;
|
|
58
79
|
const data = event.data;
|
|
59
80
|
|
|
60
|
-
// Reset daily counters at midnight
|
|
61
|
-
const
|
|
62
|
-
const
|
|
81
|
+
// Reset daily counters at local midnight
|
|
82
|
+
const lastDate = new Date(this.context.meta.lastEventAt * 1000);
|
|
83
|
+
const currentDate = new Date(now * 1000);
|
|
84
|
+
const lastDay = `${lastDate.getFullYear()}-${lastDate.getMonth()}-${lastDate.getDate()}`;
|
|
85
|
+
const currentDay = `${currentDate.getFullYear()}-${currentDate.getMonth()}-${currentDate.getDate()}`;
|
|
63
86
|
if (lastDay !== currentDay && this.context.meta.lastEventAt > 0) {
|
|
64
87
|
this.context.meta.eventsToday = 0;
|
|
65
88
|
this.context.meta.pushesToday = 0;
|
|
@@ -76,6 +99,7 @@ export class ContextManager {
|
|
|
76
99
|
isLowPowerMode: (data.isLowPowerMode ?? 0) === 1,
|
|
77
100
|
updatedAt: data.updatedAt ?? now,
|
|
78
101
|
};
|
|
102
|
+
this.timestamps.battery = event.firedAt;
|
|
79
103
|
break;
|
|
80
104
|
|
|
81
105
|
case "geofence.triggered": {
|
|
@@ -112,6 +136,8 @@ export class ContextManager {
|
|
|
112
136
|
label: this.context.activity.currentZone,
|
|
113
137
|
updatedAt: data.timestamp ?? now,
|
|
114
138
|
};
|
|
139
|
+
this.timestamps.location = event.firedAt;
|
|
140
|
+
this.timestamps.activity = event.firedAt;
|
|
115
141
|
break;
|
|
116
142
|
}
|
|
117
143
|
|
|
@@ -127,6 +153,7 @@ export class ContextManager {
|
|
|
127
153
|
sleepDurationSeconds: data.sleepDurationSeconds ?? this.context.device.health?.sleepDurationSeconds ?? null,
|
|
128
154
|
updatedAt: data.updatedAt ?? now,
|
|
129
155
|
};
|
|
156
|
+
this.timestamps.health = event.firedAt;
|
|
130
157
|
}
|
|
131
158
|
break;
|
|
132
159
|
}
|
|
@@ -141,8 +168,8 @@ export class ContextManager {
|
|
|
141
168
|
sleepDurationSeconds?: number;
|
|
142
169
|
};
|
|
143
170
|
geofence?: { type: string; zoneName: string; latitude: number; longitude: number };
|
|
144
|
-
}): void {
|
|
145
|
-
const now = Date.now() / 1000;
|
|
171
|
+
}, timestamp?: number): void {
|
|
172
|
+
const now = timestamp ?? Date.now() / 1000;
|
|
146
173
|
|
|
147
174
|
if (snapshot.battery) {
|
|
148
175
|
this.context.device.battery = {
|
|
@@ -151,6 +178,7 @@ export class ContextManager {
|
|
|
151
178
|
isLowPowerMode: snapshot.battery.isLowPowerMode,
|
|
152
179
|
updatedAt: now,
|
|
153
180
|
};
|
|
181
|
+
this.timestamps.battery = now;
|
|
154
182
|
}
|
|
155
183
|
|
|
156
184
|
if (snapshot.location) {
|
|
@@ -161,6 +189,7 @@ export class ContextManager {
|
|
|
161
189
|
label: this.context.activity.currentZone,
|
|
162
190
|
updatedAt: now,
|
|
163
191
|
};
|
|
192
|
+
this.timestamps.location = now;
|
|
164
193
|
}
|
|
165
194
|
|
|
166
195
|
if (snapshot.health) {
|
|
@@ -174,6 +203,7 @@ export class ContextManager {
|
|
|
174
203
|
sleepDurationSeconds: snapshot.health.sleepDurationSeconds ?? null,
|
|
175
204
|
updatedAt: now,
|
|
176
205
|
};
|
|
206
|
+
this.timestamps.health = now;
|
|
177
207
|
}
|
|
178
208
|
|
|
179
209
|
if (snapshot.geofence) {
|
|
@@ -196,6 +226,24 @@ export class ContextManager {
|
|
|
196
226
|
this.context.device.location.label = this.context.activity.currentZone;
|
|
197
227
|
}
|
|
198
228
|
}
|
|
229
|
+
|
|
230
|
+
this.timestamps.lastSnapshot = now;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
getRuntimeState(): RuntimeState {
|
|
234
|
+
return { ...this.runtimeState };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
setRuntimeState(state: RuntimeState): void {
|
|
238
|
+
this.runtimeState = { ...state };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
getDeviceConfig(): DeviceConfig {
|
|
242
|
+
return { ...this.deviceConfig };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
setDeviceConfig(update: DeviceConfig): void {
|
|
246
|
+
this.deviceConfig = { ...this.deviceConfig, ...update };
|
|
199
247
|
}
|
|
200
248
|
|
|
201
249
|
recordPush(): void {
|
package/src/events.ts
CHANGED
|
@@ -26,7 +26,10 @@ export class EventLog {
|
|
|
26
26
|
.trim()
|
|
27
27
|
.split("\n")
|
|
28
28
|
.filter((line) => line.length > 0)
|
|
29
|
-
.
|
|
29
|
+
.flatMap((line) => {
|
|
30
|
+
try { return [JSON.parse(line) as EventLogEntry]; }
|
|
31
|
+
catch { return []; }
|
|
32
|
+
});
|
|
30
33
|
} catch {
|
|
31
34
|
return [];
|
|
32
35
|
}
|
|
@@ -51,7 +54,9 @@ export class EventLog {
|
|
|
51
54
|
const removed = entries.length - kept.length;
|
|
52
55
|
|
|
53
56
|
const content = kept.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
54
|
-
|
|
57
|
+
const tmpPath = this.filePath + ".tmp";
|
|
58
|
+
await fs.writeFile(tmpPath, content, "utf8");
|
|
59
|
+
await fs.rename(tmpPath, this.filePath);
|
|
55
60
|
|
|
56
61
|
return removed;
|
|
57
62
|
}
|
package/src/filter.ts
CHANGED
|
@@ -18,7 +18,7 @@ export class RulesEngine {
|
|
|
18
18
|
this.pushBudget = pushBudget;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
evaluate(event: DeviceEvent, context: DeviceContext): FilterDecision {
|
|
21
|
+
evaluate(event: DeviceEvent, context: DeviceContext, budgetOverride?: number): FilterDecision {
|
|
22
22
|
// Debug events always pass
|
|
23
23
|
if (event.data._debugFired === 1.0) {
|
|
24
24
|
return { action: "push", reason: "debug event — always push" };
|
|
@@ -65,12 +65,13 @@ export class RulesEngine {
|
|
|
65
65
|
if (hour >= 6 && hour <= 10) {
|
|
66
66
|
return { action: "push", reason: "daily health summary — within morning window" };
|
|
67
67
|
}
|
|
68
|
-
return { action: "
|
|
68
|
+
return { action: "drop", reason: "daily health summary — outside morning window" };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Push budget check
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const budget = budgetOverride ?? this.pushBudget;
|
|
73
|
+
if (context.meta.pushesToday >= budget) {
|
|
74
|
+
return { action: "drop", reason: `push budget exhausted (${context.meta.pushesToday}/${budget} today)` };
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
// Anything else is ambiguous — forward to LLM judgment
|