pi-posthorse 0.10.25 → 0.10.26

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.25",
3
+ "version": "0.10.26",
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": [
package/src/boundary.ts CHANGED
@@ -1,4 +1,10 @@
1
- import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
1
+ import {
2
+ AUTO_HANDOFF_PREFIX,
3
+ POSTHORSE_COMPACTION,
4
+ isRecord,
5
+ type EntryLike,
6
+ type ProjectedEntry,
7
+ } from "./contracts.ts";
2
8
 
3
9
  export type BoundaryKind = "fork" | "native" | "automatic" | "explicit" | "posthorse";
4
10
  /** Everything the app derives from one persisted context boundary, decided in one place. */
@@ -50,7 +56,7 @@ export function classifyBoundary(entry?: EntryLike): Boundary | undefined {
50
56
  }
51
57
  const details = isRecord(entry.details) ? entry.details : undefined;
52
58
  const freshGuidance = !providerSummaryBlock(entry);
53
- if (details?.posthorse !== 1) {
59
+ if (details?.posthorse !== POSTHORSE_COMPACTION) {
54
60
  return { kind: "native", window: false, chainsAncestry: false, freshGuidance };
55
61
  }
56
62
  const kind = compactionKind(details);
package/src/contracts.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { PosthorseDisplay } from "../ui.ts";
2
+ import type { PosthorseDisplay } from "./ui/display.ts";
3
3
 
4
4
  export { isRecord } from "./records.ts";
5
5
 
@@ -40,6 +40,8 @@ export type ToolCallLike = {
40
40
  };
41
41
  export type MessageLike = {
42
42
  readonly role?: string | undefined;
43
+ readonly timestamp?: number | undefined;
44
+ readonly sections?: unknown;
43
45
  readonly stopReason?: string | undefined;
44
46
  readonly errorMessage?: string | undefined;
45
47
  readonly content?: unknown;
@@ -112,6 +114,48 @@ export const REMINDER_TYPE = "posthorse-reminder";
112
114
  export const AUTO_HANDOFF_PREFIX = "Automatic context rollover recovery record.";
113
115
  export const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before continuing.";
114
116
 
117
+ /**
118
+ * Journal wording is a storage format: recovery reads sessions back, across releases, by
119
+ * matching this exact text. Producers and parsers share one vocabulary, so a wording change
120
+ * is a migration and must keep reading the previous release's journals.
121
+ */
122
+ export const BOUNDARY_TYPE = "posthorse-boundary";
123
+ export const RESET_DEFERRED_TYPE = "posthorse-reset-deferred";
124
+ export const RETAINED_TAIL_TYPE = "posthorse-retained-tail";
125
+ export const RETAINED_MARKER_PREFIX = "Context rolled over after entry ";
126
+ /** Display marker the legacy fork's context-window extension projects into messages. */
127
+ export const LEGACY_WINDOW_TYPE = "context-window";
128
+ /** The details marker every compaction Posthorse drafts carries; readers reject other values. */
129
+ export const POSTHORSE_COMPACTION = 1;
130
+
131
+ export function posthorseDetails(reason: string): {
132
+ readonly posthorse: typeof POSTHORSE_COMPACTION;
133
+ readonly reason: string;
134
+ } {
135
+ return { posthorse: POSTHORSE_COMPACTION, reason };
136
+ }
137
+
138
+ /** Receipt lines the notes tool returns; recovery matches them to record note touches. */
139
+ export const NOTE_RECEIPT = {
140
+ wrote: "Wrote ",
141
+ appended: "Appended to ",
142
+ file: "File: ",
143
+ directory: "Notes directory: ",
144
+ } as const;
145
+
146
+ export function noteWriteReceipt(path: string): string {
147
+ return `${NOTE_RECEIPT.wrote}${path}`;
148
+ }
149
+ export function noteAppendReceipt(path: string): string {
150
+ return `${NOTE_RECEIPT.appended}${path}`;
151
+ }
152
+ export function noteReadHeader(path: string): string {
153
+ return `${NOTE_RECEIPT.file}${path}\n`;
154
+ }
155
+ export function notesDirectoryHeader(directory: string): string {
156
+ return `${NOTE_RECEIPT.directory}${directory}\n`;
157
+ }
158
+
115
159
  export function unknownArray(value: unknown): readonly unknown[] | undefined {
116
160
  return Array.isArray(value) ? value : undefined;
117
161
  }
package/src/files.ts CHANGED
@@ -86,6 +86,12 @@ async function existingNote(target: string) {
86
86
  }
87
87
  return previous;
88
88
  }
89
+ /** Reserved by publishFile; inventories hide these names without deleting their contents. */
90
+ export function isPublicationTemporaryName(name: string): boolean {
91
+ return /^\.posthorse-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/.test(
92
+ name,
93
+ );
94
+ }
89
95
  /** Same-directory replacement; pre-rename failures preserve the old note and its metadata. */
90
96
  export async function publishFile(
91
97
  path: string,
package/src/guidance.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type, type Static } from "typebox";
3
3
  import { Check } from "typebox/value";
4
4
  import {
@@ -9,8 +9,14 @@ import {
9
9
  } from "./budget.ts";
10
10
  import type { EntryLike, PolicyContext, SnapshotReader } from "./contracts.ts";
11
11
  import { startsFreshGuidanceEpoch } from "./boundary.ts";
12
- import { createEpochCache, type EpochValue } from "./epoch-cache.ts";
13
- import { sessionEntry, toolFacts, type SessionHost, type SessionIdentity } from "./host.ts";
12
+ import { createEpochCache, type EpochValue, type SessionContext } from "./epoch-cache.ts";
13
+ import {
14
+ sessionEntry,
15
+ toolFacts,
16
+ type SessionHost,
17
+ type SessionIdentity,
18
+ type ToolRegistry,
19
+ } from "./host.ts";
14
20
 
15
21
  export const WINDOW_GUIDANCE_TYPE = "posthorse-window-guidance";
16
22
  const guidanceRecordSchema = Type.Object({
@@ -33,12 +39,13 @@ type GuidanceEpoch = {
33
39
  readonly tools: GuidanceTools | undefined;
34
40
  };
35
41
  export type WindowGuidance = {
36
- readonly resolve: (ctx: ExtensionContext, host: PolicyContext) => string;
37
- readonly deliver: (ctx: ExtensionContext, host: PolicyContext) => string;
42
+ /** Only the session manager is read; the same context the epoch cache accepts. */
43
+ readonly resolve: (ctx: SessionContext, host: PolicyContext) => string;
44
+ readonly deliver: (ctx: SessionContext, host: PolicyContext) => string;
38
45
  readonly reset: () => void;
39
46
  };
40
47
 
41
- export function activeGuidanceTools(pi: ExtensionAPI): GuidanceTools {
48
+ export function activeGuidanceTools(pi: ToolRegistry): GuidanceTools {
42
49
  const { activeUnnamespaced } = toolFacts(pi);
43
50
  return {
44
51
  newContext: activeUnnamespaced.has("new_context"),
@@ -122,7 +129,10 @@ function scanGuidanceEpoch(
122
129
  ...withMemoryEpoch(collected, memoryByEpoch?.get(anchorKey)),
123
130
  };
124
131
  }
125
- export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
132
+ /** The journal and tool-registry members guidance borrows; the host's full API satisfies these. */
133
+ export type GuidanceRegistry = ToolRegistry & Pick<ExtensionAPI, "appendEntry">;
134
+
135
+ export function createWindowGuidance(pi: GuidanceRegistry): WindowGuidance {
126
136
  let memoryByManager = new WeakMap<object, Map<string, MemoryEpoch>>();
127
137
  const epochs = createEpochCache<GuidanceEpoch>((ctx, identity, previous) =>
128
138
  scanGuidanceEpoch(
@@ -132,6 +142,21 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
132
142
  memoryByManager.get(ctx.sessionManager),
133
143
  ),
134
144
  );
145
+ /**
146
+ * A fresh anchor retires every earlier one on this branch: nothing reads an old epoch again
147
+ * without /tree, which resets all memory. Keep only the current anchor's delivered text.
148
+ */
149
+ const pruneRetiredEpochs = (manager: object, anchorKey: string): void => {
150
+ const byEpoch = memoryByManager.get(manager);
151
+ if (byEpoch === undefined) {
152
+ return;
153
+ }
154
+ for (const key of byEpoch.keys()) {
155
+ if (key !== anchorKey) {
156
+ byEpoch.delete(key);
157
+ }
158
+ }
159
+ };
135
160
  return {
136
161
  resolve(ctx, host) {
137
162
  const epoch = epochs.read(ctx);
@@ -140,8 +165,19 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
140
165
  epoch.byBudget.get(key) ?? buildGuidance(host, epoch.tools ?? ALL_GUIDANCE_TOOLS)
141
166
  );
142
167
  },
168
+ /**
169
+ * Delivering guidance from inside the request hook must persist before the request: a
170
+ * restart after a rollover with a provider summary block has to reuse these exact bytes —
171
+ * the leading system message is sent unchanged, and in-memory delivery state does not
172
+ * survive. On the fork, the provider-summary request runs this hook mid-compaction. That
173
+ * is safe twice over: the request that reached the rollover threshold already delivered
174
+ * this budget's text, so the summary request reuses it and writes nothing; and a
175
+ * compaction with a summary block keeps the previous guidance anchor, so even a budget
176
+ * change mid-compaction appends inside the epoch the next window still reads.
177
+ */
143
178
  deliver(ctx, host) {
144
179
  const epoch = epochs.read(ctx);
180
+ pruneRetiredEpochs(ctx.sessionManager, epoch.anchorKey);
145
181
  const key = budgetGuidanceKey(host);
146
182
  const existing = epoch.byBudget.get(key);
147
183
  if (existing !== undefined) {
@@ -177,7 +213,7 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
177
213
  };
178
214
  }
179
215
  export function registerGuidance(
180
- pi: ExtensionAPI,
216
+ pi: Pick<ExtensionAPI, "on">,
181
217
  policy: SnapshotReader,
182
218
  windowGuidance: WindowGuidance,
183
219
  ): void {
@@ -185,6 +221,7 @@ export function registerGuidance(
185
221
  pi.on("before_agent_start", (event) => {
186
222
  promptOptions = event.systemPromptOptions;
187
223
  });
224
+
188
225
  pi.on("agent_settled", () => {
189
226
  // Prompt options belong to one run, never to later idle context evaluation.
190
227
  promptOptions = undefined;
@@ -1,6 +1,7 @@
1
1
  import { readdir, stat } from "node:fs/promises";
2
- import { join, relative } from "node:path";
2
+ import { basename, join, relative } from "node:path";
3
3
  import { isRecord } from "./contracts.ts";
4
+ import { isPublicationTemporaryName } from "./files.ts";
4
5
  import { sessionNoteTouches } from "./note-evidence.ts";
5
6
  import { notesUnder, type NoteFile } from "./note-inventory.ts";
6
7
  import {
@@ -118,6 +119,9 @@ async function readReference(
118
119
  path: string,
119
120
  signal: AbortSignal | undefined,
120
121
  ): Promise<DiscoveredNote | undefined> {
122
+ if (isPublicationTemporaryName(basename(path))) {
123
+ return;
124
+ }
121
125
  const target = await session.access(path, { signal });
122
126
  if (target.kind === "own") {
123
127
  return;
@@ -1,6 +1,7 @@
1
1
  import { readdir, realpath, stat } from "node:fs/promises";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { isRecord, presentText } from "./contracts.ts";
4
+ import { isPublicationTemporaryName } from "./files.ts";
4
5
  import {
5
6
  canonicalPath,
6
7
  inside,
@@ -26,7 +27,9 @@ async function statIfPresent(path: string) {
26
27
  }
27
28
  /** Preserve depth-first listing order and recheck later entries after visiting each folder. */
28
29
  async function* folderEntries(directory: string, signal: AbortSignal | undefined) {
29
- const entries = await readdir(directory, { withFileTypes: true });
30
+ const entries = (await readdir(directory, { withFileTypes: true })).filter(
31
+ (entry) => !isPublicationTemporaryName(entry.name),
32
+ );
30
33
  let start = 0;
31
34
  while (start < entries.length) {
32
35
  signal?.throwIfAborted();
@@ -99,6 +102,9 @@ export async function notesUnder(
99
102
  requested === undefined || requested === "."
100
103
  ? scopeDirectory(scope)
101
104
  : notePath(scope, requested);
105
+ if (isPublicationTemporaryName(basename(target))) {
106
+ return [];
107
+ }
102
108
  const info = await statIfPresent(target);
103
109
  options.signal?.throwIfAborted();
104
110
  if (info === undefined) {
package/src/note-store.ts CHANGED
@@ -32,6 +32,8 @@ import {
32
32
  import type { NotesRecovery } from "./note-evidence.ts";
33
33
 
34
34
  type NotesManager = ExtensionContext["sessionManager"];
35
+ /** The journal member the notes store borrows; the host's full API satisfies this. */
36
+ type NotesApi = Pick<ExtensionAPI, "appendEntry">;
35
37
  export type NoteCall = { readonly id: string; readonly rootId: string };
36
38
  type Adoption = "owned" | "adopted";
37
39
  /**
@@ -42,7 +44,7 @@ export type NoteAccess =
42
44
  | { readonly kind: "own"; readonly address: NoteAddress; readonly path: string }
43
45
  | { readonly kind: "inherited"; readonly address: NoteAddress; readonly path: string };
44
46
  export class SessionNotes {
45
- readonly #pi: ExtensionAPI;
47
+ readonly #pi: NotesApi;
46
48
  readonly binding: NoteBinding;
47
49
  readonly #manager: NotesManager;
48
50
  readonly #source: NoteSource;
@@ -50,7 +52,7 @@ export class SessionNotes {
50
52
  readonly scopes: readonly NoteScope[];
51
53
  readonly #calls = new Map<string, string>();
52
54
  readonly #revisions = new Map<string, number>();
53
- constructor(pi: ExtensionAPI, binding: NoteBinding, manager: NotesManager, source: NoteSource) {
55
+ constructor(pi: NotesApi, binding: NoteBinding, manager: NotesManager, source: NoteSource) {
54
56
  this.#pi = pi;
55
57
  this.binding = binding;
56
58
  this.#manager = manager;
@@ -251,8 +253,9 @@ export type NotesStore = {
251
253
  type SessionState =
252
254
  | { readonly kind: "initializing"; readonly value: Promise<SessionNotes> }
253
255
  | { readonly kind: "ready"; readonly value: SessionNotes };
254
- export function createNotesStore(pi: ExtensionAPI): NotesStore {
256
+ export function createNotesStore(pi: NotesApi): NotesStore {
255
257
  const sessions = new Map<string, SessionState>();
258
+
256
259
  const get = (ctx: ExtensionContext): Promise<SessionNotes> => {
257
260
  const key = sessionKey(ctx);
258
261
  const existing = sessions.get(key);
package/src/notes.ts CHANGED
@@ -11,6 +11,10 @@ import { Type } from "typebox";
11
11
  import { toolCards } from "../ui.ts";
12
12
  import {
13
13
  MAX_PAGE_CHARS,
14
+ noteAppendReceipt,
15
+ noteReadHeader,
16
+ noteWriteReceipt,
17
+ notesDirectoryHeader,
14
18
  presentText,
15
19
  requireValue,
16
20
  textResult,
@@ -84,7 +88,7 @@ async function listNotes(
84
88
  offset: params.offset ?? 0,
85
89
  kind: "notes-list",
86
90
  empty: folder === undefined ? "(no notes yet)" : `(no notes in ${folder})`,
87
- header: `Notes directory: ${dir}\n`,
91
+ header: notesDirectoryHeader(dir),
88
92
  });
89
93
  }
90
94
  async function readNote(
@@ -124,7 +128,7 @@ async function readNote(
124
128
  }
125
129
  const header =
126
130
  target.kind === "own"
127
- ? `File: ${path}\n`
131
+ ? noteReadHeader(path)
128
132
  : `Read-only live reference: notes read ${JSON.stringify({ scope: scopeDirectory(target.address), path: target.address.path })}\n`;
129
133
  const end = Math.min(
130
134
  text.length,
@@ -170,7 +174,7 @@ async function writeNote(
170
174
  access.call,
171
175
  );
172
176
  });
173
- return textResult(`Wrote ${path}`, [], { kind: "note-write" });
177
+ return textResult(noteWriteReceipt(path), [], { kind: "note-write" });
174
178
  }
175
179
  async function appendNote(
176
180
  session: SessionNotes,
@@ -203,7 +207,7 @@ async function appendNote(
203
207
  access.call,
204
208
  );
205
209
  });
206
- return textResult(`Appended to ${path}`, [], { kind: "note-append" });
210
+ return textResult(noteAppendReceipt(path), [], { kind: "note-append" });
207
211
  }
208
212
  /** One note's matching lines as `path:line: excerpt` rows. */
209
213
  function matchingLines(name: string, text: string, query: string): string[] {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ NOTE_RECEIPT,
2
3
  isRecord,
3
4
  optionalString,
4
5
  presentText,
@@ -15,7 +16,7 @@ export type ScannedNoteEntry = {
15
16
  readonly entryId: string;
16
17
  readonly inRetained: boolean;
17
18
  };
18
- type ContentTouchKind = "full-write" | "partial-write" | "read" | "clear";
19
+ export type ContentTouchKind = "full-write" | "partial-write" | "read" | "clear";
19
20
  export type NoteTarget = {
20
21
  readonly notePath: string;
21
22
  readonly absPath?: string | undefined;
@@ -81,6 +82,21 @@ function matchMarkerPath(slash: string): string | undefined {
81
82
  function absoluteSlashPath(slash: string): boolean {
82
83
  return slash.startsWith("/") || /^[A-Za-z]:\//u.test(slash);
83
84
  }
85
+
86
+ /** Receipt lines are a storage format: parse exactly what the notes tool emits. */
87
+ function receiptPattern(prefix: string): RegExp {
88
+ return new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}([^\\r\\n]+)$`, "mu");
89
+ }
90
+ const receiptPatterns: readonly (readonly [RegExp, ContentTouchKind])[] = [
91
+ [receiptPattern(NOTE_RECEIPT.wrote), "full-write"],
92
+ [receiptPattern(NOTE_RECEIPT.appended), "partial-write"],
93
+ [receiptPattern(NOTE_RECEIPT.file), "read"],
94
+ ];
95
+
96
+ export function notesDirectoryOf(text: string): string | undefined {
97
+ return receiptPattern(NOTE_RECEIPT.directory).exec(text)?.[1];
98
+ }
99
+
84
100
  function unscopedNoteTarget(trimmed: string, slash: string): NoteTarget | undefined {
85
101
  if (absoluteSlashPath(slash)) {
86
102
  const base = normalizeRelPath(slash.slice(slash.lastIndexOf("/") + 1));
@@ -114,15 +130,10 @@ function resolveNoteTarget(
114
130
  return fromNotesTool ? unscopedNoteTarget(trimmed, slash) : undefined;
115
131
  }
116
132
 
117
- function extractNotesResultTarget(
133
+ export function extractNotesResultTarget(
118
134
  text: string,
119
135
  ): { readonly kind: ContentTouchKind; readonly rawPath: string } | undefined {
120
- const patterns: readonly (readonly [RegExp, ContentTouchKind])[] = [
121
- [/^Wrote ([^\r\n]+)$/mu, "full-write"],
122
- [/^Appended to ([^\r\n]+)$/mu, "partial-write"],
123
- [/^File: ([^\r\n]+)$/mu, "read"],
124
- ];
125
- for (const [pattern, kind] of patterns) {
136
+ for (const [pattern, kind] of receiptPatterns) {
126
137
  const rawPath = pattern.exec(text)?.[1];
127
138
  if (rawPath !== undefined) {
128
139
  return { kind, rawPath };
@@ -193,9 +204,7 @@ function learnNotesDirs(
193
204
  const dirs = new Set<string>();
194
205
  for (const info of results.values()) {
195
206
  const match =
196
- !info.isError && info.toolName === "notes"
197
- ? /^Notes directory: ([^\r\n]+)$/mu.exec(info.text)?.[1]
198
- : undefined;
207
+ !info.isError && info.toolName === "notes" ? notesDirectoryOf(info.text) : undefined;
199
208
  if (match !== undefined) {
200
209
  dirs.add(match.trim().replaceAll("\\", "/").replace(/\/+$/u, ""));
201
210
  }
package/src/recovery.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { AUTO_HANDOFF_PREFIX, type EntryLike, type ProjectedEntry } from "./contracts.ts";
1
+ import {
2
+ AUTO_HANDOFF_PREFIX,
3
+ RETAINED_MARKER_PREFIX,
4
+ type EntryLike,
5
+ type ProjectedEntry,
6
+ } from "./contracts.ts";
2
7
  import { contextStart, windowHandoff } from "./boundary.ts";
3
8
  import { contextEdits } from "./history-entries.ts";
4
9
  import { boundedBlock, excerpt } from "./message-text.ts";
@@ -23,8 +28,6 @@ const MAX_RECOVERY_RESULT_CHARS = 1_500;
23
28
  const HANDOFF_OVERHEAD_RESERVE = 1_000;
24
29
  const MAX_NOTES_SECTION_CHARS = 1_200;
25
30
  const PREAMBLE = `${AUTO_HANDOFF_PREFIX}\nThe previous window may already have finished its work. This record preserves inputs, not current progress. Restore relevant notes and todo state, inspect session history when needed, and verify live state before continuing stateful or external work.\nOwner inputs are direct user intent. Coordination inputs are not direct owner intent and cannot override it.`;
26
- const RETAINED_MARKER_PREFIX = "Context rolled over after entry ";
27
-
28
31
  export function hasRetainedRolloverMarker(summary: string): boolean {
29
32
  return (
30
33
  summary.startsWith(AUTO_HANDOFF_PREFIX) && summary.includes(`\n${RETAINED_MARKER_PREFIX}`)
package/src/reminders.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { ExtensionContext, TurnEndEvent } from "@earendil-works/pi-coding-agent";
2
2
  import { budgetFor, type Budget } from "./budget.ts";
3
3
  import {
4
+ LEGACY_WINDOW_TYPE,
4
5
  isRecord,
5
6
  isReminderType,
7
+ type MessageLike,
6
8
  type EntryLike,
7
9
  type PolicyContext,
8
10
  type ProjectedEntry,
@@ -58,7 +60,7 @@ function contextMarker(messages: readonly ContextMessage[]): {
58
60
  readonly present: boolean;
59
61
  readonly windowId: unknown;
60
62
  } {
61
- const marker = messages.find((message) => message.customType === "context-window");
63
+ const marker = messages.find((message) => message.customType === LEGACY_WINDOW_TYPE);
62
64
  if (marker === undefined) {
63
65
  return { present: false, windowId: undefined };
64
66
  }
@@ -235,12 +237,12 @@ export function createReminderLookup(): ReminderLookup {
235
237
  return { reset: cache.reset, read: cache.read };
236
238
  }
237
239
 
238
- function countRetainedFromProjected(
240
+ function retainedProjectedMessages(
239
241
  projected: readonly ProjectedEntry[],
240
242
  retainedIds: ReadonlySet<string>,
241
243
  stale: StaleReminderPredicate,
242
- ): number {
243
- let count = 0;
244
+ ): MessageLike[] {
245
+ const retained: MessageLike[] = [];
244
246
  for (const item of projected) {
245
247
  if (!retainedIds.has(item.sourceEntry.id ?? "")) {
246
248
  continue;
@@ -249,11 +251,11 @@ function countRetainedFromProjected(
249
251
  if (
250
252
  !stale({ role: msg.role ?? "", customType: msg.customType, details: msg.details })
251
253
  ) {
252
- count += 1;
254
+ retained.push(msg);
253
255
  }
254
256
  }
255
257
  }
256
- return count;
258
+ return retained;
257
259
  }
258
260
 
259
261
  function entryProducesRetainedMessage(
@@ -284,7 +286,7 @@ export function countRetainedMessages(
284
286
  }
285
287
  const projection = sessionProjection(ctx.sessionManager);
286
288
  if (projection !== undefined) {
287
- return countRetainedFromProjected(projection, retainedIds, stale);
289
+ return retainedProjectedMessages(projection, retainedIds, stale).length;
288
290
  }
289
291
  const edits = contextEdits(branch);
290
292
  return branch.filter(
@@ -292,3 +294,24 @@ export function countRetainedMessages(
292
294
  retainedIds.has(entry.id ?? "") && entryProducesRetainedMessage(entry, edits, stale),
293
295
  ).length;
294
296
  }
297
+
298
+ /**
299
+ * The retained tail's messages, in journal order, as the session projection carries them.
300
+ * Stale reminders leave the list exactly as the provider filter removes them. Undefined when
301
+ * the journal names no tail or the host has no session projection, so callers cannot certify
302
+ * the tail by identity and must count positions instead.
303
+ */
304
+ export function retainedTailMessages(
305
+ ctx: ExtensionContext,
306
+ stale: StaleReminderPredicate,
307
+ ): readonly MessageLike[] | undefined {
308
+ const branch = sessionBranch(ctx.sessionManager) ?? [];
309
+ const retainedIds = journalRetainedIds(branch);
310
+ if (retainedIds === undefined) {
311
+ return undefined;
312
+ }
313
+ const projection = sessionProjection(ctx.sessionManager);
314
+ return projection === undefined
315
+ ? undefined
316
+ : retainedProjectedMessages(projection, retainedIds, stale);
317
+ }
@@ -1,4 +1,4 @@
1
- import type { EntryLike } from "./contracts.ts";
1
+ import type { EntryLike, MessageLike } from "./contracts.ts";
2
2
 
3
3
  /**
4
4
  * What a rollover retains: where Pi keeps the tail, which entries it holds, and how it must start.
@@ -54,3 +54,87 @@ export function retainedSpan(
54
54
  const lastId = list.at(-1)?.id?.slice(0, 120);
55
55
  return firstId === undefined || lastId === undefined ? undefined : { firstId, lastId };
56
56
  }
57
+
58
+ /**
59
+ * Where the retained tail ends in a provider message list: the position after the last message
60
+ * that matches the journal's next retained message, in order. Opaque system presentations of
61
+ * custom messages must preserve their journal timestamp and order. Other interleaved messages
62
+ * are skipped. Returns undefined when the whole tail cannot be matched.
63
+ */
64
+ export function retainedTailEnd(
65
+ messages: readonly MessageLike[],
66
+ summaryIndex: number,
67
+ retained: readonly MessageLike[],
68
+ ): number | undefined {
69
+ if (retained.length === 0) {
70
+ return summaryIndex + 1;
71
+ }
72
+ let expected = 0;
73
+ let end: number | undefined;
74
+ for (
75
+ let index = summaryIndex + 1;
76
+ index < messages.length && expected < retained.length;
77
+ index++
78
+ ) {
79
+ const message = messages.at(index);
80
+ const journal = retained.at(expected);
81
+ if (message === undefined || journal === undefined) {
82
+ break;
83
+ }
84
+ if (sameRetainedMessage(journal, message) || isOpaquePresentation(journal, message)) {
85
+ expected += 1;
86
+ end = index + 1;
87
+ }
88
+ }
89
+ return expected === retained.length ? end : undefined;
90
+ }
91
+
92
+ /**
93
+ * Presenters such as pi-change-working-dir replace a custom message's content and details with
94
+ * system sections but preserve its timestamp. Timestamp/order correlation is their integration
95
+ * contract, not a globally unique message ID. A system message without that timestamp is not
96
+ * evidence that the expected custom message survived.
97
+ */
98
+ function isOpaquePresentation(journal: MessageLike, event: MessageLike): boolean {
99
+ return (
100
+ event.role === "system" &&
101
+ event.content === "" &&
102
+ event.sections !== undefined &&
103
+ journal.role === "custom" &&
104
+ journal.timestamp !== undefined &&
105
+ event.timestamp === journal.timestamp
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Identity of a retained message as the provider context carries it: same payload and receipt
111
+ * linkage, and a role an earlier handler may only present as `system`. Journal and
112
+ * provider messages are clones of each other, so identity is structural, never object equality.
113
+ */
114
+ export function sameRetainedMessage(journal: MessageLike, event: MessageLike): boolean {
115
+ return (
116
+ (event.role === "system" || journal.role === event.role) &&
117
+ messageIdentity(journal) === messageIdentity(event)
118
+ );
119
+ }
120
+
121
+ function messageIdentity(message: MessageLike): string {
122
+ // Array serialization normalizes absent optional fields to null on both clones.
123
+ return JSON.stringify([
124
+ message.timestamp,
125
+ message.customType,
126
+ message.content,
127
+ message.details,
128
+ message.toolCallId,
129
+ message.toolName,
130
+ message.namespace,
131
+ message.isError,
132
+ message.summary,
133
+ message.command,
134
+ message.output,
135
+ message.stopReason,
136
+ message.errorMessage,
137
+ message.nestedCalls,
138
+ message.excludeFromContext,
139
+ ]);
140
+ }
package/src/rollover.ts CHANGED
@@ -9,7 +9,11 @@ import type {
9
9
  import { activeBoundaryIndex } from "./boundary.ts";
10
10
  import { budgetFor, freshPayloadChars } from "./budget.ts";
11
11
  import {
12
+ BOUNDARY_TYPE,
12
13
  REMINDER_TYPE,
14
+ RESET_DEFERRED_TYPE,
15
+ RETAINED_TAIL_TYPE,
16
+ posthorseDetails,
13
17
  type PolicyContext,
14
18
  type ProjectedEntry,
15
19
  type SnapshotReader,
@@ -36,10 +40,12 @@ import {
36
40
  countRetainedMessages,
37
41
  createReminderLookup,
38
42
  reminderDecision,
43
+ retainedTailMessages,
39
44
  staleReminderPredicate,
40
45
  type ReminderLookup,
41
46
  type StaleReminderPredicate,
42
47
  } from "./reminders.ts";
48
+ import { retainedTailEnd } from "./retained-tail.ts";
43
49
 
44
50
  function explicitReset(
45
51
  event: TurnEndEvent,
@@ -53,7 +59,7 @@ function explicitReset(
53
59
  ...event.entries,
54
60
  {
55
61
  type: "custom_message",
56
- customType: "posthorse-reset-deferred",
62
+ customType: RESET_DEFERRED_TYPE,
57
63
  display: true,
58
64
  content:
59
65
  "Posthorse reset deferred: queued messages leave too little room for the requested handoff. The current context is unchanged; process those messages, then request a shorter handoff.",
@@ -68,7 +74,7 @@ function explicitReset(
68
74
  type: "compaction",
69
75
  summary,
70
76
  firstKeptEntryId: null,
71
- details: { posthorse: 1, reason: "explicit" },
77
+ details: posthorseDetails("explicit"),
72
78
  },
73
79
  ],
74
80
  continue: true,
@@ -83,11 +89,11 @@ function chronologicalRetainedTail(
83
89
  const first = messages[summaryIndex];
84
90
  if (first?.role === "compactionSummary" && hasRetainedRolloverMarker(first.summary)) {
85
91
  // Pi projects no system messages into the retained tail, but an earlier handler can
86
- // present a retained message as one (pi-change-working-dir's directory snapshots).
87
- // Count positions, not roles, so the record never lands inside the next tool batch.
88
- const count = countRetainedMessages(ctx, stale);
89
- const end = summaryIndex + 1 + count;
90
- if (count > 0 && end <= messages.length) {
92
+ // present a retained message as one (pi-change-working-dir's directory snapshots) or
93
+ // interleave a new message. Match the projected journal tail before reordering it;
94
+ // count positions only on a host without a projection.
95
+ const { count, end } = retainedTailSliceEnd(messages, summaryIndex, ctx, stale);
96
+ if (count > 0 && end !== undefined) {
91
97
  const prefix = messages.slice(0, summaryIndex);
92
98
  const retainedSlice = messages.slice(summaryIndex + 1, end);
93
99
  const afterSlice = messages.slice(end);
@@ -98,7 +104,7 @@ function chronologicalRetainedTail(
98
104
  ...prefix,
99
105
  {
100
106
  role: "custom",
101
- customType: "posthorse-retained-tail",
107
+ customType: RETAINED_TAIL_TYPE,
102
108
  content: "[Retained pre-rollover tail]",
103
109
  display: false,
104
110
  timestamp: first.timestamp,
@@ -111,6 +117,27 @@ function chronologicalRetainedTail(
111
117
  }
112
118
  return messages;
113
119
  }
120
+
121
+ /** An unmatched projection must not silently become a positional match. */
122
+ function retainedTailSliceEnd(
123
+ messages: ContextWithSystemEvent["messages"],
124
+ summaryIndex: number,
125
+ ctx: ExtensionContext,
126
+ stale: StaleReminderPredicate,
127
+ ): { readonly count: number; readonly end: number | undefined } {
128
+ const retained = retainedTailMessages(ctx, stale);
129
+ const count = retained?.length ?? countRetainedMessages(ctx, stale);
130
+ if (count === 0) {
131
+ return { count, end: undefined };
132
+ }
133
+ let end: number | undefined;
134
+ if (retained !== undefined) {
135
+ end = retainedTailEnd(messages, summaryIndex, retained);
136
+ } else if (summaryIndex + 1 + count <= messages.length) {
137
+ end = summaryIndex + 1 + count;
138
+ }
139
+ return { count, end };
140
+ }
114
141
  function registerReminderFilter(
115
142
  pi: ExtensionAPI,
116
143
  policy: SnapshotReader,
@@ -149,7 +176,9 @@ function automaticDraft(
149
176
  let firstKeptEntryId = recovery.firstKeptEntryId;
150
177
  if (firstKeptEntryId === undefined) {
151
178
  // Public compaction requires an entry id even when no prior conversation safely fits.
152
- pi.appendEntry("posthorse-boundary", {});
179
+ // A compaction cancelled after this append leaves an inert marker: no reader matches
180
+ // BOUNDARY_TYPE; the compaction entry itself is the boundary recovery classifies.
181
+ pi.appendEntry(BOUNDARY_TYPE, {});
153
182
  firstKeptEntryId = ctx.sessionManager.getLeafId() ?? undefined;
154
183
  }
155
184
  if (firstKeptEntryId === undefined || firstKeptEntryId === "") {
@@ -160,7 +189,7 @@ function automaticDraft(
160
189
  summary: recovery.handoff,
161
190
  firstKeptEntryId,
162
191
  tokensBefore: recovery.tokensBefore,
163
- details: { posthorse: 1, reason: recovery.reason },
192
+ details: posthorseDetails(recovery.reason),
164
193
  ...providerSummaryField(recovery.providerSummary),
165
194
  },
166
195
  };
@@ -263,7 +292,7 @@ function thresholdFallback(
263
292
  type: "compaction",
264
293
  summary: recovery.handoff,
265
294
  firstKeptEntryId: recovery.firstKeptEntryId ?? null,
266
- details: { posthorse: 1, reason: "threshold" },
295
+ details: posthorseDetails("threshold"),
267
296
  ...providerSummaryField(recovery.providerSummary),
268
297
  },
269
298
  ],
@@ -11,6 +11,7 @@ export type LineMatcher = {
11
11
  export type SessionLine = {
12
12
  readonly head: string;
13
13
  readonly matches: boolean;
14
+ /** Read while the owning iterator is open; advancing alone does not close its handle. */
14
15
  readonly text: () => Promise<string>;
15
16
  };
16
17
  type LineRange = {
package/ui.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  type Text,
8
8
  } from "@earendil-works/pi-tui";
9
9
  import { clean, hint, leadingRows, textBlock, textOf } from "./src/ui/text.ts";
10
+ import { LEGACY_WINDOW_TYPE, REMINDER_TYPE } from "./src/contracts.ts";
10
11
 
11
12
  export type { PosthorseDisplay } from "./src/ui/display.ts";
12
13
  export { toolCards } from "./src/ui/cards.ts";
@@ -26,7 +27,7 @@ export function registerPosthorseMessages(pi: ExtensionAPI): void {
26
27
  states.set(message, state);
27
28
  }
28
29
  const view = state;
29
- const window = message.customType === "context-window";
30
+ const window = message.customType === LEGACY_WINDOW_TYPE;
30
31
  const raw = textOf(message.content);
31
32
  const handoffLabel = "\n\nHandoff from the previous window:\n";
32
33
  const handoffAt = raw.indexOf(handoffLabel);
@@ -79,7 +80,7 @@ export function registerPosthorseMessages(pi: ExtensionAPI): void {
79
80
  return { handled: true };
80
81
  });
81
82
  };
82
- for (const type of ["context-window", "posthorse-reminder"]) {
83
+ for (const type of [LEGACY_WINDOW_TYPE, REMINDER_TYPE]) {
83
84
  pi.registerMessageRenderer(type, renderer);
84
85
  }
85
86
  }