pi-posthorse 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +41 -47
  2. package/index.ts +726 -317
  3. package/package.json +12 -9
  4. package/ui.ts +17 -7
package/index.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * Posthorse — fresh context, same journey.
3
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.
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.
7
8
  */
8
9
 
9
- import { createHash } from "node:crypto";
10
+ import { execFile } from "node:child_process";
11
+ import { createHash, randomUUID } from "node:crypto";
10
12
  import {
11
13
  appendFileSync,
12
- constants,
13
- copyFileSync,
14
14
  createReadStream,
15
15
  existsSync,
16
16
  mkdirSync,
@@ -19,16 +19,20 @@ import {
19
19
  realpathSync,
20
20
  statSync,
21
21
  } from "node:fs";
22
+ import { access, constants, lstat, open, readlink, realpath, rename, stat, unlink } from "node:fs/promises";
22
23
  import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
23
- import * as codingAgent from "@earendil-works/pi-coding-agent";
24
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
+ import { promisify } from "node:util";
25
+ import { estimateTokens, formatSize, getAgentDir, SettingsManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
26
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
25
27
  import { Type } from "typebox";
26
28
  import { registerPosthorseMessages, toolCards, type PosthorseDisplay } from "./ui.ts";
27
29
 
28
30
  const REMINDER_BUFFER_TOKENS = 32_000;
29
- /** Absolute ceiling for handoffs and recovery pages; pages shrink to the live remaining budget. */
31
+ /** Absolute ceilings; handoffs and pages shrink to the live remaining budget. */
30
32
  const MAX_HANDOFF_CHARS = 20_000;
33
+ const MAX_PAGE_CHARS = 40_000;
31
34
  const MAX_RECOVERY_RECORD_CHARS = 4_000;
35
+ const MAX_RECOVERY_RESULT_CHARS = 1_500;
32
36
  const HANDOFF_OVERHEAD_RESERVE = 1_000;
33
37
  const PAGE_MARGIN_TOKENS = 1_000;
34
38
  const MIN_PAGE_CHARS = 1_000;
@@ -36,45 +40,55 @@ const ESTIMATED_IMAGE_CHARS = 4_800;
36
40
  /** A window must hold the largest handoff (~5,000 tokens) plus equal working room below Pi's line. */
37
41
  const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
38
42
  const REMINDER_TYPE = "posthorse-reminder";
39
- /** Persisted by pi-headroom transcripts before the rename; still recognized everywhere. */
40
- const LEGACY_REMINDER_TYPE = "headroom-reminder";
41
43
  const AUTO_HANDOFF_PREFIX = "Automatic context rollover recovery record.";
42
- const LEGACY_AUTO_HANDOFF_PREFIX =
43
- "Automatic context rollover. Continue the current task without asking the user to repeat it.";
44
+ const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before continuing.";
44
45
 
45
46
  type CompactionPolicy = { enabled: boolean; reserveTokens: number };
46
47
  type ContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
47
- /** Fork-only SDK export; the public npm declarations do not include it. */
48
- const { publishLocalFile } = codingAgent as typeof codingAgent & {
49
- publishLocalFile(absolutePath: string, content: string | Uint8Array, signal?: AbortSignal): Promise<void>;
50
- };
51
48
 
52
- /** Fork-only ExtensionContext members; the published Pi types do not declare them. */
53
- type NativeContext = {
49
+ /** The fork exposes live settings on the context; official Pi gets them from the injected policy. */
50
+ type PolicyContext = {
54
51
  model?: { contextWindow: number };
55
- newContext(options?: { handoff?: string }): void;
56
52
  getCompactionSettings(): CompactionPolicy;
57
53
  getContextUsage(): ContextUsage | undefined;
58
54
  getSystemPrompt(): string;
59
55
  };
60
56
 
57
+ /** Fork-only extension API; the published Pi types do not declare it. */
61
58
  type NativeExtensionAPI = {
59
+ registerContextWindowHook(
60
+ handler: (
61
+ event: { contextEntries: ProjectedEntry[]; pendingMessages: MessageLike[] },
62
+ ctx: ExtensionContext,
63
+ ) => ReceiptEdit[] | undefined,
64
+ ): void;
62
65
  on(
63
66
  event: "session_before_auto_compact",
64
67
  handler: (
65
- event: { reason: "overflow" | "threshold"; branchEntries: EntryLike[]; pendingMessages?: MessageLike[] },
68
+ event: { reason: "overflow" | "threshold"; branchEntries: EntryLike[]; pendingMessages?: MessageLike[]; retainedToolResultIds: string[] },
66
69
  ctx: unknown,
67
70
  ) => { newContext: { handoff?: string } } | undefined,
68
71
  ): void;
69
72
  };
70
73
 
71
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
+ };
72
84
  type MessageLike = {
73
85
  role?: string;
74
86
  stopReason?: string;
75
87
  errorMessage?: string;
76
88
  content?: unknown;
89
+ summary?: string;
77
90
  toolName?: string;
91
+ namespace?: string;
78
92
  toolCallId?: string;
79
93
  isError?: boolean;
80
94
  command?: string;
@@ -93,9 +107,14 @@ type EntryLike = {
93
107
  details?: unknown;
94
108
  display?: boolean;
95
109
  handoff?: string;
110
+ firstKeptEntryId?: string;
111
+ targetId?: string;
112
+ replacement?: { content: unknown } | null;
96
113
  };
97
114
 
98
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 } };
99
118
  type HistoryHit = { id: string; text: string; headerLength: number; priority: 0 | 1 };
100
119
  type RecoveryRecord = {
101
120
  id: string;
@@ -105,6 +124,7 @@ type RecoveryRecord = {
105
124
  text: string;
106
125
  };
107
126
  type ReminderFingerprint = { windowId: string; contextWindow?: number; reserveTokens?: number };
127
+ type NoteFile = { path: string; size: number; mtimeMs: number };
108
128
  type Budget = {
109
129
  contextWindow: number;
110
130
  reserveTokens: number;
@@ -114,21 +134,92 @@ type Budget = {
114
134
  supported: boolean;
115
135
  };
116
136
 
117
- function nativeContext<T>(ctx: T): T & NativeContext {
118
- const candidate = ctx as T & Partial<NativeContext>;
119
- if (
120
- typeof candidate.newContext !== "function" ||
121
- typeof candidate.getCompactionSettings !== "function" ||
122
- typeof candidate.getSystemPrompt !== "function" ||
123
- typeof publishLocalFile !== "function"
124
- ) {
125
- throw new Error("Posthorse requires the fitchmultz/pi fork with native context windows and publishLocalFile (see README).");
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
+ }
126
155
  }
127
- return candidate as T & NativeContext;
156
+ return { start: 0 };
128
157
  }
129
158
 
130
159
  function isReminderType(customType: unknown): boolean {
131
- return customType === REMINDER_TYPE || customType === LEGACY_REMINDER_TYPE;
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
+ }
132
223
  }
133
224
 
134
225
  function isImage(part: unknown): part is ImageLike {
@@ -154,16 +245,26 @@ function safeJsonStringify(value: unknown): string {
154
245
  }
155
246
  }
156
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
+
157
256
  function textOf(message: MessageLike): string {
158
257
  if (typeof message.content === "string") return message.content;
159
258
  if (!Array.isArray(message.content)) return "";
160
259
  return message.content
161
260
  .map((part) => {
162
261
  if (!part || typeof part !== "object") return "";
163
- const block = part as { type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown };
262
+ const block = part as ToolCallLike & { text?: string; thinking?: string };
164
263
  if (block.type === "text") return block.text ?? "";
165
264
  if (block.type === "thinking") return block.thinking ?? "";
166
- if (block.type === "toolCall") return `${block.name ?? "tool"} ${safeJsonStringify(block.arguments ?? {})}`;
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
+ }
167
268
  return "";
168
269
  })
169
270
  .filter(Boolean)
@@ -174,32 +275,11 @@ function textResult(text: string, images: ImageLike[] = [], display?: PosthorseD
174
275
  return { content: [{ type: "text" as const, text }, ...images], details: display };
175
276
  }
176
277
 
177
- /** Import old checkout-local notes without overwriting shared notes, including concurrent writes. */
178
- function importLegacyNotes(source: string, target: string): void {
179
- if (!existsSync(source)) return;
180
- if (existsSync(target) && (!statSync(target).isDirectory() || realpathSync(source) === realpathSync(target))) return;
181
- mkdirSync(target, { recursive: true });
182
- for (const name of readdirSync(source)) {
183
- const from = join(source, name);
184
- const to = join(target, name);
185
- if (statSync(from).isDirectory()) {
186
- importLegacyNotes(from, to);
187
- } else {
188
- try {
189
- copyFileSync(from, to, constants.COPYFILE_EXCL);
190
- } catch (error) {
191
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
192
- }
193
- }
194
- }
195
- }
196
-
197
278
  /** Conventional repos use the main checkout; separate Git directories are their own shared root. */
198
279
  function notesRoot(cwd: string): string {
199
280
  for (let dir = cwd; ; dir = dirname(dir)) {
200
281
  const marker = join(dir, ".git");
201
282
  if (existsSync(marker)) {
202
- let root: string;
203
283
  try {
204
284
  let target = marker;
205
285
  if (!statSync(marker).isDirectory()) {
@@ -210,13 +290,11 @@ function notesRoot(cwd: string): string {
210
290
  target = realpathSync(target);
211
291
  const commonFile = join(target, "commondir");
212
292
  const common = realpathSync(existsSync(commonFile) ? resolve(target, readFileSync(commonFile, "utf8").trim()) : target);
213
- root = basename(common) === ".git" ? dirname(common) : common;
293
+ return basename(common) === ".git" ? dirname(common) : common;
214
294
  } catch {
215
295
  // Orphaned/copied worktrees can outlive their Git metadata; local notes still work.
216
296
  return dir;
217
297
  }
218
- if (root !== dir) importLegacyNotes(join(dir, ".pi", "notes"), join(root, ".pi", "notes"));
219
- return root;
220
298
  }
221
299
  if (dirname(dir) === dir) return cwd;
222
300
  }
@@ -247,7 +325,10 @@ function flattenEntry(entry: EntryLike): string | undefined {
247
325
  if (message.excludeFromContext === true) return "[bashExecution] (excluded from model context by Pi)";
248
326
  return `[bashExecution] $ ${message.command ?? ""}\n${message.output ?? ""}`;
249
327
  }
250
- return `[${message.role ?? "message"}] ${[textOf(message), assistantFailure(message), imageSummary(imagesOf(message.content))].filter(Boolean).join("\n")}`;
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")}`;
251
332
  }
252
333
  if (entry.type === "compaction" || entry.type === "branch_summary") {
253
334
  return `[${entry.type}] ${entry.summary ?? ""}`;
@@ -258,6 +339,10 @@ function flattenEntry(entry: EntryLike): string | undefined {
258
339
  if (entry.type === "context_window") {
259
340
  return `[context_window] ${entry.handoff ? `Handoff: ${entry.handoff}` : "No handoff"}`;
260
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
+ }
261
346
  return undefined;
262
347
  }
263
348
 
@@ -270,6 +355,24 @@ function isRecoveryCall(part: unknown): boolean {
270
355
  return block?.type === "toolCall" && isRecoveryTool(block.name);
271
356
  }
272
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
+
273
376
  function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit | undefined {
274
377
  const { entry } = item;
275
378
  let text = item.text;
@@ -308,9 +411,10 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
308
411
  matchIndex = text.toLowerCase().indexOf(query);
309
412
  }
310
413
  }
311
- const header = `${source ? `${source} ` : ""}${entry.timestamp ?? ""} [window ${item.windowId}] [${entry.id}] `;
414
+ const id = source ? `${entry.id}@${shortKey(source)}` : entry.id!;
415
+ const header = `${entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${id}] `;
312
416
  return {
313
- id: entry.id!,
417
+ id,
314
418
  priority,
315
419
  text: `${header}${excerptAround(text, matchIndex, 100, 400)}`,
316
420
  headerLength: header.length,
@@ -319,11 +423,11 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
319
423
 
320
424
  function toWindowedEntry(entry: EntryLike, windows: Map<string, string>): WindowedEntry | undefined {
321
425
  const inheritedWindow = entry.parentId ? (windows.get(entry.parentId) ?? "initial") : "initial";
322
- const windowId = entry.type === "context_window" && entry.id ? entry.id : inheritedWindow;
426
+ const windowId = isWindow(entry) && entry.id ? entry.id : inheritedWindow;
323
427
  if (entry.id) windows.set(entry.id, windowId);
324
428
  const text = flattenEntry(entry);
325
429
  if (!text || !entry.id) return undefined;
326
- const images = imagesOf(entry.type === "message" ? entry.message?.content : entry.content);
430
+ const images = imagesOf(entry.type === "context_edit" ? entry.replacement?.content : entry.type === "message" ? entry.message?.content : entry.content);
327
431
  return { entry, windowId, text, images };
328
432
  }
329
433
 
@@ -335,28 +439,80 @@ function* windowEntries(entries: Iterable<EntryLike>): Generator<WindowedEntry>
335
439
  }
336
440
  }
337
441
 
338
- /** JSONL splits on LF, not the Unicode separators that Node's readline also recognizes. */
339
- async function* jsonlLines(file: string, signal?: AbortSignal): AsyncGenerator<string> {
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> {
340
444
  const stream = createReadStream(file, { encoding: "utf8", signal });
341
- let pending = "";
445
+ let pending: string[] = [];
342
446
  for await (const chunk of stream) {
343
- const lines = (pending + chunk).split("\n");
344
- pending = lines.pop()!;
345
- yield* lines;
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();
346
467
  }
347
- if (pending) yield pending;
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;
348
503
  }
349
504
 
350
505
  async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncGenerator<WindowedEntry> {
351
506
  if (!existsSync(file)) return;
352
507
  const windows = new Map<string, string>();
353
- for await (const line of jsonlLines(file, signal)) {
508
+ for await (const line of lfLines(file, signal)) {
354
509
  let entry: EntryLike;
355
510
  try {
356
511
  entry = JSON.parse(line) as EntryLike;
357
512
  } catch {
358
513
  continue;
359
514
  }
515
+ if (!entry) continue;
360
516
  const item = toWindowedEntry(entry, windows);
361
517
  if (item) yield item;
362
518
  }
@@ -364,20 +520,64 @@ async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncG
364
520
 
365
521
  function sessionFiles(dir: string): string[] {
366
522
  if (!existsSync(dir)) return [];
367
- const files = readdirSync(dir, { recursive: true, withFileTypes: true })
523
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
368
524
  .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl") && !entry.name.includes(".intent."))
369
525
  .map((entry) => join(entry.parentPath, entry.name));
370
- if (files.length < 2) return files;
371
- return files
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)))))
372
570
  .map((file) => ({ file, mtime: statSync(file).mtimeMs }))
373
- .sort((a, b) => b.mtime - a.mtime)
374
- .map(({ file }) => file);
571
+ .sort((a, b) => b.mtime - a.mtime);
572
+ for (const { file } of selected) {
573
+ if (await belongs(file)) yield file;
574
+ }
375
575
  }
