lynkr 9.4.0 → 9.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.4.0",
3
+ "version": "9.4.2",
4
4
  "description": "Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.",
5
5
  "main": "index.js",
6
6
  "bin": {
package/src/api/router.js CHANGED
@@ -764,6 +764,17 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
764
764
  const parsed = JSON.parse(text);
765
765
  if (parsed && typeof parsed === 'object' && parsed.type === 'message') {
766
766
  parsed.lynkr_interaction = interaction;
767
+ // Inject a visible text block into content so Claude Code renders it.
768
+ if (Array.isArray(parsed.content)) {
769
+ const lines = [
770
+ `╭─ Lynkr ${'─'.repeat(40)}`,
771
+ `│ Tier ${interaction.tier ?? '—'} → ${interaction.model ?? '—'} (${interaction.provider ?? '—'})`,
772
+ `│ Score ${interaction.complexity_score ?? '—'}/100 · Risk: ${interaction.risk ?? '—'} · Savings: ~${interaction.estimated_savings_percent ?? 0}%`,
773
+ `│ Route ${interaction.mode ?? '—'} — ${interaction.headline ?? ''}`,
774
+ `╰${'─'.repeat(46)}`,
775
+ ];
776
+ parsed.content.unshift({ type: 'text', text: lines.join('\n') });
777
+ }
767
778
  finalBody = JSON.stringify(parsed);
768
779
  }
769
780
  }
@@ -1,10 +1,19 @@
1
1
  const express = require('express');
2
2
  const path = require('path');
3
+ const fs = require('fs');
3
4
  const api = require('./api');
4
5
 
5
6
  const router = express.Router();
6
7
 
7
- router.get('/', (_req, res) => res.sendFile(path.join(__dirname, '../../public/dashboard.html')));
8
+ const DASHBOARD_HTML = path.resolve(__dirname, '../../public/dashboard.html');
9
+
10
+ router.get(['/', ''], (_req, res) => {
11
+ if (!fs.existsSync(DASHBOARD_HTML)) {
12
+ return res.status(500).json({ error: 'dashboard_unavailable', path: DASHBOARD_HTML });
13
+ }
14
+ res.sendFile(DASHBOARD_HTML);
15
+ });
16
+
8
17
  router.get('/api/overview', api.overview);
9
18
  router.get('/api/usage', api.usage);
10
19
  router.get('/api/routing', api.routing);
@@ -1,347 +1,170 @@
1
1
  /**
2
- * Smart Tool Selection Module
2
+ * Smart Tool Selection — Conservative Stripping
3
3
  *
4
- * Intelligently selects relevant tools based on request type classification.
5
- * Reduces tool token overhead by 50-70% for non-coding queries.
4
+ * Strategy: instead of predicting which tools ARE needed (brittle regex),
5
+ * only strip groups we are CERTAIN are irrelevant based on clear absence
6
+ * of intent signals.
6
7
  *
7
- * @module tools/smart-selection
8
+ * Rules:
9
+ * 1. Greeting → strip everything
10
+ * 2. No write intent → strip Write / Edit / NotebookEdit
11
+ * 3. No execution intent → strip Bash / KillShell
12
+ * 4. No web intent → strip WebSearch / WebFetch
13
+ *
14
+ * File ops (Read, Grep, Glob) are NEVER stripped — they are the most
15
+ * broadly useful and the most commonly needed unexpectedly.
8
16
  */
9
17
 
10
18
  const logger = require('../logger');
11
19
 
12
- // Strip system-reminder blocks injected by the CLI before classification
13
20
  const SYSTEM_REMINDER_PATTERN = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
14
21
 
15
- // Pre-compiled regex patterns for performance (avoid recompiling on every request)
22
+ // Clear greeting strip all tools
16
23
  const GREETING_PATTERN = /^(hi|hello|hey|good morning|good afternoon|good evening|howdy|greetings|sup|yo)[\s\.\!\?]*$/i;
