pi-sessions 0.5.1 → 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 -1
- package/extensions/session-ask/agent.ts +400 -0
- package/extensions/session-ask/navigate.ts +880 -0
- package/extensions/session-ask.ts +82 -134
- package/extensions/session-auto-title/command.ts +1 -1
- package/extensions/session-auto-title/controller.ts +3 -3
- package/extensions/session-auto-title/generate.ts +2 -2
- package/extensions/session-auto-title/model.ts +4 -12
- package/extensions/session-auto-title/retitle.ts +5 -5
- package/extensions/session-auto-title/state.ts +1 -1
- package/extensions/session-auto-title/wizard.ts +4 -4
- package/extensions/session-auto-title.ts +8 -8
- package/extensions/session-handoff/extract.ts +18 -5
- package/extensions/session-handoff/metadata.ts +4 -1
- package/extensions/session-handoff/picker.ts +52 -6
- package/extensions/session-handoff/query.ts +78 -62
- package/extensions/session-handoff/refs.ts +14 -20
- package/extensions/session-handoff/spawn.ts +1 -1
- package/extensions/session-handoff.ts +54 -35
- package/extensions/session-hooks.ts +3 -3
- package/extensions/session-index.ts +4 -4
- package/extensions/session-messaging/broker/process.ts +302 -0
- package/extensions/session-messaging/broker/spawn.ts +164 -0
- package/extensions/session-messaging/pi/incoming-runtime.ts +114 -0
- package/extensions/session-messaging/pi/message-contracts.ts +36 -0
- package/extensions/session-messaging/pi/message-view.ts +131 -0
- package/extensions/session-messaging/pi/renderer.ts +16 -0
- package/extensions/session-messaging/pi/service.ts +370 -0
- package/extensions/session-messaging/pi/tools.ts +92 -0
- package/extensions/session-messaging.ts +30 -0
- package/extensions/session-search/extract.ts +90 -440
- package/extensions/session-search/hooks.ts +20 -28
- package/extensions/session-search/normalize.ts +1 -1
- package/extensions/session-search/reindex.ts +3 -2
- package/extensions/session-search.ts +161 -132
- package/extensions/shared/model.ts +18 -0
- package/extensions/shared/session-broker/active.ts +26 -0
- package/extensions/shared/session-broker/client.ts +253 -0
- package/extensions/shared/session-broker/framing.ts +72 -0
- package/extensions/shared/session-broker/protocol.ts +95 -0
- package/extensions/shared/session-broker/socket-path.ts +30 -0
- package/extensions/shared/session-index/access.ts +128 -0
- package/extensions/shared/session-index/common.ts +46 -51
- package/extensions/shared/session-index/index.ts +6 -5
- package/extensions/shared/session-index/lineage.ts +19 -2
- 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 +25 -9
- package/extensions/shared/session-index/scoring.ts +80 -0
- package/extensions/shared/session-index/search.ts +555 -281
- package/extensions/shared/session-index/sqlite.ts +16 -9
- package/extensions/shared/session-index/store.ts +14 -23
- package/extensions/shared/settings.ts +62 -5
- package/extensions/shared/text.ts +50 -0
- package/package.json +4 -3
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
-
import type { FileTouchOp, FileTouchSource, PathScope } from "../../session-search/normalize.
|
|
3
|
-
import { safeParseTypeBoxJson } from "../typebox.
|
|
4
|
-
import type { SqliteDatabase } from "./sqlite.
|
|
2
|
+
import type { FileTouchOp, FileTouchSource, PathScope } from "../../session-search/normalize.ts";
|
|
3
|
+
import { safeParseTypeBoxJson } from "../typebox.ts";
|
|
4
|
+
import type { SqliteDatabase } from "./sqlite.ts";
|
|
5
5
|
|
|
6
|
-
export const INDEX_SCHEMA_VERSION =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,5 +1,6 @@
|
|
|
1
|
-
export * from "./
|
|
2
|
-
export * from "./
|
|
3
|
-
export * from "./
|
|
4
|
-
export * from "./
|
|
5
|
-
export * from "./
|
|
1
|
+
export * from "./access.ts";
|
|
2
|
+
export * from "./common.ts";
|
|
3
|
+
export * from "./lineage.ts";
|
|
4
|
+
export * from "./schema.ts";
|
|
5
|
+
export * from "./search.ts";
|
|
6
|
+
export * from "./store.ts";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Static, Type } from "typebox";
|
|
2
|
-
import { parseTypeBoxRows, parseTypeBoxValue } from "../typebox.
|
|
2
|
+
import { parseTypeBoxRows, parseTypeBoxValue } from "../typebox.ts";
|
|
3
3
|
import {
|
|
4
4
|
NULLABLE_STRING_SCHEMA,
|
|
5
5
|
parseRepoRoots,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type SessionLineageRelation,
|
|
10
10
|
type SessionLineageRow,
|
|
11
11
|
type SessionRelatedSessionRow,
|
|
12
|
-
} from "./common.
|
|
12
|
+
} from "./common.ts";
|
|
13
13
|
|
|
14
14
|
const SESSION_GRAPH_ROW_SCHEMA = Type.Object({
|
|
15
15
|
sessionId: Type.String(),
|
|
@@ -160,6 +160,13 @@ export function getLineageSessions(
|
|
|
160
160
|
return queryRelatedSessions(db, sessionId);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
export function getLineageRelationMap(
|
|
164
|
+
db: SessionIndexDatabase,
|
|
165
|
+
sessionId: string,
|
|
166
|
+
): Map<string, SessionLineageRelation> {
|
|
167
|
+
return new Map(getLineageSessions(db, sessionId).map((row) => [row.sessionId, row.relation]));
|
|
168
|
+
}
|
|
169
|
+
|
|
163
170
|
export function getParentSession(
|
|
164
171
|
db: SessionIndexDatabase,
|
|
165
172
|
sessionId: string,
|
|
@@ -339,6 +346,7 @@ function queryRelatedSessions(
|
|
|
339
346
|
WHERE r.session_id = ?${relationFilter}
|
|
340
347
|
ORDER BY
|
|
341
348
|
CASE r.relation
|
|
349
|
+
WHEN 'self' THEN 0
|
|
342
350
|
WHEN 'parent' THEN 1
|
|
343
351
|
WHEN 'child' THEN 2
|
|
344
352
|
WHEN 'sibling' THEN 3
|
|
@@ -364,6 +372,13 @@ function collectMaterializedLineageRows(
|
|
|
364
372
|
childrenByParent: Map<string, string[]>,
|
|
365
373
|
): Map<string, MaterializedLineageRow> {
|
|
366
374
|
const relations = new Map<string, MaterializedLineageRow>();
|
|
375
|
+
setMaterializedLineageRow(relations, {
|
|
376
|
+
sessionId,
|
|
377
|
+
relatedSessionId: sessionId,
|
|
378
|
+
relation: "self",
|
|
379
|
+
distance: 0,
|
|
380
|
+
});
|
|
381
|
+
|
|
367
382
|
const visitedAncestors = new Set<string>();
|
|
368
383
|
const ancestors: Array<{ sessionId: string; distance: number }> = [];
|
|
369
384
|
|
|
@@ -469,6 +484,8 @@ function setMaterializedLineageRow(
|
|
|
469
484
|
|
|
470
485
|
function getLineageRelationPriority(relation: SessionLineageRelation): number {
|
|
471
486
|
switch (relation) {
|
|
487
|
+
case "self":
|
|
488
|
+
return 0;
|
|
472
489
|
case "parent":
|
|
473
490
|
return 1;
|
|
474
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
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { SearchQuerySyntaxError } from "./ast.ts";
|
|
2
|
+
|
|
3
|
+
export type QueryToken =
|
|
4
|
+
| { kind: "term"; value: string; quoted: boolean; prefix: boolean; offset: number }
|
|
5
|
+
| { kind: "and"; offset: number }
|
|
6
|
+
| { kind: "or"; offset: number }
|
|
7
|
+
| { kind: "not"; offset: number }
|
|
8
|
+
| { kind: "leftParen"; offset: number }
|
|
9
|
+
| { kind: "rightParen"; offset: number };
|
|
10
|
+
|
|
11
|
+
export function lexSearchQuery(input: string): QueryToken[] {
|
|
12
|
+
const tokens: QueryToken[] = [];
|
|
13
|
+
let index = 0;
|
|
14
|
+
|
|
15
|
+
while (index < input.length) {
|
|
16
|
+
const char = input[index];
|
|
17
|
+
if (isWhitespace(char)) {
|
|
18
|
+
index += 1;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (char === "(") {
|
|
23
|
+
tokens.push({ kind: "leftParen", offset: index });
|
|
24
|
+
index += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (char === ")") {
|
|
29
|
+
tokens.push({ kind: "rightParen", offset: index });
|
|
30
|
+
index += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (char === '"') {
|
|
35
|
+
const token = readQuotedTerm(input, index);
|
|
36
|
+
tokens.push(token.token);
|
|
37
|
+
index = token.nextIndex;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (char === "-" && isNegationBoundary(input, index)) {
|
|
42
|
+
tokens.push({ kind: "not", offset: index });
|
|
43
|
+
index += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const token = readUnquotedToken(input, index);
|
|
48
|
+
tokens.push(token.token);
|
|
49
|
+
index = token.nextIndex;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return tokens;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readQuotedTerm(input: string, start: number): { token: QueryToken; nextIndex: number } {
|
|
56
|
+
let index = start + 1;
|
|
57
|
+
let value = "";
|
|
58
|
+
|
|
59
|
+
while (index < input.length) {
|
|
60
|
+
const char = input[index];
|
|
61
|
+
if (char === '"') {
|
|
62
|
+
const afterQuote = index + 1;
|
|
63
|
+
const hasPrefix = input[afterQuote] === "*";
|
|
64
|
+
if (value.length === 0) {
|
|
65
|
+
throw new SearchQuerySyntaxError(`Empty quoted term at offset ${start}`);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
token: { kind: "term", value, quoted: true, prefix: hasPrefix, offset: start },
|
|
69
|
+
nextIndex: hasPrefix ? afterQuote + 1 : afterQuote,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
value += char;
|
|
74
|
+
index += 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new SearchQuerySyntaxError(`Unclosed quote at offset ${start}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readUnquotedToken(input: string, start: number): { token: QueryToken; nextIndex: number } {
|
|
81
|
+
let index = start;
|
|
82
|
+
let value = "";
|
|
83
|
+
|
|
84
|
+
while (index < input.length) {
|
|
85
|
+
const char = input[index];
|
|
86
|
+
if (isWhitespace(char) || char === "(" || char === ")" || char === '"') {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
value += char;
|
|
91
|
+
index += 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (value.length === 0) {
|
|
95
|
+
throw new SearchQuerySyntaxError(`Unexpected character at offset ${start}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const upper = value.toUpperCase();
|
|
99
|
+
if (upper === "AND") {
|
|
100
|
+
return { token: { kind: "and", offset: start }, nextIndex: index };
|
|
101
|
+
}
|
|
102
|
+
if (upper === "OR") {
|
|
103
|
+
return { token: { kind: "or", offset: start }, nextIndex: index };
|
|
104
|
+
}
|
|
105
|
+
if (upper === "NOT") {
|
|
106
|
+
return { token: { kind: "not", offset: start }, nextIndex: index };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const firstStar = value.indexOf("*");
|
|
110
|
+
if (firstStar >= 0 && firstStar !== value.length - 1) {
|
|
111
|
+
throw new SearchQuerySyntaxError(`Only trailing * is supported at offset ${start}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const hasPrefix = value.endsWith("*");
|
|
115
|
+
const termValue = hasPrefix ? value.slice(0, -1) : value;
|
|
116
|
+
if (termValue.length === 0) {
|
|
117
|
+
throw new SearchQuerySyntaxError(`Empty prefix term at offset ${start}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
token: { kind: "term", value: termValue, quoted: false, prefix: true, offset: start },
|
|
122
|
+
nextIndex: index,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isNegationBoundary(input: string, index: number): boolean {
|
|
127
|
+
if (index > 0) {
|
|
128
|
+
const previous = input[index - 1];
|
|
129
|
+
if (!isWhitespace(previous) && previous !== "(") {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const next = input[index + 1];
|
|
135
|
+
return next !== undefined && !isWhitespace(next) && next !== ")";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isWhitespace(value: string | undefined): boolean {
|
|
139
|
+
return value !== undefined && /\s/.test(value);
|
|
140
|
+
}
|