@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,73 @@
|
|
|
1
|
+
/** Shared helpers for Pi tool implementations. */
|
|
2
|
+
|
|
3
|
+
import type { Static, TSchema } from "typebox";
|
|
4
|
+
import { Value } from "typebox/value";
|
|
5
|
+
import { err, ok, type Result } from "../util/errors.ts";
|
|
6
|
+
|
|
7
|
+
export interface TextToolResponse<Details> {
|
|
8
|
+
readonly content: { readonly type: "text"; readonly text: string }[];
|
|
9
|
+
readonly details: Details;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateToolParams<Schema extends TSchema, Details>(
|
|
13
|
+
schema: Schema,
|
|
14
|
+
rawParams: unknown,
|
|
15
|
+
toolName: string,
|
|
16
|
+
createErrorDetails: (errors: string) => Details,
|
|
17
|
+
): Result<Static<Schema>, TextToolResponse<Details>> {
|
|
18
|
+
if (Value.Check(schema, rawParams)) return ok(rawParams as Static<Schema>);
|
|
19
|
+
const errors = [...Value.Errors(schema, rawParams)]
|
|
20
|
+
.map((error) => `${error.instancePath}: ${error.message}`)
|
|
21
|
+
.join("; ");
|
|
22
|
+
return err({
|
|
23
|
+
content: [{ type: "text", text: `Invalid ${toolName} parameters: ${errors}` }],
|
|
24
|
+
details: createErrorDetails(errors),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TextUpdateCallback<Details> = (update: TextToolResponse<Details>) => void;
|
|
29
|
+
|
|
30
|
+
export interface ProgressNotifierOptions<Details> {
|
|
31
|
+
readonly onUpdate?: TextUpdateCallback<Details>;
|
|
32
|
+
readonly getDetails: () => Details;
|
|
33
|
+
readonly isRunning: (details: Details) => boolean;
|
|
34
|
+
readonly renderText: (details: Details) => string;
|
|
35
|
+
readonly intervalMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProgressNotifier {
|
|
39
|
+
readonly notify: () => void;
|
|
40
|
+
readonly start: () => void;
|
|
41
|
+
readonly stop: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createProgressNotifier<Details>(opts: ProgressNotifierOptions<Details>): ProgressNotifier {
|
|
45
|
+
let handle: ReturnType<typeof setInterval> | undefined;
|
|
46
|
+
|
|
47
|
+
const stop = (): void => {
|
|
48
|
+
if (handle === undefined) return;
|
|
49
|
+
clearInterval(handle);
|
|
50
|
+
handle = undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const notify = (): void => {
|
|
54
|
+
if (!opts.onUpdate) return;
|
|
55
|
+
const details = opts.getDetails();
|
|
56
|
+
opts.onUpdate({ content: [{ type: "text", text: opts.renderText(details) }], details });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const start = (): void => {
|
|
60
|
+
if (!opts.onUpdate || handle !== undefined) return;
|
|
61
|
+
notify();
|
|
62
|
+
handle = setInterval(() => {
|
|
63
|
+
const details = opts.getDetails();
|
|
64
|
+
if (!opts.isRunning(details)) {
|
|
65
|
+
stop();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
opts.onUpdate?.({ content: [{ type: "text", text: opts.renderText(details) }], details });
|
|
69
|
+
}, opts.intervalMs ?? 100);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return Object.freeze({ notify, start, stop });
|
|
73
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Config panel TUI — toggle RLM run parameters with descriptions. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { RlmConfig } from "../core/types.ts";
|
|
7
|
+
|
|
8
|
+
const CHOICES = Object.freeze({
|
|
9
|
+
maxDepth: Object.freeze(["1", "2", "3", "4"]),
|
|
10
|
+
maxIterations: Object.freeze(["10", "20", "30", "50"]),
|
|
11
|
+
execTimeoutS: Object.freeze(["30", "60", "120", "300"]),
|
|
12
|
+
maxConcurrentSubcalls: Object.freeze(["2", "4", "8", "16"]),
|
|
13
|
+
maxBudgetUsd: Object.freeze(["none", "0.50", "1", "5"]),
|
|
14
|
+
maxTimeoutMs: Object.freeze(["none", "60", "120", "300"]),
|
|
15
|
+
maxTokens: Object.freeze(["none", "10000", "50000", "100000"]),
|
|
16
|
+
maxErrors: Object.freeze(["3", "5", "10", "none"]),
|
|
17
|
+
orchestrator: Object.freeze(["on", "off"]),
|
|
18
|
+
compaction: Object.freeze(["on", "off"]),
|
|
19
|
+
rootSamplingMaxTokens: Object.freeze(["4096", "8192", "16384", "32768"]),
|
|
20
|
+
sandboxInitTimeoutMs: Object.freeze(["10000", "30000", "60000", "120000"]),
|
|
21
|
+
askUserQuestion: Object.freeze(["on", "off"]),
|
|
22
|
+
todo: Object.freeze(["on", "off"]),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function item(id: string, label: string, currentValue: string, values: readonly string[], description: string): SettingItem {
|
|
26
|
+
return { id, label, currentValue, values: [...values], description };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig): Promise<void> {
|
|
30
|
+
if (ctx.mode !== "tui") return;
|
|
31
|
+
const items: SettingItem[] = [
|
|
32
|
+
item("maxDepth", "Max recursion depth", String(config.maxDepth), CHOICES.maxDepth, "rlm_query past this depth degrades to plain llm_query (1 = no recursion)."),
|
|
33
|
+
item("maxIterations", "Max iterations", String(config.maxIterations), CHOICES.maxIterations, "Maximum root REPL turns before RLM asks the model for a final answer."),
|
|
34
|
+
item("execTimeoutS", "REPL block timeout (s)", String(config.execTimeoutS), CHOICES.execTimeoutS, "Wall-clock limit for one model-authored Python REPL block."),
|
|
35
|
+
item("maxConcurrentSubcalls", "Max concurrent sub-calls", String(config.maxConcurrentSubcalls), CHOICES.maxConcurrentSubcalls, "Concurrency pool size for llm_query_batched and rlm_query_batched."),
|
|
36
|
+
item("maxBudgetUsd", "Budget ceiling (USD)", config.maxBudgetUsd != null ? String(config.maxBudgetUsd) : "none", CHOICES.maxBudgetUsd, "Total spend cap for the whole recursive tree; none disables the cap."),
|
|
37
|
+
item("maxTimeoutMs", "Wall-clock ceiling (min)", config.maxTimeoutMs != null ? String(Math.round(config.maxTimeoutMs / 60_000)) : "none", CHOICES.maxTimeoutMs, "Total runtime cap for the whole recursive tree; none disables the cap."),
|
|
38
|
+
item("maxTokens", "Token ceiling", config.maxTokens != null ? String(config.maxTokens) : "none", CHOICES.maxTokens, "Total input+output token cap for the whole recursive tree."),
|
|
39
|
+
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."),
|
|
40
|
+
item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
|
|
41
|
+
item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
|
|
42
|
+
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."),
|
|
43
|
+
item("sandboxInitTimeoutMs", "Sandbox init timeout", String(config.sandboxInitTimeoutMs), CHOICES.sandboxInitTimeoutMs, "How long to wait for the Python worker to start."),
|
|
44
|
+
item("askUserQuestion", "[Interactive] Ask user", config.askUserQuestion ? "on" : "off", CHOICES.askUserQuestion, "Allow root REPL code to present structured ask_user_question dialogs."),
|
|
45
|
+
item("todo", "[Interactive] Todo", config.todo ? "on" : "off", CHOICES.todo, "Allow REPL code to manage a visible todo task list."),
|
|
46
|
+
item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
50
|
+
const container = new Container();
|
|
51
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("RLM settings")), 1, 1));
|
|
52
|
+
const list = new SettingsList(
|
|
53
|
+
items,
|
|
54
|
+
items.length + 2,
|
|
55
|
+
getSettingsListTheme(),
|
|
56
|
+
(id, value) => {
|
|
57
|
+
if (id === "__save__") {
|
|
58
|
+
done();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
applySetting(config, id, value);
|
|
62
|
+
},
|
|
63
|
+
() => done(),
|
|
64
|
+
);
|
|
65
|
+
container.addChild(list);
|
|
66
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ move · enter change · esc save & close"), 1, 1));
|
|
67
|
+
return {
|
|
68
|
+
render: (w) => container.render(w),
|
|
69
|
+
invalidate: () => container.invalidate(),
|
|
70
|
+
handleInput: (data) => list.handleInput?.(data),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function applySetting(config: RlmConfig, id: string, value: string): void {
|
|
76
|
+
switch (id) {
|
|
77
|
+
case "maxDepth": config.maxDepth = Number(value); break;
|
|
78
|
+
case "maxIterations": config.maxIterations = Number(value); break;
|
|
79
|
+
case "execTimeoutS": config.execTimeoutS = Number(value); break;
|
|
80
|
+
case "maxConcurrentSubcalls": config.maxConcurrentSubcalls = Number(value); break;
|
|
81
|
+
case "maxBudgetUsd": config.maxBudgetUsd = value === "none" ? undefined : Number(value); break;
|
|
82
|
+
case "maxTimeoutMs": config.maxTimeoutMs = value === "none" ? undefined : Number(value) * 60_000; break;
|
|
83
|
+
case "maxTokens": config.maxTokens = value === "none" ? undefined : Number(value); break;
|
|
84
|
+
case "maxErrors": config.maxErrors = value === "none" ? undefined : Number(value); break;
|
|
85
|
+
case "orchestrator": config.orchestrator = value === "on"; break;
|
|
86
|
+
case "compaction": config.compaction = value === "on"; break;
|
|
87
|
+
case "rootSamplingMaxTokens": config.rootSampling = Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }); break;
|
|
88
|
+
case "sandboxInitTimeoutMs": config.sandboxInitTimeoutMs = Number(value); break;
|
|
89
|
+
case "askUserQuestion": config.askUserQuestion = value === "on"; break;
|
|
90
|
+
case "todo": config.todo = value === "on"; break;
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/ui/intro.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Startup/help guide card for RLM mode. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
5
|
+
import { formatRlmStateLine } from "./status.ts";
|
|
6
|
+
|
|
7
|
+
export const RLM_GUIDE = `# RLM mode
|
|
8
|
+
|
|
9
|
+
{state}
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
- \`/rlm\` — toggle RLM mode (shortcut: Ctrl+Shift+R). Turning it OFF also stops a running query.
|
|
14
|
+
- \`/rlm-config\` — choose models, reasoning, and budget limits
|
|
15
|
+
- \`/rlm-stop\` — cancel the current run but stay in RLM mode (use /rlm or Ctrl+Shift+R to leave)
|
|
16
|
+
- \`/rlm-help\` — show this guide again
|
|
17
|
+
|
|
18
|
+
When RLM mode is ON, plain messages route to RLM. The footer/status line shows the current state.`;
|
|
19
|
+
|
|
20
|
+
export function postRlmGuide(pi: ExtensionAPI, controller: RlmController): void {
|
|
21
|
+
const content = RLM_GUIDE.replace("{state}", formatRlmStateLine(controller));
|
|
22
|
+
pi.sendMessage({ customType: "rlm-intro", content, display: true });
|
|
23
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** Model picker TUI — choose a model and, when supported, a thinking level. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
|
+
import { Container, type Component, type SelectItem, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { formatCost } from "./theme.ts";
|
|
8
|
+
|
|
9
|
+
export interface ModelSelection {
|
|
10
|
+
readonly model: Model<Api>;
|
|
11
|
+
readonly thinkingLevel?: ThinkingLevel;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
15
|
+
type SelectableThinkingLevel = (typeof LEVELS)[number];
|
|
16
|
+
|
|
17
|
+
function items(models: Model<Api>[]): SelectItem[] {
|
|
18
|
+
return models.map((m) => ({
|
|
19
|
+
value: `${m.provider}/${m.id}`,
|
|
20
|
+
label: `${m.provider}/${m.id}`,
|
|
21
|
+
description: `in ${formatCost(m.cost.input)}/Mtok · out ${formatCost(m.cost.output)}/Mtok${m.reasoning ? " · reasoning" : ""}`,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function supportedThinkingLevels(model: Model<Api>): SelectableThinkingLevel[] {
|
|
26
|
+
if (!model.reasoning) return [];
|
|
27
|
+
const map = model.thinkingLevelMap;
|
|
28
|
+
if (!map) return [...LEVELS];
|
|
29
|
+
return LEVELS.filter((level) => map[level] !== null);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function selectThinkingLevel(
|
|
33
|
+
ctx: ExtensionContext,
|
|
34
|
+
model: Model<Api>,
|
|
35
|
+
current?: ThinkingLevel,
|
|
36
|
+
): Promise<ThinkingLevel | undefined> {
|
|
37
|
+
const levels = supportedThinkingLevels(model);
|
|
38
|
+
if (levels.length === 0) return undefined;
|
|
39
|
+
if (ctx.mode !== "tui") {
|
|
40
|
+
const level = current ?? levels[0];
|
|
41
|
+
return level === "off" ? undefined : level;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const chosen = await ctx.ui.custom<SelectableThinkingLevel | null>((_tui, theme, _kb, done) => {
|
|
45
|
+
const container = new Container();
|
|
46
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
47
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Thinking level")), 1, 0));
|
|
48
|
+
const list = new SelectList(
|
|
49
|
+
levels.map((level) => ({ value: level, label: level, description: `Use ${level} reasoning for ${model.id}` })),
|
|
50
|
+
levels.length,
|
|
51
|
+
{
|
|
52
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
53
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
54
|
+
description: (t) => theme.fg("muted", t),
|
|
55
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
56
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
const initial = levels.indexOf(current ?? "off");
|
|
60
|
+
if (initial >= 0) list.setSelectedIndex(initial);
|
|
61
|
+
list.onSelect = (item) => done(item.value as SelectableThinkingLevel);
|
|
62
|
+
list.onCancel = () => done(null);
|
|
63
|
+
container.addChild(list);
|
|
64
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc skip"), 1, 0));
|
|
65
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
66
|
+
return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => list.handleInput(data) };
|
|
67
|
+
});
|
|
68
|
+
return chosen === "off" ? undefined : (chosen ?? undefined);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Show a model selector; resolves to the chosen model plus optional thinking level. */
|
|
72
|
+
export async function selectModel(
|
|
73
|
+
ctx: ExtensionContext,
|
|
74
|
+
title: string,
|
|
75
|
+
models: Model<Api>[],
|
|
76
|
+
current?: Model<Api>,
|
|
77
|
+
currentThinking?: ThinkingLevel,
|
|
78
|
+
): Promise<ModelSelection | undefined> {
|
|
79
|
+
if (models.length === 0) {
|
|
80
|
+
ctx.ui.notify("RLM: no models with configured auth", "warning");
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
if (ctx.mode !== "tui") {
|
|
84
|
+
const fallback = models[0];
|
|
85
|
+
if (!fallback) return undefined;
|
|
86
|
+
const model = current ?? fallback;
|
|
87
|
+
return { model, thinkingLevel: await selectThinkingLevel(ctx, model, currentThinking) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const chosen = await ctx.ui.custom<string | null>((_tui, theme, _kb, done) => {
|
|
91
|
+
let query = "";
|
|
92
|
+
const container = new Container();
|
|
93
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
94
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
95
|
+
const filterLine: Component = {
|
|
96
|
+
render: (w) => [truncateToWidth(theme.fg("dim", `Filter: ${query || "type to filter…"}`), w)],
|
|
97
|
+
invalidate: () => {},
|
|
98
|
+
};
|
|
99
|
+
const list = new SelectList(items(models), Math.min(models.length, 12), {
|
|
100
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
101
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
102
|
+
description: (t) => theme.fg("muted", t),
|
|
103
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
104
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
105
|
+
});
|
|
106
|
+
const isFilterText = (s: string): boolean => {
|
|
107
|
+
const sanitized = s.replace(/ /g, "");
|
|
108
|
+
return sanitized.length > 0 && Array.from(sanitized).every((char) => char >= " " && char !== "\x7f");
|
|
109
|
+
};
|
|
110
|
+
const isBackspace = (s: string): boolean => s === "\x7f" || s === "\b";
|
|
111
|
+
list.onSelect = (item) => done(item.value);
|
|
112
|
+
list.onCancel = () => done(null);
|
|
113
|
+
container.addChild(filterLine);
|
|
114
|
+
container.addChild(list);
|
|
115
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • type to filter • enter select • esc cancel"), 1, 0));
|
|
116
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
117
|
+
return {
|
|
118
|
+
render: (w) => container.render(w),
|
|
119
|
+
invalidate: () => container.invalidate(),
|
|
120
|
+
handleInput: (data) => {
|
|
121
|
+
if (isFilterText(data)) {
|
|
122
|
+
query = `${query}${data.replace(/ /g, "")}`;
|
|
123
|
+
list.setFilter(query);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (isBackspace(data)) {
|
|
127
|
+
query = query.slice(0, -1);
|
|
128
|
+
list.setFilter(query);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
list.handleInput(data);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const model = chosen ? models.find((m) => `${m.provider}/${m.id}` === chosen) : undefined;
|
|
137
|
+
if (!model) return undefined;
|
|
138
|
+
return { model, thinkingLevel: await selectThinkingLevel(ctx, model, currentThinking) };
|
|
139
|
+
}
|
package/src/ui/status.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Footer status line for RLM mode and active runs. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
|
+
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
6
|
+
|
|
7
|
+
const KEY = "rlm";
|
|
8
|
+
|
|
9
|
+
export function modelLabel(model: Model<Api> | undefined, fallback: string): string {
|
|
10
|
+
return model ? `${model.provider}/${model.id}` : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatRlmStateLine(controller: RlmController): string {
|
|
14
|
+
if (!controller.enabled) return "○ RLM OFF";
|
|
15
|
+
const worker = modelLabel(controller.workerModel, controller.savedWorkerRef ?? "cheapest");
|
|
16
|
+
const workerSuffix = controller.config.subSampling.reasoning ? `:${controller.config.subSampling.reasoning}` : "";
|
|
17
|
+
return `● RLM ON · worker=${worker}${workerSuffix}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setRlmModeStatus(ui: ExtensionUIContext, controller: RlmController): void {
|
|
21
|
+
ui.setStatus(KEY, formatRlmStateLine(controller));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clearRlmStatus(ui: ExtensionUIContext): void {
|
|
25
|
+
ui.setStatus(KEY, undefined);
|
|
26
|
+
}
|
package/src/ui/theme.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Small presentation helpers shared by the RLM widgets (glyphs, spinner, formatting). */
|
|
2
|
+
|
|
3
|
+
import type { SubcallKind, SubcallStatus } from "../tool/rlm-details.ts";
|
|
4
|
+
|
|
5
|
+
export const SPINNER = Object.freeze(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]);
|
|
6
|
+
|
|
7
|
+
export function spinnerFrame(): string {
|
|
8
|
+
return SPINNER[Math.floor(Date.now() / 100) % SPINNER.length] ?? "⠋";
|
|
9
|
+
}
|
|
10
|
+
|
|
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
|
+
export function formatCost(usd: number): string {
|
|
35
|
+
return `$${usd.toFixed(usd < 1 ? 4 : 2)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatTokens(n: number): string {
|
|
39
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
40
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
41
|
+
return String(n);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatDuration(ms: number): string {
|
|
45
|
+
const s = ms / 1000;
|
|
46
|
+
return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Fixed-size concurrency pool: run `fn` over `items` with at most `limit` in flight, preserving order. */
|
|
2
|
+
export async function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T, idx: number) => Promise<R>): Promise<R[]> {
|
|
3
|
+
const out = new Array<R>(items.length);
|
|
4
|
+
let next = 0;
|
|
5
|
+
const worker = async (): Promise<void> => {
|
|
6
|
+
while (true) {
|
|
7
|
+
const index = next;
|
|
8
|
+
next += 1;
|
|
9
|
+
if (index >= items.length) return;
|
|
10
|
+
out[index] = await fn(items[index], index);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Shared result and error-string helpers. */
|
|
2
|
+
|
|
3
|
+
export type Result<T, E> =
|
|
4
|
+
| { readonly ok: true; readonly value: T }
|
|
5
|
+
| { readonly ok: false; readonly error: E };
|
|
6
|
+
|
|
7
|
+
export function ok<T, E = never>(value: T): Result<T, E> {
|
|
8
|
+
return { ok: true, value };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function err<T = never, E = string>(error: E): Result<T, E> {
|
|
12
|
+
return { ok: false, error };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ERROR_PREFIX = "Error:";
|
|
16
|
+
|
|
17
|
+
export function formatError(message: string): string {
|
|
18
|
+
return `${ERROR_PREFIX} ${message}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isErrorText(text: string): boolean {
|
|
22
|
+
return text.startsWith(`${ERROR_PREFIX} `);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function errorMessage(error: unknown): string {
|
|
26
|
+
return error instanceof Error ? error.message : String(error);
|
|
27
|
+
}
|