@hicaru/pi-rlm 0.3.6 → 0.3.9

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 (63) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/emitting.ts +5 -23
  4. package/src/bridge/handlers/index.ts +1 -1
  5. package/src/bridge/handlers/llm-query.ts +84 -29
  6. package/src/bridge/handlers/rlm-query.ts +133 -33
  7. package/src/bridge/handlers/types.ts +12 -0
  8. package/src/commands/pins.ts +51 -0
  9. package/src/commands/rlm-config.ts +4 -88
  10. package/src/commands/rlm-llm.ts +59 -0
  11. package/src/commands/rlm-rlm.ts +58 -0
  12. package/src/commands/rlm.ts +2 -2
  13. package/src/config/defaults.ts +17 -0
  14. package/src/config/settings.ts +58 -5
  15. package/src/core/answer.ts +7 -10
  16. package/src/core/budget.ts +182 -0
  17. package/src/core/compaction.ts +46 -0
  18. package/src/core/engine.ts +185 -5
  19. package/src/core/iteration.ts +5 -0
  20. package/src/core/ledger.ts +343 -0
  21. package/src/core/memory.ts +589 -0
  22. package/src/core/model-registry.ts +88 -0
  23. package/src/core/types.ts +44 -3
  24. package/src/index.ts +107 -12
  25. package/src/mode/rlm-mode.ts +58 -10
  26. package/src/prompts/glossary.ts +147 -57
  27. package/src/prompts/native.ts +12 -7
  28. package/src/prompts/system.ts +22 -7
  29. package/src/prompts/user.ts +6 -3
  30. package/src/sandbox/interrupts.ts +24 -0
  31. package/src/sandbox/protocol.ts +69 -5
  32. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  34. package/src/sandbox/py/guards.py +11 -6
  35. package/src/sandbox/py/scaffold.py +615 -0
  36. package/src/sandbox/py/worker.py +53 -506
  37. package/src/sandbox/sandbox.ts +21 -3
  38. package/src/text/repl-output.ts +15 -0
  39. package/src/tool/repl-render.ts +4 -10
  40. package/src/tool/repl-result.ts +54 -10
  41. package/src/tool/repl-tool.ts +50 -3
  42. package/src/tool/rlm-aggregator.ts +16 -3
  43. package/src/tool/rlm-details.ts +7 -0
  44. package/src/tool/rlm-events.ts +17 -1
  45. package/src/tool/rlm-tool.ts +25 -14
  46. package/src/tool/subcall-render.ts +14 -129
  47. package/src/tool/subcall-store.ts +11 -1
  48. package/src/ui/intro.ts +13 -4
  49. package/src/ui/modal/agent-modal.ts +104 -0
  50. package/src/ui/modal/modal-view.ts +132 -0
  51. package/src/ui/modal/timeline-store.ts +85 -0
  52. package/src/ui/model-picker/drilldown.ts +173 -0
  53. package/src/ui/model-picker/grouping.ts +81 -0
  54. package/src/ui/model-picker/levels.ts +63 -0
  55. package/src/ui/model-picker.ts +7 -197
  56. package/src/ui/panel/run-registry.ts +135 -0
  57. package/src/ui/panel/tree-panel.ts +46 -0
  58. package/src/ui/status.ts +26 -10
  59. package/src/ui/theme.ts +0 -4
  60. package/src/ui/tree/tree-model.ts +221 -0
  61. package/src/ui/tree/tree-rows.ts +73 -0
  62. package/src/ui/tree/tree-widget.ts +186 -0
  63. package/src/util/concurrency.ts +47 -0
