dsh-locator 0.8.2 → 0.9.0

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.
@@ -1,48 +1,571 @@
1
1
  /**
2
- * Content FTS5 query shared by the host thread and the search worker.
2
+ * Content FTS5 query engine: normal substring search, Notepad++-style extended
3
+ * escaping, case-sensitive GLOB matching and whole-word post-filtering, with
4
+ * exact or bounded counts.
5
+ *
6
+ * Shared by the host thread, the search worker and the combined panel search.
7
+ * The page path is deliberately two-phase: rank the rowids first, then read the
8
+ * few bodies and build the snippet in JS. Putting `snippet()` in the same
9
+ * `ORDER BY rank` statement made a SQLite sorter evaluate it for every match
10
+ * (measured 2.7 s for "the" on a 1 GB corpus; 60 ms with this split).
3
11
  * @module dsh-locator/content-search
4
12
  */
5
13
 
6
14
  import type { DatabaseSync } from 'node:sqlite'
7
15
  import type { ContentHit } from './content.ts'
8
16
 
17
+ /** How the query text is interpreted. */
18
+ export type ContentMatchMode = 'normal' | 'extended' | 'regex'
19
+
20
+ /** How much work the caller wants the count to spend. */
21
+ export type ContentCountMode = 'exact' | 'bounded' | 'none'
22
+
23
+ /** A match count, with its confidence. `bounded` means "at least value". */
24
+ export interface ContentCount {
25
+ value: number
26
+ kind: 'exact' | 'bounded' | 'unknown'
27
+ limit?: number
28
+ }
29
+
30
+ /** Options accepted by {@link searchContent} and the content search worker. */
31
+ export interface ContentSearchOptions {
32
+ matchMode?: ContentMatchMode
33
+ caseSensitive?: boolean
34
+ wholeWord?: boolean
35
+ /** Resolved by ContentIndex from the request's sessionId. */
36
+ sessionId?: string
37
+ /** Session log path; set by ContentIndex after resolving sessionId. */
38
+ sessionPath?: string | null
39
+ count?: ContentCountMode
40
+ countLimit?: number
41
+ }
42
+
43
+ /** Result of one content query. */
44
+ export interface ContentSearchResult {
45
+ hits: ContentHit[]
46
+ count: ContentCount
47
+ warnings: string[]
48
+ error?: string
49
+ }
50
+
51
+ /** Shortest text a trigram tokenizer can index. */
52
+ const TRIGRAM_MIN = 3
53
+ const MAX_LIMIT = 100
54
+ const DEFAULT_COUNT_LIMIT = 1000
55
+ const MAX_COUNT_LIMIT = 5000
56
+ const SNIPPET_BEFORE = 60
57
+ const SNIPPET_AFTER = 120
58
+ const SNIPPET_MAX = 400
59
+
60
+ /** Bytes that count as "inside a word", matching the name engine. */
61
+ const WORD_BYTE = /[A-Za-z0-9_]/
62
+ const CONTROL_CHARS = /[\u0000-\u001f]/
63
+
64
+ /** Text escaped for an FTS5 quoted phrase (`"` doubled). */
65
+ export function quoteFts(text: string): string {
66
+ return '"' + text.replace(/"/g, '""') + '"'
67
+ }
68
+
69
+ /** Escape a literal for SQLite GLOB (`*`, `?`, `[`, `]`). */
70
+ export function escapeGlob(text: string): string {
71
+ return text.replace(/[*?[\]]/g, ch => (ch === ']' ? '[]]' : '[' + ch + ']'))
72
+ }
73
+
74
+ /** Escape a literal for SQL LIKE with `ESCAPE '\'`. */
75
+ export function escapeLike(text: string): string {
76
+ return text.replace(/[\\%_]/g, ch => '\\' + ch)
77
+ }
78
+
79
+ /** Decoded extended-mode query text. */
80
+ export interface DecodedExtended {
81
+ ok: boolean
82
+ text: string
83
+ warnings: string[]
84
+ /** `invalid-escape` when `\\xNN` is malformed. */
85
+ error?: string
86
+ }
87
+
88
+ /**
89
+ * Decode Notepad++-style extended escapes: `\n`, `\r`, `\t`, `\0`, `\\`,
90
+ * `\xNN`. Unknown escapes are kept literally with a warning.
91
+ */
92
+ export function decodeExtended(input: string): DecodedExtended {
93
+ const warnings = new Set<string>()
94
+ let text = ''
95
+ for (let i = 0; i < input.length; i++) {
96
+ const ch = input[i]!
97
+ if (ch !== '\\') {
98
+ text += ch
99
+ continue
100
+ }
101
+ const next = input[i + 1]
102
+ if (next === undefined) {
103
+ warnings.add('extended-trailing-backslash')
104
+ text += '\\'
105
+ continue
106
+ }
107
+ switch (next) {
108
+ case 'n':
109
+ text += '\n'
110
+ i++
111
+ continue
112
+ case 'r':
113
+ text += '\r'
114
+ i++
115
+ continue
116
+ case 't':
117
+ text += '\t'
118
+ i++
119
+ continue
120
+ case '0':
121
+ text += '\0'
122
+ warnings.add('extended-control-char')
123
+ i++
124
+ continue
125
+ case '\\':
126
+ text += '\\'
127
+ i++
128
+ continue
129
+ case 'x': {
130
+ const hex = input.slice(i + 2, i + 4)
131
+ if (!/^[0-9a-fA-F]{2}$/.test(hex)) {
132
+ return { ok: false, text: '', warnings: [...warnings], error: 'invalid-escape' }
133
+ }
134
+ const code = Number.parseInt(hex, 16)
135
+ text += String.fromCharCode(code)
136
+ if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) warnings.add('extended-control-char')
137
+ i += 3
138
+ continue
139
+ }
140
+ default:
141
+ warnings.add('extended-unknown-escape')
142
+ text += '\\' + next
143
+ i++
144
+ }
145
+ }
146
+ return { ok: true, text, warnings: [...warnings] }
147
+ }
148
+
9
149
  /**
10
- * Run one content query against an open SQLite handle.
11
- * @param db - open database (read-only in the worker).
12
- * @param query - user query; shorter than three characters falls back to LIKE.
13
- * @param limit - maximum hits.
14
- * @returns hits, ordered by relevance.
150
+ * Extended decoding for file names: names cannot contain control characters, so
151
+ * they are dropped from the pattern and reported instead of silently matching
152
+ * nothing.
15
153
  */
