@nxuss/lemma 1.35.0 → 1.37.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 (94) hide show
  1. package/README.md +84 -426
  2. package/dashboard/README.md +48 -19
  3. package/dashboard/dist/assets/index-BoeQau6n.css +1 -0
  4. package/dashboard/dist/assets/index-Clf3qR64.js +451 -0
  5. package/dashboard/dist/index.html +2 -2
  6. package/dist/cjs/api/dashboardRoutes.d.ts.map +1 -1
  7. package/dist/cjs/api/dashboardRoutes.js +7 -3
  8. package/dist/cjs/api/dashboardRoutes.js.map +1 -1
  9. package/dist/cjs/autopilot/Notifier.d.ts.map +1 -1
  10. package/dist/cjs/autopilot/Notifier.js +3 -0
  11. package/dist/cjs/autopilot/Notifier.js.map +1 -1
  12. package/dist/cjs/cli/lemma-proxy.d.ts.map +1 -1
  13. package/dist/cjs/cli/lemma-proxy.js +214 -103
  14. package/dist/cjs/cli/lemma-proxy.js.map +1 -1
  15. package/dist/cjs/mcp/index.js +9 -8
  16. package/dist/cjs/mcp/index.js.map +1 -1
  17. package/dist/cjs/mcp/resources.d.ts.map +1 -1
  18. package/dist/cjs/mcp/resources.js +23 -4
  19. package/dist/cjs/mcp/resources.js.map +1 -1
  20. package/dist/cjs/mcp/tools/memory.js +11 -11
  21. package/dist/cjs/mcp/tools/memory.js.map +1 -1
  22. package/dist/cjs/mcp/tools/misc.js +1 -1
  23. package/dist/cjs/mcp/tools/misc.js.map +1 -1
  24. package/dist/cjs/mcp/tools/review.js +1 -1
  25. package/dist/cjs/mcp/tools/review.js.map +1 -1
  26. package/dist/cjs/mcp/tools/workspace.d.ts.map +1 -1
  27. package/dist/cjs/mcp/tools/workspace.js +10 -7
  28. package/dist/cjs/mcp/tools/workspace.js.map +1 -1
  29. package/dist/cjs/mcp/tools.d.ts +16 -1
  30. package/dist/cjs/mcp/tools.d.ts.map +1 -1
  31. package/dist/cjs/mcp/tools.js +63 -33
  32. package/dist/cjs/mcp/tools.js.map +1 -1
  33. package/dist/cjs/utils/ProjectIdentity.d.ts +8 -0
  34. package/dist/cjs/utils/ProjectIdentity.d.ts.map +1 -1
  35. package/dist/cjs/utils/ProjectIdentity.js +15 -0
  36. package/dist/cjs/utils/ProjectIdentity.js.map +1 -1
  37. package/dist/cjs/utils/SavingsLedger.d.ts +2 -0
  38. package/dist/cjs/utils/SavingsLedger.d.ts.map +1 -1
  39. package/dist/cjs/utils/SavingsLedger.js +5 -5
  40. package/dist/cjs/utils/SavingsLedger.js.map +1 -1
  41. package/dist/cjs/utils/TokenReceipt.d.ts +2 -0
  42. package/dist/cjs/utils/TokenReceipt.d.ts.map +1 -1
  43. package/dist/cjs/utils/TokenReceipt.js +11 -1
  44. package/dist/cjs/utils/TokenReceipt.js.map +1 -1
  45. package/dist/cjs/utils/reportSavings.d.ts +7 -0
  46. package/dist/cjs/utils/reportSavings.d.ts.map +1 -1
  47. package/dist/cjs/utils/reportSavings.js +14 -2
  48. package/dist/cjs/utils/reportSavings.js.map +1 -1
  49. package/dist/esm/api/dashboardRoutes.d.ts.map +1 -1
  50. package/dist/esm/api/dashboardRoutes.js +7 -3
  51. package/dist/esm/api/dashboardRoutes.js.map +1 -1
  52. package/dist/esm/autopilot/Notifier.d.ts.map +1 -1
  53. package/dist/esm/autopilot/Notifier.js +3 -0
  54. package/dist/esm/autopilot/Notifier.js.map +1 -1
  55. package/dist/esm/cli/lemma-proxy.d.ts.map +1 -1
  56. package/dist/esm/cli/lemma-proxy.js +215 -104
  57. package/dist/esm/cli/lemma-proxy.js.map +1 -1
  58. package/dist/esm/mcp/index.js +9 -8
  59. package/dist/esm/mcp/index.js.map +1 -1
  60. package/dist/esm/mcp/resources.d.ts.map +1 -1
  61. package/dist/esm/mcp/resources.js +23 -4
  62. package/dist/esm/mcp/resources.js.map +1 -1
  63. package/dist/esm/mcp/tools/memory.js +11 -11
  64. package/dist/esm/mcp/tools/memory.js.map +1 -1
  65. package/dist/esm/mcp/tools/misc.js +1 -1
  66. package/dist/esm/mcp/tools/misc.js.map +1 -1
  67. package/dist/esm/mcp/tools/review.js +1 -1
  68. package/dist/esm/mcp/tools/review.js.map +1 -1
  69. package/dist/esm/mcp/tools/workspace.d.ts.map +1 -1
  70. package/dist/esm/mcp/tools/workspace.js +10 -7
  71. package/dist/esm/mcp/tools/workspace.js.map +1 -1
  72. package/dist/esm/mcp/tools.d.ts +16 -1
  73. package/dist/esm/mcp/tools.d.ts.map +1 -1
  74. package/dist/esm/mcp/tools.js +62 -34
  75. package/dist/esm/mcp/tools.js.map +1 -1
  76. package/dist/esm/utils/ProjectIdentity.d.ts +8 -0
  77. package/dist/esm/utils/ProjectIdentity.d.ts.map +1 -1
  78. package/dist/esm/utils/ProjectIdentity.js +14 -0
  79. package/dist/esm/utils/ProjectIdentity.js.map +1 -1
  80. package/dist/esm/utils/SavingsLedger.d.ts +2 -0
  81. package/dist/esm/utils/SavingsLedger.d.ts.map +1 -1
  82. package/dist/esm/utils/SavingsLedger.js +1 -1
  83. package/dist/esm/utils/SavingsLedger.js.map +1 -1
  84. package/dist/esm/utils/TokenReceipt.d.ts +2 -0
  85. package/dist/esm/utils/TokenReceipt.d.ts.map +1 -1
  86. package/dist/esm/utils/TokenReceipt.js +10 -1
  87. package/dist/esm/utils/TokenReceipt.js.map +1 -1
  88. package/dist/esm/utils/reportSavings.d.ts +7 -0
  89. package/dist/esm/utils/reportSavings.d.ts.map +1 -1
  90. package/dist/esm/utils/reportSavings.js +13 -2
  91. package/dist/esm/utils/reportSavings.js.map +1 -1
  92. package/package.json +10 -3
  93. package/dashboard/dist/assets/index-Yt1H2B7F.css +0 -1
  94. package/dashboard/dist/assets/index-euG1IvQ7.js +0 -481