@@ -0,0 +1,59 @@
1
+ /** `/rlm-llm` — pin the leaf-LLM model (llm_query / llm_batch / map_files). */
2
+
3
+ import type { Api, Model } from "@earendil-works/pi-ai";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { modelRef } from "../config/settings.ts";
6
+ import { cheapestModel } from "../mode/llm-model.ts";
7
+ import type { RlmController } from "../mode/rlm-mode.ts";
8
+ import { pickableModels, selectModel } from "../ui/model-picker.ts";
9
+ import { setRlmModeStatus } from "../ui/status.ts";
10
+ import { applyLlmSelection } from "./pins.ts";
11
+
12
+ /** Newer Pi hosts expose session-scoped models; 0.79 peers do not — duck-type safely. */
13
+ function sessionScopedModels(
14
+ ctx: ExtensionContext,
15
+ ): readonly { readonly model: Model<Api> }[] | undefined {
16
+ const scoped: unknown = Reflect.get(ctx, "scopedModels");
17
+ return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
18
+ }
19
+
20
+ async function runRlmLlm(controller: RlmController, ctx: ExtensionContext): Promise<void> {
21
+ try {
22
+ await ctx.modelRegistry.refresh();
23
+ } catch {
24
+ // Fail-soft: show the cached available snapshot rather than aborting config.
25
+ }
26
+ const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
27
+ const llm = await selectModel(
28
+ ctx,
29
+ "llm",
30
+ models,
31
+ controller.llmModel,
32
+ controller.config.subSampling.reasoning,
33
+ controller.savedLlmRef,
34
+ );
35
+ applyLlmSelection(controller, llm);
36
+ const persisted = await controller.persist();
37
+ if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
38
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
39
+
40
+ // Name the model that actually resolved, not "(cheapest)" — otherwise there is no way to
41
+ // tell whether the free model in the catalog was the one picked.
42
+ const pinned = controller.llmModel;
43
+ const effective = pinned ?? cheapestModel(ctx.modelRegistry);
44
+ const reasoning = controller.config.subSampling.reasoning;
45
+ ctx.ui.notify(
46
+ `RLM: llm=${modelRef(effective) ?? "(none available)"}`
47
+ + `${pinned ? "" : " (cheapest, auto)"}${reasoning ? `/${reasoning}` : ""}`,
48
+ "info",
49
+ );
50
+ }
51
+
52
+ export function registerRlmLlmCommand(pi: ExtensionAPI, controller: RlmController): void {
53
+ pi.registerCommand("rlm-llm", {
54
+ description: "Pin the LLM model used by llm_query / llm_batch / map_files sub-calls.",
55
+ handler: async (_args, ctx) => {
56
+ await runRlmLlm(controller, ctx);
57
+ },
58
+ });
59
+ }
@@ -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
  });
@@ -31,4 +31,21 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
31
31
  rootSampling: Object.freeze({ maxTokens: 16_384 }),
32
32
  subSystemPrompt: DEFAULT_SUB_SYSTEM_PROMPT,
33
33
  subSampling: Object.freeze({ maxTokens: 8192 }),
34
+ // v5 token budget cascade — the primary run-length control (wall-clock stays a hang backstop).
35
+ enableTokenBudget: true,
36
+ budgetShare: 0.25,
37
+ budgetSoftFrac: 0.8,
38
+ budgetTaskCap: 400_000,
39
+ budgetMaxContinuations: 2,
40
+ budgetHandoffChars: 4_000,
41
+ // v5 TaskLedger blackboard
42
+ enableLedger: true,
43
+ rlmBudget: 8,
44
+ // v5 durable memory
45
+ enableMemory: true,
46
+ injectNoteTokens: 2_000,
47
+ evolveEvery: 8,
48
+ memoryDir: null,
49
+ // v5 role separation: children delegate (llm + memory/ledger); "legacy" = full child surface.
50
+ childSurface: "delegation",
34
51
  });
@@ -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] };
@@ -45,7 +48,9 @@ function validateThinkingLevel(v: unknown): ThinkingLevel | undefined {
45
48
  return typeof v === "string" && Object.hasOwn(THINKING_LEVELS, v) ? (v as ThinkingLevel) : undefined;
46
49
  }
47
50
 
