@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.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/connectors/capabilities/cookies-chrome.d.ts +19 -0
- package/dist/connectors/capabilities/cookies-chrome.d.ts.map +1 -0
- package/dist/connectors/capabilities/cookies-chrome.js +199 -0
- package/dist/connectors/capabilities/cookies-chrome.js.map +1 -0
- package/dist/connectors/capabilities/exec-impl.d.ts +3 -0
- package/dist/connectors/capabilities/exec-impl.d.ts.map +1 -0
- package/dist/connectors/capabilities/exec-impl.js +108 -0
- package/dist/connectors/capabilities/exec-impl.js.map +1 -0
- package/dist/connectors/capabilities/fetch-impl.d.ts +3 -0
- package/dist/connectors/capabilities/fetch-impl.d.ts.map +1 -0
- package/dist/connectors/capabilities/fetch-impl.js +4 -0
- package/dist/connectors/capabilities/fetch-impl.js.map +1 -0
- package/dist/connectors/capabilities/index.d.ts +6 -0
- package/dist/connectors/capabilities/index.d.ts.map +1 -0
- package/dist/connectors/capabilities/index.js +6 -0
- package/dist/connectors/capabilities/index.js.map +1 -0
- package/dist/connectors/capabilities/log-impl.d.ts +3 -0
- package/dist/connectors/capabilities/log-impl.d.ts.map +1 -0
- package/dist/connectors/capabilities/log-impl.js +26 -0
- package/dist/connectors/capabilities/log-impl.js.map +1 -0
- package/dist/connectors/capabilities/sqlite-impl.d.ts +3 -0
- package/dist/connectors/capabilities/sqlite-impl.d.ts.map +1 -0
- package/dist/connectors/capabilities/sqlite-impl.js +19 -0
- package/dist/connectors/capabilities/sqlite-impl.js.map +1 -0
- package/dist/connectors/loader.d.ts +47 -0
- package/dist/connectors/loader.d.ts.map +1 -0
- package/dist/connectors/loader.js +328 -0
- package/dist/connectors/loader.js.map +1 -0
- package/dist/connectors/npm-install.d.ts +26 -0
- package/dist/connectors/npm-install.d.ts.map +1 -0
- package/dist/connectors/npm-install.js +71 -0
- package/dist/connectors/npm-install.js.map +1 -0
- package/dist/connectors/prerequisites.d.ts +14 -0
- package/dist/connectors/prerequisites.d.ts.map +1 -0
- package/dist/connectors/prerequisites.js +109 -0
- package/dist/connectors/prerequisites.js.map +1 -0
- package/dist/connectors/registry-fetch.d.ts +22 -0
- package/dist/connectors/registry-fetch.d.ts.map +1 -0
- package/dist/connectors/registry-fetch.js +54 -0
- package/dist/connectors/registry-fetch.js.map +1 -0
- package/dist/connectors/registry.d.ts +23 -0
- package/dist/connectors/registry.d.ts.map +1 -0
- package/dist/connectors/registry.js +59 -0
- package/dist/connectors/registry.js.map +1 -0
- package/dist/connectors/sync-engine.d.ts +30 -0
- package/dist/connectors/sync-engine.d.ts.map +1 -0
- package/dist/connectors/sync-engine.js +589 -0
- package/dist/connectors/sync-engine.js.map +1 -0
- package/dist/connectors/sync-scheduler.d.ts +56 -0
- package/dist/connectors/sync-scheduler.d.ts.map +1 -0
- package/dist/connectors/sync-scheduler.js +238 -0
- package/dist/connectors/sync-scheduler.js.map +1 -0
- package/dist/connectors/trust-store.d.ts +13 -0
- package/dist/connectors/trust-store.d.ts.map +1 -0
- package/dist/connectors/trust-store.js +48 -0
- package/dist/connectors/trust-store.js.map +1 -0
- package/dist/connectors/types.d.ts +111 -0
- package/dist/connectors/types.d.ts.map +1 -0
- package/dist/connectors/types.js +50 -0
- package/dist/connectors/types.js.map +1 -0
- package/dist/db/db.d.ts +6 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +347 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/queries.d.ts +68 -0
- package/dist/db/queries.d.ts.map +1 -0
- package/dist/db/queries.js +828 -0
- package/dist/db/queries.js.map +1 -0
- package/dist/db/search-query.d.ts +13 -0
- package/dist/db/search-query.d.ts.map +1 -0
- package/dist/db/search-query.js +62 -0
- package/dist/db/search-query.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers/claude.d.ts +9 -0
- package/dist/parsers/claude.d.ts.map +1 -0
- package/dist/parsers/claude.js +153 -0
- package/dist/parsers/claude.js.map +1 -0
- package/dist/parsers/codex.d.ts +5 -0
- package/dist/parsers/codex.d.ts.map +1 -0
- package/dist/parsers/codex.js +192 -0
- package/dist/parsers/codex.js.map +1 -0
- package/dist/parsers/gemini.d.ts +4 -0
- package/dist/parsers/gemini.d.ts.map +1 -0
- package/dist/parsers/gemini.js +149 -0
- package/dist/parsers/gemini.js.map +1 -0
- package/dist/sync/source-paths.d.ts +5 -0
- package/dist/sync/source-paths.d.ts.map +1 -0
- package/dist/sync/source-paths.js +119 -0
- package/dist/sync/source-paths.js.map +1 -0
- package/dist/sync/syncer.d.ts +19 -0
- package/dist/sync/syncer.d.ts.map +1 -0
- package/dist/sync/syncer.js +308 -0
- package/dist/sync/syncer.js.map +1 -0
- package/dist/sync/watcher.d.ts +19 -0
- package/dist/sync/watcher.d.ts.map +1 -0
- package/dist/sync/watcher.js +67 -0
- package/dist/sync/watcher.js.map +1 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/util/resolve-bin.d.ts +6 -0
- package/dist/util/resolve-bin.d.ts.map +1 -0
- package/dist/util/resolve-bin.js +66 -0
- package/dist/util/resolve-bin.js.map +1 -0
- 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
|