opencode-engram 0.1.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.
@@ -0,0 +1,1280 @@
1
+ /**
2
+ * search.ts - Search Infrastructure (Phase 1 + Phase 2 + Phase 3)
3
+ *
4
+ * This module provides the search infrastructure for history_search.
5
+ *
6
+ * Phase 1 (established):
7
+ * - Search document model (part-level indexing)
8
+ * - Cache entry structure
9
+ * - Cache boundary skeleton
10
+ *
11
+ * Phase 2 (established):
12
+ * - Orama database initialization with Mandarin tokenizer
13
+ * - Document extraction pipeline (text/reasoning/tool content + input header)
14
+ * - Index build layer
15
+ * - Session-level cache read/write integration
16
+ *
17
+ * Phase 3 (current):
18
+ * - Search execution (exact/fulltext)
19
+ * - Hit aggregation and snippet generation
20
+ * - Result grouping by message with relevance-first ordering
21
+ */
22
+
23
+ import { create, insertMultiple, search, type Orama } from "@orama/orama";
24
+ import { createTokenizer } from "@orama/tokenizers/mandarin";
25
+
26
+ import {
27
+ composeContentWithToolInputSignature,
28
+ } from "../common/common.ts";
29
+ import type { SearchPartType } from "../domain/types.ts";
30
+ import type {
31
+ NormalizedPart,
32
+ NormalizedTextPart,
33
+ NormalizedReasoningPart,
34
+ NormalizedToolPart,
35
+ } from "../domain/types.ts";
36
+
37
+ // =============================================================================
38
+ // Search Document Model
39
+ // =============================================================================
40
+
41
+ /**
42
+ * A single searchable document representing a part within a message.
43
+ *
44
+ * This is the indexing unit for Orama. Each part that contains searchable
45
+ * content is converted into a SearchDocument for indexing.
46
+ *
47
+ * Fields:
48
+ * - id: unique identifier (part_id)
49
+ * - messageId: parent message identifier
50
+ * - type: part type (text, reasoning, tool)
51
+ * - content: searchable text content (tool docs include a JSON input header)
52
+ * - toolName: present only for tool parts
53
+ * - time: message creation time (for tie-breaking)
54
+ */
55
+ export interface SearchDocument {
56
+ id: string;
57
+ messageId: string;
58
+ type: SearchPartType;
59
+ content: string;
60
+ toolName?: string;
61
+ time: number | undefined;
62
+ }
63
+
64
+ /**
65
+ * Message metadata used for grouping search results.
66
+ */
67
+ export interface SearchMessageMeta {
68
+ id: string;
69
+ role: "user" | "assistant";
70
+ turn: number;
71
+ }
72
+
73
+ // =============================================================================
74
+ // Orama Schema and Database Type
75
+ // =============================================================================
76
+
77
+ /**
78
+ * Orama schema for search documents.
79
+ *
80
+ * Maps SearchDocument fields to Orama schema types.
81
+ */
82
+ const searchSchema = {
83
+ id: "string",
84
+ messageId: "string",
85
+ type: "string",
86
+ content: "string",
87
+ toolName: "string",
88
+ time: "number",
89
+ } as const;
90
+
91
+ /**
92
+ * Orama database type for search documents.
93
+ */
94
+ export type SearchOramaDb = Orama<typeof searchSchema>;
95
+
96
+ // =============================================================================
97
+ // Search Cache Entry
98
+ // =============================================================================
99
+
100
+ /**
101
+ * Cache entry for a session's search index.
102
+ *
103
+ * Contains the built search documents, Orama database instance, and
104
+ * fingerprint for invalidation.
105
+ */
106
+ export interface SearchCacheEntry {
107
+ /** Session ID this cache belongs to */
108
+ sessionId: string;
109
+
110
+ /** Session fingerprint for invalidation */
111
+ fingerprint: string | undefined;
112
+
113
+ /** Timestamp when this cache was created */
114
+ createdAt: number;
115
+
116
+ /** Array of searchable documents (retained for debugging/inspection) */
117
+ documents: SearchDocument[];
118
+
119
+ /** Orama database instance for search execution */
120
+ db: SearchOramaDb;
121
+
122
+ /** Message metadata map for result grouping (messageId -> meta) */
123
+ messageMeta: Map<string, SearchMessageMeta>;
124
+ }
125
+
126
+ // =============================================================================
127
+ // Search Cache (Session-Level Memory Cache)
128
+ // =============================================================================
129
+
130
+ const searchCacheMaxEntries = 32;
131
+ const searchCache = new Map<string, SearchCacheEntry>();
132
+
133
+ /**
134
+ * Prune old entries from the search cache.
135
+ *
136
+ * Uses LRU-like eviction: oldest entries (by insertion order) are removed first.
137
+ */
138
+ function pruneSearchCache(maxEntries: number): void {
139
+ while (searchCache.size > maxEntries) {
140
+ const oldest = searchCache.keys().next().value;
141
+ if (oldest === undefined) break;
142
+ searchCache.delete(oldest);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Get a search cache entry if valid.
148
+ *
149
+ * Validates against:
150
+ * - Session fingerprint (invalidates when session is modified)
151
+ * - TTL (invalidates when cache is too old)
152
+ *
153
+ * @param sessionId Session to look up
154
+ * @param fingerprint Current session fingerprint
155
+ * @param ttlMs Maximum age in milliseconds
156
+ * @returns Cache entry if valid, undefined otherwise
157
+ */
158
+ export function getSearchCacheEntry(
159
+ sessionId: string,
160
+ fingerprint: string | undefined,
161
+ ttlMs: number,
162
+ ): SearchCacheEntry | undefined {
163
+ const entry = searchCache.get(sessionId);
164
+ if (!entry) return undefined;
165
+
166
+ // Fingerprint mismatch - session has been modified
167
+ if (fingerprint !== entry.fingerprint) {
168
+ searchCache.delete(sessionId);
169
+ return undefined;
170
+ }
171
+
172
+ // TTL expired
173
+ const age = Date.now() - entry.createdAt;
174
+ if (age > ttlMs) {
175
+ searchCache.delete(sessionId);
176
+ return undefined;
177
+ }
178
+
179
+ // Refresh insertion order (LRU behavior)
180
+ searchCache.delete(sessionId);
181
+ searchCache.set(sessionId, entry);
182
+ return entry;
183
+ }
184
+
185
+ /**
186
+ * Store a search cache entry.
187
+ *
188
+ * Overwrites any existing entry for the session.
189
+ * Automatically prunes old entries to stay within limits.
190
+ *
191
+ * @param entry Cache entry to store
192
+ */
193
+ export function setSearchCacheEntry(entry: SearchCacheEntry): void {
194
+ searchCache.delete(entry.sessionId);
195
+ searchCache.set(entry.sessionId, entry);
196
+ pruneSearchCache(searchCacheMaxEntries);
197
+ }
198
+
199
+ // =============================================================================
200
+ // In-Flight Build Coalescing
201
+ // =============================================================================
202
+
203
+ /**
204
+ * In-flight cache build promises for request coalescing.
205
+ *
206
+ * Keyed by (sessionId, fingerprint) so callers never join a build that started
207
+ * with a stale session fingerprint.
208
+ */
209
+ const searchCacheInflight = new Map<string, Promise<SearchCacheEntry>>();
210
+
211
+ function searchInflightKey(sessionId: string, fingerprint: string | undefined): string {
212
+ return JSON.stringify([sessionId, fingerprint ?? null]);
213
+ }
214
+
215
+ /**
216
+ * Get an in-flight cache build promise if one exists.
217
+ */
218
+ export function getSearchCacheInflight(
219
+ sessionId: string,
220
+ fingerprint: string | undefined,
221
+ ): Promise<SearchCacheEntry> | undefined {
222
+ return searchCacheInflight.get(searchInflightKey(sessionId, fingerprint));
223
+ }
224
+
225
+ /**
226
+ * Register an in-flight cache build promise.
227
+ */
228
+ export function setSearchCacheInflight(
229
+ sessionId: string,
230
+ fingerprint: string | undefined,
231
+ promise: Promise<SearchCacheEntry>,
232
+ ): void {
233
+ searchCacheInflight.set(searchInflightKey(sessionId, fingerprint), promise);
234
+ }
235
+
236
+ /**
237
+ * Clear an in-flight cache build registration.
238
+ *
239
+ * Only clears if the registered promise matches (guard against races).
240
+ */
241
+ export function clearSearchCacheInflight(
242
+ sessionId: string,
243
+ fingerprint: string | undefined,
244
+ promise: Promise<SearchCacheEntry>,
245
+ ): void {
246
+ const key = searchInflightKey(sessionId, fingerprint);
247
+ if (searchCacheInflight.get(key) === promise) {
248
+ searchCacheInflight.delete(key);
249
+ }
250
+ }
251
+
252
+ // =============================================================================
253
+ // Search Input Types (Validated)
254
+ // =============================================================================
255
+
256
+ /**
257
+ * Validated search input parameters.
258
+ *
259
+ * All fields are validated and normalized by the time they reach this type.
260
+ */
261
+ export interface SearchInput {
262
+ /** Normalized query string (non-empty, within length limit) */
263
+ query: string;
264
+
265
+ /** Search mode: false = fulltext/BM25, true = literal substring match */
266
+ literal: boolean;
267
+
268
+ /** Maximum messages to return (1-10) */
269
+ limit: number;
270
+
271
+ /** Allowed searchable part types */
272
+ types: SearchPartType[];
273
+ }
274
+
275
+ // =============================================================================
276
+ // Search Execution Types
277
+ // =============================================================================
278
+
279
+ /**
280
+ * A single raw hit from Orama search.
281
+ *
282
+ * Used internally before grouping by message.
283
+ */
284
+ interface RawSearchHit {
285
+ documentId: string;
286
+ messageId: string;
287
+ type: SearchPartType;
288
+ toolName?: string;
289
+ content: string;
290
+ score: number;
291
+ time: number;
292
+ }
293
+
294
+ interface RawSearchResult {
295
+ totalHits: number;
296
+ hits: RawSearchHit[];
297
+ }
298
+
299
+ /**
300
+ * Search execution result after grouping and snippet generation.
301
+ *
302
+ * - totalHits: total unique hits found before message limiting
303
+ * - hits: grouped hits selected for returned messages with snippets
304
+ */
305
+ export interface SearchExecutionResult {
306
+ totalHits: number;
307
+ hits: Array<{
308
+ documentId: string;
309
+ messageId: string;
310
+ type: SearchPartType;
311
+ toolName?: string;
312
+ snippets: string[];
313
+ }>;
314
+ }
315
+
316
+ export interface ToolSearchVisibility {
317
+ visibleToolInputs: ReadonlySet<string>;
318
+ visibleToolOutputs: ReadonlySet<string>;
319
+ }
320
+
321
+ // =============================================================================
322
+ // Document Extraction Pipeline
323
+ // =============================================================================
324
+
325
+ /**
326
+ * Check if a text part should be included in search.
327
+ *
328
+ * Excludes:
329
+ * - Ignored parts
330
+ * - Empty/whitespace-only content
331
+ */
332
+ function isSearchableTextPart(part: NormalizedTextPart): boolean {
333
+ if (part.ignored) return false;
334
+ return part.text.trim().length > 0;
335
+ }
336
+
337
+ /**
338
+ * Check if a reasoning part should be included in search.
339
+ *
340
+ * Excludes:
341
+ * - Empty/whitespace-only content
342
+ */
343
+ function isSearchableReasoningPart(part: NormalizedReasoningPart): boolean {
344
+ return part.text.trim().length > 0;
345
+ }
346
+
347
+ /**
348
+ * Check if a tool part should be included in search.
349
+ *
350
+ * Tool parts are searchable when they have either:
351
+ * - structured input parameters, or
352
+ * - output content
353
+ */
354
+ function isSearchableToolPart(part: NormalizedToolPart): boolean {
355
+ if (Object.keys(part.input).length > 0) {
356
+ return true;
357
+ }
358
+
359
+ return part.content !== undefined && part.content.trim().length > 0;
360
+ }
361
+
362
+ function shouldSearchToolInput(
363
+ part: NormalizedToolPart,
364
+ toolVisibility: ToolSearchVisibility | undefined,
365
+ ): boolean {
366
+ if (!toolVisibility) {
367
+ return true;
368
+ }
369
+ return toolVisibility.visibleToolInputs.has(part.tool);
370
+ }
371
+
372
+ function shouldSearchToolOutput(
373
+ part: NormalizedToolPart,
374
+ toolVisibility: ToolSearchVisibility | undefined,
375
+ ): boolean {
376
+ if (!toolVisibility) {
377
+ return true;
378
+ }
379
+ return toolVisibility.visibleToolOutputs.has(part.tool);
380
+ }
381
+
382
+ function isSearchableVisibleToolPart(
383
+ part: NormalizedToolPart,
384
+ toolVisibility: ToolSearchVisibility | undefined,
385
+ ): boolean {
386
+ const canSearchInput = shouldSearchToolInput(part, toolVisibility);
387
+ const canSearchOutput = shouldSearchToolOutput(part, toolVisibility);
388
+
389
+ if (canSearchInput && Object.keys(part.input).length > 0) {
390
+ return true;
391
+ }
392
+
393
+ if (canSearchOutput && part.content !== undefined && part.content.trim().length > 0) {
394
+ return true;
395
+ }
396
+
397
+ return false;
398
+ }
399
+
400
+ /**
401
+ * Extract a search document from a text part.
402
+ */
403
+ function extractTextDocument(
404
+ part: NormalizedTextPart,
405
+ time: number | undefined,
406
+ ): SearchDocument {
407
+ return {
408
+ id: part.partId,
409
+ messageId: part.messageId,
410
+ type: "text",
411
+ content: part.text,
412
+ time,
413
+ };
414
+ }
415
+
416
+ /**
417
+ * Extract a search document from a reasoning part.
418
+ */
419
+ function extractReasoningDocument(
420
+ part: NormalizedReasoningPart,
421
+ time: number | undefined,
422
+ ): SearchDocument {
423
+ return {
424
+ id: part.partId,
425
+ messageId: part.messageId,
426
+ type: "reasoning",
427
+ content: part.text,
428
+ time,
429
+ };
430
+ }
431
+
432
+ /**
433
+ * Extract a search document from a tool part.
434
+ *
435
+ * The searchable content is prefixed with a tool-signature header containing
436
+ * the tool input so search can match parameter values.
437
+ */
438
+ function extractToolDocument(
439
+ part: NormalizedToolPart,
440
+ time: number | undefined,
441
+ toolVisibility: ToolSearchVisibility | undefined,
442
+ ): SearchDocument {
443
+ const input = shouldSearchToolInput(part, toolVisibility)
444
+ ? part.input
445
+ : undefined;
446
+ const output = shouldSearchToolOutput(part, toolVisibility)
447
+ ? part.content
448
+ : undefined;
449
+ const content = composeContentWithToolInputSignature(
450
+ part.tool,
451
+ input,
452
+ output,
453
+ );
454
+ return {
455
+ id: part.partId,
456
+ messageId: part.messageId,
457
+ type: "tool",
458
+ content: content ?? "",
459
+ toolName: part.tool,
460
+ time,
461
+ };
462
+ }
463
+
464
+ /**
465
+ * Extract search documents from normalized parts.
466
+ *
467
+ * Extracts from:
468
+ * - text (user or assistant text content)
469
+ * - reasoning (assistant reasoning content)
470
+ * - tool (input header + output content)
471
+ *
472
+ * Does NOT extract from image/file attachments.
473
+ *
474
+ * @param parts Normalized parts from a message
475
+ * @param messageTime Message creation time
476
+ * @returns Array of search documents
477
+ */
478
+ export function extractSearchDocuments(
479
+ parts: NormalizedPart[],
480
+ messageTime: number | undefined,
481
+ toolVisibility?: ToolSearchVisibility,
482
+ ): SearchDocument[] {
483
+ const documents: SearchDocument[] = [];
484
+
485
+ for (const part of parts) {
486
+ switch (part.type) {
487
+ case "text":
488
+ if (isSearchableTextPart(part)) {
489
+ documents.push(extractTextDocument(part, messageTime));
490
+ }
491
+ break;
492
+ case "reasoning":
493
+ if (isSearchableReasoningPart(part)) {
494
+ documents.push(extractReasoningDocument(part, messageTime));
495
+ }
496
+ break;
497
+ case "tool":
498
+ if (isSearchableToolPart(part) && isSearchableVisibleToolPart(part, toolVisibility)) {
499
+ documents.push(extractToolDocument(part, messageTime, toolVisibility));
500
+ }
501
+ break;
502
+ // image/file parts are intentionally not searchable
503
+ case "image":
504
+ case "file":
505
+ break;
506
+ }
507
+ }
508
+
509
+ return documents;
510
+ }
511
+
512
+ // =============================================================================
513
+ // Orama Database Initialization
514
+ // =============================================================================
515
+
516
+ /**
517
+ * Create a new Orama database instance with Mandarin tokenizer.
518
+ *
519
+ * The Mandarin tokenizer also handles English and other languages,
520
+ * making it suitable for mixed-language content.
521
+ */
522
+ export async function createSearchDatabase(): Promise<SearchOramaDb> {
523
+ const tokenizer = await createTokenizer();
524
+ return create({
525
+ schema: searchSchema,
526
+ components: {
527
+ tokenizer,
528
+ },
529
+ });
530
+ }
531
+
532
+ /**
533
+ * Internal document shape for Orama insertion.
534
+ *
535
+ * Matches the searchSchema definition for type-safe insertion.
536
+ * Optional fields must use empty string as Orama requires all
537
+ * schema fields to be present.
538
+ */
539
+ interface OramaSearchDocInsert {
540
+ id: string;
541
+ messageId: string;
542
+ type: string;
543
+ content: string;
544
+ toolName: string;
545
+ time: number;
546
+ }
547
+
548
+ /**
549
+ * Convert SearchDocument to Orama insert format.
550
+ *
551
+ * Handles optional fields:
552
+ * - toolName: defaults to empty string
553
+ * - time: defaults to -1 to avoid bad tie-break behavior when mixed with real timestamps
554
+ * (0 would sort equivalently to epoch, -1 ensures missing times sort last)
555
+ */
556
+ function toOramaDocument(doc: SearchDocument): OramaSearchDocInsert {
557
+ return {
558
+ id: doc.id,
559
+ messageId: doc.messageId,
560
+ type: doc.type,
561
+ content: doc.content,
562
+ toolName: doc.toolName ?? "",
563
+ time: doc.time ?? -1,
564
+ };
565
+ }
566
+
567
+ /**
568
+ * Build the search index from extracted documents.
569
+ *
570
+ * Creates a new Orama database and inserts all documents.
571
+ *
572
+ * @param documents Array of search documents to index
573
+ * @returns Orama database instance with indexed documents
574
+ */
575
+ export async function buildSearchIndex(
576
+ documents: SearchDocument[],
577
+ ): Promise<SearchOramaDb> {
578
+ const db = await createSearchDatabase();
579
+
580
+ if (documents.length > 0) {
581
+ const oramaDocuments = documents.map(toOramaDocument);
582
+ await insertMultiple(db, oramaDocuments, 500);
583
+ }
584
+
585
+ return db;
586
+ }
587
+
588
+ // =============================================================================
589
+ // Full Cache Build Pipeline
590
+ // =============================================================================
591
+
592
+ /**
593
+ * Message bundle input for cache building.
594
+ *
595
+ * This type represents the message data needed to build the search cache.
596
+ * It's aligned with the MessageBundle type used in runtime.ts but defined
597
+ * independently to avoid circular dependencies.
598
+ */
599
+ export interface SearchMessageInput {
600
+ id: string;
601
+ role: "user" | "assistant";
602
+ time: number | undefined;
603
+ parts: NormalizedPart[];
604
+ turn: number;
605
+ }
606
+
607
+ /**
608
+ * Build a complete search cache entry from messages.
609
+ *
610
+ * This is the main entry point for Phase 2 cache building.
611
+ * It orchestrates:
612
+ * 1. Document extraction from all messages
613
+ * 2. Message metadata collection
614
+ * 3. Orama database initialization and indexing
615
+ * 4. Cache entry assembly
616
+ *
617
+ * @param sessionId Session identifier
618
+ * @param fingerprint Session fingerprint for invalidation
619
+ * @param messages Array of message inputs with normalized parts and turn numbers
620
+ * @returns Complete cache entry ready for storage
621
+ */
622
+ export async function buildSearchCacheEntry(
623
+ sessionId: string,
624
+ fingerprint: string | undefined,
625
+ messages: SearchMessageInput[],
626
+ toolVisibility?: ToolSearchVisibility,
627
+ ): Promise<SearchCacheEntry> {
628
+ // 1. Extract documents from all messages
629
+ const allDocuments: SearchDocument[] = [];
630
+ for (const msg of messages) {
631
+ const docs = extractSearchDocuments(msg.parts, msg.time, toolVisibility);
632
+ allDocuments.push(...docs);
633
+ }
634
+
635
+ // 2. Collect message metadata
636
+ const messageMeta = new Map<string, SearchMessageMeta>();
637
+ for (const msg of messages) {
638
+ messageMeta.set(msg.id, {
639
+ id: msg.id,
640
+ role: msg.role,
641
+ turn: msg.turn,
642
+ });
643
+ }
644
+
645
+ // 3. Build the Orama index
646
+ const db = await buildSearchIndex(allDocuments);
647
+
648
+ // 4. Assemble and return the cache entry
649
+ return {
650
+ sessionId,
651
+ fingerprint,
652
+ createdAt: Date.now(),
653
+ documents: allDocuments,
654
+ db,
655
+ messageMeta,
656
+ };
657
+ }
658
+
659
+ // =============================================================================
660
+ // Snippet Generation
661
+ // =============================================================================
662
+
663
+ /**
664
+ * Generate a single snippet around a match position.
665
+ *
666
+ * Creates a window of text around the match position, trimming to word
667
+ * boundaries where possible and adding ellipsis markers.
668
+ *
669
+ * @param content Full content string
670
+ * @param matchStart Start position of the match
671
+ * @param matchEnd End position of the match
672
+ * @param snippetLength Maximum snippet length
673
+ * @returns Snippet string with ellipsis markers
674
+ */
675
+ function generateSnippetWindow(
676
+ content: string,
677
+ matchStart: number,
678
+ matchEnd: number,
679
+ snippetLength: number,
680
+ ): string {
681
+ const contextBefore = Math.floor((snippetLength - (matchEnd - matchStart)) / 2);
682
+ const contextAfter = snippetLength - (matchEnd - matchStart) - contextBefore;
683
+
684
+ let start = Math.max(0, matchStart - contextBefore);
685
+ let end = Math.min(content.length, matchEnd + contextAfter);
686
+
687
+ // Try to align to word boundaries
688
+ if (start > 0) {
689
+ const wordBoundary = content.lastIndexOf(" ", start + 10);
690
+ if (wordBoundary > start - 20 && wordBoundary >= 0) {
691
+ start = wordBoundary + 1;
692
+ }
693
+ }
694
+ if (end < content.length) {
695
+ const wordBoundary = content.indexOf(" ", end - 10);
696
+ if (wordBoundary > 0 && wordBoundary < end + 20) {
697
+ end = wordBoundary;
698
+ }
699
+ }
700
+
701
+ let snippet = content.slice(start, end).trim();
702
+
703
+ // Add ellipsis markers
704
+ if (start > 0) {
705
+ snippet = "..." + snippet;
706
+ }
707
+ if (end < content.length) {
708
+ snippet = snippet + "...";
709
+ }
710
+
711
+ return hardCapSnippet(snippet, snippetLength);
712
+ }
713
+
714
+ /**
715
+ * Enforce a hard snippet max length, including ellipsis markers.
716
+ */
717
+ function hardCapSnippet(snippet: string, snippetLength: number): string {
718
+ const trimmed = snippet.trim();
719
+ if (trimmed.length <= snippetLength) {
720
+ return trimmed;
721
+ }
722
+
723
+ if (snippetLength <= 3) {
724
+ return "...".slice(0, snippetLength);
725
+ }
726
+
727
+ return `${trimmed.slice(0, snippetLength - 3).trimEnd()}...`;
728
+ }
729
+
730
+ /**
731
+ * Generate a fallback snippet from the start of content.
732
+ */
733
+ function generateStartSnippet(content: string, snippetLength: number): string {
734
+ const trimmed = content.trim();
735
+ if (!trimmed) {
736
+ return "";
737
+ }
738
+
739
+ if (trimmed.length <= snippetLength) {
740
+ return trimmed;
741
+ }
742
+
743
+ if (snippetLength <= 3) {
744
+ return "...".slice(0, snippetLength);
745
+ }
746
+
747
+ return `${trimmed.slice(0, snippetLength - 3).trimEnd()}...`;
748
+ }
749
+
750
+ /**
751
+ * Find all literal occurrences of a term in content (case-sensitive).
752
+ *
753
+ * @param content Content to search
754
+ * @param term Term to find
755
+ * @returns Array of {start, end} positions
756
+ */
757
+ function findLiteralPositions(
758
+ content: string,
759
+ term: string,
760
+ ): Array<{ start: number; end: number }> {
761
+ const positions: Array<{ start: number; end: number }> = [];
762
+ let pos = 0;
763
+
764
+ while (pos < content.length) {
765
+ const found = content.indexOf(term, pos);
766
+ if (found === -1) break;
767
+ positions.push({ start: found, end: found + term.length });
768
+ pos = found + 1;
769
+ }
770
+
771
+ return positions;
772
+ }
773
+
774
+ function tokenizeSnippetTerms(tokenizer: SearchOramaDb["tokenizer"], query: string): string[] {
775
+ const rawTerms = tokenizer.tokenize(query, tokenizer.language);
776
+ const terms: string[] = [];
777
+ const seen = new Set<string>();
778
+
779
+ for (const term of rawTerms) {
780
+ const normalized = term.trim();
781
+ if (!normalized || seen.has(normalized)) {
782
+ continue;
783
+ }
784
+
785
+ seen.add(normalized);
786
+ terms.push(normalized);
787
+ }
788
+
789
+ return terms;
790
+ }
791
+
792
+ type SnippetCandidate = {
793
+ start: number;
794
+ end: number;
795
+ priority: number;
796
+ secondary: number;
797
+ };
798
+
799
+ function compareSnippetCandidates(
800
+ a: SnippetCandidate,
801
+ b: SnippetCandidate,
802
+ ): number {
803
+ const priorityDiff = b.priority - a.priority;
804
+ if (Math.abs(priorityDiff) > 0.0001) {
805
+ return priorityDiff;
806
+ }
807
+
808
+ const secondaryDiff = a.secondary - b.secondary;
809
+ if (secondaryDiff !== 0) {
810
+ return secondaryDiff;
811
+ }
812
+
813
+ const startDiff = a.start - b.start;
814
+ if (startDiff !== 0) {
815
+ return startDiff;
816
+ }
817
+
818
+ return a.end - b.end;
819
+ }
820
+
821
+ function collectTopSnippets(
822
+ content: string,
823
+ candidates: SnippetCandidate[],
824
+ snippetLength: number,
825
+ maxSnippets: number,
826
+ ): string[] {
827
+ const snippets: string[] = [];
828
+ const usedRanges: Array<{ start: number; end: number }> = [];
829
+
830
+ for (const candidate of [...candidates].sort(compareSnippetCandidates)) {
831
+ if (snippets.length >= maxSnippets) {
832
+ break;
833
+ }
834
+
835
+ const overlaps = usedRanges.some(
836
+ (range) => candidate.start < range.end && candidate.end > range.start,
837
+ );
838
+ if (overlaps) {
839
+ continue;
840
+ }
841
+
842
+ const snippet = generateSnippetWindow(
843
+ content,
844
+ candidate.start,
845
+ candidate.end,
846
+ snippetLength,
847
+ );
848
+ if (!snippet) {
849
+ continue;
850
+ }
851
+
852
+ snippets.push(snippet);
853
+ usedRanges.push({
854
+ start: Math.max(0, candidate.start - snippetLength),
855
+ end: Math.min(content.length, candidate.end + snippetLength),
856
+ });
857
+ }
858
+
859
+ return snippets;
860
+ }
861
+
862
+ /**
863
+ * Generate snippet array for a document match.
864
+ *
865
+ * For exact mode: ranks literal occurrences by earliest position.
866
+ * For fulltext mode: ranks term matches by term specificity, then query order,
867
+ * then document position.
868
+ *
869
+ * @param content Document content
870
+ * @param query Search query
871
+ * @param exact Whether exact mode was used
872
+ * @param snippetLength Maximum snippet length
873
+ * @param maxSnippetsPerMessage Maximum number of snippets to return for this hit
874
+ * @returns Array of snippet strings (may be empty if no good snippets found)
875
+ */
876
+ export function generateSnippets(
877
+ content: string,
878
+ query: string,
879
+ exact: boolean,
880
+ snippetLength: number,
881
+ maxSnippetsPerMessage: number,
882
+ fulltextQueryTerms?: string[],
883
+ ): string[] {
884
+ if (!content || content.trim().length === 0) {
885
+ return [];
886
+ }
887
+
888
+ if (exact) {
889
+ // Exact mode: rank literal substring occurrences by earliest position.
890
+ const positions = findLiteralPositions(content, query);
891
+ if (positions.length === 0) {
892
+ // Fallback: return start of content
893
+ const snippet = generateStartSnippet(content, snippetLength);
894
+ return snippet ? [snippet] : [];
895
+ }
896
+
897
+ const candidates = positions.map((pos) => ({
898
+ start: pos.start,
899
+ end: pos.end,
900
+ priority: -pos.start,
901
+ secondary: pos.end,
902
+ }));
903
+
904
+ const snippets = collectTopSnippets(
905
+ content,
906
+ candidates,
907
+ snippetLength,
908
+ maxSnippetsPerMessage,
909
+ );
910
+ return snippets.length > 0 ? snippets : [];
911
+ }
912
+
913
+ // Fulltext mode: rank snippets by term specificity, then term order, then position.
914
+ const queryTerms = fulltextQueryTerms ?? query
915
+ .split(/\s+/)
916
+ .map((term) => term.trim())
917
+ .filter((term) => term.length > 0);
918
+
919
+ const seenTerms = new Map<string, { term: string; index: number }>();
920
+ for (let i = 0; i < queryTerms.length; i += 1) {
921
+ const term = queryTerms[i];
922
+ if (!seenTerms.has(term)) {
923
+ seenTerms.set(term, { term, index: i });
924
+ }
925
+ }
926
+
927
+ const terms = Array.from(seenTerms.values());
928
+
929
+ const candidates: SnippetCandidate[] = [];
930
+ for (const entry of terms) {
931
+ const positions = findLiteralPositions(content, entry.term);
932
+ for (const pos of positions) {
933
+ candidates.push({
934
+ start: pos.start,
935
+ end: pos.end,
936
+ priority: entry.term.length * 1000 - entry.index,
937
+ secondary: pos.start,
938
+ });
939
+ }
940
+ }
941
+
942
+ if (candidates.length > 0) {
943
+ const snippets = collectTopSnippets(
944
+ content,
945
+ candidates,
946
+ snippetLength,
947
+ maxSnippetsPerMessage,
948
+ );
949
+ if (snippets.length > 0) {
950
+ return snippets;
951
+ }
952
+ }
953
+
954
+ // No terms found, return start of content.
955
+ const snippet = generateStartSnippet(content, snippetLength);
956
+ return snippet ? [snippet] : [];
957
+ }
958
+
959
+ // =============================================================================
960
+ // Search Execution
961
+ // =============================================================================
962
+
963
+ /**
964
+ * Convert Orama search results to raw hits.
965
+ */
966
+ function toRawHits(
967
+ oramaHits: Array<{
968
+ id: string;
969
+ score: number;
970
+ document: OramaSearchDocInsert;
971
+ }>,
972
+ ): RawSearchHit[] {
973
+ return oramaHits.map((hit) => ({
974
+ documentId: hit.document.id,
975
+ messageId: hit.document.messageId,
976
+ type: hit.document.type as SearchPartType,
977
+ toolName: hit.document.toolName || undefined,
978
+ content: hit.document.content,
979
+ score: hit.score,
980
+ time: hit.document.time,
981
+ }));
982
+ }
983
+
984
+ function scoreSubstringMatchPosition(position: number): number {
985
+ return 1 / (position + 1);
986
+ }
987
+
988
+ function compareRawHitsByPriority(a: RawSearchHit, b: RawSearchHit): number {
989
+ const scoreDiff = b.score - a.score;
990
+ if (Math.abs(scoreDiff) > 0.0001) {
991
+ return scoreDiff;
992
+ }
993
+
994
+ const timeDiff = b.time - a.time;
995
+ if (timeDiff !== 0) {
996
+ return timeDiff;
997
+ }
998
+
999
+ const messageDiff = a.messageId.localeCompare(b.messageId);
1000
+ if (messageDiff !== 0) {
1001
+ return messageDiff;
1002
+ }
1003
+
1004
+ return a.documentId.localeCompare(b.documentId);
1005
+ }
1006
+
1007
+ /**
1008
+ * Execute exact search with literal substring matching.
1009
+ *
1010
+ * This path is independent from Orama and uses direct substring checks
1011
+ * on cached content documents for predictable Unicode behavior.
1012
+ */
1013
+ async function executeExactSearch(
1014
+ documents: SearchDocument[],
1015
+ query: string,
1016
+ allowedTypes: ReadonlySet<SearchPartType>,
1017
+ ): Promise<RawSearchResult> {
1018
+ if (query.length === 0) {
1019
+ return { totalHits: 0, hits: [] };
1020
+ }
1021
+
1022
+ const hits: RawSearchHit[] = [];
1023
+ let totalHits = 0;
1024
+
1025
+ for (const doc of documents) {
1026
+ if (!allowedTypes.has(doc.type)) {
1027
+ continue;
1028
+ }
1029
+
1030
+ if (query.length > doc.content.length) {
1031
+ continue;
1032
+ }
1033
+
1034
+ const position = doc.content.indexOf(query);
1035
+ if (position === -1) {
1036
+ continue;
1037
+ }
1038
+
1039
+ totalHits += 1;
1040
+ hits.push({
1041
+ documentId: doc.id,
1042
+ messageId: doc.messageId,
1043
+ type: doc.type,
1044
+ toolName: doc.toolName,
1045
+ content: doc.content,
1046
+ score: scoreSubstringMatchPosition(position),
1047
+ time: doc.time ?? -1,
1048
+ });
1049
+ }
1050
+
1051
+ if (totalHits === 0) {
1052
+ return { totalHits: 0, hits: [] };
1053
+ }
1054
+
1055
+ hits.sort(compareRawHitsByPriority);
1056
+
1057
+ return {
1058
+ totalHits,
1059
+ hits,
1060
+ };
1061
+ }
1062
+
1063
+ /**
1064
+ * Execute Orama search with fulltext/BM25 mode.
1065
+ *
1066
+ * Uses Orama's default BM25 ranking for relevance-based results,
1067
+ * paging through all matches to preserve complete message grouping.
1068
+ */
1069
+ async function executeFulltextSearch(
1070
+ db: SearchOramaDb,
1071
+ query: string,
1072
+ types: SearchPartType[],
1073
+ ): Promise<RawSearchResult> {
1074
+ const pageSize = 500;
1075
+ let offset = 0;
1076
+ let totalHits = 0;
1077
+ const rawHits: RawSearchHit[] = [];
1078
+
1079
+ while (true) {
1080
+ const result = await search(db, {
1081
+ term: query,
1082
+ properties: ["content"],
1083
+ where: {
1084
+ type: types.length === 1 ? types[0]! : types,
1085
+ },
1086
+ limit: pageSize,
1087
+ offset,
1088
+ });
1089
+
1090
+ if (offset === 0) {
1091
+ totalHits = result.count;
1092
+ }
1093
+
1094
+ const pageHits = toRawHits(result.hits as Array<{
1095
+ id: string;
1096
+ score: number;
1097
+ document: OramaSearchDocInsert;
1098
+ }>);
1099
+
1100
+ if (pageHits.length === 0) {
1101
+ break;
1102
+ }
1103
+
1104
+ rawHits.push(...pageHits);
1105
+ offset += pageHits.length;
1106
+
1107
+ if (offset >= totalHits) {
1108
+ break;
1109
+ }
1110
+ }
1111
+
1112
+ rawHits.sort(compareRawHitsByPriority);
1113
+
1114
+ return {
1115
+ totalHits,
1116
+ hits: rawHits,
1117
+ };
1118
+ }
1119
+
1120
+ /**
1121
+ * Group raw hits by message and aggregate.
1122
+ *
1123
+ * Groups hits by message ID, preserving per-message hit ordering by score,
1124
+ * and orders messages by the best hit score within each message.
1125
+ *
1126
+ * @param rawHits Array of raw hits (already sorted by relevance)
1127
+ * @param messageMeta Message metadata map
1128
+ * @returns Grouped hits with message metadata
1129
+ */
1130
+ function groupHitsByMessage(
1131
+ rawHits: RawSearchHit[],
1132
+ messageMeta: Map<string, SearchMessageMeta>,
1133
+ ): Map<
1134
+ string,
1135
+ {
1136
+ meta: SearchMessageMeta;
1137
+ bestScore: number;
1138
+ bestTime: number;
1139
+ hits: RawSearchHit[];
1140
+ }
1141
+ > {
1142
+ const groups = new Map<
1143
+ string,
1144
+ {
1145
+ meta: SearchMessageMeta;
1146
+ bestScore: number;
1147
+ bestTime: number;
1148
+ hits: RawSearchHit[];
1149
+ }
1150
+ >();
1151
+
1152
+ for (const hit of rawHits) {
1153
+ const meta = messageMeta.get(hit.messageId);
1154
+ if (!meta) continue; // Skip orphaned hits
1155
+
1156
+ const existing = groups.get(hit.messageId);
1157
+ if (existing) {
1158
+ existing.hits.push(hit);
1159
+ if (hit.score > existing.bestScore) {
1160
+ existing.bestScore = hit.score;
1161
+ }
1162
+ // Use the most recent time for tie-breaking (higher = newer)
1163
+ if (hit.time > existing.bestTime) {
1164
+ existing.bestTime = hit.time;
1165
+ }
1166
+ } else {
1167
+ groups.set(hit.messageId, {
1168
+ meta,
1169
+ bestScore: hit.score,
1170
+ bestTime: hit.time,
1171
+ hits: [hit],
1172
+ });
1173
+ }
1174
+ }
1175
+
1176
+ return groups;
1177
+ }
1178
+
1179
+ /**
1180
+ * Sort message groups by relevance (score descending, then time descending).
1181
+ */
1182
+ function sortMessageGroups(
1183
+ groups: Map<
1184
+ string,
1185
+ {
1186
+ meta: SearchMessageMeta;
1187
+ bestScore: number;
1188
+ bestTime: number;
1189
+ hits: RawSearchHit[];
1190
+ }
1191
+ >,
1192
+ ): Array<{
1193
+ meta: SearchMessageMeta;
1194
+ hits: RawSearchHit[];
1195
+ }> {
1196
+ return Array.from(groups.values()).sort((a, b) => {
1197
+ // Primary: score descending
1198
+ const scoreDiff = b.bestScore - a.bestScore;
1199
+ if (Math.abs(scoreDiff) > 0.0001) {
1200
+ return scoreDiff;
1201
+ }
1202
+ // Secondary: time descending (newer first)
1203
+ return b.bestTime - a.bestTime;
1204
+ });
1205
+ }
1206
+
1207
+ /**
1208
+ * Execute search against a cache entry.
1209
+ *
1210
+ * Handles:
1211
+ * - exact/fulltext mode routing
1212
+ * - Result grouping by message
1213
+ * - Relevance-first ordering
1214
+ * - Snippet generation
1215
+ *
1216
+ * @param cache Search cache entry with Orama database
1217
+ * @param input Validated search parameters
1218
+ * @param snippetLength Snippet length from config
1219
+ * @param maxSnippetsPerMessage Maximum snippets to include per hit
1220
+ * @returns Search execution result with grouped hits
1221
+ */
1222
+ export async function executeSearch(
1223
+ cache: SearchCacheEntry,
1224
+ input: SearchInput,
1225
+ snippetLength: number,
1226
+ maxSnippetsPerMessage: number,
1227
+ ): Promise<SearchExecutionResult> {
1228
+ const allowedTypes = new Set(input.types);
1229
+ const fulltextQueryTerms = input.literal
1230
+ ? undefined
1231
+ : tokenizeSnippetTerms(cache.db.tokenizer, input.query);
1232
+
1233
+ // 1. Execute appropriate search mode.
1234
+ const searchResult = input.literal
1235
+ ? await executeExactSearch(cache.documents, input.query, allowedTypes)
1236
+ : await executeFulltextSearch(cache.db, input.query, input.types);
1237
+
1238
+ if (searchResult.totalHits === 0 || searchResult.hits.length === 0) {
1239
+ return { totalHits: 0, hits: [] };
1240
+ }
1241
+
1242
+ // 2. Group hits by message
1243
+ const groups = groupHitsByMessage(searchResult.hits, cache.messageMeta);
1244
+
1245
+ // 3. Sort groups by relevance
1246
+ const sortedGroups = sortMessageGroups(groups);
1247
+
1248
+ // 4. Limit by message count, then flatten grouped hits.
1249
+ const limitedGroups = sortedGroups.slice(0, input.limit);
1250
+ const resultHits: SearchExecutionResult["hits"] = [];
1251
+
1252
+ for (const group of limitedGroups) {
1253
+ const sortedGroupHits = [...group.hits].sort(compareRawHitsByPriority);
1254
+
1255
+ for (const hit of sortedGroupHits) {
1256
+ const snippets = generateSnippets(
1257
+ hit.content,
1258
+ input.query,
1259
+ input.literal,
1260
+ snippetLength,
1261
+ maxSnippetsPerMessage,
1262
+ fulltextQueryTerms,
1263
+ );
1264
+
1265
+ resultHits.push({
1266
+ documentId: hit.documentId,
1267
+ messageId: hit.messageId,
1268
+ type: hit.type,
1269
+ toolName: hit.toolName,
1270
+ snippets,
1271
+ });
1272
+ }
1273
+ }
1274
+
1275
+ return {
1276
+ // totalHits is full matched hit count before message limiting.
1277
+ totalHits: searchResult.totalHits,
1278
+ hits: resultHits,
1279
+ };
1280
+ }