@hicaru/pi-rlm 0.3.8 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/bridge/handlers/emitting.ts +5 -23
- package/src/bridge/handlers/index.ts +1 -1
- package/src/bridge/handlers/llm-query.ts +21 -22
- package/src/bridge/handlers/rlm-query.ts +9 -31
- package/src/commands/pins.ts +51 -0
- package/src/commands/rlm-config.ts +4 -88
- package/src/commands/rlm-llm.ts +59 -0
- package/src/commands/rlm-rlm.ts +58 -0
- package/src/commands/rlm.ts +2 -2
- package/src/config/settings.ts +13 -3
- package/src/core/engine.ts +16 -2
- package/src/core/iteration.ts +5 -0
- package/src/core/ledger.ts +14 -122
- package/src/core/memory.ts +12 -0
- package/src/index.ts +47 -3
- package/src/mode/rlm-mode.ts +11 -1
- package/src/prompts/glossary.ts +11 -3
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/tool/repl-render.ts +4 -10
- package/src/tool/repl-tool.ts +30 -17
- package/src/tool/rlm-aggregator.ts +16 -3
- package/src/tool/rlm-details.ts +7 -0
- package/src/tool/rlm-events.ts +17 -1
- package/src/tool/rlm-tool.ts +25 -14
- package/src/tool/subcall-render.ts +14 -129
- package/src/tool/subcall-store.ts +11 -1
- package/src/ui/intro.ts +13 -4
- package/src/ui/modal/agent-modal.ts +104 -0
- package/src/ui/modal/modal-view.ts +132 -0
- package/src/ui/modal/timeline-store.ts +85 -0
- package/src/ui/model-picker/drilldown.ts +173 -0
- package/src/ui/model-picker/grouping.ts +81 -0
- package/src/ui/model-picker/levels.ts +63 -0
- package/src/ui/model-picker.ts +7 -197
- package/src/ui/panel/run-registry.ts +135 -0
- package/src/ui/panel/tree-panel.ts +46 -0
- package/src/ui/status.ts +26 -13
- package/src/ui/theme.ts +0 -4
- package/src/ui/tree/tree-model.ts +221 -0
- package/src/ui/tree/tree-rows.ts +73 -0
- package/src/ui/tree/tree-widget.ts +186 -0
package/src/core/ledger.ts
CHANGED
|
@@ -139,111 +139,10 @@ export function taskKey(
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
export const ECHO_STUB: string = Object.freeze(
|
|
142
|
-
"[ledger
|
|
143
|
-
"
|
|
142
|
+
"[ledger: ancestor echo rejected. You are already doing this task. " +
|
|
143
|
+
"Use context / answers / the files already in scope. Do not rlm_query the parent task.]",
|
|
144
144
|
);
|
|
145
145
|
|
|
146
|
-
const RLM_CALL_OPEN = /\brlm_(?:query|batch)\s*\(/g;
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Native `repl()` cells are Python, not a task (audit R1). Pull quoted
|
|
150
|
-
* `rlm_query` / `rlm_batch` arguments so `beginRun` has a task-shaped ancestor
|
|
151
|
-
* instead of `print` / `await_task` tokens. Falls back to the raw cell when no
|
|
152
|
-
* such call is present. `paths=` keyword args are not tasks.
|
|
153
|
-
*/
|
|
154
|
-
export function nativeRunAncestors(code: string): readonly string[] {
|
|
155
|
-
const found = extractRlmTaskPrompts(code);
|
|
156
|
-
return Object.freeze(found.length > 0 ? found : [code]);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function extractRlmTaskPrompts(code: string): readonly string[] {
|
|
160
|
-
const out: string[] = [];
|
|
161
|
-
RLM_CALL_OPEN.lastIndex = 0;
|
|
162
|
-
for (const m of code.matchAll(RLM_CALL_OPEN)) {
|
|
163
|
-
const start = (m.index ?? 0) + m[0].length;
|
|
164
|
-
const body = sliceCallBody(code, start);
|
|
165
|
-
const pathSplit = body.split(/\bpaths\s*=/);
|
|
166
|
-
const taskPart = pathSplit[0] ?? body;
|
|
167
|
-
const strings = quotedStrings(taskPart);
|
|
168
|
-
for (let i = 0; i < strings.length; i++) {
|
|
169
|
-
const s = strings[i];
|
|
170
|
-
if (s !== undefined && s.trim() !== "") out.push(s);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return out;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function sliceCallBody(src: string, start: number): string {
|
|
177
|
-
let depth = 1;
|
|
178
|
-
let i = start;
|
|
179
|
-
while (i < src.length && depth > 0) {
|
|
180
|
-
const c = src[i];
|
|
181
|
-
if (c === "'" || c === '"') {
|
|
182
|
-
i = skipPyString(src, i);
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
if (c === "#") {
|
|
186
|
-
const nl = src.indexOf("\n", i);
|
|
187
|
-
i = nl === -1 ? src.length : nl + 1;
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
if (c === "(") depth++;
|
|
191
|
-
else if (c === ")") depth--;
|
|
192
|
-
i++;
|
|
193
|
-
}
|
|
194
|
-
return src.slice(start, depth === 0 ? i - 1 : i);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function quotedStrings(src: string): readonly string[] {
|
|
198
|
-
const out: string[] = [];
|
|
199
|
-
let i = 0;
|
|
200
|
-
while (i < src.length) {
|
|
201
|
-
const c = src[i];
|
|
202
|
-
if (c === "'" || c === '"') {
|
|
203
|
-
const parsed = readPyString(src, i);
|
|
204
|
-
if (parsed.keep) out.push(parsed.value);
|
|
205
|
-
i = parsed.end;
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
i++;
|
|
209
|
-
}
|
|
210
|
-
return out;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function skipPyString(src: string, quoteAt: number): number {
|
|
214
|
-
return readPyString(src, quoteAt).end;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function readPyString(
|
|
218
|
-
src: string,
|
|
219
|
-
quoteAt: number,
|
|
220
|
-
): { readonly value: string; readonly end: number; readonly keep: boolean } {
|
|
221
|
-
const quote = src[quoteAt] ?? '"';
|
|
222
|
-
const prefix = quoteAt > 0 ? src[quoteAt - 1] : "";
|
|
223
|
-
const keep = prefix !== "f" && prefix !== "F";
|
|
224
|
-
const triple = src.startsWith(quote + quote + quote, quoteAt);
|
|
225
|
-
const delimLen = triple ? 3 : 1;
|
|
226
|
-
const from = quoteAt + delimLen;
|
|
227
|
-
if (triple) {
|
|
228
|
-
const close = src.indexOf(quote + quote + quote, from);
|
|
229
|
-
if (close === -1) return { value: src.slice(from), end: src.length, keep };
|
|
230
|
-
return { value: src.slice(from, close), end: close + 3, keep };
|
|
231
|
-
}
|
|
232
|
-
const parts: string[] = [];
|
|
233
|
-
let j = from;
|
|
234
|
-
while (j < src.length) {
|
|
235
|
-
const ch = src[j];
|
|
236
|
-
if (ch === "\\") {
|
|
237
|
-
parts.push(src[j + 1] ?? "");
|
|
238
|
-
j += 2;
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
if (ch === quote) return { value: parts.join(""), end: j + 1, keep };
|
|
242
|
-
parts.push(ch ?? "");
|
|
243
|
-
j++;
|
|
244
|
-
}
|
|
245
|
-
return { value: parts.join(""), end: src.length, keep };
|
|
246
|
-
}
|
|
247
146
|
|
|
248
147
|
export class TaskLedger {
|
|
249
148
|
private readonly claims = new Map<string, Claim>();
|
|
@@ -252,7 +151,11 @@ export class TaskLedger {
|
|
|
252
151
|
private readonly hitCounts = { exact: 0, echo: 0, near: 0 };
|
|
253
152
|
private rlmRuns = 0;
|
|
254
153
|
|
|
255
|
-
/** Engine marks the active run's root prompt — the ancestor chain for echo detection.
|
|
154
|
+
/** Engine marks the active run's root prompt — the ancestor chain for echo detection.
|
|
155
|
+
* v5 `begin_run` parity: the ONLY producer. `endRun` pops in the engine's finally, so a
|
|
156
|
+
* stack entry exists exactly while that engine is RUNNING — v5's `status in (pending,
|
|
157
|
+
* running)` filter is structural here. Native `repl()` cells never push ancestors: their
|
|
158
|
+
* spawns claim against an empty stack, so an originator can never echo against itself. */
|
|
256
159
|
beginRun(rootPrompt: string): void {
|
|
257
160
|
this.stack.push(normalizePrompt(rootPrompt));
|
|
258
161
|
}
|
|
@@ -261,20 +164,6 @@ export class TaskLedger {
|
|
|
261
164
|
this.stack.pop();
|
|
262
165
|
}
|
|
263
166
|
|
|
264
|
-
/** Native `repl()` path (audit R1): push task-shaped ancestors extracted from the cell. */
|
|
265
|
-
beginNativeCell(code: string): number {
|
|
266
|
-
const ancestors = nativeRunAncestors(code);
|
|
267
|
-
for (let i = 0; i < ancestors.length; i++) {
|
|
268
|
-
const a = ancestors[i];
|
|
269
|
-
if (a !== undefined) this.beginRun(a);
|
|
270
|
-
}
|
|
271
|
-
return ancestors.length;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
endNativeCell(n: number): void {
|
|
275
|
-
for (let i = 0; i < n; i++) this.endRun();
|
|
276
|
-
}
|
|
277
|
-
|
|
278
167
|
/** A child prompt echoing any ancestor (exact or ≥ 0.8 Jaccard) is rejected as a stub. */
|
|
279
168
|
detectEcho(prompt: string): boolean {
|
|
280
169
|
const np = normalizePrompt(prompt);
|
|
@@ -410,11 +299,13 @@ export class TaskLedger {
|
|
|
410
299
|
return Object.freeze({ ...this.hitCounts });
|
|
411
300
|
}
|
|
412
301
|
|
|
413
|
-
/** Compact table for the sandbox's `list_claims()` REPL call.
|
|
302
|
+
/** Compact table for the sandbox's `list_claims()` REPL call. Echo hits are appended —
|
|
303
|
+
* a suppressed spawn must be visible from inside the session (audit BUG-1). */
|
|
414
304
|
listClaims(): string {
|
|
415
|
-
|
|
305
|
+
const echoNote = this.hitCounts.echo > 0 ? ` (echo_rejected=${this.hitCounts.echo})` : "";
|
|
306
|
+
if (this.claims.size === 0) return `ledger: no claims${echoNote}`;
|
|
416
307
|
const lines: string[] = new Array<string>(this.claims.size + 1);
|
|
417
|
-
lines[0] =
|
|
308
|
+
lines[0] = `ledger claims:${echoNote}`;
|
|
418
309
|
let n = 1;
|
|
419
310
|
for (const c of this.claims.values()) {
|
|
420
311
|
lines[n++] = ` ${c.key.slice(0, 8)} ${c.kind} ${c.status} depth=${c.depth} paths=${pathSig(c.paths) || "-"} '${c.prompt.slice(0, PROMPT_PREVIEW)}'`;
|
|
@@ -435,7 +326,8 @@ export class TaskLedger {
|
|
|
435
326
|
if (inflight.length === 0 && done.length === 0 && stackN <= 1) return "";
|
|
436
327
|
const lines: string[] = [
|
|
437
328
|
"[ledger]",
|
|
438
|
-
` depth_stack=${stackN} inflight=${inflight.length} done=${done.length}
|
|
329
|
+
` depth_stack=${stackN} inflight=${inflight.length} done=${done.length}` +
|
|
330
|
+
(this.hitCounts.echo > 0 ? ` echo_rejected=${this.hitCounts.echo}` : ""),
|
|
439
331
|
" rlm_query only for a disjoint goal. ancestor echo is rejected.",
|
|
440
332
|
];
|
|
441
333
|
if (inflight.length > 0) {
|
package/src/core/memory.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { createHash } from "node:crypto";
|
|
|
15
15
|
import { closeSync, openSync, readSync } from "node:fs";
|
|
16
16
|
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { dirname, join, resolve, sep } from "node:path";
|
|
18
|
+
import { formatError } from "../util/errors.ts";
|
|
18
19
|
|
|
19
20
|
const TOK = /[a-z0-9]{2,}/g;
|
|
20
21
|
const EPISODE_CAP = 4_000;
|
|
@@ -123,6 +124,10 @@ export function rootContextPaths(context: unknown, max: number): readonly string
|
|
|
123
124
|
return Object.freeze(out);
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/** Who is calling serviceOp — delegation children read durable notes but never write them
|
|
128
|
+
* (their findings persist via recordEpisode in the rlm_query handler, one per run). */
|
|
129
|
+
export type MemoryScope = "root" | "child";
|
|
130
|
+
|
|
126
131
|
export class MemoryStore {
|
|
127
132
|
readonly enabled: boolean;
|
|
128
133
|
private dir: string | undefined;
|
|
@@ -462,9 +467,16 @@ export class MemoryStore {
|
|
|
462
467
|
serviceOp(
|
|
463
468
|
op: "query" | "add" | "stats",
|
|
464
469
|
args: { readonly query?: string; readonly k?: number; readonly content?: string; readonly paths?: readonly string[]; readonly tags?: readonly string[] },
|
|
470
|
+
scope: MemoryScope = "root",
|
|
465
471
|
): string {
|
|
466
472
|
if (!this.enabled) return "memory disabled";
|
|
467
473
|
if (op === "stats") return JSON.stringify(this.stats());
|
|
474
|
+
if (op === "add" && scope === "child") {
|
|
475
|
+
return formatError(
|
|
476
|
+
"memory.add is root-only — query durable notes with memory.query; " +
|
|
477
|
+
"your final answer is recorded as an episode automatically",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
468
480
|
if (op === "add") {
|
|
469
481
|
const n = this.addNote({ content: args.content ?? "", paths: args.paths ?? [], tags: args.tags ?? [] });
|
|
470
482
|
return n === undefined ? "add skipped (empty content)" : `ok note ${n.id}`;
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { Markdown } from "@earendil-works/pi-tui";
|
|
5
5
|
import { registerRlmCommand } from "./commands/rlm.ts";
|
|
6
6
|
import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
|
|
7
|
+
import { registerRlmLlmCommand } from "./commands/rlm-llm.ts";
|
|
8
|
+
import { registerRlmRlmCommand } from "./commands/rlm-rlm.ts";
|
|
7
9
|
import type { RlmConfig } from "./core/types.ts";
|
|
8
10
|
import { createRlmTool } from "./tool/rlm-tool.ts";
|
|
9
11
|
import { createReplTool } from "./tool/repl-tool.ts";
|
|
@@ -12,6 +14,8 @@ import { RlmController } from "./mode/rlm-mode.ts";
|
|
|
12
14
|
import { cheapestModel } from "./mode/llm-model.ts";
|
|
13
15
|
import { postRlmGuide } from "./ui/intro.ts";
|
|
14
16
|
import { setRlmModeStatus } from "./ui/status.ts";
|
|
17
|
+
import { RunRegistry } from "./ui/panel/run-registry.ts";
|
|
18
|
+
import { installTreePanel } from "./ui/panel/tree-panel.ts";
|
|
15
19
|
import { markdownTheme } from "./ui/theme-adapter.ts";
|
|
16
20
|
import { SANDBOX_WATCHDOG_HEARTBEAT_MS } from "./sandbox/sandbox.ts";
|
|
17
21
|
import { SandboxManager } from "./sandbox/sandbox-manager.ts";
|
|
@@ -98,6 +102,19 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
98
102
|
maxTokens: config.maxTokens,
|
|
99
103
|
maxErrors: config.maxErrors,
|
|
100
104
|
});
|
|
105
|
+
// Session tree panel: every repl cell / rlm run / detached bg task registers here;
|
|
106
|
+
// the below-editor widget + agent modal read from it. Background is persistent
|
|
107
|
+
// and hides itself while idle.
|
|
108
|
+
const runRegistry = new RunRegistry();
|
|
109
|
+
runRegistry.register({
|
|
110
|
+
runId: "background",
|
|
111
|
+
label: "background tasks",
|
|
112
|
+
emitter: background.emitter,
|
|
113
|
+
subcalls: () => background.liveSubcalls(),
|
|
114
|
+
totals: () => background.liveTotals(),
|
|
115
|
+
hideWhenEmpty: true,
|
|
116
|
+
});
|
|
117
|
+
let treePanelInstalled = false;
|
|
101
118
|
// A detached child works in its OWN sandbox, so this one sees no frames and its request
|
|
102
119
|
// watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
|
|
103
120
|
// with it. Keep it alive while detached work is genuinely in flight.
|
|
@@ -161,9 +178,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
161
178
|
// ── Commands ──
|
|
162
179
|
registerRlmCommand(pi, controller);
|
|
163
180
|
registerRlmConfigCommand(pi, controller);
|
|
181
|
+
registerRlmLlmCommand(pi, controller);
|
|
182
|
+
registerRlmRlmCommand(pi, controller);
|
|
164
183
|
|
|
165
184
|
// ── Tool registration ──
|
|
166
|
-
pi.registerTool(createRlmTool(controller));
|
|
185
|
+
pi.registerTool(createRlmTool(controller, runRegistry));
|
|
167
186
|
let guidePosted = false;
|
|
168
187
|
|
|
169
188
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -172,6 +191,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
172
191
|
const persisted = await loadSettings();
|
|
173
192
|
controller.config = mergeConfig(persisted.config);
|
|
174
193
|
controller.savedLlmRef = persisted.llm ?? undefined;
|
|
194
|
+
controller.savedRlmRef = persisted.rlm ?? undefined;
|
|
175
195
|
|
|
176
196
|
// An explicit --rlm flag wins over the persisted setting for this session.
|
|
177
197
|
const flag = pi.getFlag("rlm");
|
|
@@ -188,6 +208,25 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
188
208
|
console.warn(`[rlm] model registry refresh failed: ${errorMessage(err)}`);
|
|
189
209
|
}
|
|
190
210
|
|
|
211
|
+
if (controller.savedRlmRef) {
|
|
212
|
+
const resolvedRlm = resolveModelId(ctx.modelRegistry, controller.savedRlmRef);
|
|
213
|
+
if (resolvedRlm) {
|
|
214
|
+
controller.rlmModel = resolvedRlm;
|
|
215
|
+
} else {
|
|
216
|
+
console.warn(
|
|
217
|
+
`[rlm] pinned rlm model ${controller.savedRlmRef} not in registry; following session model until it reappears`,
|
|
218
|
+
);
|
|
219
|
+
try {
|
|
220
|
+
ctx.ui.notify(
|
|
221
|
+
`RLM: pinned rlm=${controller.savedRlmRef} unavailable — following session model until it is`,
|
|
222
|
+
"warning",
|
|
223
|
+
);
|
|
224
|
+
} catch {
|
|
225
|
+
// Some hosts have no UI at session_start.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
191
230
|
if (controller.savedLlmRef) {
|
|
192
231
|
const resolved = resolveModelId(ctx.modelRegistry, controller.savedLlmRef);
|
|
193
232
|
if (resolved) {
|
|
@@ -259,6 +298,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
259
298
|
gates: resolveSessionGates(),
|
|
260
299
|
resolveGates: resolveSessionGates,
|
|
261
300
|
background,
|
|
301
|
+
runRegistry,
|
|
262
302
|
memory,
|
|
263
303
|
registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
|
|
264
304
|
registerContextBundle: (bundle) => {
|
|
@@ -282,7 +322,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
282
322
|
}
|
|
283
323
|
}
|
|
284
324
|
|
|
285
|
-
setRlmModeStatus(ctx
|
|
325
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
326
|
+
if (!treePanelInstalled) {
|
|
327
|
+
treePanelInstalled = true;
|
|
328
|
+
installTreePanel(ctx, runRegistry);
|
|
329
|
+
}
|
|
286
330
|
if (!guidePosted && controller.enabled) {
|
|
287
331
|
guidePosted = true;
|
|
288
332
|
postRlmGuide(pi, controller);
|
|
@@ -291,7 +335,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
291
335
|
|
|
292
336
|
// ── Keep the footer's context reading live (RLM exists to shrink this number) ──
|
|
293
337
|
pi.on("turn_end", async (_event, ctx) => {
|
|
294
|
-
setRlmModeStatus(ctx
|
|
338
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
295
339
|
});
|
|
296
340
|
|
|
297
341
|
/** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
|
package/src/mode/rlm-mode.ts
CHANGED
|
@@ -35,6 +35,11 @@ export class RlmController {
|
|
|
35
35
|
savedLlmRef: string | undefined;
|
|
36
36
|
/** Set by applyLlmSelection when the user explicitly picks "cheapest (auto)". */
|
|
37
37
|
explicitClearPin = false;
|
|
38
|
+
/** Pinned rlm root/worker model — when unset, child engines follow pi's session model. */
|
|
39
|
+
rlmModel: Model<Api> | undefined;
|
|
40
|
+
savedRlmRef: string | undefined;
|
|
41
|
+
/** Set by applyRlmSelection when the user explicitly picks "(follow session model)". */
|
|
42
|
+
explicitClearRlmPin = false;
|
|
38
43
|
private active: AbortController | null = null;
|
|
39
44
|
/** v5: session admission gates (provider-capped), shared with the repl() tool — set at
|
|
40
45
|
* session_start so BOTH composition roots admit through one pool (audit C1). */
|
|
@@ -78,6 +83,9 @@ export class RlmController {
|
|
|
78
83
|
llm: this.explicitClearPin
|
|
79
84
|
? null
|
|
80
85
|
: (modelRef(this.llmModel) ?? this.savedLlmRef),
|
|
86
|
+
rlm: this.explicitClearRlmPin
|
|
87
|
+
? null
|
|
88
|
+
: (modelRef(this.rlmModel) ?? this.savedRlmRef),
|
|
81
89
|
});
|
|
82
90
|
}
|
|
83
91
|
|
|
@@ -91,7 +99,9 @@ export class RlmController {
|
|
|
91
99
|
|
|
92
100
|
resolveModels(ctx: ExtensionContext): { model: Model<Api>; llm: Model<Api> } | undefined {
|
|
93
101
|
if (!this.llmModel && this.savedLlmRef) this.llmModel = resolveModelId(ctx.modelRegistry, this.savedLlmRef);
|
|
94
|
-
|
|
102
|
+
if (!this.rlmModel && this.savedRlmRef) this.rlmModel = resolveModelId(ctx.modelRegistry, this.savedRlmRef);
|
|
103
|
+
// The rlm pin wins over the session model; unset → follow pi's active model.
|
|
104
|
+
const model = this.rlmModel ?? ctx.model ?? cheapestModel(ctx.modelRegistry);
|
|
95
105
|
if (!model) return undefined;
|
|
96
106
|
const llm = this.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
|
|
97
107
|
return { model, llm };
|
package/src/prompts/glossary.ts
CHANGED
|
@@ -357,9 +357,17 @@ export function replGlossary(
|
|
|
357
357
|
" verified result in `answers` — see the decomposition doctrine below.",
|
|
358
358
|
"- `SHOW_VARS() -> str`: list every variable currently in the REPL (Task handles show as `<Task …>`).",
|
|
359
359
|
"- `list_tasks()`: every Task this REPL created — [{kind, label, done, var}].",
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
...(delegation
|
|
361
|
+
? [
|
|
362
|
+
"- `memory.query(q) -> str`: durable notes under `.rlm/` that survive across sessions.",
|
|
363
|
+
" **READ-ONLY here** — `memory.add` is root-only. Query before re-studying a known area;",
|
|
364
|
+
" your own final answer is recorded as an episode automatically.",
|
|
365
|
+
]
|
|
366
|
+
: [
|
|
367
|
+
"- `memory.query(q) -> str` / `memory.add(text, paths=…, tags=…)`: durable notes under `.rlm/`",
|
|
368
|
+
" that survive across sessions. Query before re-studying a known area; add concise findings",
|
|
369
|
+
" (facts, locations, decisions) — never secrets or API keys (notes persist on disk).",
|
|
370
|
+
]),
|
|
363
371
|
"- `list_claims()`: the live `[ledger]` table of inflight/done agent work.",
|
|
364
372
|
'- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
|
|
365
373
|
' set `answer["content"]` to the answer text and `answer["ready"] = True`.',
|
|
Binary file
|
|
Binary file
|
package/src/tool/repl-render.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
/** repl() tool TUI views —
|
|
1
|
+
/** repl() tool TUI views — the single-line card and the expanded output view.
|
|
2
|
+
* Sub-call trees are not rendered here; the live tree widget owns agent visualization. */
|
|
2
3
|
|
|
3
4
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
5
6
|
import type { ReplDetails } from "./repl-details.ts";
|
|
6
|
-
import { cardHeader, cardStatsLine, renderCollapsedCard
|
|
7
|
+
import { cardHeader, cardStatsLine, renderCollapsedCard } from "./subcall-render.ts";
|
|
7
8
|
|
|
8
9
|
/** Chars of stdout/stderr shown in the expanded view. */
|
|
9
10
|
const EXPANDED_STDOUT_CHARS = 2_000;
|
|
@@ -17,7 +18,7 @@ export function replStats(details: ReplDetails, theme: Theme): string {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
|
|
20
|
-
return renderCollapsedCard("REPL", details.status, replStats(details, theme),
|
|
21
|
+
return renderCollapsedCard("REPL", details.status, replStats(details, theme), theme);
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
// ── Expanded view ──
|
|
@@ -47,12 +48,5 @@ export function renderReplExpanded(details: ReplDetails, theme: Theme): Containe
|
|
|
47
48
|
container.addChild(new Text(theme.fg("error", details.stderr.slice(0, EXPANDED_STDERR_CHARS)), 0, 0));
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
// Sub-call tree
|
|
51
|
-
if (details.subcalls.length > 0) {
|
|
52
|
-
container.addChild(new Spacer(1));
|
|
53
|
-
container.addChild(new Text(theme.fg("muted", "─── Sub-calls ───"), 0, 0));
|
|
54
|
-
container.addChild(renderExpandedSubcallTree(details.subcalls, theme));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
51
|
return container;
|
|
58
52
|
}
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -33,10 +33,12 @@ import type { MemoryStore } from "../core/memory.ts";
|
|
|
33
33
|
import { BackgroundTasks } from "./background-tasks.ts";
|
|
34
34
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
35
35
|
import { RlmEmitter } from "./rlm-events.ts";
|
|
36
|
+
import type { RunRegistry } from "../ui/panel/run-registry.ts";
|
|
36
37
|
import { SubcallStore } from "./subcall-store.ts";
|
|
37
38
|
import type { ReplDetails } from "./repl-details.ts";
|
|
38
39
|
import type { RlmSubcall } from "./rlm-details.ts";
|
|
39
40
|
import { createEngine } from "../core/engine.ts";
|
|
41
|
+
import { modelRef } from "../config/settings.ts";
|
|
40
42
|
import { spinnerFrame } from "../ui/theme.ts";
|
|
41
43
|
import { previewText } from "../text/preview.ts";
|
|
42
44
|
import { errorMessage } from "../util/errors.ts";
|
|
@@ -117,6 +119,8 @@ export interface ReplToolDeps {
|
|
|
117
119
|
readonly resolveGates?: () => SubcallGates;
|
|
118
120
|
/** Session-scoped home for detached spawn() work. */
|
|
119
121
|
readonly background: BackgroundTasks;
|
|
122
|
+
/** Session tree panel index; omitted → runs don't appear in the widget. */
|
|
123
|
+
readonly runRegistry?: RunRegistry;
|
|
120
124
|
/** v5 durable memory (session-wide `.rlm` store); omitted → memory off for this tool. */
|
|
121
125
|
readonly memory?: MemoryStore;
|
|
122
126
|
readonly signal?: AbortSignal;
|
|
@@ -250,6 +254,20 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
250
254
|
|
|
251
255
|
const emitter = new RlmEmitter();
|
|
252
256
|
const store = new SubcallStore(emitter);
|
|
257
|
+
// Surface the cell in the tree panel for its whole lifetime (unregistered in finally).
|
|
258
|
+
const unregisterRun = deps.runRegistry?.register({
|
|
259
|
+
runId: `repl-${_toolCallId}`,
|
|
260
|
+
label: `repl: ${previewText(params.code, 40)}`,
|
|
261
|
+
emitter,
|
|
262
|
+
subcalls: () => store.getSubcalls(),
|
|
263
|
+
totals: () => store.getTotals(),
|
|
264
|
+
// Root = the session's default (pi) model driving this repl cell, own spend only.
|
|
265
|
+
rootModel: () => {
|
|
266
|
+
const m = getModel();
|
|
267
|
+
return modelRef(m) ?? m.id;
|
|
268
|
+
},
|
|
269
|
+
rootTokens: () => store.getRootUsage().tokens,
|
|
270
|
+
});
|
|
253
271
|
let capturedStdout = "";
|
|
254
272
|
let capturedStderr = "";
|
|
255
273
|
let progressStatus: ReplDetails["status"] = "running";
|
|
@@ -319,23 +337,17 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
319
337
|
}
|
|
320
338
|
|
|
321
339
|
const start = Date.now();
|
|
322
|
-
// v5 blackboard (audit C3 /
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
// emitter/limits for the currently running REPL execution.
|
|
334
|
-
bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
|
|
335
|
-
}, execSignal);
|
|
336
|
-
} finally {
|
|
337
|
-
if (ledgerActive) sessionLedger.endNativeCell(ancestorN);
|
|
338
|
-
}
|
|
340
|
+
// v5 blackboard (audit C3 / BUG-1): ancestors are RUNNING engines only — engine.ts
|
|
341
|
+
// brackets each child run with beginRun/endRun. A native cell pushes NOTHING: its
|
|
342
|
+
// spawns claim against an empty stack, so an originator can never echo against
|
|
343
|
+
// itself. Duplicates are caught by the ledger's claim store (exact/near
|
|
344
|
+
// coalescing + rlmBudget demotion), never by silent suppression.
|
|
345
|
+
const result: ReplResult = await sandboxManager.execWithSetup(params.code, () => {
|
|
346
|
+
// Wire per-invocation mutable state only after the serialized exec slot
|
|
347
|
+
// is active. Swapping earlier would let queued repl() calls overwrite
|
|
348
|
+
// emitter/limits for the currently running REPL execution.
|
|
349
|
+
bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
|
|
350
|
+
}, execSignal);
|
|
339
351
|
const elapsed = Date.now() - start;
|
|
340
352
|
capturedStdout = result.stdout;
|
|
341
353
|
capturedStderr = result.stderr;
|
|
@@ -427,6 +439,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
427
439
|
};
|
|
428
440
|
} finally {
|
|
429
441
|
progress.stop();
|
|
442
|
+
unregisterRun?.();
|
|
430
443
|
for (const off of detachTracers) off();
|
|
431
444
|
store.dispose();
|
|
432
445
|
emitter.shutdown();
|
|
@@ -13,16 +13,17 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
|
|
16
|
-
import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, StatusEvent, RootPromptEvent } from "./rlm-events.ts";
|
|
17
|
-
import type { RlmDetails, RlmRunStatus } from "./rlm-details.ts";
|
|
16
|
+
import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, StatusEvent, RootPromptEvent, RootPhaseEvent } from "./rlm-events.ts";
|
|
17
|
+
import type { RlmDetails, RlmRunStatus, SubcallPhase } from "./rlm-details.ts";
|
|
18
18
|
import { EmitterListener } from "./emitter-listener.ts";
|
|
19
|
-
import { SubcallStore } from "./subcall-store.ts";
|
|
19
|
+
import { SubcallStore, type SubcallTotals } from "./subcall-store.ts";
|
|
20
20
|
|
|
21
21
|
export class RlmEventAggregator extends EmitterListener {
|
|
22
22
|
private readonly store: SubcallStore;
|
|
23
23
|
|
|
24
24
|
// Root-level state
|
|
25
25
|
private rootStatus: RlmRunStatus = "running";
|
|
26
|
+
private rootPhase?: SubcallPhase;
|
|
26
27
|
private rootPrompt = "";
|
|
27
28
|
private turnCurrent = 0;
|
|
28
29
|
private turnMax = 0;
|
|
@@ -41,6 +42,7 @@ export class RlmEventAggregator extends EmitterListener {
|
|
|
41
42
|
emitter.onAnswer((e) => this.handleAnswer(e)),
|
|
42
43
|
emitter.onStatus((e) => this.handleStatus(e)),
|
|
43
44
|
emitter.onRootPrompt((e) => this.handleRootPrompt(e)),
|
|
45
|
+
emitter.onRootPhase((e) => this.handleRootPhase(e)),
|
|
44
46
|
]);
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -72,12 +74,18 @@ export class RlmEventAggregator extends EmitterListener {
|
|
|
72
74
|
// No notify — root prompt is set before listeners exist; no TUI re-render needed
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
private handleRootPhase(event: RootPhaseEvent): void {
|
|
78
|
+
this.rootPhase = event.phase;
|
|
79
|
+
this.notify();
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
// ── Read ──
|
|
76
83
|
|
|
77
84
|
/** Snapshot the current accumulated state. O(1). */
|
|
78
85
|
getState(): RlmDetails {
|
|
79
86
|
return {
|
|
80
87
|
status: this.rootStatus,
|
|
88
|
+
rootPhase: this.rootPhase,
|
|
81
89
|
rootPrompt: this.rootPrompt,
|
|
82
90
|
turns: { current: this.turnCurrent, max: this.turnMax },
|
|
83
91
|
subcalls: this.store.getSubcalls(),
|
|
@@ -86,6 +94,11 @@ export class RlmEventAggregator extends EmitterListener {
|
|
|
86
94
|
};
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
/** Root engine's OWN spend (driver-model turns) — never blends sub-call models. */
|
|
98
|
+
getRootUsage(): SubcallTotals {
|
|
99
|
+
return this.store.getRootUsage();
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
// ── Lifecycle ──
|
|
90
103
|
|
|
91
104
|
/** Detach all emitter listeners. Call after the run completes. */
|
package/src/tool/rlm-details.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
export type SubcallKind = "root" | "rlm" | "llm" | "batch" | "tool";
|
|
10
10
|
export type SubcallStatus = "running" | "done" | "error";
|
|
11
|
+
|
|
12
|
+
/** Live activity of a node while status is "running" — powers the tree/modal UI. */
|
|
13
|
+
export type SubcallPhase = "thinking" | "texting" | "repl" | "waiting" | "spawning";
|
|
11
14
|
export type RlmRunStatus = "running" | "done" | "error" | "aborted";
|
|
12
15
|
|
|
13
16
|
export interface RlmSubcall {
|
|
@@ -20,6 +23,8 @@ export interface RlmSubcall {
|
|
|
20
23
|
readonly label: string;
|
|
21
24
|
readonly model?: string;
|
|
22
25
|
readonly status: SubcallStatus;
|
|
26
|
+
/** Current activity while running (undefined = not reported). */
|
|
27
|
+
readonly phase?: SubcallPhase;
|
|
23
28
|
readonly detail?: string;
|
|
24
29
|
readonly args?: string;
|
|
25
30
|
readonly resultPreview?: string;
|
|
@@ -35,6 +40,8 @@ export interface RlmSubcall {
|
|
|
35
40
|
|
|
36
41
|
export interface RlmDetails {
|
|
37
42
|
readonly status: RlmRunStatus;
|
|
43
|
+
/** Root node's live activity phase (root has no subcall entry). */
|
|
44
|
+
readonly rootPhase?: SubcallPhase;
|
|
38
45
|
readonly rootPrompt: string;
|
|
39
46
|
readonly turns: { readonly current: number; readonly max: number };
|
|
40
47
|
readonly subcalls: readonly RlmSubcall[];
|
package/src/tool/rlm-events.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { EventEmitter } from "node:events";
|
|
14
|
-
import type { SubcallKind, SubcallStatus, RlmRunStatus } from "./rlm-details.ts";
|
|
14
|
+
import type { SubcallKind, SubcallStatus, RlmRunStatus, SubcallPhase } from "./rlm-details.ts";
|
|
15
15
|
|
|
16
16
|
// ── Event payloads ──
|
|
17
17
|
|
|
@@ -32,6 +32,8 @@ export interface SubcallCreatedEvent {
|
|
|
32
32
|
export interface SubcallUpdatedEvent {
|
|
33
33
|
readonly id: string;
|
|
34
34
|
readonly status?: SubcallStatus;
|
|
35
|
+
/** Live activity while running (thinking/repl/waiting…). */
|
|
36
|
+
readonly phase?: SubcallPhase;
|
|
35
37
|
readonly detail?: string;
|
|
36
38
|
readonly args?: string;
|
|
37
39
|
readonly resultPreview?: string;
|
|
@@ -67,6 +69,10 @@ export interface RootPromptEvent {
|
|
|
67
69
|
readonly text: string;
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
export interface RootPhaseEvent {
|
|
73
|
+
readonly phase: SubcallPhase;
|
|
74
|
+
}
|
|
75
|
+
|
|
70
76
|
// ── RlmEmitter ──
|
|
71
77
|
|
|
72
78
|
/**
|
|
@@ -127,6 +133,11 @@ export class RlmEmitter {
|
|
|
127
133
|
this.ee.emit("root-prompt", { text } satisfies RootPromptEvent);
|
|
128
134
|
}
|
|
129
135
|
|
|
136
|
+
/** Set the root node's live activity phase (root-only; children use subcall updates). */
|
|
137
|
+
emitRootPhase(phase: SubcallPhase): void {
|
|
138
|
+
this.ee.emit("root-phase", { phase } satisfies RootPhaseEvent);
|
|
139
|
+
}
|
|
140
|
+
|
|
130
141
|
// ── Subscribe (returns unsubscribe function) ──
|
|
131
142
|
|
|
132
143
|
onSubcallCreated(handler: (event: SubcallCreatedEvent) => void): () => void {
|
|
@@ -149,6 +160,11 @@ export class RlmEmitter {
|
|
|
149
160
|
return () => { this.ee.off("root-usage", handler); };
|
|
150
161
|
}
|
|
151
162
|
|
|
163
|
+
onRootPhase(handler: (event: RootPhaseEvent) => void): () => void {
|
|
164
|
+
this.ee.on("root-phase", handler);
|
|
165
|
+
return () => { this.ee.off("root-phase", handler); };
|
|
166
|
+
}
|
|
167
|
+
|
|
152
168
|
onAnswer(handler: (event: AnswerEvent) => void): () => void {
|
|
153
169
|
this.ee.on("answer", handler);
|
|
154
170
|
return () => { this.ee.off("answer", handler); };
|