376
576
 
377
577
  function currentWindowId(entries: readonly EntryLike[]): string {
378
578
  for (let i = entries.length - 1; i >= 0; i--) {
379
579
  const entry = entries[i];
380
- if (entry.type === "context_window" && entry.id) return entry.id;
580
+ if (isWindow(entry) && entry.id) return entry.id;
381
581
  }
382
582
  return "initial";
383
583
  }
@@ -409,6 +609,7 @@ function recoveryRecord(entry: EntryLike): RecoveryRecord | undefined {
409
609
  entry.type === "message" &&
410
610
  entry.message?.role === "toolResult" &&
411
611
  entry.message.toolName === "ask_question" &&
612
+ entry.message.namespace === undefined &&
412
613
  entry.message.isError !== true
413
614
  ) {
414
615
  kind = "owner";
@@ -437,77 +638,100 @@ function formatRecoveryRecord(record: RecoveryRecord, limit: number): string {
437
638
  return boundedBlock(`[${record.label} | ${record.timestamp} | entry ${record.id}]`, record.text, limit);
438
639
  }
439
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
+
440
649
  function formatPriorCheckpoint(entry: EntryLike | undefined, limit: number): string | undefined {
441
- const handoff = entry?.handoff?.trim();
650
+ const handoff = windowHandoff(entry)?.trim();
442
651
  if (!entry || !handoff) return undefined;
443
652
  const id = entry.id?.slice(0, 120) ?? "unknown";
444
653
  const header = `[older checkpoint; possibly stale | context-window entry ${id}]`;
445
- if (handoff.startsWith(AUTO_HANDOFF_PREFIX) || handoff.startsWith(LEGACY_AUTO_HANDOFF_PREFIX)) {
654
+ if (handoff.startsWith(AUTO_HANDOFF_PREFIX)) {
446
655
  return boundedBlock(header, `Prior automatic recovery text is not nested here. Use history read with entry ${id} if needed.`, limit);
447
656
  }
448
657
  return boundedBlock(header, handoff, limit);
449
658
  }
450
659
 
451
- /** Trailing results without a later complete assistant response; interrupted requests may have received them. */
452
- function trailingToolBatch(
660
+ /** Async receipts can arrive during a later response, so its completion cannot establish receipt delivery. */
661
+ function recoverableToolResults(
453
662
  entries: readonly EntryLike[],
454
- ): { callId: string; blocks: Array<{ header: string; text: string }> } | undefined {
455
- const results: EntryLike[] = [];
456
- let call: EntryLike | undefined;
457
- for (let i = entries.length - 1; i >= 0 && !call; i--) {
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--) {
458
674
  const entry = entries[i];
459
- const role = entry.type === "message" ? entry.message?.role : undefined;
675
+ const role = entry.message?.role;
460
676
  if (role === "toolResult") {
461
- results.unshift(entry);
677
+ hasTrailingResult = true;
462
678
  } else if (role === "assistant") {
463
679
  const invalid =
464
680
  entry.message?.stopReason === "error" ||
465
681
  entry.message?.stopReason === "aborted" ||
466
682
  entry.message?.stopReason === "length";
467
- if (invalid) {
468
- if (results.length) return undefined;
469
- } else {
470
- call = entry;
683
+ if (!invalid || hasTrailingResult) {
684
+ trailingStart = i;
685
+ break;
471
686
  }
472
687
  }
473
688
  }
474
- if (!call || !results.length) return undefined;
475
- const calls = Array.isArray(call.message?.content)
476
- ? (call.message.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>).filter(
477
- (block) => block?.type === "toolCall",
478
- )
479
- : [];
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);
480
699
  const blocks = results.map((result) => {
481
700
  const message = result.message ?? {};
482
- const matching = calls.find((block) => block.id === message.toolCallId);
483
- const name = (message.toolName ?? matching?.name ?? "tool").slice(0, 80);
701
+ const matching = calls.get(message.toolCallId!);
702
+ const name = toolIdentity(message.toolName ?? matching?.block.name, message.namespace ?? matching?.block.namespace);
484
703
  const resultId = (result.id ?? "unknown").slice(0, 120);
485
704
  const images = imageSummary(imagesOf(message.content));
486
705
  const output =
487
706
  [textOf(message).trim(), images && `${images} — recover with history read id ${resultId}`]
488
707
  .filter(Boolean)
489
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";
490
712
  return {
491
713
  header: `[${message.isError ? "error" : "result"} entry ${resultId}]`,
492
- text: `${output}\n\nTool: ${name}\nCall arguments: ${safeJsonStringify(matching?.arguments ?? {})}`,
714
+ text: `${output}\n\nTool: ${name}\nCall ID: ${message.toolCallId}${callDetail}`,
493
715
  };
494
716
  });
495
- return { callId: (call.id ?? "unknown").slice(0, 120), blocks };
717
+ return { callId: allLinked ? (call?.id ?? "unknown").slice(0, 120) : undefined, blocks };
496
718
  }
497
719
 
498
- function buildAutoHandoff(entries: readonly EntryLike[], maxChars: number): string {
499
- let windowStart = 0;
500
- let priorWindow: EntryLike | undefined;
501
- for (let i = entries.length - 1; i >= 0; i--) {
502
- if (entries[i].type === "context_window") {
503
- windowStart = i + 1;
504
- priorWindow = entries[i];
505
- break;
506
- }
507
- }
508
-
509
- const current = entries.slice(windowStart);
510
- const records = current
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
511
735
  .map((entry) => recoveryRecord(entry))
512
736
  .filter((record): record is RecoveryRecord => record !== undefined);
513
737
  const firstOwnerRequest = records.find((record) => record.label === "owner input") ?? records[0];
@@ -523,9 +747,18 @@ function buildAutoHandoff(entries: readonly EntryLike[], maxChars: number): stri
523
747
  const joinedLength = (parts: Array<string | undefined>) =>
524
748
  parts.filter((part): part is string => Boolean(part)).join("\n\n").length;
525
749
 
526
- const toolBatch = trailingToolBatch(current);
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);
527
760
  const batchHeader = toolBatch
528
- ? `Trailing tool batch without a later complete assistant response (failed or interrupted requests may already have received these results). Tool-call entry ${toolBatch.callId}:`
761
+ ? `Tool result evidence (current projected window; may already have been received or handled; not current progress).${toolBatch.callId ? ` Tool-call entry ${toolBatch.callId}:` : ""}`
529
762
  : undefined;
530
763
  const minimumParts = [
531
764
  preamble,
@@ -544,14 +777,14 @@ function buildAutoHandoff(entries: readonly EntryLike[], maxChars: number): stri
544
777
  }
545
778
  const batchBlocks = toolBatch?.blocks.slice(firstBatchBlock) ?? [];
546
779
  const batchOmission = firstBatchBlock
547
- ? `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.`
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.`
548
781
  : undefined;
549
782
  const bareBatch = batchHeader
550
783
  ? [batchHeader, batchOmission, ...batchBlocks.map((block) => block.header)]
551
784
  .filter((part): part is string => Boolean(part))
552
785
  .join("\n\n")
553
786
  : undefined;
554
- const fixedCount = selected.size + (priorWindow?.handoff?.trim() ? 1 : 0);
787
+ const fixedCount = selected.size + (windowHandoff(priorWindow)?.trim() ? 1 : 0);
555
788
  const fixedBudget = Math.max(
556
789
  0,
557
790
  maxChars - joinedLength([preamble, currentHeader, bareBatch]) - HANDOFF_OVERHEAD_RESERVE,
@@ -560,16 +793,10 @@ function buildAutoHandoff(entries: readonly EntryLike[], maxChars: number): stri
560
793
  ? Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(fixedBudget / fixedCount))
561
794
  : MAX_RECOVERY_RECORD_CHARS;
562
795
  const prior = formatPriorCheckpoint(priorWindow, fixedLimit);
563
- const formatted = new Map(
564
- records.map((record) => [
565
- record,
566
- formatRecoveryRecord(record, selected.has(record) ? fixedLimit : MAX_RECOVERY_RECORD_CHARS),
567
- ]),
568
- );
569
- const fixedParts = () => [
796
+ const fixedParts = [
570
797
  preamble,
571
798
  currentHeader,
572
- ...records.filter((record) => selected.has(record)).map((record) => formatted.get(record)!),
799
+ ...records.filter((record) => selected.has(record)).map((record) => formatRecoveryRecord(record, fixedLimit)),
573
800
  ];
574
801
 
575
802
  let batch: string | undefined;
@@ -577,11 +804,11 @@ function buildAutoHandoff(entries: readonly EntryLike[], maxChars: number): stri
577
804
  const availableText = Math.max(
578
805
  0,
579
806
  maxChars -
580
- joinedLength([...fixedParts(), bareBatch, prior]) -
807
+ joinedLength([...fixedParts, bareBatch, prior]) -
581
808
  HANDOFF_OVERHEAD_RESERVE -
582
809
  batchBlocks.length,
583
810
  );
584
- const perBlock = Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(availableText / batchBlocks.length));
811
+ const perBlock = Math.min(MAX_RECOVERY_RESULT_CHARS, Math.floor(availableText / batchBlocks.length));
585
812
  batch = [
586
813
  batchHeader,
587
814
  batchOmission,
@@ -591,68 +818,40 @@ function buildAutoHandoff(entries: readonly EntryLike[], maxChars: number): stri
591
818
  .join("\n\n");
592
819
  }
593
820
 
594
- let optionalBudget = Math.max(0, maxChars - joinedLength([...fixedParts(), batch, prior]) - HANDOFF_OVERHEAD_RESERVE);
595
- for (let i = records.length - 1; i >= 0; i--) {
596
- const record = records[i];
597
- if (selected.has(record)) continue;
598
- const length = formatted.get(record)!.length + 2;
599
- if (length > optionalBudget) continue;
600
- selected.add(record);
601
- optionalBudget -= length;
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;
602
831
  }
603
-
604
- const omitted = records.filter((record) => !selected.has(record));
605
- const omission = omitted.length
606
- ? `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.`
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")
607
835
  : undefined;
608
- return [...fixedParts(), omission, batch, prior].filter((part): part is string => Boolean(part)).join("\n\n");
836
+ return [...fixedParts, index, batch, prior].filter((part): part is string => Boolean(part)).join("\n\n");
609
837
  }
610
838
 
611
- /** Legacy reminders carry only windowId; they match on it alone until the model changes. */
612
839
  function reminderMatches(details: unknown, fingerprint: ReminderFingerprint): boolean {
613
840
  const stored = (details ?? {}) as Partial<ReminderFingerprint>;
614
841
  return (
615
842
  stored.windowId === fingerprint.windowId &&
616
- (stored.contextWindow ?? fingerprint.contextWindow) === fingerprint.contextWindow &&
617
- (stored.reserveTokens ?? fingerprint.reserveTokens) === fingerprint.reserveTokens
843
+ stored.contextWindow === fingerprint.contextWindow &&
844
+ stored.reserveTokens === fingerprint.reserveTokens
618
845
  );
619
846
  }
620
847
 
621
- function reminderIsStale(
622
- customType: unknown,
623
- details: unknown,
624
- fingerprint: ReminderFingerprint,
625
- branch: readonly EntryLike[],
626
- ): boolean {
627
- if (!reminderMatches(details, fingerprint)) return true;
628
- const stored = (details ?? {}) as Partial<ReminderFingerprint>;
629
- if (customType !== LEGACY_REMINDER_TYPE || stored.contextWindow !== undefined || stored.reserveTokens !== undefined) {
630
- return false;
631
- }
632
- let reminderIndex = -1;
633
- for (let i = 0; i < branch.length; i++) {
634
- const entry = branch[i];
635
- if (
636
- entry.type === "custom_message" &&
637
- entry.customType === LEGACY_REMINDER_TYPE &&
638
- ((entry.details ?? {}) as Partial<ReminderFingerprint>).windowId === stored.windowId
639
- ) {
640
- reminderIndex = i;
641
- }
642
- }
643
- return reminderIndex !== -1 && branch.slice(reminderIndex + 1).some((entry) => entry.type === "model_change");
644
- }
645
-
646
848
  function hasReminder(entries: readonly EntryLike[], fingerprint: ReminderFingerprint): boolean {
647
849
  return entries.some(
648
- (entry) =>
649
- entry.type === "custom_message" &&
650
- isReminderType(entry.customType) &&
651
- !reminderIsStale(entry.customType, entry.details, fingerprint, entries),
850
+ (entry) => entry.type === "custom_message" && isReminderType(entry.customType) && reminderMatches(entry.details, fingerprint),
652
851
  );
653
852
  }
654
853
 
655
- function budgetFor(ctx: NativeContext, contextWindow = ctx.model?.contextWindow): Budget | undefined {
854
+ function budgetFor(ctx: PolicyContext, contextWindow = ctx.model?.contextWindow): Budget | undefined {
656
855
  if (!contextWindow || contextWindow <= 0) return undefined;
657
856
  const { enabled, reserveTokens } = ctx.getCompactionSettings();
658
857
  const usable = contextWindow - reserveTokens;
@@ -660,9 +859,9 @@ function budgetFor(ctx: NativeContext, contextWindow = ctx.model?.contextWindow)
660
859
  }
661
860
 
662
861
  /** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
663
- function freshPayloadChars(ctx: NativeContext, toolTokens: number, pendingMessages: readonly MessageLike[] = []): number {
862
+ function freshPayloadChars(ctx: PolicyContext, toolTokens: number, pendingMessages: readonly MessageLike[] = [], cap = MAX_HANDOFF_CHARS): number {
664
863
  const contextWindow = ctx.model?.contextWindow;
665
- if (!contextWindow || contextWindow <= 0) return MAX_HANDOFF_CHARS;
864
+ if (!contextWindow || contextWindow <= 0) return cap;
666
865
  const budget = budgetFor(ctx, contextWindow);
667
866
  const line = budget?.enabled && budget.supported ? budget.rolloverAt : contextWindow;
668
867
  const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4);
@@ -671,54 +870,141 @@ function freshPayloadChars(ctx: NativeContext, toolTokens: number, pendingMessag
671
870
  0,
672
871
  );
673
872
  return Math.min(
674
- MAX_HANDOFF_CHARS,
873
+ cap,
675
874
  Math.max(0, Math.floor((line - PAGE_MARGIN_TOKENS - promptTokens - toolTokens - pendingTokens) / 2)) * 4,
676
875
  );
677
876
  }
678
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
+
679
947
  function unsupportedMessage(budget: Budget): string {
680
948
  const n = (value: number) => value.toLocaleString("en-US");
681
- 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.`;
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.`;
682
950
  }
683
951
 
684
952
  /** Capacity before Pi's automatic line (or configured context limit), including page metadata and refusals. */
685
- function pageCapacity(ctx: NativeContext, toolTokens: number): number {
953
+ function pageCapacity(ctx: PolicyContext, toolTokens: number): number {
686
954
  const usage = ctx.getContextUsage();
687
- if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens) / 4 + PAGE_MARGIN_TOKENS;
955
+ if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens, [], MAX_PAGE_CHARS) / 4 + PAGE_MARGIN_TOKENS;
688
956
  const budget = budgetFor(ctx, usage.contextWindow);
689
957
  const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
690
958
  return line - usage.tokens;
691
959
  }
692
960
 
693
- function buildGuidance(ctx: NativeContext): string {
961
+ function buildGuidance(ctx: PolicyContext, official: boolean): string {
694
962
  const budget = budgetFor(ctx);
695
963
  const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
696
964
  let automatic: string;
697
965
  if (!enabled) {
698
- automatic =
699
- "Pi compaction is disabled, so Posthorse sends no checkpoint reminder and performs no automatic rollover. new_context remains available.";
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.";
700
969
  } else if (budget && !budget.supported) {
701
970
  automatic = unsupportedMessage(budget);
702
971
  } else {
703
972
  const deadline = budget
704
973
  ? `${Math.max(1, Math.round((budget.rolloverAt / budget.contextWindow) * 100))}% used`
705
974
  : "the configured Pi context limit";
706
- 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.`;
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}.`;
707
976
  }
708
977
  return `## Context self-management (Posthorse)
709
- 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.
710
978
  ${automatic}
711
- 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.
712
- 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.`;
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.`;
713
981
  }
714
982
 
715
- export default function (pi: ExtensionAPI) {
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
+ };
716
1001
  registerPosthorseMessages(pi);
717
1002
  const activeToolTokens = () => {
718
1003
  const active = new Set(pi.getActiveTools());
719
1004
  return pi
720
1005
  .getAllTools()
721
- .filter((tool) => active.has(tool.name))
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))
722
1008
  .reduce(
723
1009
  (total, tool) =>
724
1010
  total +
@@ -730,9 +1016,15 @@ export default function (pi: ExtensionAPI) {
730
1016
  );
731
1017
  };
