@better_openclaw/betterclaw 3.0.3 → 3.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better_openclaw/betterclaw",
3
- "version": "3.0.3",
3
+ "version": "3.1.0",
4
4
  "description": "Intelligent event filtering, context tracking, and proactive triggers for BetterClaw",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -28,6 +28,17 @@ You have two complementary tools:
28
28
 
29
29
  Both are useful. Node commands for precision, `get_context` for the big picture.
30
30
 
31
+ ### Action commands (premium only)
32
+
33
+ Beyond sensors, you can perform actions on the device:
34
+
35
+ - **Notifications**: `system.notify` — send a push notification to the user's device
36
+ - **Clipboard**: `clipboard.write` — copy text to the device clipboard
37
+ - **Shortcuts**: `shortcuts.run`, `shortcuts.install` — run or install iOS Shortcuts
38
+ - **Geofences**: `geofence.add`, `geofence.remove`, `geofence.list` — manage location-based geofences
39
+ - **Subscriptions**: `subscribe.add`, `subscribe.remove`, `subscribe.list`, `subscribe.pause`, `subscribe.resume` — manage event subscriptions that trigger proactive alerts
40
+ - **Capabilities**: `system.capabilities` — check what the device supports
41
+
31
42
  ### Free tier
32
43
 
33
44
  `get_context` is the only data source. It returns a cached snapshot from the
package/src/context.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 { DeviceConfig, DeviceContext, DeviceEvent, Patterns, RuntimeState } from "./types.js";
3
+ import { errorMessage, noopLogger, type DeviceConfig, type DeviceContext, type DeviceEvent, type Patterns, type PluginModuleLogger, type RuntimeState } from "./types.js";
4
4
 
5
5
  const CONTEXT_FILE = "context.json";
6
6
  const PATTERNS_FILE = "patterns.json";
