@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.
- package/README.md +84 -426
- package/dashboard/README.md +48 -19
- package/dashboard/dist/assets/index-BoeQau6n.css +1 -0
- package/dashboard/dist/assets/index-Clf3qR64.js +451 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/cjs/api/dashboardRoutes.d.ts.map +1 -1
- package/dist/cjs/api/dashboardRoutes.js +7 -3
- package/dist/cjs/api/dashboardRoutes.js.map +1 -1
- package/dist/cjs/autopilot/Notifier.d.ts.map +1 -1
- package/dist/cjs/autopilot/Notifier.js +3 -0
- package/dist/cjs/autopilot/Notifier.js.map +1 -1
- package/dist/cjs/cli/lemma-proxy.d.ts.map +1 -1
- package/dist/cjs/cli/lemma-proxy.js +214 -103
- package/dist/cjs/cli/lemma-proxy.js.map +1 -1
- package/dist/cjs/mcp/index.js +9 -8
- package/dist/cjs/mcp/index.js.map +1 -1
- package/dist/cjs/mcp/resources.d.ts.map +1 -1
- package/dist/cjs/mcp/resources.js +23 -4
- package/dist/cjs/mcp/resources.js.map +1 -1
- package/dist/cjs/mcp/tools/memory.js +11 -11
- package/dist/cjs/mcp/tools/memory.js.map +1 -1
- package/dist/cjs/mcp/tools/misc.js +1 -1
- package/dist/cjs/mcp/tools/misc.js.map +1 -1
- package/dist/cjs/mcp/tools/review.js +1 -1
- package/dist/cjs/mcp/tools/review.js.map +1 -1
- package/dist/cjs/mcp/tools/workspace.d.ts.map +1 -1
- package/dist/cjs/mcp/tools/workspace.js +10 -7
- package/dist/cjs/mcp/tools/workspace.js.map +1 -1
- package/dist/cjs/mcp/tools.d.ts +16 -1
- package/dist/cjs/mcp/tools.d.ts.map +1 -1
- package/dist/cjs/mcp/tools.js +63 -33
- package/dist/cjs/mcp/tools.js.map +1 -1
- package/dist/cjs/utils/ProjectIdentity.d.ts +8 -0
- package/dist/cjs/utils/ProjectIdentity.d.ts.map +1 -1
- package/dist/cjs/utils/ProjectIdentity.js +15 -0
- package/dist/cjs/utils/ProjectIdentity.js.map +1 -1
- package/dist/cjs/utils/SavingsLedger.d.ts +2 -0
- package/dist/cjs/utils/SavingsLedger.d.ts.map +1 -1
- package/dist/cjs/utils/SavingsLedger.js +5 -5
- package/dist/cjs/utils/SavingsLedger.js.map +1 -1
- package/dist/cjs/utils/TokenReceipt.d.ts +2 -0
- package/dist/cjs/utils/TokenReceipt.d.ts.map +1 -1
- package/dist/cjs/utils/TokenReceipt.js +11 -1
- package/dist/cjs/utils/TokenReceipt.js.map +1 -1
- package/dist/cjs/utils/reportSavings.d.ts +7 -0
- package/dist/cjs/utils/reportSavings.d.ts.map +1 -1
- package/dist/cjs/utils/reportSavings.js +14 -2
- package/dist/cjs/utils/reportSavings.js.map +1 -1
- package/dist/esm/api/dashboardRoutes.d.ts.map +1 -1
- package/dist/esm/api/dashboardRoutes.js +7 -3
- package/dist/esm/api/dashboardRoutes.js.map +1 -1
- package/dist/esm/autopilot/Notifier.d.ts.map +1 -1
- package/dist/esm/autopilot/Notifier.js +3 -0
- package/dist/esm/autopilot/Notifier.js.map +1 -1
- package/dist/esm/cli/lemma-proxy.d.ts.map +1 -1
- package/dist/esm/cli/lemma-proxy.js +215 -104
- package/dist/esm/cli/lemma-proxy.js.map +1 -1
- package/dist/esm/mcp/index.js +9 -8
- package/dist/esm/mcp/index.js.map +1 -1
- package/dist/esm/mcp/resources.d.ts.map +1 -1
- package/dist/esm/mcp/resources.js +23 -4
- package/dist/esm/mcp/resources.js.map +1 -1
- package/dist/esm/mcp/tools/memory.js +11 -11
- package/dist/esm/mcp/tools/memory.js.map +1 -1
- package/dist/esm/mcp/tools/misc.js +1 -1
- package/dist/esm/mcp/tools/misc.js.map +1 -1
- package/dist/esm/mcp/tools/review.js +1 -1
- package/dist/esm/mcp/tools/review.js.map +1 -1
- package/dist/esm/mcp/tools/workspace.d.ts.map +1 -1
- package/dist/esm/mcp/tools/workspace.js +10 -7
- package/dist/esm/mcp/tools/workspace.js.map +1 -1
- package/dist/esm/mcp/tools.d.ts +16 -1
- package/dist/esm/mcp/tools.d.ts.map +1 -1
- package/dist/esm/mcp/tools.js +62 -34
- package/dist/esm/mcp/tools.js.map +1 -1
- package/dist/esm/utils/ProjectIdentity.d.ts +8 -0
- package/dist/esm/utils/ProjectIdentity.d.ts.map +1 -1
- package/dist/esm/utils/ProjectIdentity.js +14 -0
- package/dist/esm/utils/ProjectIdentity.js.map +1 -1
- package/dist/esm/utils/SavingsLedger.d.ts +2 -0
- package/dist/esm/utils/SavingsLedger.d.ts.map +1 -1
- package/dist/esm/utils/SavingsLedger.js +1 -1
- package/dist/esm/utils/SavingsLedger.js.map +1 -1
- package/dist/esm/utils/TokenReceipt.d.ts +2 -0
- package/dist/esm/utils/TokenReceipt.d.ts.map +1 -1
- package/dist/esm/utils/TokenReceipt.js +10 -1
- package/dist/esm/utils/TokenReceipt.js.map +1 -1
- package/dist/esm/utils/reportSavings.d.ts +7 -0
- package/dist/esm/utils/reportSavings.d.ts.map +1 -1
- package/dist/esm/utils/reportSavings.js +13 -2
- package/dist/esm/utils/reportSavings.js.map +1 -1
- package/package.json +10 -3
- package/dashboard/dist/assets/index-Yt1H2B7F.css +0 -1
- 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;
|
|
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":";
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1263
|
+
// Semantic entries across every project's collection (completions are cached per project)
|
|
1208
1264
|
try {
|
|
1209
|
-
const
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1221
|
-
|
|
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
|
|
1227
|
-
costSaved:
|
|
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
|
|
1246
|
-
const latencies = EVENT_LOG
|
|
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
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
1414
|
+
// Selective delete, by provider metadata, across every project's collection
|
|
1349
1415
|
try {
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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
|
-
//
|
|
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' ? '
|
|
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
|
|
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:
|
|
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
|
-
|
|
1867
|
-
|
|
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,
|
|
1967
|
+
const semHit = await semanticGet(provider, prompt, requestProject);
|
|
1879
1968
|
if (semHit) {
|
|
1880
1969
|
if (semHit.similarity >= 0.90) {
|
|
1881
|
-
await recordStat(this.stats,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
2225
|
-
|
|
2226
|
-
|
|
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
|
-
|
|
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);
|