@hicaru/pi-rlm 0.3.17 → 0.3.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,25 +56,29 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
56
56
  // bare-number finalize gets one coached redo instead of being accepted. Opt-in via rlm.json.
57
57
  enableVerificationNudge: false,
58
58
  // SKILL.state integration: Σ_t execution state + cross-session distilled knowledge.
59
- enableRunState: true,
59
+ // Paradigm flags are ENFORCED (R0, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md) — validateEnforcedOn
60
+ // forces true whatever rlm.json carries; only calibrations are tunable.
61
+ enableRunState: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
60
62
  runStateRetryMax: 2,
61
- enableSkillState: true,
63
+ enableSkillState: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
62
64
  // Default ON (bench rec #3): deterministic harvest — one cheap distill leaf per finalize
63
65
  // replaces the stochastic fence-emission harvest (0 vs 4 notes across identical ON arms).
64
- enableSkillStateDistill: true,
66
+ enableSkillStateDistill: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
65
67
  skillStateMaxTokens: 1_200,
66
68
  skillStateLeafTokens: 200,
67
69
  skillStateMinScore: 4.0,
68
70
  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,
71
+ // Root Σ integration (WS-2..WS-4): every LLM call assembles A_t = (P, Σ_t, O_t) discard
72
+ // semantics on stale payloads + exactly one Σ snapshot splice, and model-proposed ΔΣ_t
73
+ // fences taught in the native prompt (v2 R1/R2). Digest compaction swaps Pi's summarizer
74
+ // for a deterministic digest. Flags are ENFORCED (R0); RLM_BENCH_NO_ROOTCONTEXT=1 remains
75
+ // the dev-only A/B measurement hatch — it alters measurement, never ships as a disable path.
76
+ enableRootDigestCompaction: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
73
77
  rootDigestKeepRecentChars: 12_000,
74
78
  rootDigestMaxChars: 8_000,
75
- enableRootContextTransform: false,
79
+ enableRootContextTransform: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0 (was soak-OFF pre-v2)
76
80
  rootContextKeepTurns: 2,
77
81
  rootContextElideChars: 1_500,
78
82
  rootContextSnapshot: true,
79
- enableRootStateFences: false,
83
+ enableRootStateFences: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0 (was soak-OFF pre-v2)
80
84
  });
@@ -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 } = {};
@@ -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,28 @@ 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
+ if (this.mode.kind !== "active") return { fences: fences.length, accepted: 0, problems: 0 };
210
+ if (fences.length === 0) {
211
+ // R4 (G6): a fence-free turn on a conditioned loop is IDLE — the contract rode the
212
+ // prompt for nothing. Grow the streak; degrade at the engine's threshold.
213
+ this.idleFenceTurns += 1;
214
+ this.degradeIfIdle();
215
+ return { fences: 0, accepted: 0, problems: 0 };
216
+ }
166
217
  let state = this.snapshot();
167
218
  const problems: string[] = [];
