@nxuss/lemma 1.22.0 → 1.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/init.js CHANGED
@@ -399,7 +399,7 @@ function configureClaudeCode() {
399
399
  // license tier (semantic cache vs. exact-match cache), which has nothing to do with
400
400
  // gateway credentials. The proxy (lemma-proxy.ts) never validates whatever token the
401
401
  // client sends anyway: it always signs upstream requests with its OWN server-side
402
- // ANTHROPIC_API_KEY (see installProxyDaemon). So ANTHROPIC_AUTH_TOKEN here is just a
402
+ // ANTHROPIC_API_KEY (loaded from ~/.lemma-cache/env). So ANTHROPIC_AUTH_TOKEN here is just a
403
403
  // fixed local placeholder to satisfy Claude Code's "a token is present" check.
404
404
  function configureClaudeCodeGateway(anthropicApiKey) {
405
405
  if (!anthropicApiKey) {
@@ -795,20 +795,19 @@ function configureOpenCode() {
795
795
  return true;
796
796
  }
797
797
 
798
- function installProxyDaemon() {
798
+ // Installs the cache-proxy daemon as a user-level launchd/systemd unit.
799
+ // OPT-IN ONLY: `lemma init` never calls this without an explicit --daemon flag.
800
+ // Secrets are never baked into the unit file: the daemon loads ~/.lemma-cache/env
801
+ // at startup (see lemma-proxy.cjs loadEnvFile), where `lemma init` persists keys
802
+ // with mode 0600 instead.
803
+ function installProxyDaemon(homeDir = os.homedir()) {
799
804
  const isMac = process.platform === 'darwin';
800
805
  const isLinux = process.platform === 'linux';
801
806
 
802
- // The full proxy server (lemma-proxy.ts) needs its own server-side credentials to sign
803
- // upstream requests — it never trusts whatever token the calling client sends. A launchd/
804
- // systemd service does not inherit the shell's exported env, so these must be baked into
805
- // the unit definition at install time or the daemon silently falls back to local Ollama.
806
- const anthropicApiKey = process.env.ANTHROPIC_API_KEY || '';
807
- const openaiApiKey = process.env.OPENAI_API_KEY || '';
808
807
  const proxyArgs = ['start', '--port', PORT, '--no-configure', '--no-clipboard'];
809
808
 
810
809
  if (isMac) {
811
- const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
810
+ const plistDir = path.join(homeDir, 'Library', 'LaunchAgents');
812
811
  ensureDir(plistDir);
813
812
  const plistPath = path.join(plistDir, 'com.lemma.cache-proxy.plist');
814
813
  const argEntries = proxyArgs.map((a) => ` <string>${a}</string>`).join('\n');
@@ -816,9 +815,7 @@ function installProxyDaemon() {
816
815
  ` <key>LEMMA_PROXY_PORT</key><string>${PORT}</string>`,
817
816
  ` <key>LEMMA_CACHE_DIR</key><string>${LEMMA_CACHE}</string>`,
818
817
  ` <key>LEMMA_DISABLE_CLIPBOARD</key><string>true</string>`,
819
- anthropicApiKey ? ` <key>ANTHROPIC_API_KEY</key><string>${anthropicApiKey}</string>` : '',
820
- openaiApiKey ? ` <key>OPENAI_API_KEY</key><string>${openaiApiKey}</string>` : '',
821
- ].filter(Boolean).join('\n');
818
+ ].join('\n');
822
819
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
823
820
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
824
821
  <plist version="1.0">
@@ -848,15 +845,13 @@ ${envEntries}
848
845
  }
849
846
 
850
847
  if (isLinux) {
851
- const svcDir = path.join(os.homedir(), '.config', 'systemd', 'user');
848
+ const svcDir = path.join(homeDir, '.config', 'systemd', 'user');
852
849
  ensureDir(svcDir);
853
850
  const envLines = [
854
851
  `Environment=LEMMA_PROXY_PORT=${PORT}`,
855
852
  `Environment=LEMMA_CACHE_DIR=${LEMMA_CACHE}`,
856
853
  `Environment=LEMMA_DISABLE_CLIPBOARD=true`,
857
- anthropicApiKey ? `Environment=ANTHROPIC_API_KEY=${anthropicApiKey}` : '',
858
- openaiApiKey ? `Environment=OPENAI_API_KEY=${openaiApiKey}` : '',
859
- ].filter(Boolean).join('\n');
854
+ ].join('\n');
860
855
  const svc = `[Unit]
861
856
  Description=Lemma Cache Proxy
862
857
  After=network.target
@@ -879,6 +874,39 @@ WantedBy=default.target
879
874
  return false;
880
875
  }
881
876
 
877
+ // Best-effort removal of a previously installed daemon unit. Plain `lemma init`
878
+ // (no --daemon) calls this so a silent or legacy install can never linger behind:
879
+ // the daemon only exists while the user explicitly asked for it.
880
+ function removeProxyDaemon(homeDir = os.homedir()) {
881
+ if (process.platform === 'darwin') {
882
+ const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', 'com.lemma.cache-proxy.plist');
883
+ if (!fs.existsSync(plistPath)) return false;
884
+ try { execSync('launchctl unload ' + plistPath, { stdio: 'ignore' }); } catch {}
885
+ try { fs.unlinkSync(plistPath); } catch {}
886
+ return !fs.existsSync(plistPath);
887
+ }
888
+ if (process.platform === 'linux') {
889
+ const svcPath = path.join(homeDir, '.config', 'systemd', 'user', 'lemma-cache-proxy.service');
890
+ if (!fs.existsSync(svcPath)) return false;
891
+ try { execSync('systemctl --user disable lemma-cache-proxy --now', { stdio: 'ignore' }); } catch {}
892
+ try { fs.unlinkSync(svcPath); } catch {}
893
+ return !fs.existsSync(svcPath);
894
+ }
895
+ return false;
896
+ }
897
+
898
+ // Global side effects stay opt-in: a plain `lemma init` only writes project-local
899
+ // files + MCP registrations. --daemon installs the background proxy daemon,
900
+ // --gateway reroutes the user's Claude Code model traffic through that proxy.
901
+ function parseInitArgs(argv = process.argv) {
902
+ const args = argv.slice(2);
903
+ return {
904
+ showHelp: args.includes('--help') || args.includes('-h'),
905
+ wantDaemon: args.includes('--daemon'),
906
+ wantGateway: args.includes('--gateway'),
907
+ };
908
+ }
909
+
882
910
  function applyEnvToCurrentProcess() {
883
911
  const proxyUrl = `http://localhost:${PORT}`;
884
912
  process.env.LEMMA_PROXY_PORT = PORT;
@@ -889,21 +917,29 @@ function applyEnvToCurrentProcess() {
889
917
  process.env.LITELLM_PROXY_BASE_URL = proxyUrl;
890
918
  }
891
919
 
892
- function createEnvFile() {
920
+ // Upstream API keys live here (mode 0600) instead of inside the launchd/systemd
921
+ // unit: the proxy daemon loads this file at startup (lemma-proxy.cjs loadEnvFile),
922
+ // so a background service gets credentials without baking secrets into a unit file.
923
+ function createEnvFile(envFile = ENV_FILE) {
893
924
  const proxyUrl = `http://localhost:${PORT}`;
925
+ const keyLines = [
926
+ process.env.ANTHROPIC_API_KEY ? `export ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : '',
927
+ process.env.OPENAI_API_KEY ? `export OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : '',
928
+ ].filter(Boolean).join('\n');
894
929
  const envContent = `# Lemma Cache Proxy — Auto-generated by \`lemma init\`
895
930
  # Source this file in your shell to route LLM traffic through the cache:
896
- # source ${ENV_FILE}
931
+ # source ${envFile}
897
932
  export LEMMA_PROXY_PORT=${PORT}
898
933
  export LEMMA_CACHE_DIR=${LEMMA_CACHE}
899
934
  export OPENAI_BASE_URL=${proxyUrl}
900
935
  export OPENAI_API_BASE=${proxyUrl}
901
936
  export CODEX_BASE_URL=${proxyUrl}
902
937
  export LITELLM_PROXY_BASE_URL=${proxyUrl}
903
- `;
904
- fs.writeFileSync(ENV_FILE, envContent, 'utf8');
938
+ ${keyLines ? keyLines + '\n' : ''}`;
939
+ fs.writeFileSync(envFile, envContent, 'utf8');
940
+ try { fs.chmodSync(envFile, 0o600); } catch {}
905
941
  applyEnvToCurrentProcess();
906
- return ENV_FILE;
942
+ return envFile;
907
943
  }
908
944
 
909
945
  function setupShellIntegration() {
@@ -942,7 +978,26 @@ function setupShellIntegration() {
942
978
  return configured;
943
979
  }
944
980
 
945
- async function main() {
981
+ async function main(argv = process.argv) {
982
+ const opts = parseInitArgs(argv);
983
+ if (opts.showHelp) {
984
+ process.stderr.write(`
985
+ Lemma Init — project setup (project-local files + MCP registrations only)
986
+
987
+ Usage: lemma init [--daemon] [--gateway]
988
+
989
+ --daemon Install + start the background cache-proxy daemon
990
+ (launchd KeepAlive on macOS, systemd user unit on Linux).
991
+ Without it, no daemon is installed — and a previously installed
992
+ one is removed.
993
+ --gateway Reroute this user's Claude Code model traffic through the proxy
994
+ (~/.claude/settings.json). Requires ANTHROPIC_API_KEY in env.
995
+ Without it, Claude Code keeps its own auth untouched.
996
+
997
+ `);
998
+ return;
999
+ }
1000
+
946
1001
  process.stderr.write(`\n ${BOLD}${CYAN}Lemma Init — Magic Setup${RESET}\n\n`);
947
1002
 
948
1003
  ensureDir(LEMMA_CACHE);
@@ -961,17 +1016,23 @@ async function main() {
961
1016
  configureClaudeCode();
962
1017
  ok('Claude Code / CLI configured (CLAUDE.md + .claude/settings.local.json)');
963
1018
 
964
- const anthropicApiKey = process.env.ANTHROPIC_API_KEY || '';
965
- const gatewayResult = configureClaudeCodeGateway(anthropicApiKey);
966
- if (gatewayResult.activated) {
967
- ok(`Claude Code gateway activated (~/.claude/settings.json ANTHROPIC_BASE_URL=http://localhost:${PORT})`);
1019
+ // --gateway is opt-in: flipping the user's global Claude Code auth without being
1020
+ // asked is exactly how Pro subscribers end up billed as API usage.
1021
+ if (!opts.wantGateway) {
1022
+ skip('Claude Code gateway not requested — model traffic + auth untouched (MCP tools only). Re-run with --gateway to route it via the proxy.');
968
1023
  } else {
969
- warn('Claude Code gateway NOT activated — no ANTHROPIC_API_KEY in this environment.');
970
- warn(` Model traffic keeps going directly to Anthropic (subscription/OAuth or your own key, unchanged).`);
971
- warn(` Only Lemma's MCP tools are active for Claude Code — no cache savings on the model traffic itself.`);
972
- warn(` To enable it: export ANTHROPIC_API_KEY=sk-ant-... and re-run \`lemma init\`.`);
973
- warn(` This SWITCHES Claude Code off your subscription/OAuth session for LLM calls — they'll be`);
974
- warn(` signed by the proxy's own ANTHROPIC_API_KEY instead, and billed/rate-limited as API usage.`);
1024
+ const anthropicApiKey = process.env.ANTHROPIC_API_KEY || '';
1025
+ const gatewayResult = configureClaudeCodeGateway(anthropicApiKey);
1026
+ if (gatewayResult.activated) {
1027
+ ok(`Claude Code gateway activated (~/.claude/settings.json ANTHROPIC_BASE_URL=http://localhost:${PORT})`);
1028
+ } else {
1029
+ warn('Claude Code gateway NOT activated no ANTHROPIC_API_KEY in this environment.');
1030
+ warn(` Model traffic keeps going directly to Anthropic (subscription/OAuth or your own key, unchanged).`);
1031
+ warn(` Only Lemma's MCP tools are active for Claude Code — no cache savings on the model traffic itself.`);
1032
+ warn(` To enable it: export ANTHROPIC_API_KEY=sk-ant-... and re-run \`lemma init --gateway\`.`);
1033
+ warn(` This SWITCHES Claude Code off your subscription/OAuth session for LLM calls — they'll be`);
1034
+ warn(` signed by the proxy's own ANTHROPIC_API_KEY instead, and billed/rate-limited as API usage.`);
1035
+ }
975
1036
  }
976
1037
 
977
1038
  updateAgentsMd();
@@ -1024,9 +1085,15 @@ async function main() {
1024
1085
  if (hasWindsurf) ok('Windsurf detected — env auto-configured');
1025
1086
  else skip('Windsurf not detected');
1026
1087
 
1027
- const daemonOk = installProxyDaemon();
1028
- if (daemonOk) ok('Cache proxy daemon installed & started (OpenAI + Anthropic /v1/messages, streaming)');
1029
- else warn('Could not install daemon run `lemma proxy start` manually');
1088
+ if (opts.wantDaemon) {
1089
+ const daemonOk = installProxyDaemon();
1090
+ if (daemonOk) ok('Cache proxy daemon installed & started (OpenAI + Anthropic /v1/messages, streaming)');
1091
+ else warn('Could not install daemon — run `lemma proxy start` manually');
1092
+ } else if (removeProxyDaemon()) {
1093
+ ok('Removed previously installed proxy daemon (re-run with --daemon to restore it)');
1094
+ } else {
1095
+ skip('Proxy daemon not installed (opt-in via `lemma init --daemon`)');
1096
+ }
1030
1097
 
1031
1098
  const envFile = createEnvFile();
1032
1099
  ok(`Env file created: ${envFile}`);
@@ -1037,7 +1104,11 @@ async function main() {
1037
1104
 
1038
1105
  process.stderr.write(`\n ${BOLD}${GREEN}✓ Lemma init complete${RESET}\n`);
1039
1106
  process.stderr.write(` ${DIM}──────────────────────────────────────${RESET}\n`);
1040
- process.stderr.write(` ${GREEN}●${RESET} Cache proxy running on http://localhost:${PORT}\n`);
1107
+ if (opts.wantDaemon) {
1108
+ process.stderr.write(` ${GREEN}●${RESET} Cache proxy running on http://localhost:${PORT}\n`);
1109
+ } else {
1110
+ process.stderr.write(` ${DIM}○ Cache proxy daemon not installed (opt-in: \`lemma init --daemon\`)${RESET}\n`);
1111
+ }
1041
1112
  process.stderr.write(` ${GREEN}●${RESET} MCP server ready ${DIM}(lemma mcp)${RESET}\n`);
1042
1113
  process.stderr.write(` ${GREEN}●${RESET} Dashboard active ${DIM}(lemma stats)${RESET}\n`);
1043
1114
  process.stderr.write(` ${GREEN}●${RESET} New terminals: env vars auto-loaded\n`);
@@ -1057,4 +1128,4 @@ if (require.main === module) {
1057
1128
  });
1058
1129
  }
1059
1130
 
1060
- module.exports = { main, configureMuse, configureMuseUserSkill, updateAgentsMd, MUSE_SKILL_CONTENT, LEMMA_RULES_CONTENT };
1131
+ module.exports = { main, configureMuse, configureMuseUserSkill, updateAgentsMd, MUSE_SKILL_CONTENT, LEMMA_RULES_CONTENT, installProxyDaemon, removeProxyDaemon, parseInitArgs, createEnvFile };
@@ -2,6 +2,8 @@
2
2
  export declare function startBackgroundClipboardWatcher(cliOpts?: {
3
3
  clipboard?: boolean;
4
4
  }): void;
5
+ export declare const MAX_WORKSPACE_WATCHERS = 512;
6
+ export declare function isUnsafeWatchRoot(dir: string): boolean;
5
7
  export declare function performAutoHeal(apply: boolean): Promise<{
6
8
  success: boolean;
7
9
  message: string;
@@ -1 +1 @@
1
- {"version":3,"file":"lemma-proxy.d.ts","sourceRoot":"","sources":["../../../src/cli/lemma-proxy.ts"],"names":[],"mappings":";AA0cA,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CA4DvF;AA4nED,wBAAsB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA8KhL"}
1
+ {"version":3,"file":"lemma-proxy.d.ts","sourceRoot":"","sources":["../../../src/cli/lemma-proxy.ts"],"names":[],"mappings":";AA0cA,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CA4DvF;AAqfD,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAGtD;AAyqDD,wBAAsB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA8KhL"}
@@ -4,7 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  return (mod && mod.__esModule) ? mod : { "default": mod };
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.MAX_WORKSPACE_WATCHERS = void 0;
7
8
  exports.startBackgroundClipboardWatcher = startBackgroundClipboardWatcher;
9
+ exports.isUnsafeWatchRoot = isUnsafeWatchRoot;
8
10
  exports.performAutoHeal = performAutoHeal;
9
11
  const commander_1 = require("commander");
10
12
  const express_1 = __importDefault(require("express"));
@@ -960,6 +962,16 @@ class WriteQueue {
960
962
  }
961
963
  }
962
964
  const usageWriteQueue = new WriteQueue();
965
+ // One fs.watch FD is opened per watched directory. Watching $HOME (the launchd
966
+ // daemon's WorkingDirectory) or a filesystem root exhausts FDs (EMFILE), crashes
967
+ // the process, and a KeepAlive supervisor turns that into an infinite
968
+ // crash/relaunch loop — on macOS each relaunch re-triggers the TCC data-access
969
+ // prompt. Refuse those roots outright.
970
+ exports.MAX_WORKSPACE_WATCHERS = 512;
971
+ function isUnsafeWatchRoot(dir) {
972
+ const resolved = path_1.default.resolve(dir);
973
+ return resolved === path_1.default.resolve(os_1.default.homedir()) || resolved === path_1.default.parse(resolved).root;
974
+ }
963
975
  // ── Proxy Server class ─────────────────────────────────────────────────────────
964
976
  class LemmaServer {
965
977
  constructor(port, projectName, openBrowser = true) {
@@ -1648,12 +1660,28 @@ class LemmaServer {
1648
1660
  }
1649
1661
  startWorkspaceTimeWatcher() {
1650
1662
  const cwd = process.cwd();
1663
+ if (isUnsafeWatchRoot(cwd)) {
1664
+ console.log(`⏭️ [TimeTravel] Skipping workspace watcher: refusing to watch ${cwd} recursively (file-descriptor exhaustion risk). Start the proxy from a project directory instead.`);
1665
+ return;
1666
+ }
1651
1667
  const { watch } = require('fs');
1652
1668
  // Debounced capture function
1653
1669
  let debounceTimer = null;
1654
1670
  const pendingChanges = new Set();
1671
+ // One FD per watched directory: cap it so giant trees degrade to a warning
1672
+ // instead of an EMFILE crash (which a KeepAlive supervisor turns into a
1673
+ // crash/relaunch loop).
1674
+ let watchCount = 0;
1675
+ let limitWarned = false;
1655
1676
  const ignoreDirs = ['node_modules', '.git', 'dist', 'chroma_data', '.lemma', 'dashboard', 'bin', 'sdks'];
1656
1677
  const watchDirRecursive = (dir) => {
1678
+ if (watchCount >= exports.MAX_WORKSPACE_WATCHERS) {
1679
+ if (!limitWarned) {
1680
+ limitWarned = true;
1681
+ console.log(`⚠️ [TimeTravel] Watcher limit (${exports.MAX_WORKSPACE_WATCHERS} dirs) reached at ${dir} — deeper directories won't trigger snapshots.`);
1682
+ }
1683
+ return;
1684
+ }
1657
1685
  try {
1658
1686
  const watcher = watch(dir, (eventType, filename) => {
1659
1687
  if (!filename)
@@ -1675,6 +1703,19 @@ class LemmaServer {
1675
1703
  // Prevent watcher from keeping process alive if in background
1676
1704
  if (watcher.unref)
1677
1705
  watcher.unref();
1706
+ watchCount++;
1707
+ // An async EMFILE/ENOSPC on a giant tree must never take down the server:
1708
+ // contain it to a single warning and keep serving.
1709
+ watcher.on('error', (err) => {
1710
+ if (!limitWarned) {
1711
+ limitWarned = true;
1712
+ console.log(`⚠️ [TimeTravel] Watcher error (${(err && err.code) || 'unknown'}) — snapshots degraded, server keeps running.`);
1713
+ }
1714
+ try {
1715
+ watcher.close();
1716
+ }
1717
+ catch { }
1718
+ });
1678
1719
  // Recursively watch subfolders
1679
1720
  const files = fs_1.default.readdirSync(dir, { withFileTypes: true });
1680
1721
  for (const file of files) {