@zenithfoundry/slm-gate 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.env.example +669 -0
  2. package/LICENSE +21 -0
  3. package/README.md +317 -0
  4. package/configs/antigravity/.env.16gb.example +674 -0
  5. package/configs/antigravity/.env.24gb.example +674 -0
  6. package/configs/antigravity/.env.32gb.example +674 -0
  7. package/configs/antigravity/README.md +109 -0
  8. package/configs/claude-code/.env.16gb.example +674 -0
  9. package/configs/claude-code/.env.24gb.example +674 -0
  10. package/configs/claude-code/.env.32gb.example +674 -0
  11. package/configs/claude-code/README.md +52 -0
  12. package/configs/claude-desktop/.env.16gb.example +674 -0
  13. package/configs/claude-desktop/.env.24gb.example +674 -0
  14. package/configs/claude-desktop/.env.32gb.example +674 -0
  15. package/configs/claude-desktop/README.md +37 -0
  16. package/configs/cline-continue-opencode/.env.16gb.example +674 -0
  17. package/configs/cline-continue-opencode/.env.24gb.example +674 -0
  18. package/configs/cline-continue-opencode/.env.32gb.example +674 -0
  19. package/configs/cline-continue-opencode/README.md +34 -0
  20. package/configs/cursor/.env.16gb.example +674 -0
  21. package/configs/cursor/.env.24gb.example +674 -0
  22. package/configs/cursor/.env.32gb.example +674 -0
  23. package/configs/cursor/README.md +26 -0
  24. package/configs/generic-http/.env.16gb.example +674 -0
  25. package/configs/generic-http/.env.24gb.example +674 -0
  26. package/configs/generic-http/.env.32gb.example +674 -0
  27. package/configs/generic-http/README.md +20 -0
  28. package/configs/generic-stdio/.env.16gb.example +674 -0
  29. package/configs/generic-stdio/.env.24gb.example +674 -0
  30. package/configs/generic-stdio/.env.32gb.example +674 -0
  31. package/configs/generic-stdio/README.md +24 -0
  32. package/configs/preserve/README.md +26 -0
  33. package/configs/preserve/tls.json +61 -0
  34. package/dist/adapters/tech-lead-stack.js +38 -0
  35. package/dist/cache/index.js +173 -0
  36. package/dist/cli.js +256 -0
  37. package/dist/config.js +255 -0
  38. package/dist/dashboard/data.js +149 -0
  39. package/dist/dashboard/export.js +42 -0
  40. package/dist/dashboard/serve.js +63 -0
  41. package/dist/doctor.js +338 -0
  42. package/dist/hardware.js +126 -0
  43. package/dist/home-dir.js +39 -0
  44. package/dist/ledger/flush-lifecycle.js +50 -0
  45. package/dist/ledger/index.js +946 -0
  46. package/dist/ledger/report.js +69 -0
  47. package/dist/ledger/setup-dashboard.js +456 -0
  48. package/dist/ledger/smoke.js +37 -0
  49. package/dist/ledger/sync-config.js +177 -0
  50. package/dist/ledger/sync.js +307 -0
  51. package/dist/ledger/verify.js +185 -0
  52. package/dist/ledger/wipe-langfuse.js +130 -0
  53. package/dist/llm-gate/distill.js +239 -0
  54. package/dist/llm-gate/formats/anthropic.js +185 -0
  55. package/dist/llm-gate/formats/chat-completions.js +103 -0
  56. package/dist/llm-gate/formats/contract.js +29 -0
  57. package/dist/llm-gate/formats/gemini.js +84 -0
  58. package/dist/llm-gate/formats/internal.js +1 -0
  59. package/dist/llm-gate/formats/openai.js +77 -0
  60. package/dist/llm-gate/formats/responses.js +146 -0
  61. package/dist/llm-gate/forward.js +150 -0
  62. package/dist/llm-gate/index.js +40 -0
  63. package/dist/llm-gate/local-first.js +217 -0
  64. package/dist/llm-gate/pipeline.js +267 -0
  65. package/dist/llm-gate/server.js +289 -0
  66. package/dist/mcp-gate/ground.js +64 -0
  67. package/dist/mcp-gate/index.js +57 -0
  68. package/dist/mcp-gate/pipeline.js +252 -0
  69. package/dist/mcp-gate/server.js +302 -0
  70. package/dist/mcp-gate/tool-names.js +57 -0
  71. package/dist/models/check.js +26 -0
  72. package/dist/models/footprint.js +137 -0
  73. package/dist/models/helpers.js +91 -0
  74. package/dist/models/index.js +5 -0
  75. package/dist/models/reasoning.js +91 -0
  76. package/dist/models/roles.js +9 -0
  77. package/dist/models/slm.js +243 -0
  78. package/dist/models/types.js +1 -0
  79. package/dist/pricing/index.js +115 -0
  80. package/dist/pricing/plans.js +54 -0
  81. package/dist/pricing/providers.js +172 -0
  82. package/dist/resolver/index.js +277 -0
  83. package/dist/resolver/types.js +1 -0
  84. package/dist/setup/claim.js +41 -0
  85. package/dist/setup/gate-command.js +41 -0
  86. package/dist/setup/init.js +92 -0
  87. package/dist/setup/local-models.js +123 -0
  88. package/dist/setup/model-gate.js +220 -0
  89. package/dist/setup/notify.js +45 -0
  90. package/dist/setup/ollama-install.js +53 -0
  91. package/dist/setup/parent-watch.js +84 -0
  92. package/dist/setup/required-models.js +20 -0
  93. package/dist/setup/startup.js +132 -0
  94. package/dist/setup/tool-settings.js +101 -0
  95. package/dist/utils/backoff.js +47 -0
  96. package/dist/utils/compression.js +145 -0
  97. package/dist/utils/constants.js +22 -0
  98. package/dist/utils/duration.js +43 -0
  99. package/dist/utils/elision.js +556 -0
  100. package/dist/utils/embedding.js +32 -0
  101. package/dist/utils/entry-point.js +23 -0
  102. package/dist/utils/local-only.js +82 -0
  103. package/dist/utils/preserve-patterns.js +115 -0
  104. package/dist/utils/safety.js +30 -0
  105. package/dist/verifier/index.js +67 -0
  106. package/package.json +121 -0
@@ -0,0 +1,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
+ }