@hicaru/pi-rlm 0.3.16 → 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.
Files changed (88) hide show
  1. package/README.md +0 -4
  2. package/README.ru.md +56 -66
  3. package/README.zh-CN.md +61 -65
  4. package/package.json +5 -5
  5. package/src/bridge/add-context.ts +1 -1
  6. package/src/bridge/handlers/await.ts +13 -22
  7. package/src/bridge/handlers/completion.ts +27 -5
  8. package/src/bridge/handlers/emitting.ts +2 -2
  9. package/src/bridge/handlers/llm-query.ts +46 -68
  10. package/src/bridge/handlers/rlm-query.ts +14 -84
  11. package/src/bridge/handlers/task-registry.ts +22 -17
  12. package/src/bridge/handlers/types.ts +8 -6
  13. package/src/bridge/model.ts +6 -3
  14. package/src/commands/rlm-llm.ts +1 -10
  15. package/src/commands/rlm-rlm.ts +1 -8
  16. package/src/config/defaults.ts +32 -12
  17. package/src/config/settings.ts +53 -31
  18. package/src/config/skillstate.ts +465 -0
  19. package/src/context/md-cache.ts +1 -1
  20. package/src/context/merge.ts +1 -1
  21. package/src/context/namespace.ts +2 -2
  22. package/src/context/refresh.ts +1 -1
  23. package/src/context/source-dir.ts +21 -11
  24. package/src/context/source-doc.ts +1 -1
  25. package/src/context/source-git.ts +3 -15
  26. package/src/context/source-text.ts +1 -1
  27. package/src/context/walk.ts +6 -14
  28. package/src/core/budget.ts +107 -21
  29. package/src/core/compaction.ts +44 -1
  30. package/src/core/engine.ts +141 -84
  31. package/src/core/iteration.ts +1 -1
  32. package/src/core/ledger.ts +10 -13
  33. package/src/core/limits.ts +1 -1
  34. package/src/core/model-registry.ts +1 -1
  35. package/src/core/resource-limits.ts +1 -1
  36. package/src/core/root-context.ts +184 -0
  37. package/src/core/root-digest.ts +213 -0
  38. package/src/core/root-state.ts +310 -0
  39. package/src/core/run-state.ts +587 -0
  40. package/src/core/types.ts +51 -12
  41. package/src/index.ts +220 -39
  42. package/src/mode/llm-model.ts +13 -1
  43. package/src/mode/native-guards.ts +0 -6
  44. package/src/mode/rlm-mode.ts +34 -11
  45. package/src/mode/subagent.ts +5 -5
  46. package/src/prompts/glossary.ts +46 -25
  47. package/src/prompts/native.ts +27 -5
  48. package/src/prompts/system.ts +12 -4
  49. package/src/sandbox/context-file.ts +1 -1
  50. package/src/sandbox/interrupts.ts +25 -31
  51. package/src/sandbox/protocol.ts +14 -20
  52. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  53. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  54. package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
  55. package/src/sandbox/py/guards.py +1 -1
  56. package/src/sandbox/py/scaffold.py +24 -31
  57. package/src/sandbox/py/worker.py +3 -1
  58. package/src/sandbox/sandbox-manager.ts +2 -2
  59. package/src/sandbox/sandbox.ts +21 -4
  60. package/src/text/agent-text.ts +58 -0
  61. package/src/text/parsing.ts +35 -3
  62. package/src/text/preview.ts +3 -0
  63. package/src/text/repl-output.ts +1 -1
  64. package/src/tool/background-tasks.ts +1 -1
  65. package/src/tool/repl-render.ts +1 -1
  66. package/src/tool/repl-result.ts +1 -1
  67. package/src/tool/repl-tool.ts +50 -26
  68. package/src/tool/rlm-tool.ts +4 -5
  69. package/src/tool/subcall-render.ts +1 -1
  70. package/src/tool/subcall-store.ts +2 -2
  71. package/src/tool/tool-utils.ts +5 -5
  72. package/src/ui/config-panel.ts +12 -0
  73. package/src/ui/intro.ts +1 -1
  74. package/src/ui/modal/timeline-store.ts +1 -1
  75. package/src/ui/model-picker/drilldown.ts +1 -1
  76. package/src/ui/model-picker/levels.ts +1 -1
  77. package/src/ui/panel/run-registry.ts +1 -1
  78. package/src/ui/status.ts +39 -4
  79. package/src/ui/tree/tree-rows.ts +1 -1
  80. package/src/ui/tree/tree-widget.ts +1 -1
  81. package/src/util/bm25.ts +97 -0
  82. package/src/util/concurrency.ts +1 -1
  83. package/src/util/errors.ts +1 -1
  84. package/src/util/retry.ts +22 -7
  85. package/src/util/state-merge.ts +34 -0
  86. package/src/util/throttle.ts +1 -1
  87. package/src/util/type-guards.ts +6 -0
  88. package/src/core/memory.ts +0 -589
