@hicaru/pi-rlm 0.3.8 → 0.3.13

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 (55) hide show
  1. package/README.md +3 -4
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/completion.ts +5 -0
  4. package/src/bridge/handlers/emitting.ts +33 -23
  5. package/src/bridge/handlers/index.ts +1 -1
  6. package/src/bridge/handlers/llm-query.ts +23 -24
  7. package/src/bridge/handlers/rlm-query.ts +10 -32
  8. package/src/bridge/handlers/types.ts +8 -1
  9. package/src/bridge/model.ts +33 -15
  10. package/src/commands/pins.ts +51 -0
  11. package/src/commands/rlm-config.ts +4 -88
  12. package/src/commands/rlm-llm.ts +59 -0
  13. package/src/commands/rlm-rlm.ts +58 -0
  14. package/src/commands/rlm.ts +2 -2
  15. package/src/config/defaults.ts +14 -4
  16. package/src/config/settings.ts +26 -3
  17. package/src/core/budget.ts +1 -1
  18. package/src/core/compaction.ts +4 -0
  19. package/src/core/engine.ts +21 -4
  20. package/src/core/iteration.ts +12 -0
  21. package/src/core/ledger.ts +15 -123
  22. package/src/core/memory.ts +13 -1
  23. package/src/core/model-registry.ts +1 -1
  24. package/src/core/types.ts +14 -0
  25. package/src/index.ts +53 -4
  26. package/src/mode/rlm-mode.ts +11 -1
  27. package/src/prompts/glossary.ts +11 -3
  28. package/src/prompts/native.ts +1 -1
  29. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  31. package/src/tool/repl-render.ts +4 -10
  32. package/src/tool/repl-tool.ts +30 -17
  33. package/src/tool/rlm-aggregator.ts +16 -3
  34. package/src/tool/rlm-details.ts +8 -0
  35. package/src/tool/rlm-events.ts +17 -1
  36. package/src/tool/rlm-tool.ts +25 -14
  37. package/src/tool/subcall-render.ts +14 -129
  38. package/src/tool/subcall-store.ts +11 -1
  39. package/src/ui/intro.ts +13 -4
  40. package/src/ui/modal/agent-modal.ts +104 -0
  41. package/src/ui/modal/modal-view.ts +132 -0
  42. package/src/ui/modal/timeline-store.ts +85 -0
  43. package/src/ui/model-picker/drilldown.ts +173 -0
  44. package/src/ui/model-picker/grouping.ts +81 -0
  45. package/src/ui/model-picker/levels.ts +63 -0
  46. package/src/ui/model-picker.ts +7 -197
  47. package/src/ui/panel/run-registry.ts +135 -0
  48. package/src/ui/panel/tree-panel.ts +46 -0
  49. package/src/ui/status.ts +26 -13
  50. package/src/ui/theme.ts +0 -4
  51. package/src/ui/tree/tree-model.ts +226 -0
  52. package/src/ui/tree/tree-rows.ts +74 -0
  53. package/src/ui/tree/tree-widget.ts +186 -0
  54. package/src/util/retry.ts +180 -0
  55. package/src/util/throttle.ts +90 -0
