@vanillagreen/pi-qol 1.4.3 → 1.5.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 CHANGED
@@ -58,6 +58,8 @@ Arguments support autocomplete.
58
58
 
59
59
  Open `/extensions:settings`; settings appear under the **QOL** tab. Names below match the labels shown there.
60
60
 
61
+ Glyph style: each package exposes `glyphStyle` (`unicode` default, `ascii` for terminal-safe chrome). `@vanillagreen/pi-tool-renderer.globalGlyphStyleOverride=ascii` forces ASCII chrome across vstack Pi extensions while leaving tool/model/user content unchanged.
62
+
61
63
  ### Statusline
62
64
 
63
65
  | Setting | What it does |
@@ -107,6 +109,8 @@ Advanced: input cap, title length, output tokens, timeout, custom prompt templat
107
109
  | --- | --- |
108
110
  | Enable /context command | Register `/context`. |
109
111
 
112
+ `/context` also estimates the serialized payload size of the messages that would be sent on the next request. When that payload crosses **Transcript-risk warn budget (chars)** a `Transcript risk` block appears below the compact buffer section even if token count is still under the context window — useful for catching large blob-shaped tool outputs that inflate the request long before token count alone would page anyone. If transcript-risk estimation itself errors, `/context` shows a sanitized error in the same block rather than silently hiding the warning.
113
+
110
114
  ### Session search
111
115
 
112
116
  | Setting | What it does |
@@ -154,7 +158,7 @@ Off by default. When enabled, non-interactive matches are blocked.
154
158
  | Setting | What it does |
155
159
  | --- | --- |
156
160
  | Custom compaction summaries | Use QOL summaries instead of Pi's default. |
157
- | Compaction model | Summarizer model. |
161
+ | Compaction model | Summarizer model. Defaults to `current`, meaning Pi's active model; set a provider/model when you want a dedicated larger-context summarizer. |
158
162
  | Compaction detail profile | `concise`, `balanced`, or `exhaustive`. |
159
163
  | Include previous summary | Pass the previous summary for iterative continuity. |
160
164
  | Fallback to Pi default compaction | Run Pi's default compaction if QOL's fails. |
@@ -165,6 +169,27 @@ Off by default. When enabled, non-interactive matches are blocked.
165
169
 
166
170
  Idle thresholds (token threshold, idle delay, fixed token limit, percent limit) tune when idle compaction fires.
167
171
 
172
+ ### Long-session budget guard
173
+
174
+ For autonomous/Flightdeck-style runs the agent never goes idle, so idle compaction may never fire and the transcript can grow until provider/buffer limits hit. The budget guard runs on `agent_end` (not idle) and starts a compaction immediately when context usage crosses a percent of the model window or an absolute token limit. It fires once per threshold crossing — repeated `agent_end` events above the same threshold do not retrigger it, and the crossing key resets on a successful compaction or on a transient compaction failure (so the next `agent_end` retries).
175
+
176
+ | Setting | What it does | Default |
177
+ | --- | --- | --- |
178
+ | Long-session budget guard | Master toggle for the agent_end budget guard. | on |
179
+ | Budget guard percent | Context-window percentage that fires the guard. `-1` disables percent-based firing. | `85` |
180
+ | Budget guard token limit | Absolute tokens that fire the guard. `-1` uses percent only. | `-1` |
181
+ | Chunked compaction input cap | Max serialized characters per summarization request. Long transcripts are chunked, summarized chunk-by-chunk, then tree-reduced — every model/remote request (chunk + every reduce pass) is bounded so the compaction call itself cannot exceed provider buffer limits. `0` disables chunking. | `240000` |
182
+ | Write pre-compaction handoff artifact | Before compaction, write `~/.pi/agent/vstack/sessions/<session>/pi-qol/handoff/<timestamp>.json` plus a `latest.json` pointer containing previous summary, last task state, and referenced files/artifacts. Write failures surface as a QOL warning notification and a `handoffArtifactError` field in the compaction details. | on |
183
+ | Transcript-risk warn budget (chars) | `/context` shows a warning when the serialized payload of messages-to-send exceeds this many characters, even if tokens are still below the context window. `0` disables. | `600000` |
184
+
185
+ When the budget guard fires it injects a sentinel into the compaction request so the QOL bounded handler always runs — chunked summarizer + handoff artifact — even if **Custom compaction summaries** is off. Manual compactions (`/tree`, idle compaction, user-triggered) still only use the QOL handler when **Custom compaction summaries** is on; otherwise they fall through to Pi's default compaction with no handoff artifact and no chunking. If you want every compaction to use the QOL bounded path, turn **Custom compaction summaries** on.
186
+
187
+ Recommended values for autonomous/Flightdeck runs:
188
+
189
+ - Keep **Long-session budget guard** on. Lower **Budget guard percent** to `75` if your provider buffers are tight.
190
+ - Optionally turn **Custom compaction summaries** on so user-initiated and idle compactions also use the QOL chunked summarizer + handoff artifact. (Budget-guard-triggered compactions always use them regardless of this setting.)
191
+ - Lower **Chunked compaction input cap** to ~`120000` when the summarizer model has a small context window.
192
+
168
193
  ### Thinking
