pi-posthorse 0.10.21 → 0.10.22

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Posthorse
2
2
 
3
- Posthorse starts fresh context windows for long [Pi](https://github.com/earendil-works/pi) tasks without an automatic model-generated summary. Stock Pi already preserves the session journal and retains a recent tail during compaction; Posthorse adds explicit notes and history retrieval, with a handoff or bounded recovery record instead of the automatic summary.
3
+ Posthorse starts fresh context windows for long [Pi](https://github.com/earendil-works/pi) tasks without an automatic model-generated summary of the work. Stock Pi already preserves the session journal and retains a recent tail during compaction; Posthorse adds explicit notes and history retrieval, with a handoff or bounded recovery record instead of the automatic summary.
4
4
 
5
5
  ![A Pi conversation rolls into a fresh context window. Task notes and the complete session transcript remain available.](https://raw.githubusercontent.com/fitchmultz/pi-posthorse/main/.github/readme/context-rollover.png)
6
6
 
@@ -25,7 +25,7 @@ Give Pi your task. For example:
25
25
 
26
26
  Posthorse can send a routine task-note reminder before rollover. The reminder tells the model to save current task state if needed, then immediately continue the prescribed work. The context window is not a task deadline. The reminder does not require `new_context` or wake an agent after its final answer. Large turns can reach rollover before the reminder. See [Reminder timing](docs/reminder-timing.md) for the trigger and its relationship to Pi's reserve and retention settings.
27
27
 
28
- Automatic rollover retains a safe recent-message tail using Pi's `compaction.keepRecentTokens`, alongside a bounded recovery record for discarded inputs and tool evidence. It never calls a summarization model. The model must restore notes and check live state before it continues. Optional `new_context` deliberately retains no prior messages; manual `/compact` uses Pi's normal summarization.
28
+ Automatic rollover retains a safe recent-message tail using Pi's `compaction.keepRecentTokens`, alongside a bounded recovery record for discarded inputs and tool evidence. No model summarizes the work. The model must restore notes and check live state before it continues. On [fitchmultz/pi](https://github.com/fitchmultz/pi), a Claude model also writes one line that points to the task note, so that Claude keeps [the reasoning of the retained replies](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/context-recovery.md#claude-reasoning-in-the-retained-tail). Optional `new_context` deliberately retains no prior messages; manual `/compact` uses Pi's normal summarization.
29
29
 
30
30
  Posthorse does not schedule rollover when a provider cache expires. An idle session below the
31
31
  rollover threshold keeps its context; use an explicit `new_context` when a deliberate clean reset
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.10.21",
3
+ "version": "0.10.22",
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": [
@@ -0,0 +1,53 @@
1
+ import * as host from "@earendil-works/pi-ai";
2
+
3
+ /**
4
+ * A one-line summary block written by the provider at an automatic rollover.
5
+ *
6
+ * A host that implements `providerSummary` on a compaction asks the session's model for the block
7
+ * and sends it ahead of the retained tail, which keeps the reasoning of the tail's replies valid.
8
+ * The block carries no task state; the recovery record after the tail and the task note do.
9
+ */
10
+ export type ProviderSummaryRequest = { readonly instructions: string };
11
+ type SupportProbe = (model: unknown) => unknown;
12
+ function isProbe(value: unknown): value is SupportProbe {
13
+ return typeof value === "function";
14
+ }
15
+ /** Exported by a host that can make the block; it knows which models return one. */
16
+ const hostSupport: unknown = Reflect.get(host, "supportsProviderSummary");
17
+ export function hostMakesProviderSummary(model: unknown): boolean {
18
+ return isProbe(hostSupport) && model !== undefined && hostSupport(model) === true;
19
+ }
20
+ /** Measured size of a one-line block is 59 to 77 tokens; Pi's estimate does not count it. */
21
+ export const PROVIDER_SUMMARY_TOKENS = 100;
22
+ /**
23
+ * The model answers a request for a one-line summary. It refuses a request to reply with a fixed
24
+ * line or with the path only, and one that tells the summary to speak about "the recent messages",
25
+ * so the wording asks for a summary that points to the note, or to the record when there is none.
26
+ */
27
+ export function providerSummaryInstructions(notePath: string | undefined): string {
28
+ const pointer =
29
+ notePath === undefined
30
+ ? "state that the details are in the recovery record below"
31
+ : `point to the task note \`${notePath}\` and say to read that note and proceed from there`;
32
+ return `Write a summary of one line, at most 20 words: name the task in a few words, then ${pointer}. Do not mention progress, a pending step or a next step. Do not call any tools; respond with the summary text only.`;
33
+ }
34
+ type MessageLike = { readonly role: string };
35
+ /**
36
+ * How many leading messages Pi sends exactly as the provider last saw them. With a block, those
37
+ * are the leading system message and the retained tail up to the record; changing, removing or
38
+ * reordering one makes the provider drop the reasoning of every reply after it. A tail that
39
+ * reaches back across an earlier rollover holds that rollover's record too, so the boundary is
40
+ * the last record.
41
+ */
42
+ export function messagesHeldByProviderSummary(messages: readonly MessageLike[]): number {
43
+ const [first] = messages;
44
+ if (
45
+ first?.role !== "system" ||
46
+ !("providerSummary" in first) ||
47
+ first.providerSummary === undefined
48
+ ) {
49
+ return 0;
50
+ }
51
+ const record = messages.findLastIndex((message) => message.role === "compactionSummary");
52
+ return record < 0 ? messages.length : record;
53
+ }
@@ -172,34 +172,62 @@ function trackedNotes(
172
172
  }
173
173
  return [...tracked.values()].sort((a, b) => b.order - a.order);
174
174
  }
175
- export function renderSessionNotes(
175
+ type NoteSources = {
176
+ readonly retained: ReadonlySet<string>;
177
+ readonly notes?: NotesRecovery | undefined;
178
+ };
179
+ function sessionNotes(
176
180
  chainEntries: readonly EntryLike[],
177
181
  allEntries: readonly EntryLike[],
178
182
  projected: readonly ProjectedEntry[],
179
- options: {
180
- readonly retained: ReadonlySet<string>;
181
- readonly span: RetainedSpan | undefined;
182
- readonly maxChars: number;
183
- readonly notes?: NotesRecovery | undefined;
184
- },
185
- ): string | undefined {
186
- const { retained, span, maxChars } = options;
183
+ sources: NoteSources,
184
+ ): { readonly all: readonly TrackedNote[]; readonly notesDirectory: string | undefined } {
187
185
  const collected = sessionNoteTouches({
188
186
  branch: chainEntries,
189
- allEntries: options.notes?.entries ?? allEntries,
187
+ allEntries: sources.notes?.entries ?? allEntries,
190
188
  projected,
191
- retained,
189
+ retained: sources.retained,
192
190
  });
193
- const touches = scopeRecoveryTouches(collected.touches, options.notes);
191
+ const touches = scopeRecoveryTouches(collected.touches, sources.notes);
194
192
  const { defaultDir } = collected;
195
- const all = trackedNotes(touches, defaultDir);
193
+ return {
194
+ all: trackedNotes(touches, defaultDir),
195
+ notesDirectory:
196
+ sources.notes === undefined ? defaultDir : scopeDirectory(sources.notes.binding),
197
+ };
198
+ }
199
+ /**
200
+ * The file path of the note that the record names first, when this session wrote one. The path is
201
+ * absolute whenever the notes directory is known, so the `read` tool opens it from any directory.
202
+ */
203
+ export function primaryNotePath(
204
+ chainEntries: readonly EntryLike[],
205
+ allEntries: readonly EntryLike[],
206
+ projected: readonly ProjectedEntry[],
207
+ sources: NoteSources,
208
+ ): string | undefined {
209
+ const { all, notesDirectory } = sessionNotes(chainEntries, allEntries, projected, sources);
210
+ const primary = all.find((note) => note.kind === "written");
211
+ return primary === undefined
212
+ ? undefined
213
+ : (pathElsewhere(primary, notesDirectory) ?? primary.absPath ?? primary.notePath);
214
+ }
215
+ export function renderSessionNotes(
216
+ chainEntries: readonly EntryLike[],
217
+ allEntries: readonly EntryLike[],
218
+ projected: readonly ProjectedEntry[],
219
+ options: NoteSources & {
220
+ readonly span: RetainedSpan | undefined;
221
+ readonly maxChars: number;
222
+ },
223
+ ): string | undefined {
224
+ const { span, maxChars } = options;
225
+ const { all, notesDirectory } = sessionNotes(chainEntries, allEntries, projected, options);
196
226
  if (all.length === 0) {
197
227
  return;
198
228
  }
199
229
  const written = all.filter((note) => note.kind === "written").slice(0, MAX_WRITTEN_NOTES);
200
230
  const readOnly = all.filter((note) => note.kind === "read").slice(0, MAX_READ_NOTES);
201
- const notesDirectory =
202
- options.notes === undefined ? defaultDir : scopeDirectory(options.notes.binding);
203
231
  const lines = [
204
232
  ...written.map((note) => formatWrittenNoteLine(note, span, notesDirectory)),
205
233
  ...readOnly.map((note) => formatReadNoteLine(note, span, notesDirectory)),
package/src/recovery.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  type RecoveryRecord,
10
10
  } from "./recovery-records.ts";
11
11
  import type { NotesRecovery } from "./note-evidence.ts";
12
- import { renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
12
+ import { primaryNotePath, renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
13
13
  import {
14
14
  recoverableToolResults,
15
15
  type RecoveryBatch,
@@ -255,6 +255,20 @@ function rolloverPreambleWithNotes(
255
255
  );
256
256
  return joined([basePreamble, notesSection]);
257
257
  }
258
+ /** The note the automatic record tells the agent to read, for text that names it outside the record. */
259
+ export function recoveryNotePath(
260
+ entries: readonly EntryLike[],
261
+ projected: readonly ProjectedEntry[],
262
+ options: {
263
+ readonly retainedEntryIds: ReadonlySet<string>;
264
+ readonly notes?: NotesRecovery | undefined;
265
+ },
266
+ ): string | undefined {
267
+ return primaryNotePath(entries.slice(automaticChainStart(entries)), entries, projected, {
268
+ retained: options.retainedEntryIds,
269
+ notes: options.notes,
270
+ });
271
+ }
258
272
  /** Raw owner anchors and projected tool evidence have distinct provenance and share one bounded payload. */
259
273
  export function buildAutoHandoff(
260
274
  entries: readonly EntryLike[],
package/src/retention.ts CHANGED
@@ -15,7 +15,12 @@ import {
15
15
  type PolicyContext,
16
16
  } from "./contracts.ts";
17
17
  import type { NotesRecovery } from "./note-evidence.ts";
18
- import { buildAutoHandoff } from "./recovery.ts";
18
+ import {
19
+ PROVIDER_SUMMARY_TOKENS,
20
+ providerSummaryInstructions,
21
+ type ProviderSummaryRequest,
22
+ } from "./provider-summary.ts";
23
+ import { buildAutoHandoff, recoveryNotePath } from "./recovery.ts";
19
24
 
20
25
  type RecoveryCapacity = {
21
26
  readonly host: PolicyContext;
@@ -23,6 +28,8 @@ type RecoveryCapacity = {
23
28
  readonly pendingMessages?: readonly MessageLike[];
24
29
  readonly firstKeptEntryId?: string | undefined;
25
30
  readonly notes?: NotesRecovery | undefined;
31
+ /** The host will ask the provider for a summary block, which needs an assistant message first in the tail. */
32
+ readonly providerSummary?: boolean | undefined;
26
33
  };
27
34
  type RetainedTail = {
28
35
  readonly firstKeptEntryId?: string | undefined;
@@ -53,6 +60,19 @@ function safeStart(entry: ProjectionEntry): boolean {
53
60
  visibleMessages(entry).some((message) => CUT_ROLES.has(message.role))
54
61
  );
55
62
  }
63
+ /**
64
+ * A provider summary block stands for the request that produced the tail's first message, so
65
+ * that message must be a reply the provider completed.
66
+ */
67
+ function assistantStart(entry: ProjectionEntry): boolean {
68
+ const [first] = visibleMessages(entry);
69
+ return (
70
+ entry.sourceEntry.type !== "compaction" &&
71
+ first?.role === "assistant" &&
72
+ first.stopReason !== "error" &&
73
+ first.stopReason !== "aborted"
74
+ );
75
+ }
56
76
  function nativeMessage(
57
77
  message: ProjectionEntry["messages"][number],
58
78
  ): SessionMessageEntry["message"] {
@@ -129,6 +149,7 @@ function boundedTail(
129
149
  entries: readonly ProjectionEntry[],
130
150
  start: number,
131
151
  maxTokens: number,
152
+ startsTail: (entry: ProjectionEntry) => boolean,
132
153
  ): RetainedTail {
133
154
  const costs = entries.slice(start).map((entry) => ({
134
155
  entry,
@@ -143,7 +164,7 @@ function boundedTail(
143
164
  let tokens = costs.reduce((sum, { cost }) => sum + cost, 0);
144
165
  for (const [offset, { entry, cost }] of costs.entries()) {
145
166
  const index = start + offset;
146
- if (tokens > 0 && tokens <= maxTokens && safeStart(entry)) {
167
+ if (tokens > 0 && tokens <= maxTokens && startsTail(entry)) {
147
168
  const tail = entries.slice(index);
148
169
  if (pairedTail(tail)) {
149
170
  return {
@@ -178,7 +199,14 @@ function retainedTail(
178
199
  (entry) => entry.sourceEntry.id === capacity.firstKeptEntryId,
179
200
  );
180
201
  const start = prepared >= 0 ? prepared : projectedCut(entries, keep);
181
- return start < 0 ? { ids: new Set() } : boundedTail(entries, start, maxTokens);
202
+ return start < 0
203
+ ? { ids: new Set() }
204
+ : boundedTail(
205
+ entries,
206
+ start,
207
+ maxTokens,
208
+ capacity.providerSummary === true ? assistantStart : safeStart,
209
+ );
182
210
  }
183
211
  /** Recent messages and recovery share half the fresh operational budget, leaving work headroom. */
184
212
  export function automaticRecovery(
@@ -186,7 +214,13 @@ export function automaticRecovery(
186
214
  projected: readonly ProjectionEntry[],
187
215
  capacity: RecoveryCapacity,
188
216
  ownerQuestionRegistered: boolean,
189
- ): { readonly handoff: string; readonly firstKeptEntryId?: string | undefined } | undefined {
217
+ ):
218
+ | {
219
+ readonly handoff: string;
220
+ readonly firstKeptEntryId?: string | undefined;
221
+ readonly providerSummary?: ProviderSummaryRequest | undefined;
222
+ }
223
+ | undefined {
190
224
  const payload = freshPayloadChars(
191
225
  capacity.host,
192
226
  capacity.toolTokens,
@@ -197,13 +231,34 @@ export function automaticRecovery(
197
231
  if (limit < MIN_PAGE_CHARS) {
198
232
  return undefined;
199
233
  }
200
- const retained = retainedTail(projected, capacity, Math.floor((payload - limit) / 4));
234
+ const asksProvider = capacity.providerSummary === true;
235
+ const retained = retainedTail(
236
+ projected,
237
+ capacity,
238
+ Math.floor((payload - limit) / 4) - (asksProvider ? PROVIDER_SUMMARY_TOKENS : 0),
239
+ );
201
240
  const handoff = buildAutoHandoff(entries, projected, limit, {
202
241
  ownerQuestionRegistered,
203
242
  retainedEntryIds: retained.ids,
204
243
  notes: capacity.notes,
205
244
  });
206
- return handoff.length > limit
207
- ? undefined
208
- : { handoff, firstKeptEntryId: retained.firstKeptEntryId };
245
+ if (handoff.length > limit) {
246
+ return undefined;
247
+ }
248
+ return {
249
+ handoff,
250
+ firstKeptEntryId: retained.firstKeptEntryId,
251
+ // Without a tail there is no reasoning to keep, so the host is not asked for a block.
252
+ providerSummary:
253
+ !asksProvider || retained.firstKeptEntryId === undefined
254
+ ? undefined
255
+ : {
256
+ instructions: providerSummaryInstructions(
257
+ recoveryNotePath(entries, projected, {
258
+ retainedEntryIds: retained.ids,
259
+ notes: capacity.notes,
260
+ }),
261
+ ),
262
+ },
263
+ };
209
264
  }
package/src/rollover.ts CHANGED
@@ -19,6 +19,11 @@ import {
19
19
  import { registerContextTools } from "./context-tools.ts";
20
20
  import { registerGuidance } from "./guidance.ts";
21
21
  import { safeJsonStringify } from "./message-text.ts";
22
+ import {
23
+ hostMakesProviderSummary,
24
+ messagesHeldByProviderSummary,
25
+ type ProviderSummaryRequest,
26
+ } from "./provider-summary.ts";
22
27
  import { hasRetainedRolloverMarker } from "./recovery.ts";
23
28
  import { automaticRecovery } from "./retention.ts";
24
29
  import { ResetRequests } from "./reset-requests.ts";
@@ -157,8 +162,11 @@ function registerReminderFilter(
157
162
  ): void {
158
163
  pi.on("context_with_system", (event, ctx) => {
159
164
  const stale = staleReminderPredicate(event.messages, ctx, policy, reminders);
160
- const filtered = event.messages.some(stale)
161
- ? event.messages.filter((message) => !stale(message))
165
+ const held = messagesHeldByProviderSummary(event.messages);
166
+ const removable = (message: (typeof event.messages)[number], index: number) =>
167
+ index >= held && stale(message);
168
+ const filtered = event.messages.some(removable)
169
+ ? event.messages.filter((message, index) => !removable(message, index))
162
170
  : event.messages;
163
171
  const ordered = chronologicalRetainedTail(filtered, ctx, stale);
164
172
  if (ordered !== event.messages) {
@@ -167,6 +175,10 @@ function registerReminderFilter(
167
175
  return;
168
176
  });
169
177
  }
178
+ /** A host without the capability ignores the field; one that has it stores why no block was made. */
179
+ function providerSummaryField(request: ProviderSummaryRequest | undefined) {
180
+ return request === undefined ? {} : { providerSummary: request };
181
+ }
170
182
  function automaticDraft(
171
183
  pi: ExtensionAPI,
172
184
  ctx: ExtensionContext,
@@ -175,6 +187,7 @@ function automaticDraft(
175
187
  readonly tokensBefore: number;
176
188
  readonly reason: string;
177
189
  readonly firstKeptEntryId?: string | undefined;
190
+ readonly providerSummary?: ProviderSummaryRequest | undefined;
178
191
  },
179
192
  ) {
180
193
  let firstKeptEntryId = recovery.firstKeptEntryId;
@@ -192,6 +205,7 @@ function automaticDraft(
192
205
  firstKeptEntryId,
193
206
  tokensBefore: recovery.tokensBefore,
194
207
  details: { posthorse: 1, reason: recovery.reason },
208
+ ...providerSummaryField(recovery.providerSummary),
195
209
  },
196
210
  };
197
211
  }
@@ -280,6 +294,7 @@ function thresholdFallback(
280
294
  toolTokens: toolTokens(),
281
295
  pendingMessages: event.context.pendingMessages,
282
296
  notes: capacity.notes(ctx),
297
+ providerSummary: hostMakesProviderSummary(ctx.model),
283
298
  },
284
299
  ownerQuestionRegistered,
285
300
  );
@@ -298,6 +313,7 @@ function thresholdFallback(
298
313
  summary: recovery.handoff,
299
314
  firstKeptEntryId: recovery.firstKeptEntryId ?? null,
300
315
  details: { posthorse: 1, reason: "threshold" },
316
+ ...providerSummaryField(recovery.providerSummary),
301
317
  },
302
318
  ],
303
319
  continue: event.continue,
@@ -341,6 +357,8 @@ function prepareAutomatic(
341
357
  toolTokens: capacity.toolTokens(),
342
358
  firstKeptEntryId: event.preparation.firstKeptEntryId,
343
359
  notes: capacity.notes(ctx),
360
+ // Pi makes no block at an overflow, so the tail keeps its usual start there.
361
+ providerSummary: event.reason !== "overflow" && hostMakesProviderSummary(ctx.model),
344
362
  },
345
363
  ownerQuestionRegistered,
346
364
  );