@chrysb/alphaclaw 0.8.5 → 0.8.7-beta.0

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.
Files changed (45) hide show
  1. package/bin/alphaclaw.js +56 -20
  2. package/lib/public/css/explorer.css +48 -0
  3. package/lib/public/css/shell.css +149 -0
  4. package/lib/public/css/tailwind.generated.css +1 -1
  5. package/lib/public/css/theme.css +265 -0
  6. package/lib/public/dist/app.bundle.js +2441 -2352
  7. package/lib/public/js/app.js +7 -0
  8. package/lib/public/js/components/gateway.js +6 -3
  9. package/lib/public/js/components/general/index.js +2 -0
  10. package/lib/public/js/components/icons.js +38 -0
  11. package/lib/public/js/components/models-tab/provider-auth-card.js +60 -49
  12. package/lib/public/js/components/models-tab/use-models.js +74 -9
  13. package/lib/public/js/components/models.js +52 -37
  14. package/lib/public/js/components/onboarding/use-welcome-codex.js +34 -24
  15. package/lib/public/js/components/onboarding/welcome-config.js +76 -10
  16. package/lib/public/js/components/onboarding/welcome-form-step.js +31 -11
  17. package/lib/public/js/components/onboarding/welcome-header.js +12 -14
  18. package/lib/public/js/components/onboarding/welcome-setup-step.js +3 -3
  19. package/lib/public/js/components/providers.js +53 -42
  20. package/lib/public/js/components/routes/general-route.js +2 -0
  21. package/lib/public/js/components/routes/watchdog-route.js +2 -0
  22. package/lib/public/js/components/sidebar.js +29 -8
  23. package/lib/public/js/components/theme-toggle.js +113 -0
  24. package/lib/public/js/components/update-modal-helpers.js +12 -0
  25. package/lib/public/js/components/update-modal.js +2 -1
  26. package/lib/public/js/components/watchdog-tab/index.js +2 -0
  27. package/lib/public/js/components/welcome/index.js +1 -2
  28. package/lib/public/js/components/welcome/use-welcome.js +153 -38
  29. package/lib/public/js/hooks/use-app-shell-controller.js +33 -9
  30. package/lib/public/js/lib/api.js +35 -0
  31. package/lib/public/js/lib/codex-oauth-window.js +22 -0
  32. package/lib/public/js/lib/model-catalog.js +20 -0
  33. package/lib/public/js/lib/storage-keys.js +1 -1
  34. package/lib/public/login.html +8 -4
  35. package/lib/public/setup.html +9 -0
  36. package/lib/server/alphaclaw-version.js +30 -127
  37. package/lib/server/db/webhooks/index.js +48 -8
  38. package/lib/server/model-catalog-cache.js +251 -0
  39. package/lib/server/openclaw-version.js +59 -130
  40. package/lib/server/pending-alphaclaw-update.js +71 -0
  41. package/lib/server/pending-openclaw-update.js +71 -0
  42. package/lib/server/routes/models.js +14 -23
  43. package/lib/server/routes/system.js +6 -1
  44. package/lib/server/routes/webhooks.js +12 -1
  45. package/package.json +1 -1
@@ -527,6 +527,41 @@ export async function updateAlphaclaw() {
527
527
  return res.json();
528
528
  }
529
529
 