732
1018
 
1019
+ if (native) {
1020
+ nativePi.registerContextWindowHook((event, ctx) =>
1021
+ boundWindowReceipts(ctx as ExtensionContext & PolicyContext, event.contextEntries, event.pendingMessages, activeToolTokens()),
1022
+ );
1023
+ }
1024
+
733
1025
  let pendingPageTokens = 0;
734
1026
  let previousPageUsage: number | null | undefined;
735
- const pageSize = (ctx: NativeContext, offset: number, imageCount: number, cursor?: string) => {
1027
+ const pageSize = (ctx: PolicyContext, offset: number, imageCount: number, cursor?: string, imageOffset?: number) => {
736
1028
  const usage = ctx.getContextUsage()?.tokens;
737
1029
  // Parallel siblings are not in native usage yet; sequential results must not be counted twice.
738
1030
  if (usage != null && previousPageUsage != null) {
@@ -741,11 +1033,11 @@ export default function (pi: ExtensionAPI) {
741
1033
  previousPageUsage = usage;
742
1034
  const remaining = pageCapacity(ctx, activeToolTokens()) - pendingPageTokens;
743
1035
  const chars = Math.min(
744
- MAX_HANDOFF_CHARS,
1036
+ MAX_PAGE_CHARS,
745
1037
  Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
746
1038
  );
747
1039
  if (chars < MIN_PAGE_CHARS) {
748
- 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}`}.`;
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}`}`}.`;
749
1041
  // Refusals consume context too. Once even the guidance no longer fits, omit repeated text;
750
1042
  // the failed call still carries its original offset and earlier refusals explain the retry.
751
1043
  const text = Math.ceil(message.length / 4) <= remaining ? message : "";
@@ -758,12 +1050,12 @@ export default function (pi: ExtensionAPI) {
758
1050
  pendingPageTokens += Math.ceil(text.length / 4) + images.length * (ESTIMATED_IMAGE_CHARS / 4);
759
1051
  return textResult(text, images, display);
760
1052
  };
761
- const pageError = (ctx: NativeContext, message: string, cursor?: string): never => {
1053
+ const pageError = (ctx: PolicyContext, message: string, cursor?: string): never => {
762
1054
  pageSize(ctx, 0, 0, cursor);
763
1055
  pendingPageTokens += Math.ceil(message.length / 4);
764
1056
  throw new Error(message);
765
1057
  };
766
- const notesPage = (ctx: NativeContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string) => {
1058
+ const notesPage = (ctx: PolicyContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string) => {
767
1059
  const text = rows.length ? rows.join("\n") : empty;
768
1060
  const chars = pageSize(ctx, offset, 0);
769
1061
  if (offset && offset >= text.length) pageError(ctx, "Offset is past the end; restart with offset 0.");
@@ -772,11 +1064,12 @@ export default function (pi: ExtensionAPI) {
772
1064
  let end = Math.min(text.length, offset + chars);
773
1065
  let start = 0;
774
1066
  const starts: number[] = [];
1067
+ const maxRows = kind === "notes-search" ? 20 : 100;
775
1068
  for (const row of rows) {
776
1069
  if (start >= end) break;
777
1070
  if (start + row.length > offset) starts.push(start);
778
1071
  start += row.length + 1;
779
- if (kind === "notes-search" && starts.length === 20) { end = Math.min(end, start - 1); break; }
1072
+ if (starts.length === maxRows) { end = Math.min(end, start - 1); break; }
780
1073
  }
781
1074
  // A result-count cap can require continuation even when the character cap did not.
782
1075
  if (end < text.length) end = Math.min(end, offset + chars - footer(text.length).length);
@@ -787,14 +1080,13 @@ export default function (pi: ExtensionAPI) {
787
1080
  previousPageUsage = undefined;
788
1081
  });
789
1082
 
790
- pi.on("session_start", (_event, ctx) => {
1083
+ pi.on("session_start", () => {
791
1084
  pendingPageTokens = 0;
792
1085
  previousPageUsage = undefined;
793
- nativeContext(ctx);
794
1086
  });
795
1087
 
796
1088
  pi.on("before_agent_start", (event, ctx) => {
797
- const guidance = buildGuidance(nativeContext(ctx));
1089
+ const guidance = buildGuidance(policy(ctx), !native);
798
1090
  // An earlier full-prompt override makes section edits invisible to Pi.
799
1091
  if (event.systemPromptOptions.forceSystemPrompt !== undefined) {
800
1092
  return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` };
@@ -803,21 +1095,32 @@ export default function (pi: ExtensionAPI) {
803
1095
  });
