@hicaru/pi-rlm 0.1.9 → 0.2.1

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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/bridge/fallback-todo.ts +12 -1
  3. package/src/bridge/subcall-handlers.ts +336 -0
  4. package/src/commands/rlm-config.ts +8 -8
  5. package/src/commands/rlm.ts +48 -12
  6. package/src/config/defaults.ts +4 -1
  7. package/src/config/settings.ts +33 -3
  8. package/src/context/repomix-context.ts +5 -10
  9. package/src/core/answer.ts +4 -3
  10. package/src/core/artifacts.ts +4 -3
  11. package/src/core/engine.ts +101 -267
  12. package/src/core/gates.ts +3 -3
  13. package/src/core/limits.ts +19 -1
  14. package/src/core/pipeline-handlers.ts +319 -0
  15. package/src/core/pipeline.ts +2 -2
  16. package/src/core/types.ts +25 -27
  17. package/src/index.ts +63 -17
  18. package/src/mode/rlm-mode.ts +8 -11
  19. package/src/prompts/system.ts +164 -52
  20. package/src/prompts/user.ts +1 -5
  21. package/src/sandbox/protocol.ts +6 -7
  22. package/src/sandbox/sandbox-manager.ts +25 -11
  23. package/src/sandbox/sandbox.ts +93 -22
  24. package/src/sandbox/worker.py +798 -66
  25. package/src/state/paths.ts +1 -1
  26. package/src/state/reads.ts +12 -4
  27. package/src/state/resume.ts +5 -11
  28. package/src/text/parsing.ts +0 -6
  29. package/src/tool/background-tasks.ts +95 -0
  30. package/src/tool/repl-details.ts +2 -0
  31. package/src/tool/repl-tool.ts +223 -318
  32. package/src/tool/rlm-details.ts +0 -10
  33. package/src/tool/rlm-events.ts +10 -2
  34. package/src/tool/rlm-tool.ts +18 -31
  35. package/src/tool/subcall-render.ts +75 -11
  36. package/src/tool/subcall-store.ts +57 -1
  37. package/src/ui/config-panel.ts +41 -21
  38. package/src/ui/intro.ts +2 -1
  39. package/src/ui/status.ts +8 -5
  40. package/src/ui/theme-adapter.ts +36 -0
  41. package/src/ui/theme.ts +0 -25
  42. package/src/util/concurrency.ts +87 -13
  43. package/src/util/trace.ts +42 -0
  44. package/src/bridge/llm-query.ts +0 -133
  45. package/src/bridge/rlm-query.ts +0 -122
  46. package/src/mode/input-router.ts +0 -23
@@ -44,13 +44,3 @@ export interface RlmDetails {
44
44
  readonly warnings?: readonly string[];
45
45
  }
46
46
 
