pi-posthorse 0.10.17 → 0.10.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.10.17",
3
+ "version": "0.10.19",
4
4
  "type": "module",
5
5
  "description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
6
6
  "keywords": [
@@ -7,6 +7,7 @@ import { parseSessionLine } from "./session-files.ts";
7
7
  import { sessionNoteTouches } from "./note-evidence.ts";
8
8
  import { mapBatch, readManifest, snapshot, type SnapshotNote } from "./note-snapshots.ts";
9
9
  import {
10
+ addressIn,
10
11
  addressOf,
11
12
  bindingsOf,
12
13
  canonicalPath,
@@ -130,7 +131,7 @@ export async function resolveReference(
130
131
  }
131
132
  visited.add(directory);
132
133
  if (existsSync(notePath(current, path))) {
133
- return { ...current, path };
134
+ return addressIn(current, path);
134
135
  }
135
136
  // A manifest includes later adoptions; the journal includes later writes and deletions.
136
137
  const binding: NoteBinding | undefined =
@@ -204,7 +205,7 @@ function seedCandidates(
204
205
  ) {
205
206
  selected.set(seed.path, {
206
207
  ...seed,
207
- source: { root: binding.root, scope: binding.scope, path: seed.path },
208
+ source: addressIn(binding, seed.path),
208
209
  cleared: false,
209
210
  });
210
211
  }
@@ -83,6 +83,13 @@ export function forkScope(owner: NoteOwner): string {
83
83
  export function scopeDirectory(scope: NoteScope): string {
84
84
  return join(scope.root, scope.scope);
85
85
  }
86
+ /**
87
+ * An address is stored in the journal, and a scope value is often a whole binding. Build an
88
+ * address here, never by spreading a scope, so it holds the two location fields and nothing else.
89
+ */
90
+ export function addressIn(scope: NoteScope, path: string): NoteAddress {
91
+ return { root: scope.root, scope: scope.scope, path };
92
+ }
86
93
  export function sameScope(left: NoteScope, right: NoteScope): boolean {
87
94
  return left.root === right.root && left.scope === right.scope;
88
95
  }
@@ -278,7 +285,7 @@ export function addressOf(path: string, scopes: readonly NoteScope[]): NoteAddre
278
285
  ? undefined
279
286
  : candidates.find((item) => item.directory === match.directory);
280
287
  if (match !== undefined && candidate !== undefined) {
281
- return { ...candidate.scope, path: match.path };
288
+ return addressIn(candidate.scope, match.path);
282
289
  }
283
290
  if (
284
291
  scopes.some(
@@ -18,6 +18,7 @@ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
18
18
  import { isRecord } from "./contracts.ts";
19
19
  import { publishFile } from "./files.ts";
20
20
  import {
21
+ addressIn,
21
22
  notePath,
22
23
  parseBinding,
23
24
  sameOwner,
@@ -127,7 +128,19 @@ async function copyNote(note: SnapshotNote, destination: string): Promise<CopyRe
127
128
  }
128
129
  await utimes(destination, before.atimeMs / 1000, before.mtimeMs / 1000);
129
130
  const [digest, destInfo] = await Promise.all([fileDigest(destination), stat(destination)]);
130
- return { kind: "verified", seed: { ...note, digest, identity: fileIdentity(destInfo) } };
131
+ return {
132
+ kind: "verified",
133
+ // Journals written by 0.10.4 through 0.10.17 hold whole bindings in seed addresses.
134
+ // A new seed keeps each location only, so that data is not copied into this fork.
135
+ seed: {
136
+ path: note.path,
137
+ source: addressIn(note.source, note.source.path),
138
+ lineage: addressIn(note.lineage, note.lineage.path),
139
+ digest,
140
+ identity: fileIdentity(destInfo),
141
+ anchors: note.anchors,
142
+ },
143
+ };
131
144
  } catch (error) {
132
145
  return {
133
146
  kind: "unverified",
package/src/note-store.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  NOTES_BINDING,
15
15
  NOTES_TOUCH,
16
+ addressIn,
16
17
  bindingsOf,
17
18
  canonicalPath,
18
19
  inside,
@@ -154,7 +155,7 @@ export class SessionNotes {
154
155
  if (options.scope !== undefined) {
155
156
  const scope = await this.selectScope(options.scope);
156
157
  return {
157
- address: { ...scope, path: relative },
158
+ address: addressIn(scope, relative),
158
159
  path: notePath(scope, relative),
159
160
  writable: sameScope(scope, this.binding),
160
161
  };
@@ -162,11 +163,7 @@ export class SessionNotes {
162
163
  const reference = this.owns(relative)
163
164
  ? undefined
164
165
  : await this.#reference(relative, options.signal);
165
- const address = reference ?? {
166
- root: this.binding.root,
167
- scope: this.binding.scope,
168
- path: relative,
169
- };
166
+ const address = reference ?? addressIn(this.binding, relative);
170
167
  return { address, path: notePath(address, relative), writable: reference === undefined };
171
168
  }
172
169
  async adopt(path: string, call: NoteCall): Promise<Adoption> {
@@ -0,0 +1,255 @@
1
+ import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
2
+ import { contextEdits, contextStart, windowHandoff } from "./history-entries.ts";
3
+ import { toolCalls } from "./message-text.ts";
4
+ import {
5
+ formatRecord,
6
+ MAX_RECOVERY_RECORD_CHARS,
7
+ recoveryRecord,
8
+ type RecoveryRecord,
9
+ } from "./recovery-records.ts";
10
+
11
+ /** One input's room as a share of the whole record: 4,000 of the 20,000 characters a record may have. */
12
+ const RECORD_SHARE = 5;
13
+
14
+ function projectedCurrent(
15
+ entries: readonly EntryLike[],
16
+ projected: readonly ProjectedEntry[],
17
+ current: readonly EntryLike[] = entries.slice(contextStart(entries).start),
18
+ retained: ReadonlySet<string> = new Set(),
19
+ ): EntryLike[] {
20
+ const edits = contextEdits(entries);
21
+ const omittedIds = new Set(
22
+ projected
23
+ .filter(({ messages }) => messages.length === 0)
24
+ .map(({ sourceEntry }) => sourceEntry.id),
25
+ );
26
+ return current.flatMap((entry) => {
27
+ const id = entry.id ?? "";
28
+ if (retained.has(id) || omittedIds.has(id)) {
29
+ return [];
30
+ }
31
+ const edit = edits.get(id);
32
+ if (edit?.replacement === null) {
33
+ return [];
34
+ }
35
+ if (edit?.replacement === undefined) {
36
+ return [entry];
37
+ }
38
+ if (entry.type === "message") {
39
+ return [
40
+ {
41
+ ...entry,
42
+ id: edit.id,
43
+ message: { ...entry.message, content: edit.replacement.content },
44
+ },
45
+ ];
46
+ }
47
+ if (entry.type === "custom_message") {
48
+ return [{ ...entry, id: edit.id, content: edit.replacement.content }];
49
+ }
50
+ return [entry];
51
+ });
52
+ }
53
+ function ownerRecords(
54
+ entries: readonly EntryLike[],
55
+ projected: readonly ProjectedEntry[],
56
+ ownerQuestionRegistered: boolean,
57
+ ): RecoveryRecord[] {
58
+ const calls = new Map(
59
+ projected.flatMap(({ messages }) =>
60
+ messages.flatMap((message) =>
61
+ message.role === "assistant"
62
+ ? toolCalls(message.content).map((call) => [call.id, call] as const)
63
+ : [],
64
+ ),
65
+ ),
66
+ );
67
+ return entries.flatMap((entry) => {
68
+ const call = calls.get(entry.message?.toolCallId);
69
+ const record = recoveryRecord(
70
+ entry,
71
+ ownerQuestionRegistered &&
72
+ call?.name === "ask_question" &&
73
+ call.namespace === undefined,
74
+ );
75
+ return record === undefined ? [] : [record];
76
+ });
77
+ }
78
+ function anchors(records: readonly RecoveryRecord[]): {
79
+ fixed: RecoveryRecord[];
80
+ others: RecoveryRecord[];
81
+ } {
82
+ const firstOwner = records.find((record) => record.label === "owner input") ?? records[0];
83
+ const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
84
+ const latestOverall = records.at(-1);
85
+ const selected = new Set([firstOwner, latestOwner, latestOverall]);
86
+ return {
87
+ fixed: records.filter((record) => selected.has(record)),
88
+ others: records.filter((record) => !selected.has(record)),
89
+ };
90
+ }
91
+ function automaticBoundary(entry: EntryLike | undefined): boolean {
92
+ if (
93
+ entry === undefined ||
94
+ windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) !== true
95
+ ) {
96
+ return false;
97
+ }
98
+ return (
99
+ entry.type === "context_window" ||
100
+ (isRecord(entry.details) &&
101
+ entry.details.posthorse === 1 &&
102
+ (entry.details.reason === "threshold" || entry.details.reason === "overflow"))
103
+ );
104
+ }
105
+ export function automaticChainStart(entries: readonly EntryLike[]): number {
106
+ let prefix = entries;
107
+ let window = contextStart(prefix);
108
+ while (automaticBoundary(window.boundary) && window.boundary !== undefined) {
109
+ prefix = prefix.slice(0, prefix.indexOf(window.boundary));
110
+ window = contextStart(prefix);
111
+ }
112
+ return window.start;
113
+ }
114
+ function windowOwners(
115
+ entries: readonly EntryLike[],
116
+ projected: readonly ProjectedEntry[],
117
+ window: readonly EntryLike[],
118
+ ownerQuestionRegistered: boolean,
119
+ ): RecoveryRecord[] {
120
+ const restored = projectedCurrent(entries, projected, window);
121
+ const historical = restored.map((sourceEntry) => ({
122
+ sourceEntry,
123
+ messages: sourceEntry.message === undefined ? [] : [sourceEntry.message],
124
+ }));
125
+ return ownerRecords(restored, historical, ownerQuestionRegistered).filter(
126
+ (record) => record.kind === "owner",
127
+ );
128
+ }
129
+ /** The windows before the current one, newest first, as far back as automatic boundaries reach. */
130
+ function* earlierWindows(entries: readonly EntryLike[]): Generator<{
131
+ readonly closedBy: EntryLike;
132
+ readonly entries: readonly EntryLike[];
133
+ readonly openedBy: EntryLike | undefined;
134
+ }> {
135
+ let prefix = entries;
136
+ let boundary = contextStart(prefix).boundary;
137
+ while (boundary !== undefined && automaticBoundary(boundary)) {
138
+ prefix = prefix.slice(0, prefix.indexOf(boundary));
139
+ const window = contextStart(prefix);
140
+ yield {
141
+ closedBy: boundary,
142
+ entries: prefix.slice(window.start),
143
+ openedBy: window.boundary,
144
+ };
145
+ boundary = window.boundary;
146
+ }
147
+ }
148
+ /** A window's owner inputs that still fit the room, taken newest first and returned oldest first. */
149
+ function newestThatFit(
150
+ owners: readonly RecoveryRecord[],
151
+ room: number,
152
+ seen: Set<string>,
153
+ ): { readonly records: readonly RecoveryRecord[]; readonly used: number } {
154
+ const records: RecoveryRecord[] = [];
155
+ let used = 0;
156
+ for (const owner of owners.toReversed()) {
157
+ if (used >= room) {
158
+ break;
159
+ }
160
+ if (seen.has(owner.id)) {
161
+ continue;
162
+ }
163
+ const record = {
164
+ ...owner,
165
+ label: `${owner.label}; carried from an earlier window, possibly already handled; not current progress`,
166
+ };
167
+ seen.add(owner.id);
168
+ records.unshift(record);
169
+ used += formatRecord(record, Number.MAX_SAFE_INTEGER).length;
170
+ }
171
+ return { records, used };
172
+ }
173
+ /**
174
+ * Earlier windows give a record the owner's most recent inputs, newest first, until they fill the
175
+ * room of one input. Owner text the current window already holds counts against that room, so a
176
+ * short follow-up keeps the task before it and a long new request stands alone.
177
+ */
178
+ function recoveryAncestry(
179
+ entries: readonly EntryLike[],
180
+ projected: readonly ProjectedEntry[],
181
+ ownerQuestionRegistered: boolean,
182
+ options: { readonly current: readonly RecoveryRecord[]; readonly maxChars: number },
183
+ ): {
184
+ readonly checkpoint?: EntryLike | undefined;
185
+ readonly carried: readonly RecoveryRecord[];
186
+ readonly skipped: readonly string[];
187
+ } {
188
+ const held = options.current
189
+ .filter((record) => record.kind === "owner")
190
+ .reduce((chars, record) => chars + record.text.length, 0);
191
+ let room =
192
+ Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(options.maxChars / RECORD_SHARE)) - held;
193
+ const seen = new Set(options.current.map((record) => record.id));
194
+ const carried: RecoveryRecord[] = [];
195
+ const skipped: EntryLike[] = [];
196
+ let ownerWindowFound = false;
197
+ let reached = contextStart(entries).boundary;
198
+ for (const window of earlierWindows(entries)) {
199
+ if (ownerWindowFound && room <= 0) {
200
+ break;
201
+ }
202
+ if (!ownerWindowFound) {
203
+ skipped.push(window.closedBy);
204
+ }
205
+ const owners = windowOwners(entries, projected, window.entries, ownerQuestionRegistered);
206
+ ownerWindowFound ||= owners.length > 0;
207
+ const fit = newestThatFit(owners, room, seen);
208
+ carried.unshift(...fit.records);
209
+ room -= fit.used;
210
+ reached = window.openedBy;
211
+ }
212
+ return {
213
+ checkpoint: automaticBoundary(reached) ? undefined : reached,
214
+ carried,
215
+ skipped: skipped.map((boundary) => boundary.id?.slice(0, 120) ?? "unknown"),
216
+ };
217
+ }
218
+ /** The inputs a record quotes in full (oldest first), the ones it only lists, and where its walk back ended. */
219
+ export function recoveryInputs(
220
+ entries: readonly EntryLike[],
221
+ projected: readonly ProjectedEntry[],
222
+ options: {
223
+ readonly ownerQuestionRegistered: boolean;
224
+ readonly retained: ReadonlySet<string>;
225
+ readonly maxChars: number;
226
+ },
227
+ ): {
228
+ readonly fixed: readonly RecoveryRecord[];
229
+ readonly others: readonly RecoveryRecord[];
230
+ readonly checkpoint?: EntryLike | undefined;
231
+ readonly skipped: readonly string[];
232
+ } {
233
+ const { ownerQuestionRegistered, retained, maxChars } = options;
234
+ const { carried, checkpoint, skipped } = recoveryAncestry(
235
+ entries,
236
+ projected,
237
+ ownerQuestionRegistered,
238
+ {
239
+ current: ownerRecords(
240
+ projectedCurrent(entries, projected),
241
+ projected,
242
+ ownerQuestionRegistered,
243
+ ),
244
+ maxChars,
245
+ },
246
+ );
247
+ const { fixed, others } = anchors(
248
+ ownerRecords(
249
+ projectedCurrent(entries, projected, undefined, retained),
250
+ projected,
251
+ ownerQuestionRegistered,
252
+ ),
253
+ );
254
+ return { fixed: [...carried, ...fixed], others, checkpoint, skipped };
255
+ }
@@ -1,6 +1,7 @@
1
1
  import { isReminderType, type EntryLike } from "./contracts.ts";
2
2
  import { boundedBlock, imageSummary, imagesOf, textOf } from "./message-text.ts";
3
3
 
4
+ export const MAX_RECOVERY_RECORD_CHARS = 4_000;
4
5
  export type RecoveryRecord = {
5
6
  readonly id: string;
6
7
  readonly timestamp: string;
package/src/recovery.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
1
+ import { AUTO_HANDOFF_PREFIX, type EntryLike, type ProjectedEntry } from "./contracts.ts";
2
2
  import { contextEdits, contextStart, windowHandoff } from "./history-entries.ts";
3
- import { boundedBlock, excerpt, toolCalls } from "./message-text.ts";
3
+ import { boundedBlock, excerpt } from "./message-text.ts";
4
+ import { automaticChainStart, recoveryInputs } from "./recovery-inputs.ts";
4
5
  import {
5
6
  formatRecord,
6
7
  indexLine,
7
- recoveryRecord,
8
+ MAX_RECOVERY_RECORD_CHARS,
8
9
  type RecoveryRecord,
9
10
  } from "./recovery-records.ts";
10
11
  import type { NotesRecovery } from "./note-evidence.ts";
@@ -16,7 +17,6 @@ import {
16
17
  type RecoveryEntry,
17
18
  } from "./recovery-results.ts";
18
19
 
19
- const MAX_RECOVERY_RECORD_CHARS = 4_000;
20
20
  const MAX_RECOVERY_RESULT_CHARS = 1_500;
21
21
  const HANDOFF_OVERHEAD_RESERVE = 1_000;
22
22
  const MAX_NOTES_SECTION_CHARS = 1_200;
@@ -69,142 +69,6 @@ function priorCheckpoint(entry: EntryLike | undefined, limit: number): string |
69
69
  const header = `[older checkpoint; possibly stale | context-window entry ${id}]`;
70
70
  return boundedBlock(header, handoff, limit);
71
71
  }
72
- function projectedCurrent(
73
- entries: readonly EntryLike[],
74
- projected: readonly ProjectedEntry[],
75
- current: readonly EntryLike[] = entries.slice(contextStart(entries).start),
76
- retained: ReadonlySet<string> = new Set(),
77
- ): EntryLike[] {
78
- const edits = contextEdits(entries);
79
- const omittedIds = new Set(
80
- projected
81
- .filter(({ messages }) => messages.length === 0)
82
- .map(({ sourceEntry }) => sourceEntry.id),
83
- );
84
- return current.flatMap((entry) => {
85
- const id = entry.id ?? "";
86
- if (retained.has(id) || omittedIds.has(id)) {
87
- return [];
88
- }
89
- const edit = edits.get(id);
90
- if (edit?.replacement === null) {
91
- return [];
92
- }
93
- if (edit?.replacement === undefined) {
94
- return [entry];
95
- }
96
- if (entry.type === "message") {
97
- return [
98
- {
99
- ...entry,
100
- id: edit.id,
101
- message: { ...entry.message, content: edit.replacement.content },
102
- },
103
- ];
104
- }
105
- if (entry.type === "custom_message") {
106
- return [{ ...entry, id: edit.id, content: edit.replacement.content }];
107
- }
108
- return [entry];
109
- });
110
- }
111
- function ownerRecords(
112
- entries: readonly EntryLike[],
113
- projected: readonly ProjectedEntry[],
114
- ownerQuestionRegistered: boolean,
115
- ): RecoveryRecord[] {
116
- const calls = new Map(
117
- projected.flatMap(({ messages }) =>
118
- messages.flatMap((message) =>
119
- message.role === "assistant"
120
- ? toolCalls(message.content).map((call) => [call.id, call] as const)
121
- : [],
122
- ),
123
- ),
124
- );
125
- return entries.flatMap((entry) => {
126
- const call = calls.get(entry.message?.toolCallId);
127
- const record = recoveryRecord(
128
- entry,
129
- ownerQuestionRegistered &&
130
- call?.name === "ask_question" &&
131
- call.namespace === undefined,
132
- );
133
- return record === undefined ? [] : [record];
134
- });
135
- }
136
- function anchors(records: readonly RecoveryRecord[]): {
137
- fixed: RecoveryRecord[];
138
- others: RecoveryRecord[];
139
- } {
140
- const firstOwner = records.find((record) => record.label === "owner input") ?? records[0];
141
- const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
142
- const latestOverall = records.at(-1);
143
- const selected = new Set([firstOwner, latestOwner, latestOverall]);
144
- return {
145
- fixed: records.filter((record) => selected.has(record)),
146
- others: records.filter((record) => !selected.has(record)),
147
- };
148
- }
149
- function automaticBoundary(entry: EntryLike | undefined): boolean {
150
- if (
151
- entry === undefined ||
152
- windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) !== true
153
- ) {
154
- return false;
155
- }
156
- return (
157
- entry.type === "context_window" ||
158
- (isRecord(entry.details) &&
159
- entry.details.posthorse === 1 &&
160
- (entry.details.reason === "threshold" || entry.details.reason === "overflow"))
161
- );
162
- }
163
- function automaticChainStart(entries: readonly EntryLike[]): number {
164
- let prefix = entries;
165
- let window = contextStart(prefix);
166
- while (automaticBoundary(window.boundary) && window.boundary !== undefined) {
167
- prefix = prefix.slice(0, prefix.indexOf(window.boundary));
168
- window = contextStart(prefix);
169
- }
170
- return window.start;
171
- }
172
- function recoveryAncestry(
173
- entries: readonly EntryLike[],
174
- projected: readonly ProjectedEntry[],
175
- ownerQuestionRegistered: boolean,
176
- retained: ReadonlySet<string>,
177
- ): {
178
- readonly checkpoint?: EntryLike | undefined;
179
- readonly carried: readonly RecoveryRecord[];
180
- readonly skipped: readonly string[];
181
- } {
182
- let prefix = entries;
183
- let boundary = contextStart(prefix).boundary;
184
- const skipped: string[] = [];
185
- while (automaticBoundary(boundary) && boundary !== undefined) {
186
- skipped.push(boundary.id?.slice(0, 120) ?? "unknown");
187
- prefix = prefix.slice(0, prefix.indexOf(boundary));
188
- const window = contextStart(prefix);
189
- const restored = projectedCurrent(entries, projected, prefix.slice(window.start));
190
- const historical = restored.map((sourceEntry) => ({
191
- sourceEntry,
192
- messages: sourceEntry.message === undefined ? [] : [sourceEntry.message],
193
- }));
194
- const owners = ownerRecords(restored, historical, ownerQuestionRegistered).filter(
195
- (record) => record.kind === "owner",
196
- );
197
- if (owners.length > 0) {
198
- return {
199
- checkpoint: automaticBoundary(window.boundary) ? undefined : window.boundary,
200
- carried: anchors(owners.filter((record) => !retained.has(record.id))).fixed,
201
- skipped,
202
- };
203
- }
204
- boundary = window.boundary;
205
- }
206
- return { checkpoint: boundary, carried: [], skipped };
207
- }
208
72
  function skippedRecords(ids: readonly string[], limit: number): string | undefined {
209
73
  const remaining = ids.values();
210
74
  const first = remaining.next();
@@ -346,34 +210,20 @@ function renderIndex(records: readonly RecoveryRecord[], available: number): str
346
210
  ...listed,
347
211
  ].join("\n");
348
212
  }
349
- function selectedInputs(
350
- entries: readonly EntryLike[],
351
- projected: readonly ProjectedEntry[],
352
- options: {
353
- readonly ownerQuestionRegistered: boolean;
354
- readonly retained: ReadonlySet<string>;
355
- readonly carried: readonly RecoveryRecord[];
356
- },
357
- ): { readonly fixed: readonly RecoveryRecord[]; readonly others: readonly RecoveryRecord[] } {
358
- const { ownerQuestionRegistered, retained, carried } = options;
359
- const current = projectedCurrent(entries, projected);
360
- const allRecords = ownerRecords(current, projected, ownerQuestionRegistered);
361
- const records = ownerRecords(
362
- projectedCurrent(entries, projected, undefined, retained),
363
- projected,
364
- ownerQuestionRegistered,
365
- );
366
- const { fixed, others } = anchors(records);
367
- if (!allRecords.some((record) => record.kind === "owner")) {
368
- fixed.unshift(
369
- ...carried.map((record) =>
370
- Object.assign({}, record, {
371
- label: `${record.label}; carried from an earlier window, possibly already handled; not current progress`,
372
- }),
373
- ),
374
- );
213
+ /** The per-block limit at which the blocks fit the budget together; short blocks leave room to long ones. */
214
+ function sharedLimit(lengths: readonly number[], budget: number): number {
215
+ let remaining = budget;
216
+ const ascending = lengths
217
+ .map((length) => Math.min(length, MAX_RECOVERY_RECORD_CHARS))
218
+ .toSorted((a, b) => a - b);
219
+ for (const [index, length] of ascending.entries()) {
220
+ const share = Math.floor(remaining / (ascending.length - index));
221
+ if (length > share) {
222
+ return share;
223
+ }
224
+ remaining -= length;
375
225
  }
376
- return { fixed, others };
226
+ return MAX_RECOVERY_RECORD_CHARS;
377
227
  }
378
228
  function rolloverPreambleWithNotes(
379
229
  entries: readonly EntryLike[],
@@ -417,13 +267,12 @@ export function buildAutoHandoff(
417
267
  },
418
268
  ): string {
419
269
  const { ownerQuestionRegistered, retainedEntryIds: retained = new Set<string>() } = options;
420
- const history = recoveryAncestry(entries, projected, ownerQuestionRegistered, retained);
421
- const priorWindow = history.checkpoint;
422
- const { fixed, others } = selectedInputs(entries, projected, {
270
+ const { fixed, others, ...history } = recoveryInputs(entries, projected, {
423
271
  ownerQuestionRegistered,
424
272
  retained,
425
- carried: history.carried,
273
+ maxChars,
426
274
  });
275
+ const priorWindow = history.checkpoint;
427
276
  const emptyInputHeader =
428
277
  retained.size > 0
429
278
  ? "No discarded owner or visible coordination inputs were selected for this recovery record."
@@ -453,19 +302,18 @@ export function buildAutoHandoff(
453
302
  header,
454
303
  Math.max(0, maxChars - joined(minimum).length - HANDOFF_OVERHEAD_RESERVE),
455
304
  );
456
- const handoff = windowHandoff(priorWindow)?.trim();
457
- const count =
458
- fixed.length +
459
- (handoff === undefined || handoff === "" ? 0 : 1) +
460
- (history.skipped.length > 0 ? 1 : 0);
461
305
  const fixedBudget = Math.max(
462
306
  0,
463
307
  maxChars - joined([preamble, inputHeader, plan.bare]).length - HANDOFF_OVERHEAD_RESERVE,
464
308
  );
465
- const fixedLimit =
466
- count > 0
467
- ? Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(fixedBudget / count))
468
- : MAX_RECOVERY_RECORD_CHARS;
309
+ const fixedLimit = sharedLimit(
310
+ [
311
+ ...fixed.map((record) => formatRecord(record, Number.MAX_SAFE_INTEGER)),
312
+ priorCheckpoint(priorWindow, Number.MAX_SAFE_INTEGER),
313
+ skippedRecords(history.skipped, Number.MAX_SAFE_INTEGER),
314
+ ].flatMap((block) => (block === undefined ? [] : [block.length])),
315
+ fixedBudget,
316
+ );
469
317
  const prior = joined([
470
318
  priorCheckpoint(priorWindow, fixedLimit),
471
319
  skippedRecords(history.skipped, fixedLimit),