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,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History Compression for Token Optimization
|
|
3
|
+
*
|
|
4
|
+
* Compresses conversation history to reduce token usage while
|
|
5
|
+
* maintaining context quality. Uses sliding window approach:
|
|
6
|
+
* - Keep recent turns verbatim
|
|
7
|
+
* - Summarize older turns
|
|
8
|
+
* - Compress tool results
|
|
9
|
+
*
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const logger = require('../logger');
|
|
13
|
+
const config = require('../config');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compress conversation history to fit within token budget
|
|
17
|
+
*
|
|
18
|
+
* Strategy:
|
|
19
|
+
* 1. Keep last N turns verbatim (fresh context)
|
|
20
|
+
* 2. Summarize older turns (compressed history)
|
|
21
|
+
* 3. Compress tool results to key information only
|
|
22
|
+
* 4. Remove redundant exchanges
|
|
23
|
+
*
|
|
24
|
+
* @param {Array} messages - Conversation history
|
|
25
|
+
* @param {Object} options - Compression options
|
|
26
|
+
* @returns {Array} Compressed messages
|
|
27
|
+
*/
|
|
28
|
+
function compressHistory(messages, options = {}) {
|
|
29
|
+
if (!messages || messages.length === 0) return messages;
|
|
30
|
+
|
|
31
|
+
const opts = {
|
|
32
|
+
keepRecentTurns: options.keepRecentTurns ?? config.historyCompression?.keepRecentTurns ?? 10,
|
|
33
|
+
summarizeOlder: options.summarizeOlder ?? config.historyCompression?.summarizeOlder ?? true,
|
|
34
|
+
enabled: options.enabled ?? config.historyCompression?.enabled ?? true,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (!opts.enabled) {
|
|
38
|
+
return messages; // Return uncompressed if disabled
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Calculate split point
|
|
42
|
+
const splitIndex = Math.max(0, messages.length - opts.keepRecentTurns);
|
|
43
|
+
|
|
44
|
+
if (splitIndex === 0) {
|
|
45
|
+
// All messages are recent, no compression needed
|
|
46
|
+
return messages;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const recentMessages = messages.slice(splitIndex);
|
|
50
|
+
const oldMessages = messages.slice(0, splitIndex);
|
|
51
|
+
|
|
52
|
+
let compressed = [];
|
|
53
|
+
|
|
54
|
+
// Summarize old messages if configured
|
|
55
|
+
if (opts.summarizeOlder && oldMessages.length > 0) {
|
|
56
|
+
const summary = summarizeOldHistory(oldMessages);
|
|
57
|
+
if (summary) {
|
|
58
|
+
compressed.push(summary);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
// Just compress tool results in old messages
|
|
62
|
+
compressed = oldMessages.map(msg => compressMessage(msg));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Add recent messages (may compress tool results but keep content)
|
|
66
|
+
const recentCompressed = recentMessages.map(msg => compressToolResults(msg));
|
|
67
|
+
|
|
68
|
+
const finalMessages = [...compressed, ...recentCompressed];
|
|
69
|
+
|
|
70
|
+
// Log compression stats
|
|
71
|
+
const originalLength = JSON.stringify(messages).length;
|
|
72
|
+
const compressedLength = JSON.stringify(finalMessages).length;
|
|
73
|
+
const saved = originalLength - compressedLength;
|
|
74
|
+
|
|
75
|
+
if (saved > 1000) {
|
|
76
|
+
logger.debug({
|
|
77
|
+
originalMessages: messages.length,
|
|
78
|
+
compressedMessages: finalMessages.length,
|
|
79
|
+
originalChars: originalLength,
|
|
80
|
+
compressedChars: compressedLength,
|
|
81
|
+
saved,
|
|
82
|
+
percentage: ((saved / originalLength) * 100).toFixed(1),
|
|
83
|
+
splitIndex,
|
|
84
|
+
oldMessages: oldMessages.length,
|
|
85
|
+
recentMessages: recentMessages.length
|
|
86
|
+
}, 'History compression applied');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return finalMessages;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Summarize old conversation history into a single message
|
|
94
|
+
*
|
|
95
|
+
* Creates a compact summary of older exchanges to preserve
|
|
96
|
+
* context without consuming excessive tokens.
|
|
97
|
+
*
|
|
98
|
+
* @param {Array} messages - Old messages to summarize
|
|
99
|
+
* @returns {Object} Summary message
|
|
100
|
+
*/
|
|
101
|
+
function summarizeOldHistory(messages) {
|
|
102
|
+
if (!messages || messages.length === 0) return null;
|
|
103
|
+
|
|
104
|
+
// Extract key exchanges and decisions
|
|
105
|
+
const keyPoints = [];
|
|
106
|
+
let hasUserInput = false;
|
|
107
|
+
let hasAssistantOutput = false;
|
|
108
|
+
|
|
109
|
+
for (const msg of messages) {
|
|
110
|
+
if (msg.role === 'user') {
|
|
111
|
+
hasUserInput = true;
|
|
112
|
+
const content = extractTextContent(msg);
|
|
113
|
+
if (content.length < 200) {
|
|
114
|
+
keyPoints.push(`User: ${content}`);
|
|
115
|
+
} else {
|
|
116
|
+
// Compress long user messages
|
|
117
|
+
keyPoints.push(`User: ${content.substring(0, 150)}...`);
|
|
118
|
+
}
|
|
119
|
+
} else if (msg.role === 'assistant') {
|
|
120
|
+
hasAssistantOutput = true;
|
|
121
|
+
const content = extractTextContent(msg);
|
|
122
|
+
|
|
123
|
+
// Extract tool uses
|
|
124
|
+
const toolUses = extractToolUses(msg);
|
|
125
|
+
if (toolUses.length > 0) {
|
|
126
|
+
keyPoints.push(`Assistant used tools: ${toolUses.join(', ')}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Add assistant text if meaningful
|
|
130
|
+
if (content.length > 20 && content.length < 200) {
|
|
131
|
+
keyPoints.push(`Assistant: ${content}`);
|
|
132
|
+
} else if (content.length >= 200) {
|
|
133
|
+
keyPoints.push(`Assistant: ${content.substring(0, 150)}...`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!hasUserInput || !hasAssistantOutput) {
|
|
139
|
+
// Not enough content to summarize meaningfully
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const summaryText = `[Earlier conversation summary: ${keyPoints.join(' | ')}]`;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
role: 'user',
|
|
147
|
+
content: summaryText
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Compress a single message
|
|
153
|
+
*
|
|
154
|
+
* Reduces message size while preserving essential information.
|
|
155
|
+
*
|
|
156
|
+
* @param {Object} message - Message to compress
|
|
157
|
+
* @returns {Object} Compressed message
|
|
158
|
+
*/
|
|
159
|
+
function compressMessage(message) {
|
|
160
|
+
if (!message) return message;
|
|
161
|
+
|
|
162
|
+
const compressed = {
|
|
163
|
+
role: message.role
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Compress content based on type
|
|
167
|
+
if (typeof message.content === 'string') {
|
|
168
|
+
compressed.content = compressText(message.content, 300);
|
|
169
|
+
} else if (Array.isArray(message.content)) {
|
|
170
|
+
compressed.content = message.content
|
|
171
|
+
.map(block => compressContentBlock(block))
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
} else {
|
|
174
|
+
compressed.content = message.content;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return compressed;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Compress tool results in a message while keeping other content
|
|
182
|
+
*
|
|
183
|
+
* Tool results can be very large. This compresses them while
|
|
184
|
+
* keeping user and assistant text intact.
|
|
185
|
+
*
|
|
186
|
+
* @param {Object} message - Message to process
|
|
187
|
+
* @returns {Object} Message with compressed tool results
|
|
188
|
+
*/
|
|
189
|
+
function compressToolResults(message) {
|
|
190
|
+
if (!message) return message;
|
|
191
|
+
|
|
192
|
+
const compressed = {
|
|
193
|
+
role: message.role
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
if (typeof message.content === 'string') {
|
|
197
|
+
compressed.content = message.content;
|
|
198
|
+
} else if (Array.isArray(message.content)) {
|
|
199
|
+
compressed.content = message.content.map(block => {
|
|
200
|
+
// Compress tool_result blocks
|
|
201
|
+
if (block.type === 'tool_result') {
|
|
202
|
+
return compressToolResultBlock(block);
|
|
203
|
+
}
|
|
204
|
+
// Keep other blocks as-is
|
|
205
|
+
return block;
|
|
206
|
+
});
|
|
207
|
+
} else {
|
|
208
|
+
compressed.content = message.content;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return compressed;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Compress a content block
|
|
216
|
+
*
|
|
217
|
+
* @param {Object} block - Content block
|
|
218
|
+
* @returns {Object|null} Compressed block or null if removed
|
|
219
|
+
*/
|
|
220
|
+
function compressContentBlock(block) {
|
|
221
|
+
if (!block) return null;
|
|
222
|
+
|
|
223
|
+
switch (block.type) {
|
|
224
|
+
case 'text':
|
|
225
|
+
return {
|
|
226
|
+
type: 'text',
|
|
227
|
+
text: compressText(block.text, 300)
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
case 'tool_use':
|
|
231
|
+
// Keep tool_use but compress arguments
|
|
232
|
+
return {
|
|
233
|
+
type: 'tool_use',
|
|
234
|
+
id: block.id,
|
|
235
|
+
name: block.name,
|
|
236
|
+
input: block.input // Keep as-is, these are usually small
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
case 'tool_result':
|
|
240
|
+
return compressToolResultBlock(block);
|
|
241
|
+
|
|
242
|
+
default:
|
|
243
|
+
return block;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Compress tool result block
|
|
249
|
+
*
|
|
250
|
+
* Tool results can be very large (file contents, bash output).
|
|
251
|
+
* Compress while preserving essential information.
|
|
252
|
+
*
|
|
253
|
+
* @param {Object} block - tool_result block
|
|
254
|
+
* @returns {Object} Compressed tool_result
|
|
255
|
+
*/
|
|
256
|
+
function compressToolResultBlock(block) {
|
|
257
|
+
if (!block || block.type !== 'tool_result') return block;
|
|
258
|
+
|
|
259
|
+
const compressed = {
|
|
260
|
+
type: 'tool_result',
|
|
261
|
+
tool_use_id: block.tool_use_id,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Compress content
|
|
265
|
+
if (typeof block.content === 'string') {
|
|
266
|
+
compressed.content = compressText(block.content, 500);
|
|
267
|
+
} else if (Array.isArray(block.content)) {
|
|
268
|
+
compressed.content = block.content.map(item => {
|
|
269
|
+
if (typeof item === 'string') {
|
|
270
|
+
return compressText(item, 500);
|
|
271
|
+
} else if (item.type === 'text') {
|
|
272
|
+
return {
|
|
273
|
+
type: 'text',
|
|
274
|
+
text: compressText(item.text, 500)
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return item;
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
compressed.content = block.content;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Preserve error status
|
|
284
|
+
if (block.is_error !== undefined) {
|
|
285
|
+
compressed.is_error = block.is_error;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return compressed;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Compress text to maximum length
|
|
293
|
+
*
|
|
294
|
+
* Uses intelligent truncation to preserve meaning.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} text - Text to compress
|
|
297
|
+
* @param {number} maxLength - Maximum length
|
|
298
|
+
* @returns {string} Compressed text
|
|
299
|
+
*/
|
|
300
|
+
function compressText(text, maxLength) {
|
|
301
|
+
if (!text || text.length <= maxLength) return text;
|
|
302
|
+
|
|
303
|
+
// Try to preserve beginning and end
|
|
304
|
+
const keepStart = Math.floor(maxLength * 0.4);
|
|
305
|
+
const keepEnd = Math.floor(maxLength * 0.4);
|
|
306
|
+
|
|
307
|
+
const start = text.substring(0, keepStart);
|
|
308
|
+
const end = text.substring(text.length - keepEnd);
|
|
309
|
+
|
|
310
|
+
return `${start}...[${text.length - maxLength} chars omitted]...${end}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Extract text content from message
|
|
315
|
+
*
|
|
316
|
+
* @param {Object} message - Message object
|
|
317
|
+
* @returns {string} Extracted text
|
|
318
|
+
*/
|
|
319
|
+
function extractTextContent(message) {
|
|
320
|
+
if (typeof message.content === 'string') {
|
|
321
|
+
return message.content;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (Array.isArray(message.content)) {
|
|
325
|
+
return message.content
|
|
326
|
+
.filter(block => block.type === 'text')
|
|
327
|
+
.map(block => block.text)
|
|
328
|
+
.join(' ')
|
|
329
|
+
.trim();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return '';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Extract tool names used in message
|
|
337
|
+
*
|
|
338
|
+
* @param {Object} message - Message object
|
|
339
|
+
* @returns {Array} Tool names
|
|
340
|
+
*/
|
|
341
|
+
function extractToolUses(message) {
|
|
342
|
+
if (!Array.isArray(message.content)) return [];
|
|
343
|
+
|
|
344
|
+
return message.content
|
|
345
|
+
.filter(block => block.type === 'tool_use')
|
|
346
|
+
.map(block => block.name);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Calculate compression statistics
|
|
351
|
+
*
|
|
352
|
+
* @param {Array} original - Original messages
|
|
353
|
+
* @param {Array} compressed - Compressed messages
|
|
354
|
+
* @returns {Object} Statistics
|
|
355
|
+
*/
|
|
356
|
+
function calculateCompressionStats(original, compressed) {
|
|
357
|
+
const originalLength = JSON.stringify(original).length;
|
|
358
|
+
const compressedLength = JSON.stringify(compressed).length;
|
|
359
|
+
const saved = originalLength - compressedLength;
|
|
360
|
+
|
|
361
|
+
// Rough token estimate (4 chars ≈ 1 token)
|
|
362
|
+
const tokensOriginal = Math.ceil(originalLength / 4);
|
|
363
|
+
const tokensCompressed = Math.ceil(compressedLength / 4);
|
|
364
|
+
const tokensSaved = tokensOriginal - tokensCompressed;
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
originalMessages: original.length,
|
|
368
|
+
compressedMessages: compressed.length,
|
|
369
|
+
originalChars: originalLength,
|
|
370
|
+
compressedChars: compressedLength,
|
|
371
|
+
charsSaved: saved,
|
|
372
|
+
tokensOriginal,
|
|
373
|
+
tokensCompressed,
|
|
374
|
+
tokensSaved,
|
|
375
|
+
percentage: originalLength > 0 ? ((saved / originalLength) * 100).toFixed(1) : '0.0'
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Check if history needs compression
|
|
381
|
+
*
|
|
382
|
+
* @param {Array} messages - Messages to check
|
|
383
|
+
* @param {number} threshold - Minimum message count to trigger compression
|
|
384
|
+
* @returns {boolean} True if compression recommended
|
|
385
|
+
*/
|
|
386
|
+
function needsCompression(messages, threshold = 15) {
|
|
387
|
+
return messages && messages.length > threshold;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
module.exports = {
|
|
391
|
+
compressHistory,
|
|
392
|
+
compressMessage,
|
|
393
|
+
compressToolResults,
|
|
394
|
+
calculateCompressionStats,
|
|
395
|
+
needsCompression,
|
|
396
|
+
summarizeOldHistory,
|
|
397
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const logger = require("../logger");
|
|
2
|
+
const config = require("../config");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Format memories for injection into context
|
|
6
|
+
*/
|
|
7
|
+
function formatMemoriesForContext(memories, format = 'compact') {
|
|
8
|
+
if (!memories || memories.length === 0) {
|
|
9
|
+
return '';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Get format from config if not specified
|
|
13
|
+
format = format || config.memory?.format || 'compact';
|
|
14
|
+
|
|
15
|
+
if (format === 'verbose' || format === 'xml') {
|
|
16
|
+
return formatVerbose(memories);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Compact format (default)
|
|
20
|
+
return formatCompact(memories);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compact memory format - 75% fewer tokens
|
|
25
|
+
*/
|
|
26
|
+
function formatCompact(memories) {
|
|
27
|
+
const items = memories
|
|
28
|
+
.map(mem => `- ${mem.content}`)
|
|
29
|
+
.join('\n');
|
|
30
|
+
|
|
31
|
+
return `# Context\n${items}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Verbose XML format (original)
|
|
36
|
+
*/
|
|
37
|
+
function formatVerbose(memories) {
|
|
38
|
+
const items = memories.map((mem, idx) => {
|
|
39
|
+
const age = formatAge(mem.createdAt);
|
|
40
|
+
const type = mem.type ? `[${mem.type}] ` : '';
|
|
41
|
+
return `${idx + 1}. ${type}${mem.content} (${age})`;
|
|
42
|
+
}).join('\n');
|
|
43
|
+
|
|
44
|
+
return `<long_term_memory>
|
|
45
|
+
The following are relevant facts from previous conversations:
|
|
46
|
+
${items}
|
|
47
|
+
</long_term_memory>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format age in human-readable form
|
|
52
|
+
*/
|
|
53
|
+
function formatAge(timestamp) {
|
|
54
|
+
const ageMs = Date.now() - timestamp;
|
|
55
|
+
const days = Math.floor(ageMs / (24 * 60 * 60 * 1000));
|
|
56
|
+
const hours = Math.floor(ageMs / (60 * 60 * 1000));
|
|
57
|
+
|
|
58
|
+
if (days > 0) return `${days}d ago`;
|
|
59
|
+
if (hours > 0) return `${hours}h ago`;
|
|
60
|
+
return 'recent';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Deduplicate memories that are already in recent conversation
|
|
65
|
+
*/
|
|
66
|
+
function filterRedundantMemories(memories, recentMessages) {
|
|
67
|
+
if (!memories || memories.length === 0) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!recentMessages || recentMessages.length === 0) {
|
|
72
|
+
return memories;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Get last N messages content (configurable)
|
|
76
|
+
const lookbackCount = config.memory?.dedupLookback || 5;
|
|
77
|
+
const recentContent = recentMessages
|
|
78
|
+
.slice(-lookbackCount)
|
|
79
|
+
.map(m => extractMessageContent(m))
|
|
80
|
+
.join(' ')
|
|
81
|
+
.toLowerCase();
|
|
82
|
+
|
|
83
|
+
// Filter out memories that appear in recent context
|
|
84
|
+
const filtered = memories.filter(mem => {
|
|
85
|
+
const memSnippet = mem.content.toLowerCase().slice(0, 50);
|
|
86
|
+
return !recentContent.includes(memSnippet);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const dedupedCount = memories.length - filtered.length;
|
|
90
|
+
if (dedupedCount > 0) {
|
|
91
|
+
logger.debug({
|
|
92
|
+
original: memories.length,
|
|
93
|
+
filtered: filtered.length,
|
|
94
|
+
deduped: dedupedCount
|
|
95
|
+
}, 'Deduplicated redundant memories from recent conversation');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return filtered;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extract text content from a message
|
|
103
|
+
*/
|
|
104
|
+
function extractMessageContent(message) {
|
|
105
|
+
if (!message || !message.content) return '';
|
|
106
|
+
|
|
107
|
+
if (typeof message.content === 'string') {
|
|
108
|
+
return message.content;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (Array.isArray(message.content)) {
|
|
112
|
+
return message.content
|
|
113
|
+
.filter(block => block.type === 'text')
|
|
114
|
+
.map(block => block.text || '')
|
|
115
|
+
.join(' ');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Calculate token savings from compact format
|
|
123
|
+
*/
|
|
124
|
+
function calculateFormatSavings(memories, originalFormat = 'verbose', newFormat = 'compact') {
|
|
125
|
+
if (!memories || memories.length === 0) {
|
|
126
|
+
return { original: 0, optimized: 0, saved: 0, percentage: 0 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const originalTokens = estimateTokens(formatMemoriesForContext(memories, originalFormat));
|
|
130
|
+
const optimizedTokens = estimateTokens(formatMemoriesForContext(memories, newFormat));
|
|
131
|
+
const saved = originalTokens - optimizedTokens;
|
|
132
|
+
const percentage = originalTokens > 0 ? ((saved / originalTokens) * 100).toFixed(1) : 0;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
original: originalTokens,
|
|
136
|
+
optimized: optimizedTokens,
|
|
137
|
+
saved,
|
|
138
|
+
percentage: parseFloat(percentage)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Rough token estimate (4 chars ≈ 1 token)
|
|
144
|
+
*/
|
|
145
|
+
function estimateTokens(text) {
|
|
146
|
+
if (!text) return 0;
|
|
147
|
+
return Math.ceil(text.length / 4);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
formatMemoriesForContext,
|
|
152
|
+
filterRedundantMemories,
|
|
153
|
+
formatCompact,
|
|
154
|
+
formatVerbose,
|
|
155
|
+
calculateFormatSavings
|
|
156
|
+
};
|
package/src/memory/retriever.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const store = require("./store");
|
|
2
2
|
const search = require("./search");
|
|
3
3
|
const logger = require("../logger");
|
|
4
|
+
const format = require("./format");
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Retrieve relevant memories using multi-signal ranking
|
|
@@ -198,24 +199,41 @@ function formatAge(ageMs) {
|
|
|
198
199
|
/**
|
|
199
200
|
* Inject memories into system prompt
|
|
200
201
|
*/
|
|
201
|
-
function injectMemoriesIntoSystem(existingSystem, memories,
|
|
202
|
+
function injectMemoriesIntoSystem(existingSystem, memories, injectionFormat = 'system', recentMessages = null) {
|
|
202
203
|
if (!memories || memories.length === 0) return existingSystem;
|
|
203
204
|
|
|
204
|
-
|
|
205
|
+
// Apply deduplication if recent messages provided
|
|
206
|
+
const dedupedMemories = recentMessages
|
|
207
|
+
? format.filterRedundantMemories(memories, recentMessages)
|
|
208
|
+
: memories;
|
|
205
209
|
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
if (dedupedMemories.length === 0) {
|
|
211
|
+
logger.debug('All memories filtered out as redundant');
|
|
212
|
+
return existingSystem;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Use compact format (or configured format)
|
|
216
|
+
const config = require("../config");
|
|
217
|
+
const formatType = config.memory?.format || 'compact';
|
|
218
|
+
const formattedMemories = format.formatMemoriesForContext(dedupedMemories, formatType);
|
|
219
|
+
|
|
220
|
+
// Log token savings
|
|
221
|
+
const savings = format.calculateFormatSavings(dedupedMemories, 'verbose', formatType);
|
|
222
|
+
if (savings.saved > 0) {
|
|
223
|
+
logger.debug({
|
|
224
|
+
memories: dedupedMemories.length,
|
|
225
|
+
tokensSaved: savings.saved,
|
|
226
|
+
percentage: savings.percentage
|
|
227
|
+
}, 'Memory format optimization applied');
|
|
228
|
+
}
|
|
212
229
|
|
|
230
|
+
if (injectionFormat === 'system') {
|
|
213
231
|
return existingSystem
|
|
214
|
-
? `${existingSystem}\n${
|
|
215
|
-
:
|
|
232
|
+
? `${existingSystem}\n\n${formattedMemories}`
|
|
233
|
+
: formattedMemories;
|
|
216
234
|
}
|
|
217
235
|
|
|
218
|
-
if (
|
|
236
|
+
if (injectionFormat === 'assistant_preamble') {
|
|
219
237
|
return {
|
|
220
238
|
system: existingSystem,
|
|
221
239
|
memoryPreamble: formattedMemories,
|
|
@@ -228,22 +246,45 @@ ${formattedMemories}
|
|
|
228
246
|
/**
|
|
229
247
|
* Get memory statistics
|
|
230
248
|
*/
|
|
231
|
-
function getMemoryStats(
|
|
249
|
+
function getMemoryStats(options = {}) {
|
|
232
250
|
try {
|
|
233
|
-
const
|
|
251
|
+
const { sessionId = null } = options;
|
|
252
|
+
const total = store.countMemories({ sessionId });
|
|
253
|
+
|
|
234
254
|
const byType = {};
|
|
255
|
+
const byCategory = {};
|
|
235
256
|
const types = ['preference', 'decision', 'fact', 'entity', 'relationship'];
|
|
257
|
+
const categories = ['user', 'code', 'project', 'general'];
|
|
236
258
|
|
|
259
|
+
// Count by type
|
|
237
260
|
for (const type of types) {
|
|
238
|
-
|
|
261
|
+
const memories = store.getMemoriesByType(type, 10000);
|
|
262
|
+
// Filter by session if needed
|
|
263
|
+
const filtered = sessionId
|
|
264
|
+
? memories.filter(m => m.sessionId === sessionId || m.sessionId === null)
|
|
265
|
+
: memories;
|
|
266
|
+
byType[type] = filtered.length;
|
|
239
267
|
}
|
|
240
268
|
|
|
269
|
+
// Count by category
|
|
270
|
+
const allMemories = store.getRecentMemories({ limit: 10000, sessionId });
|
|
271
|
+
for (const category of categories) {
|
|
272
|
+
byCategory[category] = allMemories.filter(m => m.category === category).length;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Calculate average importance
|
|
276
|
+
const avgImportance = allMemories.length > 0
|
|
277
|
+
? allMemories.reduce((sum, m) => sum + (m.importance || 0), 0) / allMemories.length
|
|
278
|
+
: 0;
|
|
279
|
+
|
|
241
280
|
const recent = store.getRecentMemories({ limit: 10, sessionId });
|
|
242
281
|
const important = store.getMemoriesByImportance({ limit: 10, sessionId });
|
|
243
282
|
|
|
244
283
|
return {
|
|
245
284
|
total,
|
|
246
285
|
byType,
|
|
286
|
+
byCategory,
|
|
287
|
+
avgImportance,
|
|
247
288
|
recentCount: recent.length,
|
|
248
289
|
importantCount: important.length,
|
|
249
290
|
sessionId,
|