804
1096
 
805
1097
  pi.on("turn_end", (event, ctx) => {
806
- const native = nativeContext(ctx);
1098
+ const host = policy(ctx);
807
1099
  if (
808
1100
  event.message.role === "assistant" &&
809
1101
  (event.message.stopReason === "error" || event.message.stopReason === "aborted")
810
1102
  )
811
1103
  return;
812
- // Pi commits a new_context request only when every tool in the batch succeeded.
813
- if (
814
- event.toolResults.some((result) => result.toolName === "new_context") &&
815
- !event.toolResults.some((result) => result.isError)
816
- )
817
- return;
818
- const usage = native.getContextUsage();
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();
819
1122
  if (!usage || usage.tokens == null || usage.contextWindow <= 0) return;
820
- const budget = budgetFor(native, usage.contextWindow);
1123
+ const budget = budgetFor(host, usage.contextWindow);
821
1124
  if (!budget || !budget.enabled || !budget.supported) return;
822
1125
  if (usage.tokens >= budget.rolloverAt) return;
823
1126
  const reminderBuffer = Math.min(REMINDER_BUFFER_TOKENS, Math.floor(budget.usable * 0.1));
@@ -830,11 +1133,12 @@ export default function (pi: ExtensionAPI) {
830
1133
  contextWindow: budget.contextWindow,
831
1134
  reserveTokens: budget.reserveTokens,
832
1135
  };
833
- if (hasReminder(branch, fingerprint)) return;
1136
+ // A reminder a compaction summarized away no longer reaches the model.
1137
+ if (hasReminder(branch.slice(contextStart(branch).start), fingerprint)) return;
834
1138
  pi.sendMessage(
835
1139
  {
836
1140
  customType: REMINDER_TYPE,
837
- 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.`,
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}.`,
838
1142
  display: true,
839
1143
  details: fingerprint,
840
1144
  },
@@ -844,15 +1148,15 @@ export default function (pi: ExtensionAPI) {
844
1148
 
845
1149
  // Filter active input only; raw reminders remain in history for deduplication and recovery.
846
1150
  pi.on("context", (event, ctx) => {
847
- const marker = event.messages.find(
848
- (message) => message.role === "custom" && message.customType === "context-window",
849
- );
850
- const windowId = marker?.role === "custom" ? (marker.details as { windowId?: unknown } | undefined)?.windowId : "initial";
851
- if (typeof windowId !== "string") return;
852
- const native = nativeContext(ctx);
853
1151
  if (!event.messages.some((message) => message.role === "custom" && isReminderType(message.customType))) return;
854
- const budget = budgetFor(native);
855
- const branch = ctx.sessionManager.getBranch() as EntryLike[];
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);
856
1160
  const fingerprint: ReminderFingerprint = {
857
1161
  windowId,
858
1162
  contextWindow: budget?.contextWindow,
@@ -861,58 +1165,88 @@ export default function (pi: ExtensionAPI) {
861
1165
  const stale = (message: (typeof event.messages)[number]) =>
862
1166
  message.role === "custom" &&
863
1167
  isReminderType(message.customType) &&
864
- (!native.getCompactionSettings().enabled || reminderIsStale(message.customType, message.details, fingerprint, branch));
1168
+ (!host.getCompactionSettings().enabled || !reminderMatches(message.details, fingerprint));
865
1169
  if (event.messages.some(stale)) return { messages: event.messages.filter((message) => !stale(message)) };
866
1170
  });
867
1171
 
868
- // Claim Pi's automatic threshold/overflow trigger with a fresh window: no summary, no summarization auth.
869
- (pi as unknown as NativeExtensionAPI).on("session_before_auto_compact", (event, ctx) => {
870
- const native = nativeContext(ctx);
871
- const budget = budgetFor(native);
872
- // An unsupported budget would roll over every turn; leave Pi's own behavior in place instead.
873
- if (budget && !budget.supported) return undefined;
874
- const limit = freshPayloadChars(native, activeToolTokens(), event.pendingMessages);
875
- if (limit < MIN_PAGE_CHARS) return undefined;
876
- const handoff = buildAutoHandoff(event.branchEntries, limit);
877
- if (handoff.length > limit) return undefined;
878
- return { newContext: { handoff } };
879
- });
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 } };
1187
+ });
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
+ }
880
1219
 
