@spool-lab/core 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/dist/connectors/capabilities/cookies-chrome.d.ts +19 -0
  4. package/dist/connectors/capabilities/cookies-chrome.d.ts.map +1 -0
  5. package/dist/connectors/capabilities/cookies-chrome.js +199 -0
  6. package/dist/connectors/capabilities/cookies-chrome.js.map +1 -0
  7. package/dist/connectors/capabilities/exec-impl.d.ts +3 -0
  8. package/dist/connectors/capabilities/exec-impl.d.ts.map +1 -0
  9. package/dist/connectors/capabilities/exec-impl.js +108 -0
  10. package/dist/connectors/capabilities/exec-impl.js.map +1 -0
  11. package/dist/connectors/capabilities/fetch-impl.d.ts +3 -0
  12. package/dist/connectors/capabilities/fetch-impl.d.ts.map +1 -0
  13. package/dist/connectors/capabilities/fetch-impl.js +4 -0
  14. package/dist/connectors/capabilities/fetch-impl.js.map +1 -0
  15. package/dist/connectors/capabilities/index.d.ts +6 -0
  16. package/dist/connectors/capabilities/index.d.ts.map +1 -0
  17. package/dist/connectors/capabilities/index.js +6 -0
  18. package/dist/connectors/capabilities/index.js.map +1 -0
  19. package/dist/connectors/capabilities/log-impl.d.ts +3 -0
  20. package/dist/connectors/capabilities/log-impl.d.ts.map +1 -0
  21. package/dist/connectors/capabilities/log-impl.js +26 -0
  22. package/dist/connectors/capabilities/log-impl.js.map +1 -0
  23. package/dist/connectors/capabilities/sqlite-impl.d.ts +3 -0
  24. package/dist/connectors/capabilities/sqlite-impl.d.ts.map +1 -0
  25. package/dist/connectors/capabilities/sqlite-impl.js +19 -0
  26. package/dist/connectors/capabilities/sqlite-impl.js.map +1 -0
  27. package/dist/connectors/loader.d.ts +47 -0
  28. package/dist/connectors/loader.d.ts.map +1 -0
  29. package/dist/connectors/loader.js +328 -0
  30. package/dist/connectors/loader.js.map +1 -0
  31. package/dist/connectors/npm-install.d.ts +26 -0
  32. package/dist/connectors/npm-install.d.ts.map +1 -0
  33. package/dist/connectors/npm-install.js +71 -0
  34. package/dist/connectors/npm-install.js.map +1 -0
  35. package/dist/connectors/prerequisites.d.ts +14 -0
  36. package/dist/connectors/prerequisites.d.ts.map +1 -0
  37. package/dist/connectors/prerequisites.js +109 -0
  38. package/dist/connectors/prerequisites.js.map +1 -0
  39. package/dist/connectors/registry-fetch.d.ts +22 -0
  40. package/dist/connectors/registry-fetch.d.ts.map +1 -0
  41. package/dist/connectors/registry-fetch.js +54 -0
  42. package/dist/connectors/registry-fetch.js.map +1 -0
  43. package/dist/connectors/registry.d.ts +23 -0
  44. package/dist/connectors/registry.d.ts.map +1 -0
  45. package/dist/connectors/registry.js +59 -0
  46. package/dist/connectors/registry.js.map +1 -0
  47. package/dist/connectors/sync-engine.d.ts +30 -0
  48. package/dist/connectors/sync-engine.d.ts.map +1 -0
  49. package/dist/connectors/sync-engine.js +589 -0
  50. package/dist/connectors/sync-engine.js.map +1 -0
  51. package/dist/connectors/sync-scheduler.d.ts +56 -0
  52. package/dist/connectors/sync-scheduler.d.ts.map +1 -0
  53. package/dist/connectors/sync-scheduler.js +238 -0
  54. package/dist/connectors/sync-scheduler.js.map +1 -0
  55. package/dist/connectors/trust-store.d.ts +13 -0
  56. package/dist/connectors/trust-store.d.ts.map +1 -0
  57. package/dist/connectors/trust-store.js +48 -0
  58. package/dist/connectors/trust-store.js.map +1 -0
  59. package/dist/connectors/types.d.ts +111 -0
  60. package/dist/connectors/types.d.ts.map +1 -0
  61. package/dist/connectors/types.js +50 -0
  62. package/dist/connectors/types.js.map +1 -0
  63. package/dist/db/db.d.ts +6 -0
  64. package/dist/db/db.d.ts.map +1 -0
  65. package/dist/db/db.js +347 -0
  66. package/dist/db/db.js.map +1 -0
  67. package/dist/db/queries.d.ts +68 -0
  68. package/dist/db/queries.d.ts.map +1 -0
  69. package/dist/db/queries.js +828 -0
  70. package/dist/db/queries.js.map +1 -0
  71. package/dist/db/search-query.d.ts +13 -0
  72. package/dist/db/search-query.d.ts.map +1 -0
  73. package/dist/db/search-query.js +62 -0
  74. package/dist/db/search-query.js.map +1 -0
  75. package/dist/index.d.ts +29 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +24 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/parsers/claude.d.ts +9 -0
  80. package/dist/parsers/claude.d.ts.map +1 -0
  81. package/dist/parsers/claude.js +153 -0
  82. package/dist/parsers/claude.js.map +1 -0
  83. package/dist/parsers/codex.d.ts +5 -0
  84. package/dist/parsers/codex.d.ts.map +1 -0
  85. package/dist/parsers/codex.js +192 -0
  86. package/dist/parsers/codex.js.map +1 -0
  87. package/dist/parsers/gemini.d.ts +4 -0
  88. package/dist/parsers/gemini.d.ts.map +1 -0
  89. package/dist/parsers/gemini.js +149 -0
  90. package/dist/parsers/gemini.js.map +1 -0
  91. package/dist/sync/source-paths.d.ts +5 -0
  92. package/dist/sync/source-paths.d.ts.map +1 -0
  93. package/dist/sync/source-paths.js +119 -0
  94. package/dist/sync/source-paths.js.map +1 -0
  95. package/dist/sync/syncer.d.ts +19 -0
  96. package/dist/sync/syncer.d.ts.map +1 -0
  97. package/dist/sync/syncer.js +308 -0
  98. package/dist/sync/syncer.js.map +1 -0
  99. package/dist/sync/watcher.d.ts +19 -0
  100. package/dist/sync/watcher.d.ts.map +1 -0
  101. package/dist/sync/watcher.js +67 -0
  102. package/dist/sync/watcher.js.map +1 -0
  103. package/dist/types.d.ts +112 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +2 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/util/resolve-bin.d.ts +6 -0
  108. package/dist/util/resolve-bin.d.ts.map +1 -0
  109. package/dist/util/resolve-bin.js +66 -0
  110. package/dist/util/resolve-bin.js.map +1 -0
  111. package/package.json +43 -0
