@better_openclaw/betterclaw 2.2.2 → 3.0.1

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/README.md CHANGED
@@ -19,34 +19,30 @@
19
19
 
20
20
  This is the server-side plugin for [BetterClaw](https://betterclaw.app), an iOS app that connects your iPhone's sensors to your [OpenClaw](https://openclaw.dev) AI agent. The app streams device events (location, battery, health, geofences) to your gateway — this plugin decides what to do with them.
21
21
 
22
- The plugin is the **sole event gateway** for all tiers. Smart mode controls filtering depth: OFF = passive context store, ON = full pipeline with rules, LLM triage, and proactive insights.
22
+ The plugin differentiates between **free** and **premium** tiers:
23
23
 
24
- ```
25
- BetterClaw iOS App This Plugin (on gateway) Agent
26
- ────────────────── ──────────────────────── ────────
27
-
28
- battery ──────▶ ┌───────────────────┼───────────────────┐
29
- location ─────▶ │ Rules Engine │ Context Store │
30
- health ───────▶ │ LLM Triage │ Pattern Engine │ ──▶ filtered events
31
- geofence ─────▶ │ Daily Learner │ Proactive Triggers│ + full context
32
- └───────────────────┼───────────────────┘
33
-
34
- proactive insights
35
- (low battery + away from
36
- home, sleep deficit, etc.)
37
- ```
24
+ - **Free** — passive context store. The agent can pull device snapshots via `get_context`, but no events are pushed proactively.
25
+ - **Premium** full smart mode pipeline with rules-based filtering, LLM triage, engagement tracking, and a daily learner that adapts to your preferences.
26
+
27
+ <p align="center">
28
+ <picture>
29
+ <img src=".github/architecture.svg" alt="BetterClaw Plugin Architecture" width="500" />
30
+ </picture>
31
+ </p>
38
32
 
39
33
  ## Features
40
34
 
41
- - **Tier-Aware Smart Mode** — Smart mode ON = full pipeline (rules triage push). Smart mode OFF = passive store (context updated, no filtering or pushing). Synced via periodic heartbeat from iOS.
42
- - **Two-Layer LLM Triage** — Daily learner builds a personalized triage profile from OpenClaw memory summaries + event reactions. Per-event cheap LLM call with structured output for ambiguous events.
43
- - **Smart Filtering** — Per-source dedup, cooldown windows, and a configurable daily push budget prevent event spam
44
- - **Device Context** — Rolling state snapshot with per-field timestamps: battery, GPS, zone occupancy, health metrics, activity classification
45
- - **Pattern Recognition** — Daily analysis computes location routines, health trends (7d/30d baselines), and event frequency stats
46
- - **Proactive Insights** — Combined-signal triggers: low battery away from home, unusual inactivity, sleep deficit, routine deviations, weekly digest
47
- - **Per-Device Config** — iOS app can override push budget and proactive settings at runtime via RPC
48
- - **Agent Tool** — `get_context` tool lets your agent read the full device snapshot, tier, smart mode status, and triage profile on demand
49
- - **CLI Setup** — `openclaw betterclaw setup` configures gateway allowedCommands automatically
35
+ - **Tier-Aware Routing** — `check_tier` tool tells the agent whether to use node commands (premium, fresh data) or `get_context` (free, cached snapshots). Includes a 24h cache TTL so the agent doesn't re-check every turn.
36
+ - **Free = Pull-Only** — Events are stored for context but never triaged or pushed. `get_context` with staleness indicators (`dataAgeSeconds`) is the only data source.
37
+ - **Premium Smart Mode** — Rules engine + LLM triage with fail-closed error handling and budget-aware prompts. Daily push budget prevents event spam.
38
+ - **Engagement Tracking** — Deterministic transcript scanner finds pushed messages by timestamp, then an LLM classifies user engagement as `engaged`, `ignored`, or `unclear`. Feeds into the learner.
39
+ - **Adaptive Learner** — Daily subagent builds a simplified triage profile (`summary` + `interruptionTolerance`) from event history, engagement data, and workspace memory.
40
+ - **Calibration Period** — First 3 days after install, triage runs in rules-only mode while the system collects engagement data. Skipped automatically for users upgrading from v2.
41
+ - **Device Context** — Rolling state snapshot with per-field timestamps and `dataAgeSeconds`: battery, GPS, zone occupancy, health metrics, activity classification.
42
+ - **Pattern Recognition** — Daily analysis computes location routines, health trends (7d/30d baselines), and event frequency stats.
43
+ - **Per-Device Config** — iOS app can override push budget at runtime via RPC.
44
+ - **Agent Tools** — `check_tier` for routing decisions, `get_context` for patterns/trends/cached state.
45
+ - **CLI Setup** — `openclaw betterclaw setup` configures gateway allowedCommands automatically.
50
46
 
51
47
  ## Requirements
52
48
 
@@ -74,8 +70,8 @@ Add to your `openclaw.json`:
74
70
  "triageModel": "openai/gpt-4o-mini",
75
71
  "pushBudgetPerDay": 10,
76
72
  "patternWindowDays": 14,
77
- "proactiveEnabled": true,
78
- "analysisHour": 5
73
+ "analysisHour": 5,
74
+ "calibrationDays": 3
79
75
  }
