limbo-ai 1.20.4 → 1.22.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/README.md CHANGED
@@ -52,12 +52,12 @@ Headless mode skips Telegram setup. To add Telegram later, run `npx limbo-ai sta
52
52
  ### Available commands
53
53
 
54
54
  ```sh
55
- npx limbo-ai start # Install and start (default if no command given)
56
- npx limbo-ai stop # Stop the container
57
- npx limbo-ai update # Pull latest image and restart
58
- npx limbo-ai status # Show container status
59
- npx limbo-ai logs # Tail container logs
60
- npx limbo-ai start --reconfigure # Change API keys or settings
55
+ npx limbo-ai@latest start # Install and start (default if no command given)
56
+ npx limbo-ai@latest stop # Stop the container
57
+ npx limbo-ai@latest update # Pull latest image and restart
58
+ npx limbo-ai@latest status # Show container status
59
+ npx limbo-ai@latest logs # Tail container logs
60
+ npx limbo-ai@latest start --reconfigure # Change API keys or settings
61
61
  ```
62
62
 
63
63
  ---
@@ -65,7 +65,7 @@ npx limbo-ai start --reconfigure # Change API keys or settings
65
65
  ## Updating
66
66
 
67
67
  ```sh
68
- npx limbo-ai update
68
+ npx limbo-ai@latest update
69
69
  ```
70
70
 
71
71
  Pulls the latest Limbo image and restarts the container. Your vault data is persisted in the `limbo-data` Docker volume and is not affected.
package/cli.js CHANGED
@@ -6,6 +6,7 @@
6
6
  const { execSync, spawn, spawnSync } = require('child_process');
7
7
  const crypto = require('crypto');
8
8
  const fs = require('fs');
9
+ const https = require('https');
9
10
  const os = require('os');
10
11
  const path = require('path');
11
12
  const readline = require('readline');
@@ -162,6 +163,8 @@ function composeContent() {
162
163
  - llm_api_key
163
164
  - telegram_bot_token
164
165
  - gateway_token
166
+ - groq_api_key
167
+ - brave_api_key
165
168
  env_file:
166
169
  - ${LIMBO_DIR}/.env
167
170
  environment:
@@ -183,6 +186,10 @@ secrets:
183
186
  file: ${SECRETS_DIR}/telegram_bot_token
184
187
  gateway_token:
185
188
  file: ${SECRETS_DIR}/gateway_token
189
+ groq_api_key:
190
+ file: ${SECRETS_DIR}/groq_api_key
191
+ brave_api_key:
192
+ file: ${SECRETS_DIR}/brave_api_key
186
193
 
187
194
  volumes:
188
195
  limbo-data:
@@ -218,6 +225,8 @@ function composeContentHardened() {
218
225
  - llm_api_key
219
226
  - telegram_bot_token
220
227
  - gateway_token
228
+ - groq_api_key
229
+ - brave_api_key
221
230
  env_file:
222
231
  - ${LIMBO_DIR}/.env
223
232
  environment:
@@ -270,6 +279,10 @@ secrets:
270
279
  file: ${SECRETS_DIR}/telegram_bot_token
271
280
  gateway_token:
272
281
  file: ${SECRETS_DIR}/gateway_token
282
+ groq_api_key:
283
+ file: ${SECRETS_DIR}/groq_api_key
284
+ brave_api_key:
285
+ file: ${SECRETS_DIR}/brave_api_key
273
286
 
274
287
  volumes:
275
288
  limbo-data:
@@ -699,6 +712,8 @@ function normalizeConfig(cfg, existingEnv = {}) {
699
712
  TELEGRAM_BOT_TOKEN: cfg.telegramToken || (cfg.keepExisting ? existingEnv.TELEGRAM_BOT_TOKEN || '' : ''),
700
713
  TELEGRAM_AUTO_PAIR_FIRST_DM: cfg.telegramAutoPair || existingEnv.TELEGRAM_AUTO_PAIR_FIRST_DM || 'false',
701
714
  GATEWAY_TOKEN: gatewayToken,
715
+ VOICE_ENABLED: cfg.voiceEnabled || existingEnv.VOICE_ENABLED || 'false',
716
+ WEB_SEARCH_ENABLED: cfg.webSearchEnabled || existingEnv.WEB_SEARCH_ENABLED || 'false',
702
717
  };
703
718
 
704
719
  return base;
@@ -718,6 +733,8 @@ function writeSecrets(cfg, existingEnv = {}) {
718
733
  writeSecretFile('llm_api_key', normalized.LLM_API_KEY);
719
734
  writeSecretFile('telegram_bot_token', normalized.TELEGRAM_BOT_TOKEN);
720
735
  writeSecretFile('gateway_token', normalized.GATEWAY_TOKEN);
736
+ writeSecretFile('groq_api_key', cfg.groqApiKey || readSecretFile('groq_api_key'));
737
+ writeSecretFile('brave_api_key', cfg.braveApiKey || readSecretFile('brave_api_key'));
721
738
  }
722
739
 
723
740
  const SECRET_KEYS = new Set([
@@ -922,7 +939,7 @@ function ensureComposeFile(hardened = false) {
922
939
  fs.mkdirSync(path.join(VAULT_DIR, 'maps'), { recursive: true });
923
940
  fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
924
941
  // 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']) {
942
+ for (const name of ['llm_api_key', 'telegram_bot_token', 'gateway_token', 'groq_api_key', 'brave_api_key']) {
926
943
  const fp = path.join(SECRETS_DIR, name);
927
944
  if (!fs.existsSync(fp)) fs.writeFileSync(fp, '', { mode: 0o644 });
928
945
  }
@@ -1683,6 +1700,98 @@ function cmdStatus() {
1683
1700
  run('docker compose ps');
1684
1701
  }
1685
1702
 
1703
+ function cmdConfig() {
1704
+ const args = process.argv.slice(3);
1705
+ const feature = args[0];
1706
+
1707
+ if (!feature || !['voice', 'web-search'].includes(feature)) {
1708
+ console.log(`
1709
+ ${c.bold}Usage:${c.reset}
1710
+ limbo config voice --enable --api-key <key>
1711
+ limbo config voice --disable
1712
+ limbo config voice --status
1713
+ limbo config web-search --enable --api-key <key>
1714
+ limbo config web-search --disable
1715
+ limbo config web-search --status
1716
+ `);
1717
+ return;
1718
+ }
1719
+
1720
+ if (!fs.existsSync(ENV_FILE)) {
1721
+ die('Limbo is not configured. Run "limbo start" first.');
1722
+ }
1723
+
1724
+ const existingEnv = {};
1725
+ const envContent = fs.readFileSync(ENV_FILE, 'utf8');
1726
+ for (const line of envContent.split('\n')) {
1727
+ const match = line.match(/^([A-Z_]+)=(.*)$/);
1728
+ if (match) existingEnv[match[1]] = match[2].replace(/^"|"$/g, '');
1729
+ }
1730
+
1731
+ const isVoice = feature === 'voice';
1732
+ const envKey = isVoice ? 'VOICE_ENABLED' : 'WEB_SEARCH_ENABLED';
1733
+ const secretName = isVoice ? 'groq_api_key' : 'brave_api_key';
1734
+ const featureLabel = isVoice ? 'Voice transcription' : 'Web search';
1735
+
1736
+ const hasEnable = args.includes('--enable');
1737
+ const hasDisable = args.includes('--disable');
1738
+ const hasStatus = args.includes('--status');
1739
+ const apiKeyIdx = args.indexOf('--api-key');
1740
+ const apiKey = apiKeyIdx !== -1 ? args[apiKeyIdx + 1] : null;
1741
+
1742
+ if (hasStatus) {
1743
+ const enabled = existingEnv[envKey] === 'true';
1744
+ const key = readSecretFile(secretName);
1745
+ console.log(`${featureLabel}: ${enabled ? `${c.green}enabled${c.reset}` : `${c.dim}disabled${c.reset}`}`);
1746
+ if (key) {
1747
+ const masked = key.length > 8 ? key.substring(0, 4) + '...' + key.slice(-4) : '***';
1748
+ console.log(`API Key: ${masked}`);
1749
+ }
1750
+ return;
1751
+ }
1752
+
1753
+ if (!hasEnable && !hasDisable) {
1754
+ die('Specify --enable, --disable, or --status');
1755
+ }
1756
+
1757
+ if (hasEnable) {
1758
+ if (apiKey) {
1759
+ if (isVoice && !apiKey.startsWith('gsk_')) {
1760
+ warn('Groq API keys typically start with gsk_');
1761
+ }
1762
+ if (!isVoice && !apiKey.startsWith('BSA')) {
1763
+ warn('Brave API keys typically start with BSA');
1764
+ }
1765
+ writeSecretFile(secretName, apiKey);
1766
+ ok(`${featureLabel} API key saved.`);
1767
+ } else {
1768
+ const existing = readSecretFile(secretName);
1769
+ if (!existing) {
1770
+ die(`No API key found. Use --api-key <key> to set one.`);
1771
+ }
1772
+ }
1773
+ existingEnv[envKey] = 'true';
1774
+ } else {
1775
+ existingEnv[envKey] = 'false';
1776
+ }
1777
+
1778
+ const newContent = Object.entries(existingEnv)
1779
+ .map(([key, value]) => `${key}=${value}`)
1780
+ .join('\n') + '\n';
1781
+ fs.writeFileSync(ENV_FILE, newContent, { mode: 0o600 });
1782
+ ok(`${featureLabel} ${hasEnable ? 'enabled' : 'disabled'}.`);
1783
+
1784
+ if (fs.existsSync(COMPOSE_FILE)) {
1785
+ log('Restarting container...');
1786
+ try {
1787
+ execSync(`docker compose -f "${COMPOSE_FILE}" restart limbo`, { stdio: 'inherit' });
1788
+ ok('Container restarted.');
1789
+ } catch {
1790
+ warn('Could not restart container. Restart manually with: limbo stop && limbo start');
1791
+ }
1792
+ }
1793
+ }
1794
+
1686
1795
  function cmdHelp() {
1687
1796
  console.log(`
