lynkr 9.0.1 → 9.1.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.
Files changed (58) hide show
  1. package/README.md +70 -21
  2. package/bin/cli.js +34 -4
  3. package/bin/lynkr-trajectory.js +136 -0
  4. package/bin/lynkr-usage.js +219 -0
  5. package/funding.json +110 -0
  6. package/index.js +7 -3
  7. package/install.sh +3 -3
  8. package/lynkr-skill.tar.gz +0 -0
  9. package/native/Cargo.toml +26 -0
  10. package/native/index.js +29 -0
  11. package/native/lynkr-native.node +0 -0
  12. package/native/src/lib.rs +321 -0
  13. package/package.json +6 -5
  14. package/public/dashboard.html +665 -0
  15. package/src/api/files-multipart.js +30 -0
  16. package/src/api/files-router.js +81 -0
  17. package/src/api/middleware/budget.js +19 -1
  18. package/src/api/middleware/load-shedding.js +17 -0
  19. package/src/api/openai-router.js +353 -301
  20. package/src/api/router.js +275 -40
  21. package/src/cache/prompt.js +13 -0
  22. package/src/clients/databricks.js +42 -18
  23. package/src/clients/ollama-utils.js +21 -17
  24. package/src/clients/openai-format.js +50 -10
  25. package/src/clients/openrouter-utils.js +42 -37
  26. package/src/clients/prompt-cache-injection.js +140 -0
  27. package/src/clients/provider-capabilities.js +41 -0
  28. package/src/clients/responses-format.js +8 -7
  29. package/src/clients/standard-tools.js +1 -1
  30. package/src/clients/xml-tool-extractor.js +307 -0
  31. package/src/cluster.js +82 -0
  32. package/src/config/index.js +16 -0
  33. package/src/context/distill.js +15 -0
  34. package/src/context/tool-result-compressor.js +563 -0
  35. package/src/dashboard/api.js +170 -0
  36. package/src/dashboard/router.js +13 -0
  37. package/src/headroom/client.js +3 -109
  38. package/src/headroom/index.js +0 -14
  39. package/src/memory/extractor.js +22 -0
  40. package/src/memory/search.js +0 -50
  41. package/src/orchestrator/index.js +163 -204
  42. package/src/orchestrator/preflight.js +188 -0
  43. package/src/routing/index.js +64 -32
  44. package/src/routing/interaction.js +183 -0
  45. package/src/routing/risk-analyzer.js +194 -0
  46. package/src/routing/telemetry.js +47 -2
  47. package/src/server.js +15 -0
  48. package/src/stores/file-store.js +104 -0
  49. package/src/stores/response-store.js +25 -0
  50. package/src/tools/index.js +1 -1
  51. package/src/tools/smart-selection.js +11 -2
  52. package/src/tools/web.js +1 -1
  53. package/src/training/trajectory-compressor.js +266 -0
  54. package/src/usage/aggregator.js +206 -0
  55. package/src/utils/markdown-ansi.js +146 -0
  56. package/.lynkr/telemetry.db +0 -0
  57. package/.lynkr/telemetry.db-shm +0 -0
  58. package/.lynkr/telemetry.db-wal +0 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Risk Analyzer
