@hicaru/pi-rlm 0.3.16 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +0 -4
  2. package/README.ru.md +56 -66
  3. package/README.zh-CN.md +61 -65
  4. package/package.json +5 -5
  5. package/src/bridge/add-context.ts +1 -1
  6. package/src/bridge/handlers/await.ts +13 -22
  7. package/src/bridge/handlers/completion.ts +27 -5
  8. package/src/bridge/handlers/emitting.ts +2 -2
  9. package/src/bridge/handlers/llm-query.ts +46 -68
  10. package/src/bridge/handlers/rlm-query.ts +14 -84
  11. package/src/bridge/handlers/task-registry.ts +22 -17
  12. package/src/bridge/handlers/types.ts +8 -6
  13. package/src/bridge/model.ts +6 -3
  14. package/src/commands/rlm-llm.ts +1 -10
  15. package/src/commands/rlm-rlm.ts +1 -8
  16. package/src/config/defaults.ts +32 -12
  17. package/src/config/settings.ts +53 -31
  18. package/src/config/skillstate.ts +465 -0
  19. package/src/context/md-cache.ts +1 -1
  20. package/src/context/merge.ts +1 -1
  21. package/src/context/namespace.ts +2 -2
  22. package/src/context/refresh.ts +1 -1
  23. package/src/context/source-dir.ts +21 -11
  24. package/src/context/source-doc.ts +1 -1
  25. package/src/context/source-git.ts +3 -15
  26. package/src/context/source-text.ts +1 -1
  27. package/src/context/walk.ts +6 -14
  28. package/src/core/budget.ts +107 -21
  29. package/src/core/compaction.ts +44 -1
  30. package/src/core/engine.ts +141 -84
  31. package/src/core/iteration.ts +1 -1
  32. package/src/core/ledger.ts +10 -13
  33. package/src/core/limits.ts +1 -1
  34. package/src/core/model-registry.ts +1 -1
  35. package/src/core/resource-limits.ts +1 -1
  36. package/src/core/root-context.ts +184 -0
  37. package/src/core/root-digest.ts +213 -0
  38. package/src/core/root-state.ts +310 -0
  39. package/src/core/run-state.ts +587 -0
  40. package/src/core/types.ts +51 -12
  41. package/src/index.ts +220 -39
  42. package/src/mode/llm-model.ts +13 -1
  43. package/src/mode/native-guards.ts +0 -6
  44. package/src/mode/rlm-mode.ts +34 -11
  45. package/src/mode/subagent.ts +5 -5
  46. package/src/prompts/glossary.ts +46 -25
  47. package/src/prompts/native.ts +27 -5
  48. package/src/prompts/system.ts +12 -4
  49. package/src/sandbox/context-file.ts +1 -1
  50. package/src/sandbox/interrupts.ts +25 -31
  51. package/src/sandbox/protocol.ts +14 -20
  52. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  53. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  54. package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
  55. package/src/sandbox/py/guards.py +1 -1
  56. package/src/sandbox/py/scaffold.py +24 -31
  57. package/src/sandbox/py/worker.py +3 -1
  58. package/src/sandbox/sandbox-manager.ts +2 -2
  59. package/src/sandbox/sandbox.ts +21 -4
  60. package/src/text/agent-text.ts +58 -0
  61. package/src/text/parsing.ts +35 -3
  62. package/src/text/preview.ts +3 -0
  63. package/src/text/repl-output.ts +1 -1
  64. package/src/tool/background-tasks.ts +1 -1
  65. package/src/tool/repl-render.ts +1 -1
  66. package/src/tool/repl-result.ts +1 -1
  67. package/src/tool/repl-tool.ts +50 -26
  68. package/src/tool/rlm-tool.ts +4 -5
  69. package/src/tool/subcall-render.ts +1 -1
  70. package/src/tool/subcall-store.ts +2 -2
  71. package/src/tool/tool-utils.ts +5 -5
  72. package/src/ui/config-panel.ts +12 -0
  73. package/src/ui/intro.ts +1 -1
  74. package/src/ui/modal/timeline-store.ts +1 -1
  75. package/src/ui/model-picker/drilldown.ts +1 -1
  76. package/src/ui/model-picker/levels.ts +1 -1
  77. package/src/ui/panel/run-registry.ts +1 -1
  78. package/src/ui/status.ts +39 -4
  79. package/src/ui/tree/tree-rows.ts +1 -1
  80. package/src/ui/tree/tree-widget.ts +1 -1
  81. package/src/util/bm25.ts +97 -0
  82. package/src/util/concurrency.ts +1 -1
  83. package/src/util/errors.ts +1 -1
  84. package/src/util/retry.ts +22 -7
  85. package/src/util/state-merge.ts +34 -0
  86. package/src/util/throttle.ts +1 -1
  87. package/src/util/type-guards.ts +6 -0
  88. package/src/core/memory.ts +0 -589
