limbo-ai 1.32.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 (105) hide show
  1. package/Dockerfile +116 -0
  2. package/cli.js +63 -3
  3. package/mcp-server/index.js +159 -1
  4. package/mcp-server/tools/google-calendar.js +277 -0
  5. package/migrations/index.js +199 -0
  6. package/migrations/package.json +3 -0
  7. package/migrations/versions/001-initial-schema.js +47 -0
  8. package/migrations/versions/002-unified-note-types.js +150 -0
  9. package/migrations/versions/003-zeroclaw-migration.js +24 -0
  10. package/migrations/versions/004-assets-directory.js +25 -0
  11. package/migrations/versions/005-fts5-search.js +137 -0
  12. package/migrations/versions/006-fts5-dedupe.js +155 -0
  13. package/openclaw.json.template +56 -0
  14. package/package.json +38 -3
  15. package/scripts/entrypoint.sh +496 -0
  16. package/scripts/patch-openclaw-audio.mjs +158 -0
  17. package/setup-server/public/index.html +124 -4
  18. package/setup-server/server.js +190 -24
  19. package/workspace/skills/google-calendar/SKILL.md +144 -0
  20. package/workspace/skills/retrieve-file/SKILL.md +41 -0
  21. package/workspace/system/AGENTS.md +85 -0
  22. package/workspace/system/IDENTITY.md +61 -0
  23. package/workspace/system/SOUL.md +71 -0
  24. package/workspace/system/TOOLS.md +306 -0
  25. package/workspace/system/limbo-skill.md +152 -0
  26. package/workspace/templates/USER.md.template +19 -0
  27. package/.gitlab-ci.yml +0 -209
  28. package/ARCHITECTURE.md +0 -174
  29. package/CONTRIBUTING.md +0 -34
  30. package/SECURITY.md +0 -108
  31. package/docker-compose.dev.yml +0 -12
  32. package/docker-compose.test.yml +0 -74
  33. package/evals/config.eval.env +0 -9
  34. package/evals/dashboard/public/app.js +0 -975
  35. package/evals/dashboard/public/index.html +0 -89
  36. package/evals/dashboard/public/styles.css +0 -908
  37. package/evals/dashboard/server.js +0 -129
  38. package/evals/docker-compose.eval.yml +0 -57
  39. package/evals/promptfoo/assertions.js +0 -215
  40. package/evals/promptfoo/hooks.js +0 -119
  41. package/evals/promptfoo/promptfooconfig.yaml +0 -106
  42. package/evals/promptfoo/provider.js +0 -206
  43. package/evals/promptfoo/run.sh +0 -25
  44. package/evals/promptfoo/seeds/notes/eval-seed-birthday.md +0 -9
  45. package/evals/results/.gitkeep +0 -0
  46. package/evals/results/history/.gitkeep +0 -0
  47. package/evals/results/history/run-1774559258082.json +0 -662
  48. package/evals/results/history/run-1774559485256.json +0 -662
  49. package/evals/results/history/run-1774559674855.json +0 -662
  50. package/evals/results/history/run-1774561108314.json +0 -662
  51. package/evals/results/history/run-1774561286576.json +0 -662
  52. package/evals/results/history/run-1774561575363.json +0 -575
  53. package/evals/results/history/run-1774563070869.json +0 -662
  54. package/evals/results/history/run-1774563275178.json +0 -662
  55. package/evals/results/history/run-1774622867363.json +0 -934
  56. package/evals/results/history/run-1774623126438.json +0 -934
  57. package/evals/results/history/run-1774624683868.json +0 -934
  58. package/evals/results/history/run-1774625379694.json +0 -934
  59. package/evals/results/history/run-1774629331960.json +0 -746
  60. package/evals/results/history/run-1774632319238.json +0 -39
  61. package/evals/results/history/run-1774633277690.json +0 -94
  62. package/evals/results/history/run-1774636000952.json +0 -934
  63. package/evals/results/history/run-1774636946600.json +0 -151
  64. package/evals/results/history/run-1774637141591.json +0 -374
  65. package/evals/results/history/run-1774639388611.json +0 -1578
  66. package/evals/results/history/run-1774641629961.json +0 -1523
  67. package/evals/results/history/run-1774643063585.json +0 -1653
  68. package/evals/results/history/run-1774644145726.json +0 -73
  69. package/evals/results/history/run-1774644299624.json +0 -1489
  70. package/evals/results/history/run-1774644416754.json +0 -58
  71. package/evals/results/history/run-1774644909594.json +0 -58
  72. package/evals/results/history/run-1774796618679.json +0 -73
  73. package/evals/results/history/run-1774796879800.json +0 -73
  74. package/evals/results/history/run-1774797434760.json +0 -94
  75. package/evals/results/history/run-1774797567080.json +0 -57
  76. package/evals/results/history/run-1774895167606.json +0 -574
  77. package/evals/results/history/run-1774895670045.json +0 -540
  78. package/evals/results/history/run-1774895876781.json +0 -466
  79. package/evals/results/history/run-1774898060232.json +0 -162
  80. package/evals/results/history/run-1774966775381.json +0 -135
  81. package/evals/results/history/run-1774966839076.json +0 -33
  82. package/evals/results/history/run-1774966890459.json +0 -33
  83. package/evals/results/history/run-1774967730887.json +0 -189
  84. package/evals/results/history/run-1774967764419.json +0 -113
  85. package/evals/results/history/run-1775043267611.json +0 -4470
  86. package/evals/results/history/run-1775046132278.json +0 -4420
  87. package/evals/results/history/run-1775068115506.json +0 -5277
  88. package/evals/results/latest.json +0 -5277
  89. package/evals/test/scorer.test.js +0 -218
  90. package/mcp-server/test/benchmark.js +0 -365
  91. package/mcp-server/test/eval-logging.test.js +0 -259
  92. package/mcp-server/test/get-file.test.js +0 -256
  93. package/test/cli-auth.test.js +0 -357
  94. package/test/cli-compose.test.js +0 -471
  95. package/test/cli-filter.test.js +0 -200
  96. package/test/cli-registry.test.js +0 -95
  97. package/test/cli-wizard-parity.test.js +0 -227
  98. package/test/entrypoint.test.js +0 -873
  99. package/test/fts.test.js +0 -141
  100. package/test/mcp-tools.test.js +0 -606
  101. package/test/openclaw-migration.test.js +0 -317
  102. package/test/red-phase.test.js +0 -181
  103. package/test/sanitize-control-chars.test.js +0 -92
  104. package/test/setup-server.test.js +0 -784
  105. package/test/update-system.test.js +0 -210
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/cli.js CHANGED
@@ -768,6 +768,7 @@ function normalizeConfig(cfg, existingEnv = {}) {
768
768
  GATEWAY_TOKEN: gatewayToken,
769
769
  VOICE_ENABLED: cfg.voiceEnabled || existingEnv.VOICE_ENABLED || 'false',
770
770
  WEB_SEARCH_ENABLED: cfg.webSearchEnabled || existingEnv.WEB_SEARCH_ENABLED || 'false',
771
+ GOOGLE_CALENDAR_ENABLED: cfg.googleCalendarEnabled || existingEnv.GOOGLE_CALENDAR_ENABLED || 'false',
771
772
  };
