@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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/commands/rlm.ts +14 -7
  3. package/src/config/defaults.ts +33 -10
  4. package/src/config/settings.ts +6 -0
  5. package/src/config/skillstate.ts +236 -44
  6. package/src/core/budget.ts +7 -3
  7. package/src/core/compaction.ts +2 -2
  8. package/src/core/engine.ts +87 -19
  9. package/src/core/root-context.ts +74 -21
  10. package/src/core/root-digest.ts +48 -11
  11. package/src/core/root-state.ts +39 -12
  12. package/src/core/run-state.ts +86 -14
  13. package/src/core/session-archive.ts +174 -0
  14. package/src/core/types.ts +6 -0
  15. package/src/index.ts +142 -12
  16. package/src/mode/rlm-mode.ts +2 -2
  17. package/src/prompts/glossary.ts +34 -5
  18. package/src/prompts/native.ts +8 -2
  19. package/src/prompts/user.ts +4 -3
  20. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  21. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  22. package/src/sandbox/py/retrieval.py +202 -36
  23. package/src/sandbox/py/scaffold.py +20 -5
  24. package/src/sandbox/py/worker.py +1 -1
  25. package/src/sandbox/sandbox-manager.ts +19 -0
  26. package/src/text/parsing.ts +133 -2
  27. package/src/text/tokens.ts +39 -4
  28. package/src/tool/repl-render.ts +38 -2
  29. package/src/tool/repl-tool.ts +34 -18
  30. package/src/tool/subcall-render.ts +7 -4
  31. package/src/ui/config-panel.ts +2 -2
  32. package/src/ui/intro.ts +1 -1
  33. package/src/ui/python-highlight.ts +49 -0
  34. package/src/ui/stage-cards.ts +192 -0
  35. package/src/ui/tree/tree-model.ts +69 -19
  36. package/src/ui/tree/tree-rows.ts +2 -1
  37. package/src/util/abort.ts +34 -0
  38. package/src/util/bm25.ts +170 -21
  39. package/src/util/errors.ts +1 -1
@@ -26,11 +26,11 @@ import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox
26
26
  import type { ReplResult } from "../sandbox/protocol.ts";
27
27
  import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
28
28
  import { previewStdout, previewText } from "../text/preview.ts";
29
- import { findReplBlocks, stripStateFences } from "../text/parsing.ts";
29
+ import { finalVarRepairCode, findFinalTag, findReplBlocks, stripStateFences } from "../text/parsing.ts";
30
30
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
31
31
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, latestStdoutOf, turnHadError } from "./answer.ts";
32
32
  import { compactHistory, elideOldToolPayloads, rebaseWithState, shouldCompact } from "./compaction.ts";
33
- import { applyStatePatches, freshRunState, runStateTurnBlock, type RunState, type RunStateMode } from "./run-state.ts";
33
+ import { applyStatePatches, compactJSON, freshRunState, runStateTurnBlock, SIGMA_UNCHANGED_LINE, type RunState, type RunStateMode } from "./run-state.ts";
34
34
  import { findStatePatches } from "../text/parsing.ts";
35
35
  import { complete1, completeDeps } from "../bridge/handlers/completion.ts";
36
36
  import type { SubcallHandlerDeps } from "../bridge/handlers/types.ts";
@@ -202,12 +202,27 @@ export function createEngine(deps: EngineDeps): RunRlm {
202
202
  if (detachedInFlight === 0) detachedIdle?.();
203
203
  }
204
204
  },
205
- // SKILL.state: leaf grounding (Workstream D, DRY #1) + the parent Ξ for children (C, DRY #6).
205
+ // SKILL.state: leaf grounding (Workstream D, DRY #1) + the Ξ block for children (C, DRY #6).
206
206
  groundLeaf:
207
207
  skillStore === undefined || !deps.config.enableSkillState
208
208
  ? undefined
209
209
  : (prompt: string) => groundLeafPrompt(skillStore, deps.config, prompt),
210
- getSkillBlock: () => input.skillBlock,
210
+ // The child's Ξ is re-ranked against the CHILD's task from the live store — inheriting
211
+ // the parent's block verbatim wasted the Ξ budget on facts ranked for someone else's
212
+ // question. Falls back to the inherited block when re-ranking comes up empty (or for
213
+ // the degenerate same-task call), and to `undefined` semantics when the store is off.
214
+ getSkillBlock:
215
+ skillStore === undefined || !deps.config.enableSkillState
216
+ ? () => input.skillBlock
217
+ : (task: string): string | undefined => {
218
+ if (task === input.rootPrompt || task.trim() === "") return input.skillBlock;
219
+ const block = skillStore.blockFor(
220
+ task,
221
+ deps.config.skillStateMaxTokens,
222
+ deps.config.skillStateXiMinScore,
223
+ );
224
+ return block !== "" ? block : input.skillBlock;
225
+ },
211
226
  } satisfies SubcallHandlerDeps;
212
227
  const subcalls = createSubcallHandlers(subcallDeps, taskRegistry);
213
228
 
@@ -220,8 +235,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
220
235
  // off (one source, two sinks; never a second harvest implementation).
