@hicaru/pi-rlm 0.3.20 → 0.3.22

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 (41) hide show
  1. package/README.md +58 -46
  2. package/package.json +1 -1
  3. package/src/commands/rlm.ts +14 -7
  4. package/src/config/defaults.ts +33 -10
  5. package/src/config/settings.ts +6 -0
  6. package/src/config/skillstate.ts +236 -44
  7. package/src/core/budget.ts +7 -3
  8. package/src/core/compaction.ts +2 -2
  9. package/src/core/engine.ts +87 -19
  10. package/src/core/root-context.ts +74 -21
  11. package/src/core/root-digest.ts +48 -11
  12. package/src/core/root-state.ts +39 -12
  13. package/src/core/run-state.ts +86 -14
  14. package/src/core/session-archive.ts +174 -0
  15. package/src/core/types.ts +6 -0
  16. package/src/index.ts +142 -12
  17. package/src/mode/rlm-mode.ts +2 -2
  18. package/src/prompts/glossary.ts +34 -5
  19. package/src/prompts/native.ts +8 -2
  20. package/src/prompts/user.ts +4 -3
  21. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  22. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  23. package/src/sandbox/py/retrieval.py +202 -36
  24. package/src/sandbox/py/scaffold.py +20 -5
  25. package/src/sandbox/py/worker.py +1 -1
  26. package/src/sandbox/sandbox-manager.ts +19 -0
  27. package/src/text/parsing.ts +133 -2
  28. package/src/text/tokens.ts +39 -4
  29. package/src/tool/repl-render.ts +38 -2
  30. package/src/tool/repl-tool.ts +34 -18
  31. package/src/tool/subcall-render.ts +7 -4
  32. package/src/ui/config-panel.ts +2 -2
  33. package/src/ui/intro.ts +1 -1
  34. package/src/ui/python-highlight.ts +49 -0
  35. package/src/ui/stage-cards.ts +192 -0
  36. package/src/ui/theme-adapter.ts +85 -3
  37. package/src/ui/tree/tree-model.ts +69 -19
  38. package/src/ui/tree/tree-rows.ts +2 -1
  39. package/src/util/abort.ts +34 -0
  40. package/src/util/bm25.ts +170 -21
  41. package/src/util/errors.ts +1 -1
package/src/index.ts CHANGED
@@ -29,11 +29,18 @@ import type { AddContextHandlerBundle } from "./bridge/add-context.ts";
29
29
  import { buildNativeSystemPrompt } from "./prompts/native.ts";
30
30
  import { SkillStore, notesFromRunState, xiQuery } from "./config/skillstate.ts";
31
31
  import { buildRootDigestCompaction } from "./core/root-digest.ts";
32
- import { RootStateTracker } from "./core/root-state.ts";
32
+ import { ROOT_IDLE_DEGRADE_TURNS, RootStateTracker } from "./core/root-state.ts";
33
33
  import { elideStalePayloads, spliceSigmaSnapshot } from "./core/root-context.ts";
34
+ import { SessionArchive } from "./core/session-archive.ts";
34
35
  import { agentMessageText, firstLine, textContentOf } from "./text/agent-text.ts";
35
36
  import { findStatePatches } from "./text/parsing.ts";
36
37
  import { capToolResultText } from "./mode/native-guards.ts";
38
+ import {
39
+ STAGE_CUSTOM_TYPE,
40
+ renderStageCard,
41
+ stageCardMarkdown,
42
+ type StageCardDetails,
43
+ } from "./ui/stage-cards.ts";
37
44
  import {
38
45
  isSubagentChildBypass,
39
46
  commitSubagentForceActivation,
@@ -108,17 +115,36 @@ export default function rlmExtension(pi: ExtensionAPI): void {
108
115
  hideWhenEmpty: true,
109
116
  });
110
117
  let treePanelInstalled = false;
118
+ /**
119
+ * Native-mode abort: repl cells' child engines, detached spawn() tasks and add_context
120
+ * loads read this signal lazily (createReplTool getSignal), so /rlm-stop aborts AND
121
+ * rotates the controller mid-session — work started after a stop sees a fresh signal.
122
+ */
123
+ let nativeAbort = new AbortController();
124
+ const stopNativeWork = (): boolean => {
125
+ const hadWork = runRegistry.hasActive() || background.pending > 0;
126
+ nativeAbort.abort();
127
+ nativeAbort = new AbortController();
128
+ return hadWork;
129
+ };
111
130
  /** SKILL.state (Workstream B): session store — hydrated at session_start, flushed at shutdown. */
