@better_openclaw/betterclaw 2.2.2 → 3.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.
@@ -2,7 +2,7 @@
2
2
  "id": "betterclaw",
3
3
  "name": "BetterClaw",
4
4
  "description": "Intelligent device context, event filtering, and proactive insights for BetterClaw iOS",
5
- "version": "2.0.0",
5
+ "version": "3.0.0",
6
6
  "main": "dist/index.js",
7
7
  "skills": ["./skills/betterclaw"],
8
8
  "configSchema": {
@@ -52,6 +52,13 @@
52
52
  "type": "number",
53
53
  "default": 1800,
54
54
  "description": "Default dedup cooldown in seconds for subscriptions not in deduplicationCooldowns"
55
+ },
56
+ "calibrationDays": {
57
+ "type": "number",
58
+ "default": 3,
59
+ "minimum": 1,
60
+ "maximum": 14,
61
+ "description": "Days of calibration before learner profile influences triage decisions"
55
62
  }
56
63
  },
57
64
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better_openclaw/betterclaw",
3
- "version": "2.2.2",
3
+ "version": "3.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": {
@@ -1,32 +1,48 @@
1
1
  ---
2
2
  name: BetterClaw Device Context
3
- description: Instructions for handling physical device events and context from BetterClaw iOS
3
+ description: Instructions for accessing physical device state from BetterClaw iOS
4
4
  ---
5
5
 
6
6
  # BetterClaw Device Context
7
7
 
8
8
  You have access to the user's physical device state via BetterClaw (iOS companion app).
9
9
 
10
- ## Capabilities
10
+ ## How to access device data
11
11
 
12
- - **Sensors**: battery level/state, GPS location, health metrics (steps, heart rate, HRV, sleep, distance, energy)
13
- - **Geofence events**: enter/exit named zones (Home, Office, etc.)
14
- - **Patterns**: location routines, health trends (7d/30d), battery drain rate
15
- - **Tool**: `get_context` — call anytime to read the full current device snapshot and derived patterns
12
+ 1. Call `check_tier` (or use your cached tier if still valid).
13
+ 2. Follow the `dataPath` instructions in the response.
16
14
 
17
- ## Adaptive Behavior
15
+ ### Premium tier
18
16
 
19
- Check `get_context().smartMode` and `get_context().tier` to understand your data flow:
17
+ You have two complementary tools:
20
18
 
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.
19
+ - **Node commands** (`location.get`, `device.battery`, `health.*`, etc.)
20
+ return live data directly from the device. Use these when the user asks
21
+ for current state — "where am I?", "what's my battery?", "how many steps?"
23
22
 
24
- Check `get_context().triageProfile.summary` for a description of the user's notification preferences.
23
+ - **`get_context`** returns patterns, trends, activity zone, event history,
24
+ and a cached device snapshot. The snapshot may not be perfectly recent.
25
+ Use this for contextual awareness — understanding routines, spotting
26
+ anomalies, checking trends, or getting a broad picture without
27
+ querying each sensor individually.
28
+
29
+ Both are useful. Node commands for precision, `get_context` for the big picture.
30
+
31
+ ### Free tier
32
+
33
+ `get_context` is the only data source. It returns a cached snapshot from the
34
+ last time the user had BetterClaw open — check timestamps for freshness.
35
+ If data is more than 1 hour old, mention that it may be outdated.
36
+
37
+ ## Pushed events (premium only)
38
+
39
+ When smart mode is on, you may receive proactive alerts about the user's
40
+ physical state. These are pre-filtered for relevance — if you receive one,
41
+ it's worth acknowledging naturally. Don't parrot raw data.
25
42
 
26
43
  ## Guidelines
27
44
 
28
- - Use `get_context` when physical context would improve your response — don't rely on stale data.
29
- - Don't parrot raw data. Synthesize naturally: "You're running low and away from home" not "Battery: 0.15, location label: null".
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.
45
+ - Synthesize naturally: "You're running low and away from home" not
46
+ "Battery: 0.15, location: 48.1234, 11.5678"
47
+ - Check timestamps to assess data freshness before acting on data
48
+ - Respond with `no_reply` for routine events that need no user attention
package/src/context.ts CHANGED
@@ -66,6 +66,16 @@ export class ContextManager {
66
66
  return this.timestamps[field];
67
67
  }
68
68
 
69
+ /** Returns age of each device data section in seconds, or null if no data */
70
+ getDataAge(): { battery: number | null; location: number | null; health: number | null } {
71
+ const now = Date.now() / 1000;
72
+ return {
73
+ battery: this.timestamps.battery != null ? Math.round(now - this.timestamps.battery) : null,
74
+ location: this.timestamps.location != null ? Math.round(now - this.timestamps.location) : null,
75
+ health: this.timestamps.health != null ? Math.round(now - this.timestamps.health) : null,
76
+ };
77
+ }
78
+
69
79
  async save(): Promise<void> {
70
80
  await fs.mkdir(path.dirname(this.contextPath), { recursive: true });
71
81
  const data = { ...this.context, _timestamps: this.timestamps };
@@ -81,8 +91,8 @@ export class ContextManager {
81
91
  // Reset daily counters at local midnight
82
92
  const lastDate = new Date(this.context.meta.lastEventAt * 1000);
83
93
  const currentDate = new Date(now * 1000);
84
- const lastDay = `${lastDate.getFullYear()}-${lastDate.getMonth()}-${lastDate.getDate()}`;
85
- const currentDay = `${currentDate.getFullYear()}-${currentDate.getMonth()}-${currentDate.getDate()}`;
94
+ const lastDay = `${lastDate.getFullYear()}-${String(lastDate.getMonth() + 1).padStart(2, "0")}-${String(lastDate.getDate()).padStart(2, "0")}`;
95
+ const currentDay = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`;
86
96
  if (lastDay !== currentDay && this.context.meta.lastEventAt > 0) {
87
97
  this.context.meta.eventsToday = 0;
88
98
  this.context.meta.pushesToday = 0;
package/src/index.ts CHANGED
@@ -5,13 +5,15 @@ import { createGetContextTool } from "./tools/get-context.js";
5
5
  import { EventLog } from "./events.js";
6
6
  import { RulesEngine } from "./filter.js";
7
7
  import { PatternEngine } from "./patterns.js";
8
- import { ProactiveEngine } from "./triggers.js";
9
8
  import { processEvent } from "./pipeline.js";
10
9
  import type { PipelineDeps } from "./pipeline.js";
11
10
  import { BETTERCLAW_COMMANDS, mergeAllowCommands } from "./cli.js";
12
11
  import { storeJwt } from "./jwt.js";
13
12
  import { loadTriageProfile, runLearner } from "./learner.js";
14
13
  import { ReactionTracker } from "./reactions.js";
14
+ import { createCheckTierTool } from "./tools/check-tier.js";
15
+ import { scanPendingReactions } from "./reaction-scanner.js";
16
+ import * as fs from "node:fs/promises";
15
17
  import * as os from "node:os";
16
18
  import * as path from "node:path";
17
19
 
@@ -33,6 +35,7 @@ const DEFAULT_CONFIG: PluginConfig = {
33
35
  analysisHour: 5,
34
36
  deduplicationCooldowns: DEFAULT_COOLDOWNS,
35
37
  defaultCooldown: 1800,
38
+ calibrationDays: 3,
36
39
  };
37
40
 
38
41
  function resolveConfig(raw: Record<string, unknown> | undefined): PluginConfig {
@@ -51,6 +54,7 @@ function resolveConfig(raw: Record<string, unknown> | undefined): PluginConfig {
51
54
  : {}),
52
55
  },
53
56
  defaultCooldown: typeof cfg.defaultCooldown === "number" && cfg.defaultCooldown > 0 ? cfg.defaultCooldown : 1800,
57
+ calibrationDays: typeof cfg.calibrationDays === "number" && cfg.calibrationDays > 0 ? cfg.calibrationDays : 3,
54
58
  };
55
59
  }
56
60
 
@@ -64,6 +68,27 @@ export default {
64
68
 
65
69
  api.logger.info(`betterclaw plugin loaded (model=${config.triageModel}, budget=${config.pushBudgetPerDay})`);
66
70
 
71
+ // Calibration state
72
+ let calibrationStartedAt: number | null = null;
73
+ let pingReceived = false;
74
+
75
+ const calibrationFile = path.join(stateDir, "calibration.json");
76
+ (async () => {
77
+ try {
78
+ const raw = await fs.readFile(calibrationFile, "utf8");
79
+ const parsed = JSON.parse(raw);
80
+ calibrationStartedAt = parsed.startedAt ?? null;
81
+ } catch {
82
+ // No calibration file yet — will be created on first premium ping
83
+ }
84
+ })();
85
+
86
+ function isCalibrating(): boolean {
87
+ if (!calibrationStartedAt) return true;
88
+ const elapsed = Date.now() / 1000 - calibrationStartedAt;
89
+ return elapsed < config.calibrationDays * 86400;
90
+ }
91
+
67
92
  // Context manager (load synchronously — file read deferred to first access)
68
93
  const ctxManager = new ContextManager(stateDir);
69
94
 
@@ -134,6 +159,20 @@ export default {
134
159
 
135
160
  ctxManager.setRuntimeState({ tier, smartMode });
136
161
 
162
+ pingReceived = true;
163
+
164
+ // Initialize calibration on first premium ping
165
+ if ((tier === "premium" || tier === "premium+") && calibrationStartedAt === null) {
166
+ const existingProfile = await loadTriageProfile(stateDir);
167
+ if (existingProfile?.computedAt) {
168
+ calibrationStartedAt = existingProfile.computedAt - config.calibrationDays * 86400;
169
+ api.logger.info("betterclaw: existing triage profile found — skipping calibration");
170
+ } else {
171
+ calibrationStartedAt = Date.now() / 1000;
172
+ }
173
+ fs.writeFile(calibrationFile, JSON.stringify({ startedAt: calibrationStartedAt }), "utf8").catch(() => {});
174
+ }
175
+
137
176
  const meta = ctxManager.get().meta;
138
177
  const effectiveBudget = ctxManager.getDeviceConfig().pushBudgetPerDay ?? config.pushBudgetPerDay;
139
178
  respond(true, {
@@ -233,19 +272,10 @@ export default {
233
272
  }
234
273
  : null;
235
274
 
236
- const triggerIds = ["low-battery-away", "unusual-inactivity", "sleep-deficit", "routine-deviation", "health-weekly-digest"];
237
- const cooldowns: Record<string, number> = {};
238
- if (patterns?.triggerCooldowns) {
239
- for (const id of triggerIds) {
240
- const ts = patterns.triggerCooldowns[id];
241
- if (ts != null) cooldowns[id] = ts;
242
- }
243
- }
244
- const triggers = patterns ? { cooldowns } : null;
245
-
246
275
  respond(true, {
247
276
  tier: runtime.tier,
248
277
  smartMode: runtime.smartMode,
278
+ calibrating: isCalibrating(),
249
279
  activity,
250
280
  trends,
251
281
  decisions,
@@ -253,7 +283,6 @@ export default {
253
283
  routines,
254
284
  timestamps,
255
285
  triageProfile: profile ?? null,
256
- triggers,
257
286
  });
258
287
  } catch (err) {
259
288
  api.logger.error(`betterclaw.context error: ${err instanceof Error ? err.message : String(err)}`);
@@ -333,7 +362,17 @@ export default {
333
362
  }
334
363
  });
335
364
 
336
- // Agent tool
365
+ // Agent tools
366
+ api.registerTool(
367
+ createCheckTierTool(ctxManager, () => ({
368
+ pingReceived,
369
+ calibrating: isCalibrating(),
370
+ calibrationEndsAt: calibrationStartedAt
371
+ ? calibrationStartedAt + config.calibrationDays * 86400
372
+ : undefined,
373
+ })),
374
+ { optional: true },
375
+ );
337
376
  api.registerTool(createGetContextTool(ctxManager, stateDir), { optional: true });
338
377
 
339
378
  // Auto-reply command
@@ -405,17 +444,23 @@ export default {
405
444
  }
406
445
  });
407
446
 
408
- // Pattern engine + proactive engine
447
+ // Pattern engine
409
448
  const patternEngine = new PatternEngine(ctxManager, eventLog, config.patternWindowDays);
410
- const proactiveEngine = new ProactiveEngine(ctxManager, api, config);
411
449
 
412
450
  // Background service
413
451
  api.registerService({
414
452
  id: "betterclaw-engine",
415
453
  start: () => {
416
454
  patternEngine.startSchedule(config.analysisHour, async () => {
417
- // Run learner after patterns (only if smartMode ON)
418
455
  if (ctxManager.getRuntimeState().smartMode) {
456
+ // Scan reactions first (feeds into learner)
457
+ try {
458
+ await scanPendingReactions({ reactions: reactionTracker, api, config, stateDir });
459
+ } catch (err) {
460
+ api.logger.error(`betterclaw: reaction scan failed: ${err}`);
461
+ }
462
+
463
+ // Then run learner
419
464
  try {
420
465
  await runLearner({
421
466
  stateDir,
@@ -431,12 +476,10 @@ export default {
431
476
  }
432
477
  }
433
478
  });
434
- proactiveEngine.startSchedule();
435
479
  api.logger.info("betterclaw: background services started");
436
480
  },
437
481
  stop: () => {
438
482
  patternEngine.stopSchedule();
439
- proactiveEngine.stopSchedule();
440
483
  api.logger.info("betterclaw: background services stopped");
441
484
  },
442
485
  });
package/src/learner.ts CHANGED
@@ -42,15 +42,12 @@ export function buildLearnerPrompt(input: LearnerInput): string {
42
42
 
43
43
  const reactionsSection = reactions.length > 0
44
44
  ? `## Notification Reactions\n\n${reactions
45
- .map((r) => {
46
- const status = r.engaged === true ? "engaged" : r.engaged === false ? "ignored" : "unknown";
47
- return `- ${r.source} (${r.subscriptionId}): ${status}`;
48
- })
45
+ .map((r) => `- ${r.source} (${r.subscriptionId}): ${r.status}`)
49
46
  .join("\n")}`
50
47
  : "## Notification Reactions\nNo push reaction data available.";
51
48
 
52
49
  const prevSection = previousProfile
53
- ? `## Previous Triage Profile\n\nSummary: ${previousProfile.summary}\nLife context: ${previousProfile.lifeContext}\nInterruption tolerance: ${previousProfile.interruptionTolerance}`
50
+ ? `## Previous Triage Profile\n\nSummary: ${previousProfile.summary}\nInterruption tolerance: ${previousProfile.interruptionTolerance}`
54
51
  : "## Previous Triage Profile\nNo previous profile — this is the first analysis.";
55
52
 
56
53
  return `You are analyzing a user's device event patterns and daily activity to build a personalized notification triage profile.
@@ -67,13 +64,8 @@ ${patternsJson}
67
64
  ${prevSection}
68
65
 
69
66
  Based on all of the above, produce an updated triage profile as JSON with these fields:
70
- - eventPreferences: Record<string, "push"|"drop"|"context-dependent"> — per event source
71
- - lifeContext: string — brief description of user's current life situation
72
- - interruptionTolerance: "low"|"normal"|"high"
73
- - timePreferences: { quietHoursStart?, quietHoursEnd?, activeStart?, activeEnd? } — hours (0-23)
74
- - sensitivityThresholds: Record<string, number> — e.g. batteryLevel: 0.15
75
- - locationRules: Record<string, "push"|"drop"|"context-dependent"> — per zone name
76
67
  - summary: string — 1-2 sentence human-readable summary of this user's preferences
68
+ - interruptionTolerance: "low"|"normal"|"high"
77
69
 
78
70
  Respond with ONLY the JSON object, no markdown fences.`;
79
71
  }
@@ -85,15 +77,10 @@ export function parseTriageProfile(text: string): TriageProfile | null {
85
77
  if (!parsed.summary || !parsed.interruptionTolerance) return null;
86
78
  const validTolerances = ["low", "normal", "high"];
87
79
  return {
88
- eventPreferences: parsed.eventPreferences ?? {},
89
- lifeContext: parsed.lifeContext ?? "",
80
+ summary: parsed.summary,
90
81
  interruptionTolerance: validTolerances.includes(parsed.interruptionTolerance)
91
82
  ? parsed.interruptionTolerance
92
83
  : "normal",
93
- timePreferences: parsed.timePreferences ?? {},
94
- sensitivityThresholds: parsed.sensitivityThresholds ?? {},
95
- locationRules: parsed.locationRules ?? {},
96
- summary: parsed.summary,
97
84
  computedAt: parsed.computedAt ?? Date.now() / 1000,
98
85
  };
99
86
  } catch {
package/src/patterns.ts CHANGED
@@ -53,14 +53,11 @@ export class PatternEngine {
53
53
  const windowStart = Date.now() / 1000 - this.windowDays * 86400;
54
54
  const entries = await this.events.readSince(windowStart);
55
55
 
56
- const existing = (await this.context.readPatterns()) ?? emptyPatterns();
57
-
58
56
  const patterns: Patterns = {
59
57
  locationRoutines: computeLocationRoutines(entries),
60
58
  healthTrends: computeHealthTrends(entries),
61
59
  batteryPatterns: computeBatteryPatterns(entries),
62
60
  eventStats: computeEventStats(entries),
63
- triggerCooldowns: existing.triggerCooldowns,
64
61
  computedAt: Date.now() / 1000,
65
62
  };
66
63
 
@@ -96,7 +93,6 @@ export function emptyPatterns(): Patterns {
96
93
  dropRate7d: 0,
97
94
  topSources: [],
98
95
  },
99
- triggerCooldowns: {},
100
96
  computedAt: 0,
101
97
  };
102
98
  }
package/src/pipeline.ts CHANGED
@@ -25,7 +25,14 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
25
25
  context.updateFromEvent(event);
26
26
  await context.save();
27
27
 
28
- // Gate event forwarding behind premium entitlement
28
+ // Tier gate: free users get store-only path — no triage, no push
29
+ if (context.getRuntimeState().tier === "free") {
30
+ api.logger.info(`betterclaw: event stored (free tier)`);
31
+ await events.append({ event, decision: "free_stored", reason: "free tier", timestamp: Date.now() / 1000 });
32
+ return;
33
+ }
34
+
35
+ // Gate event forwarding behind premium entitlement (security boundary)
29
36
  const entitlementError = requireEntitlement("premium");
30
37
  if (entitlementError) {
31
38
  api.logger.info(`betterclaw: event blocked (no premium entitlement)`);
@@ -50,7 +57,7 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
50
57
  event,
51
58
  deps.context,
52
59
  profile,
53
- { triageModel: deps.config.triageModel, triageApiBase: deps.config.triageApiBase },
60
+ { triageModel: deps.config.triageModel, triageApiBase: deps.config.triageApiBase, budgetUsed: context.get().meta.pushesToday, budgetTotal: effectiveBudget },
54
61
  async () => {
55
62
  try {
56
63
  const auth = await deps.api.runtime.modelAuth.resolveApiKeyForProvider({
@@ -66,16 +73,17 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
66
73
  );
67
74
 
68
75
  if (triageResult.push) {
69
- const pushed = await pushToAgent(deps, event, `triage: ${triageResult.reason}`);
76
+ const message = formatEnrichedMessage(event, deps.context);
77
+ const pushed = await pushToAgent(deps, event, `triage: ${triageResult.reason}`, message);
70
78
 
71
79
  if (pushed) {
72
80
  rules.recordFired(event.subscriptionId, event.firedAt);
73
81
  context.recordPush();
74
82
  deps.reactions.recordPush({
75
- idempotencyKey: `event-${event.subscriptionId}-${Math.floor(event.firedAt)}`,
76
83
  subscriptionId: event.subscriptionId,
77
84
  source: event.source,
78
85
  pushedAt: Date.now() / 1000,
86
+ messageSummary: message.slice(0, 100),
79
87
  });
80
88
  }
81
89
 
@@ -100,16 +108,17 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
100
108
  }
101
109
 
102
110
  if (decision.action === "push") {
103
- const pushed = await pushToAgent(deps, event, decision.reason);
111
+ const message = formatEnrichedMessage(event, deps.context);
112
+ const pushed = await pushToAgent(deps, event, decision.reason, message);
104
113
 
105
114
  if (pushed) {
106
115
  rules.recordFired(event.subscriptionId, event.firedAt);
107
116
  context.recordPush();
108
117
  deps.reactions.recordPush({
109
- idempotencyKey: `event-${event.subscriptionId}-${Math.floor(event.firedAt)}`,
110
118
  subscriptionId: event.subscriptionId,
111
119
  source: event.source,
112
120
  pushedAt: Date.now() / 1000,
121
+ messageSummary: message.slice(0, 100),
113
122
  });
114
123
  }
115
124
 
@@ -134,8 +143,7 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
134
143
  await deps.reactions.save();
135
144
  }
136
145
 
137
- async function pushToAgent(deps: PipelineDeps, event: DeviceEvent, reason: string): Promise<boolean> {
138
- const message = formatEnrichedMessage(event, deps.context);
146
+ async function pushToAgent(deps: PipelineDeps, event: DeviceEvent, reason: string, message: string): Promise<boolean> {
139
147
  const idempotencyKey = `event-${event.subscriptionId}-${Math.floor(event.firedAt)}`;
140
148
 
141
149
  try {
@@ -0,0 +1,238 @@
1
+ import type { ReactionStatus } from "./types.js";
2
+ import type { ReactionTracker } from "./reactions.js";
3
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
4
+
5
+ // --- Types ---
6
+
7
+ export interface PushMatch {
8
+ pushIndex: number;
9
+ subsequentMessages: Array<{ role: string; content: unknown }>;
10
+ }
11
+
12
+ export interface ClassificationResult {
13
+ status: ReactionStatus;
14
+ reason: string;
15
+ }
16
+
17
+ export interface ScanDeps {
18
+ api: OpenClawPluginApi;
19
+ reactions: ReactionTracker;
20
+ classificationModel: string;
21
+ classificationApiBase?: string;
22
+ getApiKey: () => Promise<string | undefined>;
23
+ }
24
+
25
+ // --- Helpers ---
26
+
27
+ function extractText(content: unknown): string {
28
+ if (typeof content === "string") return content;
29
+ if (Array.isArray(content)) {
30
+ return content
31
+ .filter((b: any) => b.type === "text")
32
+ .map((b: any) => b.text ?? "")
33
+ .join("");
34
+ }
35
+ return "";
36
+ }
37
+
38
+ function isBetterClawPush(text: string): boolean {
39
+ return text.includes("[BetterClaw device event");
40
+ }
41
+
42
+ // --- Exported functions ---
43
+
44
+ /**
45
+ * Deterministic search: find the pushed message in the session transcript by
46
+ * timestamp proximity (within 30s) + content prefix match.
47
+ */
48
+ export function findPushInMessages(
49
+ messages: Array<{ role: string; content: unknown; timestamp?: number }>,
50
+ pushedAt: number,
51
+ messageSummary: string,
52
+ ): PushMatch | null {
53
+ const prefix = messageSummary.slice(0, 30);
54
+
55
+ for (let i = 0; i < messages.length; i++) {
56
+ const msg = messages[i];
57
+ const text = extractText(msg.content);
58
+
59
+ // Must be a BetterClaw push message
60
+ if (!isBetterClawPush(text)) continue;
61
+
62
+ // Must be within 30s of pushedAt
63
+ if (msg.timestamp !== undefined) {
64
+ if (Math.abs(msg.timestamp - pushedAt) > 30) continue;
65
+ }
66
+
67
+ // Must contain the first 30 chars of messageSummary
68
+ if (!text.includes(prefix)) continue;
69
+
70
+ // Collect subsequent messages until next BetterClaw push or limit of 5
71
+ const subsequent: Array<{ role: string; content: unknown }> = [];
72
+ for (let j = i + 1; j < messages.length && subsequent.length < 5; j++) {
73
+ const next = messages[j];
74
+ const nextText = extractText(next.content);
75
+ if (isBetterClawPush(nextText)) break;
76
+ subsequent.push({ role: next.role, content: next.content });
77
+ }
78
+
79
+ return { pushIndex: i, subsequentMessages: subsequent };
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Build a classification prompt asking the LLM to determine engagement status.
87
+ */
88
+ export function buildClassificationPrompt(
89
+ pushMessage: string,
90
+ subsequentMessages: Array<{ role: string; content: unknown }>,
91
+ ): string {
92
+ const convoLines = subsequentMessages
93
+ .map((m) => `${m.role}: ${extractText(m.content)}`)
94
+ .join("\n");
95
+
96
+ return `You are classifying user engagement with a pushed device notification.
97
+
98
+ The following message was pushed to the user's AI assistant:
99
+ ---
100
+ ${pushMessage}
101
+ ---
102
+
103
+ Conversation that followed:
104
+ ${convoLines || "(no subsequent messages)"}
105
+
106
+ Classify the user's engagement with this notification:
107
+ - "engaged": the user acknowledged, replied, or acted on the notification
108
+ - "ignored": the user changed topic, ignored it, or showed no reaction
109
+ - "unclear": not enough information to determine
110
+
111
+ Respond with JSON: {"status": "engaged"|"ignored"|"unclear", "reason": string}`;
112
+ }
113
+
114
+ /**
115
+ * Parse LLM JSON response into classification result.
116
+ */
117
+ export function parseClassificationResponse(text: string): ClassificationResult {
118
+ try {
119
+ const cleaned = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim();
120
+ const parsed = JSON.parse(cleaned);
121
+ const validStatuses: ReactionStatus[] = ["engaged", "ignored", "unclear"];
122
+ const status = validStatuses.includes(parsed.status) ? parsed.status : "unclear";
123
+ return {
124
+ status,
125
+ reason: typeof parsed.reason === "string" ? parsed.reason : "no reason provided",
126
+ };
127
+ } catch {
128
+ return { status: "unclear", reason: "failed to parse LLM response" };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Orchestrator: scan all pending reactions, do deterministic transcript search,
134
+ * classify via LLM, and record results on the ReactionTracker.
135
+ */
136
+ export async function scanPendingReactions(deps: ScanDeps): Promise<void> {
137
+ const { api, reactions } = deps;
138
+
139
+ const pending = reactions.getPending();
140
+ if (pending.length === 0) {
141
+ api.logger.info("reaction-scanner: no pending reactions to classify");
142
+ return;
143
+ }
144
+
145
+ api.logger.info(`reaction-scanner: scanning ${pending.length} pending reaction(s)`);
146
+
147
+ // Fetch session messages once (limit 200) to search through
148
+ let messages: Array<{ role: string; content: unknown; timestamp?: number }> = [];
149
+ try {
150
+ const { messages: fetched } = await api.runtime.subagent.getSessionMessages({
151
+ sessionKey: "main",
152
+ limit: 200,
153
+ });
154
+ messages = fetched;
155
+ } catch (err) {
156
+ api.logger.error(
157
+ `reaction-scanner: failed to fetch session messages: ${err instanceof Error ? err.message : String(err)}`,
158
+ );
159
+ return;
160
+ }
161
+
162
+ for (const reaction of pending) {
163
+ try {
164
+ // Step 1: Deterministic search
165
+ const match = findPushInMessages(messages, reaction.pushedAt, reaction.messageSummary);
166
+ if (!match) {
167
+ api.logger.info(
168
+ `reaction-scanner: no transcript match for ${reaction.subscriptionId} at ${reaction.pushedAt} — skipping`,
169
+ );
170
+ continue;
171
+ }
172
+
173
+ // Step 2: Build prompt
174
+ const pushText = extractText(
175
+ messages[match.pushIndex].content,
176
+ );
177
+ const prompt = buildClassificationPrompt(pushText, match.subsequentMessages);
178
+
179
+ // Step 3: LLM classification via subagent
180
+ const sessionKey = `betterclaw-classify-${reaction.subscriptionId}-${Math.floor(reaction.pushedAt)}`;
181
+ let classificationResult: ClassificationResult = { status: "unclear", reason: "classification not attempted" };
182
+
183
+ try {
184
+ // Clean up any stale session first
185
+ try { await api.runtime.subagent.deleteSession({ sessionKey }); } catch { /* ignore */ }
186
+
187
+ const { runId } = await api.runtime.subagent.run({
188
+ sessionKey,
189
+ message: prompt,
190
+ deliver: false,
191
+ idempotencyKey: `classify-${reaction.subscriptionId}-${Math.floor(reaction.pushedAt)}`,
192
+ });
193
+
194
+ await api.runtime.subagent.waitForRun({ runId, timeoutMs: 30000 });
195
+
196
+ const { messages: classifyMessages } = await api.runtime.subagent.getSessionMessages({
197
+ sessionKey,
198
+ limit: 5,
199
+ });
200
+
201
+ const lastAssistant = classifyMessages.filter((m: any) => m.role === "assistant").pop();
202
+ if (lastAssistant) {
203
+ const content = extractText(lastAssistant.content);
204
+ if (content) {
205
+ classificationResult = parseClassificationResponse(content);
206
+ }
207
+ }
208
+ } finally {
209
+ try { await api.runtime.subagent.deleteSession({ sessionKey }); } catch { /* ignore */ }
210
+ }
211
+
212
+ // Step 4: Record classification using compound key
213
+ reactions.classify(
214
+ reaction.subscriptionId,
215
+ reaction.pushedAt,
216
+ classificationResult.status,
217
+ classificationResult.reason,
218
+ );
219
+
220
+ api.logger.info(
221
+ `reaction-scanner: classified ${reaction.subscriptionId} as "${classificationResult.status}" — ${classificationResult.reason}`,
222
+ );
223
+ } catch (err) {
224
+ api.logger.error(
225
+ `reaction-scanner: error classifying ${reaction.subscriptionId}: ${err instanceof Error ? err.message : String(err)}`,
226
+ );
227
+ }
228
+ }
229
+
230
+ // Persist updated reactions
231
+ try {
232
+ await reactions.save();
233
+ } catch (err) {
234
+ api.logger.error(
235
+ `reaction-scanner: failed to save reactions: ${err instanceof Error ? err.message : String(err)}`,
236
+ );
237
+ }
238
+ }
package/src/reactions.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 { ReactionEntry } from "./types.js";
3
+ import type { ReactionEntry, ReactionStatus } from "./types.js";
4
4
 
5
5
  export class ReactionTracker {
6
6
  private reactions: ReactionEntry[] = [];
@@ -10,21 +10,43 @@ export class ReactionTracker {
10
10
  this.filePath = path.join(stateDir, "push-reactions.jsonl");
11
11
  }
12
12
 
13
- recordPush(entry: Omit<ReactionEntry, "engaged" | "checkedAt">): void {
14
- this.reactions.push({ ...entry, engaged: null });
13
+ recordPush(entry: { subscriptionId: string; source: string; pushedAt: number; messageSummary: string }): void {
14
+ this.reactions.push({
15
+ ...entry,
16
+ status: "pending",
17
+ });
15
18
  }
16
19
 
17
- markEngaged(idempotencyKey: string, engaged: boolean): void {
18
- const entry = this.reactions.find((r) => r.idempotencyKey === idempotencyKey);
20
+ /** Classify a reaction by matching on subscriptionId + pushedAt compound key */
21
+ classify(subscriptionId: string, pushedAt: number, status: ReactionStatus, reason: string): void {
22
+ const entry = this.reactions.find(
23
+ (r) => r.subscriptionId === subscriptionId && r.pushedAt === pushedAt && r.status === "pending"
24
+ );
19
25
  if (entry) {
20
- entry.engaged = engaged;
21
- entry.checkedAt = Date.now() / 1000;
26
+ entry.status = status;
27
+ entry.classifiedAt = Date.now() / 1000;
28
+ entry.classificationReason = reason;
22
29
  }
23
30
  }
24
31
 
25
- getRecent(hours: number): ReactionEntry[] {
32
+ /** Get pending (unclassified) reactions; optionally filtered to the last N hours */
33
+ getPending(hours?: number): ReactionEntry[] {
34
+ const pending = this.reactions.filter((r) => r.status === "pending");
35
+ if (hours === undefined) {
36
+ return pending;
37
+ }
38
+ const cutoff = Date.now() / 1000 - hours * 3600;
39
+ return pending.filter((r) => r.pushedAt >= cutoff);
40
+ }
41
+
42
+ getRecent(_hours?: number): ReactionEntry[] {
43
+ return [...this.reactions];
44
+ }
45
+
46
+ /** Get classified reactions for learner input */
47
+ getClassified(hours: number = 24): ReactionEntry[] {
26
48
  const cutoff = Date.now() / 1000 - hours * 3600;
27
- return this.reactions.filter((r) => r.pushedAt >= cutoff);
49
+ return this.reactions.filter((r) => r.status !== "pending" && r.pushedAt >= cutoff);
28
50
  }
29
51
 
30
52
  async save(): Promise<void> {
@@ -49,7 +71,6 @@ export class ReactionTracker {
49
71
  }
50
72
  }
51
73
 
52
- /** Rotate: keep only last 30 days */
53
74
  rotate(): void {
54
75
  const cutoff = Date.now() / 1000 - 30 * 86400;
55
76
  this.reactions = this.reactions.filter((r) => r.pushedAt >= cutoff);
@@ -0,0 +1,63 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { ContextManager } from "../context.js";
3
+
4
+ export interface CheckTierState {
5
+ pingReceived: boolean;
6
+ calibrating: boolean;
7
+ calibrationEndsAt?: number;
8
+ }
9
+
10
+ export function createCheckTierTool(ctx: ContextManager, getState: () => CheckTierState) {
11
+ return {
12
+ name: "check_tier",
13
+ label: "Check Device Tier",
14
+ description:
15
+ "Check the user's BetterClaw subscription tier and get instructions for how to access their device data. Call this first before accessing device data, or use your cached tier if still valid.",
16
+ parameters: Type.Object({}),
17
+ async execute(_id: string, _params: Record<string, unknown>) {
18
+ const state = getState();
19
+
20
+ if (!state.pingReceived) {
21
+ return {
22
+ content: [{
23
+ type: "text" as const,
24
+ text: JSON.stringify({
25
+ tier: "unknown",
26
+ dataPath: "Tier not yet determined — the device hasn't connected. Try again shortly.",
27
+ cacheUntil: Math.floor(Date.now() / 1000) + 60,
28
+ cacheInstruction: "Re-check in about a minute.",
29
+ }, null, 2),
30
+ }],
31
+ };
32
+ }
33
+
34
+ const runtime = ctx.getRuntimeState();
35
+ const cacheUntil = Math.floor(Date.now() / 1000) + 86400;
36
+
37
+ const isPremium = runtime.tier === "premium" || runtime.tier === "premium+";
38
+
39
+ const dataPath = isPremium
40
+ ? "Use node commands for current device readings: location.get, device.battery, health.steps, health.heartrate, health.hrv, health.sleep, health.distance, health.restinghr, health.workouts, health.summary, geofence.list. Use get_context for patterns, trends, history, and broad situational awareness — its device snapshot may not be perfectly recent but is useful for the big picture."
41
+ : "Use get_context for all device data. This is a cached snapshot from the last time the user had BetterClaw open — check timestamps for freshness. You cannot query fresh data on free tier.";
42
+
43
+ const cacheInstruction = `Save your tier and data path to your memory. Re-check after cacheUntil (${new Date(cacheUntil * 1000).toISOString()}). Until then, use the cached tier to decide how to access device data.`;
44
+
45
+ const result: Record<string, unknown> = {
46
+ tier: runtime.tier,
47
+ dataPath,
48
+ cacheUntil,
49
+ cacheInstruction,
50
+ };
51
+
52
+ if (state.calibrating) {
53
+ result.calibrating = true;
54
+ result.calibrationEndsAt = state.calibrationEndsAt;
55
+ result.calibrationNote = "BetterClaw's smart filtering is still calibrating — it needs a few days to learn your preferences. Events are being tracked but filtering is in rules-only mode.";
56
+ }
57
+
58
+ return {
59
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
60
+ };
61
+ },
62
+ };
63
+ }
@@ -1,4 +1,3 @@
1
- import { Type } from "@sinclair/typebox";
2
1
  import type { ContextManager } from "../context.js";
3
2
  import { loadTriageProfile } from "../learner.js";
4
3
 
@@ -7,47 +6,47 @@ export function createGetContextTool(ctx: ContextManager, stateDir?: string) {
7
6
  name: "get_context",
8
7
  label: "Get Device Context",
9
8
  description:
10
- "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.",
11
- parameters: Type.Object({
12
- include: Type.Optional(
13
- Type.Array(Type.String(), {
14
- description: "Sections to include. Omit for all. Options: device, activity, patterns, meta",
15
- }),
16
- ),
17
- }),
18
- async execute(_id: string, params: Record<string, unknown>) {
19
- const sections =
20
- Array.isArray(params.include) && params.include.every((s) => typeof s === "string")
21
- ? (params.include as string[])
22
- : ["device", "activity", "patterns", "meta"];
23
-
9
+ "Get BetterClaw context — patterns, trends, activity zone, event history, and cached device snapshots with staleness indicators. On premium, node commands return fresher data for current readings. On free, this includes the latest device snapshot.",
10
+ parameters: {},
11
+ async execute(_id: string, _params: Record<string, unknown>) {
24
12
  const state = ctx.get();
25
13
  const runtime = ctx.getRuntimeState();
26
14
  const patterns = await ctx.readPatterns();
15
+ const dataAge = ctx.getDataAge();
16
+
17
+ const isPremium = runtime.tier === "premium" || runtime.tier === "premium+";
27
18
 
28
19
  const result: Record<string, unknown> = {
29
- tier: runtime.tier,
20
+ tierHint: {
21
+ tier: runtime.tier,
22
+ note: isPremium
23
+ ? "Node commands available for fresh readings (location.get, device.battery, health.*)"
24
+ : "This is the only data source on free tier — check dataAgeSeconds for freshness",
25
+ },
30
26
  smartMode: runtime.smartMode,
31
27
  };
32
28
 
33
- if (sections.includes("device")) {
34
- result.device = {
35
- battery: state.device.battery ? { ...state.device.battery, updatedAt: ctx.getTimestamp("battery") } : null,
36
- location: state.device.location ? { ...state.device.location, updatedAt: ctx.getTimestamp("location") } : null,
37
- health: state.device.health ? { ...state.device.health, updatedAt: ctx.getTimestamp("health") } : null,
38
- };
39
- }
40
- if (sections.includes("activity")) {
41
- result.activity = { ...state.activity, updatedAt: ctx.getTimestamp("activity") };
42
- }
43
- if (sections.includes("patterns") && patterns) result.patterns = patterns;
44
- if (sections.includes("meta")) {
45
- result.meta = {
46
- ...state.meta,
47
- lastSnapshotAt: ctx.getTimestamp("lastSnapshot"),
48
- lastAnalysisAt: patterns?.computedAt,
49
- };
50
- }
29
+ result.device = {
30
+ battery: state.device.battery
31
+ ? { ...state.device.battery, updatedAt: ctx.getTimestamp("battery"), dataAgeSeconds: dataAge.battery }
32
+ : null,
33
+ location: state.device.location
34
+ ? { ...state.device.location, updatedAt: ctx.getTimestamp("location"), dataAgeSeconds: dataAge.location }
35
+ : null,
36
+ health: state.device.health
37
+ ? { ...state.device.health, updatedAt: ctx.getTimestamp("health"), dataAgeSeconds: dataAge.health }
38
+ : null,
39
+ };
40
+
41
+ result.activity = { ...state.activity, updatedAt: ctx.getTimestamp("activity") };
42
+
43
+ if (patterns) result.patterns = patterns;
44
+
45
+ result.meta = {
46
+ ...state.meta,
47
+ lastSnapshotAt: ctx.getTimestamp("lastSnapshot"),
48
+ lastAnalysisAt: patterns?.computedAt,
49
+ };
51
50
 
52
51
  const profile = stateDir ? await loadTriageProfile(stateDir) : null;
53
52
  result.triageProfile = profile ? { summary: profile.summary, computedAt: profile.computedAt } : null;
package/src/triage.ts CHANGED
@@ -11,6 +11,7 @@ export function buildTriagePrompt(
11
11
  event: DeviceEvent,
12
12
  context: ContextManager,
13
13
  profile: TriageProfile | null,
14
+ budget?: { budgetUsed: number; budgetTotal: number },
14
15
  ): string {
15
16
  const ctx = context.get();
16
17
  const battery = ctx.device.battery;
@@ -33,12 +34,16 @@ export function buildTriagePrompt(
33
34
  .filter(Boolean)
34
35
  .join("\n");
35
36
 
37
+ const budgetSection = budget
38
+ ? `## Push Budget\n${budget.budgetUsed} of ${budget.budgetTotal} pushes used today — be selective.`
39
+ : "";
40
+
36
41
  return `You are an event triage system for a personal assistant. Decide whether this device event should be pushed as a notification to the user.
37
42
 
38
43
  ${profileSection}
39
44
 
40
45
  ${contextSection}
41
-
46
+ ${budgetSection ? `\n${budgetSection}` : ""}
42
47
  ## Event
43
48
  - Subscription: ${event.subscriptionId}
44
49
  - Source: ${event.source}
@@ -60,7 +65,7 @@ export function parseTriageResponse(text: string): TriageResult {
60
65
  priority: ["low", "normal", "high"].includes(parsed.priority) ? parsed.priority : undefined,
61
66
  };
62
67
  } catch {
63
- return { push: true, reason: "failed to parse triage response" };
68
+ return { push: false, reason: "failed to parse triage response — defaulting to drop" };
64
69
  }
65
70
  }
66
71
 
@@ -68,15 +73,19 @@ export async function triageEvent(
68
73
  event: DeviceEvent,
69
74
  context: ContextManager,
70
75
  profile: TriageProfile | null,
71
- config: { triageModel: string; triageApiBase?: string },
76
+ config: { triageModel: string; triageApiBase?: string; budgetUsed?: number; budgetTotal?: number },
72
77
  resolveApiKey: () => Promise<string | undefined>,
73
78
  ): Promise<TriageResult> {
74
- const prompt = buildTriagePrompt(event, context, profile);
79
+ const prompt = buildTriagePrompt(event, context, profile,
80
+ config.budgetUsed != null && config.budgetTotal != null
81
+ ? { budgetUsed: config.budgetUsed, budgetTotal: config.budgetTotal }
82
+ : undefined
83
+ );
75
84
 
76
85
  try {
77
86
  const apiKey = await resolveApiKey();
78
87
  if (!apiKey) {
79
- return { push: true, reason: "no API key for triage — defaulting to push" };
88
+ return { push: false, reason: "no API key for triage — defaulting to drop" };
80
89
  }
81
90
 
82
91
  const baseUrl = config.triageApiBase ?? "https://api.openai.com/v1";
@@ -116,7 +125,7 @@ export async function triageEvent(
116
125
  });
117
126
 
118
127
  if (!response.ok) {
119
- return { push: true, reason: `triage API error: ${response.status}` };
128
+ return { push: false, reason: `triage API error: ${response.status} — defaulting to drop` };
120
129
  }
121
130
 
122
131
  const data = (await response.json()) as {
@@ -124,11 +133,11 @@ export async function triageEvent(
124
133
  };
125
134
  const content = data.choices?.[0]?.message?.content;
126
135
  if (!content) {
127
- return { push: true, reason: "empty triage response" };
136
+ return { push: false, reason: "empty triage response — defaulting to drop" };
128
137
  }
129
138
 
130
139
  return parseTriageResponse(content);
131
140
  } catch (err) {
132
- return { push: true, reason: `triage call failed: ${err}` };
141
+ return { push: false, reason: `triage call failed: ${err} — defaulting to drop` };
133
142
  }
134
143
  }
package/src/types.ts CHANGED
@@ -76,7 +76,7 @@ export type FilterDecision =
76
76
 
77
77
  export interface EventLogEntry {
78
78
  event: DeviceEvent;
79
- decision: "push" | "drop" | "stored";
79
+ decision: "push" | "drop" | "stored" | "free_stored";
80
80
  reason: string;
81
81
  timestamp: number;
82
82
  }
@@ -114,7 +114,6 @@ export interface Patterns {
114
114
  dropRate7d: number;
115
115
  topSources: string[];
116
116
  };
117
- triggerCooldowns: Record<string, number>;
118
117
  computedAt: number;
119
118
  }
120
119
 
@@ -129,28 +128,27 @@ export interface PluginConfig {
129
128
  analysisHour: number;
130
129
  deduplicationCooldowns: Record<string, number>;
131
130
  defaultCooldown: number;
131
+ calibrationDays: number;
132
132
  }
133
133
 
134
134
  // Triage profile produced by daily learning agent
135
135
  export interface TriageProfile {
136
- eventPreferences: Record<string, "push" | "drop" | "context-dependent">;
137
- lifeContext: string;
136
+ summary: string; // 1-2 sentence description of what the user cares about
138
137
  interruptionTolerance: "low" | "normal" | "high";
139
- timePreferences: { quietHoursStart?: number; quietHoursEnd?: number; activeStart?: number; activeEnd?: number };
140
- sensitivityThresholds: Record<string, number>;
141
- locationRules: Record<string, "push" | "drop" | "context-dependent">;
142
- summary: string;
143
- computedAt: number;
138
+ computedAt: number; // epoch seconds
144
139
  }
145
140
 
146
141
  // Reaction tracking for pushed events
142
+ export type ReactionStatus = "pending" | "engaged" | "ignored" | "unclear";
143
+
147
144
  export interface ReactionEntry {
148
- idempotencyKey: string;
149
145
  subscriptionId: string;
150
146
  source: string;
151
147
  pushedAt: number;
152
- engaged: boolean | null; // null = not yet determined
153
- checkedAt?: number;
148
+ messageSummary: string; // first ~100 chars of the pushed message
149
+ status: ReactionStatus;
150
+ classifiedAt?: number; // epoch when LLM classified
151
+ classificationReason?: string; // one-line reason from classifier
154
152
  }
155
153
 
156
154
  // Per-device config from betterclaw.config RPC
@@ -1,38 +0,0 @@
1
- # OpenClaw Plugin SDK — What Plugins Can and Cannot Do
2
-
3
- ## No LLM/Agent Access from Plugins
4
-
5
- Plugins **cannot** invoke LLMs or spawn agent sessions. This was discovered through deep investigation of the OpenClaw source code and plugin SDK.
6
-
7
- ### What we tried
8
- - `runEmbeddedPiAgent` — an internal OpenClaw function used by the core agent. Both betteremail and betterclaw plugins tried to import it via `openclaw/agents/pi-embedded`. This **does not work** because it's not exported in the plugin SDK.
9
-
10
- ### Why it doesn't work
11
- - The plugin SDK (`openclaw/plugin-sdk`) only exports a limited surface:
12
- - `OpenClawPluginApi` — the `api` object passed to `register()`
13
- - `openclaw/plugin-sdk/llm-task` — exists as an export path but provides no agent invocation
14
- - `openclaw/agents/*` is **not an exported path** — the import fails at runtime via Jiti (TypeScript loader)
15
- - `runEmbeddedPiAgent` is internal to OpenClaw's agent system and intentionally not exposed to plugins
16
- - The betterclaw plugin has the **exact same bug** — it also imports `runEmbeddedPiAgent` and has never successfully run its judgment layer
17
-
18
- ### What plugins CAN do
19
- - `api.registerTool()` — expose tools to the agent (with optional `{ optional: true }` flag)
20
- - `api.registerService()` — run background services (polling loops, etc.)
21
- - `api.registerCommand()` — add `/commands` to the chat UI
22
- - `api.runtime.system.runCommandWithTimeout()` — run shell commands (including `gog`, `openclaw` CLI)
23
- - `api.runtime.state.resolveStateDir()` — get persistent state directory
24
- - `api.pluginConfig` — read plugin config from openclaw.yaml
25
- - `api.logger` — structured logging
26
-
27
- ### What plugins CANNOT do
28
- - Invoke LLMs or create agent sessions
29
- - Import internal OpenClaw modules outside the plugin SDK exports
30
- - Access `runEmbeddedPiAgent` or any agent runtime internals
31
- - Run embedded "sub-agents" for classification or judgment
32
-
33
- ### The solution we adopted
34
- Instead of classifying emails in the plugin, we removed the classifier entirely and made the plugin a **pure stateful memory layer**. All emails enter the digest, and the agent triages them during cron-triggered sessions with full context (memory, skills, user preferences). This is actually better because:
35
- 1. The agent has full context for triage decisions
36
- 2. No separate LLM invocation cost
37
- 3. Simpler architecture — the plugin just tracks state
38
- 4. Cron job runs in isolated session, only notifies main session when something matters
package/src/triggers.ts DELETED
@@ -1,232 +0,0 @@
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
- if (!this.context.getRuntimeState().smartMode) {
193
- return;
194
- }
195
- const deviceConfig = this.context.getDeviceConfig();
196
- if (deviceConfig.proactiveEnabled === false) {
197
- return;
198
- }
199
- const ctx = this.context.get();
200
- const patterns = (await this.context.readPatterns()) ?? (await import("./patterns.js")).emptyPatterns();
201
-
202
- for (const trigger of triggers) {
203
- const lastFired = patterns.triggerCooldowns[trigger.id] ?? 0;
204
- const cooldown = TRIGGER_COOLDOWNS[trigger.id] ?? 3600;
205
- if (Date.now() / 1000 - lastFired < cooldown) continue;
206
-
207
- const result = trigger.check(ctx, patterns);
208
- if (!result) continue;
209
-
210
- this.api.logger.info(`betterclaw: proactive trigger fired: ${trigger.id}`);
211
-
212
- // Write cooldown BEFORE push to prevent runaway retries on failure
213
- patterns.triggerCooldowns[trigger.id] = Date.now() / 1000;
214
- await this.context.writePatterns(patterns);
215
-
216
- const message = `[BetterClaw proactive insight — combined signal analysis]\n\n${result.message}`;
217
-
218
- try {
219
- await this.api.runtime.subagent.run({
220
- sessionKey: "main",
221
- message,
222
- deliver: true,
223
- idempotencyKey: `trigger-${trigger.id}-${Math.floor(Date.now() / 1000)}`,
224
- });
225
- } catch (err) {
226
- this.api.logger.error(
227
- `betterclaw: trigger push failed: ${err instanceof Error ? err.message : String(err)}`,
228
- );
229
- }
230
- }
231
- }
232
- }