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.
- package/LICENSE +21 -0
- package/README.md +259 -0
- package/README.zh.md +257 -0
- package/README.zht.md +255 -0
- package/package.json +48 -0
- package/src/common/charting.ts +107 -0
- package/src/common/common.ts +74 -0
- package/src/common/config.ts +963 -0
- package/src/common/history-prompt.ts +72 -0
- package/src/common/plugin.ts +593 -0
- package/src/common/upstream-navigator-prompt.ts +131 -0
- package/src/core/index.ts +33 -0
- package/src/core/sdk-bridge.ts +73 -0
- package/src/core/session.ts +196 -0
- package/src/core/turn-index.ts +219 -0
- package/src/domain/adapter.ts +386 -0
- package/src/domain/clip-text.ts +86 -0
- package/src/domain/domain.ts +618 -0
- package/src/domain/preview.ts +132 -0
- package/src/domain/serialize.ts +409 -0
- package/src/domain/types.ts +321 -0
- package/src/runtime/charting.ts +73 -0
- package/src/runtime/debug.ts +155 -0
- package/src/runtime/logger.ts +34 -0
- package/src/runtime/message-io.ts +224 -0
- package/src/runtime/runtime.ts +1033 -0
- package/src/runtime/search.ts +1280 -0
- package/src/runtime/turn-resolve.ts +111 -0
|
@@ -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
|
+
}
|