112
131
  let skillStore: SkillStore | undefined;
113
132
  /** Root Σ (WS-3/4): the native session's digest-level Σ_t — runtime-derived (tool outcomes,
114
133
  * engine mirrors, prompts); lazily born on the first prompt, harvested + dropped at shutdown. */
115
134
  let rootTracker: RootStateTracker | undefined;
135
+ /** Recall W1: elided turns archive here and materialize into the sandbox
136
+ * (ctx/session-log/*) so search()/grep_context() recall them. Per-session, closure-only. */
137
+ const sessionArchive = new SessionArchive(config.rootArchiveMaxChars);
138
+ /** Archive gate: enabled (chars > 0) AND the context transform actually running. */
139
+ const archiveActive = (): boolean =>
140
+ controller.config.rootArchiveMaxChars > 0 && rootContextActive();
116
141
  // Root Σ WS-5.1 telemetry — journal counters (trace lines + status widget when tracing).
117
142
  let xiCompositions = 0;
118
143
  let rootDigests = 0;
119
144
  let elidedMessages = 0;
120
145
  let sigmaSplices = 0;
121
146
  let idleDegrades = 0;
147
+ let archivedTurns = 0;
122
148
  /** R6: Σ counter snapshot for the status line — a fresh readonly object per render. */
123
149
  const sigmaTelemetry = (): RootSigmaTelemetry => ({
124
150
  xiCompositions,
@@ -127,6 +153,21 @@ export default function rlmExtension(pi: ExtensionAPI): void {
127
153
  sigmaSplices,
128
154
  idleDegrades,
129
155
  });
156
+ /** [rlm.stage]: post one stage-transition card into the transcript (persisted, ctrl+o-expandable). */
157
+ const postStageCard = (details: StageCardDetails): void => {
158
+ try {
159
+ pi.sendMessage({ customType: STAGE_CUSTOM_TYPE, content: stageCardMarkdown(details), display: true, details });
160
+ } catch (err) {
161
+ // Stage cards are decoration — never fail the hook that produced the transition.
162
+ if (traceEnabled) trace("stage-card.fail", { error: errorMessage(err) });
163
+ }
164
+ };
165
+ /**
166
+ * Compaction-hook re-entrancy guard: session_before_compact builds the digest card but does
167
+ * NOT post it from inside the hook (a message appended mid-compaction would fold into the
168
+ * very span being digested) — the pending card flushes at the next turn_start instead.
169
+ */
170
+ let pendingDigestCard: StageCardDetails | undefined;
130
171
  // A detached child works in its OWN sandbox, so this one sees no frames and its request
131
172
  // watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
132
173
  // with it. Keep it alive while detached work is genuinely in flight.
@@ -173,7 +214,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
173
214
  const cfg = controller.config;
174
215
  // R0: enableSkillState is enforced (validateEnforcedOn) — no config check remains.
175
216
  if (skillStore === undefined) return undefined;
176
- const block = skillStore.blockFor(query, cfg.skillStateMaxTokens);
217
+ // Recall W3: the Ξ block honors the configured score floor (was MIN_VALUE — any
218
+ // positively-scored stale note rode every prompt).
219
+ const block = skillStore.blockFor(query, cfg.skillStateMaxTokens, cfg.skillStateXiMinScore);
177
220
  return block === "" ? undefined : block;
178
221
  };
179
222
 
@@ -189,6 +232,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
189
232
  pi.registerMessageRenderer("rlm-intro", (message, _options, theme) =>
190
233
  new Markdown(textContentOf(message.content), 1, 0, markdownTheme(theme)),
191
234
  );
235
+ // [rlm.stage] cards — orchestrator stage transitions, collapsed until the user's ctrl+o.
236
+ pi.registerMessageRenderer(STAGE_CUSTOM_TYPE, renderStageCard);
192
237
 
193
238
  // ── CLI flag: `pi --rlm` / `pi --rlm=false` overrides the persisted mode for this run ──
194
239
  pi.registerFlag("rlm", {
@@ -197,7 +242,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
197
242
  });
198
243
 
199
244
  // ── Commands ──
200
- registerRlmCommand(pi, controller);
245
+ registerRlmCommand(pi, controller, stopNativeWork);
201
246
  registerRlmConfigCommand(pi, controller);
