pi-posthorse 0.7.0 → 0.8.1

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/index.ts CHANGED
@@ -1,1597 +1,52 @@
1
1
  /**
2
2
  * Posthorse — fresh context, same journey.
3
3
  *
4
- * No-summary context rollover for Pi. On the fitchmultz/pi fork, Pi persists native context
5
- * windows; on official Pi, rollover is a retain-none compaction whose summary is the handoff.
6
- * Pi owns the persisted boundary. Posthorse owns the policy: sparse budget reminders, rollover
7
- * tools, durable notes, and history recovery.
4
+ * Pi owns persisted retain-none context boundaries. Posthorse owns reminder and
5
+ * rollover policy, bounded recovery, durable notes, and history retrieval.
8
6
  */
9
-
10
- import { execFile } from "node:child_process";
11
- import { createHash, randomUUID } from "node:crypto";
12
- import {
13
- appendFileSync,
14
- createReadStream,
15
- existsSync,
16
- mkdirSync,
17
- readdirSync,
18
- readFileSync,
19
- realpathSync,
20
- statSync,
21
- } from "node:fs";
22
- import { access, constants, lstat, open, readlink, realpath, rename, stat, unlink } from "node:fs/promises";
23
- import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
24
- import { promisify } from "node:util";
25
- import { estimateTokens, formatSize, getAgentDir, SettingsManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
26
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
27
- import { Type } from "typebox";
28
- import { registerPosthorseMessages, toolCards, type PosthorseDisplay } from "./ui.ts";
29
-
30
- const REMINDER_BUFFER_TOKENS = 32_000;
31
- /** Absolute ceilings; handoffs and pages shrink to the live remaining budget. */
32
- const MAX_HANDOFF_CHARS = 20_000;
33
- const MAX_PAGE_CHARS = 40_000;
34
- const MAX_RECOVERY_RECORD_CHARS = 4_000;
35
- const MAX_RECOVERY_RESULT_CHARS = 1_500;
36
- const HANDOFF_OVERHEAD_RESERVE = 1_000;
37
- const PAGE_MARGIN_TOKENS = 1_000;
38
- const MIN_PAGE_CHARS = 1_000;
39
- const ESTIMATED_IMAGE_CHARS = 4_800;
40
- /** A window must hold the largest handoff (~5,000 tokens) plus equal working room below Pi's line. */
41
- const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
42
- const REMINDER_TYPE = "posthorse-reminder";
43
- const AUTO_HANDOFF_PREFIX = "Automatic context rollover recovery record.";
44
- const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before continuing.";
45
-
46
- type CompactionPolicy = { enabled: boolean; reserveTokens: number };
47
- type ContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
48
-
49
- /** The fork exposes live settings on the context; official Pi gets them from the injected policy. */
50
- type PolicyContext = {
51
- model?: { contextWindow: number };
52
- getCompactionSettings(): CompactionPolicy;
53
- getContextUsage(): ContextUsage | undefined;
54
- getSystemPrompt(): string;
55
- };
56
-
57
- /** Fork-only extension API; the published Pi types do not declare it. */
58
- type NativeExtensionAPI = {
59
- registerContextWindowHook(
60
- handler: (
61
- event: { contextEntries: ProjectedEntry[]; pendingMessages: MessageLike[] },
62
- ctx: ExtensionContext,
63
- ) => ReceiptEdit[] | undefined,
64
- ): void;
65
- on(
66
- event: "session_before_auto_compact",
67
- handler: (
68
- event: { reason: "overflow" | "threshold"; branchEntries: EntryLike[]; pendingMessages?: MessageLike[]; retainedToolResultIds: string[] },
69
- ctx: unknown,
70
- ) => { newContext: { handoff?: string } } | undefined,
71
- ): void;
72
- };
73
-
74
- type ImageLike = { type: "image"; data: string; mimeType: string };
75
- type ToolCallLike = {
76
- type?: string;
77
- id?: string;
78
- name?: string;
79
- namespace?: string;
80
- arguments?: unknown;
81
- executionArguments?: unknown;
82
- async?: boolean;
83
- };
84
- type MessageLike = {
85
- role?: string;
86
- stopReason?: string;
87
- errorMessage?: string;
88
- content?: unknown;
89
- summary?: string;
90
- toolName?: string;
91
- namespace?: string;
92
- toolCallId?: string;
93
- isError?: boolean;
94
- command?: string;
95
- output?: string;
96
- excludeFromContext?: boolean;
97
- };
98
- type EntryLike = {
99
- type?: string;
100
- id?: string;
101
- parentId?: string | null;
102
- timestamp?: string;
103
- message?: MessageLike;
104
- summary?: string;
105
- customType?: string;
106
- content?: unknown;
107
- details?: unknown;
108
- display?: boolean;
109
- handoff?: string;
110
- firstKeptEntryId?: string;
111
- targetId?: string;
112
- replacement?: { content: unknown } | null;
113
- };
114
-
115
- type WindowedEntry = { entry: EntryLike; windowId: string; text: string; images: ImageLike[] };
116
- type ProjectedEntry = { sourceEntry: EntryLike; messages: MessageLike[] };
117
- type ReceiptEdit = { type: "context_edit"; targetId: string; replacement: { content: string } };
118
- type HistoryHit = { id: string; text: string; headerLength: number; priority: 0 | 1 };
119
- type RecoveryRecord = {
120
- id: string;
121
- timestamp: string;
122
- kind: "owner" | "coordination";
123
- label: string;
124
- text: string;
125
- };
126
- type ReminderFingerprint = { windowId: string; contextWindow?: number; reserveTokens?: number };
127
- type NoteFile = { path: string; size: number; mtimeMs: number };
128
- type Budget = {
129
- contextWindow: number;
130
- reserveTokens: number;
131
- enabled: boolean;
132
- usable: number;
133
- rolloverAt: number;
134
- supported: boolean;
135
- };
136
-
137
- /** A fork context window, or the retain-none compaction Posthorse commits on official Pi. */
138
- function isWindow(entry: EntryLike): boolean {
139
- return entry.type === "context_window" || (entry.type === "compaction" && (entry.details as { posthorse?: unknown } | undefined)?.posthorse === 1);
140
- }
141
-
142
- function windowHandoff(entry: EntryLike | undefined): string | undefined {
143
- return entry?.type === "compaction" ? entry.summary : entry?.handoff;
144
- }
145
-
146
- /** First branch entry still in model context: after a fork window, or a compaction's kept tail (including Pi's own `/compact`). */
147
- function contextStart(entries: readonly EntryLike[]): { start: number; boundary?: EntryLike } {
148
- for (let i = entries.length - 1; i >= 0; i--) {
149
- const entry = entries[i];
150
- if (entry.type === "context_window") return { start: i + 1, boundary: entry };
151
- if (entry.type === "compaction") {
152
- const kept = entries.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
153
- return { start: kept >= 0 ? kept : i + 1, boundary: entry };
154
- }
155
- }
156
- return { start: 0 };
157
- }
158
-
159
- function isReminderType(customType: unknown): boolean {
160
- return customType === REMINDER_TYPE;
161
- }
162
-
163
- /** Resolve each link like the fork's publisher: never normalize away a path constraint. */
164
- async function resolveFileTarget(absolutePath: string): Promise<string> {
165
- let target = absolutePath;
166
- const links = new Set<string>();
167
- for (;;) {
168
- // A trailing separator requires an existing directory, never a new regular file.
169
- if (target.endsWith(sep) || target.endsWith("/")) {
170
- await stat(target);
171
- return realpath(target);
172
- }
173
- const parentInput = dirname(target);
174
- // realpath alone can accept regular-file/.. on macOS; stat checks traversal.
175
- if (!(await stat(parentInput)).isDirectory()) {
176
- throw Object.assign(new Error(`Not a directory: ${parentInput}`), { code: "ENOTDIR" });
177
- }
178
- const parent = await realpath(parentInput);
179
- target = join(parent, basename(target));
180
- const info = await lstat(target).catch((error: NodeJS.ErrnoException) => {
181
- if (error.code !== "ENOENT") throw error;
182
- return undefined;
183
- });
184
- if (!info) return target;
185
- if (!info.isSymbolicLink()) return realpath(target);
186
- if (links.has(target)) throw Object.assign(new Error(`Symlink cycle: ${absolutePath}`), { code: "ELOOP" });
187
- links.add(target);
188
- const link = await readlink(target);
189
- // Preserve ".." until realpath has followed any preceding directory symlinks.
190
- target = isAbsolute(link) ? link : `${parent}${sep}${link}`;
191
- }
192
- }
193
-
194
- /** Same-directory replacement: a failure before rename leaves the previous note; mode/uid/gid are kept. */
195
- async function publishFile(path: string, content: string, signal?: AbortSignal): Promise<void> {
196
- signal?.throwIfAborted();
197
- const target = await resolveFileTarget(path);
198
- const previous = await lstat(target).catch((error: NodeJS.ErrnoException) => {
199
- if (error.code !== "ENOENT") throw error;
200
- return undefined;
201
- });
202
- if (previous && !previous.isFile()) {
203
- throw Object.assign(new Error(`Cannot publish to a non-regular file: ${target}`), { code: previous.isDirectory() ? "EISDIR" : "EINVAL", path: target });
204
- }
205
- if (previous) await access(target, constants.W_OK);
206
- const temporary = join(dirname(target), `.posthorse-${randomUUID()}.tmp`);
207
- const file = await open(temporary, "wx", previous ? 0o600 : 0o666);
208
- try {
209
- await file.writeFile(content, { signal });
210
- if (previous) {
211
- const staged = await file.stat();
212
- if (staged.uid !== previous.uid || staged.gid !== previous.gid) await file.chown(previous.uid, previous.gid);
213
- await file.chmod(previous.mode & 0o777);
214
- }
215
- await file.close();
216
- signal?.throwIfAborted();
217
- await rename(temporary, target);
218
- } catch (error) {
219
- await file.close().catch(() => {});
220
- await unlink(temporary).catch(() => {});
221
- throw error;
222
- }
223
- }
224
-
225
- function isImage(part: unknown): part is ImageLike {
226
- const block = part as Partial<ImageLike> | null;
227
- return typeof block === "object" && block !== null && block.type === "image" && typeof block.data === "string";
228
- }
229
-
230
- function imagesOf(content: unknown): ImageLike[] {
231
- return Array.isArray(content) ? content.filter(isImage) : [];
232
- }
233
-
234
- function imageSummary(images: ImageLike[]): string {
235
- if (!images.length) return "";
236
- const types = [...new Set(images.map((image) => image.mimeType || "unknown type"))].join(", ");
237
- return `[${images.length} image${images.length === 1 ? "" : "s"}: ${types}]`;
238
- }
239
-
240
- function safeJsonStringify(value: unknown): string {
241
- try {
242
- return JSON.stringify(value) ?? "undefined";
243
- } catch {
244
- return "[unserializable]";
245
- }
246
- }
247
-
248
- function toolIdentity(name?: string, namespace?: string): string {
249
- return `${name ?? "tool"}${namespace === undefined ? "" : ` (namespace: ${safeJsonStringify(namespace)})`}`;
250
- }
251
-
252
- function executionArgumentsText(call: ToolCallLike): string {
253
- return call.executionArguments === undefined ? "" : `\nExecution arguments: ${safeJsonStringify(call.executionArguments)}`;
254
- }
255
-
256
- function textOf(message: MessageLike): string {
257
- if (typeof message.content === "string") return message.content;
258
- if (!Array.isArray(message.content)) return "";
259
- return message.content
260
- .map((part) => {
261
- if (!part || typeof part !== "object") return "";
262
- const block = part as ToolCallLike & { text?: string; thinking?: string };
263
- if (block.type === "text") return block.text ?? "";
264
- if (block.type === "thinking") return block.thinking ?? "";
265
- if (block.type === "toolCall") {
266
- return `${toolIdentity(block.name, block.namespace)} ${safeJsonStringify(block.arguments ?? {})}${block.id === undefined ? "" : `\nCall ID: ${block.id}`}${executionArgumentsText(block)}`;
267
- }
268
- return "";
269
- })
270
- .filter(Boolean)
271
- .join("\n");
272
- }
273
-
274
- function textResult(text: string, images: ImageLike[] = [], display?: PosthorseDisplay) {
275
- return { content: [{ type: "text" as const, text }, ...images], details: display };
276
- }
277
-
278
- /** Conventional repos use the main checkout; separate Git directories are their own shared root. */
279
- function notesRoot(cwd: string): string {
280
- for (let dir = cwd; ; dir = dirname(dir)) {
281
- const marker = join(dir, ".git");
282
- if (existsSync(marker)) {
283
- try {
284
- let target = marker;
285
- if (!statSync(marker).isDirectory()) {
286
- const gitdir = readFileSync(marker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
287
- if (!gitdir) return dir;
288
- target = resolve(dir, gitdir);
289
- }
290
- target = realpathSync(target);
291
- const commonFile = join(target, "commondir");
292
- const common = realpathSync(existsSync(commonFile) ? resolve(target, readFileSync(commonFile, "utf8").trim()) : target);
293
- return basename(common) === ".git" ? dirname(common) : common;
294
- } catch {
295
- // Orphaned/copied worktrees can outlive their Git metadata; local notes still work.
296
- return dir;
297
- }
298
- }
299
- if (dirname(dir) === dir) return cwd;
300
- }
301
- }
302
-
303
- function requireValue(value: string | undefined, name: string, op: string): string {
304
- if (value === undefined || value === "") throw new Error(`"${name}" is required for op "${op}".`);
305
- return value;
306
- }
307
-
308
- function excerptAround(text: string, index: number, before: number, length: number): string {
309
- const start = Math.max(0, index - before);
310
- const end = Math.min(text.length, start + length);
311
- return `${start ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
312
- }
313
-
314
- function assistantFailure(message: MessageLike): string {
315
- if (message.role !== "assistant") return "";
316
- if (message.stopReason !== "error" && message.stopReason !== "aborted" && !message.errorMessage) return "";
317
- return `[${message.stopReason ?? "error"}]${message.errorMessage ? ` ${message.errorMessage}` : ""}`;
318
- }
319
-
320
- function flattenEntry(entry: EntryLike): string | undefined {
321
- if (entry.type === "message") {
322
- const message = entry.message ?? {};
323
- if (message.role === "bashExecution") {
324
- // Pi keeps these out of every model's input on purpose; do not smuggle them back in through history.
325
- if (message.excludeFromContext === true) return "[bashExecution] (excluded from model context by Pi)";
326
- return `[bashExecution] $ ${message.command ?? ""}\n${message.output ?? ""}`;
327
- }
328
- const resultIdentity = message.role === "toolResult"
329
- ? `Tool: ${toolIdentity(message.toolName, message.namespace)}${message.toolCallId === undefined ? "" : `\nCall ID: ${message.toolCallId}`}`
330
- : "";
331
- return `[${message.role ?? "message"}] ${[textOf(message), assistantFailure(message), imageSummary(imagesOf(message.content)), resultIdentity].filter(Boolean).join("\n")}`;
332
- }
333
- if (entry.type === "compaction" || entry.type === "branch_summary") {
334
- return `[${entry.type}] ${entry.summary ?? ""}`;
335
- }
336
- if (entry.type === "custom_message") {
337
- return `[custom:${entry.customType ?? "unknown"}] ${[textOf({ content: entry.content }), imageSummary(imagesOf(entry.content))].filter(Boolean).join("\n")}`;
338
- }
339
- if (entry.type === "context_window") {
340
- return `[context_window] ${entry.handoff ? `Handoff: ${entry.handoff}` : "No handoff"}`;
341
- }
342
- if (entry.type === "context_edit" && entry.replacement) {
343
- const content = entry.replacement.content;
344
- return `[context_edit] ${[textOf({ content }), imageSummary(imagesOf(content))].filter(Boolean).join("\n")}`;
345
- }
346
- return undefined;
347
- }
348
-
349
- function isRecoveryTool(name: unknown): boolean {
350
- return name === "history" || name === "notes" || name === "new_context";
351
- }
352
-
353
- function isRecoveryCall(part: unknown): boolean {
354
- const block = part as { type?: string; name?: string } | null;
355
- return block?.type === "toolCall" && isRecoveryTool(block.name);
356
- }
357
-
358
- /** Ten base64url characters of SHA-256; 0.6 file keys were the whole digest, which starts with these. */
359
- function shortKey(text: string): string {
360
- return createHash("sha256").update(text).digest("base64url").slice(0, 10);
361
- }
362
-
363
- /** Nested session files are subagent runs, whose user-role input came from a parent agent. */
364
- function sourceTag(source: string): string {
365
- return source.includes(sep) ? " [subagent]" : "";
366
- }
367
-
368
- function contextEdits(entries: readonly EntryLike[]): Map<string, EntryLike> {
369
- const edits = new Map<string, EntryLike>();
370
- for (const entry of entries) {
371
- if (entry.type === "context_edit" && entry.targetId) edits.set(entry.targetId, entry);
372
- }
373
- return edits;
374
- }
375
-
376
- function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit | undefined {
377
- const { entry } = item;
378
- let text = item.text;
379
- let matchIndex = text.toLowerCase().indexOf(query);
380
- if (matchIndex === -1) return undefined;
381
- let priority: 0 | 1 =
382
- entry.type === "context_window" ||
383
- entry.type === "compaction" ||
384
- entry.type === "branch_summary" ||
385
- (entry.type === "custom_message" && isReminderType(entry.customType)) ||
386
- (entry.type === "message" && entry.message?.role === "toolResult" && isRecoveryTool(entry.message.toolName))
387
- ? 1
388
- : 0;
389
-
390
- if (
391
- entry.type === "message" && entry.message?.role === "assistant" &&
392
- Array.isArray(entry.message.content) && entry.message.content.some(isRecoveryCall)
393
- ) {
394
- // A lookup beside a matching ordinary call or prose must not demote that original content.
395
- const originals = [""];
396
- for (const part of entry.message.content) {
397
- if (isRecoveryCall(part)) {
398
- originals.push("");
399
- } else {
400
- const partText = textOf({ content: [part] });
401
- if (partText) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${partText}`;
402
- }
403
- }
404
- const metadata = [assistantFailure(entry.message), imageSummary(item.images)].filter(Boolean).join("\n");
405
- if (metadata) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${metadata}`;
406
- if (originals[0]) originals[0] = `[assistant] ${originals[0]}`;
407
- const original = originals.find((part) => part.toLowerCase().includes(query));
408
- priority = original ? 0 : 1;
409
- if (original) {
410
- text = original === originals[0] ? original : `[assistant] ${original}`;
411
- matchIndex = text.toLowerCase().indexOf(query);
412
- }
413
- }
414
- const id = source ? `${entry.id}@${shortKey(source)}` : entry.id!;
415
- const header = `${entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${id}] `;
416
- return {
417
- id,
418
- priority,
419
- text: `${header}${excerptAround(text, matchIndex, 100, 400)}`,
420
- headerLength: header.length,
421
- };
422
- }
423
-
424
- function toWindowedEntry(entry: EntryLike, windows: Map<string, string>): WindowedEntry | undefined {
425
- const inheritedWindow = entry.parentId ? (windows.get(entry.parentId) ?? "initial") : "initial";
426
- const windowId = isWindow(entry) && entry.id ? entry.id : inheritedWindow;
427
- if (entry.id) windows.set(entry.id, windowId);
428
- const text = flattenEntry(entry);
429
- if (!text || !entry.id) return undefined;
430
- const images = imagesOf(entry.type === "context_edit" ? entry.replacement?.content : entry.type === "message" ? entry.message?.content : entry.content);
431
- return { entry, windowId, text, images };
432
- }
433
-
434
- function* windowEntries(entries: Iterable<EntryLike>): Generator<WindowedEntry> {
435
- const windows = new Map<string, string>();
436
- for (const entry of entries) {
437
- const item = toWindowedEntry(entry, windows);
438
- if (item) yield item;
439
- }
440
- }
441
-
442
- /** Split on LF only: JSONL strings and notes can contain Unicode separators that Node's readline also splits on. */
443
- async function* lfLines(file: string, signal?: AbortSignal): AsyncGenerator<string> {
444
- const stream = createReadStream(file, { encoding: "utf8", signal });
445
- let pending: string[] = [];
446
- for await (const chunk of stream) {
447
- const lines = chunk.split("\n");
448
- pending.push(lines[0]);
449
- if (lines.length > 1) {
450
- yield pending.join("");
451
- yield* lines.slice(1, -1);
452
- pending = [lines.at(-1)!];
453
- }
454
- }
455
- const last = pending.join("");
456
- if (last) yield last;
457
- }
458
-
459
- /** Git's heuristic: a NUL byte in the first 8,000 bytes marks binary content. */
460
- async function isBinaryFile(path: string): Promise<boolean> {
461
- const file = await open(path, "r");
462
- try {
463
- const { buffer, bytesRead } = await file.read(Buffer.alloc(8_000), 0, 8_000, 0);
464
- return buffer.subarray(0, bytesRead).includes(0);
465
- } finally {
466
- await file.close();
467
- }
468
- }
469
-
470
- const execFileAsync = promisify(execFile);
471
- /**
472
- * ripgrep needles whose raw JSONL matches cover every normalized match, or undefined when only a full scan is exact.
473
- * Raw JSONL escapes quotes, backslashes, and control characters; flattenEntry adds brackets, parentheses, braces,
474
- * colons, `$`, spaces beside those labels, and label-only phrases. Image summaries ("3 images: image/png,
475
- * image/jpeg") come only from entries with an image block, so their text needs that block instead.
476
- */
477
- function prefilterNeedles(query: string): string[] | undefined {
478
- if (/["\\[\](){}:$\p{Cc}]/u.test(query) || query.trim() !== query) return undefined;
479
- if (["call id", "execution arguments", "no handoff", "excluded from model context by pi"].some((phrase) => phrase.includes(query))) return undefined;
480
- const image = query.includes(",") || /^\d+ ?i?m?a?g?e?s?$/.test(query) || ["images", "unknown type"].some((phrase) => phrase.includes(query));
481
- return image ? [query, '"type":"image"'] : [query];
482
- }
483
-
484
- /** Session files whose raw JSONL contains any needle in any case, via Pi's managed ripgrep or PATH; undefined means scan every file. */
485
- async function filesContaining(dir: string, needles: string[], signal?: AbortSignal): Promise<Set<string> | undefined> {
486
- const args = [
487
- "--files-with-matches", "--fixed-strings", "--ignore-case", "--no-ignore", "--hidden", "--no-messages",
488
- "--glob", "*.jsonl", ...needles.flatMap((needle) => ["--regexp", needle]), dir,
489
- ];
490
- for (const rg of [join(getAgentDir(), "bin", "rg"), "rg"]) {
491
- try {
492
- const { stdout } = await execFileAsync(rg, args, { signal, maxBuffer: 64 * 1024 * 1024 });
493
- return new Set(stdout.split("\n").filter(Boolean).map((file) => resolve(file)));
494
- } catch (error) {
495
- const { code } = error as { code?: unknown };
496
- if (code === 1) return new Set();
497
- if (code === "ENOENT") continue;
498
- signal?.throwIfAborted();
499
- return undefined;
500
- }
501
- }
502
- return undefined;
503
- }
504
-
505
- async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncGenerator<WindowedEntry> {
506
- if (!existsSync(file)) return;
507
- const windows = new Map<string, string>();
508
- for await (const line of lfLines(file, signal)) {
509
- let entry: EntryLike;
510
- try {
511
- entry = JSON.parse(line) as EntryLike;
512
- } catch {
513
- continue;
514
- }
515
- if (!entry) continue;
516
- const item = toWindowedEntry(entry, windows);
517
- if (item) yield item;
518
- }
519
- }
520
-
521
- function sessionFiles(dir: string): string[] {
522
- if (!existsSync(dir)) return [];
523
- return readdirSync(dir, { recursive: true, withFileTypes: true })
524
- .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl") && !entry.name.includes(".intent."))
525
- .map((entry) => join(entry.parentPath, entry.name));
526
- }
527
-
528
- /** Project session files, newest-modified first, optionally limited to one file key or to prefiltered candidates. */
529
- async function* scopedSessionFiles(
530
- dir: string, cwd: string, currentFile: string | undefined, signal?: AbortSignal,
531
- { fileKey, candidates }: { fileKey?: string; candidates?: Set<string> } = {},
532
- ): AsyncGenerator<string> {
533
- const files = sessionFiles(dir);
534
- const fileSet = new Set(files);
535
- const visible = new Map<string, boolean>();
536
- const belongs = async (file: string): Promise<boolean> => {
537
- const known = visible.get(file);
538
- if (known !== undefined) return known;
539
- let header: { cwd?: string } | null = null;
540
- for await (const line of lfLines(file, signal)) {
541
- try {
542
- const entry = JSON.parse(line) as { type?: string; id?: unknown; cwd?: unknown } | null;
543
- if (!entry) continue;
544
- if (entry.type === "session" && typeof entry.id === "string") header = { cwd: typeof entry.cwd === "string" ? entry.cwd : undefined };
545
- break;
546
- } catch {
547
- // Pi also skips malformed lines before the first parsed entry.
548
- }
549
- }
550
- if (!header) {
551
- visible.set(file, false);
552
- return false;
553
- }
554
- const parts = relative(dir, file).split(sep);
555
- let parent: string | undefined;
556
- for (let i = parts.length - 1; i > 0; i--) {
557
- const candidate = `${join(dir, ...parts.slice(0, i))}.jsonl`;
558
- if (fileSet.has(candidate)) {
559
- parent = candidate;
560
- break;
561
- }
562
- }
563
- const allowed = file === currentFile || (parent ? await belongs(parent) : !!header.cwd && resolve(header.cwd) === cwd);
564
- visible.set(file, allowed);
565
- return allowed;
566
- };
567
- const selected = files
568
- .filter((file) => (!candidates || candidates.has(resolve(file))) &&
569
- (fileKey === undefined || fileKey.startsWith(shortKey(relative(dir, file)))))
570
- .map((file) => ({ file, mtime: statSync(file).mtimeMs }))
571
- .sort((a, b) => b.mtime - a.mtime);
572
- for (const { file } of selected) {
573
- if (await belongs(file)) yield file;
574
- }
575
- }
576
-
577
- function currentWindowId(entries: readonly EntryLike[]): string {
578
- for (let i = entries.length - 1; i >= 0; i--) {
579
- const entry = entries[i];
580
- if (isWindow(entry) && entry.id) return entry.id;
581
- }
582
- return "initial";
583
- }
584
-
585
- function excerpt(text: string, limit: number): string {
586
- if (text.length <= limit) return text;
587
- const marker = "\n… middle omitted …\n";
588
- if (limit <= marker.length) return text.slice(0, limit);
589
- const head = Math.floor((limit - marker.length) / 2);
590
- return `${text.slice(0, head)}${marker}${text.slice(text.length - (limit - marker.length - head))}`;
591
- }
592
-
593
- function boundedBlock(header: string, text: string, limit: number): string {
594
- const textLimit = Math.max(0, limit - header.length - 1);
595
- return textLimit ? `${header}\n${excerpt(text, textLimit)}` : header;
596
- }
597
-
598
- function recoveryRecord(entry: EntryLike): RecoveryRecord | undefined {
599
- let kind: RecoveryRecord["kind"];
600
- let label: string;
601
- let text: string;
602
- let images: ImageLike[] = [];
603
- if (entry.type === "message" && entry.message?.role === "user") {
604
- kind = "owner";
605
- label = "owner input";
606
- text = textOf(entry.message).trim();
607
- images = imagesOf(entry.message.content);
608
- } else if (
609
- entry.type === "message" &&
610
- entry.message?.role === "toolResult" &&
611
- entry.message.toolName === "ask_question" &&
612
- entry.message.namespace === undefined &&
613
- entry.message.isError !== true
614
- ) {
615
- kind = "owner";
616
- label = "owner answer via ask_question";
617
- text = textOf(entry.message).trim();
618
- } else if (entry.type === "custom_message" && entry.display === true && !isReminderType(entry.customType)) {
619
- kind = "coordination";
620
- label = `visible ${(entry.customType ?? "custom").slice(0, 80)} coordination input (not direct owner input)`;
621
- text = textOf({ content: entry.content }).trim();
622
- images = imagesOf(entry.content);
623
- } else {
624
- return undefined;
625
- }
626
- const id = entry.id?.slice(0, 120) ?? "unknown";
627
- const summary = imageSummary(images);
628
- return {
629
- id,
630
- timestamp: entry.timestamp?.slice(0, 80) ?? "unknown time",
631
- kind,
632
- label,
633
- text: [text, summary && `${summary} — recover with history read id ${id}`].filter(Boolean).join("\n") || "(non-text content; recover the entry from history)",
634
- };
635
- }
636
-
637
- function formatRecoveryRecord(record: RecoveryRecord, limit: number): string {
638
- return boundedBlock(`[${record.label} | ${record.timestamp} | entry ${record.id}]`, record.text, limit);
639
- }
640
-
641
- /** Owner inputs keep a one-line preview: they are direct intent, and most are short. */
642
- function indexLine(record: RecoveryRecord): string {
643
- const header = formatRecoveryRecord(record, 0);
644
- if (record.kind !== "owner") return header;
645
- const preview = record.text.replace(/\s+/g, " ").trim();
646
- return `${header} ${preview.length > 120 ? `${preview.slice(0, 119)}…` : preview}`;
647
- }
648
-
649
- function formatPriorCheckpoint(entry: EntryLike | undefined, limit: number): string | undefined {
650
- const handoff = windowHandoff(entry)?.trim();
651
- if (!entry || !handoff) return undefined;
652
- const id = entry.id?.slice(0, 120) ?? "unknown";
653
- const header = `[older checkpoint; possibly stale | context-window entry ${id}]`;
654
- if (handoff.startsWith(AUTO_HANDOFF_PREFIX)) {
655
- return boundedBlock(header, `Prior automatic recovery text is not nested here. Use history read with entry ${id} if needed.`, limit);
656
- }
657
- return boundedBlock(header, handoff, limit);
658
- }
659
-
660
- /** Async receipts can arrive during a later response, so its completion cannot establish receipt delivery. */
661
- function recoverableToolResults(
662
- entries: readonly EntryLike[],
663
- ): { callId?: string; blocks: Array<{ header: string; text: string }> } | undefined {
664
- const calls = new Map<string, { entry: EntryLike; block: ToolCallLike }>();
665
- for (const entry of entries) {
666
- if (entry.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
667
- for (const block of entry.message.content as ToolCallLike[]) {
668
- if (block?.type === "toolCall" && typeof block.id === "string") calls.set(block.id, { entry, block });
669
- }
670
- }
671
- let trailingStart = -1;
672
- let hasTrailingResult = false;
673
- for (let i = entries.length - 1; i >= 0; i--) {
674
- const entry = entries[i];
675
- const role = entry.message?.role;
676
- if (role === "toolResult") {
677
- hasTrailingResult = true;
678
- } else if (role === "assistant") {
679
- const invalid =
680
- entry.message?.stopReason === "error" ||
681
- entry.message?.stopReason === "aborted" ||
682
- entry.message?.stopReason === "length";
683
- if (!invalid || hasTrailingResult) {
684
- trailingStart = i;
685
- break;
686
- }
687
- }
688
- }
689
- const results = entries.filter((entry, index) => {
690
- const message = entry.message;
691
- if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") return false;
692
- const call = calls.get(message.toolCallId);
693
- // Use only projected provenance. An orphan may be async; never resurrect its raw call.
694
- return index > trailingStart || !call || call.block.async === true;
695
- });
696
- if (!results.length) return undefined;
697
- const call = calls.get(results[0].message!.toolCallId!)?.entry;
698
- const allLinked = !!call && results.every((result) => calls.get(result.message!.toolCallId!)?.entry === call);
699
- const blocks = results.map((result) => {
700
- const message = result.message ?? {};
701
- const matching = calls.get(message.toolCallId!);
702
- const name = toolIdentity(message.toolName ?? matching?.block.name, message.namespace ?? matching?.block.namespace);
703
- const resultId = (result.id ?? "unknown").slice(0, 120);
704
- const images = imageSummary(imagesOf(message.content));
705
- const output =
706
- [textOf(message).trim(), images && `${images} — recover with history read id ${resultId}`]
707
- .filter(Boolean)
708
- .join("\n") || "(empty result)";
709
- const callDetail = matching
710
- ? `${allLinked ? "" : `\nCall entry: ${(matching.entry.id ?? "unknown").slice(0, 120)}`}\nCall arguments: ${safeJsonStringify(matching.block.arguments ?? {})}${executionArgumentsText(matching.block)}`
711
- : "\nNo matching projected call";
712
- return {
713
- header: `[${message.isError ? "error" : "result"} entry ${resultId}]`,
714
- text: `${output}\n\nTool: ${name}\nCall ID: ${message.toolCallId}${callDetail}`,
715
- };
716
- });
717
- return { callId: allLinked ? (call?.id ?? "unknown").slice(0, 120) : undefined, blocks };
718
- }
719
-
720
- function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly ProjectedEntry[], maxChars: number): string {
721
- const { start, boundary: priorWindow } = contextStart(entries);
722
- const current = entries.slice(start);
723
- const edits = contextEdits(entries);
724
- const omittedProjectedIds = new Set(projected.filter(({ messages }) => !messages.length).map(({ sourceEntry }) => sourceEntry.id));
725
- const edited = current.flatMap((entry) => {
726
- if (entry.id && omittedProjectedIds.has(entry.id)) return [];
727
- const edit = entry.id ? edits.get(entry.id) : undefined;
728
- if (edit?.replacement === null) return [];
729
- if (!edit?.replacement) return [entry];
730
- if (entry.type === "message") return [{ ...entry, id: edit.id, message: { ...entry.message, content: edit.replacement.content } }];
731
- if (entry.type === "custom_message") return [{ ...entry, id: edit.id, content: edit.replacement.content }];
732
- return [entry];
733
- });
734
- const records = edited
735
- .map((entry) => recoveryRecord(entry))
736
- .filter((record): record is RecoveryRecord => record !== undefined);
737
- const firstOwnerRequest = records.find((record) => record.label === "owner input") ?? records[0];
738
- const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
739
- const latestOverall = records.at(-1);
740
- const selected = new Set(
741
- [firstOwnerRequest, latestOwner, latestOverall].filter((record): record is RecoveryRecord => record !== undefined),
742
- );
743
- 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.`;
744
- const currentHeader = records.length
745
- ? "Current-window inputs (chronological):"
746
- : "No selected current-window owner or visible coordination inputs were found.";
747
- const joinedLength = (parts: Array<string | undefined>) =>
748
- parts.filter((part): part is string => Boolean(part)).join("\n\n").length;
749
-
750
- const toolEntries = projected.flatMap(({ sourceEntry, messages }) =>
751
- messages
752
- .filter((message) => message.role === "assistant" || message.role === "toolResult")
753
- .map((message) => ({
754
- type: "message",
755
- id: edits.get(sourceEntry.id ?? "")?.id ?? sourceEntry.id,
756
- message,
757
- })),
758
- );
759
- const toolBatch = recoverableToolResults(toolEntries);
760
- const batchHeader = toolBatch
761
- ? `Tool result evidence (current projected window; may already have been received or handled; not current progress).${toolBatch.callId ? ` Tool-call entry ${toolBatch.callId}:` : ""}`
762
- : undefined;
763
- const minimumParts = [
764
- preamble,
765
- currentHeader,
766
- ...records.filter((record) => selected.has(record)).map((record) => formatRecoveryRecord(record, 0)),
767
- batchHeader,
768
- formatPriorCheckpoint(priorWindow, 0),
769
- ];
770
- let headerBudget = Math.max(0, maxChars - joinedLength(minimumParts) - HANDOFF_OVERHEAD_RESERVE);
771
- let firstBatchBlock = toolBatch?.blocks.length ?? 0;
772
- while (firstBatchBlock > 0) {
773
- const length = toolBatch!.blocks[firstBatchBlock - 1].header.length + 2;
774
- if (length > headerBudget) break;
775
- headerBudget -= length;
776
- firstBatchBlock--;
777
- }
778
- const batchBlocks = toolBatch?.blocks.slice(firstBatchBlock) ?? [];
779
- const batchOmission = firstBatchBlock
780
- ? `Omitted ${firstBatchBlock} earlier tool result(s) whose headers could not fit. ${toolBatch!.callId ? `Use history read with tool-call entry ${toolBatch!.callId}, then ` : "Use "}history search/read to recover them.`
781
- : undefined;
782
- const bareBatch = batchHeader
783
- ? [batchHeader, batchOmission, ...batchBlocks.map((block) => block.header)]
784
- .filter((part): part is string => Boolean(part))
785
- .join("\n\n")
786
- : undefined;
787
- const fixedCount = selected.size + (windowHandoff(priorWindow)?.trim() ? 1 : 0);
788
- const fixedBudget = Math.max(
789
- 0,
790
- maxChars - joinedLength([preamble, currentHeader, bareBatch]) - HANDOFF_OVERHEAD_RESERVE,
791
- );
792
- const fixedLimit = fixedCount
793
- ? Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(fixedBudget / fixedCount))
794
- : MAX_RECOVERY_RECORD_CHARS;
795
- const prior = formatPriorCheckpoint(priorWindow, fixedLimit);
796
- const fixedParts = [
797
- preamble,
798
- currentHeader,
799
- ...records.filter((record) => selected.has(record)).map((record) => formatRecoveryRecord(record, fixedLimit)),
800
- ];
801
-
802
- let batch: string | undefined;
803
- if (batchHeader && bareBatch) {
804
- const availableText = Math.max(
8
+ import { registerPosthorseMessages } from "./ui.ts";
9
+ import { persistedPolicy, policySnapshot } from "./src/budget.ts";
10
+ import type { CompactionPolicy, PolicyReader } from "./src/contracts.ts";
11
+ import { registerHistory } from "./src/history.ts";
12
+ import { safeJsonStringify } from "./src/message-text.ts";
13
+ import { registerNotes } from "./src/notes.ts";
14
+ import { PageReservations } from "./src/paging.ts";
15
+ import { registerRollover } from "./src/rollover.ts";
16
+
17
+ function activeToolTokens(pi: ExtensionAPI): number {
18
+ const active = new Set(pi.getActiveTools());
19
+ return pi
20
+ .getAllTools()
21
+ .filter((tool) => active.has(tool.name))
22
+ .reduce(
23
+ (total, tool) =>
24
+ total +
25
+ Math.ceil(
26
+ safeJsonStringify({
27
+ name: tool.name,
28
+ description: tool.description,
29
+ parameters: tool.parameters,
30
+ }).length / 4,
31
+ ),
805
32
  0,
806
- maxChars -
807
- joinedLength([...fixedParts, bareBatch, prior]) -
808
- HANDOFF_OVERHEAD_RESERVE -
809
- batchBlocks.length,
810
33
  );
811
- const perBlock = Math.min(MAX_RECOVERY_RESULT_CHARS, Math.floor(availableText / batchBlocks.length));
812
- batch = [
813
- batchHeader,
814
- batchOmission,
815
- ...batchBlocks.map((block) => (perBlock ? `${block.header}\n${excerpt(block.text, perBlock)}` : block.header)),
816
- ]
817
- .filter((part): part is string => Boolean(part))
818
- .join("\n\n");
819
- }
820
-
821
- // Other inputs are indexed, newest kept first when space runs out; history read recovers their full text.
822
- const others = records.filter((record) => !selected.has(record));
823
- const indexHeader = "Other current-window inputs, oldest first (recover with history read):";
824
- let indexBudget = Math.max(0, maxChars - joinedLength([...fixedParts, indexHeader, batch, prior]) - HANDOFF_OVERHEAD_RESERVE);
825
- const listed: string[] = [];
826
- for (let i = others.length - 1; i >= 0; i--) {
827
- const line = indexLine(others[i]);
828
- if (line.length + 1 > indexBudget) break;
829
- listed.unshift(line);
830
- indexBudget -= line.length + 1;
831
- }
832
- const unlisted = others.length - listed.length;
833
- const index = others.length
834
- ? [indexHeader, ...(unlisted ? [`${unlisted} earlier input(s) not listed; use history search to find them.`] : []), ...listed].join("\n")
835
- : undefined;
836
- return [...fixedParts, index, batch, prior].filter((part): part is string => Boolean(part)).join("\n\n");
837
34
  }
838
-
839
- function reminderMatches(details: unknown, fingerprint: ReminderFingerprint): boolean {
840
- const stored = (details ?? {}) as Partial<ReminderFingerprint>;
841
- return (
842
- stored.windowId === fingerprint.windowId &&
843
- stored.contextWindow === fingerprint.contextWindow &&
844
- stored.reserveTokens === fingerprint.reserveTokens
845
- );
846
- }
847
-
848
- function hasReminder(entries: readonly EntryLike[], fingerprint: ReminderFingerprint): boolean {
849
- return entries.some(
850
- (entry) => entry.type === "custom_message" && isReminderType(entry.customType) && reminderMatches(entry.details, fingerprint),
851
- );
852
- }
853
-
854
- function budgetFor(ctx: PolicyContext, contextWindow = ctx.model?.contextWindow): Budget | undefined {
855
- if (!contextWindow || contextWindow <= 0) return undefined;
856
- const { enabled, reserveTokens } = ctx.getCompactionSettings();
857
- const usable = contextWindow - reserveTokens;
858
- return { contextWindow, reserveTokens, enabled, usable, rolloverAt: usable + 1, supported: !enabled || usable >= MIN_USABLE_TOKENS };
859
- }
860
-
861
- /** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
862
- function freshPayloadChars(ctx: PolicyContext, toolTokens: number, pendingMessages: readonly MessageLike[] = [], cap = MAX_HANDOFF_CHARS): number {
863
- const contextWindow = ctx.model?.contextWindow;
864
- if (!contextWindow || contextWindow <= 0) return cap;
865
- const budget = budgetFor(ctx, contextWindow);
866
- const line = budget?.enabled && budget.supported ? budget.rolloverAt : contextWindow;
867
- const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4);
868
- const pendingTokens = pendingMessages.reduce(
869
- (total, message) => total + Math.ceil(textOf(message).length / 4) + imagesOf(message.content).length * (ESTIMATED_IMAGE_CHARS / 4),
870
- 0,
871
- );
872
- return Math.min(
873
- cap,
874
- Math.max(0, Math.floor((line - PAGE_MARGIN_TOKENS - promptTokens - toolTokens - pendingTokens) / 2)) * 4,
875
- );
876
- }
877
-
878
- /** Bound only the carried receipts; edits preserve the journal and native call/result identity. */
879
- function boundWindowReceipts(
880
- ctx: ExtensionContext & PolicyContext,
881
- projected: readonly ProjectedEntry[],
882
- pending: readonly MessageLike[],
883
- toolTokens: number,
884
- ): ReceiptEdit[] | undefined {
885
- if (!ctx.model) return undefined;
886
- const estimate = estimateTokens as unknown as (message: MessageLike) => number;
887
- const messages = projected.flatMap((entry) => entry.messages);
888
- const budget = budgetFor(ctx);
889
- const reserve = budget?.enabled && budget.supported ? budget.reserveTokens : 0;
890
- const overhead =
891
- reserve +
892
- PAGE_MARGIN_TOKENS +
893
- Math.max(
894
- Math.ceil(ctx.getSystemPrompt().length / 4) + toolTokens,
895
- messages.filter((message) => message.role === "system").reduce((sum, message) => sum + estimate(message), 0),
896
- ) +
897
- pending.reduce((sum, message) => sum + estimate(message), 0);
898
- const fixed = messages.filter((message) => message.role !== "system" && message.role !== "toolResult");
899
- const available = ctx.model.contextWindow - overhead - fixed.reduce((sum, message) => sum + estimate(message), 0);
900
- const edits = contextEdits(ctx.sessionManager.getBranch() as EntryLike[]);
901
- const receipts = projected.flatMap(({ sourceEntry, messages }) =>
902
- messages.filter((message) => message.role === "toolResult").map((message) => {
903
- if (!sourceEntry.id) throw new Error("Posthorse: retained receipt has no history entry");
904
- const recoveryId = edits.get(sourceEntry.id)?.id ?? sourceEntry.id;
905
- const images = imageSummary(imagesOf(message.content));
906
- const header = `Retained ${message.isError ? "error" : "result"} excerpt. Full content: history read id ${recoveryId}.${images ? `\n${images}` : ""}\n`;
907
- const cost = estimate(message);
908
- const minimum = Math.min(cost, Math.ceil(header.length / 4));
909
- return { id: sourceEntry.id, message, header, cost, minimum };
910
- }),
911
- );
912
- if (receipts.reduce((sum, receipt) => sum + receipt.cost, 0) <= available) return undefined;
913
- let extra = available - receipts.reduce((sum, receipt) => sum + receipt.minimum, 0);
914
- if (extra < 0) throw new Error("Posthorse: fresh context cannot fit fixed input and receipt recovery references");
915
- // Like handoffs, oversized receipt excerpts leave half the remaining capacity for continued work.
916
- extra = Math.floor(extra / 2);
917
- const drafts: ReceiptEdit[] = [];
918
- receipts.sort((a, b) => a.cost - a.minimum - (b.cost - b.minimum));
919
- for (const [index, receipt] of receipts.entries()) {
920
- const allowance = receipt.minimum + Math.min(receipt.cost - receipt.minimum, Math.floor(extra / (receipts.length - index)));
921
- extra -= allowance - receipt.minimum;
922
- if (receipt.cost <= allowance) continue;
923
- const content = receipt.header + excerpt(textOf(receipt.message), allowance * 4 - receipt.header.length);
924
- drafts.push({ type: "context_edit", targetId: receipt.id, replacement: { content } });
925
- }
926
- const replacements = new Map(drafts.map((draft) => [draft.targetId, draft.replacement.content]));
927
- const total = projected.reduce(
928
- (sum, entry) =>
929
- sum + entry.messages.reduce(
930
- (tokens, message) => tokens + (message.role === "system" ? 0 : estimate(
931
- replacements.has(entry.sourceEntry.id ?? "")
932
- ? { ...message, content: replacements.get(entry.sourceEntry.id!) }
933
- : message,
934
- )),
935
- 0,
936
- ),
937
- overhead,
938
- );
939
- if (total > ctx.model.contextWindow) throw new Error("Posthorse: retained receipts exceed fresh context capacity");
940
- return drafts;
941
- }
942
-
943
- /** Official Pi cannot expose its live settings to extensions; budget reports say when they used the persisted snapshot. */
944
- const SNAPSHOT_NOTE = "Reminder and budget policy uses available settings (a persisted CLI snapshot by default); Pi's live settings control automatic compaction.";
945
- const CHECKPOINT_STEPS = "rewrite the task's current-state note in place with notes write (goal, progress, decisions, next steps), then call new_context";
946
-
947
- function unsupportedMessage(budget: Budget): string {
948
- const n = (value: number) => value.toLocaleString("en-US");
949
- 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 Posthorse rollover and checkpoint reminders are off for this model, so Pi's own compaction applies. Lower compaction.reserveTokens in Pi settings or use a larger-context model. new_context remains available with a model-aware handoff limit.`;
950
- }
951
-
952
- /** Capacity before Pi's automatic line (or configured context limit), including page metadata and refusals. */
953
- function pageCapacity(ctx: PolicyContext, toolTokens: number): number {
954
- const usage = ctx.getContextUsage();
955
- if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens, [], MAX_PAGE_CHARS) / 4 + PAGE_MARGIN_TOKENS;
956
- const budget = budgetFor(ctx, usage.contextWindow);
957
- const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
958
- return line - usage.tokens;
959
- }
960
-
961
- function buildGuidance(ctx: PolicyContext, official: boolean): string {
962
- const budget = budgetFor(ctx);
963
- const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
964
- let automatic: string;
965
- if (!enabled) {
966
- automatic = official
967
- ? "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available."
968
- : "Pi compaction is disabled, so Posthorse sends no checkpoint reminder and performs no automatic rollover. new_context remains available.";
969
- } else if (budget && !budget.supported) {
970
- automatic = unsupportedMessage(budget);
971
- } else {
972
- const deadline = budget
973
- ? `${Math.max(1, Math.round((budget.rolloverAt / budget.contextWindow) * 100))}% used`
974
- : "the configured Pi context limit";
975
- automatic = `Automatic rollover follows Pi's compaction setting. At most one checkpoint reminder may arrive before the rollover line (${deadline}); when it does, stop normal work, ${CHECKPOINT_STEPS}.`;
976
- }
977
- return `## Context self-management (Posthorse)
978
- ${automatic}
979
- After a rollover, earlier conversation stays in history. Restore notes and todos, then verify live state before stateful or external work; automatic handoffs record inputs, not progress.
980
- Write handoffs and notes as normal readable prose; notes and history search match literal text.`;
981
- }
982
-
983
- function persistedPolicy(ctx: ExtensionContext): CompactionPolicy {
984
- const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted: ctx.isProjectTrusted() });
985
- const errors = settings.drainErrors();
986
- if (errors.length) throw new Error(`Posthorse could not read persisted Pi settings: ${errors.map((error) => error.error.message).join("; ")}`);
987
- return settings.getCompactionSettings(ctx.model);
988
- }
989
-
990
- /** SDK hosts on official Pi can inject their live settings instead of the persisted snapshot; the fork always uses its own. */
991
- export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => CompactionPolicy = persistedPolicy) => (pi: ExtensionAPI) => {
992
- const nativePi = pi as unknown as NativeExtensionAPI;
993
- const native = typeof nativePi.registerContextWindowHook === "function";
994
- const policy = (ctx: ExtensionContext): PolicyContext =>
995
- native ? (ctx as ExtensionContext & PolicyContext) : {
996
- model: ctx.model,
997
- getCompactionSettings: () => getPolicy(ctx),
998
- getContextUsage: () => ctx.getContextUsage(),
999
- getSystemPrompt: () => ctx.getSystemPrompt(),
1000
- };
1001
- registerPosthorseMessages(pi);
1002
- const activeToolTokens = () => {
1003
- const active = new Set(pi.getActiveTools());
1004
- return pi
1005
- .getAllTools()
1006
- // The fork identifies namespaced tools by id; official Pi only has names.
1007
- .filter((tool) => active.has((tool as typeof tool & { id?: string }).id ?? tool.name))
1008
- .reduce(
1009
- (total, tool) =>
1010
- total +
1011
- Math.ceil(
1012
- safeJsonStringify({ name: tool.name, description: tool.description ?? "", parameters: tool.parameters })
1013
- .length / 4,
1014
- ),
1015
- 0,
1016
- );
1017
- };
1018
-
1019
- if (native) {
1020
- nativePi.registerContextWindowHook((event, ctx) =>
1021
- boundWindowReceipts(ctx as ExtensionContext & PolicyContext, event.contextEntries, event.pendingMessages, activeToolTokens()),
1022
- );
1023
- }
1024
-
1025
- let pendingPageTokens = 0;
1026
- let previousPageUsage: number | null | undefined;
1027
- const pageSize = (ctx: PolicyContext, offset: number, imageCount: number, cursor?: string, imageOffset?: number) => {
1028
- const usage = ctx.getContextUsage()?.tokens;
1029
- // Parallel siblings are not in native usage yet; sequential results must not be counted twice.
1030
- if (usage != null && previousPageUsage != null) {
1031
- pendingPageTokens = Math.max(0, pendingPageTokens - Math.max(0, usage - previousPageUsage));
1032
- }
1033
- previousPageUsage = usage;
1034
- const remaining = pageCapacity(ctx, activeToolTokens()) - pendingPageTokens;
1035
- const chars = Math.min(
1036
- MAX_PAGE_CHARS,
1037
- Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
1038
- );
1039
- if (chars < MIN_PAGE_CHARS) {
1040
- const message = `Too little context remains to read a page safely. Call new_context first, then retry ${cursor ? "with the same cursor" : `with offset ${offset}${imageOffset === undefined ? "" : ` and imageOffset ${imageOffset}`}`}.`;
1041
- // Refusals consume context too. Once even the guidance no longer fits, omit repeated text;
1042
- // the failed call still carries its original offset and earlier refusals explain the retry.
1043
- const text = Math.ceil(message.length / 4) <= remaining ? message : "";
1044
- pendingPageTokens += Math.ceil(text.length / 4);
1045
- throw new Error(text);
1046
- }
1047
- return chars;
1048
- };
1049
- const pageResult = (text: string, images: ImageLike[], display: PosthorseDisplay) => {
1050
- pendingPageTokens += Math.ceil(text.length / 4) + images.length * (ESTIMATED_IMAGE_CHARS / 4);
1051
- return textResult(text, images, display);
1052
- };
1053
- const pageError = (ctx: PolicyContext, message: string, cursor?: string): never => {
1054
- pageSize(ctx, 0, 0, cursor);
1055
- pendingPageTokens += Math.ceil(message.length / 4);
1056
- throw new Error(message);
1057
- };
1058
- const notesPage = (ctx: PolicyContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string) => {
1059
- const text = rows.length ? rows.join("\n") : empty;
1060
- const chars = pageSize(ctx, offset, 0);
1061
- if (offset && offset >= text.length) pageError(ctx, "Offset is past the end; restart with offset 0.");
1062
- // Reserve the complete continuation, even when one path or excerpt needs several pages.
1063
- const footer = (end: number) => `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`;
1064
- let end = Math.min(text.length, offset + chars);
1065
- let start = 0;
1066
- const starts: number[] = [];
1067
- const maxRows = kind === "notes-search" ? 20 : 100;
1068
- for (const row of rows) {
1069
- if (start >= end) break;
1070
- if (start + row.length > offset) starts.push(start);
1071
- start += row.length + 1;
1072
- if (starts.length === maxRows) { end = Math.min(end, start - 1); break; }
1073
- }
1074
- // A result-count cap can require continuation even when the character cap did not.
1075
- if (end < text.length) end = Math.min(end, offset + chars - footer(text.length).length);
1076
- return pageResult(text.slice(offset, end) + (end < text.length ? footer(end) : ""), [], { kind, count: starts.filter((start) => start < end).length, page: { offset, end, total: text.length } });
1077
- };
1078
- pi.on("turn_start", () => {
1079
- pendingPageTokens = 0;
1080
- previousPageUsage = undefined;
1081
- });
1082
-
1083
- pi.on("session_start", () => {
1084
- pendingPageTokens = 0;
1085
- previousPageUsage = undefined;
1086
- });
1087
-
1088
- pi.on("before_agent_start", (event, ctx) => {
1089
- const guidance = buildGuidance(policy(ctx), !native);
1090
- // An earlier full-prompt override makes section edits invisible to Pi.
1091
- if (event.systemPromptOptions.forceSystemPrompt !== undefined) {
1092
- return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` };
1093
- }
1094
- event.systemPromptOptions.sections.posthorse = guidance;
1095
- });
1096
-
1097
- pi.on("turn_end", (event, ctx) => {
1098
- const host = policy(ctx);
1099
- if (
1100
- event.message.role === "assistant" &&
1101
- (event.message.stopReason === "error" || event.message.stopReason === "aborted")
1102
- )
1103
- return;
1104
- const requested = event.toolResults.find((result) => result.toolName === "new_context");
1105
- // Suppress clear successes; turn_end can also include unrelated background errors.
1106
- // If Pi still rolls over, context filtering below removes any stale reminder.
1107
- if (requested && !event.toolResults.some((result) => result.isError)) {
1108
- // The fork commits new_context from the tool result; official Pi needs a retain-none compaction draft.
1109
- if (native || event.outcome === "aborted" || ctx.signal?.aborted) return;
1110
- const content = (event.message as MessageLike).content;
1111
- const call = (Array.isArray(content) ? content as ToolCallLike[] : []).find((block) => block?.type === "toolCall" && block.id === requested.toolCallId);
1112
- const handoff = (call?.arguments as { handoff?: unknown } | undefined)?.handoff;
1113
- const summary = (typeof handoff === "string" && handoff.trim()) || EMPTY_HANDOFF;
1114
- // Input steered in after execute() checked capacity can leave too little room.
1115
- if (summary.length > freshPayloadChars(host, activeToolTokens(), event.context.pendingMessages)) {
1116
- return { entries: [...event.entries, { type: "custom_message" as const, customType: "posthorse-reset-deferred", display: true,
1117
- content: "Posthorse reset deferred: queued messages leave too little room for the requested handoff. The current context is unchanged; process those messages, then request a shorter handoff." }] };
1118
- }
1119
- return { entries: [...event.entries, { type: "compaction" as const, summary, firstKeptEntryId: null, details: { posthorse: 1, reason: "explicit" } }], continue: true };
1120
- }
1121
- const usage = host.getContextUsage();
1122
- if (!usage || usage.tokens == null || usage.contextWindow <= 0) return;
1123
- const budget = budgetFor(host, usage.contextWindow);
1124
- if (!budget || !budget.enabled || !budget.supported) return;
1125
- if (usage.tokens >= budget.rolloverAt) return;
1126
- const reminderBuffer = Math.min(REMINDER_BUFFER_TOKENS, Math.floor(budget.usable * 0.1));
1127
- const remindAt = budget.rolloverAt - reminderBuffer;
1128
- if (usage.tokens < remindAt) return;
1129
-
1130
- const branch = ctx.sessionManager.getBranch() as EntryLike[];
1131
- const fingerprint: ReminderFingerprint = {
1132
- windowId: currentWindowId(branch),
1133
- contextWindow: budget.contextWindow,
1134
- reserveTokens: budget.reserveTokens,
1135
- };
1136
- // A reminder a compaction summarized away no longer reaches the model.
1137
- if (hasReminder(branch.slice(contextStart(branch).start), fingerprint)) return;
1138
- pi.sendMessage(
1139
- {
1140
- customType: REMINDER_TYPE,
1141
- content: `[posthorse] Checkpoint now: ${(budget.rolloverAt - usage.tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Stop normal work, ${CHECKPOINT_STEPS}.`,
1142
- display: true,
1143
- details: fingerprint,
1144
- },
1145
- { deliverAs: "steer" },
1146
- );
1147
- });
1148
-
1149
- // Filter active input only; raw reminders remain in history for deduplication and recovery.
1150
- pi.on("context", (event, ctx) => {
1151
- if (!event.messages.some((message) => message.role === "custom" && isReminderType(message.customType))) return;
1152
- // The fork's window marker saves a branch walk, but a compaction inside the window drops it.
1153
- const marker = event.messages.find((message) => message.role === "custom" && message.customType === "context-window");
1154
- const windowId = marker?.role === "custom"
1155
- ? (marker.details as { windowId?: unknown } | undefined)?.windowId
1156
- : currentWindowId(ctx.sessionManager.getBranch() as EntryLike[]);
1157
- if (typeof windowId !== "string") return;
1158
- const host = policy(ctx);
1159
- const budget = budgetFor(host);
1160
- const fingerprint: ReminderFingerprint = {
1161
- windowId,
1162
- contextWindow: budget?.contextWindow,
1163
- reserveTokens: budget?.reserveTokens,
1164
- };
1165
- const stale = (message: (typeof event.messages)[number]) =>
1166
- message.role === "custom" &&
1167
- isReminderType(message.customType) &&
1168
- (!host.getCompactionSettings().enabled || !reminderMatches(message.details, fingerprint));
1169
- if (event.messages.some(stale)) return { messages: event.messages.filter((message) => !stale(message)) };
1170
- });
1171
-
1172
- if (native) {
1173
- // Claim Pi's automatic threshold/overflow trigger with a fresh window: no summary, no summarization auth.
1174
- nativePi.on("session_before_auto_compact", (event, ctx) => {
1175
- const host = policy(ctx as ExtensionContext);
1176
- const budget = budgetFor(host);
1177
- // An unsupported budget would roll over every turn; leave Pi's own behavior in place instead.
1178
- if (budget && !budget.supported) return undefined;
1179
- const limit = freshPayloadChars(host, activeToolTokens(), event.pendingMessages);
1180
- const projected = (ctx as ExtensionContext).sessionManager.buildSessionProjection().entries;
1181
- const handoff = limit >= MIN_PAGE_CHARS ? buildAutoHandoff(event.branchEntries, projected, limit) : "";
1182
- if (!handoff || handoff.length > limit) {
1183
- // Only receipts Pi will retain require shaping; consumed results must not force an empty cut.
1184
- return event.retainedToolResultIds.length ? { newContext: {} } : undefined;
1185
- }
1186
- return { newContext: { handoff } };
35
+ /** SDK hosts can inject live policy; CLI hosts use the persisted settings snapshot. */
36
+ export function createPosthorse(
37
+ getPolicy: PolicyReader = persistedPolicy,
38
+ ): (pi: ExtensionAPI) => void {
39
+ return (pi) => {
40
+ const policy = (ctx: ExtensionContext, livePolicy?: CompactionPolicy) =>
41
+ policySnapshot(ctx, getPolicy, livePolicy);
42
+ const toolTokens = () => activeToolTokens(pi);
43
+ const pages = new PageReservations(toolTokens);
44
+ registerPosthorseMessages(pi);
45
+ registerRollover(pi, policy, toolTokens, () => {
46
+ pages.reset();
1187
47
  });
1188
- } else {
1189
- // Official Pi owns thresholds, recovery retries, and whether another request follows.
1190
- pi.on("session_before_compact", (event, ctx) => {
1191
- if (event.reason === "manual") return;
1192
- const cancelled = () => event.signal.aborted || ctx.signal?.aborted;
1193
- if (cancelled()) return { cancel: true };
1194
- try {
1195
- // The event carries Pi's live settings; prefer them over the persisted snapshot.
1196
- const host: PolicyContext = { ...policy(ctx), getCompactionSettings: () => event.preparation.settings };
1197
- // Small/unknown context windows deliberately keep Pi's own compaction.
1198
- if (!budgetFor(host)?.supported) return;
1199
- const limit = freshPayloadChars(host, activeToolTokens());
1200
- const handoff = buildAutoHandoff(ctx.sessionManager.getBranch() as EntryLike[], ctx.sessionManager.buildSessionProjection().entries, limit);
1201
- if (limit < MIN_PAGE_CHARS || handoff.length > limit) throw new Error("Too little fresh context capacity for an automatic recovery record.");
1202
- if (cancelled()) return { cancel: true };
1203
- // firstKeptEntryId must name an entry; an invisible sentinel retains no prior conversation.
1204
- pi.appendEntry("posthorse-boundary", {});
1205
- const firstKeptEntryId = ctx.sessionManager.getLeafId();
1206
- if (!firstKeptEntryId) throw new Error("Pi did not persist the context boundary.");
1207
- return { compaction: { summary: handoff, firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details: { posthorse: 1, reason: event.reason } } };
1208
- } catch (error) {
1209
- // Pi swallows handler errors and would then run its summarizer; cancel instead.
1210
- if (!cancelled()) {
1211
- const message = `Posthorse automatic rollover cancelled: ${error instanceof Error ? error.message : String(error)}`;
1212
- if (ctx.hasUI) ctx.ui.notify(message, "error");
1213
- else console.error(message);
1214
- }
1215
- return { cancel: true };
1216
- }
1217
- });
1218
- }
1219
-
1220
- pi.registerTool({
1221
- name: "new_context",
1222
- label: "New Context",
1223
- ...toolCards("new_context"),
1224
- description:
1225
- "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.",
1226
- promptSnippet: "request a fresh context window with an optional handoff",
1227
- parameters: Type.Object({
1228
- handoff: Type.Optional(
1229
- Type.String({
1230
- description: "What the fresh window needs to continue, as readable prose",
1231
- maxLength: MAX_HANDOFF_CHARS,
1232
- }),
1233
- ),
1234
- }),
1235
- async execute(_id, { handoff }, _signal, _onUpdate, ctx) {
1236
- const trimmed = handoff?.trim() || undefined;
1237
- const limit = freshPayloadChars(policy(ctx), activeToolTokens());
1238
- if (trimmed && trimmed.length > limit) {
1239
- throw new Error(
1240
- `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.`,
1241
- );
1242
- }
1243
- const result = textResult(
1244
- "Requested a fresh Pi context after the current foreground tools succeed. Background work continues across the reset. Earlier conversation stays in session history.",
1245
- [],
1246
- { kind: "new-context" },
1247
- );
1248
- // Official Pi ignores this field; its reset is committed at turn_end from the call arguments.
1249
- return native ? { ...result, newContext: { handoff: trimmed } } : result;
1250
- },
1251
- });
1252
-
1253
- pi.registerTool({
1254
- name: "get_context_remaining",
1255
- label: "Context Remaining",
1256
- ...toolCards("get_context_remaining"),
1257
- description:
1258
- "Estimate tokens left before Pi's automatic rollover and the configured context limit. A checkpoint reminder already arrives before rollover; use this only before a large step late in a window.",
1259
- promptSnippet: "check the context budget before a large step late in a window",
1260
- parameters: Type.Object({}),
1261
- async execute(_id, _params, _signal, _onUpdate, ctx) {
1262
- const host = policy(ctx);
1263
- const usage = host.getContextUsage();
1264
- if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.", [], { kind: "context" });
1265
- const n = (value: number) => value.toLocaleString("en-US");
1266
- const budget = budgetFor(host, usage.contextWindow);
1267
- const display: PosthorseDisplay = {
1268
- kind: "context", usage,
1269
- rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
1270
- rolloverAt: budget?.rolloverAt,
1271
- };
1272
- const configured = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens until the configured context limit (${n(usage.tokens)}/${n(usage.contextWindow)} used, ${Math.round(usage.percent ?? 0)}%). Best available native estimate.${native ? "" : ` ${SNAPSHOT_NOTE}`}`;
1273
- if (!budget?.enabled) return textResult(`Automatic rollover is disabled${native ? "" : " in the available settings"} (Pi compaction.enabled=false). ${configured}`, [], display);
1274
- if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${configured}`, [], display);
1275
- return textResult(
1276
- `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
1277
- [], display,
1278
- );
1279
- },
1280
- });
1281
-
1282
- pi.registerTool({
1283
- name: "notes",
1284
- label: "Notes",
1285
- ...toolCards("notes"),
1286
- description:
1287
- "Durable notes in .pi/notes/, shared across Git worktrees and kept across context resets. Ops: list (newest first, with size and date; optional folder path), read, search (case-insensitive substring over text notes), write (replace; empty content clears), append (adds one line-terminated record). list/read/search are paged; continue with the returned offset. Keep one current-state note per task and rewrite it in place.",
1288
- promptSnippet: "save and recall durable state that survives context resets",
1289
- parameters: Type.Object({
1290
- op: Type.Union(
1291
- [Type.Literal("list"), Type.Literal("read"), Type.Literal("write"), Type.Literal("append"), Type.Literal("search")],
1292
- { description: "Operation to perform" },
1293
- ),
1294
- path: Type.Optional(Type.String({ description: "Note or folder path relative to .pi/notes/" })),
1295
- content: Type.Optional(Type.String({ description: "Full file content (write) or text to add (append)" })),
1296
- query: Type.Optional(Type.String({ description: "Substring to find in notes (search)" })),
1297
- offset: Type.Optional(Type.Integer({ description: "Character offset for list/read/search (default 0)", minimum: 0 })),
1298
- }),
1299
- async execute(_id, params, signal, _onUpdate, ctx) {
1300
- const dir = join(notesRoot(ctx.cwd), ".pi", "notes");
1301
- const safeJoin = (path: string) => {
1302
- const relative = normalize(path.replace(/^[/\\]+/, ""));
1303
- if (relative === ".." || relative.startsWith(`..${sep}`) || isAbsolute(relative)) {
1304
- throw new Error(`Invalid path "${path}": must stay inside .pi/notes/.`);
1305
- }
1306
- return join(dir, relative);
1307
- };
1308
- const walk = (directory: string, output: NoteFile[], ancestors = new Set<string>()) => {
1309
- const root = realpathSync(directory);
1310
- if (ancestors.has(root)) return;
1311
- ancestors.add(root);
1312
- for (const file of readdirSync(directory)) {
1313
- const path = join(directory, file);
1314
- const info = statSync(path, { throwIfNoEntry: false });
1315
- if (!info) continue;
1316
- if (info.isDirectory()) walk(path, output, ancestors);
1317
- else output.push({ path, size: info.size, mtimeMs: info.mtimeMs });
1318
- }
1319
- ancestors.delete(root);
1320
- };
1321
- const notesUnder = (target: string): NoteFile[] => {
1322
- const info = statSync(target, { throwIfNoEntry: false });
1323
- if (!info) return [];
1324
- const files: NoteFile[] = [];
1325
- if (info.isDirectory()) walk(target, files);
1326
- else files.push({ path: target, size: info.size, mtimeMs: info.mtimeMs });
1327
- return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
1328
- };
1329
- const list = (folder?: string) => notesPage(
1330
- policy(ctx),
1331
- notesUnder(folder ? safeJoin(folder) : dir).map((file) =>
1332
- `${file.path.slice(dir.length + 1)} ${formatSize(file.size)} ${new Date(file.mtimeMs).toISOString().slice(0, 16)}Z`),
1333
- params.offset ?? 0,
1334
- "notes-list",
1335
- folder ? `(no notes in ${folder})` : "(no notes yet)",
1336
- );
1337
-
1338
- switch (params.op) {
1339
- case "list":
1340
- return list(params.path);
1341
- case "read": {
1342
- const relative = requireValue(params.path, "path", params.op);
1343
- const path = safeJoin(relative);
1344
- const info = statSync(path, { throwIfNoEntry: false });
1345
- if (!info) throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
1346
- if (info.isDirectory()) return list(relative);
1347
- if (await isBinaryFile(path)) throw new Error(`${relative} is a binary file (${formatSize(info.size)}); notes read returns text only.`);
1348
- const text = readFileSync(path, "utf8");
1349
- const offset = params.offset ?? 0;
1350
- if (offset && offset >= text.length) {
1351
- throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
1352
- }
1353
- const end = Math.min(text.length, offset + pageSize(policy(ctx), offset, 0));
1354
- const more =
1355
- end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
1356
- return pageResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
1357
- }
1358
- case "write": {
1359
- const relative = requireValue(params.path, "path", params.op);
1360
- if (params.content === undefined) throw new Error(`"content" is required for op "write" (use "" to clear a note).`);
1361
- const path = safeJoin(relative);
1362
- mkdirSync(dirname(path), { recursive: true });
1363
- await withFileMutationQueue(path, () => publishFile(path, params.content!, signal));
1364
- return textResult(`Wrote ${path}`, [], { kind: "note-write" });
1365
- }
1366
- case "append": {
1367
- const relative = requireValue(params.path, "path", params.op);
1368
- const content = requireValue(params.content, "content", params.op);
1369
- const path = safeJoin(relative);
1370
- mkdirSync(dirname(path), { recursive: true });
1371
- // One O_APPEND write per newline-terminated record, so concurrent Pi processes appending to a
1372
- // shared note never merge records. The separator only matters after a write that left no trailing
1373
- // newline; a torn read of another process's in-flight append costs at most one blank line.
1374
- await withFileMutationQueue(path, async () => {
1375
- signal?.throwIfAborted();
1376
- const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
1377
- const separator = existing && !existing.endsWith("\n") ? "\n" : "";
1378
- appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
1379
- });
1380
- return textResult(`Appended to ${path}`, [], { kind: "note-append" });
1381
- }
1382
- case "search": {
1383
- const query = requireValue(params.query, "query", params.op).toLowerCase();
1384
- const hits: string[] = [];
1385
- for (const { path: file } of notesUnder(dir)) {
1386
- if (await isBinaryFile(file)) continue;
1387
- let number = 0;
1388
- for await (const line of lfLines(file, signal)) {
1389
- number++;
1390
- const trimmed = line.trim();
1391
- const match = trimmed.toLowerCase().indexOf(query);
1392
- if (match !== -1) {
1393
- hits.push(`${file.slice(dir.length + 1)}:${number}: ${excerptAround(trimmed, match, 50, 200)}`);
1394
- }
1395
- }
1396
- }
1397
- return notesPage(policy(ctx), hits, params.offset ?? 0, "notes-search", `No notes match "${excerpt(params.query!, 200)}".`);
1398
- }
1399
- }
1400
- },
1401
- });
1402
-
1403
- pi.registerTool({
1404
- name: "history",
1405
- label: "History",
1406
- ...toolCards("history"),
1407
- description:
1408
- "Search or read earlier session entries, including previous context windows. search skips entries still in your active context and lists original content before recovery echoes, newest first; all=true also searches this project's other sessions and subagent runs. read takes an entry id exactly as search printed it and pages text and images.",
1409
- promptSnippet: "recover earlier conversation that left the active context window",
1410
- promptGuidelines: ["Use history search first, then history read with the entry id exactly as printed"],
1411
- parameters: Type.Object({
1412
- op: Type.Union([Type.Literal("search"), Type.Literal("read")], { description: "Operation to perform" }),
1413
- query: Type.Optional(Type.String({ description: "Case-insensitive text to find (search)" })),
1414
- id: Type.Optional(Type.String({ description: "Entry id exactly as search printed it (read)" })),
1415
- all: Type.Optional(Type.Boolean({ description: "Also search this project's other sessions and subagent runs" })),
1416
- limit: Type.Optional(Type.Integer({ description: "Maximum search results per page (default 10, max 50)", minimum: 1, maximum: 50 })),
1417
- cursor: Type.Optional(Type.String({ description: "Continuation from the previous search page; keep query and all unchanged", maxLength: 512 })),
1418
- offset: Type.Optional(Type.Integer({ description: "Character offset for read (default 0)", minimum: 0 })),
1419
- imageOffset: Type.Optional(Type.Integer({ description: "First image to return (read); use the value from the continuation", minimum: 0 })),
1420
- }),
1421
- async execute(_id, params, signal, _onUpdate, ctx) {
1422
- const manager = ctx.sessionManager;
1423
- const cwd = resolve(ctx.cwd);
1424
- const currentFile = manager.getSessionFile?.();
1425
- const dir = manager.getSessionDir();
1426
-
1427
- if (params.op === "search") {
1428
- const query = requireValue(params.query, "query", params.op).toLowerCase();
1429
- const limit = params.limit ?? 10;
1430
- const searchKey = shortKey(JSON.stringify([query, params.all === true]));
1431
- // Projections show replacements and stripped async calls under the original id, so an entry is in
1432
- // context only when all of its own text is visible there. Posthorse strips stale reminders from input.
1433
- const seen = (message: MessageLike) => [
1434
- textOf(message), assistantFailure(message), imageSummary(imagesOf(message.content)), message.summary, message.command, message.output,
1435
- ].filter(Boolean).join("\n").toLowerCase();
1436
- const visible = new Map((manager.buildSessionProjection().entries as readonly ProjectedEntry[]).flatMap(({ sourceEntry, messages }) =>
1437
- sourceEntry.id && messages.length ? [[sourceEntry.id, messages.map(seen).join("\n")] as const] : []));
1438
- const inContext = (entry: EntryLike) => {
1439
- if (entry.type === "custom_message" && isReminderType(entry.customType)) return false;
1440
- const own = entry.type === "message" ? entry.message ?? {}
1441
- : entry.type === "context_edit" ? { content: entry.replacement?.content }
1442
- : entry.type === "context_window" ? { content: entry.handoff }
1443
- : { content: entry.content, summary: entry.summary };
1444
- return visible.get((entry.type === "context_edit" ? entry.targetId : entry.id) ?? "")?.includes(seen(own)) === true;
1445
- };
1446
- let skipped = 0;
1447
- let cursor: [string, 0 | 1, number, string] | undefined;
1448
- if (params.cursor) {
1449
- try {
1450
- const value = JSON.parse(Buffer.from(params.cursor, "base64url").toString());
1451
- if (!Array.isArray(value) || value.length !== 4 || typeof value[0] !== "string" || !value[0] || ![0, 1].includes(value[1]) || !Number.isSafeInteger(value[2]) || value[2] < 0 || value[3] !== searchKey) throw new Error();
1452
- cursor = value as typeof cursor;
1453
- } catch { pageError(policy(ctx), "Invalid history cursor or changed query/scope; restart the search without it.", params.cursor); }
1454
- }
1455
- const hits: HistoryHit[][] = [[], []];
1456
- let found = !cursor;
1457
- const addHit = (hit: HistoryHit | undefined) => {
1458
- if (!hit) return;
1459
- if (cursor && hit.priority < cursor[1]) return;
1460
- if (cursor && hit.priority === cursor[1] && !found) {
1461
- if (hit.id !== cursor[0]) return;
1462
- found = true;
1463
- if (cursor[2] > hit.text.length) pageError(policy(ctx), "History cursor is past the entry; restart the search without it.", params.cursor);
1464
- if (cursor[2] === hit.text.length) return;
1465
- }
1466
- if (hits[hit.priority].length <= limit) hits[hit.priority].push(hit);
1467
- };
1468
-
1469
- if (params.all) {
1470
- const needles = prefilterNeedles(query);
1471
- const candidates = needles && await filesContaining(dir, needles, signal);
1472
- const current = currentFile && resolve(currentFile);
1473
- for await (const file of scopedSessionFiles(dir, cwd, currentFile, signal, { candidates })) {
1474
- const recent: HistoryHit[][] = [[], []];
1475
- let anchorHere = false;
1476
- const source = relative(dir, file);
1477
- const inCurrent = resolve(file) === current;
1478
- for await (const item of sessionWindowEntries(file, signal)) {
1479
- const hit = historyHit(item, query, source);
1480
- if (!hit) continue;
1481
- if (inCurrent && inContext(item.entry)) {
1482
- skipped++;
1483
- continue;
1484
- }
1485
- const seeking = cursor && !found && hit.priority === cursor[1];
1486
- if (seeking && anchorHere) continue;
1487
- if (seeking && hit.id === cursor?.[0]) anchorHere = true;
1488
- const group = recent[hit.priority];
1489
- group.push(hit);
1490
- // Keep one page plus its anchor and lookahead, not every matching excerpt.
1491
- if (group.length > limit + 2) group.shift();
1492
- }
1493
- if (cursor && !found && !anchorHere) recent[cursor[1]] = [];
1494
- for (const group of recent) for (const hit of group.reverse()) addHit(hit);
1495
- if (hits[0].length > limit) break;
1496
- }
1497
- } else {
1498
- const current = [...windowEntries(manager.getBranch() as EntryLike[])];
1499
- for (const item of current.reverse()) {
1500
- const hit = historyHit(item, query);
1501
- if (!hit) continue;
1502
- if (inContext(item.entry)) {
1503
- skipped++;
1504
- continue;
1505
- }
1506
- addHit(hit);
1507
- if (hits[0].length > limit) break;
1508
- }
1509
- }
1510
- const chars = pageSize(policy(ctx), 0, 0, params.cursor);
1511
- if (!found) pageError(policy(ctx), "History cursor entry no longer matches; restart the search without it.", params.cursor);
1512
- const results = hits.flat();
1513
- const cursorFor = (hit: HistoryHit, offset: number) => Buffer.from(JSON.stringify([hit.id, hit.priority, offset, searchKey])).toString("base64url");
1514
- const footer = (next: string) => `\n[More results; continue with cursor "${next}" and the same query/scope.]`;
1515
- const reserve = Math.max(0, ...results.map((hit) => footer(cursorFor(hit, hit.text.length)).length));
1516
- if (reserve >= chars) pageError(policy(ctx), "History entry id is too large for pagination.", params.cursor);
1517
- const skippedNote = skipped && !params.cursor
1518
- ? `\n[Skipped ${skipped} match${skipped === 1 ? "" : "es"} already in your active context.]`
1519
- : "";
1520
- const parts: string[] = [];
1521
- const spans: Array<{ headerLength: number; length: number }> = [];
1522
- let available = chars - reserve - skippedNote.length;
1523
- let next: string | undefined;
1524
- for (const hit of results) {
1525
- const offset = cursor?.[0] === hit.id ? cursor[2] : 0;
1526
- if (parts.length && hit.text.length - offset > available) break;
1527
- // A split header or excerpt must still identify the native entry on every page.
1528
- const prefix = offset || hit.text.length > available ? `[entry ${hit.id}; from char ${offset}] ` : "";
1529
- if (prefix.length >= available) break;
1530
- const end = Math.min(hit.text.length, offset + available - prefix.length);
1531
- const part = prefix + hit.text.slice(offset, end);
1532
- parts.push(part);
1533
- spans.push({ headerLength: prefix.length + Math.max(0, Math.min(hit.headerLength - offset, end - offset)), length: part.length });
1534
- available -= part.length + 1;
1535
- next = end < hit.text.length || parts.length < results.length ? cursorFor(hit, end) : undefined;
1536
- if (available <= 0 || parts.length >= limit) break;
1537
- }
1538
- const body = parts.length ? parts.join("\n") : `No history matches "${excerpt(params.query!, 200)}".`;
1539
- const tail = `${skippedNote}${next ? footer(next) : ""}`;
1540
- return pageResult(body + tail, [], { kind: "history-search", entries: spans, footerLength: tail.length, more: next !== undefined, skipped });
1541
- }
1542
-
1543
- const id = requireValue(params.id, "id", params.op);
1544
- const separator = id.indexOf("@");
1545
- const entryId = separator < 0 ? id : id.slice(0, separator);
1546
- const fileKey = separator < 0 ? undefined : id.slice(separator + 1);
1547
- const formatEntry = (item: WindowedEntry, source = "") => {
1548
- // Session-file reads print the qualified id, so continuations reopen the same file.
1549
- const shownId = source ? `${item.entry.id}@${shortKey(source)}` : id;
1550
- const offset = params.offset ?? 0;
1551
- const imageOffset = params.imageOffset ?? (offset === 0 ? 0 : item.images.length);
1552
- if (imageOffset > item.images.length) {
1553
- throw new Error(`Image offset ${imageOffset} is past the end of history entry "${id}" (${item.images.length} images).`);
1554
- }
1555
- if (offset > item.text.length || (offset === item.text.length && imageOffset === item.images.length)) {
1556
- throw new Error(`Offset ${offset} is past the end of history entry "${id}" (${item.text.length} chars).`);
1557
- }
1558
- // Reserve one image first so an image-only continuation either advances or asks for fresh context.
1559
- const firstImage = imageOffset < item.images.length ? 1 : 0;
1560
- const chars = pageSize(policy(ctx), offset, firstImage, undefined, item.images.length ? imageOffset : undefined);
1561
- const imageEnd = Math.min(item.images.length, imageOffset + firstImage + Math.floor((chars - MIN_PAGE_CHARS) / ESTIMATED_IMAGE_CHARS));
1562
- const images = item.images.slice(imageOffset, imageEnd);
1563
- const end = Math.min(
1564
- item.text.length,
1565
- offset + chars - (images.length - firstImage) * ESTIMATED_IMAGE_CHARS,
1566
- );
1567
- const more = end < item.text.length || imageEnd < item.images.length
1568
- ? `\nMore remains; call history read with id "${shownId}" and offset ${end}${item.images.length ? ` and imageOffset ${imageEnd}` : ""}.` : "";
1569
- const header = `${item.entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${shownId}] [chars ${offset}-${end} of ${item.text.length}] `;
1570
- return pageResult(
1571
- `${header}${item.text.slice(offset, end)}${more}`,
1572
- images,
1573
- { kind: "history-read", headerLength: header.length, offset, end, total: item.text.length, imageOffset, imageEnd, imageTotal: item.images.length },
1574
- );
1575
- };
1576
-
1577
- if (fileKey === undefined) {
1578
- for (const item of windowEntries(manager.getBranch() as EntryLike[])) {
1579
- if (item.entry.id === entryId) return formatEntry(item);
1580
- }
1581
- }
1582
- // Bare ids from other sessions reach here through older notes and handoffs.
1583
- const candidates = fileKey === undefined && /^[\w-]+$/.test(entryId)
1584
- ? await filesContaining(dir, [`"id":"${entryId}"`], signal)
1585
- : undefined;
1586
- for await (const file of scopedSessionFiles(dir, cwd, currentFile, signal, { fileKey, candidates })) {
1587
- const fileSource = relative(dir, file);
1588
- for await (const item of sessionWindowEntries(file, signal)) {
1589
- if (item.entry.id === entryId) return formatEntry(item, fileSource);
1590
- }
1591
- }
1592
- throw new Error(`No history entry "${id}" in this session or this project's other sessions. Pass an id exactly as history search prints it, such as 1a2f07de, or 1a2f07de@Ab3dE5fG7h from another session; search first when you only have text or a timestamp.`);
1593
- },
1594
- });
1595
- };
1596
-
48
+ registerNotes(pi, policy, pages);
49
+ registerHistory(pi, policy, pages);
50
+ };
51
+ }
1597
52
  export default createPosthorse();