@hicaru/pi-rlm 0.3.8 → 0.3.13

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.
Files changed (55) hide show
  1. package/README.md +3 -4
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/completion.ts +5 -0
  4. package/src/bridge/handlers/emitting.ts +33 -23
  5. package/src/bridge/handlers/index.ts +1 -1
  6. package/src/bridge/handlers/llm-query.ts +23 -24
  7. package/src/bridge/handlers/rlm-query.ts +10 -32
  8. package/src/bridge/handlers/types.ts +8 -1
  9. package/src/bridge/model.ts +33 -15
  10. package/src/commands/pins.ts +51 -0
  11. package/src/commands/rlm-config.ts +4 -88
  12. package/src/commands/rlm-llm.ts +59 -0
  13. package/src/commands/rlm-rlm.ts +58 -0
  14. package/src/commands/rlm.ts +2 -2
  15. package/src/config/defaults.ts +14 -4
  16. package/src/config/settings.ts +26 -3
  17. package/src/core/budget.ts +1 -1
  18. package/src/core/compaction.ts +4 -0
  19. package/src/core/engine.ts +21 -4
  20. package/src/core/iteration.ts +12 -0
  21. package/src/core/ledger.ts +15 -123
  22. package/src/core/memory.ts +13 -1
  23. package/src/core/model-registry.ts +1 -1
  24. package/src/core/types.ts +14 -0
  25. package/src/index.ts +53 -4
  26. package/src/mode/rlm-mode.ts +11 -1
  27. package/src/prompts/glossary.ts +11 -3
  28. package/src/prompts/native.ts +1 -1
  29. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  31. package/src/tool/repl-render.ts +4 -10
  32. package/src/tool/repl-tool.ts +30 -17
  33. package/src/tool/rlm-aggregator.ts +16 -3
  34. package/src/tool/rlm-details.ts +8 -0
  35. package/src/tool/rlm-events.ts +17 -1
  36. package/src/tool/rlm-tool.ts +25 -14
  37. package/src/tool/subcall-render.ts +14 -129
  38. package/src/tool/subcall-store.ts +11 -1
  39. package/src/ui/intro.ts +13 -4
  40. package/src/ui/modal/agent-modal.ts +104 -0
  41. package/src/ui/modal/modal-view.ts +132 -0
  42. package/src/ui/modal/timeline-store.ts +85 -0
  43. package/src/ui/model-picker/drilldown.ts +173 -0
  44. package/src/ui/model-picker/grouping.ts +81 -0
  45. package/src/ui/model-picker/levels.ts +63 -0
  46. package/src/ui/model-picker.ts +7 -197
  47. package/src/ui/panel/run-registry.ts +135 -0
  48. package/src/ui/panel/tree-panel.ts +46 -0
  49. package/src/ui/status.ts +26 -13
  50. package/src/ui/theme.ts +0 -4
  51. package/src/ui/tree/tree-model.ts +226 -0
  52. package/src/ui/tree/tree-rows.ts +74 -0
  53. package/src/ui/tree/tree-widget.ts +186 -0
  54. package/src/util/retry.ts +180 -0
  55. package/src/util/throttle.ts +90 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Retry + rate-limit classification for LLM completions — the retry half of resilience.
