@vanillagreen/pi-qol 1.2.0 → 1.4.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.
@@ -0,0 +1,264 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { complete, type Message } from "@earendil-works/pi-ai";
3
+ import { convertToLlm, serializeConversation, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ DEFAULT_COMPACTION_MAX_TOKENS,
6
+ DEFAULT_COMPACTION_MODEL,
7
+ DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS,
8
+ QOL_COMPACTION_SYSTEM_PROMPT,
9
+ } from "./constants.js";
10
+ import { settingBoolean, settingNumber, settingString } from "./settings.js";
11
+ import { stringifyError } from "./util.js";
12
+
13
+ export type QolSummaryProfile = "concise" | "balanced" | "exhaustive";
14
+ export type QolSummaryPurpose = "compaction" | "branch-summary" | "session-search";
15
+
16
+ export function compactionNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
17
+ if (ctx.hasUI && settingBoolean("compaction.notify", true, ctx.cwd)) ctx.ui.notify(message, level);
18
+ }
19
+
20
+ export function compactionProfile(cwd: string): QolSummaryProfile {
21
+ const value = settingString("compaction.profile", "balanced", cwd);
22
+ return value === "concise" || value === "exhaustive" ? value : "balanced";
23
+ }
24
+
25
+ function compactionProfileInstructions(profile: QolSummaryProfile): string {
26
+ if (profile === "concise") return "Prefer a compact continuation summary. Include only decisions, current state, modified/read files, blockers, and concrete next steps.";
27
+ if (profile === "exhaustive") return "Be thorough. The summary may replace substantial conversation history, so preserve all relevant implementation details, alternatives considered, exact file paths, commands, errors, and pending work.";
28
+ return "Be complete but not verbose. Preserve enough detail for a future assistant to continue without the old transcript.";
29
+ }
30
+
31
+ function stripThinkingForSummary(messages: Message[]): Message[] {
32
+ return messages.map((message) => {
33
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return message;
34
+ return {
35
+ ...message,
36
+ content: message.content.filter((part: any) => part?.type !== "thinking"),
37
+ };
38
+ });
39
+ }
40
+
41
+ export function serializeMessagesForSummary(messages: AgentMessage[]): string {
42
+ return serializeConversation(stripThinkingForSummary(convertToLlm(messages)));
43
+ }
44
+
45
+ function customMessageContentToText(content: unknown): string {
46
+ if (typeof content === "string") return content;
47
+ if (!Array.isArray(content)) return "";
48
+ const parts: string[] = [];
49
+ for (const part of content) {
50
+ if (part?.type === "text" && typeof part.text === "string") parts.push(part.text);
51
+ else if (part?.type === "image") parts.push(`[image${typeof part.mimeType === "string" ? ` ${part.mimeType}` : ""}]`);
52
+ else if (part?.type) parts.push(`[${String(part.type)}]`);
53
+ }
54
+ return parts.join("\n").trim();
55
+ }
56
+
57
+ function buildSummaryPrompt(options: {
58
+ conversationText: string;
59
+ customInstructions?: string;
60
+ previousSummary?: string;
61
+ profile: QolSummaryProfile;
62
+ purpose: QolSummaryPurpose;
63
+ }): string {
64
+ const purposeText = options.purpose === "branch-summary"
65
+ ? "the branch being left during /tree navigation"
66
+ : options.purpose === "session-search"
67
+ ? "the previous session being imported into the current context"
68
+ : "the conversation span being compacted";
69
+ const previous = options.previousSummary ? `<previous-summary>\n${options.previousSummary}\n</previous-summary>\n\n` : "";
70
+ const custom = options.customInstructions?.trim() ? `<custom-instructions>\n${options.customInstructions.trim()}\n</custom-instructions>\n\n` : "";
71
+ return `${custom}${previous}<conversation>\n${options.conversationText}\n</conversation>\n\nSummarize ${purposeText} for a coding agent that must continue the work.\n\n${compactionProfileInstructions(options.profile)}\n\nUse this markdown shape:\n\n## Goal\n[What the user is trying to accomplish]\n\n## Constraints & Preferences\n- [Requirements, style, safety, or user preferences]\n\n## Progress\n### Done\n- [x] [Completed work]\n\n### In Progress\n- [ ] [Current partial work]\n\n### Blocked\n- [Blockers or none]\n\n## Key Decisions\n- **[Decision]**: [Rationale]\n\n## Files & Commands\n- [Files read/modified and important commands/results]\n\n## Next Steps\n1. [Most important next action]\n\n## Critical Context\n- [Anything easy to lose but needed later]`;
72
+ }
73
+
74
+ async function summarizeWithRemote(endpoint: string, systemPrompt: string, promptText: string, maxTokens: number, signal?: AbortSignal): Promise<string> {
75
+ const response = await fetch(endpoint, {
76
+ body: JSON.stringify({ maxTokens, prompt: promptText, systemPrompt }),
77
+ headers: { "content-type": "application/json" },
78
+ method: "POST",
79
+ signal,
80
+ });
81
+ const text = await response.text();
82
+ if (!response.ok) throw new Error(`Remote compaction endpoint returned ${response.status}: ${text.slice(0, 500)}`);
83
+ let parsed: unknown;
84
+ try {
85
+ parsed = JSON.parse(text);
86
+ } catch {
87
+ throw new Error("Remote compaction endpoint did not return JSON");
88
+ }
89
+ if (parsed && typeof parsed === "object") {
90
+ const record = parsed as Record<string, unknown>;
91
+ if (typeof record.summary === "string") return record.summary;
92
+ if (typeof record.text === "string") return record.text;
93
+ }
94
+ throw new Error("Remote compaction response missing summary");
95
+ }
96
+
97
+ export function resolveConfiguredModel(ctx: ExtensionContext, configured: string): any | undefined {
98
+ if (!configured || configured === "current") return ctx.model;
99
+ const withoutThinking = configured.replace(/:(off|minimal|low|medium|high|xhigh)$/i, "");
100
+ const slash = withoutThinking.indexOf("/");
101
+ if (slash > 0) return ctx.modelRegistry.find(withoutThinking.slice(0, slash), withoutThinking.slice(slash + 1));
102
+ const providers = [ctx.model?.provider, "google", "openai", "anthropic", "mistral", "moonshot", "cloudflare-ai-gateway", "cloudflare-workers-ai"].filter((value): value is string => typeof value === "string");
103
+ for (const provider of providers) {
104
+ const model = ctx.modelRegistry.find(provider, withoutThinking);
105
+ if (model) return model;
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ export function modelLabel(model: any): string {
111
+ return model ? `${model.provider}/${model.id}` : "unknown model";
112
+ }
113
+
114
+ export async function generateQolSummary(ctx: ExtensionContext, options: {
115
+ conversationText: string;
116
+ customInstructions?: string;
117
+ previousSummary?: string;
118
+ maxTokens?: number;
119
+ model?: string;
120
+ purpose: QolSummaryPurpose;
121
+ signal?: AbortSignal;
122
+ }): Promise<{ model: string; summary: string; via: "model" | "remote" }> {
123
+ const maxTokens = Math.max(256, Math.floor(options.maxTokens ?? settingNumber("compaction.maxTokens", DEFAULT_COMPACTION_MAX_TOKENS, ctx.cwd)));
124
+ const promptText = buildSummaryPrompt({
125
+ conversationText: options.conversationText,
126
+ customInstructions: options.customInstructions,
127
+ previousSummary: settingBoolean("compaction.includePreviousSummary", true, ctx.cwd) ? options.previousSummary : undefined,
128
+ profile: compactionProfile(ctx.cwd),
129
+ purpose: options.purpose,
130
+ });
131
+
132
+ const remoteEndpoint = settingString("compaction.remoteEndpoint", "", ctx.cwd);
133
+ if (settingBoolean("compaction.remoteEnabled", false, ctx.cwd) && remoteEndpoint) {
134
+ try {
135
+ const summary = await summarizeWithRemote(remoteEndpoint, QOL_COMPACTION_SYSTEM_PROMPT, promptText, maxTokens, options.signal);
136
+ return { model: remoteEndpoint, summary, via: "remote" };
137
+ } catch (error) {
138
+ compactionNotify(ctx, `Remote compaction failed, trying model fallback: ${stringifyError(error)}`, "warning");
139
+ }
140
+ }
141
+
142
+ const configuredModel = options.model ?? settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd);
143
+ const model = resolveConfiguredModel(ctx, configuredModel);
144
+ if (!model) throw new Error(`Summary model not found: ${configuredModel}`);
145
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
146
+ if (!auth.ok) throw new Error(auth.error);
147
+ if (!auth.apiKey) throw new Error(`No API key for ${model.provider}`);
148
+
149
+ const message: Message = {
150
+ content: [{ text: promptText, type: "text" }],
151
+ role: "user",
152
+ timestamp: Date.now(),
153
+ };
154
+ const response = await complete(
155
+ model,
156
+ { messages: [message], systemPrompt: QOL_COMPACTION_SYSTEM_PROMPT },
157
+ { apiKey: auth.apiKey, headers: auth.headers, maxTokens, signal: options.signal },
158
+ );
159
+ const summary = response.content
160
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
161
+ .map((content) => content.text)
162
+ .join("\n")
163
+ .trim();
164
+ return { model: modelLabel(model), summary, via: "model" };
165
+ }
166
+
167
+ export async function handleQolCompaction(event: any, ctx: ExtensionContext): Promise<any> {
168
+ if (!settingBoolean("compaction.customEnabled", false, ctx.cwd)) return undefined;
169
+ const preparation = event.preparation ?? {};
170
+ const messages = [...(preparation.messagesToSummarize ?? []), ...(preparation.turnPrefixMessages ?? [])];
171
+ if (messages.length === 0) return undefined;
172
+ const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
173
+ compactionNotify(ctx, `QOL compaction: summarizing ${messages.length} message(s), ${tokensBefore.toLocaleString()} token(s).`, "info");
174
+ try {
175
+ const conversationText = serializeMessagesForSummary(messages);
176
+ const result = await generateQolSummary(ctx, {
177
+ conversationText,
178
+ customInstructions: event.customInstructions,
179
+ previousSummary: preparation.previousSummary,
180
+ purpose: "compaction",
181
+ signal: event.signal,
182
+ });
183
+ if (!result.summary.trim()) throw new Error("Compaction summary was empty");
184
+ compactionNotify(ctx, `QOL compaction complete via ${result.via}: ${result.model}`, "info");
185
+ return {
186
+ compaction: {
187
+ details: {
188
+ messageCount: messages.length,
189
+ model: result.model,
190
+ profile: compactionProfile(ctx.cwd),
191
+ source: "pi-qol",
192
+ via: result.via,
193
+ },
194
+ firstKeptEntryId: preparation.firstKeptEntryId,
195
+ summary: result.summary,
196
+ tokensBefore: preparation.tokensBefore,
197
+ },
198
+ };
199
+ } catch (error) {
200
+ if (event.signal?.aborted) return undefined;
201
+ compactionNotify(ctx, `QOL compaction failed: ${stringifyError(error)}`, "error");
202
+ return settingBoolean("compaction.fallbackToDefault", true, ctx.cwd) ? undefined : { cancel: true };
203
+ }
204
+ }
205
+
206
+ function summarizeEntryForBranch(entry: any): string[] {
207
+ if (entry?.type === "message" && entry.message) return [serializeMessagesForSummary([entry.message])];
208
+ if (entry?.type === "compaction" && typeof entry.summary === "string") return [`[Compaction summary]: ${entry.summary}`];
209
+ if (entry?.type === "branch_summary" && typeof entry.summary === "string") return [`[Branch summary]: ${entry.summary}`];
210
+ if (entry?.type === "custom_message") return [`[Custom message${entry.customType ? `:${entry.customType}` : ""}]: ${customMessageContentToText(entry.content) || "[empty]"}`];
211
+ return [];
212
+ }
213
+
214
+ export async function handleQolBranchSummary(event: any, ctx: ExtensionContext): Promise<any> {
215
+ if (!settingBoolean("compaction.branchSummaryEnabled", false, ctx.cwd)) return undefined;
216
+ const preparation = event.preparation ?? {};
217
+ if (preparation.userWantsSummary !== true) return undefined;
218
+ const entries = Array.isArray(preparation.entriesToSummarize) ? preparation.entriesToSummarize : [];
219
+ const conversationText = entries.flatMap(summarizeEntryForBranch).join("\n\n").trim();
220
+ if (!conversationText) return undefined;
221
+ compactionNotify(ctx, `QOL branch summary: summarizing ${entries.length} entr${entries.length === 1 ? "y" : "ies"}.`, "info");
222
+ try {
223
+ const result = await generateQolSummary(ctx, {
224
+ conversationText,
225
+ customInstructions: event.customInstructions ?? preparation.customInstructions,
226
+ purpose: "branch-summary",
227
+ signal: event.signal,
228
+ });
229
+ if (!result.summary.trim()) throw new Error("Branch summary was empty");
230
+ return {
231
+ summary: {
232
+ details: { entryCount: entries.length, model: result.model, profile: compactionProfile(ctx.cwd), source: "pi-qol", via: result.via },
233
+ summary: result.summary,
234
+ },
235
+ };
236
+ } catch (error) {
237
+ if (event.signal?.aborted) return undefined;
238
+ compactionNotify(ctx, `QOL branch summary failed: ${stringifyError(error)}`, "error");
239
+ return undefined;
240
+ }
241
+ }
242
+
243
+ function contextUsage(ctx: ExtensionContext): { contextWindow?: number; tokens: number } | undefined {
244
+ const usage = ctx.getContextUsage?.() as { tokens?: unknown; contextWindow?: unknown } | undefined;
245
+ const tokens = Number(usage?.tokens);
246
+ if (!Number.isFinite(tokens) || tokens <= 0) return undefined;
247
+ const contextWindow = Number(usage?.contextWindow ?? ctx.model?.contextWindow);
248
+ return { contextWindow: Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : undefined, tokens };
249
+ }
250
+
251
+ export function compactionTriggerReason(ctx: ExtensionContext): string | undefined {
252
+ const usage = contextUsage(ctx);
253
+ if (!usage) return undefined;
254
+ const tokenLimit = settingNumber("compaction.thresholdTokens", -1, ctx.cwd);
255
+ if (tokenLimit > 0 && usage.tokens >= tokenLimit) return `${usage.tokens.toLocaleString()} tokens >= ${Math.floor(tokenLimit).toLocaleString()} token limit`;
256
+ const percentLimit = settingNumber("compaction.thresholdPercent", -1, ctx.cwd);
257
+ if (percentLimit > 0 && usage.contextWindow) {
258
+ const percent = (usage.tokens / usage.contextWindow) * 100;
259
+ if (percent >= percentLimit) return `${percent.toFixed(1)}% context >= ${percentLimit}% limit`;
260
+ }
261
+ const idleLimit = settingNumber("compaction.idleThresholdTokens", DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS, ctx.cwd);
262
+ if (usage.tokens >= idleLimit) return `${usage.tokens.toLocaleString()} tokens >= ${Math.floor(idleLimit).toLocaleString()} idle threshold`;
263
+ return undefined;
264
+ }
@@ -0,0 +1,107 @@
1
+ export const INSTALL_SYMBOL = Symbol.for("vstack.pi-qol.installed");
2
+ export const CONFIG_ID = "@vanillagreen/pi-qol";
3
+
4
+ export const STATUS_KEY = "qol-attachments";
5
+ export const SESSION_SEARCH_STATUS_KEY = "qol-session-search";
6
+ export const SESSION_SEARCH_CONTEXT_TYPE = "qol-session-context";
7
+ export const CONTEXT_USAGE_MESSAGE_TYPE = "qol-context-usage";
8
+ export const SESSION_MANAGER_STATUS_KEY = "session-manager";
9
+
10
+ export const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".heic", ".heif"]);
11
+ export const IMAGE_PATH_PATTERN = /(^|[\s(\[{<"'`])(@?(?:~|\.\.?|\/)[^\s)\]}>"'`]+?\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|heif))(?=$|[\s)\]}>"'`,.;:!?])/gi;
12
+
13
+ export const QUESTION_SERVICE_SYMBOL = Symbol.for("vstack.pi-questions.service");
14
+ export const QOL_NOTIFICATION_SERVICE_SYMBOL = Symbol.for("vstack.pi-qol.notification-service");
15
+ export const VSTACK_MODAL_LOCK_SYMBOL = Symbol.for("vstack.pi.modal-lock");
16
+ export const CAVEMAN_BRIDGE_SYMBOL = Symbol.for("vstack.pi.caveman");
17
+ export const PI_AGENTS_STATUSLINE_SYMBOL = Symbol.for("vstack.pi-agents-tmux.statusline");
18
+
19
+ export const CAVEMAN_ICON_ACTIVE = "\uee9a";
20
+ export const CAVEMAN_ICON_INACTIVE = "\u{f19e0}";
21
+
22
+ export const THINKING_TIMER_STORE_SYMBOL = Symbol.for("vstack.pi-qol.thinking-timer.store");
23
+ export const THINKING_TIMER_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.thinking-timer.patch");
24
+ export const SESSION_SEARCH_PENDING_SYMBOL = Symbol.for("vstack.pi-qol.session-search.pending-context");
25
+ export const PENDING_QUEUE_THEME_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.pending-queue.theme-patch");
26
+ export const STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.status-text-alignment-patch");
27
+
28
+ export const QUESTION_OPENED_EVENT = "vstack:pi-questions:opened";
29
+ export const QUESTION_NOTIFY_DEDUP_MS = 2000;
30
+
31
+ export const DEFAULT_NOTIFICATION_TITLE = "Pi";
32
+ export const DEFAULT_NOTIFICATION_COOLDOWN_SECONDS = 8;
33
+ export const DEFAULT_NOTIFICATION_BODY_MAX_CHARS = 240;
34
+ export const DEFAULT_TMUX_MESSAGE_DURATION_MS = 5000;
35
+
36
+ export const DEFAULT_COMPACTION_MODEL = "google/gemini-2.5-flash";
37
+ export const DEFAULT_COMPACTION_MAX_TOKENS = 8192;
38
+ export const DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS = 200000;
39
+ export const DEFAULT_IDLE_COMPACTION_SECONDS = 300;
40
+
41
+ export const DEFAULT_PERMISSION_GATE_COMMANDS = "rm -Rf";
42
+ export const DEFAULT_PERMISSION_GATE_PREVIEW_LINES = 12;
43
+ export const DEFAULT_PERMISSION_GATE_PREVIEW_CHARS = 1200;
44
+ export const DEFAULT_PERMISSION_GATE_PREVIEW_LINE_WIDTH = 120;
45
+
46
+ export const DEFAULT_SESSION_SEARCH_LIMIT = 40;
47
+ export const DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS = 6;
48
+ export const DEFAULT_SESSION_SEARCH_SHORTCUT = "f2";
49
+ export const DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS = 180_000;
50
+ export const DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS = 4096;
51
+ export const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 0;
52
+ export const SESSION_SEARCH_OVERLAY_HEIGHT_RATIO = 0.9;
53
+
54
+ export const DEFAULT_AUTO_RENAME_MODEL = "openai-codex/gpt-5.4-mini";
55
+ export const DEFAULT_AUTO_RENAME_FALLBACK_MODEL = "current";
56
+ export const DEFAULT_AUTO_RENAME_INPUT_CHARS = 2000;
57
+ export const DEFAULT_AUTO_RENAME_NAME_CHARS = 80;
58
+ export const DEFAULT_AUTO_RENAME_MAX_TOKENS = 96;
59
+ export const DEFAULT_AUTO_RENAME_TIMEOUT_MS = 12_000;
60
+ export const DEFAULT_INPUT_BOTTOM_PADDING_LINES = 0;
61
+
62
+ export const SESSION_TITLE_SYNC_INTERVAL_MS = 1000;
63
+
64
+ // tmux trims plain leading/trailing spaces from pane-border-format output, so
65
+ // use NBSP padding. Terminals render it as a space, but tmux keeps it visible.
66
+ export const TMUX_SESSION_TITLE_PAD = "\u00a0";
67
+ export const TMUX_SESSION_TITLE_BORDER_FORMAT = `${TMUX_SESSION_TITLE_PAD}#{pane_title}${TMUX_SESSION_TITLE_PAD}`;
68
+
69
+ export const THINKING_LABEL_DEFAULT = "\ue28c ";
70
+
71
+ export const QOL_COMPACTION_SYSTEM_PROMPT = "You summarize coding-agent sessions for continuation. Preserve exact technical facts, filenames, commands, constraints, decisions, blockers, and next actions. Do not invent details.";
72
+
73
+ export const AUTO_RENAME_SYSTEM_PROMPT = "You create short, descriptive session names for coding-agent chats. Use 2-6 words in Title Case. Respond with only the name, no quotes, explanations, markdown, emoji, or trailing punctuation.";
74
+
75
+ export const DEFAULT_AUTO_RENAME_PROMPT = `Generate a short, descriptive title for this Pi coding-agent session based on the first user message.
76
+
77
+ Rules:
78
+ - Use 2-6 words
79
+ - Use Title Case
80
+ - Be specific about the user's task or topic
81
+ - Do not mention Pi unless Pi itself is the task
82
+ - Return only the title
83
+
84
+ First user message:
85
+ {{message}}`;
86
+
87
+ export const HANDOFF_SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
88
+
89
+ 1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
90
+ 2. Lists any relevant files that were discussed or modified
91
+ 3. Clearly states the next task based on the user's goal
92
+ 4. Is self-contained - the new thread should be able to proceed without the old conversation
93
+
94
+ Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
95
+
96
+ Example output format:
97
+ ## Context
98
+ We've been working on X. Key decisions:
99
+ - Decision 1
100
+ - Decision 2
101
+
102
+ Files involved:
103
+ - path/to/file1.ts
104
+ - path/to/file2.ts
105
+
106
+ ## Task
107
+ [Clear description of what to do next based on user's goal]`;