pi-posthorse 0.10.2 → 0.10.4

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.
@@ -0,0 +1,90 @@
1
+ import { isReminderType, type EntryLike } from "./contracts.ts";
2
+ import { boundedBlock, imageSummary, imagesOf, textOf } from "./message-text.ts";
3
+
4
+ export type RecoveryRecord = {
5
+ readonly id: string;
6
+ readonly timestamp: string;
7
+ readonly kind: "owner" | "coordination";
8
+ readonly label: string;
9
+ readonly text: string;
10
+ };
11
+ type RecordSource = {
12
+ readonly kind: RecoveryRecord["kind"];
13
+ readonly label: string;
14
+ readonly content: unknown;
15
+ };
16
+ function ownerAnswer(entry: EntryLike, ownerQuestion: boolean): boolean {
17
+ const message = entry.message;
18
+ return (
19
+ ownerQuestion &&
20
+ entry.type === "message" &&
21
+ message?.role === "toolResult" &&
22
+ message.toolName === "ask_question" &&
23
+ message.namespace === undefined &&
24
+ message.isError !== true
25
+ );
26
+ }
27
+ function recordSource(entry: EntryLike, ownerQuestion: boolean): RecordSource | undefined {
28
+ if (entry.type === "message" && entry.message?.role === "user") {
29
+ return { kind: "owner", label: "owner input", content: entry.message.content };
30
+ }
31
+ if (ownerAnswer(entry, ownerQuestion)) {
32
+ return {
33
+ kind: "owner",
34
+ label: "owner answer via ask_question",
35
+ content: entry.message?.content,
36
+ };
37
+ }
38
+ if (
39
+ entry.type === "custom_message" &&
40
+ entry.display === true &&
41
+ !isReminderType(entry.customType)
42
+ ) {
43
+ return {
44
+ kind: "coordination",
45
+ label: `visible ${(entry.customType ?? "custom").slice(0, 80)} coordination input (not direct owner input)`,
46
+ content: entry.content,
47
+ };
48
+ }
49
+ return;
50
+ }
51
+ export function recoveryRecord(
52
+ entry: EntryLike,
53
+ ownerQuestion: boolean,
54
+ ): RecoveryRecord | undefined {
55
+ const source = recordSource(entry, ownerQuestion);
56
+ if (source === undefined) {
57
+ return undefined;
58
+ }
59
+ const id = entry.id?.slice(0, 120) ?? "unknown";
60
+ const text = textOf({ content: source.content }).trim();
61
+ const summary =
62
+ source.label === "owner answer via ask_question"
63
+ ? ""
64
+ : imageSummary(imagesOf(source.content));
65
+ const body = [text, summary === "" ? "" : `${summary} — recover with history read id ${id}`]
66
+ .filter((part) => part !== "")
67
+ .join("\n");
68
+ return {
69
+ id,
70
+ timestamp: entry.timestamp?.slice(0, 80) ?? "unknown time",
71
+ kind: source.kind,
72
+ label: source.label,
73
+ text: body === "" ? "(non-text content; recover the entry from history)" : body,
74
+ };
75
+ }
76
+ export function formatRecord(record: RecoveryRecord, limit: number): string {
77
+ return boundedBlock(
78
+ `[${record.label} | ${record.timestamp} | entry ${record.id}]`,
79
+ record.text,
80
+ limit,
81
+ );
82
+ }
83
+ export function indexLine(record: RecoveryRecord): string {
84
+ const header = formatRecord(record, 0);
85
+ if (record.kind !== "owner") {
86
+ return header;
87
+ }
88
+ const preview = record.text.replace(/\s+/g, " ").trim();
89
+ return `${header} ${preview.length > 120 ? `${preview.slice(0, 119)}…` : preview}`;
90
+ }
@@ -10,9 +10,13 @@ import {
10
10
  } from "./message-text.ts";
11
11
 
12
12
  export type RecoveryEntry = EntryLike & { readonly retained: boolean };
13
+ const INCOMPLETE_STOPS = new Set(["error", "aborted", "length"]);
13
14
  type LinkedCall = { readonly entry: EntryLike; readonly block: ToolCallLike };
14
15
  export type RecoveryBlock = { readonly header: string; readonly text: string };