3
+ *
4
+ * pi-ai surfaces provider failures as `stopReason:"error"` plus an errorMessage STRING
5
+ * (the HTTP status baked into the text, e.g. `429: {"code":"1302",...}`), but its
6
+ * `onResponse` hook still hands us the raw `{status, headers}` of every HTTP response.
7
+ * Classification therefore runs on both: captured status first, text patterns as the
8
+ * fallback — the same shape pi-ai's own codex provider uses internally. Timing, when the
9
+ * provider sends it, comes from `retry-after` / `retry-after-ms` headers (numeric seconds,
10
+ * milliseconds, or an HTTP-date); otherwise exponential backoff with jitter, capped.
11
+ *
12
+ * Rate-limit errors additionally penalize the per-provider cooldown (util/throttle.ts):
13
+ * the retry sleeps, and every OTHER request queued behind the same provider waits too.
14
+ */
15
+
16
+ import { ProviderCooldown, sleepMs, sharedCooldown } from "./throttle.ts";
17
+
18
+ // Auth/quota failures must not be retried — they burn attempts and never recover.
19
+ const NON_RETRYABLE_TEXT =
20
+ /api[ -]?key|unauthorized|forbidden|permission denied|billing|insufficient|balance|quota exceeded|not.?found|context length|too large|invalid request|malformed/i;
21
+ // Transport/server transients — worth another attempt.
22
+ const RETRYABLE_TEXT =
23
+ /\b429\b|rate.?limit|overloaded|service.?unavailable|upstream|timeout|timed.?out|temporarily|try.?again|econnreset|econnrefused|etimedout|socket hang up|network|1302|速率|频率/i;
24
+ const RATE_LIMIT_TEXT = /\b429\b|rate.?limit|1302|速率|频率/i;
25
+
26
+ const NON_RETRYABLE_STATUS = new Set([400, 401, 402, 403, 404, 413, 422]);
27
+ const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
28
+
29
+ /** Did this failure mean "too many requests"? Drives the cooldown penalty. */
30
+ export function isRateLimited(status: number | undefined, text: string): boolean {
31
+ return status === 429 || (status === undefined && RATE_LIMIT_TEXT.test(text));
32
+ }
33
+
34
+ /** Should this failure get another attempt? Explicit non-retryables win over patterns. */
35
+ export function retryableError(status: number | undefined, text: string): boolean {
36
+ if (status !== undefined) {
37
+ if (NON_RETRYABLE_STATUS.has(status)) return false;
38
+ if (RETRYABLE_STATUS.has(status)) return true;
39
+ }
40
+ if (NON_RETRYABLE_TEXT.test(text)) return false;
41
+ return RETRYABLE_TEXT.test(text);
42
+ }
43
+
44
+ /** Header lookup that tolerates any key casing the provider layer kept. */
45
+ function header(headers: Record<string, string>, name: string): string | undefined {
46
+ const lower = name.toLowerCase();
47
+ for (const k of Object.keys(headers)) {
48
+ if (k.toLowerCase() === lower) return headers[k];
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /** Parse `retry-after` / `retry-after-ms` into ms; undefined when absent or garbage. */
54
+ export function retryAfterMs(headers: Record<string, string> | undefined): number | undefined {
55
+ if (headers === undefined) return undefined;
56
+ const ms = header(headers, "retry-after-ms");
57
+ if (ms !== undefined) {
58
+ const n = Number(ms);
59
+ if (Number.isFinite(n) && n >= 0) return n;
60
+ }
61
+ const ra = header(headers, "retry-after");
62
+ if (ra === undefined) return undefined;
63
+ const seconds = Number(ra);
64
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
65
+ const date = Date.parse(ra);
66
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
67
+ }
68
+
69
+ /** Exponential backoff with ±30% jitter, hard-capped at `maxMs`. */
70
+ export function backoffMs(attempt: number, baseMs: number, maxMs: number): number {
71
+ const raw = baseMs * 2 ** attempt * (0.7 + Math.random() * 0.6);
72
+ return Math.min(Math.round(raw), maxMs);
73
+ }
74
+
75
+ /** Numbers knobs — mirrors the optional RlmConfig fields (core/types.ts). */
76
+ export interface RetryPolicy {
77
+ /** TOTAL attempts per call, including the first. 1 = never retry. */
78
+ readonly maxAttempts: number;
79
+ /** Separate, GENEROUS budget for rate limits only: a 429 means "come back later",
80
+ * not "fail" — the call keeps parking on the cooldown window instead of dying.
81
+ * Burned only by rate-limited failures, never by 5xx/timeouts. */
82
+ readonly rateLimitMaxAttempts: number;
83
+ readonly baseDelayMs: number;
84
+ /** Cap for any single retry delay, including a parsed `retry-after`. */
85
+ readonly maxDelayMs: number;
86
+ /** First cooldown when a provider 429s without timing; doubles per consecutive strike. */
87
+ readonly throttleBaseMs: number;
88
+ /** Ceiling for the adaptive per-provider cooldown. */
89
+ readonly throttleMaxMs: number;
90
+ /** Isolation for tests; defaults to the process-wide shared cooldown. */
91
+ readonly cooldown?: ProviderCooldown;
92
+ }
93
+
94
+ export const DEFAULT_RETRY_POLICY: Readonly<RetryPolicy> = Object.freeze({
95
+ maxAttempts: 3,
96
+ rateLimitMaxAttempts: 8,
97
+ baseDelayMs: 500,
98
+ maxDelayMs: 15_000,
99
+ throttleBaseMs: 2_000,
100
+ throttleMaxMs: 60_000,
101
+ });
102
+
103
+ /** Shape of the optional retry knobs on RlmConfig — kept structural to avoid a cycle. */
104
+ export interface RetryConfigNumbers {
105
+ readonly retryMaxAttempts?: number;
106
+ readonly rateLimitMaxAttempts?: number;
107
+ readonly retryBaseDelayMs?: number;
108
+ readonly retryMaxDelayMs?: number;
109
+ readonly throttleBaseMs?: number;
110
+ readonly throttleMaxMs?: number;
111
+ }
112
+
113
+ /** Derive a policy from persisted config knobs, falling back to the defaults. */
114
+ export function retryPolicy(from: RetryConfigNumbers = {}): RetryPolicy {
115
+ return {
116
+ maxAttempts: from.retryMaxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
117
+ rateLimitMaxAttempts: from.rateLimitMaxAttempts ?? DEFAULT_RETRY_POLICY.rateLimitMaxAttempts,
118
+ baseDelayMs: from.retryBaseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs,
119
+ maxDelayMs: from.retryMaxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
120
+ throttleBaseMs: from.throttleBaseMs ?? DEFAULT_RETRY_POLICY.throttleBaseMs,
121
+ throttleMaxMs: from.throttleMaxMs ?? DEFAULT_RETRY_POLICY.throttleMaxMs,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Run `attempt` under the policy. `note` is how the caller feeds captured HTTP
127
+ * `{status, headers}` back (pi-ai's onResponse hook) — cleared before every attempt so a
128
+ * stale capture never misclassifies a fresh failure. Rate-limit failures park on the
129
+ * cooldown — their own, generous budget — so sibling requests slow down with us;
130
+ * `onPark`/`onRelease` surface the parking to the UI as a "queued" phase.
131
+ */
132
+ export async function completeWithRetry<T>(
133
+ attempt: (note: (status: number, headers: Record<string, string>) => void) => Promise<T>,
134
+ opts: {
135
+ readonly policy: RetryPolicy;
136
+ readonly provider: string;
137
+ readonly signal?: AbortSignal;
138
+ readonly onPark?: (ms: number) => void;
139
+ readonly onRelease?: () => void;
140
+ },
141
+ ): Promise<T> {
142
+ const { policy, provider, signal, onPark, onRelease } = opts;
143
+ const cooldown = policy.cooldown ?? sharedCooldown;
144
+ let status: number | undefined;
145
+ let headers: Record<string, string> | undefined;
146
+ const note = (s: number, h: Record<string, string>): void => {
147
+ status = s;
148
+ headers = h;
149
+ };
150
+ let rlTries = 0; // rate-limit failures burn their OWN budget, never maxAttempts
151
+ for (let tries = 0; ; tries++) {
152
+ await cooldown.wait(provider, signal, onPark, onRelease);
153
+ status = undefined;
154
+ headers = undefined;
155
+ try {
156
+ const out = await attempt(note);
157
+ cooldown.success(provider);
158
+ return out;
159
+ } catch (err: unknown) {
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ if (signal?.aborted) throw err;
162
+ if (isRateLimited(status, msg)) {
163
+ // "Come back later" — park on the shared cooldown instead of dying. The strike
164
+ // heuristic escalates the window; the wait itself happens at the loop top, so
165
+ // sibling requests behind the same provider slow down together.
166
+ if (rlTries + 1 >= policy.rateLimitMaxAttempts) throw err;
167
+ rlTries++;
168
+ const hint = retryAfterMs(headers);
169
+ cooldown.penalize(provider, hint !== undefined ? Math.min(hint, policy.throttleMaxMs) : undefined);
170
+ continue;
171
+ }
172
+ if (tries + 1 >= policy.maxAttempts) throw err;
173
+ if (!retryableError(status, msg)) throw err;
174
+ await sleepMs(
175
+ Math.min(retryAfterMs(headers) ?? backoffMs(tries, policy.baseDelayMs, policy.maxDelayMs), policy.maxDelayMs),
176
+ signal,
177
+ );
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Per-provider adaptive cooldown — the throttle half of rate-limit resilience.
3
+ *
4
+ * A 429 means the provider wants FEWER requests for a while, not just this one retried.
5
+ * `ProviderCooldown` holds each provider's admission time: `wait()` parks new requests
6
+ * until the window opens; `penalize()` extends it, escalating on consecutive strikes
7
+ * (base × 2^strikes, capped) so a persistent limit backs the whole fan-out off
8
+ * exponentially. Any success clears the strike counter — providers rarely announce
9
+ * recovery, so we probe again at full concurrency rather than assume the worst.
10
+ *
11
+ * `sharedCooldown` is process-wide on purpose: the provider's limit is process-wide.
12
+ * Tests inject a fresh instance via `RetryPolicy.cooldown` for isolation.
13
+ */
14
+
15
+ /** Abort-aware sleep. Rejects with "aborted" the moment `signal` fires. */
16
+ export function sleepMs(ms: number, signal?: AbortSignal): Promise<void> {
17
+ if (ms <= 0) return Promise.resolve();
18
+ return new Promise<void>((resolve, reject) => {
19
+ const timer = setTimeout((): void => {
20
+ signal?.removeEventListener("abort", onAbort);
21
+ resolve();
22
+ }, ms);
23
+ const onAbort = (): void => {
24
+ clearTimeout(timer);
25
+ reject(new Error("aborted"));
26
+ };
27
+ if (signal?.aborted) {
28
+ clearTimeout(timer);
29
+ reject(new Error("aborted"));
30
+ return;
31
+ }
32
+ signal?.addEventListener("abort", onAbort, { once: true });
33
+ });
34
+ }
35
+
36
+ export class ProviderCooldown {
37
+ private readonly blockedUntil = new Map<string, number>();
38
+ private readonly strikes = new Map<string, number>();
39
+
40
+ constructor(private readonly baseMs: number, private readonly maxMs: number) {}
41
+
42
+ /** ms until `provider` may be admitted again; 0 = free. */
43
+ waitMs(provider: string): number {
44
+ return Math.max(0, (this.blockedUntil.get(provider) ?? 0) - Date.now());
45
+ }
46
+
47
+ /** Park until the window opens. Re-checks after every wake — penalize() may extend it.
48
+ * `onPark` fires with the pending ms before each sleep (UI: "queued on rate limit"),
49
+ * `onRelease` once right after the wait ends — but only if we actually parked. */
50
+ async wait(provider: string, signal?: AbortSignal, onPark?: (ms: number) => void, onRelease?: () => void): Promise<void> {
51
+ let parked = false;
52
+ for (;;) {
53
+ const ms = this.waitMs(provider);
54
+ if (ms <= 0) break;
55
+ parked = true;
56
+ onPark?.(ms);
57
+ await sleepMs(ms, signal);
58
+ }
59
+ if (parked) onRelease?.();
60
+ }
61
+
62
+ /**
63
+ * Extend the window. `ms` (e.g. a parsed `retry-after`) floors the penalty; the strike
64
+ * heuristic (base × 2^strikes, capped) always applies on top so repeated 429s escalate
65
+ * even when the provider sends no timing at all — zai's `{"code":"1302",...}` body, for
66
+ * one, carries none.
67
+ */
68
+ penalize(provider: string, ms?: number): void {
69
+ const strikes = (this.strikes.get(provider) ?? 0) + 1;
70
+ this.strikes.set(provider, strikes);
71
+ const heuristic = Math.min(this.baseMs * 2 ** (strikes - 1), this.maxMs);
72
+ const until = Date.now() + Math.max(ms ?? 0, heuristic);
73
+ const prev = this.blockedUntil.get(provider) ?? 0;
74
+ if (until > prev) this.blockedUntil.set(provider, until);
75
+ }
76
+
77
+ /** A success means the window opened — reset escalation for the next burst. */
78
+ success(provider: string): void {
79
+ this.strikes.delete(provider);
80
+ }
81
+ }
82
+
83
+ /** Mirrored into DEFAULT_RETRY_POLICY (util/retry.ts imports these — keep one-way). */
84
+ export const THROTTLE_DEFAULTS = Object.freeze({ baseMs: 2_000, maxMs: 60_000 } as const);
85
+
86
+ /** Process-wide instance: every completion in this pi session shares it. */
87
+ export const sharedCooldown: ProviderCooldown = new ProviderCooldown(
88
+ THROTTLE_DEFAULTS.baseMs,
89
+ THROTTLE_DEFAULTS.maxMs,
90
+ );