limbo-ai 1.31.0 → 2026.4.1

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 (108) hide show
  1. package/Dockerfile +116 -0
  2. package/RELEASES.md +32 -0
  3. package/cli.js +157 -18
  4. package/lib/telegram-notify.js +97 -0
  5. package/lib/wakeup.js +206 -0
  6. package/mcp-server/index.js +174 -1
  7. package/mcp-server/tools/google-calendar.js +277 -0
  8. package/mcp-server/tools/update-instance.js +59 -0
  9. package/migrations/index.js +199 -0
  10. package/migrations/package.json +3 -0
  11. package/migrations/versions/001-initial-schema.js +47 -0
  12. package/migrations/versions/002-unified-note-types.js +150 -0
  13. package/migrations/versions/003-zeroclaw-migration.js +24 -0
  14. package/migrations/versions/004-assets-directory.js +25 -0
  15. package/migrations/versions/005-fts5-search.js +137 -0
  16. package/migrations/versions/006-fts5-dedupe.js +155 -0
  17. package/openclaw.json.template +56 -0
  18. package/package.json +39 -4
  19. package/scripts/entrypoint.sh +496 -0
  20. package/scripts/patch-openclaw-audio.mjs +158 -0
  21. package/setup-server/public/index.html +178 -249
  22. package/setup-server/server.js +251 -58
  23. package/workspace/skills/google-calendar/SKILL.md +144 -0
  24. package/workspace/skills/retrieve-file/SKILL.md +41 -0
  25. package/workspace/system/AGENTS.md +85 -0
  26. package/workspace/system/IDENTITY.md +61 -0
  27. package/workspace/system/SOUL.md +71 -0
  28. package/workspace/system/TOOLS.md +306 -0
  29. package/workspace/system/limbo-skill.md +152 -0
  30. package/workspace/templates/USER.md.template +19 -0
  31. package/.gitlab-ci.yml +0 -208
  32. package/ARCHITECTURE.md +0 -174
  33. package/CONTRIBUTING.md +0 -34
  34. package/SECURITY.md +0 -108
  35. package/docker-compose.dev.yml +0 -10
  36. package/docker-compose.test.yml +0 -36
  37. package/evals/config.eval.env +0 -9
  38. package/evals/dashboard/public/app.js +0 -975
  39. package/evals/dashboard/public/index.html +0 -89
  40. package/evals/dashboard/public/styles.css +0 -908
  41. package/evals/dashboard/server.js +0 -129
  42. package/evals/docker-compose.eval.yml +0 -57
  43. package/evals/promptfoo/assertions.js +0 -215
  44. package/evals/promptfoo/hooks.js +0 -119
  45. package/evals/promptfoo/promptfooconfig.yaml +0 -106
  46. package/evals/promptfoo/provider.js +0 -206
  47. package/evals/promptfoo/run.sh +0 -25
  48. package/evals/promptfoo/seeds/notes/eval-seed-birthday.md +0 -9
  49. package/evals/results/.gitkeep +0 -0
  50. package/evals/results/history/.gitkeep +0 -0
  51. package/evals/results/history/run-1774559258082.json +0 -662
  52. package/evals/results/history/run-1774559485256.json +0 -662
  53. package/evals/results/history/run-1774559674855.json +0 -662
  54. package/evals/results/history/run-1774561108314.json +0 -662
  55. package/evals/results/history/run-1774561286576.json +0 -662
  56. package/evals/results/history/run-1774561575363.json +0 -575
  57. package/evals/results/history/run-1774563070869.json +0 -662
  58. package/evals/results/history/run-1774563275178.json +0 -662
  59. package/evals/results/history/run-1774622867363.json +0 -934
  60. package/evals/results/history/run-1774623126438.json +0 -934
  61. package/evals/results/history/run-1774624683868.json +0 -934
  62. package/evals/results/history/run-1774625379694.json +0 -934
  63. package/evals/results/history/run-1774629331960.json +0 -746
  64. package/evals/results/history/run-1774632319238.json +0 -39
  65. package/evals/results/history/run-1774633277690.json +0 -94
  66. package/evals/results/history/run-1774636000952.json +0 -934
  67. package/evals/results/history/run-1774636946600.json +0 -151
  68. package/evals/results/history/run-1774637141591.json +0 -374
  69. package/evals/results/history/run-1774639388611.json +0 -1578
  70. package/evals/results/history/run-1774641629961.json +0 -1523
  71. package/evals/results/history/run-1774643063585.json +0 -1653
  72. package/evals/results/history/run-1774644145726.json +0 -73
  73. package/evals/results/history/run-1774644299624.json +0 -1489
  74. package/evals/results/history/run-1774644416754.json +0 -58
  75. package/evals/results/history/run-1774644909594.json +0 -58
  76. package/evals/results/history/run-1774796618679.json +0 -73
  77. package/evals/results/history/run-1774796879800.json +0 -73
  78. package/evals/results/history/run-1774797434760.json +0 -94
  79. package/evals/results/history/run-1774797567080.json +0 -57
  80. package/evals/results/history/run-1774895167606.json +0 -574
  81. package/evals/results/history/run-1774895670045.json +0 -540
  82. package/evals/results/history/run-1774895876781.json +0 -466
  83. package/evals/results/history/run-1774898060232.json +0 -162
  84. package/evals/results/history/run-1774966775381.json +0 -135
  85. package/evals/results/history/run-1774966839076.json +0 -33
  86. package/evals/results/history/run-1774966890459.json +0 -33
  87. package/evals/results/history/run-1774967730887.json +0 -189
  88. package/evals/results/history/run-1774967764419.json +0 -113
  89. package/evals/results/history/run-1775043267611.json +0 -4470
  90. package/evals/results/history/run-1775046132278.json +0 -4420
  91. package/evals/results/history/run-1775068115506.json +0 -5277
  92. package/evals/results/latest.json +0 -5277
  93. package/evals/test/scorer.test.js +0 -218
  94. package/mcp-server/test/benchmark.js +0 -365
  95. package/mcp-server/test/eval-logging.test.js +0 -259
  96. package/mcp-server/test/get-file.test.js +0 -256
  97. package/test/cli-auth.test.js +0 -357
  98. package/test/cli-compose.test.js +0 -471
  99. package/test/cli-filter.test.js +0 -200
  100. package/test/cli-registry.test.js +0 -93
  101. package/test/cli-wizard-parity.test.js +0 -224
  102. package/test/entrypoint.test.js +0 -776
  103. package/test/fts.test.js +0 -141
  104. package/test/mcp-tools.test.js +0 -606
  105. package/test/openclaw-migration.test.js +0 -317
  106. package/test/red-phase.test.js +0 -181
  107. package/test/sanitize-control-chars.test.js +0 -92
  108. package/test/setup-server.test.js +0 -784
