@zenithfoundry/slm-gate 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.env.example +669 -0
  2. package/LICENSE +21 -0
  3. package/README.md +317 -0
  4. package/configs/antigravity/.env.16gb.example +674 -0
  5. package/configs/antigravity/.env.24gb.example +674 -0
  6. package/configs/antigravity/.env.32gb.example +674 -0
  7. package/configs/antigravity/README.md +109 -0
  8. package/configs/claude-code/.env.16gb.example +674 -0
  9. package/configs/claude-code/.env.24gb.example +674 -0
  10. package/configs/claude-code/.env.32gb.example +674 -0
  11. package/configs/claude-code/README.md +52 -0
  12. package/configs/claude-desktop/.env.16gb.example +674 -0
  13. package/configs/claude-desktop/.env.24gb.example +674 -0
  14. package/configs/claude-desktop/.env.32gb.example +674 -0
  15. package/configs/claude-desktop/README.md +37 -0
  16. package/configs/cline-continue-opencode/.env.16gb.example +674 -0
  17. package/configs/cline-continue-opencode/.env.24gb.example +674 -0
  18. package/configs/cline-continue-opencode/.env.32gb.example +674 -0
  19. package/configs/cline-continue-opencode/README.md +34 -0
  20. package/configs/cursor/.env.16gb.example +674 -0
  21. package/configs/cursor/.env.24gb.example +674 -0
  22. package/configs/cursor/.env.32gb.example +674 -0
  23. package/configs/cursor/README.md +26 -0
  24. package/configs/generic-http/.env.16gb.example +674 -0
  25. package/configs/generic-http/.env.24gb.example +674 -0
  26. package/configs/generic-http/.env.32gb.example +674 -0
  27. package/configs/generic-http/README.md +20 -0
  28. package/configs/generic-stdio/.env.16gb.example +674 -0
  29. package/configs/generic-stdio/.env.24gb.example +674 -0
  30. package/configs/generic-stdio/.env.32gb.example +674 -0
  31. package/configs/generic-stdio/README.md +24 -0
  32. package/configs/preserve/README.md +26 -0
  33. package/configs/preserve/tls.json +61 -0
  34. package/dist/adapters/tech-lead-stack.js +38 -0
  35. package/dist/cache/index.js +173 -0
  36. package/dist/cli.js +256 -0
  37. package/dist/config.js +255 -0
  38. package/dist/dashboard/data.js +149 -0
  39. package/dist/dashboard/export.js +42 -0
  40. package/dist/dashboard/serve.js +63 -0
  41. package/dist/doctor.js +338 -0
  42. package/dist/hardware.js +126 -0
  43. package/dist/home-dir.js +39 -0
  44. package/dist/ledger/flush-lifecycle.js +50 -0
  45. package/dist/ledger/index.js +946 -0
  46. package/dist/ledger/report.js +69 -0
  47. package/dist/ledger/setup-dashboard.js +456 -0
  48. package/dist/ledger/smoke.js +37 -0
  49. package/dist/ledger/sync-config.js +177 -0
  50. package/dist/ledger/sync.js +307 -0
  51. package/dist/ledger/verify.js +185 -0
  52. package/dist/ledger/wipe-langfuse.js +130 -0
  53. package/dist/llm-gate/distill.js +239 -0
  54. package/dist/llm-gate/formats/anthropic.js +185 -0
  55. package/dist/llm-gate/formats/chat-completions.js +103 -0
  56. package/dist/llm-gate/formats/contract.js +29 -0
  57. package/dist/llm-gate/formats/gemini.js +84 -0
  58. package/dist/llm-gate/formats/internal.js +1 -0
  59. package/dist/llm-gate/formats/openai.js +77 -0
  60. package/dist/llm-gate/formats/responses.js +146 -0
  61. package/dist/llm-gate/forward.js +150 -0
  62. package/dist/llm-gate/index.js +40 -0
  63. package/dist/llm-gate/local-first.js +217 -0
  64. package/dist/llm-gate/pipeline.js +267 -0
  65. package/dist/llm-gate/server.js +289 -0
  66. package/dist/mcp-gate/ground.js +64 -0
  67. package/dist/mcp-gate/index.js +57 -0
  68. package/dist/mcp-gate/pipeline.js +252 -0
  69. package/dist/mcp-gate/server.js +302 -0
  70. package/dist/mcp-gate/tool-names.js +57 -0
  71. package/dist/models/check.js +26 -0
  72. package/dist/models/footprint.js +137 -0
  73. package/dist/models/helpers.js +91 -0
  74. package/dist/models/index.js +5 -0
  75. package/dist/models/reasoning.js +91 -0
  76. package/dist/models/roles.js +9 -0
  77. package/dist/models/slm.js +243 -0
  78. package/dist/models/types.js +1 -0
  79. package/dist/pricing/index.js +115 -0
  80. package/dist/pricing/plans.js +54 -0
  81. package/dist/pricing/providers.js +172 -0
  82. package/dist/resolver/index.js +277 -0
  83. package/dist/resolver/types.js +1 -0
  84. package/dist/setup/claim.js +41 -0
  85. package/dist/setup/gate-command.js +41 -0
  86. package/dist/setup/init.js +92 -0
  87. package/dist/setup/local-models.js +123 -0
  88. package/dist/setup/model-gate.js +220 -0
  89. package/dist/setup/notify.js +45 -0
  90. package/dist/setup/ollama-install.js +53 -0
  91. package/dist/setup/parent-watch.js +84 -0
  92. package/dist/setup/required-models.js +20 -0
  93. package/dist/setup/startup.js +132 -0
  94. package/dist/setup/tool-settings.js +101 -0
  95. package/dist/utils/backoff.js +47 -0
  96. package/dist/utils/compression.js +145 -0
  97. package/dist/utils/constants.js +22 -0
  98. package/dist/utils/duration.js +43 -0
  99. package/dist/utils/elision.js +556 -0
  100. package/dist/utils/embedding.js +32 -0
  101. package/dist/utils/entry-point.js +23 -0
  102. package/dist/utils/local-only.js +82 -0
  103. package/dist/utils/preserve-patterns.js +115 -0
  104. package/dist/utils/safety.js +30 -0
  105. package/dist/verifier/index.js +67 -0
  106. package/package.json +121 -0
