@hicaru/pi-rlm 0.1.2 → 0.1.5

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/README.md CHANGED
@@ -24,7 +24,7 @@ Then run `/reload` or restart Pi. Verify with `pi list` that the package appears
24
24
 
25
25
  <div align="center">
26
26
 
27
- <a href="https://arxiv.org/abs/2512.24601"><img src="../../assets/hero.png" alt="pi-rlm"></a>
27
+ <a href="https://arxiv.org/abs/2512.24601"><img src="https://github.com/openzebra/rlm.pi/blob/master/assets/hero.png?raw=true" alt="pi-rlm"></a>
28
28
 
29
29
  <sub>Modeled on the method in the RLM paper, reimplemented natively for Pi.</sub>
30
30
 
@@ -112,6 +112,7 @@ These functions are injected into the model's Python namespace inside the REPL:
112
112
  | `context` | `list[dict]` | Repository packed as `[{"path","content","tokens"}, ...]` — the full codebase |
113
113
  | `llm_query` | `(prompt, model=None) -> str` | One-shot sub-LLM call (worker model) |
114
114
  | `llm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent sub-LLM calls (pool-bounded) |
115
+ | `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | Split large text into cap-sized chunks and fan out via sub-LLMs |
115
116
  | `rlm_query` | `(prompt, model=None) -> str` | Recursive child RLM with its own sandbox (depth-capped) |
116
117
  | `rlm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent recursive child RLMs |
117
118
  | `todo` | `(action, **kwargs) -> str` | Task list: `create`/`update`/`list`/`get`/`delete`/`clear` |
package/README.ru.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="../../assets/hero.png" alt="pi-rlm">
3
+ <img src="https://github.com/openzebra/rlm.pi/blob/master/assets/hero.png?raw=true" alt="pi-rlm">
4
4
 
5
5
  </div>
6
6
 
@@ -106,6 +106,7 @@ rm -rf ~/.pi/agent/extensions/rlm
106
106
  | `context` | `list[dict]` | Репозиторий, упакованный как `[{"path","content","tokens"}, ...]` — вся кодовая база |
107
107
  | `llm_query` | `(prompt, model=None) -> str` | Одноразовый вызов sub-LLM (worker-модель) |
108
108
  | `llm_query_batched` | `(prompts, model=None) -> list[str]` | Параллельные вызовы sub-LLM (с ограничением пула) |
109
+ | `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | Дробит большой текст на части по лимиту и обрабатывает через sub-LLM |
109
110
  | `rlm_query` | `(prompt, model=None) -> str` | Рекурсивный дочерний RLM со своей песочницей (с ограничением глубины) |
110
111
  | `rlm_query_batched` | `(prompts, model=None) -> list[str]` | Параллельные рекурсивные дочерние RLM |
111
112
  | `todo` | `(action, **kwargs) -> str` | Список задач: `create`/`update`/`list`/`get`/`delete`/`clear` |
package/README.zh-CN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="../../assets/hero.png" alt="pi-rlm">
3
+ <img src="https://github.com/openzebra/rlm.pi/blob/master/assets/hero.png?raw=true" alt="pi-rlm">
4
4
 
5
5
  </div>
6
6
 
@@ -113,6 +113,7 @@ rm -rf ~/.pi/agent/extensions/rlm
113
113
  | `context` | `list[dict]` | 打包为 `[{"path","content","tokens"}, ...]` 的仓库 —— 完整的代码库 |
114
114
  | `llm_query` | `(prompt, model=None) -> str` | 单次子 LLM 调用 (worker 模型) |
115
115
  | `llm_query_batched` | `(prompts, model=None) -> list[str]` | 并发子 LLM 调用 (池上限) |