881
1220
  pi.registerTool({
882
1221
  name: "new_context",
883
1222
  label: "New Context",
884
1223
  ...toolCards("new_context"),
885
1224
  description:
886
- "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.",
887
- promptSnippet: "start a fresh context window with an optional atomic handoff",
888
- promptGuidelines: [
889
- "Before calling new_context, pass concise continuation state in handoff or save durable goal/progress/decisions/next-steps with notes",
890
- ],
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",
891
1227
  parameters: Type.Object({
892
1228
  handoff: Type.Optional(
893
1229
  Type.String({
894
- description: "Concise state the fresh window needs to continue correctly",
1230
+ description: "What the fresh window needs to continue, as readable prose",
895
1231
  maxLength: MAX_HANDOFF_CHARS,
896
1232
  }),
897
1233
  ),
898
1234
  }),
899
1235
  async execute(_id, { handoff }, _signal, _onUpdate, ctx) {
900
- const native = nativeContext(ctx);
901
1236
  const trimmed = handoff?.trim() || undefined;
902
- const limit = freshPayloadChars(native, activeToolTokens());
1237
+ const limit = freshPayloadChars(policy(ctx), activeToolTokens());
903
1238
  if (trimmed && trimmed.length > limit) {
904
1239
  throw new Error(
905
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.`,
906
1241
  );
907
1242
  }
908
- return {
909
- ...textResult(
910
- "Requested a fresh Pi context after this complete tool batch succeeds. Earlier conversation stays in session history.",
911
- [],
912
- { kind: "new-context" },
913
- ),
914
- newContext: { handoff: trimmed },
915
- };
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;
916
1250
  },
917
1251
  });
918
1252
 
@@ -921,22 +1255,22 @@ export default function (pi: ExtensionAPI) {
921
1255
  label: "Context Remaining",
922
1256
  ...toolCards("get_context_remaining"),
923
1257
  description:
924
- "Best available native estimate of the context budget: tokens until Pi's automatic rollover line and until the configured context limit.",
925
- promptSnippet: "check the remaining context budget only when needed",
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",
926
1260
  parameters: Type.Object({}),
927
1261
  async execute(_id, _params, _signal, _onUpdate, ctx) {
928
- const native = nativeContext(ctx);
929
- const usage = native.getContextUsage();
1262
+ const host = policy(ctx);
1263
+ const usage = host.getContextUsage();
930
1264
  if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.", [], { kind: "context" });
931
1265
  const n = (value: number) => value.toLocaleString("en-US");
932
- const budget = budgetFor(native, usage.contextWindow);
1266
+ const budget = budgetFor(host, usage.contextWindow);
933
1267
  const display: PosthorseDisplay = {
934
1268
  kind: "context", usage,
935
1269
  rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
936
1270
  rolloverAt: budget?.rolloverAt,
937
1271
  };
938
- 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.`;
939
- if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${configured}`, [], display);
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);
940
1274
  if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${configured}`, [], display);
941
1275
  return textResult(
942
1276
  `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
@@ -950,18 +1284,14 @@ export default function (pi: ExtensionAPI) {
950
1284
  label: "Notes",
951
1285
  ...toolCards("notes"),
952
1286
  description:
953
- "Persistent notes in .pi/notes/ that survive context resets. Ops: list/read/search (paged; repeat with offset to continue), write (create/replace; empty content clears), append. Search matches case-insensitive substrings over note lines. Git worktrees share notes: at the main checkout for conventional .git layouts, or in the common Git directory when metadata is stored separately. Old checkout-local notes are imported without overwriting shared notes.",
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.",
954
1288
  promptSnippet: "save and recall durable state that survives context resets",
955
- promptGuidelines: [
956
- "Use notes for durable state too large for a new_context handoff",
957
- "Reload relevant notes after a context rollover",
958
- ],
959
1289
  parameters: Type.Object({
960
1290
  op: Type.Union(
961
1291
  [Type.Literal("list"), Type.Literal("read"), Type.Literal("write"), Type.Literal("append"), Type.Literal("search")],
962
1292
  { description: "Operation to perform" },
963
1293
  ),
964
- path: Type.Optional(Type.String({ description: "Note path relative to .pi/notes/ (read/write/append)" })),
1294
+ path: Type.Optional(Type.String({ description: "Note or folder path relative to .pi/notes/" })),
965
1295
  content: Type.Optional(Type.String({ description: "Full file content (write) or text to add (append)" })),
966
1296
  query: Type.Optional(Type.String({ description: "Substring to find in notes (search)" })),
967
1297
  offset: Type.Optional(Type.Integer({ description: "Character offset for list/read/search (default 0)", minimum: 0 })),
@@ -975,30 +1305,52 @@ export default function (pi: ExtensionAPI) {
975
1305
  }
976
1306
  return join(dir, relative);
977
1307
  };
978
- const walk = (directory: string, output: string[]) => {
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);
979
1312
  for (const file of readdirSync(directory)) {
980
1313
  const path = join(directory, file);
981
- if (statSync(path).isDirectory()) walk(path, output);
982
- else output.push(path);
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 });
983
1318
  }
1319
+ ancestors.delete(root);
984
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
+ );
985
1337
 
986
1338
  switch (params.op) {
987
- case "list": {
988
- const files: string[] = [];
989
- if (existsSync(dir)) walk(dir, files);
990
- return notesPage(nativeContext(ctx), files.map((file) => file.slice(dir.length + 1)), params.offset ?? 0, "notes-list", "(no notes yet)");
991
- }
1339
+ case "list":
1340
+ return list(params.path);
992
1341
  case "read": {
993
1342
  const relative = requireValue(params.path, "path", params.op);
994
1343
  const path = safeJoin(relative);
995
- if (!existsSync(path)) throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
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.`);
996
1348
  const text = readFileSync(path, "utf8");
997
1349
  const offset = params.offset ?? 0;
998
1350
  if (offset && offset >= text.length) {
999
1351
  throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
1000
1352
  }
1001
- const end = Math.min(text.length, offset + pageSize(nativeContext(ctx), offset, 0));
1353
+ const end = Math.min(text.length, offset + pageSize(policy(ctx), offset, 0));
1002
1354
  const more =
1003
1355
  end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
1004
1356
  return pageResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
@@ -1008,7 +1360,7 @@ export default function (pi: ExtensionAPI) {
1008
1360
  if (params.content === undefined) throw new Error(`"content" is required for op "write" (use "" to clear a note).`);
1009
1361
  const path = safeJoin(relative);
1010
1362
  mkdirSync(dirname(path), { recursive: true });
1011
- await publishLocalFile(path, params.content, signal);
1363
+ await withFileMutationQueue(path, () => publishFile(path, params.content!, signal));
1012
1364
  return textResult(`Wrote ${path}`, [], { kind: "note-write" });
1013
1365
  }
1014
1366
  case "append": {
@@ -1019,26 +1371,30 @@ export default function (pi: ExtensionAPI) {
1019
1371
  // One O_APPEND write per newline-terminated record, so concurrent Pi processes appending to a
1020
1372
  // shared note never merge records. The separator only matters after a write that left no trailing
1021
1373
  // newline; a torn read of another process's in-flight append costs at most one blank line.
1022
- const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
1023
- const separator = existing && !existing.endsWith("\n") ? "\n" : "";
1024
- appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
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
+ });
1025
1380
  return textResult(`Appended to ${path}`, [], { kind: "note-append" });
1026
1381
  }
1027
1382
  case "search": {
1028
1383
  const query = requireValue(params.query, "query", params.op).toLowerCase();
1029
- const files: string[] = [];
1030
- if (existsSync(dir)) walk(dir, files);
1031
1384
  const hits: string[] = [];
1032
- for (const file of files) {
1033
- for (const [index, line] of readFileSync(file, "utf8").split("\n").entries()) {
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++;
1034
1390
  const trimmed = line.trim();
1035
1391
  const match = trimmed.toLowerCase().indexOf(query);
1036
1392
  if (match !== -1) {
1037
- hits.push(`${file.slice(dir.length + 1)}:${index + 1}: ${excerptAround(trimmed, match, 50, 200)}`);
1393
+ hits.push(`${file.slice(dir.length + 1)}:${number}: ${excerptAround(trimmed, match, 50, 200)}`);
1038
1394
  }
1039
1395
  }
1040
1396
  }
1041
- return notesPage(nativeContext(ctx), hits, params.offset ?? 0, "notes-search", `No notes match "${excerpt(params.query!, 200)}".`);
1397
+ return notesPage(policy(ctx), hits, params.offset ?? 0, "notes-search", `No notes match "${excerpt(params.query!, 200)}".`);
1042
1398
  }
1043
1399
  }
1044
1400
  },
@@ -1049,61 +1405,83 @@ export default function (pi: ExtensionAPI) {
1049
1405
  label: "History",
1050
1406
  ...toolCards("history"),
1051
1407
  description:
1052
- "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. Continue searches with the returned cursor and the same query/scope; new searches see newer entries. Reads return stored images and page long text with the next offset.",
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.",
1053
1409
  promptSnippet: "recover earlier conversation that left the active context window",
1054
- promptGuidelines: ["Use history search first, then history read with the returned entry id"],
1410
+ promptGuidelines: ["Use history search first, then history read with the entry id exactly as printed"],
1055
1411
  parameters: Type.Object({
1056
1412
  op: Type.Union([Type.Literal("search"), Type.Literal("read")], { description: "Operation to perform" }),
1057
1413
  query: Type.Optional(Type.String({ description: "Case-insensitive text to find (search)" })),
1058
- id: Type.Optional(Type.String({ description: "Entry id returned by search (read)" })),
1059
- all: Type.Optional(Type.Boolean({ description: "Search all project sessions instead of the current branch" })),
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" })),
1060
1416
  limit: Type.Optional(Type.Integer({ description: "Maximum search results per page (default 10, max 50)", minimum: 1, maximum: 50 })),
1061
- cursor: Type.Optional(Type.String({ description: "Search continuation returned by the previous page; keep query and all unchanged", maxLength: 512 })),
1417
+ cursor: Type.Optional(Type.String({ description: "Continuation from the previous search page; keep query and all unchanged", maxLength: 512 })),
1062
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 })),
1063
1420
  }),
