@hicaru/pi-rlm 0.2.1 → 0.2.2

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 (61) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  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 +6 -17
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +55 -335
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +23 -12
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -407
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +8 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/{worker.py → py/worker.py} +76 -696
  30. package/src/sandbox/sandbox-manager.ts +13 -0
  31. package/src/sandbox/sandbox.ts +99 -193
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/repl-details.ts +2 -2
  34. package/src/tool/repl-render.ts +58 -0
  35. package/src/tool/repl-result.ts +70 -0
  36. package/src/tool/repl-tool.ts +37 -159
  37. package/src/tool/rlm-aggregator.ts +2 -10
  38. package/src/tool/rlm-details.ts +0 -2
  39. package/src/tool/rlm-events.ts +0 -14
  40. package/src/tool/rlm-tool.ts +1 -12
  41. package/src/ui/config-panel.ts +4 -16
  42. package/src/ui/intro.ts +1 -2
  43. package/src/ui/model-picker.ts +34 -10
  44. package/src/ui/status.ts +3 -7
  45. package/src/util/concurrency.ts +9 -5
  46. package/src/bridge/fallback-todo.ts +0 -148
  47. package/src/bridge/interactive.ts +0 -65
  48. package/src/bridge/pi-interactive.ts +0 -41
  49. package/src/core/artifacts.ts +0 -89
  50. package/src/core/critique.ts +0 -92
  51. package/src/core/gates.ts +0 -301
  52. package/src/core/pipeline-handlers.ts +0 -319
  53. package/src/core/pipeline.ts +0 -268
  54. package/src/prompts/phases.ts +0 -104
  55. package/src/state/index.ts +0 -24
  56. package/src/state/internal.ts +0 -46
  57. package/src/state/paths.ts +0 -44
  58. package/src/state/reads.ts +0 -133
  59. package/src/state/resume.ts +0 -173
  60. package/src/state/rows.ts +0 -123
  61. package/src/state/writes.ts +0 -58
package/src/core/types.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  /** Shared configuration + runtime types for the RLM engine. */
2
2
 
3
3
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
4
- import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
5
- import type { ReconstructResult } from "../state/resume.ts";
6
4
 
