lynkr 9.14.0 → 9.14.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 (49) hide show
  1. package/package.json +10 -22
  2. package/src/api/health.js +0 -1
  3. package/src/api/middleware/session.js +0 -0
  4. package/src/api/openai-router.js +94 -13
  5. package/src/api/providers-handler.js +0 -1
  6. package/src/api/router.js +6 -3
  7. package/src/cache/prompt.js +0 -7
  8. package/src/clients/bedrock-utils.js +2 -1
  9. package/src/clients/codex-process.js +1 -1
  10. package/src/clients/codex-utils.js +0 -2
  11. package/src/clients/databricks.js +59 -25
  12. package/src/clients/gpt-utils.js +1 -1
  13. package/src/clients/openai-format.js +73 -23
  14. package/src/clients/provider-capabilities.js +0 -2
  15. package/src/clients/resilience.js +0 -1
  16. package/src/clients/responses-format.js +0 -56
  17. package/src/clients/routing.js +1 -1
  18. package/src/config/index.js +0 -1
  19. package/src/context/budget.js +1 -1
  20. package/src/context/tool-result-compressor.js +3 -5
  21. package/src/dashboard/api.js +0 -1
  22. package/src/headroom/client.js +44 -20
  23. package/src/logger/oversized-error-stream.js +1 -1
  24. package/src/memory/extractor.js +3 -3
  25. package/src/memory/search.js +2 -2
  26. package/src/memory/store.js +9 -24
  27. package/src/memory/tools.js +14 -0
  28. package/src/observability/metrics.js +0 -3
  29. package/src/orchestrator/index.js +1 -80
  30. package/src/orchestrator/sse-transformer.js +68 -10
  31. package/src/policy/safe-commands.js +0 -1
  32. package/src/routing/calibration.js +21 -1
  33. package/src/routing/client-profiles.js +26 -0
  34. package/src/routing/complexity-analyzer.js +5 -8
  35. package/src/routing/confidence-scorer.js +2 -2
  36. package/src/routing/cost-optimizer.js +1 -2
  37. package/src/routing/deescalator.js +1 -1
  38. package/src/routing/embedding-cache.js +0 -1
  39. package/src/routing/intent-score.js +0 -1
  40. package/src/routing/knn-router.js +27 -5
  41. package/src/routing/latency-tracker.js +1 -1
  42. package/src/routing/regret-estimator.js +1 -1
  43. package/src/stores/file-store.js +5 -1
  44. package/src/training/trajectory-compressor.js +1 -1
  45. package/src/workers/pool.js +1 -1
  46. package/src/workers/worker.js +1 -1
  47. package/scripts/convert-routellm.py +0 -105
  48. package/src/diff/comments.js +0 -153
  49. package/src/policy/web-fallback.js +0 -33
@@ -25,7 +25,7 @@ function stringSimilarity(s1, s2) {
25
25
  // Tokenize by whitespace and common delimiters
26
26
  const tokenize = (s) => new Set(
27
27
  s.toLowerCase()
28
- .split(/[\s\-_\/\.\,\:\;]+/)
28
+ .split(/[\s\-_/.,:;]+/)
29
29
  .filter(t => t.length > 0)
30
30
  );
31
31
 
@@ -113,21 +113,48 @@ function convertOpenAIToAnthropic(openaiRequest) {
113
113
  });
114
114
  }