@@ -27,6 +27,10 @@ import { trace, traceEnabled } from "../util/trace.ts";
27
27
 
28
28
  export type { AddContextResult, SubcallOpts, SubLlmHandlers } from "./interrupts.ts";
29
29
 
30
+ /** Event-loop guard: frames are model/summary-sized by construction; payloads travel via temp
31
+ * files, never the wire. Anything near this cap is a runaway producer — drop it. */
32
+ const MAX_FRAME_CHARS = 8_000_000;
33
+
30
34
  export interface SandboxOptions {
31
35
  /** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
32
36
  readonly depth?: number;
@@ -139,7 +143,7 @@ export class PythonSandbox {
139
143
  // windowsHide: without it each sandbox flashes a console window on Windows (pi sets
140
144
  // this on every spawn — bash.ts / shell.ts). Same Windows surface as issue #7.
141
145
  { stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv(), windowsHide: true },
142
- ) as ChildProcessWithoutNullStreams;
146
+ );
143
147
 
144
148
  this.proc.stdout.setEncoding("utf8");
145
149
  this.proc.stdout.on("data", (chunk: string) => this.onData(chunk));
@@ -262,7 +266,8 @@ export class PythonSandbox {
262
266
  this.pending.set("_init", {
263
267
  resolve: (res) => {
264
268
  clearTimeout(timer);
265
- res.ok ? resolve() : reject(new Error(res.error ?? "worker init failed"));
269
+ if (res.ok) resolve();
270
+ else reject(new Error(res.error ?? "worker init failed"));
266
271
  },
267
272
  reject,
268
273
  timer,
@@ -299,7 +304,7 @@ export class PythonSandbox {
299
304
  settle(value);
300
305
  };
301
306
  this.pending.set(id, { resolve: once(resolve), reject: once(reject), timer, requestType: payload.type });
302
- this.send({ id, ...payload } as ParentMessage);
307
+ this.send({ id, ...payload });
303
308
  });
304
309
  }
305
310
 
@@ -356,7 +361,12 @@ export class PythonSandbox {
356
361
  this.appendStderr(`[rlm] dropped '${msg.type}' frame: worker ${this.exitDescription()}\n`);
357
362
  return;
358
363
  }
359
- this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
364
+ const frame = JSON.stringify(msg);
365
+ if (frame.length > MAX_FRAME_CHARS) {
366
+ this.appendStderr(`[rlm] dropped '${msg.type}' frame: ${frame.length} chars exceed the frame cap\n`);
367
+ return;
368
+ }
369
+ this.proc.stdin.write(`${frame}\n`);
360
370
  }
361
371
 
362
372
  /**
@@ -391,6 +401,13 @@ export class PythonSandbox {
391
401
 
392
402
  private onData(chunk: string): void {
393
403
  this.buf += chunk;
404
+ // Untrusted-stream guard: a worker that stops emitting newlines would otherwise balloon
405
+ // this buffer without bound and stall the pump. Reset (both cursors) and keep draining.
406
+ if (this.buf.length > MAX_FRAME_CHARS) {
407
+ this.appendStderr(`\n[protocol] stdout buffer exceeded ${MAX_FRAME_CHARS} chars without a newline — truncated\n`);
408
+ this.buf = "";
409
+ this.scanOffset = 0;
410
+ }
394
411
  let nl: number;
395
412
  while ((nl = this.buf.indexOf("\n", this.scanOffset)) >= 0) {
396
413
  const line = this.buf.slice(this.scanOffset, nl).trim();
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Text extraction from host AgentMessages (coding-agent `Message | CustomMessage | …` union).
3
+ *
4
+ * The root-Σ modules (core/root-digest.ts, core/root-context.ts) and the index.ts event
5
+ * handlers all need the same "text payload of a message" projection. One implementation
6
+ * here; callers never re-walk content blocks. Everything takes `unknown` and narrows via
7
+ * guards — host shapes evolve, this module is the single adaptation seam.
8
+ */
9
+
10
+ /** Text payload of one content block list / string: string as-is, `{text}` blocks joined. */
11
+ export function textContentOf(content: unknown): string {
12
+ if (typeof content === "string") return content;
13
+ if (!Array.isArray(content)) return "";
14
+ const parts = new Array<string>(content.length);
15
+ let n = 0;
16
+ for (const block of content) {
17
+ if (isTextBlock(block)) parts[n++] = block.text;
18
+ }
19
+ parts.length = n;
20
+ return parts.join("");
21
+ }
22
+
23
+ function isTextBlock(block: unknown): block is { readonly type: "text"; readonly text: string } {
24
+ return (
25
+ typeof block === "object" && block !== null &&
26
+ (block as Record<string, unknown>).type === "text" &&
27
+ typeof (block as Record<string, unknown>).text === "string"
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Text payload of a whole AgentMessage: role-disciplined extraction. `thinking` blocks and
33
+ * tool-call payloads are deliberately skipped (digests/elision want observable prose and
34
+ * results, never the model's private reasoning). Unknown shapes yield "".
35
+ */
36
+ export function agentMessageText(message: unknown): string {
37
+ if (typeof message !== "object" || message === null) return "";
38
+ const m = message as Record<string, unknown>;
39
+ switch (m.role) {
40
+ case "user":
41
+ case "assistant":
42
+ case "custom":
43
+ case "toolResult":
44
+ return textContentOf(m.content);
45
+ case "compactionSummary":
46
+ case "branchSummary":
47
+ return typeof m.summary === "string" ? m.summary : "";
48
+ default:
49
+ return "";
50
+ }
51
+ }
52
+
53
+ /** First non-blank line, trimmed and capped — the error-reason shape trackers store. */
54
+ export function firstLine(text: string, maxChars = 200): string {
55
+ const line = text.split("\n", 1)[0] ?? "";
56
+ const trimmed = line.trim();
57
+ return trimmed.length > maxChars ? trimmed.slice(0, maxChars) : trimmed;
58
+ }
@@ -15,6 +15,8 @@ const FENCE = /(`{3,})[ \t]*repl[ \t]*\r?\n([\s\S]*?)\1/g;
15
15
  const FALLBACK_FENCE = /(`{3,})[ \t]*([^`\r\n]*)[ \t]*\r?\n([\s\S]*?)\1/g;
16
16
  const PYTHON_TAG = /^py(thon)?$/i;
17
17
 
18
+ import { errorMessage } from "../util/errors.ts";
19
+
18
20
  /** Shared fence scan: run `re` over `text`, keep bodies the selector accepts (same trimming). */
19
21
  function collectFences(text: string, re: RegExp, select: (m: RegExpExecArray) => string | null): string[] {
20
22
  const blocks: string[] = [];
@@ -40,11 +42,41 @@ export function findReplBlocks(text: string): string[] {
40
42
  });
41
43
  }
42
44
 
43
- /** Truncate REPL stdout for the model's context window (head + tail, with an elision note). */
44
- export function truncateOutput(text: string, limit = 20_000): string {
45
+ /** One ```state fence: parsed JSON payload, or the parse error (error-as-observation). */
46
+ export type StateFenceResult =
47
+ | { readonly ok: true; readonly value: unknown }
48
+ | { readonly ok: false; readonly error: string };
49
+
50
+ const STATE_FENCE = /(`{3,})[ \t]*state[ \t]*\r?\n([\s\S]*?)\1/g;
51
+
52
+ /**
53
+ * 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.
56
+ */
57
+ export function findStatePatches(text: string): readonly StateFenceResult[] {
58
+ const out: StateFenceResult[] = [];
59
+ let m: RegExpExecArray | null;
60
+ STATE_FENCE.lastIndex = 0;
61
+ while ((m = STATE_FENCE.exec(text)) !== null) {
62
+ const body = (m[2] ?? "").trim();
63
+ if (body === "") continue;
64
+ try {
65
+ out.push({ ok: true, value: JSON.parse(body) as unknown });
66
+ } catch (err: unknown) {
67
+ out.push({ ok: false, error: errorMessage(err) });
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** Truncate REPL stdout for the model's context window (head + tail, with an elision note).
74
+ * `mark` lets callers specialize the wording (root elision cites the session log) while the
75
+ * head/tail math stays the one implementation. */
76
+ export function truncateOutput(text: string, limit = 20_000, mark = "chars elided"): string {
45
77
  if (text.length <= limit) return text;
46
78
  const head = Math.floor(limit * 0.7);
47
79
  const tail = limit - head;
48
80
  const cut = text.length - head - tail;
49
- return `${text.slice(0, head)}\n... [${cut} chars elided] ...\n${text.slice(-tail)}`;
81
+ return `${text.slice(0, head)}\n... [${cut} ${mark}] ...\n${text.slice(-tail)}`;
50
82
  }
@@ -4,6 +4,9 @@ import type { ReplResult } from "../sandbox/protocol.ts";
4
4
 
5
5
  const DEFAULT_PREVIEW_CHARS = 200;
6
6
 
7
+ /** Chars of a task/prompt shown on the collapsed tool-call line (repl + rlm tools share it). */
8
+ export const CALL_PREVIEW_CHARS = 80;
9
+
7
10
  export function previewText(text: string, maxChars = DEFAULT_PREVIEW_CHARS): string {
8
11
  const normalized = text.replace(/\s+/g, " ").trim();
9
12
  return normalized.length > maxChars ? `${normalized.slice(0, Math.max(0, maxChars - 1))}…` : normalized;
@@ -6,7 +6,7 @@
6
6
  import { truncateOutput } from "./parsing.ts";
7
7
 
8
8
  /** Max stderr kept in model-visible REPL output (headless history and native tool_result). */
9
- export const STDERR_LIMIT = 8_000;
9
+ const STDERR_LIMIT = 8_000;
10
10
 
11
11
  /** Prefix stderr so the model can tell prints from exceptions. Empty when stderr is blank. */
12
12
  export function formatReplStderr(stderr: string, limit = STDERR_LIMIT): string {
@@ -19,7 +19,7 @@ import type { Invocation } from "../bridge/handlers/index.ts";
19
19
  import { trace, traceEnabled } from "../util/trace.ts";
20
20
 
21
21
  /** What a drain hands to the turn that is reporting it. */
22
- export interface BackgroundDrain {
22
+ interface BackgroundDrain {
23
23
  readonly subcalls: readonly RlmSubcall[];
24
24
  readonly totals: SubcallTotals;
25
25
  }
@@ -12,7 +12,7 @@ const EXPANDED_STDERR_CHARS = 500;
12
12
 
13
13
  // ── Collapsed view ──
14
14
 
15
- export function replStats(details: ReplDetails, theme: Theme): string {
15
+ function replStats(details: ReplDetails, theme: Theme): string {
16
16
  const elapsed = details.executionTimeMs > 0 ? `${details.executionTimeMs}ms` : undefined;
17
17
  return cardStatsLine(details.totals, theme, elapsed, details.backgroundPending);
18
18
  }
@@ -10,7 +10,7 @@ import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts
10
10
  import { formatReplStderr } from "../text/repl-output.ts";
11
11
 
12
12
  /** Model-visible text assembled from a repl() result. */
13
- export interface ReplResultText {
13
+ interface ReplResultText {
14
14
  readonly text: string;
15
15
  }
16
16
 
@@ -29,7 +29,6 @@ import { SandboxManager } from "../sandbox/sandbox-manager.ts";
29
29
  import type { SubcallOpts } from "../sandbox/sandbox.ts";
30
30
  import { createSubcallHandlers, type Invocation } from "../bridge/handlers/index.ts";
31
31
  import { TaskLedger } from "../core/ledger.ts";
32
- import type { MemoryStore } from "../core/memory.ts";
33
32
  import { BackgroundTasks } from "./background-tasks.ts";
34
33
  import type { ReplResult } from "../sandbox/protocol.ts";
35
34
  import { RlmEmitter } from "./rlm-events.ts";
@@ -38,18 +37,21 @@ import { SubcallStore } from "./subcall-store.ts";
38
37
  import type { ReplDetails } from "./repl-details.ts";
39
38
  import type { RlmSubcall } from "./rlm-details.ts";
40
39
  import { createEngine } from "../core/engine.ts";
40
+ import type { RunState } from "../core/run-state.ts";
41
41
  import { modelRef } from "../config/settings.ts";
42
42
  import { spinnerFrame } from "../ui/theme.ts";
43
- import { previewText } from "../text/preview.ts";
43
+ import { CALL_PREVIEW_CHARS, previewText } from "../text/preview.ts";
44
44
  import { errorMessage } from "../util/errors.ts";
45
+ import {
46
+ groundLeafPrompt,
47
+ skillSearchHandler,
48
+ type SkillStore,
49
+ } from "../config/skillstate.ts";
45
50
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
46
51
  import { buildReplResultText, collectReplWarnings } from "./repl-result.ts";
47
52
  import { renderReplCollapsed, renderReplExpanded } from "./repl-render.ts";
48
53
  import { attachTracer, trace, traceEnabled } from "../util/trace.ts";
49
54
 
50
- /** Chars of code shown on the tool call line. */
51
- const CALL_PREVIEW_CHARS = 80;
52
-
53
55
  /** Last non-empty line of a Python traceback — the `TypeError: …` line, not the frames. */
54
56
  function lastLine(text: string): string {
55
57
  const lines = text.trimEnd().split("\n");
@@ -62,7 +64,7 @@ function lastLine(text: string): string {
62
64
 
63
65
  // ── Parameter schema ──
64
66
 
65
- export const ReplToolParams = Object.freeze(Type.Object({
67
+ const ReplToolParams = Object.freeze(Type.Object({
66
68
  code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
67
69
  }));
68
70
 
@@ -103,7 +105,7 @@ class NativeBridgeState {
103
105
 
104
106
  // ── Tool factory ──
105
107
 
106
- export interface ReplToolDeps {
108
+ interface ReplToolDeps {
107
109
  readonly sandboxManager: SandboxManager;
108
110
  readonly model: Model<Api>;
109
111
  readonly llmModel: Model<Api>;
@@ -119,10 +121,14 @@ export interface ReplToolDeps {
119
121
  readonly resolveGates?: () => SubcallGates;
120
122
  /** Session-scoped home for detached spawn() work. */
121
123
  readonly background: BackgroundTasks;
124
+ /** SKILL.state (Workstream B): session store — leaf grounding, skill_search, child Ξ. */
125
+ readonly skillStore?: SkillStore;
126
+ /** Root Σ (WS-4): engine-finalize Σ observer, forwarded to createEngine (one seam). */
127
+ readonly onRunState?: (state: RunState) => void;
128
+ /** Ξ composer for repl()-spawned child engines (first hop; deeper hops copy via childRun). */
129
+ readonly getSkillBlock?: (task: string) => string | undefined;
122
130
  /** Session tree panel index; omitted → runs don't appear in the widget. */
123
131
  readonly runRegistry?: RunRegistry;
124
- /** v5 durable memory (session-wide `.rlm` store); omitted → memory off for this tool. */
125
- readonly memory?: MemoryStore;
126
132
  readonly signal?: AbortSignal;
127
133
  readonly onUsage?: (usage: Usage, role: "sub") => void;
128
134
  readonly ensureContext?: () => Promise<void>;
@@ -149,25 +155,40 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
149
155
  const getModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
150
156
  // v5 (audit C6): resolve lazily per call so provider-cap edits via /rlm-config apply live.
151
157
  const currentGates = (): SubcallGates => deps.resolveGates?.() ?? deps.gates;
158
+ // Workstream D: leaf grounding — one closure, self-gating on the live config.
159
+ const groundLeaf = (prompt: string): string => {
160
+ const store = deps.skillStore;
161
+ const cfg = getConfig();
162
+ if (store === undefined || !cfg.enableSkillState) return prompt;
163
+ return groundLeafPrompt(store, cfg, prompt);
164
+ };
152
165
 
153
166
  // Each rlm_query spawns a child RLM with its own sandbox and turn loop, not a flat
154
167
  // one-shot llm_query. The engine is created per call so the child's subcalls, turn
155
168
  // progress and cost deltas land on the emitter the parent invocation is using.
156
- const runChild = (input: RlmInput, inv: Invocation): Promise<RlmResult> => createEngine({
157
- model: getModel(),
158
- llmModel: getLlmModel(),
159
- registry,
160
- config: getConfig(),
161
- signal,
162
- memory: deps.memory,
163
- gates: currentGates(),
164
- // Same emitter the parent subcall node lives on — see SubcallHandlerDeps.runChild.
165
- emitter: inv.emitter,
166
- // Everything a child engine spends is sub-work from this tool's perspective, including
167
- // the child's own root turns — so fold both roles into "sub" rather than casting.
168
- onUsage: onUsage === undefined ? undefined : (usage: Usage) => onUsage(usage, "sub"),
169
- limits: limitsFromConfig(getConfig()),
170
- })(input);
169
+ const runChild = (input: RlmInput, inv: Invocation): Promise<RlmResult> => {
170
+ // Ξ inheritance, first hop (Workstream C): a repl()-spawned child has no parent RlmInput
171
+ // to copy from, so the session store composes the block for its task here. Deeper hops
172
+ // copy input.skillBlock inside childRun (DRY #6 — one construction site).
173
+ const block = input.skillBlock ?? deps.getSkillBlock?.(input.rootPrompt);
174
+ const childInput: RlmInput = block === undefined ? input : { ...input, skillBlock: block };
175
+ return createEngine({
176
+ model: getModel(),
177
+ llmModel: getLlmModel(),
178
+ registry,
179
+ config: getConfig(),
180
+ signal,
181
+ gates: currentGates(),
182
+ // Same emitter the parent subcall node lives on — see SubcallHandlerDeps.runChild.
183
+ emitter: inv.emitter,
184
+ // Everything a child engine spends is sub-work from this tool's perspective, including
185
+ // the child's own root turns — so fold both roles into "sub" rather than casting.
186
+ onUsage: onUsage === undefined ? undefined : (usage: Usage) => onUsage(usage, "sub"),
187
+ limits: limitsFromConfig(getConfig()),
188
+ skillStore: deps.skillStore,
189
+ onRunState: deps.onRunState,
190
+ })(childInput);
191
+ };
171
192
 
172
193
  // Built once: the same closures stay correct across repl() calls because everything
173
194
  // per-invocation is reached through bridgeState.resolve, not captured here.
@@ -188,8 +209,10 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
188
209
  // earlier repl() reaches a child spawned in a later one. Populated before any interrupt can
189
210
  // fire: execute() awaits ensureContext() before getOrCreate().
190
211
  getChildContext: () => sandboxManager.contextPayload ?? undefined,
212
+ // SKILL.state: leaf grounding (Workstream D) + Ξ inheritance for children (DRY #6).
213
+ groundLeaf,
214
+ getSkillBlock: (task) => deps.getSkillBlock?.(task),
191
215
  ledger: sessionLedger,
192
- memory: deps.memory,
193
216
  trackDetached: (task) => background.track(task),
194
217
  });
195
218
 
@@ -322,7 +345,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
322
345
  ...subcallHandlers,
323
346
  ...(contextBundle?.handlers ?? {}),
324
347
  ledgerClaims: () => Promise.resolve(sessionLedger.listClaims()),
325
- memoryOp: (op, args) => Promise.resolve(deps.memory?.serviceOp(op, args) ?? "memory off"),
348
+ // SKILL.state (Workstream E): the model-visible recall surface one function.
349
+ skillSearch: skillSearchHandler(() => (getConfig().enableSkillState ? deps.skillStore : undefined)),
326
350
  });
327
351
 
328
352
  // Detect queue contention AFTER sandbox init (initPromise settled, isExecuting now accurate)
@@ -13,7 +13,7 @@ import { modelRef } from "../config/settings.ts";
13
13
  import { spinnerFrame } from "../ui/theme.ts";
14
14
  import type { RunRegistry } from "../ui/panel/run-registry.ts";
15
15
  import { markdownTheme } from "../ui/theme-adapter.ts";
16
- import { previewText } from "../text/preview.ts";
16
+ import { CALL_PREVIEW_CHARS, previewText } from "../text/preview.ts";
17
17
  import { errorMessage } from "../util/errors.ts";
18
18
  import { type RlmDetails } from "./rlm-details.ts";
19
19
  import { RlmEmitter } from "./rlm-events.ts";
@@ -21,14 +21,12 @@ import { RlmEventAggregator } from "./rlm-aggregator.ts";
21
21
  import { cardHeader, cardStatsLine, renderCollapsedCard } from "./subcall-render.ts";
22
22
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
23
23
 
24
- /** Chars of the prompt shown on the tool call line. */
25
- const CALL_PREVIEW_CHARS = 80;
26
-
27
24
  // ── Parameter schema ──
28
25
 
29
- export const RlmToolParams = Object.freeze(Type.Object({
26
+ const RlmToolParams = Object.freeze(Type.Object({
30
27
  prompt: Type.String({ description: "The task or question for the RLM engine" }),
31
28
  context: Type.Optional(Type.String({ description: "Optional context. If omitted, the working directory is packed into context." })),
29
+ narrative: Type.Optional(Type.Boolean({ description: "History-as-deliverable run (audit/provenance/debug narrative): RunState stays off — the archive is the product." })),
32
30
  }));
33
31
 
34
32
  // ── Rendering helpers ──
@@ -101,6 +99,7 @@ export function createRlmTool(controller: RlmController, runRegistry?: RunRegist
101
99
  const input: StartInput = {
102
100
  rootPrompt: params.prompt,
103
101
  context: params.context ?? undefined,
102
+ narrative: params.narrative,
104
103
  };
105
104
  const { done } = controller.start(ctx, input, emitter);
106
105
  const result = await done;
@@ -14,7 +14,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
14
14
 
15
15
  // ── Glyphs ──
16
16
 
17
- export function headlineStatusGlyph(status: SubcallStatus | "aborted" | "done", theme: Theme): string {
17
+ function headlineStatusGlyph(status: SubcallStatus | "aborted" | "done", theme: Theme): string {
18
18
  switch (status) {
19
19
  case "done": return theme.fg("success", "✓");
20
20
  case "error": return theme.fg("error", "✗");
@@ -7,7 +7,7 @@
7
7
  * subcall accumulation logic.
8
8
  */
9
9
  import type { RlmEmitter, SubcallCreatedEvent, SubcallUpdatedEvent } from "./rlm-events.ts";
10
- import type { RlmSubcall, SubcallStatus } from "./rlm-details.ts";
10
+ import type { RlmSubcall } from "./rlm-details.ts";
11
11
  import { EmitterListener } from "./emitter-listener.ts";
12
12
 
13
13
  type MutableSubcall = {
@@ -153,7 +153,7 @@ export class SubcallStore extends EmitterListener {
153
153
  tokens += node.tokens;
154
154
  tokensIn += node.tokensIn;
155
155
  tokensOut += node.tokensOut;
156
- taken.push(Object.freeze({ ...node, status: node.status as SubcallStatus }));
156
+ taken.push(Object.freeze({ ...node, status: node.status }));
157
157
  this.subcalls.delete(node.id);
158
158
  }
159
159
  }
@@ -4,7 +4,7 @@ import type { Static, TSchema } from "typebox";
4
4
  import { Value } from "typebox/value";
5
5
  import { err, ok, type Result } from "../util/errors.ts";
6
6
 
7
- export interface TextToolResponse<Details> {
7
+ interface TextToolResponse<Details> {
8
8
  readonly content: { readonly type: "text"; readonly text: string }[];
9
9
  readonly details: Details;
10
10
  }
@@ -15,7 +15,7 @@ export function validateToolParams<Schema extends TSchema, Details>(
15
15
  toolName: string,
16
16
  createErrorDetails: (errors: string) => Details,
17
17
  ): Result<Static<Schema>, TextToolResponse<Details>> {
18
- if (Value.Check(schema, rawParams)) return ok(rawParams as Static<Schema>);
18
+ if (Value.Check(schema, rawParams)) return ok(rawParams);
19
19
  const errors = [...Value.Errors(schema, rawParams)]
20
20
  .map((error) => `${error.instancePath}: ${error.message}`)
21
21
  .join("; ");
@@ -25,9 +25,9 @@ export function validateToolParams<Schema extends TSchema, Details>(
25
25
  });
26
26
  }
27
27
 
28
- export type TextUpdateCallback<Details> = (update: TextToolResponse<Details>) => void;
28
+ type TextUpdateCallback<Details> = (update: TextToolResponse<Details>) => void;
29
29
 
30
- export interface ProgressNotifierOptions<Details> {
30
+ interface ProgressNotifierOptions<Details> {
31
31
  readonly onUpdate?: TextUpdateCallback<Details>;
32
32
  readonly getDetails: () => Details;
33
33
  readonly isRunning: (details: Details) => boolean;
@@ -35,7 +35,7 @@ export interface ProgressNotifierOptions<Details> {
35
35
  readonly intervalMs?: number;
36
36
  }
37
37
 
38
- export interface ProgressNotifier {
38
+ interface ProgressNotifier {
39
39
  readonly notify: () => void;
40
40
  readonly start: () => void;
41
41
  readonly stop: () => void;
@@ -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/intro.ts CHANGED
@@ -4,7 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import type { RlmController } from "../mode/rlm-mode.ts";
5
5
  import { modelLabel } from "./status.ts";
6
6
 
7
- export const RLM_GUIDE = `# RLM mode
7
+ const RLM_GUIDE = `# RLM mode
8
8
 
9
9
  {state}
10
10
 
@@ -16,7 +16,7 @@ import type {
16
16
  } from "../../tool/rlm-events.ts";
17
17
  import { EmitterListener } from "../../tool/emitter-listener.ts";
18
18
 
19
- export const TIMELINE_CAP = 200;
19
+ const TIMELINE_CAP = 200;
20
20
 
21
21
  export type TimelineIcon = "spawn" | "phase" | "done" | "error" | "note" | "turn";
22
22
 
@@ -58,7 +58,7 @@ async function pickFromList(
58
58
  });
59
59
  if (initialIndex > 0) list.setSelectedIndex(initialIndex);
60
60
  const isFilterText = (s: string): boolean =>
61
- s.length > 0 && [...s].every((char) => char >= " " && char !== "\x7f");
61
+ s.length > 0 && Array.from(s).every((char) => char >= " " && char !== "\x7f");
62
62
  const isBackspace = (s: string): boolean => s === "\x7f" || s === "\b";
63
63
  list.onSelect = (item) => done(item.value);
64
64
  list.onCancel = () => done(null);
@@ -10,7 +10,7 @@ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
10
10
  import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
11
11
  import { Container, SelectList, Text } from "@earendil-works/pi-tui";
12
12
 
13
- const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
13
+ const LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const);
14
14
  export type SelectableThinkingLevel = (typeof LEVELS)[number];
15
15
 
16
16
  /** Levels the model actually supports, in canonical order. */
@@ -14,7 +14,7 @@ import type { RlmRunStatus, RlmSubcall, SubcallPhase } from "../../tool/rlm-deta
14
14
  import type { RunSnapshot } from "../tree/tree-model.ts";
15
15
  import { TimelineStore } from "../modal/timeline-store.ts";
16
16
 
17
- export interface RunRegistration {
17
+ interface RunRegistration {
18
18
  readonly runId: string;
19
19
  /** Root row label — prompt or code preview. */
20
20
  readonly label: string;
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
  }
@@ -59,7 +59,7 @@ function formatGroup(row: GroupRow, selected: boolean, width: number, theme: The
59
59
  return assembleLine(left, row.tokens, row.tokensIn, row.tokensOut, row.model, selected, width, theme);
60
60
  }
61
61
 
62
- export function formatRow(row: TreeRow, selected: boolean, width: number, theme: Theme): string {
62
+ function formatRow(row: TreeRow, selected: boolean, width: number, theme: Theme): string {
63
63
  return row.type === "group" ? formatGroup(row, selected, width, theme) : formatNode(row, selected, width, theme);
64
64
  }
65
65
 
@@ -28,7 +28,7 @@ const KEYS = Object.freeze({
28
28
  } as const);
29
29
 
30
30
  /** What the panel should do after a keypress. Discriminated — never a boolean soup. */
31
- export type KeyAction =
31
+ type KeyAction =
32
32
  | { readonly type: "swallowed" }
33
33
  | { readonly type: "unfocus" }
34
34
  | { readonly type: "open"; readonly runId: string; readonly nodeId: string };