1064
1421
  async execute(_id, params, signal, _onUpdate, ctx) {
1065
1422
  const manager = ctx.sessionManager;
1423
+ const cwd = resolve(ctx.cwd);
1424
+ const currentFile = manager.getSessionFile?.();
1425
+ const dir = manager.getSessionDir();
1066
1426
 
1067
1427
  if (params.op === "search") {
1068
1428
  const query = requireValue(params.query, "query", params.op).toLowerCase();
1069
1429
  const limit = params.limit ?? 10;
1070
- const searchKey = createHash("sha256").update(JSON.stringify([query, params.all === true])).digest("base64url");
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;
1071
1447
  let cursor: [string, 0 | 1, number, string] | undefined;
1072
1448
  if (params.cursor) {
1073
1449
  try {
1074
1450
  const value = JSON.parse(Buffer.from(params.cursor, "base64url").toString());
1075
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();
1076
1452
  cursor = value as typeof cursor;
1077
- } catch { pageError(nativeContext(ctx), "Invalid history cursor or changed query/scope; restart the search without it.", params.cursor); }
1453
+ } catch { pageError(policy(ctx), "Invalid history cursor or changed query/scope; restart the search without it.", params.cursor); }
1078
1454
  }
1079
1455
  const hits: HistoryHit[][] = [[], []];
1080
- // Mark skipped ids too: fork copies must not reappear on subsequent pages.
1081
- const seen = new Set<string>();
1082
1456
  let found = !cursor;
1083
1457
  const addHit = (hit: HistoryHit | undefined) => {
1084
- if (!hit || seen.has(hit.id)) return;
1085
- seen.add(hit.id);
1458
+ if (!hit) return;
1086
1459
  if (cursor && hit.priority < cursor[1]) return;
1087
1460
  if (cursor && hit.priority === cursor[1] && !found) {
1088
1461
  if (hit.id !== cursor[0]) return;
1089
1462
  found = true;
1090
- if (cursor[2] > hit.text.length) pageError(nativeContext(ctx), "History cursor is past the entry; restart the search without it.", params.cursor);
1463
+ if (cursor[2] > hit.text.length) pageError(policy(ctx), "History cursor is past the entry; restart the search without it.", params.cursor);
1091
1464
  if (cursor[2] === hit.text.length) return;
1092
1465
  }
1093
1466
  if (hits[hit.priority].length <= limit) hits[hit.priority].push(hit);
1094
1467
  };
1095
1468
 
1096
1469
  if (params.all) {
1097
- for (const file of sessionFiles(manager.getSessionDir())) {
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 })) {
1098
1474
  const recent: HistoryHit[][] = [[], []];
1099
- const matchedIds = new Set<string>();
1100
1475
  let anchorHere = false;
1101
- const source = relative(manager.getSessionDir(), file);
1476
+ const source = relative(dir, file);
1477
+ const inCurrent = resolve(file) === current;
1102
1478
  for await (const item of sessionWindowEntries(file, signal)) {
1103
- if (seen.has(item.entry.id!)) continue;
1104
1479
  const hit = historyHit(item, query, source);
1105
1480
  if (!hit) continue;
1106
- matchedIds.add(hit.id);
1481
+ if (inCurrent && inContext(item.entry)) {
1482
+ skipped++;
1483
+ continue;
1484
+ }
1107
1485
  const seeking = cursor && !found && hit.priority === cursor[1];
1108
1486
  if (seeking && anchorHere) continue;
1109
1487
  if (seeking && hit.id === cursor?.[0]) anchorHere = true;
@@ -1114,26 +1492,34 @@ export default function (pi: ExtensionAPI) {
1114
1492
  }
1115
1493
  if (cursor && !found && !anchorHere) recent[cursor[1]] = [];
1116
1494
  for (const group of recent) for (const hit of group.reverse()) addHit(hit);
1117
- for (const id of matchedIds) seen.add(id);
1118
1495
  if (hits[0].length > limit) break;
1119
1496
  }
1120
1497
  } else {
1121
1498
  const current = [...windowEntries(manager.getBranch() as EntryLike[])];
1122
1499
  for (const item of current.reverse()) {
1123
- addHit(historyHit(item, query));
1500
+ const hit = historyHit(item, query);
1501
+ if (!hit) continue;
1502
+ if (inContext(item.entry)) {
1503
+ skipped++;
1504
+ continue;
1505
+ }
1506
+ addHit(hit);
1124
1507
  if (hits[0].length > limit) break;
1125
1508
  }
1126
1509
  }