116
+ | `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | 将大文本拆分为不超过上限的块并通过子 LLM 处理 |
116
117
  | `rlm_query` | `(prompt, model=None) -> str` | 具有自有沙箱的递归子 RLM (设有深度限制) |
117
118
  | `rlm_query_batched` | `(prompts, model=None) -> list[str]` | 并发递归子 RLM |
118
119
  | `todo` | `(action, **kwargs) -> str` | 任务列表:`create`/`update`/`list`/`get`/`delete`/`clear` |
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
6
6
  "license": "MIT",
7
7
  "author": "hicaru",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/hicaru/rlm.pi.git"
10
+ "url": "git+https://github.com/openzebra/rlm.pi.git"
11
11
  },
12
- "homepage": "https://github.com/hicaru/rlm.pi",
12
+ "homepage": "https://github.com/openzebra/rlm.pi",
13
13
  "bugs": {
14
- "url": "https://github.com/hicaru/rlm.pi/issues"
14
+ "url": "https://github.com/openzebra/rlm.pi/issues"
15
15
  },
16
16
  "files": [
17
17
  "src/",
@@ -11,6 +11,7 @@ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
11
11
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
12
  import type { RlmEmitter } from "../tool/rlm-events.ts";
13
13
  import { modelRef, resolveModelId } from "../config/settings.ts";
14
+ import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
14
15
  import type { Sampling } from "../core/types.ts";
15
16
  import { type ChatMsg, modelComplete } from "./model.ts";
16
17
  import { previewText } from "../text/preview.ts";
@@ -26,6 +27,8 @@ export interface LlmBridgeOptions {
26
27
  readonly sampling?: Sampling;
27
28
  readonly signal?: AbortSignal;
28
29
  readonly onUsage?: (usage: Usage, model: Model<Api>) => void;
30
+ /** Parent run's remaining budget/timeout; checked before every sub-call. */
31
+ readonly remainingBudget?: () => RemainingResources;
29
32
  /** Live RlmDetails reporting via onUpdate. */
30
33
  readonly emitter?: RlmEmitter;
31
34
  readonly parentId?: string;
@@ -49,6 +52,11 @@ export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
49
52
 
50
53
  // Run one completion; report cost/tokens via `track` (a per-call or per-batch accumulator).
51
54
  async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
55
+ const rem = opts.remainingBudget?.();
56
+ if (rem !== undefined) {
57
+ const limitError = checkResourceLimits(rem);
58
+ if (limitError !== undefined) return limitError;
59
+ }
52
60
  if (prompt.length > maxPromptChars) {
53
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.`);
54
62
  }
@@ -20,6 +20,7 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
20
20
  maxPromptChars: 400_000,
21
21
  maxErrors: 5,
22
22
  orchestrator: true,
23
+ pipeline: false,
23
24
  compaction: true,
24
25
  compactionThresholdPct: 0.65,
25
26
  python: "python3",
@@ -73,6 +73,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
73
73
  if (maxErrors !== undefined) out.maxErrors = maxErrors;
74
74
  const orchestrator = validateBoolean(r.orchestrator);
75
75
  if (orchestrator !== undefined) out.orchestrator = orchestrator;
76
+ const pipeline = validateBoolean(r.pipeline);
77
+ if (pipeline !== undefined) out.pipeline = pipeline;
76
78
  const compaction = validateBoolean(r.compaction);
77
79
  if (compaction !== undefined) out.compaction = compaction;
78
80
  const compactionThresholdPct = validateNumber(r.compactionThresholdPct, 0);