80
76
  }
81
77
  }
@@ -89,14 +85,14 @@ All config keys are optional — defaults are shown above.
89
85
 
90
86
  | Key | Default | Description |
91
87
  |-----|---------|-------------|
92
- | `triageModel` | `openai/gpt-4o-mini` | Model for per-event triage (supports `provider/model` format) |
93
- | `triageApiBase` | | Optional base URL for OpenAI-compatible endpoint (e.g., Ollama) |
88
+ | `triageModel` | `openai/gpt-4o-mini` | Model for per-event triage and engagement classification |
89
+ | `triageApiBase` | -- | Optional base URL for OpenAI-compatible endpoint (e.g., Ollama) |
94
90
  | `pushBudgetPerDay` | `10` | Max events forwarded to the agent per day |
95
91
  | `patternWindowDays` | `14` | Days of event history used for pattern computation |
96
- | `proactiveEnabled` | `true` | Enable proactive combined-signal insights |
97
92
  | `analysisHour` | `5` | Hour (0-23, system timezone) for daily pattern + learner analysis |
93
+ | `calibrationDays` | `3` | Days of rules-only triage before learner profile kicks in |
98
94
 
99
- > **Migration:** `llmModel` still works as a deprecated alias for `triageModel`.
95
+ > **Migration from v2:** `llmModel` still works as a deprecated alias for `triageModel`. `proactiveEnabled` is ignored (proactive triggers removed in v3).
100
96
 
101
97
  ## How It Works
102
98
 
@@ -104,26 +100,33 @@ All config keys are optional — defaults are shown above.
104
100
 
105
101
  Every device event from the BetterClaw app goes through the plugin:
106
102
 
107
- 1. **Context Update** — Device context store is always updated with the latest sensor data.
108
- 2. **Smart Mode Check** — If smart mode is OFF, the event is stored and processing stops. If ON, continues.
109
- 3. **Rules Engine** — Checks dedup, cooldown timers, and daily budget. Critical events (geofence, low battery) always push. Obvious spam is dropped.
110
- 4. **LLM Triage** — Ambiguous events get a cheap LLM call with the personalized triage profile for a push/drop decision.
111
- 5. **Agent Injection** — Events that pass are injected into the agent's main session with formatted context.
103
+ <p align="center">
104
+ <picture>
105
+ <img src=".github/pipeline.svg" alt="Event Pipeline" width="350" />
106
+ </picture>
107
+ </p>
112
108
 
113
109
  ### Background Services
114
110
 
115
- - **Pattern Engine + Daily Learner** (daily at `analysisHour`) — Computes location routines, health trends, event stats. Then runs a subagent turn to build a personalized triage profile from OpenClaw memory summaries and notification reaction data.
116
- - **Proactive Engine** (hourly) — Evaluates combined-signal conditions and fires insights when thresholds are met.
111
+ - **Pattern Engine + Reaction Scanner + Learner** (daily at `analysisHour`) — Computes location routines, health trends, event stats. Then scans session transcripts for engagement with past pushes (deterministic timestamp search + LLM classification). Finally runs a subagent to build a personalized triage profile from engagement data and workspace memory.
112
+
113
+ ### Agent Tools
114
+
115
+ | Tool | Purpose |
116
+ |------|---------|
117
+ | `check_tier` | Returns tier + routing instructions + cache TTL. No device data. Call first. |
118
+ | `get_context` | Returns patterns, trends, zone state, cached device snapshots with `dataAgeSeconds`. |
117
119
 
