@hicaru/pi-rlm 0.3.16 → 0.3.17
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 +0 -4
- package/README.ru.md +56 -66
- package/README.zh-CN.md +61 -65
- package/package.json +5 -5
- package/src/bridge/add-context.ts +1 -1
- package/src/bridge/handlers/await.ts +13 -22
- package/src/bridge/handlers/completion.ts +27 -5
- package/src/bridge/handlers/emitting.ts +2 -2
- package/src/bridge/handlers/llm-query.ts +46 -68
- package/src/bridge/handlers/rlm-query.ts +14 -84
- package/src/bridge/handlers/task-registry.ts +22 -17
- package/src/bridge/handlers/types.ts +8 -6
- package/src/bridge/model.ts +6 -3
- package/src/commands/rlm-llm.ts +1 -10
- package/src/commands/rlm-rlm.ts +1 -8
- package/src/config/defaults.ts +28 -12
- package/src/config/settings.ts +41 -31
- package/src/config/skillstate.ts +465 -0
- package/src/context/md-cache.ts +1 -1
- package/src/context/merge.ts +1 -1
- package/src/context/namespace.ts +2 -2
- package/src/context/refresh.ts +1 -1
- package/src/context/source-dir.ts +21 -11
- package/src/context/source-doc.ts +1 -1
- package/src/context/source-git.ts +3 -15
- package/src/context/source-text.ts +1 -1
- package/src/context/walk.ts +6 -14
- package/src/core/budget.ts +107 -21
- package/src/core/compaction.ts +44 -1
- package/src/core/engine.ts +141 -84
- package/src/core/iteration.ts +1 -1
- package/src/core/ledger.ts +10 -13
- package/src/core/limits.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/resource-limits.ts +1 -1
- package/src/core/root-context.ts +126 -0
- package/src/core/root-digest.ts +213 -0
- package/src/core/root-state.ts +240 -0
- package/src/core/run-state.ts +577 -0
- package/src/core/types.ts +51 -12
- package/src/index.ts +167 -36
- package/src/mode/llm-model.ts +13 -1
- package/src/mode/native-guards.ts +0 -6
- package/src/mode/rlm-mode.ts +34 -11
- package/src/mode/subagent.ts +5 -5
- package/src/prompts/glossary.ts +41 -25
- package/src/prompts/native.ts +1 -3
- package/src/prompts/system.ts +12 -4
- package/src/sandbox/context-file.ts +1 -1
- package/src/sandbox/interrupts.ts +25 -31
- package/src/sandbox/protocol.ts +14 -20
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +1 -1
- package/src/sandbox/py/scaffold.py +24 -31
- package/src/sandbox/py/worker.py +3 -1
- package/src/sandbox/sandbox-manager.ts +2 -2
- package/src/sandbox/sandbox.ts +21 -4
- package/src/text/agent-text.ts +58 -0
- package/src/text/parsing.ts +35 -3
- package/src/text/preview.ts +3 -0
- package/src/text/repl-output.ts +1 -1
- package/src/tool/background-tasks.ts +1 -1
- package/src/tool/repl-render.ts +1 -1
- package/src/tool/repl-result.ts +1 -1
- package/src/tool/repl-tool.ts +50 -26
- package/src/tool/rlm-tool.ts +4 -5
- package/src/tool/subcall-render.ts +1 -1
- package/src/tool/subcall-store.ts +2 -2
- package/src/tool/tool-utils.ts +5 -5
- package/src/ui/intro.ts +1 -1
- package/src/ui/modal/timeline-store.ts +1 -1
- package/src/ui/model-picker/drilldown.ts +1 -1
- package/src/ui/model-picker/levels.ts +1 -1
- package/src/ui/panel/run-registry.ts +1 -1
- package/src/ui/tree/tree-rows.ts +1 -1
- package/src/ui/tree/tree-widget.ts +1 -1
- package/src/util/bm25.ts +97 -0
- package/src/util/concurrency.ts +1 -1
- package/src/util/errors.ts +1 -1
- package/src/util/retry.ts +22 -7
- package/src/util/state-merge.ts +34 -0
- package/src/util/throttle.ts +1 -1
- package/src/util/type-guards.ts +6 -0
- package/src/core/memory.ts +0 -589
package/src/index.ts
CHANGED
|
@@ -21,15 +21,18 @@ import { SANDBOX_WATCHDOG_HEARTBEAT_MS } from "./sandbox/sandbox.ts";
|
|
|
21
21
|
import { SandboxManager } from "./sandbox/sandbox-manager.ts";
|
|
22
22
|
import { buildSessionGates, type SubcallGates } from "./util/concurrency.ts";
|
|
23
23
|
import { BackgroundTasks } from "./tool/background-tasks.ts";
|
|
24
|
-
import { MemoryStore } from "./core/memory.ts";
|
|
25
|
-
import { modelComplete } from "./bridge/model.ts";
|
|
26
|
-
import { retryPolicy } from "./util/retry.ts";
|
|
27
24
|
import { resolve } from "node:path";
|
|
28
25
|
import { resolveSource } from "./context/resolve.ts";
|
|
29
26
|
import { formatContextListing } from "./context/listing.ts";
|
|
30
27
|
import { extractEditPaths, readDiskFile } from "./context/refresh.ts";
|
|
31
28
|
import type { AddContextHandlerBundle } from "./bridge/add-context.ts";
|
|
32
29
|
import { buildNativeSystemPrompt } from "./prompts/native.ts";
|
|
30
|
+
import { SkillStore, notesFromRunState, xiQuery } from "./config/skillstate.ts";
|
|
31
|
+
import { buildRootDigestCompaction } from "./core/root-digest.ts";
|
|
32
|
+
import { RootStateTracker } from "./core/root-state.ts";
|
|
33
|
+
import { elideStalePayloads, spliceSigmaSnapshot } from "./core/root-context.ts";
|
|
34
|
+
import { agentMessageText, firstLine, textContentOf } from "./text/agent-text.ts";
|
|
35
|
+
import { findStatePatches } from "./text/parsing.ts";
|
|
33
36
|
import { capToolResultText } from "./mode/native-guards.ts";
|
|
34
37
|
import {
|
|
35
38
|
isSubagentChildBypass,
|
|
@@ -69,21 +72,10 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
69
72
|
|
|
70
73
|
// Init synchronously with defaults — ensures commands/tools/handlers register before session_start
|
|
71
74
|
const config = mergeConfig({});
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
// The consolidation LLM + real workspace root are attached in session_start (setLlm/setRoot).
|
|
77
|
-
const memory = new MemoryStore(
|
|
78
|
-
process.cwd(),
|
|
79
|
-
{
|
|
80
|
-
dir: config.memoryDir ?? undefined,
|
|
81
|
-
injectNoteTokens: config.injectNoteTokens,
|
|
82
|
-
evolveEvery: config.evolveEvery,
|
|
83
|
-
},
|
|
84
|
-
config.enableMemory,
|
|
85
|
-
);
|
|
86
|
-
const controller = new RlmController(config, memory);
|
|
75
|
+
const controller = new RlmController(config);
|
|
76
|
+
// Root Σ WS-4: engine-finalize Σ flows into the root tracker (assigned once; buildEngine
|
|
77
|
+
// reads it per run, so /rlm-config and session resets never stale this wire).
|
|
78
|
+
controller.onRunState = (state) => { rootTracker?.absorbEngineState(state); };
|
|
87
79
|
let onSandboxDiscardExtra: (() => void) | undefined;
|
|
88
80
|
const sandboxManager = new SandboxManager({
|
|
89
81
|
execTimeoutS: config.execTimeoutS,
|
|
@@ -116,6 +108,16 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
116
108
|
hideWhenEmpty: true,
|
|
117
109
|
});
|
|
118
110
|
let treePanelInstalled = false;
|
|
111
|
+
/** SKILL.state (Workstream B): session store — hydrated at session_start, flushed at shutdown. */
|
|
112
|
+
let skillStore: SkillStore | undefined;
|
|
113
|
+
/** Root Σ (WS-3/4): the native session's digest-level Σ_t — runtime-derived (tool outcomes,
|
|
114
|
+
* engine mirrors, prompts); lazily born on the first prompt, harvested + dropped at shutdown. */
|
|
115
|
+
let rootTracker: RootStateTracker | undefined;
|
|
116
|
+
// Root Σ WS-5.1 telemetry — journal counters (trace lines only; no TUI surface by design).
|
|
117
|
+
let xiCompositions = 0;
|
|
118
|
+
let rootDigests = 0;
|
|
119
|
+
let elidedMessages = 0;
|
|
120
|
+
let sigmaSplices = 0;
|
|
119
121
|
// A detached child works in its OWN sandbox, so this one sees no frames and its request
|
|
120
122
|
// watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
|
|
121
123
|
// with it. Keep it alive while detached work is genuinely in flight.
|
|
@@ -157,17 +159,25 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
157
159
|
await seedPromise;
|
|
158
160
|
};
|
|
159
161
|
|
|
162
|
+
/** Ξ (Workstream C): BM25 slice of the SkillState store for a query; undefined when off. */
|
|
163
|
+
const composeSkillBlock = (query: string): string | undefined => {
|
|
164
|
+
const cfg = controller.config;
|
|
165
|
+
if (!cfg.enableSkillState || skillStore === undefined) return undefined;
|
|
166
|
+
const block = skillStore.blockFor(query, cfg.skillStateMaxTokens);
|
|
167
|
+
return block === "" ? undefined : block;
|
|
168
|
+
};
|
|
169
|
+
|
|
160
170
|
// ── Message renderers ──
|
|
161
171
|
// Markdown themes are derived from the injected `theme`, never pi's module-global
|
|
162
172
|
// `getMarkdownTheme()` — under jiti that global can be undefined inside a plugin.
|
|
163
173
|
pi.registerMessageRenderer("rlm-answer", (message, _options, theme) =>
|
|
164
|
-
new Markdown(
|
|
174
|
+
new Markdown(textContentOf(message.content), 1, 0, markdownTheme(theme)),
|
|
165
175
|
);
|
|
166
176
|
pi.registerMessageRenderer("rlm-question", (message, _options, theme) =>
|
|
167
|
-
new Markdown(`**RLM question**\n\n${
|
|
177
|
+
new Markdown(`**RLM question**\n\n${textContentOf(message.content)}`, 1, 0, markdownTheme(theme)),
|
|
168
178
|
);
|
|
169
179
|
pi.registerMessageRenderer("rlm-intro", (message, _options, theme) =>
|
|
170
|
-
new Markdown(
|
|
180
|
+
new Markdown(textContentOf(message.content), 1, 0, markdownTheme(theme)),
|
|
171
181
|
);
|
|
172
182
|
|
|
173
183
|
// ── CLI flag: `pi --rlm` / `pi --rlm=false` overrides the persisted mode for this run ──
|
|
@@ -194,11 +204,17 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
194
204
|
controller.savedLlmRef = persisted.llm ?? undefined;
|
|
195
205
|
controller.savedRlmRef = persisted.rlm ?? undefined;
|
|
196
206
|
|
|
207
|
+
// SKILL.state (Workstream B): hydrate the cross-session note store (fail-soft).
|
|
208
|
+
skillStore = controller.config.enableSkillState
|
|
209
|
+
? await SkillStore.hydrate(controller.config.skillStateNotesPerProject)
|
|
210
|
+
: undefined;
|
|
211
|
+
controller.skillStore = skillStore;
|
|
212
|
+
|
|
197
213
|
// An explicit --rlm flag wins over the persisted setting for this session.
|
|
198
214
|
const flag = pi.getFlag("rlm");
|
|
199
215
|
if (typeof flag === "boolean") controller.setConfig(Object.freeze({ ...controller.config, enabled: flag }));
|
|
200
216
|
|
|
201
|
-
// Reload the catalog before the worker
|
|
217
|
+
// Reload the catalog before the worker pick below reads it. Newer pi builds make
|
|
202
218
|
// `getAvailable()` an async-populated snapshot that starts empty, and picking from an empty
|
|
203
219
|
// catalog silently falls back to the root model. Called with no arguments and awaited so it
|
|
204
220
|
// is valid whether `refresh` returns void (current) or a promise (newer); fail-soft, because
|
|
@@ -253,17 +269,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
253
269
|
const llmModel = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
|
|
254
270
|
const model = ctx.model;
|
|
255
271
|
if (llmModel && model) {
|
|
256
|
-
// Consolidation runs on the cheap worker model through the single completion entry point;
|
|
257
|
-
// the workspace root is only known once the session starts.
|
|
258
|
-
const consolidateModel = llmModel;
|
|
259
|
-
memory.setLlm((prompt) =>
|
|
260
|
-
modelComplete([{ role: "user", content: prompt }], {
|
|
261
|
-
model: consolidateModel,
|
|
262
|
-
registry: ctx.modelRegistry,
|
|
263
|
-
retry: retryPolicy(controller.config),
|
|
264
|
-
})
|
|
265
|
-
.then((r) => r.text));
|
|
266
|
-
memory.setRoot(ctx.cwd ?? process.cwd());
|
|
267
272
|
// v5 provider caps (audit C1/C6): ONE resolver shared by both composition roots — the
|
|
268
273
|
// repl() tool and RlmController.start admit through the same pool, each gate capped
|
|
269
274
|
// against the model that actually runs on it (leaves = worker, children = smart).
|
|
@@ -294,7 +299,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
294
299
|
try {
|
|
295
300
|
pi.registerTool(createReplTool({
|
|
296
301
|
sandboxManager,
|
|
302
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pi-ai registry types Model<any>; runtime models conform to Model<Api>
|
|
297
303
|
model,
|
|
304
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- see above
|
|
298
305
|
llmModel,
|
|
299
306
|
getModel: () => controller.resolveModels(ctx)?.model,
|
|
300
307
|
getLlmModel: () => controller.resolveModels(ctx)?.llm,
|
|
@@ -304,7 +311,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
304
311
|
resolveGates: resolveSessionGates,
|
|
305
312
|
background,
|
|
306
313
|
runRegistry,
|
|
307
|
-
|
|
314
|
+
skillStore,
|
|
315
|
+
onRunState: (state) => { rootTracker?.absorbEngineState(state); },
|
|
316
|
+
getSkillBlock: composeSkillBlock,
|
|
308
317
|
registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
|
|
309
318
|
registerContextBundle: (bundle) => {
|
|
310
319
|
contextBundleRef = bundle;
|
|
@@ -350,10 +359,83 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
350
359
|
activeToolNames: typeof pi.getActiveTools === "function" ? pi.getActiveTools() : undefined,
|
|
351
360
|
});
|
|
352
361
|
|
|
362
|
+
/** Root Σ WS-3 gate: config flag AND the bench A/B toggle (RLM_BENCH_NO_ROOTCONTEXT=1 skips). */
|
|
363
|
+
const rootContextActive = (): boolean =>
|
|
364
|
+
controller.config.enableRootContextTransform && process.env.RLM_BENCH_NO_ROOTCONTEXT !== "1";
|
|
365
|
+
|
|
353
366
|
// ── System prompt: native RLM mode addendum (only when the trade holds) ──
|
|
354
367
|
pi.on("before_agent_start", async (event) => {
|
|
355
368
|
if (!nativeTradeHolds()) return;
|
|
356
|
-
|
|
369
|
+
// Root Σ: lazy tracker birth on the session's first prompt (the task IS that prompt).
|
|
370
|
+
rootTracker ??= RootStateTracker.fresh(event.prompt, controller.config.runStateRetryMax);
|
|
371
|
+
// Ξ (Workstream C / Root Σ WS-1): the block is PER-PROMPT text — BM25 ranks the store
|
|
372
|
+
// against the live user prompt (mid-session harvests surface immediately), falling back
|
|
373
|
+
// to the static slice when the prompt is empty. Concatenated here, so NATIVE_PROMPT_STATIC
|
|
374
|
+
// (the module-load snapshot) and its budget math stay untouched.
|
|
375
|
+
const xi = composeSkillBlock(xiQuery(event.prompt, event.systemPrompt.slice(0, 2_000)));
|
|
376
|
+
if (xi !== undefined) {
|
|
377
|
+
xiCompositions += 1;
|
|
378
|
+
if (traceEnabled) trace("root-xi.compose", { total: xiCompositions, chars: xi.length });
|
|
379
|
+
}
|
|
380
|
+
const xiPart = xi === undefined ? "" : `${xi}\n\n`;
|
|
381
|
+
// Root Σ WS-4: the user's latest ask IS the next step by definition; feed the tracker
|
|
382
|
+
// before the turn so the Σ snapshot in this turn's context carries it.
|
|
383
|
+
rootTracker?.setNextStep(event.prompt);
|
|
384
|
+
// Root Σ WS-4.2 (enableRootStateFences): a rejected ΔΣ_t from the previous turn surfaces
|
|
385
|
+
// as an observation message — the same error-as-observation retry ladder engine runs use.
|
|
386
|
+
const observation = rootTracker?.takePendingObservation();
|
|
387
|
+
const message = observation === undefined
|
|
388
|
+
? undefined
|
|
389
|
+
: { customType: "rlm-sigma-observation", content: observation, display: false, details: undefined };
|
|
390
|
+
return {
|
|
391
|
+
...(message === undefined ? {} : { message }),
|
|
392
|
+
systemPrompt: event.systemPrompt + "\n\n" + xiPart + buildNativeSystemPrompt(),
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Root Σ WS-4.2 (default OFF): capture model-proposed ΔΣ_t fences from finalized assistant
|
|
397
|
+
// replies and run them through the ONE patch validator (run-state.ts applyPatch).
|
|
398
|
+
pi.on("message_end", async (event) => {
|
|
399
|
+
const tracker = rootTracker;
|
|
400
|
+
if (tracker === undefined || !controller.config.enableRootStateFences) return;
|
|
401
|
+
if (event.message.role !== "assistant") return;
|
|
402
|
+
const text = agentMessageText(event.message);
|
|
403
|
+
if (text === "") return;
|
|
404
|
+
tracker.applyFences(findStatePatches(text));
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ── Root Σ WS-2: deterministic root compaction (no summary LLM call) ──
|
|
408
|
+
pi.on("session_before_compact", async (event) => {
|
|
409
|
+
if (!controller.config.enableRootDigestCompaction) return undefined;
|
|
410
|
+
if (!nativeTradeHolds() && !controller.enabled) return undefined;
|
|
411
|
+
try {
|
|
412
|
+
const result = buildRootDigestCompaction({
|
|
413
|
+
preparation: event.preparation,
|
|
414
|
+
branchEntries: event.branchEntries,
|
|
415
|
+
config: controller.config,
|
|
416
|
+
store: skillStore,
|
|
417
|
+
});
|
|
418
|
+
if (result !== undefined) {
|
|
419
|
+
rootDigests += 1;
|
|
420
|
+
if (traceEnabled) {
|
|
421
|
+
// V1 soak probe: host-consumed tokensBefore vs our recomputation over the same span.
|
|
422
|
+
trace("root-digest.built", {
|
|
423
|
+
count: rootDigests,
|
|
424
|
+
chars: result.compaction.summary.length,
|
|
425
|
+
tokensBefore: result.compaction.tokensBefore,
|
|
426
|
+
tokensBeforeRecomputed: result.tokensBeforeRecomputed,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// SessionBeforeCompactResult SHAPE: the host consumes `result.compaction` — return the
|
|
431
|
+
// WRAPPER, never the bare CompactionResult (a bare one reads as `compaction: undefined`
|
|
432
|
+
// host-side and silently falls back to the LLM summarizer).
|
|
433
|
+
return result === undefined ? undefined : { compaction: result.compaction };
|
|
434
|
+
} catch (err) {
|
|
435
|
+
// Fail-soft by contract (packages/agent/src/types.ts:191): Pi falls back to its summarizer.
|
|
436
|
+
if (traceEnabled) trace("root-digest.fail", { error: errorMessage(err) });
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
357
439
|
});
|
|
358
440
|
|
|
359
441
|
// ── Context injection: listing of whatever is currently loaded ──
|
|
@@ -367,6 +449,29 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
367
449
|
);
|
|
368
450
|
if (!nativeTradeHolds()) return { messages: filtered };
|
|
369
451
|
|
|
452
|
+
// Root Σ WS-3: per-call A_t on the clone Pi hands us (runner.ts structuredClone — the last
|
|
453
|
+
// returned array wins; the disk transcript is untouched). Elide stale payloads (§5.3),
|
|
454
|
+
// then splice exactly one fresh Σ snapshot. Fail-soft: a throw here must never break a turn.
|
|
455
|
+
if (rootContextActive()) {
|
|
456
|
+
try {
|
|
457
|
+
const elided = elideStalePayloads(filtered, {
|
|
458
|
+
keepTurns: controller.config.rootContextKeepTurns,
|
|
459
|
+
elideChars: controller.config.rootContextElideChars,
|
|
460
|
+
});
|
|
461
|
+
elidedMessages += elided;
|
|
462
|
+
const tracker = rootTracker;
|
|
463
|
+
if (controller.config.rootContextSnapshot && tracker !== undefined && !tracker.isEmpty) {
|
|
464
|
+
spliceSigmaSnapshot(filtered, tracker.snapshot(), tracker.rectifyHint());
|
|
465
|
+
sigmaSplices += 1;
|
|
466
|
+
}
|
|
467
|
+
if (traceEnabled && (elided > 0 || sigmaSplices > 0)) {
|
|
468
|
+
trace("root-context.transform", { elided, total: elidedMessages, splices: sigmaSplices });
|
|
469
|
+
}
|
|
470
|
+
} catch (err) {
|
|
471
|
+
if (traceEnabled) trace("root-context.fail", { error: errorMessage(err) });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
370
475
|
type PiMessage = (typeof filtered)[number];
|
|
371
476
|
|
|
372
477
|
const payload = sandboxManager.contextPayload;
|
|
@@ -398,17 +503,29 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
398
503
|
const MUTATING_FILE_TOOLS = Object.freeze(new Set(["edit", "write"]));
|
|
399
504
|
|
|
400
505
|
pi.on("tool_result", async (event, ctx) => {
|
|
506
|
+
// Root Σ WS-4: the tracker learns the session's shape from outcomes — deterministic, zero
|
|
507
|
+
// model cooperation; fresh results always outrank Σ (paper §5.3 observation override).
|
|
508
|
+
const tracker = rootTracker;
|
|
509
|
+
if (tracker !== undefined) {
|
|
510
|
+
tracker.observeToolResult(
|
|
511
|
+
event.toolName,
|
|
512
|
+
event.isError,
|
|
513
|
+
event.isError ? firstLine(textContentOf(event.content)) : "",
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
401
517
|
// ── Keep RLM context fresh after native file mutations ──
|
|
402
518
|
if (
|
|
403
519
|
nativeTradeHolds()
|
|
404
520
|
&& MUTATING_FILE_TOOLS.has(event.toolName)
|
|
405
|
-
&& event.isError
|
|
521
|
+
&& !event.isError
|
|
406
522
|
) {
|
|
407
523
|
const cwd = resolve(ctx?.cwd ?? process.cwd());
|
|
408
524
|
const paths = extractEditPaths(event.input);
|
|
409
525
|
for (const p of paths) {
|
|
410
526
|
const body = await readDiskFile(p, cwd);
|
|
411
527
|
if (body === null) continue;
|
|
528
|
+
rootTracker?.noteFact(`edited ${p}`); // WS-4: the tracker learns the session's file shape
|
|
412
529
|
try {
|
|
413
530
|
await sandboxManager.refreshFileFromDisk(p, body, cwd);
|
|
414
531
|
// Listing must re-inject if we rewrote payload identity
|
|
@@ -435,6 +552,18 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
435
552
|
|
|
436
553
|
// ── Session shutdown: cleanup ──
|
|
437
554
|
pi.on("session_shutdown", async () => {
|
|
555
|
+
// Root Σ WS-4 harvest symmetry: the root tracker teaches the store exactly like engine
|
|
556
|
+
// runs — the ONE notesFromRunState path — then the existing flush persists everything.
|
|
557
|
+
const tracker = rootTracker;
|
|
558
|
+
if (skillStore !== undefined && tracker !== undefined && tracker.dirty && controller.config.enableSkillState) {
|
|
559
|
+
try {
|
|
560
|
+
skillStore.merge(notesFromRunState(tracker.snapshot()));
|
|
561
|
+
if (traceEnabled) trace("root-harvest.merged", { notes: tracker.snapshot().verifiedFacts.length });
|
|
562
|
+
} catch (err) {
|
|
563
|
+
if (traceEnabled) trace("root-harvest.fail", { error: errorMessage(err) });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
await skillStore?.flush(); // SKILL.state (Workstream B): persist distilled notes
|
|
438
567
|
controller.abort();
|
|
439
568
|
clearInterval(watchdogHeartbeat);
|
|
440
569
|
background.dispose();
|
|
@@ -444,6 +573,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
444
573
|
contextBundleRef = undefined;
|
|
445
574
|
listingPayloadRef = undefined;
|
|
446
575
|
listingInjected = false;
|
|
576
|
+
skillStore = undefined;
|
|
577
|
+
rootTracker = undefined;
|
|
447
578
|
sandboxManager.contextPayload = [];
|
|
448
579
|
});
|
|
449
580
|
}
|
package/src/mode/llm-model.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
12
|
-
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
|
|
14
14
|
/** $/Mtok, input-weighted 3:1 — a sub-call sends a file body and gets back a sentence. */
|
|
15
15
|
function priceOf(model: Model<Api>): number {
|
|
@@ -48,7 +48,19 @@ export function cheapestModel(registry: ModelRegistry): Model<Api> | undefined {
|
|
|
48
48
|
for (let i = 0; i < models.length; i++) {
|
|
49
49
|
const model = models[i];
|
|
50
50
|
if (model === undefined) continue;
|
|
51
|
+
// Sentinel negative cost (openrouter/auto reports −1e6) = unpriceable router. priceOf()
|
|
52
|
+
// would crown it "cheapest" (−4M) — skip; fail closed on unknown prices.
|
|
53
|
+
if ((model.cost?.input ?? 0) < 0 || (model.cost?.output ?? 0) < 0) continue;
|
|
51
54
|
if (best === undefined || compareLlm(model, best) < 0) best = model;
|
|
52
55
|
}
|
|
53
56
|
return best;
|
|
54
57
|
}
|
|
58
|
+
|
|
59
|
+
/** Newer Pi hosts expose session-scoped models; 0.79 peers do not — duck-type safely.
|
|
60
|
+
* DRY: the ONE reflection probe — shared by /rlm-llm and /rlm-rlm. */
|
|
61
|
+
export function sessionScopedModels(
|
|
62
|
+
ctx: ExtensionContext,
|
|
63
|
+
): readonly { readonly model: Model<Api> }[] | undefined {
|
|
64
|
+
const scoped: unknown = Reflect.get(ctx, "scopedModels");
|
|
65
|
+
return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
|
|
66
|
+
}
|
|
@@ -40,12 +40,6 @@ export function isFileReadingCommand(command: string): boolean {
|
|
|
40
40
|
return statementHeads(command).some((h) => READER_COMMANDS.has(h));
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export const BASH_BLOCK_REASON =
|
|
44
|
-
"RLM mode: reading files via bash is blocked — that dumps file content into the root model's " +
|
|
45
|
-
"context. All files are pre-loaded in the REPL `context` variable: use repl({code}) with Python " +
|
|
46
|
-
"string/regex search, and delegate bulk analysis to llm_query / llm_batch / " +
|
|
47
|
-
"llm_query_chunked. bash is for RUNNING things (tests, builds, git).";
|
|
48
|
-
|
|
49
43
|
/** Max chars of tool output forwarded to the root model (≈1K tokens). */
|
|
50
44
|
export const TOOL_RESULT_CAP = 4_000;
|
|
51
45
|
|
package/src/mode/rlm-mode.ts
CHANGED
|
@@ -16,11 +16,12 @@ import { resolveSource } from "../context/resolve.ts";
|
|
|
16
16
|
import { RlmEmitter } from "../tool/rlm-events.ts";
|
|
17
17
|
import { formatError } from "../util/errors.ts";
|
|
18
18
|
import { cheapestModel } from "./llm-model.ts";
|
|
19
|
-
import type {
|
|
19
|
+
import type { SkillStore } from "../config/skillstate.ts";
|
|
20
|
+
import type { RunState } from "../core/run-state.ts";
|
|
20
21
|
import type { RunRlm } from "../core/types.ts";
|
|
21
22
|
import type { SubcallGates } from "../util/concurrency.ts";
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
interface RunHandle {
|
|
24
25
|
readonly abort: () => void;
|
|
25
26
|
readonly done: Promise<RlmResult>;
|
|
26
27
|
}
|
|
@@ -28,16 +29,23 @@ export interface RunHandle {
|
|
|
28
29
|
export interface StartInput {
|
|
29
30
|
readonly rootPrompt: string;
|
|
30
31
|
readonly context: unknown;
|
|
32
|
+
/** History-as-deliverable opt-out (§12.1): RunState stays off — the archive is the product. */
|
|
33
|
+
readonly narrative?: boolean;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export class RlmController {
|
|
34
37
|
llmModel: Model<Api> | undefined;
|
|
35
|
-
savedLlmRef
|
|
38
|
+
savedLlmRef?: string;
|
|
36
39
|
/** Set by applyLlmSelection when the user explicitly picks "cheapest (auto)". */
|
|
37
40
|
explicitClearPin = false;
|
|
38
41
|
/** Pinned rlm root/worker model — when unset, child engines follow pi's session model. */
|
|
39
42
|
rlmModel: Model<Api> | undefined;
|
|
40
|
-
savedRlmRef
|
|
43
|
+
savedRlmRef?: string;
|
|
44
|
+
/** SKILL.state (Workstream B): the session store — hydrated by index.ts at session_start.
|
|
45
|
+
* Undefined ⇒ no Ξ composition, no harvest: the headless path runs exactly as built. */
|
|
46
|
+
skillStore: SkillStore | undefined;
|
|
47
|
+
/** Root Σ (WS-4): set by the extension — engine-finalize Σ flows to the root tracker. */
|
|
48
|
+
onRunState: ((state: RunState) => void) | undefined;
|
|
41
49
|
/** Set by applyRlmSelection when the user explicitly picks "(follow session model)". */
|
|
42
50
|
explicitClearRlmPin = false;
|
|
43
51
|
private active: AbortController | null = null;
|
|
@@ -45,11 +53,7 @@ export class RlmController {
|
|
|
45
53
|
* session_start so BOTH composition roots admit through one pool (audit C1). */
|
|
46
54
|
private sessionGates: (() => SubcallGates) | undefined;
|
|
47
55
|
|
|
48
|
-
constructor(
|
|
49
|
-
public config: RlmConfig,
|
|
50
|
-
/** v5 durable memory — shared with the repl tool so child runs replay/persist too. */
|
|
51
|
-
public readonly memory?: MemoryStore,
|
|
52
|
-
) {}
|
|
56
|
+
constructor(public config: RlmConfig) {}
|
|
53
57
|
|
|
54
58
|
setSessionGates(getGates: () => SubcallGates): void {
|
|
55
59
|
this.sessionGates = getGates;
|
|
@@ -101,9 +105,12 @@ export class RlmController {
|
|
|
101
105
|
if (!this.llmModel && this.savedLlmRef) this.llmModel = resolveModelId(ctx.modelRegistry, this.savedLlmRef);
|
|
102
106
|
if (!this.rlmModel && this.savedRlmRef) this.rlmModel = resolveModelId(ctx.modelRegistry, this.savedRlmRef);
|
|
103
107
|
// The rlm pin wins over the session model; unset → follow pi's active model.
|
|
108
|
+
// pi's ctx.model is typed Model<any>; runtime models conform to Model<Api> — the two
|
|
109
|
+
// directives below mark exactly where that external-boundary any enters and leaves.
|
|
104
110
|
const model = this.rlmModel ?? ctx.model ?? cheapestModel(ctx.modelRegistry);
|
|
105
111
|
if (!model) return undefined;
|
|
106
112
|
const llm = this.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
107
114
|
return { model, llm };
|
|
108
115
|
}
|
|
109
116
|
|
|
@@ -112,6 +119,14 @@ export class RlmController {
|
|
|
112
119
|
return createEngine(deps);
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
/** Ξ (Workstream C): BM25 slice of the session SkillState for a root prompt; undefined when
|
|
123
|
+
* the store is absent/disabled or nothing is relevant. */
|
|
124
|
+
private skillBlockFor(query: string): string | undefined {
|
|
125
|
+
if (this.skillStore === undefined || !this.config.enableSkillState) return undefined;
|
|
126
|
+
const block = this.skillStore.blockFor(query, this.config.skillStateMaxTokens);
|
|
127
|
+
return block === "" ? undefined : block;
|
|
128
|
+
}
|
|
129
|
+
|
|
115
130
|
/** The ONE engine construction path for this controller (DRY #6 — a second path that
|
|
116
131
|
* forgets to grow is exactly how issue #4 and audit C1 happened). Protected so tests can
|
|
117
132
|
* subclass and assert the wiring without touching the network. */
|
|
@@ -129,8 +144,9 @@ export class RlmController {
|
|
|
129
144
|
signal: args.signal,
|
|
130
145
|
emitter: args.emitter,
|
|
131
146
|
limits: limitsFromConfig(this.config),
|
|
132
|
-
memory: this.memory,
|
|
133
147
|
gates: this.sessionGates?.(),
|
|
148
|
+
skillStore: this.skillStore,
|
|
149
|
+
onRunState: this.onRunState,
|
|
134
150
|
});
|
|
135
151
|
}
|
|
136
152
|
|
|
@@ -159,7 +175,14 @@ export class RlmController {
|
|
|
159
175
|
signal: abortController.signal,
|
|
160
176
|
emitter: emitter ?? new RlmEmitter(),
|
|
161
177
|
});
|
|
162
|
-
|
|
178
|
+
const skillBlock = this.skillBlockFor(input.rootPrompt);
|
|
179
|
+
return await engine({
|
|
180
|
+
rootPrompt: input.rootPrompt,
|
|
181
|
+
context: contextValue,
|
|
182
|
+
depth: 0,
|
|
183
|
+
...(skillBlock === undefined ? {} : { skillBlock }), // Ξ (Workstream C)
|
|
184
|
+
...(input.narrative === undefined ? {} : { narrative: input.narrative }),
|
|
185
|
+
});
|
|
163
186
|
})().finally(() => {
|
|
164
187
|
if (this.active === abortController) this.active = null;
|
|
165
188
|
});
|
package/src/mode/subagent.ts
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { DEFAULT_CONFIG } from "../config/defaults.ts";
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
|
|
15
|
+
const RLM_FORCE_IN_SUBAGENT_ENV = "PI_RLM_FORCE_IN_SUBAGENT";
|
|
16
|
+
const RLM_DEPTH_ENV = "PI_RLM_DEPTH";
|
|
17
17
|
|
|
18
18
|
/** Cross-process depth from env. Missing / invalid → 0. */
|
|
19
19
|
export function processRlmDepth(): number {
|
|
@@ -48,7 +48,7 @@ export function commitSubagentForceActivation(maxDepth: number = DEFAULT_CONFIG.
|
|
|
48
48
|
if (process.env[RLM_FORCE_IN_SUBAGENT_ENV] !== "1") return;
|
|
49
49
|
if (processRlmDepth() >= maxDepth) return;
|
|
50
50
|
const next = processRlmDepth() + 1;
|
|
51
|
-
|
|
51
|
+
Reflect.deleteProperty(process.env, RLM_FORCE_IN_SUBAGENT_ENV);
|
|
52
52
|
process.env[RLM_DEPTH_ENV] = String(next);
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -59,7 +59,7 @@ export function commitSubagentForceActivation(maxDepth: number = DEFAULT_CONFIG.
|
|
|
59
59
|
*/
|
|
60
60
|
export function shouldEnforceNativeReaderBlock(opts: {
|
|
61
61
|
readonly enabled: boolean;
|
|
62
|
-
readonly activeToolNames
|
|
62
|
+
readonly activeToolNames?: readonly string[];
|
|
63
63
|
}): boolean {
|
|
64
64
|
if (!opts.enabled) return false;
|
|
65
65
|
const names = opts.activeToolNames;
|