bereach-openclaw 1.5.4 → 1.5.6

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/README.md CHANGED
@@ -21,8 +21,11 @@ openclaw plugins install bereach-openclaw
21
21
  **Before upgrading:** note your `BEREACH_API_KEY` - uninstall may remove `plugins.entries.bereach-openclaw`.
22
22
 
23
23
  ```bash
24
- # 1. Uninstall
24
+ # 1. Uninstall + fully delete (rm prevents stale .old copies causing 401 loops)
25
25
  openclaw plugins uninstall bereach-openclaw
26
+ rm -rf /data/.openclaw/extensions/bereach-openclaw*
27
+ rm -rf /data/.openclaw/node_modules/bereach-openclaw
28
+ rm -rf /data/.openclaw/node_modules/bereach
26
29
 
27
30
  # 2. Reinstall latest
28
31
  openclaw plugins install bereach-openclaw
@@ -108,9 +111,9 @@ OpenClaw loads plugins from **two locations**:
108
111
 
109
112
  If `extensions/bereach-openclaw/` is corrupt or incomplete, you get trim/undefined errors. Fix:
110
113
 
111
- **1. Backup and remove the active extension:**
114
+ **1. Fully remove the active extension** (do NOT rename to `.bak` - OpenClaw loads `.bak`/`.old` copies as duplicate plugins):
112
115
  ```bash
