@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.
- package/package.json +1 -1
- package/src/bridge/fallback-todo.ts +12 -1
- package/src/bridge/subcall-handlers.ts +336 -0
- package/src/commands/rlm-config.ts +8 -8
- package/src/commands/rlm.ts +48 -12
- package/src/config/defaults.ts +4 -1
- package/src/config/settings.ts +33 -3
- package/src/context/repomix-context.ts +5 -10
- package/src/core/answer.ts +4 -3
- package/src/core/artifacts.ts +4 -3
- package/src/core/engine.ts +101 -267
- package/src/core/gates.ts +3 -3
- package/src/core/limits.ts +19 -1
- package/src/core/pipeline-handlers.ts +319 -0
- package/src/core/pipeline.ts +2 -2
- package/src/core/types.ts +25 -27
- package/src/index.ts +63 -17
- package/src/mode/rlm-mode.ts +8 -11
- package/src/prompts/system.ts +164 -52
- package/src/prompts/user.ts +1 -5
- package/src/sandbox/protocol.ts +6 -7
- package/src/sandbox/sandbox-manager.ts +25 -11
- package/src/sandbox/sandbox.ts +93 -22
- package/src/sandbox/worker.py +798 -66
- package/src/state/paths.ts +1 -1
- package/src/state/reads.ts +12 -4
- package/src/state/resume.ts +5 -11
- package/src/text/parsing.ts +0 -6
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +2 -0
- package/src/tool/repl-tool.ts +223 -318
- package/src/tool/rlm-details.ts +0 -10
- package/src/tool/rlm-events.ts +10 -2
- package/src/tool/rlm-tool.ts +18 -31
- package/src/tool/subcall-render.ts +75 -11
- package/src/tool/subcall-store.ts +57 -1
- package/src/ui/config-panel.ts +41 -21
- package/src/ui/intro.ts +2 -1
- package/src/ui/status.ts +8 -5
- package/src/ui/theme-adapter.ts +36 -0
- package/src/ui/theme.ts +0 -25
- package/src/util/concurrency.ts +87 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/llm-query.ts +0 -133
- package/src/bridge/rlm-query.ts +0 -122
- package/src/mode/input-router.ts +0 -23
package/src/tool/rlm-details.ts
CHANGED
|
@@ -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
|
-
}
|
package/src/tool/rlm-events.ts
CHANGED
|
@@ -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 =
|
|
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;
|
package/src/tool/rlm-tool.ts
CHANGED
|
@@ -5,23 +5,29 @@
|
|
|
5
5
|
* onUpdate(partialResult) for progressive TUI re-rendering.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
36
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
+
const sBg = backgroundTag(sc, theme);
|
|
170
|
+
let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}${sBg}`;
|
|
105
171
|
|
|
106
172
|
if (sc.args) {
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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. */
|
package/src/ui/config-panel.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
85
|
-
case "maxIterations": config
|
|
86
|
-
case "execTimeoutS": config
|
|
87
|
-
case "maxConcurrentSubcalls": config
|
|
88
|
-
case "maxBudgetUsd":
|
|
89
|
-
case "maxTimeoutMs":
|
|
90
|
-
case "maxTokens":
|
|
91
|
-
case "maxErrors":
|
|
92
|
-
case "orchestrator": config
|
|
93
|
-
case "pipeline": config
|
|
94
|
-
case "maxBackwardJumps": config
|
|
95
|
-
case "compaction": config
|
|
96
|
-
case "
|
|
97
|
-
case "
|
|
98
|
-
|
|
99
|
-
case "
|
|
100
|
-
case "
|
|
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,
|
|
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
|
-
|
|
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
|
}
|