@@ -0,0 +1,58 @@
1
+ /** `/rlm-rlm` — pin the root/worker model for rlm_query / rlm_batch child engines.
2
+ *
3
+ * Unpinned (default), child engines follow pi's active session model — exactly
4
+ * the pre-pin behavior, now an explicit picker row.
5
+ */
6
+
7
+ import type { Api, Model } from "@earendil-works/pi-ai";
8
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { modelRef } from "../config/settings.ts";
10
+ import type { RlmController } from "../mode/rlm-mode.ts";
11
+ import { pickableModels, selectModel } from "../ui/model-picker.ts";
12
+ import { setRlmModeStatus } from "../ui/status.ts";
13
+ import { applyRlmSelection } from "./pins.ts";
14
+
15
+ function sessionScopedModels(
16
+ ctx: ExtensionContext,
17
+ ): readonly { readonly model: Model<Api> }[] | undefined {
18
+ const scoped: unknown = Reflect.get(ctx, "scopedModels");
19
+ return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
20
+ }
21
+
22
+ async function runRlmRlm(controller: RlmController, ctx: ExtensionContext): Promise<void> {
23
+ try {
24
+ await ctx.modelRegistry.refresh();
25
+ } catch {
26
+ // Fail-soft: show the cached available snapshot rather than aborting config.
27
+ }
28
+ const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
29
+ const rlm = await selectModel(
30
+ ctx,
31
+ "rlm",
32
+ models,
33
+ controller.rlmModel,
34
+ controller.config.rootSampling?.reasoning,
35
+ controller.savedRlmRef,
36
+ );
37
+ applyRlmSelection(controller, rlm);
38
+ const persisted = await controller.persist();
39
+ if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
40
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
41
+
42
+ const reasoning = controller.config.rootSampling?.reasoning;
43
+ ctx.ui.notify(
44
+ controller.rlmModel
45
+ ? `RLM: rlm=${modelRef(controller.rlmModel) ?? "(none)"}${reasoning ? `/${reasoning}` : ""}`
46
+ : "RLM: rlm follows session model",
47
+ "info",
48
+ );
49
+ }
50
+
51
+ export function registerRlmRlmCommand(pi: ExtensionAPI, controller: RlmController): void {
52
+ pi.registerCommand("rlm-rlm", {
53
+ description: "Pin the model used by rlm_query / rlm_batch child engines (default: session model).",
54
+ handler: async (_args, ctx) => {
55
+ await runRlmRlm(controller, ctx);
56
+ },
57
+ });
58
+ }
@@ -9,7 +9,7 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
9
9
  description: "Toggle persistent RLM mode (route plain prompts through the RLM engine).",
10
10
  handler: async (_args, ctx) => {
11
11
  const enabled = controller.toggle();
12
- setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
12
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
13
13
  ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
14
14
  },
15
15
  });
@@ -30,7 +30,7 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
30
30
  description: "Toggle RLM mode (off also stops a running query)",
31
31
  handler: async (ctx) => {
32
32
  const enabled = controller.toggle();
33
- setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
33
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
34
34
  ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
35
35
  },
36
36
  });
@@ -13,12 +13,22 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
13
13
  execTimeoutS: 120,
14
14
  requestTimeoutMs: 15 * 60_000,
15
15
  // Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
16
- // the only thing bounding leaf fan-out.
17
- maxConcurrentSubcalls: 16,
16
+ // the only thing bounding leaf fan-out. Default 8 — a sane rate for per-account limits
17
+ // (raise to 16/32 via /rlm-config when the provider allows).
18
+ maxConcurrentSubcalls: 8,
18
19
  // Children are bounded separately and lower: each is a Python subprocess holding its own copy
19
20
  // of the context it inherited, where a leaf is one HTTP request. Worst case is
20
- // (maxDepth - 1) × this many concurrent child engines.
21
- maxConcurrentChildren: 6,
21
+ // (maxDepth - 1) × this many concurrent child engines. Default 4.
22
+ maxConcurrentChildren: 4,
23
+ // v5.1 rate-limit resilience (util/retry.ts): 3 total attempts, 500ms→15s backoff,
24
+ // 2s→60s adaptive per-provider cooldown. All overridable in rlm.json.
25
+ retryMaxAttempts: 3,
26
+ // 429s park on the cooldown instead of dying — up to 8 windows (2s→4s→…≤60s ≈ 4 min).
27
+ rateLimitMaxAttempts: 8,
28
+ retryBaseDelayMs: 500,
29
+ retryMaxDelayMs: 15_000,
30
+ throttleBaseMs: 2_000,
31
+ throttleMaxMs: 60_000,
22
32
  maxPromptChars: 400_000,
23
33
  maxErrors: 5,
24
34
  orchestrator: true,
@@ -12,6 +12,9 @@ export interface PersistedSettings {
12
12
  /** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)".
13
13
  * `null` = explicit "cheapest" clear (omit key on disk). */
14
14
  readonly llm?: string | null;
15
+ /** "provider/id" of the pinned rlm root/worker model, or undefined for "follow session".
16
+ * `null` = explicit "follow session model" clear (omit key on disk). */
17
+ readonly rlm?: string | null;
15
18
  }
16
19
 