772
773
 
773
774
  return base;
@@ -2341,6 +2342,63 @@ async function cmdSwitchBrain() {
2341
2342
  } catch {}
2342
2343
  }
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
+
2344
2402
  function cmdHelp() {
2345
2403
  console.log(`
2346
2404
  ${c.bold}limbo${c.reset} - personal AI memory agent
@@ -2354,9 +2412,10 @@ ${c.bold}Commands:${c.reset}
2354
2412
  logs Tail container logs
2355
2413
  update Pull latest image and restart
2356
2414
  status Show container status
2357
- config Configure optional features (voice, web-search)
2358
- switch-brain Change your AI provider (opens a quick wizard)
2359
- 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
2360
2419
 
2361
2420
  ${c.bold}Flags:${c.reset}
2362
2421
  --cli Use interactive CLI prompts instead of the web setup wizard
@@ -2481,6 +2540,7 @@ if (require.main === module) {
2481
2540
  case 'status': cmdStatus(); break;
2482
2541
  case 'config': cmdConfig(); break;
2483
2542
  case 'switch-brain': await cmdSwitchBrain(); break;
2543
+ case 'connect-calendar': await cmdConnectCalendar(); break;
2484
2544
  case 'version':
2485
2545
  case '--version':
2486
2546
  case '-v': console.log(require('./package.json').version); break;
@@ -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 { calendarRead, calendarCreate, calendarDelete, calendarUpdate } from "./tools/google-calendar.js";
18
19
  import { updateInstance } from "./tools/update-instance.js";
19
20
 
20
21
  /**
@@ -260,6 +261,126 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
260
261
  required: ["filename", "content"],
261
262
  },
262
263
  },
264
+ {
265
+ name: "calendar_read",
266
+ description:
267
+ "List upcoming Google Calendar events. Returns events within a date range. Defaults to today if no range specified. Use when the user asks about their schedule, meetings, or availability.",
268
+ inputSchema: {
269
+ type: "object",
270
+ properties: {
271
+ startDate: {
272
+ type: "string",
273
+ description:
274
+ "Start of range in ISO 8601 date format (e.g. '2026-04-09'). Defaults to today.",
275
+ },
276
+ endDate: {
277
+ type: "string",
278
+ description:
279
+ "End of range in ISO 8601 date format (e.g. '2026-04-10'). Defaults to end of startDate.",
280
+ },
281
+ maxResults: {
282
+ type: "number",
283
+ description:
284
+ "Maximum number of events to return. Default: 25, max: 100.",
285
+ },
286
+ },
287
+ required: [],
288
+ },
289
+ },
290
+ {
291
+ name: "calendar_create",
292
+ description:
293
+ "Create a new Google Calendar event. Requires a title and start time. Duration defaults to 60 minutes. Always confirm details with the user before creating. IMPORTANT: pass the user's timeZone (read from USER.md) so the event lands at the correct local time.",
294
+ inputSchema: {
295
+ type: "object",
296
+ properties: {
297
+ title: {
298
+ type: "string",
299
+ description: "Event title/summary",
300
+ },
301
+ startTime: {
302
+ type: "string",
303
+ description:
304
+ "Event start time. Either ISO 8601 without offset (e.g. '2026-04-09T14:00:00') — in which case pass timeZone — or with offset (e.g. '2026-04-09T14:00:00-03:00').",
305
+ },
306
+ duration: {
307
+ type: "number",
308
+ description: "Duration in minutes. Default: 60.",
309
+ },
310
+ description: {
311
+ type: "string",
312
+ description: "Optional event description/notes",
313
+ },
314
+ location: {
315
+ type: "string",
316
+ description: "Optional event location",
317
+ },
318
+ timeZone: {
319
+ type: "string",
320
+ description:
321
+ "IANA timezone identifier (e.g. 'America/Argentina/Buenos_Aires'). Read from USER.md. Required when startTime has no offset.",
322
+ },
323
+ },
324
+ required: ["title", "startTime"],
325
+ },
326
+ },
327
+ {
328
+ name: "calendar_delete",
329
+ description:
330
+ "Delete a Google Calendar event by its id. Get the id first via calendar_read. Always confirm with the user before deleting — this is irreversible.",
331
+ inputSchema: {
332
+ type: "object",
333
+ properties: {
334
+ eventId: {
335
+ type: "string",
336
+ description: "Google Calendar event id (from calendar_read results)",
337
+ },
338
+ },
339
+ required: ["eventId"],
340
+ },
341
+ },
342
+ {
343
+ name: "calendar_update",
344
+ description:
345
+ "Update an existing Google Calendar event. Only the provided fields are changed (PATCH). Get the event id first via calendar_read. Always confirm changes with the user before applying.",
346
+ inputSchema: {
347
+ type: "object",
348
+ properties: {
349
+ eventId: {
350
+ type: "string",
351
+ description: "Google Calendar event id (from calendar_read results)",
352
+ },
353
+ title: {
354
+ type: "string",
355
+ description: "New event title/summary",
356
+ },
357
+ startTime: {
358
+ type: "string",
359
+ description:
360
+ "New event start time. ISO 8601, with or without offset. When changing the time, pass timeZone too.",
361
+ },
362
+ duration: {
363
+ type: "number",
364
+ description:
365
+ "New duration in minutes. Must be passed together with startTime (duration-only updates not supported).",
366
+ },
367
+ description: {
368
+ type: "string",
369
+ description: "New event description",
370
+ },
371
+ location: {
372
+ type: "string",
373
+ description: "New event location",
374
+ },
375
+ timeZone: {
376
+ type: "string",
377
+ description:
378
+ "IANA timezone (e.g. 'America/Argentina/Buenos_Aires'). Read from USER.md. Required when startTime has no offset.",
379
+ },
380
+ },
381
+ required: ["eventId"],
382
+ },
383
+ },
263
384
  {
264
385
  name: "update_instance",
265
386
  description:
@@ -277,7 +398,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
277
398
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
278
399
  const { name, arguments: args } = request.params;
279
400
 
280
- log(`tool_call: ${name}`);
401
+ // Include params as JSON in the log line so eval assertions can verify
402
+ // tool arguments (e.g. calendar_create was called with the right timeZone).
403
+ // The provider's parser matches `tool_call: <name>` first — the trailing
404
+ // JSON is ignored by the existing regex, so this is backwards compatible.
405
+ const paramsStr = args && Object.keys(args).length ? ` params=${JSON.stringify(args)}` : "";
406
+ log(`tool_call: ${name}${paramsStr}`);
281
407
  evalLog({ type: "tool_call", tool: name, params: args });
282
408
 
283
409
  try {
@@ -381,6 +507,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
381
507
  break;
382
508
  }
383
509
 
510
+ case "calendar_read": {
511
+ const events = await calendarRead(args);
512
+ result = {
513
+ content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
514
+ };
515
+ break;
516
+ }
517
+
518
+ case "calendar_create": {
519
+ const event = await calendarCreate(args);
520
+ result = {
521
+ content: [{ type: "text", text: JSON.stringify(event, null, 2) }],
522
+ };
523
+ break;
524
+ }
525
+
526
+ case "calendar_delete": {
527
+ const deleted = await calendarDelete(args);
528
+ result = {
529
+ content: [{ type: "text", text: JSON.stringify(deleted, null, 2) }],
530
+ };
531
+ break;
532
+ }
533
+
534
+ case "calendar_update": {
535
+ const updated = await calendarUpdate(args);
536
+ result = {
537
+ content: [{ type: "text", text: JSON.stringify(updated, null, 2) }],
538
+ };
539
+ break;
540
+ }
541
+
384
542
  case "update_instance": {
385
543
  result = await updateInstance();
386
544
  break;
@@ -0,0 +1,277 @@
1
+ // mcp-server/tools/google-calendar.js — Google Calendar MCP tools (gws CLI wrappers)
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ function ensureEnabled() {
8
+ if (process.env.GOOGLE_CALENDAR_ENABLED !== 'true') {
9
+ throw new Error(
10
+ 'Google Calendar is not connected. Enable it by running: limbo connect-calendar'
11
+ );
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Call gws CLI and return parsed JSON output.
17
+ * gws reads credentials from GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE env var.
18
+ *
19
+ * cwd=/tmp: gws ALWAYS writes a side-file to cwd (e.g. "download.html" for
20
+ * empty-body responses). On read-only root filesystems this fails with
21
+ * "Failed to create output file". /tmp is a tmpfs in the hardened container.
22
+ */
23
+ async function callGws(args) {
24
+ const { stdout } = await execFileAsync('gws', args, {
25
+ env: process.env,
26
+ cwd: '/tmp',
27
+ timeout: 30_000,
28
+ });
29
+ return JSON.parse(stdout);
30
+ }
31
+
32
+ /**
33
+ * Call gws CLI for operations that return no body (HTTP 204, e.g. delete).
34
+ * Skip JSON parsing — gws still writes a placeholder file but we don't read it.
35
+ */
36
+ async function callGwsNoBody(args) {
37
+ await execFileAsync('gws', args, {
38
+ env: process.env,
39
+ cwd: '/tmp',
40
+ timeout: 30_000,
41
+ });
42
+ }
43
+
44
+ /**
45
+ * List Google Calendar events for a date range.
46
+ * @param {object} opts
47
+ * @param {string} [opts.startDate] - ISO date, defaults to today
48
+ * @param {string} [opts.endDate] - ISO date, defaults to end of startDate
49
+ * @param {number} [opts.maxResults] - max events, default 25
50
+ */
51
+ export async function calendarRead({ startDate, endDate, maxResults } = {}) {
52
+ ensureEnabled();
53
+
54
+ const now = new Date();
55
+ // Default start = today at 00:00 (local time)
56
+ const start = startDate ? new Date(startDate) : new Date(now.getFullYear(), now.getMonth(), now.getDate());
57
+ // Default end = start + 24h (full day of events).
58
+ // If endDate is given and equals startDate (or is a bare date), extend to end-of-day
59
+ // so the caller gets all events on that day, not an empty range.
60
+ let end;
61
+ if (endDate) {
62
+ end = new Date(endDate);
63
+ if (end.getTime() <= start.getTime()) {
64
+ end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
65
+ }
66
+ } else {
67
+ end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
68
+ }
69
+
70
+ // Google Calendar API wants RFC 3339 timestamps
71
+ const timeMin = start.toISOString();
72
+ const timeMax = end.toISOString();
73
+ const max = Math.min(maxResults || 25, 100);
74
+
75
+ const params = {
76
+ calendarId: 'primary',
77
+ timeMin,
78
+ timeMax,
79
+ maxResults: max,
80
+ singleEvents: true,
81
+ orderBy: 'startTime',
82
+ };
83
+
84
+ const result = await callGws([
85
+ 'calendar', 'events', 'list',
86
+ '--params', JSON.stringify(params),
87
+ '--format', 'json',
88
+ ]);
89
+
90
+ // Map to simplified format
91
+ const items = result.items || [];
92
+ return items.map(ev => ({
93
+ id: ev.id,
94
+ summary: ev.summary || '(no title)',
95
+ start: ev.start?.dateTime || ev.start?.date || null,
96
+ end: ev.end?.dateTime || ev.end?.date || null,
97
+ location: ev.location || null,
98
+ status: ev.status || 'confirmed',
99
+ htmlLink: ev.htmlLink || null,
100
+ }));
101
+ }
102
+
103
+ /**
104
+ * Create a Google Calendar event.
105
+ * @param {object} opts
106
+ * @param {string} opts.title - Event summary (required)
107
+ * @param {string} opts.startTime - ISO datetime, with or without timezone offset (required)
108
+ * @param {number} [opts.duration] - Minutes, default 60
109
+ * @param {string} [opts.description] - Event description
110
+ * @param {string} [opts.location] - Event location
111
+ * @param {string} [opts.timeZone] - IANA timezone (e.g. "America/Argentina/Buenos_Aires").
112
+ * If the startTime has no offset, this tells Google how to interpret it.
113
+ * The agent should pass this from USER.md.
114
+ */
115
+ export async function calendarCreate({ title, startTime, duration, description, location, timeZone } = {}) {
116
+ ensureEnabled();
117
+
118
+ if (!title) throw new Error('title is required');
119
+ if (!startTime) throw new Error('startTime is required');
120
+
121
+ // Preserve the literal startTime string if the agent passed one without offset —
122
+ // Google will interpret it using the provided timeZone. If the agent passed an
123
+ // offset (e.g. "2026-04-11T11:00:00-03:00"), normalize to ISO.
124
+ const hasOffset = /[+-]\d{2}:\d{2}$|Z$/.test(startTime);
125
+ let startDateTimeStr;
126
+ let endDateTimeStr;
127
+ const durationMin = duration || 60;
128
+
129
+ if (hasOffset) {
130
+ const start = new Date(startTime);
131
+ const end = new Date(start.getTime() + durationMin * 60 * 1000);
132
+ startDateTimeStr = start.toISOString();
133
+ endDateTimeStr = end.toISOString();
134
+ } else {
135
+ // No offset — keep it as a "floating" local time and let Google interpret it
136
+ // via the timeZone field. Compute end by parsing the local-time components.
137
+ const m = startTime.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/);
138
+ if (!m) throw new Error(`Invalid startTime format: ${startTime}`);
139
+ const [, y, mo, d, h, min, s] = m;
140
+ const startMs = Date.UTC(+y, +mo - 1, +d, +h, +min, +(s || 0));
141
+ const endMs = startMs + durationMin * 60 * 1000;
142
+ const fmt = (ms) => {
143
+ const dt = new Date(ms);
144
+ return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}T${String(dt.getUTCHours()).padStart(2, '0')}:${String(dt.getUTCMinutes()).padStart(2, '0')}:${String(dt.getUTCSeconds()).padStart(2, '0')}`;
145
+ };
146
+ startDateTimeStr = fmt(startMs);
147
+ endDateTimeStr = fmt(endMs);
148
+ }
149
+
150
+ const event = {
151
+ summary: title,
152
+ start: { dateTime: startDateTimeStr },
153
+ end: { dateTime: endDateTimeStr },
154
+ };
155
+ // If the agent provided a timeZone (from USER.md), pass it to Google so the
156
+ // dateTime is interpreted correctly regardless of container clock.
157
+ if (timeZone) {
158
+ event.start.timeZone = timeZone;
159
+ event.end.timeZone = timeZone;
160
+ }
161
+ if (description) event.description = description;
162
+ if (location) event.location = location;
163
+
164
+ const result = await callGws([
165
+ 'calendar', 'events', 'insert',
166
+ '--params', JSON.stringify({ calendarId: 'primary' }),
167
+ '--json', JSON.stringify(event),
168
+ '--format', 'json',
169
+ ]);
170
+
171
+ return {
172
+ id: result.id,
173
+ summary: result.summary,
174
+ start: result.start?.dateTime || result.start?.date,
175
+ end: result.end?.dateTime || result.end?.date,
176
+ htmlLink: result.htmlLink || null,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Delete a Google Calendar event by id.
182
+ * @param {object} opts
183
+ * @param {string} opts.eventId - The Google Calendar event id (from calendar_read)
184
+ */
185
+ export async function calendarDelete({ eventId } = {}) {
186
+ ensureEnabled();
187
+ if (!eventId) throw new Error('eventId is required');
188
+
189
+ // events.delete returns HTTP 204 No Content — no JSON body to parse.
190
+ // Passing --format json triggers gws to create an output file on disk,
191
+ // which fails on read-only filesystems.
192
+ await callGwsNoBody([
193
+ 'calendar', 'events', 'delete',
194
+ '--params', JSON.stringify({ calendarId: 'primary', eventId }),
195
+ ]);
196
+
197
+ return { id: eventId, deleted: true };
198
+ }
199
+
200
+ /**
201
+ * Update an existing Google Calendar event by id. Only the provided fields
202
+ * are changed (PATCH semantics).
203
+ * @param {object} opts
204
+ * @param {string} opts.eventId - Event id (required)
205
+ * @param {string} [opts.title] - New title/summary
206
+ * @param {string} [opts.startTime] - New start time (ISO, with or without offset)
207
+ * @param {number} [opts.duration] - New duration in minutes (updates endTime relative to startTime)
208
+ * @param {string} [opts.description] - New description
209
+ * @param {string} [opts.location] - New location
210
+ * @param {string} [opts.timeZone] - IANA timezone, passed from USER.md
211
+ */
212
+ export async function calendarUpdate({ eventId, title, startTime, duration, description, location, timeZone } = {}) {
213
+ ensureEnabled();
214
+ if (!eventId) throw new Error('eventId is required');
215
+
216
+ const patch = {};
217
+ if (title) patch.summary = title;
218
+ if (description) patch.description = description;
219
+ if (location) patch.location = location;
220
+
221
+ if (startTime) {
222
+ // Same parsing logic as calendarCreate — respect floating time + timeZone.
223
+ const hasOffset = /[+-]\d{2}:\d{2}$|Z$/.test(startTime);
224
+ const durationMin = duration || 60;
225
+ let startDateTimeStr;
226
+ let endDateTimeStr;
227
+
228
+ if (hasOffset) {
229
+ const start = new Date(startTime);
230
+ const end = new Date(start.getTime() + durationMin * 60 * 1000);
231
+ startDateTimeStr = start.toISOString();
232
+ endDateTimeStr = end.toISOString();
233
+ } else {
234
+ const m = startTime.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/);
235
+ if (!m) throw new Error(`Invalid startTime format: ${startTime}`);
236
+ const [, y, mo, d, h, min, s] = m;
237
+ const startMs = Date.UTC(+y, +mo - 1, +d, +h, +min, +(s || 0));
238
+ const endMs = startMs + durationMin * 60 * 1000;
239
+ const fmt = (ms) => {
240
+ const dt = new Date(ms);
241
+ return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}T${String(dt.getUTCHours()).padStart(2, '0')}:${String(dt.getUTCMinutes()).padStart(2, '0')}:${String(dt.getUTCSeconds()).padStart(2, '0')}`;
242
+ };
243
+ startDateTimeStr = fmt(startMs);
244
+ endDateTimeStr = fmt(endMs);
245
+ }
246
+
247
+ patch.start = { dateTime: startDateTimeStr };
248
+ patch.end = { dateTime: endDateTimeStr };
249
+ if (timeZone) {
250
+ patch.start.timeZone = timeZone;
251
+ patch.end.timeZone = timeZone;
252
+ }
253
+ } else if (duration) {
254
+ // Duration change without startTime change: we'd need to read the current event
255
+ // first to compute the new end. Not supported yet — require startTime for now.
256
+ throw new Error('duration-only updates are not supported yet; pass startTime too');
257
+ }
258
+
259
+ if (Object.keys(patch).length === 0) {
260
+ throw new Error('No fields to update. Provide at least one of: title, startTime, description, location.');
261
+ }
262
+
263
+ const result = await callGws([
264
+ 'calendar', 'events', 'patch',
265
+ '--params', JSON.stringify({ calendarId: 'primary', eventId }),
266
+ '--json', JSON.stringify(patch),
267
+ '--format', 'json',
268
+ ]);
269
+
270
+ return {
271
+ id: result.id,
272
+ summary: result.summary,
273
+ start: result.start?.dateTime || result.start?.date,
274
+ end: result.end?.dateTime || result.end?.date,
275
+ htmlLink: result.htmlLink || null,
276
+ };
277
+ }