@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.
- package/README.md +38 -5
- package/extensions/qol/budget-guard-runtime.ts +100 -0
- package/extensions/qol/budget-guard.ts +317 -0
- package/extensions/qol/compaction-handoff.ts +190 -0
- package/extensions/qol/compaction.ts +135 -20
- package/extensions/qol/constants.ts +23 -1
- package/extensions/qol/context-usage.ts +35 -0
- package/extensions/qol/editor.ts +13 -5
- package/extensions/qol/glyphs.ts +122 -0
- package/extensions/qol/rate-limit-auto-resume.ts +447 -0
- package/extensions/qol/schedule.ts +320 -0
- package/extensions/qol/session-search/component.ts +33 -11
- package/extensions/qol/status-message.ts +7 -1
- package/extensions/qol/statusline.ts +5 -4
- package/extensions/qol/transcript-risk.ts +28 -0
- package/extensions/qol.ts +101 -21
- package/package.json +140 -5
|
@@ -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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
126
|
-
customInstructions:
|
|
127
|
-
previousSummary: settingBoolean("compaction.includePreviousSummary", true, ctx.cwd) ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = "
|
|
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
|
};
|
package/extensions/qol/editor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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);
|