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 +1 -0
- package/RELEASES.md +32 -0
- package/cli.js +95 -16
- package/docker-compose.dev.yml +2 -0
- package/docker-compose.test.yml +55 -17
- package/lib/telegram-notify.js +97 -0
- package/lib/wakeup.js +206 -0
- package/mcp-server/index.js +15 -0
- package/mcp-server/tools/update-instance.js +59 -0
- package/package.json +2 -2
- package/setup-server/public/index.html +57 -248
- package/setup-server/server.js +70 -43
- package/test/cli-registry.test.js +9 -7
- package/test/cli-wizard-parity.test.js +5 -2
- package/test/entrypoint.test.js +97 -0
- package/test/update-system.test.js +210 -0
package/.gitlab-ci.yml
CHANGED
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
|
|
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
|
-
- ${
|
|
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
|
|
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
|
-
- ${
|
|
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
|
|
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
|
-
//
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
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;
|
package/docker-compose.dev.yml
CHANGED
package/docker-compose.test.yml
CHANGED
|
@@ -1,36 +1,74 @@
|
|
|
1
|
-
# Local testing —
|
|
2
|
-
#
|
|
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:-
|
|
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:
|
|
27
|
+
- "127.0.0.1:18900:18900"
|
|
13
28
|
volumes:
|
|
14
|
-
- limbo-
|
|
15
|
-
- limbo-test
|
|
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:
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
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-
|
|
34
|
-
name: limbo-
|
|
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
|
+
});
|
package/mcp-server/index.js
CHANGED
|
@@ -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}` }],
|