lynkr 8.0.1 → 9.0.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 (55) hide show
  1. package/README.md +238 -315
  2. package/bin/cli.js +16 -3
  3. package/index.js +7 -3
  4. package/install.sh +3 -3
  5. package/lynkr-skill.tar.gz +0 -0
  6. package/native/Cargo.toml +26 -0
  7. package/native/index.js +29 -0
  8. package/native/lynkr-native.node +0 -0
  9. package/native/src/lib.rs +321 -0
  10. package/package.json +8 -6
  11. package/src/api/files-multipart.js +30 -0
  12. package/src/api/files-router.js +81 -0
  13. package/src/api/openai-router.js +379 -308
  14. package/src/api/providers-handler.js +171 -3
  15. package/src/api/router.js +109 -5
  16. package/src/cache/prompt.js +13 -0
  17. package/src/clients/circuit-breaker.js +10 -247
  18. package/src/clients/codex-process.js +342 -0
  19. package/src/clients/codex-utils.js +143 -0
  20. package/src/clients/databricks.js +243 -76
  21. package/src/clients/ollama-utils.js +21 -17
  22. package/src/clients/openai-format.js +20 -6
  23. package/src/clients/openrouter-utils.js +42 -37
  24. package/src/clients/prompt-cache-injection.js +140 -0
  25. package/src/clients/provider-capabilities.js +41 -0
  26. package/src/clients/resilience.js +540 -0
  27. package/src/clients/responses-format.js +8 -7
  28. package/src/clients/retry.js +22 -167
  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 +66 -0
  33. package/src/context/compression.js +42 -9
  34. package/src/context/distill.js +507 -0
  35. package/src/context/tool-result-compressor.js +563 -0
  36. package/src/memory/extractor.js +22 -0
  37. package/src/orchestrator/index.js +147 -205
  38. package/src/routing/complexity-analyzer.js +258 -5
  39. package/src/routing/index.js +15 -34
  40. package/src/routing/latency-tracker.js +148 -0
  41. package/src/routing/model-tiers.js +2 -0
  42. package/src/routing/quality-scorer.js +113 -0
  43. package/src/routing/telemetry.js +502 -0
  44. package/src/server.js +23 -0
  45. package/src/stores/file-store.js +69 -0
  46. package/src/stores/response-store.js +25 -0
  47. package/src/tools/code-graph.js +538 -0
  48. package/src/tools/code-mode.js +304 -0
  49. package/src/tools/index.js +1 -1
  50. package/src/tools/lazy-loader.js +11 -0
  51. package/src/tools/mcp-remote.js +7 -0
  52. package/src/tools/smart-selection.js +11 -0
  53. package/src/tools/web.js +1 -1
  54. package/src/utils/payload.js +206 -0
  55. package/src/utils/perf-timer.js +80 -0
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Graphify Integration — Knowledge Graph for Code Intelligence
3
+ *
4
+ * Communicates with Graphify's CLI to provide blast radius analysis,
5
+ * god node detection, community cohesion, surprise scoring, and
6
+ * structural complexity signals for intelligent routing decisions.
7
+ *
8
+ * Workspace resolution order (per-request):
9
+ * 1. Explicit workspace passed by caller (e.g. from X-Lynkr-Workspace header)
10
+ * 2. Auto-detected from absolute file paths in the conversation messages
11
+ * 3. CODE_GRAPH_WORKSPACE env var
12
+ * 4. process.cwd() (last resort)
13
+ *
14
+ * Graphify: https://github.com/safishamsi/graphify
15
+ *
16
+ * @module tools/code-graph
17
+ */
18
+
19
+ const path = require("path");
20
+ const { execFile } = require("child_process");
21
+ const config = require("../config");
22
+ const logger = require("../logger");
23
+
24
+ // ============================================================================
25
+ // CACHE
26
+ // ============================================================================
27
+
28
+ /** @type {Map<string, { data: any, ts: number }>} */
29
+ const resultCache = new Map();
30
+ const CACHE_TTL_MS = 30_000; // 30 seconds
31
+
32
+ /**
33
+ * Retrieve a cached value or null if expired / missing.
34
+ * @param {string} key
35
+ * @returns {any|null}
36
+ */
37
+ function cacheGet(key) {
38
+ const entry = resultCache.get(key);
39
+ if (!entry) return null;
40
+ if (Date.now() - entry.ts > CACHE_TTL_MS) {
41
+ resultCache.delete(key);
42
+ return null;
43
+ }
44
+ return entry.data;
45
+ }
46
+
47
+ /**
48
+ * Store a value in the cache.
49
+ * @param {string} key
50
+ * @param {any} data
51
+ */
52
+ function cacheSet(key, data) {
53
+ resultCache.set(key, { data, ts: Date.now() });
54
+
55
+ // Prevent unbounded growth — evict oldest entries beyond 200
56
+ if (resultCache.size > 200) {
57
+ const oldest = resultCache.keys().next().value;
58
+ resultCache.delete(oldest);
59
+ }
60
+ }
61
+
62
+ // ============================================================================
63
+ // FAILURE SUPPRESSION
64
+ // ============================================================================
65
+
66
+ /** Timestamp of the last logged warning (0 = never) */
67
+ let lastWarningTs = 0;
68
+ const WARNING_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
69
+
70
+ /**
71
+ * Log a warning at most once per cooldown period.
72
+ * @param {string} msg
73
+ * @param {Object} [meta]
74
+ */
75
+ function warnOnce(msg, meta = {}) {
76
+ const now = Date.now();
77
+ if (now - lastWarningTs < WARNING_COOLDOWN_MS) return;
78
+ lastWarningTs = now;
79
+ logger.warn(meta, `[graphify] ${msg}`);
80
+ }
81
+
82
+ // ============================================================================
83
+ // WORKSPACE DETECTION
84
+ // ============================================================================
85
+
86
+ /**
87
+ * Detect the workspace root from a list of absolute file paths by finding
88
+ * their longest common directory prefix.
89
+ *
90
+ * Example:
91
+ * ["/Users/bob/app/src/a.js", "/Users/bob/app/src/b.js", "/Users/bob/app/test/c.js"]
92
+ * → "/Users/bob/app"
93
+ *
94
+ * Returns null if no absolute paths are provided or they share no common root.
95
+ *
96
+ * @param {string[]} filePaths
97
+ * @returns {string|null}
98
+ */
99
+ function detectWorkspaceFromPaths(filePaths) {
100
+ // Only consider absolute paths
101
+ const absolute = filePaths.filter((p) => path.isAbsolute(p));
102
+ if (absolute.length === 0) return null;
103
+
104
+ // Split each path into segments
105
+ const segmented = absolute.map((p) => p.split(path.sep).filter(Boolean));
106
+
107
+ // Find common prefix segments
108
+ const first = segmented[0];
109
+ let commonLength = first.length;
110
+
111
+ for (let i = 1; i < segmented.length; i++) {
112
+ const other = segmented[i];
113
+ let j = 0;
114
+ while (j < commonLength && j < other.length && first[j] === other[j]) {
115
+ j++;
116
+ }
117
+ commonLength = j;
118
+ }
119
+
120
+ if (commonLength === 0) return null;
121
+
122
+ // Reconstruct the common path — must be a directory, not a file
123
+ let common = path.sep + first.slice(0, commonLength).join(path.sep);
124
+
125
+ // If the common path looks like a file (has extension), go up one level
126
+ if (path.extname(common)) {
127
+ common = path.dirname(common);
128
+ }
129
+
130
+ // Don't return root or home-level paths — too broad to be useful
131
+ const depth = common.split(path.sep).filter(Boolean).length;
132
+ if (depth < 2) return null;
133
+
134
+ return common;
135
+ }
136
+
137
+ // ============================================================================
138
+ // CONFIGURATION HELPERS
139
+ // ============================================================================
140
+
141
+ /**
142
+ * Return resolved code-graph configuration from config module.
143
+ * @returns {{ enabled: boolean, command: string, defaultWorkspace: string, timeout: number }}
144
+ */
145
+ function getConfig() {
146
+ const cfg = config.codeGraph || {};
147
+ return {
148
+ enabled: cfg.enabled === true,
149
+ command: cfg.command || "graphify",
150
+ defaultWorkspace: cfg.workspace || process.cwd(),
151
+ timeout: cfg.timeout || 5000,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Resolve the workspace for a given request.
157
+ *
158
+ * Priority:
159
+ * 1. Explicit workspace (from header or caller)
160
+ * 2. Auto-detected from file paths
161
+ * 3. CODE_GRAPH_WORKSPACE env var
162
+ * 4. process.cwd()
163
+ *
164
+ * @param {Object} [options]
165
+ * @param {string} [options.workspace] - Explicit workspace from caller/header
166
+ * @param {string[]} [options.filePaths] - File paths from the conversation
167
+ * @returns {string}
168
+ */
169
+ function resolveWorkspace(options = {}) {
170
+ // 1. Explicit workspace
171
+ if (options.workspace && typeof options.workspace === "string") {
172
+ return options.workspace;
173
+ }
174
+
175
+ // 2. Auto-detect from file paths
176
+ if (Array.isArray(options.filePaths) && options.filePaths.length > 0) {
177
+ const detected = detectWorkspaceFromPaths(options.filePaths);
178
+ if (detected) {
179
+ logger.debug({ workspace: detected }, "[graphify] auto-detected workspace from file paths");
180
+ return detected;
181
+ }
182
+ }
183
+
184
+ // 3/4. Static config or cwd
185
+ return getConfig().defaultWorkspace;
186
+ }
187
+
188
+ // ============================================================================
189
+ // COMMAND EXECUTION
190
+ // ============================================================================
191
+
192
+ /**
193
+ * Execute a Graphify CLI command and parse JSON output.
194
+ *
195
+ * Graphify CLI: `graphify query --workspace <path> <query>`
196
+ * or: `graphify --workspace <path>` (builds graph + reports)
197
+ *
198
+ * @param {string} subcommand — e.g. "query", "benchmark", or null for build
199
+ * @param {string[]} [args] — additional CLI arguments
200
+ * @param {string} [workspace] — resolved workspace path
201
+ * @returns {Promise<Object|null>} Parsed JSON or null on failure
202
+ */
203
+ function execGraph(subcommand, args = [], workspace = null) {
204
+ const cfg = getConfig();
205
+ if (!cfg.enabled) return Promise.resolve(null);
206
+
207
+ const ws = workspace || cfg.defaultWorkspace;
208
+ const parts = cfg.command.split(/\s+/);
209
+ const bin = parts[0];
210
+ const baseArgs = parts.slice(1);
211
+
212
+ const fullArgs = [
213
+ ...baseArgs,
214
+ ...(subcommand ? [subcommand] : []),
215
+ "--workspace",
216
+ ws,
217
+ ...args,
218
+ ];
219
+
220
+ return new Promise((resolve) => {
221
+ execFile(
222
+ bin,
223
+ fullArgs,
224
+ { timeout: cfg.timeout, maxBuffer: 1024 * 1024 },
225
+ (err, stdout, stderr) => {
226
+ if (err) {
227
+ warnOnce(`command failed: ${subcommand || 'build'}`, {
228
+ err: err.message,
229
+ stderr: (stderr || "").slice(0, 200),
230
+ });
231
+ return resolve(null);
232
+ }
233
+ try {
234
+ const data = JSON.parse(stdout);
235
+ return resolve(data);
236
+ } catch (parseErr) {
237
+ // Graphify may output non-JSON for build commands — try reading report
238
+ warnOnce(`failed to parse JSON for: ${subcommand || 'build'}`, {
239
+ err: parseErr.message,
240
+ });
241
+ return resolve(null);
242
+ }
243
+ }
244
+ );
245
+ });
246
+ }
247
+
248
+ // ============================================================================
249
+ // AVAILABILITY CHECK
250
+ // ============================================================================
251
+
252
+ /** Cached availability result per workspace */
253
+ const availabilityCache = new Map(); // workspace → { value, ts }
254
+ const AVAILABILITY_TTL_MS = 60_000; // 1 minute
255
+
256
+ /**
257
+ * Check whether Graphify is configured and responsive.
258
+ * Result is cached per workspace for 60 seconds.
259
+ *
260
+ * @param {Object} [options]
261
+ * @param {string} [options.workspace] - Explicit workspace
262
+ * @param {string[]} [options.filePaths] - File paths for auto-detection
263
+ * @returns {Promise<boolean>}
264
+ */
265
+ async function isAvailable(options = {}) {
266
+ const cfg = getConfig();
267
+ if (!cfg.enabled) return false;
268
+
269
+ const ws = resolveWorkspace(options);
270
+ const now = Date.now();
271
+ const cached = availabilityCache.get(ws);
272
+ if (cached && cached.value !== null && now - cached.ts < AVAILABILITY_TTL_MS) {
273
+ return cached.value;
274
+ }
275
+
276
+ const result = await execGraph("query", ["graph_stats"], ws);
277
+ const available = result !== null;
278
+ availabilityCache.set(ws, { value: available, ts: now });
279
+
280
+ if (available) {
281
+ logger.debug({ workspace: ws }, "[graphify] available");
282
+ }
283
+
284
+ return available;
285
+ }
286
+
287
+ // ============================================================================
288
+ // PUBLIC API
289
+ // ============================================================================
290
+
291
+ /**
292
+ * @typedef {Object} CodeGraphOptions
293
+ * @property {string} [workspace] - Explicit workspace path (e.g. from X-Lynkr-Workspace header)
294
+ * @property {string[]} [filePaths] - File paths from conversation (used for auto-detection)
295
+ */
296
+
297
+ /**
298
+ * Get blast radius for a set of file paths.
299
+ *
300
+ * Uses Graphify's `query get_neighbors` on each file to find affected nodes,
301
+ * then aggregates into blast radius metrics.
302
+ *
303
+ * @param {string[]} filePaths — list of file paths to analyze
304
+ * @param {CodeGraphOptions} [options]
305
+ * @returns {Promise<{ affected_files: number, affected_functions: number, affected_tests: number, dependency_depth: number, risk_score: number }|null>}
306
+ */
307
+ async function getBlastRadius(filePaths, options = {}) {
308
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
309
+
310
+ const ws = resolveWorkspace({ ...options, filePaths });
311
+ const cacheKey = `blast:${ws}:${filePaths.sort().join(",")}`;
312
+ const cached = cacheGet(cacheKey);
313
+ if (cached) return cached;
314
+
315
+ // Query neighbors for each file to estimate blast radius
316
+ const result = await execGraph(
317
+ "query",
318
+ ["get_neighbors", "--files", ...filePaths, "--depth", "2", "--json"],
319
+ ws
320
+ );
321
+ if (!result) return null;
322
+
323
+ // Normalize Graphify output into our standard blast radius format
324
+ const nodes = result.nodes || result.neighbors || [];
325
+ const affectedFiles = new Set();
326
+ const affectedFunctions = [];
327
+ const affectedTests = [];
328
+ let maxDepth = 0;
329
+
330
+ for (const node of nodes) {
331
+ const src = node.source_file || node.source || "";
332
+ if (src) affectedFiles.add(src);
333
+ const label = (node.label || node.id || "").toLowerCase();
334
+ if (label.includes("test") || src.includes("test")) {
335
+ affectedTests.push(node);
336
+ } else {
337
+ affectedFunctions.push(node);
338
+ }
339
+ if (node.depth && node.depth > maxDepth) maxDepth = node.depth;
340
+ }
341
+
342
+ // Risk score: based on affected count and depth
343
+ const riskScore = Math.min(100,
344
+ affectedFiles.size * 3 +
345
+ affectedFunctions.length * 2 +
346
+ maxDepth * 5
347
+ );
348
+
349
+ const normalized = {
350
+ affected_files: affectedFiles.size,
351
+ affected_functions: affectedFunctions.length,
352
+ affected_tests: affectedTests.length,
353
+ dependency_depth: maxDepth,
354
+ risk_score: riskScore,
355
+ };
356
+
357
+ cacheSet(cacheKey, normalized);
358
+ return normalized;
359
+ }
360
+
361
+ /**
362
+ * Get relevant file paths that should be included as context.
363
+ *
364
+ * Uses Graphify's BFS-based query to find related nodes.
365
+ *
366
+ * @param {string[]} filePaths — seed file paths
367
+ * @param {number} [maxFiles=20] — maximum files to return
368
+ * @param {CodeGraphOptions} [options]
369
+ * @returns {Promise<string[]|null>}
370
+ */
371
+ async function getRelevantContext(filePaths, maxFiles = 20, options = {}) {
372
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
373
+
374
+ const ws = resolveWorkspace({ ...options, filePaths });
375
+ const cacheKey = `ctx:${ws}:${filePaths.sort().join(",")}:${maxFiles}`;
376
+ const cached = cacheGet(cacheKey);
377
+ if (cached) return cached;
378
+
379
+ // Use query_graph with BFS to find related files
380
+ const searchTerms = filePaths.map(f => path.basename(f, path.extname(f))).join(" ");
381
+ const result = await execGraph(
382
+ "query",
383
+ ["query_graph", searchTerms, "--max-tokens", String(maxFiles * 100), "--json"],
384
+ ws
385
+ );
386
+ if (!result) return null;
387
+
388
+ // Extract unique source files from result nodes
389
+ const nodes = result.nodes || result.results || [];
390
+ const fileSet = new Set();
391
+ for (const node of nodes) {
392
+ const src = node.source_file || node.source || "";
393
+ if (src) fileSet.add(src);
394
+ }
395
+
396
+ const files = [...fileSet].slice(0, maxFiles);
397
+ if (files.length === 0) return null;
398
+
399
+ cacheSet(cacheKey, files);
400
+ return files;
401
+ }
402
+
403
+ /**
404
+ * Get complexity signals for routing decisions.
405
+ *
406
+ * Queries Graphify for god nodes, community cohesion, and structural signals
407
+ * that indicate how complex a code change is.
408
+ *
409
+ * @param {string[]} filePaths — list of file paths to analyze
410
+ * @param {CodeGraphOptions} [options]
411
+ * @returns {Promise<{ blast_radius: number, dependency_depth: number, test_coverage_pct: number, is_infrastructure: boolean, god_node_touched: boolean, community_count: number, cohesion: number }|null>}
412
+ */
413
+ async function getComplexitySignals(filePaths, options = {}) {
414
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
415
+
416
+ const ws = resolveWorkspace({ ...options, filePaths });
417
+ const cacheKey = `complexity:${ws}:${filePaths.sort().join(",")}`;
418
+ const cached = cacheGet(cacheKey);
419
+ if (cached) return cached;
420
+
421
+ // Run parallel queries: neighbors (blast radius) + god_nodes + graph_stats
422
+ const [neighborsResult, godNodesResult, statsResult] = await Promise.all([
423
+ execGraph("query", ["get_neighbors", "--files", ...filePaths, "--depth", "2", "--json"], ws),
424
+ execGraph("query", ["god_nodes", "--json"], ws),
425
+ execGraph("query", ["graph_stats", "--json"], ws),
426
+ ]);
427
+
428
+ // If all queries failed (tool not available), return null
429
+ if (!neighborsResult && !godNodesResult && !statsResult) return null;
430
+
431
+ // Compute blast radius from neighbors
432
+ let blastRadius = 0;
433
+ let depthMax = 0;
434
+ const affectedFiles = new Set();
435
+ if (neighborsResult) {
436
+ const nodes = neighborsResult.nodes || neighborsResult.neighbors || [];
437
+ for (const node of nodes) {
438
+ if (node.source_file) affectedFiles.add(node.source_file);
439
+ if (node.depth && node.depth > depthMax) depthMax = node.depth;
440
+ }
441
+ blastRadius = affectedFiles.size;
442
+ }
443
+
444
+ // Check if any touched file contains a god node
445
+ let godNodeTouched = false;
446
+ if (godNodesResult) {
447
+ const godNodes = godNodesResult.god_nodes || godNodesResult.nodes || godNodesResult || [];
448
+ const godFiles = new Set(
449
+ (Array.isArray(godNodes) ? godNodes : []).map(n => n.source_file || n.source || "")
450
+ );
451
+ godNodeTouched = filePaths.some(fp => {
452
+ const base = path.basename(fp);
453
+ for (const gf of godFiles) {
454
+ if (gf.includes(base) || base.includes(path.basename(gf))) return true;
455
+ }
456
+ return false;
457
+ });
458
+ }
459
+
460
+ // Extract community/cohesion from stats
461
+ let communityCount = 0;
462
+ let cohesion = 1;
463
+ if (statsResult) {
464
+ communityCount = statsResult.communities || statsResult.community_count || 0;
465
+ cohesion = statsResult.avg_cohesion ?? statsResult.cohesion ?? 1;
466
+ }
467
+
468
+ // Detect infrastructure files
469
+ const infraPatterns = [
470
+ /docker/i, /compose/i, /makefile/i, /webpack/i, /babel/i, /eslint/i,
471
+ /tsconfig/i, /package\.json/i, /\.github/i, /ci/i, /cd/i, /deploy/i,
472
+ /terraform/i, /ansible/i, /k8s/i, /kubernetes/i, /helm/i,
473
+ ];
474
+ const isInfrastructure = filePaths.some(fp =>
475
+ infraPatterns.some(pattern => pattern.test(fp))
476
+ );
477
+
478
+ // Estimate test coverage from graph — ratio of test files to affected files
479
+ const testFiles = [...affectedFiles].filter(f => /test|spec|__test/i.test(f));
480
+ const testCoveragePct = affectedFiles.size > 0
481
+ ? Math.round((testFiles.length / affectedFiles.size) * 100)
482
+ : 100; // Assume covered if we can't tell
483
+
484
+ const normalized = {
485
+ blast_radius: blastRadius,
486
+ dependency_depth: depthMax,
487
+ test_coverage_pct: testCoveragePct,
488
+ is_infrastructure: isInfrastructure,
489
+ god_node_touched: godNodeTouched,
490
+ community_count: communityCount,
491
+ cohesion,
492
+ };
493
+
494
+ cacheSet(cacheKey, normalized);
495
+ return normalized;
496
+ }
497
+
498
+ /**
499
+ * Get overall graph statistics.
500
+ *
501
+ * @param {CodeGraphOptions} [options]
502
+ * @returns {Promise<{ total_files: number, total_functions: number, total_edges: number, languages: string[], communities: number, god_nodes: string[] }|null>}
503
+ */
504
+ async function getGraphStats(options = {}) {
505
+ const ws = resolveWorkspace(options);
506
+ const cacheKey = `stats:${ws}`;
507
+ const cached = cacheGet(cacheKey);
508
+ if (cached) return cached;
509
+
510
+ const result = await execGraph("query", ["graph_stats", "--json"], ws);
511
+ if (!result) return null;
512
+
513
+ const normalized = {
514
+ total_files: result.total_files ?? result.files ?? 0,
515
+ total_functions: result.total_functions ?? result.nodes ?? 0,
516
+ total_edges: result.total_edges ?? result.edges ?? 0,
517
+ languages: Array.isArray(result.languages) ? result.languages : [],
518
+ communities: result.communities ?? result.community_count ?? 0,
519
+ god_nodes: Array.isArray(result.god_nodes) ? result.god_nodes.map(n => n.label || n.id || n) : [],
520
+ };
521
+
522
+ cacheSet(cacheKey, normalized);
523
+ return normalized;
524
+ }
525
+
526
+ // ============================================================================
527
+ // EXPORTS
528
+ // ============================================================================
529
+
530
+ module.exports = {
531
+ isAvailable,
532
+ getBlastRadius,
533
+ getRelevantContext,
534
+ getComplexitySignals,
535
+ getGraphStats,
536
+ resolveWorkspace,
537
+ detectWorkspaceFromPaths,
538
+ };