pi-posthorse 0.4.5 → 0.4.6

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 (3) hide show
  1. package/README.md +6 -4
  2. package/index.ts +120 -56
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,6 +8,8 @@ Native, no-summary context windows for the [`fitchmultz/pi`](https://github.com/
8
8
 
9
9
  Pi owns the persisted boundary. Posthorse owns the policy: stable window guidance, one best-effort checkpoint reminder, `new_context`, `get_context_remaining`, durable `notes`, and window-aware `history`. A rollover removes the old window from active model context while the JSONL transcript stays append-only and complete.
10
10
 
11
+ An isolated [Codex prototype](adapters/codex-posthorse/README.md) tests local notes and recovery with Codex's native context reset. It is not ready for everyday use and is not included in the Pi package.
12
+
11
13
  ## Requirements
12
14
 
13
15
  - Node `>=22.19.0`.
@@ -52,7 +54,7 @@ Keep exactly one copy loaded. `pi list` shows every package source; if an older
52
54
  4. **`new_context`.** Requests an atomic rollover after the complete tool batch succeeds. An optional handoff is persisted and becomes the first state of the fresh window. If a sibling tool in the same batch fails, Pi does not commit the boundary; the checkpoint reminder still applies.
53
55
  5. **Automatic rollover without summaries.** With a supported context budget and room for a recovery record, Posthorse claims Pi's automatic threshold and overflow trigger through `session_before_auto_compact`, before Pi resolves summarization credentials or prepares a summary. Oversized first turns and tool results can then roll over even without summarization credentials. Otherwise Pi's own compaction remains in control. Manual `/compact` is unchanged.
54
56
  6. **Bounded recovery record.** The automatic handoff keeps direct user inputs, `ask_question` outcomes, visible coordination messages, and the trailing tool batch that no model has consumed yet (call arguments, bounded result text, and the entry ids to recover the rest). A clearly labeled, possibly stale older checkpoint comes last, after the current inputs and unseen results. Older assistant prose and consumed tool results are not treated as state. Newly submitted input stays separate and is saved after the boundary, not copied into the handoff.
55
- 7. **`notes` and `history`.** Notes live with the repository root, shared across linked worktrees. History searches normalized transcript text and returns stored images for a requested entry.
57
+ 7. **`notes` and `history`.** Notes are shared across linked worktrees, at the main checkout for conventional Git layouts or inside the common Git directory when metadata is stored separately. History searches normalized transcript text and returns stored images for a requested entry.
56
58
 
57
59
  At turn end, Posthorse checks whether usage is in the reminder band before explicitly looking up the full branch. Context filtering skips its branch lookup when model input contains neither `posthorse-reminder` nor legacy `headroom-reminder` messages. History searches, reads, and recovery remain available with no new limits.
58
60
 
@@ -79,20 +81,20 @@ In the TUI, tools use Pi's native expandable cards. Collapsed cards show the ope
79
81
 
80
82
  Committed context-window messages and checkpoint reminders are compact, expandable cards too. The `new_context` tool card describes a request; only the committed context-window message says a fresh window has started.
81
83
 
82
- Read pages, including returned images, shrink to the context that is actually left. Before usage is known, they reserve prompt/tool overhead and leave half the rest free. Unsafe pages are refused with the offset preserved; call `new_context` and retry.
84
+ Read pages, including returned images, shrink to the context that is actually left. Parallel note and history reads share that budget; pages already counted by Pi are not counted twice. Before usage is known, they reserve prompt/tool overhead and leave half the rest free. Unsafe pages are refused with the offset preserved in the call; call `new_context` and retry. Refusal text uses the same budget, so later failures omit repeated guidance once no more fits.
83
85
 
84
86
  `history search` puts matching original content before recovery material: handoffs, compaction and branch summaries, checkpoint reminders, and `notes`, `new_context`, and `history` calls/results. Ordinary prose or another tool call in the same assistant entry keeps its priority when that content matches. Every entry remains searchable; `history read` returns the complete normalized entry, including any recovery content omitted from a search excerpt.
85
87
 
86
88
  Within each group, current-branch matches are newest first. With `all: true`, Posthorse searches every session file in the active Pi session directory, newest-modified sessions first and newest entries within each session; this is not a global timestamp sort. The result limit applies after priority, so newer echoes cannot displace older original matches. Entries copied by a fork are reported once.
87
89
 
88
- Notes live in `.pi/notes/` at the repository root (the main checkout for a linked worktree, the current directory outside Git). Add the directory to `.gitignore` when the project should not track it.
90
+ Notes live in `.pi/notes/` at the main checkout for conventional Git layouts, or the current directory outside Git. When Git metadata is stored separately, including in submodules, all checkouts use `.pi/notes/` inside Git's common directory instead; no configuration is needed. If that metadata is unavailable, notes stay local to the checkout. Old checkout-local notes are imported when that checkout is accessed, without overwriting shared files or deleting the originals. Writes report the actual storage path. Add `.pi/notes/` to `.gitignore` when the project should not track it.
89
91
 
90
92
  ## Data and privacy
91
93
 
92
94
  - Posthorse makes no network requests.
93
95
  - Notes are plaintext files under `.pi/notes`. They survive package removal and may be committed unless ignored.
94
96
  - `history` with `all: true` scans nested JSONL files in the active Pi session directory, including subagent sessions.
95
- - History can return user text, assistant text and thinking, tool arguments and results, handoffs, custom messages, and images. Direct shell entries Pi marked `excludeFromContext` come back as a placeholder only.
97
+ - History can return user text, assistant text and thinking, assistant failure status and error messages, tool arguments and results, handoffs, custom messages, and images. Direct shell entries Pi marked `excludeFromContext` come back as a placeholder only.
96
98
  - Returned history content enters the currently selected model and provider context.
97
99
  - Removing Posthorse does not remove notes or Pi session history.
98
100
 
package/index.ts CHANGED
@@ -8,16 +8,18 @@
8
8
 
9
9
  import {
10
10
  appendFileSync,
11
+ constants,
12
+ copyFileSync,
11
13
  createReadStream,
12
14
  existsSync,
13
15
  mkdirSync,
14
16
  readdirSync,
15
17
  readFileSync,
18
+ realpathSync,
16
19
  statSync,
17
20
  writeFileSync,
18
21
  } from "node:fs";
19
- import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
20
- import { createInterface } from "node:readline";
22
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
21
23
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
24
  import { Type } from "typebox";
23
25
  import { registerPosthorseMessages, toolCards, type PosthorseDisplay } from "./ui.ts";
@@ -64,6 +66,7 @@ type ImageLike = { type: "image"; data: string; mimeType: string };
64
66
  type MessageLike = {
65
67
  role?: string;
66
68
  stopReason?: string;
69
+ errorMessage?: string;
67
70
  content?: unknown;
68
71
  toolName?: string;
69
72
  toolCallId?: string;
@@ -164,21 +167,49 @@ function textResult(text: string, images: ImageLike[] = [], display?: PosthorseD
164
167
  return { content: [{ type: "text" as const, text }, ...images], details: display };
165
168
  }
166
169
 
167
- /**
168
- * Notes belong to the repository root, walking up from cwd. A linked worktree's `.git` is a file
169
- * ("gitdir: <main>/.git/worktrees/<name>"); its notes belong to the main checkout so every worktree
170
- * shares them and they outlive the worktree. Outside Git, cwd is the root.
171
- */
170
+ /** Import old checkout-local notes without overwriting shared notes, including concurrent writes. */
171
+ function importLegacyNotes(source: string, target: string): void {
172
+ if (!existsSync(source)) return;
173
+ if (existsSync(target) && (!statSync(target).isDirectory() || realpathSync(source) === realpathSync(target))) return;
174
+ mkdirSync(target, { recursive: true });
175
+ for (const name of readdirSync(source)) {
176
+ const from = join(source, name);
177
+ const to = join(target, name);
178
+ if (statSync(from).isDirectory()) {
179
+ importLegacyNotes(from, to);
180
+ } else {
181
+ try {
182
+ copyFileSync(from, to, constants.COPYFILE_EXCL);
183
+ } catch (error) {
184
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ /** Conventional repos use the main checkout; separate Git directories are their own shared root. */
172
191
  function notesRoot(cwd: string): string {
173
192
  for (let dir = cwd; ; dir = dirname(dir)) {
174
193
  const marker = join(dir, ".git");
175
194
  if (existsSync(marker)) {
195
+ let root: string;
176
196
  try {
177
- const gitdir = readFileSync(marker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
178
- const main = gitdir && resolve(dir, gitdir).match(/^(.+)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/)?.[1];
179
- if (main) return main;
180
- } catch {}
181
- return dir;
197
+ let target = marker;
198
+ if (!statSync(marker).isDirectory()) {
199
+ const gitdir = readFileSync(marker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
200
+ if (!gitdir) return dir;
201
+ target = resolve(dir, gitdir);
202
+ }
203
+ target = realpathSync(target);
204
+ const commonFile = join(target, "commondir");
205
+ const common = realpathSync(existsSync(commonFile) ? resolve(target, readFileSync(commonFile, "utf8").trim()) : target);
206
+ root = basename(common) === ".git" ? dirname(common) : common;
207
+ } catch {
208
+ // Orphaned/copied worktrees can outlive their Git metadata; local notes still work.
209
+ return dir;
210
+ }
211
+ if (root !== dir) importLegacyNotes(join(dir, ".pi", "notes"), join(root, ".pi", "notes"));
212
+ return root;
182
213
  }
183
214
  if (dirname(dir) === dir) return cwd;
184
215
  }
@@ -195,6 +226,12 @@ function excerptAround(text: string, index: number, before: number, length: numb
195
226
  return `${start ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
196
227
  }
197
228
 
229
+ function assistantFailure(message: MessageLike): string {
230
+ if (message.role !== "assistant") return "";
231
+ if (message.stopReason !== "error" && message.stopReason !== "aborted" && !message.errorMessage) return "";
232
+ return `[${message.stopReason ?? "error"}]${message.errorMessage ? ` ${message.errorMessage}` : ""}`;
233
+ }
234
+
198
235
  function flattenEntry(entry: EntryLike): string | undefined {
199
236
  if (entry.type === "message") {
200
237
  const message = entry.message ?? {};
@@ -203,7 +240,7 @@ function flattenEntry(entry: EntryLike): string | undefined {
203
240
  if (message.excludeFromContext === true) return "[bashExecution] (excluded from model context by Pi)";
204
241
  return `[bashExecution] $ ${message.command ?? ""}\n${message.output ?? ""}`;
205
242
  }
206
- return `[${message.role ?? "message"}] ${[textOf(message), imageSummary(imagesOf(message.content))].filter(Boolean).join("\n")}`;
243
+ return `[${message.role ?? "message"}] ${[textOf(message), assistantFailure(message), imageSummary(imagesOf(message.content))].filter(Boolean).join("\n")}`;
207
244
  }
208
245
  if (entry.type === "compaction" || entry.type === "branch_summary") {
209
246
  return `[${entry.type}] ${entry.summary ?? ""}`;
@@ -254,8 +291,8 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
254
291
  if (partText) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${partText}`;
255
292
  }
256
293
  }
257
- const images = imageSummary(item.images);
258
- if (images) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${images}`;
294
+ const metadata = [assistantFailure(entry.message), imageSummary(item.images)].filter(Boolean).join("\n");
295
+ if (metadata) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${metadata}`;
259
296
  if (originals[0]) originals[0] = `[assistant] ${originals[0]}`;
260
297
  const original = originals.find((part) => part.toLowerCase().includes(query));
261
298
  priority = original ? 0 : 1;
@@ -291,25 +328,30 @@ function* windowEntries(entries: Iterable<EntryLike>): Generator<WindowedEntry>
291
328
  }
292
329
  }
293
330
 
331
+ /** JSONL splits on LF, not the Unicode separators that Node's readline also recognizes. */
332
+ async function* jsonlLines(file: string, signal?: AbortSignal): AsyncGenerator<string> {
333
+ const stream = createReadStream(file, { encoding: "utf8", signal });
334
+ let pending = "";
335
+ for await (const chunk of stream) {
336
+ const lines = (pending + chunk).split("\n");
337
+ pending = lines.pop()!;
338
+ yield* lines;
339
+ }
340
+ if (pending) yield pending;
341
+ }
342
+
294
343
  async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncGenerator<WindowedEntry> {
295
344
  if (!existsSync(file)) return;
296
- const stream = createReadStream(file, { encoding: "utf8", signal });
297
- const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
298
345
  const windows = new Map<string, string>();
299
- try {
300
- for await (const line of lines) {
301
- let entry: EntryLike;
302
- try {
303
- entry = JSON.parse(line) as EntryLike;
304
- } catch {
305
- continue;
306
- }
307
- const item = toWindowedEntry(entry, windows);
308
- if (item) yield item;
346
+ for await (const line of jsonlLines(file, signal)) {
347
+ let entry: EntryLike;
348
+ try {
349
+ entry = JSON.parse(line) as EntryLike;
350
+ } catch {
351
+ continue;
309
352
  }
310
- } finally {
311
- lines.close();
312
- stream.destroy();
353
+ const item = toWindowedEntry(entry, windows);
354
+ if (item) yield item;
313
355
  }
314
356
  }
315
357
 
@@ -636,26 +678,13 @@ function unsupportedMessage(budget: Budget): string {
636
678
  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.`;
637
679
  }
638
680
 
639
- /** Characters that fit before Pi's automatic line (or the hard limit), capped at the absolute ceiling. */
640
- function requirePage(ctx: NativeContext, offset: number, imageCount: number, toolTokens: number): number {
681
+ /** Capacity before Pi's automatic line (or hard limit), including the margin for page metadata and refusals. */
682
+ function pageCapacity(ctx: NativeContext, toolTokens: number): number {
641
683
  const usage = ctx.getContextUsage();
642
- let chars: number;
643
- if (!usage || usage.tokens == null) {
644
- chars = Math.max(0, freshPayloadChars(ctx, toolTokens) - imageCount * ESTIMATED_IMAGE_CHARS);
645
- } else {
646
- const budget = budgetFor(ctx, usage.contextWindow);
647
- const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
648
- chars = Math.min(
649
- MAX_HANDOFF_CHARS,
650
- Math.max(0, line - usage.tokens - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
651
- );
652
- }
653
- if (chars < MIN_PAGE_CHARS) {
654
- throw new Error(
655
- `Too little context remains to read a page safely. Call new_context first, then retry with offset ${offset}.`,
656
- );
657
- }
658
- return chars;
684
+ if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens) / 4 + PAGE_MARGIN_TOKENS;
685
+ const budget = budgetFor(ctx, usage.contextWindow);
686
+ const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
687
+ return line - usage.tokens;
659
688
  }
660
689
 
661
690
  function buildGuidance(ctx: NativeContext): string {
@@ -698,7 +727,42 @@ export default function (pi: ExtensionAPI) {
698
727
  );
699
728
  };
700
729
 
730
+ let pendingPageTokens = 0;
731
+ let previousPageUsage: number | null | undefined;
732
+ const pageSize = (ctx: NativeContext, offset: number, imageCount: number) => {
733
+ const usage = ctx.getContextUsage()?.tokens;
734
+ // Parallel siblings are not in native usage yet; sequential results must not be counted twice.
735
+ if (usage != null && previousPageUsage != null) {
736
+ pendingPageTokens = Math.max(0, pendingPageTokens - Math.max(0, usage - previousPageUsage));
737
+ }
738
+ previousPageUsage = usage;
739
+ const remaining = pageCapacity(ctx, activeToolTokens()) - pendingPageTokens;
740
+ const chars = Math.min(
741
+ MAX_HANDOFF_CHARS,
742
+ Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
743
+ );
744
+ if (chars < MIN_PAGE_CHARS) {
745
+ const message = `Too little context remains to read a page safely. Call new_context first, then retry with offset ${offset}.`;
746
+ // Refusals consume context too. Once even the guidance no longer fits, omit repeated text;
747
+ // the failed call still carries its original offset and earlier refusals explain the retry.
748
+ const text = Math.ceil(message.length / 4) <= remaining ? message : "";
749
+ pendingPageTokens += Math.ceil(text.length / 4);
750
+ throw new Error(text);
751
+ }
752
+ return chars;
753
+ };
754
+ const pageResult = (text: string, images: ImageLike[], display: PosthorseDisplay) => {
755
+ pendingPageTokens += Math.ceil(text.length / 4) + images.length * (ESTIMATED_IMAGE_CHARS / 4);
756
+ return textResult(text, images, display);
757
+ };
758
+ pi.on("turn_start", () => {
759
+ pendingPageTokens = 0;
760
+ previousPageUsage = undefined;
761
+ });
762
+
701
763
  pi.on("session_start", (_event, ctx) => {
764
+ pendingPageTokens = 0;
765
+ previousPageUsage = undefined;
702
766
  nativeContext(ctx);
703
767
  });
704
768
 
@@ -854,7 +918,7 @@ export default function (pi: ExtensionAPI) {
854
918
  label: "Notes",
855
919
  ...toolCards("notes"),
856
920
  description:
857
- "Persistent notes in .pi/notes/ that survive context resets. Ops: list, read (paged; pass offset to continue), write (create/replace; empty content clears), append, search (case-insensitive substring over note lines). Inside a Git repository, including nested directories and linked worktrees, notes belong to the main checkout.",
921
+ "Persistent notes in .pi/notes/ that survive context resets. Ops: list, read (paged; pass offset to continue), write (create/replace; empty content clears), append, search (case-insensitive substring over note lines). 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.",
858
922
  promptSnippet: "save and recall durable state that survives context resets",
859
923
  promptGuidelines: [
860
924
  "Use notes for durable state too large for a new_context handoff",
@@ -903,10 +967,10 @@ export default function (pi: ExtensionAPI) {
903
967
  if (offset && offset >= text.length) {
904
968
  throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
905
969
  }
906
- const end = Math.min(text.length, offset + requirePage(nativeContext(ctx), offset, 0, activeToolTokens()));
970
+ const end = Math.min(text.length, offset + pageSize(nativeContext(ctx), offset, 0));
907
971
  const more =
908
972
  end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
909
- return textResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
973
+ return pageResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
910
974
  }
911
975
  case "write": {
912
976
  const relative = requireValue(params.path, "path", params.op);
@@ -914,7 +978,7 @@ export default function (pi: ExtensionAPI) {
914
978
  const path = safeJoin(relative);
915
979
  mkdirSync(dirname(path), { recursive: true });
916
980
  writeFileSync(path, params.content);
917
- return textResult(`Wrote .pi/notes/${relative}`, [], { kind: "note-write" });
981
+ return textResult(`Wrote ${path}`, [], { kind: "note-write" });
918
982
  }
919
983
  case "append": {
920
984
  const relative = requireValue(params.path, "path", params.op);
@@ -927,7 +991,7 @@ export default function (pi: ExtensionAPI) {
927
991
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
928
992
  const separator = existing && !existing.endsWith("\n") ? "\n" : "";
929
993
  appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
930
- return textResult(`Appended to .pi/notes/${relative}`, [], { kind: "note-append" });
994
+ return textResult(`Appended to ${path}`, [], { kind: "note-append" });
931
995
  }
932
996
  case "search": {
933
997
  const query = requireValue(params.query, "query", params.op).toLowerCase();
@@ -1019,12 +1083,12 @@ export default function (pi: ExtensionAPI) {
1019
1083
  }
1020
1084
  const end = Math.min(
1021
1085
  item.text.length,
1022
- offset + requirePage(nativeContext(ctx), offset, offset === 0 ? item.images.length : 0, activeToolTokens()),
1086
+ offset + pageSize(nativeContext(ctx), offset, offset === 0 ? item.images.length : 0),
1023
1087
  );
1024
1088
  const more = end < item.text.length ? `\nMore remains; call history read with id "${id}" and offset ${end}.` : "";
1025
1089
  const header = `${source ? `${source} ` : ""}${item.entry.timestamp ?? ""} [window ${item.windowId}] [${id}] [chars ${offset}-${end} of ${item.text.length}] `;
1026
1090
  // Stored images ride along with the first page only.
1027
- return textResult(
1091
+ return pageResult(
1028
1092
  `${header}${item.text.slice(offset, end)}${more}`,
1029
1093
  offset === 0 ? item.images : [],
1030
1094
  { kind: "history-read", headerLength: header.length, offset, end, total: item.text.length },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "type": "module",
5
5
  "description": "Posthorse: fresh context, same journey. Native no-summary context windows for the fitchmultz/pi fork, with sparse budget reminders, handoffs, notes, and history.",
6
6
  "keywords": ["pi-package", "pi-extension", "context", "posthorse", "rollover"],