15
- export type RecoveryBatch = { readonly callId?: string; readonly blocks: readonly RecoveryBlock[] };
16
+ export type RecoveryBatch = {
17
+ readonly callId?: string | undefined;
18
+ readonly blocks: readonly RecoveryBlock[];
19
+ };
16
20
  function callsById(entries: readonly EntryLike[]): Map<string, LinkedCall> {
17
21
  const calls = new Map<string, LinkedCall>();
18
22
  for (const entry of entries) {
@@ -30,14 +34,11 @@ function callsById(entries: readonly EntryLike[]): Map<string, LinkedCall> {
30
34
  function trailingStart(entries: readonly EntryLike[]): number {
31
35
  let hasTrailingResult = false;
32
36
  for (let i = entries.length - 1; i >= 0; i--) {
33
- const message = entries[i].message;
37
+ const message = entries[i]?.message;
34
38
  if (message?.role === "toolResult") {
35
39
  hasTrailingResult = true;
36
40
  } else if (message?.role === "assistant") {
37
- const invalid =
38
- message.stopReason === "error" ||
39
- message.stopReason === "aborted" ||
40
- message.stopReason === "length";
41
+ const invalid = INCOMPLETE_STOPS.has(message.stopReason ?? "");
41
42
  if (!invalid || hasTrailingResult) {
42
43
  return i;
43
44
  }
package/src/recovery.ts CHANGED
@@ -1,19 +1,13 @@
1
- import {
2
- AUTO_HANDOFF_PREFIX,
3
- isRecord,
4
- isReminderType,
5
- type EntryLike,
6
- type ProjectedEntry,
7
- } from "./contracts.ts";
1
+ import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
8
2
  import { contextEdits, contextStart, windowHandoff } from "./history-entries.ts";
3
+ import { boundedBlock, excerpt, toolCalls } from "./message-text.ts";
9
4
  import {
10
- boundedBlock,
11
- excerpt,
12
- imageSummary,
13
- imagesOf,
14
- textOf,
15
- toolCalls,
16
- } from "./message-text.ts";
5
+ formatRecord,
6
+ indexLine,
7
+ recoveryRecord,
8
+ type RecoveryRecord,
9
+ } from "./recovery-records.ts";
10
+ import type { NotesRecovery } from "./note-evidence.ts";
17
11
  import { renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
18
12
  import {
19
13
  recoverableToolResults,
@@ -66,90 +60,6 @@ function buildRolloverPreamble(span: RetainedSpan | undefined): string {
66
60
  : `entries ${span.firstId} through ${span.lastId} above are the carried-over recent tail`;
67
61
  return `${PREAMBLE}\n${RETAINED_MARKER_PREFIX}${span.lastId}. Earlier turns before entry ${span.firstId} were compacted into session history; ${range} from before this rollover. Recent messages follow separately; their inputs and tool evidence are not repeated in this record. Start the new context window from here.`;
68
62
  }
69
- type RecoveryRecord = {
70
- readonly id: string;
71
- readonly timestamp: string;
72
- readonly kind: "owner" | "coordination";
73
- readonly label: string;
74
- readonly text: string;
75
- };
76
- type RecordSource = {
77
- readonly kind: RecoveryRecord["kind"];
78
- readonly label: string;
79
- readonly content: unknown;
80
- };
81
- function ownerAnswer(entry: EntryLike, ownerQuestion: boolean): boolean {
82
- const message = entry.message;
83
- return (
84
- ownerQuestion &&
85
- entry.type === "message" &&
86
- message?.role === "toolResult" &&
87
- message.toolName === "ask_question" &&
88
- message.namespace === undefined &&
89
- message.isError !== true
90
- );
91
- }
92
- function recordSource(entry: EntryLike, ownerQuestion: boolean): RecordSource | undefined {
93
- if (entry.type === "message" && entry.message?.role === "user") {
94
- return { kind: "owner", label: "owner input", content: entry.message.content };
95
- }
96
- if (ownerAnswer(entry, ownerQuestion)) {
97
- return {
98
- kind: "owner",
99
- label: "owner answer via ask_question",
100
- content: entry.message?.content,
101
- };
102
- }
103
- if (
104
- entry.type === "custom_message" &&
105
- entry.display === true &&
106
- !isReminderType(entry.customType)
107
- ) {
108
- return {
109
- kind: "coordination",
110
- label: `visible ${(entry.customType ?? "custom").slice(0, 80)} coordination input (not direct owner input)`,
111
- content: entry.content,
112
- };
113
- }
114
- return;
115
- }
116
- function recoveryRecord(entry: EntryLike, ownerQuestion: boolean): RecoveryRecord | undefined {
117
- const source = recordSource(entry, ownerQuestion);
118
- if (source === undefined) {
119
- return undefined;
120
- }
121
- const id = entry.id?.slice(0, 120) ?? "unknown";
122
- const text = textOf({ content: source.content }).trim();
123
- const summary =
124
- source.label === "owner answer via ask_question"
125
- ? ""
126
- : imageSummary(imagesOf(source.content));
127
- const body = [text, summary === "" ? "" : `${summary} — recover with history read id ${id}`]
128
- .filter((part) => part !== "")
129
- .join("\n");
130
- return {
131
- id,
132
- timestamp: entry.timestamp?.slice(0, 80) ?? "unknown time",
133
- kind: source.kind,
134
- label: source.label,
135
- text: body === "" ? "(non-text content; recover the entry from history)" : body,
136
- };
137
- }
138
- function formatRecord(record: RecoveryRecord, limit: number): string {
139
- return boundedBlock(
140
- `[${record.label} | ${record.timestamp} | entry ${record.id}]`,
141
- record.text,
142
- limit,
143
- );
144
- }
145
- function indexLine(record: RecoveryRecord): string {
146
- const header = formatRecord(record, 0);
147
- if (record.kind !== "owner") {
148
- return header;
149
- }
150
- const preview = record.text.replace(/\s+/g, " ").trim();
151
- return `${header} ${preview.length > 120 ? `${preview.slice(0, 119)}…` : preview}`;
152
- }
153
63
  function priorCheckpoint(entry: EntryLike | undefined, limit: number): string | undefined {
154
64
  const handoff = windowHandoff(entry)?.trim();
155
65
  if (entry === undefined || handoff === undefined || handoff === "") {
@@ -265,7 +175,7 @@ function recoveryAncestry(
265
175
  ownerQuestionRegistered: boolean,
266
176
  retained: ReadonlySet<string>,
267
177
  ): {
268
- readonly checkpoint?: EntryLike;
178
+ readonly checkpoint?: EntryLike | undefined;
269
179
  readonly carried: readonly RecoveryRecord[];
270
180
  readonly skipped: readonly string[];
271
181
  } {
@@ -296,13 +206,15 @@ function recoveryAncestry(
296
206
  return { checkpoint: boundary, carried: [], skipped };
297
207
  }
298
208
  function skippedRecords(ids: readonly string[], limit: number): string | undefined {
299
- if (ids.length === 0) {
209
+ const remaining = ids.values();
210
+ const first = remaining.next();
211
+ if (first.done === true) {
300
212
  return undefined;
301
213
  }
302
- let names = ids[0];
214
+ let names = first.value;
303
215
  let count = 1;
304
- while (count < ids.length) {
305
- const next = `, ${ids[count]}`;
216
+ for (const id of remaining) {
217
+ const next = `, ${id}`;
306
218
  if (names.length + next.length > limit) {
307
219
  break;
308
220
  }
@@ -335,7 +247,7 @@ function joined(parts: readonly (string | undefined)[]): string {
335
247
  }
336
248
  type BatchPlan = {
337
249
  readonly header?: string;
338
- readonly omission?: string;
250
+ readonly omission?: string | undefined;
339
251
  readonly blocks: readonly RecoveryBlock[];
340
252
  readonly bare?: string;
341
253
  };
@@ -359,8 +271,8 @@ function planBatch(
359
271
  }
360
272
  let budget = available;
361
273
  let first = batch.blocks.length;
362
- while (first > 0) {
363
- const length = batch.blocks[first - 1].header.length + 2;
274
+ for (const block of batch.blocks.toReversed()) {
275
+ const length = block.header.length + 2;
364
276
  if (length > budget) {
365
277
  break;
366
278
  }
@@ -417,8 +329,8 @@ function renderIndex(records: readonly RecoveryRecord[], available: number): str
417
329
  }
418
330
  let budget = available;
419
331
  const listed: string[] = [];
420
- for (let i = records.length - 1; i >= 0; i--) {
421
- const line = indexLine(records[i]);
332
+ for (const record of records.toReversed()) {
333
+ const line = indexLine(record);
422
334
  if (line.length + 1 > budget) {
423
335
  break;
424
336
  }
@@ -469,6 +381,7 @@ function rolloverPreambleWithNotes(
469
381
  options: {
470
382
  readonly retained: ReadonlySet<string>;
471
383
  readonly reserveParts: readonly (string | undefined)[];
384
+ readonly notes?: NotesRecovery | undefined;
472
385
  readonly maxChars: number;
473
386
  },
474
387
  ): string {
@@ -483,6 +396,7 @@ function rolloverPreambleWithNotes(
483
396
  {
484
397
  retained,
485
398
  span,
399
+ notes: options.notes,
486
400
  maxChars: Math.min(
487
401
  MAX_NOTES_SECTION_CHARS,
488
402
  Math.max(0, maxChars - reserveLen - HANDOFF_OVERHEAD_RESERVE),
@@ -499,6 +413,7 @@ export function buildAutoHandoff(
499
413
  options: {
500
414
  readonly ownerQuestionRegistered: boolean;
501
415
  readonly retainedEntryIds?: ReadonlySet<string>;
416
+ readonly notes?: NotesRecovery | undefined;
502
417
  },
503
418
  ): string {
504
419
  const { ownerQuestionRegistered, retainedEntryIds: retained = new Set<string>() } = options;
@@ -530,6 +445,7 @@ export function buildAutoHandoff(
530
445
  retained,
531
446
  reserveParts,
532
447
  maxChars,
448
+ notes: options.notes,
533
449
  });
534
450
  const minimum = [preamble, ...reserveParts];
535
451
  const plan = planBatch(
package/src/reminders.ts CHANGED
@@ -22,7 +22,7 @@ export type ReminderLookup = {
22
22
  };
23
23
  export type StaleReminderPredicate = (message: {
24
24
  readonly role: string;
25
- readonly customType?: string;
25
+ readonly customType?: string | undefined;
26
26
  readonly details?: unknown;
27
27
  }) => boolean;
28
28
 
@@ -53,8 +53,8 @@ function onlyReminders(entries: readonly EntryLike[]): EntryLike[] {
53
53
  );
54
54
  }
55
55
  type ScanProgress = {
56
- readonly boundary?: EntryLike;
57
- readonly window?: EntryLike;
56
+ readonly boundary?: EntryLike | undefined;
57
+ readonly window?: EntryLike | undefined;
58
58
  readonly kept: boolean;
59
59
  };
60
60
  function advanceBoundary(entry: EntryLike, progress: ScanProgress): ScanProgress {
package/src/retention.ts CHANGED
@@ -14,16 +14,18 @@ import {
14
14
  type MessageLike,
15
15
  type PolicyContext,
16
16
  } from "./contracts.ts";
17
+ import type { NotesRecovery } from "./note-evidence.ts";
17
18
  import { buildAutoHandoff } from "./recovery.ts";
18
19
 
19
20
  type RecoveryCapacity = {
20
21
  readonly host: PolicyContext;
21
22
  readonly toolTokens: number;
22
23
  readonly pendingMessages?: readonly MessageLike[];
23
- readonly firstKeptEntryId?: string;
24
+ readonly firstKeptEntryId?: string | undefined;
25
+ readonly notes?: NotesRecovery | undefined;
24
26
  };
25
27
  type RetainedTail = {
26
- readonly firstKeptEntryId?: string;
28
+ readonly firstKeptEntryId?: string | undefined;
27
29
  readonly ids: ReadonlySet<string>;
28
30
  };
29
31
  type Immutable<T> = T extends string | number | boolean | null | undefined
@@ -128,26 +130,29 @@ function boundedTail(
128
130
  start: number,
129
131
  maxTokens: number,
130
132
  ): RetainedTail {
131
- const costs = entries.map((entry) =>
132
- entry.sourceEntry.type === "compaction"
133
- ? 0
134
- : visibleMessages(entry).reduce(
135
- (sum, message) => sum + estimateTokens(nativeMessage(message)),
136
- 0,
137
- ),
138
- );
139
- let tokens = costs.slice(start).reduce((sum, cost) => sum + cost, 0);
140
- for (let index = start; index < entries.length; index++) {
141
- if (tokens > 0 && tokens <= maxTokens && safeStart(entries[index])) {
133
+ const costs = entries.slice(start).map((entry) => ({
134
+ entry,
135
+ cost:
136
+ entry.sourceEntry.type === "compaction"
137
+ ? 0
138
+ : visibleMessages(entry).reduce(
139
+ (sum, message) => sum + estimateTokens(nativeMessage(message)),
140
+ 0,
141
+ ),
142
+ }));
143
+ let tokens = costs.reduce((sum, { cost }) => sum + cost, 0);
144
+ for (const [offset, { entry, cost }] of costs.entries()) {
145
+ const index = start + offset;
146
+ if (tokens > 0 && tokens <= maxTokens && safeStart(entry)) {
142
147
  const tail = entries.slice(index);
143
148
  if (pairedTail(tail)) {
144
149
  return {
145
- firstKeptEntryId: entries[index].sourceEntry.id,
146
- ids: new Set(tail.map((entry) => entry.sourceEntry.id)),
150
+ firstKeptEntryId: entry.sourceEntry.id,
151
+ ids: new Set(tail.map((retained) => retained.sourceEntry.id)),
147
152
  };
148
153
  }
149
154
  }
150
- tokens -= costs[index];
155
+ tokens -= cost;
151
156
  }
152
157
  return { ids: new Set() };
153
158
  }
@@ -181,7 +186,7 @@ export function automaticRecovery(
181
186
  projected: readonly ProjectionEntry[],
182
187
  capacity: RecoveryCapacity,
183
188
  ownerQuestionRegistered: boolean,
184
- ): { readonly handoff: string; readonly firstKeptEntryId?: string } | undefined {
189
+ ): { readonly handoff: string; readonly firstKeptEntryId?: string | undefined } | undefined {
185
190
  const payload = freshPayloadChars(
186
191
  capacity.host,
187
192
  capacity.toolTokens,
@@ -196,6 +201,7 @@ export function automaticRecovery(
196
201
  const handoff = buildAutoHandoff(entries, projected, limit, {
197
202
  ownerQuestionRegistered,
198
203
  retainedEntryIds: retained.ids,
204
+ notes: capacity.notes,
199
205
  });
200
206
  return handoff.length > limit
201
207
  ? undefined
package/src/rollover.ts CHANGED
@@ -1,29 +1,30 @@
1
1
  import type {
2
2
  BoundaryResult,
3
+ ContextEvent,
3
4
  ExtensionAPI,
4
5
  ExtensionContext,
5
6
  TurnEndEvent,
6
7
  } from "@earendil-works/pi-coding-agent";
7
- import {
8
- budgetFor,
9
- buildGuidance,
10
- CHECKPOINT_STEPS,
11
- freshPayloadChars,
12
- type Budget,
13
- } from "./budget.ts";
8
+ import { budgetFor, CHECKPOINT_STEPS, freshPayloadChars, type Budget } from "./budget.ts";
14
9
  import {
15
10
  isRecord,
16
11
  isReminderType,
17
12
  REMINDER_TYPE,
18
- type CompactionPolicy,
19
13
  type PolicyContext,
20
14
  type ProjectedEntry,
21
15
  type ReminderFingerprint,
16
+ type SnapshotReader,
22
17
  } from "./contracts.ts";
23
18
  import { registerContextTools } from "./context-tools.ts";
19
+ import { registerGuidance } from "./guidance.ts";
24
20
  import { safeJsonStringify } from "./message-text.ts";
25
21
  import { hasRetainedRolloverMarker } from "./recovery.ts";
26
22
  import { automaticRecovery } from "./retention.ts";
23
+ import type { NotesRecovery } from "./note-evidence.ts";
24
+ type RolloverResources = {
25
+ readonly resetPages: () => void;
26
+ readonly notes: (ctx: ExtensionContext) => NotesRecovery | undefined;
27
+ };
27
28
  import {
28
29
  countRetainedMessages,
29
30
  createReminderLookup,
@@ -33,48 +34,7 @@ import {
33
34
  type StaleReminderPredicate,
34
35
  } from "./reminders.ts";
35
36
 
36
- export type SnapshotReader = (
37
- ctx: ExtensionContext,
38
- livePolicy?: CompactionPolicy,
39
- ) => PolicyContext;
40
37
  const REMINDER_BUFFER_TOKENS = 32_000;
41
- function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
42
- let promptOptions: { readonly forceSystemPrompt?: string } | undefined;
43
- pi.on("before_agent_start", (event) => {
44
- promptOptions = event.systemPromptOptions;
45
- });
46
- pi.on("agent_settled", () => {
47
- // Prompt options belong to one run, never to later idle context evaluation.
48
- promptOptions = undefined;
49
- });
50
- pi.on("context_with_system", (event, ctx) => {
51
- const guidance = buildGuidance(policy(ctx));
52
- const system =
53
- event.messages.findLast(
54
- (message) =>
55
- message.role === "system" && typeof message.sections?.posthorse === "string",
56
- ) ?? event.messages.find((message) => message.role === "system");
57
- if (system === undefined || system.role !== "system") {
58
- return;
59
- }
60
- if (promptOptions?.forceSystemPrompt !== undefined) {
61
- // ponytail: Pi applies forced prompts after this hook; keep request-local guidance
62
- // until the host projects forced text before context_with_system.
63
- event.messages.splice(1, 0, {
64
- role: "custom",
65
- customType: "posthorse-guidance",
66
- content: guidance,
67
- display: false,
68
- timestamp: 0,
69
- });
70
- } else {
71
- system.sections = {
72
- ...system.sections,
73
- posthorse: `<posthorse>\n${guidance}\n</posthorse>`,
74
- };
75
- }
76
- });
77
- }
78
38
  function explicitReset(
79
39
  event: TurnEndEvent,
80
40
  ctx: ExtensionContext,
@@ -150,7 +110,7 @@ function sendReminder(
150
110
  readonly tokens: number;
151
111
  readonly pendingMessages: readonly {
152
112
  readonly role: string;
153
- readonly customType?: string;
113
+ readonly customType?: string | undefined;
154
114
  readonly details?: unknown;
155
115
  }[];
156
116
  },
@@ -188,7 +148,7 @@ const NO_STALE_REMINDER: StaleReminderPredicate = () => false;
188
148
  function staleReminderPredicate(
189
149
  messages: readonly {
190
150
  readonly role: string;
191
- readonly customType?: string;
151
+ readonly customType?: string | undefined;
192
152
  readonly details?: unknown;
193
153
  }[],
194
154
  ctx: ExtensionContext,
@@ -225,6 +185,40 @@ function staleReminderPredicate(
225
185
  isReminderType(message.customType) &&
226
186
  (!enabled || !reminderMatches(message.details, fingerprint));
227
187
  }
188
+ function chronologicalRetainedTail(
189
+ messages: ContextEvent["messages"],
190
+ ctx: ExtensionContext,
191
+ stale: StaleReminderPredicate,
192
+ ): ContextEvent["messages"] {
193
+ const first = messages[0];
194
+ if (
195
+ messages.length >= 2 &&
196
+ first?.role === "compactionSummary" &&
197
+ hasRetainedRolloverMarker(first.summary)
198
+ ) {
199
+ const count = countRetainedMessages(ctx, stale);
200
+ if (count > 0 && 1 + count <= messages.length) {
201
+ const retainedSlice = messages.slice(1, 1 + count);
202
+ const afterSlice = messages.slice(1 + count);
203
+ if (retainedSlice[0]?.role !== "assistant") {
204
+ return [...retainedSlice, first, ...afterSlice];
205
+ }
206
+ return [
207
+ {
208
+ role: "custom",
209
+ customType: "posthorse-retained-tail",
210
+ content: "[Retained pre-rollover tail]",
211
+ display: false,
212
+ timestamp: first.timestamp,
213
+ },
214
+ ...retainedSlice,
215
+ first,
216
+ ...afterSlice,
217
+ ];
218
+ }
219
+ }
220
+ return messages;
221
+ }
228
222
  function registerReminderFilter(
229
223
  pi: ExtensionAPI,
230
224
  policy: SnapshotReader,
@@ -235,37 +229,9 @@ function registerReminderFilter(
235
229
  const filtered = event.messages.some(stale)
236
230
  ? event.messages.filter((message) => !stale(message))
237
231
  : event.messages;
238
- const first = filtered[0];
239
- if (
240
- filtered.length >= 2 &&
241
- first.role === "compactionSummary" &&
242
- hasRetainedRolloverMarker(first.summary)
243
- ) {
244
- const count = countRetainedMessages(ctx, stale);
245
- if (count > 0 && 1 + count <= filtered.length) {
246
- const retainedSlice = filtered.slice(1, 1 + count);
247
- const afterSlice = filtered.slice(1 + count);
248
- if (retainedSlice[0].role !== "assistant") {
249
- return { messages: [...retainedSlice, first, ...afterSlice] };
250
- }
251
- return {
252
- messages: [
253
- {
254
- role: "custom",
255
- customType: "posthorse-retained-tail",
256
- content: "[Retained pre-rollover tail]",
257
- display: false,
258
- timestamp: first.timestamp,
259
- },
260
- ...retainedSlice,
261
- first,
262
- ...afterSlice,
263
- ],
264
- };
265
- }
266
- }
267
- if (filtered !== event.messages) {
268
- return { messages: filtered };
232
+ const ordered = chronologicalRetainedTail(filtered, ctx, stale);
233
+ if (ordered !== event.messages) {
234
+ return { messages: ordered };
269
235
  }
270
236
  return;
271
237
  });
@@ -277,7 +243,7 @@ function automaticDraft(
277
243
  readonly handoff: string;
278
244
  readonly tokensBefore: number;
279
245
  readonly reason: string;
280
- readonly firstKeptEntryId?: string;
246
+ readonly firstKeptEntryId?: string | undefined;
281
247
  },
282
248
  ) {
283
249
  let firstKeptEntryId = recovery.firstKeptEntryId;
@@ -352,7 +318,11 @@ function thresholdFallback(
352
318
  pi: ExtensionAPI,
353
319
  event: TurnEndEvent,
354
320
  ctx: ExtensionContext,
355
- capacity: { readonly host: PolicyContext; readonly toolTokens: () => number },
321
+ capacity: {
322
+ readonly host: PolicyContext;
323
+ readonly toolTokens: () => number;
324
+ readonly notes: RolloverResources["notes"];
325
+ },
356
326
  ): BoundaryResult | undefined {
357
327
  if (!fallbackTurnEligible(event, ctx)) {
358
328
  return undefined;
@@ -371,7 +341,12 @@ function thresholdFallback(
371
341
  const recovery = automaticRecovery(
372
342
  ctx.sessionManager.getBranch(),
373
343
  projected,
374
- { host, toolTokens: toolTokens(), pendingMessages: event.context.pendingMessages },
344
+ {
345
+ host,
346
+ toolTokens: toolTokens(),
347
+ pendingMessages: event.context.pendingMessages,
348
+ notes: capacity.notes(ctx),
349
+ },
375
350
  ownerQuestionRegistered,
376
351
  );
377
352
  if (recovery === undefined) {
@@ -396,6 +371,7 @@ function registerAutomaticRollover(
396
371
  pi: ExtensionAPI,
397
372
  policy: SnapshotReader,
398
373
  activeToolTokens: () => number,
374
+ notes: RolloverResources["notes"],
399
375
  ): void {
400
376
  pi.on("session_before_compact", (event, ctx) => {
401
377
  if (event.reason === "manual") {
@@ -420,6 +396,7 @@ function registerAutomaticRollover(
420
396
  host,
421
397
  toolTokens: activeToolTokens(),
422
398
  firstKeptEntryId: event.preparation.firstKeptEntryId,
399
+ notes: notes(ctx),
423
400
  },
424
401
  ownerQuestionRegistered,
425
402
  );
@@ -450,13 +427,13 @@ export function registerRollover(
450
427
  pi: ExtensionAPI,
451
428
  policy: SnapshotReader,
452
429
  activeToolTokens: () => number,
453
- resetPages: () => void,
430
+ resources: RolloverResources,
454
431
  ): void {
455
432
  const resetRequests = new Map<string, string>();
456
433
  const reminders = createReminderLookup();
457
434
  const resetTurn = () => {
458
435
  resetRequests.clear();
459
- resetPages();
436
+ resources.resetPages();
460
437
  };
461
438
  pi.on("turn_start", resetTurn);
462
439
  pi.on("session_start", () => {
@@ -468,7 +445,7 @@ export function registerRollover(
468
445
  });
469
446
  registerGuidance(pi, policy);
470
447
  registerReminderFilter(pi, policy, reminders);
471
- registerAutomaticRollover(pi, policy, activeToolTokens);
448
+ registerAutomaticRollover(pi, policy, activeToolTokens, resources.notes);
472
449
  pi.on("turn_end", (event, ctx) => {
473
450
  const requested = event.toolResults.find(
474
451
  (result) => result.toolName === "new_context" && resetRequests.has(result.toolCallId),
@@ -491,6 +468,7 @@ export function registerRollover(
491
468
  const fallback = thresholdFallback(pi, event, ctx, {
492
469
  host,
493
470
  toolTokens: activeToolTokens,
471
+ notes: resources.notes,
494
472
  });
495
473
  if (fallback !== undefined) {
496
474
  return fallback;