- Runtime: bun.js (use
bun run index.ts,bun install,bun test— never npm/pnpm/yarn) - Source: root
index.ts(harness) +pi-plugin/rlm/src/(the actual RLM plugin) - TypeScript:
strict: true,noUnusedLocals: true,noUnusedParameters: trueenabled in both tsconfigs - Do not commit secrets, API keys, or session files.
This is a Recursive Language Model (RLM) plugin for the Pi coding agent. The engine drives a "smart" model turn-by-turn over Python repl() blocks, each executing in a persistent Python subprocess sandbox (sandbox/py/worker.py). Sub-LLM calls (llm_query, rlm_query) are serviced in-process by bridges that hold API keys — the sandbox never sees them.
pi-plugin/rlm/src/
├── core/ Headless RLM loop, limits, compaction, history
├── bridge/ Sub-LLM/rlm handlers (bridge/handlers/ is the single impl)
├── sandbox/ Python subprocess (py/), JSONL protocol, interrupt dispatch, sandbox manager
├── tool/ repl() and rlm() Pi tool registrations + event emitter
├── config/ rlm.json persistence, defaults, model resolution
├── prompts/ glossary (shared) → system (headless) + native
├── context/ native walker + anydoc document conversion + add_context
├── ui/ Config panel, model picker, status line, theme
├── text/ REPL block parsing, token estimation, text preview
├── mode/ RlmController, native-mode guards
├── util/ Result type, error formatting, concurrency pool
├── commands/ /rlm, /rlm-stop, /rlm-config
└── index.ts Extension entry point
The engine performs no disk I/O. There is no run trail, no snapshot, no resume: a run lives entirely in memory and its answer is its only durable output.
Entry points:
- Root
index.ts— harness that boots Pi withcreateAgentSession() pi-plugin/rlm/src/index.ts—rlmExtension(): registers tools, commands, prompt injection, input routingcore/engine.ts—createEngine(): builds therunRlmfunction (headless turn loop)sandbox/py/worker.py— Python REPL worker: executes model code, bridges sub-LLM calls over stdin/stdout. Siblingsguards.py/retrieval.py/tasks.pyresolve viasys.path[0](the script's own directory) — no packaging step, and they ship withsrc/like everything else.
Key types file: core/types.ts — RlmConfig, RlmInput, RlmResult, RunRlm, Sampling
Model-visible surface is deliberately small (prime-agent's rule): two Pi tools (repl, rlm)
and a REPL namespace of retrieval + delegation only. There is no todo, no save_artifact, no
advance_phase. Task tracking belongs to the main agent and its own tools, not to the sandbox —
do not re-add a wrapper for something Pi already owns.
Rules 1–5 below were a standing duplication between the headless engine and the repl() tool.
They are now resolved: bridge/handlers/ (createSubcallHandlers) is the single
implementation of llm_query / llm_batch / rlm_query / rlm_batch, and
bridge/llm-query.ts + bridge/rlm-query.ts have been deleted. Keep it that way:
-
LLM completion logic —
complete1exists once, insidecreateSubcallHandlers. Never inline another one; if you need LLM handlers, callcreateSubcallHandlers. -
RLM recursion logic —
childRun(depth cap → resource guard → spawn engine → debit parent) exists once, in the same file. Callers supplyrunChildanddegrade, not their own copy of the sequence. -
Display model resolution — one private
displayModel()inbridge/handlers/, built onmodelRef+resolveModelIdfromconfig/settings.ts. -
Leaf answer summary —
summarizeLeaf(), exported frombridge/handlers/. Batch handlers emit ONE node per item (llm_query/rlm_queryrows — nothing collapsed, no×N). -
The subcall emit pattern (create → execute → update status/cost/tokens) — the
emitting()helper inbridge/handlers/for leaf sub-calls;childRunemits its own node for recursive ones (never wrap it, or the node is reported twice).bridge/add-context.tsfollows the same shape by hand (extra mid-flight statuses). Adding a new subcall handler? Reuse these — don't invent a new pattern. -
Child context inheritance —
getChildContextonSubcallHandlerDepsis the ONE seam by which a child RLM receives its parent's world (repo pack + loaded libraries).childRunis the only place anRlmInputfor a child is constructed. A second path here is how issue #4 happened: whichever path forgets to grow leaves the child blind, and it degrades silently.
Callers differ only in resolve. The engine binds one Invocation for a whole run; the
repl() tool swaps one per turn and routes spawn()ed (detached) work to the session-scoped
BackgroundTasks registry. If you find yourself adding a second construction path, add a
field to SubcallHandlerDeps instead.
- ZERO
any— useunknownalways. Currently clean; keep it that way. - ZERO
!non-null assertions — use?.,??, type guards. Currently clean. readonlywhere it makes sense — immutable DTOs, constants, and option bags may usereadonly; mutable state objects (registries, counters, snapshots, timers) are fine without it. No blanket requirement.Object.freeze()on constants — arrays, sets, default configs, enums must be frozen.- Discriminated unions over flags — never a boolean that silently changes a shape.
Result<T, E>({ ok: true, value } | { ok: false, error }) for fallible operations — use fromutil/errors.ts.- Fail-soft I/O — writers return
booleanand warn instead of throwing; neverconsole.logfrom a TUI path (it corrupts the render). - Type guards over casts — every
unknownmust be narrowed viais*functions before use.
- The DRY principle is absolute: we never copy/paste or duplicate logic/code!
- No dynamic types! Always use strict TypeScript typing. If a type is unknown, using
anyis strictly forbidden — always useunknown. Ensure"strict": trueis enabled intsconfig.json. - Strict null/undefined safety: NEVER use the non-null assertion operator (
!) to force-unwrap or bypass the compiler. Always use safe checks: optional chaining (?.), nullish coalescing (??), or explicit Type Guards. - No unhandled exceptions (no crashes): Avoid application crashes. Use try/catch blocks for critical sections, or prefer the
Eitherpattern / Discriminated Unions for compile-time error handling (functional programming style). - Heavy tasks out of the main thread: Never block the Event Loop. Parsing massive JSON strings, cryptography, or heavy mathematical computations must be offloaded to Web Workers (browser) or Worker Threads (Node.js).
- Zero extra allocations in the UI: Prevent wasted renders and memory leaks. In UI frameworks (React, Vue, etc.), use memoization (
React.memo,useMemo,useCallback) wherever justified. For immutable configurations and constants, always applyObject.freeze()(freezing astring/numberis pointless — they are immutable by nature). - Keep components and render functions pure and fast: No business logic, heavy calculations, or side effects inside the component body or JSX/templates. Only UI declaration.
- Optimize memory collections: Avoid creating empty arrays and continuously calling
.push()in loops if the collection size is known beforehand. Pre-allocate memory usingArray.from({ length })ornew Array(size)to prevent ongoing V8 engine reallocations. - Efficient string manipulation: Never use string concatenation (
+or+=) inside heavy loops. For assembling large volumes of text, push segments into an array and use.join('')to minimize the creation of intermediate strings in memory. - Data predictability and comparison: For data objects (DTOs/Value Objects), mark all properties as
readonly. Mutable runtime state (registries, counters, timers) is fine without it. If value-based (instead of reference-based) object comparison is required, explicitly implementequals()/hashCode()methods or utilize proven deep-equality libraries. - Safe FFI/Wasm memory management: When integrating with low-level code via WebAssembly (Wasm), Node-API (N-API), or Bun FFI, strictly manage allocated memory. Always manually free native memory and destroy references to prevent memory leaks outside the V8 heap.
- Follow best practices: Enforce strict ESLint rules (including
@typescript-eslint/eslint-pluginwithstrict-type-checkedconfigurations), Prettier, and the official style guides of your chosen framework. - Optional props use
?syntax — never writeprop: string | undefinedin interfaces or option bags; writeprop?: string. The explicit| undefinedunion is the same thing but noisier —?is the convention.
| Pattern | Example |
|---|---|
| Single model completion entry point | bridge/model.ts — the only file that calls pi-ai's completeSimple |
| Error formatting | formatError(msg) returns "Error: msg", isErrorText() detects it — never throw strings |
| Concurrency pool | util/concurrency.ts SubcallGates — one session-wide Semaphore for leaf completions, per-depth DepthGates for child engines (a single shared gate would deadlock on recursion) |
| REPL block extraction | text/parsing.ts findReplBlocks(text) — regex over fenced code blocks |
| Sandbox lifecycle | SandboxManager.getOrCreate() → exec(code) → serialized queue, death-recreate on failure |
| Event emission | RlmEmitter (typed EventEmitter) → SubcallStore (accumulator) → RlmEventAggregator (snapshot) |
| Config validation | settings.ts validateNumber(v, min), validateBoolean(v), validateString(v) — all accept unknown |
| Pre-allocated arrays | new Array<R>(items.length) before loops, never .push() in a loop |
| JSONL protocol | sandbox/protocol.ts — newline-delimited JSON, parent→worker requests, worker→parent interrupts |
| Async sub-calls | py/worker.py posts a request and parks the reply by rid (_post / park_reply / _drain_until); spawn() returns a Task, await_task / await_task collect it, possibly in a later exec |
If a new sandbox function is needed (e.g., new_tool() from Python):
- Add the interrupt type to
protocol.ts(WorkerInterruptunion) - Add the handler to the
SubLlmHandlersinterface insandbox/interrupts.ts - Implement in
py/worker.py(Worker class_new_tool+ RPC) - Wire in
bridge/add-context.tsor a new bridge file — reuse the emitter pattern - Register in
sandbox/interrupts.ts: theSubLlmHandlersinterface, theREJECTdefault, and theserviceInterruptdispatch - Register in
py/worker.pyRESERVED+_restore_scaffold
The SKILL.state integration (full plan: /tmp/SKILL_STATE_INTEGRATION_PLAN.md) replaces
append-only history with execution state Σ_t and adds a persistent SkillState store. All code
in these workstreams obeys the standards above PLUS:
- Notation in comments: use the paper's symbols —
P(immutable spec/system prompt),Σ_t(execution state),ΔΣ_t(model patch),⊕(deep-merge,null= delete),V(ΔΣ_t, Σ_t)(deterministic validator),A_t = (P, Σ_t, O_t)(per-step inputs),Ξ(injected skill block),κ_Σ(state byte cap). Comments referencing SKILL.state cite section numbers (§3.2, §5.7, §7). - One merge implementation:
util/state-merge.tsdeepMergeWithNullDeletionis the only merge for both RunState (Σ) and SkillState notes. Never inline a second merge. - State is validated runtime-side, never model-side: patches arrive as
unknown, narrow viais*type guards, returnResult<RunState, PatchError>fromutil/errors.ts. Implicit key drops are errors (implicit-drop), not silent keeps — small models overwrite keys prematurely (paper §5.7: 68% of failures). - Degrade, never crash: RunStateTracker is a discriminated union
(
active | degraded); on retry-cap exhaustion the engine falls back to as-built append-only +compactHistorybehavior. No throw paths in the turn loop. - Persistence mirrors
config/settings.ts: new disk artifacts usejoin(getAgentDir(), name)path helpers, fail-soft readers (try/catch→ frozen empty value), fail-soft boolean writers (mkdir→writeFile→true | false). No second I/O style. Three-state pins:undefined= merge-from-disk,null= explicit clear. - No session state at module load:
NATIVE_PROMPT_STATIC/NATIVE_PROMPT_BUDGETare frozen import-time snapshots — never bake per-session text into them or any module-level const. Dynamic text enters via function arguments (PromptMeta.skillBlock?,before_agent_startconcat) only. - One leaf-completion seam: grounding attaches inside
completion.ts:complete1viaSubcallHandlerDeps.groundLeaf?— never per-handler prompt surgery (DRY #1). - One child-construction seam:
childRuncopiesskillBlockinto childRlmInput— no second child-input path (DRY #6). - Compact serialization: state JSON uses
JSON.stringify(Σ)(no pretty-print, paper A.4); caps (RUN_STATE_LIMITS) areObject.freezed and enforced before every persist — state size must stay flat across iterations. - BM25 duality is documented, not accidental:
sandbox/py/retrieval.py:_Bm25Index(sandbox) andutil/bm25.ts(host) implement the same scoring for two runtimes; keep their constants identical. - New sandbox functions: follow "Adding a New Bridge Handler" (§ above) in full —
interrupt type,
SubLlmHandlers+REJECT+ dispatch, worker method +RESERVEDinguards.py/worker.py, glossary line. Model-visible surface grows only when the profit is proven.
The Root Σ plan (/tmp/ROOT_SKILL_STATE_PLAN.md) brings SKILL.state to the native Pi harness
orchestrator through public plugin seams only — no host modifications. Conventions on top of
the SKILL.state rules above:
- RootStateTracker (
core/root-state.ts) is runtime-derived (tool outcomes, engine-run mirrors via the ONEEngineDeps.onRunStateseam, the user's latest prompt). The root's Σ is a digest, never model-authored by default; the fence protocol (enableRootStateFences) is the only model-proposed input, is OFF by default (paper §5.7 fence tax), and rides the sameV(ΔΣ_t,Σ_t)ladder + retry/degrade semantics as engine runs (cites §3, §5.3, §5.7).Amendment 2025-09-08 (Root Σ v2, R0/R1/R2/R4 —
/tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the "OFF by default" sentence above is superseded. The paradigm flags (enableRunState,enableSkillState,enableSkillStateDistill,enableRootContextTransform,enableRootStateFences,enableRootDigestCompaction) are ENFORCED viavalidateEnforcedOn(config/settings.ts) — always true; explicitfalsein rlm.json is traced (skillstate.override-ignored) and ignored. The fence contract rides the native system prompt (buildNativeSystemPrompt({ stateFences }), R1) and the Σ splice carries it (runStateRootBlock(state, { withContract }), R2). Idle-degrade is native-parity (R4):ROOT_IDLE_DEGRADE_TURNS(6 — root-specific; the engine keeps its bench-tuned 4 — soak found native cold-start fences land at turn 3–6, so 4 amputated before first commit) fence-eligible turns with zero accepted deltas degrade the tracker (isActivegate stops splices;observeToolResultstays the Σ floor).RLM_BENCH_NO_ROOTCONTEXT=1remains the DEV-ONLY measurement hatch — never a disable path. session_before_compactseam (WS-2):core/root-digest.tssupplies a deterministic no-LLM digest. Cut points only ever TIGHTEN Pi's boundary (never extend) and every displaced message folds into the digest inputs — nothing is dropped unaccounted. Never returncancel: true(overflow recovery depends on compaction happening). The gate (nativeTradeHolds() || controller.enabled) intentionally covers pure-headless sessions too — when the plugin is enabled at all, the digest replaces the summarizer, which is strictly better (same facts, zero tokens). INTENT, not an accident. V1 soak probe:root-digest.builttracestokensBefore(host-consumed) vstokensBeforeRecomputed— confirm at the first real/compactthat the host never diverges blindly.context-event discipline (WS-3): handlers receive astructuredClone— mutate it in place (zero extra allocations), return{ messages }; the disk transcript is never touched (context is the query channel, the session log is the archive). Every handler body is wrapped fail-soft: a throw must return the identity, never break the turn (host contractpackages/agent/src/types.ts:191). The Σ snapshot self-identifies viacustomType: "rlm-sigma"and elision must keep it immune.- "No session state at module load" also covers event handlers: per-session state
(
rootTracker, telemetry counters, the skill store) lives in therlmExtensionclosure — module-level consts stay frozen import-time snapshots. - One wording source: digest header/section labels, the skill_search recall line, and the
R5 turn-elision stub live in
prompts/glossary.ts(ROOT_DIGEST_*,SKILL_RECALL_LINE,ROOT_TURN_ELIDED_LINE);runStateRootBlockcomposes from them instead of re-wording. - Bench A/B:
RLM_BENCH_NO_ROOTCONTEXT=1skips the WS-3 transform at the gate (rootContextActive()in index.ts); telemetry is journal-only (trace+ closure counters:xiCompositions,rootDigests,elidedMessages,sigmaSplices,idleDegrades) and surfaces in the status widget only under trace mode (R6). Two-tier elision (R6 outcome): stale toolResults get the §5.3 preview only within amax(keepTurns, 1)-turn ring; deeper staleness collapses to the glossary stub — previews per stale turn would re-create O(T). [rlm.stage]transcript cards (UI): stage transitions — root digest compactions, tracker degrade/recover, session distill — postrlm.stagecustom messages (ui/stage-cards.ts) that render collapsed until the user's ctrl+o (the host'sapp.tools.expandre-invokes the renderer viaCustomMessageComponent.setExpanded). Digest bodies embed the summary VERBATIM (glossary wording — one source). The digest card is built insidesession_before_compactbut posted at the nextturn_start— neversendMessagere-entrantly mid-compaction. R6 status-widget gating is unchanged: the cards, not the status line, are the user-visible stage surface. Tree-side companion: identical siblingllmleaves (label+model+status) consolidate into one×Ngroup row at the first member's position (ui/tree/tree-model.ts);rlmnodes NEVER group.
- Tests live in
pi-plugin/rlm/test/ - Phase-based tests:
phase1.ts…phase-*.ts;test/smoke.tsruns every suite and boots a real sandbox native-smoke.tsandnative-mode.tstest the repl() tool integrationhelpers.tsprovides test utilities