@@ -16,8 +16,7 @@ import {
16
16
  createTaskRegistry,
17
17
  type Invocation,
18
18
  } from "../bridge/handlers/index.ts";
19
- import { TaskLedger, contextSig, taskKey } from "./ledger.ts";
20
- import { type MemoryStore, rootContextPaths } from "./memory.ts";
19
+ import { TaskLedger } from "./ledger.ts";
21
20
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
22
21
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
23
22
  import { buildTurnPrompt, FINALIZE_PROMPT, RETRIEVAL_NUDGE, REASONING_BUDGET_HINT, VERIFICATION_NUDGE } from "../prompts/user.ts";
@@ -30,12 +29,32 @@ import { previewStdout, previewText } from "../text/preview.ts";
30
29
  import { findReplBlocks } from "../text/parsing.ts";
31
30
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
32
31
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
33
- import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
32
+ import { compactHistory, elideOldToolPayloads, rebaseWithState, shouldCompact } from "./compaction.ts";
33
+ import { applyStatePatches, freshRunState, runStateTurnBlock, type RunState, type RunStateMode } from "./run-state.ts";
34
+ import { findStatePatches } from "../text/parsing.ts";
35
+ import { complete1, completeDeps } from "../bridge/handlers/completion.ts";
36
+ import type { SubcallHandlerDeps } from "../bridge/handlers/types.ts";
37
+ import {
38
+ distillPromptFor,
39
+ groundLeafPrompt,
40
+ notesFromRunState,
41
+ parseDistilledNotes,
42
+ skillSearchHandler,
43
+ type SkillStore,
44
+ } from "../config/skillstate.ts";
34
45
  import { retryPolicy } from "../util/retry.ts";
35
46
  import { appendUserMessage } from "./history.ts";
36
47
  import { runTurn } from "./iteration.ts";
37
48
  import { type Limits, LimitError, LimitGuard } from "./limits.ts";
38
- import { continuationPrompt, distillTrajectory, resolveBudget, WRAP_UP_BUDGET } from "./budget.ts";
49
+ import {
50
+ continuationPrompt,
51
+ distillTrajectory,
52
+ rectify,
53
+ rectifyLabel,
54
+ resolveBudget,
55
+ stateHandoff,
56
+ WRAP_UP_BUDGET,
57
+ } from "./budget.ts";
39
58
  import { ModelContextRegistry, modelsCachePath } from "./model-registry.ts";
40
59
  import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
41
60
  import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
@@ -49,11 +68,6 @@ const DETACHED_SETTLE_MS = 5_000;
49
68
  /** Verification nudge (enableVerificationNudge): only an EARLY finalize is suspicious — from
50
69
  * turn 4 on, a bare answer is just... an answer. "Before iteration ~4", per the bench data. */
51
70
  const VERIFICATION_NUDGE_TURN_CAP = 4;
