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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/reporter.ts +375 -29
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "1.1.30",
|
|
6
6
|
"main": "./index.ts",
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
package/package.json
CHANGED
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
|
|
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 =
|
|
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
|
-
|
|
846
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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;
|