@hicaru/pi-rlm 0.1.8 → 0.2.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.
Files changed (52) hide show
  1. package/README.md +22 -19
  2. package/package.json +2 -1
  3. package/src/bridge/library.ts +93 -15
  4. package/src/bridge/llm-query.ts +60 -36
  5. package/src/bridge/rlm-query.ts +63 -79
  6. package/src/commands/rlm-config.ts +8 -8
  7. package/src/commands/rlm.ts +48 -12
  8. package/src/config/settings.ts +33 -3
  9. package/src/context/library-context.ts +209 -22
  10. package/src/context/repomix-context.ts +7 -58
  11. package/src/core/answer.ts +5 -13
  12. package/src/core/artifacts.ts +4 -3
  13. package/src/core/critique.ts +92 -0
  14. package/src/core/engine.ts +94 -299
  15. package/src/core/gates.ts +33 -4
  16. package/src/core/limits.ts +19 -1
  17. package/src/core/pipeline-handlers.ts +319 -0
  18. package/src/core/pipeline.ts +40 -15
  19. package/src/core/types.ts +26 -30
  20. package/src/index.ts +36 -26
  21. package/src/mode/native-guards.ts +2 -2
  22. package/src/mode/rlm-mode.ts +8 -11
  23. package/src/prompts/phases.ts +18 -39
  24. package/src/prompts/system.ts +167 -64
  25. package/src/prompts/user.ts +1 -5
  26. package/src/sandbox/protocol.ts +5 -17
  27. package/src/sandbox/sandbox-manager.ts +5 -5
  28. package/src/sandbox/sandbox.ts +67 -27
  29. package/src/sandbox/worker.py +534 -48
  30. package/src/state/paths.ts +1 -1
  31. package/src/state/reads.ts +12 -4
  32. package/src/state/resume.ts +26 -25
  33. package/src/state/rows.ts +2 -2
  34. package/src/text/parsing.ts +0 -6
  35. package/src/text/tokens.ts +7 -1
  36. package/src/tool/repl-details.ts +2 -3
  37. package/src/tool/repl-tool.ts +132 -337
  38. package/src/tool/rlm-aggregator.ts +7 -7
  39. package/src/tool/rlm-details.ts +6 -13
  40. package/src/tool/rlm-events.ts +14 -11
  41. package/src/tool/rlm-tool.ts +20 -38
  42. package/src/tool/subcall-render.ts +61 -9
  43. package/src/tool/subcall-store.ts +4 -2
  44. package/src/ui/config-panel.ts +43 -23
  45. package/src/ui/intro.ts +2 -1
  46. package/src/ui/status.ts +8 -5
  47. package/src/ui/theme-adapter.ts +36 -0
  48. package/src/ui/theme.ts +0 -25
  49. package/src/mode/input-router.ts +0 -23
  50. package/src/registry/edit-registry.ts +0 -22
  51. package/src/text/edits.ts +0 -164
  52. package/src/tool/apply-edits-tool.ts +0 -295
@@ -6,14 +6,14 @@
6
6
  * getState(): RlmDetails for direct access (spinner loop, final return).
7
7
  *
8
8
  * Subcall storage and totals are delegated to SubcallStore. Root-level state
9
- * (status, prompt, turns, answer, edits) is kept in the aggregator.
9
+ * (status, prompt, turns, answer, warnings) is kept in the aggregator.
10
10
  *
11
11
  * Replaces RlmToolBridge's internal state accumulation. The emitter is pure
12
12
  * dispatch; the aggregator is pure state. Separated for independent testing.
13
13
  */
14
14
 
15
15
  import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
16
- import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, EditsEvent, StatusEvent, RootPromptEvent } from "./rlm-events.ts";
16
+ import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, StatusEvent, RootPromptEvent, WarningsEvent } from "./rlm-events.ts";
17
17
  import type { RlmDetails, RlmRunStatus } from "./rlm-details.ts";
18
18
  import { EmitterListener } from "./emitter-listener.ts";
19
19
  import { SubcallStore } from "./subcall-store.ts";