115
115
  } else if (msg.role === "tool") {
116
- // OpenAI tool response → Anthropic tool_result
117
- const previousMsg = anthropicMessages[anthropicMessages.length - 1];
118
-
119
- // Tool results must follow assistant message with tool_use
120
- // Add as separate user message with tool_result
121
- anthropicMessages.push({
122
- role: "user",
123
- content: [
124
- {
125
- type: "tool_result",
126
- tool_use_id: msg.tool_call_id,
127
- content: msg.content
128
- }
129
- ]
130
- });
116
+ // OpenAI tool response → Anthropic tool_result. Two Anthropic rules
117
+ // apply (violating either 400s the request):
118
+ // 1. Every tool_use_id must match a tool_use block in the preceding
119
+ // assistant message — orphans are rejected as "unexpected
120
+ // tool_use_id".
121
+ // 2. All tool_results for one assistant turn must arrive in a SINGLE
122
+ // user message — OpenAI sends parallel tool results as consecutive
123
+ // role:"tool" messages, which must merge, not split.
124
+ const lastAssistant = anthropicMessages.findLast?.(m => m.role === "assistant")
125
+ ?? [...anthropicMessages].reverse().find(m => m.role === "assistant");
126
+ const hasMatchingToolUse = Array.isArray(lastAssistant?.content) &&
127
+ lastAssistant.content.some(b => b?.type === "tool_use" && b.id === msg.tool_call_id);
128
+
129
+ if (!hasMatchingToolUse) {
130
+ // Orphaned result (history truncated or ids rewritten by the client):
131
+ // degrade to plain text so the request survives instead of 400ing.
132
+ logger.warn({ toolCallId: msg.tool_call_id }, "Orphaned tool result — no matching tool_use in prior assistant message; converting to text");
133
+ const text = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
134
+ anthropicMessages.push({
135
+ role: "user",
136
+ content: [{ type: "text", text: `[Tool result ${msg.tool_call_id}]: ${text}` }]
137
+ });
138
+ continue;
139
+ }
140
+
141
+ const resultBlock = {
142
+ type: "tool_result",
143
+ tool_use_id: msg.tool_call_id,
144
+ content: msg.content
145
+ };
146
+
147
+ const prev = anthropicMessages[anthropicMessages.length - 1];
148
+ const prevIsToolResultCarrier = prev?.role === "user" &&
149
+ Array.isArray(prev.content) &&
150
+ prev.content.length > 0 &&
151
+ prev.content.every(b => b?.type === "tool_result");
152
+
153
+ if (prevIsToolResultCarrier) {
154
+ prev.content.push(resultBlock);
155
+ } else {
156
+ anthropicMessages.push({ role: "user", content: [resultBlock] });
157
+ }
131
158
  }
132
159
  }
133
160
 
@@ -261,7 +288,10 @@ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-
261
288
 
262
289
  // Build OpenAI response
263
290
  // Ensure ID has the chatcmpl- prefix that OpenAI clients expect
264
- const responseId = id && id.startsWith("chatcmpl-") ? id : `chatcmpl-${Date.now()}`;
291
+ // Preserve the upstream id whatever its prefix (msg_… from Anthropic-shaped
292
+ // providers) — clients don't validate the prefix and a stable id keeps
293
+ // request tracing intact across the format conversion.
294
+ const responseId = id || `chatcmpl-${Date.now()}`;
265
295
  const openaiResponse = {
266
296
  id: responseId,
267
297
  object: "chat.completion",
@@ -277,11 +307,23 @@ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-
277
307
  finish_reason: mapStopReason(stop_reason)
278
308
  }
279
309
  ],
280
- usage: {
281
- prompt_tokens: usage?.input_tokens || 0,
282
- completion_tokens: usage?.output_tokens || 0,
283
- total_tokens: (usage?.input_tokens || 0) + (usage?.output_tokens || 0)
284
- }
310
+ // OpenAI semantics: prompt_tokens INCLUDES cached tokens (Anthropic's
311
+ // input_tokens excludes cache reads/writes). Dropping cache tokens made
312
+ // clients like opencode undercount context — with prompt caching on,
313
+ // most of a turn's input is cache-read (issue: sidebar token drift).
314
+ usage: (() => {
315
+ const cacheRead = usage?.cache_read_input_tokens || 0;
316
+ const cacheWrite = usage?.cache_creation_input_tokens || 0;
317
+ const promptTokens = (usage?.input_tokens || 0) + cacheRead + cacheWrite;
318
+ const completionTokens = usage?.output_tokens || 0;
319
+ const u = {
320
+ prompt_tokens: promptTokens,
321
+ completion_tokens: completionTokens,
322
+ total_tokens: promptTokens + completionTokens
323
+ };
324
+ if (cacheRead > 0) u.prompt_tokens_details = { cached_tokens: cacheRead };
325
+ return u;
326
+ })()
285
327
  };
