pi-posthorse 0.4.2

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/index.ts +1022 -0
  4. package/package.json +42 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mitch Fultz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # Posthorse
2
+
3
+ **Fresh context. Same journey.** (POST-horse)
4
+
5
+ Native, no-summary context windows for the [`fitchmultz/pi`](https://github.com/fitchmultz/pi) fork of [Pi](https://github.com/earendil-works/pi). A post-horse was swapped in at relay stations so the courier and the message could continue on fresh legs. Posthorse does the same for a model: fresh context, same work, complete recoverable transcript.
6
+
7
+ ![Posthorse flow](diagram.png)
8
+
9
+ Pi owns the persisted boundary. Posthorse owns the policy: stable window guidance, one best-effort checkpoint reminder, `new_context`, `get_context_remaining`, durable `notes`, and window-aware `history`. A rollover removes the old window from active model context while the JSONL transcript stays append-only and complete.
10
+
11
+ ## Requirements
12
+
13
+ - Node `>=22.19.0`.
14
+ - The `fitchmultz/pi` fork. The current tested revision is `f9b06177e565f70cd243a785d088d1c491830dbd` (Pi `0.85.0`). Posthorse needs the fork's native `context_window` entries, its `session_before_auto_compact` hook, and `ctx.getCompactionSettings()`.
15
+ - Official, unpatched Pi is unsupported. Posthorse reports a clear extension error at session start and cannot operate; Pi itself keeps running.
16
+
17
+ ## Install
18
+
19
+ Build the fork:
20
+
21
+ ```bash
22
+ git clone https://github.com/fitchmultz/pi.git
23
+ cd pi
24
+ git checkout f9b06177e565f70cd243a785d088d1c491830dbd
25
+ npm install --ignore-scripts
26
+ npm run build
27
+ ```
28
+
29
+ After updating the fork, run the install and build commands again, then restart Pi. `pi --version` reads the checkout's package metadata, so it does not prove that the updated source has been built.
30
+
31
+ Run it as `node packages/coding-agent/dist/bundle/cli.js`, or run `npm link` inside `packages/coding-agent` so that build becomes your `pi` command.
32
+
33
+ Then install Posthorse with that `pi`:
34
+
35
+ ```bash
36
+ pi install git:github.com/fitchmultz/pi-posthorse # from Git; add @<tag> to pin a release
37
+ pi install npm:pi-posthorse # once published
38
+ pi -e git:github.com/fitchmultz/pi-posthorse # try it for one run without installing
39
+ ```
40
+
41
+ Update with `pi update npm:pi-posthorse` or `pi update --extensions`; move a pinned Git install with `pi install git:github.com/fitchmultz/pi-posthorse@<new tag>`. Uninstall with `pi remove npm:pi-posthorse` (or the Git source you installed). Removing the package leaves `.pi/notes` and Pi's session history in place.
42
+
43
+ Keep exactly one copy loaded. `pi list` shows every package source; if an older entry such as `git:github.com/fitchmultz/pi-headroom.git` or a local checkout is still listed, `pi remove` it before installing the npm package, otherwise two copies register the same tools and compete for the same rollover hook.
44
+
45
+ ## How it works
46
+
47
+ 1. **Stable guidance.** Window behavior is part of the system prompt. There is no per-request meter to churn the prompt.
48
+ 2. **One best-effort checkpoint.** While Pi compaction is enabled, one reminder may appear shortly before Pi's rollover line. A large turn, overflow, restart, or smaller model can reach rollover without it. Reminders are fingerprinted by window, context size, and reserve, so switching to a different context size gets a fresh reminder and stale ones are filtered from model input.
49
+ 3. **`get_context_remaining`.** Reports the best available native estimate of tokens until Pi's automatic rollover line and until the model's hard limit. Pi's value is an estimate until the active model reports usage.
50
+ 4. **`new_context`.** Requests an atomic rollover after the complete tool batch succeeds. An optional handoff is persisted and becomes the first state of the fresh window. If a sibling tool in the same batch fails, Pi does not commit the boundary; the checkpoint reminder still applies.
51
+ 5. **Automatic rollover without summaries.** Posthorse claims Pi's automatic threshold and overflow trigger through `session_before_auto_compact`, before Pi resolves summarization credentials or prepares a summary. A single oversized first turn, an oversized tool result, or a missing summarization login all still roll over. Manual `/compact` is unchanged.
52
+ 6. **Bounded recovery record.** The automatic handoff keeps direct user inputs, `ask_question` outcomes, visible coordination messages, and the trailing tool batch that no model has consumed yet (call arguments, bounded result text, and the entry ids to recover the rest). A clearly labeled, possibly stale older checkpoint comes last, after the current inputs and unseen results. Older assistant prose and consumed tool results are not treated as state. Newly submitted input stays separate and is saved after the boundary, not copied into the handoff.
53
+ 7. **`notes` and `history`.** Notes live with the repository root, shared across linked worktrees. History searches normalized transcript text and returns stored images for a requested entry.
54
+
55
+ Automatic recovery is an emergency input record, not proof of progress. The fresh model is told to restore notes and todo state, inspect history when needed, and verify live state before taking stateful or external action.
56
+
57
+ ## Settings
58
+
59
+ Posthorse follows Pi's effective `compaction` settings, including Pi's decision about whether project settings are trusted. Disabling `compaction.enabled` disables reminders and automatic rollover; `new_context` stays available.
60
+
61
+ The model's context window minus `compaction.reserveTokens` must leave at least 10,000 usable tokens. Below that (for example an 8K or 16K model with the default 16,384 reserve) Posthorse reports an unsupported configuration in the guidance and in `get_context_remaining`, turns automatic behavior off for that model, and leaves Pi's own compaction in place. Lower the reserve or use a larger model. The checkpoint reminder band is the last 10% of usable context, capped at 32,000 tokens, so a large reserve cannot trigger a reminder immediately in a fresh window.
62
+
63
+ Explicit and automatic handoffs are capped at 20,000 characters and half the active model's fresh operational capacity after prompt/tool overhead and any pending input, whichever is smaller. Oversized explicit handoffs are rejected with an instruction to save fuller state in notes; automatic rollover stays with Pi when no safe recovery record fits. When usage is not known yet, notes and history pages use the same model-aware limit.
64
+
65
+ Only one automatic compaction or rollover policy extension should be enabled at a time. Pi keeps the last non-cancel result from multiple handlers of the same hook, so load order would otherwise decide which policy wins.
66
+
67
+ ## Tools
68
+
69
+ - `new_context({ handoff? })`
70
+ - `get_context_remaining()`
71
+ - `notes({ op, ... })`: `list`, `read` (paged; `offset` continues), `write` (empty content clears), `append` (one atomic newline-terminated record), `search` (excerpts centered on the match)
72
+ - `history({ op, ... })`: `search`, `read`; results carry native window ids, reads return stored images with the first page and the next character offset when text remains
73
+
74
+ Read pages, including returned images, shrink to the context that is actually left. Before usage is known, they reserve prompt/tool overhead and leave half the rest free. Unsafe pages are refused with the offset preserved; call `new_context` and retry.
75
+
76
+ `history search` puts matching original content before recovery material: handoffs, compaction and branch summaries, checkpoint reminders, and `notes`, `new_context`, and `history` calls/results. Ordinary prose or another tool call in the same assistant entry keeps its priority when that content matches. Every entry remains searchable; `history read` returns the complete normalized entry, including any recovery content omitted from a search excerpt.
77
+
78
+ Within each group, current-branch matches are newest first. With `all: true`, Posthorse searches every session file in the active Pi session directory, newest-modified sessions first and newest entries within each session; this is not a global timestamp sort. The result limit applies after priority, so newer echoes cannot displace older original matches. Entries copied by a fork are reported once.
79
+
80
+ Notes live in `.pi/notes/` at the repository root (the main checkout for a linked worktree, the current directory outside Git). Add the directory to `.gitignore` when the project should not track it.
81
+
82
+ ## Data and privacy
83
+
84
+ - Posthorse makes no network requests.
85
+ - Notes are plaintext files under `.pi/notes`. They survive package removal and are committed unless ignored.
86
+ - `history` with `all: true` scans nested JSONL files in the active Pi session directory, including subagent sessions.
87
+ - History can return user text, assistant text and thinking, tool arguments and results, handoffs, custom messages, and images. Direct shell entries Pi marked `excludeFromContext` come back as a placeholder only.
88
+ - Returned history content enters the currently selected model and provider context.
89
+ - Removing Posthorse does not remove notes or Pi session history.
90
+
91
+ ## Compatibility
92
+
93
+ Reminders persisted by pi-headroom (`headroom-reminder`) are recognized alongside `posthorse-reminder` for deduplication, filtering, and recovery records. Notes, tool names, `.pi/notes`, and Pi's `context_window` entries are unchanged.
94
+
95
+ ## Develop
96
+
97
+ ```bash
98
+ npm ci
99
+ npm test
100
+ npm run check
101
+ PI_FORK=../pi scripts/integration.sh # loads the real extension into the fork's test harness (fork built)
102
+ ```
103
+
104
+ `npm run check` type-checks `index.ts` and the unit tests; the integration test runs inside the fork's harness. CI runs the unit tests on Node 22.19 and 24, `npm audit`, `npm pack --dry-run`, and the integration job against the pinned fork revision.
package/index.ts ADDED
@@ -0,0 +1,1022 @@
1
+ /**
2
+ * Posthorse — fresh context, same journey.
3
+ *
4
+ * Native no-summary context windows for the fitchmultz/pi fork. Pi owns the persisted
5
+ * boundary. Posthorse owns the policy: sparse budget reminders, rollover tools, durable
6
+ * notes, and history recovery.
7
+ */
8
+
9
+ import {
10
+ appendFileSync,
11
+ createReadStream,
12
+ existsSync,
13
+ mkdirSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ statSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
20
+ import { createInterface } from "node:readline";
21
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import { Type } from "typebox";
23
+
24
+ const REMINDER_BUFFER_TOKENS = 32_000;
25
+ /** Absolute ceiling for handoffs and read pages; pages shrink to the live remaining budget. */
26
+ const MAX_HANDOFF_CHARS = 20_000;
27
+ const MAX_RECOVERY_RECORD_CHARS = 4_000;
28
+ const HANDOFF_OVERHEAD_RESERVE = 1_000;
29
+ const PAGE_MARGIN_TOKENS = 1_000;
30
+ const MIN_PAGE_CHARS = 1_000;
31
+ const ESTIMATED_IMAGE_CHARS = 4_800;
32
+ /** A window must hold the largest handoff (~5,000 tokens) plus equal working room below Pi's line. */
33
+ const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
34
+ const REMINDER_TYPE = "posthorse-reminder";
35
+ /** Persisted by pi-headroom transcripts before the rename; still recognized everywhere. */
36
+ const LEGACY_REMINDER_TYPE = "headroom-reminder";
37
+ const AUTO_HANDOFF_PREFIX = "Automatic context rollover recovery record.";
38
+ const LEGACY_AUTO_HANDOFF_PREFIX =
39
+ "Automatic context rollover. Continue the current task without asking the user to repeat it.";
40
+
41
+ type CompactionPolicy = { enabled: boolean; reserveTokens: number };
42
+ type ContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
43
+ /** Fork-only ExtensionContext members; the published Pi types do not declare them. */
44
+ type NativeContext = {
45
+ model?: { contextWindow: number };
46
+ newContext(options?: { handoff?: string }): void;
47
+ getCompactionSettings(): CompactionPolicy;
48
+ getContextUsage(): ContextUsage | undefined;
49
+ getSystemPrompt(): string;
50
+ };
51
+
52
+ type NativeExtensionAPI = {
53
+ on(
54
+ event: "session_before_auto_compact",
55
+ handler: (
56
+ event: { reason: "overflow" | "threshold"; branchEntries: EntryLike[]; pendingMessages?: MessageLike[] },
57
+ ctx: unknown,
58
+ ) => { newContext: { handoff?: string } } | undefined,
59
+ ): void;
60
+ };
61
+
62
+ type ImageLike = { type: "image"; data: string; mimeType: string };
63
+ type MessageLike = {
64
+ role?: string;
65
+ stopReason?: string;
66
+ content?: unknown;
67
+ toolName?: string;
68
+ toolCallId?: string;
69
+ isError?: boolean;
70
+ command?: string;
71
+ output?: string;
72
+ excludeFromContext?: boolean;
73
+ };
74
+ type EntryLike = {
75
+ type?: string;
76
+ id?: string;
77
+ parentId?: string | null;
78
+ timestamp?: string;
79
+ message?: MessageLike;
80
+ summary?: string;
81
+ customType?: string;
82
+ content?: unknown;
83
+ details?: unknown;
84
+ display?: boolean;
85
+ handoff?: string;
86
+ };
87
+
88
+ type WindowedEntry = { entry: EntryLike; windowId: string; text: string; images: ImageLike[] };
89
+ type HistoryHit = { id: string; text: string; priority: 0 | 1 };
90
+ type RecoveryRecord = {
91
+ id: string;
92
+ timestamp: string;
93
+ kind: "owner" | "coordination";
94
+ label: string;
95
+ text: string;
96
+ };
97
+ type ReminderFingerprint = { windowId: string; contextWindow?: number; reserveTokens?: number };
98
+ type Budget = {
99
+ contextWindow: number;
100
+ reserveTokens: number;
101
+ enabled: boolean;
102
+ usable: number;
103
+ rolloverAt: number;
104
+ supported: boolean;
105
+ };
106
+
107
+ function nativeContext<T>(ctx: T): T & NativeContext {
108
+ const candidate = ctx as T & Partial<NativeContext>;
109
+ if (
110
+ typeof candidate.newContext !== "function" ||
111
+ typeof candidate.getCompactionSettings !== "function" ||
112
+ typeof candidate.getSystemPrompt !== "function"
113
+ ) {
114
+ throw new Error("Posthorse requires the fitchmultz/pi fork with native context windows (see README).");
115
+ }
116
+ return candidate as T & NativeContext;
117
+ }
118
+
119
+ function isReminderType(customType: unknown): boolean {
120
+ return customType === REMINDER_TYPE || customType === LEGACY_REMINDER_TYPE;
121
+ }
122
+
123
+ function isImage(part: unknown): part is ImageLike {
124
+ const block = part as Partial<ImageLike> | null;
125
+ return typeof block === "object" && block !== null && block.type === "image" && typeof block.data === "string";
126
+ }
127
+
128
+ function imagesOf(content: unknown): ImageLike[] {
129
+ return Array.isArray(content) ? content.filter(isImage) : [];
130
+ }
131
+
132
+ function imageSummary(images: ImageLike[]): string {
133
+ if (!images.length) return "";
134
+ const types = [...new Set(images.map((image) => image.mimeType || "unknown type"))].join(", ");
135
+ return `[${images.length} image${images.length === 1 ? "" : "s"}: ${types}]`;
136
+ }
137
+
138
+ function safeJsonStringify(value: unknown): string {
139
+ try {
140
+ return JSON.stringify(value) ?? "undefined";
141
+ } catch {
142
+ return "[unserializable]";
143
+ }
144
+ }
145
+
146
+ function textOf(message: MessageLike): string {
147
+ if (typeof message.content === "string") return message.content;
148
+ if (!Array.isArray(message.content)) return "";
149
+ return message.content
150
+ .map((part) => {
151
+ if (!part || typeof part !== "object") return "";
152
+ const block = part as { type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown };
153
+ if (block.type === "text") return block.text ?? "";
154
+ if (block.type === "thinking") return block.thinking ?? "";
155
+ if (block.type === "toolCall") return `${block.name ?? "tool"} ${safeJsonStringify(block.arguments ?? {})}`;
156
+ return "";
157
+ })
158
+ .filter(Boolean)
159
+ .join("\n");
160
+ }
161
+
162
+ function textResult(text: string, images: ImageLike[] = []) {
163
+ return { content: [{ type: "text" as const, text }, ...images], details: undefined };
164
+ }
165
+
166
+ /**
167
+ * Notes belong to the repository root, walking up from cwd. A linked worktree's `.git` is a file
168
+ * ("gitdir: <main>/.git/worktrees/<name>"); its notes belong to the main checkout so every worktree
169
+ * shares them and they outlive the worktree. Outside Git, cwd is the root.
170
+ */
171
+ function notesRoot(cwd: string): string {
172
+ for (let dir = cwd; ; dir = dirname(dir)) {
173
+ const marker = join(dir, ".git");
174
+ if (existsSync(marker)) {
175
+ try {
176
+ const gitdir = readFileSync(marker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
177
+ const main = gitdir && resolve(dir, gitdir).match(/^(.+)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/)?.[1];
178
+ if (main) return main;
179
+ } catch {}
180
+ return dir;
181
+ }
182
+ if (dirname(dir) === dir) return cwd;
183
+ }
184
+ }
185
+
186
+ function requireValue(value: string | undefined, name: string, op: string): string {
187
+ if (value === undefined || value === "") throw new Error(`"${name}" is required for op "${op}".`);
188
+ return value;
189
+ }
190
+
191
+ function excerptAround(text: string, index: number, before: number, length: number): string {
192
+ const start = Math.max(0, index - before);
193
+ const end = Math.min(text.length, start + length);
194
+ return `${start ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
195
+ }
196
+
197
+ function flattenEntry(entry: EntryLike): string | undefined {
198
+ if (entry.type === "message") {
199
+ const message = entry.message ?? {};
200
+ if (message.role === "bashExecution") {
201
+ // Pi keeps these out of every model's input on purpose; do not smuggle them back in through history.
202
+ if (message.excludeFromContext === true) return "[bashExecution] (excluded from model context by Pi)";
203
+ return `[bashExecution] $ ${message.command ?? ""}\n${message.output ?? ""}`;
204
+ }
205
+ return `[${message.role ?? "message"}] ${[textOf(message), imageSummary(imagesOf(message.content))].filter(Boolean).join("\n")}`;
206
+ }
207
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
208
+ return `[${entry.type}] ${entry.summary ?? ""}`;
209
+ }
210
+ if (entry.type === "custom_message") {
211
+ return `[custom:${entry.customType ?? "unknown"}] ${[textOf({ content: entry.content }), imageSummary(imagesOf(entry.content))].filter(Boolean).join("\n")}`;
212
+ }
213
+ if (entry.type === "context_window") {
214
+ return `[context_window] ${entry.handoff ? `Handoff: ${entry.handoff}` : "No handoff"}`;
215
+ }
216
+ return undefined;
217
+ }
218
+
219
+ function isRecoveryTool(name: unknown): boolean {
220
+ return name === "history" || name === "notes" || name === "new_context";
221
+ }
222
+
223
+ function isRecoveryCall(part: unknown): boolean {
224
+ const block = part as { type?: string; name?: string } | null;
225
+ return block?.type === "toolCall" && isRecoveryTool(block.name);
226
+ }
227
+
228
+ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit | undefined {
229
+ const { entry } = item;
230
+ let text = item.text;
231
+ let matchIndex = text.toLowerCase().indexOf(query);
232
+ if (matchIndex === -1) return undefined;
233
+ let priority: 0 | 1 =
234
+ entry.type === "context_window" ||
235
+ entry.type === "compaction" ||
236
+ entry.type === "branch_summary" ||
237
+ (entry.type === "custom_message" && isReminderType(entry.customType)) ||
238
+ (entry.type === "message" && entry.message?.role === "toolResult" && isRecoveryTool(entry.message.toolName))
239
+ ? 1
240
+ : 0;
241
+
242
+ if (
243
+ entry.type === "message" && entry.message?.role === "assistant" &&
244
+ Array.isArray(entry.message.content) && entry.message.content.some(isRecoveryCall)
245
+ ) {
246
+ // A lookup beside a matching ordinary call or prose must not demote that original content.
247
+ const originals = [""];
248
+ for (const part of entry.message.content) {
249
+ if (isRecoveryCall(part)) {
250
+ originals.push("");
251
+ } else {
252
+ const partText = textOf({ content: [part] });
253
+ if (partText) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${partText}`;
254
+ }
255
+ }
256
+ const images = imageSummary(item.images);
257
+ if (images) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${images}`;
258
+ if (originals[0]) originals[0] = `[assistant] ${originals[0]}`;
259
+ const original = originals.find((part) => part.toLowerCase().includes(query));
260
+ priority = original ? 0 : 1;
261
+ if (original) {
262
+ text = original === originals[0] ? original : `[assistant] ${original}`;
263
+ matchIndex = text.toLowerCase().indexOf(query);
264
+ }
265
+ }
266
+ return {
267
+ id: entry.id!,
268
+ priority,
269
+ text: `${source ? `${source} ` : ""}${entry.timestamp ?? ""} [window ${item.windowId}] [${entry.id}] ${excerptAround(text, matchIndex, 100, 400)}`,
270
+ };
271
+ }
272
+
273
+ function toWindowedEntry(entry: EntryLike, windows: Map<string, string>): WindowedEntry | undefined {
274
+ const inheritedWindow = entry.parentId ? (windows.get(entry.parentId) ?? "initial") : "initial";
275
+ const windowId = entry.type === "context_window" && entry.id ? entry.id : inheritedWindow;
276
+ if (entry.id) windows.set(entry.id, windowId);
277
+ const text = flattenEntry(entry);
278
+ if (!text || !entry.id) return undefined;
279
+ const images = imagesOf(entry.type === "message" ? entry.message?.content : entry.content);
280
+ return { entry, windowId, text, images };
281
+ }
282
+
283
+ function* windowEntries(entries: Iterable<EntryLike>): Generator<WindowedEntry> {
284
+ const windows = new Map<string, string>();
285
+ for (const entry of entries) {
286
+ const item = toWindowedEntry(entry, windows);
287
+ if (item) yield item;
288
+ }
289
+ }
290
+
291
+ async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncGenerator<WindowedEntry> {
292
+ if (!existsSync(file)) return;
293
+ const stream = createReadStream(file, { encoding: "utf8", signal });
294
+ const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
295
+ const windows = new Map<string, string>();
296
+ try {
297
+ for await (const line of lines) {
298
+ let entry: EntryLike;
299
+ try {
300
+ entry = JSON.parse(line) as EntryLike;
301
+ } catch {
302
+ continue;
303
+ }
304
+ const item = toWindowedEntry(entry, windows);
305
+ if (item) yield item;
306
+ }
307
+ } finally {
308
+ lines.close();
309
+ stream.destroy();
310
+ }
311
+ }
312
+
313
+ function sessionFiles(dir: string): string[] {
314
+ if (!existsSync(dir)) return [];
315
+ const files = readdirSync(dir, { recursive: true, withFileTypes: true })
316
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl") && !entry.name.includes(".intent."))
317
+ .map((entry) => join(entry.parentPath, entry.name));
318
+ if (files.length < 2) return files;
319
+ return files
320
+ .map((file) => ({ file, mtime: statSync(file).mtimeMs }))
321
+ .sort((a, b) => b.mtime - a.mtime)
322
+ .map(({ file }) => file);
323
+ }
324
+
325
+ function currentWindowId(entries: readonly EntryLike[]): string {
326
+ for (let i = entries.length - 1; i >= 0; i--) {
327
+ const entry = entries[i];
328
+ if (entry.type === "context_window" && entry.id) return entry.id;
329
+ }
330
+ return "initial";
331
+ }
332
+
333
+ function excerpt(text: string, limit: number): string {
334
+ if (text.length <= limit) return text;
335
+ const marker = "\n… middle omitted …\n";
336
+ if (limit <= marker.length) return text.slice(0, limit);
337
+ const head = Math.floor((limit - marker.length) / 2);
338
+ return `${text.slice(0, head)}${marker}${text.slice(text.length - (limit - marker.length - head))}`;
339
+ }
340
+
341
+ function boundedBlock(header: string, text: string, limit: number): string {
342
+ const textLimit = Math.max(0, limit - header.length - 1);
343
+ return textLimit ? `${header}\n${excerpt(text, textLimit)}` : header;
344
+ }
345
+
346
+ function recoveryRecord(entry: EntryLike): RecoveryRecord | undefined {
347
+ let kind: RecoveryRecord["kind"];
348
+ let label: string;
349
+ let text: string;
350
+ let images: ImageLike[] = [];
351
+ if (entry.type === "message" && entry.message?.role === "user") {
352
+ kind = "owner";
353
+ label = "owner input";
354
+ text = textOf(entry.message).trim();
355
+ images = imagesOf(entry.message.content);
356
+ } else if (
357
+ entry.type === "message" &&
358
+ entry.message?.role === "toolResult" &&
359
+ entry.message.toolName === "ask_question" &&
360
+ entry.message.isError !== true
361
+ ) {
362
+ kind = "owner";
363
+ label = "owner answer via ask_question";
364
+ text = textOf(entry.message).trim();
365
+ } else if (entry.type === "custom_message" && entry.display === true && !isReminderType(entry.customType)) {
366
+ kind = "coordination";
367
+ label = `visible ${(entry.customType ?? "custom").slice(0, 80)} coordination input (not direct owner input)`;
368
+ text = textOf({ content: entry.content }).trim();
369
+ images = imagesOf(entry.content);
370
+ } else {
371
+ return undefined;
372
+ }
373
+ const id = entry.id?.slice(0, 120) ?? "unknown";
374
+ const summary = imageSummary(images);
375
+ return {
376
+ id,
377
+ timestamp: entry.timestamp?.slice(0, 80) ?? "unknown time",
378
+ kind,
379
+ label,
380
+ text: [text, summary && `${summary} — recover with history read id ${id}`].filter(Boolean).join("\n") || "(non-text content; recover the entry from history)",
381
+ };
382
+ }
383
+
384
+ function formatRecoveryRecord(record: RecoveryRecord, limit: number): string {
385
+ return boundedBlock(`[${record.label} | ${record.timestamp} | entry ${record.id}]`, record.text, limit);
386
+ }
387
+
388
+ function formatPriorCheckpoint(entry: EntryLike | undefined, limit: number): string | undefined {
389
+ const handoff = entry?.handoff?.trim();
390
+ if (!entry || !handoff) return undefined;
391
+ const id = entry.id?.slice(0, 120) ?? "unknown";
392
+ const header = `[older checkpoint; possibly stale | context-window entry ${id}]`;
393
+ if (handoff.startsWith(AUTO_HANDOFF_PREFIX) || handoff.startsWith(LEGACY_AUTO_HANDOFF_PREFIX)) {
394
+ return boundedBlock(header, `Prior automatic recovery text is not nested here. Use history read with entry ${id} if needed.`, limit);
395
+ }
396
+ return boundedBlock(header, handoff, limit);
397
+ }
398
+
399
+ /**
400
+ * The trailing tool batch no model has consumed yet: an assistant tool-call message followed only by
401
+ * its completed results (and non-message entries). Pi can roll over right after tools finish, so the
402
+ * result that triggered the rollover would otherwise vanish before any model saw it.
403
+ */
404
+ function unconsumedToolBatch(
405
+ entries: readonly EntryLike[],
406
+ ): { callId: string; blocks: Array<{ header: string; text: string }> } | undefined {
407
+ const results: EntryLike[] = [];
408
+ let call: EntryLike | undefined;
409
+ for (let i = entries.length - 1; i >= 0 && !call; i--) {
410
+ const entry = entries[i];
411
+ const role = entry.type === "message" ? entry.message?.role : undefined;
412
+ if (role === "toolResult") {
413
+ results.unshift(entry);
414
+ } else if (role === "assistant") {
415
+ const invalid =
416
+ entry.message?.stopReason === "error" ||
417
+ entry.message?.stopReason === "aborted" ||
418
+ entry.message?.stopReason === "length";
419
+ if (invalid) {
420
+ if (results.length) return undefined;
421
+ } else {
422
+ call = entry;
423
+ }
424
+ }
425
+ }
426
+ if (!call || !results.length) return undefined;
427
+ const calls = Array.isArray(call.message?.content)
428
+ ? (call.message.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>).filter(
429
+ (block) => block?.type === "toolCall",
430
+ )
431
+ : [];
432
+ const blocks = results.map((result) => {
433
+ const message = result.message ?? {};
434
+ const matching = calls.find((block) => block.id === message.toolCallId);
435
+ const name = (message.toolName ?? matching?.name ?? "tool").slice(0, 80);
436
+ const resultId = (result.id ?? "unknown").slice(0, 120);
437
+ const images = imageSummary(imagesOf(message.content));
438
+ const output =
439
+ [textOf(message).trim(), images && `${images} — recover with history read id ${resultId}`]
440
+ .filter(Boolean)
441
+ .join("\n") || "(empty result)";
442
+ return {
443
+ header: `[${message.isError ? "error" : "result"} entry ${resultId}]`,
444
+ text: `${output}\n\nTool: ${name}\nCall arguments: ${safeJsonStringify(matching?.arguments ?? {})}`,
445
+ };
446
+ });
447
+ return { callId: (call.id ?? "unknown").slice(0, 120), blocks };
448
+ }
449
+
450
+ function buildAutoHandoff(entries: readonly EntryLike[], maxChars: number): string {
451
+ let windowStart = 0;
452
+ let priorWindow: EntryLike | undefined;
453
+ for (let i = entries.length - 1; i >= 0; i--) {
454
+ if (entries[i].type === "context_window") {
455
+ windowStart = i + 1;
456
+ priorWindow = entries[i];
457
+ break;
458
+ }
459
+ }
460
+
461
+ const current = entries.slice(windowStart);
462
+ const records = current
463
+ .map((entry) => recoveryRecord(entry))
464
+ .filter((record): record is RecoveryRecord => record !== undefined);
465
+ const firstOwnerRequest = records.find((record) => record.label === "owner input") ?? records[0];
466
+ const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
467
+ const latestOverall = records.at(-1);
468
+ const selected = new Set(
469
+ [firstOwnerRequest, latestOwner, latestOverall].filter((record): record is RecoveryRecord => record !== undefined),
470
+ );
471
+ 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.`;
472
+ const currentHeader = records.length
473
+ ? "Current-window inputs (chronological):"
474
+ : "No selected current-window owner or visible coordination inputs were found.";
475
+ const joinedLength = (parts: Array<string | undefined>) =>
476
+ parts.filter((part): part is string => Boolean(part)).join("\n\n").length;
477
+
478
+ const toolBatch = unconsumedToolBatch(current);
479
+ const batchHeader = toolBatch
480
+ ? `Unconsumed tool batch (completed after the last assistant response; no model has seen these results). Tool-call entry ${toolBatch.callId}:`
481
+ : undefined;
482
+ const minimumParts = [
483
+ preamble,
484
+ currentHeader,
485
+ ...records.filter((record) => selected.has(record)).map((record) => formatRecoveryRecord(record, 0)),
486
+ batchHeader,
487
+ formatPriorCheckpoint(priorWindow, 0),
488
+ ];
489
+ let headerBudget = Math.max(0, maxChars - joinedLength(minimumParts) - HANDOFF_OVERHEAD_RESERVE);
490
+ let firstBatchBlock = toolBatch?.blocks.length ?? 0;
491
+ while (firstBatchBlock > 0) {
492
+ const length = toolBatch!.blocks[firstBatchBlock - 1].header.length + 2;
493
+ if (length > headerBudget) break;
494
+ headerBudget -= length;
495
+ firstBatchBlock--;
496
+ }
497
+ const batchBlocks = toolBatch?.blocks.slice(firstBatchBlock) ?? [];
498
+ const batchOmission = firstBatchBlock
499
+ ? `Omitted ${firstBatchBlock} earlier tool result(s) whose headers could not fit. Use history read with tool-call entry ${toolBatch!.callId}, then history search/read to recover them.`
500
+ : undefined;
501
+ const bareBatch = batchHeader
502
+ ? [batchHeader, batchOmission, ...batchBlocks.map((block) => block.header)]
503
+ .filter((part): part is string => Boolean(part))
504
+ .join("\n\n")
505
+ : undefined;
506
+ const fixedCount = selected.size + (priorWindow?.handoff?.trim() ? 1 : 0);
507
+ const fixedBudget = Math.max(
508
+ 0,
509
+ maxChars - joinedLength([preamble, currentHeader, bareBatch]) - HANDOFF_OVERHEAD_RESERVE,
510
+ );
511
+ const fixedLimit = fixedCount
512
+ ? Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(fixedBudget / fixedCount))
513
+ : MAX_RECOVERY_RECORD_CHARS;
514
+ const prior = formatPriorCheckpoint(priorWindow, fixedLimit);
515
+ const formatted = new Map(
516
+ records.map((record) => [
517
+ record,
518
+ formatRecoveryRecord(record, selected.has(record) ? fixedLimit : MAX_RECOVERY_RECORD_CHARS),
519
+ ]),
520
+ );
521
+ const fixedParts = () => [
522
+ preamble,
523
+ currentHeader,
524
+ ...records.filter((record) => selected.has(record)).map((record) => formatted.get(record)!),
525
+ ];
526
+
527
+ let batch: string | undefined;
528
+ if (batchHeader && bareBatch) {
529
+ const availableText = Math.max(
530
+ 0,
531
+ maxChars -
532
+ joinedLength([...fixedParts(), bareBatch, prior]) -
533
+ HANDOFF_OVERHEAD_RESERVE -
534
+ batchBlocks.length,
535
+ );
536
+ const perBlock = Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(availableText / batchBlocks.length));
537
+ batch = [
538
+ batchHeader,
539
+ batchOmission,
540
+ ...batchBlocks.map((block) => (perBlock ? `${block.header}\n${excerpt(block.text, perBlock)}` : block.header)),
541
+ ]
542
+ .filter((part): part is string => Boolean(part))
543
+ .join("\n\n");
544
+ }
545
+
546
+ let optionalBudget = Math.max(0, maxChars - joinedLength([...fixedParts(), batch, prior]) - HANDOFF_OVERHEAD_RESERVE);
547
+ for (let i = records.length - 1; i >= 0; i--) {
548
+ const record = records[i];
549
+ if (selected.has(record)) continue;
550
+ const length = formatted.get(record)!.length + 2;
551
+ if (length > optionalBudget) continue;
552
+ selected.add(record);
553
+ optionalBudget -= length;
554
+ }
555
+
556
+ const omitted = records.filter((record) => !selected.has(record));
557
+ const omission = omitted.length
558
+ ? `Omitted ${omitted.length} current-window input(s) to stay within the handoff limit (${omitted.filter((record) => record.kind === "owner").length} owner, ${omitted.filter((record) => record.kind === "coordination").length} coordination; ${omitted[0].timestamp} through ${omitted.at(-1)!.timestamp}). Use history search/read to recover them.`
559
+ : undefined;
560
+ return [...fixedParts(), omission, batch, prior].filter((part): part is string => Boolean(part)).join("\n\n");
561
+ }
562
+
563
+ /** Legacy reminders carry only windowId; they match on it alone until the model changes. */
564
+ function reminderMatches(details: unknown, fingerprint: ReminderFingerprint): boolean {
565
+ const stored = (details ?? {}) as Partial<ReminderFingerprint>;
566
+ return (
567
+ stored.windowId === fingerprint.windowId &&
568
+ (stored.contextWindow ?? fingerprint.contextWindow) === fingerprint.contextWindow &&
569
+ (stored.reserveTokens ?? fingerprint.reserveTokens) === fingerprint.reserveTokens
570
+ );
571
+ }
572
+
573
+ function reminderIsStale(
574
+ customType: unknown,
575
+ details: unknown,
576
+ fingerprint: ReminderFingerprint,
577
+ branch: readonly EntryLike[],
578
+ ): boolean {
579
+ if (!reminderMatches(details, fingerprint)) return true;
580
+ const stored = (details ?? {}) as Partial<ReminderFingerprint>;
581
+ if (customType !== LEGACY_REMINDER_TYPE || stored.contextWindow !== undefined || stored.reserveTokens !== undefined) {
582
+ return false;
583
+ }
584
+ let reminderIndex = -1;
585
+ for (let i = 0; i < branch.length; i++) {
586
+ const entry = branch[i];
587
+ if (
588
+ entry.type === "custom_message" &&
589
+ entry.customType === LEGACY_REMINDER_TYPE &&
590
+ ((entry.details ?? {}) as Partial<ReminderFingerprint>).windowId === stored.windowId
591
+ ) {
592
+ reminderIndex = i;
593
+ }
594
+ }
595
+ return reminderIndex !== -1 && branch.slice(reminderIndex + 1).some((entry) => entry.type === "model_change");
596
+ }
597
+
598
+ function hasReminder(entries: readonly EntryLike[], fingerprint: ReminderFingerprint): boolean {
599
+ return entries.some(
600
+ (entry) =>
601
+ entry.type === "custom_message" &&
602
+ isReminderType(entry.customType) &&
603
+ !reminderIsStale(entry.customType, entry.details, fingerprint, entries),
604
+ );
605
+ }
606
+
607
+ function budgetFor(ctx: NativeContext, contextWindow = ctx.model?.contextWindow): Budget | undefined {
608
+ if (!contextWindow || contextWindow <= 0) return undefined;
609
+ const { enabled, reserveTokens } = ctx.getCompactionSettings();
610
+ const usable = contextWindow - reserveTokens;
611
+ return { contextWindow, reserveTokens, enabled, usable, rolloverAt: usable + 1, supported: !enabled || usable >= MIN_USABLE_TOKENS };
612
+ }
613
+
614
+ /** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
615
+ function freshPayloadChars(ctx: NativeContext, toolTokens: number, pendingMessages: readonly MessageLike[] = []): number {
616
+ const contextWindow = ctx.model?.contextWindow;
617
+ if (!contextWindow || contextWindow <= 0) return MAX_HANDOFF_CHARS;
618
+ const budget = budgetFor(ctx, contextWindow);
619
+ const line = budget?.enabled && budget.supported ? budget.rolloverAt : contextWindow;
620
+ const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4);
621
+ const pendingTokens = pendingMessages.reduce(
622
+ (total, message) => total + Math.ceil(textOf(message).length / 4) + imagesOf(message.content).length * (ESTIMATED_IMAGE_CHARS / 4),
623
+ 0,
624
+ );
625
+ return Math.min(
626
+ MAX_HANDOFF_CHARS,
627
+ Math.max(0, Math.floor((line - PAGE_MARGIN_TOKENS - promptTokens - toolTokens - pendingTokens) / 2)) * 4,
628
+ );
629
+ }
630
+
631
+ function unsupportedMessage(budget: Budget): string {
632
+ const n = (value: number) => value.toLocaleString("en-US");
633
+ return `Posthorse: unsupported configuration. The model's context window (${n(budget.contextWindow)} tokens) minus Pi's compaction.reserveTokens (${n(budget.reserveTokens)}) leaves ${n(budget.usable)} usable tokens; Posthorse needs at least ${n(MIN_USABLE_TOKENS)}. Automatic rollover and checkpoint reminders are off for this model. Lower compaction.reserveTokens in Pi settings or use a larger-context model. new_context remains available with a model-aware handoff limit.`;
634
+ }
635
+
636
+ /** Characters that fit before Pi's automatic line (or the hard limit), capped at the absolute ceiling. */
637
+ function requirePage(ctx: NativeContext, offset: number, imageCount: number, toolTokens: number): number {
638
+ const usage = ctx.getContextUsage();
639
+ let chars: number;
640
+ if (!usage || usage.tokens == null) {
641
+ chars = Math.max(0, freshPayloadChars(ctx, toolTokens) - imageCount * ESTIMATED_IMAGE_CHARS);
642
+ } else {
643
+ const budget = budgetFor(ctx, usage.contextWindow);
644
+ const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
645
+ chars = Math.min(
646
+ MAX_HANDOFF_CHARS,
647
+ Math.max(0, line - usage.tokens - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
648
+ );
649
+ }
650
+ if (chars < MIN_PAGE_CHARS) {
651
+ throw new Error(
652
+ `Too little context remains to read a page safely. Call new_context first, then retry with offset ${offset}.`,
653
+ );
654
+ }
655
+ return chars;
656
+ }
657
+
658
+ function buildGuidance(ctx: NativeContext): string {
659
+ const budget = budgetFor(ctx);
660
+ const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
661
+ let automatic: string;
662
+ if (!enabled) {
663
+ automatic =
664
+ "Pi compaction is disabled, so Posthorse sends no checkpoint reminder and performs no automatic rollover. new_context remains available.";
665
+ } else if (budget && !budget.supported) {
666
+ automatic = unsupportedMessage(budget);
667
+ } else {
668
+ const deadline = budget
669
+ ? `${Math.max(1, Math.round((budget.rolloverAt / budget.contextWindow) * 100))}% used`
670
+ : "the configured Pi context limit";
671
+ automatic = `Automatic Posthorse rollover follows Pi's enabled compaction setting. At most one best-effort checkpoint reminder may appear before the rollover line (${deadline}); a large turn, overflow, restart, or smaller model can skip it.\nWhen reminded, stop normal work, save goal/progress/decisions/next steps, then call new_context now.`;
672
+ }
673
+ return `## Context self-management (Posthorse)
674
+ Context windows are finite. Use get_context_remaining for the best available native estimate when it matters; routine turns do not include a changing meter.
675
+ ${automatic}
676
+ new_context starts a genuinely fresh Pi context after the complete tool batch. Earlier conversation remains in the session transcript and is recoverable with notes and history.
677
+ Automatic handoffs are emergency recovery records, not proof of current state. Restore notes/todos/history and verify live state before continuing stateful or external work.`;
678
+ }
679
+
680
+ export default function (pi: ExtensionAPI) {
681
+ const activeToolTokens = () => {
682
+ const active = new Set(pi.getActiveTools());
683
+ return pi
684
+ .getAllTools()
685
+ .filter((tool) => active.has(tool.name))
686
+ .reduce(
687
+ (total, tool) =>
688
+ total +
689
+ Math.ceil(
690
+ safeJsonStringify({ name: tool.name, description: tool.description ?? "", parameters: tool.parameters })
691
+ .length / 4,
692
+ ),
693
+ 0,
694
+ );
695
+ };
696
+
697
+ pi.on("session_start", (_event, ctx) => {
698
+ nativeContext(ctx);
699
+ });
700
+
701
+ pi.on("before_agent_start", (event, ctx) => ({
702
+ systemPrompt: `${event.systemPrompt}\n\n${buildGuidance(nativeContext(ctx))}`,
703
+ }));
704
+
705
+ pi.on("turn_end", (event, ctx) => {
706
+ const native = nativeContext(ctx);
707
+ if (
708
+ event.message.role === "assistant" &&
709
+ (event.message.stopReason === "error" || event.message.stopReason === "aborted")
710
+ )
711
+ return;
712
+ // Pi commits a new_context request only when every tool in the batch succeeded.
713
+ if (
714
+ event.toolResults.some((result) => result.toolName === "new_context") &&
715
+ !event.toolResults.some((result) => result.isError)
716
+ )
717
+ return;
718
+ const usage = native.getContextUsage();
719
+ if (!usage || usage.tokens == null || usage.contextWindow <= 0) return;
720
+ const budget = budgetFor(native, usage.contextWindow);
721
+ if (!budget || !budget.enabled || !budget.supported) return;
722
+
723
+ const branch = ctx.sessionManager.getBranch() as EntryLike[];
724
+ const fingerprint: ReminderFingerprint = {
725
+ windowId: currentWindowId(branch),
726
+ contextWindow: budget.contextWindow,
727
+ reserveTokens: budget.reserveTokens,
728
+ };
729
+ if (usage.tokens >= budget.rolloverAt) return;
730
+ const reminderBuffer = Math.min(REMINDER_BUFFER_TOKENS, Math.floor(budget.usable * 0.1));
731
+ const remindAt = budget.rolloverAt - reminderBuffer;
732
+ if (usage.tokens < remindAt || hasReminder(branch, fingerprint)) return;
733
+ pi.sendMessage(
734
+ {
735
+ customType: REMINDER_TYPE,
736
+ content: `[posthorse] Checkpoint now: ${(budget.rolloverAt - usage.tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Stop normal work, save goal/progress/decisions/next steps, then call new_context now. This reminder is best-effort; a large turn, overflow, restart, or smaller model can reach rollover without one.`,
737
+ display: true,
738
+ details: fingerprint,
739
+ },
740
+ { deliverAs: "steer" },
741
+ );
742
+ });
743
+
744
+ // Drop reminders from another window or computed for another context size/reserve from model input.
745
+ pi.on("context", (event, ctx) => {
746
+ const marker = event.messages.find(
747
+ (message) => message.role === "custom" && message.customType === "context-window",
748
+ );
749
+ const windowId = marker?.role === "custom" ? (marker.details as { windowId?: unknown } | undefined)?.windowId : "initial";
750
+ if (typeof windowId !== "string") return;
751
+ const native = nativeContext(ctx);
752
+ const budget = budgetFor(native);
753
+ const branch = ctx.sessionManager.getBranch() as EntryLike[];
754
+ const fingerprint: ReminderFingerprint = {
755
+ windowId,
756
+ contextWindow: budget?.contextWindow,
757
+ reserveTokens: budget?.reserveTokens,
758
+ };
759
+ const stale = (message: (typeof event.messages)[number]) =>
760
+ message.role === "custom" &&
761
+ isReminderType(message.customType) &&
762
+ reminderIsStale(message.customType, message.details, fingerprint, branch);
763
+ if (event.messages.some(stale)) return { messages: event.messages.filter((message) => !stale(message)) };
764
+ });
765
+
766
+ // Claim Pi's automatic threshold/overflow trigger with a fresh window: no summary, no summarization auth.
767
+ (pi as unknown as NativeExtensionAPI).on("session_before_auto_compact", (event, ctx) => {
768
+ const native = nativeContext(ctx);
769
+ const budget = budgetFor(native);
770
+ // An unsupported budget would roll over every turn; leave Pi's own behavior in place instead.
771
+ if (budget && !budget.supported) return undefined;
772
+ const limit = freshPayloadChars(native, activeToolTokens(), event.pendingMessages);
773
+ if (limit < MIN_PAGE_CHARS) return undefined;
774
+ const handoff = buildAutoHandoff(event.branchEntries, limit);
775
+ if (handoff.length > limit) return undefined;
776
+ return { newContext: { handoff } };
777
+ });
778
+
779
+ pi.registerTool({
780
+ name: "new_context",
781
+ label: "New Context",
782
+ description:
783
+ "Start a genuinely fresh context window after this tool batch. Earlier conversation leaves active context without a generated summary but remains recoverable through history. Pass concise continuation state in handoff, or save richer state with notes first.",
784
+ promptSnippet: "start a fresh context window with an optional atomic handoff",
785
+ promptGuidelines: [
786
+ "Before calling new_context, pass concise continuation state in handoff or save durable goal/progress/decisions/next-steps with notes",
787
+ ],
788
+ parameters: Type.Object({
789
+ handoff: Type.Optional(
790
+ Type.String({
791
+ description: "Concise state the fresh window needs to continue correctly",
792
+ maxLength: MAX_HANDOFF_CHARS,
793
+ }),
794
+ ),
795
+ }),
796
+ async execute(_id, { handoff }, _signal, _onUpdate, ctx) {
797
+ const native = nativeContext(ctx);
798
+ const trimmed = handoff?.trim() || undefined;
799
+ const limit = freshPayloadChars(native, activeToolTokens());
800
+ if (trimmed && trimmed.length > limit) {
801
+ throw new Error(
802
+ `Handoff is too large for the active model (${trimmed.length.toLocaleString("en-US")} characters; limit ${limit.toLocaleString("en-US")}). Save fuller state in notes, then retry with a shorter handoff or no handoff.`,
803
+ );
804
+ }
805
+ return {
806
+ ...textResult(
807
+ "Requested a fresh Pi context after this complete tool batch succeeds. Earlier conversation stays in session history.",
808
+ ),
809
+ newContext: { handoff: trimmed },
810
+ };
811
+ },
812
+ });
813
+
814
+ pi.registerTool({
815
+ name: "get_context_remaining",
816
+ label: "Context Remaining",
817
+ description:
818
+ "Best available native estimate of the context budget: tokens until Pi's automatic rollover line and until the model's hard limit.",
819
+ promptSnippet: "check the remaining context budget only when needed",
820
+ parameters: Type.Object({}),
821
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
822
+ const native = nativeContext(ctx);
823
+ const usage = native.getContextUsage();
824
+ if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.");
825
+ const n = (value: number) => value.toLocaleString("en-US");
826
+ const budget = budgetFor(native, usage.contextWindow);
827
+ const hard = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens until the hard context limit (${n(usage.tokens)}/${n(usage.contextWindow)} used, ${Math.round(usage.percent ?? 0)}%). Best available native estimate.`;
828
+ if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${hard}`);
829
+ if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${hard}`);
830
+ return textResult(
831
+ `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${hard}`,
832
+ );
833
+ },
834
+ });
835
+
836
+ pi.registerTool({
837
+ name: "notes",
838
+ label: "Notes",
839
+ description:
840
+ "Persistent notes in .pi/notes/ that survive context resets. Ops: list, read (paged; pass offset to continue), write (create/replace; empty content clears), append, search (case-insensitive substring over note lines). Inside a Git repository, including nested directories and linked worktrees, notes belong to the main checkout.",
841
+ promptSnippet: "save and recall durable state that survives context resets",
842
+ promptGuidelines: [
843
+ "Use notes for durable state too large for a new_context handoff",
844
+ "Reload relevant notes after a context rollover",
845
+ ],
846
+ parameters: Type.Object({
847
+ op: Type.Union(
848
+ [Type.Literal("list"), Type.Literal("read"), Type.Literal("write"), Type.Literal("append"), Type.Literal("search")],
849
+ { description: "Operation to perform" },
850
+ ),
851
+ path: Type.Optional(Type.String({ description: "Note path relative to .pi/notes/ (read/write/append)" })),
852
+ content: Type.Optional(Type.String({ description: "Full file content (write) or text to add (append)" })),
853
+ query: Type.Optional(Type.String({ description: "Substring to find in notes (search)" })),
854
+ offset: Type.Optional(Type.Integer({ description: "Character offset for read (default 0)", minimum: 0 })),
855
+ }),
856
+ async execute(_id, params, _signal, _onUpdate, ctx) {
857
+ const dir = join(notesRoot(ctx.cwd), ".pi", "notes");
858
+ const safeJoin = (path: string) => {
859
+ const relative = normalize(path.replace(/^[/\\]+/, ""));
860
+ if (relative === ".." || relative.startsWith(`..${sep}`) || isAbsolute(relative)) {
861
+ throw new Error(`Invalid path "${path}": must stay inside .pi/notes/.`);
862
+ }
863
+ return join(dir, relative);
864
+ };
865
+ const walk = (directory: string, output: string[]) => {
866
+ for (const file of readdirSync(directory)) {
867
+ const path = join(directory, file);
868
+ if (statSync(path).isDirectory()) walk(path, output);
869
+ else output.push(path);
870
+ }
871
+ };
872
+
873
+ switch (params.op) {
874
+ case "list": {
875
+ if (!existsSync(dir)) return textResult("(no notes yet)");
876
+ const files: string[] = [];
877
+ walk(dir, files);
878
+ return textResult(files.length ? files.map((file) => file.slice(dir.length + 1)).join("\n") : "(no notes yet)");
879
+ }
880
+ case "read": {
881
+ const relative = requireValue(params.path, "path", params.op);
882
+ const path = safeJoin(relative);
883
+ if (!existsSync(path)) throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
884
+ const text = readFileSync(path, "utf8");
885
+ const offset = params.offset ?? 0;
886
+ if (offset && offset >= text.length) {
887
+ throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
888
+ }
889
+ const end = Math.min(text.length, offset + requirePage(nativeContext(ctx), offset, 0, activeToolTokens()));
890
+ const more =
891
+ end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
892
+ return textResult(`${text.slice(offset, end)}${more}`);
893
+ }
894
+ case "write": {
895
+ const relative = requireValue(params.path, "path", params.op);
896
+ if (params.content === undefined) throw new Error(`"content" is required for op "write" (use "" to clear a note).`);
897
+ const path = safeJoin(relative);
898
+ mkdirSync(dirname(path), { recursive: true });
899
+ writeFileSync(path, params.content);
900
+ return textResult(`Wrote .pi/notes/${relative}`);
901
+ }
902
+ case "append": {
903
+ const relative = requireValue(params.path, "path", params.op);
904
+ const content = requireValue(params.content, "content", params.op);
905
+ const path = safeJoin(relative);
906
+ mkdirSync(dirname(path), { recursive: true });
907
+ // One O_APPEND write per newline-terminated record, so concurrent Pi processes appending to a
908
+ // shared note never merge records. The separator only matters after a write that left no trailing
909
+ // newline; a torn read of another process's in-flight append costs at most one blank line.
910
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
911
+ const separator = existing && !existing.endsWith("\n") ? "\n" : "";
912
+ appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
913
+ return textResult(`Appended to .pi/notes/${relative}`);
914
+ }
915
+ case "search": {
916
+ const query = requireValue(params.query, "query", params.op).toLowerCase();
917
+ if (!existsSync(dir)) return textResult("(no notes yet)");
918
+ const files: string[] = [];
919
+ walk(dir, files);
920
+ const hits: string[] = [];
921
+ for (const file of files) {
922
+ for (const [index, line] of readFileSync(file, "utf8").split("\n").entries()) {
923
+ if (hits.length >= 20) break;
924
+ const trimmed = line.trim();
925
+ const match = trimmed.toLowerCase().indexOf(query);
926
+ if (match !== -1) {
927
+ hits.push(`${file.slice(dir.length + 1)}:${index + 1}: ${excerptAround(trimmed, match, 50, 200)}`);
928
+ }
929
+ }
930
+ }
931
+ return textResult(hits.length ? hits.join("\n") : `No notes match "${params.query}".`);
932
+ }
933
+ }
934
+ },
935
+ });
936
+
937
+ pi.registerTool({
938
+ name: "history",
939
+ label: "History",
940
+ description:
941
+ "Search or read normalized session entries, including earlier native context windows. Search prioritizes original content before recovery notes, handoffs, and history lookups; all remain searchable. Current branch by default; all=true searches every project session file. Within each group: newest-modified sessions first, newest entries per session. Reads return stored images and page long text with the next offset.",
942
+ promptSnippet: "recover earlier conversation that left the active context window",
943
+ promptGuidelines: ["Use history search first, then history read with the returned entry id"],
944
+ parameters: Type.Object({
945
+ op: Type.Union([Type.Literal("search"), Type.Literal("read")], { description: "Operation to perform" }),
946
+ query: Type.Optional(Type.String({ description: "Case-insensitive text to find (search)" })),
947
+ id: Type.Optional(Type.String({ description: "Entry id returned by search (read)" })),
948
+ all: Type.Optional(Type.Boolean({ description: "Search all project sessions instead of the current branch" })),
949
+ limit: Type.Optional(Type.Integer({ description: "Maximum results (default 10, max 50)", minimum: 1, maximum: 50 })),
950
+ offset: Type.Optional(Type.Integer({ description: "Character offset for read (default 0)", minimum: 0 })),
951
+ }),
952
+ async execute(_id, params, signal, _onUpdate, ctx) {
953
+ const manager = ctx.sessionManager;
954
+
955
+ if (params.op === "search") {
956
+ const query = requireValue(params.query, "query", params.op).toLowerCase();
957
+ const limit = params.limit ?? 10;
958
+ const hits: string[][] = [[], []];
959
+ // Forks copy ancestor entries under the same ids into new session files; report each id once.
960
+ const seen = new Set<string>();
961
+ const addHit = (hit: HistoryHit | undefined) => {
962
+ if (!hit || seen.has(hit.id) || hits[hit.priority].length >= limit) return;
963
+ seen.add(hit.id);
964
+ hits[hit.priority].push(hit.text);
965
+ };
966
+
967
+ if (params.all) {
968
+ for (const file of sessionFiles(manager.getSessionDir())) {
969
+ const recent: HistoryHit[][] = [[], []];
970
+ const source = relative(manager.getSessionDir(), file);
971
+ for await (const item of sessionWindowEntries(file, signal)) {
972
+ if (seen.has(item.entry.id!)) continue;
973
+ const hit = historyHit(item, query, source);
974
+ if (!hit) continue;
975
+ const group = recent[hit.priority];
976
+ group.push(hit);
977
+ if (group.length > limit - hits[hit.priority].length) group.shift();
978
+ }
979
+ for (const group of recent) for (const hit of group.reverse()) addHit(hit);
980
+ if (hits[0].length >= limit) break;
981
+ }
982
+ } else {
983
+ const current = [...windowEntries(manager.getBranch() as EntryLike[])];
984
+ for (const item of current.reverse()) {
985
+ addHit(historyHit(item, query));
986
+ if (hits[0].length >= limit) break;
987
+ }
988
+ }
989
+ const results = hits.flat().slice(0, limit);
990
+ return textResult(results.length ? results.join("\n") : `No history matches "${params.query}".`);
991
+ }
992
+
993
+ const id = requireValue(params.id, "id", params.op);
994
+ const formatEntry = (item: WindowedEntry, source = "") => {
995
+ const offset = params.offset ?? 0;
996
+ if (offset >= item.text.length) {
997
+ throw new Error(`Offset ${offset} is past the end of history entry "${id}" (${item.text.length} chars).`);
998
+ }
999
+ const end = Math.min(
1000
+ item.text.length,
1001
+ offset + requirePage(nativeContext(ctx), offset, offset === 0 ? item.images.length : 0, activeToolTokens()),
1002
+ );
1003
+ const more = end < item.text.length ? `\nMore remains; call history read with id "${id}" and offset ${end}.` : "";
1004
+ // Stored images ride along with the first page only.
1005
+ return textResult(
1006
+ `${source ? `${source} ` : ""}${item.entry.timestamp ?? ""} [window ${item.windowId}] [${id}] [chars ${offset}-${end} of ${item.text.length}] ${item.text.slice(offset, end)}${more}`,
1007
+ offset === 0 ? item.images : [],
1008
+ );
1009
+ };
1010
+
1011
+ for (const item of windowEntries(manager.getBranch() as EntryLike[])) {
1012
+ if (item.entry.id === id) return formatEntry(item);
1013
+ }
1014
+ for (const file of sessionFiles(manager.getSessionDir())) {
1015
+ for await (const item of sessionWindowEntries(file, signal)) {
1016
+ if (item.entry.id === id) return formatEntry(item, relative(manager.getSessionDir(), file));
1017
+ }
1018
+ }
1019
+ throw new Error(`No history entry with id "${id}".`);
1020
+ },
1021
+ });
1022
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "pi-posthorse",
3
+ "version": "0.4.2",
4
+ "type": "module",
5
+ "description": "Posthorse: fresh context, same journey. Native no-summary context windows for the fitchmultz/pi fork, with sparse budget reminders, handoffs, notes, and history.",
6
+ "keywords": ["pi-package", "pi-extension", "context", "posthorse", "rollover"],
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/fitchmultz/pi-posthorse.git"
10
+ },
11
+ "license": "MIT",
12
+ "files": ["index.ts"],
13
+ "engines": {
14
+ "node": ">=22.19.0"
15
+ },
16
+ "pi": {
17
+ "extensions": ["./index.ts"],
18
+ "image": "https://raw.githubusercontent.com/fitchmultz/pi-posthorse/main/diagram.png"
19
+ },
20
+ "scripts": {
21
+ "check": "tsc --noEmit",
22
+ "test": "node --test test/posthorse.test.ts",
23
+ "prepublishOnly": "npm test && npm run check"
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-coding-agent": "*",
27
+ "typebox": "*"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "@earendil-works/pi-coding-agent": {
31
+ "optional": true
32
+ },
33
+ "typebox": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "devDependencies": {
38
+ "@earendil-works/pi-coding-agent": "0.85.0",
39
+ "typebox": "1.3.7",
40
+ "typescript": "^5.5.0"
41
+ }
42
+ }