@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
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Root Σ (WS-3) — per-LLM-call A_t assembly for the native Pi session, via the `context`
3
+ * event (which feeds Pi's transformContext → applied on EVERY provider call).
4
+ *
5
+ * Pi hands handlers a structuredClone of the outgoing messages; the LAST returned array
6
+ * wins and the disk transcript is never touched — context is the query channel, the
7
+ * session log stays the archive (LLM-memory-survey thesis). Both transforms mutate the
8
+ * clone in place (new message objects land in the same array slots; splice uses the same
9
+ * array) to honor the zero-extra-allocations rule.
10
+ *
11
+ * - `elideStalePayloads` — discard semantics (paper §5.3): tool payloads older than the
12
+ * keep window become head+tail previews; the full bytes remain in the session log.
13
+ * - `spliceSigmaSnapshot` — the fresh Σ snapshot rides immediately before the last user
14
+ * message, exactly one instance (previous ones are removed — idempotent per call).
15
+ *
16
+ * Both are pure with respect to the transcript and never throw on weird shapes.
17
+ */
18
+
19
+ import type { ContextEvent } from "@earendil-works/pi-coding-agent";
20
+ import type { RunState } from "./run-state.ts";
21
+ import { runStateRootBlock } from "./run-state.ts";
22
+ import { ROOT_TURN_ELIDED_LINE } from "../prompts/glossary.ts";
23
+ import { truncateOutput } from "../text/parsing.ts";
24
+ import { textContentOf } from "../text/agent-text.ts";
25
+
26
+ /** The message union Pi passes through the context event (indexed — host evolution safe). */
27
+ export type RootMessage = ContextEvent["messages"][number];
28
+
29
+ export interface ElideOptions {
30
+ readonly keepTurns: number;
31
+ readonly elideChars: number;
32
+ }
33
+
34
+ const ELIDE_MARK = "chars elided — full result in session log";
35
+
36
+ /**
37
+ * WS-3a: elide stale turns. The newest `keepTurns` assistant turns and the final user message
38
+ * stay verbatim. Older turns collapse in two tiers (§5.3 discard semantics + R5/G4 honest
39
+ * strictness, and the only way the R6 acceptance bound — per-call ≤ Σ + window — can hold):
40
+ * - recent stale ring (the last `max(keepTurns, 1)` stale turns): toolResult payloads over
41
+ * `elideChars` become head+tail previews (same truncation shape as repl stdout); assistant
42
+ * prose always collapses to the one-line stub.
43
+ * - older still: EVERYTHING (payloads included) becomes the one-line session-log stub —
44
+ * a stale preview per turn would itself accumulate linearly and re-create O(T).
45
+ * `role:"custom"` messages of the Σ/intro kinds are immune (WS-3b owns them). Mutates the
46
+ * array in place; returns the number of messages elided (telemetry), for zero-cost counters.
47
+ */
48
+ export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions): number {
49
+ const keepTurns = Math.max(0, Math.floor(opts.keepTurns));
50
+ if (messages.length === 0) return 0;
51
+ // Preview ring: stale turns recent enough to deserve the §5.3 head+tail preview. Scales with
52
+ // the window knob — one calibration, two tiers (preview ring, then stub) — and never zero,
53
+ // so keepTurns=0 still previews the single closest stale payload instead of stubbing blind.
54
+ const previewRing = Math.max(keepTurns, 1);
55
+ if (keepTurns === 0) {
56
+ // R5 strict-0: nothing inside the window — Σ + immune customs + the final user message
57
+ // are all that survive verbatim.
58
+ return elideRange(messages, 0, messages.length, opts, previewRing);
59
+ }
60
+
61
+ // Index of the assistant message that opens the keepTurns-th-from-last turn — everything
62
+ // from there on is the protected tail (same walk as core/compaction.ts elideOldToolPayloads).
63
+ let tailStart = -1;
64
+ let seen = 0;
65
+ for (let i = messages.length - 1; i >= 0; i--) {
66
+ if (messages[i]?.role === "assistant") {
67
+ seen += 1;
68
+ if (seen >= keepTurns) {
69
+ tailStart = i;
70
+ break;
71
+ }
72
+ }
73
+ }
74
+ if (tailStart <= 0) return 0; // fewer turns than the window — nothing to elide
75
+ return elideRange(messages, 0, tailStart, opts, previewRing);
76
+ }
77
+
78
+ /** Custom messages the elision never touches — the Σ snapshot/observation and the intro. */
79
+ const IMMUNE_CUSTOM_TYPES: ReadonlySet<string> = new Set(["rlm-sigma", "rlm-sigma-observation", "rlm-intro"]);
80
+
81
+ function isImmuneCustom(m: RootMessage): boolean {
82
+ if (m.role !== "custom") return false;
83
+ const customType: unknown = (m as { customType?: unknown }).customType;
84
+ return typeof customType === "string" && IMMUNE_CUSTOM_TYPES.has(customType);
85
+ }
86
+
87
+ /** R5: elide [from, to) — stale assistant prose always becomes the one-line Σ stub; stale
88
+ * toolResults get the §5.3 preview while `assistantsAfter < previewRing` and the stub beyond
89
+ * (two-tier elision — previews must not accumulate linearly). Immune customs and the final
90
+ * user message are never touched. Mutates in place; returns the elided count. */
91
+ function elideRange(
92
+ messages: RootMessage[],
93
+ from: number,
94
+ to: number,
95
+ opts: ElideOptions,
96
+ previewRing: number,
97
+ ): number {
98
+ const lastUser = lastIndexOfRole(messages, "user");
99
+ // Pre-pass: stale assistant indices in [from, to) — a payload's recency is measured by the
100
+ // stale turns AFTER it (pre-allocated walk, no per-message allocation).
101
+ let staleAssistants = 0;
102
+ for (let i = from; i < to; i++) {
103
+ if (messages[i]?.role === "assistant") staleAssistants += 1;
104
+ }
105
+ let elided = 0;
106
+ for (let i = from; i < to; i++) {
107
+ const m = messages[i];
108
+ if (m === undefined || isImmuneCustom(m)) continue;
109
+ if (i === lastUser) continue; // paranoia: the final user message is never touched
110
+ if (m.role === "assistant") {
111
+ staleAssistants -= 1; // turns AFTER this one = count minus itself
112
+ messages[i] = { ...m, content: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }] } as RootMessage;
113
+ elided += 1;
114
+ continue;
115
+ }
116
+ if (m.role !== "toolResult") continue;
117
+ if (staleAssistants >= previewRing) {
118
+ // Deep-stale payload: even the preview would accumulate — collapse to the stub.
119
+ messages[i] = { ...m, content: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }] } as RootMessage;
120
+ elided += 1;
121
+ continue;
122
+ }
123
+ if (totalTextLength(m) <= opts.elideChars) continue;
124
+ messages[i] = {
125
+ ...m,
126
+ content: [{ type: "text", text: previewToolText(m, opts.elideChars) }],
127
+ } as RootMessage;
128
+ elided += 1;
129
+ }
130
+ return elided;
131
+ }
132
+
133
+ /** WS-3b: exactly one live Σ snapshot, immediately before the LAST user message.
134
+ * R2 (G2): `withContract` makes the splice carry the fence contract (A.4 authoring mode) —
135
+ * pass-through to runStateRootBlock; without it, the v1 observation-only block. */
136
+ export function spliceSigmaSnapshot(
137
+ messages: RootMessage[],
138
+ state: RunState,
139
+ rectifyHint: string | undefined,
140
+ opts?: { readonly withContract?: boolean },
141
+ ): void {
142
+ const block = runStateRootBlock(state, opts);
143
+ const text = rectifyHint === undefined ? block : `${block}\n${rectifyHint}`;
144
+ // Remove any previous instance (only one lives at a time — idempotent across calls).
145
+ for (let i = messages.length - 1; i >= 0; i--) {
146
+ const m = messages[i];
147
+ if (isSigmaSnapshot(m)) messages.splice(i, 1);
148
+ }
149
+ const at = lastIndexOfRole(messages, "user");
150
+ const sigma: RootMessage = {
151
+ role: "custom",
152
+ customType: "rlm-sigma",
153
+ content: text,
154
+ display: false,
155
+ details: { kind: "sigma-snapshot", updatedAt: state.updatedAt },
156
+ timestamp: Date.now(),
157
+ } as RootMessage;
158
+ if (at < 0) {
159
+ messages.push(sigma); // no user message (degenerate) — append; still exactly one
160
+ } else {
161
+ messages.splice(at, 0, sigma);
162
+ }
163
+ }
164
+
165
+ function isSigmaSnapshot(m: RootMessage | undefined): boolean {
166
+ return m !== undefined && m.role === "custom" && (m as { customType?: unknown }).customType === "rlm-sigma";
167
+ }
168
+
169
+ function lastIndexOfRole(messages: readonly RootMessage[], role: "user"): number {
170
+ for (let i = messages.length - 1; i >= 0; i--) {
171
+ if (messages[i]?.role === role) return i;
172
+ }
173
+ return -1;
174
+ }
175
+
176
+ function totalTextLength(message: RootMessage): number {
177
+ return textContentOf((message as { content?: unknown }).content).length;
178
+ }
179
+
180
+ function previewToolText(message: RootMessage, elideChars: number): string {
181
+ const text = textContentOf((message as { content?: unknown }).content);
182
+ // The preview REPLACES the payload, so `elideChars` budgets the WHOLE head+tail result.
183
+ return truncateOutput(text, Math.max(100, elideChars), ELIDE_MARK);
184
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Root Σ (WS-2) — deterministic root compaction for the Pi harness orchestrator.
3
+ *
4
+ * When the native session compacts, this replaces Pi's LLM prose summarizer with a
5
+ * structural digest: the same no-LLM distillation shape the engine proven-uses at its
6
+ * budget hard-state (core/budget.ts:distillTrajectory), adapted to host AgentMessages.
7
+ * Zero summary tokens; compaction becomes reproducible. Paper framing: the digest is a
8
+ * Σ-style sufficient statistic (§3.1) — sections are ordered by continuity value, and the
9
+ * verbatim tail after `firstKeptEntryId` stays byte-identical (§5.3 observation override:
10
+ * fresh tool results outrank the digest).
11
+ *
12
+ * Cut-point semantics: Pi's preparation already picked `firstKeptEntryId` via its
13
+ * keepRecentTokens walk. We only ever TIGHTEN (move the cut later, shrinking the verbatim
14
+ * tail toward `rootDigestKeepRecentChars`) — never extend — and fold every message the
15
+ * tighter cut displaces into the digest inputs, so nothing is dropped unaccounted. Cuts
16
+ * land on entry boundaries Pi itself treats as safe (the walk is char-budgeted over whole
17
+ * entries, mirroring pi-docs/compaction.md's accumulate-and-cut).
18
+ *
19
+ * Failure semantics: pure function, no model call, no throw paths — the index.ts handler
20
+ * still wraps it fail-soft (host contract: handlers must not throw) and returns undefined
21
+ * so Pi falls back to its own summarizer.
22
+ */
23
+
24
+ import type { CompactionResult, SessionEntry } from "@earendil-works/pi-coding-agent";
25
+ import type { RlmConfig } from "./types.ts";
26
+ import { DEFAULT_NEXT_STEP, FINDINGS_MAX, FINDINGS_MIN_CHARS, NEXT_STEP_RE, STATE_MAX, truncateMid } from "./budget.ts";
27
+ import { estimateMessageTokens } from "../text/tokens.ts";
28
+ import { agentMessageText } from "../text/agent-text.ts";
29
+ import { isRecord } from "../util/type-guards.ts";
30
+ import { ROOT_DIGEST_HEADER, ROOT_DIGEST_SECTIONS } from "../prompts/glossary.ts";
31
+
32
+ /** Marker persisted on the session's compaction entry — tests and soaks assert on it. */
33
+ export interface RootDigestDetails {
34
+ readonly kind: "root-digest";
35
+ readonly version: 1;
36
+ }
37
+
38
+ /** Structural slice of SkillStore the digest needs — tests inject stand-ins without a store. */
39
+ export interface DigestFactSource {
40
+ sliceForPrompt(query: string, budgetTokens: number, minScore: number): string;
41
+ }
42
+
43
+ /** `SessionBeforeCompactEvent["preparation"]` slice — indexed on the event shape upstream. */
44
+ export interface RootDigestPreparation {
45
+ readonly firstKeptEntryId: string;
46
+ readonly messagesToSummarize: readonly unknown[];
47
+ readonly isSplitTurn: boolean;
48
+ readonly tokensBefore: number;
49
+ }
50
+
51
+ export interface RootDigestArgs {
52
+ readonly preparation: RootDigestPreparation;
53
+ /** Chronological session entries (event.branchEntries) — the tighten walk reads these. */
54
+ readonly branchEntries: readonly SessionEntry[];
55
+ readonly config: Pick<RlmConfig, "rootDigestKeepRecentChars" | "rootDigestMaxChars" | "skillStateMinScore">;
56
+ readonly store: DigestFactSource | undefined;
57
+ }
58
+
59
+ /** Section caps come from budget.ts (ONE source — N2); only digest-local shapes live here. */
60
+ const BULLET_CHARS = 400;
61
+ const TASK_FRACTION = 0.3;
62
+ const FACTS_FRACTION = 4; // facts budget = maxChars / FACTS_FRACTION, tokens at 4 chars/token
63
+
64
+ /** AgentMessage-shaped projection of one session entry (host sessionEntryToContextMessages shape). */
65
+ function entryMessages(entry: SessionEntry): readonly unknown[] {
66
+ if (!isRecord(entry)) return [];
67
+ if (entry.type === "message" && isRecord(entry.message)) return [entry.message];
68
+ if (entry.type === "custom_message") return [{ role: "custom", content: entry.content }];
69
+ if (entry.type === "compaction" && typeof entry.summary === "string") {
70
+ return [{ role: "compactionSummary", summary: entry.summary }];
71
+ }
72
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
73
+ return [{ role: "branchSummary", summary: entry.summary }];
74
+ }
75
+ return [];
76
+ }
77
+
78
+ function entryChars(entry: SessionEntry): number {
79
+ let total = 0;
80
+ for (const message of entryMessages(entry)) total += agentMessageText(message).length + 8;
81
+ return total;
82
+ }
83
+ /**
84
+ * The ONE tighten walk: index of the first entry kept verbatim (≥ piCut). Walks backward
85
+ * accumulating entry chars until the budget is met; when the whole tail fits under the
86
+ * budget, Pi's boundary stands — we never EXTEND the digest span past Pi's cut.
87
+ */
88
+ function tightenedCut(entries: readonly SessionEntry[], piCut: number, keepChars: number): number {
89
+ let acc = 0;
90
+ for (let i = entries.length - 1; i >= piCut; i--) {
91
+ acc += entryChars(entries[i]);
92
+ if (acc >= keepChars) return i;
93
+ }
94
+ return piCut;
95
+ }
96
+
97
+ /** [Task] — the LAST user prompt in the span (the ask still in force when compaction hits). */
98
+ function taskSection(messages: readonly unknown[]): string {
99
+ for (let i = messages.length - 1; i >= 0; i--) {
100
+ const m = messages[i];
101
+ if (isRecord(m) && m.role === "user") {
102
+ const text = agentMessageText(m).trim();
103
+ if (text !== "") return text;
104
+ }
105
+ }
106
+ return "";
107
+ }
108
+
109
+ /** [Findings] — newest-first substantive assistant blobs, capped, then chronological. */
110
+ function findingsSection(messages: readonly unknown[]): readonly string[] {
111
+ const findings: string[] = [];
112
+ for (let i = messages.length - 1; i >= 0 && findings.length < FINDINGS_MAX; i--) {
113
+ const m = messages[i];
114
+ if (!isRecord(m) || m.role !== "assistant") continue;
115
+ const text = agentMessageText(m).trim();
116
+ if (text.length > FINDINGS_MIN_CHARS) findings.push(text);
117
+ }
118
+ findings.reverse();
119
+ return findings;
120
+ }
121
+
122
+ /** [State] — newest-first toolResult first-lines (the last observed machinery state). */
123
+ function stateSection(messages: readonly unknown[]): readonly string[] {
124
+ const states: string[] = [];
125
+ for (let i = messages.length - 1; i >= 0 && states.length < STATE_MAX; i--) {
126
+ const m = messages[i];
127
+ if (!isRecord(m) || m.role !== "toolResult") continue;
128
+ const text = agentMessageText(m).trim();
129
+ if (text === "") continue;
130
+ const tool = typeof m.toolName === "string" ? m.toolName : "tool";
131
+ states.push(`${tool}: ${text.split("\n", 1)[0] ?? text}`);
132
+ }
133
+ states.reverse();
134
+ return states;
135
+ }
136
+
137
+ /**
138
+ * Build the digest compaction. Returns undefined when Pi should keep its own path:
139
+ * split turns (our flat summary would double-count the turn prefix) or an empty span.
140
+ *
141
+ * `tokensBeforeRecomputed` is our own estimateMessageTokens pass over the same span —
142
+ * V1 soak probe: the host consumes `compaction.tokensBefore` (Pi's own preparation number)
143
+ * as-is; one trace line carries both so the first real /compact confirms whether the host
144
+ * ever diverges (status-line truth check) without touching the persisted CompactionResult.
145
+ */
146
+ export function buildRootDigestCompaction(
147
+ args: RootDigestArgs,
148
+ ): {
149
+ readonly compaction: CompactionResult<RootDigestDetails>;
150
+ readonly tokensBeforeRecomputed: number;
151
+ } | undefined {
152
+ const prep = args.preparation;
153
+ if (prep.isSplitTurn) return undefined;
154
+
155
+ const piCut = args.branchEntries.findIndex((entry) => entry.id === prep.firstKeptEntryId);
156
+ const cut = piCut < 0 ? -1 : tightenedCut(args.branchEntries, piCut, args.config.rootDigestKeepRecentChars);
157
+
158
+ const messages: unknown[] = [...prep.messagesToSummarize];
159
+ if (cut > piCut && piCut >= 0) {
160
+ for (let i = piCut; i < cut; i++) messages.push(...entryMessages(args.branchEntries[i]));
161
+ }
162
+ if (messages.length === 0) return undefined;
163
+
164
+ const max = Math.max(200, args.config.rootDigestMaxChars);
165
+ const task = truncateMid(taskSection(messages), Math.floor(max * TASK_FRACTION));
166
+ const findings = findingsSection(messages).map((f) => truncateMid(f, BULLET_CHARS));
167
+ const states = stateSection(messages);
168
+ // Next-step probe mirrors distillTrajectory: newest-first scan, chronological render.
169
+ const next = findings.find((f) => NEXT_STEP_RE.test(f)) ?? DEFAULT_NEXT_STEP;
170
+ const facts = args.store === undefined
171
+ ? ""
172
+ : args.store.sliceForPrompt(
173
+ task,
174
+ Math.max(1, Math.floor(max / FACTS_FRACTION / 4)),
175
+ args.config.skillStateMinScore,
176
+ );
177
+
178
+ const render = (factsText: string, stateLines: readonly string[], findingLines: readonly string[]): string => {
179
+ const blocks: string[] = [ROOT_DIGEST_HEADER];
180
+ const section = (label: string, body: string): void => {
181
+ if (body.trim() !== "") blocks.push(`[${label}] ${body}`);
182
+ };
183
+ section(ROOT_DIGEST_SECTIONS.task, task);
184
+ section(ROOT_DIGEST_SECTIONS.findings, findingLines.map((f) => `- ${f}`).join("\n"));
185
+ section(ROOT_DIGEST_SECTIONS.state, stateLines.map((s) => `- ${s}`).join("\n"));
186
+ section(ROOT_DIGEST_SECTIONS.next, next);
187
+ section(ROOT_DIGEST_SECTIONS.facts, factsText);
188
+ return blocks.join("\n\n");
189
+ };
190
+
191
+ // Over-cap drop order: [Project facts] → [State] → [Findings]; [Task]/[Next] survive to a
192
+ // final truncate — task continuity beats trivia (paper §3.1 sufficient statistic).
193
+ let summary = render(facts, states, findings);
194
+ if (summary.length > max) summary = render("", states, findings);
195
+ if (summary.length > max) summary = render("", [], findings);
196
+ if (summary.length > max) {
197
+ summary = render("", [], findings.slice(0, Math.max(1, Math.floor(findings.length / 2))));
198
+ }
199
+ summary = truncateMid(summary, max);
200
+
201
+ const boundary = cut >= 0 ? args.branchEntries[cut] : undefined;
202
+ return {
203
+ compaction: {
204
+ summary,
205
+ firstKeptEntryId: (isRecord(boundary) && typeof boundary.id === "string" ? boundary.id : undefined)
206
+ ?? prep.firstKeptEntryId,
207
+ tokensBefore: prep.tokensBefore,
208
+ details: { kind: "root-digest", version: 1 },
209
+ },
210
+ // Same CHAR_PER_TOKEN=4 (+8/message) math as the engine budgets — comparable numbers.
211
+ tokensBeforeRecomputed: estimateMessageTokens(messages.map((m) => ({ content: agentMessageText(m) }))),
212
+ };
213
+ }