@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/patterns.ts CHANGED
@@ -2,13 +2,11 @@ import type { ContextManager } from "./context.js";
2
2
  import type { EventLog } from "./events.js";
3
3
  import type { EventLogEntry, Patterns } from "./types.js";
4
4
 
5
- const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
6
-
7
5
  export class PatternEngine {
8
6
  private context: ContextManager;
9
7
  private events: EventLog;
10
8
  private windowDays: number;
11
- private interval: ReturnType<typeof setInterval> | null = null;
9
+ private timer: ReturnType<typeof setTimeout> | null = null;
12
10
 
13
11
  constructor(context: ContextManager, events: EventLog, windowDays: number) {
14
12
  this.context = context;
@@ -16,19 +14,38 @@ export class PatternEngine {
16
14
  this.windowDays = windowDays;
17
15
  }
18
16
 
19
- startSchedule(): void {
20
- // Run immediately, then every 6 hours
17
+ startSchedule(analysisHour: number, dailyCallback?: () => Promise<void>): void {
18
+ // Run initial compute on startup
21
19
  void this.compute().catch(() => {});
22
- this.interval = setInterval(() => {
23
- void this.compute().catch(() => {});
24
- }, SIX_HOURS_MS);
25
- this.interval.unref?.();
20
+
21
+ this.scheduleNext(analysisHour, dailyCallback);
22
+ }
23
+
24
+ private scheduleNext(analysisHour: number, dailyCallback?: () => Promise<void>): void {
25
+ const now = new Date();
26
+ const target = new Date(now);
27
+ target.setHours(analysisHour, 0, 0, 0);
28
+ if (target <= now) {
29
+ target.setDate(target.getDate() + 1);
30
+ }
31
+ const msUntil = target.getTime() - now.getTime();
32
+
33
+ this.timer = setTimeout(async () => {
34
+ try {
35
+ await this.compute();
36
+ if (dailyCallback) await dailyCallback();
37
+ } catch {
38
+ // ignore errors, will retry next day
39
+ }
40
+ this.scheduleNext(analysisHour, dailyCallback);
41
+ }, msUntil);
42
+ this.timer.unref?.();
26
43
  }
27
44
 
28
45
  stopSchedule(): void {
29
- if (this.interval) {
30
- clearInterval(this.interval);
31
- this.interval = null;
46
+ if (this.timer) {
47
+ clearTimeout(this.timer);
48
+ this.timer = null;
32
49
  }
33
50
  }
34
51
 
package/src/pipeline.ts CHANGED
@@ -2,8 +2,10 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import type { ContextManager } from "./context.js";
3
3
  import type { EventLog } from "./events.js";
4
4
  import type { RulesEngine } from "./filter.js";
5
- import type { JudgmentLayer } from "./judgment.js";
5
+ import type { ReactionTracker } from "./reactions.js";
6
6
  import type { DeviceEvent, DeviceContext, PluginConfig } from "./types.js";
7
+ import { triageEvent } from "./triage.js";
8
+ import { loadTriageProfile } from "./learner.js";
7
9
 
8
10
  export interface PipelineDeps {
9
11
  api: OpenClawPluginApi;
@@ -11,68 +13,138 @@ export interface PipelineDeps {
11
13
  context: ContextManager;
12
14
  events: EventLog;
13
15
  rules: RulesEngine;
14
- judgment: JudgmentLayer;
16
+ reactions: ReactionTracker;
17
+ stateDir: string;
15
18
  }
16
19
 
17
20
  export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Promise<void> {
18
- const { api, config, context, events, rules, judgment } = deps;
21
+ const { api, config, context, events, rules } = deps;
19
22
 
20
23
  // Always update context
21
24
  context.updateFromEvent(event);
25
+ await context.save();
26
+
27
+ // If smartMode is OFF, store only — no filtering or pushing
28
+ if (!context.getRuntimeState().smartMode) {
29
+ await events.append({ event, decision: "stored", reason: "smartMode off", timestamp: Date.now() / 1000 });
30
+ return;
31
+ }
22
32
 
23
33
  // Run rules engine
24
- let decision = rules.evaluate(event, context.get());
34
+ const deviceConfig = context.getDeviceConfig();
35
+ const effectiveBudget = deviceConfig.pushBudgetPerDay ?? config.pushBudgetPerDay;
36
+ const decision = rules.evaluate(event, context.get(), effectiveBudget);
25
37
 
26
- // If ambiguous, run LLM judgment
38
+ // Ambiguous events go through LLM triage
27
39
  if (decision.action === "ambiguous") {
28
- const result = await judgment.evaluate(event, context.get());
29
- decision = result.push
30
- ? { action: "push" as const, reason: `llm: ${result.reason}` }
31
- : { action: "drop" as const, reason: `llm: ${result.reason}` };
32
- }
33
-
34
- // Log the event + decision
35
- await events.append({
36
- event,
37
- decision: decision.action === "push" ? "push" : decision.action === "defer" ? "defer" : "drop",
38
- reason: decision.reason,
39
- timestamp: Date.now() / 1000,
40
- });
40
+ const profile = await loadTriageProfile(deps.stateDir);
41
+ const triageResult = await triageEvent(
42
+ event,
43
+ deps.context,
44
+ profile,
45
+ { triageModel: deps.config.triageModel, triageApiBase: deps.config.triageApiBase },
46
+ async () => {
47
+ try {
48
+ const auth = await deps.api.runtime.modelAuth.resolveApiKeyForProvider({
49
+ provider: deps.config.triageModel.includes("/")
50
+ ? deps.config.triageModel.split("/")[0]
51
+ : "openai",
52
+ });
53
+ return auth.apiKey;
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ },
58
+ );
59
+
60
+ if (triageResult.push) {
61
+ const pushed = await pushToAgent(deps, event, `triage: ${triageResult.reason}`);
62
+
63
+ if (pushed) {
64
+ rules.recordFired(event.subscriptionId, event.firedAt);
65
+ context.recordPush();
66
+ deps.reactions.recordPush({
67
+ idempotencyKey: `event-${event.subscriptionId}-${Math.floor(event.firedAt)}`,
68
+ subscriptionId: event.subscriptionId,
69
+ source: event.source,
70
+ pushedAt: Date.now() / 1000,
71
+ });
72
+ }
41
73
 
42
- // If push, inject into agent session
43
- if (decision.action === "push") {
44
- rules.recordFired(event.subscriptionId, event.firedAt);
45
- context.recordPush();
46
-
47
- // If the iOS tunnel already pushed this event directly to the agent
48
- // session (always-push events like geofence/battery-critical), skip
49
- // delivery to avoid double-sending. Context was already updated above.
50
- if (event.data._alreadyPushed === 1.0) {
51
- api.logger.info(`betterclaw: skipped push for ${event.subscriptionId} (already pushed by tunnel)`);
74
+ await events.append({
75
+ event,
76
+ decision: pushed ? "push" : "drop",
77
+ reason: pushed ? `triage: ${triageResult.reason}` : `triage push failed: ${triageResult.reason}`,
78
+ timestamp: Date.now() / 1000,
79
+ });
52
80
  } else {
53
- const message = formatEnrichedMessage(event, context);
81
+ await events.append({
82
+ event,
83
+ decision: "drop",
84
+ reason: `triage: ${triageResult.reason}`,
85
+ timestamp: Date.now() / 1000,
86
+ });
87
+ }
54
88
 
55
- try {
56
- await api.runtime.subagent.run({
57
- sessionKey: "main",
58
- message,
59
- deliver: true,
60
- idempotencyKey: `event-${event.subscriptionId}-${Math.floor(event.firedAt)}`,
61
- });
89
+ await context.save();
90
+ await deps.reactions.save();
91
+ return;
92
+ }
62
93
 
63
- api.logger.info(`betterclaw: pushed event ${event.subscriptionId} to agent`);
64
- } catch (err) {
65
- api.logger.error(
66
- `betterclaw: failed to push to agent: ${err instanceof Error ? err.message : String(err)}`,
67
- );
68
- }
94
+ if (decision.action === "push") {
95
+ const pushed = await pushToAgent(deps, event, decision.reason);
96
+
97
+ if (pushed) {
98
+ rules.recordFired(event.subscriptionId, event.firedAt);
99
+ context.recordPush();
100
+ deps.reactions.recordPush({
101
+ idempotencyKey: `event-${event.subscriptionId}-${Math.floor(event.firedAt)}`,
102
+ subscriptionId: event.subscriptionId,
103
+ source: event.source,
104
+ pushedAt: Date.now() / 1000,
105
+ });
69
106
  }
107
+
108
+ await events.append({
109
+ event,
110
+ decision: pushed ? "push" : "drop",
111
+ reason: pushed ? decision.reason : `push failed: ${decision.reason}`,
112
+ timestamp: Date.now() / 1000,
113
+ });
70
114
  } else {
71
- api.logger.info(`betterclaw: ${decision.action} event ${event.subscriptionId}: ${decision.reason}`);
115
+ await events.append({
116
+ event,
117
+ decision: "drop",
118
+ reason: decision.reason,
119
+ timestamp: Date.now() / 1000,
120
+ });
121
+ api.logger.info(`betterclaw: drop event ${event.subscriptionId}: ${decision.reason}`);
72
122
  }
73
123
 
74
- // Persist context
124
+ // Persist context and reactions
75
125
  await context.save();
126
+ await deps.reactions.save();
127
+ }
128
+
129
+ async function pushToAgent(deps: PipelineDeps, event: DeviceEvent, reason: string): Promise<boolean> {
130
+ const message = formatEnrichedMessage(event, deps.context);
131
+ const idempotencyKey = `event-${event.subscriptionId}-${Math.floor(event.firedAt)}`;
132
+
133
+ try {
134
+ await deps.api.runtime.subagent.run({
135
+ sessionKey: "main",
136
+ message,
137
+ deliver: true,
138
+ idempotencyKey,
139
+ });
140
+ deps.api.logger.info(`betterclaw: pushed event ${event.subscriptionId} to agent`);
141
+ return true;
142
+ } catch (err) {
143
+ deps.api.logger.error(
144
+ `betterclaw: failed to push to agent: ${err instanceof Error ? err.message : String(err)}`,
145
+ );
146
+ return false;
147
+ }
76
148
  }
77
149
 
78
150
  function formatEnrichedMessage(event: DeviceEvent, context: ContextManager): string {
@@ -0,0 +1,57 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type { ReactionEntry } from "./types.js";
4
+
5
+ export class ReactionTracker {
6
+ private reactions: ReactionEntry[] = [];
7
+ private filePath: string;
8
+
9
+ constructor(stateDir: string) {
10
+ this.filePath = path.join(stateDir, "push-reactions.jsonl");
11
+ }
12
+
13
+ recordPush(entry: Omit<ReactionEntry, "engaged" | "checkedAt">): void {
14
+ this.reactions.push({ ...entry, engaged: null });
15
+ }
16
+
17
+ markEngaged(idempotencyKey: string, engaged: boolean): void {
18
+ const entry = this.reactions.find((r) => r.idempotencyKey === idempotencyKey);
19
+ if (entry) {
20
+ entry.engaged = engaged;
21
+ entry.checkedAt = Date.now() / 1000;
22
+ }
23
+ }
24
+
25
+ getRecent(hours: number): ReactionEntry[] {
26
+ const cutoff = Date.now() / 1000 - hours * 3600;
27
+ return this.reactions.filter((r) => r.pushedAt >= cutoff);
28
+ }
29
+
30
+ async save(): Promise<void> {
31
+ await fs.mkdir(path.dirname(this.filePath), { recursive: true });
32
+ const lines = this.reactions.map((r) => JSON.stringify(r)).join("\n");
33
+ await fs.writeFile(this.filePath, lines + "\n", "utf-8");
34
+ }
35
+
36
+ async load(): Promise<void> {
37
+ try {
38
+ const content = await fs.readFile(this.filePath, "utf-8");
39
+ this.reactions = content
40
+ .trim()
41
+ .split("\n")
42
+ .filter(Boolean)
43
+ .flatMap((line) => {
44
+ try { return [JSON.parse(line) as ReactionEntry]; }
45
+ catch { return []; }
46
+ });
47
+ } catch {
48
+ this.reactions = [];
49
+ }
50
+ }
51
+
52
+ /** Rotate: keep only last 30 days */
53
+ rotate(): void {
54
+ const cutoff = Date.now() / 1000 - 30 * 86400;
55
+ this.reactions = this.reactions.filter((r) => r.pushedAt >= cutoff);
56
+ }
57
+ }
@@ -1,7 +1,8 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { ContextManager } from "../context.js";
3
+ import { loadTriageProfile } from "../learner.js";
3
4
 
4
- export function createGetContextTool(ctx: ContextManager) {
5
+ export function createGetContextTool(ctx: ContextManager, stateDir?: string) {
5
6
  return {
6
7
  name: "get_context",
7
8
  label: "Get Device Context",
@@ -21,13 +22,35 @@ export function createGetContextTool(ctx: ContextManager) {
21
22
  : ["device", "activity", "patterns", "meta"];
22
23
 
23
24
  const state = ctx.get();
25
+ const runtime = ctx.getRuntimeState();
24
26
  const patterns = await ctx.readPatterns();
25
27
 
26
- const result: Record<string, unknown> = {};
27
- if (sections.includes("device")) result.device = state.device;
28
- if (sections.includes("activity")) result.activity = state.activity;
28
+ const result: Record<string, unknown> = {
29
+ tier: runtime.tier,
30
+ smartMode: runtime.smartMode,
31
+ };
32
+
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
+ }
29
43
  if (sections.includes("patterns") && patterns) result.patterns = patterns;
30
- if (sections.includes("meta")) result.meta = state.meta;
44
+ if (sections.includes("meta")) {
45
+ result.meta = {
46
+ ...state.meta,
47
+ lastSnapshotAt: ctx.getTimestamp("lastSnapshot"),
48
+ lastAnalysisAt: patterns?.computedAt,
49
+ };
50
+ }
51
+
52
+ const profile = stateDir ? await loadTriageProfile(stateDir) : null;
53
+ result.triageProfile = profile ? { summary: profile.summary, computedAt: profile.computedAt } : null;
31
54
 
32
55
  return {
33
56
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
package/src/triage.ts ADDED
@@ -0,0 +1,134 @@
1
+ import type { DeviceEvent, TriageProfile } from "./types.js";
2
+ import type { ContextManager } from "./context.js";
3
+
4
+ export interface TriageResult {
5
+ push: boolean;
6
+ reason: string;
7
+ priority?: "low" | "normal" | "high";
8
+ }
9
+
10
+ export function buildTriagePrompt(
11
+ event: DeviceEvent,
12
+ context: ContextManager,
13
+ profile: TriageProfile | null,
14
+ ): string {
15
+ const ctx = context.get();
16
+ const battery = ctx.device.battery;
17
+ const location = ctx.device.location;
18
+ const health = ctx.device.health;
19
+ const activity = ctx.activity;
20
+
21
+ const profileSection = profile
22
+ ? `## User Triage Profile\n${profile.summary}\n\nInterruption tolerance: ${profile.interruptionTolerance}`
23
+ : "## User Triage Profile\nNo triage profile available — default to pushing the event.";
24
+
25
+ const contextSection = [
26
+ `## Current Device Context`,
27
+ battery ? `Battery: ${Math.round(battery.level * 100)}% (${battery.state})` : null,
28
+ location?.label ? `Location: ${location.label}` : null,
29
+ health?.stepsToday != null ? `Steps today: ${health.stepsToday}` : null,
30
+ activity?.currentZone ? `Zone: ${activity.currentZone}` : null,
31
+ `Time: ${String(new Date().getHours()).padStart(2, "0")}:${String(new Date().getMinutes()).padStart(2, "0")}`,
32
+ ]
33
+ .filter(Boolean)
34
+ .join("\n");
35
+
36
+ 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
+
38
+ ${profileSection}
39
+
40
+ ${contextSection}
41
+
42
+ ## Event
43
+ - Subscription: ${event.subscriptionId}
44
+ - Source: ${event.source}
45
+ - Data: ${JSON.stringify(event.data)}
46
+ - Fired at: ${new Date(event.firedAt * 1000).toISOString()}
47
+ ${event.metadata ? `- Metadata: ${JSON.stringify(event.metadata)}` : ""}
48
+
49
+ Respond with JSON: {"push": boolean, "reason": string, "priority"?: "low"|"normal"|"high"}`;
50
+ }
51
+
52
+ export function parseTriageResponse(text: string): TriageResult {
53
+ try {
54
+ // Strip markdown code fence if present
55
+ const cleaned = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim();
56
+ const parsed = JSON.parse(cleaned);
57
+ return {
58
+ push: parsed.push === true,
59
+ reason: String(parsed.reason ?? "no reason"),
60
+ priority: ["low", "normal", "high"].includes(parsed.priority) ? parsed.priority : undefined,
61
+ };
62
+ } catch {
63
+ return { push: true, reason: "failed to parse triage response" };
64
+ }
65
+ }
66
+
67
+ export async function triageEvent(
68
+ event: DeviceEvent,
69
+ context: ContextManager,
70
+ profile: TriageProfile | null,
71
+ config: { triageModel: string; triageApiBase?: string },
72
+ resolveApiKey: () => Promise<string | undefined>,
73
+ ): Promise<TriageResult> {
74
+ const prompt = buildTriagePrompt(event, context, profile);
75
+
76
+ try {
77
+ const apiKey = process.env.BETTERCLAW_TRIAGE_API_KEY ?? (await resolveApiKey());
78
+ if (!apiKey) {
79
+ return { push: true, reason: "no API key for triage — defaulting to push" };
80
+ }
81
+
82
+ const baseUrl = config.triageApiBase ?? "https://api.openai.com/v1";
83
+ const model = config.triageModel.includes("/")
84
+ ? config.triageModel.split("/").slice(1).join("/")
85
+ : config.triageModel;
86
+
87
+ const response = await fetch(`${baseUrl}/chat/completions`, {
88
+ method: "POST",
89
+ headers: {
90
+ "Content-Type": "application/json",
91
+ Authorization: `Bearer ${apiKey}`,
92
+ },
93
+ body: JSON.stringify({
94
+ model,
95
+ messages: [{ role: "user", content: prompt }],
96
+ response_format: {
97
+ type: "json_schema",
98
+ json_schema: {
99
+ name: "triage_decision",
100
+ strict: true,
101
+ schema: {
102
+ type: "object",
103
+ properties: {
104
+ push: { type: "boolean" },
105
+ reason: { type: "string" },
106
+ priority: { type: "string", enum: ["low", "normal", "high"] },
107
+ },
108
+ required: ["push", "reason", "priority"],
109
+ additionalProperties: false,
110
+ },
111
+ },
112
+ },
113
+ max_tokens: 150,
114
+ temperature: 0,
115
+ }),
116
+ });
117
+
118
+ if (!response.ok) {
119
+ return { push: true, reason: `triage API error: ${response.status}` };
120
+ }
121
+
122
+ const data = (await response.json()) as {
123
+ choices?: Array<{ message?: { content?: string } }>;
124
+ };
125
+ const content = data.choices?.[0]?.message?.content;
126
+ if (!content) {
127
+ return { push: true, reason: "empty triage response" };
128
+ }
129
+
130
+ return parseTriageResponse(content);
131
+ } catch (err) {
132
+ return { push: true, reason: `triage call failed: ${err}` };
133
+ }
134
+ }
package/src/triggers.ts CHANGED
@@ -189,6 +189,13 @@ export class ProactiveEngine {
189
189
  }
190
190
 
191
191
  async checkAll(): Promise<void> {
192
+ if (!this.context.getRuntimeState().smartMode) {
193
+ return;
194
+ }
195
+ const deviceConfig = this.context.getDeviceConfig();
196
+ if (deviceConfig.proactiveEnabled === false) {
197
+ return;
198
+ }
192
199
  const ctx = this.context.get();
193
200
  const patterns = (await this.context.readPatterns()) ?? (await import("./patterns.js")).emptyPatterns();
194
201
 
package/src/types.ts CHANGED
@@ -70,14 +70,13 @@ export interface DeviceContext {
70
70
  export type FilterDecision =
71
71
  | { action: "push"; reason: string }
72
72
  | { action: "drop"; reason: string }
73
- | { action: "defer"; reason: string }
74
73
  | { action: "ambiguous"; reason: string };
75
74
 
76
75
  // -- Event log entry --
77
76
 
78
77
  export interface EventLogEntry {
79
78
  event: DeviceEvent;
80
- decision: "push" | "drop" | "defer";
79
+ decision: "push" | "drop" | "stored";
81
80
  reason: string;
82
81
  timestamp: number;
83
82
  }
@@ -122,8 +121,44 @@ export interface Patterns {
122
121
  // -- Plugin config --
123
122
 
124
123
  export interface PluginConfig {
125
- llmModel: string;
124
+ triageModel: string;
125
+ triageApiBase?: string;
126
126
  pushBudgetPerDay: number;
127
127
  patternWindowDays: number;
128
128
  proactiveEnabled: boolean;
129
+ analysisHour: number;
129
130
  }
131
+
132
+ // Triage profile produced by daily learning agent
133
+ export interface TriageProfile {
134
+ eventPreferences: Record<string, "push" | "drop" | "context-dependent">;
135
+ lifeContext: string;
136
+ interruptionTolerance: "low" | "normal" | "high";
137
+ timePreferences: { quietHoursStart?: number; quietHoursEnd?: number; activeStart?: number; activeEnd?: number };
138
+ sensitivityThresholds: Record<string, number>;
139
+ locationRules: Record<string, "push" | "drop" | "context-dependent">;
140
+ summary: string;
141
+ computedAt: number;
142
+ }
143
+
144
+ // Reaction tracking for pushed events
145
+ export interface ReactionEntry {
146
+ idempotencyKey: string;
147
+ subscriptionId: string;
148
+ source: string;
149
+ pushedAt: number;
150
+ engaged: boolean | null; // null = not yet determined
151
+ checkedAt?: number;
152
+ }
153
+
154
+ // Per-device config from betterclaw.config RPC
155
+ export interface DeviceConfig {
156
+ pushBudgetPerDay?: number;
157
+ proactiveEnabled?: boolean;
158
+ }
159
+
160
+ // Runtime state (not persisted)
161
+ export interface RuntimeState {
162
+ tier: "free" | "premium" | "premium+";
163
+ smartMode: boolean;
164
+ }