@hicaru/pi-rlm 0.3.20 → 0.3.21
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 +1 -1
- package/src/commands/rlm.ts +14 -7
- package/src/config/defaults.ts +33 -10
- package/src/config/settings.ts +6 -0
- package/src/config/skillstate.ts +236 -44
- package/src/core/budget.ts +7 -3
- package/src/core/compaction.ts +2 -2
- package/src/core/engine.ts +87 -19
- package/src/core/root-context.ts +74 -21
- package/src/core/root-digest.ts +48 -11
- package/src/core/root-state.ts +39 -12
- package/src/core/run-state.ts +86 -14
- package/src/core/session-archive.ts +174 -0
- package/src/core/types.ts +6 -0
- package/src/index.ts +142 -12
- package/src/mode/rlm-mode.ts +2 -2
- package/src/prompts/glossary.ts +34 -5
- package/src/prompts/native.ts +8 -2
- package/src/prompts/user.ts +4 -3
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/retrieval.py +202 -36
- package/src/sandbox/py/scaffold.py +20 -5
- package/src/sandbox/py/worker.py +1 -1
- package/src/sandbox/sandbox-manager.ts +19 -0
- package/src/text/parsing.ts +133 -2
- package/src/text/tokens.ts +39 -4
- package/src/tool/repl-render.ts +38 -2
- package/src/tool/repl-tool.ts +34 -18
- package/src/tool/subcall-render.ts +7 -4
- package/src/ui/config-panel.ts +2 -2
- package/src/ui/intro.ts +1 -1
- package/src/ui/python-highlight.ts +49 -0
- package/src/ui/stage-cards.ts +192 -0
- package/src/ui/tree/tree-model.ts +69 -19
- package/src/ui/tree/tree-rows.ts +2 -1
- package/src/util/abort.ts +34 -0
- package/src/util/bm25.ts +170 -21
- package/src/util/errors.ts +1 -1
package/src/tool/repl-render.ts
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
|
-
/** repl() tool TUI views —
|
|
1
|
+
/** repl() tool TUI views — call card (code payload), collapsed/expanded result views.
|
|
2
2
|
* Sub-call trees are not rendered here; the live tree widget owns agent visualization. */
|
|
3
3
|
|
|
4
4
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
6
|
+
import { CALL_PREVIEW_CHARS, previewText } from "../text/preview.ts";
|
|
6
7
|
import type { ReplDetails } from "./repl-details.ts";
|
|
8
|
+
import { highlightPython } from "../ui/python-highlight.ts";
|
|
7
9
|
import { cardHeader, cardStatsLine, renderCollapsedCard } from "./subcall-render.ts";
|
|
8
10
|
|
|
9
11
|
/** Chars of stdout/stderr shown in the expanded view. */
|
|
10
12
|
const EXPANDED_STDOUT_CHARS = 2_000;
|
|
11
13
|
const EXPANDED_STDERR_CHARS = 500;
|
|
14
|
+
/** Lines of Python source shown on the collapsed card before the "+N more lines" cut. */
|
|
15
|
+
const CODE_PREVIEW_LINES = 40;
|
|
16
|
+
|
|
17
|
+
// ── Call card ──
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The tool-call row. The collapsed card's payload IS the source — expanding swaps it for the
|
|
21
|
+
* result view (context.expanded). While args still stream in (context.argsComplete false),
|
|
22
|
+
* keep the one-line preview: half-arrived code reads as garbage.
|
|
23
|
+
*/
|
|
24
|
+
export function replCallView(
|
|
25
|
+
args: { readonly code: string },
|
|
26
|
+
theme: Theme,
|
|
27
|
+
context?: { readonly expanded?: boolean; readonly argsComplete?: boolean },
|
|
28
|
+
): Text {
|
|
29
|
+
const header = theme.fg("toolTitle", theme.bold("repl ")) + theme.fg("dim", previewText(args.code, CALL_PREVIEW_CHARS));
|
|
30
|
+
if (context?.expanded === true || context?.argsComplete === false) {
|
|
31
|
+
return new Text(header, 0, 0);
|
|
32
|
+
}
|
|
33
|
+
return new Text([header, "", renderReplCode(args.code, theme)].join("\n"), 0, 0);
|
|
34
|
+
}
|
|
12
35
|
|
|
13
36
|
// ── Collapsed view ──
|
|
14
37
|
|
|
@@ -18,7 +41,20 @@ function replStats(details: ReplDetails, theme: Theme): string {
|
|
|
18
41
|
}
|
|
19
42
|
|
|
20
43
|
export function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
|
|
21
|
-
|
|
44
|
+
// Collapsed shows the CODE (replCallView); expanding reveals the result — say so.
|
|
45
|
+
return renderCollapsedCard("REPL", details.status, replStats(details, theme), theme, "to show result");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The collapsed card's payload — the cell's Python source, capped. Full source lives in the
|
|
50
|
+
* session args; expanding swaps this block for the result view (see replCallView). Sliced
|
|
51
|
+
* BEFORE highlighting so a triple-quoted string cut by the cap can only fall back to plain.
|
|
52
|
+
*/
|
|
53
|
+
export function renderReplCode(code: string, theme: Theme): string {
|
|
54
|
+
const lines = code.split("\n");
|
|
55
|
+
const shown = highlightPython(lines.slice(0, CODE_PREVIEW_LINES).join("\n"), theme);
|
|
56
|
+
const rest = lines.length - CODE_PREVIEW_LINES;
|
|
57
|
+
return rest > 0 ? `${shown}\n${theme.fg("muted", `… +${String(rest)} more lines`)}` : shown;
|
|
22
58
|
}
|
|
23
59
|
|
|
24
60
|
// ── Expanded view ──
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
|
23
23
|
import { buildAddContextHandler, type AddContextHandlerBundle } from "../bridge/add-context.ts";
|
|
24
24
|
import { contextPrefixesIn } from "../context/namespace.ts";
|
|
25
25
|
import type { SubcallGates } from "../util/concurrency.ts";
|
|
26
|
+
import { raceAbort } from "../util/abort.ts";
|
|
26
27
|
import { LimitGuard, limitsFromConfig } from "../core/limits.ts";
|
|
27
28
|
import type { RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
|
|
28
29
|
import { SandboxManager } from "../sandbox/sandbox-manager.ts";
|
|
@@ -40,7 +41,7 @@ import { createEngine } from "../core/engine.ts";
|
|
|
40
41
|
import type { RunState } from "../core/run-state.ts";
|
|
41
42
|
import { modelRef } from "../config/settings.ts";
|
|
42
43
|
import { spinnerFrame } from "../ui/theme.ts";
|
|
43
|
-
import {
|
|
44
|
+
import { previewText } from "../text/preview.ts";
|
|
44
45
|
import { errorMessage } from "../util/errors.ts";
|
|
45
46
|
import {
|
|
46
47
|
groundLeafPrompt,
|
|
@@ -49,7 +50,7 @@ import {
|
|
|
49
50
|
} from "../config/skillstate.ts";
|
|
50
51
|
import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
|
|
51
52
|
import { buildReplResultText, collectReplWarnings } from "./repl-result.ts";
|
|
52
|
-
import { renderReplCollapsed, renderReplExpanded } from "./repl-render.ts";
|
|
53
|
+
import { renderReplCollapsed, renderReplExpanded, replCallView } from "./repl-render.ts";
|
|
53
54
|
import { attachTracer, trace, traceEnabled } from "../util/trace.ts";
|
|
54
55
|
|
|
55
56
|
/** Last non-empty line of a Python traceback — the `TypeError: …` line, not the frames. */
|
|
@@ -129,7 +130,12 @@ interface ReplToolDeps {
|
|
|
129
130
|
readonly getSkillBlock?: (task: string) => string | undefined;
|
|
130
131
|
/** Session tree panel index; omitted → runs don't appear in the widget. */
|
|
131
132
|
readonly runRegistry?: RunRegistry;
|
|
132
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Live abort signal accessor — read per child engine / sub-call / exec, NOT captured at
|
|
135
|
+
* registration. /rlm-stop aborts and ROTATES the session controller, so a stale signal
|
|
136
|
+
* must never outlive the call that started it.
|
|
137
|
+
*/
|
|
138
|
+
readonly getSignal?: () => AbortSignal | undefined;
|
|
133
139
|
readonly onUsage?: (usage: Usage, role: "sub") => void;
|
|
134
140
|
readonly ensureContext?: () => Promise<void>;
|
|
135
141
|
/** Register a reset hook for sandbox death/dispose (e.g. add_context prefix cache). */
|
|
@@ -142,7 +148,9 @@ interface ReplToolDeps {
|
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
|
|
145
|
-
const { sandboxManager, llmModel, registry, getConfig,
|
|
151
|
+
const { sandboxManager, llmModel, registry, getConfig, onUsage, background } = deps;
|
|
152
|
+
// Read per use, never captured: see ReplToolDeps.getSignal.
|
|
153
|
+
const currentSignal = (): AbortSignal | undefined => deps.getSignal?.();
|
|
146
154
|
const bridgeState = new NativeBridgeState(background);
|
|
147
155
|
// v5: one session-wide blackboard for the native repl() path — the same claim/coalesce/
|
|
148
156
|
// demote logic the engine gets per run, shared by every turn and every child it spawns.
|
|
@@ -177,7 +185,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
177
185
|
llmModel: getLlmModel(),
|
|
178
186
|
registry,
|
|
179
187
|
config: getConfig(),
|
|
180
|
-
signal,
|
|
188
|
+
signal: currentSignal(),
|
|
181
189
|
gates: currentGates(),
|
|
182
190
|
// Same emitter the parent subcall node lives on — see SubcallHandlerDeps.runChild.
|
|
183
191
|
emitter: inv.emitter,
|
|
@@ -202,7 +210,9 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
202
210
|
getLlmModel,
|
|
203
211
|
getModel,
|
|
204
212
|
getConfig,
|
|
205
|
-
signal
|
|
213
|
+
get signal() {
|
|
214
|
+
return currentSignal();
|
|
215
|
+
},
|
|
206
216
|
onUsage,
|
|
207
217
|
runChild,
|
|
208
218
|
// The session sandbox's context is the child's world. Read lazily so an add_context from an
|
|
@@ -224,7 +234,9 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
224
234
|
// committed for an append that did not happen.
|
|
225
235
|
getContext: () => sandboxManager.contextPayload,
|
|
226
236
|
parentId: undefined,
|
|
227
|
-
signal
|
|
237
|
+
get signal() {
|
|
238
|
+
return currentSignal();
|
|
239
|
+
},
|
|
228
240
|
// Keep the manager's replay copy in step with the worker's live `context`, and with it
|
|
229
241
|
// whatever a child spawned after this load will inherit.
|
|
230
242
|
onLoaded: (payload) => { sandboxManager.appendContext(payload); },
|
|
@@ -368,12 +380,19 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
368
380
|
// spawns claim against an empty stack, so an originator can never echo against
|
|
369
381
|
// itself. Duplicates are caught by the ledger's claim store (exact/near
|
|
370
382
|
// coalescing + rlmBudget demotion), never by silent suppression.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
383
|
+
// The cell races pi's per-call signal (esc) against /rlm-stop's session signal.
|
|
384
|
+
const cellAbort = raceAbort(execSignal, currentSignal());
|
|
385
|
+
let result: ReplResult;
|
|
386
|
+
try {
|
|
387
|
+
result = await sandboxManager.execWithSetup(params.code, () => {
|
|
388
|
+
// Wire per-invocation mutable state only after the serialized exec slot
|
|
389
|
+
// is active. Swapping earlier would let queued repl() calls overwrite
|
|
390
|
+
// emitter/limits for the currently running REPL execution.
|
|
391
|
+
bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
|
|
392
|
+
}, cellAbort.signal);
|
|
393
|
+
} finally {
|
|
394
|
+
cellAbort.dispose();
|
|
395
|
+
}
|
|
377
396
|
const elapsed = Date.now() - start;
|
|
378
397
|
capturedStdout = result.stdout;
|
|
379
398
|
capturedStderr = result.stderr;
|
|
@@ -470,11 +489,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
470
489
|
}
|
|
471
490
|
},
|
|
472
491
|
|
|
473
|
-
renderCall(args, theme) {
|
|
474
|
-
return
|
|
475
|
-
theme.fg("toolTitle", theme.bold("repl ")) + theme.fg("dim", previewText(args.code, CALL_PREVIEW_CHARS)),
|
|
476
|
-
0, 0,
|
|
477
|
-
);
|
|
492
|
+
renderCall(args, theme, context) {
|
|
493
|
+
return replCallView(args, theme, context);
|
|
478
494
|
},
|
|
479
495
|
|
|
480
496
|
renderResult(result, { expanded }, theme) {
|
|
@@ -64,21 +64,24 @@ export function cardHeader(
|
|
|
64
64
|
* Deliberately `keyText` + the injected theme rather than pi's `keyHint`: `keyHint` colours via
|
|
65
65
|
* pi's module-global theme, which throws when that global is uninitialized — the same jiti
|
|
66
66
|
* hazard `ui/theme-adapter.ts` exists to avoid. `keyText` only reads the keybinding registry.
|
|
67
|
+
* Shared by tool cards AND the [rlm.stage] transcript cards (ui/stage-cards.ts).
|
|
67
68
|
*/
|
|
68
|
-
function expandHint(theme: Theme): string {
|
|
69
|
+
export function expandHint(theme: Theme, action = "to expand"): string {
|
|
69
70
|
// Empty outside a live pi session (the app installs the real binding registry at startup) —
|
|
70
71
|
// the phrase stays the same, only the key prefix drops out.
|
|
71
72
|
const key = keyText("app.tools.expand");
|
|
72
|
-
return theme.fg("muted", key ? `${key}
|
|
73
|
+
return theme.fg("muted", key ? `${key} ${action}` : action);
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/** The collapsed card: header line, then the expand hint once settled.
|
|
76
|
+
/** The collapsed card: header line, then the expand hint once settled. `action` labels what
|
|
77
|
+
* expanding reveals — the repl card says "to show result" (collapsed shows the code instead). */
|
|
76
78
|
export function renderCollapsedCard(
|
|
77
79
|
title: string,
|
|
78
80
|
status: SubcallStatus | "aborted" | "done",
|
|
79
81
|
stats: string,
|
|
80
82
|
theme: Theme,
|
|
83
|
+
action = "to expand",
|
|
81
84
|
): Text {
|
|
82
|
-
const hint = status === "running" ? "" : `\n${expandHint(theme)}`;
|
|
85
|
+
const hint = status === "running" ? "" : `\n${expandHint(theme, action)}`;
|
|
83
86
|
return new Text(`${cardHeader(title, status, stats, theme)}${hint}`, 0, 0);
|
|
84
87
|
}
|
package/src/ui/config-panel.ts
CHANGED
|
@@ -67,9 +67,9 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
|
|
|
67
67
|
"Seed the working directory into context on the first repl() call (otherwise starts empty)."),
|
|
68
68
|
// R5: the window calibrations are rlm.json-only knobs — shown read-only with live values.
|
|
69
69
|
item("__sigma_window__", "Root Σ window (calibration)",
|
|
70
|
-
`keepTurns=${config.rootContextKeepTurns} · elide=${config.rootContextElideChars} · snapshot=${config.rootContextSnapshot ? "on" : "off"}`,
|
|
70
|
+
`keepTurns=${config.rootContextKeepTurns} · elide=${config.rootContextElideChars} · snapshot=${config.rootContextSnapshot ? "on" : "off"} · archive=${config.rootArchiveMaxChars > 0 ? `${Math.round(config.rootArchiveMaxChars / 1000)}k` : "off"}`,
|
|
71
71
|
["rlm.json"],
|
|
72
|
-
"Query-time window calibrations, rlm.json only: rootContextKeepTurns (
|
|
72
|
+
"Query-time window calibrations, rlm.json only: rootContextKeepTurns (4 = default), rootContextElideChars, rootContextSnapshot, rootArchiveMaxChars (0 = archive off; elided turns are otherwise unrecoverable). " +
|
|
73
73
|
"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."),
|
|
74
74
|
item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
|
|
75
75
|
];
|
package/src/ui/intro.ts
CHANGED
|
@@ -14,7 +14,7 @@ const RLM_GUIDE = `# RLM mode
|
|
|
14
14
|
- \`/rlm-llm\` — pin the LLM model for llm_query / llm_batch / map_files
|
|
15
15
|
- \`/rlm-rlm\` — pin the model for rlm_query / rlm_batch child engines (default: session model)
|
|
16
16
|
- \`/rlm-config\` — run limits and engine settings
|
|
17
|
-
- \`/rlm-stop\` —
|
|
17
|
+
- \`/rlm-stop\` — abort all in-flight RLM work: RLM runs, native repl cells and background tasks (use /rlm or Ctrl+Shift+R to leave RLM mode)
|
|
18
18
|
|
|
19
19
|
## Live tree
|
|
20
20
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* python-highlight — minimal Python syntax coloring for the repl card's code payload.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately NOT pi's highlightCode(): that closes over the module-global theme,
|
|
5
|
+
* which is unreliable inside a jiti-loaded plugin (see ui/theme-adapter.ts). The
|
|
6
|
+
* grammar here is a small, honest subset — comments, strings (triple-quoted and
|
|
7
|
+
* prefixed), keywords, numbers, decorators — enough to read a cell at a glance.
|
|
8
|
+
* Colors come from the theme pi hands each render pass.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Master pattern; alternation order is priority — a `#` comment consumes to end-of-line
|
|
15
|
+
* before strings can match, leftmost match wins everywhere else. Group indices:
|
|
16
|
+
* 1 comment, 2 triple-quoted string, 3 single-line string (optional prefix), 4 decorator,
|
|
17
|
+
* 5 number, 6 keyword. Module-level snapshot: a regex is immutable state, not session state.
|
|
18
|
+
*/
|
|
19
|
+
const TOKEN_RE = new RegExp(
|
|
20
|
+
[
|
|
21
|
+
"(#[^\\n]*)", // 1
|
|
22
|
+
'("""[\\s\\S]*?"""|\'\'\'[\\s\\S]*?\'\'\')', // 2
|
|
23
|
+
'([fFrRbBuU]{0,2}"(?:\\\\.|[^"\\\\\\n])*"|[fFrRbBuU]{0,2}\'(?:\\\\.|[^\'\\\\\\n])*\')', // 3
|
|
24
|
+
"(@[A-Za-z_][\\w.]*)", // 4
|
|
25
|
+
"\\b(\\d[\\d_]*(?:\\.\\d+)?(?:[eE][+-]?\\d+)?j?)\\b", // 5
|
|
26
|
+
"\\b(False|None|True|and|as|assert|async|await|break|class|continue|def|del|elif|else|except|" +
|
|
27
|
+
"finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield)\\b", // 6
|
|
28
|
+
].join("|"),
|
|
29
|
+
"g",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** The whole code colored in one pass; spans never overlap, text content is preserved byte-for-byte. */
|
|
33
|
+
export function highlightPython(code: string, theme: Theme): string {
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
let last = 0;
|
|
36
|
+
for (const m of code.matchAll(TOKEN_RE)) {
|
|
37
|
+
const at = m.index ?? 0;
|
|
38
|
+
if (at > last) out.push(code.slice(last, at));
|
|
39
|
+
const [raw = "", comment, triple, str, decorator, num, keyword] = m;
|
|
40
|
+
if (comment !== undefined) out.push(theme.fg("muted", comment));
|
|
41
|
+
else if (triple !== undefined || str !== undefined) out.push(theme.fg("mdCode", raw));
|
|
42
|
+
else if (decorator !== undefined) out.push(theme.fg("mdHeading", decorator));
|
|
43
|
+
else if (num !== undefined) out.push(theme.fg("warning", num));
|
|
44
|
+
else out.push(theme.fg("accent", keyword));
|
|
45
|
+
last = at + raw.length;
|
|
46
|
+
}
|
|
47
|
+
if (last < code.length) out.push(code.slice(last));
|
|
48
|
+
return out.join("");
|
|
49
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stage-cards — [rlm.stage] transcript cards for orchestrator stage transitions.
|
|
3
|
+
*
|
|
4
|
+
* Same visual language as zebra-catch's finding cards: a `**◆ …**` header with
|
|
5
|
+
* status + stats, a body paragraph, `− tag (source)` bullets — sent via
|
|
6
|
+
* `pi.sendMessage({ customType, display: true })` so pi draws the labeled box.
|
|
7
|
+
* The renderer honors `options.expanded`, which pi's CustomMessageComponent
|
|
8
|
+
* re-invokes on the user's ctrl+o (app.tools.expand) — collapsed shows the
|
|
9
|
+
* header + expand hint, expanded shows the full card. Digest bodies embed the
|
|
10
|
+
* root-digest text VERBATIM (glossary wording — never re-worded here).
|
|
11
|
+
*
|
|
12
|
+
* Pure builders + one renderer; emission points live in the rlmExtension
|
|
13
|
+
* closure (src/index.ts) — no session state at module load.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
|
+
import type { MessageRenderer, Theme } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { SkillNoteInput } from "../config/skillstate.ts";
|
|
19
|
+
import { previewText } from "../text/preview.ts";
|
|
20
|
+
import { expandHint } from "../tool/subcall-render.ts";
|
|
21
|
+
import { formatTokens } from "./theme.ts";
|
|
22
|
+
import { markdownTheme } from "./theme-adapter.ts";
|
|
23
|
+
|
|
24
|
+
/** The transcript tag pi renders for these cards — `[rlm.stage]`. */
|
|
25
|
+
export const STAGE_CUSTOM_TYPE = "rlm.stage";
|
|
26
|
+
|
|
27
|
+
/** Distill bullets shown before the "+N more" ellipsis. */
|
|
28
|
+
const DISTILL_BULLET_CAP = 8;
|
|
29
|
+
/** Note text cap inside a distill bullet — the full note lives in skill.state itself. */
|
|
30
|
+
const DISTILL_TEXT_CHARS = 100;
|
|
31
|
+
|
|
32
|
+
/** One orchestrator stage transition. Discriminated — the renderer never guesses. */
|
|
33
|
+
export type StageCardDetails =
|
|
34
|
+
| {
|
|
35
|
+
readonly kind: "digest";
|
|
36
|
+
/** 1-based compaction index (rootDigests counter at emit time). */
|
|
37
|
+
readonly index: number;
|
|
38
|
+
readonly turnsFolded: number;
|
|
39
|
+
/** Host-consumed token estimate for the displaced span (V1 soak probe). */
|
|
40
|
+
readonly tokensBefore: number;
|
|
41
|
+
/** Our own estimate over the same span — divergence here is the probe's signal. */
|
|
42
|
+
readonly tokensBeforeRecomputed: number;
|
|
43
|
+
/** The root-digest summary text, embedded verbatim when expanded. */
|
|
44
|
+
readonly summary: string;
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
readonly kind: "degrade";
|
|
48
|
+
readonly reason: string;
|
|
49
|
+
readonly idleTurns: number;
|
|
50
|
+
readonly idleMax: number;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
readonly kind: "recover";
|
|
54
|
+
readonly fencesAccepted: number;
|
|
55
|
+
readonly fencesTotal: number;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
readonly kind: "distill";
|
|
59
|
+
/** The notes this session contributed (pre-id/hits inputs, as handed to SkillStore.merge). */
|
|
60
|
+
readonly merged: readonly SkillNoteInput[];
|
|
61
|
+
readonly total: number;
|
|
62
|
+
readonly byTag: Readonly<Record<string, number>>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
|
+
return typeof value === "object" && value !== null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Runtime guard for details replayed from old/corrupt session files — fail-soft, never throws. */
|
|
70
|
+
export function isStageCardDetails(value: unknown): value is StageCardDetails {
|
|
71
|
+
if (!isRecord(value)) return false;
|
|
72
|
+
switch (value.kind) {
|
|
73
|
+
case "digest":
|
|
74
|
+
return (
|
|
75
|
+
typeof value.index === "number" &&
|
|
76
|
+
typeof value.turnsFolded === "number" &&
|
|
77
|
+
typeof value.tokensBefore === "number" &&
|
|
78
|
+
typeof value.tokensBeforeRecomputed === "number" &&
|
|
79
|
+
typeof value.summary === "string"
|
|
80
|
+
);
|
|
81
|
+
case "degrade":
|
|
82
|
+
return (
|
|
83
|
+
typeof value.reason === "string" &&
|
|
84
|
+
typeof value.idleTurns === "number" &&
|
|
85
|
+
typeof value.idleMax === "number"
|
|
86
|
+
);
|
|
87
|
+
case "recover":
|
|
88
|
+
return typeof value.fencesAccepted === "number" && typeof value.fencesTotal === "number";
|
|
89
|
+
case "distill": {
|
|
90
|
+
if (typeof value.total !== "number" || !Array.isArray(value.merged) || !isRecord(value.byTag)) return false;
|
|
91
|
+
if (!Object.values(value.byTag).every((v) => typeof v === "number")) return false;
|
|
92
|
+
return value.merged.every((note) => isRecord(note) && typeof note.text === "string");
|
|
93
|
+
}
|
|
94
|
+
default:
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** "gotcha 4 · recipe 3" — tags with a nonzero count, insertion order. */
|
|
100
|
+
function byTagPart(byTag: Readonly<Record<string, number>>): string {
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
for (const [tag, count] of Object.entries(byTag)) {
|
|
103
|
+
if (count > 0) parts.push(`${tag} ${String(count)}`);
|
|
104
|
+
}
|
|
105
|
+
return parts.length === 0 ? "" : ` (${parts.join(" · ")})`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The `**◆ …**` headline — the only thing visible while collapsed. */
|
|
109
|
+
export function stageCardHeaderLine(details: StageCardDetails): string {
|
|
110
|
+
switch (details.kind) {
|
|
111
|
+
case "digest":
|
|
112
|
+
return (
|
|
113
|
+
`**◆ digest #${String(details.index)}** folded ${String(details.turnsFolded)} turns · ` +
|
|
114
|
+
`${formatTokens(details.tokensBefore)} tok (recomputed ${formatTokens(details.tokensBeforeRecomputed)})`
|
|
115
|
+
);
|
|
116
|
+
case "degrade":
|
|
117
|
+
return (
|
|
118
|
+
`**⚠ Σ degraded** idle ${String(details.idleTurns)}/${String(details.idleMax)} fence-eligible turns · ` +
|
|
119
|
+
previewText(details.reason, 80)
|
|
120
|
+
);
|
|
121
|
+
case "recover":
|
|
122
|
+
return `**◆ Σ recovered** fences accepted ${String(details.fencesAccepted)}/${String(details.fencesTotal)}`;
|
|
123
|
+
case "distill":
|
|
124
|
+
return (
|
|
125
|
+
`**◆ skill.state** +${String(details.merged.length)} notes distilled · ` +
|
|
126
|
+
`${String(details.total)} total${byTagPart(details.byTag)}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Body under the header — blank for recover (the headline says it all). */
|
|
132
|
+
function stageCardBody(details: StageCardDetails): string {
|
|
133
|
+
switch (details.kind) {
|
|
134
|
+
case "digest":
|
|
135
|
+
return details.summary;
|
|
136
|
+
case "degrade":
|
|
137
|
+
return "− splices paused; tool outcomes still feed Σ (observe floor)";
|
|
138
|
+
case "distill": {
|
|
139
|
+
const shown = details.merged.slice(0, DISTILL_BULLET_CAP);
|
|
140
|
+
const lines = new Array<string>(shown.length);
|
|
141
|
+
for (let i = 0; i < shown.length; i++) {
|
|
142
|
+
const note = shown[i];
|
|
143
|
+
if (note === undefined) continue;
|
|
144
|
+
lines[i] = `− ${note.tags?.[0] ?? "note"}: ${previewText(note.text, DISTILL_TEXT_CHARS)}`;
|
|
145
|
+
}
|
|
146
|
+
const rest = details.merged.length - shown.length;
|
|
147
|
+
return rest > 0 ? [...lines, `− +${String(rest)} more`].join("\n") : lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
case "recover":
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Full card content — what sendMessage stores and what expanded rendering shows. */
|
|
155
|
+
export function stageCardMarkdown(details: StageCardDetails): string {
|
|
156
|
+
const body = stageCardBody(details);
|
|
157
|
+
return body === "" ? stageCardHeaderLine(details) : `${stageCardHeaderLine(details)}\n\n${body}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Markdown block styled through the injected theme's adapter. */
|
|
161
|
+
function themedMarkdown(text: string, theme: Theme): Markdown {
|
|
162
|
+
return new Markdown(text, 0, 0, markdownTheme(theme), {
|
|
163
|
+
color: (t) => theme.fg("customMessageText", t),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The [rlm.stage] message renderer — mirrors pi's default custom-message box
|
|
169
|
+
* (labeled Box + Markdown) but honors `expanded`: collapsed shows just the
|
|
170
|
+
* headline + expand hint, expanded the full card. Returns undefined on
|
|
171
|
+
* stale/corrupt details so pi falls back to default rendering (fail-soft,
|
|
172
|
+
* host contract).
|
|
173
|
+
*/
|
|
174
|
+
export const renderStageCard: MessageRenderer = (message, options, theme) => {
|
|
175
|
+
try {
|
|
176
|
+
if (!isStageCardDetails(message.details)) return undefined;
|
|
177
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
178
|
+
box.addChild(new Text(theme.fg("customMessageLabel", theme.bold(`[${STAGE_CUSTOM_TYPE}]`)), 0, 0));
|
|
179
|
+
box.addChild(new Spacer(1));
|
|
180
|
+
box.addChild(themedMarkdown(
|
|
181
|
+
options.expanded ? stageCardMarkdown(message.details) : stageCardHeaderLine(message.details),
|
|
182
|
+
theme,
|
|
183
|
+
));
|
|
184
|
+
if (!options.expanded) {
|
|
185
|
+
box.addChild(new Spacer(1));
|
|
186
|
+
box.addChild(new Text(expandHint(theme), 0, 0));
|
|
187
|
+
}
|
|
188
|
+
return box;
|
|
189
|
+
} catch {
|
|
190
|
+
return undefined; // default rendering shows the stored markdown instead
|
|
191
|
+
}
|
|
192
|
+
};
|
|
@@ -5,18 +5,21 @@
|
|
|
5
5
|
* the result and only rebuilds when the underlying store reports a change.
|
|
6
6
|
*
|
|
7
7
|
* Nothing is ever hidden: every sub-call renders as its own row (parity with
|
|
8
|
-
* pi, which shows each concurrent tool call individually) — except
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
8
|
+
* pi, which shows each concurrent tool call individually) — except identical
|
|
9
|
+
* sibling llm leaves (same label+model+status), which consolidate into ONE
|
|
10
|
+
* expandable "label ×N" group row placed at the first member's position: a
|
|
11
|
+
* 16-item batch failure is a single `✗ label ×N · reason` line however many
|
|
12
|
+
* other rows interleave the run. rlm nodes and nodes with children never
|
|
13
|
+
* group; grouped llm leaves move up to the group head, every other row keeps
|
|
14
|
+
* its encounter order. Errors group exactly like successes; diverging reasons
|
|
15
|
+
* collapse to "N failure reasons" (per-item reasons stay in the detail
|
|
16
|
+
* modal). Singletons render as plain rows. Collapsed subtrees are skipped at
|
|
17
|
+
* the user's explicit request (chevron flips). Token rows are own-spend only
|
|
18
|
+
* — a row never blends models.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import type { RlmSubcall, RlmRunStatus, SubcallPhase, SubcallStatus } from "../../tool/rlm-details.ts";
|
|
22
|
+
import { ERROR_PREFIX, isErrorText } from "../../util/errors.ts";
|
|
20
23
|
|
|
21
24
|
/** Immutable per-run view the model consumes (built by RunRegistry from a live store). */
|
|
22
25
|
export interface RunSnapshot {
|
|
@@ -77,6 +80,8 @@ export interface GroupRow {
|
|
|
77
80
|
readonly icon: SubcallStatus | "queued";
|
|
78
81
|
readonly expandable: boolean;
|
|
79
82
|
readonly expanded: boolean;
|
|
83
|
+
/** First-line failure reason shared by every member — error groups only, else "N failure reasons". */
|
|
84
|
+
readonly reason?: string;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
/** Internal build-time entry: a real node or an accumulating group. */
|
|
@@ -94,21 +99,65 @@ const groupable = (sc: RlmSubcall, byParent: ReadonlyMap<string | undefined, Rlm
|
|
|
94
99
|
|
|
95
100
|
const groupKey = (sc: RlmSubcall): string => `${sc.label}|${sc.model ?? ""}|${sc.status}`;
|
|
96
101
|
|
|
97
|
-
/**
|
|
102
|
+
/** Longest reason shown inline in a group header — full text lives in the modal. */
|
|
103
|
+
const REASON_MAX_CHARS = 48;
|
|
104
|
+
|
|
105
|
+
/** First line of an error text, "Error: " prefix stripped — undefined when there is nothing usable. */
|
|
106
|
+
function errorLineOf(sc: RlmSubcall): string | undefined {
|
|
107
|
+
const raw = sc.detail ?? sc.resultPreview;
|
|
108
|
+
if (raw === undefined || raw === "") return undefined;
|
|
109
|
+
const body = isErrorText(raw) ? raw.slice(ERROR_PREFIX.length + 1) : raw;
|
|
110
|
+
const nl = body.indexOf("\n");
|
|
111
|
+
const line = (nl === -1 ? body : body.slice(0, nl)).trim();
|
|
112
|
+
return line === "" ? undefined : line;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The reason all members share, or "N failure reasons" when their errors diverge. */
|
|
116
|
+
function groupReason(members: readonly RlmSubcall[]): string | undefined {
|
|
117
|
+
let first: string | undefined;
|
|
118
|
+
const distinct = new Set<string>();
|
|
119
|
+
for (const m of members) {
|
|
120
|
+
const line = errorLineOf(m);
|
|
121
|
+
if (line === undefined) continue;
|
|
122
|
+
if (first === undefined) first = line;
|
|
123
|
+
distinct.add(line);
|
|
124
|
+
}
|
|
125
|
+
if (first === undefined || distinct.size === 0) return undefined;
|
|
126
|
+
const reason = distinct.size === 1 ? first : `${String(distinct.size)} failure reasons`;
|
|
127
|
+
return reason.length > REASON_MAX_CHARS ? `${reason.slice(0, REASON_MAX_CHARS - 1)}…` : reason;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Consolidate every identical sibling leaf (same label+model+status) into ONE
|
|
132
|
+
* group entry at its first member's position — a 16-item batch failure stays
|
|
133
|
+
* a single `✗ label ×16` line however many other rows interleave the run.
|
|
134
|
+
* Non-grouping siblings keep their encounter order; members keep start order
|
|
135
|
+
* for the expanded view.
|
|
136
|
+
*/
|
|
98
137
|
function partition(children: readonly RlmSubcall[], byParent: ReadonlyMap<string | undefined, RlmSubcall[]>): readonly Entry[] {
|
|
138
|
+
const groups = new Map<string, Extract<Entry, { type: "group" }>>();
|
|
99
139
|
const out: Entry[] = [];
|
|
100
140
|
for (const sc of children) {
|
|
101
|
-
if (groupable(sc, byParent)) {
|
|
102
|
-
const key = groupKey(sc);
|
|
103
|
-
const last = out[out.length - 1];
|
|
104
|
-
if (last !== undefined && last.type === "group" && last.key === key) {
|
|
105
|
-
last.members.push(sc);
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
out.push({ type: "group", key, label: sc.label, model: sc.model, status: sc.status, members: [sc] });
|
|
109
|
-
} else {
|
|
141
|
+
if (!groupable(sc, byParent)) {
|
|
110
142
|
out.push({ type: "node", sc });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const key = groupKey(sc);
|
|
146
|
+
const existing = groups.get(key);
|
|
147
|
+
if (existing !== undefined) {
|
|
148
|
+
existing.members.push(sc);
|
|
149
|
+
continue;
|
|
111
150
|
}
|
|
151
|
+
const group: Extract<Entry, { type: "group" }> = {
|
|
152
|
+
type: "group",
|
|
153
|
+
key,
|
|
154
|
+
label: sc.label,
|
|
155
|
+
model: sc.model,
|
|
156
|
+
status: sc.status,
|
|
157
|
+
members: [sc],
|
|
158
|
+
};
|
|
159
|
+
groups.set(key, group);
|
|
160
|
+
out.push(group);
|
|
112
161
|
}
|
|
113
162
|
return out;
|
|
114
163
|
}
|
|
@@ -218,6 +267,7 @@ export function buildRows(
|
|
|
218
267
|
icon: iconOf(entry.status, entry.members.some((m) => m.phase === "queued") ? "queued" : undefined),
|
|
219
268
|
expandable: true,
|
|
220
269
|
expanded,
|
|
270
|
+
reason: entry.status === "error" ? groupReason(entry.members) : undefined,
|
|
221
271
|
});
|
|
222
272
|
if (!expanded) return;
|
|
223
273
|
for (let i = 0; i < entry.members.length; i++) {
|
package/src/ui/tree/tree-rows.ts
CHANGED
|
@@ -55,7 +55,8 @@ function formatGroup(row: GroupRow, selected: boolean, width: number, theme: The
|
|
|
55
55
|
const chevron = row.expanded ? GLYPHS.expanded : GLYPHS.collapsed;
|
|
56
56
|
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
57
57
|
const icon = row.icon === "done" ? theme.fg("success", GLYPHS.done) : row.icon === "error" ? theme.fg("error", GLYPHS.error) : theme.fg("warning", spinnerFrame());
|
|
58
|
-
const
|
|
58
|
+
const reason = row.reason === undefined ? "" : theme.fg("warning", ` · ${row.reason}`);
|
|
59
|
+
const left = `${cursor} ${row.prefix}${chevron} ${icon} ${row.label} ×${row.count}${reason}`;
|
|
59
60
|
return assembleLine(left, row.tokens, row.tokensIn, row.tokensOut, row.model, selected, width, theme);
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* abort — combine abort signals without leaking listeners.
|
|
3
|
+
*
|
|
4
|
+
* pi hands every tool execution a per-call signal (esc) while the session adds
|
|
5
|
+
* longer-lived controllers (/rlm-stop rotation) — in-flight work must react to both.
|
|
6
|
+
* The longer-lived signal outlives any single call, so its relay listener MUST detach
|
|
7
|
+
* when the awaited work settles, or every call accumulates one listener forever.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface RacedSignal {
|
|
11
|
+
/** Fires when either input aborts; undefined when no input signal was given. */
|
|
12
|
+
readonly signal: AbortSignal | undefined;
|
|
13
|
+
/** Detach relay listeners — call when the awaited work settles. */
|
|
14
|
+
dispose(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function raceAbort(a: AbortSignal | undefined, b: AbortSignal | undefined): RacedSignal {
|
|
18
|
+
if (a === undefined || b === undefined) return { signal: a ?? b, dispose: () => {} };
|
|
19
|
+
const combined = new AbortController();
|
|
20
|
+
const relay = (src: AbortSignal): (() => void) => () => combined.abort(src.reason);
|
|
21
|
+
const onA = relay(a);
|
|
22
|
+
const onB = relay(b);
|
|
23
|
+
if (a.aborted) combined.abort(a.reason);
|
|
24
|
+
if (b.aborted) combined.abort(b.reason);
|
|
25
|
+
a.addEventListener("abort", onA);
|
|
26
|
+
b.addEventListener("abort", onB);
|
|
27
|
+
return {
|
|
28
|
+
signal: combined.signal,
|
|
29
|
+
dispose: () => {
|
|
30
|
+
a.removeEventListener("abort", onA);
|
|
31
|
+
b.removeEventListener("abort", onB);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|