@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
@@ -109,9 +109,32 @@ const RUN_STATE_FIELDS: ReadonlySet<string> = new Set([
109
109
  const ARRAY_FIELDS: ReadonlySet<string> = new Set(["findings", "verifiedFacts", "openQuestions"]);
110
110
  const TASK_MAX_CHARS = 200;
111
111
  const NEXT_STEP_MAX_CHARS = 300;
112
+ // Recall W2 contract alignment: STATE_FENCE_INSTRUCTION promises "≤ 5 keys per patch, every
113
+ // string value ≤ 120 chars" — the validator now MEANS it. Oversized values are CLAMPED
114
+ // fail-soft (the prose carries the story; Σ carries pointers), while the key count is
115
+ // rejected with an explicit error (a restatement-shaped patch must come back as feedback —
116
+ // §5.7: consistent validator feedback is the small-model bottleneck).
117
+ const STATE_PATCH_MAX_KEYS = 5;
118
+ const STATE_VALUE_MAX_CHARS = 120;
112
119
  // `findings` | `findings[+]` | `findings[2]` | `testedApproaches.h1`
113
120
  const PATCH_KEY = /^([a-zA-Z_][a-zA-Z0-9_]*)((?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)(\[\+\]|\[\d+\])?$/;
114
121
 
122
+ /** Fail-soft contract clamp — every string Σ value honors the promised ≤120 chars. */
123
+ function clampStateValue(value: string): string {
124
+ return value.length > STATE_VALUE_MAX_CHARS ? value.slice(0, STATE_VALUE_MAX_CHARS) : value;
125
+ }
126
+
127
+ /** Clamp the string fields of a validated outcome record IN PLACE (string→clamped, everything
128
+ * else — explicit `null` deletes included — passes through untouched; the strict-merge null
129
+ * semantics live in strictMergeRecord and must never be eaten by the clamp). */
130
+ function clampOutcomeRecord(value: Readonly<Record<string, unknown>>): Record<string, unknown> {
131
+ const out: Record<string, unknown> = {};
132
+ for (const [key, field] of Object.entries(value)) {
133
+ out[key] = typeof field === "string" ? clampStateValue(field) : field;
134
+ }
135
+ return out;
136
+ }
137
+
115
138
  export function freshRunState(task: string): RunState {
116
139
  return {
117
140
  task,
@@ -256,20 +279,23 @@ export function enforceCaps(draft: MutableState): Result<RunState, PatchError> {
256
279
  artifacts: { ...draft.artifacts },
257
280
  };
258
281
  // bytesTotal: |Σ_t| must never exceed κ_Σ — deterministic eviction order keeps runs flat.
282
+ // Order (recall W2): diary entries (findings) go first, SPECULATIVE entries (openQuestions)
283
+ // next, FOUNDATION (verifiedFacts: paths/symbols/configs) survives longest — losing a
284
+ // foundational fact is the irreversible-recall failure the paper warns about (§7 case 2).
259
285
  let guard = 0;
260
286
  while (JSON.stringify(capped).length > RUN_STATE_LIMITS.bytesTotal && guard++ < 10_000) {
261
287
  if (capped.findings.length > 0) {
262
288
  capped.findings = capped.findings.slice(1);
263
289
  continue;
264
290
  }
265
- if (capped.verifiedFacts.length > 0) {
266
- capped.verifiedFacts = capped.verifiedFacts.slice(1);
267
- continue;
268
- }
269
291
  if (capped.openQuestions.length > 0) {
270
292
  capped.openQuestions = capped.openQuestions.slice(1);
271
293
  continue;
272
294
  }
295
+ if (capped.verifiedFacts.length > 0) {
296
+ capped.verifiedFacts = capped.verifiedFacts.slice(1);
297
+ continue;
298
+ }
273
299
  const approach = firstKey(capped.testedApproaches);
274
300
  if (approach !== undefined) {
275
301
  const rest = { ...capped.testedApproaches };
@@ -331,7 +357,7 @@ function applyKey(draft: MutableState, rawKey: string, value: unknown): Result<n
331
357
  }
332
358
  if (op === "[+]") {
333
359
  if (typeof value !== "string") return err({ kind: "type", path: rawKey, expected: "string" });
334
- list.push(value);
360
+ list.push(clampStateValue(value));
335
361
  } else if (op !== undefined) {
336
362
  const index = Number.parseInt(op.slice(1, -1), 10);
337
363
  if (value === null) {
@@ -346,11 +372,11 @@ function applyKey(draft: MutableState, rawKey: string, value: unknown): Result<n
346
372
  if (index > list.length) {
347
373
  return err({ kind: "schema", detail: `${rawKey} would leave a hole (len=${list.length})` });
348
374
  }
349
- list[index] = value;
375
+ list[index] = clampStateValue(value);
350
376
  } else {
351
377
  if (!isStringArray(value)) return err({ kind: "type", path: rawKey, expected: "string[]" });
352
378
  list.length = 0;
353
- list.push(...dedupStrings(value));
379
+ list.push(...dedupStrings(value.map(clampStateValue)));
354
380
  }
355
381
  return ok(null);
356
382
  }
@@ -409,15 +435,17 @@ function applyKey(draft: MutableState, rawKey: string, value: unknown): Result<n
409
435
  if (!isPlainObject(value) || !isApproachOutcome(value)) {
410
436
  return err({ kind: "type", path: rawKey, expected: "ApproachOutcome" });
411
437
  }
438
+ // Contract clamp: outcome strings honor the promised ≤120 chars (nulls stay nulls).
439
+ const outcome = clampOutcomeRecord(value);
412
440
  const prev: unknown = cursor[leaf];
413
441
  const merged = isPlainObject(prev)
414
- ? strictMergeRecord(prev, value, rawKey)
415
- : ok<Record<string, unknown>, PatchError>({ ...value });
442
+ ? strictMergeRecord(prev, outcome, rawKey)
443
+ : ok<Record<string, unknown>, PatchError>(outcome);
416
444
  if (!merged.ok) return merged;
417
445
  refreshOrder(cursor, leaf, merged.value); // last-mention ordering for eviction
418
446
  } else {
419
447
  if (typeof value !== "string") return err({ kind: "type", path: rawKey, expected: "string" });
420
- refreshOrder(cursor, leaf, value);
448
+ refreshOrder(cursor, leaf, clampStateValue(value));
421
449
  }
422
450
  commitRecord(draft, root, record);
423
451
  return ok(null);
@@ -454,6 +482,14 @@ export function applyPatch(prev: RunState, patch: unknown, t: number): Result<Ru
454
482
  }
455
483
  const ops = Object.entries(patch.state_patch);
456
484
  if (ops.length === 0) return err({ kind: "schema", detail: "empty state_patch" });
485
+ // Contract alignment (recall W2): the instruction promises ≤ 5 keys — a bigger patch is a
486
+ // restatement, and restatements must come back as feedback, not silently inflate Σ.
487
+ if (ops.length > STATE_PATCH_MAX_KEYS) {
488
+ return err({
489
+ kind: "schema",
490
+ detail: `${ops.length} keys — ≤ ${STATE_PATCH_MAX_KEYS} per patch; commit deltas only`,
491
+ });
492
+ }
457
493
  // Per-turn byte cap (bench rec #1): reject BEFORE the draft clone — a verbose restatement
458
494
  // must come back as an error observation, never silently eat output tokens.
459
495
  if (JSON.stringify(patch).length > RUN_STATE_LIMITS.patchBytes) {
@@ -469,6 +505,21 @@ export function applyPatch(prev: RunState, patch: unknown, t: number): Result<Ru
469
505
  return enforceCaps(draft);
470
506
  }
471
507
 
508
+ /** The actual limits, spelled out at rejection time — a small model told only "cap exceeded"
509
+ * thrashes blind retries into the degrade threshold. Field name → human limit (one source:
510
+ * RUN_STATE_LIMITS + the scalar caps). */
511
+ const CAP_LIMITS: Readonly<Record<string, string>> = Object.freeze({
512
+ task: `≤ ${TASK_MAX_CHARS} chars`,
513
+ nextStep: `≤ ${NEXT_STEP_MAX_CHARS} chars`,
514
+ findings: `≤ ${RUN_STATE_LIMITS.findings} entries`,
515
+ verifiedFacts: `≤ ${RUN_STATE_LIMITS.verifiedFacts} entries`,
516
+ testedApproaches: `≤ ${RUN_STATE_LIMITS.testedApproaches} entries`,
517
+ openQuestions: `≤ ${RUN_STATE_LIMITS.openQuestions} entries`,
518
+ artifacts: `≤ ${RUN_STATE_LIMITS.artifacts} entries`,
519
+ patchBytes: `≤ ${RUN_STATE_LIMITS.patchBytes} bytes per patch`,
520
+ bytesTotal: `≤ ${RUN_STATE_LIMITS.bytesTotal} bytes total`,
521
+ });
522
+
472
523
  /** Human-facing patch rejection — becomes the next O_t prefix (error-as-observation). */
473
524
  export function patchErrorText(error: PatchError): string {
474
525
  switch (error.kind) {
@@ -478,8 +529,10 @@ export function patchErrorText(error: PatchError): string {
478
529
  return `type mismatch at "${error.path}" (expected ${error.expected})`;
479
530
  case "implicit-drop":
480
531
  return `implicit key drop at "${error.path}" — restate the key or delete it with null`;
481
- case "cap":
482
- return `cap exceeded on "${error.field}"`;
532
+ case "cap": {
533
+ const limit = CAP_LIMITS[error.field];
534
+ return `cap exceeded on "${error.field}"${limit === undefined ? "" : ` — the limit is ${limit}`}`;
535
+ }
483
536
  }
484
537
  }
485
538
 
@@ -491,6 +544,9 @@ export function patchErrorText(error: PatchError): string {
491
544
  * Idle degrade (bench rec #2): when `fenceRequested` is true (the turn conditioned on Σ)
492
545
  * and zero patches were accepted, the turn is IDLE — Σ inflated the prompt for nothing.
493
546
  * `RUN_STATE_IDLE_DEGRADE_TURNS` consecutive idle turns degrade, same as-built outcome.
547
+ * `productive` (root parity, recall W2): a turn that executed real repl work without
548
+ * raising RESETS the idle streak — heavy execution is progress even without a delta, and
549
+ * a mid-run amputation of Σ costs more than the prompt it saves.
494
550
  */
495
551
  export function applyStatePatches(
496
552
  mode: Extract<RunStateMode, { kind: "active" }>,
@@ -498,10 +554,11 @@ export function applyStatePatches(
498
554
  iteration: number,
499
555
  config: Pick<RlmConfig, "runStateRetryMax">,
500
556
  fenceRequested = false,
557
+ productive = false,
501
558
  ): { readonly mode: RunStateMode; readonly observation: string | undefined } {
502
559
  // Identity fast-path: a fence-free turn on a run that never asked for fences changes
503
560
  // nothing — keep the same mode object (callers may compare identity).
504
- if (parsed.length === 0 && !fenceRequested) return { mode, observation: undefined };
561
+ if (parsed.length === 0 && !fenceRequested && !productive) return { mode, observation: undefined };
505
562
  let state = mode.state;
506
563
  let retries = mode.retries;
507
564
  let accepted = 0;
@@ -522,7 +579,8 @@ export function applyStatePatches(
522
579
  }
523
580
  }
524
581
  // Bench rec #2: accepted deltas reset the idle streak; a requested-but-empty turn grows it.
525
- const idle = accepted > 0 || !fenceRequested ? 0 : mode.idle + 1;
582
+ // Productive-turn parity (root tracker): executed work resets the streak too.
583
+ const idle = accepted > 0 || !fenceRequested || productive ? 0 : mode.idle + 1;
526
584
  let nextMode: RunStateMode = { kind: "active", state, retries, idle };
527
585
  if (retries > config.runStateRetryMax) {
528
586
  nextMode = {
@@ -557,6 +615,9 @@ export const STATE_FENCE_INSTRUCTION: string =
557
615
  "Σ is an index of pointers, not a report: ≤ 5 keys per patch, every string value ≤ 120 chars, " +
558
616
  "telegraphic style (`path — fact`, `verdict — numbers`). NEVER paste findings, tables, JSON " +
559
617
  "blobs, or long excerpts into Σ — the prose carries the story, Σ carries only the pointers.\n" +
618
+ `Caps: findings ≤ ${RUN_STATE_LIMITS.findings}, verifiedFacts ≤ ${RUN_STATE_LIMITS.verifiedFacts}, ` +
619
+ `testedApproaches ≤ ${RUN_STATE_LIMITS.testedApproaches}, openQuestions/artifacts ≤ ${RUN_STATE_LIMITS.openQuestions}/${RUN_STATE_LIMITS.artifacts} — ` +
620
+ "oldest entries are evicted automatically, so push new facts and let Σ prune itself.\n" +
560
621
  "Keys: dotted paths write record leaves; [+] appends; [N] sets an array slot; null deletes.\n" +
561
622
  "Commit DELTAS only — never restate unchanged records or arrays; touch single dotted keys " +
562
623
  `or append with [+]. Whole-record restatements must keep EVERY key (implicit drops are ` +
@@ -578,6 +639,17 @@ export function runStateTurnBlock(state: RunState): string {
578
639
  return sigmaBlock(state, true);
579
640
  }
580
641
 
642
+ /**
643
+ * Σ economics: re-sending a byte-identical Σ every turn is up to ~3K tokens of attention tax
644
+ * that carries zero new information — the model saw it one turn ago. The marker keeps the
645
+ * patch grammar within reach (a small model without it stops committing at all) while cutting
646
+ * the block ~10x. Callers must re-send the FULL block after compaction/rebase or degrade.
647
+ */
648
+ export const SIGMA_UNCHANGED_LINE =
649
+ "[Σ] unchanged since last turn — deltas apply against it. Grammar: dotted keys, [+] append, " +
650
+ "[N] set slot, null delete; ≤ 5 keys per patch, every value ≤ 120 chars. Fence only NEW " +
651
+ "durable facts; an absent fence is free.";
652
+
581
653
  /**
582
654
  * Root Σ (WS-3b): the ROOT's A_t block. R2 (G2): with `withContract` the splice carries the
583
655
  * SAME fence contract (delegating to the one composer above — no re-wording), making the
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Session archive (recall W1) — the model-reachable copy of elided root history.
3
+ *
4
+ * The root context transform (core/root-context.ts) stubs every turn older than the keep
5
+ * window; before this module those bytes were unrecoverable — the session log is host-side
6
+ * only and the repl sandbox never saw native read/bash payloads. The archive closes the
7
+ * loop: every message the elision destroys is recorded here, rendered into markdown
8
+ * segments, and materialized into the sandbox under `ctx/session-log/` through the SAME
9
+ * upsert seam native edit/write uses (context/refresh.ts) — so the existing free
10
+ * search() / grep_context() BM25 recall them (RLM paper §2: context stays an environment;
11
+ * LLM-memory survey §5: query, not storage, is the recall bottleneck).
12
+ *
13
+ * The elision re-runs on EVERY provider call over a fresh clone, so records dedup by
14
+ * content hash (the transcript is append-only; the same stale turn re-elides each call).
15
+ * Segment materialization is idempotent per segment path and fail-soft end to end.
16
+ */
17
+
18
+ import { truncateOutput } from "../text/parsing.ts";
19
+
20
+ /** Sandbox namespace the segments materialize under — one wording source for the stubs
21
+ * that teach recall (prompts/glossary.ts) and for the materialized paths. */
22
+ export const ARCHIVE_NAMESPACE = "ctx/session-log/";
23
+
24
+ /** Per-record cap: a 2MB tool payload is archived mid-truncated (head+tail) — recall needs
25
+ * the shape and the needles, not every byte (paper A.4 compact-serialization doctrine). */
26
+ const ARCHIVE_RECORD_MAX_CHARS = 100_000;
27
+ /** Host-memory ring cap (chars). Already-materialized segments live in the worker; this
28
+ * caps only the host copy. Configurable as `rootArchiveMaxChars` (0 = archive off). */
29
+ export const ARCHIVE_DEFAULT_MAX_CHARS = 2_000_000;
30
+ /** Dedup-set cap: FIFO eviction of the oldest hashes; a re-elided evicted message is
31
+ * re-recorded harmlessly (a duplicate segment entry, not a correctness issue). */
32
+ const HASH_SET_MAX = 8_192;
33
+
34
+ const ARCHIVE_TRUNCATE_MARK = "chars archived out — the middle never enters the archive";
35
+
36
+ export interface ArchivedEntry {
37
+ readonly role: "assistant" | "toolResult";
38
+ readonly toolName: string | undefined;
39
+ readonly text: string;
40
+ }
41
+
42
+ /** FNV-1a 32-bit over role+name+text — the dedup key (same double-hash idiom as skillstate). */
43
+ function entryHash(entry: ArchivedEntry): string {
44
+ const basis = `${entry.role}\u0000${entry.toolName ?? ""}\u0000${entry.text}`;
45
+ let h1 = 0x811c9dc5;
46
+ let h2 = 0x811c9dc5;
47
+ for (let i = 0; i < basis.length; i++) {
48
+ const c = basis.charCodeAt(i);
49
+ h1 = (h1 ^ c) * 0x01000193;
50
+ h2 = (h2 ^ (c + i)) * 0x01000193;
51
+ h1 >>>= 0;
52
+ h2 >>>= 0;
53
+ }
54
+ return `${h1.toString(36)}${h2.toString(36)}`;
55
+ }
56
+
57
+ interface RingEntry {
58
+ readonly seq: number;
59
+ readonly hash: string;
60
+ readonly entry: ArchivedEntry;
61
+ }
62
+
63
+ export interface ArchiveSegment {
64
+ /** Unique context path for this segment (`ctx/session-log/turn-<a>-<b>.md`). */
65
+ readonly path: string;
66
+ readonly text: string;
67
+ }
68
+
69
+ export class SessionArchive {
70
+ private ring: RingEntry[] = [];
71
+ private readonly seen = new Map<string, true>();
72
+ private totalChars = 0;
73
+ private nextSeq = 1;
74
+ private materializedThrough = 0;
75
+ private lostWhilePending = 0;
76
+ private pendingChars = 0;
77
+ private pendingCount = 0;
78
+
79
+ constructor(private readonly maxChars: number) {}
80
+
81
+ /**
82
+ * Record one elided message. Returns the assigned seq, or undefined for
83
+ * empty/duplicate records. Per-record truncation applies BEFORE the ring cap.
84
+ */
85
+ record(entry: ArchivedEntry): number | undefined {
86
+ const text = entry.text.trim();
87
+ if (text === "") return undefined;
88
+ const capped: ArchivedEntry = {
89
+ ...entry,
90
+ text: text.length > ARCHIVE_RECORD_MAX_CHARS
91
+ ? truncateOutput(text, ARCHIVE_RECORD_MAX_CHARS, ARCHIVE_TRUNCATE_MARK)
92
+ : text,
93
+ };
94
+ const hash = entryHash(capped);
95
+ if (this.seen.has(hash)) return undefined;
96
+ this.seen.set(hash, true);
97
+ if (this.seen.size > HASH_SET_MAX) {
98
+ // FIFO: Map preserves insertion order; drop the oldest hash only.
99
+ const oldest = this.seen.keys().next();
100
+ if (oldest.done !== true) this.seen.delete(oldest.value);
101
+ }
102
+ const seq = this.nextSeq++;
103
+ this.ring.push({ seq, hash, entry: capped });
104
+ this.totalChars += capped.text.length;
105
+ if (seq > this.materializedThrough) {
106
+ this.pendingChars += capped.text.length;
107
+ this.pendingCount += 1;
108
+ }
109
+ this.evictOverCap();
110
+ return seq;
111
+ }
112
+
113
+ /** True when unmaterialized records exist (flush when the sandbox is next alive). */
114
+ get hasPending(): boolean {
115
+ return this.pendingCount > 0;
116
+ }
117
+
118
+ get pendingCharsValue(): number {
119
+ return this.pendingChars;
120
+ }
121
+
122
+ /** Telemetry: entries + chars currently held (host copy). */
123
+ get stats(): { readonly entries: number; readonly chars: number; readonly recorded: number } {
124
+ return { entries: this.ring.length, chars: this.totalChars, recorded: this.nextSeq - 1 };
125
+ }
126
+
127
+ /**
128
+ * Render every unmaterialized record into ONE segment and mark it materialized.
129
+ * Returns undefined when nothing is pending. The path embeds the covered seq range so
130
+ * segments never collide and the `ctx/session-log/*` glob covers them all.
131
+ */
132
+ renderPending(): ArchiveSegment | undefined {
133
+ const pending = this.ring.filter((r) => r.seq > this.materializedThrough);
134
+ if (pending.length === 0) return undefined;
135
+ const parts: string[] = [];
136
+ if (this.lostWhilePending > 0) {
137
+ parts.push(
138
+ `(archive cap dropped ${this.lostWhilePending} older elided turn(s) before materialization)`,
139
+ );
140
+ this.lostWhilePending = 0;
141
+ }
142
+ for (const r of pending) {
143
+ const label = r.entry.role === "toolResult" && r.entry.toolName !== undefined
144
+ ? `${r.entry.role} (${r.entry.toolName})`
145
+ : r.entry.role;
146
+ parts.push(`### turn ${r.seq} — ${label}\n${r.entry.text}`);
147
+ }
148
+ const first = pending[0]?.seq ?? 0;
149
+ const last = pending[pending.length - 1]?.seq ?? first;
150
+ this.materializedThrough = last;
151
+ this.pendingChars = 0;
152
+ this.pendingCount = 0;
153
+ return {
154
+ path: `${ARCHIVE_NAMESPACE}turn-${first}-${last}.md`,
155
+ text: parts.join("\n\n"),
156
+ };
157
+ }
158
+
159
+ /** Ring eviction: drop OLDEST entries until under cap. Evicting an unmaterialized
160
+ * record loses it from recall — counted so the next segment says so honestly. */
161
+ private evictOverCap(): void {
162
+ while (this.totalChars > this.maxChars && this.ring.length > 1) {
163
+ const oldest = this.ring[0];
164
+ if (oldest === undefined) return;
165
+ this.ring = this.ring.slice(1);
166
+ this.totalChars -= oldest.entry.text.length;
167
+ if (oldest.seq > this.materializedThrough) {
168
+ this.pendingChars -= oldest.entry.text.length;
169
+ this.pendingCount -= 1;
170
+ this.lostWhilePending += 1;
171
+ }
172
+ }
173
+ }
174
+ }
package/src/core/types.ts CHANGED
@@ -121,6 +121,9 @@ export interface RlmConfig {
121
121
  readonly skillStateLeafTokens: number;
122
122
  /** BM25 score a note must clear before a leaf prompt gets grounded (below ⇒ byte-identical). */
123
123
  readonly skillStateMinScore: number;
124
+ /** BM25 score a note must clear before it enters the Ξ root block (below ⇒ not injected).
125
+ * Was effectively 0 (`Number.MIN_VALUE`) — stale cross-session notes rode every prompt. */
126
+ readonly skillStateXiMinScore: number;
124
127
  /** Per-project note cap; LRU by ts with the top-hits quartile pinned (Workstream B). */
125
128
  readonly skillStateNotesPerProject: number;
126
129
 
@@ -141,6 +144,9 @@ export interface RlmConfig {
141
144
  readonly rootContextKeepTurns: number;
142
145
  /** Tool-result payloads older than the keep window are preview-capped at this many chars. */
143
146
  readonly rootContextElideChars: number;
147
+ /** Recall W1: host-memory ring cap (chars) for the session archive that makes elision
148
+ * dereferenceable (`ctx/session-log/*` in the sandbox). 0 = archive off (plain stubs). */
149
+ readonly rootArchiveMaxChars: number;
144
150
  /** Splice the RootStateTracker Σ snapshot before the last user message each call. */
145
151
  readonly rootContextSnapshot: boolean;
146
152
  /** WS-4.2 (default OFF, paper §5.7 fence tax): the root may commit ΔΣ_t via a ```state