limbo-ai 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/ARCHITECTURE.md +178 -0
  2. package/README.md +16 -8
  3. package/assets/og-banner.png +0 -0
  4. package/cli.js +287 -16
  5. package/config.toml.template +4 -0
  6. package/docker-compose.test.yml +5 -0
  7. package/evals/cases/create-recurring-reminder.json +40 -0
  8. package/evals/cases/create-reminder.json +29 -8
  9. package/evals/cases/medium-search-implicit.json +1 -1
  10. package/evals/cases/reminder-timezone.json +4 -1
  11. package/evals/cases/search-subdirectory-note.json +24 -0
  12. package/evals/cases/speed-search-broad.json +14 -0
  13. package/evals/cases/speed-search-simple.json +14 -0
  14. package/evals/cases/speed-write-and-search.json +25 -0
  15. package/evals/cases/telegram-audio.json +19 -0
  16. package/evals/cases/telegram-pdf.json +21 -0
  17. package/evals/cases/web-search.json +1 -1
  18. package/evals/cases/workspace-read-identity.json +14 -0
  19. package/evals/cases/workspace-write-timezone.json +17 -0
  20. package/evals/cases/workspace-write-username.json +18 -0
  21. package/evals/cli.js +622 -73
  22. package/evals/config.eval.env +8 -0
  23. package/evals/dashboard/public/app.js +690 -370
  24. package/evals/dashboard/public/index.html +31 -38
  25. package/evals/dashboard/public/styles.css +521 -345
  26. package/evals/dashboard/server.js +22 -14
  27. package/evals/docker-compose.eval.yml +12 -3
  28. package/evals/lib/scorer.js +95 -9
  29. package/evals/lib/vault-diff.js +41 -1
  30. package/evals/results/baseline.json +928 -101
  31. package/evals/results/baselines/anthropic__claude-sonnet-4-6__default-full.json +1653 -0
  32. package/evals/results/baselines/anthropic__claude-sonnet-4-6__medium/search-subdirectory-note.json +140 -0
  33. package/evals/results/baselines/anthropic__claude-sonnet-4-6__medium-full.json +1489 -0
  34. package/evals/results/baselines-index.json +38 -0
  35. package/evals/results/history/run-1774561108314.json +662 -0
  36. package/evals/results/history/run-1774561286576.json +662 -0
  37. package/evals/results/history/run-1774561575363.json +575 -0
  38. package/evals/results/history/run-1774563070869.json +662 -0
  39. package/evals/results/history/run-1774563275178.json +662 -0
  40. package/evals/results/history/run-1774622867363.json +934 -0
  41. package/evals/results/history/run-1774623126438.json +934 -0
  42. package/evals/results/history/run-1774624683868.json +934 -0
  43. package/evals/results/history/run-1774625379694.json +934 -0
  44. package/evals/results/history/run-1774629331960.json +746 -0
  45. package/evals/results/history/run-1774632319238.json +39 -0
  46. package/evals/results/history/run-1774633277690.json +94 -0
  47. package/evals/results/history/run-1774636000952.json +934 -0
  48. package/evals/results/history/run-1774636946600.json +151 -0
  49. package/evals/results/history/run-1774637141591.json +374 -0
  50. package/evals/results/history/run-1774639388611.json +1578 -0
  51. package/evals/results/history/run-1774641629961.json +1523 -0
  52. package/evals/results/history/run-1774643063585.json +1653 -0
  53. package/evals/results/history/run-1774644145726.json +73 -0
  54. package/evals/results/history/run-1774644299624.json +1489 -0
  55. package/evals/results/history/run-1774644416754.json +58 -0
  56. package/evals/results/history/run-1774644909594.json +58 -0
  57. package/evals/results/history/run-1774796618679.json +73 -0
  58. package/evals/results/history/run-1774796879800.json +73 -0
  59. package/evals/results/history/run-1774797434760.json +94 -0
  60. package/evals/results/history/run-1774797567080.json +57 -0
  61. package/evals/results/history/run-1774898060232.json +162 -0
  62. package/evals/results/history/run-1774966775381.json +135 -0
  63. package/evals/results/history/run-1774966839076.json +33 -0
  64. package/evals/results/history/run-1774966890459.json +33 -0
  65. package/evals/results/history/run-1774967730887.json +189 -0
  66. package/evals/results/history/run-1774967764419.json +113 -0
  67. package/evals/results/latest.json +116 -616
  68. package/evals/test/scorer.test.js +38 -0
  69. package/evals/vault-seed/.README +4 -0
  70. package/evals/vault-seed/notes/analysis-personal-006.md +10 -0
  71. package/evals/vault-seed/notes/analysis-personal-016.md +10 -0
  72. package/evals/vault-seed/notes/analysis-personal-026.md +10 -0
  73. package/evals/vault-seed/notes/brainstorm-tech-005.md +10 -0
  74. package/evals/vault-seed/notes/brainstorm-tech-015.md +10 -0
  75. package/evals/vault-seed/notes/brainstorm-tech-025.md +10 -0
  76. package/evals/vault-seed/notes/comparison-work-007.md +10 -0
  77. package/evals/vault-seed/notes/comparison-work-017.md +10 -0
  78. package/evals/vault-seed/notes/comparison-work-027.md +10 -0
  79. package/evals/vault-seed/notes/decision-use-postgres.md +10 -0
  80. package/evals/vault-seed/notes/draft-health-008.md +10 -0
  81. package/evals/vault-seed/notes/draft-health-018.md +10 -0
  82. package/evals/vault-seed/notes/draft-health-028.md +10 -0
  83. package/evals/vault-seed/notes/event-dentist-march.md +10 -0
  84. package/evals/vault-seed/notes/fact-alergia-mani.md +10 -0
  85. package/evals/vault-seed/notes/fact-timezone-argentina.md +10 -0
  86. package/evals/vault-seed/notes/follow-up-personal-001.md +10 -0
  87. package/evals/vault-seed/notes/follow-up-personal-011.md +10 -0
  88. package/evals/vault-seed/notes/follow-up-personal-021.md +10 -0
  89. package/evals/vault-seed/notes/follow-up-personal-031.md +10 -0
  90. package/evals/vault-seed/notes/idea-whatsapp-agent.md +10 -0
  91. package/evals/vault-seed/notes/insight-eval-tool-calling.md +10 -0
  92. package/evals/vault-seed/notes/meeting-tech-000.md +10 -0
  93. package/evals/vault-seed/notes/meeting-tech-010.md +10 -0
  94. package/evals/vault-seed/notes/meeting-tech-020.md +10 -0
  95. package/evals/vault-seed/notes/meeting-tech-030.md +10 -0
  96. package/evals/vault-seed/notes/newsletter/newsletter-7-ideas.md +11 -0
  97. package/evals/vault-seed/notes/persona-carlos-ward.md +10 -0
  98. package/evals/vault-seed/notes/persona-lucas-tech.md +10 -0
  99. package/evals/vault-seed/notes/persona-maria-lopez.md +10 -0
  100. package/evals/vault-seed/notes/persona-sofia-globant.md +10 -0
  101. package/evals/vault-seed/notes/preference-asado-sundays.md +10 -0
  102. package/evals/vault-seed/notes/project-knok-alerts.md +10 -0
  103. package/evals/vault-seed/notes/project-limbo-memory-agent.md +10 -0
  104. package/evals/vault-seed/notes/question-kubernetes-scale.md +10 -0
  105. package/evals/vault-seed/notes/research-work-002.md +10 -0
  106. package/evals/vault-seed/notes/research-work-012.md +10 -0
  107. package/evals/vault-seed/notes/research-work-022.md +10 -0
  108. package/evals/vault-seed/notes/research-work-032.md +10 -0
  109. package/evals/vault-seed/notes/review-finance-004.md +10 -0
  110. package/evals/vault-seed/notes/review-finance-014.md +10 -0
  111. package/evals/vault-seed/notes/review-finance-024.md +10 -0
  112. package/evals/vault-seed/notes/review-finance-034.md +10 -0
  113. package/evals/vault-seed/notes/source-designing-data-intensive.md +10 -0
  114. package/evals/vault-seed/notes/summary-finance-009.md +10 -0
  115. package/evals/vault-seed/notes/summary-finance-019.md +10 -0
  116. package/evals/vault-seed/notes/summary-finance-029.md +10 -0
  117. package/evals/vault-seed/notes/update-health-003.md +10 -0
  118. package/evals/vault-seed/notes/update-health-013.md +10 -0
  119. package/evals/vault-seed/notes/update-health-023.md +10 -0
  120. package/evals/vault-seed/notes/update-health-033.md +10 -0
  121. package/mcp-server/fts.js +148 -0
  122. package/mcp-server/index.js +138 -2
  123. package/mcp-server/package-lock.json +433 -1
  124. package/mcp-server/package.json +2 -1
  125. package/mcp-server/test/eval-logging.test.js +5 -0
  126. package/mcp-server/tools/get-file.js +74 -0
  127. package/mcp-server/tools/search.js +3 -7
  128. package/mcp-server/tools/store-file.js +175 -0
  129. package/mcp-server/tools/workspace.js +56 -0
  130. package/mcp-server/tools/write.js +6 -0
  131. package/mcp-server/vault-index.js +31 -33
  132. package/package.json +1 -1
  133. package/setup-server/public/index.html +750 -675
  134. package/test/fts.test.js +141 -0
  135. package/test/zeroclaw-migration.test.js +40 -7
