@vanillagreen/pi-qol 2.3.2 → 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 +3 -1
- package/extensions/qol/images.ts +39 -5
- package/extensions/qol/session-search/cache.ts +109 -31
- package/extensions/qol/session-search/component.ts +89 -13
- package/extensions/qol/session-search/index.ts +13 -11
- 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 +3 -0
- package/extensions/qol.ts +15 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,8 +33,10 @@ The extension reads your enabled features when Pi starts. It adds their editor c
|
|
|
33
33
|
|
|
34
34
|
- A session without a UI loads no session-search index. With a UI, the index is loaded shortly after start and kept for `sessionSearch.cacheTtlSeconds` (default 300 seconds; 0 keeps it until the session ends). A search after the index was released opens at once and fills in when the index has loaded again.
|
|
35
35
|
- The index keeps at most 32,768 characters of message text per session and 8,388,608 characters in total, newest session first. A session past the total is searched by name, path and first prompt only.
|
|
36
|
+
- Prepared user prompts have the same per-session and total character limits, measured in decoded UTF-16 code units. Indexing skips JSONL records above 2,097,152 decoded UTF-16 code units, not bytes. Copy and Fork read the complete selected prompt separately. Search waits 20 ms after the last edit and cancels an older query. Regex matching runs in a disposable worker with a 25 ms deadline and a separate 1 second startup deadline. A deadline error appears in the search popup.
|
|
37
|
+
- Submitted image paths and existing clipboard images share a 20 MiB raw-byte limit. The extension checks all file sizes before reading any image. A refusal blocks submission and reports an error. It restores the rejected prompt only if the editor is empty; a newer draft stays unchanged. Print and JSON callers receive the diagnostic on stderr.
|
|
36
38
|
- Where Pi cannot switch sessions directly, a chosen resume or fork waits as one `/search:resume-pending` command in the editor. Only the latest one is kept, and it is dropped when the session ends.
|
|
37
|
-
-
|
|
39
|
+
- The separate on-demand parsed-prompt cache keeps at most 64 sessions. The prepared search index uses the character limits above, not this session count. Finished thinking times are kept for at most 256 blocks. Thinking labels are released at the end of each agent run, and notification cooldown entries once their cooldown has passed and all of them when the session ends.
|
|
38
40
|
|
|
39
41
|
## Saved files
|
|
40
42
|
|
package/extensions/qol/images.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
2
3
|
import { basename, resolve } from "node:path";
|
|
3
4
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { applyVisibleReplacements, buildVisibleMap, type VisibleReplacement } from "./ansi.js";
|
|
@@ -42,11 +43,44 @@ function mimeTypeForPath(path: string): string {
|
|
|
42
43
|
return "image/png";
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
/** Submitted image paths and clipboard images share this raw-byte budget. */
|
|
47
|
+
export const SUBMITTED_IMAGES_MAX_BYTES = 20 * 1024 * 1024;
|
|
48
|
+
|
|
49
|
+
/** Stat every open file before reading any of them. Read only the observed
|
|
50
|
+
* bytes and one growth probe, so a growing file cannot bypass the budget. */
|
|
51
|
+
export async function imageContentsForPaths(paths: string[], existing: Array<{ data: string }> = []): Promise<Array<{ type: "image"; data: string; mimeType: string }>> {
|
|
52
|
+
const files: Array<{ path: string; handle: Awaited<ReturnType<typeof open>>; size: number }> = [];
|
|
53
|
+
let total = existing.reduce((sum, image) => sum + Buffer.byteLength(image.data, "base64"), 0);
|
|
54
|
+
const checkSize = () => {
|
|
55
|
+
if (total > SUBMITTED_IMAGES_MAX_BYTES) throw new Error(`IMAGE_SIZE_LIMIT: ${total} bytes exceeds ${SUBMITTED_IMAGES_MAX_BYTES}`);
|
|
56
|
+
};
|
|
46
57
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
checkSize();
|
|
59
|
+
for (const path of paths) {
|
|
60
|
+
const handle = await open(path, "r");
|
|
61
|
+
const file = { path, handle, size: 0 };
|
|
62
|
+
files.push(file);
|
|
63
|
+
const stat = await handle.stat();
|
|
64
|
+
if (!stat.isFile()) throw new Error(`IMAGE_NOT_FILE: ${path}`);
|
|
65
|
+
file.size = stat.size;
|
|
66
|
+
total += stat.size;
|
|
67
|
+
checkSize();
|
|
68
|
+
}
|
|
69
|
+
const buffers: Buffer[] = [];
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const buffer = Buffer.alloc(file.size + 1);
|
|
72
|
+
let offset = 0;
|
|
73
|
+
while (offset < buffer.length) {
|
|
74
|
+
const { bytesRead } = await file.handle.read(buffer, offset, buffer.length - offset, offset);
|
|
75
|
+
if (bytesRead === 0) break;
|
|
76
|
+
offset += bytesRead;
|
|
77
|
+
}
|
|
78
|
+
if (offset > file.size) throw new Error(`IMAGE_SIZE_CHANGED: ${file.path}`);
|
|
79
|
+
buffers.push(buffer.subarray(0, offset));
|
|
80
|
+
}
|
|
81
|
+
return files.map((file, index) => ({ type: "image", data: buffers[index]!.toString("base64"), mimeType: mimeTypeForPath(file.path) }));
|
|
82
|
+
} finally {
|
|
83
|
+
await Promise.all(files.map((file) => file.handle.close()));
|
|
50
84
|
}
|
|
51
85
|
}
|
|
52
86
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "../constants.js";
|
|
15
15
|
import { expandHome, piSettingsPaths, readSettingsFiles } from "../package-config.js";
|
|
16
16
|
import { settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "../settings.js";
|
|
17
|
-
import { forEachSessionJsonlLine } from "./jsonl.js";
|
|
17
|
+
import { forEachSessionJsonlLine, forEachSessionJsonlLineAsync } from "./jsonl.js";
|
|
18
18
|
import type {
|
|
19
19
|
QolSessionPaletteAction,
|
|
20
20
|
QolSessionSearchPendingMessage,
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
let qolSessionSearchCache: QolSessionSearchSession[] = [];
|
|
28
28
|
let qolSessionSearchLoadedAt = 0;
|
|
29
29
|
let qolSessionSearchLoading: Promise<QolSessionSearchSession[]> | undefined;
|
|
30
|
+
let qolSessionSearchLoadController: AbortController | undefined;
|
|
30
31
|
let qolSessionSearchReleaseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
31
32
|
/** Parsed user prompts per session path, oldest insertion first; at most
|
|
32
33
|
* SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS entries. */
|
|
@@ -115,17 +116,57 @@ function configuredSessionDir(cwd: string): string | undefined {
|
|
|
115
116
|
return configured;
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
/** Resolve a project once at index entry. A deleted directory keeps its
|
|
120
|
+
* absolute spelling; other filesystem failures remain visible. */
|
|
121
|
+
export async function canonicalPathForSessionSearch(path: string): Promise<string> {
|
|
120
122
|
try {
|
|
121
|
-
return
|
|
122
|
-
} catch {
|
|
123
|
-
return resolve(path);
|
|
123
|
+
return await realpath(path);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return resolve(path);
|
|
126
|
+
throw error;
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
/** Prepare prompts and canonical projects asynchronously. Both the shared
|
|
131
|
+
* cache and an overlay supplied with sessions use this index entry point. */
|
|
132
|
+
export async function prepareQolSessionSearchSessions(sessions: QolSessionSearchSession[], signal: AbortSignal): Promise<QolSessionSearchSession[]> {
|
|
133
|
+
const paths = new Map<string, string>();
|
|
134
|
+
let budget = SESSION_SEARCH_TEXT_MAX_CHARS;
|
|
135
|
+
const prepared: QolSessionSearchSession[] = [];
|
|
136
|
+
for (const session of [...sessions].sort((a, b) => b.modified.getTime() - a.modified.getTime())) {
|
|
137
|
+
signal.throwIfAborted();
|
|
138
|
+
let canonicalCwd = session.canonicalCwd ?? paths.get(session.cwd);
|
|
139
|
+
if (canonicalCwd === undefined) {
|
|
140
|
+
canonicalCwd = session.cwd ? await canonicalPathForSessionSearch(session.cwd) : "";
|
|
141
|
+
paths.set(session.cwd, canonicalCwd);
|
|
142
|
+
}
|
|
143
|
+
const messages: QolSessionUserMessage[] = [];
|
|
144
|
+
let remaining = Math.min(SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION, budget);
|
|
145
|
+
const keep = (message: QolSessionUserMessage) => {
|
|
146
|
+
if (remaining <= 0) return;
|
|
147
|
+
const text = message.text.slice(0, remaining);
|
|
148
|
+
remaining -= text.length;
|
|
149
|
+
budget -= text.length;
|
|
150
|
+
messages.push({ ...message, text });
|
|
151
|
+
};
|
|
152
|
+
if (session.userMessages !== undefined) session.userMessages.forEach(keep);
|
|
153
|
+
else if (remaining > 0) {
|
|
154
|
+
try {
|
|
155
|
+
let index = 0;
|
|
156
|
+
await forEachSessionJsonlLineAsync(session.path, (line) => {
|
|
157
|
+
const message = userMessageFromLine(line, index + 1);
|
|
158
|
+
if (message) { index++; keep(message); }
|
|
159
|
+
}, signal);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
signal.throwIfAborted();
|
|
162
|
+
// Pi can list a session which is removed before its prompt read.
|
|
163
|
+
if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
prepared.push({ ...session, canonicalCwd, userMessages: messages });
|
|
167
|
+
}
|
|
168
|
+
signal.throwIfAborted();
|
|
169
|
+
return prepared;
|
|
129
170
|
}
|
|
130
171
|
|
|
131
172
|
export function defaultSessionSearchScope(cwd?: string): QolSessionSearchScope {
|
|
@@ -145,17 +186,21 @@ export function capSessionSearchText(sessions: QolSessionSearchSession[]): QolSe
|
|
|
145
186
|
return sessions;
|
|
146
187
|
}
|
|
147
188
|
|
|
148
|
-
async function loadQolSessionSearchSessions(ctx: ExtensionContext, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
|
|
189
|
+
async function loadQolSessionSearchSessions(ctx: ExtensionContext, signal: AbortSignal, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
|
|
149
190
|
const customSessionDir = configuredSessionDir(ctx.cwd);
|
|
150
191
|
const infos = customSessionDir
|
|
151
|
-
? await SessionManager.list(ctx.cwd, customSessionDir, onProgress)
|
|
152
|
-
: await SessionManager.listAll(onProgress);
|
|
153
|
-
|
|
192
|
+
? await SessionManager.list(ctx.cwd, customSessionDir, onProgress, signal)
|
|
193
|
+
: await SessionManager.listAll(onProgress, signal);
|
|
194
|
+
signal.throwIfAborted();
|
|
195
|
+
return prepareQolSessionSearchSessions(capSessionSearchText(infos.map(sessionInfoToSearchSession).filter((session): session is QolSessionSearchSession => session !== undefined)), signal);
|
|
154
196
|
}
|
|
155
197
|
|
|
156
198
|
/** Drop the loaded index and the parsed prompts. Runs when the index outlives
|
|
157
199
|
* its TTL and on session shutdown; the next search loads the index again. */
|
|
158
200
|
export function releaseQolSessionSearchCache(): void {
|
|
201
|
+
qolSessionSearchLoadController?.abort();
|
|
202
|
+
qolSessionSearchLoadController = undefined;
|
|
203
|
+
qolSessionSearchLoading = undefined;
|
|
159
204
|
if (qolSessionSearchReleaseTimer) clearTimeout(qolSessionSearchReleaseTimer);
|
|
160
205
|
qolSessionSearchReleaseTimer = undefined;
|
|
161
206
|
qolSessionSearchCache = [];
|
|
@@ -171,9 +216,14 @@ export async function refreshQolSessionSearchCache(ctx: ExtensionContext, option
|
|
|
171
216
|
if (qolSessionSearchLoading) return qolSessionSearchLoading;
|
|
172
217
|
|
|
173
218
|
if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, "Loading sessions...");
|
|
174
|
-
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
qolSessionSearchLoadController = controller;
|
|
221
|
+
qolSessionSearchLoading = loadQolSessionSearchSessions(ctx, controller.signal, (loaded, total) => {
|
|
222
|
+
if (controller.signal.aborted) return;
|
|
175
223
|
if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, `Loading sessions ${loaded}/${total}`);
|
|
176
224
|
}).then((sessions) => {
|
|
225
|
+
controller.signal.throwIfAborted();
|
|
226
|
+
qolSessionSearchLoadController = undefined;
|
|
177
227
|
releaseQolSessionSearchCache();
|
|
178
228
|
qolSessionSearchCache = sessions;
|
|
179
229
|
qolSessionSearchLoadedAt = Date.now();
|
|
@@ -183,8 +233,11 @@ export async function refreshQolSessionSearchCache(ctx: ExtensionContext, option
|
|
|
183
233
|
}
|
|
184
234
|
return sessions;
|
|
185
235
|
}).finally(() => {
|
|
186
|
-
|
|
187
|
-
|
|
236
|
+
if (qolSessionSearchLoadController === controller || !controller.signal.aborted) {
|
|
237
|
+
qolSessionSearchLoadController = undefined;
|
|
238
|
+
qolSessionSearchLoading = undefined;
|
|
239
|
+
if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
|
|
240
|
+
}
|
|
188
241
|
});
|
|
189
242
|
return qolSessionSearchLoading;
|
|
190
243
|
}
|
|
@@ -209,26 +262,31 @@ function sessionMessageTimestamp(entry: any, message: any): number | undefined {
|
|
|
209
262
|
return undefined;
|
|
210
263
|
}
|
|
211
264
|
|
|
265
|
+
function userMessageFromLine(line: string, index: number, fullText = false): QolSessionUserMessage | undefined {
|
|
266
|
+
let entry: { type?: string; id?: unknown; parentId?: unknown; timestamp?: unknown; message?: { role?: string; content?: unknown; timestamp?: unknown } };
|
|
267
|
+
try { entry = JSON.parse(line); } catch { return undefined; }
|
|
268
|
+
const message = entry?.type === "message" ? entry.message : undefined;
|
|
269
|
+
if (message?.role !== "user") return undefined;
|
|
270
|
+
const content = messageContentText(message.content);
|
|
271
|
+
const text = fullText ? content : oneLine(content);
|
|
272
|
+
if (!text) return undefined;
|
|
273
|
+
return {
|
|
274
|
+
entryId: typeof entry.id === "string" ? entry.id : undefined,
|
|
275
|
+
index,
|
|
276
|
+
parentId: typeof entry.parentId === "string" || entry.parentId === null ? entry.parentId : undefined,
|
|
277
|
+
text,
|
|
278
|
+
timestamp: sessionMessageTimestamp(entry, message),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
212
282
|
export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
|
|
213
283
|
const cached = qolSessionUserMessagesCache.get(sessionPath);
|
|
214
284
|
if (cached) return cached;
|
|
215
285
|
const messages: QolSessionUserMessage[] = [];
|
|
216
286
|
try {
|
|
217
287
|
forEachSessionJsonlLine(sessionPath, (line) => {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
try { entry = JSON.parse(line); } catch { return; }
|
|
221
|
-
const message = entry?.type === "message" ? entry.message : undefined;
|
|
222
|
-
if (!message || message.role !== "user") return;
|
|
223
|
-
const text = oneLine(messageContentText(message.content));
|
|
224
|
-
if (!text) return;
|
|
225
|
-
messages.push({
|
|
226
|
-
entryId: typeof entry.id === "string" ? entry.id : undefined,
|
|
227
|
-
index: messages.length + 1,
|
|
228
|
-
parentId: typeof entry.parentId === "string" || entry.parentId === null ? entry.parentId : undefined,
|
|
229
|
-
text,
|
|
230
|
-
timestamp: sessionMessageTimestamp(entry, message),
|
|
231
|
-
});
|
|
288
|
+
const message = userMessageFromLine(line, messages.length + 1);
|
|
289
|
+
if (message) messages.push(message);
|
|
232
290
|
});
|
|
233
291
|
} catch {
|
|
234
292
|
// Ignore unreadable sessions; callers fall back to SessionInfo.firstMessage.
|
|
@@ -242,11 +300,31 @@ export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[
|
|
|
242
300
|
}
|
|
243
301
|
|
|
244
302
|
export function userMessagesForResult(result: QolSessionSearchResult): QolSessionUserMessage[] {
|
|
245
|
-
const messages = sessionUserMessages(result.path);
|
|
303
|
+
const messages = result.userMessages ?? sessionUserMessages(result.path);
|
|
246
304
|
if (messages.length > 0) return messages;
|
|
247
305
|
return [{ index: 1, text: oneLine(result.firstMessage || "No user messages") }];
|
|
248
306
|
}
|
|
249
307
|
|
|
308
|
+
/** Load the original Copy/Fork payload without retaining it in the index.
|
|
309
|
+
* SessionManager.open reads synchronously, so use Pi's documented JSONL
|
|
310
|
+
* format to stream to the selected entry instead. */
|
|
311
|
+
export async function sessionUserMessageForAction(result: QolSessionSearchResult, selected?: QolSessionUserMessage, signal = new AbortController().signal): Promise<QolSessionUserMessage> {
|
|
312
|
+
let found: QolSessionUserMessage | undefined;
|
|
313
|
+
let index = 0;
|
|
314
|
+
await forEachSessionJsonlLineAsync(result.path, (line) => {
|
|
315
|
+
const message = userMessageFromLine(line, index + 1, true);
|
|
316
|
+
if (!message) return;
|
|
317
|
+
index++;
|
|
318
|
+
if (selected?.entryId ? message.entryId === selected.entryId : message.index === (selected?.index ?? 1)) {
|
|
319
|
+
found = message;
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}, signal, { maxLineChars: Infinity });
|
|
323
|
+
signal.throwIfAborted();
|
|
324
|
+
if (!found) throw new Error(`Session prompt not found: ${selected?.entryId ?? selected?.index ?? 1}`);
|
|
325
|
+
return found;
|
|
326
|
+
}
|
|
327
|
+
|
|
250
328
|
export function sessionUserPromptCount(session: QolSessionSearchSession): number {
|
|
251
329
|
const count = sessionUserMessages(session.path).length;
|
|
252
330
|
if (count > 0) return count;
|
|
@@ -5,17 +5,18 @@ import { SESSION_SEARCH_OVERLAY_HEIGHT_RATIO } from "../constants.js";
|
|
|
5
5
|
import { settingNumber } from "../settings.js";
|
|
6
6
|
import {
|
|
7
7
|
defaultSessionSearchScope,
|
|
8
|
+
canonicalPathForSessionSearch,
|
|
9
|
+
prepareQolSessionSearchSessions,
|
|
8
10
|
modelLabel,
|
|
9
11
|
sameModel,
|
|
10
|
-
sameSessionSearchProject,
|
|
11
12
|
sessionModelInfo,
|
|
12
13
|
sessionResumeTitle,
|
|
13
14
|
shortPathForUi,
|
|
14
15
|
userMessagesForResult,
|
|
16
|
+
sessionUserMessageForAction,
|
|
15
17
|
type QolModelInfo,
|
|
16
18
|
} from "./cache.js";
|
|
17
19
|
import {
|
|
18
|
-
buildPromptSnippet,
|
|
19
20
|
formatSessionSearchDate,
|
|
20
21
|
parseSessionSearchQuery,
|
|
21
22
|
promptRecencyTime,
|
|
@@ -121,6 +122,11 @@ export class QolSessionSearchComponent {
|
|
|
121
122
|
private contextConfirmState: QolSessionContextConfirmState | undefined;
|
|
122
123
|
private forkConfirmState: QolSessionForkConfirmState | undefined;
|
|
123
124
|
private confirmModelState: QolSessionConfirmModelState | undefined;
|
|
125
|
+
private readonly indexController = new AbortController();
|
|
126
|
+
private queryController: AbortController | undefined;
|
|
127
|
+
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
128
|
+
private currentSessions: QolSessionSearchSession[] = [];
|
|
129
|
+
private searchStatus: { status: "searching" | "action" } | { status: "ready" } | { status: "failed"; error: string } = { status: "ready" };
|
|
124
130
|
|
|
125
131
|
|
|
126
132
|
constructor(
|
|
@@ -132,7 +138,22 @@ export class QolSessionSearchComponent {
|
|
|
132
138
|
initialQuery = "",
|
|
133
139
|
currentModel: QolModelInfo | undefined = undefined,
|
|
134
140
|
) {
|
|
135
|
-
this.done =
|
|
141
|
+
this.done = async (action) => {
|
|
142
|
+
try {
|
|
143
|
+
if (action.type === "copy" || action.type === "fork") {
|
|
144
|
+
this.screen = "search";
|
|
145
|
+
this.searchStatus = { status: "action" };
|
|
146
|
+
this.tui.requestRender();
|
|
147
|
+
action = { ...action, message: await sessionUserMessageForAction(action.result!, action.message, this.indexController.signal) };
|
|
148
|
+
}
|
|
149
|
+
if (this.indexController.signal.aborted) return;
|
|
150
|
+
this.dispose(); done(action);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (this.indexController.signal.aborted) return;
|
|
153
|
+
this.searchStatus = { status: "failed", error: `${action.type === "copy" ? "Copy" : "Fork"} failed: ${String(error)}` };
|
|
154
|
+
this.tui.requestRender();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
136
157
|
this.tui = tui;
|
|
137
158
|
this.theme = theme;
|
|
138
159
|
this.sessions = sessions;
|
|
@@ -140,24 +161,53 @@ export class QolSessionSearchComponent {
|
|
|
140
161
|
this.currentModel = currentModel;
|
|
141
162
|
const query = initialQuery.trim();
|
|
142
163
|
const scope = defaultSessionSearchScope(cwd);
|
|
143
|
-
const scopedSessions = this.sessionsForScope(scope);
|
|
144
164
|
this.searchState = {
|
|
145
165
|
cursor: query.length,
|
|
146
166
|
query,
|
|
147
|
-
results:
|
|
167
|
+
results: [],
|
|
148
168
|
selected: 0,
|
|
149
169
|
scope,
|
|
150
|
-
total:
|
|
170
|
+
total: 0,
|
|
151
171
|
};
|
|
172
|
+
if (sessions.status === "ready") this.setSessions(sessions);
|
|
152
173
|
}
|
|
153
174
|
|
|
154
175
|
invalidate(): void {}
|
|
155
176
|
|
|
177
|
+
/** Pi disposes a completed custom overlay. Late loads cannot redraw it. */
|
|
178
|
+
dispose(): void {
|
|
179
|
+
this.indexController.abort();
|
|
180
|
+
this.queryController?.abort();
|
|
181
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
182
|
+
this.debounceTimer = undefined;
|
|
183
|
+
this.currentSessions = [];
|
|
184
|
+
this.sessions = { status: "loading" };
|
|
185
|
+
this.searchState.results = [];
|
|
186
|
+
}
|
|
187
|
+
|
|
156
188
|
/** Show the index once its load settles; the overlay opens before that. */
|
|
157
189
|
setSessions(sessions: QolSessionSearchLoad): void {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
190
|
+
const signal = this.indexController.signal;
|
|
191
|
+
if (signal.aborted) return;
|
|
192
|
+
if (sessions.status !== "ready") {
|
|
193
|
+
this.sessions = sessions;
|
|
194
|
+
this.tui.requestRender();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.sessions = { status: "loading" };
|
|
198
|
+
void Promise.all([
|
|
199
|
+
prepareQolSessionSearchSessions(sessions.sessions, signal),
|
|
200
|
+
canonicalPathForSessionSearch(this.cwd),
|
|
201
|
+
]).then(([prepared, cwd]) => {
|
|
202
|
+
if (signal.aborted) return;
|
|
203
|
+
this.sessions = { status: "ready", sessions: prepared };
|
|
204
|
+
this.currentSessions = prepared.filter((session) => session.canonicalCwd === cwd);
|
|
205
|
+
this.updateResults(false);
|
|
206
|
+
}, (error: unknown) => {
|
|
207
|
+
if (signal.aborted) return;
|
|
208
|
+
this.sessions = { status: "failed", error: String(error) };
|
|
209
|
+
this.tui.requestRender();
|
|
210
|
+
});
|
|
161
211
|
}
|
|
162
212
|
|
|
163
213
|
private maxOverlayRows(): number {
|
|
@@ -205,6 +255,7 @@ export class QolSessionSearchComponent {
|
|
|
205
255
|
}
|
|
206
256
|
|
|
207
257
|
handleInput(data: string): void {
|
|
258
|
+
if (this.searchStatus.status === "action") return matchesKey(data, "escape") ? this.done({ type: "cancel" }) : undefined;
|
|
208
259
|
if (this.screen === "messages" && this.messagesState) this.handleMessagesInput(data);
|
|
209
260
|
else if (this.screen === "actions" && this.actionState) this.handleActionInput(data);
|
|
210
261
|
else if (this.screen === "confirmContext" && this.contextConfirmState) this.handleContextConfirmInput(data);
|
|
@@ -224,16 +275,33 @@ export class QolSessionSearchComponent {
|
|
|
224
275
|
private sessionsForScope(scope = this.searchState.scope): QolSessionSearchSession[] {
|
|
225
276
|
if (this.sessions.status !== "ready") return [];
|
|
226
277
|
if (scope === "all") return this.sessions.sessions;
|
|
227
|
-
return this.
|
|
278
|
+
return this.currentSessions;
|
|
228
279
|
}
|
|
229
280
|
|
|
230
281
|
private updateResults(resetSelection = true): void {
|
|
282
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
283
|
+
this.debounceTimer = undefined;
|
|
284
|
+
this.queryController?.abort();
|
|
285
|
+
if (this.indexController.signal.aborted || this.sessions.status !== "ready") return;
|
|
286
|
+
const controller = new AbortController();
|
|
287
|
+
this.queryController = controller;
|
|
231
288
|
const state = this.searchState;
|
|
232
289
|
const scopedSessions = this.sessionsForScope(state.scope);
|
|
233
|
-
state.results =
|
|
290
|
+
state.results = [];
|
|
291
|
+
this.searchStatus = { status: "searching" };
|
|
234
292
|
state.total = scopedSessions.length;
|
|
235
293
|
if (resetSelection) state.selected = 0;
|
|
236
|
-
this.
|
|
294
|
+
void searchQolSessionHits(scopedSessions, state.query, this.cwd, controller.signal).then((results) => {
|
|
295
|
+
if (controller.signal.aborted || this.indexController.signal.aborted) return;
|
|
296
|
+
state.results = results;
|
|
297
|
+
this.searchStatus = { status: "ready" };
|
|
298
|
+
this.clampSelection();
|
|
299
|
+
this.tui.requestRender();
|
|
300
|
+
}, (error: unknown) => {
|
|
301
|
+
if (controller.signal.aborted || this.indexController.signal.aborted) return;
|
|
302
|
+
this.searchStatus = { status: "failed", error: String(error) };
|
|
303
|
+
this.tui.requestRender();
|
|
304
|
+
});
|
|
237
305
|
}
|
|
238
306
|
|
|
239
307
|
private toggleScope(): void {
|
|
@@ -244,7 +312,11 @@ export class QolSessionSearchComponent {
|
|
|
244
312
|
private updateQuery(query: string, cursor: number): void {
|
|
245
313
|
this.searchState.query = query;
|
|
246
314
|
this.searchState.cursor = Math.max(0, Math.min(cursor, query.length));
|
|
247
|
-
this.
|
|
315
|
+
this.queryController?.abort();
|
|
316
|
+
this.searchState.results = [];
|
|
317
|
+
this.searchStatus = { status: "searching" };
|
|
318
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
319
|
+
this.debounceTimer = setTimeout(() => this.updateResults(true), 20);
|
|
248
320
|
}
|
|
249
321
|
|
|
250
322
|
private selectedMessageIndex(messages: QolSessionUserMessage[], query: string): number {
|
|
@@ -628,6 +700,10 @@ export class QolSessionSearchComponent {
|
|
|
628
700
|
lines.push(row(muted("Loading sessions…")));
|
|
629
701
|
} else if (this.sessions.status === "failed") {
|
|
630
702
|
lines.push(row(muted(`Could not load sessions: ${this.sessions.error.split("\n")[0]}`)));
|
|
703
|
+
} else if (this.searchStatus.status === "failed") {
|
|
704
|
+
lines.push(row(muted(`Search failed: ${this.searchStatus.error.split("\n")[0]}`)));
|
|
705
|
+
} else if (this.searchStatus.status === "searching" || this.searchStatus.status === "action") {
|
|
706
|
+
lines.push(row(muted(this.searchStatus.status === "action" ? "Loading prompt… Esc cancel" : "Searching…")));
|
|
631
707
|
} else if (state.results.length === 0) {
|
|
632
708
|
lines.push(row(muted(state.query.trim() ? "No prompts match your search" : "No prompts found")));
|
|
633
709
|
} else {
|
|
@@ -42,12 +42,12 @@ export async function runSessionSearchResumeOrFork(pi: ExtensionAPI, ctx: Extens
|
|
|
42
42
|
const commandCtx = asCommandContext(ctx);
|
|
43
43
|
if (typeof commandCtx.switchSession !== "function") return false;
|
|
44
44
|
const targetTitle = sessionDisplayName(action.result);
|
|
45
|
-
const selectedMessage = action.type === "fork" ? action.message : undefined;
|
|
46
45
|
const currentModel = action.keepCurrentModel ? ctx.model : undefined;
|
|
47
46
|
const currentThinking = action.keepCurrentModel && typeof pi.getThinkingLevel === "function" ? pi.getThinkingLevel() : undefined;
|
|
48
|
-
if (currentModel) pinSessionModel(action.result.path, currentModel, currentThinking);
|
|
49
47
|
let replacementStarted = false;
|
|
50
48
|
try {
|
|
49
|
+
const selectedMessage = action.type === "fork" ? action.message : undefined;
|
|
50
|
+
if (currentModel) pinSessionModel(action.result.path, currentModel, currentThinking);
|
|
51
51
|
const result = await commandCtx.switchSession(action.result.path, {
|
|
52
52
|
withSession: async (replacementCtx: any) => {
|
|
53
53
|
replacementStarted = true;
|
|
@@ -68,7 +68,7 @@ export async function runSessionSearchResumeOrFork(pi: ExtensionAPI, ctx: Extens
|
|
|
68
68
|
});
|
|
69
69
|
if (result.cancelled) ctx.ui.notify(selectedMessage ? "Fork cancelled" : "Resume cancelled", "info");
|
|
70
70
|
} catch (error) {
|
|
71
|
-
if (!replacementStarted) ctx.ui.notify(`${
|
|
71
|
+
if (!replacementStarted) ctx.ui.notify(`${action.type === "fork" ? "Fork" : "Resume"} failed: ${stringifyError(error)}`, "error");
|
|
72
72
|
}
|
|
73
73
|
return true;
|
|
74
74
|
}
|
|
@@ -129,11 +129,18 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
|
|
|
129
129
|
);
|
|
130
130
|
const releaseModalLock = acquirekendexModalLock();
|
|
131
131
|
let action: QolSessionPaletteAction | undefined;
|
|
132
|
+
let component: QolSessionSearchComponent | undefined;
|
|
132
133
|
try {
|
|
133
134
|
const currentModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
|
|
134
135
|
action = await ctx.ui.custom<QolSessionPaletteAction>((tui, theme, _keybindings, done) => {
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
component = new QolSessionSearchComponent((selected) => {
|
|
137
|
+
if (selected.type === "copy") {
|
|
138
|
+
ctx.ui.setEditorText(selected.message!.text);
|
|
139
|
+
ctx.ui.notify("Copied selected prompt into the editor", "info");
|
|
140
|
+
}
|
|
141
|
+
done(selected);
|
|
142
|
+
}, tui, theme, { status: "loading" }, ctx.cwd, initialQuery, currentModel);
|
|
143
|
+
void loading.then((load) => component?.setSessions(load));
|
|
137
144
|
return component;
|
|
138
145
|
}, {
|
|
139
146
|
overlay: true,
|
|
@@ -144,6 +151,7 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
|
|
|
144
151
|
},
|
|
145
152
|
});
|
|
146
153
|
} finally {
|
|
154
|
+
component?.dispose();
|
|
147
155
|
releaseModalLock();
|
|
148
156
|
}
|
|
149
157
|
if (!action || action.type === "cancel" || !action.result) return;
|
|
@@ -151,12 +159,6 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
|
|
|
151
159
|
if (!(await runSessionSearchResumeOrFork(pi, ctx, action))) queueSessionSearchCommandAction(ctx, action);
|
|
152
160
|
return;
|
|
153
161
|
}
|
|
154
|
-
if (action.type === "copy") {
|
|
155
|
-
const text = action.message?.text || action.result.firstMessage;
|
|
156
|
-
ctx.ui.setEditorText(text);
|
|
157
|
-
ctx.ui.notify("Copied selected prompt into the editor", "info");
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
162
|
if (action.type === "summarize") {
|
|
161
163
|
try {
|
|
162
164
|
await injectSessionSearchContext(pi, ctx, action.result, action.customPrompt);
|
|
@@ -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,6 +11,9 @@ 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
|
|
|
16
19
|
/** The session-search index as the overlay sees it: still loading, loaded,
|
package/extensions/qol.ts
CHANGED
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
QolEditor,
|
|
40
40
|
} from "./qol/editor.js";
|
|
41
41
|
import { runHandoff } from "./qol/handoff.js";
|
|
42
|
-
import {
|
|
42
|
+
import { imageContentsForPaths, resolveSubmittedImagePaths } from "./qol/images.js";
|
|
43
43
|
import {
|
|
44
44
|
clearTmuxWindowMark,
|
|
45
45
|
notifyQuestionOpened,
|
|
@@ -847,15 +847,24 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
847
847
|
if (event?.toolName === "tasks_write") maybeNotifyTaskCompletion(ctx, event.details?.state);
|
|
848
848
|
});
|
|
849
849
|
|
|
850
|
-
pi.on("input", async (event) => {
|
|
850
|
+
pi.on("input", async (event, ctx) => {
|
|
851
851
|
clearTmuxWindowMark(pi);
|
|
852
852
|
if (event.source === "extension") return { action: "continue" };
|
|
853
853
|
const text = event.text ?? "";
|
|
854
|
-
const paths =
|
|
854
|
+
const paths = resolveSubmittedImagePaths(text, ctx.cwd);
|
|
855
855
|
if (paths.length === 0) return { action: "continue" };
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
856
|
+
try {
|
|
857
|
+
const images = await imageContentsForPaths(paths, event.images ?? []);
|
|
858
|
+
return { action: "transform", images: [...(event.images ?? []), ...images], text: event.text };
|
|
859
|
+
} catch (error) {
|
|
860
|
+
const message = `Image submission refused: ${stringifyError(error)}`;
|
|
861
|
+
ctx.ui.notify(message, "error");
|
|
862
|
+
// Print and JSON modes reserve stderr for diagnostics; input exceptions
|
|
863
|
+
// would let Pi continue submission instead of refusing the image.
|
|
864
|
+
if (!ctx.hasUI) console.error(message);
|
|
865
|
+
if (ctx.hasUI && ctx.ui.getEditorText() === "") ctx.ui.setEditorText(text);
|
|
866
|
+
return { action: "handled" };
|
|
867
|
+
}
|
|
859
868
|
});
|
|
860
869
|
|
|
861
870
|
if (settingBoolean("enableSessionNameCommand", true)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillagreen/pi-qol",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.4",
|
|
4
4
|
"description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, scheduled prompts, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|