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.
- package/package.json +10 -22
- package/src/api/health.js +0 -1
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +94 -13
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +6 -3
- package/src/cache/prompt.js +0 -7
- package/src/clients/bedrock-utils.js +2 -1
- package/src/clients/codex-process.js +1 -1
- package/src/clients/codex-utils.js +0 -2
- package/src/clients/databricks.js +59 -25
- package/src/clients/gpt-utils.js +1 -1
- package/src/clients/openai-format.js +73 -23
- package/src/clients/provider-capabilities.js +0 -2
- package/src/clients/resilience.js +0 -1
- package/src/clients/responses-format.js +0 -56
- package/src/clients/routing.js +1 -1
- package/src/config/index.js +0 -1
- package/src/context/budget.js +1 -1
- package/src/context/tool-result-compressor.js +3 -5
- package/src/dashboard/api.js +0 -1
- package/src/headroom/client.js +44 -20
- package/src/logger/oversized-error-stream.js +1 -1
- package/src/memory/extractor.js +3 -3
- package/src/memory/search.js +2 -2
- package/src/memory/store.js +9 -24
- package/src/memory/tools.js +14 -0
- package/src/observability/metrics.js +0 -3
- package/src/orchestrator/index.js +1 -80
- package/src/orchestrator/sse-transformer.js +68 -10
- package/src/policy/safe-commands.js +0 -1
- package/src/routing/calibration.js +21 -1
- package/src/routing/client-profiles.js +26 -0
- package/src/routing/complexity-analyzer.js +5 -8
- package/src/routing/confidence-scorer.js +2 -2
- package/src/routing/cost-optimizer.js +1 -2
- package/src/routing/deescalator.js +1 -1
- package/src/routing/embedding-cache.js +0 -1
- package/src/routing/intent-score.js +0 -1
- package/src/routing/knn-router.js +27 -5
- package/src/routing/latency-tracker.js +1 -1
- package/src/routing/regret-estimator.js +1 -1
- package/src/stores/file-store.js +5 -1
- package/src/training/trajectory-compressor.js +1 -1
- package/src/workers/pool.js +1 -1
- package/src/workers/worker.js +1 -1
- package/scripts/convert-routellm.py +0 -105
- package/src/diff/comments.js +0 -153
- package/src/policy/web-fallback.js +0 -33
package/src/clients/gpt-utils.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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:
|
|
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.
|
|
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") {
|
|
@@ -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
|
|
package/src/clients/routing.js
CHANGED
|
@@ -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(
|
|
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
|
|
package/src/config/index.js
CHANGED
|
@@ -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);
|
package/src/context/budget.js
CHANGED
|
@@ -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,
|
|
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
|
|
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+[
|
|
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 (/^[
|
|
961
|
+
if (/^[@#[]/.test(trimmed)) {
|
|
964
962
|
skeleton.push(line);
|
|
965
963
|
continue;
|
|
966
964
|
}
|
package/src/dashboard/api.js
CHANGED
|
@@ -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.
|
package/src/headroom/client.js
CHANGED
|
@@ -115,27 +115,48 @@ async function compressMessages(messages, tools = [], options = {}) {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
try {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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(
|
|
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 [
|
|
69
|
+
for (const [_sessionId, { writeStream }] of sessionFiles.entries()) {
|
|
70
70
|
writeStream.end();
|
|
71
71
|
}
|
|
72
72
|
sessionFiles.clear();
|
package/src/memory/extractor.js
CHANGED
|
@@ -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_
|
|
30
|
-
/in\s+['"`]([A-Za-z0-9_
|
|
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_
|
|
36
|
+
/([A-Za-z0-9_./-]+)\s+(?:calls|invokes|references)\s+([A-Za-z0-9_./-]+)/gi,
|
|
37
37
|
],
|
|
38
38
|
};
|
|
39
39
|
|
package/src/memory/search.js
CHANGED
|
@@ -30,7 +30,7 @@ function sanitizeKeyword(keyword) {
|
|
|
30
30
|
|
|
31
31
|
// Remove ALL FTS5 special characters
|
|
32
32
|
const sanitized = keyword
|
|
33
|
-
.replace(/[*()
|
|
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(/[*()
|
|
100
|
+
cleaned = cleaned.replace(/[*()<>\-:[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ');
|
|
101
101
|
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
102
102
|
|
|
103
103
|
if (!cleaned) {
|
package/src/memory/store.js
CHANGED
|
@@ -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 {
|
package/src/memory/tools.js
CHANGED
|
@@ -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) {
|
|
@@ -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
|
|
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,
|