17
20
  type MutablePartialRlmConfig = { -readonly [K in keyof RlmConfig]?: RlmConfig[K] };
@@ -65,6 +68,19 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
65
68
  if (maxConcurrentSubcalls !== undefined) out.maxConcurrentSubcalls = maxConcurrentSubcalls;
66
69
  const maxConcurrentChildren = validateNumber(r.maxConcurrentChildren, 1);
67
70
  if (maxConcurrentChildren !== undefined) out.maxConcurrentChildren = maxConcurrentChildren;
71
+ // v5.1 rate-limit resilience knobs
72
+ const retryMaxAttempts = validateNumber(r.retryMaxAttempts, 1);
73
+ if (retryMaxAttempts !== undefined) out.retryMaxAttempts = retryMaxAttempts;
74
+ const rateLimitMaxAttempts = validateNumber(r.rateLimitMaxAttempts, 1);
75
+ if (rateLimitMaxAttempts !== undefined) out.rateLimitMaxAttempts = rateLimitMaxAttempts;
76
+ const retryBaseDelayMs = validateNumber(r.retryBaseDelayMs, 0);
77
+ if (retryBaseDelayMs !== undefined) out.retryBaseDelayMs = retryBaseDelayMs;
78
+ const retryMaxDelayMs = validateNumber(r.retryMaxDelayMs, 100);
79
+ if (retryMaxDelayMs !== undefined) out.retryMaxDelayMs = retryMaxDelayMs;
80
+ const throttleBaseMs = validateNumber(r.throttleBaseMs, 0);
81
+ if (throttleBaseMs !== undefined) out.throttleBaseMs = throttleBaseMs;
82
+ const throttleMaxMs = validateNumber(r.throttleMaxMs, 100);
83
+ if (throttleMaxMs !== undefined) out.throttleMaxMs = throttleMaxMs;
68
84
  const maxPromptChars = validateNumber(r.maxPromptChars, 1000);
69
85
  if (maxPromptChars !== undefined) out.maxPromptChars = maxPromptChars;
70
86
  const maxTimeoutMs = validateNumber(r.maxTimeoutMs, 1000);
@@ -167,6 +183,7 @@ export async function loadSettings(): Promise<PersistedSettings> {
167
183
  config: validateConfig(r.config),
168
184
  // `worker` is the pre-rename key — still read so an existing pin survives the upgrade.
169
185
  llm: validateString(r.llm) ?? validateString(r.worker),
186
+ rlm: validateString(r.rlm),
170
187
  };
171
188
  } catch {
172
189
  return { config: {} };
@@ -178,13 +195,19 @@ export async function saveSettings(s: PersistedSettings): Promise<boolean> {
178
195
  const p = settingsPath();
179
196
  await mkdir(dirname(p), { recursive: true });
180
197
  const body: Record<string, unknown> = { config: s.config };
198
+ const mergeDisk = s.llm === undefined || s.rlm === undefined;
199
+ const existing = mergeDisk ? await loadSettings() : undefined;
181
200
  if (s.llm !== undefined) {
182
201
  // Explicit: string → write pin, null → omit key (cheapest).
183
202
  if (s.llm !== null) body.llm = s.llm;
184
- } else {
203
+ } else if (existing?.llm) {
185
204
  // Merge: preserve existing disk pin so config-only saves never strip it.
186
- const existing = await loadSettings();
187
- if (existing.llm) body.llm = existing.llm;
205
+ body.llm = existing.llm;
206
+ }
207
+ if (s.rlm !== undefined) {
208
+ if (s.rlm !== null) body.rlm = s.rlm;
209
+ } else if (existing?.rlm) {
210
+ body.rlm = existing.rlm;
188
211
  }
189
212
  await writeFile(p, `${JSON.stringify(body, null, 2)}\n`);
190
213
  return true;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Token budget cascade (port of rlm_test v4/v5 `budget.py`).
2
+ * Token budget cascade (port of the v4/v5 `budget.py` engine).
3
3
  *
4
4
  * The budget is the PRIMARY run-length control: cap = budgetShare × model context window,
5
5
  * one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
@@ -9,6 +9,7 @@
9
9
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
10
10
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
11
11
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
12
+ import type { RetryPolicy } from "../util/retry.ts";
12
13
  import { estimateMessageTokens } from "../text/tokens.ts";
13
14
 
14
15
  const DEFAULT_CONTEXT_WINDOW = 128_000;
@@ -24,6 +25,8 @@ export interface CompactionDeps {
24
25
  readonly contextWindow?: number;
25
26
  readonly thresholdPct?: number;
26
27
  readonly signal?: AbortSignal;
28
+ /** v5.1 retry policy for modelComplete; defaults apply when omitted. */
29
+ readonly retry?: RetryPolicy;
27
30
  }
28
31
 
29
32
  /** True if the history is at/over the compaction threshold. */
@@ -93,6 +96,7 @@ export async function compactHistory(
93
96
  model: deps.model,
94
97
  registry: deps.registry,
95
98
  signal: deps.signal,
99
+ retry: deps.retry,
96
100
  });
97
101
  onUsage?.(usage);
98
102
  const system = history.find((m) => m.role === "system");
@@ -22,12 +22,14 @@ import { type ChatMsg, modelComplete } from "../bridge/model.ts";
22
22
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
23
23
  import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
24
24
  import type { RlmEmitter } from "../tool/rlm-events.ts";
25
+ import type { SubcallPhase } from "../tool/rlm-details.ts";
25
26
  import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox.ts";
26
27
  import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
27
28
  import { previewStdout, previewText } from "../text/preview.ts";
28
29
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
29
30
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
30
31
  import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
32
+ import { retryPolicy } from "../util/retry.ts";
31
33
  import { appendUserMessage } from "./history.ts";
32
34
  import { runTurn } from "./iteration.ts";
33
35
  import { type Limits, LimitError, LimitGuard } from "./limits.ts";
@@ -119,6 +121,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
119
121
  },
