lynkr 2.0.0 → 3.0.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,239 @@
1
+ const db = require("../db");
2
+ const logger = require("../logger");
3
+ const store = require("./store");
4
+
5
+ /**
6
+ * Search memories using FTS5 full-text search
7
+ */
8
+ function searchMemories(options) {
9
+ const {
10
+ query,
11
+ limit = 10,
12
+ types = null, // Filter by memory types
13
+ categories = null, // Filter by categories
14
+ sessionId = null, // Filter by session
15
+ minImportance = null,
16
+ } = options;
17
+
18
+ if (!query || typeof query !== "string") {
19
+ logger.warn("Search query must be a non-empty string");
20
+ return [];
21
+ }
22
+
23
+ // Build FTS5 query - escape special characters
24
+ const ftsQuery = prepareFTS5Query(query);
25
+
26
+ // Build SQL with filters
27
+ let sql = `
28
+ SELECT m.id, m.session_id, m.content, m.type, m.category,
29
+ m.importance, m.surprise_score, m.access_count, m.decay_factor,
30
+ m.source_turn_id, m.created_at, m.updated_at, m.last_accessed_at, m.metadata,
31
+ fts.rank
32
+ FROM memories_fts fts
33
+ JOIN memories m ON m.id = fts.rowid
34
+ WHERE memories_fts MATCH ?
35
+ `;
36
+
37
+ const params = [ftsQuery];
38
+
39
+ // Add filters
40
+ if (sessionId) {
41
+ sql += ` AND (m.session_id = ? OR m.session_id IS NULL)`;
42
+ params.push(sessionId);
43
+ }
44
+
45
+ if (types && Array.isArray(types) && types.length > 0) {
46
+ const placeholders = types.map(() => "?").join(",");
47
+ sql += ` AND m.type IN (${placeholders})`;
48
+ params.push(...types);
49
+ }
50
+
51
+ if (categories && Array.isArray(categories) && categories.length > 0) {
52
+ const placeholders = categories.map(() => "?").join(",");
53
+ sql += ` AND m.category IN (${placeholders})`;
54
+ params.push(...categories);
55
+ }
56
+
57
+ if (minImportance !== null && typeof minImportance === "number") {
58
+ sql += ` AND m.importance >= ?`;
59
+ params.push(minImportance);
60
+ }
61
+
62
+ // Order by FTS5 rank and importance
63
+ sql += ` ORDER BY fts.rank, m.importance DESC LIMIT ?`;
64
+ params.push(limit);
65
+
66
+ try {
67
+ const stmt = db.prepare(sql);
68
+ const rows = stmt.all(...params);
69
+
70
+ return rows.map((row) => ({
71
+ id: row.id,
72
+ sessionId: row.session_id ?? null,
73
+ content: row.content,
74
+ type: row.type,
75
+ category: row.category ?? null,
76
+ importance: row.importance ?? 0.5,
77
+ surpriseScore: row.surprise_score ?? 0.0,
78
+ accessCount: row.access_count ?? 0,
79
+ decayFactor: row.decay_factor ?? 1.0,
80
+ sourceTurnId: row.source_turn_id ?? null,
81
+ createdAt: row.created_at,
82
+ updatedAt: row.updated_at,
83
+ lastAccessedAt: row.last_accessed_at ?? null,
84
+ metadata: row.metadata ? JSON.parse(row.metadata) : {},
85
+ rank: row.rank, // FTS5 relevance score
86
+ }));
87
+ } catch (err) {
88
+ logger.error({ err, query: ftsQuery }, "FTS5 search failed");
89
+ return [];
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Prepare FTS5 query - handle special characters and phrases
95
+ */
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
99
+ let cleaned = query.trim();
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, '""');
108
+ }
109
+
110
+ return cleaned;
111
+ }
112
+
113
+ /**
114
+ * Search with keyword expansion (extract key terms)
115
+ */
116
+ function searchWithExpansion(options) {
117
+ const { query, limit = 10 } = options;
118
+
119
+ // Extract keywords from query
120
+ const keywords = extractKeywords(query);
121
+
122
+ // Search with original query
123
+ const results = searchMemories({ ...options, limit: limit * 2 });
124
+
125
+ // If not enough results, try individual keywords
126
+ if (results.length < limit && keywords.length > 1) {
127
+ const seen = new Set(results.map((r) => r.id));
128
+
129
+ for (const keyword of keywords) {
130
+ if (results.length >= limit) break;
131
+
132
+ const kwResults = searchMemories({
133
+ ...options,
134
+ query: keyword,
135
+ limit: limit - results.length,
136
+ });
137
+
138
+ for (const result of kwResults) {
139
+ if (!seen.has(result.id)) {
140
+ results.push(result);
141
+ seen.add(result.id);
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ return results.slice(0, limit);
148
+ }
149
+
150
+ /**
151
+ * Extract keywords from text (simple tokenization)
152
+ */
153
+ function extractKeywords(text) {
154
+ if (!text) return [];
155
+
156
+ // Simple keyword extraction:
157
+ // - Split on whitespace
158
+ // - Remove stopwords
159
+ // - Keep words > 3 characters
160
+ // - Lowercase
161
+
162
+ const stopwords = new Set([
163
+ "the",
164
+ "is",
165
+ "at",
166
+ "which",
167
+ "on",
168
+ "and",
169
+ "or",
170
+ "not",
171
+ "this",
172
+ "that",
173
+ "with",
174
+ "from",
175
+ "for",
176
+ "to",
177
+ "in",
178
+ "of",
179
+ "a",
180
+ "an",
181
+ ]);
182
+
183
+ return text
184
+ .toLowerCase()
185
+ .split(/\s+/)
186
+ .map((word) => word.replace(/[^\w]/g, ""))
187
+ .filter((word) => word.length > 3 && !stopwords.has(word));
188
+ }
189
+
190
+ /**
191
+ * Find similar memories by keyword overlap
192
+ */
193
+ function findSimilar(memoryId, limit = 5) {
194
+ const memory = store.getMemory(memoryId);
195
+ if (!memory) return [];
196
+
197
+ const keywords = extractKeywords(memory.content);
198
+ if (keywords.length === 0) return [];
199
+
200
+ // Build OR query for keywords
201
+ const query = keywords.join(" OR ");
202
+
203
+ const results = searchMemories({
204
+ query,
205
+ limit: limit + 1, // +1 to exclude self
206
+ });
207
+
208
+ // Filter out the original memory
209
+ return results.filter((r) => r.id !== memoryId).slice(0, limit);
210
+ }
211
+
212
+ /**
213
+ * Search by content similarity (simple keyword-based)
214
+ */
215
+ function searchByContent(content, options = {}) {
216
+ const keywords = extractKeywords(content);
217
+ if (keywords.length === 0) return [];
218
+
219
+ const query = keywords.slice(0, 5).join(" OR "); // Top 5 keywords
220
+ return searchMemories({ ...options, query });
221
+ }
222
+
223
+ /**
224
+ * Count search results without fetching them
225
+ */
226
+ function countSearchResults(query, options = {}) {
227
+ const results = searchMemories({ ...options, query, limit: 1000 });
228
+ return results.length;
229
+ }
230
+
231
+ module.exports = {
232
+ searchMemories,
233
+ searchWithExpansion,
234
+ extractKeywords,
235
+ findSimilar,
236
+ searchByContent,
237
+ countSearchResults,
238
+ prepareFTS5Query,
239
+ };
@@ -0,0 +1,411 @@
1
+ const db = require("../db");
2
+ const logger = require("../logger");
3
+
4
+ // Prepared statements for memory operations
5
+ const insertMemoryStmt = db.prepare(`
6
+ INSERT INTO memories (
7
+ session_id, content, type, category, importance, surprise_score,
8
+ access_count, decay_factor, source_turn_id, created_at, updated_at,
9
+ last_accessed_at, metadata
10
+ ) VALUES (
11
+ @session_id, @content, @type, @category, @importance, @surprise_score,
12
+ @access_count, @decay_factor, @source_turn_id, @created_at, @updated_at,
13
+ @last_accessed_at, @metadata
14
+ )
15
+ `);
16
+
17
+ const getMemoryStmt = db.prepare(`
18
+ SELECT
19
+ id, session_id, content, type, category, importance, surprise_score,
20
+ access_count, decay_factor, source_turn_id, created_at, updated_at,
21
+ last_accessed_at, metadata
22
+ FROM memories
23
+ WHERE id = ?
24
+ `);
25
+
26
+ const updateMemoryStmt = db.prepare(`
27
+ UPDATE memories
28
+ SET content = @content,
29
+ type = @type,
30
+ category = @category,
31
+ importance = @importance,
32
+ surprise_score = @surprise_score,
33
+ decay_factor = @decay_factor,
34
+ updated_at = @updated_at,
35
+ metadata = @metadata
36
+ WHERE id = @id
37
+ `);
38
+
39
+ const deleteMemoryStmt = db.prepare("DELETE FROM memories WHERE id = ?");
40
+
41
+ const incrementAccessStmt = db.prepare(`
42
+ UPDATE memories
43
+ SET access_count = access_count + 1,
44
+ last_accessed_at = ?
45
+ WHERE id = ?
46
+ `);
47
+
48
+ const updateImportanceStmt = db.prepare(`
49
+ UPDATE memories
50
+ SET importance = ?,
51
+ updated_at = ?
52
+ WHERE id = ?
53
+ `);
54
+
55
+ const getRecentMemoriesStmt = db.prepare(`
56
+ SELECT
57
+ id, session_id, content, type, category, importance, surprise_score,
58
+ access_count, decay_factor, source_turn_id, created_at, updated_at,
59
+ last_accessed_at, metadata
60
+ FROM memories
61
+ WHERE (session_id = ? OR ? IS NULL)
62
+ ORDER BY created_at DESC
63
+ LIMIT ?
64
+ `);
65
+
66
+ const getMemoriesByImportanceStmt = db.prepare(`
67
+ SELECT
68
+ id, session_id, content, type, category, importance, surprise_score,
69
+ access_count, decay_factor, source_turn_id, created_at, updated_at,
70
+ last_accessed_at, metadata
71
+ FROM memories
72
+ WHERE (session_id = ? OR ? IS NULL)
73
+ ORDER BY importance DESC, created_at DESC
74
+ LIMIT ?
75
+ `);
76
+
77
+ const getMemoriesBySurpriseStmt = db.prepare(`
78
+ SELECT
79
+ id, session_id, content, type, category, importance, surprise_score,
80
+ access_count, decay_factor, source_turn_id, created_at, updated_at,
81
+ last_accessed_at, metadata
82
+ FROM memories
83
+ WHERE surprise_score >= ?
84
+ ORDER BY surprise_score DESC, created_at DESC
85
+ LIMIT ?
86
+ `);
87
+
88
+ const pruneOldMemoriesStmt = db.prepare(`
89
+ DELETE FROM memories
90
+ WHERE created_at < ?
91
+ `);
92
+
93
+ const pruneByCountStmt = db.prepare(`
94
+ DELETE FROM memories
95
+ WHERE id NOT IN (
96
+ SELECT id FROM memories
97
+ ORDER BY importance DESC, created_at DESC
98
+ LIMIT ?
99
+ )
100
+ `);
101
+
102
+ const countMemoriesStmt = db.prepare("SELECT COUNT(*) as count FROM memories");
103
+
104
+ const getMemoriesByTypeStmt = db.prepare(`
105
+ SELECT
106
+ id, session_id, content, type, category, importance, surprise_score,
107
+ access_count, decay_factor, source_turn_id, created_at, updated_at,
108
+ last_accessed_at, metadata
109
+ FROM memories
110
+ WHERE type = ?
111
+ ORDER BY importance DESC, created_at DESC
112
+ LIMIT ?
113
+ `);
114
+
115
+ // Entity tracking
116
+ const upsertEntityStmt = db.prepare(`
117
+ INSERT INTO memory_entities (entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties)
118
+ VALUES (@entity_type, @entity_name, @timestamp, @timestamp, 1, @properties)
119
+ ON CONFLICT(entity_type, entity_name) DO UPDATE SET
120
+ last_seen_at = @timestamp,
121
+ occurrence_count = occurrence_count + 1,
122
+ properties = @properties
123
+ `);
124
+
125
+ const getEntityStmt = db.prepare(`
126
+ SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
127
+ FROM memory_entities
128
+ WHERE entity_type = ? AND entity_name = ?
129
+ `);
130
+
131
+ const getAllEntitiesStmt = db.prepare(`
132
+ SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
133
+ FROM memory_entities
134
+ ORDER BY occurrence_count DESC
135
+ LIMIT ?
136
+ `);
137
+
138
+ // Helper functions
139
+ function parseJSON(value, fallback = null) {
140
+ if (value === null || value === undefined) return fallback;
141
+ try {
142
+ return JSON.parse(value);
143
+ } catch (err) {
144
+ logger.warn({ err }, "Failed to parse JSON from memory store");
145
+ return fallback;
146
+ }
147
+ }
148
+
149
+ function serialize(value) {
150
+ if (value === undefined || value === null) return null;
151
+ try {
152
+ return JSON.stringify(value);
153
+ } catch (err) {
154
+ logger.warn({ err }, "Failed to serialize JSON for memory store");
155
+ return null;
156
+ }
157
+ }
158
+
159
+ function toMemory(row) {
160
+ if (!row) return null;
161
+ return {
162
+ id: row.id,
163
+ sessionId: row.session_id ?? null,
164
+ content: row.content,
165
+ type: row.type,
166
+ category: row.category ?? null,
167
+ importance: row.importance ?? 0.5,
168
+ surpriseScore: row.surprise_score ?? 0.0,
169
+ accessCount: row.access_count ?? 0,
170
+ decayFactor: row.decay_factor ?? 1.0,
171
+ sourceTurnId: row.source_turn_id ?? null,
172
+ createdAt: row.created_at,
173
+ updatedAt: row.updated_at,
174
+ lastAccessedAt: row.last_accessed_at ?? null,
175
+ metadata: parseJSON(row.metadata, {}),
176
+ };
177
+ }
178
+
179
+ function toEntity(row) {
180
+ if (!row) return null;
181
+ return {
182
+ id: row.id,
183
+ entityType: row.entity_type,
184
+ entityName: row.entity_name,
185
+ firstSeenAt: row.first_seen_at,
186
+ lastSeenAt: row.last_seen_at,
187
+ occurrenceCount: row.occurrence_count ?? 1,
188
+ properties: parseJSON(row.properties, {}),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Create a new memory
194
+ */
195
+ function createMemory(options) {
196
+ const now = Date.now();
197
+ const {
198
+ sessionId = null,
199
+ content,
200
+ type,
201
+ category = null,
202
+ importance = 0.5,
203
+ surpriseScore = 0.0,
204
+ accessCount = 0,
205
+ decayFactor = 1.0,
206
+ sourceTurnId = null,
207
+ metadata = {},
208
+ } = options;
209
+
210
+ if (!content || !type) {
211
+ throw new Error("Memory content and type are required");
212
+ }
213
+
214
+ const result = insertMemoryStmt.run({
215
+ session_id: sessionId,
216
+ content,
217
+ type,
218
+ category,
219
+ importance,
220
+ surprise_score: surpriseScore,
221
+ access_count: accessCount,
222
+ decay_factor: decayFactor,
223
+ source_turn_id: sourceTurnId,
224
+ created_at: now,
225
+ updated_at: now,
226
+ last_accessed_at: null,
227
+ metadata: serialize(metadata),
228
+ });
229
+
230
+ return {
231
+ id: result.lastInsertRowid,
232
+ sessionId,
233
+ content,
234
+ type,
235
+ category,
236
+ importance,
237
+ surpriseScore,
238
+ accessCount,
239
+ decayFactor,
240
+ sourceTurnId,
241
+ createdAt: now,
242
+ updatedAt: now,
243
+ lastAccessedAt: null,
244
+ metadata,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Get a memory by ID
250
+ */
251
+ function getMemory(id) {
252
+ const row = getMemoryStmt.get(id);
253
+ return toMemory(row);
254
+ }
255
+
256
+ /**
257
+ * Update a memory
258
+ */
259
+ function updateMemory(id, updates) {
260
+ const memory = getMemory(id);
261
+ if (!memory) {
262
+ throw new Error(`Memory with ID ${id} not found`);
263
+ }
264
+
265
+ const now = Date.now();
266
+ updateMemoryStmt.run({
267
+ id,
268
+ content: updates.content ?? memory.content,
269
+ type: updates.type ?? memory.type,
270
+ category: updates.category ?? memory.category,
271
+ importance: updates.importance ?? memory.importance,
272
+ surprise_score: updates.surpriseScore ?? memory.surpriseScore,
273
+ decay_factor: updates.decayFactor ?? memory.decayFactor,
274
+ updated_at: now,
275
+ metadata: serialize(updates.metadata ?? memory.metadata),
276
+ });
277
+
278
+ return getMemory(id);
279
+ }
280
+
281
+ /**
282
+ * Delete a memory
283
+ */
284
+ function deleteMemory(id) {
285
+ const result = deleteMemoryStmt.run(id);
286
+ return result.changes > 0;
287
+ }
288
+
289
+ /**
290
+ * Increment access count for a memory
291
+ */
292
+ function incrementAccessCount(id) {
293
+ const now = Date.now();
294
+ incrementAccessStmt.run(now, id);
295
+ }
296
+
297
+ /**
298
+ * Update importance score
299
+ */
300
+ function updateImportance(id, importance) {
301
+ const now = Date.now();
302
+ updateImportanceStmt.run(importance, now, id);
303
+ }
304
+
305
+ /**
306
+ * Get recent memories
307
+ */
308
+ function getRecentMemories(options = {}) {
309
+ const { limit = 10, sessionId = null } = options;
310
+ const rows = getRecentMemoriesStmt.all(sessionId, sessionId, limit);
311
+ return rows.map(toMemory);
312
+ }
313
+
314
+ /**
315
+ * Get memories by importance
316
+ */
317
+ function getMemoriesByImportance(options = {}) {
318
+ const { limit = 10, sessionId = null } = options;
319
+ const rows = getMemoriesByImportanceStmt.all(sessionId, sessionId, limit);
320
+ return rows.map(toMemory);
321
+ }
322
+
323
+ /**
324
+ * Get memories by surprise score
325
+ */
326
+ function getMemoriesBySurprise(options = {}) {
327
+ const { minScore = 0.3, limit = 10 } = options;
328
+ const rows = getMemoriesBySurpriseStmt.all(minScore, limit);
329
+ return rows.map(toMemory);
330
+ }
331
+
332
+ /**
333
+ * Get memories by type
334
+ */
335
+ function getMemoriesByType(type, limit = 10) {
336
+ const rows = getMemoriesByTypeStmt.all(type, limit);
337
+ return rows.map(toMemory);
338
+ }
339
+
340
+ /**
341
+ * Prune old memories
342
+ */
343
+ function pruneOldMemories(olderThanMs) {
344
+ const threshold = Date.now() - olderThanMs;
345
+ const result = pruneOldMemoriesStmt.run(threshold);
346
+ return result.changes;
347
+ }
348
+
349
+ /**
350
+ * Prune to keep only top N memories by importance
351
+ */
352
+ function pruneByCount(maxCount) {
353
+ const result = pruneByCountStmt.run(maxCount);
354
+ return result.changes;
355
+ }
356
+
357
+ /**
358
+ * Count total memories
359
+ */
360
+ function countMemories() {
361
+ const result = countMemoriesStmt.get();
362
+ return result.count;
363
+ }
364
+
365
+ /**
366
+ * Track or update an entity
367
+ */
368
+ function trackEntity(entityType, entityName, properties = {}) {
369
+ const now = Date.now();
370
+ upsertEntityStmt.run({
371
+ entity_type: entityType,
372
+ entity_name: entityName,
373
+ timestamp: now,
374
+ properties: serialize(properties),
375
+ });
376
+ }
377
+
378
+ /**
379
+ * Get an entity
380
+ */
381
+ function getEntity(entityType, entityName) {
382
+ const row = getEntityStmt.get(entityType, entityName);
383
+ return toEntity(row);
384
+ }
385
+
386
+ /**
387
+ * Get all entities
388
+ */
389
+ function getAllEntities(limit = 100) {
390
+ const rows = getAllEntitiesStmt.all(limit);
391
+ return rows.map(toEntity);
392
+ }
393
+
394
+ module.exports = {
395
+ createMemory,
396
+ getMemory,
397
+ updateMemory,
398
+ deleteMemory,
399
+ incrementAccessCount,
400
+ updateImportance,
401
+ getRecentMemories,
402
+ getMemoriesByImportance,
403
+ getMemoriesBySurprise,
404
+ getMemoriesByType,
405
+ pruneOldMemories,
406
+ pruneByCount,
407
+ countMemories,
408
+ trackEntity,
409
+ getEntity,
410
+ getAllEntities,
411
+ };