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,20 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
3
  import net from "node:net";
4
- import { readFrames, writeFrame } from "../shared/framing.ts";
5
- import {
6
- BROKER_FRAME_SCHEMA,
7
- type SessionMessagingBrokerFrame,
8
- type SessionMessagingSessionInfo,
9
- } from "../shared/protocol.ts";
10
- import { getSessionMessagingSocketPath } from "../shared/socket-path.ts";
4
+ import { readFrames, writeFrame } from "./framing.ts";
5
+ import { BROKER_FRAME_SCHEMA, type SessionMessagingBrokerFrame } from "./protocol.ts";
6
+ import { getSessionMessagingSocketPath } from "./socket-path.ts";
7
+
11
8
  export const INCOMING_SESSION_MESSAGE_EVENT = "session_messaging.incoming_message";
12
9
 
13
10
  const REQUEST_TIMEOUT_MS = 10_000;
14
11
  const SOCKET_PATH = getSessionMessagingSocketPath();
15
12
 
16
13
  interface PendingListRequest {
17
- resolve(sessions: SessionMessagingSessionInfo[]): void;
14
+ resolve(sessionIds: string[]): void;
18
15
  reject(error: Error): void;
19
16
  timeout: NodeJS.Timeout;
20
17
  }
@@ -42,19 +39,19 @@ export interface SendSessionMessageOptions {
42
39
 
43
40
  export class SessionMessagingClient extends EventEmitter {
44
41
  private socket: net.Socket | undefined;
45
- private session: SessionMessagingSessionInfo | undefined;
42
+ private sessionId: string | undefined;
46
43
  private pendingLists = new Map<string, PendingListRequest>();
47
44
  private pendingSends = new Map<string, PendingSendRequest>();
48
45
 
49
46
  get isConnected(): boolean {
50
- return Boolean(this.socket && !this.socket.destroyed && this.socket.writable && this.session);
47
+ return Boolean(this.socket && !this.socket.destroyed && this.socket.writable && this.sessionId);
51
48
  }
52
49
 
53
- get registeredSession(): SessionMessagingSessionInfo | undefined {
54
- return this.session;
50
+ get registeredSessionId(): string | undefined {
51
+ return this.sessionId;
55
52
  }
56
53
 
57
- async connect(session: SessionMessagingSessionInfo): Promise<void> {
54
+ async connect(sessionId: string): Promise<void> {
58
55
  if (this.isConnected) {
59
56
  return;
60
57
  }
@@ -112,7 +109,7 @@ export class SessionMessagingClient extends EventEmitter {
112
109
  void this.readSocket(socket);
113
110
 
114
111
  socket.setKeepAlive(true, 5_000);
115
- writeFrame(socket, { type: "register", session });
112
+ writeFrame(socket, { type: "register", sessionId });
116
113
  });
117
114
  }
118
115
 
@@ -128,7 +125,7 @@ export class SessionMessagingClient extends EventEmitter {
128
125
  this.cleanup();
129
126
  }
130
127
 
131
- listSessions(): Promise<SessionMessagingSessionInfo[]> {
128
+ listSessionIds(): Promise<string[]> {
132
129
  const socket = this.requireSocket();
133
130
  const requestId = randomUUID();
134
131
 
@@ -197,7 +194,7 @@ export class SessionMessagingClient extends EventEmitter {
197
194
  private handleFrame(frame: SessionMessagingBrokerFrame): void {
198
195
  switch (frame.type) {
199
196
  case "registered":
200
- this.session = frame.session;
197
+ this.sessionId = frame.sessionId;
201
198
  this.emit("registered");
202
199
  break;
203
200
  case "register_failed":
@@ -208,7 +205,7 @@ export class SessionMessagingClient extends EventEmitter {
208
205
  if (!pending) return;
209
206
  clearTimeout(pending.timeout);
210
207
  this.pendingLists.delete(frame.requestId);
211
- pending.resolve(frame.sessions);
208
+ pending.resolve(frame.sessionIds);
212
209
  break;
213
210
  }
214
211
  case "incoming":
@@ -233,7 +230,7 @@ export class SessionMessagingClient extends EventEmitter {
233
230
  }
234
231
 
235
232
  private requireSocket(): net.Socket {
236
- if (!this.socket || this.socket.destroyed || !this.socket.writable || !this.session) {
233
+ if (!this.socket || this.socket.destroyed || !this.socket.writable || !this.sessionId) {
237
234
  throw new Error("Session messaging is not connected.");
238
235
  }
239
236
 
@@ -241,7 +238,7 @@ export class SessionMessagingClient extends EventEmitter {
241
238
  }
242
239
 
243
240
  private cleanup(): void {
244
- this.session = undefined;
241
+ this.sessionId = undefined;
245
242
  for (const pending of this.pendingLists.values()) {
246
243
  clearTimeout(pending.timeout);
247
244
  pending.reject(new Error("Session messaging disconnected."));
@@ -1,7 +1,7 @@
1
1
  import type { Socket } from "node:net";
2
2
  import { createInterface } from "node:readline";
3
3
  import type { Static, TSchema } from "typebox";
4
- import { parseTypeBoxValue } from "../../shared/typebox.ts";
4
+ import { parseTypeBoxValue } from "../typebox.ts";
5
5
 
6
6
  const MAX_FRAME_BYTES = 256 * 1024;
7
7
 
@@ -1,15 +1,9 @@
1
1
  import { type Static, Type } from "typebox";
2
2
 
3
- const SESSION_INFO_SCHEMA = Type.Object({
4
- sessionId: Type.String(),
5
- sessionName: Type.Optional(Type.String()),
6
- cwd: Type.String(),
7
- });
8
-
9
3
  export const SESSION_MESSAGE_PAYLOAD_SCHEMA = Type.Object({
10
4
  messageId: Type.String(),
11
- source: SESSION_INFO_SCHEMA,
12
- target: SESSION_INFO_SCHEMA,
5
+ source: Type.String(),
6
+ target: Type.String(),
13
7
  body: Type.String(),
14
8
  requestResponse: Type.Optional(Type.Boolean()),
15
9
  sentAt: Type.String(),
@@ -18,7 +12,7 @@ export const SESSION_MESSAGE_PAYLOAD_SCHEMA = Type.Object({
18
12
 
19
13
  const REGISTER_CLIENT_FRAME_SCHEMA = Type.Object({
20
14
  type: Type.Literal("register"),
21
- session: SESSION_INFO_SCHEMA,
15
+ sessionId: Type.String(),
22
16
  });
23
17
  const UNREGISTER_CLIENT_FRAME_SCHEMA = Type.Object({
24
18
  type: Type.Literal("unregister"),
@@ -47,7 +41,7 @@ const INCOMING_ACK_CLIENT_FRAME_SCHEMA = Type.Object({
47
41
 
48
42
  const REGISTERED_BROKER_FRAME_SCHEMA = Type.Object({
49
43
  type: Type.Literal("registered"),
50
- session: SESSION_INFO_SCHEMA,
44
+ sessionId: Type.String(),
51
45
  });
52
46
  const REGISTER_FAILED_BROKER_FRAME_SCHEMA = Type.Object({
53
47
  type: Type.Literal("register_failed"),
@@ -56,7 +50,7 @@ const REGISTER_FAILED_BROKER_FRAME_SCHEMA = Type.Object({
56
50
  const SESSIONS_BROKER_FRAME_SCHEMA = Type.Object({
57
51
  type: Type.Literal("sessions"),
58
52
  requestId: Type.String(),
59
- sessions: Type.Array(SESSION_INFO_SCHEMA),
53
+ sessionIds: Type.Array(Type.String()),
60
54
  });
61
55
  const INCOMING_BROKER_FRAME_SCHEMA = Type.Object({
62
56
  type: Type.Literal("incoming"),
@@ -92,7 +86,6 @@ export const BROKER_FRAME_SCHEMA = Type.Union([
92
86
  ERROR_BROKER_FRAME_SCHEMA,
93
87
  ]);
94
88
 
95
- export type SessionMessagingSessionInfo = Static<typeof SESSION_INFO_SCHEMA>;
96
89
  export type SessionMessagePayload = Static<typeof SESSION_MESSAGE_PAYLOAD_SCHEMA>;
97
90
  export type SessionMessagingSendClientFrame = Static<typeof SEND_CLIENT_FRAME_SCHEMA>;
98
91
  export type SessionMessagingIncomingAckClientFrame = Static<
@@ -0,0 +1,128 @@
1
+ import { existsSync } from "node:fs";
2
+ import { parseTypeBoxValue } from "../typebox.ts";
3
+ import {
4
+ INDEX_SCHEMA_VERSION,
5
+ ROW_COUNT_SCHEMA,
6
+ type SessionIndexDatabase,
7
+ type SessionIndexStatus,
8
+ } from "./common.ts";
9
+ import { getMetadata, openIndexDatabase } from "./schema.ts";
10
+
11
+ export type SessionIndexOpenMode = "read" | "write";
12
+
13
+ export interface WithSessionIndexOptions {
14
+ mode: SessionIndexOpenMode;
15
+ required: boolean;
16
+ timeoutMs?: number | undefined;
17
+ }
18
+
19
+ export interface ValidSessionIndex {
20
+ db: SessionIndexDatabase;
21
+ status: SessionIndexStatus;
22
+ }
23
+
24
+ export function withSessionIndex<T>(
25
+ indexPath: string,
26
+ options: WithSessionIndexOptions & { required: true },
27
+ read: (index: ValidSessionIndex) => T,
28
+ ): T;
29
+ export function withSessionIndex<T>(
30
+ indexPath: string,
31
+ options: WithSessionIndexOptions & { required: false },
32
+ read: (index: ValidSessionIndex) => T,
33
+ ): T | undefined;
34
+ export function withSessionIndex<T>(
35
+ indexPath: string,
36
+ options: WithSessionIndexOptions,
37
+ read: (index: ValidSessionIndex) => T,
38
+ ): T | undefined {
39
+ const opened = openValidatedSessionIndexInternal(indexPath, options);
40
+ if (!opened) {
41
+ return undefined;
42
+ }
43
+
44
+ try {
45
+ return read(opened);
46
+ } finally {
47
+ opened.db.close();
48
+ }
49
+ }
50
+
51
+ export function openValidatedSessionIndex(
52
+ indexPath: string,
53
+ options: WithSessionIndexOptions & { required: true },
54
+ ): ValidSessionIndex;
55
+ export function openValidatedSessionIndex(
56
+ indexPath: string,
57
+ options: WithSessionIndexOptions & { required: false },
58
+ ): ValidSessionIndex | undefined;
59
+ export function openValidatedSessionIndex(
60
+ indexPath: string,
61
+ options: WithSessionIndexOptions,
62
+ ): ValidSessionIndex | undefined {
63
+ return openValidatedSessionIndexInternal(indexPath, options);
64
+ }
65
+
66
+ function openValidatedSessionIndexInternal(
67
+ indexPath: string,
68
+ options: WithSessionIndexOptions,
69
+ ): ValidSessionIndex | undefined {
70
+ if (!existsSync(indexPath)) {
71
+ return handleUnavailableIndex(indexPath, options);
72
+ }
73
+
74
+ let db: SessionIndexDatabase | undefined;
75
+ try {
76
+ db = openIndexDatabase(indexPath, {
77
+ create: false,
78
+ mode: options.mode,
79
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
80
+ });
81
+ const status = getIndexStatusFromDb(indexPath, db);
82
+ if (status.schemaVersion !== INDEX_SCHEMA_VERSION) {
83
+ const invalidDb = db;
84
+ db = undefined;
85
+ invalidDb.close();
86
+ return handleUnavailableIndex(indexPath, options);
87
+ }
88
+
89
+ return { db, status };
90
+ } catch (error) {
91
+ db?.close();
92
+ return handleUnavailableIndex(indexPath, options, error);
93
+ }
94
+ }
95
+
96
+ export function getIndexStatusFromDb(dbPath: string, db: SessionIndexDatabase): SessionIndexStatus {
97
+ const schemaVersionRaw = getMetadata(db, "schema_version");
98
+ const lastFullReindexAt = getMetadata(db, "indexed_at");
99
+ const sessionCountRow = parseTypeBoxValue(
100
+ ROW_COUNT_SCHEMA,
101
+ db.prepare(`SELECT COUNT(*) as count FROM sessions`).get(),
102
+ "Invalid session count row",
103
+ );
104
+
105
+ return {
106
+ dbPath,
107
+ exists: true,
108
+ schemaVersion: schemaVersionRaw ? Number(schemaVersionRaw) : undefined,
109
+ sessionCount: sessionCountRow.count,
110
+ lastFullReindexAt,
111
+ };
112
+ }
113
+
114
+ export function formatRequiredSessionIndexError(indexPath: string): string {
115
+ return `Session index missing or incompatible at ${indexPath}. Run /session-index and press r to rebuild it.`;
116
+ }
117
+
118
+ function handleUnavailableIndex(
119
+ indexPath: string,
120
+ options: WithSessionIndexOptions,
121
+ cause?: unknown,
122
+ ): undefined {
123
+ if (!options.required) {
124
+ return undefined;
125
+ }
126
+
127
+ throw new Error(formatRequiredSessionIndexError(indexPath), { cause });
128
+ }
@@ -3,10 +3,11 @@ import type { FileTouchOp, FileTouchSource, PathScope } from "../../session-sear
3
3
  import { safeParseTypeBoxJson } from "../typebox.ts";
4
4
  import type { SqliteDatabase } from "./sqlite.ts";
5
5
 
6
- export const INDEX_SCHEMA_VERSION = 8;
6
+ export const INDEX_SCHEMA_VERSION = 12;
7
7
 
8
8
  export type SessionOrigin = "handoff" | "fork" | "unknown_child";
9
9
  export type SessionLineageRelation =
10
+ | "self"
10
11
  | "parent"
11
12
  | "ancestor"
12
13
  | "child"
@@ -14,6 +15,8 @@ export type SessionLineageRelation =
14
15
  | "sibling"
15
16
  | "ancestor_sibling";
16
17
 
18
+ export type SearchSort = "relevance" | "modified_desc" | "modified_asc";
19
+
17
20
  export interface SessionRow {
18
21
  sessionId: string;
19
22
  sessionPath: string;
@@ -58,7 +61,7 @@ export interface SessionRelatedSessionRow extends SessionLineageRow {
58
61
  export interface SessionTextChunkRow {
59
62
  id?: number | undefined;
60
63
  sessionId: string;
61
- entryId?: string | undefined;
64
+ entryId: string;
62
65
  entryType: string;
63
66
  role?: string | undefined;
64
67
  ts: string;
@@ -89,9 +92,34 @@ export interface SearchSessionsParams {
89
92
  after?: string | undefined;
90
93
  before?: string | undefined;
91
94
  touched?: string[] | undefined;
95
+ changed?: string[] | undefined;
96
+ sort?: SearchSort | undefined;
92
97
  limit?: number | undefined;
93
98
  excludeSessionIds?: string[] | undefined;
94
- }
99
+ includeSessionIds?: string[] | undefined;
100
+ relativeToSessionId?: string | undefined;
101
+ }
102
+
103
+ export type SessionSearchEvidence =
104
+ | {
105
+ kind: "text";
106
+ sourceKind: string;
107
+ snippet: string;
108
+ score: number;
109
+ entryId: string;
110
+ }
111
+ | {
112
+ kind: "file_touch";
113
+ op: "read" | "changed";
114
+ query: string;
115
+ path: string;
116
+ entryId?: string | undefined;
117
+ }
118
+ | {
119
+ kind: "session_id";
120
+ match: "exact" | "prefix" | "substring";
121
+ score: number;
122
+ };
95
123
 
96
124
  export interface SearchSessionResult {
97
125
  sessionId: string;
@@ -108,8 +136,9 @@ export interface SearchSessionResult {
108
136
  sessionOrigin?: SessionOrigin | undefined;
109
137
  handoffGoal?: string | undefined;
110
138
  handoffNextTask?: string | undefined;
139
+ relation?: SessionLineageRelation | undefined;
111
140
  snippet: string;
112
- matchedFiles: string[];
141
+ evidence: SessionSearchEvidence[];
113
142
  score: number;
114
143
  hitCount: number;
115
144
  }
@@ -131,6 +160,7 @@ export const SESSION_ORIGIN_SCHEMA = Type.Union([
131
160
  Type.Literal("unknown_child"),
132
161
  ]);
133
162
  export const SESSION_LINEAGE_RELATION_SCHEMA = Type.Union([
163
+ Type.Literal("self"),
134
164
  Type.Literal("parent"),
135
165
  Type.Literal("ancestor"),
136
166
  Type.Literal("child"),
@@ -150,7 +180,7 @@ export function parseRepoRoots(value: string): string[] {
150
180
  }
151
181
 
152
182
  export function escapeLikePrefix(value: string): string {
153
- return value.replace(/[%_]/g, "\\$&");
183
+ return value.replace(/[%_\\]/g, "\\$&");
154
184
  }
155
185
 
156
186
  export function normalizeTimeFilter(value?: string): string | undefined {
@@ -160,7 +190,7 @@ export function normalizeTimeFilter(value?: string): string | undefined {
160
190
 
161
191
  const date = new Date(value);
162
192
  if (Number.isNaN(date.getTime())) {
163
- return undefined;
193
+ throw new Error(`Invalid time filter value: ${value}`);
164
194
  }
165
195
 
166
196
  return date.toISOString();
@@ -174,48 +204,6 @@ export function sanitizeFilterValues(values?: string[]): string[] {
174
204
  return values.map((value) => value.trim()).filter((value) => value.length > 0);
175
205
  }
176
206
 
177
- export function boostIndependentHits(hitCount: number): number {
178
- return hitCount > 1 ? (hitCount - 1) * 0.75 : 0;
179
- }
180
-
181
- export function buildFtsQuery(query: string): string | undefined {
182
- const trimmed = query.trim();
183
- if (!trimmed) {
184
- return undefined;
185
- }
186
-
187
- if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
188
- const exact = sanitizeFtsToken(trimmed.slice(1, -1));
189
- return exact ? quoteFtsToken(exact) : undefined;
190
- }
191
-
192
- const tokens = tokenizeSearchTerms(trimmed);
193
- if (tokens.length === 0) {
194
- return undefined;
195
- }
196
-
197
- return tokens.map(quoteFtsPrefixToken).join(" AND ");
198
- }
199
-
200
- export function tokenizeSearchTerms(query: string): string[] {
201
- return query
202
- .split(/\s+/)
203
- .flatMap((token) => sanitizeFtsToken(token).split(/\s+/))
204
- .filter((token) => token.length > 0);
205
- }
206
-
207
- export function sanitizeFtsToken(token: string): string {
208
- return token.replace(/[^A-Za-z0-9_]+/g, " ").trim();
209
- }
210
-
211
- export function quoteFtsToken(token: string): string {
212
- return `"${token.replace(/"/g, '""')}"`;
213
- }
214
-
215
- export function quoteFtsPrefixToken(token: string): string {
216
- return `${quoteFtsToken(token)}*`;
217
- }
218
-
219
207
  export function compactSearchValue(value: string): string {
220
208
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
221
209
  }
@@ -223,3 +211,10 @@ export function compactSearchValue(value: string): string {
223
211
  export function compactSessionId(value: string): string {
224
212
  return value.toLowerCase().replace(/-/g, "");
225
213
  }
214
+
215
+ export function tokenizeSearchText(value: string): string[] {
216
+ return value
217
+ .split(/[^A-Za-z0-9_]+/g)
218
+ .map((token) => token.trim())
219
+ .filter((token) => token.length > 0);
220
+ }
@@ -1,3 +1,4 @@
1
+ export * from "./access.ts";
1
2
  export * from "./common.ts";
2
3
  export * from "./lineage.ts";
3
4
  export * from "./schema.ts";
@@ -346,6 +346,7 @@ function queryRelatedSessions(
346
346
  WHERE r.session_id = ?${relationFilter}
347
347
  ORDER BY
348
348
  CASE r.relation
349
+ WHEN 'self' THEN 0
349
350
  WHEN 'parent' THEN 1
350
351
  WHEN 'child' THEN 2
351
352
  WHEN 'sibling' THEN 3
@@ -371,6 +372,13 @@ function collectMaterializedLineageRows(
371
372
  childrenByParent: Map<string, string[]>,
372
373
  ): Map<string, MaterializedLineageRow> {
373
374
  const relations = new Map<string, MaterializedLineageRow>();
375
+ setMaterializedLineageRow(relations, {
376
+ sessionId,
377
+ relatedSessionId: sessionId,
378
+ relation: "self",
379
+ distance: 0,
380
+ });
381
+
374
382
  const visitedAncestors = new Set<string>();
375
383
  const ancestors: Array<{ sessionId: string; distance: number }> = [];
376
384
 
@@ -476,6 +484,8 @@ function setMaterializedLineageRow(
476
484
 
477
485
  function getLineageRelationPriority(relation: SessionLineageRelation): number {
478
486
  switch (relation) {
487
+ case "self":
488
+ return 0;
479
489
  case "parent":
480
490
  return 1;
481
491
  case "child":
@@ -0,0 +1,40 @@
1
+ export interface QueryTermNode {
2
+ kind: "term";
3
+ value: string;
4
+ quoted: boolean;
5
+ prefix: boolean;
6
+ }
7
+
8
+ export interface QueryAndNode {
9
+ kind: "and";
10
+ children: SearchQueryNode[];
11
+ }
12
+
13
+ export interface QueryOrNode {
14
+ kind: "or";
15
+ children: SearchQueryNode[];
16
+ }
17
+
18
+ export interface QueryAdjacencyNode {
19
+ kind: "adjacency";
20
+ children: QueryTermNode[];
21
+ }
22
+
23
+ export interface QueryNotNode {
24
+ kind: "not";
25
+ child: SearchQueryNode;
26
+ }
27
+
28
+ export type SearchQueryNode =
29
+ | QueryTermNode
30
+ | QueryAndNode
31
+ | QueryOrNode
32
+ | QueryAdjacencyNode
33
+ | QueryNotNode;
34
+
35
+ export class SearchQuerySyntaxError extends Error {
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = "SearchQuerySyntaxError";
39
+ }
40
+ }
@@ -0,0 +1,146 @@
1
+ import { type SearchQueryNode, SearchQuerySyntaxError } from "./ast.ts";
2
+ import { parseSearchQuery } from "./parser.ts";
3
+
4
+ export interface CompiledSearchQuery {
5
+ match: string;
6
+ relaxedMatch?: string | undefined;
7
+ excludes: string[];
8
+ positiveTerms: string[];
9
+ }
10
+
11
+ export function compileSearchQuery(input: string): CompiledSearchQuery | undefined {
12
+ const ast = parseSearchQuery(input);
13
+ if (!ast) {
14
+ return undefined;
15
+ }
16
+
17
+ const { positive, excludes } = splitTopLevelExcludes(ast);
18
+ if (!positive) {
19
+ throw new SearchQuerySyntaxError(
20
+ "Negative-only searches are not supported yet. Add at least one positive search term.",
21
+ );
22
+ }
23
+
24
+ validateNoNestedNegation(positive);
25
+ for (const exclude of excludes) {
26
+ validateNoNestedNegation(exclude);
27
+ }
28
+
29
+ const match = compilePositiveNode(positive);
30
+ const relaxedMatch = compilePositiveNode(positive, "OR");
31
+
32
+ return {
33
+ match,
34
+ relaxedMatch: relaxedMatch === match ? undefined : relaxedMatch,
35
+ excludes: excludes.map((exclude) => compilePositiveNode(exclude)),
36
+ positiveTerms: collectPositiveTerms(positive),
37
+ };
38
+ }
39
+
40
+ interface SplitQuery {
41
+ positive: SearchQueryNode | undefined;
42
+ excludes: SearchQueryNode[];
43
+ }
44
+
45
+ function splitTopLevelExcludes(node: SearchQueryNode): SplitQuery {
46
+ if (node.kind === "not") {
47
+ return { positive: undefined, excludes: [node.child] };
48
+ }
49
+
50
+ if (node.kind !== "and") {
51
+ return { positive: node, excludes: [] };
52
+ }
53
+
54
+ const positives: SearchQueryNode[] = [];
55
+ const excludes: SearchQueryNode[] = [];
56
+
57
+ for (const child of flattenAndChildren(node)) {
58
+ if (child.kind === "not") {
59
+ excludes.push(child.child);
60
+ continue;
61
+ }
62
+
63
+ positives.push(child);
64
+ }
65
+
66
+ return {
67
+ positive: buildAndNode(positives),
68
+ excludes,
69
+ };
70
+ }
71
+
72
+ function flattenAndChildren(node: SearchQueryNode): SearchQueryNode[] {
73
+ if (node.kind !== "and") {
74
+ return [node];
75
+ }
76
+
77
+ return node.children.flatMap(flattenAndChildren);
78
+ }
79
+
80
+ function buildAndNode(children: SearchQueryNode[]): SearchQueryNode | undefined {
81
+ if (children.length === 0) {
82
+ return undefined;
83
+ }
84
+
85
+ if (children.length === 1) {
86
+ return children[0];
87
+ }
88
+
89
+ return { kind: "and", children };
90
+ }
91
+
92
+ function validateNoNestedNegation(node: SearchQueryNode): void {
93
+ switch (node.kind) {
94
+ case "not":
95
+ throw new SearchQuerySyntaxError(
96
+ "Negation is only supported as a top-level AND clause in this release.",
97
+ );
98
+ case "and":
99
+ case "or":
100
+ case "adjacency":
101
+ for (const child of node.children) {
102
+ validateNoNestedNegation(child);
103
+ }
104
+ return;
105
+ case "term":
106
+ return;
107
+ }
108
+ }
109
+
110
+ function compilePositiveNode(node: SearchQueryNode, adjacencyOp: "AND" | "OR" = "AND"): string {
111
+ switch (node.kind) {
112
+ case "term":
113
+ return compileTerm(node.value, node.prefix);
114
+ case "and":
115
+ return `(${node.children.map((child) => compilePositiveNode(child, adjacencyOp)).join(" AND ")})`;
116
+ case "or":
117
+ return `(${node.children.map((child) => compilePositiveNode(child, adjacencyOp)).join(" OR ")})`;
118
+ case "adjacency":
119
+ return `(${node.children.map((child) => compileTerm(child.value, child.prefix)).join(` ${adjacencyOp} `)})`;
120
+ case "not":
121
+ throw new SearchQuerySyntaxError(
122
+ "Negation is only supported as a top-level AND clause in this release.",
123
+ );
124
+ }
125
+ }
126
+
127
+ function compileTerm(value: string, prefix: boolean): string {
128
+ return `${quoteFtsValue(value)}${prefix ? "*" : ""}`;
129
+ }
130
+
131
+ function quoteFtsValue(value: string): string {
132
+ return `"${value.replace(/"/g, '""')}"`;
133
+ }
134
+
135
+ function collectPositiveTerms(node: SearchQueryNode): string[] {
136
+ switch (node.kind) {
137
+ case "term":
138
+ return [node.value];
139
+ case "and":
140
+ case "or":
141
+ case "adjacency":
142
+ return node.children.flatMap(collectPositiveTerms);
143
+ case "not":
144
+ return [];
145
+ }
146
+ }