@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
package/README.md
CHANGED
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|

|
|
6
6
|

|
|
7
7
|
|
|
8
|
-
Quality-of-life extension for Pi: compact statusline, multiline input, session naming and search, notifications, and a permission gate.
|
|
8
|
+
Quality-of-life extension for Pi: compact statusline, multiline input, session naming and search, scheduled prompts, notifications, and a permission gate.
|
|
9
9
|
|
|
10
10
|
## Highlights
|
|
11
11
|
|
|
12
|
-
- Compact statusline with repo, branch, model, thinking level, and context percent.
|
|
12
|
+
- Compact statusline with repo, branch, model, thinking level, and context percent; can be disabled.
|
|
13
13
|
- Newline-insert in the editor (multi-line drafts without auto-submit), with a fallback binding for terminals that can't distinguish the primary key.
|
|
14
14
|
- Sessions auto-name from your first prompt. `/rename` overrides anytime.
|
|
15
15
|
- `/search` browses previous sessions with snippet previews; the configured shortcut opens it instantly.
|
|
16
16
|
- `/context` shows a Claude-style context-window breakdown.
|
|
17
17
|
- `/handoff <goal>` drafts a focused prompt for a new session.
|
|
18
|
+
- `/schedule 20m <message>` sends a delayed prompt without invoking the model until the timer fires.
|
|
19
|
+
- Optional rate-limit auto-resume sends a configurable continuation after reset.
|
|
18
20
|
- Permission gate prompts before risky `bash` commands. Default match: `rm -Rf`.
|
|
19
21
|
- Notifications for ready, questions, blocked states, and task completion.
|
|
20
22
|
- Thinking timer next to collapsed `Thinking...` labels.
|
|
@@ -51,19 +53,25 @@ Restart Pi after installation.
|
|
|
51
53
|
| `/search [query]` | Open previous-session search. |
|
|
52
54
|
| `/search:refresh` | Refresh the session search cache. |
|
|
53
55
|
| `/handoff <goal>` | Draft a handoff prompt for a new session. |
|
|
56
|
+
| `/schedule <delay> <message>` | Send a user message after a timer without invoking the model now. Example: `/schedule 20m retry the previous request`. |
|
|
54
57
|
|
|
55
58
|
Arguments support autocomplete.
|
|
56
59
|
|
|
60
|
+
`/schedule` accepts `ms`, `s`, `m`, `h`, and `d` units; bare numbers mean minutes. Pending prompts render above the statusline like steering/follow-up previews until they are sent or cancelled. Manage pending prompts with `/schedule list` and `/schedule cancel <id|all>`. Schedules are stored in the Pi session and re-armed on reload/resume; if Pi is not running at the due time, an overdue prompt sends when that session is next loaded.
|
|
61
|
+
|
|
57
62
|
## Settings
|
|
58
63
|
|
|
59
64
|
Open `/extensions:settings`; settings appear under the **QOL** tab. Names below match the labels shown there.
|
|
60
65
|
|
|
66
|
+
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.
|
|
67
|
+
|
|
61
68
|
### Statusline
|
|
62
69
|
|
|
63
70
|
| Setting | What it does |
|
|
64
71
|
| --- | --- |
|
|
65
72
|
| Enable QOL editor helpers | Master toggle for QOL statusline, commands, notifications, search, compaction, and editor helpers. |
|
|
66
|
-
|
|
|
73
|
+
| Show compact statusline | Render or disable the QOL statusline row. |
|
|
74
|
+
| Replace built-in footer | Hide Pi's default footer while the QOL statusline is enabled. |
|
|
67
75
|
| Use π prompt editor | Use the compact prompt editor. |
|
|
68
76
|
| Show session name title | Show the session name above the prompt and in the tmux pane title. |
|
|
69
77
|
| Sync session name to tmux window name | Rename the tmux window to `π <session>`. |
|
|
@@ -80,11 +88,13 @@ Open `/extensions:settings`; settings appear under the **QOL** tab. Names below
|
|
|
80
88
|
| Style image chips | Render `[Image #N]` placeholders as distinct chips. |
|
|
81
89
|
| Show attachment count | Show a status badge when the draft has image placeholders. |
|
|
82
90
|
|
|
83
|
-
### Session
|
|
91
|
+
### Session
|
|
84
92
|
|
|
85
93
|
| Setting | What it does |
|
|
86
94
|
| --- | --- |
|
|
87
95
|
| Enable /rename command | Register the `/rename` command. |
|
|
96
|
+
| Enable /schedule command | Register `/schedule` for timer-based prompts, useful for retrying after rate limits reset. |
|
|
97
|
+
| Auto-resume after rate limits | Send the configured continuation after a detected reset; cancels on newer turn. |
|
|
88
98
|
| Auto-name new sessions | Generate a friendly session name from the first prompt. |
|
|
89
99
|
| Auto-rename model | Model used for title generation. |
|
|
90
100
|
| Auto-rename fallback model | Model tried when the primary fails. |
|
|
@@ -107,6 +117,8 @@ Advanced: input cap, title length, output tokens, timeout, custom prompt templat
|
|
|
107
117
|
| --- | --- |
|
|
108
118
|
| Enable /context command | Register `/context`. |
|
|
109
119
|
|
|
120
|
+
`/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.
|
|
121
|
+
|
|
110
122
|
### Session search
|
|
111
123
|
|
|
112
124
|
| Setting | What it does |
|
|
@@ -154,7 +166,7 @@ Off by default. When enabled, non-interactive matches are blocked.
|
|
|
154
166
|
| Setting | What it does |
|
|
155
167
|
| --- | --- |
|
|
156
168
|
| Custom compaction summaries | Use QOL summaries instead of Pi's default. |
|
|
157
|
-
| Compaction model | Summarizer model. |
|
|
169
|
+
| 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
170
|
| Compaction detail profile | `concise`, `balanced`, or `exhaustive`. |
|
|
159
171
|
| Include previous summary | Pass the previous summary for iterative continuity. |
|
|
160
172
|
| Fallback to Pi default compaction | Run Pi's default compaction if QOL's fails. |
|
|
@@ -165,6 +177,27 @@ Off by default. When enabled, non-interactive matches are blocked.
|
|
|
165
177
|
|
|
166
178
|
Idle thresholds (token threshold, idle delay, fixed token limit, percent limit) tune when idle compaction fires.
|
|
167
179
|
|
|
180
|
+
### Long-session budget guard
|
|
181
|
+
|
|
182
|
+
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).
|
|
183
|
+
|
|
184
|
+
| Setting | What it does | Default |
|
|
185
|
+
| --- | --- | --- |
|
|
186
|
+
| Long-session budget guard | Master toggle for the agent_end budget guard. | on |
|
|
187
|
+
| Budget guard percent | Context-window percentage that fires the guard. `-1` disables percent-based firing. | `85` |
|
|
188
|
+
| Budget guard token limit | Absolute tokens that fire the guard. `-1` uses percent only. | `-1` |
|
|
189
|
+
| 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` |
|
|
190
|
+
| 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 |
|
|
191
|
+
| 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` |
|
|
192
|
+
|
|
193
|
+
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.
|
|
194
|
+
|
|
195
|
+
Recommended values for autonomous/Flightdeck runs:
|
|
196
|
+
|
|
197
|
+
- Keep **Long-session budget guard** on. Lower **Budget guard percent** to `75` if your provider buffers are tight.
|
|
198
|
+
- 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.)
|
|
199
|
+
- Lower **Chunked compaction input cap** to ~`120000` when the summarizer model has a small context window.
|
|
200
|
+
|
|
168
201
|
### Thinking
|
|
169
202
|
|
|
170
203
|
| 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
|
+
}
|