@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,556 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { CONFIG } from '../config.js';
|
|
4
|
+
import { getDb, getDistillFeedback, getDistillPolicy, writeElision } from '../ledger/index.js';
|
|
5
|
+
import { bufferToFloat64Array, cosineSimilarity, embedText } from '../utils/embedding.js';
|
|
6
|
+
import { TOOL_RESULT_PREFIXES } from './constants.js';
|
|
7
|
+
// NOTE:: "Elision" meaning is; Leaving out a sound, a syllable, or a word part when speaking.
|
|
8
|
+
/**
|
|
9
|
+
* Narrative runs shorter than this are left verbatim during summarize-mode compression.
|
|
10
|
+
* A model round-trip on a line or two costs more wall-clock time than the tokens it saves.
|
|
11
|
+
*/
|
|
12
|
+
const MIN_COMPRESSIBLE_SEGMENT_CHARS = 400;
|
|
13
|
+
/**
|
|
14
|
+
* How many narrative runs are in flight at the model at once.
|
|
15
|
+
*
|
|
16
|
+
* Ollama executes at most OLLAMA_NUM_PARALLEL requests (1 on most hosts) and queues the rest,
|
|
17
|
+
* while the per-call timeout is a Promise.race that cannot cancel the queued generation. Firing
|
|
18
|
+
* every run at once therefore made late runs burn their whole SLM_TIMEOUT_MS waiting in Ollama's
|
|
19
|
+
* queue, time out having never run, and then keep occupying the GPU after the caller had given
|
|
20
|
+
* up — starving the runs still queued behind them. A run is now handed to the model only when a
|
|
21
|
+
* slot frees up, so its timer starts when its generation can actually start.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_CONCURRENT_SEGMENT_CALLS = 2;
|
|
24
|
+
/**
|
|
25
|
+
* Computes a deterministic SHA-256 ID for a tool elision.
|
|
26
|
+
* By hashing the tool name, arguments, and original content, we ensure that
|
|
27
|
+
* identical tool outputs always produce the exact same elision ID,
|
|
28
|
+
* enabling safe and highly-cacheable reconstruction logic.
|
|
29
|
+
*
|
|
30
|
+
* @param toolName - The name of the tool (e.g., 'read_file')
|
|
31
|
+
* @param args - The structured arguments provided to the tool
|
|
32
|
+
* @param originalText - The complete, uncompressed raw text returned by the tool
|
|
33
|
+
* @returns A unique hexadecimal SHA-256 hash identifying this exact output state
|
|
34
|
+
*/
|
|
35
|
+
export function computeElisionId(toolName, args, originalText) {
|
|
36
|
+
const hashText = crypto.createHash('sha256').update(originalText).digest('hex');
|
|
37
|
+
const payload = `${toolName}:${JSON.stringify(args || {})}:${hashText}`;
|
|
38
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Formats a clear, identifiable text marker indicating that lines were elided.
|
|
42
|
+
* This marker informs downstream LLMs that content is missing and provides the `expand_elision`
|
|
43
|
+
* ID they can use if they determine they actually need the omitted lines.
|
|
44
|
+
*
|
|
45
|
+
* @param elisionId - The unique SHA-256 hash for the original content
|
|
46
|
+
* @param elidedLinesCount - Number of lines that were removed
|
|
47
|
+
* @param startLine - Optional starting line index of the removed chunk
|
|
48
|
+
* @param endLine - Optional ending line index of the removed chunk
|
|
49
|
+
* @returns A formatted string marker designed for robust parsing
|
|
50
|
+
*/
|
|
51
|
+
export function formatElisionMarker(elisionId, elidedLinesCount, startLine, endLine) {
|
|
52
|
+
const hasRange = startLine !== undefined && endLine !== undefined;
|
|
53
|
+
const rangeStr = hasRange ? `, lines ${startLine}-${endLine}` : '';
|
|
54
|
+
// Spell out the range argument: without it, expand_elision starts from the top of the
|
|
55
|
+
// original, which looks like the same cut text coming back.
|
|
56
|
+
const how = hasRange
|
|
57
|
+
? `call expand_elision with this id and range {"startLine": ${startLine}, "endLine": ${endLine}} to retrieve`
|
|
58
|
+
: 'call expand_elision with this id to retrieve';
|
|
59
|
+
return `\n... ${elidedLinesCount} lines elided [id: ${elisionId}${rangeStr}] — ${how} ...\n`;
|
|
60
|
+
}
|
|
61
|
+
// Matches the retrieval clause formatElisionMarker writes, with or without a range. Kept next to it so
|
|
62
|
+
// a wording change is made in both places; a test pairs them.
|
|
63
|
+
const EXPAND_HINT = /— call expand_elision with this id(?: and range \{"startLine": \d+, "endLine": \d+\})? to retrieve \.\.\./g;
|
|
64
|
+
/**
|
|
65
|
+
* Rewrites the markers in a distilled text for a client that has no `expand_elision` tool (the MCP
|
|
66
|
+
* layer is optional): the marker still says how many lines are missing, but points the model at
|
|
67
|
+
* re-running its own tool instead of a tool it cannot call.
|
|
68
|
+
*
|
|
69
|
+
* @param text Distilled text containing markers from formatElisionMarker
|
|
70
|
+
* @returns The same text with every retrieval clause replaced
|
|
71
|
+
*/
|
|
72
|
+
export function rewriteElisionHint(text) {
|
|
73
|
+
return text.replace(EXPAND_HINT, '— not shown by slm-gate; re-run the tool with a narrower scope to see them ...');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Takes as many lines from a range as fit in a token budget, never skipping any.
|
|
77
|
+
*
|
|
78
|
+
* @param params.lines All lines of the original text
|
|
79
|
+
* @param params.startLine First line wanted (inclusive)
|
|
80
|
+
* @param params.endLine Last line wanted (inclusive)
|
|
81
|
+
* @param params.maxTokens Budget for this page
|
|
82
|
+
* @returns The page text, and the first line not yet returned (undefined when done)
|
|
83
|
+
*/
|
|
84
|
+
export function pageLines(params) {
|
|
85
|
+
const { lines, startLine, endLine, maxTokens } = params;
|
|
86
|
+
const page = [];
|
|
87
|
+
let tokens = 0;
|
|
88
|
+
let i = startLine;
|
|
89
|
+
for (; i <= endLine; i++) {
|
|
90
|
+
const lineTokens = estimateTokens(lines[i] + '\n');
|
|
91
|
+
// Always return at least one line, so paging can never stall.
|
|
92
|
+
if (page.length > 0 && tokens + lineTokens > maxTokens)
|
|
93
|
+
break;
|
|
94
|
+
page.push(lines[i]);
|
|
95
|
+
tokens += lineTokens;
|
|
96
|
+
}
|
|
97
|
+
return { text: page.join('\n'), nextStart: i <= endLine ? i : undefined };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Estimates the number of tokens in a string based on a rough character-to-token heuristic.
|
|
101
|
+
* Used for fast, zero-dependency short-circuiting before invoking the actual SLM.
|
|
102
|
+
*
|
|
103
|
+
* @param text - The text to evaluate
|
|
104
|
+
* @returns Estimated token count (roughly 1 token per 3.5 characters)
|
|
105
|
+
*/
|
|
106
|
+
export function estimateTokens(text) {
|
|
107
|
+
return Math.ceil(text.length / 3.5);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Attempts to parse raw tool-result context to deduce the tool name, arguments, and target file.
|
|
111
|
+
* Many MCP clients just dump raw text; this heuristic extractor tries to glean structure
|
|
112
|
+
* by matching common JSON wrappers, path structures, and standard prefixes.
|
|
113
|
+
*
|
|
114
|
+
* @param content - The raw context string to analyze
|
|
115
|
+
* @returns An `ExtractResult` containing any successfully deduced tool metadata
|
|
116
|
+
*/
|
|
117
|
+
export function extractToolSignature(content) {
|
|
118
|
+
let toolName;
|
|
119
|
+
let args;
|
|
120
|
+
let filePath;
|
|
121
|
+
// Try to parse JSON blocks that might represent tool calls
|
|
122
|
+
try {
|
|
123
|
+
const jsonMatch = content.match(/```json\n([\s\S]*?)\n```/);
|
|
124
|
+
if (jsonMatch) {
|
|
125
|
+
const parsed = JSON.parse(jsonMatch[1]);
|
|
126
|
+
if (parsed.name || parsed.tool)
|
|
127
|
+
toolName = parsed.name || parsed.tool;
|
|
128
|
+
if (parsed.arguments || parsed.args)
|
|
129
|
+
args = parsed.arguments || parsed.args;
|
|
130
|
+
if (args && args.path)
|
|
131
|
+
filePath = args.path;
|
|
132
|
+
if (args && args.file)
|
|
133
|
+
filePath = args.file;
|
|
134
|
+
if (args && args.AbsolutePath)
|
|
135
|
+
filePath = args.AbsolutePath;
|
|
136
|
+
if (args && args.TargetFile)
|
|
137
|
+
filePath = args.TargetFile;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (e) { }
|
|
141
|
+
// Fallback regex for common file path mentions
|
|
142
|
+
if (!filePath) {
|
|
143
|
+
const pathMatch = content.match(/(?:\/|\\|^[a-zA-Z]:\\)(?:[^\s"'<>|]+)+/);
|
|
144
|
+
if (pathMatch)
|
|
145
|
+
filePath = pathMatch[0];
|
|
146
|
+
}
|
|
147
|
+
// Fallback for tool name if it starts with something like "Tool output: read_file"
|
|
148
|
+
if (!toolName) {
|
|
149
|
+
for (const prefix of TOOL_RESULT_PREFIXES) {
|
|
150
|
+
if (content.includes(prefix)) {
|
|
151
|
+
const parts = content.split(prefix);
|
|
152
|
+
if (parts.length > 1) {
|
|
153
|
+
const words = parts[1].trim().split(/[\s:()]+/);
|
|
154
|
+
if (words.length > 0 && words[0].length > 0) {
|
|
155
|
+
toolName = words[0];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { toolName, args, filePath };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Analyzes file contents line-by-line to identify critical structural lines
|
|
165
|
+
* (e.g., imports, function signatures) and lines conceptually related to the user's task.
|
|
166
|
+
*
|
|
167
|
+
* Used during deterministic distillation of `read_file` operations to safely truncate
|
|
168
|
+
* unneeded implementation details while preserving the file's "skeleton".
|
|
169
|
+
*
|
|
170
|
+
* @param lines - Array of lines comprising the file
|
|
171
|
+
* @param task - The active user task string, used to derive search terms
|
|
172
|
+
* @param searchTerms - Explicit additional search terms to match
|
|
173
|
+
* @returns A sorted array of 0-indexed line numbers that should be preserved
|
|
174
|
+
*/
|
|
175
|
+
export function findRelevantRegions(lines, task, searchTerms = []) {
|
|
176
|
+
const keepLines = new Set();
|
|
177
|
+
// Create lowercase search terms from task
|
|
178
|
+
const terms = new Set(searchTerms.map(t => t.toLowerCase()));
|
|
179
|
+
if (task) {
|
|
180
|
+
task.split(/\W+/).filter(w => w.length > 3).forEach(w => terms.add(w.toLowerCase()));
|
|
181
|
+
}
|
|
182
|
+
lines.forEach((line, i) => {
|
|
183
|
+
const lowerLine = line.toLowerCase();
|
|
184
|
+
// 1. Keep skeleton: imports, exports, class/function signatures
|
|
185
|
+
if (/^(?:import|export|class|function|interface|type)\s/.test(line)) {
|
|
186
|
+
keepLines.add(i);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// 2. Keep lines matching task terms
|
|
190
|
+
for (const term of terms) {
|
|
191
|
+
if (lowerLine.includes(term)) {
|
|
192
|
+
// Keep a window around the match
|
|
193
|
+
for (let j = Math.max(0, i - 2); j <= Math.min(lines.length - 1, i + 2); j++) {
|
|
194
|
+
keepLines.add(j);
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
return Array.from(keepLines).sort((a, b) => a - b);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Condenses a sparse array of kept line indices into contiguous block objects.
|
|
204
|
+
* Joins line ranges that are separated by small gaps to prevent fragmented,
|
|
205
|
+
* unreadable output with too many elision markers.
|
|
206
|
+
*
|
|
207
|
+
* @param lines - The complete original array of lines
|
|
208
|
+
* @param keepLines - Sorted array of line indices to preserve
|
|
209
|
+
* @returns Array of segmented block objects containing the text and boundary indices
|
|
210
|
+
*/
|
|
211
|
+
export function mergeRegions(lines, keepLines) {
|
|
212
|
+
if (keepLines.length === 0)
|
|
213
|
+
return [];
|
|
214
|
+
const segments = [];
|
|
215
|
+
let currentStart = keepLines[0];
|
|
216
|
+
let currentEnd = keepLines[0];
|
|
217
|
+
for (let i = 1; i < keepLines.length; i++) {
|
|
218
|
+
if (keepLines[i] <= currentEnd + 2) { // merge if gap <= 2
|
|
219
|
+
currentEnd = keepLines[i];
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
segments.push({
|
|
223
|
+
lines: lines.slice(currentStart, currentEnd + 1),
|
|
224
|
+
start: currentStart,
|
|
225
|
+
end: currentEnd
|
|
226
|
+
});
|
|
227
|
+
currentStart = keepLines[i];
|
|
228
|
+
currentEnd = keepLines[i];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
segments.push({
|
|
232
|
+
lines: lines.slice(currentStart, currentEnd + 1),
|
|
233
|
+
start: currentStart,
|
|
234
|
+
end: currentEnd
|
|
235
|
+
});
|
|
236
|
+
return segments;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The core engine of Reasoned Redundancy.
|
|
240
|
+
* Dynamically distills excessively large tool results into compressed representations using
|
|
241
|
+
* tool-specific deterministic policies (e.g. keeping stack traces, top-K search results,
|
|
242
|
+
* or code skeletons).
|
|
243
|
+
*
|
|
244
|
+
* If the resulting output remains stubbornly above token limits, it engages a strict local
|
|
245
|
+
* SLM fallback to further distill the text, utilizing protective placeholders to guarantee
|
|
246
|
+
* invariant lines (like file paths or function identifiers) are never dropped.
|
|
247
|
+
*
|
|
248
|
+
* @param slm - The local offline Small Language Model integration client
|
|
249
|
+
* @param text - The raw, verbose tool result text
|
|
250
|
+
* @param task - The overall user directive/task string
|
|
251
|
+
* @param toolName - The categorized tool name (e.g., 'read_file', 'get_logs')
|
|
252
|
+
* @param args - The arguments originally passed to the tool
|
|
253
|
+
* @param preservePatterns - A rigid list of RegExp patterns whose matched lines MUST survive SLM compression
|
|
254
|
+
* @returns The final compressed text representation, optionally featuring elision markers
|
|
255
|
+
*/
|
|
256
|
+
export async function distillToolResult(slm, text, task, toolName, args, preservePatterns) {
|
|
257
|
+
const minTokens = CONFIG.DISTILL_MIN_TOKENS ?? 500;
|
|
258
|
+
const maxTokens = CONFIG.DISTILL_MAX_TOKENS ?? 2000;
|
|
259
|
+
const originalTokens = estimateTokens(text);
|
|
260
|
+
// If small enough, bypass all logic
|
|
261
|
+
if (originalTokens < minTokens) {
|
|
262
|
+
return text;
|
|
263
|
+
}
|
|
264
|
+
// Determine caching keys
|
|
265
|
+
// Was a hardcoded 'v1', so a distilled result could never be invalidated at all.
|
|
266
|
+
// CONFIG.PROMPT_VERSION is the documented lever for exactly this, so it belongs in the key.
|
|
267
|
+
const policyVersion = CONFIG.PROMPT_VERSION;
|
|
268
|
+
const elisionId = computeElisionId(toolName || 'unknown', args, text);
|
|
269
|
+
const cacheKey = crypto.createHash('sha256').update(text + (task || '') + (toolName || '') + policyVersion).digest('hex');
|
|
270
|
+
const db = getDb();
|
|
271
|
+
const cached = db.prepare('SELECT value FROM cache WHERE key = ?').get(cacheKey);
|
|
272
|
+
if (cached) {
|
|
273
|
+
return cached.value;
|
|
274
|
+
}
|
|
275
|
+
// Commit the uncompressed original to the DB for recovery via `expand_elision`
|
|
276
|
+
let ranges = { startLine: 0, endLine: text.split('\n').length - 1 };
|
|
277
|
+
writeElision({
|
|
278
|
+
id: elisionId,
|
|
279
|
+
tool_name: toolName || 'unknown',
|
|
280
|
+
args: JSON.stringify(args || {}),
|
|
281
|
+
original_text: text,
|
|
282
|
+
ranges: JSON.stringify(ranges),
|
|
283
|
+
content_hash: crypto.createHash('sha256').update(text).digest('hex'),
|
|
284
|
+
size_bytes: Buffer.byteLength(text, 'utf8')
|
|
285
|
+
});
|
|
286
|
+
// Default to keeping the whole text
|
|
287
|
+
let processedText = text;
|
|
288
|
+
// Domain-Specific Deterministic Heuristics
|
|
289
|
+
if (toolName && ['read_file', 'view_file', 'File_Read'].some(t => toolName.toLowerCase().includes(t))) {
|
|
290
|
+
const lines = text.split('\n');
|
|
291
|
+
const keepLines = findRelevantRegions(lines, task || '');
|
|
292
|
+
if (keepLines.length > 0 && keepLines.length < lines.length * 0.8) {
|
|
293
|
+
const segments = mergeRegions(lines, keepLines);
|
|
294
|
+
let newText = '';
|
|
295
|
+
let lastEnd = -1;
|
|
296
|
+
for (const seg of segments) {
|
|
297
|
+
if (seg.start > lastEnd + 1) {
|
|
298
|
+
const elidedCount = seg.start - (lastEnd + 1);
|
|
299
|
+
newText += formatElisionMarker(elisionId, elidedCount, lastEnd + 1, seg.start - 1);
|
|
300
|
+
}
|
|
301
|
+
newText += seg.lines.join('\n') + '\n';
|
|
302
|
+
lastEnd = seg.end;
|
|
303
|
+
}
|
|
304
|
+
if (lastEnd < lines.length - 1) {
|
|
305
|
+
const elidedCount = lines.length - 1 - lastEnd;
|
|
306
|
+
newText += formatElisionMarker(elisionId, elidedCount, lastEnd + 1, lines.length - 1);
|
|
307
|
+
}
|
|
308
|
+
processedText = newText.trim();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else if (toolName && ['run_command', 'get_logs', 'execute'].some(t => toolName.toLowerCase().includes(t))) {
|
|
312
|
+
const lines = text.split('\n');
|
|
313
|
+
// Maintain critical telemetry: errors, stack traces, failures, and the definitive log tail
|
|
314
|
+
const keepLines = new Set();
|
|
315
|
+
lines.forEach((line, i) => {
|
|
316
|
+
if (/error|fail|exception|trace/i.test(line)) {
|
|
317
|
+
for (let j = Math.max(0, i - 2); j <= Math.min(lines.length - 1, i + 5); j++) {
|
|
318
|
+
keepLines.add(j);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
// Mandatory Log Tail Retention
|
|
323
|
+
for (let i = Math.max(0, lines.length - 50); i < lines.length; i++) {
|
|
324
|
+
keepLines.add(i);
|
|
325
|
+
}
|
|
326
|
+
if (keepLines.size < lines.length * 0.8) {
|
|
327
|
+
const segments = mergeRegions(lines, Array.from(keepLines).sort((a, b) => a - b));
|
|
328
|
+
let newText = '';
|
|
329
|
+
let lastEnd = -1;
|
|
330
|
+
for (const seg of segments) {
|
|
331
|
+
if (seg.start > lastEnd + 1) {
|
|
332
|
+
const elidedCount = seg.start - (lastEnd + 1);
|
|
333
|
+
newText += formatElisionMarker(elisionId, elidedCount, lastEnd + 1, seg.start - 1);
|
|
334
|
+
}
|
|
335
|
+
newText += seg.lines.join('\n') + '\n';
|
|
336
|
+
lastEnd = seg.end;
|
|
337
|
+
}
|
|
338
|
+
processedText = newText.trim();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else if (toolName && ['grep_search', 'list_dir', 'search'].some(t => toolName.toLowerCase().includes(t))) {
|
|
342
|
+
// Greedy truncation via Top-K
|
|
343
|
+
const lines = text.split('\n');
|
|
344
|
+
const topK = 50;
|
|
345
|
+
if (lines.length > topK) {
|
|
346
|
+
const elidedCount = lines.length - topK;
|
|
347
|
+
processedText = lines.slice(0, topK).join('\n') + formatElisionMarker(elisionId, elidedCount, topK, lines.length - 1);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
let finalText = processedText;
|
|
351
|
+
// ============================================================================
|
|
352
|
+
// DISTILLATION POLICY & SLM FALLBACK
|
|
353
|
+
// ============================================================================
|
|
354
|
+
const isSkill = (toolName && /skill/i.test(toolName));
|
|
355
|
+
const resolvedToolName = toolName || (isSkill ? 'skill' : 'unknown');
|
|
356
|
+
/**
|
|
357
|
+
* Determine the handling fidelity mode for this specific tool.
|
|
358
|
+
* - 'verbatim': Skip SLM summarization entirely.
|
|
359
|
+
* - 'structural': Preserve AST structures, defer truncation to the hard limit.
|
|
360
|
+
* - 'summarize': Proceed with full SLM compression.
|
|
361
|
+
*/
|
|
362
|
+
let fidelity = getDistillPolicy(resolvedToolName);
|
|
363
|
+
// Guardrail: If DISTILL_SKILLS is off, force verbatim mode for all skill payloads
|
|
364
|
+
// to guarantee skill instruction contracts are not corrupted by summarization.
|
|
365
|
+
if (isSkill && !CONFIG.DISTILL_SKILLS) {
|
|
366
|
+
fidelity = 'verbatim';
|
|
367
|
+
}
|
|
368
|
+
let skillBypassFired = false;
|
|
369
|
+
let adaptivePreservedCount = 0;
|
|
370
|
+
if (fidelity === 'verbatim') {
|
|
371
|
+
skillBypassFired = true;
|
|
372
|
+
console.error(`[distill] bypass: verbatim mode enforced for ${resolvedToolName}`);
|
|
373
|
+
}
|
|
374
|
+
else if (fidelity === 'structural' && estimateTokens(finalText) > maxTokens) {
|
|
375
|
+
// Structural mode relies purely on the Hard Truncation check at the bottom of this file.
|
|
376
|
+
// We skip the SLM Semantic loop because structural integrity is more important than size.
|
|
377
|
+
}
|
|
378
|
+
else if (estimateTokens(finalText) > maxTokens) {
|
|
379
|
+
// --------------------------------------------------------------------------
|
|
380
|
+
// SUMMARIZE MODE
|
|
381
|
+
//
|
|
382
|
+
// Some lines in the payload are "protected": contract lines, signatures, anything
|
|
383
|
+
// the caller must receive character-for-character. Everything else is ordinary
|
|
384
|
+
// prose we're allowed to shorten.
|
|
385
|
+
//
|
|
386
|
+
// The obvious approach is to hand the model the whole payload with the protected
|
|
387
|
+
// lines swapped for markers like ⟦PRESERVE_3⟧, ask it to leave the markers alone,
|
|
388
|
+
// and put the real lines back afterwards. We tried that. On a 5KB markdown payload
|
|
389
|
+
// with nine markers, qwen2.5-coder:3b returned none of them. It didn't garble them —
|
|
390
|
+
// it ignored the instruction completely and summarised everything, protected lines
|
|
391
|
+
// included. There was no way to tell which parts were still exact, so the entire
|
|
392
|
+
// result had to be discarded and the payload went out uncompressed.
|
|
393
|
+
//
|
|
394
|
+
// That isn't a wording problem. A 3B model summarises prose well, but it will not
|
|
395
|
+
// reliably carry meaningless tokens through a rewrite, and no prompt tuning fixes it.
|
|
396
|
+
//
|
|
397
|
+
// So we never show the model the protected content at all:
|
|
398
|
+
//
|
|
399
|
+
// 1. Split the payload into alternating runs — protected, prose, protected, ...
|
|
400
|
+
// 2. Send only the prose runs to the model to be shortened.
|
|
401
|
+
// 3. Stitch the result back together, taking the protected runs from our own
|
|
402
|
+
// copy rather than from anything the model returned.
|
|
403
|
+
//
|
|
404
|
+
// Reassembly is pure string joining. The protected content is physically never in
|
|
405
|
+
// the model's input or output, so it cannot be dropped or altered — which is what
|
|
406
|
+
// makes this safe to run on a 3B model.
|
|
407
|
+
// --------------------------------------------------------------------------
|
|
408
|
+
const slmLines = finalText.split('\n');
|
|
409
|
+
const protectedLineIndices = new Set();
|
|
410
|
+
// Phase 1: Structural Tokenization
|
|
411
|
+
// We use MarkdownIt to build an AST of the payload. We extract the start/end lines
|
|
412
|
+
// of critical structures (code fences, tables, frontmatter) so they aren't split.
|
|
413
|
+
try {
|
|
414
|
+
const md = new MarkdownIt();
|
|
415
|
+
const tokens = md.parse(finalText, {});
|
|
416
|
+
const protectNode = (start, end) => {
|
|
417
|
+
for (let i = start; i < end; i++)
|
|
418
|
+
protectedLineIndices.add(i);
|
|
419
|
+
};
|
|
420
|
+
for (const token of tokens) {
|
|
421
|
+
if (!token.map)
|
|
422
|
+
continue;
|
|
423
|
+
const [start, end] = token.map;
|
|
424
|
+
// Protect structural blocks
|
|
425
|
+
if (['fence', 'table_open', 'blockquote_open', 'heading_open', 'front_matter'].includes(token.type)) {
|
|
426
|
+
protectNode(start, end);
|
|
427
|
+
}
|
|
428
|
+
// Protect explicitly marked verbatim HTML blocks from TLS
|
|
429
|
+
else if (token.type === 'html_block' && token.content.includes('slm-gate:verbatim-start')) {
|
|
430
|
+
protectNode(start, end);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (e) {
|
|
435
|
+
console.error('[distill] Structural parsing failed', e);
|
|
436
|
+
}
|
|
437
|
+
// Phase 2: user-supplied preserve patterns, plus elision markers from an earlier pass.
|
|
438
|
+
for (let i = 0; i < slmLines.length; i++) {
|
|
439
|
+
const line = slmLines[i];
|
|
440
|
+
if (preservePatterns.some(p => p.test(line)) || line.includes('lines elided [id:')) {
|
|
441
|
+
protectedLineIndices.add(i);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Phase 3: Adaptive Semantic Feedback Loop. Probabilistically sample unprotected lines; if
|
|
445
|
+
// one closely matches text a user previously pulled back with expand_elision, protect it
|
|
446
|
+
// preemptively. (The list is empty unless DISTILL_ADAPTIVE is on.)
|
|
447
|
+
const pastFeedback = CONFIG.DISTILL_ADAPTIVE ? getDistillFeedback(resolvedToolName) : [];
|
|
448
|
+
if (pastFeedback.length > 0) {
|
|
449
|
+
for (let i = 0; i < slmLines.length; i++) {
|
|
450
|
+
const line = slmLines[i];
|
|
451
|
+
if (protectedLineIndices.has(i) || line.trim().length < 20)
|
|
452
|
+
continue;
|
|
453
|
+
// The explore rate is the FRACTION OF LINES that get an embedding call (0.15 = 15%), as
|
|
454
|
+
// .env.example documents and as ROUTING_TUNE_EXPLORE_RATE reads. Each call is a serial
|
|
455
|
+
// round-trip to the embedding model, so sampling is what keeps it off the critical path.
|
|
456
|
+
if (Math.random() >= CONFIG.DISTILL_ADAPTIVE_EXPLORE_RATE)
|
|
457
|
+
continue;
|
|
458
|
+
const emb = await embedText(line);
|
|
459
|
+
if (!emb)
|
|
460
|
+
continue;
|
|
461
|
+
let maxSim = 0;
|
|
462
|
+
for (const fb of pastFeedback) {
|
|
463
|
+
const sim = cosineSimilarity(emb, bufferToFloat64Array(fb.embedding_blob));
|
|
464
|
+
if (sim > maxSim)
|
|
465
|
+
maxSim = sim;
|
|
466
|
+
}
|
|
467
|
+
if (maxSim >= CONFIG.DISTILL_ADAPTIVE_THRESHOLD) {
|
|
468
|
+
protectedLineIndices.add(i);
|
|
469
|
+
adaptivePreservedCount++;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (adaptivePreservedCount > 0) {
|
|
474
|
+
console.info(`[distill] Adaptive loop preemptively preserved ${adaptivePreservedCount} regions based on semantic memory.`);
|
|
475
|
+
}
|
|
476
|
+
// Phase 4: collapse the line map into contiguous protected / narrative runs.
|
|
477
|
+
const segments = [];
|
|
478
|
+
for (let i = 0; i < slmLines.length; i++) {
|
|
479
|
+
const isProtected = protectedLineIndices.has(i);
|
|
480
|
+
const current = segments[segments.length - 1];
|
|
481
|
+
if (current && current.protected === isProtected)
|
|
482
|
+
current.lines.push(slmLines[i]);
|
|
483
|
+
else
|
|
484
|
+
segments.push({ protected: isProtected, lines: [slmLines[i]] });
|
|
485
|
+
}
|
|
486
|
+
// Phase 5: compress narrative runs through a small worker pool. Protected runs are never
|
|
487
|
+
// sent anywhere. A run is handed to the model only when a worker is free, so the per-call
|
|
488
|
+
// timeout (started inside `slm`) measures generation time, not time spent in Ollama's queue.
|
|
489
|
+
const protectedBlocks = segments.filter(s => s.protected).map(s => s.lines.join('\n'));
|
|
490
|
+
const narrativeRuns = segments.filter(s => !s.protected);
|
|
491
|
+
const queue = narrativeRuns.filter(s => s.lines.join('\n').trim().length >= MIN_COMPRESSIBLE_SEGMENT_CHARS);
|
|
492
|
+
const skippedUnderFloor = narrativeRuns.length - queue.length;
|
|
493
|
+
let failedRuns = 0;
|
|
494
|
+
const compressRun = async (segment) => {
|
|
495
|
+
const original = segment.lines.join('\n');
|
|
496
|
+
try {
|
|
497
|
+
const summary = (await slm(original, task)).trim();
|
|
498
|
+
// Accept only a non-empty result that actually shrank; otherwise keep the original.
|
|
499
|
+
if (summary.length > 0 && summary.length < original.length) {
|
|
500
|
+
segment.lines = summary.split('\n');
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
// Fail open for THIS run only — every other run still compresses.
|
|
505
|
+
failedRuns++;
|
|
506
|
+
console.error(`[distill] segment kept verbatim after compression failure: ${err instanceof Error ? err.message : String(err)}`);
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
const workerCount = Math.min(MAX_CONCURRENT_SEGMENT_CALLS, queue.length);
|
|
510
|
+
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
511
|
+
for (let run = queue.shift(); run; run = queue.shift()) {
|
|
512
|
+
await compressRun(run);
|
|
513
|
+
}
|
|
514
|
+
}));
|
|
515
|
+
const rebuilt = segments.map(s => s.lines.join('\n')).join('\n');
|
|
516
|
+
// Summarize mode can legitimately change nothing: a heading-dense document splits into many
|
|
517
|
+
// narrative runs that each fall under the size floor. Without this line the payload would
|
|
518
|
+
// then be hard-truncated below with no trace of WHY compression was never attempted.
|
|
519
|
+
if (estimateTokens(rebuilt) > maxTokens) {
|
|
520
|
+
const sentRuns = narrativeRuns.length - skippedUnderFloor;
|
|
521
|
+
console.warn(`[distill] summarize left ~${estimateTokens(rebuilt)} tokens against a budget of ${maxTokens}: ` +
|
|
522
|
+
`${narrativeRuns.length} narrative run(s), ${skippedUnderFloor} under the ${MIN_COMPRESSIBLE_SEGMENT_CHARS}-char floor, ` +
|
|
523
|
+
`${sentRuns} sent, ${failedRuns} failed; hard truncation follows`);
|
|
524
|
+
}
|
|
525
|
+
// Invariant, not a discard path. Protected runs are spliced back from our own copy, so this
|
|
526
|
+
// can only trip if the segmentation is wrong — never because a model misbehaved.
|
|
527
|
+
const lost = protectedBlocks.filter(block => !rebuilt.includes(block));
|
|
528
|
+
if (lost.length === 0) {
|
|
529
|
+
finalText = rebuilt;
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
console.warn(`distill_fallback: ${lost.length} protected block(s) missing after reassembly; keeping the original text`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// Hard Truncation Check: If everything fails (including SLM), slice the block to protect context windows
|
|
536
|
+
if (maxTokens > 0 && estimateTokens(finalText) > maxTokens) {
|
|
537
|
+
const lines = finalText.split('\n');
|
|
538
|
+
const keepHead = Math.floor((maxTokens * 3.5) / 100);
|
|
539
|
+
if (lines.length > keepHead * 2) {
|
|
540
|
+
const headLines = lines.slice(0, keepHead);
|
|
541
|
+
const tailLines = lines.slice(-keepHead);
|
|
542
|
+
const elidedCount = lines.length - (keepHead * 2);
|
|
543
|
+
// expand_elision reads the stored ORIGINAL. Line numbers are only meaningful when this
|
|
544
|
+
// text still is the original; after distillation they would point at the wrong lines.
|
|
545
|
+
const linesMatchOriginal = finalText === text;
|
|
546
|
+
finalText = headLines.join('\n') +
|
|
547
|
+
(linesMatchOriginal
|
|
548
|
+
? formatElisionMarker(elisionId, elidedCount, keepHead, lines.length - keepHead - 1)
|
|
549
|
+
: formatElisionMarker(elisionId, elidedCount)) +
|
|
550
|
+
tailLines.join('\n');
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Memorize the computed result
|
|
554
|
+
db.prepare('INSERT OR REPLACE INTO cache (key, value, ts) VALUES (?, ?, ?)').run(cacheKey, finalText, new Date().toISOString());
|
|
555
|
+
return finalText;
|
|
556
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CONFIG } from '../config.js';
|
|
2
|
+
import { SLM } from '../models/slm.js';
|
|
3
|
+
const slm = new SLM();
|
|
4
|
+
export function cosineSimilarity(a, b) {
|
|
5
|
+
let dotProduct = 0;
|
|
6
|
+
let normA = 0;
|
|
7
|
+
let normB = 0;
|
|
8
|
+
for (let i = 0; i < a.length; i++) {
|
|
9
|
+
dotProduct += a[i] * b[i];
|
|
10
|
+
normA += a[i] * a[i];
|
|
11
|
+
normB += b[i] * b[i];
|
|
12
|
+
}
|
|
13
|
+
if (normA === 0 || normB === 0)
|
|
14
|
+
return 0;
|
|
15
|
+
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
16
|
+
}
|
|
17
|
+
export async function embedText(text) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await slm.embed(CONFIG.EMBED_MODEL, text);
|
|
20
|
+
return res;
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
console.error('[embed] Error computing embedding:', e);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function float64ArrayToBuffer(arr) {
|
|
28
|
+
return Buffer.from(new Float64Array(arr).buffer);
|
|
29
|
+
}
|
|
30
|
+
export function bufferToFloat64Array(buf) {
|
|
31
|
+
return Array.from(new Float64Array(buf.buffer, buf.byteOffset, buf.byteLength / 8));
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
/**
|
|
4
|
+
* True when the module at `moduleUrl` is the script Node was started with (`node file.js`, `tsx file.ts`,
|
|
5
|
+
* or a symlinked launcher), false when it was only imported.
|
|
6
|
+
*
|
|
7
|
+
* Why not `import.meta.url === \`file://${process.argv[1]}\``: the URL is percent-encoded and argv is not,
|
|
8
|
+
* so an install folder with a space (`My Projects`) never matched, and the model gate ran without ever
|
|
9
|
+
* listening. Comparing real paths also makes a symlinked bin match.
|
|
10
|
+
*
|
|
11
|
+
* @param moduleUrl The caller's `import.meta.url`
|
|
12
|
+
*/
|
|
13
|
+
export function isEntryPoint(moduleUrl) {
|
|
14
|
+
const script = process.argv[1];
|
|
15
|
+
if (!script)
|
|
16
|
+
return false;
|
|
17
|
+
try {
|
|
18
|
+
return fs.realpathSync(fileURLToPath(moduleUrl)) === fs.realpathSync(script);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|