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.
- 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 +24 -3
- 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 +5 -4
- /package/extensions/{session-messaging/shared → shared/session-broker}/socket-path.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type QueryAdjacencyNode,
|
|
3
|
+
type QueryAndNode,
|
|
4
|
+
type QueryNotNode,
|
|
5
|
+
type QueryOrNode,
|
|
6
|
+
type QueryTermNode,
|
|
7
|
+
type SearchQueryNode,
|
|
8
|
+
SearchQuerySyntaxError,
|
|
9
|
+
} from "./ast.ts";
|
|
10
|
+
import { lexSearchQuery, type QueryToken } from "./lexer.ts";
|
|
11
|
+
|
|
12
|
+
export function parseSearchQuery(input: string): SearchQueryNode | undefined {
|
|
13
|
+
const tokens = lexSearchQuery(input);
|
|
14
|
+
if (tokens.length === 0) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const parser = new Parser(tokens);
|
|
19
|
+
return parser.parse();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class Parser {
|
|
23
|
+
private index = 0;
|
|
24
|
+
|
|
25
|
+
constructor(private readonly tokens: QueryToken[]) {}
|
|
26
|
+
|
|
27
|
+
parse(): SearchQueryNode {
|
|
28
|
+
const node = this.parseOr();
|
|
29
|
+
const token = this.peek();
|
|
30
|
+
if (token) {
|
|
31
|
+
throw new SearchQuerySyntaxError(`Unexpected token at offset ${token.offset}`);
|
|
32
|
+
}
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private parseOr(): SearchQueryNode {
|
|
37
|
+
const children = [this.parseAnd()];
|
|
38
|
+
|
|
39
|
+
while (this.match("or")) {
|
|
40
|
+
children.push(this.parseAnd());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return buildOrNode(children);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private parseAnd(): SearchQueryNode {
|
|
47
|
+
const children = [this.parseAdjacencyRun()];
|
|
48
|
+
|
|
49
|
+
while (true) {
|
|
50
|
+
if (this.match("and")) {
|
|
51
|
+
children.push(this.parseAdjacencyRun());
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (this.startsUnary(this.peek())) {
|
|
56
|
+
children.push(this.parseAdjacencyRun());
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return buildAndNode(children);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private parseAdjacencyRun(): SearchQueryNode {
|
|
67
|
+
const first = this.peek();
|
|
68
|
+
if (!isUnquotedTerm(first)) {
|
|
69
|
+
return this.parseUnary();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const children: QueryTermNode[] = [];
|
|
73
|
+
while (true) {
|
|
74
|
+
const token = this.peek();
|
|
75
|
+
if (!isUnquotedTerm(token)) {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.index += 1;
|
|
80
|
+
children.push({
|
|
81
|
+
kind: "term",
|
|
82
|
+
value: token.value,
|
|
83
|
+
quoted: token.quoted,
|
|
84
|
+
prefix: token.prefix,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return buildAdjacencyNode(children);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private parseUnary(): SearchQueryNode {
|
|
92
|
+
const token = this.peek();
|
|
93
|
+
if (token?.kind === "not") {
|
|
94
|
+
this.index += 1;
|
|
95
|
+
const child = this.parseUnary();
|
|
96
|
+
return { kind: "not", child } satisfies QueryNotNode;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return this.parsePrimary();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private parsePrimary(): SearchQueryNode {
|
|
103
|
+
const token = this.peek();
|
|
104
|
+
if (!token) {
|
|
105
|
+
throw new SearchQuerySyntaxError("Unexpected end of query");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (token.kind === "term") {
|
|
109
|
+
this.index += 1;
|
|
110
|
+
return {
|
|
111
|
+
kind: "term",
|
|
112
|
+
value: token.value,
|
|
113
|
+
quoted: token.quoted,
|
|
114
|
+
prefix: token.prefix,
|
|
115
|
+
} satisfies QueryTermNode;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (token.kind === "leftParen") {
|
|
119
|
+
this.index += 1;
|
|
120
|
+
const node = this.parseOr();
|
|
121
|
+
const next = this.peek();
|
|
122
|
+
if (next?.kind !== "rightParen") {
|
|
123
|
+
throw new SearchQuerySyntaxError(`Unclosed parenthesis at offset ${token.offset}`);
|
|
124
|
+
}
|
|
125
|
+
this.index += 1;
|
|
126
|
+
return node;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
throw new SearchQuerySyntaxError(`Expected term at offset ${token.offset}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private startsUnary(token: QueryToken | undefined): boolean {
|
|
133
|
+
return token?.kind === "term" || token?.kind === "leftParen" || token?.kind === "not";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private match(kind: "and" | "or"): boolean {
|
|
137
|
+
if (this.peek()?.kind !== kind) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.index += 1;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private peek(): QueryToken | undefined {
|
|
146
|
+
return this.tokens[this.index];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildAndNode(children: SearchQueryNode[]): SearchQueryNode {
|
|
151
|
+
if (children.length === 1) {
|
|
152
|
+
return children[0] as SearchQueryNode;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { kind: "and", children } satisfies QueryAndNode;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildOrNode(children: SearchQueryNode[]): SearchQueryNode {
|
|
159
|
+
if (children.length === 1) {
|
|
160
|
+
return children[0] as SearchQueryNode;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { kind: "or", children } satisfies QueryOrNode;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildAdjacencyNode(children: QueryTermNode[]): SearchQueryNode {
|
|
167
|
+
if (children.length === 1) {
|
|
168
|
+
return children[0] as SearchQueryNode;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { kind: "adjacency", children } satisfies QueryAdjacencyNode;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isUnquotedTerm(
|
|
175
|
+
token: QueryToken | undefined,
|
|
176
|
+
): token is Extract<QueryToken, { kind: "term" }> {
|
|
177
|
+
return token?.kind === "term" && !token.quoted;
|
|
178
|
+
}
|
|
@@ -18,10 +18,11 @@ export function ensureIndexDir(dir: string): string {
|
|
|
18
18
|
|
|
19
19
|
export function openIndexDatabase(
|
|
20
20
|
dbPath: string,
|
|
21
|
-
options?: { create?: boolean; timeoutMs?: number },
|
|
21
|
+
options?: { create?: boolean; mode?: "read" | "write"; timeoutMs?: number | undefined },
|
|
22
22
|
): SessionIndexDatabase {
|
|
23
23
|
const create = options?.create ?? true;
|
|
24
|
-
|
|
24
|
+
const readonly = options?.mode === "read";
|
|
25
|
+
return openSqlite(dbPath, { create, readonly, timeoutMs: options?.timeoutMs });
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export function initializeSchema(db: SessionIndexDatabase): void {
|
|
@@ -61,6 +62,17 @@ export function initializeSchema(db: SessionIndexDatabase): void {
|
|
|
61
62
|
CREATE INDEX IF NOT EXISTS sessions_parent_id_idx ON sessions(parent_session_id);
|
|
62
63
|
CREATE INDEX IF NOT EXISTS sessions_parent_path_idx ON sessions(parent_session_path);
|
|
63
64
|
|
|
65
|
+
CREATE TABLE IF NOT EXISTS session_repo_roots (
|
|
66
|
+
session_id TEXT NOT NULL,
|
|
67
|
+
repo_root TEXT NOT NULL,
|
|
68
|
+
repo_basename TEXT NOT NULL,
|
|
69
|
+
PRIMARY KEY (session_id, repo_root),
|
|
70
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE INDEX IF NOT EXISTS session_repo_roots_root_idx ON session_repo_roots(repo_root);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS session_repo_roots_basename_idx ON session_repo_roots(repo_basename);
|
|
75
|
+
|
|
64
76
|
CREATE TABLE IF NOT EXISTS session_lineage_relations (
|
|
65
77
|
session_id TEXT NOT NULL,
|
|
66
78
|
related_session_id TEXT NOT NULL,
|
|
@@ -79,7 +91,7 @@ export function initializeSchema(db: SessionIndexDatabase): void {
|
|
|
79
91
|
CREATE TABLE IF NOT EXISTS session_text_chunks (
|
|
80
92
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
81
93
|
session_id TEXT NOT NULL,
|
|
82
|
-
entry_id TEXT,
|
|
94
|
+
entry_id TEXT NOT NULL,
|
|
83
95
|
entry_type TEXT NOT NULL,
|
|
84
96
|
role TEXT,
|
|
85
97
|
ts TEXT NOT NULL,
|
|
@@ -94,7 +106,8 @@ export function initializeSchema(db: SessionIndexDatabase): void {
|
|
|
94
106
|
CREATE VIRTUAL TABLE IF NOT EXISTS session_text_chunks_fts USING fts5(
|
|
95
107
|
text,
|
|
96
108
|
content='session_text_chunks',
|
|
97
|
-
content_rowid='id'
|
|
109
|
+
content_rowid='id',
|
|
110
|
+
tokenize = 'unicode61 remove_diacritics 2 tokenchars ''_'''
|
|
98
111
|
);
|
|
99
112
|
|
|
100
113
|
CREATE TRIGGER IF NOT EXISTS session_text_chunks_fts_ai
|
|
@@ -164,8 +177,9 @@ export function getIndexStatus(dbPath: string): SessionIndexStatus {
|
|
|
164
177
|
return { dbPath, exists: false };
|
|
165
178
|
}
|
|
166
179
|
|
|
167
|
-
|
|
180
|
+
let db: SessionIndexDatabase | undefined;
|
|
168
181
|
try {
|
|
182
|
+
db = openIndexDatabase(dbPath, { create: false, mode: "read" });
|
|
169
183
|
const schemaVersionRaw = getMetadata(db, "schema_version");
|
|
170
184
|
const lastFullReindexAt = getMetadata(db, "indexed_at");
|
|
171
185
|
const sessionCountRow = parseTypeBoxValue(
|
|
@@ -181,7 +195,9 @@ export function getIndexStatus(dbPath: string): SessionIndexStatus {
|
|
|
181
195
|
sessionCount: sessionCountRow.count,
|
|
182
196
|
lastFullReindexAt,
|
|
183
197
|
};
|
|
198
|
+
} catch {
|
|
199
|
+
return { dbPath, exists: true };
|
|
184
200
|
} finally {
|
|
185
|
-
db
|
|
201
|
+
db?.close();
|
|
186
202
|
}
|
|
187
203
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const SESSION_ID_EXACT_SCORE = 100_000;
|
|
2
|
+
const SESSION_ID_PREFIX_SCORE = 80_000;
|
|
3
|
+
const SESSION_ID_SUBSTRING_SCORE = 60_000;
|
|
4
|
+
const RECENCY_MAX_SCORE = 300;
|
|
5
|
+
const RECENCY_HALF_LIFE_DAYS = 21;
|
|
6
|
+
|
|
7
|
+
const TEXT_SOURCE_WEIGHTS = new Map<string, number>([
|
|
8
|
+
["session_name", 360],
|
|
9
|
+
["handoff_next_task", 300],
|
|
10
|
+
["handoff_goal", 260],
|
|
11
|
+
["user_text", 180],
|
|
12
|
+
["assistant_text", 150],
|
|
13
|
+
["bash_command", 140],
|
|
14
|
+
["tool_result", 110],
|
|
15
|
+
["bash_output", 90],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const MAX_SCORING_TEXT_HITS_PER_SESSION = 5;
|
|
19
|
+
export const MAX_TEXT_EVIDENCE_PER_SESSION = 20;
|
|
20
|
+
export const DEFAULT_SESSION_SEARCH_LIMIT = 50;
|
|
21
|
+
export const MAX_SESSION_SEARCH_LIMIT = 500;
|
|
22
|
+
export const MIN_TEXT_OVERFETCH_LIMIT = 200;
|
|
23
|
+
export const TEXT_OVERFETCH_MULTIPLIER = 25;
|
|
24
|
+
export const MAX_TEXT_OVERFETCH_LIMIT = 5_000;
|
|
25
|
+
export const MAX_FILTERED_SESSION_CANDIDATES = 10_000;
|
|
26
|
+
|
|
27
|
+
export type SessionIdMatchKind = "exact" | "prefix" | "substring";
|
|
28
|
+
|
|
29
|
+
export interface SessionIdScore {
|
|
30
|
+
kind: SessionIdMatchKind;
|
|
31
|
+
score: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function scoreSessionIdMatch(kind: SessionIdMatchKind, matchedLength = 0): number {
|
|
35
|
+
switch (kind) {
|
|
36
|
+
case "exact":
|
|
37
|
+
return SESSION_ID_EXACT_SCORE;
|
|
38
|
+
case "prefix":
|
|
39
|
+
return SESSION_ID_PREFIX_SCORE + matchedLength;
|
|
40
|
+
case "substring":
|
|
41
|
+
return SESSION_ID_SUBSTRING_SCORE + matchedLength;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function scoreTextHit(sourceKind: string, rankIndex: number): number {
|
|
46
|
+
return getSourceWeight(sourceKind) / (rankIndex + 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function scoreRecency(modifiedAt: string, nowMs: number): number {
|
|
50
|
+
const modifiedMs = new Date(modifiedAt).getTime();
|
|
51
|
+
if (Number.isNaN(modifiedMs)) {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ageDays = Math.max(0, (nowMs - modifiedMs) / 86_400_000);
|
|
56
|
+
return RECENCY_MAX_SCORE * 0.5 ** (ageDays / RECENCY_HALF_LIFE_DAYS);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildTextOverfetchLimit(resultLimit: number): number {
|
|
60
|
+
return Math.min(
|
|
61
|
+
MAX_TEXT_OVERFETCH_LIMIT,
|
|
62
|
+
Math.max(MIN_TEXT_OVERFETCH_LIMIT, resultLimit * TEXT_OVERFETCH_MULTIPLIER),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function normalizeResultLimit(value: number | undefined): number {
|
|
67
|
+
if (value === undefined) {
|
|
68
|
+
return DEFAULT_SESSION_SEARCH_LIMIT;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
72
|
+
throw new Error("limit must be greater than 0");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return Math.min(MAX_SESSION_SEARCH_LIMIT, Math.trunc(value));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getSourceWeight(sourceKind: string): number {
|
|
79
|
+
return TEXT_SOURCE_WEIGHTS.get(sourceKind) ?? 120;
|
|
80
|
+
}
|