@hicaru/pi-rlm 0.2.1 → 0.3.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.
Files changed (79) hide show
  1. package/README.md +28 -47
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +22 -19
  5. package/src/bridge/add-context.ts +322 -0
  6. package/src/bridge/subcall-handlers.ts +63 -17
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +8 -18
  10. package/src/config/settings.ts +13 -34
  11. package/src/context/anydoc.ts +67 -0
  12. package/src/context/listing.ts +70 -0
  13. package/src/context/md-cache.ts +112 -0
  14. package/src/context/merge.ts +97 -0
  15. package/src/context/namespace.ts +180 -0
  16. package/src/context/resolve.ts +122 -0
  17. package/src/context/source-dir.ts +166 -0
  18. package/src/context/source-doc.ts +71 -0
  19. package/src/context/source-git.ts +51 -0
  20. package/src/context/source-text.ts +45 -0
  21. package/src/context/types.ts +88 -0
  22. package/src/context/walk.ts +250 -0
  23. package/src/core/engine.ts +61 -345
  24. package/src/core/history.ts +1 -1
  25. package/src/core/limits.ts +5 -12
  26. package/src/core/resource-limits.ts +0 -2
  27. package/src/core/types.ts +10 -38
  28. package/src/index.ts +92 -54
  29. package/src/mode/llm-model.ts +54 -0
  30. package/src/mode/rlm-mode.ts +28 -58
  31. package/src/prompts/glossary.ts +290 -0
  32. package/src/prompts/native.ts +127 -0
  33. package/src/prompts/system.ts +15 -408
  34. package/src/sandbox/context-file.ts +154 -0
  35. package/src/sandbox/interrupts.ts +160 -0
  36. package/src/sandbox/protocol.ts +20 -75
  37. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  38. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  39. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  40. package/src/sandbox/py/guards.py +150 -0
  41. package/src/sandbox/py/retrieval.py +265 -0
  42. package/src/sandbox/py/tasks.py +129 -0
  43. package/src/sandbox/py/worker.py +856 -0
  44. package/src/sandbox/sandbox-manager.ts +24 -9
  45. package/src/sandbox/sandbox.ts +99 -193
  46. package/src/text/tokens.ts +31 -5
  47. package/src/tool/repl-details.ts +2 -2
  48. package/src/tool/repl-render.ts +58 -0
  49. package/src/tool/repl-result.ts +70 -0
  50. package/src/tool/repl-tool.ts +60 -170
  51. package/src/tool/rlm-aggregator.ts +2 -10
  52. package/src/tool/rlm-details.ts +0 -2
  53. package/src/tool/rlm-events.ts +0 -14
  54. package/src/tool/rlm-tool.ts +2 -13
  55. package/src/ui/config-panel.ts +12 -20
  56. package/src/ui/intro.ts +1 -2
  57. package/src/ui/model-picker.ts +34 -10
  58. package/src/ui/status.ts +3 -7
  59. package/src/util/concurrency.ts +9 -5
  60. package/src/bridge/fallback-todo.ts +0 -148
  61. package/src/bridge/interactive.ts +0 -65
  62. package/src/bridge/library.ts +0 -155
  63. package/src/bridge/pi-interactive.ts +0 -41
  64. package/src/context/library-context.ts +0 -266
  65. package/src/context/repomix-context.ts +0 -204
  66. package/src/core/artifacts.ts +0 -89
  67. package/src/core/critique.ts +0 -92
  68. package/src/core/gates.ts +0 -301
  69. package/src/core/pipeline-handlers.ts +0 -319
  70. package/src/core/pipeline.ts +0 -268
  71. package/src/prompts/phases.ts +0 -104
  72. package/src/sandbox/worker.py +0 -1456
  73. package/src/state/index.ts +0 -24
  74. package/src/state/internal.ts +0 -46
  75. package/src/state/paths.ts +0 -44
  76. package/src/state/reads.ts +0 -133
  77. package/src/state/resume.ts +0 -173
  78. package/src/state/rows.ts +0 -123
  79. package/src/state/writes.ts +0 -58
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Shared add_context handler for headless engine and native repl() mode.
3
+ *
4
+ * Host packs the source via resolveSource (namespaced under ctx/<id>/) and returns the
5
+ * payload for the worker to append into the single `context` list.
6
+ *
7
+ * Idempotency is host-side:
8
+ * - prefix set (ctx/<id>/) for external sources
9
+ * - cwd seed: markSeededCwd + exact-path short-circuit for add_context(".")
10
+ * - subpath of seed: short-circuit only when the live context already holds un-prefixed
11
+ * entries under that relative path (gitignored subtrees still pack for real)
12
+ *
13
+ * Late-bound deps (getCwd / getEmitter) keep a single handler closure correct
14
+ * across native repl() calls — getOrCreate only installs handlers at spawn.
15
+ */
16
+
17
+ import { isAbsolute, relative, resolve, sep } from "node:path";
18
+ import type { RlmEmitter } from "../tool/rlm-events.ts";
19
+ import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
20
+ import {
21
+ contextNamespace,
22
+ isContextFile,
23
+ } from "../context/namespace.ts";
24
+ import { resolveSource } from "../context/resolve.ts";
25
+ import { previewText } from "../text/preview.ts";
26
+
27
+ export interface AddContextBridgeOpts {
28
+ /** Fixed cwd (headless). Prefer getCwd when the sandbox outlives a single invocation. */
29
+ readonly cwd?: string;
30
+ /** Late-bound cwd (native mode — sandbox handlers outlive a single repl()). */
31
+ readonly getCwd?: () => string;
32
+ readonly emitter?: RlmEmitter;
33
+ /** Native mode: read the live emitter each call. */
34
+ readonly getEmitter?: () => RlmEmitter | null | undefined;
35
+ readonly parentId?: string;
36
+ readonly signal?: AbortSignal;
37
+ /** Prefixes already present in context — seeds host-side idempotency after a sandbox restart. */
38
+ readonly loadedPrefixes?: readonly string[];
39
+ /**
40
+ * The live context this sandbox holds. Read to refuse pre-flight exactly what the worker's
41
+ * `_append_context` would reject, before any prefix is committed.
42
+ */
43
+ readonly getContext?: () => unknown;
44
+ /**
45
+ * Post-load hook. The engine grows its live context here; native mode grows
46
+ * SandboxManager.contextPayload.
47
+ */
48
+ readonly onLoaded?: (payload: unknown) => void | Promise<void>;
49
+ }
50
+
51
+ export interface AddContextHandlerBundle {
52
+ readonly handlers: Pick<SubLlmHandlers, "addContext">;
53
+ /**
54
+ * Reset the loaded-prefix cache (call when the sandbox is
55
+ * discarded and will re-spawn).
56
+ *
57
+ * `keep` re-seeds the cache from the payload that will be replayed into the fresh worker.
58
+ * `loaded` is a CACHE of `contextPrefixesIn(context)` plus the cwd sentinel `""`, never
59
+ * independent state, so it may only be cleared by re-deriving it — clearing it outright
60
+ * would make the host re-clone a source the recreated worker already holds.
61
+ */
62
+ readonly reset: (keep?: readonly string[]) => void;
63
+ /**
64
+ * Register a prefix as already loaded without packing. Used by the cwd seed to plant the
65
+ * `""` sentinel so add_context of the same tree is a no-op.
66
+ */
67
+ readonly markLoaded: (prefix: string) => void;
68
+ /**
69
+ * Record the absolute path of the cwd seed. add_context(".") resolves to a ctx/<id>/
70
+ * namespace, not "", so the absolute-path check is the only reliable short-circuit.
71
+ */
72
+ readonly markSeededCwd: (absPath: string) => void;
73
+ /** Prefixes loaded in this sandbox lifetime (for tests). */
74
+ readonly loadedPrefixes: () => ReadonlySet<string>;
75
+ /** Absolute seeded cwd, if any (for tests). */
76
+ readonly seededCwd: () => string | undefined;
77
+ }
78
+
79
+ /**
80
+ * JS runtime kind → the Python type name worker.py reports, so both sides emit exactly one
81
+ * message for the same refusal. Covers every shape a context payload can take after JSON
82
+ * transport; anything else is a plain object, which `json.load` materializes as a dict.
83
+ */
84
+ const PY_TYPE_NAME: Readonly<Record<string, string>> = Object.freeze({
85
+ string: "str", boolean: "bool", number: "int", bigint: "int", undefined: "None",
86
+ });
87
+
88
+ function pythonKindOf(value: unknown): string {
89
+ if (value === null) return "None"; // matches worker.py's `if ctx is not None else "None"`
90
+ return PY_TYPE_NAME[typeof value] ?? "dict";
91
+ }
92
+
93
+ /**
94
+ * Refusal messages shared with worker.py `_append_context`. The worker is the backstop; the host
95
+ * pre-flights the same two conditions so it never commits a prefix for an append that
96
+ * will be rejected. Keep the wording identical — a comment in worker.py points back here.
97
+ */
98
+ const LIST_CONTEXT_REQUIRED = (kind: string): string =>
99
+ `add_context requires list context (file bundle); got ${kind}`;
100
+ const NO_FILES_PRODUCED = "add_context produced no files";
101
+
102
+ /** Absolute path with no trailing slash (except root). */
103
+ function absKey(path: string): string {
104
+ const resolved = resolve(path);
105
+ return resolved.length > 1 && (resolved.endsWith("/") || resolved.endsWith("\\"))
106
+ ? resolved.slice(0, -1)
107
+ : resolved;
108
+ }
109
+
110
+ /**
111
+ * True when the live context already holds un-prefixed (cwd-seed) entries under `relPrefix`.
112
+ * Used so add_context("./src/context") does not double-load a subpath that the seed already
113
+ * has — without blocking genuinely-absent (gitignored) subtrees.
114
+ */
115
+ function contextHasUnprefixedUnder(context: unknown, relPrefix: string): boolean {
116
+ if (!Array.isArray(context) || relPrefix === "") return false;
117
+ const clean = relPrefix.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
118
+ if (clean === "" || clean.startsWith("..")) return false;
119
+ const withSlash = `${clean}/`;
120
+ for (let i = 0; i < context.length; i++) {
121
+ const entry: unknown = context[i];
122
+ if (!isContextFile(entry)) continue;
123
+ // Only cwd-seed paths are un-prefixed; ctx/<id>/… is a different source.
124
+ if (entry.path.startsWith("ctx/")) continue;
125
+ if (entry.path === clean || entry.path.startsWith(withSlash)) return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ function alreadyLoadedResult(
131
+ sourceId: string,
132
+ pathPrefix: string,
133
+ ): Awaited<ReturnType<SubLlmHandlers["addContext"]>> {
134
+ return {
135
+ payload: Object.freeze([]),
136
+ files: 0,
137
+ chars: 0,
138
+ sourceId,
139
+ pathPrefix,
140
+ alreadyLoaded: true,
141
+ documents: 0,
142
+ converted: 0,
143
+ skipped: Object.freeze([]),
144
+ };
145
+ }
146
+
147
+ export function buildAddContextHandler(opts: AddContextBridgeOpts): AddContextHandlerBundle {
148
+ /** Prefixes already loaded in this sandbox — mirrors the worker's context state. */
149
+ const loaded = new Set<string>(opts.loadedPrefixes ?? []);
150
+ /** Absolute path of the cwd seed, if autoSeedCwd planted one successfully. */
151
+ let seededCwdAbs: string | undefined;
152
+ return {
153
+ reset: (keep) => {
154
+ const seed = keep ?? opts.loadedPrefixes ?? [];
155
+ loaded.clear();
156
+ for (const prefix of seed) loaded.add(prefix);
157
+ // Do NOT clear seededCwdAbs — the payload is still on disk in the manager and will be
158
+ // replayed; the absolute-path short-circuit must keep working after a death-recreate.
159
+ },
160
+ markLoaded: (prefix) => { loaded.add(prefix); },
161
+ markSeededCwd: (absPath) => {
162
+ seededCwdAbs = absKey(absPath);
163
+ loaded.add(""); // cwd sentinel — payloadPrefix never sees un-prefixed files
164
+ },
165
+ loadedPrefixes: () => loaded,
166
+ seededCwd: () => seededCwdAbs,
167
+ handlers: {
168
+ async addContext(source, depth) {
169
+ const emitter = opts.getEmitter?.() ?? opts.emitter;
170
+ const cwd = opts.getCwd?.() ?? opts.cwd;
171
+ if (cwd === undefined || cwd === "") {
172
+ throw new Error("add_context: no cwd configured");
173
+ }
174
+ const id = emitter?.emitSubcallCreated({
175
+ kind: "tool", parentId: opts.parentId,
176
+ label: "add_context",
177
+ args: previewText(source, 80),
178
+ depth,
179
+ });
180
+ try {
181
+ // Pre-flight the worker's own refusal: a non-list context cannot be appended to, and
182
+ // committing a prefix for it would make the NEXT load lie with already_loaded.
183
+ const current = opts.getContext?.();
184
+ if (current !== undefined && !Array.isArray(current)) {
185
+ throw new Error(LIST_CONTEXT_REQUIRED(pythonKindOf(current)));
186
+ }
187
+
188
+ const trimmed = source.trim();
189
+ const isLocal = trimmed !== "" && !/^(https:\/\/|git@)/.test(trimmed)
190
+ && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
191
+ const candidate = isLocal
192
+ ? absKey(isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed))
193
+ : undefined;
194
+ const cwdAbs = absKey(cwd);
195
+
196
+ // ── Cwd seed short-circuit / recovery ──
197
+ // Exact cwd: if already seeded → no-op; if seed failed (sticky, no markSeededCwd)
198
+ // pack un-prefixed so paths stay edit/write-friendly.
199
+ if (candidate !== undefined && candidate === cwdAbs) {
200
+ if (seededCwdAbs !== undefined) {
201
+ if (id) {
202
+ emitter?.emitSubcallUpdated({
203
+ id, status: "done", resultPreview: "already loaded (cwd seed)",
204
+ });
205
+ }
206
+ return alreadyLoadedResult("cwd", "");
207
+ }
208
+ // Recovery after failed seed (or autoSeedCwd off): pack as primary, un-prefixed.
209
+ const recovered = await resolveSource(source, {
210
+ cwd, pathPrefix: "", signal: opts.signal,
211
+ });
212
+ if (!recovered.ok) throw new Error(recovered.error);
213
+ const r = recovered.value;
214
+ if (r.payload.length === 0) throw new Error(NO_FILES_PRODUCED);
215
+ if (opts.onLoaded) await opts.onLoaded(r.payload);
216
+ seededCwdAbs = cwdAbs;
217
+ loaded.add("");
218
+ if (id) {
219
+ emitter?.emitSubcallUpdated({
220
+ id, status: "done",
221
+ resultPreview: `+${r.files} file(s) → context (cwd seed recovery, ${r.chars.toLocaleString()} chars)`,
222
+ });
223
+ }
224
+ return {
225
+ payload: r.payload,
226
+ files: r.files,
227
+ chars: r.chars,
228
+ sourceId: r.sourceId,
229
+ pathPrefix: "",
230
+ alreadyLoaded: false,
231
+ documents: r.documents,
232
+ converted: r.converted,
233
+ skipped: r.skipped,
234
+ };
235
+ }
236
+
237
+ // Subpath of the seeded cwd: only short-circuit when those files are already in
238
+ // context (un-prefixed). A gitignored subtree that the seed never had still packs.
239
+ if (candidate !== undefined && seededCwdAbs !== undefined
240
+ && candidate !== seededCwdAbs
241
+ && (candidate.startsWith(seededCwdAbs + sep) || candidate.startsWith(seededCwdAbs + "/"))) {
242
+ const rel = relative(seededCwdAbs, candidate).split(sep).join("/");
243
+ if (rel !== "" && !rel.startsWith("..") && contextHasUnprefixedUnder(current, rel)) {
244
+ if (id) {
245
+ emitter?.emitSubcallUpdated({
246
+ id, status: "done",
247
+ resultPreview:
248
+ `already in cwd seed under '${rel}/' — filter context by path prefix`,
249
+ });
250
+ }
251
+ return alreadyLoadedResult("cwd", "");
252
+ }
253
+ }
254
+
255
+ // Cheap pre-check BEFORE cloning/packing: same namespace ⇒ nothing to do.
256
+ const { sourceId: preId, pathPrefix: prefix } = contextNamespace(source, cwd);
257
+ if (loaded.has(prefix)) {
258
+ if (id) {
259
+ emitter?.emitSubcallUpdated({
260
+ id,
261
+ status: "done",
262
+ resultPreview: `already loaded (${prefix}*)`,
263
+ });
264
+ }
265
+ return alreadyLoadedResult(preId, prefix);
266
+ }
267
+
268
+ const resolved = await resolveSource(source, { cwd, signal: opts.signal });
269
+ if (!resolved.ok) throw new Error(resolved.error);
270
+ const { payload, files, chars, sourceId, pathPrefix, documents, converted, skipped } =
271
+ resolved.value;
272
+ // The worker's other refusal, pre-flighted for the same reason.
273
+ if (payload.length === 0) throw new Error(NO_FILES_PRODUCED);
274
+
275
+ // Race: another concurrent load of the same prefix finished while we packed.
276
+ if (loaded.has(pathPrefix)) {
277
+ if (id) {
278
+ emitter?.emitSubcallUpdated({
279
+ id,
280
+ status: "done",
281
+ resultPreview: `already loaded (${pathPrefix}*)`,
282
+ });
283
+ }
284
+ return alreadyLoadedResult(sourceId, pathPrefix);
285
+ }
286
+
287
+ // Mark loaded only after the host has grown its own copy of the context.
288
+ if (opts.onLoaded) {
289
+ await opts.onLoaded(payload);
290
+ }
291
+ loaded.add(pathPrefix);
292
+
293
+ if (id) {
294
+ const docNote = documents > 0
295
+ ? `, ${documents} doc(s)${converted > 0 ? ` (${converted} fresh)` : " (cached)"}`
296
+ : "";
297
+ emitter?.emitSubcallUpdated({
298
+ id,
299
+ status: "done",
300
+ resultPreview:
301
+ `+${files} file(s) → context (${pathPrefix}*, ${chars.toLocaleString()} chars${docNote})`,
302
+ });
303
+ }
304
+ return {
305
+ payload,
306
+ files,
307
+ chars,
308
+ sourceId,
309
+ pathPrefix,
310
+ alreadyLoaded: false,
311
+ documents,
312
+ converted,
313
+ skipped,
314
+ };
315
+ } catch (err) {
316
+ if (id) emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
317
+ throw err; // serviceInterrupt catch → {error} reply → "Error: …" in the REPL
318
+ }
319
+ },
320
+ },
321
+ };
322
+ }
@@ -20,6 +20,7 @@ import { displayModelRef, modelRef, resolveModelId } from "../config/settings.ts
20
20
  import { type ChatMsg, modelComplete } from "./model.ts";
21
21
  import { previewText } from "../text/preview.ts";
22
22
  import { checkResourceLimits } from "../core/resource-limits.ts";
23
+ import { filterContextByPaths } from "../context/merge.ts";
23
24
  import type { RlmInput, RlmResult, Sampling } from "../core/types.ts";
24
25
  import type { SubcallGates } from "../util/concurrency.ts";
25
26
  import type { SubcallOpts, SubLlmHandlers } from "../sandbox/sandbox.ts";
@@ -28,11 +29,10 @@ import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
28
29
 
29
30
  /**
30
31
  * The slice of LimitGuard these handlers need. Narrow on purpose: the headless bridge is
31
- * constructed from a `remainingBudget()` callback rather than owning a guard, and both
32
+ * constructed from a remaining-timeout callback rather than owning a guard, and both
32
33
  * shapes satisfy this.
33
34
  */
34
35
  export interface InvocationLimits {
35
- remainingBudgetUsd(): number | undefined;
36
36
  remainingTimeoutMs(): number | undefined;
37
37
  addUsage(usage: Usage): void;
38
38
  addRaw(costUsd: number, inputTokens: number, outputTokens: number): void;
@@ -45,10 +45,9 @@ export interface InvocationLimits {
45
45
  * `onChildUsage`, so the accounting methods here are deliberately inert.
46
46
  */
47
47
  export function limitsFromRemaining(
48
- remaining?: () => { readonly budgetUsd?: number; readonly timeoutMs?: number },
48
+ remaining?: () => { readonly timeoutMs?: number },
49
49
  ): InvocationLimits {
50
50
  return {
51
- remainingBudgetUsd: () => remaining?.().budgetUsd,
52
51
  remainingTimeoutMs: () => remaining?.().timeoutMs,
53
52
  addUsage: () => {},
54
53
  addRaw: () => {},
@@ -60,7 +59,7 @@ export function limitsFromRemaining(
60
59
  *
61
60
  * Captured at interrupt entry and threaded down, never re-read: once handlers can outlive
62
61
  * their exec, re-reading mutable tool state after an await would attribute a sub-call to
63
- * whichever turn happens to be current when it resumes.
62
+ * whichever turn happens to be current when it settles.
64
63
  */
65
64
  export interface Invocation {
66
65
  readonly emitter: RlmEmitter;
@@ -91,7 +90,7 @@ export interface SubcallHandlerDeps {
91
90
  /** Session-wide admission control. Required — a per-caller default would silently unbound it. */
92
91
  readonly gates: SubcallGates;
93
92
  readonly registry: ModelRegistry;
94
- readonly getWorkerModel: () => Model<Api>;
93
+ readonly getLlmModel: () => Model<Api>;
95
94
  /** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
96
95
  readonly getConfig: () => SubcallConfig;
97
96
  readonly signal?: AbortSignal;
@@ -107,6 +106,15 @@ export interface SubcallHandlerDeps {
107
106
  * one emitter is what lets the session registry drain the subtree intact.
108
107
  */
109
108
  readonly runChild?: (input: RlmInput, inv: Invocation) => Promise<RlmResult>;
109
+ /**
110
+ * The parent's live context, read at spawn time and never captured: a library loaded on turn 3
111
+ * must reach a child spawned on turn 4. `undefined`/`null` ⇒ no inheritance, and the child falls
112
+ * back to prompt-as-context.
113
+ *
114
+ * This is the ONLY inheritance seam. Adding a second construction path for a child's world
115
+ * would re-open issue #4 on whichever path forgets to grow.
116
+ */
117
+ readonly getChildContext?: () => unknown;
110
118
  readonly getModel?: () => Model<Api>;
111
119
  /**
112
120
  * What rlm_query degrades to at the depth cap. A child RLM there would just be an LM, so
@@ -141,10 +149,18 @@ function emptyResult(answer: string): RlmResult {
141
149
  return { answer, iterations: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 };
142
150
  }
143
151
 
152
+ /** What a child RLM will see, plus any `paths=` prefix that selected nothing. */
153
+ interface ChildContext {
154
+ readonly context: unknown;
155
+ readonly unmatched: readonly string[];
156
+ }
157
+
158
+ const NO_UNMATCHED: readonly string[] = Object.freeze([]);
159
+
144
160
  export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers {
145
161
  /** DRY #3 — the one display-model resolution. */
146
162
  const displayModel = (model: string | null): string =>
147
- displayModelRef(deps.registry, model, deps.getWorkerModel());
163
+ displayModelRef(deps.registry, model, deps.getLlmModel());
148
164
 
149
165
  /** Detached work is counted by the session registry; attached work runs as-is. */
150
166
  const detachable = <T>(opts: SubcallOpts, run: () => Promise<T>): Promise<T> =>
@@ -159,7 +175,6 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
159
175
  ): Promise<string> {
160
176
  const config = deps.getConfig();
161
177
  const limitError = checkResourceLimits({
162
- budgetUsd: inv.limits.remainingBudgetUsd(),
163
178
  timeoutMs: inv.limits.remainingTimeoutMs(),
164
179
  });
165
180
  if (limitError !== undefined) return limitError;
@@ -174,7 +189,7 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
174
189
  try {
175
190
  const messages: ChatMsg[] = [{ role: "user", content: prompt }];
176
191
  const res = await deps.gates.leaf.run(() => modelComplete(messages, {
177
- model: resolved ?? deps.getWorkerModel(),
192
+ model: resolved ?? deps.getLlmModel(),
178
193
  registry: deps.registry,
179
194
  system: config.subSystemPrompt,
180
195
  maxTokens: config.subSampling?.maxTokens,
@@ -236,11 +251,38 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
236
251
  return out;
237
252
  }
238
253
 
254
+ /**
255
+ * Resolve the child's world: the parent's live context, optionally narrowed by path prefixes.
256
+ *
257
+ * Falls back to prompt-as-context when nothing is wired, and to the FULL context when `paths`
258
+ * matched nothing — a silently blind child is exactly the bug this fixes, so a bad prefix
259
+ * degrades loudly (see the note childRun folds into rootPrompt) rather than quietly.
260
+ */
261
+ function childContextFor(prompt: string, paths: readonly string[] | undefined): ChildContext {
262
+ const inherited = deps.getChildContext?.();
263
+ if (inherited === undefined || inherited === null) {
264
+ return Object.freeze({ context: prompt, unmatched: NO_UNMATCHED });
265
+ }
266
+ if (paths === undefined || paths.length === 0) {
267
+ return Object.freeze({ context: inherited, unmatched: NO_UNMATCHED });
268
+ }
269
+ const filtered = filterContextByPaths(inherited, paths);
270
+ return Object.freeze({
271
+ context: filtered.files.length > 0 ? filtered.files : inherited,
272
+ unmatched: filtered.unmatched,
273
+ });
274
+ }
275
+
239
276
  /**
240
277
  * One child RLM run: depth cap → resource guard → spawn engine → debit parent.
241
278
  * Emits its own subcall node, so callers must not wrap it in another.
242
279
  */
243
- async function childRun(inv: Invocation, prompt: string, model: string | null): Promise<RlmResult> {
280
+ async function childRun(
281
+ inv: Invocation,
282
+ prompt: string,
283
+ model: string | null,
284
+ paths: readonly string[] | undefined,
285
+ ): Promise<RlmResult> {
244
286
  const childDepth = inv.depth + 1;
245
287
  const run = deps.runChild;
246
288
  const maxDepth = deps.getConfig().maxDepth;
@@ -254,9 +296,8 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
254
296
  return emptyResult(answer);
255
297
  }
256
298
 
257
- const remBudget = inv.limits.remainingBudgetUsd();
258
299
  const remTimeout = inv.limits.remainingTimeoutMs();
259
- const limitError = checkResourceLimits({ budgetUsd: remBudget, timeoutMs: remTimeout });
300
+ const limitError = checkResourceLimits({ timeoutMs: remTimeout });
260
301
  if (limitError) return emptyResult(limitError);
261
302
 
262
303
  const rootModel = deps.getModel?.();
@@ -268,14 +309,19 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
268
309
  kind: "rlm", parentId: inv.parentId, label: "rlm_query",
269
310
  model: modelLabel, detail: prompt.slice(0, 60), depth: childDepth,
270
311
  });
312
+ // The child's context is the parent's world, not the prompt text. The prompt becomes the
313
+ // child's rootPrompt, exactly as a depth-0 run takes the user's question.
314
+ const child = childContextFor(prompt, paths);
315
+ const rootPrompt = child.unmatched.length === 0
316
+ ? prompt
317
+ : `${prompt}\n\n[rlm] paths=${child.unmatched.join(", ")} matched no files; you received the full context.`;
271
318
  try {
272
319
  const res = await deps.gates.rlm.at(childDepth).run(() => run({
273
- rootPrompt: "",
274
- context: prompt,
320
+ rootPrompt,
321
+ context: child.context,
275
322
  depth: childDepth,
276
323
  parentNodeId: subId,
277
324
  modelOverride: model ?? undefined,
278
- remainingBudgetUsd: remBudget,
279
325
  remainingTimeoutMs: remTimeout,
280
326
  }, inv));
281
327
  inv.limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
@@ -320,7 +366,7 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
320
366
  async rlmQuery(prompt, model, depth, opts) {
321
367
  const inv = deps.resolve(opts, depth);
322
368
  if (inv === null) return UNWIRED;
323
- return detachable(opts, async () => (await childRun(inv, prompt, model)).answer);
369
+ return detachable(opts, async () => (await childRun(inv, prompt, model, opts.paths)).answer);
324
370
  },
325
371
 
326
372
  async rlmQueryBatched(prompts, model, depth, opts) {
@@ -328,7 +374,7 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
328
374
  if (inv === null) return prompts.map(() => UNWIRED);
329
375
  // Bounded by the per-depth rlm gate inside childRun, not by an outer pool.
330
376
  return detachable(opts, async () => {
331
- const results = await Promise.all(prompts.map((p) => childRun(inv, p, model)));
377
+ const results = await Promise.all(prompts.map((p) => childRun(inv, p, model, opts.paths)));
332
378
  return results.map((r) => r.answer);
333
379
  });
334
380
  },
@@ -1,47 +1,76 @@
1
- /** `/rlm-config` — choose worker model, reasoning level, and run settings (smart is always pi's active model). */
1
+ /** `/rlm-config` — choose the sub-LLM model, reasoning level, and run settings.
2
+ * The root model is always pi's active model; only the sub-LLM is configurable here. */
2
3
 
4
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
6
  import { modelRef } from "../config/settings.ts";
5
- import { cheapestModel, type RlmController } from "../mode/rlm-mode.ts";
7
+ import type { RlmController } from "../mode/rlm-mode.ts";
8
+ import { cheapestModel } from "../mode/llm-model.ts";
6
9
  import { setRlmModeStatus } from "../ui/status.ts";
7
10
  import { showConfigPanel } from "../ui/config-panel.ts";
8
- import { selectModel } from "../ui/model-picker.ts";
11
+ import { pickableModels, selectModel } from "../ui/model-picker.ts";
12
+
13
+ /** Newer Pi hosts expose session-scoped models; 0.79 peers do not — duck-type safely. */
14
+ function sessionScopedModels(
15
+ ctx: ExtensionContext,
16
+ ): readonly { readonly model: Model<Api> }[] | undefined {
17
+ const scoped: unknown = Reflect.get(ctx, "scopedModels");
18
+ return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
19
+ }
9
20
 
10
21
  export async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<boolean> {
11
- const models = ctx.modelRegistry.getAvailable();
22
+ // Match Pi's native list: refresh so a just-added key appears, then use scoped models when
23
+ // the session narrowed them, else every available (auth-configured) model. Never getAll().
24
+ try {
25
+ await ctx.modelRegistry.refresh();
26
+ } catch {
27
+ // Fail-soft: show the cached available snapshot rather than aborting config.
28
+ }
29
+ const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
12
30
 
13
- const worker = await selectModel(ctx, "Worker model (sub-LLM / llm_query)", models, controller.workerModel, controller.config.subSampling.reasoning);
14
- if (worker !== undefined) {
15
- controller.workerModel = worker?.model;
31
+ const llm = await selectModel(
32
+ ctx,
33
+ "LLM model (sub-calls: llm_query / map_files / rlm_query)",
34
+ models,
35
+ controller.llmModel,
36
+ controller.config.subSampling.reasoning,
37
+ );
38
+ if (llm !== undefined) {
39
+ controller.llmModel = llm?.model;
16
40
  controller.setConfig(Object.freeze({
17
41
  ...controller.config,
18
- subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: worker?.thinkingLevel }),
42
+ subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: llm?.thinkingLevel }),
19
43
  }));
20
44
  }
21
45
 
22
46
  controller.setConfig(await showConfigPanel(ctx, controller.config));
23
47
 
24
- if (worker === null) {
25
- controller.savedWorkerRef = undefined;
26
- } else {
27
- const effectiveWorker = controller.workerModel ?? cheapestModel(ctx.modelRegistry);
28
- controller.savedWorkerRef = modelRef(controller.workerModel) ?? modelRef(effectiveWorker);
29
- }
48
+ // Only an explicit choice touches the persisted pin. ESC (`undefined`) used to fall through
49
+ // here and freeze whatever cheapest resolved to at that moment, which silently ended
50
+ // "cheapest (auto)" for every later session — including once a cheaper model appeared.
51
+ if (llm === null) controller.savedLlmRef = undefined; // "⟳ cheapest (auto)"
52
+ else if (llm !== undefined) controller.savedLlmRef = modelRef(llm.model);
53
+
30
54
  const persisted = await controller.persist();
31
55
  if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
32
56
  setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
33
57
 
34
- const w = controller.workerModel;
58
+ // Name the model that actually resolved, not "(cheapest)" — otherwise there is no way to
59
+ // tell whether the free model in the catalog was the one picked.
60
+ const pinned = controller.llmModel;
61
+ const effective = pinned ?? cheapestModel(ctx.modelRegistry);
62
+ const reasoning = controller.config.subSampling.reasoning;
35
63
  ctx.ui.notify(
36
- `RLM: worker=${w ? `${w.provider}/${w.id}` : "(cheapest)"}${controller.config.subSampling.reasoning ? `/${controller.config.subSampling.reasoning}` : ""}`,
64
+ `RLM: llm=${modelRef(effective) ?? "(none available)"}`
65
+ + `${pinned ? "" : " (cheapest, auto)"}${reasoning ? `/${reasoning}` : ""}`,
37
66
  "info",
38
67
  );
39
- return worker !== undefined;
68
+ return llm !== undefined;
40
69
  }
41
70
 
42
71
  export function registerRlmConfigCommand(pi: ExtensionAPI, controller: RlmController): void {
43
72
  pi.registerCommand("rlm-config", {
44
- description: "Configure RLM worker model and run settings.",
73
+ description: "Configure the RLM sub-LLM model and run settings.",
45
74
  handler: async (_args, ctx) => {
46
75
  await runRlmConfig(controller, ctx);
47
76
  },