@hicaru/pi-rlm 0.3.17 → 0.3.19

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.
package/README.md CHANGED
@@ -43,24 +43,14 @@ models, recursively. Same Pi session, same tools, same keys: `/rlm` and go. Read
43
43
 
44
44
  ## Benchmarks
45
45
 
46
- **OOLONG (oolong-synth)** — paper-tier long-context suite; latest journal per model,
47
- cost per task from real `costUsd` (older journals estimated at OpenRouter list prices):
46
+ **OOLONG (oolong-synth)** — paper-tier long-context suite (the only suite); latest
47
+ journal per model, cost per task from real `costUsd`:
48
48
 
49
49
  | Model | Score | Avg. cost/task |
50
50
  |-------|-------|----------------|
51
- | `qwen/qwen3.8-27b` | **100%** | $0.0127 |
52
- | `google/gemma-3-27b-it` | 83.3% | $0.0013 |
53
- | `qwen/qwen3-30b-a3b-instruct-2507` | 66.7% | $0.0009 |
54
- | `mistralai/mistral-small-3.2-24b-instruct` | 66.7% | $0.0025 |
55
-
56
- Lite suite — `needle` multi-needle recall, `codeqa` repo-QA, `coding` fix task
57
- (7 tasks × 2 passes per model, deterministic graders, no LLM-as-judge):
58
-
59
- | Model | Score | Accuracy |
60
- |-------|-------|----------|
61
- | `qwen/qwen3-30b-a3b-instruct-2507` | **14/14** | **100%** |
62
- | `google/gemma-3-27b-it` | 12/14 | 86% |
63
- | `mistralai/mistral-small-3.2-24b-instruct` | 12/14 | 86% |
51
+ | `qwen/qwen3.8-27b` | **87.5%** | $0.0460 |
52
+ | `google/gemma-3-27b-it` | 50.0% | $0.0011 |
53
+ | `inception/mercury-2.5` | | |
64
54
 
65
55
  Raw per-task rows (correct, recall, latency, tokens, cost) live in
66
56
  `bench/runs/*.jsonl` — one JSONL row per task, committed as history.
@@ -70,15 +60,13 @@ Raw per-task rows (correct, recall, latency, tokens, cost) live in
70
60
  ```bash
71
61
  export OPENROUTER_API_KEY=sk-or-... # required — env vars are the only key transport
72
62
 
73
- bun run bench # lite suite: needle + codeqa + coding
74
- bun run bench --suite needle --limit 1 # one suite, first task only
75
- bun run bench --model openrouter/qwen/qwen3-30b-a3b-instruct-2507
63
+ bun run bench # oolong suite, default model (qwen3.8-27b)
64
+ bun run bench --model openrouter/google/gemma-3-27b-it
65
+ bun run bench --model openrouter/inception/mercury-2.5
76
66
  bun run bench --list # print tasks, no engine / no key
77
- bun run bench --suite paper # paper tier: s_niah, oolong, browsecomp, codeqa_lb (downloads datasets)
78
67
  ```
79
68
 
80
- Suites: `all` (lite, default) · `needle` · `codeqa` · `coding` · `paper` · `s_niah` ·
81
- `oolong` · `browsecomp` · `codeqa_lb`. Regenerate the hero chart:
69
+ One suite (`oolong`). Regenerate the hero chart:
82
70
  `python3 bench/hero.py` (needs `matplotlib`).
83
71
 
84
72
  ## How it works
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,10 @@ const DEFAULT_SUB_SYSTEM_PROMPT =
9
9
  export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
10
10
  enabled: true,
11
11
  maxDepth: 4,
12
- maxIterations: 30,
12
+ // Max-long runs: the engine may keep iterating until budget/compaction walls hit. The budget
13
+ // cascade and compactionThresholdPct are the real length controls; this ceiling only stops
14
+ // truly runaway loops. Was 30 — capped long tasks prematurely.
15
+ maxIterations: 200,
13
16
  execTimeoutS: 120,
14
17
  requestTimeoutMs: 15 * 60_000,
15
18
  // Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
@@ -34,7 +37,11 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
34
37
  maxErrors: 5,
35
38
  orchestrator: true,
36
39
  compaction: true,
37
- compactionThresholdPct: 0.65,
40
+ // Compact only near the hard ceiling: ≈125K of the default 128K window (0.976). Earlier
41
+ // compaction (0.65) amputated usable working memory long before it was needed.
42
+ // DEPRECATED: ignored since the absolute 256k compaction ceiling (limits.ts); kept so old
43
+ // rlm.json files still load. Do not read this value in new code.
44
+ compactionThresholdPct: 0.976,
38
45
  python: "python3",
39
46
  sandboxInitTimeoutMs: 30_000,
40
47
  contextLoader: true,
@@ -56,25 +63,29 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
56
63
  // bare-number finalize gets one coached redo instead of being accepted. Opt-in via rlm.json.
57
64
  enableVerificationNudge: false,
58
65
  // SKILL.state integration: Σ_t execution state + cross-session distilled knowledge.
59
- enableRunState: true,
66
+ // Paradigm flags are ENFORCED (R0, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md) — validateEnforcedOn
67
+ // forces true whatever rlm.json carries; only calibrations are tunable.
68
+ enableRunState: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
60
69
  runStateRetryMax: 2,
61
- enableSkillState: true,
70
+ enableSkillState: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
62
71
  // Default ON (bench rec #3): deterministic harvest — one cheap distill leaf per finalize
63
72
  // replaces the stochastic fence-emission harvest (0 vs 4 notes across identical ON arms).
64
- enableSkillStateDistill: true,
73
+ enableSkillStateDistill: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
65
74
  skillStateMaxTokens: 1_200,
66
75
  skillStateLeafTokens: 200,
67
76
  skillStateMinScore: 4.0,
68
77
  skillStateNotesPerProject: 128,
69
- // Root Σ integration (WS-2..WS-4): digest compaction ON (it only swaps the summarizer for
70
- // a deterministic digest zero tokens, strictly less latency); the context transform and
71
- // model-proposed fences soak with flags OFF until the A/B says otherwise.
72
- enableRootDigestCompaction: true,
78
+ // Root Σ integration (WS-2..WS-4): every LLM call assembles A_t = (P, Σ_t, O_t) discard
79
+ // semantics on stale payloads + exactly one Σ snapshot splice, and model-proposed ΔΣ_t
80
+ // fences taught in the native prompt (v2 R1/R2). Digest compaction swaps Pi's summarizer
81
+ // for a deterministic digest. Flags are ENFORCED (R0); RLM_BENCH_NO_ROOTCONTEXT=1 remains
82
+ // the dev-only A/B measurement hatch — it alters measurement, never ships as a disable path.
83
+ enableRootDigestCompaction: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
73
84
  rootDigestKeepRecentChars: 12_000,
74
85
  rootDigestMaxChars: 8_000,
75
- enableRootContextTransform: false,
86
+ enableRootContextTransform: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0 (was soak-OFF pre-v2)
76
87
  rootContextKeepTurns: 2,
77
88
  rootContextElideChars: 1_500,
78
89
  rootContextSnapshot: true,
79
- enableRootStateFences: false,
90
+ enableRootStateFences: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0 (was soak-OFF pre-v2)
80
91
  });
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
6
6
  import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
7
7
  import type { RlmConfig } from "../core/types.ts";
8
+ import { trace, traceEnabled } from "../util/trace.ts";
8
9
  import { DEFAULT_CONFIG } from "./defaults.ts";
9
10
 