1688
1797
  ${c.bold}limbo${c.reset} - personal AI memory agent
@@ -1696,6 +1805,7 @@ ${c.bold}Commands:${c.reset}
1696
1805
  logs Tail container logs
1697
1806
  update Pull latest image and restart
1698
1807
  status Show container status
1808
+ config Configure optional features (voice, web-search)
1699
1809
  help Show this help
1700
1810
 
1701
1811
  ${c.bold}Flags:${c.reset}
@@ -1708,30 +1818,129 @@ ${c.bold}Flags:${c.reset}
1708
1818
  --language <code> Language: en, es (default: en)
1709
1819
  --tunnel-domain <d> Admin: use branded subdomain for setup tunnel (e.g. limbo.tomasward.com)
1710
1820
 
1821
+ ${c.bold}Config:${c.reset}
1822
+ limbo config voice --enable --api-key gsk_xxx Enable voice transcription
1823
+ limbo config voice --disable Disable voice transcription
1824
+ limbo config web-search --enable --api-key BSAxxx Enable web search
1825
+ limbo config web-search --disable Disable web search
1826
+ limbo config voice --status Show feature status
1827
+
1711
1828
  ${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
1712
1829
  `);
1713
1830
  }
1714
1831
 
1832
+ // ─── Update Notifier ─────────────────────────────────────────────────────────
1833
+
1834
+ const UPDATE_CHECK_FILE = path.join(LIMBO_DIR, '.update-check');
1835
+ const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
1836
+
1837
+ // Spawn a detached background process to check the npm registry.
1838
+ // Writes {latest, checkedAt} to UPDATE_CHECK_FILE and exits.
1839
+ function checkForUpdateInBackground() {
1840
+ try {
1841
+ let shouldCheck = true;
1842
+ if (fs.existsSync(UPDATE_CHECK_FILE)) {
1843
+ const cached = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'));
1844
+ if (Date.now() - cached.checkedAt < UPDATE_CHECK_INTERVAL) shouldCheck = false;
1845
+ }
1846
+ if (!shouldCheck) return;
1847
+
1848
+ // Spawn detached child that hits the registry and writes cache
1849
+ const child = spawn(process.execPath, ['-e', `
1850
+ const https = require('https');
1851
+ const fs = require('fs');
1852
+ const req = https.get('https://registry.npmjs.org/limbo-ai/latest', { timeout: 5000 }, (res) => {
1853
+ let data = '';
1854
+ res.on('data', (chunk) => data += chunk);
1855
+ res.on('end', () => {
1856
+ try {
1857
+ const { version } = JSON.parse(data);
1858
+ fs.mkdirSync('${LIMBO_DIR.replace(/\\/g, '\\\\')}', { recursive: true });
1859
+ fs.writeFileSync('${UPDATE_CHECK_FILE.replace(/\\/g, '\\\\')}', JSON.stringify({ latest: version, checkedAt: Date.now() }));
1860
+ } catch {}
1861
+ });
1862
+ });
1863
+ req.on('error', () => {});
1864
+ req.end();
1865
+ `], { detached: true, stdio: 'ignore' });
1866
+ child.unref();
1867
+ } catch {}
1868
+ }
1869
+
1870
+ // Read cache and print banner if a newer version is available.
1871
+ function notifyUpdate() {
1872
+ try {
1873
+ if (!fs.existsSync(UPDATE_CHECK_FILE)) return;
1874
+ const { latest } = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'));
1875
+ const pkg = require('./package.json');
1876
+ if (!latest || latest === pkg.version) return;
1877
+
1878
+ // Simple semver compare: split on dots, compare numerically
1879
+ const cur = pkg.version.split('.').map(Number);
1880
+ const lat = latest.split('.').map(Number);
1881
+ const isNewer = lat[0] > cur[0] || (lat[0] === cur[0] && lat[1] > cur[1]) ||
1882
+ (lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
1883
+ if (!isNewer) return;
1884
+
1885
+ // Strip ANSI escapes for visible-length padding
1886
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
1887
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - strip(s).length));
1888
+
1889
+ const line = ` Update available: ${c.dim}${pkg.version}${c.reset} → ${c.green}${latest}${c.reset} `;
1890
+ const instruction = ` Run ${c.cyan}npx limbo-ai@latest update${c.reset} to update `;
1891
+ const inner = Math.max(strip(line).length, strip(instruction).length);
1892
+ const border = '─'.repeat(inner);
1893
+ console.error(`\n ${c.dim}╭${border}╮${c.reset}`);
1894
+ console.error(` ${c.dim}│${c.reset}${pad(line, inner)}${c.dim}│${c.reset}`);
1895
+ console.error(` ${c.dim}│${c.reset}${pad(instruction, inner)}${c.dim}│${c.reset}`);
1896
+ console.error(` ${c.dim}╰${border}╯${c.reset}\n`);
1897
+ } catch {}
1898
+ }
1899
+
1900
+ // ─── Exports (for testing) ────────────────────────────────────────────────────
1901
+
1902
+ module.exports = {
1903
+ MODEL_CATALOG,
1904
+ normalizeConfig,
1905
+ parseEnvFile,
1906
+ deriveProviderFamily,
1907
+ getModelCatalog,
1908
+ parseCallbackInput,
1909
+ decodeJwtPayload,
1910
+ parseClaudeSetupToken,
1911
+ buildCodexAuthProfile,
1912
+ buildAnthropicAuthProfile,
1913
+ generatePKCE,
1914
+ buildOAuthUrl,
1915
+ };
1916
+
1715
1917
  // ─── Main ────────────────────────────────────────────────────────────────────
1716
1918
 
1717
- const [,, cmd = 'start'] = process.argv;
1718
-
1719
- (async () => {
1720
- switch (cmd) {
1721
- case 'start':
1722
- case 'install': await cmdStart(); break;
1723
- case 'stop': cmdStop(); break;
1724
- case 'logs': cmdLogs(); break;
1725
- case 'update': cmdUpdate(); break;
1726
- case 'status': cmdStatus(); break;
1727
- case 'help':
1728
- case '--help':
1729
- case '-h': cmdHelp(); break;
1730
- default:
1731
- warn(t('en', 'unknownCommand', cmd));
1732
- cmdHelp();
1733
- process.exit(1);
1734
- }
1735
- })().catch((err) => {
1736
- die(err.message || String(err));
1737
- });
1919
+ if (require.main === module) {
1920
+ const [,, cmd = 'start'] = process.argv;
1921
+
1922
+ (async () => {
1923
+ checkForUpdateInBackground();
1924
+
1925
+ switch (cmd) {
1926
+ case 'start':
1927
+ case 'install': await cmdStart(); break;
1928
+ case 'stop': cmdStop(); break;
1929
+ case 'logs': cmdLogs(); break;
1930
+ case 'update': cmdUpdate(); break;
1931
+ case 'status': cmdStatus(); break;
1932
+ case 'config': cmdConfig(); break;
1933
+ case 'help':
1934
+ case '--help':
1935
+ case '-h': cmdHelp(); break;
1936
+ default:
1937
+ warn(t('en', 'unknownCommand', cmd));
1938
+ cmdHelp();
1939
+ process.exit(1);
1940
+ }
1941
+
1942
+ notifyUpdate();
1943
+ })().catch((err) => {
1944
+ die(err.message || String(err));
1945
+ });
1946
+ }
@@ -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);