@hicaru/pi-rlm 0.1.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.
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/README.ru.md +200 -0
- package/README.zh-CN.md +224 -0
- package/package.json +54 -0
- package/src/bridge/fallback-todo.ts +137 -0
- package/src/bridge/interactive.ts +65 -0
- package/src/bridge/llm-query.ts +124 -0
- package/src/bridge/model.ts +97 -0
- package/src/bridge/pi-interactive.ts +86 -0
- package/src/bridge/rlm-query.ts +78 -0
- package/src/commands/rlm-config.ts +42 -0
- package/src/commands/rlm.ts +165 -0
- package/src/config/defaults.ts +38 -0
- package/src/config/settings.ts +185 -0
- package/src/context/repomix-context.ts +253 -0
- package/src/core/answer.ts +97 -0
- package/src/core/compaction.ts +64 -0
- package/src/core/engine.ts +408 -0
- package/src/core/history.ts +13 -0
- package/src/core/iteration.ts +45 -0
- package/src/core/limits.ts +90 -0
- package/src/core/pipeline.ts +100 -0
- package/src/core/resource-limits.ts +14 -0
- package/src/core/types.ts +131 -0
- package/src/index.ts +165 -0
- package/src/mode/input-router.ts +23 -0
- package/src/mode/rlm-mode.ts +149 -0
- package/src/patch/apply.ts +148 -0
- package/src/patch/index.ts +37 -0
- package/src/prompts/system.ts +278 -0
- package/src/prompts/user.ts +21 -0
- package/src/sandbox/protocol.ts +191 -0
- package/src/sandbox/sandbox-manager.ts +143 -0
- package/src/sandbox/sandbox.ts +362 -0
- package/src/sandbox/worker.py +457 -0
- package/src/state/events.ts +22 -0
- package/src/state/index.ts +23 -0
- package/src/state/internal.ts +46 -0
- package/src/state/paths.ts +42 -0
- package/src/state/reads.ts +96 -0
- package/src/state/resume.ts +154 -0
- package/src/state/rows.ts +117 -0
- package/src/state/writes.ts +56 -0
- package/src/telemetry/dispatcher.ts +116 -0
- package/src/telemetry/index.ts +14 -0
- package/src/telemetry/mlflow-config.ts +15 -0
- package/src/telemetry/mlflow-sink.ts +136 -0
- package/src/telemetry/mlflow.ts +99 -0
- package/src/telemetry/sink.ts +8 -0
- package/src/text/edits.ts +16 -0
- package/src/text/parsing.ts +35 -0
- package/src/text/preview.ts +18 -0
- package/src/text/tokens.ts +64 -0
- package/src/tool/apply-diff-tool.ts +125 -0
- package/src/tool/emitter-listener.ts +24 -0
- package/src/tool/repl-details.ts +23 -0
- package/src/tool/repl-tool.ts +528 -0
- package/src/tool/rlm-aggregator.ts +115 -0
- package/src/tool/rlm-details.ts +53 -0
- package/src/tool/rlm-events.ts +215 -0
- package/src/tool/rlm-tool.ts +199 -0
- package/src/tool/subcall-render.ts +129 -0
- package/src/tool/subcall-store.ts +90 -0
- package/src/tool/tool-utils.ts +73 -0
- package/src/ui/config-panel.ts +92 -0
- package/src/ui/intro.ts +23 -0
- package/src/ui/model-picker.ts +139 -0
- package/src/ui/status.ts +26 -0
- package/src/ui/theme.ts +47 -0
- package/src/util/concurrency.ts +15 -0
- package/src/util/errors.ts +27 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RlmEmitter — typed EventEmitter wrapper for RLM lifecycle events.
|
|
3
|
+
*
|
|
4
|
+
* Replaces RlmToolBridge mutation methods. The engine and bridges emit typed
|
|
5
|
+
* events instead of calling bridge.addSubcall/updateSubcall/etc. Listeners
|
|
6
|
+
* (RlmEventAggregator, TelemetrySink) subscribe to build derived state.
|
|
7
|
+
*
|
|
8
|
+
* Node.js EventEmitter is synchronous — listeners run in registration order
|
|
9
|
+
* during emit. No backpressure needed: engine events are sequential, one at
|
|
10
|
+
* a time during the turn loop.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
import type { TelemetrySink } from "../telemetry/sink.ts";
|
|
15
|
+
import type { SubcallKind, SubcallStatus, RlmRunStatus } from "./rlm-details.ts";
|
|
16
|
+
import type { ProposedEdit } from "../sandbox/protocol.ts";
|
|
17
|
+
|
|
18
|
+
// ── Event payloads ──
|
|
19
|
+
|
|
20
|
+
/** Emitted when a sub-call is created. ID is auto-generated by the emitter. */
|
|
21
|
+
export interface SubcallCreatedEvent {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly parentId?: string;
|
|
24
|
+
readonly kind: SubcallKind;
|
|
25
|
+
readonly label: string;
|
|
26
|
+
readonly model?: string;
|
|
27
|
+
readonly detail?: string;
|
|
28
|
+
readonly args?: string;
|
|
29
|
+
/** Recursion depth. Required — all 9 call sites pass this. */
|
|
30
|
+
readonly depth: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Emitted when a sub-call is updated. All fields are partial — only supplied fields are applied. */
|
|
34
|
+
export interface SubcallUpdatedEvent {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly status?: SubcallStatus;
|
|
37
|
+
readonly detail?: string;
|
|
38
|
+
readonly args?: string;
|
|
39
|
+
readonly resultPreview?: string;
|
|
40
|
+
/** Delta — additive on both the subcall and running totals. */
|
|
41
|
+
readonly costUsd?: number;
|
|
42
|
+
/** Delta — additive on both the subcall and running totals. */
|
|
43
|
+
readonly tokens?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TurnEvent {
|
|
47
|
+
readonly current: number;
|
|
48
|
+
readonly max: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RootUsageEvent {
|
|
52
|
+
readonly costUsd: number;
|
|
53
|
+
readonly tokens: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AnswerEvent {
|
|
57
|
+
readonly text: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface EditsEvent {
|
|
61
|
+
readonly edits: readonly ProposedEdit[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface StatusEvent {
|
|
65
|
+
readonly status: RlmRunStatus;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RootPromptEvent {
|
|
69
|
+
readonly text: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── RlmEmitter ──
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Typed wrapper around a Node.js EventEmitter for RLM lifecycle events.
|
|
76
|
+
*
|
|
77
|
+
* Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()`.
|
|
78
|
+
* Provides typed `on*` methods that return unsubscribe functions.
|
|
79
|
+
* `attachSink()` wires all events to a TelemetrySink and returns a detach function.
|
|
80
|
+
*/
|
|
81
|
+
export class RlmEmitter {
|
|
82
|
+
private readonly ee = new EventEmitter();
|
|
83
|
+
private seq = 0;
|
|
84
|
+
|
|
85
|
+
// ── Emit ──
|
|
86
|
+
|
|
87
|
+
/** Create a new sub-call entry. Returns the auto-generated ID. */
|
|
88
|
+
emitSubcallCreated(init: Omit<SubcallCreatedEvent, "id">): string {
|
|
89
|
+
const id = `s${++this.seq}`;
|
|
90
|
+
const event: SubcallCreatedEvent = { id, ...init };
|
|
91
|
+
this.ee.emit("subcall:created", event);
|
|
92
|
+
return id;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Update an existing sub-call. All fields are partial. costUsd/tokens are additive. */
|
|
96
|
+
emitSubcallUpdated(event: SubcallUpdatedEvent): void {
|
|
97
|
+
this.ee.emit("subcall:updated", event);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Set turn progress (root-only). */
|
|
101
|
+
emitTurn(current: number, max: number): void {
|
|
102
|
+
this.ee.emit("turn", { current, max } satisfies TurnEvent);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Accumulate usage directly to root-level totals. */
|
|
106
|
+
emitRootUsage(costUsd: number, tokens: number): void {
|
|
107
|
+
this.ee.emit("root-usage", { costUsd, tokens } satisfies RootUsageEvent);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Set the final answer text (root-only). */
|
|
111
|
+
emitAnswer(text: string): void {
|
|
112
|
+
this.ee.emit("answer", { text } satisfies AnswerEvent);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Set proposed edits (root-only). */
|
|
116
|
+
emitEdits(edits: readonly ProposedEdit[]): void {
|
|
117
|
+
this.ee.emit("edits", { edits } satisfies EditsEvent);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Set the root run status (done/error/aborted). */
|
|
121
|
+
emitStatus(status: RlmRunStatus): void {
|
|
122
|
+
this.ee.emit("status", { status } satisfies StatusEvent);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Set the root prompt text. Called once before the engine starts. */
|
|
126
|
+
emitRootPrompt(text: string): void {
|
|
127
|
+
this.ee.emit("root-prompt", { text } satisfies RootPromptEvent);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Subscribe (returns unsubscribe function) ──
|
|
131
|
+
|
|
132
|
+
onSubcallCreated(handler: (event: SubcallCreatedEvent) => void): () => void {
|
|
133
|
+
this.ee.on("subcall:created", handler);
|
|
134
|
+
return () => { this.ee.off("subcall:created", handler); };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
onSubcallUpdated(handler: (event: SubcallUpdatedEvent) => void): () => void {
|
|
138
|
+
this.ee.on("subcall:updated", handler);
|
|
139
|
+
return () => { this.ee.off("subcall:updated", handler); };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
onTurn(handler: (event: TurnEvent) => void): () => void {
|
|
143
|
+
this.ee.on("turn", handler);
|
|
144
|
+
return () => { this.ee.off("turn", handler); };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
onRootUsage(handler: (event: RootUsageEvent) => void): () => void {
|
|
148
|
+
this.ee.on("root-usage", handler);
|
|
149
|
+
return () => { this.ee.off("root-usage", handler); };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
onAnswer(handler: (event: AnswerEvent) => void): () => void {
|
|
153
|
+
this.ee.on("answer", handler);
|
|
154
|
+
return () => { this.ee.off("answer", handler); };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
onEdits(handler: (event: EditsEvent) => void): () => void {
|
|
158
|
+
this.ee.on("edits", handler);
|
|
159
|
+
return () => { this.ee.off("edits", handler); };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
onStatus(handler: (event: StatusEvent) => void): () => void {
|
|
163
|
+
this.ee.on("status", handler);
|
|
164
|
+
return () => { this.ee.off("status", handler); };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
onRootPrompt(handler: (event: RootPromptEvent) => void): () => void {
|
|
168
|
+
this.ee.on("root-prompt", handler);
|
|
169
|
+
return () => { this.ee.off("root-prompt", handler); };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Sink integration ──
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Wire all lifecycle events to a TelemetrySink.
|
|
176
|
+
* Returns a detach function that unsubscribes all sink listeners.
|
|
177
|
+
* The caller is responsible for calling `sink.shutdown()` after detaching.
|
|
178
|
+
*/
|
|
179
|
+
attachSink(sink: TelemetrySink): () => void {
|
|
180
|
+
const unsubs: (() => void)[] = [];
|
|
181
|
+
|
|
182
|
+
unsubs.push(this.onSubcallCreated((event) => {
|
|
183
|
+
sink.start(event.id, {
|
|
184
|
+
kind: event.kind,
|
|
185
|
+
depth: event.depth,
|
|
186
|
+
parentId: event.parentId,
|
|
187
|
+
model: event.model,
|
|
188
|
+
label: event.label,
|
|
189
|
+
detail: event.detail,
|
|
190
|
+
args: event.args,
|
|
191
|
+
});
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
unsubs.push(this.onSubcallUpdated((event) => {
|
|
195
|
+
if (event.costUsd !== undefined || event.tokens !== undefined) {
|
|
196
|
+
sink.usage(event.id, event.costUsd ?? 0, event.tokens ?? 0);
|
|
197
|
+
}
|
|
198
|
+
if (event.status !== undefined && event.status !== "running") {
|
|
199
|
+
sink.end(event.id, {
|
|
200
|
+
error: event.status === "error" ? (event.detail ?? "error") : undefined,
|
|
201
|
+
resultPreview: event.resultPreview,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
return () => { unsubs.forEach((fn) => fn()); };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Lifecycle ──
|
|
210
|
+
|
|
211
|
+
/** Remove all listeners. Call after the run completes to prevent leaks. */
|
|
212
|
+
shutdown(): void {
|
|
213
|
+
this.ee.removeAllListeners();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RLM tool — registers the RLM engine as a Pi tool with inline rendering.
|
|
3
|
+
*
|
|
4
|
+
* Modeled after rpiv-mono's subagent tool.
|
|
5
|
+
* The tool's execute() wraps createEngine() with an RlmEmitter + RlmEventAggregator that feeds
|
|
6
|
+
* onUpdate(partialResult) for progressive TUI re-rendering.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getMarkdownTheme, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Container, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
|
|
13
|
+
import type { RlmController, StartInput } from "../mode/rlm-mode.ts";
|
|
14
|
+
import { createTelemetrySink } from "../telemetry/index.ts";
|
|
15
|
+
import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
|
|
16
|
+
import { errorMessage } from "../util/errors.ts";
|
|
17
|
+
import { type RlmDetails } from "./rlm-details.ts";
|
|
18
|
+
import { RlmEmitter } from "./rlm-events.ts";
|
|
19
|
+
import { RlmEventAggregator } from "./rlm-aggregator.ts";
|
|
20
|
+
import {
|
|
21
|
+
headlineStatusGlyph,
|
|
22
|
+
renderCollapsedSubcallTree,
|
|
23
|
+
renderExpandedSubcallTree,
|
|
24
|
+
} from "./subcall-render.ts";
|
|
25
|
+
import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
|
|
26
|
+
import { applyEdits } from "../patch/index.ts";
|
|
27
|
+
import { tryExtractDiff } from "../core/answer.ts";
|
|
28
|
+
|
|
29
|
+
// ── Parameter schema ──
|
|
30
|
+
|
|
31
|
+
export const RlmToolParams = Object.freeze(Type.Object({
|
|
32
|
+
prompt: Type.String({ description: "The task or question for the RLM engine" }),
|
|
33
|
+
context: Type.Optional(Type.String({ description: "Optional context. If omitted, repo is auto-packed via repomix." })),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// ── Rendering helpers ──
|
|
37
|
+
|
|
38
|
+
function rootStats(details: RlmDetails, theme: Theme): string {
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
parts.push(formatCost(details.totals.costUsd));
|
|
41
|
+
parts.push(`${formatTokens(details.totals.tokens)} tok`);
|
|
42
|
+
if (details.turns.current > 0) parts.push(`${details.turns.current} turn${details.turns.current > 1 ? "s" : ""}`);
|
|
43
|
+
return theme.fg("dim", parts.join(" · "));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Tool definition ──
|
|
47
|
+
|
|
48
|
+
export function createRlmTool(controller: RlmController): ToolDefinition<typeof RlmToolParams, RlmDetails> {
|
|
49
|
+
return {
|
|
50
|
+
name: "rlm",
|
|
51
|
+
label: "RLM",
|
|
52
|
+
description: "Run the Recursive Language Model engine to answer complex questions with code execution and recursive sub-agent calls.",
|
|
53
|
+
parameters: RlmToolParams,
|
|
54
|
+
|
|
55
|
+
async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
|
|
56
|
+
const validation = validateToolParams(RlmToolParams, rawParams, "RLM", (_errors): RlmDetails => ({
|
|
57
|
+
status: "error",
|
|
58
|
+
rootPrompt: "",
|
|
59
|
+
turns: { current: 0, max: 0 },
|
|
60
|
+
subcalls: [],
|
|
61
|
+
totals: { costUsd: 0, tokens: 0 },
|
|
62
|
+
}));
|
|
63
|
+
if (!validation.ok) return validation.error;
|
|
64
|
+
const params = validation.value;
|
|
65
|
+
|
|
66
|
+
const sink = await createTelemetrySink(controller.config.telemetry);
|
|
67
|
+
const emitter = new RlmEmitter();
|
|
68
|
+
const aggregator = new RlmEventAggregator(emitter, onUpdate ?? (() => {}));
|
|
69
|
+
let detachSink: (() => void) | undefined;
|
|
70
|
+
if (sink) detachSink = emitter.attachSink(sink);
|
|
71
|
+
emitter.emitRootPrompt(params.prompt);
|
|
72
|
+
|
|
73
|
+
// Wire abort signal to controller
|
|
74
|
+
if (signal) {
|
|
75
|
+
signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Animated spinner: cycle through braille frames while running
|
|
79
|
+
const progress = createProgressNotifier<RlmDetails>({
|
|
80
|
+
onUpdate,
|
|
81
|
+
getDetails: () => aggregator.getState(),
|
|
82
|
+
isRunning: (details) => details.status === "running",
|
|
83
|
+
renderText: () => `${spinnerFrame()} RLM running…`,
|
|
84
|
+
});
|
|
85
|
+
progress.start();
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const input: StartInput = {
|
|
89
|
+
kind: "fresh",
|
|
90
|
+
rootPrompt: params.prompt,
|
|
91
|
+
context: params.context ?? undefined,
|
|
92
|
+
};
|
|
93
|
+
const interactive = createPiInteractiveDeps(ctx);
|
|
94
|
+
const { done } = controller.start(ctx, input, emitter, {
|
|
95
|
+
onAskUserQuestion: controller.config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
|
|
96
|
+
onTodo: controller.config.todo ? interactive.onTodo : undefined,
|
|
97
|
+
});
|
|
98
|
+
const result = await done;
|
|
99
|
+
|
|
100
|
+
emitter.emitAnswer(result.answer);
|
|
101
|
+
const proposedEdits = result.edits ?? [];
|
|
102
|
+
const proposedDiffs = result.diffs?.length ? result.diffs : tryExtractDiff(result.answer);
|
|
103
|
+
if (proposedEdits.length > 0) emitter.emitEdits(proposedEdits);
|
|
104
|
+
await applyEdits(proposedEdits, proposedDiffs, ctx);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: "text", text: result.answer }],
|
|
108
|
+
details: aggregator.getState(),
|
|
109
|
+
};
|
|
110
|
+
} catch (e) {
|
|
111
|
+
emitter.emitStatus("error");
|
|
112
|
+
const msg = `RLM failed: ${errorMessage(e)}`;
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text", text: msg }],
|
|
115
|
+
details: aggregator.getState(),
|
|
116
|
+
};
|
|
117
|
+
} finally {
|
|
118
|
+
progress.stop();
|
|
119
|
+
detachSink?.();
|
|
120
|
+
aggregator.dispose();
|
|
121
|
+
emitter.shutdown();
|
|
122
|
+
try { await sink?.shutdown(); }
|
|
123
|
+
catch (err) { console.warn(`[rlm] telemetry shutdown failed: ${errorMessage(err)}`); }
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
renderCall(args, theme, _context) {
|
|
128
|
+
const preview = args.prompt.length > 80
|
|
129
|
+
? `${args.prompt.slice(0, 80)}...`
|
|
130
|
+
: args.prompt;
|
|
131
|
+
return new Text(
|
|
132
|
+
theme.fg("toolTitle", theme.bold("rlm ")) +
|
|
133
|
+
theme.fg("dim", preview.replace(/\n/g, " ")),
|
|
134
|
+
0, 0,
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
renderResult(result, { expanded, isPartial: _isPartial }, theme, _context) {
|
|
139
|
+
const details = result.details as RlmDetails | undefined;
|
|
140
|
+
if (!details) {
|
|
141
|
+
const text = result.content[0];
|
|
142
|
+
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
143
|
+
}
|
|
144
|
+
if (expanded) {
|
|
145
|
+
return renderExpanded(details, theme);
|
|
146
|
+
}
|
|
147
|
+
return renderCollapsed(details, theme);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Expanded view ──
|
|
153
|
+
|
|
154
|
+
function renderExpanded(details: RlmDetails, theme: Theme): Component {
|
|
155
|
+
const container = new Container();
|
|
156
|
+
|
|
157
|
+
const glyph = headlineStatusGlyph(details.status, theme);
|
|
158
|
+
const header = `${glyph} ${theme.fg("toolTitle", theme.bold("RLM"))} · ${rootStats(details, theme)}`;
|
|
159
|
+
container.addChild(new Text(header, 0, 0));
|
|
160
|
+
|
|
161
|
+
if (details.subcalls.length > 0) {
|
|
162
|
+
container.addChild(new Spacer(1));
|
|
163
|
+
container.addChild(new Text(theme.fg("muted", "─── Sub-calls ───"), 0, 0));
|
|
164
|
+
container.addChild(renderExpandedSubcallTree(details.subcalls, theme));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (details.answer) {
|
|
168
|
+
container.addChild(new Spacer(1));
|
|
169
|
+
container.addChild(new Text(theme.fg("muted", "─── Answer ───"), 0, 0));
|
|
170
|
+
container.addChild(new Markdown(details.answer, 0, 0, getMarkdownTheme()));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (details.edits && details.edits.length > 0) {
|
|
174
|
+
container.addChild(new Spacer(1));
|
|
175
|
+
const editFiles = new Set(details.edits.map(e => e.path));
|
|
176
|
+
container.addChild(new Text(
|
|
177
|
+
theme.fg("muted", "─── Edits ───") +
|
|
178
|
+
`\n ${theme.fg("dim", `${details.edits.length} edit${details.edits.length > 1 ? "s" : ""} proposed across ${editFiles.size} file${editFiles.size > 1 ? "s" : ""}`)}`,
|
|
179
|
+
0, 0,
|
|
180
|
+
));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return container;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Collapsed view ──
|
|
187
|
+
|
|
188
|
+
function renderCollapsed(details: RlmDetails, theme: Theme): Text {
|
|
189
|
+
const glyph = headlineStatusGlyph(details.status, theme);
|
|
190
|
+
const header = `${glyph} ${theme.fg("toolTitle", theme.bold("RLM"))} · ${rootStats(details, theme)}`;
|
|
191
|
+
|
|
192
|
+
let body = "";
|
|
193
|
+
if (details.subcalls.length > 0) {
|
|
194
|
+
body = `\n${renderCollapsedSubcallTree(details.subcalls, theme)}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const expandHint = details.status === "running" ? "" : `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
198
|
+
return new Text(`${header}${body}${expandHint}`, 0, 0);
|
|
199
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared sub-call tree rendering for RLM tools (rlm + repl).
|
|
3
|
+
*
|
|
4
|
+
* Both tools accumulate RlmSubcall[] arrays with parentId links. This module
|
|
5
|
+
* provides the collapsed ASCII tree and expanded Container-based tree rendering
|
|
6
|
+
* used by their renderResult() implementations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Container, Text, type Component } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { RlmSubcall, SubcallStatus } from "./rlm-details.ts";
|
|
11
|
+
import { formatCost, formatDuration, formatTokens, spinnerFrame } from "../ui/theme.ts";
|
|
12
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
// ── Glyphs ──
|
|
15
|
+
|
|
16
|
+
export function subcallRunningGlyph(theme: Theme): string {
|
|
17
|
+
return theme.fg("warning", spinnerFrame());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function subcallStatusGlyph(sc: Pick<RlmSubcall, "status">, theme: Theme): string {
|
|
21
|
+
if (sc.status === "running") return theme.fg("warning", "⏳");
|
|
22
|
+
if (sc.status === "error") return theme.fg("error", "✗");
|
|
23
|
+
return theme.fg("success", "✓");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function headlineStatusGlyph(status: SubcallStatus | "aborted" | "done", theme: Theme): string {
|
|
27
|
+
switch (status) {
|
|
28
|
+
case "done": return theme.fg("success", "✓");
|
|
29
|
+
case "error": return theme.fg("error", "✗");
|
|
30
|
+
case "aborted": return theme.fg("warning", "◐");
|
|
31
|
+
default: return theme.fg("warning", spinnerFrame());
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Stats formatting ──
|
|
36
|
+
|
|
37
|
+
export function subcallStatsLine(sc: Pick<RlmSubcall, "costUsd" | "tokens" | "endedAt" | "startedAt">): string {
|
|
38
|
+
const parts: string[] = [];
|
|
39
|
+
if (sc.costUsd > 0) parts.push(formatCost(sc.costUsd));
|
|
40
|
+
if (sc.tokens > 0) parts.push(`${formatTokens(sc.tokens)} tok`);
|
|
41
|
+
if (sc.endedAt && sc.startedAt) parts.push(formatDuration(sc.endedAt - sc.startedAt));
|
|
42
|
+
return parts.join(" · ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Tree building ──
|
|
46
|
+
|
|
47
|
+
function buildParentMap(subcalls: readonly RlmSubcall[]): Map<string | undefined, RlmSubcall[]> {
|
|
48
|
+
const map = new Map<string | undefined, RlmSubcall[]>();
|
|
49
|
+
for (const sc of subcalls) {
|
|
50
|
+
const list = map.get(sc.parentId) ?? [];
|
|
51
|
+
list.push(sc);
|
|
52
|
+
map.set(sc.parentId, list);
|
|
53
|
+
}
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Collapsed tree (ASCII) ──
|
|
58
|
+
|
|
59
|
+
export function renderCollapsedSubcallTree(
|
|
60
|
+
subcalls: readonly RlmSubcall[],
|
|
61
|
+
theme: Theme,
|
|
62
|
+
): string {
|
|
63
|
+
if (subcalls.length === 0) return "";
|
|
64
|
+
|
|
65
|
+
const byParent = buildParentMap(subcalls);
|
|
66
|
+
|
|
67
|
+
function walk(parentId: string | undefined, prefix: string): string[] {
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
const direct = byParent.get(parentId) ?? [];
|
|
70
|
+
for (let i = 0; i < direct.length; i++) {
|
|
71
|
+
const sc = direct[i];
|
|
72
|
+
if (!sc) continue;
|
|
73
|
+
const isLast = i === direct.length - 1;
|
|
74
|
+
const branch = isLast ? "└─" : "├─";
|
|
75
|
+
const gGlyph = subcallStatusGlyph(sc, theme);
|
|
76
|
+
const gStats = subcallStatsLine(sc);
|
|
77
|
+
lines.push(`${prefix}${branch} ${sc.label} ${gGlyph} ${gStats}`);
|
|
78
|
+
const childPrefix = prefix + (isLast ? " " : "│ ");
|
|
79
|
+
lines.push(...walk(sc.id, childPrefix));
|
|
80
|
+
}
|
|
81
|
+
return lines;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return walk(undefined, " ").join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Expanded tree (Container) ──
|
|
88
|
+
|
|
89
|
+
export function renderExpandedSubcallTree(
|
|
90
|
+
subcalls: readonly RlmSubcall[],
|
|
91
|
+
theme: Theme,
|
|
92
|
+
): Component {
|
|
93
|
+
const container = new Container();
|
|
94
|
+
if (subcalls.length === 0) return container;
|
|
95
|
+
|
|
96
|
+
const byParent = buildParentMap(subcalls);
|
|
97
|
+
|
|
98
|
+
function renderNode(sc: RlmSubcall, indent: number): void {
|
|
99
|
+
const pad = " ".repeat(indent);
|
|
100
|
+
const sGlyph = subcallStatusGlyph(sc, theme);
|
|
101
|
+
const sKind = theme.fg("muted", sc.label);
|
|
102
|
+
const sModel = sc.model ? theme.fg("dim", ` ${sc.model}`) : "";
|
|
103
|
+
const sStats = sc.endedAt ? ` ${theme.fg("dim", subcallStatsLine(sc))}` : "";
|
|
104
|
+
let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}`;
|
|
105
|
+
|
|
106
|
+
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)}`;
|
|
109
|
+
}
|
|
110
|
+
if (sc.status === "error" && sc.detail) {
|
|
111
|
+
line += `\n${pad} ${theme.fg("error", `✗ ${sc.detail}`)}`;
|
|
112
|
+
} 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)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
container.addChild(new Text(line, 0, 0));
|
|
118
|
+
|
|
119
|
+
for (const child of (byParent.get(sc.id) ?? [])) {
|
|
120
|
+
renderNode(child, indent + 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const sc of (byParent.get(undefined) ?? [])) {
|
|
125
|
+
renderNode(sc, 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return container;
|
|
129
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubcallStore — shared subcall state accumulator for RLM lifecycle events.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to RlmEmitter subcall:created / subcall:updated events and
|
|
5
|
+
* accumulates RlmSubcall[] state with O(1) running totals. Used by both
|
|
6
|
+
* RlmEventAggregator (rlm tool) and repl() tool to eliminate duplicated
|
|
7
|
+
* subcall accumulation logic.
|
|
8
|
+
*/
|
|
9
|
+
import type { RlmEmitter, SubcallCreatedEvent, SubcallUpdatedEvent } from "./rlm-events.ts";
|
|
10
|
+
import type { RlmSubcall, SubcallStatus } from "./rlm-details.ts";
|
|
11
|
+
import { EmitterListener } from "./emitter-listener.ts";
|
|
12
|
+
|
|
13
|
+
type MutableSubcall = {
|
|
14
|
+
-readonly [Key in keyof RlmSubcall]: RlmSubcall[Key];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class SubcallStore extends EmitterListener {
|
|
18
|
+
private readonly subcalls = new Map<string, MutableSubcall>();
|
|
19
|
+
|
|
20
|
+
private totalCostUsd = 0;
|
|
21
|
+
private totalTokens = 0;
|
|
22
|
+
|
|
23
|
+
constructor(emitter: RlmEmitter, private readonly onChange?: () => void) {
|
|
24
|
+
super();
|
|
25
|
+
this.trackAll([
|
|
26
|
+
emitter.onSubcallCreated((e) => { this.handleSubcallCreated(e); this.onChange?.(); }),
|
|
27
|
+
emitter.onSubcallUpdated((e) => { this.handleSubcallUpdated(e); this.onChange?.(); }),
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Event handlers ──
|
|
32
|
+
|
|
33
|
+
private handleSubcallCreated(event: SubcallCreatedEvent): void {
|
|
34
|
+
this.subcalls.set(event.id, {
|
|
35
|
+
id: event.id,
|
|
36
|
+
parentId: event.parentId,
|
|
37
|
+
depth: event.depth,
|
|
38
|
+
kind: event.kind,
|
|
39
|
+
label: event.label,
|
|
40
|
+
model: event.model,
|
|
41
|
+
status: "running",
|
|
42
|
+
detail: event.detail,
|
|
43
|
+
args: event.args,
|
|
44
|
+
startedAt: Date.now(),
|
|
45
|
+
costUsd: 0,
|
|
46
|
+
tokens: 0,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private handleSubcallUpdated(event: SubcallUpdatedEvent): void {
|
|
51
|
+
const sc = this.subcalls.get(event.id);
|
|
52
|
+
if (!sc) return;
|
|
53
|
+
|
|
54
|
+
if (event.status !== undefined) {
|
|
55
|
+
sc.status = event.status;
|
|
56
|
+
if (event.status !== "running") sc.endedAt = Date.now();
|
|
57
|
+
}
|
|
58
|
+
if (event.detail !== undefined) sc.detail = event.detail;
|
|
59
|
+
if (event.args !== undefined) sc.args = event.args;
|
|
60
|
+
if (event.resultPreview !== undefined) sc.resultPreview = event.resultPreview;
|
|
61
|
+
if (event.costUsd !== undefined) {
|
|
62
|
+
sc.costUsd += event.costUsd;
|
|
63
|
+
this.totalCostUsd += event.costUsd;
|
|
64
|
+
}
|
|
65
|
+
if (event.tokens !== undefined) {
|
|
66
|
+
sc.tokens += event.tokens;
|
|
67
|
+
this.totalTokens += event.tokens;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Read ──
|
|
72
|
+
|
|
73
|
+
/** Snapshot subcall array. Allocates a new array from Map values. */
|
|
74
|
+
getSubcalls(): RlmSubcall[] {
|
|
75
|
+
return Array.from(this.subcalls.values(), (subcall) => Object.freeze({ ...subcall, status: subcall.status as SubcallStatus }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Snapshot running totals. O(1). */
|
|
79
|
+
getTotals(): { readonly costUsd: number; readonly tokens: number } {
|
|
80
|
+
return { costUsd: this.totalCostUsd, tokens: this.totalTokens };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Root usage (delegated from RlmEventAggregator) ──
|
|
84
|
+
|
|
85
|
+
/** Accumulate root-level usage into shared totals. Called by aggregator. */
|
|
86
|
+
addRootUsage(costUsd: number, tokens: number): void {
|
|
87
|
+
this.totalCostUsd += costUsd;
|
|
88
|
+
this.totalTokens += tokens;
|
|
89
|
+
}
|
|
90
|
+
}
|