@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.
- package/.env.example +669 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/configs/antigravity/.env.16gb.example +674 -0
- package/configs/antigravity/.env.24gb.example +674 -0
- package/configs/antigravity/.env.32gb.example +674 -0
- package/configs/antigravity/README.md +109 -0
- package/configs/claude-code/.env.16gb.example +674 -0
- package/configs/claude-code/.env.24gb.example +674 -0
- package/configs/claude-code/.env.32gb.example +674 -0
- package/configs/claude-code/README.md +52 -0
- package/configs/claude-desktop/.env.16gb.example +674 -0
- package/configs/claude-desktop/.env.24gb.example +674 -0
- package/configs/claude-desktop/.env.32gb.example +674 -0
- package/configs/claude-desktop/README.md +37 -0
- package/configs/cline-continue-opencode/.env.16gb.example +674 -0
- package/configs/cline-continue-opencode/.env.24gb.example +674 -0
- package/configs/cline-continue-opencode/.env.32gb.example +674 -0
- package/configs/cline-continue-opencode/README.md +34 -0
- package/configs/cursor/.env.16gb.example +674 -0
- package/configs/cursor/.env.24gb.example +674 -0
- package/configs/cursor/.env.32gb.example +674 -0
- package/configs/cursor/README.md +26 -0
- package/configs/generic-http/.env.16gb.example +674 -0
- package/configs/generic-http/.env.24gb.example +674 -0
- package/configs/generic-http/.env.32gb.example +674 -0
- package/configs/generic-http/README.md +20 -0
- package/configs/generic-stdio/.env.16gb.example +674 -0
- package/configs/generic-stdio/.env.24gb.example +674 -0
- package/configs/generic-stdio/.env.32gb.example +674 -0
- package/configs/generic-stdio/README.md +24 -0
- package/configs/preserve/README.md +26 -0
- package/configs/preserve/tls.json +61 -0
- package/dist/adapters/tech-lead-stack.js +38 -0
- package/dist/cache/index.js +173 -0
- package/dist/cli.js +256 -0
- package/dist/config.js +255 -0
- package/dist/dashboard/data.js +149 -0
- package/dist/dashboard/export.js +42 -0
- package/dist/dashboard/serve.js +63 -0
- package/dist/doctor.js +338 -0
- package/dist/hardware.js +126 -0
- package/dist/home-dir.js +39 -0
- package/dist/ledger/flush-lifecycle.js +50 -0
- package/dist/ledger/index.js +946 -0
- package/dist/ledger/report.js +69 -0
- package/dist/ledger/setup-dashboard.js +456 -0
- package/dist/ledger/smoke.js +37 -0
- package/dist/ledger/sync-config.js +177 -0
- package/dist/ledger/sync.js +307 -0
- package/dist/ledger/verify.js +185 -0
- package/dist/ledger/wipe-langfuse.js +130 -0
- package/dist/llm-gate/distill.js +239 -0
- package/dist/llm-gate/formats/anthropic.js +185 -0
- package/dist/llm-gate/formats/chat-completions.js +103 -0
- package/dist/llm-gate/formats/contract.js +29 -0
- package/dist/llm-gate/formats/gemini.js +84 -0
- package/dist/llm-gate/formats/internal.js +1 -0
- package/dist/llm-gate/formats/openai.js +77 -0
- package/dist/llm-gate/formats/responses.js +146 -0
- package/dist/llm-gate/forward.js +150 -0
- package/dist/llm-gate/index.js +40 -0
- package/dist/llm-gate/local-first.js +217 -0
- package/dist/llm-gate/pipeline.js +267 -0
- package/dist/llm-gate/server.js +289 -0
- package/dist/mcp-gate/ground.js +64 -0
- package/dist/mcp-gate/index.js +57 -0
- package/dist/mcp-gate/pipeline.js +252 -0
- package/dist/mcp-gate/server.js +302 -0
- package/dist/mcp-gate/tool-names.js +57 -0
- package/dist/models/check.js +26 -0
- package/dist/models/footprint.js +137 -0
- package/dist/models/helpers.js +91 -0
- package/dist/models/index.js +5 -0
- package/dist/models/reasoning.js +91 -0
- package/dist/models/roles.js +9 -0
- package/dist/models/slm.js +243 -0
- package/dist/models/types.js +1 -0
- package/dist/pricing/index.js +115 -0
- package/dist/pricing/plans.js +54 -0
- package/dist/pricing/providers.js +172 -0
- package/dist/resolver/index.js +277 -0
- package/dist/resolver/types.js +1 -0
- package/dist/setup/claim.js +41 -0
- package/dist/setup/gate-command.js +41 -0
- package/dist/setup/init.js +92 -0
- package/dist/setup/local-models.js +123 -0
- package/dist/setup/model-gate.js +220 -0
- package/dist/setup/notify.js +45 -0
- package/dist/setup/ollama-install.js +53 -0
- package/dist/setup/parent-watch.js +84 -0
- package/dist/setup/required-models.js +20 -0
- package/dist/setup/startup.js +132 -0
- package/dist/setup/tool-settings.js +101 -0
- package/dist/utils/backoff.js +47 -0
- package/dist/utils/compression.js +145 -0
- package/dist/utils/constants.js +22 -0
- package/dist/utils/duration.js +43 -0
- package/dist/utils/elision.js +556 -0
- package/dist/utils/embedding.js +32 -0
- package/dist/utils/entry-point.js +23 -0
- package/dist/utils/local-only.js +82 -0
- package/dist/utils/preserve-patterns.js +115 -0
- package/dist/utils/safety.js +30 -0
- package/dist/verifier/index.js +67 -0
- package/package.json +121 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResultSchema, CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import { CONFIG } from '../config.js';
|
|
9
|
+
import { isLocalRequest, listenOnThisComputer } from '../utils/local-only.js';
|
|
10
|
+
import { conditionPrompt } from './pipeline.js';
|
|
11
|
+
import { buildGateInstructions, rewriteToolReferences } from './tool-names.js';
|
|
12
|
+
/**
|
|
13
|
+
* @param options.notices Set-up problems from the start-up checks, passed to the AI in the instructions
|
|
14
|
+
*/
|
|
15
|
+
export async function createServer(options = {}) {
|
|
16
|
+
let rootUri;
|
|
17
|
+
let downstreamClient;
|
|
18
|
+
// Tools the connected toolbox serves. Learned at connect time and refreshed on every
|
|
19
|
+
// tools/list, so any toolbox works without toolbox-specific configuration.
|
|
20
|
+
let downstreamToolNames = [];
|
|
21
|
+
// Standalone mode has no toolbox to describe, but still passes on set-up notices.
|
|
22
|
+
let instructions = buildGateInstructions({ toolNames: [], notices: options.notices });
|
|
23
|
+
// We intercept initialize via transport.onmessage below to capture rootUri without breaking SDK logic.
|
|
24
|
+
if (CONFIG.DOWNSTREAM_MCP) {
|
|
25
|
+
// Proxy mode. Connect to the toolbox BEFORE creating our server: the instructions we
|
|
26
|
+
// hand the editor are fixed at construction and are built from what the toolbox reports.
|
|
27
|
+
downstreamClient = new Client({ name: "mcp-gate-proxy", version: "1.0.0" }, { capabilities: {} });
|
|
28
|
+
let transport;
|
|
29
|
+
if (CONFIG.DOWNSTREAM_MCP.command) {
|
|
30
|
+
transport = new StdioClientTransport({
|
|
31
|
+
command: CONFIG.DOWNSTREAM_MCP.command,
|
|
32
|
+
args: CONFIG.DOWNSTREAM_MCP.args || [],
|
|
33
|
+
env: { ...process.env, ...(CONFIG.DOWNSTREAM_MCP.env || {}) }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
else if (CONFIG.DOWNSTREAM_MCP.url) {
|
|
37
|
+
transport = new StreamableHTTPClientTransport(new URL(CONFIG.DOWNSTREAM_MCP.url));
|
|
38
|
+
}
|
|
39
|
+
if (transport) {
|
|
40
|
+
await downstreamClient.connect(transport);
|
|
41
|
+
const { tools } = await downstreamClient.request({ method: "tools/list" }, ListToolsResultSchema);
|
|
42
|
+
downstreamToolNames = (tools || []).map(t => t.name);
|
|
43
|
+
instructions = buildGateInstructions({
|
|
44
|
+
toolNames: downstreamToolNames,
|
|
45
|
+
downstreamInstructions: downstreamClient.getInstructions(),
|
|
46
|
+
notices: options.notices,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const server = new Server({
|
|
51
|
+
name: "small-language-model-gate",
|
|
52
|
+
version: "1.0.0",
|
|
53
|
+
}, {
|
|
54
|
+
capabilities: {
|
|
55
|
+
tools: {},
|
|
56
|
+
},
|
|
57
|
+
instructions,
|
|
58
|
+
});
|
|
59
|
+
if (CONFIG.DOWNSTREAM_MCP) {
|
|
60
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
61
|
+
let tools = [];
|
|
62
|
+
if (downstreamClient) {
|
|
63
|
+
const res = await downstreamClient.request({ method: "tools/list" }, ListToolsResultSchema);
|
|
64
|
+
tools = res.tools || [];
|
|
65
|
+
downstreamToolNames = tools.map(t => t.name);
|
|
66
|
+
}
|
|
67
|
+
// Advertise expand_elision
|
|
68
|
+
tools.push({
|
|
69
|
+
name: "expand_elision",
|
|
70
|
+
description: "Expand a previously elided block of text using its elisionId",
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: {
|
|
74
|
+
elisionId: { type: "string" },
|
|
75
|
+
range: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
startLine: { type: "number" },
|
|
79
|
+
endLine: { type: "number" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
required: ["elisionId"]
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return { tools };
|
|
87
|
+
});
|
|
88
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
89
|
+
const name = request.params.name;
|
|
90
|
+
const args = request.params.arguments;
|
|
91
|
+
const task = typeof args?.task === 'string' ? args.task : 'Unknown task';
|
|
92
|
+
if (name === "expand_elision") {
|
|
93
|
+
const elisionId = args?.elisionId;
|
|
94
|
+
const range = args?.range;
|
|
95
|
+
if (!elisionId)
|
|
96
|
+
throw new Error("elisionId is required");
|
|
97
|
+
// Dynamic imports to prevent circular dependencies at boot
|
|
98
|
+
const { getElision, writeElision, writeDistillFeedback, writeEvent } = await import('../ledger/index.js');
|
|
99
|
+
const { formatElisionMarker, pageLines } = await import('../utils/elision.js');
|
|
100
|
+
const { embedText, float64ArrayToBuffer } = await import('../utils/embedding.js');
|
|
101
|
+
const crypto = await import('node:crypto');
|
|
102
|
+
const record = getElision(elisionId);
|
|
103
|
+
if (record) {
|
|
104
|
+
// Fire-and-forget: Embed the text the user explicitly wanted expanded.
|
|
105
|
+
// This populates the Adaptive Feedback DB, teaching the engine to preserve
|
|
106
|
+
// semantically similar lines in future compressions.
|
|
107
|
+
embedText(record.original_text).then(emb => {
|
|
108
|
+
if (emb) {
|
|
109
|
+
writeDistillFeedback({
|
|
110
|
+
id: `fd_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
|
111
|
+
tool_name: record.tool_name,
|
|
112
|
+
skill: '',
|
|
113
|
+
content_hash: record.content_hash,
|
|
114
|
+
region_text: record.original_text,
|
|
115
|
+
embedding_blob: float64ArrayToBuffer(emb),
|
|
116
|
+
signal: 1
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
// Log the manual expansion for analytics/billing
|
|
121
|
+
writeEvent({
|
|
122
|
+
ts: new Date().toISOString(),
|
|
123
|
+
layer: 'mcp',
|
|
124
|
+
request_id: `fb_${crypto.randomUUID()}`,
|
|
125
|
+
// Its own route: this is a user feedback signal, not a conditioning event.
|
|
126
|
+
// As 'condition' it polluted route counts and collected a free accuracy score.
|
|
127
|
+
route: 'feedback',
|
|
128
|
+
is_local_call: 0,
|
|
129
|
+
api_model: args?.model ? String(args.model) : undefined,
|
|
130
|
+
agent: args?.agent ? String(args.agent) : undefined,
|
|
131
|
+
api_in_tok: 0,
|
|
132
|
+
api_out_tok: 0,
|
|
133
|
+
in_tok: 0,
|
|
134
|
+
out_tok: 0,
|
|
135
|
+
cost_usd: 0,
|
|
136
|
+
slm_latency_s: 0,
|
|
137
|
+
api_latency_s: 0,
|
|
138
|
+
slm_gate: 'on',
|
|
139
|
+
meta: JSON.stringify({ type: 'distill_feedback', elision_id: elisionId, action: 'expand' })
|
|
140
|
+
});
|
|
141
|
+
// If it's a file read, we should theoretically re-read if hash changed, but we can't easily read here without downstreamClient calling the exact same tool.
|
|
142
|
+
// Let's check if downstreamClient is available to re-run
|
|
143
|
+
let textToReturn = record.original_text;
|
|
144
|
+
if (downstreamClient && ['read_file', 'view_file'].some(t => record.tool_name.includes(t))) {
|
|
145
|
+
try {
|
|
146
|
+
const result = await downstreamClient.request({
|
|
147
|
+
method: "tools/call",
|
|
148
|
+
params: { name: record.tool_name, arguments: JSON.parse(record.args) }
|
|
149
|
+
}, CallToolResultSchema);
|
|
150
|
+
let newerText = '';
|
|
151
|
+
if (result.content && Array.isArray(result.content)) {
|
|
152
|
+
const textBlock = result.content.find((c) => c.type === 'text');
|
|
153
|
+
if (textBlock && typeof textBlock.text === 'string') {
|
|
154
|
+
newerText = textBlock.text;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const newHash = crypto.createHash('sha256').update(newerText).digest('hex');
|
|
158
|
+
if (newHash !== record.content_hash) {
|
|
159
|
+
textToReturn = newerText;
|
|
160
|
+
// Update cache with new text
|
|
161
|
+
writeElision({ ...record, original_text: newerText, content_hash: newHash, size_bytes: Buffer.byteLength(newerText) });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
// ignore
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const lines = textToReturn.split('\n');
|
|
169
|
+
const hasRange = range && typeof range.startLine === 'number' && typeof range.endLine === 'number';
|
|
170
|
+
// No range means the whole original, from the top.
|
|
171
|
+
const startLine = hasRange ? Math.max(0, range.startLine) : 0;
|
|
172
|
+
const endLine = hasRange ? Math.min(lines.length - 1, range.endLine) : lines.length - 1;
|
|
173
|
+
// The caller asked for these exact lines, so never cut out the middle of them again:
|
|
174
|
+
// that made a large region impossible to retrieve. Return them in order, one
|
|
175
|
+
// budget-sized page at a time, and say exactly which range to ask for next.
|
|
176
|
+
const page = pageLines({ lines, startLine, endLine, maxTokens: CONFIG.DISTILL_MAX_TOKENS || 2000 });
|
|
177
|
+
const expandedText = page.nextStart === undefined
|
|
178
|
+
? page.text
|
|
179
|
+
: page.text + formatElisionMarker(elisionId, endLine - page.nextStart + 1, page.nextStart, endLine);
|
|
180
|
+
return { content: [{ type: "text", text: rewriteToolReferences(expandedText, downstreamToolNames) }] };
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
// Missing or expired, try to re-run if we have args
|
|
184
|
+
if (!downstreamClient)
|
|
185
|
+
throw new Error("Elision not found and no downstream client to re-run.");
|
|
186
|
+
// We don't have tool_name/args if it's missing from cache and user only provided elisionId.
|
|
187
|
+
throw new Error(`Elision ${elisionId} not found in cache. Cannot recover without original tool arguments.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!downstreamClient)
|
|
191
|
+
throw new Error("No downstream client");
|
|
192
|
+
// Pass request unchanged downstream
|
|
193
|
+
const result = await downstreamClient.request({
|
|
194
|
+
method: "tools/call",
|
|
195
|
+
params: request.params
|
|
196
|
+
}, CallToolResultSchema);
|
|
197
|
+
const textBlocks = result.content.filter((block) => block.type === 'text');
|
|
198
|
+
if (textBlocks.length === 0)
|
|
199
|
+
return result;
|
|
200
|
+
const conditioned = await conditionPrompt(textBlocks.map(block => block.text).join('\n\n'), task, rootUri, name, args);
|
|
201
|
+
// The conditioned text takes the first text block's place; later text blocks are part of it.
|
|
202
|
+
const content = result.content.flatMap((block) => {
|
|
203
|
+
if (block === textBlocks[0])
|
|
204
|
+
return [{ ...block, text: rewriteToolReferences(conditioned, downstreamToolNames) }];
|
|
205
|
+
return block.type === 'text' ? [] : [block];
|
|
206
|
+
});
|
|
207
|
+
return { ...result, content };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
// Standalone mode
|
|
212
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
213
|
+
return {
|
|
214
|
+
tools: [
|
|
215
|
+
{
|
|
216
|
+
name: "condition_prompt",
|
|
217
|
+
description: "Condense and condition a prompt using a local SLM",
|
|
218
|
+
inputSchema: {
|
|
219
|
+
type: "object",
|
|
220
|
+
properties: {
|
|
221
|
+
text: { type: "string" },
|
|
222
|
+
task: { type: "string" }
|
|
223
|
+
},
|
|
224
|
+
required: ["text", "task"]
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
231
|
+
if (request.params.name === "condition_prompt") {
|
|
232
|
+
const text = String(request.params.arguments?.text || '');
|
|
233
|
+
const task = String(request.params.arguments?.task || '');
|
|
234
|
+
const conditioned = await conditionPrompt(text, task, rootUri);
|
|
235
|
+
return {
|
|
236
|
+
content: [{ type: "text", text: conditioned }]
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
throw new Error(`Tool not found: ${request.params.name}`);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/** Resolves once serving; in HTTP mode with the listening servers (tests close them), else with none. */
|
|
243
|
+
const start = async () => {
|
|
244
|
+
const sinks = ['sqlite'];
|
|
245
|
+
if (CONFIG.LANGFUSE_PUBLIC_KEY && CONFIG.LANGFUSE_SECRET_KEY && CONFIG.LANGFUSE_HOST) {
|
|
246
|
+
sinks.push('langfuse');
|
|
247
|
+
}
|
|
248
|
+
const sinksStr = `sinks: [${sinks.join(', ')}]`;
|
|
249
|
+
if (CONFIG.MCP_GATE_TRANSPORT === 'stdio') {
|
|
250
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
251
|
+
process.stdout.write = process.stderr.write.bind(process.stderr);
|
|
252
|
+
console.error(`[mcp-gate] Stdio server starting. ${sinksStr}`);
|
|
253
|
+
const transport = new StdioServerTransport();
|
|
254
|
+
const origStart = transport.start.bind(transport);
|
|
255
|
+
transport.start = async () => {
|
|
256
|
+
const origOnmessage = transport.onmessage;
|
|
257
|
+
if (origOnmessage) {
|
|
258
|
+
transport.onmessage = (message) => {
|
|
259
|
+
if (message?.method === 'initialize') {
|
|
260
|
+
const rootsCap = message.params?.capabilities?.roots;
|
|
261
|
+
// Check if roots is passed directly in capabilities (older clients)
|
|
262
|
+
if (Array.isArray(rootsCap) && rootsCap.length > 0 && rootsCap[0]?.uri) {
|
|
263
|
+
rootUri = rootsCap[0].uri;
|
|
264
|
+
}
|
|
265
|
+
else if (message.params?.rootUri) {
|
|
266
|
+
// Fallback for non-compliant clients passing top-level rootUri
|
|
267
|
+
rootUri = message.params.rootUri;
|
|
268
|
+
}
|
|
269
|
+
console.error(`[mcp-gate] rootUri: ${rootUri || 'none'}`);
|
|
270
|
+
}
|
|
271
|
+
origOnmessage(message);
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
await origStart();
|
|
275
|
+
};
|
|
276
|
+
await server.connect(transport);
|
|
277
|
+
process.stdout.write = originalStdoutWrite;
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const { randomUUID } = await import('node:crypto');
|
|
282
|
+
const transport = new StreamableHTTPServerTransport({
|
|
283
|
+
sessionIdGenerator: () => randomUUID()
|
|
284
|
+
});
|
|
285
|
+
await server.connect(transport);
|
|
286
|
+
// This server's tools can read and patch files: only programs on this computer may call them.
|
|
287
|
+
return listenOnThisComputer({
|
|
288
|
+
handler: (req, res) => {
|
|
289
|
+
if (!isLocalRequest(req.headers)) {
|
|
290
|
+
res.writeHead(403, { 'content-type': 'application/json' });
|
|
291
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'slm-gate only accepts requests from programs on this computer' }, id: null }));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
transport.handleRequest(req, res);
|
|
295
|
+
},
|
|
296
|
+
port: CONFIG.MCP_GATE_PORT,
|
|
297
|
+
onListening: () => console.error(`[mcp-gate] HTTP Streamable server running on port ${CONFIG.MCP_GATE_PORT}, for programs on this computer only. ${sinksStr}`),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
return { server, start };
|
|
302
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Makes any downstream toolbox's tool names resolve to slm-gate.
|
|
3
|
+
*
|
|
4
|
+
* A toolbox usually refers to its own tools by the name its installer registered them under.
|
|
5
|
+
* Tech-Lead-Stack's slash commands, for example, say `mcp__tech-lead-stack__get_skills`.
|
|
6
|
+
* Behind slm-gate those tools are served by the gate, so that name does not exist in the
|
|
7
|
+
* editor. The gate learns the toolbox's tool list when it connects and uses it to
|
|
8
|
+
* (1) tell the editor which tools it serves, and (2) rewrite prefixed references to those
|
|
9
|
+
* tools in results. Nothing here is specific to any one toolbox.
|
|
10
|
+
*/
|
|
11
|
+
/** The name slm-gate is registered under in the editor (see docs/setup.md → Step 4). */
|
|
12
|
+
export const GATE_MCP_NAME = 'slm-gate';
|
|
13
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
14
|
+
/**
|
|
15
|
+
* Rewrites `mcp__<any server>__<tool>` (and the single-underscore `mcp_<server>_<tool>` form)
|
|
16
|
+
* to `mcp__slm-gate__<tool>`, but only for tools the connected toolbox actually serves.
|
|
17
|
+
*
|
|
18
|
+
* @param text Text returned to the editor
|
|
19
|
+
* @param toolNames Tool names served by the connected toolbox
|
|
20
|
+
* @returns The text with those tool references pointing at slm-gate
|
|
21
|
+
*/
|
|
22
|
+
export function rewriteToolReferences(text, toolNames) {
|
|
23
|
+
if (!text || toolNames.length === 0)
|
|
24
|
+
return text;
|
|
25
|
+
// Longest first, so `get_skill` can never match inside `get_skills`.
|
|
26
|
+
const tools = [...toolNames].sort((a, b) => b.length - a.length).map(escapeRegExp).join('|');
|
|
27
|
+
// Server names are matched without underscores, so the tool name must start right after
|
|
28
|
+
// the separator: `mcp__foo__bar_get_skill` is NOT read as tool `get_skill`.
|
|
29
|
+
const pattern = new RegExp(`\\bmcp__?[A-Za-z0-9][A-Za-z0-9-]*__?(${tools})\\b`, 'g');
|
|
30
|
+
return text.replace(pattern, (_match, tool) => `mcp__${GATE_MCP_NAME}__${tool}`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Builds the instructions the gate hands the editor when it connects.
|
|
34
|
+
*
|
|
35
|
+
* @param params.toolNames Tool names served by the connected toolbox
|
|
36
|
+
* @param params.downstreamInstructions The toolbox's own instructions, passed through
|
|
37
|
+
* @param params.notices Set-up problems found at start-up (src/setup/startup.ts), passed on to the user
|
|
38
|
+
* @returns Instructions text, or undefined when there is nothing to say
|
|
39
|
+
*/
|
|
40
|
+
export function buildGateInstructions(params) {
|
|
41
|
+
const { toolNames, downstreamInstructions, notices = [] } = params;
|
|
42
|
+
const parts = [];
|
|
43
|
+
if (notices.length > 0) {
|
|
44
|
+
// These instructions are fixed when the server starts and cannot be changed afterwards, so they are
|
|
45
|
+
// written as what was true at start-up rather than as a live fact, and the AI is told it may be out
|
|
46
|
+
// of date. Without that, one bad check at start-up has the AI repeating it for the whole session.
|
|
47
|
+
parts.push(`${GATE_MCP_NAME} notice — what slm-gate found when it started. Tell the user about this at the start of your next reply, word for word:`, ...notices.map(notice => `- ${notice.message} Fix: ${notice.fix}`), 'This was checked once, when slm-gate started, and is not rechecked in these instructions. If the user shows you it is already working, they are right and this notice is stale.', '');
|
|
48
|
+
}
|
|
49
|
+
if (toolNames.length > 0) {
|
|
50
|
+
parts.push(`This server (${GATE_MCP_NAME}) serves the tools of the toolbox connected behind it: ${toolNames.join(', ')}.`, `Commands, skills or docs may refer to these tools under another server name, for example \`mcp__<toolbox>__${toolNames[0]}\`. ` +
|
|
51
|
+
`They mean the tool of the same name on this server: call \`mcp__${GATE_MCP_NAME}__<tool>\` instead.`);
|
|
52
|
+
}
|
|
53
|
+
if (downstreamInstructions?.trim()) {
|
|
54
|
+
parts.push('', 'Instructions from the connected toolbox:', downstreamInstructions.trim());
|
|
55
|
+
}
|
|
56
|
+
return parts.length > 0 ? parts.join('\n') : undefined;
|
|
57
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { footprintReport, warmup } from './footprint.js';
|
|
2
|
+
import { handleSlmError } from './helpers.js';
|
|
3
|
+
import { isEntryPoint } from '../utils/entry-point.js';
|
|
4
|
+
async function check() {
|
|
5
|
+
try {
|
|
6
|
+
await warmup();
|
|
7
|
+
await footprintReport();
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
if (err.name === 'SlmTimeoutError' || err.message?.includes('fetch failed') || err.code === 'ECONNREFUSED' || err.message?.includes('ECONNREFUSED')) {
|
|
11
|
+
handleSlmError(err, 'models:check', 'unknown (check config)');
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
if (err instanceof Error) {
|
|
15
|
+
console.error(`Error: ${err.message}`);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.error(`Error: ${String(err)}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (isEntryPoint(import.meta.url)) {
|
|
25
|
+
check();
|
|
26
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import ollama from 'ollama';
|
|
2
|
+
import { CONFIG } from '../config.js';
|
|
3
|
+
import { detectHardware, MIN_RAM_GB, modelsForRam, recommendPreset } from '../hardware.js';
|
|
4
|
+
/**
|
|
5
|
+
* Warms up the provided SLM models by sending a single, short prompt.
|
|
6
|
+
* This forces the Ollama backend to load the model into memory, reducing latency on subsequent requests.
|
|
7
|
+
*
|
|
8
|
+
* @param client - The Ollama client instance (defaults to the global imported ollama instance).
|
|
9
|
+
* @returns A promise that resolves when the warmup chats are dispatched (or fail gracefully).
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // Warms up models configured in SLM_BRAIN_MODEL and SLM_GATE_MODEL
|
|
13
|
+
* await warmup();
|
|
14
|
+
*/
|
|
15
|
+
export async function warmup(client = ollama) {
|
|
16
|
+
const modelsToWarm = new Set([CONFIG.SLM_BRAIN_MODEL, CONFIG.SLM_GATE_MODEL]);
|
|
17
|
+
try {
|
|
18
|
+
const listResponse = await client.list();
|
|
19
|
+
const availableModels = listResponse.models || [];
|
|
20
|
+
for (const model of modelsToWarm) {
|
|
21
|
+
if (!availableModels.some(m => m.name === model || m.model === model)) {
|
|
22
|
+
console.warn(`not pulled — run ollama pull ${model}`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
try {
|
|
26
|
+
await client.chat({
|
|
27
|
+
model: model,
|
|
28
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
29
|
+
options: { num_predict: 1 }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
// Ignore error during warmup chat
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
console.error('Failed to connect to Ollama for warmup:', err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Fetches the footprint (size in bytes) of specific models from the Ollama backend.
|
|
44
|
+
*
|
|
45
|
+
* @param models - An array of model strings to check (e.g., ['qwen3:14b', 'qwen3:1.7b']).
|
|
46
|
+
* @param client - The Ollama client instance (defaults to the global imported ollama instance).
|
|
47
|
+
* @returns A promise that resolves to an object mapping each model name to its size in bytes.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* const sizes = await getModelsFootprint(['qwen3:14b']);
|
|
51
|
+
* console.log(`Size: ${sizes['qwen3:14b']} bytes`);
|
|
52
|
+
*/
|
|
53
|
+
export async function getModelsFootprint(models, client = ollama) {
|
|
54
|
+
const result = {};
|
|
55
|
+
try {
|
|
56
|
+
const listResponse = await client.list();
|
|
57
|
+
const availableModels = listResponse.models || [];
|
|
58
|
+
for (const model of models) {
|
|
59
|
+
const found = availableModels.find(m => m.name === model || m.model === model);
|
|
60
|
+
if (found && found.size) {
|
|
61
|
+
result[model] = found.size;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
// Ignore error
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Generates and prints a detailed footprint report of the configured SLM models.
|
|
72
|
+
* Used primarily for diagnostic purposes to ensure the user's models fit within their RAM budget.
|
|
73
|
+
*
|
|
74
|
+
* @param client - The Ollama client instance.
|
|
75
|
+
* @throws {Error} If the Ollama backend is completely unreachable.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* await footprintReport();
|
|
79
|
+
* // Outputs:
|
|
80
|
+
* // === SLM Footprint Report ===
|
|
81
|
+
* // - qwen3:14b: 8.50 GB
|
|
82
|
+
* // - qwen3:1.7b: 1.10 GB
|
|
83
|
+
* // Total footprint: 9.60 GB
|
|
84
|
+
* // Current RAM preset: custom
|
|
85
|
+
* //
|
|
86
|
+
* // --- Recommendations (24GB RAM Detected) ---
|
|
87
|
+
* // * Dedicated AI Node: ram-24 (Max capacity)
|
|
88
|
+
* // * Primary Workhorse: ram-16 (Leaves headroom for apps)
|
|
89
|
+
* // -----------------------------------------------
|
|
90
|
+
*/
|
|
91
|
+
export async function footprintReport(client = ollama) {
|
|
92
|
+
console.log(`\n=== SLM Footprint Report ===`);
|
|
93
|
+
const modelsToCheck = new Set([CONFIG.SLM_BRAIN_MODEL, CONFIG.SLM_GATE_MODEL]);
|
|
94
|
+
let totalBytes = 0;
|
|
95
|
+
try {
|
|
96
|
+
const listResponse = await client.list();
|
|
97
|
+
const availableModels = listResponse.models || [];
|
|
98
|
+
for (const model of modelsToCheck) {
|
|
99
|
+
const found = availableModels.find(m => m.name === model || m.model === model);
|
|
100
|
+
if (found && found.size) {
|
|
101
|
+
const sizeGB = (found.size / (1024 * 1024 * 1024)).toFixed(2);
|
|
102
|
+
console.log(`- ${model}: ${sizeGB} GB`);
|
|
103
|
+
totalBytes += found.size;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
console.log(`- ${model}: not pulled — run ollama pull ${model}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const totalGB = (totalBytes / (1024 * 1024 * 1024)).toFixed(2);
|
|
110
|
+
console.log(`Total footprint: ${totalGB} GB`);
|
|
111
|
+
console.log(`Current RAM preset: ${CONFIG.RAM_PRESET}`);
|
|
112
|
+
try {
|
|
113
|
+
const hw = detectHardware();
|
|
114
|
+
const workhorseRamGB = Math.max(MIN_RAM_GB, hw.totalRamGB - 8);
|
|
115
|
+
const dedicatedPreset = recommendPreset(hw.totalRamGB);
|
|
116
|
+
const workhorsePreset = recommendPreset(workhorseRamGB);
|
|
117
|
+
const dedicatedModels = modelsForRam(hw.totalRamGB);
|
|
118
|
+
const workhorseModels = modelsForRam(workhorseRamGB);
|
|
119
|
+
console.log(`\n--- Recommendations (${hw.totalRamGB}GB RAM Detected) ---\n`);
|
|
120
|
+
console.log(`OPTION A: Dedicated AI Node (Preset: ${dedicatedPreset})`);
|
|
121
|
+
console.log(`- Recommended Models: ${dedicatedModels.brain} (Brain) + ${dedicatedModels.gate} (Gate)`);
|
|
122
|
+
console.log(`- Target Workload: Machines where SLM-Gate is the primary running application.`);
|
|
123
|
+
console.log(`- Note: This maximizes AI capabilities but leaves minimal RAM for other heavy applications.\n`);
|
|
124
|
+
console.log(`OPTION B: Primary Workhorse (Preset: ${workhorsePreset})`);
|
|
125
|
+
console.log(`- Recommended Models: ${workhorseModels.brain} (Brain) + ${workhorseModels.gate} (Gate)`);
|
|
126
|
+
console.log(`- Target Workload: Daily driver machines running browsers, IDEs, or databases simultaneously.`);
|
|
127
|
+
console.log(`- Note: If you choose to run Option A's larger models on a workhorse machine, you MUST actively free up system RAM before starting. Otherwise, the OS will swap heavily, causing severe UI lag and slow token generation.`);
|
|
128
|
+
console.log(`-----------------------------------------------`);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// In case hardware detection fails gracefully
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
throw new Error('Could not fetch model sizes. Is Ollama running?');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents an error that occurs when the SLM returns a response that cannot be parsed
|
|
3
|
+
* or formatted correctly (e.g. invalid JSON when JSON is expected).
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* throw new SlmFormatError("Invalid JSON returned by the model");
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export class SlmFormatError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'SlmFormatError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Represents an error that occurs when a call to the SLM exceeds the configured timeout duration.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* throw new SlmTimeoutError("classification");
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export class SlmTimeoutError extends Error {
|
|
25
|
+
constructor(stage) {
|
|
26
|
+
super(`SLM call timed out during stage: ${stage}`);
|
|
27
|
+
this.name = 'SlmTimeoutError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Wraps a promise (usually an SLM generation call) with a timeout. If the promise does not
|
|
32
|
+
* resolve within `timeoutMs`, an `SlmTimeoutError` is thrown.
|
|
33
|
+
*
|
|
34
|
+
* @param promise - The asynchronous operation to wrap.
|
|
35
|
+
* @param stage - The name of the pipeline stage (e.g. 'distill', 'classify') used in the error message.
|
|
36
|
+
* @param timeoutMs - The maximum allowed time in milliseconds.
|
|
37
|
+
* @returns A promise that resolves with the original result if completed in time.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* const result = await withSlmTimeout(slm.generateText(model, prompt), 'generate', 15000);
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export async function withSlmTimeout(promise, stage, timeoutMs) {
|
|
45
|
+
let timer;
|
|
46
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
47
|
+
timer = setTimeout(() => reject(new SlmTimeoutError(stage)), timeoutMs);
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A centralized error handler for formatting and logging SLM failures.
|
|
58
|
+
* Handles timeouts and network connection issues by printing actionable remediation
|
|
59
|
+
* steps to the console, while falling back to standard error logging for other issues.
|
|
60
|
+
*
|
|
61
|
+
* @param err - The caught error object.
|
|
62
|
+
* @param stage - The stage of the pipeline where the error occurred.
|
|
63
|
+
* @param model - The name of the model being used.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* try {
|
|
68
|
+
* await slm.generateText(model, prompt);
|
|
69
|
+
* } catch (err) {
|
|
70
|
+
* handleSlmError(err, 'pipeline:distill', model);
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function handleSlmError(err, stage, model) {
|
|
75
|
+
const isTimeout = err instanceof SlmTimeoutError || err.name === 'SlmTimeoutError';
|
|
76
|
+
const isNetwork = err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED') || err.code === 'ECONNREFUSED';
|
|
77
|
+
if (isTimeout || isNetwork) {
|
|
78
|
+
const errorType = isTimeout ? 'Timeout' : 'Unreachable';
|
|
79
|
+
console.error(`\n[${stage}] ❌ SLM Error (${errorType}) - Model: ${model}`);
|
|
80
|
+
console.error(` Usually the model is too large for available RAM or is cold-loading.`);
|
|
81
|
+
console.error(` Fixes:`);
|
|
82
|
+
console.error(` 1. Switch to a smaller model (check SLM_BRAIN_MODEL/SLM_GATE_MODEL against the RAM table in docs/prerequisites-and-hardware.md).`);
|
|
83
|
+
console.error(` 2. Raise SLM_TIMEOUT_MS in your .env if it is just cold-loading.`);
|
|
84
|
+
console.error(` 3. Confirm the model is pulled ('ollama pull ${model}') and Ollama is running ('ollama list').`);
|
|
85
|
+
console.error(` 4. Free up system RAM.\n`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
console.error(`\n[${stage}] ❌ SLM Error - Model: ${model}`);
|
|
89
|
+
console.error(` ${err.message || String(err)}\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|