pi-sessions 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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/extensions/session-ask.ts +297 -0
  4. package/extensions/session-auto-title/command.ts +109 -0
  5. package/extensions/session-auto-title/context.ts +41 -0
  6. package/extensions/session-auto-title/controller.ts +298 -0
  7. package/extensions/session-auto-title/model.ts +61 -0
  8. package/extensions/session-auto-title/prompt.ts +54 -0
  9. package/extensions/session-auto-title/retitle.ts +641 -0
  10. package/extensions/session-auto-title/state.ts +69 -0
  11. package/extensions/session-auto-title/wizard.ts +583 -0
  12. package/extensions/session-auto-title.ts +209 -0
  13. package/extensions/session-handoff/extract.ts +278 -0
  14. package/extensions/session-handoff/metadata.ts +96 -0
  15. package/extensions/session-handoff/picker.ts +394 -0
  16. package/extensions/session-handoff/query.ts +318 -0
  17. package/extensions/session-handoff/refs.ts +151 -0
  18. package/extensions/session-handoff/review.ts +268 -0
  19. package/extensions/session-handoff.ts +203 -0
  20. package/extensions/session-hooks.ts +55 -0
  21. package/extensions/session-index.ts +159 -0
  22. package/extensions/session-search/extract.ts +997 -0
  23. package/extensions/session-search/hooks.ts +350 -0
  24. package/extensions/session-search/normalize.ts +170 -0
  25. package/extensions/session-search/reindex.ts +93 -0
  26. package/extensions/session-search.ts +390 -0
  27. package/extensions/shared/search-snippet.ts +40 -0
  28. package/extensions/shared/session-index/common.ts +222 -0
  29. package/extensions/shared/session-index/index.ts +5 -0
  30. package/extensions/shared/session-index/lineage.ts +417 -0
  31. package/extensions/shared/session-index/schema.ts +178 -0
  32. package/extensions/shared/session-index/search.ts +688 -0
  33. package/extensions/shared/session-index/store.ts +173 -0
  34. package/extensions/shared/session-ui.ts +15 -0
  35. package/extensions/shared/settings.ts +141 -0
  36. package/extensions/shared/time.ts +38 -0
  37. package/extensions/shared/typebox.ts +61 -0
  38. package/images/handoff.png +0 -0
  39. package/images/session-title.png +0 -0
  40. package/images/session_ask.png +0 -0
  41. package/images/session_picker.png +0 -0
  42. package/images/session_search.png +0 -0
  43. package/package.json +64 -0
