@vanillagreen/pi-qol 2.2.1 → 2.3.4
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 +13 -0
- package/extensions/qol/compaction-handoff.ts +6 -19
- package/extensions/qol/compaction.ts +1 -1
- package/extensions/qol/constants.ts +12 -1
- package/extensions/qol/glyphs.ts +1 -1
- package/extensions/qol/images.ts +41 -6
- package/extensions/qol/notifications.ts +14 -1
- package/extensions/qol/package-config.ts +445 -0
- package/extensions/qol/session-search/cache.ts +175 -46
- package/extensions/qol/session-search/component.ts +104 -14
- package/extensions/qol/session-search/index.ts +28 -18
- package/extensions/qol/session-search/jsonl.ts +33 -1
- package/extensions/qol/session-search/regex-worker.mjs +12 -0
- package/extensions/qol/session-search/regex.ts +49 -0
- package/extensions/qol/session-search/search.ts +60 -166
- package/extensions/qol/session-search/types.ts +10 -0
- package/extensions/qol/settings.ts +5 -100
- package/extensions/qol.ts +50 -12
- package/package.json +4 -3
- package/scripts/lane-retention.ts +187 -0
|
@@ -1,6 +1,38 @@
|
|
|
1
|
-
import { closeSync, openSync, readSync } from "node:fs";
|
|
1
|
+
import { closeSync, createReadStream, openSync, readSync } from "node:fs";
|
|
2
2
|
import { StringDecoder } from "node:string_decoder";
|
|
3
3
|
|
|
4
|
+
/** Stream records off the input path. Index reads skip oversized records;
|
|
5
|
+
* an action can read a complete prompt and stop at its selected entry. */
|
|
6
|
+
export async function forEachSessionJsonlLineAsync(sessionPath: string, onLine: (line: string) => unknown, signal: AbortSignal, options?: { maxLineChars: number }): Promise<void> {
|
|
7
|
+
const stream = createReadStream(sessionPath, { signal, highWaterMark: 64 * 1024 });
|
|
8
|
+
const decoder = new StringDecoder("utf8");
|
|
9
|
+
const maxLineChars = options?.maxLineChars ?? 2 * 1024 * 1024;
|
|
10
|
+
let pending = "";
|
|
11
|
+
let skipping = false;
|
|
12
|
+
try {
|
|
13
|
+
for await (const chunk of stream) {
|
|
14
|
+
signal.throwIfAborted();
|
|
15
|
+
const text = decoder.write(chunk);
|
|
16
|
+
let start = 0;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const end = text.indexOf("\n", start);
|
|
19
|
+
const part = text.slice(start, end < 0 ? undefined : end);
|
|
20
|
+
if (!skipping && pending.length + part.length <= maxLineChars) pending += part;
|
|
21
|
+
else { pending = ""; skipping = true; }
|
|
22
|
+
if (end < 0) break;
|
|
23
|
+
if (!skipping && onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending) === false) return;
|
|
24
|
+
pending = "";
|
|
25
|
+
skipping = false;
|
|
26
|
+
start = end + 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
pending += decoder.end();
|
|
30
|
+
if (!skipping && pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
|
|
31
|
+
} finally {
|
|
32
|
+
stream.destroy();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
4
36
|
export function forEachSessionJsonlLine(sessionPath: string, onLine: (line: string) => void, chunkSize = 64 * 1024): void {
|
|
5
37
|
const fd = openSync(sessionPath, "r");
|
|
6
38
|
const buffer = Buffer.allocUnsafe(Math.max(1, Math.floor(chunkSize)));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
|
|
3
|
+
// The parent owns the deadline and terminates this worker even when exec
|
|
4
|
+
// never returns. Neither prompt matching nor snippet matching runs in Pi.
|
|
5
|
+
parentPort.on("message", ({ source, texts }) => {
|
|
6
|
+
const regex = new RegExp(source, "i");
|
|
7
|
+
parentPort.postMessage({ status: "matched", matches: texts.map((text) => {
|
|
8
|
+
const match = regex.exec(text);
|
|
9
|
+
return match ? { index: match.index, length: match[0].length } : null;
|
|
10
|
+
}) });
|
|
11
|
+
});
|
|
12
|
+
parentPort.postMessage({ status: "ready" });
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
|
|
3
|
+
export interface SessionRegexMatch { index: number; length: number }
|
|
4
|
+
type WorkerReply = { status: "ready" } | { status: "matched"; matches: Array<SessionRegexMatch | null> };
|
|
5
|
+
|
|
6
|
+
/** Run one query's matching in a disposable worker. User-entered regex can
|
|
7
|
+
* backtrack forever, so the owner terminates it rather than timing exec. */
|
|
8
|
+
export function sessionRegexMatches(source: string, texts: string[], signal: AbortSignal): Promise<Array<SessionRegexMatch | null>> {
|
|
9
|
+
signal.throwIfAborted();
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const worker = new Worker(new URL("./regex-worker.mjs", import.meta.url), { execArgv: [], env: {} });
|
|
12
|
+
let settled = false;
|
|
13
|
+
// Startup is separate from the 25 ms execution budget.
|
|
14
|
+
let timer = setTimeout(() => finish(new Error("SESSION_SEARCH_REGEX_DEADLINE: worker startup")), 1000);
|
|
15
|
+
const finish = (error?: Error, matches?: Array<SessionRegexMatch | null>) => {
|
|
16
|
+
if (settled) return;
|
|
17
|
+
settled = true;
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
signal.removeEventListener("abort", abort);
|
|
20
|
+
worker.removeAllListeners("message");
|
|
21
|
+
// A runtime can defer thread exit until native regex execution returns.
|
|
22
|
+
// Request cleanup without making input wait for that exit.
|
|
23
|
+
worker.unref();
|
|
24
|
+
void worker.terminate().catch((cleanupError: unknown) => {
|
|
25
|
+
console.error("Session regex worker cleanup failed:", cleanupError);
|
|
26
|
+
});
|
|
27
|
+
if (error) reject(error);
|
|
28
|
+
else resolve(matches!);
|
|
29
|
+
};
|
|
30
|
+
const abort = () => finish(new Error("Session search cancelled"));
|
|
31
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
32
|
+
worker.on("message", (reply: WorkerReply) => {
|
|
33
|
+
if (settled) return;
|
|
34
|
+
switch (reply.status) {
|
|
35
|
+
case "ready":
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
timer = setTimeout(() => finish(new Error("SESSION_SEARCH_REGEX_DEADLINE: 25 ms")), 25);
|
|
38
|
+
worker.postMessage({ source, texts });
|
|
39
|
+
break;
|
|
40
|
+
case "matched": finish(undefined, reply.matches); break;
|
|
41
|
+
default: { const unexpected: never = reply; finish(new Error(`Unknown regex worker reply: ${unexpected}`)); }
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
worker.once("error", (error) => finish(error));
|
|
45
|
+
worker.once("exit", (code) => {
|
|
46
|
+
if (!settled) finish(new Error(`Session regex worker exited before results: ${code}`));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS,
|
|
4
|
-
} from "../constants.js";
|
|
1
|
+
import { setImmediate as yieldToInput } from "node:timers/promises";
|
|
2
|
+
import { DEFAULT_SESSION_SEARCH_LIMIT } from "../constants.js";
|
|
5
3
|
import { settingNumber } from "../settings.js";
|
|
6
4
|
import { stringifyError } from "../util.js";
|
|
7
5
|
import { sessionDisplayName, sessionResumeTitle, userMessagesForResult } from "./cache.js";
|
|
6
|
+
import { sessionRegexMatches, type SessionRegexMatch } from "./regex.js";
|
|
8
7
|
import type {
|
|
9
8
|
QolParsedSessionQuery,
|
|
10
9
|
QolSessionSearchHit,
|
|
@@ -13,10 +12,6 @@ import type {
|
|
|
13
12
|
QolSessionUserMessage,
|
|
14
13
|
} from "./types.js";
|
|
15
14
|
|
|
16
|
-
export function sessionSearchText(session: QolSessionSearchSession): string {
|
|
17
|
-
return [session.id, session.name ?? "", session.cwd, session.path, session.firstMessage, session.allMessagesText].join("\n");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
15
|
export function normalizeSearchText(text: string): string {
|
|
21
16
|
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
22
17
|
}
|
|
@@ -33,161 +28,57 @@ export function parseSessionSearchQuery(query: string): QolParsedSessionQuery {
|
|
|
33
28
|
return { error: stringifyError(error), mode: "regex", tokens: [] };
|
|
34
29
|
}
|
|
35
30
|
}
|
|
36
|
-
|
|
37
31
|
const tokens: QolParsedSessionQuery["tokens"] = [];
|
|
38
32
|
let buffer = "";
|
|
39
33
|
let inQuote = false;
|
|
40
|
-
let unclosed = false;
|
|
41
34
|
const flush = (kind: "fuzzy" | "phrase") => {
|
|
42
35
|
const value = buffer.trim();
|
|
43
36
|
buffer = "";
|
|
44
37
|
if (value) tokens.push({ kind, value });
|
|
45
38
|
};
|
|
46
|
-
|
|
47
|
-
for (let i = 0; i < trimmed.length; i++) {
|
|
48
|
-
const char = trimmed[i]!;
|
|
39
|
+
for (const char of trimmed) {
|
|
49
40
|
if (char === '"') {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
flush("fuzzy");
|
|
55
|
-
inQuote = true;
|
|
56
|
-
}
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
if (!inQuote && /\s/.test(char)) {
|
|
60
|
-
flush("fuzzy");
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
buffer += char;
|
|
41
|
+
flush(inQuote ? "phrase" : "fuzzy");
|
|
42
|
+
inQuote = !inQuote;
|
|
43
|
+
} else if (!inQuote && /\s/.test(char)) flush("fuzzy");
|
|
44
|
+
else buffer += char;
|
|
64
45
|
}
|
|
65
|
-
if (inQuote)
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
mode: "tokens",
|
|
69
|
-
tokens: trimmed.split(/\s+/).filter(Boolean).map((value) => ({ kind: "fuzzy", value })),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
flush(inQuote ? "phrase" : "fuzzy");
|
|
46
|
+
if (inQuote) return { mode: "tokens", tokens: trimmed.split(/\s+/).filter(Boolean).map((value) => ({ kind: "fuzzy", value })) };
|
|
47
|
+
flush("fuzzy");
|
|
73
48
|
return { mode: "tokens", tokens };
|
|
74
49
|
}
|
|
75
50
|
|
|
76
|
-
function searchWordLengthPenalty(word: string, query: string): number {
|
|
77
|
-
return Math.max(0, word.length - query.length);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
51
|
export function searchStringScore(needle: string, haystack: string): number | undefined {
|
|
81
52
|
const query = normalizeSearchText(needle);
|
|
82
53
|
const text = normalizeSearchText(haystack);
|
|
83
54
|
if (!query) return 0;
|
|
84
55
|
let best: number | undefined;
|
|
85
|
-
const record = (score: number) => {
|
|
86
|
-
best = best === undefined ? score : Math.min(best, score);
|
|
87
|
-
};
|
|
56
|
+
const record = (score: number) => { best = best === undefined ? score : Math.min(best, score); };
|
|
88
57
|
if (/^[a-z0-9_]+$/i.test(query)) {
|
|
89
58
|
for (const match of text.matchAll(/[a-z0-9_]+/gi)) {
|
|
90
59
|
const word = match[0].toLowerCase();
|
|
60
|
+
const penalty = Math.max(0, word.length - query.length);
|
|
91
61
|
if (word === query) record(0);
|
|
92
|
-
else if (word.startsWith(query)) record(10 +
|
|
93
|
-
else if (word.includes(query)) record(100 +
|
|
62
|
+
else if (word.startsWith(query)) record(10 + penalty);
|
|
63
|
+
else if (word.includes(query)) record(100 + penalty);
|
|
94
64
|
}
|
|
95
65
|
return best;
|
|
96
66
|
}
|
|
97
67
|
return text.includes(query) ? 0 : undefined;
|
|
98
68
|
}
|
|
99
69
|
|
|
100
|
-
export function matchSessionSearch(session: QolSessionSearchSession, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
|
|
101
|
-
const text = sessionSearchText(session);
|
|
102
|
-
if (parsed.mode === "regex") {
|
|
103
|
-
if (!parsed.regex) return { matches: false, score: 0 };
|
|
104
|
-
const index = text.search(parsed.regex);
|
|
105
|
-
return index < 0 ? { matches: false, score: 0 } : { matches: true, score: 0 };
|
|
106
|
-
}
|
|
107
|
-
if (parsed.tokens.length === 0) return { matches: true, score: 0 };
|
|
108
|
-
let score = 0;
|
|
109
|
-
for (const token of parsed.tokens) {
|
|
110
|
-
const tokenScore = searchStringScore(token.value, text);
|
|
111
|
-
if (tokenScore === undefined) return { matches: false, score: 0 };
|
|
112
|
-
score += tokenScore;
|
|
113
|
-
}
|
|
114
|
-
return { matches: true, score };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function sessionSnippetSource(session: QolSessionSearchSession): string {
|
|
118
|
-
return (session.allMessagesText || session.firstMessage || "").replace(/\s+/g, " ").trim();
|
|
119
|
-
}
|
|
120
|
-
|
|
121
70
|
function snippetAround(text: string, start: number, length: number, width: number, lead = Math.floor(width / 3)): string {
|
|
122
|
-
const
|
|
123
|
-
const safeStart = Math.max(0, start - safeLead);
|
|
71
|
+
const safeStart = Math.max(0, start - Math.max(0, Math.min(width, lead)));
|
|
124
72
|
const safeEnd = Math.min(text.length, Math.max(start + length, safeStart + width));
|
|
125
|
-
|
|
126
|
-
const suffix = safeEnd < text.length ? "…" : "";
|
|
127
|
-
return `${prefix}${text.slice(safeStart, safeEnd)}${suffix}`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function buildSessionSnippets(session: QolSessionSearchSession, parsed: QolParsedSessionQuery, limit: number): string[] {
|
|
131
|
-
const source = sessionSnippetSource(session);
|
|
132
|
-
if (!source) return [];
|
|
133
|
-
if (parsed.mode === "regex" && parsed.regex) {
|
|
134
|
-
parsed.regex.lastIndex = 0;
|
|
135
|
-
const match = parsed.regex.exec(source);
|
|
136
|
-
return match ? [snippetAround(source, match.index, match[0].length, 220)] : [];
|
|
137
|
-
}
|
|
138
|
-
if (parsed.tokens.length === 0) return [source.slice(0, 220)];
|
|
139
|
-
const snippets: string[] = [];
|
|
140
|
-
const lower = source.toLowerCase();
|
|
141
|
-
const seen = new Set<string>();
|
|
142
|
-
for (const token of parsed.tokens) {
|
|
143
|
-
const value = normalizeSearchText(token.value);
|
|
144
|
-
if (!value) continue;
|
|
145
|
-
const index = lower.indexOf(value.toLowerCase());
|
|
146
|
-
if (index < 0) continue;
|
|
147
|
-
const snippet = snippetAround(source, index, value.length, 220);
|
|
148
|
-
const key = snippet.toLowerCase();
|
|
149
|
-
if (!seen.has(key)) {
|
|
150
|
-
seen.add(key);
|
|
151
|
-
snippets.push(snippet);
|
|
152
|
-
}
|
|
153
|
-
if (snippets.length >= limit) break;
|
|
154
|
-
}
|
|
155
|
-
return snippets.length > 0 ? snippets : [source.slice(0, 220)];
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export function searchQolSessions(sessions: QolSessionSearchSession[], query: string, cwd: string): QolSessionSearchResult[] {
|
|
159
|
-
const limit = Math.max(1, Math.floor(settingNumber("sessionSearch.resultLimit", DEFAULT_SESSION_SEARCH_LIMIT, cwd)));
|
|
160
|
-
const snippetLimit = Math.max(1, Math.floor(settingNumber("sessionSearch.previewSnippets", DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS, cwd)));
|
|
161
|
-
const parsed = parseSessionSearchQuery(query);
|
|
162
|
-
if (parsed.error) return [];
|
|
163
|
-
if (!query.trim()) {
|
|
164
|
-
return sessions
|
|
165
|
-
.slice()
|
|
166
|
-
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
|
|
167
|
-
.slice(0, limit)
|
|
168
|
-
.map((session) => ({ ...session, rank: 0, snippets: buildSessionSnippets(session, { mode: "tokens", tokens: [] }, 1) }));
|
|
169
|
-
}
|
|
170
|
-
const results: QolSessionSearchResult[] = [];
|
|
171
|
-
for (const session of sessions) {
|
|
172
|
-
const match = matchSessionSearch(session, parsed);
|
|
173
|
-
if (!match.matches) continue;
|
|
174
|
-
results.push({ ...session, rank: match.score, snippets: buildSessionSnippets(session, parsed, snippetLimit) });
|
|
175
|
-
}
|
|
176
|
-
results.sort((a, b) => a.rank - b.rank || b.modified.getTime() - a.modified.getTime());
|
|
177
|
-
return results.slice(0, limit);
|
|
73
|
+
return `${safeStart > 0 ? "…" : ""}${text.slice(safeStart, safeEnd)}${safeEnd < text.length ? "…" : ""}`;
|
|
178
74
|
}
|
|
179
75
|
|
|
180
76
|
export function resultFromSession(session: QolSessionSearchSession, rank = 0, snippets: string[] = []): QolSessionSearchResult {
|
|
181
77
|
return { ...session, rank, snippets };
|
|
182
78
|
}
|
|
183
79
|
|
|
184
|
-
function matchTextSearch(text: string, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
|
|
185
|
-
if (parsed.mode === "regex") {
|
|
186
|
-
if (!parsed.regex) return { matches: false, score: 0 };
|
|
187
|
-
const index = text.search(parsed.regex);
|
|
188
|
-
return index < 0 ? { matches: false, score: 0 } : { matches: true, score: 0 };
|
|
189
|
-
}
|
|
190
|
-
if (parsed.tokens.length === 0) return { matches: true, score: 0 };
|
|
80
|
+
function matchTextSearch(text: string, parsed: QolParsedSessionQuery, regexMatches: Map<string, SessionRegexMatch | null>): { matches: boolean; score: number } {
|
|
81
|
+
if (parsed.mode === "regex") return { matches: regexMatches.get(text) != null, score: 0 };
|
|
191
82
|
let score = 0;
|
|
192
83
|
for (const token of parsed.tokens) {
|
|
193
84
|
const tokenScore = searchStringScore(token.value, text);
|
|
@@ -201,24 +92,17 @@ function sessionTitleSearchText(session: QolSessionSearchSession): string {
|
|
|
201
92
|
return [session.name ?? "", sessionResumeTitle(session), sessionDisplayName(session)].join("\n");
|
|
202
93
|
}
|
|
203
94
|
|
|
204
|
-
function
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
export function buildPromptSnippet(message: QolSessionUserMessage, parsed: QolParsedSessionQuery): string {
|
|
95
|
+
export function buildPromptSnippet(message: QolSessionUserMessage, parsed: QolParsedSessionQuery, regexMatch?: SessionRegexMatch | null): string {
|
|
96
|
+
// Worker offsets refer to the original prompt, before whitespace collapses.
|
|
97
|
+
if (parsed.mode === "regex" && regexMatch) return snippetAround(message.text, regexMatch.index, Math.min(regexMatch.length, 160), 160, 24).replace(/\s+/g, " ").trim();
|
|
209
98
|
const source = message.text.replace(/\s+/g, " ").trim();
|
|
210
99
|
if (!source) return "";
|
|
211
|
-
if (parsed.mode === "regex"
|
|
212
|
-
parsed.regex.lastIndex = 0;
|
|
213
|
-
const match = parsed.regex.exec(source);
|
|
214
|
-
return match ? snippetAround(source, match.index, match[0].length, 160, 24) : source.slice(0, 160);
|
|
215
|
-
}
|
|
216
|
-
if (parsed.tokens.length === 0) return source.slice(0, 160);
|
|
100
|
+
if (parsed.mode === "regex") return source.slice(0, 160);
|
|
217
101
|
const lower = source.toLowerCase();
|
|
218
102
|
for (const token of parsed.tokens) {
|
|
219
103
|
const value = normalizeSearchText(token.value);
|
|
220
104
|
if (!value) continue;
|
|
221
|
-
const index = lower.indexOf(value
|
|
105
|
+
const index = lower.indexOf(value);
|
|
222
106
|
if (index >= 0) return snippetAround(source, index, value.length, 160, 24);
|
|
223
107
|
}
|
|
224
108
|
return source.slice(0, 160);
|
|
@@ -235,48 +119,58 @@ function compareSessionSearchHitsRecent(a: QolSessionSearchHit, b: QolSessionSea
|
|
|
235
119
|
|| b.message.index - a.message.index;
|
|
236
120
|
}
|
|
237
121
|
|
|
238
|
-
|
|
122
|
+
/** Search only prepared prompts. Yield between batches, cancel obsolete
|
|
123
|
+
* queries, and run all regex matching in a deadline-owned worker. */
|
|
124
|
+
export async function searchQolSessionHits(sessions: QolSessionSearchSession[], query: string, cwd: string, signal: AbortSignal): Promise<QolSessionSearchHit[]> {
|
|
125
|
+
signal.throwIfAborted();
|
|
239
126
|
const limit = Math.max(1, Math.floor(settingNumber("sessionSearch.resultLimit", DEFAULT_SESSION_SEARCH_LIMIT, cwd)));
|
|
240
127
|
const parsed = parseSessionSearchQuery(query);
|
|
241
|
-
if (parsed.error)
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const snippet = buildPromptSnippet(message, parsed);
|
|
248
|
-
hits.push({ message, rank: 0, result: resultFromSession(session, 0, [snippet]), snippet });
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
hits.sort(compareSessionSearchHitsRecent);
|
|
252
|
-
return hits.slice(0, limit);
|
|
128
|
+
if (parsed.error) throw new Error(parsed.error);
|
|
129
|
+
const regexMatches = new Map<string, SessionRegexMatch | null>();
|
|
130
|
+
if (parsed.mode === "regex" && parsed.regex) {
|
|
131
|
+
const texts = [...new Set(sessions.flatMap((session) => [sessionTitleSearchText(session), ...userMessagesForResult(resultFromSession(session)).map((message) => message.text)]))];
|
|
132
|
+
const matches = await sessionRegexMatches(parsed.regex.source, texts, signal);
|
|
133
|
+
texts.forEach((text, index) => regexMatches.set(text, matches[index]!));
|
|
253
134
|
}
|
|
135
|
+
const hits: QolSessionSearchHit[] = [];
|
|
136
|
+
let batchStarted = performance.now();
|
|
254
137
|
for (const session of sessions) {
|
|
138
|
+
signal.throwIfAborted();
|
|
139
|
+
const messages = userMessagesForResult(resultFromSession(session));
|
|
255
140
|
let addedPromptHit = false;
|
|
256
|
-
for (const message of
|
|
257
|
-
const match =
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
141
|
+
for (const message of messages) {
|
|
142
|
+
const match = matchTextSearch(message.text, parsed, regexMatches);
|
|
143
|
+
if (match.matches) {
|
|
144
|
+
const snippet = buildPromptSnippet(message, parsed, regexMatches.get(message.text));
|
|
145
|
+
hits.push({ message, rank: match.score, result: resultFromSession(session, match.score, [snippet]), snippet });
|
|
146
|
+
addedPromptHit = true;
|
|
147
|
+
}
|
|
148
|
+
if (performance.now() - batchStarted >= 4) {
|
|
149
|
+
await yieldToInput(undefined, { signal });
|
|
150
|
+
batchStarted = performance.now();
|
|
151
|
+
}
|
|
262
152
|
}
|
|
263
153
|
if (!addedPromptHit) {
|
|
264
|
-
const titleMatch = matchTextSearch(sessionTitleSearchText(session), parsed);
|
|
265
|
-
if (!titleMatch.matches) continue;
|
|
266
|
-
const messages = userMessagesForResult(resultFromSession(session));
|
|
154
|
+
const titleMatch = matchTextSearch(sessionTitleSearchText(session), parsed, regexMatches);
|
|
267
155
|
const message = messages[messages.length - 1];
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
156
|
+
if (titleMatch.matches && message) {
|
|
157
|
+
const snippet = buildPromptSnippet(message, { mode: "tokens", tokens: [] });
|
|
158
|
+
hits.push({ message, rank: titleMatch.score, result: resultFromSession(session, titleMatch.score, [snippet]), snippet });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Only the best resultLimit hits need to stay alive between batches.
|
|
162
|
+
if (hits.length > limit * 2) {
|
|
163
|
+
hits.sort((a, b) => a.rank - b.rank || compareSessionSearchHitsRecent(a, b));
|
|
164
|
+
hits.length = limit;
|
|
271
165
|
}
|
|
272
166
|
}
|
|
167
|
+
signal.throwIfAborted();
|
|
273
168
|
hits.sort((a, b) => a.rank - b.rank || compareSessionSearchHitsRecent(a, b));
|
|
274
169
|
return hits.slice(0, limit);
|
|
275
170
|
}
|
|
276
171
|
|
|
277
172
|
export function formatSessionSearchDate(date: Date): string {
|
|
278
|
-
const
|
|
279
|
-
const diffMs = Math.max(0, now - date.getTime());
|
|
173
|
+
const diffMs = Math.max(0, Date.now() - date.getTime());
|
|
280
174
|
const diffMins = Math.floor(diffMs / 60_000);
|
|
281
175
|
const diffHours = Math.floor(diffMs / 3_600_000);
|
|
282
176
|
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
@@ -11,8 +11,18 @@ export interface QolSessionSearchSession {
|
|
|
11
11
|
name?: string;
|
|
12
12
|
parentSessionPath?: string;
|
|
13
13
|
path: string;
|
|
14
|
+
/** Prepared once per index load, not read from disk during a query. */
|
|
15
|
+
canonicalCwd?: string;
|
|
16
|
+
userMessages?: QolSessionUserMessage[];
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
/** The session-search index as the overlay sees it: still loading, loaded,
|
|
20
|
+
* or failed with the load's error. */
|
|
21
|
+
export type QolSessionSearchLoad =
|
|
22
|
+
| { status: "loading" }
|
|
23
|
+
| { status: "ready"; sessions: QolSessionSearchSession[] }
|
|
24
|
+
| { status: "failed"; error: string };
|
|
25
|
+
|
|
16
26
|
export interface QolSessionSearchResult extends QolSessionSearchSession {
|
|
17
27
|
rank: number;
|
|
18
28
|
snippets: string[];
|
|
@@ -1,113 +1,18 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
1
|
import { CONFIG_ID } from "./constants.js";
|
|
2
|
+
import { piSettingsPaths, readPackageConfig, readSettingsFiles } from "./package-config.js";
|
|
5
3
|
|
|
6
4
|
export type kendexConfig = Record<string, unknown>;
|
|
7
5
|
|
|
8
|
-
export function expandHome(input: string): string {
|
|
9
|
-
if (input === "~") return homedir();
|
|
10
|
-
if (input.startsWith("~/")) return join(homedir(), input.slice(2));
|
|
11
|
-
return input;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function projectSettingsPath(cwd: string): string {
|
|
15
|
-
let current = resolve(cwd);
|
|
16
|
-
while (true) {
|
|
17
|
-
const candidate = join(current, ".pi", "settings.json");
|
|
18
|
-
if (existsSync(candidate)) return candidate;
|
|
19
|
-
if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".kendex-lock.json"))) return candidate;
|
|
20
|
-
const parent = dirname(current);
|
|
21
|
-
if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
|
|
22
|
-
current = parent;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const PROJECT_TRUST_SYMBOL = Symbol.for("kendex.pi.project-trust");
|
|
27
|
-
|
|
28
|
-
interface ProjectTrustRegistry {
|
|
29
|
-
projectSettings?: Map<string, boolean>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function projectTrustRegistry(): ProjectTrustRegistry {
|
|
33
|
-
const host = globalThis as unknown as Record<PropertyKey, ProjectTrustRegistry | undefined>;
|
|
34
|
-
const existing = host[PROJECT_TRUST_SYMBOL];
|
|
35
|
-
if (existing) return existing;
|
|
36
|
-
const created: ProjectTrustRegistry = {};
|
|
37
|
-
host[PROJECT_TRUST_SYMBOL] = created;
|
|
38
|
-
return created;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function recordProjectTrust(ctx: { cwd?: string; isProjectTrusted?: () => boolean }): void {
|
|
42
|
-
if (!ctx.cwd) return;
|
|
43
|
-
let trusted = true;
|
|
44
|
-
try {
|
|
45
|
-
trusted = ctx.isProjectTrusted?.() === true;
|
|
46
|
-
} catch {
|
|
47
|
-
trusted = false;
|
|
48
|
-
}
|
|
49
|
-
const registry = projectTrustRegistry();
|
|
50
|
-
if (!registry.projectSettings) registry.projectSettings = new Map();
|
|
51
|
-
registry.projectSettings.set(projectSettingsPath(ctx.cwd), trusted);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function projectSettingsTrusted(settingsPath: string): boolean {
|
|
55
|
-
return projectTrustRegistry().projectSettings?.get(settingsPath) === true;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
|
|
59
|
-
* means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
|
|
60
|
-
* where the renderer does not, putting the two on different roots. Hoisted, so
|
|
61
|
-
* a circular import cannot reach it inside a temporal dead zone. */
|
|
62
|
-
function rootAnchored(path: string, windows: boolean): boolean { return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/"); }
|
|
63
|
-
|
|
64
|
-
export function piSettingsPaths(cwd = process.cwd()): string[] {
|
|
65
|
-
const override = expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "");
|
|
66
|
-
const userDir = resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
|
|
67
|
-
const user = join(userDir, "settings.json");
|
|
68
|
-
const project = projectSettingsPath(cwd);
|
|
69
|
-
return projectSettingsTrusted(project) ? [user, project] : [user];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
73
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Each settings file `piSettingsPaths` names that exists and parses, later
|
|
77
|
-
* files overriding earlier ones. A malformed file is skipped, as Pi's own
|
|
78
|
-
* settings load treats one as empty. */
|
|
79
|
-
function readPiSettingsFiles(cwd?: string): Record<string, any>[] {
|
|
80
|
-
const files: Record<string, any>[] = [];
|
|
81
|
-
for (const settingsPath of piSettingsPaths(cwd)) {
|
|
82
|
-
if (!existsSync(settingsPath)) continue;
|
|
83
|
-
try {
|
|
84
|
-
const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
85
|
-
if (isRecord(parsed)) files.push(parsed);
|
|
86
|
-
} catch {
|
|
87
|
-
// Ignore a malformed settings file.
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return files;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function readPackageConfig(packageId: string, cwd?: string): Record<string, unknown> {
|
|
94
|
-
const merged: Record<string, unknown> = {};
|
|
95
|
-
for (const settings of readPiSettingsFiles(cwd)) {
|
|
96
|
-
const config = settings.kendex?.extensionManager?.config?.[packageId];
|
|
97
|
-
if (isRecord(config)) Object.assign(merged, config);
|
|
98
|
-
}
|
|
99
|
-
return merged;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
6
|
/** Pi core's `compaction.enabled` over the files QOL reads its own config
|
|
103
7
|
* from, resolved as Pi's `SettingsManager.getCompactionEnabled` resolves it:
|
|
104
8
|
* the last file that sets the key decides, a key no file sets is Pi's default,
|
|
105
9
|
* true, and any falsy value turns compaction off. */
|
|
106
10
|
export function piCompactionEnabled(cwd?: string): boolean {
|
|
107
11
|
let enabled: unknown;
|
|
108
|
-
for (const
|
|
109
|
-
|
|
110
|
-
|
|
12
|
+
for (const file of readSettingsFiles(piSettingsPaths(cwd))) {
|
|
13
|
+
if (file.kind !== "parsed") continue;
|
|
14
|
+
const compaction = file.settings.compaction as { enabled?: unknown } | null | undefined;
|
|
15
|
+
if (compaction && typeof compaction === "object" && !Array.isArray(compaction) && compaction.enabled !== undefined) enabled = compaction.enabled;
|
|
111
16
|
}
|
|
112
17
|
return Boolean(enabled ?? true);
|
|
113
18
|
}
|