@better_openclaw/betterclaw 1.4.0 → 2.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
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/banner.png" alt="BetterClaw" width="100%" />
2
+ <img src="banner.png" alt="BetterClaw" width="100%" />
3
3
  </p>
4
4
 
5
5
  <p align="center">
@@ -17,9 +17,9 @@
17
17
 
18
18
  ## What is this?
19
19
 
20
- This is the server-side plugin for [BetterClaw](https://github.com/BetterClaw-app/BetterClaw-ios), 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.
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
- Without this plugin, raw events go straight to your agent. With it, they're filtered, triaged, and enriched before anything reaches your agent's conversation.
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.
23
23
 
24
24
  ```
25
25
  BetterClaw iOS App This Plugin (on gateway) Agent
@@ -28,7 +28,7 @@ Without this plugin, raw events go straight to your agent. With it, they're filt
28
28
  battery ──────▶ ┌───────────────────┼───────────────────┐
29
29
  location ─────▶ │ Rules Engine │ Context Store │
30
30
  health ───────▶ │ LLM Triage │ Pattern Engine │ ──▶ filtered events
31
- geofence ─────▶ │ Budget Limiter │ Proactive Triggers│ + full context
31
+ geofence ─────▶ │ Daily Learner │ Proactive Triggers│ + full context
32
32
  └───────────────────┼───────────────────┘
33
33
 
34
34
  proactive insights
@@ -38,12 +38,15 @@ Without this plugin, raw events go straight to your agent. With it, they're filt
38
38
 
39
39
  ## Features
40
40
 
41
- - **Smart Filtering** — Per-source dedup, cooldown windows, and a daily push budget prevent event spam
42
- - **LLM Triage** — Ambiguous events get a cheap LLM call to decide push vs. suppress, keeping the expensive agent focused
43
- - **Device Context** — Rolling state snapshot: battery, GPS, zone occupancy, health metrics, activity classification
44
- - **Pattern Recognition** — Computes location routines, health trends (7d/30d baselines), and event stats every 6 hours
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
45
46
  - **Proactive Insights** — Combined-signal triggers: low battery away from home, unusual inactivity, sleep deficit, routine deviations, weekly digest
46
- - **Agent Tool** — `get_context` tool lets your agent read the full device snapshot on demand
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
47
50
 
48
51
  ## Requirements
49
52
 
@@ -54,6 +57,7 @@ Without this plugin, raw events go straight to your agent. With it, they're filt
54
57
 
55
58
  ```bash
56
59
  openclaw plugins install @betterclaw-app/betterclaw
60
+ openclaw betterclaw setup # configures gateway allowedCommands
57
61
  ```
58
62
 
59
63
  ## Configure
@@ -67,10 +71,11 @@ Add to your `openclaw.json`:
67
71
  "betterclaw": {
68
72
  "enabled": true,
69
73
  "config": {
70
- "llmModel": "openai/gpt-4o-mini",
74
+ "triageModel": "openai/gpt-4o-mini",
71
75
  "pushBudgetPerDay": 10,
72
76
  "patternWindowDays": 14,
73
- "proactiveEnabled": true
77
+ "proactiveEnabled": true,
78
+ "analysisHour": 5
74
79
  }
75
80
  }
76
81
  }
@@ -84,29 +89,41 @@ All config keys are optional — defaults are shown above.
84
89
 
85
90
  | Key | Default | Description |
86
91
  |-----|---------|-------------|
87
- | `llmModel` | `openai/gpt-4o-mini` | Model used for ambiguous event triage |
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
94
  | `pushBudgetPerDay` | `10` | Max events forwarded to the agent per day |
89
95
  | `patternWindowDays` | `14` | Days of event history used for pattern computation |
90
96
  | `proactiveEnabled` | `true` | Enable proactive combined-signal insights |
97
+ | `analysisHour` | `5` | Hour (0-23, system timezone) for daily pattern + learner analysis |
98
+
99
+ > **Migration:** `llmModel` still works as a deprecated alias for `triageModel`.
91
100
 
92
101
  ## How It Works
93
102
 
94
103
  ### Event Pipeline
95
104
 
96
- Every device event from the BetterClaw app goes through a multi-stage pipeline before reaching your agent:
105
+ Every device event from the BetterClaw app goes through the plugin:
97
106
 
98
- 1. **Rules Engine** — Checks dedup, cooldown timers, and daily budget. Obvious spam is dropped immediately.
99
- 2. **LLM Triage** — Events that aren't clearly push or suppress get a fast LLM call with device context for a judgment call.
100
- 3. **Context Update** — The device context store is updated with the latest sensor data regardless of whether the event is forwarded.
101
- 4. **Event Logging** — Every event and its decision (push/suppress/defer) is logged for pattern computation.
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.
102
111
  5. **Agent Injection** — Events that pass are injected into the agent's main session with formatted context.
103
112
 
104
113
  ### Background Services
105
114
 
106
- Two engines run on a schedule in the background:
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.
117
+
118
+ ### Gateway RPCs
107
119
 
108
- - **Pattern Engine** (every 6h) Analyzes event history to compute location routines, health trends, and event frequency stats
109
- - **Proactive Engine** (every 30min) — Evaluates combined-signal conditions and fires insights when thresholds are met
120
+ | RPC | Direction | Purpose |
121
+ |-----|-----------|---------|
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 |
110
127
 
111
128
  ## Commands
112
129
 
@@ -118,7 +135,8 @@ Two engines run on a schedule in the background:
118
135
 
119
136
  | Plugin | BetterClaw iOS | OpenClaw |
120
137
  |--------|----------------|----------|
121
- | 1.x | 1.x+ | 2025.12+ |
138
+ | 2.x | 2.x+ | 2025.12+ |
139
+ | 1.x | 1.x | 2025.12+ |
122
140
 
123
141
  ## License
124
142
 
package/banner.png ADDED
Binary file
@@ -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.1",
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