@@ -0,0 +1,688 @@
1
+ import path from "node:path";
2
+ import { type Static, Type } from "@sinclair/typebox";
3
+ import {
4
+ type FileTouchOp,
5
+ matchesRepoRoot,
6
+ normalizeSearchPath,
7
+ } from "../../session-search/normalize.js";
8
+ import {
9
+ SEARCH_SNIPPET_ELLIPSIS,
10
+ SEARCH_SNIPPET_MATCH_END,
11
+ SEARCH_SNIPPET_MATCH_START,
12
+ } from "../search-snippet.js";
13
+ import { parseTypeBoxRows } from "../typebox.js";
14
+ import {
15
+ boostIndependentHits,
16
+ buildFtsQuery,
17
+ compactSearchValue,
18
+ compactSessionId,
19
+ escapeLikePrefix,
20
+ NULLABLE_STRING_SCHEMA,
21
+ normalizeTimeFilter,
22
+ parseRepoRoots,
23
+ SESSION_ORIGIN_SCHEMA,
24
+ type SearchSessionResult,
25
+ type SearchSessionsParams,
26
+ type SessionIndexDatabase,
27
+ sanitizeFilterValues,
28
+ tokenizeSearchTerms,
29
+ } from "./common.js";
30
+
31
+ const SESSION_LIST_ROW_SCHEMA = Type.Object({
32
+ sessionId: Type.String(),
33
+ sessionName: Type.String(),
34
+ sessionPath: Type.String(),
35
+ cwd: Type.String(),
36
+ repoRootsJson: Type.String(),
37
+ startedAt: Type.String(),
38
+ modifiedAt: Type.String(),
39
+ messageCount: Type.Number(),
40
+ parentSessionPath: NULLABLE_STRING_SCHEMA,
41
+ parentSessionId: NULLABLE_STRING_SCHEMA,
42
+ firstUserPrompt: NULLABLE_STRING_SCHEMA,
43
+ sessionOrigin: Type.Union([SESSION_ORIGIN_SCHEMA, Type.Null()]),
44
+ handoffGoal: NULLABLE_STRING_SCHEMA,
45
+ handoffNextTask: NULLABLE_STRING_SCHEMA,
46
+ });
47
+
48
+ type SessionListRow = Static<typeof SESSION_LIST_ROW_SCHEMA>;
49
+
50
+ const SEARCH_CHUNK_ROW_SCHEMA = Type.Object({
51
+ sessionId: Type.String(),
52
+ sessionName: Type.String(),
53
+ sessionPath: Type.String(),
54
+ cwd: Type.String(),
55
+ repoRootsJson: Type.String(),
56
+ startedAt: Type.String(),
57
+ modifiedAt: Type.String(),
58
+ messageCount: Type.Number(),
59
+ parentSessionPath: NULLABLE_STRING_SCHEMA,
60
+ parentSessionId: NULLABLE_STRING_SCHEMA,
61
+ firstUserPrompt: NULLABLE_STRING_SCHEMA,
62
+ sessionOrigin: Type.Union([SESSION_ORIGIN_SCHEMA, Type.Null()]),
63
+ handoffGoal: NULLABLE_STRING_SCHEMA,
64
+ handoffNextTask: NULLABLE_STRING_SCHEMA,
65
+ snippet: Type.String(),
66
+ rank: Type.Number(),
67
+ entryId: NULLABLE_STRING_SCHEMA,
68
+ sourceKind: Type.String(),
69
+ });
70
+
71
+ type SearchChunkRow = Static<typeof SEARCH_CHUNK_ROW_SCHEMA>;
72
+
73
+ const FILE_TOUCH_MATCH_ROW_SCHEMA = Type.Object({
74
+ sessionId: Type.String(),
75
+ rawPath: Type.String(),
76
+ absPath: NULLABLE_STRING_SCHEMA,
77
+ cwdRelPath: NULLABLE_STRING_SCHEMA,
78
+ repoRelPath: NULLABLE_STRING_SCHEMA,
79
+ basename: Type.String(),
80
+ op: Type.Union([Type.Literal("read"), Type.Literal("changed")]),
81
+ });
82
+
83
+ type FileTouchMatchRow = Static<typeof FILE_TOUCH_MATCH_ROW_SCHEMA>;
84
+
85
+ interface SearchFilters {
86
+ after: string | undefined;
87
+ before: string | undefined;
88
+ cwd: string | undefined;
89
+ cwdLike: string | undefined;
90
+ repo: string | undefined;
91
+ touched: string[];
92
+ limit: number | undefined;
93
+ query: string | undefined;
94
+ excludeSessionIds: Set<string>;
95
+ }
96
+
97
+ interface FileMatchSummary {
98
+ matchedFiles: string[];
99
+ }
100
+
101
+ interface FileMatchAccumulator {
102
+ matchedFiles: Set<string>;
103
+ evidenceKeys: Set<string>;
104
+ }
105
+
106
+ interface FilePathMatch {
107
+ displayPath: string;
108
+ score: number;
109
+ }
110
+
111
+ interface SearchResultAccumulator {
112
+ result: SearchSessionResult;
113
+ evidenceKeys: Set<string>;
114
+ snippetScore: number;
115
+ }
116
+
117
+ const SESSION_ID_EXACT_SCORE = 1_500;
118
+ const SESSION_ID_PREFIX_SCORE = 1_250;
119
+ const SESSION_ID_SUBSTRING_SCORE = 1_000;
120
+ const RECENCY_BASE_SCORE = 220;
121
+ const SESSION_NAME_SCORE = 80;
122
+ const HANDOFF_NEXT_TASK_SCORE = 60;
123
+ const HANDOFF_GOAL_SCORE = 50;
124
+ const DEFAULT_TEXT_SCORE = 20;
125
+
126
+ export function searchSessions(
127
+ db: SessionIndexDatabase,
128
+ params: SearchSessionsParams,
129
+ _options?: { defaultLimit?: number | undefined },
130
+ ): SearchSessionResult[] {
131
+ const filters = buildSearchFilters(params);
132
+ const fileMatches = hasFileFilters(filters)
133
+ ? collectFileMatches(getCandidateFileTouches(db, filters), filters)
134
+ : new Map<string, FileMatchSummary>();
135
+ const candidates = applySessionFilters(
136
+ getFilteredSessionCandidates(db, filters),
137
+ filters,
138
+ fileMatches,
139
+ );
140
+
141
+ if (candidates.length === 0) {
142
+ return [];
143
+ }
144
+
145
+ return filters.query
146
+ ? limitSearchResults(searchFilteredSessions(db, candidates, fileMatches, filters), filters)
147
+ : limitSearchResults(browseFilteredSessions(candidates, fileMatches), filters);
148
+ }
149
+
150
+ function buildSearchFilters(params: SearchSessionsParams): SearchFilters {
151
+ const cwd = params.cwd?.trim();
152
+
153
+ return {
154
+ after: normalizeTimeFilter(params.after),
155
+ before: normalizeTimeFilter(params.before),
156
+ cwd,
157
+ cwdLike: cwd ? `${escapeLikePrefix(cwd)}%` : undefined,
158
+ repo: params.repo?.trim(),
159
+ touched: sanitizeFilterValues(params.touched),
160
+ limit: params.limit,
161
+ query: params.query?.trim(),
162
+ excludeSessionIds: new Set(sanitizeFilterValues(params.excludeSessionIds)),
163
+ };
164
+ }
165
+
166
+ function getFilteredSessionCandidates(
167
+ db: SessionIndexDatabase,
168
+ filters: SearchFilters,
169
+ ): SessionListRow[] {
170
+ return parseTypeBoxRows(
171
+ SESSION_LIST_ROW_SCHEMA,
172
+ db
173
+ .prepare(
174
+ `
175
+ SELECT
176
+ session_id as sessionId,
177
+ session_name as sessionName,
178
+ session_path as sessionPath,
179
+ cwd,
180
+ repo_roots_json as repoRootsJson,
181
+ created_ts as startedAt,
182
+ modified_ts as modifiedAt,
183
+ message_count as messageCount,
184
+ parent_session_path as parentSessionPath,
185
+ parent_session_id as parentSessionId,
186
+ first_user_prompt as firstUserPrompt,
187
+ session_origin as sessionOrigin,
188
+ handoff_goal as handoffGoal,
189
+ handoff_next_task as handoffNextTask
190
+ FROM sessions
191
+ WHERE (? IS NULL OR modified_ts >= ?)
192
+ AND (? IS NULL OR created_ts <= ?)
193
+ AND (? IS NULL OR cwd = ? OR cwd LIKE ? ESCAPE '\\')
194
+ ORDER BY modified_ts DESC
195
+ `,
196
+ )
197
+ .all(...getSearchFilterBindings(filters)),
198
+ "Invalid recent session rows",
199
+ );
200
+ }
201
+
202
+ function applySessionFilters(
203
+ rows: SessionListRow[],
204
+ filters: SearchFilters,
205
+ fileMatches: Map<string, FileMatchSummary>,
206
+ ): SessionListRow[] {
207
+ return rows.filter((row) => {
208
+ if (filters.excludeSessionIds.has(row.sessionId)) {
209
+ return false;
210
+ }
211
+
212
+ const repoQuery = filters.repo;
213
+ if (
214
+ repoQuery &&
215
+ !parseRepoRoots(row.repoRootsJson).some((repoRoot) => matchesRepoRoot(repoRoot, repoQuery))
216
+ ) {
217
+ return false;
218
+ }
219
+
220
+ if (hasFileFilters(filters) && !fileMatches.has(row.sessionId)) {
221
+ return false;
222
+ }
223
+
224
+ return true;
225
+ });
226
+ }
227
+
228
+ function browseFilteredSessions(
229
+ rows: SessionListRow[],
230
+ fileMatches: Map<string, FileMatchSummary>,
231
+ ): SearchSessionResult[] {
232
+ return rows.map((row) => buildSearchResult(row, "", 0, 0, fileMatches));
233
+ }
234
+
235
+ function searchFilteredSessions(
236
+ db: SessionIndexDatabase,
237
+ candidates: SessionListRow[],
238
+ fileMatches: Map<string, FileMatchSummary>,
239
+ filters: SearchFilters,
240
+ ): SearchSessionResult[] {
241
+ const candidateById = new Map(
242
+ candidates.map((candidate, index) => [candidate.sessionId, { candidate, index }]),
243
+ );
244
+ const accumulators = new Map<string, SearchResultAccumulator>();
245
+ const queryTokens = tokenizeSessionIdQuery(filters.query ?? "");
246
+
247
+ for (const [sessionId, candidate] of candidateById.entries()) {
248
+ const sessionIdEvidence = getSessionIdEvidence(queryTokens, sessionId);
249
+ if (sessionIdEvidence === undefined) {
250
+ continue;
251
+ }
252
+
253
+ const accumulator = ensureSearchAccumulator(
254
+ accumulators,
255
+ candidate.candidate,
256
+ candidate.index,
257
+ fileMatches,
258
+ );
259
+ addSearchEvidence(accumulator, "session_id", sessionIdEvidence, undefined, 0);
260
+ }
261
+
262
+ for (const row of getTextMatchRows(db, filters)) {
263
+ if (row.sourceKind === "session_id") {
264
+ continue;
265
+ }
266
+
267
+ const candidate = candidateById.get(row.sessionId);
268
+ if (!candidate) {
269
+ continue;
270
+ }
271
+
272
+ const sourceWeight = getSearchSourceWeight(row.sourceKind);
273
+ const accumulator = ensureSearchAccumulator(
274
+ accumulators,
275
+ candidate.candidate,
276
+ candidate.index,
277
+ fileMatches,
278
+ );
279
+ addSearchEvidence(
280
+ accumulator,
281
+ getSearchEvidenceKey(row),
282
+ sourceWeight,
283
+ selectSearchSnippet(row),
284
+ sourceWeight,
285
+ );
286
+ }
287
+
288
+ return [...accumulators.values()]
289
+ .map(({ result }) => ({
290
+ ...result,
291
+ score: result.score + boostIndependentHits(result.hitCount),
292
+ }))
293
+ .sort((a, b) => {
294
+ if (b.score !== a.score) {
295
+ return b.score - a.score;
296
+ }
297
+
298
+ return b.modifiedAt.localeCompare(a.modifiedAt);
299
+ });
300
+ }
301
+
302
+ function getTextMatchRows(db: SessionIndexDatabase, filters: SearchFilters): SearchChunkRow[] {
303
+ const match = buildFtsQuery(filters.query ?? "");
304
+ if (!match) {
305
+ return [];
306
+ }
307
+
308
+ return parseTypeBoxRows(
309
+ SEARCH_CHUNK_ROW_SCHEMA,
310
+ db
311
+ .prepare(
312
+ `
313
+ SELECT
314
+ s.session_id as sessionId,
315
+ s.session_name as sessionName,
316
+ s.session_path as sessionPath,
317
+ s.cwd as cwd,
318
+ s.repo_roots_json as repoRootsJson,
319
+ s.created_ts as startedAt,
320
+ s.modified_ts as modifiedAt,
321
+ s.message_count as messageCount,
322
+ s.parent_session_path as parentSessionPath,
323
+ s.parent_session_id as parentSessionId,
324
+ s.first_user_prompt as firstUserPrompt,
325
+ s.session_origin as sessionOrigin,
326
+ s.handoff_goal as handoffGoal,
327
+ s.handoff_next_task as handoffNextTask,
328
+ snippet(session_text_chunks_fts, 2, ?, ?, ?, 12) as snippet,
329
+ bm25(session_text_chunks_fts) as rank,
330
+ c.entry_id as entryId,
331
+ c.source_kind as sourceKind
332
+ FROM session_text_chunks_fts
333
+ JOIN session_text_chunks c ON c.id = CAST(session_text_chunks_fts.chunk_id AS INTEGER)
334
+ JOIN sessions s ON s.session_id = c.session_id
335
+ WHERE session_text_chunks_fts MATCH ?
336
+ AND (? IS NULL OR s.modified_ts >= ?)
337
+ AND (? IS NULL OR s.created_ts <= ?)
338
+ AND (? IS NULL OR s.cwd = ? OR s.cwd LIKE ? ESCAPE '\\')
339
+ ORDER BY rank ASC, s.modified_ts DESC
340
+ `,
341
+ )
342
+ .all(
343
+ SEARCH_SNIPPET_MATCH_START,
344
+ SEARCH_SNIPPET_MATCH_END,
345
+ SEARCH_SNIPPET_ELLIPSIS,
346
+ match,
347
+ ...getSearchFilterBindings(filters),
348
+ ),
349
+ "Invalid text search rows",
350
+ );
351
+ }
352
+
353
+ function getCandidateFileTouches(
354
+ db: SessionIndexDatabase,
355
+ filters: SearchFilters,
356
+ ): FileTouchMatchRow[] {
357
+ return parseTypeBoxRows(
358
+ FILE_TOUCH_MATCH_ROW_SCHEMA,
359
+ db
360
+ .prepare(
361
+ `
362
+ SELECT
363
+ f.session_id as sessionId,
364
+ f.raw_path as rawPath,
365
+ f.abs_path as absPath,
366
+ f.cwd_rel_path as cwdRelPath,
367
+ f.repo_rel_path as repoRelPath,
368
+ f.basename as basename,
369
+ f.op as op
370
+ FROM session_file_touches f
371
+ JOIN sessions s ON s.session_id = f.session_id
372
+ WHERE (? IS NULL OR s.modified_ts >= ?)
373
+ AND (? IS NULL OR s.created_ts <= ?)
374
+ AND (? IS NULL OR s.cwd = ? OR s.cwd LIKE ? ESCAPE '\\')
375
+ `,
376
+ )
377
+ .all(...getSearchFilterBindings(filters)),
378
+ "Invalid file touch match rows",
379
+ );
380
+ }
381
+
382
+ function collectFileMatches(
383
+ rows: FileTouchMatchRow[],
384
+ filters: SearchFilters,
385
+ ): Map<string, FileMatchSummary> {
386
+ const accumulators = new Map<string, FileMatchAccumulator>();
387
+
388
+ for (const query of filters.touched) {
389
+ for (const row of rows) {
390
+ if (!matchesTouchedFileOp(row.op)) {
391
+ continue;
392
+ }
393
+
394
+ const fileMatch = matchFileTouch(row, query);
395
+ if (!fileMatch) {
396
+ continue;
397
+ }
398
+
399
+ const accumulator = getFileMatchAccumulator(accumulators, row.sessionId);
400
+ const evidenceKey = `${normalizeSearchPath(query)}:${fileMatch.displayPath}`;
401
+ if (accumulator.evidenceKeys.has(evidenceKey)) {
402
+ continue;
403
+ }
404
+
405
+ accumulator.evidenceKeys.add(evidenceKey);
406
+ accumulator.matchedFiles.add(fileMatch.displayPath);
407
+ }
408
+ }
409
+
410
+ return new Map(
411
+ [...accumulators.entries()].map(([sessionId, accumulator]) => [
412
+ sessionId,
413
+ {
414
+ matchedFiles: [...accumulator.matchedFiles].sort(),
415
+ },
416
+ ]),
417
+ );
418
+ }
419
+
420
+ function limitSearchResults(
421
+ results: SearchSessionResult[],
422
+ filters: SearchFilters,
423
+ ): SearchSessionResult[] {
424
+ return typeof filters.limit === "number" ? results.slice(0, filters.limit) : results;
425
+ }
426
+
427
+ function ensureSearchAccumulator(
428
+ accumulators: Map<string, SearchResultAccumulator>,
429
+ row: SessionListRow,
430
+ recencyIndex: number,
431
+ fileMatches: Map<string, FileMatchSummary>,
432
+ ): SearchResultAccumulator {
433
+ const existing = accumulators.get(row.sessionId);
434
+ if (existing) {
435
+ return existing;
436
+ }
437
+
438
+ const nextAccumulator: SearchResultAccumulator = {
439
+ result: buildSearchResult(
440
+ row,
441
+ getDefaultSearchSnippet(row),
442
+ getRecencyScore(recencyIndex),
443
+ 0,
444
+ fileMatches,
445
+ ),
446
+ evidenceKeys: new Set<string>(),
447
+ snippetScore: 0,
448
+ };
449
+ accumulators.set(row.sessionId, nextAccumulator);
450
+ return nextAccumulator;
451
+ }
452
+
453
+ function addSearchEvidence(
454
+ accumulator: SearchResultAccumulator,
455
+ evidenceKey: string,
456
+ score: number,
457
+ snippet: string | undefined,
458
+ snippetScore: number,
459
+ ): void {
460
+ if (accumulator.evidenceKeys.has(evidenceKey)) {
461
+ return;
462
+ }
463
+
464
+ accumulator.evidenceKeys.add(evidenceKey);
465
+ accumulator.result.score += score;
466
+ accumulator.result.hitCount += 1;
467
+
468
+ if (snippet && snippetScore >= accumulator.snippetScore) {
469
+ accumulator.result.snippet = snippet;
470
+ accumulator.snippetScore = snippetScore;
471
+ }
472
+ }
473
+
474
+ function getSearchEvidenceKey(row: SearchChunkRow): string {
475
+ return row.entryId ? `${row.entryId}:${row.sourceKind}` : `${row.sourceKind}:${row.snippet}`;
476
+ }
477
+
478
+ function buildSearchResult(
479
+ row: SessionListRow | SearchChunkRow,
480
+ snippet: string,
481
+ score: number,
482
+ hitCount: number,
483
+ fileMatches: Map<string, FileMatchSummary> = new Map(),
484
+ ): SearchSessionResult {
485
+ return {
486
+ sessionId: row.sessionId,
487
+ sessionName: row.sessionName,
488
+ sessionPath: row.sessionPath,
489
+ cwd: row.cwd,
490
+ repoRoots: parseRepoRoots(row.repoRootsJson),
491
+ startedAt: row.startedAt,
492
+ modifiedAt: row.modifiedAt,
493
+ messageCount: row.messageCount,
494
+ parentSessionPath: row.parentSessionPath ?? undefined,
495
+ parentSessionId: row.parentSessionId ?? undefined,
496
+ firstUserPrompt: row.firstUserPrompt ?? undefined,
497
+ sessionOrigin: row.sessionOrigin ?? undefined,
498
+ handoffGoal: row.handoffGoal ?? undefined,
499
+ handoffNextTask: row.handoffNextTask ?? undefined,
500
+ snippet,
501
+ matchedFiles: fileMatches.get(row.sessionId)?.matchedFiles ?? [],
502
+ score,
503
+ hitCount,
504
+ };
505
+ }
506
+
507
+ function getDefaultSearchSnippet(row: SessionListRow | SearchChunkRow): string {
508
+ return row.handoffNextTask || row.handoffGoal || row.sessionName || row.cwd;
509
+ }
510
+
511
+ function selectSearchSnippet(row: SearchChunkRow): string | undefined {
512
+ return row.sourceKind === "session_id" ? undefined : row.snippet;
513
+ }
514
+
515
+ function getSearchSourceWeight(sourceKind: string): number {
516
+ switch (sourceKind) {
517
+ case "session_name":
518
+ return SESSION_NAME_SCORE;
519
+ case "handoff_next_task":
520
+ return HANDOFF_NEXT_TASK_SCORE;
521
+ case "handoff_goal":
522
+ return HANDOFF_GOAL_SCORE;
523
+ default:
524
+ return DEFAULT_TEXT_SCORE;
525
+ }
526
+ }
527
+
528
+ function tokenizeSessionIdQuery(query: string): string[] {
529
+ const trimmed = query.trim();
530
+ if (!trimmed) {
531
+ return [];
532
+ }
533
+
534
+ return [...new Set<string>([trimmed, ...tokenizeSearchTerms(trimmed)])];
535
+ }
536
+
537
+ function getSessionIdEvidence(tokens: string[], sessionId: string): number | undefined {
538
+ if (tokens.length === 0) {
539
+ return undefined;
540
+ }
541
+
542
+ const canonicalSessionId = sessionId.toLowerCase();
543
+ const compactSessionIdValue = compactSessionId(sessionId);
544
+ let bestScore: number | undefined;
545
+
546
+ for (const token of tokens) {
547
+ const score = getSessionIdEvidenceForToken(token, canonicalSessionId, compactSessionIdValue);
548
+ if (score !== undefined && (bestScore === undefined || score > bestScore)) {
549
+ bestScore = score;
550
+ }
551
+ }
552
+
553
+ return bestScore;
554
+ }
555
+
556
+ function getSessionIdEvidenceForToken(
557
+ token: string,
558
+ canonicalSessionId: string,
559
+ compactSessionIdValue: string,
560
+ ): number | undefined {
561
+ const canonicalToken = token.trim().toLowerCase();
562
+ const compactToken = compactSearchValue(token);
563
+
564
+ if (canonicalToken.length >= 8) {
565
+ if (canonicalToken === canonicalSessionId || compactToken === compactSessionIdValue) {
566
+ return SESSION_ID_EXACT_SCORE;
567
+ }
568
+
569
+ if (
570
+ canonicalSessionId.startsWith(canonicalToken) ||
571
+ (compactToken.length >= 8 && compactSessionIdValue.startsWith(compactToken))
572
+ ) {
573
+ return SESSION_ID_PREFIX_SCORE + canonicalToken.length;
574
+ }
575
+ }
576
+
577
+ if (
578
+ compactToken.length >= 8 &&
579
+ (canonicalSessionId.includes(canonicalToken) || compactSessionIdValue.includes(compactToken))
580
+ ) {
581
+ return SESSION_ID_SUBSTRING_SCORE + compactToken.length;
582
+ }
583
+
584
+ return undefined;
585
+ }
586
+
587
+ function getRecencyScore(recencyIndex: number): number {
588
+ return Math.max(1, Math.round(RECENCY_BASE_SCORE / (recencyIndex + 1)));
589
+ }
590
+
591
+ function getSearchFilterBindings(
592
+ filters: SearchFilters,
593
+ ): [
594
+ string | null,
595
+ string | null,
596
+ string | null,
597
+ string | null,
598
+ string | null,
599
+ string | null,
600
+ string | null,
601
+ ] {
602
+ return [
603
+ filters.after ?? null,
604
+ filters.after ?? null,
605
+ filters.before ?? null,
606
+ filters.before ?? null,
607
+ filters.cwd ?? null,
608
+ filters.cwd ?? null,
609
+ filters.cwdLike ?? null,
610
+ ];
611
+ }
612
+
613
+ function getFileMatchAccumulator(
614
+ accumulators: Map<string, FileMatchAccumulator>,
615
+ sessionId: string,
616
+ ): FileMatchAccumulator {
617
+ const existing = accumulators.get(sessionId);
618
+ if (existing) {
619
+ return existing;
620
+ }
621
+
622
+ const nextAccumulator: FileMatchAccumulator = {
623
+ matchedFiles: new Set<string>(),
624
+ evidenceKeys: new Set<string>(),
625
+ };
626
+ accumulators.set(sessionId, nextAccumulator);
627
+ return nextAccumulator;
628
+ }
629
+
630
+ function matchesTouchedFileOp(actualOp: FileTouchOp): boolean {
631
+ return actualOp === "read" || actualOp === "changed";
632
+ }
633
+
634
+ function matchFileTouch(row: FileTouchMatchRow, rawQuery: string): FilePathMatch | undefined {
635
+ const query = normalizeSearchPath(rawQuery);
636
+ if (!query) {
637
+ return undefined;
638
+ }
639
+
640
+ if (path.isAbsolute(query)) {
641
+ return matchAbsolutePath(row, query);
642
+ }
643
+
644
+ if (query.includes("/")) {
645
+ return matchRelativePath(row, query);
646
+ }
647
+
648
+ return row.basename === query
649
+ ? {
650
+ displayPath: row.repoRelPath ?? row.cwdRelPath ?? row.absPath ?? row.rawPath,
651
+ score: 1,
652
+ }
653
+ : undefined;
654
+ }
655
+
656
+ function matchAbsolutePath(row: FileTouchMatchRow, query: string): FilePathMatch | undefined {
657
+ if (!row.absPath) {
658
+ return undefined;
659
+ }
660
+
661
+ if (row.absPath === query) {
662
+ return { displayPath: row.absPath, score: 3 };
663
+ }
664
+
665
+ return row.absPath.endsWith(query) ? { displayPath: row.absPath, score: 2.5 } : undefined;
666
+ }
667
+
668
+ function matchRelativePath(row: FileTouchMatchRow, query: string): FilePathMatch | undefined {
669
+ const candidates = [row.repoRelPath, row.cwdRelPath].filter(
670
+ (value): value is string => typeof value === "string" && value.length > 0,
671
+ );
672
+
673
+ for (const candidate of candidates) {
674
+ if (candidate === query) {
675
+ return { displayPath: candidate, score: 2.5 };
676
+ }
677
+
678
+ if (candidate.endsWith(`/${query}`)) {
679
+ return { displayPath: candidate, score: 2 };
680
+ }
681
+ }
682
+
683
+ return undefined;
684
+ }
685
+
686
+ function hasFileFilters(filters: SearchFilters): boolean {
687
+ return filters.touched.length > 0;
688
+ }