bereach-openclaw 1.5.0-beta.2 → 1.5.0-beta.3
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 +1 -1
- package/package.json +5 -2
- package/src/commands/connector.ts +161 -20
- package/src/commands/index.ts +2 -2
- package/src/commands/setup.ts +1 -1
- package/src/connector/manager.ts +1 -1
- package/src/connector-cli.ts +1 -1
- package/src/hooks/cache.ts +2 -2
- package/src/hooks/context.ts +6 -5
- package/src/hooks/enforcement.ts +30 -18
- package/src/hooks/lifecycle.ts +94 -15
- package/src/hooks/tracking.ts +18 -6
- package/src/hooks/types.ts +4 -3
- package/src/index.ts +19 -1
- package/src/tools/definitions.ts +113 -113
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bereach-openclaw",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.3",
|
|
4
4
|
"description": "BeReach LinkedIn automation plugin for OpenClaw",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"exports": {
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
"test:e2e:check-regression": "tsx __tests__/e2e/lib/token-tracker.ts --check-regression",
|
|
33
33
|
"test:e2e:upgrade-setup": "cd __tests__/docker && docker compose -f docker-compose.upgrade-test.yml up -d --build",
|
|
34
34
|
"test:e2e:upgrade-teardown": "cd __tests__/docker && docker compose -f docker-compose.upgrade-test.yml down",
|
|
35
|
-
"test:e2e:upgrade": "cd __tests__/docker && ./upgrade-test.sh"
|
|
35
|
+
"test:e2e:upgrade": "cd __tests__/docker && ./upgrade-test.sh",
|
|
36
|
+
"test:e2e:vps-setup": "cd __tests__/docker && docker compose -f docker-compose.vps-test.yml up -d --build",
|
|
37
|
+
"test:e2e:vps-teardown": "cd __tests__/docker && docker compose -f docker-compose.vps-test.yml down",
|
|
38
|
+
"test:e2e:vps-smoke": "cd __tests__/docker && ./vps-smoke-test.sh"
|
|
36
39
|
},
|
|
37
40
|
"openclaw": {
|
|
38
41
|
"extensions": [
|
|
@@ -61,6 +61,86 @@ type TaskResult = {
|
|
|
61
61
|
|
|
62
62
|
const MIN_POLL_MS = 5000;
|
|
63
63
|
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// TaskResult extraction from OpenClaw agent output
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract the last JSON block from agent output text.
|
|
70
|
+
* Copied from lifecycle.ts — the connector must be self-contained
|
|
71
|
+
* since it can run as a standalone CLI without lifecycle hooks.
|
|
72
|
+
*/
|
|
73
|
+
function parseStructuredResult(text: string): Record<string, unknown> | null {
|
|
74
|
+
// Try fenced code blocks first
|
|
75
|
+
const jsonBlocks = text.match(/```(?:json)?\s*([\s\S]*?)```/g);
|
|
76
|
+
if (jsonBlocks?.length) {
|
|
77
|
+
const lastBlock = jsonBlocks[jsonBlocks.length - 1];
|
|
78
|
+
const jsonStr = lastBlock.replace(/^```(?:json)?\s*/, "").replace(/```$/, "").trim();
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(jsonStr);
|
|
81
|
+
} catch { /* fall through */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Try raw JSON at end of text
|
|
85
|
+
const lastBrace = text.lastIndexOf("{");
|
|
86
|
+
if (lastBrace >= 0) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(text.slice(lastBrace));
|
|
89
|
+
} catch { /* fall through */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract a TaskResult from OpenClaw --json wrapper output.
|
|
97
|
+
* OpenClaw wraps agent output as: { result: { payloads: [{text: "..."}], meta: {...} } }
|
|
98
|
+
* or sometimes: { payloads: [{text: "..."}], meta: {...} }
|
|
99
|
+
* The actual TaskResult JSON is embedded in the last payload's text field.
|
|
100
|
+
*/
|
|
101
|
+
function extractTaskResultFromWrapper(output: Record<string, unknown>): TaskResult | null {
|
|
102
|
+
const payloads = (
|
|
103
|
+
(output?.result as Record<string, unknown>)?.payloads ??
|
|
104
|
+
output?.payloads ??
|
|
105
|
+
[]
|
|
106
|
+
) as { text?: string }[];
|
|
107
|
+
|
|
108
|
+
if (payloads.length === 0) return null;
|
|
109
|
+
|
|
110
|
+
// Walk payloads backwards — TaskResult JSON is typically in the last one
|
|
111
|
+
for (let i = payloads.length - 1; i >= 0; i--) {
|
|
112
|
+
const text = payloads[i]?.text;
|
|
113
|
+
if (text) {
|
|
114
|
+
const parsed = parseStructuredResult(text);
|
|
115
|
+
if (parsed && ("success" in parsed || "contactsProcessed" in parsed)) {
|
|
116
|
+
return parsed as TaskResult;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Stderr patterns that are noise (module warnings, not actual errors) */
|
|
125
|
+
const HARMLESS_STDERR_RE = [
|
|
126
|
+
/Cannot find module '@aws-sdk\/client-bedrock'/,
|
|
127
|
+
/Cannot find module 'amazon-bedrock'/,
|
|
128
|
+
/Require stack:/,
|
|
129
|
+
/ExperimentalWarning/,
|
|
130
|
+
/DEP\d+/,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
function filterStderr(raw: string): string {
|
|
134
|
+
return raw
|
|
135
|
+
.split("\n")
|
|
136
|
+
.filter((line) => {
|
|
137
|
+
const t = line.trim();
|
|
138
|
+
return t && !HARMLESS_STDERR_RE.some((p) => p.test(t));
|
|
139
|
+
})
|
|
140
|
+
.join("\n")
|
|
141
|
+
.trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
64
144
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
65
145
|
return new Promise((resolve) => {
|
|
66
146
|
const delay = Math.max(MIN_POLL_MS, ms);
|
|
@@ -96,7 +176,7 @@ async function pullTask(config: ConnectorConfig): Promise<PullResponse> {
|
|
|
96
176
|
if (!config.apiKey && config.connectorToken) {
|
|
97
177
|
body.connectorToken = config.connectorToken;
|
|
98
178
|
}
|
|
99
|
-
return httpPost(`${config.apiUrl}/
|
|
179
|
+
return httpPost(`${config.apiUrl}/tasks/pull`, body, headers) as Promise<PullResponse>;
|
|
100
180
|
}
|
|
101
181
|
|
|
102
182
|
async function submitResult(
|
|
@@ -110,7 +190,7 @@ async function submitResult(
|
|
|
110
190
|
if (!config.apiKey && config.connectorToken) {
|
|
111
191
|
body.connectorToken = config.connectorToken;
|
|
112
192
|
}
|
|
113
|
-
await httpPost(`${config.apiUrl}/
|
|
193
|
+
await httpPost(`${config.apiUrl}/tasks/${taskId}/result`, body, headers);
|
|
114
194
|
}
|
|
115
195
|
|
|
116
196
|
async function heartbeat(
|
|
@@ -122,7 +202,7 @@ async function heartbeat(
|
|
|
122
202
|
if (!config.apiKey && config.connectorToken) {
|
|
123
203
|
body.connectorToken = config.connectorToken;
|
|
124
204
|
}
|
|
125
|
-
return httpPost(`${config.apiUrl}/
|
|
205
|
+
return httpPost(`${config.apiUrl}/connectors/heartbeat`, body, headers) as Promise<{ pollIntervalMs: number }>;
|
|
126
206
|
}
|
|
127
207
|
|
|
128
208
|
/**
|
|
@@ -161,6 +241,25 @@ async function isWebhookAvailable(config: ConnectorConfig): Promise<boolean> {
|
|
|
161
241
|
}
|
|
162
242
|
}
|
|
163
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Probe webhook with retries — gateway may still be starting (B9 fix).
|
|
246
|
+
*/
|
|
247
|
+
async function probeWebhookWithRetry(
|
|
248
|
+
config: ConnectorConfig,
|
|
249
|
+
attempts = 3,
|
|
250
|
+
delayMs = 2000,
|
|
251
|
+
): Promise<boolean> {
|
|
252
|
+
for (let i = 1; i <= attempts; i++) {
|
|
253
|
+
const ok = await isWebhookAvailable(config);
|
|
254
|
+
if (ok) return true;
|
|
255
|
+
if (i < attempts) {
|
|
256
|
+
console.log(`[connector] Webhook probe ${i}/${attempts} failed, retrying in ${delayMs / 1000}s...`);
|
|
257
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
164
263
|
/**
|
|
165
264
|
* Execute a task via the gateway /hooks/agent webhook.
|
|
166
265
|
* This is the preferred execution path - uses OpenClaw's native agent runner
|
|
@@ -251,17 +350,27 @@ async function executeViaExecFile(
|
|
|
251
350
|
task: NonNullable<PullResponse["task"]>,
|
|
252
351
|
): Promise<{ result: TaskResult | null; error: string | null }> {
|
|
253
352
|
try {
|
|
353
|
+
// Use task ID as session ID. The sessionKey ("hook:{userId}:{campaignId}:{type}")
|
|
354
|
+
// contains colons which the gateway rejects as invalid session IDs.
|
|
355
|
+
// Task metadata is passed via TASK_META in the message instead.
|
|
254
356
|
const sessionId = `task-${task.id}`;
|
|
255
|
-
const
|
|
357
|
+
const maxCredits = (task.payload as Record<string, unknown>)?.maxCredits ?? 100;
|
|
358
|
+
|
|
359
|
+
// The `openclaw agent` CLI connects to the gateway process via WebSocket,
|
|
360
|
+
// so env vars set here do NOT reach the gateway's plugin hooks.
|
|
361
|
+
// Instead, inject [TASK_META: ...] into the message so the gateway's
|
|
362
|
+
// init_context hook can parse task metadata from the prompt text.
|
|
363
|
+
const taskMeta = `[TASK_META: taskType=${task.type} taskId=${task.id} campaignId=${task.campaignId ?? ""} maxCredits=${maxCredits}]`;
|
|
364
|
+
const baseMessage = task.message || `Execute ${task.type} task`;
|
|
365
|
+
const message = `${taskMeta}\n\n${baseMessage}`;
|
|
256
366
|
|
|
257
|
-
//
|
|
258
|
-
// detectTaskMode() picks them up without needing the hook: session prefix
|
|
367
|
+
// Keep env vars for --local mode (direct execution without gateway)
|
|
259
368
|
const env: Record<string, string> = {
|
|
260
369
|
...process.env as Record<string, string>,
|
|
261
370
|
BEREACH_TASK_ID: task.id,
|
|
262
371
|
BEREACH_TASK_TYPE: task.type,
|
|
263
372
|
BEREACH_TASK_CAMPAIGN_ID: task.campaignId ?? "",
|
|
264
|
-
BEREACH_TASK_MAX_CREDITS: String(
|
|
373
|
+
BEREACH_TASK_MAX_CREDITS: String(maxCredits),
|
|
265
374
|
};
|
|
266
375
|
|
|
267
376
|
const args = [
|
|
@@ -297,12 +406,22 @@ async function executeViaExecFile(
|
|
|
297
406
|
if (output.error) {
|
|
298
407
|
return { result: null, error: output.error };
|
|
299
408
|
}
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
409
|
+
|
|
410
|
+
// OpenClaw --json wraps agent output in { result: { payloads: [...], meta: {...} } }
|
|
411
|
+
// The actual TaskResult is embedded in the last payload's text.
|
|
412
|
+
// B17 fix: extract it instead of returning the raw wrapper.
|
|
413
|
+
const extracted = extractTaskResultFromWrapper(output);
|
|
414
|
+
if (extracted) {
|
|
415
|
+
return { result: extracted, error: null };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Fallback: if output itself looks like a flat TaskResult
|
|
419
|
+
if (output.success !== undefined || output.contactsProcessed !== undefined) {
|
|
420
|
+
return { result: output as TaskResult, error: null };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Last resort: lifecycle hook may have already posted the real result
|
|
424
|
+
return { result: { success: true }, error: null };
|
|
306
425
|
} catch {
|
|
307
426
|
// Non-JSON output - treat as success if process exited cleanly
|
|
308
427
|
return {
|
|
@@ -323,15 +442,26 @@ async function executeViaExecFile(
|
|
|
323
442
|
if (stdout) {
|
|
324
443
|
try {
|
|
325
444
|
const output = JSON.parse(stdout);
|
|
326
|
-
if (output.
|
|
327
|
-
return { result:
|
|
445
|
+
if (output.error) {
|
|
446
|
+
return { result: null, error: String(output.error).slice(0, 500) };
|
|
447
|
+
}
|
|
448
|
+
// B17 fix: extract TaskResult from OpenClaw wrapper (same as happy path)
|
|
449
|
+
const extracted = extractTaskResultFromWrapper(output);
|
|
450
|
+
if (extracted) {
|
|
451
|
+
return { result: extracted, error: null };
|
|
452
|
+
}
|
|
453
|
+
// Accept flat { success: true } format
|
|
454
|
+
if (output.success !== undefined || output.contactsProcessed !== undefined) {
|
|
455
|
+
return { result: output as TaskResult, error: null };
|
|
328
456
|
}
|
|
329
457
|
} catch {
|
|
330
458
|
// Not JSON
|
|
331
459
|
}
|
|
332
460
|
}
|
|
333
461
|
|
|
334
|
-
|
|
462
|
+
// Filter known-harmless stderr noise (e.g. @aws-sdk/client-bedrock module warnings)
|
|
463
|
+
const filtered = filterStderr(stderr);
|
|
464
|
+
const message = filtered || execErr.message || String(err);
|
|
335
465
|
return { result: null, error: message.slice(0, 500) };
|
|
336
466
|
}
|
|
337
467
|
}
|
|
@@ -361,7 +491,7 @@ async function updateTaskStatus(
|
|
|
361
491
|
if (!config.apiKey && config.connectorToken) {
|
|
362
492
|
body.connectorToken = config.connectorToken;
|
|
363
493
|
}
|
|
364
|
-
await httpPost(`${config.apiUrl}/
|
|
494
|
+
await httpPost(`${config.apiUrl}/tasks/${taskId}/result`, body, headers).catch((err) => {
|
|
365
495
|
console.error(`[connector] Task status update to "${status}" failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
366
496
|
});
|
|
367
497
|
}
|
|
@@ -392,8 +522,8 @@ export async function runConnectorLoop(
|
|
|
392
522
|
console.log(`[connector] API: ${config.apiUrl}`);
|
|
393
523
|
console.log(`[connector] Auth: ${config.apiKey ? "API key (Bearer)" : "connector token (legacy)"}`);
|
|
394
524
|
|
|
395
|
-
// Probe webhook availability on startup
|
|
396
|
-
webhookAvailable = await
|
|
525
|
+
// Probe webhook availability on startup (with retry for gateway startup race — B9 fix)
|
|
526
|
+
webhookAvailable = await probeWebhookWithRetry(config);
|
|
397
527
|
const execMode = webhookAvailable ? "webhook" : "execFile (fallback)";
|
|
398
528
|
console.log(`[connector] Execution mode: ${execMode}`);
|
|
399
529
|
if (webhookAvailable) {
|
|
@@ -462,13 +592,15 @@ export async function runConnectorLoop(
|
|
|
462
592
|
webhookAvailable,
|
|
463
593
|
);
|
|
464
594
|
|
|
595
|
+
const taskStatus = error ? "failed" : (result?.success !== false ? "succeeded" : "failed");
|
|
465
596
|
console.log(
|
|
466
|
-
`[connector] Task ${response.task.id} ${
|
|
597
|
+
`[connector] Task ${response.task.id} ${taskStatus}${error ? `: ${error.slice(0, 100)}` : ""}`,
|
|
467
598
|
);
|
|
468
599
|
|
|
469
600
|
// With webhook execution, the lifecycle hook auto-reports results.
|
|
470
601
|
// Submit from connector for execFile fallback, errors, or as safety net
|
|
471
602
|
// when lifecycle hook may not have fired (agent crash).
|
|
603
|
+
// Don't submit if result looks successful — lifecycle hook already posted.
|
|
472
604
|
if (!webhookAvailable || error) {
|
|
473
605
|
await submitResult(config, response.task.id, result, error);
|
|
474
606
|
}
|
|
@@ -497,6 +629,15 @@ export async function runConnectorLoop(
|
|
|
497
629
|
config.pollIntervalMs * Math.pow(2, consecutiveErrors),
|
|
498
630
|
5 * 60 * 1000,
|
|
499
631
|
);
|
|
632
|
+
|
|
633
|
+
// Re-probe webhook if stuck in execFile mode with repeated errors (B9 fix)
|
|
634
|
+
if (!webhookAvailable && consecutiveErrors >= 3 && consecutiveErrors % 3 === 0) {
|
|
635
|
+
console.log(`[connector] ${consecutiveErrors} consecutive errors, re-probing webhook...`);
|
|
636
|
+
webhookAvailable = await isWebhookAvailable(config);
|
|
637
|
+
if (webhookAvailable) {
|
|
638
|
+
console.log(`[connector] Webhook now available, switching to webhook mode`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
500
641
|
}
|
|
501
642
|
|
|
502
643
|
await sleep(pollInterval, signal);
|
package/src/commands/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ export function registerCommands(api: any, state?: SessionState, apiKey?: string
|
|
|
9
9
|
name: "bereach-credits",
|
|
10
10
|
description: "Show current BeReach credit balance",
|
|
11
11
|
handler: async () => {
|
|
12
|
-
const res = await apiFetch<{ credits: { current: number; limit: number | null; remaining: number | null; isUnlimited: boolean; percentage: number } }>("/
|
|
12
|
+
const res = await apiFetch<{ credits: { current: number; limit: number | null; remaining: number | null; isUnlimited: boolean; percentage: number } }>("/me/credits", resolvedKey);
|
|
13
13
|
if (!res) return { text: "Failed to fetch credits." };
|
|
14
14
|
const c = res.credits;
|
|
15
15
|
return {
|
|
@@ -24,7 +24,7 @@ export function registerCommands(api: any, state?: SessionState, apiKey?: string
|
|
|
24
24
|
name: "bereach-status",
|
|
25
25
|
description: "Show LinkedIn rate limit summary",
|
|
26
26
|
handler: async () => {
|
|
27
|
-
const res = await apiFetch<any>("/
|
|
27
|
+
const res = await apiFetch<any>("/rate-limits", resolvedKey);
|
|
28
28
|
if (!res) return { text: "Failed to fetch limits." };
|
|
29
29
|
const limits = res?.linkedIn?.limits ?? res?.limits ?? {};
|
|
30
30
|
const lines = ["BeReach Rate Limits:"];
|
package/src/commands/setup.ts
CHANGED
|
@@ -31,7 +31,7 @@ export function registerSetupCommand(api: any) {
|
|
|
31
31
|
|
|
32
32
|
// Validate key by calling a lightweight endpoint
|
|
33
33
|
try {
|
|
34
|
-
const res = await fetch(`${API_BASE}/
|
|
34
|
+
const res = await fetch(`${API_BASE}/me/credits`, {
|
|
35
35
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
36
36
|
signal: AbortSignal.timeout(5000),
|
|
37
37
|
});
|
package/src/connector/manager.ts
CHANGED
|
@@ -52,7 +52,7 @@ class ConnectorManager {
|
|
|
52
52
|
if (taskId && this.config?.apiKey) {
|
|
53
53
|
console.log(`[bereach:connector] reporting task ${taskId} as interrupted...`);
|
|
54
54
|
try {
|
|
55
|
-
await fetch(`${this.config.apiUrl}/
|
|
55
|
+
await fetch(`${this.config.apiUrl}/tasks/${taskId}/result`, {
|
|
56
56
|
method: "POST",
|
|
57
57
|
headers: {
|
|
58
58
|
"Authorization": `Bearer ${this.config.apiKey}`,
|
package/src/connector-cli.ts
CHANGED
|
@@ -37,7 +37,7 @@ async function gracefulShutdown(signal: string) {
|
|
|
37
37
|
try {
|
|
38
38
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
39
39
|
if (config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`;
|
|
40
|
-
await fetch(`${config.apiUrl}/
|
|
40
|
+
await fetch(`${config.apiUrl}/tasks/${taskId}/result`, {
|
|
41
41
|
method: "POST",
|
|
42
42
|
headers,
|
|
43
43
|
body: JSON.stringify({ result: null, error: `Connector terminated (${signal})` }),
|
package/src/hooks/cache.ts
CHANGED
|
@@ -219,7 +219,7 @@ export { apiFetchUtil as apiFetch };
|
|
|
219
219
|
// ---------------------------------------------------------------------------
|
|
220
220
|
|
|
221
221
|
export async function fetchSnapshot(apiKey: string): Promise<CacheStore> {
|
|
222
|
-
const snapshot = await apiFetchUtil<any>("/
|
|
222
|
+
const snapshot = await apiFetchUtil<any>("/agent/snapshot", apiKey);
|
|
223
223
|
|
|
224
224
|
if (!snapshot) {
|
|
225
225
|
log("fetchSnapshot: FAIL — snapshot endpoint returned null");
|
|
@@ -255,7 +255,7 @@ export async function fetchSnapshot(apiKey: string): Promise<CacheStore> {
|
|
|
255
255
|
// Fallback: if snapshot didn't include limits, fetch from dedicated endpoint
|
|
256
256
|
if (!store.limits) {
|
|
257
257
|
log("fetchSnapshot: limits missing from snapshot, fetching from /api/me/limits");
|
|
258
|
-
const limitsData = await apiFetchUtil<CachedLimits>("/
|
|
258
|
+
const limitsData = await apiFetchUtil<CachedLimits>("/me/limits", apiKey);
|
|
259
259
|
if (limitsData) {
|
|
260
260
|
store.limits = limitsData;
|
|
261
261
|
log(`fetchSnapshot: limits fallback OK — multiplier=${limitsData.multiplier ?? 1} actions=${Object.keys(limitsData.limits ?? {}).length}`);
|
package/src/hooks/context.ts
CHANGED
|
@@ -43,6 +43,7 @@ export function resetProfileState(state: SessionState) {
|
|
|
43
43
|
state.profileInitDone = false;
|
|
44
44
|
state.toneInferenceInjected = false;
|
|
45
45
|
state.onboardingDirectiveInjected = false;
|
|
46
|
+
state.anthropicKeyWarningInjected = false;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
// Version check cache
|
|
@@ -154,7 +155,7 @@ async function fetchSoulTemplate(apiKey?: string): Promise<string> {
|
|
|
154
155
|
try {
|
|
155
156
|
const headers: Record<string, string> = {};
|
|
156
157
|
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
157
|
-
const res = await fetch(`${API_BASE}/
|
|
158
|
+
const res = await fetch(`${API_BASE}/onboarding/soul`, {
|
|
158
159
|
headers,
|
|
159
160
|
signal: AbortSignal.timeout(3000),
|
|
160
161
|
});
|
|
@@ -194,7 +195,7 @@ async function _autoInitProfileInner(data: CacheStore, apiKey: string): Promise<
|
|
|
194
195
|
profileUrl?: string;
|
|
195
196
|
linkedinEmail?: string;
|
|
196
197
|
profileData?: { location?: string; connectionsCount?: number };
|
|
197
|
-
}>("/
|
|
198
|
+
}>("/me/linkedin", apiKey);
|
|
198
199
|
|
|
199
200
|
if (!profile?.linkedinName) return;
|
|
200
201
|
|
|
@@ -211,7 +212,7 @@ async function _autoInitProfileInner(data: CacheStore, apiKey: string): Promise<
|
|
|
211
212
|
return;
|
|
212
213
|
}
|
|
213
214
|
|
|
214
|
-
await sharedApiFetch("/
|
|
215
|
+
await sharedApiFetch("/context", apiKey, {
|
|
215
216
|
method: "PUT",
|
|
216
217
|
body: { type: "user-profile", content, scope: "user" },
|
|
217
218
|
});
|
|
@@ -223,7 +224,7 @@ async function _autoInitProfileInner(data: CacheStore, apiKey: string): Promise<
|
|
|
223
224
|
else data.contexts.push(entry);
|
|
224
225
|
|
|
225
226
|
// Save refresh timestamp
|
|
226
|
-
await sharedApiFetch("/
|
|
227
|
+
await sharedApiFetch("/agent-state/session-meta", apiKey, {
|
|
227
228
|
method: "PATCH",
|
|
228
229
|
body: { data: { lastProfileRefreshAt: new Date().toISOString() } },
|
|
229
230
|
});
|
|
@@ -421,7 +422,7 @@ function formatOnboardingDirective(state: SessionState, data: CacheStore, apiKey
|
|
|
421
422
|
cacheSet("onboardingState", completedState);
|
|
422
423
|
|
|
423
424
|
if (apiKey) {
|
|
424
|
-
sharedApiFetch("/
|
|
425
|
+
sharedApiFetch("/agent-state/onboarding", apiKey, {
|
|
425
426
|
method: "PUT",
|
|
426
427
|
body: {
|
|
427
428
|
data: { completed: true, completedAt: new Date().toISOString(), autoCompleted: true },
|
package/src/hooks/enforcement.ts
CHANGED
|
@@ -333,7 +333,7 @@ async function guardScheduledDm(toolName: string, params: Record<string, unknown
|
|
|
333
333
|
|
|
334
334
|
try {
|
|
335
335
|
const res = await fetch(
|
|
336
|
-
`${API_BASE}/
|
|
336
|
+
`${API_BASE}/contacts/${encodeURIComponent(contactId)}`,
|
|
337
337
|
{
|
|
338
338
|
headers: { Authorization: `Bearer ${key}` },
|
|
339
339
|
signal: AbortSignal.timeout(3000),
|
|
@@ -382,7 +382,7 @@ async function guardDoNotContact(toolName: string, params: Record<string, unknow
|
|
|
382
382
|
let contactData: any = null;
|
|
383
383
|
if (effectiveProfile) {
|
|
384
384
|
const res = await fetch(
|
|
385
|
-
`${API_BASE}/
|
|
385
|
+
`${API_BASE}/contacts/by-url?linkedinUrl=${encodeURIComponent(effectiveProfile)}`,
|
|
386
386
|
{
|
|
387
387
|
headers: { Authorization: `Bearer ${key}` },
|
|
388
388
|
signal: AbortSignal.timeout(3000),
|
|
@@ -395,7 +395,7 @@ async function guardDoNotContact(toolName: string, params: Record<string, unknow
|
|
|
395
395
|
}
|
|
396
396
|
} else if (contactId) {
|
|
397
397
|
const res = await fetch(
|
|
398
|
-
`${API_BASE}/
|
|
398
|
+
`${API_BASE}/contacts/${encodeURIComponent(contactId)}`,
|
|
399
399
|
{
|
|
400
400
|
headers: { Authorization: `Bearer ${key}` },
|
|
401
401
|
signal: AbortSignal.timeout(3000),
|
|
@@ -479,33 +479,36 @@ function guardBulkInChat(toolName: string, params: Record<string, unknown>, stat
|
|
|
479
479
|
?? (typeof params.postUrn === "string" ? params.postUrn : null);
|
|
480
480
|
if (!contactId) { log(`bulk-in-chat: SKIP ${toolName} (no contactId, params=${JSON.stringify(Object.keys(params))})`); return null; }
|
|
481
481
|
|
|
482
|
-
//
|
|
482
|
+
// Check limit BEFORE adding to set — otherwise the blocked contact
|
|
483
|
+
// ends up in the set, inflating the count even though it was never processed.
|
|
484
|
+
const isNewContact = !state.targetedContacts.has(contactId);
|
|
485
|
+
if (isNewContact && state.targetedContacts.size >= MAX_CONTACTS_IN_CHAT) {
|
|
486
|
+
log(`bulk-in-chat BLOCKED ${toolName}: ${state.targetedContacts.size} unique contacts already targeted (max ${MAX_CONTACTS_IN_CHAT})`);
|
|
487
|
+
return {
|
|
488
|
+
blocked: true,
|
|
489
|
+
message: `BLOCKED — ${toolName} not executed. ${MAX_CONTACTS_IN_CHAT} contacts processed. Stop all tool calls. Propose campaign for remaining contacts. Do NOT say remaining were processed.`,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Track after check passes — only contacts that actually get processed
|
|
483
494
|
state.targetedContacts.add(contactId);
|
|
484
495
|
log(`bulk-in-chat: ${toolName} target=${contactId} unique=${state.targetedContacts.size}/${MAX_CONTACTS_IN_CHAT}`);
|
|
485
|
-
|
|
486
|
-
// Allow up to MAX_CONTACTS_IN_CHAT unique contacts
|
|
487
|
-
if (state.targetedContacts.size <= MAX_CONTACTS_IN_CHAT) return null;
|
|
488
|
-
|
|
489
|
-
log(`bulk-in-chat BLOCKED ${toolName}: ${state.targetedContacts.size} unique contacts targeted (max ${MAX_CONTACTS_IN_CHAT})`);
|
|
490
|
-
const remaining = state.targetedContacts.size - MAX_CONTACTS_IN_CHAT;
|
|
491
|
-
return {
|
|
492
|
-
blocked: true,
|
|
493
|
-
message: `BLOCKED — ${toolName} not executed. ${MAX_CONTACTS_IN_CHAT} contacts processed, ${remaining} remain. Stop all tool calls. Propose campaign for remaining contacts. Do NOT say remaining were processed.`,
|
|
494
|
-
};
|
|
496
|
+
return null;
|
|
495
497
|
}
|
|
496
498
|
|
|
497
499
|
const MAX_POSTS_PER_SESSION = 3;
|
|
498
500
|
|
|
499
501
|
function guardPostLimit(toolName: string, state: SessionState) {
|
|
500
502
|
if (toolName !== "bereach_publish_post") return null;
|
|
501
|
-
state.postsThisSession
|
|
502
|
-
if (state.postsThisSession > MAX_POSTS_PER_SESSION) {
|
|
503
|
+
if (state.postsThisSession >= MAX_POSTS_PER_SESSION) {
|
|
503
504
|
log(`post limit BLOCKED: ${state.postsThisSession}/${MAX_POSTS_PER_SESSION} this session`);
|
|
504
505
|
return {
|
|
505
506
|
blocked: true,
|
|
506
507
|
message: `BLOCKED: Post limit reached (${MAX_POSTS_PER_SESSION} posts per session). Publishing too many posts in a short time can trigger LinkedIn restrictions.`,
|
|
507
508
|
};
|
|
508
509
|
}
|
|
510
|
+
// Increment AFTER check passes — blocked calls shouldn't inflate the counter
|
|
511
|
+
state.postsThisSession++;
|
|
509
512
|
return null;
|
|
510
513
|
}
|
|
511
514
|
|
|
@@ -558,6 +561,11 @@ export function registerEnforcementHook(
|
|
|
558
561
|
// Per-session DM guard mutex (scoped to this hook registration's closure)
|
|
559
562
|
const guardDmPacingAndDedup = createDmGuard();
|
|
560
563
|
|
|
564
|
+
// Scrape serialization lock — when OpenClaw batches parallel tool calls,
|
|
565
|
+
// each before_tool_call fires concurrently. Without serialization, all scrape
|
|
566
|
+
// calls hit the API simultaneously and trip the minInterval rate limit (B13 fix).
|
|
567
|
+
let scrapeLock: Promise<void> = Promise.resolve();
|
|
568
|
+
|
|
561
569
|
// Reset per-turn counters at the start of each agent turn.
|
|
562
570
|
api.on("before_prompt_build", () => {
|
|
563
571
|
state.toolCallCount = 0;
|
|
@@ -648,8 +656,12 @@ export function registerEnforcementHook(
|
|
|
648
656
|
if (toolName === "bereach_bulk_visit_profiles") {
|
|
649
657
|
log(`pacing: SKIP for ${toolName} (server-side via Workflow)`);
|
|
650
658
|
} else if (isRead) {
|
|
651
|
-
|
|
652
|
-
|
|
659
|
+
// Serialize scrape-type reads through a promise chain so parallel batched
|
|
660
|
+
// calls don't all hit the API simultaneously (B13 fix)
|
|
661
|
+
const prev = scrapeLock;
|
|
662
|
+
scrapeLock = prev.then(() => randomDelay(readMin, readMax));
|
|
663
|
+
log(`pacing: read delay ${readMin}-${readMax}s for ${toolName} (serialized)`);
|
|
664
|
+
await scrapeLock;
|
|
653
665
|
} else if (isWrite || PACED_FREE_TOOLS.has(toolName)) {
|
|
654
666
|
log(`pacing: write delay ${writeMin}-${writeMax}s for ${toolName}`);
|
|
655
667
|
await randomDelay(writeMin, writeMax);
|