113
- mv /data/.openclaw/extensions/bereach-openclaw /data/.openclaw/extensions/bereach-openclaw.bak.$(date +%s)
116
+ rm -rf /data/.openclaw/extensions/bereach-openclaw*
114
117
  ```
115
118
 
116
119
  **2. Reinstall from npm (inside the container):**
@@ -1428,6 +1428,7 @@ export const definitions: ToolDefinition[] = [
1428
1428
  lifecycleStage: { type: "string", enum: ["contact", "lead", "qualified", "approved", "rejected"], description: "New stage (forward-only, or rejected)." },
1429
1429
  hotScore: { type: "integer", minimum: 0, maximum: 100 },
1430
1430
  qualificationNotes: { type: "string", description: "Agent reasoning for qualification/rejection." },
1431
+ leadBrief: { type: "string", description: "2-3 sentence human-readable lead summary for sales prep. NOT ICP analysis — a cheat sheet: who this person is, what they care about, notable background, conversation hooks." },
1431
1432
  notes: { type: "string" },
1432
1433
  name: { type: "string", description: "Update contact name." },
1433
1434
  profileData: { type: "object", description: "Full LinkedIn profile snapshot (JSON)." },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "bereach-openclaw",
3
3
  "name": "BeReach",
4
- "version": "1.5.4",
4
+ "version": "1.5.6",
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.4",
3
+ "version": "1.5.6",
4
4
  "description": "BeReach LinkedIn automation plugin for OpenClaw",
5
5
  "license": "AGPL-3.0",
6
6
  "exports": {
@@ -58,6 +58,7 @@ type TaskResult = {
58
58
  commentsPosted?: number;
59
59
  invitationsAccepted?: number;
60
60
  error?: string;
61
+ reason?: string;
61
62
  };
62
63
 
63
64
  const MIN_POLL_MS = 5000;
@@ -230,13 +231,12 @@ async function isWebhookAvailable(config: ConnectorConfig): Promise<boolean> {
230
231
  if (res.status === 401 || res.status === 403) return false;
231
232
  if (res.ok || res.status === 405 || res.status === 204) return true;
232
233
 
233
- // Some gateways don't support OPTIONS - fall back to GET on root
234
- const rootRes = await fetch(config.gatewayUrl, {
235
- method: "GET",
236
- signal: AbortSignal.timeout(3000),
237
- });
238
- // Gateway is up - assume hooks are available if we have a token
239
- return rootRes.ok || rootRes.status === 404;
234
+ // OPTIONS returned non-2xx/405 (e.g. 404): /hooks/agent doesn't exist.
235
+ // Don't assume hooks are available just because the gateway root responds.
236
+ // This prevents a false-positive where the connector keeps dispatching
237
+ // to a webhook URL that doesn't exist, failing every task with 404.
238
+ console.log(`[connector] Webhook probe: OPTIONS /hooks/agent returned ${res.status}, hooks not available`);
239
+ return false;
240
240
  } catch {
241
241
  return false;
242
242
  }
@@ -480,9 +480,18 @@ async function executeOnOpenClaw(
480
480
  config: ConnectorConfig,
481
481
  task: NonNullable<PullResponse["task"]>,
482
482
  webhookAvailable: boolean,
483
- ): Promise<{ result: TaskResult | null; error: string | null }> {
483
+ ): Promise<{ result: TaskResult | null; error: string | null; webhookDead?: boolean }> {
484
484
  if (webhookAvailable) {
485
- return executeViaWebhook(config, task);
485
+ const res = await executeViaWebhook(config, task);
486
+ // Detect webhook 404 — hooks endpoint doesn't exist (plugin version mismatch,
487
+ // gateway restart needed). Fall back to execFile for this task AND signal the
488
+ // main loop to flip webhookAvailable so subsequent tasks don't keep failing.
489
+ if (res.error?.startsWith("Webhook HTTP 404")) {
490
+ console.warn(`[connector] Webhook returned 404, falling back to execFile for task ${task.id}`);
491
+ const fallback = await executeViaExecFile(task);
492
+ return { ...fallback, webhookDead: true };
493
+ }
494
+ return res;
486
495
  }
487
496
  console.log(`[connector] Webhook unavailable, falling back to execFile`);
488
497
  return executeViaExecFile(task);
@@ -612,6 +621,7 @@ export async function runConnectorLoop(
612
621
  );
613
622
 
614
623
  try {
624
+ (task as any)._startedAt = Date.now();
615
625
  await updateTaskStatus(config, task.id, "accepted");
616
626
  await updateTaskStatus(config, task.id, "running");
617
627
 
@@ -625,9 +635,9 @@ export async function runConnectorLoop(
625
635
  }, watchdogMs);
626
636
  watchdogTimer.unref(); // Don't prevent graceful shutdown
627
637
 
628
- const { result, error } = await Promise.race([
638
+ const execResult = await Promise.race([
629
639
  executeOnOpenClaw(config, task, webhookAvailable),
630
- new Promise<{ result: null; error: string }>((resolve) => {
640
+ new Promise<{ result: null; error: string; webhookDead?: boolean }>((resolve) => {
631
641
  const check = setTimeout(() => {
632
642
  if (watchdogFired) {
633
643
  resolve({ result: null, error: `Watchdog: task execution exceeded ${Math.round(watchdogMs / 1000)}s timeout` });
@@ -636,21 +646,36 @@ export async function runConnectorLoop(
636
646
  check.unref();
637
647
  }),
638
648
  ]);
649
+ const { result, error } = execResult;
650
+ // If webhook returned 404, disable it for future tasks
651
+ if (execResult.webhookDead) {
652
+ console.warn(`[connector] Disabling webhook mode — /hooks/agent not available`);
653
+ webhookAvailable = false;
654
+ }
639
655
  clearTimeout(watchdogTimer);
640
656
 
641
657
  const taskStatus = error ? "failed" : (result?.success !== false ? "succeeded" : "failed");
658
+ const execDuration = Date.now() - (task as any)._startedAt;
642
659
  console.log(
643
- `[connector] Task ${task.id} ${taskStatus}${error ? `: ${error.slice(0, 100)}` : ""}${task.workflowRunId ? ` workflow=${task.workflowRunId}` : ""}`,
660
+ `[connector] Task ${task.id} ${taskStatus} (${Math.round(execDuration / 1000)}s)${error ? `: ${error.slice(0, 100)}` : ""}${result?.reason ? ` reason=${result.reason}` : ""}${task.workflowRunId ? ` workflow=${task.workflowRunId}` : ""}`,
644
661
  );
645
-
646
- // With webhook execution, the lifecycle hook auto-reports results.
647
- // Submit from connector for execFile fallback, errors, or as safety net
648
- // when lifecycle hook may not have fired (agent crash).
649
- // Don't submit if result looks successful — lifecycle hook already posted.
650
- if (!webhookAvailable || error) {
651
- await submitResult(config, task.id, result, error);
662
+ // Flag suspicious fast failures with no tools for investigation
663
+ if (!error && result?.success === false && (result as any)?.toolCallCount === 0 && execDuration < 10_000) {
664
+ console.warn(`[connector] DIAGNOSTIC: Task ${task.id} failed in <10s with 0 tool calls — likely context injection failure or model error`);
652
665
  }
653
666
 
667
+ // Always submit from connector as safety net. In webhook mode the lifecycle
668
+ // hook usually reports first, but if it fails silently (network, crash) the
669
+ // result is lost and the task stays "running" forever. The result endpoint
670
+ // uses an optimistic lock so double-submissions are harmless (second is a no-op).
671
+ // Derive error when result indicates failure but no explicit error exists.
672
+ const derivedError = error ?? (
673
+ result?.success === false
674
+ ? (result.error ?? result.reason ?? "Task failed (no details from agent)")
675
+ : undefined
676
+ );
677
+ await submitResult(config, task.id, result, derivedError ?? null);
678
+
654
679
  totalTasksExecuted++;
655
680
  pollInterval = 5_000;
656
681
  } finally {
@@ -374,7 +374,7 @@ function formatCampaignDispatch(
374
374
  const shown = Math.min(ranked.length, 7);
375
375
  for (let i = 0; i < shown; i++) {
376
376
  const { campaign: c } = ranked[i];
377
- const pipelineUrl = `${CHAT_BASE}/pipeline?campaign=${c.id}`;
377
+ const pipelineUrl = `${CHAT_BASE}/campaigns/${c.id}/pipeline`;
378
378
  const s = c.stageCounts;
379
379
  const funnel = s && (s.lead || s.qualified || s.approved || s.rejected)
380
380
  ? ` — ${s.lead ?? 0}L / ${s.qualified ?? 0}Q / ${s.approved ?? 0}A / ${s.rejected ?? 0}R`
@@ -479,7 +479,7 @@ function formatOnboardingDirective(state: SessionState, data: CacheStore, apiKey
479
479
  "",
480
480
  "Search 5 matching prospects, visit top 3. Present: name, title, company, why they fit, suggested approach.",
481
481
  "Offer first action: connect, warm up (like/comment), or save to campaign.",
482
- "Execute, then link: [Pipeline](${CHAT_BASE}/pipeline) / [Campaigns](${CHAT_BASE}/campaigns)",
482
+ "Execute, then link: [Campaigns](${CHAT_BASE}/campaigns)",
483
483
  '- `bereach_state_patch({ key: "onboarding", data: { quickWinDone: true } })`',
484
484
  "",
485
485
  );
@@ -576,7 +576,7 @@ function formatLiveStatus(state: SessionState, data: CacheStore, apiKey?: string
576
576
  lines.push("");
577
577
  }
578
578
  lines.push("### Dashboard Links");
579
- lines.push(`[Pipeline](${CHAT_BASE}/pipeline) | [Campaigns](${CHAT_BASE}/campaigns) | [Drafts](${CHAT_BASE}/drafts) | [Context](${CHAT_BASE}/context) | [Settings](${CHAT_BASE}/settings) | [Pricing](${PRICING_URL})`);
579
+ lines.push(`[Campaigns](${CHAT_BASE}/campaigns) | [Activity](${CHAT_BASE}/activity) | [Context](${CHAT_BASE}/context) | [Settings](${CHAT_BASE}/settings) | [Pricing](${PRICING_URL})`);
580
580
  lines.push("");
581
581
  log(`live-status: ONBOARDING mode (${lines.join("\n").length} chars)`);
582
582
  return lines.join("\n");
@@ -733,7 +733,7 @@ function formatLiveStatus(state: SessionState, data: CacheStore, apiKey?: string
733
733
  if (upgradeBlock) lines.push(upgradeBlock);
734
734
 
735
735
  // Dashboard links
736
- lines.push(`**Links**: [Pipeline](${CHAT_BASE}/pipeline) | [Campaigns](${CHAT_BASE}/campaigns) | [Drafts](${CHAT_BASE}/drafts) | [Context](${CHAT_BASE}/context) | [Settings](${CHAT_BASE}/settings) | [Pricing](${PRICING_URL})`);
736
+ lines.push(`**Links**: [Campaigns](${CHAT_BASE}/campaigns) | [Activity](${CHAT_BASE}/activity) | [Context](${CHAT_BASE}/context) | [Settings](${CHAT_BASE}/settings) | [Pricing](${PRICING_URL})`);
737
737
  lines.push("");
738
738
 
739
739
  log(`live-status: ACTIVE mode, campaigns=${hasCampaigns} contacts=${hasContacts} pending=${data.pendingDrafts > 0 || data.failedDrafts > 0 || data.unreadDMs > 0} (${lines.join("\n").length} chars)`);
@@ -125,9 +125,25 @@ export function registerLifecycleHook(
125
125
  creditsUsed: state.creditsUsedThisSession,
126
126
  toolCallCount: state.toolCallCount,
127
127
  visitCount: state.visitCount,
128
- ...(state.toolCallCount === 0 ? { reason: "no_tools_called" } : {}),
128
+ ...(state.toolCallCount === 0 ? { reason: "no_tools_called", error: "Task completed with no tool calls" } : {}),
129
129
  };
130
130
 
131
+ // Diagnostic logging for no_tools_called — capture WHY the agent did nothing.
132
+ // This helps diagnose context injection failures, model errors, etc.
133
+ if (state.toolCallCount === 0) {
134
+ const msgCount = allMessages.length;
135
+ const userMsgCount = userMsgs.length;
136
+ const assistantMsgCount = assistantMsgs.length;
137
+ const lastAssistantText = outputText?.slice(0, 300) || "(empty)";
138
+ const hasTaskMeta = userMsgs.some((m: any) => extractTextFromContent(m.content).includes("TASK_META"));
139
+ const endSuccess = (endCtx as any)?.success;
140
+ const endError = (endCtx as any)?.error;
141
+ log(`NO_TOOLS_CALLED diagnostic for ${taskMode.taskId}: ` +
142
+ `msgs=${msgCount} (user=${userMsgCount} assistant=${assistantMsgCount}) ` +
143
+ `hasTaskMeta=${hasTaskMeta} endSuccess=${endSuccess} endError=${endError ? String(endError).slice(0, 200) : "none"} ` +
144
+ `lastAssistant="${lastAssistantText}"`);
145
+ }
146
+
131
147
  // B17 fix: In execFile mode, the connector also submits results after
132
148
  // parsing the OpenClaw --json wrapper (extractTaskResultFromWrapper).
133
149
  // The lifecycle hook fires first (inside subprocess), so it wins the race.
@@ -148,10 +164,14 @@ export function registerLifecycleHook(
148
164
  attempts++;
149
165
  try {
150
166
  if (attempt > 0) await new Promise((r) => setTimeout(r, 2000 * attempt));
167
+ // Derive error from result when task failed but no explicit error exists
168
+ const taskError = taskResult.success === false
169
+ ? (taskResult.error ?? taskResult.reason ?? "Task failed (no details captured)")
170
+ : undefined;
151
171
  const res = await fetch(`${API_BASE}/tasks/${taskMode.taskId}/result`, {
152
172
  method: "POST",
153
173
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
154
- body: JSON.stringify({ result: taskResult }),
174
+ body: JSON.stringify({ result: taskResult, ...(taskError ? { error: taskError } : {}) }),
155
175
  signal: AbortSignal.timeout(10000),
156
176
  });
157
177
  if (res.ok) {
package/src/index.ts CHANGED
@@ -17,6 +17,10 @@ const log = createLogger("init");
17
17
  /** Guard against multiple register() calls with the same api instance */
18
18
  const registeredApis = new WeakSet<object>();
19
19
  let registeredCount = 0;
20
+ let autoDetectDone = false;
21
+ let lastRegisterAt = 0;
22
+ /** After initial burst, suppress noisy re-registration logs */
23
+ const REGISTER_LOG_THRESHOLD = 5;
20
24
 
21
25
  /**
22
26
  * Resolve the BeReach API key from all supported sources (in priority order):
@@ -32,6 +36,86 @@ export function resolveApiKey(api: any): string | undefined {
32
36
  return typeof key === "string" && key.trim().length > 0 ? key.trim() : undefined;
33
37
  }
34
38
 
39
+ /**
40
+ * Auto-detect LLM provider from available API keys and set workspace defaults.
41
+ *
42
+ * Runs once at install time. If the workspace still has Anthropic defaults but
43
+ * no ANTHROPIC_API_KEY is available (only GEMINI_API_KEY), switches to Gemini
44
+ * models automatically. This prevents tasks from failing because the default
45
+ * models require an API key the user doesn't have.
46
+ *
47
+ * Does nothing if:
48
+ * - Already ran this session (idempotent guard)
49
+ * - ANTHROPIC_API_KEY is available (defaults are correct)
50
+ * - No GEMINI_API_KEY available (nothing to switch to)
51
+ * - User already changed models from defaults (respects manual choice)
52
+ */
53
+ async function autoDetectModels(apiKey: string): Promise<void> {
54
+ if (autoDetectDone) return;
55
+ autoDetectDone = true;
56
+
57
+ const hasAnthropic = !!readEnv("ANTHROPIC_API_KEY");
58
+ const hasGemini = !!(readEnv("GEMINI_API_KEY") || readEnv("GOOGLE_API_KEY"));
59
+
60
+ // If Anthropic key exists, defaults are already correct
61
+ if (hasAnthropic) {
62
+ log("model auto-detect: Anthropic key available, keeping defaults");
63
+ return;
64
+ }
65
+
66
+ // If no Gemini key either, nothing we can switch to
67
+ if (!hasGemini) {
68
+ log("model auto-detect: no LLM provider keys found, skipping");
69
+ return;
70
+ }
71
+
72
+ // Gemini key available, no Anthropic key - check if we need to switch
73
+ try {
74
+ const res = await fetch(`${API_BASE}/me/settings`, {
75
+ headers: { Authorization: `Bearer ${apiKey}` },
76
+ signal: AbortSignal.timeout(5000),
77
+ });
78
+ if (!res.ok) {
79
+ log(`model auto-detect: settings GET failed (HTTP ${res.status}), skipping`);
80
+ return;
81
+ }
82
+
83
+ const data = await res.json() as {
84
+ aiModels?: { fast?: string; creative?: string };
85
+ };
86
+ const currentFast = data.aiModels?.fast;
87
+ const currentCreative = data.aiModels?.creative;
88
+
89
+ // Only auto-switch if still on Anthropic defaults (respect manual choices)
90
+ if (
91
+ currentFast === "anthropic/claude-haiku-4-5" &&
92
+ currentCreative === "anthropic/claude-sonnet-4-6"
93
+ ) {
94
+ const patchRes = await fetch(`${API_BASE}/me/settings`, {
95
+ method: "PATCH",
96
+ headers: {
97
+ Authorization: `Bearer ${apiKey}`,
98
+ "Content-Type": "application/json",
99
+ },
100
+ body: JSON.stringify({
101
+ aiFastModel: "google/gemini-2.5-flash",
102
+ aiCreativeModel: "google/gemini-2.5-pro",
103
+ }),
104
+ signal: AbortSignal.timeout(5000),
105
+ });
106
+ if (patchRes.ok) {
107
+ log("model auto-detect: no Anthropic key, Gemini key found - switched to Gemini Flash (fast) + Gemini Pro (creative)");
108
+ } else {
109
+ log(`model auto-detect: PATCH failed (HTTP ${patchRes.status})`);
110
+ }
111
+ } else {
112
+ log("model auto-detect: models already customized, skipping");
113
+ }
114
+ } catch (err) {
115
+ log(`model auto-detect: ${err instanceof Error ? err.message : String(err)}`);
116
+ }
117
+ }
118
+
35
119
  export default function register(api: any) {
36
120
  if (api && typeof api === "object" && registeredApis.has(api)) {
37
121
  log(`skip duplicate register() call (same api instance, call #${registeredCount + 1})`);
@@ -39,8 +123,11 @@ export default function register(api: any) {
39
123
  }
40
124
 
41
125
  registeredCount++;
42
- if (registeredCount > 1) {
126
+ lastRegisterAt = Date.now();
127
+ if (registeredCount > 1 && registeredCount <= REGISTER_LOG_THRESHOLD) {
43
128
  log(`register() called again (call #${registeredCount}) — re-registering on new api instance`);
129
+ } else if (registeredCount === REGISTER_LOG_THRESHOLD + 1) {
130
+ log(`register() call #${registeredCount} — suppressing further re-registration logs`);
44
131
  }
45
132
  if (api && typeof api === "object") {
46
133
  registeredApis.add(api);
@@ -127,6 +214,15 @@ export default function register(api: any) {
127
214
  }
128
215
  }
129
216
 
217
+ // Auto-detect LLM provider and set workspace model defaults.
218
+ // Runs before connector start so tasks use the correct models from the first poll.
219
+ // Fire-and-forget — non-critical, must not block registration.
220
+ if (apiKey) {
221
+ autoDetectModels(apiKey).catch((err) => {
222
+ log(`model auto-detect error: ${err instanceof Error ? err.message : String(err)}`);
223
+ });
224
+ }
225
+
130
226
  // Auto-start connector if API key is present and connector is enabled.
131
227
  // Skip when running as an agent child process (openclaw agent) — each child
132
228
  // would start its own polling loop, stealing tasks from the main connector.
@@ -155,6 +155,7 @@ export function registerAllTools(api: any, taskType?: string) {
155
155
  content: [{ type: "text" as const, text: JSON.stringify({ success: false, error: "Invalid key format. Must start with brc_. Get your key at https://bereach.ai/token" }) }],
156
156
  };
157
157
  }
158
+
158
159
  if (typeof api?.config?.set !== "function") {
159
160
  return {
160
161
  content: [{ type: "text" as const, text: JSON.stringify({ success: false, error: "Config API not available. Set BEREACH_API_KEY as an environment variable instead." }) }],