120
122
  },
121
123
  };
124
+ // Live activity phase for the tree UI: child engines report on their own subcall
125
+ // node; the root engine has no node, so it reports via the root-phase channel.
126
+ const reportPhase = (phase: SubcallPhase): void => {
127
+ if (selfReportId !== undefined) emitter.emitSubcallUpdated({ id: selfReportId, phase });
128
+ else emitter.emitRootPhase(phase);
129
+ };
122
130
  // Detached work must not outlive the sandbox we dispose in `finally`: track it so the
123
131
  // run can settle or abort it first (a child engine left running would keep spending).
124
132
  let detachedInFlight = 0;
@@ -131,7 +139,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
131
139
  if (deps.config.enableLedger) runLedger.beginRun(input.rootPrompt);
132
140
 
133
141
  // v5 durable memory: read-only root replay — an identical prompt over an identical
134
- // context answers for zero API calls (measured 10,051 → 0 tok in rlm_test).
142
+ // context answers for zero API calls (measured 10,051 → 0 tok in bake-off runs).
135
143
  const rootMemory =
136
144
  deps.memory !== undefined && deps.config.enableMemory ? deps.memory : undefined;
137
145
  const modelRefStr = `${model.provider}/${model.id}`;
@@ -276,9 +284,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
276
284
  }).handlers
277
285
  : {};
278
286
 
287
+ // v5 doctrine: one condition feeds BOTH the python surface and the memory scope —
288
+ // delegation children keep llm/memory-read/ledger, never repo retrieval or memory.add.
289
+ const surface = input.depth > 0 && deps.config.childSurface === "delegation" ? "child" : "root";
279
290
  sandbox = await PythonSandbox.spawn({
280
291
  depth: input.depth,
281
- surface: input.depth > 0 && deps.config.childSurface === "delegation" ? "child" : "root",
292
+ surface,
282
293
  execTimeoutS: deps.config.execTimeoutS,
283
294
  requestTimeoutMs: deps.config.requestTimeoutMs,
284
295
  python: deps.config.python,
@@ -290,7 +301,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
290
301
  ...subcalls,
291
302
  ...contextHandlers,
292
303
  ledgerClaims: () => Promise.resolve(runLedger.listClaims()),
293
- memoryOp: (op, args) => Promise.resolve(rootMemory?.serviceOp(op, args) ?? "memory off"),
304
+ memoryOp: (op, args) =>
305
+ Promise.resolve(
306
+ rootMemory === undefined ? "memory off" : rootMemory.serviceOp(op, args, surface === "child" ? "child" : "root"),
307
+ ),
294
308
  },
295
309
  });