47
- export interface SubcallInit {
48
- readonly parentId?: string;
49
- readonly kind: SubcallKind;
50
- readonly label: string;
51
- readonly model?: string;
52
- readonly detail?: string;
53
- readonly args?: string;
54
- /** Recursion depth. Required — all call sites pass this. */
55
- readonly depth: number;
56
- }
@@ -76,18 +76,26 @@ export interface RootPromptEvent {
76
76
  /**
77
77
  * Typed wrapper around a Node.js EventEmitter for RLM lifecycle events.
78
78
  *
79
- * Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()`.
79
+ * Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()`, with a
80
+ * configurable prefix so a second emitter's IDs cannot collide with the default ones.
80
81
  * Provides typed `on*` methods that return unsubscribe functions.
81
82
  */
82
83
  export class RlmEmitter {
83
84
  private readonly ee = new EventEmitter();
84
85
  private seq = 0;
85
86
 
87
+ /**
88
+ * `idPrefix` namespaces generated IDs. The counter is per-instance, so two emitters
89
+ * would both start at `s1`; a distinct prefix is what lets one emitter's subcalls be
90
+ * merged into another's tree without colliding IDs or corrupting parentId links.
91
+ */
92
+ constructor(private readonly idPrefix = "s") {}
93
+
86
94
  // ── Emit ──
87
95
 
88
96
  /** Create a new sub-call entry. Returns the auto-generated ID. */
89
97
  emitSubcallCreated(init: Omit<SubcallCreatedEvent, "id">): string {
90
- const id = `s${++this.seq}`;
98
+ const id = `${this.idPrefix}${++this.seq}`;
91
99
  const event: SubcallCreatedEvent = { id, ...init };
92
100
  this.ee.emit("subcall:created", event);
93
101
  return id;
@@ -5,23 +5,29 @@
5
5
  * onUpdate(partialResult) for progressive TUI re-rendering.
6
6
  */
7
7
 
8
- import { getMarkdownTheme, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
8
+ import { type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
9
9
  import { Container, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
10
10
  import { Type } from "typebox";
11
11
  import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
12
12
  import type { RlmController, StartInput } from "../mode/rlm-mode.ts";
13
- import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
13
+ import { spinnerFrame } from "../ui/theme.ts";
14
+ import { markdownTheme } from "../ui/theme-adapter.ts";
15
+ import { previewText } from "../text/preview.ts";
14
16
  import { errorMessage } from "../util/errors.ts";
15
17
  import { type RlmDetails } from "./rlm-details.ts";
16
18
  import { RlmEmitter } from "./rlm-events.ts";
17
19
  import { RlmEventAggregator } from "./rlm-aggregator.ts";
18
20
  import {
19
- headlineStatusGlyph,
20
- renderCollapsedSubcallTree,
21
+ cardHeader,
22
+ cardStatsLine,
23
+ renderCollapsedCard,
21
24
  renderExpandedSubcallTree,
22
25
  } from "./subcall-render.ts";
23
26
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
24
27
 
28
+ /** Chars of the prompt shown on the tool call line. */
29
+ const CALL_PREVIEW_CHARS = 80;
30
+
25
31
  // ── Parameter schema ──
26
32
 
27
33
  export const RlmToolParams = Object.freeze(Type.Object({
@@ -32,11 +38,8 @@ export const RlmToolParams = Object.freeze(Type.Object({
32
38
  // ── Rendering helpers ──
33
39
 
34
40
  function rootStats(details: RlmDetails, theme: Theme): string {
35
- const parts: string[] = [];
36
- parts.push(formatCost(details.totals.costUsd));
37
- parts.push(`${formatTokens(details.totals.tokens)} tok`);
38
- if (details.turns.current > 0) parts.push(`${details.turns.current} turn${details.turns.current > 1 ? "s" : ""}`);
39
- return theme.fg("dim", parts.join(" · "));
41
+ const turns = details.turns.current;
42
+ return cardStatsLine(details.totals, theme, turns > 0 ? `${turns} turn${turns > 1 ? "s" : ""}` : undefined);
40
43
  }
41
44
 
42
45
  // ── Tool definition ──
@@ -110,18 +113,14 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
110
113
  }
111
114
  },
112
115
 
113
- renderCall(args, theme, _context) {
114
- const preview = args.prompt.length > 80
115
- ? `${args.prompt.slice(0, 80)}...`
116
- : args.prompt;
116
+ renderCall(args, theme) {
117
117
  return new Text(
118
- theme.fg("toolTitle", theme.bold("rlm ")) +
119
- theme.fg("dim", preview.replace(/\n/g, " ")),
118
+ theme.fg("toolTitle", theme.bold("rlm ")) + theme.fg("dim", previewText(args.prompt, CALL_PREVIEW_CHARS)),
120
119
  0, 0,
121
120
  );
122
121
  },
123
122
 
124
- renderResult(result, { expanded, isPartial: _isPartial }, theme, _context) {
123
+ renderResult(result, { expanded }, theme) {
125
124
  const details = result.details as RlmDetails | undefined;
126
125
  if (!details) {
127
126
  const text = result.content[0];
@@ -139,10 +138,7 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
139
138
 
140
139
  function renderExpanded(details: RlmDetails, theme: Theme): Component {
141
140
  const container = new Container();
142
-
143
- const glyph = headlineStatusGlyph(details.status, theme);
144
- const header = `${glyph} ${theme.fg("toolTitle", theme.bold("RLM"))} · ${rootStats(details, theme)}`;
145
- container.addChild(new Text(header, 0, 0));
141
+ container.addChild(new Text(cardHeader("RLM", details.status, rootStats(details, theme), theme), 0, 0));
146
142
 
147
143
  if (details.subcalls.length > 0) {
148
144
  container.addChild(new Spacer(1));
@@ -153,7 +149,7 @@ function renderExpanded(details: RlmDetails, theme: Theme): Component {
153
149
  if (details.answer) {
154
150
  container.addChild(new Spacer(1));
155
151
  container.addChild(new Text(theme.fg("muted", "─── Answer ───"), 0, 0));
156
- container.addChild(new Markdown(details.answer, 0, 0, getMarkdownTheme()));
152
+ container.addChild(new Markdown(details.answer, 0, 0, markdownTheme(theme)));
157
153
  }
158
154
 
159
155
  if (details.warnings && details.warnings.length > 0) {
@@ -167,14 +163,5 @@ function renderExpanded(details: RlmDetails, theme: Theme): Component {
167
163
  // ── Collapsed view ──
168
164
 
169
165
  function renderCollapsed(details: RlmDetails, theme: Theme): Text {
170
- const glyph = headlineStatusGlyph(details.status, theme);
171
- const header = `${glyph} ${theme.fg("toolTitle", theme.bold("RLM"))} · ${rootStats(details, theme)}`;
172
-
173
- let body = "";
174
- if (details.subcalls.length > 0) {
175
- body = `\n${renderCollapsedSubcallTree(details.subcalls, theme)}`;
176
- }
177
-
178
- const expandHint = details.status === "running" ? "" : `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
179
- return new Text(`${header}${body}${expandHint}`, 0, 0);
166
+ return renderCollapsedCard("RLM", details.status, rootStats(details, theme), details.subcalls, theme);
180
167
  }
@@ -7,15 +7,17 @@
7
7
  */
8
8
 
9
9
  import { Container, Text, type Component } from "@earendil-works/pi-tui";
10
+ import { keyText } from "@earendil-works/pi-coding-agent";
10
11
  import type { RlmSubcall, SubcallStatus } from "./rlm-details.ts";
11
12
  import { formatCost, formatDuration, formatTokens, spinnerFrame } from "../ui/theme.ts";
13
+ import { previewText } from "../text/preview.ts";
12
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
15
 
14
- // ── Glyphs ──
16
+ /** Preview budgets for the expanded tree (args are terse, results get more room). */
17
+ const ARGS_PREVIEW_CHARS = 80;
18
+ const RESULT_PREVIEW_CHARS = 120;
15
19
 
16
- export function subcallRunningGlyph(theme: Theme): string {
17
- return theme.fg("warning", spinnerFrame());
18
- }
20
+ // ── Glyphs ──
19
21
 
20
22
  export function subcallStatusGlyph(sc: Pick<RlmSubcall, "status">, theme: Theme): string {
21
23
  if (sc.status === "running") return theme.fg("warning", "⏳");
@@ -38,10 +40,72 @@ export function subcallStatsLine(sc: Pick<RlmSubcall, "costUsd" | "tokens" | "en
38
40
  const parts: string[] = [];
39
41
  if (sc.costUsd > 0) parts.push(formatCost(sc.costUsd));
40
42
  if (sc.tokens > 0) parts.push(`${formatTokens(sc.tokens)} tok`);
41
- if (sc.endedAt && sc.startedAt) parts.push(formatDuration(sc.endedAt - sc.startedAt));
43
+ // Explicit undefined checks: a 0 timestamp is falsy but legitimate (fixtures, epoch clocks).
44
+ if (sc.endedAt !== undefined && sc.startedAt !== undefined) parts.push(formatDuration(sc.endedAt - sc.startedAt));
42
45
  return parts.join(" · ");
43
46
  }
44
47
 
48
+ // ── Shared card scaffolding (rlm + repl render the same shape) ──
49
+
50
+ /** The `$0.0123 · 4.2k tok · 812ms` run of a card header. Omits any zero component. */
51
+ export function cardStatsLine(
52
+ totals: { readonly costUsd: number; readonly tokens: number },
53
+ theme: Theme,
54
+ extra?: string,
55
+ backgroundPending?: number,
56
+ ): string {
57
+ const parts: string[] = [formatCost(totals.costUsd)];
58
+ if (totals.tokens > 0) parts.push(`${formatTokens(totals.tokens)} tok`);
59
+ if (extra) parts.push(extra);
60
+ const line = theme.fg("dim", parts.join(" · "));
61
+ // The one thing no tree can show: spawned work that may outlive this block.
62
+ return backgroundPending !== undefined && backgroundPending > 0
63
+ ? `${line} ${theme.fg("warning", `↯${backgroundPending} bg`)}`
64
+ : line;
65
+ }
66
+
67
+ /** Detached nodes carry BackgroundTasks' "bg" id prefix (RlmEmitter("bg")). */
68
+ function backgroundTag(sc: RlmSubcall, theme: Theme): string {
69
+ return sc.id.startsWith("bg") ? ` ${theme.fg("warning", "↯bg")}` : "";
70
+ }
71
+
72
+ /** `<glyph> <TITLE> <stats>` — the first line of both tools' collapsed and expanded views. */
73
+ export function cardHeader(
74
+ title: string,
75
+ status: SubcallStatus | "aborted" | "done",
76
+ stats: string,
77
+ theme: Theme,
78
+ ): string {
79
+ return `${headlineStatusGlyph(status, theme)} ${theme.fg("toolTitle", theme.bold(title))} ${stats}`;
80
+ }
81
+
82
+ /**
83
+ * The expand hint, using the user's actual binding rather than a hardcoded "Ctrl+O".
84
+ *
85
+ * Deliberately `keyText` + the injected theme rather than pi's `keyHint`: `keyHint` colours via
86
+ * pi's module-global theme, which throws when that global is uninitialized — the same jiti
87
+ * hazard `ui/theme-adapter.ts` exists to avoid. `keyText` only reads the keybinding registry.
88
+ */
89
+ function expandHint(theme: Theme): string {
90
+ // Empty outside a live pi session (the app installs the real binding registry at startup) —
91
+ // the phrase stays the same, only the key prefix drops out.
92
+ const key = keyText("app.tools.expand");
93
+ return theme.fg("muted", key ? `${key} to expand` : "to expand");
94
+ }
95
+
96
+ /** The collapsed card: header, the sub-call tree, and the expand hint. */
97
+ export function renderCollapsedCard(
98
+ title: string,
99
+ status: SubcallStatus | "aborted" | "done",
100
+ stats: string,
101
+ subcalls: readonly RlmSubcall[],
102
+ theme: Theme,
103
+ ): Text {
104
+ const body = subcalls.length > 0 ? `\n${renderCollapsedSubcallTree(subcalls, theme)}` : "";
105
+ const hint = status === "running" ? "" : `\n${expandHint(theme)}`;
106
+ return new Text(`${cardHeader(title, status, stats, theme)}${body}${hint}`, 0, 0);
107
+ }
108
+
45
109
  // ── Tree building ──
46
110
 
47
111
  function buildParentMap(subcalls: readonly RlmSubcall[]): Map<string | undefined, RlmSubcall[]> {
@@ -74,7 +138,8 @@ export function renderCollapsedSubcallTree(
74
138
  const branch = isLast ? "└─" : "├─";
75
139
  const gGlyph = subcallStatusGlyph(sc, theme);
76
140
  const gStats = subcallStatsLine(sc);
77
- lines.push(`${prefix}${branch} ${sc.label} ${gGlyph} ${gStats}`);
141
+ const gBg = backgroundTag(sc, theme);
142
+ lines.push(`${prefix}${branch} ${sc.label} ${gGlyph} ${gStats}${gBg}`);
78
143
  const childPrefix = prefix + (isLast ? " " : "│ ");
79
144
  lines.push(...walk(sc.id, childPrefix));
80
145
  }
@@ -101,17 +166,16 @@ export function renderExpandedSubcallTree(
101
166
  const sKind = theme.fg("muted", sc.label);
102
167
  const sModel = sc.model ? theme.fg("dim", ` ${sc.model}`) : "";
103
168
  const sStats = sc.endedAt ? ` ${theme.fg("dim", subcallStatsLine(sc))}` : "";
104
- let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}`;
169
+ const sBg = backgroundTag(sc, theme);
170
+ let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}${sBg}`;
105
171
 
106
172
  if (sc.args) {
107
- const ap = sc.args.length > 80 ? `${sc.args.slice(0, 80)}...` : sc.args;
108
- line += `\n${pad} ${theme.fg("dim", ap)}`;
173
+ line += `\n${pad} ${theme.fg("dim", previewText(sc.args, ARGS_PREVIEW_CHARS))}`;
109
174
  }
110
175
  if (sc.status === "error" && sc.detail) {
111
176
  line += `\n${pad} ${theme.fg("error", `✗ ${sc.detail}`)}`;
112
177
  } else if (sc.resultPreview) {
113
- const rp = sc.resultPreview.length > 120 ? `${sc.resultPreview.slice(0, 120)}...` : sc.resultPreview;
114
- line += `\n${pad} ${theme.fg("toolOutput", rp)}`;
178
+ line += `\n${pad} ${theme.fg("toolOutput", previewText(sc.resultPreview, RESULT_PREVIEW_CHARS))}`;
115
179
  }
116
180
 
117
181
  container.addChild(new Text(line, 0, 0));
@@ -14,6 +14,12 @@ type MutableSubcall = {
14
14
  -readonly [Key in keyof RlmSubcall]: RlmSubcall[Key];
15
15
  };
16
16
 
17
+ /** Accumulated cost/tokens, shared by getTotals() and takeSettledSubtrees(). */
18
+ export interface SubcallTotals {
19
+ readonly costUsd: number;
20
+ readonly tokens: number;
21
+ }
22
+
17
23
  export class SubcallStore extends EmitterListener {
18
24
  private readonly subcalls = new Map<string, MutableSubcall>();
19
25
 
@@ -74,7 +80,7 @@ export class SubcallStore extends EmitterListener {
74
80
 
75
81
  /** Snapshot subcall array. Allocates a new array from Map values. */
76
82
  getSubcalls(): RlmSubcall[] {
77
- return Array.from(this.subcalls.values(), (subcall) => Object.freeze({ ...subcall, status: subcall.status as SubcallStatus }));
83
+ return Array.from(this.subcalls.values(), (subcall) => Object.freeze({ ...subcall }));
78
84
  }
79
85
 
80
86
  /** Snapshot running totals. O(1). */
@@ -82,6 +88,56 @@ export class SubcallStore extends EmitterListener {
82
88
  return { costUsd: this.totalCostUsd, tokens: this.totalTokens };
83
89
  }
84
90
 
91
+ /**
92
+ * Remove and return every fully-settled root subtree, with its cost/tokens subtracted
93
+ * from the running totals so the caller can add them without double-counting.
94
+ *
95
+ * A root whose subtree still has a running node stays put. That matters because
96
+ * `renderCollapsedSubcallTree` walks down from `parentId === undefined`: a subcall handed
97
+ * over without its parent has no path from a root and is silently dropped from the tree.
98
+ * Handing over whole subtrees is what keeps adopted nodes renderable.
99
+ */
100
+ takeSettledSubtrees(): { readonly subcalls: readonly RlmSubcall[]; readonly totals: SubcallTotals } {
101
+ const children = new Map<string | undefined, MutableSubcall[]>();
102
+ for (const sc of this.subcalls.values()) {
103
+ const siblings = children.get(sc.parentId);
104
+ if (siblings === undefined) children.set(sc.parentId, [sc]);
105
+ else siblings.push(sc);
106
+ }
107
+
108
+ // Collect a root's subtree, or undefined when any node in it is still running.
109
+ const settledSubtree = (root: MutableSubcall): MutableSubcall[] | undefined => {
110
+ const collected: MutableSubcall[] = [];
111
+ const stack: MutableSubcall[] = [root];
112
+ while (stack.length > 0) {
113
+ const node = stack.pop();
114
+ if (node === undefined) continue;
115
+ if (node.status === "running") return undefined;
116
+ collected.push(node);
117
+ const kids = children.get(node.id);
118
+ if (kids !== undefined) stack.push(...kids);
119
+ }
120
+ return collected;
121
+ };
122
+
123
+ const taken: RlmSubcall[] = [];
124
+ let costUsd = 0;
125
+ let tokens = 0;
126
+ for (const root of children.get(undefined) ?? []) {
127
+ const subtree = settledSubtree(root);
128
+ if (subtree === undefined) continue;
129
+ for (const node of subtree) {
130
+ costUsd += node.costUsd;
131
+ tokens += node.tokens;
132
+ taken.push(Object.freeze({ ...node, status: node.status as SubcallStatus }));
133
+ this.subcalls.delete(node.id);
134
+ }
135
+ }
136
+ this.totalCostUsd -= costUsd;
137
+ this.totalTokens -= tokens;
138
+ return { subcalls: taken, totals: { costUsd, tokens } };
139
+ }
140
+
85
141
  // ── Root usage (delegated from RlmEventAggregator) ──
86
142
 
87
143
  /** Accumulate root-level usage into shared totals. Called by aggregator. */
@@ -18,8 +18,10 @@ const CHOICES = Object.freeze({
18
18
  pipeline: Object.freeze(["on", "off"]),
19
19
  maxBackwardJumps: Object.freeze(["0", "1", "2", "3"]),
20
20
  compaction: Object.freeze(["on", "off"]),
21
+ compactionThresholdPct: Object.freeze(["50", "65", "80", "90"]),
21
22
  rootSamplingMaxTokens: Object.freeze(["4096", "8192", "16384", "32768"]),
22
23
  sandboxInitTimeoutMs: Object.freeze(["10000", "30000", "60000", "120000"]),
24
+ requestTimeoutMs: Object.freeze(["2", "5", "10", "20"]),
23
25
  askUserQuestion: Object.freeze(["on", "off"]),
24
26
  todo: Object.freeze(["on", "off"]),
25
27
  libraryLoader: Object.freeze(["on", "off"]),
@@ -29,8 +31,13 @@ function item(id: string, label: string, currentValue: string, values: readonly
29
31
  return { id, label, currentValue, values: [...values], description };
30
32
  }
31
33
 
32
- export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig): Promise<void> {
33
- if (ctx.mode !== "tui") return;
34
+ /**
35
+ * Show the settings panel and resolve with the edited config.
36
+ * `config` is never mutated — each change produces a new frozen object.
37
+ */
38
+ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig): Promise<RlmConfig> {
39
+ if (ctx.mode !== "tui") return config;
40
+ let edited = config;
34
41
  const items: SettingItem[] = [
35
42
  item("maxDepth", "Max recursion depth", String(config.maxDepth), CHOICES.maxDepth, "rlm_query past this depth degrades to plain llm_query (1 = no recursion)."),
36
43
  item("maxIterations", "Max iterations", String(config.maxIterations), CHOICES.maxIterations, "Maximum root REPL turns before RLM asks the model for a final answer."),
@@ -44,8 +51,10 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
44
51
  item("pipeline", "Phase pipeline", config.pipeline ? "on" : "off", CHOICES.pipeline, "Enable artifact-gated phases: clarify→research→blueprint→validate (read-only plan pipeline; clarify needs Ask user on)."),
45
52
  item("maxBackwardJumps", "Max validate→blueprint loops", String(config.maxBackwardJumps), CHOICES.maxBackwardJumps, "Bounded corrective re-entries when validation reports blockers_count > 0."),
46
53
  item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
54
+ 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."),
47
55
  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."),
48
56
  item("sandboxInitTimeoutMs", "Sandbox init timeout", String(config.sandboxInitTimeoutMs), CHOICES.sandboxInitTimeoutMs, "How long to wait for the Python worker to start."),
57
+ 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."),
49
58
  item("askUserQuestion", "[Interactive] Ask user", config.askUserQuestion ? "on" : "off", CHOICES.askUserQuestion, "Allow root REPL code to present structured ask_user_question dialogs."),
50
59
  item("todo", "[Interactive] Todo", config.todo ? "on" : "off", CHOICES.todo, "Allow REPL code to manage a visible todo task list."),
51
60
  item("libraryLoader", "Library loader", config.libraryLoader ? "on" : "off", CHOICES.libraryLoader,
@@ -65,7 +74,7 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
65
74
  done();
66
75
  return;
67
76
  }
68
- applySetting(config, id, value);
77
+ edited = applySetting(edited, id, value);
69
78
  },
70
79
  () => done(),
71
80
  );
@@ -77,26 +86,37 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
77
86
  handleInput: (data) => list.handleInput?.(data),
78
87
  };
79
88
  });
89
+ return edited;
80
90
  }
81
91
 
82
- function applySetting(config: RlmConfig, id: string, value: string): void {
92
+ /** Optional numeric field: the literal "none" clears it. */
93
+ function optionalNumber(value: string, scale = 1): number | undefined {
94
+ return value === "none" ? undefined : Number(value) * scale;
95
+ }
96
+
97
+ /** Pure: returns a new frozen config with `id` set to `value`; unknown ids pass through. */
98
+ export function applySetting(config: RlmConfig, id: string, value: string): RlmConfig {
83
99
  switch (id) {
84
- case "maxDepth": config.maxDepth = Number(value); break;
85
- case "maxIterations": config.maxIterations = Number(value); break;
86
- case "execTimeoutS": config.execTimeoutS = Number(value); break;
87
- case "maxConcurrentSubcalls": config.maxConcurrentSubcalls = Number(value); break;
88
- case "maxBudgetUsd": config.maxBudgetUsd = value === "none" ? undefined : Number(value); break;
89
- case "maxTimeoutMs": config.maxTimeoutMs = value === "none" ? undefined : Number(value) * 60_000; break;
90
- case "maxTokens": config.maxTokens = value === "none" ? undefined : Number(value); break;
91
- case "maxErrors": config.maxErrors = value === "none" ? undefined : Number(value); break;
92
- case "orchestrator": config.orchestrator = value === "on"; break;
93
- case "pipeline": config.pipeline = value === "on"; break;
94
- case "maxBackwardJumps": config.maxBackwardJumps = Number(value); break;
95
- case "compaction": config.compaction = value === "on"; break;
96
- case "rootSamplingMaxTokens": config.rootSampling = Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }); break;
97
- case "sandboxInitTimeoutMs": config.sandboxInitTimeoutMs = Number(value); break;
98
- case "askUserQuestion": config.askUserQuestion = value === "on"; break;
99
- case "todo": config.todo = value === "on"; break;
100
- case "libraryLoader": config.libraryLoader = value === "on"; break;
100
+ case "maxDepth": return Object.freeze({ ...config, maxDepth: Number(value) });
101
+ case "maxIterations": return Object.freeze({ ...config, maxIterations: Number(value) });
102
+ case "execTimeoutS": return Object.freeze({ ...config, execTimeoutS: Number(value) });
103
+ case "maxConcurrentSubcalls": return Object.freeze({ ...config, maxConcurrentSubcalls: Number(value) });
104
+ case "maxBudgetUsd": return Object.freeze({ ...config, maxBudgetUsd: optionalNumber(value) });
105
+ case "maxTimeoutMs": return Object.freeze({ ...config, maxTimeoutMs: optionalNumber(value, 60_000) });
106
+ case "maxTokens": return Object.freeze({ ...config, maxTokens: optionalNumber(value) });
107
+ case "maxErrors": return Object.freeze({ ...config, maxErrors: optionalNumber(value) });
108
+ case "orchestrator": return Object.freeze({ ...config, orchestrator: value === "on" });
109
+ case "pipeline": return Object.freeze({ ...config, pipeline: value === "on" });
110
+ case "maxBackwardJumps": return Object.freeze({ ...config, maxBackwardJumps: Number(value) });
111
+ case "compaction": return Object.freeze({ ...config, compaction: value === "on" });
112
+ case "compactionThresholdPct": return Object.freeze({ ...config, compactionThresholdPct: Number(value) / 100 });
113
+ case "rootSamplingMaxTokens":
114
+ return Object.freeze({ ...config, rootSampling: Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }) });
115
+ case "sandboxInitTimeoutMs": return Object.freeze({ ...config, sandboxInitTimeoutMs: Number(value) });
116
+ case "requestTimeoutMs": return Object.freeze({ ...config, requestTimeoutMs: Number(value) * 60_000 });
117
+ case "askUserQuestion": return Object.freeze({ ...config, askUserQuestion: value === "on" });
118
+ case "todo": return Object.freeze({ ...config, todo: value === "on" });
119
+ case "libraryLoader": return Object.freeze({ ...config, libraryLoader: value === "on" });
120
+ default: return config;
101
121
  }
102
122
  }
package/src/ui/intro.ts CHANGED
@@ -15,7 +15,8 @@ export const RLM_GUIDE = `# RLM mode
15
15
  - \`/rlm-stop\` — cancel the current run but stay in RLM mode (use /rlm or Ctrl+Shift+R to leave)
16
16
  - \`/rlm-help\` — show this guide again
17
17
 
18
- When RLM mode is ON, plain messages route to RLM. The footer/status line shows the current state.`;
18
+ When RLM mode is ON, \`read\`/\`grep\` are disabled and the agent reads the repository through the
19
+ \`repl\` tool, delegating bulk analysis to sub-LLMs. The footer/status line shows the current state.`;
19
20
 
20
21
  export function postRlmGuide(pi: ExtensionAPI, controller: RlmController): void {
21
22
  const content = RLM_GUIDE.replace("{state}", formatRlmStateLine(controller));
package/src/ui/status.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Footer status line for RLM mode and active runs. */
2
2
 
3
- import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
3
+ import type { ContextUsage, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { Api, Model } from "@earendil-works/pi-ai";
5
5
  import type { RlmController } from "../mode/rlm-mode.ts";
6
6
 
@@ -10,15 +10,18 @@ export function modelLabel(model: Model<Api> | undefined, fallback: string): str
10
10
  return model ? `${model.provider}/${model.id}` : fallback;
11
11
  }
12
12
 
13
- export function formatRlmStateLine(controller: RlmController): string {
13
+ export function formatRlmStateLine(controller: RlmController, contextUsage?: ContextUsage): string {
14
14
  if (!controller.enabled) return "○ RLM OFF";
15
15
  const worker = modelLabel(controller.workerModel, controller.savedWorkerRef ?? "cheapest");
16
16
  const workerSuffix = controller.config.subSampling.reasoning ? `:${controller.config.subSampling.reasoning}` : "";
17
- return `● RLM ON · worker=${worker}${workerSuffix}`;
17
+ // `percent` is null right after a compaction, before the next assistant response reports usage.
18
+ const percent = contextUsage?.percent;
19
+ const ctxSuffix = percent === null || percent === undefined ? "" : ` · ctx ${Math.round(percent)}%`;
20
+ return `● RLM ON · worker=${worker}${workerSuffix}${ctxSuffix}`;
18
21
  }
19
22
 
20
- export function setRlmModeStatus(ui: ExtensionUIContext, controller: RlmController): void {
21
- ui.setStatus(KEY, formatRlmStateLine(controller));
23
+ export function setRlmModeStatus(ui: ExtensionUIContext, controller: RlmController, contextUsage?: ContextUsage): void {
24
+ ui.setStatus(KEY, formatRlmStateLine(controller, contextUsage));
22
25
  }
23
26
 
24
27
  export function clearRlmStatus(ui: ExtensionUIContext): void {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Theme adapters bound to an *injected* Theme instance.
3
+ *
4
+ * Pi's own `getMarkdownTheme()` closes over a module-global `theme` singleton. Extensions are
5
+ * loaded through jiti, which gives them a separate module cache, so that global can be
6
+ * `undefined` inside a plugin — pi documents this footgun on `DynamicBorder`. Every renderer
7
+ * pi calls hands us a live `Theme`, so we build the adapter from that instead of the global.
8
+ */
9
+
10
+ import type { Theme } from "@earendil-works/pi-coding-agent";
11
+ import type { MarkdownTheme } from "@earendil-works/pi-tui";
12
+
13
+ /**
14
+ * A `MarkdownTheme` derived from the theme pi passed to this render pass.
15
+ *
16
+ * `highlightCode` is deliberately omitted: pi's implementation also reads the module global,
17
+ * and it is optional on `MarkdownTheme` — code blocks render uncoloured rather than crashing.
18
+ */
19
+ export function markdownTheme(theme: Theme): MarkdownTheme {
20
+ return {
21
+ heading: (text) => theme.fg("mdHeading", text),
22
+ link: (text) => theme.fg("mdLink", text),
23
+ linkUrl: (text) => theme.fg("mdLinkUrl", text),
24
+ code: (text) => theme.fg("mdCode", text),
25
+ codeBlock: (text) => theme.fg("mdCodeBlock", text),
26
+ codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
27
+ quote: (text) => theme.fg("mdQuote", text),
28
+ quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
29
+ hr: (text) => theme.fg("mdHr", text),
30
+ listBullet: (text) => theme.fg("mdListBullet", text),
31
+ bold: (text) => theme.bold(text),
32
+ italic: (text) => theme.italic(text),
33
+ underline: (text) => theme.underline(text),
34
+ strikethrough: (text) => theme.strikethrough(text),
35
+ };
36
+ }
package/src/ui/theme.ts CHANGED
@@ -1,36 +1,11 @@
1
1
  /** Small presentation helpers shared by the RLM widgets (glyphs, spinner, formatting). */
2
2
 
3
- import type { SubcallKind, SubcallStatus } from "../tool/rlm-details.ts";
4
-
5
3
  export const SPINNER = Object.freeze(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]);
6
4
 
7
5
  export function spinnerFrame(): string {
8
6
  return SPINNER[Math.floor(Date.now() / 100) % SPINNER.length] ?? "⠋";
9
7
  }
10
8
 
11
- /** Glyph for a node's status. */
12
- export function statusGlyph(status: SubcallStatus): string {
13
- if (status === "done") return "✓";
14
- if (status === "error") return "✗";
15
- return spinnerFrame();
16
- }
17
-
18
- /** Short role label for a node kind. */
19
- export function kindLabel(kind: SubcallKind): string {
20
- switch (kind) {
21
- case "root":
22
- return "RLM ▸ root";
23
- case "rlm":
24
- return "rlm_query";
25
- case "batch":
26
- return "llm_query×";
27
- case "tool":
28
- return "tool";
29
- default:
30
- return "llm_query";
31
- }
32
- }
33
-
34
9
  export function formatCost(usd: number): string {
35
10
  return `$${usd.toFixed(usd < 1 ? 4 : 2)}`;
36
11
  }