202
247
  registerRlmLlmCommand(pi, controller);
203
248
  registerRlmRlmCommand(pi, controller);
@@ -322,6 +367,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
322
367
  background,
323
368
  runRegistry,
324
369
  skillStore,
370
+ getSignal: () => nativeAbort.signal,
325
371
  onRunState: (state) => { rootTracker?.absorbEngineState(state); },
326
372
  getSkillBlock: composeSkillBlock,
327
373
  registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
@@ -362,6 +408,14 @@ export default function rlmExtension(pi: ExtensionAPI): void {
362
408
  setRlmModeStatus(ctx, controller, ctx.getContextUsage(), sigmaTelemetry());
363
409
  });
364
410
 
411
+ // Deferred [rlm.stage] digest card — built inside session_before_compact, posted here.
412
+ pi.on("turn_start", async () => {
413
+ const card = pendingDigestCard;
414
+ if (card === undefined) return;
415
+ pendingDigestCard = undefined;
416
+ postStageCard(card);
417
+ });
418
+
365
419
  /** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
366
420
  const nativeTradeHolds = (): boolean =>
367
421
  shouldEnforceNativeReaderBlock({
@@ -423,8 +477,13 @@ export default function rlmExtension(pi: ExtensionAPI): void {
423
477
  const tracker = rootTracker;
424
478
  if (tracker === undefined || !controller.config.enableRootStateFences) return;
425
479
  if (event.message.role !== "assistant") return;
480
+ // Recall W2: a turn that ran TOOLS did work — it is never fence-idle (file-editing
481
+ // sessions were degrading before their first fence landed). Productive turns neither
482
+ // grow nor reset the idle streak; prose-only turns keep the R4 ladder.
483
+ const productiveTurn = Array.isArray(event.message.content) &&
484
+ (event.message.content as Array<{ type?: string }>).some((b) => b?.type === "toolCall");
426
485
  const wasActive = tracker.isActive;
427
- const outcome = tracker.applyFences(findStatePatches(agentMessageText(event.message)));
486
+ const outcome = tracker.applyFences(findStatePatches(agentMessageText(event.message)), { productiveTurn });
428
487
  // R3 soak observability: per-turn fence outcomes — the soak-B bars (≥50% of turns commit
429
488
  // ≥1 accepted delta, rejection storms <10%) are computed from these journal lines.
430
489
  if (traceEnabled) {
@@ -439,6 +498,12 @@ export default function rlmExtension(pi: ExtensionAPI): void {
439
498
  }
440
499
  if (wasActive && !tracker.isActive) {
441
500
  idleDegrades += 1;
501
+ postStageCard({
502
+ kind: "degrade",
503
+ reason: tracker.degradeReason ?? "unknown",
504
+ idleTurns: tracker.idleTurns,
505
+ idleMax: ROOT_IDLE_DEGRADE_TURNS,
506
+ });
442
507
  if (traceEnabled) {
443
508
  const reason = tracker.degradeReason ?? "unknown";
444
509
  trace(reason.startsWith("idle") ? "root-state.idle-degrade" : "root-state.degrade", {
@@ -446,6 +511,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
446
511
  reason,
447
512
  });
448
513
  }
514
+ } else if (!wasActive && tracker.isActive) {
515
+ // R7-fix recovery observability, user-visible: a degraded tracker accepted a clean batch.
516
+ postStageCard({ kind: "recover", fencesAccepted: outcome.accepted, fencesTotal: outcome.fences });
449
517
  }
450
518
  });
451
519
 
@@ -462,6 +530,14 @@ export default function rlmExtension(pi: ExtensionAPI): void {
462
530
  });
463
531
  if (result !== undefined) {
464
532
  rootDigests += 1;
533
+ pendingDigestCard = {
534
+ kind: "digest",
535
+ index: rootDigests,
536
+ turnsFolded: event.preparation.messagesToSummarize.length,
537
+ tokensBefore: result.compaction.tokensBefore,
538
+ tokensBeforeRecomputed: result.tokensBeforeRecomputed,
539
+ summary: result.compaction.summary,
540
+ };
465
541
  if (traceEnabled) {
466
542
  // V1 soak probe: host-consumed tokensBefore vs our recomputation over the same span.
467
543
  trace("root-digest.built", {
@@ -486,7 +562,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
486
562
  // ── Context injection: listing of whatever is currently loaded ──
487
563
  // Re-inject only when the payload identity changes (seed / add_context), not every turn —
488
564
  // the listing can be up to 200 file lines and the plugin exists to shrink the root window.
489
- pi.on("context", async (event) => {
565
+ pi.on("context", async (event, ctx) => {
490
566
  const filtered = event.messages.filter(
491
567
  (message) =>
492
568
  !(message.role === "custom" && message.customType === "rlm-intro")
@@ -499,10 +575,23 @@ export default function rlmExtension(pi: ExtensionAPI): void {
499
575
  // then splice exactly one fresh Σ snapshot. Fail-soft: a throw here must never break a turn.
500
576
  if (rootContextActive()) {
501
577
  try {
502
- const elided = elideStalePayloads(filtered, {
503
- keepTurns: controller.config.rootContextKeepTurns,
504
- elideChars: controller.config.rootContextElideChars,
505
- });
578
+ // Recall W1: every destroyed message's full text rides the sink into the session
579
+ // archive BEFORE the stub replaces it — elision stays dereferenceable.
580
+ const sink = archiveActive()
581
+ ? (entry: { role: "assistant" | "toolResult"; toolName: string | undefined; text: string }) => {
582
+ const seq = sessionArchive.record(entry);
583
+ if (seq !== undefined) archivedTurns += 1;
584
+ }
585
+ : undefined;
586
+ const elided = elideStalePayloads(
587
+ filtered,
588
+ {
589
+ keepTurns: controller.config.rootContextKeepTurns,
590
+ elideChars: controller.config.rootContextElideChars,
591
+ archiveActive: sink !== undefined,
592
+ },
593
+ sink,
594
+ );
506
595
  elidedMessages += elided;
507
596
  const tracker = rootTracker;
508
597
  // R4 REV (amnesia fix): degrade suspends fence WRITES (applyFences gate) — never Σ
@@ -523,8 +612,31 @@ export default function rlmExtension(pi: ExtensionAPI): void {
523
612
  });
524
613
  sigmaSplices += 1;
525
614
  }
615
+ // Recall W1 flush: materialize new archive segments into the sandbox — ONLY when a
616
+ // worker already exists (never spawn Python from a context event) and fail-soft.
617
+ const pendingSegment = archiveActive() && sandboxManager.isAlive
618
+ ? sessionArchive.renderPending()
619
+ : undefined;
620
+ if (pendingSegment !== undefined) {
621
+ const cwd = resolve(ctx?.cwd ?? process.cwd());
622
+ const written = await sandboxManager.upsertArchiveSegment(pendingSegment.path, pendingSegment.text, cwd);
623
+ if (traceEnabled) {
624
+ trace("root-archive.flush", {
625
+ path: pendingSegment.path,
626
+ chars: pendingSegment.text.length,
627
+ written,
628
+ stats: sessionArchive.stats,
629
+ });
630
+ }
631
+ }
526
632
  if (traceEnabled && (elided > 0 || sigmaSplices > 0)) {
527
- trace("root-context.transform", { elided, total: elidedMessages, splices: sigmaSplices });
633
+ trace("root-context.transform", {
634
+ elided,
635
+ total: elidedMessages,
636
+ splices: sigmaSplices,
637
+ archived: archivedTurns,
638
+ archiveChars: sessionArchive.stats.chars,
639
+ });
528
640
  }
529
641
  } catch (err) {
530
642
  if (traceEnabled) trace("root-context.fail", { error: errorMessage(err) });
@@ -571,6 +683,13 @@ export default function rlmExtension(pi: ExtensionAPI): void {
571
683
  event.isError,
572
684
  event.isError ? firstLine(textContentOf(event.content)) : "",
573
685
  );
686
+ // Recall W2 deterministic harvest (commit-at-first-sight, paper §7): successful reads
687
+ // are Σ facts the moment they happen — previously the only runtime feeds were tool
688
+ // ERRORS and edits, so the early turns (the ones elided first) never reached Σ.
689
+ if (!event.isError && event.toolName === "read") {
690
+ const path = extractEditPaths(event.input)[0];
691
+ if (path !== undefined) tracker.noteFact(`read ${path}`);
692
+ }
574
693
  }
575
694
 
576
695
  // ── Keep RLM context fresh after native file mutations ──
@@ -617,14 +736,25 @@ export default function rlmExtension(pi: ExtensionAPI): void {
617
736
  const tracker = rootTracker;
618
737
  if (skillStore !== undefined && tracker !== undefined && tracker.dirty) {
619
738
  try {
620
- skillStore.merge(notesFromRunState(tracker.snapshot()));
621
- if (traceEnabled) trace("root-harvest.merged", { notes: tracker.snapshot().verifiedFacts.length });
739
+ const merged = notesFromRunState(tracker.snapshot());
740
+ skillStore.merge(merged);
741
+ if (merged.length > 0) {
742
+ // One-line stage card: what this session taught the store (tag histogram via stats()).
743
+ postStageCard({
744
+ kind: "distill",
745
+ merged,
746
+ total: skillStore.noteCount,
747
+ byTag: skillStore.stats().byTag,
748
+ });
749
+ }
750
+ if (traceEnabled) trace("root-harvest.merged", { notes: merged.length });
622
751
  } catch (err) {
623
752
  if (traceEnabled) trace("root-harvest.fail", { error: errorMessage(err) });
624
753
  }
625
754
  }
626
755
  await skillStore?.flush(); // SKILL.state (Workstream B): persist distilled notes
627
756
  controller.abort();
757
+ nativeAbort.abort(); // detach engines/spawns still holding the session signal
628
758
  clearInterval(watchdogHeartbeat);
629
759
  background.dispose();
630
760
  await sandboxManager.dispose();
@@ -120,10 +120,10 @@ export class RlmController {
120
120
  }
121
121
 
122
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. */
123
+ * the store is absent/disabled or nothing is relevant. Recall W3: honors the Ξ score floor. */
124
124
  private skillBlockFor(query: string): string | undefined {
125
125
  if (this.skillStore === undefined || !this.config.enableSkillState) return undefined;
126
- const block = this.skillStore.blockFor(query, this.config.skillStateMaxTokens);
126
+ const block = this.skillStore.blockFor(query, this.config.skillStateMaxTokens, this.config.skillStateXiMinScore);
127
127
  return block === "" ? undefined : block;
128
128
  }
