@spool-lab/core 0.5.2 → 0.6.1
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/README.md +22 -14
- package/dist/db/db.d.ts +2 -2
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +106 -15
- package/dist/db/db.js.map +1 -1
- package/dist/db/native-binding.d.ts +21 -0
- package/dist/db/native-binding.d.ts.map +1 -0
- package/dist/db/native-binding.js +32 -0
- package/dist/db/native-binding.js.map +1 -0
- package/dist/db/published-shares-cache.d.ts +39 -0
- package/dist/db/published-shares-cache.d.ts.map +1 -0
- package/dist/db/published-shares-cache.js +60 -0
- package/dist/db/published-shares-cache.js.map +1 -0
- package/dist/db/queries.d.ts +5 -0
- package/dist/db/queries.d.ts.map +1 -1
- package/dist/db/queries.js +219 -80
- package/dist/db/queries.js.map +1 -1
- package/dist/db/search-query.d.ts +7 -0
- package/dist/db/search-query.d.ts.map +1 -1
- package/dist/db/search-query.js +33 -7
- package/dist/db/search-query.js.map +1 -1
- package/dist/db/share-drafts.d.ts.map +1 -1
- package/dist/db/share-drafts.js +1 -3
- package/dist/db/share-drafts.js.map +1 -1
- package/dist/doctor/checks/config.d.ts.map +1 -1
- package/dist/doctor/checks/config.js +3 -3
- package/dist/doctor/checks/config.js.map +1 -1
- package/dist/doctor/checks/db.d.ts.map +1 -1
- package/dist/doctor/checks/db.js +36 -18
- package/dist/doctor/checks/db.js.map +1 -1
- package/dist/doctor/checks/env.d.ts.map +1 -1
- package/dist/doctor/checks/env.js +5 -1
- package/dist/doctor/checks/env.js.map +1 -1
- package/dist/doctor/checks/index.js +2 -2
- package/dist/doctor/checks/index.js.map +1 -1
- package/dist/doctor/checks/native.d.ts.map +1 -1
- package/dist/doctor/checks/native.js.map +1 -1
- package/dist/doctor/checks/versions.d.ts.map +1 -1
- package/dist/doctor/checks/versions.js +2 -2
- package/dist/doctor/checks/versions.js.map +1 -1
- package/dist/doctor/preflight.js +2 -2
- package/dist/doctor/preflight.js.map +1 -1
- package/dist/doctor/runner.d.ts.map +1 -1
- package/dist/doctor/runner.js +1 -1
- package/dist/doctor/runner.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/migrations/agent-search-cleanup.d.ts.map +1 -1
- package/dist/migrations/agent-search-cleanup.js +10 -6
- package/dist/migrations/agent-search-cleanup.js.map +1 -1
- package/dist/migrations/worktree-identity-upgrade.d.ts +5 -3
- package/dist/migrations/worktree-identity-upgrade.d.ts.map +1 -1
- package/dist/migrations/worktree-identity-upgrade.js +26 -3
- package/dist/migrations/worktree-identity-upgrade.js.map +1 -1
- package/dist/observability/exporter-file.d.ts.map +1 -1
- package/dist/observability/exporter-file.js +14 -6
- package/dist/observability/exporter-file.js.map +1 -1
- package/dist/observability/exporter-pretty.d.ts.map +1 -1
- package/dist/observability/exporter-pretty.js +13 -7
- package/dist/observability/exporter-pretty.js.map +1 -1
- package/dist/observability/layer.d.ts.map +1 -1
- package/dist/observability/layer.js +1 -1
- package/dist/observability/layer.js.map +1 -1
- package/dist/parsers/claude.d.ts.map +1 -1
- package/dist/parsers/claude.js +4 -144
- package/dist/parsers/claude.js.map +1 -1
- package/dist/parsers/codex.d.ts +7 -1
- package/dist/parsers/codex.d.ts.map +1 -1
- package/dist/parsers/codex.js +43 -186
- package/dist/parsers/codex.js.map +1 -1
- package/dist/parsers/gemini.d.ts.map +1 -1
- package/dist/parsers/gemini.js +113 -13
- package/dist/parsers/gemini.js.map +1 -1
- package/dist/parsers/opencode.d.ts.map +1 -1
- package/dist/parsers/opencode.js +67 -33
- package/dist/parsers/opencode.js.map +1 -1
- package/dist/parsers/pi.d.ts +18 -0
- package/dist/parsers/pi.d.ts.map +1 -0
- package/dist/parsers/pi.js +155 -0
- package/dist/parsers/pi.js.map +1 -0
- package/dist/parsers/spool-prelude.d.ts +1 -15
- package/dist/parsers/spool-prelude.d.ts.map +1 -1
- package/dist/parsers/spool-prelude.js +3 -20
- package/dist/parsers/spool-prelude.js.map +1 -1
- package/dist/projects/fs.d.ts.map +1 -1
- package/dist/projects/fs.js +1 -1
- package/dist/projects/fs.js.map +1 -1
- package/dist/projects/groups.d.ts +4 -1
- package/dist/projects/groups.d.ts.map +1 -1
- package/dist/projects/groups.js +32 -11
- package/dist/projects/groups.js.map +1 -1
- package/dist/projects/identity-synthesizers.d.ts.map +1 -1
- package/dist/projects/identity-synthesizers.js.map +1 -1
- package/dist/projects/identity.d.ts +2 -2
- package/dist/projects/identity.d.ts.map +1 -1
- package/dist/projects/identity.js +24 -13
- package/dist/projects/identity.js.map +1 -1
- package/dist/projects/sessions.d.ts.map +1 -1
- package/dist/projects/sessions.js +11 -10
- package/dist/projects/sessions.js.map +1 -1
- package/dist/projects/worktree-resolvers.d.ts +3 -0
- package/dist/projects/worktree-resolvers.d.ts.map +1 -1
- package/dist/projects/worktree-resolvers.js +144 -8
- package/dist/projects/worktree-resolvers.js.map +1 -1
- package/dist/security/index.d.ts +2 -2
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/index.js.map +1 -1
- package/dist/security/maintenance.d.ts.map +1 -1
- package/dist/security/maintenance.js +1 -1
- package/dist/security/maintenance.js.map +1 -1
- package/dist/security/profile.d.ts.map +1 -1
- package/dist/security/profile.js +6 -4
- package/dist/security/profile.js.map +1 -1
- package/dist/security/purge.d.ts +2 -2
- package/dist/security/purge.d.ts.map +1 -1
- package/dist/security/purge.js +31 -15
- package/dist/security/purge.js.map +1 -1
- package/dist/security/repo.d.ts +1 -1
- package/dist/security/repo.d.ts.map +1 -1
- package/dist/security/repo.js +64 -37
- package/dist/security/repo.js.map +1 -1
- package/dist/security/scan.d.ts +2 -2
- package/dist/security/scan.d.ts.map +1 -1
- package/dist/security/scan.js +6 -4
- package/dist/security/scan.js.map +1 -1
- package/dist/security/worker.d.ts +2 -2
- package/dist/security/worker.d.ts.map +1 -1
- package/dist/security/worker.js +5 -11
- package/dist/security/worker.js.map +1 -1
- package/dist/sync/source-paths.d.ts.map +1 -1
- package/dist/sync/source-paths.js +46 -16
- package/dist/sync/source-paths.js.map +1 -1
- package/dist/sync/syncer.d.ts.map +1 -1
- package/dist/sync/syncer.js +112 -80
- package/dist/sync/syncer.js.map +1 -1
- package/dist/sync/watcher.d.ts +1 -1
- package/dist/sync/watcher.d.ts.map +1 -1
- package/dist/sync/watcher.js +17 -4
- package/dist/sync/watcher.js.map +1 -1
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/util/resolve-bin.d.ts.map +1 -1
- package/dist/util/resolve-bin.js +40 -25
- package/dist/util/resolve-bin.js.map +1 -1
- package/package.json +35 -17
package/dist/db/queries.js
CHANGED
|
@@ -1,16 +1,47 @@
|
|
|
1
1
|
import { DB_PATH, getDBSize } from './db.js';
|
|
2
|
-
import { buildSearchPlan, canUseSessionSearchFts, getNaturalSearchPhrase, getNaturalSearchTerms, selectFtsTableKind } from './search-query.js';
|
|
2
|
+
import { buildPreviewFtsPlan, buildSearchPlan, canUseSessionSearchFts, containsCjk, getNaturalSearchPhrase, getNaturalSearchTerms, selectFtsTableKind, } from './search-query.js';
|
|
3
3
|
export function getOrCreateProject(db, sourceId, slug, displayPath, displayName, identity) {
|
|
4
4
|
const existing = db
|
|
5
|
-
.prepare('SELECT id FROM projects WHERE source_id = ? AND slug = ?')
|
|
5
|
+
.prepare('SELECT id, identity_kind FROM projects WHERE source_id = ? AND slug = ?')
|
|
6
6
|
.get(sourceId, slug);
|
|
7
|
-
if (existing)
|
|
7
|
+
if (existing) {
|
|
8
|
+
// Project rows predate identity grouping and are keyed by source + slug.
|
|
9
|
+
// A worktree may have been missing on first sync (stored as `path`) and
|
|
10
|
+
// become resolvable later. Refresh the row when the new signal is at least
|
|
11
|
+
// as strong, but never let a temporarily missing checkout downgrade a
|
|
12
|
+
// stable git/manifest identity back to `path`.
|
|
13
|
+
if (identityRank(identity.identityKind) >= identityRank(existing.identity_kind)) {
|
|
14
|
+
db.prepare(`
|
|
15
|
+
UPDATE projects
|
|
16
|
+
SET display_path = ?, display_name = ?, identity_kind = ?, identity_key = ?
|
|
17
|
+
WHERE id = ?
|
|
18
|
+
`).run(displayPath, displayName, identity.identityKind, identity.identityKey, existing.id);
|
|
19
|
+
}
|
|
8
20
|
return existing.id;
|
|
21
|
+
}
|
|
9
22
|
const result = db
|
|
10
23
|
.prepare('INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key) VALUES (?, ?, ?, ?, ?, ?)')
|
|
11
24
|
.run(sourceId, slug, displayPath, displayName, identity.identityKind, identity.identityKey);
|
|
12
25
|
return Number(result.lastInsertRowid);
|
|
13
26
|
}
|
|
27
|
+
function identityRank(kind) {
|
|
28
|
+
switch (kind) {
|
|
29
|
+
case 'git_remote':
|
|
30
|
+
return 5;
|
|
31
|
+
case 'git_common_dir':
|
|
32
|
+
return 4;
|
|
33
|
+
case 'manifest_path':
|
|
34
|
+
return 3;
|
|
35
|
+
case 'synthetic':
|
|
36
|
+
case 'spool_internal':
|
|
37
|
+
return 2;
|
|
38
|
+
case 'loose':
|
|
39
|
+
return 1;
|
|
40
|
+
case 'path':
|
|
41
|
+
case null:
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
14
45
|
export function getSourceId(db, name) {
|
|
15
46
|
const row = db.prepare('SELECT id FROM sources WHERE name = ?').get(name);
|
|
16
47
|
if (!row)
|
|
@@ -24,7 +55,10 @@ export function getSourceId(db, name) {
|
|
|
24
55
|
*/
|
|
25
56
|
export function getOrCreateAskProject(db, source) {
|
|
26
57
|
const sourceId = getSourceId(db, source);
|
|
27
|
-
return getOrCreateProject(db, sourceId, '__spool_ask__', '<spool:ask>', 'Asks', {
|
|
58
|
+
return getOrCreateProject(db, sourceId, '__spool_ask__', '<spool:ask>', 'Asks', {
|
|
59
|
+
identityKind: 'spool_internal',
|
|
60
|
+
identityKey: 'ask',
|
|
61
|
+
});
|
|
28
62
|
}
|
|
29
63
|
/**
|
|
30
64
|
* Insert a session row authored by Spool itself (e.g. agent search). Title is
|
|
@@ -37,18 +71,21 @@ export function getOrCreateAskProject(db, source) {
|
|
|
37
71
|
* id without modifying anything.
|
|
38
72
|
*/
|
|
39
73
|
export function insertSpoolAuthoredSession(db, opts) {
|
|
40
|
-
const existing = db
|
|
74
|
+
const existing = db
|
|
75
|
+
.prepare('SELECT id FROM sessions WHERE session_uuid = ?')
|
|
41
76
|
.get(opts.sessionUuid);
|
|
42
77
|
if (existing)
|
|
43
78
|
return existing.id;
|
|
44
79
|
const now = new Date().toISOString();
|
|
45
80
|
const sentinel = `spool:pending:${opts.sessionUuid}`;
|
|
46
|
-
const result = db
|
|
81
|
+
const result = db
|
|
82
|
+
.prepare(`
|
|
47
83
|
INSERT INTO sessions
|
|
48
84
|
(project_id, source_id, session_uuid, file_path, title, title_source,
|
|
49
85
|
started_at, ended_at, message_count, has_tool_use, cwd, model, raw_file_mtime)
|
|
50
86
|
VALUES (?, ?, ?, ?, ?, 'spool', ?, ?, 0, 0, ?, '', '')
|
|
51
|
-
`)
|
|
87
|
+
`)
|
|
88
|
+
.run(opts.projectId, opts.sourceId, opts.sessionUuid, sentinel, opts.title, now, now, opts.cwd);
|
|
52
89
|
return Number(result.lastInsertRowid);
|
|
53
90
|
}
|
|
54
91
|
export function getSessionMtime(db, filePath) {
|
|
@@ -62,10 +99,8 @@ export function deleteSessionByFilePath(db, filePath) {
|
|
|
62
99
|
return result.changes > 0;
|
|
63
100
|
}
|
|
64
101
|
export function getAllSessionMtimes(db) {
|
|
65
|
-
const rows = db
|
|
66
|
-
|
|
67
|
-
.all();
|
|
68
|
-
return new Map(rows.map(r => [r.file_path, r.raw_file_mtime]));
|
|
102
|
+
const rows = db.prepare('SELECT file_path, raw_file_mtime FROM sessions').all();
|
|
103
|
+
return new Map(rows.map((r) => [r.file_path, r.raw_file_mtime]));
|
|
69
104
|
}
|
|
70
105
|
export function upsertSession(db, opts, mode = 'rewrite') {
|
|
71
106
|
const existing = db
|
|
@@ -77,10 +112,9 @@ export function upsertSession(db, opts, mode = 'rewrite') {
|
|
|
77
112
|
// means we're about to silently overwrite a session row — historically this
|
|
78
113
|
// happened when subagent jsonls under <slug>/<uuid>/subagents/ got indexed
|
|
79
114
|
// and clobbered the parent row.
|
|
80
|
-
if (existing.file_path !== opts.filePath
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
+ `${existing.file_path} → ${opts.filePath}`);
|
|
115
|
+
if (existing.file_path !== opts.filePath && !existing.file_path.startsWith('spool:pending:')) {
|
|
116
|
+
console.warn(`[upsertSession] session_uuid ${opts.sessionUuid} switching file_path: ` +
|
|
117
|
+
`${existing.file_path} → ${opts.filePath}`);
|
|
84
118
|
}
|
|
85
119
|
if (mode === 'rewrite') {
|
|
86
120
|
db.prepare('DELETE FROM messages WHERE session_id = ?').run(existing.id);
|
|
@@ -105,12 +139,14 @@ export function upsertSession(db, opts, mode = 'rewrite') {
|
|
|
105
139
|
`).run(opts.title, opts.filePath, opts.startedAt, opts.endedAt, opts.hasToolUse ? 1 : 0, opts.cwd, opts.model, opts.rawFileMtime, existing.id);
|
|
106
140
|
return existing.id;
|
|
107
141
|
}
|
|
108
|
-
const result = db
|
|
142
|
+
const result = db
|
|
143
|
+
.prepare(`
|
|
109
144
|
INSERT INTO sessions
|
|
110
145
|
(project_id, source_id, session_uuid, file_path, title,
|
|
111
146
|
started_at, ended_at, message_count, has_tool_use, cwd, model, raw_file_mtime)
|
|
112
147
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
113
|
-
`)
|
|
148
|
+
`)
|
|
149
|
+
.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);
|
|
114
150
|
return Number(result.lastInsertRowid);
|
|
115
151
|
}
|
|
116
152
|
export function upsertSessionSearch(db, opts) {
|
|
@@ -124,6 +160,36 @@ export function upsertSessionSearch(db, opts) {
|
|
|
124
160
|
updated_at = datetime('now')
|
|
125
161
|
`).run(opts.sessionId, opts.title, opts.userText, opts.assistantText);
|
|
126
162
|
}
|
|
163
|
+
/** Rebuild the denormalized session-level search document from the authoritative
|
|
164
|
+
* session title and current message rows. Security purge calls this inside its
|
|
165
|
+
* transaction so raw values cannot survive in either session-level FTS index. */
|
|
166
|
+
export function refreshSessionSearchFromMessages(db, sessionId) {
|
|
167
|
+
const session = db.prepare('SELECT title FROM sessions WHERE id = ?').get(sessionId);
|
|
168
|
+
if (!session)
|
|
169
|
+
return;
|
|
170
|
+
const rows = db
|
|
171
|
+
.prepare(`
|
|
172
|
+
SELECT role, content_text AS contentText
|
|
173
|
+
FROM messages
|
|
174
|
+
WHERE session_id = ?
|
|
175
|
+
AND is_sidechain = 0
|
|
176
|
+
AND role IN ('user', 'assistant')
|
|
177
|
+
ORDER BY seq, id
|
|
178
|
+
`)
|
|
179
|
+
.all(sessionId);
|
|
180
|
+
const byRole = { user: [], assistant: [] };
|
|
181
|
+
for (const row of rows) {
|
|
182
|
+
const text = row.contentText.trim();
|
|
183
|
+
if (text)
|
|
184
|
+
byRole[row.role].push(text);
|
|
185
|
+
}
|
|
186
|
+
upsertSessionSearch(db, {
|
|
187
|
+
sessionId,
|
|
188
|
+
title: session.title ?? '',
|
|
189
|
+
userText: byRole.user.join('\n'),
|
|
190
|
+
assistantText: byRole.assistant.join('\n'),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
127
193
|
/** Insert message rows, skipping any that collide with the partial
|
|
128
194
|
* UNIQUE index on (session_id, msg_uuid). Returns the count of rows
|
|
129
195
|
* actually inserted — callers use this to decide whether anything
|
|
@@ -167,19 +233,26 @@ export function insertMessages(db, sessionId, sourceId, messages) {
|
|
|
167
233
|
* rows" — classifySync treats both as `rewrite` so the two-state
|
|
168
234
|
* collapse is intentional, not a lost signal. */
|
|
169
235
|
export function getMessageSnapshot(db, sessionUuid) {
|
|
170
|
-
// Single window-function query: pulls total
|
|
171
|
-
// index scan over (session_id, msg_uuid). `LIMIT 1`
|
|
172
|
-
// smallest-seq row; `COUNT(*) OVER ()`
|
|
173
|
-
//
|
|
174
|
-
// WHERE already scopes to one session).
|
|
175
|
-
const row = db
|
|
236
|
+
// Single window-function query: pulls total plus leading and trailing
|
|
237
|
+
// uuids in one index scan over (session_id, msg_uuid). `LIMIT 1`
|
|
238
|
+
// returns the smallest-seq row; `COUNT(*) OVER ()` / `LAST_VALUE`
|
|
239
|
+
// annotate it with the partition-wide total and tail (no GROUP BY
|
|
240
|
+
// needed since the WHERE already scopes to one session).
|
|
241
|
+
const row = db
|
|
242
|
+
.prepare(`SELECT m.msg_uuid AS msg_uuid,
|
|
243
|
+
COUNT(*) OVER () AS total,
|
|
244
|
+
LAST_VALUE(m.msg_uuid) OVER (
|
|
245
|
+
ORDER BY m.seq, m.id
|
|
246
|
+
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
|
|
247
|
+
) AS last_uuid
|
|
176
248
|
FROM messages m
|
|
177
249
|
JOIN sessions s ON s.id = m.session_id
|
|
178
250
|
WHERE s.session_uuid = ? AND m.msg_uuid IS NOT NULL
|
|
179
|
-
ORDER BY m.seq, m.id LIMIT 1`)
|
|
251
|
+
ORDER BY m.seq, m.id LIMIT 1`)
|
|
252
|
+
.get(sessionUuid);
|
|
180
253
|
if (!row)
|
|
181
|
-
return { firstUuid: null, total: 0 };
|
|
182
|
-
return { firstUuid: row.msg_uuid, total: row.total };
|
|
254
|
+
return { firstUuid: null, lastUuid: null, total: 0 };
|
|
255
|
+
return { firstUuid: row.msg_uuid, lastUuid: row.last_uuid, total: row.total };
|
|
183
256
|
}
|
|
184
257
|
/** Refresh `sessions.message_count` for a single session from the
|
|
185
258
|
* actual non-sidechain row count. Called after every sync's INSERT
|
|
@@ -211,14 +284,17 @@ export const SESSION_SELECT = `
|
|
|
211
284
|
JOIN sources src ON src.id = s.source_id
|
|
212
285
|
JOIN projects p ON p.id = s.project_id`;
|
|
213
286
|
export function getSessionWithMessages(db, sessionUuid) {
|
|
214
|
-
const sessionRow = db
|
|
287
|
+
const sessionRow = db
|
|
288
|
+
.prepare(`
|
|
215
289
|
${SESSION_SELECT}
|
|
216
290
|
WHERE s.session_uuid = ?
|
|
217
|
-
`)
|
|
291
|
+
`)
|
|
292
|
+
.get(sessionUuid);
|
|
218
293
|
if (!sessionRow)
|
|
219
294
|
return null;
|
|
220
295
|
const session = rowToSession(sessionRow);
|
|
221
|
-
const msgRows = db
|
|
296
|
+
const msgRows = db
|
|
297
|
+
.prepare(`
|
|
222
298
|
SELECT id, session_id AS sessionId, msg_uuid AS msgUuid,
|
|
223
299
|
parent_uuid AS parentUuid, role, content_text AS contentText,
|
|
224
300
|
timestamp, is_sidechain AS isSidechain, tool_names AS toolNames, seq
|
|
@@ -226,8 +302,9 @@ export function getSessionWithMessages(db, sessionUuid) {
|
|
|
226
302
|
WHERE session_id = ?
|
|
227
303
|
AND (is_sidechain = 0 OR parent_uuid LIKE 'opencode-subagent:%')
|
|
228
304
|
ORDER BY seq
|
|
229
|
-
`)
|
|
230
|
-
|
|
305
|
+
`)
|
|
306
|
+
.all(session.id);
|
|
307
|
+
const messages = msgRows.map((r) => ({
|
|
231
308
|
id: r['id'],
|
|
232
309
|
sessionId: r['sessionId'],
|
|
233
310
|
msgUuid: r['msgUuid'],
|
|
@@ -256,8 +333,9 @@ export function searchFragments(db, query, opts = {}) {
|
|
|
256
333
|
if (naturalTerms.length === 1) {
|
|
257
334
|
return searchFragmentSessionFallback(db, naturalTerms, naturalPhrase, rowLimit, 'fts', sharedOpts).slice(0, limit);
|
|
258
335
|
}
|
|
259
|
-
const groups = buildSearchPlan(query).map(step => {
|
|
260
|
-
if (naturalTerms.length > 1 &&
|
|
336
|
+
const groups = buildSearchPlan(query).map((step) => {
|
|
337
|
+
if (naturalTerms.length > 1 &&
|
|
338
|
+
(step.matchType === 'phrase' || step.matchType === 'all_terms')) {
|
|
261
339
|
return searchFragmentSessionFallback(db, naturalTerms, naturalPhrase, rowLimit, step.matchType, sharedOpts);
|
|
262
340
|
}
|
|
263
341
|
const ftsTable = selectFtsTableKind(query) === 'trigram' ? 'messages_fts_trigram' : 'messages_fts';
|
|
@@ -269,15 +347,18 @@ export function searchFragments(db, query, opts = {}) {
|
|
|
269
347
|
export function searchSessionPreview(db, query, opts = {}) {
|
|
270
348
|
const { limit = 5, source, since } = opts;
|
|
271
349
|
const normalizedQuery = query.trim();
|
|
272
|
-
|
|
350
|
+
// Single-character ascii previews stay gated (a per-keystroke full scan
|
|
351
|
+
// buys nothing), but a single CJK character is a meaningful query — it
|
|
352
|
+
// takes the LIKE fallback below, as it did before the FTS preview landed.
|
|
353
|
+
if (Array.from(normalizedQuery).length < 2 && !containsCjk(normalizedQuery))
|
|
273
354
|
return [];
|
|
274
355
|
const terms = getNaturalSearchTerms(query);
|
|
275
356
|
const previewTerms = terms.length > 0 ? terms : [normalizedQuery];
|
|
276
|
-
const rows = searchPreviewRows(db, previewTerms, limit, {
|
|
357
|
+
const rows = searchPreviewRows(db, normalizedQuery, previewTerms, limit, {
|
|
277
358
|
...(source ? { source } : {}),
|
|
278
359
|
...(since ? { since } : {}),
|
|
279
360
|
});
|
|
280
|
-
const snippetRows = selectBestSessionSnippets(db, rows.map(row => row['sessionId']), previewTerms);
|
|
361
|
+
const snippetRows = selectBestSessionSnippets(db, rows.map((row) => row['sessionId']), previewTerms, buildPreviewFtsPlan(normalizedQuery));
|
|
281
362
|
return rows.map((row, index) => {
|
|
282
363
|
const sessionId = row['sessionId'];
|
|
283
364
|
const snippetRow = snippetRows.get(sessionId);
|
|
@@ -315,7 +396,7 @@ function searchFragmentRows(db, ftsTable, ftsQuery, limit, opts = {}) {
|
|
|
315
396
|
params.push(since);
|
|
316
397
|
}
|
|
317
398
|
if (onlyPinned) {
|
|
318
|
-
conditions.push(
|
|
399
|
+
conditions.push('EXISTS (SELECT 1 FROM pins WHERE pins.session_uuid = sess.session_uuid)');
|
|
319
400
|
}
|
|
320
401
|
if (identityKey) {
|
|
321
402
|
conditions.push('p.identity_key = ?');
|
|
@@ -348,7 +429,50 @@ function searchFragmentRows(db, ftsTable, ftsQuery, limit, opts = {}) {
|
|
|
348
429
|
`;
|
|
349
430
|
return db.prepare(sql).all(...params);
|
|
350
431
|
}
|
|
351
|
-
function searchPreviewRows(db, terms, limit, opts = {}) {
|
|
432
|
+
function searchPreviewRows(db, query, terms, limit, opts = {}) {
|
|
433
|
+
const ftsPlan = buildPreviewFtsPlan(query);
|
|
434
|
+
if (ftsPlan) {
|
|
435
|
+
return searchPreviewRowsByFts(db, query, ftsPlan, limit, opts);
|
|
436
|
+
}
|
|
437
|
+
return searchPreviewRowsByLike(db, terms, limit, opts);
|
|
438
|
+
}
|
|
439
|
+
function searchPreviewRowsByFts(db, query, plan, limit, opts = {}) {
|
|
440
|
+
const { source, since } = opts;
|
|
441
|
+
const ftsTable = plan.tableKind === 'trigram' ? 'session_search_fts_trigram' : 'session_search_fts';
|
|
442
|
+
const conditions = [`${ftsTable} MATCH ?`];
|
|
443
|
+
const filterParams = [plan.query];
|
|
444
|
+
if (source) {
|
|
445
|
+
conditions.push('src2.name = ?');
|
|
446
|
+
filterParams.push(source);
|
|
447
|
+
}
|
|
448
|
+
if (since) {
|
|
449
|
+
conditions.push('sess.started_at >= ?');
|
|
450
|
+
filterParams.push(since);
|
|
451
|
+
}
|
|
452
|
+
const sql = `
|
|
453
|
+
SELECT
|
|
454
|
+
sess.id AS sessionId,
|
|
455
|
+
sess.session_uuid AS sessionUuid,
|
|
456
|
+
sess.file_path AS filePath,
|
|
457
|
+
sess.title AS sessionTitle,
|
|
458
|
+
sess.started_at AS startedAt,
|
|
459
|
+
sess.cwd AS cwd,
|
|
460
|
+
p.display_path AS project,
|
|
461
|
+
src2.name AS source,
|
|
462
|
+
CASE WHEN ss.title LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END AS titlePrefixScore,
|
|
463
|
+
bm25(${ftsTable}, 5.0, 1.5, 0.8) AS ftsScore
|
|
464
|
+
FROM ${ftsTable}
|
|
465
|
+
JOIN session_search ss ON ss.session_id = ${ftsTable}.rowid
|
|
466
|
+
JOIN sessions sess ON sess.id = ss.session_id
|
|
467
|
+
JOIN projects p ON p.id = sess.project_id
|
|
468
|
+
JOIN sources src2 ON src2.id = sess.source_id
|
|
469
|
+
WHERE ${conditions.join(' AND ')}
|
|
470
|
+
ORDER BY titlePrefixScore DESC, ftsScore ASC, sess.started_at DESC
|
|
471
|
+
LIMIT ?
|
|
472
|
+
`;
|
|
473
|
+
return db.prepare(sql).all(`${escapeLike(query)}%`, ...filterParams, limit);
|
|
474
|
+
}
|
|
475
|
+
function searchPreviewRowsByLike(db, terms, limit, opts = {}) {
|
|
352
476
|
const { source, since } = opts;
|
|
353
477
|
const scoreParts = [];
|
|
354
478
|
const scoreParams = [];
|
|
@@ -464,7 +588,7 @@ function searchFragmentSessionFallback(db, terms, phrase, limit, matchType, opts
|
|
|
464
588
|
if (terms.length < 1)
|
|
465
589
|
return [];
|
|
466
590
|
const rows = searchSessionRowsByTerms(db, terms, phrase, limit, matchType, opts);
|
|
467
|
-
const snippetRows = selectBestSessionSnippets(db, rows.map(row => row['sessionId']), terms);
|
|
591
|
+
const snippetRows = selectBestSessionSnippets(db, rows.map((row) => row['sessionId']), terms);
|
|
468
592
|
const ranked = rows.map((row) => {
|
|
469
593
|
const snippetRow = snippetRows.get(row['sessionId']);
|
|
470
594
|
const snippetSource = snippetRow?.contentText ?? String(row['sessionTitle'] ?? '');
|
|
@@ -561,7 +685,7 @@ function searchSessionRowsByLike(db, terms, limit, opts = {}) {
|
|
|
561
685
|
params.push(since);
|
|
562
686
|
}
|
|
563
687
|
if (onlyPinned) {
|
|
564
|
-
conditions.push(
|
|
688
|
+
conditions.push('EXISTS (SELECT 1 FROM pins WHERE pins.session_uuid = sess.session_uuid)');
|
|
565
689
|
}
|
|
566
690
|
if (identityKey) {
|
|
567
691
|
conditions.push('p.identity_key = ?');
|
|
@@ -599,7 +723,7 @@ function searchSessionRowsByFts(db, terms, phrase, limit, matchType, opts = {})
|
|
|
599
723
|
const ftsTable = selectFtsTableKind(phrase) === 'trigram' ? 'session_search_fts_trigram' : 'session_search_fts';
|
|
600
724
|
const ftsQuery = matchType === 'phrase'
|
|
601
725
|
? `"${phrase.replace(/"/g, '""')}"`
|
|
602
|
-
: terms.map(term => `"${term.replace(/"/g, '""')}"`).join(' AND ');
|
|
726
|
+
: terms.map((term) => `"${term.replace(/"/g, '""')}"`).join(' AND ');
|
|
603
727
|
const titleScoreParts = [];
|
|
604
728
|
const scoreParams = [];
|
|
605
729
|
const likeParams = [];
|
|
@@ -623,7 +747,7 @@ function searchSessionRowsByFts(db, terms, phrase, limit, matchType, opts = {})
|
|
|
623
747
|
params.push(since);
|
|
624
748
|
}
|
|
625
749
|
if (onlyPinned) {
|
|
626
|
-
conditions.push(
|
|
750
|
+
conditions.push('EXISTS (SELECT 1 FROM pins WHERE pins.session_uuid = sess.session_uuid)');
|
|
627
751
|
}
|
|
628
752
|
if (identityKey) {
|
|
629
753
|
conditions.push('p.identity_key = ?');
|
|
@@ -658,28 +782,36 @@ function searchSessionRowsByFts(db, terms, phrase, limit, matchType, opts = {})
|
|
|
658
782
|
`;
|
|
659
783
|
return db.prepare(sql).all(...params);
|
|
660
784
|
}
|
|
661
|
-
function selectBestSessionSnippets(db, sessionIds, terms) {
|
|
785
|
+
function selectBestSessionSnippets(db, sessionIds, terms, ftsPlan = null) {
|
|
662
786
|
if (sessionIds.length === 0)
|
|
663
787
|
return new Map();
|
|
664
|
-
const coverageExpr = terms
|
|
665
|
-
|
|
788
|
+
const coverageExpr = terms
|
|
789
|
+
.map(() => `CASE WHEN m.content_text LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END`)
|
|
790
|
+
.join(' + ');
|
|
791
|
+
const anyClauses = terms.map(() => "m.content_text LIKE ? ESCAPE '\\'").join(' OR ');
|
|
666
792
|
const allPatterns = terms.map(toLikePattern);
|
|
667
793
|
const anyPatterns = terms.map(toLikePattern);
|
|
668
794
|
const sessionPlaceholders = sessionIds.map(() => '?').join(', ');
|
|
795
|
+
const messageSource = ftsPlan
|
|
796
|
+
? `${ftsPlan.tableKind === 'trigram' ? 'messages_fts_trigram' : 'messages_fts'} AS message_matches
|
|
797
|
+
JOIN messages AS m ON m.id = message_matches.rowid`
|
|
798
|
+
: 'messages AS m';
|
|
799
|
+
const matchCondition = ftsPlan ? 'message_matches.content_text MATCH ?' : `(${anyClauses})`;
|
|
800
|
+
const matchParams = ftsPlan ? [ftsPlan.anyTermQuery] : anyPatterns;
|
|
669
801
|
const matchSql = `
|
|
670
802
|
WITH raw AS (
|
|
671
803
|
SELECT
|
|
672
|
-
id AS messageId,
|
|
673
|
-
session_id AS sessionId,
|
|
674
|
-
role AS messageRole,
|
|
675
|
-
timestamp AS messageTimestamp,
|
|
676
|
-
content_text AS contentText,
|
|
677
|
-
seq,
|
|
804
|
+
m.id AS messageId,
|
|
805
|
+
m.session_id AS sessionId,
|
|
806
|
+
m.role AS messageRole,
|
|
807
|
+
m.timestamp AS messageTimestamp,
|
|
808
|
+
m.content_text AS contentText,
|
|
809
|
+
m.seq,
|
|
678
810
|
${coverageExpr} AS termCoverage
|
|
679
|
-
FROM
|
|
680
|
-
WHERE session_id IN (${sessionPlaceholders})
|
|
681
|
-
AND is_sidechain = 0
|
|
682
|
-
AND
|
|
811
|
+
FROM ${messageSource}
|
|
812
|
+
WHERE m.session_id IN (${sessionPlaceholders})
|
|
813
|
+
AND m.is_sidechain = 0
|
|
814
|
+
AND ${matchCondition}
|
|
683
815
|
),
|
|
684
816
|
ranked AS (
|
|
685
817
|
SELECT
|
|
@@ -707,8 +839,10 @@ function selectBestSessionSnippets(db, sessionIds, terms) {
|
|
|
707
839
|
FROM ranked
|
|
708
840
|
WHERE rn = 1
|
|
709
841
|
`;
|
|
710
|
-
const rows = db
|
|
711
|
-
|
|
842
|
+
const rows = db
|
|
843
|
+
.prepare(matchSql)
|
|
844
|
+
.all(...allPatterns, ...sessionIds, ...matchParams);
|
|
845
|
+
return new Map(rows.map((row) => [row.sessionId, row]));
|
|
712
846
|
}
|
|
713
847
|
export function buildLikeSnippet(text, terms) {
|
|
714
848
|
const normalizedText = text.trim();
|
|
@@ -719,8 +853,8 @@ export function buildLikeSnippet(text, terms) {
|
|
|
719
853
|
// query terms (e.g. user typed "dark fantasy", text has "Dark Fantasy").
|
|
720
854
|
const lowerText = normalizedText.toLowerCase();
|
|
721
855
|
const firstHit = terms
|
|
722
|
-
.map(term => lowerText.indexOf(term.toLowerCase()))
|
|
723
|
-
.filter(index => index >= 0)
|
|
856
|
+
.map((term) => lowerText.indexOf(term.toLowerCase()))
|
|
857
|
+
.filter((index) => index >= 0)
|
|
724
858
|
.sort((a, b) => a - b)[0] ?? 0;
|
|
725
859
|
const start = Math.max(0, firstHit - 60);
|
|
726
860
|
const end = Math.min(normalizedText.length, firstHit + 140);
|
|
@@ -735,7 +869,7 @@ export function buildLikeSnippet(text, terms) {
|
|
|
735
869
|
if (!term)
|
|
736
870
|
continue;
|
|
737
871
|
const pattern = new RegExp(escapeRegex(term), 'gi');
|
|
738
|
-
snippet = snippet.replace(pattern, m => `<mark>${m}</mark>`);
|
|
872
|
+
snippet = snippet.replace(pattern, (m) => `<mark>${m}</mark>`);
|
|
739
873
|
}
|
|
740
874
|
return snippet;
|
|
741
875
|
}
|
|
@@ -746,10 +880,7 @@ function toLikePattern(term) {
|
|
|
746
880
|
return `%${escapeLike(term)}%`;
|
|
747
881
|
}
|
|
748
882
|
function escapeLike(value) {
|
|
749
|
-
return value
|
|
750
|
-
.replace(/\\/g, '\\\\')
|
|
751
|
-
.replace(/%/g, '\\%')
|
|
752
|
-
.replace(/_/g, '\\_');
|
|
883
|
+
return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
753
884
|
}
|
|
754
885
|
// ── Pins (sessions) ─────────────────────────────────────────────────────────
|
|
755
886
|
export function pinSession(db, sessionUuid) {
|
|
@@ -759,46 +890,53 @@ export function unpinSession(db, sessionUuid) {
|
|
|
759
890
|
db.prepare('DELETE FROM pins WHERE session_uuid = ?').run(sessionUuid);
|
|
760
891
|
}
|
|
761
892
|
export function isPinned(db, sessionUuid) {
|
|
762
|
-
const row = db
|
|
763
|
-
.prepare('SELECT 1 AS hit FROM pins WHERE session_uuid = ?')
|
|
764
|
-
.get(sessionUuid);
|
|
893
|
+
const row = db.prepare('SELECT 1 AS hit FROM pins WHERE session_uuid = ?').get(sessionUuid);
|
|
765
894
|
return row !== undefined;
|
|
766
895
|
}
|
|
767
896
|
export function getPinnedUuids(db) {
|
|
768
|
-
const rows = db
|
|
897
|
+
const rows = db
|
|
898
|
+
.prepare(`
|
|
769
899
|
SELECT session_uuid AS uuid
|
|
770
900
|
FROM pins
|
|
771
901
|
WHERE EXISTS (SELECT 1 FROM sessions WHERE session_uuid = pins.session_uuid)
|
|
772
|
-
`)
|
|
773
|
-
|
|
902
|
+
`)
|
|
903
|
+
.all();
|
|
904
|
+
return rows.map((r) => r.uuid);
|
|
774
905
|
}
|
|
775
906
|
export function listPinnedSessions(db) {
|
|
776
|
-
const rows = db
|
|
907
|
+
const rows = db
|
|
908
|
+
.prepare(`
|
|
777
909
|
${SESSION_SELECT}
|
|
778
910
|
JOIN pins ON pins.session_uuid = s.session_uuid
|
|
779
911
|
WHERE s.message_count > 0
|
|
780
912
|
ORDER BY pins.pinned_at DESC
|
|
781
|
-
`)
|
|
913
|
+
`)
|
|
914
|
+
.all();
|
|
782
915
|
return rows.map(rowToSession);
|
|
783
916
|
}
|
|
784
917
|
export function getStatus(db) {
|
|
785
918
|
// Filter empty sessions so this matches every user-facing list, which
|
|
786
919
|
// all gate on message_count > 0 (aborted starts and crash files have
|
|
787
920
|
// zero messages but still occupy a row in the sessions table).
|
|
788
|
-
const counts = db
|
|
921
|
+
const counts = db
|
|
922
|
+
.prepare(`
|
|
789
923
|
SELECT src.name, COUNT(*) AS cnt
|
|
790
924
|
FROM sessions s JOIN sources src ON src.id = s.source_id
|
|
791
925
|
WHERE s.message_count > 0
|
|
792
926
|
GROUP BY src.name
|
|
793
|
-
`)
|
|
794
|
-
|
|
927
|
+
`)
|
|
928
|
+
.all();
|
|
929
|
+
const lastSync = db
|
|
930
|
+
.prepare(`
|
|
795
931
|
SELECT MAX(synced_at) AS last FROM sync_log WHERE status = 'ok'
|
|
796
|
-
`)
|
|
932
|
+
`)
|
|
933
|
+
.get();
|
|
797
934
|
const totalSessions = counts.reduce((sum, r) => sum + r.cnt, 0);
|
|
798
|
-
const claudeRow = counts.find(r => r.name === 'claude');
|
|
799
|
-
const codexRow = counts.find(r => r.name === 'codex');
|
|
800
|
-
const geminiRow = counts.find(r => r.name === 'gemini');
|
|
801
|
-
const opencodeRow = counts.find(r => r.name === 'opencode');
|
|
935
|
+
const claudeRow = counts.find((r) => r.name === 'claude');
|
|
936
|
+
const codexRow = counts.find((r) => r.name === 'codex');
|
|
937
|
+
const geminiRow = counts.find((r) => r.name === 'gemini');
|
|
938
|
+
const opencodeRow = counts.find((r) => r.name === 'opencode');
|
|
939
|
+
const piRow = counts.find((r) => r.name === 'pi');
|
|
802
940
|
return {
|
|
803
941
|
dbPath: DB_PATH,
|
|
804
942
|
totalSessions,
|
|
@@ -806,6 +944,7 @@ export function getStatus(db) {
|
|
|
806
944
|
codexSessions: codexRow?.cnt ?? 0,
|
|
807
945
|
geminiSessions: geminiRow?.cnt ?? 0,
|
|
808
946
|
opencodeSessions: opencodeRow?.cnt ?? 0,
|
|
947
|
+
piSessions: piRow?.cnt ?? 0,
|
|
809
948
|
lastSyncedAt: lastSync?.last ?? null,
|
|
810
949
|
dbSizeBytes: getDBSize(),
|
|
811
950
|
};
|