pi-sessions 0.6.0 → 0.7.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/README.md +22 -11
- package/extensions/session-ask/agent.ts +400 -0
- package/extensions/session-ask/navigate.ts +880 -0
- package/extensions/session-ask.ts +79 -131
- package/extensions/session-auto-title/model.ts +3 -11
- package/extensions/session-handoff/picker.ts +50 -4
- package/extensions/session-handoff/query.ts +74 -56
- package/extensions/session-handoff/refs.ts +12 -18
- package/extensions/session-handoff.ts +36 -27
- package/extensions/session-messaging/broker/process.ts +23 -26
- package/extensions/session-messaging/broker/spawn.ts +7 -2
- package/extensions/session-messaging/pi/incoming-runtime.ts +2 -18
- package/extensions/session-messaging/pi/message-contracts.ts +9 -10
- package/extensions/session-messaging/pi/message-view.ts +12 -1
- package/extensions/session-messaging/pi/service.ts +117 -62
- package/extensions/session-messaging/pi/tools.ts +4 -56
- package/extensions/session-search/extract.ts +86 -436
- package/extensions/session-search/hooks.ts +17 -25
- package/extensions/session-search/normalize.ts +1 -1
- package/extensions/session-search/reindex.ts +1 -0
- package/extensions/session-search.ts +157 -128
- package/extensions/shared/model.ts +18 -0
- package/extensions/shared/session-broker/active.ts +26 -0
- package/extensions/{session-messaging/pi → shared/session-broker}/client.ts +16 -19
- package/extensions/{session-messaging/shared → shared/session-broker}/framing.ts +1 -1
- package/extensions/{session-messaging/shared → shared/session-broker}/protocol.ts +5 -12
- package/extensions/shared/session-index/access.ts +128 -0
- package/extensions/shared/session-index/common.ts +43 -48
- package/extensions/shared/session-index/index.ts +1 -0
- package/extensions/shared/session-index/lineage.ts +10 -0
- package/extensions/shared/session-index/query/ast.ts +40 -0
- package/extensions/shared/session-index/query/compiler.ts +146 -0
- package/extensions/shared/session-index/query/lexer.ts +140 -0
- package/extensions/shared/session-index/query/parser.ts +178 -0
- package/extensions/shared/session-index/schema.ts +22 -6
- package/extensions/shared/session-index/scoring.ts +80 -0
- package/extensions/shared/session-index/search.ts +552 -278
- package/extensions/shared/session-index/sqlite.ts +16 -9
- package/extensions/shared/session-index/store.ts +12 -21
- package/extensions/shared/settings.ts +61 -4
- package/extensions/shared/text.ts +50 -0
- package/package.json +1 -1
- /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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
+
evidence: FileTouchEvidence[];
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
interface FileMatchAccumulator {
|
|
102
|
-
|
|
113
|
+
evidence: FileTouchEvidence[];
|
|
103
114
|
evidenceKeys: Set<string>;
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
interface FilePathMatch {
|
|
107
118
|
displayPath: string;
|
|
108
|
-
|
|
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
|
-
|
|
132
|
+
textEvidenceCount: number;
|
|
133
|
+
textScoreContributionCount: number;
|
|
134
|
+
bestSnippetScore: number;
|
|
115
135
|
}
|
|
116
136
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
155
|
-
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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(...
|
|
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
|
|
242
|
-
|
|
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
|
|
289
|
+
if (!sessionIdEvidence) {
|
|
250
290
|
continue;
|
|
251
291
|
}
|
|
252
292
|
|
|
253
293
|
const accumulator = ensureSearchAccumulator(
|
|
254
294
|
accumulators,
|
|
255
|
-
candidate
|
|
256
|
-
candidate.index,
|
|
295
|
+
candidate,
|
|
257
296
|
fileMatches,
|
|
297
|
+
nowMs,
|
|
298
|
+
relationBySessionId,
|
|
258
299
|
);
|
|
259
|
-
|
|
300
|
+
addSessionIdEvidence(accumulator, sessionIdEvidence.kind, sessionIdEvidence.score);
|
|
260
301
|
}
|
|
261
302
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
314
|
+
return;
|
|
270
315
|
}
|
|
271
316
|
|
|
272
|
-
const
|
|
317
|
+
const score = scoreTextHit(row.sourceKind, rankIndex);
|
|
273
318
|
const accumulator = ensureSearchAccumulator(
|
|
274
319
|
accumulators,
|
|
275
|
-
candidate
|
|
276
|
-
candidate.index,
|
|
320
|
+
candidate,
|
|
277
321
|
fileMatches,
|
|
322
|
+
nowMs,
|
|
323
|
+
relationBySessionId,
|
|
278
324
|
);
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
331
|
+
hitCount: result.evidence.length,
|
|
292
332
|
}))
|
|
293
|
-
.sort(
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
ORDER BY
|
|
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
|
-
...
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
AND
|
|
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(...
|
|
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
|
-
|
|
490
|
+
fileQueries: FileFilterQuery[],
|
|
385
491
|
): Map<string, FileMatchSummary> {
|
|
386
492
|
const accumulators = new Map<string, FileMatchAccumulator>();
|
|
387
493
|
|
|
388
|
-
for (const query of
|
|
494
|
+
for (const query of fileQueries) {
|
|
389
495
|
for (const row of rows) {
|
|
390
|
-
if (
|
|
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 = `${
|
|
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.
|
|
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
|
-
|
|
530
|
+
evidence: accumulator.evidence.sort(compareFileEvidence),
|
|
415
531
|
},
|
|
416
532
|
]),
|
|
417
533
|
);
|
|
418
534
|
}
|
|
419
535
|
|
|
420
|
-
function
|
|
536
|
+
function limitBrowseResults(
|
|
421
537
|
results: SearchSessionResult[],
|
|
422
538
|
filters: SearchFilters,
|
|
423
539
|
): SearchSessionResult[] {
|
|
424
|
-
return
|
|
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
|
-
|
|
442
|
-
getRecencyScore(recencyIndex),
|
|
443
|
-
0,
|
|
573
|
+
scoreRecency(row.modifiedAt, nowMs),
|
|
444
574
|
fileMatches,
|
|
575
|
+
relationBySessionId,
|
|
445
576
|
),
|
|
446
577
|
evidenceKeys: new Set<string>(),
|
|
447
|
-
|
|
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
|
|
586
|
+
function addSessionIdEvidence(
|
|
454
587
|
accumulator: SearchResultAccumulator,
|
|
455
|
-
|
|
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.
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
475
|
-
return
|
|
635
|
+
function getTextEvidenceKey(row: SearchChunkRow): string {
|
|
636
|
+
return `${row.entryId}:${row.sourceKind}`;
|
|
476
637
|
}
|
|
477
638
|
|
|
478
639
|
function buildSearchResult(
|
|
479
|
-
row: SessionListRow
|
|
480
|
-
snippet: string,
|
|
640
|
+
row: SessionListRow,
|
|
481
641
|
score: number,
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
|
508
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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 [...
|
|
685
|
+
return [...tokens];
|
|
535
686
|
}
|
|
536
687
|
|
|
537
|
-
function getSessionIdEvidence(tokens: string[], sessionId: string):
|
|
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
|
|
695
|
+
let bestEvidence: SessionIdEvidence | undefined;
|
|
545
696
|
|
|
546
697
|
for (const token of tokens) {
|
|
547
|
-
const
|
|
548
|
-
if (
|
|
549
|
-
|
|
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
|
|
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
|
-
):
|
|
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
|
|
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
|
|
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
|
|
737
|
+
return { kind: "substring", score: scoreSessionIdMatch("substring", compactToken.length) };
|
|
582
738
|
}
|
|
583
739
|
|
|
584
740
|
return undefined;
|
|
585
741
|
}
|
|
586
742
|
|
|
587
|
-
function
|
|
588
|
-
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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.
|
|
604
|
-
filters.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
918
|
+
if (row.absPath === query || row.absPath.endsWith(query)) {
|
|
919
|
+
return { displayPath: row.absPath };
|
|
663
920
|
}
|
|
664
921
|
|
|
665
|
-
return
|
|
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
|
|
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
|
}
|