@@ -0,0 +1,828 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { DB_PATH, getDBSize } from './db.js';
3
+ import { buildSearchPlan, canUseSessionSearchFts, getNaturalSearchPhrase, getNaturalSearchTerms, selectFtsTableKind } from './search-query.js';
4
+ export function getOrCreateProject(db, sourceId, slug, displayPath, displayName) {
5
+ const existing = db
6
+ .prepare('SELECT id FROM projects WHERE source_id = ? AND slug = ?')
7
+ .get(sourceId, slug);
8
+ if (existing)
9
+ return existing.id;
10
+ const result = db
11
+ .prepare('INSERT INTO projects (source_id, slug, display_path, display_name) VALUES (?, ?, ?, ?)')
12
+ .run(sourceId, slug, displayPath, displayName);
13
+ return Number(result.lastInsertRowid);
14
+ }
15
+ export function getSourceId(db, name) {
16
+ const row = db.prepare('SELECT id FROM sources WHERE name = ?').get(name);
17
+ if (!row)
18
+ throw new Error(`Source '${name}' not found in DB`);
19
+ return row.id;
20
+ }
21
+ export function getSessionMtime(db, filePath) {
22
+ const row = db
23
+ .prepare('SELECT raw_file_mtime FROM sessions WHERE file_path = ?')
24
+ .get(filePath);
25
+ return row?.raw_file_mtime ?? null;
26
+ }
27
+ export function deleteSessionByFilePath(db, filePath) {
28
+ const result = db.prepare('DELETE FROM sessions WHERE file_path = ?').run(filePath);
29
+ return result.changes > 0;
30
+ }
31
+ export function getAllSessionMtimes(db) {
32
+ const rows = db
33
+ .prepare('SELECT file_path, raw_file_mtime FROM sessions')
34
+ .all();
35
+ return new Map(rows.map(r => [r.file_path, r.raw_file_mtime]));
36
+ }
37
+ export function upsertSession(db, opts) {
38
+ const existing = db
39
+ .prepare('SELECT id FROM sessions WHERE session_uuid = ?')
40
+ .get(opts.sessionUuid);
41
+ if (existing) {
42
+ db.prepare('DELETE FROM messages WHERE session_id = ?').run(existing.id);
43
+ db.prepare(`
44
+ UPDATE sessions SET
45
+ title = ?, started_at = ?, ended_at = ?, message_count = ?,
46
+ has_tool_use = ?, cwd = ?, model = ?, raw_file_mtime = ?
47
+ WHERE id = ?
48
+ `).run(opts.title, opts.startedAt, opts.endedAt, opts.messageCount, opts.hasToolUse ? 1 : 0, opts.cwd, opts.model, opts.rawFileMtime, existing.id);
49
+ return existing.id;
50
+ }
51
+ const result = db.prepare(`
52
+ INSERT INTO sessions
53
+ (project_id, source_id, session_uuid, file_path, title,
54
+ started_at, ended_at, message_count, has_tool_use, cwd, model, raw_file_mtime)
55
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
56
+ `).run(opts.projectId, opts.sourceId, opts.sessionUuid, opts.filePath, opts.title, opts.startedAt, opts.endedAt, opts.messageCount, opts.hasToolUse ? 1 : 0, opts.cwd, opts.model, opts.rawFileMtime);
57
+ return Number(result.lastInsertRowid);
58
+ }
59
+ export function upsertSessionSearch(db, opts) {
60
+ db.prepare(`
61
+ INSERT INTO session_search (session_id, title, user_text, assistant_text, updated_at)
62
+ VALUES (?, ?, ?, ?, datetime('now'))
63
+ ON CONFLICT(session_id) DO UPDATE SET
64
+ title = excluded.title,
65
+ user_text = excluded.user_text,
66
+ assistant_text = excluded.assistant_text,
67
+ updated_at = datetime('now')
68
+ `).run(opts.sessionId, opts.title, opts.userText, opts.assistantText);
69
+ }
70
+ export function insertMessages(db, sessionId, sourceId, messages) {
71
+ const stmt = db.prepare(`
72
+ INSERT INTO messages
73
+ (session_id, source_id, msg_uuid, parent_uuid, role,
74
+ content_text, timestamp, is_sidechain, tool_names, seq)
75
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
76
+ `);
77
+ for (const m of messages) {
78
+ stmt.run(sessionId, sourceId, m.uuid, m.parentUuid, m.role, m.contentText, m.timestamp, m.isSidechain ? 1 : 0, JSON.stringify(m.toolNames), m.seq);
79
+ }
80
+ }
81
+ export function listRecentSessions(db, limit = 50) {
82
+ return db.prepare(`
83
+ SELECT
84
+ s.id, s.project_id AS projectId, s.source_id AS sourceId,
85
+ s.session_uuid AS sessionUuid, s.file_path AS filePath,
86
+ s.title, s.started_at AS startedAt, s.ended_at AS endedAt,
87
+ s.message_count AS messageCount, s.has_tool_use AS hasToolUse,
88
+ s.cwd, s.model,
89
+ src.name AS source,
90
+ p.display_path AS projectDisplayPath,
91
+ p.display_name AS projectDisplayName
92
+ FROM sessions s
93
+ JOIN sources src ON src.id = s.source_id
94
+ JOIN projects p ON p.id = s.project_id
95
+ ORDER BY s.started_at DESC
96
+ LIMIT ?
97
+ `).all(limit).map(rowToSession);
98
+ }
99
+ export function getSessionWithMessages(db, sessionUuid) {
100
+ const sessionRow = db.prepare(`
101
+ SELECT
102
+ s.id, s.project_id AS projectId, s.source_id AS sourceId,
103
+ s.session_uuid AS sessionUuid, s.file_path AS filePath,
104
+ s.title, s.started_at AS startedAt, s.ended_at AS endedAt,
105
+ s.message_count AS messageCount, s.has_tool_use AS hasToolUse,
106
+ s.cwd, s.model,
107
+ src.name AS source,
108
+ p.display_path AS projectDisplayPath,
109
+ p.display_name AS projectDisplayName
110
+ FROM sessions s
111
+ JOIN sources src ON src.id = s.source_id
112
+ JOIN projects p ON p.id = s.project_id
113
+ WHERE s.session_uuid = ?
114
+ `).get(sessionUuid);
115
+ if (!sessionRow)
116
+ return null;
117
+ const session = rowToSession(sessionRow);
118
+ const msgRows = db.prepare(`
119
+ SELECT id, session_id AS sessionId, msg_uuid AS msgUuid,
120
+ parent_uuid AS parentUuid, role, content_text AS contentText,
121
+ timestamp, is_sidechain AS isSidechain, tool_names AS toolNames, seq
122
+ FROM messages
123
+ WHERE session_id = ? AND is_sidechain = 0
124
+ ORDER BY seq
125
+ `).all(session.id);
126
+ const messages = msgRows.map(r => ({
127
+ id: r['id'],
128
+ sessionId: r['sessionId'],
129
+ msgUuid: r['msgUuid'],
130
+ parentUuid: r['parentUuid'],
131
+ role: r['role'],
132
+ contentText: r['contentText'],
133
+ timestamp: r['timestamp'],
134
+ isSidechain: Boolean(r['isSidechain']),
135
+ toolNames: JSON.parse(r['toolNames']),
136
+ seq: r['seq'],
137
+ }));
138
+ return { session, messages };
139
+ }
140
+ export function searchFragments(db, query, opts = {}) {
141
+ const { limit = 10, source, since } = opts;
142
+ const rowLimit = Math.max(limit * 10, 50);
143
+ const naturalTerms = getNaturalSearchTerms(query);
144
+ const naturalPhrase = getNaturalSearchPhrase(query);
145
+ const canUseSessionFts = canUseSessionSearchFts(query);
146
+ if (naturalTerms.length === 1) {
147
+ return searchFragmentSessionFallback(db, naturalTerms, naturalPhrase, rowLimit, 'fts', {
148
+ ...(source ? { source } : {}),
149
+ ...(since ? { since } : {}),
150
+ }).slice(0, limit);
151
+ }
152
+ const groups = buildSearchPlan(query).map(step => {
153
+ if (naturalTerms.length > 1 && (step.matchType === 'phrase' || step.matchType === 'all_terms')) {
154
+ return searchFragmentSessionFallback(db, naturalTerms, naturalPhrase, rowLimit, step.matchType, {
155
+ ...(source ? { source } : {}),
156
+ ...(since ? { since } : {}),
157
+ });
158
+ }
159
+ const ftsTable = selectFtsTableKind(query) === 'trigram' ? 'messages_fts_trigram' : 'messages_fts';
160
+ const rows = searchFragmentRows(db, ftsTable, step.query, rowLimit, {
161
+ ...(source ? { source } : {}),
162
+ ...(since ? { since } : {}),
163
+ });
164
+ return collapseFragmentRows(rows, step.matchType);
165
+ });
166
+ return mergeFragmentGroups(groups, limit);
167
+ }
168
+ export function searchSessionPreview(db, query, opts = {}) {
169
+ const { limit = 5, source, since } = opts;
170
+ const normalizedQuery = query.trim();
171
+ if (!normalizedQuery)
172
+ return [];
173
+ const terms = getNaturalSearchTerms(query);
174
+ const previewTerms = terms.length > 0 ? terms : [normalizedQuery];
175
+ const rows = searchPreviewRows(db, previewTerms, limit, {
176
+ ...(source ? { source } : {}),
177
+ ...(since ? { since } : {}),
178
+ });
179
+ const snippetRows = selectBestSessionSnippets(db, rows.map(row => row['sessionId']), previewTerms);
180
+ return rows.map((row, index) => {
181
+ const sessionId = row['sessionId'];
182
+ const snippetRow = snippetRows.get(sessionId);
183
+ const snippetSource = snippetRow?.contentText ?? String(row['sessionTitle'] ?? '');
184
+ const profileLabel = getProfileLabelFromFilePath(row['filePath']);
185
+ return {
186
+ rank: index + 1,
187
+ sessionId,
188
+ sessionUuid: row['sessionUuid'],
189
+ sessionTitle: row['sessionTitle'] ?? '(no title)',
190
+ matchCount: snippetRow?.matchingMessageCount ?? 1,
191
+ matchType: 'all_terms',
192
+ source: row['source'],
193
+ ...(profileLabel ? { profileLabel } : {}),
194
+ ...(row['cwd'] ? { cwd: row['cwd'] } : {}),
195
+ project: row['project'],
196
+ startedAt: row['startedAt'],
197
+ snippet: buildLikeSnippet(snippetSource, previewTerms),
198
+ messageId: snippetRow?.messageId ?? 0,
199
+ messageRole: snippetRow?.messageRole ?? 'system',
200
+ messageTimestamp: snippetRow?.messageTimestamp ?? row['startedAt'],
201
+ };
202
+ });
203
+ }
204
+ function searchFragmentRows(db, ftsTable, ftsQuery, limit, opts = {}) {
205
+ const { source, since } = opts;
206
+ const conditions = [`${ftsTable} MATCH ?`, 'm.is_sidechain = 0'];
207
+ const params = [ftsQuery];
208
+ if (source) {
209
+ conditions.push('src2.name = ?');
210
+ params.push(source);
211
+ }
212
+ if (since) {
213
+ conditions.push('m.timestamp >= ?');
214
+ params.push(since);
215
+ }
216
+ params.push(limit);
217
+ const sql = `
218
+ SELECT
219
+ rank,
220
+ m.id AS messageId,
221
+ m.role AS messageRole,
222
+ m.timestamp AS messageTimestamp,
223
+ sess.id AS sessionId,
224
+ sess.session_uuid AS sessionUuid,
225
+ sess.file_path AS filePath,
226
+ sess.title AS sessionTitle,
227
+ sess.started_at AS startedAt,
228
+ sess.cwd AS cwd,
229
+ p.display_path AS project,
230
+ src2.name AS source,
231
+ snippet(${ftsTable}, -1, '<mark>', '</mark>', '…', 20) AS snippet
232
+ FROM ${ftsTable}
233
+ JOIN messages m ON m.id = ${ftsTable}.rowid
234
+ JOIN sessions sess ON sess.id = m.session_id
235
+ JOIN projects p ON p.id = sess.project_id
236
+ JOIN sources src2 ON src2.id = sess.source_id
237
+ WHERE ${conditions.join(' AND ')}
238
+ ORDER BY rank
239
+ LIMIT ?
240
+ `;
241
+ return db.prepare(sql).all(...params);
242
+ }
243
+ function searchPreviewRows(db, terms, limit, opts = {}) {
244
+ const { source, since } = opts;
245
+ const scoreParts = [];
246
+ const scoreParams = [];
247
+ const whereClauses = [];
248
+ const whereParams = [];
249
+ for (const term of terms) {
250
+ const containsPattern = toLikePattern(term);
251
+ const prefixPattern = `${escapeLike(term)}%`;
252
+ scoreParts.push(`CASE WHEN ss.title LIKE ? ESCAPE '\\' THEN 20 ELSE 0 END`);
253
+ scoreParams.push(prefixPattern);
254
+ scoreParts.push(`CASE WHEN ss.title LIKE ? ESCAPE '\\' THEN 8 ELSE 0 END`);
255
+ scoreParams.push(containsPattern);
256
+ scoreParts.push(`CASE WHEN ss.user_text LIKE ? ESCAPE '\\' THEN 4 ELSE 0 END`);
257
+ scoreParams.push(containsPattern);
258
+ scoreParts.push(`CASE WHEN ss.assistant_text LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END`);
259
+ scoreParams.push(containsPattern);
260
+ whereClauses.push(`(
261
+ ss.title LIKE ? ESCAPE '\\'
262
+ OR ss.user_text LIKE ? ESCAPE '\\'
263
+ OR ss.assistant_text LIKE ? ESCAPE '\\'
264
+ )`);
265
+ whereParams.push(containsPattern, containsPattern, containsPattern);
266
+ }
267
+ const conditions = [...whereClauses];
268
+ const params = [...scoreParams, ...whereParams];
269
+ if (source) {
270
+ conditions.push('src2.name = ?');
271
+ params.push(source);
272
+ }
273
+ if (since) {
274
+ conditions.push('sess.started_at >= ?');
275
+ params.push(since);
276
+ }
277
+ params.push(limit);
278
+ const previewScoreExpr = scoreParts.join(' + ');
279
+ const sql = `
280
+ SELECT
281
+ sess.id AS sessionId,
282
+ sess.session_uuid AS sessionUuid,
283
+ sess.file_path AS filePath,
284
+ sess.title AS sessionTitle,
285
+ sess.started_at AS startedAt,
286
+ sess.cwd AS cwd,
287
+ p.display_path AS project,
288
+ src2.name AS source,
289
+ ${previewScoreExpr} AS previewScore
290
+ FROM sessions sess
291
+ JOIN session_search ss ON ss.session_id = sess.id
292
+ JOIN projects p ON p.id = sess.project_id
293
+ JOIN sources src2 ON src2.id = sess.source_id
294
+ WHERE ${conditions.join(' AND ')}
295
+ ORDER BY previewScore DESC, sess.started_at DESC
296
+ LIMIT ?
297
+ `;
298
+ return db.prepare(sql).all(...params);
299
+ }
300
+ function collapseFragmentRows(rows, matchType) {
301
+ const seen = new Map();
302
+ const ordered = [];
303
+ for (const row of rows) {
304
+ const sessionUuid = row['sessionUuid'];
305
+ const existing = seen.get(sessionUuid);
306
+ if (existing) {
307
+ existing.matchCount += 1;
308
+ continue;
309
+ }
310
+ const profileLabel = getProfileLabelFromFilePath(row['filePath']);
311
+ const fragment = {
312
+ rank: ordered.length + 1,
313
+ sessionId: row['sessionId'],
314
+ sessionUuid,
315
+ sessionTitle: row['sessionTitle'] ?? '(no title)',
316
+ matchCount: 1,
317
+ matchType,
318
+ source: row['source'],
319
+ ...(profileLabel ? { profileLabel } : {}),
320
+ ...(row['cwd'] ? { cwd: row['cwd'] } : {}),
321
+ project: row['project'],
322
+ startedAt: row['startedAt'],
323
+ snippet: row['snippet'],
324
+ messageId: row['messageId'],
325
+ messageRole: row['messageRole'],
326
+ messageTimestamp: row['messageTimestamp'],
327
+ };
328
+ seen.set(sessionUuid, fragment);
329
+ ordered.push(fragment);
330
+ }
331
+ return ordered;
332
+ }
333
+ function mergeFragmentGroups(groups, limit) {
334
+ const merged = [];
335
+ const seen = new Map();
336
+ for (const group of groups) {
337
+ for (const fragment of group) {
338
+ const existing = seen.get(fragment.sessionUuid);
339
+ if (existing) {
340
+ existing.matchCount = Math.max(existing.matchCount, fragment.matchCount);
341
+ continue;
342
+ }
343
+ if (merged.length >= limit)
344
+ continue;
345
+ const next = {
346
+ ...fragment,
347
+ rank: merged.length + 1,
348
+ };
349
+ merged.push(next);
350
+ seen.set(next.sessionUuid, next);
351
+ }
352
+ }
353
+ return merged;
354
+ }
355
+ function searchFragmentSessionFallback(db, terms, phrase, limit, matchType, opts = {}) {
356
+ if (terms.length < 1)
357
+ return [];
358
+ const rows = searchSessionRowsByTerms(db, terms, phrase, limit, matchType, opts);
359
+ const snippetRows = selectBestSessionSnippets(db, rows.map(row => row['sessionId']), terms);
360
+ const ranked = rows.map((row) => {
361
+ const snippetRow = snippetRows.get(row['sessionId']);
362
+ const snippetSource = snippetRow?.contentText ?? String(row['sessionTitle'] ?? '');
363
+ const profileLabel = getProfileLabelFromFilePath(row['filePath']);
364
+ return {
365
+ sessionId: row['sessionId'],
366
+ sessionUuid: row['sessionUuid'],
367
+ sessionTitle: row['sessionTitle'] ?? '(no title)',
368
+ matchCount: snippetRow?.matchingMessageCount ?? 1,
369
+ matchType,
370
+ source: row['source'],
371
+ ...(profileLabel ? { profileLabel } : {}),
372
+ ...(row['cwd'] ? { cwd: row['cwd'] } : {}),
373
+ project: row['project'],
374
+ startedAt: row['startedAt'],
375
+ snippet: buildLikeSnippet(snippetSource, terms),
376
+ messageId: snippetRow?.messageId ?? 0,
377
+ messageRole: snippetRow?.messageRole ?? 'system',
378
+ messageTimestamp: snippetRow?.messageTimestamp ?? row['startedAt'],
379
+ _titleMatchScore: row['titleMatchScore'],
380
+ _userMatchScore: row['userMatchScore'],
381
+ _assistantMatchScore: row['assistantMatchScore'],
382
+ _sameMessageCoverage: snippetRow?.termCoverage ?? 0,
383
+ };
384
+ });
385
+ ranked.sort((a, b) => {
386
+ if (b._sameMessageCoverage !== a._sameMessageCoverage) {
387
+ return b._sameMessageCoverage - a._sameMessageCoverage;
388
+ }
389
+ if (b._titleMatchScore !== a._titleMatchScore) {
390
+ return b._titleMatchScore - a._titleMatchScore;
391
+ }
392
+ if (b._userMatchScore !== a._userMatchScore) {
393
+ return b._userMatchScore - a._userMatchScore;
394
+ }
395
+ if (b._assistantMatchScore !== a._assistantMatchScore) {
396
+ return b._assistantMatchScore - a._assistantMatchScore;
397
+ }
398
+ return String(b.startedAt).localeCompare(String(a.startedAt));
399
+ });
400
+ return ranked.map((row, index) => ({
401
+ rank: index + 1,
402
+ sessionId: row.sessionId,
403
+ sessionUuid: row.sessionUuid,
404
+ sessionTitle: row.sessionTitle,
405
+ matchCount: row.matchCount,
406
+ matchType: row.matchType,
407
+ source: row.source,
408
+ ...(row.profileLabel ? { profileLabel: row.profileLabel } : {}),
409
+ ...(row.cwd ? { cwd: row.cwd } : {}),
410
+ project: row.project,
411
+ startedAt: row.startedAt,
412
+ snippet: row.snippet,
413
+ messageId: row.messageId,
414
+ messageRole: row.messageRole,
415
+ messageTimestamp: row.messageTimestamp,
416
+ }));
417
+ }
418
+ function searchSessionRowsByTerms(db, terms, phrase, limit, matchType, opts = {}) {
419
+ if (canUseSessionSearchFts(phrase)) {
420
+ return searchSessionRowsByFts(db, terms, phrase, limit, matchType, opts);
421
+ }
422
+ return searchSessionRowsByLike(db, terms, limit, opts);
423
+ }
424
+ function searchSessionRowsByLike(db, terms, limit, opts = {}) {
425
+ const { source, since } = opts;
426
+ const titleScoreParts = [];
427
+ const whereClauses = [];
428
+ const scoreParams = [];
429
+ const whereParams = [];
430
+ for (const term of terms) {
431
+ const pattern = toLikePattern(term);
432
+ titleScoreParts.push(`CASE WHEN ss.title LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END`);
433
+ scoreParams.push(pattern);
434
+ titleScoreParts.push(`CASE WHEN ss.user_text LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END`);
435
+ scoreParams.push(pattern);
436
+ titleScoreParts.push(`CASE WHEN ss.assistant_text LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END`);
437
+ scoreParams.push(pattern);
438
+ whereClauses.push(`(
439
+ ss.title LIKE ? ESCAPE '\\'
440
+ OR ss.user_text LIKE ? ESCAPE '\\'
441
+ OR ss.assistant_text LIKE ? ESCAPE '\\'
442
+ )`);
443
+ whereParams.push(pattern, pattern, pattern);
444
+ }
445
+ const conditions = [...whereClauses];
446
+ const params = [...scoreParams, ...whereParams];
447
+ if (source) {
448
+ conditions.push('src2.name = ?');
449
+ params.push(source);
450
+ }
451
+ if (since) {
452
+ conditions.push('sess.started_at >= ?');
453
+ params.push(since);
454
+ }
455
+ params.push(limit);
456
+ const titleScoreExpr = titleScoreParts.filter((_, index) => index % 3 === 0).join(' + ');
457
+ const userScoreExpr = titleScoreParts.filter((_, index) => index % 3 === 1).join(' + ');
458
+ const assistantScoreExpr = titleScoreParts.filter((_, index) => index % 3 === 2).join(' + ');
459
+ const sql = `
460
+ SELECT
461
+ sess.id AS sessionId,
462
+ sess.session_uuid AS sessionUuid,
463
+ sess.file_path AS filePath,
464
+ sess.title AS sessionTitle,
465
+ sess.started_at AS startedAt,
466
+ sess.cwd AS cwd,
467
+ p.display_path AS project,
468
+ src2.name AS source,
469
+ ${titleScoreExpr} AS titleMatchScore,
470
+ ${userScoreExpr} AS userMatchScore,
471
+ ${assistantScoreExpr} AS assistantMatchScore
472
+ FROM sessions sess
473
+ JOIN session_search ss ON ss.session_id = sess.id
474
+ JOIN projects p ON p.id = sess.project_id
475
+ JOIN sources src2 ON src2.id = sess.source_id
476
+ WHERE ${conditions.join(' AND ')}
477
+ ORDER BY titleMatchScore DESC, userMatchScore DESC, assistantMatchScore DESC, sess.started_at DESC
478
+ LIMIT ?
479
+ `;
480
+ return db.prepare(sql).all(...params);
481
+ }
482
+ function searchSessionRowsByFts(db, terms, phrase, limit, matchType, opts = {}) {
483
+ const { source, since } = opts;
484
+ const ftsTable = selectFtsTableKind(phrase) === 'trigram' ? 'session_search_fts_trigram' : 'session_search_fts';
485
+ const ftsQuery = matchType === 'phrase'
486
+ ? `"${phrase.replace(/"/g, '""')}"`
487
+ : terms.map(term => `"${term.replace(/"/g, '""')}"`).join(' AND ');
488
+ const titleScoreParts = [];
489
+ const scoreParams = [];
490
+ const likeParams = [];
491
+ for (const term of terms) {
492
+ const pattern = toLikePattern(term);
493
+ titleScoreParts.push(`CASE WHEN ss.title LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END`);
494
+ scoreParams.push(pattern);
495
+ titleScoreParts.push(`CASE WHEN ss.user_text LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END`);
496
+ scoreParams.push(pattern);
497
+ titleScoreParts.push(`CASE WHEN ss.assistant_text LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END`);
498
+ scoreParams.push(pattern);
499
+ }
500
+ const conditions = [`${ftsTable} MATCH ?`];
501
+ const params = [...scoreParams, ftsQuery];
502
+ if (source) {
503
+ conditions.push('src2.name = ?');
504
+ params.push(source);
505
+ }
506
+ if (since) {
507
+ conditions.push('sess.started_at >= ?');
508
+ params.push(since);
509
+ }
510
+ params.push(limit);
511
+ const titleScoreExpr = titleScoreParts.filter((_, index) => index % 3 === 0).join(' + ');
512
+ const userScoreExpr = titleScoreParts.filter((_, index) => index % 3 === 1).join(' + ');
513
+ const assistantScoreExpr = titleScoreParts.filter((_, index) => index % 3 === 2).join(' + ');
514
+ const sql = `
515
+ SELECT
516
+ sess.id AS sessionId,
517
+ sess.session_uuid AS sessionUuid,
518
+ sess.file_path AS filePath,
519
+ sess.title AS sessionTitle,
520
+ sess.started_at AS startedAt,
521
+ sess.cwd AS cwd,
522
+ p.display_path AS project,
523
+ src2.name AS source,
524
+ bm25(${ftsTable}, 5.0, 1.5, 0.8) AS ftsScore,
525
+ ${titleScoreExpr} AS titleMatchScore,
526
+ ${userScoreExpr} AS userMatchScore,
527
+ ${assistantScoreExpr} AS assistantMatchScore
528
+ FROM ${ftsTable}
529
+ JOIN session_search ss ON ss.session_id = ${ftsTable}.rowid
530
+ JOIN sessions sess ON sess.id = ss.session_id
531
+ JOIN projects p ON p.id = sess.project_id
532
+ JOIN sources src2 ON src2.id = sess.source_id
533
+ WHERE ${conditions.join(' AND ')}
534
+ ORDER BY titleMatchScore DESC, userMatchScore DESC, assistantMatchScore DESC, ftsScore ASC, sess.started_at DESC
535
+ LIMIT ?
536
+ `;
537
+ return db.prepare(sql).all(...params);
538
+ }
539
+ function selectBestSessionSnippets(db, sessionIds, terms) {
540
+ if (sessionIds.length === 0)
541
+ return new Map();
542
+ const coverageExpr = terms.map(() => `CASE WHEN content_text LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END`).join(' + ');
543
+ const anyClauses = terms.map(() => 'content_text LIKE ? ESCAPE \'\\\'').join(' OR ');
544
+ const allPatterns = terms.map(toLikePattern);
545
+ const anyPatterns = terms.map(toLikePattern);
546
+ const sessionPlaceholders = sessionIds.map(() => '?').join(', ');
547
+ const matchSql = `
548
+ WITH raw AS (
549
+ SELECT
550
+ id AS messageId,
551
+ session_id AS sessionId,
552
+ role AS messageRole,
553
+ timestamp AS messageTimestamp,
554
+ content_text AS contentText,
555
+ seq,
556
+ ${coverageExpr} AS termCoverage
557
+ FROM messages
558
+ WHERE session_id IN (${sessionPlaceholders})
559
+ AND is_sidechain = 0
560
+ AND (${anyClauses})
561
+ ),
562
+ ranked AS (
563
+ SELECT
564
+ sessionId,
565
+ messageId,
566
+ messageRole,
567
+ messageTimestamp,
568
+ contentText,
569
+ termCoverage,
570
+ COUNT(*) OVER (PARTITION BY sessionId) AS matchingMessageCount,
571
+ ROW_NUMBER() OVER (
572
+ PARTITION BY sessionId
573
+ ORDER BY termCoverage DESC, CASE WHEN messageRole = 'user' THEN 0 ELSE 1 END, seq
574
+ ) AS rn
575
+ FROM raw
576
+ )
577
+ SELECT
578
+ sessionId,
579
+ messageId,
580
+ messageRole,
581
+ messageTimestamp,
582
+ contentText,
583
+ termCoverage,
584
+ matchingMessageCount
585
+ FROM ranked
586
+ WHERE rn = 1
587
+ `;
588
+ const rows = db.prepare(matchSql).all(...allPatterns, ...sessionIds, ...anyPatterns);
589
+ return new Map(rows.map(row => [row.sessionId, row]));
590
+ }
591
+ export function buildLikeSnippet(text, terms) {
592
+ const normalizedText = text.trim();
593
+ if (!normalizedText)
594
+ return '';
595
+ // Case-insensitive hit search: the upstream SQL uses LIKE which is ASCII
596
+ // case-insensitive, so the matched content may differ in case from the
597
+ // query terms (e.g. user typed "dark fantasy", text has "Dark Fantasy").
598
+ const lowerText = normalizedText.toLowerCase();
599
+ const firstHit = terms
600
+ .map(term => lowerText.indexOf(term.toLowerCase()))
601
+ .filter(index => index >= 0)
602
+ .sort((a, b) => a - b)[0] ?? 0;
603
+ const start = Math.max(0, firstHit - 60);
604
+ const end = Math.min(normalizedText.length, firstHit + 140);
605
+ let snippet = normalizedText.slice(start, end);
606
+ if (start > 0)
607
+ snippet = `…${snippet}`;
608
+ if (end < normalizedText.length)
609
+ snippet = `${snippet}…`;
610
+ // Highlight preserving original casing via case-insensitive regex.
611
+ const uniqueTerms = Array.from(new Set(terms)).sort((a, b) => b.length - a.length);
612
+ for (const term of uniqueTerms) {
613
+ if (!term)
614
+ continue;
615
+ const pattern = new RegExp(escapeRegex(term), 'gi');
616
+ snippet = snippet.replace(pattern, m => `<mark>${m}</mark>`);
617
+ }
618
+ return snippet;
619
+ }
620
+ function escapeRegex(value) {
621
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
622
+ }
623
+ function toLikePattern(term) {
624
+ return `%${escapeLike(term)}%`;
625
+ }
626
+ function escapeLike(value) {
627
+ return value
628
+ .replace(/\\/g, '\\\\')
629
+ .replace(/%/g, '\\%')
630
+ .replace(/_/g, '\\_');
631
+ }
632
+ export function getStatus(db) {
633
+ const counts = db.prepare(`
634
+ SELECT src.name, COUNT(*) AS cnt
635
+ FROM sessions s JOIN sources src ON src.id = s.source_id
636
+ GROUP BY src.name
637
+ `).all();
638
+ const lastSync = db.prepare(`
639
+ SELECT MAX(synced_at) AS last FROM sync_log WHERE status = 'ok'
640
+ `).get();
641
+ const totalSessions = counts.reduce((sum, r) => sum + r.cnt, 0);
642
+ const claudeRow = counts.find(r => r.name === 'claude');
643
+ const codexRow = counts.find(r => r.name === 'codex');
644
+ const geminiRow = counts.find(r => r.name === 'gemini');
645
+ return {
646
+ dbPath: DB_PATH,
647
+ totalSessions,
648
+ claudeSessions: claudeRow?.cnt ?? 0,
649
+ codexSessions: codexRow?.cnt ?? 0,
650
+ geminiSessions: geminiRow?.cnt ?? 0,
651
+ lastSyncedAt: lastSync?.last ?? null,
652
+ dbSizeBytes: getDBSize(),
653
+ };
654
+ }
655
+ function rowToSession(r) {
656
+ return {
657
+ id: r['id'],
658
+ projectId: r['projectId'],
659
+ sourceId: r['sourceId'],
660
+ sessionUuid: r['sessionUuid'],
661
+ filePath: r['filePath'],
662
+ title: r['title'],
663
+ startedAt: r['startedAt'],
664
+ endedAt: r['endedAt'],
665
+ messageCount: r['messageCount'],
666
+ hasToolUse: Boolean(r['hasToolUse']),
667
+ cwd: r['cwd'],
668
+ model: r['model'],
669
+ source: r['source'],
670
+ projectDisplayPath: r['projectDisplayPath'],
671
+ projectDisplayName: r['projectDisplayName'],
672
+ };
673
+ }
674
+ function getProfileLabelFromFilePath(filePath) {
675
+ const match = filePath.match(/\/\.(?:claude|codex)-profiles\/([^/]+)\//);
676
+ return match?.[1];
677
+ }
678
+ // ── Captures ────────────────────────────────────────────────────────────────
679
+ export function insertCapture(db, sourceId, item) {
680
+ const captureUuid = randomUUID();
681
+ // Dedup by platform_id if provided
682
+ if (item.platformId) {
683
+ const existing = db
684
+ .prepare('SELECT id FROM captures WHERE platform = ? AND platform_id = ?')
685
+ .get(item.platform, item.platformId);
686
+ if (existing) {
687
+ db.prepare(`
688
+ UPDATE captures SET
689
+ title = ?, content_text = ?, author = ?, metadata = ?,
690
+ captured_at = ?, raw_json = ?, thumbnail_url = ?
691
+ WHERE id = ?
692
+ `).run(item.title, item.contentText, item.author, JSON.stringify(item.metadata), item.capturedAt, item.rawJson, item.thumbnailUrl, existing.id);
693
+ return existing.id;
694
+ }
695
+ }
696
+ const result = db.prepare(`
697
+ INSERT INTO captures
698
+ (source_id, capture_uuid, url, title, content_text,
699
+ author, platform, platform_id, content_type, thumbnail_url,
700
+ metadata, captured_at, raw_json)
701
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
702
+ `).run(sourceId, captureUuid, item.url, item.title, item.contentText, item.author, item.platform, item.platformId, item.contentType, item.thumbnailUrl, JSON.stringify(item.metadata), item.capturedAt, item.rawJson);
703
+ return Number(result.lastInsertRowid);
704
+ }
705
+ export function searchCaptures(db, query, opts = {}) {
706
+ const { limit = 10, platform, since } = opts;
707
+ const ftsTable = selectFtsTableKind(query) === 'trigram' ? 'captures_fts_trigram' : 'captures_fts';
708
+ const rowLimit = Math.max(limit * 10, 50);
709
+ const groups = buildSearchPlan(query).map(step => {
710
+ const rows = searchCaptureRows(db, ftsTable, step.query, rowLimit, {
711
+ ...(platform ? { platform } : {}),
712
+ ...(since ? { since } : {}),
713
+ });
714
+ return mapCaptureRows(rows, step.matchType);
715
+ });
716
+ return mergeCaptureGroups(groups, limit);
717
+ }
718
+ function searchCaptureRows(db, ftsTable, ftsQuery, limit, opts = {}) {
719
+ const { platform, since } = opts;
720
+ const conditions = [`${ftsTable} MATCH ?`];
721
+ const params = [ftsQuery];
722
+ if (platform) {
723
+ conditions.push('c.platform = ?');
724
+ params.push(platform);
725
+ }
726
+ if (since) {
727
+ conditions.push('c.captured_at >= ?');
728
+ params.push(since);
729
+ }
730
+ params.push(limit);
731
+ const sql = `
732
+ SELECT
733
+ rank,
734
+ c.id AS captureId,
735
+ c.capture_uuid AS captureUuid,
736
+ c.url,
737
+ c.title,
738
+ c.author,
739
+ c.platform,
740
+ c.content_type AS contentType,
741
+ c.captured_at AS capturedAt,
742
+ snippet(${ftsTable}, -1, '<mark>', '</mark>', '…', 20) AS snippet
743
+ FROM ${ftsTable}
744
+ JOIN captures c ON c.id = ${ftsTable}.rowid
745
+ WHERE ${conditions.join(' AND ')}
746
+ ORDER BY rank
747
+ LIMIT ?
748
+ `;
749
+ return db.prepare(sql).all(...params);
750
+ }
751
+ function mapCaptureRows(rows, matchType) {
752
+ return rows.map((row, i) => ({
753
+ rank: i + 1,
754
+ captureId: row['captureId'],
755
+ captureUuid: row['captureUuid'],
756
+ matchType,
757
+ url: row['url'],
758
+ title: row['title'] || '(no title)',
759
+ snippet: row['snippet'],
760
+ platform: row['platform'],
761
+ contentType: row['contentType'],
762
+ author: row['author'] ?? null,
763
+ capturedAt: row['capturedAt'],
764
+ }));
765
+ }
766
+ function mergeCaptureGroups(groups, limit) {
767
+ const merged = [];
768
+ const seen = new Set();
769
+ for (const group of groups) {
770
+ for (const capture of group) {
771
+ if (seen.has(capture.captureUuid))
772
+ continue;
773
+ if (merged.length >= limit)
774
+ continue;
775
+ const next = {
776
+ ...capture,
777
+ rank: merged.length + 1,
778
+ };
779
+ merged.push(next);
780
+ seen.add(next.captureUuid);
781
+ }
782
+ }
783
+ return merged;
784
+ }
785
+ export function searchAll(db, query, opts = {}) {
786
+ const { limit = 20, source, since } = opts;
787
+ const fragOpts = { limit };
788
+ if (source === 'claude' || source === 'codex' || source === 'gemini')
789
+ fragOpts.source = source;
790
+ if (since)
791
+ fragOpts.since = since;
792
+ const fragments = searchFragments(db, query, fragOpts)
793
+ .map(f => ({ ...f, kind: 'fragment' }));
794
+ const capOpts = { limit };
795
+ if (since)
796
+ capOpts.since = since;
797
+ const captures = searchCaptures(db, query, capOpts)
798
+ .map(c => ({ ...c, kind: 'capture' }));
799
+ return [...fragments, ...captures]
800
+ .sort(compareSearchResultRelevance)
801
+ .slice(0, limit)
802
+ .map((result, index) => ({ ...result, rank: index + 1 }));
803
+ }
804
+ function compareSearchResultRelevance(a, b) {
805
+ const typeDiff = getMatchTypePriority(a.matchType) - getMatchTypePriority(b.matchType);
806
+ if (typeDiff !== 0)
807
+ return typeDiff;
808
+ return a.rank - b.rank;
809
+ }
810
+ function getMatchTypePriority(matchType) {
811
+ switch (matchType) {
812
+ case 'phrase':
813
+ return 0;
814
+ case 'all_terms':
815
+ return 1;
816
+ default:
817
+ return 2;
818
+ }
819
+ }
820
+ export function getCaptureCount(db, platform) {
821
+ if (platform) {
822
+ const row = db.prepare('SELECT COUNT(*) AS cnt FROM captures WHERE platform = ?').get(platform);
823
+ return row.cnt;
824
+ }
825
+ const row = db.prepare('SELECT COUNT(*) AS cnt FROM captures').get();
826
+ return row.cnt;
827
+ }
828
+ //# sourceMappingURL=queries.js.map