129
129
 
@@ -7,6 +7,8 @@
7
7
  * about functions that do not exist, or not told about ones that do.
8
8
  */
9
9
 
10
+ import { ARCHIVE_NAMESPACE } from "../core/session-archive.ts";
11
+
10
12
  export type ContextKind = "files" | "text";
11
13
 
12
14
  /** "str" (raw string context, e.g. rlm_query children) → text; everything else → files. */
@@ -28,9 +30,11 @@ export function promptCapTokensK(maxPromptChars: number): number {
28
30
  * the outcome (§5, Fig. 4a). These cost no tokens and no sub-calls.
29
31
  */
30
32
  const RETRIEVAL_GLOSSARY_LINES: readonly string[] = Object.freeze([
31
- "- `search(query: str, k=10, path_glob=None)`: BM25 ranking over `context`. Returns",
32
- " [{path, line, score, snippet, text}] — POINTERS, not bodies (`text` aliases `snippet`).",
33
- " **Start here.** Free: no sub-LLM call. Use before guessing filenames.",
33
+ "- `search(query: str, k=10, path_glob=None)`: BM25 ranking over `context` (stemmed, with",
34
+ " query expansion try plain words, not just exact identifiers). Returns",
35
+ " [{path, line, end, score, snippet, text}] POINTERS, not bodies (`text` aliases",
36
+ " `snippet`; line..end is the match span; `index_truncated: true` means the context tail",
37
+ " is NOT indexed — narrow with path_glob). **Start here.** Free: no sub-LLM call.",
34
38
  "- `grep_context(pattern, k=50, path_glob=None, before=0, after=0) -> dict`: regex over",
35
39
  " `context`. Returns {hits: [{path, line, text, snippet}], counts, total, truncated} —",
36
40
  " `counts` is complete even when `hits` is capped, so a wide pattern reports its shape",
@@ -56,6 +60,15 @@ const SKILL_SEARCH_GLOSSARY_LINES: readonly string[] = Object.freeze([
56
60
  " smells like something already learned — do not re-discover it.",
57
61
  ]);
58
62
 
63
+ /** W3 recall discoverability: the condensed NATIVE twins. glossary doctrine — divergence
64
+ * between the headless and native surfaces is a bug; `skill_search` had no native twin, so
65
+ * native models could not discover cross-session recall at all (and the archive line
66
+ * teaches the ctx/session-log recall that honest elision stubs point at). */
67
+ export const SKILL_SEARCH_LINE_NATIVE =
68
+ "- `skill_search(query, k=8) -> [{id, text, tags, score}]` — BM25 over distilled project facts from PRIOR sessions. Free; use before re-discovering a learned config/gotcha/symbol.";
69
+ export const ARCHIVE_RECALL_LINE_NATIVE =
70
+ `- Elided chat turns are archived in the sandbox: \`search("<keywords>", path_glob="${ARCHIVE_NAMESPACE}*")\` / \`grep_context()\` recall text that scrolled out of your context — free, no sub-LLM call.`;
71
+
59
72
  /** Single source of wording for the injected SkillState block (headless + native, Workstream C).
60
73
  * Takes the dynamic body as an argument — the glossary itself stays static-only. */
61
74
  /** One wording source for the skill_search recall hint (Ξ block + root Σ snapshot). */
@@ -64,10 +77,21 @@ export const SKILL_RECALL_LINE =
64
77
 
65
78
  /** R5 (G4, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the one-line replacement for assistant prose
66
79
  * older than the keep window — durable facts live in Σ; the repl sandbox (answers/vars) is
67
- * the model-reachable recovery channel the session log is host-side only, never re-openable
68
- * by the model, so stubs must not promise it. */
80
+ * the model-reachable recovery channel for repl-owned payloads. Stubs must promise only a
81
+ * channel that actually holds the bytes (recall W1): repl-owned payloads the repl line,
82
+ * native payloads → the archive line (searchable `ctx/session-log/`), else the plain line. */
69
83
  export const ROOT_TURN_ELIDED_LINE =
70
84
  "… turn elided — durable facts live in Σ; your repl sandbox persists: print(answers) / SHOW_VARS() to re-derive";
85
+ export const ROOT_TURN_ELIDED_ARCHIVE_LINE =
86
+ `… turn elided — durable facts live in Σ; the full text is archived in the sandbox: ` +
87
+ `search('<keywords>', path_glob='${ARCHIVE_NAMESPACE}*') or grep_context() recalls it`;
88
+ export const ROOT_TURN_ELIDED_PLAIN_LINE =
89
+ "… turn elided — durable facts live in Σ";
90
+ /** Preview mark twin: payloads kept as head+tail previews point at the same archive. */
91
+ export const ROOT_ELIDE_PREVIEW_MARK =
92
+ `chars elided — full text archived under ${ARCHIVE_NAMESPACE} (search/grep_context it)`;
93
+ export const ROOT_ELIDE_PREVIEW_MARK_REPL =
94
+ "chars elided — repl sandbox persists: print(answers[k]) or re-run repl to re-derive";
71
95
 
72
96
  export function skillStateLines(noteCount: number, body: string): string {
73
97
  return [
@@ -343,6 +367,9 @@ export function replGlossary(
343
367
  lines.push(
344
368
  "- `llm_query(prompt: str) -> Task`: spawn one sub-LLM (await_task for str). The prompt must",
345
369
  " **contain the text** to analyze — this call has no filesystem and no `context`.",
370
+ " Leaf convention: a sub-LLM answers exactly `NOT_FOUND` when its slice lacks the answer —",
371
+ " treat that as \"not in this slice\": slice differently, search elsewhere, or narrow the ask.",
372
+ " Never re-send an identical prompt hoping for a different verdict.",
346
373
  "- `llm_batch(prompts: list[str]) -> Task`: many parallel one-shots (same rule: embed text).",
347
374
  " await_task → ordered list[str]. NEVER pass bare file paths as if the worker can open them.",
348
375
  ...CHUNKED_GLOSSARY_LINES,
@@ -395,6 +422,8 @@ export function replGlossary(
395
422
  '- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
396
423
  ' set `answer["content"]` to the answer text and `answer["ready"] = True`.',
397
424
  ' **You MUST flip `answer["ready"] = True` — runs that never finalize are discarded.**',
425
+ ' Never write FINAL(...) / FINAL_VAR(...) prose and never emit a ```state fence in the',
426
+ ' reply that finalizes — `answer` is the only finalize channel; Σ bookkeeping waits.',
398
427
  );
399
428
  return lines.join("\n");
400
429
  }
@@ -6,11 +6,13 @@
6
6
  */
7
7
 
8
8
  import {
9
+ ARCHIVE_RECALL_LINE_NATIVE,
9
10
  CHUNKED_GLOSSARY_LINE_NATIVE,
10
11
  ENV_TIPS_CONDENSED,
11
12
  LARGE_FILE_RULE_NATIVE,
12
13
  DEFAULT_PROMPT_CAP,
13
14
  promptCapTokensK,
15
+ SKILL_SEARCH_LINE_NATIVE,
14
16
  } from "./glossary.ts";
15
17
  import { STATE_FENCE_INSTRUCTION } from "../core/run-state.ts";
16
18
 
@@ -25,6 +27,7 @@ function nativeReplGlossary(): string {
25
27
  "- `search(query, k=10, path_glob=None) -> [{path, line, score, snippet, text}]` — BM25 pointers, not bodies",
26
28
  "- `grep_context(pattern, k=50, …) -> {hits, counts, total, truncated}` — regex / lexical needles",
27
29
  "- `outline(path) -> str` — definition skeleton (~200 chars)",
30
+ ARCHIVE_RECALL_LINE_NATIVE,
28
31
  "",
29
32
  "### Always-spawn fan-out (return Task + run ↯bg — NEVER the answer)",
30
33
  "| Call | await_task → | When | NOT for |",
@@ -47,6 +50,7 @@ function nativeReplGlossary(): string {
47
50
  "- `answers` / `plan` — persistent dicts for **collected** results. Task handles are REPL vars (`t`), not `answers` keys.",
48
51
  "- `add_context(source) -> dict` — append external dir/file/doc/git under `ctx/<id>/…` (metadata only).",
49
52
  "- `list_claims()` — the live `[ledger]` table of agent work.",
53
+ SKILL_SEARCH_LINE_NATIVE,
50
54
  "- `SHOW_VARS()` — list REPL vars (Tasks as `<Task …>`). `list_tasks()` finds Task handles. `answer[\"ready\"]=True` only for headless finalize (native: write a normal message).",
51
55
  "",
52
56
  ENV_TIPS_CONDENSED,
@@ -189,8 +193,10 @@ export function buildNativeSystemPrompt(opts?: { readonly stateFences?: boolean
189
193
  ].join("\n");
190
194
  }
191
195
 
192
- /** Soft cap on the static native prompt. Raised for v5-style contract/routing/examples. */
193
- export const NATIVE_PROMPT_BUDGET = 9_500;
196
+ /** Soft cap on the static native prompt. Raised for v5-style contract/routing/examples;
197
+ * recall W3 raised it again (+200) for the archive-recall + skill_search twin lines —
198
+ * the model cannot recall elided turns or prior-session facts it is never told about. */
199
+ export const NATIVE_PROMPT_BUDGET = 9_700;
194
200
 
195
201
  /** Exported for tests — prompt length without context metadata (which is injected separately). */
196
202
  export const NATIVE_PROMPT_STATIC = buildNativeSystemPrompt();
@@ -26,6 +26,7 @@ export function buildTurnPrompt(
26
26
  export const FINALIZE_PROMPT =
27
27
  "You are out of turns. Finalize NOW: set `answer[\"content\"]` and `answer[\"ready\"] = True` " +
28
28
  "(fenced ```repl```) with your best final answer from everything you have gathered. " +
29
+ "Finalize ONLY through `answer` — no FINAL(...) prose and no ```state fence in this reply. " +
29
30
  "Only if the REPL is unavailable, answer as plain text.";
30
31
 
31
32
  /** One-shot retrieval-discipline nudge (the engine owns the when — see core/engine.ts). The
@@ -44,9 +45,9 @@ export const REASONING_BUDGET_HINT =
44
45
  "the completion budget with the answer, so long thought may be cut off mid-reasoning. " +
45
46
  "Keep thought concise, or raise rootSampling.maxTokens.";
46
47
 
47
- /** One-shot verification-discipline nudge (default OFF — enableVerificationNudge): fired when
48
- * the root finalizes suspiciously early with a bare number / short label. The model gets ONE
49
- * coached redo instead of having the answer accepted. */
48
+ /** One-shot verification-discipline nudge (default ON — enableVerificationNudge): fired when
49
+ * the root finalizes suspiciously early with a bare answer, or without having inspected its
50
+ * context at all. The model gets ONE coached redo instead of having the answer accepted. */
50
51
  export const VERIFICATION_NUDGE =
51
52
  "[coach] That answer was submitted suspiciously early and looks under-verified. Before " +
52
53
  "finalizing: recompute the key quantity inside a ```repl block (show the actual computation, " +