3
+ *
4
+ * Scores a request along a risk axis that is orthogonal to complexity.
5
+ * A trivially short edit to `auth/middleware.ts` is still high risk and
6
+ * should not be served by a cheap local model.
7
+ *
8
+ * @module routing/risk-analyzer
9
+ */
10
+
11
+ const { extractContent } = require('./complexity-analyzer');
12
+
13
+ // Substring keywords found in file paths or instruction text.
14
+ // Matched case-insensitively as raw substrings, so "auth" hits
15
+ // "src/auth/login.ts" and "authentication".
16
+ const PROTECTED_PATH_KEYWORDS = [
17
+ 'auth', 'oauth', 'jwt', 'session', 'security', 'permission', 'rbac',
18
+ 'payment', 'payments', 'billing', 'invoice', 'subscription',
19
+ 'migration', 'migrations', 'schema',
20
+ 'infra', 'terraform', 'kustomize', 'helm', 'kubernetes',
21
+ '.github/workflows', '.env', 'secret', 'credential',
22
+ 'api-key', 'api_key', 'apikey', 'token',
23
+ 'webhook', 'admin',
24
+ ];
25
+
26
+ // Whole-word instruction keywords that signal sensitive intent regardless
27
+ // of which files are involved. Higher signal than path keywords because
28
+ // they reflect what the user is *asking for*.
29
+ const HIGH_RISK_INSTRUCTION_KEYWORDS = [
30
+ 'authentication', 'authorization', 'permission', 'security',
31
+ 'payment', 'billing', 'migration', 'database schema',
32
+ 'encrypt', 'decrypt', 'secret', 'credential', 'api key',
33
+ 'production', 'deploy', 'rollout', 'rollback',
34
+ ];
35
+
36
+ // Path-extracting patterns. We look at:
37
+ // 1. Anything that looks like a file path inside the instruction text.
38
+ // 2. Explicit path-like fields in tool inputs (e.g. tool_use blocks).
39
+ const PATH_LIKE_RE = /(?:^|[\s`'"([])([./a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,8})(?=[\s`'")\]:,;]|$)/g;
40
+ const SLASHED_PATH_RE = /(?:^|[\s`'"([])((?:[a-zA-Z0-9_.-]+\/)+[a-zA-Z0-9_.-]+)(?=[\s`'")\]:,;]|$)/g;
41
+
42
+ /**
43
+ * Pull every path-shaped substring out of free-form text.
44
+ * @param {string} text
45
+ * @returns {string[]}
46
+ */
47
+ function extractPathsFromText(text) {
48
+ if (!text) return [];
49
+ const out = new Set();
50
+ let m;
51
+ while ((m = PATH_LIKE_RE.exec(text)) !== null) {
52
+ out.add(m[1]);
53
+ }
54
+ while ((m = SLASHED_PATH_RE.exec(text)) !== null) {
55
+ out.add(m[1]);
56
+ }
57
+ return Array.from(out);
58
+ }
59
+
60
+ /**
61
+ * Walk every tool_use block in the conversation and collect any string
62
+ * inputs that look like paths. Catches cases where the model already
63
+ * called an Edit/Read tool on a sensitive file.
64
+ * @param {object} payload
65
+ * @returns {string[]}
66
+ */
67
+ function extractPathsFromToolUses(payload) {
68
+ const out = new Set();
69
+ const messages = payload?.messages;
70
+ if (!Array.isArray(messages)) return [];
71
+
72
+ for (const msg of messages) {
73
+ if (!Array.isArray(msg?.content)) continue;
74
+ for (const block of msg.content) {
75
+ if (block?.type !== 'tool_use' || !block.input) continue;
76
+ const stack = [block.input];
77
+ while (stack.length) {
78
+ const node = stack.pop();
79
+ if (typeof node === 'string') {
80
+ if (node.includes('/') || node.includes('.')) {
81
+ // Treat short tool-input strings that look path-y as paths.
82
+ if (node.length <= 200) out.add(node);
83
+ }
84
+ } else if (Array.isArray(node)) {
85
+ for (const v of node) stack.push(v);
86
+ } else if (node && typeof node === 'object') {
87
+ for (const v of Object.values(node)) stack.push(v);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ return Array.from(out);
93
+ }
94
+
95
+ /**
96
+ * Find which keywords from `keywords` appear (case-insensitively) inside
97
+ * any of `haystack`. Substring match — by design — so "auth" matches
98
+ * both "src/auth/login.ts" and the word "authorization".
99
+ * @param {string[]} keywords
100
+ * @param {string[]} haystack
101
+ * @returns {string[]} hit keywords, sorted
102
+ */
103
+ function findHits(keywords, haystack) {
104
+ const hits = new Set();
105
+ const joined = haystack.join('\n').toLowerCase();
106
+ for (const kw of keywords) {
107
+ if (joined.includes(kw.toLowerCase())) hits.add(kw);
108
+ }
109
+ return Array.from(hits).sort();
110
+ }
111
+
112
+ /**
113
+ * Analyze the risk level of a request.
114
+ *
115
+ * Risk is orthogonal to complexity:
116
+ * - low → no protected paths or sensitive keywords detected
117
+ * - medium → protected paths *or* a read-only task on a protected area
118
+ * - high → instruction explicitly names sensitive domain logic,
119
+ * or protected paths combined with a write-intent task
120
+ *
121
+ * @param {object} payload - Anthropic-format request payload
122
+ * @returns {{ level: 'low'|'medium'|'high',
123
+ * reason: string,
124
+ * pathHits: string[],
125
+ * instructionHits: string[],
126
+ * paths: string[] }}
127
+ */
128
+ function analyzeRisk(payload) {
129
+ const instructionText = extractContent(payload) || '';
130
+ const lowText = instructionText.toLowerCase();
131
+
132
+ const textPaths = extractPathsFromText(instructionText);
133
+ const toolPaths = extractPathsFromToolUses(payload);
134
+ const allPaths = Array.from(new Set([...textPaths, ...toolPaths]));
135
+
136
+ // Instruction-level hits scan the raw text. Path-level hits scan only
137
+ // the extracted path strings so phrases like "authentication is hard"
138
+ // don't double-fire as a path hit.
139
+ const instructionHits = findHits(HIGH_RISK_INSTRUCTION_KEYWORDS, [instructionText]);
140
+ const pathHits = findHits(PROTECTED_PATH_KEYWORDS, allPaths.length ? allPaths : []);
141
+ // Also let path keywords match against the instruction text — covers
142
+ // "update the auth flow" with no path mentioned.
143
+ const textPathHits = findHits(PROTECTED_PATH_KEYWORDS, [instructionText]);
144
+ const mergedPathHits = Array.from(new Set([...pathHits, ...textPathHits])).sort();
145
+
146
+ if (instructionHits.length > 0) {
147
+ return {
148
+ level: 'high',
149
+ reason: 'High-risk instruction keyword detected.',
150
+ pathHits: mergedPathHits,
151
+ instructionHits,
152
+ paths: allPaths,
153
+ };
154
+ }
155
+
156
+ if (mergedPathHits.length > 0) {
157
+ // Read-only intent on a protected area is medium, not high.
158
+ // Heuristic: presence of explain/summarize/read verbs.
159
+ const readOnly = /\b(explain|summarize|describe|what does|walk me through|read|show|list|search|find|grep|locate)\b/i.test(lowText);
160
+ if (readOnly) {
161
+ return {
162
+ level: 'medium',
163
+ reason: 'Protected paths involved but task appears read-only.',
164
+ pathHits: mergedPathHits,
165
+ instructionHits: [],
166
+ paths: allPaths,
167
+ };
168
+ }
169
+ return {
170
+ level: 'high',
171
+ reason: 'Protected path referenced with write-capable intent.',
172
+ pathHits: mergedPathHits,
173
+ instructionHits: [],
174
+ paths: allPaths,
175
+ };
176
+ }
177
+
178
+ return {
179
+ level: 'low',
180
+ reason: 'No risk signals detected.',
181
+ pathHits: [],
182
+ instructionHits: [],
183
+ paths: allPaths,
184
+ };
185
+ }
186
+
187
+ module.exports = {
188
+ analyzeRisk,
189
+ PROTECTED_PATH_KEYWORDS,
190
+ HIGH_RISK_INSTRUCTION_KEYWORDS,
191
+ // Exposed for tests
192
+ extractPathsFromText,
193
+ extractPathsFromToolUses,
194
+ };
@@ -105,6 +105,9 @@ function init() {
105
105
 
106
106
  CREATE INDEX IF NOT EXISTS idx_telemetry_timestamp
107
107
  ON routing_telemetry(timestamp);
108
+
109
+ CREATE INDEX IF NOT EXISTS idx_telemetry_session_id
110
+ ON routing_telemetry(session_id, timestamp);
108
111
  `);
109
112
 
110
113
  logger.info({ dbPath }, "Routing telemetry database initialised");
@@ -233,6 +236,10 @@ function query(filters = {}) {
233
236
  clauses.push("timestamp >= @since");
234
237
  params.since = filters.since;
235
238
  }
239
+ if (filters.session_id) {
240
+ clauses.push("session_id = @session_id");
241
+ params.session_id = filters.session_id;
242
+ }
236
243
 
237
244
  const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
238
245
  const limit = filters.limit ?? 100;
@@ -454,11 +461,49 @@ function cleanup(olderThanMs) {
454
461
  }
455
462
  }
456
463
 
464
+ // ---------------------------------------------------------------------------
465
+ // In-memory stats cache (avoids SQLite queries on every /v1/routing/stats hit)
466
+ // ---------------------------------------------------------------------------
467
+
468
+ const STATS_CACHE_TTL = 5000; // 5 seconds
469
+ let statsCache = null;
470
+ let statsCacheTs = 0;
471
+
472
+ function getStatsCached(timeRange = {}) {
473
+ const now = Date.now();
474
+ // Use cache for default time range (last 24h) — custom ranges bypass cache
475
+ if (!timeRange.since && !timeRange.until && statsCache && now - statsCacheTs < STATS_CACHE_TTL) {
476
+ return statsCache;
477
+ }
478
+ const result = getStats(timeRange);
479
+ if (!timeRange.since && !timeRange.until) {
480
+ statsCache = result;
481
+ statsCacheTs = now;
482
+ }
483
+ return result;
484
+ }
485
+
486
+ let providerStatsCache = new Map();
487
+ let providerStatsCacheTs = 0;
488
+
489
+ function getProviderStatsCached(provider, timeRange = {}) {
490
+ const now = Date.now();
491
+ if (!timeRange.since && !timeRange.until && providerStatsCache.has(provider) && now - providerStatsCacheTs < STATS_CACHE_TTL) {
492
+ return providerStatsCache.get(provider);
493
+ }
494
+ const result = getProviderStats(provider, timeRange);
495
+ if (!timeRange.since && !timeRange.until) {
496
+ providerStatsCache.set(provider, result);
497
+ providerStatsCacheTs = now;
498
+ }
499
+ return result;
500
+ }
501
+
457
502
  module.exports = {
458
503
  record,
459
504
  query,
460
- getStats,
461
- getProviderStats,
505
+ getStats: getStatsCached,
506
+ getProviderStats: getProviderStatsCached,
462
507
  getRoutingAccuracy,
463
508
  cleanup,
464
509
  };
package/src/server.js CHANGED
@@ -147,6 +147,13 @@ function createApp() {
147
147
 
148
148
  app.use(router);
149
149
 
150
+ // Dashboard UI
151
+ app.use('/dashboard', require('./dashboard/router'));
152
+
153
+ // Files API
154
+ const filesRouter = require("./api/files-router");
155
+ app.use("/v1", filesRouter);
156
+
150
157
  // 404 handler (must be after all routes)
151
158
  app.use(notFoundHandler);
152
159
 
@@ -195,6 +202,14 @@ async function start() {
195
202
  const provider = config.modelProvider?.type?.toLowerCase();
196
203
  if (provider === "ollama" || config.tiersReferenceOllama()) {
197
204
  await waitForOllama();
205
+
206
+ // Pre-probe Ollama's Anthropic API at startup (avoids 1-3s cold-start on first request)
207
+ try {
208
+ const { hasAnthropicEndpoint } = require("./clients/ollama-utils");
209
+ await hasAnthropicEndpoint(config.ollama.endpoint);
210
+ } catch (err) {
211
+ logger.debug({ err: err.message }, "Ollama Anthropic endpoint probe failed at startup");
212
+ }
198
213
  }
199
214
 
200
215
  const server = app.listen(config.port, () => {
@@ -0,0 +1,104 @@
1
+ const fs = require("fs");
2
+ const fsp = require("fs").promises;
3
+ const path = require("path");
4
+ const crypto = require("crypto");
5
+ const logger = require("../logger");
6
+
7
+ const STORAGE_DIR = path.resolve(process.env.FILES_STORAGE_PATH || "./data/files");
8
+ const METADATA_FILE = path.join(STORAGE_DIR, "_metadata.json");
9
+ const MAX_FILES = parseInt(process.env.FILES_MAX_COUNT || "1000", 10);
10
+
11
+ const metadata = new Map();
12
+
13
+ function ensureStorageDir() {
14
+ if (!fs.existsSync(STORAGE_DIR)) {
15
+ fs.mkdirSync(STORAGE_DIR, { recursive: true });
16
+ }
17
+ }
18
+
19
+ function persistMetadata() {
20
+ try {
21
+ const entries = Array.from(metadata.values());
22
+ fs.writeFileSync(METADATA_FILE, JSON.stringify(entries), "utf8");
23
+ } catch (err) {
24
+ logger.warn({ err: err.message }, "Failed to persist file metadata");
25
+ }
26
+ }
27
+
28
+ function loadMetadata() {
29
+ ensureStorageDir();
30
+ try {
31
+ if (!fs.existsSync(METADATA_FILE)) return;
32
+ const entries = JSON.parse(fs.readFileSync(METADATA_FILE, "utf8"));
33
+ for (const entry of entries) {
34
+ // Only restore entries whose backing file still exists on disk
35
+ if (fs.existsSync(entry.storage_path)) {
36
+ metadata.set(entry.id, entry);
37
+ } else {
38
+ logger.debug({ fileId: entry.id }, "Dropping orphaned metadata entry (file missing)");
39
+ }
40
+ }
41
+ logger.info({ count: metadata.size }, "File metadata restored from disk");
42
+ } catch (err) {
43
+ logger.warn({ err: err.message }, "Could not load file metadata; starting fresh");
44
+ }
45
+ }
46
+
47
+ // Restore metadata at module load so restarts don't orphan files
48
+ loadMetadata();
49
+
50
+ async function storeFile(buffer, { filename, purpose, mimeType }) {
51
+ ensureStorageDir();
52
+ if (metadata.size >= MAX_FILES) {
53
+ const oldest = metadata.keys().next().value;
54
+ await deleteFile(oldest);
55
+ }
56
+ const id = `file-${crypto.randomUUID()}`;
57
+ const storagePath = path.join(STORAGE_DIR, id);
58
+ await fsp.writeFile(storagePath, buffer);
59
+ const entry = {
60
+ id,
61
+ object: "file",
62
+ filename: filename || "upload",
63
+ purpose: purpose || "assistants",
64
+ bytes: buffer.length,
65
+ mime_type: mimeType || "application/octet-stream",
66
+ created_at: Math.floor(Date.now() / 1000),
67
+ storage_path: storagePath,
68
+ };
69
+ metadata.set(id, entry);
70
+ persistMetadata();
71
+ logger.info({ fileId: id, bytes: buffer.length, filename }, "File stored");
72
+ return entry;
73
+ }
74
+
75
+ function getFile(id) {
76
+ return metadata.get(id) || null;
77
+ }
78
+
79
+ async function getFileContent(id) {
80
+ const entry = metadata.get(id);
81
+ if (!entry) return null;
82
+ try {
83
+ return await fsp.readFile(entry.storage_path);
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ async function deleteFile(id) {
90
+ const entry = metadata.get(id);
91
+ if (!entry) return false;
92
+ try { await fsp.unlink(entry.storage_path); } catch {}
93
+ metadata.delete(id);
94
+ persistMetadata();
95
+ return true;
96
+ }
97
+
98
+ function listFiles({ purpose } = {}) {
99
+ const files = Array.from(metadata.values());
100
+ if (purpose) return files.filter((f) => f.purpose === purpose);
101
+ return files;
102
+ }
103
+
104
+ module.exports = { storeFile, getFile, getFileContent, deleteFile, listFiles };
@@ -0,0 +1,25 @@
1
+ const MAX_ENTRIES = 1000;
2
+
3
+ const store = new Map();
4
+
5
+ function storeResponse(id, data) {
6
+ if (store.size >= MAX_ENTRIES) {
7
+ const oldest = store.keys().next().value;
8
+ store.delete(oldest);
9
+ }
10
+ store.set(id, { ...data, createdAt: Date.now() });
11
+ }
12
+
13
+ function getResponse(id) {
14
+ return store.get(id) || null;
15
+ }
16
+
17
+ function deleteResponse(id) {
18
+ return store.delete(id);
19
+ }
20
+
21
+ function size() {
22
+ return store.size;
23
+ }
24
+
25
+ module.exports = { storeResponse, getResponse, deleteResponse, size };
@@ -34,7 +34,7 @@ const TOOL_ALIASES = {
34
34
  webagent: "web_agent",
35
35
  WebAgent: "web_agent",
36
36
  tinyfish: "web_agent",
37
- task: "fs_write",
37
+ task: "Task",
38
38
  write: "fs_write",
39
39
  filewrite: "fs_write",
40
40
  read: "fs_read",
@@ -280,9 +280,18 @@ function selectToolsSmartly(tools, classification, options = {}) {
280
280
 
281
281
  // Get relevant tool names for this request type
282
282
  const relevantToolNames = TOOL_SELECTION_MAP[requestType] || TOOL_SELECTION_MAP.coding;
283
+ const relevantLower = new Set(relevantToolNames.map(n => n.toLowerCase()));
283
284
 
284
- // Filter to relevant tools only
285
- let selectedTools = tools.filter(tool => relevantToolNames.includes(tool.name));
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
+ }
286
295
 
287
296
  // Mode-specific adjustments
288
297
  if (config.mode === 'aggressive') {
package/src/tools/web.js CHANGED
@@ -152,7 +152,7 @@ function summariseResult(item) {
152
152
  return {
153
153
  title: item.title ?? item.name ?? null,
154
154
  url: item.url ?? item.link ?? null,
155
- snippet: item.snippet ?? item.summary ?? item.excerpt ?? null,
155
+ snippet: item.snippet ?? item.content ?? item.summary ?? item.excerpt ?? null,
156
156
  score: item.score ?? item.rank ?? null,
157
157
  source: item.source ?? null,
158
158
  metadata: item.metadata ?? null,