limbo-ai 1.20.4 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -162,6 +162,8 @@ function composeContent() {
162
162
  - llm_api_key
163
163
  - telegram_bot_token
164
164
  - gateway_token
165
+ - groq_api_key
166
+ - brave_api_key
165
167
  env_file:
166
168
  - ${LIMBO_DIR}/.env
167
169
  environment:
@@ -183,6 +185,10 @@ secrets:
183
185
  file: ${SECRETS_DIR}/telegram_bot_token
184
186
  gateway_token:
185
187
  file: ${SECRETS_DIR}/gateway_token
188
+ groq_api_key:
189
+ file: ${SECRETS_DIR}/groq_api_key
190
+ brave_api_key:
191
+ file: ${SECRETS_DIR}/brave_api_key
186
192
 
187
193
  volumes:
188
194
  limbo-data:
@@ -218,6 +224,8 @@ function composeContentHardened() {
218
224
  - llm_api_key
219
225
  - telegram_bot_token
220
226
  - gateway_token
227
+ - groq_api_key
228
+ - brave_api_key
221
229
  env_file:
222
230
  - ${LIMBO_DIR}/.env
223
231
  environment:
@@ -270,6 +278,10 @@ secrets:
270
278
  file: ${SECRETS_DIR}/telegram_bot_token
271
279
  gateway_token:
272
280
  file: ${SECRETS_DIR}/gateway_token
281
+ groq_api_key:
282
+ file: ${SECRETS_DIR}/groq_api_key
283
+ brave_api_key:
284
+ file: ${SECRETS_DIR}/brave_api_key
273
285
 
274
286
  volumes:
275
287
  limbo-data:
@@ -699,6 +711,8 @@ function normalizeConfig(cfg, existingEnv = {}) {
699
711
  TELEGRAM_BOT_TOKEN: cfg.telegramToken || (cfg.keepExisting ? existingEnv.TELEGRAM_BOT_TOKEN || '' : ''),
700
712
  TELEGRAM_AUTO_PAIR_FIRST_DM: cfg.telegramAutoPair || existingEnv.TELEGRAM_AUTO_PAIR_FIRST_DM || 'false',
701
713
  GATEWAY_TOKEN: gatewayToken,
714
+ VOICE_ENABLED: cfg.voiceEnabled || existingEnv.VOICE_ENABLED || 'false',
715
+ WEB_SEARCH_ENABLED: cfg.webSearchEnabled || existingEnv.WEB_SEARCH_ENABLED || 'false',
702
716
  };
703
717
 
704
718
  return base;
@@ -718,6 +732,8 @@ function writeSecrets(cfg, existingEnv = {}) {
718
732
  writeSecretFile('llm_api_key', normalized.LLM_API_KEY);
719
733
  writeSecretFile('telegram_bot_token', normalized.TELEGRAM_BOT_TOKEN);
720
734
  writeSecretFile('gateway_token', normalized.GATEWAY_TOKEN);
735
+ writeSecretFile('groq_api_key', cfg.groqApiKey || readSecretFile('groq_api_key'));
736
+ writeSecretFile('brave_api_key', cfg.braveApiKey || readSecretFile('brave_api_key'));
721
737
  }
722
738
 
723
739
  const SECRET_KEYS = new Set([
@@ -922,7 +938,7 @@ function ensureComposeFile(hardened = false) {
922
938
  fs.mkdirSync(path.join(VAULT_DIR, 'maps'), { recursive: true });
923
939
  fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
924
940
  // Ensure secret files exist (Docker Compose secrets require the files to be present)
925
- for (const name of ['llm_api_key', 'telegram_bot_token', 'gateway_token']) {
941
+ for (const name of ['llm_api_key', 'telegram_bot_token', 'gateway_token', 'groq_api_key', 'brave_api_key']) {
926
942
  const fp = path.join(SECRETS_DIR, name);
927
943
  if (!fs.existsSync(fp)) fs.writeFileSync(fp, '', { mode: 0o644 });
928
944
  }
@@ -1683,6 +1699,98 @@ function cmdStatus() {
1683
1699
  run('docker compose ps');
1684
1700
  }
1685
1701
 
1702
+ function cmdConfig() {
1703
+ const args = process.argv.slice(3);
1704
+ const feature = args[0];
1705
+
1706
+ if (!feature || !['voice', 'web-search'].includes(feature)) {
1707
+ console.log(`
1708
+ ${c.bold}Usage:${c.reset}
1709
+ limbo config voice --enable --api-key <key>
1710
+ limbo config voice --disable
1711
+ limbo config voice --status
1712
+ limbo config web-search --enable --api-key <key>
1713
+ limbo config web-search --disable
1714
+ limbo config web-search --status
1715
+ `);
1716
+ return;
1717
+ }
1718
+
1719
+ if (!fs.existsSync(ENV_FILE)) {
1720
+ die('Limbo is not configured. Run "limbo start" first.');
1721
+ }
1722
+
1723
+ const existingEnv = {};
1724
+ const envContent = fs.readFileSync(ENV_FILE, 'utf8');
1725
+ for (const line of envContent.split('\n')) {
1726
+ const match = line.match(/^([A-Z_]+)=(.*)$/);
1727
+ if (match) existingEnv[match[1]] = match[2].replace(/^"|"$/g, '');
1728
+ }
1729
+
1730
+ const isVoice = feature === 'voice';
1731
+ const envKey = isVoice ? 'VOICE_ENABLED' : 'WEB_SEARCH_ENABLED';
1732
+ const secretName = isVoice ? 'groq_api_key' : 'brave_api_key';
1733
+ const featureLabel = isVoice ? 'Voice transcription' : 'Web search';
1734
+
1735
+ const hasEnable = args.includes('--enable');
1736
+ const hasDisable = args.includes('--disable');
1737
+ const hasStatus = args.includes('--status');
1738
+ const apiKeyIdx = args.indexOf('--api-key');
1739
+ const apiKey = apiKeyIdx !== -1 ? args[apiKeyIdx + 1] : null;
1740
+
1741
+ if (hasStatus) {
1742
+ const enabled = existingEnv[envKey] === 'true';
1743
+ const key = readSecretFile(secretName);
1744
+ console.log(`${featureLabel}: ${enabled ? `${c.green}enabled${c.reset}` : `${c.dim}disabled${c.reset}`}`);
1745
+ if (key) {
1746
+ const masked = key.length > 8 ? key.substring(0, 4) + '...' + key.slice(-4) : '***';
1747
+ console.log(`API Key: ${masked}`);
1748
+ }
1749
+ return;
1750
+ }
1751
+
1752
+ if (!hasEnable && !hasDisable) {
1753
+ die('Specify --enable, --disable, or --status');
1754
+ }
1755
+
1756
+ if (hasEnable) {
1757
+ if (apiKey) {
1758
+ if (isVoice && !apiKey.startsWith('gsk_')) {
1759
+ warn('Groq API keys typically start with gsk_');
1760
+ }
1761
+ if (!isVoice && !apiKey.startsWith('BSA')) {
1762
+ warn('Brave API keys typically start with BSA');
1763
+ }
1764
+ writeSecretFile(secretName, apiKey);
1765
+ ok(`${featureLabel} API key saved.`);
1766
+ } else {
1767
+ const existing = readSecretFile(secretName);
1768
+ if (!existing) {
1769
+ die(`No API key found. Use --api-key <key> to set one.`);
1770
+ }
1771
+ }
1772
+ existingEnv[envKey] = 'true';
1773
+ } else {
1774
+ existingEnv[envKey] = 'false';
1775
+ }
1776
+
1777
+ const newContent = Object.entries(existingEnv)
1778
+ .map(([key, value]) => `${key}=${value}`)
1779
+ .join('\n') + '\n';
1780
+ fs.writeFileSync(ENV_FILE, newContent, { mode: 0o600 });
1781
+ ok(`${featureLabel} ${hasEnable ? 'enabled' : 'disabled'}.`);
1782
+
1783
+ if (fs.existsSync(COMPOSE_FILE)) {
1784
+ log('Restarting container...');
1785
+ try {
1786
+ execSync(`docker compose -f "${COMPOSE_FILE}" restart limbo`, { stdio: 'inherit' });
1787
+ ok('Container restarted.');
1788
+ } catch {
1789
+ warn('Could not restart container. Restart manually with: limbo stop && limbo start');
1790
+ }
1791
+ }
1792
+ }
1793
+
1686
1794
  function cmdHelp() {
1687
1795
  console.log(`
1688
1796
  ${c.bold}limbo${c.reset} - personal AI memory agent
@@ -1696,6 +1804,7 @@ ${c.bold}Commands:${c.reset}
1696
1804
  logs Tail container logs
1697
1805
  update Pull latest image and restart
1698
1806
  status Show container status
1807
+ config Configure optional features (voice, web-search)
1699
1808
  help Show this help
1700
1809
 
1701
1810
  ${c.bold}Flags:${c.reset}
@@ -1708,6 +1817,13 @@ ${c.bold}Flags:${c.reset}
1708
1817
  --language <code> Language: en, es (default: en)
1709
1818
  --tunnel-domain <d> Admin: use branded subdomain for setup tunnel (e.g. limbo.tomasward.com)
1710
1819
 
1820
+ ${c.bold}Config:${c.reset}
1821
+ limbo config voice --enable --api-key gsk_xxx Enable voice transcription
1822
+ limbo config voice --disable Disable voice transcription
1823
+ limbo config web-search --enable --api-key BSAxxx Enable web search
1824
+ limbo config web-search --disable Disable web search
1825
+ limbo config voice --status Show feature status
1826
+
1711
1827
  ${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
1712
1828
  `);
1713
1829
  }
@@ -1724,6 +1840,7 @@ const [,, cmd = 'start'] = process.argv;
1724
1840
  case 'logs': cmdLogs(); break;
1725
1841
  case 'update': cmdUpdate(); break;
1726
1842
  case 'status': cmdStatus(); break;
1843
+ case 'config': cmdConfig(); break;
1727
1844
  case 'help':
1728
1845
  case '--help':
1729
1846
  case '-h': cmdHelp(); break;
@@ -5,6 +5,7 @@ import {
5
5
  ListToolsRequestSchema,
6
6
  } from "@modelcontextprotocol/sdk/types.js";
7
7
 
8
+ import { buildIndex } from "./vault-index.js";
8
9
  import { vaultSearch } from "./tools/search.js";
9
10
  import { vaultRead } from "./tools/read.js";
10
11
  import { vaultWriteNote } from "./tools/write.js";
@@ -13,7 +14,7 @@ import { vaultUpdateMap } from "./tools/update-map.js";
13
14
  const server = new Server(
14
15
  {
15
16
  name: "limbo-vault",
16
- version: "1.1.0",
17
+ version: "1.2.0",
17
18
  },
18
19
  {
19
20
  capabilities: {
@@ -172,5 +173,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
172
173
 
173
174
  // ── Start ───────────────────────────────────────────────────────────────────
174
175
 
176
+ // Build in-memory index before accepting connections
177
+ const noteCount = await buildIndex();
178
+ process.stderr.write(`[limbo-vault] Index built: ${noteCount} notes indexed\n`);
179
+
175
180
  const transport = new StdioServerTransport();
176
181
  await server.connect(transport);
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Benchmark: old (filesystem scan) vs new (in-memory index)
3
+ *
4
+ * Creates a temporary vault with N notes, then compares:
5
+ * - vault_search latency
6
+ * - vault_read latency
7
+ * - vault_write_note + subsequent search latency
8
+ *
9
+ * Run: node test/benchmark.js [noteCount]
10
+ * default noteCount = 200
11
+ */
12
+
13
+ import { mkdir, writeFile, rm } from "fs/promises";
14
+ import { join, basename, relative } from "path";
15
+ import { tmpdir } from "os";
16
+ import { randomUUID } from "crypto";
17
+
18
+ // ── Scaffold a temp vault ──────────────────────────────────────────────────
19
+
20
+ const NOTE_COUNT = parseInt(process.argv[2] || "200", 10);
21
+ const VAULT_DIR = join(tmpdir(), `limbo-bench-${randomUUID().slice(0, 8)}`);
22
+ const NOTES_DIR = join(VAULT_DIR, "notes");
23
+ const MAPS_DIR = join(VAULT_DIR, "maps");
24
+
25
+ const DOMAINS = ["personal", "research", "projects", "aios", "limbo"];
26
+ const TYPES = ["fact", "preference", "idea", "insight", "decision"];
27
+
28
+ function generateNote(i) {
29
+ const domain = DOMAINS[i % DOMAINS.length];
30
+ const type = TYPES[i % TYPES.length];
31
+ const id = `bench-note-${String(i).padStart(4, "0")}`;
32
+ const title = `Benchmark note number ${i} about ${domain}`;
33
+ const description = `This is test note ${i} in the ${domain} domain for performance benchmarking`;
34
+ const body = [
35
+ `This note contains searchable content for domain ${domain}.`,
36
+ `Keywords: optimization, performance, latency, throughput, indexing.`,
37
+ `Note index: ${i}. UUID: ${randomUUID()}.`,
38
+ `The quick brown fox jumps over the lazy dog.`,
39
+ i % 7 === 0 ? "Special keyword: UNIQUE_NEEDLE" : "",
40
+ i % 3 === 0 ? `Reference to [[bench-note-${String(i - 1).padStart(4, "0")}]]` : "",
41
+ ].join("\n\n");
42
+
43
+ const frontmatter = [
44
+ "---",
45
+ `id: ${id}`,
46
+ `title: "${title}"`,
47
+ `description: "${description}"`,
48
+ `type: ${type}`,
49
+ `schema_version: 1`,
50
+ `domain: ${domain}`,
51
+ `created: "2026-03-19"`,
52
+ `source: benchmark`,
53
+ "topics:",
54
+ ` - "[[${domain}-map]]"`,
55
+ "---",
56
+ ].join("\n");
57
+
58
+ return { id, domain, content: `${frontmatter}\n\n${body}\n` };
59
+ }
60
+
61
+ async function scaffoldVault() {
62
+ await mkdir(NOTES_DIR, { recursive: true });
63
+ await mkdir(MAPS_DIR, { recursive: true });
64
+
65
+ const writes = [];
66
+ for (let i = 0; i < NOTE_COUNT; i++) {
67
+ const { id, domain, content } = generateNote(i);
68
+ const dir = join(NOTES_DIR, domain);
69
+ writes.push(
70
+ mkdir(dir, { recursive: true }).then(() =>
71
+ writeFile(join(dir, `${id}.md`), content, "utf8")
72
+ )
73
+ );
74
+ }
75
+ await Promise.all(writes);
76
+ }
77
+
78
+ async function cleanup() {
79
+ await rm(VAULT_DIR, { recursive: true, force: true });
80
+ }
81
+
82
+ // ── Old implementation (filesystem scan) ───────────────────────────────────
83
+
84
+ import { readdir, readFile, stat } from "fs/promises";
85
+
86
+ async function oldWalkNotes(dir, base = dir) {
87
+ const entries = [];
88
+ let items;
89
+ try {
90
+ items = await readdir(dir);
91
+ } catch {
92
+ return entries;
93
+ }
94
+ for (const item of items) {
95
+ if (item.startsWith(".") || item === "_meta") continue;
96
+ const full = join(dir, item);
97
+ let s;
98
+ try {
99
+ s = await stat(full);
100
+ } catch {
101
+ continue;
102
+ }
103
+ if (s.isDirectory()) {
104
+ const sub = await oldWalkNotes(full, base);
105
+ entries.push(...sub);
106
+ } else if (item.endsWith(".md")) {
107
+ const rel = relative(base, dir);
108
+ entries.push({ filePath: full, domain: rel || null });
109
+ }
110
+ }
111
+ return entries;
112
+ }
113
+
114
+ function oldExtractTitle(content) {
115
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
116
+ if (fmMatch) {
117
+ const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
118
+ if (titleMatch) return titleMatch[1];
119
+ }
120
+ return null;
121
+ }
122
+
123
+ async function oldSearch(query) {
124
+ const files = await oldWalkNotes(NOTES_DIR);
125
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
126
+ const regex = new RegExp(escaped, "gi");
127
+ const results = [];
128
+ for (const { filePath, domain } of files) {
129
+ let content;
130
+ try {
131
+ content = await readFile(filePath, "utf8");
132
+ } catch {
133
+ continue;
134
+ }
135
+ const matches = content.match(regex);
136
+ if (!matches) continue;
137
+ const noteId = basename(filePath, ".md");
138
+ const title = oldExtractTitle(content) || noteId;
139
+ results.push({ noteId, title, score: matches.length, domain });
140
+ }
141
+ results.sort((a, b) => b.score - a.score);
142
+ return results;
143
+ }
144
+
145
+ async function oldRead(noteId) {
146
+ // Fast path
147
+ const flatPath = join(NOTES_DIR, `${noteId}.md`);
148
+ try {
149
+ await stat(flatPath);
150
+ return await readFile(flatPath, "utf8");
151
+ } catch {}
152
+
153
+ // Recursive search
154
+ async function searchDir(dir) {
155
+ let items;
156
+ try {
157
+ items = await readdir(dir);
158
+ } catch {
159
+ return null;
160
+ }
161
+ for (const item of items) {
162
+ if (item.startsWith(".") || item === "_meta") continue;
163
+ const full = join(dir, item);
164
+ let s;
165
+ try {
166
+ s = await stat(full);
167
+ } catch {
168
+ continue;
169
+ }
170
+ if (s.isDirectory()) {
171
+ const candidate = join(full, `${noteId}.md`);
172
+ try {
173
+ await stat(candidate);
174
+ return await readFile(candidate, "utf8");
175
+ } catch {
176
+ const found = await searchDir(full);
177
+ if (found) return found;
178
+ }
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+
184
+ return searchDir(NOTES_DIR);
185
+ }
186
+
187
+ // ── New implementation (in-memory index) ───────────────────────────────────
188
+
189
+ // We inline the index here to avoid env var coupling with vault-index.js
190
+
191
+ const index = new Map();
192
+
193
+ function newExtractTitle(content) {
194
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
195
+ if (fmMatch) {
196
+ const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
197
+ if (titleMatch) return titleMatch[1];
198
+ const descMatch = fmMatch[1].match(/^description:\s*["']?(.+?)["']?\s*$/m);
199
+ if (descMatch) return descMatch[1];
200
+ }
201
+ return null;
202
+ }
203
+
204
+ async function newWalkAndIndex(dir, base = dir) {
205
+ let items;
206
+ try {
207
+ items = await readdir(dir);
208
+ } catch {
209
+ return;
210
+ }
211
+ const promises = [];
212
+ for (const item of items) {
213
+ if (item.startsWith(".") || item === "_meta") continue;
214
+ const full = join(dir, item);
215
+ promises.push(
216
+ stat(full)
217
+ .then((s) => {
218
+ if (s.isDirectory()) return newWalkAndIndex(full, base);
219
+ if (item.endsWith(".md")) {
220
+ return readFile(full, "utf8")
221
+ .then((content) => {
222
+ const noteId = basename(full, ".md");
223
+ const domain = relative(base, dir) || null;
224
+ const title = newExtractTitle(content) || noteId;
225
+ index.set(noteId, { path: full, title, content, domain });
226
+ })
227
+ .catch(() => {});
228
+ }
229
+ })
230
+ .catch(() => {})
231
+ );
232
+ }
233
+ await Promise.all(promises);
234
+ }
235
+
236
+ async function newBuildIndex() {
237
+ index.clear();
238
+ await newWalkAndIndex(NOTES_DIR);
239
+ return index.size;
240
+ }
241
+
242
+ function newSearch(query) {
243
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
244
+ const regex = new RegExp(escaped, "gi");
245
+ const results = [];
246
+ for (const [noteId, entry] of index) {
247
+ const matches = entry.content.match(regex);
248
+ if (!matches) continue;
249
+ results.push({ noteId, title: entry.title, score: matches.length, domain: entry.domain });
250
+ }
251
+ results.sort((a, b) => b.score - a.score);
252
+ return results;
253
+ }
254
+
255
+ function newRead(noteId) {
256
+ const entry = index.get(noteId);
257
+ return entry ? entry.content : null;
258
+ }
259
+
260
+ // ── Benchmark harness ──────────────────────────────────────────────────────
261
+
262
+ async function timeMs(fn, iterations = 1) {
263
+ const times = [];
264
+ for (let i = 0; i < iterations; i++) {
265
+ const start = performance.now();
266
+ await fn();
267
+ times.push(performance.now() - start);
268
+ }
269
+ times.sort((a, b) => a - b);
270
+ return {
271
+ min: times[0],
272
+ median: times[Math.floor(times.length / 2)],
273
+ max: times[times.length - 1],
274
+ avg: times.reduce((a, b) => a + b, 0) / times.length,
275
+ };
276
+ }
277
+
278
+ function fmt(ms) {
279
+ if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`;
280
+ return `${ms.toFixed(2)}ms`;
281
+ }
282
+
283
+ function printResult(label, old, now) {
284
+ const speedup = old.median / now.median;
285
+ console.log(` ${label}`);
286
+ console.log(` OLD: median=${fmt(old.median)} avg=${fmt(old.avg)} min=${fmt(old.min)} max=${fmt(old.max)}`);
287
+ console.log(` NEW: median=${fmt(now.median)} avg=${fmt(now.avg)} min=${fmt(now.min)} max=${fmt(now.max)}`);
288
+ console.log(` Speedup: ${speedup.toFixed(1)}x faster`);
289
+ console.log();
290
+ }
291
+
292
+ // ── Main ───────────────────────────────────────────────────────────────────
293
+
294
+ console.log(`\n=== Limbo MCP Benchmark ===`);
295
+ console.log(`Notes: ${NOTE_COUNT}`);
296
+ console.log(`Vault: ${VAULT_DIR}\n`);
297
+
298
+ try {
299
+ // Setup
300
+ console.log("Scaffolding vault...");
301
+ await scaffoldVault();
302
+
303
+ // Build index (one-time cost)
304
+ console.log("Building index...");
305
+ const buildTime = await timeMs(() => newBuildIndex(), 3);
306
+ console.log(` Index build: median=${fmt(buildTime.median)} (${index.size} notes)\n`);
307
+
308
+ const ITERS = 20;
309
+
310
+ // ── Search: broad query (many matches) ─────────────────────────────────
311
+ console.log(`--- Search: broad query ("optimization") × ${ITERS} ---`);
312
+ const oldSearchBroad = await timeMs(() => oldSearch("optimization"), ITERS);
313
+ const newSearchBroad = await timeMs(() => newSearch("optimization"), ITERS);
314
+ printResult("Broad search", oldSearchBroad, newSearchBroad);
315
+
316
+ // ── Search: narrow query (few matches) ─────────────────────────────────
317
+ console.log(`--- Search: narrow query ("UNIQUE_NEEDLE") × ${ITERS} ---`);
318
+ const oldSearchNarrow = await timeMs(() => oldSearch("UNIQUE_NEEDLE"), ITERS);
319
+ const newSearchNarrow = await timeMs(() => newSearch("UNIQUE_NEEDLE"), ITERS);
320
+ printResult("Narrow search", oldSearchNarrow, newSearchNarrow);
321
+
322
+ // ── Search: no matches ─────────────────────────────────────────────────
323
+ console.log(`--- Search: miss ("xyzzy_nonexistent") × ${ITERS} ---`);
324
+ const oldSearchMiss = await timeMs(() => oldSearch("xyzzy_nonexistent"), ITERS);
325
+ const newSearchMiss = await timeMs(() => newSearch("xyzzy_nonexistent"), ITERS);
326
+ printResult("Miss search", oldSearchMiss, newSearchMiss);
327
+
328
+ // ── Read: note in subdirectory (worst case for old) ────────────────────
329
+ console.log(`--- Read: note in subdirectory × ${ITERS} ---`);
330
+ const readTarget = `bench-note-${String(Math.floor(NOTE_COUNT / 2)).padStart(4, "0")}`;
331
+ const oldReadSub = await timeMs(() => oldRead(readTarget), ITERS);
332
+ const newReadSub = await timeMs(() => newRead(readTarget), ITERS);
333
+ printResult(`Read "${readTarget}"`, oldReadSub, newReadSub);
334
+
335
+ // ── Read: nonexistent note ─────────────────────────────────────────────
336
+ console.log(`--- Read: nonexistent note × ${ITERS} ---`);
337
+ const oldReadMiss = await timeMs(() => oldRead("does-not-exist-999"), ITERS);
338
+ const newReadMiss = await timeMs(() => newRead("does-not-exist-999"), ITERS);
339
+ printResult("Read miss", oldReadMiss, newReadMiss);
340
+
341
+ // ── Correctness check ──────────────────────────────────────────────────
342
+ console.log("--- Correctness ---");
343
+ const oldResults = await oldSearch("optimization");
344
+ const newResults = newSearch("optimization");
345
+ const oldCount = oldResults.length;
346
+ const newCount = newResults.length;
347
+ const match = oldCount === newCount;
348
+ console.log(` Old result count: ${oldCount}`);
349
+ console.log(` New result count: ${newCount}`);
350
+ console.log(` Match: ${match ? "PASS ✓" : "FAIL ✗"}`);
351
+ if (!match) {
352
+ console.log(" WARNING: result counts differ!");
353
+ }
354
+
355
+ // Verify read returns identical content
356
+ const oldContent = await oldRead(readTarget);
357
+ const newContent = newRead(readTarget);
358
+ const readMatch = oldContent === newContent;
359
+ console.log(` Read content match: ${readMatch ? "PASS ✓" : "FAIL ✗"}`);
360
+ console.log();
361
+
362
+ } finally {
363
+ await cleanup();
364
+ console.log("Cleaned up.\n");
365
+ }
@@ -1,65 +1,13 @@
1
- import { readFile, readdir, stat } from "fs/promises";
2
- import { join, resolve } from "path";
1
+ import { readFile } from "fs/promises";
2
+ import { resolve } from "path";
3
+ import { ensureIndex, getNote } from "../vault-index.js";
3
4
 
4
5
  const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
- const NOTES_DIR = join(VAULT_PATH, "notes");
6
-
7
- /**
8
- * Recursively find a note file by ID. Checks flat first, then subdirectories.
9
- * Returns the file path or null.
10
- */
11
- async function findNote(noteId) {
12
- // Fast path: check flat location first
13
- const flatPath = join(NOTES_DIR, `${noteId}.md`);
14
- try {
15
- await stat(flatPath);
16
- return flatPath;
17
- } catch {
18
- // Not in root — search subdirectories
19
- }
20
-
21
- return searchDir(NOTES_DIR, noteId);
22
- }
23
-
24
- async function searchDir(dir, noteId) {
25
- let items;
26
- try {
27
- items = await readdir(dir);
28
- } catch {
29
- return null;
30
- }
31
-
32
- for (const item of items) {
33
- if (item.startsWith(".") || item === "_meta") continue;
34
-
35
- const full = join(dir, item);
36
- let s;
37
- try {
38
- s = await stat(full);
39
- } catch {
40
- continue;
41
- }
42
-
43
- if (s.isDirectory()) {
44
- // Check if the note exists directly in this subdirectory
45
- const candidate = join(full, `${noteId}.md`);
46
- try {
47
- await stat(candidate);
48
- return candidate;
49
- } catch {
50
- // Recurse deeper
51
- const found = await searchDir(full, noteId);
52
- if (found) return found;
53
- }
54
- }
55
- }
56
-
57
- return null;
58
- }
6
+ const NOTES_DIR = resolve(VAULT_PATH, "notes");
59
7
 
60
8
  /**
61
9
  * vault_read(noteId): reads full content of a note by ID.
62
- * Searches recursively through subdirectories.
10
+ * Uses in-memory index for O(1) path lookup — no recursive filesystem search.
63
11
  * Returns the raw markdown content including YAML frontmatter.
64
12
  * Returns null if the note doesn't exist.
65
13
  */
@@ -74,19 +22,16 @@ export async function vaultRead(noteId) {
74
22
  throw new Error("noteId contains invalid characters");
75
23
  }
76
24
 
77
- const filePath = await findNote(safe);
78
- if (!filePath) return null;
25
+ await ensureIndex();
26
+ const entry = getNote(safe);
27
+ if (!entry) return null;
79
28
 
80
29
  // Defense-in-depth: ensure resolved path stays within vault
81
- const resolved = resolve(filePath);
82
- if (!resolved.startsWith(resolve(NOTES_DIR) + "/")) {
30
+ const resolved = resolve(entry.path);
31
+ if (!resolved.startsWith(NOTES_DIR + "/")) {
83
32
  throw new Error("Path traversal detected");
84
33
  }
85
34
 
86
- try {
87
- return await readFile(filePath, "utf8");
88
- } catch (err) {
89
- if (err.code === "ENOENT") return null;
90
- throw err;
91
- }
35
+ // Return content from index (already in memory)
36
+ return entry.content;
92
37
  }
@@ -1,118 +1,15 @@
1
- import { readdir, readFile, stat } from "fs/promises";
2
- import { join, basename, relative } from "path";
3
-
4
- const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
- const NOTES_DIR = join(VAULT_PATH, "notes");
6
-
7
- /**
8
- * Recursively collects all .md files under a directory.
9
- * Returns array of { filePath, domain } where domain is the relative subdirectory.
10
- */
11
- async function walkNotes(dir, base = dir) {
12
- const entries = [];
13
- let items;
14
- try {
15
- items = await readdir(dir);
16
- } catch {
17
- return entries;
18
- }
19
-
20
- for (const item of items) {
21
- // Skip hidden directories and _meta
22
- if (item.startsWith(".") || item === "_meta") continue;
23
-
24
- const full = join(dir, item);
25
- let s;
26
- try {
27
- s = await stat(full);
28
- } catch {
29
- continue;
30
- }
31
-
32
- if (s.isDirectory()) {
33
- const sub = await walkNotes(full, base);
34
- entries.push(...sub);
35
- } else if (item.endsWith(".md")) {
36
- const rel = relative(base, dir);
37
- entries.push({ filePath: full, domain: rel || null });
38
- }
39
- }
40
- return entries;
41
- }
42
-
43
- /**
44
- * Extracts the title from YAML frontmatter, falling back to description or first H1.
45
- */
46
- function extractTitle(content) {
47
- const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
48
- if (fmMatch) {
49
- const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
50
- if (titleMatch) return titleMatch[1];
51
- // Fallback: use description if no title field
52
- const descMatch = fmMatch[1].match(/^description:\s*["']?(.+?)["']?\s*$/m);
53
- if (descMatch) return descMatch[1];
54
- }
55
- const h1Match = content.match(/^#\s+(.+)$/m);
56
- if (h1Match) return h1Match[1];
57
- return null;
58
- }
1
+ import { ensureIndex, search } from "../vault-index.js";
59
2
 
60
3
  /**
61
- * Finds a short snippet around the first match.
62
- */
63
- function extractSnippet(content, regex, maxLen = 150) {
64
- regex.lastIndex = 0;
65
- const match = regex.exec(content);
66
- regex.lastIndex = 0;
67
- if (!match) return "";
68
- const start = Math.max(0, match.index - 60);
69
- const end = Math.min(content.length, match.index + maxLen);
70
- let snippet = content.slice(start, end).replace(/\n/g, " ").trim();
71
- if (start > 0) snippet = "..." + snippet;
72
- if (end < content.length) snippet += "...";
73
- return snippet;
74
- }
75
-
76
- /**
77
- * vault_search(query): recursive search across all .md files in vault/notes/.
4
+ * vault_search(query): searches all notes via in-memory index.
78
5
  * Returns [{noteId, title, snippet, score, domain}] sorted by score desc.
79
- *
80
- * NOTE: Current implementation is a linear scan (O(n) per query). This is fine
81
- * for small vaults (hundreds of notes), but will need optimization at scale —
82
- * consider an inverted index (e.g. SQLite FTS5) when the vault grows large.
6
+ * No disk I/O after initial index build.
83
7
  */
84
8
  export async function vaultSearch(query) {
85
9
  if (query.length > 200) {
86
10
  throw new Error("Search query too long (max 200 characters)");
87
11
  }
88
12
 
89
- const files = await walkNotes(NOTES_DIR);
90
- if (files.length === 0) return [];
91
-
92
- // Always escape user input to prevent ReDoS from pathological patterns
93
- const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
- const regex = new RegExp(escaped, "gi");
95
-
96
- const results = [];
97
- for (const { filePath, domain } of files) {
98
- let content;
99
- try {
100
- content = await readFile(filePath, "utf8");
101
- } catch {
102
- continue;
103
- }
104
-
105
- const matches = content.match(regex);
106
- if (!matches) continue;
107
-
108
- const noteId = basename(filePath, ".md");
109
- const title = extractTitle(content) || noteId;
110
- const score = matches.length;
111
- const snippet = extractSnippet(content, regex);
112
-
113
- results.push({ noteId, title, snippet, score, domain });
114
- }
115
-
116
- results.sort((a, b) => b.score - a.score);
117
- return results;
13
+ await ensureIndex();
14
+ return search(query);
118
15
  }
@@ -26,8 +26,23 @@ function buildMapFrontmatter(name) {
26
26
  return lines.join("\n");
27
27
  }
28
28
 
29
+ /**
30
+ * Extracts wikilink noteIds from a block of text.
31
+ * Matches [[noteId]] and [[noteId|Display Title]].
32
+ */
33
+ function extractWikilinks(text) {
34
+ const regex = /\[\[([^\]|]+)/g;
35
+ const ids = new Set();
36
+ let match;
37
+ while ((match = regex.exec(text)) !== null) {
38
+ ids.add(match[1].trim());
39
+ }
40
+ return ids;
41
+ }
42
+
29
43
  /**
30
44
  * Finds or creates a section in markdown content.
45
+ * Deduplicates entries — skips any whose wikilink noteId already exists in the section.
31
46
  * Returns the updated content string.
32
47
  */
33
48
  function upsertSection(content, section, entries) {
@@ -37,20 +52,37 @@ function upsertSection(content, section, entries) {
37
52
  const sectionIdx = lines.findIndex((l) => l.trim() === sectionHeader);
38
53
 
39
54
  if (sectionIdx === -1) {
40
- // Section doesn't exist — append it
55
+ // Section doesn't exist — append it (all entries are new)
41
56
  const toAdd = ["", sectionHeader, "", ...entries, ""];
42
- return lines.concat(toAdd).join("\n");
57
+ return { content: lines.concat(toAdd).join("\n"), added: entries.length };
43
58
  }
44
59
 
45
60
  // Find where the section ends (next ## or EOF)
46
- let insertIdx = sectionIdx + 1;
47
- while (insertIdx < lines.length && !lines[insertIdx].startsWith("## ")) {
48
- insertIdx++;
61
+ let endIdx = sectionIdx + 1;
62
+ while (endIdx < lines.length && !lines[endIdx].startsWith("## ")) {
63
+ endIdx++;
49
64
  }
50
65
 
51
- // Insert entries before the next section (or EOF)
52
- lines.splice(insertIdx, 0, ...entries);
53
- return lines.join("\n");
66
+ // Collect existing wikilinks in this section
67
+ const sectionText = lines.slice(sectionIdx, endIdx).join("\n");
68
+ const existing = extractWikilinks(sectionText);
69
+
70
+ // Filter out entries whose noteId is already present
71
+ const newEntries = entries.filter((entry) => {
72
+ const entryIds = extractWikilinks(entry);
73
+ for (const id of entryIds) {
74
+ if (existing.has(id)) return false;
75
+ }
76
+ return true;
77
+ });
78
+
79
+ if (newEntries.length === 0) {
80
+ return { content: content, added: 0 };
81
+ }
82
+
83
+ // Insert new entries before the next section (or EOF)
84
+ lines.splice(endIdx, 0, ...newEntries);
85
+ return { content: lines.join("\n"), added: newEntries.length };
54
86
  }
55
87
 
56
88
  /**
@@ -85,7 +117,11 @@ export async function vaultUpdateMap(map, section, entries) {
85
117
  existing = `${buildMapFrontmatter(map)}\n\n# ${map}\n`;
86
118
  }
87
119
 
88
- const updated = upsertSection(existing, section, entries);
89
- await writeFile(filePath, updated, "utf8");
90
- return { map: safeMap, section, added: entries.length };
120
+ const { content: updated, added } = upsertSection(existing, section, entries);
121
+
122
+ if (added > 0) {
123
+ await writeFile(filePath, updated, "utf8");
124
+ }
125
+
126
+ return { map: safeMap, section, added };
91
127
  }
@@ -1,5 +1,6 @@
1
1
  import { writeFile, mkdir } from "fs/promises";
2
- import { join, resolve } from "path";
2
+ import { join, resolve, relative } from "path";
3
+ import { updateEntry } from "../vault-index.js";
3
4
 
4
5
  const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
6
  const NOTES_DIR = join(VAULT_PATH, "notes");
@@ -91,5 +92,10 @@ export async function vaultWriteNote(note) {
91
92
  }
92
93
 
93
94
  await writeFile(filePath, fileContent, "utf8");
95
+
96
+ // Update in-memory index immediately — no re-scan needed
97
+ const domain = relative(NOTES_DIR, resolve(targetDir)) || null;
98
+ updateEntry(safe, filePath, fileContent, domain);
99
+
94
100
  return { id: safe, path: filePath };
95
101
  }
@@ -0,0 +1,127 @@
1
+ import { readdir, readFile, stat } from "fs/promises";
2
+ import { join, basename, relative } from "path";
3
+
4
+ const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
+ const NOTES_DIR = join(VAULT_PATH, "notes");
6
+
7
+ // In-memory index: noteId → { path, title, content, domain }
8
+ const index = new Map();
9
+ let built = false;
10
+
11
+ function extractTitle(content) {
12
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
13
+ if (fmMatch) {
14
+ const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
15
+ if (titleMatch) return titleMatch[1];
16
+ const descMatch = fmMatch[1].match(/^description:\s*["']?(.+?)["']?\s*$/m);
17
+ if (descMatch) return descMatch[1];
18
+ }
19
+ const h1Match = content.match(/^#\s+(.+)$/m);
20
+ if (h1Match) return h1Match[1];
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Recursively walks notes/ and indexes all .md files in parallel.
26
+ * Uses Promise.all for concurrent I/O instead of sequential await.
27
+ */
28
+ async function walkAndIndex(dir, base = dir) {
29
+ let items;
30
+ try {
31
+ items = await readdir(dir);
32
+ } catch {
33
+ return;
34
+ }
35
+
36
+ const promises = [];
37
+ for (const item of items) {
38
+ if (item.startsWith(".") || item === "_meta") continue;
39
+ const full = join(dir, item);
40
+ promises.push(
41
+ stat(full)
42
+ .then((s) => {
43
+ if (s.isDirectory()) {
44
+ return walkAndIndex(full, base);
45
+ }
46
+ if (item.endsWith(".md")) {
47
+ return readFile(full, "utf8")
48
+ .then((content) => {
49
+ const noteId = basename(full, ".md");
50
+ const domain = relative(base, dir) || null;
51
+ const title = extractTitle(content) || noteId;
52
+ index.set(noteId, { path: full, title, content, domain });
53
+ })
54
+ .catch(() => {});
55
+ }
56
+ })
57
+ .catch(() => {})
58
+ );
59
+ }
60
+ await Promise.all(promises);
61
+ }
62
+
63
+ /**
64
+ * Build (or rebuild) the full in-memory index from disk.
65
+ */
66
+ export async function buildIndex() {
67
+ index.clear();
68
+ await walkAndIndex(NOTES_DIR);
69
+ built = true;
70
+ return index.size;
71
+ }
72
+
73
+ /**
74
+ * Ensure the index is built before use.
75
+ */
76
+ export async function ensureIndex() {
77
+ if (!built) await buildIndex();
78
+ }
79
+
80
+ /**
81
+ * O(1) lookup by noteId. Returns { path, title, content, domain } or null.
82
+ */
83
+ export function getNote(noteId) {
84
+ return index.get(noteId) || null;
85
+ }
86
+
87
+ /**
88
+ * Update a single entry in the index (called after vault_write_note).
89
+ */
90
+ export function updateEntry(noteId, path, content, domain) {
91
+ const title = extractTitle(content) || noteId;
92
+ index.set(noteId, { path, title, content, domain });
93
+ }
94
+
95
+ /**
96
+ * Search all indexed notes by keyword. O(n) over in-memory strings — no disk I/O.
97
+ */
98
+ export function search(query) {
99
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
100
+ const regex = new RegExp(escaped, "gi");
101
+
102
+ const results = [];
103
+ for (const [noteId, entry] of index) {
104
+ const matches = entry.content.match(regex);
105
+ if (!matches) continue;
106
+
107
+ const score = matches.length;
108
+
109
+ // Extract snippet around first match
110
+ regex.lastIndex = 0;
111
+ const match = regex.exec(entry.content);
112
+ regex.lastIndex = 0;
113
+ let snippet = "";
114
+ if (match) {
115
+ const start = Math.max(0, match.index - 60);
116
+ const end = Math.min(entry.content.length, match.index + 150);
117
+ snippet = entry.content.slice(start, end).replace(/\n/g, " ").trim();
118
+ if (start > 0) snippet = "..." + snippet;
119
+ if (end < entry.content.length) snippet += "...";
120
+ }
121
+
122
+ results.push({ noteId, title: entry.title, snippet, score, domain: entry.domain });
123
+ }
124
+
125
+ results.sort((a, b) => b.score - a.score);
126
+ return results;
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "limbo-ai",
3
- "version": "1.20.4",
3
+ "version": "1.21.0",
4
4
  "description": "Your personal AI memory agent — install and manage Limbo via npx",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -686,6 +686,7 @@
686
686
  <div class="progress-dot"></div>
687
687
  <div class="progress-dot"></div>
688
688
  <div class="progress-dot"></div>
689
+ <div class="progress-dot"></div>
689
690
  </div>
690
691
 
691
692
  <!-- Nav Header -->
@@ -1160,8 +1161,107 @@
1160
1161
  </div>
1161
1162
  </div>
1162
1163
 
1163
- <!-- Step 7: Confirm -->
1164
+ <!-- Step 7: Optional Features -->
1164
1165
  <div class="step" data-step="7">
1166
+ <h2 class="step-title">
1167
+ <span data-lang="en">Optional Features</span>
1168
+ <span data-lang="es">Funcionalidades Opcionales</span>
1169
+ </h2>
1170
+ <p class="step-desc">
1171
+ <span data-lang="en">Enable additional capabilities for Limbo. You can always change these later.</span>
1172
+ <span data-lang="es">Habilitá capacidades adicionales para Limbo. Podés cambiar esto después.</span>
1173
+ </p>
1174
+
1175
+ <!-- Voice Messages (Groq) -->
1176
+ <div class="toggle-row" style="margin-bottom:12px">
1177
+ <div>
1178
+ <div class="toggle-label">
1179
+ <span data-lang="en">Voice Messages</span>
1180
+ <span data-lang="es">Mensajes de Voz</span>
1181
+ </div>
1182
+ <div class="toggle-sub">
1183
+ <span data-lang="en">Transcribe voice notes with Groq Whisper</span>
1184
+ <span data-lang="es">Transcribir notas de voz con Groq Whisper</span>
1185
+ </div>
1186
+ </div>
1187
+ <label class="toggle">
1188
+ <input type="checkbox" id="voiceToggle" onchange="toggleFeatureVoice()">
1189
+ <span class="toggle-track"></span>
1190
+ <span class="toggle-thumb"></span>
1191
+ </label>
1192
+ </div>
1193
+
1194
+ <div id="voiceFields" class="tg-fields">
1195
+ <div class="input-group">
1196
+ <label class="input-label" for="groqKeyInput">Groq API Key</label>
1197
+ <input
1198
+ type="password"
1199
+ class="input-field"
1200
+ id="groqKeyInput"
1201
+ placeholder="gsk_..."
1202
+ autocomplete="off"
1203
+ spellcheck="false"
1204
+ >
1205
+ <div class="validation-msg" id="groqKeyValidation"></div>
1206
+ <p class="input-hint">
1207
+ <span data-lang="en">Get your key at <a href="https://console.groq.com/keys" target="_blank">console.groq.com/keys</a></span>
1208
+ <span data-lang="es">Obtené tu key en <a href="https://console.groq.com/keys" target="_blank">console.groq.com/keys</a></span>
1209
+ </p>
1210
+ </div>
1211
+ </div>
1212
+
1213
+ <!-- Web Search (Brave) -->
1214
+ <div class="toggle-row" style="margin-bottom:12px">
1215
+ <div>
1216
+ <div class="toggle-label">
1217
+ <span data-lang="en">Web Search</span>
1218
+ <span data-lang="es">Búsqueda Web</span>
1219
+ </div>
1220
+ <div class="toggle-sub">
1221
+ <span data-lang="en">Search the web via Brave Search API</span>
1222
+ <span data-lang="es">Buscar en la web con Brave Search API</span>
1223
+ </div>
1224
+ </div>
1225
+ <label class="toggle">
1226
+ <input type="checkbox" id="webSearchToggle" onchange="toggleFeatureWebSearch()">
1227
+ <span class="toggle-track"></span>
1228
+ <span class="toggle-thumb"></span>
1229
+ </label>
1230
+ </div>
1231
+
1232
+ <div id="webSearchFields" class="tg-fields">
1233
+ <div class="input-group">
1234
+ <label class="input-label" for="braveKeyInput">Brave API Key</label>
1235
+ <input
1236
+ type="password"
1237
+ class="input-field"
1238
+ id="braveKeyInput"
1239
+ placeholder="BSA..."
1240
+ autocomplete="off"
1241
+ spellcheck="false"
1242
+ >
1243
+ <div class="validation-msg" id="braveKeyValidation"></div>
1244
+ <p class="input-hint">
1245
+ <span data-lang="en">Get your key at <a href="https://brave.com/search/api/" target="_blank">brave.com/search/api</a></span>
1246
+ <span data-lang="es">Obtené tu key en <a href="https://brave.com/search/api/" target="_blank">brave.com/search/api</a></span>
1247
+ </p>
1248
+ </div>
1249
+ </div>
1250
+
1251
+ <div class="btn-row">
1252
+ <button class="btn-secondary" onclick="skipFeatures()">
1253
+ <span data-lang="en">Skip</span>
1254
+ <span data-lang="es">Omitir</span>
1255
+ </button>
1256
+ <button class="btn-primary" onclick="submitFeatures()">
1257
+ <span data-lang="en">Continue</span>
1258
+ <span data-lang="es">Continuar</span>
1259
+ </button>
1260
+ </div>
1261
+ </div>
1262
+
1263
+ <!-- Step 8: Confirm -->
1264
+ <div class="step" data-step="8">
1165
1265
  <h2 class="step-title">
1166
1266
  <span data-lang="en">Ready to launch</span>
1167
1267
  <span data-lang="es">Listo para arrancar</span>
@@ -1233,11 +1333,15 @@
1233
1333
  apiKey: '',
1234
1334
  model: null,
1235
1335
  telegram: { enabled: true, botToken: '' },
1336
+ features: {
1337
+ voice: { enabled: false, apiKey: '' },
1338
+ webSearch: { enabled: false, apiKey: '' },
1339
+ },
1236
1340
  subscriptionDone: false,
1237
1341
  subscriptionEmail: '',
1238
1342
  };
1239
1343
 
1240
- const totalSteps = 7;
1344
+ const totalSteps = 8;
1241
1345
 
1242
1346
  // --- Prefix validation rules ---
1243
1347
  const keyPrefixes = {
@@ -1292,7 +1396,8 @@
1292
1396
  if (n === 4) setupApiKeyStep();
1293
1397
  if (n === 5) fetchModels();
1294
1398
  if (n === 6) resetTelegramUI();
1295
- if (n === 7) buildSummary();
1399
+ if (n === 7) resetFeaturesUI();
1400
+ if (n === 8) buildSummary();
1296
1401
  }
1297
1402
 
1298
1403
  document.getElementById('btnBack').addEventListener('click', () => {
@@ -1843,7 +1948,105 @@
1843
1948
  }
1844
1949
  }
1845
1950
 
1846
- // --- Step 7: Summary ---
1951
+ // --- Step 7: Features ---
1952
+ function toggleFeatureVoice() {
1953
+ const enabled = document.getElementById('voiceToggle').checked;
1954
+ state.features.voice.enabled = enabled;
1955
+ document.getElementById('voiceFields').classList.toggle('visible', enabled);
1956
+ }
1957
+
1958
+ function toggleFeatureWebSearch() {
1959
+ const enabled = document.getElementById('webSearchToggle').checked;
1960
+ state.features.webSearch.enabled = enabled;
1961
+ document.getElementById('webSearchFields').classList.toggle('visible', enabled);
1962
+ }
1963
+
1964
+ function resetFeaturesUI() {
1965
+ document.getElementById('voiceFields').classList.toggle('visible', state.features.voice.enabled);
1966
+ document.getElementById('webSearchFields').classList.toggle('visible', state.features.webSearch.enabled);
1967
+ }
1968
+
1969
+ document.getElementById('groqKeyInput').addEventListener('input', function () {
1970
+ const val = this.value.trim();
1971
+ const msgEl = document.getElementById('groqKeyValidation');
1972
+ state.features.voice.apiKey = val;
1973
+ if (!val) {
1974
+ this.className = 'input-field';
1975
+ msgEl.innerHTML = '';
1976
+ return;
1977
+ }
1978
+ if (val.startsWith('gsk_') && val.length > 10) {
1979
+ this.className = 'input-field valid';
1980
+ const msg = state.language === 'es' ? 'Formato válido' : 'Valid format';
1981
+ msgEl.innerHTML = `<span class="validation-msg success">${msg}</span>`;
1982
+ } else if (!val.startsWith('gsk_')) {
1983
+ this.className = 'input-field invalid';
1984
+ const msg = state.language === 'es'
1985
+ ? 'La key debe empezar con <code>gsk_</code>'
1986
+ : 'Key should start with <code>gsk_</code>';
1987
+ msgEl.innerHTML = `<span class="validation-msg error">${msg}</span>`;
1988
+ } else {
1989
+ this.className = 'input-field';
1990
+ msgEl.innerHTML = '';
1991
+ }
1992
+ });
1993
+
1994
+ document.getElementById('braveKeyInput').addEventListener('input', function () {
1995
+ const val = this.value.trim();
1996
+ const msgEl = document.getElementById('braveKeyValidation');
1997
+ state.features.webSearch.apiKey = val;
1998
+ if (!val) {
1999
+ this.className = 'input-field';
2000
+ msgEl.innerHTML = '';
2001
+ return;
2002
+ }
2003
+ if (val.startsWith('BSA') && val.length > 10) {
2004
+ this.className = 'input-field valid';
2005
+ const msg = state.language === 'es' ? 'Formato válido' : 'Valid format';
2006
+ msgEl.innerHTML = `<span class="validation-msg success">${msg}</span>`;
2007
+ } else if (!val.startsWith('BSA')) {
2008
+ this.className = 'input-field invalid';
2009
+ const msg = state.language === 'es'
2010
+ ? 'La key debe empezar con <code>BSA</code>'
2011
+ : 'Key should start with <code>BSA</code>';
2012
+ msgEl.innerHTML = `<span class="validation-msg error">${msg}</span>`;
2013
+ } else {
2014
+ this.className = 'input-field';
2015
+ msgEl.innerHTML = '';
2016
+ }
2017
+ });
2018
+
2019
+ function skipFeatures() {
2020
+ state.features.voice = { enabled: false, apiKey: '' };
2021
+ state.features.webSearch = { enabled: false, apiKey: '' };
2022
+ goToStep(8);
2023
+ }
2024
+
2025
+ function submitFeatures() {
2026
+ if (state.features.voice.enabled) {
2027
+ const key = document.getElementById('groqKeyInput').value.trim();
2028
+ if (!key) {
2029
+ const msgEl = document.getElementById('groqKeyValidation');
2030
+ const msg = state.language === 'es' ? 'Ingresá tu API key de Groq' : 'Enter your Groq API key';
2031
+ msgEl.innerHTML = `<span class="validation-msg error">${msg}</span>`;
2032
+ return;
2033
+ }
2034
+ state.features.voice.apiKey = key;
2035
+ }
2036
+ if (state.features.webSearch.enabled) {
2037
+ const key = document.getElementById('braveKeyInput').value.trim();
2038
+ if (!key) {
2039
+ const msgEl = document.getElementById('braveKeyValidation');
2040
+ const msg = state.language === 'es' ? 'Ingresá tu API key de Brave' : 'Enter your Brave API key';
2041
+ msgEl.innerHTML = `<span class="validation-msg error">${msg}</span>`;
2042
+ return;
2043
+ }
2044
+ state.features.webSearch.apiKey = key;
2045
+ }
2046
+ goToStep(8);
2047
+ }
2048
+
2049
+ // --- Step 8: Summary ---
1847
2050
  function buildSummary() {
1848
2051
  const card = document.getElementById('summaryCard');
1849
2052
  const l = state.language;
@@ -1886,6 +2089,20 @@
1886
2089
  : (l === 'es' ? 'Desactivado' : 'Disabled'),
1887
2090
  step: 6,
1888
2091
  },
2092
+ {
2093
+ label: l === 'es' ? 'Voz' : 'Voice',
2094
+ value: state.features.voice.enabled
2095
+ ? (l === 'es' ? 'Activado' : 'Enabled')
2096
+ : (l === 'es' ? 'Desactivado' : 'Disabled'),
2097
+ step: 7,
2098
+ },
2099
+ {
2100
+ label: l === 'es' ? 'Búsqueda Web' : 'Web Search',
2101
+ value: state.features.webSearch.enabled
2102
+ ? (l === 'es' ? 'Activado' : 'Enabled')
2103
+ : (l === 'es' ? 'Desactivado' : 'Disabled'),
2104
+ step: 7,
2105
+ },
1889
2106
  ];
1890
2107
 
1891
2108
  card.innerHTML = items
@@ -1917,8 +2134,11 @@
1917
2134
  state.subscriptionDone = false;
1918
2135
  state.subscriptionEmail = '';
1919
2136
  state.telegram = { enabled: true, botToken: '' };
2137
+ state.features = { voice: { enabled: false, apiKey: '' }, webSearch: { enabled: false, apiKey: '' } };
1920
2138
  pairingActive = false;
1921
2139
  document.getElementById('tgToggle').checked = true;
2140
+ document.getElementById('voiceToggle').checked = false;
2141
+ document.getElementById('webSearchToggle').checked = false;
1922
2142
  resetTelegramUI();
1923
2143
  goToStep(1);
1924
2144
  }
@@ -1943,6 +2163,16 @@
1943
2163
  enabled: state.telegram.enabled,
1944
2164
  botToken: state.telegram.enabled ? state.telegram.botToken : '',
1945
2165
  },
2166
+ features: {
2167
+ voice: {
2168
+ enabled: state.features.voice.enabled,
2169
+ apiKey: state.features.voice.enabled ? state.features.voice.apiKey : '',
2170
+ },
2171
+ webSearch: {
2172
+ enabled: state.features.webSearch.enabled,
2173
+ apiKey: state.features.webSearch.enabled ? state.features.webSearch.apiKey : '',
2174
+ },
2175
+ },
1946
2176
  };
1947
2177
  if (state.authMode === 'api-key') {
1948
2178
  payload.apiKey = state.apiKey;
@@ -734,6 +734,15 @@ async function handleConfigure(req, res) {
734
734
  // chat_id is already captured by /api/telegram/pair during wizard Step 6
735
735
  }
736
736
 
737
+ // Handle optional features (voice transcription, web search)
738
+ const features = data.features || {};
739
+ if (features.voice && features.voice.enabled && features.voice.apiKey) {
740
+ writeSecretFile('groq_api_key', features.voice.apiKey);
741
+ }
742
+ if (features.webSearch && features.webSearch.enabled && features.webSearch.apiKey) {
743
+ writeSecretFile('brave_api_key', features.webSearch.apiKey);
744
+ }
745
+
737
746
  const gatewayToken = ensureGatewayToken();
738
747
 
739
748
  // Build env vars (excluding secrets)
@@ -745,6 +754,8 @@ async function handleConfigure(req, res) {
745
754
  MODEL_NAME: modelName,
746
755
  LIMBO_PORT: String(PORT),
747
756
  TELEGRAM_ENABLED: telegram.enabled ? 'true' : 'false',
757
+ VOICE_ENABLED: (features.voice && features.voice.enabled) ? 'true' : 'false',
758
+ WEB_SEARCH_ENABLED: (features.webSearch && features.webSearch.enabled) ? 'true' : 'false',
748
759
  };
749
760
 
750
761
  // Write .env file (quote values to handle special chars)