lynkr 9.7.2 → 9.7.3

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.7.2",
3
+ "version": "9.7.3",
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": {
@@ -16,7 +16,7 @@
16
16
  "dev": "nodemon index.js",
17
17
  "lint": "eslint src index.js",
18
18
  "test": "npm run test:unit && npm run test:performance",
19
- "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/code-mode.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/model-registry-cost.test.js test/task-decomposition.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js",
19
+ "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/code-mode.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/model-registry-cost.test.js test/task-decomposition.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/agent-learning.test.js test/tool-call-response-metadata.test.js",
20
20
  "test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js",
21
21
  "test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
22
22
  "test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
@@ -62,6 +62,14 @@ class ContextManager {
62
62
  allowedTools: agentDef.allowedTools,
63
63
  startTime: Date.now(),
64
64
 
65
+ // Original task prompt — consumed by the Reflector to infer task type.
66
+ taskPrompt,
67
+
68
+ // In-memory transcript mirror of the JSONL file — consumed by the
69
+ // Reflector for tool-usage / error-recovery analysis. The JSONL file
70
+ // remains the durable record; this array is the hot-path copy.
71
+ transcript: [],
72
+
65
73
  // Token tracking
66
74
  inputTokens: 0,
67
75
  outputTokens: 0,
@@ -102,7 +110,7 @@ class ContextManager {
102
110
  * Record tool execution in transcript
103
111
  */
104
112
  recordToolCall(context, toolName, input, output, error = null) {
105
- this.writeTranscriptEntry(context.transcriptPath, {
113
+ const entry = {
106
114
  type: "tool_call",
107
115
  agentId: context.agentId,
108
116
  step: context.steps,
@@ -111,7 +119,15 @@ class ContextManager {
111
119
  output: error ? null : output,
112
120
  error: error ? error.message : null,
113
121
  timestamp: Date.now()
114
- });
122
+ };
123
+
124
+ // Keep the in-memory transcript in sync so the Reflector (which reads
125
+ // context.transcript) sees tool calls without re-parsing the JSONL file.
126
+ if (Array.isArray(context.transcript)) {
127
+ context.transcript.push(entry);
128
+ }
129
+
130
+ this.writeTranscriptEntry(context.transcriptPath, entry);
115
131
  }
116
132
 
117
133
  /**
@@ -4,11 +4,17 @@ const yaml = require("js-yaml");
4
4
  const logger = require("../../logger");
5
5
  const Skillbook = require("../skillbook");
6
6
 
7
+ // How often to prune low-quality learned skills. 6 hours is frequent enough to
8
+ // keep skillbooks tidy on a long-running process but rare enough to be
9
+ // negligible overhead. Hardcoded on purpose — not worth a config knob.
10
+ const SKILL_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000;
11
+
7
12
  class AgentDefinitionLoader {
8
13
  constructor() {
9
14
  this.agents = new Map();
10
15
  this.skillbooks = new Map(); // agentType → Skillbook
11
16
  this.initialized = false;
17
+ this.pruneTimer = null;
12
18
 
13
19
  // Initialize synchronously for compatibility
14
20
  this.loadBuiltInAgentsSync();
@@ -82,6 +88,27 @@ class AgentDefinitionLoader {
82
88
  return this.skillbooks.get(agentType);
83
89
  }
84
90
 
91
+ /**
92
+ * Replace an agent's skillbook with a freshly-learned instance and re-inject
93
+ * its skills into the (in-memory) system prompt. Called by the executor after
94
+ * a run persists new skills, so learning takes effect within the running
95
+ * process instead of only after a restart. Injection rebuilds from
96
+ * originalSystemPrompt, so repeated calls do not duplicate the skills block.
97
+ */
98
+ setSkillbook(agentType, skillbook) {
99
+ if (!agentType || !skillbook) return;
100
+ // Match the case-insensitive key an agent is actually stored under.
101
+ const key = this.agents.has(agentType)
102
+ ? agentType
103
+ : Array.from(this.agents.keys()).find(
104
+ k => k.toLowerCase() === String(agentType).toLowerCase()
105
+ );
106
+ if (!key) return;
107
+
108
+ this.skillbooks.set(key, skillbook);
109
+ this._injectSkillsIntoPrompt(key);
110
+ }
111
+
85
112
  /**
86
113
  * Reload skillbooks and update prompts (call after learning)
87
114
  */
@@ -89,6 +116,69 @@ class AgentDefinitionLoader {
89
116
  await this._loadSkillbooksAsync();
90
117
  }
91
118
 
119
+ /**
120
+ * Prune low-quality skills from every loaded skillbook. Skillbook.prune()
121
+ * only drops skills that have been tried enough times to prove they don't
122
+ * help (default: useCount >= 3 && confidence < 0.2), so fresh skills are
123
+ * never removed. Persists and re-injects only the skillbooks that changed.
124
+ * @returns {Promise<number>} total skills pruned across all agents
125
+ */
126
+ async pruneSkillbooks() {
127
+ let totalPruned = 0;
128
+
129
+ for (const [agentType, skillbook] of this.skillbooks.entries()) {
130
+ try {
131
+ const pruned = skillbook.prune();
132
+ if (pruned > 0) {
133
+ await skillbook.save();
134
+ this._injectSkillsIntoPrompt(agentType);
135
+ totalPruned += pruned;
136
+ }
137
+ } catch (error) {
138
+ logger.warn(
139
+ { agentType, error: error.message },
140
+ "Failed to prune skillbook"
141
+ );
142
+ }
143
+ }
144
+
145
+ if (totalPruned > 0) {
146
+ logger.info({ totalPruned }, "Pruned low-quality agent skills");
147
+ }
148
+ return totalPruned;
149
+ }
150
+
151
+ /**
152
+ * Start periodic skillbook pruning. Idempotent; a non-positive interval
153
+ * disables pruning. The timer is unref'd so it never keeps the process alive.
154
+ */
155
+ startSkillPruning(intervalMs = SKILL_PRUNE_INTERVAL_MS) {
156
+ if (this.pruneTimer) return; // already running
157
+ if (!Number.isInteger(intervalMs) || intervalMs <= 0) {
158
+ logger.debug({ intervalMs }, "Skillbook pruning disabled");
159
+ return;
160
+ }
161
+
162
+ this.pruneTimer = setInterval(() => {
163
+ this.pruneSkillbooks().catch((error) => {
164
+ logger.warn({ error: error.message }, "Skillbook pruning failed");
165
+ });
166
+ }, intervalMs);
167
+ this.pruneTimer.unref();
168
+
169
+ logger.info({ intervalMs }, "Skillbook pruning started");
170
+ }
171
+
172
+ /**
173
+ * Stop periodic skillbook pruning (for shutdown / tests).
174
+ */
175
+ stopSkillPruning() {
176
+ if (this.pruneTimer) {
177
+ clearInterval(this.pruneTimer);
178
+ this.pruneTimer = null;
179
+ }
180
+ }
181
+
92
182
  /**
93
183
  * Load built-in agents (Explore, Plan, General)
94
184
  */
@@ -9,6 +9,18 @@ const Reflector = require("./reflector");
9
9
  const contextManager = new ContextManager();
10
10
 
11
11
  class SubagentExecutor {
12
+ /**
13
+ * @param {Object} [options]
14
+ * @param {Object} [options.definitionLoader] - Agent definition loader. When
15
+ * provided, newly-learned skills are re-injected into agent prompts live
16
+ * (within the running process). Optional so the executor still works
17
+ * standalone (e.g. in tests); without it, learning still persists to disk
18
+ * and is picked up on the next process start.
19
+ */
20
+ constructor({ definitionLoader = null } = {}) {
21
+ this.definitionLoader = definitionLoader;
22
+ }
23
+
12
24
  /**
13
25
  * Execute a single subagent
14
26
  * @param {Object} agentDef - Agent definition
@@ -403,8 +415,12 @@ class SubagentExecutor {
403
415
  return; // Nothing to learn
404
416
  }
405
417
 
406
- // Load skillbook for this agent type
407
- const skillbook = await Skillbook.load(context.agentName);
418
+ // Prefer the loader's live skillbook instance (so use-counts/confidence
419
+ // accumulate in one place); fall back to loading from disk when running
420
+ // standalone.
421
+ const skillbook =
422
+ this.definitionLoader?.getSkillbook(context.agentName) ||
423
+ (await Skillbook.load(context.agentName));
408
424
 
409
425
  // Add each learned pattern
410
426
  for (const pattern of patterns) {
@@ -414,6 +430,12 @@ class SubagentExecutor {
414
430
  // Save skillbook (persists learning)
415
431
  await skillbook.save();
416
432
 
433
+ // Re-inject into the agent's prompt so the next run benefits without a
434
+ // restart. No-op when no loader was provided.
435
+ if (this.definitionLoader) {
436
+ this.definitionLoader.setSkillbook(context.agentName, skillbook);
437
+ }
438
+
417
439
  logger.info({
418
440
  agentType: context.agentName,
419
441
  patternsLearned: patterns.length,
@@ -5,7 +5,15 @@ const ParallelCoordinator = require("./parallel-coordinator");
5
5
  const agentStore = require("./store");
6
6
 
7
7
  const definitionLoader = new AgentDefinitionLoader();
8
- const coordinator = new ParallelCoordinator(config.agents?.maxConcurrent || 10);
8
+ const coordinator = new ParallelCoordinator(config.agents?.maxConcurrent || 10, {
9
+ definitionLoader,
10
+ });
11
+
12
+ // Periodically drop learned skills that have proven unhelpful. Only when the
13
+ // agents subsystem is active; the timer is unref'd so it never blocks exit.
14
+ if (config.agents?.enabled) {
15
+ definitionLoader.startSkillPruning();
16
+ }
9
17
 
10
18
  /**
11
19
  * Spawn and execute subagent(s)
@@ -2,9 +2,9 @@ const logger = require("../logger");
2
2
  const SubagentExecutor = require("./executor");
3
3
 
4
4
  class ParallelCoordinator {
5
- constructor(maxConcurrent = 10) {
5
+ constructor(maxConcurrent = 10, { definitionLoader = null } = {}) {
6
6
  this.maxConcurrent = maxConcurrent;
7
- this.executor = new SubagentExecutor();
7
+ this.executor = new SubagentExecutor({ definitionLoader });
8
8
  }
9
9
 
10
10
  /**
@@ -184,8 +184,13 @@ class Reflector {
184
184
  // Pattern 1: Recovered from errors
185
185
  if (successful) {
186
186
  const failedTools = errorEntries.map(e => e.toolName);
187
+ // Tools invoked *after* the last error entry are the recovery path.
188
+ // Use the entry's position in the transcript, not its timestamp
189
+ // (slicing by a ~1.7e12 timestamp always yielded []).
190
+ const lastErrorEntry = errorEntries[errorEntries.length - 1];
191
+ const lastErrorIndex = transcript.indexOf(lastErrorEntry);
187
192
  const recoveryTools = transcript
188
- .slice(errorEntries[errorEntries.length - 1].timestamp)
193
+ .slice(lastErrorIndex + 1)
189
194
  .filter(e => e.type === "tool_call" && !e.error)
190
195
  .map(e => e.toolName);
191
196
 
@@ -248,6 +253,11 @@ class Reflector {
248
253
  * Infer task type from prompt
249
254
  */
250
255
  static _inferTaskType(prompt) {
256
+ // Guard against a missing/non-string prompt so reflection never throws
257
+ // (a throw here previously aborted all learning silently).
258
+ if (typeof prompt !== "string" || prompt.length === 0) {
259
+ return null;
260
+ }
251
261
  const lower = prompt.toLowerCase();
252
262
 
253
263
  const taskTypes = [
@@ -2708,7 +2708,7 @@ async function invokeModel(body, options = {}) {
2708
2708
  message_count: Array.isArray(body?.messages) ? body.messages.length : 0,
2709
2709
  request_type: routingResult.analysis?.requestType || null,
2710
2710
  provider: initialProvider,
2711
- model: routingDecision.model,
2711
+ model: routingDecision.model ?? body._tierModel ?? null,
2712
2712
  routing_method: routingDecision.method,
2713
2713
  was_fallback: false,
2714
2714
  output_tokens: outputTokens || null,
@@ -2826,7 +2826,7 @@ async function invokeModel(body, options = {}) {
2826
2826
  message_count: Array.isArray(body?.messages) ? body.messages.length : 0,
2827
2827
  request_type: routingResult.analysis?.requestType || null,
2828
2828
  provider: initialProvider,
2829
- model: routingDecision.model,
2829
+ model: routingDecision.model ?? body._tierModel ?? null,
2830
2830
  routing_method: routingDecision.method,
2831
2831
  was_fallback: false,
2832
2832
  latency_ms: failLatency,
@@ -2934,7 +2934,7 @@ async function invokeModel(body, options = {}) {
2934
2934
  message_count: Array.isArray(body?.messages) ? body.messages.length : 0,
2935
2935
  request_type: routingResult.analysis?.requestType || null,
2936
2936
  provider: fallbackProvider,
2937
- model: routingDecision.model,
2937
+ model: routingDecision.model ?? body._tierModel ?? null,
2938
2938
  routing_method: "fallback",
2939
2939
  was_fallback: true,
2940
2940
  output_tokens: fbOutputTokens || null,
@@ -2980,7 +2980,7 @@ async function invokeModel(body, options = {}) {
2980
2980
  complexity_score: routingResult.score ?? null,
2981
2981
  tier: routingDecision.tier,
2982
2982
  provider: fallbackProvider,
2983
- model: routingDecision.model,
2983
+ model: routingDecision.model ?? body._tierModel ?? null,
2984
2984
  routing_method: "fallback",
2985
2985
  was_fallback: true,
2986
2986
  latency_ms: Date.now() - startTime,
@@ -1111,7 +1111,13 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
1111
1111
  id: openai.id ?? `msg_${Date.now()}`,
1112
1112
  type: "message",
1113
1113
  role: "assistant",
1114
- model: requestedModel,
1114
+ // Prefer the model the provider actually served with; fall back to the
1115
+ // requested model only when the provider omits it. Mirrors the direct
1116
+ // (non-tool) path at `databricksResponse.json.model || requestedModel`, so
1117
+ // tool-call responses no longer report a stale/aliased client-request model.
1118
+ model: (typeof openai?.model === "string" && openai.model.trim())
1119
+ ? openai.model
1120
+ : requestedModel,
1115
1121
  content: contentItems,
1116
1122
  stop_reason:
1117
1123
  choice?.finish_reason === "stop"
@@ -1123,8 +1129,11 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
1123
1129
  : choice?.finish_reason ?? "end_turn",
1124
1130
  stop_sequence: null,
1125
1131
  usage: {
1126
- input_tokens: usage.prompt_tokens ?? 0,
1127
- output_tokens: usage.completion_tokens ?? 0,
1132
+ // Accept both OpenAI (prompt_tokens/completion_tokens) and
1133
+ // already-Anthropic (input_tokens/output_tokens) usage shapes so token
1134
+ // counts survive regardless of which provider/converter produced them.
1135
+ input_tokens: usage.prompt_tokens ?? usage.input_tokens ?? 0,
1136
+ output_tokens: usage.completion_tokens ?? usage.output_tokens ?? 0,
1128
1137
  cache_creation_input_tokens: 0,
1129
1138
  cache_read_input_tokens: 0,
1130
1139
  },
@@ -4053,6 +4062,16 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4053
4062
  anthropicPayload.usage.cache_read_input_tokens = promptTokens;
4054
4063
  anthropicPayload.usage.cache_creation_input_tokens = 0;
4055
4064
 
4065
+ // Carry routing metadata on the cache-hit path too, so downstream model
4066
+ // name resolution (OpenClaw) behaves the same as the live loop path.
4067
+ if (cachedResponse.routingDecision) {
4068
+ anthropicPayload._routingMeta = {
4069
+ provider: cachedResponse.routingDecision.provider,
4070
+ model: cachedResponse.routingDecision.model,
4071
+ tier: cachedResponse.routingDecision.tier,
4072
+ };
4073
+ }
4074
+
4056
4075
  appendTurnToSession(session, {
4057
4076
  role: "assistant",
4058
4077
  type: "message",
@@ -4147,4 +4166,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4147
4166
 
4148
4167
  module.exports = {
4149
4168
  processMessage,
4169
+ // Exported for unit testing of response-metadata conversion.
4170
+ toAnthropicResponse,
4150
4171
  };
@@ -49,12 +49,27 @@ function init() {
49
49
  }
50
50
 
51
51
  try {
52
- const dbDir = path.resolve(process.cwd(), ".lynkr");
53
- if (!fs.existsSync(dbDir)) {
54
- fs.mkdirSync(dbDir, { recursive: true });
52
+ // Allow tests to redirect telemetry to an isolated DB so unit/integration
53
+ // runs never pollute the production .lynkr/telemetry.db that build-knn-index.js
54
+ // reads from. Empty string disables telemetry entirely.
55
+ const override = process.env.LYNKR_TELEMETRY_DB_PATH;
56
+ let dbPath;
57
+ if (override === "") {
58
+ logger.debug("Telemetry: LYNKR_TELEMETRY_DB_PATH is empty, telemetry disabled");
59
+ return false;
60
+ } else if (override) {
61
+ dbPath = path.resolve(override);
62
+ const overrideDir = path.dirname(dbPath);
63
+ if (!fs.existsSync(overrideDir)) {
64
+ fs.mkdirSync(overrideDir, { recursive: true });
65
+ }
66
+ } else {
67
+ const dbDir = path.resolve(process.cwd(), ".lynkr");
68
+ if (!fs.existsSync(dbDir)) {
69
+ fs.mkdirSync(dbDir, { recursive: true });
70
+ }
71
+ dbPath = path.join(dbDir, "telemetry.db");
55
72
  }
56
-
57
- const dbPath = path.join(dbDir, "telemetry.db");
58
73
  db = new Database(dbPath, {
59
74
  verbose: process.env.DEBUG_SQL ? console.log : null,
60
75
  fileMustExist: false,