bereach-openclaw 1.5.10 → 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.
@@ -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
- return (uncached * p.input + cacheReadTokens * p.cacheRead + outputTokens * p.output) / 1_000_000;
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 (older versions / test runner)
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
- const inputTokens = lastCall.input ?? costData.inputTokens ?? costData.input ?? usageData.input ?? 0;
53
- const outputTokens = lastCall.output ?? costData.outputTokens ?? costData.output ?? usageData.output ?? 0;
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 = lastCall.cacheRead ?? usageData.cacheRead ?? costData.cacheReadTokens ?? 0;
58
- const cacheWrite = lastCall.cacheWrite ?? usageData.cacheWrite ?? costData.cacheWriteTokens ?? 0;
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
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "bereach-openclaw",
3
3
  "name": "BeReach",
4
- "version": "1.5.10",
4
+ "version": "1.5.11",
5
5
  "description": "LinkedIn outreach automation — 75+ tools, hook-based enforcement, dynamic context",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bereach-openclaw",
3
- "version": "1.5.10",
3
+ "version": "1.5.11",
4
4
  "description": "BeReach LinkedIn automation plugin for OpenClaw",
5
5
  "license": "AGPL-3.0",
6
6
  "exports": {
@@ -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
- const extracted = extractTokenUsage(meta);
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} cache=${u.cacheReadTokens ?? 0} cost=$${result.estimatedCostUsd.toFixed(4)} model=${effectiveModel ?? "unknown"}`);
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
  // ---------------------------------------------------------------------------
@@ -47,8 +61,10 @@ export async function executeViaWebhook(
47
61
  const maxCredits = (task.payload as Record<string, unknown>)?.maxCredits ?? 100;
48
62
  const timeoutMs = (task.timeoutSeconds || 300) * 1000;
49
63
 
50
- // Encode task metadata in the session name (Strategy 1 for detectTaskMode)
51
- const sessionName = task.sessionKey || `hook:${task.id}:${task.campaignId ?? ""}:${task.type}`;
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}`;
@@ -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
- if (toneDirective) fullContext += toneDirective;
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) fullContext += 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 (fullContext.length > MAX_CONTEXT_CHARS) {
169
- log(`context size WARNING: ${fullContext.length} chars exceeds ${MAX_CONTEXT_CHARS} soft limit (NOT truncating)`);
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=${soulTemplate.length} live=${liveStatus.length} tone=${yn(toneDirective)} total=${fullContext.length}`);
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 fullContext;
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 fullContext = buildInteractiveContext(state, soulTemplate, liveData, key);
307
+ const { staticContext, dynamicContext } = buildInteractiveContext(state, soulTemplate, liveData, key);
291
308
 
292
- return { appendSystemContext: fullContext };
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:{userId}:{campaignId}:{type}"
29
+ // Strategy 1: sessionKey format "hook:{taskId|task}:{campaignId}:{type}"
30
30
  // The connector/workflow sets the session key during task creation.
31
- // parts[1] = userId, parts[2] = campaignId, parts[3+] = taskType
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) || parts[1] || fallbackTaskId(),
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),
@@ -70,16 +70,52 @@ function enrichWithTokenUsage(
70
70
  taskMode: TaskModeInfo,
71
71
  ): void {
72
72
  try {
73
- // Strategy 1: Try endCtx.meta (works on OpenClaw 2026.4+ if/when they expose agentMeta)
74
- const meta = (endCtx as any)?.meta ?? (endCtx as any)?.result?.meta ?? {};
75
- let extracted = extractTokenUsage(meta);
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
+ }
76
95
 
77
96
  // Strategy 2: Extract from assistant messages' .usage fields
78
97
  // OpenClaw v2026.2.x stores per-turn token usage on each assistant message
79
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;
80
103
  if (!extracted) {
81
- const allMessages: Array<{ role?: string; usage?: Record<string, unknown>; model?: string }> = (endCtx as any)?.messages ?? [];
82
- const fromMessages = extractUsageFromMessages(allMessages);
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);
83
119
  if (fromMessages) {
84
120
  extracted = {
85
121
  usage: {
@@ -90,12 +126,17 @@ function enrichWithTokenUsage(
90
126
  },
91
127
  model: fromMessages.model,
92
128
  };
93
- log(`token usage: extracted from ${allMessages.filter(m => m.role === "assistant").length} assistant messages for ${taskMode.taskId}`);
129
+ log(`token usage: extracted from ${taskMessages.filter(m => m.role === "assistant").length} assistant messages (of ${allMessages.length} total) for ${taskMode.taskId}`);
94
130
  }
95
131
  }
96
132
 
97
133
  if (!extracted) {
98
- log(`token usage: no data in endCtx.meta or messages for ${taskMode.taskId} (meta keys: ${Object.keys(meta).join(",")})`);
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}]`);
99
140
  return;
100
141
  }
101
142
 
@@ -108,10 +149,11 @@ function enrichWithTokenUsage(
108
149
  extracted.usage.outputTokens,
109
150
  extracted.usage.cacheReadTokens ?? 0,
110
151
  modelSlug,
152
+ extracted.usage.cacheWriteTokens ?? 0,
111
153
  );
112
154
 
113
155
  const u = extracted.usage;
114
- log(`tokens ${taskMode.taskId}: in=${u.inputTokens} out=${u.outputTokens} cache=${u.cacheReadTokens ?? 0} cost=$${(taskResult.estimatedCostUsd as number).toFixed(4)} model=${modelSlug ?? "unknown"}`);
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"}`);
115
157
  } catch (err) {
116
158
  log(`token usage extraction failed: ${errMsg(err)}`);
117
159
  }
@@ -141,10 +183,12 @@ export function registerLifecycleHook(
141
183
  let taskMode: TaskModeInfo | null = null;
142
184
 
143
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.
144
188
  const allMessages: Array<{ role?: string; content?: unknown }> = endCtx?.messages ?? [];
145
189
  const userMsgs = allMessages.filter((m: any) => m.role === "user");
146
- for (const userMsg of userMsgs) {
147
- const msgText = extractTextFromContent(userMsg?.content);
190
+ for (let i = userMsgs.length - 1; i >= 0; i--) {
191
+ const msgText = extractTextFromContent(userMsgs[i]?.content);
148
192
  const match = msgText.match(TASK_META_RE);
149
193
  if (match?.[1] && match?.[2]) {
150
194
  taskMode = {