@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.
@@ -1,34 +1,55 @@
1
1
  {
2
2
  "id": "betterclaw",
3
- "name": "BetterClaw Context",
4
- "description": "Intelligent context layer for BetterClaw iOS companion app",
5
- "version": "1.0.3",
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
- "default": "openai/gpt-4o-mini"
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
- "llmModel": { "label": "Triage LLM Model", "placeholder": "openai/gpt-4o-mini" },
31
- "pushBudgetPerDay": { "label": "Max pushes per day" },
32
- "proactiveEnabled": { "label": "Enable proactive insights" }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better_openclaw/betterclaw",
3
- "version": "1.4.0",
3
+ "version": "2.0.0",
4
4
  "description": "Intelligent event filtering, context tracking, and proactive triggers for BetterClaw",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -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
- - **Real-time sensors**: battery level/state, GPS location, health metrics (steps, heart rate, HRV, sleep, distance, energy)
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
- ## Event Messages
17
+ ## Adaptive Behavior
18
18
 
19
- You'll receive two types of automated messages:
19
+ Check `get_context().smartMode` and `get_context().tier` to understand your data flow:
20
20
 
21
- - **Device events** prefixed with `[BetterClaw device event]`. These are real sensor events that passed the plugin's filtering pipeline. They include relevant context.
22
- - **Proactive insights** prefixed with `[BetterClaw proactive insight]`. These are combined-signal analyses (e.g., low battery + away from home, unusual inactivity, sleep deficit).
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
- - Events are pre-filtered for relevance. If you receive one, it's likely worth acknowledging.
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 to the user.
30
- - When the user asks about their health, location, battery, or activity — call `get_context` first rather than relying on stale event data.
31
- - Respond with `no_reply` for routine events that don't need user attention (e.g., geofence enter at expected time).
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
- this.context = JSON.parse(raw) as DeviceContext;
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
- await fs.writeFile(this.contextPath, JSON.stringify(this.context, null, 2) + "\n", "utf8");
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 UTC
61
- const lastDay = Math.floor(this.context.meta.lastEventAt / 86400);
62
- const currentDay = Math.floor(now / 86400);
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
- .map((line) => JSON.parse(line) as EventLogEntry);
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
- await fs.writeFile(this.filePath, content, "utf8");
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: "defer", reason: "daily health summary — outside morning window" };
68
+ return { action: "drop", reason: "daily health summary — outside morning window" };
69
69
  }
70
70
 
71
71
  // Push budget check
72
- if (context.meta.pushesToday >= this.pushBudget) {
73
- return { action: "drop", reason: `push budget exhausted (${context.meta.pushesToday}/${this.pushBudget} today)` };
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