@better_openclaw/betterclaw 1.4.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.
- package/LICENSE +661 -0
- package/README.md +125 -0
- package/openclaw-plugin-sdk.md +38 -0
- package/openclaw.plugin.json +34 -0
- package/package.json +20 -0
- package/skills/betterclaw/SKILL.md +31 -0
- package/src/context.ts +219 -0
- package/src/events.ts +67 -0
- package/src/filter.ts +93 -0
- package/src/index.ts +251 -0
- package/src/judgment.ts +145 -0
- package/src/patterns.ts +248 -0
- package/src/pipeline.ts +169 -0
- package/src/tools/get-context.ts +37 -0
- package/src/triggers.ts +225 -0
- package/src/types.ts +129 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/banner.png" alt="BetterClaw" width="100%" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<em>OpenClaw plugin for the BetterClaw iOS app</em>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/@betterclaw-app/betterclaw"><img src="https://img.shields.io/npm/v/@betterclaw-app/betterclaw?style=flat-square&color=blue" alt="npm version" /></a>
|
|
11
|
+
<a href="https://github.com/BetterClaw-app/betterclaw/blob/main/LICENSE"><img src="https://img.shields.io/github/license/BetterClaw-app/betterclaw?style=flat-square" alt="license" /></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/@betterclaw-app/betterclaw"><img src="https://img.shields.io/npm/dm/@betterclaw-app/betterclaw?style=flat-square&color=green" alt="downloads" /></a>
|
|
13
|
+
<a href="https://openclaw.dev"><img src="https://img.shields.io/badge/platform-OpenClaw-orange?style=flat-square" alt="OpenClaw" /></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## What is this?
|
|
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.
|
|
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.
|
|
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 ─────▶ │ Budget Limiter │ Proactive Triggers│ + full context
|
|
32
|
+
└───────────────────┼───────────────────┘
|
|
33
|
+
│
|
|
34
|
+
proactive insights
|
|
35
|
+
(low battery + away from
|
|
36
|
+
home, sleep deficit, etc.)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Features
|
|
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
|
|
45
|
+
- **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
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
|
|
50
|
+
- [BetterClaw iOS app](https://github.com/BetterClaw-app/BetterClaw-ios) installed and connected to your gateway
|
|
51
|
+
- [OpenClaw](https://openclaw.dev) gateway (2025.12+)
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
openclaw plugins install @betterclaw-app/betterclaw
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Configure
|
|
60
|
+
|
|
61
|
+
Add to your `openclaw.json`:
|
|
62
|
+
|
|
63
|
+
```jsonc
|
|
64
|
+
{
|
|
65
|
+
"plugins": {
|
|
66
|
+
"entries": {
|
|
67
|
+
"betterclaw": {
|
|
68
|
+
"enabled": true,
|
|
69
|
+
"config": {
|
|
70
|
+
"llmModel": "openai/gpt-4o-mini",
|
|
71
|
+
"pushBudgetPerDay": 10,
|
|
72
|
+
"patternWindowDays": 14,
|
|
73
|
+
"proactiveEnabled": true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
All config keys are optional — defaults are shown above.
|
|
82
|
+
|
|
83
|
+
### Config Reference
|
|
84
|
+
|
|
85
|
+
| Key | Default | Description |
|
|
86
|
+
|-----|---------|-------------|
|
|
87
|
+
| `llmModel` | `openai/gpt-4o-mini` | Model used for ambiguous event triage |
|
|
88
|
+
| `pushBudgetPerDay` | `10` | Max events forwarded to the agent per day |
|
|
89
|
+
| `patternWindowDays` | `14` | Days of event history used for pattern computation |
|
|
90
|
+
| `proactiveEnabled` | `true` | Enable proactive combined-signal insights |
|
|
91
|
+
|
|
92
|
+
## How It Works
|
|
93
|
+
|
|
94
|
+
### Event Pipeline
|
|
95
|
+
|
|
96
|
+
Every device event from the BetterClaw app goes through a multi-stage pipeline before reaching your agent:
|
|
97
|
+
|
|
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.
|
|
102
|
+
5. **Agent Injection** — Events that pass are injected into the agent's main session with formatted context.
|
|
103
|
+
|
|
104
|
+
### Background Services
|
|
105
|
+
|
|
106
|
+
Two engines run on a schedule in the background:
|
|
107
|
+
|
|
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
|
|
110
|
+
|
|
111
|
+
## Commands
|
|
112
|
+
|
|
113
|
+
| Command | Description |
|
|
114
|
+
|---------|-------------|
|
|
115
|
+
| `/bc` | Show current device context snapshot in chat |
|
|
116
|
+
|
|
117
|
+
## Compatibility
|
|
118
|
+
|
|
119
|
+
| Plugin | BetterClaw iOS | OpenClaw |
|
|
120
|
+
|--------|----------------|----------|
|
|
121
|
+
| 1.x | 1.x+ | 2025.12+ |
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
[AGPL-3.0](LICENSE) — Free to use, modify, and self-host. Derivative works must remain open source.
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "betterclaw",
|
|
3
|
+
"name": "BetterClaw Context",
|
|
4
|
+
"description": "Intelligent context layer for BetterClaw iOS companion app",
|
|
5
|
+
"version": "1.0.3",
|
|
6
|
+
"skills": ["./skills/betterclaw"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"llmModel": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"default": "openai/gpt-4o-mini"
|
|
14
|
+
},
|
|
15
|
+
"pushBudgetPerDay": {
|
|
16
|
+
"type": "number",
|
|
17
|
+
"default": 10
|
|
18
|
+
},
|
|
19
|
+
"patternWindowDays": {
|
|
20
|
+
"type": "number",
|
|
21
|
+
"default": 14
|
|
22
|
+
},
|
|
23
|
+
"proactiveEnabled": {
|
|
24
|
+
"type": "boolean",
|
|
25
|
+
"default": true
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"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" }
|
|
33
|
+
}
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@better_openclaw/betterclaw",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Intelligent event filtering, context tracking, and proactive triggers for BetterClaw",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/BetterClaw-app/betterclaw.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"openclaw": {
|
|
12
|
+
"extensions": ["./src/index.ts"]
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@sinclair/typebox": "^0.34.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"vitest": "^3.0.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: BetterClaw Device Context
|
|
3
|
+
description: Instructions for handling physical device events and context from BetterClaw iOS
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# BetterClaw Device Context
|
|
7
|
+
|
|
8
|
+
You have access to the user's physical device state via BetterClaw (iOS companion app).
|
|
9
|
+
|
|
10
|
+
## Capabilities
|
|
11
|
+
|
|
12
|
+
- **Real-time 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
|
|
16
|
+
|
|
17
|
+
## Event Messages
|
|
18
|
+
|
|
19
|
+
You'll receive two types of automated messages:
|
|
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).
|
|
23
|
+
|
|
24
|
+
## Guidelines
|
|
25
|
+
|
|
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
|
+
- 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).
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { DeviceContext, DeviceEvent, Patterns } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const CONTEXT_FILE = "context.json";
|
|
6
|
+
const PATTERNS_FILE = "patterns.json";
|
|
7
|
+
|
|
8
|
+
export class ContextManager {
|
|
9
|
+
private contextPath: string;
|
|
10
|
+
private patternsPath: string;
|
|
11
|
+
private context: DeviceContext;
|
|
12
|
+
|
|
13
|
+
constructor(stateDir: string) {
|
|
14
|
+
this.contextPath = path.join(stateDir, CONTEXT_FILE);
|
|
15
|
+
this.patternsPath = path.join(stateDir, PATTERNS_FILE);
|
|
16
|
+
this.context = ContextManager.empty();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static empty(): DeviceContext {
|
|
20
|
+
return {
|
|
21
|
+
device: { battery: null, location: null, health: null },
|
|
22
|
+
activity: {
|
|
23
|
+
currentZone: null,
|
|
24
|
+
zoneEnteredAt: null,
|
|
25
|
+
lastTransition: null,
|
|
26
|
+
isStationary: true,
|
|
27
|
+
stationarySince: null,
|
|
28
|
+
},
|
|
29
|
+
meta: {
|
|
30
|
+
lastEventAt: 0,
|
|
31
|
+
eventsToday: 0,
|
|
32
|
+
lastAgentPushAt: 0,
|
|
33
|
+
pushesToday: 0,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async load(): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await fs.readFile(this.contextPath, "utf8");
|
|
41
|
+
this.context = JSON.parse(raw) as DeviceContext;
|
|
42
|
+
} catch {
|
|
43
|
+
this.context = ContextManager.empty();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get(): DeviceContext {
|
|
48
|
+
return this.context;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async save(): Promise<void> {
|
|
52
|
+
await fs.mkdir(path.dirname(this.contextPath), { recursive: true });
|
|
53
|
+
await fs.writeFile(this.contextPath, JSON.stringify(this.context, null, 2) + "\n", "utf8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
updateFromEvent(event: DeviceEvent): void {
|
|
57
|
+
const now = event.firedAt;
|
|
58
|
+
const data = event.data;
|
|
59
|
+
|
|
60
|
+
// Reset daily counters at midnight UTC
|
|
61
|
+
const lastDay = Math.floor(this.context.meta.lastEventAt / 86400);
|
|
62
|
+
const currentDay = Math.floor(now / 86400);
|
|
63
|
+
if (lastDay !== currentDay && this.context.meta.lastEventAt > 0) {
|
|
64
|
+
this.context.meta.eventsToday = 0;
|
|
65
|
+
this.context.meta.pushesToday = 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.context.meta.lastEventAt = now;
|
|
69
|
+
this.context.meta.eventsToday++;
|
|
70
|
+
|
|
71
|
+
switch (event.source) {
|
|
72
|
+
case "device.battery":
|
|
73
|
+
this.context.device.battery = {
|
|
74
|
+
level: data.level ?? this.context.device.battery?.level ?? 0,
|
|
75
|
+
state: this.context.device.battery?.state ?? "unknown",
|
|
76
|
+
isLowPowerMode: (data.isLowPowerMode ?? 0) === 1,
|
|
77
|
+
updatedAt: data.updatedAt ?? now,
|
|
78
|
+
};
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
case "geofence.triggered": {
|
|
82
|
+
const type = data.type === 1 ? "enter" : "exit";
|
|
83
|
+
const zoneName = event.metadata?.zoneName ?? null;
|
|
84
|
+
const prevZone = this.context.activity.currentZone;
|
|
85
|
+
|
|
86
|
+
if (type === "enter") {
|
|
87
|
+
this.context.activity.lastTransition = {
|
|
88
|
+
from: prevZone,
|
|
89
|
+
to: zoneName,
|
|
90
|
+
at: now,
|
|
91
|
+
};
|
|
92
|
+
this.context.activity.currentZone = zoneName;
|
|
93
|
+
this.context.activity.zoneEnteredAt = now;
|
|
94
|
+
this.context.activity.isStationary = true;
|
|
95
|
+
this.context.activity.stationarySince = now;
|
|
96
|
+
} else if (type === "exit") {
|
|
97
|
+
this.context.activity.lastTransition = {
|
|
98
|
+
from: prevZone,
|
|
99
|
+
to: null,
|
|
100
|
+
at: now,
|
|
101
|
+
};
|
|
102
|
+
this.context.activity.currentZone = null;
|
|
103
|
+
this.context.activity.zoneEnteredAt = null;
|
|
104
|
+
this.context.activity.isStationary = false;
|
|
105
|
+
this.context.activity.stationarySince = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.context.device.location = {
|
|
109
|
+
latitude: data.latitude ?? this.context.device.location?.latitude ?? 0,
|
|
110
|
+
longitude: data.longitude ?? this.context.device.location?.longitude ?? 0,
|
|
111
|
+
horizontalAccuracy: this.context.device.location?.horizontalAccuracy ?? 0,
|
|
112
|
+
label: this.context.activity.currentZone,
|
|
113
|
+
updatedAt: data.timestamp ?? now,
|
|
114
|
+
};
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
default:
|
|
119
|
+
if (event.source.startsWith("health")) {
|
|
120
|
+
this.context.device.health = {
|
|
121
|
+
stepsToday: data.stepsToday ?? this.context.device.health?.stepsToday ?? null,
|
|
122
|
+
distanceMeters: data.distanceMeters ?? this.context.device.health?.distanceMeters ?? null,
|
|
123
|
+
heartRateAvg: data.heartRateAvg ?? this.context.device.health?.heartRateAvg ?? null,
|
|
124
|
+
restingHeartRate: data.restingHeartRate ?? this.context.device.health?.restingHeartRate ?? null,
|
|
125
|
+
hrv: data.hrv ?? this.context.device.health?.hrv ?? null,
|
|
126
|
+
activeEnergyKcal: data.activeEnergyKcal ?? this.context.device.health?.activeEnergyKcal ?? null,
|
|
127
|
+
sleepDurationSeconds: data.sleepDurationSeconds ?? this.context.device.health?.sleepDurationSeconds ?? null,
|
|
128
|
+
updatedAt: data.updatedAt ?? now,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
applySnapshot(snapshot: {
|
|
136
|
+
battery?: { level: number; state: string; isLowPowerMode: boolean };
|
|
137
|
+
location?: { latitude: number; longitude: number };
|
|
138
|
+
health?: {
|
|
139
|
+
stepsToday?: number; distanceMeters?: number; heartRateAvg?: number;
|
|
140
|
+
restingHeartRate?: number; hrv?: number; activeEnergyKcal?: number;
|
|
141
|
+
sleepDurationSeconds?: number;
|
|
142
|
+
};
|
|
143
|
+
geofence?: { type: string; zoneName: string; latitude: number; longitude: number };
|
|
144
|
+
}): void {
|
|
145
|
+
const now = Date.now() / 1000;
|
|
146
|
+
|
|
147
|
+
if (snapshot.battery) {
|
|
148
|
+
this.context.device.battery = {
|
|
149
|
+
level: snapshot.battery.level,
|
|
150
|
+
state: snapshot.battery.state,
|
|
151
|
+
isLowPowerMode: snapshot.battery.isLowPowerMode,
|
|
152
|
+
updatedAt: now,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (snapshot.location) {
|
|
157
|
+
this.context.device.location = {
|
|
158
|
+
latitude: snapshot.location.latitude,
|
|
159
|
+
longitude: snapshot.location.longitude,
|
|
160
|
+
horizontalAccuracy: 0,
|
|
161
|
+
label: this.context.activity.currentZone,
|
|
162
|
+
updatedAt: now,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (snapshot.health) {
|
|
167
|
+
this.context.device.health = {
|
|
168
|
+
stepsToday: snapshot.health.stepsToday ?? null,
|
|
169
|
+
distanceMeters: snapshot.health.distanceMeters ?? null,
|
|
170
|
+
heartRateAvg: snapshot.health.heartRateAvg ?? null,
|
|
171
|
+
restingHeartRate: snapshot.health.restingHeartRate ?? null,
|
|
172
|
+
hrv: snapshot.health.hrv ?? null,
|
|
173
|
+
activeEnergyKcal: snapshot.health.activeEnergyKcal ?? null,
|
|
174
|
+
sleepDurationSeconds: snapshot.health.sleepDurationSeconds ?? null,
|
|
175
|
+
updatedAt: now,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (snapshot.geofence) {
|
|
180
|
+
const prevZone = this.context.activity.currentZone;
|
|
181
|
+
if (snapshot.geofence.type === "enter") {
|
|
182
|
+
this.context.activity.currentZone = snapshot.geofence.zoneName;
|
|
183
|
+
this.context.activity.zoneEnteredAt = now;
|
|
184
|
+
this.context.activity.isStationary = true;
|
|
185
|
+
this.context.activity.stationarySince = now;
|
|
186
|
+
this.context.activity.lastTransition = { from: prevZone, to: snapshot.geofence.zoneName, at: now };
|
|
187
|
+
} else {
|
|
188
|
+
this.context.activity.currentZone = null;
|
|
189
|
+
this.context.activity.zoneEnteredAt = null;
|
|
190
|
+
this.context.activity.isStationary = false;
|
|
191
|
+
this.context.activity.stationarySince = null;
|
|
192
|
+
this.context.activity.lastTransition = { from: prevZone, to: null, at: now };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (this.context.device.location) {
|
|
196
|
+
this.context.device.location.label = this.context.activity.currentZone;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
recordPush(): void {
|
|
202
|
+
this.context.meta.lastAgentPushAt = Date.now() / 1000;
|
|
203
|
+
this.context.meta.pushesToday++;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async readPatterns(): Promise<Patterns | null> {
|
|
207
|
+
try {
|
|
208
|
+
const raw = await fs.readFile(this.patternsPath, "utf8");
|
|
209
|
+
return JSON.parse(raw) as Patterns;
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async writePatterns(patterns: Patterns): Promise<void> {
|
|
216
|
+
await fs.mkdir(path.dirname(this.patternsPath), { recursive: true });
|
|
217
|
+
await fs.writeFile(this.patternsPath, JSON.stringify(patterns, null, 2) + "\n", "utf8");
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { EventLogEntry } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const EVENTS_FILE = "events.jsonl";
|
|
6
|
+
const MAX_LINES = 10_000;
|
|
7
|
+
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
8
|
+
|
|
9
|
+
export class EventLog {
|
|
10
|
+
private filePath: string;
|
|
11
|
+
|
|
12
|
+
constructor(stateDir: string) {
|
|
13
|
+
this.filePath = path.join(stateDir, EVENTS_FILE);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async append(entry: EventLogEntry): Promise<void> {
|
|
17
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
18
|
+
const line = JSON.stringify(entry) + "\n";
|
|
19
|
+
await fs.appendFile(this.filePath, line, "utf8");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async readAll(): Promise<EventLogEntry[]> {
|
|
23
|
+
try {
|
|
24
|
+
const raw = await fs.readFile(this.filePath, "utf8");
|
|
25
|
+
return raw
|
|
26
|
+
.trim()
|
|
27
|
+
.split("\n")
|
|
28
|
+
.filter((line) => line.length > 0)
|
|
29
|
+
.map((line) => JSON.parse(line) as EventLogEntry);
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async readRecent(limit: number = 20): Promise<EventLogEntry[]> {
|
|
36
|
+
const all = await this.readAll();
|
|
37
|
+
return all.slice(-limit);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async readSince(sinceEpoch: number): Promise<EventLogEntry[]> {
|
|
41
|
+
const all = await this.readAll();
|
|
42
|
+
return all.filter((e) => e.timestamp >= sinceEpoch);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async rotate(): Promise<number> {
|
|
46
|
+
const entries = await this.readAll();
|
|
47
|
+
if (entries.length <= MAX_LINES) return 0;
|
|
48
|
+
|
|
49
|
+
const cutoff = Date.now() / 1000 - MAX_AGE_MS / 1000;
|
|
50
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff).slice(-MAX_LINES);
|
|
51
|
+
const removed = entries.length - kept.length;
|
|
52
|
+
|
|
53
|
+
const content = kept.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
54
|
+
await fs.writeFile(this.filePath, content, "utf8");
|
|
55
|
+
|
|
56
|
+
return removed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async count(): Promise<number> {
|
|
60
|
+
try {
|
|
61
|
+
const raw = await fs.readFile(this.filePath, "utf8");
|
|
62
|
+
return raw.trim().split("\n").filter((l) => l.length > 0).length;
|
|
63
|
+
} catch {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/filter.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { DeviceContext, DeviceEvent, FilterDecision } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// Cooldowns in seconds
|
|
4
|
+
const DEDUP_COOLDOWN: Record<string, number> = {
|
|
5
|
+
"default.battery-low": 3600,
|
|
6
|
+
"default.battery-critical": 1800,
|
|
7
|
+
"default.daily-health": 82800, // 23 hours
|
|
8
|
+
"default.geofence": 300,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const DEFAULT_COOLDOWN = 1800; // 30 minutes
|
|
12
|
+
|
|
13
|
+
export class RulesEngine {
|
|
14
|
+
private lastFired: Map<string, number> = new Map();
|
|
15
|
+
private pushBudget: number;
|
|
16
|
+
|
|
17
|
+
constructor(pushBudget: number = 10) {
|
|
18
|
+
this.pushBudget = pushBudget;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
evaluate(event: DeviceEvent, context: DeviceContext): FilterDecision {
|
|
22
|
+
// Debug events always pass
|
|
23
|
+
if (event.data._debugFired === 1.0) {
|
|
24
|
+
return { action: "push", reason: "debug event — always push" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Dedup check
|
|
28
|
+
const lastFiredAt = this.lastFired.get(event.subscriptionId);
|
|
29
|
+
const cooldown = DEDUP_COOLDOWN[event.subscriptionId] ?? DEFAULT_COOLDOWN;
|
|
30
|
+
if (lastFiredAt && event.firedAt - lastFiredAt < cooldown) {
|
|
31
|
+
return {
|
|
32
|
+
action: "drop",
|
|
33
|
+
reason: `dedup: ${event.subscriptionId} fired ${Math.round(event.firedAt - lastFiredAt)}s ago (cooldown: ${cooldown}s)`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Critical battery — always push
|
|
38
|
+
if (event.subscriptionId === "default.battery-critical") {
|
|
39
|
+
return { action: "push", reason: "critical battery — always push" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Geofence — always push
|
|
43
|
+
if (event.source === "geofence.triggered") {
|
|
44
|
+
return { action: "push", reason: "geofence event — always push" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Battery low — check if level changed since last push
|
|
48
|
+
if (event.subscriptionId === "default.battery-low") {
|
|
49
|
+
const currentLevel = event.data.level;
|
|
50
|
+
const lastLevel = context.device.battery?.level;
|
|
51
|
+
if (
|
|
52
|
+
lastLevel !== undefined &&
|
|
53
|
+
currentLevel !== undefined &&
|
|
54
|
+
Math.abs(currentLevel - lastLevel) < 0.02
|
|
55
|
+
) {
|
|
56
|
+
return { action: "drop", reason: "battery-low: level unchanged since last push" };
|
|
57
|
+
}
|
|
58
|
+
return { action: "push", reason: "battery low — level changed" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Daily health — check time window
|
|
62
|
+
if (event.subscriptionId === "default.daily-health") {
|
|
63
|
+
const hour = new Date(event.firedAt * 1000).getHours();
|
|
64
|
+
// Preferred window: 6am-10am
|
|
65
|
+
if (hour >= 6 && hour <= 10) {
|
|
66
|
+
return { action: "push", reason: "daily health summary — within morning window" };
|
|
67
|
+
}
|
|
68
|
+
return { action: "defer", reason: "daily health summary — outside morning window" };
|
|
69
|
+
}
|
|
70
|
+
|
|
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)` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Anything else is ambiguous — forward to LLM judgment
|
|
77
|
+
return { action: "ambiguous", reason: "no rule matched — forward to LLM judgment" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
recordFired(subscriptionId: string, firedAt: number): void {
|
|
81
|
+
this.lastFired.set(subscriptionId, firedAt);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Restore cooldown state (call on load) */
|
|
85
|
+
restoreCooldowns(entries: Array<{ subscriptionId: string; firedAt: number }>): void {
|
|
86
|
+
for (const { subscriptionId, firedAt } of entries) {
|
|
87
|
+
const existing = this.lastFired.get(subscriptionId);
|
|
88
|
+
if (!existing || firedAt > existing) {
|
|
89
|
+
this.lastFired.set(subscriptionId, firedAt);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|