@@ -0,0 +1,178 @@
1
+ # Limbo — Architecture Reference
2
+
3
+ > This file is loaded by AI assistants to avoid re-scanning the codebase every session.
4
+ > Keep it updated when structure changes. Last verified: 2026-03-29.
5
+
6
+ ## What Is Limbo
7
+
8
+ Self-hosted personal AI memory agent. Runs as a Docker container exposing a ZeroClaw gateway (WebSocket on :18789). Users interact via Telegram. The agent stores and retrieves knowledge from a markdown vault using MCP tools.
9
+
10
+ **Stack**: ZeroClaw (Rust agent runtime, custom fork) + Node.js MCP server + SQLite FTS5 + Telegram bot.
11
+
12
+ **Published as**: `limbo-ai` on npm — the CLI (`npx limbo-ai`) handles install, start, stop, update, and setup.
13
+
14
+ ## High-Level Flow
15
+
16
+ ```
17
+ User (Telegram) → ZeroClaw Gateway (:18789) → LLM (configurable provider)
18
+
19
+ MCP Tools (stdio)
20
+
21
+ Vault (markdown + SQLite FTS5)
22
+ ```
23
+
24
+ ## Directory Structure
25
+
26
+ ```
27
+ limbo/
28
+ ├── cli.js # Main CLI (84KB) — install, start, stop, update, configure
29
+ ├── Dockerfile # Multi-stage: deps → zeroclaw binary → runtime (node:22-slim)
30
+ ├── config.toml.template # ZeroClaw config — rendered by entrypoint via envsubst
31
+ ├── docker-compose.yml # Production reference (generated per-user into ~/.limbo)
32
+ ├── docker-compose.dev.yml # Local dev
33
+ ├── docker-compose.test.yml # Local testing
34
+ ├── package.json # npm package: limbo-ai v1.20.4
35
+
36
+ ├── mcp-server/ # Node.js MCP server (JSON-RPC 2.0 over stdio)
37
+ │ ├── index.js # Entry point — tool routing, vault init, FTS setup
38
+ │ ├── vault-index.js # In-memory vault index (walks markdown files + YAML frontmatter)
39
+ │ ├── fts.js # SQLite FTS5 — BM25 scoring, title-weighted, WAL mode
40
+ │ └── tools/ # One file per MCP tool
41
+ │ ├── search.js # vault_search — FTS5 full-text search
42
+ │ ├── read.js # vault_read — O(1) lookup via in-memory index
43
+ │ ├── write.js # vault_write_note — create/update with YAML frontmatter
44
+ │ ├── update-map.js # vault_update_map — append entries to MOCs
45
+ │ ├── store-file.js # vault_store_file — binary files (images/PDFs) + linked note
46
+ │ └── get-file.js # vault_get_file — retrieve stored files as base64
47
+
48
+ ├── workspace/ # Agent persona files (injected into ZeroClaw context)
49
+ │ ├── system/ # Product-owned, root-owned, reset every boot
50
+ │ │ ├── AGENTS.md # Behavioral workflows and rules
51
+ │ │ ├── TOOLS.md # Tool usage instructions
52
+ │ │ └── limbo-skill.md # Agent skill definitions
53
+ │ └── templates/ # User-owned, seeded on first run only
54
+ │ ├── IDENTITY.md
55
+ │ ├── SOUL.md
56
+ │ └── USER.md.template # Rendered with envsubst on first run
57
+
58
+ ├── setup-server/ # Zero-dependency HTTP setup wizard (pure Node.js)
59
+ │ └── server.js # Serves on :18789 until config complete, then exits
60
+
61
+ ├── migrations/ # Data migration runner
62
+ │ ├── index.js # Runner — executes versioned migrations sequentially
63
+ │ └── versions/ # Individual migration files (4 versions)
64
+
65
+ ├── scripts/
66
+ │ ├── entrypoint.sh # Container startup (13KB) — 12-stage orchestration
67
+ │ ├── build-zeroclaw.sh # Custom ZeroClaw image builder (multi-platform)
68
+ │ └── install.sh # Server provisioning (Ubuntu/Debian)
69
+
70
+ ├── evals/ # End-to-end eval framework
71
+ │ ├── cli.js # Eval runner (28KB) — run, compare, promote, judge
72
+ │ ├── docker-compose.eval.yml
73
+ │ ├── cases/ # 20+ JSON test cases (search, create, multi-step, speed)
74
+ │ ├── vault-seed/ # Pre-populated vault for deterministic eval runs
75
+ │ ├── judge/ # LLM-as-judge rubrics
76
+ │ ├── lib/ # Shared eval utilities
77
+ │ ├── dashboard/ # Web UI for results
78
+ │ ├── results/ # Run outputs + baselines/
79
+ │ └── scripts/ # Eval helper scripts
80
+
81
+ ├── test/ # Unit tests (node --test)
82
+ │ ├── cli-filter.test.js
83
+ │ ├── cli-auth.test.js
84
+ │ ├── zeroclaw-migration.test.js
85
+ │ ├── setup-server.test.js
86
+ │ └── cli-wizard-parity.test.js
87
+
88
+ ├── docs/ # Public documentation
89
+ ├── agents/ # Paperclip agent configs (not deployed in Limbo)
90
+ └── squid/ # Squid proxy config (for container network access)
91
+ ```
92
+
93
+ ## Docker Build (3 stages)
94
+
95
+ 1. **deps** (node:22-slim) — `npm ci` + compile better-sqlite3 native addon
96
+ 2. **zeroclaw** — copies binary from custom image `ghcr.io/tomasward1/zeroclaw:<ver>-custom`
97
+ 3. **runtime** (node:22-slim) — non-root `limbo` user, copies app + binary + node_modules
98
+
99
+ **Data volume**: `/data` — contains vault/, db/, config/, logs/, backups/, memory/
100
+
101
+ **Build arg**: `ZEROCLAW_IMAGE` — override to test custom ZeroClaw builds locally.
102
+
103
+ ## Entrypoint Flow (scripts/entrypoint.sh)
104
+
105
+ 12-stage startup:
106
+ 1. Directory setup (`/data/*`)
107
+ 2. Secrets sync (`/run/secrets/` → `$ZEROCLAW_STATE_DIR/secrets/`)
108
+ 3. First-run detection (presence of `.env` in /data)
109
+ 4. Setup wizard (if no `MODEL_PROVIDER` in .env → serve wizard on :18789)
110
+ 5. Workspace file seeding (templates → /data, system files symlinked)
111
+ 6. Config template rendering (envsubst on config.toml.template)
112
+ 7. Feature sections (Telegram, Voice, Web Search) conditionally appended to config.toml
113
+ 8. Auth profiles generation
114
+ 9. Migration runner
115
+ 10. FTS index build
116
+ 11. MCP server registration
117
+ 12. ZeroClaw launch
118
+
119
+ ## MCP Server Details
120
+
121
+ - **Protocol**: JSON-RPC 2.0 over stdio
122
+ - **Invoked by ZeroClaw**: `node /app/mcp-server/index.js`
123
+ - **Vault path**: `/data/vault/` (markdown files with YAML frontmatter)
124
+ - **FTS database**: `/data/db/fts.db` (SQLite, WAL mode)
125
+ - **Index**: In-memory hashmap of all vault notes, rebuilt on startup
126
+
127
+ ### Frontmatter Schema
128
+
129
+ ```yaml
130
+ ---
131
+ id: unique-slug
132
+ title: Display Name
133
+ description: Falsifiable claim or summary
134
+ type: note|map|reminder|file
135
+ status: seed|growing|evergreen
136
+ domain: personal|tech|...
137
+ created: 2026-03-29
138
+ source: telegram|manual|...
139
+ topics:
140
+ - "[[related-note]]"
141
+ ---
142
+ ```
143
+
144
+ ## Key Architectural Decisions
145
+
146
+ These are documented in the vault but rarely change:
147
+
148
+ - **Extension = MCP tools, not ZeroClaw features**. New capabilities go in `mcp-server/tools/` as Node.js. Cargo features only for things that must compile into Rust (e.g., `rag-pdf`).
149
+ - **Separate container, not plugin**. Limbo is a standalone Docker container, not an OpenClaw plugin.
150
+ - **System files reset on boot, user files persist**. AGENTS.md/TOOLS.md overwrite from image; SOUL.md/IDENTITY.md/USER.md survive across container restarts.
151
+ - **Maps live in vault/maps/, notes in vault/notes/**. Separated to simplify `vault_update_map`.
152
+ - **Feature integration pattern**: wizard toggle → secret file → env var → entrypoint appends TOML section.
153
+ - **Minimal .env triggers setup wizard**. Container detects first run by absence of `MODEL_PROVIDER`.
154
+
155
+ ## Eval System
156
+
157
+ - 20+ JSON test cases in `evals/cases/`
158
+ - Each case: sends message via WebSocket, asserts on tool_called + response_matches + vault_state
159
+ - Current baseline: 94.0% (FTS5 + ZeroClaw v0.6.3)
160
+ - `node evals/cli.js run` → `compare --strict` → `promote`
161
+ - Uses real LLM calls (costs tokens)
162
+
163
+ ## Environment Variables
164
+
165
+ Key env vars (see `.env.example` for full list):
166
+ - `MODEL_PROVIDER` — anthropic, openai, etc.
167
+ - `TELEGRAM_ENABLED` — true/false
168
+ - `LIMBO_PORT` — gateway port (default 18789)
169
+ - `ZEROCLAW_STATE_DIR` — where ZeroClaw stores its state
170
+ - `LIMBO_EVAL` — enables MCP tool call logging
171
+
172
+ ## Testing
173
+
174
+ ```bash
175
+ npm test # runs: cli-filter, cli-auth, zeroclaw-migration, setup-server, cli-wizard-parity
176
+ ```
177
+
178
+ Tests use Node.js built-in test runner (`node --test`).
package/README.md CHANGED
@@ -1,14 +1,21 @@
1
- # Limbo
1
+ <p align="center">
2
+ <img src="assets/og-banner.png" alt="Limbo — Tu segundo cerebro" width="720" />
3
+ </p>
2
4
 
3
- [![npm](https://img.shields.io/npm/v/limbo-ai?color=blue&label=release)](https://www.npmjs.com/package/limbo-ai)
4
- [![build](https://img.shields.io/github/actions/workflow/status/TomasWard1/limbo/ci.yml?branch=staging&label=build)](https://github.com/TomasWard1/limbo/actions)
5
- [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
6
- [![platform](https://img.shields.io/badge/platform-linux%20%7C%20macOS-lightgrey)](.)
7
- [![docker](https://img.shields.io/badge/docker-%E2%9C%93-blue)](https://github.com/TomasWard1/limbo/pkgs/container/limbo)
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/limbo-ai"><img src="https://img.shields.io/npm/v/limbo-ai?color=blue&label=release" alt="npm" /></a>
7
+ <a href="https://github.com/TomasWard1/limbo/actions"><img src="https://img.shields.io/github/actions/workflow/status/TomasWard1/limbo/ci.yml?branch=staging&label=build" alt="build" /></a>
8
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="license" /></a>
9
+ <a href="."><img src="https://img.shields.io/badge/platform-linux%20%7C%20macOS-lightgrey" alt="platform" /></a>
10
+ <a href="https://github.com/TomasWard1/limbo/pkgs/container/limbo"><img src="https://img.shields.io/badge/docker-%E2%9C%93-blue" alt="docker" /></a>
11
+ <a href="https://github.com/TomasWard1/limbo"><img src="https://img.shields.io/github/stars/TomasWard1/limbo?style=social" alt="stars" /></a>
12
+ </p>
8
13
 
9
- A personal memory agent. Captures ideas, remembers things, and connects knowledge across time — running in a Docker container, accessible via Telegram or the ZeroClaw gateway.
14
+ <p align="center">A personal memory agent that captures ideas, remembers things, and connects knowledge across time.</p>
10
15
 
11
- Limbo is a second brain with a conversational interface. It stores atomic notes in a local vault, searches them semantically, and maintains Maps of Content (MOCs) to keep knowledge navigable.
16
+ ---
17
+
18
+ Limbo is a second brain with a conversational interface. It stores atomic notes in a local vault, searches them semantically, and maintains Maps of Content (MOCs) to keep knowledge navigable. Runs in a Docker container, accessible via Telegram or the ZeroClaw gateway.
12
19
 
13
20
  ---
14
21
 
@@ -208,6 +215,7 @@ Managed by `limbo start`, stored in `~/.limbo/.env`.
208
215
  | `AUTH_MODE` | `api-key` | `api-key` or `subscription` |
209
216
  | `MODEL_PROVIDER` | `anthropic` | `anthropic`, `openai`, `openai-codex`, or `openrouter` |
210
217
  | `MODEL_NAME` | `claude-sonnet-4-6` | Model to use |
218
+ | `RUNTIME_REASONING_EFFORT` | `medium` | ZeroClaw `runtime.reasoning_effort` override |
211
219
  | `TELEGRAM_ENABLED` | `false` | Enable Telegram integration |
212
220
  | `VOICE_ENABLED` | `false` | Enable Groq voice transcription |
213
221
  | `WEB_SEARCH_ENABLED` | `false` | Enable Brave web search |
Binary file
package/cli.js CHANGED
@@ -15,6 +15,7 @@ const readline = require('readline');
15
15
 
16
16
  const LIMBO_DIR = path.join(os.homedir(), '.limbo');
17
17
  const VAULT_DIR = path.join(LIMBO_DIR, 'vault');
18
+ const ZEROCLAW_STATE_DIR = path.join(LIMBO_DIR, 'zeroclaw-state');
18
19
  const SECRETS_DIR = path.join(LIMBO_DIR, 'secrets');
19
20
  const ENV_FILE = path.join(LIMBO_DIR, '.env');
20
21
  const COMPOSE_FILE = path.join(LIMBO_DIR, 'docker-compose.yml');
@@ -158,7 +159,7 @@ function composeContent() {
158
159
  volumes:
159
160
  - limbo-data:/data
160
161
  - ${VAULT_DIR}:/data/vault
161
- - limbo-zeroclaw-state:/home/limbo/.zeroclaw
162
+ - ${ZEROCLAW_STATE_DIR}:/home/limbo/.zeroclaw
162
163
  secrets:
163
164
  - llm_api_key
164
165
  - telegram_bot_token
@@ -193,7 +194,6 @@ secrets:
193
194
 
194
195
  volumes:
195
196
  limbo-data:
196
- limbo-zeroclaw-state:
197
197
  `;
198
198
  }
199
199
 
@@ -220,7 +220,7 @@ function composeContentHardened() {
220
220
  volumes:
221
221
  - limbo-data:/data
222
222
  - ${VAULT_DIR}:/data/vault
223
- - limbo-zeroclaw-state:/home/limbo/.zeroclaw
223
+ - ${ZEROCLAW_STATE_DIR}:/home/limbo/.zeroclaw
224
224
  secrets:
225
225
  - llm_api_key
226
226
  - telegram_bot_token
@@ -286,7 +286,6 @@ secrets:
286
286
 
287
287
  volumes:
288
288
  limbo-data:
289
- limbo-zeroclaw-state:
290
289
  `;
291
290
  }
292
291
 
@@ -1022,10 +1021,51 @@ async function collectConfig(existingEnv = {}) {
1022
1021
  };
1023
1022
  }
1024
1023
 
1024
+ // Migrate zeroclaw state from old named volume (limbo_limbo-zeroclaw-state or
1025
+ // limbo-zeroclaw-state) to the new bind-mount directory at ZEROCLAW_STATE_DIR.
1026
+ // Only runs if the bind-mount dir is empty and the named volume exists.
1027
+ function migrateZeroclawStateVolume() {
1028
+ // Skip if bind-mount dir already has content
1029
+ try {
1030
+ const entries = fs.readdirSync(ZEROCLAW_STATE_DIR);
1031
+ if (entries.length > 0) return;
1032
+ } catch { return; }
1033
+
1034
+ // Check whether the old named volume exists (Docker may prefix with project name)
1035
+ const candidateVolumes = ['limbo_limbo-zeroclaw-state', 'limbo-zeroclaw-state'];
1036
+ let foundVolume = null;
1037
+ try {
1038
+ const result = spawnSync('docker', ['volume', 'ls', '--format', '{{.Name}}'], { encoding: 'utf8', stdio: 'pipe' });
1039
+ if (result.status === 0) {
1040
+ const existing = result.stdout.split('\n').map(s => s.trim());
1041
+ foundVolume = candidateVolumes.find(v => existing.includes(v)) || null;
1042
+ }
1043
+ } catch { /* docker not available yet */ }
1044
+
1045
+ if (!foundVolume) return;
1046
+
1047
+ log(`Migrating ZeroClaw state from volume "${foundVolume}" to ${ZEROCLAW_STATE_DIR} ...`);
1048
+ const migrate = spawnSync('docker', [
1049
+ 'run', '--rm',
1050
+ '-v', `${foundVolume}:/src:ro`,
1051
+ '-v', `${ZEROCLAW_STATE_DIR}:/dst`,
1052
+ 'alpine',
1053
+ 'sh', '-c', 'cp -a /src/. /dst/',
1054
+ ], { stdio: 'pipe' });
1055
+
1056
+ if (migrate.status === 0) {
1057
+ log('Migration complete. Old volume data is preserved and can be removed with: docker volume rm ' + foundVolume);
1058
+ } else {
1059
+ warn('Migration from old volume failed — continuing with empty state. Run `limbo start` again after verifying Docker is available.');
1060
+ }
1061
+ }
1062
+
1025
1063
  function ensureComposeFile(hardened = false) {
1026
1064
  fs.mkdirSync(LIMBO_DIR, { recursive: true });
1027
1065
  fs.mkdirSync(path.join(VAULT_DIR, 'notes'), { recursive: true });
1028
1066
  fs.mkdirSync(path.join(VAULT_DIR, 'maps'), { recursive: true });
1067
+ fs.mkdirSync(ZEROCLAW_STATE_DIR, { recursive: true });
1068
+ migrateZeroclawStateVolume();
1029
1069
  fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
1030
1070
  // Ensure secret files exist (Docker Compose secrets require the files to be present)
1031
1071
  for (const name of ['llm_api_key', 'telegram_bot_token', 'gateway_token', 'groq_api_key', 'brave_api_key']) {
@@ -1097,24 +1137,191 @@ function ensureVolumePermissions() {
1097
1137
  ], { stdio: 'pipe' });
1098
1138
  }
