@nxuss/lemma 1.36.0 → 1.38.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 +88 -426
- package/bin/brain-ingest.js +22 -0
- package/bin/doctor.js +31 -4
- package/bin/init.js +92 -51
- package/dashboard/README.md +48 -19
- package/dashboard/dist/assets/index-CLXWgzTm.css +1 -0
- package/dashboard/dist/assets/index-CoMIMxyC.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/hostHooks.d.ts +13 -1
- package/dist/cjs/cli/hostHooks.d.ts.map +1 -1
- package/dist/cjs/cli/hostHooks.js +34 -4
- package/dist/cjs/cli/hostHooks.js.map +1 -1
- package/dist/cjs/cli/lemma-proxy.d.ts.map +1 -1
- package/dist/cjs/cli/lemma-proxy.js +235 -107
- package/dist/cjs/cli/lemma-proxy.js.map +1 -1
- package/dist/cjs/mcp/index.js +42 -18
- package/dist/cjs/mcp/index.js.map +1 -1
- package/dist/cjs/mcp/prompts.d.ts +1 -1
- package/dist/cjs/mcp/prompts.d.ts.map +1 -1
- package/dist/cjs/mcp/prompts.js +2 -0
- package/dist/cjs/mcp/prompts.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/mcp/workspaceShared.d.ts.map +1 -1
- package/dist/cjs/mcp/workspaceShared.js +6 -9
- package/dist/cjs/mcp/workspaceShared.js.map +1 -1
- package/dist/cjs/proxy/ProjectRegistry.d.ts +24 -0
- package/dist/cjs/proxy/ProjectRegistry.d.ts.map +1 -1
- package/dist/cjs/proxy/ProjectRegistry.js +50 -3
- package/dist/cjs/proxy/ProjectRegistry.js.map +1 -1
- package/dist/cjs/subconscious/HostRecall.d.ts +10 -1
- package/dist/cjs/subconscious/HostRecall.d.ts.map +1 -1
- package/dist/cjs/subconscious/HostRecall.js +68 -6
- package/dist/cjs/subconscious/HostRecall.js.map +1 -1
- package/dist/cjs/subconscious/recallDigest.d.ts +34 -0
- package/dist/cjs/subconscious/recallDigest.d.ts.map +1 -0
- package/dist/cjs/subconscious/recallDigest.js +82 -0
- package/dist/cjs/subconscious/recallDigest.js.map +1 -0
- 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/hostHooks.d.ts +13 -1
- package/dist/esm/cli/hostHooks.d.ts.map +1 -1
- package/dist/esm/cli/hostHooks.js +32 -3
- package/dist/esm/cli/hostHooks.js.map +1 -1
- package/dist/esm/cli/lemma-proxy.d.ts.map +1 -1
- package/dist/esm/cli/lemma-proxy.js +236 -108
- package/dist/esm/cli/lemma-proxy.js.map +1 -1
- package/dist/esm/mcp/index.js +42 -18
- package/dist/esm/mcp/index.js.map +1 -1
- package/dist/esm/mcp/prompts.d.ts +1 -1
- package/dist/esm/mcp/prompts.d.ts.map +1 -1
- package/dist/esm/mcp/prompts.js +2 -0
- package/dist/esm/mcp/prompts.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/mcp/workspaceShared.d.ts.map +1 -1
- package/dist/esm/mcp/workspaceShared.js +6 -9
- package/dist/esm/mcp/workspaceShared.js.map +1 -1
- package/dist/esm/proxy/ProjectRegistry.d.ts +24 -0
- package/dist/esm/proxy/ProjectRegistry.d.ts.map +1 -1
- package/dist/esm/proxy/ProjectRegistry.js +49 -2
- package/dist/esm/proxy/ProjectRegistry.js.map +1 -1
- package/dist/esm/subconscious/HostRecall.d.ts +10 -1
- package/dist/esm/subconscious/HostRecall.d.ts.map +1 -1
- package/dist/esm/subconscious/HostRecall.js +66 -5
- package/dist/esm/subconscious/HostRecall.js.map +1 -1
- package/dist/esm/subconscious/recallDigest.d.ts +34 -0
- package/dist/esm/subconscious/recallDigest.d.ts.map +1 -0
- package/dist/esm/subconscious/recallDigest.js +76 -0
- package/dist/esm/subconscious/recallDigest.js.map +1 -0
- 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-D6nem4OA.js +0 -461
- package/dashboard/dist/assets/index-DNz43ews.css +0 -1
|
@@ -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) {
|
|
@@ -1047,18 +1097,33 @@ class LemmaServer {
|
|
|
1047
1097
|
console.log('▶️ [Debugger] Resumed');
|
|
1048
1098
|
res.json({ resumed: true, paused: false, timestamp: new Date().toISOString() });
|
|
1049
1099
|
});
|
|
1050
|
-
// Project registration from MCP handshake
|
|
1100
|
+
// Project registration from MCP handshake; type "mcp_heartbeat" is the 30s keep-alive
|
|
1051
1101
|
this.app.post('/api/handshake', (req, res) => {
|
|
1052
|
-
const { projectPath, projectName,
|
|
1102
|
+
const { projectPath, projectName, type } = req.body;
|
|
1103
|
+
// MCP servers send techStack as an array; older callers sent stack as a string.
|
|
1104
|
+
const rawStack = req.body.techStack ?? req.body.stack;
|
|
1105
|
+
const stack = Array.isArray(rawStack) ? rawStack.join(',') : typeof rawStack === 'string' ? rawStack : '';
|
|
1053
1106
|
if (projectPath && projectName) {
|
|
1054
|
-
|
|
1055
|
-
|
|
1107
|
+
if (type === 'mcp_heartbeat') {
|
|
1108
|
+
const known = !!this.registry.get(projectPath);
|
|
1109
|
+
this.registry.heartbeat(projectPath, projectName, stack);
|
|
1110
|
+
if (!known)
|
|
1111
|
+
console.log(`📋 [Handshake] Project re-registered by heartbeat: "${projectName}" (${projectPath})`);
|
|
1112
|
+
}
|
|
1113
|
+
else {
|
|
1114
|
+
this.registry.register(projectPath, projectName, stack);
|
|
1115
|
+
console.log(`📋 [Handshake] Project registered: "${projectName}" (${projectPath})`);
|
|
1116
|
+
}
|
|
1056
1117
|
res.json({ ok: true });
|
|
1057
1118
|
}
|
|
1058
1119
|
else {
|
|
1059
1120
|
res.status(400).json({ error: 'projectPath and projectName required' });
|
|
1060
1121
|
}
|
|
1061
1122
|
});
|
|
1123
|
+
// Every project with an MCP server connected to this daemon, idle ones included
|
|
1124
|
+
this.app.get('/api/projects', (req, res) => {
|
|
1125
|
+
res.json({ projects: this.registry.snapshot() });
|
|
1126
|
+
});
|
|
1062
1127
|
this.app.get('/v1/models', (req, res) => {
|
|
1063
1128
|
res.json({ object: 'list', data: [
|
|
1064
1129
|
{ id: 'gpt-4o', object: 'model', owned_by: 'lemma-proxy' },
|
|
@@ -1087,10 +1152,10 @@ class LemmaServer {
|
|
|
1087
1152
|
if (fs.existsSync(this.dashboardPath)) {
|
|
1088
1153
|
this.app.use('/dashboard/', express.static(this.dashboardPath));
|
|
1089
1154
|
this.app.get('/api/metrics', async (req, res) => {
|
|
1090
|
-
const stats = this.stats
|
|
1155
|
+
const stats = aggregateStats(this.stats);
|
|
1091
1156
|
const hitRate = stats.total > 0 ? stats.hits / stats.total : 0;
|
|
1092
1157
|
const avgLat = stats.total > 0 ? stats.totalLatency / stats.total : 0;
|
|
1093
|
-
const secretsMasked =
|
|
1158
|
+
const secretsMasked = SECRETS_MASKED.count;
|
|
1094
1159
|
const ledgerSnap = savingsLedger.getSnapshot();
|
|
1095
1160
|
let activeAgents = 1; // Always include the Gateway itself
|
|
1096
1161
|
let totalAgents = 1;
|
|
@@ -1121,8 +1186,7 @@ class LemmaServer {
|
|
|
1121
1186
|
});
|
|
1122
1187
|
this.app.get('/api/agents', async (req, res) => {
|
|
1123
1188
|
// Load statistics for the Gateway itself
|
|
1124
|
-
const
|
|
1125
|
-
const statsProj = stats[this.projectName] || { total: 0, hits: 0 };
|
|
1189
|
+
const statsProj = aggregateStats(await loadStats());
|
|
1126
1190
|
const total = statsProj.total || 0;
|
|
1127
1191
|
const hits = statsProj.hits || 0;
|
|
1128
1192
|
const gatewayAgent = {
|
|
@@ -1159,7 +1223,8 @@ class LemmaServer {
|
|
|
1159
1223
|
type: e.type, // e.g., 'cache:hit' or 'cache:miss' which client normalizes to 'cache_hit' or 'cache_miss'
|
|
1160
1224
|
latency: e.latency,
|
|
1161
1225
|
tokensSaved: e.tokens || e.tokensImpact || 0,
|
|
1162
|
-
|
|
1226
|
+
// Same per-token price as the savings ledger, so a row and the totals agree.
|
|
1227
|
+
costSaved: (e.tokens || e.tokensImpact || 0) * AVG_COST_PER_TOKEN,
|
|
1163
1228
|
metadata: {
|
|
1164
1229
|
provider: e.provider,
|
|
1165
1230
|
project: e.project
|
|
@@ -1171,6 +1236,12 @@ class LemmaServer {
|
|
|
1171
1236
|
eventData.args = e.args;
|
|
1172
1237
|
eventData.result = e.result;
|
|
1173
1238
|
eventData.tokensImpact = e.tokensImpact;
|
|
1239
|
+
eventData.error = e.error;
|
|
1240
|
+
eventData.receipt = e.receipt;
|
|
1241
|
+
eventData.projectPath = e.projectPath;
|
|
1242
|
+
// tokensImpact is the size of the tool result — a context cost, not a saving.
|
|
1243
|
+
eventData.tokensSaved = 0;
|
|
1244
|
+
eventData.costSaved = 0;
|
|
1174
1245
|
}
|
|
1175
1246
|
return eventData;
|
|
1176
1247
|
});
|
|
@@ -1204,28 +1275,34 @@ class LemmaServer {
|
|
|
1204
1275
|
this.app.get('/api/cache-stats', async (req, res) => {
|
|
1205
1276
|
let topQueries = [];
|
|
1206
1277
|
let totalCached = CACHE.size;
|
|
1207
|
-
//
|
|
1278
|
+
// Semantic entries across every project's collection (completions are cached per project)
|
|
1208
1279
|
try {
|
|
1209
|
-
const
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1280
|
+
for (const name of await listCacheCollections()) {
|
|
1281
|
+
const collection = await chroma.getOrCreateCollection({
|
|
1282
|
+
name,
|
|
1283
|
+
embeddingFunction: dummyEmbeddingFunction,
|
|
1284
|
+
metadata: { "hnsw:space": "cosine" }
|
|
1285
|
+
});
|
|
1286
|
+
totalCached += await collection.count();
|
|
1287
|
+
}
|
|
1216
1288
|
}
|
|
1217
1289
|
catch { }
|
|
1218
|
-
//
|
|
1290
|
+
// Most reused answers: per-entry hit counts and the measured size of the cached response.
|
|
1291
|
+
// These used to be a flat 2000 tokens at $2/M with the provider's hit count stamped on
|
|
1292
|
+
// every entry, and "last hit" was really the creation time.
|
|
1219
1293
|
for (const [key, entry] of CACHE.entries()) {
|
|
1220
|
-
|
|
1221
|
-
|
|
1294
|
+
if (entry.hits === 0)
|
|
1295
|
+
continue;
|
|
1296
|
+
const tokensPerHit = measureCachedTokens(entry.data) || 0;
|
|
1297
|
+
const totalTokens = tokensPerHit * entry.hits;
|
|
1222
1298
|
topQueries.push({
|
|
1223
1299
|
queryHash: key.substring(0, 16),
|
|
1224
1300
|
query: entry.input,
|
|
1225
|
-
hitCount,
|
|
1226
|
-
totalTokens
|
|
1227
|
-
costSaved:
|
|
1228
|
-
lastHit: new Date(entry.createdAt).toISOString()
|
|
1301
|
+
hitCount: entry.hits,
|
|
1302
|
+
totalTokens,
|
|
1303
|
+
costSaved: totalTokens * AVG_COST_PER_TOKEN,
|
|
1304
|
+
lastHit: new Date(entry.lastHitAt || entry.createdAt).toISOString(),
|
|
1305
|
+
responsePreview: extractResponseText(entry.data).slice(0, 600),
|
|
1229
1306
|
});
|
|
1230
1307
|
}
|
|
1231
1308
|
// Try to query the stack orchestration API if running to append stack cache stats
|
|
@@ -1242,8 +1319,11 @@ class LemmaServer {
|
|
|
1242
1319
|
catch { }
|
|
1243
1320
|
// Sort by hitCount desc
|
|
1244
1321
|
topQueries.sort((a, b) => b.hitCount - a.hitCount);
|
|
1245
|
-
// Latency percentiles
|
|
1246
|
-
const latencies = EVENT_LOG
|
|
1322
|
+
// Latency percentiles of proxy completions only — MCP tool calls share EVENT_LOG.
|
|
1323
|
+
const latencies = EVENT_LOG
|
|
1324
|
+
.filter(e => (e.type === 'cache:hit' || e.type === 'cache:miss') && e.provider !== 'mcp' && typeof e.latency === 'number')
|
|
1325
|
+
.map(e => e.latency)
|
|
1326
|
+
.sort((a, b) => a - b);
|
|
1247
1327
|
const latencyPercentiles = {
|
|
1248
1328
|
p50: latencies[Math.floor(latencies.length * 0.5)] || 0,
|
|
1249
1329
|
p95: latencies[Math.floor(latencies.length * 0.95)] || 0,
|
|
@@ -1309,32 +1389,33 @@ class LemmaServer {
|
|
|
1309
1389
|
}
|
|
1310
1390
|
});
|
|
1311
1391
|
this.app.post('/api/cache/clear', async (req, res) => {
|
|
1312
|
-
|
|
1392
|
+
// resetHistory must be asked for explicitly: the ledger and stats are persisted lifetime
|
|
1393
|
+
// accounting, and a "clear cache" click used to wipe them to disk as a side effect.
|
|
1394
|
+
const { provider, resetHistory } = req.body || {};
|
|
1313
1395
|
let clearedCount = 0;
|
|
1314
1396
|
if (!provider) {
|
|
1315
1397
|
// Global clear
|
|
1316
1398
|
clearedCount = CACHE.size;
|
|
1317
1399
|
CACHE.clear();
|
|
1318
1400
|
EVENT_LOG.length = 0; // Clear live event log
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
totalLatency: 0,
|
|
1327
|
-
totalTokensSaved: 0,
|
|
1328
|
-
providers: {}
|
|
1329
|
-
};
|
|
1401
|
+
if (resetHistory === true) {
|
|
1402
|
+
savingsLedger.reset();
|
|
1403
|
+
}
|
|
1404
|
+
// Stats are per project but reported machine-wide, so a history reset covers them all.
|
|
1405
|
+
if (resetHistory === true) {
|
|
1406
|
+
for (const key of Object.keys(this.stats))
|
|
1407
|
+
delete this.stats[key];
|
|
1330
1408
|
writeJson(STATS_FILE, this.stats);
|
|
1331
1409
|
}
|
|
1332
|
-
//
|
|
1410
|
+
// Completions are cached per project, so clearing the cache clears every project's
|
|
1411
|
+
// collection — not only the one this daemon happened to be started for.
|
|
1333
1412
|
try {
|
|
1334
|
-
|
|
1413
|
+
for (const name of await listCacheCollections()) {
|
|
1414
|
+
await chroma.deleteCollection({ name }).catch(() => { });
|
|
1415
|
+
}
|
|
1335
1416
|
}
|
|
1336
1417
|
catch (e) {
|
|
1337
|
-
console.error(`[CacheClear] Failed to clear Chroma
|
|
1418
|
+
console.error(`[CacheClear] Failed to clear Chroma collections: ${e.message}`);
|
|
1338
1419
|
}
|
|
1339
1420
|
}
|
|
1340
1421
|
else {
|
|
@@ -1345,14 +1426,16 @@ class LemmaServer {
|
|
|
1345
1426
|
clearedCount++;
|
|
1346
1427
|
}
|
|
1347
1428
|
}
|
|
1348
|
-
// Selective delete
|
|
1429
|
+
// Selective delete, by provider metadata, across every project's collection
|
|
1349
1430
|
try {
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1431
|
+
for (const name of await listCacheCollections()) {
|
|
1432
|
+
const collection = await chroma.getOrCreateCollection({
|
|
1433
|
+
name,
|
|
1434
|
+
embeddingFunction: dummyEmbeddingFunction,
|
|
1435
|
+
metadata: { "hnsw:space": "cosine" }
|
|
1436
|
+
});
|
|
1437
|
+
await collection.delete({ where: { provider } });
|
|
1438
|
+
}
|
|
1356
1439
|
}
|
|
1357
1440
|
catch (e) {
|
|
1358
1441
|
console.error(`[CacheClear] Failed to selectively delete provider ${provider} from Chroma: ${e.message}`);
|
|
@@ -1377,8 +1460,40 @@ class LemmaServer {
|
|
|
1377
1460
|
res.status(500).json({ error: e.message });
|
|
1378
1461
|
}
|
|
1379
1462
|
});
|
|
1463
|
+
// Dashboard search over Brain memories. /api/search queries the completions cache in Chroma
|
|
1464
|
+
// and needs Ollama for embeddings; this needs neither. countStats:false — browsing the
|
|
1465
|
+
// corpus from the dashboard must not count as a recall or credit reuse to any memory.
|
|
1466
|
+
this.app.get('/api/brain/search', async (req, res) => {
|
|
1467
|
+
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
|
1468
|
+
if (!query)
|
|
1469
|
+
return res.status(400).json({ error: 'Query required' });
|
|
1470
|
+
try {
|
|
1471
|
+
// Same floor as search_memory, so the dashboard shows what the model would actually get.
|
|
1472
|
+
const results = await getBrain().searchHybrid(query, 10, DEFAULT_MEMORY_SIMILARITY_FLOOR, { countStats: false });
|
|
1473
|
+
res.json({
|
|
1474
|
+
results: results.map((r) => ({
|
|
1475
|
+
id: r.id,
|
|
1476
|
+
prompt: r.query,
|
|
1477
|
+
response: r.response,
|
|
1478
|
+
timestamp: r.timestamp,
|
|
1479
|
+
similarity: r.similarity,
|
|
1480
|
+
fresh: r.fresh,
|
|
1481
|
+
untracked: !!r.untracked,
|
|
1482
|
+
})),
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
catch (e) {
|
|
1486
|
+
res.status(500).json({ error: e.message });
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1380
1489
|
// Serve codebase timeline snapshots (AST Time-Travel Telemetry)
|
|
1490
|
+
// Snapshots come from watching this daemon's own cwd. Like onboarding, decline a caller from
|
|
1491
|
+
// another project instead of serving this project's history as theirs.
|
|
1381
1492
|
this.app.get('/api/project/timeline', (req, res) => {
|
|
1493
|
+
const expectedProject = typeof req.query.expectedProject === 'string' ? req.query.expectedProject : null;
|
|
1494
|
+
if (isProjectMismatch(expectedProject, this.projectName)) {
|
|
1495
|
+
return res.json({ success: false, mismatch: true, servingProject: this.projectName, timeline: [] });
|
|
1496
|
+
}
|
|
1382
1497
|
res.json({ success: true, timeline: this.timeline });
|
|
1383
1498
|
});
|
|
1384
1499
|
// Dynamic Zero-Shot Onboarding mental model builder
|
|
@@ -1557,36 +1672,15 @@ class LemmaServer {
|
|
|
1557
1672
|
tk = Math.floor(Math.max(0, charsBefore - charsAfter) / 4);
|
|
1558
1673
|
savingsLedger.record(source, charsBefore, charsAfter);
|
|
1559
1674
|
}
|
|
1560
|
-
//
|
|
1675
|
+
// A saving reported by an MCP tool is not a proxy completion: it used to bump the
|
|
1676
|
+
// completion stats (total++, a "miss" for every non-cache source, a mock 15ms latency),
|
|
1677
|
+
// which skewed the proxy's hit rate, request count and average latency. Ledger only;
|
|
1678
|
+
// the event just shows the saving in the activity feed, with nothing invented.
|
|
1561
1679
|
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
1680
|
logEvent({
|
|
1585
|
-
type: source === 'cache' ? '
|
|
1586
|
-
project: this.projectName,
|
|
1587
|
-
latency: 15,
|
|
1681
|
+
type: source === 'cache' ? 'brain_hit' : source,
|
|
1588
1682
|
provider: 'mcp',
|
|
1589
|
-
tokens: tk
|
|
1683
|
+
tokens: tk,
|
|
1590
1684
|
});
|
|
1591
1685
|
}
|
|
1592
1686
|
res.json({ ok: true, snapshot: savingsLedger.getSnapshot().total });
|
|
@@ -1636,11 +1730,15 @@ class LemmaServer {
|
|
|
1636
1730
|
result: event.result || 'UNKNOWN',
|
|
1637
1731
|
latency: event.latency || 0,
|
|
1638
1732
|
tokensImpact: event.tokensImpact || 0,
|
|
1639
|
-
error: event.error
|
|
1733
|
+
error: typeof event.error === 'string' ? event.error.slice(0, 200) : null,
|
|
1734
|
+
receipt: typeof event.receipt === 'string' ? event.receipt : undefined,
|
|
1735
|
+
projectPath: typeof event.projectPath === 'string' ? event.projectPath : undefined,
|
|
1640
1736
|
timestamp: event.timestamp || new Date().toISOString(),
|
|
1641
1737
|
};
|
|
1642
1738
|
// Add to EVENT_LOG
|
|
1643
1739
|
logEvent(mcpEvent);
|
|
1740
|
+
if (mcpEvent.projectPath)
|
|
1741
|
+
this.registry.recordToolCall(mcpEvent.projectPath);
|
|
1644
1742
|
res.json({ success: true, eventId: mcpEvent.id });
|
|
1645
1743
|
}
|
|
1646
1744
|
catch (error) {
|
|
@@ -1841,6 +1939,12 @@ class LemmaServer {
|
|
|
1841
1939
|
const tier = pro ? 'pro' : 'standard';
|
|
1842
1940
|
const isStream = !!req.body.stream;
|
|
1843
1941
|
let historyTokensSaved = 0;
|
|
1942
|
+
// The daemon is one process for every project on the machine; its own projectName is just
|
|
1943
|
+
// whichever cwd it was started from. Attribute each completion — stats, and above all the
|
|
1944
|
+
// semantic-cache collection — to the project the request comes from (MCP handshake
|
|
1945
|
+
// registry, then the messages themselves), so one project's cached answer is never served
|
|
1946
|
+
// to another. Falls back to the daemon's project when nothing identifies the caller.
|
|
1947
|
+
const requestProject = this.detectActiveProject(req)?.name ?? this.projectName;
|
|
1844
1948
|
if (isDebuggerPaused()) {
|
|
1845
1949
|
res.setHeader('Retry-After', '5');
|
|
1846
1950
|
return res.status(503).json({ error: { message: 'Lemma is paused from the dashboard debugger. Resume it to continue.' } });
|
|
@@ -1856,15 +1960,17 @@ class LemmaServer {
|
|
|
1856
1960
|
return res.status(400).json({ error: 'Invalid request format' });
|
|
1857
1961
|
const { maskedPrompt: prompt, tokenMap } = semanticScrubber.mask(rawPrompt);
|
|
1858
1962
|
if (Object.keys(tokenMap).length > 0) {
|
|
1859
|
-
logEvent({ type: 'privacy:mask', count: Object.keys(tokenMap).length, project:
|
|
1963
|
+
logEvent({ type: 'privacy:mask', count: Object.keys(tokenMap).length, project: requestProject });
|
|
1964
|
+
SECRETS_MASKED.count += Object.keys(tokenMap).length;
|
|
1860
1965
|
}
|
|
1861
1966
|
const originalModel = req.body.model || 'gpt-4o';
|
|
1862
1967
|
// ── 1. Cache-First Hot Path ──
|
|
1863
1968
|
// Perform exact cache check immediately (resolves in <1ms)
|
|
1864
1969
|
const hit = cacheGet(provider, prompt);
|
|
1865
1970
|
if (hit) {
|
|
1866
|
-
|
|
1867
|
-
|
|
1971
|
+
// Measured from the cached response, like the semantic path — a flat 2000 used to go
|
|
1972
|
+
// straight into the ledger as if it had been counted.
|
|
1973
|
+
await recordStat(this.stats, requestProject, true, Date.now() - t0, provider, measureCachedTokens(hit.data));
|
|
1868
1974
|
const unmaskedData = semanticScrubber.unmask(hit.data, tokenMap);
|
|
1869
1975
|
if (isStream)
|
|
1870
1976
|
return SseRelay.simulateHit(res, unmaskedData, { provider: provider, tier, similarity: 1.0 });
|
|
@@ -1875,10 +1981,10 @@ class LemmaServer {
|
|
|
1875
1981
|
}
|
|
1876
1982
|
// Perform semantic cache check (resolves in ~15ms)
|
|
1877
1983
|
if (pro) {
|
|
1878
|
-
const semHit = await semanticGet(provider, prompt,
|
|
1984
|
+
const semHit = await semanticGet(provider, prompt, requestProject);
|
|
1879
1985
|
if (semHit) {
|
|
1880
1986
|
if (semHit.similarity >= 0.90) {
|
|
1881
|
-
await recordStat(this.stats,
|
|
1987
|
+
await recordStat(this.stats, requestProject, true, Date.now() - t0, provider, measureCachedTokens(semHit.data));
|
|
1882
1988
|
const unmaskedData = semanticScrubber.unmask(semHit.data, tokenMap);
|
|
1883
1989
|
const cacheHeader = semHit.hiveMind ? 'HIT-HIVE-MIND' : 'HIT';
|
|
1884
1990
|
if (isStream)
|
|
@@ -1929,7 +2035,7 @@ Adjusted Answer:`;
|
|
|
1929
2035
|
else if (provider === 'anthropic') {
|
|
1930
2036
|
synthesizedData.content[0].text = adjustedText;
|
|
1931
2037
|
}
|
|
1932
|
-
await recordStat(this.stats,
|
|
2038
|
+
await recordStat(this.stats, requestProject, true, Date.now() - t0, provider, measureCachedTokens(synthesizedData));
|
|
1933
2039
|
const unmaskedData = semanticScrubber.unmask(synthesizedData, tokenMap);
|
|
1934
2040
|
if (isStream) {
|
|
1935
2041
|
return SseRelay.simulateHit(res, unmaskedData, { provider: provider, tier, similarity: semHit.similarity });
|
|
@@ -1949,7 +2055,7 @@ Adjusted Answer:`;
|
|
|
1949
2055
|
const cloudHit = await cloudSync.get(prompt);
|
|
1950
2056
|
if (cloudHit) {
|
|
1951
2057
|
cacheSet(provider, prompt, cloudHit.data);
|
|
1952
|
-
await recordStat(this.stats,
|
|
2058
|
+
await recordStat(this.stats, requestProject, true, Date.now() - t0, provider, measureCachedTokens(cloudHit.data));
|
|
1953
2059
|
const unmaskedData = semanticScrubber.unmask(cloudHit.data, tokenMap);
|
|
1954
2060
|
if (isStream)
|
|
1955
2061
|
return SseRelay.simulateHit(res, unmaskedData, { provider: provider, tier, similarity: cloudHit.similarity || 0.95 });
|
|
@@ -2045,7 +2151,7 @@ Adjusted Answer:`;
|
|
|
2045
2151
|
}
|
|
2046
2152
|
}
|
|
2047
2153
|
if (isStream) {
|
|
2048
|
-
await recordStat(this.stats,
|
|
2154
|
+
await recordStat(this.stats, requestProject, false, Date.now() - t0, provider, historyTokensSaved);
|
|
2049
2155
|
if (!pro) {
|
|
2050
2156
|
await usageWriteQueue.enqueue(async () => {
|
|
2051
2157
|
const u = await loadUsage();
|
|
@@ -2059,7 +2165,7 @@ Adjusted Answer:`;
|
|
|
2059
2165
|
const data = await this.callUpstream(req.body, provider);
|
|
2060
2166
|
cacheSet(provider, prompt, data);
|
|
2061
2167
|
if (pro) {
|
|
2062
|
-
await semanticSet(provider, prompt, data,
|
|
2168
|
+
await semanticSet(provider, prompt, data, requestProject);
|
|
2063
2169
|
await cloudSync.set(prompt, data);
|
|
2064
2170
|
}
|
|
2065
2171
|
if (!pro) {
|
|
@@ -2069,7 +2175,7 @@ Adjusted Answer:`;
|
|
|
2069
2175
|
await writeJson(USAGE_FILE, u);
|
|
2070
2176
|
});
|
|
2071
2177
|
}
|
|
2072
|
-
await recordStat(this.stats,
|
|
2178
|
+
await recordStat(this.stats, requestProject, false, Date.now() - t0, provider, historyTokensSaved);
|
|
2073
2179
|
return res.json(semanticScrubber.unmask(data, tokenMap));
|
|
2074
2180
|
}
|
|
2075
2181
|
catch (err) {
|
|
@@ -2221,23 +2327,31 @@ Adjusted Answer:`;
|
|
|
2221
2327
|
console.log('simply copy-paste this command into your MCP settings:');
|
|
2222
2328
|
console.log(`\n \x1b[1m\x1b[33mlemma mcp\x1b[0m\n`);
|
|
2223
2329
|
console.log('────────────────────────────────────────────────\n');
|
|
2224
|
-
//
|
|
2225
|
-
|
|
2226
|
-
|
|
2330
|
+
// The clipboard watcher runs as its own background process (startBackgroundClipboardWatcher,
|
|
2331
|
+
// tracked in CLIPBOARD_PID_FILE and stopped by `lemma stop`). This used to also start a
|
|
2332
|
+
// second, in-process watcher here: two pollers on one clipboard squeezed and reported the
|
|
2333
|
+
// same copy twice.
|
|
2334
|
+
if (process.env.LEMMA_DISABLE_CLIPBOARD === 'true') {
|
|
2227
2335
|
console.log(`📋 Clipboard Watcher : \x1b[90m[DISABLED via LEMMA_DISABLE_CLIPBOARD]\x1b[0m`);
|
|
2228
2336
|
}
|
|
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
2337
|
});
|
|
2240
|
-
|
|
2338
|
+
// A SIGTERM listener replaces Node's default exit, and server.close() only stops accepting:
|
|
2339
|
+
// keep-alive and socket.io connections, file watchers and intervals keep the loop alive, so
|
|
2340
|
+
// `lemma stop` used to leave a daemon with no port that never exited. Flush the Brain's
|
|
2341
|
+
// debounced write (same reason as the MCP bootstrap), close, and exit — with a hard cap.
|
|
2342
|
+
const shutdown = () => {
|
|
2343
|
+
try {
|
|
2344
|
+
getBrain().flushSync();
|
|
2345
|
+
}
|
|
2346
|
+
catch {
|
|
2347
|
+
// A flush failure must never block shutdown — the next save() heals.
|
|
2348
|
+
}
|
|
2349
|
+
server.close(() => process.exit(0));
|
|
2350
|
+
server.closeAllConnections();
|
|
2351
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
2352
|
+
};
|
|
2353
|
+
process.once('SIGTERM', shutdown);
|
|
2354
|
+
process.once('SIGINT', shutdown);
|
|
2241
2355
|
}
|
|
2242
2356
|
}
|
|
2243
2357
|
// ── CLI ────────────────────────────────────────────────────────────────────────
|
|
@@ -2932,6 +3046,20 @@ program.command('clipboard')
|
|
|
2932
3046
|
.option('--interval <ms>', 'Polling interval in milliseconds', '1000')
|
|
2933
3047
|
.action((opts) => {
|
|
2934
3048
|
try {
|
|
3049
|
+
// One watcher per machine: every poller on the same clipboard squeezes and reports the
|
|
3050
|
+
// same copy again. Defer to a live watcher recorded in the pid file; otherwise record us.
|
|
3051
|
+
try {
|
|
3052
|
+
const recorded = parseInt(fs.readFileSync(CLIPBOARD_PID_FILE, 'utf8'), 10);
|
|
3053
|
+
if (recorded && recorded !== process.pid) {
|
|
3054
|
+
process.kill(recorded, 0); // throws if it's gone
|
|
3055
|
+
console.log(`✅ [Clipboard] Watcher already running (PID: ${recorded}) — not starting another.`);
|
|
3056
|
+
process.exit(0);
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
catch {
|
|
3060
|
+
// No pid file, or its process is dead: this one is the watcher.
|
|
3061
|
+
}
|
|
3062
|
+
fs.writeFileSync(CLIPBOARD_PID_FILE, String(process.pid));
|
|
2935
3063
|
const interval = parseInt(opts.interval) || 1000;
|
|
2936
3064
|
const { startClipboardWatcher } = require('../utils/ClipboardWatcher');
|
|
2937
3065
|
startClipboardWatcher(interval);
|