530
+ const delay = (ms) =>
531
+ new Promise((resolve) => {
532
+ setTimeout(resolve, Math.max(0, Number(ms) || 0));
533
+ });
534
+
535
+ export async function waitForAlphaclawRestart({
536
+ initialDelayMs = 1500,
537
+ intervalMs = 1000,
538
+ timeoutMs = 60000,
539
+ } = {}) {
540
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
541
+ await delay(initialDelayMs);
542
+
543
+ while (Date.now() <= deadline) {
544
+ try {
545
+ const headers = new Headers();
546
+ const browserTimeZone = getBrowserTimeZone();
547
+ if (browserTimeZone) {
548
+ headers.set(kClientTimeZoneHeader, browserTimeZone);
549
+ }
550
+ const res = await fetch("/api/auth/status", {
551
+ cache: "no-store",
552
+ credentials: "same-origin",
553
+ headers,
554
+ });
555
+ if (res.status < 500) {
556
+ return { ok: true };
557
+ }
558
+ } catch {}
559
+ await delay(intervalMs);
560
+ }
561
+
562
+ throw new Error("AlphaClaw restart is taking longer than expected");
563
+ }
564
+
530
565
  export async function fetchSyncCron() {
531
566
  const res = await authFetch("/api/sync-cron");
532
567
  const text = await res.text();
@@ -0,0 +1,22 @@
1
+ const kCodexAuthStartPath = "/auth/codex/start";
2
+ const kCodexAuthWindowName = "codex-auth";
3
+ const kCodexAuthPopupFeatures = "popup=yes,width=640,height=780";
4
+ const kCodexAuthCallbackMessageType = "callback-input";
5
+
6
+ export const openCodexAuthWindow = () => {
7
+ const popup = window.open(
8
+ kCodexAuthStartPath,
9
+ kCodexAuthWindowName,
10
+ kCodexAuthPopupFeatures,
11
+ );
12
+ if (!popup || popup.closed) {
13
+ window.location.href = kCodexAuthStartPath;
14
+ return null;
15
+ }
16
+ return popup;
17
+ };
18
+
19
+ export const isCodexAuthCallbackMessage = (value) =>
20
+ value?.codex === kCodexAuthCallbackMessageType &&
21
+ typeof value.input === "string" &&
22
+ value.input.trim().length > 0;
@@ -0,0 +1,20 @@
1
+ import { getFeaturedModels } from "./model-config.js";
2
+
3
+ export const kModelCatalogCacheKey = "/api/models";
4
+ export const kModelCatalogPollIntervalMs = 3000;
5
+
6
+ export const getModelCatalogModels = (payload) =>
7
+ Array.isArray(payload?.models) ? payload.models : [];
8
+
9
+ export const isModelCatalogRefreshing = (payload) =>
10
+ Boolean(payload?.refreshing);
11
+
12
+ export const getInitialOnboardingModelKey = ({
13
+ catalog = [],
14
+ currentModelKey = "",
15
+ } = {}) => {
16
+ const normalizedCurrent = String(currentModelKey || "").trim();
17
+ if (normalizedCurrent) return normalizedCurrent;
18
+ const featuredModels = getFeaturedModels(catalog);
19
+ return String(featuredModels[0]?.key || catalog[0]?.key || "");
20
+ };
@@ -6,6 +6,7 @@
6
6
 
7
7
  // --- UI settings (single JSON blob containing sub-keys) ---
8
8
  export const kUiSettingsStorageKey = "alphaclaw.ui.settings";
9
+ export const kThemeStorageKey = "alphaclaw.ui.theme";
9
10
 
10
11
  // --- Browse / file viewer ---
11
12
  export const kFileViewerModeStorageKey = "alphaclaw.browse.viewerMode";
@@ -30,4 +31,3 @@ export const kAgentLastSessionKey = "alphaclaw.agent.lastSessionKey";
30
31
 
31
32
  // --- Chat ---
32
33
  export const kChatSessionDraftsStorageKey = "alphaclaw.chat.sessionDrafts";
33
-
@@ -11,6 +11,14 @@
11
11
  <link rel="icon" type="image/svg+xml" href="./img/logo.svg" />
12
12
  <link rel="stylesheet" href="./css/theme.css" />
13
13
  <link rel="stylesheet" href="./css/tailwind.generated.css" />
14
+ <script>
15
+ try {
16
+ var t = localStorage.getItem("alphaclaw.ui.theme");
17
+ if (t === "system") t = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
18
+ else if (t !== "dark" && t !== "light") t = "dark";
19
+ document.documentElement.dataset.theme = t;
20
+ } catch {}
21
+ </script>
14
22
  </head>
15
23
  <body class="min-h-screen flex items-center justify-center p-4">
16
24
  <div class="max-w-sm w-full relative z-10">
@@ -53,10 +61,6 @@
53
61
  </form>
54
62
  </div>
55
63
  <script>
56
- try {
57
- window.localStorage?.clear?.();
58
- } catch {}
59
-
60
64
  const formEl = document.getElementById("login-form");
61
65
  const passwordEl = document.getElementById("password");
62
66
  const submitButtonEl = document.getElementById("submit-btn");
@@ -16,6 +16,15 @@
16
16
  <link rel="stylesheet" href="./css/agents.css" />
17
17
  <link rel="stylesheet" href="./css/chat.css" />
18
18
  <link rel="stylesheet" href="./css/cron.css" />
19
+ <script>
20
+ // Apply saved theme before render to prevent flash.
21
+ try {
22
+ var t = localStorage.getItem("alphaclaw.ui.theme");
23
+ if (t === "system") t = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
24
+ else if (t !== "dark" && t !== "light") t = "dark";
25
+ document.documentElement.dataset.theme = t;
26
+ } catch {}
27
+ </script>
19
28
  </head>
20
29
  <body>
21
30
  <div id="app"></div>
@@ -1,6 +1,4 @@
1
- const childProcess = require("child_process");
2
1
  const fs = require("fs");
3
- const os = require("os");
4
2
  const path = require("path");
5
3
  const https = require("https");
6
4
  const http = require("http");
@@ -8,7 +6,6 @@ const {
8
6
  kLatestVersionCacheTtlMs,
9
7
  kAlphaclawRegistryUrl,
10
8
  kNpmPackageRoot,
11
- kOpenclawUpdateCopyTimeoutMs,
12
9
  kRootDir,
13
10
  } = require("./constants");
14
11
 
@@ -26,6 +23,9 @@ const isNewerVersion = (latest, current) => {
26
23
  return l.patch > c.patch;
27
24
  };
28
25
 
26
+ const buildAlphaclawInstallSpec = (version = "latest") =>
27
+ `@chrysb/alphaclaw@${String(version || "").trim() || "latest"}`;
28
+
29
29
  const createAlphaclawVersionService = () => {
30
30
  let kUpdateStatusCache = {
31
31
  latestVersion: null,
@@ -108,120 +108,6 @@ const createAlphaclawVersionService = () => {
108
108
  return { latestVersion, hasUpdate };
109
109
  };
110
110
 
111
- const findInstallDir = () => {
112
- // Walk up from kNpmPackageRoot to find the consuming project's directory
113
- // (the one with node_modules/@chrysb/alphaclaw). In Docker this is /app.
114
- let dir = kNpmPackageRoot;
115
- while (dir !== path.dirname(dir)) {
116
- const parent = path.dirname(dir);
117
- if (
118
- path.basename(parent) === "node_modules" ||
119
- parent.includes(`${path.sep}node_modules${path.sep}`)
120
- ) {
121
- dir = parent;
122
- continue;
123
- }
124
- const pkgPath = path.join(parent, "package.json");
125
- if (fs.existsSync(pkgPath)) {
126
- try {
127
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
128
- if (
129
- pkg.dependencies?.["@chrysb/alphaclaw"] ||
130
- pkg.devDependencies?.["@chrysb/alphaclaw"] ||
131
- pkg.optionalDependencies?.["@chrysb/alphaclaw"]
132
- ) {
133
- return parent;
134
- }
135
- } catch {}
136
- }
137
- dir = parent;
138
- }
139
- // Fallback: if running directly (not from node_modules), use kNpmPackageRoot
140
- return kNpmPackageRoot;
141
- };
142
-
143
- const installLatestAlphaclaw = () =>
144
- new Promise((resolve, reject) => {
145
- const installDir = findInstallDir();
146
- const tmpDir = fs.mkdtempSync(
147
- path.join(os.tmpdir(), "alphaclaw-update-"),
148
- );
149
-
150
- const cleanup = () => {
151
- try {
152
- fs.rmSync(tmpDir, { recursive: true, force: true });
153
- } catch {}
154
- };
155
-
156
- fs.writeFileSync(
157
- path.join(tmpDir, "package.json"),
158
- JSON.stringify({
159
- private: true,
160
- dependencies: { "@chrysb/alphaclaw": "latest" },
161
- }),
162
- );
163
-
164
- const npmEnv = {
165
- ...process.env,
166
- npm_config_update_notifier: "false",
167
- npm_config_fund: "false",
168
- npm_config_audit: "false",
169
- };
170
-
171
- console.log(
172
- `[alphaclaw] Running: npm install @chrysb/alphaclaw@latest in temp dir (target: ${installDir})`,
173
- );
174
- childProcess.exec(
175
- "npm install --omit=dev --prefer-online --package-lock=false",
176
- {
177
- cwd: tmpDir,
178
- env: npmEnv,
179
- timeout: 180000,
180
- },
181
- (err, stdout, stderr) => {
182
- if (err) {
183
- const message = String(stderr || err.message || "").trim();
184
- console.log(
185
- `[alphaclaw] alphaclaw install error: ${message.slice(0, 200)}`,
186
- );
187
- cleanup();
188
- return reject(
189
- new Error(
190
- message || "Failed to install @chrysb/alphaclaw@latest",
191
- ),
192
- );
193
- }
194
- if (stdout?.trim()) {
195
- console.log(
196
- `[alphaclaw] alphaclaw install stdout: ${stdout.trim().slice(0, 300)}`,
197
- );
198
- }
199
-
200
- const src = path.join(tmpDir, "node_modules");
201
- const dest = path.join(installDir, "node_modules");
202
- childProcess.exec(
203
- `cp -af "${src}/." "${dest}/"`,
204
- { timeout: kOpenclawUpdateCopyTimeoutMs },
205
- (copyErr) => {
206
- cleanup();
207
- if (copyErr) {
208
- console.log(
209
- `[alphaclaw] alphaclaw copy error: ${(copyErr.message || "").slice(0, 200)}`,
210
- );
211
- return reject(
212
- new Error(
213
- `Failed to copy updated AlphaClaw files: ${copyErr.message}`,
214
- ),
215
- );
216
- }
217
- console.log("[alphaclaw] alphaclaw install completed");
218
- resolve({ stdout: stdout?.trim(), stderr: stderr?.trim() });
219
- },
220
- );
221
- },
222
- );
223
- });
224
-
225
111
  const isContainer = () =>
226
112
  process.env.RAILWAY_ENVIRONMENT ||
227
113
  process.env.RENDER ||
@@ -277,18 +163,33 @@ const createAlphaclawVersionService = () => {
277
163
  kUpdateInProgress = true;
278
164
  const previousVersion = readAlphaclawVersion();
279
165
  try {
280
- await installLatestAlphaclaw();
281
- // Write marker to persistent volume so the update survives container recreation
282
- const markerPath = path.join(kRootDir, ".alphaclaw-update-pending");
166
+ let targetVersion = "latest";
283
167
  try {
284
- fs.writeFileSync(
285
- markerPath,
286
- JSON.stringify({ from: previousVersion, ts: Date.now() }),
168
+ const updateStatus = await readAlphaclawUpdateStatus({ refresh: true });
169
+ if (updateStatus.latestVersion) {
170
+ targetVersion = updateStatus.latestVersion;
171
+ }
172
+ } catch (error) {
173
+ console.log(
174
+ `[alphaclaw] Could not resolve exact AlphaClaw version before restart: ${error.message || "unknown error"}`,
287
175
  );
288
- console.log(`[alphaclaw] Update marker written to ${markerPath}`);
289
- } catch (e) {
290
- console.log(`[alphaclaw] Could not write update marker: ${e.message}`);
291
176
  }
177
+
178
+ const spec = buildAlphaclawInstallSpec(targetVersion);
179
+ // Write marker to persistent volume so the update survives container recreation
180
+ const markerPath = path.join(kRootDir, ".alphaclaw-update-pending");
181
+ fs.writeFileSync(
182
+ markerPath,
183
+ JSON.stringify({
184
+ from: previousVersion,
185
+ to: targetVersion,
186
+ spec,
187
+ ts: Date.now(),
188
+ }),
189
+ );
190
+ console.log(
191
+ `[alphaclaw] Update marker written to ${markerPath} for ${spec}`,
192
+ );
292
193
  kUpdateStatusCache = {
293
194
  latestVersion: null,
294
195
  hasUpdate: false,
@@ -299,15 +200,17 @@ const createAlphaclawVersionService = () => {
299
200
  body: {
300
201
  ok: true,
301
202
  previousVersion,
203
+ targetVersion: targetVersion === "latest" ? null : targetVersion,
302
204
  restarting: true,
303
205
  },
304
206
  };
305
207
  } catch (err) {
306
- kUpdateInProgress = false;
307
208
  return {
308
209
  status: 500,
309
210
  body: { ok: false, error: err.message || "Failed to update AlphaClaw" },
310
211
  };
212
+ } finally {
213
+ kUpdateInProgress = false;
311
214
  }
312
215
  };
313
216
 
@@ -10,6 +10,7 @@ let pruneTimer = null;
10
10
  const kDefaultRequestLimit = 50;
11
11
  const kMaxRequestLimit = 200;
12
12
  const kPruneIntervalMs = 12 * 60 * 60 * 1000;
13
+ const kHealthSummaryWindow = 25;
13
14
 
14
15
  const ensureDb = () => {
15
16
  if (!db) throw new Error("Webhooks DB not initialized");
@@ -202,22 +203,61 @@ const getHookSummaries = () => {
202
203
  const database = ensureDb();
203
204
  const rows = database
204
205
  .prepare(`
206
+ WITH ranked_requests AS (
207
+ SELECT
208
+ hook_name,
209
+ created_at,
210
+ gateway_status,
211
+ ROW_NUMBER() OVER (
212
+ PARTITION BY hook_name
213
+ ORDER BY created_at DESC, id DESC
214
+ ) AS row_num
215
+ FROM webhook_requests
216
+ ),
217
+ overall_counts AS (
218
+ SELECT
219
+ hook_name,
220
+ MAX(created_at) AS last_received,
221
+ COUNT(*) AS total_count,
222
+ SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS success_count,
223
+ SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS error_count
224
+ FROM webhook_requests
225
+ GROUP BY hook_name
226
+ ),
227
+ recent_counts AS (
228
+ SELECT
229
+ hook_name,
230
+ COUNT(*) AS recent_total_count,
231
+ SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS recent_success_count,
232
+ SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS recent_error_count
233
+ FROM ranked_requests
234
+ WHERE row_num <= $health_window
235
+ GROUP BY hook_name
236
+ )
205
237
  SELECT
206
- hook_name,
207
- MAX(created_at) AS last_received,
208
- COUNT(*) AS total_count,
209
- SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS success_count,
210
- SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS error_count
211
- FROM webhook_requests
212
- GROUP BY hook_name
238
+ overall_counts.hook_name,
239
+ overall_counts.last_received,
240
+ overall_counts.total_count,
241
+ overall_counts.success_count,
242
+ overall_counts.error_count,
243
+ COALESCE(recent_counts.recent_total_count, 0) AS recent_total_count,
244
+ COALESCE(recent_counts.recent_success_count, 0) AS recent_success_count,
245
+ COALESCE(recent_counts.recent_error_count, 0) AS recent_error_count
246
+ FROM overall_counts
247
+ LEFT JOIN recent_counts
248
+ ON recent_counts.hook_name = overall_counts.hook_name
213
249
  `)
214
- .all();
250
+ .all({ $health_window: kHealthSummaryWindow });
215
251
  return rows.map((row) => ({
216
252
  hookName: row.hook_name,
217
253
  lastReceived: row.last_received || null,
218
254
  totalCount: Number(row.total_count || 0),
219
255
  successCount: Number(row.success_count || 0),
220
256
  errorCount: Number(row.error_count || 0),
257
+ recentTotalCount: Number(row.recent_total_count || 0),
258
+ recentSuccessCount: Number(row.recent_success_count || 0),
259
+ recentErrorCount: Number(row.recent_error_count || 0),
260
+ healthWindowSize: kHealthSummaryWindow,
221
261
  }));
222
262
  };
223
263
 
@@ -0,0 +1,251 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { ALPHACLAW_DIR, kFallbackOnboardingModels } = require("./constants");
4
+
5
+ const kModelCatalogCacheVersion = 1;
6
+ const kModelCatalogRefreshBackoffMs = 30 * 1000;
7
+ const kDefaultCachePath = path.join(ALPHACLAW_DIR, "cache", "model-catalog.json");
8
+
9
+ const createResponse = ({
10
+ source = "fallback",
11
+ fetchedAt = null,
12
+ stale = false,
13
+ refreshing = false,
14
+ models = [],
15
+ } = {}) => ({
16
+ ok: true,
17
+ source,
18
+ fetchedAt,
19
+ stale,
20
+ refreshing,
21
+ models,
22
+ });
23
+
24
+ const normalizeCachedModels = ({
25
+ models,
26
+ normalizeOnboardingModels = (items) => items,
27
+ } = {}) =>
28
+ normalizeOnboardingModels(
29
+ (Array.isArray(models) ? models : []).map((model) => ({
30
+ key: model?.key,
31
+ name: model?.label || model?.name || model?.key,
32
+ })),
33
+ );
34
+
35
+ const normalizeCacheEntry = ({
36
+ raw,
37
+ normalizeOnboardingModels = (items) => items,
38
+ } = {}) => {
39
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
40
+ const fetchedAt = Number(raw.fetchedAt || 0);
41
+ const models = normalizeCachedModels({
42
+ models: raw.models,
43
+ normalizeOnboardingModels,
44
+ });
45
+ if (!Number.isFinite(fetchedAt) || fetchedAt <= 0 || models.length === 0) {
46
+ return null;
47
+ }
48
+ return {
49
+ version: kModelCatalogCacheVersion,
50
+ fetchedAt,
51
+ models,
52
+ };
53
+ };
54
+
55
+ const createModelCatalogCache = ({
56
+ fsModule = fs,
57
+ pathModule = path,
58
+ shellCmd,
59
+ gatewayEnv = () => ({}),
60
+ parseJsonFromNoisyOutput = () => ({}),
61
+ normalizeOnboardingModels = (items) => items,
62
+ fallbackModels = kFallbackOnboardingModels,
63
+ cachePath = kDefaultCachePath,
64
+ refreshBackoffMs = kModelCatalogRefreshBackoffMs,
65
+ now = () => Date.now(),
66
+ setTimeoutFn = setTimeout,
67
+ clearTimeoutFn = clearTimeout,
68
+ logger = console,
69
+ } = {}) => {
70
+ let cacheLoaded = false;
71
+ let memoryCache = null;
72
+ let cacheIsStale = false;
73
+ let refreshPromise = null;
74
+ let retryTimer = null;
75
+ let backoffUntilMs = 0;
76
+
77
+ const clearRetryTimer = () => {
78
+ if (!retryTimer) return;
79
+ clearTimeoutFn(retryTimer);
80
+ retryTimer = null;
81
+ };
82
+
83
+ const isRefreshPending = () => !!refreshPromise || !!retryTimer;
84
+
85
+ const setCacheEntry = (entry, { fresh = false } = {}) => {
86
+ memoryCache = entry;
87
+ cacheLoaded = true;
88
+ cacheIsStale = !fresh;
89
+ backoffUntilMs = 0;
90
+ clearRetryTimer();
91
+ return memoryCache;
92
+ };
93
+
94
+ const readDiskCache = () => {
95
+ if (cacheLoaded) return memoryCache;
96
+ cacheLoaded = true;
97
+ try {
98
+ const raw = JSON.parse(fsModule.readFileSync(cachePath, "utf8"));
99
+ const entry = normalizeCacheEntry({
100
+ raw,
101
+ normalizeOnboardingModels,
102
+ });
103
+ if (!entry) return null;
104
+ memoryCache = entry;
105
+ cacheIsStale = true;
106
+ return memoryCache;
107
+ } catch {
108
+ memoryCache = null;
109
+ cacheIsStale = false;
110
+ return null;
111
+ }
112
+ };
113
+
114
+ const writeDiskCache = (entry) => {
115
+ fsModule.mkdirSync(pathModule.dirname(cachePath), { recursive: true });
116
+ fsModule.writeFileSync(
117
+ cachePath,
118
+ `${JSON.stringify(entry, null, 2)}\n`,
119
+ "utf8",
120
+ );
121
+ };
122
+
123
+ const loadFreshCatalog = async () => {
124
+ const output = await shellCmd("openclaw models list --all --json", {
125
+ env: gatewayEnv(),
126
+ timeout: 20000,
127
+ });
128
+ const parsed = parseJsonFromNoisyOutput(output);
129
+ const models = normalizeOnboardingModels(parsed?.models || []);
130
+ if (models.length === 0) {
131
+ throw new Error("No models found");
132
+ }
133
+ const entry = {
134
+ version: kModelCatalogCacheVersion,
135
+ fetchedAt: now(),
136
+ models,
137
+ };
138
+ writeDiskCache(entry);
139
+ setCacheEntry(entry, { fresh: true });
140
+ return entry;
141
+ };
142
+
143
+ const scheduleRetry = () => {
144
+ if (!memoryCache || retryTimer) return;
145
+ const delayMs = Math.max(backoffUntilMs - now(), 0);
146
+ retryTimer = setTimeoutFn(() => {
147
+ retryTimer = null;
148
+ if (!memoryCache || !cacheIsStale || refreshPromise) return;
149
+ void startBackgroundRefresh();
150
+ }, delayMs);
151
+ if (typeof retryTimer?.unref === "function") retryTimer.unref();
152
+ };
153
+
154
+ const handleRefreshFailure = (err) => {
155
+ if (memoryCache) {
156
+ cacheIsStale = true;
157
+ backoffUntilMs = now() + refreshBackoffMs;
158
+ scheduleRetry();
159
+ logger.error?.(
160
+ `[models] Failed to refresh cached models: ${err.message || String(err)}`,
161
+ );
162
+ return;
163
+ }
164
+ logger.error?.(
165
+ `[models] Failed to load dynamic models: ${err.message || String(err)}`,
166
+ );
167
+ };
168
+
169
+ const startBackgroundRefresh = () => {
170
+ readDiskCache();
171
+ if (!memoryCache) return null;
172
+ if (refreshPromise) return refreshPromise;
173
+ if (retryTimer) return null;
174
+ if (backoffUntilMs > now()) {
175
+ scheduleRetry();
176
+ return null;
177
+ }
178
+ refreshPromise = Promise.resolve()
179
+ .then(() => loadFreshCatalog())
180
+ .catch((err) => {
181
+ handleRefreshFailure(err);
182
+ return null;
183
+ })
184
+ .finally(() => {
185
+ refreshPromise = null;
186
+ });
187
+ return refreshPromise;
188
+ };
189
+
190
+ return {
191
+ async getCatalogResponse() {
192
+ readDiskCache();
193
+ if (memoryCache && !cacheIsStale) {
194
+ return createResponse({
195
+ source: "openclaw",
196
+ fetchedAt: memoryCache.fetchedAt,
197
+ stale: false,
198
+ refreshing: false,
199
+ models: memoryCache.models,
200
+ });
201
+ }
202
+ if (memoryCache) {
203
+ startBackgroundRefresh();
204
+ return createResponse({
205
+ source: "cache",
206
+ fetchedAt: memoryCache.fetchedAt,
207
+ stale: true,
208
+ refreshing: isRefreshPending(),
209
+ models: memoryCache.models,
210
+ });
211
+ }
212
+ try {
213
+ const freshEntry = await loadFreshCatalog();
214
+ return createResponse({
215
+ source: "openclaw",
216
+ fetchedAt: freshEntry.fetchedAt,
217
+ stale: false,
218
+ refreshing: false,
219
+ models: freshEntry.models,
220
+ });
221
+ } catch (err) {
222
+ handleRefreshFailure(err);
223
+ return createResponse({
224
+ source: "fallback",
225
+ fetchedAt: null,
226
+ stale: false,
227
+ refreshing: false,
228
+ models: fallbackModels,
229
+ });
230
+ }
231
+ },
232
+
233
+ markStale() {
234
+ readDiskCache();
235
+ if (!memoryCache) return;
236
+ cacheIsStale = true;
237
+ backoffUntilMs = 0;
238
+ clearRetryTimer();
239
+ },
240
+ };
241
+ };
242
+
243
+ module.exports = {
244
+ createModelCatalogCache,
245
+ createResponse,
246
+ normalizeCachedModels,
247
+ normalizeCacheEntry,
248
+ kModelCatalogCacheVersion,
249
+ kModelCatalogRefreshBackoffMs,
250
+ kDefaultCachePath,
251
+ };