@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.
- package/package.json +1 -1
- package/src/bridge/llm-query.ts +59 -36
- package/src/bridge/rlm-query.ts +63 -77
- package/src/commands/rlm-config.ts +8 -8
- package/src/commands/rlm.ts +48 -12
- 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 +48 -249
- 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 +35 -17
- package/src/mode/rlm-mode.ts +8 -11
- package/src/prompts/system.ts +143 -52
- package/src/prompts/user.ts +1 -5
- package/src/sandbox/protocol.ts +0 -7
- package/src/sandbox/sandbox-manager.ts +5 -5
- package/src/sandbox/sandbox.ts +25 -19
- package/src/sandbox/worker.py +354 -0
- 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/repl-tool.ts +105 -284
- package/src/tool/rlm-details.ts +0 -10
- package/src/tool/rlm-tool.ts +18 -31
- package/src/tool/subcall-render.ts +61 -9
- package/src/tool/subcall-store.ts +2 -2
- 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/mode/input-router.ts +0 -23
package/package.json
CHANGED
package/src/bridge/llm-query.ts
CHANGED
|
@@ -5,88 +5,112 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Caps enforce the divide-and-conquer budget from the RLM method: per-prompt size and batch
|
|
7
7
|
* fan-out are bounded, and batches run through a fixed-size concurrency pool.
|
|
8
|
+
*
|
|
9
|
+
* Every input that can change between calls (worker model, emitter, parent node, depth,
|
|
10
|
+
* remaining budget) is an accessor, so a single bridge instance serves both the headless
|
|
11
|
+
* engine — which binds them once per run — and the native `repl` tool, which swaps them per
|
|
12
|
+
* invocation without recreating the sandbox.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
11
16
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
12
17
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
13
|
-
import {
|
|
18
|
+
import { displayModelRef, resolveModelId } from "../config/settings.ts";
|
|
14
19
|
import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
|
|
15
20
|
import type { Sampling } from "../core/types.ts";
|
|
16
21
|
import { type ChatMsg, modelComplete } from "./model.ts";
|
|
17
22
|
import { previewText } from "../text/preview.ts";
|
|
18
|
-
import { formatError, isErrorText } from "../util/errors.ts";
|
|
23
|
+
import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
|
|
19
24
|
import { mapPool } from "../util/concurrency.ts";
|
|
20
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The config slice this bridge reads. Structurally satisfied by `RlmConfig`, and re-read on
|
|
28
|
+
* every call so `/rlm-config` changes take effect without rebuilding the sandbox.
|
|
29
|
+
*/
|
|
30
|
+
export interface LlmBridgeConfig {
|
|
31
|
+
readonly maxPromptChars: number;
|
|
32
|
+
readonly maxConcurrentSubcalls: number;
|
|
33
|
+
readonly subSystemPrompt?: string;
|
|
34
|
+
readonly subSampling?: Sampling;
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
export interface LlmBridgeOptions {
|
|
22
|
-
|
|
38
|
+
/** Resolved per call so provider/config changes between calls are picked up. */
|
|
39
|
+
readonly workerModel: () => Model<Api>;
|
|
23
40
|
readonly registry: ModelRegistry;
|
|
24
|
-
readonly
|
|
25
|
-
readonly maxPromptChars?: number;
|
|
26
|
-
readonly maxConcurrent?: number;
|
|
27
|
-
readonly sampling?: Sampling;
|
|
41
|
+
readonly config: () => LlmBridgeConfig;
|
|
28
42
|
readonly signal?: AbortSignal;
|
|
29
43
|
readonly onUsage?: (usage: Usage, model: Model<Api>) => void;
|
|
30
44
|
/** Parent run's remaining budget/timeout; checked before every sub-call. */
|
|
31
|
-
readonly remainingBudget?: () => RemainingResources;
|
|
32
|
-
/** Live RlmDetails reporting
|
|
33
|
-
readonly emitter?: RlmEmitter;
|
|
34
|
-
readonly parentId?: string;
|
|
35
|
-
readonly depth?: number;
|
|
45
|
+
readonly remainingBudget?: () => RemainingResources | undefined;
|
|
46
|
+
/** Live RlmDetails reporting target, resolved per call. */
|
|
47
|
+
readonly emitter?: () => RlmEmitter | undefined;
|
|
48
|
+
readonly parentId?: () => string | undefined;
|
|
49
|
+
readonly depth?: () => number;
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
|
|
39
|
-
const
|
|
52
|
+
/** Provider failures that a smaller batch or a cheaper model might get past. */
|
|
53
|
+
const RETRYABLE_HINT = /credit|402|payment|quota|rate.limit/i;
|
|
40
54
|
|
|
41
55
|
export interface LlmBridge {
|
|
42
56
|
llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
43
|
-
llmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
|
|
57
|
+
llmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Batch outcome summary, or undefined when every prompt succeeded. */
|
|
61
|
+
function batchError(failed: number, total: number): string | undefined {
|
|
62
|
+
if (failed === 0) return undefined;
|
|
63
|
+
return failed === total
|
|
64
|
+
? `all ${total} sub-calls failed — reduce batch size or try llm_query individually`
|
|
65
|
+
: `${failed}/${total} sub-calls failed`;
|
|
44
66
|
}
|
|
45
67
|
|
|
46
68
|
export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
|
|
47
|
-
const maxPromptChars = opts.maxPromptChars ?? DEFAULT_MAX_PROMPT_CHARS;
|
|
48
|
-
const maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
49
|
-
const { emitter } = opts;
|
|
50
69
|
const displayModel = (model: string | null): string =>
|
|
51
|
-
|
|
70
|
+
displayModelRef(opts.registry, model, opts.workerModel());
|
|
52
71
|
|
|
53
72
|
// Run one completion; report cost/tokens via `track` (a per-call or per-batch accumulator).
|
|
54
73
|
async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
|
|
74
|
+
const config = opts.config();
|
|
55
75
|
const rem = opts.remainingBudget?.();
|
|
56
76
|
if (rem !== undefined) {
|
|
57
77
|
const limitError = checkResourceLimits(rem);
|
|
58
78
|
if (limitError !== undefined) return limitError;
|
|
59
79
|
}
|
|
60
|
-
if (prompt.length > maxPromptChars) {
|
|
61
|
-
return formatError(`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ${maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`);
|
|
80
|
+
if (prompt.length > config.maxPromptChars) {
|
|
81
|
+
return formatError(`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ${config.maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`);
|
|
62
82
|
}
|
|
63
83
|
const resolved = model ? resolveModelId(opts.registry, model) : undefined;
|
|
64
84
|
if (model && !resolved) return formatError(`unknown model override '${model}'`);
|
|
85
|
+
const target = resolved ?? opts.workerModel();
|
|
65
86
|
try {
|
|
66
87
|
const messages: ChatMsg[] = [{ role: "user", content: prompt }];
|
|
67
88
|
const res = await modelComplete(messages, {
|
|
68
|
-
model:
|
|
89
|
+
model: target,
|
|
69
90
|
registry: opts.registry,
|
|
70
|
-
system:
|
|
71
|
-
maxTokens:
|
|
72
|
-
temperature:
|
|
73
|
-
reasoning:
|
|
91
|
+
system: config.subSystemPrompt,
|
|
92
|
+
maxTokens: config.subSampling?.maxTokens,
|
|
93
|
+
temperature: config.subSampling?.temperature,
|
|
94
|
+
reasoning: config.subSampling?.reasoning,
|
|
74
95
|
signal: opts.signal,
|
|
75
96
|
});
|
|
76
|
-
opts.onUsage?.(res.usage,
|
|
97
|
+
opts.onUsage?.(res.usage, target);
|
|
77
98
|
track(res.usage);
|
|
78
99
|
return res.text;
|
|
79
100
|
} catch (err) {
|
|
80
|
-
|
|
101
|
+
const msg = errorMessage(err);
|
|
102
|
+
const hint = RETRYABLE_HINT.test(msg) ? " — try smaller batches or individual llm_query calls" : "";
|
|
103
|
+
return formatError(`${msg}${hint}`);
|
|
81
104
|
}
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
return {
|
|
85
108
|
async llmQuery(prompt, model) {
|
|
109
|
+
const emitter = opts.emitter?.();
|
|
86
110
|
const id = emitter?.emitSubcallCreated({
|
|
87
|
-
kind: "llm", parentId: opts.parentId, label: "llm_query",
|
|
111
|
+
kind: "llm", parentId: opts.parentId?.(), label: "llm_query",
|
|
88
112
|
model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
|
|
89
|
-
depth: opts.depth ?? 0,
|
|
113
|
+
depth: opts.depth?.() ?? 0,
|
|
90
114
|
});
|
|
91
115
|
let cost = 0;
|
|
92
116
|
let tokens = 0;
|
|
@@ -103,23 +127,22 @@ export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
|
|
|
103
127
|
},
|
|
104
128
|
|
|
105
129
|
async llmQueryBatched(prompts, model) {
|
|
130
|
+
const emitter = opts.emitter?.();
|
|
106
131
|
const id = emitter?.emitSubcallCreated({
|
|
107
|
-
kind: "batch", parentId: opts.parentId, label: `llm_query ×${prompts.length}`,
|
|
132
|
+
kind: "batch", parentId: opts.parentId?.(), label: `llm_query ×${prompts.length}`,
|
|
108
133
|
model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
|
|
109
|
-
depth: opts.depth ?? 0,
|
|
134
|
+
depth: opts.depth?.() ?? 0,
|
|
110
135
|
});
|
|
111
136
|
let cost = 0;
|
|
112
137
|
let tokens = 0;
|
|
113
|
-
const out = await mapPool(prompts,
|
|
138
|
+
const out = await mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) =>
|
|
114
139
|
complete1(p, model, (u) => {
|
|
115
140
|
cost += u.cost.total;
|
|
116
141
|
tokens += u.totalTokens;
|
|
117
142
|
}),
|
|
118
143
|
);
|
|
119
144
|
const failed = out.filter(isErrorText).length;
|
|
120
|
-
const error = failed
|
|
121
|
-
? `all ${out.length} sub-calls failed`
|
|
122
|
-
: failed > 0 ? `${failed}/${out.length} sub-calls failed` : undefined;
|
|
145
|
+
const error = batchError(failed, out.length);
|
|
123
146
|
const firstPreview = previewText(out[0] ?? "");
|
|
124
147
|
const resultPreview = out.length > 1 ? `${firstPreview} (+${out.length - 1} more)` : firstPreview;
|
|
125
148
|
if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
|
package/src/bridge/rlm-query.ts
CHANGED
|
@@ -5,118 +5,104 @@
|
|
|
5
5
|
* depth cap it degrades to a plain `llm_query` (ported from rlm/core/rlm.py `_subcall`). The
|
|
6
6
|
* concurrency pool bounds parallel children for `rlm_query_batched`.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* As with the llm bridge, everything that can change between calls is an accessor, so the
|
|
9
|
+
* headless engine (which binds them once per run) and the native `repl` tool (which swaps them
|
|
10
|
+
* per invocation) share one implementation.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import type { RlmResult, RunRlm } from "../core/types.ts";
|
|
12
14
|
import type { LlmBridge } from "./llm-query.ts";
|
|
13
15
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
14
|
-
import { checkResourceLimits } from "../core/resource-limits.ts";
|
|
15
|
-
import { formatError } from "../util/errors.ts";
|
|
16
|
+
import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
|
|
17
|
+
import { errorMessage, formatError } from "../util/errors.ts";
|
|
16
18
|
import { mapPool } from "../util/concurrency.ts";
|
|
19
|
+
import { previewText } from "../text/preview.ts";
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
readonly
|
|
21
|
-
readonly
|
|
22
|
-
readonly label?: string;
|
|
23
|
-
readonly model?: string | null;
|
|
21
|
+
/** The config slice this bridge reads; structurally satisfied by `RlmConfig`. */
|
|
22
|
+
export interface RlmBridgeConfig {
|
|
23
|
+
readonly maxDepth: number;
|
|
24
|
+
readonly maxConcurrentSubcalls: number;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export interface RlmHandlers {
|
|
27
28
|
rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
28
|
-
rlmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
|
|
29
|
-
/** Full child-run result (answer + usage) for recursive spawns. */
|
|
30
|
-
childRun(input: ChildRunInput): Promise<RlmResult>;
|
|
29
|
+
rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
export interface RlmBridgeOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Spawns one child run. The engine passes its own `run` (self-recursion); the native tool
|
|
35
|
+
* passes a closure that builds a child engine bound to the current invocation's emitter.
|
|
36
|
+
*/
|
|
34
37
|
readonly run: RunRlm;
|
|
35
38
|
readonly llm: LlmBridge;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
readonly
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
readonly config: () => RlmBridgeConfig;
|
|
40
|
+
/** "provider/id" shown on the sub-call node; see `displayModelRef` in config/settings.ts. */
|
|
41
|
+
readonly modelLabel?: (override: string | null) => string;
|
|
42
|
+
/** Live RlmDetails reporting target, resolved per call. */
|
|
43
|
+
readonly emitter: () => RlmEmitter | undefined;
|
|
44
|
+
/** Parent subcall ID that this run is attached under, resolved per call. */
|
|
45
|
+
readonly parentNodeId?: () => string | undefined;
|
|
42
46
|
/** Returns the parent's remaining budget/timeout for seeding child runs. */
|
|
43
|
-
readonly remainingBudget?: () =>
|
|
47
|
+
readonly remainingBudget?: () => RemainingResources | undefined;
|
|
44
48
|
/** Called with a child run's total cost/tokens so the parent LimitGuard debits it. */
|
|
45
49
|
readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
function emptyResult(answer: string): RlmResult {
|
|
49
|
-
return {
|
|
50
|
-
answer,
|
|
51
|
-
iterations: 0,
|
|
52
|
-
costUsd: 0,
|
|
53
|
-
inputTokens: 0,
|
|
54
|
-
outputTokens: 0,
|
|
55
|
-
durationMs: 0,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
52
|
export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
/**
|
|
54
|
+
* One child spawn. `childDepth` is the absolute depth the child will run at.
|
|
55
|
+
* Never throws: failures come back as "Error: ..." strings, matching the sandbox contract.
|
|
56
|
+
*/
|
|
57
|
+
async function child(prompt: string, model: string | null, childDepth: number): Promise<string> {
|
|
62
58
|
// At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const answer = await opts.llm.llmQuery(
|
|
66
|
-
input.rootPrompt || String(input.context),
|
|
67
|
-
input.model ?? null,
|
|
68
|
-
childDepth - 1,
|
|
69
|
-
);
|
|
70
|
-
return emptyResult(answer);
|
|
59
|
+
if (childDepth >= opts.config().maxDepth) {
|
|
60
|
+
return opts.llm.llmQuery(prompt, model, childDepth - 1);
|
|
71
61
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
62
|
+
|
|
63
|
+
const rem = opts.remainingBudget?.();
|
|
64
|
+
if (rem !== undefined) {
|
|
75
65
|
// Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
|
|
76
66
|
// (reference: _subcall checks remaining_budget/timeout before spawning).
|
|
77
67
|
const limitError = checkResourceLimits(rem);
|
|
78
|
-
if (limitError) return
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
68
|
+
if (limitError !== undefined) return limitError;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const emitter = opts.emitter();
|
|
72
|
+
const subId = emitter?.emitSubcallCreated({
|
|
73
|
+
kind: "rlm", parentId: opts.parentNodeId?.(), label: "rlm_query",
|
|
74
|
+
model: opts.modelLabel?.(model) ?? model ?? undefined,
|
|
75
|
+
detail: prompt.slice(0, 60),
|
|
76
|
+
depth: childDepth,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const res: RlmResult = await opts.run({
|
|
81
|
+
rootPrompt: "",
|
|
82
|
+
context: prompt,
|
|
89
83
|
depth: childDepth,
|
|
90
84
|
parentNodeId: subId,
|
|
91
|
-
modelOverride:
|
|
92
|
-
remainingBudgetUsd: rem
|
|
93
|
-
remainingTimeoutMs: rem
|
|
85
|
+
modelOverride: model ?? undefined,
|
|
86
|
+
remainingBudgetUsd: rem?.budgetUsd,
|
|
87
|
+
remainingTimeoutMs: rem?.timeoutMs,
|
|
94
88
|
});
|
|
95
89
|
opts.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
// The child emits live cost/token deltas on the shared emitter as it runs, so the node
|
|
91
|
+
// must NOT also receive a final aggregate — that would double-count.
|
|
92
|
+
if (emitter && subId !== undefined) {
|
|
93
|
+
emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: previewText(res.answer) });
|
|
94
|
+
}
|
|
95
|
+
return res.answer;
|
|
100
96
|
} catch (err) {
|
|
101
|
-
const msg =
|
|
102
|
-
if (subId)
|
|
103
|
-
return
|
|
97
|
+
const msg = errorMessage(err);
|
|
98
|
+
if (emitter && subId !== undefined) emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
|
|
99
|
+
return formatError(`child RLM failed - ${msg}`);
|
|
104
100
|
}
|
|
105
101
|
}
|
|
106
102
|
|
|
107
|
-
async function child(prompt: string, model: string | null, depth: number): Promise<string> {
|
|
108
|
-
const res = await childRun({
|
|
109
|
-
rootPrompt: "",
|
|
110
|
-
context: prompt,
|
|
111
|
-
depth: depth + 1,
|
|
112
|
-
model,
|
|
113
|
-
});
|
|
114
|
-
return res.answer;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
103
|
return {
|
|
118
|
-
rlmQuery: (prompt, model, depth) => child(prompt, model, depth),
|
|
119
|
-
rlmQueryBatched: (prompts, model, depth) =>
|
|
120
|
-
|
|
104
|
+
rlmQuery: (prompt, model, depth) => child(prompt, model, depth + 1),
|
|
105
|
+
rlmQueryBatched: (prompts, model, depth) =>
|
|
106
|
+
mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) => child(p, model, depth + 1)),
|
|
121
107
|
};
|
|
122
108
|
}
|
|
@@ -11,15 +11,15 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
|
|
|
11
11
|
const models = ctx.modelRegistry.getAvailable();
|
|
12
12
|
|
|
13
13
|
const worker = await selectModel(ctx, "Worker model (sub-LLM / llm_query)", models, controller.workerModel, controller.config.subSampling.reasoning);
|
|
14
|
-
if (worker
|
|
15
|
-
controller.workerModel =
|
|
16
|
-
controller.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
if (worker !== undefined) {
|
|
15
|
+
controller.workerModel = worker?.model;
|
|
16
|
+
controller.setConfig(Object.freeze({
|
|
17
|
+
...controller.config,
|
|
18
|
+
subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: worker?.thinkingLevel }),
|
|
19
|
+
}));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
await showConfigPanel(ctx, controller.config);
|
|
22
|
+
controller.setConfig(await showConfigPanel(ctx, controller.config));
|
|
23
23
|
|
|
24
24
|
if (worker === null) {
|
|
25
25
|
controller.savedWorkerRef = undefined;
|
|
@@ -29,7 +29,7 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
|
|
|
29
29
|
}
|
|
30
30
|
const persisted = await controller.persist();
|
|
31
31
|
if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
|
|
32
|
-
setRlmModeStatus(ctx.ui, controller);
|
|
32
|
+
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
|
33
33
|
|
|
34
34
|
const w = controller.workerModel;
|
|
35
35
|
ctx.ui.notify(
|
package/src/commands/rlm.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** `/rlm` — toggle persistent Recursive Language Model mode. */
|
|
2
2
|
|
|
3
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Container, Text, type Component } from "@earendil-works/pi-tui";
|
|
4
5
|
import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
|
|
5
6
|
import type { RlmController, RunHandle } from "../mode/rlm-mode.ts";
|
|
6
7
|
import { postRlmGuide } from "../ui/intro.ts";
|
|
@@ -13,13 +14,19 @@ import type { RunHeader } from "../state/rows.ts";
|
|
|
13
14
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
14
15
|
import { RlmEmitter } from "../tool/rlm-events.ts";
|
|
15
16
|
import { RlmEventAggregator } from "../tool/rlm-aggregator.ts";
|
|
17
|
+
import type { RlmDetails } from "../tool/rlm-details.ts";
|
|
18
|
+
import { cardHeader, cardStatsLine, renderCollapsedSubcallTree } from "../tool/subcall-render.ts";
|
|
19
|
+
import { errorMessage } from "../util/errors.ts";
|
|
20
|
+
|
|
21
|
+
/** Run ids offered for `/rlm-resume <TAB>`. */
|
|
22
|
+
const MAX_COMPLETIONS = 20;
|
|
16
23
|
|
|
17
24
|
export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController): void {
|
|
18
25
|
pi.registerCommand("rlm", {
|
|
19
26
|
description: "Toggle persistent RLM mode (route plain prompts through the RLM engine).",
|
|
20
27
|
handler: async (_args, ctx) => {
|
|
21
28
|
const enabled = controller.toggle();
|
|
22
|
-
setRlmModeStatus(ctx.ui, controller);
|
|
29
|
+
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
|
23
30
|
ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
|
|
24
31
|
},
|
|
25
32
|
});
|
|
@@ -45,6 +52,15 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
45
52
|
|
|
46
53
|
pi.registerCommand("rlm-resume", {
|
|
47
54
|
description: "Resume an interrupted RLM run (default @latest).",
|
|
55
|
+
getArgumentCompletions: async (prefix) => {
|
|
56
|
+
const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
|
|
57
|
+
const ids = await listRunIds(process.cwd(), dir);
|
|
58
|
+
const candidates = ["@latest", ...ids];
|
|
59
|
+
return candidates
|
|
60
|
+
.filter((value) => value.startsWith(prefix))
|
|
61
|
+
.slice(0, MAX_COMPLETIONS)
|
|
62
|
+
.map((value) => ({ value, label: value }));
|
|
63
|
+
},
|
|
48
64
|
handler: async (args, ctx) => {
|
|
49
65
|
if (controller.isBusy()) {
|
|
50
66
|
ctx.ui.notify("RLM is busy (use /rlm-stop to cancel).", "warning");
|
|
@@ -69,7 +85,7 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
69
85
|
let recon: ReconstructResult;
|
|
70
86
|
try { recon = await reconstructRlmState(cwd, dir, runId, systemPrompt); }
|
|
71
87
|
catch (e) {
|
|
72
|
-
ctx.ui.notify(`RLM resume failed: corrupt run state — ${
|
|
88
|
+
ctx.ui.notify(`RLM resume failed: corrupt run state — ${errorMessage(e)}`, "error");
|
|
73
89
|
return;
|
|
74
90
|
}
|
|
75
91
|
if (!recon.ok) { ctx.ui.notify(`Cannot resume ${runId}: ${recon.reason}.`, "error"); return; }
|
|
@@ -94,12 +110,29 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
94
110
|
description: "Toggle RLM mode (off also stops a running query)",
|
|
95
111
|
handler: async (ctx) => {
|
|
96
112
|
const enabled = controller.toggle();
|
|
97
|
-
setRlmModeStatus(ctx.ui, controller);
|
|
113
|
+
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
|
98
114
|
ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
|
|
99
115
|
},
|
|
100
116
|
});
|
|
101
117
|
}
|
|
102
118
|
|
|
119
|
+
/** Above-editor progress card for a `/rlm-resume` run: header + the live sub-call tree. */
|
|
120
|
+
function renderResumeWidget(details: RlmDetails | undefined, theme: Theme): Component {
|
|
121
|
+
const container = new Container();
|
|
122
|
+
if (!details) return container;
|
|
123
|
+
const turns = details.turns;
|
|
124
|
+
const stats = cardStatsLine(
|
|
125
|
+
details.totals,
|
|
126
|
+
theme,
|
|
127
|
+
turns.max > 0 ? `turn ${turns.current}/${turns.max}` : undefined,
|
|
128
|
+
);
|
|
129
|
+
container.addChild(new Text(cardHeader("RLM resume", details.status, stats, theme), 0, 0));
|
|
130
|
+
if (details.subcalls.length > 0) {
|
|
131
|
+
container.addChild(new Text(renderCollapsedSubcallTree(details.subcalls, theme), 0, 0));
|
|
132
|
+
}
|
|
133
|
+
return container;
|
|
134
|
+
}
|
|
135
|
+
|
|
103
136
|
async function executeRlmRunWithResume(
|
|
104
137
|
pi: ExtensionAPI,
|
|
105
138
|
controller: RlmController,
|
|
@@ -113,13 +146,16 @@ async function executeRlmRunWithResume(
|
|
|
113
146
|
let aggregator: RlmEventAggregator | undefined;
|
|
114
147
|
try {
|
|
115
148
|
emitter = new RlmEmitter();
|
|
149
|
+
// Component factory rather than the string[] form: the array form is hard-capped at 10
|
|
150
|
+
// lines by pi, which the live sub-call tree exceeds as soon as a run fans out. The factory
|
|
151
|
+
// also receives the live theme, so the widget follows /theme switches.
|
|
152
|
+
let latest: RlmDetails | undefined;
|
|
116
153
|
aggregator = new RlmEventAggregator(emitter, (partial) => {
|
|
117
|
-
|
|
118
|
-
if (!
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
ctx.ui.setWidget?.("rlm-status", [`${glyph} RLM resume${turn}${cost}`], { placement: "aboveEditor" });
|
|
154
|
+
latest = partial.details;
|
|
155
|
+
if (!latest) return;
|
|
156
|
+
ctx.ui.setWidget?.("rlm-status", (_tui, theme) => renderResumeWidget(latest, theme), {
|
|
157
|
+
placement: "aboveEditor",
|
|
158
|
+
});
|
|
123
159
|
});
|
|
124
160
|
emitter.emitRootPrompt(header.rootPrompt);
|
|
125
161
|
const interactive = createPiInteractiveDeps(ctx);
|
|
@@ -131,7 +167,7 @@ async function executeRlmRunWithResume(
|
|
|
131
167
|
onTodo: controller.config.todo ? interactive.onTodo : undefined,
|
|
132
168
|
});
|
|
133
169
|
} catch (e) {
|
|
134
|
-
ctx.ui.notify(`RLM resume failed: ${
|
|
170
|
+
ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
|
|
135
171
|
return;
|
|
136
172
|
}
|
|
137
173
|
pi.sendMessage({ customType: "rlm-question", content: `[resume] ${header.rootPrompt}`, display: true });
|
|
@@ -140,7 +176,7 @@ async function executeRlmRunWithResume(
|
|
|
140
176
|
const result = await done;
|
|
141
177
|
pi.sendMessage({ customType: "rlm-answer", content: result.answer, display: true });
|
|
142
178
|
} catch (e) {
|
|
143
|
-
ctx.ui.notify(`RLM resume failed: ${
|
|
179
|
+
ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
|
|
144
180
|
} finally {
|
|
145
181
|
clearRlmStatus(ctx.ui);
|
|
146
182
|
ctx.ui.setWidget?.("rlm-status", undefined);
|
package/src/config/settings.ts
CHANGED
|
@@ -30,6 +30,19 @@ function validateString(v: unknown): string | undefined {
|
|
|
30
30
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Every value pi-ai accepts for `reasoning`. Keyed by the union so a new level added upstream
|
|
35
|
+
* is a compile error here rather than a silently-rejected setting. Note `off` and `max` are
|
|
36
|
+
* NOT ThinkingLevels — a hand-edited rlm.json carrying one is dropped, not forwarded.
|
|
37
|
+
*/
|
|
38
|
+
const THINKING_LEVELS: Readonly<Record<ThinkingLevel, true>> = Object.freeze({
|
|
39
|
+
minimal: true, low: true, medium: true, high: true, xhigh: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function validateThinkingLevel(v: unknown): ThinkingLevel | undefined {
|
|
43
|
+
return typeof v === "string" && Object.hasOwn(THINKING_LEVELS, v) ? (v as ThinkingLevel) : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
function validateRunLog(raw: unknown): Partial<RunLogConfig> | undefined {
|
|
34
47
|
if (typeof raw !== "object" || raw === null) return undefined;
|
|
35
48
|
const r = raw as Record<string, unknown>;
|
|
@@ -83,7 +96,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
83
96
|
if (compactionThresholdPct !== undefined && compactionThresholdPct <= 1) out.compactionThresholdPct = compactionThresholdPct;
|
|
84
97
|
const python = validateString(r.python);
|
|
85
98
|
if (python !== undefined) out.python = python;
|
|
86
|
-
|
|
99
|
+
const smartReasoning = validateThinkingLevel(r.smartReasoning);
|
|
100
|
+
if (smartReasoning !== undefined) out.smartReasoning = smartReasoning;
|
|
87
101
|
const subSystemPrompt = validateString(r.subSystemPrompt);
|
|
88
102
|
if (subSystemPrompt !== undefined) out.subSystemPrompt = subSystemPrompt;
|
|
89
103
|
const runLog = validateRunLog(r.runLog);
|
|
@@ -103,7 +117,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
103
117
|
if (maxTokensValue !== undefined) sampling.maxTokens = maxTokensValue;
|
|
104
118
|
const temperature = validateNumber(ss.temperature, 0);
|
|
105
119
|
if (temperature !== undefined) sampling.temperature = temperature;
|
|
106
|
-
|
|
120
|
+
const ssReasoning = validateThinkingLevel(ss.reasoning);
|
|
121
|
+
if (ssReasoning !== undefined) sampling.reasoning = ssReasoning;
|
|
107
122
|
out.subSampling = sampling;
|
|
108
123
|
}
|
|
109
124
|
if (typeof r.rootSampling === "object" && r.rootSampling !== null) {
|
|
@@ -113,7 +128,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
113
128
|
if (rsMaxTokens !== undefined) rootSampling.maxTokens = rsMaxTokens;
|
|
114
129
|
const rsTemperature = validateNumber(rs.temperature, 0);
|
|
115
130
|
if (rsTemperature !== undefined) rootSampling.temperature = rsTemperature;
|
|
116
|
-
|
|
131
|
+
const rsReasoning = validateThinkingLevel(rs.reasoning);
|
|
132
|
+
if (rsReasoning !== undefined) rootSampling.reasoning = rsReasoning;
|
|
117
133
|
out.rootSampling = Object.freeze(rootSampling);
|
|
118
134
|
}
|
|
119
135
|
return out;
|
|
@@ -166,3 +182,17 @@ export function resolveModelId(registry: ModelRegistry, ref?: string): Model<Api
|
|
|
166
182
|
export function modelRef(model: Model<Api> | undefined): string | undefined {
|
|
167
183
|
return model ? `${model.provider}/${model.id}` : undefined;
|
|
168
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Human-readable "provider/id" for a sub-call node: the resolved override when one was
|
|
188
|
+
* supplied and resolves, otherwise the fallback model. Shared by the llm and rlm bridges
|
|
189
|
+
* so sub-call trees label their nodes identically.
|
|
190
|
+
*/
|
|
191
|
+
export function displayModelRef(
|
|
192
|
+
registry: ModelRegistry,
|
|
193
|
+
override: string | null,
|
|
194
|
+
fallback: Model<Api>,
|
|
195
|
+
): string {
|
|
196
|
+
const resolved = override ? (resolveModelId(registry, override) ?? fallback) : fallback;
|
|
197
|
+
return modelRef(resolved) ?? fallback.id;
|
|
198
|
+
}
|
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
import { pack } from "repomix";
|
|
12
12
|
import type { PackResult as RepomixPackResult } from "repomix";
|
|
13
|
+
/** repomix's own config parameter type — `satisfies` keeps the literal checked against it. */
|
|
14
|
+
type PackConfig = NonNullable<Parameters<typeof pack>[1]>;
|
|
13
15
|
import { resolve } from "node:path";
|
|
14
16
|
import { tmpdir } from "node:os";
|
|
15
17
|
import { errorMessage } from "../util/errors.ts";
|
|
18
|
+
import { estimateTokens } from "../text/tokens.ts";
|
|
16
19
|
|
|
17
20
|
// ── Public types ──
|
|
18
21
|
|
|
@@ -51,11 +54,6 @@ interface CacheEntry {
|
|
|
51
54
|
const cache = new Map<string, CacheEntry>();
|
|
52
55
|
const DEFAULT_CACHE_TTL_MS = 30_000;
|
|
53
56
|
|
|
54
|
-
/** Exported for tests — empties the module-level cache. */
|
|
55
|
-
export function clearCache(): void {
|
|
56
|
-
cache.clear();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
57
|
function cacheKey(cwd: string): string {
|
|
60
58
|
return resolve(cwd);
|
|
61
59
|
}
|
|
@@ -76,8 +74,6 @@ function cacheSet(key: string, bundle: ContextBundle): void {
|
|
|
76
74
|
|
|
77
75
|
// ── Core functions ──
|
|
78
76
|
|
|
79
|
-
const ESTIMATED_CHARS_PER_TOKEN = 4;
|
|
80
|
-
|
|
81
77
|
export async function packRepository(
|
|
82
78
|
cwd: string,
|
|
83
79
|
signal?: AbortSignal,
|
|
@@ -133,7 +129,7 @@ export async function packRepository(
|
|
|
133
129
|
},
|
|
134
130
|
security: { enableSecurityCheck: false },
|
|
135
131
|
tokenCount: { encoding: "o200k_base" as const },
|
|
136
|
-
}
|
|
132
|
+
} satisfies PackConfig),
|
|
137
133
|
new Promise<never>((_, reject) => {
|
|
138
134
|
signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
|
139
135
|
}),
|
|
@@ -147,8 +143,7 @@ export async function packRepository(
|
|
|
147
143
|
|
|
148
144
|
for (let i = 0; i < processedFiles.length; i++) {
|
|
149
145
|
const file = processedFiles[i];
|
|
150
|
-
const tokens = tokenCounts[file.path]
|
|
151
|
-
?? Math.ceil(file.content.length / ESTIMATED_CHARS_PER_TOKEN);
|
|
146
|
+
const tokens = tokenCounts[file.path] ?? estimateTokens(file.content.length);
|
|
152
147
|
files[i] = { path: file.path, content: file.content, tokens };
|
|
153
148
|
totalTokens += tokens;
|
|
154
149
|
totalChars += file.content.length;
|