16
- export function searchContent(db: DatabaseSync, query: string, limit: number): ContentHit[] {
154
+ export function decodeExtendedForNames(input: string): DecodedExtended {
155
+ const decoded = decodeExtended(input)
156
+ if (!decoded.ok) return decoded
157
+ const kept = decoded.text.replace(CONTROL_CHARS, '')
158
+ if (kept !== decoded.text) decoded.warnings.push('name-extended-control-char')
159
+ return { ...decoded, text: kept }
160
+ }
161
+
162
+ /** Whether `index` starts a whole-word occurrence of `length` bytes. */
163
+ export function isWholeWord(hay: string, index: number, length: number): boolean {
164
+ const left = index === 0 || !WORD_BYTE.test(hay[index - 1] ?? '')
165
+ const end = index + length
166
+ const right = end >= hay.length || !WORD_BYTE.test(hay[end] ?? '')
167
+ return left && right
168
+ }
169
+
170
+ /** One located match: byte offset and length. */
171
+ interface Located {
172
+ index: number
173
+ length: number
174
+ }
175
+
176
+ /** First whole-word (or plain) occurrence of any term, or null. */
177
+ export function locateMatch(
178
+ body: string,
179
+ terms: readonly string[],
180
+ caseSensitive: boolean,
181
+ wholeWord: boolean,
182
+ ): Located | null {
183
+ const hay = caseSensitive ? body : body.toLowerCase()
184
+ let best: Located | null = null
185
+ for (const term of terms) {
186
+ if (term === '') continue
187
+ const needle = caseSensitive ? term : term.toLowerCase()
188
+ let from = 0
189
+ while (from <= hay.length) {
190
+ const index = hay.indexOf(needle, from)
191
+ if (index < 0) break
192
+ if (!wholeWord || isWholeWord(hay, index, needle.length)) {
193
+ if (best === null || index < best.index) best = { index, length: needle.length }
194
+ break
195
+ }
196
+ from = index + 1
197
+ }
198
+ }
199
+ return best
200
+ }
201
+
202
+ /** Every term must appear as a whole word (normal mode) or the literal must
203
+ * have non-word edges (extended mode is a single "term"). */
204
+ function matchesWholeWord(body: string, terms: readonly string[], caseSensitive: boolean): boolean {
205
+ for (const term of terms) {
206
+ if (term === '') continue
207
+ if (locateMatch(body, [term], caseSensitive, true) === null) return false
208
+ }
209
+ return true
210
+ }
211
+
212
+ /** 1-based line number of a byte offset. */
213
+ export function lineOf(body: string, index: number): number {
214
+ let line = 1
215
+ let from = 0
216
+ for (;;) {
217
+ const next = body.indexOf('\n', from)
218
+ if (next < 0 || next >= index) return line
219
+ line++
220
+ from = next + 1
221
+ }
222
+ }
223
+
224
+ /** Bounded, single-line snippet around the first match. */
225
+ function makeSnippet(body: string, located: Located | null): string {
226
+ if (located === null) {
227
+ const flat = body.replace(/\s+/g, ' ').trim()
228
+ return flat.length > SNIPPET_MAX ? flat.slice(0, SNIPPET_MAX - 1) + '…' : flat
229
+ }
230
+ const start = Math.max(0, located.index - SNIPPET_BEFORE)
231
+ const end = Math.min(body.length, located.index + located.length + SNIPPET_AFTER)
232
+ const flat = body.slice(start, end).replace(/\s+/g, ' ')
233
+ const text = (start > 0 ? '…' : '') + flat + (end < body.length ? '…' : '')
234
+ return text.length > SNIPPET_MAX ? text.slice(0, SNIPPET_MAX - 1) + '…' : text
235
+ }
236
+
237
+ function emptyCount(mode: ContentCountMode): ContentCount {
238
+ if (mode === 'none') return { value: 0, kind: 'unknown' }
239
+ return { value: 0, kind: 'exact' }
240
+ }
241
+
242
+ function clampLimit(limit: number): number {
243
+ return Math.max(1, Math.min(MAX_LIMIT, Number.isFinite(limit) ? Math.trunc(limit) : MAX_LIMIT))
244
+ }
245
+
246
+ function clampCountLimit(limit: number | undefined): number {
247
+ if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_COUNT_LIMIT
248
+ return Math.max(1, Math.min(MAX_COUNT_LIMIT, Math.trunc(limit)))
249
+ }
250
+
251
+ /** Split a normal-mode query on whitespace, dropping empty terms. */
252
+ function splitTerms(text: string): string[] {
253
+ return text.split(/\s+/).map(part => part.replace(/^"|"$/g, '')).filter(part => part !== '')
254
+ }
255
+
256
+ /** One query plan: the SQL WHERE fragment plus how to page/count it. */
257
+ interface QueryPlan {
258
+ where: string
259
+ params: Array<string | number>
260
+ /** Literal terms used for whole-word checks and snippet location. */
261
+ terms: string[]
262
+ caseSensitive: boolean
263
+ wholeWord: boolean
264
+ /** Match the whole phrase as one literal (extended mode). */
265
+ phrase: boolean
266
+ rankOrder: boolean
267
+ exactCount: boolean
268
+ /** GLOB counts are exact only below countLimit; above it they are bounded. */
269
+ boundedCount: boolean
270
+ }
271
+
272
+ function buildPlan(terms: readonly string[], options: {
273
+ caseSensitive: boolean
274
+ wholeWord: boolean
275
+ phrase: boolean
276
+ sessionPath: string | null
277
+ }): QueryPlan | null {
278
+ const { caseSensitive, wholeWord, phrase, sessionPath } = options
279
+ if (terms.length === 0) return null
280
+ const sessionClause = sessionPath === null ? '' : ' AND rowid IN (SELECT rowid FROM docs_meta WHERE path = ?)'
281
+ const sessionParams: Array<string | number> = sessionPath === null ? [] : [sessionPath]
282
+ const tooShort = terms.some(term => term.length < TRIGRAM_MIN)
283
+
284
+ if (phrase) {
285
+ const literal = terms[0]!
286
+ if (caseSensitive) {
287
+ return {
288
+ where: 'body GLOB ?' + sessionClause,
289
+ params: ['*' + escapeGlob(literal) + '*', ...sessionParams],
290
+ terms: [literal],
291
+ caseSensitive,
292
+ wholeWord,
293
+ phrase,
294
+ rankOrder: false,
295
+ exactCount: true,
296
+ boundedCount: true,
297
+ }
298
+ }
299
+ if (literal.length < TRIGRAM_MIN) {
300
+ return {
301
+ where: "body LIKE ? ESCAPE '\\'" + sessionClause,
302
+ params: ['%' + escapeLike(literal) + '%', ...sessionParams],
303
+ terms: [literal],
304
+ caseSensitive,
305
+ wholeWord,
306
+ phrase,
307
+ rankOrder: false,
308
+ exactCount: false,
309
+ boundedCount: true,
310
+ }
311
+ }
312
+ return {
313
+ where: 'docs MATCH ?' + sessionClause,
314
+ params: [quoteFts(literal), ...sessionParams],
315
+ terms: [literal],
316
+ caseSensitive,
317
+ wholeWord,
318
+ phrase,
319
+ rankOrder: true,
320
+ exactCount: true,
321
+ boundedCount: false,
322
+ }
323
+ }
324
+
325
+ if (caseSensitive) {
326
+ return {
327
+ where: terms.map(() => 'body GLOB ?').join(' AND ') + sessionClause,
328
+ params: [...terms.map(term => '*' + escapeGlob(term) + '*'), ...sessionParams],
329
+ terms: [...terms],
330
+ caseSensitive,
331
+ wholeWord,
332
+ phrase,
333
+ rankOrder: false,
334
+ exactCount: true,
335
+ boundedCount: true,
336
+ }
337
+ }
338
+ if (tooShort) {
339
+ return {
340
+ where: terms.map(() => "body LIKE ? ESCAPE '\\'").join(' AND ') + sessionClause,
341
+ params: [...terms.map(term => '%' + escapeLike(term) + '%'), ...sessionParams],
342
+ terms: [...terms],
343
+ caseSensitive,
344
+ wholeWord,
345
+ phrase,
346
+ rankOrder: false,
347
+ exactCount: false,
348
+ boundedCount: true,
349
+ }
350
+ }
351
+ return {
352
+ where: 'docs MATCH ?' + sessionClause,
353
+ params: [terms.map(quoteFts).join(' AND '), ...sessionParams],
354
+ terms: [...terms],
355
+ caseSensitive,
356
+ wholeWord,
357
+ phrase,
358
+ rankOrder: true,
359
+ exactCount: true,
360
+ boundedCount: false,
361
+ }
362
+ }
363
+
364
+ /** One document row fetched for a hit. */
365
+ interface DocRow {
366
+ kind?: string
367
+ path?: string
368
+ title?: string
369
+ session_id?: string | null
370
+ message_id?: string | null
371
+ body?: string
372
+ node_key?: string | null
373
+ score?: number
374
+ }
375
+
376
+ const DOC_SQL = `SELECT d.kind AS kind, d.path AS path, d.title AS title,
377
+ d.session_id AS session_id, d.message_id AS message_id, d.body AS body,
378
+ m.node_key AS node_key
379
+ FROM docs d LEFT JOIN docs_meta m ON m.rowid = d.rowid
380
+ WHERE d.rowid = ?`
381
+
382
+ /** Build hits from rowids: metadata, snippet, first-match line and score. */
383
+ function buildHits(db: DatabaseSync, plan: QueryPlan, rowids: readonly number[], scores: ReadonlyMap<number, number>): ContentHit[] {
384
+ const statement = db.prepare(DOC_SQL)
385
+ const hits: ContentHit[] = []
386
+ for (const rowid of rowids) {
387
+ const row = statement.get(rowid) as DocRow | undefined
388
+ if (row === undefined || typeof row.body !== 'string') continue
389
+ const located = locateMatch(row.body, plan.terms, plan.caseSensitive, plan.wholeWord)
390
+ const line = located === null ? undefined : lineOf(row.body, located.index)
391
+ const score = scores.get(rowid)
392
+ const hit: ContentHit = {
393
+ kind: row.kind === 'session' ? 'session' : 'file',
394
+ path: typeof row.path === 'string' ? row.path : '',
395
+ title: typeof row.title === 'string' ? row.title : '',
396
+ snippet: makeSnippet(row.body, located),
397
+ score: score === undefined ? 0 : score,
398
+ ...(typeof row.session_id === 'string' && row.session_id !== '' ? { sessionId: row.session_id } : {}),
399
+ ...(typeof row.message_id === 'string' && row.message_id !== '' ? { messageId: row.message_id } : {}),
400
+ ...(typeof row.node_key === 'string' && row.node_key !== '' ? { nodeKey: row.node_key } : {}),
401
+ ...(line === undefined ? {} : { line }),
402
+ }
403
+ hits.push(hit)
404
+ }
405
+ return hits
406
+ }
407
+
408
+ /** Candidate rowids for one page (no post-filter). */
409
+ function pageRowids(db: DatabaseSync, plan: QueryPlan, limit: number): { rowids: number[]; scores: Map<number, number> } {
410
+ const scores = new Map<number, number>()
411
+ const sql = plan.rankOrder
412
+ ? `SELECT rowid AS rowid, rank AS score FROM docs WHERE ${plan.where} ORDER BY rank LIMIT ?`
413
+ : `SELECT rowid AS rowid FROM docs WHERE ${plan.where} LIMIT ?`
414
+ const rows = db.prepare(sql).all(...plan.params, limit) as Array<{ rowid?: number; score?: number }>
415
+ const rowids: number[] = []
416
+ for (const row of rows) {
417
+ if (typeof row.rowid !== 'number') continue
418
+ rowids.push(row.rowid)
419
+ if (typeof row.score === 'number') scores.set(row.rowid, row.score)
420
+ }
421
+ return { rowids, scores }
422
+ }
423
+
424
+ /** Exact COUNT over the plan's WHERE fragment. */
425
+ function exactCount(db: DatabaseSync, plan: QueryPlan): number {
426
+ const row = db.prepare(`SELECT count(*) AS n FROM docs WHERE ${plan.where}`).get(...plan.params) as { n?: number } | undefined
427
+ return Number(row?.n ?? 0)
428
+ }
429
+
430
+ /** Count up to `limit + 1` rows: exact below the cap, bounded at the cap. */
431
+ function boundedCount(db: DatabaseSync, plan: QueryPlan, limit: number): ContentCount {
432
+ const row = db
433
+ .prepare(`SELECT count(*) AS n FROM (SELECT rowid FROM docs WHERE ${plan.where} LIMIT ?)`)
434
+ .get(...plan.params, limit + 1) as { n?: number } | undefined
435
+ const value = Number(row?.n ?? 0)
436
+ if (value > limit) return { value: limit, kind: 'bounded', limit }
437
+ return { value, kind: 'exact' }
438
+ }
439
+
440
+ /** A plan with a whole-word post-filter. */
441
+ interface WholeWordScan {
442
+ rowids: number[]
443
+ count: ContentCount
444
+ warnings: string[]
445
+ }
446
+
447
+ function scanWholeWord(
448
+ db: DatabaseSync,
449
+ plan: QueryPlan,
450
+ limit: number,
451
+ countMode: ContentCountMode,
452
+ countLimit: number,
453
+ sessionScoped: boolean,
454
+ ): WholeWordScan {
455
+ const warnings: string[] = []
456
+ const rowids: number[] = []
457
+ const sql = plan.rankOrder
458
+ ? `SELECT rowid AS rowid FROM docs WHERE ${plan.where} ORDER BY rank`
459
+ : `SELECT rowid AS rowid FROM docs WHERE ${plan.where}`
460
+ const iterator = db.prepare(sql).iterate(...plan.params)
461
+ const bodyStatement = db.prepare('SELECT body FROM docs WHERE rowid = ?')
462
+ let count = 0
463
+ let exhausted = true
464
+ for (const raw of iterator) {
465
+ const rowid = (raw as { rowid?: number }).rowid
466
+ if (typeof rowid !== 'number') continue
467
+ const row = bodyStatement.get(rowid) as { body?: string } | undefined
468
+ if (row === undefined || typeof row.body !== 'string') continue
469
+ if (!matchesWholeWord(row.body, plan.terms, plan.caseSensitive)) continue
470
+ count++
471
+ if (rowids.length < limit) rowids.push(rowid)
472
+ if (countMode === 'none') {
473
+ if (rowids.length >= limit) break
474
+ } else if (!sessionScoped && count > countLimit) {
475
+ exhausted = false
476
+ break
477
+ }
478
+ }
479
+ let result: ContentCount
480
+ if (countMode === 'none') {
481
+ result = { value: 0, kind: 'unknown' }
482
+ } else if (!exhausted || count > countLimit) {
483
+ // Above the cap: report the known floor, never an exact count we did not
484
+ // finish. Global whole-word counts are deliberately bounded.
485
+ result = { value: countLimit, kind: 'bounded', limit: countLimit }
486
+ warnings.push('bounded-count')
487
+ } else {
488
+ result = { value: count, kind: 'exact' }
489
+ }
490
+ return { rowids, count: result, warnings: [...new Set(warnings)] }
491
+ }
492
+
493
+ /**
494
+ * Run one content query.
495
+ *
496
+ * Modes: `normal` (whitespace-separated substring terms), `extended`
497
+ * (Notepad++ escapes, one literal phrase), `regex` (not executed server-side in
498
+ * 0.9.0: returns `content-regex-not-supported`).
499
+ */
500
+ export function searchContent(
501
+ db: DatabaseSync,
502
+ query: string,
503
+ limit: number,
504
+ options: ContentSearchOptions = {},
505
+ ): ContentSearchResult {
17
506
  const text = query.trim()
18
- if (text === '') return []
19
- const capped = Math.max(1, Math.min(100, limit))
20
- let rows: Array<Record<string, unknown>>
21
- if ([...text].length < 3) {
22
- // A one- or two-character query cannot use the trigram index, so bound the
23
- // payload as well as the row count: never ship a whole megabyte body.
24
- rows = db.prepare(`SELECT kind, path, title, session_id, message_id,
25
- (SELECT node_key FROM docs_meta WHERE docs_meta.rowid = docs.rowid) AS node_key,
26
- substr(body, 1, 400) AS snippet, 0 AS score
27
- FROM docs WHERE body LIKE '%' || ? || '%' LIMIT ?`).all(text, capped) as Array<Record<string, unknown>>
507
+ const capped = clampLimit(limit)
508
+ const matchMode = options.matchMode ?? 'normal'
509
+ const countMode = options.count ?? 'exact'
510
+ const countLimit = clampCountLimit(options.countLimit)
511
+ const warnings: string[] = []
512
+
513
+ if (text === '') return { hits: [], count: emptyCount(countMode), warnings }
514
+ if (matchMode === 'regex') {
515
+ return {
516
+ hits: [],
517
+ count: { value: 0, kind: 'unknown' },
518
+ warnings: ['content-regex-paused'],
519
+ error: 'content-regex-not-supported',
520
+ }
521
+ }
522
+
523
+ let terms: string[]
524
+ let phrase = false
525
+ if (matchMode === 'extended') {
526
+ const decoded = decodeExtended(text)
527
+ warnings.push(...decoded.warnings)
528
+ if (!decoded.ok) {
529
+ return { hits: [], count: { value: 0, kind: 'unknown' }, warnings, error: decoded.error ?? 'invalid-escape' }
530
+ }
531
+ phrase = true
532
+ terms = decoded.text === '' ? [] : [decoded.text]
28
533
  } else {
29
- const match = text.split(/\s+/).filter(Boolean).map(term => '"' + term.replace(/"/g, '') + '"').join(' AND ')
30
- // Keep MATCH in this statement: FTS5 snippet() and bm25() are only defined
31
- // inside a matching query (a rowid subquery silently degrades both). The
32
- // query runs in the search worker, so its cost stays off the host thread.
33
- rows = db.prepare(`SELECT kind, path, title, session_id, message_id,
34
- (SELECT node_key FROM docs_meta WHERE docs_meta.rowid = docs.rowid) AS node_key,
35
- snippet(docs, 0, char(1), char(2), '…', 16) AS snippet, bm25(docs) AS score
36
- FROM docs WHERE docs MATCH ? ORDER BY score LIMIT ?`).all(match, capped) as Array<Record<string, unknown>>
37
- }
38
- return rows.map(row => ({
39
- kind: row.kind === 'session' ? 'session' : 'file',
40
- path: String(row.path ?? ''),
41
- title: String(row.title ?? ''),
42
- snippet: String(row.snippet ?? row.body ?? '').replace(/[\u0001\u0002]/g, ''),
43
- ...(row.session_id ? { sessionId: String(row.session_id) } : {}),
44
- ...(row.message_id ? { messageId: String(row.message_id) } : {}),
45
- ...(row.node_key ? { nodeKey: String(row.node_key) } : {}),
46
- score: Number(row.score ?? 0),
47
- }))
534
+ terms = splitTerms(text)
535
+ }
536
+ if (terms.length === 0) return { hits: [], count: emptyCount(countMode), warnings }
537
+
538
+ const sessionPath = options.sessionPath ?? null
539
+ const plan = buildPlan(terms, {
540
+ caseSensitive: options.caseSensitive === true,
541
+ wholeWord: options.wholeWord === true,
542
+ phrase,
543
+ sessionPath,
544
+ })
545
+ if (plan === null) return { hits: [], count: emptyCount(countMode), warnings }
546
+
547
+ if (plan.wholeWord) {
548
+ const scanned = scanWholeWord(db, plan, capped, countMode, countLimit, sessionPath !== null)
549
+ warnings.push(...scanned.warnings)
550
+ const hits = buildHits(db, plan, scanned.rowids, new Map())
551
+ return { hits, count: scanned.count, warnings: [...new Set(warnings)] }
552
+ }
553
+
554
+ let count: ContentCount
555
+ if (countMode === 'none') {
556
+ count = { value: 0, kind: 'unknown' }
557
+ } else if (countMode === 'bounded') {
558
+ count = boundedCount(db, plan, countLimit)
559
+ if (count.kind === 'bounded') warnings.push('bounded-count')
560
+ } else if (plan.exactCount && !plan.boundedCount) {
561
+ count = { value: exactCount(db, plan), kind: 'exact' }
562
+ } else {
563
+ // LIKE plans and very common GLOB patterns cannot count cheaply: cap the
564
+ // work and report the known floor plus a warning.
565
+ count = boundedCount(db, plan, countLimit)
566
+ if (count.kind === 'bounded') warnings.push('bounded-count')
567
+ }
568
+ const page = pageRowids(db, plan, capped)
569
+ const hits = buildHits(db, plan, page.rowids, page.scores)
570
+ return { hits, count, warnings: [...new Set(warnings)] }
48
571
  }