52
- /** H6 (audit): root episodes snapshot at most this many real files — replay invalidation for
53
- * the disk-backed slice of the context without hashing an unbounded repository. */
54
- const ROOT_HASH_MAX = 64;
55
-
56
-
57
71
  export interface EngineDeps {
58
72
  readonly model: Model<Api>;
59
73
  readonly llmModel: Model<Api>;
@@ -69,8 +83,13 @@ export interface EngineDeps {
69
83
  readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
70
84
  /** Test-only: override model completion (scripted multi-turn responses). */
71
85
  readonly complete?: import("./iteration.ts").CompleteFn;
72
- /** v5: session-wide durable memory store (`.rlm/`); omitted memory off for this engine. */
73
- readonly memory?: MemoryStore;
86
+ /** SKILL.state (Workstream B): session-scoped distilled-knowledge store. Omitted no Ξ
87
+ * harvest, no leaf grounding, no skill_search — zero behavior change. */
88
+ readonly skillStore?: SkillStore;
89
+ /** Root Σ (WS-4): observer for the FINAL Σ of accepted runs — the root session tracker
90
+ * mirrors it (one source, two sinks: the SkillStore harvest below + the tracker). Fires
91
+ * for child engines (depth > 0) too; the tracker's dedup keeps the volume bounded. */
92
+ readonly onRunState?: (state: RunState) => void;
74
93
  }
75
94
 
76
95
  /** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
@@ -87,6 +106,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
87
106
  }
88
107
 
89
108
  const model = deps.model;
109
+ const skillStore = deps.skillStore;
90
110
 
91
111
  // Create LimitGuard BEFORE the bridge so sub-LLM usage feeds into it.
92
112
  // Children inherit the parent's remaining timeout (propagated as remaining amount, not
@@ -142,54 +162,26 @@ export function createEngine(deps: EngineDeps): RunRlm {
142
162
  // childRun (RlmInput.ledger — the one construction seam, DRY #6).
143
163
  const runLedger = input.ledger ?? new TaskLedger();
144
164
  if (deps.config.enableLedger) runLedger.beginRun(input.rootPrompt);
165
+ // ── Workstream A: Σ_t — the structured execution state (paper §3). Degraded ⇒ the run
166
+ // behaves exactly as built. History-as-deliverable runs (narrative) keep their archive
167
+ // as the product — RunState never activates there (§12.1).
168
+ let runStateMode: RunStateMode =
169
+ deps.config.enableRunState && input.narrative !== true
170
+ ? { kind: "active", state: freshRunState(input.rootPrompt.slice(0, 200)), retries: 0, idle: 0 }
171
+ : { kind: "degraded", reason: input.narrative === true ? "narrative" : "disabled" };
145
172
 
146
- // v5 durable memory: read-only root replay an identical prompt over an identical
147
- // context answers for zero API calls (measured 10,051 0 tok in bake-off runs).
148
- const rootMemory =
149
- deps.memory !== undefined && deps.config.enableMemory ? deps.memory : undefined;
150
- const modelRefStr = `${model.provider}/${model.id}`;
151
- const rootKey = taskKey("root", input.rootPrompt, [], modelRefStr, contextSig(input.context));
152
- if (rootMemory !== undefined && input.depth === 0 && input.budget === undefined) {
153
- const hit = rootMemory.replay(rootKey);
154
- if (hit !== undefined) {
155
- emitter.emitStatus("done");
156
- return {
157
- answer: hit.result,
158
- iterations: 0,
159
- costUsd: 0,
160
- inputTokens: 0,
161
- outputTokens: 0,
162
- durationMs: 0,
163
- };
164
- }
165
- }
166
- const persistRoot = (
167
- answer: string,
168
- spend?: { readonly inputTokens: number; readonly outputTokens: number },
169
- ): void => {
170
- if (rootMemory === undefined || input.depth !== 0) return;
171
- // H2 (audit): only clean root runs persist — a continuation leaf carries the ORIGINAL
172
- // run's key (it persists the chain itself), and stopped/aborted partials must never
173
- // replay as if they were real answers.
174
- if (input.budget !== undefined) return;
175
- if (answer === "" || answer === "(aborted)" || answer.startsWith("(stopped")) return;
176
- const u = spend ?? limits.usage();
177
- rootMemory.recordEpisode({
178
- key: rootKey,
179
- kind: "root",
180
- model: modelRefStr,
181
- prompt: input.rootPrompt,
182
- paths: rootContextPaths(input.context, ROOT_HASH_MAX),
183
- result: answer,
184
- tokensIn: u.inputTokens,
185
- tokensOut: u.outputTokens,
186
- });
187
- };
188
-
189
- const subcalls = createSubcallHandlers({
173
+ // Workstream F: a rectified continuation may shrink leaf admission for THIS run only —
174
+ // the choice rides in on input.rectification (deterministic, logged). DOCTRINE: the
175
+ // model/provider pair is never a rectification axis — a failing model retries on itself
176
+ // until the attempt budget is exhausted, then fails loudly. No silent fallback.
177
+ const leafLimit = input.rectification?.kind === "reduce-concurrency"
178
+ ? input.rectification.maxConcurrentSubcalls
179
+ : deps.config.maxConcurrentSubcalls;
180
+ const gates = deps.gates
181
+ ?? createSubcallGates(leafLimit, deps.config.maxConcurrentChildren);
182
+ const subcallDeps = {
190
183
  resolve: () => invocation,
191
- gates: deps.gates
192
- ?? createSubcallGates(deps.config.maxConcurrentSubcalls, deps.config.maxConcurrentChildren),
184
+ gates,
193
185
  registry: deps.registry,
194
186
  getLlmModel: () => deps.llmModel,
195
187
  getModel: () => model,
@@ -201,7 +193,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
201
193
  // interrupt during runTurn, which is strictly after loadContext below.
202
194
  getChildContext: () => liveContext,
203
195
  ledger: runLedger,
204
- memory: rootMemory,
205
196
  trackDetached: async (task) => {
206
197
  detachedInFlight += 1;
207
198
  try {
@@ -211,7 +202,39 @@ export function createEngine(deps: EngineDeps): RunRlm {
211
202
  if (detachedInFlight === 0) detachedIdle?.();
212
203
  }
213
204
  },
214
- }, taskRegistry);
205
+ // SKILL.state: leaf grounding (Workstream D, DRY #1) + the parent Ξ for children (C, DRY #6).
206
+ groundLeaf:
207
+ skillStore === undefined || !deps.config.enableSkillState
208
+ ? undefined
209
+ : (prompt: string) => groundLeafPrompt(skillStore, deps.config, prompt),
210
+ getSkillBlock: () => input.skillBlock,
211
+ } satisfies SubcallHandlerDeps;
212
+ const subcalls = createSubcallHandlers(subcallDeps, taskRegistry);
213
+
214
+ // ── Workstream B write path: distill Σ into the session store at run end ──────────
215
+ // Deterministic harvest is free (Σ is already structured); the opt-in A-Mem phrasing is
216
+ // ONE cheap leaf call. Fail-soft: a distill failure must never damage a finished run.
217
+ const harvestSkillNotes = async (): Promise<void> => {
218
+ if (runStateMode.kind !== "active") return;
219
+ // Root Σ WS-4 mirror FIRST — the tracker absorbs the final Σ even when the store is
220
+ // off (one source, two sinks; never a second harvest implementation).
221
+ deps.onRunState?.(runStateMode.state);
222
+ if (skillStore === undefined || !deps.config.enableSkillState) return;
223
+ skillStore.merge(notesFromRunState(runStateMode.state));
224
+ if (!deps.config.enableSkillStateDistill || deps.signal?.aborted === true) return;
225
+ try {
226
+ const raw = await complete1(
227
+ invocation,
228
+ distillPromptFor(runStateMode.state),
229
+ () => {},
230
+ completeDeps(subcallDeps),
231
+ );
232
+ const parsed = parseDistilledNotes(raw);
233
+ if (parsed.length > 0) skillStore.merge(parsed);
234
+ } catch {
235
+ // fail-soft by design
236
+ }
237
+ };
215
238
  /** Wait (bounded) for detached work before the sandbox goes away. */
216
239
  const settleDetached = async (): Promise<void> => {
217
240
  if (detachedInFlight === 0) return;
@@ -267,6 +290,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
267
290
  contextChars: contextLength(input.context),
268
291
  contextStats: contextSizeStats(input.context),
269
292
  rootPrompt: input.rootPrompt || undefined,
293
+ skillBlock: input.skillBlock,
270
294
  };
271
295
  const system = buildRlmSystemPrompt(meta, {
272
296
  orchestrator: deps.config.orchestrator,
@@ -274,13 +298,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
274
298
  maxPromptChars: deps.config.maxPromptChars,
275
299
  contextLoader: deps.config.contextLoader,
276
300
  child: input.depth > 0,
277
- delegation: input.depth > 0 && deps.config.childSurface === "delegation",
301
+ delegation: input.depth > 0,
278
302
  depth: input.depth,
279
303
  });
280
304
 
281
305
  // v5 (audit M5): a delegation child does not grow the world — add_context stays root-only.
282
306
  const contextHandlers =
283
- deps.config.contextLoader && (input.depth === 0 || deps.config.childSurface !== "delegation")
307
+ deps.config.contextLoader && input.depth === 0
284
308
  ? buildAddContextHandler({
285
309
  cwd: runCwd,
286
310
  emitter,
@@ -296,9 +320,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
296
320
  }).handlers
297
321
  : {};
298
322
 
299
- // v5 doctrine: one condition feeds BOTH the python surface and the memory scope —
300
- // delegation children keep llm/memory-read/ledger, never repo retrieval or memory.add.
301
- const surface = input.depth > 0 && deps.config.childSurface === "delegation" ? "child" : "root";
323
+ // v5 doctrine: delegation children keep the llm/ledger surface never repo retrieval.
324
+ const surface = input.depth > 0 ? "child" : "root";
302
325
  sandbox = await PythonSandbox.spawn({
303
326
  depth: input.depth,
304
327
  surface,
@@ -313,10 +336,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
313
336
  ...subcalls,
314
337
  ...contextHandlers,
315
338
  ledgerClaims: () => Promise.resolve(runLedger.listClaims()),
316
- memoryOp: (op, args) =>
317
- Promise.resolve(
318
- rootMemory === undefined ? "memory off" : rootMemory.serviceOp(op, args, surface === "child" ? "child" : "root"),
319
- ),
339
+ // SKILL.state (Workstream E): the model-visible recall surface — one function.
340
+ skillSearch: skillSearchHandler(() => (deps.config.enableSkillState ? skillStore : undefined)),
320
341
  },
321
342
  });
