@vanillagreen/pi-qol 1.4.3 → 1.6.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,190 @@
1
+ // Pre-compaction handoff artifact: writes a JSON snapshot of the previous
2
+ // summary, the most recent task panel state, and recently referenced file
3
+ // paths so an operator can recover continuity if the compaction summary
4
+ // drops something critical. The pure data-building + write logic lives here
5
+ // so it can be unit-tested without the pi-coding-agent / pi-ai peer deps.
6
+
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { basename, dirname, join, resolve } from "node:path";
10
+
11
+ import { QOL_BUDGET_HANDOFF_FOLDER, QOL_BUDGET_HANDOFF_LATEST } from "./constants.js";
12
+
13
+ export interface QolBudgetHandoff {
14
+ reason: string;
15
+ timestamp: number;
16
+ sessionId: string;
17
+ tokensBefore?: number;
18
+ messageCount: number;
19
+ previousSummary?: string;
20
+ taskState?: unknown;
21
+ artifactRefs: string[];
22
+ model?: string;
23
+ }
24
+
25
+ function expandHome(input: string): string {
26
+ if (input === "~") return homedir();
27
+ if (input.startsWith("~/")) return join(homedir(), input.slice(2));
28
+ return input;
29
+ }
30
+
31
+ export function piUserDir(): string {
32
+ return resolve(expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "~/.pi/agent"));
33
+ }
34
+
35
+ export function safeFileName(value: string): string {
36
+ return value.replace(/[^\w.-]+/g, "_");
37
+ }
38
+
39
+ export interface HandoffSessionAccessor {
40
+ getSessionId?: () => string | undefined;
41
+ getSessionFile?: () => string | undefined;
42
+ getBranch?: () => unknown[];
43
+ }
44
+
45
+ export function sessionIdFromManager(sm: HandoffSessionAccessor, fallbackPid: number = process.pid): string {
46
+ try {
47
+ const id = typeof sm.getSessionId === "function" ? sm.getSessionId() : undefined;
48
+ if (typeof id === "string" && id.trim()) return id;
49
+ } catch {
50
+ // Stale ctx may throw; fall through to file/pid.
51
+ }
52
+ try {
53
+ const file = typeof sm.getSessionFile === "function" ? sm.getSessionFile() : undefined;
54
+ if (typeof file === "string" && file.trim()) return basename(file, ".jsonl");
55
+ } catch {
56
+ // Same: fall through.
57
+ }
58
+ return `ephemeral-${fallbackPid}`;
59
+ }
60
+
61
+ /**
62
+ * Walks the branch newest-first and returns the most recent task panel state
63
+ * embedded in a tool result. Returns undefined when no task state is found.
64
+ */
65
+ export function findLatestTaskState(branch: unknown[]): unknown {
66
+ for (let i = branch.length - 1; i >= 0; i -= 1) {
67
+ const entry = branch[i] as any;
68
+ if (entry?.type !== "message" || entry.message?.role !== "toolResult") continue;
69
+ const content = entry.message.content;
70
+ const parts = Array.isArray(content) ? content : [];
71
+ for (const part of parts) {
72
+ if (part?.type !== "toolResult") continue;
73
+ const details = part?.details;
74
+ if (details && typeof details === "object" && "state" in (details as Record<string, unknown>)) {
75
+ return (details as Record<string, unknown>).state;
76
+ }
77
+ }
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ const ARTIFACT_REF_PATTERN = /(?:^|\s|["'`(\[<])((?:\.{1,2}\/|\/|~\/)?[\w.\-+@/]+\.(?:md|json|jsonl|txt|log|ts|tsx|js|jsx|rs|toml|yml|yaml|html|sh|fish|bash|py|go|java|cs|cpp|h|hpp|sql|csv|env|lock|patch|diff))/g;
83
+
84
+ /**
85
+ * Collects up to `maxRefs` file path-shaped strings from message contents,
86
+ * walking newest-first. Used to give the handoff artifact a quick "if you
87
+ * are recovering, here are recent files" pointer list.
88
+ */
89
+ export function collectArtifactRefs(branch: unknown[], maxRefs = 20): string[] {
90
+ const refs = new Set<string>();
91
+ for (let i = branch.length - 1; i >= 0 && refs.size < maxRefs; i -= 1) {
92
+ const entry = branch[i] as any;
93
+ if (entry?.type !== "message") continue;
94
+ const content = entry.message?.content;
95
+ const parts = Array.isArray(content) ? content : [];
96
+ for (const part of parts) {
97
+ const text = typeof part?.text === "string" ? part.text : typeof part?.thinking === "string" ? part.thinking : "";
98
+ if (!text) continue;
99
+ ARTIFACT_REF_PATTERN.lastIndex = 0;
100
+ let match: RegExpExecArray | null;
101
+ while ((match = ARTIFACT_REF_PATTERN.exec(text)) !== null && refs.size < maxRefs) {
102
+ if (match[1]) refs.add(match[1]);
103
+ }
104
+ }
105
+ }
106
+ return Array.from(refs);
107
+ }
108
+
109
+ export interface BuildHandoffInput {
110
+ reason: string;
111
+ sessionManager: HandoffSessionAccessor;
112
+ preparation?: {
113
+ messagesToSummarize?: unknown[];
114
+ turnPrefixMessages?: unknown[];
115
+ previousSummary?: string;
116
+ tokensBefore?: number;
117
+ };
118
+ timestamp?: number;
119
+ }
120
+
121
+ export function buildBudgetHandoff(input: BuildHandoffInput): QolBudgetHandoff {
122
+ const preparation = input.preparation ?? {};
123
+ const messageCount = (preparation.messagesToSummarize?.length ?? 0) + (preparation.turnPrefixMessages?.length ?? 0);
124
+ let branch: unknown[] = [];
125
+ try {
126
+ const raw = typeof input.sessionManager.getBranch === "function" ? input.sessionManager.getBranch() : [];
127
+ branch = Array.isArray(raw) ? raw : [];
128
+ } catch {
129
+ branch = [];
130
+ }
131
+ return {
132
+ artifactRefs: collectArtifactRefs(branch),
133
+ messageCount,
134
+ previousSummary: preparation.previousSummary,
135
+ reason: input.reason,
136
+ sessionId: sessionIdFromManager(input.sessionManager),
137
+ taskState: findLatestTaskState(branch),
138
+ timestamp: input.timestamp ?? Date.now(),
139
+ tokensBefore: typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : undefined,
140
+ };
141
+ }
142
+
143
+ export interface HandoffWriteResult {
144
+ path?: string;
145
+ latestPath?: string;
146
+ error?: string;
147
+ }
148
+
149
+ export function handoffStampedPath(baseDir: string, timestamp: number): string {
150
+ return join(baseDir, `${new Date(timestamp).toISOString().replace(/[:.]/g, "-")}.json`);
151
+ }
152
+
153
+ export function handoffBaseDir(sessionId: string, root: string = piUserDir()): string {
154
+ return join(root, "vstack", "sessions", safeFileName(sessionId), QOL_BUDGET_HANDOFF_FOLDER);
155
+ }
156
+
157
+ export interface WriteHandoffOptions {
158
+ enabled: boolean;
159
+ root?: string;
160
+ /** Injection point so tests can stub fs without mocking node:fs. */
161
+ writer?: (path: string, payload: string) => void;
162
+ mkdir?: (path: string) => void;
163
+ }
164
+
165
+ function defaultMkdir(path: string): void {
166
+ mkdirSync(path, { recursive: true, mode: 0o700 });
167
+ }
168
+
169
+ function defaultWriter(path: string, payload: string): void {
170
+ writeFileSync(path, payload, { mode: 0o600 });
171
+ }
172
+
173
+ export function writeBudgetHandoffArtifact(handoff: QolBudgetHandoff, options: WriteHandoffOptions): HandoffWriteResult {
174
+ if (!options.enabled) return {};
175
+ const baseDir = handoffBaseDir(handoff.sessionId, options.root);
176
+ const stampedPath = handoffStampedPath(baseDir, handoff.timestamp);
177
+ const latestPath = join(dirname(baseDir), basename(baseDir), QOL_BUDGET_HANDOFF_LATEST);
178
+ const mkdir = options.mkdir ?? defaultMkdir;
179
+ const writer = options.writer ?? defaultWriter;
180
+ try {
181
+ mkdir(baseDir);
182
+ const payload = JSON.stringify(handoff, null, 2);
183
+ writer(stampedPath, payload);
184
+ writer(latestPath, payload);
185
+ return { latestPath, path: stampedPath };
186
+ } catch (error) {
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ return { error: message };
189
+ }
190
+ }
@@ -2,11 +2,30 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
2
  import { complete, type Message } from "@earendil-works/pi-ai";
3
3
  import { convertToLlm, serializeConversation, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import {
5
+ DEFAULT_BUDGET_GUARD_PERCENT,
6
+ DEFAULT_BUDGET_GUARD_TOKENS,
7
+ DEFAULT_BUDGET_MAX_INPUT_CHARS,
5
8
  DEFAULT_COMPACTION_MAX_TOKENS,
6
9
  DEFAULT_COMPACTION_MODEL,
7
10
  DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS,
8
11
  QOL_COMPACTION_SYSTEM_PROMPT,
9
12
  } from "./constants.js";
13
+ import {
14
+ chunkConversationText as chunkConversationTextRaw,
15
+ computeBudgetTrigger,
16
+ isBudgetGuardCompaction,
17
+ orchestrateChunkedSummary,
18
+ type BudgetTrigger,
19
+ type SummarizeOutcome,
20
+ type SummarizeRequest,
21
+ type TranscriptRiskResult,
22
+ } from "./budget-guard.js";
23
+ import {
24
+ buildBudgetHandoff as buildBudgetHandoffData,
25
+ writeBudgetHandoffArtifact as writeBudgetHandoffArtifactRaw,
26
+ type HandoffWriteResult,
27
+ type QolBudgetHandoff,
28
+ } from "./compaction-handoff.js";
10
29
  import { settingBoolean, settingNumber, settingString } from "./settings.js";
11
30
  import { stringifyError } from "./util.js";
12
31
 
@@ -95,7 +114,7 @@ async function summarizeWithRemote(endpoint: string, systemPrompt: string, promp
95
114
  }
96
115
 
97
116
  export function resolveConfiguredModel(ctx: ExtensionContext, configured: string): any | undefined {
98
- if (!configured || configured === "current") return ctx.model;
117
+ if (!configured || configured.trim().toLowerCase() === "current") return ctx.model;
99
118
  const withoutThinking = configured.replace(/:(off|minimal|low|medium|high|xhigh)$/i, "");
100
119
  const slash = withoutThinking.indexOf("/");
101
120
  if (slash > 0) return ctx.modelRegistry.find(withoutThinking.slice(0, slash), withoutThinking.slice(slash + 1));
@@ -111,20 +130,20 @@ export function modelLabel(model: any): string {
111
130
  return model ? `${model.provider}/${model.id}` : "unknown model";
112
131
  }
113
132
 
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)));
133
+ export function budgetMaxInputChars(ctx: ExtensionContext): number {
134
+ const raw = Math.floor(settingNumber("compaction.maxInputChars", DEFAULT_BUDGET_MAX_INPUT_CHARS, ctx.cwd));
135
+ // 0 or negative disables chunking. Anything above the hard floor is honored.
136
+ return raw <= 0 ? 0 : Math.max(20_000, raw);
137
+ }
138
+
139
+ export const chunkConversationText = chunkConversationTextRaw;
140
+ export type { QolBudgetHandoff } from "./compaction-handoff.js";
141
+
142
+ async function singleShotSummary(ctx: ExtensionContext, request: SummarizeRequest, options: { maxTokens: number; model?: string; purpose: QolSummaryPurpose; signal?: AbortSignal }): Promise<SummarizeOutcome> {
124
143
  const promptText = buildSummaryPrompt({
125
- conversationText: options.conversationText,
126
- customInstructions: options.customInstructions,
127
- previousSummary: settingBoolean("compaction.includePreviousSummary", true, ctx.cwd) ? options.previousSummary : undefined,
144
+ conversationText: request.text,
145
+ customInstructions: request.customInstructions,
146
+ previousSummary: settingBoolean("compaction.includePreviousSummary", true, ctx.cwd) ? request.previousSummary : undefined,
128
147
  profile: compactionProfile(ctx.cwd),
129
148
  purpose: options.purpose,
130
149
  });
@@ -132,7 +151,7 @@ export async function generateQolSummary(ctx: ExtensionContext, options: {
132
151
  const remoteEndpoint = settingString("compaction.remoteEndpoint", "", ctx.cwd);
133
152
  if (settingBoolean("compaction.remoteEnabled", false, ctx.cwd) && remoteEndpoint) {
134
153
  try {
135
- const summary = await summarizeWithRemote(remoteEndpoint, QOL_COMPACTION_SYSTEM_PROMPT, promptText, maxTokens, options.signal);
154
+ const summary = await summarizeWithRemote(remoteEndpoint, QOL_COMPACTION_SYSTEM_PROMPT, promptText, options.maxTokens, options.signal);
136
155
  return { model: remoteEndpoint, summary, via: "remote" };
137
156
  } catch (error) {
138
157
  compactionNotify(ctx, `Remote compaction failed, trying model fallback: ${stringifyError(error)}`, "warning");
@@ -154,7 +173,7 @@ export async function generateQolSummary(ctx: ExtensionContext, options: {
154
173
  const response = await complete(
155
174
  model,
156
175
  { messages: [message], systemPrompt: QOL_COMPACTION_SYSTEM_PROMPT },
157
- { apiKey: auth.apiKey, headers: auth.headers, maxTokens, signal: options.signal },
176
+ { apiKey: auth.apiKey, headers: auth.headers, maxTokens: options.maxTokens, signal: options.signal },
158
177
  );
159
178
  const summary = response.content
160
179
  .filter((content): content is { type: "text"; text: string } => content.type === "text")
@@ -164,13 +183,79 @@ export async function generateQolSummary(ctx: ExtensionContext, options: {
164
183
  return { model: modelLabel(model), summary, via: "model" };
165
184
  }
166
185
 
186
+ export async function generateQolSummary(ctx: ExtensionContext, options: {
187
+ conversationText: string;
188
+ customInstructions?: string;
189
+ previousSummary?: string;
190
+ maxTokens?: number;
191
+ model?: string;
192
+ purpose: QolSummaryPurpose;
193
+ signal?: AbortSignal;
194
+ /** Internal: set true on recursive summary-of-summaries pass to skip rechunking. */
195
+ skipChunking?: boolean;
196
+ }): Promise<{ model: string; summary: string; via: "model" | "remote"; chunkCount?: number; reduceLevels?: number; requestCount?: number }> {
197
+ const maxTokens = Math.max(256, Math.floor(options.maxTokens ?? settingNumber("compaction.maxTokens", DEFAULT_COMPACTION_MAX_TOKENS, ctx.cwd)));
198
+ const maxInputChars = budgetMaxInputChars(ctx);
199
+ const summarize = (request: SummarizeRequest) => singleShotSummary(ctx, request, {
200
+ maxTokens,
201
+ model: options.model,
202
+ purpose: options.purpose,
203
+ signal: options.signal,
204
+ });
205
+ if (options.skipChunking || maxInputChars <= 0 || options.conversationText.length <= maxInputChars) {
206
+ return summarize({
207
+ customInstructions: options.customInstructions,
208
+ previousSummary: options.previousSummary,
209
+ skipChunking: true,
210
+ text: options.conversationText,
211
+ });
212
+ }
213
+ const orchestrated = await orchestrateChunkedSummary({
214
+ customInstructions: options.customInstructions,
215
+ maxInputChars,
216
+ notify: (message, level = "info") => compactionNotify(ctx, message, level),
217
+ previousSummary: options.previousSummary,
218
+ signal: options.signal,
219
+ summarize,
220
+ text: options.conversationText,
221
+ });
222
+ return orchestrated;
223
+ }
224
+
225
+ export function buildBudgetHandoff(ctx: ExtensionContext, options: {
226
+ reason: string;
227
+ preparation?: { messagesToSummarize?: AgentMessage[]; turnPrefixMessages?: AgentMessage[]; previousSummary?: string; tokensBefore?: number };
228
+ }): QolBudgetHandoff {
229
+ return buildBudgetHandoffData({
230
+ preparation: options.preparation,
231
+ reason: options.reason,
232
+ sessionManager: ctx.sessionManager as any,
233
+ });
234
+ }
235
+
236
+ export function writeBudgetHandoffArtifact(ctx: ExtensionContext, handoff: QolBudgetHandoff): HandoffWriteResult {
237
+ const enabled = settingBoolean("compaction.handoffArtifactEnabled", true, ctx.cwd);
238
+ const result = writeBudgetHandoffArtifactRaw(handoff, { enabled });
239
+ if (result.error) {
240
+ compactionNotify(ctx, `QOL handoff artifact write failed: ${result.error}`, "warning");
241
+ }
242
+ return result;
243
+ }
244
+
167
245
  export async function handleQolCompaction(event: any, ctx: ExtensionContext): Promise<any> {
168
- if (!settingBoolean("compaction.customEnabled", false, ctx.cwd)) return undefined;
246
+ const isBudgetGuard = isBudgetGuardCompaction(event?.customInstructions);
247
+ // Budget-guard-triggered compactions force the QOL bounded path so the
248
+ // chunked summarizer + handoff artifact always run, even when the user
249
+ // has not flipped compaction.customEnabled on.
250
+ if (!isBudgetGuard && !settingBoolean("compaction.customEnabled", false, ctx.cwd)) return undefined;
169
251
  const preparation = event.preparation ?? {};
170
252
  const messages = [...(preparation.messagesToSummarize ?? []), ...(preparation.turnPrefixMessages ?? [])];
171
253
  if (messages.length === 0) return undefined;
172
254
  const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
173
- compactionNotify(ctx, `QOL compaction: summarizing ${messages.length} message(s), ${tokensBefore.toLocaleString()} token(s).`, "info");
255
+ const handoff = buildBudgetHandoff(ctx, { preparation, reason: event.customInstructions ?? "session_before_compact" });
256
+ const handoffResult = writeBudgetHandoffArtifact(ctx, handoff);
257
+ const sourceLabel = isBudgetGuard ? "pi-qol budget-guard" : "pi-qol";
258
+ compactionNotify(ctx, `QOL compaction: summarizing ${messages.length} message(s), ${tokensBefore.toLocaleString()} token(s)${isBudgetGuard ? " (budget guard)" : ""}.`, "info");
174
259
  try {
175
260
  const conversationText = serializeMessagesForSummary(messages);
176
261
  const result = await generateQolSummary(ctx, {
@@ -181,14 +266,22 @@ export async function handleQolCompaction(event: any, ctx: ExtensionContext): Pr
181
266
  signal: event.signal,
182
267
  });
183
268
  if (!result.summary.trim()) throw new Error("Compaction summary was empty");
184
- compactionNotify(ctx, `QOL compaction complete via ${result.via}: ${result.model}`, "info");
269
+ const chunkSuffix = result.chunkCount && result.chunkCount > 1 ? ` (${result.chunkCount} chunks, ${result.reduceLevels ?? 1} reduce level${(result.reduceLevels ?? 1) === 1 ? "" : "s"})` : "";
270
+ compactionNotify(ctx, `QOL compaction complete via ${result.via}: ${result.model}${chunkSuffix}`, "info");
185
271
  return {
186
272
  compaction: {
187
273
  details: {
274
+ chunkCount: result.chunkCount,
275
+ handoffArtifact: handoffResult.path,
276
+ handoffArtifactLatest: handoffResult.latestPath,
277
+ handoffArtifactError: handoffResult.error,
188
278
  messageCount: messages.length,
189
279
  model: result.model,
190
280
  profile: compactionProfile(ctx.cwd),
191
- source: "pi-qol",
281
+ reduceLevels: result.reduceLevels,
282
+ requestCount: result.requestCount,
283
+ source: sourceLabel,
284
+ trigger: isBudgetGuard ? "budget-guard" : "session_before_compact",
192
285
  via: result.via,
193
286
  },
194
287
  firstKeptEntryId: preparation.firstKeptEntryId,
@@ -262,3 +355,25 @@ export function compactionTriggerReason(ctx: ExtensionContext): string | undefin
262
355
  if (usage.tokens >= idleLimit) return `${usage.tokens.toLocaleString()} tokens >= ${Math.floor(idleLimit).toLocaleString()} idle threshold`;
263
356
  return undefined;
264
357
  }
358
+
359
+ export type BudgetGuardTrigger = BudgetTrigger;
360
+ export type TranscriptRiskState = TranscriptRiskResult;
361
+
362
+ /**
363
+ * Budget guard fires on agent_end (no idle wait) when context usage crosses a
364
+ * percent of the model window or an absolute token limit. Returns a stable key
365
+ * per crossing so the caller can suppress repeated triggers while usage stays
366
+ * above the threshold.
367
+ */
368
+ export function budgetGuardTrigger(ctx: ExtensionContext): BudgetGuardTrigger | undefined {
369
+ if (!settingBoolean("compaction.budgetGuardEnabled", true, ctx.cwd)) return undefined;
370
+ const usage = contextUsage(ctx);
371
+ if (!usage) return undefined;
372
+ return computeBudgetTrigger({
373
+ contextWindow: usage.contextWindow,
374
+ enabled: true,
375
+ percentLimit: settingNumber("compaction.budgetPercent", DEFAULT_BUDGET_GUARD_PERCENT, ctx.cwd),
376
+ tokenLimit: settingNumber("compaction.budgetTokens", DEFAULT_BUDGET_GUARD_TOKENS, ctx.cwd),
377
+ tokens: usage.tokens,
378
+ });
379
+ }
@@ -33,11 +33,33 @@ export const DEFAULT_NOTIFICATION_COOLDOWN_SECONDS = 8;
33
33
  export const DEFAULT_NOTIFICATION_BODY_MAX_CHARS = 240;
34
34
  export const DEFAULT_TMUX_MESSAGE_DURATION_MS = 5000;
35
35
 
36
- export const DEFAULT_COMPACTION_MODEL = "google/gemini-2.5-flash";
36
+ export const DEFAULT_COMPACTION_MODEL = "current";
37
37
  export const DEFAULT_COMPACTION_MAX_TOKENS = 8192;
38
38
  export const DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS = 200000;
39
39
  export const DEFAULT_IDLE_COMPACTION_SECONDS = 300;
40
40
 
41
+ // Budget guard: catches long autonomous sessions before they hit provider/buffer
42
+ // limits. Fires on agent_end (no idle wait) when usage crosses a percent or
43
+ // absolute token threshold. One notification + one compaction trigger per
44
+ // threshold crossing so it does not loop.
45
+ export const DEFAULT_BUDGET_GUARD_PERCENT = 85;
46
+ export const DEFAULT_BUDGET_GUARD_TOKENS = -1;
47
+
48
+ // Chunked summary input caps. Long transcripts are split on paragraph
49
+ // boundaries (which align with serializeConversation message separators) into
50
+ // chunks of at most this many characters, summarized chunk-by-chunk in the
51
+ // original order, then a summary-of-summaries reduce pass merges them with
52
+ // "prefer the most recent" semantics so recency is preserved.
53
+ export const DEFAULT_BUDGET_MAX_INPUT_CHARS = 240_000;
54
+
55
+ // Transcript-risk warning: shown in /context when the serialized payload of
56
+ // messages-to-send is larger than this character budget, even if token count is
57
+ // still below the model window.
58
+ export const DEFAULT_TRANSCRIPT_RISK_WARN_CHARS = 600_000;
59
+
60
+ export const QOL_BUDGET_HANDOFF_FOLDER = "pi-qol/handoff";
61
+ export const QOL_BUDGET_HANDOFF_LATEST = "latest.json";
62
+
41
63
  export const DEFAULT_PERMISSION_GATE_COMMANDS = "rm -Rf";
42
64
  export const DEFAULT_PERMISSION_GATE_PREVIEW_LINES = 12;
43
65
  export const DEFAULT_PERMISSION_GATE_PREVIEW_CHARS = 1200;
@@ -2,6 +2,12 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { isAbsolute } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
4
4
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+ import type { TranscriptRiskResult } from "./budget-guard.js";
6
+ import { DEFAULT_TRANSCRIPT_RISK_WARN_CHARS } from "./constants.js";
7
+ import { settingNumber } from "./settings.js";
8
+ import { transcriptRiskState } from "./transcript-risk.js";
9
+
10
+ type TranscriptRiskState = TranscriptRiskResult;
5
11
 
6
12
  type QolContextColor = "accent" | "success" | "warning" | "error" | "muted" | "dim" | "borderMuted" | "text";
7
13
 
@@ -53,6 +59,7 @@ export interface QolContextUsageMessageDetails {
53
59
  user: number;
54
60
  };
55
61
  compactSummaries: QolContextDetailItem[];
62
+ transcriptRisk?: TranscriptRiskState;
56
63
  note?: string;
57
64
  }
58
65
 
@@ -319,6 +326,14 @@ function qcuExtractProjectAgents(systemPrompt: string): { agents: QolContextDeta
319
326
  return { agents: qcuUniqDetails(agents), tokens: qcuEstimateTokens(section) };
320
327
  }
321
328
 
329
+ function qcuMessagesForRisk(ctx: ExtensionCommandContext): any[] {
330
+ const sm = ctx.sessionManager as any;
331
+ const built = typeof sm.buildSessionContext === "function" ? sm.buildSessionContext() : undefined;
332
+ if (Array.isArray(built?.messages)) return built.messages;
333
+ const branch = Array.isArray(sm.getBranch?.()) ? sm.getBranch() : [];
334
+ return branch.filter((entry: any) => entry?.type === "message").map((entry: any) => entry.message);
335
+ }
336
+
322
337
  export function buildQolContextUsageDetails(pi: ExtensionAPI, ctx: ExtensionCommandContext, promptOptions: unknown): QolContextUsageMessageDetails | undefined {
323
338
  const usage = ctx.getContextUsage?.() as { contextWindow?: unknown; percent?: unknown; tokens?: unknown } | undefined;
324
339
  const tokens = Number(usage?.tokens);
@@ -355,6 +370,8 @@ export function buildQolContextUsageDetails(pi: ExtensionAPI, ctx: ExtensionComm
355
370
  const modelId = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : "unknown";
356
371
  const modelName = typeof model.name === "string" ? model.name : modelId;
357
372
  const thinking = typeof (pi as any).getThinkingLevel === "function" ? (pi as any).getThinkingLevel() : undefined;
373
+ const transcriptThreshold = Math.floor(settingNumber("compaction.transcriptRiskWarnChars", DEFAULT_TRANSCRIPT_RISK_WARN_CHARS, ctx.cwd));
374
+ const transcriptRisk = transcriptRiskState(qcuMessagesForRisk(ctx), transcriptThreshold);
358
375
  return {
359
376
  builtinTools,
360
377
  categories,
@@ -365,6 +382,7 @@ export function buildQolContextUsageDetails(pi: ExtensionAPI, ctx: ExtensionComm
365
382
  freeTokens: safeContextWindow ? Math.max(0, safeContextWindow - tokens) : undefined,
366
383
  mcpTools,
367
384
  messageStats: stats,
385
+ transcriptRisk: transcriptRisk.chars > 0 || transcriptRisk.error ? transcriptRisk : undefined,
368
386
  model: {
369
387
  contextWindow: safeContextWindow,
370
388
  id: modelId,
@@ -486,6 +504,23 @@ export function renderQolContextUsageMessage(message: any, _options: any, theme:
486
504
  qcuRenderDetailSection(lines, "Context / memory files", details.contextFiles, theme, { limit: 12, showDescriptions: false });
487
505
  qcuRenderDetailSection(lines, "Skills · /skills", details.skills, theme, { empty: "No skill commands discovered.", limit: 14, showDescriptions: false });
488
506
  qcuRenderDetailSection(lines, "Compact buffer", details.compactSummaries, theme, { limit: 6, showDescriptions: false });
507
+ if (details.transcriptRisk?.error) {
508
+ const r = details.transcriptRisk;
509
+ const safe = r.error.replace(/[\x00-\x1f]+/g, " ").slice(0, 200);
510
+ lines.push("");
511
+ lines.push(`${theme.fg("warning", theme.bold("Transcript risk"))}`);
512
+ lines.push(`${qcuBranch(theme, "└")}${theme.fg("warning", `risk calculation failed: ${safe}`)}`);
513
+ } else if (details.transcriptRisk?.exceeded) {
514
+ const r = details.transcriptRisk;
515
+ lines.push("");
516
+ lines.push(`${theme.fg("warning", theme.bold("Transcript risk"))}`);
517
+ lines.push(`${qcuBranch(theme, "├")}${theme.fg("warning", `serialized payload ${qcuFormatTokens(Math.ceil(r.chars / 4))} tokens (${r.chars.toLocaleString()} chars) >= ${r.threshold.toLocaleString()} char warn budget`)}`);
518
+ lines.push(`${qcuBranch(theme, "└")}${theme.fg("muted", "compact soon or raise compaction.transcriptRiskWarnChars to silence")}`);
519
+ } else if (details.transcriptRisk && details.transcriptRisk.chars > 0) {
520
+ const r = details.transcriptRisk;
521
+ lines.push("");
522
+ lines.push(`${theme.fg("muted", `Transcript payload: ${qcuFormatTokens(Math.ceil(r.chars / 4))} tokens (${r.chars.toLocaleString()} chars / ${r.threshold.toLocaleString()} warn budget).`)}`);
523
+ }
489
524
  return lines.map((line) => truncateToWidth(line, safeWidth, ""));
490
525
  },
491
526
  };
@@ -3,6 +3,7 @@ import { matchesKey, truncateToWidth, visibleWidth, type AutocompleteItem, type
3
3
  import { stripAnsi } from "./ansi.js";
4
4
  import { readCavemanBridge } from "./bridges.js";
5
5
  import { STATUS_KEY } from "./constants.js";
6
+ import { glyphs } from "./glyphs.js";
6
7
  import { statusText, styleImageChips } from "./images.js";
7
8
  import { newlineFallbackKey, settingBoolean } from "./settings.js";
8
9
 
@@ -101,14 +102,16 @@ function syncQolEditorStatus(
101
102
 
102
103
  export class QolEditor extends CustomEditor {
103
104
  private readonly statusCache: { last?: string } = {};
105
+ private readonly ctx: ExtensionContext;
104
106
 
105
107
  constructor(
106
108
  tui: TUI,
107
109
  editorTheme: EditorTheme,
108
110
  keybindings: KeybindingsManager,
109
- private readonly ctx: ExtensionContext,
111
+ ctx: ExtensionContext,
110
112
  ) {
111
113
  super(tui, editorTheme, keybindings);
114
+ this.ctx = ctx;
112
115
  }
113
116
 
114
117
  handleInput(data: string): void {
@@ -128,15 +131,19 @@ function isEditorBorderLine(line: string): boolean {
128
131
 
129
132
  export class QolCompactPromptEditor extends CustomEditor {
130
133
  private readonly statusCache: { last?: string } = {};
134
+ private readonly inputBottomPaddingLines: number;
135
+ private readonly ctx: ExtensionContext;
131
136
 
132
137
  constructor(
133
138
  tui: TUI,
134
139
  editorTheme: EditorTheme,
135
140
  keybindings: KeybindingsManager,
136
- private readonly inputBottomPaddingLines: number,
137
- private readonly ctx: ExtensionContext,
141
+ inputBottomPaddingLines: number,
142
+ ctx: ExtensionContext,
138
143
  ) {
139
144
  super(tui, editorTheme, keybindings, { paddingX: 0 });
145
+ this.inputBottomPaddingLines = inputBottomPaddingLines;
146
+ this.ctx = ctx;
140
147
  }
141
148
 
142
149
  handleInput(data: string): void {
@@ -145,9 +152,10 @@ export class QolCompactPromptEditor extends CustomEditor {
145
152
 
146
153
  render(width: number): string[] {
147
154
  syncQolEditorStatus(this.ctx, this.getText(), this.statusCache);
148
- const prompt = this.borderColor("π");
155
+ const promptGlyph = glyphs(this.ctx.cwd).prompt;
156
+ const prompt = this.borderColor(promptGlyph);
149
157
  const prefix = `${prompt} `;
150
- const prefixWidth = visibleWidth("π ");
158
+ const prefixWidth = visibleWidth(`${promptGlyph} `);
151
159
  const continuationPrefix = " ".repeat(prefixWidth);
152
160
  const innerWidth = Math.max(1, width - prefixWidth);
153
161
  const rendered = super.render(innerWidth);