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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/reporter.ts +393 -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";
|
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
828
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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;
|