@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,122 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type GlyphStyle = "unicode" | "ascii";
|
|
6
|
+
export type GlobalGlyphStyleOverride = "inherit" | GlyphStyle;
|
|
7
|
+
|
|
8
|
+
const LOCAL_CONFIG_ID = "@vanillagreen/pi-qol";
|
|
9
|
+
const GLOBAL_CONFIG_ID = "@vanillagreen/pi-tool-renderer";
|
|
10
|
+
|
|
11
|
+
function expandHome(input: string): string {
|
|
12
|
+
if (input === "~") return homedir();
|
|
13
|
+
if (input.startsWith("~/")) return join(homedir(), input.slice(2));
|
|
14
|
+
return input;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function projectSettingsPath(cwd: string): string {
|
|
18
|
+
let current = resolve(cwd);
|
|
19
|
+
while (true) {
|
|
20
|
+
const candidate = join(current, ".pi", "settings.json");
|
|
21
|
+
if (existsSync(candidate)) return candidate;
|
|
22
|
+
if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".vstack-lock.json"))) return candidate;
|
|
23
|
+
const parent = dirname(current);
|
|
24
|
+
if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
|
|
25
|
+
current = parent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function piSettingsPaths(cwd = process.cwd()): string[] {
|
|
30
|
+
const userDir = resolve(expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "~/.pi/agent"));
|
|
31
|
+
return [join(userDir, "settings.json"), projectSettingsPath(cwd)];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readPackageConfig(packageId: string, cwd?: string): Record<string, unknown> {
|
|
35
|
+
const merged: Record<string, unknown> = {};
|
|
36
|
+
for (const settingsPath of piSettingsPaths(cwd)) {
|
|
37
|
+
if (!existsSync(settingsPath)) continue;
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
40
|
+
const config = parsed?.vstack?.extensionManager?.config?.[packageId];
|
|
41
|
+
if (config && typeof config === "object" && !Array.isArray(config)) Object.assign(merged, config);
|
|
42
|
+
} catch {
|
|
43
|
+
// Ignore malformed optional manager config.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return merged;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function asGlyphStyle(value: unknown): GlyphStyle | undefined {
|
|
50
|
+
return value === "unicode" || value === "ascii" ? value : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function glyphStyle(cwd?: string): GlyphStyle {
|
|
54
|
+
const globalOverride = readPackageConfig(GLOBAL_CONFIG_ID, cwd).globalGlyphStyleOverride;
|
|
55
|
+
const forced = asGlyphStyle(globalOverride);
|
|
56
|
+
if (forced) return forced;
|
|
57
|
+
const local = readPackageConfig(LOCAL_CONFIG_ID, cwd);
|
|
58
|
+
return asGlyphStyle(local.glyphStyle) ?? asGlyphStyle(local.treeStyle) ?? "unicode";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const GLYPHS = {
|
|
62
|
+
unicode: {
|
|
63
|
+
frame: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
|
|
64
|
+
line: "─",
|
|
65
|
+
tree: { mid: "├─ ", last: "└─ ", stem: "│ ", blank: " " },
|
|
66
|
+
bullet: "● ",
|
|
67
|
+
emptyBullet: "○ ",
|
|
68
|
+
dot: " · ",
|
|
69
|
+
ok: "✓",
|
|
70
|
+
fail: "✗",
|
|
71
|
+
warn: "▲",
|
|
72
|
+
diamond: "◆",
|
|
73
|
+
prompt: "π",
|
|
74
|
+
ellipsis: "…",
|
|
75
|
+
arrow: "→",
|
|
76
|
+
codeBar: "▌",
|
|
77
|
+
},
|
|
78
|
+
ascii: {
|
|
79
|
+
frame: { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" },
|
|
80
|
+
line: "-",
|
|
81
|
+
tree: { mid: "|-- ", last: "`-- ", stem: "| ", blank: " " },
|
|
82
|
+
bullet: "* ",
|
|
83
|
+
emptyBullet: "o ",
|
|
84
|
+
dot: " - ",
|
|
85
|
+
ok: "+",
|
|
86
|
+
fail: "x",
|
|
87
|
+
warn: "!",
|
|
88
|
+
diamond: "*",
|
|
89
|
+
prompt: "pi",
|
|
90
|
+
ellipsis: "...",
|
|
91
|
+
arrow: "->",
|
|
92
|
+
codeBar: "|",
|
|
93
|
+
},
|
|
94
|
+
} as const;
|
|
95
|
+
|
|
96
|
+
export function glyphs(cwd?: string): (typeof GLYPHS)[GlyphStyle] {
|
|
97
|
+
return GLYPHS[glyphStyle(cwd)];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function truncateIndicator(cwd?: string): string {
|
|
101
|
+
return glyphs(cwd).ellipsis;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function truncateText(text: string, maxChars: number, cwd?: string): string {
|
|
105
|
+
if (text.length <= maxChars) return text;
|
|
106
|
+
const indicator = truncateIndicator(cwd);
|
|
107
|
+
return `${text.slice(0, Math.max(0, maxChars - indicator.length))}${indicator}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function dot(cwd?: string): string {
|
|
111
|
+
return glyphs(cwd).dot;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function treeGlyph(branch: "├" | "└" | "│", cwd?: string): string {
|
|
115
|
+
const tree = glyphs(cwd).tree;
|
|
116
|
+
if (branch === "│") return tree.stem;
|
|
117
|
+
return branch === "└" ? tree.last : tree.mid;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function frameGlyphs(cwd?: string): (typeof GLYPHS)[GlyphStyle]["frame"] {
|
|
121
|
+
return glyphs(cwd).frame;
|
|
122
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ansiRed } from "./ansi.js";
|
|
4
|
+
import { boundedSettingNumber, settingBoolean, settingString } from "./settings.js";
|
|
5
|
+
import { stringifyError } from "./util.js";
|
|
6
|
+
|
|
7
|
+
export const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
|
|
8
|
+
export const DEFAULT_RATE_LIMIT_RESUME_MESSAGE = "Hit API rate limit. It has been reset. Continue";
|
|
9
|
+
|
|
10
|
+
const MAX_TIMER_DELAY_MS = 2_147_000_000;
|
|
11
|
+
const DEFAULT_BUFFER_SECONDS = 10;
|
|
12
|
+
const DEFAULT_MAX_DELAY_MINUTES = 24 * 60;
|
|
13
|
+
const RATE_LIMIT_TOKEN = "[rate-limit]";
|
|
14
|
+
|
|
15
|
+
interface RateLimitTimer {
|
|
16
|
+
unref?: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RateLimitClock {
|
|
20
|
+
now(): number;
|
|
21
|
+
setTimeout(callback: () => void, delayMs: number): RateLimitTimer;
|
|
22
|
+
clearTimeout(timer: RateLimitTimer): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RateLimitHint {
|
|
26
|
+
message?: string;
|
|
27
|
+
model?: string;
|
|
28
|
+
observedAt: number;
|
|
29
|
+
provider?: string;
|
|
30
|
+
reason?: string;
|
|
31
|
+
resetAt?: number;
|
|
32
|
+
source: string;
|
|
33
|
+
turnId: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PendingRateLimitResume {
|
|
37
|
+
dueAt: number;
|
|
38
|
+
message: string;
|
|
39
|
+
reason?: string;
|
|
40
|
+
resetAt: number;
|
|
41
|
+
source: string;
|
|
42
|
+
timer?: RateLimitTimer;
|
|
43
|
+
turnId: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_CLOCK: RateLimitClock = {
|
|
47
|
+
now: () => Date.now(),
|
|
48
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
49
|
+
clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
53
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stringValue(value: unknown): string | undefined {
|
|
57
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function numberValue(value: unknown): number | undefined {
|
|
61
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : Number.NaN;
|
|
62
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function allHeaderValues(headers: unknown, key: string): string[] {
|
|
66
|
+
const wanted = key.toLowerCase();
|
|
67
|
+
if (!headers || typeof headers !== "object") return [];
|
|
68
|
+
const anyHeaders = headers as any;
|
|
69
|
+
try {
|
|
70
|
+
if (typeof anyHeaders.get === "function") {
|
|
71
|
+
const value = anyHeaders.get(key) ?? anyHeaders.get(wanted);
|
|
72
|
+
if (typeof value === "string" && value.trim()) return [value.trim()];
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Fall through to object form.
|
|
76
|
+
}
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (const [rawKey, rawValue] of Object.entries(headers as Record<string, unknown>)) {
|
|
79
|
+
if (rawKey.toLowerCase() !== wanted) continue;
|
|
80
|
+
if (Array.isArray(rawValue)) {
|
|
81
|
+
for (const item of rawValue) if (typeof item === "string" && item.trim()) out.push(item.trim());
|
|
82
|
+
} else if (typeof rawValue === "string" && rawValue.trim()) out.push(rawValue.trim());
|
|
83
|
+
else if (typeof rawValue === "number" && Number.isFinite(rawValue)) out.push(String(rawValue));
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function looksLikeRateLimitText(text: string): boolean {
|
|
89
|
+
return /\b(rate[-\s]?limit(?:ed)?|too many requests|\b429\b|quota exceeded|resource exhausted|try again later|retry[-\s]?after|out of extra usage|extra usage)\b/i.test(text);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseUnitDurationMs(amount: number, unit: string | undefined, defaultUnit: "ms" | "s" | "m" = "s"): number | undefined {
|
|
93
|
+
if (!Number.isFinite(amount) || amount < 0) return undefined;
|
|
94
|
+
const normalized = (unit ?? defaultUnit).toLowerCase();
|
|
95
|
+
const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(normalized)
|
|
96
|
+
? 1
|
|
97
|
+
: ["s", "sec", "secs", "second", "seconds"].includes(normalized)
|
|
98
|
+
? 1000
|
|
99
|
+
: ["m", "min", "mins", "minute", "minutes"].includes(normalized)
|
|
100
|
+
? 60 * 1000
|
|
101
|
+
: ["h", "hr", "hrs", "hour", "hours"].includes(normalized)
|
|
102
|
+
? 60 * 60 * 1000
|
|
103
|
+
: ["d", "day", "days"].includes(normalized)
|
|
104
|
+
? 24 * 60 * 60 * 1000
|
|
105
|
+
: undefined;
|
|
106
|
+
if (!multiplier) return undefined;
|
|
107
|
+
const delay = Math.round(amount * multiplier);
|
|
108
|
+
return Number.isFinite(delay) ? delay : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseDurationLikeMs(value: string, defaultUnit: "ms" | "s" | "m" = "s"): number | undefined {
|
|
112
|
+
const text = value.trim().toLowerCase();
|
|
113
|
+
if (!text) return undefined;
|
|
114
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) return parseUnitDurationMs(Number(text), undefined, defaultUnit);
|
|
115
|
+
const single = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)$/i);
|
|
116
|
+
if (single) return parseUnitDurationMs(Number(single[1]), single[2], defaultUnit);
|
|
117
|
+
let total = 0;
|
|
118
|
+
let matched = false;
|
|
119
|
+
const re = /(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)/gi;
|
|
120
|
+
for (const match of text.matchAll(re)) {
|
|
121
|
+
const part = parseUnitDurationMs(Number(match[1]), match[2], defaultUnit);
|
|
122
|
+
if (part === undefined) return undefined;
|
|
123
|
+
total += part;
|
|
124
|
+
matched = true;
|
|
125
|
+
}
|
|
126
|
+
return matched && Number.isFinite(total) ? Math.round(total) : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseRetryAfterMs(value: string, now = Date.now(), defaultUnit: "ms" | "s" | "m" = "s"): number | undefined {
|
|
130
|
+
const duration = parseDurationLikeMs(value, defaultUnit);
|
|
131
|
+
if (duration !== undefined) return now + duration;
|
|
132
|
+
const parsedDate = Date.parse(value);
|
|
133
|
+
return Number.isFinite(parsedDate) ? parsedDate : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function parseResetValueMs(value: string, now = Date.now()): number | undefined {
|
|
137
|
+
const trimmed = value.trim();
|
|
138
|
+
if (!trimmed) return undefined;
|
|
139
|
+
const numeric = Number(trimmed);
|
|
140
|
+
if (Number.isFinite(numeric)) {
|
|
141
|
+
if (numeric > 1_000_000_000_000) return numeric;
|
|
142
|
+
if (numeric > 1_000_000_000) return numeric * 1000;
|
|
143
|
+
return now + numeric * 1000;
|
|
144
|
+
}
|
|
145
|
+
const parsedDate = Date.parse(trimmed);
|
|
146
|
+
if (Number.isFinite(parsedDate)) return parsedDate;
|
|
147
|
+
const duration = parseDurationLikeMs(trimmed, "s");
|
|
148
|
+
return duration !== undefined ? now + duration : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function extractResetAtFromHeaders(headers: unknown, now = Date.now()): { resetAt: number; source: string } | undefined {
|
|
152
|
+
for (const value of allHeaderValues(headers, "retry-after-ms")) {
|
|
153
|
+
const resetAt = parseRetryAfterMs(value, now, "ms");
|
|
154
|
+
if (resetAt !== undefined) return { resetAt, source: "retry-after-ms" };
|
|
155
|
+
}
|
|
156
|
+
for (const value of allHeaderValues(headers, "retry-after")) {
|
|
157
|
+
const resetAt = parseRetryAfterMs(value, now, "s");
|
|
158
|
+
if (resetAt !== undefined) return { resetAt, source: "retry-after" };
|
|
159
|
+
}
|
|
160
|
+
const resetHeaders = [
|
|
161
|
+
"x-ratelimit-reset",
|
|
162
|
+
"x-rate-limit-reset",
|
|
163
|
+
"ratelimit-reset",
|
|
164
|
+
"x-ratelimit-reset-requests",
|
|
165
|
+
"x-ratelimit-reset-tokens",
|
|
166
|
+
"anthropic-ratelimit-requests-reset",
|
|
167
|
+
"anthropic-ratelimit-tokens-reset",
|
|
168
|
+
"anthropic-ratelimit-input-tokens-reset",
|
|
169
|
+
"anthropic-ratelimit-output-tokens-reset",
|
|
170
|
+
];
|
|
171
|
+
for (const header of resetHeaders) {
|
|
172
|
+
for (const value of allHeaderValues(headers, header)) {
|
|
173
|
+
const resetAt = parseResetValueMs(value, now);
|
|
174
|
+
if (resetAt !== undefined) return { resetAt, source: header };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseClockReset(text: string, now = Date.now()): number | undefined {
|
|
181
|
+
const match = text.match(/\bresets?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(am|pm)?\b/i);
|
|
182
|
+
if (!match) return undefined;
|
|
183
|
+
let hour = Number(match[1]);
|
|
184
|
+
const minute = Number(match[2] ?? "0");
|
|
185
|
+
const second = Number(match[3] ?? "0");
|
|
186
|
+
const meridiem = match[4]?.toLowerCase();
|
|
187
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute) || !Number.isInteger(second)) return undefined;
|
|
188
|
+
if (minute > 59 || second > 59) return undefined;
|
|
189
|
+
if (meridiem) {
|
|
190
|
+
if (hour < 1 || hour > 12) return undefined;
|
|
191
|
+
if (meridiem === "pm" && hour !== 12) hour += 12;
|
|
192
|
+
if (meridiem === "am" && hour === 12) hour = 0;
|
|
193
|
+
} else if (hour > 23) return undefined;
|
|
194
|
+
const date = new Date(now);
|
|
195
|
+
date.setHours(hour, minute, second, 0);
|
|
196
|
+
if (date.getTime() <= now) date.setDate(date.getDate() + 1);
|
|
197
|
+
return date.getTime();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function extractResetAtFromText(text: string, now = Date.now()): { resetAt: number; source: string } | undefined {
|
|
201
|
+
const iso = text.match(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})\b/);
|
|
202
|
+
if (iso) {
|
|
203
|
+
const resetAt = Date.parse(iso[0]);
|
|
204
|
+
if (Number.isFinite(resetAt)) return { resetAt, source: "text-iso" };
|
|
205
|
+
}
|
|
206
|
+
const durationMatch = text.match(/\b(?:retry(?:\s+after|\s+in)?|try again in|wait)\s+(\d+(?:\.\d+)?\s*(?:ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)(?:\s*\d+(?:\.\d+)?\s*(?:ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?))*)\b/i);
|
|
207
|
+
if (durationMatch) {
|
|
208
|
+
const duration = parseDurationLikeMs(durationMatch[1], "s");
|
|
209
|
+
if (duration !== undefined) return { resetAt: now + duration, source: "text-duration" };
|
|
210
|
+
}
|
|
211
|
+
const clock = parseClockReset(text, now);
|
|
212
|
+
return clock !== undefined ? { resetAt: clock, source: "text-clock" } : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function messageText(message: any): string {
|
|
216
|
+
const parts: string[] = [];
|
|
217
|
+
if (typeof message?.errorMessage === "string") parts.push(message.errorMessage);
|
|
218
|
+
if (typeof message?.content === "string") parts.push(message.content);
|
|
219
|
+
else if (Array.isArray(message?.content)) {
|
|
220
|
+
for (const block of message.content) {
|
|
221
|
+
if (typeof block === "string") parts.push(block);
|
|
222
|
+
else if (block?.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return parts.filter(Boolean).join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function lastAssistantMessage(event: any, ctx: ExtensionContext): any | undefined {
|
|
229
|
+
const eventMessages = Array.isArray(event?.messages) ? event.messages : [];
|
|
230
|
+
for (let index = eventMessages.length - 1; index >= 0; index -= 1) {
|
|
231
|
+
const message = eventMessages[index];
|
|
232
|
+
if (message?.role === "assistant") return message;
|
|
233
|
+
}
|
|
234
|
+
const branch = ctx.sessionManager.getBranch?.() ?? [];
|
|
235
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
236
|
+
const entry = branch[index] as any;
|
|
237
|
+
if (entry?.type === "message" && entry.message?.role === "assistant") return entry.message;
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function formatDueTime(timestamp: number): string {
|
|
243
|
+
return new Date(timestamp).toLocaleString(undefined, {
|
|
244
|
+
day: "numeric",
|
|
245
|
+
hour: "numeric",
|
|
246
|
+
minute: "2-digit",
|
|
247
|
+
month: "short",
|
|
248
|
+
timeZoneName: "short",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function previewMessage(message: string, max = 72): string {
|
|
253
|
+
const singleLine = message.replace(/\s+/g, " ").trim();
|
|
254
|
+
return singleLine.length <= max ? singleLine : `${singleLine.slice(0, Math.max(0, max - 1))}…`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function settingsEnabled(ctx: ExtensionContext): boolean {
|
|
258
|
+
return settingBoolean("rateLimitAutoResume.enabled", false, ctx.cwd);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function settingsNotify(ctx: ExtensionContext): boolean {
|
|
262
|
+
return settingBoolean("rateLimitAutoResume.notify", true, ctx.cwd);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function settingsMessage(ctx: ExtensionContext): string {
|
|
266
|
+
return settingString("rateLimitAutoResume.message", DEFAULT_RATE_LIMIT_RESUME_MESSAGE, ctx.cwd);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function settingsBufferMs(ctx: ExtensionContext): number {
|
|
270
|
+
return boundedSettingNumber("rateLimitAutoResume.bufferSeconds", DEFAULT_BUFFER_SECONDS, 0, 600, ctx.cwd) * 1000;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function settingsMaxDelayMs(ctx: ExtensionContext): number {
|
|
274
|
+
return boundedSettingNumber("rateLimitAutoResume.maxDelayMinutes", DEFAULT_MAX_DELAY_MINUTES, 1, 30 * 24 * 60, ctx.cwd) * 60 * 1000;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function createRateLimitAutoResumeController(pi: ExtensionAPI, clock: RateLimitClock = DEFAULT_CLOCK) {
|
|
278
|
+
let onChange: (() => void) | undefined;
|
|
279
|
+
let turnId = 0;
|
|
280
|
+
let latestHint: RateLimitHint | undefined;
|
|
281
|
+
let pending: PendingRateLimitResume | undefined;
|
|
282
|
+
|
|
283
|
+
const notifyChanged = (): void => {
|
|
284
|
+
try { onChange?.(); }
|
|
285
|
+
catch { /* UI refresh must never break recovery state. */ }
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const clearPendingTimer = (): void => {
|
|
289
|
+
if (!pending?.timer) return;
|
|
290
|
+
clock.clearTimeout(pending.timer);
|
|
291
|
+
pending.timer = undefined;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const clearPending = (): void => {
|
|
295
|
+
const hadPending = Boolean(pending);
|
|
296
|
+
clearPendingTimer();
|
|
297
|
+
pending = undefined;
|
|
298
|
+
if (hadPending) notifyChanged();
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const noteHint = (hint: Omit<RateLimitHint, "observedAt" | "turnId"> & { observedAt?: number; turnId?: number }): void => {
|
|
302
|
+
if (hint.resetAt !== undefined && (!Number.isFinite(hint.resetAt) || hint.resetAt <= 0)) return;
|
|
303
|
+
latestHint = {
|
|
304
|
+
...hint,
|
|
305
|
+
observedAt: hint.observedAt ?? clock.now(),
|
|
306
|
+
turnId: hint.turnId ?? turnId,
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const deliverPending = async (snapshot: PendingRateLimitResume, ctx: ExtensionContext): Promise<void> => {
|
|
311
|
+
if (pending !== snapshot) return;
|
|
312
|
+
if (snapshot.turnId !== turnId || !settingsEnabled(ctx)) {
|
|
313
|
+
clearPending();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (clock.now() < snapshot.dueAt) {
|
|
317
|
+
armPending(snapshot, ctx);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
clearPending();
|
|
321
|
+
try {
|
|
322
|
+
let idle = true;
|
|
323
|
+
try { idle = ctx.isIdle?.() ?? true; }
|
|
324
|
+
catch { idle = false; }
|
|
325
|
+
await Promise.resolve(idle ? pi.sendUserMessage(snapshot.message) : pi.sendUserMessage(snapshot.message, { deliverAs: "followUp" }));
|
|
326
|
+
if (ctx.hasUI && settingsNotify(ctx)) ctx.ui.notify(`${RATE_LIMIT_TOKEN} Auto-resume sent after rate-limit reset.`, "info");
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (ctx.hasUI) ctx.ui.notify(`${RATE_LIMIT_TOKEN} Auto-resume failed: ${stringifyError(error)}`, "error");
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
function armPending(snapshot: PendingRateLimitResume, ctx: ExtensionContext): void {
|
|
333
|
+
clearPendingTimer();
|
|
334
|
+
const remaining = Math.max(0, snapshot.dueAt - clock.now());
|
|
335
|
+
snapshot.timer = clock.setTimeout(() => { void deliverPending(snapshot, ctx); }, Math.min(remaining, MAX_TIMER_DELAY_MS));
|
|
336
|
+
snapshot.timer.unref?.();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const scheduleFromHint = (hint: RateLimitHint, ctx: ExtensionContext): boolean => {
|
|
340
|
+
if (!settingsEnabled(ctx)) return false;
|
|
341
|
+
if (hint.turnId !== turnId) return false;
|
|
342
|
+
if (ctx.hasPendingMessages?.()) return false;
|
|
343
|
+
if (hint.resetAt === undefined || !Number.isFinite(hint.resetAt)) return false;
|
|
344
|
+
const dueAt = Math.max(clock.now() + 1000, hint.resetAt + settingsBufferMs(ctx));
|
|
345
|
+
const delay = dueAt - clock.now();
|
|
346
|
+
if (delay > settingsMaxDelayMs(ctx)) return false;
|
|
347
|
+
const message = settingsMessage(ctx);
|
|
348
|
+
clearPendingTimer();
|
|
349
|
+
pending = { dueAt, message, reason: hint.reason, resetAt: hint.resetAt, source: hint.source, turnId };
|
|
350
|
+
armPending(pending, ctx);
|
|
351
|
+
notifyChanged();
|
|
352
|
+
if (ctx.hasUI && settingsNotify(ctx)) {
|
|
353
|
+
ctx.ui.notify(`${RATE_LIMIT_TOKEN} Rate limit hit. Auto-resume scheduled for ${formatDueTime(dueAt)}.`, "warning");
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
enabled(ctx: ExtensionContext): boolean {
|
|
360
|
+
return settingsEnabled(ctx);
|
|
361
|
+
},
|
|
362
|
+
clearTimers(): void {
|
|
363
|
+
clearPending();
|
|
364
|
+
},
|
|
365
|
+
noteAgentStart(_ctx: ExtensionContext): void {
|
|
366
|
+
turnId += 1;
|
|
367
|
+
latestHint = undefined;
|
|
368
|
+
clearPending();
|
|
369
|
+
},
|
|
370
|
+
noteAgentEnd(event: any, ctx: ExtensionContext): boolean {
|
|
371
|
+
const assistant = lastAssistantMessage(event, ctx);
|
|
372
|
+
const text = messageText(assistant);
|
|
373
|
+
const textShowsRateLimit = Boolean(text && looksLikeRateLimitText(text));
|
|
374
|
+
if (textShowsRateLimit) {
|
|
375
|
+
const reset = extractResetAtFromText(text, clock.now());
|
|
376
|
+
noteHint({
|
|
377
|
+
message: text,
|
|
378
|
+
model: stringValue(assistant?.model),
|
|
379
|
+
provider: stringValue(assistant?.provider),
|
|
380
|
+
reason: "assistant error",
|
|
381
|
+
resetAt: reset?.resetAt ?? latestHint?.resetAt,
|
|
382
|
+
source: reset?.source ?? latestHint?.source ?? "assistant-message",
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
const stoppedOnHint = !assistant || assistant?.stopReason === "error" || assistant?.stopReason === "aborted";
|
|
386
|
+
if (!textShowsRateLimit && !stoppedOnHint) return false;
|
|
387
|
+
return latestHint ? scheduleFromHint(latestHint, ctx) : false;
|
|
388
|
+
},
|
|
389
|
+
noteMessageEnd(event: any, _ctx: ExtensionContext): void {
|
|
390
|
+
const message = event?.message;
|
|
391
|
+
if (!message || message.role !== "assistant") return;
|
|
392
|
+
const text = messageText(message);
|
|
393
|
+
if (!looksLikeRateLimitText(text)) return;
|
|
394
|
+
const reset = extractResetAtFromText(text, clock.now());
|
|
395
|
+
noteHint({
|
|
396
|
+
message: text,
|
|
397
|
+
model: stringValue(message.model),
|
|
398
|
+
provider: stringValue(message.provider),
|
|
399
|
+
reason: "assistant message",
|
|
400
|
+
resetAt: reset?.resetAt ?? latestHint?.resetAt,
|
|
401
|
+
source: reset?.source ?? latestHint?.source ?? "message_end",
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
noteProviderResponse(event: any, _ctx: ExtensionContext): void {
|
|
405
|
+
if (Number(event?.status) !== 429) return;
|
|
406
|
+
const reset = extractResetAtFromHeaders(event?.headers, clock.now());
|
|
407
|
+
noteHint({
|
|
408
|
+
model: stringValue(event?.model?.id) ?? stringValue(event?.model),
|
|
409
|
+
provider: stringValue(event?.provider?.id) ?? stringValue(event?.provider),
|
|
410
|
+
reason: "HTTP 429",
|
|
411
|
+
resetAt: reset?.resetAt ?? latestHint?.resetAt,
|
|
412
|
+
source: reset?.source ?? latestHint?.source ?? "after_provider_response",
|
|
413
|
+
});
|
|
414
|
+
},
|
|
415
|
+
noteExternalRateLimitEvent(payload: unknown, _ctx: ExtensionContext): void {
|
|
416
|
+
const event = asRecord(payload);
|
|
417
|
+
if (!event) return;
|
|
418
|
+
const status = stringValue(event.status);
|
|
419
|
+
if (status && status !== "rejected" && status !== "rate_limited" && status !== "error") return;
|
|
420
|
+
const rawReset = numberValue(event.resetAtMs) ?? numberValue(event.resetAtUnixMs);
|
|
421
|
+
const textReset = stringValue(event.resetAt) ?? stringValue(event.resetsAt);
|
|
422
|
+
const parsedTextReset = textReset ? parseResetValueMs(textReset, clock.now()) : undefined;
|
|
423
|
+
const resetAt = rawReset ?? parsedTextReset;
|
|
424
|
+
noteHint({
|
|
425
|
+
message: stringValue(event.message),
|
|
426
|
+
model: stringValue(event.model),
|
|
427
|
+
provider: stringValue(event.provider),
|
|
428
|
+
reason: stringValue(event.reason) ?? stringValue(event.rateLimitType),
|
|
429
|
+
resetAt: resetAt ?? latestHint?.resetAt,
|
|
430
|
+
source: stringValue(event.source) ?? latestHint?.source ?? RATE_LIMIT_AUTO_RESUME_EVENT,
|
|
431
|
+
});
|
|
432
|
+
},
|
|
433
|
+
renderPreviewLines(width: number): string[] {
|
|
434
|
+
if (!pending) return [];
|
|
435
|
+
const line = `| ${RATE_LIMIT_TOKEN} Auto-resume ${formatDueTime(pending.dueAt)} — ${previewMessage(pending.message)}`;
|
|
436
|
+
return [truncateToWidth(ansiRed(line), width, "")];
|
|
437
|
+
},
|
|
438
|
+
setOnChange(callback: (() => void) | undefined): void {
|
|
439
|
+
onChange = callback;
|
|
440
|
+
},
|
|
441
|
+
// Test helper / low-level extension bridge.
|
|
442
|
+
scheduleForHint(hint: RateLimitHint, ctx: ExtensionContext): boolean {
|
|
443
|
+
latestHint = hint;
|
|
444
|
+
return scheduleFromHint(hint, ctx);
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|