@smallironman/mcp-memory-keeper 0.12.2-fork1

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +542 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1281 -0
  4. package/bin/mcp-memory-keeper +54 -0
  5. package/dist/__tests__/e2e/issue33-reproduce.test.js +234 -0
  6. package/dist/__tests__/e2e/server-e2e.test.js +341 -0
  7. package/dist/__tests__/helpers/database-test-helper.js +160 -0
  8. package/dist/__tests__/helpers/test-server.js +92 -0
  9. package/dist/__tests__/integration/advanced-features.test.js +614 -0
  10. package/dist/__tests__/integration/backward-compatibility.test.js +245 -0
  11. package/dist/__tests__/integration/batchOperationsE2E.test.js +396 -0
  12. package/dist/__tests__/integration/batchOperationsHandler.test.js +1230 -0
  13. package/dist/__tests__/integration/channelManagementHandler.test.js +1291 -0
  14. package/dist/__tests__/integration/channels.test.js +376 -0
  15. package/dist/__tests__/integration/checkpoint.test.js +251 -0
  16. package/dist/__tests__/integration/concurrent-access.test.js +190 -0
  17. package/dist/__tests__/integration/context-operations.test.js +243 -0
  18. package/dist/__tests__/integration/contextDiff.test.js +852 -0
  19. package/dist/__tests__/integration/contextDiffHandler.test.js +976 -0
  20. package/dist/__tests__/integration/contextExportHandler.test.js +510 -0
  21. package/dist/__tests__/integration/contextGetPaginationDefaults.test.js +298 -0
  22. package/dist/__tests__/integration/contextReassignChannelHandler.test.js +908 -0
  23. package/dist/__tests__/integration/contextRelationshipsHandler.test.js +1151 -0
  24. package/dist/__tests__/integration/contextSearch.test.js +1054 -0
  25. package/dist/__tests__/integration/contextSearchHandler.test.js +552 -0
  26. package/dist/__tests__/integration/contextWatchActual.test.js +165 -0
  27. package/dist/__tests__/integration/contextWatchHandler.test.js +1500 -0
  28. package/dist/__tests__/integration/database-initialization.test.js +134 -0
  29. package/dist/__tests__/integration/enhanced-context-operations.test.js +1082 -0
  30. package/dist/__tests__/integration/enhancedContextGetHandler.test.js +915 -0
  31. package/dist/__tests__/integration/enhancedContextTimelineHandler.test.js +716 -0
  32. package/dist/__tests__/integration/error-cases.test.js +411 -0
  33. package/dist/__tests__/integration/export-import.test.js +367 -0
  34. package/dist/__tests__/integration/feature-flags.test.js +542 -0
  35. package/dist/__tests__/integration/file-operations.test.js +264 -0
  36. package/dist/__tests__/integration/filterBySessionId.test.js +251 -0
  37. package/dist/__tests__/integration/git-integration.test.js +241 -0
  38. package/dist/__tests__/integration/index-tools.test.js +496 -0
  39. package/dist/__tests__/integration/issue11-actual-bug-demo.test.js +304 -0
  40. package/dist/__tests__/integration/issue11-search-filters-bug.test.js +561 -0
  41. package/dist/__tests__/integration/issue12-checkpoint-restore-behavior.test.js +621 -0
  42. package/dist/__tests__/integration/issue13-key-validation.test.js +433 -0
  43. package/dist/__tests__/integration/issue24-final-fix.test.js +241 -0
  44. package/dist/__tests__/integration/issue24-fix-validation.test.js +158 -0
  45. package/dist/__tests__/integration/issue24-reproduce.test.js +225 -0
  46. package/dist/__tests__/integration/issue24-token-limit.test.js +199 -0
  47. package/dist/__tests__/integration/issue33-array-items-schema.test.js +165 -0
  48. package/dist/__tests__/integration/knowledge-graph.test.js +338 -0
  49. package/dist/__tests__/integration/migrations.test.js +528 -0
  50. package/dist/__tests__/integration/multi-agent.test.js +546 -0
  51. package/dist/__tests__/integration/pagination-critical-fix.test.js +296 -0
  52. package/dist/__tests__/integration/paginationDefaultsHandler.test.js +600 -0
  53. package/dist/__tests__/integration/project-directory.test.js +291 -0
  54. package/dist/__tests__/integration/resource-cleanup.test.js +149 -0
  55. package/dist/__tests__/integration/retention.test.js +513 -0
  56. package/dist/__tests__/integration/search.test.js +333 -0
  57. package/dist/__tests__/integration/semantic-search.test.js +266 -0
  58. package/dist/__tests__/integration/server-initialization.test.js +305 -0
  59. package/dist/__tests__/integration/session-management.test.js +219 -0
  60. package/dist/__tests__/integration/simplified-sharing.test.js +346 -0
  61. package/dist/__tests__/integration/smart-compaction.test.js +230 -0
  62. package/dist/__tests__/integration/summarization.test.js +308 -0
  63. package/dist/__tests__/integration/tokenLimitEnforcement.test.js +134 -0
  64. package/dist/__tests__/integration/tool-profiles-integration.test.js +150 -0
  65. package/dist/__tests__/integration/watcher-migration-validation.test.js +544 -0
  66. package/dist/__tests__/security/input-validation.test.js +115 -0
  67. package/dist/__tests__/utils/agents.test.js +473 -0
  68. package/dist/__tests__/utils/database.test.js +177 -0
  69. package/dist/__tests__/utils/git.test.js +122 -0
  70. package/dist/__tests__/utils/knowledge-graph.test.js +297 -0
  71. package/dist/__tests__/utils/migrationHealthCheck.test.js +302 -0
  72. package/dist/__tests__/utils/project-directory-messages.test.js +192 -0
  73. package/dist/__tests__/utils/timezone-safe-dates.js +119 -0
  74. package/dist/__tests__/utils/token-limits.test.js +225 -0
  75. package/dist/__tests__/utils/tool-profiles.test.js +374 -0
  76. package/dist/__tests__/utils/validation.test.js +200 -0
  77. package/dist/__tests__/utils/vector-store.test.js +231 -0
  78. package/dist/handlers/contextWatchHandlers.js +206 -0
  79. package/dist/index.js +4425 -0
  80. package/dist/migrations/003_add_channels.js +174 -0
  81. package/dist/migrations/004_add_context_watch.js +151 -0
  82. package/dist/migrations/005_add_context_watch.js +98 -0
  83. package/dist/migrations/simplify-sharing.js +117 -0
  84. package/dist/repositories/BaseRepository.js +30 -0
  85. package/dist/repositories/CheckpointRepository.js +140 -0
  86. package/dist/repositories/ContextRepository.js +2017 -0
  87. package/dist/repositories/FileRepository.js +104 -0
  88. package/dist/repositories/RepositoryManager.js +62 -0
  89. package/dist/repositories/SessionRepository.js +66 -0
  90. package/dist/repositories/WatcherRepository.js +252 -0
  91. package/dist/repositories/index.js +15 -0
  92. package/dist/test-helpers/database-helper.js +128 -0
  93. package/dist/types/entities.js +3 -0
  94. package/dist/utils/agents.js +791 -0
  95. package/dist/utils/channels.js +150 -0
  96. package/dist/utils/database.js +780 -0
  97. package/dist/utils/feature-flags.js +476 -0
  98. package/dist/utils/git.js +145 -0
  99. package/dist/utils/knowledge-graph.js +264 -0
  100. package/dist/utils/migrationHealthCheck.js +373 -0
  101. package/dist/utils/migrations.js +452 -0
  102. package/dist/utils/retention.js +460 -0
  103. package/dist/utils/timestamps.js +112 -0
  104. package/dist/utils/token-limits.js +350 -0
  105. package/dist/utils/tool-profiles.js +242 -0
  106. package/dist/utils/validation.js +296 -0
  107. package/dist/utils/vector-store.js +247 -0
  108. package/examples/config.json +31 -0
  109. package/examples/project-directory-setup.md +114 -0
  110. package/package.json +85 -0