1127
- const chars = pageSize(nativeContext(ctx), 0, 0, params.cursor);
1128
- if (!found) pageError(nativeContext(ctx), "History cursor entry no longer matches; restart the search without it.", params.cursor);
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);
1129
1512
  const results = hits.flat();
1130
1513
  const cursorFor = (hit: HistoryHit, offset: number) => Buffer.from(JSON.stringify([hit.id, hit.priority, offset, searchKey])).toString("base64url");
1131
1514
  const footer = (next: string) => `\n[More results; continue with cursor "${next}" and the same query/scope.]`;
1132
1515
  const reserve = Math.max(0, ...results.map((hit) => footer(cursorFor(hit, hit.text.length)).length));
1133
- if (reserve >= chars) pageError(nativeContext(ctx), "History entry id is too large for pagination.", params.cursor);
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
+ : "";
1134
1520
  const parts: string[] = [];
1135
1521
  const spans: Array<{ headerLength: number; length: number }> = [];
1136
- let available = chars - reserve;
1522
+ let available = chars - reserve - skippedNote.length;
1137
1523
  let next: string | undefined;
1138
1524
  for (const hit of results) {
1139
1525
  const offset = cursor?.[0] === hit.id ? cursor[2] : 0;
@@ -1150,39 +1536,62 @@ export default function (pi: ExtensionAPI) {
1150
1536
  if (available <= 0 || parts.length >= limit) break;
1151
1537
  }
1152
1538
  const body = parts.length ? parts.join("\n") : `No history matches "${excerpt(params.query!, 200)}".`;
1153
- const more = next ? footer(next) : "";
1154
- return pageResult(body + more, [], { kind: "history-search", entries: spans, footerLength: more.length });
1539
+ const tail = `${skippedNote}${next ? footer(next) : ""}`;
1540
+ return pageResult(body + tail, [], { kind: "history-search", entries: spans, footerLength: tail.length, more: next !== undefined, skipped });
1155
1541
  }