48
- function validateConfig(raw: unknown): Partial<RlmConfig> {
51
+ /** Validate an unknown (e.g. hand-edited rlm.json) config blob into a partial — the single
52
+ * validation seam; exported for tests. */
53
+ export function validateConfig(raw: unknown): Partial<RlmConfig> {
49
54
  if (typeof raw !== "object" || raw === null) return {};
50
55
  const r = raw as Record<string, unknown>;
51
56
  const out: MutablePartialRlmConfig = {};
@@ -90,6 +95,47 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
90
95
  if (contextLoader !== undefined) out.contextLoader = contextLoader;
91
96
  const autoSeedCwd = validateBoolean(r.autoSeedCwd);
92
97
  if (autoSeedCwd !== undefined) out.autoSeedCwd = autoSeedCwd;
98
+ // v5 token budget cascade
99
+ const enableTokenBudget = validateBoolean(r.enableTokenBudget);
100
+ if (enableTokenBudget !== undefined) out.enableTokenBudget = enableTokenBudget;
101
+ const budgetShare = validateNumber(r.budgetShare, 0.01);
102
+ if (budgetShare !== undefined && budgetShare <= 1) out.budgetShare = budgetShare;
103
+ const budgetSoftFrac = validateNumber(r.budgetSoftFrac, 0.5);
104
+ if (budgetSoftFrac !== undefined && budgetSoftFrac < 1) out.budgetSoftFrac = budgetSoftFrac;
105
+ const budgetTaskCap = validateNumber(r.budgetTaskCap, 0);
106
+ if (budgetTaskCap !== undefined) out.budgetTaskCap = budgetTaskCap;
107
+ const budgetMaxContinuations = validateNumber(r.budgetMaxContinuations, 0);
108
+ if (budgetMaxContinuations !== undefined) out.budgetMaxContinuations = budgetMaxContinuations;
109
+ const budgetHandoffChars = validateNumber(r.budgetHandoffChars, 500);
110
+ if (budgetHandoffChars !== undefined) out.budgetHandoffChars = budgetHandoffChars;
111
+ // v5 TaskLedger blackboard
112
+ const enableLedger = validateBoolean(r.enableLedger);
113
+ if (enableLedger !== undefined) out.enableLedger = enableLedger;
114
+ const rlmBudget = validateNumber(r.rlmBudget, 0);
115
+ if (rlmBudget !== undefined) out.rlmBudget = rlmBudget;
116
+ // v5 durable memory
117
+ const enableMemory = validateBoolean(r.enableMemory);
118
+ if (enableMemory !== undefined) out.enableMemory = enableMemory;
119
+ const injectNoteTokens = validateNumber(r.injectNoteTokens, 100);
120
+ if (injectNoteTokens !== undefined) out.injectNoteTokens = injectNoteTokens;
121
+ const evolveEvery = validateNumber(r.evolveEvery, 0);
122
+ if (evolveEvery !== undefined) out.evolveEvery = evolveEvery;
123
+ if (r.memoryDir === null) out.memoryDir = null;
124
+ else {
125
+ const memoryDir = validateString(r.memoryDir);
126
+ if (memoryDir !== undefined) out.memoryDir = memoryDir;
127
+ }
128
+ // v5 provider concurrency caps: { provider: minConcurrent }
129
+ if (typeof r.providerMaxConcurrent === "object" && r.providerMaxConcurrent !== null) {
130
+ const caps: Record<string, number> = {};
131
+ for (const [provider, cap] of Object.entries(r.providerMaxConcurrent as Record<string, unknown>)) {
132
+ const n = validateNumber(cap, 1);
133
+ if (n !== undefined) caps[provider] = n;
134
+ }
135
+ if (Object.keys(caps).length > 0) out.providerMaxConcurrent = Object.freeze(caps);
136
+ }
137
+ // v5 child surface doctrine
138
+ if (r.childSurface === "delegation" || r.childSurface === "legacy") out.childSurface = r.childSurface;
93
139
  if (typeof r.subSampling === "object" && r.subSampling !== null) {
94
140
  const ss = r.subSampling as Record<string, unknown>;
95
141
  const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
@@ -99,7 +145,7 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
99
145
  if (temperature !== undefined) sampling.temperature = temperature;
100
146
  const ssReasoning = validateThinkingLevel(ss.reasoning);
101
147
  if (ssReasoning !== undefined) sampling.reasoning = ssReasoning;
102
- out.subSampling = sampling;
148
+ out.subSampling = Object.freeze(sampling);
103
149
  }
104
150
  if (typeof r.rootSampling === "object" && r.rootSampling !== null) {
105
151
  const rs = r.rootSampling as Record<string, unknown>;
@@ -124,6 +170,7 @@ export async function loadSettings(): Promise<PersistedSettings> {
124
170
  config: validateConfig(r.config),
125
171
  // `worker` is the pre-rename key — still read so an existing pin survives the upgrade.
126
172
  llm: validateString(r.llm) ?? validateString(r.worker),
173
+ rlm: validateString(r.rlm),
127
174
  };
128
175
  } catch {
129
176
  return { config: {} };
@@ -135,13 +182,19 @@ export async function saveSettings(s: PersistedSettings): Promise<boolean> {
135
182
  const p = settingsPath();
136
183
  await mkdir(dirname(p), { recursive: true });
137
184
  const body: Record<string, unknown> = { config: s.config };
185
+ const mergeDisk = s.llm === undefined || s.rlm === undefined;
186
+ const existing = mergeDisk ? await loadSettings() : undefined;
138
187
  if (s.llm !== undefined) {
139
188
  // Explicit: string → write pin, null → omit key (cheapest).
140
189
  if (s.llm !== null) body.llm = s.llm;
141
- } else {
190
+ } else if (existing?.llm) {
142
191
  // Merge: preserve existing disk pin so config-only saves never strip it.
143
- const existing = await loadSettings();
144
- if (existing.llm) body.llm = existing.llm;
192
+ body.llm = existing.llm;
193
+ }
194
+ if (s.rlm !== undefined) {
195
+ if (s.rlm !== null) body.rlm = s.rlm;
196
+ } else if (existing?.rlm) {
197
+ body.rlm = existing.rlm;
145
198
  }
146
199
  await writeFile(p, `${JSON.stringify(body, null, 2)}\n`);
147
200
  return true;
@@ -1,7 +1,7 @@
1
1
  /** Helpers for detecting and formatting the RLM final answer from a turn's REPL results. */
2
2
 
3
3
  import type { ReplResult } from "../sandbox/protocol.ts";
4
- import { truncateOutput } from "../text/parsing.ts";
4
+ import { formatReplStderr } from "../text/repl-output.ts";
5
5
 
6
6
  /** First non-null final answer across a turn's executed blocks, or null. */
7
7
  export function finalAnswerOf(results: readonly ReplResult[]): string | null {
@@ -29,9 +29,10 @@ export function turnHadError(results: readonly ReplResult[]): boolean {
29
29
  const SMALL_STDOUT_LIMIT = 800;
30
30
  const STDOUT_PREVIEW_LIMIT = 200;
31
31
  const STDOUT_TAIL_LIMIT = 200;
32
- const STDERR_LIMIT = 8_000;
33
32
 
34
- /** The REPL output fed back to the model as the next user message. */
33
+ /** The REPL output fed back to the model as the next user message. Prefixed `REPL stdout:`
34
+ * (v5 parity, audit C4): `distillTrajectory` keys on this needle to harvest the working
35
+ * set for a budget-capped continuation — without it a hard-cap chain starts blind. */
35
36
  export function formatReplOutputs(results: readonly ReplResult[], skippedBlocks = 0): string {
36
37
  if (results.length === 0) {
37
38
  return "No ```repl``` block found in your response. Write one to interact with the REPL.";
@@ -44,21 +45,21 @@ export function formatReplOutputs(results: readonly ReplResult[], skippedBlocks
44
45
  const head = multi ? `[block ${i + 1}]\n` : "";
45
46
  const { text, elided } = formatStdout(r);
46
47
  hadElision ||= elided;
47
- parts[i] = `${head}${text}${formatStderr(r)}`;
48
+ parts[i] = `${head}${text}${formatReplStderr(r.stderr)}`;
48
49
  }
49
50
  const body = parts.join("\n\n");
50
51
  const skipNote = skippedBlocks > 0
51
52
  ? `\n\n[${skippedBlocks} later \`\`\`repl\`\`\` block(s) skipped because an earlier block raised — fix and re-run them]`
52
53
  : "";
53
54
  // Orientation hint only when the model lost output to elision — otherwise it sees everything.
54
- if (!hadElision) return `${body}${skipNote}`;
55
+ if (!hadElision) return `REPL stdout:\n${body}${skipNote}`;
55
56
  // The REPL namespace is persistent across blocks in a turn, so the last block's varNames reflect
56
57
  // every variable created in any earlier block too.
57
58
  const varNames = results.at(-1)?.varNames ?? [];
58
59
  const hint = varNames.length > 0
59
60
  ? `REPL vars: ${varNames.join(", ")}`
60
61
  : `No REPL vars yet — assign results to variables before printing large outputs.`;
61
- return `${body}${skipNote}\n\n${hint}`;
62
+ return `REPL stdout:\n${body}${skipNote}\n\n${hint}`;
62
63
  }
63
64
 
64
65
  /** Stdout ≤ SMALL_STDOUT_LIMIT flows through verbatim; larger output keeps a short head + a note
@@ -78,7 +79,3 @@ function formatStdout(r: ReplResult): { text: string; elided: boolean } {
78
79
  };
79
80
  }
80
81
 
81
- function formatStderr(r: ReplResult): string {
82
- const err = r.stderr.trim();
83
- return err ? `\n[stderr]\n${truncateOutput(err, STDERR_LIMIT)}` : "";
84
- }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Token budget cascade (port of rlm_test v4/v5 `budget.py`).
3
+ *
4
+ * The budget is the PRIMARY run-length control: cap = budgetShare × model context window,
5
+ * one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
6
+ * handoff (`distillTrajectory`) is handed to a fresh continuation run — chain-capped at
7
+ * `maxContinuations`. Wall-clock timeouts stay only as hang backstops.
8
+ *
9
+ * v5 counts the whole tree (root turns + sub-LLM usage) against the cap; the engine feeds
10
+ * the run's LimitGuard totals in via `observeTotal` after every turn. Each continuation
11
+ * starts a fresh spend window (v5's offset-anchoring) — the chain total is bounded by
12
+ * `cap × (1 + maxContinuations)`, never by re-charging prior work.
13
+ */
14
+
15
+ import type { ChatMsg } from "../bridge/model.ts";
16
+ import type { RlmConfig } from "./types.ts";
17
+
18
+ export interface TokenBudgetOptions {
19
+ readonly softFrac?: number;
20
+ readonly continuations?: number;
21
+ readonly maxContinuations?: number;
22
+ }
23
+
24
+ export type BudgetState = "" | "soft" | "hard";
25
+
26
+ /** v5 verbatim: the soft wrap-up note prepended to the single turn after crossing soft. */
27
+ export const WRAP_UP_BUDGET: string = Object.freeze(
28
+ "[budget] ~80% of your token cap — ONE turn left. If the task is answerable NOW, finalize " +
29
+ '(set answer["ready"] = True). Otherwise print a compact findings dump: what is confirmed, ' +
30
+ "current file/line or search position, and the exact next step — a fresh continuation picks " +
31
+ "it up. Do not start new exploration.",
32
+ );
33
+
34
+ export const DEFAULT_NEXT_STEP: string =
35
+ Object.freeze("continue the probing that was in flight, then finalize");
36
+
37
+ /** v5 verbatim template (adapting the finalize spelling to this plugin's REPL). */
38
+ const HANDOFF_TEMPLATE: string = Object.freeze(
39
+ "A prior RLM run hit its token cap mid-task.\n" +
40
+ "You are its continuation — pick up EXACTLY where it stopped.\n\n" +
41
+ "ORIGINAL TASK:\n{query}\n\n" +
42
+ "CONFIRMED FINDINGS SO FAR:\n{findings}\n\n" +
43
+ "CURRENT STATE / LAST ACTIONS:\n{state}\n\n" +
44
+ "NEXT STEP: {next}\n" +
45
+ "Do not re-do confirmed work; continue from the NEXT STEP and finalize as\n" +
46
+ 'soon as the task is answerable (answer["ready"] = True).',
47
+ );
48
+
49
+ /** v5's elision marker, used whenever a handoff section is trimmed. */
50
+ const ELISION_MARK = "\n…(+N chars elided [v5 handoff])…\n";
51
+
52
+ export class TokenBudget {
53
+ readonly cap: number;
54
+ readonly softFrac: number;
55
+ readonly continuations: number;
56
+ readonly maxContinuations: number;
57
+ private spent = 0;
58
+
59
+ constructor(cap: number, opts: TokenBudgetOptions = {}) {
60
+ this.cap = Math.max(1, Math.floor(cap));
61
+ this.softFrac = opts.softFrac ?? 0.8;
62
+ this.continuations = opts.continuations ?? 0;
63
+ this.maxContinuations = opts.maxContinuations ?? 2;
64
+ }
65
+
66
+ get soft(): number {
67
+ return Math.floor(this.cap * this.softFrac);
68
+ }
69
+
70
+ get hard(): number {
71
+ return this.cap;
72
+ }
73
+
74
+ /** Tokens charged to this run so far (root + sub-LLM, whole tree). */
75
+ get tokensSpent(): number {
76
+ return this.spent;
77
+ }
78
+
79
+ /**
80
+ * Feed the run's cumulative token totals (LimitGuard::usage()) after each turn.
81
+ * Absolute, not incremental: one budget instance observes exactly one run, which is
82
+ * what makes a continuation's fresh instance start from zero (v5 offset anchoring).
83
+ */
84
+ observeTotal(inputTokens: number, outputTokens: number): void {
85
+ this.spent = Math.max(0, inputTokens) + Math.max(0, outputTokens);
86
+ }
87
+
88
+ state(): BudgetState {
89
+ if (this.cap <= 0) return "";
90
+ if (this.spent >= this.hard) return "hard";
91
+ if (this.spent >= this.soft) return "soft";
92
+ return "";
93
+ }
94
+
95
+ canContinue(): boolean {
96
+ return this.continuations < this.maxContinuations;
97
+ }
98
+
99
+ /** Fresh spend window, one step deeper in the chain. */
100
+ nextContinuation(): TokenBudget {
101
+ return new TokenBudget(this.cap, {
102
+ softFrac: this.softFrac,
103
+ continuations: this.continuations + 1,
104
+ maxContinuations: this.maxContinuations,
105
+ });
106
+ }
107
+ }
108
+
109
+ /** Cap derivation (v5 `resolve_budget`): share × context window, clamped by the task cap. */
110
+ export function resolveBudget(contextWindow: number | undefined, config: RlmConfig): TokenBudget {
111
+ const ctx = contextWindow !== undefined && contextWindow > 0 ? contextWindow : 32_000;
112
+ const shareCap = Math.floor(ctx * config.budgetShare);
113
+ const cap = config.budgetTaskCap > 0 ? Math.min(shareCap, config.budgetTaskCap) : shareCap;
114
+ return new TokenBudget(Math.max(cap, 1), {
115
+ softFrac: config.budgetSoftFrac,
116
+ maxContinuations: config.budgetMaxContinuations,
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Truncate at the midpoint so both the head and the tail of the content survive
122
+ * (v5 semantics: keep the opening context and the most recent actions).
123
+ */
124
+ export function truncateMid(text: string, maxChars: number): string {
125
+ if (text.length <= maxChars) return text;
126
+ const half = Math.max(0, maxChars - ELISION_MARK.length) >> 1;
127
+ const elided = text.length - (half * 2);
128
+ return text.slice(0, half) + ELISION_MARK.replace("N", String(elided)) + text.slice(text.length - half);
129
+ }
130
+
131
+ const QUERY_CHARS = 800;
132
+ const FINDINGS_MAX = 6;
133
+ const FINDINGS_MIN_CHARS = 20;
134
+ const STATE_MAX = 8;
135
+ const STATE_NEEDLE = "REPL stdout";
136
+ const NEXT_STEP_RE = /next|then|will |todo/i;
137
+
138
+ /**
139
+ * Deterministic trajectory → handoff (v5 `distill_trajectory`). No LLM call: the model was
140
+ * just told (soft wrap-up) to print a findings dump, and this harvests it — query, the last
141
+ * substantive assistant findings, the last REPL states, and the next step.
142
+ */
143
+ export function distillTrajectory(
144
+ history: readonly ChatMsg[],
145
+ query: string,
146
+ handoffChars = 4_000,
147
+ ): string {
148
+ const findings: string[] = [];
149
+ for (let i = history.length - 1; i >= 0 && findings.length < FINDINGS_MAX; i--) {
150
+ const m = history[i];
151
+ if (m.role === "assistant" && m.content.trim().length > FINDINGS_MIN_CHARS) {
152
+ findings.push(m.content.trim());
153
+ }
154
+ }
155
+ // v5 parity (audit C4): the next-step hint scans NEWEST-first; the join below is chronological.
156
+ const next = findings.find((f) => NEXT_STEP_RE.test(f)) ?? DEFAULT_NEXT_STEP;
157
+ findings.reverse();
158
+ const states: string[] = [];
159
+ for (let i = history.length - 1; i >= 0 && states.length < STATE_MAX; i--) {
160
+ const m = history[i];
161
+ if (m.role === "user" && m.content.includes(STATE_NEEDLE)) {
162
+ states.push(m.content.trim());
163
+ }
164
+ }
165
+ states.reverse();
166
+
167
+ const querySlice = query.slice(0, QUERY_CHARS);
168
+ const queryBlock = truncateMid(querySlice, Math.floor(handoffChars * 0.3));
169
+ const findingsBlock = truncateMid(findings.join("\n\n"), Math.floor(handoffChars * 0.35));
170
+ const stateBlock = truncateMid(states.join("\n\n"), Math.floor(handoffChars * 0.35));
171
+
172
+ return HANDOFF_TEMPLATE
173
+ .replace("{query}", queryBlock)
174
+ .replace("{findings}", findingsBlock)
175
+ .replace("{state}", stateBlock)
176
+ .replace("{next}", next);
177
+ }
178
+
179
+ /** The full continuation prompt: `[continuation n]` header + distilled handoff. */
180
+ export function continuationPrompt(n: number, handoff: string): string {
181
+ return `[continuation ${n}]\n${handoff}`;
182
+ }
@@ -33,6 +33,52 @@ export function shouldCompact(history: ChatMsg[], deps: CompactionDeps): boolean
33
33
  return estimateMessageTokens(history) >= threshold;
34
34
  }
35
35
 
36
+ /**
37
+ * v5 G1: elide old tool/repl payload bodies, keep the head (system) and the working-set tail
38
+ * intact. Runs BEFORE `shouldCompact` — v3 measured −97% tokens on coding tasks with this
39
+ * alone, often avoiding the summarizer entirely. Head-ONLY elision was a measured v3 bug
40
+ * (turns grew 3→8): the tail carries the current working set, so the last `keepTurns` turns
41
+ * are never touched.
42
+ */
43
+ export function elideOldToolPayloads(
44
+ history: ChatMsg[],
45
+ keepTurns = 2,
46
+ toolChars = 1_500,
47
+ ): ChatMsg[] {
48
+ if (history.length === 0) return history;
49
+ // Find the assistant message that starts the keepTurns-th-from-last turn; everything from
50
+ // there on is the protected tail.
51
+ let tailStart = 0;
52
+ let seen = 0;
53
+ for (let i = history.length - 1; i >= 0; i--) {
54
+ if (history[i].role === "assistant") {
55
+ seen++;
56
+ if (seen >= keepTurns) {
57
+ tailStart = i;
58
+ break;
59
+ }
60
+ }
61
+ }
62
+ if (tailStart === 0) return history; // fewer turns than keepTurns — nothing to elide
63
+ let changed = false;
64
+ const marker = "\n…[elided v5-G1]…";
65
+ const out: ChatMsg[] = new Array<ChatMsg>(history.length); // pre-allocated
66
+ for (let i = 0; i < history.length; i++) {
67
+ const m = history[i];
68
+ if (
69
+ i < tailStart &&
70
+ m.role === "user" &&
71
+ m.content.length > toolChars
72
+ ) {
73
+ out[i] = { role: "user", content: m.content.slice(0, toolChars) + marker };
74
+ changed = true;
75
+ } else {
76
+ out[i] = m;
77
+ }
78
+ }
79
+ return changed ? out : history;
80
+ }
81
+
36
82
  /**
37
83
  * Summarize the trajectory and return a compacted history: [system, summary(assistant),
38
84
  * continue(user)]. The caller continues appending turns from there.