@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/openclaw.plugin.json +33 -12
- package/package.json +1 -1
- package/skills/betterclaw/SKILL.md +11 -10
- package/src/cli.ts +29 -0
- package/src/context.ts +56 -8
- package/src/events.ts +7 -2
- package/src/filter.ts +5 -4
- package/src/index.ts +252 -99
- package/src/learner.ts +205 -0
- package/src/patterns.ts +29 -12
- package/src/pipeline.ts +116 -44
- package/src/reactions.ts +57 -0
- package/src/tools/get-context.ts +28 -5
- package/src/triage.ts +134 -0
- package/src/triggers.ts +7 -0
- package/src/types.ts +38 -3
- package/src/judgment.ts +0 -145
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
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,
|
|
53
|
+
// Event log, rules engine, reaction tracker
|
|
57
54
|
const eventLog = new EventLog(stateDir);
|
|
58
55
|
const rules = new RulesEngine(config.pushBudgetPerDay);
|
|
59
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
+
}
|