221
236
  deps.onRunState?.(runStateMode.state);
222
237
  if (skillStore === undefined || !deps.config.enableSkillState) return;
223
- skillStore.merge(notesFromRunState(runStateMode.state));
238
+ // Depth-tagged deterministic harvest runs at every depth; the LLM distill is ROOT-ONLY —
239
+ // every child distilling into the same project section crowded the note cap with
240
+ // sub-task fragments (A-Mem store hygiene). Fail-soft: a distill failure must never
241
+ // damage a finished run.
242
+ skillStore.merge(notesFromRunState(runStateMode.state, input.depth));
224
243
  if (!deps.config.enableSkillStateDistill || deps.signal?.aborted === true) return;
244
+ if (input.depth > 0) return;
225
245
  try {
226
246
  const raw = await complete1(
227
247
  invocation,
@@ -229,7 +249,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
229
249
  () => {},
230
250
  completeDeps(subcallDeps),
231
251
  );
232
- const parsed = parseDistilledNotes(raw);
252
+ const parsed = parseDistilledNotes(raw, runStateMode.state.task);
233
253
  if (parsed.length > 0) skillStore.merge(parsed);
234
254
  } catch {
235
255
  // fail-soft by design
@@ -281,6 +301,11 @@ export function createEngine(deps: EngineDeps): RunRlm {
281
301
  let softNoteTurn = -1;
282
302
  // H3: retrieval-discipline coach — one-shot per run; children inherit it via the same loop.
283
303
  let sawRetrieval = false;
304
+ let sawDelegation = false;
305
+ // Σ economics: the compact JSON of the Σ last sent to the model — an unchanged Σ
306
+ // re-sends as SIGMA_UNCHANGED_LINE instead of the full block. Reset by compaction/rebase
307
+ // (the rebase message re-anchors Σ verbatim) and by degrade (no more Σ blocks at all).
308
+ let lastSigmaSent: string | undefined;
284
309
  let retrievalNudged = false;
285
310
  // Verification-discipline coach (enableVerificationNudge, default OFF): one coached redo
286
311
  // when an early finalize looks like the confident-wrong bench shape.
@@ -368,8 +393,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
368
393
  history = elideOldToolPayloads(history);
369
394
  const compactionDeps = {
370
395
  // Summarisation is done by the cheap worker model; compaction fires on the ABSOLUTE
371
- // COMPACTION_CEILING_TOKENS (limits.ts): ≤256k windows never compact, larger ones
372
- // compact exactly at 256k (LO rule 2025-09-09).
396
+ // COMPACTION_CEILING_TOKENS (limits.ts): ≤1M windows never compact, larger ones
397
+ // compact exactly at 1M (LO rule 2025-09-09).
373
398
  model: deps.llmModel,
374
399
  registry: deps.registry,
375
400
  contextWindow: model.contextWindow,
@@ -382,6 +407,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
382
407
  history = runStateMode.kind === "active"
383
408
  ? rebaseWithState(history, runStateMode.state, ++compactions)
384
409
  : await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
410
+ lastSigmaSent = undefined; // the rebase message re-anchors Σ — full block next turn
385
411
  }
386
412
  }
387
413
 
@@ -401,15 +427,22 @@ export function createEngine(deps: EngineDeps): RunRlm {
401
427
 
402
428
  // v5 [ledger] blackboard — silent ("") when it has nothing to say.
403
429
  const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
430
+ const sigmaJson = runStateMode.kind === "active" ? compactJSON(runStateMode.state) : "";
404
431
  // H3: after two retrieval-free turns, inject the coach nudge exactly once, for one turn.
405
- const nudgeNow = i >= 2 && !sawRetrieval && !retrievalNudged;
432
+ // Surface-aware: delegation children (depth > 0) have NO search/grep_context — nudging
433
+ // them taught a tool their prompt elsewhere forbids (NameError bait).
434
+ const nudgeNow = input.depth === 0 && i >= 2 && !sawRetrieval && !retrievalNudged;
406
435
  if (nudgeNow) retrievalNudged = true;
407
436
  const notes =
408
437
  [
409
438
  i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
410
439
  // Workstream A: from iteration 3 the run conditions on Σ (A_t = (P, Σ_t, O_t)) and
411
440
  // the state fence is requested — exploratory cold-start stays as-built (§12.2).
412
- runStateMode.kind === "active" && i >= 2 ? runStateTurnBlock(runStateMode.state) : undefined,
441
+ // Σ economics: an unchanged Σ re-sends as a one-line marker, not the full JSON —
442
+ // full re-send happens only after compaction/rebase (lastSigmaSent reset below).
443
+ runStateMode.kind === "active" && i >= 2
444
+ ? (sigmaJson === lastSigmaSent ? SIGMA_UNCHANGED_LINE : runStateTurnBlock(runStateMode.state))
445
+ : undefined,
413
446
  ledgerBlock === "" ? undefined : ledgerBlock,
414
447
  nudgeNow ? RETRIEVAL_NUDGE : undefined,
415
448
  verificationNudgePending ? VERIFICATION_NUDGE : undefined,
@@ -424,6 +457,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
424
457
  .filter((s): s is string => s !== undefined)
425
458
  .join("\n\n") || undefined;
426
459
  verificationNudgePending = false;
460
+ if (runStateMode.kind === "active" && i >= 2) lastSigmaSent = sigmaJson;
427
461
  appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, notes));
428
462
 
429
463
  const turn = await runTurn(history, sandbox, {
@@ -439,6 +473,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
439
473
  onPhase: reportPhase,
440
474
  });
441
475
  if (turn.blocks.some((b) => /\b(?:search|grep_context)\s*\(/.test(b))) sawRetrieval = true;
476
+ if (turn.blocks.some((b) => /\b(?:llm_query|llm_batch|rlm_query|rlm_batch|map_files|llm_query_chunked|llm_map_reduce|spawn)\s*\(/.test(b))) sawDelegation = true;
442
477
  const allBlocks = turn.blocks.length > 0
443
478
  ? turn.blocks.map((b) => previewText(b, 400)).join("\n")
444
479
  : previewText(turn.response, 400);
@@ -465,13 +500,33 @@ export function createEngine(deps: EngineDeps): RunRlm {
465
500
  const turnStdout = latestStdoutOf(turn.results);
466
501
  if (turnStdout) lastStdout = turnStdout;
467
502
  completedTurns = i + 1;
468
- const final = finalAnswerOf(turn.results);
503
+ let final = finalAnswerOf(turn.results);
504
+ // RLM-paper App. A template repair: a finalize written as FINAL(x) / FINAL_VAR(v)
505
+ // instead of an `answer` flip is converted (16%/13% of small-model turns in the
506
+ // paper). The repair note rides the next observation so the model learns the channel.
507
+ let repairNote: string | undefined;
508
+ if (final == null && sandbox !== undefined) {
509
+ const tag = findFinalTag(turn.response);
510
+ if (tag?.kind === "final") {
511
+ final = tag.value;
512
+ repairNote = "[runtime] Converted FINAL(...) from your prose into the final answer — flip `answer[\"ready\"]` directly next time.";
513
+ } else if (tag?.kind === "final_var") {
514
+ const repaired = await sandbox.exec(finalVarRepairCode(tag.value));
515
+ const recovered = finalAnswerOf([repaired]);
516
+ if (recovered !== null && !recovered.startsWith("Error:")) {
517
+ final = recovered;
518
+ repairNote = `[runtime] Converted FINAL_VAR(${tag.value}) into the final answer — flip \`answer["ready"]\` directly next time.`;
519
+ } else if (recovered !== null) {
520
+ repairNote = recovered;
521
+ }
522
+ }
523
+ }
469
524
  if (final != null) {
470
- // Verification-discipline nudge (enableVerificationNudge, default OFF): an early
471
- // finalize whose answer is a bare number / short label is the confident-wrong shape
472
- // that dominated bench failures. ONE coached redo, then the answer is accepted.
525
+ // Verification-discipline nudge (default ON — bench: 28/33 failures were early
526
+ // confident wrong answers): a finalize that is bare, or arrived without any
527
+ // inspection of the context, gets ONE coached redo, then the answer is accepted.
473
528
  if (deps.config.enableVerificationNudge === true && !verificationNudged
474
- && completedTurns < VERIFICATION_NUDGE_TURN_CAP && isBareAnswer(final)) {
529
+ && completedTurns < VERIFICATION_NUDGE_TURN_CAP && isBareAnswer(final, sawRetrieval, sawDelegation)) {
475
530
  verificationNudged = true;
476
531
  verificationNudgePending = true;
477
532
  } else {
@@ -484,6 +539,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
484
539
  limits.observe(turnHadError(turn.results));
485
540
  history.push({ role: "assistant", content: turn.response });
486
541
  pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
542
+ if (repairNote !== undefined) {
543
+ pendingReplOutputs = `${pendingReplOutputs}\n\n${repairNote}`;
544
+ }
487
545
  // ── Workstream A: apply ΔΣ_t AFTER the environment reply — Algorithm 1 ordering:
488
546
  // state reflects intended effects; feedback arrives as the next O_t. Rejections roll
489
547
  // back and lead the next observation (error-as-observation retry); retries exhausted
@@ -495,6 +553,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
495
553
  i + 1,
496
554
  deps.config,
497
555
  i >= 2, // the fence was requested this turn → empty turns count as idle (bench rec #2)
556
+ // Productive-turn parity (root tracker, recall W2): executed work that neither
557
+ // raised nor skipped resets the idle streak — repl progress is progress.
558
+ turn.blocks.length > 0 && !turnHadError(turn.results) && turn.skippedBlocks === 0,
498
559
  );
499
560
  runStateMode = applied.mode;
500
561
  if (applied.observation !== undefined) {
@@ -639,11 +700,18 @@ function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegist
639
700
  return registry.limitFor(`${model.provider}/${model.id}`);
640
701
  }
641
702
 
642
- /** Bare number / short label — the early-confident answer shape the verification nudge
643
- * targets (28/33 bench failures were early confident wrong answers). */
644
- function isBareAnswer(answer: string): boolean {
703
+ /** Early-confident answer shape — the target of the verification nudge (28/33 bench failures
704
+ * were early confident wrong answers). Bare when: tiny, ≤3 words, a pure number/date/label,
705
+ * or — strongest signal — the run NEVER inspected its context (no retrieval, no delegation):
706
+ * the answer was then guessed, whatever its length. */
707
+ function isBareAnswer(answer: string, sawRetrieval: boolean, sawDelegation: boolean): boolean {
645
708
  const t = answer.trim();
646
- return t.length <= 12 || /^[-+$(€£¥]?\d+(?:[.,]\d+)*\s*%?$/.test(t);
709
+ if (t.length === 0) return true;
710
+ if (!sawRetrieval && !sawDelegation) return true;
711
+ if (t.length <= 12) return true;
712
+ if (t.split(/\s+/).length <= 3) return true;
713
+ if (/^[-+$(€£¥]?\d+(?:[.,]\d+)*\s*%?$/.test(t)) return true;
714
+ return false;
647
715
  }
648
716
 
649
717
  /** Out of turns: ask the model for its best final answer. FINALIZE_PROMPT asks for a fenced
@@ -9,7 +9,9 @@
9
9
  * array) to honor the zero-extra-allocations rule.
10
10
  *
11
11
  * - `elideStalePayloads` — discard semantics (paper §5.3): tool payloads older than the
12
- * keep window become head+tail previews; the full bytes remain in the session log.
12
+ * keep window become head+tail previews; the full bytes ride the optional `onElide`
13
+ * sink into the SessionArchive (recall W1) so elision stays dereferenceable — search()
14
+ * over `ctx/session-log/*` recalls what the stub replaced.
13
15
  * - `spliceSigmaSnapshot` — the fresh Σ snapshot rides immediately before the last user
14
16
  * message, exactly one instance (previous ones are removed — idempotent per call).
15
17
  *
@@ -19,7 +21,13 @@
19
21
  import type { ContextEvent } from "@earendil-works/pi-coding-agent";
20
22
  import type { RunState } from "./run-state.ts";
21
23
  import { runStateRootBlock } from "./run-state.ts";
22
- import { ROOT_TURN_ELIDED_LINE } from "../prompts/glossary.ts";
24
+ import {
25
+ ROOT_ELIDE_PREVIEW_MARK,
26
+ ROOT_ELIDE_PREVIEW_MARK_REPL,
27
+ ROOT_TURN_ELIDED_ARCHIVE_LINE,
28
+ ROOT_TURN_ELIDED_LINE,
29
+ ROOT_TURN_ELIDED_PLAIN_LINE,
30
+ } from "../prompts/glossary.ts";
23
31
  import { truncateOutput } from "../text/parsing.ts";
24
32
  import { textContentOf } from "../text/agent-text.ts";
25
33
 
@@ -29,9 +37,43 @@ export type RootMessage = ContextEvent["messages"][number];
29
37
  export interface ElideOptions {
30
38
  readonly keepTurns: number;
31
39
  readonly elideChars: number;
40
+ /** When true (SessionArchive wired + enabled), stubs point at the searchable archive;
41
+ * when false they stay honest by promising less (no phantom recovery channel). */
42
+ readonly archiveActive?: boolean;
32
43
  }
33
44
 
34
- const ELIDE_MARK = "chars elided repl sandbox persists: print(answers[k]) or re-run repl to re-derive";
45
+ /** One message the elision is about to destroy the SessionArchive record shape. */
46
+ export interface ElidedEntry {
47
+ readonly role: "assistant" | "toolResult";
48
+ readonly toolName: string | undefined;
49
+ /** The FULL original text (pre-preview, pre-stub) — the archive exists to hold it. */
50
+ readonly text: string;
51
+ }
52
+ export type ElideSink = (entry: ElidedEntry) => void;
53
+
54
+ const REPL_TOOL = "repl";
55
+
56
+ function elidedLineFor(role: string, toolName: string | undefined, archiveActive: boolean): string {
57
+ if (role === "toolResult" && toolName === REPL_TOOL) return ROOT_TURN_ELIDED_LINE;
58
+ return archiveActive ? ROOT_TURN_ELIDED_ARCHIVE_LINE : ROOT_TURN_ELIDED_PLAIN_LINE;
59
+ }
60
+
61
+ /** Normalized `archiveActive` — optional knob, resolved once per elide walk. */
62
+ function archiveFlag(opts: ElideOptions): boolean {
63
+ return opts.archiveActive === true;
64
+ }
65
+
66
+ function previewMarkFor(toolName: string | undefined, archiveActive: boolean): string {
67
+ if (toolName === REPL_TOOL) return ROOT_ELIDE_PREVIEW_MARK_REPL;
68
+ // Recall W1: the archive mark may only promise what the archive actually holds.
69
+ return archiveActive ? ROOT_ELIDE_PREVIEW_MARK : "chars elided";
70
+ }
71
+
72
+ /** Narrow the toolName field off a toolResult message (indexed — host evolution safe). */
73
+ function toolNameOf(message: RootMessage): string | undefined {
74
+ const name: unknown = (message as { toolName?: unknown }).toolName;
75
+ return typeof name === "string" ? name : undefined;
76
+ }
35
77
 
36
78
  /**
37
79
  * WS-3a: elide stale turns. The newest `keepTurns` assistant turns and the final user message
@@ -40,12 +82,18 @@ const ELIDE_MARK = "chars elided — repl sandbox persists: print(answers[k]) or
40
82
  * - recent stale ring (the last `max(keepTurns, 1)` stale turns): toolResult payloads over
41
83
  * `elideChars` become head+tail previews (same truncation shape as repl stdout); assistant
42
84
  * prose always collapses to the one-line stub.
43
- * - older still: EVERYTHING (payloads included) becomes the one-line session-log stub —
44
- * a stale preview per turn would itself accumulate linearly and re-create O(T).
85
+ * - older still: EVERYTHING (payloads included) becomes the one-line stub — a stale preview
86
+ * per turn would itself accumulate linearly and re-create O(T).
87
+ * When `onElide` is provided, every destroyed message's FULL text is handed to the sink
88
+ * BEFORE the stub replaces it (recall W1: the archive keeps elision dereferenceable).
45
89
  * `role:"custom"` messages of the Σ/intro kinds are immune (WS-3b owns them). Mutates the
46
90
  * array in place; returns the number of messages elided (telemetry), for zero-cost counters.
47
91
  */
48
- export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions): number {
92
+ export function elideStalePayloads(
93
+ messages: RootMessage[],
94
+ opts: ElideOptions,
95
+ onElide?: ElideSink,
96
+ ): number {
49
97
  const keepTurns = Math.max(0, Math.floor(opts.keepTurns));
50
98
  if (messages.length === 0) return 0;
51
99
  // Preview ring: stale turns recent enough to deserve the §5.3 head+tail preview. Scales with
@@ -55,7 +103,7 @@ export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions):
55
103
  if (keepTurns === 0) {
56
104
  // R5 strict-0: nothing inside the window — Σ + immune customs + the final user message
57
105
  // are all that survive verbatim.
58
- return elideRange(messages, 0, messages.length, opts, previewRing);
106
+ return elideRange(messages, 0, messages.length, opts, previewRing, onElide);
59
107
  }
60
108
 
61
109
  // Index of the assistant message that opens the keepTurns-th-from-last turn — everything
@@ -72,7 +120,7 @@ export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions):
72
120
  }
73
121
  }
74
122
  if (tailStart <= 0) return 0; // fewer turns than the window — nothing to elide
75
- return elideRange(messages, 0, tailStart, opts, previewRing);
123
+ return elideRange(messages, 0, tailStart, opts, previewRing, onElide);
76
124
  }
77
125
 
78
126
  /** Custom messages the elision never touches — the Σ snapshot/observation and the intro. */
@@ -94,6 +142,7 @@ function elideRange(
94
142
  to: number,
95
143
  opts: ElideOptions,
96
144
  previewRing: number,
145
+ onElide: ElideSink | undefined,
97
146
  ): number {
98
147
  const lastUser = lastIndexOfRole(messages, "user");
99
148
  // Pre-pass: stale assistant indices in [from, to) — a payload's recency is measured by the
@@ -109,6 +158,8 @@ function elideRange(
109
158
  if (i === lastUser) continue; // paranoia: the final user message is never touched
110
159
  if (m.role === "assistant") {
111
160
  staleAssistants -= 1; // turns AFTER this one = count minus itself
161
+ const prose = textContentOf(m.content);
162
+ onElide?.({ role: "assistant", toolName: undefined, text: prose });
112
163
  // Provider pairing invariant: Anthropic requires every tool_result to follow the
113
164
  // assistant message carrying its tool_use; OpenAI requires each tool/function_call_output
114
165
  // to pair with its tool_call/function_call. Eliding the toolCall blocks here orphaned the
@@ -116,25 +167,31 @@ function elideRange(
116
167
  const toolCalls = Array.isArray(m.content)
117
168
  ? (m.content as Array<{ type?: string }>).filter((b) => b?.type === "toolCall")
118
169
  : [];
170
+ const line = elidedLineFor("assistant", undefined, archiveFlag(opts));
119
171
  const content: unknown[] =
120
- toolCalls.length > 0
121
- ? [...toolCalls, { type: "text", text: ROOT_TURN_ELIDED_LINE }]
122
- : [{ type: "text", text: ROOT_TURN_ELIDED_LINE }];
172
+ toolCalls.length > 0 ? [...toolCalls, { type: "text", text: line }] : [{ type: "text", text: line }];
123
173
  messages[i] = { ...m, content } as RootMessage;
124
174
  elided += 1;
125
175
  continue;
126
176
  }
127
177
  if (m.role !== "toolResult") continue;
178
+ const toolName = toolNameOf(m);
179
+ const fullText = textContentOf(m.content);
128
180
  if (staleAssistants >= previewRing) {
129
181
  // Deep-stale payload: even the preview would accumulate — collapse to the stub.
130
- messages[i] = { ...m, content: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }] } as RootMessage;
182
+ onElide?.({ role: "toolResult", toolName, text: fullText });
183
+ messages[i] = {
184
+ ...m,
185
+ content: [{ type: "text", text: elidedLineFor("toolResult", toolName, archiveFlag(opts)) }],
186
+ } as RootMessage;
131
187
  elided += 1;
132
188
  continue;
133
189
  }
134
- if (totalTextLength(m) <= opts.elideChars) continue;
190
+ if (fullText.length <= opts.elideChars) continue;
191
+ onElide?.({ role: "toolResult", toolName, text: fullText });
135
192
  messages[i] = {
136
193
  ...m,
137
- content: [{ type: "text", text: previewToolText(m, opts.elideChars) }],
194
+ content: [{ type: "text", text: previewToolText(fullText, opts.elideChars, toolName, archiveFlag(opts)) }],
138
195
  } as RootMessage;
139
196
  elided += 1;
140
197
  }
@@ -184,12 +241,8 @@ function lastIndexOfRole(messages: readonly RootMessage[], role: "user"): number
184
241
  return -1;
185
242
  }
186
243
 
187
- function totalTextLength(message: RootMessage): number {
188
- return textContentOf((message as { content?: unknown }).content).length;
189
- }
190
-
191
- function previewToolText(message: RootMessage, elideChars: number): string {
192
- const text = textContentOf((message as { content?: unknown }).content);
244
+ /** Preview truncation moved the FULL text out to the caller first — this shapes what stays. */
245
+ function previewToolText(fullText: string, elideChars: number, toolName: string | undefined, archiveActive: boolean): string {
193
246
  // The preview REPLACES the payload, so `elideChars` budgets the WHOLE head+tail result.
194
- return truncateOutput(text, Math.max(100, elideChars), ELIDE_MARK);
247
+ return truncateOutput(fullText, Math.max(100, elideChars), previewMarkFor(toolName, archiveActive));
195
248
  }
@@ -25,9 +25,11 @@ import type { CompactionResult, SessionEntry } from "@earendil-works/pi-coding-a
25
25
  import type { RlmConfig } from "./types.ts";
26
26
  import { DEFAULT_NEXT_STEP, FINDINGS_MAX, FINDINGS_MIN_CHARS, NEXT_STEP_RE, STATE_MAX, truncateMid } from "./budget.ts";
27
27
  import { estimateMessageTokens } from "../text/tokens.ts";
28
+ import { bm25Rank } from "../util/bm25.ts";
28
29
  import { agentMessageText } from "../text/agent-text.ts";
29
30
  import { isRecord } from "../util/type-guards.ts";
30
31
  import { ROOT_DIGEST_HEADER, ROOT_DIGEST_SECTIONS } from "../prompts/glossary.ts";
32
+ import { trace, traceEnabled } from "../util/trace.ts";
31
33
 
32
34
  /** Marker persisted on the session's compaction entry — tests and soaks assert on it. */
33
35
  export interface RootDigestDetails {
@@ -106,17 +108,47 @@ function taskSection(messages: readonly unknown[]): string {
106
108
  return "";
107
109
  }
108
110
 
109
- /** [Findings] — newest-first substantive assistant blobs, capped, then chronological. */
110
- function findingsSection(messages: readonly unknown[]): readonly string[] {
111
- const findings: string[] = [];
112
- for (let i = messages.length - 1; i >= 0 && findings.length < FINDINGS_MAX; i--) {
111
+ /** [Findings] — hybrid selection, then chronological render. Candidates keep the dedup
112
+ * discipline (recall W4: exact + 80-char prefix), but the final pick is no longer pure
113
+ * recency: each candidate scores `BM25(task) normalized + recency share`, so load-bearing
114
+ * findings outrank newer rambles while recency breaks ties (and keeps no-overlap corpora
115
+ * byte-compatible with the old newest-first behavior). */
116
+ const FINDING_CANDIDATES_MAX = 40;
117
+
118
+ function findingsSection(messages: readonly unknown[], task: string): readonly string[] {
119
+ const candidates: string[] = [];
120
+ const seenExact = new Set<string>();
121
+ const seenPrefix = new Set<string>();
122
+ for (let i = messages.length - 1; i >= 0 && candidates.length < FINDING_CANDIDATES_MAX; i--) {
113
123
  const m = messages[i];
114
124
  if (!isRecord(m) || m.role !== "assistant") continue;
115
125
  const text = agentMessageText(m).trim();
116
- if (text.length > FINDINGS_MIN_CHARS) findings.push(text);
126
+ if (text.length <= FINDINGS_MIN_CHARS) continue;
127
+ const exactKey = text.toLowerCase().replace(/\s+/g, " ");
128
+ const prefixKey = exactKey.slice(0, 80);
129
+ if (seenExact.has(exactKey) || seenPrefix.has(prefixKey)) continue;
130
+ seenExact.add(exactKey);
131
+ seenPrefix.add(prefixKey);
132
+ candidates.push(text);
117
133
  }
118
- findings.reverse();
119
- return findings;
134
+ if (candidates.length <= FINDINGS_MAX) {
135
+ candidates.reverse();
136
+ return candidates;
137
+ }
138
+ // Hybrid rank: BM25 relevance to the live task + recency share (newest ≈ 1, oldest ≈ 0).
139
+ // `candidates` is collected newest-first, so recency decays with the index.
140
+ const ranked = bm25Rank(task, candidates.map((text) => ({ item: text, text })), candidates.length);
141
+ const relevance = new Map<string, number>();
142
+ const maxRel = ranked[0]?.score ?? 0;
143
+ for (const { item, score } of ranked) relevance.set(item, maxRel > 0 ? score / maxRel : 0);
144
+ const n = candidates.length;
145
+ const scored = candidates.map((text, i) => ({
146
+ text,
147
+ hybrid: (relevance.get(text) ?? 0) + (n - i) / n,
148
+ }));
149
+ scored.sort((a, b) => b.hybrid - a.hybrid);
150
+ const picked = new Set(scored.slice(0, FINDINGS_MAX).map((s) => s.text));
151
+ return candidates.filter((text) => picked.has(text));
120
152
  }
121
153
 
122
154
  /** [State] — newest-first toolResult first-lines (the last observed machinery state). */
@@ -163,10 +195,10 @@ export function buildRootDigestCompaction(
163
195
 
164
196
  const max = Math.max(200, args.config.rootDigestMaxChars);
165
197
  const task = truncateMid(taskSection(messages), Math.floor(max * TASK_FRACTION));
166
- const findings = findingsSection(messages).map((f) => truncateMid(f, BULLET_CHARS));
198
+ const findings = findingsSection(messages, task).map((f) => truncateMid(f, BULLET_CHARS));
167
199
  const states = stateSection(messages);
168
200
  // Next-step probe mirrors distillTrajectory: newest-first scan, chronological render.
169
- const next = findings.find((f) => NEXT_STEP_RE.test(f)) ?? DEFAULT_NEXT_STEP;
201
+ const next = [...findings].reverse().find((f) => NEXT_STEP_RE.test(f)) ?? DEFAULT_NEXT_STEP;
170
202
  const facts = args.store === undefined
171
203
  ? ""
172
204
  : args.store.sliceForPrompt(
@@ -190,12 +222,17 @@ export function buildRootDigestCompaction(
190
222
 
191
223
  // Over-cap drop order: [Project facts] → [State] → [Findings]; [Task]/[Next] survive to a
192
224
  // final truncate — task continuity beats trivia (paper §3.1 sufficient statistic).
225
+ // Recall W4: drops are traced (they were invisible before — a fat facts section could
226
+ // silently eat the entire [State] continuity floor).
227
+ let dropped = "";
193
228
  let summary = render(facts, states, findings);
194
- if (summary.length > max) summary = render("", states, findings);
195
- if (summary.length > max) summary = render("", [], findings);
229
+ if (summary.length > max) { summary = render("", states, findings); dropped = "facts"; }
230
+ if (summary.length > max) { summary = render("", [], findings); dropped += " state"; }
196
231
  if (summary.length > max) {
197
232
  summary = render("", [], findings.slice(0, Math.max(1, Math.floor(findings.length / 2))));
233
+ dropped += " findings-half";
198
234
  }
235
+ if (dropped !== "" && traceEnabled) trace("root-digest.drop", { dropped, max, finalChars: summary.length });
199
236
  summary = truncateMid(summary, max);
200
237
 
201
238
  const boundary = cut >= 0 ? args.branchEntries[cut] : undefined;
@@ -124,14 +124,17 @@ export class RootStateTracker {
124
124
  }
125
125
 
126
126
  noteFinding(text: string): void {
127
- const trimmed = text.trim();
127
+ // Runtime-harvested findings are clamped at the source — fence-sourced ones arrive ≤120
128
+ // via the patch clamp; this is the defense for direct feeds (engine mirrors arrive capped).
129
+ const trimmed = text.trim().slice(0, 300);
128
130
  if (trimmed === "") return;
129
131
  this.draft.findings = dedupStrings([...this.draft.findings, trimmed]).slice(-RUN_STATE_LIMITS.findings);
130
132
  this.touch();
131
133
  }
132
134
 
133
135
  noteFact(text: string): void {
134
- const trimmed = text.trim();
136
+ // Clamp before dedup (recall W2): a verbose runtime feed must not eat the κ_Σ budget.
137
+ const trimmed = text.trim().slice(0, 200);
135
138
  if (trimmed === "") return;
136
139
  this.draft.verifiedFacts = dedupStrings([...this.draft.verifiedFacts, trimmed])
137
140
  .slice(-RUN_STATE_LIMITS.verifiedFacts);
@@ -150,17 +153,27 @@ export class RootStateTracker {
150
153
  this.touch();
151
154
  }
152
155
 
153
- /** Tool-outcome feed (WS-4 v1 source): failures become approach outcomes, verbatim. */
156
+ /** Tool-outcome feed (WS-4 v1 source; recall W2 pollution fix): a SINGLE transient failure
157
+ * (binary read, typo'd path) no longer writes a `testedApproaches` record — that record
158
+ * evicted real approach entries and fired spurious [rectify] hints. Failures always grow
159
+ * the rectify counter, but only PROMOTE to Σ once the failure repeats (threshold parity)
160
+ * or a record already exists (then the record refreshes, keeping recency truthful). */
154
161
  observeToolResult(toolName: string, isError: boolean, reasonFirstLine: string): void {
162
+ const key = `tool:${toolName}`;
155
163
  if (isError) {
156
- this.noteOutcome(`tool:${toolName}`, { status: "failed", reason: reasonFirstLine });
164
+ const streak = (this.failures.get(key) ?? 0) + 1;
165
+ this.failures.set(key, streak);
166
+ if (streak >= RECTIFY_FAILURE_THRESHOLD || this.draft.testedApproaches[key] !== undefined) {
167
+ this.noteOutcome(key, { status: "failed", reason: reasonFirstLine });
168
+ }
157
169
  } else {
158
170
  // A success clears the tool's failure streak — the state reflects the newest truth.
159
- if (this.draft.testedApproaches[`tool:${toolName}`] !== undefined) {
160
- this.noteOutcome(`tool:${toolName}`, { status: "succeeded", evidence: reasonFirstLine });
161
- } else {
162
- this.failures.delete(`tool:${toolName}`);
171
+ if (this.draft.testedApproaches[key] !== undefined) {
172
+ const failures = this.failures.get(key) ?? 0;
173
+ const evidence = failures > 0 ? `recovered after ${failures} failure(s)` : reasonFirstLine;
174
+ this.noteOutcome(key, { status: "succeeded", evidence });
163
175
  }
176
+ this.failures.delete(key);
164
177
  }
165
178
  }
166
179
 
@@ -204,14 +217,27 @@ export class RootStateTracker {
204
217
  * see the const's soak citation). Any accepted delta resets the streak. In
205
218
  * degraded mode fences stop applying and the context transform stops splicing (`isActive`),
206
219
  * while runtime `observeToolResult` remains the Σ floor (degrade, never crash).
220
+ *
221
+ * Recall W2 amendments (root-only, engine ladder untouched):
222
+ * - `productiveTurn` — a turn that RAN TOOLS did work; growing the idle streak over it
223
+ * degraded file-editing sessions whose turns legitimately need no fence. Productive
224
+ * turns neither grow nor reset the streak (neutral).
225
+ * - Retry accounting — a batch that ACCEPTED deltas does not accumulate its problems into
226
+ * the session-wide retry counter (real work is never punished for a sibling's malformed
227
+ * fence — the observation still reports them); the counter only grows over zero-progress
228
+ * batches, so the storm cap stays a storm cap, not a session-lifetime budget.
207
229
  */
208
- applyFences(fences: readonly StateFenceResult[]): FenceOutcome {
230
+ applyFences(fences: readonly StateFenceResult[], opts?: { readonly productiveTurn?: boolean }): FenceOutcome {
209
231
  // R7-fix (recoverable degrade): a DEGRADED tracker no longer drops fences on the floor.
210
232
  // The old early-return turned idle degrade into a one-way amnesia valve — every later
211
233
  // fence vanished silently while the context transform kept eliding turns. Now the same
212
234
  // validation ladder runs in degraded mode, and a CLEAN batch re-activates compensation.
213
235
  // Degrade stays sticky only against zero-progress storms (all-malformed batches).
214
236
  if (fences.length === 0) {
237
+ if (opts?.productiveTurn === true) {
238
+ // Recall W2: tool-running turns are work, not fence idleness — neutral, no degrade.
239
+ return { fences: 0, accepted: 0, problems: 0 };
240
+ }
215
241
  // R4 (G6): a fence-free turn on a conditioned loop is IDLE — the contract rode the
216
242
  // prompt for nothing. Grow the streak; degrade at the engine's threshold.
217
243
  this.idleFenceTurns += 1;
@@ -247,9 +273,10 @@ export class RootStateTracker {
247
273
  this.touch();
248
274
  return { fences: fences.length, accepted, problems: 0 };
249
275
  }
250
- // Degraded variant carries `reason`, not `retries` recovery is clean-batch-only, so a
251
- // degraded tracker neither accumulates retries nor re-activates on a partial batch.
252
- const retries = (this.mode.kind === "active" ? this.mode.retries : 0) + problems.length;
276
+ // Recall W2: a productive batch (accepted > 0) starts its retry count at zero instead of
277
+ // accumulating the session's history the storm cap then measures CONSECUTIVE failure
278
+ // storms, not session age. Zero-progress batches accumulate exactly as before.
279
+ const retries = accepted > 0 ? problems.length : (this.mode.kind === "active" ? this.mode.retries : 0) + problems.length;
253
280
  this.pendingObservation = statePatchObservation(problems);
254
281
  // Accepted deltas in a partially-failing batch still land — engine parity: real work is
255
282
  // never rolled back just because a sibling fence was malformed.