@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
@@ -29,7 +29,7 @@ import { applyPathPrefix, contextSourceId, pathPrefixFor } from "./namespace.ts"
29
29
  /** Conversions are libuv-threadpool bound (default pool 4); 8 keeps the pool busy without thrash. */
30
30
  const CONVERT_CONCURRENCY = 8;
31
31
 
32
- export interface PackDirResult {
32
+ interface PackDirResult {
33
33
  readonly files: readonly ContextFile[];
34
34
  readonly chars: number;
35
35
  readonly documents: number;
@@ -37,6 +37,25 @@ export interface PackDirResult {
37
37
  readonly skipped: readonly SkippedFile[];
38
38
  }
39
39
 
40
+ /** DRY: the ONE PackDirResult → SourceResult flattening — shared by the local-dir source and
41
+ * the git-clone source (which wraps it in Result). Never inline a second copy. */
42
+ export function packToSourceResult(
43
+ packed: PackDirResult,
44
+ sourceId: string,
45
+ pathPrefix: string,
46
+ ): SourceResult {
47
+ return Object.freeze({
48
+ payload: packed.files,
49
+ files: packed.files.length,
50
+ chars: packed.chars,
51
+ sourceId,
52
+ pathPrefix,
53
+ documents: packed.documents,
54
+ converted: packed.converted,
55
+ skipped: packed.skipped,
56
+ });
57
+ }
58
+
40
59
  /**
41
60
  * Walk `dir` and produce ContextFiles. Resolves the anydoc handle once per source
42
61
  * (not per file) and threads it into the per-file router.
@@ -153,14 +172,5 @@ export async function sourceDir(
153
172
  // Explicit pathPrefix (including "") wins; otherwise derive ctx/<id>/.
154
173
  const pathPrefix = opts.pathPrefix !== undefined ? opts.pathPrefix : pathPrefixFor(sourceId);
155
174
  const packed = await packDirectory(dir, pathPrefix, opts.signal);
156
- return Object.freeze({
157
- payload: packed.files,
158
- files: packed.files.length,
159
- chars: packed.chars,
160
- sourceId,
161
- pathPrefix,
162
- documents: packed.documents,
163
- converted: packed.converted,
164
- skipped: packed.skipped,
165
- });
175
+ return packToSourceResult(packed, sourceId, pathPrefix);
166
176
  }
@@ -16,7 +16,7 @@ import {
16
16
  type SkippedFile,
17
17
  } from "./types.ts";
18
18
 
19
- export type DocFileResult =
19
+ type DocFileResult =
20
20
  | { readonly ok: true; readonly value: ContextFile; /** true only for a real anydoc conversion */ readonly converted: boolean }
21
21
  | { readonly ok: false; readonly skipped: SkippedFile };
22
22
 
@@ -2,18 +2,15 @@
2
2
  * Shallow-clone a git URL, then pack with source-dir.
3
3
  */
4
4
 
5
- import { execFile } from "node:child_process";
6
5
  import { mkdtemp, rm } from "node:fs/promises";
7
6
  import { tmpdir } from "node:os";
8
7
  import { join } from "node:path";
9
- import { promisify } from "node:util";
10
8
  import { errorMessage, type Result } from "../util/errors.ts";
9
+ import { execFileP } from "./walk.ts";
11
10
  import { contextSourceId, pathPrefixFor } from "./namespace.ts";
12
- import { packDirectory } from "./source-dir.ts";
11
+ import { packDirectory, packToSourceResult } from "./source-dir.ts";
13
12
  import type { ResolveOpts, SourceResult } from "./types.ts";
14
13
 
15
- const execFileP = promisify(execFile);
16
-
17
14
  /** Shallow-clone `url` into a temp dir, pack, then remove the clone. */
18
15
  export async function sourceGit(
19
16
  url: string,
@@ -32,16 +29,7 @@ export async function sourceGit(
32
29
  const packed = await packDirectory(dir, pathPrefix, opts.signal);
33
30
  return {
34
31
  ok: true,
35
- value: Object.freeze({
36
- payload: packed.files,
37
- files: packed.files.length,
38
- chars: packed.chars,
39
- sourceId,
40
- pathPrefix,
41
- documents: packed.documents,
42
- converted: packed.converted,
43
- skipped: packed.skipped,
44
- }),
32
+ value: packToSourceResult(packed, sourceId, pathPrefix),
45
33
  };
46
34
  } catch (err: unknown) {
47
35
  return { ok: false, error: `git clone failed for ${url} — ${errorMessage(err)}` };
@@ -9,7 +9,7 @@ import { applyPathPrefix } from "./namespace.ts";
9
9
  import { isBinary } from "./walk.ts";
10
10
  import { MAX_WALK_FILE_BYTES, type ContextFile, type SkippedFile } from "./types.ts";
11
11
 
12
- export type TextFileResult =
12
+ type TextFileResult =
13
13
  | { readonly ok: true; readonly value: ContextFile }
14
14
  | { readonly ok: false; readonly skipped: SkippedFile };
15
15
 
@@ -13,12 +13,13 @@
13
13
 
14
14
  import { execFile } from "node:child_process";
15
15
  import type { Dirent } from "node:fs";
16
- import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
16
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
17
17
  import { basename, join, relative, resolve, sep } from "node:path";
18
18
  import { promisify } from "node:util";
19
19
  import type { SkipReason } from "./types.ts";
20
20
 
21
- const execFileP = promisify(execFile);
21
+ /** Shared promisified execFile (git listing) — source-git.ts reuses it (DRY). */
22
+ export const execFileP = promisify(execFile);
22
23
 
23
24
  /** 8KB probe window for the NUL-byte binary check. */
24
25
  const BINARY_PROBE_BYTES = 8 * 1024;
@@ -94,7 +95,7 @@ function absKey(path: string): string {
94
95
  }
95
96
 
96
97
  /** True when `fileAbs` is `rootAbs` or a descendant (prefix + separator). */
97
- export function isInsideRoot(fileAbs: string, rootAbs: string): boolean {
98
+ function isInsideRoot(fileAbs: string, rootAbs: string): boolean {
98
99
  const root = absKey(rootAbs);
99
100
  const file = absKey(fileAbs);
100
101
  if (file === root) return true;
@@ -102,7 +103,7 @@ export function isInsideRoot(fileAbs: string, rootAbs: string): boolean {
102
103
  return file.startsWith(prefix);
103
104
  }
104
105
 
105
- export type PathSafety =
106
+ type PathSafety =
106
107
  | { readonly ok: true; readonly realAbs: string }
107
108
  | { readonly ok: false; readonly reason: Extract<SkipReason, "sensitive" | "symlink-escape" | "unreadable"> };
108
109
 
@@ -147,7 +148,7 @@ export async function checkPathSafety(absPath: string, packRoot: string): Promis
147
148
  * Paths are returned raw from git (forward-slashed). Sensitive paths are NOT filtered here —
148
149
  * packDirectory reports them as skipped: "sensitive" so the drop is visible.
149
150
  */
150
- export async function gitFiles(cwd: string, signal?: AbortSignal): Promise<readonly string[] | undefined> {
151
+ async function gitFiles(cwd: string, signal?: AbortSignal): Promise<readonly string[] | undefined> {
151
152
  try {
152
153
  const { stdout } = await execFileP(
153
154
  "git",
@@ -239,12 +240,3 @@ export async function enumerateFiles(root: string, signal?: AbortSignal): Promis
239
240
  return await walkFs(root, signal);
240
241
  }
241
242
 
242
- /** True when a path exists and is a regular file (or symlink to one). */
243
- export async function isRegularFile(absPath: string): Promise<boolean> {
244
- try {
245
- const s = await stat(absPath);
246
- return s.isFile();
247
- } catch {
248
- return false;
249
- }
250
- }
@@ -14,28 +14,29 @@
14
14
 
15
15
  import type { ChatMsg } from "../bridge/model.ts";
16
16
  import type { RlmConfig } from "./types.ts";
17
+ import type { RunState } from "./run-state.ts";
18
+ import { compactJSON } from "./run-state.ts";
17
19
 
18
- export interface TokenBudgetOptions {
20
+ interface TokenBudgetOptions {
19
21
  readonly softFrac?: number;
20
22
  readonly continuations?: number;
21
23
  readonly maxContinuations?: number;
22
24
  }
23
25
 
24
- export type BudgetState = "" | "soft" | "hard";
26
+ type BudgetState = "" | "soft" | "hard";
25
27
 
26
28
  /** v5 verbatim: the soft wrap-up note prepended to the single turn after crossing soft. */
27
- export const WRAP_UP_BUDGET: string = Object.freeze(
29
+ export const WRAP_UP_BUDGET: string =
28
30
  "[budget] ~80% of your token cap — ONE turn left. If the task is answerable NOW, finalize " +
29
31
  '(set answer["ready"] = True). Otherwise print a compact findings dump: what is confirmed, ' +
30
32
  "current file/line or search position, and the exact next step — a fresh continuation picks " +
31
- "it up. Do not start new exploration.",
32
- );
33
+ "it up. Do not start new exploration.";
33
34
 
34
35
  export const DEFAULT_NEXT_STEP: string =
35
- Object.freeze("continue the probing that was in flight, then finalize");
36
+ "continue the probing that was in flight, then finalize";
36
37
 
37
38
  /** v5 verbatim template (adapting the finalize spelling to this plugin's REPL). */
38
- const HANDOFF_TEMPLATE: string = Object.freeze(
39
+ const HANDOFF_TEMPLATE: string =
39
40
  "A prior RLM run hit its token cap mid-task.\n" +
40
41
  "You are its continuation — pick up EXACTLY where it stopped.\n\n" +
41
42
  "ORIGINAL TASK:\n{query}\n\n" +
@@ -43,8 +44,7 @@ const HANDOFF_TEMPLATE: string = Object.freeze(
43
44
  "CURRENT STATE / LAST ACTIONS:\n{state}\n\n" +
44
45
  "NEXT STEP: {next}\n" +
45
46
  "Do not re-do confirmed work; continue from the NEXT STEP and finalize as\n" +
46
- 'soon as the task is answerable (answer["ready"] = True).',
47
- );
47
+ 'soon as the task is answerable (answer["ready"] = True).';
48
48
 
49
49
  /** v5's elision marker, used whenever a handoff section is trimmed. */
50
50
  const ELISION_MARK = "\n…(+N chars elided [v5 handoff])…\n";
@@ -119,24 +119,26 @@ export class TokenBudget {
119
119
  */
120
120
  export const BUDGET_WINDOW_FLOOR = 250_000;
121
121
 
122
- /** An effective budget that can never trigger the cascade "switched off" without changing
123
- * any call-site types (budget: TokenBudget | undefined). */
124
- function unboundedBudget(config: RlmConfig): TokenBudget {
125
- return new TokenBudget(Number.MAX_SAFE_INTEGER, {
122
+ /** One TokenBudget construction shape the cap varies, the policy knobs never do (DRY). */
123
+ function makeBudget(config: RlmConfig, cap: number): TokenBudget {
124
+ return new TokenBudget(cap, {
126
125
  softFrac: config.budgetSoftFrac,
127
126
  maxContinuations: config.budgetMaxContinuations,
128
127
  });
129
128
  }
130
129
 
130
+ /** An effective budget that can never trigger — the cascade "switched off" without changing
131
+ * any call-site types (budget: TokenBudget | undefined). */
132
+ function unboundedBudget(config: RlmConfig): TokenBudget {
133
+ return makeBudget(config, Number.MAX_SAFE_INTEGER);
134
+ }
135
+
131
136
  export function resolveBudget(contextWindow: number | undefined, config: RlmConfig): TokenBudget {
132
137
  const ctx = contextWindow !== undefined && contextWindow > 0 ? contextWindow : 32_000;
133
138
  if (ctx < BUDGET_WINDOW_FLOOR) return unboundedBudget(config);
134
139
  const shareCap = Math.floor(ctx * config.budgetShare);
135
140
  const cap = config.budgetTaskCap > 0 ? Math.min(shareCap, config.budgetTaskCap) : shareCap;
136
- return new TokenBudget(Math.max(cap, 1), {
137
- softFrac: config.budgetSoftFrac,
138
- maxContinuations: config.budgetMaxContinuations,
139
- });
141
+ return makeBudget(config, Math.max(cap, 1));
140
142
  }
141
143
 
142
144
  /**
@@ -150,12 +152,15 @@ export function truncateMid(text: string, maxChars: number): string {
150
152
  return text.slice(0, half) + ELISION_MARK.replace("N", String(elided)) + text.slice(text.length - half);
151
153
  }
152
154
 
155
+ /** Digest/handoff section caps — ONE source: budget.ts's handoff distillation and the root
156
+ * digest (core/root-digest.ts) must never drift apart on the same trajectory heuristics. */
157
+ export const FINDINGS_MAX = 6;
158
+ export const FINDINGS_MIN_CHARS = 20;
159
+ export const STATE_MAX = 8;
153
160
  const QUERY_CHARS = 800;
154
- const FINDINGS_MAX = 6;
155
- const FINDINGS_MIN_CHARS = 20;
156
- const STATE_MAX = 8;
157
161
  const STATE_NEEDLE = "REPL stdout";
158
- const NEXT_STEP_RE = /next|then|will |todo/i;
162
+ /** Next-step probe shared by the engine handoff and the root digest (one wording source). */
163
+ export const NEXT_STEP_RE = /next|then|will |todo/i;
159
164
 
160
165
  /**
161
166
  * Deterministic trajectory → handoff (v5 `distill_trajectory`). No LLM call: the model was
@@ -202,3 +207,84 @@ export function distillTrajectory(
202
207
  export function continuationPrompt(n: number, handoff: string): string {
203
208
  return `[continuation ${n}]\n${handoff}`;
204
209
  }
210
+
211
+ // ── Workstream A: state-shaped hard-budget handoff ─────────────────────────────────
212
+
213
+ /**
214
+ * With Σ active, the hard-budget handoff IS the execution state: compactJSON(Σ) replaces the
215
+ * prose walk — smaller and lossless where it matters (findings/verifiedFacts survive
216
+ * verbatim; the paper's exact-state > prose-summary result, Tables 1/5). Reuses the v5
217
+ * HANDOFF_TEMPLATE slots; `distillTrajectory` remains only for degraded runs.
218
+ */
219
+ export function stateHandoff(state: RunState, query: string, handoffChars = 4_000): string {
220
+ const findingsBlock = state.findings.slice(-3).join("\n");
221
+ return HANDOFF_TEMPLATE.replace("{query}", truncateMid(query.slice(0, QUERY_CHARS), Math.floor(handoffChars * 0.3)))
222
+ .replace("{findings}", truncateMid(findingsBlock, Math.floor(handoffChars * 0.2)))
223
+ .replace("{state}", truncateMid(compactJSON(state), Math.floor(handoffChars * 0.35)))
224
+ .replace("{next}", state.nextStep !== "" ? state.nextStep : DEFAULT_NEXT_STEP);
225
+ }
226
+
227
+ // ── Workstream F: rectification at budget hard-state (MAS2 Eq. 5, local tier) ─────────
228
+
229
+ /** One deterministic local fix for a continuation — discriminated union, no flags.
230
+ * DOCTRINE: no arm ever switches models or providers — a failing model retries on itself
231
+ * until the attempt budget is exhausted, then fails loudly. */
232
+ export type RectifyAction =
233
+ | { readonly kind: "narrow-paths"; readonly paths: readonly string[] }
234
+ | { readonly kind: "reduce-concurrency"; readonly maxConcurrentSubcalls: number }
235
+ | { readonly kind: "none"; readonly reason: string };
236
+
237
+ /** Compact telemetry label for a rectification (run-node detail line). */
238
+ export function rectifyLabel(action: RectifyAction): string {
239
+ switch (action.kind) {
240
+ case "narrow-paths":
241
+ return `narrow-paths (${action.paths.length})`;
242
+ case "reduce-concurrency":
243
+ return `reduce-concurrency → ${action.maxConcurrentSubcalls}`;
244
+ case "none":
245
+ return "none";
246
+ }
247
+ }
248
+
249
+ /** Path-like tokens harvested from Σ — must contain a separator and an extension. */
250
+ const STATE_PATH_TOKEN = /(?:[\w@.-]+\/+)+[\w@.-]+\.[A-Za-z]{1,6}/g;
251
+
252
+ /** Top repeated paths from Σ, deterministic: frequency desc, then lexicographic; ≤4. */
253
+ function topPathsFromState(state: RunState | undefined): readonly string[] {
254
+ if (state === undefined) return [];
255
+ const freq = new Map<string, number>();
256
+ const sources = [...state.verifiedFacts, ...state.findings];
257
+ for (const line of sources) {
258
+ for (const match of line.matchAll(STATE_PATH_TOKEN)) {
259
+ const path = match[0];
260
+ freq.set(path, (freq.get(path) ?? 0) + 1);
261
+ }
262
+ }
263
+ return [...freq.entries()]
264
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
265
+ .slice(0, 4)
266
+ .map(([path]) => path);
267
+ }
268
+
269
+ /**
270
+ * MAS2 rectification (Eq. 5 adapted): at a budget hard-state, sample ONE local fix — no LLM.
271
+ * Deterministic priority: (1) narrow child paths from the run's top Σ paths, (2) halve leaf
272
+ * admission, (3) none. The model/provider pair is NEVER a rectification axis: a failing
273
+ * model retries until its attempt budget is exhausted and then the call fails loudly —
274
+ * no silent fallback, no swapping. The engine applies the choice to the continuation
275
+ * invocation and logs it on the run node.
276
+ */
277
+ export function rectify(args: {
278
+ readonly state?: RunState;
279
+ readonly config: RlmConfig;
280
+ }): RectifyAction {
281
+ const paths = topPathsFromState(args.state);
282
+ if (paths.length >= 2) return { kind: "narrow-paths", paths };
283
+ if (args.config.maxConcurrentSubcalls >= 4) {
284
+ return {
285
+ kind: "reduce-concurrency",
286
+ maxConcurrentSubcalls: Math.max(1, Math.floor(args.config.maxConcurrentSubcalls / 2)),
287
+ };
288
+ }
289
+ return { kind: "none", reason: "no local fix available" };
290
+ }
@@ -11,6 +11,8 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
11
11
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
12
12
  import type { RetryPolicy } from "../util/retry.ts";
13
13
  import { estimateMessageTokens } from "../text/tokens.ts";
14
+ import type { RunState } from "../core/run-state.ts";
15
+ import { compactJSON } from "../core/run-state.ts";
14
16
 
15
17
  const DEFAULT_CONTEXT_WINDOW = 128_000;
16
18
 
@@ -19,7 +21,7 @@ const SUMMARY_REQUEST =
19
21
  "(2) any concrete intermediate results — numbers, values, variable names — preserved exactly; " +
20
22
  "(3) your next action. Be concise (1–3 paragraphs) but preserve all key results.";
21
23
 
22
- export interface CompactionDeps {
24
+ interface CompactionDeps {
23
25
  readonly model: Model<Api>;
24
26
  readonly registry: ModelRegistry;
25
27
  readonly contextWindow?: number;
@@ -112,3 +114,44 @@ export async function compactHistory(
112
114
  },
113
115
  ];
114
116
  }
117
+
118
+ /** Same protected-tail rule as elideOldToolPayloads: the working set survives verbatim. */
119
+ const REBASE_KEEP_TURNS = 2;
120
+
121
+ /**
122
+ * Workstream A: structural rebase — the SKILL.state replacement for the LLM summary path.
123
+ *
124
+ * history := [ P , user("[Σ] …") , window(O) ]
125
+ *
126
+ * Everything older than the protected tail is superseded by the EXACT execution state — no
127
+ * model call is made at all (the summarizer LLM call disappears by design; the `compactions`
128
+ * counter still moves so telemetry stays comparable). Failure semantics: pure function, no
129
+ * fallible operations — the degraded path keeps `compactHistory` for as-built runs.
130
+ */
131
+ export function rebaseWithState(history: ChatMsg[], state: RunState, count = 1): ChatMsg[] {
132
+ const system = history.find((m) => m.role === "system");
133
+ const head: ChatMsg[] = system ? [system] : [];
134
+ let tailStart = history.length;
135
+ let seen = 0;
136
+ for (let i = history.length - 1; i >= 0; i--) {
137
+ if (history[i].role === "assistant") {
138
+ seen += 1;
139
+ if (seen >= REBASE_KEEP_TURNS) {
140
+ tailStart = i;
141
+ break;
142
+ }
143
+ }
144
+ }
145
+ const window: ChatMsg[] = tailStart < history.length ? history.slice(tailStart) : [];
146
+ return [
147
+ ...head,
148
+ {
149
+ role: "user",
150
+ content:
151
+ `Your conversation was structurally rebased ${count} time(s): older turns are superseded ` +
152
+ `by the exact execution state below — do NOT repeat completed work; fresh tool results ` +
153
+ `outrank Σ when they disagree.\n[Σ] ${compactJSON(state)}`,
154
+ },
155
+ ...window,
156
+ ];
157
+ }