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,390 @@
1
+ import path from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
3
+ import { Text } from "@mariozechner/pi-tui";
4
+ import { Type } from "@sinclair/typebox";
5
+ import {
6
+ stripSearchSnippetMarkers,
7
+ transformSearchSnippetMatches,
8
+ } from "./shared/search-snippet.js";
9
+ import {
10
+ getIndexStatus,
11
+ INDEX_SCHEMA_VERSION,
12
+ openIndexDatabase,
13
+ type SearchSessionResult,
14
+ type SearchSessionsParams,
15
+ type SessionIndexStatus,
16
+ searchSessions,
17
+ } from "./shared/session-index/index.js";
18
+ import { formatSessionTitleOrShortId } from "./shared/session-ui.js";
19
+ import { loadSettings } from "./shared/settings.js";
20
+
21
+ interface SessionSearchToolParams {
22
+ query?: string;
23
+ files?: {
24
+ touched?: string[];
25
+ };
26
+ repo?: string;
27
+ cwd?: string;
28
+ time?: {
29
+ after?: string;
30
+ before?: string;
31
+ };
32
+ limit?: number;
33
+ }
34
+
35
+ interface SessionSearchToolDetails {
36
+ error: boolean;
37
+ params?: SessionSearchToolParams | undefined;
38
+ results: SearchSessionResult[];
39
+ status?: SessionIndexStatus | undefined;
40
+ }
41
+
42
+ const DEFAULT_SESSION_SEARCH_LIMIT = 6;
43
+ const COLLAPSED_RESULT_PREVIEW_ROWS = 6;
44
+
45
+ export default function sessionSearchExtension(pi: ExtensionAPI): void {
46
+ const settings = loadSettings();
47
+
48
+ pi.registerTool({
49
+ name: "session_search",
50
+ label: "Session Search",
51
+ description: "Search prior Pi sessions",
52
+ promptSnippet: "Use when you need to locate an earlier session to do a detailed follow-up",
53
+ promptGuidelines: [
54
+ "query is plain text only. Do not use boolean operators like OR/AND, parentheses, regex, or other search syntax",
55
+ "If you want alternatives, run multiple session_search calls with different queries",
56
+ "Once you have the right session id, switch to session_ask for questions about that session",
57
+ ],
58
+ parameters: Type.Object({
59
+ query: Type.Optional(
60
+ Type.String({
61
+ description: "Free-text terms to match against",
62
+ }),
63
+ ),
64
+ files: Type.Optional(
65
+ Type.Object({
66
+ touched: Type.Optional(
67
+ Type.Array(
68
+ Type.String({
69
+ description: "File path touched in the session",
70
+ }),
71
+ ),
72
+ ),
73
+ }),
74
+ ),
75
+ repo: Type.Optional(
76
+ Type.String({
77
+ description: "Git repository touched in the session",
78
+ }),
79
+ ),
80
+ cwd: Type.Optional(
81
+ Type.String({
82
+ description: "Directory the session was started in",
83
+ }),
84
+ ),
85
+ time: Type.Optional(
86
+ Type.Object({
87
+ after: Type.Optional(
88
+ Type.String({
89
+ description: "Inclusive lower bound for session modified time, in ISO format",
90
+ }),
91
+ ),
92
+ before: Type.Optional(
93
+ Type.String({
94
+ description: "Inclusive upper bound for session modified time, in ISO format",
95
+ }),
96
+ ),
97
+ }),
98
+ ),
99
+ limit: Type.Optional(
100
+ Type.Number({
101
+ description: "Number of matches to return",
102
+ }),
103
+ ),
104
+ }),
105
+ async execute(_toolCallId, params: SessionSearchToolParams, _signal, onUpdate, ctx) {
106
+ const validationError = validateSearchParams(params);
107
+ if (validationError) {
108
+ const details: SessionSearchToolDetails = {
109
+ error: true,
110
+ params,
111
+ results: [],
112
+ };
113
+ return {
114
+ content: [{ type: "text", text: validationError }],
115
+ details,
116
+ };
117
+ }
118
+
119
+ const progressDetails: SessionSearchToolDetails = {
120
+ error: false,
121
+ params,
122
+ results: [],
123
+ };
124
+ onUpdate?.({
125
+ content: [{ type: "text", text: "Searching sessions..." }],
126
+ details: progressDetails,
127
+ });
128
+
129
+ const indexPath = settings.index.path;
130
+ const status = getIndexStatus(indexPath);
131
+ if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
132
+ const details: SessionSearchToolDetails = {
133
+ error: true,
134
+ params,
135
+ status,
136
+ results: [],
137
+ };
138
+ return {
139
+ content: [
140
+ {
141
+ type: "text",
142
+ text: `Session index missing or incompatible at ${indexPath}. Run /session-index and press r to rebuild it.`,
143
+ },
144
+ ],
145
+ details,
146
+ };
147
+ }
148
+
149
+ const db = openIndexDatabase(status.dbPath, { create: false });
150
+ try {
151
+ const results = searchSessions(db, buildSearchParams(params, ctx));
152
+
153
+ if (results.length === 0) {
154
+ const details: SessionSearchToolDetails = {
155
+ error: false,
156
+ params,
157
+ status,
158
+ results: [],
159
+ };
160
+ return {
161
+ content: [{ type: "text", text: "No matching sessions found." }],
162
+ details,
163
+ };
164
+ }
165
+
166
+ const details: SessionSearchToolDetails = {
167
+ error: false,
168
+ params,
169
+ status,
170
+ results,
171
+ };
172
+ return {
173
+ content: [{ type: "text", text: formatSearchResults(results) }],
174
+ details,
175
+ };
176
+ } finally {
177
+ db.close();
178
+ }
179
+ },
180
+ renderResult(result, { expanded, isPartial }, theme) {
181
+ const details = result.details as SessionSearchToolDetails | undefined;
182
+ const content = result.content[0];
183
+ if (content?.type !== "text") {
184
+ return new Text(theme.fg("error", "No search output"), 0, 0);
185
+ }
186
+
187
+ if (isPartial) {
188
+ const lines = [theme.bold(theme.fg("warning", "Searching sessions..."))];
189
+ lines.push(...formatSessionSearchContextLines(details?.params, theme));
190
+ return new Text(lines.join("\n"), 0, 0);
191
+ }
192
+
193
+ if (!details || details.error) {
194
+ return new Text(theme.fg("error", content.text), 0, 0);
195
+ }
196
+
197
+ const lines = formatSessionSearchContextLines(details.params, theme);
198
+ if (details.results.length === 0) {
199
+ if (lines.length > 0) lines.push("");
200
+ lines.push(theme.fg("warning", content.text));
201
+ return new Text(lines.join("\n"), 0, 0);
202
+ }
203
+
204
+ if (lines.length > 0) lines.push("");
205
+ lines.push(
206
+ ...formatSessionSearchPanelResults(details.results, details.params, expanded, theme),
207
+ );
208
+ return new Text(lines.join("\n"), 0, 0);
209
+ },
210
+ });
211
+ }
212
+
213
+ function buildSearchParams(
214
+ params: SessionSearchToolParams,
215
+ ctx: ExtensionContext,
216
+ ): SearchSessionsParams {
217
+ const currentSessionId = ctx.sessionManager.getSessionId();
218
+
219
+ return {
220
+ query: params.query,
221
+ touched: params.files?.touched,
222
+ repo: params.repo,
223
+ cwd: params.cwd,
224
+ after: params.time?.after,
225
+ before: params.time?.before,
226
+ limit: params.limit ?? DEFAULT_SESSION_SEARCH_LIMIT,
227
+ excludeSessionIds: currentSessionId ? [currentSessionId] : undefined,
228
+ };
229
+ }
230
+
231
+ function formatSessionSearchContextLines(
232
+ params: SessionSearchToolParams | undefined,
233
+ theme: Theme,
234
+ ): string[] {
235
+ if (!params) return [];
236
+
237
+ const lines: string[] = [];
238
+ if (params.query?.trim()) {
239
+ lines.push(theme.fg("muted", `query: ${params.query.trim()}`));
240
+ }
241
+
242
+ const filters: string[] = [];
243
+ if (params.repo?.trim()) filters.push(`repo: ${params.repo.trim()}`);
244
+ if (params.cwd?.trim()) filters.push(`cwd: ${params.cwd.trim()}`);
245
+ if (params.files?.touched?.length) filters.push(`files: ${params.files.touched.join(", ")}`);
246
+ if (params.time?.after?.trim()) filters.push(`after: ${params.time.after.trim()}`);
247
+ if (params.time?.before?.trim()) filters.push(`before: ${params.time.before.trim()}`);
248
+ if (params.limit !== undefined) filters.push(`limit: ${params.limit}`);
249
+
250
+ if (filters.length > 0) {
251
+ lines.push(theme.fg("dim", filters.join(" • ")));
252
+ }
253
+
254
+ if (lines.length === 0) {
255
+ lines.push(theme.fg("dim", "all sessions"));
256
+ }
257
+
258
+ return lines;
259
+ }
260
+
261
+ function formatSessionSearchPanelResults(
262
+ results: SearchSessionResult[],
263
+ params: SessionSearchToolParams | undefined,
264
+ expanded: boolean,
265
+ theme: Theme,
266
+ ): string[] {
267
+ const visibleResults = expanded ? results : results.slice(0, COLLAPSED_RESULT_PREVIEW_ROWS);
268
+ const lines = visibleResults.flatMap((result, index) => {
269
+ const location = formatSearchResultLocation(result.cwd);
270
+ const heading = `${index + 1}. ${theme.bold(formatSearchResultLabel(result))}${location ? ` ${theme.fg("dim", `(${location})`)}` : ""}`;
271
+ const snippet = params?.query ? formatSearchSnippet(result) : undefined;
272
+ return snippet ? [heading, theme.fg("dim", ` - ${snippet}`)] : [heading];
273
+ });
274
+
275
+ if (!expanded && results.length > visibleResults.length) {
276
+ lines.push(theme.fg("dim", `... ${results.length - visibleResults.length} more`));
277
+ }
278
+
279
+ return lines;
280
+ }
281
+
282
+ function formatSearchResultLabel(result: SearchSessionResult): string {
283
+ return formatSessionTitleOrShortId(result.sessionName, result.sessionId);
284
+ }
285
+
286
+ function formatSearchResultLocation(cwd: string | undefined): string | undefined {
287
+ if (!cwd) return undefined;
288
+ const base = path.basename(cwd);
289
+ return base || cwd;
290
+ }
291
+
292
+ function formatSearchSnippet(result: SearchSessionResult): string | undefined {
293
+ const plainSnippet = stripSearchSnippetMarkers(result.snippet)?.replace(/\s+/g, " ").trim();
294
+ if (!plainSnippet) return undefined;
295
+ if (plainSnippet === result.sessionName || plainSnippet === result.cwd) return undefined;
296
+ return transformSearchSnippetMatches(result.snippet, (match) => `[${match}]`)
297
+ ?.replace(/\s+/g, " ")
298
+ .trim();
299
+ }
300
+
301
+ interface SearchResultTextStyles {
302
+ cwd: (text: string) => string;
303
+ primary: (text: string) => string;
304
+ secondary: (text: string) => string;
305
+ }
306
+
307
+ function formatSearchResults(
308
+ results: SearchSessionResult[],
309
+ styles: SearchResultTextStyles = defaultSearchResultTextStyles,
310
+ ): string {
311
+ const groups = new Map<string, SearchSessionResult[]>();
312
+
313
+ for (const result of results) {
314
+ const bucket = groups.get(result.cwd);
315
+ if (bucket) {
316
+ bucket.push(result);
317
+ continue;
318
+ }
319
+
320
+ groups.set(result.cwd, [result]);
321
+ }
322
+
323
+ return [...groups.entries()]
324
+ .flatMap(([cwd, groupResults], groupIndex) => {
325
+ const lines = [styles.cwd(`cwd: ${cwd}`)];
326
+ for (const result of groupResults) {
327
+ lines.push(...formatSearchResult(result, styles));
328
+ }
329
+ if (groupIndex < groups.size - 1) {
330
+ lines.push("");
331
+ }
332
+ return lines;
333
+ })
334
+ .join("\n")
335
+ .trim();
336
+ }
337
+
338
+ function formatSearchResult(result: SearchSessionResult, styles: SearchResultTextStyles): string[] {
339
+ const lines = [styles.primary(`${result.sessionName || "[unnamed]"}: ${result.sessionId}`)];
340
+
341
+ if (result.matchedFiles.length > 0) {
342
+ lines.push(styles.secondary(`matched_files: ${result.matchedFiles.join(", ")}`));
343
+ }
344
+
345
+ if (result.score > 0 || result.hitCount > 0) {
346
+ lines.push(styles.secondary(`score: ${result.score.toFixed(2)} / hits: ${result.hitCount}`));
347
+ }
348
+
349
+ const plainSnippet = stripSearchSnippetMarkers(result.snippet)?.replace(/\s+/g, " ").trim();
350
+ if (plainSnippet && plainSnippet !== result.sessionName && plainSnippet !== result.cwd) {
351
+ lines.push(styles.secondary(`snippet: ${plainSnippet}`));
352
+ }
353
+
354
+ return lines;
355
+ }
356
+
357
+ const defaultSearchResultTextStyles: SearchResultTextStyles = {
358
+ cwd: (text) => text,
359
+ primary: (text) => text,
360
+ secondary: (text) => text,
361
+ };
362
+
363
+ function validateSearchParams(params: SessionSearchToolParams): string | undefined {
364
+ if (params.time?.after && !isValidIsoDateLike(params.time.after)) {
365
+ return `Invalid time.after value: ${params.time.after}`;
366
+ }
367
+
368
+ if (params.time?.before && !isValidIsoDateLike(params.time.before)) {
369
+ return `Invalid time.before value: ${params.time.before}`;
370
+ }
371
+
372
+ if (params.time?.after && params.time?.before) {
373
+ const after = new Date(params.time.after);
374
+ const before = new Date(params.time.before);
375
+ if (after.getTime() > before.getTime()) {
376
+ return `time.after must be less than or equal to time.before`;
377
+ }
378
+ }
379
+
380
+ if (params.limit !== undefined && params.limit <= 0) {
381
+ return `limit must be greater than 0`;
382
+ }
383
+
384
+ return undefined;
385
+ }
386
+
387
+ function isValidIsoDateLike(value: string): boolean {
388
+ const date = new Date(value);
389
+ return !Number.isNaN(date.getTime());
390
+ }
@@ -0,0 +1,40 @@
1
+ export const SEARCH_SNIPPET_MATCH_START = "__PI_MATCH_START__";
2
+ export const SEARCH_SNIPPET_MATCH_END = "__PI_MATCH_END__";
3
+ export const SEARCH_SNIPPET_ELLIPSIS = " … ";
4
+
5
+ export function transformSearchSnippetMatches(
6
+ snippet: string | undefined,
7
+ transformMatch: (match: string) => string,
8
+ ): string | undefined {
9
+ if (!snippet) {
10
+ return undefined;
11
+ }
12
+
13
+ let result = "";
14
+ let offset = 0;
15
+
16
+ while (offset < snippet.length) {
17
+ const start = snippet.indexOf(SEARCH_SNIPPET_MATCH_START, offset);
18
+ if (start < 0) {
19
+ result += snippet.slice(offset);
20
+ return result;
21
+ }
22
+
23
+ result += snippet.slice(offset, start);
24
+ const matchStart = start + SEARCH_SNIPPET_MATCH_START.length;
25
+ const end = snippet.indexOf(SEARCH_SNIPPET_MATCH_END, matchStart);
26
+ if (end < 0) {
27
+ result += snippet.slice(start);
28
+ return result;
29
+ }
30
+
31
+ result += transformMatch(snippet.slice(matchStart, end));
32
+ offset = end + SEARCH_SNIPPET_MATCH_END.length;
33
+ }
34
+
35
+ return result;
36
+ }
37
+
38
+ export function stripSearchSnippetMarkers(snippet: string | undefined): string | undefined {
39
+ return transformSearchSnippetMatches(snippet, (match) => match);
40
+ }
@@ -0,0 +1,222 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type Database from "better-sqlite3";
3
+ import type { FileTouchOp, FileTouchSource, PathScope } from "../../session-search/normalize.js";
4
+ import { safeParseTypeBoxJson } from "../typebox.js";
5
+
6
+ export const INDEX_SCHEMA_VERSION = 7;
7
+
8
+ export type SessionOrigin = "handoff" | "fork" | "unknown_child";
9
+ export type SessionLineageRelation =
10
+ | "parent"
11
+ | "ancestor"
12
+ | "child"
13
+ | "descendant"
14
+ | "sibling"
15
+ | "ancestor_sibling";
16
+
17
+ export interface SessionRow {
18
+ sessionId: string;
19
+ sessionPath: string;
20
+ sessionName: string;
21
+ firstUserPrompt?: string | undefined;
22
+ cwd: string;
23
+ repoRoots: string[];
24
+ startedAt: string;
25
+ modifiedAt: string;
26
+ messageCount: number;
27
+ entryCount: number;
28
+ parentSessionPath?: string | undefined;
29
+ parentSessionId?: string | undefined;
30
+ sessionOrigin?: SessionOrigin | undefined;
31
+ handoffGoal?: string | undefined;
32
+ handoffNextTask?: string | undefined;
33
+ }
34
+
35
+ export interface SessionLineageRow {
36
+ sessionId: string;
37
+ sessionPath: string;
38
+ sessionName: string;
39
+ firstUserPrompt?: string | undefined;
40
+ cwd: string;
41
+ repoRoots: string[];
42
+ modifiedAt: string;
43
+ parentSessionPath?: string | undefined;
44
+ parentSessionId?: string | undefined;
45
+ sessionOrigin?: SessionOrigin | undefined;
46
+ handoffGoal?: string | undefined;
47
+ handoffNextTask?: string | undefined;
48
+ }
49
+
50
+ export interface SessionRelatedSessionRow extends SessionLineageRow {
51
+ relation: SessionLineageRelation;
52
+ distance: number;
53
+ }
54
+
55
+ export interface SessionTextChunkRow {
56
+ id?: number | undefined;
57
+ sessionId: string;
58
+ entryId?: string | undefined;
59
+ entryType: string;
60
+ role?: string | undefined;
61
+ ts: string;
62
+ sourceKind: string;
63
+ text: string;
64
+ }
65
+
66
+ export interface SessionFileTouchRow {
67
+ id?: number | undefined;
68
+ sessionId: string;
69
+ entryId?: string | undefined;
70
+ op: FileTouchOp;
71
+ source: FileTouchSource;
72
+ rawPath: string;
73
+ absPath?: string | undefined;
74
+ cwdRelPath?: string | undefined;
75
+ repoRoot?: string | undefined;
76
+ repoRelPath?: string | undefined;
77
+ basename: string;
78
+ pathScope: PathScope;
79
+ ts: string;
80
+ }
81
+
82
+ export interface SearchSessionsParams {
83
+ query?: string | undefined;
84
+ cwd?: string | undefined;
85
+ repo?: string | undefined;
86
+ after?: string | undefined;
87
+ before?: string | undefined;
88
+ touched?: string[] | undefined;
89
+ limit?: number | undefined;
90
+ excludeSessionIds?: string[] | undefined;
91
+ }
92
+
93
+ export interface SearchSessionResult {
94
+ sessionId: string;
95
+ sessionName: string;
96
+ sessionPath: string;
97
+ cwd: string;
98
+ repoRoots: string[];
99
+ startedAt: string;
100
+ modifiedAt: string;
101
+ messageCount: number;
102
+ parentSessionPath?: string | undefined;
103
+ parentSessionId?: string | undefined;
104
+ firstUserPrompt?: string | undefined;
105
+ sessionOrigin?: SessionOrigin | undefined;
106
+ handoffGoal?: string | undefined;
107
+ handoffNextTask?: string | undefined;
108
+ snippet: string;
109
+ matchedFiles: string[];
110
+ score: number;
111
+ hitCount: number;
112
+ }
113
+
114
+ export interface SessionIndexStatus {
115
+ dbPath: string;
116
+ exists: boolean;
117
+ schemaVersion?: number | undefined;
118
+ sessionCount?: number | undefined;
119
+ lastFullReindexAt?: string | undefined;
120
+ }
121
+
122
+ export type SessionIndexDatabase = Database.Database;
123
+
124
+ export const NULLABLE_STRING_SCHEMA = Type.Union([Type.String(), Type.Null()]);
125
+ export const SESSION_ORIGIN_SCHEMA = Type.Union([
126
+ Type.Literal("handoff"),
127
+ Type.Literal("fork"),
128
+ Type.Literal("unknown_child"),
129
+ ]);
130
+ export const SESSION_LINEAGE_RELATION_SCHEMA = Type.Union([
131
+ Type.Literal("parent"),
132
+ Type.Literal("ancestor"),
133
+ Type.Literal("child"),
134
+ Type.Literal("descendant"),
135
+ Type.Literal("sibling"),
136
+ Type.Literal("ancestor_sibling"),
137
+ ]);
138
+ export const ROW_COUNT_SCHEMA = Type.Object({
139
+ count: Type.Number(),
140
+ });
141
+ export const METADATA_ROW_SCHEMA = Type.Object({
142
+ value: Type.String(),
143
+ });
144
+
145
+ export function parseRepoRoots(value: string): string[] {
146
+ return safeParseTypeBoxJson(Type.Array(Type.String()), value) ?? [];
147
+ }
148
+
149
+ export function escapeLikePrefix(value: string): string {
150
+ return value.replace(/[%_]/g, "\\$&");
151
+ }
152
+
153
+ export function normalizeTimeFilter(value?: string): string | undefined {
154
+ if (!value) {
155
+ return undefined;
156
+ }
157
+
158
+ const date = new Date(value);
159
+ if (Number.isNaN(date.getTime())) {
160
+ return undefined;
161
+ }
162
+
163
+ return date.toISOString();
164
+ }
165
+
166
+ export function sanitizeFilterValues(values?: string[]): string[] {
167
+ if (!values) {
168
+ return [];
169
+ }
170
+
171
+ return values.map((value) => value.trim()).filter((value) => value.length > 0);
172
+ }
173
+
174
+ export function boostIndependentHits(hitCount: number): number {
175
+ return hitCount > 1 ? (hitCount - 1) * 0.75 : 0;
176
+ }
177
+
178
+ export function buildFtsQuery(query: string): string | undefined {
179
+ const trimmed = query.trim();
180
+ if (!trimmed) {
181
+ return undefined;
182
+ }
183
+
184
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
185
+ const exact = sanitizeFtsToken(trimmed.slice(1, -1));
186
+ return exact ? quoteFtsToken(exact) : undefined;
187
+ }
188
+
189
+ const tokens = tokenizeSearchTerms(trimmed);
190
+ if (tokens.length === 0) {
191
+ return undefined;
192
+ }
193
+
194
+ return tokens.map(quoteFtsPrefixToken).join(" AND ");
195
+ }
196
+
197
+ export function tokenizeSearchTerms(query: string): string[] {
198
+ return query
199
+ .split(/\s+/)
200
+ .flatMap((token) => sanitizeFtsToken(token).split(/\s+/))
201
+ .filter((token) => token.length > 0);
202
+ }
203
+
204
+ export function sanitizeFtsToken(token: string): string {
205
+ return token.replace(/[^A-Za-z0-9_]+/g, " ").trim();
206
+ }
207
+
208
+ export function quoteFtsToken(token: string): string {
209
+ return `"${token.replace(/"/g, '""')}"`;
210
+ }
211
+
212
+ export function quoteFtsPrefixToken(token: string): string {
213
+ return `${quoteFtsToken(token)}*`;
214
+ }
215
+
216
+ export function compactSearchValue(value: string): string {
217
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
218
+ }
219
+
220
+ export function compactSessionId(value: string): string {
221
+ return value.toLowerCase().replace(/-/g, "");
222
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./common.js";
2
+ export * from "./lineage.js";
3
+ export * from "./schema.js";
4
+ export * from "./search.js";
5
+ export * from "./store.js";