@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 +47 -43
- package/openclaw.plugin.json +8 -1
- package/package.json +1 -1
- package/skills/betterclaw/SKILL.md +32 -16
- package/src/context.ts +12 -2
- package/src/index.ts +61 -19
- package/src/learner.ts +4 -17
- package/src/patterns.ts +0 -4
- package/src/pipeline.ts +16 -8
- package/src/reaction-scanner.ts +238 -0
- package/src/reactions.ts +31 -10
- package/src/tools/check-tier.ts +63 -0
- package/src/tools/get-context.ts +33 -34
- package/src/triage.ts +17 -8
- package/src/types.ts +10 -12
- package/openclaw-plugin-sdk.md +0 -38
- package/src/triggers.ts +0 -232
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
|
|
22
|
+
The plugin differentiates between **free** and **premium** tiers:
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
42
|
-
- **
|
|
43
|
-
- **Smart
|
|
44
|
-
- **
|
|
45
|
-
- **
|
|
46
|
-
- **
|
|
47
|
-
- **
|
|
48
|
-
- **
|
|
49
|
-
- **
|
|
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
|
-
"
|
|
78
|
-
"
|
|
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
|
|
93
|
-
| `triageApiBase` |
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 +
|
|
116
|
-
|
|
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
|
|
123
|
-
| `betterclaw.ping` | iOS
|
|
124
|
-
| `betterclaw.config` | iOS
|
|
125
|
-
| `betterclaw.context` | iOS
|
|
126
|
-
| `betterclaw.snapshot` | iOS
|
|
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)
|
|
147
|
+
[AGPL-3.0](LICENSE) -- Free to use, modify, and self-host. Derivative works must remain open source.
|
package/openclaw.plugin.json
CHANGED
|
@@ -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": "
|
|
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,32 +1,48 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: BetterClaw Device Context
|
|
3
|
-
description: Instructions for
|
|
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
|
-
##
|
|
10
|
+
## How to access device data
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
15
|
+
### Premium tier
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
You have two complementary tools:
|
|
20
18
|
|
|
21
|
-
- **
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
- Respond with `no_reply` for routine events that
|
|
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
|
|
337
|
-
api.registerTool(
|
|
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
|
|
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}\
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 {
|