@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,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
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './roles.js';
3
+ export * from './slm.js';
4
+ export * from './footprint.js';
5
+ export * from './reasoning.js';