@@ -0,0 +1,289 @@
1
+ import fs from 'node:fs';
2
+ import http from 'node:http';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { CONFIG } from '../config.js';
5
+ import { writeEvent } from '../ledger/index.js';
6
+ import { estimateTokens } from '../utils/elision.js';
7
+ import { distilRequest } from './distill.js';
8
+ import * as anthropic from './formats/anthropic.js';
9
+ import * as chatCompletions from './formats/chat-completions.js';
10
+ import * as gemini from './formats/gemini.js';
11
+ import * as responses from './formats/responses.js';
12
+ import { forwardRequest, resolveUpstream, SUPPORTED_PATHS } from './forward.js';
13
+ import { answerFirstRequestLocally } from './local-first.js';
14
+ import { HEALTH_PATH } from '../setup/model-gate.js';
15
+ import { requiredModels } from '../setup/local-models.js';
16
+ import { isLocalRequest } from '../utils/local-only.js';
17
+ const FORMATS = {
18
+ anthropic,
19
+ 'chat-completions': chatCompletions,
20
+ responses,
21
+ gemini,
22
+ };
23
+ // The answer to "is the model gate running, and which one?" (HEALTH_PATH) — answered here, never
24
+ // forwarded. Read once at start and frozen: when this file on disk later has another modification time,
25
+ // the running gate is older than the installed build (see src/setup/model-gate.ts).
26
+ const SERVER_FILE = fileURLToPath(import.meta.url);
27
+ const HEALTH = {
28
+ service: 'slm-gate',
29
+ pid: process.pid,
30
+ entry: SERVER_FILE,
31
+ build: String(Math.round(fs.statSync(SERVER_FILE).mtimeMs)),
32
+ startedAt: new Date().toISOString(),
33
+ // So the MCP servers also check the models the gate's own settings (slm-gate's .env) name.
34
+ models: requiredModels(),
35
+ };
36
+ function generateId() {
37
+ return 'req_' + Math.random().toString(36).substring(2, 15);
38
+ }
39
+ /** The model a request is for: Gemini names it in the path, the other formats in the body. */
40
+ function requestModel(route, body) {
41
+ if (route.pathModel)
42
+ return route.pathModel;
43
+ try {
44
+ const model = JSON.parse(body.toString('utf8'))?.model;
45
+ return typeof model === 'string' ? model : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function parseJsonObject(body) {
52
+ try {
53
+ const parsed = JSON.parse(body.toString('utf8'));
54
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * Step A: a first request the local model answers itself, in the request's own format. Anything else —
62
+ * a later request, structured output, a busy or slow local model, a declined or rejected answer —
63
+ * returns no reply and the request goes on unchanged.
64
+ */
65
+ async function localFirst(params) {
66
+ const { route, parsed, body } = params;
67
+ const format = FORMATS[route.format];
68
+ const prompt = format.firstRequestPrompt(parsed);
69
+ // Gemini's JSON-array streaming (no `alt=sse`) is left to the provider.
70
+ if (!prompt || route.geminiStream === 'json-array')
71
+ return null;
72
+ const stream = route.format === 'gemini' ? route.geminiStream === 'sse' : parsed.stream === true;
73
+ const started = Date.now();
74
+ let local;
75
+ try {
76
+ local = await answerFirstRequestLocally({ task: prompt.text, toolsListed: prompt.toolsListed });
77
+ }
78
+ catch (err) {
79
+ // Step A can only make a request cheaper; if it fails in any way the request goes on as normal.
80
+ console.error(`LLM Gate: local answer skipped: ${err instanceof Error ? err.message : String(err)}`);
81
+ return null;
82
+ }
83
+ const { attempt, outcome } = local;
84
+ const step = {
85
+ reply: null,
86
+ latencyMs: Date.now() - started,
87
+ verifierFlags: attempt?.verifierFlags ?? [],
88
+ meta: {
89
+ local_outcome: outcome,
90
+ category: attempt?.category ?? null,
91
+ local_attempted: attempt?.attempted ? 1 : 0,
92
+ local_accepted: attempt?.accepted ? 1 : 0,
93
+ from_cache: attempt?.fromCache ? 1 : 0,
94
+ prompt_chars: prompt.text.length,
95
+ prompt_tok_est: estimateTokens(prompt.text),
96
+ has_code_fence: /```/.test(prompt.text) ? 1 : 0,
97
+ },
98
+ };
99
+ if (!attempt || attempt.answer === null)
100
+ return step;
101
+ const answer = attempt.answer;
102
+ const reply = format.buildLocalReply({
103
+ text: answer,
104
+ stream,
105
+ model: attempt.model,
106
+ usage: { inputTokens: estimateTokens(body.toString('utf8')), outputTokens: estimateTokens(answer) },
107
+ });
108
+ return { ...step, reply: { ...reply, answer, model: attempt.model } };
109
+ }
110
+ /**
111
+ * Step B: the bytes to forward. The original bytes go out whenever nothing changed, the body is not
112
+ * JSON, or distillation fails in any way — it can make a request smaller, never break it.
113
+ */
114
+ async function distilBody(params) {
115
+ const { route, parsed, body } = params;
116
+ if (!parsed)
117
+ return { sent: body, distill: null };
118
+ try {
119
+ const result = await distilRequest({ format: FORMATS[route.format], body: parsed });
120
+ return { sent: result.body ? Buffer.from(JSON.stringify(result.body)) : body, distill: result.stats };
121
+ }
122
+ catch (err) {
123
+ console.error(`LLM Gate: distillation skipped for this request: ${err instanceof Error ? err.message : String(err)}`);
124
+ return { sent: body, distill: null };
125
+ }
126
+ }
127
+ /**
128
+ * Writes the ledger row for a forwarded generation request or a rejected path. Hello pings, model
129
+ * lists and token counts get no row, so they never count as model work in the metrics.
130
+ */
131
+ function recordRequest(params) {
132
+ recordSafely({ reqId: params.reqId, path: params.path, write: () => writeLedgerRow(params) });
133
+ }
134
+ function recordSafely(params) {
135
+ try {
136
+ params.write();
137
+ }
138
+ catch (err) {
139
+ // Telemetry must never hold up model traffic: the row is lost, the request is not.
140
+ console.error(`LLM Gate: ledger row for ${params.reqId} (${params.path}) not written: ${err instanceof Error ? err.message : String(err)}`);
141
+ }
142
+ }
143
+ /** The ledger row of a first request the local model answered: the whole cloud request was avoided. */
144
+ function recordLocalAnswer(params) {
145
+ const { reqId, path, body, route, local } = params;
146
+ recordSafely({
147
+ reqId,
148
+ path,
149
+ write: () => writeEvent({
150
+ ts: new Date().toISOString(),
151
+ layer: 'llm',
152
+ request_id: reqId,
153
+ route: 'defer_local',
154
+ is_local_call: 1,
155
+ slm_model: local.reply.model,
156
+ // The provider whose request was avoided, for per-provider savings.
157
+ api_model: requestModel(route, body),
158
+ in_tok: estimateTokens(body.toString('utf8')),
159
+ out_tok: estimateTokens(local.reply.answer),
160
+ api_in_tok: 0,
161
+ api_out_tok: 0,
162
+ cost_usd: 0,
163
+ slm_latency_s: local.latencyMs / 1000,
164
+ api_latency_s: 0,
165
+ verifier_flags: JSON.stringify(local.verifierFlags),
166
+ slm_gate: 'on',
167
+ meta: JSON.stringify({ format: route.format, path, status: 200, ...local.meta }),
168
+ }),
169
+ });
170
+ }
171
+ function writeLedgerRow(params) {
172
+ const { reqId, path, body, route, outcome, distill = null, local = null } = params;
173
+ const sent = params.sent ?? body;
174
+ const distilled = sent !== body;
175
+ writeEvent({
176
+ ts: new Date().toISOString(),
177
+ layer: 'llm',
178
+ request_id: reqId,
179
+ route: distilled ? 'forward_compressed' : 'forward_raw',
180
+ is_local_call: 0,
181
+ api_model: route ? requestModel(route, body) : undefined,
182
+ in_tok: 0,
183
+ out_tok: 0,
184
+ // Estimated from the bodies until provider usage is read from responses.
185
+ api_in_tok: route ? estimateTokens(sent.toString('utf8')) : 0,
186
+ api_out_tok: 0,
187
+ cost_usd: 0,
188
+ slm_latency_s: (local?.latencyMs ?? 0) / 1000,
189
+ api_latency_s: (outcome.durationMs ?? 0) / 1000,
190
+ ...(local ? { verifier_flags: JSON.stringify(local.verifierFlags) } : {}),
191
+ slm_gate: 'on',
192
+ meta: JSON.stringify({
193
+ // A Step A attempt that did not answer: ROUTING_TUNE learns from local_attempted/local_accepted.
194
+ ...(local?.meta ?? {}),
195
+ format: route?.format ?? null,
196
+ path,
197
+ status: outcome.status,
198
+ bytes_in: body.length,
199
+ bytes_sent: sent.length,
200
+ bytes_out: outcome.bytesOut ?? 0,
201
+ client_aborted: outcome.clientAborted ? 1 : 0,
202
+ upstream_error: outcome.error ?? null,
203
+ tokens_estimated: 1,
204
+ // The metrics read raw_in_tok as "input before distillation" on forward_compressed rows.
205
+ ...(distilled ? { raw_in_tok: estimateTokens(body.toString('utf8')) } : {}),
206
+ distill,
207
+ }),
208
+ });
209
+ }
210
+ async function handleRequest(params) {
211
+ const { req, res, reqId, body } = params;
212
+ const path = new URL(req.url || '/', 'http://gate.local').pathname;
213
+ res.setHeader('Access-Control-Allow-Origin', '*');
214
+ res.setHeader('x-correlation-id', reqId);
215
+ if (req.method === 'GET' && path === HEALTH_PATH) {
216
+ const address = req.socket.localPort;
217
+ res.writeHead(200, { 'content-type': 'application/json' });
218
+ res.end(JSON.stringify({ ...HEALTH, port: address }));
219
+ return;
220
+ }
221
+ const route = resolveUpstream({ pathAndQuery: req.url || '/', headers: req.headers });
222
+ if (!route) {
223
+ res.writeHead(404, { 'content-type': 'application/json' });
224
+ res.end(JSON.stringify({
225
+ error: { type: 'slm_gate_unsupported_path', message: `slm-gate does not handle ${req.method} ${path}`, supported: SUPPORTED_PATHS },
226
+ }));
227
+ console.info(`LLM Gate: ${req.method} ${path} -> 404 (unsupported path)`);
228
+ recordRequest({ reqId, path, body, route, outcome: { status: 404 } });
229
+ return;
230
+ }
231
+ const parsed = route.generation ? parseJsonObject(body) : null;
232
+ const local = parsed && CONFIG.LLM_GATE_LOCAL_FIRST ? await localFirst({ route, parsed, body }) : null;
233
+ if (local?.reply) {
234
+ res.writeHead(200, { 'content-type': local.reply.contentType, 'x-slm-gate-route': 'defer_local' });
235
+ res.end(local.reply.body);
236
+ console.info(`LLM Gate: ${req.method} ${path} -> answered locally by ${local.reply.model} in ${local.latencyMs}ms`);
237
+ recordLocalAnswer({ reqId, path, body, route, local });
238
+ return;
239
+ }
240
+ const { sent, distill } = CONFIG.LLM_GATE_DISTILL
241
+ ? await distilBody({ route, parsed, body })
242
+ : { sent: body, distill: null };
243
+ const outcome = await forwardRequest({ req, res, body: sent, route });
244
+ const saved = sent === body ? '' : `, distilled ${body.length} -> ${sent.length} bytes`;
245
+ console.info(`LLM Gate: ${req.method} ${path} -> ${route.format} ${outcome.status} in ${outcome.durationMs}ms${saved}${outcome.clientAborted ? ' (client aborted)' : ''}`);
246
+ if (route.generation)
247
+ recordRequest({ reqId, path, body, route, outcome, sent, distill, local });
248
+ }
249
+ /**
250
+ * Handles every request to the `llm-gate`.
251
+ *
252
+ * Only programs on this computer are served (src/utils/local-only.ts); anything else gets a 403 and is
253
+ * neither forwarded nor recorded. Every supported request is forwarded, unchanged and with the tool's own
254
+ * login, to the provider its wire format belongs to (see forward.ts), and the response is streamed back
255
+ * unchanged. Answers CORS preflights itself so web pages served from this computer can reach it.
256
+ */
257
+ export function requestListener(req, res) {
258
+ if (!isLocalRequest(req.headers)) {
259
+ res.writeHead(403, { 'content-type': 'application/json' });
260
+ res.end(JSON.stringify({ error: { type: 'slm_gate_error', message: 'slm-gate only accepts requests from programs on this computer' } }));
261
+ return;
262
+ }
263
+ const reqId = generateId();
264
+ if (req.method === 'OPTIONS') {
265
+ res.writeHead(204, {
266
+ 'Access-Control-Allow-Origin': '*',
267
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
268
+ 'Access-Control-Allow-Headers': '*'
269
+ });
270
+ res.end();
271
+ return;
272
+ }
273
+ const chunks = [];
274
+ req.on('data', (chunk) => chunks.push(chunk));
275
+ req.on('end', () => {
276
+ handleRequest({ req, res, reqId, body: Buffer.concat(chunks) }).catch(err => {
277
+ // Only a ledger or programming error reaches here; the response may already be complete.
278
+ console.error('LLM Gate Error:', err);
279
+ if (!res.headersSent) {
280
+ res.writeHead(500, { 'content-type': 'application/json' });
281
+ res.end(JSON.stringify({ error: { type: 'slm_gate_error', message: err instanceof Error ? err.message : String(err) } }));
282
+ }
283
+ });
284
+ });
285
+ // The client dropped while still sending: nothing was forwarded, so there is nothing to record.
286
+ req.on('error', () => res.destroy());
287
+ }
288
+ /** A server for tests; the gate itself listens through listenOnThisComputer (index.ts). */
289
+ export const server = http.createServer(requestListener);
@@ -0,0 +1,64 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ export async function scan(rootUri) {
5
+ let rootPath = rootUri;
6
+ if (rootUri.startsWith('file://')) {
7
+ try {
8
+ rootPath = fileURLToPath(rootUri);
9
+ }
10
+ catch {
11
+ rootPath = rootUri.substring(7);
12
+ }
13
+ }
14
+ const detected = [];
15
+ const fileExists = async (filename) => {
16
+ try {
17
+ const stats = await fs.stat(path.join(rootPath, filename));
18
+ return stats.isFile();
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ };
24
+ if (await fileExists('package.json')) {
25
+ detected.push('Node.js / npm project');
26
+ try {
27
+ const pkg = JSON.parse(await fs.readFile(path.join(rootPath, 'package.json'), 'utf-8'));
28
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
29
+ if (allDeps['react'])
30
+ detected.push('React');
31
+ if (allDeps['next'])
32
+ detected.push('Next.js');
33
+ if (allDeps['vue'])
34
+ detected.push('Vue');
35
+ if (allDeps['svelte'])
36
+ detected.push('Svelte');
37
+ if (allDeps['jest'])
38
+ detected.push('Jest');
39
+ if (allDeps['vitest'])
40
+ detected.push('Vitest');
41
+ }
42
+ catch (e) {
43
+ // ignore parse errors
44
+ }
45
+ }
46
+ if (await fileExists('tsconfig.json'))
47
+ detected.push('TypeScript');
48
+ if (await fileExists('yarn.lock'))
49
+ detected.push('Yarn package manager');
50
+ if (await fileExists('pnpm-lock.yaml'))
51
+ detected.push('pnpm package manager');
52
+ if (await fileExists('package-lock.json'))
53
+ detected.push('npm package manager');
54
+ if (await fileExists('Cargo.toml'))
55
+ detected.push('Rust (Cargo)');
56
+ if (await fileExists('go.mod'))
57
+ detected.push('Go modules');
58
+ if (detected.length === 0) {
59
+ return 'No specific framework or environment detected from root files.';
60
+ }
61
+ // Single `\n`: `\\n` emitted the two characters backslash-n, so the whole environment
62
+ // context reached the cloud model as one run-on line.
63
+ return 'Detected environment context:\n- ' + detected.join('\n- ');
64
+ }
@@ -0,0 +1,57 @@
1
+ import { CONFIG } from '../config.js';
2
+ import { createServer } from './server.js';
3
+ import { installLangfuseFlushLifecycle } from '../ledger/flush-lifecycle.js';
4
+ import { logLedgerInfo } from '../ledger/index.js';
5
+ import { exitWithParent } from '../setup/parent-watch.js';
6
+ import { runStartupChecks, watchModelGate } from '../setup/startup.js';
7
+ /**
8
+ * Leave nothing running that no coding tool owns. Registered before any work that could hang, so a
9
+ * toolbox that never finishes connecting cannot strand a server either.
10
+ */
11
+ function shutDownWhenNobodyOwnsUs() {
12
+ const quit = () => process.kill(process.pid, 'SIGTERM');
13
+ if (CONFIG.MCP_GATE_TRANSPORT === 'stdio') {
14
+ // Our input closing means the coding tool is gone (closed or crashed). Whichever of these arrives
15
+ // first wins; SIGTERM is idempotent here, and stdin is deliberately not resumed, because reading
16
+ // it ourselves would swallow the handshake the transport is about to read.
17
+ process.stdin.once('end', quit);
18
+ process.stdin.once('close', quit);
19
+ process.stdin.on('error', quit);
20
+ }
21
+ // The backstop: this arrives even when start-up hangs before anything reads stdin, and it is the
22
+ // only signal at all when the transport is HTTP.
23
+ exitWithParent();
24
+ }
25
+ async function main() {
26
+ if (CONFIG.MCP_GATE_TRANSPORT === 'stdio') {
27
+ console.log = console.error;
28
+ }
29
+ shutDownWhenNobodyOwnsUs();
30
+ console.error(`[mcp-gate] Starting up...`);
31
+ console.error(`[mcp-gate] Mode: ${CONFIG.DOWNSTREAM_MCP ? 'Proxy' : 'Standalone'}`);
32
+ console.error(`[mcp-gate] Transport: ${CONFIG.MCP_GATE_TRANSPORT}`);
33
+ console.error(`[mcp-gate] Models -> Brain: ${CONFIG.SLM_BRAIN_MODEL} | Gate: ${CONFIG.SLM_GATE_MODEL}`);
34
+ logLedgerInfo('mcp-gate');
35
+ installLangfuseFlushLifecycle('mcp-gate');
36
+ // A coding tool starting this server is the sign that work is about to start: check Ollama and the
37
+ // configured models and make sure the model gate runs (launched in the background if it does not).
38
+ // At most ~1.5 s; problems reach the AI through the instructions and you through a notification.
39
+ let notices = [];
40
+ try {
41
+ notices = await runStartupChecks();
42
+ }
43
+ catch (err) {
44
+ console.error(`[mcp-gate] Start-up checks failed (continuing):`, err);
45
+ }
46
+ try {
47
+ const { start } = await createServer({ notices });
48
+ await start();
49
+ console.error(`[mcp-gate] Server is running and listening for messages.`);
50
+ watchModelGate();
51
+ }
52
+ catch (err) {
53
+ console.error(`[mcp-gate] Fatal error during startup:`, err);
54
+ process.exit(1);
55
+ }
56
+ }
57
+ main();
@@ -0,0 +1,252 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { checkSemanticCache, setSemanticCache } from '../cache/index.js';
6
+ import { CONFIG } from '../config.js';
7
+ import { cacheGet, cacheSet, writeEvent } from '../ledger/index.js';
8
+ import { handleSlmError } from '../models/helpers.js';
9
+ import { compressNarrativeRun } from '../models/reasoning.js';
10
+ import { SLM } from '../models/slm.js';
11
+ import { resolveAmbiguities } from '../resolver/index.js';
12
+ import { distillToolResult } from '../utils/elision.js';
13
+ import { scan } from './ground.js';
14
+ import { buildPreserveList } from '../utils/preserve-patterns.js';
15
+ let slmClient;
16
+ /**
17
+ * Creates and initializes a new instance of the Small Language Model (SLM) client.
18
+ * @returns {SLM} A new SLM client instance.
19
+ */
20
+ function createSlmClient() {
21
+ return new SLM();
22
+ }
23
+ let cachedPreserveList = null;
24
+ /**
25
+ * Retrieves the compiled list of regular expressions used to identify text blocks
26
+ * that must be preserved verbatim during the distillation process. Results are cached.
27
+ * @returns {Promise<RegExp[]>} A promise resolving to an array of preservation regular expressions.
28
+ */
29
+ async function getPreserveList() {
30
+ if (!cachedPreserveList) {
31
+ cachedPreserveList = await buildPreserveList();
32
+ }
33
+ return cachedPreserveList;
34
+ }
35
+ const SLM_ERROR_NOTES = {
36
+ timeout: 'The local model exceeded SLM_TIMEOUT_MS. Raise the budget or use a smaller model.',
37
+ format: 'The local model returned output that could not be parsed. Try a larger or more instruction-following model.',
38
+ transport: 'The local model could not be reached. Check that Ollama is running at OLLAMA_HOST.',
39
+ unknown: 'The local model failed for an unrecognised reason. See stderr for details.',
40
+ };
41
+ /**
42
+ * Classifies an SLM failure so the cause is actionable.
43
+ *
44
+ * @param err The thrown error
45
+ * @returns The error class, never null (callers use null to mean "no error").
46
+ */
47
+ function classifySlmError(err) {
48
+ const name = err?.name ?? '';
49
+ const message = (err?.message ?? '').toLowerCase();
50
+ if (name === 'SlmTimeoutError' || message.includes('timed out') || message.includes('timeout'))
51
+ return 'timeout';
52
+ if (name === 'SlmFormatError' || message.includes('json') || message.includes('parse'))
53
+ return 'format';
54
+ if (message.includes('fetch failed') || message.includes('econnrefused') || message.includes('econnreset'))
55
+ return 'transport';
56
+ return 'unknown';
57
+ }
58
+ /**
59
+ * Writes a single `condition` ledger event.
60
+ *
61
+ * Centralised so the cache-hit path, the semantic-cache path and the full pipeline all
62
+ * report identically — previously only the slow path emitted anything at all.
63
+ *
64
+ * @param params.text Raw inbound tool output
65
+ * @param params.conditioned Text actually returned to the host
66
+ * @param params.args Tool arguments, carrying host-supplied model/agent/session hints
67
+ * @param params.startTime Epoch ms when conditioning began
68
+ * @param params.cacheHit Which cache served this request, if any
69
+ * @param params.errorKinds Per-stage failure classes, for diagnosis
70
+ */
71
+ function emitConditionEvent(params) {
72
+ const { text, conditioned, args, startTime, cacheHit, errorKinds } = params;
73
+ const meta = {};
74
+ if (cacheHit)
75
+ meta.cache_hit = cacheHit;
76
+ if (errorKinds?.distill)
77
+ meta.distill_error = errorKinds.distill;
78
+ if (errorKinds?.resolver)
79
+ meta.resolver_error = errorKinds.resolver;
80
+ writeEvent({
81
+ ts: new Date().toISOString(),
82
+ layer: 'mcp',
83
+ // randomUUID, not Date.now()+short random: the old id could collide within the same
84
+ // millisecond, and INSERT OR REPLACE on the request_id PK then silently dropped a row.
85
+ request_id: `cond_${crypto.randomUUID()}`,
86
+ session_id: args?.sessionId ? String(args.sessionId) : (args?.session_id ? String(args.session_id) : undefined),
87
+ skill: args?.skillName ? String(args.skillName) : undefined,
88
+ route: 'condition',
89
+ is_local_call: 1,
90
+ slm_model: CONFIG.SLM_GATE_MODEL,
91
+ api_model: args?.model ? String(args.model) : undefined,
92
+ agent: args?.agent ? String(args.agent) : undefined,
93
+ in_tok: Math.round(text.length / 4),
94
+ out_tok: Math.round(conditioned.length / 4),
95
+ api_in_tok: 0,
96
+ api_out_tok: 0,
97
+ cost_usd: 0,
98
+ // A cache hit does no SLM work; charging it the wall-clock time would inflate latency stats.
99
+ slm_latency_s: cacheHit ? 0 : (Date.now() - startTime) / 1000,
100
+ api_latency_s: 0,
101
+ slm_gate: 'on',
102
+ meta: Object.keys(meta).length > 0 ? JSON.stringify(meta) : undefined,
103
+ });
104
+ }
105
+ /**
106
+ * The main entry point for the MCP gate pipeline. Processes an incoming prompt by checking the cache,
107
+ * optionally distilling (compressing) the text, grounding it with workspace context, and resolving
108
+ * ambiguities using a small language model.
109
+ *
110
+ * @param {string} text - The raw skill/prompt text received from the client.
111
+ * @param {string} task - A description of the current task for context during distillation and resolution.
112
+ * @param {string} [rootUri] - Optional URI of the workspace root to enable grounding and file context extraction.
113
+ * @returns {Promise<string>} The conditioned and enriched prompt ready for the cloud model.
114
+ */
115
+ export async function conditionPrompt(text, task, rootUri, toolName, args) {
116
+ const startTime = Date.now();
117
+ if (!slmClient) {
118
+ slmClient = createSlmClient();
119
+ console.error(`[pipeline] Initialized SLM with OLLAMA_HOST=${CONFIG.OLLAMA_HOST}`);
120
+ }
121
+ // 1. Cache Check
122
+ // PROMPT_VERSION participates in the key. It is documented in .env.example as the lever to
123
+ // bump when prompt logic changes.
124
+ const hash = crypto.createHash('sha256')
125
+ .update(text + '||' + task + '||' + (rootUri || '') + '||' + (toolName || '') + '||' + CONFIG.PROMPT_VERSION)
126
+ .digest('hex');
127
+ const cacheKey = `condition_${hash}`;
128
+ const cached = cacheGet(cacheKey);
129
+ if (cached) {
130
+ // A cache hit is the BEST outcome — full compression at zero SLM compute — yet it used
131
+ // to return before any writeEvent, making it invisible to every metric and biasing all
132
+ // savings figures toward the slow path.
133
+ emitConditionEvent({ text, conditioned: cached, args, startTime, cacheHit: 'exact' });
134
+ return cached;
135
+ }
136
+ if (CONFIG.SEMCACHE) {
137
+ const semCached = await checkSemanticCache(text);
138
+ if (semCached && typeof semCached === 'string') {
139
+ emitConditionEvent({ text, conditioned: semCached, args, startTime, cacheHit: 'semantic' });
140
+ return semCached;
141
+ }
142
+ }
143
+ const preserveList = await getPreserveList();
144
+ // 2. Distill (the compressor is shared with the model gate; see compressNarrativeRun)
145
+ const slmFunc = (t, taskDesc) => compressNarrativeRun({ slm: slmClient, text: t, task: taskDesc });
146
+ const startDistill = Date.now();
147
+ let conditioned = text;
148
+ let distillErrorKind = null;
149
+ try {
150
+ conditioned = await distillToolResult(slmFunc, text, task, toolName, args, preserveList);
151
+ }
152
+ catch (err) {
153
+ handleSlmError(err, 'pipeline:distill', CONFIG.SLM_GATE_MODEL);
154
+ distillErrorKind = classifySlmError(err);
155
+ conditioned = text;
156
+ }
157
+ console.error(`[pipeline] distill ${((Date.now() - startDistill) / 1000).toFixed(1)}s`);
158
+ // 3. Ground
159
+ let groundCtx = '';
160
+ if (rootUri) {
161
+ groundCtx = await scan(rootUri);
162
+ }
163
+ // 4. Clarify (Resolver)
164
+ let fsReadFn = async (pattern) => [];
165
+ if (rootUri) {
166
+ let rootPath = rootUri;
167
+ if (rootUri.startsWith('file://')) {
168
+ try {
169
+ rootPath = fileURLToPath(rootUri);
170
+ }
171
+ catch {
172
+ rootPath = rootUri.substring(7);
173
+ }
174
+ }
175
+ fsReadFn = async (pattern) => {
176
+ try {
177
+ const content = await fs.readFile(path.join(rootPath, pattern), 'utf-8');
178
+ // Single `\n`: splitting on the two characters backslash-n never matched, so the whole
179
+ // file arrived as ONE element and the 50-line cap silently did nothing.
180
+ return content.split('\n').slice(0, 50);
181
+ }
182
+ catch {
183
+ return [];
184
+ }
185
+ };
186
+ }
187
+ const startResolver = Date.now();
188
+ let resolveOut = { autoApplied: [], askUser: [] };
189
+ let resolverErrorKind = null;
190
+ try {
191
+ resolveOut = await resolveAmbiguities(slmClient, fsReadFn, {
192
+ skillText: text,
193
+ task,
194
+ repoRoot: rootUri ? (rootUri.startsWith('file://') ? fileURLToPath(rootUri) : rootUri) : undefined
195
+ });
196
+ }
197
+ catch (err) {
198
+ handleSlmError(err, 'pipeline:resolver', CONFIG.SLM_BRAIN_MODEL);
199
+ resolverErrorKind = classifySlmError(err);
200
+ }
201
+ console.error(`[pipeline] resolver ${((Date.now() - startResolver) / 1000).toFixed(1)}s`);
202
+ // Append findings to the conditioned output.
203
+ // NOTE: single `\n` inside these template literals. `\\n` emits the two characters backslash-n,
204
+ // so every appended section used to arrive at the cloud model as one unbroken line littered
205
+ // with literal "\n" — markdown the model then had to read through.
206
+ if (groundCtx) {
207
+ conditioned += `\n\n# Environment Context\n${groundCtx}`;
208
+ }
209
+ if (resolveOut.autoApplied.length > 0) {
210
+ conditioned += `\n\n# Auto-Resolved Decisions\n`;
211
+ for (const res of resolveOut.autoApplied) {
212
+ conditioned += `- **${res.question}**: ${res.answer}\n`;
213
+ }
214
+ }
215
+ if (resolveOut.askUser.length > 0) {
216
+ conditioned += `\n\n# Pending Clarifications (Ask User)\n`;
217
+ for (const ask of resolveOut.askUser) {
218
+ conditioned += `- **${ask.question}** (Recommendation: ${ask.recommendedAnswer || 'None'})\n`;
219
+ }
220
+ }
221
+ // Report the ACTUAL failure. Both stages previously reported "timed out" for every error
222
+ // class, so a model that simply could not produce valid JSON was indistinguishable from a
223
+ // genuine timeout — the difference between raising SLM_TIMEOUT_MS and changing the model.
224
+ if (distillErrorKind) {
225
+ conditioned += `\n\n# Note\n[distill_${distillErrorKind}] ${SLM_ERROR_NOTES[distillErrorKind]}`;
226
+ }
227
+ if (resolverErrorKind) {
228
+ conditioned += `\n\n# Note\n[resolver_${resolverErrorKind}] ${SLM_ERROR_NOTES[resolverErrorKind]}`;
229
+ }
230
+ // 5. Ledger
231
+ emitConditionEvent({
232
+ text,
233
+ conditioned,
234
+ args,
235
+ startTime,
236
+ errorKinds: { distill: distillErrorKind, resolver: resolverErrorKind }
237
+ });
238
+ // 6. Cache Set.
239
+ //
240
+ // Previously this required BOTH stages to succeed. The resolver fails on essentially
241
+ // every call in practice, so the cache was never written and every identical tool call
242
+ // re-ran the full distil + resolve pipeline from scratch. Distillation is the expensive,
243
+ // valuable half and its result is correct on its own, so a resolver failure alone no
244
+ // longer blocks caching.
245
+ if (!distillErrorKind) {
246
+ cacheSet(cacheKey, conditioned);
247
+ if (CONFIG.SEMCACHE) {
248
+ await setSemanticCache(text, conditioned);
249
+ }
250
+ }
251
+ return conditioned;
252
+ }