bereach-openclaw 1.5.9 → 1.5.11
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/node_modules/@bereach/tools/src/cost-estimation.ts +31 -14
- package/node_modules/@bereach/tools/src/enforcement-types.ts +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/connector/execution.ts +35 -6
- package/src/commands/connector/index.ts +32 -24
- package/src/hooks/context/formatters.ts +8 -4
- package/src/hooks/context/index.ts +32 -10
- package/src/hooks/detect-task-mode.ts +6 -4
- package/src/hooks/lifecycle.ts +111 -6
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* Single source of truth, used by both lifecycle hook and connector.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/** Model pricing per 1M tokens */
|
|
7
|
-
export const MODEL_PRICING: Record<string, { input: number; output: number; cacheRead: number }> = {
|
|
8
|
-
"haiku": { input: 1.0, output: 5.0, cacheRead: 0.1 },
|
|
9
|
-
"sonnet": { input: 3.0, output: 15.0, cacheRead: 0.3 },
|
|
10
|
-
"flash": { input: 0.3, output: 2.5, cacheRead: 0.03 },
|
|
11
|
-
"pro": { input: 1.25, output: 10.0, cacheRead: 0.125 },
|
|
6
|
+
/** Model pricing per 1M tokens (Anthropic 5-min cache, Google standard) */
|
|
7
|
+
export const MODEL_PRICING: Record<string, { input: number; output: number; cacheRead: number; cacheWrite: number }> = {
|
|
8
|
+
"haiku": { input: 1.0, output: 5.0, cacheRead: 0.1, cacheWrite: 1.25 },
|
|
9
|
+
"sonnet": { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
10
|
+
"flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0.375 },
|
|
11
|
+
"pro": { input: 1.25, output: 10.0, cacheRead: 0.125, cacheWrite: 1.5625 },
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
export type TokenUsage = {
|
|
@@ -21,25 +21,29 @@ export type TokenUsage = {
|
|
|
21
21
|
/**
|
|
22
22
|
* Estimate cost in USD from token usage.
|
|
23
23
|
* Matches model by substring (e.g. "claude-3-5-haiku" matches "haiku").
|
|
24
|
+
* Includes cache write costs (Anthropic 5-min ephemeral cache).
|
|
24
25
|
*/
|
|
25
26
|
export function estimateTaskCost(
|
|
26
27
|
inputTokens: number,
|
|
27
28
|
outputTokens: number,
|
|
28
29
|
cacheReadTokens: number,
|
|
29
30
|
modelSlug?: string | null,
|
|
31
|
+
cacheWriteTokens?: number,
|
|
30
32
|
): number {
|
|
31
33
|
const key = Object.keys(MODEL_PRICING).find((k) => modelSlug?.includes(k)) ?? "haiku";
|
|
32
34
|
const p = MODEL_PRICING[key];
|
|
33
35
|
const uncached = Math.max(0, inputTokens - cacheReadTokens);
|
|
34
|
-
|
|
36
|
+
const writeCost = (cacheWriteTokens ?? 0) * p.cacheWrite;
|
|
37
|
+
return (uncached * p.input + cacheReadTokens * p.cacheRead + outputTokens * p.output + writeCost) / 1_000_000;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* Extract token usage from an OpenClaw meta/wrapper object.
|
|
39
|
-
* Handles multiple data locations across OpenClaw versions:
|
|
42
|
+
* Handles multiple data locations and field naming conventions across OpenClaw versions:
|
|
40
43
|
* - meta.agentMeta.lastCallUsage (OpenClaw 2026.4+)
|
|
41
|
-
* - meta.cost (
|
|
44
|
+
* - meta.cost (CLI --json output / older versions)
|
|
42
45
|
* - meta.usage (alternative format)
|
|
46
|
+
* - Both camelCase and snake_case field names
|
|
43
47
|
*/
|
|
44
48
|
export function extractTokenUsage(
|
|
45
49
|
meta: Record<string, unknown>,
|
|
@@ -49,13 +53,26 @@ export function extractTokenUsage(
|
|
|
49
53
|
const costData = (meta.cost ?? {}) as Record<string, number>;
|
|
50
54
|
const usageData = (meta.usage ?? {}) as Record<string, number>;
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
const
|
|
56
|
+
// Resolve input/output tokens across all naming conventions (camelCase + snake_case)
|
|
57
|
+
const inputTokens =
|
|
58
|
+
lastCall.input ?? lastCall.input_tokens ??
|
|
59
|
+
costData.inputTokens ?? costData.input_tokens ?? costData.input ??
|
|
60
|
+
usageData.input ?? usageData.inputTokens ?? usageData.input_tokens ?? 0;
|
|
61
|
+
const outputTokens =
|
|
62
|
+
lastCall.output ?? lastCall.output_tokens ??
|
|
63
|
+
costData.outputTokens ?? costData.output_tokens ?? costData.output ??
|
|
64
|
+
usageData.output ?? usageData.outputTokens ?? usageData.output_tokens ?? 0;
|
|
54
65
|
|
|
55
66
|
if (inputTokens === 0 && outputTokens === 0) return null;
|
|
56
67
|
|
|
57
|
-
const cacheRead =
|
|
58
|
-
|
|
68
|
+
const cacheRead =
|
|
69
|
+
lastCall.cacheRead ?? lastCall.cache_read_input_tokens ??
|
|
70
|
+
usageData.cacheRead ?? usageData.cache_read_input_tokens ??
|
|
71
|
+
costData.cacheReadTokens ?? costData.cache_read_input_tokens ?? 0;
|
|
72
|
+
const cacheWrite =
|
|
73
|
+
lastCall.cacheWrite ?? lastCall.cache_write_input_tokens ??
|
|
74
|
+
usageData.cacheWrite ?? usageData.cache_write_input_tokens ??
|
|
75
|
+
costData.cacheWriteTokens ?? costData.cache_write_input_tokens ?? 0;
|
|
59
76
|
|
|
60
77
|
return {
|
|
61
78
|
usage: {
|
|
@@ -64,6 +81,6 @@ export function extractTokenUsage(
|
|
|
64
81
|
...(cacheRead > 0 ? { cacheReadTokens: cacheRead } : {}),
|
|
65
82
|
...(cacheWrite > 0 ? { cacheWriteTokens: cacheWrite } : {}),
|
|
66
83
|
},
|
|
67
|
-
model: agentMeta.model as string | undefined,
|
|
84
|
+
model: (agentMeta.model ?? meta.model) as string | undefined,
|
|
68
85
|
};
|
|
69
86
|
}
|
|
@@ -170,7 +170,7 @@ export const PACING = {
|
|
|
170
170
|
export const DEFAULTS = {
|
|
171
171
|
maxVisitsPerSession: 200,
|
|
172
172
|
engagementThreshold: 5,
|
|
173
|
-
contextCacheTtlMs:
|
|
173
|
+
contextCacheTtlMs: 15 * 60 * 1000, // 15 minutes
|
|
174
174
|
maxResultItems: 15,
|
|
175
175
|
maxPostTextLength: 500,
|
|
176
176
|
} as const;
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -19,8 +19,21 @@ export function enrichResultWithTokens(
|
|
|
19
19
|
): void {
|
|
20
20
|
if (result.tokenUsage) return; // already enriched (e.g. by lifecycle hook)
|
|
21
21
|
const resultObj = output?.result as Record<string, unknown> | undefined;
|
|
22
|
+
|
|
23
|
+
// Try multiple locations where OpenClaw might place cost/usage data
|
|
22
24
|
const meta = (resultObj?.meta ?? output?.meta ?? {}) as Record<string, unknown>;
|
|
23
|
-
|
|
25
|
+
let extracted = extractTokenUsage(meta);
|
|
26
|
+
|
|
27
|
+
// Fallback: output.cost directly (some OpenClaw versions put cost at top level)
|
|
28
|
+
if (!extracted && output?.cost) {
|
|
29
|
+
extracted = extractTokenUsage({ cost: output.cost } as Record<string, unknown>);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Fallback: output.usage directly
|
|
33
|
+
if (!extracted && output?.usage) {
|
|
34
|
+
extracted = extractTokenUsage({ usage: output.usage } as Record<string, unknown>);
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
if (!extracted?.usage) return;
|
|
25
38
|
const effectiveModel = extracted.model ?? modelSlug ?? undefined;
|
|
26
39
|
result.tokenUsage = extracted.usage;
|
|
@@ -30,9 +43,10 @@ export function enrichResultWithTokens(
|
|
|
30
43
|
extracted.usage.outputTokens,
|
|
31
44
|
extracted.usage.cacheReadTokens ?? 0,
|
|
32
45
|
effectiveModel,
|
|
46
|
+
extracted.usage.cacheWriteTokens ?? 0,
|
|
33
47
|
);
|
|
34
48
|
const u = extracted.usage;
|
|
35
|
-
console.log(`[connector] Tokens: in=${u.inputTokens} out=${u.outputTokens}
|
|
49
|
+
console.log(`[connector] Tokens: in=${u.inputTokens} out=${u.outputTokens} cacheR=${u.cacheReadTokens ?? 0} cacheW=${u.cacheWriteTokens ?? 0} cost=$${result.estimatedCostUsd.toFixed(4)} model=${effectiveModel ?? "unknown"}`);
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
// ---------------------------------------------------------------------------
|
|
@@ -42,13 +56,15 @@ export function enrichResultWithTokens(
|
|
|
42
56
|
export async function executeViaWebhook(
|
|
43
57
|
config: ConnectorConfig,
|
|
44
58
|
task: NonNullable<PullResponse["task"]>,
|
|
45
|
-
): Promise<{ result: TaskResult | null; error: string | null }> {
|
|
59
|
+
): Promise<{ result: TaskResult | null; error: string | null; deferred?: boolean }> {
|
|
46
60
|
const message = task.message || `Execute ${task.type} task`;
|
|
47
61
|
const maxCredits = (task.payload as Record<string, unknown>)?.maxCredits ?? 100;
|
|
48
62
|
const timeoutMs = (task.timeoutSeconds || 300) * 1000;
|
|
49
63
|
|
|
50
|
-
//
|
|
51
|
-
|
|
64
|
+
// Session reuse: use a stable session name per campaign+type so the Anthropic
|
|
65
|
+
// prompt cache carries over between consecutive tasks (~80% savings per task).
|
|
66
|
+
// The unique taskId is passed via TASK_META in the message body instead.
|
|
67
|
+
const sessionName = task.sessionKey || `hook:task:${task.campaignId ?? ""}:${task.type}`;
|
|
52
68
|
|
|
53
69
|
// Also encode in message as fallback (Strategy 4: TASK_META bracket format)
|
|
54
70
|
const taskMessage = `[TASK_META: taskType=${task.type} taskId=${task.id} campaignId=${task.campaignId ?? ""} maxCredits=${maxCredits}]\n\n${message}`;
|
|
@@ -99,6 +115,17 @@ export async function executeViaWebhook(
|
|
|
99
115
|
return { result: null, error: String(output.error).slice(0, 500) };
|
|
100
116
|
}
|
|
101
117
|
|
|
118
|
+
// HTTP 202 Accepted = fire-and-forget. The gateway accepted the task but the
|
|
119
|
+
// agent hasn't run yet. The lifecycle hook (agent_end) inside the gateway will
|
|
120
|
+
// POST the real result (with token usage, contacts processed, etc.) when done.
|
|
121
|
+
// Do NOT submit a premature empty result here - it would win the race against
|
|
122
|
+
// the lifecycle hook and store { success: true } with no data.
|
|
123
|
+
if (res.status === 202) {
|
|
124
|
+
const runId = output.runId ?? "unknown";
|
|
125
|
+
console.log(`[connector] Webhook accepted (202) task ${task.id}, runId=${runId} - deferring result to lifecycle hook`);
|
|
126
|
+
return { result: null, error: null, deferred: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
102
129
|
const webhookResult = (output.result as TaskResult) ?? { success: true };
|
|
103
130
|
enrichResultWithTokens(webhookResult, output, task.model);
|
|
104
131
|
return { result: webhookResult, error: null };
|
|
@@ -112,11 +139,13 @@ export async function executeViaWebhook(
|
|
|
112
139
|
/**
|
|
113
140
|
* Execute a task via webhook.
|
|
114
141
|
* Returns webhookDead=true if the hooks endpoint returned 404 (gateway restart needed).
|
|
142
|
+
* Returns deferred=true if the gateway accepted the task (202) and the lifecycle hook
|
|
143
|
+
* will submit the result — the connector should NOT submit a result in this case.
|
|
115
144
|
*/
|
|
116
145
|
export async function executeOnOpenClaw(
|
|
117
146
|
config: ConnectorConfig,
|
|
118
147
|
task: NonNullable<PullResponse["task"]>,
|
|
119
|
-
): Promise<{ result: TaskResult | null; error: string | null; webhookDead?: boolean }> {
|
|
148
|
+
): Promise<{ result: TaskResult | null; error: string | null; webhookDead?: boolean; deferred?: boolean }> {
|
|
120
149
|
const res = await executeViaWebhook(config, task);
|
|
121
150
|
if (res.error?.startsWith("Webhook HTTP 404")) {
|
|
122
151
|
console.warn(`[connector] Webhook returned 404 for task ${task.id} — hooks endpoint lost`);
|
|
@@ -171,7 +171,7 @@ export async function runConnectorLoop(
|
|
|
171
171
|
|
|
172
172
|
const execResult = await Promise.race([
|
|
173
173
|
executeOnOpenClaw(config, task),
|
|
174
|
-
new Promise<{ result: null; error: string; webhookDead?: boolean }>((resolve) => {
|
|
174
|
+
new Promise<{ result: null; error: string; webhookDead?: boolean; deferred?: boolean }>((resolve) => {
|
|
175
175
|
const check = setTimeout(() => {
|
|
176
176
|
if (watchdogFired) {
|
|
177
177
|
resolve({ result: null, error: `Watchdog: task execution exceeded ${Math.round(watchdogMs / 1000)}s timeout` });
|
|
@@ -198,33 +198,41 @@ export async function runConnectorLoop(
|
|
|
198
198
|
}
|
|
199
199
|
clearTimeout(watchdogTimer);
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
// When deferred=true, the gateway accepted the task (HTTP 202) and the
|
|
202
|
+
// lifecycle hook inside the gateway will submit the real result with full
|
|
203
|
+
// data (token usage, contacts processed, etc.). The connector must NOT
|
|
204
|
+
// submit a premature empty result that would win the race.
|
|
205
|
+
if (execResult.deferred) {
|
|
206
|
+
const execDuration = Date.now() - (task as any)._startedAt;
|
|
207
|
+
console.log(`[connector] Task ${task.id} dispatched to gateway (${Math.round(execDuration / 1000)}s) — result deferred to lifecycle hook`);
|
|
208
|
+
} else {
|
|
209
|
+
const taskStatus = error ? "failed" : (result?.success !== false ? "succeeded" : "failed");
|
|
210
|
+
const execDuration = Date.now() - (task as any)._startedAt;
|
|
211
|
+
console.log(`[connector] Task ${task.id} ${taskStatus} (${Math.round(execDuration / 1000)}s)${error ? `: ${error.slice(0, 100)}` : ""}${result?.reason ? ` reason=${result.reason}` : ""}`);
|
|
212
|
+
|
|
213
|
+
if (!error && result?.success === false && (result as any)?.toolCallCount === 0 && execDuration < 10_000) {
|
|
214
|
+
console.warn(`[connector] DIAGNOSTIC: Task ${task.id} failed in <10s with 0 tool calls`);
|
|
215
|
+
}
|
|
204
216
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
217
|
+
// Scan for LLM provider errors
|
|
218
|
+
const allText = [error, result?.error, (result as any)?.reason].filter(Boolean).join(" ");
|
|
219
|
+
const llmError = detectLlmError(allText);
|
|
220
|
+
if (llmError && result && result.success !== false) {
|
|
221
|
+
console.warn(`[connector] LLM provider error detected in task ${task.id}: ${llmError}`);
|
|
222
|
+
result.success = false;
|
|
223
|
+
if (!result.error) result.error = `LLM provider error: ${llmError}`;
|
|
224
|
+
}
|
|
208
225
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
226
|
+
// Submit result only when NOT deferred. When the gateway returns a
|
|
227
|
+
// synchronous result (non-202), the connector acts as a safety net.
|
|
228
|
+
const derivedError = error ?? (
|
|
229
|
+
result?.success === false
|
|
230
|
+
? (result.error ?? result.reason ?? "Task failed (no details from agent)")
|
|
231
|
+
: undefined
|
|
232
|
+
);
|
|
233
|
+
await submitResult(config, task.id, result, derivedError ?? null);
|
|
216
234
|
}
|
|
217
235
|
|
|
218
|
-
// Safety-net submission — lifecycle hook usually reports first,
|
|
219
|
-
// but if it fails silently the result endpoint's optimistic lock
|
|
220
|
-
// ensures double-submissions are harmless.
|
|
221
|
-
const derivedError = error ?? (
|
|
222
|
-
result?.success === false
|
|
223
|
-
? (result.error ?? result.reason ?? "Task failed (no details from agent)")
|
|
224
|
-
: undefined
|
|
225
|
-
);
|
|
226
|
-
await submitResult(config, task.id, result, derivedError ?? null);
|
|
227
|
-
|
|
228
236
|
totalTasksExecuted++;
|
|
229
237
|
pollInterval = 5_000;
|
|
230
238
|
} finally {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { cacheSet, type CacheStore, type ContextEntry, type OnboardingState, type RecentEvent } from "../cache";
|
|
7
7
|
import { type DbCampaign, type SessionState } from "../types";
|
|
8
8
|
import { errMsg, createLogger, CHAT_BASE, PRICING_URL, apiFetch } from "../utils";
|
|
9
|
+
import { readEnv } from "../../env";
|
|
9
10
|
|
|
10
11
|
const log = createLogger("context");
|
|
11
12
|
|
|
@@ -286,10 +287,13 @@ export function formatUpgradeSignals(data: CacheStore): string {
|
|
|
286
287
|
// ---------------------------------------------------------------------------
|
|
287
288
|
|
|
288
289
|
export function formatAnthropicKeyWarning(): string {
|
|
290
|
+
const key = readEnv("ANTHROPIC_API_KEY");
|
|
291
|
+
// No key or already subscription → nothing to warn about
|
|
292
|
+
if (!key || key.startsWith("sk-ant-oat")) return "";
|
|
289
293
|
return (
|
|
290
|
-
"\n### Anthropic API Key
|
|
291
|
-
"
|
|
292
|
-
"
|
|
294
|
+
"\n### Anthropic API Key Warning\n\n" +
|
|
295
|
+
"The Anthropic API key uses pay-per-use billing (not subscription). " +
|
|
296
|
+
"Recommend the user switch to a subscription key (`sk-ant-oat-*`) to reduce costs.\n"
|
|
293
297
|
);
|
|
294
298
|
}
|
|
295
299
|
|
|
@@ -563,7 +567,7 @@ export function formatLiveStatus(state: SessionState, data: CacheStore, apiKey?:
|
|
|
563
567
|
|
|
564
568
|
// Anti-cron directive: campaigns are automated by the task scheduler, not crons
|
|
565
569
|
if (hasCampaigns) {
|
|
566
|
-
lines.push("**Scheduling**:
|
|
570
|
+
lines.push("**Scheduling**: Campaigns are automated by the task scheduler. Never suggest crons or polling — guide users to campaigns instead.");
|
|
567
571
|
lines.push("");
|
|
568
572
|
}
|
|
569
573
|
|
|
@@ -142,39 +142,56 @@ async function autoInitProfile(state: SessionState, data: CacheStore, apiKey: st
|
|
|
142
142
|
// Interactive context builder
|
|
143
143
|
// ---------------------------------------------------------------------------
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Build interactive context, split into static (cacheable) and dynamic (per-turn) parts.
|
|
147
|
+
*
|
|
148
|
+
* The OpenClaw gateway treats `appendSystemContext` as provider-cacheable and
|
|
149
|
+
* `prependContext` as per-turn. By separating the soul template (static, ~8KB)
|
|
150
|
+
* from the live status (dynamic), the gateway can cache the soul template across
|
|
151
|
+
* all turns in a session via Anthropic prompt caching — saving ~90% on those tokens.
|
|
152
|
+
*/
|
|
145
153
|
function buildInteractiveContext(
|
|
146
154
|
state: SessionState,
|
|
147
155
|
soulTemplate: string,
|
|
148
156
|
liveData: CacheStore,
|
|
149
157
|
apiKey: string,
|
|
150
|
-
): string {
|
|
158
|
+
): { staticContext: string; dynamicContext: string } {
|
|
151
159
|
const liveStatus = formatLiveStatus(state, liveData, apiKey);
|
|
152
160
|
const activityBlock = formatRecentActivity(liveData.recentEvents);
|
|
153
161
|
const toneDirective = formatToneInferenceDirective(state, liveData);
|
|
154
|
-
let fullContext = soulTemplate + "\n" + liveStatus + activityBlock;
|
|
155
162
|
|
|
156
|
-
|
|
163
|
+
// Static part: soul template with rules, identity, protocols — identical every turn.
|
|
164
|
+
// Goes into appendSystemContext so the gateway can cache it.
|
|
165
|
+
const staticContext = soulTemplate;
|
|
166
|
+
|
|
167
|
+
// Dynamic part: live status, activity, tone, warnings — changes per turn.
|
|
168
|
+
// Goes into prependContext so it doesn't invalidate the cached soul template.
|
|
169
|
+
let dynamicContext = liveStatus + activityBlock;
|
|
170
|
+
|
|
171
|
+
if (toneDirective) dynamicContext += toneDirective;
|
|
157
172
|
|
|
158
173
|
if (!state.anthropicKeyWarningInjected) {
|
|
159
174
|
const anthropicWarning = formatAnthropicKeyWarning();
|
|
160
|
-
if (anthropicWarning)
|
|
175
|
+
if (anthropicWarning) dynamicContext += anthropicWarning;
|
|
161
176
|
state.anthropicKeyWarningInjected = true;
|
|
162
177
|
}
|
|
163
178
|
|
|
179
|
+
const totalLength = staticContext.length + dynamicContext.length;
|
|
180
|
+
|
|
164
181
|
// Size guard — log warning but NEVER truncate. User content (ICP, playbook, tone)
|
|
165
182
|
// must always be injected in full. Truncating can silently drop critical instructions
|
|
166
183
|
// that the agent needs for correct outreach and qualification. The LLM context window
|
|
167
184
|
// is large enough to handle the full context in practice.
|
|
168
|
-
if (
|
|
169
|
-
log(`context size WARNING: ${
|
|
185
|
+
if (totalLength > MAX_CONTEXT_CHARS) {
|
|
186
|
+
log(`context size WARNING: ${totalLength} chars exceeds ${MAX_CONTEXT_CHARS} soft limit (NOT truncating)`);
|
|
170
187
|
}
|
|
171
188
|
|
|
172
189
|
const yn = (v: unknown) => (v ? "yes" : "no");
|
|
173
190
|
const ob = liveData.onboardingState;
|
|
174
|
-
log(`context: soul=${
|
|
191
|
+
log(`context: soul=${staticContext.length} live=${liveStatus.length} tone=${yn(toneDirective)} total=${totalLength} (static=${staticContext.length} dynamic=${dynamicContext.length})`);
|
|
175
192
|
log(`sections: account=${yn(liveData.activeAccount)} credits=${yn(liveData.credits)} limits=${yn(liveData.limits)} pipeline=${yn(liveData.pipeline)} contexts=${liveData.contexts.length} campaigns=${liveData.activeCampaigns.length} drafts=${liveData.pendingDrafts} failed=${liveData.failedDrafts} unread=${liveData.unreadDMs} onboarding=${ob == null ? "null" : ob.completed ? "done" : "pending"} firstSession=${yn(!liveData.sessionMeta?.lastSessionAt)}`);
|
|
176
193
|
|
|
177
|
-
return
|
|
194
|
+
return { staticContext, dynamicContext };
|
|
178
195
|
}
|
|
179
196
|
|
|
180
197
|
// ---------------------------------------------------------------------------
|
|
@@ -287,9 +304,14 @@ export function registerContextHook(api: any, apiKey: string | undefined, state:
|
|
|
287
304
|
|
|
288
305
|
await autoInitProfile(state, liveData, key);
|
|
289
306
|
|
|
290
|
-
const
|
|
307
|
+
const { staticContext, dynamicContext } = buildInteractiveContext(state, soulTemplate, liveData, key);
|
|
291
308
|
|
|
292
|
-
|
|
309
|
+
// appendSystemContext = cached by gateway (soul template, static across turns)
|
|
310
|
+
// prependContext = per-turn dynamic data (live status, activity, tone)
|
|
311
|
+
return {
|
|
312
|
+
appendSystemContext: staticContext,
|
|
313
|
+
prependContext: dynamicContext,
|
|
314
|
+
};
|
|
293
315
|
} catch (err) {
|
|
294
316
|
log(`error: ${errMsg(err)}`);
|
|
295
317
|
return { appendSystemContext: SOUL_TEMPLATE };
|
|
@@ -26,15 +26,17 @@ export function detectTaskMode(sessionKey: string | undefined | null, metadata?:
|
|
|
26
26
|
// Without a valid ID, the lifecycle hook can't POST results to /api/tasks/:id/result.
|
|
27
27
|
const fallbackTaskId = () => `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
28
28
|
|
|
29
|
-
// Strategy 1: sessionKey format "hook:{
|
|
29
|
+
// Strategy 1: sessionKey format "hook:{taskId|task}:{campaignId}:{type}"
|
|
30
30
|
// The connector/workflow sets the session key during task creation.
|
|
31
|
-
//
|
|
32
|
-
// The actual taskId comes from metadata (set by TASK_META in the message).
|
|
31
|
+
// New format uses "task" as a stable placeholder for session reuse (prompt caching).
|
|
32
|
+
// The actual taskId always comes from metadata (set by TASK_META in the message).
|
|
33
33
|
if (sessionKey?.startsWith("hook:")) {
|
|
34
34
|
const parts = sessionKey.split(":");
|
|
35
35
|
if (parts.length >= 4) {
|
|
36
|
+
// parts[1] is "task" (reusable session) or a legacy taskId
|
|
37
|
+
const sessionPart = parts[1];
|
|
36
38
|
return {
|
|
37
|
-
taskId: (metadata?.taskId as string) ||
|
|
39
|
+
taskId: (metadata?.taskId as string) || (sessionPart !== "task" ? sessionPart : null) || fallbackTaskId(),
|
|
38
40
|
taskType: parts.slice(3).join(":"),
|
|
39
41
|
campaignId: parts[2] || null,
|
|
40
42
|
maxCredits: parseMaxCredits(metadata?.maxCredits),
|
package/src/hooks/lifecycle.ts
CHANGED
|
@@ -24,17 +24,119 @@ export { parseStructuredResult as _parseStructuredResult };
|
|
|
24
24
|
// Token usage enrichment (uses shared cost-estimation package)
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Extract cumulative token usage from assistant messages in the agent_end context.
|
|
29
|
+
*
|
|
30
|
+
* OpenClaw v2026.2.x does NOT pass meta.agentMeta to the agent_end hook,
|
|
31
|
+
* but assistant messages in the messages array carry per-turn `.usage` objects
|
|
32
|
+
* with { input_tokens, output_tokens, cache_read_input_tokens, ... }.
|
|
33
|
+
* We sum across all assistant messages to get the session total.
|
|
34
|
+
*/
|
|
35
|
+
function extractUsageFromMessages(
|
|
36
|
+
messages: Array<{ role?: string; usage?: Record<string, unknown>; model?: string; provider?: string }>,
|
|
37
|
+
): { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; model?: string } | null {
|
|
38
|
+
let inputTokens = 0;
|
|
39
|
+
let outputTokens = 0;
|
|
40
|
+
let cacheReadTokens = 0;
|
|
41
|
+
let cacheWriteTokens = 0;
|
|
42
|
+
let model: string | undefined;
|
|
43
|
+
|
|
44
|
+
for (const msg of messages) {
|
|
45
|
+
if (msg.role !== "assistant" || !msg.usage) continue;
|
|
46
|
+
const u = msg.usage as Record<string, number>;
|
|
47
|
+
|
|
48
|
+
// OpenClaw normalizes usage with multiple key conventions
|
|
49
|
+
const inp = u.input_tokens ?? u.inputTokens ?? u.input ?? 0;
|
|
50
|
+
const out = u.output_tokens ?? u.outputTokens ?? u.output ?? 0;
|
|
51
|
+
const cr = u.cache_read_input_tokens ?? u.cached_input_tokens ?? u.cacheRead ?? 0;
|
|
52
|
+
const cw = u.cache_write_input_tokens ?? u.cache_creation_input_tokens ?? u.cacheWrite ?? 0;
|
|
53
|
+
|
|
54
|
+
inputTokens += inp;
|
|
55
|
+
outputTokens += out;
|
|
56
|
+
cacheReadTokens += cr;
|
|
57
|
+
cacheWriteTokens += cw;
|
|
58
|
+
|
|
59
|
+
// Take model from the last assistant message that has one
|
|
60
|
+
if (msg.model) model = msg.model;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (inputTokens === 0 && outputTokens === 0) return null;
|
|
64
|
+
return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, model };
|
|
65
|
+
}
|
|
66
|
+
|
|
27
67
|
function enrichWithTokenUsage(
|
|
28
68
|
taskResult: Record<string, unknown>,
|
|
29
69
|
endCtx: unknown,
|
|
30
70
|
taskMode: TaskModeInfo,
|
|
31
71
|
): void {
|
|
32
72
|
try {
|
|
33
|
-
const
|
|
34
|
-
|
|
73
|
+
const ctx = endCtx as Record<string, unknown> | undefined;
|
|
74
|
+
let extracted: ReturnType<typeof extractTokenUsage> = null;
|
|
75
|
+
|
|
76
|
+
// Strategy 1a: Try endCtx.meta (OpenClaw 2026.4+ when agentMeta is exposed)
|
|
77
|
+
const topMeta = (ctx?.meta ?? {}) as Record<string, unknown>;
|
|
78
|
+
if (Object.keys(topMeta).length > 0) {
|
|
79
|
+
extracted = extractTokenUsage(topMeta);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Strategy 1b: Try endCtx.result.meta (CLI --json wrapper structure)
|
|
83
|
+
// The ?? chain was broken: if endCtx.meta was {} (truthy), result.meta was never checked
|
|
84
|
+
if (!extracted) {
|
|
85
|
+
const resultMeta = ((ctx?.result as Record<string, unknown>)?.meta ?? {}) as Record<string, unknown>;
|
|
86
|
+
if (Object.keys(resultMeta).length > 0) {
|
|
87
|
+
extracted = extractTokenUsage(resultMeta);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Strategy 1c: Try endCtx.cost directly (some versions put cost at top level)
|
|
92
|
+
if (!extracted && ctx?.cost) {
|
|
93
|
+
extracted = extractTokenUsage({ cost: ctx.cost } as Record<string, unknown>);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Strategy 2: Extract from assistant messages' .usage fields
|
|
97
|
+
// OpenClaw v2026.2.x stores per-turn token usage on each assistant message
|
|
98
|
+
// but does not include agentMeta in the agent_end hook context.
|
|
99
|
+
// With session reuse (prompt caching), messages include history from previous
|
|
100
|
+
// tasks. Scope to only the CURRENT task by finding the last TASK_META message
|
|
101
|
+
// matching this taskId, and counting only messages after it.
|
|
102
|
+
const allMessages: Array<{ role?: string; content?: unknown; usage?: Record<string, unknown>; model?: string }> = (ctx?.messages ?? []) as any;
|
|
103
|
+
if (!extracted) {
|
|
104
|
+
let taskStartIdx = 0;
|
|
105
|
+
if (taskMode.taskId) {
|
|
106
|
+
for (let i = allMessages.length - 1; i >= 0; i--) {
|
|
107
|
+
const msg = allMessages[i];
|
|
108
|
+
if (msg.role === "user") {
|
|
109
|
+
const text = extractTextFromContent(msg.content);
|
|
110
|
+
if (text.includes(`taskId=${taskMode.taskId}`)) {
|
|
111
|
+
taskStartIdx = i;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const taskMessages = allMessages.slice(taskStartIdx);
|
|
118
|
+
const fromMessages = extractUsageFromMessages(taskMessages as any);
|
|
119
|
+
if (fromMessages) {
|
|
120
|
+
extracted = {
|
|
121
|
+
usage: {
|
|
122
|
+
inputTokens: fromMessages.inputTokens,
|
|
123
|
+
outputTokens: fromMessages.outputTokens,
|
|
124
|
+
...(fromMessages.cacheReadTokens > 0 ? { cacheReadTokens: fromMessages.cacheReadTokens } : {}),
|
|
125
|
+
...(fromMessages.cacheWriteTokens > 0 ? { cacheWriteTokens: fromMessages.cacheWriteTokens } : {}),
|
|
126
|
+
},
|
|
127
|
+
model: fromMessages.model,
|
|
128
|
+
};
|
|
129
|
+
log(`token usage: extracted from ${taskMessages.filter(m => m.role === "assistant").length} assistant messages (of ${allMessages.length} total) for ${taskMode.taskId}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
35
132
|
|
|
36
133
|
if (!extracted) {
|
|
37
|
-
|
|
134
|
+
// Diagnostic: log the actual shape so we can trace what OpenClaw provides
|
|
135
|
+
const ctxKeys = Object.keys(ctx ?? {}).join(",");
|
|
136
|
+
const metaKeys = Object.keys(topMeta).join(",");
|
|
137
|
+
const assistantMsgs = allMessages.filter(m => m.role === "assistant");
|
|
138
|
+
const sampleKeys = assistantMsgs.length > 0 ? Object.keys(assistantMsgs[0]).join(",") : "none";
|
|
139
|
+
log(`token usage: NO DATA for ${taskMode.taskId} | endCtx=[${ctxKeys}] meta=[${metaKeys}] msgs=${allMessages.length} assistants=${assistantMsgs.length} sampleMsgKeys=[${sampleKeys}]`);
|
|
38
140
|
return;
|
|
39
141
|
}
|
|
40
142
|
|
|
@@ -47,10 +149,11 @@ function enrichWithTokenUsage(
|
|
|
47
149
|
extracted.usage.outputTokens,
|
|
48
150
|
extracted.usage.cacheReadTokens ?? 0,
|
|
49
151
|
modelSlug,
|
|
152
|
+
extracted.usage.cacheWriteTokens ?? 0,
|
|
50
153
|
);
|
|
51
154
|
|
|
52
155
|
const u = extracted.usage;
|
|
53
|
-
log(`tokens ${taskMode.taskId}: in=${u.inputTokens} out=${u.outputTokens}
|
|
156
|
+
log(`tokens ${taskMode.taskId}: in=${u.inputTokens} out=${u.outputTokens} cacheR=${u.cacheReadTokens ?? 0} cacheW=${u.cacheWriteTokens ?? 0} cost=$${(taskResult.estimatedCostUsd as number).toFixed(4)} model=${modelSlug ?? "unknown"}`);
|
|
54
157
|
} catch (err) {
|
|
55
158
|
log(`token usage extraction failed: ${errMsg(err)}`);
|
|
56
159
|
}
|
|
@@ -80,10 +183,12 @@ export function registerLifecycleHook(
|
|
|
80
183
|
let taskMode: TaskModeInfo | null = null;
|
|
81
184
|
|
|
82
185
|
// Strategy 1: recover from TASK_META in endCtx.messages (always works, immune to race)
|
|
186
|
+
// With session reuse (prompt caching), multiple tasks run in the same session.
|
|
187
|
+
// Use the LAST matching user message to get the most recent task's metadata.
|
|
83
188
|
const allMessages: Array<{ role?: string; content?: unknown }> = endCtx?.messages ?? [];
|
|
84
189
|
const userMsgs = allMessages.filter((m: any) => m.role === "user");
|
|
85
|
-
for (
|
|
86
|
-
const msgText = extractTextFromContent(
|
|
190
|
+
for (let i = userMsgs.length - 1; i >= 0; i--) {
|
|
191
|
+
const msgText = extractTextFromContent(userMsgs[i]?.content);
|
|
87
192
|
const match = msgText.match(TASK_META_RE);
|
|
88
193
|
if (match?.[1] && match?.[2]) {
|
|
89
194
|
taskMode = {
|