@hicaru/pi-rlm 0.3.14 → 0.3.16

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.
@@ -18,6 +18,9 @@ type MutableSubcall = {
18
18
  export interface SubcallTotals {
19
19
  readonly costUsd: number;
20
20
  readonly tokens: number;
21
+ /** In/out split (input / output) — mirrors tokens, shown separately in the tree. */
22
+ readonly tokensIn: number;
23
+ readonly tokensOut: number;
21
24
  }
22
25
 
23
26
  export class SubcallStore extends EmitterListener {
@@ -25,8 +28,12 @@ export class SubcallStore extends EmitterListener {
25
28
 
26
29
  private totalCostUsd = 0;
27
30
  private totalTokens = 0;
31
+ private totalTokensIn = 0;
32
+ private totalTokensOut = 0;
28
33
  private rootCostUsd = 0;
29
34
  private rootTokens = 0;
35
+ private rootTokensIn = 0;
36
+ private rootTokensOut = 0;
30
37
 
31
38
  constructor(emitter: RlmEmitter, private readonly onChange?: () => void) {
32
39
  super();
@@ -52,6 +59,8 @@ export class SubcallStore extends EmitterListener {
52
59
  startedAt: Date.now(),
53
60
  costUsd: 0,
54
61
  tokens: 0,
62
+ tokensIn: 0,
63
+ tokensOut: 0,
55
64
  });
56
65
  }
57
66
 
@@ -75,6 +84,14 @@ export class SubcallStore extends EmitterListener {
75
84
  sc.tokens += event.tokens;
76
85
  this.totalTokens += event.tokens;
77
86
  }
87
+ if (event.tokensIn !== undefined) {
88
+ sc.tokensIn += event.tokensIn;
89
+ this.totalTokensIn += event.tokensIn;
90
+ }
91
+ if (event.tokensOut !== undefined) {
92
+ sc.tokensOut += event.tokensOut;
93
+ this.totalTokensOut += event.tokensOut;
94
+ }
78
95
  if (event.failedCount !== undefined) sc.failedCount = event.failedCount;
79
96
  if (event.totalCount !== undefined) sc.totalCount = event.totalCount;
80
97
  }
@@ -87,8 +104,8 @@ export class SubcallStore extends EmitterListener {
87
104
  }
88
105
 
89
106
  /** Snapshot running totals. O(1). */
90
- getTotals(): { readonly costUsd: number; readonly tokens: number } {
91
- return { costUsd: this.totalCostUsd, tokens: this.totalTokens };
107
+ getTotals(): SubcallTotals {
108
+ return { costUsd: this.totalCostUsd, tokens: this.totalTokens, tokensIn: this.totalTokensIn, tokensOut: this.totalTokensOut };
92
109
  }
93
110
 
94
111
  /**
@@ -126,33 +143,43 @@ export class SubcallStore extends EmitterListener {
126
143
  const taken: RlmSubcall[] = [];
127
144
  let costUsd = 0;
128
145
  let tokens = 0;
146
+ let tokensIn = 0;
147
+ let tokensOut = 0;
129
148
  for (const root of children.get(undefined) ?? []) {
130
149
  const subtree = settledSubtree(root);
131
150
  if (subtree === undefined) continue;
132
151
  for (const node of subtree) {
133
152
  costUsd += node.costUsd;
134
153
  tokens += node.tokens;
154
+ tokensIn += node.tokensIn;
155
+ tokensOut += node.tokensOut;
135
156
  taken.push(Object.freeze({ ...node, status: node.status as SubcallStatus }));
136
157
  this.subcalls.delete(node.id);
137
158
  }
138
159
  }
139
160
  this.totalCostUsd -= costUsd;
140
161
  this.totalTokens -= tokens;
141
- return { subcalls: taken, totals: { costUsd, tokens } };
162
+ this.totalTokensIn -= tokensIn;
163
+ this.totalTokensOut -= tokensOut;
164
+ return { subcalls: taken, totals: { costUsd, tokens, tokensIn, tokensOut } };
142
165
  }
143
166
 
144
167
  // ── Root usage (delegated from RlmEventAggregator) ──
145
168
 
146
169
  /** Accumulate root-level usage into shared totals. Called by aggregator. */
147
- addRootUsage(costUsd: number, tokens: number): void {
170
+ addRootUsage(costUsd: number, tokens: number, tokensIn = 0, tokensOut = 0): void {
148
171
  this.totalCostUsd += costUsd;
149
172
  this.totalTokens += tokens;
173
+ this.totalTokensIn += tokensIn;
174
+ this.totalTokensOut += tokensOut;
150
175
  this.rootCostUsd += costUsd;
151
176
  this.rootTokens += tokens;
177
+ this.rootTokensIn += tokensIn;
178
+ this.rootTokensOut += tokensOut;
152
179
  }
153
180
 
154
181
  /** Root engine's OWN spend (driver-model turns only) — never blends sub-call models. */
155
182
  getRootUsage(): SubcallTotals {
156
- return { costUsd: this.rootCostUsd, tokens: this.rootTokens };
183
+ return { costUsd: this.rootCostUsd, tokens: this.rootTokens, tokensIn: this.rootTokensIn, tokensOut: this.rootTokensOut };
157
184
  }
158
185
  }
@@ -1,9 +1,11 @@
1
1
  /** Config panel TUI — toggle RLM run parameters with descriptions. */
2
2
 
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
4
5
  import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
5
6
  import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
6
7
  import type { RlmConfig } from "../core/types.ts";
8
+ import { THINKING_LEVELS } from "../config/settings.ts";
7
9
 
8
10
  const CHOICES = Object.freeze({
9
11
  maxDepth: Object.freeze(["1", "2", "3", "4"]),
@@ -18,6 +20,10 @@ const CHOICES = Object.freeze({
18
20
  compaction: Object.freeze(["on", "off"]),
19
21
  compactionThresholdPct: Object.freeze(["50", "65", "80", "90"]),
20
22
  rootSamplingMaxTokens: Object.freeze(["4096", "8192", "16384", "32768"]),
23
+ rootSamplingTemperature: Object.freeze(["0", "0.3", "0.7", "1.0", "default"]),
24
+ smartReasoning: Object.freeze(["default", ...Object.keys(THINKING_LEVELS)]),
25
+ subSamplingMaxTokens: Object.freeze(["1024", "2048", "4096", "8192"]),
26
+ subSamplingTemperature: Object.freeze(["0", "0.3", "0.7", "1.0", "default"]),
21
27
  sandboxInitTimeoutMs: Object.freeze(["10000", "30000", "60000", "120000"]),
22
28
  requestTimeoutMs: Object.freeze(["2", "5", "10", "15", "20"]),
23
29
  contextLoader: Object.freeze(["on", "off"]),
@@ -48,6 +54,14 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
48
54
  item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
49
55
  item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "Compact once estimated history tokens reach this share of the root model's context window."),
50
56
  item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
57
+ item("rootSamplingTemperature", "Root sampling temperature", config.rootSampling?.temperature === undefined ? "default" : String(config.rootSampling?.temperature), CHOICES.rootSamplingTemperature,
58
+ "Sampling temperature for RLM root turns, finalize included — 0 = deterministic (the r3 reproducibility setting); 'default' = provider default. Applies to RLM-mode runs, rlm() delegation and child recursion; the native Pi agent loop follows Pi's own session settings."),
59
+ item("smartReasoning", "Root reasoning effort", config.smartReasoning ?? "default", CHOICES.smartReasoning,
60
+ "Thinking effort for the root model ('default' = none). Only models whose registry entry supports reasoning will think; others silently run without it. Reasoning tokens share the output cap — raise the root output cap when thinking is on."),
61
+ item("subSamplingMaxTokens", "Worker output cap (tok)", String(config.subSampling?.maxTokens ?? 8192), CHOICES.subSamplingMaxTokens,
62
+ "Max output tokens per leaf sub-call (llm_query / llm_batch / map_files)."),
63
+ item("subSamplingTemperature", "Worker sampling temperature", config.subSampling?.temperature === undefined ? "default" : String(config.subSampling?.temperature), CHOICES.subSamplingTemperature,
64
+ "Sampling temperature for leaf sub-calls; 'default' = provider default. Deterministic extraction (temp 0) is what made the r3 bench stable."),
51
65
  item("sandboxInitTimeoutMs", "Sandbox init timeout", String(config.sandboxInitTimeoutMs), CHOICES.sandboxInitTimeoutMs, "How long to wait for the Python worker to start."),
52
66
  item("requestTimeoutMs", "Sandbox request timeout (min)", String(Math.round(config.requestTimeoutMs / 60_000)), CHOICES.requestTimeoutMs, "Parent-side watchdog per sandbox request; on breach the Python worker is killed."),
53
67
  item("contextLoader", "Context loader", config.contextLoader ? "on" : "off", CHOICES.contextLoader,
@@ -89,6 +103,13 @@ function optionalNumber(value: string, scale = 1): number | undefined {
89
103
  return value === "none" ? undefined : Number(value) * scale;
90
104
  }
91
105
 
106
+ /** Optional temperature: the literal "default" clears it (provider default); else [0, 2]. */
107
+ function optionalTemperature(value: string): number | undefined {
108
+ if (value === "default") return undefined;
109
+ const n = Number(value);
110
+ return Number.isFinite(n) && n >= 0 && n <= 2 ? n : undefined;
111
+ }
112
+
92
113
  /** Pure: returns a new frozen config with `id` set to `value`; unknown ids pass through. */
93
114
  export function applySetting(config: RlmConfig, id: string, value: string): RlmConfig {
94
115
  switch (id) {
@@ -105,6 +126,24 @@ export function applySetting(config: RlmConfig, id: string, value: string): RlmC
105
126
  case "compactionThresholdPct": return Object.freeze({ ...config, compactionThresholdPct: Number(value) / 100 });
106
127
  case "rootSamplingMaxTokens":
107
128
  return Object.freeze({ ...config, rootSampling: Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }) });
129
+ case "rootSamplingTemperature": {
130
+ const t = optionalTemperature(value);
131
+ // Reject invalid values (NaN / out of range) — keep the current setting.
132
+ if (t === undefined && value !== "default") return config;
133
+ return Object.freeze({ ...config, rootSampling: Object.freeze({ ...config.rootSampling, temperature: t }) });
134
+ }
135
+ case "smartReasoning":
136
+ if (value === "default") return Object.freeze({ ...config, smartReasoning: undefined });
137
+ return Object.hasOwn(THINKING_LEVELS, value)
138
+ ? Object.freeze({ ...config, smartReasoning: value as ThinkingLevel })
139
+ : config;
140
+ case "subSamplingMaxTokens":
141
+ return Object.freeze({ ...config, subSampling: Object.freeze({ ...config.subSampling, maxTokens: Number(value) }) });
142
+ case "subSamplingTemperature": {
143
+ const st = optionalTemperature(value);
144
+ if (st === undefined && value !== "default") return config;
145
+ return Object.freeze({ ...config, subSampling: Object.freeze({ ...config.subSampling, temperature: st }) });
146
+ }
108
147
  case "sandboxInitTimeoutMs": return Object.freeze({ ...config, sandboxInitTimeoutMs: Number(value) });
109
148
  case "requestTimeoutMs": return Object.freeze({ ...config, requestTimeoutMs: Number(value) * 60_000 });
110
149
  case "contextLoader": return Object.freeze({ ...config, contextLoader: value === "on" });
@@ -29,6 +29,8 @@ export interface RunRegistration {
29
29
  readonly rootModel?: () => string | undefined;
30
30
  /** Root's OWN spend (driver-model turns) — never a subtree sum across models. */
31
31
  readonly rootTokens?: () => number;
32
+ readonly rootTokensIn?: () => number;
33
+ readonly rootTokensOut?: () => number;
32
34
  /** Persistent entries (background work) stay hidden until they hold subcalls. */
33
35
  readonly hideWhenEmpty?: boolean;
34
36
  }
@@ -44,6 +46,8 @@ export interface RunEntry {
44
46
  readonly turns: () => { readonly current: number; readonly max: number };
45
47
  readonly rootModel: () => string | undefined;
46
48
  readonly rootTokens: () => number;
49
+ readonly rootTokensIn: () => number;
50
+ readonly rootTokensOut: () => number;
47
51
  readonly hideWhenEmpty: boolean;
48
52
  }
49
53
 
@@ -66,6 +70,8 @@ export class RunRegistry {
66
70
  turns: run.turns ?? (() => DEFAULT_TURNS),
67
71
  rootModel: run.rootModel ?? (() => undefined),
68
72
  rootTokens: run.rootTokens ?? (() => 0),
73
+ rootTokensIn: run.rootTokensIn ?? (() => 0),
74
+ rootTokensOut: run.rootTokensOut ?? (() => 0),
69
75
  hideWhenEmpty: run.hideWhenEmpty ?? false,
70
76
  };
71
77
  this.entries.set(run.runId, entry);
@@ -119,6 +125,8 @@ export class RunRegistry {
119
125
  rootPhase: entry.rootPhase(),
120
126
  rootModel: entry.rootModel(),
121
127
  rootTokens: entry.rootTokens(),
128
+ rootTokensIn: entry.rootTokensIn(),
129
+ rootTokensOut: entry.rootTokensOut(),
122
130
  subcalls: entry.subcalls(),
123
131
  });
124
132
  }
package/src/ui/theme.ts CHANGED
@@ -12,6 +12,12 @@ export function formatTokens(n: number): string {
12
12
  return String(n);
13
13
  }
14
14
 
15
+ /** `190.2k↑ 18.6k↓ tok` — the in/out split. Falls back to plain when out is zero. */
16
+ export function formatTokensSplit(tokensIn: number, tokensOut: number): string {
17
+ if (tokensOut > 0) return `${formatTokens(tokensIn)}↑ ${formatTokens(tokensOut)}↓ tok`;
18
+ return `${formatTokens(tokensIn)} tok`;
19
+ }
20
+
15
21
  export function formatDuration(ms: number): string {
16
22
  const s = ms / 1000;
17
23
  return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`;
@@ -26,6 +26,8 @@ export interface RunSnapshot {
26
26
  readonly rootModel?: string;
27
27
  /** Root's OWN token spend (driver-model turns) — never a subtree sum. */
28
28
  readonly rootTokens: number;
29
+ readonly rootTokensIn: number;
30
+ readonly rootTokensOut: number;
29
31
  readonly subcalls: readonly RlmSubcall[];
30
32
  }
31
33
 
@@ -46,6 +48,8 @@ export interface NodeRow {
46
48
  readonly label: string;
47
49
  /** The row's OWN token spend for its OWN model — never a subtree sum. */
48
50
  readonly tokens: number;
51
+ readonly tokensIn: number;
52
+ readonly tokensOut: number;
49
53
  readonly model?: string;
50
54
  }
51
55
 
@@ -64,6 +68,8 @@ export interface GroupRow {
64
68
  readonly model?: string;
65
69
  /** Sum over members — one model only (the group key pins it), so never a blend. */
66
70
  readonly tokens: number;
71
+ readonly tokensIn: number;
72
+ readonly tokensOut: number;
67
73
  /** SubcallStatus, or "queued" while any member parks on the rate-limit cooldown. */
68
74
  readonly icon: SubcallStatus | "queued";
69
75
  readonly expandable: boolean;
@@ -147,6 +153,8 @@ export function buildRows(
147
153
  phase: sc.phase,
148
154
  label: sc.label,
149
155
  tokens: sc.tokens,
156
+ tokensIn: sc.tokensIn,
157
+ tokensOut: sc.tokensOut,
150
158
  model: sc.model,
151
159
  });
152
160
  if (!expanded || children.length === 0) return;
@@ -181,7 +189,13 @@ export function buildRows(
181
189
  const id = `grp:${run.runId}:${entry.key}:${first.id}`;
182
190
  const expanded = expandedGroups.has(id);
183
191
  let tokens = 0;
184
- for (const m of entry.members) tokens += m.tokens;
192
+ let tokensIn = 0;
193
+ let tokensOut = 0;
194
+ for (const m of entry.members) {
195
+ tokens += m.tokens;
196
+ tokensIn += m.tokensIn;
197
+ tokensOut += m.tokensOut;
198
+ }
185
199
  rows.push({
186
200
  type: "group",
187
201
  id,
@@ -192,6 +206,8 @@ export function buildRows(
192
206
  label: entry.label,
193
207
  model: entry.model,
194
208
  tokens,
209
+ tokensIn,
210
+ tokensOut,
195
211
  icon: iconOf(entry.status, entry.members.some((m) => m.phase === "queued") ? "queued" : undefined),
196
212
  expandable: true,
197
213
  expanded,
@@ -218,6 +234,8 @@ export function buildRows(
218
234
  phase: run.rootPhase,
219
235
  label: run.rootLabel,
220
236
  tokens: run.rootTokens,
237
+ tokensIn: run.rootTokensIn,
238
+ tokensOut: run.rootTokensOut,
221
239
  model: run.rootModel,
222
240
  });
223
241
  if (!collapsed.has(run.runId)) visitChildren(roots, 0, "");
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
10
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
- import { formatTokens, spinnerFrame } from "../theme.ts";
11
+ import { formatTokens, formatTokensSplit, spinnerFrame } from "../theme.ts";
12
12
  import type { GroupRow, NodeRow, TreeRow } from "./tree-model.ts";
13
13
 
14
14
  const GLYPHS = Object.freeze({ done: "✓", error: "✗", queued: "◷", expanded: "▾", collapsed: "▸", leaf: " " } as const);
@@ -29,16 +29,16 @@ function iconGlyph(row: NodeRow, theme: Theme): string {
29
29
  }
30
30
  }
31
31
 
32
- /** Right-hand stats: "4.3k tok · gpt-5-mini" (model only when present). */
33
- function statsText(tokens: number, model: string | undefined): string {
34
- const parts = [`${formatTokens(tokens)} tok`];
32
+ /** Right-hand stats: "190.2k↑ 18.6k↓ tok · gpt-5-mini" (split + model only when present). */
33
+ function statsText(tokens: number, tokensIn: number, tokensOut: number, model: string | undefined): string {
34
+ const parts = [tokensOut > 0 ? formatTokensSplit(tokensIn, tokensOut) : `${formatTokens(tokens)} tok`];
35
35
  if (model !== undefined) parts.push(modelShort(model));
36
36
  return parts.join(" · ");
37
37
  }
38
38
 
39
39
  /** Left/right assembly shared by node and group rows — one padding rule, no fork. */
40
- function assembleLine(left: string, tokens: number, model: string | undefined, selected: boolean, width: number, theme: Theme): string {
41
- const right = theme.fg("dim", statsText(tokens, model));
40
+ function assembleLine(left: string, tokens: number, tokensIn: number, tokensOut: number, model: string | undefined, selected: boolean, width: number, theme: Theme): string {
41
+ const right = theme.fg("dim", statsText(tokens, tokensIn, tokensOut, model));
42
42
  const gap = width - visibleWidth(left) - visibleWidth(right) - 1;
43
43
  const line = gap > 0 ? `${left}${" ".repeat(gap)}${right}` : `${truncateToWidth(left, width - 1)} `;
44
44
  return selected ? theme.fg("accent", line) : line;
@@ -48,7 +48,7 @@ function formatNode(row: NodeRow, selected: boolean, width: number, theme: Theme
48
48
  const chevron = row.expandable ? (row.expanded ? GLYPHS.expanded : GLYPHS.collapsed) : GLYPHS.leaf;
49
49
  const cursor = selected ? theme.fg("accent", "❯") : " ";
50
50
  const left = `${cursor} ${row.prefix}${chevron} ${iconGlyph(row, theme)} ${row.label}`;
51
- return assembleLine(left, row.tokens, row.model, selected, width, theme);
51
+ return assembleLine(left, row.tokens, row.tokensIn, row.tokensOut, row.model, selected, width, theme);
52
52
  }
53
53
 
54
54
  function formatGroup(row: GroupRow, selected: boolean, width: number, theme: Theme): string {
@@ -56,7 +56,7 @@ function formatGroup(row: GroupRow, selected: boolean, width: number, theme: The
56
56
  const cursor = selected ? theme.fg("accent", "❯") : " ";
57
57
  const icon = row.icon === "done" ? theme.fg("success", GLYPHS.done) : row.icon === "error" ? theme.fg("error", GLYPHS.error) : theme.fg("warning", spinnerFrame());
58
58
  const left = `${cursor} ${row.prefix}${chevron} ${icon} ${row.label} ×${row.count}`;
59
- return assembleLine(left, row.tokens, row.model, selected, width, theme);
59
+ return assembleLine(left, row.tokens, row.tokensIn, row.tokensOut, row.model, selected, width, theme);
60
60
  }
61
61
 
62
62
  export function formatRow(row: TreeRow, selected: boolean, width: number, theme: Theme): string {
package/src/util/retry.ts CHANGED
@@ -17,10 +17,13 @@ import { ProviderCooldown, sleepMs, sharedCooldown } from "./throttle.ts";
17
17
 
18
18
  // Auth/quota failures must not be retried — they burn attempts and never recover.
19
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.
20
+ /api[ -]?key|unauthorized|forbidden|permission denied|billing|insufficient|balance|quota exceeded|not.?found|context length|too large|invalid request|malformed|content.?filter/i;
21
+ // Transport/server transients — worth another attempt. "Provider finish_reason: error" is the
22
+ // generic shape OpenRouter relays when the UPSTREAM kills a generation mid-stream (observed
23
+ // from Cohere's free pool: native_finish_reason "error", no message, no code, partial usage) —
24
+ // HTTP 200, so it is inherently transient-by-nature and must be retried.
22
25
  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;
26
+ /\b429\b|rate.?limit|overloaded|service.?unavailable|upstream|timeout|timed.?out|temporarily|try.?again|econnreset|econnrefused|etimedout|socket hang up|network|finish.?reason: ?(error|network_error)|1302|速率|频率/i;
24
27
  const RATE_LIMIT_TEXT = /\b429\b|rate.?limit|1302|速率|频率/i;
25
28
 
26
29
  const NON_RETRYABLE_STATUS = new Set([400, 401, 402, 403, 404, 413, 422]);