@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
|
@@ -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
|