@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.
Files changed (148) hide show
  1. package/README.md +22 -14
  2. package/dist/db/db.d.ts +2 -2
  3. package/dist/db/db.d.ts.map +1 -1
  4. package/dist/db/db.js +106 -15
  5. package/dist/db/db.js.map +1 -1
  6. package/dist/db/native-binding.d.ts +21 -0
  7. package/dist/db/native-binding.d.ts.map +1 -0
  8. package/dist/db/native-binding.js +32 -0
  9. package/dist/db/native-binding.js.map +1 -0
  10. package/dist/db/published-shares-cache.d.ts +39 -0
  11. package/dist/db/published-shares-cache.d.ts.map +1 -0
  12. package/dist/db/published-shares-cache.js +60 -0
  13. package/dist/db/published-shares-cache.js.map +1 -0
  14. package/dist/db/queries.d.ts +5 -0
  15. package/dist/db/queries.d.ts.map +1 -1
  16. package/dist/db/queries.js +219 -80
  17. package/dist/db/queries.js.map +1 -1
  18. package/dist/db/search-query.d.ts +7 -0
  19. package/dist/db/search-query.d.ts.map +1 -1
  20. package/dist/db/search-query.js +33 -7
  21. package/dist/db/search-query.js.map +1 -1
  22. package/dist/db/share-drafts.d.ts.map +1 -1
  23. package/dist/db/share-drafts.js +1 -3
  24. package/dist/db/share-drafts.js.map +1 -1
  25. package/dist/doctor/checks/config.d.ts.map +1 -1
  26. package/dist/doctor/checks/config.js +3 -3
  27. package/dist/doctor/checks/config.js.map +1 -1
  28. package/dist/doctor/checks/db.d.ts.map +1 -1
  29. package/dist/doctor/checks/db.js +36 -18
  30. package/dist/doctor/checks/db.js.map +1 -1
  31. package/dist/doctor/checks/env.d.ts.map +1 -1
  32. package/dist/doctor/checks/env.js +5 -1
  33. package/dist/doctor/checks/env.js.map +1 -1
  34. package/dist/doctor/checks/index.js +2 -2
  35. package/dist/doctor/checks/index.js.map +1 -1
  36. package/dist/doctor/checks/native.d.ts.map +1 -1
  37. package/dist/doctor/checks/native.js.map +1 -1
  38. package/dist/doctor/checks/versions.d.ts.map +1 -1
  39. package/dist/doctor/checks/versions.js +2 -2
  40. package/dist/doctor/checks/versions.js.map +1 -1
  41. package/dist/doctor/preflight.js +2 -2
  42. package/dist/doctor/preflight.js.map +1 -1
  43. package/dist/doctor/runner.d.ts.map +1 -1
  44. package/dist/doctor/runner.js +1 -1
  45. package/dist/doctor/runner.js.map +1 -1
  46. package/dist/index.d.ts +2 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +2 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/migrations/agent-search-cleanup.d.ts.map +1 -1
  51. package/dist/migrations/agent-search-cleanup.js +10 -6
  52. package/dist/migrations/agent-search-cleanup.js.map +1 -1
  53. package/dist/migrations/worktree-identity-upgrade.d.ts +5 -3
  54. package/dist/migrations/worktree-identity-upgrade.d.ts.map +1 -1
  55. package/dist/migrations/worktree-identity-upgrade.js +26 -3
  56. package/dist/migrations/worktree-identity-upgrade.js.map +1 -1
  57. package/dist/observability/exporter-file.d.ts.map +1 -1
  58. package/dist/observability/exporter-file.js +14 -6
  59. package/dist/observability/exporter-file.js.map +1 -1
  60. package/dist/observability/exporter-pretty.d.ts.map +1 -1
  61. package/dist/observability/exporter-pretty.js +13 -7
  62. package/dist/observability/exporter-pretty.js.map +1 -1
  63. package/dist/observability/layer.d.ts.map +1 -1
  64. package/dist/observability/layer.js +1 -1
  65. package/dist/observability/layer.js.map +1 -1
  66. package/dist/parsers/claude.d.ts.map +1 -1
  67. package/dist/parsers/claude.js +4 -144
  68. package/dist/parsers/claude.js.map +1 -1
  69. package/dist/parsers/codex.d.ts +7 -1
  70. package/dist/parsers/codex.d.ts.map +1 -1
  71. package/dist/parsers/codex.js +43 -186
  72. package/dist/parsers/codex.js.map +1 -1
  73. package/dist/parsers/gemini.d.ts.map +1 -1
  74. package/dist/parsers/gemini.js +113 -13
  75. package/dist/parsers/gemini.js.map +1 -1
  76. package/dist/parsers/opencode.d.ts.map +1 -1
  77. package/dist/parsers/opencode.js +67 -33
  78. package/dist/parsers/opencode.js.map +1 -1
  79. package/dist/parsers/pi.d.ts +18 -0
  80. package/dist/parsers/pi.d.ts.map +1 -0
  81. package/dist/parsers/pi.js +155 -0
  82. package/dist/parsers/pi.js.map +1 -0
  83. package/dist/parsers/spool-prelude.d.ts +1 -15
  84. package/dist/parsers/spool-prelude.d.ts.map +1 -1
  85. package/dist/parsers/spool-prelude.js +3 -20
  86. package/dist/parsers/spool-prelude.js.map +1 -1
  87. package/dist/projects/fs.d.ts.map +1 -1
  88. package/dist/projects/fs.js +1 -1
  89. package/dist/projects/fs.js.map +1 -1
  90. package/dist/projects/groups.d.ts +4 -1
  91. package/dist/projects/groups.d.ts.map +1 -1
  92. package/dist/projects/groups.js +32 -11
  93. package/dist/projects/groups.js.map +1 -1
  94. package/dist/projects/identity-synthesizers.d.ts.map +1 -1
  95. package/dist/projects/identity-synthesizers.js.map +1 -1
  96. package/dist/projects/identity.d.ts +2 -2
  97. package/dist/projects/identity.d.ts.map +1 -1
  98. package/dist/projects/identity.js +24 -13
  99. package/dist/projects/identity.js.map +1 -1
  100. package/dist/projects/sessions.d.ts.map +1 -1
  101. package/dist/projects/sessions.js +11 -10
  102. package/dist/projects/sessions.js.map +1 -1
  103. package/dist/projects/worktree-resolvers.d.ts +3 -0
  104. package/dist/projects/worktree-resolvers.d.ts.map +1 -1
  105. package/dist/projects/worktree-resolvers.js +144 -8
  106. package/dist/projects/worktree-resolvers.js.map +1 -1
  107. package/dist/security/index.d.ts +2 -2
  108. package/dist/security/index.d.ts.map +1 -1
  109. package/dist/security/index.js +1 -1
  110. package/dist/security/index.js.map +1 -1
  111. package/dist/security/maintenance.d.ts.map +1 -1
  112. package/dist/security/maintenance.js +1 -1
  113. package/dist/security/maintenance.js.map +1 -1
  114. package/dist/security/profile.d.ts.map +1 -1
  115. package/dist/security/profile.js +6 -4
  116. package/dist/security/profile.js.map +1 -1
  117. package/dist/security/purge.d.ts +2 -2
  118. package/dist/security/purge.d.ts.map +1 -1
  119. package/dist/security/purge.js +31 -15
  120. package/dist/security/purge.js.map +1 -1
  121. package/dist/security/repo.d.ts +1 -1
  122. package/dist/security/repo.d.ts.map +1 -1
  123. package/dist/security/repo.js +64 -37
  124. package/dist/security/repo.js.map +1 -1
  125. package/dist/security/scan.d.ts +2 -2
  126. package/dist/security/scan.d.ts.map +1 -1
  127. package/dist/security/scan.js +6 -4
  128. package/dist/security/scan.js.map +1 -1
  129. package/dist/security/worker.d.ts +2 -2
  130. package/dist/security/worker.d.ts.map +1 -1
  131. package/dist/security/worker.js +5 -11
  132. package/dist/security/worker.js.map +1 -1
  133. package/dist/sync/source-paths.d.ts.map +1 -1
  134. package/dist/sync/source-paths.js +46 -16
  135. package/dist/sync/source-paths.js.map +1 -1
  136. package/dist/sync/syncer.d.ts.map +1 -1
  137. package/dist/sync/syncer.js +112 -80
  138. package/dist/sync/syncer.js.map +1 -1
  139. package/dist/sync/watcher.d.ts +1 -1
  140. package/dist/sync/watcher.d.ts.map +1 -1
  141. package/dist/sync/watcher.js +17 -4
  142. package/dist/sync/watcher.js.map +1 -1
  143. package/dist/types.d.ts +8 -1
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/util/resolve-bin.d.ts.map +1 -1
  146. package/dist/util/resolve-bin.js +40 -25
  147. package/dist/util/resolve-bin.js.map +1 -1
  148. package/package.json +35 -17