296
310
 
@@ -316,6 +330,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
316
330
  registry: deps.registry,
317
331
  contextWindow: model.contextWindow,
318
332
  thresholdPct: deps.config.compactionThresholdPct,
333
+ retry: retryPolicy(deps.config),
319
334
  signal: deps.signal,
320
335
  };
321
336
  if (shouldCompact(history, compactionDeps)) {
@@ -328,7 +343,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
328
343
  pendingReplOutputs = undefined;
329
344
  }
330
345
 
331
- // Soft runtime nudge (rlm_test parity): remind the model to await pending host tasks.
346
+ // Soft runtime nudge (engine parity): remind the model to await pending host tasks.
332
347
  const pendingIds = taskRegistry.awaitDeps.unawaitedIds();
333
348
  if (pendingIds.length > 0) {
334
349
  appendUserMessage(
@@ -359,8 +374,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
359
374
  model: model,
360
375
  registry: deps.registry,
361
376
  sampling: rootSampling,
377
+ retry: deps.complete === undefined ? retryPolicy(deps.config) : undefined,
362
378
  signal: deps.signal,
363
379
  complete: deps.complete,
380
+ onPhase: reportPhase,
364
381
  });
365
382
  const allBlocks = turn.blocks.length > 0
366
383
  ? turn.blocks.map((b) => previewText(b, 400)).join("\n")
@@ -7,9 +7,11 @@
7
7
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
8
8
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
9
9
  import { type ChatMsg, type CompleteOptions, type CompleteResult, modelComplete } from "../bridge/model.ts";
10
+ import type { RetryPolicy } from "../util/retry.ts";
10
11
  import type { ReplResult } from "../sandbox/protocol.ts";
11
12
  import type { PythonSandbox } from "../sandbox/sandbox.ts";
12
13
  import { findReplBlocks } from "../text/parsing.ts";
14
+ import type { SubcallPhase } from "../tool/rlm-details.ts";
13
15
  import type { Sampling } from "./types.ts";
14
16
 