1156
1542
 
1157
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);
1158
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;
1159
1550
  const offset = params.offset ?? 0;
1160
- if (offset >= item.text.length) {
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)) {
1161
1556
  throw new Error(`Offset ${offset} is past the end of history entry "${id}" (${item.text.length} chars).`);
1162
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);
1163
1563
  const end = Math.min(
1164
1564
  item.text.length,
1165
- offset + pageSize(nativeContext(ctx), offset, offset === 0 ? item.images.length : 0),
1565
+ offset + chars - (images.length - firstImage) * ESTIMATED_IMAGE_CHARS,
1166
1566
  );
1167
- const more = end < item.text.length ? `\nMore remains; call history read with id "${id}" and offset ${end}.` : "";
1168
- const header = `${source ? `${source} ` : ""}${item.entry.timestamp ?? ""} [window ${item.windowId}] [${id}] [chars ${offset}-${end} of ${item.text.length}] `;
1169
- // Stored images ride along with the first page only.
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}] `;
1170
1570
  return pageResult(
1171
1571
  `${header}${item.text.slice(offset, end)}${more}`,
1172
- offset === 0 ? item.images : [],
1173
- { kind: "history-read", headerLength: header.length, offset, end, total: item.text.length },
1572
+ images,
1573
+ { kind: "history-read", headerLength: header.length, offset, end, total: item.text.length, imageOffset, imageEnd, imageTotal: item.images.length },
1174
1574
  );
1175
1575
  };
1176
1576
 
1177
- for (const item of windowEntries(manager.getBranch() as EntryLike[])) {
1178
- if (item.entry.id === id) return formatEntry(item);
1577
+ if (fileKey === undefined) {
1578
+ for (const item of windowEntries(manager.getBranch() as EntryLike[])) {
1579
+ if (item.entry.id === entryId) return formatEntry(item);
1580
+ }
1179
1581
  }
1180
- for (const file of sessionFiles(manager.getSessionDir())) {
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);
1181
1588
  for await (const item of sessionWindowEntries(file, signal)) {
1182
- if (item.entry.id === id) return formatEntry(item, relative(manager.getSessionDir(), file));
1589
+ if (item.entry.id === entryId) return formatEntry(item, fileSource);
1183
1590
  }
1184
1591
  }
1185
- throw new Error(`No history entry with id "${id}".`);
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.`);
1186
1593
  },
1187
1594
  });
1188
- }
1595
+ };
1596
+
1597
+ export default createPosthorse();