pi-better-btw-plus 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /**
6
+ * config.json `promptPack` manifest (#13): each value is a path relative to
7
+ * the extension dir, or an absolute path. All keys are optional — an absent
8
+ * or unreadable key falls back to the bundled `prompts/` default.
9
+ */
10
+ export interface PromptPackManifest {
11
+ framing?: string;
12
+ focusAnchor?: string;
13
+ laneReminders?: {
14
+ base?: string;
15
+ escalated?: string;
16
+ failedNote?: string;
17
+ preamble?: string;
18
+ };
19
+ }
20
+
21
+ /** Fully resolved prompt texts after manifest resolution + file reads. */
22
+ export interface PromptPack {
23
+ framing: string;
24
+ focusAnchor: string;
25
+ laneReminders: {
26
+ base: string;
27
+ escalated: string;
28
+ failedNote: string;
29
+ preamble: string;
30
+ };
31
+ }
32
+
33
+ /** Bundled defaults, shipped git-tracked in `prompts/` (#14 drafts). */
34
+ const BUNDLED_PATHS = {
35
+ framing: "prompts/btw-framing.md",
36
+ focusAnchor: "prompts/btw-focus-anchor.md",
37
+ laneReminders: {
38
+ base: "prompts/lane-reminder-base.md",
39
+ escalated: "prompts/lane-reminder-escalated.md",
40
+ failedNote: "prompts/lane-failed-note.md",
41
+ preamble: "prompts/lane-preamble.md",
42
+ },
43
+ } as const;
44
+
45
+ /**
46
+ * Package root: base for the bundle `config.json` and the `prompts/`
47
+ * defaults. This module lives in `srcs/`, so the extension dir is the
48
+ * parent directory of this file.
49
+ */
50
+ export function getExtensionDir(): string {
51
+ return join(dirname(fileURLToPath(import.meta.url)), "..");
52
+ }
53
+
54
+ /**
55
+ * Load the prompt pack fresh — no caching, every fork re-reads the manifest
56
+ * files so edits apply on the next fork. Per-key fallback (#13):
57
+ *
58
+ * - configured path missing/unreadable → bundled default + notify warning;
59
+ * - bundled default unreadable (packaging error) → empty text + notify.
60
+ */
61
+ export function loadPromptPack(
62
+ manifest: PromptPackManifest | undefined,
63
+ options: { extensionDir: string; notify: (message: string) => void },
64
+ ): PromptPack {
65
+ const read = (
66
+ key: string,
67
+ configured: string | undefined,
68
+ bundled: string,
69
+ ): string => {
70
+ if (configured && configured.trim()) {
71
+ try {
72
+ return readFileSync(
73
+ resolvePath(options.extensionDir, configured),
74
+ "utf-8",
75
+ ).trim();
76
+ } catch {
77
+ options.notify(
78
+ `promptPack ${key}: cannot read "${configured}" — falling back to the bundled default`,
79
+ );
80
+ }
81
+ }
82
+ try {
83
+ return readFileSync(
84
+ resolvePath(options.extensionDir, bundled),
85
+ "utf-8",
86
+ ).trim();
87
+ } catch {
88
+ options.notify(
89
+ `promptPack ${key}: bundled default "${bundled}" unreadable — using empty prompt text`,
90
+ );
91
+ return "";
92
+ }
93
+ };
94
+
95
+ const laneReminders = manifest?.laneReminders ?? {};
96
+ return {
97
+ framing: read("framing", manifest?.framing, BUNDLED_PATHS.framing),
98
+ focusAnchor: read(
99
+ "focusAnchor",
100
+ manifest?.focusAnchor,
101
+ BUNDLED_PATHS.focusAnchor,
102
+ ),
103
+ laneReminders: {
104
+ base: read(
105
+ "laneReminders.base",
106
+ laneReminders.base,
107
+ BUNDLED_PATHS.laneReminders.base,
108
+ ),
109
+ escalated: read(
110
+ "laneReminders.escalated",
111
+ laneReminders.escalated,
112
+ BUNDLED_PATHS.laneReminders.escalated,
113
+ ),
114
+ failedNote: read(
115
+ "laneReminders.failedNote",
116
+ laneReminders.failedNote,
117
+ BUNDLED_PATHS.laneReminders.failedNote,
118
+ ),
119
+ preamble: read(
120
+ "laneReminders.preamble",
121
+ laneReminders.preamble,
122
+ BUNDLED_PATHS.laneReminders.preamble,
123
+ ),
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Template substitution (#13): replaces `{{name}}` with the given value.
130
+ * Undefined variables are left as-is (typos stay visible, never swallowed).
131
+ * Framing vars: `{{cwd}}` `{{model}}`; lane reminder vars: `{{tool}}` `{{count}}`.
132
+ */
133
+ export function substituteTemplate(
134
+ template: string,
135
+ vars: Record<string, string | number | undefined>,
136
+ ): string {
137
+ return template.replace(/\{\{(\w+)\}\}/g, (match, name: string) => {
138
+ const value = vars[name];
139
+ return value === undefined ? match : String(value);
140
+ });
141
+ }
142
+
143
+ function resolvePath(extensionDir: string, path: string): string {
144
+ return isAbsolute(path) ? path : join(extensionDir, path);
145
+ }
package/srcs/retry.ts ADDED
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Turn-level retry engine (issue #6, D9 seam): the classifier and the
3
+ * injectable retry loop the fork's turn cycle wraps around `agent.prompt()`.
4
+ *
5
+ * The engine mirrors pi's turn retry semantics verbatim — classification
6
+ * patterns are transcribed from `@earendil-works/pi-ai@0.85.1` (see
7
+ * `dist/utils/retry.js` and `dist/utils/overflow.js`, themselves the pieces
8
+ * `AgentSession._isRetryableError` composes). The fork targets pi 0.85.1 but
9
+ * the repo's devDependencies pin pi-ai 0.84.2, which does not export those
10
+ * helpers, so the tables are copied here (with their source annotations)
11
+ * rather than imported — "照抄语义,不发明私有协议".
12
+ *
13
+ * The loop is fully injected so tests drive it with a fake agent (scripted
14
+ * attempt results) and a fake clock (recorded delays); the engine itself has
15
+ * no overlay / TUI / pi-runtime dependency.
16
+ */
17
+ /** Assistant-message-shaped failure the classifier accepts (pi's message shape). */
18
+ export interface RetryableFailure {
19
+ stopReason?: string;
20
+ errorMessage?: string;
21
+ usage?: { input?: number; output?: number; cacheRead?: number };
22
+ }
23
+
24
+ // =============================================================================
25
+ // Classifier (pi-ai 0.85.1 semantics)
26
+ // =============================================================================
27
+
28
+ /** Non-retryable provider limit / billing exhaustion patterns (pi retry.js). */
29
+ function buildProviderErrorPattern(patterns: string[]): RegExp {
30
+ return new RegExp(patterns.join("|"), "i");
31
+ }
32
+
33
+ const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
34
+ // OpenCode Go/free-tier limits returned as 429 JSON error types by OpenCode's
35
+ // Zen API. These are subscription/account limits, not transient throttles.
36
+ "GoUsageLimitError",
37
+ "FreeUsageLimitError",
38
+ // OpenCode Go subscription-limit text asks users to enable available-balance
39
+ // usage after rolling/weekly/monthly limits are reached.
40
+ "Monthly usage limit reached",
41
+ "available balance",
42
+ // Generic quota/budget/billing exhaustion. `insufficient_quota` is OpenAI's
43
+ // quota/billing error code; the other strings cover common gateway wording.
44
+ "insufficient_quota",
45
+ "out of budget",
46
+ "quota exceeded",
47
+ "billing",
48
+ ]);
49
+
50
+ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
51
+ // Generic provider load, HTTP status, and server-side transient failures.
52
+ "overloaded",
53
+ "rate.?limit",
54
+ "too many requests",
55
+ "429",
56
+ "500",
57
+ "502",
58
+ "503",
59
+ "504",
60
+ "524",
61
+ "service.?unavailable",
62
+ "server.?error",
63
+ "internal.?error",
64
+ // Wrapper/provider text for transient upstream failures, including OpenRouter
65
+ // "Provider returned error" responses (#2264).
66
+ "provider.?returned.?error",
67
+ "exceeded request buffer limit while retrying upstream",
68
+ // Network, proxy, and fetch transport failures. This includes OpenAI Codex
69
+ // raw-fetch failures such as "upstream connect", "connection refused", and
70
+ // "reset before headers" (#733), plus OpenRouter connection drops (#3317).
71
+ "network.?error",
72
+ "connection.?error",
73
+ "connection.?refused",
74
+ "connection.?lost",
75
+ "other side closed",
76
+ "fetch failed",
77
+ "getaddrinfo",
78
+ "ENOTFOUND",
79
+ "EAI_AGAIN",
80
+ "upstream.?connect",
81
+ "reset before headers",
82
+ "socket hang up",
83
+ "socket connection was closed",
84
+ "timed? out",
85
+ "timeout",
86
+ "terminated",
87
+ // WebSocket transports can report close/error text instead of HTTP/fetch text.
88
+ "websocket.?closed",
89
+ "websocket.?error",
90
+ // Premature stream endings from SDKs and transports. Anthropic can throw
91
+ // "stream ended without ..." and "Anthropic stream ended before message_stop"
92
+ // (#4433); Bedrock/Smithy can throw an HTTP/2 no-response error (#3594).
93
+ "ended without",
94
+ "stream ended before message_stop",
95
+ "stream ended before a terminal response event",
96
+ "http2 request did not get a response",
97
+ // Provider-requested retry delay cap failures should flow through the outer
98
+ // retry policy so callers can surface/abort the backoff (#1123).
99
+ "retry delay",
100
+ // Explicit retry guidance emitted mid-stream by OpenAI Responses and Bedrock
101
+ // stream exceptions (#6019).
102
+ "you can retry your request",
103
+ "try your request again",
104
+ "please retry your request",
105
+ // gRPC based providers (e.g. NVIDIA NIM)
106
+ "ResourceExhausted",
107
+ ]);
108
+
109
+ /**
110
+ * Context-overflow error patterns (pi overflow.js). The engine reimplements
111
+ * the fork's retry decision on top of these, so overflow never retries (it is
112
+ * handled by compaction in the main session and shown as a final error here).
113
+ */
114
+ const OVERFLOW_PATTERNS = [
115
+ /prompt is too long/i, // Anthropic token overflow
116
+ /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413)
117
+ /input is too long for requested model/i, // Amazon Bedrock
118
+ /exceeds the context window/i, // OpenAI (Completions & Responses API)
119
+ /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies (LiteLLM)
120
+ /input token count.*exceeds the maximum/i, // Google (Gemini)
121
+ /maximum prompt length is \d+/i, // xAI (Grok)
122
+ /reduce the length of the messages/i, // Groq
123
+ /maximum context length is \d+ tokens/i, // OpenRouter (most backends)
124
+ /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside
125
+ /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI
126
+ /exceeds the limit of \d+/i, // GitHub Copilot
127
+ /exceeds the available context size/i, // llama.cpp server
128
+ /greater than the context length/i, // LM Studio
129
+ /context window exceeds limit/i, // MiniMax
130
+ /exceeded model token limit/i, // Kimi For Coding
131
+ /too large for model with \d+ maximum context length/i, // Mistral
132
+ /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, // DS4 server
133
+ /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text
134
+ /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error
135
+ /range of input length should be/i, // DashScope / Qwen Token Plan
136
+ /context[_ ]length[_ ]exceeded/i, // Generic fallback
137
+ /too many tokens/i, // Generic fallback
138
+ /token limit exceeded/i, // Generic fallback
139
+ /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i, // Cerebras: 400/413 with no body
140
+ ];
141
+
142
+ /**
143
+ * Non-overflow patterns excluded from overflow detection (pi overflow.js):
144
+ * throttling/rate-limit text that would otherwise match an overflow pattern
145
+ * (e.g. Bedrock "ThrottlingException: Too many tokens, please wait...").
146
+ */
147
+ const NON_OVERFLOW_PATTERNS = [
148
+ /^(Throttling error|Service unavailable):/i, // AWS Bedrock non-overflow errors
149
+ /rate limit/i, // Generic rate limiting
150
+ /too many requests/i, // Generic HTTP 429 style
151
+ ];
152
+
153
+ /** Anything the classifier can be handed: a message-shaped failure, an Error, or text. */
154
+ export type RetryableInput = RetryableFailure | Error | string | null | undefined;
155
+
156
+ /** Normalize loose error shapes to the assistant-message shape pi classifies. */
157
+ function toRetryableFailure(error: RetryableInput): RetryableFailure | null {
158
+ if (error == null) return null;
159
+ if (typeof error === "string") return { stopReason: "error", errorMessage: error };
160
+ if (error instanceof Error) return { stopReason: "error", errorMessage: error.message };
161
+ if (typeof error === "object") return error;
162
+ return null;
163
+ }
164
+
165
+ /** isContextOverflow mirror (pi-ai 0.85.1 dist/utils/overflow.js). */
166
+ function isContextOverflow(message: RetryableFailure, contextWindow?: number): boolean {
167
+ // Case 1: error-message patterns.
168
+ if (message.stopReason === "error" && message.errorMessage) {
169
+ const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) =>
170
+ p.test(message.errorMessage as string),
171
+ );
172
+ if (
173
+ !isNonOverflow &&
174
+ OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage as string))
175
+ ) {
176
+ return true;
177
+ }
178
+ }
179
+ // Case 2: silent overflow (z.ai style) — successful but usage exceeds context.
180
+ if (contextWindow && message.stopReason === "stop") {
181
+ const inputTokens = (message.usage?.input ?? 0) + (message.usage?.cacheRead ?? 0);
182
+ if (inputTokens > contextWindow) return true;
183
+ }
184
+ // Case 3: length-stop overflow (Xiaomi MiMo style) — the server truncates
185
+ // oversized input to fit the context window, leaving no room for output.
186
+ if (contextWindow && message.stopReason === "length" && message.usage?.output === 0) {
187
+ const inputTokens = (message.usage?.input ?? 0) + (message.usage?.cacheRead ?? 0);
188
+ if (inputTokens >= contextWindow * 0.99) return true;
189
+ }
190
+ return false;
191
+ }
192
+
193
+ /** isRetryableAssistantError mirror (pi-ai 0.85.1 dist/utils/retry.js). */
194
+ function isRetryableAssistantError(message: RetryableFailure): boolean {
195
+ if (message.stopReason !== "error" || !message.errorMessage) return false;
196
+ if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(message.errorMessage)) return false;
197
+ return RETRYABLE_PROVIDER_ERROR_PATTERN.test(message.errorMessage);
198
+ }
199
+
200
+ /**
201
+ * Classify a failed turn as retryable, mirroring pi's `_isRetryableError`
202
+ * semantics: transient provider errors (overloaded / rate limit / 5xx /
203
+ * transport) retry; context overflow, aborts, non-error stops, and
204
+ * quota/billing exhaustion never do. When the caller knows the model's
205
+ * context window it can be passed to also catch silent overflow (pi's
206
+ * `isContextOverflow` cases 2/3); the fork's wiring binds it via the
207
+ * injected `classify` when it is available.
208
+ */
209
+ export function classifyRetryable(
210
+ error: RetryableInput,
211
+ contextWindow?: number,
212
+ ): boolean {
213
+ const failure = toRetryableFailure(error);
214
+ if (!failure) return false;
215
+ if (isContextOverflow(failure, contextWindow)) return false;
216
+ return isRetryableAssistantError(failure);
217
+ }
218
+
219
+ // =============================================================================
220
+ // Retry loop
221
+ // =============================================================================
222
+
223
+ /** The `settings.retry` budget/backoff block, same shape pi reads. */
224
+ export interface RetryPolicy {
225
+ enabled: boolean;
226
+ maxRetries: number;
227
+ baseDelayMs: number;
228
+ }
229
+
230
+ /** Status payload for `onAttempt`, emitted before each backoff wait. */
231
+ export interface RetryAttemptInfo {
232
+ /** Retry number, 1-based (the n-th retry after the original failed attempt). */
233
+ attempt: number;
234
+ /** Total retry budget (`policy.maxRetries`). */
235
+ maxAttempts: number;
236
+ /** Backoff for this retry: `baseDelayMs * 2^(attempt-1)`. */
237
+ delayMs: number;
238
+ /** Error text of the failed result. */
239
+ errorMessage: string;
240
+ }
241
+
242
+ export interface RunWithRetryOptions {
243
+ /** Runs one turn attempt and resolves with its result (the fake agent in tests). */
244
+ attempt: () => Promise<unknown>;
245
+ /**
246
+ * Retryability decision for a result; defaults to `classifyRetryable`.
247
+ * Injected so tests can classify arbitrary shapes and the wiring can bind
248
+ * the model's context window.
249
+ */
250
+ classify?: (result: unknown) => boolean;
251
+ /**
252
+ * Backoff sleep; injected for a fake clock. Defaults to {@link sleep}.
253
+ * Must settle (resolve or reject) when `signal` aborts — the engine treats
254
+ * an aborted signal after the wait as "stop retrying, return the last error".
255
+ */
256
+ delay?: (ms: number, signal?: AbortSignal) => Promise<void>;
257
+ /** Cancellation (Esc): interrupts the backoff wait. */
258
+ signal?: AbortSignal;
259
+ /** Called before each backoff wait (e.g. to render "Retrying (attempt n)…"). */
260
+ onAttempt?: (info: RetryAttemptInfo) => void | Promise<void>;
261
+ /** Retry budget + backoff base (`settings.retry` shape). */
262
+ policy: RetryPolicy;
263
+ }
264
+
265
+ /** Abortable setTimeout sleep, mirroring pi's retry sleep behavior. */
266
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
267
+ return new Promise((resolve, reject) => {
268
+ if (signal?.aborted) {
269
+ reject(abortError());
270
+ return;
271
+ }
272
+ const onAbort = () => {
273
+ clearTimeout(timer);
274
+ signal?.removeEventListener("abort", onAbort);
275
+ reject(abortError());
276
+ };
277
+ const timer = setTimeout(() => {
278
+ signal?.removeEventListener("abort", onAbort);
279
+ resolve();
280
+ }, ms);
281
+ signal?.addEventListener("abort", onAbort, { once: true });
282
+ });
283
+ }
284
+
285
+ function abortError(): Error {
286
+ const error = new Error("Aborted");
287
+ error.name = "AbortError";
288
+ return error;
289
+ }
290
+
291
+ function extractErrorMessage(result: unknown): string {
292
+ if (
293
+ typeof result === "object" &&
294
+ result !== null &&
295
+ "errorMessage" in result
296
+ ) {
297
+ const errorMessage = (result as { errorMessage?: unknown }).errorMessage;
298
+ if (typeof errorMessage === "string" && errorMessage.length > 0) {
299
+ return errorMessage;
300
+ }
301
+ }
302
+ if (typeof result === "string" && result.length > 0) return result;
303
+ if (result instanceof Error && result.message.length > 0) return result.message;
304
+ return "Unknown error";
305
+ }
306
+
307
+ /**
308
+ * Run one turn attempt with bounded retry, mirroring pi's turn retry loop
309
+ * (`retryAssistantCall` + `_prepareRetry`):
310
+ *
311
+ * - A non-retryable result (success, abort, overflow, quota, …) returns as-is;
312
+ * - a retryable failure retries up to `policy.maxRetries` times with
313
+ * `baseDelayMs * 2^(n-1)` backoff, emitting `onAttempt` before each wait;
314
+ * - budget exhaustion returns the final error result unchanged;
315
+ * - an aborted `signal` interrupts the backoff wait — an abort that lands
316
+ * before a wait is scheduled skips it entirely — and returns the last
317
+ * error result (so "Esc cancel" and "budget exhausted" are isomorphic —
318
+ * the wiring shows the final error either way);
319
+ * - `policy.enabled === false` runs a single attempt with zero overhead
320
+ * (no classify / delay / onAttempt).
321
+ *
322
+ * A thrown rejection from `attempt` propagates to the caller (pi's stream
323
+ * encodes failures as messages instead of throwing; the fork's wiring decides
324
+ * how to surface its own throws).
325
+ */
326
+ export async function runWithRetry(options: RunWithRetryOptions): Promise<unknown> {
327
+ const { attempt, signal, onAttempt } = options;
328
+ const policy = options.policy;
329
+ const classify =
330
+ options.classify ?? ((result: unknown) => classifyRetryable(result as RetryableInput));
331
+ const delay = options.delay ?? sleep;
332
+
333
+ const maxAttempts = policy.enabled ? (policy.maxRetries ?? 0) : 0;
334
+ const baseDelayMs = policy.baseDelayMs ?? 0;
335
+
336
+ let result: unknown = await attempt();
337
+ let retryCount = 0;
338
+ while (retryCount < maxAttempts && classify(result)) {
339
+ // Esc may have fired between the failed attempt and this decision point:
340
+ // skip scheduling a wait that would be interrupted immediately.
341
+ if (signal?.aborted) return result;
342
+ retryCount++;
343
+ const delayMs = baseDelayMs * 2 ** (retryCount - 1);
344
+ await onAttempt?.({
345
+ attempt: retryCount,
346
+ maxAttempts,
347
+ delayMs,
348
+ errorMessage: extractErrorMessage(result),
349
+ });
350
+ try {
351
+ await delay(delayMs, signal);
352
+ } catch (error) {
353
+ if (signal?.aborted) return result;
354
+ throw error;
355
+ }
356
+ if (signal?.aborted) return result;
357
+ result = await attempt();
358
+ }
359
+ return result;
360
+ }
@@ -0,0 +1,4 @@
1
+ import type { KeyId } from "@earendil-works/pi-tui";
2
+
3
+ /** Open, background, or restore the side chat from the main editor. */
4
+ export const SIDE_CHAT_SHORTCUT: KeyId = "alt+w";