118
120
  ### Gateway RPCs
119
121
 
120
122
  | RPC | Direction | Purpose |
121
123
  |-----|-----------|---------|
122
- | `betterclaw.event` | iOS plugin | Send a device event for processing |
123
- | `betterclaw.ping` | iOS plugin | Heartbeat: sync tier + smartMode, get budget info |
124
- | `betterclaw.config` | iOS plugin | Per-device settings override |
125
- | `betterclaw.context` | iOS plugin | Full context for iOS Context tab |
126
- | `betterclaw.snapshot` | iOS plugin | Bulk device state catch-up |
124
+ | `betterclaw.event` | iOS -> plugin | Send a device event for processing |
125
+ | `betterclaw.ping` | iOS -> plugin | Heartbeat: sync tier + smartMode, init calibration |
126
+ | `betterclaw.config` | iOS -> plugin | Per-device settings override |
127
+ | `betterclaw.context` | iOS -> plugin | Full context for iOS Context tab (includes `calibrating` flag) |
128
+ | `betterclaw.snapshot` | iOS -> plugin | Bulk device state catch-up |
129
+ | `betterclaw.learn` | iOS -> plugin | Trigger on-demand triage profile learning |
127
130
 
128
131
  ## Commands
129
132
 
@@ -135,9 +138,10 @@ Every device event from the BetterClaw app goes through the plugin:
135
138
 
136
139
  | Plugin | BetterClaw iOS | OpenClaw |
137
140
  |--------|----------------|----------|
141
+ | 3.x | 2.x+ | 2025.12+ |
138
142
  | 2.x | 2.x+ | 2025.12+ |
139
143
  | 1.x | 1.x | 2025.12+ |
140
144
 
141
145
  ## License
142
146
 
143
- [AGPL-3.0](LICENSE) Free to use, modify, and self-host. Derivative works must remain open source.
147
+ [AGPL-3.0](LICENSE) -- Free to use, modify, and self-host. Derivative works must remain open source.
@@ -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.1",
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,8 +362,17 @@ export default {
333
362
  }
334
363
  });
335
364
 
336
- // Agent tool
337
- api.registerTool(createGetContextTool(ctxManager, stateDir), { optional: true });
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
+ );
375
+ api.registerTool(createGetContextTool(ctxManager, stateDir));
338
376
 
339
377
  // Auto-reply command
340
378
  api.registerCommand({
@@ -405,17 +443,23 @@ export default {
405
443
  }
406
444
  });
407
445
 
408
- // Pattern engine + proactive engine
446
+ // Pattern engine
409
447
  const patternEngine = new PatternEngine(ctxManager, eventLog, config.patternWindowDays);
410
- const proactiveEngine = new ProactiveEngine(ctxManager, api, config);
411
448
 
412
449
  // Background service
413
450
  api.registerService({
414
451
  id: "betterclaw-engine",
415
452
  start: () => {
416
453
  patternEngine.startSchedule(config.analysisHour, async () => {
417
- // Run learner after patterns (only if smartMode ON)
418
454
  if (ctxManager.getRuntimeState().smartMode) {
455
+ // Scan reactions first (feeds into learner)
456
+ try {
457
+ await scanPendingReactions({ reactions: reactionTracker, api, config, stateDir });
458
+ } catch (err) {
459
+ api.logger.error(`betterclaw: reaction scan failed: ${err}`);
460
+ }
461
+
462
+ // Then run learner
419
463
  try {
420
464
  await runLearner({
421
465
  stateDir,
@@ -431,12 +475,10 @@ export default {
431
475
  }
432
476
  }
433
477
  });
434
- proactiveEngine.startSchedule();
435
478
  api.logger.info("betterclaw: background services started");
436
479
  },
437
480
  stop: () => {
438
481
  patternEngine.stopSchedule();
439
- proactiveEngine.stopSchedule();
440
482
  api.logger.info("betterclaw: background services stopped");
441
483
  },
442
484
  });
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 {