@hicaru/pi-rlm 0.1.9 → 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.
@@ -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 = {
@@ -74,7 +74,7 @@ export class SubcallStore extends EmitterListener {
74
74
 
75
75
  /** Snapshot subcall array. Allocates a new array from Map values. */
76
76
  getSubcalls(): RlmSubcall[] {
77
- 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 }));
78
78
  }
79
79
 
80
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."),
@@ -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
  }
@@ -1,23 +0,0 @@
1
- import type { InputSource } from "@earendil-works/pi-coding-agent";
2
-
3
- export interface InputRouteState {
4
- readonly enabled: boolean;
5
- readonly busy: boolean;
6
- }
7
-
8
- export interface InputRouteEvent {
9
- readonly source: InputSource;
10
- readonly text: string;
11
- }
12
-
13
- export type InputRouteDecision = "continue" | "route" | "busy";
14
-
15
- export function decideRlmInputRoute(event: InputRouteEvent, state: InputRouteState): InputRouteDecision {
16
- const eligible = state.enabled && event.source === "interactive" && !event.text.trimStart().startsWith("/");
17
- if (!eligible) return "continue";
18
- return state.busy ? "busy" : "route";
19
- }
20
-
21
- export function shouldRouteRlmInput(event: InputRouteEvent, state: InputRouteState): boolean {
22
- return decideRlmInputRoute(event, state) === "route";
23
- }