@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,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview What slm-gate's MCP server does when a coding tool starts it, and every minute after: checks
|
|
3
|
+
* Ollama and the configured models, and keeps the model gate running. Nobody has to run a command. Problems
|
|
4
|
+
* are logged, shown as a desktop notification and (at start-up) passed to the AI in the MCP server's
|
|
5
|
+
* instructions; nothing is said when all is well. None of this can stop the MCP server from starting.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { CONFIG } from '../config.js';
|
|
9
|
+
import { checkLocalModels } from './local-models.js';
|
|
10
|
+
import { cliCommand, GATE_LOG_FILE, isStoppedByUser, launchModelGate, probeGate, waitForModelGate } from './model-gate.js';
|
|
11
|
+
import { notifyUser } from './notify.js';
|
|
12
|
+
// The coding tool is waiting for the MCP handshake: the checks before it get at most this long.
|
|
13
|
+
const FAST_CHECK_MS = 1500;
|
|
14
|
+
const WATCH_EVERY_MS = 60_000;
|
|
15
|
+
// The first watch check runs once a gate launched at start-up has had time to come up.
|
|
16
|
+
const FIRST_WATCH_MS = 15_000;
|
|
17
|
+
const port = () => CONFIG.MODEL_GATE_PORT;
|
|
18
|
+
function portTakenNotice() {
|
|
19
|
+
return {
|
|
20
|
+
key: 'port-taken',
|
|
21
|
+
message: `Port ${port()} is used by another program, so the model gate cannot run there and coding tools pointed at http://localhost:${port()} cannot reach their AI provider.`,
|
|
22
|
+
fix: `Quit that program (\`${cliCommand('doctor')}\` names it). Or set LLM_GATE_PORT to a free port in ${path.join(CONFIG.HOME_DIR, '.env')}, run \`${cliCommand('restart')}\`, change the address in each coding tool (\`${cliCommand('doctor')}\` prints the lines) and restart them.`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function staleNotice() {
|
|
26
|
+
return {
|
|
27
|
+
key: 'gate-stale',
|
|
28
|
+
message: `The model gate on port ${port()} is still running an older slm-gate build.`,
|
|
29
|
+
fix: `Run \`${cliCommand('restart')}\` when no coding tool is in the middle of an answer.`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function notRunningNotice() {
|
|
33
|
+
return {
|
|
34
|
+
key: 'gate-not-running',
|
|
35
|
+
message: `The model gate on port ${port()} is not running and did not start, so coding tools pointed at it cannot reach their AI provider.`,
|
|
36
|
+
fix: `Run \`${cliCommand('start')}\`. If it still fails, see ${GATE_LOG_FILE} or run \`${cliCommand('doctor')}\`.`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function report(notice) {
|
|
40
|
+
console.error(`[slm-gate] ${notice.message} Fix: ${notice.fix}`);
|
|
41
|
+
notifyUser({ key: notice.key, message: `${notice.message} ${notice.fix}` });
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* What the gate probe means at start-up; launches the gate at once when nothing listens.
|
|
45
|
+
* @returns The problems, and the models a running gate uses (checked along with this process's own)
|
|
46
|
+
*/
|
|
47
|
+
async function checkGateAtStartup() {
|
|
48
|
+
if (!CONFIG.LLM_GATE_AUTOSTART)
|
|
49
|
+
return { notices: [] };
|
|
50
|
+
const probe = await probeGate({ timeoutMs: 1000 });
|
|
51
|
+
if (probe.kind === 'slm-gate')
|
|
52
|
+
return { notices: probe.stale ? [staleNotice()] : [], gateModels: probe.health.models };
|
|
53
|
+
if (probe.kind === 'other')
|
|
54
|
+
return { notices: [portTakenNotice()] };
|
|
55
|
+
// Nothing listens. Launch now, so the gate is booting before the coding tool's first model request;
|
|
56
|
+
// whether it came up, and its models, are checked later by the watch.
|
|
57
|
+
if (!isStoppedByUser())
|
|
58
|
+
launchModelGate();
|
|
59
|
+
return { notices: [] };
|
|
60
|
+
}
|
|
61
|
+
function modelNotices(problems) {
|
|
62
|
+
return problems.map(problem => ({ key: `models:${problem.fix}`, ...problem }));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The start-up checks, capped at about 1.5 s. Late results are dropped (their promises are already
|
|
66
|
+
* handled); a gate launch still happens when the probe answers late.
|
|
67
|
+
*
|
|
68
|
+
* @returns The problems to pass to the AI; each has also been logged and notified
|
|
69
|
+
*/
|
|
70
|
+
export async function runStartupChecks() {
|
|
71
|
+
const cap = new Promise(resolve => setTimeout(() => resolve([]), FAST_CHECK_MS).unref());
|
|
72
|
+
const gate = checkGateAtStartup().catch(() => ({ notices: [], gateModels: undefined }));
|
|
73
|
+
// The probe answers in milliseconds, so the model check can wait for the running gate's models.
|
|
74
|
+
const models = gate
|
|
75
|
+
.then(result => checkLocalModels({ timeoutMs: FAST_CHECK_MS - 300, gateModels: result.gateModels }))
|
|
76
|
+
// A check squeezed into the start-up budget, while the editor, the toolbox and often Ollama itself
|
|
77
|
+
// are all still starting, is weak evidence. Anything that might be a timing artifact waits for the
|
|
78
|
+
// watch, which gets a quiet moment and a second opinion. This matters more here than anywhere else:
|
|
79
|
+
// the instructions handed to the editor are fixed for the whole session, so a wrong notice at
|
|
80
|
+
// start-up is repeated by the AI until the session ends, long after the truth has changed.
|
|
81
|
+
.then(result => modelNotices(result.problems).filter(notice => !notice.transient))
|
|
82
|
+
.catch(() => []);
|
|
83
|
+
const notices = (await Promise.all([Promise.race([gate.then(result => result.notices), cap]), Promise.race([models, cap])])).flat();
|
|
84
|
+
notices.forEach(report);
|
|
85
|
+
return notices;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* While the MCP server lives: relaunch the gate if it died (unless you stopped it; at most once a minute
|
|
89
|
+
* across all sessions) and re-check the local models. Problems are logged and notified.
|
|
90
|
+
*
|
|
91
|
+
* @param params.firstMs Delay before the first check (default 15 s)
|
|
92
|
+
* @param params.everyMs Interval after that (default 60 s)
|
|
93
|
+
* @returns Stops the watch (tests)
|
|
94
|
+
*/
|
|
95
|
+
export function watchModelGate(params = {}) {
|
|
96
|
+
if (!CONFIG.LLM_GATE_AUTOSTART)
|
|
97
|
+
return () => { };
|
|
98
|
+
// Transient problems reported by the previous check. One that is still there on the next check is
|
|
99
|
+
// real; one that has gone was the machine being busy, and nobody ever hears about it.
|
|
100
|
+
let awaitingConfirmation = new Set();
|
|
101
|
+
const check = async () => {
|
|
102
|
+
const probe = await probeGate();
|
|
103
|
+
let gate = probe.kind === 'slm-gate' ? probe.health : null;
|
|
104
|
+
if (probe.kind === 'other')
|
|
105
|
+
report(portTakenNotice());
|
|
106
|
+
if (probe.kind === 'nothing' && !isStoppedByUser()) {
|
|
107
|
+
const { launched } = launchModelGate();
|
|
108
|
+
// Not launched = another session already launched it this minute. Give that one a moment, but stay
|
|
109
|
+
// quiet if it is not up: the next check can launch again, and reports if that launch fails.
|
|
110
|
+
gate = await waitForModelGate({ timeoutMs: launched ? 10_000 : 2_000 });
|
|
111
|
+
if (!gate && launched)
|
|
112
|
+
report(notRunningNotice());
|
|
113
|
+
}
|
|
114
|
+
const { problems } = await checkLocalModels({ gateModels: gate?.models });
|
|
115
|
+
const notices = modelNotices(problems);
|
|
116
|
+
notices.filter(notice => !notice.transient || awaitingConfirmation.has(notice.key)).forEach(report);
|
|
117
|
+
awaitingConfirmation = new Set(notices.filter(notice => notice.transient).map(notice => notice.key));
|
|
118
|
+
};
|
|
119
|
+
const run = () => void check().catch(err => console.error(`[slm-gate] model gate check failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
120
|
+
let interval;
|
|
121
|
+
const first = setTimeout(() => {
|
|
122
|
+
run();
|
|
123
|
+
interval = setInterval(run, params.everyMs ?? WATCH_EVERY_MS);
|
|
124
|
+
interval.unref();
|
|
125
|
+
}, params.firstMs ?? FIRST_WATCH_MS);
|
|
126
|
+
first.unref();
|
|
127
|
+
return () => {
|
|
128
|
+
clearTimeout(first);
|
|
129
|
+
if (interval)
|
|
130
|
+
clearInterval(interval);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The exact setting that points each coding tool at the model gate, for `slm-gate doctor`
|
|
3
|
+
* and the docs. Every address uses the configured port, so after changing LLM_GATE_PORT doctor prints the
|
|
4
|
+
* lines to update. Facts checked against each tool's documentation on 2026-09-18.
|
|
5
|
+
*/
|
|
6
|
+
export function toolSettings(port) {
|
|
7
|
+
const base = `http://localhost:${port}`;
|
|
8
|
+
return [
|
|
9
|
+
{
|
|
10
|
+
tool: 'Claude Code (CLI and VS Code / JetBrains extension)',
|
|
11
|
+
login: 'claude.ai Pro/Max login or API key',
|
|
12
|
+
lines: [
|
|
13
|
+
`~/.claude/settings.json: { "env": { "ANTHROPIC_BASE_URL": "${base}" } } (no /v1 at the end)`,
|
|
14
|
+
`VS Code user settings: "claudeCode.environmentVariables": [{ "name": "ANTHROPIC_BASE_URL", "value": "${base}" }]`,
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
tool: 'Codex (CLI and IDE extension)',
|
|
19
|
+
login: 'ChatGPT login or API key',
|
|
20
|
+
lines: [
|
|
21
|
+
'~/.codex/config.toml:',
|
|
22
|
+
' model_provider = "slm-gate"',
|
|
23
|
+
' [model_providers.slm-gate]',
|
|
24
|
+
' name = "slm-gate"',
|
|
25
|
+
` base_url = "${base}/v1"`,
|
|
26
|
+
' requires_openai_auth = true',
|
|
27
|
+
' (do not set supports_websockets: over WebSocket the gate cannot see the conversation)',
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
tool: 'Gemini CLI',
|
|
32
|
+
login: 'Gemini API key only',
|
|
33
|
+
lines: [
|
|
34
|
+
`export GOOGLE_GEMINI_BASE_URL=${base}`,
|
|
35
|
+
'export GEMINI_API_KEY=<your key>',
|
|
36
|
+
'~/.gemini/settings.json: { "security": { "auth": { "selectedType": "gemini-api-key" } } }',
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
tool: 'Antigravity CLI (agy)',
|
|
41
|
+
login: 'Gemini API key only',
|
|
42
|
+
lines: [
|
|
43
|
+
'~/.gemini/antigravity-cli/settings.json: { "modelProvider": "gemini" }',
|
|
44
|
+
`export GOOGLE_GEMINI_BASE_URL=${base}`,
|
|
45
|
+
'export GEMINI_API_KEY=<your key>',
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
tool: 'OpenCode',
|
|
50
|
+
login: 'API key',
|
|
51
|
+
lines: [`opencode.json: { "provider": { "anthropic": { "options": { "baseURL": "${base}/v1" } } } }`],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
tool: 'Cline / Roo Code (VS Code)',
|
|
55
|
+
login: 'API key',
|
|
56
|
+
lines: [`Settings > API Provider "Anthropic" > tick "Use custom base URL" > ${base}`],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
tool: 'Kilo Code (extension and CLI)',
|
|
60
|
+
login: 'API key',
|
|
61
|
+
lines: [`Custom provider, Provider API "Anthropic Messages", Base URL ${base}/v1 (CLI: kilo.jsonc provider.<id>.options.baseURL)`],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
tool: 'Continue (VS Code / JetBrains)',
|
|
65
|
+
login: 'API key',
|
|
66
|
+
lines: [`~/.continue/config.yaml model: provider: anthropic, apiBase: ${base}/v1/ (OpenAI models: provider: openai, apiBase: ${base}/v1)`],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
tool: 'Zed',
|
|
70
|
+
login: 'API key (entered in Agent Settings, not in settings.json)',
|
|
71
|
+
lines: [`settings.json: "language_models": { "anthropic": { "api_url": "${base}" } }`],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
tool: 'GitHub Copilot Chat (VS Code) Custom Endpoint',
|
|
75
|
+
login: 'your own API key',
|
|
76
|
+
lines: [
|
|
77
|
+
`Chat > Manage Language Models > Add Models > Custom Endpoint: apiType "messages", url ${base}/v1/messages`,
|
|
78
|
+
'(inline suggestions and semantic search keep using GitHub\'s own models)',
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
tool: 'JetBrains Junie CLI',
|
|
83
|
+
login: 'API key',
|
|
84
|
+
lines: [`~/.junie/models/slm-gate.json: { "id": "<model>", "baseUrl": "${base}/v1/messages", "apiType": "Anthropic", "apiKey": "<key>" }`],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
tool: 'Aider',
|
|
88
|
+
login: 'API key',
|
|
89
|
+
lines: [`ANTHROPIC_API_BASE=${base} (OpenAI models: OPENAI_API_BASE=${base}/v1)`],
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
/** Tools whose model requests cannot go through a local gate, and why. Their MCP connection still works. */
|
|
94
|
+
export const UNROUTABLE_TOOLS = [
|
|
95
|
+
{ tool: 'Cursor, Windsurf', reason: 'model requests go through the vendor\'s servers first, which cannot reach your machine' },
|
|
96
|
+
{ tool: 'Claude desktop app (chat and its Code tab), claude.ai', reason: 'no setting for the model address' },
|
|
97
|
+
{ tool: 'ChatGPT app/web, Gemini app/web', reason: 'no setting for the model address' },
|
|
98
|
+
{ tool: 'Gemini Code Assist extension, Antigravity IDE, Copilot\'s included models', reason: 'no setting for the model address' },
|
|
99
|
+
{ tool: 'Gemini CLI / agy with a Google-account login', reason: 'that login ignores the address setting, and Google\'s terms forbid using it through other tools' },
|
|
100
|
+
{ tool: 'Models bundled in a tool\'s own subscription (Cline, Kilo, Copilot, …)', reason: 'only your own API key can be pointed at the gate' },
|
|
101
|
+
];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Shared exponential-backoff helper.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from llm-gate/pipeline.ts so the ledger's Langfuse flush can reuse the same
|
|
5
|
+
* retry policy. It cannot simply import from the pipeline: llm-gate/pipeline.ts imports
|
|
6
|
+
* the ledger, so that direction would be circular.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Calculates exponential backoff with jitter and awaits the delay period.
|
|
10
|
+
*
|
|
11
|
+
* @desc Computes 1500ms * 2^attempt + jitter, respecting an optional `Retry-After` header value in seconds.
|
|
12
|
+
* Logs a diagnostic warning to stderr before waiting.
|
|
13
|
+
* @param attempt Current zero-indexed retry attempt
|
|
14
|
+
* @param maxRetries Total allowed retry attempts
|
|
15
|
+
* @param reason Human-readable context for why the backoff is being executed
|
|
16
|
+
* @param retryAfter Optional `Retry-After` header string from HTTP response
|
|
17
|
+
* @param layer Log prefix identifying the calling layer (e.g. 'llm-gate', 'ledger')
|
|
18
|
+
* @returns Promise that resolves once the backoff delay has completed
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* await waitWithBackoff(attempt, MAX_RETRIES, 'Upstream HTTP 429', res.headers.get('retry-after'));
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export async function waitWithBackoff(attempt, maxRetries, reason, retryAfter, layer = 'llm-gate') {
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, computeBackoffMs(attempt, reason, retryAfter, maxRetries, layer)));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pure delay calculation, split out so it can be unit-tested without waiting in real time.
|
|
29
|
+
*
|
|
30
|
+
* @param attempt Current zero-indexed retry attempt
|
|
31
|
+
* @param reason Human-readable context, used only for the log line
|
|
32
|
+
* @param retryAfter Optional `Retry-After` header string, in seconds
|
|
33
|
+
* @param maxRetries Total allowed retry attempts, used only for the log line
|
|
34
|
+
* @param layer Log prefix identifying the calling layer
|
|
35
|
+
* @returns Delay in milliseconds
|
|
36
|
+
*/
|
|
37
|
+
export function computeBackoffMs(attempt, reason, retryAfter, maxRetries = 0, layer = 'llm-gate') {
|
|
38
|
+
let delayMs = 1500 * Math.pow(2, attempt) + Math.random() * 500;
|
|
39
|
+
if (retryAfter) {
|
|
40
|
+
const parsedSeconds = parseInt(retryAfter, 10);
|
|
41
|
+
if (!isNaN(parsedSeconds) && parsedSeconds > 0) {
|
|
42
|
+
delayMs = Math.max(delayMs, parsedSeconds * 1000);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
console.error(`[${layer}] ${reason}. Retrying in ${Math.round(delayMs)}ms (attempt ${attempt + 1}/${maxRetries})...`);
|
|
46
|
+
return delayMs;
|
|
47
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { extractToolSignature, distillToolResult, estimateTokens, computeElisionId, formatElisionMarker } from './elision.js';
|
|
2
|
+
import { CONFIG } from '../config.js';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { writeElision } from '../ledger/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* Core context compression engine for the LLM Gate.
|
|
7
|
+
* This function iterates through the conversation history and intelligently prunes,
|
|
8
|
+
* drops, or distills verbose tool outputs to save tokens and prevent context overflow,
|
|
9
|
+
* while maintaining strict cache-stability and preserving critical structural data.
|
|
10
|
+
*
|
|
11
|
+
* @param messages - The full history of internal messages in the conversation.
|
|
12
|
+
* @param options - Configuration for pruning aggression.
|
|
13
|
+
* @returns A promise resolving to the compressed array of messages.
|
|
14
|
+
*/
|
|
15
|
+
export async function compressContext(messages, options = {}) {
|
|
16
|
+
const { dropStaleToolOutput = true } = options;
|
|
17
|
+
if (messages.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
const lastIndex = messages.length - 1;
|
|
20
|
+
// We keep a configurable number of recent tool outputs verbatim to ensure
|
|
21
|
+
// the model has full context for its most immediate recent actions.
|
|
22
|
+
const recentToolTurns = CONFIG.KEEP_RECENT_TOOL_TURNS ?? 2;
|
|
23
|
+
// Tool outputs smaller than this threshold are considered "cheap" and are kept verbatim
|
|
24
|
+
// to save CPU cycles and avoid unnecessary fragmentation.
|
|
25
|
+
const minTokens = CONFIG.DISTILL_MIN_TOKENS ?? 500;
|
|
26
|
+
// Pre-process messages to extract structural metadata (tool name, args, target file paths).
|
|
27
|
+
// This metadata is crucial for detecting when an older tool output is "superseded" by a newer one.
|
|
28
|
+
const toolResults = messages.map((m, i) => {
|
|
29
|
+
if (m.role !== 'tool' && m.role !== 'user' && m.role !== 'assistant')
|
|
30
|
+
return null;
|
|
31
|
+
const extracted = extractToolSignature(m.content);
|
|
32
|
+
return {
|
|
33
|
+
index: i,
|
|
34
|
+
role: m.role,
|
|
35
|
+
content: m.content,
|
|
36
|
+
toolName: extracted.toolName,
|
|
37
|
+
args: extracted.args,
|
|
38
|
+
filePath: extracted.filePath,
|
|
39
|
+
// We hash the raw content here to easily detect exact duplicate responses later.
|
|
40
|
+
hash: crypto.createHash('sha256').update(m.content).digest('hex')
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
// Perform a reverse-scan to flag the N most recent tool results.
|
|
44
|
+
// These flagged results will be protected from compression.
|
|
45
|
+
let toolTurnsFound = 0;
|
|
46
|
+
const isRecentTool = new Array(messages.length).fill(false);
|
|
47
|
+
for (let i = lastIndex; i >= 0; i--) {
|
|
48
|
+
if (messages[i].role === 'tool') {
|
|
49
|
+
if (toolTurnsFound < recentToolTurns) {
|
|
50
|
+
isRecentTool[i] = true;
|
|
51
|
+
}
|
|
52
|
+
toolTurnsFound++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// The LLM-gate primarily relies on deterministic truncation (heuristics) for speed.
|
|
56
|
+
// Since we don't have an active SLM client instantiated here, we pass a dummy passthrough function.
|
|
57
|
+
// The underlying `distillToolResult` handles deterministic heuristic truncation gracefully even with a dummy SLM.
|
|
58
|
+
const dummySlm = async (t) => t;
|
|
59
|
+
const processed = [];
|
|
60
|
+
// Main forward-scan loop to evaluate each message for compression or dropping.
|
|
61
|
+
for (let i = 0; i < messages.length; i++) {
|
|
62
|
+
const msg = messages[i];
|
|
63
|
+
// Always keep the very last message, non-tool messages, or everything if pruning is disabled.
|
|
64
|
+
if (i === lastIndex || msg.role !== 'tool' || !dropStaleToolOutput) {
|
|
65
|
+
processed.push(msg);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const currentTool = toolResults[i];
|
|
69
|
+
const sizeTokens = estimateTokens(msg.content);
|
|
70
|
+
// Rule 1: KEEP VERBATIM
|
|
71
|
+
// If the output is tiny or it is one of the most recent tool interactions, protect it.
|
|
72
|
+
if (sizeTokens < minTokens || isRecentTool[i]) {
|
|
73
|
+
processed.push(msg);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// Rule 2: Check for Supersession
|
|
77
|
+
// We scan ahead in the conversation to see if a future action renders this current tool output obsolete.
|
|
78
|
+
let superseded = false;
|
|
79
|
+
for (let j = i + 1; j < messages.length; j++) {
|
|
80
|
+
const later = toolResults[j];
|
|
81
|
+
if (!later)
|
|
82
|
+
continue;
|
|
83
|
+
// Condition A: An exact duplicate of this output appears later in the conversation.
|
|
84
|
+
if (later.hash === currentTool.hash) {
|
|
85
|
+
superseded = true;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
// Condition B: The exact same file was read again later, making this older read obsolete.
|
|
89
|
+
if (currentTool.filePath && later.filePath === currentTool.filePath && later.role === 'tool') {
|
|
90
|
+
if (['read_file', 'view_file'].some(t => later.toolName?.includes(t))) {
|
|
91
|
+
superseded = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Condition C: The file this tool read was subsequently modified/written to later,
|
|
96
|
+
// rendering this older read's contents invalid/stale.
|
|
97
|
+
if (currentTool.filePath && later.filePath === currentTool.filePath) {
|
|
98
|
+
if (['write', 'edit', 'replace', 'patch'].some(t => later.toolName?.toLowerCase().includes(t))) {
|
|
99
|
+
superseded = true;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (superseded && currentTool.toolName) {
|
|
105
|
+
// Rule 3: DROP (with marker)
|
|
106
|
+
// The output is obsolete. We drop the payload but leave a precise breadcrumb (elision marker)
|
|
107
|
+
// in the context window and write the full payload to the local SQLite ledger.
|
|
108
|
+
const elisionId = computeElisionId(currentTool.toolName, currentTool.args, msg.content);
|
|
109
|
+
const lines = msg.content.split('\n');
|
|
110
|
+
const marker = formatElisionMarker(elisionId, lines.length, 0, lines.length - 1);
|
|
111
|
+
writeElision({
|
|
112
|
+
id: elisionId,
|
|
113
|
+
tool_name: currentTool.toolName,
|
|
114
|
+
args: JSON.stringify(currentTool.args || {}),
|
|
115
|
+
original_text: msg.content,
|
|
116
|
+
ranges: JSON.stringify({ startLine: 0, endLine: lines.length - 1 }),
|
|
117
|
+
content_hash: currentTool.hash,
|
|
118
|
+
size_bytes: Buffer.byteLength(msg.content)
|
|
119
|
+
});
|
|
120
|
+
processed.push({ ...msg, content: marker.trim() });
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Rule 4: DISTILL (with marker)
|
|
124
|
+
// The output is not obsolete but it is large and old. We use deterministic heuristics
|
|
125
|
+
// (like top-K truncation or log tailing) to shrink it down.
|
|
126
|
+
let task = '';
|
|
127
|
+
if (i > 0 && messages[i - 1].role === 'user')
|
|
128
|
+
task = messages[i - 1].content;
|
|
129
|
+
// `distillToolResult` will leave its own markers inside the returned compressed string.
|
|
130
|
+
const distilled = await distillToolResult(dummySlm, msg.content, task, currentTool.toolName, currentTool.args, []);
|
|
131
|
+
processed.push({ ...msg, content: distilled });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Final Pass: Deduplication
|
|
135
|
+
// Strip out back-to-back identical messages (usually caused by tool errors or retries)
|
|
136
|
+
// to save a marginal amount of tokens.
|
|
137
|
+
const deduped = [];
|
|
138
|
+
for (let i = 0; i < processed.length; i++) {
|
|
139
|
+
if (i > 0 && processed[i].role === processed[i - 1].role && processed[i].content === processed[i - 1].content) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
deduped.push(processed[i]);
|
|
143
|
+
}
|
|
144
|
+
return deduped;
|
|
145
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard prefixes used by various tools and plugins to indicate
|
|
3
|
+
* that the following text is the result of an automated action,
|
|
4
|
+
* rather than a human user's prompt.
|
|
5
|
+
*/
|
|
6
|
+
export const TOOL_RESULT_PREFIXES = [
|
|
7
|
+
'Tool result:',
|
|
8
|
+
'Tool output:',
|
|
9
|
+
'<tool_response>',
|
|
10
|
+
'<function_results>',
|
|
11
|
+
'Command output:',
|
|
12
|
+
'```output'
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Cursor-specific markers often injected into user messages
|
|
16
|
+
* to represent intermediate tool calls or steps.
|
|
17
|
+
*/
|
|
18
|
+
export const CURSOR_TOOL_MARKERS = [
|
|
19
|
+
'<tool_call>',
|
|
20
|
+
'<tool_response>',
|
|
21
|
+
'Step:'
|
|
22
|
+
];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Human-readable duration rendering for terminal output and reports.
|
|
3
|
+
*
|
|
4
|
+
* This lives apart from `minutesFreed` in pricing/providers.ts on purpose: that function
|
|
5
|
+
* owns the *arithmetic*, this one owns the *presentation*, and only some consumers can use
|
|
6
|
+
* it. The Langfuse dashboard cannot: a numeric score row carries `value: number` and no
|
|
7
|
+
* unit field, and the card aggregates rows server-side, so there is no point at which a
|
|
8
|
+
* formatted string could be attached. Anywhere we build the final string ourselves — the
|
|
9
|
+
* sync summary table, the bench leaderboard — renders through here so they cannot drift.
|
|
10
|
+
*/
|
|
11
|
+
const SECONDS_PER_MINUTE = 60;
|
|
12
|
+
const SECONDS_PER_HOUR = 3600;
|
|
13
|
+
/**
|
|
14
|
+
* Renders a duration in the largest unit that still reads as a real amount of time.
|
|
15
|
+
*
|
|
16
|
+
* Below a minute it stays in seconds with one decimal, because that is the range these
|
|
17
|
+
* estimates actually live in; above it, minutes and seconds; above an hour, hours and
|
|
18
|
+
* minutes. The point is that the reader never has to multiply anything.
|
|
19
|
+
*
|
|
20
|
+
* @param seconds Duration in seconds. Non-finite and non-positive inputs render as '0s'.
|
|
21
|
+
* @returns e.g. '11.4s', '4m 12s', '1h 02m'
|
|
22
|
+
*/
|
|
23
|
+
export function formatDuration(seconds) {
|
|
24
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
25
|
+
return '0s';
|
|
26
|
+
if (seconds < SECONDS_PER_MINUTE) {
|
|
27
|
+
return `${Number(seconds.toFixed(1))}s`;
|
|
28
|
+
}
|
|
29
|
+
// Round to whole seconds BEFORE choosing a unit. Rounding inside each branch let 3599.7s
|
|
30
|
+
// pick the minutes branch and then carry to '60m 00s' instead of '1h 00m'.
|
|
31
|
+
const whole = Math.round(seconds);
|
|
32
|
+
if (whole < SECONDS_PER_HOUR) {
|
|
33
|
+
const minutes = Math.floor(whole / SECONDS_PER_MINUTE);
|
|
34
|
+
const remainder = whole % SECONDS_PER_MINUTE;
|
|
35
|
+
return `${minutes}m ${String(remainder).padStart(2, '0')}s`;
|
|
36
|
+
}
|
|
37
|
+
const hours = Math.floor(whole / SECONDS_PER_HOUR);
|
|
38
|
+
const remainder = Math.round((whole % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
|
|
39
|
+
// The same carry one unit up: 7199s is 1h 59.98m, which rounds to a 60th minute.
|
|
40
|
+
if (remainder === SECONDS_PER_MINUTE)
|
|
41
|
+
return `${hours + 1}h 00m`;
|
|
42
|
+
return `${hours}h ${String(remainder).padStart(2, '0')}m`;
|
|
43
|
+
}
|