pi-posthorse 0.9.2 → 0.10.0

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,12 +1,12 @@
1
1
  # Posthorse
2
2
 
3
- Posthorse starts fresh context windows for long [Pi](https://github.com/earendil-works/pi) tasks and keeps earlier conversation recoverable. Each rollover uses a handoff or recovery record without a model-generated summary.
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.
4
4
 
5
5
  ![A Pi conversation rolls into a fresh context window. Task notes and the complete session transcript remain available.](.github/readme/context-rollover.png)
6
6
 
7
7
  ## Install and start
8
8
 
9
- Use Node 24.12.0 or later and Pi 1.0.0 or later. Posthorse supports official Pi and [fitchmultz/pi](https://github.com/fitchmultz/pi).
9
+ Use Node 24.12.0 or later and Pi 1.3.0 or later. Posthorse supports official Pi and [fitchmultz/pi](https://github.com/fitchmultz/pi).
10
10
 
11
11
  Update an older Pi installation before you install Posthorse. Run these commands:
12
12
 
@@ -23,22 +23,24 @@ Give Pi your task. For example:
23
23
 
24
24
  ## How rollover works
25
25
 
26
- Posthorse can remind the model to save task state before rollover. Large turns can reach rollover before this reminder. The reminder does not wake an agent after its final answer.
26
+ Posthorse can nudge the model to update task notes before rollover, then continue working. The nudge does not require `new_context` or wake an agent after its final answer. Large turns can reach rollover before the nudge.
27
27
 
28
- Automatic rollover creates a recovery record from session inputs and tool results. The model must restore notes and check live state before it continues. 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. 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.
29
29
 
30
- See [rollover and recovery details](docs/reference.md#how-it-works).
30
+ Safe recent-tail retention and the notes-only reminder are part of the [0.10.0 changes](CHANGELOG.md#0100). Match these docs to your installed version using the changelog; default-branch documentation may describe changes not yet published.
31
+
32
+ Recoverable history is not guaranteed model recall. See the [practical guarantees and limits](docs/context-recovery.md) for the stock-Pi comparison, search scope, checkpoints, privacy, concurrency and cost, or the [rollover reference](docs/reference.md#how-it-works) for implementation details.
31
33
 
32
34
  ## Tools
33
35
 
34
36
  These tools are available to the model:
35
37
 
36
- | Tool | Use |
37
- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
38
- | `new_context({ handoff? })` | Request a fresh window after every foreground tool succeeds. Aborted turns and failed tools prevent the reset. |
39
- | `get_context_remaining()` | Estimate tokens left until rollover and the configured context limit. Usage can be unknown before a model response. |
40
- | `notes({ op, ... })` | List, read, search, write, or append task notes. |
41
- | `history({ op, ... })` | Search earlier conversation and read entries, including images. `all: true` includes this project's other sessions and nested subagent runs. |
38
+ | Tool | Use |
39
+ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
40
+ | `new_context({ handoff? })` | Optionally request a clean reset with no retained messages after every foreground tool succeeds. Aborted turns and failed tools prevent the reset. |
41
+ | `get_context_remaining()` | Estimate tokens left until rollover and the configured context limit. Usage can be unknown before a model response. |
42
+ | `notes({ op, ... })` | List, read, search, write, or append task notes. |
43
+ | `history({ op, ... })` | Search earlier conversation and read entries, including images. `all: true` includes this project's other sessions and nested subagent runs. |
42
44
 
43
45
  Expand a tool card with Ctrl+O. In fullscreen mode, click a tool card to expand it. A `new_context` card shows a request. Pi's `Compacted from … tokens` card confirms the rollover.
44
46
 
@@ -48,10 +50,11 @@ See [tool examples and paging details](docs/reference.md#tools).
48
50
 
49
51
  Posthorse uses Pi's effective `compaction` settings. It has no separate settings.
50
52
 
51
- | Pi setting | Effect |
52
- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
53
- | `compaction.enabled` | Set to `false` to disable reminders and automatic rollover. `new_context` remains available. |
54
- | `compaction.reserveTokens` | Automatic Posthorse rollover needs at least 10,000 tokens after this reserve. Smaller budgets use Pi's own compaction. Lower the reserve or choose a larger model. |
53
+ | Pi setting | Effect |
54
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
55
+ | `compaction.enabled` | Set to `false` to disable reminders and automatic rollover. `new_context` remains available. |
56
+ | `compaction.reserveTokens` | Automatic Posthorse rollover needs at least 10,000 tokens after this reserve. Smaller budgets use Pi's own compaction. Lower the reserve or choose a larger model. |
57
+ | `compaction.keepRecentTokens` | Approximate recent tail for automatic rollover (Pi's default: 20,000). Zero keeps no messages. Tail size shrinks when safe groups cannot fit the fresh budget. |
55
58
 
56
59
  Two Posthorse copies can register the same tools and compete for rollover. Keep one copy loaded. Check installed sources with `pi list`.
57
60
 
@@ -73,7 +76,7 @@ Removal leaves notes and Pi session history in place. See the [installation refe
73
76
 
74
77
  ## Data and privacy
75
78
 
76
- Posthorse makes no network requests. Notes are plaintext files under `.pi/notes/`; linked Git worktrees share them. Add `.pi/notes/` to `.gitignore` if the notes must stay private.
79
+ Posthorse makes no network requests. Notes are plaintext files under `.pi/notes/`; linked Git worktrees share them. Notes for Pi-managed Git packages live outside their checkouts because `pi update` deletes untracked files there: global packages (including their descendants) use Pi's agent-directory `notes/`, and other project-local packages use the containing project's `.pi/package-notes/`, with project-local stores keyed by a full SHA-256 digest of the host and package path. Access existing checkout-local notes with this version before updating the package so they can migrate. Add `.pi/notes/` and `.pi/package-notes/` to the containing project's `.gitignore` if the notes must stay private.
77
80
 
78
81
  History can include user messages, assistant thinking, tool arguments, errors, and images. Content you retrieve enters the context of your selected model and provider.
79
82
 
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Posthorse — fresh context, same journey.
3
3
  *
4
- * Pi owns persisted retain-none context boundaries. Posthorse owns reminder and
4
+ * Pi owns persisted context boundaries. Posthorse owns recent-tail retention, reminder and
5
5
  * rollover policy, bounded recovery, durable notes, and history retrieval.
6
6
  */
7
7
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
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": [
@@ -48,8 +48,8 @@
48
48
  "prepublishOnly": "npm run verify"
49
49
  },
50
50
  "peerDependencies": {
51
- "@earendil-works/pi-coding-agent": "*",
52
- "@earendil-works/pi-tui": "*",
51
+ "@earendil-works/pi-coding-agent": ">=1.3.0",
52
+ "@earendil-works/pi-tui": ">=1.3.0",
53
53
  "typebox": "*"
54
54
  },
55
55
  "peerDependenciesMeta": {
@@ -64,9 +64,9 @@
64
64
  }
65
65
  },
66
66
  "devDependencies": {
67
- "@earendil-works/pi-ai": "1.0.0",
68
- "@earendil-works/pi-coding-agent": "1.0.0",
69
- "@earendil-works/pi-tui": "1.0.0",
67
+ "@earendil-works/pi-ai": "1.3.0",
68
+ "@earendil-works/pi-coding-agent": "1.3.0",
69
+ "@earendil-works/pi-tui": "1.3.0",
70
70
  "@types/node": "24.19.1",
71
71
  "oxc-parser": "0.153.0",
72
72
  "oxfmt": "0.72.0",
package/src/budget.ts CHANGED
@@ -14,7 +14,7 @@ import { imagesOf, textOf } from "./message-text.ts";
14
14
 
15
15
  const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
16
16
  export const CHECKPOINT_STEPS =
17
- "update the task's current-state note (goal, progress, decisions, next steps) with available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. If the note is already current, leave it unchanged. Then call new_context";
17
+ "update the task's current-state note (goal, progress, decisions, next steps) with available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. If the note is already current, leave it unchanged. Then continue working; let automatic rollover happen without calling new_context";
18
18
  export type Budget = {
19
19
  readonly contextWindow: number;
20
20
  readonly reserveTokens: number;
@@ -84,7 +84,7 @@ function automaticGuidance(budget: Budget | undefined, enabled: boolean): string
84
84
  budget === undefined
85
85
  ? ""
86
86
  : ` The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used under the available Pi settings.`;
87
- return `Automatic rollover follows Pi's compaction setting.${line} At most one checkpoint reminder may arrive at the end of a working turn before the rollover line (${deadline}), never on a turn that ends the run; when it does, stop normal work, ${CHECKPOINT_STEPS}.`;
87
+ return `Automatic rollover follows Pi's compaction setting and retains a safe recent-message tail using compaction.keepRecentTokens, without model summarization.${line} At most one notes reminder may arrive at the end of a working turn before the rollover line (${deadline}), never on a turn that ends the run; when it does, ${CHECKPOINT_STEPS}. new_context is an optional deliberate clean reset, not a checkpoint requirement.`;
88
88
  }
89
89
  export function buildGuidance(ctx: PolicyContext): string {
90
90
  const budget = budgetFor(ctx);
@@ -92,7 +92,7 @@ export function buildGuidance(ctx: PolicyContext): string {
92
92
  const capacity =
93
93
  budget === undefined
94
94
  ? "Configured context capacity is unknown."
95
- : `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens. This is Pi's best available native limit, not remaining space or a measured provider boundary. Fresh windows use the active configuration; system instructions, tools, the handoff, and new messages consume part of it.`;
95
+ : `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens. This is Pi's best available native limit, not remaining space or a measured provider boundary. Fresh windows use the active configuration; system instructions, tools, the handoff, retained recent messages, and new messages consume part of it.`;
96
96
  return `## Context self-management (Posthorse)
97
97
  ${capacity}
98
98
  Do not guess context capacity or remaining space from response-token limits or reasoning budgets. Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining. If usage is unknown, report it as unknown. No routine budget checks are needed.
@@ -60,8 +60,8 @@ export function registerContextTools(
60
60
  label: "New Context",
61
61
  ...toolCards("new_context"),
62
62
  description:
63
- "Start a fresh context window once the current foreground tools succeed; background work continues across the reset. Earlier conversation stays recoverable through history. Put continuation state in handoff, or save fuller state in notes first.",
64
- promptSnippet: "request a fresh context window with an optional handoff",
63
+ "Optionally request a deliberate clean context reset once the current foreground tools succeed; retains no prior messages, unlike automatic rollover. Not required after a notes reminder. Background work continues. Earlier conversation stays recoverable through history. Put continuation state in handoff, or save fuller state in notes first.",
64
+ promptSnippet: "optionally request a clean context reset with an optional handoff",
65
65
  parameters: Type.Object({
66
66
  handoff: Type.Optional(
67
67
  Type.String({
@@ -94,7 +94,7 @@ export function registerContextTools(
94
94
  label: "Context Remaining",
95
95
  ...toolCards("get_context_remaining"),
96
96
  description:
97
- "Estimate tokens left before Pi's automatic rollover and the configured context limit. Check before reporting remaining space or changing your work plan because of context limits. A checkpoint reminder may arrive before rollover on a working turn; no routine checks are needed.",
97
+ "Estimate tokens left before Pi's automatic rollover and the configured context limit. Check before reporting remaining space or changing your work plan because of context limits. A notes-only reminder may arrive before rollover on a working turn; continue afterward and let rollover happen automatically. No routine checks are needed.",
98
98
  promptSnippet:
99
99
  "verify remaining context before reporting a token count or changing plans because of context limits",
100
100
  parameters: Type.Object({}),
package/src/contracts.ts CHANGED
@@ -4,6 +4,7 @@ import type { PosthorseDisplay } from "../ui.ts";
4
4
  export type CompactionPolicy = {
5
5
  readonly enabled: boolean;
6
6
  readonly reserveTokens: number;
7
+ readonly keepRecentTokens?: number;
7
8
  };
8
9
  export type ContextUsage = {
9
10
  readonly tokens: number | null;
package/src/files.ts CHANGED
@@ -1,5 +1,15 @@
1
- import { randomUUID } from "node:crypto";
2
- import { createReadStream, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ createReadStream,
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ renameSync,
10
+ statSync,
11
+ symlinkSync,
12
+ } from "node:fs";
3
13
  import {
4
14
  access,
5
15
  constants,
@@ -12,7 +22,8 @@ import {
12
22
  stat,
13
23
  unlink,
14
24
  } from "node:fs/promises";
15
- import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
25
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
26
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
27
  import { isRecord } from "./contracts.ts";
17
28
 
18
29
  async function lstatOrMissing(path: string) {
@@ -171,7 +182,7 @@ function gitRoot(dir: string, marker: string): string {
171
182
  return basename(common) === ".git" ? dirname(common) : common;
172
183
  }
173
184
  /** Conventional repos share the primary checkout; separate Git directories own their shared root. */
174
- export function notesRoot(cwd: string): string {
185
+ function notesRoot(cwd: string): string {
175
186
  for (let dir = cwd; ; dir = dirname(dir)) {
176
187
  const marker = join(dir, ".git");
177
188
  if (existsSync(marker)) {
@@ -186,3 +197,88 @@ export function notesRoot(cwd: string): string {
186
197
  }
187
198
  }
188
199
  }
200
+ function packageRelativePath(installs: string, root: string): string | undefined {
201
+ const path = relative(installs, root);
202
+ return path !== "" && path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path)
203
+ ? path
204
+ : undefined;
205
+ }
206
+ function projectPackageNotes(root: string, cwd: string): string | undefined {
207
+ let moved: string | undefined;
208
+ let checkout = root;
209
+ for (let dir = cwd; dirname(dir) !== dir; dir = dirname(dir)) {
210
+ // Retain the logical checkout path when a package container or source segment is linked.
211
+ if (existsSync(join(dir, ".git")) && realpathSync(dir) === root) {
212
+ checkout = dir;
213
+ }
214
+ if (basename(dir) !== "git" || basename(dirname(dir)) !== ".pi") {
215
+ continue;
216
+ }
217
+ const path = packageRelativePath(dir, checkout);
218
+ if (path !== undefined) {
219
+ // Flat full keys keep arbitrary Git URL prefix paths out of one another's note stores.
220
+ const key = createHash("sha256").update(path.split(sep).join("/")).digest("hex");
221
+ moved = join(realpathSync(dirname(dir)), "package-notes", key, "notes");
222
+ }
223
+ }
224
+ // The outermost package container also protects packages installed inside another package.
225
+ return moved;
226
+ }
227
+ /**
228
+ * Pi's package manager installs Git sources in <agent>/git or <project>/.pi/git and runs
229
+ * `git clean -fdx` inside their checkouts on update. Keep notes alongside, not inside, that tree.
230
+ * ponytail: notes a package commits under `.pi/notes` return with each update and move into
231
+ * another `from-checkout-*` folder; skip tracked files if a package ever commits its notes.
232
+ */
233
+ function packageNotes(root: string, cwd: string): string | undefined {
234
+ // Pi cleans only the checkouts; folders that hold several of them keep their own notes.
235
+ if (!existsSync(join(root, ".git"))) {
236
+ return;
237
+ }
238
+ const agentDir = getAgentDir();
239
+ const installs = join(agentDir, "git");
240
+ const path = packageRelativePath(
241
+ existsSync(installs) ? realpathSync(installs) : installs,
242
+ root,
243
+ );
244
+ return path === undefined
245
+ ? (projectPackageNotes(root, cwd) ?? projectPackageNotes(root, root))
246
+ : join(agentDir, "notes", path);
247
+ }
248
+ /** Preserve every existing entry, including empty directories and dangling links. */
249
+ function migrationTarget(moved: string): string {
250
+ if (lstatSync(moved, { throwIfNoEntry: false }) === undefined) {
251
+ return moved;
252
+ }
253
+ const base = join(moved, `from-checkout-${Date.now()}`);
254
+ let target = base;
255
+ for (let suffix = 1; lstatSync(target, { throwIfNoEntry: false }) !== undefined; suffix++) {
256
+ target = `${base}-${suffix}`;
257
+ }
258
+ return target;
259
+ }
260
+ /** The notes directory for `cwd`, moving any notes found inside a Pi package checkout. */
261
+ export function notesDir(cwd: string): string {
262
+ const root = notesRoot(cwd);
263
+ const local = join(root, ".pi", "notes");
264
+ const moved = packageNotes(root, cwd);
265
+ if (moved === undefined) {
266
+ return local;
267
+ }
268
+ if (lstatSync(local, { throwIfNoEntry: false })?.isDirectory() === true) {
269
+ mkdirSync(dirname(moved), { recursive: true });
270
+ // Notes an older Posthorse wrote here after an earlier move keep a folder of their own.
271
+ const target = migrationTarget(moved);
272
+ try {
273
+ renameSync(local, target);
274
+ // Sessions still running an older Posthorse keep reaching the notes until they reload.
275
+ symlinkSync(moved, local, "junction");
276
+ } catch (error) {
277
+ if (lstatSync(local, { throwIfNoEntry: false })?.isDirectory() === true) {
278
+ throw error;
279
+ }
280
+ // Another session moved them first, or only the compatibility link failed.
281
+ }
282
+ }
283
+ return moved;
284
+ }
package/src/notes.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  type PolicyContext,
24
24
  type TextResult,
25
25
  } from "./contracts.ts";
26
- import { isBinaryFile, notesRoot, publishFile, readText } from "./files.ts";
26
+ import { isBinaryFile, notesDir, publishFile, readText } from "./files.ts";
27
27
  import { excerpt, excerptAround } from "./message-text.ts";
28
28
  import type { PageAccess } from "./paging.ts";
29
29
 
@@ -45,7 +45,7 @@ const SEARCH_OFFSET_LIMIT = 1_000_000;
45
45
  function safeJoin(dir: string, path: string): string {
46
46
  const relative = normalize(path.replace(/^[/\\]+/, ""));
47
47
  if (relative === ".." || relative.startsWith(`..${sep}`) || isAbsolute(relative)) {
48
- throw new Error(`Invalid path "${path}": must stay inside .pi/notes/.`);
48
+ throw new Error(`Invalid path "${path}": must stay inside the notes directory.`);
49
49
  }
50
50
  return join(dir, relative);
51
51
  }
@@ -278,7 +278,7 @@ export function registerNotes(
278
278
  { description: "Operation to perform" },
279
279
  ),
280
280
  path: Type.Optional(
281
- Type.String({ description: "Note or folder path relative to .pi/notes/" }),
281
+ Type.String({ description: "Note or folder path relative to the notes directory" }),
282
282
  ),
283
283
  content: Type.Optional(
284
284
  Type.String({ description: "Full file content (write) or text to add (append)" }),
@@ -296,7 +296,7 @@ export function registerNotes(
296
296
  // Pi's fixed tool contract supplies id, params, signal, updates, and context.
297
297
  // oxlint-disable-next-line max-params
298
298
  async execute(_id, params, signal, _onUpdate, ctx) {
299
- const dir = join(notesRoot(ctx.cwd), ".pi", "notes");
299
+ const dir = notesDir(ctx.cwd);
300
300
  switch (params.op) {
301
301
  case "list":
302
302
  return listNotes(dir, params, policy(ctx), pages);
@@ -9,6 +9,7 @@ import {
9
9
  toolIdentity,
10
10
  } from "./message-text.ts";
11
11
 
12
+ export type RecoveryEntry = EntryLike & { readonly sourceEntryId: string | undefined };
12
13
  type LinkedCall = { readonly entry: EntryLike; readonly block: ToolCallLike };
13
14
  export type RecoveryBlock = { readonly header: string; readonly text: string };
14
15
  export type RecoveryBatch = { readonly callId?: string; readonly blocks: readonly RecoveryBlock[] };
@@ -82,12 +83,19 @@ function resultBlock(
82
83
  };
83
84
  }
84
85
  /** Async receipts may arrive after a response; completion does not establish receipt delivery. */
85
- export function recoverableToolResults(entries: readonly EntryLike[]): RecoveryBatch | undefined {
86
+ export function recoverableToolResults(
87
+ entries: readonly RecoveryEntry[],
88
+ retained: ReadonlySet<string>,
89
+ ): RecoveryBatch | undefined {
86
90
  const calls = callsById(entries);
87
91
  const start = trailingStart(entries);
88
92
  const results = entries.filter((entry, index) => {
89
93
  const message = entry.message;
90
- if (message?.role !== "toolResult" || message.toolCallId === undefined) {
94
+ if (
95
+ message?.role !== "toolResult" ||
96
+ message.toolCallId === undefined ||
97
+ retained.has(entry.sourceEntryId ?? "")
98
+ ) {
91
99
  return false;
92
100
  }
93
101
  const call = calls.get(message.toolCallId);
package/src/recovery.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  recoverableToolResults,
19
19
  type RecoveryBatch,
20
20
  type RecoveryBlock,
21
+ type RecoveryEntry,
21
22
  } from "./recovery-results.ts";
22
23
 
23
24
  const MAX_RECOVERY_RECORD_CHARS = 4_000;
@@ -121,6 +122,7 @@ function projectedCurrent(
121
122
  entries: readonly EntryLike[],
122
123
  projected: readonly ProjectedEntry[],
123
124
  current: readonly EntryLike[] = entries.slice(contextStart(entries).start),
125
+ retained: ReadonlySet<string> = new Set(),
124
126
  ): EntryLike[] {
125
127
  const edits = contextEdits(entries);
126
128
  const omittedIds = new Set(
@@ -130,7 +132,7 @@ function projectedCurrent(
130
132
  );
131
133
  return current.flatMap((entry) => {
132
134
  const id = entry.id ?? "";
133
- if (id !== "" && omittedIds.has(id)) {
135
+ if (retained.has(id) || omittedIds.has(id)) {
134
136
  return [];
135
137
  }
136
138
  const edit = edits.get(id);
@@ -211,6 +213,7 @@ function recoveryAncestry(
211
213
  entries: readonly EntryLike[],
212
214
  projected: readonly ProjectedEntry[],
213
215
  ownerQuestionRegistered: boolean,
216
+ retained: ReadonlySet<string>,
214
217
  ): {
215
218
  readonly checkpoint?: EntryLike;
216
219
  readonly carried: readonly RecoveryRecord[];
@@ -234,7 +237,7 @@ function recoveryAncestry(
234
237
  if (owners.length > 0) {
235
238
  return {
236
239
  checkpoint: automaticBoundary(window.boundary) ? undefined : window.boundary,
237
- carried: anchors(owners).fixed,
240
+ carried: anchors(owners.filter((record) => !retained.has(record.id))).fixed,
238
241
  skipped,
239
242
  };
240
243
  }
@@ -262,13 +265,15 @@ function skippedRecords(ids: readonly string[], limit: number): string | undefin
262
265
  function projectedToolEntries(
263
266
  entries: readonly EntryLike[],
264
267
  projected: readonly ProjectedEntry[],
265
- ): EntryLike[] {
268
+ ): RecoveryEntry[] {
266
269
  const edits = contextEdits(entries);
267
270
  return projected.flatMap(({ sourceEntry, messages }) =>
268
271
  messages
269
272
  .filter((message) => message.role === "assistant" || message.role === "toolResult")
270
273
  .map((message) => ({
271
274
  type: "message",
275
+ // Retention follows the source; history reads follow the effective replacement.
276
+ sourceEntryId: sourceEntry.id,
272
277
  id: edits.get(sourceEntry.id ?? "")?.id ?? sourceEntry.id,
273
278
  message,
274
279
  })),
@@ -378,37 +383,69 @@ function renderIndex(records: readonly RecoveryRecord[], available: number): str
378
383
  ...listed,
379
384
  ].join("\n");
380
385
  }
381
- /** Raw owner anchors and projected tool evidence have distinct provenance and share one bounded payload. */
382
- export function buildAutoHandoff(
386
+ function selectedInputs(
383
387
  entries: readonly EntryLike[],
384
388
  projected: readonly ProjectedEntry[],
385
- maxChars: number,
386
- ownerQuestionRegistered: boolean,
387
- ): string {
388
- const history = recoveryAncestry(entries, projected, ownerQuestionRegistered);
389
- const priorWindow = history.checkpoint;
389
+ options: {
390
+ readonly ownerQuestionRegistered: boolean;
391
+ readonly retained: ReadonlySet<string>;
392
+ readonly carried: readonly RecoveryRecord[];
393
+ },
394
+ ): { readonly fixed: readonly RecoveryRecord[]; readonly others: readonly RecoveryRecord[] } {
395
+ const { ownerQuestionRegistered, retained, carried } = options;
396
+ const current = projectedCurrent(entries, projected);
397
+ const allRecords = ownerRecords(current, projected, ownerQuestionRegistered);
390
398
  const records = ownerRecords(
391
- projectedCurrent(entries, projected),
399
+ projectedCurrent(entries, projected, undefined, retained),
392
400
  projected,
393
401
  ownerQuestionRegistered,
394
402
  );
395
403
  const { fixed, others } = anchors(records);
396
- if (!records.some((record) => record.kind === "owner")) {
404
+ if (!allRecords.some((record) => record.kind === "owner")) {
397
405
  fixed.unshift(
398
- ...history.carried.map((record) => ({
399
- ...record,
400
- label: `${record.label}; carried from an earlier window, possibly already handled; not current progress`,
401
- })),
406
+ ...carried.map((record) =>
407
+ Object.assign({}, record, {
408
+ label: `${record.label}; carried from an earlier window, possibly already handled; not current progress`,
409
+ }),
410
+ ),
402
411
  );
403
412
  }
413
+ return { fixed, others };
414
+ }
415
+ /** Raw owner anchors and projected tool evidence have distinct provenance and share one bounded payload. */
416
+ export function buildAutoHandoff(
417
+ entries: readonly EntryLike[],
418
+ projected: readonly ProjectedEntry[],
419
+ maxChars: number,
420
+ options: {
421
+ readonly ownerQuestionRegistered: boolean;
422
+ readonly retainedEntryIds?: ReadonlySet<string>;
423
+ },
424
+ ): string {
425
+ const { ownerQuestionRegistered, retainedEntryIds: retained = new Set<string>() } = options;
426
+ const preamble =
427
+ retained.size === 0
428
+ ? PREAMBLE
429
+ : `${PREAMBLE}\nRecent messages follow separately; their inputs and tool evidence are not repeated in this record.`;
430
+ const history = recoveryAncestry(entries, projected, ownerQuestionRegistered, retained);
431
+ const priorWindow = history.checkpoint;
432
+ const { fixed, others } = selectedInputs(entries, projected, {
433
+ ownerQuestionRegistered,
434
+ retained,
435
+ carried: history.carried,
436
+ });
437
+ const emptyInputHeader =
438
+ retained.size > 0
439
+ ? "No discarded owner or visible coordination inputs were selected for this recovery record."
440
+ : "No selected current-window owner or visible coordination inputs were found.";
404
441
  const inputHeader =
405
442
  fixed.length > 0
406
443
  ? "Selected owner and coordination inputs (chronological):"
407
- : "No selected current-window owner or visible coordination inputs were found.";
408
- const batch = recoverableToolResults(projectedToolEntries(entries, projected));
444
+ : emptyInputHeader;
445
+ const batch = recoverableToolResults(projectedToolEntries(entries, projected), retained);
409
446
  const header = batchHeader(batch);
410
447
  const minimum = [
411
- PREAMBLE,
448
+ preamble,
412
449
  inputHeader,
413
450
  ...fixed.map((record) => formatRecord(record, 0)),
414
451
  header,
@@ -427,7 +464,7 @@ export function buildAutoHandoff(
427
464
  (history.skipped.length > 0 ? 1 : 0);
428
465
  const fixedBudget = Math.max(
429
466
  0,
430
- maxChars - joined([PREAMBLE, inputHeader, plan.bare]).length - HANDOFF_OVERHEAD_RESERVE,
467
+ maxChars - joined([preamble, inputHeader, plan.bare]).length - HANDOFF_OVERHEAD_RESERVE,
431
468
  );
432
469
  const fixedLimit =
433
470
  count > 0
@@ -438,7 +475,7 @@ export function buildAutoHandoff(
438
475
  skippedRecords(history.skipped, fixedLimit),
439
476
  ]);
440
477
  const fixedParts = [
441
- PREAMBLE,
478
+ preamble,
442
479
  inputHeader,
443
480
  ...fixed.map((record) => formatRecord(record, fixedLimit)),
444
481
  ];
@@ -0,0 +1,203 @@
1
+ import {
2
+ DEFAULT_COMPACTION_SETTINGS,
3
+ estimateTokens,
4
+ findCutPoint,
5
+ type ProjectedSessionEntry,
6
+ type SessionEntry,
7
+ type SessionMessageEntry,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { freshPayloadChars } from "./budget.ts";
10
+ import {
11
+ MAX_HANDOFF_CHARS,
12
+ MIN_PAGE_CHARS,
13
+ type EntryLike,
14
+ type MessageLike,
15
+ type PolicyContext,
16
+ } from "./contracts.ts";
17
+ import { buildAutoHandoff } from "./recovery.ts";
18
+
19
+ type RecoveryCapacity = {
20
+ readonly host: PolicyContext;
21
+ readonly toolTokens: number;
22
+ readonly pendingMessages?: readonly MessageLike[];
23
+ readonly firstKeptEntryId?: string;
24
+ };
25
+ type RetainedTail = {
26
+ readonly firstKeptEntryId?: string;
27
+ readonly ids: ReadonlySet<string>;
28
+ };
29
+ type Immutable<T> = T extends string | number | boolean | null | undefined
30
+ ? T
31
+ : T extends readonly (infer Item)[]
32
+ ? readonly Immutable<Item>[]
33
+ : T extends object
34
+ ? { readonly [Key in keyof T]: Immutable<T[Key]> }
35
+ : T;
36
+ type ProjectionEntry = Immutable<ProjectedSessionEntry>;
37
+ const CUT_ROLES = new Set([
38
+ "user",
39
+ "assistant",
40
+ "bashExecution",
41
+ "custom",
42
+ "branchSummary",
43
+ "compactionSummary",
44
+ ]);
45
+ function visibleMessages(entry: ProjectionEntry) {
46
+ return entry.messages.filter((message) => message.role !== "system");
47
+ }
48
+ function safeStart(entry: ProjectionEntry): boolean {
49
+ return (
50
+ entry.sourceEntry.type !== "compaction" &&
51
+ visibleMessages(entry).some((message) => CUT_ROLES.has(message.role))
52
+ );
53
+ }
54
+ function nativeMessage(
55
+ message: ProjectionEntry["messages"][number],
56
+ ): SessionMessageEntry["message"] {
57
+ switch (message.role) {
58
+ case "assistant":
59
+ return {
60
+ role: "assistant",
61
+ api: message.api,
62
+ provider: message.provider,
63
+ model: message.model,
64
+ stopReason: message.stopReason,
65
+ usage: message.usage,
66
+ timestamp: message.timestamp,
67
+ content: [...message.content],
68
+ };
69
+ case "toolResult":
70
+ return {
71
+ role: "toolResult",
72
+ toolCallId: message.toolCallId,
73
+ toolName: message.toolName,
74
+ isError: message.isError,
75
+ timestamp: message.timestamp,
76
+ content: [...message.content],
77
+ };
78
+ case "user":
79
+ case "custom":
80
+ return {
81
+ ...message,
82
+ content:
83
+ typeof message.content === "string" ? message.content : [...message.content],
84
+ };
85
+ case "system":
86
+ // Compaction checkpoints replace earlier system declarations.
87
+ return { role: "user", content: "", timestamp: message.timestamp };
88
+ case "bashExecution":
89
+ case "branchSummary":
90
+ case "compactionSummary":
91
+ return { ...message };
92
+ }
93
+ }
94
+ /** The exported native cut finder reads raw entries; materialize only projected contributions. */
95
+ function projectedCut(entries: readonly ProjectionEntry[], keep: number): number {
96
+ const materialized = entries.flatMap<SessionEntry>(({ sourceEntry, messages }) =>
97
+ messages.length === 0 || sourceEntry.type === "compaction"
98
+ ? [{ ...sourceEntry, type: "custom", customType: "posthorse-projection", data: {} }]
99
+ : messages.map((message) => ({
100
+ id: sourceEntry.id,
101
+ parentId: sourceEntry.parentId,
102
+ timestamp: sourceEntry.timestamp,
103
+ type: "message",
104
+ message: nativeMessage(message),
105
+ })),
106
+ );
107
+ const cut = findCutPoint(materialized, 0, materialized.length, keep);
108
+ const id = materialized[cut.firstKeptEntryIndex]?.id;
109
+ return entries.findIndex((entry) => entry.sourceEntry.id === id);
110
+ }
111
+ function pairedTail(entries: readonly ProjectionEntry[]): boolean {
112
+ const calls = new Set<string>();
113
+ for (const message of entries.flatMap(visibleMessages)) {
114
+ if (message.role === "assistant") {
115
+ for (const part of message.content.filter((block) => block.type === "toolCall")) {
116
+ calls.add(part.id);
117
+ }
118
+ } else if (message.role === "toolResult") {
119
+ if (!calls.delete(message.toolCallId)) {
120
+ return false;
121
+ }
122
+ }
123
+ }
124
+ return calls.size === 0;
125
+ }
126
+ function boundedTail(
127
+ entries: readonly ProjectionEntry[],
128
+ start: number,
129
+ maxTokens: number,
130
+ ): 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])) {
142
+ const tail = entries.slice(index);
143
+ if (pairedTail(tail)) {
144
+ return {
145
+ firstKeptEntryId: entries[index].sourceEntry.id,
146
+ ids: new Set(tail.map((entry) => entry.sourceEntry.id)),
147
+ };
148
+ }
149
+ }
150
+ tokens -= costs[index];
151
+ }
152
+ return { ids: new Set() };
153
+ }
154
+ function retainedTail(
155
+ projected: readonly ProjectionEntry[],
156
+ capacity: RecoveryCapacity,
157
+ maxTokens: number,
158
+ ): RetainedTail {
159
+ const keep =
160
+ capacity.host.getCompactionSettings().keepRecentTokens ??
161
+ DEFAULT_COMPACTION_SETTINGS.keepRecentTokens;
162
+ if (!Number.isFinite(keep) || keep <= 0 || maxTokens <= 0) {
163
+ return { ids: new Set() };
164
+ }
165
+ const boundary = projected.findIndex(
166
+ (entry) => entry.sourceEntry.type === "compaction" && entry.messages.length > 0,
167
+ );
168
+ const entries = projected.slice(boundary + 1);
169
+ if (entries.length === 0) {
170
+ return { ids: new Set() };
171
+ }
172
+ const prepared = entries.findIndex(
173
+ (entry) => entry.sourceEntry.id === capacity.firstKeptEntryId,
174
+ );
175
+ const start = prepared >= 0 ? prepared : projectedCut(entries, keep);
176
+ return start < 0 ? { ids: new Set() } : boundedTail(entries, start, maxTokens);
177
+ }
178
+ /** Recent messages and recovery share half the fresh operational budget, leaving work headroom. */
179
+ export function automaticRecovery(
180
+ entries: readonly EntryLike[],
181
+ projected: readonly ProjectionEntry[],
182
+ capacity: RecoveryCapacity,
183
+ ownerQuestionRegistered: boolean,
184
+ ): { readonly handoff: string; readonly firstKeptEntryId?: string } | undefined {
185
+ const payload = freshPayloadChars(
186
+ capacity.host,
187
+ capacity.toolTokens,
188
+ capacity.pendingMessages,
189
+ Number.MAX_SAFE_INTEGER,
190
+ );
191
+ const limit = Math.min(MAX_HANDOFF_CHARS, payload);
192
+ if (limit < MIN_PAGE_CHARS) {
193
+ return undefined;
194
+ }
195
+ const retained = retainedTail(projected, capacity, Math.floor((payload - limit) / 4));
196
+ const handoff = buildAutoHandoff(entries, projected, limit, {
197
+ ownerQuestionRegistered,
198
+ retainedEntryIds: retained.ids,
199
+ });
200
+ return handoff.length > limit
201
+ ? undefined
202
+ : { handoff, firstKeptEntryId: retained.firstKeptEntryId };
203
+ }
package/src/rollover.ts CHANGED
@@ -14,7 +14,6 @@ import {
14
14
  import {
15
15
  isRecord,
16
16
  isReminderType,
17
- MIN_PAGE_CHARS,
18
17
  REMINDER_TYPE,
19
18
  type CompactionPolicy,
20
19
  type PolicyContext,
@@ -23,7 +22,7 @@ import {
23
22
  } from "./contracts.ts";
24
23
  import { registerContextTools } from "./context-tools.ts";
25
24
  import { safeJsonStringify } from "./message-text.ts";
26
- import { buildAutoHandoff } from "./recovery.ts";
25
+ import { automaticRecovery } from "./retention.ts";
27
26
  import {
28
27
  createReminderLookup,
29
28
  hasReminder,
@@ -42,7 +41,7 @@ function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
42
41
  promptOptions = event.systemPromptOptions;
43
42
  });
44
43
  pi.on("agent_settled", () => {
45
- // Older hosts skip the start hook for custom runs; do not reuse a settled run's options.
44
+ // Prompt options belong to one run, never to later idle context evaluation.
46
45
  promptOptions = undefined;
47
46
  });
48
47
  pi.on("context_with_system", (event, ctx) => {
@@ -175,7 +174,7 @@ function sendReminder(
175
174
  pi.sendMessage(
176
175
  {
177
176
  customType: REMINDER_TYPE,
178
- content: `[posthorse] Checkpoint now: ${(budget.rolloverAt - tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Stop normal work, ${CHECKPOINT_STEPS}.`,
177
+ content: `[posthorse] Notes checkpoint: ${(budget.rolloverAt - tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Please ${CHECKPOINT_STEPS}.`,
179
178
  display: true,
180
179
  details: fingerprint,
181
180
  },
@@ -232,12 +231,20 @@ function registerReminderFilter(
232
231
  function automaticDraft(
233
232
  pi: ExtensionAPI,
234
233
  ctx: ExtensionContext,
235
- recovery: { readonly handoff: string; readonly tokensBefore: number; readonly reason: string },
234
+ recovery: {
235
+ readonly handoff: string;
236
+ readonly tokensBefore: number;
237
+ readonly reason: string;
238
+ readonly firstKeptEntryId?: string;
239
+ },
236
240
  ) {
237
- // Public compaction requires an entry id. An invisible sentinel keeps no prior conversation.
238
- pi.appendEntry("posthorse-boundary", {});
239
- const firstKeptEntryId = ctx.sessionManager.getLeafId();
240
- if (firstKeptEntryId === null || firstKeptEntryId === "") {
241
+ let firstKeptEntryId = recovery.firstKeptEntryId;
242
+ if (firstKeptEntryId === undefined) {
243
+ // Public compaction requires an entry id even when no prior conversation safely fits.
244
+ pi.appendEntry("posthorse-boundary", {});
245
+ firstKeptEntryId = ctx.sessionManager.getLeafId() ?? undefined;
246
+ }
247
+ if (firstKeptEntryId === undefined || firstKeptEntryId === "") {
241
248
  throw new Error("Pi did not persist the context boundary.");
242
249
  }
243
250
  return {
@@ -316,17 +323,16 @@ function thresholdFallback(
316
323
  return undefined;
317
324
  }
318
325
  const projected = event.context.contextEntries;
319
- const limit = freshPayloadChars(host, toolTokens(), event.context.pendingMessages);
320
326
  const ownerQuestionRegistered = pi
321
327
  .getAllTools()
322
328
  .some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
323
- const summary = buildAutoHandoff(
329
+ const recovery = automaticRecovery(
324
330
  ctx.sessionManager.getBranch(),
325
331
  projected,
326
- limit,
332
+ { host, toolTokens: toolTokens(), pendingMessages: event.context.pendingMessages },
327
333
  ownerQuestionRegistered,
328
334
  );
329
- if (limit < MIN_PAGE_CHARS || summary.length > limit) {
335
+ if (recovery === undefined) {
330
336
  return undefined;
331
337
  }
332
338
  // ponytail: turn_end drafts are eager. Later projection editors must replace/remove this draft;
@@ -336,8 +342,8 @@ function thresholdFallback(
336
342
  ...event.entries,
337
343
  {
338
344
  type: "compaction",
339
- summary,
340
- firstKeptEntryId: null,
345
+ summary: recovery.handoff,
346
+ firstKeptEntryId: recovery.firstKeptEntryId ?? null,
341
347
  details: { posthorse: 1, reason: "threshold" },
342
348
  },
343
349
  ],
@@ -362,17 +368,20 @@ function registerAutomaticRollover(
362
368
  if (budgetFor(host)?.supported !== true) {
363
369
  return;
364
370
  }
365
- const limit = freshPayloadChars(host, activeToolTokens());
366
371
  const ownerQuestionRegistered = pi
367
372
  .getAllTools()
368
373
  .some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
369
- const handoff = buildAutoHandoff(
374
+ const recovery = automaticRecovery(
370
375
  event.branchEntries,
371
376
  ctx.sessionManager.buildSessionProjection().entries,
372
- limit,
377
+ {
378
+ host,
379
+ toolTokens: activeToolTokens(),
380
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
381
+ },
373
382
  ownerQuestionRegistered,
374
383
  );
375
- if (limit < MIN_PAGE_CHARS || handoff.length > limit) {
384
+ if (recovery === undefined) {
376
385
  throw new Error(
377
386
  "Too little fresh context capacity for an automatic recovery record.",
378
387
  );
@@ -381,7 +390,7 @@ function registerAutomaticRollover(
381
390
  return { cancel: true };
382
391
  }
383
392
  return automaticDraft(pi, ctx, {
384
- handoff,
393
+ ...recovery,
385
394
  tokensBefore: event.preparation.tokensBefore,
386
395
  reason: event.reason,
387
396
  });