@vanillagreen/pi-qol 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/extensions/qol/agent-end.ts +48 -0
- package/extensions/qol/agent-statusline.ts +52 -0
- package/extensions/qol/ansi.ts +115 -0
- package/extensions/qol/bridges.ts +78 -0
- package/extensions/qol/compaction.ts +264 -0
- package/extensions/qol/constants.ts +107 -0
- package/extensions/qol/context-usage.ts +492 -0
- package/extensions/qol/editor.ts +183 -0
- package/extensions/qol/handoff.ts +109 -0
- package/extensions/qol/images.ts +123 -0
- package/extensions/qol/notifications.ts +269 -0
- package/extensions/qol/pending-queue.ts +79 -0
- package/extensions/qol/permission-gate.ts +139 -0
- package/extensions/qol/session-rename.ts +323 -0
- package/extensions/qol/session-search/cache.ts +273 -0
- package/extensions/qol/session-search/component.ts +937 -0
- package/extensions/qol/session-search/context.ts +152 -0
- package/extensions/qol/session-search/index.ts +171 -0
- package/extensions/qol/session-search/search.ts +288 -0
- package/extensions/qol/session-search/types.ts +96 -0
- package/extensions/qol/settings.ts +74 -0
- package/extensions/qol/status-message.ts +41 -0
- package/extensions/qol/statusline.ts +219 -0
- package/extensions/qol/thinking-timer.ts +97 -0
- package/extensions/qol/util.ts +7 -0
- package/extensions/qol.ts +93 -4408
- package/package.json +1 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import { SessionManager, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { acquireVstackModalLock } from "../bridges.js";
|
|
6
|
+
import { generateQolSummary, serializeMessagesForSummary } from "../compaction.js";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS,
|
|
9
|
+
DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS,
|
|
10
|
+
SESSION_SEARCH_CONTEXT_TYPE,
|
|
11
|
+
SESSION_SEARCH_STATUS_KEY,
|
|
12
|
+
} from "../constants.js";
|
|
13
|
+
import { settingNumber, settingString } from "../settings.js";
|
|
14
|
+
import { sessionDisplayName } from "./cache.js";
|
|
15
|
+
import { QolSessionSearchLoadingComponent } from "./component.js";
|
|
16
|
+
import type { QolSessionSearchPendingMessage, QolSessionSearchResult } from "./types.js";
|
|
17
|
+
|
|
18
|
+
export function trimSessionSummaryInput(text: string, cwd: string): string {
|
|
19
|
+
const maxChars = Math.max(20_000, Math.floor(settingNumber("sessionSearch.summaryInputMaxChars", DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS, cwd)));
|
|
20
|
+
if (text.length <= maxChars) return text;
|
|
21
|
+
const headChars = Math.floor(maxChars * 0.35);
|
|
22
|
+
const tailChars = maxChars - headChars;
|
|
23
|
+
const omitted = text.length - maxChars;
|
|
24
|
+
return `${text.slice(0, headChars)}\n\n[... ${omitted.toLocaleString()} character(s) omitted from the middle of this imported session ...]\n\n${text.slice(-tailChars)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function messagesForSessionSummary(sessionPath: string): AgentMessage[] {
|
|
28
|
+
try {
|
|
29
|
+
const manager = SessionManager.open(sessionPath);
|
|
30
|
+
const context = manager.buildSessionContext();
|
|
31
|
+
if (Array.isArray(context.messages) && context.messages.length > 0) return context.messages as AgentMessage[];
|
|
32
|
+
} catch {
|
|
33
|
+
// Fall back to a direct JSONL parse below.
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const lines = readFileSync(sessionPath, "utf8").split(/\r?\n/);
|
|
37
|
+
const messages: AgentMessage[] = [];
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (!line.trim()) continue;
|
|
40
|
+
let entry: any;
|
|
41
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
42
|
+
if (entry?.type === "message" && entry.message) messages.push(entry.message);
|
|
43
|
+
}
|
|
44
|
+
return messages;
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function buildSessionSearchContextMessage(ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string, signal?: AbortSignal): Promise<QolSessionSearchPendingMessage> {
|
|
51
|
+
const messages = messagesForSessionSummary(result.path);
|
|
52
|
+
const fallbackText = result.allMessagesText || result.firstMessage;
|
|
53
|
+
const conversationText = messages.length > 0 ? serializeMessagesForSummary(messages) : fallbackText;
|
|
54
|
+
if (!conversationText.trim()) throw new Error("Selected session has no text content to summarize");
|
|
55
|
+
const focus = customPrompt?.trim();
|
|
56
|
+
const summary = await generateQolSummary(ctx, {
|
|
57
|
+
conversationText: trimSessionSummaryInput(conversationText, ctx.cwd),
|
|
58
|
+
maxTokens: Math.max(256, Math.floor(settingNumber("sessionSearch.summaryMaxTokens", DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS, ctx.cwd))),
|
|
59
|
+
model: settingString("sessionSearch.summaryModel", "current", ctx.cwd),
|
|
60
|
+
customInstructions: [
|
|
61
|
+
`Source session file: ${result.path}`,
|
|
62
|
+
`Source project/cwd: ${result.cwd || sessionDisplayName(result)}`,
|
|
63
|
+
focus ? `User focus: ${focus}` : "Focus on facts needed to continue or reference this previous session.",
|
|
64
|
+
].join("\n"),
|
|
65
|
+
purpose: "session-search",
|
|
66
|
+
signal: signal ?? ctx.signal,
|
|
67
|
+
});
|
|
68
|
+
const title = sessionDisplayName(result);
|
|
69
|
+
const content = [
|
|
70
|
+
`## Session Search Context: ${title}`,
|
|
71
|
+
`**Date:** ${result.modified.toISOString()} | **File:** ${result.path}`,
|
|
72
|
+
focus ? `**Focus:** ${focus}` : undefined,
|
|
73
|
+
"",
|
|
74
|
+
summary.summary,
|
|
75
|
+
].filter((line): line is string => line !== undefined).join("\n");
|
|
76
|
+
return {
|
|
77
|
+
content,
|
|
78
|
+
details: {
|
|
79
|
+
file: result.path,
|
|
80
|
+
focus,
|
|
81
|
+
model: summary.model,
|
|
82
|
+
project: result.cwd || title,
|
|
83
|
+
source: "pi-qol-session-search",
|
|
84
|
+
via: summary.via,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function buildSessionSearchContextMessageWithLoader(ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt: string | undefined, actionLabel: string): Promise<QolSessionSearchPendingMessage> {
|
|
90
|
+
const title = sessionDisplayName(result);
|
|
91
|
+
if (!ctx.hasUI) return buildSessionSearchContextMessage(ctx, result, customPrompt);
|
|
92
|
+
ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, `Summarizing ${title}...`);
|
|
93
|
+
const releaseModalLock = acquireVstackModalLock();
|
|
94
|
+
let failure: unknown;
|
|
95
|
+
try {
|
|
96
|
+
const message = await ctx.ui.custom<QolSessionSearchPendingMessage | null>((tui: any, theme: any, _kb: any, done: (value: QolSessionSearchPendingMessage | null) => void) => {
|
|
97
|
+
let closed = false;
|
|
98
|
+
const loader = new QolSessionSearchLoadingComponent(tui, theme, actionLabel, "Summarizing session context...", (value) => {
|
|
99
|
+
if (closed) return;
|
|
100
|
+
closed = true;
|
|
101
|
+
done(value);
|
|
102
|
+
});
|
|
103
|
+
const finish = (value: QolSessionSearchPendingMessage | null) => {
|
|
104
|
+
if (closed) return;
|
|
105
|
+
closed = true;
|
|
106
|
+
loader.dispose();
|
|
107
|
+
done(value);
|
|
108
|
+
};
|
|
109
|
+
buildSessionSearchContextMessage(ctx, result, customPrompt, loader.signal)
|
|
110
|
+
.then(finish)
|
|
111
|
+
.catch((error) => {
|
|
112
|
+
if (!loader.signal?.aborted) failure = error;
|
|
113
|
+
finish(null);
|
|
114
|
+
});
|
|
115
|
+
return loader;
|
|
116
|
+
}, {
|
|
117
|
+
overlay: true,
|
|
118
|
+
overlayOptions: {
|
|
119
|
+
anchor: "center",
|
|
120
|
+
width: Math.max(56, Math.min(92, Math.floor(settingNumber("sessionSearch.overlayWidth", 104, ctx.cwd) * 0.75))),
|
|
121
|
+
maxHeight: "70%",
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
if (failure) throw failure;
|
|
125
|
+
if (!message) throw new Error("Session context summary cancelled");
|
|
126
|
+
return message;
|
|
127
|
+
} finally {
|
|
128
|
+
releaseModalLock();
|
|
129
|
+
ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function injectSessionSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string): Promise<void> {
|
|
134
|
+
const title = sessionDisplayName(result);
|
|
135
|
+
const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "Inject + Context");
|
|
136
|
+
pi.sendMessage({ customType: SESSION_SEARCH_CONTEXT_TYPE, content: message.content, details: message.details, display: true }, { deliverAs: "followUp", triggerTurn: false });
|
|
137
|
+
ctx.ui.notify(`Session context injected: ${title}`, "info");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function renderSessionSearchContextMessage(message: any, options: any, theme: Theme): Text {
|
|
141
|
+
const raw = typeof message.content === "string"
|
|
142
|
+
? message.content
|
|
143
|
+
: Array.isArray(message.content)
|
|
144
|
+
? message.content.map((part: any) => part?.type === "text" ? part.text ?? "" : "").join("")
|
|
145
|
+
: "";
|
|
146
|
+
const title = raw.match(/^## Session Search Context:\s*(.+)$/m)?.[1]?.trim() || "session";
|
|
147
|
+
const date = raw.match(/\*\*Date:\*\*\s*([^|\n]+)/)?.[1]?.trim() || "";
|
|
148
|
+
const header = `${theme.fg("customMessageLabel", theme.bold("Session context: "))}${theme.fg("accent", title)}${date ? theme.fg("muted", ` (${date})`) : ""}`;
|
|
149
|
+
if (!options?.expanded) return new Text(`${header}${theme.fg("dim", " ctrl+o expand")}`, 0, 0);
|
|
150
|
+
const body = raw.slice(raw.indexOf("\n") + 1).trim();
|
|
151
|
+
return new Text(body ? `${header}\n\n${body}` : header, 0, 0);
|
|
152
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { acquireVstackModalLock } from "../bridges.js";
|
|
3
|
+
import { SESSION_SEARCH_CONTEXT_TYPE, SESSION_SEARCH_STATUS_KEY } from "../constants.js";
|
|
4
|
+
import { settingNumber } from "../settings.js";
|
|
5
|
+
import { stringifyError } from "../util.js";
|
|
6
|
+
import {
|
|
7
|
+
getPendingSessionSearchMessage,
|
|
8
|
+
nextSessionSearchPendingActionId,
|
|
9
|
+
pinSessionModel,
|
|
10
|
+
qolSessionSearchPendingActions,
|
|
11
|
+
refreshQolSessionSearchCache,
|
|
12
|
+
sessionDisplayName,
|
|
13
|
+
setPendingSessionSearchMessage,
|
|
14
|
+
} from "./cache.js";
|
|
15
|
+
import { QolSessionSearchComponent } from "./component.js";
|
|
16
|
+
import {
|
|
17
|
+
buildSessionSearchContextMessageWithLoader,
|
|
18
|
+
injectSessionSearchContext,
|
|
19
|
+
} from "./context.js";
|
|
20
|
+
import type { QolSessionPaletteAction, QolSessionSearchResult } from "./types.js";
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
qolSessionSearchPendingActions,
|
|
24
|
+
refreshQolSessionSearchCache,
|
|
25
|
+
sessionSearchShortcut,
|
|
26
|
+
} from "./cache.js";
|
|
27
|
+
export { renderSessionSearchContextMessage } from "./context.js";
|
|
28
|
+
|
|
29
|
+
function asCommandContext(ctx: ExtensionContext): (ExtensionContext & Partial<ExtensionCommandContext>) {
|
|
30
|
+
return ctx as ExtensionContext & Partial<ExtensionCommandContext>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function queueSessionSearchCommandAction(ctx: ExtensionContext, action: QolSessionPaletteAction): void {
|
|
34
|
+
const id = nextSessionSearchPendingActionId();
|
|
35
|
+
qolSessionSearchPendingActions.set(id, action);
|
|
36
|
+
ctx.ui.setEditorText(`/search:resume-pending ${id}`);
|
|
37
|
+
ctx.ui.notify(`${sessionDisplayName(action.result!)} — press Enter to ${action.type === "fork" ? "fork" : "resume"}`, "info");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runSessionSearchResumeOrFork(pi: ExtensionAPI, ctx: ExtensionContext, action: QolSessionPaletteAction): Promise<boolean> {
|
|
41
|
+
if (!action.result || (action.type !== "resume" && action.type !== "fork")) return true;
|
|
42
|
+
const commandCtx = asCommandContext(ctx);
|
|
43
|
+
if (typeof commandCtx.switchSession !== "function") return false;
|
|
44
|
+
const targetTitle = sessionDisplayName(action.result);
|
|
45
|
+
const selectedMessage = action.type === "fork" ? action.message : undefined;
|
|
46
|
+
const currentModel = action.keepCurrentModel ? ctx.model : undefined;
|
|
47
|
+
const currentThinking = action.keepCurrentModel && typeof pi.getThinkingLevel === "function" ? pi.getThinkingLevel() : undefined;
|
|
48
|
+
if (currentModel) pinSessionModel(action.result.path, currentModel, currentThinking);
|
|
49
|
+
let replacementStarted = false;
|
|
50
|
+
try {
|
|
51
|
+
const result = await commandCtx.switchSession(action.result.path, {
|
|
52
|
+
withSession: async (replacementCtx: any) => {
|
|
53
|
+
replacementStarted = true;
|
|
54
|
+
if (currentModel) replacementCtx.ui.notify(`Using current model: ${currentModel.provider}/${currentModel.id}`, "info");
|
|
55
|
+
if (selectedMessage) {
|
|
56
|
+
const manager = replacementCtx.sessionManager as any;
|
|
57
|
+
if (selectedMessage.entryId && selectedMessage.parentId && typeof manager?.branch === "function") manager.branch(selectedMessage.parentId);
|
|
58
|
+
else if (selectedMessage.entryId && selectedMessage.parentId === null && typeof manager?.resetLeaf === "function") manager.resetLeaf();
|
|
59
|
+
replacementCtx.ui.setEditorText(selectedMessage.text);
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
replacementCtx.ui.notify(`Forked session: ${targetTitle} at prompt #${selectedMessage.index}. Submit to branch from here.`, "info");
|
|
62
|
+
}, 0);
|
|
63
|
+
timer.unref?.();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
replacementCtx.ui.notify(`Resumed session: ${targetTitle}${currentModel ? " with current model" : ""}`, "info");
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (result.cancelled) ctx.ui.notify(selectedMessage ? "Fork cancelled" : "Resume cancelled", "info");
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!replacementStarted) ctx.ui.notify(`${selectedMessage ? "Fork" : "Resume"} failed: ${stringifyError(error)}`, "error");
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function createNewSessionWithSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string): Promise<void> {
|
|
77
|
+
const title = sessionDisplayName(result);
|
|
78
|
+
const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "New + Context");
|
|
79
|
+
|
|
80
|
+
const commandCtx = asCommandContext(ctx);
|
|
81
|
+
if (typeof commandCtx.newSession === "function") {
|
|
82
|
+
const parentSession = ctx.sessionManager.getSessionFile?.();
|
|
83
|
+
let replacementStarted = false;
|
|
84
|
+
try {
|
|
85
|
+
const switchResult = await commandCtx.newSession({
|
|
86
|
+
parentSession,
|
|
87
|
+
withSession: async (replacementCtx: any) => {
|
|
88
|
+
replacementStarted = true;
|
|
89
|
+
try {
|
|
90
|
+
if (typeof replacementCtx.sendMessage === "function") {
|
|
91
|
+
await replacementCtx.sendMessage({ customType: SESSION_SEARCH_CONTEXT_TYPE, content: message.content, details: message.details, display: true }, { triggerTurn: false });
|
|
92
|
+
}
|
|
93
|
+
replacementCtx.ui.notify(`New session has context from ${title}`, "info");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
replacementCtx.ui.notify(`New session context import failed: ${stringifyError(error)}`, "error");
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
if (switchResult.cancelled) ctx.ui.notify("New session cancelled", "info");
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!replacementStarted) throw error;
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
setPendingSessionSearchMessage(message);
|
|
106
|
+
ctx.ui.setEditorText("/new");
|
|
107
|
+
ctx.ui.notify(`${title} — press enter to create a new session with this context`, "info");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function consumePendingSessionSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, reason: unknown): Promise<void> {
|
|
111
|
+
if (reason !== "new") return;
|
|
112
|
+
const pending = getPendingSessionSearchMessage();
|
|
113
|
+
if (!pending) return;
|
|
114
|
+
setPendingSessionSearchMessage(undefined);
|
|
115
|
+
pi.sendMessage({ customType: SESSION_SEARCH_CONTEXT_TYPE, content: pending.content, details: pending.details, display: true }, { triggerTurn: false });
|
|
116
|
+
if (ctx.hasUI) ctx.ui.notify("Imported session-search context.", "info");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, initialQuery = ""): Promise<void> {
|
|
120
|
+
if (!ctx.hasUI) {
|
|
121
|
+
ctx.ui.notify("Session search requires interactive UI", "warning");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const sessions = await refreshQolSessionSearchCache(ctx);
|
|
125
|
+
const releaseModalLock = acquireVstackModalLock();
|
|
126
|
+
let action: QolSessionPaletteAction | undefined;
|
|
127
|
+
try {
|
|
128
|
+
const currentModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
|
|
129
|
+
action = await ctx.ui.custom<QolSessionPaletteAction>((tui, theme, _keybindings, done) =>
|
|
130
|
+
new QolSessionSearchComponent(done, tui, theme, sessions, ctx.cwd, initialQuery, currentModel), {
|
|
131
|
+
overlay: true,
|
|
132
|
+
overlayOptions: {
|
|
133
|
+
anchor: "center",
|
|
134
|
+
width: Math.max(70, Math.floor(settingNumber("sessionSearch.overlayWidth", 104, ctx.cwd))),
|
|
135
|
+
maxHeight: "90%",
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
} finally {
|
|
139
|
+
releaseModalLock();
|
|
140
|
+
}
|
|
141
|
+
if (!action || action.type === "cancel" || !action.result) return;
|
|
142
|
+
if (action.type === "resume" || action.type === "fork") {
|
|
143
|
+
if (!(await runSessionSearchResumeOrFork(pi, ctx, action))) queueSessionSearchCommandAction(ctx, action);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (action.type === "copy") {
|
|
147
|
+
const text = action.message?.text || action.result.firstMessage;
|
|
148
|
+
ctx.ui.setEditorText(text);
|
|
149
|
+
ctx.ui.notify("Copied selected prompt into the editor", "info");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (action.type === "summarize") {
|
|
153
|
+
try {
|
|
154
|
+
await injectSessionSearchContext(pi, ctx, action.result, action.customPrompt);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const message = stringifyError(error);
|
|
157
|
+
ctx.ui.notify(/cancelled/i.test(message) ? "Inject + Context cancelled" : `Inject + Context failed: ${message}`, /cancelled/i.test(message) ? "info" : "error");
|
|
158
|
+
ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (action.type === "newSession") {
|
|
163
|
+
try {
|
|
164
|
+
await createNewSessionWithSearchContext(pi, ctx, action.result, action.customPrompt);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const message = stringifyError(error);
|
|
167
|
+
ctx.ui.notify(/cancelled/i.test(message) ? "New + Context cancelled" : `New + Context failed: ${message}`, /cancelled/i.test(message) ? "info" : "error");
|
|
168
|
+
ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SESSION_SEARCH_LIMIT,
|
|
3
|
+
DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS,
|
|
4
|
+
} from "../constants.js";
|
|
5
|
+
import { settingNumber } from "../settings.js";
|
|
6
|
+
import { stringifyError } from "../util.js";
|
|
7
|
+
import { sessionDisplayName, sessionResumeTitle, userMessagesForResult } from "./cache.js";
|
|
8
|
+
import type {
|
|
9
|
+
QolParsedSessionQuery,
|
|
10
|
+
QolSessionSearchHit,
|
|
11
|
+
QolSessionSearchResult,
|
|
12
|
+
QolSessionSearchSession,
|
|
13
|
+
QolSessionUserMessage,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
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
|
+
export function normalizeSearchText(text: string): string {
|
|
21
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseSessionSearchQuery(query: string): QolParsedSessionQuery {
|
|
25
|
+
const trimmed = query.trim();
|
|
26
|
+
if (!trimmed) return { mode: "tokens", tokens: [] };
|
|
27
|
+
if (trimmed.startsWith("re:")) {
|
|
28
|
+
const source = trimmed.slice(3).trim();
|
|
29
|
+
if (!source) return { error: "Empty regex", mode: "regex", tokens: [] };
|
|
30
|
+
try {
|
|
31
|
+
return { mode: "regex", regex: new RegExp(source, "i"), tokens: [] };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return { error: stringifyError(error), mode: "regex", tokens: [] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tokens: QolParsedSessionQuery["tokens"] = [];
|
|
38
|
+
let buffer = "";
|
|
39
|
+
let inQuote = false;
|
|
40
|
+
let unclosed = false;
|
|
41
|
+
const flush = (kind: "fuzzy" | "phrase") => {
|
|
42
|
+
const value = buffer.trim();
|
|
43
|
+
buffer = "";
|
|
44
|
+
if (value) tokens.push({ kind, value });
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
48
|
+
const char = trimmed[i]!;
|
|
49
|
+
if (char === '"') {
|
|
50
|
+
if (inQuote) {
|
|
51
|
+
flush("phrase");
|
|
52
|
+
inQuote = false;
|
|
53
|
+
} else {
|
|
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;
|
|
64
|
+
}
|
|
65
|
+
if (inQuote) unclosed = true;
|
|
66
|
+
if (unclosed) {
|
|
67
|
+
return {
|
|
68
|
+
mode: "tokens",
|
|
69
|
+
tokens: trimmed.split(/\s+/).filter(Boolean).map((value) => ({ kind: "fuzzy", value })),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
flush(inQuote ? "phrase" : "fuzzy");
|
|
73
|
+
return { mode: "tokens", tokens };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function searchWordLengthPenalty(word: string, query: string): number {
|
|
77
|
+
return Math.max(0, word.length - query.length);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function searchStringScore(needle: string, haystack: string): number | undefined {
|
|
81
|
+
const query = normalizeSearchText(needle);
|
|
82
|
+
const text = normalizeSearchText(haystack);
|
|
83
|
+
if (!query) return 0;
|
|
84
|
+
let best: number | undefined;
|
|
85
|
+
const record = (score: number) => {
|
|
86
|
+
best = best === undefined ? score : Math.min(best, score);
|
|
87
|
+
};
|
|
88
|
+
if (/^[a-z0-9_]+$/i.test(query)) {
|
|
89
|
+
for (const match of text.matchAll(/[a-z0-9_]+/gi)) {
|
|
90
|
+
const word = match[0].toLowerCase();
|
|
91
|
+
if (word === query) record(0);
|
|
92
|
+
else if (word.startsWith(query)) record(10 + searchWordLengthPenalty(word, query));
|
|
93
|
+
else if (word.includes(query)) record(100 + searchWordLengthPenalty(word, query));
|
|
94
|
+
}
|
|
95
|
+
return best;
|
|
96
|
+
}
|
|
97
|
+
return text.includes(query) ? 0 : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
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
|
+
function snippetAround(text: string, start: number, length: number, width: number, lead = Math.floor(width / 3)): string {
|
|
122
|
+
const safeLead = Math.max(0, Math.min(width, lead));
|
|
123
|
+
const safeStart = Math.max(0, start - safeLead);
|
|
124
|
+
const safeEnd = Math.min(text.length, Math.max(start + length, safeStart + width));
|
|
125
|
+
const prefix = safeStart > 0 ? "…" : "";
|
|
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);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resultFromSession(session: QolSessionSearchSession, rank = 0, snippets: string[] = []): QolSessionSearchResult {
|
|
181
|
+
return { ...session, rank, snippets };
|
|
182
|
+
}
|
|
183
|
+
|
|
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 };
|
|
191
|
+
let score = 0;
|
|
192
|
+
for (const token of parsed.tokens) {
|
|
193
|
+
const tokenScore = searchStringScore(token.value, text);
|
|
194
|
+
if (tokenScore === undefined) return { matches: false, score: 0 };
|
|
195
|
+
score += tokenScore;
|
|
196
|
+
}
|
|
197
|
+
return { matches: true, score };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sessionTitleSearchText(session: QolSessionSearchSession): string {
|
|
201
|
+
return [session.name ?? "", sessionResumeTitle(session), sessionDisplayName(session)].join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function matchPromptSearch(_session: QolSessionSearchSession, message: QolSessionUserMessage, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
|
|
205
|
+
return matchTextSearch(message.text, parsed);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function buildPromptSnippet(message: QolSessionUserMessage, parsed: QolParsedSessionQuery): string {
|
|
209
|
+
const source = message.text.replace(/\s+/g, " ").trim();
|
|
210
|
+
if (!source) return "";
|
|
211
|
+
if (parsed.mode === "regex" && parsed.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);
|
|
217
|
+
const lower = source.toLowerCase();
|
|
218
|
+
for (const token of parsed.tokens) {
|
|
219
|
+
const value = normalizeSearchText(token.value);
|
|
220
|
+
if (!value) continue;
|
|
221
|
+
const index = lower.indexOf(value.toLowerCase());
|
|
222
|
+
if (index >= 0) return snippetAround(source, index, value.length, 160, 24);
|
|
223
|
+
}
|
|
224
|
+
return source.slice(0, 160);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function promptRecencyTime(hit: QolSessionSearchHit): number {
|
|
228
|
+
if (typeof hit.message.timestamp === "number" && Number.isFinite(hit.message.timestamp)) return hit.message.timestamp;
|
|
229
|
+
return hit.result.modified.getTime();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function compareSessionSearchHitsRecent(a: QolSessionSearchHit, b: QolSessionSearchHit): number {
|
|
233
|
+
return promptRecencyTime(b) - promptRecencyTime(a)
|
|
234
|
+
|| b.result.modified.getTime() - a.result.modified.getTime()
|
|
235
|
+
|| b.message.index - a.message.index;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function searchQolSessionHits(sessions: QolSessionSearchSession[], query: string, cwd: string): QolSessionSearchHit[] {
|
|
239
|
+
const limit = Math.max(1, Math.floor(settingNumber("sessionSearch.resultLimit", DEFAULT_SESSION_SEARCH_LIMIT, cwd)));
|
|
240
|
+
const parsed = parseSessionSearchQuery(query);
|
|
241
|
+
if (parsed.error) return [];
|
|
242
|
+
const hits: QolSessionSearchHit[] = [];
|
|
243
|
+
if (!query.trim()) {
|
|
244
|
+
for (const session of sessions) {
|
|
245
|
+
const result = resultFromSession(session);
|
|
246
|
+
for (const message of userMessagesForResult(result)) {
|
|
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);
|
|
253
|
+
}
|
|
254
|
+
for (const session of sessions) {
|
|
255
|
+
let addedPromptHit = false;
|
|
256
|
+
for (const message of userMessagesForResult(resultFromSession(session))) {
|
|
257
|
+
const match = matchPromptSearch(session, message, parsed);
|
|
258
|
+
if (!match.matches) continue;
|
|
259
|
+
const snippet = buildPromptSnippet(message, parsed);
|
|
260
|
+
hits.push({ message, rank: match.score, result: resultFromSession(session, match.score, [snippet]), snippet });
|
|
261
|
+
addedPromptHit = true;
|
|
262
|
+
}
|
|
263
|
+
if (!addedPromptHit) {
|
|
264
|
+
const titleMatch = matchTextSearch(sessionTitleSearchText(session), parsed);
|
|
265
|
+
if (!titleMatch.matches) continue;
|
|
266
|
+
const messages = userMessagesForResult(resultFromSession(session));
|
|
267
|
+
const message = messages[messages.length - 1];
|
|
268
|
+
if (!message) continue;
|
|
269
|
+
const snippet = buildPromptSnippet(message, { mode: "tokens", tokens: [] });
|
|
270
|
+
hits.push({ message, rank: titleMatch.score, result: resultFromSession(session, titleMatch.score, [snippet]), snippet });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
hits.sort((a, b) => a.rank - b.rank || compareSessionSearchHitsRecent(a, b));
|
|
274
|
+
return hits.slice(0, limit);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function formatSessionSearchDate(date: Date): string {
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
const diffMs = Math.max(0, now - date.getTime());
|
|
280
|
+
const diffMins = Math.floor(diffMs / 60_000);
|
|
281
|
+
const diffHours = Math.floor(diffMs / 3_600_000);
|
|
282
|
+
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
283
|
+
if (diffMins < 1) return "now";
|
|
284
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
285
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
286
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
287
|
+
return date.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
|
|
288
|
+
}
|