@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.
@@ -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: Omit<ReactionEntry, "engaged" | "checkedAt">): void {
14
- this.reactions.push({ ...entry, engaged: null });
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
- markEngaged(idempotencyKey: string, engaged: boolean): void {
18
- const entry = this.reactions.find((r) => r.idempotencyKey === idempotencyKey);
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.engaged = engaged;
21
- entry.checkedAt = Date.now() / 1000;
26
+ entry.status = status;
27
+ entry.classifiedAt = Date.now() / 1000;
28
+ entry.classificationReason = reason;
22
29
  }
23
30
  }
24
31
 
25
- getRecent(hours: number): ReactionEntry[] {
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
+ }
@@ -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 the current physical context of the user's iPhone battery, location, health metrics, activity zone, patterns, and trends. Call this when you need to know about the user's physical state.",
11
- parameters: Type.Object({
12
- include: Type.Optional(
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
- tier: runtime.tier,
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
- if (sections.includes("device")) {
34
- result.device = {
35
- battery: state.device.battery ? { ...state.device.battery, updatedAt: ctx.getTimestamp("battery") } : null,
36
- location: state.device.location ? { ...state.device.location, updatedAt: ctx.getTimestamp("location") } : null,
37
- health: state.device.health ? { ...state.device.health, updatedAt: ctx.getTimestamp("health") } : null,
38
- };
39
- }
40
- if (sections.includes("activity")) {
41
- result.activity = { ...state.activity, updatedAt: ctx.getTimestamp("activity") };
42
- }
43
- if (sections.includes("patterns") && patterns) result.patterns = patterns;
44
- if (sections.includes("meta")) {
45
- result.meta = {
46
- ...state.meta,
47
- lastSnapshotAt: ctx.getTimestamp("lastSnapshot"),
48
- lastAnalysisAt: patterns?.computedAt,
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: true, reason: "failed to parse triage response" };
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: true, reason: "no API key for triage — defaulting to 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: true, reason: `triage API error: ${response.status}` };
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: true, reason: "empty triage response" };
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: true, reason: `triage call failed: ${err}` };
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
- eventPreferences: Record<string, "push" | "drop" | "context-dependent">;
137
- lifeContext: string;
136
+ summary: string; // 1-2 sentence description of what the user cares about
138
137
  interruptionTolerance: "low" | "normal" | "high";
139
- timePreferences: { quietHoursStart?: number; quietHoursEnd?: number; activeStart?: number; activeEnd?: number };
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
- engaged: boolean | null; // null = not yet determined
153
- checkedAt?: number;
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
@@ -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