1099
1139
 
1100
- // ─── Server detection & Cloudflare tunnel for remote wizard access ──────────
1140
+ // ─── Server detection & tunnel for remote wizard access ─────────────────────
1101
1141
 
1102
1142
  function isServerEnvironment() {
1103
1143
  return !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT ||
1104
1144
  (os.platform() === 'linux' && !process.env.DISPLAY));
1105
1145
  }
1106
1146
 
1107
- // Creates a public HTTPS tunnel via localhost.run (SSH-based, zero install).
1108
- // Falls back gracefully if SSH is unavailable or the service is down.
1109
- async function createSetupTunnel(port) {
1147
+ const CF_CERT_PATH = path.join(os.homedir(), '.cloudflared', 'cert.pem');
1148
+ const CF_TUNNEL_CONFIG = path.join(LIMBO_DIR, 'tunnel-config.json');
1149
+
1150
+ function hasCloudflared() {
1151
+ try { execSync('cloudflared --version', { stdio: 'pipe' }); return true; } // hardcoded, safe
1152
+ catch { return false; }
1153
+ }
1154
+
1155
+ function isCloudflareLoggedIn() {
1156
+ return fs.existsSync(CF_CERT_PATH);
1157
+ }
1158
+
1159
+ // Interactive prompt: choose tunnel type
1160
+ async function promptTunnelChoice() {
1161
+ const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout });
1162
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
1163
+
1164
+ console.log(`
1165
+ ${c.bold}Setup wizard needs a public URL for your client.${c.reset}
1166
+
1167
+ ${c.green}1)${c.reset} Cloudflare tunnel ${c.dim}(stable URL under your domain, recommended)${c.reset}
1168
+ ${c.green}2)${c.reset} Quick tunnel ${c.dim}(instant, temporary URL via localhost.run)${c.reset}
1169
+ `);
1170
+ const choice = (await ask(' Choose [1/2]: ')).trim();
1171
+ rl.close();
1172
+ return choice === '2' ? 'quick' : 'cloudflare';
1173
+ }
1174
+
1175
+ // cloudflared login (interactive, opens browser or prints URL)
1176
+ async function ensureCloudflareLogin() {
1177
+ if (isCloudflareLoggedIn()) return true;
1178
+
1179
+ log('Logging in to Cloudflare...');
1180
+ log('A browser window will open (or a URL will be printed). Select your domain.\n');
1181
+
1182
+ const result = spawnSync('cloudflared', ['login'], { stdio: 'inherit' });
1183
+ if (result.status !== 0 || !isCloudflareLoggedIn()) {
1184
+ warn('Cloudflare login failed or was cancelled.');
1185
+ return false;
1186
+ }
1187
+ ok('Cloudflare login successful.');
1188
+ return true;
1189
+ }
1190
+
1191
+ // Tunnel hostnames are always setup-<slug>.heylimbo.com
1192
+ const CF_TUNNEL_BASE_DOMAIN = 'heylimbo.com';
1193
+
1194
+ // Create a named CF tunnel using cloudflared CLI (requires cert.pem from login)
1195
+ async function createNamedCfTunnel(port) {
1196
+ const slug = crypto.randomBytes(4).toString('hex').slice(0, 7);
1197
+ const tunnelName = 'limbo-setup-' + slug;
1198
+ const hostname = 'setup-' + slug + '.' + CF_TUNNEL_BASE_DOMAIN;
1199
+
1200
+ try {
1201
+ // 1. Create tunnel
1202
+ spinnerWrite('Creating tunnel...');
1203
+ const createResult = spawnSync('cloudflared', ['tunnel', 'create', tunnelName], {
1204
+ stdio: 'pipe', encoding: 'utf8',
1205
+ });
1206
+ if (createResult.status !== 0) {
1207
+ spinnerClear();
1208
+ warn('Failed to create tunnel: ' + (createResult.stderr || '').trim());
1209
+ return null;
1210
+ }
1211
+
1212
+ // Extract tunnel ID from output ("Created tunnel <name> with id <uuid>")
1213
+ const idMatch = (createResult.stdout + createResult.stderr).match(/with id ([0-9a-f-]+)/i);
1214
+ if (!idMatch) {
1215
+ spinnerClear();
1216
+ warn('Could not parse tunnel ID from cloudflared output.');
1217
+ return null;
1218
+ }
1219
+ const tunnelId = idMatch[1];
1220
+
1221
+ // 2. Route DNS
1222
+ spinnerWrite('Configuring DNS...');
1223
+ const dnsResult = spawnSync('cloudflared', ['tunnel', 'route', 'dns', tunnelName, hostname], {
1224
+ stdio: 'pipe', encoding: 'utf8',
1225
+ });
1226
+ if (dnsResult.status !== 0) {
1227
+ // Non-fatal: might already exist, or we can continue anyway
1228
+ const stderr = (dnsResult.stderr || '').trim();
1229
+ if (!stderr.includes('already exists')) {
1230
+ warn('DNS routing warning: ' + stderr);
1231
+ }
1232
+ }
1233
+
1234
+ // 3. Write minimal config file for this tunnel
1235
+ const cfCredPath = path.join(os.homedir(), '.cloudflared', tunnelId + '.json');
1236
+ const tunnelConfig = path.join(LIMBO_DIR, 'tunnel-cloudflared.yml');
1237
+ const configContent = [
1238
+ 'tunnel: ' + tunnelId,
1239
+ 'credentials-file: ' + cfCredPath,
1240
+ 'ingress:',
1241
+ ' - hostname: ' + hostname,
1242
+ ' service: http://localhost:' + port,
1243
+ ' - service: http_status:404',
1244
+ '',
1245
+ ].join('\n');
1246
+ fs.writeFileSync(tunnelConfig, configContent, { mode: 0o600 });
1247
+
1248
+ // 4. Run tunnel
1249
+ const logFile = path.join(LIMBO_DIR, 'tunnel-setup.log');
1250
+ const tunnelProc = spawn('cloudflared', [
1251
+ 'tunnel', '--config', tunnelConfig, 'run', tunnelName,
1252
+ ], {
1253
+ detached: true,
1254
+ stdio: ['ignore', fs.openSync(logFile, 'w'), fs.openSync(logFile, 'a')],
1255
+ });
1256
+ tunnelProc.unref();
1257
+
1258
+ // Wait for connection
1259
+ let connected = false;
1260
+ for (let i = 0; i < 15; i++) {
1261
+ spinnerWrite('Connecting tunnel...');
1262
+ sleep(1000);
1263
+ try {
1264
+ const logs = fs.readFileSync(logFile, 'utf8');
1265
+ if (logs.includes('Registered tunnel connection') || logs.includes('INF Registered')) {
1266
+ connected = true;
1267
+ break;
1268
+ }
1269
+ } catch {}
1270
+ }
1271
+ spinnerClear();
1272
+
1273
+ if (!connected) {
1274
+ warn('Cloudflare tunnel did not connect in time.');
1275
+ try { tunnelProc.kill(); } catch {}
1276
+ spawnSync('cloudflared', ['tunnel', 'delete', '-f', tunnelName], { stdio: 'pipe' });
1277
+ return null;
1278
+ }
1279
+
1280
+ // Wait for DNS propagation (Chromium caches negative DNS lookups aggressively)
1281
+ const https = require('https');
1282
+ for (let i = 0; i < 15; i++) {
1283
+ spinnerWrite('Waiting for DNS (' + (i + 1) + 's)...');
1284
+ try {
1285
+ await new Promise((resolve, reject) => {
1286
+ const req = https.get('https://' + hostname + '/healthz', (res) => {
1287
+ resolve(res.statusCode);
1288
+ });
1289
+ req.on('error', reject);
1290
+ req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); });
1291
+ });
1292
+ break; // DNS resolved and tunnel responded
1293
+ } catch {
1294
+ sleep(1000);
1295
+ }
1296
+ }
1297
+ spinnerClear();
1298
+
1299
+ // Save metadata for cleanup
1300
+ const meta = { tunnelName, tunnelId, hostname, type: 'cloudflare-named' };
1301
+ fs.writeFileSync(CF_TUNNEL_CONFIG, JSON.stringify(meta), { mode: 0o600 });
1302
+
1303
+ return {
1304
+ type: 'cloudflare-named',
1305
+ url: 'https://' + hostname,
1306
+ pid: tunnelProc.pid,
1307
+ logFile,
1308
+ tunnelName,
1309
+ };
1310
+ } catch (err) {
1311
+ spinnerClear();
1312
+ warn('Cloudflare tunnel failed: ' + err.message);
1313
+ return null;
1314
+ }
1315
+ }
1316
+
1317
+ // Fallback: localhost.run SSH tunnel (ephemeral, no install needed)
1318
+ async function createQuickTunnel(port) {
1110
1319
  try {
1111
1320
  const logFile = path.join(LIMBO_DIR, 'tunnel-setup.log');
1112
- // localhost.run provides instant HTTPS URLs via SSH reverse tunneling.
1113
- // No binary to install, no DNS propagation delay, no Chrome caching issues.
1114
1321
  const tunnelProc = spawn('ssh', [
1115
1322
  '-o', 'StrictHostKeyChecking=accept-new',
1116
1323
  '-o', 'ServerAliveInterval=30',
1117
- '-R', `80:localhost:${port}`,
1324
+ '-R', '80:localhost:' + port,
1118
1325
  'nokey@localhost.run',
1119
1326
  ], {
1120
1327
  detached: true,
@@ -1122,7 +1329,6 @@ async function createSetupTunnel(port) {
1122
1329
  });
1123
1330
  tunnelProc.unref();
1124
1331
 
1125
- // localhost.run prints the URL almost instantly (no DNS propagation needed)
1126
1332
  let tunnelUrl = null;
1127
1333
  for (let i = 0; i < 10; i++) {
1128
1334
  spinnerWrite('Securing tunnel...');
@@ -1147,12 +1353,74 @@ async function createSetupTunnel(port) {
1147
1353
  }
1148
1354
  }
1149
1355
 
1356
+ // Interactive tunnel creation: prompts admin for choice
1357
+ async function createSetupTunnel(port) {
1358
+ const hasCf = hasCloudflared();
1359
+
1360
+ // If cloudflared is available, offer the choice
1361
+ if (hasCf) {
1362
+ const choice = await promptTunnelChoice();
1363
+
1364
+ if (choice === 'cloudflare') {
1365
+ const loggedIn = await ensureCloudflareLogin();
1366
+ if (loggedIn) {
1367
+ const tunnel = await createNamedCfTunnel(port);
1368
+ if (tunnel) return tunnel;
1369
+ warn('Falling back to quick tunnel...');
1370
+ }
1371
+ }
1372
+ }
1373
+
1374
+ return createQuickTunnel(port);
1375
+ }
1376
+
1377
+ // Clean up tunnel process and CF resources
1150
1378
  function teardownSetupTunnel(tunnel) {
1151
1379
  if (!tunnel) return;
1152
1380
  try { process.kill(tunnel.pid); } catch {}
1153
1381
  if (tunnel.logFile) try { fs.unlinkSync(tunnel.logFile); } catch {}
1154
1382
  }
1155
1383
 
1384
+ // Clean up leftover CF tunnels from previous runs
1385
+ function cleanupCfTunnel() {
1386
+ try {
1387
+ const meta = JSON.parse(fs.readFileSync(CF_TUNNEL_CONFIG, 'utf8'));
1388
+ if (meta.tunnelName) {
1389
+ spawnSync('cloudflared', ['tunnel', 'cleanup', meta.tunnelName], { stdio: 'pipe' });
1390
+ spawnSync('cloudflared', ['tunnel', 'delete', '-f', meta.tunnelName], { stdio: 'pipe' });
1391
+ }
1392
+ fs.unlinkSync(CF_TUNNEL_CONFIG);
1393
+ const tunnelConfig = path.join(LIMBO_DIR, 'tunnel-cloudflared.yml');
1394
+ try { fs.unlinkSync(tunnelConfig); } catch {}
1395
+ } catch {}
1396
+ }
1397
+
1398
+ // Read a single env var from ~/.limbo/.env
1399
+ function loadEnvVar(name) {
1400
+ try {
1401
+ const content = fs.readFileSync(ENV_FILE, 'utf8');
1402
+ const match = content.match(new RegExp('^' + name + '=(.+)$', 'm'));
1403
+ return match ? match[1].trim() : null;
1404
+ } catch { return null; }
1405
+ }
1406
+
1407
+ // Append or update env vars in ~/.limbo/.env without overwriting existing ones
1408
+ function persistEnvVars(vars) {
1409
+ try {
1410
+ let content = '';
1411
+ try { content = fs.readFileSync(ENV_FILE, 'utf8'); } catch {}
1412
+ for (const [key, value] of Object.entries(vars)) {
1413
+ const re = new RegExp('^' + key + '=.*$', 'm');
1414
+ if (re.test(content)) {
1415
+ content = content.replace(re, key + '=' + value);
1416
+ } else {
1417
+ content = content.trimEnd() + '\n' + key + '=' + value + '\n';
1418
+ }
1419
+ }
1420
+ fs.writeFileSync(ENV_FILE, content, { mode: 0o600 });
1421
+ } catch {}
1422
+ }
1423
+
1156
1424
  function installGlobalAlias() {
1157
1425
  // Create a `limbo` shell wrapper so users don't have to type `npx limbo-ai` every time.
1158
1426
  // Tries /usr/local/bin first (macOS, Linux with sudo), falls back to ~/.local/bin (no sudo).
@@ -1569,6 +1837,9 @@ function writeMinimalEnv() {
1569
1837
  // ─── Commands ────────────────────────────────────────────────────────────────
1570
1838
 
1571
1839
  async function cmdStart() {
1840
+ // Clean up any leftover CF tunnel from a previous setup run
1841
+ cleanupCfTunnel();
1842
+
1572
1843
  // ── Auto-install Docker if missing ────────────────────────────────────────
1573
1844
  if (!hasDocker()) {
1574
1845
  installDocker();
@@ -1613,7 +1884,7 @@ async function cmdStart() {
1613
1884
  const flagApiKey = parseFlag('--api-key');
1614
1885
  const flagModel = parseFlag('--model');
1615
1886
  const flagLang = parseFlag('--language') || 'en';
1616
- const flagTunnelDomain = parseFlag('--tunnel-domain');
1887
+ // CF tunnel flags parsed by createSetupTunnel() via parseFlag() — no local var needed
1617
1888
 
1618
1889
  if (flagProvider) {
1619
1890
  const validProviders = ['openai', 'anthropic', 'openrouter'];
@@ -1705,9 +1976,9 @@ async function cmdStart() {
1705
1976
  // Extract wizard URL from container logs (polls briefly, no healthcheck needed)
1706
1977
  const wizardUrl = extractWizardUrl();
1707
1978
 
1708
- // On servers, try to create a public tunnel (non-blocking show localhost/SSH first)
1979
+ // Create a public tunnel (auto on servers, or with --tunnel flag)
1709
1980
  let tunnel = null;
1710
- if (isServerEnvironment()) {
1981
+ if (isServerEnvironment() || process.argv.includes('--tunnel')) {
1711
1982
  tunnel = await createSetupTunnel(PORT);
1712
1983
  }
1713
1984
 
@@ -1970,7 +2241,7 @@ ${c.bold}Flags:${c.reset}
1970
2241
  --api-key <key> API key for headless install
1971
2242
  --model <name> Model name (optional, uses provider default)
1972
2243
  --language <code> Language: en, es (default: en)
1973
- --tunnel-domain <d> Admin: use branded subdomain for setup tunnel (e.g. limbo.tomasward.com)
2244
+ --tunnel Force tunnel creation prompt (even on local/non-server environments)
1974
2245
 
1975
2246
  ${c.bold}Config:${c.reset}
1976
2247
  limbo config voice --enable --api-key gsk_xxx Enable voice transcription
@@ -5,6 +5,9 @@
5
5
  default_provider = "${MODEL_PROVIDER}"
6
6
  default_model = "${MODEL_NAME}"
7
7
 
8
+ [runtime]
9
+ reasoning_effort = "${RUNTIME_REASONING_EFFORT}"
10
+
8
11
  [gateway]
9
12
  host = "127.0.0.1"
10
13
  port = ${LIMBO_PORT}
@@ -21,3 +24,4 @@ enabled = true
21
24
  name = "limbo-vault"
22
25
  command = "node"
23
26
  args = ["/app/mcp-server/index.js"]
27
+ env = { ZEROCLAW_STATE_DIR = "${ZEROCLAW_STATE_DIR}", ZEROCLAW_WORKSPACE_DIR = "${ZEROCLAW_STATE_DIR}/workspace" }
@@ -12,6 +12,11 @@ services:
12
12
  volumes:
13
13
  - limbo-test-data:/data
14
14
  - limbo-test-state:/home/limbo/.zeroclaw
15
+ logging:
16
+ driver: json-file
17
+ options:
18
+ max-size: "10m"
19
+ max-file: "3"
15
20
  tmpfs:
16
21
  - /tmp:size=100M
17
22