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