@@ -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', { identityKind: 'spool_internal', identityKey: 'ask' });
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.prepare('SELECT id FROM sessions WHERE session_uuid = ?')
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.prepare(`
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
- `).run(opts.projectId, opts.sourceId, opts.sessionUuid, sentinel, opts.title, now, now, opts.cwd);
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
- .prepare('SELECT file_path, raw_file_mtime FROM sessions')
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
- && !existing.file_path.startsWith('spool:pending:')) {
82
- console.warn(`[upsertSession] session_uuid ${opts.sessionUuid} switching file_path: `
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.prepare(`
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
- `).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);
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 and leading uuid in one
171
- // index scan over (session_id, msg_uuid). `LIMIT 1` returns the
172
- // smallest-seq row; `COUNT(*) OVER ()` annotates it with the total
173
- // across the windowed partition (no GROUP BY needed since the
174
- // WHERE already scopes to one session).
175
- const row = db.prepare(`SELECT m.msg_uuid AS msg_uuid, COUNT(*) OVER () AS total
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`).get(sessionUuid);
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.prepare(`
287
+ const sessionRow = db
288
+ .prepare(`
215
289
  ${SESSION_SELECT}
216
290
  WHERE s.session_uuid = ?
217
- `).get(sessionUuid);
291
+ `)
292
+ .get(sessionUuid);
218
293
  if (!sessionRow)
219
294
  return null;
220
295
  const session = rowToSession(sessionRow);
221
- const msgRows = db.prepare(`
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
- `).all(session.id);
230
- const messages = msgRows.map(r => ({
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 && (step.matchType === 'phrase' || step.matchType === 'all_terms')) {
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
- if (!normalizedQuery)
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("EXISTS (SELECT 1 FROM pins WHERE pins.session_uuid = sess.session_uuid)");
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("EXISTS (SELECT 1 FROM pins WHERE pins.session_uuid = sess.session_uuid)");
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("EXISTS (SELECT 1 FROM pins WHERE pins.session_uuid = sess.session_uuid)");
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.map(() => `CASE WHEN content_text LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END`).join(' + ');
665
- const anyClauses = terms.map(() => 'content_text LIKE ? ESCAPE \'\\\'').join(' OR ');
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 messages
680
- WHERE session_id IN (${sessionPlaceholders})
681
- AND is_sidechain = 0
682
- AND (${anyClauses})
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.prepare(matchSql).all(...allPatterns, ...sessionIds, ...anyPatterns);
711
- return new Map(rows.map(row => [row.sessionId, row]));
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.prepare(`
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
- `).all();
773
- return rows.map(r => r.uuid);
902
+ `)
903
+ .all();
904
+ return rows.map((r) => r.uuid);
774
905
  }
775
906
  export function listPinnedSessions(db) {
776
- const rows = db.prepare(`
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
- `).all();
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.prepare(`
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
- `).all();
794
- const lastSync = db.prepare(`
927
+ `)
928
+ .all();
929
+ const lastSync = db
930
+ .prepare(`
795
931
  SELECT MAX(synced_at) AS last FROM sync_log WHERE status = 'ok'
796
- `).get();
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
  };