package/Dockerfile ADDED
@@ -0,0 +1,116 @@
1
+ # syntax=docker/dockerfile:1
2
+
3
+ # OpenClaw version — pin to avoid surprise upgrades in production
4
+ ARG OPENCLAW_VERSION=latest
5
+
6
+ # ──────────────────────────────────────────────
7
+ # Stage 1: deps — build MCP server native addons
8
+ # better-sqlite3 requires python3/make/g++ for node-gyp compilation.
9
+ # This stage is discarded after extracting node_modules.
10
+ # ──────────────────────────────────────────────
11
+ FROM node:22-slim AS deps
12
+
13
+ # Build tools for native addons (better-sqlite3 requires compilation)
14
+ RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
15
+
16
+ WORKDIR /build
17
+
18
+ # Copy MCP server manifest and lockfile (layer cached unless these change)
19
+ COPY mcp-server/package.json mcp-server/package-lock.json* ./mcp-server/
20
+
21
+ # Install deps without running lifecycle scripts, then rebuild better-sqlite3
22
+ # native addon explicitly. Verify with an in-memory open/close smoke test.
23
+ RUN cd mcp-server \
24
+ && npm ci --omit=dev --ignore-scripts \
25
+ && cd node_modules/better-sqlite3 \
26
+ && npx node-gyp rebuild --release \
27
+ && cd /build \
28
+ && node -e "const d=require('/build/mcp-server/node_modules/better-sqlite3');const db=d(':memory:');db.close();console.log('better-sqlite3 OK')"
29
+
30
+ # ──────────────────────────────────────────────
31
+ # Stage 2: runtime — OpenClaw + MCP server + workspace
32
+ # Migrated from ZeroClaw (Rust binary) to OpenClaw (Node.js npm package).
33
+ # OpenClaw is installed globally via npm — no custom binary build needed.
34
+ # ──────────────────────────────────────────────
35
+ FROM node:22-slim AS runtime
36
+
37
+ ARG OPENCLAW_VERSION
38
+
39
+ # Runtime system deps:
40
+ # gettext-base — envsubst for config template rendering
41
+ # tzdata — timezone support
42
+ # tini — minimal init for proper signal handling (PID 1 reaping)
43
+ # libssl3 — OpenSSL 3 shared lib needed by OpenClaw's ACP runtime (codex-acp)
44
+ # python3 — required by OpenClaw's pinned-write-helper for safe atomic file writes
45
+ RUN apt-get update && apt-get install -y --no-install-recommends gettext-base tzdata tini libssl3 python3 ca-certificates && rm -rf /var/lib/apt/lists/* \
46
+ && groupadd -r limbo && useradd --create-home -r -g limbo limbo
47
+
48
+ # Install OpenClaw globally — replaces the ZeroClaw Rust binary.
49
+ # Pinned via OPENCLAW_VERSION build arg (default: latest).
50
+ # @googleworkspace/cli (gws) — Google Calendar integration (optional feature).
51
+ RUN npm install -g "openclaw@${OPENCLAW_VERSION}" "@googleworkspace/cli"
52
+
53
+ # Apply local patch for openclaw#63851 — the guarded fetch drops FormData fields,
54
+ # breaking Groq audio transcription. Remove this once upstream PR #64349 ships in
55
+ # a released openclaw version; the patcher is idempotent and fails loudly if
56
+ # the openclaw code shape has changed.
57
+ COPY scripts/patch-openclaw-audio.mjs /tmp/patch-openclaw-audio.mjs
58
+ RUN node /tmp/patch-openclaw-audio.mjs && rm /tmp/patch-openclaw-audio.mjs
59
+
60
+ # App directories
61
+ WORKDIR /app
62
+
63
+ # MCP server: source code first, then node_modules from deps stage (overrides host binaries)
64
+ COPY --chown=limbo:limbo mcp-server/ ./mcp-server/
65
+ COPY --from=deps /build/mcp-server/node_modules ./mcp-server/node_modules
66
+
67
+ # Setup wizard server (zero dependencies — plain Node.js HTTP server)
68
+ COPY --chown=limbo:limbo setup-server/ /app/setup-server/
69
+
70
+ # System workspace files (product-owned, root-owned for read-only enforcement via symlinks)
71
+ COPY workspace/system/ ./workspace/system/
72
+
73
+ # Skills (product-owned, synced to OpenClaw workspace on boot by entrypoint)
74
+ COPY workspace/skills/ ./workspace/skills/
75
+
76
+ # User workspace templates (limbo-owned, seeded on first run)
77
+ COPY --chown=limbo:limbo workspace/templates/ ./workspace/templates/
78
+
79
+ # Migration runner (no external deps — pure Node.js stdlib)
80
+ COPY --chown=limbo:limbo migrations/ ./migrations/
81
+
82
+ # Shared libs (telegram-notify, wakeup routine)
83
+ COPY --chown=limbo:limbo lib/ ./lib/
84
+
85
+ # Package metadata (version read by wakeup routine)
86
+ COPY --chown=limbo:limbo package.json ./package.json
87
+
88
+ # User-facing release notes (parsed by wakeup routine for update messages)
89
+ COPY --chown=limbo:limbo RELEASES.md ./RELEASES.md
90
+
91
+ # OpenClaw config template (populated by entrypoint from env vars)
92
+ COPY --chown=limbo:limbo openclaw.json.template ./openclaw.json.template
93
+
94
+ # Entrypoint script
95
+ COPY scripts/entrypoint.sh /entrypoint.sh
96
+ RUN chmod +x /entrypoint.sh
97
+
98
+ # Pre-create dirs with correct ownership for image-layer defaults
99
+ RUN mkdir -p /data && chown limbo:limbo /data
100
+ RUN mkdir -p /flags && chown limbo:limbo /flags
101
+ RUN mkdir -p /home/limbo/.openclaw && chown limbo:limbo /home/limbo/.openclaw
102
+ # Fix npm cache ownership — npm install -g runs as root but limbo user needs write access at runtime
103
+ RUN mkdir -p /home/limbo/.npm && chown -R limbo:limbo /home/limbo/.npm
104
+ RUN chown limbo:limbo /app
105
+
106
+ # Data volume — vault, db, config, memory, backups, logs
107
+ VOLUME ["/data"]
108
+
109
+ # OpenClaw gateway port
110
+ EXPOSE 18789
111
+
112
+ # Run as non-root limbo user
113
+ USER limbo
114
+
115
+ # tini as init process for proper signal forwarding and zombie reaping
116
+ ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
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
@@ -762,6 +768,7 @@ function normalizeConfig(cfg, existingEnv = {}) {
762
768
  GATEWAY_TOKEN: gatewayToken,
763
769
  VOICE_ENABLED: cfg.voiceEnabled || existingEnv.VOICE_ENABLED || 'false',
764
770
  WEB_SEARCH_ENABLED: cfg.webSearchEnabled || existingEnv.WEB_SEARCH_ENABLED || 'false',
771
+ GOOGLE_CALENDAR_ENABLED: cfg.googleCalendarEnabled || existingEnv.GOOGLE_CALENDAR_ENABLED || 'false',
765
772
  };
766
773
 
767
774
  return base;
@@ -1105,8 +1112,17 @@ function ensureComposeFile(hardened = false) {
1105
1112
  fs.mkdirSync(path.join(VAULT_DIR, 'notes'), { recursive: true });
1106
1113
  fs.mkdirSync(path.join(VAULT_DIR, 'maps'), { recursive: true });
1107
1114
  fs.mkdirSync(OPENCLAW_STATE_DIR, { recursive: true });
1115
+ fs.mkdirSync(FLAGS_DIR, { recursive: true });
1108
1116
  migrateLegacyState();
1109
1117
  fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
1118
+ // Ensure config dir and .env exist (bind-mounted into container as /data/config/)
1119
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
1120
+ // Migrate legacy .env from LIMBO_DIR root to config/ subdir
1121
+ const legacyEnv = path.join(LIMBO_DIR, '.env');
1122
+ if (legacyEnv !== ENV_FILE && fs.existsSync(legacyEnv) && !fs.existsSync(ENV_FILE)) {
1123
+ fs.renameSync(legacyEnv, ENV_FILE);
1124
+ }
1125
+ if (!fs.existsSync(ENV_FILE)) fs.writeFileSync(ENV_FILE, '', { mode: 0o600 });
1110
1126
  // Ensure secret files exist (Docker Compose secrets require the files to be present)
1111
1127
  for (const name of ['llm_api_key', 'telegram_bot_token', 'gateway_token', 'groq_api_key', 'brave_api_key']) {
1112
1128
  const fp = path.join(SECRETS_DIR, name);
@@ -2140,19 +2156,22 @@ function cmdUpdate() {
2140
2156
  return;
2141
2157
  }
2142
2158
 
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}`);
2159
+ // Restore PORT from existing .env so the regenerated compose uses the right port.
2160
+ const existingEnv = parseEnvFile();
2161
+ if (existingEnv.LIMBO_PORT) {
2162
+ const parsed = parseInt(existingEnv.LIMBO_PORT, 10);
2163
+ if (Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535) PORT = parsed;
2154
2164
  }
2155
2165
 
2166
+ // Regenerate compose file from current template. This handles:
2167
+ // - ZeroClaw → OpenClaw migration (volume mounts, healthcheck)
2168
+ // - Image registry/tag updates
2169
+ // - Any new compose changes shipped with the CLI
2170
+ // Detect hardened mode from existing compose (squid sidecar present).
2171
+ const existingCompose = fs.readFileSync(COMPOSE_FILE, 'utf8');
2172
+ const hardened = existingCompose.includes('squid:');
2173
+ ensureComposeFile(hardened);
2174
+
2156
2175
  log('Pulling latest image...');
2157
2176
  run(`docker compose -f "${COMPOSE_FILE}" pull -q`);
2158
2177
  log('Restarting...');
@@ -2264,6 +2283,122 @@ ${c.bold}Usage:${c.reset}
2264
2283
  }
2265
2284
  }
2266
2285
 
2286
+ async function cmdSwitchBrain() {
2287
+ const existingEnv = parseEnvFile();
2288
+ if (!existingEnv.MODEL_PROVIDER) {
2289
+ die('No existing configuration found. Run `limbo start` first to set up.');
2290
+ }
2291
+
2292
+ // Resolve port from existing config before generating compose file
2293
+ if (existingEnv.LIMBO_PORT) {
2294
+ const parsed = parseInt(existingEnv.LIMBO_PORT, 10);
2295
+ if (Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535) PORT = parsed;
2296
+ }
2297
+
2298
+ ensureComposeFile(false);
2299
+
2300
+ const lang = existingEnv.CLI_LANGUAGE || 'en';
2301
+ const currentProvider = existingEnv.MODEL_PROVIDER || 'unknown';
2302
+ const currentModel = existingEnv.MODEL_NAME || 'unknown';
2303
+
2304
+ header(lang === 'es' ? 'Cambiar Proveedor' : 'Switch Provider');
2305
+ console.log(` ${c.dim}${lang === 'es' ? 'Proveedor actual' : 'Current provider'}: ${c.reset}${c.bold}${currentProvider}${c.reset} (${currentModel})\n`);
2306
+
2307
+ const envContent = fs.readFileSync(ENV_FILE, 'utf8');
2308
+ const cleaned = envContent
2309
+ .replace(/^SWITCH_BRAIN_MODE=.*\n?/gm, '')
2310
+ .replace(/^AUTH_MODE=.*\n?/gm, '')
2311
+ .replace(/^MODEL_PROVIDER=.*\n?/gm, '')
2312
+ .replace(/^MODEL_NAME=.*\n?/gm, '');
2313
+ fs.writeFileSync(ENV_FILE, cleaned + 'SWITCH_BRAIN_MODE=true\n', { mode: 0o600 });
2314
+
2315
+ pullOrBuildImage(lang);
2316
+ ensureVolumePermissions();
2317
+
2318
+ log(lang === 'es' ? 'Iniciando wizard de cambio de proveedor...' : 'Starting provider switch wizard...');
2319
+ const upResult = runDockerCompose(['up', '-d', '--remove-orphans', '--force-recreate'], { stdio: 'pipe' });
2320
+ if (upResult.status !== 0) {
2321
+ process.stderr.write(upResult.stderr || '');
2322
+ die('Container failed to start. Run `limbo logs` to investigate.');
2323
+ }
2324
+
2325
+ const wizardUrl = extractWizardUrl();
2326
+
2327
+ let tunnel = null;
2328
+ if (isServerEnvironment() || process.argv.includes('--tunnel')) {
2329
+ tunnel = await createSetupTunnel(PORT);
2330
+ }
2331
+
2332
+ const displayUrl = wizardUrl || `http://127.0.0.1:${PORT}`;
2333
+ if (!wizardUrl) {
2334
+ warn('Could not extract setup token from container logs.');
2335
+ }
2336
+ printWizardUrl(displayUrl, tunnel);
2337
+
2338
+ try {
2339
+ const envAfter = fs.readFileSync(ENV_FILE, 'utf8');
2340
+ const final = envAfter.replace(/^SWITCH_BRAIN_MODE=.*\n?/gm, '');
2341
+ if (final !== envAfter) fs.writeFileSync(ENV_FILE, final, { mode: 0o600 });
2342
+ } catch {}
2343
+ }
2344
+
2345
+ async function cmdConnectCalendar() {
2346
+ const existingEnv = parseEnvFile();
2347
+ if (!existingEnv.MODEL_PROVIDER) {
2348
+ die('No existing configuration found. Run `limbo start` first to set up.');
2349
+ }
2350
+
2351
+ if (existingEnv.GOOGLE_CALENDAR_ENABLED === 'true') {
2352
+ ok('Google Calendar is already connected.');
2353
+ return;
2354
+ }
2355
+
2356
+ // Resolve port from existing config
2357
+ if (existingEnv.LIMBO_PORT) {
2358
+ const parsed = parseInt(existingEnv.LIMBO_PORT, 10);
2359
+ if (Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535) PORT = parsed;
2360
+ }
2361
+
2362
+ ensureComposeFile(false);
2363
+
2364
+ const lang = existingEnv.CLI_LANGUAGE || 'en';
2365
+ header(lang === 'es' ? 'Conectar Google Calendar' : 'Connect Google Calendar');
2366
+
2367
+ // Write CONNECT_CALENDAR_MODE to .env (preserve existing config)
2368
+ const envContent = fs.readFileSync(ENV_FILE, 'utf8');
2369
+ const cleaned = envContent.replace(/^CONNECT_CALENDAR_MODE=.*\n?/gm, '');
2370
+ fs.writeFileSync(ENV_FILE, cleaned + 'CONNECT_CALENDAR_MODE=true\n', { mode: 0o600 });
2371
+
2372
+ pullOrBuildImage(lang);
2373
+ ensureVolumePermissions();
2374
+
2375
+ log(lang === 'es' ? 'Iniciando wizard de Google Calendar...' : 'Starting Google Calendar setup wizard...');
2376
+ const upResult = runDockerCompose(['up', '-d', '--remove-orphans', '--force-recreate'], { stdio: 'pipe' });
2377
+ if (upResult.status !== 0) {
2378
+ process.stderr.write(upResult.stderr || '');
2379
+ die('Container failed to start. Run `limbo logs` to investigate.');
2380
+ }
2381
+
2382
+ const wizardUrl = extractWizardUrl();
2383
+
2384
+ let tunnel = null;
2385
+ if (isServerEnvironment() || process.argv.includes('--tunnel')) {
2386
+ tunnel = await createSetupTunnel(PORT);
2387
+ }
2388
+
2389
+ const displayUrl = wizardUrl || `http://127.0.0.1:${PORT}`;
2390
+ if (!wizardUrl) {
2391
+ warn('Could not extract setup token from container logs.');
2392
+ }
2393
+ printWizardUrl(displayUrl, tunnel);
2394
+
2395
+ try {
2396
+ const envAfter = fs.readFileSync(ENV_FILE, 'utf8');
2397
+ const final = envAfter.replace(/^CONNECT_CALENDAR_MODE=.*\n?/gm, '');
2398
+ if (final !== envAfter) fs.writeFileSync(ENV_FILE, final, { mode: 0o600 });
2399
+ } catch {}
2400
+ }
2401
+
2267
2402
  function cmdHelp() {
2268
2403
  console.log(`
2269
2404
  ${c.bold}limbo${c.reset} - personal AI memory agent
@@ -2277,8 +2412,10 @@ ${c.bold}Commands:${c.reset}
2277
2412
  logs Tail container logs
2278
2413
  update Pull latest image and restart
2279
2414
  status Show container status
2280
- config Configure optional features (voice, web-search)
2281
- help Show this help
2415
+ config Configure optional features (voice, web-search)
2416
+ switch-brain Change your AI provider (opens a quick wizard)
2417
+ connect-calendar Connect Google Calendar (opens a quick wizard)
2418
+ help Show this help
2282
2419
 
2283
2420
  ${c.bold}Flags:${c.reset}
2284
2421
  --cli Use interactive CLI prompts instead of the web setup wizard
@@ -2402,6 +2539,8 @@ if (require.main === module) {
2402
2539
  case 'update': cmdUpdate(); break;
2403
2540
  case 'status': cmdStatus(); break;
2404
2541
  case 'config': cmdConfig(); break;
2542
+ case 'switch-brain': await cmdSwitchBrain(); break;
2543
+ case 'connect-calendar': await cmdConnectCalendar(); break;
2405
2544
  case 'version':
2406
2545
  case '--version':
2407
2546
  case '-v': console.log(require('./package.json').version); break;
@@ -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 };