169
194
 
170
195
  | Setting | What it does |
@@ -0,0 +1,100 @@
1
+ // Runtime dispatch for the agent_end budget guard. Owns the last-crossing
2
+ // key and in-flight flag so the guard fires exactly once per threshold
3
+ // crossing, validates ctx.compact before marking in-flight, and clears the
4
+ // crossing key on failure so the next agent_end can retry.
5
+
6
+ import { QOL_BUDGET_GUARD_SENTINEL, type BudgetTrigger } from "./budget-guard.js";
7
+
8
+ export type GuardLevel = "info" | "warning" | "error";
9
+
10
+ export interface GuardCompactOptions {
11
+ customInstructions?: string;
12
+ onComplete?: (...args: unknown[]) => void;
13
+ onError?: (error: Error) => void;
14
+ }
15
+
16
+ export interface GuardDispatchInput {
17
+ trigger: BudgetTrigger | undefined;
18
+ compact: ((options: GuardCompactOptions) => void) | undefined;
19
+ notify: (message: string, level: GuardLevel) => void;
20
+ staleCtx?: () => boolean;
21
+ }
22
+
23
+ export type DispatchOutcome =
24
+ | { kind: "ignored" }
25
+ | { kind: "no-trigger" }
26
+ | { kind: "dedup" }
27
+ | { kind: "in-flight" }
28
+ | { kind: "no-compact-fn" }
29
+ | { kind: "dispatched"; reason: string }
30
+ | { kind: "dispatch-threw"; reason: string; error: string }
31
+ | { kind: "complete" }
32
+ | { kind: "failed"; error: string };
33
+
34
+ export class BudgetGuardDriver {
35
+ private lastKey: string | undefined;
36
+ private inFlight = false;
37
+
38
+ reset(): void {
39
+ this.lastKey = undefined;
40
+ this.inFlight = false;
41
+ }
42
+
43
+ /** Returns true if the next call to dispatch is allowed to fire. Visible for tests. */
44
+ get canFire(): boolean {
45
+ return !this.inFlight;
46
+ }
47
+
48
+ get currentKey(): string | undefined {
49
+ return this.lastKey;
50
+ }
51
+
52
+ noteSessionCompacted(): void {
53
+ // Successful compaction drops usage below the budget; let the next
54
+ // crossing re-fire.
55
+ this.lastKey = undefined;
56
+ this.inFlight = false;
57
+ }
58
+
59
+ dispatch(input: GuardDispatchInput): DispatchOutcome {
60
+ if (input.staleCtx?.()) return { kind: "ignored" };
61
+ if (this.inFlight) return { kind: "in-flight" };
62
+ const trigger = input.trigger;
63
+ if (!trigger) {
64
+ this.lastKey = undefined;
65
+ return { kind: "no-trigger" };
66
+ }
67
+ if (trigger.key === this.lastKey) return { kind: "dedup" };
68
+ if (typeof input.compact !== "function") {
69
+ input.notify(`QOL budget guard cannot fire: ctx.compact is unavailable (${trigger.reason}).`, "warning");
70
+ return { kind: "no-compact-fn" };
71
+ }
72
+ input.notify(`QOL budget guard starting compaction: ${trigger.reason}`, "info");
73
+ this.inFlight = true;
74
+ this.lastKey = trigger.key;
75
+ const instructions = `${QOL_BUDGET_GUARD_SENTINEL} QOL budget guard triggered at agent_end because ${trigger.reason}. Bound the summary input, preserve current task state, decisions, files, blockers, and next steps.`;
76
+ try {
77
+ input.compact({
78
+ customInstructions: instructions,
79
+ onComplete: () => {
80
+ this.inFlight = false;
81
+ input.notify("QOL budget guard compaction completed.", "info");
82
+ },
83
+ onError: (error: Error) => {
84
+ this.inFlight = false;
85
+ // Allow the next agent_end to retry rather than poisoning the
86
+ // crossing key after a transient failure.
87
+ this.lastKey = undefined;
88
+ input.notify(`QOL budget guard compaction failed: ${error.message}`, "error");
89
+ },
90
+ });
91
+ return { kind: "dispatched", reason: trigger.reason };
92
+ } catch (error) {
93
+ this.inFlight = false;
94
+ this.lastKey = undefined;
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ input.notify(`QOL budget guard compaction failed to start: ${message}`, "error");
97
+ return { kind: "dispatch-threw", error: message, reason: trigger.reason };
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,317 @@
1
+ // Pure helpers for the long-session compaction budget guard. Kept dependency-free
2
+ // so the chunking + risk math can be unit-tested without pulling the pi-ai or
3
+ // pi-coding-agent peer deps. The wiring into the qol extension (settings reads,
4
+ // session manager, ctx.compact) lives in compaction.ts and qol.ts.
5
+
6
+ export interface BudgetTriggerInput {
7
+ enabled: boolean;
8
+ tokens: number;
9
+ contextWindow?: number;
10
+ tokenLimit: number;
11
+ percentLimit: number;
12
+ }
13
+
14
+ export interface BudgetTrigger {
15
+ reason: string;
16
+ key: string;
17
+ tokens: number;
18
+ contextWindow?: number;
19
+ percent?: number;
20
+ }
21
+
22
+ export function computeBudgetTrigger(input: BudgetTriggerInput): BudgetTrigger | undefined {
23
+ if (!input.enabled) return undefined;
24
+ if (!Number.isFinite(input.tokens) || input.tokens <= 0) return undefined;
25
+ const tokenLimit = Math.floor(input.tokenLimit);
26
+ if (tokenLimit > 0 && input.tokens >= tokenLimit) {
27
+ const bucket = Math.floor(input.tokens / Math.max(1, tokenLimit));
28
+ return {
29
+ contextWindow: input.contextWindow,
30
+ key: `tokens:${tokenLimit}:${bucket}`,
31
+ percent: input.contextWindow ? (input.tokens / input.contextWindow) * 100 : undefined,
32
+ reason: `${input.tokens.toLocaleString()} tokens >= ${tokenLimit.toLocaleString()} budget token limit`,
33
+ tokens: input.tokens,
34
+ };
35
+ }
36
+ const percentLimit = input.percentLimit;
37
+ if (percentLimit > 0 && input.contextWindow) {
38
+ const percent = (input.tokens / input.contextWindow) * 100;
39
+ if (percent >= percentLimit) {
40
+ const bucket = Math.floor(percent / Math.max(1, percentLimit));
41
+ return {
42
+ contextWindow: input.contextWindow,
43
+ key: `percent:${percentLimit}:${bucket}`,
44
+ percent,
45
+ reason: `${percent.toFixed(1)}% context >= ${percentLimit}% budget guard`,
46
+ tokens: input.tokens,
47
+ };
48
+ }
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ export function chunkConversationText(text: string, maxChars: number): string[] {
54
+ if (maxChars <= 0 || text.length <= maxChars) return [text];
55
+ const chunks: string[] = [];
56
+ const breaks = /\n{2,}/g;
57
+ let cursor = 0;
58
+ while (cursor < text.length) {
59
+ const remaining = text.length - cursor;
60
+ if (remaining <= maxChars) {
61
+ chunks.push(text.slice(cursor));
62
+ break;
63
+ }
64
+ const slice = text.slice(cursor, cursor + maxChars);
65
+ breaks.lastIndex = 0;
66
+ let breakAt = -1;
67
+ let match: RegExpExecArray | null;
68
+ // Pick the latest paragraph break inside the slice so chunks land on
69
+ // message boundaries. The half-slice floor avoids degenerate tiny chunks
70
+ // when the only paragraph break is right at the start of the window.
71
+ while ((match = breaks.exec(slice)) !== null) {
72
+ if (match.index >= Math.floor(maxChars / 2)) breakAt = match.index + match[0].length;
73
+ }
74
+ const end = breakAt > 0 ? cursor + breakAt : cursor + maxChars;
75
+ chunks.push(text.slice(cursor, end));
76
+ cursor = end;
77
+ }
78
+ return chunks;
79
+ }
80
+
81
+ export interface TranscriptRiskInput {
82
+ chars: number;
83
+ threshold: number;
84
+ messageCount: number;
85
+ error?: string;
86
+ }
87
+
88
+ export interface TranscriptRiskResult extends TranscriptRiskInput {
89
+ exceeded: boolean;
90
+ }
91
+
92
+ export function evaluateTranscriptRisk(input: TranscriptRiskInput): TranscriptRiskResult {
93
+ if (input.error) {
94
+ return { chars: 0, error: input.error, exceeded: false, messageCount: input.messageCount, threshold: input.threshold };
95
+ }
96
+ if (input.threshold <= 0 || input.messageCount <= 0 || input.chars <= 0) {
97
+ return { chars: input.chars, exceeded: false, messageCount: input.messageCount, threshold: input.threshold };
98
+ }
99
+ return {
100
+ chars: input.chars,
101
+ exceeded: input.chars >= input.threshold,
102
+ messageCount: input.messageCount,
103
+ threshold: input.threshold,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Sentinel marker injected into customInstructions when budget guard triggers
109
+ * a compaction. Detected by handleQolCompaction to force the bounded QOL path
110
+ * + handoff artifact write regardless of the global compaction.customEnabled
111
+ * setting.
112
+ */
113
+ export const QOL_BUDGET_GUARD_SENTINEL = "[QOL_BUDGET_GUARD]";
114
+
115
+ export function isBudgetGuardCompaction(customInstructions: unknown): boolean {
116
+ return typeof customInstructions === "string" && customInstructions.includes(QOL_BUDGET_GUARD_SENTINEL);
117
+ }
118
+
119
+ export interface SummarizeRequest {
120
+ text: string;
121
+ customInstructions?: string;
122
+ previousSummary?: string;
123
+ skipChunking: true;
124
+ }
125
+
126
+ export interface SummarizeOutcome {
127
+ model: string;
128
+ summary: string;
129
+ via: "model" | "remote";
130
+ }
131
+
132
+ export type SummarizeFn = (request: SummarizeRequest) => Promise<SummarizeOutcome>;
133
+
134
+ export interface OrchestrateChunkedOptions {
135
+ text: string;
136
+ summarize: SummarizeFn;
137
+ maxInputChars: number;
138
+ signal?: AbortSignal;
139
+ customInstructions?: string;
140
+ previousSummary?: string;
141
+ notify?: (message: string, level?: "info" | "warning" | "error") => void;
142
+ }
143
+
144
+ export interface OrchestrateChunkedResult extends SummarizeOutcome {
145
+ chunkCount: number;
146
+ reduceLevels: number;
147
+ requestCount: number;
148
+ }
149
+
150
+ function instructionsForChunk(custom: string | undefined, index: number, total: number): string {
151
+ const base = `This is chunk ${index + 1} of ${total} from a long conversation. Preserve all concrete files, commands, decisions, blockers, and current tasks visible in this chunk so a follow-up summary-of-summaries pass can stitch the timeline together.`;
152
+ return custom?.trim() ? `${custom.trim()}\n\n${base}` : base;
153
+ }
154
+
155
+ function instructionsForReduce(custom: string | undefined, level: number, count: number): string {
156
+ const base = `Tree-reduce level ${level}: merge the following ${count} chunk summaries (oldest first) into a single continuation summary. De-duplicate facts, keep exact files/commands/paths, preserve decisions, blockers, current tasks, and any artifact paths. If chunks contradict, prefer the most recent.`;
157
+ return custom?.trim() ? `${custom.trim()}\n\n${base}` : base;
158
+ }
159
+
160
+ function partition(items: string[], maxChars: number, separator: string, minBatch: number = 1): string[][] {
161
+ const groups: string[][] = [];
162
+ let current: string[] = [];
163
+ let currentLen = 0;
164
+ const sepLen = separator.length;
165
+ for (const item of items) {
166
+ const projected = current.length === 0 ? item.length : currentLen + sepLen + item.length;
167
+ if (current.length >= minBatch && projected > maxChars) {
168
+ groups.push(current);
169
+ current = [item];
170
+ currentLen = item.length;
171
+ } else {
172
+ current.push(item);
173
+ currentLen = projected;
174
+ }
175
+ }
176
+ if (current.length > 0) groups.push(current);
177
+ return groups;
178
+ }
179
+
180
+ /**
181
+ * Bounded chunk + tree-reduce orchestrator. Every summarize() request gets
182
+ * a text body that is guaranteed to be <= maxInputChars (the chunker enforces
183
+ * the cap for source chunks; partition enforces it for reduce batches). When
184
+ * a reduce batch contains a single partial that's already over cap the
185
+ * orchestrator hard-truncates rather than retrying forever.
186
+ */
187
+ export async function orchestrateChunkedSummary(options: OrchestrateChunkedOptions): Promise<OrchestrateChunkedResult> {
188
+ const { customInstructions, maxInputChars, notify, previousSummary, signal, summarize, text } = options;
189
+ if (maxInputChars <= 0 || text.length <= maxInputChars) {
190
+ if (signal?.aborted) throw new Error("Compaction aborted");
191
+ const single = await summarize({
192
+ customInstructions,
193
+ previousSummary,
194
+ skipChunking: true,
195
+ text,
196
+ });
197
+ return { ...single, chunkCount: 1, reduceLevels: 0, requestCount: 1 };
198
+ }
199
+ const chunks = chunkConversationText(text, maxInputChars);
200
+ notify?.(`QOL chunked compaction: summarizing ${chunks.length} chunk(s) (input ${text.length.toLocaleString()} chars > ${maxInputChars.toLocaleString()} cap).`, "info");
201
+ let requestCount = 0;
202
+ const partials: string[] = [];
203
+ let lastVia: "model" | "remote" = "model";
204
+ let lastModel = "";
205
+ let previous = previousSummary;
206
+ for (let i = 0; i < chunks.length; i += 1) {
207
+ if (signal?.aborted) throw new Error("Compaction aborted");
208
+ const chunk = chunks[i] ?? "";
209
+ const partial = await summarize({
210
+ customInstructions: instructionsForChunk(customInstructions, i, chunks.length),
211
+ previousSummary: previous,
212
+ skipChunking: true,
213
+ text: chunk,
214
+ });
215
+ requestCount += 1;
216
+ if (!partial.summary.trim()) throw new Error(`Chunk ${i + 1}/${chunks.length} summary was empty`);
217
+ partials.push(`### Chunk ${i + 1}/${chunks.length}\n${partial.summary.trim()}`);
218
+ previous = partial.summary;
219
+ lastVia = partial.via;
220
+ lastModel = partial.model;
221
+ }
222
+
223
+ let level = partials;
224
+ let reduceLevels = 0;
225
+ const separator = "\n\n";
226
+ const MAX_REDUCE_LEVELS = 12;
227
+ while (true) {
228
+ const combined = level.join(separator);
229
+ if (combined.length <= maxInputChars) {
230
+ if (signal?.aborted) throw new Error("Compaction aborted");
231
+ const final = await summarize({
232
+ customInstructions: instructionsForReduce(customInstructions, reduceLevels + 1, level.length),
233
+ previousSummary,
234
+ skipChunking: true,
235
+ text: combined,
236
+ });
237
+ requestCount += 1;
238
+ if (!final.summary.trim()) throw new Error("Final reduce summary was empty");
239
+ return {
240
+ chunkCount: chunks.length,
241
+ model: final.model || lastModel,
242
+ reduceLevels: reduceLevels + 1,
243
+ requestCount,
244
+ summary: final.summary,
245
+ via: final.via || lastVia,
246
+ };
247
+ }
248
+ reduceLevels += 1;
249
+ if (reduceLevels > MAX_REDUCE_LEVELS) {
250
+ // Defensive: refuse to spin forever if a model keeps emitting
251
+ // summaries larger than the cap. Final summarize over the
252
+ // hard-truncated joined level returns a bounded answer.
253
+ const truncated = combined.slice(0, maxInputChars);
254
+ const final = await summarize({
255
+ customInstructions: instructionsForReduce(customInstructions, reduceLevels, level.length),
256
+ previousSummary,
257
+ skipChunking: true,
258
+ text: truncated,
259
+ });
260
+ requestCount += 1;
261
+ return {
262
+ chunkCount: chunks.length,
263
+ model: final.model || lastModel,
264
+ reduceLevels,
265
+ requestCount,
266
+ summary: final.summary,
267
+ via: final.via || lastVia,
268
+ };
269
+ }
270
+ // Force-pair: at level >1 every batch must contain >=2 items so we
271
+ // always make progress. The summarize() call gets a text that may
272
+ // exceed the cap when each partial is itself near-cap; truncate to
273
+ // cap so the request remains bounded.
274
+ const minBatch = 2;
275
+ const batches = partition(level, maxInputChars, separator, minBatch);
276
+ const nextLevel: string[] = [];
277
+ for (const batch of batches) {
278
+ if (signal?.aborted) throw new Error("Compaction aborted");
279
+ if (batch.length === 1) {
280
+ const only = batch[0] ?? "";
281
+ nextLevel.push(only.length > maxInputChars ? only.slice(0, maxInputChars) : only);
282
+ continue;
283
+ }
284
+ let batchText = batch.join(separator);
285
+ if (batchText.length > maxInputChars) batchText = batchText.slice(0, maxInputChars);
286
+ const summary = await summarize({
287
+ customInstructions: instructionsForReduce(customInstructions, reduceLevels, batch.length),
288
+ previousSummary,
289
+ skipChunking: true,
290
+ text: batchText,
291
+ });
292
+ requestCount += 1;
293
+ if (!summary.summary.trim()) throw new Error(`Tree-reduce level ${reduceLevels} batch summary was empty`);
294
+ nextLevel.push(summary.summary);
295
+ lastVia = summary.via;
296
+ lastModel = summary.model;
297
+ }
298
+ if (nextLevel.length === 0) throw new Error("Tree-reduce collapsed to empty level");
299
+ if (nextLevel.length >= level.length) {
300
+ // No progress this round — fall back to hard truncation on the next
301
+ // pass via the level-cap guard. This typically means the model is
302
+ // emitting near-cap summaries; the MAX_REDUCE_LEVELS guard above
303
+ // will eventually terminate the loop with a truncated final.
304
+ }
305
+ level = nextLevel;
306
+ if (level.length === 1 && level[0]!.length <= maxInputChars) {
307
+ return {
308
+ chunkCount: chunks.length,
309
+ model: lastModel,
310
+ reduceLevels,
311
+ requestCount,
312
+ summary: level[0]!,
313
+ via: lastVia,
314
+ };
315
+ }
316
+ }
317
+ }
@@ -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
+ }