17
- const QUESTION_PATTERN = /^(what is|what's|how does|when|where|why|explain|define|tell me about|can you explain)/i;
18
- const TECHNICAL_KEYWORDS = /code|function|class|file|module|import|export|async|await|promise|callback|api|database|server|client|component|method|variable|array|object|string|number/i;
19
- const EXPLANATION_PATTERN = /explain|describe|summarize|what does|how does|tell me about|give me an overview|clarify|elaborate/i;
20
- const WEB_PATTERN = /search|lookup|find info|google|documentation|docs|website|url|link|online|internet|browse/i;
21
- const READ_PATTERN = /read|show|display|view|cat|check|inspect|look at|see|examine|review|print|output/i;
22
- const WRITE_PATTERN = /write|create|add|update|modify|change|fix|delete|remove|insert|append|replace|save|put|make|generate|produce/i;
23
- const EDIT_PATTERN = /edit|refactor|rename|move|reorganize|restructure|rewrite/i;
24
- const EXECUTION_PATTERN = /run|execute|test|compile|build|deploy|start|install|launch|boot|fire up|npm|git|python|node|docker|bash|sh|cmd/i;
25
- const COMPLEX_PATTERN = /implement|build|create|develop|design|architect|plan|strategy|approach|help with|work on|improve|optimize|enhance|refactor|migrate/i;
26
-
27
- /**
28
- * Tool selection map: request type → relevant tools
29
- */
24
+ const TECHNICAL_KEYWORDS = /code|function|class|file|module|import|export|async|await|promise|api|database|server|component|variable|array|object|\.[a-z]{1,5}\b|npm|git|docker|python|node|bash|run|install/i;
25
+
26
+ // Intent signals absence means we strip that group
27
+ const WRITE_INTENT = /write|create\b|add to|update|modify|change|fix|delete|remove|insert|append|replace|save|edit|refactor|rename|move|reorganize|rewrite|implement|generate|produce|scaffold/i;
28
+ const EXECUTE_INTENT = /run|execute|test|compile|build|deploy|start|install|launch|boot|npm|yarn|pnpm|git|python|node|docker|bash|sh\b|cmd|script|make|cargo|go run/i;
29
+ const WEB_INTENT = /search online|search the web|search google|look up online|browse|website|https?:\/\//i;
30
+
31
+ // Tools always kept (file search is never useless)
32
+ const ALWAYS_KEEP = new Set([
33
+ 'Read', 'Grep', 'Glob',
34
+ 'Task', 'TaskOutput', 'TodoWrite', 'TodoRead',
35
+ 'AskUserQuestion', 'Skill',
36
+ 'EnterPlanMode', 'ExitPlanMode',
37
+ ]);
38
+
39
+ // Conditional strips: group → intent pattern that must be present to keep it
40
+ const CONDITIONAL_GROUPS = [
41
+ { names: ['Write', 'Edit', 'NotebookEdit'], intent: WRITE_INTENT },
42
+ { names: ['Bash', 'KillShell'], intent: EXECUTE_INTENT },
43
+ { names: ['WebSearch', 'WebFetch'], intent: WEB_INTENT },
44
+ ];
45
+
46
+ // Legacy map kept for telemetry label compatibility
30
47
  const TOOL_SELECTION_MAP = {
31
- conversational: [], // No tools needed for greetings
32
- simple_qa: [], // No tools needed for simple questions
33
-
34
- research: [
35
- 'Read', 'Grep', 'Glob', // File search
36
- 'WebSearch', 'WebFetch' // Web research
37
- ],
38
-
39
- file_reading: [
40
- 'Read', 'Grep', 'Glob' // Read-only tools
41
- ],
42
-
43
- file_modification: [
44
- 'Read', 'Write', 'Edit', // Full I/O
45
- 'Grep', 'Glob', 'Bash' // Support tools
46
- ],
47
-
48
- code_execution: [
49
- 'Read', 'Write', 'Edit', // File operations
50
- 'Bash', 'Grep', 'Glob' // Execution + search
51
- ],
52
-
53
- coding: [
54
- 'Read', 'Write', 'Edit', // Core file ops
55
- 'Bash', 'Grep', 'Glob' // Support tools
56
- ],
57
-
58
- complex_task: [
59
- 'Read', 'Write', 'Edit', // Tier 1
60
- 'Bash', 'Grep', 'Glob', // Tier 1
61
- 'WebSearch', 'WebFetch', // Tier 2
62
- 'Task', 'TodoWrite', 'AskUserQuestion' // Tier 3+4
63
- ]
48
+ conversational: [],
49
+ simple_qa: [],
50
+ file_reading: ['Read', 'Grep', 'Glob'],
51
+ file_modification: ['Read', 'Write', 'Edit', 'Grep', 'Glob', 'Bash'],
52
+ code_execution: ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'],
53
+ coding: ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'],
54
+ research: ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'],
55
+ complex_task: ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob', 'WebSearch', 'WebFetch', 'Task', 'TodoWrite', 'AskUserQuestion'],
64
56
  };
65
57
 
66
- /**
67
- * Extract content from last user message
68
- */
69
- function getLastUserMessage(payload) {
70
- if (!Array.isArray(payload.messages) || payload.messages.length === 0) {
71
- return null;
72
- }
58
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
73
59
 
74
- // Find last user message
60
+ function getLastUserContent(payload) {
61
+ if (!Array.isArray(payload.messages)) return '';
75
62
  for (let i = payload.messages.length - 1; i >= 0; i--) {
76
63
  const msg = payload.messages[i];
77
- if (msg?.role === 'user') {
78
- return msg;
64
+ if (msg?.role !== 'user') continue;
65
+ let text = '';
66
+ if (typeof msg.content === 'string') {
67
+ text = msg.content;
68
+ } else if (Array.isArray(msg.content)) {
69
+ text = msg.content.filter(b => b?.type === 'text').map(b => b.text || '').join(' ');
79
70
  }
71
+ return text.replace(SYSTEM_REMINDER_PATTERN, '').trim();
80
72
  }
81
-
82
- return null;
83
- }
84
-
85
- /**
86
- * Extract text content from message (handles string or array format)
87
- */
88
- function extractContent(message) {
89
- if (!message) return '';
90
-
91
- if (typeof message.content === 'string') {
92
- return message.content;
93
- }
94
-
95
- if (Array.isArray(message.content)) {
96
- return message.content
97
- .filter(block => block?.type === 'text')
98
- .map(block => block.text || '')
99
- .join(' ');
100
- }
101
-
102
73
  return '';
103
74
  }
104
75
 
105
- /**
106
- * Check if content matches greeting patterns
107
- */
108
76
  function isGreeting(content) {
109
- return GREETING_PATTERN.test(content.trim());
110
- }
111
-
112
- /**
113
- * Check if content is short and non-technical
114
- */
115
- function isShortNonTechnical(content) {
116
- const trimmed = content.trim();
117
- return trimmed.length < 20 && !TECHNICAL_KEYWORDS.test(trimmed);
118
- }
119
-
120
- /**
121
- * Check if content is a simple question
122
- */
123
- function isSimpleQuestion(content) {
124
- return QUESTION_PATTERN.test(content.trim());
125
- }
126
-
127
- /**
128
- * Check for technical keywords
129
- */
130
- function hasTechnicalKeywords(content) {
131
- return TECHNICAL_KEYWORDS.test(content);
132
- }
133
-
134
- /**
135
- * Check for explanation/research keywords
136
- */
137
- function hasExplanationKeywords(content) {
138
- return EXPLANATION_PATTERN.test(content);
139
- }
140
-
141
- /**
142
- * Check for web/search keywords
143
- */
144
- function hasWebKeywords(content) {
145
- return WEB_PATTERN.test(content);
146
- }
147
-
148
- /**
149
- * Check for file reading keywords
150
- */
151
- function hasReadKeywords(content) {
152
- return READ_PATTERN.test(content);
153
- }
154
-
155
- /**
156
- * Check for file writing/modification keywords
157
- */
158
- function hasWriteKeywords(content) {
159
- return WRITE_PATTERN.test(content);
77
+ const t = content.trim();
78
+ return GREETING_PATTERN.test(t) || (t.length < 20 && !TECHNICAL_KEYWORDS.test(t));
160
79
  }
161
80
 
162
- /**
163
- * Check for edit/refactor keywords
164
- */
165
- function hasEditKeywords(content) {
166
- return EDIT_PATTERN.test(content);
167
- }
81
+ // ─── Classifier (conservative) ───────────────────────────────────────────────
168
82
 
169
83
  /**
170
- * Check for execution/testing keywords
171
- */
172
- function hasExecutionKeywords(content) {
173
- return EXECUTION_PATTERN.test(content);
174
- }
175
-
176
- /**
177
- * Check for complex task keywords
178
- */
179
- function hasComplexKeywords(content) {
180
- return COMPLEX_PATTERN.test(content);
181
- }
182
-
183
- /**
184
- * Classify request type based on content analysis
185
- *
186
- * @param {Object} payload - Request payload with messages
187
- * @returns {Object} Classification result { type, confidence, keywords }
84
+ * Classify request and compute which tool groups to strip.
85
+ * Returns a classification object for logging/telemetry compatibility.
188
86
  */
189
87
  function classifyRequestType(payload) {
190
- const lastMessage = getLastUserMessage(payload);
191
-
192
- if (!lastMessage) {
193
- return { type: 'coding', confidence: 0.5, keywords: [] };
194
- }
195
-
196
- const rawContent = extractContent(lastMessage);
197
- // Strip <system-reminder> blocks before classification to prevent
198
- // CLI-injected keywords (search, explain, documentation) from polluting results
199
- const content = rawContent.replace(SYSTEM_REMINDER_PATTERN, '').trim();
200
- const contentLower = content.toLowerCase();
201
- const messageCount = payload.messages?.length ?? 0;
202
-
203
- // 1. Conversational (no tools)
204
- if (isGreeting(contentLower)) {
205
- return { type: 'conversational', confidence: 1.0, keywords: ['greeting'] };
206
- }
207
-
208
- if (isShortNonTechnical(contentLower)) {
209
- return { type: 'conversational', confidence: 0.8, keywords: ['short', 'non-technical'] };
210
- }
211
-
212
- // 2. Simple Q&A (no tools)
213
- if (isSimpleQuestion(contentLower) && !hasTechnicalKeywords(contentLower)) {
214
- return { type: 'simple_qa', confidence: 0.9, keywords: ['question', 'non-technical'] };
215
- }
216
-
217
- // 3. Research/Explanation (minimal tools)
218
- if (hasExplanationKeywords(contentLower)) {
219
- return { type: 'research', confidence: 0.85, keywords: ['explanation'] };
220
- }
221
-
222
- if (hasWebKeywords(contentLower)) {
223
- return { type: 'research', confidence: 0.9, keywords: ['web', 'search'] };
224
- }
88
+ const content = getLastUserContent(payload);
89
+ const lower = content.toLowerCase();
90
+ const msgCount = payload.messages?.length ?? 0;
225
91
 
226
- // 4. File reading (read-only tools)
227
- if (hasReadKeywords(contentLower) && !hasWriteKeywords(contentLower)) {
228
- return { type: 'file_reading', confidence: 0.8, keywords: ['read'] };
92
+ // Greeting strip everything
93
+ if (isGreeting(lower)) {
94
+ return { type: 'conversational', confidence: 1.0, keywords: ['greeting'], _stripped: ['Write', 'Edit', 'NotebookEdit', 'Bash', 'KillShell', 'WebSearch', 'WebFetch'] };
229
95
  }
230
96
 
231
- // 5. File modification (full I/O tools)
232
- if (hasWriteKeywords(contentLower) || hasEditKeywords(contentLower)) {
233
- return { type: 'file_modification', confidence: 0.85, keywords: ['write', 'edit'] };
97
+ const stripped = [];
98
+ for (const { names, intent } of CONDITIONAL_GROUPS) {
99
+ if (!intent.test(lower)) stripped.push(...names);
234
100
  }
235
101
 
236
- // 6. Execution/Testing (execution tools)
237
- if (hasExecutionKeywords(contentLower)) {
238
- return { type: 'code_execution', confidence: 0.8, keywords: ['execution'] };
239
- }
102
+ // Derive a label for telemetry
103
+ const hasWrite = WRITE_INTENT.test(lower);
104
+ const hasExec = EXECUTE_INTENT.test(lower);
105
+ const hasWeb = WEB_INTENT.test(lower);
240
106
 
241
- // 7. Complex task (all tools)
242
- if (hasComplexKeywords(contentLower)) {
243
- return { type: 'complex_task', confidence: 0.75, keywords: ['complex'] };
244
- }
245
-
246
- // Long conversations likely need more tools
247
- if (messageCount > 10) {
248
- return { type: 'complex_task', confidence: 0.7, keywords: ['long_conversation'] };
249
- }
107
+ const type = hasWrite || hasExec ? 'file_modification'
108
+ : hasWeb ? 'research'
109
+ : msgCount > 10 ? 'complex_task'
110
+ : 'file_reading';
250
111
 
251
- // Default: coding (core tools)
252
- return { type: 'coding', confidence: 0.6, keywords: ['default'] };
112
+ return { type, confidence: 0.9, keywords: ['conservative'], _stripped: stripped };
253
113
  }
254
114
 
255
- /**
256
- * Estimate token count for tools (rough approximation)
257
- */
115
+ // ─── Tool filter ─────────────────────────────────────────────────────────────
116
+
258
117
  function estimateToolTokens(tools) {
259
118
  if (!Array.isArray(tools)) return 0;
260
-
261
- // Average: ~175 tokens per tool (based on STANDARD_TOOLS analysis)
262
119
  return tools.length * 175;
263
120
  }
264
121
 
265
122
  /**
266
- * Select relevant tools based on classification
267
- *
268
- * @param {Array} tools - Available tools
269
- * @param {Object} classification - Classification result from classifyRequestType
270
- * @param {Object} options - Selection options (provider, tokenBudget, config)
271
- * @returns {Array} Filtered list of relevant tools
123
+ * Apply conservative stripping to the tool list.
272
124
  */
273
125
  function selectToolsSmartly(tools, classification, options = {}) {
274
- if (!Array.isArray(tools) || tools.length === 0) {
275
- return tools;
276
- }
126
+ if (!Array.isArray(tools) || tools.length === 0) return tools;
277
127
 
278
- const { provider = 'databricks', tokenBudget = 2500, config = {} } = options;
279
- const requestType = classification.type || 'coding';
128
+ const { provider = 'databricks' } = options;
129
+ const strippedNames = new Set(classification._stripped ?? []);
280
130
 
281
- // Get relevant tool names for this request type
282
- const relevantToolNames = TOOL_SELECTION_MAP[requestType] || TOOL_SELECTION_MAP.coding;
283
- const relevantLower = new Set(relevantToolNames.map(n => n.toLowerCase()));
284
-
285
- // Filter to relevant tools only (case-insensitive match so external clients
286
- // using lowercase names like Pi's `bash`/`read` aren't filtered out)
287
- let selectedTools = tools.filter(tool => relevantLower.has(String(tool.name || '').toLowerCase()));
288
-
289
- // If nothing matched, the caller is using a tool ecosystem we don't recognize
290
- // (e.g. Pi's read/write/edit/bash). Pass tools through untouched rather than
291
- // deleting them — otherwise the LLM gets no schema and hallucinates defaults.
292
- if (selectedTools.length === 0) {
293
- return tools;
294
- }
295
-
296
- // Mode-specific adjustments
297
- if (config.mode === 'aggressive') {
298
- // Aggressive: Further reduce tools for ambiguous cases
299
- if (classification.confidence < 0.7 && selectedTools.length > 4) {
300
- selectedTools = selectedTools.slice(0, 4);
301
- }
302
- } else if (config.mode === 'conservative') {
303
- // Conservative: Include one extra tier of tools for safety
304
- if (requestType === 'file_reading' && !relevantToolNames.includes('Bash')) {
305
- const bashTool = tools.find(t => t.name === 'Bash');
306
- if (bashTool) selectedTools.push(bashTool);
307
- }
131
+ // Greeting: strip everything
132
+ if (classification.type === 'conversational') {
133
+ return [];
308
134
  }
309
135
 
310
- // Provider-specific limits
311
- if (provider === 'ollama' && selectedTools.length > 8) {
312
- selectedTools = selectedTools.slice(0, 8);
313
- }
314
-
315
- // Token budget enforcement
316
- const estimatedTokens = estimateToolTokens(selectedTools);
317
- if (estimatedTokens > tokenBudget) {
318
- const targetCount = Math.floor(tokenBudget / 175);
319
- selectedTools = selectedTools.slice(0, Math.max(targetCount, 0));
320
- }
136
+ // Strip only the flagged groups; always keep ALWAYS_KEEP tools
137
+ let selected = tools.filter(tool => {
138
+ const name = String(tool.name || '');
139
+ if (ALWAYS_KEEP.has(name)) return true;
140
+ return !strippedNames.has(name);
141
+ });
321
142
 
322
- // Minimal mode override (if configured)
323
- if (config.minimalMode) {
324
- const minimalTools = ['Read', 'Write', 'Edit', 'Bash'];
325
- selectedTools = selectedTools.filter(t => minimalTools.includes(t.name));
326
- }
143
+ // Safety: if we somehow stripped everything, return full list
144
+ if (selected.length === 0) return tools;
327
145
 
328
- // Code Mode: always include the 4 meta-tools (only ~700 tokens total)
146
+ // Code Mode meta-tools always included
329
147
  const codeConfig = require('../config');
330
148
  if (codeConfig.mcp?.codeMode?.enabled) {
331
149
  const codeModeNames = new Set(['mcp_list_tools', 'mcp_tool_info', 'mcp_tool_docs', 'mcp_execute']);
332
150
  for (const tool of tools) {
333
- if (codeModeNames.has(tool.name) && !selectedTools.some(t => t.name === tool.name)) {
334
- selectedTools.push(tool);
151
+ if (codeModeNames.has(tool.name) && !selected.some(t => t.name === tool.name)) {
152
+ selected.push(tool);
335
153
  }
336
154
  }
337
155
  }
338
156
 
339
- return selectedTools;
157
+ // Ollama has a smaller context — cap at 10 tools
158
+ if (provider === 'ollama' && selected.length > 10) {
159
+ selected = selected.slice(0, 10);
160
+ }
161
+
162
+ return selected;
340
163
  }
341
164
 
342
165
  module.exports = {
343
166
  classifyRequestType,
344
167
  selectToolsSmartly,
345
168
  estimateToolTokens,
346
- TOOL_SELECTION_MAP
169
+ TOOL_SELECTION_MAP,
347
170
  };