openclaw-clawtown-plugin 1.1.27 → 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.27",
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.27",
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";
@@ -162,6 +165,14 @@ interface AuthHealthCheck {
162
165
  reason: string;
163
166
  }
164
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
+
165
176
  interface SyncProfileResult {
166
177
  ok: boolean;
167
178
  status?: number;
@@ -234,6 +245,7 @@ class Reporter {
234
245
  private activeTaskDedupKey = "";
235
246
  private queuedTaskDedupKeys = new Set<string>();
236
247
  private pendingContextUpdates = new Map<string, PendingQuestionContextUpdate>();
248
+ private locallySuppressedMineTasks = new Map<string, number>();
237
249
  private sessionHintLogged = false;
238
250
  private instanceLockPath: string | null = null;
239
251
  private instanceLockHeld = false;
@@ -292,7 +304,14 @@ class Reporter {
292
304
  isolationMode: this.openClawIsolationMode,
293
305
  isolationActive: Boolean(this.forcedOpenClawHome),
294
306
  };
295
- 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
+ }
296
315
  this.authHealth = authHealth;
297
316
  if (!authHealth.ok) {
298
317
  console.error(`[forum-reporter-v2] ${formatAuthHealthFailure(authHealth)}`);
@@ -638,6 +657,10 @@ class Reporter {
638
657
  if (message.event === "task_push") {
639
658
  const qid = String((message.payload as any)?.questionId ?? "").trim();
640
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
+ }
641
664
  const dedupKey = buildTaskDedupKey(message);
642
665
  this.lastTaskPushAt = Date.now();
643
666
  if (dedupKey && (this.activeTaskDedupKey === dedupKey || this.queuedTaskDedupKeys.has(dedupKey))) {
@@ -837,13 +860,19 @@ class Reporter {
837
860
  if (!serverFeedback) return;
838
861
  console.warn(`[forum-reporter-v2] task feedback: ${serverFeedback.replace(/\s+/g, " ").trim()}`);
839
862
  console.warn(`[forum-reporter-v2] submit rejected for taskType=${taskType}, retry once with server feedback`);
840
- const retryInstructions = buildRetryInstructions(baseInstructions, serverFeedback);
863
+ const retryInstructions = buildSubmitRetryInstructions(taskType, baseInstructions, serverFeedback, normalized.payload);
841
864
  let retryOutput = "";
842
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;
843
872
  retryOutput = await runTaskTurn(
844
873
  retryInstructions,
845
- TASK_FIRST_TURN_TIMEOUT_SECONDS,
846
- TASK_FIRST_TURN_TIMEOUT_MS,
874
+ submitRetryTimeoutSeconds,
875
+ submitRetryTimeoutMs,
847
876
  `${tid || qid || taskType}-submit-retry1`,
848
877
  );
849
878
  } catch (error: any) {
@@ -904,6 +933,12 @@ class Reporter {
904
933
  if (!submitRes.ok) {
905
934
  const reasonCode = String(input.payload?.reasonCode ?? "");
906
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
+ }
907
942
  const logLine = `[forum-reporter-v2] action-response failed: ${submitRes.status} ${rawText}${reasonCode ? ` (reasonCode=${reasonCode})` : ""}`;
908
943
  if (isExpectedSubmitConflict(bodyError)) {
909
944
  console.log(logLine);
@@ -972,7 +1007,7 @@ class Reporter {
972
1007
  openClawIdentity: identity ?? undefined,
973
1008
  reporterRuntime: this.reporterRuntime,
974
1009
  }),
975
- });
1010
+ }, { retryOnNetworkError: true });
976
1011
  if (!res.ok) {
977
1012
  const text = await res.text().catch(() => "");
978
1013
  return {
@@ -1039,7 +1074,7 @@ class Reporter {
1039
1074
  const res = await this.forumFetch("/api/regenerate-pair-code", {
1040
1075
  method: "POST",
1041
1076
  headers: { "x-api-key": this.apiKey },
1042
- });
1077
+ }, { retryOnNetworkError: true });
1043
1078
  if (!res.ok) {
1044
1079
  const text = await res.text().catch(() => "");
1045
1080
  const error = new Error(`regenerate pair code failed: ${res.status} ${truncate(text || "request failed", 240)}`);
@@ -1078,7 +1113,7 @@ class Reporter {
1078
1113
  method: "GET",
1079
1114
  headers: { "x-api-key": this.apiKey },
1080
1115
  cache: "no-store",
1081
- });
1116
+ }, { retryOnNetworkError: true });
1082
1117
  if (res.status === 204) return;
1083
1118
  if (!res.ok) {
1084
1119
  const text = await res.text().catch(() => "");
@@ -1311,9 +1346,52 @@ class Reporter {
1311
1346
  this.instanceLockHeld = false;
1312
1347
  }
1313
1348
 
1314
- 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
+ ) {
1315
1358
  const target = new URL(inputPath, this.serverUrl).toString();
1316
- 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);
1317
1395
  }
1318
1396
  }
1319
1397
 
@@ -1753,6 +1831,53 @@ function buildRetryInstructions(baseInstructions: string, feedback: string) {
1753
1831
  ].join("\n");
1754
1832
  }
