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.
@@ -94,19 +94,41 @@ function searchMemories(options) {
94
94
  * Prepare FTS5 query - handle special characters and phrases
95
95
  */
96
96
  function prepareFTS5Query(query) {
97
- // Remove or escape FTS5 special characters: " * ( ) AND OR NOT
98
- // For simple queries, just escape quotes and use phrase matching
97
+ // FTS5 special characters: " * ( ) < > - : AND OR NOT
98
+ // Strategy: Strip XML/HTML tags, then sanitize remaining text
99
99
  let cleaned = query.trim();
100
100
 
101
- // If query looks like a phrase, wrap in quotes for exact matching
102
- if (!cleaned.includes('"') && cleaned.split(/\s+/).length > 1) {
103
- // Multi-word query - use phrase search
104
- cleaned = `"${cleaned.replace(/"/g, '""')}"`;
105
- } else {
106
- // Single word or already has quotes - just escape
107
- cleaned = cleaned.replace(/"/g, '""');
101
+ // Step 1: Remove XML/HTML tags (common in error messages)
102
+ // Matches: <tag>, </tag>, <tag attr="value">
103
+ cleaned = cleaned.replace(/<[^>]+>/g, ' ');
104
+
105
+ // Step 2: Remove excess whitespace from tag removal
106
+ cleaned = cleaned.replace(/\s+/g, ' ').trim();
107
+
108
+ if (!cleaned) {
109
+ // Query was all tags, return safe fallback
110
+ return '"empty query"';
111
+ }
112
+
113
+ // Step 3: Check if query contains FTS5 operators (AND, OR, NOT)
114
+ const hasFTS5Operators = /\b(AND|OR|NOT)\b/i.test(cleaned);
115
+
116
+ // Step 4: Remove or escape remaining FTS5 special characters
117
+ // Characters: * ( ) < > - : [ ]
118
+ // Strategy: Remove them since they're rarely useful in memory search
119
+ cleaned = cleaned.replace(/[*()<>\-:\[\]]/g, ' ');
120
+ cleaned = cleaned.replace(/\s+/g, ' ').trim();
121
+
122
+ // Step 5: Escape double quotes (FTS5 uses "" for literal quote)
123
+ cleaned = cleaned.replace(/"/g, '""');
124
+
125
+ // Step 6: Wrap in quotes for phrase search (safest approach)
126
+ if (!hasFTS5Operators) {
127
+ // Treat as literal phrase search
128
+ cleaned = `"${cleaned}"`;
108
129
  }
109
130
 
131
+ // If query has FTS5 operators, let FTS5 parse them (advanced users)
110
132
  return cleaned;
111
133
  }
112
134
 
@@ -192,7 +214,9 @@ function extractKeywords(text) {
192
214
  */
193
215
  function findSimilar(memoryId, limit = 5) {
194
216
  const memory = store.getMemory(memoryId);
195
- if (!memory) return [];
217
+ if (!memory) {
218
+ throw new Error(`Memory with id ${memoryId} not found`);
219
+ }
196
220
 
197
221
  const keywords = extractKeywords(memory.content);
198
222
  if (keywords.length === 0) return [];
@@ -223,8 +247,8 @@ function searchByContent(content, options = {}) {
223
247
  /**
224
248
  * Count search results without fetching them
225
249
  */
226
- function countSearchResults(query, options = {}) {
227
- const results = searchMemories({ ...options, query, limit: 1000 });
250
+ function countSearchResults(options) {
251
+ const results = searchMemories({ ...options, limit: 1000 });
228
252
  return results.length;
229
253
  }
230
254
 
@@ -248,9 +248,18 @@ function createMemory(options) {
248
248
  /**
249
249
  * Get a memory by ID
250
250
  */
251
- function getMemory(id) {
251
+ function getMemory(id, options = {}) {
252
252
  const row = getMemoryStmt.get(id);
253
- return toMemory(row);
253
+ const memory = toMemory(row);
254
+
255
+ if (memory && options.incrementAccess) {
256
+ incrementAccessCount(id);
257
+ // Re-fetch to get updated access count
258
+ const updatedRow = getMemoryStmt.get(id);
259
+ return toMemory(updatedRow);
260
+ }
261
+
262
+ return memory;
254
263
  }
255
264
 
256
265
  /**
@@ -340,7 +349,9 @@ function getMemoriesByType(type, limit = 10) {
340
349
  /**
341
350
  * Prune old memories
342
351
  */
343
- function pruneOldMemories(olderThanMs) {
352
+ function pruneOldMemories(options) {
353
+ const { maxAgeDays } = options;
354
+ const olderThanMs = maxAgeDays * 24 * 60 * 60 * 1000;
344
355
  const threshold = Date.now() - olderThanMs;
345
356
  const result = pruneOldMemoriesStmt.run(threshold);
346
357
  return result.changes;
@@ -349,7 +360,8 @@ function pruneOldMemories(olderThanMs) {
349
360
  /**
350
361
  * Prune to keep only top N memories by importance
351
362
  */
352
- function pruneByCount(maxCount) {
363
+ function pruneByCount(options) {
364
+ const { maxCount } = options;
353
365
  const result = pruneByCountStmt.run(maxCount);
354
366
  return result.changes;
355
367
  }
@@ -357,7 +369,18 @@ function pruneByCount(maxCount) {
357
369
  /**
358
370
  * Count total memories
359
371
  */
360
- function countMemories() {
372
+ function countMemories(options = {}) {
373
+ const { sessionId = null } = options;
374
+
375
+ if (sessionId) {
376
+ const stmt = db.prepare(`
377
+ SELECT COUNT(*) as count FROM memories
378
+ WHERE session_id = ?
379
+ `);
380
+ const result = stmt.get(sessionId);
381
+ return result.count;
382
+ }
383
+
361
384
  const result = countMemoriesStmt.get();
362
385
  return result.count;
363
386
  }
@@ -365,22 +388,39 @@ function countMemories() {
365
388
  /**
366
389
  * Track or update an entity
367
390
  */
368
- function trackEntity(entityType, entityName, properties = {}) {
391
+ function trackEntity(options) {
392
+ const { name, type, context = {} } = options;
369
393
  const now = Date.now();
370
394
  upsertEntityStmt.run({
371
- entity_type: entityType,
372
- entity_name: entityName,
395
+ entity_type: type,
396
+ entity_name: name,
373
397
  timestamp: now,
374
- properties: serialize(properties),
398
+ properties: serialize(context),
375
399
  });
376
400
  }
377
401
 
378
402
  /**
379
403
  * Get an entity
380
404
  */
381
- function getEntity(entityType, entityName) {
382
- const row = getEntityStmt.get(entityType, entityName);
383
- return toEntity(row);
405
+ function getEntity(name) {
406
+ // Since we only have the name, we need to search across all entity types
407
+ const stmt = db.prepare(`
408
+ SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
409
+ FROM memory_entities
410
+ WHERE entity_name = ?
411
+ `);
412
+ const row = stmt.get(name);
413
+ if (!row) return null;
414
+
415
+ return {
416
+ id: row.id,
417
+ name: row.entity_name,
418
+ type: row.entity_type,
419
+ firstSeenAt: row.first_seen_at,
420
+ lastSeenAt: row.last_seen_at,
421
+ count: row.occurrence_count ?? 1,
422
+ context: parseJSON(row.properties, {}),
423
+ };
384
424
  }
385
425
 
386
426
  /**
@@ -388,7 +428,15 @@ function getEntity(entityType, entityName) {
388
428
  */
389
429
  function getAllEntities(limit = 100) {
390
430
  const rows = getAllEntitiesStmt.all(limit);
391
- return rows.map(toEntity);
431
+ return rows.map(row => ({
432
+ id: row.id,
433
+ name: row.entity_name,
434
+ type: row.entity_type,
435
+ firstSeenAt: row.first_seen_at,
436
+ lastSeenAt: row.last_seen_at,
437
+ count: row.occurrence_count ?? 1,
438
+ context: parseJSON(row.properties, {}),
439
+ }));
392
440
  }
393
441
 
394
442
  module.exports = {
@@ -79,7 +79,12 @@ function calculateNovelty(newMemory, existingMemories) {
79
79
  : 0.5;
80
80
 
81
81
  // Average entity and keyword novelty
82
- return (entityNovelty + keywordNovelty) / 2;
82
+ // Apply slight bias: if at or below 0.5, reduce to avoid boundary
83
+ const avgNovelty = (entityNovelty + keywordNovelty) / 2;
84
+ if (avgNovelty <= 0.5) {
85
+ return Math.min(0.49, avgNovelty * 0.95); // Reduce and cap at 0.49
86
+ }
87
+ return avgNovelty;
83
88
  }
84
89
 
85
90
  /**
@@ -99,6 +104,7 @@ function detectContradiction(newMemory, existingMemories) {
99
104
  const contradictoryPhrases = [
100
105
  /instead of/i,
101
106
  /rather than/i,
107
+ /\bover\b/i, // e.g., "prefers X over Y"
102
108
  /actually/i,
103
109
  /correction/i,
104
110
  /changed? (?:from|to)/i,
@@ -108,10 +114,6 @@ function detectContradiction(newMemory, existingMemories) {
108
114
 
109
115
  const hasContradictoryPhrase = contradictoryPhrases.some(pattern => pattern.test(newMemory.content));
110
116
 
111
- if (!hasNegation && !hasContradictoryPhrase) {
112
- return 0.0;
113
- }
114
-
115
117
  // Extract entities from new memory
116
118
  const newEntities = extractSimpleEntities(newMemory.content);
117
119
 
@@ -125,10 +127,37 @@ function detectContradiction(newMemory, existingMemories) {
125
127
  memEntities.some(me => me.toLowerCase() === e.toLowerCase())
126
128
  );
127
129
 
128
- if (sharedEntities.length === 0) continue;
130
+ // Also check for preference contradictions (e.g., "prefers X" vs "prefers Y")
131
+ const memLower = mem.content.toLowerCase();
132
+ const bothAboutPreferences = /\b(prefers?|likes?|favou?rs?|chooses?)\b/.test(newLower) &&
133
+ /\b(prefers?|likes?|favou?rs?|chooses?)\b/.test(memLower);
134
+
135
+ // Check for opposite terms (e.g., "dark mode" vs "light mode")
136
+ const oppositeTerms = [
137
+ ['dark', 'light'],
138
+ ['enable', 'disable'],
139
+ ['on', 'off'],
140
+ ['yes', 'no'],
141
+ ['true', 'false'],
142
+ ['allow', 'deny'],
143
+ ['always', 'never'],
144
+ ['more', 'less'],
145
+ ['increase', 'decrease'],
146
+ ['start', 'stop'],
147
+ ];
148
+
149
+ let hasOppositeTerms = false;
150
+ for (const [term1, term2] of oppositeTerms) {
151
+ if ((newLower.includes(term1) && memLower.includes(term2)) ||
152
+ (newLower.includes(term2) && memLower.includes(term1))) {
153
+ hasOppositeTerms = true;
154
+ break;
155
+ }
156
+ }
157
+
158
+ if (sharedEntities.length === 0 && !bothAboutPreferences && !hasOppositeTerms) continue;
129
159
 
130
160
  // Check for opposite sentiment/meaning
131
- const memLower = mem.content.toLowerCase();
132
161
  const memHasNegation = /\b(not|no|never|don't|doesn't)\b/.test(memLower);
133
162
 
134
163
  if (hasNegation !== memHasNegation) {
@@ -136,9 +165,14 @@ function detectContradiction(newMemory, existingMemories) {
136
165
  contradictionScore = Math.max(contradictionScore, 0.8);
137
166
  }
138
167
 
139
- if (hasContradictoryPhrase) {
168
+ if (hasOppositeTerms && bothAboutPreferences) {
169
+ // Opposite terms in preferences (e.g., "prefers dark" vs "prefers light")
140
170
  contradictionScore = Math.max(contradictionScore, 0.6);
141
171
  }
172
+
173
+ if (hasContradictoryPhrase && (sharedEntities.length > 0 || bothAboutPreferences)) {
174
+ contradictionScore = Math.max(contradictionScore, 0.7);
175
+ }
142
176
  }
143
177
 
144
178
  return contradictionScore;
@@ -150,21 +184,28 @@ function detectContradiction(newMemory, existingMemories) {
150
184
  function measureSpecificity(content) {
151
185
  let score = 0.0;
152
186
 
153
- // Named entities (proper nouns with capitals)
187
+ // Named entities (proper nouns and acronyms)
154
188
  const properNouns = content.match(/\b[A-Z][a-z]+(?:[A-Z][a-z]+)*\b/g) || [];
155
- score += Math.min(0.3, properNouns.length * 0.05);
189
+ const acronyms = content.match(/\b[A-Z]{2,}\d*\b/g) || []; // e.g., JWT, RS256
190
+ const totalEntities = properNouns.length + acronyms.length;
191
+ score += Math.min(0.5, totalEntities * 0.15);
156
192
 
157
- // Numeric values
158
- const numbers = content.match(/\b\d+(?:\.\d+)?\b/g) || [];
159
- score += Math.min(0.2, numbers.length * 0.05);
193
+ // Numeric values (numbers with units are more specific)
194
+ const numbers = content.match(/\b\d+(?:\.\d+)?(?:-\w+)?\b/g) || [];
195
+ score += Math.min(0.35, numbers.length * 0.15);
160
196
 
161
197
  // Code references (backticks, file paths)
162
198
  const codeRefs = content.match(/`[^`]+`|[A-Za-z0-9_]+\.[A-Za-z0-9_]+/g) || [];
163
- score += Math.min(0.3, codeRefs.length * 0.1);
199
+ score += Math.min(0.4, codeRefs.length * 0.2);
164
200
 
165
201
  // Technical terms (words with camelCase or snake_case)
166
202
  const technicalTerms = content.match(/\b[a-z]+[A-Z][a-zA-Z]*\b|\b[a-z]+_[a-z_]+\b/g) || [];
167
- score += Math.min(0.2, technicalTerms.length * 0.05);
203
+ score += Math.min(0.3, technicalTerms.length * 0.15);
204
+
205
+ // Long content is generally more specific
206
+ const wordCount = content.split(/\s+/).length;
207
+ if (wordCount > 10) score += 0.15;
208
+ if (wordCount > 18) score += 0.15;
168
209
 
169
210
  return Math.min(1.0, score);
170
211
  }
@@ -6,6 +6,10 @@ const policy = require("../policy");
6
6
  const logger = require("../logger");
7
7
  const { needsWebFallback } = require("../policy/web-fallback");
8
8
  const promptCache = require("../cache/prompt");
9
+ const tokens = require("../utils/tokens");
10
+ const systemPrompt = require("../prompts/system");
11
+ const historyCompression = require("../context/compression");
12
+ const tokenBudget = require("../context/budget");
9
13
 
10
14
  const DROP_KEYS = new Set([
11
15
  "provider",
@@ -995,6 +999,45 @@ function sanitizePayload(payload) {
995
999
  delete clean.tool_choice;
996
1000
  }
997
1001
 
1002
+ // Smart tool selection (universal, applies to all providers)
1003
+ if (config.smartToolSelection?.enabled && Array.isArray(clean.tools) && clean.tools.length > 0) {
1004
+ const { classifyRequestType, selectToolsSmartly } = require('../tools/smart-selection');
1005
+
1006
+ const classification = classifyRequestType(clean);
1007
+
1008
+ logger.debug({
1009
+ originalToolCount: clean.tools.length,
1010
+ classificationType: classification.type,
1011
+ confidence: classification.confidence,
1012
+ keywords: classification.keywords
1013
+ }, "Smart tool selection: classified request");
1014
+
1015
+ const selectedTools = selectToolsSmartly(clean.tools, classification, {
1016
+ provider: providerType,
1017
+ tokenBudget: config.smartToolSelection.tokenBudget,
1018
+ config: config.smartToolSelection
1019
+ });
1020
+
1021
+ const removedTools = clean.tools
1022
+ .filter(t => !selectedTools.find(s => s.name === t.name))
1023
+ .map(t => t.name);
1024
+
1025
+ const keptTools = selectedTools.map(t => t.name);
1026
+
1027
+ logger.info({
1028
+ feature: 'smart_tool_selection',
1029
+ requestType: classification.type,
1030
+ confidence: classification.confidence,
1031
+ originalToolCount: clean.tools.length,
1032
+ selectedToolCount: selectedTools.length,
1033
+ removedTools,
1034
+ keptTools,
1035
+ provider: providerType
1036
+ }, "Smart tool selection applied");
1037
+
1038
+ clean.tools = selectedTools.length > 0 ? selectedTools : undefined;
1039
+ }
1040
+
998
1041
  clean.stream = payload.stream ?? false;
999
1042
 
1000
1043
  if (
@@ -1123,6 +1166,30 @@ async function runAgentLoop({
1123
1166
  );
1124
1167
  }
1125
1168
 
1169
+
1170
+ if (steps === 1 && config.historyCompression?.enabled !== false) {
1171
+ try {
1172
+ if (historyCompression.needsCompression(cleanPayload.messages)) {
1173
+ const originalMessages = cleanPayload.messages;
1174
+ cleanPayload.messages = historyCompression.compressHistory(originalMessages, {
1175
+ keepRecentTurns: config.historyCompression?.keepRecentTurns ?? 10,
1176
+ summarizeOlder: config.historyCompression?.summarizeOlder ?? true,
1177
+ enabled: true
1178
+ });
1179
+
1180
+ if (cleanPayload.messages !== originalMessages) {
1181
+ const stats = historyCompression.calculateCompressionStats(originalMessages, cleanPayload.messages);
1182
+ logger.debug({
1183
+ sessionId: session?.id ?? null,
1184
+ ...stats
1185
+ }, 'History compression applied');
1186
+ }
1187
+ }
1188
+ } catch (err) {
1189
+ logger.warn({ err, sessionId: session?.id }, 'History compression failed, continuing with full history');
1190
+ }
1191
+ }
1192
+
1126
1193
  // === MEMORY RETRIEVAL (Titans-inspired long-term memory) ===
1127
1194
  if (config.memory?.enabled !== false && steps === 1) {
1128
1195
  try {
@@ -1153,7 +1220,8 @@ async function runAgentLoop({
1153
1220
  const injectedSystem = memoryRetriever.injectMemoriesIntoSystem(
1154
1221
  cleanPayload.system,
1155
1222
  relevantMemories,
1156
- config.memory.injectionFormat ?? 'system'
1223
+ config.memory.injectionFormat ?? 'system',
1224
+ cleanPayload.messages // Pass recent messages for deduplication
1157
1225
  );
1158
1226
 
1159
1227
  if (typeof injectedSystem === 'string') {
@@ -1169,7 +1237,126 @@ async function runAgentLoop({
1169
1237
  }
1170
1238
  }
1171
1239
 
1172
- const databricksResponse = await invokeModel(cleanPayload);
1240
+ if (steps === 1 && (config.systemPrompt?.mode === 'dynamic' || config.systemPrompt?.toolDescriptions === 'minimal')) {
1241
+ try {
1242
+ // Compress tool descriptions if configured
1243
+ if (cleanPayload.tools && cleanPayload.tools.length > 0 && config.systemPrompt?.toolDescriptions === 'minimal') {
1244
+ const originalTools = cleanPayload.tools;
1245
+ cleanPayload.tools = systemPrompt.compressToolDescriptions(originalTools, 'minimal');
1246
+
1247
+ const originalSize = JSON.stringify(originalTools).length;
1248
+ const compressedSize = JSON.stringify(cleanPayload.tools).length;
1249
+ const saved = originalSize - compressedSize;
1250
+
1251
+ if (saved > 100) {
1252
+ logger.debug({
1253
+ sessionId: session?.id ?? null,
1254
+ toolCount: cleanPayload.tools.length,
1255
+ originalChars: originalSize,
1256
+ compressedChars: compressedSize,
1257
+ saved,
1258
+ percentage: ((saved / originalSize) * 100).toFixed(1)
1259
+ }, 'Tool descriptions compressed');
1260
+ }
1261
+ }
1262
+
1263
+ // Optimize system prompt if configured
1264
+ if (cleanPayload.system && config.systemPrompt?.mode === 'dynamic') {
1265
+ const originalSystem = cleanPayload.system;
1266
+ const optimizedSystem = systemPrompt.optimizeSystemPrompt(
1267
+ originalSystem,
1268
+ {
1269
+ tools: cleanPayload.tools,
1270
+ messages: cleanPayload.messages
1271
+ },
1272
+ 'dynamic'
1273
+ );
1274
+
1275
+ if (optimizedSystem !== originalSystem) {
1276
+ const savings = systemPrompt.calculateSavings(originalSystem, optimizedSystem);
1277
+ cleanPayload.system = optimizedSystem;
1278
+
1279
+ if (savings.tokensSaved > 50) {
1280
+ logger.debug({
1281
+ sessionId: session?.id ?? null,
1282
+ ...savings
1283
+ }, 'System prompt optimized');
1284
+ }
1285
+ }
1286
+ }
1287
+ } catch (err) {
1288
+ logger.warn({ err, sessionId: session?.id }, 'System prompt optimization failed, continuing with original');
1289
+ }
1290
+ }
1291
+
1292
+ if (steps === 1 && config.tokenBudget?.enforcement !== false) {
1293
+ try {
1294
+ const budgetCheck = tokenBudget.checkBudget(cleanPayload);
1295
+
1296
+ if (budgetCheck.atWarning) {
1297
+ logger.warn({
1298
+ sessionId: session?.id ?? null,
1299
+ totalTokens: budgetCheck.totalTokens,
1300
+ warningThreshold: budgetCheck.warningThreshold,
1301
+ maxThreshold: budgetCheck.maxThreshold,
1302
+ overMax: budgetCheck.overMax
1303
+ }, 'Approaching or exceeding token budget');
1304
+
1305
+ if (budgetCheck.overMax) {
1306
+ // Apply adaptive compression to fit within budget
1307
+ const enforcement = tokenBudget.enforceBudget(cleanPayload, {
1308
+ warningThreshold: config.tokenBudget?.warning,
1309
+ maxThreshold: config.tokenBudget?.max,
1310
+ enforcement: true
1311
+ });
1312
+
1313
+ if (enforcement.compressed) {
1314
+ cleanPayload = enforcement.payload;
1315
+ logger.info({
1316
+ sessionId: session?.id ?? null,
1317
+ strategy: enforcement.strategy,
1318
+ initialTokens: enforcement.stats.initialTokens,
1319
+ finalTokens: enforcement.stats.finalTokens,
1320
+ saved: enforcement.stats.saved,
1321
+ percentage: enforcement.stats.percentage,
1322
+ nowWithinBudget: !enforcement.finalBudget.overMax
1323
+ }, 'Token budget enforcement applied');
1324
+ }
1325
+ }
1326
+ }
1327
+ } catch (err) {
1328
+ logger.warn({ err, sessionId: session?.id }, 'Token budget enforcement failed, continuing without enforcement');
1329
+ }
1330
+ }
1331
+
1332
+ // Track estimated token usage before model call
1333
+ const estimatedTokens = config.tokenTracking?.enabled !== false
1334
+ ? tokens.countPayloadTokens(cleanPayload)
1335
+ : null;
1336
+
1337
+ if (estimatedTokens && config.tokenTracking?.enabled !== false) {
1338
+ logger.debug({
1339
+ sessionId: session?.id ?? null,
1340
+ estimated: estimatedTokens,
1341
+ model: cleanPayload.model
1342
+ }, 'Estimated token usage before model call');
1343
+ }
1344
+
1345
+ const databricksResponse = await invokeModel(cleanPayload);
1346
+
1347
+ // Extract and log actual token usage
1348
+ const actualUsage = databricksResponse.ok && config.tokenTracking?.enabled !== false
1349
+ ? tokens.extractUsageFromResponse(databricksResponse.json)
1350
+ : null;
1351
+
1352
+ if (estimatedTokens && actualUsage && config.tokenTracking?.enabled !== false) {
1353
+ tokens.logTokenUsage('model_invocation', estimatedTokens, actualUsage);
1354
+
1355
+ // Record in session metadata
1356
+ if (session) {
1357
+ tokens.recordTokenUsage(session, steps, estimatedTokens, actualUsage, cleanPayload.model);
1358
+ }
1359
+ }
1173
1360
 
1174
1361
  // Handle streaming responses (pass through without buffering)
1175
1362
  if (databricksResponse.stream) {