219
+ let accepted = 0;
168
220
  for (const fence of fences) {
169
221
  if (!fence.ok) {
170
222
  problems.push(malformedFenceProblem(fence.error));
@@ -173,16 +225,21 @@ export class RootStateTracker {
173
225
  const next: Result<RunState, PatchError> = applyPatch(state, fence.value, ++this.opCounter);
174
226
  if (next.ok) {
175
227
  state = next.value;
228
+ accepted += 1;
176
229
  } else {
177
230
  problems.push(patchErrorText(next.error));
178
231
  }
179
232
  }
233
+ // Accepted deltas reset the idle streak — even in a partially-failing batch (engine
234
+ // parity: real work is never punished for a sibling's malformed fence).
235
+ this.idleFenceTurns = accepted > 0 ? 0 : this.idleFenceTurns + 1;
236
+ this.degradeIfIdle();
180
237
  if (problems.length === 0) {
181
238
  this.mode = { kind: "active", retries: 0 };
182
239
  this.pendingObservation = undefined;
183
240
  this.draft = this.toMutable(state);
184
241
  this.touch();
185
- return;
242
+ return { fences: fences.length, accepted, problems: 0 };
186
243
  }
187
244
  const retries = this.mode.retries + problems.length;
188
245
  this.pendingObservation = statePatchObservation(problems);
@@ -192,9 +249,22 @@ export class RootStateTracker {
192
249
  this.touch();
193
250
  if (retries > this.retryMax) {
194
251
  this.mode = { kind: "degraded", reason: `state-patch retry cap exceeded (${retries} rejected)` };
195
- } else {
252
+ } else if (this.mode.kind === "active") {
253
+ // An idle degrade fired earlier in this call wins over re-activating — degrade is
254
+ // sticky; the runtime observation floor keeps Σ alive until the session ends.
196
255
  this.mode = { kind: "active", retries };
197
256
  }
257
+ return { fences: fences.length, accepted, problems: problems.length };
258
+ }
259
+
260
+ /** R4: fire the idle degrade at the root threshold (active trackers only). */
261
+ private degradeIfIdle(): void {
262
+ if (this.mode.kind === "active" && this.idleFenceTurns >= ROOT_IDLE_DEGRADE_TURNS) {
263
+ this.mode = {
264
+ kind: "degraded",
265
+ reason: `idle degrade — ${this.idleFenceTurns} consecutive turns with zero accepted deltas`,
266
+ };
267
+ }
198
268
  }
199
269
 
200
270
  /** Consume (and clear) the pending fence-rejection observation, if any. */
@@ -560,18 +560,28 @@ export const STATE_FENCE_INSTRUCTION: string =
560
560
  `rejected), and a patch over ${RUN_STATE_LIMITS.patchBytes} bytes is rejected whole.\n` +
561
561
  "Commit anything possibly relevant NOW; the raw observation will not be shown again.";
562
562
 
563
+ /** ONE Σ-block composer (R2): the engine turn block and the root splice both delegate here —
564
+ * never duplicate the contract + Σ assembly. `withContract` is paper A.4 authoring mode;
565
+ * without it, observation-only mode. */
566
+ function sigmaBlock(state: RunState, withContract: boolean): string {
567
+ const sigma = `[Σ] ${compactJSON(state)}`;
568
+ return withContract ? `${STATE_FENCE_INSTRUCTION}\n\n${sigma}` : sigma;
569
+ }
570
+
563
571
  /** The per-turn A_t block: the fence contract + the current Σ (paper A_t = (P, Σ_t, O_t)). */
564
572
  export function runStateTurnBlock(state: RunState): string {
565
- return `${STATE_FENCE_INSTRUCTION}\n\n[Σ] ${compactJSON(state)}`;
573
+ return sigmaBlock(state, true);
566
574
  }
567
575
 
568
576
  /**
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).
577
+ * Root Σ (WS-3b): the ROOT's A_t block. R2 (G2): with `withContract` the splice carries the
578
+ * SAME fence contract (delegating to the one composer above — no re-wording), making the
579
+ * splice paper-faithful A.4 authoring mode; without it, byte-identical to the v1
580
+ * observation-only snapshot. Recall line comes from the glossary (one wording source).
572
581
  */
573
- export function runStateRootBlock(state: RunState): string {
574
- return `[Σ] ${compactJSON(state)}\n` +
582
+ export function runStateRootBlock(state: RunState, opts?: { readonly withContract?: boolean }): string {
583
+ return sigmaBlock(state, opts?.withContract === true) +
584
+ "\n" +
575
585
  "Fresh tool results outrank Σ when they disagree.\n" +
576
586
  `[Project facts recall: skill_search()] — ${SKILL_RECALL_LINE}`;
577
587
  }
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,54 @@ 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
+ });
437
+ }
438
+ if (wasActive && !tracker.isActive) {
439
+ idleDegrades += 1;
440
+ if (traceEnabled) {
441
+ const reason = tracker.degradeReason ?? "unknown";
442
+ trace(reason.startsWith("idle") ? "root-state.idle-degrade" : "root-state.degrade", {
443
+ idleTurns: tracker.idleTurns,
444
+ reason,
445
+ });
446
+ }
447
+ }
405
448
  });
406
449
 
407
450
  // ── Root Σ WS-2: deterministic root compaction (no summary LLM call) ──
@@ -460,8 +503,14 @@ export default function rlmExtension(pi: ExtensionAPI): void {
460
503
  });
461
504
  elidedMessages += elided;
462
505
  const tracker = rootTracker;
463
- if (controller.config.rootContextSnapshot && tracker !== undefined && !tracker.isEmpty) {
464
- spliceSigmaSnapshot(filtered, tracker.snapshot(), tracker.rectifyHint());
506
+ // R4: a DEGRADED tracker stops splicing Σ (isActive gate) — pure input tax otherwise.
507
+ if (
508
+ controller.config.rootContextSnapshot && tracker !== undefined &&
509
+ tracker.isActive && !tracker.isEmpty
510
+ ) {
511
+ spliceSigmaSnapshot(filtered, tracker.snapshot(), tracker.rectifyHint(), {
512
+ withContract: controller.config.enableRootStateFences,
513
+ });
465
514
  sigmaSplices += 1;
466
515
  }
467
516
  if (traceEnabled && (elided > 0 || sigmaSplices > 0)) {
@@ -554,8 +603,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
554
603
  pi.on("session_shutdown", async () => {
555
604
  // Root Σ WS-4 harvest symmetry: the root tracker teaches the store exactly like engine
556
605
  // runs — the ONE notesFromRunState path — then the existing flush persists everything.
606
+ // R0: enableSkillState is enforced; no config check remains on this path.
557
607
  const tracker = rootTracker;
558
- if (skillStore !== undefined && tracker !== undefined && tracker.dirty && controller.config.enableSkillState) {
608
+ if (skillStore !== undefined && tracker !== undefined && tracker.dirty) {
559
609
  try {
560
610
  skillStore.merge(notesFromRunState(tracker.snapshot()));
561
611
  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
 
@@ -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
  }