1755
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
+
1756
1881
  function buildFallbackTaskInstructions(taskType: PushTaskType, payload: Record<string, any>) {
1757
1882
  if (taskType === "answer_question") {
1758
1883
  const minLength = Math.max(MIN_ANSWER_LENGTH, Number(payload.minLength ?? MIN_ANSWER_LENGTH) || MIN_ANSWER_LENGTH);
@@ -2515,21 +2640,36 @@ function resolveStateDirForConfiguredHome(openclawHome: string) {
2515
2640
  function inspectAuthHealthForStateDir(stateDir: string): AuthHealthCheck {
2516
2641
  const configInfo = readOpenClawConfigInfo(stateDir);
2517
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 {
2518
2662
  const storePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
2519
2663
  const modelsPath = path.join(stateDir, "agents", "main", "agent", "models.json");
2520
- const availableProviders = new Set<string>(readInlineCredentialProviders(stateDir, configInfo.config));
2664
+ const availableProviders = new Set<string>(readInlineCredentialProviders(stateDir, config));
2521
2665
  const invalidProfileIds: string[] = [];
2522
2666
  if (!fs.existsSync(storePath)) {
2523
- const missingProviders = requiredProviders.filter((provider) => !availableProviders.has(provider));
2524
2667
  return {
2525
- ok: missingProviders.length === 0,
2526
2668
  storePath,
2527
2669
  modelsPath,
2528
- requiredProviders,
2529
2670
  availableProviders: Array.from(availableProviders),
2530
2671
  invalidProfileIds,
2531
- missingProviders,
2532
- reason: missingProviders.length ? "missing_store" : "ok",
2672
+ storeStatus: "missing",
2533
2673
  };
2534
2674
  }
2535
2675
  let parsed: Record<string, any> | null = null;
@@ -2537,14 +2677,11 @@ function inspectAuthHealthForStateDir(stateDir: string): AuthHealthCheck {
2537
2677
  parsed = JSON.parse(fs.readFileSync(storePath, "utf-8"));
2538
2678
  } catch {
2539
2679
  return {
2540
- ok: false,
2541
2680
  storePath,
2542
2681
  modelsPath,
2543
- requiredProviders,
2544
2682
  availableProviders: Array.from(availableProviders),
2545
2683
  invalidProfileIds,
2546
- missingProviders: requiredProviders.filter((provider) => !availableProviders.has(provider)),
2547
- reason: "invalid_json",
2684
+ storeStatus: "invalid_json",
2548
2685
  };
2549
2686
  }
2550
2687
  const profiles = parsed?.profiles && typeof parsed.profiles === "object"
@@ -2552,14 +2689,11 @@ function inspectAuthHealthForStateDir(stateDir: string): AuthHealthCheck {
2552
2689
  : null;
2553
2690
  if (!profiles) {
2554
2691
  return {
2555
- ok: false,
2556
2692
  storePath,
2557
2693
  modelsPath,
2558
- requiredProviders,
2559
2694
  availableProviders: Array.from(availableProviders),
2560
2695
  invalidProfileIds,
2561
- missingProviders: requiredProviders.filter((provider) => !availableProviders.has(provider)),
2562
- reason: "invalid_store",
2696
+ storeStatus: "invalid_store",
2563
2697
  };
2564
2698
  }
2565
2699
  for (const [profileId, profile] of Object.entries(profiles)) {
@@ -2570,16 +2704,12 @@ function inspectAuthHealthForStateDir(stateDir: string): AuthHealthCheck {
2570
2704
  }
2571
2705
  availableProviders.add(normalized.provider);
2572
2706
  }
2573
- const missingProviders = requiredProviders.filter((provider) => !availableProviders.has(provider));
2574
2707
  return {
2575
- ok: missingProviders.length === 0,
2576
2708
  storePath,
2577
2709
  modelsPath,
2578
- requiredProviders,
2579
2710
  availableProviders: Array.from(availableProviders),
2580
2711
  invalidProfileIds,
2581
- missingProviders,
2582
- reason: missingProviders.length ? "missing_provider" : "ok",
2712
+ storeStatus: "ok",
2583
2713
  };
2584
2714
  }
2585
2715
 
@@ -2734,6 +2864,184 @@ function formatAuthHealthFailure(health: AuthHealthCheck) {
2734
2864
  return parts.join(" | ");
2735
2865
  }
2736
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
+
2737
3045
  function syncReporterConfigToForum(
2738
3046
  sourceLocalConfig: ReturnType<typeof readLocalReporterConfig>,
2739
3047
  forumStateDir: string,
@@ -2789,7 +3097,7 @@ function syncOpenClawConfigs(
2789
3097
  changed = writeOpenClawConfig(defaultInfo.path, normalizedDefault) || changed;
2790
3098
  }
2791
3099
 
2792
- const forumBaseConfig = forumInfo.config ?? defaultInfo.config ?? {};
3100
+ const forumBaseConfig = mergeOpenClawConfig(defaultInfo.config ?? {}, forumInfo.config ?? {});
2793
3101
  const normalizedForum = normalizeForumHomeConfig(forumBaseConfig, forumStateDir, runtimeInfo, defaultInfo.config, forumInfo.config);
2794
3102
  changed = writeOpenClawConfig(targetForumConfigPath, normalizedForum) || changed;
2795
3103
  return changed;
@@ -2934,6 +3242,44 @@ function writeOpenClawConfig(filePath: string, nextConfig: Record<string, any>)
2934
3242
  return true;
2935
3243
  }
2936
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
+
2937
3283
  function cloneJson<T>(input: T): T {
2938
3284
  try {
2939
3285
  return JSON.parse(JSON.stringify(input)) as T;