10
11
  interface PersistedSettings {
@@ -31,6 +32,21 @@ function validateBoolean(v: unknown): boolean | undefined {
31
32
  return typeof v === "boolean" ? v : undefined;
32
33
  }
33
34
 
35
+ /**
36
+ * R0 enforcement (/tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the SKILL.state paradigm flags are
37
+ * operating law — they ALWAYS resolve to `true`, whatever rlm.json says. An explicit `false`
38
+ * is not an error (fail-soft by contract): it is traced (`skillstate.override-ignored`) and
39
+ * ignored, so a hostile or typo'd config loses visibly instead of crashing the load or
40
+ * silently unbounding the root context. Calibrations (window sizes, budgets) stay tunable —
41
+ * the PARADIGM is enforced, the calibrations are not.
42
+ */
43
+ function validateEnforcedOn(value: unknown, flag: string): true {
44
+ if (value === false && traceEnabled) {
45
+ trace("skillstate.override-ignored", { flag, value });
46
+ }
47
+ return true;
48
+ }
49
+
34
50
  function validateString(v: unknown): string | undefined {
35
51
  return typeof v === "string" && v.trim() ? v : undefined;
36
52
  }
@@ -138,15 +154,13 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
138
154
  // Verification-discipline nudge (default OFF).
139
155
  const enableVerificationNudge = validateBoolean(r.enableVerificationNudge);
140
156
  if (enableVerificationNudge !== undefined) out.enableVerificationNudge = enableVerificationNudge;
141
- // SKILL.state integration (Workstreams A–F)
142
- const enableRunState = validateBoolean(r.enableRunState);
143
- if (enableRunState !== undefined) out.enableRunState = enableRunState;
157
+ // SKILL.state integration (Workstreams A–F) — paradigm flags ENFORCED (R0); runStateRetryMax
158
+ // is a calibration and stays tunable.
159
+ out.enableRunState = validateEnforcedOn(r.enableRunState, "enableRunState");
144
160
  const runStateRetryMax = validateNumber(r.runStateRetryMax, 0);
145
161
  if (runStateRetryMax !== undefined) out.runStateRetryMax = runStateRetryMax;
146
- const enableSkillState = validateBoolean(r.enableSkillState);
147
- if (enableSkillState !== undefined) out.enableSkillState = enableSkillState;
148
- const enableSkillStateDistill = validateBoolean(r.enableSkillStateDistill);
149
- if (enableSkillStateDistill !== undefined) out.enableSkillStateDistill = enableSkillStateDistill;
162
+ out.enableSkillState = validateEnforcedOn(r.enableSkillState, "enableSkillState");
163
+ out.enableSkillStateDistill = validateEnforcedOn(r.enableSkillStateDistill, "enableSkillStateDistill");
150
164
  const skillStateMaxTokens = validateNumber(r.skillStateMaxTokens, 50);
151
165
  if (skillStateMaxTokens !== undefined) out.skillStateMaxTokens = skillStateMaxTokens;
152
166
  const skillStateLeafTokens = validateNumber(r.skillStateLeafTokens, 0);
@@ -155,23 +169,21 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
155
169
  if (skillStateMinScore !== undefined) out.skillStateMinScore = skillStateMinScore;
156
170
  const skillStateNotesPerProject = validateNumber(r.skillStateNotesPerProject, 1);
157
171
  if (skillStateNotesPerProject !== undefined) out.skillStateNotesPerProject = skillStateNotesPerProject;
158
- // Root Σ integration (WS-2..WS-4)
159
- const enableRootDigestCompaction = validateBoolean(r.enableRootDigestCompaction);
160
- if (enableRootDigestCompaction !== undefined) out.enableRootDigestCompaction = enableRootDigestCompaction;
172
+ // Root Σ integration (WS-2..WS-4) — paradigm flags ENFORCED (R0); the window/byte knobs
173
+ // below are calibrations and stay tunable.
174
+ out.enableRootDigestCompaction = validateEnforcedOn(r.enableRootDigestCompaction, "enableRootDigestCompaction");
161
175
  const rootDigestKeepRecentChars = validateNumber(r.rootDigestKeepRecentChars, 200);
162
176
  if (rootDigestKeepRecentChars !== undefined) out.rootDigestKeepRecentChars = rootDigestKeepRecentChars;
163
177
  const rootDigestMaxChars = validateNumber(r.rootDigestMaxChars, 200);
164
178
  if (rootDigestMaxChars !== undefined) out.rootDigestMaxChars = rootDigestMaxChars;
165
- const enableRootContextTransform = validateBoolean(r.enableRootContextTransform);
166
- if (enableRootContextTransform !== undefined) out.enableRootContextTransform = enableRootContextTransform;
179
+ out.enableRootContextTransform = validateEnforcedOn(r.enableRootContextTransform, "enableRootContextTransform");
167
180
  const rootContextKeepTurns = validateNumber(r.rootContextKeepTurns, 0);
168
181
  if (rootContextKeepTurns !== undefined) out.rootContextKeepTurns = rootContextKeepTurns;
169
182
  const rootContextElideChars = validateNumber(r.rootContextElideChars, 100);
170
183
  if (rootContextElideChars !== undefined) out.rootContextElideChars = rootContextElideChars;
171
184
  const rootContextSnapshot = validateBoolean(r.rootContextSnapshot);
172
185
  if (rootContextSnapshot !== undefined) out.rootContextSnapshot = rootContextSnapshot;
173
- const enableRootStateFences = validateBoolean(r.enableRootStateFences);
174
- if (enableRootStateFences !== undefined) out.enableRootStateFences = enableRootStateFences;
186
+ out.enableRootStateFences = validateEnforcedOn(r.enableRootStateFences, "enableRootStateFences");
175
187
  if (typeof r.subSampling === "object" && r.subSampling !== null) {
176
188
  const ss = r.subSampling as Record<string, unknown>;
177
189
  const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Token budget cascade (port of the v4/v5 `budget.py` engine).
3
3
  *
4
- * The budget is the PRIMARY run-length control: cap = budgetShare × model context window,
4
+ * The budget is the PRIMARY run-length control: above COMPACTION_CEILING_TOKENS the cap is
5
+ * max(ceiling, budgetShare × model context window) — the share can only stretch the working
6
+ * budget further out, never cut under the ceiling;
5
7
  * one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
6
8
  * handoff (`distillTrajectory`) is handed to a fresh continuation run — chain-capped at
7
9
  * `maxContinuations`. Wall-clock timeouts stay only as hang backstops.
@@ -16,6 +18,7 @@ import type { ChatMsg } from "../bridge/model.ts";
16
18
  import type { RlmConfig } from "./types.ts";
17
19
  import type { RunState } from "./run-state.ts";
18
20
  import { compactJSON } from "./run-state.ts";
21
+ import { COMPACTION_CEILING_TOKENS } from "./limits.ts";
19
22
 
20
23
  interface TokenBudgetOptions {
21
24
  readonly softFrac?: number;
@@ -110,14 +113,13 @@ export class TokenBudget {
110
113
  /**
111
114
  * Minimum context window (tokens) for the token-budget cascade to engage at all.
112
115
  *
113
- * The formula (window × budgetShare) assumes the window is large enough that a fraction of it
114
- * is a meaningful working budget. Below this floor the derived cap shrinks below a task's FIXED
115
- * overhead (system prompt + per-turn history re-send + sub-LLM calls) and strangles the run
116
- * a 32k window would cap a task at 8k tokens, less than the protocol scaffolding alone.
117
- * So for smaller windows the rule does not apply: the budget is effectively unbounded and runs
118
- * stay bounded by maxIterations / maxErrors / wall-clock instead.
116
+ * LO rule (2025-09-09): windows at/below COMPACTION_CEILING_TOKENS (256k) are never
117
+ * budget-amputated the derived share would shrink below a task's FIXED overhead (system
118
+ * prompt + per-turn history re-send + sub-LLM calls); a 32k window would cap a task at 8k
119
+ * tokens, less than the protocol scaffolding alone. Windows above the ceiling are budgeted
120
+ * AT the ceiling, never below it. Unbounded runs stay bounded by
121
+ * maxIterations / maxErrors / wall-clock instead.
119
122
  */
120
- export const BUDGET_WINDOW_FLOOR = 250_000;
121
123
 
122
124
  /** One TokenBudget construction shape — the cap varies, the policy knobs never do (DRY). */
123
125
  function makeBudget(config: RlmConfig, cap: number): TokenBudget {
@@ -135,8 +137,9 @@ function unboundedBudget(config: RlmConfig): TokenBudget {
135
137
 
136
138
  export function resolveBudget(contextWindow: number | undefined, config: RlmConfig): TokenBudget {
137
139
  const ctx = contextWindow !== undefined && contextWindow > 0 ? contextWindow : 32_000;
138
- if (ctx < BUDGET_WINDOW_FLOOR) return unboundedBudget(config);
139
- const shareCap = Math.floor(ctx * config.budgetShare);
140
+ if (ctx <= COMPACTION_CEILING_TOKENS) return unboundedBudget(config);
141
+ // The share only stretches the budget BEYOND the absolute ceiling — never under it.
142
+ const shareCap = Math.max(COMPACTION_CEILING_TOKENS, Math.floor(ctx * config.budgetShare));
140
143
  const cap = config.budgetTaskCap > 0 ? Math.min(shareCap, config.budgetTaskCap) : shareCap;
141
144
  return makeBudget(config, Math.max(cap, 1));
142
145
  }
@@ -13,8 +13,7 @@ import type { RetryPolicy } from "../util/retry.ts";
13
13
  import { estimateMessageTokens } from "../text/tokens.ts";
14
14
  import type { RunState } from "../core/run-state.ts";
15
15
  import { compactJSON } from "../core/run-state.ts";
16
-
17
- const DEFAULT_CONTEXT_WINDOW = 128_000;
16
+ import { COMPACTION_CEILING_TOKENS } from "./limits.ts";
18
17
 
19
18
  const SUMMARY_REQUEST =
20
19
  "Summarize your progress so far. Include: (1) which sub-tasks are done and which remain; " +
@@ -25,17 +24,18 @@ interface CompactionDeps {
25
24
  readonly model: Model<Api>;
26
25
  readonly registry: ModelRegistry;
27
26
  readonly contextWindow?: number;
28
- readonly thresholdPct?: number;
29
27
  readonly signal?: AbortSignal;
30
28
  /** v5.1 retry policy for modelComplete; defaults apply when omitted. */
31
29
  readonly retry?: RetryPolicy;
32
30
  }
33
31
 
34
- /** True if the history is at/over the compaction threshold. */
35
- export function shouldCompact(history: ChatMsg[], deps: CompactionDeps): boolean {
36
- const contextWindow = deps.contextWindow && deps.contextWindow > 0 ? deps.contextWindow : DEFAULT_CONTEXT_WINDOW;
37
- const threshold = (deps.thresholdPct ?? 0.85) * contextWindow;
38
- return estimateMessageTokens(history) >= threshold;
32
+ /**
33
+ * True if the history is at/over the compaction threshold — the ABSOLUTE
34
+ * COMPACTION_CEILING_TOKENS (LO rule 2025-09-09): windows 256k never compact; larger
35
+ * windows compact exactly at 256k. `contextWindow`/`thresholdPct` percentage math is gone.
36
+ */
37
+ export function shouldCompact(history: ChatMsg[]): boolean {
38
+ return estimateMessageTokens(history) >= COMPACTION_CEILING_TOKENS;
39
39
  }
40
40
 
41
41
  /**
@@ -142,6 +142,16 @@ export function rebaseWithState(history: ChatMsg[], state: RunState, count = 1):
142
142
  }
143
143
  }
144
144
  }
145
+ // Token-bounded tail (LO rule 2025-09-09): the kept turns must also fit under the absolute
146
+ // ceiling; walk tailStart forward until the tail does. Σ carries everything dropped turns held.
147
+ const sizes: number[] = new Array<number>(history.length);
148
+ for (let i = 0; i < history.length; i++) sizes[i] = estimateMessageTokens([history[i]]);
149
+ let tailTokens = 0;
150
+ for (let i = tailStart; i < history.length; i++) tailTokens += sizes[i];
151
+ while (tailStart < history.length && tailTokens > COMPACTION_CEILING_TOKENS) {
152
+ tailTokens -= sizes[tailStart];
153
+ tailStart += 1;
154
+ }
145
155
  const window: ChatMsg[] = tailStart < history.length ? history.slice(tailStart) : [];
146
156
  return [
147
157
  ...head,
@@ -26,7 +26,7 @@ import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox
26
26
  import type { ReplResult } from "../sandbox/protocol.ts";
27
27
  import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
28
28
  import { previewStdout, previewText } from "../text/preview.ts";
29
- import { findReplBlocks } from "../text/parsing.ts";
29
+ import { findReplBlocks, stripStateFences } from "../text/parsing.ts";
30
30
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
31
31
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
32
32
  import { compactHistory, elideOldToolPayloads, rebaseWithState, shouldCompact } from "./compaction.ts";
@@ -364,16 +364,16 @@ export function createEngine(deps: EngineDeps): RunRlm {
364
364
  // v5 G1 first: elide old tool payloads head+tail — often avoids the summary entirely.
365
365
  history = elideOldToolPayloads(history);
366
366
  const compactionDeps = {
367
- // Summarisation is done by the cheap worker model; the threshold stays on the
368
- // root model's context window (that is the window the history fills each turn).
367
+ // Summarisation is done by the cheap worker model; compaction fires on the ABSOLUTE
368
+ // COMPACTION_CEILING_TOKENS (limits.ts): ≤256k windows never compact, larger ones
369
+ // compact exactly at 256k (LO rule 2025-09-09).
369
370
  model: deps.llmModel,
370
371
  registry: deps.registry,
371
372
  contextWindow: model.contextWindow,
372
- thresholdPct: deps.config.compactionThresholdPct,
373
373
  retry: retryPolicy(deps.config),
374
374
  signal: deps.signal,
375
375
  };
376
- if (shouldCompact(history, compactionDeps)) {
376
+ if (shouldCompact(history)) {
377
377
  // Workstream A: with Σ active, rebase structurally — [P, Σ_t, window(O)] — and
378
378
  // the summarizer call disappears entirely; degraded runs keep compactHistory.
379
379
  history = runStateMode.kind === "active"
@@ -602,8 +602,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
602
602
  }
603
603
 
604
604
  function result(answer: string, iterations: number, limits: LimitGuard): RlmResult {
605
+ // State fences are a Σ transport, never user-visible output (§7): scrub them from the
606
+ // FINAL answer. A fence-only answer means the model spent its last turn committing state
607
+ // and never re-answered — surface the stub instead of a raw patch JSON.
608
+ const clean = stripStateFences(answer);
609
+ const final = clean.trim().length > 0 ? clean.trim() : "(no final answer — last turn committed state only; see Σ)";
605
610
  const u = limits.usage();
606
- return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
611
+ return { answer: final, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
607
612
  }
608
613
 
609
614
  /** Model metadata window, else the offline registry fallback (disk cache → table → 32k). */
@@ -8,6 +8,15 @@
8
8
 
9
9
  import type { Usage } from "@earendil-works/pi-ai";
10
10
 
11
+ /**
12
+ * Absolute working ceiling (LO rule 2025-09-09): model windows AT/BELOW this value are never
13
+ * compacted or budget-amputated — the agent runs its full window. Windows ABOVE it are
14
+ * compacted/budgeted exactly AT the ceiling (e.g. a 1M-context model works up to ~256k tokens
15
+ * of history, then rebases/compacts). Single source of truth for budget.ts (resolveBudget)
16
+ * and compaction.ts (shouldCompact + rebaseWithState token bound).
17
+ */
18
+ export const COMPACTION_CEILING_TOKENS = 256_000;
19
+
11
20
  export interface Limits {
12
21
  readonly maxTimeoutMs?: number;
13
22
  readonly maxTokens?: number;
@@ -19,6 +19,7 @@
19
19
  import type { ContextEvent } from "@earendil-works/pi-coding-agent";
20
20
  import type { RunState } from "./run-state.ts";
21
21
  import { runStateRootBlock } from "./run-state.ts";
22
+ import { ROOT_TURN_ELIDED_LINE } from "../prompts/glossary.ts";
22
23
  import { truncateOutput } from "../text/parsing.ts";
23
24
  import { textContentOf } from "../text/agent-text.ts";
24
25
 
@@ -33,15 +34,29 @@ export interface ElideOptions {
33
34
  const ELIDE_MARK = "chars elided — full result in session log";
34
35
 
35
36
  /**
36
- * WS-3a: elide stale tool payloads. The newest `keepTurns` assistant turns and the final
37
- * user message stay verbatim; older toolResult content over `elideChars` becomes a
38
- * head+tail preview (same truncation shape as repl stdout). `role:"custom"` messages with
39
- * `customType: "rlm-sigma"` are immune (WS-3b owns them). Mutates the array in place;
40
- * returns the number of messages elided (telemetry), for zero-cost counters at the seam.
37
+ * WS-3a: elide stale turns. The newest `keepTurns` assistant turns and the final user message
38
+ * stay verbatim. Older turns collapse in two tiers (§5.3 discard semantics + R5/G4 honest
39
+ * strictness, and the only way the R6 acceptance bound per-call ≤ Σ + window — can hold):
40
+ * - recent stale ring (the last `max(keepTurns, 1)` stale turns): toolResult payloads over
41
+ * `elideChars` become head+tail previews (same truncation shape as repl stdout); assistant
42
+ * prose always collapses to the one-line stub.
43
+ * - older still: EVERYTHING (payloads included) becomes the one-line session-log stub —
44
+ * a stale preview per turn would itself accumulate linearly and re-create O(T).
45
+ * `role:"custom"` messages of the Σ/intro kinds are immune (WS-3b owns them). Mutates the
46
+ * array in place; returns the number of messages elided (telemetry), for zero-cost counters.
41
47
  */
42
48
  export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions): number {
43
49
  const keepTurns = Math.max(0, Math.floor(opts.keepTurns));
44
- if (keepTurns === 0 || messages.length === 0) return 0;
50
+ if (messages.length === 0) return 0;
51
+ // Preview ring: stale turns recent enough to deserve the §5.3 head+tail preview. Scales with
52
+ // the window knob — one calibration, two tiers (preview ring, then stub) — and never zero,
53
+ // so keepTurns=0 still previews the single closest stale payload instead of stubbing blind.
54
+ const previewRing = Math.max(keepTurns, 1);
55
+ if (keepTurns === 0) {
56
+ // R5 strict-0: nothing inside the window — Σ + immune customs + the final user message
57
+ // are all that survive verbatim.
58
+ return elideRange(messages, 0, messages.length, opts, previewRing);
59
+ }
45
60
 
46
61
  // Index of the assistant message that opens the keepTurns-th-from-last turn — everything
47
62
  // from there on is the protected tail (same walk as core/compaction.ts elideOldToolPayloads).
@@ -57,15 +72,55 @@ export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions):
57
72
  }
58
73
  }
59
74
  if (tailStart <= 0) return 0; // fewer turns than the window — nothing to elide
75
+ return elideRange(messages, 0, tailStart, opts, previewRing);
76
+ }
77
+
78
+ /** Custom messages the elision never touches — the Σ snapshot/observation and the intro. */
79
+ const IMMUNE_CUSTOM_TYPES: ReadonlySet<string> = new Set(["rlm-sigma", "rlm-sigma-observation", "rlm-intro"]);
80
+
81
+ function isImmuneCustom(m: RootMessage): boolean {
82
+ if (m.role !== "custom") return false;
83
+ const customType: unknown = (m as { customType?: unknown }).customType;
84
+ return typeof customType === "string" && IMMUNE_CUSTOM_TYPES.has(customType);
85
+ }
60
86
 
87
+ /** R5: elide [from, to) — stale assistant prose always becomes the one-line Σ stub; stale
88
+ * toolResults get the §5.3 preview while `assistantsAfter < previewRing` and the stub beyond
89
+ * (two-tier elision — previews must not accumulate linearly). Immune customs and the final
90
+ * user message are never touched. Mutates in place; returns the elided count. */
91
+ function elideRange(
92
+ messages: RootMessage[],
93
+ from: number,
94
+ to: number,
95
+ opts: ElideOptions,
96
+ previewRing: number,
97
+ ): number {
61
98
  const lastUser = lastIndexOfRole(messages, "user");
99
+ // Pre-pass: stale assistant indices in [from, to) — a payload's recency is measured by the
100
+ // stale turns AFTER it (pre-allocated walk, no per-message allocation).
101
+ let staleAssistants = 0;
102
+ for (let i = from; i < to; i++) {
103
+ if (messages[i]?.role === "assistant") staleAssistants += 1;
104
+ }
62
105
  let elided = 0;
63
- for (let i = 0; i < tailStart; i++) {
106
+ for (let i = from; i < to; i++) {
64
107
  const m = messages[i];
65
- if (m === undefined || m.role !== "toolResult") continue;
108
+ if (m === undefined || isImmuneCustom(m)) continue;
66
109
  if (i === lastUser) continue; // paranoia: the final user message is never touched
67
- const total = totalTextLength(m);
68
- if (total <= opts.elideChars) continue;
110
+ if (m.role === "assistant") {
111
+ staleAssistants -= 1; // turns AFTER this one = count minus itself
112
+ messages[i] = { ...m, content: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }] } as RootMessage;
113
+ elided += 1;
114
+ continue;
115
+ }
116
+ if (m.role !== "toolResult") continue;
117
+ if (staleAssistants >= previewRing) {
118
+ // Deep-stale payload: even the preview would accumulate — collapse to the stub.
119
+ messages[i] = { ...m, content: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }] } as RootMessage;
120
+ elided += 1;
121
+ continue;
122
+ }
123
+ if (totalTextLength(m) <= opts.elideChars) continue;
69
124
  messages[i] = {
70
125
  ...m,
71
126
  content: [{ type: "text", text: previewToolText(m, opts.elideChars) }],
@@ -75,13 +130,16 @@ export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions):
75
130
  return elided;
76
131
  }
77
132
 
78
- /** WS-3b: exactly one live Σ snapshot, immediately before the LAST user message. */
133
+ /** WS-3b: exactly one live Σ snapshot, immediately before the LAST user message.
134
+ * R2 (G2): `withContract` makes the splice carry the fence contract (A.4 authoring mode) —
135
+ * pass-through to runStateRootBlock; without it, the v1 observation-only block. */
79
136
  export function spliceSigmaSnapshot(
80
137
  messages: RootMessage[],
81
138
  state: RunState,
82
139
  rectifyHint: string | undefined,
140
+ opts?: { readonly withContract?: boolean },
83
141
  ): void {
84
- const block = runStateRootBlock(state);
142
+ const block = runStateRootBlock(state, opts);
85
143
  const text = rectifyHint === undefined ? block : `${block}\n${rectifyHint}`;
86
144
  // Remove any previous instance (only one lives at a time — idempotent across calls).
87
145
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -6,7 +6,8 @@
6
6
  * controller/sandboxManager. It is runtime-derived — tool outcomes, engine-run mirrors,
7
7
  * the user's latest prompt — zero model cooperation required (paper §5.3: observation
8
8
  * override; §5.7: small models must not be the state's author by default). The optional
9
- * fence protocol (enableRootStateFences, default OFF) is the only model-proposed input and
9
+ * fence protocol (enableRootStateFences ENFORCED ON since Root Σ v2 R0) is the only
10
+ * model-proposed input and
10
11
  * rides the SAME V(ΔΣ_t,Σ_t) validator + retry/degrade ladder as engine runs.
11
12
  *
12
13
  * Caps/dedup/serialization are the engine's own machinery: RUN_STATE_LIMITS, dedupStrings,
@@ -35,6 +36,23 @@ const RECTIFY_FAILURE_THRESHOLD = 2;
35
36
  /** Task restatement cap — mirrors run-state.ts TASK_MAX_CHARS (kept in sync by comment). */
36
37
  const ROOT_TASK_MAX_CHARS = 200;
37
38
 
39
+ /** R4: idle-degrade threshold for the NATIVE root tracker — deliberately ROOT-SPECIFIC
40
+ * (soak finding, 2025-09-08 live sessions: qwen3.8-27b ×3, qwen3-30b/32b, gemini-flash).
41
+ * The engine's `RUN_STATE_IDLE_DEGRADE_TURNS = 4` is bench-tuned for runs conditioned on Σ
42
+ * from turn 1; NATIVE sessions have a cold-start ramp — first fences land on turn 3 (short
43
+ * tasks) or turn 5–6 (study tasks), so 4 amputated exactly before the first commit (2/2
44
+ * study sessions degraded at 4, then fenced at 5). 6 clears the observed ramp while still
45
+ * bounding the fence tax. The engine const and its tuning are untouched.
46
+ */
47
+ export const ROOT_IDLE_DEGRADE_TURNS = 6;
48
+
49
+ /** R3 soak observability: per-turn fence outcome, returned by applyFences. */
50
+ export interface FenceOutcome {
51
+ readonly fences: number;
52
+ readonly accepted: number;
53
+ readonly problems: number;
54
+ }
55
+
38
56
  /** Root Σ mode — same discriminated union shape as the engine's (active | degraded). */
39
57
  type RootStateMode =
40
58
  | { readonly kind: "active"; readonly retries: number }
@@ -45,6 +63,8 @@ export class RootStateTracker {
45
63
  private mode: RootStateMode = { kind: "active", retries: 0 };
46
64
  private opCounter = 0;
47
65
  private dirtyFlag = false;
66
+ /** R4: consecutive fence-eligible turns with zero accepted deltas (idle streak). */
67
+ private idleFenceTurns = 0;
48
68
  private readonly failures = new Map<string, number>();
49
69
  private pendingObservation: string | undefined;
50
70
  private readonly retryMax: number;
@@ -82,6 +102,21 @@ export class RootStateTracker {
82
102
  return this.dirtyFlag;
83
103
  }
84
104
 
105
+ /** R4: true while the tracker accepts fences and the context transform may splice Σ. */
106
+ get isActive(): boolean {
107
+ return this.mode.kind === "active";
108
+ }
109
+
110
+ /** R4 telemetry: consecutive fence-eligible turns with zero accepted deltas. */
111
+ get idleTurns(): number {
112
+ return this.idleFenceTurns;
113
+ }
114
+
115
+ /** R4 telemetry: the degrade reason while degraded; undefined while active. */
116
+ get degradeReason(): string | undefined {
117
+ return this.mode.kind === "degraded" ? this.mode.reason : undefined;
118
+ }
119
+
85
120
  snapshot(): RunState {
86
121
  const capped = enforceCaps(this.draft);
87
122
  // enforceCaps never fails in practice; the fallback keeps the tracker fail-soft anyway.
@@ -160,11 +195,32 @@ export class RootStateTracker {
160
195
  * deltas land sequentially, ALL problems accumulate into ONE observation (error-as-
161
196
  * observation), and only past `runStateRetryMax` total rejections the tracker degrades and
162
197
  * fences stop being applied. Wording delegates to run-state.ts (N1) — one source.
198
+ *
199
+ * R4 (G6, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md): idle-degrade parity with the engine — once
200
+ * the native prompt teaches the fence contract, EVERY finalized assistant turn is
201
+ * fence-eligible; a turn with zero accepted deltas grows `idleFenceTurns` and
202
+ * `ROOT_IDLE_DEGRADE_TURNS` consecutive idle turns degrade the tracker (an idle Σ is
203
+ * pure input tax — bench rec #2, paper §5.7; root threshold is 6, not the engine's 4 —
204
+ * see the const's soak citation). Any accepted delta resets the streak. In
205
+ * degraded mode fences stop applying and the context transform stops splicing (`isActive`),
206
+ * while runtime `observeToolResult` remains the Σ floor (degrade, never crash).
163
207
  */
164
- applyFences(fences: readonly StateFenceResult[]): void {
165
- if (this.mode.kind !== "active" || fences.length === 0) return;
208
+ applyFences(fences: readonly StateFenceResult[]): FenceOutcome {
209
+ // R7-fix (recoverable degrade): a DEGRADED tracker no longer drops fences on the floor.
210
+ // The old early-return turned idle degrade into a one-way amnesia valve — every later
211
+ // fence vanished silently while the context transform kept eliding turns. Now the same
212
+ // validation ladder runs in degraded mode, and a CLEAN batch re-activates compensation.
213
+ // Degrade stays sticky only against zero-progress storms (all-malformed batches).
214
+ if (fences.length === 0) {
215
+ // R4 (G6): a fence-free turn on a conditioned loop is IDLE — the contract rode the
216
+ // prompt for nothing. Grow the streak; degrade at the engine's threshold.
217
+ this.idleFenceTurns += 1;
218
+ this.degradeIfIdle();
219
+ return { fences: 0, accepted: 0, problems: 0 };
220
+ }
166
221
  let state = this.snapshot();
167
222
  const problems: string[] = [];
223
+ let accepted = 0;
168
224
  for (const fence of fences) {
169
225
  if (!fence.ok) {
170
226
  problems.push(malformedFenceProblem(fence.error));
@@ -173,27 +229,50 @@ export class RootStateTracker {
173
229
  const next: Result<RunState, PatchError> = applyPatch(state, fence.value, ++this.opCounter);
174
230
  if (next.ok) {
175
231
  state = next.value;
232
+ accepted += 1;
176
233
  } else {
177
234
  problems.push(patchErrorText(next.error));
178
235
  }
179
236
  }
237
+ // Accepted deltas reset the idle streak — even in a partially-failing batch (engine
238
+ // parity: real work is never punished for a sibling's malformed fence).
239
+ this.idleFenceTurns = accepted > 0 ? 0 : this.idleFenceTurns + 1;
240
+ this.degradeIfIdle();
180
241
  if (problems.length === 0) {
242
+ // Recovery seam: a clean batch re-activates a degraded tracker, so one honest fence
243
+ // ends the amnesia window instead of requiring a session restart.
181
244
  this.mode = { kind: "active", retries: 0 };
182
245
  this.pendingObservation = undefined;
183
246
  this.draft = this.toMutable(state);
184
247
  this.touch();
185
- return;
248
+ return { fences: fences.length, accepted, problems: 0 };
186
249
  }
187
- const retries = this.mode.retries + problems.length;
250
+ // Degraded variant carries `reason`, not `retries` recovery is clean-batch-only, so a
251
+ // degraded tracker neither accumulates retries nor re-activates on a partial batch.
252
+ const retries = (this.mode.kind === "active" ? this.mode.retries : 0) + problems.length;
188
253
  this.pendingObservation = statePatchObservation(problems);
189
254
  // Accepted deltas in a partially-failing batch still land — engine parity: real work is
190
255
  // never rolled back just because a sibling fence was malformed.
191
256
  this.draft = this.toMutable(state);
192
257
  this.touch();
193
- if (retries > this.retryMax) {
194
- this.mode = { kind: "degraded", reason: `state-patch retry cap exceeded (${retries} rejected)` };
195
- } else {
196
- this.mode = { kind: "active", retries };
258
+ if (this.mode.kind === "active") {
259
+ if (retries > this.retryMax) {
260
+ this.mode = { kind: "degraded", reason: `state-patch retry cap exceeded (${retries} rejected)` };
261
+ } else {
262
+ // Persist the running rejection count — the retry cap is CUMULATIVE across turns.
263
+ this.mode = { kind: "active", retries };
264
+ }
265
+ }
266
+ return { fences: fences.length, accepted, problems: problems.length };
267
+ }
268
+
269
+ /** R4: fire the idle degrade at the root threshold (active trackers only). */
270
+ private degradeIfIdle(): void {
271
+ if (this.mode.kind === "active" && this.idleFenceTurns >= ROOT_IDLE_DEGRADE_TURNS) {
272
+ this.mode = {
273
+ kind: "degraded",
274
+ reason: `idle degrade — ${this.idleFenceTurns} consecutive turns with zero accepted deltas`,
275
+ };
197
276
  }
198
277
  }
199
278
 
@@ -552,26 +552,41 @@ export function statePatchObservation(problems: readonly string[]): string | und
552
552
  }
553
553
 
554
554
  export const STATE_FENCE_INSTRUCTION: string =
555
- "[state] Alongside your ```repl block(s), commit durable progress to Σ with a ```state fence:\n" +
555
+ "[state] Your user-facing reply is normal prose a readable report. The ```state fence is OPTIONAL compact metadata that trails it, never a replacement for the report:\n" +
556
556
  '{"state_patch": {"verifiedFacts[+]": "src/x.ts — fact", "testedApproaches.h1.status": "failed"}}\n' +
557
+ "Σ is an index of pointers, not a report: ≤ 5 keys per patch, every string value ≤ 120 chars, " +
558
+ "telegraphic style (`path — fact`, `verdict — numbers`). NEVER paste findings, tables, JSON " +
559
+ "blobs, or long excerpts into Σ — the prose carries the story, Σ carries only the pointers.\n" +
557
560
  "Keys: dotted paths write record leaves; [+] appends; [N] sets an array slot; null deletes.\n" +
558
561
  "Commit DELTAS only — never restate unchanged records or arrays; touch single dotted keys " +
559
562
  `or append with [+]. Whole-record restatements must keep EVERY key (implicit drops are ` +
560
563
  `rejected), and a patch over ${RUN_STATE_LIMITS.patchBytes} bytes is rejected whole.\n` +
561
- "Commit anything possibly relevant NOW; the raw observation will not be shown again.";
564
+ "If this turn produced nothing durable and new, end your reply on the prose NO fence at all. " +
565
+ "An absent fence is free; a malformed or oversized one costs a retry. " +
566
+ "Stale turns are elided and compensated by Σ, so a durable fact you skip here is gone.";
567
+
568
+ /** ONE Σ-block composer (R2): the engine turn block and the root splice both delegate here —
569
+ * never duplicate the contract + Σ assembly. `withContract` is paper A.4 authoring mode;
570
+ * without it, observation-only mode. */
571
+ function sigmaBlock(state: RunState, withContract: boolean): string {
572
+ const sigma = `[Σ] ${compactJSON(state)}`;
573
+ return withContract ? `${STATE_FENCE_INSTRUCTION}\n\n${sigma}` : sigma;
574
+ }
562
575
 
563
576
  /** The per-turn A_t block: the fence contract + the current Σ (paper A_t = (P, Σ_t, O_t)). */
564
577
  export function runStateTurnBlock(state: RunState): string {
565
- return `${STATE_FENCE_INSTRUCTION}\n\n[Σ] ${compactJSON(state)}`;
578
+ return sigmaBlock(state, true);
566
579
  }
567
580
 
568
581
  /**
569
- * Root Σ (WS-3b): the ROOT's A_t block the snapshot WITHOUT the patch-fence contract (the
570
- * root answers through Pi, not the engine's fence parser; WS-4.2 teaches fences separately
571
- * when enabled). Recall line comes from the glossary (one wording source).
582
+ * Root Σ (WS-3b): the ROOT's A_t block. R2 (G2): with `withContract` the splice carries the
583
+ * SAME fence contract (delegating to the one composer above — no re-wording), making the
584
+ * splice paper-faithful A.4 authoring mode; without it, byte-identical to the v1
585
+ * observation-only snapshot. Recall line comes from the glossary (one wording source).
572
586
  */
573
- export function runStateRootBlock(state: RunState): string {
574
- return `[Σ] ${compactJSON(state)}\n` +
587
+ export function runStateRootBlock(state: RunState, opts?: { readonly withContract?: boolean }): string {
588
+ return sigmaBlock(state, opts?.withContract === true) +
589
+ "\n" +
575
590
  "Fresh tool results outrank Σ when they disagree.\n" +
576
591
  `[Project facts recall: skill_search()] — ${SKILL_RECALL_LINE}`;
577
592
  }
package/src/core/types.ts CHANGED
@@ -54,7 +54,8 @@ export interface RlmConfig {
54
54
  readonly orchestrator: boolean;
55
55
  /** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
56
56
  readonly compaction: boolean;
57
- /** Compact when estimated history tokens reach this fraction of the model's context window. */
57
+ /** DEPRECATED (LO rule 2025-09-09): ignored compaction is governed by the absolute
58
+ * COMPACTION_CEILING_TOKENS (limits.ts). Kept for settings/UI compatibility only. */
58
59
  readonly compactionThresholdPct: number;
59
60
  /** Python executable used to launch the sandbox worker. */
60
61
  readonly python: string;
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts"
13
13
  import { RlmController } from "./mode/rlm-mode.ts";
14
14
  import { cheapestModel } from "./mode/llm-model.ts";
15
15
  import { postRlmGuide } from "./ui/intro.ts";
16
- import { setRlmModeStatus } from "./ui/status.ts";
16
+ import { setRlmModeStatus, type RootSigmaTelemetry } from "./ui/status.ts";
17
17
  import { RunRegistry } from "./ui/panel/run-registry.ts";
18
18
  import { installTreePanel } from "./ui/panel/tree-panel.ts";
19
19
  import { markdownTheme } from "./ui/theme-adapter.ts";
@@ -113,11 +113,20 @@ export default function rlmExtension(pi: ExtensionAPI): void {
113
113
  /** Root Σ (WS-3/4): the native session's digest-level Σ_t — runtime-derived (tool outcomes,
114
114
  * engine mirrors, prompts); lazily born on the first prompt, harvested + dropped at shutdown. */
115
115
  let rootTracker: RootStateTracker | undefined;
116
- // Root Σ WS-5.1 telemetry — journal counters (trace lines only; no TUI surface by design).
116
+ // Root Σ WS-5.1 telemetry — journal counters (trace lines + status widget when tracing).
117
117
  let xiCompositions = 0;
118
118
  let rootDigests = 0;
119
119
  let elidedMessages = 0;
120
120
  let sigmaSplices = 0;
121
+ let idleDegrades = 0;
122
+ /** R6: Σ counter snapshot for the status line — a fresh readonly object per render. */
123
+ const sigmaTelemetry = (): RootSigmaTelemetry => ({
124
+ xiCompositions,
125
+ rootDigests,
126
+ elidedMessages,
127
+ sigmaSplices,
128
+ idleDegrades,
129
+ });
121
130
  // A detached child works in its OWN sandbox, so this one sees no frames and its request
122
131
  // watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
123
132
  // with it. Keep it alive while detached work is genuinely in flight.
@@ -162,7 +171,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
162
171
  /** Ξ (Workstream C): BM25 slice of the SkillState store for a query; undefined when off. */
163
172
  const composeSkillBlock = (query: string): string | undefined => {
164
173
  const cfg = controller.config;
165
- if (!cfg.enableSkillState || skillStore === undefined) return undefined;
174
+ // R0: enableSkillState is enforced (validateEnforcedOn) no config check remains.
175
+ if (skillStore === undefined) return undefined;
166
176
  const block = skillStore.blockFor(query, cfg.skillStateMaxTokens);
167
177
  return block === "" ? undefined : block;
168
178
  };
@@ -204,10 +214,10 @@ export default function rlmExtension(pi: ExtensionAPI): void {
204
214
  controller.savedLlmRef = persisted.llm ?? undefined;
205
215
  controller.savedRlmRef = persisted.rlm ?? undefined;
206
216
 
207
- // SKILL.state (Workstream B): hydrate the cross-session note store (fail-soft).
208
- skillStore = controller.config.enableSkillState
209
- ? await SkillStore.hydrate(controller.config.skillStateNotesPerProject)
210
- : undefined;
217
+ // SKILL.state (Workstream B / R0): hydrate the cross-session note store UNCONDITIONALLY —
218
+ // the SkillStore is operating law; no rlm.json, command, or UI path can prevent its birth
219
+ // (hostile configs are traced + ignored at the validateEnforcedOn seam; fail-soft).
220
+ skillStore = await SkillStore.hydrate(controller.config.skillStateNotesPerProject);
211
221
  controller.skillStore = skillStore;
212
222
 
213
223
  // An explicit --rlm flag wins over the persisted setting for this session.
@@ -336,7 +346,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
336
346
  }
337
347
  }
338
348
 
339
- setRlmModeStatus(ctx, controller, ctx.getContextUsage());
349
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage(), sigmaTelemetry());
340
350
  if (!treePanelInstalled) {
341
351
  treePanelInstalled = true;
342
352
  installTreePanel(ctx, runRegistry);
@@ -349,7 +359,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
349
359
 
350
360
  // ── Keep the footer's context reading live (RLM exists to shrink this number) ──
351
361
  pi.on("turn_end", async (_event, ctx) => {
352
- setRlmModeStatus(ctx, controller, ctx.getContextUsage());
362
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage(), sigmaTelemetry());
353
363
  });
354
364
 
355
365
  /** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
@@ -387,21 +397,56 @@ export default function rlmExtension(pi: ExtensionAPI): void {
387
397
  const message = observation === undefined
388
398
  ? undefined
389
399
  : { customType: "rlm-sigma-observation", content: observation, display: false, details: undefined };
400
+ // R1 + R3 soak instrumentation: what the plugin RETURNS as the system prompt — the host
401
+ // applies result.systemPrompt verbatim (agent-session.js), so this line + the journal
402
+ // decide "wiring bug vs model non-compliance" for the fence contract.
403
+ const nativePrompt = buildNativeSystemPrompt({ stateFences: controller.config.enableRootStateFences });
404
+ if (traceEnabled) {
405
+ trace("root-prompt.composed", {
406
+ chars: nativePrompt.length,
407
+ fences: controller.config.enableRootStateFences,
408
+ contract: nativePrompt.includes("[state] Alongside"),
409
+ xi: xi !== undefined,
410
+ });
411
+ }
390
412
  return {
391
413
  ...(message === undefined ? {} : { message }),
392
- systemPrompt: event.systemPrompt + "\n\n" + xiPart + buildNativeSystemPrompt(),
414
+ systemPrompt: event.systemPrompt + "\n\n" + xiPart + nativePrompt,
393
415
  };
394
416
  });
395
417
 
396
- // Root Σ WS-4.2 (default OFF): capture model-proposed ΔΣ_t fences from finalized assistant
397
- // replies and run them through the ONE patch validator (run-state.ts applyPatch).
418
+ // Root Σ WS-4.2 + v2 R4: capture model-proposed ΔΣ_t fences from finalized assistant
419
+ // replies and run them through the ONE patch validator (run-state.ts applyPatch). EVERY
420
+ // assistant turn feeds the ladder — a fence-free turn grows the idle streak, and
421
+ // RUN_STATE_IDLE_DEGRADE_TURNS consecutive idle turns degrade the tracker (G6 parity).
398
422
  pi.on("message_end", async (event) => {
399
423
  const tracker = rootTracker;
400
424
  if (tracker === undefined || !controller.config.enableRootStateFences) return;
401
425
  if (event.message.role !== "assistant") return;
402
- const text = agentMessageText(event.message);
403
- if (text === "") return;
404
- tracker.applyFences(findStatePatches(text));
426
+ const wasActive = tracker.isActive;
427
+ const outcome = tracker.applyFences(findStatePatches(agentMessageText(event.message)));
428
+ // R3 soak observability: per-turn fence outcomes — the soak-B bars (≥50% of turns commit
429
+ // ≥1 accepted delta, rejection storms <10%) are computed from these journal lines.
430
+ if (traceEnabled) {
431
+ trace("root-state.turn", {
432
+ ...outcome,
433
+ idle: tracker.idleTurns,
434
+ active: tracker.isActive,
435
+ degraded: wasActive && !tracker.isActive,
436
+ // R7-fix: recovery observability — a degraded tracker that accepted a clean batch.
437
+ recovered: !wasActive && tracker.isActive,
438
+ });
439
+ }
440
+ if (wasActive && !tracker.isActive) {
441
+ idleDegrades += 1;
442
+ if (traceEnabled) {
443
+ const reason = tracker.degradeReason ?? "unknown";
444
+ trace(reason.startsWith("idle") ? "root-state.idle-degrade" : "root-state.degrade", {
445
+ idleTurns: tracker.idleTurns,
446
+ reason,
447
+ });
448
+ }
449
+ }
405
450
  });
406
451
 
407
452
  // ── Root Σ WS-2: deterministic root compaction (no summary LLM call) ──
@@ -460,8 +505,22 @@ export default function rlmExtension(pi: ExtensionAPI): void {
460
505
  });
461
506
  elidedMessages += elided;
462
507
  const tracker = rootTracker;
463
- if (controller.config.rootContextSnapshot && tracker !== undefined && !tracker.isEmpty) {
464
- spliceSigmaSnapshot(filtered, tracker.snapshot(), tracker.rectifyHint());
508
+ // R4 REV (amnesia fix): degrade suspends fence WRITES (applyFences gate) — never Σ
509
+ // READBACK. SKILL.state §5.3 makes elision lossless precisely because Σ rides with
510
+ // the elided turns; stubbing history while withholding Σ amnesia-loops the agent
511
+ // (announce-continue-then-stop). Splice whenever Σ has content, active or degraded;
512
+ // the fence CONTRACT rides only while active — a degraded tracker ignores fences
513
+ // (applyFences early-returns), so teaching the contract is pure tax.
514
+ if (
515
+ controller.config.rootContextSnapshot && tracker !== undefined &&
516
+ !tracker.isEmpty
517
+ ) {
518
+ spliceSigmaSnapshot(filtered, tracker.snapshot(), tracker.rectifyHint(), {
519
+ // R7-fix: teach the contract while DEGRADED too — it is the only road back.
520
+ // Recovery is a clean fence; hiding the notation after degrade made the
521
+ // amnesia window permanent (the fence turns that taught it get elided).
522
+ withContract: controller.config.enableRootStateFences,
523
+ });
465
524
  sigmaSplices += 1;
466
525
  }
467
526
  if (traceEnabled && (elided > 0 || sigmaSplices > 0)) {
@@ -554,8 +613,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
554
613
  pi.on("session_shutdown", async () => {
555
614
  // Root Σ WS-4 harvest symmetry: the root tracker teaches the store exactly like engine
556
615
  // runs — the ONE notesFromRunState path — then the existing flush persists everything.
616
+ // R0: enableSkillState is enforced; no config check remains on this path.
557
617
  const tracker = rootTracker;
558
- if (skillStore !== undefined && tracker !== undefined && tracker.dirty && controller.config.enableSkillState) {
618
+ if (skillStore !== undefined && tracker !== undefined && tracker.dirty) {
559
619
  try {
560
620
  skillStore.merge(notesFromRunState(tracker.snapshot()));
561
621
  if (traceEnabled) trace("root-harvest.merged", { notes: tracker.snapshot().verifiedFacts.length });
@@ -62,6 +62,11 @@ const SKILL_SEARCH_GLOSSARY_LINES: readonly string[] = Object.freeze([
62
62
  export const SKILL_RECALL_LINE =
63
63
  "Recall more anytime inside repl: `skill_search(query, k=8)` → [{id, text, tags, score}].";
64
64
 
65
+ /** R5 (G4, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the one-line replacement for assistant prose
66
+ * older than the keep window — durable facts live in Σ, the full text in the session log. */
67
+ export const ROOT_TURN_ELIDED_LINE =
68
+ "… turn elided — durable facts live in Σ; full text in session log";
69
+
65
70
  export function skillStateLines(noteCount: number, body: string): string {
66
71
  return [
67
72
  `[Project facts — SkillState, ${noteCount} note${noteCount === 1 ? "" : "s"}, distilled from prior sessions]`,
@@ -12,6 +12,7 @@ import {
12
12
  DEFAULT_PROMPT_CAP,
13
13
  promptCapTokensK,
14
14
  } from "./glossary.ts";
15
+ import { STATE_FENCE_INSTRUCTION } from "../core/run-state.ts";
15
16
 
16
17
  /** Adapts the REPL glossary for native mode — agent calls `repl({code})` instead of writing ```repl``` blocks. */
17
18
  function nativeReplGlossary(): string {
@@ -67,8 +68,15 @@ function nativeReplGlossary(): string {
67
68
  ].join("\n");
68
69
  }
69
70
 
70
- /** Build the native-mode system prompt for the main Pi agent. */
71
- export function buildNativeSystemPrompt(): string {
71
+ /** Build the native-mode system prompt for the main Pi agent.
72
+ *
73
+ * R1 (G1, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md): `stateFences` appends the ONE Σ fence contract
74
+ * (STATE_FENCE_INSTRUCTION verbatim — one wording source, engine and native share it) so the
75
+ * native model can author ΔΣ_t through ```state fences. The fence text is STATIC, so it may
76
+ * ride call-time composition; the FLAG decision happens at the call site — NATIVE_PROMPT_STATIC
77
+ * (the frozen module-load snapshot) is composed with no options and stays contract-free.
78
+ */
79
+ export function buildNativeSystemPrompt(opts?: { readonly stateFences?: boolean }): string {
72
80
  return [
73
81
  "╔══════════════════════════════════════════════════════════════════╗",
74
82
  "║ NATIVE RLM MODE — YOU ARE AN ORCHESTRATOR, NOT A READER ║",
@@ -162,6 +170,22 @@ export function buildNativeSystemPrompt(): string {
162
170
  "</rules>",
163
171
  "",
164
172
  nativeReplGlossary(),
173
+ ...(opts?.stateFences === true
174
+ ? [
175
+ "",
176
+ STATE_FENCE_INSTRUCTION,
177
+ // Soak-B finding (R3): models ignore the contract when it only speaks headless
178
+ // \u201c```repl block(s)\u201d \u2014 in native mode those are repl({code}) TOOL calls. The engine
179
+ // wording above stays byte-identical (one source); this line maps it 1:1 onto the
180
+ // native tool-call loop so the fence obligation is unambiguous.
181
+ "NATIVE MODE: you emit repl({code}) as TOOL calls, not ```repl text blocks \u2014 the " +
182
+ "contract above maps 1:1 onto this loop. In ANY reply where you learned something " +
183
+ "durable (a path, a fact, a failed approach, the next step), ALSO emit a ```state " +
184
+ 'fenced block in that same reply: {"state_patch": {"verifiedFacts[+]": ' +
185
+ '"src/x.ts \u2014 what you just verified"}}. Deltas only; one small patch per turn; ' +
186
+ "never restate unchanged keys.",
187
+ ]
188
+ : []),
165
189
  ].join("\n");
166
190
  }
167
191
 
@@ -49,10 +49,55 @@ export type StateFenceResult =
49
49
 
50
50
  const STATE_FENCE = /(`{3,})[ \t]*state[ \t]*\r?\n([\s\S]*?)\1/g;
51
51
 
52
+ /** Tolerant fallback: a payload object whose FIRST key is the patch key, emitted without a
53
+ * (well-formed) fence — soak keeps catching `...report.state {"state_patch": …}}` blobs from
54
+ * small models that mangle the opening backticks. Matched literally so ordinary prose or
55
+ * example JSON never trips the scanner. */
56
+ const BARE_PATCH = /\{"state_patch"\s*:/g;
57
+
58
+ /** String-aware balanced-brace scan from `start` (an index of `{`). Honors string literals and
59
+ * backslash escapes so braces inside JSON strings cannot unbalance the count. Returns the
60
+ * complete object slice, or undefined when braces never balance before EOF. */
61
+ function balancedJsonObject(text: string, start: number): string | undefined {
62
+ let depth = 0;
63
+ let inStr = false;
64
+ let esc = false;
65
+ for (let i = start; i < text.length; i++) {
66
+ const ch = text.charAt(i);
67
+ if (inStr) {
68
+ if (esc) esc = false;
69
+ else if (ch === "\\") esc = true;
70
+ else if (ch === '"') inStr = false;
71
+ continue;
72
+ }
73
+ if (ch === '"') inStr = true;
74
+ else if (ch === "{") depth += 1;
75
+ else if (ch === "}") {
76
+ depth -= 1;
77
+ if (depth === 0) return text.slice(start, i + 1);
78
+ }
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /** A dangling opener/closer pair after the fence body was mangled (e.g. `.state {…}}` followed
84
+ * by a lone ``` line) — cosmetic residue we scrub alongside the object itself. */
85
+ const ORPHAN_FENCE = /^ {0,3}`{3,}[ \t]*\r?$/gm;
86
+
87
+ /** Bookkeeping transport, never content: remove well-formed ```state fences AND the bare
88
+ * {"state_patch"…} objects models leak when they botch the fence syntax (both directions —
89
+ * parse for Σ harvest, strip for user-visible answers). Order: well-formed fences out first,
90
+ * then the bare-object scan over the remainder can never double-count the same payload. */
91
+ function sansFences(text: string): string {
92
+ return text.replace(STATE_FENCE, "");
93
+ }
94
+
52
95
  /**
53
96
  * Workstream A: extract ```state fences (model-proposed ΔΣ_t) from a response, in document
54
- * order. ```repl parsing is untouched — the two fences coexist in one response. Malformed
55
- * JSON is surfaced as an error result for the retry loop, never thrown.
97
+ * order. ```repl parsing is untouched — the two fences coexist in one response. Well-formed
98
+ * fences yield parsed payloads or a parse error (error-as-observation for the retry ladder);
99
+ * malformed-fence payloads are recovered by the tolerant bare-object scanner, so a mangled
100
+ * opening fence never orphans a valid delta.
56
101
  */
57
102
  export function findStatePatches(text: string): readonly StateFenceResult[] {
58
103
  const out: StateFenceResult[] = [];
@@ -67,9 +112,50 @@ export function findStatePatches(text: string): readonly StateFenceResult[] {
67
112
  out.push({ ok: false, error: errorMessage(err) });
68
113
  }
69
114
  }
115
+ // Tolerant harvest over fence-free remainder (well-formed payloads already taken above).
116
+ const rest = sansFences(text);
117
+ BARE_PATCH.lastIndex = 0;
118
+ while ((m = BARE_PATCH.exec(rest)) !== null) {
119
+ const obj = balancedJsonObject(rest, m.index);
120
+ if (obj === undefined) continue;
121
+ try {
122
+ out.push({ ok: true, value: JSON.parse(obj) as unknown });
123
+ } catch (err: unknown) {
124
+ out.push({ ok: false, error: errorMessage(err) });
125
+ }
126
+ }
70
127
  return out;
71
128
  }
72
129
 
130
+ /** Strip ```state fences from free text. A Σ fence is bookkeeping, never content — but models
131
+ * that finalize right after a Σ splice tend to echo the fence verbatim as their final output,
132
+ * which leaked raw state JSON into RlmResult.answer (bench graders scored JSON, reports showed
133
+ * bookkeeping). Deterministic scrub on the answer path; parse semantics stay in findStatePatches.
134
+ * Also removes bare {"state_patch"…} objects (mangled-fence leaks), a `state` token glued to
135
+ * preceding prose, and orphan ``` lines left behind by the mangled pair. */
136
+ export function stripStateFences(text: string): string {
137
+ let out = sansFences(text);
138
+ const parts: string[] = [];
139
+ let cursor = 0;
140
+ BARE_PATCH.lastIndex = 0;
141
+ let m: RegExpExecArray | null;
142
+ while ((m = BARE_PATCH.exec(out)) !== null) {
143
+ const obj = balancedJsonObject(out, m.index);
144
+ if (obj === undefined) continue;
145
+ // Glom any immediately-preceding bare `state`/`.state` token (prose like "...report.state {").
146
+ const before = out.slice(cursor, m.index).replace(/\s*(?:\.?state)\s*$/i, "");
147
+ parts.push(before);
148
+ cursor = m.index + obj.length;
149
+ BARE_PATCH.lastIndex = cursor;
150
+ }
151
+ // No bare objects → leave the text exactly as the well-formed pass left it (a lone ```
152
+ // line can be a legitimate unclosed code fence in real content; only scrub residue that
153
+ // our own removal created).
154
+ if (parts.length === 0) return out.trim();
155
+ parts.push(out.slice(cursor));
156
+ return parts.join("").replace(ORPHAN_FENCE, "").trim();
157
+ }
158
+
73
159
  /** Truncate REPL stdout for the model's context window (head + tail, with an elision note).
74
160
  * `mark` lets callers specialize the wording (root elision cites the session log) while the
75
161
  * head/tail math stays the one implementation. */
@@ -52,7 +52,7 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
52
52
  item("maxErrors", "Max consecutive errors", config.maxErrors != null ? String(config.maxErrors) : "none", CHOICES.maxErrors, "Stop after this many consecutive failing turns; none disables the guard."),
53
53
  item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
54
54
  item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
55
- item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "Compact once estimated history tokens reach this share of the root model's context window."),
55
+ item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "DEPRECATED ignored: compaction uses the absolute 256k ceiling (COMPACTION_CEILING_TOKENS)."),
56
56
  item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
57
57
  item("rootSamplingTemperature", "Root sampling temperature", config.rootSampling?.temperature === undefined ? "default" : String(config.rootSampling?.temperature), CHOICES.rootSamplingTemperature,
58
58
  "Sampling temperature for RLM root turns, finalize included — 0 = deterministic (the r3 reproducibility setting); 'default' = provider default. Applies to RLM-mode runs, rlm() delegation and child recursion; the native Pi agent loop follows Pi's own session settings."),
@@ -68,6 +68,18 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
68
68
  "Allow add_context() to pull an external dir, file, document, or git repo into context."),
69
69
  item("autoSeedCwd", "Auto-seed cwd", config.autoSeedCwd ? "on" : "off", CHOICES.autoSeedCwd,
70
70
  "Seed the working directory into context on the first repl() call (otherwise starts empty)."),
71
+ // R0 (/tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the SKILL.state / Root Σ paradigm flags are
72
+ // ENFORCED — rendered as a read-only badge so the truth is visible instead of hidden.
73
+ // No toggle exists: applySetting has no case for them and the validator forces true.
74
+ item("__sigma_enforced__", "SKILL.state / Root Σ", "enforced", ["enforced"],
75
+ "ENFORCED (no opt-out): run state, skill state + distill, root context transform, state fences, digest compaction. " +
76
+ "Override attempts in rlm.json are traced (skillstate.override-ignored) and ignored; RLM_BENCH_NO_ROOTCONTEXT=1 is the dev-only measurement hatch."),
77
+ // R5: the window calibrations are rlm.json-only knobs — shown read-only with live values.
78
+ item("__sigma_window__", "Root Σ window (calibration)",
79
+ `keepTurns=${config.rootContextKeepTurns} · elide=${config.rootContextElideChars} · snapshot=${config.rootContextSnapshot ? "on" : "off"}`,
80
+ ["rlm.json"],
81
+ "Query-time window calibrations, rlm.json only: rootContextKeepTurns (1 = strict: Σ + current turn; 2 = default), rootContextElideChars, rootContextSnapshot. " +
82
+ "Session resume/fork: the tracker is reborn lazily and Σ re-grows from live observations — the first call after a resume has an empty Σ by design."),
71
83
  item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
72
84
  ];
73
85
 
package/src/ui/status.ts CHANGED
@@ -3,23 +3,47 @@
3
3
  * The footer's extension-status row is sanitized to a single line, so the
4
4
  * two-model layout lives in a dedicated multi-line widget instead: one line
5
5
  * for the mode, one per model lane (llm = leaf sub-calls, rlm = child engines),
6
- * each with the live context token spend.
6
+ * each with the live context token spend — and, when trace mode is on, a Root Σ
7
+ * telemetry line (R6): Ξ compositions, digest compactions, elisions, Σ splices,
8
+ * idle degrades.
7
9
  */
8
10
 
9
11
  import type { ContextUsage, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
12
  import type { Api, Model } from "@earendil-works/pi-ai";
11
13
  import type { RlmController } from "../mode/rlm-mode.ts";
14
+ import { traceEnabled } from "../util/trace.ts";
12
15
  import { formatTokens } from "./theme.ts";
13
16
 
14
17
  const KEY = "rlm";
15
18
 
19
+ /** R6: Root Σ closure-counter snapshot (fresh readonly object per render). */
20
+ export interface RootSigmaTelemetry {
21
+ readonly xiCompositions: number;
22
+ readonly rootDigests: number;
23
+ readonly elidedMessages: number;
24
+ readonly sigmaSplices: number;
25
+ readonly idleDegrades: number;
26
+ }
27
+
16
28
  export function modelLabel(model: Model<Api> | undefined, fallback: string): string {
17
29
  return model ? `${model.provider}/${model.id}` : fallback;
18
30
  }
19
31
 
32
+ /** R6: the Σ telemetry line — built only when tracing and at least one counter is live. */
33
+ function sigmaLine(telemetry: RootSigmaTelemetry): string | undefined {
34
+ const parts: string[] = [];
35
+ if (telemetry.xiCompositions > 0) parts.push(`Ξ${telemetry.xiCompositions}`);
36
+ if (telemetry.elidedMessages > 0) parts.push(`elided ${telemetry.elidedMessages}`);
37
+ if (telemetry.sigmaSplices > 0) parts.push(`Σ${telemetry.sigmaSplices}`);
38
+ if (telemetry.rootDigests > 0) parts.push(`digest ${telemetry.rootDigests}`);
39
+ if (telemetry.idleDegrades > 0) parts.push(`degraded ${telemetry.idleDegrades}`);
40
+ return parts.length === 0 ? undefined : ` Σ ${parts.join(" · ")}`;
41
+ }
42
+
20
43
  export function formatRlmStatusLines(
21
44
  controller: RlmController,
22
45
  contextUsage?: ContextUsage,
46
+ telemetry?: RootSigmaTelemetry,
23
47
  ): readonly string[] {
24
48
  if (!controller.enabled) return ["○ RLM OFF"];
25
49
  const tokens = contextUsage?.tokens;
@@ -28,14 +52,25 @@ export function formatRlmStatusLines(
28
52
  const llmSuffix = controller.config.subSampling.reasoning ? `:${controller.config.subSampling.reasoning}` : "";
29
53
  const rlm = modelLabel(controller.rlmModel, controller.savedRlmRef ?? "session");
30
54
  const rlmSuffix = controller.config.rootSampling?.reasoning ? `:${controller.config.rootSampling.reasoning}` : "";
31
- return [
55
+ const lines = [
32
56
  "● RLM ON",
33
57
  ` llm=${llm}${llmSuffix}${tokSuffix}`,
34
58
  ` rlm=${rlm}${rlmSuffix}${tokSuffix}`,
35
59
  ];
60
+ // R6: counters surface only under trace mode — the default UI stays clean.
61
+ if (traceEnabled && telemetry !== undefined) {
62
+ const sigma = sigmaLine(telemetry);
63
+ if (sigma !== undefined) lines.push(sigma);
64
+ }
65
+ return lines;
36
66
  }
37
67
 
38
68
  /** Set the above-editor status widget. Idempotent — call on every state change. */
39
- export function setRlmModeStatus(ctx: ExtensionContext, controller: RlmController, contextUsage?: ContextUsage): void {
40
- ctx.ui.setWidget(KEY, [...formatRlmStatusLines(controller, contextUsage)], { placement: "aboveEditor" });
69
+ export function setRlmModeStatus(
70
+ ctx: ExtensionContext,
71
+ controller: RlmController,
72
+ contextUsage?: ContextUsage,
73
+ telemetry?: RootSigmaTelemetry,
74
+ ): void {
75
+ ctx.ui.setWidget(KEY, [...formatRlmStatusLines(controller, contextUsage, telemetry)], { placement: "aboveEditor" });
41
76
  }
@@ -6,11 +6,14 @@
6
6
  *
7
7
  * Nothing is ever hidden: every sub-call renders as its own row (parity with
8
8
  * pi, which shows each concurrent tool call individually) — except runs of
9
- * IDENTICAL sibling leaves (same label+model+status), which collapse into one
10
- * expandable "label ×N" group row so a 20-item llm_batch is one line, not 20.
11
- * Error leaves are NEVER grouped each keeps its own row and reason.
12
- * Collapsed subtrees are skipped at the user's explicit request (chevron flips).
13
- * Token rows are own-spend only a row never blends models.
9
+ * CONSECUTIVE IDENTICAL sibling leaves (same label+model+status), which
10
+ * collapse into one expandable "label ×N" group row so a 20-item llm_batch is
11
+ * one line, not 20 and a wholesale batch failure is one `✗ label ×N` line.
12
+ * Errors group exactly like successes; distinct failures keep their own rows
13
+ * and reasons, and singletons render as plain rows. Interleaved siblings (✗ ✓
14
+ * ✗ with different keys between) stay in encounter order — position is never
15
+ * rewritten. Collapsed subtrees are skipped at the user's explicit request
16
+ * (chevron flips). Token rows are own-spend only — a row never blends models.
14
17
  */
15
18
 
16
19
  import type { RlmSubcall, RlmRunStatus, SubcallPhase, SubcallStatus } from "../../tool/rlm-details.ts";
@@ -81,9 +84,13 @@ type Entry =
81
84
  | { readonly type: "node"; readonly sc: RlmSubcall }
82
85
  | { readonly type: "group"; readonly key: string; readonly label: string; readonly model?: string; readonly status: SubcallStatus; readonly members: RlmSubcall[] };
83
86
 
84
- /** Errors never group — each keeps its own row and its own reason. */
87
+ /**
88
+ * Any childless llm leaf may group — errors included. groupKey pins status, so
89
+ * only runs of identical failures merge; per-item reasons stay in the detail
90
+ * modal (expand the group).
91
+ */
85
92
  const groupable = (sc: RlmSubcall, byParent: ReadonlyMap<string | undefined, RlmSubcall[]>): boolean =>
86
- sc.kind === "llm" && sc.status !== "error" && (byParent.get(sc.id)?.length ?? 0) === 0;
93
+ sc.kind === "llm" && (byParent.get(sc.id)?.length ?? 0) === 0;
87
94
 
88
95
  const groupKey = (sc: RlmSubcall): string => `${sc.label}|${sc.model ?? ""}|${sc.status}`;
89
96