@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
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The phase pipeline's stateful half: the `save_artifact` / `advance_phase` sandbox handlers
|
|
3
|
+
* and the validate-phase finalize routing.
|
|
4
|
+
*
|
|
5
|
+
* Pulled out of the engine's run closure so the pipeline's mutable state (current phase, the
|
|
6
|
+
* per-phase latest save, serviced ask rounds, accumulated warnings, a pending history reset)
|
|
7
|
+
* lives in one owner instead of six `let`s threaded through a 640-line function.
|
|
8
|
+
*
|
|
9
|
+
* Two invariants this file exists to protect:
|
|
10
|
+
* - Gates measure the CURRENT phase's latest save (`lastSaved`) only — never
|
|
11
|
+
* `phase.artifacts`, whose paths are the completed channel and go stale across a
|
|
12
|
+
* corrective loop-back.
|
|
13
|
+
* - `lastSaved` and `askRounds` are session-only. They are never rehydrated from the trail,
|
|
14
|
+
* so a resumed run must genuinely re-save and re-interview rather than re-gate stale work.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ChatMsg } from "../bridge/model.ts";
|
|
18
|
+
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
19
|
+
import type { PhaseRecon } from "../state/resume.ts";
|
|
20
|
+
import { formatError } from "../util/errors.ts";
|
|
21
|
+
import { readArtifact, saveArtifact, type GoalCapture } from "./artifacts.ts";
|
|
22
|
+
import { critiqueArtifact, formatCritique } from "./critique.ts";
|
|
23
|
+
import {
|
|
24
|
+
advancePhase as validatePhaseTransition,
|
|
25
|
+
initialPhaseState,
|
|
26
|
+
isPhase,
|
|
27
|
+
PHASES,
|
|
28
|
+
reconcilePhase,
|
|
29
|
+
routeAfterValidate,
|
|
30
|
+
stageForArtifactKind,
|
|
31
|
+
STAGES,
|
|
32
|
+
type ArtifactRef,
|
|
33
|
+
type Phase,
|
|
34
|
+
type PhaseState,
|
|
35
|
+
type SavedArtifact,
|
|
36
|
+
type StageGateData,
|
|
37
|
+
} from "./pipeline.ts";
|
|
38
|
+
|
|
39
|
+
/** Builds the fresh-session history for a phase. Supplied by the engine (its own policy). */
|
|
40
|
+
export type ResetHistoryForPhase = (
|
|
41
|
+
state: PhaseState,
|
|
42
|
+
options: { readonly goal?: GoalCapture; readonly validation?: import("./gates.ts").ValidationGateData; readonly notice?: string },
|
|
43
|
+
) => ChatMsg[];
|
|
44
|
+
|
|
45
|
+
/** Appends a `phase` row to the run trail. Supplied by the engine (owns persistence state). */
|
|
46
|
+
export type PersistPhaseRow = (
|
|
47
|
+
state: PhaseState,
|
|
48
|
+
artifactPath: string | undefined,
|
|
49
|
+
artifactPhase: Phase | undefined,
|
|
50
|
+
gateData: StageGateData | undefined,
|
|
51
|
+
supersededPath?: string,
|
|
52
|
+
) => Promise<void>;
|
|
53
|
+
|
|
54
|
+
export interface PipelineDeps {
|
|
55
|
+
/** Repo root that artifact paths are resolved against. */
|
|
56
|
+
readonly runCwd: string;
|
|
57
|
+
readonly maxBackwardJumps: number;
|
|
58
|
+
readonly emitter: RlmEmitter;
|
|
59
|
+
/** Turns completed so far — phase rows and `advancedAt` are stamped with it. */
|
|
60
|
+
readonly completedTurns: () => number;
|
|
61
|
+
readonly resetHistoryForPhase: ResetHistoryForPhase;
|
|
62
|
+
readonly persistPhaseRow: PersistPhaseRow;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What the engine should do with a finalize submitted while in the `validate` phase. */
|
|
66
|
+
export type ValidateOutcome =
|
|
67
|
+
/** Not finalizable yet — feed `error` back as the next turn's REPL output. */
|
|
68
|
+
| { readonly kind: "reject"; readonly error: string }
|
|
69
|
+
/** Blockers found — re-enter `blueprint` with this fresh history. */
|
|
70
|
+
| { readonly kind: "loop-back"; readonly history: ChatMsg[] }
|
|
71
|
+
/** Backward-jump cap reached — terminate with this report. */
|
|
72
|
+
| { readonly kind: "halt"; readonly report: string }
|
|
73
|
+
/** Validation passed — take the model's final answer. */
|
|
74
|
+
| { readonly kind: "accept" };
|
|
75
|
+
|
|
76
|
+
/** The sandbox handlers the pipeline contributes. Shape-compatible with `SubLlmHandlers`. */
|
|
77
|
+
export interface PipelineHandlers {
|
|
78
|
+
saveArtifact(kind: string, content: string): Promise<string>;
|
|
79
|
+
advancePhase(phase: string, summary: string | undefined): Promise<string>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class PipelineController {
|
|
83
|
+
/** Current phase state; `undefined` until seeded. Read by the engine for gate prompts/rows. */
|
|
84
|
+
phase: PhaseState | undefined;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Latest save per phase: path plus an optional gate memo, as ONE record so the two cannot
|
|
88
|
+
* desync. Cleared on phase exit and on loop-back.
|
|
89
|
+
*/
|
|
90
|
+
private lastSaved: Partial<Record<Phase, SavedArtifact>> = {};
|
|
91
|
+
|
|
92
|
+
/** Serviced ask_user_question rounds in the current phase (session-only). */
|
|
93
|
+
private askRounds = 0;
|
|
94
|
+
|
|
95
|
+
/** Advisory critique warnings accumulated across saves (surfaced in the TUI). */
|
|
96
|
+
private warnings: readonly string[] = [];
|
|
97
|
+
|
|
98
|
+
/** History replacement scheduled by advance_phase; the engine drains it at a turn boundary. */
|
|
99
|
+
private pendingReset: ChatMsg[] | undefined;
|
|
100
|
+
|
|
101
|
+
private goal: GoalCapture | undefined;
|
|
102
|
+
|
|
103
|
+
constructor(private readonly deps: PipelineDeps) {}
|
|
104
|
+
|
|
105
|
+
/** Called after each successfully serviced root-depth ask_user_question round. */
|
|
106
|
+
noteAskRound(): void {
|
|
107
|
+
this.askRounds++;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Take and clear the scheduled fresh-session history, if advance_phase left one. */
|
|
111
|
+
takePendingReset(): ChatMsg[] | undefined {
|
|
112
|
+
const reset = this.pendingReset;
|
|
113
|
+
this.pendingReset = undefined;
|
|
114
|
+
return reset;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Seed a fresh run: capture goal, enter `startPhase`, and build the opening history. */
|
|
118
|
+
seedFresh(startPhase: Phase, goal: GoalCapture | undefined, notice: string | undefined): ChatMsg[] {
|
|
119
|
+
this.goal = goal;
|
|
120
|
+
this.phase = initialPhaseState(0, startPhase);
|
|
121
|
+
return this.deps.resetHistoryForPhase(this.phase, { goal, notice });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Rehydrate phase state from a trail. `lastSaved`/`askRounds` stay empty by design: a
|
|
126
|
+
* resume must re-save and re-interview rather than re-gate work from a previous process.
|
|
127
|
+
*/
|
|
128
|
+
seedFromResume(recon: PhaseRecon): void {
|
|
129
|
+
const artifacts: Partial<Record<Phase, ArtifactRef>> = {};
|
|
130
|
+
for (const [key, value] of Object.entries(recon.artifacts ?? {})) {
|
|
131
|
+
if (value === undefined || !isPhase(key)) continue;
|
|
132
|
+
artifacts[key] = Object.freeze({
|
|
133
|
+
path: value.path,
|
|
134
|
+
status: value.superseded ? ("superseded" as const) : ("active" as const),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
this.phase = {
|
|
138
|
+
current: reconcilePhase(recon.current),
|
|
139
|
+
advancedAt: recon.advancedAt,
|
|
140
|
+
summary: recon.summary,
|
|
141
|
+
artifacts,
|
|
142
|
+
backwardJumps: recon.backwardJumps ?? 0,
|
|
143
|
+
};
|
|
144
|
+
this.lastSaved = {};
|
|
145
|
+
this.askRounds = 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
handlers(): PipelineHandlers {
|
|
149
|
+
return {
|
|
150
|
+
saveArtifact: (kind, content) => this.handleSaveArtifact(kind, content),
|
|
151
|
+
advancePhase: (phase, summary) => this.handleAdvancePhase(phase, summary),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── save_artifact ──
|
|
156
|
+
|
|
157
|
+
private async handleSaveArtifact(kind: string, content: string): Promise<string> {
|
|
158
|
+
const stage = stageForArtifactKind(kind);
|
|
159
|
+
if (stage === undefined) {
|
|
160
|
+
return formatError(`unknown artifact kind '${kind}' (valid: clarification, research, plan, validation)`);
|
|
161
|
+
}
|
|
162
|
+
const current = this.currentPhase();
|
|
163
|
+
if (stage.phase !== current) {
|
|
164
|
+
return formatError(`artifact kind '${kind}' belongs to phase '${stage.phase}', but the pipeline is in '${current}'`);
|
|
165
|
+
}
|
|
166
|
+
const saved = saveArtifact(this.deps.runCwd, stage.artifactDir, kind, content);
|
|
167
|
+
if (!saved.ok) return formatError(saved.error);
|
|
168
|
+
|
|
169
|
+
// Preflight: run the SAME gate advance_phase will run, now instead of a turn later, and
|
|
170
|
+
// memoize the verdict so the transition does not re-read and re-gate the file.
|
|
171
|
+
const critique = critiqueArtifact(stage, content, saved.path, this.deps.runCwd);
|
|
172
|
+
this.lastSaved = {
|
|
173
|
+
...this.lastSaved,
|
|
174
|
+
[stage.phase]: Object.freeze({ path: saved.path, gateData: critique.gateData }),
|
|
175
|
+
};
|
|
176
|
+
if (critique.warnings.length > 0) {
|
|
177
|
+
this.warnings = Object.freeze([...this.warnings, ...critique.warnings]);
|
|
178
|
+
this.deps.emitter.emitWarnings(this.warnings);
|
|
179
|
+
}
|
|
180
|
+
return `ok — saved ${saved.path}.\n${formatCritique(critique)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── advance_phase ──
|
|
184
|
+
|
|
185
|
+
private async handleAdvancePhase(phase: string, summary: string | undefined): Promise<string> {
|
|
186
|
+
const current = this.currentPhase();
|
|
187
|
+
const outcome = validatePhaseTransition(current, phase);
|
|
188
|
+
if (!outcome.ok) return formatError(outcome.error);
|
|
189
|
+
|
|
190
|
+
// Clarify interview gate: the engine counts serviced rounds itself, so the model cannot
|
|
191
|
+
// advance by merely claiming to have interviewed the user.
|
|
192
|
+
if (current === "clarify" && this.askRounds === 0) {
|
|
193
|
+
return formatError("clarify requires at least one ask_user_question round — interview the user before advancing");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const gated = this.gateCurrentPhase(current);
|
|
197
|
+
if (!gated.ok) return formatError(gated.error);
|
|
198
|
+
const { artifactPath, gateData } = gated;
|
|
199
|
+
|
|
200
|
+
const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = { ...(this.phase?.artifacts ?? {}) };
|
|
201
|
+
if (artifactPath !== undefined) {
|
|
202
|
+
nextArtifacts[current] = Object.freeze({ path: artifactPath, status: "active" });
|
|
203
|
+
}
|
|
204
|
+
this.phase = {
|
|
205
|
+
current: outcome.phase,
|
|
206
|
+
advancedAt: this.deps.completedTurns(),
|
|
207
|
+
summary,
|
|
208
|
+
artifacts: nextArtifacts,
|
|
209
|
+
backwardJumps: this.phase?.backwardJumps ?? 0,
|
|
210
|
+
};
|
|
211
|
+
await this.deps.persistPhaseRow(this.phase, artifactPath, artifactPath !== undefined ? current : undefined, gateData);
|
|
212
|
+
this.clearLastSaved(current);
|
|
213
|
+
this.askRounds = 0;
|
|
214
|
+
this.pendingReset = this.deps.resetHistoryForPhase(this.phase, { goal: this.goal });
|
|
215
|
+
return `ok — phase advanced to '${outcome.phase}' (was '${current}'${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Measure the current phase's latest save. Deliberately reads `lastSaved` only — falling
|
|
220
|
+
* back to `phase.artifacts` would let a stale path from before a loop-back pass the gate.
|
|
221
|
+
*/
|
|
222
|
+
private gateCurrentPhase(
|
|
223
|
+
current: Phase,
|
|
224
|
+
): { ok: true; artifactPath: string | undefined; gateData: StageGateData | undefined } | { ok: false; error: string } {
|
|
225
|
+
const stage = STAGES[current];
|
|
226
|
+
if (stage.artifactDir === "") return { ok: true, artifactPath: undefined, gateData: undefined };
|
|
227
|
+
|
|
228
|
+
const savedEntry = this.lastSaved[current];
|
|
229
|
+
const artifactPath = savedEntry?.path;
|
|
230
|
+
if (artifactPath === undefined) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
error: `phase '${current}' has no saved artifact — call save_artifact("${stage.artifactKind}", content) first`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (savedEntry?.gateData !== undefined) {
|
|
237
|
+
return { ok: true, artifactPath, gateData: savedEntry.gateData };
|
|
238
|
+
}
|
|
239
|
+
// No memo (e.g. the file was written outside save_artifact) — read and gate it now.
|
|
240
|
+
const content = readArtifact(this.deps.runCwd, artifactPath);
|
|
241
|
+
if (!content.ok) return { ok: false, error: content.error };
|
|
242
|
+
const gate = stage.gate(content.value, artifactPath, this.deps.runCwd);
|
|
243
|
+
if (!gate.ok) return { ok: false, error: gate.error };
|
|
244
|
+
this.lastSaved = {
|
|
245
|
+
...this.lastSaved,
|
|
246
|
+
[current]: Object.freeze({ path: artifactPath, gateData: gate.value }),
|
|
247
|
+
};
|
|
248
|
+
return { ok: true, artifactPath, gateData: gate.value };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── validate-phase finalize ──
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Decide what a finalize submitted during `validate` means. As with advance_phase, this
|
|
255
|
+
* measures THIS turn's validation save only, never `phase.artifacts`.
|
|
256
|
+
*/
|
|
257
|
+
async finalizeInValidate(final: string): Promise<ValidateOutcome> {
|
|
258
|
+
const phase = this.phase;
|
|
259
|
+
if (phase === undefined) return { kind: "accept" };
|
|
260
|
+
|
|
261
|
+
const vPath = this.lastSaved.validate?.path;
|
|
262
|
+
if (vPath === undefined) {
|
|
263
|
+
return {
|
|
264
|
+
kind: "reject",
|
|
265
|
+
error: formatError(
|
|
266
|
+
'finalize rejected — save the validation artifact first via save_artifact("validation", content) with status: ready, blockers_count, and verdict',
|
|
267
|
+
),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const content = readArtifact(this.deps.runCwd, vPath);
|
|
271
|
+
if (!content.ok) return { kind: "reject", error: formatError(content.error) };
|
|
272
|
+
|
|
273
|
+
const gate = STAGES.validate.gate(content.value, vPath, this.deps.runCwd);
|
|
274
|
+
if (!gate.ok) return { kind: "reject", error: formatError(gate.error) };
|
|
275
|
+
if (gate.value.kind !== "validation") {
|
|
276
|
+
return { kind: "reject", error: formatError("internal: validate gate did not return validation data") };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const { validation } = gate.value;
|
|
280
|
+
const route = routeAfterValidate(validation, phase.backwardJumps, this.deps.maxBackwardJumps);
|
|
281
|
+
if (route.kind === "halt") return { kind: "halt", report: `${route.reason}\n\n${final}` };
|
|
282
|
+
if (route.kind !== "loop-back") return { kind: "accept" };
|
|
283
|
+
|
|
284
|
+
// Loop back to blueprint. Prior artifacts are kept — the append-only journal marks the
|
|
285
|
+
// blueprint superseded by this validation rather than dropping it.
|
|
286
|
+
const prior = phase.artifacts.blueprint;
|
|
287
|
+
const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = {
|
|
288
|
+
...phase.artifacts,
|
|
289
|
+
validate: Object.freeze({ path: vPath, status: "active" }),
|
|
290
|
+
};
|
|
291
|
+
if (prior !== undefined) {
|
|
292
|
+
nextArtifacts.blueprint = Object.freeze({ path: prior.path, status: "superseded", supersededBy: vPath });
|
|
293
|
+
}
|
|
294
|
+
this.phase = {
|
|
295
|
+
current: "blueprint",
|
|
296
|
+
advancedAt: this.deps.completedTurns(),
|
|
297
|
+
summary: `loop-back: ${validation.blockersCount} blocker(s)`,
|
|
298
|
+
artifacts: nextArtifacts,
|
|
299
|
+
backwardJumps: phase.backwardJumps + 1,
|
|
300
|
+
};
|
|
301
|
+
await this.deps.persistPhaseRow(this.phase, vPath, "validate", gate.value, prior?.path);
|
|
302
|
+
// Clear BOTH so the re-entered blueprint must produce a genuinely fresh plan and a fresh
|
|
303
|
+
// validation of it, rather than re-gating what was just rejected.
|
|
304
|
+
this.clearLastSaved("blueprint", "validate");
|
|
305
|
+
this.askRounds = 0;
|
|
306
|
+
this.pendingReset = undefined;
|
|
307
|
+
return { kind: "loop-back", history: this.deps.resetHistoryForPhase(this.phase, { goal: this.goal, validation }) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private currentPhase(): Phase {
|
|
311
|
+
return this.phase?.current ?? PHASES[0];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private clearLastSaved(...phases: readonly Phase[]): void {
|
|
315
|
+
const next: Partial<Record<Phase, SavedArtifact>> = { ...this.lastSaved };
|
|
316
|
+
for (const phase of phases) delete next[phase];
|
|
317
|
+
this.lastSaved = next;
|
|
318
|
+
}
|
|
319
|
+
}
|
package/src/core/pipeline.ts
CHANGED
|
@@ -208,7 +208,7 @@ export function advancePhase(
|
|
|
208
208
|
current: Phase | undefined,
|
|
209
209
|
target: string,
|
|
210
210
|
): AdvancePhaseOutcome {
|
|
211
|
-
if (!
|
|
211
|
+
if (!isPhase(target)) {
|
|
212
212
|
return {
|
|
213
213
|
ok: false,
|
|
214
214
|
error: `unknown phase '${target}'; valid phases: ${PHASES.join(", ")}`,
|
|
@@ -231,7 +231,7 @@ export function advancePhase(
|
|
|
231
231
|
phase: from,
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
|
-
return { ok: true, phase: target
|
|
234
|
+
return { ok: true, phase: target };
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/** Return the current phase (defaults to first phase if undefined). */
|
package/src/core/types.ts
CHANGED
|
@@ -10,8 +10,6 @@ export interface Sampling {
|
|
|
10
10
|
readonly reasoning?: ThinkingLevel;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
type MutableSampling = { -readonly [Key in keyof Sampling]?: Sampling[Key] };
|
|
14
|
-
|
|
15
13
|
export interface RunLogConfig {
|
|
16
14
|
/** Default: true — always-on, opt-out. */
|
|
17
15
|
readonly enabled?: boolean;
|
|
@@ -25,59 +23,59 @@ export interface RunLogConfig {
|
|
|
25
23
|
|
|
26
24
|
export interface RlmConfig {
|
|
27
25
|
/** Persistent editor-routing mode; when enabled, plain interactive prompts use RLM. */
|
|
28
|
-
enabled: boolean;
|
|
26
|
+
readonly enabled: boolean;
|
|
29
27
|
/** Max recursion depth. depth >= maxDepth ⇒ rlm_query falls back to a plain llm_query. */
|
|
30
|
-
maxDepth: number;
|
|
28
|
+
readonly maxDepth: number;
|
|
31
29
|
/** Max turns before the engine must finalize. */
|
|
32
|
-
maxIterations: number;
|
|
30
|
+
readonly maxIterations: number;
|
|
33
31
|
/** Per-`repl`-block wall-clock timeout inside the worker (seconds). */
|
|
34
|
-
execTimeoutS: number;
|
|
32
|
+
readonly execTimeoutS: number;
|
|
35
33
|
/** Parent-side watchdog per sandbox request (ms). */
|
|
36
|
-
requestTimeoutMs: number;
|
|
34
|
+
readonly requestTimeoutMs: number;
|
|
37
35
|
/** Concurrency pool for *_batched sub-calls. */
|
|
38
|
-
maxConcurrentSubcalls: number;
|
|
36
|
+
readonly maxConcurrentSubcalls: number;
|
|
39
37
|
/** Reject sub-LLM prompts larger than this many chars. */
|
|
40
|
-
maxPromptChars: number;
|
|
38
|
+
readonly maxPromptChars: number;
|
|
41
39
|
/** Max USD spend across the whole tree before the engine stops (undefined = no cap). */
|
|
42
|
-
maxBudgetUsd?: number;
|
|
40
|
+
readonly maxBudgetUsd?: number;
|
|
43
41
|
/** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
|
|
44
|
-
maxTimeoutMs?: number;
|
|
42
|
+
readonly maxTimeoutMs?: number;
|
|
45
43
|
/** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap). */
|
|
46
|
-
maxTokens?: number;
|
|
44
|
+
readonly maxTokens?: number;
|
|
47
45
|
/** Max consecutive error turns before the engine stops (undefined = no cap). */
|
|
48
|
-
maxErrors?: number;
|
|
46
|
+
readonly maxErrors?: number;
|
|
49
47
|
/** Append the orchestrator addendum to the system prompt. */
|
|
50
|
-
orchestrator: boolean;
|
|
48
|
+
readonly orchestrator: boolean;
|
|
51
49
|
/** Enable the phase pipeline (advance_phase + stall nags) at depth 0. */
|
|
52
|
-
pipeline: boolean;
|
|
50
|
+
readonly pipeline: boolean;
|
|
53
51
|
/** Max validate→blueprint corrective re-entries when validation reports blockers (default 2). */
|
|
54
|
-
maxBackwardJumps: number;
|
|
52
|
+
readonly maxBackwardJumps: number;
|
|
55
53
|
/** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
|
|
56
|
-
compaction: boolean;
|
|
54
|
+
readonly compaction: boolean;
|
|
57
55
|
/** Compact when estimated history tokens reach this fraction of the model's context window. */
|
|
58
|
-
compactionThresholdPct: number;
|
|
56
|
+
readonly compactionThresholdPct: number;
|
|
59
57
|
/** Python executable used to launch the sandbox worker. */
|
|
60
|
-
python: string;
|
|
58
|
+
readonly python: string;
|
|
61
59
|
/** Worker startup wait before treating sandbox init as failed (ms). */
|
|
62
|
-
sandboxInitTimeoutMs: number;
|
|
60
|
+
readonly sandboxInitTimeoutMs: number;
|
|
63
61
|
/** Allow ask_user_question() calls from the root REPL. */
|
|
64
|
-
askUserQuestion: boolean;
|
|
62
|
+
readonly askUserQuestion: boolean;
|
|
65
63
|
/** Allow todo() calls from the REPL. */
|
|
66
|
-
todo: boolean;
|
|
64
|
+
readonly todo: boolean;
|
|
67
65
|
/** Enable the load_library() REPL scaffold (external dirs/files/git repos as extra context slots). */
|
|
68
|
-
libraryLoader: boolean;
|
|
66
|
+
readonly libraryLoader: boolean;
|
|
69
67
|
/** ThinkingLevel for the root smart model (set via /rlm-config). */
|
|
70
|
-
smartReasoning?: ThinkingLevel;
|
|
68
|
+
readonly smartReasoning?: ThinkingLevel;
|
|
71
69
|
/** Output token cap + temperature for the root smart model per turn.
|
|
72
70
|
* Keeps each turn short so the next turn's input stays manageable.
|
|
73
71
|
* `reasoning` is read from `smartReasoning` if omitted here. */
|
|
74
|
-
rootSampling?: Readonly<Sampling>;
|
|
72
|
+
readonly rootSampling?: Readonly<Sampling>;
|
|
75
73
|
/** System prompt injected into every llm_query / llm_query_batched sub-call.
|
|
76
74
|
* Instructs the worker model to respond concisely.
|
|
77
75
|
* undefined = no system prompt (raw completion). */
|
|
78
|
-
subSystemPrompt?: string;
|
|
76
|
+
readonly subSystemPrompt?: string;
|
|
79
77
|
/** Sampling for sub-LLM (worker) calls. */
|
|
80
|
-
subSampling:
|
|
78
|
+
readonly subSampling: Readonly<Sampling>;
|
|
81
79
|
/** Optional run-state persistence configuration. Enabled by default. */
|
|
82
80
|
readonly runLog?: RunLogConfig;
|
|
83
81
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/** pi-rlm — Recursive Language Model for Pi. */
|
|
2
2
|
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
5
4
|
import { Markdown } from "@earendil-works/pi-tui";
|
|
6
5
|
import { registerRlmCommand } from "./commands/rlm.ts";
|
|
7
6
|
import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
|
|
@@ -11,6 +10,7 @@ import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts"
|
|
|
11
10
|
import { RlmController, cheapestModel } from "./mode/rlm-mode.ts";
|
|
12
11
|
import { postRlmGuide } from "./ui/intro.ts";
|
|
13
12
|
import { setRlmModeStatus } from "./ui/status.ts";
|
|
13
|
+
import { markdownTheme } from "./ui/theme-adapter.ts";
|
|
14
14
|
import { SandboxManager } from "./sandbox/sandbox-manager.ts";
|
|
15
15
|
import { packRepository, formatForLLM, serializeForSandbox } from "./context/repomix-context.ts";
|
|
16
16
|
import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/system.ts";
|
|
@@ -62,17 +62,24 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
// ── Message renderers ──
|
|
65
|
-
pi
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// Markdown themes are derived from the injected `theme`, never pi's module-global
|
|
66
|
+
// `getMarkdownTheme()` — under jiti that global can be undefined inside a plugin.
|
|
67
|
+
pi.registerMessageRenderer("rlm-answer", (message, _options, theme) =>
|
|
68
|
+
new Markdown(String(message.content ?? ""), 1, 0, markdownTheme(theme)),
|
|
68
69
|
);
|
|
69
|
-
pi.registerMessageRenderer("rlm-question", (message, _options,
|
|
70
|
-
new Markdown(`**RLM question**\n\n${String(message.content ?? "")}`, 1, 0,
|
|
70
|
+
pi.registerMessageRenderer("rlm-question", (message, _options, theme) =>
|
|
71
|
+
new Markdown(`**RLM question**\n\n${String(message.content ?? "")}`, 1, 0, markdownTheme(theme)),
|
|
71
72
|
);
|
|
72
|
-
pi.registerMessageRenderer("rlm-intro", (message, _options,
|
|
73
|
-
new Markdown(String(message.content ?? ""), 1, 0,
|
|
73
|
+
pi.registerMessageRenderer("rlm-intro", (message, _options, theme) =>
|
|
74
|
+
new Markdown(String(message.content ?? ""), 1, 0, markdownTheme(theme)),
|
|
74
75
|
);
|
|
75
76
|
|
|
77
|
+
// ── CLI flag: `pi --rlm` / `pi --rlm=false` overrides the persisted mode for this run ──
|
|
78
|
+
pi.registerFlag("rlm", {
|
|
79
|
+
description: "Start with RLM mode on (repl-only repository reading).",
|
|
80
|
+
type: "boolean",
|
|
81
|
+
});
|
|
82
|
+
|
|
76
83
|
// ── Commands ──
|
|
77
84
|
registerRlmCommand(pi, controller);
|
|
78
85
|
registerRlmConfigCommand(pi, controller);
|
|
@@ -85,6 +92,10 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
85
92
|
// Wait for persisted settings before reading controller state
|
|
86
93
|
await settingsReady;
|
|
87
94
|
|
|
95
|
+
// An explicit --rlm flag wins over the persisted setting for this session.
|
|
96
|
+
const flag = pi.getFlag("rlm");
|
|
97
|
+
if (typeof flag === "boolean") controller.setConfig(Object.freeze({ ...controller.config, enabled: flag }));
|
|
98
|
+
|
|
88
99
|
if (controller.savedWorkerRef) {
|
|
89
100
|
const resolved = resolveModelId(ctx.modelRegistry, controller.savedWorkerRef);
|
|
90
101
|
if (resolved) controller.workerModel = resolved;
|
|
@@ -102,23 +113,34 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
102
113
|
getModel: () => controller.resolveModels(ctx)?.model,
|
|
103
114
|
getWorkerModel: () => controller.resolveModels(ctx)?.worker,
|
|
104
115
|
registry: ctx.modelRegistry,
|
|
105
|
-
|
|
116
|
+
getConfig: () => controller.config,
|
|
106
117
|
registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
|
|
107
118
|
ensureContext: async () => {
|
|
108
119
|
const contextText = await ensureRepositoryContext(ctx.cwd ?? process.cwd());
|
|
109
120
|
if (contextText === undefined) throw new Error("repository context could not be loaded into RLM sandbox");
|
|
110
121
|
},
|
|
111
122
|
}));
|
|
112
|
-
} catch {
|
|
123
|
+
} catch (err) {
|
|
124
|
+
// Re-registering the same tool each session is expected; anything else is a real failure.
|
|
125
|
+
const message = errorMessage(err);
|
|
126
|
+
if (!/already (registered|exists)/i.test(message)) {
|
|
127
|
+
console.warn(`[rlm] repl tool registration failed: ${message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
113
130
|
}
|
|
114
131
|
|
|
115
|
-
setRlmModeStatus(ctx.ui, controller);
|
|
132
|
+
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
|
116
133
|
if (!guidePosted && controller.enabled) {
|
|
117
134
|
guidePosted = true;
|
|
118
135
|
postRlmGuide(pi, controller);
|
|
119
136
|
}
|
|
120
137
|
});
|
|
121
138
|
|
|
139
|
+
// ── Keep the footer's context reading live (RLM exists to shrink this number) ──
|
|
140
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
141
|
+
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
|
142
|
+
});
|
|
143
|
+
|
|
122
144
|
// ── System prompt: native RLM mode addendum (only when enabled) ──
|
|
123
145
|
pi.on("before_agent_start", async (event) => {
|
|
124
146
|
if (!controller.enabled) return;
|
|
@@ -146,7 +168,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
146
168
|
const instruction = [
|
|
147
169
|
"ANALYZE THIS REPOSITORY using repl({code}) — read/grep are DISABLED.",
|
|
148
170
|
"Repository contents are pre-loaded in the Python REPL `context` variable.",
|
|
149
|
-
"
|
|
171
|
+
"Locate with search()/grep_context()/outline() (free), then delegate bulk reading to",
|
|
172
|
+
"map_files()/llm_query_batched(). If credits exhausted → report and stop.",
|
|
150
173
|
"",
|
|
151
174
|
].join("\n");
|
|
152
175
|
filtered.unshift({
|
|
@@ -167,11 +190,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
167
190
|
return { messages: filtered };
|
|
168
191
|
});
|
|
169
192
|
|
|
170
|
-
// ── Input routing: native mode — agent decides whether to use repl() or other tools ──
|
|
171
|
-
pi.on("input", async (_event, _ctx) => {
|
|
172
|
-
return { action: "continue" };
|
|
173
|
-
});
|
|
174
|
-
|
|
175
193
|
// ── Native mode restrictions: keep bulk file content out of root-model context ──
|
|
176
194
|
// `edit`/`write` stay unblocked so the agent modifies files through Pi's native
|
|
177
195
|
// tool flow (visible to all plugins, +/- diff preview). File reading/searching
|
package/src/mode/rlm-mode.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-
|
|
|
11
11
|
import { DEFAULT_RUN_DIR } from "../config/defaults.ts";
|
|
12
12
|
import { modelRef, resolveModelId, saveSettings } from "../config/settings.ts";
|
|
13
13
|
import { createEngine } from "../core/engine.ts";
|
|
14
|
+
import { limitsFromConfig } from "../core/limits.ts";
|
|
14
15
|
import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
|
|
15
16
|
import type { ReconstructResult } from "../state/resume.ts";
|
|
16
17
|
import { packRepository, serializeForSandbox } from "../context/repomix-context.ts";
|
|
@@ -44,8 +45,13 @@ export class RlmController {
|
|
|
44
45
|
return this.config.enabled;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/** Replace the config wholesale — `RlmConfig` is immutable, so edits produce a new object. */
|
|
49
|
+
setConfig(config: RlmConfig): void {
|
|
50
|
+
this.config = config;
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
setEnabled(enabled: boolean): void {
|
|
48
|
-
this.config
|
|
54
|
+
this.config = Object.freeze({ ...this.config, enabled });
|
|
49
55
|
void this.persist();
|
|
50
56
|
}
|
|
51
57
|
|
|
@@ -56,10 +62,6 @@ export class RlmController {
|
|
|
56
62
|
return next;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
hasSavedModels(): boolean {
|
|
60
|
-
return Boolean(this.savedWorkerRef || this.workerModel);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
65
|
async persist(): Promise<boolean> {
|
|
64
66
|
return await saveSettings({
|
|
65
67
|
config: this.config,
|
|
@@ -132,12 +134,7 @@ export class RlmController {
|
|
|
132
134
|
runState,
|
|
133
135
|
onAskUserQuestion: interactive?.onAskUserQuestion,
|
|
134
136
|
onTodo: interactive?.onTodo,
|
|
135
|
-
limits:
|
|
136
|
-
maxBudgetUsd: this.config.maxBudgetUsd,
|
|
137
|
-
maxTimeoutMs: this.config.maxTimeoutMs,
|
|
138
|
-
maxTokens: this.config.maxTokens,
|
|
139
|
-
maxErrors: this.config.maxErrors,
|
|
140
|
-
},
|
|
137
|
+
limits: limitsFromConfig(this.config),
|
|
141
138
|
});
|
|
142
139
|
return await engine(engineInput);
|
|
143
140
|
})().finally(() => {
|