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.
- package/Dockerfile +116 -0
- package/cli.js +63 -3
- package/mcp-server/index.js +159 -1
- package/mcp-server/tools/google-calendar.js +277 -0
- package/migrations/index.js +199 -0
- package/migrations/package.json +3 -0
- package/migrations/versions/001-initial-schema.js +47 -0
- package/migrations/versions/002-unified-note-types.js +150 -0
- package/migrations/versions/003-zeroclaw-migration.js +24 -0
- package/migrations/versions/004-assets-directory.js +25 -0
- package/migrations/versions/005-fts5-search.js +137 -0
- package/migrations/versions/006-fts5-dedupe.js +155 -0
- package/openclaw.json.template +56 -0
- package/package.json +38 -3
- package/scripts/entrypoint.sh +496 -0
- package/scripts/patch-openclaw-audio.mjs +158 -0
- package/setup-server/public/index.html +124 -4
- package/setup-server/server.js +190 -24
- package/workspace/skills/google-calendar/SKILL.md +144 -0
- package/workspace/skills/retrieve-file/SKILL.md +41 -0
- package/workspace/system/AGENTS.md +85 -0
- package/workspace/system/IDENTITY.md +61 -0
- package/workspace/system/SOUL.md +71 -0
- package/workspace/system/TOOLS.md +306 -0
- package/workspace/system/limbo-skill.md +152 -0
- package/workspace/templates/USER.md.template +19 -0
- package/.gitlab-ci.yml +0 -209
- package/ARCHITECTURE.md +0 -174
- package/CONTRIBUTING.md +0 -34
- package/SECURITY.md +0 -108
- package/docker-compose.dev.yml +0 -12
- package/docker-compose.test.yml +0 -74
- package/evals/config.eval.env +0 -9
- package/evals/dashboard/public/app.js +0 -975
- package/evals/dashboard/public/index.html +0 -89
- package/evals/dashboard/public/styles.css +0 -908
- package/evals/dashboard/server.js +0 -129
- package/evals/docker-compose.eval.yml +0 -57
- package/evals/promptfoo/assertions.js +0 -215
- package/evals/promptfoo/hooks.js +0 -119
- package/evals/promptfoo/promptfooconfig.yaml +0 -106
- package/evals/promptfoo/provider.js +0 -206
- package/evals/promptfoo/run.sh +0 -25
- package/evals/promptfoo/seeds/notes/eval-seed-birthday.md +0 -9
- package/evals/results/.gitkeep +0 -0
- package/evals/results/history/.gitkeep +0 -0
- package/evals/results/history/run-1774559258082.json +0 -662
- package/evals/results/history/run-1774559485256.json +0 -662
- package/evals/results/history/run-1774559674855.json +0 -662
- package/evals/results/history/run-1774561108314.json +0 -662
- package/evals/results/history/run-1774561286576.json +0 -662
- package/evals/results/history/run-1774561575363.json +0 -575
- package/evals/results/history/run-1774563070869.json +0 -662
- package/evals/results/history/run-1774563275178.json +0 -662
- package/evals/results/history/run-1774622867363.json +0 -934
- package/evals/results/history/run-1774623126438.json +0 -934
- package/evals/results/history/run-1774624683868.json +0 -934
- package/evals/results/history/run-1774625379694.json +0 -934
- package/evals/results/history/run-1774629331960.json +0 -746
- package/evals/results/history/run-1774632319238.json +0 -39
- package/evals/results/history/run-1774633277690.json +0 -94
- package/evals/results/history/run-1774636000952.json +0 -934
- package/evals/results/history/run-1774636946600.json +0 -151
- package/evals/results/history/run-1774637141591.json +0 -374
- package/evals/results/history/run-1774639388611.json +0 -1578
- package/evals/results/history/run-1774641629961.json +0 -1523
- package/evals/results/history/run-1774643063585.json +0 -1653
- package/evals/results/history/run-1774644145726.json +0 -73
- package/evals/results/history/run-1774644299624.json +0 -1489
- package/evals/results/history/run-1774644416754.json +0 -58
- package/evals/results/history/run-1774644909594.json +0 -58
- package/evals/results/history/run-1774796618679.json +0 -73
- package/evals/results/history/run-1774796879800.json +0 -73
- package/evals/results/history/run-1774797434760.json +0 -94
- package/evals/results/history/run-1774797567080.json +0 -57
- package/evals/results/history/run-1774895167606.json +0 -574
- package/evals/results/history/run-1774895670045.json +0 -540
- package/evals/results/history/run-1774895876781.json +0 -466
- package/evals/results/history/run-1774898060232.json +0 -162
- package/evals/results/history/run-1774966775381.json +0 -135
- package/evals/results/history/run-1774966839076.json +0 -33
- package/evals/results/history/run-1774966890459.json +0 -33
- package/evals/results/history/run-1774967730887.json +0 -189
- package/evals/results/history/run-1774967764419.json +0 -113
- package/evals/results/history/run-1775043267611.json +0 -4470
- package/evals/results/history/run-1775046132278.json +0 -4420
- package/evals/results/history/run-1775068115506.json +0 -5277
- package/evals/results/latest.json +0 -5277
- package/evals/test/scorer.test.js +0 -218
- package/mcp-server/test/benchmark.js +0 -365
- package/mcp-server/test/eval-logging.test.js +0 -259
- package/mcp-server/test/get-file.test.js +0 -256
- package/test/cli-auth.test.js +0 -357
- package/test/cli-compose.test.js +0 -471
- package/test/cli-filter.test.js +0 -200
- package/test/cli-registry.test.js +0 -95
- package/test/cli-wizard-parity.test.js +0 -227
- package/test/entrypoint.test.js +0 -873
- package/test/fts.test.js +0 -141
- package/test/mcp-tools.test.js +0 -606
- package/test/openclaw-migration.test.js +0 -317
- package/test/red-phase.test.js +0 -181
- package/test/sanitize-control-chars.test.js +0 -92
- package/test/setup-server.test.js +0 -784
- 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
|
|
2358
|
-
switch-brain
|
|
2359
|
-
|
|
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;
|
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 { 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
|
|
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
|
+
}
|