limbo-ai 1.31.0 → 1.32.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.
package/.gitlab-ci.yml CHANGED
@@ -186,6 +186,7 @@ release:
186
186
  git add package.json
187
187
  git commit -m "chore: release v${VERSION} [skip ci]"
188
188
  git tag "v${VERSION}"
189
+ git checkout -B main
189
190
  git push origin main --follow-tags
190
191
 
191
192
  # Create GitLab Release
package/RELEASES.md ADDED
@@ -0,0 +1,32 @@
1
+ # Limbo Releases
2
+
3
+ > User-facing changelog. The section for the latest version (above the first `---`)
4
+ > is sent to users as the update notification in Telegram. Keep it non-technical
5
+ > and human-readable. Technical details go below the `---` separator.
6
+ >
7
+ > Format:
8
+ > ```
9
+ > ## vX.Y.Z
10
+ >
11
+ > - Human-readable change 1
12
+ > - Human-readable change 2
13
+ >
14
+ > ---
15
+ >
16
+ > ### Technical changelog
17
+ > - feat: technical description (#PR)
18
+ > - fix: technical description (#PR)
19
+ > ```
20
+
21
+ ## v1.30.0
22
+
23
+ - Limbo now notifies you when a new version is available
24
+ - You can update directly from Telegram with one tap
25
+ - Improved startup reliability
26
+
27
+ ---
28
+
29
+ ### Technical changelog
30
+ - feat: update notification system with wakeup routine
31
+ - feat: update_instance MCP tool + flag-file bridge to host
32
+ - feat: telegram-notify lib for deterministic system messages
package/cli.js CHANGED
@@ -23,8 +23,10 @@ const LIMBO_DIR = (() => {
23
23
  })();
24
24
  const VAULT_DIR = path.join(LIMBO_DIR, 'vault');
25
25
  const OPENCLAW_STATE_DIR = path.join(LIMBO_DIR, 'openclaw-state');
26
+ const FLAGS_DIR = path.join(LIMBO_DIR, 'flags');
26
27
  const SECRETS_DIR = path.join(LIMBO_DIR, 'secrets');
27
- const ENV_FILE = path.join(LIMBO_DIR, '.env');
28
+ const CONFIG_DIR = path.join(LIMBO_DIR, 'config');
29
+ const ENV_FILE = path.join(CONFIG_DIR, '.env');
28
30
  const COMPOSE_FILE = path.join(LIMBO_DIR, 'docker-compose.yml');
29
31
  const DEFAULT_REGISTRY = 'registry.gitlab.com/tomas209/limbo';
30
32
  const REGISTRY_IMAGE = process.env.LIMBO_REGISTRY || DEFAULT_REGISTRY;