@@ -27,7 +27,7 @@ export class RlmEventAggregator extends EmitterListener {
27
27
  private turnCurrent = 0;
28
28
  private turnMax = 0;
29
29
  private answer?: string;
30
- private edits: RlmDetails["edits"] = [];
30
+ private warnings?: readonly string[];
31
31
 
32
32
  constructor(
33
33
  emitter: RlmEmitter,
@@ -40,7 +40,7 @@ export class RlmEventAggregator extends EmitterListener {
40
40
  emitter.onTurn((e) => this.handleTurn(e)),
41
41
  emitter.onRootUsage((e) => this.handleRootUsage(e)),
42
42
  emitter.onAnswer((e) => this.handleAnswer(e)),
43
- emitter.onEdits((e) => this.handleEdits(e)),
43
+ emitter.onWarnings((e) => this.handleWarnings(e)),
44
44
  emitter.onStatus((e) => this.handleStatus(e)),
45
45
  emitter.onRootPrompt((e) => this.handleRootPrompt(e)),
46
46
  ]);
@@ -64,8 +64,8 @@ export class RlmEventAggregator extends EmitterListener {
64
64
  this.notify();
65
65
  }
66
66
 
67
- private handleEdits(event: EditsEvent): void {
68
- this.edits = event.edits;
67
+ private handleWarnings(event: WarningsEvent): void {
68
+ this.warnings = event.warnings;
69
69
  this.notify();
70
70
  }
71
71
 
@@ -90,7 +90,7 @@ export class RlmEventAggregator extends EmitterListener {
90
90
  subcalls: this.store.getSubcalls(),
91
91
  totals: this.store.getTotals(),
92
92
  answer: this.answer,
93
- edits: this.edits,
93
+ warnings: this.warnings,
94
94
  };
95
95
  }
96
96
 
@@ -6,8 +6,6 @@
6
6
  * after every mutation, enabling Pi's built-in progressive TUI re-render.
7
7
  */
8
8
 
9
- import type { ProposedEdit } from "../sandbox/protocol.ts";
10
-
11
9
  export type SubcallKind = "root" | "rlm" | "llm" | "batch" | "tool";
12
10
  export type SubcallStatus = "running" | "done" | "error";
13
11
  export type RlmRunStatus = "running" | "done" | "error" | "aborted";
@@ -29,6 +27,10 @@ export interface RlmSubcall {
29
27
  readonly endedAt?: number;
30
28
  readonly costUsd: number;
31
29
  readonly tokens: number;
30
+ /** For batch subcalls: failed prompt count (partial failure). */
31
+ readonly failedCount?: number;
32
+ /** For batch subcalls: total prompt count. */
33
+ readonly totalCount?: number;
32
34
  }
33
35
 
34
36
  export interface RlmDetails {
@@ -38,16 +40,7 @@ export interface RlmDetails {
38
40
  readonly subcalls: readonly RlmSubcall[];
39
41
  readonly totals: { readonly costUsd: number; readonly tokens: number };
40
42
  readonly answer?: string;
41
- readonly edits?: readonly ProposedEdit[];
43
+ /** Advisory diagnostics — surfaced to the user, never a failure. */
44
+ readonly warnings?: readonly string[];
42
45
  }
43
46
 
44
- export interface SubcallInit {
45
- readonly parentId?: string;
46
- readonly kind: SubcallKind;
47
- readonly label: string;
48
- readonly model?: string;
49
- readonly detail?: string;
50
- readonly args?: string;
51
- /** Recursion depth. Required — all call sites pass this. */
52
- readonly depth: number;
53
- }
@@ -12,7 +12,6 @@
12
12
 
13
13
  import { EventEmitter } from "node:events";
14
14
  import type { SubcallKind, SubcallStatus, RlmRunStatus } from "./rlm-details.ts";
15
- import type { ProposedEdit } from "../sandbox/protocol.ts";
16
15
 
17
16
  // ── Event payloads ──
18
17
 
@@ -40,6 +39,14 @@ export interface SubcallUpdatedEvent {
40
39
  readonly costUsd?: number;
41
40
  /** Delta — additive on both the subcall and running totals. */
42
41
  readonly tokens?: number;
42
+ /** For batch subcalls: failed prompt count. */
43
+ readonly failedCount?: number;
44
+ /** For batch subcalls: total prompt count. */
45
+ readonly totalCount?: number;
46
+ }
47
+
48
+ export interface WarningsEvent {
49
+ readonly warnings: readonly string[];
43
50
  }
44
51
 
45
52
  export interface TurnEvent {
@@ -56,10 +63,6 @@ export interface AnswerEvent {
56
63
  readonly text: string;
57
64
  }
58
65
 
59
- export interface EditsEvent {
60
- readonly edits: readonly ProposedEdit[];
61
- }
62
-
63
66
  export interface StatusEvent {
64
67
  readonly status: RlmRunStatus;
65
68
  }
@@ -110,9 +113,9 @@ export class RlmEmitter {
110
113
  this.ee.emit("answer", { text } satisfies AnswerEvent);
111
114
  }
112
115
 
113
- /** Set proposed edits (root-only). */
114
- emitEdits(edits: readonly ProposedEdit[]): void {
115
- this.ee.emit("edits", { edits } satisfies EditsEvent);
116
+ /** Set advisory warnings (root-only; never a failure). */
117
+ emitWarnings(warnings: readonly string[]): void {
118
+ this.ee.emit("warnings", { warnings } satisfies WarningsEvent);
116
119
  }
117
120
 
118
121
  /** Set the root run status (done/error/aborted). */
@@ -152,9 +155,9 @@ export class RlmEmitter {
152
155
  return () => { this.ee.off("answer", handler); };
153
156
  }
154
157
 
155
- onEdits(handler: (event: EditsEvent) => void): () => void {
156
- this.ee.on("edits", handler);
157
- return () => { this.ee.off("edits", handler); };
158
+ onWarnings(handler: (event: WarningsEvent) => void): () => void {
159
+ this.ee.on("warnings", handler);
160
+ return () => { this.ee.off("warnings", handler); };
158
161
  }
159
162
 
160
163
  onStatus(handler: (event: StatusEvent) => void): () => void {
@@ -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,17 +149,12 @@ 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
- if (details.edits && details.edits.length > 0) {
155
+ if (details.warnings && details.warnings.length > 0) {
160
156
  container.addChild(new Spacer(1));
161
- const editFiles = new Set(details.edits.map(e => e.path));
162
- container.addChild(new Text(
163
- theme.fg("muted", "─── Edits ───") +
164
- `\n ${theme.fg("dim", `${details.edits.length} edit${details.edits.length > 1 ? "s" : ""} proposed across ${editFiles.size} file${editFiles.size > 1 ? "s" : ""}`)}`,
165
- 0, 0,
166
- ));
157
+ container.addChild(new Text(theme.fg("muted", details.warnings.join("\n")), 0, 0));
167
158
  }
168
159
 
169
160
  return container;
@@ -172,14 +163,5 @@ function renderExpanded(details: RlmDetails, theme: Theme): Component {
172
163
  // ── Collapsed view ──
173
164
 
174
165
  function renderCollapsed(details: RlmDetails, theme: Theme): Text {
175
- const glyph = headlineStatusGlyph(details.status, theme);
176
- const header = `${glyph} ${theme.fg("toolTitle", theme.bold("RLM"))} · ${rootStats(details, theme)}`;
177
-
178
- let body = "";
179
- if (details.subcalls.length > 0) {
180
- body = `\n${renderCollapsedSubcallTree(details.subcalls, theme)}`;
181
- }
182
-
183
- const expandHint = details.status === "running" ? "" : `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
184
- return new Text(`${header}${body}${expandHint}`, 0, 0);
166
+ return renderCollapsedCard("RLM", details.status, rootStats(details, theme), details.subcalls, theme);
185
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,62 @@ 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
+ ): string {
56
+ const parts: string[] = [formatCost(totals.costUsd)];
57
+ if (totals.tokens > 0) parts.push(`${formatTokens(totals.tokens)} tok`);
58
+ if (extra) parts.push(extra);
59
+ return theme.fg("dim", parts.join(" · "));
60
+ }
61
+
62
+ /** `<glyph> <TITLE> <stats>` — the first line of both tools' collapsed and expanded views. */
63
+ export function cardHeader(
64
+ title: string,
65
+ status: SubcallStatus | "aborted" | "done",
66
+ stats: string,
67
+ theme: Theme,
68
+ ): string {
69
+ return `${headlineStatusGlyph(status, theme)} ${theme.fg("toolTitle", theme.bold(title))} ${stats}`;
70
+ }
71
+
72
+ /**
73
+ * The expand hint, using the user's actual binding rather than a hardcoded "Ctrl+O".
74
+ *
75
+ * Deliberately `keyText` + the injected theme rather than pi's `keyHint`: `keyHint` colours via
76
+ * pi's module-global theme, which throws when that global is uninitialized — the same jiti
77
+ * hazard `ui/theme-adapter.ts` exists to avoid. `keyText` only reads the keybinding registry.
78
+ */
79
+ function expandHint(theme: Theme): string {
80
+ // Empty outside a live pi session (the app installs the real binding registry at startup) —
81
+ // the phrase stays the same, only the key prefix drops out.
82
+ const key = keyText("app.tools.expand");
83
+ return theme.fg("muted", key ? `${key} to expand` : "to expand");
84
+ }
85
+
86
+ /** The collapsed card: header, the sub-call tree, and the expand hint. */
87
+ export function renderCollapsedCard(
88
+ title: string,
89
+ status: SubcallStatus | "aborted" | "done",
90
+ stats: string,
91
+ subcalls: readonly RlmSubcall[],
92
+ theme: Theme,
93
+ ): Text {
94
+ const body = subcalls.length > 0 ? `\n${renderCollapsedSubcallTree(subcalls, theme)}` : "";
95
+ const hint = status === "running" ? "" : `\n${expandHint(theme)}`;
96
+ return new Text(`${cardHeader(title, status, stats, theme)}${body}${hint}`, 0, 0);
97
+ }
98
+
45
99
  // ── Tree building ──
46
100
 
47
101
  function buildParentMap(subcalls: readonly RlmSubcall[]): Map<string | undefined, RlmSubcall[]> {
@@ -104,14 +158,12 @@ export function renderExpandedSubcallTree(
104
158
  let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}`;
105
159
 
106
160
  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)}`;
161
+ line += `\n${pad} ${theme.fg("dim", previewText(sc.args, ARGS_PREVIEW_CHARS))}`;
109
162
  }
110
163
  if (sc.status === "error" && sc.detail) {
111
164
  line += `\n${pad} ${theme.fg("error", `✗ ${sc.detail}`)}`;
112
165
  } 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)}`;
166
+ line += `\n${pad} ${theme.fg("toolOutput", previewText(sc.resultPreview, RESULT_PREVIEW_CHARS))}`;
115
167
  }
116
168
 
117
169
  container.addChild(new Text(line, 0, 0));
@@ -7,7 +7,7 @@
7
7
  * subcall accumulation logic.
8
8
  */
9
9
  import type { RlmEmitter, SubcallCreatedEvent, SubcallUpdatedEvent } from "./rlm-events.ts";
10
- import type { RlmSubcall, SubcallStatus } from "./rlm-details.ts";
10
+ import type { RlmSubcall } from "./rlm-details.ts";
11
11
  import { EmitterListener } from "./emitter-listener.ts";
12
12
 
13
13
  type MutableSubcall = {
@@ -66,13 +66,15 @@ export class SubcallStore extends EmitterListener {
66
66
  sc.tokens += event.tokens;
67
67
  this.totalTokens += event.tokens;
68
68
  }
69
+ if (event.failedCount !== undefined) sc.failedCount = event.failedCount;
70
+ if (event.totalCount !== undefined) sc.totalCount = event.totalCount;
69
71
  }
70
72
 
71
73
  // ── Read ──
72
74
 
73
75
  /** Snapshot subcall array. Allocates a new array from Map values. */
74
76
  getSubcalls(): RlmSubcall[] {
75
- return Array.from(this.subcalls.values(), (subcall) => Object.freeze({ ...subcall, status: subcall.status as SubcallStatus }));
77
+ return Array.from(this.subcalls.values(), (subcall) => Object.freeze({ ...subcall }));
76
78
  }
77
79
 
78
80
  /** Snapshot running totals. O(1). */
@@ -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."),
@@ -41,15 +48,17 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
41
48
  item("maxTokens", "Token ceiling", config.maxTokens != null ? String(config.maxTokens) : "none", CHOICES.maxTokens, "Total input+output token cap for the whole recursive tree."),
42
49
  item("maxErrors", "Max consecutive errors", config.maxErrors != null ? String(config.maxErrors) : "none", CHOICES.maxErrors, "Stop after this many consecutive failing turns; none disables the guard."),
43
50
  item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
44
- item("pipeline", "Phase pipeline", config.pipeline ? "on" : "off", CHOICES.pipeline, "Enable artifact-gated phases: clarify→research→blueprint→implement fanout→validate (clarify needs Ask user on)."),
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,
52
- "Allow load_library() to pull an external dir, file, or git repo into a new context_N slot."),
61
+ "Allow load_library() to pull an external dir, file, or git repo into the shared context list."),
53
62
  item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
54
63
  ];
55
64
 
@@ -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
  }