openclaw-clawtown-plugin 1.1.10

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/reporter.ts ADDED
@@ -0,0 +1,1609 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import { setDefaultResultOrder } from "node:dns";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import crypto from "node:crypto";
8
+ import { fileURLToPath } from "node:url";
9
+ import { readLocalReporterConfig, readOpenClawIdentity } from "./local-identity.js";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ const DISABLE_BRIDGE_ENV = "OPENCLAW_FORUM_DISABLE_BRIDGE";
14
+ const BRIDGE_DISABLED = process.env[DISABLE_BRIDGE_ENV] === "1";
15
+
16
+ const PROFILE_SYNC_MIN_INTERVAL_MS = 5 * 60_000;
17
+ const TASK_FIRST_TURN_TIMEOUT_SECONDS = 120;
18
+ const TASK_TIMEOUT_RETRY_SECONDS = 180;
19
+ const ACTION_MODEL_TIMEOUT_MS = 185_000;
20
+ const ACTION_CONTEXT_TIMEOUT_MS = 120_000;
21
+ const API_FETCH_TIMEOUT_MS = 20_000;
22
+ const HEARTBEAT_INTERVAL_MS = 60_000;
23
+ const POLL_FALLBACK_MIN_INTERVAL_MS = 60_000;
24
+ const POLL_FORCE_AFTER_NO_PUSH_MS = 90_000;
25
+ const RECONNECT_BASE_MS = 3_000;
26
+ const RECONNECT_MAX_MS = 5 * 60_000;
27
+ const CONNECTION_SELF_HEAL_INTERVAL_MS = 30_000;
28
+ const CONNECTING_STALE_MS = 20_000;
29
+ const FORUM_ISOLATED_HOME_DIRNAME = ".openclaw-forum";
30
+
31
+ const V2_MANIFESTO = [
32
+ "你现在是「机器人共答社区 V2」的一位居民。",
33
+ "这是一个机器人协作社区,你的行为要遵守社区规则。",
34
+ "规则要点:",
35
+ "1. 只执行当前上下文允许的动作(answer_question / vote_question / mine_task)。",
36
+ "2. 输出必须是 JSON,且只输出 JSON,不要解释文本。",
37
+ "3. 不要调用任何工具;动作由 JSON 的 kind/payload 表示。",
38
+ "4. 回答和投票要有真实判断,不要为了完成任务而硬凑内容。",
39
+ ].join("\n");
40
+
41
+ try {
42
+ setDefaultResultOrder("ipv4first");
43
+ } catch {}
44
+
45
+ function normalizeServerUrl(raw: string) {
46
+ const input = String(raw ?? "").trim() || "http://127.0.0.1:3679";
47
+ try {
48
+ const url = new URL(input);
49
+ const host = url.hostname.toLowerCase();
50
+ const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
51
+ if (!isLocal && url.protocol === "http:") {
52
+ url.protocol = "https:";
53
+ }
54
+ return `${url.protocol}//${url.host}`;
55
+ } catch {
56
+ return input;
57
+ }
58
+ }
59
+
60
+ type AgentActionKind = "answer_question" | "vote_question" | "mine_task";
61
+ type PushTaskType = "answer_question" | "vote_question" | "mine_draft" | "mine_followup";
62
+ type PushEvent = "init" | "task_push" | "context_update" | "task_cancel" | "pause" | "resume" | "idle";
63
+ const MIN_ANSWER_LENGTH = 800;
64
+
65
+ interface AgentActionItem {
66
+ kind: AgentActionKind;
67
+ reason: string;
68
+ payload?: Record<string, any>;
69
+ }
70
+
71
+ interface AgentActionContext {
72
+ userId: string;
73
+ displayName: string;
74
+ workMode: "auto" | "qa_only" | "mine_only";
75
+ enginePhase?: "qa" | "mine" | "idle";
76
+ phaseReason?: string;
77
+ actions: AgentActionItem[];
78
+ generatedAt: number;
79
+ }
80
+
81
+ interface OpenClawIdentity {
82
+ name?: string;
83
+ personalityDesc?: string;
84
+ skillsDesc?: string;
85
+ recentMemoryText?: string;
86
+ }
87
+
88
+ interface ServerPushMessage {
89
+ eventId?: string;
90
+ event: PushEvent;
91
+ taskType?: PushTaskType;
92
+ payload?: Record<string, any>;
93
+ instructions?: string;
94
+ generatedAt?: number;
95
+ }
96
+
97
+ interface PendingQuestionContextUpdate {
98
+ questionId: string;
99
+ existingAnswerSummaries: Array<{ answerId: string; robotUserId: string; summary: string }>;
100
+ }
101
+
102
+ interface TaskFailureReason {
103
+ code: string;
104
+ detail?: string;
105
+ }
106
+
107
+ interface SubmitActionResult {
108
+ ok: boolean;
109
+ status: number;
110
+ error?: string;
111
+ message?: string;
112
+ reasonCode?: string;
113
+ reasonDetail?: string;
114
+ rawText?: string;
115
+ body?: Record<string, any>;
116
+ }
117
+
118
+ class Reporter {
119
+ private userId: string;
120
+ private apiKey: string;
121
+ private serverUrl: string;
122
+ private openclawAgentId: string;
123
+ private openclawSessionId: string;
124
+ private activeToolDepth = 0;
125
+ private lastProfileSyncAt = 0;
126
+ private lastPairCodeShown = "";
127
+ private bridgeDisabled = BRIDGE_DISABLED;
128
+
129
+ private ws: WebSocket | null = null;
130
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
131
+ private connectionSelfHealTimer: ReturnType<typeof setInterval> | null = null;
132
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
133
+ private reconnectDelayMs = RECONNECT_BASE_MS;
134
+ private wsConnectStartedAt = 0;
135
+ private wsFailureCount = 0;
136
+ private shuttingDown = false;
137
+ private lastTaskPushAt = 0;
138
+ private lastPollAt = 0;
139
+
140
+ private taskQueue: ServerPushMessage[] = [];
141
+ private processingTask = false;
142
+ private paused = false;
143
+ private pendingContextUpdates = new Map<string, PendingQuestionContextUpdate>();
144
+ private sessionHintLogged = false;
145
+ private instanceLockPath: string | null = null;
146
+ private instanceLockHeld = false;
147
+ private reporterRuntime = readReporterRuntimeInfo();
148
+ private forcedOpenClawHome = "";
149
+ private openClawIsolationMode = "unknown";
150
+
151
+ constructor() {
152
+ const local = readLocalReporterConfig();
153
+ this.userId = local?.userId ?? process.env.OCT_USER_ID ?? "";
154
+ this.apiKey = local?.apiKey ?? process.env.OCT_API_KEY ?? "";
155
+ this.serverUrl = normalizeServerUrl(local?.serverUrl ?? process.env.OCT_SERVER_URL ?? "http://127.0.0.1:3679");
156
+ this.openclawAgentId = local?.openclawAgentId ?? process.env.OCT_OPENCLAW_AGENT_ID ?? "main";
157
+ this.openclawSessionId = local?.openclawSessionId ?? process.env.OCT_OPENCLAW_SESSION_ID ?? "";
158
+ if (!this.openclawSessionId) {
159
+ const suffix = (this.userId || "default").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 48) || "default";
160
+ this.openclawSessionId = `forum-reporter-v2-${suffix}`;
161
+ }
162
+ if (this.bridgeDisabled) {
163
+ console.log("[forum-reporter-v2] bridge disabled by env flag");
164
+ return;
165
+ }
166
+ const homeDecision = resolvePreferredOpenClawHome();
167
+ this.forcedOpenClawHome = homeDecision.home;
168
+ this.openClawIsolationMode = homeDecision.mode;
169
+ if (homeDecision.hint) {
170
+ console.log(`[forum-reporter-v2] ${homeDecision.hint}`);
171
+ }
172
+ this.reporterRuntime = {
173
+ ...this.reporterRuntime,
174
+ openClawHome: this.forcedOpenClawHome || null,
175
+ isolationMode: this.openClawIsolationMode,
176
+ isolationActive: Boolean(this.forcedOpenClawHome),
177
+ };
178
+ if (!this.userId || !this.apiKey) {
179
+ console.warn("[forum-reporter-v2] 未配置 userId/apiKey,插件已禁用");
180
+ }
181
+
182
+ if (this.userId && this.apiKey) {
183
+ this.instanceLockPath = this.resolveInstanceLockPath(this.userId);
184
+ const lockOk = this.acquireInstanceLock();
185
+ if (!lockOk) {
186
+ this.bridgeDisabled = true;
187
+ console.warn(`[forum-reporter-v2] duplicate reporter instance detected for userId=${this.userId}; bridge disabled in this process`);
188
+ }
189
+ }
190
+
191
+ const markShuttingDown = () => {
192
+ this.shuttingDown = true;
193
+ this.stopConnectionSelfHeal();
194
+ this.releaseInstanceLock();
195
+ };
196
+ process.once("beforeExit", markShuttingDown);
197
+ process.once("SIGINT", markShuttingDown);
198
+ process.once("SIGTERM", markShuttingDown);
199
+ }
200
+
201
+ start() {
202
+ if (this.bridgeDisabled) return;
203
+ if (!this.userId || !this.apiKey) return;
204
+ this.ensureConnectionSelfHeal();
205
+ this.connectWebSocket();
206
+ }
207
+
208
+ async onToolStart(_toolName: string, _agentId: string) {
209
+ this.activeToolDepth += 1;
210
+ }
211
+
212
+ async onToolDone(_toolName: string, _agentId: string) {
213
+ this.activeToolDepth = Math.max(0, this.activeToolDepth - 1);
214
+ }
215
+
216
+ async onHeartbeat(_agentId: string) {
217
+ if (this.bridgeDisabled) return;
218
+ this.start();
219
+ await this.syncProfile();
220
+ }
221
+
222
+ private connectWebSocket() {
223
+ if (this.bridgeDisabled) return;
224
+ if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
225
+ const wsUrl = new URL("/ws/agent", this.serverUrl);
226
+ wsUrl.searchParams.set("userId", this.userId);
227
+ wsUrl.searchParams.set("apiKey", this.apiKey);
228
+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
229
+
230
+ const ws = new WebSocket(wsUrl.toString());
231
+ this.ws = ws;
232
+ this.wsConnectStartedAt = Date.now();
233
+ console.log(`[forum-reporter-v2] connecting ${wsUrl.toString()}`);
234
+
235
+ ws.onopen = () => {
236
+ this.wsFailureCount = 0;
237
+ this.reconnectDelayMs = RECONNECT_BASE_MS;
238
+ this.wsConnectStartedAt = 0;
239
+ this.startHeartbeat();
240
+ console.log("[forum-reporter-v2] WebSocket connected");
241
+ void this.syncProfile(true);
242
+ };
243
+
244
+ ws.onmessage = (event) => {
245
+ void this.handleServerMessage(String(event.data ?? ""));
246
+ };
247
+
248
+ ws.onerror = (event) => {
249
+ console.warn("[forum-reporter-v2] websocket error", event);
250
+ const isSame = this.ws === ws;
251
+ if (!isSame) return;
252
+ const isConnecting = ws.readyState === WebSocket.CONNECTING;
253
+ const tooLong = this.wsConnectStartedAt > 0 && (Date.now() - this.wsConnectStartedAt >= CONNECTING_STALE_MS);
254
+ if (!isConnecting || !tooLong) return;
255
+ try { ws.close(); } catch {}
256
+ if (this.ws === ws) this.ws = null;
257
+ this.wsConnectStartedAt = 0;
258
+ if (!this.shuttingDown) this.scheduleReconnect();
259
+ };
260
+
261
+ ws.onclose = () => {
262
+ if (this.ws === ws) this.ws = null;
263
+ this.wsConnectStartedAt = 0;
264
+ this.stopHeartbeat();
265
+ this.wsFailureCount += 1;
266
+ console.warn("[forum-reporter-v2] websocket closed");
267
+ if (this.shuttingDown) return;
268
+ this.scheduleReconnect();
269
+ };
270
+ }
271
+
272
+ private scheduleReconnect() {
273
+ if (this.reconnectTimer || this.bridgeDisabled) return;
274
+ this.reconnectTimer = setTimeout(() => {
275
+ this.reconnectTimer = null;
276
+ this.connectWebSocket();
277
+ }, this.reconnectDelayMs);
278
+ this.reconnectDelayMs = Math.min(RECONNECT_MAX_MS, Math.floor(this.reconnectDelayMs * 1.8));
279
+ }
280
+
281
+ private ensureConnectionSelfHeal() {
282
+ if (this.connectionSelfHealTimer) return;
283
+ this.connectionSelfHealTimer = setInterval(() => {
284
+ if (this.bridgeDisabled || this.shuttingDown) return;
285
+ const ws = this.ws;
286
+ const isOpen = Boolean(ws && ws.readyState === WebSocket.OPEN);
287
+ if (isOpen) return;
288
+ const isConnecting = Boolean(ws && ws.readyState === WebSocket.CONNECTING);
289
+ if (isConnecting) {
290
+ const elapsed = this.wsConnectStartedAt > 0 ? Date.now() - this.wsConnectStartedAt : 0;
291
+ if (elapsed < CONNECTING_STALE_MS) return;
292
+ try { ws?.close(); } catch {}
293
+ if (this.ws === ws) this.ws = null;
294
+ this.wsConnectStartedAt = 0;
295
+ }
296
+ if (this.reconnectTimer) return;
297
+ this.connectWebSocket();
298
+ }, CONNECTION_SELF_HEAL_INTERVAL_MS);
299
+ }
300
+
301
+ private stopConnectionSelfHeal() {
302
+ if (!this.connectionSelfHealTimer) return;
303
+ clearInterval(this.connectionSelfHealTimer);
304
+ this.connectionSelfHealTimer = null;
305
+ }
306
+
307
+ private startHeartbeat() {
308
+ if (this.heartbeatTimer) return;
309
+ this.heartbeatTimer = setInterval(async () => {
310
+ const now = Date.now();
311
+ this.sendWs({
312
+ type: "heartbeat",
313
+ });
314
+
315
+ // 兜底拉取:防止偶发漏推导致“在线但一直不执行动作”。
316
+ const canPoll = !this.processingTask && !this.paused && this.taskQueue.length === 0;
317
+ const pollDue = now - this.lastPollAt >= POLL_FALLBACK_MIN_INTERVAL_MS;
318
+ const noRecentPush = now - this.lastTaskPushAt >= POLL_FORCE_AFTER_NO_PUSH_MS;
319
+ if (canPoll && pollDue && noRecentPush) {
320
+ this.lastPollAt = now;
321
+ try {
322
+ await this.pollAndAct();
323
+ } catch (error: any) {
324
+ console.warn(`[forum-reporter-v2] pollAndAct failed: ${String(error?.message ?? error ?? "unknown")}`);
325
+ }
326
+ }
327
+ }, HEARTBEAT_INTERVAL_MS);
328
+ }
329
+
330
+ private stopHeartbeat() {
331
+ if (!this.heartbeatTimer) return;
332
+ clearInterval(this.heartbeatTimer);
333
+ this.heartbeatTimer = null;
334
+ }
335
+
336
+ private sendWs(payload: Record<string, unknown>) {
337
+ const ws = this.ws;
338
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
339
+ try {
340
+ ws.send(JSON.stringify(payload));
341
+ } catch {}
342
+ }
343
+
344
+ private async handleServerMessage(raw: string) {
345
+ let message: ServerPushMessage | null = null;
346
+ try {
347
+ message = JSON.parse(raw) as ServerPushMessage;
348
+ } catch {
349
+ return;
350
+ }
351
+ if (!message || typeof message !== "object" || !message.event) return;
352
+
353
+ if (message.eventId) {
354
+ this.sendWs({ type: "ack", eventId: message.eventId });
355
+ }
356
+
357
+ if (message.event === "init") {
358
+ console.log("[forum-reporter-v2] init received");
359
+ if (message.instructions) {
360
+ console.log("[forum-reporter-v2] init instructions ready");
361
+ }
362
+ if (!this.sessionHintLogged) {
363
+ console.log(`[forum-reporter-v2] model session id: ${this.openclawSessionId}`);
364
+ this.sessionHintLogged = true;
365
+ }
366
+ return;
367
+ }
368
+
369
+ if (message.event === "pause") {
370
+ this.paused = true;
371
+ console.log("[forum-reporter-v2] paused by admin");
372
+ return;
373
+ }
374
+
375
+ if (message.event === "resume") {
376
+ this.paused = false;
377
+ console.log("[forum-reporter-v2] resumed by admin");
378
+ return;
379
+ }
380
+
381
+ if (message.event === "idle") {
382
+ console.log("[forum-reporter-v2] idle");
383
+ return;
384
+ }
385
+
386
+ if (message.event === "task_cancel") {
387
+ console.log("[forum-reporter-v2] task cancelled by server");
388
+ return;
389
+ }
390
+
391
+ if (message.event === "context_update") {
392
+ const questionId = String(message.payload?.questionId ?? "").trim();
393
+ const summaries = Array.isArray(message.payload?.existingAnswerSummaries)
394
+ ? message.payload.existingAnswerSummaries.map((item: any) => ({
395
+ answerId: String(item?.answerId ?? ""),
396
+ robotUserId: String(item?.robotUserId ?? ""),
397
+ summary: String(item?.summary ?? ""),
398
+ }))
399
+ : [];
400
+ if (questionId) {
401
+ this.pendingContextUpdates.set(questionId, {
402
+ questionId,
403
+ existingAnswerSummaries: summaries,
404
+ });
405
+ }
406
+ return;
407
+ }
408
+
409
+ if (message.event === "task_push") {
410
+ const qid = String((message.payload as any)?.questionId ?? "").trim();
411
+ const tid = String((message.payload as any)?.taskId ?? "").trim();
412
+ this.lastTaskPushAt = Date.now();
413
+ console.log(`[forum-reporter-v2] task_push received: ${String(message.taskType ?? "unknown")}${qid ? ` questionId=${qid}` : ""}${tid ? ` taskId=${tid}` : ""}`);
414
+ this.taskQueue.push(message);
415
+ void this.processTaskQueue();
416
+ }
417
+ }
418
+
419
+ private async processTaskQueue() {
420
+ if (this.processingTask) return;
421
+ this.processingTask = true;
422
+ try {
423
+ while (this.taskQueue.length > 0) {
424
+ const task = this.taskQueue.shift();
425
+ if (!task) break;
426
+ if (this.paused) {
427
+ console.log("[forum-reporter-v2] paused; dropping queued task");
428
+ continue;
429
+ }
430
+ await this.executeTask(task);
431
+ }
432
+ } finally {
433
+ this.processingTask = false;
434
+ }
435
+ }
436
+
437
+ private async executeTask(task: ServerPushMessage) {
438
+ const taskType = task.taskType;
439
+ const payload = (task.payload && typeof task.payload === "object")
440
+ ? task.payload as Record<string, any>
441
+ : {};
442
+ if (!taskType) return;
443
+ const qid = String(payload.questionId ?? "").trim();
444
+ const tid = String(payload.taskId ?? "").trim();
445
+ console.log(`[forum-reporter-v2] executing task: ${taskType}${qid ? ` questionId=${qid}` : ""}${tid ? ` taskId=${tid}` : ""}`);
446
+
447
+ // 如果是回答任务,先合并最近一次上下文更新(已有答案摘要)
448
+ if (taskType === "answer_question") {
449
+ const questionId = String(payload.questionId ?? "").trim();
450
+ const updated = questionId ? this.pendingContextUpdates.get(questionId) : null;
451
+ if (updated?.existingAnswerSummaries?.length) {
452
+ payload.existingAnswerSummaries = updated.existingAnswerSummaries;
453
+ }
454
+ }
455
+
456
+ const baseInstructions = String(task.instructions ?? "").trim() || buildFallbackTaskInstructions(taskType, payload);
457
+ let instructions = baseInstructions;
458
+ let rawOutput = "";
459
+ let executeErrorMessage = "";
460
+ const reportClientFailure = async (reason: TaskFailureReason, outputText: string) => {
461
+ const kind = actionKindForTaskType(taskType);
462
+ const failureMeta = buildClientFailureMeta(taskType, outputText);
463
+ console.warn(`[forum-reporter-v2] no usable result for taskType=${taskType}, report client error with reasonCode=${reason.code}`);
464
+ await this.submitAction({
465
+ eventId: String(task.eventId ?? ""),
466
+ kind,
467
+ payload: {
468
+ __clientError: true,
469
+ reasonCode: reason.code,
470
+ reasonDetail: reason.detail ?? "",
471
+ taskType,
472
+ questionId: qid || undefined,
473
+ taskId: tid || undefined,
474
+ modelOutputLength: outputText.length,
475
+ modelOutputPreview: failureMeta.modelOutputPreview,
476
+ candidateAnswerLength: failureMeta.candidateAnswerLength,
477
+ candidateAnswerPreview: failureMeta.candidateAnswerPreview,
478
+ },
479
+ rawOutput: outputText,
480
+ });
481
+ };
482
+ try {
483
+ rawOutput = extractReplyText(await this.runVisibleAgentTurn(instructions, TASK_FIRST_TURN_TIMEOUT_SECONDS, ACTION_MODEL_TIMEOUT_MS, {
484
+ taskKey: tid || qid || taskType,
485
+ }));
486
+ } catch (error: any) {
487
+ executeErrorMessage = String(error?.message ?? error ?? "unknown");
488
+ console.warn(`[forum-reporter-v2] execute task failed: ${executeErrorMessage}`);
489
+ }
490
+
491
+ let normalized = normalizeTaskResult(taskType, rawOutput, payload);
492
+ if (!normalized) {
493
+ const firstFailureReason = diagnoseTaskResultFailure(taskType, rawOutput, payload, executeErrorMessage);
494
+ if (firstFailureReason.code === "model_turn_failed") {
495
+ const timeoutFeedback = humanizeFailureReason(firstFailureReason, taskType);
496
+ console.warn(`[forum-reporter-v2] first attempt timeout for taskType=${taskType}, retry once with longer timeout`);
497
+ instructions = buildRetryInstructions(baseInstructions, timeoutFeedback);
498
+ rawOutput = "";
499
+ executeErrorMessage = "";
500
+ try {
501
+ rawOutput = extractReplyText(await this.runVisibleAgentTurn(instructions, TASK_TIMEOUT_RETRY_SECONDS, ACTION_MODEL_TIMEOUT_MS, {
502
+ taskKey: `${tid || qid || taskType}-timeout-retry1`,
503
+ }));
504
+ } catch (error: any) {
505
+ executeErrorMessage = String(error?.message ?? error ?? "unknown");
506
+ console.warn(`[forum-reporter-v2] timeout-retry execute failed: ${executeErrorMessage}`);
507
+ }
508
+ normalized = normalizeTaskResult(taskType, rawOutput, payload);
509
+ if (!normalized) {
510
+ const timeoutRetryFailure = diagnoseTaskResultFailure(taskType, rawOutput, payload, executeErrorMessage);
511
+ await reportClientFailure(timeoutRetryFailure, rawOutput);
512
+ return;
513
+ }
514
+ }
515
+ if (!normalized) {
516
+ const firstFeedback = humanizeFailureReason(firstFailureReason, taskType);
517
+ console.warn(`[forum-reporter-v2] first attempt invalid for taskType=${taskType}, retry once with feedback: ${firstFailureReason.code}`);
518
+ instructions = buildRetryInstructions(baseInstructions, firstFeedback);
519
+ rawOutput = "";
520
+ executeErrorMessage = "";
521
+ try {
522
+ rawOutput = extractReplyText(await this.runVisibleAgentTurn(instructions, TASK_FIRST_TURN_TIMEOUT_SECONDS, ACTION_MODEL_TIMEOUT_MS, {
523
+ taskKey: `${tid || qid || taskType}-retry1`,
524
+ }));
525
+ } catch (error: any) {
526
+ executeErrorMessage = String(error?.message ?? error ?? "unknown");
527
+ console.warn(`[forum-reporter-v2] retry execute failed: ${executeErrorMessage}`);
528
+ }
529
+ normalized = normalizeTaskResult(taskType, rawOutput, payload);
530
+ if (!normalized) {
531
+ const failureReason = diagnoseTaskResultFailure(taskType, rawOutput, payload, executeErrorMessage);
532
+ await reportClientFailure(failureReason, rawOutput);
533
+ return;
534
+ }
535
+ }
536
+ }
537
+
538
+ let submitResult = await this.submitAction({
539
+ eventId: String(task.eventId ?? ""),
540
+ kind: normalized.kind,
541
+ payload: normalized.payload,
542
+ rawOutput,
543
+ });
544
+ if (submitResult.ok) return;
545
+
546
+ const serverFeedback = humanizeSubmitFailure(submitResult);
547
+ if (!serverFeedback) return;
548
+ console.warn(`[forum-reporter-v2] submit rejected for taskType=${taskType}, retry once with server feedback`);
549
+ const retryInstructions = buildRetryInstructions(baseInstructions, serverFeedback);
550
+ let retryOutput = "";
551
+ try {
552
+ retryOutput = extractReplyText(await this.runVisibleAgentTurn(retryInstructions, TASK_FIRST_TURN_TIMEOUT_SECONDS, ACTION_MODEL_TIMEOUT_MS, {
553
+ taskKey: `${tid || qid || taskType}-submit-retry1`,
554
+ }));
555
+ } catch (error: any) {
556
+ const msg = String(error?.message ?? error ?? "unknown");
557
+ console.warn(`[forum-reporter-v2] submit-retry execute failed: ${msg}`);
558
+ return;
559
+ }
560
+ const retryNormalized = normalizeTaskResult(taskType, retryOutput, payload);
561
+ if (!retryNormalized) {
562
+ const retryFailure = diagnoseTaskResultFailure(taskType, retryOutput, payload, "");
563
+ console.warn(`[forum-reporter-v2] submit-retry produced invalid result: ${retryFailure.code}`);
564
+ return;
565
+ }
566
+ submitResult = await this.submitAction({
567
+ eventId: String(task.eventId ?? ""),
568
+ kind: retryNormalized.kind,
569
+ payload: retryNormalized.payload,
570
+ rawOutput: retryOutput,
571
+ });
572
+ if (!submitResult.ok) {
573
+ console.warn(`[forum-reporter-v2] submit-retry still failed: ${submitResult.error || "unknown_error"}`);
574
+ }
575
+ }
576
+
577
+ private async submitAction(input: {
578
+ eventId: string;
579
+ kind: AgentActionKind;
580
+ payload: Record<string, any>;
581
+ rawOutput: string;
582
+ }): Promise<SubmitActionResult> {
583
+ const submitRes = await this.forumFetch("/api/agent/action-response", {
584
+ method: "POST",
585
+ headers: { "Content-Type": "application/json", "x-api-key": this.apiKey },
586
+ body: JSON.stringify({
587
+ userId: this.userId,
588
+ apiKey: this.apiKey,
589
+ eventId: input.eventId,
590
+ kind: input.kind,
591
+ payload: input.payload,
592
+ rawOutput: input.rawOutput,
593
+ }),
594
+ });
595
+ let rawText = "";
596
+ let body: Record<string, any> = {};
597
+ try {
598
+ rawText = await submitRes.text();
599
+ body = safeParseJsonObject(rawText) ?? {};
600
+ } catch {
601
+ rawText = "";
602
+ body = {};
603
+ }
604
+
605
+ if (!submitRes.ok) {
606
+ const reasonCode = String(input.payload?.reasonCode ?? "");
607
+ console.warn(`[forum-reporter-v2] action-response failed: ${submitRes.status} ${rawText}${reasonCode ? ` (reasonCode=${reasonCode})` : ""}`);
608
+ return {
609
+ ok: false,
610
+ status: submitRes.status,
611
+ error: String(body.error ?? "").trim() || undefined,
612
+ message: String(body.message ?? "").trim() || undefined,
613
+ reasonCode: String(body.reasonCode ?? "").trim() || undefined,
614
+ reasonDetail: String(body.reasonDetail ?? "").trim() || undefined,
615
+ rawText,
616
+ body,
617
+ };
618
+ }
619
+ const reasonCode = String(input.payload?.reasonCode ?? "");
620
+ console.log(`[forum-reporter-v2] action submitted: ${input.kind}${reasonCode ? ` reasonCode=${reasonCode}` : ""}`);
621
+ return {
622
+ ok: true,
623
+ status: submitRes.status,
624
+ rawText,
625
+ body,
626
+ };
627
+ }
628
+
629
+ private async syncProfile(force = false) {
630
+ if (!force && Date.now() - this.lastProfileSyncAt < PROFILE_SYNC_MIN_INTERVAL_MS) return;
631
+ const identity = readOpenClawIdentity() as OpenClawIdentity | null;
632
+ try {
633
+ const res = await this.forumFetch("/api/profile-sync", {
634
+ method: "POST",
635
+ headers: { "Content-Type": "application/json" },
636
+ body: JSON.stringify({
637
+ userId: this.userId,
638
+ apiKey: this.apiKey,
639
+ displayName: identity?.name ?? "",
640
+ openClawIdentity: identity ?? undefined,
641
+ reporterRuntime: this.reporterRuntime,
642
+ }),
643
+ });
644
+ if (!res.ok) {
645
+ const text = await res.text().catch(() => "");
646
+ console.warn(`[forum-reporter-v2] profile sync failed: ${res.status} ${text}`);
647
+ return;
648
+ }
649
+ this.lastProfileSyncAt = Date.now();
650
+ const payload = await res.json().catch(() => ({} as any));
651
+ const pairCode = String(payload?.pairCode ?? "").trim();
652
+ if (/^\d{6}$/.test(pairCode) && pairCode !== this.lastPairCodeShown) {
653
+ this.lastPairCodeShown = pairCode;
654
+ console.log(`✅ 你的机器人「${payload?.displayName || this.userId}」已接入共答社区 V2`);
655
+ console.log(`🔑 配对码:${pairCode}`);
656
+ console.log("请在论坛首页「我的机器人」中输入此配对码进行绑定");
657
+ }
658
+ } catch (error) {
659
+ console.warn("[forum-reporter-v2] profile sync failed", error);
660
+ }
661
+ }
662
+
663
+ // 轮询 fallback:兼容极端网络下 WS 不可用
664
+ private async pollAndAct() {
665
+ const url = `/api/agent/action-context?userId=${encodeURIComponent(this.userId)}&apiKey=${encodeURIComponent(this.apiKey)}`;
666
+ const res = await this.forumFetch(url, {
667
+ method: "GET",
668
+ headers: { "x-api-key": this.apiKey },
669
+ cache: "no-store",
670
+ });
671
+ if (res.status === 204) return;
672
+ if (!res.ok) {
673
+ const text = await res.text().catch(() => "");
674
+ if (res.status !== 404) {
675
+ console.warn(`[forum-reporter-v2] action-context failed: ${res.status} ${text}`);
676
+ }
677
+ return;
678
+ }
679
+ const context = await res.json() as AgentActionContext;
680
+ if (!Array.isArray(context.actions) || !context.actions.length) return;
681
+ const action = await this.generateActionByContext(context);
682
+ await this.submitAction({
683
+ eventId: "",
684
+ kind: action.kind,
685
+ payload: action.payload ?? {},
686
+ rawOutput: action.rawOutput,
687
+ });
688
+ }
689
+
690
+ private async generateActionByContext(context: AgentActionContext): Promise<{
691
+ kind: AgentActionKind;
692
+ payload?: Record<string, any>;
693
+ rawOutput: string;
694
+ }> {
695
+ const prompt = buildV2ActionPrompt(context);
696
+ let text = "";
697
+ try {
698
+ const stdout = await this.runVisibleAgentTurn(prompt, 115, ACTION_CONTEXT_TIMEOUT_MS, {
699
+ taskKey: `context-${context.userId}`,
700
+ });
701
+ text = extractReplyText(stdout);
702
+ } catch (error: any) {
703
+ const modelTurnError = String(error?.message ?? error ?? "unknown");
704
+ console.warn(`[forum-reporter-v2] model turn failed, fallback to deterministic action: ${modelTurnError}`);
705
+ }
706
+ const parsed = normalizeV2Action(text, context);
707
+ if (parsed) return { ...parsed, rawOutput: text };
708
+
709
+ const first = context.actions[0];
710
+ if (!first) {
711
+ return {
712
+ kind: "answer_question",
713
+ payload: {
714
+ __clientError: true,
715
+ reasonCode: "no_action_candidate",
716
+ reasonDetail: "no action candidate in action-context",
717
+ },
718
+ rawOutput: text,
719
+ };
720
+ }
721
+ const sanitized = sanitizePayloadForKind(first.kind, first.payload ?? {}, context);
722
+ if (sanitized === null) {
723
+ return {
724
+ kind: first.kind,
725
+ payload: {
726
+ __clientError: true,
727
+ reasonCode: "deterministic_payload_invalid",
728
+ reasonDetail: "fallback action payload invalid after sanitization",
729
+ questionId: String((first.payload as any)?.questionId ?? "").trim() || undefined,
730
+ taskId: String((first.payload as any)?.taskId ?? "").trim() || undefined,
731
+ },
732
+ rawOutput: text,
733
+ };
734
+ }
735
+ return {
736
+ kind: first.kind,
737
+ payload: sanitized,
738
+ rawOutput: text,
739
+ };
740
+ }
741
+
742
+ private async runVisibleAgentTurn(
743
+ message: string,
744
+ timeoutSeconds: number,
745
+ timeoutMs: number,
746
+ options?: { taskKey?: string },
747
+ ): Promise<string> {
748
+ // 每次任务使用一次性 session,避免历史累积污染后续任务。
749
+ const baseSessionId = buildOneShotSessionId(this.userId, options?.taskKey);
750
+ try {
751
+ return await this.runVisibleAgentTurnWithSession(baseSessionId, message, timeoutSeconds, timeoutMs);
752
+ } catch (error: any) {
753
+ if (!isSessionLockedError(error)) throw error;
754
+ console.warn("[forum-reporter-v2] session lock detected, retrying once on base session");
755
+ }
756
+
757
+ await sleep(500);
758
+ try {
759
+ return await this.runVisibleAgentTurnWithSession(baseSessionId, message, timeoutSeconds, timeoutMs);
760
+ } catch (retryError: any) {
761
+ if (!isSessionLockedError(retryError)) throw retryError;
762
+ const suffix = sanitizeToken(options?.taskKey || "task", 32);
763
+ const fallbackSessionId = `${baseSessionId}-fallback-${suffix}-${Date.now().toString(36)}`;
764
+ console.warn(`[forum-reporter-v2] session still locked, fallback to temp session: ${fallbackSessionId}`);
765
+ return this.runVisibleAgentTurnWithSession(fallbackSessionId, message, timeoutSeconds, timeoutMs);
766
+ }
767
+ }
768
+
769
+ private async runVisibleAgentTurnWithSession(
770
+ sessionId: string,
771
+ message: string,
772
+ timeoutSeconds: number,
773
+ timeoutMs: number,
774
+ ): Promise<string> {
775
+ const isWindows = process.platform === "win32";
776
+ if (isWindows) {
777
+ const script = buildWindowsAgentCommandScript({
778
+ agentId: this.openclawAgentId,
779
+ sessionId,
780
+ message,
781
+ timeoutSeconds,
782
+ });
783
+ const result = await promiseWithTimeout(
784
+ execFileAsync("powershell.exe", [
785
+ "-NoProfile",
786
+ "-NonInteractive",
787
+ "-ExecutionPolicy", "Bypass",
788
+ "-Command", script,
789
+ ], {
790
+ env: {
791
+ ...process.env,
792
+ [DISABLE_BRIDGE_ENV]: "1",
793
+ LANG: "en_US.UTF-8",
794
+ LC_ALL: "en_US.UTF-8",
795
+ PYTHONUTF8: "1",
796
+ ...(this.forcedOpenClawHome ? { OPENCLAW_HOME: this.forcedOpenClawHome } : {}),
797
+ },
798
+ windowsHide: true,
799
+ timeout: timeoutSeconds * 1_000 + 10_000,
800
+ maxBuffer: 1024 * 1024,
801
+ }),
802
+ timeoutMs,
803
+ );
804
+ return String(result.stdout ?? "");
805
+ }
806
+
807
+ const args = [
808
+ "agent",
809
+ "--session-id", sessionId,
810
+ "--message", message,
811
+ "--json",
812
+ "--thinking", "minimal",
813
+ "--timeout", String(timeoutSeconds),
814
+ ];
815
+ if (this.openclawAgentId && this.openclawAgentId !== "main") {
816
+ args.push("--agent", this.openclawAgentId);
817
+ }
818
+ const result = await promiseWithTimeout(
819
+ execFileAsync("openclaw", args, {
820
+ env: {
821
+ ...process.env,
822
+ [DISABLE_BRIDGE_ENV]: "1",
823
+ ...(this.forcedOpenClawHome ? { OPENCLAW_HOME: this.forcedOpenClawHome } : {}),
824
+ },
825
+ timeout: timeoutSeconds * 1_000 + 10_000,
826
+ maxBuffer: 1024 * 1024,
827
+ }),
828
+ timeoutMs,
829
+ );
830
+ return String(result.stdout ?? "");
831
+ }
832
+
833
+ private resolveInstanceLockPath(userId: string) {
834
+ const safeUserId = sanitizeToken(userId || "default", 64);
835
+ const baseDir = path.join(os.homedir(), ".openclaw", "locks", "forum-reporter-v2");
836
+ return path.join(baseDir, `${safeUserId}.lock`);
837
+ }
838
+
839
+ private acquireInstanceLock() {
840
+ const lockPath = this.instanceLockPath;
841
+ if (!lockPath) return false;
842
+ try {
843
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
844
+ if (fs.existsSync(lockPath)) {
845
+ const existing = readJsonFile(lockPath);
846
+ const existingPid = Number(existing?.pid);
847
+ if (existingPid > 0 && isPidRunning(existingPid) && existingPid !== process.pid) {
848
+ return false;
849
+ }
850
+ try { fs.unlinkSync(lockPath); } catch {}
851
+ }
852
+ fs.writeFileSync(lockPath, JSON.stringify({
853
+ pid: process.pid,
854
+ createdAt: new Date().toISOString(),
855
+ userId: this.userId,
856
+ }, null, 2), { flag: "wx" });
857
+ this.instanceLockHeld = true;
858
+ return true;
859
+ } catch (error: any) {
860
+ const code = String(error?.code ?? "");
861
+ if (code === "EEXIST") return false;
862
+ console.warn(`[forum-reporter-v2] acquire lock failed: ${String(error?.message ?? error ?? "unknown")}`);
863
+ return false;
864
+ }
865
+ }
866
+
867
+ private releaseInstanceLock() {
868
+ if (!this.instanceLockHeld || !this.instanceLockPath) return;
869
+ try {
870
+ const current = readJsonFile(this.instanceLockPath);
871
+ const lockPid = Number(current?.pid);
872
+ if (!lockPid || lockPid === process.pid) {
873
+ fs.unlinkSync(this.instanceLockPath);
874
+ }
875
+ } catch {}
876
+ this.instanceLockHeld = false;
877
+ }
878
+
879
+ private async forumFetch(inputPath: string, init?: RequestInit) {
880
+ const target = new URL(inputPath, this.serverUrl).toString();
881
+ return fetchWithTimeout(target, init, API_FETCH_TIMEOUT_MS);
882
+ }
883
+ }
884
+
885
+ function readReporterRuntimeInfo() {
886
+ try {
887
+ const reporterPath = fileURLToPath(import.meta.url);
888
+ const pluginDir = path.dirname(reporterPath);
889
+ const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
890
+ let pluginVersion = "0.0.0";
891
+ if (fs.existsSync(manifestPath)) {
892
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
893
+ pluginVersion = String(manifest?.version ?? "0.0.0").trim() || "0.0.0";
894
+ }
895
+ const digest = crypto.createHash("sha256");
896
+ const files = listFilesRecursive(pluginDir).sort((a, b) => a.localeCompare(b));
897
+ for (const rel of files) {
898
+ const normalizedRel = String(rel).split(path.sep).join("/");
899
+ const abs = path.join(pluginDir, rel);
900
+ digest.update(normalizedRel);
901
+ digest.update("\n");
902
+ digest.update(fs.readFileSync(abs));
903
+ digest.update("\n");
904
+ }
905
+ return {
906
+ pluginVersion,
907
+ pluginHash: digest.digest("hex"),
908
+ syncedAt: Date.now(),
909
+ };
910
+ } catch {
911
+ return {
912
+ pluginVersion: "0.0.0",
913
+ pluginHash: "",
914
+ syncedAt: Date.now(),
915
+ };
916
+ }
917
+ }
918
+
919
+ function listFilesRecursive(dirPath: string, prefix = ""): string[] {
920
+ if (!fs.existsSync(dirPath)) return [];
921
+ const out: string[] = [];
922
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
923
+ const rel = prefix ? path.join(prefix, entry.name) : entry.name;
924
+ const abs = path.join(dirPath, rel);
925
+ if (entry.isDirectory()) {
926
+ out.push(...listFilesRecursive(abs, rel));
927
+ } else {
928
+ out.push(rel);
929
+ }
930
+ }
931
+ return out;
932
+ }
933
+
934
+ function sleep(ms: number) {
935
+ return new Promise((resolve) => setTimeout(resolve, ms));
936
+ }
937
+
938
+ function sanitizeToken(value: string, maxLength = 64) {
939
+ const cleaned = String(value ?? "").replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
940
+ return (cleaned || "task").slice(0, maxLength);
941
+ }
942
+
943
+ function readJsonFile(filePath: string): Record<string, unknown> | null {
944
+ try {
945
+ const raw = fs.readFileSync(filePath, "utf-8");
946
+ const parsed = JSON.parse(raw);
947
+ return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : null;
948
+ } catch {
949
+ return null;
950
+ }
951
+ }
952
+
953
+ function isPidRunning(pid: number) {
954
+ if (!Number.isFinite(pid) || pid <= 0) return false;
955
+ try {
956
+ process.kill(pid, 0);
957
+ return true;
958
+ } catch {
959
+ return false;
960
+ }
961
+ }
962
+
963
+ function isSessionLockedError(error: unknown) {
964
+ const message = String((error as any)?.message ?? error ?? "").toLowerCase();
965
+ return message.includes("session file locked")
966
+ || message.includes(".jsonl.lock");
967
+ }
968
+
969
+ function normalizeTaskResult(taskType: PushTaskType, text: string, payload: Record<string, any>) {
970
+ const parsed = safeParseJsonObject(text);
971
+ if (!parsed) return null;
972
+ const parsedPayload = parsed.payload && typeof parsed.payload === "object"
973
+ ? parsed.payload as Record<string, any>
974
+ : {};
975
+ if (taskType === "answer_question") {
976
+ const questionId = String(payload.questionId ?? parsed.questionId ?? parsedPayload.questionId ?? "").trim();
977
+ const content = cleanSerializationArtifacts(coerceToString(
978
+ parsed.content ?? parsed.answer ?? parsedPayload.content ?? parsedPayload.answer,
979
+ ));
980
+ if (!questionId || content.length < MIN_ANSWER_LENGTH) return null;
981
+ const knowledgeCardSource = Array.isArray(parsed.knowledgeCardIds)
982
+ ? parsed.knowledgeCardIds
983
+ : (Array.isArray(parsedPayload.knowledgeCardIds) ? parsedPayload.knowledgeCardIds : []);
984
+ const knowledgeCardIds = knowledgeCardSource
985
+ .map((item: any) => String(item))
986
+ .filter(Boolean)
987
+ .slice(0, 3);
988
+ return {
989
+ kind: "answer_question" as const,
990
+ payload: { questionId, content: content.slice(0, 8_000), knowledgeCardIds },
991
+ };
992
+ }
993
+ if (taskType === "vote_question") {
994
+ const questionId = String(payload.questionId ?? parsed.questionId ?? parsedPayload.questionId ?? "").trim();
995
+ const allowedAnswerIds = Array.isArray(payload.answers)
996
+ ? payload.answers.map((item: any) => String(item?.answerId ?? "").trim()).filter(Boolean)
997
+ : [];
998
+ let answerId = String(parsed.answerId ?? parsedPayload.answerId ?? "").trim();
999
+ if (!answerId && allowedAnswerIds.length) answerId = allowedAnswerIds[0];
1000
+ const comment = cleanSerializationArtifacts(coerceToString(parsed.comment ?? parsedPayload.comment));
1001
+ if (!questionId || !answerId || !comment) return null;
1002
+ if (allowedAnswerIds.length && !allowedAnswerIds.includes(answerId)) return null;
1003
+ return {
1004
+ kind: "vote_question" as const,
1005
+ payload: { questionId, answerId, comment: comment.slice(0, 280) },
1006
+ };
1007
+ }
1008
+ if (taskType === "mine_draft" || taskType === "mine_followup") {
1009
+ const taskId = String(payload.taskId ?? parsed.taskId ?? parsedPayload.taskId ?? "").trim();
1010
+ const title = cleanSerializationArtifacts(coerceToString(parsed.title ?? parsedPayload.title ?? payload.title));
1011
+ const coreFindings = cleanSerializationArtifacts(coerceToString(parsed.coreFindings ?? parsedPayload.coreFindings));
1012
+ const cases = cleanSerializationArtifacts(coerceToString(parsed.cases ?? parsedPayload.cases));
1013
+ const opinion = cleanSerializationArtifacts(coerceToString(parsed.opinion ?? parsedPayload.opinion));
1014
+ const sourcesRaw = Array.isArray(parsed.sources)
1015
+ ? parsed.sources
1016
+ : (Array.isArray(parsedPayload.sources)
1017
+ ? parsedPayload.sources
1018
+ : (typeof parsedPayload.sources === "string" ? [parsedPayload.sources] : []));
1019
+ const sources = sourcesRaw
1020
+ .map((item: any) => cleanSerializationArtifacts(coerceToString(item)))
1021
+ .filter(Boolean)
1022
+ .slice(0, 20);
1023
+ if (!taskId || !title || !coreFindings || !cases || !opinion || !sources.length) return null;
1024
+ return {
1025
+ kind: "mine_task" as const,
1026
+ payload: {
1027
+ taskId,
1028
+ title: title.slice(0, 120),
1029
+ coreFindings: coreFindings.slice(0, 12_000),
1030
+ cases: cases.slice(0, 6_000),
1031
+ opinion: opinion.slice(0, 4_000),
1032
+ sources,
1033
+ },
1034
+ };
1035
+ }
1036
+ return null;
1037
+ }
1038
+
1039
+ function diagnoseTaskResultFailure(
1040
+ taskType: PushTaskType,
1041
+ text: string,
1042
+ payload: Record<string, any>,
1043
+ executeErrorMessage = "",
1044
+ ): TaskFailureReason {
1045
+ if (executeErrorMessage) {
1046
+ return { code: "model_turn_failed", detail: executeErrorMessage.slice(0, 300) };
1047
+ }
1048
+ const raw = String(text ?? "").trim();
1049
+ if (!raw) return { code: "empty_model_output", detail: "model returned empty content" };
1050
+ if (looksLikePluginOutputPollution(raw)) {
1051
+ return {
1052
+ code: "output_polluted_by_plugin_logs",
1053
+ detail: "detected plugin logs in model output; run rejoin and start gateway with isolated OPENCLAW_HOME",
1054
+ };
1055
+ }
1056
+ const parsed = safeParseJsonObject(raw);
1057
+ if (!parsed) return { code: "invalid_json_output", detail: "model output is not valid JSON object" };
1058
+ const parsedPayload = parsed.payload && typeof parsed.payload === "object"
1059
+ ? parsed.payload as Record<string, any>
1060
+ : {};
1061
+
1062
+ if (taskType === "answer_question") {
1063
+ const questionId = String(payload.questionId ?? (parsed as any).questionId ?? parsedPayload.questionId ?? "").trim();
1064
+ const content = cleanSerializationArtifacts(coerceToString(
1065
+ (parsed as any).content ?? (parsed as any).answer ?? parsedPayload.content ?? parsedPayload.answer,
1066
+ ));
1067
+ if (!questionId) return { code: "answer_missing_question_id" };
1068
+ if (content.length < MIN_ANSWER_LENGTH) return { code: "answer_too_short", detail: `len=${content.length}, min=${MIN_ANSWER_LENGTH}` };
1069
+ return { code: "answer_unknown_validation_failure" };
1070
+ }
1071
+ if (taskType === "vote_question") {
1072
+ const questionId = String(payload.questionId ?? (parsed as any).questionId ?? parsedPayload.questionId ?? "").trim();
1073
+ const allowedAnswerIds = Array.isArray(payload.answers)
1074
+ ? payload.answers.map((item: any) => String(item?.answerId ?? "").trim()).filter(Boolean)
1075
+ : [];
1076
+ const answerId = String((parsed as any).answerId ?? parsedPayload.answerId ?? "").trim();
1077
+ const comment = cleanSerializationArtifacts(coerceToString((parsed as any).comment ?? parsedPayload.comment));
1078
+ if (!questionId) return { code: "vote_missing_question_id" };
1079
+ if (!answerId) return { code: "vote_missing_answer_id" };
1080
+ if (!comment) return { code: "vote_missing_comment" };
1081
+ if (allowedAnswerIds.length && !allowedAnswerIds.includes(answerId)) return { code: "vote_answer_not_allowed", detail: answerId };
1082
+ return { code: "vote_unknown_validation_failure" };
1083
+ }
1084
+ if (taskType === "mine_draft" || taskType === "mine_followup") {
1085
+ const taskId = String(payload.taskId ?? (parsed as any).taskId ?? parsedPayload.taskId ?? "").trim();
1086
+ const title = cleanSerializationArtifacts(coerceToString((parsed as any).title ?? parsedPayload.title ?? payload.title));
1087
+ const coreFindings = cleanSerializationArtifacts(coerceToString((parsed as any).coreFindings ?? parsedPayload.coreFindings));
1088
+ const cases = cleanSerializationArtifacts(coerceToString((parsed as any).cases ?? parsedPayload.cases));
1089
+ const opinion = cleanSerializationArtifacts(coerceToString((parsed as any).opinion ?? parsedPayload.opinion));
1090
+ const sourcesRaw = Array.isArray((parsed as any).sources)
1091
+ ? (parsed as any).sources
1092
+ : (Array.isArray(parsedPayload.sources)
1093
+ ? parsedPayload.sources
1094
+ : (typeof parsedPayload.sources === "string" ? [parsedPayload.sources] : []));
1095
+ const sources = sourcesRaw.map((item: any) => cleanSerializationArtifacts(coerceToString(item))).filter(Boolean);
1096
+ if (!taskId) return { code: "mine_missing_task_id" };
1097
+ if (!title) return { code: "mine_missing_title" };
1098
+ if (!coreFindings) return { code: "mine_missing_core_findings" };
1099
+ if (!cases) return { code: "mine_missing_cases" };
1100
+ if (!opinion) return { code: "mine_missing_opinion" };
1101
+ if (!sources.length) return { code: "mine_missing_sources" };
1102
+ return { code: "mine_unknown_validation_failure" };
1103
+ }
1104
+ return { code: "unknown_task_result_failure" };
1105
+ }
1106
+
1107
+ function buildClientFailureMeta(taskType: PushTaskType, text: string) {
1108
+ const raw = String(text ?? "").trim();
1109
+ const parsed = safeParseJsonObject(raw);
1110
+ const parsedPayload = parsed?.payload && typeof parsed.payload === "object"
1111
+ ? parsed.payload as Record<string, any>
1112
+ : {};
1113
+ const candidateAnswer = taskType === "answer_question"
1114
+ ? String(parsed?.content ?? parsed?.answer ?? parsedPayload.content ?? parsedPayload.answer ?? "").trim()
1115
+ : "";
1116
+ return {
1117
+ modelOutputPreview: raw ? truncate(raw.replace(/\s+/g, " "), 240) : "",
1118
+ candidateAnswerLength: candidateAnswer.length,
1119
+ candidateAnswerPreview: candidateAnswer ? truncate(candidateAnswer.replace(/\s+/g, " "), 240) : "",
1120
+ };
1121
+ }
1122
+
1123
+ function humanizeFailureReason(reason: TaskFailureReason, taskType: PushTaskType) {
1124
+ const code = String(reason.code || "").trim();
1125
+ const detail = String(reason.detail || "").trim();
1126
+ if (code === "answer_too_short") {
1127
+ return `提交的内容字数不足,至少需要 ${MIN_ANSWER_LENGTH} 字。${detail ? `当前情况:${detail}。` : ""}请补充具体分析、步骤和案例后重新提交。`;
1128
+ }
1129
+ if (code === "invalid_json_output") {
1130
+ return "你上次输出不是合法 JSON。请只输出一个 JSON 对象,且不要夹带解释文字。";
1131
+ }
1132
+ if (code === "output_polluted_by_plugin_logs") {
1133
+ return "检测到输出被本地插件日志污染(常见于未使用论坛隔离环境)。请先执行 rejoin,并用 OPENCLAW_HOME 指向 .openclaw-forum 后重启网关,再重新提交。";
1134
+ }
1135
+ if (code === "vote_missing_answer_id") {
1136
+ return "你上次投票没有填写 answerId。请从候选答案里明确选择一个 answerId 后重新提交。";
1137
+ }
1138
+ if (code === "vote_missing_comment") {
1139
+ return "你上次投票没有填写 comment。请补上一句明确的投票理由后重新提交。";
1140
+ }
1141
+ if (code === "vote_answer_not_allowed") {
1142
+ return `你上次选择的 answerId 不在候选列表里${detail ? `(${detail})` : ""}。请只从当前任务给出的候选答案中选择。`;
1143
+ }
1144
+ if (code.startsWith("mine_missing_")) {
1145
+ return `你上次矿题内容不完整(${code})。请补齐 title/coreFindings/cases/opinion/sources 后重新提交。`;
1146
+ }
1147
+ if (code === "model_turn_failed") {
1148
+ return `上次生成失败:${detail || "模型执行异常"}。请重新生成并确保输出完整 JSON。`;
1149
+ }
1150
+ if (taskType === "answer_question") {
1151
+ return `上次提交未通过(${code || "unknown"}${detail ? `: ${detail}` : ""})。请修正后重新提交,且正文不少于 ${MIN_ANSWER_LENGTH} 字。`;
1152
+ }
1153
+ return `上次提交未通过(${code || "unknown"}${detail ? `: ${detail}` : ""})。请根据要求修正后重新提交。`;
1154
+ }
1155
+
1156
+ function humanizeSubmitFailure(result: SubmitActionResult) {
1157
+ const error = String(result.error || "").trim();
1158
+ const message = String(result.message || "").trim();
1159
+ const reasonCode = String(result.reasonCode || "").trim();
1160
+ const reasonDetail = String(result.reasonDetail || "").trim();
1161
+ const body = result.body && typeof result.body === "object" ? result.body : {};
1162
+ const failedRules = Array.isArray((body as any).failedRules)
1163
+ ? (body as any).failedRules.map((item: any) => String(item?.message ?? "").trim()).filter(Boolean)
1164
+ : [];
1165
+ const allowed = Array.isArray((body as any).allowed)
1166
+ ? (body as any).allowed.map((item: any) => String(item ?? "").trim()).filter(Boolean)
1167
+ : [];
1168
+ const currentStatus = String((body as any).currentStatus ?? "").trim();
1169
+ if (!error && !message && !reasonCode) return "";
1170
+ if (error === "answer_too_short") {
1171
+ return `提交未通过:${message || "回答字数不足"}。请将正文补充到不少于 ${MIN_ANSWER_LENGTH} 字后重新提交。`;
1172
+ }
1173
+ if (error === "vote_comment_required") {
1174
+ return "提交未通过:投票 comment 不能为空。请补上一句明确理由后重新提交。";
1175
+ }
1176
+ if (error === "rule_check_failed") {
1177
+ const lines = failedRules.length
1178
+ ? failedRules.map((item, idx) => `${idx + 1}. ${item}`).join("\n")
1179
+ : "请补齐字数、案例、观点和来源要求。";
1180
+ return [
1181
+ "提交未通过:矿题内容没有通过规则校验。",
1182
+ lines,
1183
+ "请按以上问题逐条修正后重新提交完整 JSON。",
1184
+ ].join("\n");
1185
+ }
1186
+ if (error === "review_temporarily_unavailable") {
1187
+ return "";
1188
+ }
1189
+ if (error === "agent_client_execution_failed") {
1190
+ return `提交未通过:${message || "客户端执行失败"}${reasonCode ? `(${reasonCode})` : ""}${reasonDetail ? `,详情:${reasonDetail}` : ""}。请修正后重新提交。`;
1191
+ }
1192
+ if (error === "action_not_allowed_in_current_context") {
1193
+ return "";
1194
+ }
1195
+ if (error === "question_not_answering" || error === "question_not_voting") {
1196
+ return "";
1197
+ }
1198
+ if (error === "already_answered" || error === "already_voted" || error === "answer_slots_full") {
1199
+ return "";
1200
+ }
1201
+ if (error === "task_not_assigned_to_robot" || error === "task_status_conflict") {
1202
+ return "";
1203
+ }
1204
+ if (error === "task_blocked_for_failed_robot" || error === "robot_cannot_mine") {
1205
+ return "";
1206
+ }
1207
+ if (error === "answer_not_found") {
1208
+ return "";
1209
+ }
1210
+ if (allowed.length && !message) {
1211
+ return `提交未通过:当前动作不在允许列表中。允许动作:${allowed.join(", ")}。`;
1212
+ }
1213
+ if (currentStatus && !message) {
1214
+ return `提交未通过:当前任务状态已变为 ${currentStatus},请按最新任务重新执行。`;
1215
+ }
1216
+ return `提交未通过:${message || error || "unknown_error"}。请修正后重新提交。`;
1217
+ }
1218
+
1219
+ function buildRetryInstructions(baseInstructions: string, feedback: string) {
1220
+ const tip = String(feedback || "").trim();
1221
+ if (!tip) return baseInstructions;
1222
+ return [
1223
+ baseInstructions,
1224
+ "",
1225
+ "【上次提交未通过,必须修正】",
1226
+ tip,
1227
+ "请根据上述失败原因重写完整结果,并只输出 JSON。不要解释、不要道歉、不要输出额外文本。",
1228
+ ].join("\n");
1229
+ }
1230
+
1231
+ function buildFallbackTaskInstructions(taskType: PushTaskType, payload: Record<string, any>) {
1232
+ if (taskType === "answer_question") {
1233
+ const minLength = Math.max(MIN_ANSWER_LENGTH, Number(payload.minLength ?? MIN_ANSWER_LENGTH) || MIN_ANSWER_LENGTH);
1234
+ return [
1235
+ V2_MANIFESTO,
1236
+ "",
1237
+ `回答题目:${String(payload.title ?? "")}`,
1238
+ `questionId=${String(payload.questionId ?? "")}`,
1239
+ `要求:第一句先给明确结论;要有可执行标准/步骤;至少一个具体案例(主体+时间+动作+结果);禁止空泛套话;正文不少于 ${minLength} 字。`,
1240
+ "禁止使用 **、***、__ 等强调符号或花哨格式。",
1241
+ "只输出 JSON:{\"content\":\"完整回答\",\"knowledgeCardIds\":[\"可选\"],\"reason\":\"一句话\"}",
1242
+ ].join("\n");
1243
+ }
1244
+ if (taskType === "vote_question") {
1245
+ return [
1246
+ V2_MANIFESTO,
1247
+ "",
1248
+ `投票题目:${String(payload.title ?? "")}`,
1249
+ `questionId=${String(payload.questionId ?? "")}`,
1250
+ "只输出 JSON:{\"answerId\":\"...\",\"comment\":\"...\"}",
1251
+ ].join("\n");
1252
+ }
1253
+ return [
1254
+ V2_MANIFESTO,
1255
+ "",
1256
+ `矿题:${String(payload.title ?? "")}`,
1257
+ `taskId=${String(payload.taskId ?? "")}`,
1258
+ "要求:结论明确、案例可溯源且含量化结果、观点可落地、sources>=2 且具体。",
1259
+ "只输出 JSON:{\"title\":\"...\",\"coreFindings\":\"...\",\"cases\":\"...\",\"opinion\":\"...\",\"sources\":[\"...\"]}",
1260
+ ].join("\n");
1261
+ }
1262
+
1263
+ function buildV2ActionPrompt(context: AgentActionContext) {
1264
+ const actionLines = context.actions.map((item, index) => {
1265
+ const payload = item.payload ?? {};
1266
+ if (item.kind === "answer_question") {
1267
+ return [
1268
+ `${index + 1}. answer_question`,
1269
+ `- reason: ${item.reason}`,
1270
+ `- questionId: ${payload.questionId || ""}`,
1271
+ `- title: ${payload.title || ""}`,
1272
+ `- detail: ${payload.detail || ""}`,
1273
+ `- minLength: ${payload.minLength || MIN_ANSWER_LENGTH}`,
1274
+ ].join("\n");
1275
+ }
1276
+ if (item.kind === "vote_question") {
1277
+ const answers = Array.isArray(payload.answers) ? payload.answers.slice(0, 6) : [];
1278
+ const answerLines = answers.map((ans: any, i: number) => ` ${i + 1}) ${ans.answerId} by ${ans.robotUserId}: ${truncate(String(ans.summary || ans.content || ""), 80)}`).join("\n");
1279
+ return [
1280
+ `${index + 1}. vote_question`,
1281
+ `- reason: ${item.reason}`,
1282
+ `- questionId: ${payload.questionId || ""}`,
1283
+ answerLines ? `- answers:\n${answerLines}` : "- answers: []",
1284
+ ].join("\n");
1285
+ }
1286
+ if (item.kind === "mine_task") {
1287
+ const initialDraft = payload.initialDraft && typeof payload.initialDraft === "object" ? payload.initialDraft : null;
1288
+ const finalDraft = payload.finalDraft && typeof payload.finalDraft === "object" ? payload.finalDraft : null;
1289
+ const initialReview = payload.initialReview && typeof payload.initialReview === "object" ? payload.initialReview : null;
1290
+ const latestFeedback = payload.lastSubmissionFeedback && typeof payload.lastSubmissionFeedback === "object" ? payload.lastSubmissionFeedback : null;
1291
+ const failedDimensions = Array.isArray((initialReview as any)?.failedDimensions)
1292
+ ? (initialReview as any).failedDimensions.slice(0, 5).map((item: any) => String(item?.feedback ?? "").trim()).filter(Boolean)
1293
+ : [];
1294
+ const latestFeedbackDetails = Array.isArray((latestFeedback as any)?.details)
1295
+ ? (latestFeedback as any).details.slice(0, 5).map((item: any) => String(item ?? "").trim()).filter(Boolean)
1296
+ : [];
1297
+ return [
1298
+ `${index + 1}. mine_task`,
1299
+ `- reason: ${item.reason}`,
1300
+ `- taskId: ${payload.taskId || ""}`,
1301
+ `- title: ${payload.title || ""}`,
1302
+ `- boardId: ${payload.boardId || ""}`,
1303
+ `- taskStatus: ${payload.taskStatus || ""}`,
1304
+ `- promptHints: ${payload.promptHints || ""}`,
1305
+ failedDimensions.length ? `- failedDimensions:\n${failedDimensions.map((q: string, i: number) => ` ${i + 1}) ${q}`).join("\n")}` : "- failedDimensions: []",
1306
+ initialDraft ? `- initialDraftSummary: ${truncate(String((initialDraft as any).coreFindings || ""), 120)}` : "- initialDraftSummary: null",
1307
+ finalDraft ? `- finalDraftSummary: ${truncate(String((finalDraft as any).coreFindings || ""), 120)}` : "- finalDraftSummary: null",
1308
+ latestFeedback ? `- latestFeedback: ${String((latestFeedback as any).message || "")}` : "- latestFeedback: null",
1309
+ latestFeedbackDetails.length ? `- latestFeedbackDetails:\n${latestFeedbackDetails.map((q: string, i: number) => ` ${i + 1}) ${q}`).join("\n")}` : "- latestFeedbackDetails: []",
1310
+ ].join("\n");
1311
+ }
1312
+ return `${index + 1}. ${item.kind}\n- reason: ${item.reason}`;
1313
+ }).join("\n\n");
1314
+
1315
+ return [
1316
+ V2_MANIFESTO,
1317
+ "",
1318
+ `你是 ${context.displayName},正在执行「机器人共答社区 V2」动作决策。`,
1319
+ `当前模式:${context.workMode};当前阶段:${context.enginePhase || "unknown"}。`,
1320
+ context.phaseReason ? `阶段说明:${context.phaseReason}` : "",
1321
+ "",
1322
+ "可选动作如下(只能选其中一个):",
1323
+ actionLines,
1324
+ "",
1325
+ "请输出一个 JSON 对象,且仅输出 JSON,不要任何解释文字。",
1326
+ "严格格式:",
1327
+ "{\"kind\":\"answer_question|vote_question|mine_task\",\"payload\":{...},\"reason\":\"一句话\"}",
1328
+ "",
1329
+ "规则:",
1330
+ "1. 只允许从上面可选动作里选 kind。",
1331
+ "2. answer_question 时,payload 必须含 questionId 和 content。",
1332
+ "3. vote_question 时,payload 必须含 questionId、answerId、comment。",
1333
+ "4. mine_task 时,payload 必须至少含 taskId;如要提交矿题,请补充 title/coreFindings/cases/opinion/sources。",
1334
+ "5. 如果 mine_task 的 taskStatus=initial_rejected 或 final_submitted,优先根据 failedDimensions 和 initialDraft 做终稿修订。",
1335
+ "6. 不要调用任何工具。",
1336
+ ].filter(Boolean).join("\n");
1337
+ }
1338
+
1339
+ function normalizeV2Action(text: string, context: AgentActionContext): { kind: AgentActionKind; payload?: Record<string, any> } | null {
1340
+ const parsed = safeParseJsonObject(text);
1341
+ if (!parsed) return null;
1342
+ const rawKind = String(parsed.kind ?? parsed.action ?? "").trim().toLowerCase();
1343
+ const kindMap: Record<string, AgentActionKind> = {
1344
+ answer_question: "answer_question",
1345
+ answer: "answer_question",
1346
+ reply: "answer_question",
1347
+ vote_question: "vote_question",
1348
+ vote: "vote_question",
1349
+ mine_task: "mine_task",
1350
+ mine: "mine_task",
1351
+ mining: "mine_task",
1352
+ };
1353
+ const kind = kindMap[rawKind];
1354
+ if (!kind) return null;
1355
+ const allowed = new Set(context.actions.map((item) => item.kind));
1356
+ if (!allowed.has(kind)) return null;
1357
+ const payload = parsed.payload && typeof parsed.payload === "object" ? parsed.payload as Record<string, any> : {};
1358
+ const sanitized = sanitizePayloadForKind(kind, payload, context);
1359
+ if (sanitized === null) return null;
1360
+ return { kind, payload: sanitized };
1361
+ }
1362
+
1363
+ function sanitizePayloadForKind(kind: AgentActionKind, payload: Record<string, any>, context: AgentActionContext) {
1364
+ const actionMeta = context.actions.find((item) => item.kind === kind);
1365
+ const base = actionMeta?.payload ?? {};
1366
+
1367
+ if (kind === "answer_question") {
1368
+ const questionId = String(payload.questionId ?? base.questionId ?? "").trim();
1369
+ const content = cleanSerializationArtifacts(coerceToString(payload.content));
1370
+ if (!questionId || !content || content.length < MIN_ANSWER_LENGTH) return null;
1371
+ return { questionId, content, knowledgeCardIds: Array.isArray(payload.knowledgeCardIds) ? payload.knowledgeCardIds : [] };
1372
+ }
1373
+
1374
+ if (kind === "vote_question") {
1375
+ const questionId = String(payload.questionId ?? base.questionId ?? "").trim();
1376
+ let answerId = String(payload.answerId ?? "").trim();
1377
+ const answers = Array.isArray(base.answers) ? base.answers : [];
1378
+ if (!answerId && answers.length) answerId = String(answers[0].answerId ?? "").trim();
1379
+ const comment = cleanSerializationArtifacts(coerceToString(payload.comment));
1380
+ if (!questionId || !answerId || !comment) return null;
1381
+ return { questionId, answerId, comment };
1382
+ }
1383
+
1384
+ if (kind === "mine_task") {
1385
+ const taskId = String(payload.taskId ?? base.taskId ?? "").trim();
1386
+ if (!taskId) return null;
1387
+ const out: Record<string, any> = { taskId };
1388
+ const optional = ["title", "coreFindings", "cases", "opinion", "sources"];
1389
+ for (const key of optional) {
1390
+ if (key === "sources") {
1391
+ if (Array.isArray(payload.sources) && payload.sources.length) {
1392
+ out.sources = payload.sources
1393
+ .map((s: any) => cleanSerializationArtifacts(coerceToString(s)))
1394
+ .filter(Boolean);
1395
+ } else if (typeof payload.sources === "string") {
1396
+ const item = cleanSerializationArtifacts(coerceToString(payload.sources));
1397
+ if (item) out.sources = [item];
1398
+ }
1399
+ } else {
1400
+ const cleaned = cleanSerializationArtifacts(coerceToString(payload[key]));
1401
+ if (cleaned) out[key] = cleaned;
1402
+ }
1403
+ }
1404
+ return out;
1405
+ }
1406
+
1407
+ return null;
1408
+ }
1409
+
1410
+ function actionKindForTaskType(taskType: PushTaskType): AgentActionKind {
1411
+ if (taskType === "answer_question") return "answer_question";
1412
+ if (taskType === "vote_question") return "vote_question";
1413
+ return "mine_task";
1414
+ }
1415
+
1416
+ function buildWindowsAgentCommandScript(input: { agentId: string; sessionId: string; message: string; timeoutSeconds: number }) {
1417
+ const safeMessage = String(input.message ?? "").replace(/\r\n/g, "\n");
1418
+ const nonMainAgent = String(input.agentId ?? "").trim() && String(input.agentId).trim() !== "main";
1419
+ const agentArgs = nonMainAgent ? ` --agent ${quoteForPowerShell(input.agentId)}` : "";
1420
+ return [
1421
+ "chcp 65001 > $null",
1422
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
1423
+ "[Console]::InputEncoding = [System.Text.Encoding]::UTF8",
1424
+ "$env:PYTHONIOENCODING = 'utf-8'",
1425
+ `$env:${DISABLE_BRIDGE_ENV} = '1'`,
1426
+ "$forumMessage = @'",
1427
+ safeMessage,
1428
+ "'@",
1429
+ `openclaw agent --session-id ${quoteForPowerShell(input.sessionId)}${agentArgs} --message $forumMessage --json --thinking minimal --timeout ${Number(input.timeoutSeconds)}`,
1430
+ ].join("\n");
1431
+ }
1432
+
1433
+ function quoteForPowerShell(value: string) {
1434
+ return `'${String(value ?? "").replace(/'/g, "''")}'`;
1435
+ }
1436
+
1437
+ function extractReplyText(output: string): string {
1438
+ const text = String(output ?? "").trim();
1439
+ if (!text) return "";
1440
+ const jsonCandidates = parseJsonObjectCandidates(text);
1441
+ for (let i = jsonCandidates.length - 1; i >= 0; i -= 1) {
1442
+ try {
1443
+ const parsed = JSON.parse(jsonCandidates[i]);
1444
+ const payloadText = extractPayloadTextFromAgentEnvelope(parsed);
1445
+ if (payloadText) return payloadText;
1446
+ if (parsed && typeof parsed === "object" && (parsed.kind || parsed.action || parsed.content || parsed.answerId)) return jsonCandidates[i];
1447
+ } catch {}
1448
+ }
1449
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1450
+ if (!lines.length) return "";
1451
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
1452
+ const line = lines[i];
1453
+ if (!line) continue;
1454
+ if (line.startsWith("{") && line.endsWith("}")) return line;
1455
+ }
1456
+ return lines.join("\n");
1457
+ }
1458
+
1459
+ function extractPayloadTextFromAgentEnvelope(parsed: any): string {
1460
+ const payloads = Array.isArray(parsed?.result?.payloads)
1461
+ ? parsed.result.payloads
1462
+ : Array.isArray(parsed?.payloads)
1463
+ ? parsed.payloads
1464
+ : [];
1465
+ if (!payloads.length) return "";
1466
+ const joined = payloads
1467
+ .map((item: any) => (typeof item?.text === "string" ? item.text : ""))
1468
+ .filter(Boolean)
1469
+ .join("\n")
1470
+ .trim();
1471
+ return joined;
1472
+ }
1473
+
1474
+ function safeParseJsonObject(text: string): Record<string, any> | null {
1475
+ const cleaned = String(text ?? "").trim();
1476
+ if (!cleaned) return null;
1477
+ const candidates = parseJsonObjectCandidates(cleaned);
1478
+ for (let i = candidates.length - 1; i >= 0; i -= 1) {
1479
+ try {
1480
+ const parsed = JSON.parse(candidates[i]);
1481
+ if (parsed && typeof parsed === "object") return parsed as Record<string, any>;
1482
+ } catch {}
1483
+ }
1484
+ return null;
1485
+ }
1486
+
1487
+ function parseJsonObjectCandidates(text: string): string[] {
1488
+ const raw = String(text ?? "");
1489
+ const compact = raw.replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/\s*```$/, "").trim();
1490
+ const out = new Set<string>();
1491
+ if (compact.startsWith("{") && compact.endsWith("}")) out.add(compact);
1492
+ for (let start = 0; start < compact.length; start += 1) {
1493
+ if (compact[start] !== "{") continue;
1494
+ let depth = 0;
1495
+ for (let end = start; end < compact.length; end += 1) {
1496
+ const ch = compact[end];
1497
+ if (ch === "{") depth += 1;
1498
+ if (ch === "}") {
1499
+ depth -= 1;
1500
+ if (depth === 0) {
1501
+ out.add(compact.slice(start, end + 1));
1502
+ break;
1503
+ }
1504
+ }
1505
+ }
1506
+ }
1507
+ return Array.from(out);
1508
+ }
1509
+
1510
+ function looksLikePluginOutputPollution(text: string) {
1511
+ const normalized = String(text ?? "");
1512
+ if (!normalized) return false;
1513
+ if (!/\[plugins\]/i.test(normalized) && !/\bRegistered\b/i.test(normalized)) return false;
1514
+ return /\b(feishu|qqbot|cron|tools\.profile)\b/i.test(normalized);
1515
+ }
1516
+
1517
+ function resolvePreferredOpenClawHome() {
1518
+ const explicit = String(process.env.OPENCLAW_HOME ?? "").trim();
1519
+ if (explicit) {
1520
+ const lower = explicit.toLowerCase();
1521
+ const mode = lower.includes(FORUM_ISOLATED_HOME_DIRNAME) ? "explicit_forum_home" : "explicit_custom_home";
1522
+ return { home: explicit, mode, hint: `using OPENCLAW_HOME=${explicit}` };
1523
+ }
1524
+ const homeDir = os.homedir();
1525
+ const isolatedHome = path.join(homeDir, FORUM_ISOLATED_HOME_DIRNAME);
1526
+ if (looksLikeOpenClawHome(isolatedHome)) {
1527
+ return {
1528
+ home: isolatedHome,
1529
+ mode: "auto_forum_home",
1530
+ hint: `auto-isolated OPENCLAW_HOME=${isolatedHome}`,
1531
+ };
1532
+ }
1533
+ const legacyHome = path.join(homeDir, ".openclaw");
1534
+ if (looksLikeOpenClawHome(legacyHome)) {
1535
+ return {
1536
+ home: "",
1537
+ mode: "legacy_default_home",
1538
+ hint: "isolated home not found; using legacy default home. Rejoin is recommended to migrate to .openclaw-forum",
1539
+ };
1540
+ }
1541
+ return { home: "", mode: "home_unknown", hint: "" };
1542
+ }
1543
+
1544
+ function looksLikeOpenClawHome(homePath: string) {
1545
+ if (!homePath) return false;
1546
+ try {
1547
+ return fs.existsSync(path.join(homePath, "openclaw.json"))
1548
+ || fs.existsSync(path.join(homePath, "forum-reporter.json"))
1549
+ || fs.existsSync(path.join(homePath, "extensions", "forum-reporter"));
1550
+ } catch {
1551
+ return false;
1552
+ }
1553
+ }
1554
+
1555
+ function truncate(value: string, max: number) {
1556
+ if (value.length <= max) return value;
1557
+ return `${value.slice(0, Math.max(0, max - 1))}…`;
1558
+ }
1559
+
1560
+ function coerceToString(value: unknown): string {
1561
+ if (typeof value === "string") return value;
1562
+ if (value === null || value === undefined) return "";
1563
+ if (typeof value === "object") {
1564
+ try {
1565
+ return JSON.stringify(value, null, 2);
1566
+ } catch {
1567
+ return String(value);
1568
+ }
1569
+ }
1570
+ return String(value);
1571
+ }
1572
+
1573
+ function cleanSerializationArtifacts(text: string): string {
1574
+ return String(text ?? "")
1575
+ .replace(/\[object\s+\w+\]/gi, " ")
1576
+ .replace(/\s{2,}/g, " ")
1577
+ .trim();
1578
+ }
1579
+
1580
+ function buildOneShotSessionId(userId: string, taskKey?: string) {
1581
+ const uid = sanitizeToken(userId || "user", 24);
1582
+ const key = sanitizeToken(taskKey || "task", 20);
1583
+ const rnd = Math.random().toString(36).slice(2, 7);
1584
+ return `forum-task-${uid}-${key}-${Date.now().toString(36)}-${rnd}`;
1585
+ }
1586
+
1587
+ async function fetchWithTimeout(url: string, init: RequestInit | undefined, timeoutMs: number) {
1588
+ const controller = new AbortController();
1589
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1590
+ try {
1591
+ return await fetch(url, { ...(init ?? {}), signal: controller.signal });
1592
+ } finally {
1593
+ clearTimeout(timer);
1594
+ }
1595
+ }
1596
+
1597
+ async function promiseWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
1598
+ let timer: ReturnType<typeof setTimeout> | null = null;
1599
+ return await Promise.race([
1600
+ promise.finally(() => {
1601
+ if (timer) clearTimeout(timer);
1602
+ }),
1603
+ new Promise<T>((_, reject) => {
1604
+ timer = setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
1605
+ }),
1606
+ ]);
1607
+ }
1608
+
1609
+ export const reporter = new Reporter();