@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.
- package/package.json +1 -1
- package/src/commands/rlm.ts +14 -7
- package/src/config/defaults.ts +33 -10
- package/src/config/settings.ts +6 -0
- package/src/config/skillstate.ts +236 -44
- package/src/core/budget.ts +7 -3
- package/src/core/compaction.ts +2 -2
- package/src/core/engine.ts +87 -19
- package/src/core/root-context.ts +74 -21
- package/src/core/root-digest.ts +48 -11
- package/src/core/root-state.ts +39 -12
- package/src/core/run-state.ts +86 -14
- package/src/core/session-archive.ts +174 -0
- package/src/core/types.ts +6 -0
- package/src/index.ts +142 -12
- package/src/mode/rlm-mode.ts +2 -2
- package/src/prompts/glossary.ts +34 -5
- package/src/prompts/native.ts +8 -2
- package/src/prompts/user.ts +4 -3
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/retrieval.py +202 -36
- package/src/sandbox/py/scaffold.py +20 -5
- package/src/sandbox/py/worker.py +1 -1
- package/src/sandbox/sandbox-manager.ts +19 -0
- package/src/text/parsing.ts +133 -2
- package/src/text/tokens.ts +39 -4
- package/src/tool/repl-render.ts +38 -2
- package/src/tool/repl-tool.ts +34 -18
- package/src/tool/subcall-render.ts +7 -4
- package/src/ui/config-panel.ts +2 -2
- package/src/ui/intro.ts +1 -1
- package/src/ui/python-highlight.ts +49 -0
- package/src/ui/stage-cards.ts +192 -0
- package/src/ui/tree/tree-model.ts +69 -19
- package/src/ui/tree/tree-rows.ts +2 -1
- package/src/util/abort.ts +34 -0
- package/src/util/bm25.ts +170 -21
- package/src/util/errors.ts +1 -1
package/src/core/run-state.ts
CHANGED
|
@@ -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,
|
|
415
|
-
: ok<Record<string, unknown>, PatchError>(
|
|
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
|
-
|
|
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
|
-
|
|
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
|