322
343
 
@@ -353,7 +374,11 @@ export function createEngine(deps: EngineDeps): RunRlm {
353
374
  signal: deps.signal,
354
375
  };
355
376
  if (shouldCompact(history, compactionDeps)) {
356
- history = await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
377
+ // Workstream A: with Σ active, rebase structurally — [P, Σ_t, window(O)] and
378
+ // the summarizer call disappears entirely; degraded runs keep compactHistory.
379
+ history = runStateMode.kind === "active"
380
+ ? rebaseWithState(history, runStateMode.state, ++compactions)
381
+ : await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
357
382
  }
358
383
  }
359
384
 
@@ -371,17 +396,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
371
396
  );
372
397
  }
373
398
 
374
- // v5 [ledger] blackboard + [memory] notes each silent ("") when it has nothing to say.
399
+ // v5 [ledger] blackboard — silent ("") when it has nothing to say.
375
400
  const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
376
- const memoryBlock = rootMemory !== undefined ? rootMemory.injectBlock(input.rootPrompt) : "";
377
401
  // H3: after two retrieval-free turns, inject the coach nudge exactly once, for one turn.
378
402
  const nudgeNow = i >= 2 && !sawRetrieval && !retrievalNudged;
379
403
  if (nudgeNow) retrievalNudged = true;