@@ -98,6 +98,7 @@ export async function packRepository(
98
98
  output: {
99
99
  filePath: `${tmpdir()}/repomix-out-${Date.now()}.txt`,
100
100
  style: "plain",
101
+ filePathStyle: "cwd-relative",
101
102
  parsableStyle: false,
102
103
  headerText: undefined,
103
104
  instructionFilePath: undefined,
@@ -219,7 +220,7 @@ const MAX_LLM_LISTING_FILES = 200;
219
220
  /**
220
221
  * Produces a compact human-readable text block for the parent LLM's context window.
221
222
  * Shows file paths and token estimates — NOT full file contents (those are too large
222
- * for the context window). The LLM uses its file-reading tools to inspect specific files.
223
+ * for the context window). The LLM inspects files via repl() over the pre-loaded context variable.
223
224
  */
224
225
  export function formatForLLM(bundle: ContextBundle): string {
225
226
  const files = bundle.files.slice(0, MAX_LLM_LISTING_FILES);
@@ -237,7 +238,8 @@ export function formatForLLM(bundle: ContextBundle): string {
237
238
  listing,
238
239
  truncated,
239
240
  "",
240
- "To read a file, use the file-reading tools with the exact path.",
241
+ "All file contents are pre-loaded in the REPL `context` variable — file-reading tools are disabled.",
242
+ "Use repl({code}) and delegate semantic reading to llm_query / llm_query_batched / llm_query_chunked.",
241
243
  ].join("\n");
242
244
  }
243
245
 
@@ -37,10 +37,11 @@ export function turnHadError(results: readonly ReplResult[]): boolean {
37
37
  * hist ← hist ∥ code ∥ Metadata(stdout)). */
38
38
  const SMALL_STDOUT_LIMIT = 800;
39
39
  const STDOUT_PREVIEW_LIMIT = 200;
40
+ const STDOUT_TAIL_LIMIT = 200;
40
41
  const STDERR_LIMIT = 8_000;
41
42
 
42
43
  /** The REPL output fed back to the model as the next user message. */
43
- export function formatReplOutputs(results: readonly ReplResult[]): string {
44
+ export function formatReplOutputs(results: readonly ReplResult[], skippedBlocks = 0): string {
44
45
  if (results.length === 0) {
45
46
  return "No ```repl``` block found in your response. Write one to interact with the REPL.";
46
47
  }
@@ -54,15 +55,18 @@ export function formatReplOutputs(results: readonly ReplResult[]): string {
54
55
  parts.push(`${head}${text}${formatStderr(r)}`);
55
56
  }
56
57
  const body = parts.join("\n\n");
58
+ const skipNote = skippedBlocks > 0
59
+ ? `\n\n[${skippedBlocks} later \`\`\`repl\`\`\` block(s) skipped because an earlier block raised — fix and re-run them]`
60
+ : "";
57
61
  // Orientation hint only when the model lost output to elision — otherwise it sees everything.
58
- if (!hadElision) return body;
62
+ if (!hadElision) return `${body}${skipNote}`;
59
63
  // The REPL namespace is persistent across blocks in a turn, so the last block's varNames reflect
60
64
  // every variable created in any earlier block too.
61
65
  const varNames = results.at(-1)?.varNames ?? [];
62
66
  const hint = varNames.length > 0
63
67
  ? `REPL vars: ${varNames.join(", ")}`
64
68
  : `No REPL vars yet — assign results to variables before printing large outputs.`;
65
- return `${body}\n\n${hint}`;
69
+ return `${body}${skipNote}\n\n${hint}`;
66
70
  }
67
71
 
68
72
  /** Stdout ≤ SMALL_STDOUT_LIMIT flows through verbatim; larger output keeps a short head + a note
@@ -71,8 +75,15 @@ function formatStdout(r: ReplResult): { text: string; elided: boolean } {
71
75
  const out = r.stdout.trim();
72
76
  if (!out) return { text: "(no stdout)", elided: false };
73
77
  if (out.length <= SMALL_STDOUT_LIMIT) return { text: out, elided: false };
74
- const note = `[+${out.length - STDOUT_PREVIEW_LIMIT} chars elided — use slices to inspect: print(result[:500])]`;
75
- return { text: `${out.slice(0, STDOUT_PREVIEW_LIMIT)}\n${note}`, elided: true };
78
+ const cut = out.length - STDOUT_PREVIEW_LIMIT - STDOUT_TAIL_LIMIT;
79
+ return {
80
+ text: [
81
+ out.slice(0, STDOUT_PREVIEW_LIMIT),
82
+ `[… ${cut} chars elided — full output stays in REPL vars; inspect slices: print(result[:500])]`,
83
+ out.slice(-STDOUT_TAIL_LIMIT),
84
+ ].join("\n"),
85
+ elided: true,
86
+ };
76
87
  }
77
88
 
78
89
  function formatStderr(r: ReplResult): string {
@@ -98,6 +98,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
98
98
  maxErrors: deps.limits?.maxErrors,
99
99
  maxTokens: deps.limits?.maxTokens,
100
100
  }, input.resume?.usageSeed.durationMs ?? 0);
101
+ const remainingBudget = (): { readonly budgetUsd?: number; readonly timeoutMs?: number } => ({
102
+ budgetUsd: limits.remainingBudgetUsd(),
103
+ timeoutMs: limits.remainingTimeoutMs(),
104
+ });
101
105
 
102
106
  const llm = createLlmBridge({
103
107
  workerModel: deps.workerModel,
@@ -114,6 +118,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
114
118
  emitter,
115
119
  parentId: selfReportId,
116
120
  depth: input.depth,
121
+ remainingBudget,
117
122
  });
118
123
  const rlm = createRlmHandlers({
119
124
  run,
@@ -122,10 +127,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
122
127
  maxDepth: deps.config.maxDepth,
123
128
  maxConcurrent: deps.config.maxConcurrentSubcalls,
124
129
  parentNodeId: selfReportId,
125
- remainingBudget: () => ({
126
- budgetUsd: limits.remainingBudgetUsd(),
127
- timeoutMs: limits.remainingTimeoutMs(),
128
- }),
130
+ remainingBudget,
129
131
  onChildUsage: (costUsd, inputTokens, outputTokens) => {
130
132
  limits.addRaw(costUsd, inputTokens, outputTokens);
131
133
  },
@@ -150,7 +152,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
150
152
  rootPrompt: input.rootPrompt,
151
153
  context: { type: contextTypeLabel(input.context), chars: contextLength(input.context), json },
152
154
  models: { model: model.id, worker: deps.workerModel.id },
153
- meta: { maxIterations: deps.config.maxIterations, maxDepth: deps.config.maxDepth, orchestrator: deps.config.orchestrator, pipeline: true },
155
+ meta: { maxIterations: deps.config.maxIterations, maxDepth: deps.config.maxDepth, orchestrator: deps.config.orchestrator, pipeline: deps.config.pipeline },
154
156
  };
155
157
  persistOn = await appendRow(deps.runState.cwd, deps.runState.dir, runId, header);
156
158
  }
@@ -166,7 +168,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
166
168
  };
167
169
 
168
170
  try {
169
- const phaseHandlers = input.depth === 0
171
+ const phaseHandlers = input.depth === 0 && deps.config.pipeline
170
172
  ? {
171
173
  advancePhase: async (phase: string, summary: string | undefined) => {
172
174
  const outcome = validatePhaseTransition(phaseState?.current, phase);
@@ -205,6 +207,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
205
207
  python: deps.config.python,
206
208
  signal: deps.signal,
207
209
  initTimeoutMs: deps.config.sandboxInitTimeoutMs,
210
+ maxPromptChars: deps.config.maxPromptChars,
208
211
  handlers: { ...llm, ...rlm, ...phaseHandlers, ...interactiveHandlers },
209
212
  });
210
213
 
@@ -219,6 +222,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
219
222
  recursion: input.depth + 1 < deps.config.maxDepth,
220
223
  askUserQuestion: deps.config.askUserQuestion && input.depth === 0,
221
224
  todo: deps.config.todo,
225
+ pipeline: deps.config.pipeline && input.depth === 0,
226
+ maxPromptChars: deps.config.maxPromptChars,
222
227
  });
223
228
  let history: ChatMsg[] = input.resume ? input.resume.history : [{ role: "system", content: system }];
224
229
  let pendingReplOutputs: string | undefined = input.resume?.pendingReplOutputs;
@@ -247,11 +252,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
247
252
  if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
248
253
  else emitter.emitTurn(i + 1, deps.config.maxIterations);
249
254
 
250
- if (pendingReplOutputs) {
251
- appendUserMessage(history, pendingReplOutputs);
252
- pendingReplOutputs = undefined;
253
- }
254
-
255
255
  if (deps.config.compaction) {
256
256
  const compactionDeps = {
257
257
  // Summarisation is done by the cheap worker model; the threshold stays on the
@@ -279,7 +279,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
279
279
  }
280
280
  }
281
281
 
282
- const gateMsg = phaseGatePrompt(phaseState, completedTurns);
282
+ if (pendingReplOutputs) {
283
+ appendUserMessage(history, pendingReplOutputs);
284
+ pendingReplOutputs = undefined;
285
+ }
286
+
287
+ const gateMsg = deps.config.pipeline ? phaseGatePrompt(phaseState, completedTurns) : undefined;
283
288
  const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
284
289
  appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, gateUserMsg));
285
290
 
@@ -320,7 +325,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
320
325
 
321
326
  limits.observe(turnHadError(turn.results));
322
327
  history.push({ role: "assistant", content: turn.response });
323
- const turnReplOutputs = formatReplOutputs(turn.results);
328
+ const turnReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
324
329
  pendingReplOutputs = turnReplOutputs;
325
330
 
326
331
  if (persistOn && runId && deps.runState) {
@@ -343,7 +348,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
343
348
  }
344
349
  }
345
350
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
346
- const finalized = result(await finalize(history, deps, limits), deps.config.maxIterations, limits, editsAcc);
351
+ const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits, editsAcc);
347
352
  await recordTerminal("finalized", finalized);
348
353
  lastAnswer = finalized.answer;
349
354
  return finalized;
@@ -389,11 +394,11 @@ function result(answer: string, iterations: number, limits: LimitGuard, edits: P
389
394
  }
390
395
 
391
396
  /** Out of turns: ask the model for its best final answer (plain text). */
392
- async function finalize(history: ChatMsg[], deps: EngineDeps, limits: LimitGuard): Promise<string> {
397
+ async function finalize(history: ChatMsg[], model: Model<Api>, deps: EngineDeps, limits: LimitGuard): Promise<string> {
393
398
  const finalHistory = [...history];
394
399
  appendUserMessage(finalHistory, FINALIZE_PROMPT);
395
400
  const { text, usage } = await modelComplete(finalHistory, {
396
- model: deps.model,
401
+ model,
397
402
  registry: deps.registry,
398
403
  reasoning: deps.config.smartReasoning,
399
404
  signal: deps.signal,
@@ -17,6 +17,8 @@ export interface Turn {
17
17
  readonly results: readonly ReplResult[];
18
18
  readonly usage: Usage;
19
19
  readonly blocks: readonly string[];
20
+ /** Blocks not executed because an earlier block raised. */
21
+ readonly skippedBlocks: number;
20
22
  }
21
23
 
22
24
  export interface TurnDeps {
@@ -38,8 +40,12 @@ export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbo
38
40
 
39
41
  const blocks = findReplBlocks(text);
40
42
  const results = new Array<ReplResult>(blocks.length);
43
+ let executed = 0;
41
44
  for (let i = 0; i < blocks.length; i++) {
42
45
  results[i] = await sandbox.exec(blocks[i]);
46
+ executed = i + 1;
47
+ if (results[i].raised) break;
43
48
  }
44
- return { response: text, results, usage, blocks };
49
+ results.length = executed;
50
+ return { response: text, results, usage, blocks, skippedBlocks: blocks.length - executed };
45
51
  }
@@ -80,7 +80,7 @@ export function phaseGatePrompt(
80
80
  ): string | undefined {
81
81
  const turns = turnsInPhase(state, completedTurns);
82
82
  const phase = currentPhase(state);
83
- if (turns >= PHASE_GATE_TURNS) {
83
+ if (turns >= PHASE_GATE_TURNS && turns % PHASE_GATE_TURNS === 0) {
84
84
  const next = nextPhase(phase);
85
85
  const hint = next
86
86
  ? ` Consider calling advance_phase("${next}") if your ${phase} work is complete.`
package/src/core/types.ts CHANGED
@@ -48,6 +48,8 @@ export interface RlmConfig {
48
48
  maxErrors?: number;
49
49
  /** Append the orchestrator addendum to the system prompt. */
50
50
  orchestrator: boolean;
51
+ /** Enable the phase pipeline (advance_phase + stall nags) at depth 0. */
52
+ pipeline: boolean;
51
53
  /** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
52
54
  compaction: boolean;
53
55
  /** Compact when estimated history tokens reach this fraction of the model's context window. */
package/src/index.ts CHANGED
@@ -13,10 +13,12 @@ import { postRlmGuide } from "./ui/intro.ts";
13
13
  import { setRlmModeStatus } from "./ui/status.ts";
14
14
  import { SandboxManager } from "./sandbox/sandbox-manager.ts";
15
15
  import { packRepository, formatForLLM, serializeForSandbox } from "./context/repomix-context.ts";
16
- import { buildNativeSystemPrompt } from "./prompts/system.ts";
16
+ import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/system.ts";
17
+ import { bashCommandFromInput, isFileReadingCommand, capToolResultText, BASH_BLOCK_REASON } from "./mode/native-guards.ts";
17
18
  import { errorMessage } from "./util/errors.ts";
18
19
 
19
20
  const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep"]));
21
+ const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls"]));
20
22
 
21
23
  export default function rlmExtension(pi: ExtensionAPI): void {
22
24
  // Init synchronously with defaults — ensures commands/tools/handlers register before session_start
@@ -27,6 +29,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
27
29
  requestTimeoutMs: config.requestTimeoutMs,
28
30
  python: config.python,
29
31
  sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
32
+ maxPromptChars: config.maxPromptChars,
30
33
  });
31
34
  let packedContextText: string | undefined;
32
35
  let contextPackPromise: Promise<string | undefined> | undefined;
@@ -123,11 +126,16 @@ export default function rlmExtension(pi: ExtensionAPI): void {
123
126
  let contextInjected = false;
124
127
  pi.on("context", async (event, ctx) => {
125
128
  const filtered = event.messages.filter(
126
- (message) => !(message.role === "custom" && message.customType === "rlm-intro"),
129
+ (message) =>
130
+ !(message.role === "custom" && message.customType === "rlm-intro")
131
+ && !(message.role === "user" && typeof message.content === "string" && message.content === NATIVE_TURN_REMINDER),
127
132
  );
133
+ if (!controller.enabled) return { messages: filtered };
128
134
 
129
- // Inject repository context as a compact listing (once per session, only when RLM is enabled)
130
- if (controller.enabled && !contextInjected) {
135
+ type PiMessage = (typeof filtered)[number];
136
+
137
+ // Inject repository context as a compact listing (once per session)
138
+ if (!contextInjected) {
131
139
  const cwd = ctx.cwd ?? process.cwd();
132
140
  const contextText = await ensureRepositoryContext(cwd);
133
141
  if (contextText !== undefined) {
@@ -138,16 +146,21 @@ export default function rlmExtension(pi: ExtensionAPI): void {
138
146
  "Chunk context via Python, delegate to llm_query. If credits exhausted → report and stop.",
139
147
  "",
140
148
  ].join("\n");
141
- const contextMsg = {
149
+ filtered.unshift({
142
150
  role: "user" as const,
143
151
  content: instruction + contextText,
144
152
  timestamp: 0,
145
- } as (typeof filtered)[number];
146
-
147
- return { messages: [contextMsg, ...filtered] };
153
+ } as PiMessage);
148
154
  }
149
155
  }
150
156
 
157
+ // Per-turn last-position reminder (not persisted — context hook rebuilds every request)
158
+ filtered.push({
159
+ role: "user" as const,
160
+ content: NATIVE_TURN_REMINDER,
161
+ timestamp: 0,
162
+ } as PiMessage);
163
+
151
164
  return { messages: filtered };
152
165
  });
153
166
 
@@ -156,10 +169,10 @@ export default function rlmExtension(pi: ExtensionAPI): void {
156
169
  return { action: "continue" };
157
170
  });
158
171
 
159
- // ── Tool restriction: block analysis tools when RLM is ON ──
172
+ // ── Native mode restrictions: keep bulk file content out of root-model context ──
160
173
  // `edit`/`write` stay unblocked so the agent modifies files through Pi's native
161
- // tool flow (visible to all plugins, +/- diff preview). Only read/grep are
162
- // blocked the repository is pre-loaded in the REPL `context` variable.
174
+ // tool flow (visible to all plugins, +/- diff preview). File reading/searching
175
+ // belongs in the REPL, and bash output is capped as a backstop.
163
176
  pi.on("tool_call", async (event) => {
164
177
  if (!controller.enabled) return;
165
178
  if (BLOCKED_NATIVE_TOOLS.has(event.toolName)) {
@@ -168,6 +181,23 @@ export default function rlmExtension(pi: ExtensionAPI): void {
168
181
  reason: "RLM mode active. Use repl({code}) to read files and search the repository — all files are pre-loaded in the REPL `context` variable. Use `edit`/`write` for file changes. If sub-LLM credits are exhausted, report to the user.",
169
182
  };
170
183
  }
184
+ const bashCommand = event.toolName === "bash" ? bashCommandFromInput(event.input) : undefined;
185
+ if (bashCommand !== undefined && isFileReadingCommand(bashCommand)) {
186
+ return { block: true, reason: BASH_BLOCK_REASON };
187
+ }
188
+ });
189
+
190
+ pi.on("tool_result", async (event) => {
191
+ if (!controller.enabled || !CAPPED_RESULT_TOOLS.has(event.toolName)) return;
192
+ let changed = false;
193
+ const content = event.content.map((c) => {
194
+ if (c.type !== "text") return c;
195
+ const capped = capToolResultText(c.text);
196
+ if (capped === undefined) return c;
197
+ changed = true;
198
+ return { ...c, type: "text" as const, text: capped };
199
+ });
200
+ return changed ? { content } : undefined;
171
201
  });
172
202
 
173
203
  // ── Session shutdown: cleanup ──
@@ -0,0 +1,92 @@
1
+ /**
2
+ * native-guards — keeps bulk file content out of the root model's context in native RLM mode.
3
+ * Layer 1: block file-reading bash commands (steering — the redirect message re-educates).
4
+ * Layer 2: cap tool_result text AND repl() stdout (guarantee — bulk text physically cannot
5
+ * reach the root model).
6
+ * Layer 3: nudge the model when a repl() call printed bulk text without delegating to a sub-LLM.
7
+ * This is token protection, not a security boundary: a determined model can still emit small reads.
8
+ */
9
+ import { truncateOutput } from "../text/parsing.ts";
10
+
11
+ /** Bash commands whose purpose is printing file content / searching — the REPL owns those jobs. */
12
+ const READER_COMMANDS: ReadonlySet<string> = Object.freeze(new Set([
13
+ "cat", "sed", "head", "tail", "awk", "grep", "rg", "less", "more",
14
+ "cut", "nl", "tac", "strings", "xxd", "od", "column",
15
+ ]));
16
+
17
+ /** Head token of the first pipe segment of each statement (split on ;, &&, ||, newline). */
18
+ function statementHeads(command: string): string[] {
19
+ return command
20
+ .split(/(?:\|\||&&|;|\n)/)
21
+ .map((statement) => {
22
+ const firstSegment = statement.split("|", 1)[0] ?? "";
23
+ const tokens = firstSegment.trim().split(/\s+/);
24
+ // Skip env-var prefixes (FOO=bar cmd) and leading wrappers that don't change intent.
25
+ const head = tokens.find((t) => !t.includes("=") && t !== "sudo" && t !== "command" && t !== "env");
26
+ return head === undefined || head.length === 0 ? undefined : head.replace(/^.*\//, "");
27
+ })
28
+ .filter((h): h is string => h !== undefined);
29
+ }
30
+
31
+ /** Extract bash input.command from an unknown Pi tool input shape. */
32
+ export function bashCommandFromInput(input: unknown): string | undefined {
33
+ if (typeof input !== "object" || input === null) return undefined;
34
+ const command: unknown = (input as Record<string, unknown>)["command"];
35
+ return typeof command === "string" ? command : undefined;
36
+ }
37
+
38
+ /** True when a statement starts with a file-reading command. */
39
+ export function isFileReadingCommand(command: string): boolean {
40
+ return statementHeads(command).some((h) => READER_COMMANDS.has(h));
41
+ }
42
+
43
+ export const BASH_BLOCK_REASON =
44
+ "RLM mode: reading files via bash is blocked — that dumps file content into the root model's " +
45
+ "context. All files are pre-loaded in the REPL `context` variable: use repl({code}) with Python " +
46
+ "string/regex search, and delegate bulk analysis to llm_query / llm_query_batched / " +
47
+ "llm_query_chunked. bash is for RUNNING things (tests, builds, git).";
48
+
49
+ /** Max chars of tool output forwarded to the root model (≈1K tokens). */
50
+ export const TOOL_RESULT_CAP = 4_000;
51
+
52
+ const CAP_NOTE =
53
+ `\n[RLM: tool output capped at ${TOOL_RESULT_CAP.toLocaleString()} chars to protect the root ` +
54
+ "model's context — route bulk text through repl() + llm_query_chunked / llm_query_batched.]";
55
+
56
+ const REPL_CAP_NOTE =
57
+ `\n[RLM: repl() stdout capped at ${TOOL_RESULT_CAP.toLocaleString()} chars — printing bulk text ` +
58
+ "is useless. Keep results in REPL variables and delegate semantic reading to llm_query / " +
59
+ "llm_query_batched / llm_query_chunked.]";
60
+
61
+ /** Shared truncation core. Returns undefined when under the cap (leave the text untouched). */
62
+ function capText(text: string, note: string): string | undefined {
63
+ if (text.length <= TOOL_RESULT_CAP) return undefined;
64
+ return truncateOutput(text, TOOL_RESULT_CAP) + note;
65
+ }
66
+
67
+ /** Cap a generic tool result's text (bash / find / ls). */
68
+ export function capToolResultText(text: string): string | undefined {
69
+ return capText(text, CAP_NOTE);
70
+ }
71
+
72
+ /** Cap the model-visible portion of a repl() result (stdout / answerContent). */
73
+ export function capReplResultText(text: string): string | undefined {
74
+ return capText(text, REPL_CAP_NOTE);
75
+ }
76
+
77
+ /** Stdout size above which a repl() call with zero sub-LLM calls earns a delegation nudge. */
78
+ export const NUDGE_STDOUT_CHARS = 2_000;
79
+
80
+ /**
81
+ * One-line corrective nudge when a repl() call printed bulk text without delegating.
82
+ * Returns undefined when behavior was fine (small output, or a delegation happened).
83
+ * Takes primitives (no RlmSubcall dependency) so the tool layer owns the detection.
84
+ */
85
+ export function replDelegationNudge(stdoutChars: number, delegated: boolean): string | undefined {
86
+ if (delegated || stdoutChars <= NUDGE_STDOUT_CHARS) return undefined;
87
+ return (
88
+ `\n[RLM: this repl() printed ${stdoutChars.toLocaleString()} chars with 0 sub-LLM calls — ` +
89
+ "delegate semantic reading via llm_query / llm_query_batched / llm_query_chunked instead of " +
90
+ "reading output yourself.]"
91
+ );
92
+ }