pi-posthorse 0.7.0 → 0.8.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 +35 -31
  2. package/index.ts +176 -208
  3. package/package.json +6 -6
  4. package/ui.ts +5 -4
package/README.md CHANGED
@@ -11,19 +11,23 @@ Pi owns the persisted boundary. Posthorse owns the policy: stable window guidanc
11
11
  ## Requirements
12
12
 
13
13
  - Node `>=24.12.0`.
14
- - Official Pi (`@earendil-works/pi-coding-agent`) or the `fitchmultz/pi` fork. Posthorse detects the host when it loads; no configuration is needed.
14
+ - Pi `>=1.0.0`, official (`@earendil-works/pi-coding-agent`) or a maintained fork with the same public compaction contracts. No checkpoint, metadata iterator, or native context-window API is required.
15
15
 
16
- Qualified against official Pi `0.87.1` and fork commit [`b1b4ac34d807b577d66f5879119a9ed4833c8ed7`](https://github.com/fitchmultz/pi/commit/b1b4ac34d807b577d66f5879119a9ed4833c8ed7). CI uses these exact versions.
16
+ Development baseline: exact official Pi `1.0.0` ([source `a13d35a`](https://github.com/earendil-works/pi/commit/a13d35a742c6ef8462812a28fbe1d8c8b7431c32)), TypeBox `1.3.27`, tested on Node `24.21.0`. Host identities, not version strings alone, select compatibility checks. A future fork 1.0 artifact must be qualified separately; this update does not install, activate, or restart the live runtime.
17
17
 
18
18
  ## Install
19
19
 
20
+ Posthorse `0.8.0` requires Pi `>=1.0.0`. Update the host before installing this release; do not activate it on an older live fork.
21
+
20
22
  ```bash
21
- pi install npm:pi-posthorse # from npm
22
- pi install git:github.com/fitchmultz/pi-posthorse # from Git; add @<tag> to pin a release
23
- pi -e npm:pi-posthorse # try it for one run without installing
23
+ pi install npm:pi-posthorse@0.8.0 # from npm
24
+ pi install git:github.com/fitchmultz/pi-posthorse@v0.8.0 # from Git
25
+ pi -e npm:pi-posthorse@0.8.0 # try it for one run without installing
24
26
  ```
25
27
 
26
- Update with `pi update npm:pi-posthorse` or `pi update --extensions`; move a pinned Git install with `pi install git:github.com/fitchmultz/pi-posthorse@<new tag>`. Uninstall with `pi remove npm:pi-posthorse` (or the Git source you installed). Removing the package leaves `.pi/notes` and Pi's session history in place.
28
+ Omit the version or tag to follow the latest release.
29
+
30
+ Update an unpinned install with `pi update npm:pi-posthorse` or `pi update --extensions`. Move a pinned install with `pi install npm:pi-posthorse@<new version>` or `pi install git:github.com/fitchmultz/pi-posthorse@<new tag>`. Uninstall with `pi remove` followed by the source you installed. Removing the package leaves `.pi/notes` and Pi's session history in place.
27
31
 
28
32
  Restart Pi after installing or updating Posthorse to load the new extension code.
29
33
 
@@ -31,32 +35,28 @@ Keep exactly one copy loaded. `pi list` shows every package source; if a second
31
35
 
32
36
  ## Official Pi and the fork
33
37
 
34
- Both hosts get the same tools, guidance, reminders, notes, history, and recovery records. The rollover itself differs:
35
-
36
- - **Fork.** Rollover is a native `context_window` entry: the old window leaves model context and only a short boundary message plus the handoff remains. Posthorse claims automatic rollover through `session_before_auto_compact`, before Pi prepares a summary or resolves summarization credentials, bounds retained tool receipts before the fresh window is published, reads Pi's live compaction settings, and lets native background tool calls continue across the reset.
37
- - **Official Pi.** Rollover is a compaction entry that keeps no earlier conversation and whose summary is the handoff; no summary model is called. `new_context` commits at turn end after every tool in the batch succeeds. Automatic rollover answers Pi's `session_before_compact` for threshold and overflow triggers; manual `/compact` stays Pi's own summary.
38
+ Both hosts get the same tools, guidance, reminders, notes, history, and recovery records. Rollover is a public compaction entry retaining no earlier conversation, with the handoff as its summary; no summary model is called. `new_context` commits at turn end only after Posthorse's own callback succeeds and every sibling tool succeeds. A foreign tool with the same name cannot authorize a rollover. Automatic rollover handles `session_before_compact`; manual `/compact` stays Pi's own summarization.
38
39
 
39
- Official Pi limits that the fork removes:
40
+ - **Native eligibility on both 1.0 targets:** Pi's compaction preparation must succeed before the automatic hook runs. Posthorse's custom compaction result bypasses the default summarizer and its credential lookup. With default `keepRecentTokens`, tiny overflow/truncated responses and a second large result without a new user turn can miss the hook. Lowering `keepRecentTokens` narrows, but does not eliminate, that gap. Oversized initial input can reach the provider before recovery.
41
+ - **Policy:** reminders and budget reports use persisted settings by default; automatic rollover uses the live settings in the event. SDK hosts can inject live policy with `createPosthorse(getPolicy)`. Handoffs appear as compaction-summary messages. Background work owned outside the foreground batch is not serialized or stopped by Posthorse.
40
42
 
41
- - The handoff appears as Pi's compaction-summary message.
42
- - Automatic rollover runs only when Pi's compaction preparation finds something to compact and summarization credentials resolve. With the default `keepRecentTokens`, a small overflow or truncated response, or a second large tool result without a new user turn, can skip it; lowering `keepRecentTokens` narrows that gap.
43
- - Reminders and `get_context_remaining` read persisted settings (a CLI snapshot), because extensions cannot read Pi's live settings. Automatic rollover itself uses the live settings Pi passes to the hook. SDK hosts can pass their live settings with `createPosthorse(getPolicy)`.
44
- - Retained tool receipts are not shortened before the fresh window.
45
- - Sessions are not migrated between hosts. Official Pi ignores fork `context_window` entries, so a fork session opened in official Pi can bring earlier history back into context.
43
+ Legacy fork `context_window` entries remain readable for history recovery. New boundaries do not use the retired native-window/async ABI. Opening legacy fork journals in official Pi can reintroduce earlier context because official Pi does not interpret those old markers; do not assume cross-host session migration.
46
44
 
47
45
  ## How it works
48
46
 
49
- 1. **Stable guidance.** Window behavior lives in a native system-prompt section. There is no per-request meter. Posthorse preserves custom prompts and falls back to appending guidance when an earlier extension replaces the full prompt; that compatibility path can prevent Pi from preserving an additive prompt prefix.
50
- 2. **One best-effort checkpoint.** While Pi compaction is enabled, one reminder may appear shortly before Pi's rollover line, asking the model to rewrite its current-state note in place and call `new_context`. A large turn, overflow, restart, or smaller model can reach rollover without it. Reminders are fingerprinted by window, context size, and reserve, so switching to a different context size gets a fresh reminder. Stale reminders and reminders left after disabling compaction are filtered from model input while remaining in history.
51
- 3. **`get_context_remaining`.** Reports the best available native estimate of tokens until Pi's automatic rollover line and until the configured context limit. That configured limit is not a measured provider rejection boundary. Pi's value is an estimate until the active model reports usage. The checkpoint reminder marks the deadline, so the guidance does not prompt routine budget checks; the tool is described for use before a large step late in a window.
52
- 4. **`new_context`.** Requests an atomic rollover after the current foreground (synchronous) tools succeed. Items 4–6 and the receipt bounding below describe the fork; official Pi differences are listed above. An optional handoff is persisted and becomes the first state of the fresh window. A failed synchronous checkpoint save or other foreground sibling prevents Pi from committing the boundary; the checkpoint reminder still applies. Native asynchronous background calls continue across the reset without delaying it. Pi preserves their call provenance and delivers their results in the fresh context.
53
- 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. Native asynchronous background calls can remain in progress across automatic rollover too. When a recovery record cannot fit but Pi will retain native receipts in the next window, Posthorse claims a window without a handoff so Pi can prepare and bound those receipts or refuse the window. Already-consumed receipts do not trigger an empty rollover. Otherwise Pi's own compaction remains in control. Manual `/compact` is unchanged.
54
- 6. **Bounded recovery record.** The automatic handoff quotes the window's first owner request, its latest owner input (a direct user input or `ask_question` outcome), and its latest input of any kind, including visible coordination messages. It adds tool result evidence: tool names and namespaces, call ids, requested and admitted execution arguments when available, result excerpts of up to 1,500 characters, and entry ids to recover the rest. Every other current-window input gets one index line with its label, time, and entry id; owner inputs keep a one-line preview there. Native async results from the current projected window remain eligible even across later complete assistant responses, since a result can arrive during a response that never received it. A result Pi still projects is also eligible when its call was edited out or predates the window, without attributing it to another call. These results may already have been received or handled; they are not proof of current progress. Synchronous results with projected call provenance are kept only from the trailing batch without a later complete response. Pi context edits also apply to these inputs and results: omitted entries stay out, and replacements supersede their original content. A clearly labeled, possibly stale older checkpoint comes last. Older assistant prose is not treated as current state. Newly submitted input stays separate and is saved after the boundary, not copied into the handoff.
47
+ 1. **Stable guidance.** Each request gets a native system-prompt section stating the best available native context capacity and, when automatic rollover is supported and enabled, its token threshold from the available Pi settings. Fresh windows get the same guidance, refreshed if the model or available settings change during a continuing turn. For virtual models, native limits can come from the routed physical model. These are capacity and policy limits, not remaining space. The guidance distinguishes context capacity from response-token limits and reasoning budgets, and requires a `get_context_remaining` check before reporting remaining tokens or changing plans because of context limits. Unknown usage stays unknown. There is no per-request meter or routine budget check. When an extension forces an opaque full prompt, Posthorse preserves it and supplies guidance as a request-local conversation message, because official Pi applies forced text after system-context hooks. Guidance is regenerated from current state rather than persisted in the transcript.
48
+ 2. **One best-effort checkpoint.** While Pi compaction is enabled, one reminder may appear shortly before Pi's rollover line, asking the model to update its current-state note with available file-editing tools and call `new_context`. Already-current notes need no rewrite; `notes write` remains the fallback for creation, substantial restructuring, or unavailable editing tools. A large turn, overflow, restart, or smaller model can reach rollover without it. Reminders are fingerprinted by window, context size, and reserve, so switching to a different context size gets a fresh reminder. Stale reminders and reminders left after disabling compaction are filtered from model input while remaining in history.
49
+ 3. **`get_context_remaining`.** Reports the best available native estimate of tokens until Pi's automatic rollover line and until the configured context limit. That configured limit is not a measured provider rejection boundary. Pi's value is an estimate until the active model reports usage. Check before claiming a remaining-token count or changing plans because of context limits. The checkpoint reminder marks the deadline, so routine budget checks are unnecessary.
50
+ 4. **`new_context`.** Requests an atomic retain-none rollover once the current foreground tool batch succeeds. An optional handoff becomes the first state of the fresh window. Failed sibling writes, aborted turns, or a handoff that no longer fits after queued input prevent commitment. The request card is not proof that the boundary committed. Own successful callback IDs and admitted handoffs are consumed once at settlement; transcript names alone cannot request a reset.
51
+ 5. **Automatic rollover without summaries.** When Pi reaches the public automatic hook with a supported budget, Posthorse builds a bounded recovery record and retains only an invisible boundary sentinel. Cancellation or recovery construction failure leaves active context intact rather than silently invoking a summarizer. Unsupported small-model budgets retain Pi's native fallback. Manual `/compact` is unchanged. Native eligibility limits are listed above.
52
+ 6. **Bounded recovery record.** The automatic handoff quotes the window's first owner request, its latest owner input (a direct user input or `ask_question` outcome), and its latest input of any kind, including visible coordination messages. It adds tool result evidence: tool names and namespaces, call ids, requested and admitted execution arguments when available, result excerpts of up to 1,500 characters, and entry ids to recover the rest. Every other current-window input gets one index line with its label, time, and entry id; owner inputs keep a one-line preview there. Only a successful result positively linked to a projected unnamespaced `ask_question` call **and** an effective registered unnamespaced `ask_question` definition is an owner answer. Providers can omit call namespaces, so call metadata alone is insufficient. Namespaced, unregistered or unresolved-provenance results remain tool evidence, never owner approval. Legacy async results still visible in a projected journal remain eligible even across later complete assistant responses; this is history compatibility, not a new native-async execution path. A result Pi still projects is also eligible when its call was edited out or predates the window, without attributing it to another call. These results may already have been received or handled; they are not proof of current progress. Synchronous results with projected call provenance are kept only from the trailing batch without a later complete response. Pi context edits also apply to these inputs and results: omitted entries stay out, and replacements supersede their original content. A clearly labeled, possibly stale older checkpoint comes last. Older assistant prose is not treated as current state. Newly submitted input stays separate and is saved after the boundary, not copied into the handoff.
55
53
  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, skips entries whose full text the model already sees in its active context (and says how many), and returns stored images for a requested entry. Edited-away originals and checkpoint reminders stay searchable. Tool calls and results retain names, namespaces, and call ids; admitted execution arguments are labeled separately from requested arguments when present. When a handoff carries edited content, its entry ID points to the replacement; `history read` of that ID returns the replacement text or images. Reading the original entry ID still returns the raw journal content.
56
54
 
57
- Before each fresh window becomes active, Posthorse bounds oversized retained native receipts through append-only context edits. It shares the available capacity across all receipts after accounting for the prompt, tools, pending input, handoff, carried calls, Pi's configured reserve when automatic rollover is enabled and supported, and a safety margin. Pi separately clamps each provider request's output limit to its remaining context capacity. When receipts need shortening, excerpts use half the remaining capacity so the next response has working room. Excerpts retain the native call/result identity and error state, with `history read` references for complete recovery, including images. Existing replacements remain the recovery source across later windows; raw pre-edit content is never used for a new excerpt. Already-fitting receipts stay unchanged. If fixed input and recovery references cannot fit, rollover fails visibly without publishing a window marker or any proposed edits. Later prompts retry preparation against the unchanged window; correcting the configuration allows ordinary rollover to proceed. Pi independently refuses estimated native transcript input that exceeds physical context capacity, including when compaction is disabled. Its output clamp still permits fitting requests with reduced output, even above the compaction reserve line. Raw wire replacements through `onPayload` / `before_provider_request` and custom transport implementations retain responsibility for their own request sizing.
55
+ Retain-none compaction removes prior tool receipts from active input; recovery records carry bounded excerpts and history IDs, while complete raw or edited receipts remain recoverable from the append-only journal. Posthorse no longer registers a native receipt-bounding hook. The host owns physical request sizing and output clamping; custom transports and raw payload replacements remain responsible for their request sizes.
58
56
 
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 no `posthorse-reminder` messages, and on the fork while the window's marker message is present. Complete history remains available through paged searches and reads.
57
+ Each synchronous safety evaluation lazily reads native usage and settings at most once, sharing them through guidance and page sizing. Requests, sibling tools, and final handoff admission each get a fresh snapshot; snapshots never survive an await. Official Pi still performs one uncached native usage acquisition when an evaluation needs it. Routed limits come only from that native reading, not a guessed virtual-model declaration.
58
+
59
+ Reminder lookup follows by-id ancestry to the latest Posthorse boundary and the entire native compaction kept range. It reuses certified append-only state, including negative results, and rebuilds on navigation, session changes, or changed entry identity. No history is capped or dropped. Below-band turns and reminder-free input skip that lookup; legacy window markers can identify the window directly. Automatic recovery reuses the branch supplied by Pi's compaction event. Complete history remains available through unchanged paged searches and reads.
60
60
 
61
61
  Automatic recovery is an emergency input record, not proof of progress. The fresh model is told to restore notes and todo state, inspect history when needed, and verify live state before taking stateful or external action.
62
62
 
@@ -66,7 +66,7 @@ Posthorse follows Pi's effective `compaction` settings, including Pi's decision
66
66
 
67
67
  The model's context window minus `compaction.reserveTokens` must leave at least 10,000 usable tokens. Below that (for example an 8K or 16K model with the default 16,384 reserve) Posthorse reports an unsupported configuration in the guidance and in `get_context_remaining`, turns automatic behavior off for that model, and leaves Pi's own compaction in place. Lower the reserve or use a larger model. The checkpoint reminder band is the last 10% of usable context, capped at 32,000 tokens, so a large reserve cannot trigger a reminder immediately in a fresh window.
68
68
 
69
- Explicit and automatic handoffs are capped at 20,000 characters and half the active model's fresh operational capacity after prompt/tool overhead and any pending input, whichever is smaller. Oversized explicit handoffs are rejected with an instruction to save fuller state in notes; automatic rollover stays with Pi when no safe recovery record fits and no native receipts require fresh-window preparation. Notes and history pages are capped at 40,000 characters and shrink to the remaining budget; when usage is not known yet, they also leave half the fresh operational capacity free.
69
+ Explicit and automatic handoffs are capped at 20,000 characters and half the native context limit's fresh operational capacity after prompt/tool overhead and any pending input, whichever is smaller. Oversized explicit handoffs are rejected with an instruction to save fuller state in notes; automatic rollover cancels visibly when a supported configuration has no room for a safe recovery record. Notes and history pages are capped at 40,000 characters and shrink to the remaining budget; when usage is not known yet, they also leave half the fresh operational capacity free. Payload budgets reserve Posthorse's request-local guidance too, because native estimates can omit it.
70
70
 
71
71
  Only one automatic compaction or rollover policy extension should be enabled at a time. Pi keeps the last non-cancel result from multiple handlers of the same hook, so load order would otherwise decide which policy wins.
72
72
 
@@ -74,12 +74,12 @@ Only one automatic compaction or rollover policy extension should be enabled at
74
74
 
75
75
  - `new_context({ handoff? })`
76
76
  - `get_context_remaining()`
77
- - `notes({ op, ... })`: `list`, `read`, and `search` are paged; continue with the returned character `offset`. `list` shows notes newest first with size and modification time (UTC) and takes an optional folder `path`; `read` of a folder returns its listing. `search` covers text notes, skipping binary files (a NUL byte in the first 8,000 bytes, as Git decides), and its excerpts center on the match. `write` replaces content (empty content clears); `append` adds one newline-terminated record.
77
+ - `notes({ op, ... })`: `list`, `read`, and `search` are paged; continue with the returned character `offset`. `list` shows the absolute shared notes directory, then relative note names newest first with size and modification time (UTC), and takes an optional folder `path`; `read` of a folder returns its listing. File reads show the absolute file path before the content. Offsets refer to note content or listing rows, excluding path headers. `search` covers text notes, skipping binary files (a NUL byte in the first 8,000 bytes, as Git decides), and its excerpts center on the match. `write` replaces content (empty content clears); `append` adds one newline-terminated record.
78
78
  - `history({ op, ... })`: `search` skips entries whose full text is already in the active context, continues with the returned `cursor` and the same `query` and `all` scope, and treats `limit` as the maximum results per page. `read` pages text and stored images; continue with the returned character `offset` and `imageOffset`. An image-only continuation can use an `offset` equal to the text length. If a page needs fresh context, retry with both offsets unchanged. Results keep native entry and window ids.
79
79
 
80
80
  In the TUI, tools use Pi's native expandable cards. Collapsed cards show the operation and target, a short content preview, and counts or page ranges with the next offset when more remains. Expand with Pi's tool-output shortcut (`Ctrl+O` by default), or click the card's header or body in fullscreen mode. Expanded cards show the complete returned page and its metadata, not content the tool has not fetched yet. Writes and context requests also show the submitted content or handoff.
81
81
 
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.
82
+ The `new_context` tool card describes a request, not a completed reset. Pi's native compaction card (`Compacted from … tokens`) shows the committed current boundary and expands to its handoff. Context-window cards are historical fork boundaries only; checkpoint reminders remain compact, expandable cards too.
83
83
 
84
84
  Notes list/search and history search share the remaining context budget with read pages and returned images. Search/list headers, continuation text, no-match responses, and parallel sibling results count toward that budget; pages already counted by Pi are not counted twice. Before usage is known, pages reserve prompt/tool overhead and leave half the rest free. Unsafe pages are refused with the offset or cursor 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.
85
85
 
@@ -87,12 +87,15 @@ Notes list/search and history search share the remaining context budget with rea
87
87
 
88
88
  Within each group, current-branch matches are newest first. With `all: true`, Posthorse searches session files in the active Pi session directory whose saved working directory matches the current one, along with their nested subagent sessions and the selected current session file when it is in that directory. Files without a valid session header or an attributable working directory are excluded; the active branch remains available. Within that scope, sessions are ordered newest-modified first and entries newest first; this is not a global timestamp sort. All-session hits use a file-qualified ID (`native-id@file-key`, with a 10-character key); pass the complete ID to `history read`. Full-length keys printed by earlier releases still read. Hits from nested subagent runs are tagged `[subagent]`, because their user-role input came from a parent agent. A read that resolves a bare ID in another session file prints that entry's qualified ID, so continuations reopen the same file. When ripgrep is available (Pi's managed copy, then `PATH`), all-session search and bare-ID reads skip session files that cannot contain the query or ID. A query that could match text session files store escaped (quotes, backslashes) or label text that exists only in normalized entries (brackets, parentheses, braces, colons, `$`, leading or trailing spaces, or phrases such as "Call ID" and "No handoff") scans every file instead. Image summaries come only from entries with an image block, so image-count and comma queries also search every file that has one. Either way, results match a full scan. The per-page result limit applies after priority, so newer echoes cannot displace older original matches. Each included session file contributes its own matches, including fork copies, so unrelated entries sharing a native ID are never hidden. Search cursors advance past the previous entry, including partial headers or excerpts, so appending lookup calls and results does not keep pagination alive forever. Searches are live: external session edits or file reordering can change results; start a new search to include newer sources.
89
89
 
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. Writes report the actual storage path. Add `.pi/notes/` 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. Reads, listings, and writes report the actual storage location. Add `.pi/notes/` to `.gitignore` when the project should not track it.
91
+
92
+ Keep one concise current-state note per task, preserving decisions and safety constraints and linking fuller evidence or history. For existing notes, edit changed sections with an available file-editing tool (`apply_patch`, `replace_text`, or Pi's `edit`) rather than resending the whole file. Use the absolute path returned by `notes read`, or join the directory from `notes list` with the relative note name: a worktree's local `.pi/notes/` may not be the shared location. The notes tool's own `path` argument remains relative to the shared notes directory. No Posthorse-specific editor is required, and failed sibling edits still prevent an explicit `new_context` reset.
91
93
 
92
94
  `write` is a complete-file replacement in the note's directory: a failed write before publication leaves the previous note intact. It follows symlink targets and preserves ordinary permissions and ownership, or fails before publication. Hardlink aliases and already-open handles keep the previous file; ACLs, extended attributes, and power-loss durability are not guaranteed. Within one Pi process, writes and appends to the same note run in order; across processes, full replacements do not merge concurrent appends or other replacements. `append` retains one `O_APPEND` write per newline-terminated record.
93
95
 
94
96
  ## Data and privacy
95
97
 
98
+ - For cached-token cost diagnosis and a safe stock-compaction comparison, see the [cached-token investigation](https://github.com/fitchmultz/pi-posthorse/blob/main/ARTIFACTS/investigation-report.md). It does not establish the cause of a production bill or claim a measured cost improvement.
96
99
  - Posthorse makes no network requests.
97
100
  - Notes are plaintext files under `.pi/notes`. They survive package removal and may be committed unless ignored.
98
101
  - `history` with `all: true` scans project-matching JSONL files and their nested subagent sessions in the active Pi session directory, not unrelated projects that share that directory. It runs ripgrep locally over that directory when available.
@@ -105,11 +108,12 @@ Notes live in `.pi/notes/` at the main checkout for conventional Git layouts, or
105
108
  ```bash
106
109
  npm ci --ignore-scripts
107
110
  npm run check:compat # typecheck, unit tests, and the real-SDK suite for the installed host
108
- PI_FORK=../pi scripts/integration.sh # additional full source-harness integration (disposable fork built)
109
111
  ```
110
112
 
111
113
  `npm run check` type-checks the extension, renderers, and tests against the official npm declarations. `npm test` covers reminder policy, notes/history behavior, failed note replacement, and real native card components, including width and expansion.
112
114
 
113
- `test:native` runs the real-SDK suite for whichever host is installed: `test/official.test.mjs` for official Pi, `test/native.test.mjs` when the fork SDK is installed. Both cover real SDK loading, prompt composition, explicit and automatic rollover without a summary request, recovery records, notes, and history; the fork suite adds receipt bounding, native async calls, and checkpoint restore. When `PI_COMPAT_HOST` is `fork` or `official`, a mismatch with the installed SDK fails instead of running the other suite. The shared compatibility runner installs each host's SDK cohort in this checkout's `node_modules` so both `tsc` and SDK imports resolve that graph; `PI_HOST_INDEX` and `PI_COMPAT_EXPECTED_PACKAGE_DIR` are verified against it, and `PI_COMPAT_EXPECTED_VERSION` verifies the version.
115
+ Development dependency policy: update TypeBox alongside the Pi cohort to match the host's version, not independently. Official 1.0 uses 1.3.27. The earlier 0.99.2 startup investigation is historical, not a current support target or a claim about 1.0 performance. Qualify later releases against their actual packaged SDK/CLI and companion graph; resolve companions relative to coding-agent because its shrinkwrap nests dependencies. Revisit the TypeBox pin when the host changes its dependency.
116
+
117
+ `test:native` runs public-API suites against the installed real SDK, covering native recovery eligibility and physical request boundaries, real loading, prompt composition, explicit/automatic rollover without a summary call, owner-provenance safety, failed siblings, cancellation, queued input, recovery records, notes and history. The 1.0 fork starts from official semantics; old fork checkpoint and early-compaction customizations are not current contracts. Legacy saved-window and async-result data remain recoverable; no retired runtime ABI or blanket skips are needed. `PI_COMPAT_HOST` labels the selected distribution; `PI_HOST_INDEX`, `PI_COMPAT_EXPECTED_PACKAGE_DIR` and `PI_COMPAT_EXPECTED_VERSION` validate the actual selected graph. Typechecking uses that graph too, never an optional fork method as a distribution heuristic.
114
118
 
115
- For a focused read-only SDK diagnostic only, `PI_HOST_INDEX=/absolute/fork/dist/index.js node --test test/native.test.mjs` can inspect an immutable host; this does not qualify this checkout's types. The larger source integration suite remains available through `scripts/integration.sh`; use a disposable built fork checkout because it briefly copies a test into that host. All native fixtures must use an isolated HOME/agent directory with offline mode and no provider credentials. These checks do not claim native Windows support.
119
+ For a focused read-only SDK diagnostic only, `PI_HOST_INDEX=/absolute/fork/dist/index.js node --test test/native.test.mjs` can inspect an immutable host; this does not qualify this checkout's types. The canonical real-SDK suites replace the obsolete source-copying fork harness; validation never needs to write into a host checkout. All native fixtures must use an isolated HOME/agent directory and short TMPDIR outside real-home ancestry, with no project markers in temporary-path ancestors, offline mode and no provider credentials. These checks do not claim native Windows support.
package/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Posthorse — fresh context, same journey.
3
3
  *
4
- * No-summary context rollover for Pi. On the fitchmultz/pi fork, Pi persists native context
5
- * windows; on official Pi, rollover is a retain-none compaction whose summary is the handoff.
4
+ * No-summary context rollover for Pi: a retain-none public compaction whose summary is
5
+ * the handoff, on official Pi and the maintained fork.
6
6
  * Pi owns the persisted boundary. Posthorse owns the policy: sparse budget reminders, rollover
7
7
  * tools, durable notes, and history recovery.
8
8
  */
@@ -46,31 +46,13 @@ const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before
46
46
  type CompactionPolicy = { enabled: boolean; reserveTokens: number };
47
47
  type ContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
48
48
 
49
- /** The fork exposes live settings on the context; official Pi gets them from the injected policy. */
49
+ /** Settings come from the injected policy; the compaction hook provides Pi's live policy. */
50
50
  type PolicyContext = {
51
- model?: { contextWindow: number };
52
51
  getCompactionSettings(): CompactionPolicy;
53
52
  getContextUsage(): ContextUsage | undefined;
54
53
  getSystemPrompt(): string;
55
54
  };
56
55
 
57
- /** Fork-only extension API; the published Pi types do not declare it. */
58
- type NativeExtensionAPI = {
59
- registerContextWindowHook(
60
- handler: (
61
- event: { contextEntries: ProjectedEntry[]; pendingMessages: MessageLike[] },
62
- ctx: ExtensionContext,
63
- ) => ReceiptEdit[] | undefined,
64
- ): void;
65
- on(
66
- event: "session_before_auto_compact",
67
- handler: (
68
- event: { reason: "overflow" | "threshold"; branchEntries: EntryLike[]; pendingMessages?: MessageLike[]; retainedToolResultIds: string[] },
69
- ctx: unknown,
70
- ) => { newContext: { handoff?: string } } | undefined,
71
- ): void;
72
- };
73
-
74
56
  type ImageLike = { type: "image"; data: string; mimeType: string };
75
57
  type ToolCallLike = {
76
58
  type?: string;
@@ -114,7 +96,6 @@ type EntryLike = {
114
96
 
115
97
  type WindowedEntry = { entry: EntryLike; windowId: string; text: string; images: ImageLike[] };
116
98
  type ProjectedEntry = { sourceEntry: EntryLike; messages: MessageLike[] };
117
- type ReceiptEdit = { type: "context_edit"; targetId: string; replacement: { content: string } };
118
99
  type HistoryHit = { id: string; text: string; headerLength: number; priority: 0 | 1 };
119
100
  type RecoveryRecord = {
120
101
  id: string;
@@ -134,7 +115,7 @@ type Budget = {
134
115
  supported: boolean;
135
116
  };
136
117
 
137
- /** A fork context window, or the retain-none compaction Posthorse commits on official Pi. */
118
+ /** A legacy fork context window, or the retain-none compaction Posthorse commits on both hosts. */
138
119
  function isWindow(entry: EntryLike): boolean {
139
120
  return entry.type === "context_window" || (entry.type === "compaction" && (entry.details as { posthorse?: unknown } | undefined)?.posthorse === 1);
140
121
  }
@@ -574,14 +555,6 @@ async function* scopedSessionFiles(
574
555
  }
575
556
  }
576
557
 
577
- function currentWindowId(entries: readonly EntryLike[]): string {
578
- for (let i = entries.length - 1; i >= 0; i--) {
579
- const entry = entries[i];
580
- if (isWindow(entry) && entry.id) return entry.id;
581
- }
582
- return "initial";
583
- }
584
-
585
558
  function excerpt(text: string, limit: number): string {
586
559
  if (text.length <= limit) return text;
587
560
  const marker = "\n… middle omitted …\n";
@@ -595,7 +568,7 @@ function boundedBlock(header: string, text: string, limit: number): string {
595
568
  return textLimit ? `${header}\n${excerpt(text, textLimit)}` : header;
596
569
  }
597
570
 
598
- function recoveryRecord(entry: EntryLike): RecoveryRecord | undefined {
571
+ function recoveryRecord(entry: EntryLike, ownerQuestion: boolean): RecoveryRecord | undefined {
599
572
  let kind: RecoveryRecord["kind"];
600
573
  let label: string;
601
574
  let text: string;
@@ -610,6 +583,7 @@ function recoveryRecord(entry: EntryLike): RecoveryRecord | undefined {
610
583
  entry.message?.role === "toolResult" &&
611
584
  entry.message.toolName === "ask_question" &&
612
585
  entry.message.namespace === undefined &&
586
+ ownerQuestion &&
613
587
  entry.message.isError !== true
614
588
  ) {
615
589
  kind = "owner";
@@ -717,7 +691,7 @@ function recoverableToolResults(
717
691
  return { callId: allLinked ? (call?.id ?? "unknown").slice(0, 120) : undefined, blocks };
718
692
  }
719
693
 
720
- function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly ProjectedEntry[], maxChars: number): string {
694
+ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly ProjectedEntry[], maxChars: number, ownerQuestionRegistered: boolean): string {
721
695
  const { start, boundary: priorWindow } = contextStart(entries);
722
696
  const current = entries.slice(start);
723
697
  const edits = contextEdits(entries);
@@ -731,8 +705,15 @@ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly Pro
731
705
  if (entry.type === "custom_message") return [{ ...entry, id: edit.id, content: edit.replacement.content }];
732
706
  return [entry];
733
707
  });
708
+ const projectedCalls = new Map(projected.flatMap(({ messages }) => messages.flatMap((message) =>
709
+ message.role === "assistant" && Array.isArray(message.content)
710
+ ? (message.content as ToolCallLike[]).filter((call) => call.type === "toolCall").map((call) => [call.id, call] as const) : [])));
734
711
  const records = edited
735
- .map((entry) => recoveryRecord(entry))
712
+ .map((entry) => {
713
+ // Providers can omit call namespaces; both registration and projected call must prove ownership.
714
+ const call = projectedCalls.get(entry.message?.toolCallId);
715
+ return recoveryRecord(entry, ownerQuestionRegistered && call?.name === "ask_question" && call.namespace === undefined);
716
+ })
736
717
  .filter((record): record is RecoveryRecord => record !== undefined);
737
718
  const firstOwnerRequest = records.find((record) => record.label === "owner input") ?? records[0];
738
719
  const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
@@ -851,7 +832,7 @@ function hasReminder(entries: readonly EntryLike[], fingerprint: ReminderFingerp
851
832
  );
852
833
  }
853
834
 
854
- function budgetFor(ctx: PolicyContext, contextWindow = ctx.model?.contextWindow): Budget | undefined {
835
+ function budgetFor(ctx: PolicyContext, contextWindow = ctx.getContextUsage()?.contextWindow): Budget | undefined {
855
836
  if (!contextWindow || contextWindow <= 0) return undefined;
856
837
  const { enabled, reserveTokens } = ctx.getCompactionSettings();
857
838
  const usable = contextWindow - reserveTokens;
@@ -860,11 +841,10 @@ function budgetFor(ctx: PolicyContext, contextWindow = ctx.model?.contextWindow)
860
841
 
861
842
  /** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
862
843
  function freshPayloadChars(ctx: PolicyContext, toolTokens: number, pendingMessages: readonly MessageLike[] = [], cap = MAX_HANDOFF_CHARS): number {
863
- const contextWindow = ctx.model?.contextWindow;
864
- if (!contextWindow || contextWindow <= 0) return cap;
865
- const budget = budgetFor(ctx, contextWindow);
866
- const line = budget?.enabled && budget.supported ? budget.rolloverAt : contextWindow;
867
- const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4);
844
+ const budget = budgetFor(ctx);
845
+ if (!budget) return cap;
846
+ const line = budget.enabled && budget.supported ? budget.rolloverAt : budget.contextWindow;
847
+ const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + guidanceTokens(ctx);
868
848
  const pendingTokens = pendingMessages.reduce(
869
849
  (total, message) => total + Math.ceil(textOf(message).length / 4) + imagesOf(message.content).length * (ESTIMATED_IMAGE_CHARS / 4),
870
850
  0,
@@ -875,74 +855,9 @@ function freshPayloadChars(ctx: PolicyContext, toolTokens: number, pendingMessag
875
855
  );
876
856
  }
877
857
 
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
858
  /** Official Pi cannot expose its live settings to extensions; budget reports say when they used the persisted snapshot. */
944
859
  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";
860
+ const CHECKPOINT_STEPS = "update the task's current-state note (goal, progress, decisions, next steps) with available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. If the note is already current, leave it unchanged. Then call new_context";
946
861
 
947
862
  function unsupportedMessage(budget: Budget): string {
948
863
  const n = (value: number) => value.toLocaleString("en-US");
@@ -955,28 +870,34 @@ function pageCapacity(ctx: PolicyContext, toolTokens: number): number {
955
870
  if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens, [], MAX_PAGE_CHARS) / 4 + PAGE_MARGIN_TOKENS;
956
871
  const budget = budgetFor(ctx, usage.contextWindow);
957
872
  const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
958
- return line - usage.tokens;
873
+ return line - usage.tokens - guidanceTokens(ctx);
959
874
  }
960
875
 
961
- function buildGuidance(ctx: PolicyContext, official: boolean): string {
876
+ function buildGuidance(ctx: PolicyContext): string {
962
877
  const budget = budgetFor(ctx);
963
878
  const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
879
+ const n = (value: number) => value.toLocaleString("en-US");
880
+ const capacity = budget
881
+ ? `Configured context capacity: ${n(budget.contextWindow)} tokens. This is Pi's best available native limit, not remaining space or a measured provider boundary. Fresh windows use the active configuration; system instructions, tools, the handoff, and new messages consume part of it.`
882
+ : "Configured context capacity is unknown.";
964
883
  let automatic: string;
965
884
  if (!enabled) {
966
- automatic = official
967
- ? "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available."
968
- : "Pi compaction is disabled, so Posthorse sends no checkpoint reminder and performs no automatic rollover. new_context remains available.";
885
+ automatic = "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available.";
969
886
  } else if (budget && !budget.supported) {
970
887
  automatic = unsupportedMessage(budget);
971
888
  } else {
972
889
  const deadline = budget
973
890
  ? `${Math.max(1, Math.round((budget.rolloverAt / budget.contextWindow) * 100))}% used`
974
891
  : "the configured Pi context limit";
975
- automatic = `Automatic rollover follows Pi's compaction setting. At most one checkpoint reminder may arrive before the rollover line (${deadline}); when it does, stop normal work, ${CHECKPOINT_STEPS}.`;
892
+ const line = budget ? ` The rollover line is ${n(budget.rolloverAt)} tokens used under the available Pi settings.` : "";
893
+ automatic = `Automatic rollover follows Pi's compaction setting.${line} At most one checkpoint reminder may arrive before the rollover line (${deadline}); when it does, stop normal work, ${CHECKPOINT_STEPS}.`;
976
894
  }
977
895
  return `## Context self-management (Posthorse)
896
+ ${capacity}
897
+ Do not guess context capacity or remaining space from response-token limits or reasoning budgets. Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining. If usage is unknown, report it as unknown. No routine budget checks are needed.
978
898
  ${automatic}
979
899
  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.
900
+ Keep one concise current-state note per task. Preserve decisions and safety constraints; link fuller evidence and history instead of copying them. Edit changed sections rather than resending unchanged content.
980
901
  Write handoffs and notes as normal readable prose; notes and history search match literal text.`;
981
902
  }
982
903
 
@@ -987,24 +908,74 @@ function persistedPolicy(ctx: ExtensionContext): CompactionPolicy {
987
908
  return settings.getCompactionSettings(ctx.model);
988
909
  }
989
910
 
990
- /** SDK hosts on official Pi can inject their live settings instead of the persisted snapshot; the fork always uses its own. */
911
+ /** ponytail: native estimates can omit request-local guidance; reserve its full section until post-transform usage is exposed. */
912
+ function guidanceTokens(ctx: PolicyContext): number {
913
+ return estimateTokens({ role: "system", content: `<posthorse>\n${buildGuidance(ctx)}\n</posthorse>`, timestamp: 0 });
914
+ }
915
+
916
+ /** SDK hosts can inject live settings instead of the persisted CLI snapshot. */
991
917
  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(),
918
+ const resetRequests = new Map<string, string>();
919
+ // One synchronous safety evaluation only. Never carry this snapshot across an await.
920
+ const policy = (ctx: ExtensionContext, livePolicy?: CompactionPolicy): PolicyContext => {
921
+ let usageRead = false;
922
+ let usage: ContextUsage | undefined;
923
+ let settings: CompactionPolicy | undefined;
924
+ return {
925
+ getCompactionSettings: () => settings ??= { ...(livePolicy ?? getPolicy(ctx)) },
926
+ getContextUsage: () => {
927
+ if (!usageRead) {
928
+ usage = ctx.getContextUsage();
929
+ usageRead = true;
930
+ }
931
+ return usage;
932
+ },
999
933
  getSystemPrompt: () => ctx.getSystemPrompt(),
1000
934
  };
935
+ };
936
+ type ReminderState = {
937
+ manager: ExtensionContext["sessionManager"];
938
+ sessionId: string;
939
+ file: string | undefined;
940
+ leaf: EntryLike | undefined;
941
+ windowId: string;
942
+ reminders: EntryLike[];
943
+ };
944
+ let reminderState: ReminderState | undefined;
945
+ const remindersFor = (ctx: ExtensionContext): ReminderState => {
946
+ const manager = ctx.sessionManager;
947
+ const sessionId = manager.getSessionId(), file = manager.getSessionFile();
948
+ const leaf = manager.getEntry(manager.getLeafId() ?? "") as EntryLike | undefined;
949
+ const previous = reminderState?.manager === manager && reminderState.sessionId === sessionId && reminderState.file === file
950
+ ? reminderState : undefined;
951
+ if (previous && previous.leaf === leaf) return previous;
952
+ const reversed: EntryLike[] = [];
953
+ let boundary: EntryLike | undefined, window: EntryLike | undefined;
954
+ let kept = false;
955
+ for (let entry = leaf; entry; entry = entry.parentId ? manager.getEntry(entry.parentId) as EntryLike | undefined : undefined) {
956
+ // Certify append-only ancestry by entry identity, not just a reusable id.
957
+ if (!boundary && previous?.leaf === entry) {
958
+ return reminderState = { manager, sessionId, file, leaf, windowId: previous.windowId,
959
+ reminders: [...previous.reminders, ...reversed.reverse().filter((entry) => entry.type === "custom_message" && isReminderType(entry.customType))] };
960
+ }
961
+ reversed.push(entry);
962
+ if (!boundary && (entry.type === "compaction" || entry.type === "context_window")) boundary = entry;
963
+ if (!window && isWindow(entry)) window = entry;
964
+ if (boundary && (boundary.type === "context_window" || entry.id === boundary.firstKeptEntryId)) kept = true;
965
+ // An ordinary compaction can keep a tail preceding the Posthorse boundary.
966
+ // Missing kept ids or a negative window lookup must reach the root once.
967
+ if (window && kept) break;
968
+ }
969
+ const entries = reversed.reverse();
970
+ return reminderState = { manager, sessionId, file, leaf, windowId: window?.id ?? "initial",
971
+ reminders: entries.slice(contextStart(entries).start).filter((entry) => entry.type === "custom_message" && isReminderType(entry.customType)) };
972
+ };
1001
973
  registerPosthorseMessages(pi);
1002
974
  const activeToolTokens = () => {
1003
975
  const active = new Set(pi.getActiveTools());
1004
976
  return pi
1005
977
  .getAllTools()
1006
- // The fork identifies namespaced tools by id; official Pi only has names.
1007
- .filter((tool) => active.has((tool as typeof tool & { id?: string }).id ?? tool.name))
978
+ .filter((tool) => active.has(tool.name))
1008
979
  .reduce(
1009
980
  (total, tool) =>
1010
981
  total +
@@ -1016,12 +987,6 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1016
987
  );
1017
988
  };
1018
989
 
1019
- if (native) {
1020
- nativePi.registerContextWindowHook((event, ctx) =>
1021
- boundWindowReceipts(ctx as ExtensionContext & PolicyContext, event.contextEntries, event.pendingMessages, activeToolTokens()),
1022
- );
1023
- }
1024
-
1025
990
  let pendingPageTokens = 0;
1026
991
  let previousPageUsage: number | null | undefined;
1027
992
  const pageSize = (ctx: PolicyContext, offset: number, imageCount: number, cursor?: string, imageOffset?: number) => {
@@ -1055,12 +1020,13 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1055
1020
  pendingPageTokens += Math.ceil(message.length / 4);
1056
1021
  throw new Error(message);
1057
1022
  };
1058
- const notesPage = (ctx: PolicyContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string) => {
1023
+ const notesPage = (ctx: PolicyContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string, header = "") => {
1059
1024
  const text = rows.length ? rows.join("\n") : empty;
1060
- const chars = pageSize(ctx, offset, 0);
1061
- if (offset && offset >= text.length) pageError(ctx, "Offset is past the end; restart with offset 0.");
1025
+ const chars = pageSize(ctx, offset, 0) - header.length;
1062
1026
  // Reserve the complete continuation, even when one path or excerpt needs several pages.
1063
1027
  const footer = (end: number) => `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`;
1028
+ if (chars <= footer(text.length).length) pageError(ctx, `Too little context remains to include the notes path. Call new_context first, then retry with offset ${offset}.`);
1029
+ if (offset && offset >= text.length) pageError(ctx, "Offset is past the end; restart with offset 0.");
1064
1030
  let end = Math.min(text.length, offset + chars);
1065
1031
  let start = 0;
1066
1032
  const starts: number[] = [];
@@ -1073,44 +1039,56 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1073
1039
  }
1074
1040
  // A result-count cap can require continuation even when the character cap did not.
1075
1041
  if (end < text.length) end = Math.min(end, offset + chars - footer(text.length).length);
1076
- return pageResult(text.slice(offset, end) + (end < text.length ? footer(end) : ""), [], { kind, count: starts.filter((start) => start < end).length, page: { offset, end, total: text.length } });
1042
+ return pageResult(header + text.slice(offset, end) + (end < text.length ? footer(end) : ""), [], { kind, headerLength: header.length, count: starts.filter((start) => start < end).length, page: { offset, end, total: text.length } });
1077
1043
  };
1078
1044
  pi.on("turn_start", () => {
1045
+ resetRequests.clear();
1079
1046
  pendingPageTokens = 0;
1080
1047
  previousPageUsage = undefined;
1081
1048
  });
1082
1049
 
1083
1050
  pi.on("session_start", () => {
1051
+ resetRequests.clear();
1084
1052
  pendingPageTokens = 0;
1085
1053
  previousPageUsage = undefined;
1054
+ reminderState = undefined;
1055
+ });
1056
+ pi.on("session_tree", () => { reminderState = undefined; });
1057
+
1058
+ let runPromptOptions: { forceSystemPrompt?: string } | undefined;
1059
+ pi.on("before_agent_start", (event) => {
1060
+ runPromptOptions = event.systemPromptOptions;
1086
1061
  });
1087
1062
 
1088
- pi.on("before_agent_start", (event, ctx) => {
1089
- const guidance = buildGuidance(policy(ctx), !native);
1090
- // An earlier full-prompt override makes section edits invisible to Pi.
1091
- if (event.systemPromptOptions.forceSystemPrompt !== undefined) {
1092
- return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` };
1063
+ // Continuations do not rerun before_agent_start, even after a model switch or reset.
1064
+ pi.on("context_with_system", (event, ctx) => {
1065
+ const guidance = buildGuidance(policy(ctx));
1066
+ const system = event.messages.findLast((message) => message.role === "system" && typeof message.sections?.posthorse === "string")
1067
+ ?? event.messages.find((message) => message.role === "system");
1068
+ if (!system || system.role !== "system") return;
1069
+ if (runPromptOptions?.forceSystemPrompt !== undefined) {
1070
+ // ponytail: official Pi applies forced prompts after these hooks; keep guidance in
1071
+ // request-local conversation until the host projects forced text before the hooks.
1072
+ event.messages.splice(1, 0, { role: "custom", customType: "posthorse-guidance", content: guidance, display: false, timestamp: 0 });
1073
+ } else {
1074
+ system.sections = { ...system.sections, posthorse: `<posthorse>\n${guidance}\n</posthorse>` };
1093
1075
  }
1094
- event.systemPromptOptions.sections.posthorse = guidance;
1095
1076
  });
1096
1077
 
1097
1078
  pi.on("turn_end", (event, ctx) => {
1079
+ const requested = event.toolResults.find((result) => result.toolName === "new_context" && resetRequests.has(result.toolCallId));
1080
+ const summary = requested ? resetRequests.get(requested.toolCallId) : undefined;
1081
+ resetRequests.clear();
1098
1082
  const host = policy(ctx);
1099
1083
  if (
1100
1084
  event.message.role === "assistant" &&
1101
1085
  (event.message.stopReason === "error" || event.message.stopReason === "aborted")
1102
1086
  )
1103
1087
  return;
1104
- const requested = event.toolResults.find((result) => result.toolName === "new_context");
1105
1088
  // Suppress clear successes; turn_end can also include unrelated background errors.
1106
1089
  // 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;
1090
+ if (summary !== undefined && !event.toolResults.some((result) => result.isError)) {
1091
+ if (event.outcome === "aborted" || ctx.signal?.aborted) return;
1114
1092
  // Input steered in after execute() checked capacity can leave too little room.
1115
1093
  if (summary.length > freshPayloadChars(host, activeToolTokens(), event.context.pendingMessages)) {
1116
1094
  return { entries: [...event.entries, { type: "custom_message" as const, customType: "posthorse-reset-deferred", display: true,
@@ -1127,14 +1105,14 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1127
1105
  const remindAt = budget.rolloverAt - reminderBuffer;
1128
1106
  if (usage.tokens < remindAt) return;
1129
1107
 
1130
- const branch = ctx.sessionManager.getBranch() as EntryLike[];
1108
+ const state = remindersFor(ctx);
1131
1109
  const fingerprint: ReminderFingerprint = {
1132
- windowId: currentWindowId(branch),
1110
+ windowId: state.windowId,
1133
1111
  contextWindow: budget.contextWindow,
1134
1112
  reserveTokens: budget.reserveTokens,
1135
1113
  };
1136
1114
  // A reminder a compaction summarized away no longer reaches the model.
1137
- if (hasReminder(branch.slice(contextStart(branch).start), fingerprint)) return;
1115
+ if (hasReminder(state.reminders, fingerprint)) return;
1138
1116
  pi.sendMessage(
1139
1117
  {
1140
1118
  customType: REMINDER_TYPE,
@@ -1153,7 +1131,7 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1153
1131
  const marker = event.messages.find((message) => message.role === "custom" && message.customType === "context-window");
1154
1132
  const windowId = marker?.role === "custom"
1155
1133
  ? (marker.details as { windowId?: unknown } | undefined)?.windowId
1156
- : currentWindowId(ctx.sessionManager.getBranch() as EntryLike[]);
1134
+ : remindersFor(ctx).windowId;
1157
1135
  if (typeof windowId !== "string") return;
1158
1136
  const host = policy(ctx);
1159
1137
  const budget = budgetFor(host);
@@ -1162,60 +1140,44 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1162
1140
  contextWindow: budget?.contextWindow,
1163
1141
  reserveTokens: budget?.reserveTokens,
1164
1142
  };
1143
+ const enabled = host.getCompactionSettings().enabled;
1165
1144
  const stale = (message: (typeof event.messages)[number]) =>
1166
1145
  message.role === "custom" &&
1167
1146
  isReminderType(message.customType) &&
1168
- (!host.getCompactionSettings().enabled || !reminderMatches(message.details, fingerprint));
1147
+ (!enabled || !reminderMatches(message.details, fingerprint));
1169
1148
  if (event.messages.some(stale)) return { messages: event.messages.filter((message) => !stale(message)) };
1170
1149
  });
1171
1150
 
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;
1151
+ // Pi owns thresholds, recovery retries, and whether another request follows.
1152
+ pi.on("session_before_compact", (event, ctx) => {
1153
+ if (event.reason === "manual") return;
1154
+ const cancelled = () => event.signal.aborted || ctx.signal?.aborted;
1155
+ if (cancelled()) return { cancel: true };
1156
+ try {
1157
+ // The event carries Pi's live settings; prefer them over the persisted snapshot.
1158
+ const host = policy(ctx, event.preparation.settings);
1159
+ // Small/unknown context windows deliberately keep Pi's own compaction.
1160
+ if (!budgetFor(host)?.supported) return;
1161
+ const limit = freshPayloadChars(host, activeToolTokens());
1162
+ const ownerQuestionRegistered = pi.getAllTools().some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
1163
+ const handoff = buildAutoHandoff(event.branchEntries, ctx.sessionManager.buildSessionProjection().entries, limit, ownerQuestionRegistered);
1164
+ if (limit < MIN_PAGE_CHARS || handoff.length > limit) throw new Error("Too little fresh context capacity for an automatic recovery record.");
1193
1165
  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 };
1166
+ // firstKeptEntryId must name an entry; an invisible sentinel retains no prior conversation.
1167
+ pi.appendEntry("posthorse-boundary", {});
1168
+ const firstKeptEntryId = ctx.sessionManager.getLeafId();
1169
+ if (!firstKeptEntryId) throw new Error("Pi did not persist the context boundary.");
1170
+ return { compaction: { summary: handoff, firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details: { posthorse: 1, reason: event.reason } } };
1171
+ } catch (error) {
1172
+ // Pi swallows handler errors and would then run its summarizer; cancel instead.
1173
+ if (!cancelled()) {
1174
+ const message = `Posthorse automatic rollover cancelled: ${error instanceof Error ? error.message : String(error)}`;
1175
+ if (ctx.hasUI) ctx.ui.notify(message, "error");
1176
+ else console.error(message);
1216
1177
  }
1217
- });
1218
- }
1178
+ return { cancel: true };
1179
+ }
1180
+ });
1219
1181
 
1220
1182
  pi.registerTool({
1221
1183
  name: "new_context",
@@ -1245,8 +1207,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1245
1207
  [],
1246
1208
  { kind: "new-context" },
1247
1209
  );
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;
1210
+ resetRequests.set(_id, trimmed || EMPTY_HANDOFF);
1211
+ return result;
1250
1212
  },
1251
1213
  });
1252
1214
 
@@ -1255,8 +1217,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1255
1217
  label: "Context Remaining",
1256
1218
  ...toolCards("get_context_remaining"),
1257
1219
  description:
1258
- "Estimate tokens left before Pi's automatic rollover and the configured context limit. A checkpoint reminder already arrives before rollover; use this only before a large step late in a window.",
1259
- promptSnippet: "check the context budget before a large step late in a window",
1220
+ "Estimate tokens left before Pi's automatic rollover and the configured context limit. Check before reporting remaining space or changing your work plan because of context limits. A checkpoint reminder already arrives before rollover; no routine checks are needed.",
1221
+ promptSnippet: "verify remaining context before reporting a token count or changing plans because of context limits",
1260
1222
  parameters: Type.Object({}),
1261
1223
  async execute(_id, _params, _signal, _onUpdate, ctx) {
1262
1224
  const host = policy(ctx);
@@ -1269,8 +1231,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1269
1231
  rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
1270
1232
  rolloverAt: budget?.rolloverAt,
1271
1233
  };
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);
1234
+ 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. ${SNAPSHOT_NOTE}`;
1235
+ if (!budget?.enabled) return textResult(`Automatic rollover is disabled in the available settings (Pi compaction.enabled=false). ${configured}`, [], display);
1274
1236
  if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${configured}`, [], display);
1275
1237
  return textResult(
1276
1238
  `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
@@ -1284,7 +1246,7 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1284
1246
  label: "Notes",
1285
1247
  ...toolCards("notes"),
1286
1248
  description:
1287
- "Durable notes in .pi/notes/, shared across Git worktrees and kept across context resets. Ops: list (newest first, with size and date; optional folder path), read, search (case-insensitive substring over text notes), write (replace; empty content clears), append (adds one line-terminated record). list/read/search are paged; continue with the returned offset. Keep one current-state note per task and rewrite it in place.",
1249
+ "Durable notes shared across Git worktrees and context resets. Ops: list (newest first, size/date; optional folder), read, search (case-insensitive text), write (replace; empty clears), append (one newline-terminated record). list/read/search are paged; continue with the returned offset. list shows the absolute notes directory; read shows the absolute file path. Prefer available file-editing tools for changed sections of one concise current-state note; write for creation or substantial restructuring. Link fuller evidence instead of copying it.",
1288
1250
  promptSnippet: "save and recall durable state that survives context resets",
1289
1251
  parameters: Type.Object({
1290
1252
  op: Type.Union(
@@ -1333,6 +1295,7 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1333
1295
  params.offset ?? 0,
1334
1296
  "notes-list",
1335
1297
  folder ? `(no notes in ${folder})` : "(no notes yet)",
1298
+ `Notes directory: ${dir}\n`,
1336
1299
  );
1337
1300
 
1338
1301
  switch (params.op) {
@@ -1350,10 +1313,14 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1350
1313
  if (offset && offset >= text.length) {
1351
1314
  throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
1352
1315
  }
1353
- const end = Math.min(text.length, offset + pageSize(policy(ctx), offset, 0));
1316
+ const header = `File: ${path}\n`;
1317
+ const host = policy(ctx);
1318
+ const chars = pageSize(host, offset, 0) - header.length;
1319
+ if (chars <= 0) pageError(host, `Too little context remains to include the note path. Call new_context first, then retry with offset ${offset}.`);
1320
+ const end = Math.min(text.length, offset + chars);
1354
1321
  const more =
1355
1322
  end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
1356
- return pageResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
1323
+ return pageResult(`${header}${text.slice(offset, end)}${more}`, [], { kind: "note-read", headerLength: header.length, offset, end, total: text.length });
1357
1324
  }
1358
1325
  case "write": {
1359
1326
  const relative = requireValue(params.path, "path", params.op);
@@ -1507,13 +1474,14 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1507
1474
  if (hits[0].length > limit) break;
1508
1475
  }
1509
1476
  }
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);
1477
+ const host = policy(ctx);
1478
+ const chars = pageSize(host, 0, 0, params.cursor);
1479
+ if (!found) pageError(host, "History cursor entry no longer matches; restart the search without it.", params.cursor);
1512
1480
  const results = hits.flat();
1513
1481
  const cursorFor = (hit: HistoryHit, offset: number) => Buffer.from(JSON.stringify([hit.id, hit.priority, offset, searchKey])).toString("base64url");
1514
1482
  const footer = (next: string) => `\n[More results; continue with cursor "${next}" and the same query/scope.]`;
1515
1483
  const reserve = Math.max(0, ...results.map((hit) => footer(cursorFor(hit, hit.text.length)).length));
1516
- if (reserve >= chars) pageError(policy(ctx), "History entry id is too large for pagination.", params.cursor);
1484
+ if (reserve >= chars) pageError(host, "History entry id is too large for pagination.", params.cursor);
1517
1485
  const skippedNote = skipped && !params.cursor
1518
1486
  ? `\n[Skipped ${skipped} match${skipped === 1 ? "" : "es"} already in your active context.]`
1519
1487
  : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
6
6
  "keywords": ["pi-package", "pi-extension", "context", "posthorse", "rollover"],
@@ -13,7 +13,7 @@
13
13
  "engines": {
14
14
  "node": ">=24.12.0"
15
15
  },
16
- "packageManager": "npm@12.1.0",
16
+ "packageManager": "npm@12.2.0",
17
17
  "pi": {
18
18
  "extensions": ["./index.ts"],
19
19
  "image": "https://raw.githubusercontent.com/fitchmultz/pi-posthorse/main/diagram.png"
@@ -42,11 +42,11 @@
42
42
  }
43
43
  },
44
44
  "devDependencies": {
45
- "@earendil-works/pi-ai": "0.87.1",
46
- "@earendil-works/pi-coding-agent": "0.87.1",
47
- "@earendil-works/pi-tui": "0.87.1",
45
+ "@earendil-works/pi-ai": "1.0.0",
46
+ "@earendil-works/pi-coding-agent": "1.0.0",
47
+ "@earendil-works/pi-tui": "1.0.0",
48
48
  "@types/node": "24.19.0",
49
- "typebox": "1.3.34",
49
+ "typebox": "1.3.27",
50
50
  "typescript": "7.0.2"
51
51
  }
52
52
  }
package/ui.ts CHANGED
@@ -4,8 +4,8 @@ import { Box, MouseRegion, Text, stripTerminalSequences, truncateToWidth, type C
4
4
  /** Display-only facts and offsets into content, never a second copy of a note or history page. */
5
5
  export type PosthorseDisplay =
6
6
  | { kind: "context"; usage?: { tokens: number | null; contextWindow: number; percent: number | null }; rollover?: "enabled" | "disabled" | "unsupported"; rolloverAt?: number }
7
- | { kind: "notes-list" | "notes-search"; count: number; page?: { offset: number; end: number; total: number } }
8
- | { kind: "note-read"; offset: number; end: number; total: number }
7
+ | { kind: "notes-list" | "notes-search"; count: number; headerLength?: number; page?: { offset: number; end: number; total: number } }
8
+ | { kind: "note-read"; headerLength?: number; offset: number; end: number; total: number }
9
9
  | { kind: "note-write" | "note-append" | "new-context" }
10
10
  | { kind: "history-search"; entries: Array<{ headerLength: number; length: number }>; footerLength?: number; more?: boolean; skipped?: number }
11
11
  | { kind: "history-read"; headerLength: number; offset: number; end: number; total: number; imageOffset?: number; imageEnd?: number; imageTotal?: number };
@@ -127,7 +127,8 @@ export function toolCards(name: ToolName): Pick<ToolDefinition, "renderCall" | "
127
127
  const submittedText = textBlock(submitted, theme);
128
128
  const attachmentText = textBlock(attachment, theme, "muted");
129
129
  // Old results have no spans. Only the preview sheds the known history prefix.
130
- const preview = sections ? sections.map(({ body }) => body).join("\n") : name === "history" && !context.isError ? body.replace(/^[^\n]*?\[window [^\]\n]+\] \[[^\]\n]+\](?: \[chars \d+-\d+ of \d+\])? /gm, "") : body;
130
+ const noteHeaderLength = !context.isError && (display?.kind === "note-read" || display?.kind === "notes-list") ? display.headerLength ?? 0 : 0;
131
+ const preview = sections ? sections.map(({ body }) => body).join("\n") : noteHeaderLength ? body.slice(noteHeaderLength) : name === "history" && !context.isError ? body.replace(/^[^\n]*?\[window [^\]\n]+\] \[[^\]\n]+\](?: \[chars \d+-\d+ of \d+\])? /gm, "") : body;
131
132
  const previewText = textBlock(preview, theme, color);
132
133
  const searchPreviews = sections && display?.kind === "history-search" ? sections.slice(0, 3).map(({ body }) => theme.fg("toolOutput", clean(body).replace(/\s+/g, " "))) : undefined;
133
134
  const summaryOnly = display?.kind === "context" || display?.kind === "new-context" || display?.kind === "note-write" || display?.kind === "note-append" || ((display?.kind === "notes-list" || display?.kind === "notes-search") && display.count === 0) || (display?.kind === "note-read" && display.total === 0) || (display?.kind === "history-search" && Array.isArray(display.entries) && display.entries.length === 0);
@@ -142,7 +143,7 @@ export function toolCards(name: ToolName): Pick<ToolDefinition, "renderCall" | "
142
143
  ? searchPreviews.map((line) => truncateToWidth(line, width))
143
144
  : previewText.render(width);
144
145
  const shown = previewRows.slice(0, Math.max(0, Math.min(3, 5 - summaryRows.length - (attachment ? 1 : 0))));
145
- const hidden = summaryOnly || previewRows.length > shown.length || Boolean(sections || submitted);
146
+ const hidden = summaryOnly || previewRows.length > shown.length || Boolean(sections || submitted || noteHeaderLength);
146
147
  return [...summaryRows, ...shown, ...(attachment ? [truncateToWidth(theme.fg("muted", attachment), width)] : []), ...(hidden ? [hint(theme, width)] : [])];
147
148
  },
148
149
  };