380
404
  const notes =
381
405
  [
382
406
  i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
407
+ // Workstream A: from iteration 3 the run conditions on Σ (A_t = (P, Σ_t, O_t)) and
408
+ // the state fence is requested — exploratory cold-start stays as-built (§12.2).
409
+ runStateMode.kind === "active" && i >= 2 ? runStateTurnBlock(runStateMode.state) : undefined,
383
410
  ledgerBlock === "" ? undefined : ledgerBlock,
384
- memoryBlock === "" ? undefined : memoryBlock,
385
411
  nudgeNow ? RETRIEVAL_NUDGE : undefined,
386
412
  verificationNudgePending ? VERIFICATION_NUDGE : undefined,
387
413
  // One-shot (turn 0 only): thinking tokens share the completion budget — mirror of
@@ -439,7 +465,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
439
465
  verificationNudgePending = true;
440
466
  } else {
441
467
  const done = result(final, i + 1, limits);
442
- persistRoot(done.answer);
443
468
  lastAnswer = done.answer;
444
469
  return done;
445
470
  }
@@ -448,6 +473,23 @@ export function createEngine(deps: EngineDeps): RunRlm {
448
473
  limits.observe(turnHadError(turn.results));
449
474
  history.push({ role: "assistant", content: turn.response });
450
475
  pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
476
+ // ── Workstream A: apply ΔΣ_t AFTER the environment reply — Algorithm 1 ordering:
477
+ // state reflects intended effects; feedback arrives as the next O_t. Rejections roll
478
+ // back and lead the next observation (error-as-observation retry); retries exhausted
479
+ // ⇒ degrade to as-built for the rest of the run.
480
+ if (runStateMode.kind === "active") {
481
+ const applied = applyStatePatches(
482
+ runStateMode,
483
+ findStatePatches(turn.response),
484
+ i + 1,
485
+ deps.config,
486
+ i >= 2, // the fence was requested this turn → empty turns count as idle (bench rec #2)
487
+ );
488
+ runStateMode = applied.mode;
489
+ if (applied.observation !== undefined) {
490
+ pendingReplOutputs = `${applied.observation}\n\n${pendingReplOutputs}`;
491
+ }
492
+ }
451
493
 
452
494
  // ── v5 budget cascade ─────────────────────────────────────────────────────
453
495
  // Content control lives here; wall-clock timeouts stay hang backstops. Whole-tree
@@ -465,19 +507,37 @@ export function createEngine(deps: EngineDeps): RunRlm {
465
507
  }
466
508
  if (bstate === "hard") {
467
509
  if (budget.canContinue()) {
468
- // Distill the trajectory and chain a fresh run with a fresh spend window —
469
- // the v4 "finalize NOW" flaw fix: never abort mid-task, restructure-and-resume.
470
- const handoff = distillTrajectory(history, input.rootPrompt, deps.config.budgetHandoffChars);
510
+ // Distill and chain a fresh run with a fresh spend window — the v4 "finalize
511
+ // NOW" flaw fix: never abort mid-task, restructure-and-resume. Workstream A:
512
+ // with Σ active the handoff IS the state (compactJSON lossless where it
513
+ // matters); the prose walk stays only for degraded runs.
514
+ const handoff = runStateMode.kind === "active"
515
+ ? stateHandoff(runStateMode.state, input.rootPrompt, deps.config.budgetHandoffChars)
516
+ : distillTrajectory(history, input.rootPrompt, deps.config.budgetHandoffChars);
471
517
  const cont = budget.nextContinuation();
518
+ // Workstream F (MAS2 Eq. 5): one deterministic local fix for the continuation.
519
+ const fix = rectify({
520
+ state: runStateMode.kind === "active" ? runStateMode.state : undefined,
521
+ config: deps.config,
522
+ });
472
523
  if (selfReportId) {
473
- emitter.emitSubcallUpdated({ id: selfReportId, detail: `budget hard → continuation ${cont.continuations}` });
524
+ emitter.emitSubcallUpdated({
525
+ id: selfReportId,
526
+ detail:
527
+ `budget hard → continuation ${cont.continuations}` +
528
+ (fix.kind === "none" ? "" : ` · rectify: ${rectifyLabel(fix)}`),
529
+ });
474
530
  }
475
531
  const inner = await run({
476
532
  ...input,
477
- rootPrompt: continuationPrompt(cont.continuations, handoff),
533
+ rootPrompt: continuationPrompt(cont.continuations, handoff)
534
+ + (fix.kind === "narrow-paths"
535
+ ? `\n\n[rectify] narrow child spawns: rlm_query(task, paths=${JSON.stringify(fix.paths)})`
536
+ : ""),
478
537
  context: liveContext, // H9: sources added mid-run reach the leaf
479
538
  budget: cont,
480
539
  remainingTimeoutMs: limits.remainingTimeoutMs(),
540
+ ...(fix.kind === "none" ? {} : { rectification: fix }),
481
541
  });
482
542
  // H9: report the CHAIN's spend, not just the leaf's fresh guard.
483
543
  const u = limits.usage();
@@ -488,12 +548,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
488
548
  outputTokens: inner.outputTokens + u.outputTokens,
489
549
  costUsd: inner.costUsd + u.costUsd,
490
550
  };
491
- // H2: the ORIGINAL run persists the chain's answer under the ORIGINAL key —
492
- // the next identical prompt must replay the full result, not miss.
493
- // R2: lastAnswer must be set before return — `finally` emitAnswer reads it,
494
- // and persist must store the CHAIN totals, not just the parent window.
551
+ // R2: lastAnswer must be set before return `finally` emitAnswer reads it.
495
552
  lastAnswer = chained.answer;
496
- persistRoot(chained.answer, chained);
497
553
  return chained;
498
554
  }
499
555
  // Chain cap reached — finalize with the best partial (a budget never throws).
@@ -503,7 +559,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
503
559
  }
