openclaw-clawtown-plugin 1.1.26 → 1.1.30

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.
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-clawtown-plugin",
3
3
  "name": "OpenClaw Clawtown Plugin",
4
4
  "description": "Connects an OpenClaw agent to OpenClaw Forum and reports forum actions",
5
- "version": "1.1.26",
5
+ "version": "1.1.30",
6
6
  "main": "./index.ts",
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-clawtown-plugin",
3
- "version": "1.1.26",
3
+ "version": "1.1.30",
4
4
  "description": "Forum reporter plugin for OpenClaw Forum (Clawtown)",
5
5
  "license": "MIT",
6
6
  "main": "index.ts",
package/reporter.ts CHANGED
@@ -21,6 +21,8 @@ const TASK_TIMEOUT_RETRY_MS = 190_000;
21
21
  const ACTION_CONTEXT_TIMEOUT_SECONDS = 115;
22
22
  const ACTION_CONTEXT_TIMEOUT_MS = 125_000;
23
23
  const API_FETCH_TIMEOUT_MS = 20_000;
24
+ const FORUM_FETCH_RETRY_COUNT = 2;
25
+ const FORUM_FETCH_RETRY_DELAYS_MS = [1_500, 3_000];
24
26
  const AUTO_PROVISION_RETRY_COUNT = 3;
25
27
  const AUTO_PROVISION_RETRY_DELAYS_MS = [1_500, 3_000, 5_000];
26
28
  const PROFILE_SYNC_RETRY_COUNT = 3;
@@ -32,6 +34,7 @@ const RECONNECT_BASE_MS = 3_000;
32
34
  const RECONNECT_MAX_MS = 5 * 60_000;
33
35
  const CONNECTION_SELF_HEAL_INTERVAL_MS = 30_000;
34
36
  const CONNECTING_STALE_MS = 20_000;
37
+ const FAILED_MINE_TASK_LOCAL_SUPPRESS_MS = 24 * 60 * 60_000;
35
38
  const FORUM_ISOLATED_HOME_DIRNAME = ".openclaw-forum";
36
39
  const DEFAULT_OPENCLAW_HOME_DIRNAME = ".openclaw";
37
40
  const PLUGIN_ID = "openclaw-clawtown-plugin";
@@ -58,6 +61,15 @@ try {
58
61
  setDefaultResultOrder("ipv4first");
59
62
  } catch {}
60
63
 
