@kyleparrott/where-was-i 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,60 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { discoverCodexSessionFiles, parseCodexSessionFile } from "./codex.js";
4
+ import { SessionSearchDb } from "./database.js";
5
+ export function indexCodexSessions(options) {
6
+ const db = new SessionSearchDb(options.dbPath);
7
+ try {
8
+ const cutoffMs = options.days ? Date.now() - options.days * 24 * 60 * 60 * 1000 : null;
9
+ const files = filterSessionFiles(discoverCodexSessionFiles(options.codexHome, options.includeArchived), options.sessionIds);
10
+ const summary = { discovered: files.length, indexed: 0, skipped: 0, failed: [] };
11
+ let candidates = files.map((file) => ({ file, stat: fs.statSync(file) }));
12
+ if (cutoffMs) {
13
+ const before = candidates.length;
14
+ candidates = candidates.filter(({ stat }) => stat.mtimeMs >= cutoffMs);
15
+ summary.skipped += before - candidates.length;
16
+ }
17
+ if (options.recent) {
18
+ const before = candidates.length;
19
+ candidates = candidates
20
+ .sort((left, right) => right.stat.mtimeMs - left.stat.mtimeMs || left.file.localeCompare(right.file))
21
+ .slice(0, options.recent);
22
+ summary.skipped += before - candidates.length;
23
+ }
24
+ for (const { file, stat } of candidates) {
25
+ const source = {
26
+ path: file,
27
+ source: "codex",
28
+ size: stat.size,
29
+ mtimeMs: Math.round(stat.mtimeMs)
30
+ };
31
+ const existing = db.getSource(file);
32
+ if (!options.force &&
33
+ existing &&
34
+ existing.size === source.size &&
35
+ existing.mtimeMs === source.mtimeMs &&
36
+ db.sourceHasMessages(file)) {
37
+ summary.skipped += 1;
38
+ continue;
39
+ }
40
+ try {
41
+ const parsed = parseCodexSessionFile(file, { includeTools: options.includeTools });
42
+ db.upsertParsedSession(parsed, source);
43
+ summary.indexed += 1;
44
+ }
45
+ catch (error) {
46
+ summary.failed.push({ path: file, error: error instanceof Error ? error.message : String(error) });
47
+ }
48
+ }
49
+ return summary;
50
+ }
51
+ finally {
52
+ db.close();
53
+ }
54
+ }
55
+ function filterSessionFiles(files, sessionIds) {
56
+ if (!sessionIds || sessionIds.length === 0) {
57
+ return files;
58
+ }
59
+ return files.filter((file) => sessionIds.some((sessionId) => path.basename(file).includes(sessionId)));
60
+ }
@@ -0,0 +1,20 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ export function expandHome(input) {
4
+ if (input === "~") {
5
+ return os.homedir();
6
+ }
7
+ if (input.startsWith("~/")) {
8
+ return path.join(os.homedir(), input.slice(2));
9
+ }
10
+ return input;
11
+ }
12
+ export function defaultCodexHome() {
13
+ return process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : path.join(os.homedir(), ".codex");
14
+ }
15
+ export function defaultIndexPath() {
16
+ if (process.env.WHERE_WAS_I_INDEX_PATH) {
17
+ return expandHome(process.env.WHERE_WAS_I_INDEX_PATH);
18
+ }
19
+ return path.join(os.homedir(), ".where-was-i", "index.sqlite");
20
+ }
@@ -0,0 +1,18 @@
1
+ import fs from "node:fs";
2
+ export function resetIndexFiles(dbPath) {
3
+ const paths = [dbPath, `${dbPath}-wal`, `${dbPath}-shm`, `${dbPath}-journal`];
4
+ const summary = { deleted: [], missing: [] };
5
+ for (const target of paths) {
6
+ if (!fs.existsSync(target)) {
7
+ summary.missing.push(target);
8
+ continue;
9
+ }
10
+ const stat = fs.lstatSync(target);
11
+ if (stat.isDirectory()) {
12
+ throw new Error(`Refusing to delete directory while resetting index: ${target}`);
13
+ }
14
+ fs.unlinkSync(target);
15
+ summary.deleted.push(target);
16
+ }
17
+ return summary;
18
+ }
@@ -0,0 +1,17 @@
1
+ import { OpenAICompatibleEmbeddingProvider, isEmbeddingProviderConfigured } from "./embeddings.js";
2
+ import { semanticFreshness } from "./semantic.js";
3
+ export async function resolveConfiguredSearchMode(options) {
4
+ if (options.requestedMode !== "auto") {
5
+ return options.requestedMode;
6
+ }
7
+ const provider = new OpenAICompatibleEmbeddingProvider(options.embedding);
8
+ if (!isEmbeddingProviderConfigured(provider.config)) {
9
+ return "fts";
10
+ }
11
+ const freshness = await semanticFreshness({
12
+ dbPath: options.dbPath,
13
+ embedding: options.embedding,
14
+ probeProvider: false
15
+ });
16
+ return freshness.indexedChunks > 0 && freshness.incompatibleStoredVectors === 0 ? "hybrid" : "fts";
17
+ }
@@ -0,0 +1,562 @@
1
+ import fs from "node:fs";
2
+ import { SessionSearchDb } from "./database.js";
3
+ import { OpenAICompatibleEmbeddingProvider } from "./embeddings.js";
4
+ import { searchVectorChunks } from "./vector.js";
5
+ export async function searchSessions(options) {
6
+ const mode = options.mode ?? "fts";
7
+ if (mode === "semantic") {
8
+ return searchSemanticSessions(options);
9
+ }
10
+ if (mode === "hybrid") {
11
+ return searchHybridSessions(options);
12
+ }
13
+ return searchFtsSessions(options);
14
+ }
15
+ export function groupSearchResultsBySession(results, maxHitsPerSession = 3) {
16
+ const grouped = new Map();
17
+ for (const result of results) {
18
+ const existing = grouped.get(result.sessionId);
19
+ if (existing) {
20
+ if (existing.hits.length < maxHitsPerSession && !hasDuplicatePreview(existing.hits, result)) {
21
+ existing.hits.push(result);
22
+ }
23
+ existing.score = Math.max(existing.score, result.scores.final);
24
+ if (isBetterHit(result, existing.bestHit)) {
25
+ existing.bestHit = result;
26
+ existing.bestMessage = resolvedLocatorFromSearchResult(result);
27
+ existing.match = result.match;
28
+ existing.session.displayTitle = displayTitleForResult(result);
29
+ }
30
+ continue;
31
+ }
32
+ const bestMessage = resolvedLocatorFromSearchResult(result);
33
+ grouped.set(result.sessionId, {
34
+ session: {
35
+ id: result.sessionId,
36
+ conversationId: result.conversationId,
37
+ title: isBootstrapText(result.title ?? "") ? null : result.title,
38
+ displayTitle: displayTitleForResult(result),
39
+ cwd: result.cwd,
40
+ sourcePath: result.sessionSourcePath,
41
+ startedAt: result.sessionStartedAt,
42
+ updatedAt: result.sessionUpdatedAt ?? result.timestamp
43
+ },
44
+ bestMessage,
45
+ match: result.match,
46
+ bestHit: result,
47
+ hits: [result],
48
+ score: result.scores.final
49
+ });
50
+ }
51
+ return Array.from(grouped.values()).sort((left, right) => right.score - left.score);
52
+ }
53
+ export function searchFtsSessions(options) {
54
+ const db = new SessionSearchDb(options.dbPath);
55
+ try {
56
+ const matchedTerms = contentQueryTerms(options.query);
57
+ const ftsQuery = toFtsQuery(matchedTerms);
58
+ const limit = Math.min(Math.max(options.limit ?? 10, 1), 50);
59
+ const rows = db.db
60
+ .prepare(`SELECT
61
+ c.id AS chunkId,
62
+ c.message_id AS messageId,
63
+ c.session_id AS sessionId,
64
+ c.conversation_id AS conversationId,
65
+ c.turn_id AS turnId,
66
+ c.source AS source,
67
+ c.source_path AS sourcePath,
68
+ c.ordinal AS ordinal,
69
+ c.chunk_index AS chunkIndex,
70
+ c.role AS role,
71
+ c.kind AS kind,
72
+ c.timestamp AS timestamp,
73
+ s.title AS title,
74
+ s.cwd AS cwd,
75
+ s.started_at AS sessionStartedAt,
76
+ s.updated_at AS sessionUpdatedAt,
77
+ s.source_path AS sessionSourcePath,
78
+ c.line_start AS lineStart,
79
+ c.line_end AS lineEnd,
80
+ bm25(chunks_fts) AS score,
81
+ snippet(chunks_fts, 3, '[', ']', '...', 14) AS snippet,
82
+ c.text AS text
83
+ FROM chunks_fts
84
+ JOIN chunks c ON c.id = chunks_fts.chunk_id
85
+ LEFT JOIN sessions s ON s.id = c.session_id
86
+ WHERE chunks_fts MATCH ?
87
+ ORDER BY score ASC
88
+ LIMIT ?`)
89
+ .all(ftsQuery, limit);
90
+ return rows.map((row, index) => {
91
+ const locator = locatorFromSearchRow(row);
92
+ const finalScore = 1 / (index + 1);
93
+ const result = {
94
+ ...row,
95
+ score: finalScore,
96
+ preview: previewFromText(row.text),
97
+ locator,
98
+ links: linksForLocator(locator),
99
+ matchMode: "fts",
100
+ match: {
101
+ mode: "fts",
102
+ sources: ["fts"],
103
+ score: finalScore,
104
+ rank: index + 1,
105
+ ftsRank: index + 1,
106
+ ftsScore: row.score,
107
+ matchedTerms,
108
+ matchedRoles: [row.role]
109
+ },
110
+ scores: {
111
+ final: finalScore,
112
+ ftsRank: index + 1,
113
+ ftsScore: row.score
114
+ }
115
+ };
116
+ return options.includeText ? result : withoutText(result);
117
+ });
118
+ }
119
+ finally {
120
+ db.close();
121
+ }
122
+ }
123
+ async function searchSemanticSessions(options) {
124
+ const provider = new OpenAICompatibleEmbeddingProvider(options.embedding);
125
+ const embedding = await provider.embedQuery(options.query);
126
+ const limit = Math.min(Math.max(options.limit ?? 10, 1), 50);
127
+ const db = new SessionSearchDb(options.dbPath);
128
+ try {
129
+ const vectorRows = searchVectorChunks(db.db, provider.config, embedding, limit);
130
+ return hydrateVectorResults(db, vectorRows, "semantic", options.includeText);
131
+ }
132
+ finally {
133
+ db.close();
134
+ }
135
+ }
136
+ async function searchHybridSessions(options) {
137
+ const limit = Math.min(Math.max(options.limit ?? 10, 1), 50);
138
+ const ftsResults = searchFtsSessions({ ...options, limit: Math.max(limit, 25), includeText: true });
139
+ const semanticResults = await searchSemanticSessions({ ...options, limit: Math.max(limit, 25), includeText: true });
140
+ const merged = new Map();
141
+ for (const [index, result] of ftsResults.entries()) {
142
+ const finalScore = 1 / (index + 1);
143
+ merged.set(result.chunkId, {
144
+ ...result,
145
+ matchMode: "hybrid",
146
+ score: finalScore,
147
+ match: {
148
+ ...result.match,
149
+ mode: "hybrid",
150
+ sources: ["fts"],
151
+ score: finalScore,
152
+ rank: index + 1,
153
+ ftsRank: index + 1,
154
+ ftsScore: result.scores.ftsScore,
155
+ semanticRank: undefined,
156
+ vectorDistance: undefined
157
+ },
158
+ scores: {
159
+ final: finalScore,
160
+ ftsRank: index + 1,
161
+ ftsScore: result.scores.ftsScore
162
+ }
163
+ });
164
+ }
165
+ for (const [index, result] of semanticResults.entries()) {
166
+ const vectorScore = 1 / (index + 1);
167
+ const existing = merged.get(result.chunkId);
168
+ if (existing) {
169
+ existing.scores.vectorRank = index + 1;
170
+ existing.scores.vectorDistance = result.scores.vectorDistance;
171
+ existing.scores.final += vectorScore;
172
+ existing.score = existing.scores.final;
173
+ existing.match = {
174
+ ...existing.match,
175
+ sources: mergeSources(existing.match.sources, ["semantic"]),
176
+ score: existing.scores.final,
177
+ semanticRank: index + 1,
178
+ vectorDistance: result.scores.vectorDistance,
179
+ matchedRoles: mergeRoles(existing.match.matchedRoles, [result.role])
180
+ };
181
+ }
182
+ else {
183
+ merged.set(result.chunkId, {
184
+ ...result,
185
+ matchMode: "hybrid",
186
+ score: vectorScore,
187
+ match: {
188
+ ...result.match,
189
+ mode: "hybrid",
190
+ sources: ["semantic"],
191
+ score: vectorScore,
192
+ rank: index + 1,
193
+ semanticRank: index + 1
194
+ },
195
+ scores: {
196
+ final: vectorScore,
197
+ vectorRank: index + 1,
198
+ vectorDistance: result.scores.vectorDistance
199
+ }
200
+ });
201
+ }
202
+ }
203
+ return Array.from(merged.values())
204
+ .sort((left, right) => right.scores.final - left.scores.final)
205
+ .slice(0, limit)
206
+ .map((result) => (options.includeText ? result : withoutText(result)));
207
+ }
208
+ function hydrateVectorResults(db, vectorRows, mode, includeText = false) {
209
+ if (vectorRows.length === 0) {
210
+ return [];
211
+ }
212
+ const byChunkId = new Map(vectorRows.map((row, index) => [row.chunkId, { ...row, rank: index + 1 }]));
213
+ const placeholders = vectorRows.map(() => "?").join(", ");
214
+ const rows = db.db
215
+ .prepare(`SELECT
216
+ c.id AS chunkId,
217
+ c.message_id AS messageId,
218
+ c.session_id AS sessionId,
219
+ c.conversation_id AS conversationId,
220
+ c.turn_id AS turnId,
221
+ c.source AS source,
222
+ c.source_path AS sourcePath,
223
+ c.ordinal AS ordinal,
224
+ c.chunk_index AS chunkIndex,
225
+ c.role AS role,
226
+ c.kind AS kind,
227
+ c.timestamp AS timestamp,
228
+ s.title AS title,
229
+ s.cwd AS cwd,
230
+ s.started_at AS sessionStartedAt,
231
+ s.updated_at AS sessionUpdatedAt,
232
+ s.source_path AS sessionSourcePath,
233
+ c.line_start AS lineStart,
234
+ c.line_end AS lineEnd,
235
+ c.text AS text
236
+ FROM chunks c
237
+ LEFT JOIN sessions s ON s.id = c.session_id
238
+ WHERE c.id IN (${placeholders})`)
239
+ .all(...vectorRows.map((row) => row.chunkId));
240
+ return rows
241
+ .map((row) => {
242
+ const vector = byChunkId.get(row.chunkId);
243
+ if (!vector) {
244
+ throw new Error(`Missing vector row for chunk ${row.chunkId}.`);
245
+ }
246
+ const vectorRankScore = 1 / vector.rank;
247
+ const locator = locatorFromSearchRow(row);
248
+ const result = {
249
+ ...row,
250
+ score: vectorRankScore,
251
+ snippet: snippetFromText(row.text),
252
+ preview: previewFromText(row.text),
253
+ locator,
254
+ links: linksForLocator(locator),
255
+ matchMode: mode,
256
+ match: {
257
+ mode,
258
+ sources: ["semantic"],
259
+ score: vectorRankScore,
260
+ rank: vector.rank,
261
+ semanticRank: vector.rank,
262
+ vectorDistance: vector.distance,
263
+ matchedTerms: [],
264
+ matchedRoles: [row.role]
265
+ },
266
+ scores: {
267
+ final: vectorRankScore,
268
+ vectorRank: vector.rank,
269
+ vectorDistance: vector.distance
270
+ }
271
+ };
272
+ return includeText ? result : withoutText(result);
273
+ })
274
+ .sort((left, right) => (left.scores.vectorRank ?? 0) - (right.scores.vectorRank ?? 0));
275
+ }
276
+ export function readSession(dbPath, sessionId, limit, offset) {
277
+ const db = new SessionSearchDb(dbPath);
278
+ try {
279
+ return db.listSessionMessages(sessionId, limit, offset);
280
+ }
281
+ finally {
282
+ db.close();
283
+ }
284
+ }
285
+ export function listRecentSessions(dbPath, limit, offset) {
286
+ const db = new SessionSearchDb(dbPath);
287
+ try {
288
+ return db.listRecentSessions(limit, offset).map((session) => ({
289
+ id: session.id,
290
+ conversationId: session.conversationId,
291
+ title: isBootstrapText(session.title ?? "") ? null : session.title,
292
+ displayTitle: displayTitleForSession(session.title, session.firstUserText),
293
+ cwd: session.cwd,
294
+ sourcePath: session.sourcePath,
295
+ startedAt: session.startedAt,
296
+ updatedAt: session.updatedAt,
297
+ messageCount: session.messageCount,
298
+ turnCount: session.turnCount,
299
+ links: linksForConversationId(session.conversationId, session.sourcePath)
300
+ }));
301
+ }
302
+ finally {
303
+ db.close();
304
+ }
305
+ }
306
+ export function readMessageContext(dbPath, messageId, before, after) {
307
+ const db = new SessionSearchDb(dbPath);
308
+ try {
309
+ return db.listMessagesAround(messageId, before, after);
310
+ }
311
+ finally {
312
+ db.close();
313
+ }
314
+ }
315
+ export function resolveMessageLocator(dbPath, messageId) {
316
+ const db = new SessionSearchDb(dbPath);
317
+ try {
318
+ const message = db.getMessage(messageId);
319
+ return message ? resolvedLocatorFromMessage(message) : null;
320
+ }
321
+ finally {
322
+ db.close();
323
+ }
324
+ }
325
+ export function readTurn(dbPath, turnId, limit) {
326
+ const db = new SessionSearchDb(dbPath);
327
+ try {
328
+ return db.listTurnMessages(turnId, limit);
329
+ }
330
+ finally {
331
+ db.close();
332
+ }
333
+ }
334
+ export function indexStats(dbPath) {
335
+ if (!fs.existsSync(dbPath)) {
336
+ return emptyIndexStats();
337
+ }
338
+ const db = new SessionSearchDb(dbPath);
339
+ try {
340
+ return db.stats();
341
+ }
342
+ finally {
343
+ db.close();
344
+ }
345
+ }
346
+ function emptyIndexStats() {
347
+ return {
348
+ sourceFiles: 0,
349
+ sessions: 0,
350
+ turns: 0,
351
+ messages: 0,
352
+ chunks: 0,
353
+ vectors: 0,
354
+ embeddingProviders: 0,
355
+ schemaVersion: 0,
356
+ lastIndexedAt: null
357
+ };
358
+ }
359
+ const QUERY_STOPWORDS = new Set([
360
+ "a",
361
+ "about",
362
+ "again",
363
+ "an",
364
+ "and",
365
+ "any",
366
+ "can",
367
+ "could",
368
+ "find",
369
+ "for",
370
+ "from",
371
+ "had",
372
+ "i",
373
+ "in",
374
+ "last",
375
+ "look",
376
+ "looking",
377
+ "me",
378
+ "of",
379
+ "on",
380
+ "or",
381
+ "session",
382
+ "sessions",
383
+ "some",
384
+ "that",
385
+ "the",
386
+ "this",
387
+ "to",
388
+ "talk",
389
+ "talked",
390
+ "talking",
391
+ "we",
392
+ "week",
393
+ "where",
394
+ "with",
395
+ "you"
396
+ ]);
397
+ function contentQueryTerms(query) {
398
+ const tokens = uniqueTokens(Array.from(query.matchAll(/[\p{L}\p{N}_]+/gu), (match) => match[0].toLowerCase()));
399
+ if (tokens.length === 0) {
400
+ throw new Error("Search query must contain at least one word or number.");
401
+ }
402
+ const contentTerms = tokens.filter((token) => !QUERY_STOPWORDS.has(token));
403
+ return contentTerms.length > 0 ? contentTerms : tokens;
404
+ }
405
+ function toFtsQuery(tokens) {
406
+ return tokens.map((token) => ftsTerm(token)).join(" AND ");
407
+ }
408
+ function ftsTerm(token) {
409
+ const variants = new Set([token]);
410
+ if (token.endsWith("s") && token.length > 3) {
411
+ variants.add(token.slice(0, -1));
412
+ }
413
+ if (variants.size === 1) {
414
+ return `${token}*`;
415
+ }
416
+ return `(${Array.from(variants, (variant) => `${variant}*`).join(" OR ")})`;
417
+ }
418
+ function uniqueTokens(tokens) {
419
+ return Array.from(new Set(tokens));
420
+ }
421
+ function withoutText(row) {
422
+ const { text: _text, ...rest } = row;
423
+ return rest;
424
+ }
425
+ function snippetFromText(text) {
426
+ return text.replace(/\s+/g, " ").trim().slice(0, 240);
427
+ }
428
+ function previewFromText(text) {
429
+ return snippetFromText(text);
430
+ }
431
+ function locatorFromSearchRow(row) {
432
+ return {
433
+ source: row.source,
434
+ sourcePath: row.sourcePath,
435
+ lineStart: row.lineStart,
436
+ lineEnd: row.lineEnd,
437
+ sessionId: row.sessionId,
438
+ conversationId: row.conversationId,
439
+ turnId: row.turnId,
440
+ messageId: row.messageId,
441
+ role: row.role,
442
+ timestamp: row.timestamp
443
+ };
444
+ }
445
+ function locatorFromMessage(message) {
446
+ return {
447
+ source: message.source,
448
+ sourcePath: message.sourcePath,
449
+ lineStart: message.lineStart,
450
+ lineEnd: message.lineEnd,
451
+ sessionId: message.sessionId,
452
+ conversationId: message.conversationId,
453
+ turnId: message.turnId,
454
+ messageId: message.id,
455
+ role: message.role,
456
+ timestamp: message.timestamp
457
+ };
458
+ }
459
+ function linksForLocator(locator) {
460
+ return linksForConversationId(locator.source === "codex" ? locator.conversationId : null, `${locator.sourcePath}:${locator.lineStart}`);
461
+ }
462
+ export function linksForConversationId(conversationId, source) {
463
+ const codex = conversationId ? codexThreadLink(conversationId) : null;
464
+ return {
465
+ codex,
466
+ openCommand: codex ? openCommandForUri(codex) : null,
467
+ source
468
+ };
469
+ }
470
+ export function codexThreadLink(conversationId) {
471
+ return `codex://threads/${encodeURIComponent(conversationId)}`;
472
+ }
473
+ function shellQuote(value) {
474
+ return `'${value.replaceAll("'", "'\\''")}'`;
475
+ }
476
+ export function openCommandForUri(uri) {
477
+ if (process.platform === "win32") {
478
+ return `start "" ${windowsQuote(uri)}`;
479
+ }
480
+ if (process.platform === "darwin") {
481
+ return `open ${shellQuote(uri)}`;
482
+ }
483
+ return `xdg-open ${shellQuote(uri)}`;
484
+ }
485
+ function windowsQuote(value) {
486
+ return `"${value.replaceAll('"', '\\"')}"`;
487
+ }
488
+ function resolvedLocatorFromSearchResult(result) {
489
+ return {
490
+ messageId: result.messageId,
491
+ sessionId: result.sessionId,
492
+ conversationId: result.conversationId,
493
+ turnId: result.turnId,
494
+ role: result.role,
495
+ timestamp: result.timestamp,
496
+ preview: result.preview,
497
+ locator: result.locator,
498
+ links: result.links
499
+ };
500
+ }
501
+ function resolvedLocatorFromMessage(message) {
502
+ const locator = locatorFromMessage(message);
503
+ return {
504
+ messageId: message.id,
505
+ sessionId: message.sessionId,
506
+ conversationId: message.conversationId,
507
+ turnId: message.turnId,
508
+ role: message.role,
509
+ timestamp: message.timestamp,
510
+ preview: previewFromText(message.text),
511
+ locator,
512
+ links: linksForLocator(locator)
513
+ };
514
+ }
515
+ function displayTitleForResult(result) {
516
+ if (!isBootstrapText(result.preview)) {
517
+ return result.preview;
518
+ }
519
+ if (result.title && !isBootstrapText(result.title)) {
520
+ return result.title;
521
+ }
522
+ return "(untitled session)";
523
+ }
524
+ function displayTitleForSession(title, firstUserText) {
525
+ if (title && !isBootstrapText(title)) {
526
+ return previewFromText(title);
527
+ }
528
+ if (firstUserText && !isBootstrapText(firstUserText)) {
529
+ return previewFromText(firstUserText);
530
+ }
531
+ return "(untitled session)";
532
+ }
533
+ function hasDuplicatePreview(results, candidate) {
534
+ const normalized = normalizePreview(candidate.preview);
535
+ return results.some((result) => normalizePreview(result.preview) === normalized);
536
+ }
537
+ function normalizePreview(value) {
538
+ return value.replace(/\s+/g, " ").trim().toLowerCase();
539
+ }
540
+ function isBetterHit(candidate, current) {
541
+ const scoreDelta = candidate.scores.final - current.scores.final;
542
+ if (Math.abs(scoreDelta) > Number.EPSILON) {
543
+ return scoreDelta > 0;
544
+ }
545
+ if (isBootstrapText(current.preview) !== isBootstrapText(candidate.preview)) {
546
+ return !isBootstrapText(candidate.preview);
547
+ }
548
+ if (current.role !== "user" && candidate.role === "user") {
549
+ return true;
550
+ }
551
+ return candidate.timestamp !== null && current.timestamp !== null && candidate.timestamp < current.timestamp;
552
+ }
553
+ function isBootstrapText(text) {
554
+ const normalized = text.trim();
555
+ return normalized.startsWith("# AGENTS.md instructions") || normalized.startsWith("<INSTRUCTIONS>");
556
+ }
557
+ function mergeSources(existing, next) {
558
+ return Array.from(new Set([...existing, ...next]));
559
+ }
560
+ function mergeRoles(existing, next) {
561
+ return Array.from(new Set([...existing, ...next]));
562
+ }