lynkr 3.0.0 → 3.2.0
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/LICENSE +201 -21
- package/README.md +626 -145
- package/docs/index.md +150 -18
- package/install.sh +63 -16
- package/package.json +2 -2
- package/scripts/setup.js +117 -43
- package/src/api/router.js +78 -0
- package/src/clients/openrouter-utils.js +51 -7
- package/src/config/index.js +51 -0
- package/src/context/budget.js +326 -0
- package/src/context/compression.js +397 -0
- package/src/memory/format.js +156 -0
- package/src/memory/retriever.js +55 -14
- package/src/memory/search.js +36 -12
- package/src/memory/store.js +61 -13
- package/src/memory/surprise.js +56 -15
- package/src/orchestrator/index.js +189 -2
- package/src/prompts/system.js +320 -0
- package/src/tools/index.js +9 -0
- package/src/tools/smart-selection.js +356 -0
- package/src/tools/truncate.js +105 -0
- package/src/utils/tokens.js +217 -0
- package/test/llamacpp-integration.test.js +198 -0
- package/test/memory/extractor.test.js +34 -6
- package/test/memory/retriever.test.js +45 -15
- package/test/memory/retriever.test.js.bak +585 -0
- package/test/memory/search.test.js +160 -12
- package/test/memory/search.test.js.bak +389 -0
- package/test/memory/store.test.js +57 -25
- package/test/memory/store.test.js.bak +312 -0
- package/test/memory/surprise.test.js +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const logger = require("../logger");
|
|
2
|
+
const config = require("../config");
|
|
3
|
+
|
|
4
|
+
const TRUNCATION_LIMITS = {
|
|
5
|
+
Read: { maxChars: 8000, strategy: 'middle' },
|
|
6
|
+
Bash: { maxChars: 30000, strategy: 'tail' },
|
|
7
|
+
Grep: { maxChars: 12000, strategy: 'head' },
|
|
8
|
+
Glob: { maxChars: 8000, strategy: 'head' },
|
|
9
|
+
WebFetch: { maxChars: 16000, strategy: 'head' },
|
|
10
|
+
WebSearch: { maxChars: 12000, strategy: 'head' },
|
|
11
|
+
LSP: { maxChars: 8000, strategy: 'head' },
|
|
12
|
+
Edit: { maxChars: 8000, strategy: 'middle' },
|
|
13
|
+
Write: { maxChars: 8000, strategy: 'middle' },
|
|
14
|
+
Task: { maxChars: 20000, strategy: 'tail' },
|
|
15
|
+
AgentTask: { maxChars: 20000, strategy: 'tail' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Apply truncation strategy to text
|
|
20
|
+
*/
|
|
21
|
+
function applyTruncationStrategy(text, maxChars, strategy) {
|
|
22
|
+
if (text.length <= maxChars) {
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
switch (strategy) {
|
|
27
|
+
case 'head':
|
|
28
|
+
// Keep beginning
|
|
29
|
+
return text.slice(0, maxChars);
|
|
30
|
+
|
|
31
|
+
case 'tail':
|
|
32
|
+
// Keep end
|
|
33
|
+
return text.slice(-maxChars);
|
|
34
|
+
|
|
35
|
+
case 'middle': {
|
|
36
|
+
// Keep start and end, remove middle
|
|
37
|
+
const keepSize = Math.floor(maxChars / 2);
|
|
38
|
+
const start = text.slice(0, keepSize);
|
|
39
|
+
const end = text.slice(-keepSize);
|
|
40
|
+
const removed = text.length - (keepSize * 2);
|
|
41
|
+
return `${start}\n\n... [${removed} characters truncated for token efficiency] ...\n\n${end}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
default:
|
|
45
|
+
return text.slice(0, maxChars);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Truncate tool output based on tool type
|
|
51
|
+
*/
|
|
52
|
+
function truncateToolOutput(toolName, output) {
|
|
53
|
+
// Skip if truncation disabled
|
|
54
|
+
if (config.toolTruncation?.enabled === false) {
|
|
55
|
+
return output;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!output || typeof output !== 'string') {
|
|
59
|
+
return output;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const limit = TRUNCATION_LIMITS[toolName];
|
|
63
|
+
if (!limit) {
|
|
64
|
+
// No truncation for unknown tools
|
|
65
|
+
return output;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (output.length <= limit.maxChars) {
|
|
69
|
+
return output;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const truncated = applyTruncationStrategy(output, limit.maxChars, limit.strategy);
|
|
73
|
+
const removed = output.length - truncated.length;
|
|
74
|
+
|
|
75
|
+
logger.debug({
|
|
76
|
+
tool: toolName,
|
|
77
|
+
originalLength: output.length,
|
|
78
|
+
truncatedLength: truncated.length,
|
|
79
|
+
removed,
|
|
80
|
+
strategy: limit.strategy
|
|
81
|
+
}, 'Truncated tool output for token efficiency');
|
|
82
|
+
|
|
83
|
+
return truncated;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get truncation limit for a specific tool
|
|
88
|
+
*/
|
|
89
|
+
function getTruncationLimit(toolName) {
|
|
90
|
+
return TRUNCATION_LIMITS[toolName] || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Update truncation limit for a tool (useful for testing)
|
|
95
|
+
*/
|
|
96
|
+
function setTruncationLimit(toolName, maxChars, strategy = 'head') {
|
|
97
|
+
TRUNCATION_LIMITS[toolName] = { maxChars, strategy };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
truncateToolOutput,
|
|
102
|
+
getTruncationLimit,
|
|
103
|
+
setTruncationLimit,
|
|
104
|
+
TRUNCATION_LIMITS
|
|
105
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
const logger = require("../logger");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Estimate token count (rough approximation: 4 chars ≈ 1 token)
|
|
5
|
+
* For production, consider using @anthropic-ai/tokenizer for exact counts
|
|
6
|
+
*/
|
|
7
|
+
function estimateTokens(text) {
|
|
8
|
+
if (!text) return 0;
|
|
9
|
+
if (typeof text !== 'string') {
|
|
10
|
+
text = JSON.stringify(text);
|
|
11
|
+
}
|
|
12
|
+
return Math.ceil(text.length / 4);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Count tokens in a full API payload
|
|
17
|
+
*/
|
|
18
|
+
function countPayloadTokens(payload) {
|
|
19
|
+
const breakdown = {
|
|
20
|
+
system: 0,
|
|
21
|
+
tools: 0,
|
|
22
|
+
messages: 0,
|
|
23
|
+
total: 0
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// System prompt
|
|
27
|
+
if (payload.system) {
|
|
28
|
+
if (Array.isArray(payload.system)) {
|
|
29
|
+
breakdown.system = payload.system.reduce((sum, block) =>
|
|
30
|
+
sum + estimateTokens(block.text || block), 0);
|
|
31
|
+
} else {
|
|
32
|
+
breakdown.system = estimateTokens(payload.system);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Tools
|
|
37
|
+
if (payload.tools && Array.isArray(payload.tools)) {
|
|
38
|
+
breakdown.tools = estimateTokens(JSON.stringify(payload.tools));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Messages
|
|
42
|
+
if (payload.messages && Array.isArray(payload.messages)) {
|
|
43
|
+
for (const msg of payload.messages) {
|
|
44
|
+
// Message content
|
|
45
|
+
if (typeof msg.content === 'string') {
|
|
46
|
+
breakdown.messages += estimateTokens(msg.content);
|
|
47
|
+
} else if (Array.isArray(msg.content)) {
|
|
48
|
+
breakdown.messages += msg.content.reduce((sum, block) => {
|
|
49
|
+
if (block.type === 'text') {
|
|
50
|
+
return sum + estimateTokens(block.text || '');
|
|
51
|
+
} else if (block.type === 'tool_result') {
|
|
52
|
+
return sum + estimateTokens(block.content || '');
|
|
53
|
+
} else if (block.type === 'image') {
|
|
54
|
+
// Images: rough estimate based on source length
|
|
55
|
+
return sum + estimateTokens(JSON.stringify(block.source || {}));
|
|
56
|
+
}
|
|
57
|
+
return sum + estimateTokens(JSON.stringify(block));
|
|
58
|
+
}, 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Tool calls
|
|
62
|
+
if (msg.tool_calls) {
|
|
63
|
+
breakdown.messages += estimateTokens(JSON.stringify(msg.tool_calls));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
breakdown.total = breakdown.system + breakdown.tools + breakdown.messages;
|
|
69
|
+
return breakdown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract token usage from API response
|
|
74
|
+
*/
|
|
75
|
+
function extractUsageFromResponse(response) {
|
|
76
|
+
if (!response || !response.usage) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
inputTokens: response.usage.input_tokens || 0,
|
|
82
|
+
outputTokens: response.usage.output_tokens || 0,
|
|
83
|
+
cacheCreationTokens: response.usage.cache_creation_input_tokens || 0,
|
|
84
|
+
cacheReadTokens: response.usage.cache_read_input_tokens || 0,
|
|
85
|
+
totalTokens: (response.usage.input_tokens || 0) + (response.usage.output_tokens || 0)
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Calculate cost based on token usage
|
|
91
|
+
* Prices as of 2025 (update as needed)
|
|
92
|
+
*/
|
|
93
|
+
function calculateCost(usage, model = 'claude-sonnet-4-5') {
|
|
94
|
+
const PRICES = {
|
|
95
|
+
'claude-opus-4-5': { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
|
|
96
|
+
'claude-sonnet-4-5': { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
97
|
+
'claude-haiku-4': { input: 0.8, output: 4, cache_write: 1, cache_read: 0.08 },
|
|
98
|
+
'databricks-claude-sonnet-4-5': { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
99
|
+
'databricks-claude-haiku-4': { input: 0.8, output: 4, cache_write: 1, cache_read: 0.08 },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const price = PRICES[model] || PRICES['claude-sonnet-4-5'];
|
|
103
|
+
|
|
104
|
+
const inputCost = (usage.inputTokens / 1_000_000) * price.input;
|
|
105
|
+
const outputCost = (usage.outputTokens / 1_000_000) * price.output;
|
|
106
|
+
const cacheWriteCost = ((usage.cacheCreationTokens || 0) / 1_000_000) * price.cache_write;
|
|
107
|
+
const cacheReadCost = ((usage.cacheReadTokens || 0) / 1_000_000) * price.cache_read;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
input: inputCost,
|
|
111
|
+
output: outputCost,
|
|
112
|
+
cacheWrite: cacheWriteCost,
|
|
113
|
+
cacheRead: cacheReadCost,
|
|
114
|
+
total: inputCost + outputCost + cacheWriteCost + cacheReadCost
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Log token usage with breakdown
|
|
120
|
+
*/
|
|
121
|
+
function logTokenUsage(context, estimated, actual) {
|
|
122
|
+
const efficiency = actual ? ((actual.totalTokens / estimated.total) * 100).toFixed(1) : 'N/A';
|
|
123
|
+
|
|
124
|
+
logger.info({
|
|
125
|
+
context,
|
|
126
|
+
estimated: {
|
|
127
|
+
system: estimated.system,
|
|
128
|
+
tools: estimated.tools,
|
|
129
|
+
messages: estimated.messages,
|
|
130
|
+
total: estimated.total
|
|
131
|
+
},
|
|
132
|
+
actual: actual || 'not available',
|
|
133
|
+
estimateAccuracy: efficiency + '%'
|
|
134
|
+
}, 'Token usage tracked');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Store token usage in session metadata
|
|
139
|
+
*/
|
|
140
|
+
function recordTokenUsage(session, turnId, estimated, actual, model) {
|
|
141
|
+
if (!session || !actual) return;
|
|
142
|
+
|
|
143
|
+
session.metadata = session.metadata || {};
|
|
144
|
+
session.metadata.tokenUsage = session.metadata.tokenUsage || [];
|
|
145
|
+
|
|
146
|
+
const cost = calculateCost(actual, model);
|
|
147
|
+
|
|
148
|
+
session.metadata.tokenUsage.push({
|
|
149
|
+
turn: turnId,
|
|
150
|
+
timestamp: Date.now(),
|
|
151
|
+
estimated,
|
|
152
|
+
actual,
|
|
153
|
+
cost,
|
|
154
|
+
model
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Track cumulative totals
|
|
158
|
+
session.metadata.totalTokens = (session.metadata.totalTokens || 0) + actual.totalTokens;
|
|
159
|
+
session.metadata.totalCost = (session.metadata.totalCost || 0) + cost.total;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get token statistics for a session
|
|
164
|
+
*/
|
|
165
|
+
function getSessionTokenStats(session) {
|
|
166
|
+
if (!session || !session.metadata || !session.metadata.tokenUsage) {
|
|
167
|
+
return {
|
|
168
|
+
turns: 0,
|
|
169
|
+
totalTokens: 0,
|
|
170
|
+
totalCost: 0,
|
|
171
|
+
averageTokensPerTurn: 0,
|
|
172
|
+
breakdown: []
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const usage = session.metadata.tokenUsage;
|
|
177
|
+
const totalTokens = session.metadata.totalTokens || 0;
|
|
178
|
+
const totalCost = session.metadata.totalCost || 0;
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
turns: usage.length,
|
|
182
|
+
totalTokens,
|
|
183
|
+
totalCost,
|
|
184
|
+
averageTokensPerTurn: usage.length > 0 ? Math.round(totalTokens / usage.length) : 0,
|
|
185
|
+
cacheHitRate: calculateCacheHitRate(usage),
|
|
186
|
+
breakdown: usage
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Calculate cache hit rate from usage history
|
|
192
|
+
*/
|
|
193
|
+
function calculateCacheHitRate(usageHistory) {
|
|
194
|
+
if (!usageHistory || usageHistory.length === 0) return 0;
|
|
195
|
+
|
|
196
|
+
const totalCacheableTokens = usageHistory.reduce((sum, turn) => {
|
|
197
|
+
return sum + (turn.actual.inputTokens || 0);
|
|
198
|
+
}, 0);
|
|
199
|
+
|
|
200
|
+
const cachedTokens = usageHistory.reduce((sum, turn) => {
|
|
201
|
+
return sum + (turn.actual.cacheReadTokens || 0);
|
|
202
|
+
}, 0);
|
|
203
|
+
|
|
204
|
+
return totalCacheableTokens > 0
|
|
205
|
+
? ((cachedTokens / totalCacheableTokens) * 100).toFixed(1)
|
|
206
|
+
: 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
estimateTokens,
|
|
211
|
+
countPayloadTokens,
|
|
212
|
+
extractUsageFromResponse,
|
|
213
|
+
calculateCost,
|
|
214
|
+
logTokenUsage,
|
|
215
|
+
recordTokenUsage,
|
|
216
|
+
getSessionTokenStats
|
|
217
|
+
};
|
|
@@ -682,5 +682,203 @@ describe("llama.cpp Integration", () => {
|
|
|
682
682
|
assert.strictEqual(result.usage.input_tokens, 0);
|
|
683
683
|
assert.strictEqual(result.usage.output_tokens, 0);
|
|
684
684
|
});
|
|
685
|
+
|
|
686
|
+
it("should filter duplicate tool call JSON from content when tool_calls are present", () => {
|
|
687
|
+
process.env.MODEL_PROVIDER = "databricks";
|
|
688
|
+
process.env.DATABRICKS_API_KEY = "test-key";
|
|
689
|
+
process.env.DATABRICKS_API_BASE = "http://test.com";
|
|
690
|
+
|
|
691
|
+
const { convertOpenRouterResponseToAnthropic } = require("../src/clients/openrouter-utils");
|
|
692
|
+
|
|
693
|
+
// Simulate llama.cpp response with BOTH content (as JSON) and tool_calls
|
|
694
|
+
const response = {
|
|
695
|
+
id: "chatcmpl-123",
|
|
696
|
+
choices: [
|
|
697
|
+
{
|
|
698
|
+
message: {
|
|
699
|
+
role: "assistant",
|
|
700
|
+
content: '{"type": "function", "function": {"name": "Write", "parameters": {"file_path": "test.cpp", "content": "int main() {}"}}}',
|
|
701
|
+
tool_calls: [
|
|
702
|
+
{
|
|
703
|
+
id: "call_abc123",
|
|
704
|
+
type: "function",
|
|
705
|
+
function: {
|
|
706
|
+
name: "Write",
|
|
707
|
+
arguments: '{"file_path": "test.cpp", "content": "int main() {}"}'
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
]
|
|
711
|
+
},
|
|
712
|
+
finish_reason: "tool_calls"
|
|
713
|
+
}
|
|
714
|
+
],
|
|
715
|
+
usage: {
|
|
716
|
+
prompt_tokens: 100,
|
|
717
|
+
completion_tokens: 50
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const result = convertOpenRouterResponseToAnthropic(response, "test-model");
|
|
722
|
+
|
|
723
|
+
// Should have only 1 content block (tool_use), not 2 (text + tool_use)
|
|
724
|
+
assert.strictEqual(result.content.length, 1);
|
|
725
|
+
assert.strictEqual(result.content[0].type, "tool_use");
|
|
726
|
+
assert.strictEqual(result.content[0].name, "Write");
|
|
727
|
+
assert.strictEqual(result.stop_reason, "tool_use");
|
|
728
|
+
|
|
729
|
+
// Verify the JSON text was NOT included as a text block
|
|
730
|
+
const textBlocks = result.content.filter(block => block.type === "text");
|
|
731
|
+
assert.strictEqual(textBlocks.length, 0, "Should not include text block with duplicate JSON");
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("should preserve normal text content when tool_calls are NOT present", () => {
|
|
735
|
+
process.env.MODEL_PROVIDER = "databricks";
|
|
736
|
+
process.env.DATABRICKS_API_KEY = "test-key";
|
|
737
|
+
process.env.DATABRICKS_API_BASE = "http://test.com";
|
|
738
|
+
|
|
739
|
+
const { convertOpenRouterResponseToAnthropic } = require("../src/clients/openrouter-utils");
|
|
740
|
+
|
|
741
|
+
const response = {
|
|
742
|
+
id: "chatcmpl-456",
|
|
743
|
+
choices: [
|
|
744
|
+
{
|
|
745
|
+
message: {
|
|
746
|
+
role: "assistant",
|
|
747
|
+
content: "Here is the code you requested.",
|
|
748
|
+
// No tool_calls
|
|
749
|
+
},
|
|
750
|
+
finish_reason: "stop"
|
|
751
|
+
}
|
|
752
|
+
],
|
|
753
|
+
usage: {
|
|
754
|
+
prompt_tokens: 50,
|
|
755
|
+
completion_tokens: 25
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const result = convertOpenRouterResponseToAnthropic(response, "test-model");
|
|
760
|
+
|
|
761
|
+
// Should have 1 text block
|
|
762
|
+
assert.strictEqual(result.content.length, 1);
|
|
763
|
+
assert.strictEqual(result.content[0].type, "text");
|
|
764
|
+
assert.strictEqual(result.content[0].text, "Here is the code you requested.");
|
|
765
|
+
assert.strictEqual(result.stop_reason, "end_turn");
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("should preserve text content with tool_calls when text is NOT JSON", () => {
|
|
769
|
+
process.env.MODEL_PROVIDER = "databricks";
|
|
770
|
+
process.env.DATABRICKS_API_KEY = "test-key";
|
|
771
|
+
process.env.DATABRICKS_API_BASE = "http://test.com";
|
|
772
|
+
|
|
773
|
+
const { convertOpenRouterResponseToAnthropic } = require("../src/clients/openrouter-utils");
|
|
774
|
+
|
|
775
|
+
// Some models include explanatory text before/with tool calls
|
|
776
|
+
const response = {
|
|
777
|
+
id: "chatcmpl-789",
|
|
778
|
+
choices: [
|
|
779
|
+
{
|
|
780
|
+
message: {
|
|
781
|
+
role: "assistant",
|
|
782
|
+
content: "I'll write the file for you now.",
|
|
783
|
+
tool_calls: [
|
|
784
|
+
{
|
|
785
|
+
id: "call_xyz789",
|
|
786
|
+
type: "function",
|
|
787
|
+
function: {
|
|
788
|
+
name: "Write",
|
|
789
|
+
arguments: '{"file_path": "test.cpp", "content": "int main() {}"}'
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
]
|
|
793
|
+
},
|
|
794
|
+
finish_reason: "tool_calls"
|
|
795
|
+
}
|
|
796
|
+
],
|
|
797
|
+
usage: {
|
|
798
|
+
prompt_tokens: 100,
|
|
799
|
+
completion_tokens: 60
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
const result = convertOpenRouterResponseToAnthropic(response, "test-model");
|
|
804
|
+
|
|
805
|
+
// Should have 2 content blocks (text + tool_use)
|
|
806
|
+
assert.strictEqual(result.content.length, 2);
|
|
807
|
+
assert.strictEqual(result.content[0].type, "text");
|
|
808
|
+
assert.strictEqual(result.content[0].text, "I'll write the file for you now.");
|
|
809
|
+
assert.strictEqual(result.content[1].type, "tool_use");
|
|
810
|
+
assert.strictEqual(result.content[1].name, "Write");
|
|
811
|
+
assert.strictEqual(result.stop_reason, "tool_use");
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("should filter malformed JSON when model outputs ONLY JSON without tool_calls", () => {
|
|
815
|
+
process.env.MODEL_PROVIDER = "databricks";
|
|
816
|
+
process.env.DATABRICKS_API_KEY = "test-key";
|
|
817
|
+
process.env.DATABRICKS_API_BASE = "http://test.com";
|
|
818
|
+
|
|
819
|
+
const { convertOpenRouterResponseToAnthropic } = require("../src/clients/openrouter-utils");
|
|
820
|
+
|
|
821
|
+
// Simulate llama.cpp model that outputs JSON in content but doesn't provide tool_calls
|
|
822
|
+
// This is a model training/configuration issue - model learned to output JSON
|
|
823
|
+
// but llama.cpp server isn't converting it to structured tool_calls
|
|
824
|
+
const response = {
|
|
825
|
+
id: "chatcmpl-malformed",
|
|
826
|
+
choices: [
|
|
827
|
+
{
|
|
828
|
+
message: {
|
|
829
|
+
role: "assistant",
|
|
830
|
+
content: '{"function": "Write", "parameters": {"file_path": "test.go", "content": "package main"}}',
|
|
831
|
+
// No tool_calls array - model error!
|
|
832
|
+
},
|
|
833
|
+
finish_reason: "stop"
|
|
834
|
+
}
|
|
835
|
+
],
|
|
836
|
+
usage: {
|
|
837
|
+
prompt_tokens: 50,
|
|
838
|
+
completion_tokens: 30
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const result = convertOpenRouterResponseToAnthropic(response, "test-model");
|
|
843
|
+
|
|
844
|
+
// Should have 1 empty text block (JSON was filtered out)
|
|
845
|
+
assert.strictEqual(result.content.length, 1);
|
|
846
|
+
assert.strictEqual(result.content[0].type, "text");
|
|
847
|
+
assert.strictEqual(result.content[0].text, "");
|
|
848
|
+
assert.strictEqual(result.stop_reason, "end_turn");
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("should filter alternative JSON formats without tool_calls", () => {
|
|
852
|
+
process.env.MODEL_PROVIDER = "databricks";
|
|
853
|
+
process.env.DATABRICKS_API_KEY = "test-key";
|
|
854
|
+
process.env.DATABRICKS_API_BASE = "http://test.com";
|
|
855
|
+
|
|
856
|
+
const { convertOpenRouterResponseToAnthropic } = require("../src/clients/openrouter-utils");
|
|
857
|
+
|
|
858
|
+
// Test the other JSON format seen in the wild
|
|
859
|
+
const response = {
|
|
860
|
+
id: "chatcmpl-alt-format",
|
|
861
|
+
choices: [
|
|
862
|
+
{
|
|
863
|
+
message: {
|
|
864
|
+
role: "assistant",
|
|
865
|
+
content: '{"type": "function", "function": {"name": "Read", "arguments": {"file_path": "config.json"}}}',
|
|
866
|
+
},
|
|
867
|
+
finish_reason: "stop"
|
|
868
|
+
}
|
|
869
|
+
],
|
|
870
|
+
usage: {
|
|
871
|
+
prompt_tokens: 40,
|
|
872
|
+
completion_tokens: 25
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const result = convertOpenRouterResponseToAnthropic(response, "test-model");
|
|
877
|
+
|
|
878
|
+
// Should filter out the JSON
|
|
879
|
+
assert.strictEqual(result.content.length, 1);
|
|
880
|
+
assert.strictEqual(result.content[0].type, "text");
|
|
881
|
+
assert.strictEqual(result.content[0].text, "");
|
|
882
|
+
});
|
|
685
883
|
});
|
|
686
884
|
});
|
|
@@ -12,11 +12,13 @@ describe("Memory Extractor", () => {
|
|
|
12
12
|
// Save original environment
|
|
13
13
|
originalEnv = { ...process.env };
|
|
14
14
|
|
|
15
|
-
// Create a temporary test database
|
|
16
|
-
|
|
15
|
+
// Create a unique temporary test database
|
|
16
|
+
const timestamp = Date.now();
|
|
17
|
+
const random = Math.floor(Math.random() * 1000000);
|
|
18
|
+
testDbPath = path.join(__dirname, `../../data/test-extractor-${timestamp}-${random}.db`);
|
|
17
19
|
|
|
18
20
|
// Set test environment BEFORE loading any modules
|
|
19
|
-
process.env.
|
|
21
|
+
process.env.SESSION_DB_PATH = testDbPath;
|
|
20
22
|
process.env.MEMORY_SURPRISE_THRESHOLD = "0.1"; // Very low threshold for tests
|
|
21
23
|
process.env.MEMORY_ENABLED = "true";
|
|
22
24
|
process.env.MEMORY_EXTRACTION_ENABLED = "true";
|
|
@@ -36,13 +38,39 @@ describe("Memory Extractor", () => {
|
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
afterEach(() => {
|
|
41
|
+
// Close database connection first
|
|
42
|
+
try {
|
|
43
|
+
const db = require("../../src/db");
|
|
44
|
+
if (db && typeof db.close === 'function') {
|
|
45
|
+
db.close();
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// Ignore if already closed
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Clear module cache to release all references
|
|
52
|
+
Object.keys(require.cache).forEach(key => {
|
|
53
|
+
if (key.includes('/src/')) {
|
|
54
|
+
delete require.cache[key];
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
39
58
|
// Restore environment
|
|
40
59
|
process.env = originalEnv;
|
|
41
60
|
|
|
42
|
-
// Clean up
|
|
61
|
+
// Clean up all SQLite files (db, wal, shm)
|
|
43
62
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
const files = [
|
|
64
|
+
testDbPath,
|
|
65
|
+
`${testDbPath}-wal`,
|
|
66
|
+
`${testDbPath}-shm`,
|
|
67
|
+
`${testDbPath}-journal`
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
if (fs.existsSync(file)) {
|
|
72
|
+
fs.unlinkSync(file);
|
|
73
|
+
}
|
|
46
74
|
}
|
|
47
75
|
} catch (err) {
|
|
48
76
|
// Ignore cleanup errors
|
|
@@ -9,19 +9,22 @@ describe("Memory Retriever", () => {
|
|
|
9
9
|
let testDbPath;
|
|
10
10
|
|
|
11
11
|
beforeEach(() => {
|
|
12
|
-
// Create a temporary test database
|
|
13
|
-
|
|
12
|
+
// Create a unique temporary test database
|
|
13
|
+
const timestamp = Date.now();
|
|
14
|
+
const random = Math.floor(Math.random() * 1000000);
|
|
15
|
+
testDbPath = path.join(__dirname, `../../data/test-retriever-${timestamp}-${random}.db`);
|
|
14
16
|
|
|
15
|
-
//
|
|
17
|
+
// Set test environment to new database (correct env var is SESSION_DB_PATH)
|
|
18
|
+
process.env.SESSION_DB_PATH = testDbPath;
|
|
19
|
+
|
|
20
|
+
// Clear ALL module cache to ensure fresh config is loaded
|
|
21
|
+
delete require.cache[require.resolve("../../src/config")];
|
|
16
22
|
delete require.cache[require.resolve("../../src/db")];
|
|
17
23
|
delete require.cache[require.resolve("../../src/memory/store")];
|
|
18
24
|
delete require.cache[require.resolve("../../src/memory/search")];
|
|
19
25
|
delete require.cache[require.resolve("../../src/memory/retriever")];
|
|
20
26
|
|
|
21
|
-
//
|
|
22
|
-
process.env.DB_PATH = testDbPath;
|
|
23
|
-
|
|
24
|
-
// Initialize database with schema
|
|
27
|
+
// Initialize database with schema (this creates a fresh database)
|
|
25
28
|
require("../../src/db");
|
|
26
29
|
|
|
27
30
|
// Load modules
|
|
@@ -84,10 +87,35 @@ describe("Memory Retriever", () => {
|
|
|
84
87
|
});
|
|
85
88
|
|
|
86
89
|
afterEach(() => {
|
|
87
|
-
//
|
|
90
|
+
// Close database connection first
|
|
88
91
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
const db = require("../../src/db");
|
|
93
|
+
if (db && typeof db.close === 'function') {
|
|
94
|
+
db.close();
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// Ignore if already closed
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Clear module cache to release all references
|
|
101
|
+
delete require.cache[require.resolve("../../src/db")];
|
|
102
|
+
delete require.cache[require.resolve("../../src/memory/store")];
|
|
103
|
+
delete require.cache[require.resolve("../../src/memory/search")];
|
|
104
|
+
delete require.cache[require.resolve("../../src/memory/retriever")];
|
|
105
|
+
|
|
106
|
+
// Clean up all SQLite files (db, wal, shm)
|
|
107
|
+
try {
|
|
108
|
+
const files = [
|
|
109
|
+
testDbPath,
|
|
110
|
+
`${testDbPath}-wal`,
|
|
111
|
+
`${testDbPath}-shm`,
|
|
112
|
+
`${testDbPath}-journal`
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
if (fs.existsSync(file)) {
|
|
117
|
+
fs.unlinkSync(file);
|
|
118
|
+
}
|
|
91
119
|
}
|
|
92
120
|
} catch (err) {
|
|
93
121
|
// Ignore cleanup errors
|
|
@@ -412,9 +440,11 @@ describe("Memory Retriever", () => {
|
|
|
412
440
|
);
|
|
413
441
|
|
|
414
442
|
assert.ok(typeof systemFormat === "string");
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
assert.ok(
|
|
443
|
+
// assistant_preamble format returns an object
|
|
444
|
+
assert.ok(typeof preambleFormat === "object");
|
|
445
|
+
assert.ok(preambleFormat.system === "You are helpful.");
|
|
446
|
+
assert.ok(typeof preambleFormat.memoryPreamble === "string");
|
|
447
|
+
assert.ok(preambleFormat.memoryPreamble.length > 0);
|
|
418
448
|
});
|
|
419
449
|
});
|
|
420
450
|
|
|
@@ -455,11 +485,11 @@ describe("Memory Retriever", () => {
|
|
|
455
485
|
store.createMemory({
|
|
456
486
|
content: "Session memory",
|
|
457
487
|
type: "fact",
|
|
458
|
-
sessionId: "test-session"
|
|
488
|
+
sessionId: null // was: "test-session"
|
|
459
489
|
});
|
|
460
490
|
|
|
461
491
|
const globalStats = retriever.getMemoryStats();
|
|
462
|
-
const sessionStats = retriever.getMemoryStats({ sessionId: "test-session"
|
|
492
|
+
const sessionStats = retriever.getMemoryStats({ sessionId: null }); // was: "test-session"
|
|
463
493
|
|
|
464
494
|
assert.ok(sessionStats.total <= globalStats.total);
|
|
465
495
|
});
|