15
17
  export interface Turn {
@@ -30,20 +32,30 @@ export interface TurnDeps {
30
32
  readonly signal?: AbortSignal;
31
33
  /** Test-only override for model completion (scripted responses). */
32
34
  readonly complete?: CompleteFn;
35
+ /** v5.1 retry policy for modelComplete (rate-limit resilience); defaults apply when omitted. */
36
+ readonly retry?: RetryPolicy;
37
+ /** Live activity reporting for the tree UI (thinking → repl/texting per turn). */
38
+ readonly onPhase?: (phase: SubcallPhase) => void;
33
39
  }
34
40
 
35
41
  export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbox, deps: TurnDeps): Promise<Turn> {
36
42
  const complete = deps.complete ?? modelComplete;
43
+ deps.onPhase?.("thinking");
37
44
  const { text, usage } = await complete(history, {
38
45
  model: deps.model,
39
46
  registry: deps.registry,
40
47
  maxTokens: deps.sampling?.maxTokens,
41
48
  temperature: deps.sampling?.temperature,
42
49
  reasoning: deps.sampling?.reasoning,
50
+ retry: deps.retry,
51
+ // Rate-limit parking is a visible phase too — the root/child spinner says "queued".
52
+ onThrottlePark: deps.onPhase ? () => deps.onPhase?.("queued") : undefined,
53
+ onThrottleRelease: deps.onPhase ? () => deps.onPhase?.("thinking") : undefined,
43
54
  signal: deps.signal,
44
55
  });
45
56
 
46
57
  const blocks = findReplBlocks(text);
58
+ deps.onPhase?.(blocks.length > 0 ? "repl" : "texting");
47
59
  const results = new Array<ReplResult>(blocks.length);
48
60
  let executed = 0;
49
61
  for (let i = 0; i < blocks.length; i++) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * TaskLedger — the session blackboard (port of rlm_test v5 `ledger.py`).
2
+ * TaskLedger — the session blackboard (port of the v5 `ledger.py` engine).
3
3
  *
4
4
  * One instance per root run (engine) or per session (native repl tool), threaded down to every
5
5
  * child through `SubcallHandlerDeps.ledger` / `RlmInput.ledger` — the same seam as
@@ -139,111 +139,10 @@ export function taskKey(
139
139
  }
140
140
 
141
141
  export const ECHO_STUB: string = Object.freeze(
142
- "[ledger echo] this task restates an ancestor goal the parent run already covers it. " +
143
- "Do not spawn a duplicate; answer from what you already know or await the existing task.",
142
+ "[ledger: ancestor echo rejected. You are already doing this task. " +
143
+ "Use context / answers / the files already in scope. Do not rlm_query the parent task.]",
144
144
  );
145
145
 
146
- const RLM_CALL_OPEN = /\brlm_(?:query|batch)\s*\(/g;
147
-
148
- /**
149
- * Native `repl()` cells are Python, not a task (audit R1). Pull quoted
150
- * `rlm_query` / `rlm_batch` arguments so `beginRun` has a task-shaped ancestor
151
- * instead of `print` / `await_task` tokens. Falls back to the raw cell when no
152
- * such call is present. `paths=` keyword args are not tasks.
153
- */
154
- export function nativeRunAncestors(code: string): readonly string[] {
155
- const found = extractRlmTaskPrompts(code);
156
- return Object.freeze(found.length > 0 ? found : [code]);
157
- }
158
-
159
- function extractRlmTaskPrompts(code: string): readonly string[] {
160
- const out: string[] = [];
161
- RLM_CALL_OPEN.lastIndex = 0;
162
- for (const m of code.matchAll(RLM_CALL_OPEN)) {
163
- const start = (m.index ?? 0) + m[0].length;
164
- const body = sliceCallBody(code, start);
165
- const pathSplit = body.split(/\bpaths\s*=/);
166
- const taskPart = pathSplit[0] ?? body;
167
- const strings = quotedStrings(taskPart);
168
- for (let i = 0; i < strings.length; i++) {
169
- const s = strings[i];
170
- if (s !== undefined && s.trim() !== "") out.push(s);
171
- }
172
- }
173
- return out;
174
- }
175
-
176
- function sliceCallBody(src: string, start: number): string {
177
- let depth = 1;
178
- let i = start;
179
- while (i < src.length && depth > 0) {
180
- const c = src[i];
181
- if (c === "'" || c === '"') {
182
- i = skipPyString(src, i);
183
- continue;
184
- }
185
- if (c === "#") {
186
- const nl = src.indexOf("\n", i);
187
- i = nl === -1 ? src.length : nl + 1;
188
- continue;
189
- }
190
- if (c === "(") depth++;
191
- else if (c === ")") depth--;
192
- i++;
193
- }
194
- return src.slice(start, depth === 0 ? i - 1 : i);
195
- }
196
-
197
- function quotedStrings(src: string): readonly string[] {
198
- const out: string[] = [];
199
- let i = 0;
200
- while (i < src.length) {
201
- const c = src[i];
202
- if (c === "'" || c === '"') {
203
- const parsed = readPyString(src, i);
204
- if (parsed.keep) out.push(parsed.value);
205
- i = parsed.end;
206
- continue;
207
- }
208
- i++;
209
- }
210
- return out;
211
- }
212
-
213
- function skipPyString(src: string, quoteAt: number): number {
214
- return readPyString(src, quoteAt).end;
215
- }
216
-
217
- function readPyString(
218
- src: string,
219
- quoteAt: number,
220
- ): { readonly value: string; readonly end: number; readonly keep: boolean } {
221
- const quote = src[quoteAt] ?? '"';
222
- const prefix = quoteAt > 0 ? src[quoteAt - 1] : "";
223
- const keep = prefix !== "f" && prefix !== "F";
224
- const triple = src.startsWith(quote + quote + quote, quoteAt);
225
- const delimLen = triple ? 3 : 1;
226
- const from = quoteAt + delimLen;
227
- if (triple) {
228
- const close = src.indexOf(quote + quote + quote, from);
229
- if (close === -1) return { value: src.slice(from), end: src.length, keep };
230
- return { value: src.slice(from, close), end: close + 3, keep };
231
- }
232
- const parts: string[] = [];
233
- let j = from;
234
- while (j < src.length) {
235
- const ch = src[j];
236
- if (ch === "\\") {
237
- parts.push(src[j + 1] ?? "");
238
- j += 2;
239
- continue;
240
- }
241
- if (ch === quote) return { value: parts.join(""), end: j + 1, keep };
242
- parts.push(ch ?? "");
243
- j++;
244
- }
245
- return { value: parts.join(""), end: src.length, keep };
246
- }
247
146
 
248
147
  export class TaskLedger {
249
148
  private readonly claims = new Map<string, Claim>();
@@ -252,7 +151,11 @@ export class TaskLedger {
252
151
  private readonly hitCounts = { exact: 0, echo: 0, near: 0 };
253
152
  private rlmRuns = 0;
254
153
 
255
- /** Engine marks the active run's root prompt — the ancestor chain for echo detection. */
154
+ /** Engine marks the active run's root prompt — the ancestor chain for echo detection.
155
+ * v5 `begin_run` parity: the ONLY producer. `endRun` pops in the engine's finally, so a
156
+ * stack entry exists exactly while that engine is RUNNING — v5's `status in (pending,
157
+ * running)` filter is structural here. Native `repl()` cells never push ancestors: their
158
+ * spawns claim against an empty stack, so an originator can never echo against itself. */
256
159
  beginRun(rootPrompt: string): void {
257
160
  this.stack.push(normalizePrompt(rootPrompt));
258
161
  }
@@ -261,20 +164,6 @@ export class TaskLedger {
261
164
  this.stack.pop();
262
165
  }
263
166
 
264
- /** Native `repl()` path (audit R1): push task-shaped ancestors extracted from the cell. */
265
- beginNativeCell(code: string): number {
266
- const ancestors = nativeRunAncestors(code);
267
- for (let i = 0; i < ancestors.length; i++) {
268
- const a = ancestors[i];
269
- if (a !== undefined) this.beginRun(a);
270
- }
271
- return ancestors.length;
272
- }
273
-
274
- endNativeCell(n: number): void {
275
- for (let i = 0; i < n; i++) this.endRun();
276
- }
277
-
278
167
  /** A child prompt echoing any ancestor (exact or ≥ 0.8 Jaccard) is rejected as a stub. */
279
168
  detectEcho(prompt: string): boolean {
280
169
  const np = normalizePrompt(prompt);
@@ -410,11 +299,13 @@ export class TaskLedger {
410
299
  return Object.freeze({ ...this.hitCounts });
411
300
  }
412
301
 
413
- /** Compact table for the sandbox's `list_claims()` REPL call. */
302
+ /** Compact table for the sandbox's `list_claims()` REPL call. Echo hits are appended —
303
+ * a suppressed spawn must be visible from inside the session (audit BUG-1). */
414
304
  listClaims(): string {
415
- if (this.claims.size === 0) return "ledger: no claims";
305
+ const echoNote = this.hitCounts.echo > 0 ? ` (echo_rejected=${this.hitCounts.echo})` : "";
306
+ if (this.claims.size === 0) return `ledger: no claims${echoNote}`;
416
307
  const lines: string[] = new Array<string>(this.claims.size + 1);
417
- lines[0] = "ledger claims:";
308
+ lines[0] = `ledger claims:${echoNote}`;
418
309
  let n = 1;
419
310
  for (const c of this.claims.values()) {
420
311
  lines[n++] = ` ${c.key.slice(0, 8)} ${c.kind} ${c.status} depth=${c.depth} paths=${pathSig(c.paths) || "-"} '${c.prompt.slice(0, PROMPT_PREVIEW)}'`;
@@ -435,7 +326,8 @@ export class TaskLedger {
435
326
  if (inflight.length === 0 && done.length === 0 && stackN <= 1) return "";
436
327
  const lines: string[] = [
437
328
  "[ledger]",
438
- ` depth_stack=${stackN} inflight=${inflight.length} done=${done.length}`,
329
+ ` depth_stack=${stackN} inflight=${inflight.length} done=${done.length}` +
330
+ (this.hitCounts.echo > 0 ? ` echo_rejected=${this.hitCounts.echo}` : ""),
439
331
  " rlm_query only for a disjoint goal. ancestor echo is rejected.",
440
332
  ];
441
333
  if (inflight.length > 0) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Durable memory (port of rlm_test v5 `memory/store.py`).
2
+ * Durable memory (port of the v5 `memory/store.py` engine).
3
3
  *
4
4
  * L1 episodes: content-addressed replay — a recorded child/root answer replays for ZERO
5
5
  * API calls while every file it touched still hashes to the recorded sha256.
@@ -15,6 +15,7 @@ import { createHash } from "node:crypto";
15
15
  import { closeSync, openSync, readSync } from "node:fs";
16
16
  import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
17
17
  import { dirname, join, resolve, sep } from "node:path";
18
+ import { formatError } from "../util/errors.ts";
18
19
 
19
20
  const TOK = /[a-z0-9]{2,}/g;
20
21
  const EPISODE_CAP = 4_000;
@@ -123,6 +124,10 @@ export function rootContextPaths(context: unknown, max: number): readonly string
123
124
  return Object.freeze(out);
124
125
  }
125
126
 
127
+ /** Who is calling serviceOp — delegation children read durable notes but never write them
128
+ * (their findings persist via recordEpisode in the rlm_query handler, one per run). */
129
+ export type MemoryScope = "root" | "child";
130
+
126
131
  export class MemoryStore {
127
132
  readonly enabled: boolean;
128
133
  private dir: string | undefined;
@@ -462,9 +467,16 @@ export class MemoryStore {
462
467
  serviceOp(
463
468
  op: "query" | "add" | "stats",
464
469
  args: { readonly query?: string; readonly k?: number; readonly content?: string; readonly paths?: readonly string[]; readonly tags?: readonly string[] },
470
+ scope: MemoryScope = "root",
465
471
  ): string {
466
472
  if (!this.enabled) return "memory disabled";
467
473
  if (op === "stats") return JSON.stringify(this.stats());
474
+ if (op === "add" && scope === "child") {
475
+ return formatError(
476
+ "memory.add is root-only — query durable notes with memory.query; " +
477
+ "your final answer is recorded as an episode automatically",
478
+ );
479
+ }
468
480
  if (op === "add") {
469
481
  const n = this.addNote({ content: args.content ?? "", paths: args.paths ?? [], tags: args.tags ?? [] });
470
482
  return n === undefined ? "add skipped (empty content)" : `ok note ${n.id}`;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Model context-window registry (port of rlm_test v4/v5 `models.py`).
2
+ * Model context-window registry (port of the v4/v5 `models.py` engine).
3
3
  *
4
4
  * The plugin already knows context windows from model metadata (`Model.contextWindow`); this
5
5
  * registry is the offline fallback for models whose metadata carries none: a conservative
package/src/core/types.ts CHANGED
@@ -25,6 +25,20 @@ export interface RlmConfig {
25
25
  /** Concurrent recursive child engines admitted per depth. Lower than maxConcurrentSubcalls:
26
26
  * each child is a Python subprocess holding its own copy of the inherited context. */
27
27
  readonly maxConcurrentChildren: number;
28
+ /** v5.1 rate-limit resilience (see util/retry.ts): transient 429/5xx are retried with
29
+ * backoff, and rate limits additionally cool a shared per-provider throttle.
30
+ * retryMaxAttempts counts TOTAL attempts per call (1 = never retry).
31
+ * rateLimitMaxAttempts is the SEPARATE budget a 429 may burn while parking on the
32
+ * cooldown — generous, because "come back later" is a queue, not a failure. */
33
+ readonly retryMaxAttempts?: number;
34
+ readonly rateLimitMaxAttempts?: number;
35
+ readonly retryBaseDelayMs?: number;
36
+ /** Cap for any single retry delay, including a parsed `retry-after`. */
37
+ readonly retryMaxDelayMs?: number;
38
+ /** First cooldown when a provider 429s without timing; doubles per consecutive strike. */
39
+ readonly throttleBaseMs?: number;
40
+ /** Ceiling for the adaptive per-provider cooldown. */
41
+ readonly throttleMaxMs?: number;
28
42
  /** Reject sub-LLM prompts larger than this many chars. */
29
43
  readonly maxPromptChars: number;
30
44
  /** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */