@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,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Provider registry — the single, data-driven source of truth for how each
|
|
3
|
+
* subscription provider meters its rolling usage window, and how to recognise one.
|
|
4
|
+
*
|
|
5
|
+
* Two problems this replaces:
|
|
6
|
+
*
|
|
7
|
+
* 1. Provider/agent detection was hard-coded substring matching inside ledger/index.ts, so
|
|
8
|
+
* supporting a new host or vendor meant editing code and cutting a release.
|
|
9
|
+
*
|
|
10
|
+
* 2. "Cycle Extended (min)" was computed as `windowMinutes * (tokensSaved / baselineTokens)`.
|
|
11
|
+
* That multiplies a PER-EVENT COMPRESSION RATIO by a GLOBAL WINDOW LENGTH, which is
|
|
12
|
+
* dimensionally meaningless — it claimed a single tool call that compressed 4,511 tokens
|
|
13
|
+
* to 1,825 freed 178 of 300 window minutes, i.e. two such calls would free the entire
|
|
14
|
+
* window. Converting savings into minutes requires a RATE:
|
|
15
|
+
*
|
|
16
|
+
* minutesFreed = unitsSaved / (windowBudget / windowMinutes)
|
|
17
|
+
*
|
|
18
|
+
* where the unit depends on how the provider actually meters:
|
|
19
|
+
* - 'compute' providers bill tokens -> unitsSaved = tokens saved
|
|
20
|
+
* - 'message' providers bill requests -> unitsSaved = requests never sent (defer_local)
|
|
21
|
+
*
|
|
22
|
+
* `windowBudget` is a real quantity the operator must supply; it is NOT knowable from
|
|
23
|
+
* inside the gate. When it is unset we emit NO score rather than inventing one.
|
|
24
|
+
*
|
|
25
|
+
* Override the built-in defaults by pointing PROVIDER_REGISTRY_PATH at a JSON file with the
|
|
26
|
+
* same shape. Keys are provider ids; unknown ids are accepted, which is what keeps the gate
|
|
27
|
+
* agent- and vendor-agnostic.
|
|
28
|
+
*/
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
/**
|
|
31
|
+
* Shortest permitted match pattern. Matching is substring-based, so two-character needles
|
|
32
|
+
* (the retired 'o1'/'o3'/'o4' OpenAI patterns) collide with unrelated model ids.
|
|
33
|
+
*/
|
|
34
|
+
const MIN_PATTERN_LENGTH = 3;
|
|
35
|
+
export const DEFAULT_PROVIDER_REGISTRY = {
|
|
36
|
+
claude: {
|
|
37
|
+
windowMinutes: 300,
|
|
38
|
+
// Claude's usage limits scale with how much text is sent (message length, attachments,
|
|
39
|
+
// conversation length), not with a flat message count, so tokens saved by distillation
|
|
40
|
+
// do extend the window.
|
|
41
|
+
metering: 'compute',
|
|
42
|
+
windowBudget: null,
|
|
43
|
+
// 'fable' and 'mythos' are current frontier families (claude-fable-5-1); they are NOT
|
|
44
|
+
// covered by the opus/sonnet/haiku tier names. Verified against platform.claude.com
|
|
45
|
+
// 2026-09-13.
|
|
46
|
+
modelPatterns: ['claude', 'anthropic', 'fable', 'mythos', 'opus', 'sonnet', 'haiku'],
|
|
47
|
+
agentPatterns: ['claude'],
|
|
48
|
+
},
|
|
49
|
+
chatgpt: {
|
|
50
|
+
windowMinutes: 180,
|
|
51
|
+
metering: 'message',
|
|
52
|
+
windowBudget: null,
|
|
53
|
+
// The o-series (o1/o3/o4) is RETIRED — OpenAI merged reasoning into the unified GPT
|
|
54
|
+
// line. Those patterns were also unsafe here: matching is substring-based, so a
|
|
55
|
+
// two-character needle like 'o1' can collide with unrelated ids.
|
|
56
|
+
// Every current text model is 'gpt-*' (gpt-6-astra, gpt-5.6-sol/terra/luna/cyber)
|
|
57
|
+
// EXCEPT the daybreak-red / daybreak-blue security aliases, which contain neither
|
|
58
|
+
// 'gpt' nor 'openai' and were previously unattributable.
|
|
59
|
+
// Verified against developers.openai.com/api/docs/models 2026-09-13.
|
|
60
|
+
modelPatterns: ['gpt', 'openai', 'daybreak', 'codex'],
|
|
61
|
+
agentPatterns: ['chatgpt', 'openai', 'codex'],
|
|
62
|
+
},
|
|
63
|
+
gemini: {
|
|
64
|
+
windowMinutes: 300,
|
|
65
|
+
metering: 'compute',
|
|
66
|
+
windowBudget: null,
|
|
67
|
+
// 'bison' is retained only for PaLM-era ids; all current models are gemini-*/gemma-*.
|
|
68
|
+
modelPatterns: ['gemini', 'gemma', 'bison'],
|
|
69
|
+
agentPatterns: ['antigravity', 'gemini'],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Minutes of a provider's window freed by a number of saved units. The one place this
|
|
74
|
+
* formula lives, so the dashboard and the bench report can never disagree.
|
|
75
|
+
*
|
|
76
|
+
* @param profile The provider's profile
|
|
77
|
+
* @param unitsSaved Tokens saved ('compute') or requests avoided ('message')
|
|
78
|
+
* @returns Minutes freed, clamped to [0, windowMinutes], or null when no budget is set.
|
|
79
|
+
*/
|
|
80
|
+
export function minutesFreed(profile, unitsSaved) {
|
|
81
|
+
if (!profile.windowBudget || profile.windowBudget <= 0)
|
|
82
|
+
return null;
|
|
83
|
+
const minutes = unitsSaved * (profile.windowMinutes / profile.windowBudget);
|
|
84
|
+
return Math.min(profile.windowMinutes, Math.max(0, minutes));
|
|
85
|
+
}
|
|
86
|
+
let cachedRegistry = null;
|
|
87
|
+
/**
|
|
88
|
+
* Returns the active provider registry, loading a JSON override once if configured.
|
|
89
|
+
*
|
|
90
|
+
* @param overridePath Optional path to a JSON registry; defaults to PROVIDER_REGISTRY_PATH.
|
|
91
|
+
* @returns Provider id -> profile.
|
|
92
|
+
*/
|
|
93
|
+
export function getProviderRegistry(overridePath = process.env.PROVIDER_REGISTRY_PATH) {
|
|
94
|
+
if (cachedRegistry)
|
|
95
|
+
return cachedRegistry;
|
|
96
|
+
// Clone each profile: a shallow spread would share the profile objects with
|
|
97
|
+
// DEFAULT_PROVIDER_REGISTRY, so writing windowBudget below would permanently mutate the
|
|
98
|
+
// module-level defaults and survive every subsequent reset.
|
|
99
|
+
const registry = Object.fromEntries(Object.entries(DEFAULT_PROVIDER_REGISTRY).map(([id, profile]) => [id, { ...profile }]));
|
|
100
|
+
if (overridePath) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(fs.readFileSync(overridePath, 'utf-8'));
|
|
103
|
+
for (const [id, profile] of Object.entries(parsed)) {
|
|
104
|
+
registry[id] = { ...(registry[id] ?? DEFAULT_PROVIDER_REGISTRY.claude), ...profile };
|
|
105
|
+
}
|
|
106
|
+
// Matching is substring-based, so a very short needle silently mis-attributes
|
|
107
|
+
// unrelated models. This is how the retired 'o1'/'o3'/'o4' patterns became a hazard.
|
|
108
|
+
for (const [id, profile] of Object.entries(registry)) {
|
|
109
|
+
for (const pattern of [...profile.modelPatterns, ...profile.agentPatterns]) {
|
|
110
|
+
if (pattern.length < MIN_PATTERN_LENGTH) {
|
|
111
|
+
console.error(`[providers] Ignoring pattern '${pattern}' for '${id}': patterns must be at least ${MIN_PATTERN_LENGTH} characters to avoid false matches.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
profile.modelPatterns = profile.modelPatterns.filter(p => p.length >= MIN_PATTERN_LENGTH);
|
|
115
|
+
profile.agentPatterns = profile.agentPatterns.filter(p => p.length >= MIN_PATTERN_LENGTH);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
120
|
+
console.error(`[providers] Could not read PROVIDER_REGISTRY_PATH (${overridePath}): ${message}. Using defaults.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Window budgets are operator-supplied, per provider, via env.
|
|
124
|
+
// e.g. CLAUDE_WINDOW_BUDGET=2000000 (tokens per 5h window)
|
|
125
|
+
// CHATGPT_WINDOW_BUDGET=80 (messages per 3h window)
|
|
126
|
+
for (const id of Object.keys(registry)) {
|
|
127
|
+
const raw = process.env[`${id.toUpperCase()}_WINDOW_BUDGET`];
|
|
128
|
+
if (raw) {
|
|
129
|
+
const parsed = Number(raw);
|
|
130
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
131
|
+
registry[id].windowBudget = parsed;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
cachedRegistry = registry;
|
|
135
|
+
return registry;
|
|
136
|
+
}
|
|
137
|
+
/** Test hook: forces the registry to be re-read on next access. */
|
|
138
|
+
export function __resetProviderRegistry() {
|
|
139
|
+
cachedRegistry = null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Identifies the provider behind a model id.
|
|
143
|
+
*
|
|
144
|
+
* @param model Model id as reported by the host, e.g. 'claude-opus-5[1m]'
|
|
145
|
+
* @returns Provider id, or null when nothing matches.
|
|
146
|
+
*/
|
|
147
|
+
export function providerFromModelId(model) {
|
|
148
|
+
if (!model)
|
|
149
|
+
return null;
|
|
150
|
+
const lower = model.toLowerCase();
|
|
151
|
+
for (const [id, profile] of Object.entries(getProviderRegistry())) {
|
|
152
|
+
if (profile.modelPatterns.some(p => lower.includes(p)))
|
|
153
|
+
return id;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Identifies the provider behind a host/agent name.
|
|
159
|
+
*
|
|
160
|
+
* @param agent Agent or host identifier, e.g. 'claude-code'
|
|
161
|
+
* @returns Provider id, or null when nothing matches.
|
|
162
|
+
*/
|
|
163
|
+
export function providerFromAgentName(agent) {
|
|
164
|
+
if (!agent)
|
|
165
|
+
return null;
|
|
166
|
+
const lower = agent.toLowerCase();
|
|
167
|
+
for (const [id, profile] of Object.entries(getProviderRegistry())) {
|
|
168
|
+
if (profile.agentPatterns.some(p => lower.includes(p)))
|
|
169
|
+
return id;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
3
|
+
import { selfConsistency } from '../models/reasoning.js';
|
|
4
|
+
import { getDb, writeEvent } from '../ledger/index.js';
|
|
5
|
+
import { CONFIG } from '../config.js';
|
|
6
|
+
import { withSlmTimeout } from '../models/helpers.js';
|
|
7
|
+
// Helper to determine if we can afford a cloud call
|
|
8
|
+
function checkCloudBudget() {
|
|
9
|
+
if (!CONFIG.RESOLVER_CLOUD_TIER || !CONFIG.CLOUD_API_KEY || !CONFIG.CLOUD_MODEL) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (CONFIG.RESOLVER_CLOUD_BUDGET_USD <= 0) {
|
|
13
|
+
return false; // budget is 0
|
|
14
|
+
}
|
|
15
|
+
const row = getDb().prepare('SELECT SUM(cost_usd) as total FROM events').get();
|
|
16
|
+
const spent = row.total || 0;
|
|
17
|
+
return spent < CONFIG.RESOLVER_CLOUD_BUDGET_USD;
|
|
18
|
+
}
|
|
19
|
+
// Bounded cloud API call
|
|
20
|
+
async function batchCloudResolve(unresolved, skillText, task) {
|
|
21
|
+
if (unresolved.length === 0)
|
|
22
|
+
return;
|
|
23
|
+
const prompt = `You are an expert developer resolving ambiguities for a local agent.
|
|
24
|
+
Task: ${task}
|
|
25
|
+
Skill/Context: ${skillText}
|
|
26
|
+
|
|
27
|
+
For the following open decisions, provide the best answer. If uncertain, leave it null.
|
|
28
|
+
Decisions:
|
|
29
|
+
${unresolved.map(u => `- [${u.id}] ${u.question}`).join('\n')}
|
|
30
|
+
|
|
31
|
+
Respond in JSON matching this schema:
|
|
32
|
+
{
|
|
33
|
+
"answers": [
|
|
34
|
+
{ "id": "decision_id", "answer": "best answer or null", "confidence": 0.0 to 1.0, "options": ["opt1", "opt2"] }
|
|
35
|
+
]
|
|
36
|
+
}`;
|
|
37
|
+
const schema = z.object({
|
|
38
|
+
answers: z.array(z.object({
|
|
39
|
+
id: z.string(),
|
|
40
|
+
answer: z.string().nullable(),
|
|
41
|
+
confidence: z.number(),
|
|
42
|
+
options: z.array(z.string()).optional()
|
|
43
|
+
}))
|
|
44
|
+
});
|
|
45
|
+
const startTime = Date.now();
|
|
46
|
+
let inTok = prompt.length / 4; // rough estimate
|
|
47
|
+
let outTok = 0;
|
|
48
|
+
let cost = 0;
|
|
49
|
+
try {
|
|
50
|
+
const url = CONFIG.CLOUD_BASE_URL || (CONFIG.CLOUD_API_STYLE === 'anthropic' ? 'https://api.anthropic.com/v1/messages' : 'https://api.openai.com/v1/chat/completions');
|
|
51
|
+
// Using standard OpenAI compatible payload for routing purposes since it natively supports json_schema
|
|
52
|
+
const res = await fetch(url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
'Authorization': `Bearer ${CONFIG.CLOUD_API_KEY}`
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
model: CONFIG.CLOUD_MODEL,
|
|
60
|
+
messages: [{ role: 'user', content: prompt }],
|
|
61
|
+
response_format: {
|
|
62
|
+
type: "json_schema",
|
|
63
|
+
json_schema: {
|
|
64
|
+
name: "resolver_output",
|
|
65
|
+
strict: true,
|
|
66
|
+
schema: zodToJsonSchema(schema)
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
temperature: 0
|
|
70
|
+
})
|
|
71
|
+
});
|
|
72
|
+
if (res.ok) {
|
|
73
|
+
const data = await res.json();
|
|
74
|
+
const content = data.choices[0].message.content;
|
|
75
|
+
outTok = content.length / 4;
|
|
76
|
+
try {
|
|
77
|
+
const parsed = schema.parse(JSON.parse(content));
|
|
78
|
+
for (const item of parsed.answers) {
|
|
79
|
+
const target = unresolved.find(u => u.id === item.id);
|
|
80
|
+
if (target && item.answer !== null) {
|
|
81
|
+
target.answer = item.answer;
|
|
82
|
+
target.confidence = item.confidence;
|
|
83
|
+
target.source = 'api';
|
|
84
|
+
if (item.options)
|
|
85
|
+
target.options = item.options;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// failed to parse
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
// network or other error
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
const { calculateCostUsd } = await import('../pricing/index.js');
|
|
99
|
+
try {
|
|
100
|
+
cost = calculateCostUsd(CONFIG.CLOUD_MODEL || 'unknown', inTok, outTok);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
cost = 0;
|
|
104
|
+
}
|
|
105
|
+
writeEvent({
|
|
106
|
+
ts: new Date().toISOString(),
|
|
107
|
+
layer: 'mcp',
|
|
108
|
+
request_id: `resolver_cloud_${Date.now()}`,
|
|
109
|
+
route: 'escalate',
|
|
110
|
+
is_local_call: 0,
|
|
111
|
+
api_model: CONFIG.CLOUD_MODEL || 'unknown',
|
|
112
|
+
in_tok: 0,
|
|
113
|
+
out_tok: 0,
|
|
114
|
+
api_in_tok: Math.round(inTok),
|
|
115
|
+
api_out_tok: Math.round(outTok),
|
|
116
|
+
cost_usd: cost,
|
|
117
|
+
slm_latency_s: 0,
|
|
118
|
+
api_latency_s: (Date.now() - startTime) / 1000,
|
|
119
|
+
slm_gate: 'on'
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function extractAmbiguities(slm, skillText, task) {
|
|
124
|
+
const schema = z.object({
|
|
125
|
+
decisions: z.array(z.object({
|
|
126
|
+
id: z.string(),
|
|
127
|
+
question: z.string(),
|
|
128
|
+
kind: z.string()
|
|
129
|
+
}))
|
|
130
|
+
});
|
|
131
|
+
const prompt = `Extract concrete open decisions or ambiguities from the following task and skill context.
|
|
132
|
+
Task: ${task}
|
|
133
|
+
Context: ${skillText}
|
|
134
|
+
|
|
135
|
+
Output a list of decisions that need to be made before work can begin. If none, return an empty array. Limit to AT MOST 3 critical decisions. Be very concise.`;
|
|
136
|
+
const res = await withSlmTimeout(slm.generateJSON(CONFIG.SLM_BRAIN_MODEL, prompt, schema, 0), 'resolver', CONFIG.SLM_TIMEOUT_MS);
|
|
137
|
+
return res.decisions;
|
|
138
|
+
}
|
|
139
|
+
export async function resolveEach(slm, fsRead, decisions, repoRoot) {
|
|
140
|
+
const results = [];
|
|
141
|
+
for (const dec of decisions) {
|
|
142
|
+
let resolved = {
|
|
143
|
+
id: dec.id,
|
|
144
|
+
question: dec.question,
|
|
145
|
+
answer: null,
|
|
146
|
+
confidence: 0,
|
|
147
|
+
source: 'unresolved'
|
|
148
|
+
};
|
|
149
|
+
// (a) EVIDENCE
|
|
150
|
+
if (repoRoot) {
|
|
151
|
+
const patternSchema = z.object({ patterns: z.array(z.string()) });
|
|
152
|
+
try {
|
|
153
|
+
const patternPrompt = `What file paths or contents should we grep to answer this question: "${dec.question}"?
|
|
154
|
+
Return 1 or 2 simple patterns like "package.json" or "yarn.lock" or "docker-compose.yml".`;
|
|
155
|
+
const patternRes = await withSlmTimeout(slm.generateJSON(CONFIG.SLM_BRAIN_MODEL, patternPrompt, patternSchema, 0), 'resolver', CONFIG.SLM_TIMEOUT_MS);
|
|
156
|
+
let evidenceFound = '';
|
|
157
|
+
for (const p of patternRes.patterns) {
|
|
158
|
+
const contents = await fsRead(p);
|
|
159
|
+
if (contents.length > 0) {
|
|
160
|
+
// Single `\n`: the evidence block was previously joined with the literal characters
|
|
161
|
+
// backslash-n, so the brain model saw one unbroken line of file contents.
|
|
162
|
+
evidenceFound += contents.join('\n') + '\n';
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (evidenceFound) {
|
|
167
|
+
const ansSchema = z.object({ answer: z.string(), options: z.array(z.string()) });
|
|
168
|
+
const ansPrompt = `Given this evidence from the repository:\n${evidenceFound.slice(0, 1000)}\nAnswer the question: ${dec.question}`;
|
|
169
|
+
const ansRes = await withSlmTimeout(slm.generateJSON(CONFIG.SLM_BRAIN_MODEL, ansPrompt, ansSchema, 0), 'resolver', CONFIG.SLM_TIMEOUT_MS);
|
|
170
|
+
resolved = {
|
|
171
|
+
id: dec.id,
|
|
172
|
+
question: dec.question,
|
|
173
|
+
answer: ansRes.answer,
|
|
174
|
+
confidence: 0.95,
|
|
175
|
+
source: 'evidence',
|
|
176
|
+
evidence: `Found evidence matching pattern.`,
|
|
177
|
+
options: ansRes.options
|
|
178
|
+
};
|
|
179
|
+
results.push(resolved);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
// ignore and fall through
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// (b) & (c) CONVENTION & SELF-CONSISTENCY
|
|
188
|
+
const scSchema = z.object({
|
|
189
|
+
answer: z.string(),
|
|
190
|
+
options: z.array(z.string())
|
|
191
|
+
});
|
|
192
|
+
const scPrompt = `Based on standard software conventions, answer this open decision:
|
|
193
|
+
Question: ${dec.question}
|
|
194
|
+
Provide the most standard choice as 'answer' and 2-3 alternatives as 'options'.`;
|
|
195
|
+
try {
|
|
196
|
+
const scRes = await selfConsistency(slm, CONFIG.SLM_BRAIN_MODEL, scPrompt, scSchema, CONFIG.SELF_CONSISTENCY_K, CONFIG.SELF_CONSISTENCY_TEMP);
|
|
197
|
+
resolved = {
|
|
198
|
+
id: dec.id,
|
|
199
|
+
question: dec.question,
|
|
200
|
+
answer: scRes.answer,
|
|
201
|
+
confidence: 0.6,
|
|
202
|
+
source: 'convention',
|
|
203
|
+
options: scRes.options
|
|
204
|
+
};
|
|
205
|
+
if (CONFIG.SELF_CONSISTENCY_K > 1) {
|
|
206
|
+
resolved.source = 'self_consistency';
|
|
207
|
+
resolved.confidence = 0.75;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
// ignore
|
|
212
|
+
}
|
|
213
|
+
results.push(resolved);
|
|
214
|
+
}
|
|
215
|
+
return results;
|
|
216
|
+
}
|
|
217
|
+
export async function gateDecisions(slm, items) {
|
|
218
|
+
const output = {
|
|
219
|
+
autoApplied: [],
|
|
220
|
+
askUser: []
|
|
221
|
+
};
|
|
222
|
+
const schema = z.object({
|
|
223
|
+
risk: z.enum(['reversible', 'low', 'security', 'destructive'])
|
|
224
|
+
});
|
|
225
|
+
for (const item of items) {
|
|
226
|
+
let risk = 'low';
|
|
227
|
+
try {
|
|
228
|
+
const prompt = `Classify the risk of deciding this question automatically:
|
|
229
|
+
Question: ${item.question}
|
|
230
|
+
Answer: ${item.answer}
|
|
231
|
+
|
|
232
|
+
Categories:
|
|
233
|
+
- reversible: trivial formatting, naming conventions, minor tools
|
|
234
|
+
- low: standard library choices, standard config
|
|
235
|
+
- security: auth, tokens, crypto, permissions, network exposure
|
|
236
|
+
- destructive: dropping databases, deleting files, overwriting critical data`;
|
|
237
|
+
const res = await withSlmTimeout(slm.generateJSON(CONFIG.SLM_GATE_MODEL, prompt, schema, 0), 'resolver', CONFIG.SLM_TIMEOUT_MS);
|
|
238
|
+
risk = res.risk;
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
risk = 'security';
|
|
242
|
+
}
|
|
243
|
+
item.risk = risk;
|
|
244
|
+
if (item.confidence >= 0.8 && (risk === 'reversible' || risk === 'low')) {
|
|
245
|
+
output.autoApplied.push({
|
|
246
|
+
id: item.id,
|
|
247
|
+
question: item.question,
|
|
248
|
+
answer: item.answer || 'Unknown',
|
|
249
|
+
note: `Auto-resolved based on ${item.source}`
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
output.askUser.push({
|
|
254
|
+
id: item.id,
|
|
255
|
+
question: item.question,
|
|
256
|
+
recommendedAnswer: item.answer,
|
|
257
|
+
confidence: item.confidence,
|
|
258
|
+
options: item.options || [],
|
|
259
|
+
evidence: item.evidence
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return output;
|
|
264
|
+
}
|
|
265
|
+
export async function resolveAmbiguities(slm, fsRead, input) {
|
|
266
|
+
const decisions = await extractAmbiguities(slm, input.skillText, input.task);
|
|
267
|
+
if (decisions.length === 0) {
|
|
268
|
+
return { autoApplied: [], askUser: [] };
|
|
269
|
+
}
|
|
270
|
+
const resolved = await resolveEach(slm, fsRead, decisions, input.repoRoot);
|
|
271
|
+
// Cloud API Tier
|
|
272
|
+
const unresolved = resolved.filter(r => r.confidence < 0.8);
|
|
273
|
+
if (unresolved.length > 0 && checkCloudBudget()) {
|
|
274
|
+
await batchCloudResolve(unresolved, input.skillText, input.task);
|
|
275
|
+
}
|
|
276
|
+
return gateDecisions(slm, resolved);
|
|
277
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview "At most once per time window, across every slm-gate process on this machine." Several
|
|
3
|
+
* copies of slm-gate's MCP server run at once (one per IDE window / CLI session); this is how they agree
|
|
4
|
+
* that only one of them shows a notification or launches the model gate.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
/**
|
|
9
|
+
* True for exactly one caller per window. Windows are fixed slices of time (with a minute: 12:00–12:01,
|
|
10
|
+
* 12:01–12:02, …), and each has its own marker file, created atomically (`wx`): of several callers in one
|
|
11
|
+
* window only the first succeeds. Nothing is ever read back or replaced, so no caller can mistake a marker
|
|
12
|
+
* that another process is still writing, or has just replaced, for an expired one.
|
|
13
|
+
*
|
|
14
|
+
* @param params.file The marker file for this action; the window number is appended
|
|
15
|
+
* @param params.windowMs How long one window lasts
|
|
16
|
+
* @param params.now Current time, for tests
|
|
17
|
+
*/
|
|
18
|
+
export function claimWindow(params) {
|
|
19
|
+
const window = Math.floor((params.now ?? Date.now()) / params.windowMs);
|
|
20
|
+
const dir = path.dirname(params.file);
|
|
21
|
+
try {
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
23
|
+
fs.writeFileSync(`${params.file}.${window}`, '', { flag: 'wx' });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false; // another process claimed this window first (or the folder is not writable)
|
|
27
|
+
}
|
|
28
|
+
// Markers of earlier windows are never looked at again. The previous one is kept so a caller whose clock
|
|
29
|
+
// reads a moment earlier still finds it.
|
|
30
|
+
const prefix = `${path.basename(params.file)}.`;
|
|
31
|
+
try {
|
|
32
|
+
for (const name of fs.readdirSync(dir)) {
|
|
33
|
+
if (name.startsWith(prefix) && Number(name.slice(prefix.length)) < window - 1)
|
|
34
|
+
fs.rmSync(path.join(dir, name), { force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Clean-up only.
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `slm-gate start | stop | restart` — recovery commands for the model gate. Day to day it
|
|
3
|
+
* starts by itself whenever a coding tool starts slm-gate's MCP server; these are for when you want to
|
|
4
|
+
* control it by hand (`stop` keeps it stopped until `start`, `restart` or a reboot).
|
|
5
|
+
*/
|
|
6
|
+
import { CONFIG } from '../config.js';
|
|
7
|
+
import { cliCommand, GATE_LOG_FILE, startModelGate, stopModelGate } from './model-gate.js';
|
|
8
|
+
const address = `http://localhost:${CONFIG.MODEL_GATE_PORT}`;
|
|
9
|
+
async function main(action) {
|
|
10
|
+
if (!['start', 'stop', 'restart'].includes(action)) {
|
|
11
|
+
console.error(`Unknown action "${action}": use start, stop or restart.`);
|
|
12
|
+
process.exitCode = 1;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (action === 'stop' || action === 'restart') {
|
|
16
|
+
const stopped = await stopModelGate();
|
|
17
|
+
console.log(stopped ? `Stopped the model gate on ${address} (pid ${stopped.pid}).` : `No model gate was running on ${address}.`);
|
|
18
|
+
if (action === 'stop') {
|
|
19
|
+
console.log(`It stays stopped until \`${cliCommand('start')}\`, \`${cliCommand('restart')}\` or a reboot. Coding tools pointed at it cannot reach their AI provider meanwhile.`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const probe = await startModelGate();
|
|
24
|
+
if (probe.kind === 'slm-gate') {
|
|
25
|
+
console.log(`The model gate is running on ${address} (pid ${probe.health.pid}).`);
|
|
26
|
+
if (probe.stale)
|
|
27
|
+
console.log(`It is an older slm-gate build: run \`${cliCommand('restart')}\` when no coding tool is in the middle of an answer.`);
|
|
28
|
+
}
|
|
29
|
+
else if (probe.kind === 'other') {
|
|
30
|
+
console.error(`Port ${CONFIG.MODEL_GATE_PORT} is used by another program, so the model gate cannot start there. Run \`${cliCommand('doctor')}\` to see which program and how to change the port.`);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.error(`The model gate did not start. See ${GATE_LOG_FILE}, or run \`${cliCommand('doctor')}\`.`);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
main(process.argv[2] ?? 'start').catch(err => {
|
|
39
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `slm-gate init`: creates the settings file (`.env` in the settings folder, src/home-dir.ts).
|
|
3
|
+
*
|
|
4
|
+
* It copies the full template (`.env.example`, every setting explained) and fills in only the lines that
|
|
5
|
+
* depend on this computer, with the values doctor recommends for its RAM. It assumes no coding tool, AI
|
|
6
|
+
* provider or toolbox: PROVIDER is left blank, and so are keys and DOWNSTREAM_MCP. An existing settings
|
|
7
|
+
* file is never replaced.
|
|
8
|
+
*/
|
|
9
|
+
import { parse } from 'dotenv';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { MIN_RAM_GB, modelsForRam, recommendNumCtx, recommendPreset } from '../hardware.js';
|
|
13
|
+
import { modelsFor } from './required-models.js';
|
|
14
|
+
/**
|
|
15
|
+
* The template lines `init` fills in for a computer with `ramGb` of RAM: the same preset, models and
|
|
16
|
+
* NUM_CTX doctor recommends (src/hardware.ts). Above 128 GB the preset is `custom`, with the 128 GB models.
|
|
17
|
+
*
|
|
18
|
+
* @throws When `ramGb` is below MIN_RAM_GB, which slm-gate does not support
|
|
19
|
+
*/
|
|
20
|
+
export function settingsForRam(ramGb) {
|
|
21
|
+
if (ramGb < MIN_RAM_GB) {
|
|
22
|
+
throw new Error(`slm-gate needs at least ${MIN_RAM_GB} GB of RAM; this is set up for ${ramGb} GB.`);
|
|
23
|
+
}
|
|
24
|
+
const preset = recommendPreset(ramGb);
|
|
25
|
+
const { brain, gate } = modelsForRam(ramGb);
|
|
26
|
+
return {
|
|
27
|
+
preset,
|
|
28
|
+
lines: {
|
|
29
|
+
RAM_PRESET: preset,
|
|
30
|
+
SLM_BRAIN_MODEL: brain,
|
|
31
|
+
SLM_GATE_MODEL: gate,
|
|
32
|
+
SLM_GATE_TESTING_MODEL: gate,
|
|
33
|
+
NUM_CTX: String(recommendNumCtx(ramGb)),
|
|
34
|
+
PROVIDER: '',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Sets `KEY=value` lines in a settings template.
|
|
40
|
+
*
|
|
41
|
+
* @param template The template text
|
|
42
|
+
* @param lines The values to set, by key
|
|
43
|
+
* @returns The template with those lines set
|
|
44
|
+
* @throws When a key is not on exactly one line of its own: the template changed, and guessing where the
|
|
45
|
+
* setting goes could leave the file with a value slm-gate never reads
|
|
46
|
+
*/
|
|
47
|
+
export function fillSettings(template, lines) {
|
|
48
|
+
return Object.entries(lines).reduce((text, [key, value]) => {
|
|
49
|
+
const line = new RegExp(`^${key}=.*$`, 'gm');
|
|
50
|
+
const found = text.match(line)?.length ?? 0;
|
|
51
|
+
if (found !== 1)
|
|
52
|
+
throw new Error(`.env.example has ${found} ${key}= lines; expected exactly 1.`);
|
|
53
|
+
return text.replace(line, `${key}=${value}`);
|
|
54
|
+
}, template);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The local models a settings file needs, by the start-up check's rule (src/setup/required-models.ts), so
|
|
58
|
+
* following init's download step leaves nothing for that check to report.
|
|
59
|
+
*/
|
|
60
|
+
function modelsIn(settingsText) {
|
|
61
|
+
const settings = parse(settingsText);
|
|
62
|
+
// As config.ts reads them: on, true or 1 is on; anything else, blank included, is off (both default to off).
|
|
63
|
+
const isOn = (value) => ['on', 'true', '1'].includes((value ?? '').toLowerCase());
|
|
64
|
+
const models = modelsFor({
|
|
65
|
+
SLM_GATE_MODEL: settings.SLM_GATE_MODEL ?? '',
|
|
66
|
+
SLM_BRAIN_MODEL: settings.SLM_BRAIN_MODEL ?? '',
|
|
67
|
+
EMBED_MODEL: settings.EMBED_MODEL ?? '',
|
|
68
|
+
SEMCACHE: isOn(settings.SEMCACHE),
|
|
69
|
+
DISTILL_ADAPTIVE: isOn(settings.DISTILL_ADAPTIVE),
|
|
70
|
+
});
|
|
71
|
+
return [...new Set(models.map(model => model.name).filter(Boolean))];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Creates the settings file for this computer, unless one exists.
|
|
75
|
+
*
|
|
76
|
+
* @param params.installDir The install folder, where `.env.example` is
|
|
77
|
+
* @param params.homeDir The settings folder
|
|
78
|
+
* @param params.ramGb This computer's RAM, in GB
|
|
79
|
+
* @returns Whether the file was created, where it is, the RAM preset and the models to download
|
|
80
|
+
*/
|
|
81
|
+
export function initSettings(params) {
|
|
82
|
+
const envPath = path.join(params.homeDir, '.env');
|
|
83
|
+
const { preset, lines } = settingsForRam(params.ramGb);
|
|
84
|
+
if (fs.existsSync(envPath))
|
|
85
|
+
return { created: false, envPath, preset, models: modelsIn(fs.readFileSync(envPath, 'utf8')) };
|
|
86
|
+
const template = fs.readFileSync(path.join(params.installDir, '.env.example'), 'utf8');
|
|
87
|
+
const text = fillSettings(template, lines);
|
|
88
|
+
fs.mkdirSync(params.homeDir, { recursive: true });
|
|
89
|
+
// 'wx' fails rather than replace a file created meanwhile; 0o600 because the file may later hold keys.
|
|
90
|
+
fs.writeFileSync(envPath, text, { flag: 'wx', mode: 0o600 });
|
|
91
|
+
return { created: true, envPath, preset, models: modelsIn(text) };
|
|
92
|
+
}
|