pi-sessions 0.6.0 → 0.7.1

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/README.md +22 -11
  2. package/extensions/session-ask/agent.ts +400 -0
  3. package/extensions/session-ask/navigate.ts +880 -0
  4. package/extensions/session-ask.ts +79 -131
  5. package/extensions/session-auto-title/model.ts +3 -11
  6. package/extensions/session-handoff/picker.ts +50 -4
  7. package/extensions/session-handoff/query.ts +74 -56
  8. package/extensions/session-handoff/refs.ts +12 -18
  9. package/extensions/session-handoff.ts +36 -27
  10. package/extensions/session-messaging/broker/process.ts +23 -26
  11. package/extensions/session-messaging/broker/spawn.ts +24 -3
  12. package/extensions/session-messaging/pi/incoming-runtime.ts +2 -18
  13. package/extensions/session-messaging/pi/message-contracts.ts +9 -10
  14. package/extensions/session-messaging/pi/message-view.ts +12 -1
  15. package/extensions/session-messaging/pi/service.ts +117 -62
  16. package/extensions/session-messaging/pi/tools.ts +4 -56
  17. package/extensions/session-search/extract.ts +86 -436
  18. package/extensions/session-search/hooks.ts +17 -25
  19. package/extensions/session-search/normalize.ts +1 -1
  20. package/extensions/session-search/reindex.ts +1 -0
  21. package/extensions/session-search.ts +157 -128
  22. package/extensions/shared/model.ts +18 -0
  23. package/extensions/shared/session-broker/active.ts +26 -0
  24. package/extensions/{session-messaging/pi → shared/session-broker}/client.ts +16 -19
  25. package/extensions/{session-messaging/shared → shared/session-broker}/framing.ts +1 -1
  26. package/extensions/{session-messaging/shared → shared/session-broker}/protocol.ts +5 -12
  27. package/extensions/shared/session-index/access.ts +128 -0
  28. package/extensions/shared/session-index/common.ts +43 -48
  29. package/extensions/shared/session-index/index.ts +1 -0
  30. package/extensions/shared/session-index/lineage.ts +10 -0
  31. package/extensions/shared/session-index/query/ast.ts +40 -0
  32. package/extensions/shared/session-index/query/compiler.ts +146 -0
  33. package/extensions/shared/session-index/query/lexer.ts +140 -0
  34. package/extensions/shared/session-index/query/parser.ts +178 -0
  35. package/extensions/shared/session-index/schema.ts +22 -6
  36. package/extensions/shared/session-index/scoring.ts +80 -0
  37. package/extensions/shared/session-index/search.ts +552 -278
  38. package/extensions/shared/session-index/sqlite.ts +16 -9
  39. package/extensions/shared/session-index/store.ts +12 -21
  40. package/extensions/shared/settings.ts +61 -4
  41. package/extensions/shared/text.ts +50 -0
  42. package/package.json +5 -4
  43. /package/extensions/{session-messaging/shared → shared/session-broker}/socket-path.ts +0 -0