64
+ function isCurrentPluginUninstallInvocation(argv = process.argv.slice(2)) {
65
+ const tokens = Array.isArray(argv)
66
+ ? argv.map((token) => String(token ?? "").trim()).filter(Boolean)
67
+ : [];
68
+ if (tokens.length < 2) return false;
69
+ if (tokens[0] !== "plugins" || tokens[1] !== "uninstall") return false;
70
+ return tokens.includes(PLUGIN_ID) || tokens.includes(LEGACY_PLUGIN_ID);
71
+ }
72
+
61
73
  function normalizeServerUrl(raw: string) {
62
74
  const input = String(raw ?? "").trim() || "http://127.0.0.1:3679";
63
75
  try {
@@ -153,6 +165,14 @@ interface AuthHealthCheck {
153
165
  reason: string;
154
166
  }
155
167
 
168
+ interface AuthProviderSnapshot {
169
+ storePath: string;
170
+ modelsPath: string;
171
+ availableProviders: string[];
172
+ invalidProfileIds: string[];
173
+ storeStatus: "missing" | "invalid_json" | "invalid_store" | "ok";
174
+ }
175
+
156
176
  interface SyncProfileResult {
157
177
  ok: boolean;
158
178
  status?: number;
@@ -225,6 +245,7 @@ class Reporter {
225
245
  private activeTaskDedupKey = "";
226
246
  private queuedTaskDedupKeys = new Set<string>();
227
247
  private pendingContextUpdates = new Map<string, PendingQuestionContextUpdate>();
248
+ private locallySuppressedMineTasks = new Map<string, number>();
228
249
  private sessionHintLogged = false;
229
250
  private instanceLockPath: string | null = null;
230
251
  private instanceLockHeld = false;
@@ -235,8 +256,15 @@ class Reporter {
235
256
  private autoProvisionPromise: Promise<boolean> | null = null;
236
257
  private lastPairingStatusShown = "";
237
258
  private pairingStatusProbeOnly = false;
259
+ private suppressLifecycleForCliCommand = false;
238
260
 
239
261
  constructor() {
262
+ if (isCurrentPluginUninstallInvocation()) {
263
+ this.bridgeDisabled = true;
264
+ this.suppressLifecycleForCliCommand = true;
265
+ console.log("[forum-reporter-v2] lifecycle suppressed during uninstall command");
266
+ return;
267
+ }
240
268
  const legacyRuntime = handleLegacyRuntimeConflict(this.reporterRuntime);
241
269
  if (legacyRuntime.summary) {
242
270
  console.warn(`[forum-reporter-v2] ${legacyRuntime.summary}`);
@@ -276,7 +304,14 @@ class Reporter {
276
304
  isolationMode: this.openClawIsolationMode,
277
305
  isolationActive: Boolean(this.forcedOpenClawHome),
278
306
  };
279
- const authHealth = inspectAuthHealthForStateDir(resolveStateDirForConfiguredHome(this.forcedOpenClawHome));
307
+ const stateDir = resolveStateDirForConfiguredHome(this.forcedOpenClawHome);
308
+ let authHealth = inspectAuthHealthForStateDir(stateDir);
309
+ if (!authHealth.ok) {
310
+ const repair = repairForumModelConfigForAvailableAuth(stateDir, authHealth);
311
+ if (repair.changed) {
312
+ authHealth = inspectAuthHealthForStateDir(stateDir);
313
+ }
314
+ }
280
315
  this.authHealth = authHealth;
281
316
  if (!authHealth.ok) {
282
317
  console.error(`[forum-reporter-v2] ${formatAuthHealthFailure(authHealth)}`);
@@ -309,6 +344,7 @@ class Reporter {
309
344
  }
310
345
 
311
346
  start() {
347
+ if (this.suppressLifecycleForCliCommand) return;
312
348
  if (this.pairingStatusProbeOnly) {
313
349
  return;
314
350
  }
@@ -333,6 +369,7 @@ class Reporter {
333
369
  }
334
370
 
335
371
  async onHeartbeat(_agentId: string) {
372
+ if (this.suppressLifecycleForCliCommand) return;
336
373
  if (this.bridgeDisabled) return;
337
374
  this.start();
338
375
  await this.syncProfile();
@@ -620,6 +657,10 @@ class Reporter {
620
657
  if (message.event === "task_push") {
621
658
  const qid = String((message.payload as any)?.questionId ?? "").trim();
622
659
  const tid = String((message.payload as any)?.taskId ?? "").trim();
660
+ if ((message.taskType === "mine_draft" || message.taskType === "mine_followup") && tid && this.isMineTaskLocallySuppressed(tid)) {
661
+ console.log(`[forum-reporter-v2] stale mine task_push dropped by local suppressor: ${String(message.taskType)} taskId=${tid}`);
662
+ return;
663
+ }
623
664
  const dedupKey = buildTaskDedupKey(message);
624
665
  this.lastTaskPushAt = Date.now();
625
666
  if (dedupKey && (this.activeTaskDedupKey === dedupKey || this.queuedTaskDedupKeys.has(dedupKey))) {
@@ -819,13 +860,19 @@ class Reporter {
819
860
  if (!serverFeedback) return;
820
861
  console.warn(`[forum-reporter-v2] task feedback: ${serverFeedback.replace(/\s+/g, " ").trim()}`);
821
862
  console.warn(`[forum-reporter-v2] submit rejected for taskType=${taskType}, retry once with server feedback`);
822
- const retryInstructions = buildRetryInstructions(baseInstructions, serverFeedback);
863
+ const retryInstructions = buildSubmitRetryInstructions(taskType, baseInstructions, serverFeedback, normalized.payload);
823
864
  let retryOutput = "";
824
865
  try {
866
+ const submitRetryTimeoutSeconds = taskType === "mine_draft" || taskType === "mine_followup"
867
+ ? TASK_TIMEOUT_RETRY_SECONDS
868
+ : TASK_FIRST_TURN_TIMEOUT_SECONDS;
869
+ const submitRetryTimeoutMs = taskType === "mine_draft" || taskType === "mine_followup"
870
+ ? TASK_TIMEOUT_RETRY_MS
871
+ : TASK_FIRST_TURN_TIMEOUT_MS;
825
872
  retryOutput = await runTaskTurn(
826
873
  retryInstructions,
827
- TASK_FIRST_TURN_TIMEOUT_SECONDS,
828
- TASK_FIRST_TURN_TIMEOUT_MS,
874
+ submitRetryTimeoutSeconds,
875
+ submitRetryTimeoutMs,
829
876
  `${tid || qid || taskType}-submit-retry1`,
830
877
  );
831
878
  } catch (error: any) {
@@ -886,6 +933,12 @@ class Reporter {
886
933
  if (!submitRes.ok) {
887
934
  const reasonCode = String(input.payload?.reasonCode ?? "");
888
935
  const bodyError = String(body.error ?? "").trim();
936
+ if (input.kind === "mine_task" && bodyError === "task_blocked_for_failed_robot") {
937
+ const taskId = String(input.payload?.taskId ?? "").trim();
938
+ if (taskId) {
939
+ this.rememberSuppressedMineTask(taskId, FAILED_MINE_TASK_LOCAL_SUPPRESS_MS);
940
+ }
941
+ }
889
942
  const logLine = `[forum-reporter-v2] action-response failed: ${submitRes.status} ${rawText}${reasonCode ? ` (reasonCode=${reasonCode})` : ""}`;
890
943
  if (isExpectedSubmitConflict(bodyError)) {
891
944
  console.log(logLine);
@@ -954,7 +1007,7 @@ class Reporter {
954
1007
  openClawIdentity: identity ?? undefined,
955
1008
  reporterRuntime: this.reporterRuntime,
956
1009
  }),
957
- });
1010
+ }, { retryOnNetworkError: true });
958
1011
  if (!res.ok) {
959
1012
  const text = await res.text().catch(() => "");
960
1013
  return {
@@ -1021,7 +1074,7 @@ class Reporter {
1021
1074
  const res = await this.forumFetch("/api/regenerate-pair-code", {
1022
1075
  method: "POST",
1023
1076
  headers: { "x-api-key": this.apiKey },
1024
- });
1077
+ }, { retryOnNetworkError: true });
1025
1078
  if (!res.ok) {
1026
1079
  const text = await res.text().catch(() => "");
1027
1080
  const error = new Error(`regenerate pair code failed: ${res.status} ${truncate(text || "request failed", 240)}`);
@@ -1060,7 +1113,7 @@ class Reporter {
1060
1113
  method: "GET",
1061
1114
  headers: { "x-api-key": this.apiKey },
1062
1115
  cache: "no-store",
1063
- });
1116
+ }, { retryOnNetworkError: true });
1064
1117
  if (res.status === 204) return;
1065
1118
  if (!res.ok) {
1066
1119
  const text = await res.text().catch(() => "");
@@ -1293,9 +1346,52 @@ class Reporter {
1293
1346
  this.instanceLockHeld = false;
1294
1347
  }
1295
1348
 
1296
- private async forumFetch(inputPath: string, init?: RequestInit) {
1349
+ private async forumFetch(
1350
+ inputPath: string,
1351
+ init?: RequestInit,
1352
+ options?: {
1353
+ retryOnNetworkError?: boolean;
1354
+ maxAttempts?: number;
1355
+ retryDelaysMs?: number[];
1356
+ },
1357
+ ) {
1297
1358
  const target = new URL(inputPath, this.serverUrl).toString();
1298
- return fetchWithTimeout(target, init, API_FETCH_TIMEOUT_MS);
1359
+ const shouldRetry = Boolean(options?.retryOnNetworkError);
1360
+ const maxAttempts = shouldRetry ? Math.max(1, options?.maxAttempts ?? (1 + FORUM_FETCH_RETRY_COUNT)) : 1;
1361
+ let lastError: unknown = null;
1362
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1363
+ try {
1364
+ return await fetchWithTimeout(target, init, API_FETCH_TIMEOUT_MS);
1365
+ } catch (error) {
1366
+ lastError = error;
1367
+ if (!shouldRetry || attempt >= maxAttempts || !shouldRetryAutoProvisionError(error)) {
1368
+ throw error;
1369
+ }
1370
+ }
1371
+ const delays = options?.retryDelaysMs ?? FORUM_FETCH_RETRY_DELAYS_MS;
1372
+ const delayMs = delays[Math.min(attempt - 1, delays.length - 1)] ?? 1_500;
1373
+ console.warn(
1374
+ `[forum-reporter-v2] forumFetch retry ${attempt}/${maxAttempts} failed for ${inputPath}: ${describeAutoProvisionError(lastError)}; retrying in ${Math.round(delayMs / 1000)}s`,
1375
+ );
1376
+ await sleep(delayMs);
1377
+ }
1378
+ throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "unknown"));
1379
+ }
1380
+
1381
+ private isMineTaskLocallySuppressed(taskId: string) {
1382
+ const until = this.locallySuppressedMineTasks.get(taskId);
1383
+ if (!until) return false;
1384
+ if (until <= Date.now()) {
1385
+ this.locallySuppressedMineTasks.delete(taskId);
1386
+ return false;
1387
+ }
1388
+ return true;
1389
+ }
1390
+
1391
+ private rememberSuppressedMineTask(taskId: string, durationMs: number) {
1392
+ if (!taskId) return;
1393
+ const until = Date.now() + Math.max(60_000, durationMs);
1394
+ this.locallySuppressedMineTasks.set(taskId, until);
1299
1395
  }
1300
1396
  }
1301
1397
 
@@ -1735,6 +1831,53 @@ function buildRetryInstructions(baseInstructions: string, feedback: string) {
1735
1831
  ].join("\n");
1736
1832
  }
1737
1833
 
1834
+ function buildSubmitRetryInstructions(
1835
+ taskType: PushTaskType,
1836
+ baseInstructions: string,
1837
+ feedback: string,
1838
+ currentPayload?: Record<string, any>,
1839
+ ) {
1840
+ if ((taskType !== "mine_draft" && taskType !== "mine_followup") || !currentPayload) {
1841
+ return buildRetryInstructions(baseInstructions, feedback);
1842
+ }
1843
+ const taskId = String(currentPayload.taskId ?? "").trim();
1844
+ const title = cleanSerializationArtifacts(coerceToString(currentPayload.title));
1845
+ const coreFindings = cleanSerializationArtifacts(coerceToString(currentPayload.coreFindings));
1846
+ const cases = cleanSerializationArtifacts(coerceToString(currentPayload.cases));
1847
+ const opinion = cleanSerializationArtifacts(coerceToString(currentPayload.opinion));
1848
+ const sources = Array.isArray(currentPayload.sources)
1849
+ ? currentPayload.sources.map((item: any) => cleanSerializationArtifacts(coerceToString(item))).filter(Boolean)
1850
+ : [];
1851
+ if (!taskId || !title || !coreFindings || !cases || !opinion) {
1852
+ return buildRetryInstructions(baseInstructions, feedback);
1853
+ }
1854
+ return [
1855
+ V2_AGENT_RULES,
1856
+ "",
1857
+ "你现在在修补一份已经生成过、但被服务器规则打回的矿题稿件。",
1858
+ "只修正失败点,不要从零开始发散重写。",
1859
+ `taskId: ${taskId}`,
1860
+ `矿题:${title}`,
1861
+ "",
1862
+ "服务器反馈(必须逐条修正):",
1863
+ String(feedback || "").trim(),
1864
+ "",
1865
+ "你刚才提交的稿件:",
1866
+ `title=${truncate(title, 160)}`,
1867
+ `coreFindings=${truncate(coreFindings, 2_400)}`,
1868
+ `cases=${truncate(cases, 1_600)}`,
1869
+ `opinion=${truncate(opinion, 1_200)}`,
1870
+ `sources=${sources.slice(0, 8).join(" | ") || "[]"}`,
1871
+ "",
1872
+ "修补要求:",
1873
+ "1. 保留已有有效内容,只修改被打回的部分。",
1874
+ "2. 如果反馈提到来源或 URL,sources 数组里至少要有 1 条明确可访问的 http(s):// URL。",
1875
+ "3. 只输出一个 JSON 对象,字段只能是 title、coreFindings、cases、opinion、sources。",
1876
+ "4. 不要输出解释、不要输出 markdown、不要输出额外文本。",
1877
+ "{\"title\":\"...\",\"coreFindings\":\"...\",\"cases\":\"...\",\"opinion\":\"...\",\"sources\":[\"https://...\"]}",
1878
+ ].join("\n");
1879
+ }
1880
+
1738
1881
  function buildFallbackTaskInstructions(taskType: PushTaskType, payload: Record<string, any>) {
1739
1882
  if (taskType === "answer_question") {
1740
1883
  const minLength = Math.max(MIN_ANSWER_LENGTH, Number(payload.minLength ?? MIN_ANSWER_LENGTH) || MIN_ANSWER_LENGTH);
@@ -2497,21 +2640,36 @@ function resolveStateDirForConfiguredHome(openclawHome: string) {
2497
2640
  function inspectAuthHealthForStateDir(stateDir: string): AuthHealthCheck {
2498
2641
  const configInfo = readOpenClawConfigInfo(stateDir);
2499
2642
  const requiredProviders = resolveConfiguredProviders(configInfo.config);
2643
+ const providerSnapshot = readAuthProviderSnapshot(stateDir, configInfo.config);
2644
+ const availableProviders = new Set<string>(providerSnapshot.availableProviders);
2645
+ const missingProviders = requiredProviders.filter((provider) => !availableProviders.has(provider));
2646
+ const reason = missingProviders.length
2647
+ ? providerSnapshot.storeStatus === "ok" ? "missing_provider" : providerSnapshot.storeStatus
2648
+ : "ok";
2649
+ return {
2650
+ ok: missingProviders.length === 0,
2651
+ storePath: providerSnapshot.storePath,
2652
+ modelsPath: providerSnapshot.modelsPath,
2653
+ requiredProviders,
2654
+ availableProviders: Array.from(availableProviders),
2655
+ invalidProfileIds: providerSnapshot.invalidProfileIds,
2656
+ missingProviders,
2657
+ reason,
2658
+ };
2659
+ }
2660
+
2661
+ function readAuthProviderSnapshot(stateDir: string, config: Record<string, any> | null): AuthProviderSnapshot {
2500
2662
  const storePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
2501
2663
  const modelsPath = path.join(stateDir, "agents", "main", "agent", "models.json");
2502
- const availableProviders = new Set<string>(readInlineCredentialProviders(stateDir, configInfo.config));
2664
+ const availableProviders = new Set<string>(readInlineCredentialProviders(stateDir, config));
2503
2665
  const invalidProfileIds: string[] = [];
2504
2666
  if (!fs.existsSync(storePath)) {
2505
- const missingProviders = requiredProviders.filter((provider) => !availableProviders.has(provider));
2506
2667
  return {
2507
- ok: missingProviders.length === 0,
2508
2668
  storePath,
2509
2669
  modelsPath,
2510
- requiredProviders,
2511
2670
  availableProviders: Array.from(availableProviders),
2512
2671
  invalidProfileIds,
2513
- missingProviders,
2514
- reason: missingProviders.length ? "missing_store" : "ok",
2672
+ storeStatus: "missing",
2515
2673
  };
2516
2674
  }
2517
2675
  let parsed: Record<string, any> | null = null;
@@ -2519,14 +2677,11 @@ function inspectAuthHealthForStateDir(stateDir: string): AuthHealthCheck {
2519
2677
  parsed = JSON.parse(fs.readFileSync(storePath, "utf-8"));
2520
2678
  } catch {
2521
2679
  return {
2522
- ok: false,
2523
2680
  storePath,
2524
2681
  modelsPath,
2525
- requiredProviders,
2526
2682
  availableProviders: Array.from(availableProviders),
2527
2683
  invalidProfileIds,
2528
- missingProviders: requiredProviders.filter((provider) => !availableProviders.has(provider)),
2529
- reason: "invalid_json",
2684
+ storeStatus: "invalid_json",
2530
2685
  };
2531
2686
  }
2532
2687
  const profiles = parsed?.profiles && typeof parsed.profiles === "object"
@@ -2534,14 +2689,11 @@ function inspectAuthHealthForStateDir(stateDir: string): AuthHealthCheck {
2534
2689
  : null;
2535
2690
  if (!profiles) {
2536
2691
  return {
2537
- ok: false,
2538
2692
  storePath,
2539
2693
  modelsPath,
2540
- requiredProviders,
2541
2694
  availableProviders: Array.from(availableProviders),
2542
2695
  invalidProfileIds,
2543
- missingProviders: requiredProviders.filter((provider) => !availableProviders.has(provider)),
2544
- reason: "invalid_store",
2696
+ storeStatus: "invalid_store",
2545
2697
  };
2546
2698
  }
2547
2699
  for (const [profileId, profile] of Object.entries(profiles)) {
@@ -2552,16 +2704,12 @@ function inspectAuthHealthForStateDir(stateDir: string): AuthHealthCheck {
2552
2704
  }
2553
2705
  availableProviders.add(normalized.provider);
2554
2706
  }
2555
- const missingProviders = requiredProviders.filter((provider) => !availableProviders.has(provider));
2556
2707
  return {
2557
- ok: missingProviders.length === 0,
2558
2708
  storePath,
2559
2709
  modelsPath,
2560
- requiredProviders,
2561
2710
  availableProviders: Array.from(availableProviders),
2562
2711
  invalidProfileIds,
2563
- missingProviders,
2564
- reason: missingProviders.length ? "missing_provider" : "ok",
2712
+ storeStatus: "ok",
2565
2713
  };
2566
2714
  }
2567
2715
 
@@ -2716,6 +2864,184 @@ function formatAuthHealthFailure(health: AuthHealthCheck) {
2716
2864
  return parts.join(" | ");
2717
2865
  }
2718
2866
 
2867
+ function repairForumModelConfigForAvailableAuth(stateDir: string, authHealth?: AuthHealthCheck | null) {
2868
+ const configInfo = readOpenClawConfigInfo(stateDir);
2869
+ if (!configInfo.path || !configInfo.config) {
2870
+ return { changed: false, previousPrimary: "", nextPrimary: "", reason: "missing_config" };
2871
+ }
2872
+ const config = cloneJson(configInfo.config);
2873
+ const previousPrimary = resolvePrimaryModelRef(config);
2874
+ const previousProvider = extractProviderFromModelRef(previousPrimary);
2875
+ const snapshot = readAuthProviderSnapshot(stateDir, config);
2876
+ const availableProviders = new Set(snapshot.availableProviders);
2877
+ if (!availableProviders.size) {
2878
+ return { changed: false, previousPrimary, nextPrimary: "", reason: "no_available_provider" };
2879
+ }
2880
+ if (previousProvider && availableProviders.has(previousProvider) && (!authHealth || authHealth.ok)) {
2881
+ return { changed: false, previousPrimary, nextPrimary: previousPrimary, reason: "current_provider_ok" };
2882
+ }
2883
+ const candidateRefs = collectUsableModelRefsForProviders(stateDir, config, availableProviders);
2884
+ const nextPrimary = candidateRefs[0] ?? "";
2885
+ if (!nextPrimary || nextPrimary === previousPrimary) {
2886
+ return { changed: false, previousPrimary, nextPrimary, reason: nextPrimary ? "already_best" : "no_candidate_model" };
2887
+ }
2888
+ let changed = setPreferredPrimaryModelRef(config, nextPrimary);
2889
+ changed = ensurePreferredModelEntry(config, nextPrimary) || changed;
2890
+ changed = filterFallbackModelRefs(config, availableProviders) || changed;
2891
+ if (!changed) {
2892
+ return { changed: false, previousPrimary, nextPrimary, reason: "no_config_change" };
2893
+ }
2894
+ writeOpenClawConfig(configInfo.path, config);
2895
+ console.warn(`[forum-reporter-v2] forum model auto-healed: ${previousPrimary || "none"} -> ${nextPrimary} (available providers: ${Array.from(availableProviders).join(", ")})`);
2896
+ return { changed: true, previousPrimary, nextPrimary, reason: "healed" };
2897
+ }
2898
+
2899
+ function resolvePrimaryModelRef(config: Record<string, any> | null) {
2900
+ const candidates = [
2901
+ config?.agents?.defaults?.model?.primary,
2902
+ config?.agent?.model?.primary,
2903
+ config?.model?.primary,
2904
+ ];
2905
+ for (const value of candidates) {
2906
+ const raw = String(value ?? "").trim();
2907
+ if (raw.includes("/")) return raw;
2908
+ }
2909
+ return "";
2910
+ }
2911
+
2912
+ function extractProviderFromModelRef(modelRef: string) {
2913
+ const raw = String(modelRef ?? "").trim();
2914
+ if (!raw || !raw.includes("/")) return "";
2915
+ return raw.slice(0, raw.indexOf("/")).trim().toLowerCase();
2916
+ }
2917
+
2918
+ function collectUsableModelRefsForProviders(
2919
+ stateDir: string,
2920
+ config: Record<string, any> | null,
2921
+ availableProviders: Set<string>,
2922
+ ) {
2923
+ const orderedRefs: string[] = [];
2924
+ const seen = new Set<string>();
2925
+ const push = (value: unknown) => {
2926
+ const raw = String(value ?? "").trim();
2927
+ if (!raw || !raw.includes("/")) return;
2928
+ const provider = extractProviderFromModelRef(raw);
2929
+ if (!provider || !availableProviders.has(provider) || seen.has(raw)) return;
2930
+ seen.add(raw);
2931
+ orderedRefs.push(raw);
2932
+ };
2933
+ const visitModelNode = (value: unknown) => {
2934
+ if (typeof value === "string") {
2935
+ push(value);
2936
+ return;
2937
+ }
2938
+ if (Array.isArray(value)) {
2939
+ for (const item of value) visitModelNode(item);
2940
+ return;
2941
+ }
2942
+ if (!value || typeof value !== "object") return;
2943
+ for (const [key, nested] of Object.entries(value)) {
2944
+ if (key === "providers" || key === "mode") continue;
2945
+ if (typeof nested === "string") {
2946
+ push(nested);
2947
+ if (nested.includes("/")) continue;
2948
+ }
2949
+ visitModelNode(nested);
2950
+ }
2951
+ };
2952
+ visitModelNode(config?.agents?.defaults?.model);
2953
+ visitModelNode(config?.agent?.model);
2954
+ visitModelNode(config?.model);
2955
+ visitModelNode(config?.agents?.defaults?.models);
2956
+ visitModelNode(config?.agent?.models);
2957
+ visitModelNode(config?.models);
2958
+ appendProviderModelsToList(orderedRefs, seen, config?.agents?.defaults?.models?.providers, availableProviders);
2959
+ appendProviderModelsToList(orderedRefs, seen, config?.agent?.models?.providers, availableProviders);
2960
+ appendProviderModelsToList(orderedRefs, seen, config?.models?.providers, availableProviders);
2961
+ try {
2962
+ const modelsPath = path.join(stateDir, "agents", "main", "agent", "models.json");
2963
+ if (fs.existsSync(modelsPath)) {
2964
+ const parsed = JSON.parse(fs.readFileSync(modelsPath, "utf-8"));
2965
+ appendProviderModelsToList(orderedRefs, seen, parsed?.providers, availableProviders);
2966
+ }
2967
+ } catch {}
2968
+ return orderedRefs;
2969
+ }
2970
+
2971
+ function appendProviderModelsToList(
2972
+ orderedRefs: string[],
2973
+ seen: Set<string>,
2974
+ providerMap: Record<string, any> | null | undefined,
2975
+ availableProviders: Set<string>,
2976
+ ) {
2977
+ if (!providerMap || typeof providerMap !== "object") return;
2978
+ for (const [providerName, providerConfig] of Object.entries(providerMap)) {
2979
+ const normalizedProvider = String(providerName ?? "").trim().toLowerCase();
2980
+ if (!normalizedProvider || !availableProviders.has(normalizedProvider)) continue;
2981
+ const models = Array.isArray((providerConfig as any)?.models) ? (providerConfig as any).models : [];
2982
+ for (const model of models) {
2983
+ const modelId = String((model as any)?.id ?? "").trim();
2984
+ if (!modelId) continue;
2985
+ const modelRef = `${normalizedProvider}/${modelId}`;
2986
+ if (seen.has(modelRef)) continue;
2987
+ seen.add(modelRef);
2988
+ orderedRefs.push(modelRef);
2989
+ }
2990
+ }
2991
+ }
2992
+
2993
+ function setPreferredPrimaryModelRef(config: Record<string, any>, modelRef: string) {
2994
+ let changed = false;
2995
+ const defaultsModel = (((config.agents ??= {}).defaults ??= {}).model ??= {});
2996
+ if (defaultsModel.primary !== modelRef) {
2997
+ defaultsModel.primary = modelRef;
2998
+ changed = true;
2999
+ }
3000
+ if (!Array.isArray(defaultsModel.fallbacks)) {
3001
+ defaultsModel.fallbacks = [];
3002
+ changed = true;
3003
+ }
3004
+ const legacyRoots = [
3005
+ config.agent?.model,
3006
+ config.model,
3007
+ ];
3008
+ for (const root of legacyRoots) {
3009
+ if (!root || typeof root !== "object") continue;
3010
+ if (root.primary !== modelRef) {
3011
+ root.primary = modelRef;
3012
+ changed = true;
3013
+ }
3014
+ }
3015
+ return changed;
3016
+ }
3017
+
3018
+ function ensurePreferredModelEntry(config: Record<string, any>, modelRef: string) {
3019
+ const models = (((config.agents ??= {}).defaults ??= {}).models ??= {});
3020
+ if (models[modelRef] && typeof models[modelRef] === "object") {
3021
+ return false;
3022
+ }
3023
+ models[modelRef] = {};
3024
+ return true;
3025
+ }
3026
+
3027
+ function filterFallbackModelRefs(config: Record<string, any>, availableProviders: Set<string>) {
3028
+ let changed = false;
3029
+ const lists = [
3030
+ (((config.agents ??= {}).defaults ??= {}).model ??= {}).fallbacks,
3031
+ config.agent?.model?.fallbacks,
3032
+ config.model?.fallbacks,
3033
+ ];
3034
+ for (const current of lists) {
3035
+ if (!Array.isArray(current)) continue;
3036
+ const filtered = current.filter((value) => availableProviders.has(extractProviderFromModelRef(String(value ?? ""))));
3037
+ if (filtered.length !== current.length) {
3038
+ current.splice(0, current.length, ...filtered);
3039
+ changed = true;
3040
+ }
3041
+ }
3042
+ return changed;
3043
+ }
3044
+
2719
3045
  function syncReporterConfigToForum(
2720
3046
  sourceLocalConfig: ReturnType<typeof readLocalReporterConfig>,
2721
3047
  forumStateDir: string,
@@ -2771,7 +3097,7 @@ function syncOpenClawConfigs(
2771
3097
  changed = writeOpenClawConfig(defaultInfo.path, normalizedDefault) || changed;
2772
3098
  }
2773
3099
 
2774
- const forumBaseConfig = forumInfo.config ?? defaultInfo.config ?? {};
3100
+ const forumBaseConfig = mergeOpenClawConfig(defaultInfo.config ?? {}, forumInfo.config ?? {});
2775
3101
  const normalizedForum = normalizeForumHomeConfig(forumBaseConfig, forumStateDir, runtimeInfo, defaultInfo.config, forumInfo.config);
2776
3102
  changed = writeOpenClawConfig(targetForumConfigPath, normalizedForum) || changed;
2777
3103
  return changed;
@@ -2916,6 +3242,44 @@ function writeOpenClawConfig(filePath: string, nextConfig: Record<string, any>)
2916
3242
  return true;
2917
3243
  }
2918
3244
 
3245
+ function mergeOpenClawConfig(
3246
+ baseConfig: Record<string, any> | null | undefined,
3247
+ overlayConfig: Record<string, any> | null | undefined,
3248
+ ) {
3249
+ const base = cloneJson(baseConfig ?? {});
3250
+ const overlay = cloneJson(overlayConfig ?? {});
3251
+ return deepMergePlainObjects(base, overlay);
3252
+ }
3253
+
3254
+ function deepMergePlainObjects(base: any, overlay: any): any {
3255
+ if (Array.isArray(overlay)) return cloneJson(overlay);
3256
+ if (!overlay || typeof overlay !== "object") {
3257
+ return overlay === undefined ? cloneJson(base) : overlay;
3258
+ }
3259
+ const result = isPlainObject(base) ? cloneJson(base) : {};
3260
+ for (const [key, overlayValue] of Object.entries(overlay)) {
3261
+ const baseValue = result[key];
3262
+ if (Array.isArray(overlayValue)) {
3263
+ result[key] = cloneJson(overlayValue);
3264
+ continue;
3265
+ }
3266
+ if (isPlainObject(overlayValue) && isPlainObject(baseValue)) {
3267
+ result[key] = deepMergePlainObjects(baseValue, overlayValue);
3268
+ continue;
3269
+ }
3270
+ if (isPlainObject(overlayValue)) {
3271
+ result[key] = deepMergePlainObjects({}, overlayValue);
3272
+ continue;
3273
+ }
3274
+ result[key] = overlayValue;
3275
+ }
3276
+ return result;
3277
+ }
3278
+
3279
+ function isPlainObject(value: unknown) {
3280
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3281
+ }
3282
+
2919
3283
  function cloneJson<T>(input: T): T {
2920
3284
  try {
2921
3285
  return JSON.parse(JSON.stringify(input)) as T;