@better_openclaw/betterclaw 1.4.0 → 2.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/src/index.ts CHANGED
@@ -1,42 +1,39 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import type { PluginConfig } from "./types.js";
2
+ import type { PluginConfig, DeviceConfig } from "./types.js";
3
3
  import { ContextManager } from "./context.js";
4
4
  import { createGetContextTool } from "./tools/get-context.js";
5
5
  import { EventLog } from "./events.js";
6
6
  import { RulesEngine } from "./filter.js";
7
- import { JudgmentLayer } from "./judgment.js";
8
7
  import { PatternEngine } from "./patterns.js";
9
8
  import { ProactiveEngine } from "./triggers.js";
10
9
  import { processEvent } from "./pipeline.js";
11
10
  import type { PipelineDeps } from "./pipeline.js";
11
+ import { BETTERCLAW_COMMANDS, mergeAllowCommands } from "./cli.js";
12
+ import { loadTriageProfile, runLearner } from "./learner.js";
13
+ import { ReactionTracker } from "./reactions.js";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
12
16
 
13
17
  export type { PluginConfig } from "./types.js";
14
18
 
15
19
  const DEFAULT_CONFIG: PluginConfig = {
16
- llmModel: "openai/gpt-4o-mini",
20
+ triageModel: "openai/gpt-4o-mini",
21
+ triageApiBase: undefined,
17
22
  pushBudgetPerDay: 10,
18
23
  patternWindowDays: 14,
19
24
  proactiveEnabled: true,
25
+ analysisHour: 5,
20
26
  };
21
27
 
22
28
  function resolveConfig(raw: Record<string, unknown> | undefined): PluginConfig {
29
+ const cfg = raw ?? {};
23
30
  return {
24
- llmModel:
25
- typeof raw?.llmModel === "string" && raw.llmModel.trim()
26
- ? raw.llmModel.trim()
27
- : DEFAULT_CONFIG.llmModel,
28
- pushBudgetPerDay:
29
- typeof raw?.pushBudgetPerDay === "number" && raw.pushBudgetPerDay > 0
30
- ? raw.pushBudgetPerDay
31
- : DEFAULT_CONFIG.pushBudgetPerDay,
32
- patternWindowDays:
33
- typeof raw?.patternWindowDays === "number" && raw.patternWindowDays > 0
34
- ? raw.patternWindowDays
35
- : DEFAULT_CONFIG.patternWindowDays,
36
- proactiveEnabled:
37
- typeof raw?.proactiveEnabled === "boolean"
38
- ? raw.proactiveEnabled
39
- : DEFAULT_CONFIG.proactiveEnabled,
31
+ triageModel: (cfg.triageModel as string) ?? (cfg.llmModel as string) ?? "openai/gpt-4o-mini",
32
+ triageApiBase: (cfg.triageApiBase as string) ?? undefined,
33
+ pushBudgetPerDay: typeof cfg.pushBudgetPerDay === "number" && cfg.pushBudgetPerDay > 0 ? cfg.pushBudgetPerDay : 10,
34
+ patternWindowDays: typeof cfg.patternWindowDays === "number" && cfg.patternWindowDays > 0 ? cfg.patternWindowDays : 14,
35
+ proactiveEnabled: typeof cfg.proactiveEnabled === "boolean" ? cfg.proactiveEnabled : true,
36
+ analysisHour: typeof cfg.analysisHour === "number" ? Math.max(0, Math.min(23, cfg.analysisHour)) : 5,
40
37
  };
41
38
  }
42
39
 
