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.
@@ -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
+ };
@@ -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, format = 'system') {
202
+ function injectMemoriesIntoSystem(existingSystem, memories, injectionFormat = 'system', recentMessages = null) {
202
203
  if (!memories || memories.length === 0) return existingSystem;
203
204
 
204
- const formattedMemories = formatMemoriesForContext(memories);
205
+ // Apply deduplication if recent messages provided
206
+ const dedupedMemories = recentMessages
207
+ ? format.filterRedundantMemories(memories, recentMessages)
208
+ : memories;
205
209
 
206
- if (format === 'system') {
207
- const memoryBlock = `
208
- <long_term_memory>
209
- The following are relevant facts and context from previous conversations:
210
- ${formattedMemories}
211
- </long_term_memory>`;
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${memoryBlock}`
215
- : memoryBlock;
232
+ ? `${existingSystem}\n\n${formattedMemories}`
233
+ : formattedMemories;
216
234
  }
217
235
 
218
- if (format === 'assistant_preamble') {
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(sessionId = null) {
249
+ function getMemoryStats(options = {}) {
232
250
  try {
233
- const total = store.countMemories();
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
- byType[type] = store.getMemoriesByType(type, 1000).length;
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,