@hicaru/pi-rlm 0.2.2 → 0.3.1

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.
Files changed (50) hide show
  1. package/README.md +38 -16
  2. package/README.ru.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/package.json +22 -19
  5. package/src/bridge/add-context.ts +322 -0
  6. package/src/bridge/subcall-handlers.ts +1 -1
  7. package/src/config/defaults.ts +2 -1
  8. package/src/config/settings.ts +5 -2
  9. package/src/context/anydoc.ts +67 -0
  10. package/src/context/listing.ts +70 -0
  11. package/src/context/md-cache.ts +112 -0
  12. package/src/context/merge.ts +97 -0
  13. package/src/context/namespace.ts +180 -0
  14. package/src/context/resolve.ts +122 -0
  15. package/src/context/source-dir.ts +166 -0
  16. package/src/context/source-doc.ts +71 -0
  17. package/src/context/source-git.ts +51 -0
  18. package/src/context/source-text.ts +45 -0
  19. package/src/context/types.ts +88 -0
  20. package/src/context/walk.ts +250 -0
  21. package/src/core/engine.ts +15 -19
  22. package/src/core/types.ts +7 -2
  23. package/src/index.ts +119 -47
  24. package/src/mode/rlm-mode.ts +5 -4
  25. package/src/mode/subagent.ts +68 -0
  26. package/src/prompts/glossary.ts +31 -28
  27. package/src/prompts/native.ts +4 -4
  28. package/src/prompts/system.ts +2 -2
  29. package/src/sandbox/context-file.ts +4 -4
  30. package/src/sandbox/interrupts.ts +25 -10
  31. package/src/sandbox/protocol.ts +13 -7
  32. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
  34. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  35. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  36. package/src/sandbox/py/guards.py +8 -1
  37. package/src/sandbox/py/hostio.py +57 -0
  38. package/src/sandbox/py/retrieval.py +1 -1
  39. package/src/sandbox/py/tasks.py +17 -4
  40. package/src/sandbox/py/worker.py +71 -52
  41. package/src/sandbox/sandbox-manager.ts +18 -16
  42. package/src/sandbox/sandbox.ts +9 -2
  43. package/src/text/tokens.ts +3 -3
  44. package/src/tool/repl-details.ts +1 -1
  45. package/src/tool/repl-tool.ts +31 -19
  46. package/src/tool/rlm-tool.ts +1 -1
  47. package/src/ui/config-panel.ts +8 -4
  48. package/src/bridge/library.ts +0 -190
  49. package/src/context/library-context.ts +0 -339
  50. package/src/context/repomix-context.ts +0 -204
@@ -9,8 +9,8 @@
9
9
 
10
10
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
11
11
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
- import { buildLibraryHandler } from "../bridge/library.ts";
13
- import { mergeLibraryIntoContext } from "../context/library-context.ts";
12
+ import { buildAddContextHandler } from "../bridge/add-context.ts";
13
+ import { mergeIntoContext } from "../context/merge.ts";
14
14
  import {
15
15
  createSubcallHandlers,
16
16
  type Invocation,
@@ -30,7 +30,6 @@ import { appendUserMessage } from "./history.ts";
30
30
  import { runTurn } from "./iteration.ts";
31
31
  import { type Limits, LimitError, LimitGuard } from "./limits.ts";
32
32
  import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
33
- import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
34
33
  import { formatError } from "../util/errors.ts";
35
34
  import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
36
35
 
@@ -128,7 +127,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
128
127
  getConfig: () => deps.config,
129
128
  signal: deps.signal,
130
129
  runChild: run,
131
- // Read lazily: a library loaded on turn 3 must reach a child spawned on turn 4. Safe
130
+ // Read lazily: a source added on turn 3 must reach a child spawned on turn 4. Safe
132
131
  // despite being wired before liveContext is assigned — children can only spawn from an
133
132
  // interrupt during runTurn, which is strictly after loadContext below.
134
133
  getChildContext: () => liveContext,
@@ -153,19 +152,19 @@ export function createEngine(deps: EngineDeps): RunRlm {
153
152
  };
154
153
  let sandbox: PythonSandbox | undefined;
155
154
  /**
156
- * This run's live context: the repo pack plus every library loaded so far. Children inherit
157
- * it, so it must grow when load_library appends (see the library handler's onLoaded below).
155
+ * This run's live context: whatever was seeded plus every source added so far. Children
156
+ * inherit it, so it must grow when add_context appends (see the handler's onLoaded below).
158
157
  *
159
158
  * Run-scoped on purpose — recursion means N of these are live at once, and a module-level
160
159
  * "current context" would hand a depth-3 child its cousin's world.
161
160
  */
162
- let liveContext: unknown = null;
161
+ let liveContext: unknown = [];
163
162
  /**
164
163
  * This run's hold on the serialized context file. Kept for the whole run so every child that
165
164
  * inherits the same payload reuses one file instead of re-serializing the repository.
166
165
  */
167
166
  let contextPin: PinnedContext | undefined;
168
- /** Re-pin after the payload changes identity (mergeLibraryIntoContext returns a new array). */
167
+ /** Re-pin after the payload changes identity (mergeIntoContext returns a new array). */
169
168
  const repinLiveContext = async (): Promise<void> => {
170
169
  const previous = contextPin;
171
170
  contextPin = await pinContext(liveContext);
@@ -188,12 +187,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
188
187
  orchestrator: deps.config.orchestrator,
189
188
  recursion: input.depth + 1 < deps.config.maxDepth,
190
189
  maxPromptChars: deps.config.maxPromptChars,
191
- libraryLoader: deps.config.libraryLoader,
190
+ contextLoader: deps.config.contextLoader,
192
191
  child: input.depth > 0,
193
192
  });