@@ -1,10 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { type Static, Type } from "typebox";
3
- import {
4
- type FileTouchOp,
5
- matchesRepoRoot,
6
- normalizeSearchPath,
7
- } from "../../session-search/normalize.ts";
3
+ import { normalizeSearchPath } from "../../session-search/normalize.ts";
8
4
  import {
9
5
  SEARCH_SNIPPET_ELLIPSIS,
10
6
  SEARCH_SNIPPET_MATCH_END,
@@ -12,8 +8,6 @@ import {
12
8
  } from "../search-snippet.ts";
13
9
  import { parseTypeBoxRows } from "../typebox.ts";
14
10
  import {
15
- boostIndependentHits,
16
- buildFtsQuery,
17
11
  compactSearchValue,
18
12
  compactSessionId,
19
13
  escapeLikePrefix,
@@ -23,10 +17,25 @@ import {
23
17
  SESSION_ORIGIN_SCHEMA,
24
18
  type SearchSessionResult,
25
19
  type SearchSessionsParams,
20
+ type SearchSort,
26
21
  type SessionIndexDatabase,
22
+ type SessionLineageRelation,
27
23
  sanitizeFilterValues,
28
- tokenizeSearchTerms,
24
+ tokenizeSearchText,
29
25
  } from "./common.ts";
26
+ import { getLineageRelationMap } from "./lineage.ts";
27
+ import { type CompiledSearchQuery, compileSearchQuery } from "./query/compiler.ts";
28
+ import {
29
+ buildTextOverfetchLimit,
30
+ MAX_FILTERED_SESSION_CANDIDATES,
31
+ MAX_SCORING_TEXT_HITS_PER_SESSION,
32
+ MAX_TEXT_EVIDENCE_PER_SESSION,
33
+ normalizeResultLimit,
34
+ type SessionIdMatchKind,
35
+ scoreRecency,
36
+ scoreSessionIdMatch,
37
+ scoreTextHit,
38
+ } from "./scoring.ts";
30
39
 
31
40
  const SESSION_LIST_ROW_SCHEMA = Type.Object({
32
41
  sessionId: Type.String(),
@@ -48,30 +57,19 @@ const SESSION_LIST_ROW_SCHEMA = Type.Object({
48
57
  type SessionListRow = Static<typeof SESSION_LIST_ROW_SCHEMA>;
49
58
 
50
59
  const SEARCH_CHUNK_ROW_SCHEMA = Type.Object({
60
+ chunkId: Type.Number(),
51
61
  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
62
  snippet: Type.String(),
66
- rank: Type.Number(),
67
- entryId: NULLABLE_STRING_SCHEMA,
63
+ entryId: Type.String(),
68
64
  sourceKind: Type.String(),
65
+ ts: Type.String(),
69
66
  });
70
67
 
71
- type SearchChunkRow = Static<typeof SEARCH_CHUNK_ROW_SCHEMA>;
68
+ export type SearchChunkRow = Static<typeof SEARCH_CHUNK_ROW_SCHEMA>;
72
69
 
73
70
  const FILE_TOUCH_MATCH_ROW_SCHEMA = Type.Object({
74
71
  sessionId: Type.String(),
72
+ entryId: NULLABLE_STRING_SCHEMA,
75
73
  rawPath: Type.String(),
76
74
  absPath: NULLABLE_STRING_SCHEMA,
77
75
  cwdRelPath: NULLABLE_STRING_SCHEMA,
@@ -82,278 +80,385 @@ const FILE_TOUCH_MATCH_ROW_SCHEMA = Type.Object({
82
80
 
83
81
  type FileTouchMatchRow = Static<typeof FILE_TOUCH_MATCH_ROW_SCHEMA>;
84
82
 
85
- interface SearchFilters {
83
+ interface SearchWhereFilters {
86
84
  after: string | undefined;
87
85
  before: string | undefined;
88
86
  cwd: string | undefined;
89
87
  cwdLike: string | undefined;
90
88
  repo: string | undefined;
91
89
  touched: string[];
92
- limit: number | undefined;
90
+ changed: string[];
91
+ excludeSessionIds: string[];
92
+ includeSessionIds: string[] | undefined;
93
+ }
94
+
95
+ interface SearchFilters extends SearchWhereFilters {
96
+ limit: number;
97
+ sort: SearchSort | undefined;
93
98
  query: string | undefined;
94
- excludeSessionIds: Set<string>;
99
+ }
100
+
101
+ interface FileFilterQuery {
102
+ query: string;
103
+ normalized: string;
104
+ basename: string;
105
+ op: "touched" | "changed";
95
106
  }
96
107
 
97
108
  interface FileMatchSummary {
98
- matchedFiles: string[];
109
+ evidence: FileTouchEvidence[];
99
110
  }
100
111
 
101
112
  interface FileMatchAccumulator {
102
- matchedFiles: Set<string>;
113
+ evidence: FileTouchEvidence[];
103
114
  evidenceKeys: Set<string>;
104
115
  }
105
116
 
106
117
  interface FilePathMatch {
107
118
  displayPath: string;
108
- score: number;
119
+ }
120
+
121
+ interface FileTouchEvidence {
122
+ kind: "file_touch";
123
+ op: "read" | "changed";
124
+ query: string;
125
+ path: string;
126
+ entryId?: string | undefined;
109
127
  }
110
128
 
111
129
  interface SearchResultAccumulator {
112
130
  result: SearchSessionResult;
113
131
  evidenceKeys: Set<string>;
114
- snippetScore: number;
132
+ textEvidenceCount: number;
133
+ textScoreContributionCount: number;
134
+ bestSnippetScore: number;
115
135
  }
116
136
 
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;
137
+ interface SqlClause {
138
+ sql: string;
139
+ args: Array<string | number | null>;
140
+ }
125
141
 
126
142
  export function searchSessions(
127
143
  db: SessionIndexDatabase,
128
144
  params: SearchSessionsParams,
129
- _options?: { defaultLimit?: number | undefined },
130
145
  ): SearchSessionResult[] {
131
146
  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
- );
147
+ const relationBySessionId = buildRelationMap(db, params.relativeToSessionId);
148
+ const compiledQuery = filters.query ? compileSearchQuery(filters.query) : undefined;
149
+ const hasPositiveQuery = compiledQuery !== undefined;
150
+ const fileQueries = buildFileFilterQueries(filters);
151
+ const fileMatches =
152
+ fileQueries.length > 0
153
+ ? collectFileMatches(getCandidateFileTouches(db, filters, fileQueries), fileQueries)
154
+ : new Map<string, FileMatchSummary>();
155
+
156
+ if (fileQueries.length > 0 && fileMatches.size === 0) {
157
+ return [];
158
+ }
159
+
160
+ const candidates = getFilteredSessionCandidates(db, filters, {
161
+ candidateLimit:
162
+ hasPositiveQuery || fileQueries.length > 0 ? MAX_FILTERED_SESSION_CANDIDATES : filters.limit,
163
+ sort: getBrowseSort(filters),
164
+ }).filter((row) => fileQueries.length === 0 || fileMatches.has(row.sessionId));
140
165
 
141
166
  if (candidates.length === 0) {
142
167
  return [];
143
168
  }
144
169
 
145
- return filters.query
146
- ? limitSearchResults(searchFilteredSessions(db, candidates, fileMatches, filters), filters)
147
- : limitSearchResults(browseFilteredSessions(candidates, fileMatches), filters);
170
+ if (!compiledQuery) {
171
+ return limitBrowseResults(
172
+ candidates.map((row) => buildSearchResult(row, 0, fileMatches, relationBySessionId)),
173
+ filters,
174
+ );
175
+ }
176
+
177
+ const ranked = searchFilteredSessions(
178
+ db,
179
+ candidates,
180
+ fileMatches,
181
+ filters,
182
+ compiledQuery,
183
+ relationBySessionId,
184
+ );
185
+ const selected = ranked.slice(0, filters.limit);
186
+ return applyDisplaySort(selected, filters, true);
148
187
  }
149
188
 
150
189
  function buildSearchFilters(params: SearchSessionsParams): SearchFilters {
151
190
  const cwd = params.cwd?.trim();
191
+ const after = normalizeTimeFilter(params.after);
192
+ const before = normalizeTimeFilter(params.before);
193
+ if (after && before && after > before) {
194
+ throw new Error("time.after must be less than or equal to time.before");
195
+ }
152
196
 
153
197
  return {
154
- after: normalizeTimeFilter(params.after),
155
- before: normalizeTimeFilter(params.before),
198
+ after,
199
+ before,
156
200
  cwd,
157
201
  cwdLike: cwd ? `${escapeLikePrefix(cwd)}%` : undefined,
158
202
  repo: params.repo?.trim(),
159
203
  touched: sanitizeFilterValues(params.touched),
160
- limit: params.limit,
161
- query: params.query?.trim(),
162
- excludeSessionIds: new Set(sanitizeFilterValues(params.excludeSessionIds)),
204
+ changed: sanitizeFilterValues(params.changed),
205
+ limit: normalizeResultLimit(params.limit),
206
+ sort: normalizeSort(params.sort),
207
+ query: params.query?.trim() || undefined,
208
+ excludeSessionIds: sanitizeFilterValues(params.excludeSessionIds),
209
+ includeSessionIds:
210
+ params.includeSessionIds === undefined
211
+ ? undefined
212
+ : sanitizeFilterValues(params.includeSessionIds),
163
213
  };
164
214
  }
165
215
 
216
+ function buildRelationMap(
217
+ db: SessionIndexDatabase,
218
+ sessionId: string | undefined,
219
+ ): Map<string, SessionLineageRelation> {
220
+ const normalizedSessionId = sessionId?.trim();
221
+ return normalizedSessionId ? getLineageRelationMap(db, normalizedSessionId) : new Map();
222
+ }
223
+
224
+ function normalizeSort(value: SearchSort | undefined): SearchSort | undefined {
225
+ switch (value) {
226
+ case "relevance":
227
+ case "modified_asc":
228
+ case "modified_desc":
229
+ return value;
230
+ default:
231
+ return undefined;
232
+ }
233
+ }
234
+
166
235
  function getFilteredSessionCandidates(
167
236
  db: SessionIndexDatabase,
168
237
  filters: SearchFilters,
238
+ options: { candidateLimit: number; sort: "modified_desc" | "modified_asc" },
169
239
  ): SessionListRow[] {
240
+ const where = buildSessionWhereClause("s", filters);
241
+ const orderDirection = options.sort === "modified_asc" ? "ASC" : "DESC";
242
+
170
243
  return parseTypeBoxRows(
171
244
  SESSION_LIST_ROW_SCHEMA,
172
245
  db
173
246
  .prepare(
174
247
  `
175
248
  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
249
+ s.session_id as sessionId,
250
+ s.session_name as sessionName,
251
+ s.session_path as sessionPath,
252
+ s.cwd,
253
+ s.repo_roots_json as repoRootsJson,
254
+ s.created_ts as startedAt,
255
+ s.modified_ts as modifiedAt,
256
+ s.message_count as messageCount,
257
+ s.parent_session_path as parentSessionPath,
258
+ s.parent_session_id as parentSessionId,
259
+ s.first_user_prompt as firstUserPrompt,
260
+ s.session_origin as sessionOrigin,
261
+ s.handoff_goal as handoffGoal,
262
+ s.handoff_next_task as handoffNextTask
263
+ FROM sessions s
264
+ ${where.sql}
265
+ ORDER BY s.modified_ts ${orderDirection}
266
+ LIMIT ?
195
267
  `,
196
268
  )
197
- .all(...getSearchFilterBindings(filters)),
269
+ .all(...where.args, options.candidateLimit),
198
270
  "Invalid recent session rows",
199
271
  );
200
272
  }
201
273
 
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
274
  function searchFilteredSessions(
236
275
  db: SessionIndexDatabase,
237
276
  candidates: SessionListRow[],
238
277
  fileMatches: Map<string, FileMatchSummary>,
239
278
  filters: SearchFilters,
279
+ compiledQuery: CompiledSearchQuery,
280
+ relationBySessionId: Map<string, SessionLineageRelation>,
240
281
  ): SearchSessionResult[] {
241
- const candidateById = new Map(
242
- candidates.map((candidate, index) => [candidate.sessionId, { candidate, index }]),
243
- );
282
+ const nowMs = Date.now();
283
+ const candidateById = new Map(candidates.map((candidate) => [candidate.sessionId, candidate]));
244
284
  const accumulators = new Map<string, SearchResultAccumulator>();
245
- const queryTokens = tokenizeSessionIdQuery(filters.query ?? "");
285
+ const queryTokens = tokenizeSessionIdQuery(filters.query ?? "", compiledQuery.positiveTerms);
246
286
 
247
287
  for (const [sessionId, candidate] of candidateById.entries()) {
248
288
  const sessionIdEvidence = getSessionIdEvidence(queryTokens, sessionId);
249
- if (sessionIdEvidence === undefined) {
289
+ if (!sessionIdEvidence) {
250
290
  continue;
251
291
  }
252
292
 
253
293
  const accumulator = ensureSearchAccumulator(
254
294
  accumulators,
255
- candidate.candidate,
256
- candidate.index,
295
+ candidate,
257
296
  fileMatches,
297
+ nowMs,
298
+ relationBySessionId,
258
299
  );
259
- addSearchEvidence(accumulator, "session_id", sessionIdEvidence, undefined, 0);
300
+ addSessionIdEvidence(accumulator, sessionIdEvidence.kind, sessionIdEvidence.score);
260
301
  }
261
302
 
262
- for (const row of getTextMatchRows(db, filters)) {
263
- if (row.sourceKind === "session_id") {
264
- continue;
265
- }
303
+ const textRows = getTextMatchRows(
304
+ db,
305
+ filters,
306
+ compiledQuery,
307
+ buildTextOverfetchLimit(filters.limit),
308
+ hasFileFilters(filters) ? new Set(fileMatches.keys()) : undefined,
309
+ );
266
310
 
311
+ textRows.forEach((row, rankIndex) => {
267
312
  const candidate = candidateById.get(row.sessionId);
268
313
  if (!candidate) {
269
- continue;
314
+ return;
270
315
  }
271
316
 
272
- const sourceWeight = getSearchSourceWeight(row.sourceKind);
317
+ const score = scoreTextHit(row.sourceKind, rankIndex);
273
318
  const accumulator = ensureSearchAccumulator(
274
319
  accumulators,
275
- candidate.candidate,
276
- candidate.index,
320
+ candidate,
277
321
  fileMatches,
322
+ nowMs,
323
+ relationBySessionId,
278
324
  );
279
- addSearchEvidence(
280
- accumulator,
281
- getSearchEvidenceKey(row),
282
- sourceWeight,
283
- selectSearchSnippet(row),
284
- sourceWeight,
285
- );
286
- }
325
+ addTextEvidence(accumulator, row, score);
326
+ });
287
327
 
288
328
  return [...accumulators.values()]
289
329
  .map(({ result }) => ({
290
330
  ...result,
291
- score: result.score + boostIndependentHits(result.hitCount),
331
+ hitCount: result.evidence.length,
292
332
  }))
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
- });
333
+ .sort(compareByRelevance);
300
334
  }
301
335
 
302
- function getTextMatchRows(db: SessionIndexDatabase, filters: SearchFilters): SearchChunkRow[] {
303
- const match = buildFtsQuery(filters.query ?? "");
304
- if (!match) {
336
+ export function searchSessionChunks(
337
+ db: SessionIndexDatabase,
338
+ params: { sessionIds?: string[] | undefined; query: string; limit?: number | undefined },
339
+ ): SearchChunkRow[] {
340
+ const query = params.query.trim();
341
+ if (!query) {
342
+ return [];
343
+ }
344
+
345
+ const compiledQuery = compileSearchQuery(query);
346
+ if (!compiledQuery) {
305
347
  return [];
306
348
  }
307
349
 
350
+ return getTextMatchRows(
351
+ db,
352
+ createSessionIdWhereFilters(params.sessionIds),
353
+ compiledQuery,
354
+ normalizeResultLimit(params.limit),
355
+ );
356
+ }
357
+
358
+ function getTextMatchRows(
359
+ db: SessionIndexDatabase,
360
+ filters: SearchWhereFilters,
361
+ compiledQuery: CompiledSearchQuery,
362
+ limit: number,
363
+ allowedSessionIds?: Set<string> | undefined,
364
+ ): SearchChunkRow[] {
365
+ const strictRows = queryTextMatchRows(
366
+ db,
367
+ filters,
368
+ compiledQuery.match,
369
+ compiledQuery.excludes,
370
+ limit,
371
+ allowedSessionIds,
372
+ );
373
+ if (strictRows.length >= limit || !compiledQuery.relaxedMatch) {
374
+ return strictRows;
375
+ }
376
+
377
+ const seenChunkIds = new Set(strictRows.map((row) => row.chunkId));
378
+ const relaxedRows = queryTextMatchRows(
379
+ db,
380
+ filters,
381
+ compiledQuery.relaxedMatch,
382
+ compiledQuery.excludes,
383
+ limit,
384
+ allowedSessionIds,
385
+ ).filter((row) => !seenChunkIds.has(row.chunkId));
386
+
387
+ return [...strictRows, ...relaxedRows].slice(0, limit);
388
+ }
389
+
390
+ function queryTextMatchRows(
391
+ db: SessionIndexDatabase,
392
+ filters: SearchWhereFilters,
393
+ match: string,
394
+ excludes: string[],
395
+ limit: number,
396
+ allowedSessionIds?: Set<string> | undefined,
397
+ ): SearchChunkRow[] {
398
+ const where = buildSessionWhereClause("s", filters);
399
+ const allowedSessionClause = buildAllowedSessionClause("s", allowedSessionIds);
400
+ const excludeClause = buildExcludeClause(excludes);
401
+
308
402
  return parseTypeBoxRows(
309
403
  SEARCH_CHUNK_ROW_SCHEMA,
310
404
  db
311
405
  .prepare(
312
406
  `
313
407
  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, 0, ?, ?, ?, 12) as snippet,
329
- bm25(session_text_chunks_fts) as rank,
408
+ c.id as chunkId,
409
+ c.session_id as sessionId,
410
+ snippet(session_text_chunks_fts, 0, ?, ?, ?, 16) as snippet,
330
411
  c.entry_id as entryId,
331
- c.source_kind as sourceKind
412
+ c.source_kind as sourceKind,
413
+ c.ts as ts
332
414
  FROM session_text_chunks_fts
333
415
  JOIN session_text_chunks c ON c.id = session_text_chunks_fts.rowid
334
416
  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
417
+ ${where.sql}
418
+ ${where.sql ? "AND" : "WHERE"} session_text_chunks_fts MATCH ?
419
+ ${allowedSessionClause.sql}
420
+ ${excludeClause.sql}
421
+ ORDER BY bm25(session_text_chunks_fts) ASC, s.modified_ts DESC
422
+ LIMIT ?
340
423
  `,
341
424
  )
342
425
  .all(
343
426
  SEARCH_SNIPPET_MATCH_START,
344
427
  SEARCH_SNIPPET_MATCH_END,
345
428
  SEARCH_SNIPPET_ELLIPSIS,
429
+ ...where.args,
346
430
  match,
347
- ...getSearchFilterBindings(filters),
431
+ ...allowedSessionClause.args,
432
+ ...excludeClause.args,
433
+ limit,
348
434
  ),
349
435
  "Invalid text search rows",
350
436
  );
351
437
  }
352
438
 
439
+ function createSessionIdWhereFilters(sessionIds: string[] | undefined): SearchWhereFilters {
440
+ return {
441
+ after: undefined,
442
+ before: undefined,
443
+ cwd: undefined,
444
+ cwdLike: undefined,
445
+ repo: undefined,
446
+ touched: [],
447
+ changed: [],
448
+ excludeSessionIds: [],
449
+ includeSessionIds: sessionIds === undefined ? undefined : sanitizeFilterValues(sessionIds),
450
+ };
451
+ }
452
+
353
453
  function getCandidateFileTouches(
354
454
  db: SessionIndexDatabase,
355
455
  filters: SearchFilters,
456
+ fileQueries: FileFilterQuery[],
356
457
  ): FileTouchMatchRow[] {
458
+ const where = buildSessionWhereClause("s", filters);
459
+ const basenames = [...new Set(fileQueries.map((query) => query.basename))];
460
+ const basenamePlaceholders = basenames.map(() => "?").join(", ");
461
+
357
462
  return parseTypeBoxRows(
358
463
  FILE_TOUCH_MATCH_ROW_SCHEMA,
359
464
  db
@@ -361,6 +466,7 @@ function getCandidateFileTouches(
361
466
  `
362
467
  SELECT
363
468
  f.session_id as sessionId,
469
+ f.entry_id as entryId,
364
470
  f.raw_path as rawPath,
365
471
  f.abs_path as absPath,
366
472
  f.cwd_rel_path as cwdRelPath,
@@ -369,41 +475,51 @@ function getCandidateFileTouches(
369
475
  f.op as op
370
476
  FROM session_file_touches f
371
477
  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 '\\')
478
+ ${where.sql}
479
+ ${where.sql ? "AND" : "WHERE"} f.basename IN (${basenamePlaceholders})
480
+ AND f.op IN ('read', 'changed')
375
481
  `,
376
482
  )
377
- .all(...getSearchFilterBindings(filters)),
483
+ .all(...where.args, ...basenames),
378
484
  "Invalid file touch match rows",
379
485
  );
380
486
  }
381
487
 
382
488
  function collectFileMatches(
383
489
  rows: FileTouchMatchRow[],
384
- filters: SearchFilters,
490
+ fileQueries: FileFilterQuery[],
385
491
  ): Map<string, FileMatchSummary> {
386
492
  const accumulators = new Map<string, FileMatchAccumulator>();
387
493
 
388
- for (const query of filters.touched) {
494
+ for (const query of fileQueries) {
389
495
  for (const row of rows) {
390
- if (!matchesTouchedFileOp(row.op)) {
496
+ if (query.basename !== row.basename) {
497
+ continue;
498
+ }
499
+
500
+ if (query.op === "changed" && row.op !== "changed") {
391
501
  continue;
392
502
  }
393
503
 
394
- const fileMatch = matchFileTouch(row, query);
504
+ const fileMatch = matchFileTouch(row, query.normalized);
395
505
  if (!fileMatch) {
396
506
  continue;
397
507
  }
398
508
 
399
509
  const accumulator = getFileMatchAccumulator(accumulators, row.sessionId);
400
- const evidenceKey = `${normalizeSearchPath(query)}:${fileMatch.displayPath}`;
510
+ const evidenceKey = `${query.normalized}:${fileMatch.displayPath}:${row.op}`;
401
511
  if (accumulator.evidenceKeys.has(evidenceKey)) {
402
512
  continue;
403
513
  }
404
514
 
405
515
  accumulator.evidenceKeys.add(evidenceKey);
406
- accumulator.matchedFiles.add(fileMatch.displayPath);
516
+ accumulator.evidence.push({
517
+ kind: "file_touch",
518
+ op: row.op,
519
+ query: query.query,
520
+ path: fileMatch.displayPath,
521
+ entryId: row.entryId ?? undefined,
522
+ });
407
523
  }
408
524
  }
409
525
 
@@ -411,24 +527,40 @@ function collectFileMatches(
411
527
  [...accumulators.entries()].map(([sessionId, accumulator]) => [
412
528
  sessionId,
413
529
  {
414
- matchedFiles: [...accumulator.matchedFiles].sort(),
530
+ evidence: accumulator.evidence.sort(compareFileEvidence),
415
531
  },
416
532
  ]),
417
533
  );
418
534
  }
419
535
 
420
- function limitSearchResults(
536
+ function limitBrowseResults(
421
537
  results: SearchSessionResult[],
422
538
  filters: SearchFilters,
423
539
  ): SearchSessionResult[] {
424
- return typeof filters.limit === "number" ? results.slice(0, filters.limit) : results;
540
+ return applyDisplaySort(results, filters, false).slice(0, filters.limit);
541
+ }
542
+
543
+ function applyDisplaySort(
544
+ results: SearchSessionResult[],
545
+ filters: SearchFilters,
546
+ hasPositiveQuery: boolean,
547
+ ): SearchSessionResult[] {
548
+ const sort = filters.sort ?? (hasPositiveQuery ? "relevance" : "modified_desc");
549
+ if (sort === "modified_asc") {
550
+ return [...results].sort((a, b) => a.modifiedAt.localeCompare(b.modifiedAt));
551
+ }
552
+ if (sort === "modified_desc" || (!hasPositiveQuery && sort === "relevance")) {
553
+ return [...results].sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
554
+ }
555
+ return results;
425
556
  }
426
557
 
427
558
  function ensureSearchAccumulator(
428
559
  accumulators: Map<string, SearchResultAccumulator>,
429
560
  row: SessionListRow,
430
- recencyIndex: number,
431
561
  fileMatches: Map<string, FileMatchSummary>,
562
+ nowMs: number,
563
+ relationBySessionId: Map<string, SessionLineageRelation>,
432
564
  ): SearchResultAccumulator {
433
565
  const existing = accumulators.get(row.sessionId);
434
566
  if (existing) {
@@ -438,50 +570,79 @@ function ensureSearchAccumulator(
438
570
  const nextAccumulator: SearchResultAccumulator = {
439
571
  result: buildSearchResult(
440
572
  row,
441
- getDefaultSearchSnippet(row),
442
- getRecencyScore(recencyIndex),
443
- 0,
573
+ scoreRecency(row.modifiedAt, nowMs),
444
574
  fileMatches,
575
+ relationBySessionId,
445
576
  ),
446
577
  evidenceKeys: new Set<string>(),
447
- snippetScore: 0,
578
+ textEvidenceCount: 0,
579
+ textScoreContributionCount: 0,
580
+ bestSnippetScore: Number.NEGATIVE_INFINITY,
448
581
  };
449
582
  accumulators.set(row.sessionId, nextAccumulator);
450
583
  return nextAccumulator;
451
584
  }
452
585
 
453
- function addSearchEvidence(
586
+ function addSessionIdEvidence(
454
587
  accumulator: SearchResultAccumulator,
455
- evidenceKey: string,
588
+ match: SessionIdMatchKind,
456
589
  score: number,
457
- snippet: string | undefined,
458
- snippetScore: number,
459
590
  ): void {
591
+ const evidenceKey = `session_id:${match}`;
460
592
  if (accumulator.evidenceKeys.has(evidenceKey)) {
461
593
  return;
462
594
  }
463
595
 
464
596
  accumulator.evidenceKeys.add(evidenceKey);
465
597
  accumulator.result.score += score;
466
- accumulator.result.hitCount += 1;
598
+ accumulator.result.evidence.push({ kind: "session_id", match, score });
599
+ }
600
+
601
+ function addTextEvidence(
602
+ accumulator: SearchResultAccumulator,
603
+ row: SearchChunkRow,
604
+ score: number,
605
+ ): void {
606
+ const evidenceKey = getTextEvidenceKey(row);
607
+ if (accumulator.evidenceKeys.has(evidenceKey)) {
608
+ return;
609
+ }
467
610
 
468
- if (snippet && snippetScore >= accumulator.snippetScore) {
469
- accumulator.result.snippet = snippet;
470
- accumulator.snippetScore = snippetScore;
611
+ accumulator.evidenceKeys.add(evidenceKey);
612
+
613
+ if (accumulator.textScoreContributionCount < MAX_SCORING_TEXT_HITS_PER_SESSION) {
614
+ accumulator.result.score += score;
615
+ accumulator.textScoreContributionCount += 1;
616
+ }
617
+
618
+ if (accumulator.textEvidenceCount < MAX_TEXT_EVIDENCE_PER_SESSION) {
619
+ accumulator.result.evidence.push({
620
+ kind: "text",
621
+ sourceKind: row.sourceKind,
622
+ snippet: row.snippet,
623
+ score,
624
+ entryId: row.entryId,
625
+ });
626
+ accumulator.textEvidenceCount += 1;
627
+ }
628
+
629
+ if (score > accumulator.bestSnippetScore) {
630
+ accumulator.result.snippet = row.snippet;
631
+ accumulator.bestSnippetScore = score;
471
632
  }
472
633
  }
473
634
 
474
- function getSearchEvidenceKey(row: SearchChunkRow): string {
475
- return row.entryId ? `${row.entryId}:${row.sourceKind}` : `${row.sourceKind}:${row.snippet}`;
635
+ function getTextEvidenceKey(row: SearchChunkRow): string {
636
+ return `${row.entryId}:${row.sourceKind}`;
476
637
  }
477
638
 
478
639
  function buildSearchResult(
479
- row: SessionListRow | SearchChunkRow,
480
- snippet: string,
640
+ row: SessionListRow,
481
641
  score: number,
482
- hitCount: number,
483
- fileMatches: Map<string, FileMatchSummary> = new Map(),
642
+ fileMatches: Map<string, FileMatchSummary>,
643
+ relationBySessionId: Map<string, SessionLineageRelation>,
484
644
  ): SearchSessionResult {
645
+ const fileEvidence = fileMatches.get(row.sessionId)?.evidence ?? [];
485
646
  return {
486
647
  sessionId: row.sessionId,
487
648
  sessionName: row.sessionName,
@@ -497,80 +658,75 @@ function buildSearchResult(
497
658
  sessionOrigin: row.sessionOrigin ?? undefined,
498
659
  handoffGoal: row.handoffGoal ?? undefined,
499
660
  handoffNextTask: row.handoffNextTask ?? undefined,
500
- snippet,
501
- matchedFiles: fileMatches.get(row.sessionId)?.matchedFiles ?? [],
661
+ relation: relationBySessionId.get(row.sessionId),
662
+ snippet: "",
663
+ evidence: [...fileEvidence],
502
664
  score,
503
- hitCount,
665
+ hitCount: fileEvidence.length,
504
666
  };
505
667
  }
506
668
 
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;
669
+ function tokenizeSessionIdQuery(query: string, positiveTerms: string[]): string[] {
670
+ const tokens = new Set<string>();
671
+ if (query.trim()) {
672
+ tokens.add(query.trim());
525
673
  }
526
- }
527
674
 
528
- function tokenizeSessionIdQuery(query: string): string[] {
529
- const trimmed = query.trim();
530
- if (!trimmed) {
531
- return [];
675
+ for (const term of positiveTerms) {
676
+ tokens.add(term);
677
+ if (term.startsWith("@session:")) {
678
+ tokens.add(term.slice("@session:".length));
679
+ }
680
+ for (const token of tokenizeSearchText(term)) {
681
+ tokens.add(token);
682
+ }
532
683
  }
533
684
 
534
- return [...new Set<string>([trimmed, ...tokenizeSearchTerms(trimmed)])];
685
+ return [...tokens];
535
686
  }
536
687
 
537
- function getSessionIdEvidence(tokens: string[], sessionId: string): number | undefined {
688
+ function getSessionIdEvidence(tokens: string[], sessionId: string): SessionIdEvidence | undefined {
538
689
  if (tokens.length === 0) {
539
690
  return undefined;
540
691
  }
541
692
 
542
693
  const canonicalSessionId = sessionId.toLowerCase();
543
694
  const compactSessionIdValue = compactSessionId(sessionId);
544
- let bestScore: number | undefined;
695
+ let bestEvidence: SessionIdEvidence | undefined;
545
696
 
546
697
  for (const token of tokens) {
547
- const score = getSessionIdEvidenceForToken(token, canonicalSessionId, compactSessionIdValue);
548
- if (score !== undefined && (bestScore === undefined || score > bestScore)) {
549
- bestScore = score;
698
+ const evidence = getSessionIdEvidenceForToken(token, canonicalSessionId, compactSessionIdValue);
699
+ if (evidence && (!bestEvidence || evidence.score > bestEvidence.score)) {
700
+ bestEvidence = evidence;
550
701
  }
551
702
  }
552
703
 
553
- return bestScore;
704
+ return bestEvidence;
705
+ }
706
+
707
+ interface SessionIdEvidence {
708
+ kind: SessionIdMatchKind;
709
+ score: number;
554
710
  }
555
711
 
556
712
  function getSessionIdEvidenceForToken(
557
713
  token: string,
558
714
  canonicalSessionId: string,
559
715
  compactSessionIdValue: string,
560
- ): number | undefined {
716
+ ): SessionIdEvidence | undefined {
561
717
  const canonicalToken = token.trim().toLowerCase();
562
718
  const compactToken = compactSearchValue(token);
563
719
 
564
720
  if (canonicalToken.length >= 8) {
565
721
  if (canonicalToken === canonicalSessionId || compactToken === compactSessionIdValue) {
566
- return SESSION_ID_EXACT_SCORE;
722
+ return { kind: "exact", score: scoreSessionIdMatch("exact", compactToken.length) };
567
723
  }
568
724
 
569
725
  if (
570
726
  canonicalSessionId.startsWith(canonicalToken) ||
571
727
  (compactToken.length >= 8 && compactSessionIdValue.startsWith(compactToken))
572
728
  ) {
573
- return SESSION_ID_PREFIX_SCORE + canonicalToken.length;
729
+ return { kind: "prefix", score: scoreSessionIdMatch("prefix", compactToken.length) };
574
730
  }
575
731
  }
576
732
 
@@ -578,35 +734,146 @@ function getSessionIdEvidenceForToken(
578
734
  compactToken.length >= 8 &&
579
735
  (canonicalSessionId.includes(canonicalToken) || compactSessionIdValue.includes(compactToken))
580
736
  ) {
581
- return SESSION_ID_SUBSTRING_SCORE + compactToken.length;
737
+ return { kind: "substring", score: scoreSessionIdMatch("substring", compactToken.length) };
582
738
  }
583
739
 
584
740
  return undefined;
585
741
  }
586
742
 
587
- function getRecencyScore(recencyIndex: number): number {
588
- return Math.max(1, Math.round(RECENCY_BASE_SCORE / (recencyIndex + 1)));
743
+ function buildSessionWhereClause(alias: string, filters: SearchWhereFilters): SqlClause {
744
+ const conditions: string[] = [];
745
+ const args: Array<string | number | null> = [];
746
+
747
+ if (filters.after) {
748
+ conditions.push(`${alias}.modified_ts >= ?`);
749
+ args.push(filters.after);
750
+ }
751
+
752
+ if (filters.before) {
753
+ conditions.push(`${alias}.created_ts <= ?`);
754
+ args.push(filters.before);
755
+ }
756
+
757
+ if (filters.cwd) {
758
+ conditions.push(`(${alias}.cwd = ? OR ${alias}.cwd LIKE ? ESCAPE '\\')`);
759
+ args.push(filters.cwd, filters.cwdLike ?? `${escapeLikePrefix(filters.cwd)}%`);
760
+ }
761
+
762
+ if (filters.excludeSessionIds.length > 0) {
763
+ conditions.push(
764
+ `${alias}.session_id NOT IN (${filters.excludeSessionIds.map(() => "?").join(", ")})`,
765
+ );
766
+ args.push(...filters.excludeSessionIds);
767
+ }
768
+
769
+ if (filters.includeSessionIds) {
770
+ if (filters.includeSessionIds.length === 0) {
771
+ conditions.push("0 = 1");
772
+ } else {
773
+ conditions.push(
774
+ `${alias}.session_id IN (${filters.includeSessionIds.map(() => "?").join(", ")})`,
775
+ );
776
+ args.push(...filters.includeSessionIds);
777
+ }
778
+ }
779
+
780
+ const repoClause = buildRepoFilterClause(alias, filters.repo);
781
+ if (repoClause) {
782
+ conditions.push(repoClause.sql);
783
+ args.push(...repoClause.args);
784
+ }
785
+
786
+ return {
787
+ sql: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
788
+ args,
789
+ };
589
790
  }
590
791
 
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
- ] {
792
+ function buildRepoFilterClause(alias: string, rawRepo: string | undefined): SqlClause | undefined {
793
+ if (!rawRepo) {
794
+ return undefined;
795
+ }
796
+
797
+ const repo = normalizeSearchPath(rawRepo);
798
+ if (!repo) {
799
+ return undefined;
800
+ }
801
+
802
+ if (path.isAbsolute(repo) || repo.includes("/")) {
803
+ return {
804
+ sql: `EXISTS (
805
+ SELECT 1 FROM session_repo_roots r
806
+ WHERE r.session_id = ${alias}.session_id
807
+ AND (r.repo_root = ? OR r.repo_root LIKE ? ESCAPE '\\')
808
+ )`,
809
+ args: [repo, `%${escapeLikePrefix(repo)}`],
810
+ };
811
+ }
812
+
813
+ return {
814
+ sql: `EXISTS (
815
+ SELECT 1 FROM session_repo_roots r
816
+ WHERE r.session_id = ${alias}.session_id AND r.repo_basename = ?
817
+ )`,
818
+ args: [repo],
819
+ };
820
+ }
821
+
822
+ function buildAllowedSessionClause(
823
+ alias: string,
824
+ allowedSessionIds: Set<string> | undefined,
825
+ ): SqlClause {
826
+ if (!allowedSessionIds || allowedSessionIds.size === 0) {
827
+ return { sql: "", args: [] };
828
+ }
829
+
830
+ const sessionIds = [...allowedSessionIds];
831
+ return {
832
+ sql: `AND ${alias}.session_id IN (${sessionIds.map(() => "?").join(", ")})`,
833
+ args: sessionIds,
834
+ };
835
+ }
836
+
837
+ function buildExcludeClause(excludes: string[]): SqlClause {
838
+ if (excludes.length === 0) {
839
+ return { sql: "", args: [] };
840
+ }
841
+
842
+ return {
843
+ sql: excludes
844
+ .map(
845
+ () => `AND s.session_id NOT IN (
846
+ SELECT excluded_chunks.session_id
847
+ FROM session_text_chunks_fts
848
+ JOIN session_text_chunks excluded_chunks ON excluded_chunks.id = session_text_chunks_fts.rowid
849
+ WHERE session_text_chunks_fts MATCH ?
850
+ )`,
851
+ )
852
+ .join("\n"),
853
+ args: excludes,
854
+ };
855
+ }
856
+
857
+ function buildFileFilterQueries(filters: SearchFilters): FileFilterQuery[] {
602
858
  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,
859
+ ...filters.touched.flatMap((query) => buildFileFilterQuery(query, "touched")),
860
+ ...filters.changed.flatMap((query) => buildFileFilterQuery(query, "changed")),
861
+ ];
862
+ }
863
+
864
+ function buildFileFilterQuery(rawQuery: string, op: "touched" | "changed"): FileFilterQuery[] {
865
+ const normalized = normalizeSearchPath(rawQuery);
866
+ if (!normalized) {
867
+ return [];
868
+ }
869
+
870
+ return [
871
+ {
872
+ query: rawQuery,
873
+ normalized,
874
+ basename: path.basename(normalized.replace(/[/]+$/, "")) || normalized,
875
+ op,
876
+ },
610
877
  ];
611
878
  }
612
879
 
@@ -620,23 +887,14 @@ function getFileMatchAccumulator(
620
887
  }
621
888
 
622
889
  const nextAccumulator: FileMatchAccumulator = {
623
- matchedFiles: new Set<string>(),
890
+ evidence: [],
624
891
  evidenceKeys: new Set<string>(),
625
892
  };
626
893
  accumulators.set(sessionId, nextAccumulator);
627
894
  return nextAccumulator;
628
895
  }
629
896
 
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
-
897
+ function matchFileTouch(row: FileTouchMatchRow, query: string): FilePathMatch | undefined {
640
898
  if (path.isAbsolute(query)) {
641
899
  return matchAbsolutePath(row, query);
642
900
  }
@@ -648,7 +906,6 @@ function matchFileTouch(row: FileTouchMatchRow, rawQuery: string): FilePathMatch
648
906
  return row.basename === query
649
907
  ? {
650
908
  displayPath: row.repoRelPath ?? row.cwdRelPath ?? row.absPath ?? row.rawPath,
651
- score: 1,
652
909
  }
653
910
  : undefined;
654
911
  }
@@ -658,11 +915,11 @@ function matchAbsolutePath(row: FileTouchMatchRow, query: string): FilePathMatch
658
915
  return undefined;
659
916
  }
660
917
 
661
- if (row.absPath === query) {
662
- return { displayPath: row.absPath, score: 3 };
918
+ if (row.absPath === query || row.absPath.endsWith(query)) {
919
+ return { displayPath: row.absPath };
663
920
  }
664
921
 
665
- return row.absPath.endsWith(query) ? { displayPath: row.absPath, score: 2.5 } : undefined;
922
+ return undefined;
666
923
  }
667
924
 
668
925
  function matchRelativePath(row: FileTouchMatchRow, query: string): FilePathMatch | undefined {
@@ -671,18 +928,35 @@ function matchRelativePath(row: FileTouchMatchRow, query: string): FilePathMatch
671
928
  );
672
929
 
673
930
  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 };
931
+ if (candidate === query || candidate.endsWith(`/${query}`)) {
932
+ return { displayPath: candidate };
680
933
  }
681
934
  }
682
935
 
683
936
  return undefined;
684
937
  }
685
938
 
939
+ function compareByRelevance(a: SearchSessionResult, b: SearchSessionResult): number {
940
+ if (b.score !== a.score) {
941
+ return b.score - a.score;
942
+ }
943
+
944
+ return b.modifiedAt.localeCompare(a.modifiedAt);
945
+ }
946
+
947
+ function compareFileEvidence(a: FileTouchEvidence, b: FileTouchEvidence): number {
948
+ const pathDiff = a.path.localeCompare(b.path);
949
+ if (pathDiff !== 0) {
950
+ return pathDiff;
951
+ }
952
+
953
+ return a.op.localeCompare(b.op);
954
+ }
955
+
956
+ function getBrowseSort(filters: SearchFilters): "modified_desc" | "modified_asc" {
957
+ return filters.sort === "modified_asc" ? "modified_asc" : "modified_desc";
958
+ }
959
+
686
960
  function hasFileFilters(filters: SearchFilters): boolean {
687
- return filters.touched.length > 0;
961
+ return filters.touched.length > 0 || filters.changed.length > 0;
688
962
  }