@@ -184,6 +186,8 @@ function composeContent() {
184
186
  - limbo-data:/data
185
187
  - ${VAULT_DIR}:/data/vault
186
188
  - ${OPENCLAW_STATE_DIR}:/home/limbo/.openclaw
189
+ - ${CONFIG_DIR}:/data/config
190
+ - ${FLAGS_DIR}:/flags
187
191
  secrets:
188
192
  - llm_api_key
189
193
  - telegram_bot_token
@@ -191,14 +195,14 @@ function composeContent() {
191
195
  - groq_api_key
192
196
  - brave_api_key
193
197
  env_file:
194
- - ${LIMBO_DIR}/.env
198
+ - ${ENV_FILE}
195
199
  environment:
196
200
  LIMBO_PORT: "${PORT}"
197
201
  NODE_OPTIONS: "\${LIMBO_NODE_OPTIONS:---max-old-space-size=512}"
198
202
  ${resolveExtraEnv()} healthcheck:
199
203
  test:
200
204
  - CMD-SHELL
201
- - node -e "fetch('http://localhost:'\${LIMBO_PORT:-18789}'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
205
+ - node -e "fetch('http://localhost:${PORT}/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
202
206
  interval: 10s
203
207
  timeout: 5s
204
208
  retries: 3
@@ -246,6 +250,8 @@ function composeContentHardened() {
246
250
  - limbo-data:/data
247
251
  - ${VAULT_DIR}:/data/vault
248
252
  - ${OPENCLAW_STATE_DIR}:/home/limbo/.openclaw
253
+ - ${CONFIG_DIR}:/data/config
254
+ - ${FLAGS_DIR}:/flags
249
255
  secrets:
250
256
  - llm_api_key
251
257
  - telegram_bot_token
@@ -253,7 +259,7 @@ function composeContentHardened() {
253
259
  - groq_api_key
254
260
  - brave_api_key
255
261
  env_file:
256
- - ${LIMBO_DIR}/.env
262
+ - ${ENV_FILE}
257
263
  environment:
258
264
  LIMBO_PORT: "${PORT}"
259
265
  NODE_OPTIONS: "\${LIMBO_NODE_OPTIONS:---max-old-space-size=512}"
@@ -265,7 +271,7 @@ ${resolveExtraEnv()} networks:
265
271
  healthcheck:
266
272
  test:
267
273
  - CMD-SHELL
268
- - node -e "fetch('http://localhost:'\${LIMBO_PORT:-18789}'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
274
+ - node -e "fetch('http://localhost:${PORT}/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
269
275
  interval: 10s
270
276
  timeout: 5s
271
277
  retries: 3
@@ -1105,8 +1111,17 @@ function ensureComposeFile(hardened = false) {
1105
1111
  fs.mkdirSync(path.join(VAULT_DIR, 'notes'), { recursive: true });
1106
1112
  fs.mkdirSync(path.join(VAULT_DIR, 'maps'), { recursive: true });
1107
1113
  fs.mkdirSync(OPENCLAW_STATE_DIR, { recursive: true });
1114
+ fs.mkdirSync(FLAGS_DIR, { recursive: true });
1108
1115
  migrateLegacyState();
1109
1116
  fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
1117
+ // Ensure config dir and .env exist (bind-mounted into container as /data/config/)
1118
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
1119
+ // Migrate legacy .env from LIMBO_DIR root to config/ subdir
1120
+ const legacyEnv = path.join(LIMBO_DIR, '.env');
1121
+ if (legacyEnv !== ENV_FILE && fs.existsSync(legacyEnv) && !fs.existsSync(ENV_FILE)) {
1122
+ fs.renameSync(legacyEnv, ENV_FILE);
1123
+ }
1124
+ if (!fs.existsSync(ENV_FILE)) fs.writeFileSync(ENV_FILE, '', { mode: 0o600 });
1110
1125
  // Ensure secret files exist (Docker Compose secrets require the files to be present)
1111
1126
  for (const name of ['llm_api_key', 'telegram_bot_token', 'gateway_token', 'groq_api_key', 'brave_api_key']) {
1112
1127
  const fp = path.join(SECRETS_DIR, name);
@@ -2140,19 +2155,22 @@ function cmdUpdate() {
2140
2155
  return;
2141
2156
  }
2142
2157
 
2143
- // Patch image tag to :latest in existing compose files (handles upgrades from pinned tags)
2144
- let compose = fs.readFileSync(COMPOSE_FILE, 'utf8');
2145
- // Migrate from any old registry (ghcr.io, pinned tags) to current REGISTRY_IMAGE
2146
- const patched = compose.replace(
2147
- /image:\s*(?:ghcr\.io\/tomasward1\/limbo|registry\.gitlab\.com\/tomas209\/limbo):\S+/g,
2148
- `image: ${REGISTRY_IMAGE}:${DEFAULT_TAG}`
2149
- );
2150
- if (patched !== compose) {
2151
- compose = patched;
2152
- fs.writeFileSync(COMPOSE_FILE, compose);
2153
- log(`Patched compose image to ${REGISTRY_IMAGE}:${DEFAULT_TAG}`);
2158
+ // Restore PORT from existing .env so the regenerated compose uses the right port.
2159
+ const existingEnv = parseEnvFile();
2160
+ if (existingEnv.LIMBO_PORT) {
2161
+ const parsed = parseInt(existingEnv.LIMBO_PORT, 10);
2162
+ if (Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535) PORT = parsed;
2154
2163
  }
2155
2164
 
2165
+ // Regenerate compose file from current template. This handles:
2166
+ // - ZeroClaw → OpenClaw migration (volume mounts, healthcheck)
2167
+ // - Image registry/tag updates
2168
+ // - Any new compose changes shipped with the CLI
2169
+ // Detect hardened mode from existing compose (squid sidecar present).
2170
+ const existingCompose = fs.readFileSync(COMPOSE_FILE, 'utf8');
2171
+ const hardened = existingCompose.includes('squid:');
2172
+ ensureComposeFile(hardened);
2173
+
2156
2174
  log('Pulling latest image...');
2157
2175
  run(`docker compose -f "${COMPOSE_FILE}" pull -q`);
2158
2176
  log('Restarting...');
@@ -2264,6 +2282,65 @@ ${c.bold}Usage:${c.reset}
2264
2282
  }
2265
2283
  }
2266
2284
 
2285
+ async function cmdSwitchBrain() {
2286
+ const existingEnv = parseEnvFile();
2287
+ if (!existingEnv.MODEL_PROVIDER) {
2288
+ die('No existing configuration found. Run `limbo start` first to set up.');
2289
+ }
2290
+
2291
+ // Resolve port from existing config before generating compose file
2292
+ if (existingEnv.LIMBO_PORT) {
2293
+ const parsed = parseInt(existingEnv.LIMBO_PORT, 10);
2294
+ if (Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535) PORT = parsed;
2295
+ }
2296
+
2297
+ ensureComposeFile(false);
2298
+
2299
+ const lang = existingEnv.CLI_LANGUAGE || 'en';
2300
+ const currentProvider = existingEnv.MODEL_PROVIDER || 'unknown';
2301
+ const currentModel = existingEnv.MODEL_NAME || 'unknown';
2302
+
2303
+ header(lang === 'es' ? 'Cambiar Proveedor' : 'Switch Provider');
2304
+ console.log(` ${c.dim}${lang === 'es' ? 'Proveedor actual' : 'Current provider'}: ${c.reset}${c.bold}${currentProvider}${c.reset} (${currentModel})\n`);
2305
+
2306
+ const envContent = fs.readFileSync(ENV_FILE, 'utf8');
2307
+ const cleaned = envContent
2308
+ .replace(/^SWITCH_BRAIN_MODE=.*\n?/gm, '')
2309
+ .replace(/^AUTH_MODE=.*\n?/gm, '')
2310
+ .replace(/^MODEL_PROVIDER=.*\n?/gm, '')
2311
+ .replace(/^MODEL_NAME=.*\n?/gm, '');
2312
+ fs.writeFileSync(ENV_FILE, cleaned + 'SWITCH_BRAIN_MODE=true\n', { mode: 0o600 });
2313
+
2314
+ pullOrBuildImage(lang);
2315
+ ensureVolumePermissions();
2316
+
2317
+ log(lang === 'es' ? 'Iniciando wizard de cambio de proveedor...' : 'Starting provider switch wizard...');
2318
+ const upResult = runDockerCompose(['up', '-d', '--remove-orphans', '--force-recreate'], { stdio: 'pipe' });
2319
+ if (upResult.status !== 0) {
2320
+ process.stderr.write(upResult.stderr || '');
2321
+ die('Container failed to start. Run `limbo logs` to investigate.');
2322
+ }
2323
+
2324
+ const wizardUrl = extractWizardUrl();
2325
+
2326
+ let tunnel = null;
2327
+ if (isServerEnvironment() || process.argv.includes('--tunnel')) {
2328
+ tunnel = await createSetupTunnel(PORT);
2329
+ }
2330
+
2331
+ const displayUrl = wizardUrl || `http://127.0.0.1:${PORT}`;
2332
+ if (!wizardUrl) {
2333
+ warn('Could not extract setup token from container logs.');
2334
+ }
2335
+ printWizardUrl(displayUrl, tunnel);
2336
+
2337
+ try {
2338
+ const envAfter = fs.readFileSync(ENV_FILE, 'utf8');
2339
+ const final = envAfter.replace(/^SWITCH_BRAIN_MODE=.*\n?/gm, '');
2340
+ if (final !== envAfter) fs.writeFileSync(ENV_FILE, final, { mode: 0o600 });
2341
+ } catch {}
2342
+ }
2343
+
2267
2344
  function cmdHelp() {
2268
2345
  console.log(`
2269
2346
  ${c.bold}limbo${c.reset} - personal AI memory agent
@@ -2278,6 +2355,7 @@ ${c.bold}Commands:${c.reset}
2278
2355
  update Pull latest image and restart
2279
2356
  status Show container status
2280
2357
  config Configure optional features (voice, web-search)
2358
+ switch-brain Change your AI provider (opens a quick wizard)
2281
2359
  help Show this help
2282
2360
 
2283
2361
  ${c.bold}Flags:${c.reset}
@@ -2402,6 +2480,7 @@ if (require.main === module) {
2402
2480
  case 'update': cmdUpdate(); break;
2403
2481
  case 'status': cmdStatus(); break;
2404
2482
  case 'config': cmdConfig(); break;
2483
+ case 'switch-brain': await cmdSwitchBrain(); break;
2405
2484
  case 'version':
2406
2485
  case '--version':
2407
2486
  case '-v': console.log(require('./package.json').version); break;
@@ -8,3 +8,5 @@ services:
8
8
  - ./scripts/entrypoint.sh:/entrypoint.sh:ro
9
9
  - ./migrations:/app/migrations:ro
10
10
  - ./workspace:/app/workspace:ro
11
+ - ./lib:/app/lib:ro
12
+ - ./RELEASES.md:/app/RELEASES.md:ro
@@ -1,36 +1,74 @@
1
- # Local testing — setup persists across restarts.
2
- # Start: docker compose -f docker-compose.test.yml up -d
1
+ # Local e2e testing — reuses pre-configured state from /tmp/limbo-e2e-test.
2
+ # First-time setup: run `limbo start` once to complete the wizard, then copy
3
+ # the resulting state to /tmp/limbo-e2e-test/. Subsequent tests reuse it.
4
+ #
5
+ # Build: docker build -t limbo:test .
6
+ # Start: LIMBO_IMAGE=limbo:test docker compose -f docker-compose.test.yml up -d
3
7
  # Logs: docker compose -f docker-compose.test.yml logs -f
4
8
  # Stop: docker compose -f docker-compose.test.yml down
5
- # Reset: docker compose -f docker-compose.test.yml down -v (wipes setup)
6
9
  services:
7
10
  limbo:
8
- image: ${LIMBO_IMAGE:-ghcr.io/tomasward1/limbo:latest}
11
+ image: ${LIMBO_IMAGE:-limbo:test}
9
12
  init: true
10
13
  restart: "no"
14
+ read_only: true
15
+ security_opt:
16
+ - no-new-privileges:true
17
+ cap_drop:
18
+ - ALL
19
+ cap_add:
20
+ - CHOWN
21
+ - FOWNER
22
+ pids_limit: 200
23
+ tmpfs:
24
+ - /tmp:size=100M,noexec,nosuid,nodev
25
+ - /home/limbo/.npm:size=50M,noexec,nosuid,nodev,uid=999,gid=999
11
26
  ports:
12
- - "127.0.0.1:18789:18789"
27
+ - "127.0.0.1:18900:18900"
13
28
  volumes:
14
- - limbo-test-data:/data
15
- - limbo-test-state:/home/limbo/.openclaw
29
+ - limbo-e2e-data:/data
30
+ - /tmp/limbo-e2e-test/vault:/data/vault
31
+ - /tmp/limbo-e2e-test/openclaw-state:/home/limbo/.openclaw
32
+ - /tmp/limbo-e2e-test/flags:/flags
33
+ secrets:
34
+ - llm_api_key
35
+ - telegram_bot_token
36
+ - gateway_token
37
+ - groq_api_key
38
+ - brave_api_key
39
+ env_file:
40
+ - /tmp/limbo-e2e-test/.env
41
+ environment:
42
+ LIMBO_PORT: "18900"
43
+ NODE_OPTIONS: "${LIMBO_NODE_OPTIONS:---max-old-space-size=512}"
16
44
  logging:
17
45
  driver: json-file
18
46
  options:
19
47
  max-size: "10m"
20
48
  max-file: "3"
21
- tmpfs:
22
- - /tmp:size=100M
23
49
  mem_limit: 1g
24
50
  memswap_limit: 1g
25
51
  healthcheck:
26
- test: ["CMD", "node", "-e", "fetch('http://localhost:18789/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
27
- interval: 30s
28
- timeout: 10s
52
+ test:
53
+ - CMD-SHELL
54
+ - node -e "fetch('http://localhost:'${LIMBO_PORT:-18900}'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
55
+ interval: 10s
56
+ timeout: 5s
29
57
  retries: 3
30
- start_period: 30s
58
+ start_period: 5s
59
+
60
+ secrets:
61
+ llm_api_key:
62
+ file: /tmp/limbo-e2e-test/secrets/llm_api_key
63
+ telegram_bot_token:
64
+ file: /tmp/limbo-e2e-test/secrets/telegram_bot_token
65
+ gateway_token:
66
+ file: /tmp/limbo-e2e-test/secrets/gateway_token
67
+ groq_api_key:
68
+ file: /tmp/limbo-e2e-test/secrets/groq_api_key
69
+ brave_api_key:
70
+ file: /tmp/limbo-e2e-test/secrets/brave_api_key
31
71
 
32
72
  volumes:
33
- limbo-test-data:
34
- name: limbo-test-data
35
- limbo-test-state:
36
- name: limbo-test-state
73
+ limbo-e2e-data:
74
+ name: limbo-e2e-test_limbo-data
@@ -0,0 +1,97 @@
1
+ /**
2
+ * telegram-notify.js — Deterministic Telegram messaging via Bot API.
3
+ *
4
+ * Sends messages directly through the Telegram HTTP API without depending on
5
+ * OpenClaw or any agent runtime. Used by the wakeup routine (entrypoint) and
6
+ * the update_instance MCP tool for system-level notifications that MUST fire.
7
+ *
8
+ * Requires two secrets:
9
+ * - telegram_bot_token
10
+ * - telegram_chat_id
11
+ *
12
+ * Both are written by the setup wizard (setup-server/server.js) during initial
13
+ * Telegram pairing and persisted in the OpenClaw secrets directory.
14
+ */
15
+
16
+ const https = require("node:https");
17
+ const fs = require("node:fs");
18
+ const path = require("node:path");
19
+
20
+ const STATE_DIR =
21
+ process.env.OPENCLAW_STATE_DIR || "/home/limbo/.openclaw";
22
+ const SECRETS_DIR = path.join(STATE_DIR, "secrets");
23
+
24
+ function readSecret(name) {
25
+ // Docker secrets take priority, then OpenClaw secrets dir
26
+ for (const dir of ["/run/secrets", SECRETS_DIR]) {
27
+ const p = path.join(dir, name);
28
+ try {
29
+ const v = fs.readFileSync(p, "utf8").trim();
30
+ if (v) return v;
31
+ } catch {
32
+ // not found — try next
33
+ }
34
+ }
35
+ return "";
36
+ }
37
+
38
+ /**
39
+ * Send a Telegram message via the Bot API.
40
+ *
41
+ * @param {string} text — Message text (supports MarkdownV2 if parse_mode set)
42
+ * @param {object} [options] — Extra sendMessage params (parse_mode, reply_markup, etc.)
43
+ * @returns {Promise<object>} — Telegram API response body
44
+ */
45
+ function sendMessage(text, options = {}) {
46
+ const token = readSecret("telegram_bot_token");
47
+ const chatId = readSecret("telegram_chat_id");
48
+
49
+ if (!token || !chatId) {
50
+ return Promise.reject(
51
+ new Error("telegram-notify: missing bot_token or chat_id in secrets")
52
+ );
53
+ }
54
+
55
+ const body = JSON.stringify({
56
+ chat_id: chatId,
57
+ text,
58
+ ...options,
59
+ });
60
+
61
+ return new Promise((resolve, reject) => {
62
+ const req = https.request(
63
+ {
64
+ hostname: "api.telegram.org",
65
+ path: `/bot${token}/sendMessage`,
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ "Content-Length": Buffer.byteLength(body),
70
+ },
71
+ timeout: 10000,
72
+ },
73
+ (res) => {
74
+ let data = "";
75
+ res.on("data", (chunk) => (data += chunk));
76
+ res.on("end", () => {
77
+ try {
78
+ const parsed = JSON.parse(data);
79
+ if (parsed.ok) resolve(parsed);
80
+ else reject(new Error(`Telegram API error: ${data}`));
81
+ } catch {
82
+ reject(new Error(`Telegram API parse error: ${data}`));
83
+ }
84
+ });
85
+ }
86
+ );
87
+ req.on("error", reject);
88
+ req.on("timeout", () => {
89
+ req.destroy();
90
+ reject(new Error("telegram-notify: request timed out"));
91
+ });
92
+ req.write(body);
93
+ req.end();
94
+ });
95
+ }
96
+
97
+ module.exports = { sendMessage, readSecret };
package/lib/wakeup.js ADDED
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wakeup.js — Deterministic startup routine.
4
+ *
5
+ * Runs in the entrypoint BEFORE OpenClaw starts. Handles system-level
6
+ * notifications that must always fire, independent of the LLM.
7
+ *
8
+ * Current checks:
9
+ * 1. Post-update notification — tell the user we're back after an update
10
+ * 2. Version check — notify if a newer version is available on npm
11
+ *
12
+ * Future checks can be added to the `checks` array below.
13
+ */
14
+
15
+ const fs = require("node:fs");
16
+ const path = require("node:path");
17
+ const https = require("node:https");
18
+ const { sendMessage } = require("./telegram-notify");
19
+
20
+ const FLAGS_DIR = "/flags";
21
+ const DATA_DIR = "/data";
22
+ const VERSION_FILE = path.join(DATA_DIR, ".limbo-version");
23
+ const LAST_CHECK_FILE = path.join(DATA_DIR, ".update-last-check");
24
+ const RELEASES_FILE = "/app/RELEASES.md";
25
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
26
+
27
+ function log(msg) {
28
+ const ts = new Date().toISOString();
29
+ console.log(`[${ts}] [wakeup] ${msg}`);
30
+ }
31
+
32
+ // ── Check 1: Post-update notification ──────────────────────────────────────
33
+
34
+ async function checkPostUpdate() {
35
+ const flagPath = path.join(FLAGS_DIR, "updated.flag");
36
+ if (!fs.existsSync(flagPath)) return;
37
+
38
+ let previousVersion;
39
+ try {
40
+ previousVersion = fs.readFileSync(flagPath, "utf8").trim();
41
+ } catch {
42
+ previousVersion = "unknown";
43
+ }
44
+
45
+ const currentVersion = getCurrentVersion();
46
+ const changelog = parseUserChangelog();
47
+
48
+ let text;
49
+ if (changelog) {
50
+ text =
51
+ `Ya volvi. Actualizado de v${previousVersion} a v${currentVersion}.\n\n` +
52
+ `Que hay de nuevo:\n${changelog}`;
53
+ } else {
54
+ text = `Ya volvi. Actualizado a v${currentVersion}.`;
55
+ }
56
+
57
+ try {
58
+ await sendMessage(text);
59
+ log(`Post-update notification sent (${previousVersion} -> ${currentVersion})`);
60
+ } catch (err) {
61
+ log(`Post-update notification failed: ${err.message}`);
62
+ }
63
+
64
+ // Clean up flag and persist current version
65
+ try { fs.unlinkSync(flagPath); } catch {}
66
+ persistVersion(currentVersion);
67
+ }
68
+
69
+ // ── Check 2: New version available ─────────────────────────────────────────
70
+
71
+ async function checkNewVersion() {
72
+ // Don't check more than once per day
73
+ try {
74
+ const lastCheck = fs.readFileSync(LAST_CHECK_FILE, "utf8").trim();
75
+ if (Date.now() - Number(lastCheck) < CHECK_INTERVAL_MS) return;
76
+ } catch {
77
+ // No last-check file — proceed
78
+ }
79
+
80
+ const currentVersion = getCurrentVersion();
81
+ let latestVersion;
82
+
83
+ try {
84
+ latestVersion = await fetchLatestVersion();
85
+ } catch (err) {
86
+ log(`Version check failed: ${err.message}`);
87
+ return;
88
+ }
89
+
90
+ // Persist check timestamp regardless of result
91
+ try { fs.writeFileSync(LAST_CHECK_FILE, String(Date.now())); } catch {}
92
+
93
+ if (!latestVersion || latestVersion === currentVersion) return;
94
+ if (!isNewer(latestVersion, currentVersion)) return;
95
+
96
+ log(`New version available: ${currentVersion} -> ${latestVersion}`);
97
+
98
+ try {
99
+ await sendMessage(
100
+ `Hay una nueva version de Limbo disponible: v${latestVersion}\n\n` +
101
+ `Cuando quieras actualizar, pedimelo.`,
102
+ {
103
+ reply_markup: JSON.stringify({
104
+ inline_keyboard: [
105
+ [
106
+ { text: "Actualizar", callback_data: "limbo_update_yes" },
107
+ { text: "Ahora no", callback_data: "limbo_update_no" },
108
+ ],
109
+ ],
110
+ }),
111
+ }
112
+ );
113
+ } catch (err) {
114
+ log(`Version notification failed: ${err.message}`);
115
+ }
116
+ }
117
+
118
+ // ── Helpers ────────────────────────────────────────────────────────────────
119
+
120
+ function getCurrentVersion() {
121
+ try {
122
+ const pkg = JSON.parse(
123
+ fs.readFileSync("/app/package.json", "utf8")
124
+ );
125
+ return pkg.version;
126
+ } catch {
127
+ return "0.0.0";
128
+ }
129
+ }
130
+
131
+ function persistVersion(version) {
132
+ try {
133
+ fs.writeFileSync(VERSION_FILE, version);
134
+ } catch {}
135
+ }
136
+
137
+ function parseUserChangelog() {
138
+ try {
139
+ const content = fs.readFileSync(RELEASES_FILE, "utf8");
140
+ // Extract everything between the first "## v" heading and the first "---"
141
+ const match = content.match(/^## v[\d.]+\s*\n([\s\S]*?)(?=\n---)/m);
142
+ if (!match) return null;
143
+ return match[1].trim();
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function fetchLatestVersion() {
150
+ return new Promise((resolve, reject) => {
151
+ const req = https.get(
152
+ "https://registry.npmjs.org/limbo-ai/latest",
153
+ { timeout: 10000 },
154
+ (res) => {
155
+ let data = "";
156
+ res.on("data", (chunk) => (data += chunk));
157
+ res.on("end", () => {
158
+ try {
159
+ resolve(JSON.parse(data).version);
160
+ } catch {
161
+ reject(new Error("Failed to parse npm response"));
162
+ }
163
+ });
164
+ }
165
+ );
166
+ req.on("error", reject);
167
+ req.on("timeout", () => {
168
+ req.destroy();
169
+ reject(new Error("npm registry timeout"));
170
+ });
171
+ });
172
+ }
173
+
174
+ function isNewer(latest, current) {
175
+ const l = latest.split(".").map(Number);
176
+ const c = current.split(".").map(Number);
177
+ return (
178
+ l[0] > c[0] ||
179
+ (l[0] === c[0] && l[1] > c[1]) ||
180
+ (l[0] === c[0] && l[1] === c[1] && l[2] > c[2])
181
+ );
182
+ }
183
+
184
+ // ── Run all checks ─────────────────────────────────────────────────────────
185
+
186
+ async function main() {
187
+ log("Wakeup routine starting");
188
+
189
+ const checks = [checkPostUpdate, checkNewVersion];
190
+
191
+ for (const check of checks) {
192
+ try {
193
+ await check();
194
+ } catch (err) {
195
+ log(`Check ${check.name} failed: ${err.message}`);
196
+ }
197
+ }
198
+
199
+ log("Wakeup routine complete");
200
+ }
201
+
202
+ main().catch((err) => {
203
+ log(`Wakeup fatal error: ${err.message}`);
204
+ // Non-fatal — don't prevent OpenClaw from starting
205
+ process.exit(0);
206
+ });
@@ -15,6 +15,7 @@ import { vaultUpdateMap } from "./tools/update-map.js";
15
15
  import { vaultStoreFile } from "./tools/store-file.js";
16
16
  import { vaultGetFile } from "./tools/get-file.js";
17
17
  import { workspaceRead, workspaceWrite } from "./tools/workspace.js";
18
+ import { updateInstance } from "./tools/update-instance.js";
18
19
 
19
20
  /**
20
21
  * General response size guard. Any tool_result text content exceeding this
@@ -259,6 +260,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
259
260
  required: ["filename", "content"],
260
261
  },
261
262
  },
263
+ {
264
+ name: "update_instance",
265
+ description:
266
+ "Trigger a Limbo self-update. Notifies the user that Limbo is going offline briefly, then signals the host to pull the latest image and restart. Use when the user wants to update Limbo.",
267
+ inputSchema: {
268
+ type: "object",
269
+ properties: {},
270
+ },
271
+ },
262
272
  ],
263
273
  }));
264
274
 
@@ -371,6 +381,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
371
381
  break;
372
382
  }
373
383
 
384
+ case "update_instance": {
385
+ result = await updateInstance();
386
+ break;
387
+ }
388
+
374
389
  default:
375
390
  result = {
376
391
  content: [{ type: "text", text: `Unknown tool: ${name}` }],