@@ -12,11 +12,13 @@ export class ContextManager {
12
12
  private runtimeState: RuntimeState = { tier: null, smartMode: false };
13
13
  private timestamps: Record<string, number> = {};
14
14
  private deviceConfig: DeviceConfig = {};
15
+ private logger: PluginModuleLogger;
15
16
 
16
- constructor(stateDir: string) {
17
+ constructor(stateDir: string, logger?: PluginModuleLogger) {
17
18
  this.contextPath = path.join(stateDir, CONTEXT_FILE);
18
19
  this.patternsPath = path.join(stateDir, PATTERNS_FILE);
19
20
  this.context = ContextManager.empty();
21
+ this.logger = logger ?? noopLogger;
20
22
  }
21
23
 
22
24
  static empty(): DeviceContext {
@@ -81,12 +83,18 @@ export class ContextManager {
81
83
  };
82
84
  }
83
85
 
84
- async save(): Promise<void> {
85
- await fs.mkdir(path.dirname(this.contextPath), { recursive: true });
86
- const data = { ...this.context, _timestamps: this.timestamps, _tier: this.runtimeState.tier };
87
- await fs.writeFile(this.contextPath, JSON.stringify(data, null, 2) + "\n", "utf8");
88
- const configPath = path.join(path.dirname(this.contextPath), "device-config.json");
89
- await fs.writeFile(configPath, JSON.stringify(this.deviceConfig, null, 2) + "\n", "utf8");
86
+ async save(): Promise<boolean> {
87
+ try {
88
+ await fs.mkdir(path.dirname(this.contextPath), { recursive: true });
89
+ const data = { ...this.context, _timestamps: this.timestamps, _tier: this.runtimeState.tier };
90
+ await fs.writeFile(this.contextPath, JSON.stringify(data, null, 2) + "\n", "utf8");
91
+ const configPath = path.join(path.dirname(this.contextPath), "device-config.json");
92
+ await fs.writeFile(configPath, JSON.stringify(this.deviceConfig, null, 2) + "\n", "utf8");
93
+ return true;
94
+ } catch (err) {
95
+ this.logger.error(`context save failed: ${errorMessage(err)}`);
96
+ return false;
97
+ }
90
98
  }
91
99
 
92
100
  updateFromEvent(event: DeviceEvent): void {
@@ -275,8 +283,13 @@ export class ContextManager {
275
283
  }
276
284
  }
277
285
 
278
- async writePatterns(patterns: Patterns): Promise<void> {
279
- await fs.mkdir(path.dirname(this.patternsPath), { recursive: true });
280
- await fs.writeFile(this.patternsPath, JSON.stringify(patterns, null, 2) + "\n", "utf8");
286
+ async writePatterns(patterns: Patterns): Promise<boolean> {
287
+ try {
288
+ await fs.writeFile(this.patternsPath, JSON.stringify(patterns, null, 2) + "\n", "utf8");
289
+ return true;
290
+ } catch (err) {
291
+ this.logger.error(`patterns write failed: ${errorMessage(err)}`);
292
+ return false;
293
+ }
281
294
  }
282
295
  }
@@ -0,0 +1,177 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type { PluginModuleLogger, PluginLogEntry } from "./types.js";
4
+
5
+ const LEVEL_ORDER = ["debug", "info", "warn", "error"] as const;
6
+
7
+ /** Lean interface for module-facing logging. Modules import `dlog` and call these methods. */
8
+ export interface DiagnosticLogWriter {
9
+ debug(source: string, event: string, message: string, data?: Record<string, unknown>): void;
10
+ info(source: string, event: string, message: string, data?: Record<string, unknown>): void;
11
+ warn(source: string, event: string, message: string, data?: Record<string, unknown>): void;
12
+ error(source: string, event: string, message: string, data?: Record<string, unknown>): void;
13
+ }
14
+
15
+ const NOOP: DiagnosticLogWriter = { debug() {}, info() {}, warn() {}, error() {} };
16
+
17
+ /**
18
+ * Singleton diagnostic logger. Always a valid object:
19
+ * - Before initDiagnosticLogger(): silent no-op (safe in tests)
20
+ * - After initDiagnosticLogger(): real logger with JSONL persistence + dual-write
21
+ *
22
+ * Modules import this for structured logging. index.ts uses the full
23
+ * PluginDiagnosticLogger instance (returned by init) for readLogs/rotate/flush/scoped.
24
+ */
25
+ export let dlog: DiagnosticLogWriter = NOOP;
26
+
27
+ /** Initialize the singleton. Call once from index.ts register(). Returns the full instance. */
28
+ export function initDiagnosticLogger(logDir: string, apiLogger: PluginModuleLogger): PluginDiagnosticLogger {
29
+ const instance = new PluginDiagnosticLogger(logDir, apiLogger);
30
+ dlog = instance;
31
+ return instance;
32
+ }
33
+
34
+ export class PluginDiagnosticLogger implements DiagnosticLogWriter {
35
+ private logDir: string;
36
+ private apiLogger: PluginModuleLogger;
37
+ private circuitBroken = false;
38
+ private dirEnsured = false;
39
+ private writeChain: Promise<void> = Promise.resolve();
40
+
41
+ constructor(logDir: string, apiLogger: PluginModuleLogger) {
42
+ this.logDir = logDir;
43
+ this.apiLogger = apiLogger;
44
+ }
45
+
46
+ debug(source: string, event: string, message: string, data?: Record<string, unknown>): void {
47
+ this.writeEntry({ timestamp: Date.now() / 1000, level: "debug", source, event, message, ...(data !== undefined && { data }) });
48
+ }
49
+
50
+ info(source: string, event: string, message: string, data?: Record<string, unknown>): void {
51
+ this.writeEntry({ timestamp: Date.now() / 1000, level: "info", source, event, message, ...(data !== undefined && { data }) });
52
+ this.apiLogger.info(`[${source}] ${message}`);
53
+ }
54
+
55
+ warn(source: string, event: string, message: string, data?: Record<string, unknown>): void {
56
+ this.writeEntry({ timestamp: Date.now() / 1000, level: "warn", source, event, message, ...(data !== undefined && { data }) });
57
+ this.apiLogger.warn(`[${source}] ${message}`);
58
+ }
59
+
60
+ error(source: string, event: string, message: string, data?: Record<string, unknown>): void {
61
+ this.writeEntry({ timestamp: Date.now() / 1000, level: "error", source, event, message, ...(data !== undefined && { data }) });
62
+ this.apiLogger.error(`[${source}] ${message}`);
63
+ }
64
+
65
+ scoped(source: string): PluginModuleLogger {
66
+ return {
67
+ info: (msg: string) => this.info(source, "info", msg),
68
+ warn: (msg: string) => this.warn(source, "warn", msg),
69
+ error: (msg: string) => this.error(source, "error", msg),
70
+ };
71
+ }
72
+
73
+ async readLogs(opts: { since?: number; limit?: number; level?: string; source?: string } = {}): Promise<{ entries: PluginLogEntry[]; total: number }> {
74
+ const since = opts.since ?? (Date.now() / 1000 - 86400);
75
+ const limit = Math.min(opts.limit ?? 200, 50_000);
76
+ const minLevelIdx = opts.level ? LEVEL_ORDER.indexOf(opts.level as (typeof LEVEL_ORDER)[number]) : 0;
77
+
78
+ let files: string[];
79
+ try {
80
+ files = (await fs.readdir(this.logDir))
81
+ .filter(f => f.startsWith("diagnostic-") && f.endsWith(".jsonl"))
82
+ .sort()
83
+ .reverse();
84
+ } catch {
85
+ return { entries: [], total: 0 };
86
+ }
87
+
88
+ const allEntries: PluginLogEntry[] = [];
89
+
90
+ for (const file of files) {
91
+ const dateMatch = file.match(/diagnostic-(\d{4}-\d{2}-\d{2})\.jsonl/);
92
+ if (dateMatch) {
93
+ const endOfDay = new Date(dateMatch[1] + "T23:59:59").getTime() / 1000;
94
+ if (endOfDay < since) break;
95
+ }
96
+
97
+ let content: string;
98
+ try {
99
+ content = await fs.readFile(path.join(this.logDir, file), "utf-8");
100
+ } catch {
101
+ continue;
102
+ }
103
+
104
+ for (const line of content.split("\n")) {
105
+ if (!line.trim()) continue;
106
+ try {
107
+ const entry = JSON.parse(line) as PluginLogEntry;
108
+ if (entry.timestamp < since) continue;
109
+ if (minLevelIdx > 0 && LEVEL_ORDER.indexOf(entry.level) < minLevelIdx) continue;
110
+ if (opts.source && !entry.source.startsWith(opts.source)) continue;
111
+ allEntries.push(entry);
112
+ } catch {
113
+ // Skip malformed lines
114
+ }
115
+ }
116
+ }
117
+
118
+ allEntries.sort((a, b) => a.timestamp - b.timestamp);
119
+ const total = allEntries.length;
120
+ const entries = total > limit ? allEntries.slice(total - limit) : allEntries;
121
+ return { entries, total };
122
+ }
123
+
124
+ async rotate(): Promise<void> {
125
+ const cutoff = new Date();
126
+ cutoff.setDate(cutoff.getDate() - 7);
127
+ const cutoffStr = this.formatDate(cutoff);
128
+
129
+ let files: string[];
130
+ try {
131
+ files = await fs.readdir(this.logDir);
132
+ } catch {
133
+ return;
134
+ }
135
+
136
+ for (const file of files) {
137
+ const dateMatch = file.match(/diagnostic-(\d{4}-\d{2}-\d{2})\.jsonl/);
138
+ if (dateMatch && dateMatch[1] < cutoffStr) {
139
+ try {
140
+ await fs.unlink(path.join(this.logDir, file));
141
+ } catch { /* already deleted */ }
142
+ }
143
+ }
144
+
145
+ this.circuitBroken = false;
146
+ }
147
+
148
+ async flush(): Promise<void> {
149
+ await this.writeChain;
150
+ }
151
+
152
+ private writeEntry(entry: PluginLogEntry): void {
153
+ if (this.circuitBroken) return;
154
+
155
+ const filePath = path.join(this.logDir, `diagnostic-${this.formatDate(new Date())}.jsonl`);
156
+ const line = JSON.stringify(entry) + "\n";
157
+
158
+ this.writeChain = this.writeChain
159
+ .then(async () => {
160
+ if (!this.dirEnsured) {
161
+ await fs.mkdir(this.logDir, { recursive: true });
162
+ this.dirEnsured = true;
163
+ }
164
+ await fs.appendFile(filePath, line, "utf-8");
165
+ })
166
+ .catch(() => {
167
+ if (!this.circuitBroken) {
168
+ this.apiLogger.warn("[diagnostic-logger] disk write failed, circuit breaker tripped");
169
+ }
170
+ this.circuitBroken = true;
171
+ });
172
+ }
173
+
174
+ private formatDate(d: Date): string {
175
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
176
+ }
177
+ }
package/src/events.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 { EventLogEntry } from "./types.js";
3
+ import { errorMessage, noopLogger, type EventLogEntry, type PluginModuleLogger } from "./types.js";
4
4
 
5
5
  const EVENTS_FILE = "events.jsonl";
6
6
  const MAX_LINES = 10_000;
@@ -8,15 +8,23 @@ const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
8
8
 
9
9
  export class EventLog {
10
10
  private filePath: string;
11
+ private logger: PluginModuleLogger;
11
12
 
12
- constructor(stateDir: string) {
13
+ constructor(stateDir: string, logger?: PluginModuleLogger) {
13
14
  this.filePath = path.join(stateDir, EVENTS_FILE);
15
+ this.logger = logger ?? noopLogger;
14
16
  }
15
17
 
16
- async append(entry: EventLogEntry): Promise<void> {
17
- await fs.mkdir(path.dirname(this.filePath), { recursive: true });
18
- const line = JSON.stringify(entry) + "\n";
19
- await fs.appendFile(this.filePath, line, "utf8");
18
+ async append(entry: EventLogEntry): Promise<boolean> {
19
+ try {
20
+ await fs.mkdir(path.dirname(this.filePath), { recursive: true });
21
+ const line = JSON.stringify(entry) + "\n";
22
+ await fs.appendFile(this.filePath, line, "utf8");
23
+ return true;
24
+ } catch (err) {
25
+ this.logger.error(`events append failed: ${errorMessage(err)}`);
26
+ return false;
27
+ }
20
28
  }
21
29
 
22
30
  async readAll(): Promise<EventLogEntry[]> {
@@ -46,19 +54,24 @@ export class EventLog {
46
54
  }
47
55
 
48
56
  async rotate(): Promise<number> {
49
- const entries = await this.readAll();
50
- if (entries.length <= MAX_LINES) return 0;
57
+ try {
58
+ const entries = await this.readAll();
59
+ if (entries.length <= MAX_LINES) return 0;
51
60
 
52
- const cutoff = Date.now() / 1000 - MAX_AGE_MS / 1000;
53
- const kept = entries.filter((e) => e.timestamp >= cutoff).slice(-MAX_LINES);
54
- const removed = entries.length - kept.length;
61
+ const cutoff = Date.now() / 1000 - MAX_AGE_MS / 1000;
62
+ const kept = entries.filter((e) => e.timestamp >= cutoff).slice(-MAX_LINES);
63
+ const removed = entries.length - kept.length;
55
64
 
56
- const content = kept.map((e) => JSON.stringify(e)).join("\n") + "\n";
57
- const tmpPath = this.filePath + ".tmp";
58
- await fs.writeFile(tmpPath, content, "utf8");
59
- await fs.rename(tmpPath, this.filePath);
65
+ const content = kept.map((e) => JSON.stringify(e)).join("\n") + "\n";
66
+ const tmpPath = this.filePath + ".tmp";
67
+ await fs.writeFile(tmpPath, content, "utf8");
68
+ await fs.rename(tmpPath, this.filePath);
60
69
 
61
- return removed;
70
+ return removed;
71
+ } catch (err) {
72
+ this.logger.error(`events rotate failed: ${errorMessage(err)}`);
73
+ return 0;
74
+ }
62
75
  }
63
76
 
64
77
  async count(): Promise<number> {
package/src/filter.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { DeviceContext, DeviceEvent, FilterDecision } from "./types.js";
2
+ import { dlog } from "./diagnostic-logger.js";
2
3
 
3
4
  export class RulesEngine {
4
5
  private lastFired: Map<string, number> = new Map();
6
+ private lastPushedBatteryLevel: number | undefined;
5
7
  private pushBudget: number;
6
8
  private cooldowns: Record<string, number>;
7
9
  private defaultCooldown: number;
@@ -13,6 +15,10 @@ export class RulesEngine {
13
15
  }
14
16
 
15
17
  evaluate(event: DeviceEvent, context: DeviceContext, budgetOverride?: number): FilterDecision {
18
+ // Note: debug, critical battery, and geofence events intentionally bypass the push
19
+ // budget check below. These are high-priority events that should always reach the
20
+ // agent regardless of daily budget limits.
21
+
16
22
  // Debug events always pass
17
23
  if (event.data._debugFired === 1.0) {
18
24
  return { action: "push", reason: "debug event — always push" };
@@ -41,27 +47,23 @@ export class RulesEngine {
41
47
  // Battery low — check if level changed since last push
42
48
  if (event.subscriptionId === "default.battery-low") {
43
49
  const currentLevel = event.data.level;
44
- const lastLevel = context.device.battery?.level;
45
- if (
46
- lastLevel !== undefined &&
50
+ const lastPushedLevel = this.lastPushedBatteryLevel;
51
+ const deduplicated =
52
+ lastPushedLevel !== undefined &&
47
53
  currentLevel !== undefined &&
48
- Math.abs(currentLevel - lastLevel) < 0.02
49
- ) {
54
+ Math.abs(currentLevel - lastPushedLevel) < 0.02;
55
+ dlog.debug("plugin.pipeline", "dedup.checked", "battery dedup evaluated", {
56
+ subscriptionId: event.subscriptionId,
57
+ currentLevel,
58
+ lastPushedLevel,
59
+ deduplicated,
60
+ });
61
+ if (deduplicated) {
50
62
  return { action: "drop", reason: "battery-low: level unchanged since last push" };
51
63
  }
52
64
  return { action: "push", reason: "battery low — level changed" };
53
65
  }
54
66
 
55
- // Daily health — check time window
56
- if (event.subscriptionId === "default.daily-health") {
57
- const hour = new Date(event.firedAt * 1000).getHours();
58
- // Preferred window: 6am-10am
59
- if (hour >= 6 && hour <= 10) {
60
- return { action: "push", reason: "daily health summary — within morning window" };
61
- }
62
- return { action: "drop", reason: "daily health summary — outside morning window" };
63
- }
64
-
65
67
  // Push budget check
66
68
  const budget = budgetOverride ?? this.pushBudget;
67
69
  if (context.meta.pushesToday >= budget) {
@@ -72,17 +74,23 @@ export class RulesEngine {
72
74
  return { action: "ambiguous", reason: "no rule matched — forward to LLM judgment" };
73
75
  }
74
76
 
75
- recordFired(subscriptionId: string, firedAt: number): void {
77
+ recordFired(subscriptionId: string, firedAt: number, data?: Record<string, number>): void {
76
78
  this.lastFired.set(subscriptionId, firedAt);
79
+ if (subscriptionId === "default.battery-low" && data?.level != null) {
80
+ this.lastPushedBatteryLevel = data.level;
81
+ }
77
82
  }
78
83
 
79
84
  /** Restore cooldown state (call on load) */
80
- restoreCooldowns(entries: Array<{ subscriptionId: string; firedAt: number }>): void {
81
- for (const { subscriptionId, firedAt } of entries) {
85
+ restoreCooldowns(entries: Array<{ subscriptionId: string; firedAt: number; data?: Record<string, number> }>): void {
86
+ for (const { subscriptionId, firedAt, data } of entries) {
82
87
  const existing = this.lastFired.get(subscriptionId);
83
88
  if (!existing || firedAt > existing) {
84
89
  this.lastFired.set(subscriptionId, firedAt);
85
90
  }
91
+ if (subscriptionId === "default.battery-low" && data?.level != null) {
92
+ this.lastPushedBatteryLevel = data.level;
93
+ }
86
94
  }
87
95
  }
88
96
  }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import type { PluginConfig, DeviceConfig } from "./types.js";
3
+ import { errorMessage } from "./types.js";
4
+ import { initDiagnosticLogger } from "./diagnostic-logger.js";
3
5
  import { ContextManager } from "./context.js";
4
6
  import { createGetContextTool } from "./tools/get-context.js";
5
7
  import { EventLog } from "./events.js";
@@ -65,8 +67,9 @@ export default {
65
67
  register(api: OpenClawPluginApi) {
66
68
  const config = resolveConfig(api.pluginConfig as Record<string, unknown> | undefined);
67
69
  const stateDir = api.runtime.state.resolveStateDir();
70
+ const diagnosticLogger = initDiagnosticLogger(path.join(stateDir, "logs"), api.logger);
68
71
 
69
- api.logger.info(`betterclaw plugin loaded (model=${config.triageModel}, budget=${config.pushBudgetPerDay})`);
72
+ diagnosticLogger.info("plugin.service", "loaded", "plugin loaded", { model: config.triageModel, budget: config.pushBudgetPerDay });
70
73
 
71
74
  // Calibration state
72
75
  let calibrationStartedAt: number | null = null;
@@ -89,12 +92,12 @@ export default {
89
92
  }
90
93
 
91
94
  // Context manager (load synchronously — file read deferred to first access)
92
- const ctxManager = new ContextManager(stateDir);
95
+ const ctxManager = new ContextManager(stateDir, diagnosticLogger.scoped("plugin.context"));
93
96
 
94
97
  // Event log, rules engine, reaction tracker
95
- const eventLog = new EventLog(stateDir);
98
+ const eventLog = new EventLog(stateDir, diagnosticLogger.scoped("plugin.events"));
96
99
  const rules = new RulesEngine(config.pushBudgetPerDay, config.deduplicationCooldowns, config.defaultCooldown);
97
- const reactionTracker = new ReactionTracker(stateDir);
100
+ const reactionTracker = new ReactionTracker(stateDir, diagnosticLogger.scoped("plugin.reactions"));
98
101
 
99
102
  // Pipeline dependencies
100
103
  const pipelineDeps: PipelineDeps = {
@@ -112,17 +115,22 @@ export default {
112
115
  // Track whether async init has completed
113
116
  let initialized = false;
114
117
  let learnerRunning = false;
118
+ const initStart = Date.now();
115
119
  const initPromise = (async () => {
116
120
  try {
117
121
  await ctxManager.load();
118
122
  initialized = true;
123
+ diagnosticLogger.info("plugin.service", "init.phase", "context loaded", { phase: "context", success: true });
119
124
  } catch (err) {
120
- api.logger.error(`betterclaw: context init failed: ${err instanceof Error ? err.message : String(err)}`);
125
+ const msg = errorMessage(err);
126
+ diagnosticLogger.error("plugin.service", "init.phase", "context init failed: " + msg, { phase: "context", success: false, error: msg });
121
127
  }
122
128
  try {
123
129
  await reactionTracker.load();
130
+ diagnosticLogger.info("plugin.service", "init.phase", "reactions loaded", { phase: "reactions", success: true });
124
131
  } catch (err) {
125
- api.logger.warn(`betterclaw: reaction tracker load failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
132
+ const msg = errorMessage(err);
133
+ diagnosticLogger.warn("plugin.service", "init.phase", "reaction tracker load failed: " + msg, { phase: "reactions", success: false, error: msg });
126
134
  }
127
135
  if (initialized) {
128
136
  try {
@@ -130,11 +138,12 @@ export default {
130
138
  rules.restoreCooldowns(
131
139
  recentEvents
132
140
  .filter((e) => e.decision === "push")
133
- .map((e) => ({ subscriptionId: e.event.subscriptionId, firedAt: e.event.firedAt })),
141
+ .map((e) => ({ subscriptionId: e.event.subscriptionId, firedAt: e.event.firedAt, data: e.event.data })),
134
142
  );
135
- api.logger.info("betterclaw: async init complete");
143
+ diagnosticLogger.info("plugin.service", "init.complete", "async init complete", { durationMs: Date.now() - initStart });
136
144
  } catch (err) {
137
- api.logger.error(`betterclaw: cooldown restore failed: ${err instanceof Error ? err.message : String(err)}`);
145
+ const msg = errorMessage(err);
146
+ diagnosticLogger.error("plugin.service", "init.phase", "cooldown restore failed: " + msg, { phase: "cooldowns", success: false, error: msg });
138
147
  }
139
148
  }
140
149
  })();
@@ -150,10 +159,12 @@ export default {
150
159
  if (jwt) {
151
160
  const payload = await storeJwt(jwt);
152
161
  if (payload) {
153
- api.logger.info(`betterclaw: JWT verified, entitlements=${payload.ent.join(",")}`);
162
+ diagnosticLogger.info("plugin.rpc", "ping.received", "JWT verified", { tier, smartMode, entitlements: payload.ent });
154
163
  } else {
155
- api.logger.warn("betterclaw: JWT verification failed");
164
+ diagnosticLogger.warn("plugin.rpc", "ping.received", "JWT verification failed", { tier, smartMode });
156
165
  }
166
+ } else {
167
+ diagnosticLogger.info("plugin.rpc", "ping.received", "device ping", { tier, smartMode, nodeConnected: context.hasConnectedMobileNode() });
157
168
  }
158
169
 
159
170
  ctxManager.setRuntimeState({ tier, smartMode });
@@ -163,11 +174,15 @@ export default {
163
174
  const existingProfile = await loadTriageProfile(stateDir);
164
175
  if (existingProfile?.computedAt) {
165
176
  calibrationStartedAt = existingProfile.computedAt - config.calibrationDays * 86400;
166
- api.logger.info("betterclaw: existing triage profile found — skipping calibration");
177
+ diagnosticLogger.info("plugin.calibration", "calibration.skipped", "existing triage profile found");
167
178
  } else {
168
179
  calibrationStartedAt = Date.now() / 1000;
180
+ diagnosticLogger.info("plugin.calibration", "calibration.started", "calibration period started");
169
181
  }
170
- fs.writeFile(calibrationFile, JSON.stringify({ startedAt: calibrationStartedAt }), "utf8").catch(() => {});
182
+ fs.writeFile(calibrationFile, JSON.stringify({ startedAt: calibrationStartedAt }), "utf8").catch((err) => {
183
+ const msg = errorMessage(err);
184
+ diagnosticLogger.warn("plugin.calibration", "calibration.error", "calibration file write failed: " + msg, { error: msg });
185
+ });
171
186
  }
172
187
 
173
188
  const meta = ctxManager.get().meta;
@@ -199,9 +214,11 @@ export default {
199
214
 
200
215
  ctxManager.setDeviceConfig(deviceConfig);
201
216
  await ctxManager.save();
217
+ diagnosticLogger.info("plugin.rpc", "config.applied", "config updated", { changedFields: Object.keys(deviceConfig) });
202
218
  respond(true, { applied: true });
203
219
  } catch (err) {
204
- api.logger.error(`betterclaw.config error: ${err instanceof Error ? err.message : String(err)}`);
220
+ const msg = errorMessage(err);
221
+ diagnosticLogger.error("plugin.rpc", "config.error", "config RPC failed: " + msg, { error: msg });
205
222
  respond(false, undefined, { code: "INTERNAL_ERROR", message: "config update failed" });
206
223
  }
207
224
  });
@@ -269,6 +286,7 @@ export default {
269
286
  }
270
287
  : null;
271
288
 
289
+ diagnosticLogger.info("plugin.rpc", "context.served", "context served", { tier: runtime.tier });
272
290
  respond(true, {
273
291
  tier: runtime.tier,
274
292
  smartMode: runtime.smartMode,
@@ -282,7 +300,8 @@ export default {
282
300
  triageProfile: profile ?? null,
283
301
  });
284
302
  } catch (err) {
285
- api.logger.error(`betterclaw.context error: ${err instanceof Error ? err.message : String(err)}`);
303
+ const msg = errorMessage(err);
304
+ diagnosticLogger.error("plugin.rpc", "context.error", "context RPC failed: " + msg, { error: msg });
286
305
  respond(false, undefined, { code: "INTERNAL_ERROR", message: "context fetch failed" });
287
306
  }
288
307
  });
@@ -290,6 +309,7 @@ export default {
290
309
  // Learn RPC — trigger on-demand triage profile learning
291
310
  api.registerGatewayMethod("betterclaw.learn", async ({ respond }) => {
292
311
  try {
312
+ diagnosticLogger.info("plugin.rpc", "learn.triggered", "learn RPC triggered");
293
313
  if (!initialized) await initPromise;
294
314
 
295
315
  if (learnerRunning) {
@@ -329,8 +349,9 @@ export default {
329
349
  }
330
350
  } catch (err) {
331
351
  learnerRunning = false;
332
- api.logger.error(`betterclaw.learn error: ${err instanceof Error ? err.message : String(err)}`);
333
- respond(true, { ok: false, error: err instanceof Error ? err.message : String(err) });
352
+ const msg = errorMessage(err);
353
+ diagnosticLogger.error("plugin.rpc", "learn.error", "learn RPC failed: " + msg, { error: msg });
354
+ respond(true, { ok: false, error: msg });
334
355
  }
335
356
  });
336
357
 
@@ -352,13 +373,31 @@ export default {
352
373
 
353
374
  ctxManager.applySnapshot(snapshot);
354
375
  await ctxManager.save();
376
+ diagnosticLogger.info("plugin.rpc", "snapshot.applied", "snapshot applied", { fieldCount: Object.keys(snapshot).length });
355
377
  respond(true, { applied: true });
356
378
  } catch (err) {
357
- api.logger.error(`betterclaw.snapshot error: ${err instanceof Error ? err.message : String(err)}`);
379
+ const msg = errorMessage(err);
380
+ diagnosticLogger.error("plugin.rpc", "snapshot.error", "snapshot RPC failed: " + msg, { error: msg });
358
381
  respond(false, undefined, { code: "INTERNAL_ERROR", message: "snapshot apply failed" });
359
382
  }
360
383
  });
361
384
 
385
+ // Diagnostic logs RPC — read structured log entries
386
+ api.registerGatewayMethod("betterclaw.logs", async ({ params, respond }) => {
387
+ try {
388
+ const p = (params ?? {}) as Record<string, unknown>;
389
+ const result = await diagnosticLogger.readLogs({
390
+ since: typeof p.since === "number" ? p.since : undefined,
391
+ limit: typeof p.limit === "number" ? p.limit : undefined,
392
+ level: typeof p.level === "string" ? p.level : undefined,
393
+ source: typeof p.source === "string" ? p.source : undefined,
394
+ });
395
+ respond(true, result);
396
+ } catch (err) {
397
+ respond(false, undefined, { code: "LOGS_READ_FAILED", message: errorMessage(err) });
398
+ }
399
+ });
400
+
362
401
  // Agent tools
363
402
  api.registerTool(
364
403
  createCheckTierTool(ctxManager, () => ({
@@ -431,32 +470,37 @@ export default {
431
470
 
432
471
  // Sequential processing — prevents budget races
433
472
  eventQueue = eventQueue.then(() => processEvent(pipelineDeps, event)).catch((err) => {
434
- api.logger.error(`event processing failed: ${err}`);
473
+ const msg = errorMessage(err);
474
+ diagnosticLogger.error("plugin.pipeline", "event.error", "event processing failed: " + msg, { error: msg });
475
+ eventLog.append({ event, decision: "error", reason: `processing error: ${msg}`, timestamp: Date.now() / 1000 });
435
476
  });
436
477
  } catch (err) {
437
- api.logger.error(`betterclaw.event handler error: ${err instanceof Error ? err.message : String(err)}`);
478
+ const msg = errorMessage(err);
479
+ diagnosticLogger.error("plugin.rpc", "event.error", "event handler error: " + msg, { error: msg });
438
480
  respond(false, undefined, { code: "INTERNAL_ERROR", message: "event processing failed" });
439
481
  }
440
482
  });
441
483
 
442
484
  // Pattern engine
443
- const patternEngine = new PatternEngine(ctxManager, eventLog, config.patternWindowDays);
485
+ const patternEngine = new PatternEngine(ctxManager, eventLog, config.patternWindowDays, diagnosticLogger.scoped("plugin.patterns"));
444
486
 
445
487
  // Background service
446
488
  api.registerService({
447
489
  id: "betterclaw-engine",
448
490
  start: () => {
449
491
  patternEngine.startSchedule(config.analysisHour, async () => {
492
+ await diagnosticLogger.rotate();
493
+
450
494
  if (ctxManager.getRuntimeState().smartMode) {
451
- // Scan reactions first (feeds into learner)
452
495
  try {
453
496
  await scanPendingReactions({ reactions: reactionTracker, api });
454
497
  } catch (err) {
455
- api.logger.error(`betterclaw: reaction scan failed: ${err}`);
498
+ const msg = errorMessage(err);
499
+ diagnosticLogger.error("plugin.reactions", "scan.failed", "reaction scan failed: " + msg, { error: msg });
456
500
  }
457
501
 
458
- // Then run learner
459
502
  try {
503
+ const learnerStart = Date.now();
460
504
  await runLearner({
461
505
  stateDir,
462
506
  workspaceDir: path.join(os.homedir(), ".openclaw", "workspace"),
@@ -465,17 +509,18 @@ export default {
465
509
  reactions: reactionTracker,
466
510
  api,
467
511
  });
468
- api.logger.info("betterclaw: daily learner completed");
512
+ diagnosticLogger.info("plugin.learner", "learner.completed", "daily learner completed", { durationMs: Date.now() - learnerStart });
469
513
  } catch (err) {
470
- api.logger.error(`betterclaw: daily learner failed: ${err}`);
514
+ const msg = errorMessage(err);
515
+ diagnosticLogger.error("plugin.learner", "learner.failed", "daily learner failed: " + msg, { error: msg });
471
516
  }
472
517
  }
473
518
  });
474
- api.logger.info("betterclaw: background services started");
519
+ diagnosticLogger.info("plugin.service", "started", "background services started", { analysisHour: config.analysisHour });
475
520
  },
476
521
  stop: () => {
477
522
  patternEngine.stopSchedule();
478
- api.logger.info("betterclaw: background services stopped");
523
+ diagnosticLogger.info("plugin.service", "stopped", "background services stopped");
479
524
  },
480
525
  });
481
526
 
package/src/learner.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import type { EventLogEntry, TriageProfile, ReactionEntry } from "./types.js";
3
+ import { errorMessage, noopLogger, type EventLogEntry, type PluginModuleLogger, type TriageProfile, type ReactionEntry } from "./types.js";
4
4
  import type { EventLog } from "./events.js";
5
5
  import type { ContextManager } from "./context.js";
6
6
  import type { ReactionTracker } from "./reactions.js";
7
7
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
8
+ import { dlog } from "./diagnostic-logger.js";
8
9
 
9
10
  export async function readMemorySummary(workspaceDir: string, date: Date): Promise<string | null> {
10
11
  const y = date.getFullYear();
@@ -97,9 +98,16 @@ export async function loadTriageProfile(stateDir: string): Promise<TriageProfile
97
98
  }
98
99
  }
99
100
 
100
- export async function saveTriageProfile(stateDir: string, profile: TriageProfile): Promise<void> {
101
- await fs.mkdir(stateDir, { recursive: true });
102
- await fs.writeFile(path.join(stateDir, "triage-profile.json"), JSON.stringify(profile, null, 2), "utf-8");
101
+
102
+ export async function saveTriageProfile(stateDir: string, profile: TriageProfile, logger: PluginModuleLogger = noopLogger): Promise<boolean> {
103
+ try {
104
+ await fs.mkdir(stateDir, { recursive: true });
105
+ await fs.writeFile(path.join(stateDir, "triage-profile.json"), JSON.stringify(profile, null, 2), "utf-8");
106
+ return true;
107
+ } catch (err) {
108
+ logger.error(`triage profile save failed: ${errorMessage(err)}`);
109
+ return false;
110
+ }
103
111
  }
104
112
 
105
113
  export interface RunLearnerDeps {
@@ -133,6 +141,13 @@ export async function runLearner(deps: RunLearnerDeps): Promise<void> {
133
141
  const patterns = await context.readPatterns();
134
142
  const patternsJson = JSON.stringify(patterns ?? {});
135
143
 
144
+ dlog.info("plugin.learner", "learner.started", "daily learner run started", {
145
+ eventsCount: recentEvents.length,
146
+ reactionsCount: recentReactions.length,
147
+ hasMemory: memorySummary !== null,
148
+ hasPreviousProfile: previousProfile !== null,
149
+ });
150
+
136
151
  // 6. Build prompt (include JSON-only instruction since extraSystemPrompt is not a valid SDK param)
137
152
  const prompt = buildLearnerPrompt({
138
153
  memorySummary,
@@ -147,6 +162,7 @@ export async function runLearner(deps: RunLearnerDeps): Promise<void> {
147
162
 
148
163
  // 8. Run subagent with try/finally for session cleanup
149
164
  let newProfile: TriageProfile | null = null;
165
+ let content: string | null = null;
150
166
  try {
151
167
  const { runId } = await api.runtime.subagent.run({
152
168
  sessionKey: "betterclaw-learn",
@@ -167,7 +183,7 @@ export async function runLearner(deps: RunLearnerDeps): Promise<void> {
167
183
  // 11. Parse last assistant message — handle both string and content-block formats
168
184
  const lastAssistant = (messages as any[]).filter((m) => m.role === "assistant").pop();
169
185
  if (lastAssistant) {
170
- const content = typeof lastAssistant.content === "string"
186
+ content = typeof lastAssistant.content === "string"
171
187
  ? lastAssistant.content
172
188
  : Array.isArray(lastAssistant.content)
173
189
  ? lastAssistant.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
@@ -182,8 +198,17 @@ export async function runLearner(deps: RunLearnerDeps): Promise<void> {
182
198
  }
183
199
 
184
200
  // 13. Save if valid
201
+ if (!newProfile && content) {
202
+ dlog.warn("plugin.learner", "parse.failed", "failed to parse triage profile from LLM response", {
203
+ rawContent: content.slice(0, 200),
204
+ });
205
+ }
185
206
  if (newProfile) {
186
- await saveTriageProfile(stateDir, newProfile);
207
+ await saveTriageProfile(stateDir, newProfile, api.logger);
208
+ dlog.info("plugin.learner", "profile.updated", "triage profile updated", {
209
+ summary: newProfile.summary,
210
+ interruptionTolerance: newProfile.interruptionTolerance,
211
+ });
187
212
  }
188
213
 
189
214
  // 14. Rotate old reactions
package/src/patterns.ts CHANGED
@@ -1,22 +1,25 @@
1
1
  import type { ContextManager } from "./context.js";
2
2
  import type { EventLog } from "./events.js";
3
- import type { EventLogEntry, Patterns } from "./types.js";
3
+ import { errorMessage, noopLogger, type EventLogEntry, type Patterns, type PluginModuleLogger } from "./types.js";
4
+ import { dlog } from "./diagnostic-logger.js";
4
5
 
5
6
  export class PatternEngine {
6
7
  private context: ContextManager;
7
8
  private events: EventLog;
8
9
  private windowDays: number;
9
10
  private timer: ReturnType<typeof setTimeout> | null = null;
11
+ private logger: PluginModuleLogger;
10
12
 
11
- constructor(context: ContextManager, events: EventLog, windowDays: number) {
13
+ constructor(context: ContextManager, events: EventLog, windowDays: number, logger?: PluginModuleLogger) {
12
14
  this.context = context;
13
15
  this.events = events;
14
16
  this.windowDays = windowDays;
17
+ this.logger = logger ?? noopLogger;
15
18
  }
16
19
 
17
20
  startSchedule(analysisHour: number, dailyCallback?: () => Promise<void>): void {
18
21
  // Run initial compute on startup
19
- void this.compute().catch(() => {});
22
+ void this.compute().catch((err) => { this.logger.warn(`initial pattern compute failed: ${errorMessage(err)}`); });
20
23
 
21
24
  this.scheduleNext(analysisHour, dailyCallback);
22
25
  }
@@ -34,8 +37,8 @@ export class PatternEngine {
34
37
  try {
35
38
  await this.compute();
36
39
  if (dailyCallback) await dailyCallback();
37
- } catch {
38
- // ignore errors, will retry next day
40
+ } catch (err) {
41
+ this.logger.warn(`scheduled pattern compute failed: ${errorMessage(err)}`);
39
42
  }
40
43
  this.scheduleNext(analysisHour, dailyCallback);
41
44
  }, msUntil);
@@ -63,6 +66,11 @@ export class PatternEngine {
63
66
 
64
67
  await this.context.writePatterns(patterns);
65
68
 
69
+ dlog.info("plugin.patterns", "compute.completed", "pattern compute finished", {
70
+ eventsProcessed: entries.length,
71
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
72
+ });
73
+
66
74
  // Rotate event log if needed
67
75
  await this.events.rotate();
68
76
 
package/src/pipeline.ts CHANGED
@@ -4,9 +4,11 @@ import type { EventLog } from "./events.js";
4
4
  import type { RulesEngine } from "./filter.js";
5
5
  import type { ReactionTracker } from "./reactions.js";
6
6
  import type { DeviceEvent, DeviceContext, PluginConfig } from "./types.js";
7
+ import { errorMessage } from "./types.js";
7
8
  import { triageEvent } from "./triage.js";
8
9
  import { loadTriageProfile } from "./learner.js";
9
10
  import { requireEntitlement } from "./jwt.js";
11
+ import { dlog } from "./diagnostic-logger.js";
10
12
 
11
13
  export interface PipelineDeps {
12
14
  api: OpenClawPluginApi;
@@ -21,13 +23,15 @@ export interface PipelineDeps {
21
23
  export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Promise<void> {
22
24
  const { api, config, context, events, rules } = deps;
23
25
 
26
+ dlog.info("plugin.pipeline", "event.received", "incoming device event", { subscriptionId: event.subscriptionId, source: event.source });
27
+
24
28
  // Always update context (even for non-premium users)
25
29
  context.updateFromEvent(event);
26
30
  await context.save();
27
31
 
28
32
  // Tier gate: free users get store-only path — no triage, no push
29
33
  if (context.getRuntimeState().tier === "free") {
30
- api.logger.info(`betterclaw: event stored (free tier)`);
34
+ dlog.info("plugin.pipeline", "event.free_stored", "event stored (free tier)", { subscriptionId: event.subscriptionId });
31
35
  await events.append({ event, decision: "free_stored", reason: "free tier", timestamp: Date.now() / 1000 });
32
36
  return;
33
37
  }
@@ -35,7 +39,8 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
35
39
  // Gate event forwarding behind premium entitlement (security boundary)
36
40
  const entitlementError = requireEntitlement("premium");
37
41
  if (entitlementError) {
38
- api.logger.info(`betterclaw: event blocked (no premium entitlement)`);
42
+ dlog.info("plugin.pipeline", "event.blocked", "event blocked (no premium entitlement)", { subscriptionId: event.subscriptionId });
43
+ await events.append({ event, decision: "blocked", reason: "no premium entitlement", timestamp: Date.now() / 1000 });
39
44
  return;
40
45
  }
41
46
 
@@ -77,7 +82,7 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
77
82
  const pushed = await pushToAgent(deps, event, `triage: ${triageResult.reason}`, message);
78
83
 
79
84
  if (pushed) {
80
- rules.recordFired(event.subscriptionId, event.firedAt);
85
+ rules.recordFired(event.subscriptionId, event.firedAt, event.data);
81
86
  context.recordPush();
82
87
  deps.reactions.recordPush({
83
88
  subscriptionId: event.subscriptionId,
@@ -85,6 +90,7 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
85
90
  pushedAt: Date.now() / 1000,
86
91
  messageSummary: message.slice(0, 100),
87
92
  });
93
+ dlog.info("plugin.pipeline", "push.decided", "event pushed to agent via triage", { subscriptionId: event.subscriptionId, decision: "push", reason: `triage: ${triageResult.reason}` });
88
94
  }
89
95
 
90
96
  await events.append({
@@ -94,6 +100,7 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
94
100
  timestamp: Date.now() / 1000,
95
101
  });
96
102
  } else {
103
+ dlog.info("plugin.pipeline", "push.decided", `triage drop: ${triageResult.reason}`, { subscriptionId: event.subscriptionId, decision: "drop", reason: `triage: ${triageResult.reason}` });
97
104
  await events.append({
98
105
  event,
99
106
  decision: "drop",
@@ -112,7 +119,7 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
112
119
  const pushed = await pushToAgent(deps, event, decision.reason, message);
113
120
 
114
121
  if (pushed) {
115
- rules.recordFired(event.subscriptionId, event.firedAt);
122
+ rules.recordFired(event.subscriptionId, event.firedAt, event.data);
116
123
  context.recordPush();
117
124
  deps.reactions.recordPush({
118
125
  subscriptionId: event.subscriptionId,
@@ -120,6 +127,7 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
120
127
  pushedAt: Date.now() / 1000,
121
128
  messageSummary: message.slice(0, 100),
122
129
  });
130
+ dlog.info("plugin.pipeline", "push.decided", "event pushed to agent", { subscriptionId: event.subscriptionId, decision: "push", reason: decision.reason });
123
131
  }
124
132
 
125
133
  await events.append({
@@ -135,7 +143,7 @@ export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Prom
135
143
  reason: decision.reason,
136
144
  timestamp: Date.now() / 1000,
137
145
  });
138
- api.logger.info(`betterclaw: drop event ${event.subscriptionId}: ${decision.reason}`);
146
+ dlog.info("plugin.pipeline", "push.decided", "event dropped", { subscriptionId: event.subscriptionId, decision: "drop", reason: decision.reason });
139
147
  }
140
148
 
141
149
  // Persist context and reactions
@@ -153,12 +161,11 @@ async function pushToAgent(deps: PipelineDeps, event: DeviceEvent, reason: strin
153
161
  deliver: false,
154
162
  idempotencyKey,
155
163
  });
156
- deps.api.logger.info(`betterclaw: pushed event ${event.subscriptionId} to agent`);
164
+ dlog.info("plugin.pipeline", "push.sent", "event pushed to agent", { subscriptionId: event.subscriptionId });
157
165
  return true;
158
166
  } catch (err) {
159
- deps.api.logger.error(
160
- `betterclaw: failed to push to agent: ${err instanceof Error ? err.message : String(err)}`,
161
- );
167
+ const msg = errorMessage(err);
168
+ dlog.error("plugin.pipeline", "push.failed", "failed to push event to agent", { subscriptionId: event.subscriptionId, error: msg });
162
169
  return false;
163
170
  }
164
171
  }
@@ -1,6 +1,8 @@
1
1
  import type { ReactionStatus } from "./types.js";
2
2
  import type { ReactionTracker } from "./reactions.js";
3
3
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
4
+ import { errorMessage } from "./types.js";
5
+ import { dlog } from "./diagnostic-logger.js";
4
6
 
5
7
  // --- Types ---
6
8
 
@@ -133,13 +135,16 @@ export function parseClassificationResponse(text: string): ClassificationResult
133
135
  export async function scanPendingReactions(deps: ScanDeps): Promise<void> {
134
136
  const { api, reactions } = deps;
135
137
 
138
+ let classified = 0;
139
+ let skipped = 0;
140
+
136
141
  const pending = reactions.getPending();
137
142
  if (pending.length === 0) {
138
- api.logger.info("reaction-scanner: no pending reactions to classify");
143
+ dlog.debug("plugin.reactions", "scan.empty", "no pending reactions to classify");
139
144
  return;
140
145
  }
141
146
 
142
- api.logger.info(`reaction-scanner: scanning ${pending.length} pending reaction(s)`);
147
+ dlog.info("plugin.reactions", "scan.started", "scanning pending reactions", { pendingCount: pending.length });
143
148
 
144
149
  // Fetch session messages once (limit 200) to search through
145
150
  let messages: Array<{ role: string; content: unknown; timestamp?: number }> = [];
@@ -150,9 +155,8 @@ export async function scanPendingReactions(deps: ScanDeps): Promise<void> {
150
155
  });
151
156
  messages = fetched as typeof messages;
152
157
  } catch (err) {
153
- api.logger.error(
154
- `reaction-scanner: failed to fetch session messages: ${err instanceof Error ? err.message : String(err)}`,
155
- );
158
+ const msg = errorMessage(err);
159
+ dlog.error("plugin.reactions", "scan.error", "failed to fetch session messages", { error: msg });
156
160
  return;
157
161
  }
158
162
 
@@ -161,9 +165,8 @@ export async function scanPendingReactions(deps: ScanDeps): Promise<void> {
161
165
  // Step 1: Deterministic search
162
166
  const match = findPushInMessages(messages, reaction.pushedAt, reaction.messageSummary);
163
167
  if (!match) {
164
- api.logger.info(
165
- `reaction-scanner: no transcript match for ${reaction.subscriptionId} at ${reaction.pushedAt} — skipping`,
166
- );
168
+ skipped++;
169
+ dlog.info("plugin.reactions", "scan.skipped", "no transcript match for reaction", { subscriptionId: reaction.subscriptionId, pushedAt: reaction.pushedAt });
167
170
  continue;
168
171
  }
169
172
 
@@ -214,22 +217,16 @@ export async function scanPendingReactions(deps: ScanDeps): Promise<void> {
214
217
  classificationResult.reason,
215
218
  );
216
219
 
217
- api.logger.info(
218
- `reaction-scanner: classified ${reaction.subscriptionId} as "${classificationResult.status}" ${classificationResult.reason}`,
219
- );
220
+ classified++;
221
+ dlog.info("plugin.reactions", "classified", "reaction classified", { subscriptionId: reaction.subscriptionId, status: classificationResult.status, reason: classificationResult.reason });
220
222
  } catch (err) {
221
- api.logger.error(
222
- `reaction-scanner: error classifying ${reaction.subscriptionId}: ${err instanceof Error ? err.message : String(err)}`,
223
- );
223
+ const msg = errorMessage(err);
224
+ dlog.error("plugin.reactions", "classified.error", "error classifying reaction", { subscriptionId: reaction.subscriptionId, error: msg });
224
225
  }
225
226
  }
226
227
 
227
- // Persist updated reactions
228
- try {
229
- await reactions.save();
230
- } catch (err) {
231
- api.logger.error(
232
- `reaction-scanner: failed to save reactions: ${err instanceof Error ? err.message : String(err)}`,
233
- );
234
- }
228
+ dlog.info("plugin.reactions", "scan.completed", "reaction scan finished", { classified, skipped });
229
+
230
+ // Persist updated reactions (save is self-catching)
231
+ await reactions.save();
235
232
  }
package/src/reactions.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import type { ReactionEntry, ReactionStatus } from "./types.js";
3
+ import { errorMessage, noopLogger, type PluginModuleLogger, type ReactionEntry, type ReactionStatus } from "./types.js";
4
4
 
5
5
  export class ReactionTracker {
6
6
  private reactions: ReactionEntry[] = [];
7
7
  private filePath: string;
8
+ private logger: PluginModuleLogger;
8
9
 
9
- constructor(stateDir: string) {
10
+ constructor(stateDir: string, logger?: PluginModuleLogger) {
10
11
  this.filePath = path.join(stateDir, "push-reactions.jsonl");
12
+ this.logger = logger ?? noopLogger;
11
13
  }
12
14
 
13
15
  recordPush(entry: { subscriptionId: string; source: string; pushedAt: number; messageSummary: string }): void {
@@ -49,10 +51,16 @@ export class ReactionTracker {
49
51
  return this.reactions.filter((r) => r.status !== "pending" && r.pushedAt >= cutoff);
50
52
  }
51
53
 
52
- async save(): Promise<void> {
53
- await fs.mkdir(path.dirname(this.filePath), { recursive: true });
54
- const lines = this.reactions.map((r) => JSON.stringify(r)).join("\n");
55
- await fs.writeFile(this.filePath, lines + "\n", "utf-8");
54
+ async save(): Promise<boolean> {
55
+ try {
56
+ await fs.mkdir(path.dirname(this.filePath), { recursive: true });
57
+ const lines = this.reactions.map((r) => JSON.stringify(r)).join("\n");
58
+ await fs.writeFile(this.filePath, lines + "\n", "utf-8");
59
+ return true;
60
+ } catch (err) {
61
+ this.logger.error(`reactions save failed: ${errorMessage(err)}`);
62
+ return false;
63
+ }
56
64
  }
57
65
 
58
66
  async load(): Promise<void> {
@@ -1,12 +1,55 @@
1
1
  import type { ContextManager } from "../context.js";
2
2
  import { loadTriageProfile } from "../learner.js";
3
3
 
4
+ // Per-field staleness thresholds (seconds)
5
+ // Location: updates every ~60s on movement, 10 min threshold
6
+ // Battery: updates every 10 min, 15 min threshold
7
+ // Health: updates every 30 min, 60 min threshold
8
+ const STALE_THRESHOLDS: Record<string, number> = {
9
+ location: 600,
10
+ battery: 900,
11
+ health: 3600,
12
+ };
13
+
14
+ /** Format seconds into human-readable age string */
15
+ export function formatAge(seconds: number): string {
16
+ const clamped = Math.max(0, seconds);
17
+ if (clamped < 60) return `${Math.round(clamped)}s ago`;
18
+ if (clamped < 3600) return `${Math.round(clamped / 60)}m ago`;
19
+ if (clamped < 86400) return `${Math.round(clamped / 3600)}h ago`;
20
+ return `${Math.round(clamped / 86400)}d ago`;
21
+ }
22
+
23
+ /**
24
+ * On premium, stale device data is replaced with a pointer to the fresh
25
+ * node command. Null age is treated as stale on premium (no timestamp =
26
+ * can't verify freshness).
27
+ */
28
+ function deviceFieldOrPointer(
29
+ data: Record<string, unknown> | null,
30
+ ageSeconds: number | null,
31
+ freshCommand: string,
32
+ isPremium: boolean,
33
+ field: string,
34
+ ): Record<string, unknown> | null {
35
+ if (!data) return null;
36
+ const threshold = STALE_THRESHOLDS[field] ?? 600;
37
+ if (isPremium && (ageSeconds == null || ageSeconds > threshold)) {
38
+ return {
39
+ stale: true,
40
+ ageHuman: ageSeconds != null ? formatAge(ageSeconds) : "unknown",
41
+ freshCommand,
42
+ };
43
+ }
44
+ return { ...data, dataAgeSeconds: ageSeconds };
45
+ }
46
+
4
47
  export function createGetContextTool(ctx: ContextManager, stateDir?: string) {
5
48
  return {
6
49
  name: "get_context",
7
50
  label: "Get Device Context",
8
51
  description:
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.",
52
+ "Get BetterClaw context — patterns, trends, activity zone, and event history. On premium, stale device readings are hidden use node commands (location.get, device.battery, health.*) for current data. On free, this includes the full device snapshot.",
10
53
  parameters: {},
11
54
  async execute(_id: string, _params: Record<string, unknown>) {
12
55
  const state = ctx.get();
@@ -20,22 +63,34 @@ export function createGetContextTool(ctx: ContextManager, stateDir?: string) {
20
63
  tierHint: {
21
64
  tier: runtime.tier,
22
65
  note: isPremium
23
- ? "Node commands available for fresh readings (location.get, device.battery, health.*)"
66
+ ? "Node commands available for fresh readings (location.get, device.battery, health.*). Stale device data is hidden — call the node command instead."
24
67
  : "This is the only data source on free tier — check dataAgeSeconds for freshness",
25
68
  },
26
69
  smartMode: runtime.smartMode,
27
70
  };
28
71
 
29
72
  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,
73
+ battery: deviceFieldOrPointer(
74
+ state.device.battery as unknown as Record<string, unknown>,
75
+ dataAge.battery,
76
+ "device.battery",
77
+ isPremium,
78
+ "battery",
79
+ ),
80
+ location: deviceFieldOrPointer(
81
+ state.device.location as unknown as Record<string, unknown>,
82
+ dataAge.location,
83
+ "location.get",
84
+ isPremium,
85
+ "location",
86
+ ),
87
+ health: deviceFieldOrPointer(
88
+ state.device.health as unknown as Record<string, unknown>,
89
+ dataAge.health,
90
+ "health.summary",
91
+ isPremium,
92
+ "health",
93
+ ),
39
94
  };
40
95
 
41
96
  result.activity = { ...state.activity, updatedAt: ctx.getTimestamp("activity") };
package/src/triage.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { DeviceEvent, TriageProfile } from "./types.js";
2
+ import { errorMessage } from "./types.js";
2
3
  import type { ContextManager } from "./context.js";
4
+ import { dlog } from "./diagnostic-logger.js";
3
5
 
4
6
  export interface TriageResult {
5
7
  push: boolean;
@@ -76,6 +78,11 @@ export async function triageEvent(
76
78
  config: { triageModel: string; triageApiBase?: string; budgetUsed?: number; budgetTotal?: number },
77
79
  resolveApiKey: () => Promise<string | undefined>,
78
80
  ): Promise<TriageResult> {
81
+ dlog.info("plugin.triage", "triage.called", "sending triage request", {
82
+ subscriptionId: event.subscriptionId,
83
+ model: config.triageModel,
84
+ });
85
+
79
86
  const prompt = buildTriagePrompt(event, context, profile,
80
87
  config.budgetUsed != null && config.budgetTotal != null
81
88
  ? { budgetUsed: config.budgetUsed, budgetTotal: config.budgetTotal }
@@ -136,8 +143,19 @@ export async function triageEvent(
136
143
  return { push: false, reason: "empty triage response — defaulting to drop" };
137
144
  }
138
145
 
139
- return parseTriageResponse(content);
146
+ const result = parseTriageResponse(content);
147
+ dlog.info("plugin.triage", "triage.result", `triage decision: ${result.push ? "push" : "drop"}`, {
148
+ subscriptionId: event.subscriptionId,
149
+ decision: result.push ? "push" : "drop",
150
+ reason: result.reason,
151
+ });
152
+ return result;
140
153
  } catch (err) {
154
+ dlog.error("plugin.triage", "triage.fallback", `triage failed, falling back to drop: ${errorMessage(err)}`, {
155
+ subscriptionId: event.subscriptionId,
156
+ error: errorMessage(err),
157
+ fallbackAction: "drop",
158
+ });
141
159
  return { push: false, reason: `triage call failed: ${err} — defaulting to drop` };
142
160
  }
143
161
  }
package/src/types.ts CHANGED
@@ -1,3 +1,35 @@
1
+ /**
2
+ * Minimal logger interface for plugin modules. Compatible with the SDK's PluginLogger.
3
+ *
4
+ * All stateful modules (EventLog, ContextManager, ReactionTracker, PatternEngine) accept
5
+ * an optional PluginModuleLogger in their constructor. Write operations (save, append,
6
+ * rotate, writePatterns) catch errors internally, log via this logger, and return boolean.
7
+ * Callers do NOT need try/catch around write calls — the module handles it.
8
+ */
9
+ export type PluginModuleLogger = {
10
+ info: (msg: string) => void;
11
+ warn: (msg: string) => void;
12
+ error: (msg: string) => void;
13
+ };
14
+
15
+ /** Shared noop logger for modules and tests. Use when logging is not needed. */
16
+ export const noopLogger: PluginModuleLogger = { info: () => {}, warn: () => {}, error: () => {} };
17
+
18
+ /** Extract error message from unknown catch parameter. */
19
+ export function errorMessage(err: unknown): string {
20
+ return err instanceof Error ? err.message : String(err);
21
+ }
22
+
23
+ /** Structured log entry for diagnostic JSONL files. */
24
+ export interface PluginLogEntry {
25
+ timestamp: number;
26
+ level: "debug" | "info" | "warn" | "error";
27
+ source: string;
28
+ event: string;
29
+ message: string;
30
+ data?: Record<string, unknown>;
31
+ }
32
+
1
33
  // -- Incoming event from iOS --
2
34
 
3
35
  export interface DeviceEvent {
@@ -76,7 +108,7 @@ export type FilterDecision =
76
108
 
77
109
  export interface EventLogEntry {
78
110
  event: DeviceEvent;
79
- decision: "push" | "drop" | "stored" | "free_stored";
111
+ decision: "push" | "drop" | "stored" | "free_stored" | "blocked" | "error";
80
112
  reason: string;
81
113
  timestamp: number;
82
114
  }