@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.
- package/CHANGELOG.md +542 -0
- package/LICENSE +21 -0
- package/README.md +1281 -0
- package/bin/mcp-memory-keeper +54 -0
- package/dist/__tests__/e2e/issue33-reproduce.test.js +234 -0
- package/dist/__tests__/e2e/server-e2e.test.js +341 -0
- package/dist/__tests__/helpers/database-test-helper.js +160 -0
- package/dist/__tests__/helpers/test-server.js +92 -0
- package/dist/__tests__/integration/advanced-features.test.js +614 -0
- package/dist/__tests__/integration/backward-compatibility.test.js +245 -0
- package/dist/__tests__/integration/batchOperationsE2E.test.js +396 -0
- package/dist/__tests__/integration/batchOperationsHandler.test.js +1230 -0
- package/dist/__tests__/integration/channelManagementHandler.test.js +1291 -0
- package/dist/__tests__/integration/channels.test.js +376 -0
- package/dist/__tests__/integration/checkpoint.test.js +251 -0
- package/dist/__tests__/integration/concurrent-access.test.js +190 -0
- package/dist/__tests__/integration/context-operations.test.js +243 -0
- package/dist/__tests__/integration/contextDiff.test.js +852 -0
- package/dist/__tests__/integration/contextDiffHandler.test.js +976 -0
- package/dist/__tests__/integration/contextExportHandler.test.js +510 -0
- package/dist/__tests__/integration/contextGetPaginationDefaults.test.js +298 -0
- package/dist/__tests__/integration/contextReassignChannelHandler.test.js +908 -0
- package/dist/__tests__/integration/contextRelationshipsHandler.test.js +1151 -0
- package/dist/__tests__/integration/contextSearch.test.js +1054 -0
- package/dist/__tests__/integration/contextSearchHandler.test.js +552 -0
- package/dist/__tests__/integration/contextWatchActual.test.js +165 -0
- package/dist/__tests__/integration/contextWatchHandler.test.js +1500 -0
- package/dist/__tests__/integration/database-initialization.test.js +134 -0
- package/dist/__tests__/integration/enhanced-context-operations.test.js +1082 -0
- package/dist/__tests__/integration/enhancedContextGetHandler.test.js +915 -0
- package/dist/__tests__/integration/enhancedContextTimelineHandler.test.js +716 -0
- package/dist/__tests__/integration/error-cases.test.js +411 -0
- package/dist/__tests__/integration/export-import.test.js +367 -0
- package/dist/__tests__/integration/feature-flags.test.js +542 -0
- package/dist/__tests__/integration/file-operations.test.js +264 -0
- package/dist/__tests__/integration/filterBySessionId.test.js +251 -0
- package/dist/__tests__/integration/git-integration.test.js +241 -0
- package/dist/__tests__/integration/index-tools.test.js +496 -0
- package/dist/__tests__/integration/issue11-actual-bug-demo.test.js +304 -0
- package/dist/__tests__/integration/issue11-search-filters-bug.test.js +561 -0
- package/dist/__tests__/integration/issue12-checkpoint-restore-behavior.test.js +621 -0
- package/dist/__tests__/integration/issue13-key-validation.test.js +433 -0
- package/dist/__tests__/integration/issue24-final-fix.test.js +241 -0
- package/dist/__tests__/integration/issue24-fix-validation.test.js +158 -0
- package/dist/__tests__/integration/issue24-reproduce.test.js +225 -0
- package/dist/__tests__/integration/issue24-token-limit.test.js +199 -0
- package/dist/__tests__/integration/issue33-array-items-schema.test.js +165 -0
- package/dist/__tests__/integration/knowledge-graph.test.js +338 -0
- package/dist/__tests__/integration/migrations.test.js +528 -0
- package/dist/__tests__/integration/multi-agent.test.js +546 -0
- package/dist/__tests__/integration/pagination-critical-fix.test.js +296 -0
- package/dist/__tests__/integration/paginationDefaultsHandler.test.js +600 -0
- package/dist/__tests__/integration/project-directory.test.js +291 -0
- package/dist/__tests__/integration/resource-cleanup.test.js +149 -0
- package/dist/__tests__/integration/retention.test.js +513 -0
- package/dist/__tests__/integration/search.test.js +333 -0
- package/dist/__tests__/integration/semantic-search.test.js +266 -0
- package/dist/__tests__/integration/server-initialization.test.js +305 -0
- package/dist/__tests__/integration/session-management.test.js +219 -0
- package/dist/__tests__/integration/simplified-sharing.test.js +346 -0
- package/dist/__tests__/integration/smart-compaction.test.js +230 -0
- package/dist/__tests__/integration/summarization.test.js +308 -0
- package/dist/__tests__/integration/tokenLimitEnforcement.test.js +134 -0
- package/dist/__tests__/integration/tool-profiles-integration.test.js +150 -0
- package/dist/__tests__/integration/watcher-migration-validation.test.js +544 -0
- package/dist/__tests__/security/input-validation.test.js +115 -0
- package/dist/__tests__/utils/agents.test.js +473 -0
- package/dist/__tests__/utils/database.test.js +177 -0
- package/dist/__tests__/utils/git.test.js +122 -0
- package/dist/__tests__/utils/knowledge-graph.test.js +297 -0
- package/dist/__tests__/utils/migrationHealthCheck.test.js +302 -0
- package/dist/__tests__/utils/project-directory-messages.test.js +192 -0
- package/dist/__tests__/utils/timezone-safe-dates.js +119 -0
- package/dist/__tests__/utils/token-limits.test.js +225 -0
- package/dist/__tests__/utils/tool-profiles.test.js +374 -0
- package/dist/__tests__/utils/validation.test.js +200 -0
- package/dist/__tests__/utils/vector-store.test.js +231 -0
- package/dist/handlers/contextWatchHandlers.js +206 -0
- package/dist/index.js +4425 -0
- package/dist/migrations/003_add_channels.js +174 -0
- package/dist/migrations/004_add_context_watch.js +151 -0
- package/dist/migrations/005_add_context_watch.js +98 -0
- package/dist/migrations/simplify-sharing.js +117 -0
- package/dist/repositories/BaseRepository.js +30 -0
- package/dist/repositories/CheckpointRepository.js +140 -0
- package/dist/repositories/ContextRepository.js +2017 -0
- package/dist/repositories/FileRepository.js +104 -0
- package/dist/repositories/RepositoryManager.js +62 -0
- package/dist/repositories/SessionRepository.js +66 -0
- package/dist/repositories/WatcherRepository.js +252 -0
- package/dist/repositories/index.js +15 -0
- package/dist/test-helpers/database-helper.js +128 -0
- package/dist/types/entities.js +3 -0
- package/dist/utils/agents.js +791 -0
- package/dist/utils/channels.js +150 -0
- package/dist/utils/database.js +780 -0
- package/dist/utils/feature-flags.js +476 -0
- package/dist/utils/git.js +145 -0
- package/dist/utils/knowledge-graph.js +264 -0
- package/dist/utils/migrationHealthCheck.js +373 -0
- package/dist/utils/migrations.js +452 -0
- package/dist/utils/retention.js +460 -0
- package/dist/utils/timestamps.js +112 -0
- package/dist/utils/token-limits.js +350 -0
- package/dist/utils/tool-profiles.js +242 -0
- package/dist/utils/validation.js +296 -0
- package/dist/utils/vector-store.js +247 -0
- package/examples/config.json +31 -0
- package/examples/project-directory-setup.md +114 -0
- 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;
|