7
5
  export interface Sampling {
8
6
  readonly maxTokens?: number;
@@ -10,17 +8,6 @@ export interface Sampling {
10
8
  readonly reasoning?: ThinkingLevel;
11
9
  }
12
10
 
13
- export interface RunLogConfig {
14
- /** Default: true — always-on, opt-out. */
15
- readonly enabled?: boolean;
16
- /** Default: ".rlm/runs". Directory under cwd for run artifacts. */
17
- readonly dir?: string;
18
- /** Default: true — whether to write sandbox.pkl snapshots. */
19
- readonly snapshot?: boolean;
20
- /** Default: 50 — prune oldest runs beyond this count on each new run. */
21
- readonly maxRuns?: number;
22
- }
23
-
24
11
  export interface RlmConfig {
25
12
  /** Persistent editor-routing mode; when enabled, plain interactive prompts use RLM. */
26
13
  readonly enabled: boolean;
@@ -34,10 +21,11 @@ export interface RlmConfig {
34
21
  readonly requestTimeoutMs: number;
35
22
  /** Concurrency pool for *_batched sub-calls. */
36
23
  readonly maxConcurrentSubcalls: number;
24
+ /** Concurrent recursive child engines admitted per depth. Lower than maxConcurrentSubcalls:
25
+ * each child is a Python subprocess holding its own copy of the inherited context. */
26
+ readonly maxConcurrentChildren: number;
37
27
  /** Reject sub-LLM prompts larger than this many chars. */
38
28
  readonly maxPromptChars: number;
39
- /** Max USD spend across the whole tree before the engine stops (undefined = no cap). */
40
- readonly maxBudgetUsd?: number;
41
29
  /** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
42
30
  readonly maxTimeoutMs?: number;
43
31
  /** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap). */
@@ -46,10 +34,6 @@ export interface RlmConfig {
46
34
  readonly maxErrors?: number;
47
35
  /** Append the orchestrator addendum to the system prompt. */
48
36
  readonly orchestrator: boolean;
49
- /** Enable the phase pipeline (advance_phase + stall nags) at depth 0. */
50
- readonly pipeline: boolean;
51
- /** Max validate→blueprint corrective re-entries when validation reports blockers (default 2). */
52
- readonly maxBackwardJumps: number;
53
37
  /** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
54
38
  readonly compaction: boolean;
55
39
  /** Compact when estimated history tokens reach this fraction of the model's context window. */
@@ -58,10 +42,6 @@ export interface RlmConfig {
58
42
  readonly python: string;
59
43
  /** Worker startup wait before treating sandbox init as failed (ms). */
60
44
  readonly sandboxInitTimeoutMs: number;
61
- /** Allow ask_user_question() calls from the root REPL. */
62
- readonly askUserQuestion: boolean;
63
- /** Allow todo() calls from the REPL. */
64
- readonly todo: boolean;
65
45
  /** Enable the load_library() REPL scaffold (external dirs/files/git repos as extra context slots). */
66
46
  readonly libraryLoader: boolean;
67
47
  /** ThinkingLevel for the root smart model (set via /rlm-config). */
@@ -76,8 +56,6 @@ export interface RlmConfig {
76
56
  readonly subSystemPrompt?: string;
77
57
  /** Sampling for sub-LLM (worker) calls. */
78
58
  readonly subSampling: Readonly<Sampling>;
79
- /** Optional run-state persistence configuration. Enabled by default. */
80
- readonly runLog?: RunLogConfig;
81
59
  }
82
60
 
83
61
  /** Input to a (headless) RLM run. */
@@ -92,12 +70,8 @@ export interface RlmInput {
92
70
  readonly parentNodeId?: string;
93
71
  /** "provider/id" — overrides the root model for this run (set by recursive rlm_query). */
94
72
  readonly modelOverride?: string;
95
- /** Remaining budget for this subtree (set by parent from its LimitGuard). */
96
- readonly remainingBudgetUsd?: number;
97
73
  /** Remaining timeout for this subtree (set by parent from its LimitGuard). */
98
74
  readonly remainingTimeoutMs?: number;
99
- /** Depth-0 resume payload — controller rebuilds this from the trail's `reconstructRlmState()`. */
100
- readonly resume?: ReconstructResult & { readonly ok: true };
101
75
  }
102
76
 
103
77
  /** Result of a completed RLM run. */
@@ -111,11 +85,4 @@ export interface RlmResult {
111
85
  }
112
86
 
113
87
  /** A function that runs an RLM to completion — used to wire recursion (rlm_query). */
114
- export interface InteractiveDeps {
115
- /** Called when the sandbox issues ask_user_question; undefined = feature disabled. */
116
- readonly onAskUserQuestion?: (questions: readonly AskQuestion[]) => Promise<AskAnswer[]>;
117
- /** Called when the sandbox issues todo; undefined = feature disabled. */
118
- readonly onTodo?: (action: string, params: Record<string, unknown>) => Promise<string>;
119
- }
120
-
121
88
  export type RunRlm = (input: RlmInput) => Promise<RlmResult>;
package/src/index.ts CHANGED
@@ -7,7 +7,8 @@ import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
7
7
  import { createRlmTool } from "./tool/rlm-tool.ts";
8
8
  import { createReplTool } from "./tool/repl-tool.ts";
9
9
  import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
10
- import { RlmController, cheapestModel } from "./mode/rlm-mode.ts";
10
+ import { RlmController } from "./mode/rlm-mode.ts";
11
+ import { cheapestModel } from "./mode/llm-model.ts";
11
12
  import { postRlmGuide } from "./ui/intro.ts";
12
13
  import { setRlmModeStatus } from "./ui/status.ts";
13
14
  import { markdownTheme } from "./ui/theme-adapter.ts";
@@ -15,7 +16,7 @@ import { SandboxManager } from "./sandbox/sandbox-manager.ts";
15
16
  import { createSubcallGates } from "./util/concurrency.ts";
16
17
  import { BackgroundTasks } from "./tool/background-tasks.ts";
17
18
  import { packRepository, formatForLLM, serializeForSandbox } from "./context/repomix-context.ts";
18
- import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/system.ts";
19
+ import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/native.ts";
19
20
  import { bashCommandFromInput, isFileReadingCommand, capToolResultText, BASH_BLOCK_REASON } from "./mode/native-guards.ts";
20
21
  import { errorMessage } from "./util/errors.ts";
21
22
 
@@ -42,9 +43,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
42
43
  });
43
44
  // One admission gate for the whole session: spawn() lets the sandbox put many requests on
44
45
  // the wire at once, so nothing smaller than session scope actually bounds fan-out.
45
- const gates = createSubcallGates(config.maxConcurrentSubcalls);
46
+ const gates = createSubcallGates(config.maxConcurrentSubcalls, config.maxConcurrentChildren);
46
47
  const background = new BackgroundTasks({
47
- maxBudgetUsd: config.maxBudgetUsd,
48
48
  maxTimeoutMs: config.maxTimeoutMs,
49
49
  maxTokens: config.maxTokens,
50
50
  maxErrors: config.maxErrors,
@@ -79,7 +79,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
79
79
  const settingsReady = loadSettings()
80
80
  .then((persisted) => {
81
81
  controller.config = mergeConfig(persisted.config);
82
- controller.savedWorkerRef = persisted.worker;
82
+ controller.savedLlmRef = persisted.llm;
83
83
  })
84
84
  .catch((err) => {
85
85
  console.warn(`[rlm] settings load failed: ${errorMessage(err)}`);
@@ -120,22 +120,33 @@ export default function rlmExtension(pi: ExtensionAPI): void {
120
120
  const flag = pi.getFlag("rlm");
121
121
  if (typeof flag === "boolean") controller.setConfig(Object.freeze({ ...controller.config, enabled: flag }));
122
122
 
123
- if (controller.savedWorkerRef) {
124
- const resolved = resolveModelId(ctx.modelRegistry, controller.savedWorkerRef);
125
- if (resolved) controller.workerModel = resolved;
123
+ // Reload the catalog before the worker-model pick below reads it. Newer pi builds make
124
+ // `getAvailable()` an async-populated snapshot that starts empty, and picking from an empty
125
+ // catalog silently falls back to the root model. Called with no arguments and awaited so it
126
+ // is valid whether `refresh` returns void (current) or a promise (newer); fail-soft, because
127
+ // a refresh error must not abort session start.
128
+ try {
129
+ await ctx.modelRegistry.refresh();
130
+ } catch (err) {
131
+ console.warn(`[rlm] model registry refresh failed: ${errorMessage(err)}`);
132
+ }
133
+
134
+ if (controller.savedLlmRef) {
135
+ const resolved = resolveModelId(ctx.modelRegistry, controller.savedLlmRef);
136
+ if (resolved) controller.llmModel = resolved;
126
137
  }
127
138
 
128
139
  // Re-register repl tool each session to pick up model provider changes
129
- const workerModel = controller.workerModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
140
+ const llmModel = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
130
141
  const model = ctx.model;
131
- if (workerModel && model) {
142
+ if (llmModel && model) {
132
143
  try {
133
144
  pi.registerTool(createReplTool({
134
145
  sandboxManager,
135
146
  model,
136
- workerModel,
147
+ llmModel,
137
148
  getModel: () => controller.resolveModels(ctx)?.model,
138
- getWorkerModel: () => controller.resolveModels(ctx)?.worker,
149
+ getLlmModel: () => controller.resolveModels(ctx)?.llm,
139
150
  registry: ctx.modelRegistry,
140
151
  getConfig: () => controller.config,
141
152
  gates,
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Sub-LLM model ranking — "cheapest available", with free models winning outright.
3
+ *
4
+ * Pi's `ModelCost` is non-nullable (`packages/ai/src/types.ts`), so a free model is a literal 0,
5
+ * not a null. That makes plain price sorting ambiguous rather than wrong: subscription and
6
+ * token-plan providers also publish 0, and a stable sort would hand back whichever 0-cost entry
7
+ * happened to be first in catalog order. The tie-breaks below are what actually pick a usable
8
+ * free model — and what make the pick identical across sessions and catalog reorderings.
9
+ */
10
+
11
+ import type { Api, Model } from "@earendil-works/pi-ai";
12
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
13
+
14
+ /** $/Mtok, input-weighted 3:1 — a sub-call sends a file body and gets back a sentence. */
15
+ function priceOf(model: Model<Api>): number {
16
+ const { input, output, cacheRead } = model.cost;
17
+ return input * 3 + output + cacheRead;
18
+ }
19
+
20
+ /** True when the model costs nothing to call on any axis. */
21
+ export function isFreeModel(model: Model<Api>): boolean {
22
+ return priceOf(model) === 0;
23
+ }
24
+
25
+ /**
26
+ * Negative when `a` is the better sub-LLM.
27
+ *
28
+ * Window before maxTokens before id: a free model with a 4K context is useless for bulk reading,
29
+ * so price alone must not decide. The final id comparison exists only to make the result
30
+ * deterministic — without it the pick drifts whenever a provider reorders its catalog.
31
+ */
32
+ export function compareLlm(a: Model<Api>, b: Model<Api>): number {
33
+ return (priceOf(a) - priceOf(b))
34
+ || (b.contextWindow - a.contextWindow)
35
+ || (b.maxTokens - a.maxTokens)
36
+ || `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`);
37
+ }
38
+
39
+ /**
40
+ * Best sub-LLM among the models whose provider has configured auth.
41
+ *
42
+ * Single pass rather than `[...models].sort()[0]`: the copy and the sort both allocate for a
43
+ * result that is one element.
44
+ */
45
+ export function cheapestModel(registry: ModelRegistry): Model<Api> | undefined {
46
+ const models = registry.getAvailable();
47
+ let best: Model<Api> | undefined;
48
+ for (let i = 0; i < models.length; i++) {
49
+ const model = models[i];
50
+ if (model === undefined) continue;
51
+ if (best === undefined || compareLlm(model, best) < 0) best = model;
52
+ }
53
+ return best;
54
+ }
@@ -1,42 +1,35 @@
1
1
  /**
2
2
  * RlmController — holds RLM config + chosen models.
3
3
  *
4
- * The engine drives the root model turn-by-turn over ```repl``` blocks with full budget/token/
4
+ * The engine drives the root model turn-by-turn over ```repl``` blocks with full token/
5
5
  * timeout/error guards, compaction, and a finalize fallback. `start()` returns a RunHandle with
6
6
  * the completion promise.
7
7
  */
8
8
 
9
9
  import type { Api, Model } from "@earendil-works/pi-ai";
10
- import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
11
- import { DEFAULT_RUN_DIR } from "../config/defaults.ts";
10
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
11
  import { modelRef, resolveModelId, saveSettings } from "../config/settings.ts";
13
12
  import { createEngine } from "../core/engine.ts";
14
13
  import { limitsFromConfig } from "../core/limits.ts";
15
- import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
16
- import type { ReconstructResult } from "../state/resume.ts";
14
+ import type { RlmConfig, RlmResult } from "../core/types.ts";
17
15
  import { packRepository, serializeForSandbox } from "../context/repomix-context.ts";
18
16
  import { RlmEmitter } from "../tool/rlm-events.ts";
19
17
  import { formatError } from "../util/errors.ts";
20
-
21
- export function cheapestModel(registry: ModelRegistry): Model<Api> | undefined {
22
- const models = registry.getAvailable();
23
- if (models.length === 0) return undefined;
24
- return [...models].sort((a, b) => a.cost.input + a.cost.output - (b.cost.input + b.cost.output))[0];
25
- }
18
+ import { cheapestModel } from "./llm-model.ts";
26
19
 
27
20
  export interface RunHandle {
28
21
  readonly abort: () => void;
29
22
  readonly done: Promise<RlmResult>;
30
23
  }
31
24
 
32
- /** B5+SA: discriminated union removes non-null `!` assertions and the `context: ""` hack. */
33
- export type StartInput =
34
- | { readonly kind: "fresh"; readonly rootPrompt: string; readonly context: unknown }
35
- | { readonly kind: "resume"; readonly resume: ReconstructResult & { ok: true }; readonly context: unknown };
25
+ export interface StartInput {
26
+ readonly rootPrompt: string;
27
+ readonly context: unknown;
28
+ }
36
29
 
37
30
  export class RlmController {
38
- workerModel: Model<Api> | undefined;
39
- savedWorkerRef: string | undefined;
31
+ llmModel: Model<Api> | undefined;
32
+ savedLlmRef: string | undefined;
40
33
  private active: AbortController | null = null;
41
34
 
42
35
  constructor(public config: RlmConfig) {}
@@ -65,7 +58,7 @@ export class RlmController {
65
58
  async persist(): Promise<boolean> {
66
59
  return await saveSettings({
67
60
  config: this.config,
68
- worker: modelRef(this.workerModel) ?? this.savedWorkerRef,
61
+ llm: modelRef(this.llmModel) ?? this.savedLlmRef,
69
62
  });
70
63
  }
71
64
 
@@ -77,15 +70,15 @@ export class RlmController {
77
70
  this.active?.abort();
78
71
  }
79
72
 
80
- resolveModels(ctx: ExtensionContext): { model: Model<Api>; worker: Model<Api> } | undefined {
81
- if (!this.workerModel && this.savedWorkerRef) this.workerModel = resolveModelId(ctx.modelRegistry, this.savedWorkerRef);
73
+ resolveModels(ctx: ExtensionContext): { model: Model<Api>; llm: Model<Api> } | undefined {
74
+ if (!this.llmModel && this.savedLlmRef) this.llmModel = resolveModelId(ctx.modelRegistry, this.savedLlmRef);
82
75
  const model = ctx.model ?? cheapestModel(ctx.modelRegistry);
83
76
  if (!model) return undefined;
84
- const worker = this.workerModel ?? cheapestModel(ctx.modelRegistry) ?? model;
85
- return { model, worker };
77
+ const llm = this.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
78
+ return { model, llm };
86
79
  }
87
80
 
88
- start(ctx: ExtensionContext, input: StartInput, emitter?: RlmEmitter, interactive?: InteractiveDeps): RunHandle {
81
+ start(ctx: ExtensionContext, input: StartInput, emitter?: RlmEmitter): RunHandle {
89
82
  const models = this.resolveModels(ctx);
90
83
  if (!models) throw new Error("no model with configured auth is available");
91
84
  if (this.active) throw new Error("RLM run already in progress"); // QC: mutual-exclusion guard
@@ -93,50 +86,26 @@ export class RlmController {
93
86
  const abortController = new AbortController();
94
87
  this.active = abortController;
95
88
 
96
- const runState = this.config.runLog?.enabled !== false
97
- ? { cwd: ctx.cwd ?? process.cwd(), dir: this.config.runLog?.dir ?? DEFAULT_RUN_DIR, snapshot: this.config.runLog?.snapshot !== false }
98
- : undefined;
99
-
100
89
  const done = (async () => {
101
- let engineInput: RlmInput;
102
- if (input.kind === "fresh") {
103
- // Auto-pack empty/undefined context via repomix; pass explicit context through.
104
- let contextValue: unknown = input.context;
105
- if (contextValue === undefined || contextValue === "" || (typeof contextValue === "string" && contextValue.trim() === "")) {
106
- const cwd = ctx.cwd ?? process.cwd();
107
- const result = await packRepository(cwd, abortController.signal);
108
- if (result.ok) {
109
- contextValue = serializeForSandbox(result.value);
110
- } else {
111
- contextValue = formatError(`failed to pack repository — ${result.error}`);
112
- }
113
- }
114
- engineInput = {
115
- rootPrompt: input.rootPrompt,
116
- context: contextValue,
117
- depth: 0,
118
- };
119
- } else {
120
- engineInput = {
121
- rootPrompt: input.resume.header.rootPrompt,
122
- context: input.context, // B5: load the actual context from the sidecar, not ""
123
- depth: 0,
124
- resume: input.resume,
125
- };
90
+ // Auto-pack empty/undefined context via repomix; pass explicit context through.
91
+ let contextValue: unknown = input.context;
92
+ if (contextValue === undefined || (typeof contextValue === "string" && contextValue.trim() === "")) {
93
+ const cwd = ctx.cwd ?? process.cwd();
94
+ const result = await packRepository(cwd, abortController.signal);
95
+ contextValue = result.ok
96
+ ? serializeForSandbox(result.value)
97
+ : formatError(`failed to pack repository — ${result.error}`);
126
98
  }
127
99
  const engine = createEngine({
128
100
  model: models.model,
129
- workerModel: models.worker,
101
+ llmModel: models.llm,
130
102
  registry: ctx.modelRegistry,
131
103
  config: this.config,
132
104
  signal: abortController.signal,
133
105
  emitter: emitter ?? new RlmEmitter(),
134
- runState,
135
- onAskUserQuestion: interactive?.onAskUserQuestion,
136
- onTodo: interactive?.onTodo,
137
106
  limits: limitsFromConfig(this.config),
138
107
  });
139
- return await engine(engineInput);
108
+ return await engine({ rootPrompt: input.rootPrompt, context: contextValue, depth: 0 });
140
109
  })().finally(() => {
141
110
  if (this.active === abortController) this.active = null;
142
111
  });
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The REPL vocabulary both prompts are built from.
3
+ *
4
+ * Headless (fenced ```repl``` blocks) and native (`repl({code})`) describe the same sandbox, so
5
+ * every line either lives here once or has an explicit condensed native twin next to it — that
6
+ * pairing is the whole reason this module exists. Divergence here is a bug: the model is told
7
+ * about functions that do not exist, or not told about ones that do.
8
+ */
9
+
10
+ export type ContextKind = "files" | "text";
11
+
12
+ /** "str" (raw string context, e.g. rlm_query children) → text; everything else → files. */
13
+ export function contextKindOf(contextType: string): ContextKind {
14
+ return contextType === "str" ? "text" : "files";
15
+ }
16
+
17
+ export const DEFAULT_PROMPT_CAP = 400_000;
18
+
19
+ export function promptCapTokensK(maxPromptChars: number): number {
20
+ return Math.round(maxPromptChars / 4_000);
21
+ }
22
+
23
+ /**
24
+ * Deterministic retrieval over `context` (headless + native).
25
+ *
26
+ * The paper's trajectories retrieve with hand-written regex (App. E.1); frontier models do that
27
+ * well, small ones guess keywords badly, and the first decomposition disproportionately decides
28
+ * the outcome (§5, Fig. 4a). These cost no tokens and no sub-calls.
29
+ */
30
+ export const RETRIEVAL_GLOSSARY_LINES: readonly string[] = Object.freeze([
31
+ "- `search(query: str, k=10, path_glob=None)`: BM25 ranking over `context`. Returns",
32
+ " [{path, line, score, snippet}] — POINTERS, not bodies. **Start here.** It is free:",
33
+ " no sub-LLM call, no tokens. Use it before you guess at filenames or write regex.",
34
+ "- `grep_context(pattern, k=50, path_glob=None, before=0, after=0) -> dict`: regex over",
35
+ " `context`. Returns {hits: [{path, line, text}], counts: {path: n}, total, truncated} —",
36
+ " `counts` is complete even when `hits` is capped, so a wide pattern reports its shape",
37
+ " instead of flooding you. Use for exact lexical needles; use `search` for meaning.",
38
+ "- `outline(path) -> str`: definition/heading skeleton of one file with line numbers.",
39
+ " Orient in ~200 chars instead of printing 20K. Matches exact path, then suffix, then glob.",
40
+ ]);
41
+
42
+ /** One-line delegation helpers — orchestrating must be cheaper than solving. */
43
+ export const DELEGATION_GLOSSARY_LINES: readonly string[] = Object.freeze([
44
+ "- `map_files(files, prompt, model=None) -> dict[path, str]`: ask `prompt` of every file and",
45
+ " get back {path: answer}. Accepts context entries or paths, packs them into cap-sized",
46
+ " batched sub-calls, and splits oversized files automatically. **This is the default way to",
47
+ " read many files** — prefer it over hand-rolling a chunk loop.",
48
+ "- `llm_map_reduce(items, map_prompt, reduce_prompt, model=None) -> str`: map over items in",
49
+ " one batch, then reduce the partial answers with a single call. The paper's canonical",
50
+ " strategy (query per chunk → aggregate the buffers) as one call.",
51
+ ]);
52
+
53
+ /** Shared glossary entry for the chunked-query helper (headless + native). */
54
+ export const CHUNKED_GLOSSARY_LINES: readonly string[] = Object.freeze([
55
+ "- `llm_query_chunked(text: str, prompt: str, model=None) -> list[str]`: auto-splits `text` into",
56
+ " chunks that fit the sub-LLM prompt cap, fans them out concurrently (order preserved), and",
57
+ " returns one answer per chunk. Use it for ANY text too large for a single `llm_query` — a file",
58
+ " you open()ed, an oversized sub-result, or several concatenated context files.",
59
+ ]);
60
+
61
+ /** Non-blocking fan-out: spawn now, collect later (headless glossary). */
62
+ export const SPAWN_GLOSSARY_LINES: readonly string[] = Object.freeze([
63
+ "- `spawn(fn, *args) -> Task`: start `llm_query`, `llm_query_batched`, `llm_query_chunked`,",
64
+ " `map_files`, `rlm_query` or `rlm_query_batched` WITHOUT waiting. Returns immediately.",
65
+ " (Not `llm_map_reduce` — its reduce step depends on its own map results.)",
66
+ "- `rlm_await(task)` / `rlm_await_all(tasks) -> list`: collect results; order matches input.",
67
+ " Tasks survive across turns, so spawn the slow work first, keep doing useful things, and",
68
+ " await only when you actually need the results. `task.done` tells you if it has landed.",
69
+ "",
70
+ " ```python",
71
+ " # start the slow sub-agents, then keep working while they run",
72
+ " tasks = [spawn(rlm_query, f\"Audit {area} end to end\") for area in areas]",
73
+ " hits = [f for f in context if \"TODO\" in f[\"content\"]] # overlaps with the sub-agents",
74
+ " reports = rlm_await_all(tasks)",
75
+ " ```",
76
+ ]);
77
+
78
+ /**
79
+ * What a parent must know about the child it is about to spawn. Without this the model writes
80
+ * referential prompts ("read lib/x/src/…") on the assumption the child can go fetch them, which
81
+ * is what made a missing child context degrade silently instead of failing (issue #4).
82
+ */
83
+ export const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
84
+ "",
85
+ " **What a child sees:** it inherits YOUR `context` — the repository plus every library you",
86
+ " loaded (`lib/<id>/…`) — and runs `search` / `grep_context` / `outline` / `map_files` over the",
87
+ " same paths. So send instructions, never file bodies: pasting content you already share costs",
88
+ " your tokens twice and buys nothing. Your prompt becomes the child's question.",
89
+ " Narrow its world with `rlm_query(prompt, paths=['src/auth/', 'lib/x-9f3a/'])` — path PREFIXES,",
90
+ " not globs. Omit `paths` to hand over everything.",
91
+ " Inheritance is one-way: libraries the child loads, and its whole REPL, die with it — only its",
92
+ " final answer string returns.",
93
+ " At the depth cap `rlm_query` degrades to a plain sub-LLM call with NO context, which is why",
94
+ " this section disappears at the last recursive depth.",
95
+ ]);
96
+
97
+ /**
98
+ * Sub-RLM orientation. Emitted only at depth > 0, where `context` is the parent's world rather
99
+ * than a repository the run packed for itself.
100
+ */
101
+ export const CHILD_CONTEXT_LINES: readonly string[] = Object.freeze([
102
+ " You are a sub-RLM. This `context` is your parent's world — the repository plus every library",
103
+ " it loaded (paths under `lib/<id>/…`). Answer only the question above; your REPL and anything",
104
+ " you load die with you, and only your final answer string returns to the parent.",
105
+ ]);
106
+
107
+ /** Why a file the user mentioned may be missing from `context`. */
108
+ export const CONTEXT_EXCLUSION_NOTE =
109
+ " NOTE: files larger than 1MB and gitignored files are NOT in `context` — they exist only on disk.";
110
+
111
+ /** The large-on-disk-file protocol (headless + native). */
112
+ export const LARGE_FILE_RULE_LINES: readonly string[] = Object.freeze([
113
+ "**Large on-disk files (profiles, logs, dumps, generated JSON):** files >1MB or gitignored are",
114
+ "absent from `context`. Protocol:",
115
+ '1. Load in Python: `raw = open("dhat-heap.json").read()` — loading into a variable is fine.',
116
+ "2. Deterministic processing in Python (`json.load`, `re`, counting, aggregation) is fine and preferred.",
117
+ "3. The moment you need MEANING from raw text (summarize, explain, find anomalies), do NOT read it",
118
+ " yourself — call `llm_query_chunked(raw, question)`, or slice + `llm_query_batched`.",
119
+ "4. Never print more than a small probe (~2K chars) of raw content.",
120
+ 'Example: `parts = llm_query_chunked(raw, "Extract top allocation sites with byte totals")`, then',
121
+ "aggregate `parts` in Python or with one final `llm_query`.",
122
+ ]);
123
+
124
+ /** Concise native-mode glossary line for the chunked helper (native prompt has a 6K budget). */
125
+ export const CHUNKED_GLOSSARY_LINE_NATIVE =
126
+ "- `llm_query_chunked(text, prompt, model=None) -> list[str]` — auto-splits oversized text into cap-sized chunks, fans out concurrently; one answer per chunk.";
127
+
128
+ /** Concise native-mode large-file rule (folds in the context-exclusion note; native 6K budget). */
129
+ export const LARGE_FILE_RULE_NATIVE =
130
+ "- Files >1MB or gitignored are NOT in `context`: open() + parse deterministically in Python is fine; ANY semantic reading of the raw text goes through llm_query_chunked. Never print >2K chars raw.";
131
+
132
+ /**
133
+ * The decomposition doctrine, ported from the RLM paper's Appendix C.3 `<env_tips>` and
134
+ * retargeted from competition math to repository analysis.
135
+ *
136
+ * This block is the single highest-leverage prompt intervention the paper reports: +69.5% on
137
+ * LongCoT-mini over the same RLM without it (Table 2). Plain RLM prompting alone actually
138
+ * *regressed* two of the five categories; the doctrine is what fixed them. Its purpose is to
139
+ * counter under-delegation — the model doing the work itself in the REPL instead of fanning out.
140
+ *
141
+ * Note the counterweight: `orchestratorAddendum` carries the anti-OVER-recursion batching rule.
142
+ * The paper is explicit (App. B) that one prompt does not port across models and that both
143
+ * guardrails are needed; keep them both.
144
+ */
145
+ export const ENV_TIPS = [
146
+ "## Decomposition doctrine",
147
+ "",
148
+ "**Orchestrate; don't solve.** A single chain of thought over a large repository drifts —",
149
+ "you lose partials and compound mistakes. Your sub-LLMs are competent readers: given a",
150
+ "self-contained prompt and the text, they will extract, locate, classify, and summarize",
151
+ "reliably. Trust them; don't do their reading yourself.",
152
+ "",
153
+ "Your job: (1) find the relevant slice with `search` / `grep_context` / `outline`,",
154
+ "(2) delegate all semantic reading to `map_files` / `llm_query_batched` / `llm_map_reduce`,",
155
+ "(3) memoize every result you will reuse in `answers`, (4) sanity-check an answer before",
156
+ "another step depends on it, (5) assemble the final answer from `answers` by lookup.",
157
+ "Your own compute is: pointers, dict lookups, string formatting, and decisions.",
158
+ "",
159
+ "### The only state that matters",
160
+ "`answers` and `plan` are dicts that persist across every turn.",
161
+ "**If a value isn't in `answers`, it doesn't exist.** Do not trust a number from your own",
162
+ "earlier reasoning or from truncated stdout — context drifts. Memoize everything you reuse.",
163
+ "",
164
+ "### Shape of a run",
165
+ "1. Probe: `print(len(context))`, `search(<the user's question>)`. Do not print file bodies.",
166
+ "2. Plan: write the sub-questions into `plan`; each must be answerable from a named slice.",
167
+ "3. Fan out: one `map_files` / `llm_query_batched` per independent group, not one call per",
168
+ " file. Store results into `answers` keyed by path or sub-question.",
169
+ "4. Assemble: build the answer from `answers`. Delegate the aggregation too if it is large.",
170
+ "",
171
+ "### Red flags — you are off track",
172
+ "- Printing file bodies to read them yourself → stop, delegate to `map_files`.",
173
+ "- Writing regex to *infer meaning* (naming conventions, intent, correctness) → that is a",
174
+ " sub-LLM job. Regex is for exact lexical needles only.",
175
+ "- Two turns in with zero sub-LLM calls on an analysis task → you are solving it yourself.",
176
+ "- About to reuse a value that is not in `answers` → re-derive it and store it.",
177
+ "- One sub-call per file over dozens of files → batch them; fat prompts in small batches win.",
178
+ ].join("\n");
179
+
180
+ /** Native-mode variant of the doctrine — same rules, sized for the native prompt budget. */
181
+ export const ENV_TIPS_CONDENSED = [
182
+ "### Decomposition doctrine (paper App. C.3 — worth +69.5% there)",
183
+ "Orchestrate; don't solve. Loop: `search`/`grep_context`/`outline` to find the slice →",
184
+ "`map_files` / `llm_query_batched` to read it → memoize into `answers` → assemble by lookup.",
185
+ "`answers` and `plan` persist across every turn: **if a value isn't in `answers`, it",
186
+ "doesn't exist** — never reuse a number from your own earlier reasoning or truncated stdout.",
187
+ "Red flags: printing file bodies to read them; regex used to infer meaning rather than match",
188
+ "a literal; two turns into an analysis with zero sub-LLM calls; one sub-call per file instead",
189
+ "of one batch. Exception — AUTHORING is not reading: you write every edit body yourself.",
190
+ ].join("\n");
191
+
192
+ export function howToRunCode(): string {
193
+ return [
194
+ "To run Python, write a fenced ```repl``` block. The REPL **persists** across turns. Only",
195
+ "`print(...)` output (stdout) is returned; a bare expression on the last line is discarded, so",
196
+ "always wrap inspections in `print(...)`.",
197
+ ].join(" ");
198
+ }
199
+
200
+ export function replGlossary(
201
+ kind: ContextKind,
202
+ recursion: boolean,
203
+ libraryLoader: boolean,
204
+ child: boolean,
205
+ ): string {
206
+ const lines = ["Available in the REPL:"];
207
+ if (kind === "text") {
208
+ lines.push(
209
+ "- `context`: str — the raw text you must analyze. Probe it with slices",
210
+ " (`print(context[:2000])`), split it programmatically, and delegate large chunks",
211
+ " to sub-LLMs — never dump the whole string into your own output.",
212
+ );
213
+ } else {
214
+ lines.push(
215
+ "- `context`: list[dict] — a pre-packed JSON array of every file in the repository. Each dict has",
216
+ " keys: `path` (relative file path, str), `content` (file text, str), `tokens` (estimated count, int).",
217
+ " For large repos, chunk `context` into batches and delegate to sub-LLMs — never dump raw file",
218
+ " bodies into your own output.",
219
+ CONTEXT_EXCLUSION_NOTE,
220
+ );
221
+ if (child) lines.push(...CHILD_CONTEXT_LINES);
222
+ lines.push(
223
+ "",
224
+ " Worked example — find the slice, then delegate it:",
225
+ " ```python",
226
+ ' hits = search("where is the retry/backoff policy configured?", k=8)',
227
+ " paths = sorted({h['path'] for h in hits})",
228
+ ' answers.update(map_files(paths, "Describe any retry/backoff policy in this file, with line numbers. Say NONE if absent."))',
229
+ " print({p: a[:80] for p, a in answers.items()})",
230
+ " ```",
231
+ );
232
+ }
233
+ lines.push(...RETRIEVAL_GLOSSARY_LINES);
234
+ lines.push(
235
+ "- `llm_query(prompt: str, model=None) -> str`: a single sub-LLM completion. Use for extraction,",
236
+ " summarization, or Q&A over a chunk of text.",
237
+ "- `llm_query_batched(prompts: list[str], model=None) -> list[str]`: run several sub-LLM calls",
238
+ " concurrently; output order matches input order.",
239
+ ...CHUNKED_GLOSSARY_LINES,
240
+ ...SPAWN_GLOSSARY_LINES,
241
+ ...DELEGATION_GLOSSARY_LINES,
242
+ );
243
+ if (libraryLoader) {
244
+ lines.push(
245
+ "- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document and",
246
+ " **APPEND its files into the existing `context` list** (same shape: path/content/tokens).",
247
+ " `source` may be a local directory (repomix-packed), a single file path, or an https/git@ URL",
248
+ " (shallow-cloned, then packed). Paths are namespaced under `lib/<source_id>/…` so you can filter",
249
+ " by prefix. Returns metadata only:",
250
+ " {\"source\", \"source_id\", \"path_prefix\", \"files\", \"chars\", \"context_len\", \"already_loaded\"}",
251
+ " or an \"Error: ...\" string. **Never treat the return value as the file list** — always search",
252
+ " and chunk the single variable `context`. Do not invent `context_1` / aliases; do not call",
253
+ " globals()/locals(). Idempotent: re-loading the same source is a no-op.",
254
+ "",
255
+ " ```python",
256
+ " info = load_library(\"/path/to/other-project\")",
257
+ " # info is metadata; files are already in context under info[\"path_prefix\"]",
258
+ " lib_files = [f for f in context if f[\"path\"].startswith(info[\"path_prefix\"])]",
259
+ " ```",
260
+ );
261
+ }
262
+ if (recursion) {
263
+ lines.push(
264
+ "- `rlm_query(prompt, model=None)` / `rlm_query_batched(prompts, model=None)`: recursive RLM",
265
+ " sub-calls. Each child runs a full REPL loop internally — its entire conversation is PRIVATE",
266
+ " and never enters your history. Only the final answer (a short string) is returned.",
267
+ "",
268
+ " **Choosing between `llm_query` and `rlm_query`:**",
269
+ " - `llm_query` for simple one-shot tasks — summarize a chunk, extract a fact, answer a direct",
270
+ " question. It is a single LLM call: fast and cheap. Prefer it by default, and fan out with",
271
+ " `llm_query_batched` for parallel one-shots.",
272
+ " - `rlm_query` only when a sub-task genuinely needs iterative reasoning with its own code",
273
+ " execution (e.g. a sub-context large enough to need its own chunking, or a multi-step",
274
+ " reasoning chain). It is slower and more expensive — reserve it for cases `llm_query` cannot",
275
+ " handle. Avoid excessive recursive sub-calls when a batched one-shot would suffice.",
276
+ ...RECURSION_CONTEXT_LINES,
277
+ );
278
+ }
279
+ lines.push(
280
+ "- `answers` / `plan`: two dicts that persist across turns. Memoize every",
281
+ " verified result in `answers` — see the decomposition doctrine below.",
282
+ "- `SHOW_VARS() -> str`: list every variable currently in the REPL.",
283
+ '- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
284
+ ' set `answer["content"]` to the answer text and `answer["ready"] = True`.',
285
+ );
286
+ return lines.join("\n");
287
+ }