@@ -0,0 +1,2017 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ContextRepository = void 0;
4
+ const BaseRepository_js_1 = require("./BaseRepository.js");
5
+ const timestamps_js_1 = require("../utils/timestamps.js");
6
+ const validation_js_1 = require("../utils/validation.js");
7
+ class ContextRepository extends BaseRepository_js_1.BaseRepository {
8
+ // Constants
9
+ static SQLITE_ESCAPE_CHAR = '\\';
10
+ // Helper methods for DRY code
11
+ buildSortClause(sort) {
12
+ const sortMap = {
13
+ created_desc: 'created_at DESC',
14
+ created_at_desc: 'created_at DESC',
15
+ created_asc: 'created_at ASC',
16
+ created_at_asc: 'created_at ASC',
17
+ updated_desc: 'updated_at DESC',
18
+ updated_at_desc: 'updated_at DESC',
19
+ updated_at_asc: 'updated_at ASC',
20
+ key_asc: 'key ASC',
21
+ key_desc: 'key DESC',
22
+ };
23
+ const defaultSort = sort?.includes('priority')
24
+ ? 'priority DESC, created_at DESC'
25
+ : 'created_at DESC';
26
+ return sortMap[sort || ''] || defaultSort;
27
+ }
28
+ parseRelativeTime(relativeTime) {
29
+ const now = new Date();
30
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
31
+ if (relativeTime === 'today') {
32
+ return today.toISOString();
33
+ }
34
+ else if (relativeTime === 'yesterday') {
35
+ return new Date(today.getTime() - 24 * 60 * 60 * 1000).toISOString();
36
+ }
37
+ else if (relativeTime.match(/^(\d+) hours? ago$/)) {
38
+ const hours = parseInt(relativeTime.match(/^(\d+)/)[1]);
39
+ return new Date(now.getTime() - hours * 60 * 60 * 1000).toISOString();
40
+ }
41
+ else if (relativeTime.match(/^(\d+) days? ago$/)) {
42
+ const days = parseInt(relativeTime.match(/^(\d+)/)[1]);
43
+ return new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString();
44
+ }
45
+ else if (relativeTime === 'this week') {
46
+ const startOfWeek = new Date(today);
47
+ startOfWeek.setDate(today.getDate() - today.getDay());
48
+ return startOfWeek.toISOString();
49
+ }
50
+ else if (relativeTime === 'last week') {
51
+ const startOfLastWeek = new Date(today);
52
+ startOfLastWeek.setDate(today.getDate() - today.getDay() - 7);
53
+ return startOfLastWeek.toISOString();
54
+ }
55
+ return null;
56
+ }
57
+ convertToGlobPattern(pattern) {
58
+ // SQLite GLOB supports character classes with brackets, so preserve them
59
+ // Only convert regex-style patterns to GLOB
60
+ return pattern
61
+ .replace(/\./g, '?') // . -> single char
62
+ .replace(/^\^/, '') // Remove start anchor
63
+ .replace(/\$$/, ''); // Remove end anchor
64
+ // Note: * and [...] are already GLOB syntax, so we keep them as-is
65
+ }
66
+ addPaginationToQuery(sql, params, limit, offset) {
67
+ let modifiedSql = sql;
68
+ if (limit) {
69
+ modifiedSql += ' LIMIT ?';
70
+ params.push(limit);
71
+ }
72
+ if (offset && offset > 0) {
73
+ modifiedSql += ' OFFSET ?';
74
+ params.push(offset);
75
+ }
76
+ return modifiedSql;
77
+ }
78
+ getTotalCount(baseSql, params) {
79
+ const countSql = baseSql.replace('SELECT *', 'SELECT COUNT(*) as count');
80
+ const countStmt = this.db.prepare(countSql);
81
+ const countResult = countStmt.get(...params);
82
+ return countResult.count || 0;
83
+ }
84
+ save(sessionId, input) {
85
+ // Validate the key
86
+ const validatedKey = (0, validation_js_1.validateKey)(input.key);
87
+ const id = this.generateId();
88
+ const size = this.calculateSize(input.value);
89
+ // Determine channel - use explicit channel, or session default, or 'general'
90
+ let channel = input.channel;
91
+ if (!channel) {
92
+ const sessionStmt = this.db.prepare('SELECT default_channel FROM sessions WHERE id = ?');
93
+ const session = sessionStmt.get(sessionId);
94
+ channel = session?.default_channel || 'general';
95
+ }
96
+ const stmt = this.db.prepare(`
97
+ INSERT OR REPLACE INTO context_items
98
+ (id, session_id, key, value, category, priority, metadata, size, is_private, channel)
99
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
100
+ `);
101
+ stmt.run(id, sessionId, validatedKey, input.value, input.category || null, input.priority || 'normal', input.metadata || null, size, input.isPrivate ? 1 : 0, channel);
102
+ return this.getById(id);
103
+ }
104
+ getById(id) {
105
+ const stmt = this.db.prepare('SELECT * FROM context_items WHERE id = ?');
106
+ return stmt.get(id);
107
+ }
108
+ getBySessionId(sessionId) {
109
+ const stmt = this.db.prepare(`
110
+ SELECT * FROM context_items
111
+ WHERE session_id = ?
112
+ ORDER BY priority DESC, created_at DESC
113
+ `);
114
+ return stmt.all(sessionId);
115
+ }
116
+ getByKey(sessionId, key) {
117
+ const stmt = this.db.prepare(`
118
+ SELECT * FROM context_items
119
+ WHERE session_id = ? AND key = ?
120
+ `);
121
+ return stmt.get(sessionId, key);
122
+ }
123
+ getByCategory(sessionId, category) {
124
+ const stmt = this.db.prepare(`
125
+ SELECT * FROM context_items
126
+ WHERE session_id = ? AND category = ?
127
+ ORDER BY priority DESC, created_at DESC
128
+ `);
129
+ return stmt.all(sessionId, category);
130
+ }
131
+ getByPriority(sessionId, priority) {
132
+ const stmt = this.db.prepare(`
133
+ SELECT * FROM context_items
134
+ WHERE session_id = ? AND priority = ?
135
+ ORDER BY created_at DESC
136
+ `);
137
+ return stmt.all(sessionId, priority);
138
+ }
139
+ search(query, sessionId, includePrivate = false) {
140
+ let sql = `
141
+ SELECT * FROM context_items
142
+ WHERE (key LIKE ? OR value LIKE ?)
143
+ `;
144
+ const params = [`%${query}%`, `%${query}%`];
145
+ if (sessionId) {
146
+ if (includePrivate) {
147
+ sql += ' AND (is_private = 0 OR session_id = ?)';
148
+ params.push(sessionId);
149
+ }
150
+ else {
151
+ sql += ' AND is_private = 0';
152
+ }
153
+ }
154
+ else {
155
+ sql += ' AND is_private = 0';
156
+ }
157
+ sql += ' ORDER BY priority DESC, created_at DESC';
158
+ const stmt = this.db.prepare(sql);
159
+ return stmt.all(...params);
160
+ }
161
+ // Enhanced search method with all new parameters
162
+ searchEnhanced(options) {
163
+ const { query, sessionId, searchIn = ['key', 'value'], category, channel, channels, sort = 'created_desc', limit, offset = 0, createdAfter, createdBefore, keyPattern, priorities, matchMode = 'and', useFts5 = false, } = options;
164
+ // FTS5 branch: trigram full-text search with BM25 ranking.
165
+ // Falls back to LIKE when any term is < 3 Unicode chars (trigram minimum).
166
+ if (useFts5 && query) {
167
+ const terms = query
168
+ .trim()
169
+ .split(/\s+/)
170
+ .filter(t => t.length > 0);
171
+ const hasShortTerm = terms.some(t => [...t].length < 3);
172
+ if (!hasShortTerm && terms.length > 0) {
173
+ const ftsConjunction = matchMode === 'or' ? ' OR ' : ' ';
174
+ const ftsQuery = terms.map(t => `"${t.replace(/"/g, '""')}"`).join(ftsConjunction);
175
+ let ftsSql = `
176
+ SELECT ci.* FROM context_items ci
177
+ JOIN context_items_fts fts ON ci.rowid = fts.rowid
178
+ WHERE context_items_fts MATCH ?
179
+ AND (ci.is_private = 0 OR ci.session_id = ?)
180
+ `;
181
+ const ftsParams = [ftsQuery, sessionId];
182
+ if (category) {
183
+ ftsSql += ' AND ci.category = ?';
184
+ ftsParams.push(category);
185
+ }
186
+ if (channel) {
187
+ ftsSql += ' AND ci.channel = ?';
188
+ ftsParams.push(channel);
189
+ }
190
+ if (channels && channels.length > 0) {
191
+ ftsSql += ` AND ci.channel IN (${channels.map(() => '?').join(',')})`;
192
+ ftsParams.push(...channels);
193
+ }
194
+ if (priorities && priorities.length > 0) {
195
+ ftsSql += ` AND ci.priority IN (${priorities.map(() => '?').join(',')})`;
196
+ ftsParams.push(...priorities);
197
+ }
198
+ const countResult = this.db
199
+ .prepare(ftsSql.replace('SELECT ci.*', 'SELECT COUNT(*) as count'))
200
+ .get(...ftsParams);
201
+ const totalCount = countResult?.count || 0;
202
+ ftsSql += ' ORDER BY bm25(context_items_fts)'; // bm25 is negative; ASC = most relevant first
203
+ ftsSql = this.addPaginationToQuery(ftsSql, ftsParams, limit, offset);
204
+ try {
205
+ const items = this.db.prepare(ftsSql).all(...ftsParams);
206
+ return { items, totalCount };
207
+ }
208
+ catch (_ftsErr) {
209
+ // FTS5 table not available; fall through to LIKE search
210
+ }
211
+ }
212
+ }
213
+ // Build the base query with proper privacy filtering
214
+ let sql = `
215
+ SELECT * FROM context_items
216
+ WHERE (is_private = 0 OR session_id = ?)
217
+ `;
218
+ const params = [sessionId];
219
+ // Add search query with searchIn support — split on whitespace for multi-word AND/OR
220
+ if (query) {
221
+ const conjunction = matchMode === 'or' ? ' OR ' : ' AND ';
222
+ const terms = query
223
+ .trim()
224
+ .split(/\s+/)
225
+ .filter(t => t.length > 0);
226
+ const termClauses = [];
227
+ for (const term of terms) {
228
+ const escaped = term.replace(/[%_\\]/g, `${ContextRepository.SQLITE_ESCAPE_CHAR}$&`);
229
+ const fieldConds = [];
230
+ if (searchIn.includes('key')) {
231
+ fieldConds.push(`key LIKE ? ESCAPE '${ContextRepository.SQLITE_ESCAPE_CHAR}'`);
232
+ params.push(`%${escaped}%`);
233
+ }
234
+ if (searchIn.includes('value')) {
235
+ fieldConds.push(`value LIKE ? ESCAPE '${ContextRepository.SQLITE_ESCAPE_CHAR}'`);
236
+ params.push(`%${escaped}%`);
237
+ }
238
+ if (fieldConds.length > 0) {
239
+ termClauses.push(`(${fieldConds.join(' OR ')})`);
240
+ }
241
+ }
242
+ if (termClauses.length > 0) {
243
+ sql += ` AND (${termClauses.join(conjunction)})`;
244
+ }
245
+ }
246
+ // Add filters
247
+ if (category) {
248
+ sql += ' AND category = ?';
249
+ params.push(category);
250
+ }
251
+ if (channel) {
252
+ sql += ' AND channel = ?';
253
+ params.push(channel);
254
+ }
255
+ if (channels && channels.length > 0) {
256
+ sql += ` AND channel IN (${channels.map(() => '?').join(',')})`;
257
+ params.push(...channels);
258
+ }
259
+ // Handle relative time parsing for createdAfter
260
+ if (createdAfter) {
261
+ const parsedDate = this.parseRelativeTime(createdAfter);
262
+ const effectiveDate = parsedDate || createdAfter;
263
+ sql += ' AND created_at > ?';
264
+ params.push(effectiveDate);
265
+ }
266
+ // Handle relative time parsing for createdBefore
267
+ if (createdBefore) {
268
+ let effectiveDate = createdBefore;
269
+ // Special handling for "today" and "yesterday" for createdBefore
270
+ const now = new Date();
271
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
272
+ if (createdBefore === 'today') {
273
+ effectiveDate = today.toISOString(); // Start of today
274
+ }
275
+ else if (createdBefore === 'yesterday') {
276
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
277
+ effectiveDate = yesterday.toISOString(); // Start of yesterday
278
+ }
279
+ else {
280
+ const parsedDate = this.parseRelativeTime(createdBefore);
281
+ if (parsedDate) {
282
+ effectiveDate = parsedDate;
283
+ }
284
+ }
285
+ sql += ' AND created_at < ?';
286
+ params.push(effectiveDate);
287
+ }
288
+ if (keyPattern) {
289
+ const globPattern = this.convertToGlobPattern(keyPattern);
290
+ sql += ' AND key GLOB ?';
291
+ params.push(globPattern);
292
+ }
293
+ if (priorities && priorities.length > 0) {
294
+ sql += ` AND priority IN (${priorities.map(() => '?').join(',')})`;
295
+ params.push(...priorities);
296
+ }
297
+ // Count total before pagination
298
+ const totalCount = this.getTotalCount(sql, params);
299
+ // Add sorting
300
+ sql += ` ORDER BY ${this.buildSortClause(sort)}`;
301
+ // Add pagination
302
+ sql = this.addPaginationToQuery(sql, params, limit, offset);
303
+ const stmt = this.db.prepare(sql);
304
+ const items = stmt.all(...params);
305
+ return { items, totalCount };
306
+ }
307
+ update(id, updates) {
308
+ const fieldsToUpdate = { ...updates };
309
+ const setClause = Object.keys(fieldsToUpdate)
310
+ .filter(key => key !== 'id' && key !== 'session_id' && key !== 'created_at')
311
+ .map(key => `${key} = ?`)
312
+ .join(', ');
313
+ if (setClause) {
314
+ const values = Object.keys(fieldsToUpdate)
315
+ .filter(key => key !== 'id' && key !== 'session_id' && key !== 'created_at')
316
+ .map(key => fieldsToUpdate[key]);
317
+ const stmt = this.db.prepare(`
318
+ UPDATE context_items
319
+ SET ${setClause}
320
+ WHERE id = ?
321
+ `);
322
+ stmt.run(...values, id);
323
+ }
324
+ }
325
+ delete(id) {
326
+ const stmt = this.db.prepare('DELETE FROM context_items WHERE id = ?');
327
+ stmt.run(id);
328
+ }
329
+ deleteBySessionId(sessionId) {
330
+ const stmt = this.db.prepare('DELETE FROM context_items WHERE session_id = ?');
331
+ stmt.run(sessionId);
332
+ }
333
+ deleteByKey(sessionId, key) {
334
+ const stmt = this.db.prepare('DELETE FROM context_items WHERE session_id = ? AND key = ?');
335
+ stmt.run(sessionId, key);
336
+ }
337
+ copyBetweenSessions(fromSessionId, toSessionId) {
338
+ const stmt = this.db.prepare(`
339
+ INSERT OR IGNORE INTO context_items (id, session_id, key, value, category, priority, metadata, size, is_private, channel, created_at, updated_at)
340
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
341
+ `);
342
+ const items = this.getBySessionId(fromSessionId);
343
+ let copied = 0;
344
+ for (const item of items) {
345
+ try {
346
+ stmt.run(this.generateId(), toSessionId, item.key, item.value, item.category, item.priority, item.metadata, item.size, item.is_private, item.channel || 'general', item.created_at);
347
+ copied++;
348
+ }
349
+ catch (_error) {
350
+ // Skip items that would cause unique constraint violations
351
+ console.warn(`Skipping duplicate key '${item.key}' when copying to session ${toSessionId}`);
352
+ }
353
+ }
354
+ return copied;
355
+ }
356
+ // Get items accessible from a specific session (all public items + own private items)
357
+ getAccessibleItems(sessionId, options) {
358
+ let sql = `
359
+ SELECT * FROM context_items
360
+ WHERE (is_private = 0 OR session_id = ?)
361
+ `;
362
+ const params = [sessionId];
363
+ if (options?.key) {
364
+ sql += ' AND key = ?';
365
+ params.push(options.key);
366
+ }
367
+ if (options?.category) {
368
+ sql += ' AND category = ?';
369
+ params.push(options.category);
370
+ }
371
+ sql += ' ORDER BY priority DESC, created_at DESC';
372
+ const stmt = this.db.prepare(sql);
373
+ return stmt.all(...params);
374
+ }
375
+ // Get a specific item by key, respecting privacy
376
+ getAccessibleByKey(sessionId, key) {
377
+ const stmt = this.db.prepare(`
378
+ SELECT * FROM context_items
379
+ WHERE key = ? AND (is_private = 0 OR session_id = ?)
380
+ ORDER BY
381
+ CASE WHEN session_id = ? THEN 0 ELSE 1 END, -- Prioritize own session's items
382
+ created_at DESC
383
+ LIMIT 1
384
+ `);
385
+ const result = stmt.get(key, sessionId, sessionId);
386
+ return result || null;
387
+ }
388
+ searchAcrossSessions(query, currentSessionId) {
389
+ let sql = `
390
+ SELECT * FROM context_items
391
+ WHERE (key LIKE ? OR value LIKE ?) AND is_private = 0
392
+ `;
393
+ const params = [`%${query}%`, `%${query}%`];
394
+ // Include private items from current session if provided
395
+ if (currentSessionId) {
396
+ sql = `
397
+ SELECT * FROM context_items
398
+ WHERE (key LIKE ? OR value LIKE ?)
399
+ AND (is_private = 0 OR session_id = ?)
400
+ `;
401
+ params.push(currentSessionId);
402
+ }
403
+ sql += ' ORDER BY priority DESC, created_at DESC';
404
+ const stmt = this.db.prepare(sql);
405
+ return stmt.all(...params);
406
+ }
407
+ // Enhanced search across sessions with pagination support
408
+ searchAcrossSessionsEnhanced(options) {
409
+ const { query, currentSessionId, sessions, includeShared = true, searchIn = ['key', 'value'], limit = 25, // Default pagination limit
410
+ offset = 0, sort = 'created_desc', category, channel, channels, priorities, createdAfter, createdBefore, keyPattern, matchMode = 'and', useFts5 = false, } = options;
411
+ // Validate pagination parameters
412
+ const validLimit = Math.min(Math.max(1, limit), 100); // 1-100 range
413
+ const validOffset = Math.max(0, offset);
414
+ // FTS5 branch for cross-session search
415
+ if (useFts5 && query) {
416
+ const terms = query
417
+ .trim()
418
+ .split(/\s+/)
419
+ .filter(t => t.length > 0);
420
+ const hasShortTerm = terms.some(t => [...t].length < 3);
421
+ if (!hasShortTerm && terms.length > 0) {
422
+ const ftsConjunction = matchMode === 'or' ? ' OR ' : ' ';
423
+ const ftsQuery = terms.map(t => `"${t.replace(/"/g, '""')}"`).join(ftsConjunction);
424
+ let ftsSql = `SELECT ci.* FROM context_items ci JOIN context_items_fts fts ON ci.rowid = fts.rowid WHERE context_items_fts MATCH ?`;
425
+ const ftsParams = [ftsQuery];
426
+ if (currentSessionId && includeShared) {
427
+ ftsSql += ' AND (ci.is_private = 0 OR ci.session_id = ?)';
428
+ ftsParams.push(currentSessionId);
429
+ }
430
+ else {
431
+ ftsSql += ' AND ci.is_private = 0';
432
+ }
433
+ if (sessions && sessions.length > 0) {
434
+ ftsSql += ` AND ci.session_id IN (${sessions.map(() => '?').join(',')})`;
435
+ ftsParams.push(...sessions);
436
+ }
437
+ if (category) {
438
+ ftsSql += ' AND ci.category = ?';
439
+ ftsParams.push(category);
440
+ }
441
+ if (channel) {
442
+ ftsSql += ' AND ci.channel = ?';
443
+ ftsParams.push(channel);
444
+ }
445
+ if (channels && channels.length > 0) {
446
+ ftsSql += ` AND ci.channel IN (${channels.map(() => '?').join(',')})`;
447
+ ftsParams.push(...channels);
448
+ }
449
+ if (priorities && priorities.length > 0) {
450
+ ftsSql += ` AND ci.priority IN (${priorities.map(() => '?').join(',')})`;
451
+ ftsParams.push(...priorities);
452
+ }
453
+ try {
454
+ const countResult = this.db
455
+ .prepare(ftsSql.replace('SELECT ci.*', 'SELECT COUNT(*) as count'))
456
+ .get(...ftsParams);
457
+ const totalCount = countResult?.count || 0;
458
+ ftsSql += ' ORDER BY bm25(context_items_fts)';
459
+ ftsSql = this.addPaginationToQuery(ftsSql, ftsParams, validLimit, validOffset);
460
+ const items = this.db.prepare(ftsSql).all(...ftsParams);
461
+ const totalPages = Math.ceil(totalCount / validLimit);
462
+ const currentPage = Math.floor(validOffset / validLimit) + 1;
463
+ return {
464
+ items,
465
+ totalCount,
466
+ pagination: {
467
+ currentPage,
468
+ totalPages,
469
+ totalItems: totalCount,
470
+ itemsPerPage: validLimit,
471
+ hasNextPage: currentPage < totalPages,
472
+ hasPreviousPage: currentPage > 1,
473
+ nextOffset: currentPage < totalPages ? validOffset + validLimit : null,
474
+ previousOffset: currentPage > 1 ? Math.max(0, validOffset - validLimit) : null,
475
+ },
476
+ };
477
+ }
478
+ catch (_ftsErr) {
479
+ // FTS5 table not available; fall through to LIKE search
480
+ }
481
+ }
482
+ }
483
+ // Build the base query for cross-session search
484
+ let sql = `
485
+ SELECT * FROM context_items
486
+ WHERE 1=1
487
+ `;
488
+ const params = [];
489
+ // Handle privacy filtering
490
+ if (currentSessionId && includeShared) {
491
+ sql += ' AND (is_private = 0 OR session_id = ?)';
492
+ params.push(currentSessionId);
493
+ }
494
+ else if (includeShared) {
495
+ sql += ' AND is_private = 0';
496
+ }
497
+ else if (currentSessionId) {
498
+ sql += ' AND session_id = ?';
499
+ params.push(currentSessionId);
500
+ }
501
+ else {
502
+ sql += ' AND is_private = 0';
503
+ }
504
+ // Session filtering
505
+ if (sessions && sessions.length > 0) {
506
+ sql += ` AND session_id IN (${sessions.map(() => '?').join(',')})`;
507
+ params.push(...sessions);
508
+ }
509
+ // Add search query with searchIn support — split on whitespace for multi-word AND/OR
510
+ if (query) {
511
+ const conjunction = matchMode === 'or' ? ' OR ' : ' AND ';
512
+ const terms = query
513
+ .trim()
514
+ .split(/\s+/)
515
+ .filter(t => t.length > 0);
516
+ const termClauses = [];
517
+ for (const term of terms) {
518
+ const escaped = term.replace(/[%_\\]/g, `${ContextRepository.SQLITE_ESCAPE_CHAR}$&`);
519
+ const fieldConds = [];
520
+ if (searchIn.includes('key')) {
521
+ fieldConds.push(`key LIKE ? ESCAPE '${ContextRepository.SQLITE_ESCAPE_CHAR}'`);
522
+ params.push(`%${escaped}%`);
523
+ }
524
+ if (searchIn.includes('value')) {
525
+ fieldConds.push(`value LIKE ? ESCAPE '${ContextRepository.SQLITE_ESCAPE_CHAR}'`);
526
+ params.push(`%${escaped}%`);
527
+ }
528
+ if (fieldConds.length > 0) {
529
+ termClauses.push(`(${fieldConds.join(' OR ')})`);
530
+ }
531
+ }
532
+ if (termClauses.length > 0) {
533
+ sql += ` AND (${termClauses.join(conjunction)})`;
534
+ }
535
+ }
536
+ // Add filters (same pattern as searchEnhanced)
537
+ if (category) {
538
+ sql += ' AND category = ?';
539
+ params.push(category);
540
+ }
541
+ if (channel) {
542
+ sql += ' AND channel = ?';
543
+ params.push(channel);
544
+ }
545
+ if (channels && channels.length > 0) {
546
+ sql += ` AND channel IN (${channels.map(() => '?').join(',')})`;
547
+ params.push(...channels);
548
+ }
549
+ if (createdAfter) {
550
+ const parsedDate = this.parseRelativeTime(createdAfter);
551
+ const effectiveDate = parsedDate || createdAfter;
552
+ sql += ' AND created_at > ?';
553
+ params.push(effectiveDate);
554
+ }
555
+ if (createdBefore) {
556
+ let effectiveDate = createdBefore;
557
+ const now = new Date();
558
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
559
+ if (createdBefore === 'today') {
560
+ effectiveDate = today.toISOString();
561
+ }
562
+ else if (createdBefore === 'yesterday') {
563
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
564
+ effectiveDate = yesterday.toISOString();
565
+ }
566
+ else {
567
+ const parsedDate = this.parseRelativeTime(createdBefore);
568
+ if (parsedDate) {
569
+ effectiveDate = parsedDate;
570
+ }
571
+ }
572
+ sql += ' AND created_at < ?';
573
+ params.push(effectiveDate);
574
+ }
575
+ if (keyPattern) {
576
+ const globPattern = this.convertToGlobPattern(keyPattern);
577
+ sql += ' AND key GLOB ?';
578
+ params.push(globPattern);
579
+ }
580
+ if (priorities && priorities.length > 0) {
581
+ sql += ` AND priority IN (${priorities.map(() => '?').join(',')})`;
582
+ params.push(...priorities);
583
+ }
584
+ // Count total before pagination
585
+ const totalCount = this.getTotalCount(sql, params);
586
+ // Add sorting
587
+ sql += ` ORDER BY ${this.buildSortClause(sort)}`;
588
+ // Add pagination
589
+ sql = this.addPaginationToQuery(sql, params, validLimit, validOffset);
590
+ const stmt = this.db.prepare(sql);
591
+ const items = stmt.all(...params);
592
+ // Calculate pagination metadata
593
+ const totalPages = Math.ceil(totalCount / validLimit);
594
+ const currentPage = Math.floor(validOffset / validLimit) + 1;
595
+ const hasNextPage = currentPage < totalPages;
596
+ const hasPreviousPage = currentPage > 1;
597
+ const nextOffset = hasNextPage ? validOffset + validLimit : null;
598
+ const previousOffset = hasPreviousPage ? Math.max(0, validOffset - validLimit) : null;
599
+ const pagination = {
600
+ currentPage,
601
+ totalPages,
602
+ totalItems: totalCount,
603
+ itemsPerPage: validLimit,
604
+ hasNextPage,
605
+ hasPreviousPage,
606
+ nextOffset,
607
+ previousOffset,
608
+ };
609
+ return { items, totalCount, pagination };
610
+ }
611
+ getStatsBySession(sessionId) {
612
+ const countStmt = this.db.prepare('SELECT COUNT(*) as count, SUM(size) as totalSize FROM context_items WHERE session_id = ?');
613
+ const result = countStmt.get(sessionId);
614
+ const categoryStmt = this.db.prepare(`
615
+ SELECT category, COUNT(*) as count
616
+ FROM context_items
617
+ WHERE session_id = ?
618
+ GROUP BY category
619
+ `);
620
+ const categories = categoryStmt.all(sessionId);
621
+ const priorityStmt = this.db.prepare(`
622
+ SELECT priority, COUNT(*) as count
623
+ FROM context_items
624
+ WHERE session_id = ?
625
+ GROUP BY priority
626
+ `);
627
+ const priorities = priorityStmt.all(sessionId);
628
+ return {
629
+ count: result.count || 0,
630
+ totalSize: result.totalSize || 0,
631
+ byCategory: categories.reduce((acc, cat) => {
632
+ acc[cat.category || 'uncategorized'] = cat.count;
633
+ return acc;
634
+ }, {}),
635
+ byPriority: priorities.reduce((acc, pri) => {
636
+ acc[pri.priority] = pri.count;
637
+ return acc;
638
+ }, {}),
639
+ };
640
+ }
641
+ // Get items by channel
642
+ getByChannel(sessionId, channel) {
643
+ const stmt = this.db.prepare(`
644
+ SELECT * FROM context_items
645
+ WHERE session_id = ? AND channel = ?
646
+ ORDER BY priority DESC, created_at DESC
647
+ `);
648
+ return stmt.all(sessionId, channel);
649
+ }
650
+ // Get items by multiple channels
651
+ getByChannels(sessionId, channels) {
652
+ if (channels.length === 0) {
653
+ return [];
654
+ }
655
+ const placeholders = channels.map(() => '?').join(',');
656
+ const stmt = this.db.prepare(`
657
+ SELECT * FROM context_items
658
+ WHERE session_id = ? AND channel IN (${placeholders})
659
+ ORDER BY priority DESC, created_at DESC
660
+ `);
661
+ return stmt.all(sessionId, ...channels);
662
+ }
663
+ // Get items by channel across all sessions
664
+ getByChannelAcrossSessions(channel) {
665
+ const stmt = this.db.prepare(`
666
+ SELECT * FROM context_items
667
+ WHERE channel = ? AND is_private = 0
668
+ ORDER BY priority DESC, created_at DESC
669
+ `);
670
+ return stmt.all(channel);
671
+ }
672
+ // Enhanced query method with all new parameters
673
+ queryEnhanced(options) {
674
+ const { sessionId, filterBySessionId, key, category, channel, channels, sort, limit, offset = 0, createdAfter, createdBefore, keyPattern, priorities, } = options;
675
+ // Apply default pagination parameters
676
+ // Default sort: created_desc (most recent first)
677
+ const effectiveSort = sort || 'created_desc';
678
+ // Default limit: 100 items (or unlimited if explicitly set to 0)
679
+ // Negative limits are treated as default
680
+ // Invalid types are treated as default
681
+ let effectiveLimit;
682
+ const numericLimit = typeof limit === 'number' ? limit : undefined;
683
+ if (numericLimit === 0) {
684
+ effectiveLimit = undefined; // Unlimited
685
+ }
686
+ else if (numericLimit === undefined || numericLimit < 0) {
687
+ effectiveLimit = 100; // Default limit
688
+ }
689
+ else {
690
+ effectiveLimit = numericLimit; // Use provided limit
691
+ }
692
+ // Validate offset is not negative
693
+ const validOffset = Math.max(0, offset || 0);
694
+ // Build the base query with proper privacy filtering
695
+ let sql = `
696
+ SELECT * FROM context_items
697
+ WHERE (is_private = 0 OR session_id = ?)
698
+ `;
699
+ const params = [sessionId];
700
+ // Add filters
701
+ // Filter by specific session if requested
702
+ if (filterBySessionId) {
703
+ sql += ' AND session_id = ?';
704
+ params.push(filterBySessionId);
705
+ }
706
+ if (key) {
707
+ sql += ' AND key = ?';
708
+ params.push(key);
709
+ }
710
+ if (category) {
711
+ sql += ' AND category = ?';
712
+ params.push(category);
713
+ }
714
+ if (channel) {
715
+ sql += ' AND channel = ?';
716
+ params.push(channel);
717
+ }
718
+ if (channels && channels.length > 0) {
719
+ sql += ` AND channel IN (${channels.map(() => '?').join(',')})`;
720
+ params.push(...channels);
721
+ }
722
+ // Handle relative time parsing for createdAfter
723
+ if (createdAfter) {
724
+ const parsedDate = this.parseRelativeTime(createdAfter);
725
+ const effectiveDate = parsedDate || createdAfter;
726
+ sql += ' AND created_at > ?';
727
+ params.push(effectiveDate);
728
+ }
729
+ // Handle relative time parsing for createdBefore
730
+ if (createdBefore) {
731
+ let effectiveDate = createdBefore;
732
+ // Special handling for "today" and "yesterday" for createdBefore
733
+ const now = new Date();
734
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
735
+ if (createdBefore === 'today') {
736
+ effectiveDate = today.toISOString(); // Start of today
737
+ }
738
+ else if (createdBefore === 'yesterday') {
739
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
740
+ effectiveDate = yesterday.toISOString(); // Start of yesterday
741
+ }
742
+ else {
743
+ const parsedDate = this.parseRelativeTime(createdBefore);
744
+ if (parsedDate) {
745
+ effectiveDate = parsedDate;
746
+ }
747
+ }
748
+ sql += ' AND created_at < ?';
749
+ params.push(effectiveDate);
750
+ }
751
+ if (keyPattern) {
752
+ const globPattern = this.convertToGlobPattern(keyPattern);
753
+ sql += ' AND key GLOB ?';
754
+ params.push(globPattern);
755
+ }
756
+ if (priorities && priorities.length > 0) {
757
+ sql += ` AND priority IN (${priorities.map(() => '?').join(',')})`;
758
+ params.push(...priorities);
759
+ }
760
+ // Count total before pagination
761
+ const totalCount = this.getTotalCount(sql, params);
762
+ // Add sorting
763
+ sql += ` ORDER BY ${this.buildSortClause(effectiveSort)}`;
764
+ // Add pagination
765
+ sql = this.addPaginationToQuery(sql, params, effectiveLimit, validOffset);
766
+ const stmt = this.db.prepare(sql);
767
+ const items = stmt.all(...params);
768
+ return { items, totalCount };
769
+ }
770
+ // Get timeline data with enhanced options
771
+ getTimelineData(options) {
772
+ const { sessionId, startDate, endDate, categories, relativeTime, itemsPerPeriod, includeItems, groupBy = 'day', minItemsPerPeriod, showEmpty = false, } = options;
773
+ // Calculate date range from relative time
774
+ let effectiveStartDate = startDate;
775
+ let effectiveEndDate = endDate;
776
+ if (relativeTime) {
777
+ const parsedStartDate = this.parseRelativeTime(relativeTime);
778
+ if (parsedStartDate) {
779
+ effectiveStartDate = parsedStartDate;
780
+ }
781
+ // Special handling for end dates
782
+ const now = new Date();
783
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
784
+ if (relativeTime === 'today') {
785
+ effectiveEndDate = new Date(today.getTime() + 24 * 60 * 60 * 1000).toISOString();
786
+ }
787
+ else if (relativeTime === 'yesterday') {
788
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
789
+ effectiveStartDate = yesterday.toISOString();
790
+ effectiveEndDate = today.toISOString();
791
+ }
792
+ else if (relativeTime === 'last week') {
793
+ const startOfLastWeek = new Date(today);
794
+ startOfLastWeek.setDate(today.getDate() - today.getDay() - 7);
795
+ const endOfLastWeek = new Date(startOfLastWeek);
796
+ endOfLastWeek.setDate(startOfLastWeek.getDate() + 7);
797
+ effectiveStartDate = startOfLastWeek.toISOString();
798
+ effectiveEndDate = endOfLastWeek.toISOString();
799
+ }
800
+ }
801
+ // Build query for timeline
802
+ let dateFmt = '%Y-%m-%d'; // day grouping
803
+ if (groupBy === 'hour') {
804
+ dateFmt = '%Y-%m-%d %H:00';
805
+ }
806
+ else if (groupBy === 'week') {
807
+ dateFmt = '%Y-W%W';
808
+ }
809
+ let sql = `
810
+ SELECT
811
+ strftime('${dateFmt}', created_at) as period,
812
+ COUNT(*) as count,
813
+ ${includeItems ? 'GROUP_CONCAT(id) as item_ids' : 'NULL as item_ids'}
814
+ FROM context_items
815
+ WHERE session_id = ?
816
+ `;
817
+ const params = [sessionId];
818
+ if (effectiveStartDate) {
819
+ sql += ' AND created_at >= ?';
820
+ params.push(effectiveStartDate);
821
+ }
822
+ if (effectiveEndDate) {
823
+ sql += ' AND created_at <= ?';
824
+ params.push(effectiveEndDate);
825
+ }
826
+ if (categories && categories.length > 0) {
827
+ sql += ` AND category IN (${categories.map(() => '?').join(',')})`;
828
+ params.push(...categories);
829
+ }
830
+ sql += ' GROUP BY period ORDER BY period DESC';
831
+ const stmt = this.db.prepare(sql);
832
+ let timeline = stmt.all(...params);
833
+ // Handle minItemsPerPeriod filter (before showEmpty logic)
834
+ if (minItemsPerPeriod && minItemsPerPeriod > 0 && !showEmpty) {
835
+ // Treat negative values as 0, round fractional values up
836
+ const minItems = Math.max(0, Math.ceil(minItemsPerPeriod));
837
+ timeline = timeline.filter(period => period.count >= minItems);
838
+ }
839
+ // Handle showEmpty - generate empty periods for date ranges
840
+ if (showEmpty && (effectiveStartDate || effectiveEndDate)) {
841
+ const existingPeriods = new Map(timeline.map(p => [p.period, p]));
842
+ const allPeriods = [];
843
+ // Determine date range
844
+ const start = effectiveStartDate ? new Date(effectiveStartDate) : new Date();
845
+ const end = effectiveEndDate ? new Date(effectiveEndDate) : new Date();
846
+ // Return empty array if end is before start
847
+ if (end < start) {
848
+ return [];
849
+ }
850
+ // Generate all periods in range
851
+ const current = new Date(start);
852
+ let periodCount = 0;
853
+ const maxPeriods = groupBy === 'hour' ? 24 * 365 : 365; // Reasonable limits
854
+ while (current <= end && periodCount < maxPeriods) {
855
+ let periodKey;
856
+ if (groupBy === 'hour') {
857
+ periodKey = current.toISOString().slice(0, 13) + ':00';
858
+ }
859
+ else if (groupBy === 'week') {
860
+ // ISO week format - need to calculate properly
861
+ const thursday = new Date(current);
862
+ thursday.setDate(current.getDate() - current.getDay() + 4); // Thursday of current week
863
+ const yearStart = new Date(thursday.getFullYear(), 0, 1);
864
+ const weekNum = Math.ceil(((thursday.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
865
+ periodKey = `${thursday.getFullYear()}-W${weekNum.toString().padStart(2, '0')}`;
866
+ }
867
+ else {
868
+ // day
869
+ periodKey = current.toISOString().slice(0, 10);
870
+ }
871
+ // Use existing period data or create empty one
872
+ const existingPeriod = existingPeriods.get(periodKey);
873
+ if (existingPeriod) {
874
+ allPeriods.push(existingPeriod);
875
+ }
876
+ else {
877
+ allPeriods.push({
878
+ period: periodKey,
879
+ count: 0,
880
+ item_ids: null,
881
+ items: [],
882
+ });
883
+ }
884
+ // Increment current date
885
+ if (groupBy === 'hour') {
886
+ current.setHours(current.getHours() + 1);
887
+ }
888
+ else if (groupBy === 'week') {
889
+ current.setDate(current.getDate() + 7);
890
+ }
891
+ else {
892
+ current.setDate(current.getDate() + 1);
893
+ }
894
+ periodCount++;
895
+ }
896
+ // Sort by period descending
897
+ timeline = allPeriods.sort((a, b) => b.period.localeCompare(a.period));
898
+ // Apply minItemsPerPeriod filter after generating empty periods if showEmpty overrides it
899
+ if (minItemsPerPeriod && minItemsPerPeriod > 0) {
900
+ // When showEmpty is true, we still show all periods but can use minItemsPerPeriod for other purposes
901
+ // According to tests, showEmpty should override minItemsPerPeriod behavior
902
+ }
903
+ }
904
+ // If includeItems is true, fetch the actual items for each period
905
+ if (includeItems && timeline.length > 0) {
906
+ for (const period of timeline) {
907
+ if (period.item_ids) {
908
+ const itemIds = period.item_ids.split(',');
909
+ let itemsToFetch = itemIds;
910
+ // Limit items per period if specified
911
+ if (itemsPerPeriod && itemIds.length > itemsPerPeriod) {
912
+ itemsToFetch = itemIds.slice(0, itemsPerPeriod);
913
+ period.hasMore = true;
914
+ period.totalCount = itemIds.length;
915
+ }
916
+ // Fetch the items
917
+ const itemStmt = this.db.prepare(`
918
+ SELECT * FROM context_items
919
+ WHERE id IN (${itemsToFetch.map(() => '?').join(',')})
920
+ ORDER BY created_at DESC
921
+ `);
922
+ period.items = itemStmt.all(...itemsToFetch);
923
+ }
924
+ else {
925
+ // No items for this period
926
+ period.items = [];
927
+ }
928
+ }
929
+ }
930
+ else if (includeItems) {
931
+ // Ensure all periods have items array when includeItems is true
932
+ for (const period of timeline) {
933
+ if (!period.items) {
934
+ period.items = [];
935
+ }
936
+ }
937
+ }
938
+ return timeline;
939
+ }
940
+ // Get diff data for context items
941
+ getDiff(options) {
942
+ const { sessionId, sinceTimestamp, category, channel, channels, limit, offset, includeValues = true, } = options;
943
+ // Ensure timestamp is in SQLite format for comparison
944
+ const sqliteTimestamp = (0, timestamps_js_1.ensureSQLiteFormat)(sinceTimestamp);
945
+ // Build queries for added and modified items
946
+ let addedSql = `
947
+ SELECT * FROM context_items
948
+ WHERE session_id = ?
949
+ AND created_at > ?
950
+ `;
951
+ const addedParams = [sessionId, sqliteTimestamp];
952
+ let modifiedSql = `
953
+ SELECT * FROM context_items
954
+ WHERE session_id = ?
955
+ AND created_at <= ?
956
+ AND updated_at > ?
957
+ AND created_at != updated_at
958
+ `;
959
+ const modifiedParams = [sessionId, sqliteTimestamp, sqliteTimestamp];
960
+ // Add category filter
961
+ if (category) {
962
+ addedSql += ' AND category = ?';
963
+ modifiedSql += ' AND category = ?';
964
+ addedParams.push(category);
965
+ modifiedParams.push(category);
966
+ }
967
+ // Add channel filter
968
+ if (channel) {
969
+ addedSql += ' AND channel = ?';
970
+ modifiedSql += ' AND channel = ?';
971
+ addedParams.push(channel);
972
+ modifiedParams.push(channel);
973
+ }
974
+ if (channels && channels.length > 0) {
975
+ const placeholders = channels.map(() => '?').join(',');
976
+ addedSql += ` AND channel IN (${placeholders})`;
977
+ modifiedSql += ` AND channel IN (${placeholders})`;
978
+ addedParams.push(...channels);
979
+ modifiedParams.push(...channels);
980
+ }
981
+ // Add ordering
982
+ addedSql += ' ORDER BY created_at DESC';
983
+ modifiedSql += ' ORDER BY updated_at DESC';
984
+ // Add pagination if requested
985
+ if (limit) {
986
+ addedSql += ' LIMIT ?';
987
+ modifiedSql += ' LIMIT ?';
988
+ addedParams.push(limit);
989
+ modifiedParams.push(limit);
990
+ if (offset) {
991
+ addedSql += ' OFFSET ?';
992
+ modifiedSql += ' OFFSET ?';
993
+ addedParams.push(offset);
994
+ modifiedParams.push(offset);
995
+ }
996
+ }
997
+ // Execute queries
998
+ const addedItems = this.db.prepare(addedSql).all(...addedParams);
999
+ const modifiedItems = this.db.prepare(modifiedSql).all(...modifiedParams);
1000
+ // Filter out values if not needed
1001
+ if (!includeValues) {
1002
+ const stripValue = (item) => ({
1003
+ ...item,
1004
+ value: undefined,
1005
+ });
1006
+ return {
1007
+ added: addedItems.map(stripValue),
1008
+ modified: modifiedItems.map(stripValue),
1009
+ };
1010
+ }
1011
+ return { added: addedItems, modified: modifiedItems };
1012
+ }
1013
+ // Get deleted keys by comparing with checkpoint
1014
+ getDeletedKeysFromCheckpoint(sessionId, checkpointId) {
1015
+ // Get items from checkpoint
1016
+ const checkpointItems = this.db
1017
+ .prepare(`
1018
+ SELECT ci.key FROM context_items ci
1019
+ JOIN checkpoint_items cpi ON ci.id = cpi.context_item_id
1020
+ WHERE cpi.checkpoint_id = ?
1021
+ AND ci.session_id = ?
1022
+ `)
1023
+ .all(checkpointId, sessionId);
1024
+ // Get current items
1025
+ const currentItems = this.db
1026
+ .prepare('SELECT key FROM context_items WHERE session_id = ?')
1027
+ .all(sessionId);
1028
+ const checkpointKeys = new Set(checkpointItems.map((i) => i.key));
1029
+ const currentKeys = new Set(currentItems.map((i) => i.key));
1030
+ // Find deleted items
1031
+ return Array.from(checkpointKeys).filter(key => !currentKeys.has(key));
1032
+ }
1033
+ // List all channels with metadata
1034
+ listChannels(options) {
1035
+ const { sessionId, sessionIds, sort = 'name', includeEmpty = false } = options;
1036
+ // Build the base query
1037
+ let sql = `
1038
+ SELECT
1039
+ channel,
1040
+ COUNT(*) as total_count,
1041
+ SUM(CASE WHEN is_private = 0 THEN 1 ELSE 0 END) as public_count,
1042
+ SUM(CASE WHEN is_private = 1 THEN 1 ELSE 0 END) as private_count,
1043
+ MAX(updated_at) as last_activity,
1044
+ GROUP_CONCAT(DISTINCT category) as categories,
1045
+ GROUP_CONCAT(DISTINCT priority) as priorities,
1046
+ COUNT(DISTINCT session_id) as session_count
1047
+ FROM context_items
1048
+ `;
1049
+ const params = [];
1050
+ // Add session filters
1051
+ if (sessionId) {
1052
+ sql += ' WHERE session_id = ?';
1053
+ params.push(sessionId);
1054
+ }
1055
+ else if (sessionIds && sessionIds.length > 0) {
1056
+ sql += ` WHERE session_id IN (${sessionIds.map(() => '?').join(',')})`;
1057
+ params.push(...sessionIds);
1058
+ }
1059
+ sql += ' GROUP BY channel';
1060
+ // Add having clause if not including empty channels
1061
+ if (!includeEmpty) {
1062
+ sql += ' HAVING total_count > 0';
1063
+ }
1064
+ // Add sorting
1065
+ switch (sort) {
1066
+ case 'count':
1067
+ sql += ' ORDER BY total_count DESC, channel ASC';
1068
+ break;
1069
+ case 'activity':
1070
+ sql += ' ORDER BY last_activity DESC, channel ASC';
1071
+ break;
1072
+ case 'name':
1073
+ default:
1074
+ sql += ' ORDER BY channel ASC';
1075
+ break;
1076
+ }
1077
+ const stmt = this.db.prepare(sql);
1078
+ const channels = stmt.all(...params);
1079
+ // Post-process results
1080
+ return channels.map(channel => ({
1081
+ ...channel,
1082
+ categories: channel.categories ? channel.categories.split(',').filter(Boolean) : [],
1083
+ priorities: channel.priorities ? channel.priorities.split(',').filter(Boolean) : [],
1084
+ }));
1085
+ }
1086
+ // Get detailed statistics for channels
1087
+ getChannelStats(options) {
1088
+ const { channel, sessionId, includeTimeSeries = false, includeInsights = false } = options;
1089
+ if (channel) {
1090
+ // Single channel stats
1091
+ return this.getSingleChannelStats(channel, sessionId, includeTimeSeries, includeInsights);
1092
+ }
1093
+ else {
1094
+ // All channels overview
1095
+ return this.getAllChannelsStats(sessionId, includeTimeSeries, includeInsights);
1096
+ }
1097
+ }
1098
+ getSingleChannelStats(channel, sessionId, includeTimeSeries = false, includeInsights = false) {
1099
+ // Base stats query
1100
+ let statsSQL = `
1101
+ SELECT
1102
+ ? as channel,
1103
+ COUNT(*) as total_items,
1104
+ COUNT(DISTINCT session_id) as unique_sessions,
1105
+ MAX(updated_at) as last_activity,
1106
+ MIN(created_at) as first_activity,
1107
+ SUM(size) as total_size,
1108
+ AVG(size) as avg_size,
1109
+ SUM(CASE WHEN is_private = 0 THEN 1 ELSE 0 END) as public_items,
1110
+ SUM(CASE WHEN is_private = 1 THEN 1 ELSE 0 END) as private_items
1111
+ FROM context_items
1112
+ WHERE channel = ?
1113
+ `;
1114
+ const statsParams = [channel, channel];
1115
+ if (sessionId) {
1116
+ statsSQL += ' AND (is_private = 0 OR session_id = ?)';
1117
+ statsParams.push(sessionId);
1118
+ }
1119
+ const stats = this.db.prepare(statsSQL).get(...statsParams);
1120
+ // Category distribution
1121
+ let categorySQL = `
1122
+ SELECT
1123
+ COALESCE(category, 'uncategorized') as category,
1124
+ COUNT(*) as count,
1125
+ ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM context_items WHERE channel = ?), 2) as percentage
1126
+ FROM context_items
1127
+ WHERE channel = ?
1128
+ `;
1129
+ const categoryParams = [channel, channel];
1130
+ if (sessionId) {
1131
+ categorySQL += ' AND (is_private = 0 OR session_id = ?)';
1132
+ categoryParams.push(sessionId);
1133
+ }
1134
+ categorySQL += ' GROUP BY category ORDER BY count DESC';
1135
+ const categoryDistribution = this.db.prepare(categorySQL).all(...categoryParams);
1136
+ // Priority distribution
1137
+ let prioritySQL = `
1138
+ SELECT
1139
+ priority,
1140
+ COUNT(*) as count,
1141
+ ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM context_items WHERE channel = ?), 2) as percentage
1142
+ FROM context_items
1143
+ WHERE channel = ?
1144
+ `;
1145
+ const priorityParams = [channel, channel];
1146
+ if (sessionId) {
1147
+ prioritySQL += ' AND (is_private = 0 OR session_id = ?)';
1148
+ priorityParams.push(sessionId);
1149
+ }
1150
+ prioritySQL += ' GROUP BY priority ORDER BY count DESC';
1151
+ const priorityDistribution = this.db.prepare(prioritySQL).all(...priorityParams);
1152
+ // Top contributors
1153
+ let contributorsSQL = `
1154
+ SELECT
1155
+ session_id,
1156
+ COUNT(*) as item_count,
1157
+ MAX(updated_at) as last_contribution
1158
+ FROM context_items
1159
+ WHERE channel = ?
1160
+ `;
1161
+ const contributorsParams = [channel];
1162
+ if (sessionId) {
1163
+ contributorsSQL += ' AND (is_private = 0 OR session_id = ?)';
1164
+ contributorsParams.push(sessionId);
1165
+ }
1166
+ contributorsSQL += ' GROUP BY session_id ORDER BY item_count DESC LIMIT 5';
1167
+ const topContributors = this.db.prepare(contributorsSQL).all(...contributorsParams);
1168
+ // Activity metrics
1169
+ const now = new Date();
1170
+ const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
1171
+ const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
1172
+ let activitySQL = `
1173
+ SELECT
1174
+ SUM(CASE WHEN created_at > ? THEN 1 ELSE 0 END) as items_last_24h,
1175
+ SUM(CASE WHEN updated_at > ? THEN 1 ELSE 0 END) as updates_last_24h,
1176
+ SUM(CASE WHEN created_at > ? THEN 1 ELSE 0 END) as items_last_week,
1177
+ SUM(CASE WHEN updated_at > ? THEN 1 ELSE 0 END) as updates_last_week
1178
+ FROM context_items
1179
+ WHERE channel = ?
1180
+ `;
1181
+ const activityParams = [oneDayAgo, oneDayAgo, oneWeekAgo, oneWeekAgo, channel];
1182
+ if (sessionId) {
1183
+ activitySQL += ' AND (is_private = 0 OR session_id = ?)';
1184
+ activityParams.push(sessionId);
1185
+ }
1186
+ const activityStats = this.db.prepare(activitySQL).get(...activityParams);
1187
+ // Build result
1188
+ const result = {
1189
+ channel,
1190
+ stats,
1191
+ categoryDistribution,
1192
+ priorityDistribution,
1193
+ topContributors,
1194
+ activityStats,
1195
+ };
1196
+ // Add time series if requested
1197
+ if (includeTimeSeries) {
1198
+ const hourlySQL = `
1199
+ SELECT
1200
+ strftime('%H', created_at) as hour,
1201
+ COUNT(*) as count
1202
+ FROM context_items
1203
+ WHERE channel = ?
1204
+ ${sessionId ? ' AND (is_private = 0 OR session_id = ?)' : ''}
1205
+ GROUP BY hour
1206
+ ORDER BY hour
1207
+ `;
1208
+ const hourlyParams = sessionId ? [channel, sessionId] : [channel];
1209
+ result.hourlyActivity = this.db.prepare(hourlySQL).all(...hourlyParams);
1210
+ const dailySQL = `
1211
+ SELECT
1212
+ strftime('%Y-%m-%d', created_at) as date,
1213
+ COUNT(*) as count
1214
+ FROM context_items
1215
+ WHERE channel = ?
1216
+ ${sessionId ? ' AND (is_private = 0 OR session_id = ?)' : ''}
1217
+ GROUP BY date
1218
+ ORDER BY date DESC
1219
+ LIMIT 30
1220
+ `;
1221
+ const dailyParams = sessionId ? [channel, sessionId] : [channel];
1222
+ result.dailyActivity = this.db.prepare(dailySQL).all(...dailyParams);
1223
+ }
1224
+ // Add insights if requested
1225
+ if (includeInsights) {
1226
+ result.insights = this.generateChannelInsights(channel, stats, activityStats, categoryDistribution);
1227
+ }
1228
+ return result;
1229
+ }
1230
+ getAllChannelsStats(sessionId, _includeTimeSeries = false, includeInsights = false) {
1231
+ // Overall stats
1232
+ let overallSQL = `
1233
+ SELECT
1234
+ COUNT(DISTINCT channel) as total_channels,
1235
+ COUNT(*) as total_items,
1236
+ COUNT(DISTINCT session_id) as total_sessions,
1237
+ SUM(size) as total_size,
1238
+ AVG(size) as avg_size_per_item
1239
+ FROM context_items
1240
+ `;
1241
+ const overallParams = [];
1242
+ if (sessionId) {
1243
+ overallSQL += ' WHERE (is_private = 0 OR session_id = ?)';
1244
+ overallParams.push(sessionId);
1245
+ }
1246
+ const overallStats = this.db.prepare(overallSQL).get(...overallParams);
1247
+ // Channel rankings
1248
+ let rankingSQL = `
1249
+ SELECT
1250
+ channel,
1251
+ COUNT(*) as item_count,
1252
+ SUM(size) as total_size,
1253
+ MAX(updated_at) as last_activity,
1254
+ COUNT(DISTINCT session_id) as session_count,
1255
+ ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM context_items${sessionId ? ' WHERE (is_private = 0 OR session_id = ?)' : ''}), 2) as percentage_of_total
1256
+ FROM context_items
1257
+ `;
1258
+ const rankingParams = [];
1259
+ if (sessionId) {
1260
+ rankingSQL += ' WHERE (is_private = 0 OR session_id = ?)';
1261
+ rankingParams.push(sessionId);
1262
+ rankingParams.unshift(sessionId); // For the subquery
1263
+ }
1264
+ rankingSQL += ' GROUP BY channel ORDER BY item_count DESC';
1265
+ const channelRankings = this.db.prepare(rankingSQL).all(...rankingParams);
1266
+ // Health metrics
1267
+ const healthMetrics = channelRankings.map(ch => {
1268
+ const daysSinceActivity = Math.floor((Date.now() - new Date(ch.last_activity).getTime()) / (1000 * 60 * 60 * 24));
1269
+ const avgItemsPerSession = ch.item_count / ch.session_count;
1270
+ return {
1271
+ channel: ch.channel,
1272
+ health_score: this.calculateHealthScore(daysSinceActivity, avgItemsPerSession, ch.item_count),
1273
+ days_since_activity: daysSinceActivity,
1274
+ avg_items_per_session: avgItemsPerSession,
1275
+ };
1276
+ });
1277
+ // Channel relationships
1278
+ const relationshipSQL = `
1279
+ SELECT
1280
+ c1.channel as channel1,
1281
+ c2.channel as channel2,
1282
+ COUNT(DISTINCT c1.session_id) as shared_sessions
1283
+ FROM context_items c1
1284
+ JOIN context_items c2 ON c1.session_id = c2.session_id AND c1.channel < c2.channel
1285
+ ${sessionId ? 'WHERE (c1.is_private = 0 OR c1.session_id = ?) AND (c2.is_private = 0 OR c2.session_id = ?)' : ''}
1286
+ GROUP BY c1.channel, c2.channel
1287
+ HAVING shared_sessions > 1
1288
+ ORDER BY shared_sessions DESC
1289
+ LIMIT 10
1290
+ `;
1291
+ const relationshipParams = sessionId ? [sessionId, sessionId] : [];
1292
+ const channelRelationships = this.db
1293
+ .prepare(relationshipSQL)
1294
+ .all(...relationshipParams);
1295
+ const result = {
1296
+ overallStats,
1297
+ channelRankings,
1298
+ healthMetrics,
1299
+ channelRelationships,
1300
+ };
1301
+ if (includeInsights) {
1302
+ result.insights = this.generateOverallInsights(channelRankings, healthMetrics);
1303
+ }
1304
+ return result;
1305
+ }
1306
+ calculateHealthScore(daysSinceActivity, avgItemsPerSession, totalItems) {
1307
+ // Health score based on activity recency, engagement, and size
1308
+ let score = 100;
1309
+ // Penalize for inactivity
1310
+ if (daysSinceActivity > 30)
1311
+ score -= 30;
1312
+ else if (daysSinceActivity > 14)
1313
+ score -= 20;
1314
+ else if (daysSinceActivity > 7)
1315
+ score -= 10;
1316
+ // Reward for engagement
1317
+ if (avgItemsPerSession > 10)
1318
+ score += 10;
1319
+ else if (avgItemsPerSession > 5)
1320
+ score += 5;
1321
+ // Reward for size
1322
+ if (totalItems > 100)
1323
+ score += 10;
1324
+ else if (totalItems > 50)
1325
+ score += 5;
1326
+ return Math.max(0, Math.min(100, score));
1327
+ }
1328
+ generateChannelInsights(channel, stats, activityStats, categoryDistribution) {
1329
+ const insights = [];
1330
+ // Activity insights
1331
+ if (activityStats.items_last_24h === 0 && activityStats.updates_last_24h === 0) {
1332
+ insights.push(`Channel "${channel}" has been inactive for the last 24 hours`);
1333
+ }
1334
+ if (activityStats.items_last_week > activityStats.items_last_24h * 7) {
1335
+ insights.push(`Channel "${channel}" shows declining activity compared to weekly average`);
1336
+ }
1337
+ // Category insights
1338
+ if (categoryDistribution.length > 0) {
1339
+ const topCategory = categoryDistribution[0];
1340
+ if (topCategory.percentage > 60) {
1341
+ insights.push(`Channel "${channel}" is heavily focused on "${topCategory.category}" (${topCategory.percentage}%)`);
1342
+ }
1343
+ }
1344
+ // Size insights
1345
+ if (stats.avg_size > 10000) {
1346
+ insights.push(`Channel "${channel}" contains large items (avg ${Math.round(stats.avg_size / 1024)}KB)`);
1347
+ }
1348
+ return insights;
1349
+ }
1350
+ generateOverallInsights(channelRankings, healthMetrics) {
1351
+ const insights = [];
1352
+ // Channel concentration
1353
+ if (channelRankings.length > 0) {
1354
+ const top3Percentage = channelRankings
1355
+ .slice(0, 3)
1356
+ .reduce((sum, ch) => sum + parseFloat(ch.percentage_of_total), 0);
1357
+ if (top3Percentage > 80) {
1358
+ insights.push(`Top 3 channels contain ${top3Percentage.toFixed(1)}% of all items - consider better distribution`);
1359
+ }
1360
+ }
1361
+ // Health insights
1362
+ const unhealthyChannels = healthMetrics.filter(m => m.health_score < 50);
1363
+ if (unhealthyChannels.length > 0) {
1364
+ insights.push(`${unhealthyChannels.length} channels show signs of low health (inactive or low engagement)`);
1365
+ }
1366
+ // Activity patterns
1367
+ const inactiveChannels = healthMetrics.filter(m => m.days_since_activity > 30);
1368
+ if (inactiveChannels.length > 0) {
1369
+ insights.push(`${inactiveChannels.length} channels have been inactive for over 30 days`);
1370
+ }
1371
+ return insights;
1372
+ }
1373
+ // Reassign channel for context items
1374
+ reassignChannel(options) {
1375
+ const { keys, keyPattern, fromChannel, toChannel, sessionId, category, priorities, dryRun = false, } = options;
1376
+ const errors = [];
1377
+ const itemsMoved = [];
1378
+ try {
1379
+ // Start transaction
1380
+ this.db.prepare('BEGIN TRANSACTION').run();
1381
+ // Build the base query
1382
+ let sql = 'SELECT id, key, channel FROM context_items WHERE session_id = ?';
1383
+ const params = [sessionId];
1384
+ // Add conditions based on parameters
1385
+ if (keys && keys.length > 0) {
1386
+ const placeholders = keys.map(() => '?').join(',');
1387
+ sql += ` AND key IN (${placeholders})`;
1388
+ params.push(...keys);
1389
+ }
1390
+ else if (keyPattern) {
1391
+ // Convert wildcard pattern to SQL GLOB pattern
1392
+ sql += ' AND key GLOB ?';
1393
+ params.push(keyPattern);
1394
+ }
1395
+ else if (fromChannel) {
1396
+ sql += ' AND channel = ?';
1397
+ params.push(fromChannel);
1398
+ }
1399
+ // Add category filter
1400
+ if (category) {
1401
+ sql += ' AND category = ?';
1402
+ params.push(category);
1403
+ }
1404
+ // Add priority filter
1405
+ if (priorities && priorities.length > 0) {
1406
+ const placeholders = priorities.map(() => '?').join(',');
1407
+ sql += ` AND priority IN (${placeholders})`;
1408
+ params.push(...priorities);
1409
+ }
1410
+ // Get items to be moved
1411
+ const itemsToMove = this.db.prepare(sql).all(...params);
1412
+ if (itemsToMove.length === 0) {
1413
+ this.db.prepare('ROLLBACK').run();
1414
+ return {
1415
+ itemsAffected: 0,
1416
+ itemsMoved: [],
1417
+ errors: ['No items found matching the specified criteria'],
1418
+ };
1419
+ }
1420
+ // Prepare response data
1421
+ for (const item of itemsToMove) {
1422
+ itemsMoved.push({
1423
+ key: item.key,
1424
+ oldChannel: item.channel,
1425
+ newChannel: toChannel,
1426
+ });
1427
+ }
1428
+ // If dry run, rollback and return preview
1429
+ if (dryRun) {
1430
+ this.db.prepare('ROLLBACK').run();
1431
+ return {
1432
+ itemsAffected: itemsToMove.length,
1433
+ itemsMoved,
1434
+ };
1435
+ }
1436
+ // Perform the update
1437
+ const updateSql = `
1438
+ UPDATE context_items
1439
+ SET channel = ?, updated_at = CURRENT_TIMESTAMP
1440
+ WHERE id IN (${itemsToMove.map(() => '?').join(',')})
1441
+ `;
1442
+ const updateParams = [toChannel, ...itemsToMove.map(item => item.id)];
1443
+ const result = this.db.prepare(updateSql).run(...updateParams);
1444
+ // Commit transaction
1445
+ this.db.prepare('COMMIT').run();
1446
+ return {
1447
+ itemsAffected: result.changes,
1448
+ itemsMoved,
1449
+ };
1450
+ }
1451
+ catch (error) {
1452
+ // Rollback on error
1453
+ try {
1454
+ this.db.prepare('ROLLBACK').run();
1455
+ }
1456
+ catch (_e) {
1457
+ // Ignore rollback errors
1458
+ }
1459
+ errors.push(`Database error: ${error.message}`);
1460
+ return {
1461
+ itemsAffected: 0,
1462
+ itemsMoved: [],
1463
+ errors,
1464
+ };
1465
+ }
1466
+ }
1467
+ // Batch Operations
1468
+ /**
1469
+ * Save multiple context items in a single transaction
1470
+ */
1471
+ batchSave(sessionId, items, options = {}) {
1472
+ const { updateExisting = true } = options;
1473
+ const results = [];
1474
+ let totalSize = 0;
1475
+ // Prepare statements
1476
+ const checkStmt = this.db.prepare('SELECT id FROM context_items WHERE session_id = ? AND key = ?');
1477
+ const insertStmt = this.db.prepare(`
1478
+ INSERT INTO context_items (
1479
+ id, session_id, key, value, category, priority, channel,
1480
+ created_at, updated_at, size, is_private
1481
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1482
+ `);
1483
+ const updateStmt = this.db.prepare(`
1484
+ UPDATE context_items
1485
+ SET value = ?, category = ?, priority = ?, channel = ?,
1486
+ updated_at = ?, size = ?
1487
+ WHERE session_id = ? AND key = ?
1488
+ `);
1489
+ // Get session default channel
1490
+ const sessionStmt = this.db.prepare('SELECT default_channel FROM sessions WHERE id = ?');
1491
+ const session = sessionStmt.get(sessionId);
1492
+ const defaultChannel = session?.default_channel || 'general';
1493
+ items.forEach((item, index) => {
1494
+ try {
1495
+ // Skip items with missing required fields
1496
+ if (!item.key || !item.value) {
1497
+ throw new Error('Missing required fields');
1498
+ }
1499
+ // Validate the key
1500
+ const validatedKey = (0, validation_js_1.validateKey)(item.key);
1501
+ const size = this.calculateSize(item.value);
1502
+ totalSize += size;
1503
+ // Check if key exists
1504
+ const existing = checkStmt.get(sessionId, validatedKey);
1505
+ if (existing && updateExisting) {
1506
+ // Update existing
1507
+ const now = (0, timestamps_js_1.ensureSQLiteFormat)(new Date().toISOString());
1508
+ updateStmt.run(item.value, item.category || null, item.priority || 'normal', item.channel || defaultChannel, now, size, sessionId, validatedKey);
1509
+ results.push({
1510
+ index,
1511
+ key: validatedKey,
1512
+ success: true,
1513
+ action: 'updated',
1514
+ size,
1515
+ });
1516
+ }
1517
+ else if (!existing) {
1518
+ // Insert new
1519
+ const id = this.generateId();
1520
+ const now = (0, timestamps_js_1.ensureSQLiteFormat)(new Date().toISOString());
1521
+ insertStmt.run(id, sessionId, validatedKey, item.value, item.category || null, item.priority || 'normal', item.channel || defaultChannel, now, now, size, item.isPrivate ? 1 : 0);
1522
+ results.push({
1523
+ index,
1524
+ key: validatedKey,
1525
+ success: true,
1526
+ action: 'created',
1527
+ id,
1528
+ size,
1529
+ });
1530
+ }
1531
+ else {
1532
+ // Existing but updateExisting is false
1533
+ throw new Error('Item with this key already exists');
1534
+ }
1535
+ }
1536
+ catch (error) {
1537
+ results.push({
1538
+ index,
1539
+ key: item.key,
1540
+ success: false,
1541
+ error: error.message,
1542
+ });
1543
+ }
1544
+ });
1545
+ return { results, totalSize };
1546
+ }
1547
+ /**
1548
+ * Delete multiple context items in a single transaction
1549
+ */
1550
+ batchDelete(sessionId, options) {
1551
+ const { keys, keyPattern } = options;
1552
+ let totalDeleted = 0;
1553
+ if (keys) {
1554
+ // Delete by specific keys
1555
+ const deleteStmt = this.db.prepare('DELETE FROM context_items WHERE session_id = ? AND key = ?');
1556
+ const results = [];
1557
+ keys.forEach((key, index) => {
1558
+ if (key && key.trim()) {
1559
+ const result = deleteStmt.run(sessionId, key);
1560
+ const deleted = result.changes > 0;
1561
+ results.push({
1562
+ index,
1563
+ key,
1564
+ deleted,
1565
+ count: result.changes,
1566
+ });
1567
+ totalDeleted += result.changes;
1568
+ }
1569
+ else {
1570
+ results.push({
1571
+ index,
1572
+ key: key || 'undefined',
1573
+ deleted: false,
1574
+ count: 0,
1575
+ error: 'Key cannot be empty',
1576
+ });
1577
+ }
1578
+ });
1579
+ return { results, totalDeleted };
1580
+ }
1581
+ else if (keyPattern) {
1582
+ // Delete by pattern
1583
+ const sqlPattern = keyPattern.replace(/\*/g, '%').replace(/\?/g, '_');
1584
+ const result = this.db
1585
+ .prepare('DELETE FROM context_items WHERE session_id = ? AND key LIKE ?')
1586
+ .run(sessionId, sqlPattern);
1587
+ totalDeleted = result.changes;
1588
+ return { totalDeleted };
1589
+ }
1590
+ return { totalDeleted: 0 };
1591
+ }
1592
+ /**
1593
+ * Update multiple context items in a single transaction
1594
+ */
1595
+ batchUpdate(sessionId, updates) {
1596
+ const results = [];
1597
+ updates.forEach((update, index) => {
1598
+ try {
1599
+ // Build dynamic UPDATE statement
1600
+ const setClauses = [];
1601
+ const values = [];
1602
+ if (update.value !== undefined) {
1603
+ setClauses.push('value = ?');
1604
+ values.push(update.value);
1605
+ setClauses.push('size = ?');
1606
+ values.push(this.calculateSize(update.value));
1607
+ }
1608
+ if (update.category !== undefined) {
1609
+ setClauses.push('category = ?');
1610
+ values.push(update.category);
1611
+ }
1612
+ if (update.priority !== undefined) {
1613
+ setClauses.push('priority = ?');
1614
+ values.push(update.priority);
1615
+ }
1616
+ if (update.channel !== undefined) {
1617
+ setClauses.push('channel = ?');
1618
+ values.push(update.channel);
1619
+ }
1620
+ if (setClauses.length === 0) {
1621
+ throw new Error('No updates provided');
1622
+ }
1623
+ setClauses.push('updated_at = ?');
1624
+ values.push((0, timestamps_js_1.ensureSQLiteFormat)(new Date().toISOString()));
1625
+ const sql = `
1626
+ UPDATE context_items
1627
+ SET ${setClauses.join(', ')}
1628
+ WHERE session_id = ? AND key = ?
1629
+ `;
1630
+ values.push(sessionId, update.key);
1631
+ const result = this.db.prepare(sql).run(...values);
1632
+ if (result.changes === 0) {
1633
+ throw new Error('Item not found');
1634
+ }
1635
+ results.push({
1636
+ index,
1637
+ key: update.key,
1638
+ updated: true,
1639
+ fields: Object.keys(update).filter(k => k !== 'key' && update[k] !== undefined),
1640
+ });
1641
+ }
1642
+ catch (error) {
1643
+ results.push({
1644
+ index,
1645
+ key: update.key,
1646
+ updated: false,
1647
+ error: error.message,
1648
+ });
1649
+ }
1650
+ });
1651
+ return { results };
1652
+ }
1653
+ /**
1654
+ * Get items for dry run operations
1655
+ */
1656
+ getDryRunItems(sessionId, options) {
1657
+ const { keys, keyPattern } = options;
1658
+ let items = [];
1659
+ if (keys) {
1660
+ const stmt = this.db.prepare(`
1661
+ SELECT key, value, category, priority, channel
1662
+ FROM context_items
1663
+ WHERE session_id = ? AND key = ?
1664
+ `);
1665
+ keys.forEach(key => {
1666
+ if (key && key.trim()) {
1667
+ const item = stmt.get(sessionId, key);
1668
+ if (item) {
1669
+ items.push({
1670
+ ...item,
1671
+ value: item.value.substring(0, 50) + (item.value.length > 50 ? '...' : ''),
1672
+ });
1673
+ }
1674
+ }
1675
+ });
1676
+ }
1677
+ else if (keyPattern) {
1678
+ const sqlPattern = keyPattern.replace(/\*/g, '%').replace(/\?/g, '_');
1679
+ const stmt = this.db.prepare(`
1680
+ SELECT key, value, category, priority, channel
1681
+ FROM context_items
1682
+ WHERE session_id = ? AND key LIKE ?
1683
+ `);
1684
+ const foundItems = stmt.all(sessionId, sqlPattern);
1685
+ items = foundItems.map(item => ({
1686
+ ...item,
1687
+ value: item.value.substring(0, 50) + (item.value.length > 50 ? '...' : ''),
1688
+ }));
1689
+ }
1690
+ return items;
1691
+ }
1692
+ // Context Relationships Methods
1693
+ createRelationship(params) {
1694
+ const { sessionId, sourceKey, targetKey, relationship, metadata } = params;
1695
+ // Validate relationship type
1696
+ const validTypes = [
1697
+ 'contains',
1698
+ 'depends_on',
1699
+ 'references',
1700
+ 'implements',
1701
+ 'extends',
1702
+ 'related_to',
1703
+ 'blocks',
1704
+ 'blocked_by',
1705
+ 'parent_of',
1706
+ 'child_of',
1707
+ 'has_task',
1708
+ 'documented_in',
1709
+ 'serves',
1710
+ 'leads_to',
1711
+ ];
1712
+ if (!validTypes.includes(relationship)) {
1713
+ return { id: '', created: false, error: `Invalid relationship type: ${relationship}` };
1714
+ }
1715
+ // Check if both items exist
1716
+ const sourceExists = this.db
1717
+ .prepare('SELECT 1 FROM context_items WHERE session_id = ? AND key = ?')
1718
+ .get(sessionId, sourceKey);
1719
+ const targetExists = this.db
1720
+ .prepare('SELECT 1 FROM context_items WHERE session_id = ? AND key = ?')
1721
+ .get(sessionId, targetKey);
1722
+ if (!sourceExists || !targetExists) {
1723
+ const missingKeys = [];
1724
+ if (!sourceExists)
1725
+ missingKeys.push(sourceKey);
1726
+ if (!targetExists)
1727
+ missingKeys.push(targetKey);
1728
+ return {
1729
+ id: '',
1730
+ created: false,
1731
+ error: `The following items do not exist: ${missingKeys.join(', ')}`,
1732
+ };
1733
+ }
1734
+ try {
1735
+ const relationshipId = require('uuid').v4();
1736
+ const metadataStr = metadata ? JSON.stringify(metadata) : null;
1737
+ this.db
1738
+ .prepare(`INSERT INTO context_relationships (id, session_id, from_key, to_key, relationship_type, metadata)
1739
+ VALUES (?, ?, ?, ?, ?, ?)`)
1740
+ .run(relationshipId, sessionId, sourceKey, targetKey, relationship, metadataStr);
1741
+ return { id: relationshipId, created: true };
1742
+ }
1743
+ catch (error) {
1744
+ if (error.message.includes('UNIQUE constraint failed')) {
1745
+ return { id: '', created: false, error: 'Relationship already exists' };
1746
+ }
1747
+ throw error;
1748
+ }
1749
+ }
1750
+ getRelatedItems(params) {
1751
+ const { sessionId, key, relationship, depth = 1, direction = 'both' } = params;
1752
+ const result = {
1753
+ outgoing: [],
1754
+ incoming: [],
1755
+ };
1756
+ // Get direct relationships
1757
+ if (direction === 'outgoing' || direction === 'both') {
1758
+ let outgoingSql = `
1759
+ SELECT r.*, ci.value, ci.category, ci.priority
1760
+ FROM context_relationships r
1761
+ JOIN context_items ci ON ci.key = r.to_key AND ci.session_id = r.session_id
1762
+ WHERE r.session_id = ? AND r.from_key = ?
1763
+ `;
1764
+ const outgoingParams = [sessionId, key];
1765
+ if (relationship) {
1766
+ outgoingSql += ' AND r.relationship_type = ?';
1767
+ outgoingParams.push(relationship);
1768
+ }
1769
+ const outgoingRels = this.db.prepare(outgoingSql).all(...outgoingParams);
1770
+ result.outgoing = outgoingRels.map(r => ({
1771
+ key: r.to_key,
1772
+ value: r.value,
1773
+ category: r.category,
1774
+ priority: r.priority,
1775
+ relationshipType: r.relationship_type,
1776
+ relationshipId: r.id,
1777
+ metadata: r.metadata ? JSON.parse(r.metadata) : null,
1778
+ direction: 'outgoing',
1779
+ }));
1780
+ }
1781
+ if (direction === 'incoming' || direction === 'both') {
1782
+ let incomingSql = `
1783
+ SELECT r.*, ci.value, ci.category, ci.priority
1784
+ FROM context_relationships r
1785
+ JOIN context_items ci ON ci.key = r.from_key AND ci.session_id = r.session_id
1786
+ WHERE r.session_id = ? AND r.to_key = ?
1787
+ `;
1788
+ const incomingParams = [sessionId, key];
1789
+ if (relationship) {
1790
+ incomingSql += ' AND r.relationship_type = ?';
1791
+ incomingParams.push(relationship);
1792
+ }
1793
+ const incomingRels = this.db.prepare(incomingSql).all(...incomingParams);
1794
+ result.incoming = incomingRels.map(r => ({
1795
+ key: r.from_key,
1796
+ value: r.value,
1797
+ category: r.category,
1798
+ priority: r.priority,
1799
+ relationshipType: r.relationship_type,
1800
+ relationshipId: r.id,
1801
+ metadata: r.metadata ? JSON.parse(r.metadata) : null,
1802
+ direction: 'incoming',
1803
+ }));
1804
+ }
1805
+ // Handle depth traversal if depth > 1
1806
+ if (depth > 1) {
1807
+ const visited = new Set();
1808
+ const relationships = [];
1809
+ const nodes = new Map();
1810
+ // Add the starting node
1811
+ const startItem = this.db
1812
+ .prepare('SELECT * FROM context_items WHERE session_id = ? AND key = ?')
1813
+ .get(sessionId, key);
1814
+ if (startItem) {
1815
+ nodes.set(key, {
1816
+ id: key,
1817
+ label: startItem.value,
1818
+ type: startItem.category || 'default',
1819
+ });
1820
+ }
1821
+ // Traverse function
1822
+ const traverse = (currentKey, currentDepth, path) => {
1823
+ if (currentDepth > depth || visited.has(currentKey))
1824
+ return;
1825
+ visited.add(currentKey);
1826
+ // Get outgoing relationships
1827
+ let sql = `
1828
+ SELECT r.*, ci.value, ci.category
1829
+ FROM context_relationships r
1830
+ JOIN context_items ci ON ci.key = r.to_key AND ci.session_id = r.session_id
1831
+ WHERE r.session_id = ? AND r.from_key = ?
1832
+ `;
1833
+ const params = [sessionId, currentKey];
1834
+ if (relationship) {
1835
+ sql += ' AND r.relationship_type = ?';
1836
+ params.push(relationship);
1837
+ }
1838
+ const rels = this.db.prepare(sql).all(...params);
1839
+ rels.forEach(rel => {
1840
+ // Add node if not exists
1841
+ if (!nodes.has(rel.to_key)) {
1842
+ nodes.set(rel.to_key, {
1843
+ id: rel.to_key,
1844
+ label: rel.value,
1845
+ type: rel.category || 'default',
1846
+ });
1847
+ }
1848
+ // Add relationship
1849
+ relationships.push({
1850
+ path: [...path, currentKey],
1851
+ from: currentKey,
1852
+ to: rel.to_key,
1853
+ type: rel.relationship_type,
1854
+ metadata: rel.metadata ? JSON.parse(rel.metadata) : null,
1855
+ depth: currentDepth,
1856
+ });
1857
+ // Detect cycles
1858
+ if (path.includes(rel.to_key)) {
1859
+ // Cycle detected, don't traverse deeper
1860
+ return;
1861
+ }
1862
+ // Traverse deeper
1863
+ traverse(rel.to_key, currentDepth + 1, [...path, currentKey]);
1864
+ });
1865
+ // Also get incoming relationships if direction is 'both'
1866
+ if (direction === 'both') {
1867
+ let inSql = `
1868
+ SELECT r.*, ci.value, ci.category
1869
+ FROM context_relationships r
1870
+ JOIN context_items ci ON ci.key = r.from_key AND ci.session_id = r.session_id
1871
+ WHERE r.session_id = ? AND r.to_key = ?
1872
+ `;
1873
+ const inParams = [sessionId, currentKey];
1874
+ if (relationship) {
1875
+ inSql += ' AND r.relationship_type = ?';
1876
+ inParams.push(relationship);
1877
+ }
1878
+ const inRels = this.db.prepare(inSql).all(...inParams);
1879
+ inRels.forEach(rel => {
1880
+ if (!nodes.has(rel.from_key)) {
1881
+ nodes.set(rel.from_key, {
1882
+ id: rel.from_key,
1883
+ label: rel.value,
1884
+ type: rel.category || 'default',
1885
+ });
1886
+ }
1887
+ relationships.push({
1888
+ path: [...path, currentKey],
1889
+ from: rel.from_key,
1890
+ to: currentKey,
1891
+ type: rel.relationship_type,
1892
+ metadata: rel.metadata ? JSON.parse(rel.metadata) : null,
1893
+ depth: currentDepth,
1894
+ });
1895
+ if (!path.includes(rel.from_key)) {
1896
+ traverse(rel.from_key, currentDepth + 1, [...path, currentKey]);
1897
+ }
1898
+ });
1899
+ }
1900
+ };
1901
+ traverse(key, 1, []);
1902
+ // Build graph structure
1903
+ result.graph = {
1904
+ nodes: Array.from(nodes.values()),
1905
+ edges: relationships.map(r => ({
1906
+ from: r.from,
1907
+ to: r.to,
1908
+ type: r.type,
1909
+ label: r.type.replace(/_/g, ' '),
1910
+ metadata: r.metadata,
1911
+ })),
1912
+ relationships: relationships,
1913
+ };
1914
+ }
1915
+ return result;
1916
+ }
1917
+ deleteRelationship(params) {
1918
+ const { sessionId, sourceKey, targetKey, relationship } = params;
1919
+ const result = this.db
1920
+ .prepare(`DELETE FROM context_relationships
1921
+ WHERE session_id = ? AND from_key = ? AND to_key = ? AND relationship_type = ?`)
1922
+ .run(sessionId, sourceKey, targetKey, relationship);
1923
+ return { deleted: result.changes > 0 };
1924
+ }
1925
+ deleteAllRelationshipsForItem(sessionId, key) {
1926
+ const result = this.db
1927
+ .prepare(`DELETE FROM context_relationships
1928
+ WHERE session_id = ? AND (from_key = ? OR to_key = ?)`)
1929
+ .run(sessionId, key, key);
1930
+ return { deletedCount: result.changes };
1931
+ }
1932
+ getRelationshipStats(sessionId) {
1933
+ const totalRelationships = this.db
1934
+ .prepare('SELECT COUNT(*) as count FROM context_relationships WHERE session_id = ?')
1935
+ .get(sessionId).count;
1936
+ const byType = this.db
1937
+ .prepare(`SELECT relationship_type AS type, COUNT(*) as count
1938
+ FROM context_relationships
1939
+ WHERE session_id = ?
1940
+ GROUP BY relationship_type
1941
+ ORDER BY count DESC`)
1942
+ .all(sessionId);
1943
+ const mostConnected = this.db
1944
+ .prepare(`SELECT key, COUNT(*) as connection_count
1945
+ FROM (
1946
+ SELECT from_key as key FROM context_relationships WHERE session_id = ?
1947
+ UNION ALL
1948
+ SELECT to_key as key FROM context_relationships WHERE session_id = ?
1949
+ )
1950
+ GROUP BY key
1951
+ ORDER BY connection_count DESC
1952
+ LIMIT 10`)
1953
+ .all(sessionId, sessionId);
1954
+ const orphanedItems = this.db
1955
+ .prepare(`SELECT key, value FROM context_items
1956
+ WHERE session_id = ?
1957
+ AND key NOT IN (
1958
+ SELECT from_key FROM context_relationships WHERE session_id = ?
1959
+ UNION
1960
+ SELECT to_key FROM context_relationships WHERE session_id = ?
1961
+ )
1962
+ LIMIT 20`)
1963
+ .all(sessionId, sessionId, sessionId);
1964
+ return {
1965
+ totalRelationships,
1966
+ byType,
1967
+ mostConnected,
1968
+ orphanedItems,
1969
+ };
1970
+ }
1971
+ findCycles(sessionId) {
1972
+ const cycles = [];
1973
+ const visited = new Set();
1974
+ const recursionStack = new Set();
1975
+ const detectCycle = (key, path) => {
1976
+ visited.add(key);
1977
+ recursionStack.add(key);
1978
+ const neighbors = this.db
1979
+ .prepare('SELECT to_key FROM context_relationships WHERE session_id = ? AND from_key = ?')
1980
+ .all(sessionId, key);
1981
+ for (const neighbor of neighbors) {
1982
+ if (recursionStack.has(neighbor.to_key)) {
1983
+ // Found cycle
1984
+ const cycleStart = path.indexOf(neighbor.to_key);
1985
+ if (cycleStart !== -1) {
1986
+ cycles.push([...path.slice(cycleStart), neighbor.to_key]);
1987
+ }
1988
+ else {
1989
+ // The cycle doesn't include the current path, which means we found the target node
1990
+ // but it's not in our current path. This can happen when the cycle was entered
1991
+ // from a different starting point.
1992
+ cycles.push([neighbor.to_key, key, neighbor.to_key]);
1993
+ }
1994
+ }
1995
+ else if (!visited.has(neighbor.to_key)) {
1996
+ detectCycle(neighbor.to_key, [...path, neighbor.to_key]);
1997
+ }
1998
+ }
1999
+ recursionStack.delete(key);
2000
+ };
2001
+ // Get all unique keys that have relationships
2002
+ const allKeys = this.db
2003
+ .prepare(`SELECT DISTINCT key FROM (
2004
+ SELECT from_key as key FROM context_relationships WHERE session_id = ?
2005
+ UNION
2006
+ SELECT to_key as key FROM context_relationships WHERE session_id = ?
2007
+ )`)
2008
+ .all(sessionId, sessionId);
2009
+ allKeys.forEach(item => {
2010
+ if (!visited.has(item.key)) {
2011
+ detectCycle(item.key, [item.key]);
2012
+ }
2013
+ });
2014
+ return cycles;
2015
+ }
2016
+ }
2017
+ exports.ContextRepository = ContextRepository;