@@ -48,15 +45,15 @@ export default {
48
45
  const config = resolveConfig(api.pluginConfig as Record<string, unknown> | undefined);
49
46
  const stateDir = api.runtime.state.resolveStateDir();
50
47
 
51
- api.logger.info(`betterclaw plugin loaded (model=${config.llmModel}, budget=${config.pushBudgetPerDay})`);
48
+ api.logger.info(`betterclaw plugin loaded (model=${config.triageModel}, budget=${config.pushBudgetPerDay})`);
52
49
 
53
50
  // Context manager (load synchronously — file read deferred to first access)
54
51
  const ctxManager = new ContextManager(stateDir);
55
52
 
56
- // Event log, rules engine, judgment layer
53
+ // Event log, rules engine, reaction tracker
57
54
  const eventLog = new EventLog(stateDir);
58
55
  const rules = new RulesEngine(config.pushBudgetPerDay);
59
- const judgment = new JudgmentLayer(api, config);
56
+ const reactionTracker = new ReactionTracker(stateDir);
60
57
 
61
58
  // Pipeline dependencies
62
59
  const pipelineDeps: PipelineDeps = {
@@ -65,108 +62,192 @@ export default {
65
62
  context: ctxManager,
66
63
  events: eventLog,
67
64
  rules,
68
- judgment,
65
+ reactions: reactionTracker,
66
+ stateDir,
69
67
  };
70
68
 
69
+ const pluginVersion = "2.0.0";
70
+
71
71
  // Track whether async init has completed
72
72
  let initialized = false;
73
73
  const initPromise = (async () => {
74
74
  try {
75
75
  await ctxManager.load();
76
- const recentEvents = await eventLog.readSince(Date.now() / 1000 - 86400);
77
- rules.restoreCooldowns(
78
- recentEvents
79
- .filter((e) => e.decision === "push")
80
- .map((e) => ({ subscriptionId: e.event.subscriptionId, firedAt: e.event.firedAt })),
81
- );
82
76
  initialized = true;
83
- api.logger.info("betterclaw: async init complete");
84
77
  } catch (err) {
85
- api.logger.error(`betterclaw: init failed: ${err instanceof Error ? err.message : String(err)}`);
78
+ api.logger.error(`betterclaw: context init failed: ${err instanceof Error ? err.message : String(err)}`);
79
+ }
80
+ try {
81
+ await reactionTracker.load();
82
+ } catch (err) {
83
+ api.logger.warn(`betterclaw: reaction tracker load failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
84
+ }
85
+ if (initialized) {
86
+ try {
87
+ const recentEvents = await eventLog.readSince(Date.now() / 1000 - 86400);
88
+ rules.restoreCooldowns(
89
+ recentEvents
90
+ .filter((e) => e.decision === "push")
91
+ .map((e) => ({ subscriptionId: e.event.subscriptionId, firedAt: e.event.firedAt })),
92
+ );
93
+ api.logger.info("betterclaw: async init complete");
94
+ } catch (err) {
95
+ api.logger.error(`betterclaw: cooldown restore failed: ${err instanceof Error ? err.message : String(err)}`);
96
+ }
86
97
  }
87
98
  })();
88
99
 
89
100
  // Ping health check
90
- api.registerGatewayMethod("betterclaw.ping", ({ respond }) => {
91
- respond(true, { ok: true, version: "1.0.0", initialized });
101
+ api.registerGatewayMethod("betterclaw.ping", ({ params, respond }) => {
102
+ const validTiers: Array<"free" | "premium" | "premium+"> = ["free", "premium", "premium+"];
103
+ const rawTier = (params as Record<string, unknown>)?.tier as string;
104
+ const tier = validTiers.includes(rawTier as any) ? (rawTier as "free" | "premium" | "premium+") : "free";
105
+ const smartMode = (params as Record<string, unknown>)?.smartMode === true;
106
+
107
+ ctxManager.setRuntimeState({ tier, smartMode });
108
+
109
+ const meta = ctxManager.get().meta;
110
+ const effectiveBudget = ctxManager.getDeviceConfig().pushBudgetPerDay ?? config.pushBudgetPerDay;
111
+ respond(true, {
112
+ ok: true,
113
+ version: pluginVersion,
114
+ initialized,
115
+ pushesToday: meta.pushesToday,
116
+ budgetRemaining: Math.max(0, effectiveBudget - meta.pushesToday),
117
+ });
118
+ });
119
+
120
+ // Config RPC — update per-device settings at runtime
121
+ api.registerGatewayMethod("betterclaw.config", async ({ params, respond }) => {
122
+ // Note: config save runs outside the event queue. Concurrent saves with processEvent
123
+ // could race on context.json. Accepted risk — config changes are user-initiated and infrequent.
124
+ try {
125
+ const update = params as Record<string, unknown>;
126
+ const deviceConfig: DeviceConfig = {};
127
+
128
+ if (typeof update.pushBudgetPerDay === "number") {
129
+ deviceConfig.pushBudgetPerDay = update.pushBudgetPerDay;
130
+ }
131
+ if (typeof update.proactiveEnabled === "boolean") {
132
+ deviceConfig.proactiveEnabled = update.proactiveEnabled;
133
+ }
134
+
135
+ ctxManager.setDeviceConfig(deviceConfig);
136
+ await ctxManager.save();
137
+ respond(true, { applied: true });
138
+ } catch (err) {
139
+ api.logger.error(`betterclaw.config error: ${err instanceof Error ? err.message : String(err)}`);
140
+ respond(false, undefined, { code: "INTERNAL_ERROR", message: "config update failed" });
141
+ }
92
142
  });
93
143
 
94
144
  // Context RPC — returns activity, trends, and recent decisions for iOS Context tab
95
145
  api.registerGatewayMethod("betterclaw.context", async ({ respond }) => {
96
- if (!initialized) await initPromise;
97
-
98
- const state = ctxManager.get();
99
- const patterns = await ctxManager.readPatterns();
100
- const recentEntries = await eventLog.readRecent(20);
101
-
102
- const activity = {
103
- currentZone: state.activity.currentZone,
104
- zoneEnteredAt: state.activity.zoneEnteredAt,
105
- lastTransition: state.activity.lastTransition,
106
- isStationary: state.activity.isStationary,
107
- stationarySince: state.activity.stationarySince,
108
- };
109
-
110
- const trends = patterns
111
- ? {
112
- stepsAvg7d: patterns.healthTrends.stepsAvg7d,
113
- stepsTrend: patterns.healthTrends.stepsTrend,
114
- sleepAvg7d: patterns.healthTrends.sleepAvg7d,
115
- sleepTrend: patterns.healthTrends.sleepTrend,
116
- restingHrAvg7d: patterns.healthTrends.restingHrAvg7d,
117
- restingHrTrend: patterns.healthTrends.restingHrTrend,
118
- eventsPerDay7d: patterns.eventStats.eventsPerDay7d,
119
- pushesPerDay7d: patterns.eventStats.pushesPerDay7d,
120
- dropRate7d: patterns.eventStats.dropRate7d,
121
- }
122
- : null;
123
-
124
- const decisions = recentEntries.map((e) => ({
125
- source: e.event.source,
126
- title: e.event.subscriptionId,
127
- decision: e.decision,
128
- reason: e.reason,
129
- timestamp: e.timestamp,
130
- }));
131
-
132
- const meta = {
133
- pushesToday: state.meta.pushesToday,
134
- pushBudgetPerDay: config.pushBudgetPerDay,
135
- eventsToday: state.meta.eventsToday,
136
- };
137
-
138
- const routines = patterns
139
- ? {
140
- weekday: patterns.locationRoutines.weekday,
141
- weekend: patterns.locationRoutines.weekend,
142
- }
143
- : null;
146
+ try {
147
+ if (!initialized) await initPromise;
144
148
 
145
- respond(true, { activity, trends, decisions, meta, routines });
149
+ const state = ctxManager.get();
150
+ const runtime = ctxManager.getRuntimeState();
151
+ const timestamps = {
152
+ battery: ctxManager.getTimestamp("battery") ?? null,
153
+ location: ctxManager.getTimestamp("location") ?? null,
154
+ health: ctxManager.getTimestamp("health") ?? null,
155
+ activity: ctxManager.getTimestamp("activity") ?? null,
156
+ lastSnapshot: ctxManager.getTimestamp("lastSnapshot") ?? null,
157
+ };
158
+ const patterns = await ctxManager.readPatterns();
159
+ const recentEntries = await eventLog.readRecent(20);
160
+ const profile = await loadTriageProfile(stateDir);
161
+
162
+ const activity = {
163
+ currentZone: state.activity.currentZone,
164
+ zoneEnteredAt: state.activity.zoneEnteredAt,
165
+ lastTransition: state.activity.lastTransition,
166
+ isStationary: state.activity.isStationary,
167
+ stationarySince: state.activity.stationarySince,
168
+ };
169
+
170
+ const trends = patterns
171
+ ? {
172
+ stepsAvg7d: patterns.healthTrends.stepsAvg7d,
173
+ stepsTrend: patterns.healthTrends.stepsTrend,
174
+ sleepAvg7d: patterns.healthTrends.sleepAvg7d,
175
+ sleepTrend: patterns.healthTrends.sleepTrend,
176
+ restingHrAvg7d: patterns.healthTrends.restingHrAvg7d,
177
+ restingHrTrend: patterns.healthTrends.restingHrTrend,
178
+ eventsPerDay7d: patterns.eventStats.eventsPerDay7d,
179
+ pushesPerDay7d: patterns.eventStats.pushesPerDay7d,
180
+ dropRate7d: patterns.eventStats.dropRate7d,
181
+ }
182
+ : null;
183
+
184
+ const decisions = recentEntries.map((e) => ({
185
+ source: e.event.source,
186
+ title: e.event.subscriptionId,
187
+ decision: e.decision,
188
+ reason: e.reason,
189
+ timestamp: e.timestamp,
190
+ }));
191
+
192
+ const meta = {
193
+ pushesToday: state.meta.pushesToday,
194
+ pushBudgetPerDay: config.pushBudgetPerDay,
195
+ eventsToday: state.meta.eventsToday,
196
+ lastSnapshotAt: timestamps.lastSnapshot,
197
+ lastAnalysisAt: patterns?.computedAt,
198
+ };
199
+
200
+ const routines = patterns
201
+ ? {
202
+ weekday: patterns.locationRoutines.weekday,
203
+ weekend: patterns.locationRoutines.weekend,
204
+ }
205
+ : null;
206
+
207
+ respond(true, {
208
+ tier: runtime.tier,
209
+ smartMode: runtime.smartMode,
210
+ activity,
211
+ trends,
212
+ decisions,
213
+ meta,
214
+ routines,
215
+ timestamps,
216
+ triageProfile: profile ? { summary: profile.summary, computedAt: profile.computedAt } : null,
217
+ });
218
+ } catch (err) {
219
+ api.logger.error(`betterclaw.context error: ${err instanceof Error ? err.message : String(err)}`);
220
+ respond(false, undefined, { code: "INTERNAL_ERROR", message: "context fetch failed" });
221
+ }
146
222
  });
147
223
 
148
224
  // Snapshot RPC — bulk-apply device state for Smart Mode catch-up
149
225
  api.registerGatewayMethod("betterclaw.snapshot", async ({ params, respond }) => {
150
- if (!initialized) await initPromise;
151
-
152
- const snapshot = params as {
153
- battery?: { level: number; state: string; isLowPowerMode: boolean };
154
- location?: { latitude: number; longitude: number };
155
- health?: {
156
- stepsToday?: number; distanceMeters?: number; heartRateAvg?: number;
157
- restingHeartRate?: number; hrv?: number; activeEnergyKcal?: number;
158
- sleepDurationSeconds?: number;
226
+ try {
227
+ if (!initialized) await initPromise;
228
+
229
+ const snapshot = params as {
230
+ battery?: { level: number; state: string; isLowPowerMode: boolean };
231
+ location?: { latitude: number; longitude: number };
232
+ health?: {
233
+ stepsToday?: number; distanceMeters?: number; heartRateAvg?: number;
234
+ restingHeartRate?: number; hrv?: number; activeEnergyKcal?: number;
235
+ sleepDurationSeconds?: number;
236
+ };
237
+ geofence?: { type: string; zoneName: string; latitude: number; longitude: number };
159
238
  };
160
- geofence?: { type: string; zoneName: string; latitude: number; longitude: number };
161
- };
162
239
 
163
- ctxManager.applySnapshot(snapshot);
164
- await ctxManager.save();
165
- respond(true, { applied: true });
240
+ ctxManager.applySnapshot(snapshot);
241
+ await ctxManager.save();
242
+ respond(true, { applied: true });
243
+ } catch (err) {
244
+ api.logger.error(`betterclaw.snapshot error: ${err instanceof Error ? err.message : String(err)}`);
245
+ respond(false, undefined, { code: "INTERNAL_ERROR", message: "snapshot apply failed" });
246
+ }
166
247
  });
167
248
 
168
249
  // Agent tool
169
- api.registerTool(createGetContextTool(ctxManager), { optional: true });
250
+ api.registerTool(createGetContextTool(ctxManager, stateDir), { optional: true });
170
251
 
171
252
  // Auto-reply command
172
253
  api.registerCommand({
@@ -201,6 +282,9 @@ export default {
201
282
  },
202
283
  });
203
284
 
285
+ // Sequential event queue — prevents budget races
286
+ let eventQueue: Promise<void> = Promise.resolve();
287
+
204
288
  // Event intake RPC
205
289
  api.registerGatewayMethod("betterclaw.event", async ({ params, respond }) => {
206
290
  try {
@@ -223,9 +307,14 @@ export default {
223
307
  }
224
308
 
225
309
  respond(true, { accepted: true });
226
- await processEvent(pipelineDeps, event);
310
+
311
+ // Sequential processing — prevents budget races
312
+ eventQueue = eventQueue.then(() => processEvent(pipelineDeps, event)).catch((err) => {
313
+ api.logger.error(`event processing failed: ${err}`);
314
+ });
227
315
  } catch (err) {
228
316
  api.logger.error(`betterclaw.event handler error: ${err instanceof Error ? err.message : String(err)}`);
317
+ respond(false, undefined, { code: "INTERNAL_ERROR", message: "event processing failed" });
229
318
  }
230
319
  });
231
320
 
@@ -237,7 +326,24 @@ export default {
237
326
  api.registerService({
238
327
  id: "betterclaw-engine",
239
328
  start: () => {
240
- patternEngine.startSchedule();
329
+ patternEngine.startSchedule(config.analysisHour, async () => {
330
+ // Run learner after patterns (only if smartMode ON)
331
+ if (ctxManager.getRuntimeState().smartMode) {
332
+ try {
333
+ await runLearner({
334
+ stateDir,
335
+ workspaceDir: path.join(os.homedir(), ".openclaw", "workspace"),
336
+ context: ctxManager,
337
+ events: eventLog,
338
+ reactions: reactionTracker,
339
+ api,
340
+ });
341
+ api.logger.info("betterclaw: daily learner completed");
342
+ } catch (err) {
343
+ api.logger.error(`betterclaw: daily learner failed: ${err}`);
344
+ }
345
+ }
346
+ });
241
347
  proactiveEngine.startSchedule();
242
348
  api.logger.info("betterclaw: background services started");
243
349
  },
@@ -247,5 +353,52 @@ export default {
247
353
  api.logger.info("betterclaw: background services stopped");
248
354
  },
249
355
  });
356
+
357
+ // CLI setup command
358
+ api.registerCli(
359
+ ({ program }) => {
360
+ const cmd = program.command("betterclaw").description("BetterClaw plugin management");
361
+
362
+ cmd
363
+ .command("setup")
364
+ .description("Configure gateway allowedCommands for BetterClaw")
365
+ .option("--dry-run", "Preview changes without writing")
366
+ .action(async (opts: { dryRun?: boolean }) => {
367
+ try {
368
+ const currentConfig = await api.runtime.config.loadConfig();
369
+ const existing: string[] =
370
+ (currentConfig as any)?.gateway?.nodes?.allowCommands ?? [];
371
+ const merged = mergeAllowCommands(existing, BETTERCLAW_COMMANDS);
372
+ const added = merged.length - existing.length;
373
+
374
+ if (opts.dryRun) {
375
+ console.log(`[dry-run] Would set ${merged.length} allowedCommands (${added} new)`);
376
+ if (added > 0) {
377
+ const newCmds = merged.filter((c) => !existing.includes(c));
378
+ console.log(`New commands: ${newCmds.join(", ")}`);
379
+ }
380
+ return;
381
+ }
382
+
383
+ if (added === 0) {
384
+ console.log(`All ${BETTERCLAW_COMMANDS.length} BetterClaw commands already configured.`);
385
+ return;
386
+ }
387
+
388
+ const configObj = { ...currentConfig } as any;
389
+ configObj.gateway = configObj.gateway ?? {};
390
+ configObj.gateway.nodes = configObj.gateway.nodes ?? {};
391
+ configObj.gateway.nodes.allowCommands = merged;
392
+ await api.runtime.config.writeConfigFile(configObj);
393
+
394
+ console.log(`Added ${added} new commands (${merged.length} total). Restart gateway to apply.`);
395
+ } catch (err) {
396
+ console.error(`Failed to update config: ${err}`);
397
+ process.exit(1);
398
+ }
399
+ });
400
+ },
401
+ { commands: ["betterclaw"] },
402
+ );
250
403
  },
251
404
  };
package/src/learner.ts ADDED
@@ -0,0 +1,205 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type { EventLogEntry, TriageProfile, ReactionEntry } from "./types.js";
4
+ import type { EventLog } from "./events.js";
5
+ import type { ContextManager } from "./context.js";
6
+ import type { ReactionTracker } from "./reactions.js";
7
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
8
+
9
+ export async function readMemorySummary(workspaceDir: string, date: Date): Promise<string | null> {
10
+ const y = date.getFullYear();
11
+ const m = String(date.getMonth() + 1).padStart(2, "0");
12
+ const d = String(date.getDate()).padStart(2, "0");
13
+ const dateStr = `${y}-${m}-${d}`;
14
+ const memoryPath = path.join(workspaceDir, "memory", `${dateStr}.md`);
15
+ try {
16
+ return await fs.readFile(memoryPath, "utf-8");
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export interface LearnerInput {
23
+ memorySummary: string | null;
24
+ recentEvents: EventLogEntry[];
25
+ reactions: ReactionEntry[];
26
+ previousProfile: TriageProfile | null;
27
+ patternsJson: string;
28
+ }
29
+
30
+ export function buildLearnerPrompt(input: LearnerInput): string {
31
+ const { memorySummary, recentEvents, reactions, previousProfile, patternsJson } = input;
32
+
33
+ const memorySection = memorySummary
34
+ ? `## Today's Activity (from agent memory)\n\n${memorySummary}`
35
+ : "## Today's Activity\nNo memory summary available for today.";
36
+
37
+ const eventsSection = recentEvents.length > 0
38
+ ? `## Recent Events (last 24h)\n\n${recentEvents
39
+ .map((e) => `- [${e.decision}] ${e.event.source} (${e.event.subscriptionId}): ${e.reason}`)
40
+ .join("\n")}`
41
+ : "## Recent Events\nNo events in the last 24 hours.";
42
+
43
+ const reactionsSection = reactions.length > 0
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
+ })
49
+ .join("\n")}`
50
+ : "## Notification Reactions\nNo push reaction data available.";
51
+
52
+ const prevSection = previousProfile
53
+ ? `## Previous Triage Profile\n\nSummary: ${previousProfile.summary}\nLife context: ${previousProfile.lifeContext}\nInterruption tolerance: ${previousProfile.interruptionTolerance}`
54
+ : "## Previous Triage Profile\nNo previous profile — this is the first analysis.";
55
+
56
+ return `You are analyzing a user's device event patterns and daily activity to build a personalized notification triage profile.
57
+
58
+ ${memorySection}
59
+
60
+ ${eventsSection}
61
+
62
+ ${reactionsSection}
63
+
64
+ ## Computed Patterns
65
+ ${patternsJson}
66
+
67
+ ${prevSection}
68
+
69
+ 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
+ - summary: string — 1-2 sentence human-readable summary of this user's preferences
77
+
78
+ Respond with ONLY the JSON object, no markdown fences.`;
79
+ }
80
+
81
+ export function parseTriageProfile(text: string): TriageProfile | null {
82
+ try {
83
+ const cleaned = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim();
84
+ const parsed = JSON.parse(cleaned);
85
+ if (!parsed.summary || !parsed.interruptionTolerance) return null;
86
+ const validTolerances = ["low", "normal", "high"];
87
+ return {
88
+ eventPreferences: parsed.eventPreferences ?? {},
89
+ lifeContext: parsed.lifeContext ?? "",
90
+ interruptionTolerance: validTolerances.includes(parsed.interruptionTolerance)
91
+ ? parsed.interruptionTolerance
92
+ : "normal",
93
+ timePreferences: parsed.timePreferences ?? {},
94
+ sensitivityThresholds: parsed.sensitivityThresholds ?? {},
95
+ locationRules: parsed.locationRules ?? {},
96
+ summary: parsed.summary,
97
+ computedAt: parsed.computedAt ?? Date.now() / 1000,
98
+ };
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ export async function loadTriageProfile(stateDir: string): Promise<TriageProfile | null> {
105
+ try {
106
+ const content = await fs.readFile(path.join(stateDir, "triage-profile.json"), "utf-8");
107
+ return parseTriageProfile(content);
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ export async function saveTriageProfile(stateDir: string, profile: TriageProfile): Promise<void> {
114
+ await fs.mkdir(stateDir, { recursive: true });
115
+ await fs.writeFile(path.join(stateDir, "triage-profile.json"), JSON.stringify(profile, null, 2), "utf-8");
116
+ }
117
+
118
+ export interface RunLearnerDeps {
119
+ stateDir: string;
120
+ workspaceDir: string;
121
+ context: ContextManager;
122
+ events: EventLog;
123
+ reactions: ReactionTracker;
124
+ api: OpenClawPluginApi;
125
+ }
126
+
127
+ export async function runLearner(deps: RunLearnerDeps): Promise<void> {
128
+ const { stateDir, workspaceDir, context, events, reactions, api } = deps;
129
+
130
+ // 1. Read yesterday's memory summary (learner runs at 5am, today's barely exists)
131
+ const yesterday = new Date();
132
+ yesterday.setDate(yesterday.getDate() - 1);
133
+ const memorySummary = await readMemorySummary(workspaceDir, yesterday)
134
+ ?? await readMemorySummary(workspaceDir, new Date());
135
+
136
+ // 2. Read last 24h events
137
+ const recentEvents = await events.readSince(Date.now() / 1000 - 86400);
138
+
139
+ // 3. Get recent reactions
140
+ const recentReactions = reactions.getRecent(24);
141
+
142
+ // 4. Load previous profile
143
+ const previousProfile = await loadTriageProfile(stateDir);
144
+
145
+ // 5. Read patterns for context
146
+ const patterns = await context.readPatterns();
147
+ const patternsJson = JSON.stringify(patterns ?? {});
148
+
149
+ // 6. Build prompt (include JSON-only instruction since extraSystemPrompt is not a valid SDK param)
150
+ const prompt = buildLearnerPrompt({
151
+ memorySummary,
152
+ recentEvents,
153
+ reactions: recentReactions,
154
+ previousProfile,
155
+ patternsJson,
156
+ }) + "\n\nIMPORTANT: Respond with ONLY a JSON triage profile object. Do NOT call any tools.";
157
+
158
+ // 7. Clean up any stale session from previous failed run
159
+ try { await api.runtime.subagent.deleteSession({ sessionKey: "betterclaw-learn" }); } catch { /* ignore */ }
160
+
161
+ // 8. Run subagent with try/finally for session cleanup
162
+ let newProfile: TriageProfile | null = null;
163
+ try {
164
+ const { runId } = await api.runtime.subagent.run({
165
+ sessionKey: "betterclaw-learn",
166
+ message: prompt,
167
+ deliver: false,
168
+ idempotencyKey: `betterclaw-learn-${Date.now()}`,
169
+ });
170
+
171
+ // 9. Wait for completion
172
+ await api.runtime.subagent.waitForRun({ runId, timeoutMs: 60000 });
173
+
174
+ // 10. Read response
175
+ const messages = await api.runtime.subagent.getSessionMessages({
176
+ sessionKey: "betterclaw-learn",
177
+ limit: 5,
178
+ });
179
+
180
+ // 11. Parse last assistant message — handle both string and content-block formats
181
+ const lastAssistant = messages.filter((m: any) => m.role === "assistant").pop();
182
+ if (lastAssistant) {
183
+ const content = typeof lastAssistant.content === "string"
184
+ ? lastAssistant.content
185
+ : Array.isArray(lastAssistant.content)
186
+ ? lastAssistant.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
187
+ : null;
188
+ if (content) {
189
+ newProfile = parseTriageProfile(content);
190
+ }
191
+ }
192
+ } finally {
193
+ // 12. Always delete session
194
+ try { await api.runtime.subagent.deleteSession({ sessionKey: "betterclaw-learn" }); } catch { /* ignore */ }
195
+ }
196
+
197
+ // 13. Save if valid
198
+ if (newProfile) {
199
+ await saveTriageProfile(stateDir, newProfile);
200
+ }
201
+
202
+ // 14. Rotate old reactions
203
+ reactions.rotate();
204
+ await reactions.save();
205
+ }