194
193
 
195
- const libraryHandlers = deps.config.libraryLoader
196
- ? buildLibraryHandler({
194
+ const contextHandlers = deps.config.contextLoader
195
+ ? buildAddContextHandler({
197
196
  cwd: runCwd,
198
197
  emitter,
199
198
  parentId: selfReportId,
@@ -201,8 +200,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
201
200
  getContext: () => liveContext,
202
201
  onLoaded: async (payload) => {
203
202
  // The accumulator is what children inherit, which is what makes inheritance
204
- // transitive — grandchildren see the library too.
205
- liveContext = mergeLibraryIntoContext(liveContext, payload);
203
+ // transitive — grandchildren see the source too.
204
+ liveContext = mergeIntoContext(liveContext, payload);
206
205
  await repinLiveContext();
207
206
  },
208
207
  }).handlers
@@ -217,17 +216,14 @@ export function createEngine(deps: EngineDeps): RunRlm {
217
216
  initTimeoutMs: deps.config.sandboxInitTimeoutMs,
218
217
  maxPromptChars: deps.config.maxPromptChars,
219
218
  awaitTimeoutS: Math.round(deps.config.requestTimeoutMs / 1000),
220
- handlers: { ...subcalls, ...libraryHandlers },
219
+ handlers: { ...subcalls, ...contextHandlers },
221
220
  });
222
221
 
223
222
  let history: ChatMsg[] = [{ role: "system", content: system }];
224
223
  let pendingReplOutputs: string | undefined;
225
224
 
226
- // Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
227
- liveContext =
228
- typeof input.context === "object" && input.context !== null && "files" in input.context
229
- ? serializeForSandbox(input.context as ContextBundle)
230
- : input.context;
225
+ // Context is already a sandbox-ready list (or a raw string for text children).
226
+ liveContext = input.context ?? [];
231
227
  contextPin = await pinContext(liveContext);
232
228
  await sandbox.loadContextPinned(contextPin);
233
229
  for (let i = 0; i < deps.config.maxIterations; i++) {
package/src/core/types.ts CHANGED
@@ -42,8 +42,13 @@ export interface RlmConfig {
42
42
  readonly python: string;
43
43
  /** Worker startup wait before treating sandbox init as failed (ms). */
44
44
  readonly sandboxInitTimeoutMs: number;
45
- /** Enable the load_library() REPL scaffold (external dirs/files/git repos as extra context slots). */
46
- readonly libraryLoader: boolean;
45
+ /** Enable the add_context() REPL scaffold (external dirs/files/git repos/documents into context). */
46
+ readonly contextLoader: boolean;
47
+ /**
48
+ * When true, the first repl() call seeds `context` with the working directory
49
+ * (un-prefixed paths). When false, context stays empty until add_context is called.
50
+ */
51
+ readonly autoSeedCwd: boolean;
47
52
  /** ThinkingLevel for the root smart model (set via /rlm-config). */
48
53
  readonly smartReasoning?: ThinkingLevel;
49
54
  /** Output token cap + temperature for the root smart model per turn.
package/src/index.ts CHANGED
@@ -15,10 +15,27 @@ import { markdownTheme } from "./ui/theme-adapter.ts";
15
15
  import { SandboxManager } from "./sandbox/sandbox-manager.ts";
16
16
  import { createSubcallGates } from "./util/concurrency.ts";
17
17
  import { BackgroundTasks } from "./tool/background-tasks.ts";
18
- import { packRepository, formatForLLM, serializeForSandbox } from "./context/repomix-context.ts";
18
+ import { resolve } from "node:path";
19
+ import { resolveSource } from "./context/resolve.ts";
20
+ import { formatContextListing } from "./context/listing.ts";
21
+ import type { AddContextHandlerBundle } from "./bridge/add-context.ts";
19
22
  import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/native.ts";
20
23
  import { bashCommandFromInput, isFileReadingCommand, capToolResultText, BASH_BLOCK_REASON } from "./mode/native-guards.ts";
24
+ import {
25
+ isSubagentChildBypass,
26
+ commitSubagentForceActivation,
27
+ shouldEnforceNativeReaderBlock,
28
+ processRlmDepth,
29
+ } from "./mode/subagent.ts";
21
30
  import { errorMessage } from "./util/errors.ts";
31
+ import { trace, traceEnabled } from "./util/trace.ts";
32
+
33
+ export {
34
+ isSubagentChildBypass,
35
+ commitSubagentForceActivation,
36
+ shouldEnforceNativeReaderBlock,
37
+ processRlmDepth,
38
+ } from "./mode/subagent.ts";
22
39
 
23
40
  const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep"]));
24
41
  /** How often to keep the parent sandbox's request watchdog alive during detached work. */
@@ -26,6 +43,23 @@ const WATCHDOG_HEARTBEAT_MS = 30_000;
26
43
  const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls"]));
27
44
 
28
45
  export default function rlmExtension(pi: ExtensionAPI): void {
46
+ // Subagent children run a native tool flow; RLM's contract is the opposite
47
+ // (block read/grep, route through repl). Env fast path: full bypass when
48
+ // PI_SUBAGENT_CHILD=1 (unless force-in under the depth cap). See mode/subagent.ts.
49
+ if (isSubagentChildBypass()) {
50
+ if (traceEnabled) {
51
+ trace("subagent.bypass", {
52
+ reason: process.env.PI_RLM_FORCE_IN_SUBAGENT === "1" ? "force_depth_cap" : "child",
53
+ depth: processRlmDepth(),
54
+ });
55
+ }
56
+ return;
57
+ }
58
+ commitSubagentForceActivation();
59
+ if (traceEnabled && process.env.PI_SUBAGENT_CHILD === "1") {
60
+ trace("subagent.force", { depth: processRlmDepth() });
61
+ }
62
+
29
63
  // Init synchronously with defaults — ensures commands/tools/handlers register before session_start
30
64
  const config = mergeConfig({});
31
65
  const controller = new RlmController(config);
@@ -57,22 +91,37 @@ export default function rlmExtension(pi: ExtensionAPI): void {
57
91
  }, WATCHDOG_HEARTBEAT_MS);
58
92
  watchdogHeartbeat.unref();
59
93
 
60
- let packedContextText: string | undefined;
61
- let contextPackPromise: Promise<string | undefined> | undefined;
62
- const ensureRepositoryContext = async (cwd: string): Promise<string | undefined> => {
63
- if (packedContextText !== undefined && sandboxManager.contextPayload !== null) return packedContextText;
64
- contextPackPromise ??= packRepository(cwd)
94
+ /** Memoised cwd seed — one resolveSource(pathPrefix:"") per session. */
95
+ let seedPromise: Promise<void> | undefined;
96
+ let cwdSeeded = false;
97
+ /** Live add_context bundle seed plants the "" sentinel here so add_context(".") is a no-op. */
98
+ let contextBundleRef: AddContextHandlerBundle | undefined;
99
+ /**
100
+ * Payload identity last injected into the context hook. Re-inject only when the payload
101
+ * reference changes (seed, add_context, reset) — not every turn. Keeps the root window small.
102
+ */
103
+ let listingPayloadRef: unknown = undefined;
104
+ let listingInjected = false;
105
+ const seedContext = async (cwd: string): Promise<void> => {
106
+ if (cwdSeeded) return;
107
+ if (!controller.config.autoSeedCwd) {
108
+ cwdSeeded = true;
109
+ return;
110
+ }
111
+ seedPromise ??= resolveSource(cwd, { cwd, pathPrefix: "" })
65
112
  .then((result) => {
113
+ // Sticky either way: a failed seed must not re-walk the whole repo on every repl().
114
+ cwdSeeded = true;
66
115
  if (!result.ok) {
67
- console.warn(`[rlm] repository context pack failed: ${result.error}`);
68
- return undefined;
116
+ console.warn(`[rlm] context seed failed: ${result.error}`);
117
+ return;
69
118
  }
70
- sandboxManager.contextPayload = serializeForSandbox(result.value);
71
- packedContextText = formatForLLM(result.value);
72
- return packedContextText;
119
+ sandboxManager.contextPayload = result.value.payload;
120
+ // Register the cwd seed with the bridge so add_context(".") cannot double the tree.
121
+ contextBundleRef?.markSeededCwd(resolve(cwd));
73
122
  })
74
- .finally(() => { contextPackPromise = undefined; });
75
- return contextPackPromise;
123
+ .finally(() => { seedPromise = undefined; });
124
+ await seedPromise;
76
125
  };
77
126
 
78
127
  // Load persisted settings async — applied before session_start handler reads controller state
@@ -152,9 +201,16 @@ export default function rlmExtension(pi: ExtensionAPI): void {
152
201
  gates,
153
202
  background,
154
203
  registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
204
+ registerContextBundle: (bundle) => {
205
+ contextBundleRef = bundle;
206
+ // Tool re-registers each session; re-plant sentinel if seed already landed.
207
+ if (cwdSeeded && Array.isArray(sandboxManager.contextPayload)
208
+ && sandboxManager.contextPayload.length > 0) {
209
+ bundle.markSeededCwd(resolve(ctx.cwd ?? process.cwd()));
210
+ }
211
+ },
155
212
  ensureContext: async () => {
156
- const contextText = await ensureRepositoryContext(ctx.cwd ?? process.cwd());
157
- if (contextText === undefined) throw new Error("repository context could not be loaded into RLM sandbox");
213
+ await seedContext(ctx.cwd ?? process.cwd());
158
214
  },
159
215
  }));
160
216
  } catch (err) {
@@ -178,43 +234,50 @@ export default function rlmExtension(pi: ExtensionAPI): void {
178
234
  setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
179
235
  });
180
236
 
181
- // ── System prompt: native RLM mode addendum (only when enabled) ──
237
+ /** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
238
+ const nativeTradeHolds = (): boolean =>
239
+ shouldEnforceNativeReaderBlock({
240
+ enabled: controller.enabled,
241
+ activeToolNames: typeof pi.getActiveTools === "function" ? pi.getActiveTools() : undefined,
242
+ });
243
+
244
+ // ── System prompt: native RLM mode addendum (only when the trade holds) ──
182
245
  pi.on("before_agent_start", async (event) => {
183
- if (!controller.enabled) return;
246
+ if (!nativeTradeHolds()) return;
184
247
  return { systemPrompt: event.systemPrompt + "\n\n" + buildNativeSystemPrompt() };
185
248
  });
186
249
 
187
- // ── Context injection: repo listing for the main agent ──
188
- let contextInjected = false;
189
- pi.on("context", async (event, ctx) => {
250
+ // ── Context injection: listing of whatever is currently loaded ──
251
+ // Re-inject only when the payload identity changes (seed / add_context), not every turn —
252
+ // the listing can be up to 200 file lines and the plugin exists to shrink the root window.
253
+ pi.on("context", async (event) => {
190
254
  const filtered = event.messages.filter(
191
255
  (message) =>
192
256
  !(message.role === "custom" && message.customType === "rlm-intro")
193
257
  && !(message.role === "user" && typeof message.content === "string" && message.content === NATIVE_TURN_REMINDER),
194
258
  );
195
- if (!controller.enabled) return { messages: filtered };
259
+ if (!nativeTradeHolds()) return { messages: filtered };
196
260
 
197
261
  type PiMessage = (typeof filtered)[number];
198
262
 
199
- // Inject repository context as a compact listing (once per session)
200
- if (!contextInjected) {
201
- const cwd = ctx.cwd ?? process.cwd();
202
- const contextText = await ensureRepositoryContext(cwd);
203
- if (contextText !== undefined) {
204
- contextInjected = true;
205
- const instruction = [
206
- "ANALYZE THIS REPOSITORY using repl({code}) read/grep are DISABLED.",
207
- "Repository contents are pre-loaded in the Python REPL `context` variable.",
208
- "Locate with search()/grep_context()/outline() (free), then delegate bulk reading to",
209
- "map_files()/llm_query_batched(). If credits exhausted → report and stop.",
210
- "",
211
- ].join("\n");
212
- filtered.unshift({
213
- role: "user" as const,
214
- content: instruction + contextText,
215
- timestamp: 0,
216
- } as PiMessage);
217
- }
263
+ const payload = sandboxManager.contextPayload;
264
+ if (!listingInjected || payload !== listingPayloadRef) {
265
+ listingInjected = true;
266
+ listingPayloadRef = payload;
267
+ const listing = formatContextListing(payload);
268
+ const instruction = [
269
+ "ANALYZE with repl({code}) — read/grep are DISABLED.",
270
+ "Files you have loaded live in the Python REPL `context` variable (starts empty; cwd seeds on first repl()).",
271
+ "Locate with search()/grep_context()/outline() (free), then delegate bulk reading to",
272
+ "map_files()/llm_query_batched(). Use add_context(path) for external dirs/files/docs/git URLs.",
273
+ "If credits exhausted → report and stop.",
274
+ "",
275
+ ].join("\n");
276
+ filtered.unshift({
277
+ role: "user" as const,
278
+ content: instruction + listing,
279
+ timestamp: 0,
280
+ } as PiMessage);
218
281
  }
219
282
 
220
283
  // Per-turn last-position reminder (not persisted — context hook rebuilds every request)
@@ -231,12 +294,19 @@ export default function rlmExtension(pi: ExtensionAPI): void {
231
294
  // `edit`/`write` stay unblocked so the agent modifies files through Pi's native
232
295
  // tool flow (visible to all plugins, +/- diff preview). File reading/searching
233
296
  // belongs in the REPL, and bash output is capped as a backstop.
297
+ // Fail-open when repl is not active (e.g. --tools allowlist without repl): never
298
+ // confiscate readers without a working substitute (RLM paper §2 trade).
234
299
  pi.on("tool_call", async (event) => {
235
- if (!controller.enabled) return;
300
+ if (!nativeTradeHolds()) {
301
+ if (traceEnabled && controller.enabled) {
302
+ trace("native.block_skip", { toolName: event.toolName, reason: "no_active_repl" });
303
+ }
304
+ return;
305
+ }
236
306
  if (BLOCKED_NATIVE_TOOLS.has(event.toolName)) {
237
307
  return {
238
308
  block: true,
239
- 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.",
309
+ reason: "RLM mode active. Use repl({code}) to read files and search the repository — loaded files live in the REPL `context` variable (cwd seeds on first call). Use `edit`/`write` for file changes. If sub-LLM credits are exhausted, report to the user.",
240
310
  };
241
311
  }
242
312
  const bashCommand = event.toolName === "bash" ? bashCommandFromInput(event.input) : undefined;
@@ -246,7 +316,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
246
316
  });
247
317
 
248
318
  pi.on("tool_result", async (event) => {
249
- if (!controller.enabled || !CAPPED_RESULT_TOOLS.has(event.toolName)) return;
319
+ if (!nativeTradeHolds() || !CAPPED_RESULT_TOOLS.has(event.toolName)) return;
250
320
  let changed = false;
251
321
  const content = event.content.map((c) => {
252
322
  if (c.type !== "text") return c;
@@ -264,9 +334,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
264
334
  clearInterval(watchdogHeartbeat);
265
335
  background.dispose();
266
336
  await sandboxManager.dispose();
267
- contextInjected = false;
268
- packedContextText = undefined;
269
- contextPackPromise = undefined;
270
- sandboxManager.contextPayload = null;
337
+ cwdSeeded = false;
338
+ seedPromise = undefined;
339
+ contextBundleRef = undefined;
340
+ listingPayloadRef = undefined;
341
+ listingInjected = false;
342
+ sandboxManager.contextPayload = [];
271
343
  });
272
344
  }
@@ -12,7 +12,7 @@ import { modelRef, resolveModelId, saveSettings } from "../config/settings.ts";
12
12
  import { createEngine } from "../core/engine.ts";
13
13
  import { limitsFromConfig } from "../core/limits.ts";
14
14
  import type { RlmConfig, RlmResult } from "../core/types.ts";
15
- import { packRepository, serializeForSandbox } from "../context/repomix-context.ts";
15
+ import { resolveSource } from "../context/resolve.ts";
16
16
  import { RlmEmitter } from "../tool/rlm-events.ts";
17
17
  import { formatError } from "../util/errors.ts";
18
18
  import { cheapestModel } from "./llm-model.ts";
@@ -87,13 +87,14 @@ export class RlmController {
87
87
  this.active = abortController;
88
88
 
89
89
  const done = (async () => {
90
- // Auto-pack empty/undefined context via repomix; pass explicit context through.
90
+ // Auto-seed empty/undefined context from cwd (same resolveSource path as native mode);
91
+ // pass explicit context through.
91
92
  let contextValue: unknown = input.context;
92
93
  if (contextValue === undefined || (typeof contextValue === "string" && contextValue.trim() === "")) {
93
94
  const cwd = ctx.cwd ?? process.cwd();
94
- const result = await packRepository(cwd, abortController.signal);
95
+ const result = await resolveSource(cwd, { cwd, pathPrefix: "", signal: abortController.signal });
95
96
  contextValue = result.ok
96
- ? serializeForSandbox(result.value)
97
+ ? result.value.payload
97
98
  : formatError(`failed to pack repository — ${result.error}`);
98
99
  }
99
100
  const engine = createEngine({
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Subagent / process-boundary isolation for RLM.
3
+ *
4
+ * Two layers:
5
+ * 1. Env fast path — packages that set PI_SUBAGENT_CHILD=1 fully bypass RLM.
6
+ * 2. Capability gate — never confiscate native readers unless `repl` is in the
7
+ * active tool set (paper trade: scaffold only if the REPL substitute exists).
8
+ *
9
+ * In-process rlm_query depth is handled by subcall-handlers.childRun; this module
10
+ * only covers OS-process children (pi subagents), which restart at depth 0.
11
+ */
12
+ import { DEFAULT_CONFIG } from "../config/defaults.ts";
13
+
14
+ export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
15
+ export const RLM_FORCE_IN_SUBAGENT_ENV = "PI_RLM_FORCE_IN_SUBAGENT";
16
+ export const RLM_DEPTH_ENV = "PI_RLM_DEPTH";
17
+
18
+ /** Cross-process depth from env. Missing / invalid → 0. */
19
+ export function processRlmDepth(): number {
20
+ const raw = process.env[RLM_DEPTH_ENV];
21
+ if (raw === undefined || raw === "") return 0;
22
+ const n = Number.parseInt(raw, 10);
23
+ if (!Number.isFinite(n) || n < 0) return 0;
24
+ return n;
25
+ }
26
+
27
+ /**
28
+ * True when this process should not activate RLM at all (no tools / hooks / flags).
29
+ *
30
+ * - Parent (no PI_SUBAGENT_CHILD=1) → false.
31
+ * - Child without force → true.
32
+ * - Child with force but depth >= maxDepth → true (refuse force; paper §7 cost bound).
33
+ * - Child with force and depth < maxDepth → false (experimental opt-in).
34
+ */
35
+ export function isSubagentChildBypass(maxDepth: number = DEFAULT_CONFIG.maxDepth): boolean {
36
+ if (process.env[SUBAGENT_CHILD_ENV] !== "1") return false;
37
+ if (process.env[RLM_FORCE_IN_SUBAGENT_ENV] !== "1") return true;
38
+ return processRlmDepth() >= maxDepth;
39
+ }
40
+
41
+ /**
42
+ * Call only when RLM will activate. Scrubs force so grandchildren that inherit env
43
+ * do not re-open unbounded force; bumps PI_RLM_DEPTH for any re-set force path.
44
+ * No-op when not a forced child under the depth cap.
45
+ */
46
+ export function commitSubagentForceActivation(maxDepth: number = DEFAULT_CONFIG.maxDepth): void {
47
+ if (process.env[SUBAGENT_CHILD_ENV] !== "1") return;
48
+ if (process.env[RLM_FORCE_IN_SUBAGENT_ENV] !== "1") return;
49
+ if (processRlmDepth() >= maxDepth) return;
50
+ const next = processRlmDepth() + 1;
51
+ delete process.env[RLM_FORCE_IN_SUBAGENT_ENV];
52
+ process.env[RLM_DEPTH_ENV] = String(next);
53
+ }
54
+
55
+ /**
56
+ * RLM's native-mode trade: confiscate read/grep (and bash readers) only when the
57
+ * substitute is actually callable. Fail-open when the active tool list is unknown
58
+ * or does not include `repl` (official pi subagent uses --tools without repl).
59
+ */
60
+ export function shouldEnforceNativeReaderBlock(opts: {
61
+ readonly enabled: boolean;
62
+ readonly activeToolNames: readonly string[] | undefined;
63
+ }): boolean {
64
+ if (!opts.enabled) return false;
65
+ const names = opts.activeToolNames;
66
+ if (names === undefined) return false;
67
+ return names.includes("repl");
68
+ }
@@ -82,13 +82,13 @@ export const SPAWN_GLOSSARY_LINES: readonly string[] = Object.freeze([
82
82
  */
83
83
  export const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
84
84
  "",
85
- " **What a child sees:** it inherits YOUR `context` — the repository plus every library you",
86
- " loaded (`lib/<id>/…`) — and runs `search` / `grep_context` / `outline` / `map_files` over the",
87
- " same paths. So send instructions, never file bodies: pasting content you already share costs",
88
- " your tokens twice and buys nothing. Your prompt becomes the child's question.",
89
- " Narrow its world with `rlm_query(prompt, paths=['src/auth/', 'lib/x-9f3a/'])` — path PREFIXES,",
85
+ " **What a child sees:** it inherits YOUR `context` — every file you have loaded, including",
86
+ " sources under `ctx/<id>/…` — and runs `search` / `grep_context` / `outline` / `map_files`",
87
+ " over the same paths. So send instructions, never file bodies: pasting content you already",
88
+ " share costs your tokens twice and buys nothing. Your prompt becomes the child's question.",
89
+ " Narrow its world with `rlm_query(prompt, paths=['src/auth/', 'ctx/x-9f3a/'])` — path PREFIXES,",
90
90
  " not globs. Omit `paths` to hand over everything.",
91
- " Inheritance is one-way: libraries the child loads, and its whole REPL, die with it — only its",
91
+ " Inheritance is one-way: sources the child loads, and its whole REPL, die with it — only its",
92
92
  " final answer string returns.",
93
93
  " At the depth cap `rlm_query` degrades to a plain sub-LLM call with NO context, which is why",
94
94
  " this section disappears at the last recursive depth.",
@@ -99,14 +99,17 @@ export const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
99
99
  * than a repository the run packed for itself.
100
100
  */
101
101
  export const CHILD_CONTEXT_LINES: readonly string[] = Object.freeze([
102
- " You are a sub-RLM. This `context` is your parent's world — the repository plus every library",
103
- " it loaded (paths under `lib/<id>/…`). Answer only the question above; your REPL and anything",
104
- " you load die with you, and only your final answer string returns to the parent.",
102
+ " You are a sub-RLM. This `context` is your parent's world — every file it has loaded (cwd",
103
+ " paths un-prefixed; external sources under `ctx/<id>/…`). Answer only the question above;",
104
+ " your REPL and anything you load die with you, and only your final answer string returns.",
105
105
  ]);
106
106
 
107
107
  /** Why a file the user mentioned may be missing from `context`. */
108
- export const CONTEXT_EXCLUSION_NOTE =
109
- " NOTE: files larger than 1MB and gitignored files are NOT in `context` they exist only on disk.";
108
+ export const CONTEXT_EXCLUSION_NOTE = [
109
+ " NOTE: `context` holds only the files you have loaded (starts empty; cwd seeds on first use).",
110
+ " Gitignored files and files larger than 1MB of plain text are skipped. Binary documents",
111
+ " (PDF, DOCX, XLSX, PPTX, CSV, …) ARE included — converted to Markdown on the way in.",
112
+ ].join("\n");
110
113
 
111
114
  /** The large-on-disk-file protocol (headless + native). */
112
115
  export const LARGE_FILE_RULE_LINES: readonly string[] = Object.freeze([
@@ -200,7 +203,7 @@ export function howToRunCode(): string {
200
203
  export function replGlossary(
201
204
  kind: ContextKind,
202
205
  recursion: boolean,
203
- libraryLoader: boolean,
206
+ contextLoader: boolean,
204
207
  child: boolean,
205
208
  ): string {
206
209
  const lines = ["Available in the REPL:"];
@@ -212,10 +215,10 @@ export function replGlossary(
212
215
  );
213
216
  } else {
214
217
  lines.push(
215
- "- `context`: list[dict] — a pre-packed JSON array of every file in the repository. Each dict has",
216
- " keys: `path` (relative file path, str), `content` (file text, str), `tokens` (estimated count, int).",
217
- " For large repos, chunk `context` into batches and delegate to sub-LLMs — never dump raw file",
218
- " bodies into your own output.",
218
+ "- `context`: list[dict] — the files you have loaded (starts empty; cwd seeds on first use).",
219
+ " Each dict has keys: `path` (str), `content` (str), `tokens` (int).",
220
+ " Cwd paths are un-prefixed (real paths for edit/write); external sources land under",
221
+ " `ctx/<source_id>/…`. For large sets, chunk and delegate — never dump raw file bodies.",
219
222
  CONTEXT_EXCLUSION_NOTE,
220
223
  );
221
224
  if (child) lines.push(...CHILD_CONTEXT_LINES);
@@ -240,22 +243,22 @@ export function replGlossary(
240
243
  ...SPAWN_GLOSSARY_LINES,
241
244
  ...DELEGATION_GLOSSARY_LINES,
242
245
  );
243
- if (libraryLoader) {
246
+ if (contextLoader) {
244
247
  lines.push(
245
- "- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document and",
246
- " **APPEND its files into the existing `context` list** (same shape: path/content/tokens).",
247
- " `source` may be a local directory (repomix-packed), a single file path, or an https/git@ URL",
248
- " (shallow-cloned, then packed). Paths are namespaced under `lib/<source_id>/…` so you can filter",
249
- " by prefix. Returns metadata only:",
250
- " {\"source\", \"source_id\", \"path_prefix\", \"files\", \"chars\", \"context_len\", \"already_loaded\"}",
251
- " or an \"Error: ...\" string. **Never treat the return value as the file list** — always search",
252
- " and chunk the single variable `context`. Do not invent `context_1` / aliases; do not call",
253
- " globals()/locals(). Idempotent: re-loading the same source is a no-op.",
248
+ "- `add_context(source: str) -> dict`: load a dir, file, document, or git URL and **APPEND its",
249
+ " files into `context`** (same shape: path/content/tokens). Documents (PDF, DOCX, XLSX, PPTX,",
250
+ " CSV, …) are converted to Markdown automatically and cached until the source file changes.",
251
+ " Paths are namespaced under `ctx/<source_id>/…` so you can filter by prefix. Returns metadata:",
252
+ " {\"source\", \"source_id\", \"path_prefix\", \"files\", \"chars\", \"context_len\", \"already_loaded\",",
253
+ " \"documents\", \"converted\", \"skipped\"} or an \"Error: ...\" string. `documents` is how many",
254
+ " document-type files landed (incl. cache hits); `converted` is how many were freshly converted",
255
+ " this call. **Never treat the return value as the file list** always search and chunk the",
256
+ " single variable `context`. Idempotent: re-loading the same source is a no-op.",
254
257
  "",
255
258
  " ```python",
256
- " info = load_library(\"/path/to/other-project\")",
259
+ ' info = add_context("/path/to/other-project")',
257
260
  " # info is metadata; files are already in context under info[\"path_prefix\"]",
258
- " lib_files = [f for f in context if f[\"path\"].startswith(info[\"path_prefix\"])]",
261
+ ' lib_files = [f for f in context if f["path"].startswith(info["path_prefix"])]',
259
262
  " ```",
260
263
  );
261
264
  }
@@ -17,7 +17,7 @@ function nativeReplGlossary(): string {
17
17
  "State persists; only `print()` output is returned, so wrap inspections in `print(...)`.",
18
18
  "",
19
19
  "### REPL Environment",
20
- "- `context`: list[dict] — every file in the repository. Each dict: `path` (str), `content` (str), `tokens` (int).",
20
+ "- `context`: list[dict] — the files you have loaded (starts empty; cwd seeds on first repl()). Each dict: `path` (str), `content` (str), `tokens` (int).",
21
21
  "",
22
22
  "Retrieval — free (no sub-LLM call, no tokens). **Start here, before guessing filenames:**",
23
23
  "- `search(query, k=10, path_glob=None) -> [{path, line, score, snippet}]` — BM25 over `context`. Returns pointers, not bodies.",
@@ -30,13 +30,13 @@ function nativeReplGlossary(): string {
30
30
  "- `llm_query(prompt, model=None) -> str` — one-shot sub-LLM. Use for extraction, summarization, Q&A over a chunk.",
31
31
  "- `llm_query_batched(prompts, model=None) -> list[str]` — concurrent sub-LLM calls; output order matches input order.",
32
32
  CHUNKED_GLOSSARY_LINE_NATIVE,
33
- "- `rlm_query(prompt, model=None, paths=None) -> str` — recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning. Prefer llm_query — rlm_query is slower and costlier. The child inherits your `context` (repo + loaded libraries) and takes your prompt as its question, so describe the task; never paste file text. `paths=['src/auth/']` narrows its context by prefix.",
33
+ "- `rlm_query(prompt, model=None, paths=None) -> str` — recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning. Prefer llm_query — rlm_query is slower and costlier. The child inherits your `context` (loaded files + ctx/ sources) and takes your prompt as its question, so describe the task; never paste file text. `paths=['src/auth/']` narrows its context by prefix.",
34
34
  "- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
35
35
  "- `spawn(fn, *args) -> Task` / `rlm_await(t)` / `rlm_await_all(ts)` — start `llm_query`, `llm_query_batched`, `llm_query_chunked`, `map_files`, `rlm_query` or `rlm_query_batched` without waiting (NOT `llm_map_reduce`); collect later, order preserved. Tasks outlive the repl() call, so spawn slow work early and await when you need it.",
36
36
  "",
37
37
  "",
38
38
  "- `answers` / `plan` — dicts persisted across every repl() call. Your memo.",
39
- "- `load_library(source) -> dict`: append external dir/file/git tree into `context` under `lib/<id>/…`. Return is metadata only — always use `context`.",
39
+ "- `add_context(source) -> dict`: append dir/file/document/git URL into `context` under `ctx/<id>/…`. Documents converted to Markdown. Return is metadata only — always use `context`.",
40
40
  "- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
41
41
  "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
42
42
  "",
@@ -90,7 +90,7 @@ export function buildNativeSystemPrompt(): string {
90
90
  "call (rlm_query for iterative sub-tasks). Deterministic Python over `context` is free and",
91
91
  "preferred for lookups. Semantic reading is always delegated.",
92
92
  "",
93
- "All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
93
+ "Loaded file content lives in the REPL `context` variable (cwd seeds on first call). Use ONLY `repl({code})`.",
94
94
  "If sub-LLM credits are exhausted → report the error to the user and stop.",
95
95
  "",
96
96
  "AUTHORING RULE: sub-LLMs (`llm_query` family) READ — they extract, locate, and summarize.",
@@ -28,7 +28,7 @@ export interface SystemPromptOptions {
28
28
  readonly orchestrator?: boolean;
29
29
  readonly recursion?: boolean;
30
30
  readonly maxPromptChars?: number;
31
- readonly libraryLoader?: boolean;
31
+ readonly contextLoader?: boolean;
32
32
  /** depth > 0 — this run is an rlm_query child and its `context` is the parent's world. */
33
33
  readonly child?: boolean;
34
34
  }
@@ -70,7 +70,7 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
70
70
  howToRunCode(),
71
71
  "",
72
72
  replGlossary(
73
- kind, recursion, opts.libraryLoader ?? false, opts.child ?? false,
73
+ kind, recursion, opts.contextLoader ?? false, opts.child ?? false,
74
74
  ),
75
75
  "",
76
76
  "REPL stdout over ~800 characters is truncated to a short excerpt — large results stay in your",
@@ -2,12 +2,12 @@
2
2
  * Temp-file transport for sandbox context payloads, with refcounted sharing.
3
3
  *
4
4
  * Two callers, two ownership models, one writer:
5
- * - `writeContextTempFile` — non-owning. `load_library` uses it because the WORKER unlinks
6
- * that file after reading it (see sandbox.ts serviceInterrupt / worker.py `_load_library`).
5
+ * - `writeContextTempFile` — non-owning. `add_context` uses it because the WORKER unlinks
6
+ * that file after reading it (see sandbox.ts serviceInterrupt / worker.py `_add_context`).
7
7
  * - `pinContext` — refcounted. Every child RLM of one node inherits the SAME payload, so an
8
8
  * 18-way fan-out would otherwise cost 18 serializations and 18 files. Pins are keyed by
9
- * payload identity, which is a free version key: `mergeLibraryIntoContext` always returns a
10
- * NEW array, so loading a library mints a new key and old holders keep their own file.
9
+ * payload identity, which is a free version key: `mergeIntoContext` always returns a
10
+ * NEW array, so adding a source mints a new key and old holders keep their own file.
11
11
  *
12
12
  * Serialization is chunked with an await between chunks so the event loop is never blocked for
13
13
  * more than ~SERIALIZE_CHUNK entries. A Worker Thread was considered and rejected: posting the