504
560
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
505
561
  const finalized = result(await finalize(history, model, deps, limits, sandbox), deps.config.maxIterations, limits);
506
- persistRoot(finalized.answer);
507
562
  lastAnswer = finalized.answer;
508
563
  return finalized;
509
564
  } catch (err) {
@@ -522,6 +577,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
522
577
  nodeStatus = "error";
523
578
  throw err;
524
579
  } finally {
580
+ // Workstream B Hook 1: Σ → SkillState notes at run end (all return paths; never throws).
581
+ if (skillStore !== undefined && deps.config.enableSkillState) await harvestSkillNotes();
525
582
  if (deps.config.enableLedger) runLedger.endRun();
526
583
  if (selfReportId) {
527
584
  emitter.emitSubcallUpdated({
@@ -25,7 +25,7 @@ export interface Turn {
25
25
 
26
26
  export type CompleteFn = (messages: readonly ChatMsg[], opts: CompleteOptions) => Promise<CompleteResult>;
27
27
 
28
- export interface TurnDeps {
28
+ interface TurnDeps {
29
29
  readonly model: Model<Api>;
30
30
  readonly registry: ModelRegistry;
31
31
  readonly sampling?: Sampling;
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { createHash } from "node:crypto";
15
+ import { isRecord } from "../util/type-guards.ts";
15
16
 
16
17
  const NOISE = /\b(no edits?|do not edit|analysis[- ]only|do not change)\.?/gi;
17
18
  const TOK = /[a-z0-9_]{2,}/g;
@@ -23,13 +24,13 @@ const DONE_LINES = 6;
23
24
  const PROMPT_PREVIEW = 80;
24
25
  /** v5 wait() parity (audit H1): a coalescing twin never parks forever. Generous default —
25
26
  * a twin can legitimately wait out a full child engine run. */
26
- export const WAIT_TIMEOUT_MS = 600_000;
27
+ const WAIT_TIMEOUT_MS = 600_000;
27
28
 
28
- export type ClaimKind = "llm" | "rlm";
29
- export type ClaimStatus = "pending" | "running" | "done" | "error";
29
+ type ClaimKind = "llm" | "rlm";
30
+ type ClaimStatus = "pending" | "running" | "done" | "error";
30
31
 
31
32
  /** All-readonly (project rule): transitions replace the map entry with a new frozen Claim. */
32
- export interface Claim {
33
+ interface Claim {
33
34
  readonly key: string;
34
35
  readonly kind: ClaimKind;
35
36
  readonly prompt: string;
@@ -39,7 +40,7 @@ export interface Claim {
39
40
  readonly result: string | null;
40
41
  }
41
42
 
42
- export interface ClaimRequest {
43
+ interface ClaimRequest {
43
44
  readonly kind: ClaimKind;
44
45
  readonly prompt: string;
45
46
  readonly paths: readonly string[];
@@ -47,12 +48,12 @@ export interface ClaimRequest {
47
48
  }
48
49
 
49
50
  /** Result of `tryClaim` — a discriminated union, never an exception. */
50
- export type ClaimDecision =
51
+ type ClaimDecision =
51
52
  | { readonly type: "run"; readonly key: string }
52
53
  | { readonly type: "coalesce"; readonly key: string; readonly done: boolean }
53
54
  | { readonly type: "echo" };
54
55
 
55
- export interface LedgerHits {
56
+ interface LedgerHits {
56
57
  readonly exact: number;
57
58
  readonly echo: number;
58
59
  readonly near: number;
@@ -98,11 +99,6 @@ function sha256Hex(text: string): string {
98
99
  return createHash("sha256").update(text).digest("hex");
99
100
  }
100
101
 
101
- /** Type guard (project rule: no `as` narrowing) — used by contextSig over unknown payloads. */
102
- function isRecord(value: unknown): value is Record<string, unknown> {
103
- return typeof value === "object" && value !== null;
104
- }
105
-
106
102
  /** v5 `context_sig`: fingerprint a packed context so same-question/different-haystack never collide. */
107
103
  export function contextSig(context: unknown): string {
108
104
  if (context === undefined || context === null) return "";
@@ -124,7 +120,8 @@ export function contextSig(context: unknown): string {
124
120
  }
125
121
  return h.digest("hex").slice(0, 16);
126
122
  }
127
- return sha256Hex(String(context)).slice(0, 16);
123
+ // JSON (not String()): String(obj) collapsed EVERY object context to "[object Object]".
124
+ return sha256Hex(JSON.stringify(context)).slice(0, 16);
128
125
  }
129
126
 
130
127
  export function taskKey(
@@ -24,7 +24,7 @@ export function limitsFromConfig(config: Limits): Limits {
24
24
  }
25
25
 
26
26
  /** Point-in-time totals for a run. */
27
- export interface UsageSnapshot {
27
+ interface UsageSnapshot {
28
28
  readonly inputTokens: number;
29
29
  readonly outputTokens: number;
30
30
  readonly costUsd: number;
@@ -30,7 +30,7 @@ interface CacheEntry {
30
30
  }
31
31
  type CacheFile = Readonly<Record<string, CacheEntry>>;
32
32
 
33
- /** `<root>/.rlm/models_cache.json` — the single cache path helper (also used by memory). */
33
+ /** `<root>/.rlm/models_cache.json` — the single cache path helper. */
34
34
  export function modelsCachePath(root: string): string {
35
35
  return `${root.replace(/\/+$/, "")}/.rlm/models_cache.json`;
36
36
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { formatError } from "../util/errors.ts";
4
4
 
5
- export interface RemainingResources {
5
+ interface RemainingResources {
6
6
  readonly timeoutMs?: number;
7
7
  }
8
8