@better_openclaw/betterclaw 3.0.4 → 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 +1 -1
- package/skills/betterclaw/SKILL.md +11 -0
- package/src/context.ts +24 -11
- package/src/diagnostic-logger.ts +177 -0
- package/src/events.ts +29 -16
- package/src/filter.ts +26 -18
- package/src/index.ts +73 -28
- package/src/learner.ts +31 -6
- package/src/patterns.ts +13 -5
- package/src/pipeline.ts +16 -9
- package/src/reaction-scanner.ts +19 -22
- package/src/reactions.ts +14 -6
- package/src/tools/get-context.ts +31 -12
- package/src/triage.ts +19 -1
- package/src/types.ts +33 -1
package/package.json
CHANGED
|
@@ -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
|
|
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<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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<
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
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<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
50
|
-
|
|
57
|
+
try {
|
|
58
|
+
const entries = await this.readAll();
|
|
59
|
+
if (entries.length <= MAX_LINES) return 0;
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
const lastPushedLevel = this.lastPushedBatteryLevel;
|
|
51
|
+
const deduplicated =
|
|
52
|
+
lastPushedLevel !== undefined &&
|
|
47
53
|
currentLevel !== undefined &&
|
|
48
|
-
Math.abs(currentLevel -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
+
diagnosticLogger.info("plugin.service", "init.complete", "async init complete", { durationMs: Date.now() - initStart });
|
|
136
144
|
} catch (err) {
|
|
137
|
-
|
|
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
|
-
|
|
162
|
+
diagnosticLogger.info("plugin.rpc", "ping.received", "JWT verified", { tier, smartMode, entitlements: payload.ent });
|
|
154
163
|
} else {
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
+
diagnosticLogger.info("plugin.learner", "learner.completed", "daily learner completed", { durationMs: Date.now() - learnerStart });
|
|
469
513
|
} catch (err) {
|
|
470
|
-
|
|
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
|
-
|
|
519
|
+
diagnosticLogger.info("plugin.service", "started", "background services started", { analysisHour: config.analysisHour });
|
|
475
520
|
},
|
|
476
521
|
stop: () => {
|
|
477
522
|
patternEngine.stopSchedule();
|
|
478
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
dlog.info("plugin.pipeline", "push.sent", "event pushed to agent", { subscriptionId: event.subscriptionId });
|
|
157
165
|
return true;
|
|
158
166
|
} catch (err) {
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|
package/src/reaction-scanner.ts
CHANGED
|
@@ -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
|
-
|
|
143
|
+
dlog.debug("plugin.reactions", "scan.empty", "no pending reactions to classify");
|
|
139
144
|
return;
|
|
140
145
|
}
|
|
141
146
|
|
|
142
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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> {
|
package/src/tools/get-context.ts
CHANGED
|
@@ -1,29 +1,45 @@
|
|
|
1
1
|
import type { ContextManager } from "../context.js";
|
|
2
2
|
import { loadTriageProfile } from "../learner.js";
|
|
3
3
|
|
|
4
|
-
|
|
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
|
+
};
|
|
5
13
|
|
|
6
14
|
/** Format seconds into human-readable age string */
|
|
7
|
-
function formatAge(seconds: number): string {
|
|
8
|
-
|
|
9
|
-
if (
|
|
10
|
-
if (
|
|
11
|
-
return `${Math.round(
|
|
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`;
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
/**
|
|
15
|
-
* On premium, stale device data
|
|
16
|
-
*
|
|
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).
|
|
17
27
|
*/
|
|
18
28
|
function deviceFieldOrPointer(
|
|
19
29
|
data: Record<string, unknown> | null,
|
|
20
30
|
ageSeconds: number | null,
|
|
21
31
|
freshCommand: string,
|
|
22
32
|
isPremium: boolean,
|
|
33
|
+
field: string,
|
|
23
34
|
): Record<string, unknown> | null {
|
|
24
35
|
if (!data) return null;
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
};
|
|
27
43
|
}
|
|
28
44
|
return { ...data, dataAgeSeconds: ageSeconds };
|
|
29
45
|
}
|
|
@@ -33,7 +49,7 @@ export function createGetContextTool(ctx: ContextManager, stateDir?: string) {
|
|
|
33
49
|
name: "get_context",
|
|
34
50
|
label: "Get Device Context",
|
|
35
51
|
description:
|
|
36
|
-
"Get BetterClaw context — patterns, trends, activity zone, and event history. On premium, stale device readings
|
|
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.",
|
|
37
53
|
parameters: {},
|
|
38
54
|
async execute(_id: string, _params: Record<string, unknown>) {
|
|
39
55
|
const state = ctx.get();
|
|
@@ -41,7 +57,7 @@ export function createGetContextTool(ctx: ContextManager, stateDir?: string) {
|
|
|
41
57
|
const patterns = await ctx.readPatterns();
|
|
42
58
|
const dataAge = ctx.getDataAge();
|
|
43
59
|
|
|
44
|
-
const isPremium = runtime.tier === "premium"
|
|
60
|
+
const isPremium = runtime.tier === "premium";
|
|
45
61
|
|
|
46
62
|
const result: Record<string, unknown> = {
|
|
47
63
|
tierHint: {
|
|
@@ -59,18 +75,21 @@ export function createGetContextTool(ctx: ContextManager, stateDir?: string) {
|
|
|
59
75
|
dataAge.battery,
|
|
60
76
|
"device.battery",
|
|
61
77
|
isPremium,
|
|
78
|
+
"battery",
|
|
62
79
|
),
|
|
63
80
|
location: deviceFieldOrPointer(
|
|
64
81
|
state.device.location as unknown as Record<string, unknown>,
|
|
65
82
|
dataAge.location,
|
|
66
83
|
"location.get",
|
|
67
84
|
isPremium,
|
|
85
|
+
"location",
|
|
68
86
|
),
|
|
69
87
|
health: deviceFieldOrPointer(
|
|
70
88
|
state.device.health as unknown as Record<string, unknown>,
|
|
71
89
|
dataAge.health,
|
|
72
90
|
"health.summary",
|
|
73
91
|
isPremium,
|
|
92
|
+
"health",
|
|
74
93
|
),
|
|
75
94
|
};
|
|
76
95
|
|
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
|
-
|
|
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
|
}
|