@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,123 @@
1
+ /**
2
+ * @fileoverview Are the local models slm-gate is configured to use actually there? Used by `slm-gate doctor`
3
+ * and by the MCP server's start-up check. It only reports, each problem with its exact fix; it never starts
4
+ * Ollama or downloads a model.
5
+ */
6
+ import { CONFIG } from '../config.js';
7
+ import { howToStartOllama } from './ollama-install.js';
8
+ import { modelsFor } from './required-models.js';
9
+ /** The models this process's settings use, and what each one is for. */
10
+ export function requiredModels() {
11
+ return modelsFor(CONFIG);
12
+ }
13
+ /** Ollama lists `name:tag`; a setting without a tag means `:latest`. */
14
+ function isPulled(name, pulled) {
15
+ return pulled.includes(name) || (!name.includes(':') && pulled.includes(`${name}:latest`));
16
+ }
17
+ /** Connection failures that mean nothing is listening, as opposed to a slow or wrong answer. */
18
+ const UNREACHABLE = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'ENETUNREACH', 'EAI_AGAIN', 'ECONNRESET']);
19
+ /** fetch reports the underlying error as `cause`, or as an AggregateError when it tried IPv6 and IPv4. */
20
+ function causeCodes(err) {
21
+ const cause = err?.cause;
22
+ if (cause instanceof AggregateError)
23
+ return cause.errors.map(one => one?.code ?? '');
24
+ const code = cause?.code;
25
+ return code ? [code] : [];
26
+ }
27
+ /**
28
+ * Asks Ollama what it has downloaded, and distinguishes the ways that can fail.
29
+ *
30
+ * @param params.timeoutMs How long to wait for an answer (default 3000)
31
+ */
32
+ export async function probeOllama(params = {}) {
33
+ const timeoutMs = params.timeoutMs ?? 3000;
34
+ const host = CONFIG.OLLAMA_HOST;
35
+ let url;
36
+ try {
37
+ // Keep any path prefix in OLLAMA_HOST (a reverse proxy may serve Ollama under one).
38
+ url = new URL(`${host.replace(/\/+$/, '')}/api/tags`);
39
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
40
+ throw new Error(`scheme ${url.protocol}`);
41
+ }
42
+ catch {
43
+ return { kind: 'bad-host', detail: host };
44
+ }
45
+ let res;
46
+ try {
47
+ res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
48
+ }
49
+ catch (err) {
50
+ // A timeout here can be self-inflicted: an AbortSignal armed before a busy stretch fires as soon as
51
+ // the event loop frees, sometimes before the request touches the network. Never read it as "down".
52
+ if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
53
+ return { kind: 'no-answer', detail: `no answer within ${timeoutMs} ms` };
54
+ }
55
+ const codes = causeCodes(err);
56
+ if (codes.some(code => UNREACHABLE.has(code)))
57
+ return { kind: 'unreachable', detail: codes.join(', ') };
58
+ return { kind: 'no-answer', detail: err instanceof Error ? err.message : String(err) };
59
+ }
60
+ if (!res.ok)
61
+ return { kind: 'not-ollama', detail: `answered ${res.status}` };
62
+ try {
63
+ const data = await res.json();
64
+ if (!Array.isArray(data.models))
65
+ return { kind: 'not-ollama', detail: 'answered without a model list' };
66
+ return { kind: 'ok', pulled: data.models.map(model => model.name) };
67
+ }
68
+ catch {
69
+ return { kind: 'not-ollama', detail: 'answered with something that is not JSON' };
70
+ }
71
+ }
72
+ /** The one problem to report when Ollama's model list could not be read. */
73
+ function probeProblem(probe) {
74
+ const host = CONFIG.OLLAMA_HOST;
75
+ switch (probe.kind) {
76
+ case 'unreachable':
77
+ return {
78
+ message: `Ollama is not running at ${host}, so nothing is answered or shrunk locally (requests still reach the cloud).`,
79
+ fix: howToStartOllama(),
80
+ };
81
+ case 'no-answer':
82
+ return {
83
+ message: `Ollama did not answer at ${host} (${probe.detail}), so slm-gate could not check the local models. This does not mean Ollama is down.`,
84
+ fix: 'Usually nothing: it is often just busy while everything starts. If it keeps happening, run `slm-gate doctor`.',
85
+ transient: true,
86
+ };
87
+ case 'bad-host':
88
+ return {
89
+ message: `OLLAMA_HOST is "${probe.detail}", which is not an http address, so slm-gate never asked Ollama anything.`,
90
+ fix: 'Set OLLAMA_HOST to a full URL, e.g. http://localhost:11434. Ollama\'s own OLLAMA_HOST variable is a bare host:port, but slm-gate needs the http:// in front.',
91
+ };
92
+ case 'not-ollama':
93
+ return {
94
+ message: `Something is listening at ${host} but it is not Ollama (${probe.detail}).`,
95
+ fix: 'Point OLLAMA_HOST at the address Ollama is really on, or stop the other program. `slm-gate doctor` names what holds the port.',
96
+ };
97
+ }
98
+ }
99
+ /**
100
+ * @param params.timeoutMs How long to wait for Ollama (default 3000)
101
+ * @param params.gateModels The models the running model gate uses (from its health answer). The gate reads
102
+ * only slm-gate's .env, which can name other models than the MCP env block this process was started with.
103
+ * A process whose own SLM_PROVIDER is not ollama checks nothing (its OLLAMA_HOST then points elsewhere);
104
+ * sessions on Ollama and `slm-gate doctor`, which reads the same .env as the gate, still check them.
105
+ * @returns The problems found (empty when all is well) and the models Ollama has
106
+ */
107
+ export async function checkLocalModels(params = {}) {
108
+ if (CONFIG.SLM_PROVIDER !== 'ollama')
109
+ return { problems: [], pulled: [] };
110
+ const probe = await probeOllama({ timeoutMs: params.timeoutMs });
111
+ if (probe.kind !== 'ok')
112
+ return { problems: [probeProblem(probe)], pulled: [] };
113
+ const pulled = probe.pulled;
114
+ const gateModels = (params.gateModels ?? []).map(model => ({ ...model, setting: `${model.setting} in slm-gate's .env` }));
115
+ const problems = [...requiredModels(), ...gateModels]
116
+ .filter((model, index, all) => all.findIndex(other => other.name === model.name) === index) // two settings, one model
117
+ .filter(model => !isPulled(model.name, pulled))
118
+ .map(model => ({
119
+ message: `The local model ${model.name} (${model.setting}, used for ${model.purpose}) is not downloaded.`,
120
+ fix: `ollama pull ${model.name}`,
121
+ }));
122
+ return { problems, pulled };
123
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @fileoverview Keeps the model gate (the HTTP server on LLM_GATE_PORT that coding tools send every model
3
+ * request to) running without anyone starting it by hand. slm-gate's MCP server — which every coding tool
4
+ * starts on its own — probes it at start-up and every minute, and launches it in the background when
5
+ * nothing is listening. Several MCP servers run at once (one per IDE window / CLI session); they agree
6
+ * through two marker files: a shared launch cooldown, and a "stopped by you" marker from `slm-gate stop`.
7
+ */
8
+ import { execFileSync, spawn } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import http from 'node:http';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { CONFIG, CONFIG_ENV_KEYS } from '../config.js';
15
+ import { claimWindow } from './claim.js';
16
+ /** Answered by the gate itself, never forwarded (src/llm-gate/server.ts). */
17
+ export const HEALTH_PATH = '/slm-gate/health';
18
+ export const GATE_LOG_FILE = path.join(CONFIG.OUTPUT_DIR, 'llm-gate.log');
19
+ const STOPPED_MARKER = path.join(CONFIG.OUTPUT_DIR, '.gate-stopped');
20
+ const LAUNCH_MARKER = path.join(CONFIG.OUTPUT_DIR, '.gate-launch');
21
+ const LAUNCH_COOLDOWN_MS = 60_000;
22
+ /**
23
+ * The command for an slm-gate CLI action on this install, e.g. `node /path/dist/cli.js restart`. Messages
24
+ * print this rather than `slm-gate restart`, which only works once the command has been linked onto the PATH.
25
+ */
26
+ export function cliCommand(action) {
27
+ const cli = path.join(CONFIG.ROOT_DIR, 'dist', 'cli.js');
28
+ return `node ${cli.includes(' ') ? JSON.stringify(cli) : cli} ${action}`;
29
+ }
30
+ /** A newer slm-gate build is installed than the one the running gate was started from. */
31
+ function isStale(health) {
32
+ try {
33
+ return String(Math.round(fs.statSync(health.entry).mtimeMs)) !== health.build;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /**
40
+ * Asks whatever listens on the port who it is. Nothing listening is known at once (connection refused);
41
+ * anything that is not the gate — another program, or one that does not answer in time — is 'other'.
42
+ */
43
+ export function probeGate(params = {}) {
44
+ const port = params.port ?? CONFIG.MODEL_GATE_PORT;
45
+ return new Promise(resolve => {
46
+ const req = http.get({ host: '127.0.0.1', port, path: HEALTH_PATH, timeout: params.timeoutMs ?? 1000 }, res => {
47
+ const chunks = [];
48
+ res.on('data', chunk => chunks.push(chunk));
49
+ res.on('end', () => {
50
+ try {
51
+ const health = JSON.parse(Buffer.concat(chunks).toString('utf8'));
52
+ resolve(res.statusCode === 200 && health.service === 'slm-gate' ? { kind: 'slm-gate', health, stale: isStale(health) } : { kind: 'other' });
53
+ }
54
+ catch {
55
+ resolve({ kind: 'other' });
56
+ }
57
+ });
58
+ res.on('error', () => resolve({ kind: 'other' }));
59
+ });
60
+ req.on('timeout', () => req.destroy(new Error('timeout')));
61
+ req.on('error', err => resolve(err.code === 'ECONNREFUSED' ? { kind: 'nothing' } : { kind: 'other' }));
62
+ });
63
+ }
64
+ /**
65
+ * Minutes since the epoch at which this machine booted, computed from the clock: a new value means a reboot,
66
+ * but a clock change shifts it too. Used only where the OS gives no boot ID.
67
+ */
68
+ function bootMinute() {
69
+ return Math.round((Date.now() - os.uptime() * 1000) / 60_000);
70
+ }
71
+ let cachedBootId;
72
+ /**
73
+ * The ID the OS gives this boot (macOS kern.bootsessionuuid, Linux boot_id); a clock change cannot alter it.
74
+ * Null on other systems, or when it cannot be read right now (not remembered, so the next call retries).
75
+ */
76
+ function osBootId() {
77
+ if (cachedBootId)
78
+ return cachedBootId;
79
+ try {
80
+ const id = process.platform === 'darwin'
81
+ ? execFileSync('sysctl', ['-n', 'kern.bootsessionuuid'], { encoding: 'utf8', timeout: 2000 }).trim()
82
+ : process.platform === 'linux'
83
+ ? fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim()
84
+ : '';
85
+ if (id)
86
+ cachedBootId = id;
87
+ return id || null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ /** `slm-gate stop` was run since the last boot, and no `slm-gate start` since. */
94
+ export function isStoppedByUser() {
95
+ let marker;
96
+ try {
97
+ marker = fs.readFileSync(STOPPED_MARKER, 'utf8').trim();
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ // A number is a boot minute (no OS boot ID, or written by an older slm-gate): allow a minute of drift.
103
+ if (/^\d+$/.test(marker))
104
+ return Math.abs(Number(marker) - bootMinute()) <= 1;
105
+ // A boot ID. If this boot's ID cannot be read right now, keep the stop rather than end it by mistake.
106
+ const current = osBootId();
107
+ return current === null || marker === current;
108
+ }
109
+ function setStoppedByUser(stopped) {
110
+ if (stopped) {
111
+ fs.mkdirSync(path.dirname(STOPPED_MARKER), { recursive: true });
112
+ fs.writeFileSync(STOPPED_MARKER, osBootId() ?? String(bootMinute()));
113
+ }
114
+ else {
115
+ fs.rmSync(STOPPED_MARKER, { force: true });
116
+ }
117
+ }
118
+ /** The gate's entry file in this install, next to this module (built `.js` or source `.ts`). */
119
+ function gateEntry() {
120
+ const here = fileURLToPath(import.meta.url);
121
+ return path.join(path.dirname(here), '..', 'llm-gate', `index${path.extname(here)}`);
122
+ }
123
+ /**
124
+ * The environment the gate is launched with: this process's, minus every variable slm-gate's config
125
+ * reads, so the one shared gate takes its settings only from slm-gate's own .env — never from the MCP env
126
+ * block of whichever coding tool started it first.
127
+ */
128
+ export function gateEnvironment(env = process.env) {
129
+ return Object.fromEntries(Object.entries(env).filter(([name]) => !CONFIG_ENV_KEYS.includes(name)));
130
+ }
131
+ /**
132
+ * Starts the gate in the background and returns at once, unless another session already launched it this
133
+ * minute (so a crash-looping gate is relaunched at most once a minute, however many windows are open).
134
+ *
135
+ * @param params.port Port to start it on (default LLM_GATE_PORT from slm-gate's .env)
136
+ * @param params.envOverrides Extra variables after the clean-up (tests use it for a throwaway ledger)
137
+ * @param params.execArgv Node flags for the child (default this process's, e.g. `--import tsx` from source)
138
+ * @param params.ignoreCooldown For `slm-gate start` / `restart`, which the person asked for explicitly
139
+ */
140
+ export function launchModelGate(params = {}) {
141
+ if (!params.ignoreCooldown && !claimWindow({ file: LAUNCH_MARKER, windowMs: LAUNCH_COOLDOWN_MS })) {
142
+ return { launched: false, reason: 'another slm-gate session already launched it this minute' };
143
+ }
144
+ fs.mkdirSync(path.dirname(GATE_LOG_FILE), { recursive: true });
145
+ const log = fs.openSync(GATE_LOG_FILE, 'a');
146
+ try {
147
+ const child = spawn(process.execPath, [...(params.execArgv ?? process.execArgv), gateEntry()], {
148
+ cwd: CONFIG.ROOT_DIR,
149
+ env: { ...gateEnvironment(), LLM_GATE_PORT: String(params.port ?? CONFIG.MODEL_GATE_PORT), ...params.envOverrides },
150
+ detached: true,
151
+ stdio: ['ignore', log, log],
152
+ });
153
+ child.on('error', err => fs.appendFileSync(GATE_LOG_FILE, `[slm-gate] could not start the model gate: ${err.message}\n`));
154
+ child.unref();
155
+ return { launched: true };
156
+ }
157
+ finally {
158
+ fs.closeSync(log);
159
+ }
160
+ }
161
+ /** Waits until the gate answers its health check; null when it does not within the time. */
162
+ export async function waitForModelGate(params = {}) {
163
+ const deadline = Date.now() + (params.timeoutMs ?? 10_000);
164
+ while (Date.now() < deadline) {
165
+ const probe = await probeGate({ port: params.port, timeoutMs: 500 });
166
+ if (probe.kind === 'slm-gate')
167
+ return probe.health;
168
+ await new Promise(resolve => setTimeout(resolve, 250));
169
+ }
170
+ return null;
171
+ }
172
+ /** The last lines of the gate's log, for a failure message. */
173
+ export function gateLogTail(lines = 5) {
174
+ try {
175
+ return fs.readFileSync(GATE_LOG_FILE, 'utf8').trim().split('\n').slice(-lines).join('\n');
176
+ }
177
+ catch {
178
+ return '';
179
+ }
180
+ }
181
+ /** Which program listens on the port (macOS/Linux, via lsof), e.g. "node (pid 123)"; null when unknown. */
182
+ export function portOwner(port = CONFIG.MODEL_GATE_PORT) {
183
+ try {
184
+ const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc'], { encoding: 'utf8', timeout: 2000 });
185
+ const pid = /^p(\d+)/m.exec(out)?.[1];
186
+ const command = /^c(.+)$/m.exec(out)?.[1];
187
+ return pid ? `${command ?? 'a program'} (pid ${pid})` : null;
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ /** `slm-gate start`: clears "stopped by you" and launches the gate unless it already runs. */
194
+ export async function startModelGate(params = {}) {
195
+ setStoppedByUser(false);
196
+ const probe = await probeGate({ port: params.port });
197
+ if (probe.kind !== 'nothing')
198
+ return probe;
199
+ launchModelGate({ port: params.port, ignoreCooldown: true });
200
+ const health = await waitForModelGate({ port: params.port });
201
+ return health ? { kind: 'slm-gate', health, stale: false } : { kind: 'nothing' };
202
+ }
203
+ /** `slm-gate stop`: stops the gate and keeps it stopped until `slm-gate start`/`restart` or a reboot. */
204
+ export async function stopModelGate(params = {}) {
205
+ setStoppedByUser(true);
206
+ const probe = await probeGate({ port: params.port });
207
+ if (probe.kind !== 'slm-gate')
208
+ return null;
209
+ try {
210
+ process.kill(probe.health.pid, 'SIGTERM');
211
+ }
212
+ catch {
213
+ // Already gone.
214
+ }
215
+ const deadline = Date.now() + 5000;
216
+ while (Date.now() < deadline && (await probeGate({ port: params.port, timeoutMs: 300 })).kind === 'slm-gate') {
217
+ await new Promise(resolve => setTimeout(resolve, 200));
218
+ }
219
+ return probe.health;
220
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @fileoverview Desktop notifications for slm-gate problems (macOS; Linux when notify-send exists; nothing
3
+ * on other systems — the log line and the notice to the AI still carry the message). Each message is shown
4
+ * at most once per 10 minutes across every running slm-gate process, so ten IDE windows opening together
5
+ * give one pop-up, not ten.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import crypto from 'node:crypto';
9
+ import path from 'node:path';
10
+ import { CONFIG } from '../config.js';
11
+ import { claimWindow } from './claim.js';
12
+ const REPEAT_AFTER_MS = 10 * 60 * 1000;
13
+ /** True for exactly one caller per problem per 10 minutes, across processes. */
14
+ export function claimNotice(params) {
15
+ const dir = params.dir ?? path.join(CONFIG.OUTPUT_DIR, '.notices');
16
+ const file = path.join(dir, crypto.createHash('sha256').update(params.key).digest('hex').slice(0, 16));
17
+ return claimWindow({ file, windowMs: REPEAT_AFTER_MS, now: params.now });
18
+ }
19
+ /**
20
+ * Shows a desktop notification, unless the same problem was already shown in this 10-minute window.
21
+ *
22
+ * @param params.key A stable name for the problem (e.g. 'gate-launch-failed'), so two sessions describing
23
+ * the same failure in slightly different words still show one pop-up
24
+ */
25
+ export function notifyUser(params) {
26
+ const title = params.title ?? 'slm-gate';
27
+ let command = null;
28
+ if (process.platform === 'darwin') {
29
+ // JSON string literals are valid AppleScript string literals (same quote and backslash escapes).
30
+ command = ['osascript', ['-e', `display notification ${JSON.stringify(params.message)} with title ${JSON.stringify(title)}`]];
31
+ }
32
+ else if (process.platform === 'linux') {
33
+ command = ['notify-send', [title, params.message]];
34
+ }
35
+ if (!command || !claimNotice({ key: params.key }))
36
+ return;
37
+ try {
38
+ const child = spawn(command[0], command[1], { stdio: 'ignore', detached: true });
39
+ child.on('error', () => { }); // e.g. notify-send not installed: the log line still has the message
40
+ child.unref();
41
+ }
42
+ catch {
43
+ // Same: never let a notification problem affect slm-gate itself.
44
+ }
45
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @fileoverview How Ollama is installed on this machine, so that "start Ollama" can name the command
3
+ * that actually works here. Telling somebody to run `ollama serve` when Homebrew or systemd already
4
+ * keeps Ollama running gets them "address already in use", which reads like a second fault and sends
5
+ * them looking for one. This only looks at the filesystem: it never runs Ollama and never starts it.
6
+ */
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ /** Homebrew starts Ollama through this LaunchAgent, and then owns the port. */
11
+ const BREW_AGENT = 'Library/LaunchAgents/homebrew.mxcl.ollama.plist';
12
+ const MACOS_APP = '/Applications/Ollama.app';
13
+ const SYSTEMD_UNITS = [
14
+ '/etc/systemd/system/ollama.service',
15
+ '/usr/lib/systemd/system/ollama.service',
16
+ '/lib/systemd/system/ollama.service',
17
+ ];
18
+ const INSTALL = 'Install Ollama from https://ollama.com/download, then start it.';
19
+ /**
20
+ * The sentence telling somebody how to start Ollama on this machine.
21
+ *
22
+ * Every input is injectable so this stays a pure function of the machine's state, which is also how
23
+ * the tests cover installs the test machine does not have.
24
+ *
25
+ * @param params.platform Defaults to the running platform
26
+ * @param params.home Defaults to the current user's home directory
27
+ * @param params.pathDirs Directories to look for the `ollama` binary in; defaults to PATH
28
+ * @param params.exists Defaults to checking the real filesystem
29
+ * @returns A sentence naming the command to run, ready to use as a problem's fix
30
+ */
31
+ export function howToStartOllama(params = {}) {
32
+ const platform = params.platform ?? process.platform;
33
+ const exists = params.exists ?? fs.existsSync;
34
+ const home = params.home ?? os.homedir();
35
+ const pathDirs = params.pathDirs ?? (process.env.PATH ?? '').split(path.delimiter);
36
+ const onPath = pathDirs.some(dir => dir !== '' && exists(path.join(dir, 'ollama')));
37
+ const serve = 'Run `ollama serve`.';
38
+ if (platform === 'darwin') {
39
+ if (exists(path.join(home, BREW_AGENT))) {
40
+ return 'Run `brew services start ollama` (Homebrew keeps Ollama running on this machine, so `ollama serve` would only report that the address is already in use).';
41
+ }
42
+ if (exists(MACOS_APP))
43
+ return 'Open the Ollama app.';
44
+ return onPath ? serve : INSTALL;
45
+ }
46
+ if (platform === 'linux') {
47
+ if (SYSTEMD_UNITS.some(exists)) {
48
+ return 'Run `sudo systemctl start ollama` (systemd keeps Ollama running on this machine, so `ollama serve` would only report that the address is already in use).';
49
+ }
50
+ return onPath ? serve : INSTALL;
51
+ }
52
+ return onPath ? serve : INSTALL;
53
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @fileoverview Stops this MCP server when the coding tool that started it goes away.
3
+ *
4
+ * A stdio MCP server is owned by the editor that spawned it, but nothing in the operating system
5
+ * enforces that: when the editor is force-quit or crashes, the server it started keeps running, is
6
+ * reparented to init, and stays up until the machine reboots. Those orphans are not idle — each one
7
+ * goes on probing Ollama and showing desktop notifications, from whatever build it was started with,
8
+ * so a fault fixed weeks ago can still pop up on screen.
9
+ *
10
+ * Closed input is the quick, ordinary signal, but it only arrives once something is reading stdin, so
11
+ * it is no help if start-up hangs before the transport attaches. Losing our parent is the signal that
12
+ * always arrives, whatever went wrong and whichever transport is in use.
13
+ */
14
+ import { execFileSync } from 'node:child_process';
15
+ /** A process line from `ps`: pid, parent pid, then the command. */
16
+ const PS_LINE = /^\s*(\d+)\s+(\d+)\s+(.*)$/;
17
+ /** Our MCP server's entry point as a whole argument, built or from source. */
18
+ const MCP_ENTRY = /mcp-gate[/\\]index\.[mc]?[jt]s$/;
19
+ /**
20
+ * What a JavaScript process is started by. The command has to be one of these AND the entry has to be
21
+ * one of its arguments, because a command line is text: a shell running a script that merely mentions
22
+ * the path, or a `grep` for it, otherwise counts as a server and gets offered up to be killed.
23
+ */
24
+ const RUNTIME = /(^|[/\\])(node|npm|npx|pnpm|yarn|tsx|bun|deno)$/;
25
+ /** True when this command line is a JavaScript runtime actually running our MCP server. */
26
+ function isOurServer(command) {
27
+ const [runtime, ...args] = command.trim().split(/\s+/);
28
+ return RUNTIME.test(runtime) && args.some(arg => MCP_ENTRY.test(arg));
29
+ }
30
+ /**
31
+ * slm-gate MCP servers still running with nothing to own them, for `slm-gate doctor` to name.
32
+ *
33
+ * Builds from before this file existed have no way to notice their coding tool has gone, so they stay
34
+ * up until the machine reboots. Only reports them: which processes to end is the user's call, and a
35
+ * server belonging to an editor this check cannot see would be the wrong thing to kill.
36
+ *
37
+ * @param params.ps Reads the process table; defaults to running `ps`
38
+ * @param params.self This process, never reported; defaults to the real pid
39
+ * @returns The pids, empty when there are none or the process table cannot be read
40
+ */
41
+ export function findStrandedServers(params = {}) {
42
+ const self = params.self ?? process.pid;
43
+ let table;
44
+ try {
45
+ table = params.ps ? params.ps() : execFileSync('ps', ['-Ao', 'pid=,ppid=,command='], { encoding: 'utf8', timeout: 2000 });
46
+ }
47
+ catch {
48
+ return []; // no ps, or not allowed to run it: this is a convenience, never a failure
49
+ }
50
+ return table
51
+ .split('\n')
52
+ .map(line => PS_LINE.exec(line))
53
+ .filter((parts) => parts !== null)
54
+ // Parent 1 means the process that started it is gone and init has taken it over.
55
+ .filter(parts => parts[2] === '1' && Number(parts[1]) !== self && isOurServer(parts[3]))
56
+ .map(parts => Number(parts[1]));
57
+ }
58
+ /**
59
+ * Calls `onGone` when the process that started this one has exited.
60
+ *
61
+ * Reparenting is the test: every process keeps its parent until that parent dies, at which point the
62
+ * operating system hands it to init. A process that already belongs to init was started by a service
63
+ * manager and has no parent to lose, so it is left alone.
64
+ *
65
+ * @param params.everyMs How often to look (default 30 s; this is one syscall)
66
+ * @param params.ppid Reads the current parent process id; defaults to the real one
67
+ * @param params.onGone Defaults to asking ourselves to shut down the same way a `slm-gate stop` would
68
+ * @returns Stops watching (tests, and shutdown paths that no longer need it)
69
+ */
70
+ export function exitWithParent(params = {}) {
71
+ const readPpid = params.ppid ?? (() => process.ppid);
72
+ const startedUnder = readPpid();
73
+ if (startedUnder <= 1)
74
+ return () => { }; // already owned by init: nothing to watch for
75
+ const onGone = params.onGone ?? (() => process.kill(process.pid, 'SIGTERM'));
76
+ const timer = setInterval(() => {
77
+ if (readPpid() === startedUnder)
78
+ return;
79
+ clearInterval(timer);
80
+ onGone();
81
+ }, params.everyMs ?? 30_000);
82
+ timer.unref(); // never keep the process alive just to watch for its parent
83
+ return () => clearInterval(timer);
84
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @fileoverview Which local models a set of settings needs, and what each one is for. One rule for the
3
+ * start-up check (the running settings, src/setup/local-models.ts) and `slm-gate init` (the settings file
4
+ * it writes, src/setup/init.ts). No side effects, so init can use it without loading the configuration.
5
+ */
6
+ /**
7
+ * @param settings The model settings, and the two features that use the embedding model
8
+ * @returns The models to have in Ollama: both SLM models, plus EMBED_MODEL when either feature is on
9
+ */
10
+ export function modelsFor(settings) {
11
+ const models = [
12
+ { name: settings.SLM_GATE_MODEL, setting: 'SLM_GATE_MODEL', purpose: 'sorting requests and shrinking tool output' },
13
+ { name: settings.SLM_BRAIN_MODEL, setting: 'SLM_BRAIN_MODEL', purpose: 'answering first messages locally' },
14
+ ];
15
+ if (settings.SEMCACHE || settings.DISTILL_ADAPTIVE) {
16
+ const uses = [settings.SEMCACHE && 'the semantic cache', settings.DISTILL_ADAPTIVE && 'adaptive tool-output shrinking'].filter(Boolean);
17
+ models.push({ name: settings.EMBED_MODEL, setting: 'EMBED_MODEL', purpose: uses.join(' and ') });
18
+ }
19
+ return models;
20
+ }