@@ -1 +1 @@
1
- {"version":3,"file":"Notifier.js","sourceRoot":"","sources":["../../../src/autopilot/Notifier.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,IAAI,MAAM,MAAM,CAAA;AAGvB,MAAM,kBAAkB,GAAG,uBAAuB,CAAA;AAElD,SAAS,eAAe,CAAC,KAAa,EAAE,QAAgB,EAAE,OAAe;IACvE,IAAI,CAAC;QACH,QAAQ,CACN,uCAAuC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,iBAAiB,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,eAAe,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,yBAAyB,EACnL,EAAE,OAAO,EAAE,IAAI,EAAE,CAClB,CAAA;IACH,CAAC;IAAC,MAAM,CAAC,CAAA,CAAC;AACZ,CAAC;AAED,SAAS,sBAAsB,CAAC,KAAa,EAAE,QAAgB,EAAE,OAAe;IAC9E,IAAI,CAAC;QACH,QAAQ,CACN,6BAA6B,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,gBAAgB,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,eAAe,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,8BAA8B,EAC7K,EAAE,OAAO,EAAE,IAAI,EAAE,CAClB,CAAA;IACH,CAAC;IAAC,MAAM,CAAC;QACP,eAAe,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAA;IAC3C,CAAC;AACH,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,QAAgB,EAAE,OAAe;IAC/E,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAClC,IAAI,OAAO,CAAC,GAAG,CAAC,EAAE,IAAI,OAAO,CAAC,GAAG,CAAC,SAAS;YAAE,OAAM;QACnD,sBAAsB,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAA;IAClD,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAY;IACtC,MAAM,KAAK,GAAG,aAAa,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5C,MAAM,QAAQ,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAC,IAAI,EAAE,CAAA;IACjE,MAAM,GAAG,GAAG,KAAK,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;IAE/C,gBAAgB,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,CAAC,CAAA;IACtC,eAAe,CAAC,KAAK,CAAC,CAAA;AACxB,CAAC;AAED,KAAK,UAAU,eAAe,CAAC,KAAY;IACzC,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,kBAAkB,qBAAqB,EAAE;YAC3D,EAAE,EAAE,KAAK,CAAC,EAAE;YACZ,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,WAAW,EAAE,KAAK,CAAC,WAAW;YAC9B,SAAS,EAAE,KAAK,CAAC,SAAS;SAC3B,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;IACvB,CAAC;IAAC,MAAM,CAAC,CAAA,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,gBAAgB,CAAC,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,CAAC,CAAA;AACxE,CAAC"}
1
+ {"version":3,"file":"Notifier.js","sourceRoot":"","sources":["../../../src/autopilot/Notifier.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,IAAI,MAAM,MAAM,CAAA;AAEvB,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAE/D,MAAM,kBAAkB,GAAG,uBAAuB,CAAA;AAElD,SAAS,eAAe,CAAC,KAAa,EAAE,QAAgB,EAAE,OAAe;IACvE,IAAI,CAAC;QACH,QAAQ,CACN,uCAAuC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,iBAAiB,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,eAAe,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,yBAAyB,EACnL,EAAE,OAAO,EAAE,IAAI,EAAE,CAClB,CAAA;IACH,CAAC;IAAC,MAAM,CAAC,CAAA,CAAC;AACZ,CAAC;AAED,SAAS,sBAAsB,CAAC,KAAa,EAAE,QAAgB,EAAE,OAAe;IAC9E,IAAI,CAAC;QACH,QAAQ,CACN,6BAA6B,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,gBAAgB,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,eAAe,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,8BAA8B,EAC7K,EAAE,OAAO,EAAE,IAAI,EAAE,CAClB,CAAA;IACH,CAAC;IAAC,MAAM,CAAC;QACP,eAAe,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAA;IAC3C,CAAC;AACH,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,QAAgB,EAAE,OAAe;IAC/E,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAClC,IAAI,OAAO,CAAC,GAAG,CAAC,EAAE,IAAI,OAAO,CAAC,GAAG,CAAC,SAAS;YAAE,OAAM;QACnD,sBAAsB,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAA;IAClD,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAY;IACtC,MAAM,KAAK,GAAG,aAAa,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5C,MAAM,QAAQ,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAC,IAAI,EAAE,CAAA;IACjE,MAAM,GAAG,GAAG,KAAK,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;IAE/C,gBAAgB,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,CAAC,CAAA;IACtC,eAAe,CAAC,KAAK,CAAC,CAAA;AACxB,CAAC;AAED,KAAK,UAAU,eAAe,CAAC,KAAY;IACzC,IAAI,sBAAsB,EAAE;QAAE,OAAM;IACpC,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,kBAAkB,qBAAqB,EAAE;YAC3D,EAAE,EAAE,KAAK,CAAC,EAAE;YACZ,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,WAAW,EAAE,KAAK,CAAC,WAAW;YAC9B,SAAS,EAAE,KAAK,CAAC,SAAS;SAC3B,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;IACvB,CAAC;IAAC,MAAM,CAAC,CAAA,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,gBAAgB,CAAC,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,CAAC,CAAA;AACxE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"lemma-proxy.d.ts","sourceRoot":"","sources":["../../../src/cli/lemma-proxy.ts"],"names":[],"mappings":";AAycA,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CA8DvF;AAifD,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAGtD;AAivDD,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":";AAgdA,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CA+DvF;AAiiBD,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAGtD;AA+xDD,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"}
@@ -46,12 +46,14 @@ import { Gatekeeper } from '../proxy/Gatekeeper.js';
46
46
  import { detectProjectName, isProjectMismatch } from '../utils/ProjectIdentity.js';
47
47
  import { ProjectRegistry } from '../proxy/ProjectRegistry.js';
48
48
  import { shouldInject, buildMinimalContext, buildDeepContext, sniffProjectFromMessages } from '../proxy/ContextInjector.js';
49
- import { savingsLedger } from '../utils/SavingsLedger.js';
49
+ import { savingsLedger, AVG_COST_PER_TOKEN } from '../utils/SavingsLedger.js';
50
50
  import { isDebuggerPaused, setDebuggerPaused } from '../proxy/DebuggerState.js';
51
51
  import { CACHE_DIR, PID_FILE, PORT_FILE, STATS_FILE, USAGE_FILE, LICENSE_FILE, CLIPBOARD_PID_FILE, FREE_TIER_LIMIT } from '../proxy/ProjectStore.js';
52
52
  import { installBrainHooks } from './gitHooks.js';
53
53
  import { installHostHooks } from './hostHooks.js';
54
54
  import { getBrain } from '../subconscious/TheBrainV2.js';
55
+ import { DEFAULT_MEMORY_SIMILARITY_FLOOR } from '../mcp/utils.js';
56
+ import { cacheCollectionName } from '../utils/ProjectIdentity.js';
55
57
  const chroma = new ChromaClient({ host: 'localhost', port: 8000 });
56
58
  const dummyEmbeddingFunction = { generate: (_texts) => Promise.resolve([]) };
57
59
  const complexityRouter = new ComplexityRouter();
@@ -87,7 +89,7 @@ function writeJson(filePath, data) {
87
89
  catch { }
88
90
  }
89
91
  function getCollectionName(projectName) {
90
- return `lemma-cache-${projectName}`;
92
+ return cacheCollectionName(projectName);
91
93
  }
92
94
  // detectProject/isProjectMismatch live in ../utils/ProjectIdentity — a side-effect-free
93
95
  // module shared with src/mcp/tools.ts, so the client and this daemon can never define
@@ -387,6 +389,11 @@ enabled = true
387
389
  }
388
390
  const EVENT_LOG = [];
389
391
  const MAX_EVENTS = 100;
392
+ /**
393
+ * Secrets masked since this daemon started. Counting 'privacy:mask' entries in EVENT_LOG only
394
+ * ever saw the last 100 events, and counted requests rather than secrets.
395
+ */
396
+ const SECRETS_MASKED = { count: 0 };
390
397
  // MCP Status tracking
391
398
  const MCP_STATUS = {
392
399
  lastActivity: Date.now(),
@@ -473,7 +480,7 @@ export function startBackgroundClipboardWatcher(cliOpts) {
473
480
  }
474
481
  }
475
482
  catch { }
476
- const shouldWatch = cliOpts?.clipboard !== false && !configDisabled;
483
+ const shouldWatch = cliOpts?.clipboard !== false && !configDisabled && process.env.LEMMA_DISABLE_CLIPBOARD !== 'true';
477
484
  if (!shouldWatch) {
478
485
  console.log(`⏭️ [Clipboard] Background clipboard watcher is disabled (via options or config).`);
479
486
  return;
@@ -581,6 +588,20 @@ async function loadStats() {
581
588
  * Tokens a cache hit actually avoided regenerating, measured from the cached payload
582
589
  * that was served instead of an LLM call. Replaces the old hardcoded 2000 guess.
583
590
  */
591
+ /** The answer text inside a cached provider response (OpenAI, Anthropic or Gemini shape). */
592
+ function extractResponseText(data) {
593
+ const text = data?.choices?.[0]?.message?.content ??
594
+ data?.content?.find?.((c) => c?.type === 'text')?.text ??
595
+ data?.candidates?.[0]?.content?.parts?.[0]?.text;
596
+ if (typeof text === 'string')
597
+ return text;
598
+ try {
599
+ return JSON.stringify(data);
600
+ }
601
+ catch {
602
+ return '';
603
+ }
604
+ }
584
605
  function measureCachedTokens(data) {
585
606
  try {
586
607
  const chars = JSON.stringify(data)?.length;
@@ -602,6 +623,33 @@ function measureCachedTokens(data) {
602
623
  * round-trip), so a miss costs latency, not tokens. Charging it a fabricated token
603
624
  * figure would repeat exactly the error the cost ledger exists to correct.
604
625
  */
626
+ /**
627
+ * Machine-wide totals. Completions are attributed per project (see handleCompletion), and the
628
+ * dashboard — like the savings ledger — reports the whole daemon, not one project's slice.
629
+ */
630
+ function aggregateStats(stats) {
631
+ const sum = { total: 0, hits: 0, misses: 0, totalLatency: 0, totalTokensSaved: 0 };
632
+ for (const s of Object.values(stats || {})) {
633
+ // Stats written before /api/savings-record stopped touching them counted every MCP saving
634
+ // as a completion under provider 'mcp', with a mock 15ms latency. Those counts are exact in
635
+ // the provider breakdown, so take them back out; only real completions remain.
636
+ const mcp = s?.providers?.mcp || { hits: 0, misses: 0 };
637
+ const mcpCalls = (mcp.hits || 0) + (mcp.misses || 0);
638
+ sum.total += Math.max(0, (s?.total || 0) - mcpCalls);
639
+ sum.hits += Math.max(0, (s?.hits || 0) - (mcp.hits || 0));
640
+ sum.misses += Math.max(0, (s?.misses || 0) - (mcp.misses || 0));
641
+ sum.totalLatency += Math.max(0, (s?.totalLatency || 0) - mcpCalls * 15);
642
+ sum.totalTokensSaved += s?.totalTokensSaved || 0;
643
+ }
644
+ return sum;
645
+ }
646
+ /** Every semantic-cache collection the daemon writes: one per project, all "lemma-cache" prefixed. */
647
+ async function listCacheCollections() {
648
+ const cols = await chroma.listCollections();
649
+ return cols
650
+ .map((c) => (typeof c === 'string' ? c : c?.name))
651
+ .filter((n) => typeof n === 'string' && n.startsWith('lemma-cache'));
652
+ }
605
653
  async function recordStat(stats, project, fromCache, latencyMs, provider, tokensSaved) {
606
654
  if (!stats[project]) {
607
655
  stats[project] = { total: 0, hits: 0, misses: 0, totalLatency: 0, totalTokensSaved: 0, providers: {} };
@@ -613,7 +661,8 @@ async function recordStat(stats, project, fromCache, latencyMs, provider, tokens
613
661
  s.hits++;
614
662
  else
615
663
  s.misses++;
616
- const tkSaved = fromCache ? (tokensSaved || 2000) : (tokensSaved || 0);
664
+ // Unmeasured is 0, not a guess: a 2000 placeholder used to surface as "2000 tokens saved".
665
+ const tkSaved = tokensSaved || 0;
617
666
  s.totalTokensSaved += tkSaved;
618
667
  if (!s.providers[provider])
619
668
  s.providers[provider] = { hits: 0, misses: 0 };
@@ -621,9 +670,7 @@ async function recordStat(stats, project, fromCache, latencyMs, provider, tokens
621
670
  s.providers[provider].hits++;
622
671
  else
623
672
  s.providers[provider].misses++;
624
- // Only measured savings reach the ledger. `tokensSaved === undefined` means the
625
- // caller never counted, so tkSaved is the 2000 placeholder — usable for the
626
- // dashboard, not for a net-position claim.
673
+ // Only measured savings reach the ledger.
627
674
  if (fromCache && typeof tokensSaved === 'number' && tokensSaved > 0) {
628
675
  savingsLedger.recordTokens('cache', tokensSaved);
629
676
  }
@@ -650,6 +697,9 @@ function cacheGet(provider, prompt) {
650
697
  CACHE.delete(k);
651
698
  return null;
652
699
  }
700
+ // The only caller is the exact-hit path, so a returned entry is a served hit.
701
+ e.hits++;
702
+ e.lastHitAt = Date.now();
653
703
  return e;
654
704
  }
655
705
  function cacheSet(provider, prompt, data) {
@@ -661,7 +711,7 @@ function cacheSet(provider, prompt, data) {
661
711
  if (oldestKey)
662
712
  CACHE.delete(oldestKey);
663
713
  }
664
- CACHE.set(k, { input: prompt, data, createdAt: Date.now(), ttl: TTL_MS, provider });
714
+ CACHE.set(k, { input: prompt, data, createdAt: Date.now(), ttl: TTL_MS, provider, hits: 0 });
665
715
  }
666
716
  // ── Semantic Search (Pro) ──────────────────────────────────────────────────────
667
717
  async function getEmbedding(text) {
@@ -1087,10 +1137,10 @@ class LemmaServer {
1087
1137
  if (fs.existsSync(this.dashboardPath)) {
1088
1138
  this.app.use('/dashboard/', express.static(this.dashboardPath));
1089
1139
  this.app.get('/api/metrics', async (req, res) => {
1090
- const stats = this.stats[this.projectName] || { total: 0, hits: 0, misses: 0, totalLatency: 0, totalTokensSaved: 0 };
1140
+ const stats = aggregateStats(this.stats);
1091
1141
  const hitRate = stats.total > 0 ? stats.hits / stats.total : 0;
1092
1142
  const avgLat = stats.total > 0 ? stats.totalLatency / stats.total : 0;
1093
- const secretsMasked = EVENT_LOG.filter(e => e.type === 'privacy:mask').length;
1143
+ const secretsMasked = SECRETS_MASKED.count;
1094
1144
  const ledgerSnap = savingsLedger.getSnapshot();
1095
1145
  let activeAgents = 1; // Always include the Gateway itself
1096
1146
  let totalAgents = 1;
@@ -1121,8 +1171,7 @@ class LemmaServer {
1121
1171
  });
1122
1172
  this.app.get('/api/agents', async (req, res) => {
1123
1173
  // Load statistics for the Gateway itself
1124
- const stats = await loadStats();
1125
- const statsProj = stats[this.projectName] || { total: 0, hits: 0 };
1174
+ const statsProj = aggregateStats(await loadStats());
1126
1175
  const total = statsProj.total || 0;
1127
1176
  const hits = statsProj.hits || 0;
1128
1177
  const gatewayAgent = {
@@ -1159,7 +1208,8 @@ class LemmaServer {
1159
1208
  type: e.type, // e.g., 'cache:hit' or 'cache:miss' which client normalizes to 'cache_hit' or 'cache_miss'
1160
1209
  latency: e.latency,
1161
1210
  tokensSaved: e.tokens || e.tokensImpact || 0,
1162
- costSaved: (e.tokens || e.tokensImpact || 0) ? ((e.tokens || e.tokensImpact || 0) / 1000) * 0.002 : 0,
1211
+ // Same per-token price as the savings ledger, so a row and the totals agree.
1212
+ costSaved: (e.tokens || e.tokensImpact || 0) * AVG_COST_PER_TOKEN,
1163
1213
  metadata: {
1164
1214
  provider: e.provider,
1165
1215
  project: e.project
@@ -1171,6 +1221,12 @@ class LemmaServer {
1171
1221
  eventData.args = e.args;
1172
1222
  eventData.result = e.result;
1173
1223
  eventData.tokensImpact = e.tokensImpact;
1224
+ eventData.error = e.error;
1225
+ eventData.receipt = e.receipt;
1226
+ eventData.projectPath = e.projectPath;
1227
+ // tokensImpact is the size of the tool result — a context cost, not a saving.
1228
+ eventData.tokensSaved = 0;
1229
+ eventData.costSaved = 0;
1174
1230
  }
1175
1231
  return eventData;
1176
1232
  });
@@ -1204,28 +1260,34 @@ class LemmaServer {
1204
1260
  this.app.get('/api/cache-stats', async (req, res) => {
1205
1261
  let topQueries = [];
1206
1262
  let totalCached = CACHE.size;
1207
- // Try to query ChromaDB for semantic cache count
1263
+ // Semantic entries across every project's collection (completions are cached per project)
1208
1264
  try {
1209
- const collection = await chroma.getOrCreateCollection({
1210
- name: getCollectionName(this.projectName),
1211
- embeddingFunction: dummyEmbeddingFunction,
1212
- metadata: { "hnsw:space": "cosine" }
1213
- });
1214
- const count = await collection.count();
1215
- totalCached += count;
1265
+ for (const name of await listCacheCollections()) {
1266
+ const collection = await chroma.getOrCreateCollection({
1267
+ name,
1268
+ embeddingFunction: dummyEmbeddingFunction,
1269
+ metadata: { "hnsw:space": "cosine" }
1270
+ });
1271
+ totalCached += await collection.count();
1272
+ }
1216
1273
  }
1217
1274
  catch { }
1218
- // Build top queries from in-memory CACHE
1275
+ // Most reused answers: per-entry hit counts and the measured size of the cached response.
1276
+ // These used to be a flat 2000 tokens at $2/M with the provider's hit count stamped on
1277
+ // every entry, and "last hit" was really the creation time.
1219
1278
  for (const [key, entry] of CACHE.entries()) {
1220
- const hitCount = EVENT_LOG.filter(e => e.type === 'cache:hit' && e.provider === entry.provider).length || 1;
1221
- const tokens = 2000;
1279
+ if (entry.hits === 0)
1280
+ continue;
1281
+ const tokensPerHit = measureCachedTokens(entry.data) || 0;
1282
+ const totalTokens = tokensPerHit * entry.hits;
1222
1283
  topQueries.push({
1223
1284
  queryHash: key.substring(0, 16),
1224
1285
  query: entry.input,
1225
- hitCount,
1226
- totalTokens: tokens * hitCount,
1227
- costSaved: (tokens * hitCount / 1000) * 0.002,
1228
- lastHit: new Date(entry.createdAt).toISOString()
1286
+ hitCount: entry.hits,
1287
+ totalTokens,
1288
+ costSaved: totalTokens * AVG_COST_PER_TOKEN,
1289
+ lastHit: new Date(entry.lastHitAt || entry.createdAt).toISOString(),
1290
+ responsePreview: extractResponseText(entry.data).slice(0, 600),
1229
1291
  });
1230
1292
  }
1231
1293
  // Try to query the stack orchestration API if running to append stack cache stats
@@ -1242,8 +1304,11 @@ class LemmaServer {
1242
1304
  catch { }
1243
1305
  // Sort by hitCount desc
1244
1306
  topQueries.sort((a, b) => b.hitCount - a.hitCount);
1245
- // Latency percentiles from EVENT_LOG
1246
- const latencies = EVENT_LOG.filter(e => e.latency).map(e => e.latency).sort((a, b) => a - b);
1307
+ // Latency percentiles of proxy completions only — MCP tool calls share EVENT_LOG.
1308
+ const latencies = EVENT_LOG
1309
+ .filter(e => (e.type === 'cache:hit' || e.type === 'cache:miss') && e.provider !== 'mcp' && typeof e.latency === 'number')
1310
+ .map(e => e.latency)
1311
+ .sort((a, b) => a - b);
1247
1312
  const latencyPercentiles = {
1248
1313
  p50: latencies[Math.floor(latencies.length * 0.5)] || 0,
1249
1314
  p95: latencies[Math.floor(latencies.length * 0.95)] || 0,
@@ -1309,32 +1374,33 @@ class LemmaServer {
1309
1374
  }
1310
1375
  });
1311
1376
  this.app.post('/api/cache/clear', async (req, res) => {
1312
- const { provider } = req.body || {};
1377
+ // resetHistory must be asked for explicitly: the ledger and stats are persisted lifetime
1378
+ // accounting, and a "clear cache" click used to wipe them to disk as a side effect.
1379
+ const { provider, resetHistory } = req.body || {};
1313
1380
  let clearedCount = 0;
1314
1381
  if (!provider) {
1315
1382
  // Global clear
1316
1383
  clearedCount = CACHE.size;
1317
1384
  CACHE.clear();
1318
1385
  EVENT_LOG.length = 0; // Clear live event log
1319
- savingsLedger.reset(); // Reset savings counters
1320
- // Reset and persist statistics for the current project
1321
- if (this.stats[this.projectName]) {
1322
- this.stats[this.projectName] = {
1323
- total: 0,
1324
- hits: 0,
1325
- misses: 0,
1326
- totalLatency: 0,
1327
- totalTokensSaved: 0,
1328
- providers: {}
1329
- };
1386
+ if (resetHistory === true) {
1387
+ savingsLedger.reset();
1388
+ }
1389
+ // Stats are per project but reported machine-wide, so a history reset covers them all.
1390
+ if (resetHistory === true) {
1391
+ for (const key of Object.keys(this.stats))
1392
+ delete this.stats[key];
1330
1393
  writeJson(STATS_FILE, this.stats);
1331
1394
  }
1332
- // Clear Chroma DB collection
1395
+ // Completions are cached per project, so clearing the cache clears every project's
1396
+ // collection — not only the one this daemon happened to be started for.
1333
1397
  try {
1334
- await chroma.deleteCollection({ name: getCollectionName(this.projectName) }).catch(() => { });
1398
+ for (const name of await listCacheCollections()) {
1399
+ await chroma.deleteCollection({ name }).catch(() => { });
1400
+ }
1335
1401
  }
1336
1402
  catch (e) {
1337
- console.error(`[CacheClear] Failed to clear Chroma collection: ${e.message}`);
1403
+ console.error(`[CacheClear] Failed to clear Chroma collections: ${e.message}`);
1338
1404
  }
1339
1405
  }
1340
1406
  else {
@@ -1345,14 +1411,16 @@ class LemmaServer {
1345
1411
  clearedCount++;
1346
1412
  }
1347
1413
  }
1348
- // Selective delete from Chroma DB collection using provider metadata filter
1414
+ // Selective delete, by provider metadata, across every project's collection
1349
1415
  try {
1350
- const collection = await chroma.getOrCreateCollection({
1351
- name: getCollectionName(this.projectName),
1352
- embeddingFunction: dummyEmbeddingFunction,
1353
- metadata: { "hnsw:space": "cosine" }
1354
- });
1355
- await collection.delete({ where: { provider } });
1416
+ for (const name of await listCacheCollections()) {
1417
+ const collection = await chroma.getOrCreateCollection({
1418
+ name,
1419
+ embeddingFunction: dummyEmbeddingFunction,
1420
+ metadata: { "hnsw:space": "cosine" }
1421
+ });
1422
+ await collection.delete({ where: { provider } });
1423
+ }
1356
1424
  }
1357
1425
  catch (e) {
1358
1426
  console.error(`[CacheClear] Failed to selectively delete provider ${provider} from Chroma: ${e.message}`);
@@ -1377,8 +1445,40 @@ class LemmaServer {
1377
1445
  res.status(500).json({ error: e.message });
1378
1446
  }
1379
1447
  });
1448
+ // Dashboard search over Brain memories. /api/search queries the completions cache in Chroma
1449
+ // and needs Ollama for embeddings; this needs neither. countStats:false — browsing the
1450
+ // corpus from the dashboard must not count as a recall or credit reuse to any memory.
1451
+ this.app.get('/api/brain/search', async (req, res) => {
1452
+ const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
1453
+ if (!query)
1454
+ return res.status(400).json({ error: 'Query required' });
1455
+ try {
1456
+ // Same floor as search_memory, so the dashboard shows what the model would actually get.
1457
+ const results = await getBrain().searchHybrid(query, 10, DEFAULT_MEMORY_SIMILARITY_FLOOR, { countStats: false });
1458
+ res.json({
1459
+ results: results.map((r) => ({
1460
+ id: r.id,
1461
+ prompt: r.query,
1462
+ response: r.response,
1463
+ timestamp: r.timestamp,
1464
+ similarity: r.similarity,
1465
+ fresh: r.fresh,
1466
+ untracked: !!r.untracked,
1467
+ })),
1468
+ });
1469
+ }
1470
+ catch (e) {
1471
+ res.status(500).json({ error: e.message });
1472
+ }
1473
+ });
1380
1474
  // Serve codebase timeline snapshots (AST Time-Travel Telemetry)
1475
+ // Snapshots come from watching this daemon's own cwd. Like onboarding, decline a caller from
1476
+ // another project instead of serving this project's history as theirs.
1381
1477
  this.app.get('/api/project/timeline', (req, res) => {
1478
+ const expectedProject = typeof req.query.expectedProject === 'string' ? req.query.expectedProject : null;
1479
+ if (isProjectMismatch(expectedProject, this.projectName)) {
1480
+ return res.json({ success: false, mismatch: true, servingProject: this.projectName, timeline: [] });
1481
+ }
1382
1482
  res.json({ success: true, timeline: this.timeline });
1383
1483
  });
1384
1484
  // Dynamic Zero-Shot Onboarding mental model builder
@@ -1557,36 +1657,15 @@ class LemmaServer {
1557
1657
  tk = Math.floor(Math.max(0, charsBefore - charsAfter) / 4);
1558
1658
  savingsLedger.record(source, charsBefore, charsAfter);
1559
1659
  }
1560
- // Update active project stats to immediately reflect on Dashboard total requests & charts
1660
+ // A saving reported by an MCP tool is not a proxy completion: it used to bump the
1661
+ // completion stats (total++, a "miss" for every non-cache source, a mock 15ms latency),
1662
+ // which skewed the proxy's hit rate, request count and average latency. Ledger only;
1663
+ // the event just shows the saving in the activity feed, with nothing invented.
1561
1664
  if (tk > 0) {
1562
- if (!this.stats[this.projectName]) {
1563
- this.stats[this.projectName] = { total: 0, hits: 0, misses: 0, totalLatency: 0, totalTokensSaved: 0, providers: {} };
1564
- }
1565
- const s = this.stats[this.projectName];
1566
- s.total++;
1567
- s.totalTokensSaved += tk;
1568
- s.totalLatency += 15; // mock latency for local MCP metrics
1569
- if (source === 'cache') {
1570
- s.hits++;
1571
- }
1572
- else {
1573
- s.misses++;
1574
- }
1575
- if (!s.providers['mcp'])
1576
- s.providers['mcp'] = { hits: 0, misses: 0 };
1577
- if (source === 'cache')
1578
- s.providers['mcp'].hits++;
1579
- else
1580
- s.providers['mcp'].misses++;
1581
- // Write updated stats back to disk immediately
1582
- writeJson(STATS_FILE, this.stats);
1583
- // Push to live telemetry logEvent array so the Dashboard event timeline updates instantly!
1584
1665
  logEvent({
1585
- type: source === 'cache' ? 'cache:hit' : source,
1586
- project: this.projectName,
1587
- latency: 15,
1666
+ type: source === 'cache' ? 'brain_hit' : source,
1588
1667
  provider: 'mcp',
1589
- tokens: tk
1668
+ tokens: tk,
1590
1669
  });
1591
1670
  }
1592
1671
  res.json({ ok: true, snapshot: savingsLedger.getSnapshot().total });
@@ -1636,7 +1715,9 @@ class LemmaServer {
1636
1715
  result: event.result || 'UNKNOWN',
1637
1716
  latency: event.latency || 0,
1638
1717
  tokensImpact: event.tokensImpact || 0,
1639
- error: event.error || null,
1718
+ error: typeof event.error === 'string' ? event.error.slice(0, 200) : null,
1719
+ receipt: typeof event.receipt === 'string' ? event.receipt : undefined,
1720
+ projectPath: typeof event.projectPath === 'string' ? event.projectPath : undefined,
1640
1721
  timestamp: event.timestamp || new Date().toISOString(),
1641
1722
  };
1642
1723
  // Add to EVENT_LOG
@@ -1841,6 +1922,12 @@ class LemmaServer {
1841
1922
  const tier = pro ? 'pro' : 'standard';
1842
1923
  const isStream = !!req.body.stream;
1843
1924
  let historyTokensSaved = 0;
1925
+ // The daemon is one process for every project on the machine; its own projectName is just
1926
+ // whichever cwd it was started from. Attribute each completion — stats, and above all the
1927
+ // semantic-cache collection — to the project the request comes from (MCP handshake
1928
+ // registry, then the messages themselves), so one project's cached answer is never served
1929
+ // to another. Falls back to the daemon's project when nothing identifies the caller.
1930
+ const requestProject = this.detectActiveProject(req)?.name ?? this.projectName;
1844
1931
  if (isDebuggerPaused()) {
1845
1932
  res.setHeader('Retry-After', '5');
1846
1933
  return res.status(503).json({ error: { message: 'Lemma is paused from the dashboard debugger. Resume it to continue.' } });
@@ -1856,15 +1943,17 @@ class LemmaServer {
1856
1943
  return res.status(400).json({ error: 'Invalid request format' });
1857
1944
  const { maskedPrompt: prompt, tokenMap } = semanticScrubber.mask(rawPrompt);
1858
1945
  if (Object.keys(tokenMap).length > 0) {
1859
- logEvent({ type: 'privacy:mask', count: Object.keys(tokenMap).length, project: this.projectName });
1946
+ logEvent({ type: 'privacy:mask', count: Object.keys(tokenMap).length, project: requestProject });
1947
+ SECRETS_MASKED.count += Object.keys(tokenMap).length;
1860
1948
  }
1861
1949
  const originalModel = req.body.model || 'gpt-4o';
1862
1950
  // ── 1. Cache-First Hot Path ──
1863
1951
  // Perform exact cache check immediately (resolves in <1ms)
1864
1952
  const hit = cacheGet(provider, prompt);
1865
1953
  if (hit) {
1866
- const cacheTokensSaved = 2000;
1867
- await recordStat(this.stats, this.projectName, true, Date.now() - t0, provider, cacheTokensSaved);
1954
+ // Measured from the cached response, like the semantic path — a flat 2000 used to go
1955
+ // straight into the ledger as if it had been counted.
1956
+ await recordStat(this.stats, requestProject, true, Date.now() - t0, provider, measureCachedTokens(hit.data));
1868
1957
  const unmaskedData = semanticScrubber.unmask(hit.data, tokenMap);
1869
1958
  if (isStream)
1870
1959
  return SseRelay.simulateHit(res, unmaskedData, { provider: provider, tier, similarity: 1.0 });
@@ -1875,10 +1964,10 @@ class LemmaServer {
1875
1964
  }
1876
1965
  // Perform semantic cache check (resolves in ~15ms)
1877
1966
  if (pro) {
1878
- const semHit = await semanticGet(provider, prompt, this.projectName);
1967
+ const semHit = await semanticGet(provider, prompt, requestProject);
1879
1968
  if (semHit) {
1880
1969
  if (semHit.similarity >= 0.90) {
1881
- await recordStat(this.stats, this.projectName, true, Date.now() - t0, provider, measureCachedTokens(semHit.data));
1970
+ await recordStat(this.stats, requestProject, true, Date.now() - t0, provider, measureCachedTokens(semHit.data));
1882
1971
  const unmaskedData = semanticScrubber.unmask(semHit.data, tokenMap);
1883
1972
  const cacheHeader = semHit.hiveMind ? 'HIT-HIVE-MIND' : 'HIT';
1884
1973
  if (isStream)
@@ -1929,7 +2018,7 @@ Adjusted Answer:`;
1929
2018
  else if (provider === 'anthropic') {
1930
2019
  synthesizedData.content[0].text = adjustedText;
1931
2020
  }
1932
- await recordStat(this.stats, this.projectName, true, Date.now() - t0, provider, measureCachedTokens(synthesizedData));
2021
+ await recordStat(this.stats, requestProject, true, Date.now() - t0, provider, measureCachedTokens(synthesizedData));
1933
2022
  const unmaskedData = semanticScrubber.unmask(synthesizedData, tokenMap);
1934
2023
  if (isStream) {
1935
2024
  return SseRelay.simulateHit(res, unmaskedData, { provider: provider, tier, similarity: semHit.similarity });
@@ -1949,7 +2038,7 @@ Adjusted Answer:`;
1949
2038
  const cloudHit = await cloudSync.get(prompt);
1950
2039
  if (cloudHit) {
1951
2040
  cacheSet(provider, prompt, cloudHit.data);
1952
- await recordStat(this.stats, this.projectName, true, Date.now() - t0, provider, measureCachedTokens(cloudHit.data));
2041
+ await recordStat(this.stats, requestProject, true, Date.now() - t0, provider, measureCachedTokens(cloudHit.data));
1953
2042
  const unmaskedData = semanticScrubber.unmask(cloudHit.data, tokenMap);
1954
2043
  if (isStream)
1955
2044
  return SseRelay.simulateHit(res, unmaskedData, { provider: provider, tier, similarity: cloudHit.similarity || 0.95 });
@@ -2045,7 +2134,7 @@ Adjusted Answer:`;
2045
2134
  }
2046
2135
  }
2047
2136
  if (isStream) {
2048
- await recordStat(this.stats, this.projectName, false, Date.now() - t0, provider, historyTokensSaved);
2137
+ await recordStat(this.stats, requestProject, false, Date.now() - t0, provider, historyTokensSaved);
2049
2138
  if (!pro) {
2050
2139
  await usageWriteQueue.enqueue(async () => {
2051
2140
  const u = await loadUsage();
@@ -2059,7 +2148,7 @@ Adjusted Answer:`;
2059
2148
  const data = await this.callUpstream(req.body, provider);
2060
2149
  cacheSet(provider, prompt, data);
2061
2150
  if (pro) {
2062
- await semanticSet(provider, prompt, data, this.projectName);
2151
+ await semanticSet(provider, prompt, data, requestProject);
2063
2152
  await cloudSync.set(prompt, data);
2064
2153
  }
2065
2154
  if (!pro) {
@@ -2069,7 +2158,7 @@ Adjusted Answer:`;
2069
2158
  await writeJson(USAGE_FILE, u);
2070
2159
  });
2071
2160
  }
2072
- await recordStat(this.stats, this.projectName, false, Date.now() - t0, provider, historyTokensSaved);
2161
+ await recordStat(this.stats, requestProject, false, Date.now() - t0, provider, historyTokensSaved);
2073
2162
  return res.json(semanticScrubber.unmask(data, tokenMap));
2074
2163
  }
2075
2164
  catch (err) {
@@ -2221,23 +2310,31 @@ Adjusted Answer:`;
2221
2310
  console.log('simply copy-paste this command into your MCP settings:');
2222
2311
  console.log(`\n \x1b[1m\x1b[33mlemma mcp\x1b[0m\n`);
2223
2312
  console.log('────────────────────────────────────────────────\n');
2224
- // Auto-start the Local Clipboard Optimizer Watcher in the background
2225
- const clipboardDisabled = process.env.LEMMA_DISABLE_CLIPBOARD === 'true';
2226
- if (clipboardDisabled) {
2313
+ // The clipboard watcher runs as its own background process (startBackgroundClipboardWatcher,
2314
+ // tracked in CLIPBOARD_PID_FILE and stopped by `lemma stop`). This used to also start a
2315
+ // second, in-process watcher here: two pollers on one clipboard squeezed and reported the
2316
+ // same copy twice.
2317
+ if (process.env.LEMMA_DISABLE_CLIPBOARD === 'true') {
2227
2318
  console.log(`📋 Clipboard Watcher : \x1b[90m[DISABLED via LEMMA_DISABLE_CLIPBOARD]\x1b[0m`);
2228
2319
  }
2229
- else {
2230
- console.log(`📋 Clipboard Watcher : \x1b[33m[ACTIVE]\x1b[0m — polling clipboard every 1s (disable: LEMMA_DISABLE_CLIPBOARD=true)`);
2231
- try {
2232
- const { startClipboardWatcher } = require('../utils/ClipboardWatcher');
2233
- startClipboardWatcher(1000);
2234
- }
2235
- catch (err) {
2236
- console.warn(`⚠️ Could not auto-initialize Clipboard Watcher: ${err.message}`);
2237
- }
2238
- }
2239
2320
  });
2240
- process.on('SIGTERM', () => server.close());
2321
+ // A SIGTERM listener replaces Node's default exit, and server.close() only stops accepting:
2322
+ // keep-alive and socket.io connections, file watchers and intervals keep the loop alive, so
2323
+ // `lemma stop` used to leave a daemon with no port that never exited. Flush the Brain's
2324
+ // debounced write (same reason as the MCP bootstrap), close, and exit — with a hard cap.
2325
+ const shutdown = () => {
2326
+ try {
2327
+ getBrain().flushSync();
2328
+ }
2329
+ catch {
2330
+ // A flush failure must never block shutdown — the next save() heals.
2331
+ }
2332
+ server.close(() => process.exit(0));
2333
+ server.closeAllConnections();
2334
+ setTimeout(() => process.exit(0), 3000).unref();
2335
+ };
2336
+ process.once('SIGTERM', shutdown);
2337
+ process.once('SIGINT', shutdown);
2241
2338
  }
2242
2339
  }
2243
2340
  // ── CLI ────────────────────────────────────────────────────────────────────────
@@ -2932,6 +3029,20 @@ program.command('clipboard')
2932
3029
  .option('--interval <ms>', 'Polling interval in milliseconds', '1000')
2933
3030
  .action((opts) => {
2934
3031
  try {
3032
+ // One watcher per machine: every poller on the same clipboard squeezes and reports the
3033
+ // same copy again. Defer to a live watcher recorded in the pid file; otherwise record us.
3034
+ try {
3035
+ const recorded = parseInt(fs.readFileSync(CLIPBOARD_PID_FILE, 'utf8'), 10);
3036
+ if (recorded && recorded !== process.pid) {
3037
+ process.kill(recorded, 0); // throws if it's gone
3038
+ console.log(`✅ [Clipboard] Watcher already running (PID: ${recorded}) — not starting another.`);
3039
+ process.exit(0);
3040
+ }
3041
+ }
3042
+ catch {
3043
+ // No pid file, or its process is dead: this one is the watcher.
3044
+ }
3045
+ fs.writeFileSync(CLIPBOARD_PID_FILE, String(process.pid));
2935
3046
  const interval = parseInt(opts.interval) || 1000;
2936
3047
  const { startClipboardWatcher } = require('../utils/ClipboardWatcher');
2937
3048
  startClipboardWatcher(interval);