@better_openclaw/betterclaw 2.2.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -18
- 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/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,7 +362,17 @@ export default {
|
|
|
333
362
|
}
|
|
334
363
|
});
|
|
335
364
|
|
|
336
|
-
// Agent
|
|
365
|
+
// Agent tools
|
|
366
|
+
api.registerTool(
|
|
367
|
+
createCheckTierTool(ctxManager, () => ({
|
|
368
|
+
pingReceived,
|
|
369
|
+
calibrating: isCalibrating(),
|
|
370
|
+
calibrationEndsAt: calibrationStartedAt
|
|
371
|
+
? calibrationStartedAt + config.calibrationDays * 86400
|
|
372
|
+
: undefined,
|
|
373
|
+
})),
|
|
374
|
+
{ optional: true },
|
|
375
|
+
);
|
|
337
376
|
api.registerTool(createGetContextTool(ctxManager, stateDir), { optional: true });
|
|
338
377
|
|
|
339
378
|
// Auto-reply command
|
|
@@ -405,17 +444,23 @@ export default {
|
|
|
405
444
|
}
|
|
406
445
|
});
|
|
407
446
|
|
|
408
|
-
// Pattern engine
|
|
447
|
+
// Pattern engine
|
|
409
448
|
const patternEngine = new PatternEngine(ctxManager, eventLog, config.patternWindowDays);
|
|
410
|
-
const proactiveEngine = new ProactiveEngine(ctxManager, api, config);
|
|
411
449
|
|
|
412
450
|
// Background service
|
|
413
451
|
api.registerService({
|
|
414
452
|
id: "betterclaw-engine",
|
|
415
453
|
start: () => {
|
|
416
454
|
patternEngine.startSchedule(config.analysisHour, async () => {
|
|
417
|
-
// Run learner after patterns (only if smartMode ON)
|
|
418
455
|
if (ctxManager.getRuntimeState().smartMode) {
|
|
456
|
+
// Scan reactions first (feeds into learner)
|
|
457
|
+
try {
|
|
458
|
+
await scanPendingReactions({ reactions: reactionTracker, api, config, stateDir });
|
|
459
|
+
} catch (err) {
|
|
460
|
+
api.logger.error(`betterclaw: reaction scan failed: ${err}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Then run learner
|
|
419
464
|
try {
|
|
420
465
|
await runLearner({
|
|
421
466
|
stateDir,
|
|
@@ -431,12 +476,10 @@ export default {
|
|
|
431
476
|
}
|
|
432
477
|
}
|
|
433
478
|
});
|
|
434
|
-
proactiveEngine.startSchedule();
|
|
435
479
|
api.logger.info("betterclaw: background services started");
|
|
436
480
|
},
|
|
437
481
|
stop: () => {
|
|
438
482
|
patternEngine.stopSchedule();
|
|
439
|
-
proactiveEngine.stopSchedule();
|
|
440
483
|
api.logger.info("betterclaw: background services stopped");
|
|
441
484
|
},
|
|
442
485
|
});
|
package/src/learner.ts
CHANGED
|
@@ -42,15 +42,12 @@ export function buildLearnerPrompt(input: LearnerInput): string {
|
|
|
42
42
|
|
|
43
43
|
const reactionsSection = reactions.length > 0
|
|
44
44
|
? `## Notification Reactions\n\n${reactions
|
|
45
|
-
.map((r) => {
|
|
46
|
-
const status = r.engaged === true ? "engaged" : r.engaged === false ? "ignored" : "unknown";
|
|
47
|
-
return `- ${r.source} (${r.subscriptionId}): ${status}`;
|
|
48
|
-
})
|
|
45
|
+
.map((r) => `- ${r.source} (${r.subscriptionId}): ${r.status}`)
|
|
49
46
|
.join("\n")}`
|
|
50
47
|
: "## Notification Reactions\nNo push reaction data available.";
|
|
51
48
|
|
|
52
49
|
const prevSection = previousProfile
|
|
53
|
-
? `## Previous Triage Profile\n\nSummary: ${previousProfile.summary}\
|
|
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 {
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { ReactionStatus } from "./types.js";
|
|
2
|
+
import type { ReactionTracker } from "./reactions.js";
|
|
3
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
|
|
5
|
+
// --- Types ---
|
|
6
|
+
|
|
7
|
+
export interface PushMatch {
|
|
8
|
+
pushIndex: number;
|
|
9
|
+
subsequentMessages: Array<{ role: string; content: unknown }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ClassificationResult {
|
|
13
|
+
status: ReactionStatus;
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ScanDeps {
|
|
18
|
+
api: OpenClawPluginApi;
|
|
19
|
+
reactions: ReactionTracker;
|
|
20
|
+
classificationModel: string;
|
|
21
|
+
classificationApiBase?: string;
|
|
22
|
+
getApiKey: () => Promise<string | undefined>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- Helpers ---
|
|
26
|
+
|
|
27
|
+
function extractText(content: unknown): string {
|
|
28
|
+
if (typeof content === "string") return content;
|
|
29
|
+
if (Array.isArray(content)) {
|
|
30
|
+
return content
|
|
31
|
+
.filter((b: any) => b.type === "text")
|
|
32
|
+
.map((b: any) => b.text ?? "")
|
|
33
|
+
.join("");
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isBetterClawPush(text: string): boolean {
|
|
39
|
+
return text.includes("[BetterClaw device event");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Exported functions ---
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Deterministic search: find the pushed message in the session transcript by
|
|
46
|
+
* timestamp proximity (within 30s) + content prefix match.
|
|
47
|
+
*/
|
|
48
|
+
export function findPushInMessages(
|
|
49
|
+
messages: Array<{ role: string; content: unknown; timestamp?: number }>,
|
|
50
|
+
pushedAt: number,
|
|
51
|
+
messageSummary: string,
|
|
52
|
+
): PushMatch | null {
|
|
53
|
+
const prefix = messageSummary.slice(0, 30);
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < messages.length; i++) {
|
|
56
|
+
const msg = messages[i];
|
|
57
|
+
const text = extractText(msg.content);
|
|
58
|
+
|
|
59
|
+
// Must be a BetterClaw push message
|
|
60
|
+
if (!isBetterClawPush(text)) continue;
|
|
61
|
+
|
|
62
|
+
// Must be within 30s of pushedAt
|
|
63
|
+
if (msg.timestamp !== undefined) {
|
|
64
|
+
if (Math.abs(msg.timestamp - pushedAt) > 30) continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Must contain the first 30 chars of messageSummary
|
|
68
|
+
if (!text.includes(prefix)) continue;
|
|
69
|
+
|
|
70
|
+
// Collect subsequent messages until next BetterClaw push or limit of 5
|
|
71
|
+
const subsequent: Array<{ role: string; content: unknown }> = [];
|
|
72
|
+
for (let j = i + 1; j < messages.length && subsequent.length < 5; j++) {
|
|
73
|
+
const next = messages[j];
|
|
74
|
+
const nextText = extractText(next.content);
|
|
75
|
+
if (isBetterClawPush(nextText)) break;
|
|
76
|
+
subsequent.push({ role: next.role, content: next.content });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { pushIndex: i, subsequentMessages: subsequent };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a classification prompt asking the LLM to determine engagement status.
|
|
87
|
+
*/
|
|
88
|
+
export function buildClassificationPrompt(
|
|
89
|
+
pushMessage: string,
|
|
90
|
+
subsequentMessages: Array<{ role: string; content: unknown }>,
|
|
91
|
+
): string {
|
|
92
|
+
const convoLines = subsequentMessages
|
|
93
|
+
.map((m) => `${m.role}: ${extractText(m.content)}`)
|
|
94
|
+
.join("\n");
|
|
95
|
+
|
|
96
|
+
return `You are classifying user engagement with a pushed device notification.
|
|
97
|
+
|
|
98
|
+
The following message was pushed to the user's AI assistant:
|
|
99
|
+
---
|
|
100
|
+
${pushMessage}
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
Conversation that followed:
|
|
104
|
+
${convoLines || "(no subsequent messages)"}
|
|
105
|
+
|
|
106
|
+
Classify the user's engagement with this notification:
|
|
107
|
+
- "engaged": the user acknowledged, replied, or acted on the notification
|
|
108
|
+
- "ignored": the user changed topic, ignored it, or showed no reaction
|
|
109
|
+
- "unclear": not enough information to determine
|
|
110
|
+
|
|
111
|
+
Respond with JSON: {"status": "engaged"|"ignored"|"unclear", "reason": string}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse LLM JSON response into classification result.
|
|
116
|
+
*/
|
|
117
|
+
export function parseClassificationResponse(text: string): ClassificationResult {
|
|
118
|
+
try {
|
|
119
|
+
const cleaned = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim();
|
|
120
|
+
const parsed = JSON.parse(cleaned);
|
|
121
|
+
const validStatuses: ReactionStatus[] = ["engaged", "ignored", "unclear"];
|
|
122
|
+
const status = validStatuses.includes(parsed.status) ? parsed.status : "unclear";
|
|
123
|
+
return {
|
|
124
|
+
status,
|
|
125
|
+
reason: typeof parsed.reason === "string" ? parsed.reason : "no reason provided",
|
|
126
|
+
};
|
|
127
|
+
} catch {
|
|
128
|
+
return { status: "unclear", reason: "failed to parse LLM response" };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Orchestrator: scan all pending reactions, do deterministic transcript search,
|
|
134
|
+
* classify via LLM, and record results on the ReactionTracker.
|
|
135
|
+
*/
|
|
136
|
+
export async function scanPendingReactions(deps: ScanDeps): Promise<void> {
|
|
137
|
+
const { api, reactions } = deps;
|
|
138
|
+
|
|
139
|
+
const pending = reactions.getPending();
|
|
140
|
+
if (pending.length === 0) {
|
|
141
|
+
api.logger.info("reaction-scanner: no pending reactions to classify");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
api.logger.info(`reaction-scanner: scanning ${pending.length} pending reaction(s)`);
|
|
146
|
+
|
|
147
|
+
// Fetch session messages once (limit 200) to search through
|
|
148
|
+
let messages: Array<{ role: string; content: unknown; timestamp?: number }> = [];
|
|
149
|
+
try {
|
|
150
|
+
const { messages: fetched } = await api.runtime.subagent.getSessionMessages({
|
|
151
|
+
sessionKey: "main",
|
|
152
|
+
limit: 200,
|
|
153
|
+
});
|
|
154
|
+
messages = fetched;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
api.logger.error(
|
|
157
|
+
`reaction-scanner: failed to fetch session messages: ${err instanceof Error ? err.message : String(err)}`,
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const reaction of pending) {
|
|
163
|
+
try {
|
|
164
|
+
// Step 1: Deterministic search
|
|
165
|
+
const match = findPushInMessages(messages, reaction.pushedAt, reaction.messageSummary);
|
|
166
|
+
if (!match) {
|
|
167
|
+
api.logger.info(
|
|
168
|
+
`reaction-scanner: no transcript match for ${reaction.subscriptionId} at ${reaction.pushedAt} — skipping`,
|
|
169
|
+
);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Step 2: Build prompt
|
|
174
|
+
const pushText = extractText(
|
|
175
|
+
messages[match.pushIndex].content,
|
|
176
|
+
);
|
|
177
|
+
const prompt = buildClassificationPrompt(pushText, match.subsequentMessages);
|
|
178
|
+
|
|
179
|
+
// Step 3: LLM classification via subagent
|
|
180
|
+
const sessionKey = `betterclaw-classify-${reaction.subscriptionId}-${Math.floor(reaction.pushedAt)}`;
|
|
181
|
+
let classificationResult: ClassificationResult = { status: "unclear", reason: "classification not attempted" };
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
// Clean up any stale session first
|
|
185
|
+
try { await api.runtime.subagent.deleteSession({ sessionKey }); } catch { /* ignore */ }
|
|
186
|
+
|
|
187
|
+
const { runId } = await api.runtime.subagent.run({
|
|
188
|
+
sessionKey,
|
|
189
|
+
message: prompt,
|
|
190
|
+
deliver: false,
|
|
191
|
+
idempotencyKey: `classify-${reaction.subscriptionId}-${Math.floor(reaction.pushedAt)}`,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await api.runtime.subagent.waitForRun({ runId, timeoutMs: 30000 });
|
|
195
|
+
|
|
196
|
+
const { messages: classifyMessages } = await api.runtime.subagent.getSessionMessages({
|
|
197
|
+
sessionKey,
|
|
198
|
+
limit: 5,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const lastAssistant = classifyMessages.filter((m: any) => m.role === "assistant").pop();
|
|
202
|
+
if (lastAssistant) {
|
|
203
|
+
const content = extractText(lastAssistant.content);
|
|
204
|
+
if (content) {
|
|
205
|
+
classificationResult = parseClassificationResponse(content);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
try { await api.runtime.subagent.deleteSession({ sessionKey }); } catch { /* ignore */ }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Step 4: Record classification using compound key
|
|
213
|
+
reactions.classify(
|
|
214
|
+
reaction.subscriptionId,
|
|
215
|
+
reaction.pushedAt,
|
|
216
|
+
classificationResult.status,
|
|
217
|
+
classificationResult.reason,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
api.logger.info(
|
|
221
|
+
`reaction-scanner: classified ${reaction.subscriptionId} as "${classificationResult.status}" — ${classificationResult.reason}`,
|
|
222
|
+
);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
api.logger.error(
|
|
225
|
+
`reaction-scanner: error classifying ${reaction.subscriptionId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Persist updated reactions
|
|
231
|
+
try {
|
|
232
|
+
await reactions.save();
|
|
233
|
+
} catch (err) {
|
|
234
|
+
api.logger.error(
|
|
235
|
+
`reaction-scanner: failed to save reactions: ${err instanceof Error ? err.message : String(err)}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/reactions.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type { ReactionEntry } from "./types.js";
|
|
3
|
+
import type { ReactionEntry, ReactionStatus } from "./types.js";
|
|
4
4
|
|
|
5
5
|
export class ReactionTracker {
|
|
6
6
|
private reactions: ReactionEntry[] = [];
|
|
@@ -10,21 +10,43 @@ export class ReactionTracker {
|
|
|
10
10
|
this.filePath = path.join(stateDir, "push-reactions.jsonl");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
recordPush(entry:
|
|
14
|
-
this.reactions.push({
|
|
13
|
+
recordPush(entry: { subscriptionId: string; source: string; pushedAt: number; messageSummary: string }): void {
|
|
14
|
+
this.reactions.push({
|
|
15
|
+
...entry,
|
|
16
|
+
status: "pending",
|
|
17
|
+
});
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
/** Classify a reaction by matching on subscriptionId + pushedAt compound key */
|
|
21
|
+
classify(subscriptionId: string, pushedAt: number, status: ReactionStatus, reason: string): void {
|
|
22
|
+
const entry = this.reactions.find(
|
|
23
|
+
(r) => r.subscriptionId === subscriptionId && r.pushedAt === pushedAt && r.status === "pending"
|
|
24
|
+
);
|
|
19
25
|
if (entry) {
|
|
20
|
-
entry.
|
|
21
|
-
entry.
|
|
26
|
+
entry.status = status;
|
|
27
|
+
entry.classifiedAt = Date.now() / 1000;
|
|
28
|
+
entry.classificationReason = reason;
|
|
22
29
|
}
|
|
23
30
|
}
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
/** Get pending (unclassified) reactions; optionally filtered to the last N hours */
|
|
33
|
+
getPending(hours?: number): ReactionEntry[] {
|
|
34
|
+
const pending = this.reactions.filter((r) => r.status === "pending");
|
|
35
|
+
if (hours === undefined) {
|
|
36
|
+
return pending;
|
|
37
|
+
}
|
|
38
|
+
const cutoff = Date.now() / 1000 - hours * 3600;
|
|
39
|
+
return pending.filter((r) => r.pushedAt >= cutoff);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getRecent(_hours?: number): ReactionEntry[] {
|
|
43
|
+
return [...this.reactions];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Get classified reactions for learner input */
|
|
47
|
+
getClassified(hours: number = 24): ReactionEntry[] {
|
|
26
48
|
const cutoff = Date.now() / 1000 - hours * 3600;
|
|
27
|
-
return this.reactions.filter((r) => r.pushedAt >= cutoff);
|
|
49
|
+
return this.reactions.filter((r) => r.status !== "pending" && r.pushedAt >= cutoff);
|
|
28
50
|
}
|
|
29
51
|
|
|
30
52
|
async save(): Promise<void> {
|
|
@@ -49,7 +71,6 @@ export class ReactionTracker {
|
|
|
49
71
|
}
|
|
50
72
|
}
|
|
51
73
|
|
|
52
|
-
/** Rotate: keep only last 30 days */
|
|
53
74
|
rotate(): void {
|
|
54
75
|
const cutoff = Date.now() / 1000 - 30 * 86400;
|
|
55
76
|
this.reactions = this.reactions.filter((r) => r.pushedAt >= cutoff);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { ContextManager } from "../context.js";
|
|
3
|
+
|
|
4
|
+
export interface CheckTierState {
|
|
5
|
+
pingReceived: boolean;
|
|
6
|
+
calibrating: boolean;
|
|
7
|
+
calibrationEndsAt?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createCheckTierTool(ctx: ContextManager, getState: () => CheckTierState) {
|
|
11
|
+
return {
|
|
12
|
+
name: "check_tier",
|
|
13
|
+
label: "Check Device Tier",
|
|
14
|
+
description:
|
|
15
|
+
"Check the user's BetterClaw subscription tier and get instructions for how to access their device data. Call this first before accessing device data, or use your cached tier if still valid.",
|
|
16
|
+
parameters: Type.Object({}),
|
|
17
|
+
async execute(_id: string, _params: Record<string, unknown>) {
|
|
18
|
+
const state = getState();
|
|
19
|
+
|
|
20
|
+
if (!state.pingReceived) {
|
|
21
|
+
return {
|
|
22
|
+
content: [{
|
|
23
|
+
type: "text" as const,
|
|
24
|
+
text: JSON.stringify({
|
|
25
|
+
tier: "unknown",
|
|
26
|
+
dataPath: "Tier not yet determined — the device hasn't connected. Try again shortly.",
|
|
27
|
+
cacheUntil: Math.floor(Date.now() / 1000) + 60,
|
|
28
|
+
cacheInstruction: "Re-check in about a minute.",
|
|
29
|
+
}, null, 2),
|
|
30
|
+
}],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const runtime = ctx.getRuntimeState();
|
|
35
|
+
const cacheUntil = Math.floor(Date.now() / 1000) + 86400;
|
|
36
|
+
|
|
37
|
+
const isPremium = runtime.tier === "premium" || runtime.tier === "premium+";
|
|
38
|
+
|
|
39
|
+
const dataPath = isPremium
|
|
40
|
+
? "Use node commands for current device readings: location.get, device.battery, health.steps, health.heartrate, health.hrv, health.sleep, health.distance, health.restinghr, health.workouts, health.summary, geofence.list. Use get_context for patterns, trends, history, and broad situational awareness — its device snapshot may not be perfectly recent but is useful for the big picture."
|
|
41
|
+
: "Use get_context for all device data. This is a cached snapshot from the last time the user had BetterClaw open — check timestamps for freshness. You cannot query fresh data on free tier.";
|
|
42
|
+
|
|
43
|
+
const cacheInstruction = `Save your tier and data path to your memory. Re-check after cacheUntil (${new Date(cacheUntil * 1000).toISOString()}). Until then, use the cached tier to decide how to access device data.`;
|
|
44
|
+
|
|
45
|
+
const result: Record<string, unknown> = {
|
|
46
|
+
tier: runtime.tier,
|
|
47
|
+
dataPath,
|
|
48
|
+
cacheUntil,
|
|
49
|
+
cacheInstruction,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (state.calibrating) {
|
|
53
|
+
result.calibrating = true;
|
|
54
|
+
result.calibrationEndsAt = state.calibrationEndsAt;
|
|
55
|
+
result.calibrationNote = "BetterClaw's smart filtering is still calibrating — it needs a few days to learn your preferences. Events are being tracked but filtering is in rules-only mode.";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/tools/get-context.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Type } from "@sinclair/typebox";
|
|
2
1
|
import type { ContextManager } from "../context.js";
|
|
3
2
|
import { loadTriageProfile } from "../learner.js";
|
|
4
3
|
|
|
@@ -7,47 +6,47 @@ export function createGetContextTool(ctx: ContextManager, stateDir?: string) {
|
|
|
7
6
|
name: "get_context",
|
|
8
7
|
label: "Get Device Context",
|
|
9
8
|
description:
|
|
10
|
-
"Get
|
|
11
|
-
parameters:
|
|
12
|
-
|
|
13
|
-
Type.Array(Type.String(), {
|
|
14
|
-
description: "Sections to include. Omit for all. Options: device, activity, patterns, meta",
|
|
15
|
-
}),
|
|
16
|
-
),
|
|
17
|
-
}),
|
|
18
|
-
async execute(_id: string, params: Record<string, unknown>) {
|
|
19
|
-
const sections =
|
|
20
|
-
Array.isArray(params.include) && params.include.every((s) => typeof s === "string")
|
|
21
|
-
? (params.include as string[])
|
|
22
|
-
: ["device", "activity", "patterns", "meta"];
|
|
23
|
-
|
|
9
|
+
"Get BetterClaw context — patterns, trends, activity zone, event history, and cached device snapshots with staleness indicators. On premium, node commands return fresher data for current readings. On free, this includes the latest device snapshot.",
|
|
10
|
+
parameters: {},
|
|
11
|
+
async execute(_id: string, _params: Record<string, unknown>) {
|
|
24
12
|
const state = ctx.get();
|
|
25
13
|
const runtime = ctx.getRuntimeState();
|
|
26
14
|
const patterns = await ctx.readPatterns();
|
|
15
|
+
const dataAge = ctx.getDataAge();
|
|
16
|
+
|
|
17
|
+
const isPremium = runtime.tier === "premium" || runtime.tier === "premium+";
|
|
27
18
|
|
|
28
19
|
const result: Record<string, unknown> = {
|
|
29
|
-
|
|
20
|
+
tierHint: {
|
|
21
|
+
tier: runtime.tier,
|
|
22
|
+
note: isPremium
|
|
23
|
+
? "Node commands available for fresh readings (location.get, device.battery, health.*)"
|
|
24
|
+
: "This is the only data source on free tier — check dataAgeSeconds for freshness",
|
|
25
|
+
},
|
|
30
26
|
smartMode: runtime.smartMode,
|
|
31
27
|
};
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
29
|
+
result.device = {
|
|
30
|
+
battery: state.device.battery
|
|
31
|
+
? { ...state.device.battery, updatedAt: ctx.getTimestamp("battery"), dataAgeSeconds: dataAge.battery }
|
|
32
|
+
: null,
|
|
33
|
+
location: state.device.location
|
|
34
|
+
? { ...state.device.location, updatedAt: ctx.getTimestamp("location"), dataAgeSeconds: dataAge.location }
|
|
35
|
+
: null,
|
|
36
|
+
health: state.device.health
|
|
37
|
+
? { ...state.device.health, updatedAt: ctx.getTimestamp("health"), dataAgeSeconds: dataAge.health }
|
|
38
|
+
: null,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
result.activity = { ...state.activity, updatedAt: ctx.getTimestamp("activity") };
|
|
42
|
+
|
|
43
|
+
if (patterns) result.patterns = patterns;
|
|
44
|
+
|
|
45
|
+
result.meta = {
|
|
46
|
+
...state.meta,
|
|
47
|
+
lastSnapshotAt: ctx.getTimestamp("lastSnapshot"),
|
|
48
|
+
lastAnalysisAt: patterns?.computedAt,
|
|
49
|
+
};
|
|
51
50
|
|
|
52
51
|
const profile = stateDir ? await loadTriageProfile(stateDir) : null;
|
|
53
52
|
result.triageProfile = profile ? { summary: profile.summary, computedAt: profile.computedAt } : null;
|
package/src/triage.ts
CHANGED
|
@@ -11,6 +11,7 @@ export function buildTriagePrompt(
|
|
|
11
11
|
event: DeviceEvent,
|
|
12
12
|
context: ContextManager,
|
|
13
13
|
profile: TriageProfile | null,
|
|
14
|
+
budget?: { budgetUsed: number; budgetTotal: number },
|
|
14
15
|
): string {
|
|
15
16
|
const ctx = context.get();
|
|
16
17
|
const battery = ctx.device.battery;
|
|
@@ -33,12 +34,16 @@ export function buildTriagePrompt(
|
|
|
33
34
|
.filter(Boolean)
|
|
34
35
|
.join("\n");
|
|
35
36
|
|
|
37
|
+
const budgetSection = budget
|
|
38
|
+
? `## Push Budget\n${budget.budgetUsed} of ${budget.budgetTotal} pushes used today — be selective.`
|
|
39
|
+
: "";
|
|
40
|
+
|
|
36
41
|
return `You are an event triage system for a personal assistant. Decide whether this device event should be pushed as a notification to the user.
|
|
37
42
|
|
|
38
43
|
${profileSection}
|
|
39
44
|
|
|
40
45
|
${contextSection}
|
|
41
|
-
|
|
46
|
+
${budgetSection ? `\n${budgetSection}` : ""}
|
|
42
47
|
## Event
|
|
43
48
|
- Subscription: ${event.subscriptionId}
|
|
44
49
|
- Source: ${event.source}
|
|
@@ -60,7 +65,7 @@ export function parseTriageResponse(text: string): TriageResult {
|
|
|
60
65
|
priority: ["low", "normal", "high"].includes(parsed.priority) ? parsed.priority : undefined,
|
|
61
66
|
};
|
|
62
67
|
} catch {
|
|
63
|
-
return { push:
|
|
68
|
+
return { push: false, reason: "failed to parse triage response — defaulting to drop" };
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
|
@@ -68,15 +73,19 @@ export async function triageEvent(
|
|
|
68
73
|
event: DeviceEvent,
|
|
69
74
|
context: ContextManager,
|
|
70
75
|
profile: TriageProfile | null,
|
|
71
|
-
config: { triageModel: string; triageApiBase?: string },
|
|
76
|
+
config: { triageModel: string; triageApiBase?: string; budgetUsed?: number; budgetTotal?: number },
|
|
72
77
|
resolveApiKey: () => Promise<string | undefined>,
|
|
73
78
|
): Promise<TriageResult> {
|
|
74
|
-
const prompt = buildTriagePrompt(event, context, profile
|
|
79
|
+
const prompt = buildTriagePrompt(event, context, profile,
|
|
80
|
+
config.budgetUsed != null && config.budgetTotal != null
|
|
81
|
+
? { budgetUsed: config.budgetUsed, budgetTotal: config.budgetTotal }
|
|
82
|
+
: undefined
|
|
83
|
+
);
|
|
75
84
|
|
|
76
85
|
try {
|
|
77
86
|
const apiKey = await resolveApiKey();
|
|
78
87
|
if (!apiKey) {
|
|
79
|
-
return { push:
|
|
88
|
+
return { push: false, reason: "no API key for triage — defaulting to drop" };
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
const baseUrl = config.triageApiBase ?? "https://api.openai.com/v1";
|
|
@@ -116,7 +125,7 @@ export async function triageEvent(
|
|
|
116
125
|
});
|
|
117
126
|
|
|
118
127
|
if (!response.ok) {
|
|
119
|
-
return { push:
|
|
128
|
+
return { push: false, reason: `triage API error: ${response.status} — defaulting to drop` };
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
const data = (await response.json()) as {
|
|
@@ -124,11 +133,11 @@ export async function triageEvent(
|
|
|
124
133
|
};
|
|
125
134
|
const content = data.choices?.[0]?.message?.content;
|
|
126
135
|
if (!content) {
|
|
127
|
-
return { push:
|
|
136
|
+
return { push: false, reason: "empty triage response — defaulting to drop" };
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
return parseTriageResponse(content);
|
|
131
140
|
} catch (err) {
|
|
132
|
-
return { push:
|
|
141
|
+
return { push: false, reason: `triage call failed: ${err} — defaulting to drop` };
|
|
133
142
|
}
|
|
134
143
|
}
|
package/src/types.ts
CHANGED
|
@@ -76,7 +76,7 @@ export type FilterDecision =
|
|
|
76
76
|
|
|
77
77
|
export interface EventLogEntry {
|
|
78
78
|
event: DeviceEvent;
|
|
79
|
-
decision: "push" | "drop" | "stored";
|
|
79
|
+
decision: "push" | "drop" | "stored" | "free_stored";
|
|
80
80
|
reason: string;
|
|
81
81
|
timestamp: number;
|
|
82
82
|
}
|
|
@@ -114,7 +114,6 @@ export interface Patterns {
|
|
|
114
114
|
dropRate7d: number;
|
|
115
115
|
topSources: string[];
|
|
116
116
|
};
|
|
117
|
-
triggerCooldowns: Record<string, number>;
|
|
118
117
|
computedAt: number;
|
|
119
118
|
}
|
|
120
119
|
|
|
@@ -129,28 +128,27 @@ export interface PluginConfig {
|
|
|
129
128
|
analysisHour: number;
|
|
130
129
|
deduplicationCooldowns: Record<string, number>;
|
|
131
130
|
defaultCooldown: number;
|
|
131
|
+
calibrationDays: number;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
// Triage profile produced by daily learning agent
|
|
135
135
|
export interface TriageProfile {
|
|
136
|
-
|
|
137
|
-
lifeContext: string;
|
|
136
|
+
summary: string; // 1-2 sentence description of what the user cares about
|
|
138
137
|
interruptionTolerance: "low" | "normal" | "high";
|
|
139
|
-
|
|
140
|
-
sensitivityThresholds: Record<string, number>;
|
|
141
|
-
locationRules: Record<string, "push" | "drop" | "context-dependent">;
|
|
142
|
-
summary: string;
|
|
143
|
-
computedAt: number;
|
|
138
|
+
computedAt: number; // epoch seconds
|
|
144
139
|
}
|
|
145
140
|
|
|
146
141
|
// Reaction tracking for pushed events
|
|
142
|
+
export type ReactionStatus = "pending" | "engaged" | "ignored" | "unclear";
|
|
143
|
+
|
|
147
144
|
export interface ReactionEntry {
|
|
148
|
-
idempotencyKey: string;
|
|
149
145
|
subscriptionId: string;
|
|
150
146
|
source: string;
|
|
151
147
|
pushedAt: number;
|
|
152
|
-
|
|
153
|
-
|
|
148
|
+
messageSummary: string; // first ~100 chars of the pushed message
|
|
149
|
+
status: ReactionStatus;
|
|
150
|
+
classifiedAt?: number; // epoch when LLM classified
|
|
151
|
+
classificationReason?: string; // one-line reason from classifier
|
|
154
152
|
}
|
|
155
153
|
|
|
156
154
|
// Per-device config from betterclaw.config RPC
|
package/openclaw-plugin-sdk.md
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# OpenClaw Plugin SDK — What Plugins Can and Cannot Do
|
|
2
|
-
|
|
3
|
-
## No LLM/Agent Access from Plugins
|
|
4
|
-
|
|
5
|
-
Plugins **cannot** invoke LLMs or spawn agent sessions. This was discovered through deep investigation of the OpenClaw source code and plugin SDK.
|
|
6
|
-
|
|
7
|
-
### What we tried
|
|
8
|
-
- `runEmbeddedPiAgent` — an internal OpenClaw function used by the core agent. Both betteremail and betterclaw plugins tried to import it via `openclaw/agents/pi-embedded`. This **does not work** because it's not exported in the plugin SDK.
|
|
9
|
-
|
|
10
|
-
### Why it doesn't work
|
|
11
|
-
- The plugin SDK (`openclaw/plugin-sdk`) only exports a limited surface:
|
|
12
|
-
- `OpenClawPluginApi` — the `api` object passed to `register()`
|
|
13
|
-
- `openclaw/plugin-sdk/llm-task` — exists as an export path but provides no agent invocation
|
|
14
|
-
- `openclaw/agents/*` is **not an exported path** — the import fails at runtime via Jiti (TypeScript loader)
|
|
15
|
-
- `runEmbeddedPiAgent` is internal to OpenClaw's agent system and intentionally not exposed to plugins
|
|
16
|
-
- The betterclaw plugin has the **exact same bug** — it also imports `runEmbeddedPiAgent` and has never successfully run its judgment layer
|
|
17
|
-
|
|
18
|
-
### What plugins CAN do
|
|
19
|
-
- `api.registerTool()` — expose tools to the agent (with optional `{ optional: true }` flag)
|
|
20
|
-
- `api.registerService()` — run background services (polling loops, etc.)
|
|
21
|
-
- `api.registerCommand()` — add `/commands` to the chat UI
|
|
22
|
-
- `api.runtime.system.runCommandWithTimeout()` — run shell commands (including `gog`, `openclaw` CLI)
|
|
23
|
-
- `api.runtime.state.resolveStateDir()` — get persistent state directory
|
|
24
|
-
- `api.pluginConfig` — read plugin config from openclaw.yaml
|
|
25
|
-
- `api.logger` — structured logging
|
|
26
|
-
|
|
27
|
-
### What plugins CANNOT do
|
|
28
|
-
- Invoke LLMs or create agent sessions
|
|
29
|
-
- Import internal OpenClaw modules outside the plugin SDK exports
|
|
30
|
-
- Access `runEmbeddedPiAgent` or any agent runtime internals
|
|
31
|
-
- Run embedded "sub-agents" for classification or judgment
|
|
32
|
-
|
|
33
|
-
### The solution we adopted
|
|
34
|
-
Instead of classifying emails in the plugin, we removed the classifier entirely and made the plugin a **pure stateful memory layer**. All emails enter the digest, and the agent triages them during cron-triggered sessions with full context (memory, skills, user preferences). This is actually better because:
|
|
35
|
-
1. The agent has full context for triage decisions
|
|
36
|
-
2. No separate LLM invocation cost
|
|
37
|
-
3. Simpler architecture — the plugin just tracks state
|
|
38
|
-
4. Cron job runs in isolated session, only notifies main session when something matters
|
package/src/triggers.ts
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
-
import type { ContextManager } from "./context.js";
|
|
3
|
-
import type { DeviceContext, Patterns, PluginConfig } from "./types.js";
|
|
4
|
-
|
|
5
|
-
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
6
|
-
|
|
7
|
-
interface TriggerResult {
|
|
8
|
-
id: string;
|
|
9
|
-
message: string;
|
|
10
|
-
priority: "low" | "normal" | "high";
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type TriggerCheck = (ctx: DeviceContext, patterns: Patterns) => TriggerResult | null;
|
|
14
|
-
|
|
15
|
-
const TRIGGER_COOLDOWNS: Record<string, number> = {
|
|
16
|
-
"low-battery-away": 4 * 3600,
|
|
17
|
-
"unusual-inactivity": 6 * 3600,
|
|
18
|
-
"sleep-deficit": 24 * 3600,
|
|
19
|
-
"routine-deviation": 4 * 3600,
|
|
20
|
-
"health-weekly-digest": 7 * 86400,
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const triggers: Array<{ id: string; schedule: "hourly" | "daily" | "weekly"; check: TriggerCheck }> = [
|
|
24
|
-
{
|
|
25
|
-
id: "low-battery-away",
|
|
26
|
-
schedule: "hourly",
|
|
27
|
-
check: (ctx, patterns) => {
|
|
28
|
-
const battery = ctx.device.battery;
|
|
29
|
-
if (!battery || battery.level >= 0.3) return null;
|
|
30
|
-
if (ctx.activity.currentZone === "Home") return null;
|
|
31
|
-
|
|
32
|
-
const drain = patterns.batteryPatterns.avgDrainPerHour ?? 0.04;
|
|
33
|
-
const hoursRemaining = drain > 0 ? Math.round(battery.level / drain) : 0;
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
id: "low-battery-away",
|
|
37
|
-
message: `🔋 Battery at ${Math.round(battery.level * 100)}%, draining ~${Math.round(drain * 100)}%/hr. You're away from home — estimated ${hoursRemaining}h remaining. Consider charging.`,
|
|
38
|
-
priority: battery.level < 0.15 ? "high" : "normal",
|
|
39
|
-
};
|
|
40
|
-
},
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
id: "unusual-inactivity",
|
|
44
|
-
schedule: "hourly",
|
|
45
|
-
check: (ctx, patterns) => {
|
|
46
|
-
const hour = new Date().getHours();
|
|
47
|
-
if (hour < 12) return null;
|
|
48
|
-
|
|
49
|
-
const steps = ctx.device.health?.stepsToday;
|
|
50
|
-
const avg = patterns.healthTrends.stepsAvg7d;
|
|
51
|
-
if (steps == null || avg == null) return null;
|
|
52
|
-
|
|
53
|
-
const expectedByNow = avg * (hour / 24);
|
|
54
|
-
if (steps >= expectedByNow * 0.5) return null;
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
id: "unusual-inactivity",
|
|
58
|
-
message: `🚶 It's ${hour}:00 and you've done ${Math.round(steps).toLocaleString()} steps (usually ~${Math.round(expectedByNow).toLocaleString()} by now). Everything okay?`,
|
|
59
|
-
priority: "low",
|
|
60
|
-
};
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
id: "sleep-deficit",
|
|
65
|
-
schedule: "daily",
|
|
66
|
-
check: (ctx, patterns) => {
|
|
67
|
-
const hour = new Date().getHours();
|
|
68
|
-
if (hour < 7 || hour > 10) return null;
|
|
69
|
-
|
|
70
|
-
const sleep = ctx.device.health?.sleepDurationSeconds;
|
|
71
|
-
const avg = patterns.healthTrends.sleepAvg7d;
|
|
72
|
-
if (sleep == null || avg == null) return null;
|
|
73
|
-
|
|
74
|
-
const deficit = avg - sleep;
|
|
75
|
-
if (deficit < 3600) return null;
|
|
76
|
-
|
|
77
|
-
const sleepH = Math.floor(sleep / 3600);
|
|
78
|
-
const sleepM = Math.round((sleep % 3600) / 60);
|
|
79
|
-
const avgH = Math.floor(avg / 3600);
|
|
80
|
-
const avgM = Math.round((avg % 3600) / 60);
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
id: "sleep-deficit",
|
|
84
|
-
message: `😴 You slept ${sleepH}h${sleepM}m last night (your average is ${avgH}h${avgM}m). Might want to take it easy today.`,
|
|
85
|
-
priority: "low",
|
|
86
|
-
};
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
id: "routine-deviation",
|
|
91
|
-
schedule: "hourly",
|
|
92
|
-
check: (ctx, patterns) => {
|
|
93
|
-
const now = new Date();
|
|
94
|
-
const day = now.getDay();
|
|
95
|
-
const isWeekday = day >= 1 && day <= 5;
|
|
96
|
-
if (!isWeekday) return null;
|
|
97
|
-
|
|
98
|
-
const hour = now.getHours() + now.getMinutes() / 60;
|
|
99
|
-
const routines = patterns.locationRoutines.weekday;
|
|
100
|
-
|
|
101
|
-
for (const routine of routines) {
|
|
102
|
-
if (!routine.typicalLeave) continue;
|
|
103
|
-
const [h, m] = routine.typicalLeave.split(":").map(Number);
|
|
104
|
-
const typicalLeaveHour = h + m / 60;
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
ctx.activity.currentZone === routine.zone &&
|
|
108
|
-
hour > typicalLeaveHour + 1.5
|
|
109
|
-
) {
|
|
110
|
-
return {
|
|
111
|
-
id: "routine-deviation",
|
|
112
|
-
message: `📅 It's ${now.getHours()}:${String(now.getMinutes()).padStart(2, "0")} on ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day]} and you haven't left ${routine.zone} (usually leave at ${routine.typicalLeave}). Just noting in case.`,
|
|
113
|
-
priority: "low",
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return null;
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
id: "health-weekly-digest",
|
|
123
|
-
schedule: "weekly",
|
|
124
|
-
check: (ctx, patterns) => {
|
|
125
|
-
if (new Date().getDay() !== 0) return null;
|
|
126
|
-
const hour = new Date().getHours();
|
|
127
|
-
if (hour < 9 || hour > 11) return null;
|
|
128
|
-
|
|
129
|
-
const trends = patterns.healthTrends;
|
|
130
|
-
const stats = patterns.eventStats;
|
|
131
|
-
|
|
132
|
-
const parts: string[] = [];
|
|
133
|
-
if (trends.stepsAvg7d != null) {
|
|
134
|
-
const trend = trends.stepsTrend ? ` (${trends.stepsTrend})` : "";
|
|
135
|
-
parts.push(`Avg steps: ${Math.round(trends.stepsAvg7d).toLocaleString()}/day${trend}`);
|
|
136
|
-
}
|
|
137
|
-
if (trends.sleepAvg7d != null) {
|
|
138
|
-
const h = Math.floor(trends.sleepAvg7d / 3600);
|
|
139
|
-
const m = Math.round((trends.sleepAvg7d % 3600) / 60);
|
|
140
|
-
parts.push(`Avg sleep: ${h}h${m}m`);
|
|
141
|
-
}
|
|
142
|
-
if (trends.restingHrAvg7d != null) {
|
|
143
|
-
parts.push(`Resting HR: ${Math.round(trends.restingHrAvg7d)}bpm`);
|
|
144
|
-
}
|
|
145
|
-
parts.push(`Events: ${stats.eventsPerDay7d.toFixed(1)}/day, ${Math.round(stats.dropRate7d * 100)}% filtered`);
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
id: "health-weekly-digest",
|
|
149
|
-
message: `📊 Weekly health digest\n\n${parts.join("\n")}`,
|
|
150
|
-
priority: "low",
|
|
151
|
-
};
|
|
152
|
-
},
|
|
153
|
-
},
|
|
154
|
-
];
|
|
155
|
-
|
|
156
|
-
export class ProactiveEngine {
|
|
157
|
-
private context: ContextManager;
|
|
158
|
-
private api: OpenClawPluginApi;
|
|
159
|
-
private config: PluginConfig;
|
|
160
|
-
private interval: ReturnType<typeof setInterval> | null = null;
|
|
161
|
-
|
|
162
|
-
constructor(context: ContextManager, api: OpenClawPluginApi, config: PluginConfig) {
|
|
163
|
-
this.context = context;
|
|
164
|
-
this.api = api;
|
|
165
|
-
this.config = config;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
startSchedule(): void {
|
|
169
|
-
if (!this.config.proactiveEnabled) return;
|
|
170
|
-
|
|
171
|
-
this.interval = setInterval(() => {
|
|
172
|
-
void this.checkAll().catch((err) => {
|
|
173
|
-
this.api.logger.error(`betterclaw: proactive check failed: ${err}`);
|
|
174
|
-
});
|
|
175
|
-
}, ONE_HOUR_MS);
|
|
176
|
-
this.interval.unref?.();
|
|
177
|
-
|
|
178
|
-
// Run initial check after 5 minutes (let context populate)
|
|
179
|
-
setTimeout(() => {
|
|
180
|
-
void this.checkAll().catch(() => {});
|
|
181
|
-
}, 5 * 60 * 1000).unref?.();
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
stopSchedule(): void {
|
|
185
|
-
if (this.interval) {
|
|
186
|
-
clearInterval(this.interval);
|
|
187
|
-
this.interval = null;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
async checkAll(): Promise<void> {
|
|
192
|
-
if (!this.context.getRuntimeState().smartMode) {
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
const deviceConfig = this.context.getDeviceConfig();
|
|
196
|
-
if (deviceConfig.proactiveEnabled === false) {
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
const ctx = this.context.get();
|
|
200
|
-
const patterns = (await this.context.readPatterns()) ?? (await import("./patterns.js")).emptyPatterns();
|
|
201
|
-
|
|
202
|
-
for (const trigger of triggers) {
|
|
203
|
-
const lastFired = patterns.triggerCooldowns[trigger.id] ?? 0;
|
|
204
|
-
const cooldown = TRIGGER_COOLDOWNS[trigger.id] ?? 3600;
|
|
205
|
-
if (Date.now() / 1000 - lastFired < cooldown) continue;
|
|
206
|
-
|
|
207
|
-
const result = trigger.check(ctx, patterns);
|
|
208
|
-
if (!result) continue;
|
|
209
|
-
|
|
210
|
-
this.api.logger.info(`betterclaw: proactive trigger fired: ${trigger.id}`);
|
|
211
|
-
|
|
212
|
-
// Write cooldown BEFORE push to prevent runaway retries on failure
|
|
213
|
-
patterns.triggerCooldowns[trigger.id] = Date.now() / 1000;
|
|
214
|
-
await this.context.writePatterns(patterns);
|
|
215
|
-
|
|
216
|
-
const message = `[BetterClaw proactive insight — combined signal analysis]\n\n${result.message}`;
|
|
217
|
-
|
|
218
|
-
try {
|
|
219
|
-
await this.api.runtime.subagent.run({
|
|
220
|
-
sessionKey: "main",
|
|
221
|
-
message,
|
|
222
|
-
deliver: true,
|
|
223
|
-
idempotencyKey: `trigger-${trigger.id}-${Math.floor(Date.now() / 1000)}`,
|
|
224
|
-
});
|
|
225
|
-
} catch (err) {
|
|
226
|
-
this.api.logger.error(
|
|
227
|
-
`betterclaw: trigger push failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|