286
328
 
287
329
  // Add citations if present
@@ -427,10 +469,18 @@ function convertAnthropicStreamChunkToOpenAI(chunk, model = "claude-3-5-sonnet-2
427
469
  finish_reason: mapStopReason(stopReason)
428
470
  }
429
471
  ],
472
+ // Anthropic's message_delta usage carries output_tokens and (on newer
473
+ // API versions) cumulative input_tokens — forward whatever is present
474
+ // instead of hardcoding prompt_tokens: 0.
430
475
  usage: usage ? {
431
- prompt_tokens: 0, // Not available in streaming
476
+ prompt_tokens: (usage.input_tokens || 0) +
477
+ (usage.cache_read_input_tokens || 0) +
478
+ (usage.cache_creation_input_tokens || 0),
432
479
  completion_tokens: usage.output_tokens || 0,
433
- total_tokens: usage.output_tokens || 0
480
+ total_tokens: (usage.input_tokens || 0) +
481
+ (usage.cache_read_input_tokens || 0) +
482
+ (usage.cache_creation_input_tokens || 0) +
483
+ (usage.output_tokens || 0)
434
484
  } : undefined
435
485
  };
436
486
  } else if (eventType === "message_stop") {
@@ -1,5 +1,3 @@
1
- const config = require("../config");
2
-
3
1
  const NATIVE_THINKING_PROVIDERS = new Set(["azure-anthropic", "databricks"]);
4
2
 
5
3
  const NATIVE_THINKING_BEDROCK_MODELS = [
@@ -195,7 +195,6 @@ class CockatielCircuitBreaker {
195
195
  this._lastStateChange = Date.now();
196
196
 
197
197
  // Recreate the policy to reset state
198
- const oldPolicy = this._policy;
199
198
  this._policy = circuitBreaker(handleAll, {
200
199
  breaker: new ConsecutiveBreaker(this.failureThreshold),
201
200
  halfOpenAfter: this.halfOpenAfter,
@@ -10,61 +10,6 @@
10
10
  const logger = require("../logger");
11
11
  const { repairToolCallIds } = require("./tool-call-repair");
12
12
 
13
- /**
14
- * Map client tool names back to Lynkr tool names
15
- * Used when processing incoming function_call messages from various AI coding clients
16
- * Supports: Codex CLI, Cline (VS Code), Continue.dev
17
- * @param {string} clientToolName - Client tool name (e.g., shell_command, execute_command, read_file)
18
- * @returns {string} Lynkr tool name (e.g., Bash, Read)
19
- */
20
- function mapClientToolToLynkr(clientToolName) {
21
- const reverseMapping = {
22
- // ============== CODEX CLI ==============
23
- "shell": "Bash",
24
- "shell_command": "Bash",
25
- "read_file": "Read",
26
- "write_file": "Write",
27
- "apply_patch": "Edit",
28
- "glob_file_search": "Glob",
29
- "rg": "Grep",
30
- "list_dir": "ListDir",
31
-
32
- // ============== CLINE (VS Code) ==============
33
- "execute_command": "Bash",
34
- // "read_file" already mapped above
35
- "write_to_file": "Write",
36
- "replace_in_file": "Edit",
37
- "search_files": "Grep",
38
- "list_files": "ListDir",
39
-
40
- // ============== KILO CODE (Fork of Cline) ==============
41
- // Most tools same as Cline, but apply_diff is different
42
- "apply_diff": "Edit",
43
- "codebase_search": "Grep",
44
- "delete_file": "Bash", // No direct equivalent, use Bash rm
45
- "browser_action": "WebFetch", // Approximate mapping
46
-
47
- // ============== CONTINUE.DEV ==============
48
- "run_terminal_command": "Bash",
49
- // "read_file" already mapped above
50
- "create_new_file": "Write",
51
- "edit_existing_file": "Edit",
52
- "exact_search": "Grep",
53
- "read_currently_open_file": "Read",
54
-
55
- // ============== Lowercase Lynkr tools (pass-through) ==============
56
- "bash": "Bash",
57
- "read": "Read",
58
- "write": "Write",
59
- "edit": "Edit",
60
- "glob": "Glob",
61
- "grep": "Grep",
62
- "listdir": "ListDir"
63
- };
64
-
65
- return reverseMapping[clientToolName] || clientToolName;
66
- }
67
-
68
13
  /**
69
14
  * Convert Responses API request to Chat Completions format
70
15
  * @param {Object} responsesRequest - Responses API format request
@@ -189,7 +134,6 @@ function convertResponsesToChat(responsesRequest) {
189
134
  }, "Converted multimodal content array to string");
190
135
  } else {
191
136
  // No text found, keep as array (might be image-only)
192
- content = content;
193
137
  }
194
138
  }
195
139
 
@@ -15,7 +15,7 @@ const config = require('../config');
15
15
 
16
16
  // Synchronous version for benchmarking/tests
17
17
  // (when tiers are disabled, routing is purely static)
18
- function determineProviderSync(payload) {
18
+ function determineProviderSync(_payload) {
19
19
  const primaryProvider = config.modelProvider?.type || 'databricks';
20
20
  const defaultModel = config.modelProvider?.defaultModel || 'databricks-claude-sonnet-4-5';
21
21
 
@@ -545,7 +545,6 @@ const agentsEnabled = process.env.AGENTS_ENABLED === "true";
545
545
  // LLM Audit logging configuration
546
546
  const auditEnabled = process.env.LLM_AUDIT_ENABLED === "true"; // default false
547
547
  const auditLogFile = process.env.LLM_AUDIT_LOG_FILE ?? path.join(process.cwd(), "logs", "llm-audit.log");
548
- const auditMaxContentLength = Number.parseInt(process.env.LLM_AUDIT_MAX_CONTENT_LENGTH ?? "5000", 10); // Legacy fallback
549
548
  const auditMaxSystemLength = Number.parseInt(process.env.LLM_AUDIT_MAX_SYSTEM_LENGTH ?? "2000", 10);
550
549
  const auditMaxUserLength = Number.parseInt(process.env.LLM_AUDIT_MAX_USER_LENGTH ?? "3000", 10);
551
550
  const auditMaxResponseLength = Number.parseInt(process.env.LLM_AUDIT_MAX_RESPONSE_LENGTH ?? "3000", 10);
@@ -223,7 +223,7 @@ function finalizeBudgetEnforcement(original, optimized, strategy, initialCheck,
223
223
  /**
224
224
  * Compress system prompt aggressively for budget enforcement
225
225
  */
226
- function compressSystemPromptAggressively(systemPromptContent, payload) {
226
+ function compressSystemPromptAggressively(systemPromptContent, _payload) {
227
227
  let text = typeof systemPromptContent === 'string'
228
228
  ? systemPromptContent
229
229
  : systemPrompt.flattenBlocks(systemPromptContent);
@@ -696,7 +696,7 @@ function compressDirectoryListing(text, opts = {}) {
696
696
  // skips this check — real listings can contain names (spaces, unicode)
697
697
  // the shape regex would reject.
698
698
  if (!opts.trusted) {
699
- const pathLines = lines.filter(l => /^[.\w\/-]+\.\w+$/.test(l.trim()) || /^[.\w\/-]+\/$/.test(l.trim()) || /^[-drwx]{10}/.test(l.trim()));
699
+ const pathLines = lines.filter(l => /^[.\w/-]+\.\w+$/.test(l.trim()) || /^[.\w/-]+\/$/.test(l.trim()) || /^[-drwx]{10}/.test(l.trim()));
700
700
  if (pathLines.length < lines.length * 0.6) return null;
701
701
  }
702
702
 
@@ -823,7 +823,7 @@ function compressLintOutput(text, opts = {}) {
823
823
  for (const line of clean.split("\n")) {
824
824
  // ESLint stylish prints each file as a standalone path line above its
825
825
  // issue block — track it so issues group per file.
826
- if (/^\S+[\/\\]\S+\.\w+$/.test(line.trim()) && !line.includes(":")) {
826
+ if (/^\S+[/\\]\S+\.\w+$/.test(line.trim()) && !line.includes(":")) {
827
827
  currentFile = line.trim();
828
828
  continue;
829
829
  }
@@ -935,8 +935,6 @@ function compressLargeFile(text) {
935
935
 
936
936
  // Extract structural skeleton
937
937
  const skeleton = [];
938
- let inBlock = false;
939
- let braceDepth = 0;
940
938
 
941
939
  for (let i = 0; i < lines.length; i++) {
942
940
  const line = lines[i];
@@ -960,7 +958,7 @@ function compressLargeFile(text) {
960
958
  }
961
959
 
962
960
  // Keep decorators/attributes
963
- if (/^[@#\[]/.test(trimmed)) {
961
+ if (/^[@#[]/.test(trimmed)) {
964
962
  skeleton.push(line);
965
963
  continue;
966
964
  }
@@ -2,7 +2,6 @@ const config = require('../config');
2
2
  const telemetry = require('../routing/telemetry');
3
3
  const { getUsage } = require('../usage/aggregator');
4
4
  const metrics = require('../metrics');
5
- const { getMetricsCollector } = require('../observability/metrics');
6
5
  const { TIER_DEFINITIONS } = require('../routing/model-tiers');
7
6
 
8
7
  // Per-provider type + whether its credentials/endpoint are actually present.
@@ -115,27 +115,48 @@ async function compressMessages(messages, tools = [], options = {}) {
115
115
  }
116
116
 
117
117
  try {
118
- const controller = new AbortController();
119
- const timeout = setTimeout(() => controller.abort(), headroomConfig.timeoutMs);
120
-
121
- const response = await fetch(`${headroomConfig.endpoint}/compress`, {
122
- method: "POST",
123
- headers: { "Content-Type": "application/json" },
124
- body: JSON.stringify({
125
- messages,
126
- tools,
127
- model: options.model || "claude-3-5-sonnet-20241022",
128
- model_limit: options.modelLimit || 200000,
129
- mode: options.mode || headroomConfig.mode,
130
- token_budget: options.tokenBudget,
131
- query_context: options.queryContext,
132
- preserve_recent_turns: options.preserveRecentTurns,
133
- target_ratio: options.targetRatio,
134
- }),
135
- signal: controller.signal,
118
+ const requestBody = JSON.stringify({
119
+ messages,
120
+ tools,
121
+ model: options.model || "claude-3-5-sonnet-20241022",
122
+ model_limit: options.modelLimit || 200000,
123
+ mode: options.mode || headroomConfig.mode,
124
+ token_budget: options.tokenBudget,
125
+ query_context: options.queryContext,
126
+ preserve_recent_turns: options.preserveRecentTurns,
127
+ target_ratio: options.targetRatio,
136
128
  });
137
129
 
138
- clearTimeout(timeout);
130
+ // The sidecar (uvicorn) closes idle keep-alive sockets after ~5s, and
131
+ // undici can reuse a pooled socket in that exact window → ECONNRESET
132
+ // surfaces as "fetch failed". One retry gets a fresh connection, so the
133
+ // race never costs us the compression. Aborts (real timeouts) still
134
+ // propagate immediately.
135
+ let response;
136
+ for (let attempt = 0; ; attempt++) {
137
+ const controller = new AbortController();
138
+ const timeout = setTimeout(() => controller.abort(), headroomConfig.timeoutMs);
139
+ try {
140
+ response = await fetch(`${headroomConfig.endpoint}/compress`, {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json" },
143
+ body: requestBody,
144
+ signal: controller.signal,
145
+ });
146
+ break;
147
+ } catch (fetchErr) {
148
+ if (attempt === 0 && fetchErr.name !== "AbortError") {
149
+ logger.debug(
150
+ { error: fetchErr.message, cause: fetchErr.cause?.code || fetchErr.cause?.message },
151
+ "Headroom fetch failed, retrying on a fresh connection"
152
+ );
153
+ continue;
154
+ }
155
+ throw fetchErr;
156
+ } finally {
157
+ clearTimeout(timeout);
158
+ }
159
+ }
139
160
 
140
161
  if (!response.ok) {
141
162
  const errorText = await response.text();
@@ -179,7 +200,10 @@ async function compressMessages(messages, tools = [], options = {}) {
179
200
  if (err.name === "AbortError") {
180
201
  logger.warn({ timeoutMs: headroomConfig.timeoutMs }, "Headroom compression timed out");
181
202
  } else {
182
- logger.warn({ error: err.message }, "Headroom compression failed, using original");
203
+ logger.warn(
204
+ { error: err.message, cause: err.cause?.code || err.cause?.message },
205
+ "Headroom compression failed, using original"
206
+ );
183
207
  }
184
208
 
185
209
  return {
@@ -66,7 +66,7 @@ function createOversizedErrorStream(config) {
66
66
  },
67
67
  final(callback) {
68
68
  // Close all open file handles when stream ends
69
- for (const [sessionId, { writeStream }] of sessionFiles.entries()) {
69
+ for (const [_sessionId, { writeStream }] of sessionFiles.entries()) {
70
70
  writeStream.end();
71
71
  }
72
72
  sessionFiles.clear();
@@ -26,14 +26,14 @@ const EXTRACTION_PATTERNS = {
26
26
  ],
27
27
 
28
28
  entity: [
29
- /(?:file|function|class|module|component|package|library)\s+['"`]?([A-Za-z0-9_./\-]+)['"`]?/gi,
30
- /in\s+['"`]([A-Za-z0-9_./\-]+\.(?:js|ts|py|java|go|rs|cpp|c|h))['"`]/gi,
29
+ /(?:file|function|class|module|component|package|library)\s+['"`]?([A-Za-z0-9_./-]+)['"`]?/gi,
30
+ /in\s+['"`]([A-Za-z0-9_./-]+\.(?:js|ts|py|java|go|rs|cpp|c|h))['"`]/gi,
31
31
  /`([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)`/g, // Code references
32
32
  ],
33
33
 
34
34
  relationship: [
35
35
  /([A-Za-z0-9_]+)\s+(?:depends on|imports|uses|extends|implements|inherits from)\s+([A-Za-z0-9_]+)/gi,
36
- /([A-Za-z0-9_./\-]+)\s+(?:calls|invokes|references)\s+([A-Za-z0-9_./\-]+)/gi,
36
+ /([A-Za-z0-9_./-]+)\s+(?:calls|invokes|references)\s+([A-Za-z0-9_./-]+)/gi,
37
37
  ],
38
38
  };
39
39
 
@@ -30,7 +30,7 @@ function sanitizeKeyword(keyword) {
30
30
 
31
31
  // Remove ALL FTS5 special characters
32
32
  const sanitized = keyword
33
- .replace(/[*()<>\-:\[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ')
33
+ .replace(/[*()<>\-:[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ')
34
34
  .replace(/\s+/g, ' ')
35
35
  .trim();
36
36
 
@@ -97,7 +97,7 @@ function prepareFTS5Query(query) {
97
97
  // - @ symbol → "syntax error near @"
98
98
  // - Quotes (") → can break string quoting
99
99
  // - Parentheses, brackets → break grouping syntax
100
- cleaned = cleaned.replace(/[*()<>\-:\[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ');
100
+ cleaned = cleaned.replace(/[*()<>\-:[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ');
101
101
  cleaned = cleaned.replace(/\s+/g, ' ').trim();
102
102
 
103
103
  if (!cleaned) {
@@ -122,12 +122,6 @@ const upsertEntityStmt = db.prepare(`
122
122
  properties = @properties
123
123
  `);
124
124
 
125
- const getEntityStmt = db.prepare(`
126
- SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
127
- FROM memory_entities
128
- WHERE entity_type = ? AND entity_name = ?
129
- `);
130
-
131
125
  const getAllEntitiesStmt = db.prepare(`
132
126
  SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
133
127
  FROM memory_entities
@@ -176,19 +170,6 @@ function toMemory(row) {
176
170
  };
177
171
  }
178
172
 
179
- function toEntity(row) {
180
- if (!row) return null;
181
- return {
182
- id: row.id,
183
- entityType: row.entity_type,
184
- entityName: row.entity_name,
185
- firstSeenAt: row.first_seen_at,
186
- lastSeenAt: row.last_seen_at,
187
- occurrenceCount: row.occurrence_count ?? 1,
188
- properties: parseJSON(row.properties, {}),
189
- };
190
- }
191
-
192
173
  /**
193
174
  * Create a new memory
194
175
  */
@@ -400,16 +381,20 @@ function trackEntity(options) {
400
381
  }
401
382
 
402
383
  /**
403
- * Get an entity
384
+ * Get an entity by name, optionally scoped to a type. Names can collide
385
+ * across types (e.g. a "file" and a "topic" both named "router"), so
386
+ * without a type filter the most recently seen entity wins,
387
+ * deterministically.
404
388
  */
405
- function getEntity(name) {
406
- // Since we only have the name, we need to search across all entity types
389
+ function getEntity(name, type = null) {
407
390
  const stmt = db.prepare(`
408
391
  SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
409
392
  FROM memory_entities
410
- WHERE entity_name = ?
393
+ WHERE entity_name = ? ${type ? "AND entity_type = ?" : ""}
394
+ ORDER BY last_seen_at DESC
395
+ LIMIT 1
411
396
  `);
412
- const row = stmt.get(name);
397
+ const row = type ? stmt.get(name, type) : stmt.get(name);
413
398
  if (!row) return null;
414
399
 
415
400
  return {
@@ -340,6 +340,19 @@ async function memory_stats(args, context = {}) {
340
340
  };
341
341
  }
342
342
 
343
+ // Surface tracked entities — the extractor writes them on every
344
+ // conversation, but until this they were collected and never readable.
345
+ let entities = [];
346
+ try {
347
+ entities = store.getAllEntities(10).map(e => ({
348
+ name: e.name,
349
+ type: e.type,
350
+ count: e.count,
351
+ }));
352
+ } catch (err) {
353
+ logger.debug({ err: err.message }, 'Entity stats unavailable');
354
+ }
355
+
343
356
  return {
344
357
  ok: true,
345
358
  content: JSON.stringify({
@@ -350,6 +363,7 @@ async function memory_stats(args, context = {}) {
350
363
  recentCount: stats.recentCount,
351
364
  importantCount: stats.importantCount,
352
365
  sessionId: stats.sessionId || 'global',
366
+ topEntities: entities,
353
367
  }, null, 2),
354
368
  };
355
369
  } catch (err) {
@@ -1,6 +1,3 @@
1
- const config = require("../config");
2
- const logger = require("../logger");
3
-
4
1
  /**
5
2
  * High-performance metrics collection
6
3
  *
@@ -13,12 +13,11 @@ const { applyToonCompression } = require("../context/toon");
13
13
  const { applyGcfCompression } = require("../context/gcf");
14
14
  const { compressMessages: headroomCompress, isEnabled: isHeadroomEnabled } = require("../headroom");
15
15
  const { createAuditLogger } = require("../logger/audit-logger");
16
- const { getResolvedIp, runWithDnsContext } = require("../clients/dns-logger");
17
16
  const { getShuttingDown } = require("../api/health");
18
17
  const { tryPreflight, buildSatisfiedResponse: buildPreflightResponse } = require("./preflight");
19
18
  const { detectBypass, buildBypassResponse } = require("./bypass");
20
19
  const crypto = require("crypto");
21
- const { getSemanticCache, isSemanticCacheEnabled } = require("../cache/semantic");
20
+ const { getSemanticCache } = require("../cache/semantic");
22
21
  const { areSimilarToolCalls } = require("../clients/gpt-utils");
23
22
  const { getModelRegistrySync } = require("../routing/model-registry");
24
23
  const sessionAffinity = require("../routing/session-affinity");
@@ -416,37 +415,6 @@ function countToolCallsInHistory(messages) {
416
415
  return { toolUseCount, toolResultCount, lastUserTextIndex };
417
416
  }
418
417
 
419
- /**
420
- * Inject a "stop looping" instruction if there are too many tool calls in history.
421
- * This helps prevent infinite loops when the model keeps calling tools instead of responding.
422
- *
423
- * @param {Array} messages - The conversation messages
424
- * @param {number} threshold - Max tool results before injection (default: 5)
425
- * @returns {Array} - Messages with stop instruction injected if needed
426
- */
427
- function injectToolLoopStopInstruction(messages, threshold = 5) {
428
- if (!Array.isArray(messages)) return messages;
429
-
430
- const { toolResultCount } = countToolCallsInHistory(messages);
431
-
432
- if (toolResultCount >= threshold) {
433
- logger.warn({
434
- toolResultCount,
435
- threshold,
436
- }, "[ToolLoopGuard] Too many tool results in conversation - injecting stop instruction");
437
-
438
- // Inject instruction to stop tool calls and provide a final answer
439
- const stopInstruction = {
440
- role: "user",
441
- content: `⚠️ IMPORTANT: You have already executed ${toolResultCount} tool calls in this conversation. This is likely an infinite loop. STOP calling tools immediately and provide a direct text response to the user based on the information you have gathered. If you cannot complete the task, explain why. DO NOT call any more tools. Your response must contain your actual findings — NOT an acknowledgment or restatement of this warning.`,
442
- };
443
-
444
- return [...messages, stopInstruction];
445
- }
446
-
447
- return messages;
448
- }
449
-
450
418
  // === CROSS-REQUEST TOOL CALL DEDUP TRACKING ===
451
419
  // These helpers track tool call signatures across multiple HTTP requests within
452
420
  // the same session (client/passthrough mode). The inner-loop detection in
@@ -718,52 +686,6 @@ function normaliseToolChoice(choice) {
718
686
  return undefined;
719
687
  }
720
688
 
721
- /**
722
- * Strip thinking-style reasoning from Ollama model outputs
723
- * Patterns to remove:
724
- * - Lines starting with bullet points (●, •, -, *)
725
- * - Explanatory reasoning before the actual response
726
- * - Multiple newlines used to separate thinking from response
727
- */
728
- function stripThinkingBlocks(text) {
729
- if (typeof text !== "string") return text;
730
-
731
- const lines = text.split("\n");
732
- const cleanedLines = [];
733
- let inThinkingBlock = false;
734
- let consecutiveEmptyLines = 0;
735
-
736
- for (const line of lines) {
737
- const trimmed = line.trim();
738
-
739
- // Detect thinking block markers (bullet points followed by reasoning)
740
- if (/^[●•\-\*]\s/.test(trimmed)) {
741
- inThinkingBlock = true;
742
- continue;
743
- }
744
-
745
- // Empty lines might separate thinking from response
746
- if (trimmed === "") {
747
- consecutiveEmptyLines++;
748
- // If we've seen 2+ empty lines, likely end of thinking block
749
- if (consecutiveEmptyLines >= 2) {
750
- inThinkingBlock = false;
751
- }
752
- continue;
753
- }
754
-
755
- consecutiveEmptyLines = 0;
756
-
757
- if (inThinkingBlock) {
758
- continue;
759
- }
760
-
761
- cleanedLines.push(line);
762
- }
763
-
764
- return cleanedLines.join("\n").trim();
765
- }
766
-
767
689
  /**
768
690
  * Convert legacy Ollama /api/chat response to Anthropic Messages format.
769
691
  * Used when Ollama < v0.14.0 (no native Anthropic endpoint).
@@ -1392,7 +1314,6 @@ async function runAgentLoop({
1392
1314
  requestedModel,
1393
1315
  wantsThinking,
1394
1316
  session,
1395
- cwd,
1396
1317
  options,
1397
1318
  cacheKey,
1398
1319
  providerType,