pi-posthorse 0.4.6 → 0.5.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 +23 -16
  2. package/index.ts +128 -49
  3. package/package.json +5 -3
  4. package/ui.ts +13 -9
package/README.md CHANGED
@@ -13,7 +13,7 @@ An isolated [Codex prototype](adapters/codex-posthorse/README.md) tests local no
13
13
  ## Requirements
14
14
 
15
15
  - Node `>=22.19.0`.
16
- - The `fitchmultz/pi` fork. The CI baseline is `f9b06177e565f70cd243a785d088d1c491830dbd` (Pi `0.85.0`). Posthorse needs the fork's native `context_window` entries, its `session_before_auto_compact` hook, and `ctx.getCompactionSettings()`.
16
+ - The `fitchmultz/pi` fork. The native qualification target is `8fb7886130ff1fedc415bdd6fea03aef8a8957d8` (fork package version `0.87.0`). Posthorse needs the fork's native `context_window` entries, its `session_before_auto_compact` hook, `ctx.getCompactionSettings()`, and the coding-agent SDK's `publishLocalFile` export.
17
17
  - Official, unpatched Pi is unsupported. Posthorse reports a clear extension error at session start and cannot operate; Pi itself keeps running.
18
18
 
19
19
  ## Install
@@ -23,7 +23,7 @@ Build the fork:
23
23
  ```bash
24
24
  git clone https://github.com/fitchmultz/pi.git
25
25
  cd pi
26
- git checkout f9b06177e565f70cd243a785d088d1c491830dbd
26
+ git checkout 8fb7886130ff1fedc415bdd6fea03aef8a8957d8
27
27
  npm install --ignore-scripts
28
28
  npm run build
29
29
  ```
@@ -48,15 +48,15 @@ Keep exactly one copy loaded. `pi list` shows every package source; if an older
48
48
 
49
49
  ## How it works
50
50
 
51
- 1. **Stable guidance.** Window behavior is part of the system prompt. There is no per-request meter to churn the prompt.
52
- 2. **One best-effort checkpoint.** While Pi compaction is enabled, one reminder may appear shortly before Pi's rollover line. A large turn, overflow, restart, or smaller model can reach rollover without it. Reminders are fingerprinted by window, context size, and reserve, so switching to a different context size gets a fresh reminder and stale ones are filtered from model input.
53
- 3. **`get_context_remaining`.** Reports the best available native estimate of tokens until Pi's automatic rollover line and until the model's hard limit. Pi's value is an estimate until the active model reports usage.
51
+ 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.
52
+ 2. **One best-effort checkpoint.** While Pi compaction is enabled, one reminder may appear shortly before Pi's rollover line. A large turn, overflow, restart, or smaller model can reach rollover without it. Reminders are fingerprinted by window, context size, and reserve, so switching to a different context size gets a fresh reminder. Stale reminders and reminders left after disabling compaction are filtered from model input while remaining in history.
53
+ 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.
54
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.
55
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.
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.
56
+ 6. **Bounded recovery record.** The automatic handoff keeps direct user inputs, `ask_question` outcomes, visible coordination messages, and the trailing tool batch without a later complete assistant response: call arguments, bounded result text, and entry ids to recover the rest. Failed or interrupted requests may already have received those results. A clearly labeled, possibly stale older checkpoint comes last. Older assistant prose and earlier tool batches are not treated as current state. Newly submitted input stays separate and is saved after the boundary, not copied into the handoff.
57
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.
58
58
 
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.
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. Complete history remains available through 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
 
@@ -74,21 +74,23 @@ 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` (paged; `offset` continues), `write` (empty content clears), `append` (one atomic newline-terminated record), `search` (excerpts centered on the match)
78
- - `history({ op, ... })`: `search`, `read`; results carry native window ids, reads return stored images with the first page and the next character offset when text remains
77
+ - `notes({ op, ... })`: `list`, `read`, and `search` are paged; continue with the returned character `offset`. Search excerpts center on the match. `write` replaces content (empty content clears); `append` adds one newline-terminated record.
78
+ - `history({ op, ... })`: `search` continues with the returned `cursor` and the same `query` and `all` scope; `limit` is the maximum results per page. `read` returns stored images with the first page and the next character `offset` when text remains. 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
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.
83
83
 
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.
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
 
86
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.
87
87
 
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.
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 per-page result limit applies after priority, so newer echoes cannot displace older original matches. Entries copied by a fork are reported once. 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
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.
91
91
 
92
+ `write` uses Pi's native complete-file replacement: 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. Full replacements do not merge concurrent appends or other replacements. `append` retains one `O_APPEND` write per newline-terminated record.
93
+
92
94
  ## Data and privacy
93
95
 
94
96
  - Posthorse makes no network requests.
@@ -105,10 +107,15 @@ Reminders persisted by pi-headroom (`headroom-reminder`) are recognized alongsid
105
107
  ## Develop
106
108
 
107
109
  ```bash
108
- npm ci
109
- npm test
110
- npm run check
111
- PI_FORK=../pi scripts/integration.sh # loads the real extension into the fork's test harness (fork built)
110
+ npm ci --ignore-scripts
111
+ npm run check # public npm declarations can check types, but cannot run Posthorse
112
+ # After the compatibility runner installs the selected fork SDK/types cohort:
113
+ PI_COMPAT_HOST=fork npm run check:compat
114
+ PI_FORK=../pi scripts/integration.sh # additional full source-harness integration (disposable fork built)
112
115
  ```
113
116
 
114
- `npm run check` type-checks the extension, renderers, and tests. Tests cover reminder policy, notes/history behavior, and real native card components, including width, expansion, and legacy results. The pinned Pi 0.85.0 SDK's unbundled entry imports an undeclared server package, so test-only resolution loads its shipped native bundle. Integration tests run inside the fork's harness and cover rollover and history recovery; use a disposable built fork checkout because the script briefly copies the test into its test directory. CI runs the unit tests on Node 22.19 and 24, `npm audit`, `npm pack --dry-run`, and the integration job against the pinned fork revision.
117
+ `npm run check` type-checks the extension, renderers, and tests against the exact 0.87.0 npm declaration baseline. Runtime tests require the qualified fork SDK, including `publishLocalFile`, installed in this checkout's dependency graph. `npm test` covers reminder policy, notes/history behavior, failed note replacement, and real native card components, including width, expansion, and legacy results. Tests use the public SDK entry; official Pi is not an operating target.
118
+
119
+ `check:compat` adds `test:native`: real SDK loading, prompt composition, mixed recovery-output budgets, cursor progress, reminder filtering, interrupted-response recovery, oversized-result rollover without summary compaction, explicit `new_context`, and checkpoint restore without provider replay. It requires native fork capabilities; it never substitutes synthetic windows or silently skips on official. The runner must install the fork 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. A same-version official graph is not a fork. Official qualification is a separate actual CLI startup/refusal probe, not `check:compat`.
120
+
121
+ 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.
package/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * notes, and history recovery.
7
7
  */
8
8
 
9
+ import { createHash } from "node:crypto";
9
10
  import {
10
11
  appendFileSync,
11
12
  constants,
@@ -17,15 +18,15 @@ import {
17
18
  readFileSync,
18
19
  realpathSync,
19
20
  statSync,
20
- writeFileSync,
21
21
  } from "node:fs";
22
22
  import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
23
+ import * as codingAgent from "@earendil-works/pi-coding-agent";
23
24
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
25
  import { Type } from "typebox";
25
26
  import { registerPosthorseMessages, toolCards, type PosthorseDisplay } from "./ui.ts";
26
27
 
27
28
  const REMINDER_BUFFER_TOKENS = 32_000;
28
- /** Absolute ceiling for handoffs and read pages; pages shrink to the live remaining budget. */
29
+ /** Absolute ceiling for handoffs and recovery pages; pages shrink to the live remaining budget. */
29
30
  const MAX_HANDOFF_CHARS = 20_000;
30
31
  const MAX_RECOVERY_RECORD_CHARS = 4_000;
31
32
  const HANDOFF_OVERHEAD_RESERVE = 1_000;
@@ -43,6 +44,11 @@ const LEGACY_AUTO_HANDOFF_PREFIX =
43
44
 
44
45
  type CompactionPolicy = { enabled: boolean; reserveTokens: number };
45
46
  type ContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
47
+ /** Fork-only SDK export; the public npm declarations do not include it. */
48
+ const { publishLocalFile } = codingAgent as typeof codingAgent & {
49
+ publishLocalFile(absolutePath: string, content: string | Uint8Array, signal?: AbortSignal): Promise<void>;
50
+ };
51
+
46
52
  /** Fork-only ExtensionContext members; the published Pi types do not declare them. */
47
53
  type NativeContext = {
48
54
  model?: { contextWindow: number };
@@ -113,9 +119,10 @@ function nativeContext<T>(ctx: T): T & NativeContext {
113
119
  if (
114
120
  typeof candidate.newContext !== "function" ||
115
121
  typeof candidate.getCompactionSettings !== "function" ||
116
- typeof candidate.getSystemPrompt !== "function"
122
+ typeof candidate.getSystemPrompt !== "function" ||
123
+ typeof publishLocalFile !== "function"
117
124
  ) {
118
- throw new Error("Posthorse requires the fitchmultz/pi fork with native context windows (see README).");
125
+ throw new Error("Posthorse requires the fitchmultz/pi fork with native context windows and publishLocalFile (see README).");
119
126
  }
120
127
  return candidate as T & NativeContext;
121
128
  }
@@ -441,12 +448,8 @@ function formatPriorCheckpoint(entry: EntryLike | undefined, limit: number): str
441
448
  return boundedBlock(header, handoff, limit);
442
449
  }
443
450
 
444
- /**
445
- * The trailing tool batch no model has consumed yet: an assistant tool-call message followed only by
446
- * its completed results (and non-message entries). Pi can roll over right after tools finish, so the
447
- * result that triggered the rollover would otherwise vanish before any model saw it.
448
- */
449
- function unconsumedToolBatch(
451
+ /** Trailing results without a later complete assistant response; interrupted requests may have received them. */
452
+ function trailingToolBatch(
450
453
  entries: readonly EntryLike[],
451
454
  ): { callId: string; blocks: Array<{ header: string; text: string }> } | undefined {
452
455
  const results: EntryLike[] = [];
@@ -520,9 +523,9 @@ function buildAutoHandoff(entries: readonly EntryLike[], maxChars: number): stri
520
523
  const joinedLength = (parts: Array<string | undefined>) =>
521
524
  parts.filter((part): part is string => Boolean(part)).join("\n\n").length;
522
525
 
523
- const toolBatch = unconsumedToolBatch(current);
526
+ const toolBatch = trailingToolBatch(current);
524
527
  const batchHeader = toolBatch
525
- ? `Unconsumed tool batch (completed after the last assistant response; no model has seen these results). Tool-call entry ${toolBatch.callId}:`
528
+ ? `Trailing tool batch without a later complete assistant response (failed or interrupted requests may already have received these results). Tool-call entry ${toolBatch.callId}:`
526
529
  : undefined;
527
530
  const minimumParts = [
528
531
  preamble,
@@ -678,7 +681,7 @@ function unsupportedMessage(budget: Budget): string {
678
681
  return `Posthorse: unsupported configuration. The model's context window (${n(budget.contextWindow)} tokens) minus Pi's compaction.reserveTokens (${n(budget.reserveTokens)}) leaves ${n(budget.usable)} usable tokens; Posthorse needs at least ${n(MIN_USABLE_TOKENS)}. Automatic rollover and checkpoint reminders are off for this model. Lower compaction.reserveTokens in Pi settings or use a larger-context model. new_context remains available with a model-aware handoff limit.`;
679
682
  }
680
683
 
681
- /** Capacity before Pi's automatic line (or hard limit), including the margin for page metadata and refusals. */
684
+ /** Capacity before Pi's automatic line (or configured context limit), including page metadata and refusals. */
682
685
  function pageCapacity(ctx: NativeContext, toolTokens: number): number {
683
686
  const usage = ctx.getContextUsage();
684
687
  if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens) / 4 + PAGE_MARGIN_TOKENS;
@@ -729,7 +732,7 @@ export default function (pi: ExtensionAPI) {
729
732
 
730
733
  let pendingPageTokens = 0;
731
734
  let previousPageUsage: number | null | undefined;
732
- const pageSize = (ctx: NativeContext, offset: number, imageCount: number) => {
735
+ const pageSize = (ctx: NativeContext, offset: number, imageCount: number, cursor?: string) => {
733
736
  const usage = ctx.getContextUsage()?.tokens;
734
737
  // Parallel siblings are not in native usage yet; sequential results must not be counted twice.
735
738
  if (usage != null && previousPageUsage != null) {
@@ -742,7 +745,7 @@ export default function (pi: ExtensionAPI) {
742
745
  Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
743
746
  );
744
747
  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}.`;
748
+ const message = `Too little context remains to read a page safely. Call new_context first, then retry ${cursor ? "with the same cursor" : `with offset ${offset}`}.`;
746
749
  // Refusals consume context too. Once even the guidance no longer fits, omit repeated text;
747
750
  // the failed call still carries its original offset and earlier refusals explain the retry.
748
751
  const text = Math.ceil(message.length / 4) <= remaining ? message : "";
@@ -755,6 +758,30 @@ export default function (pi: ExtensionAPI) {
755
758
  pendingPageTokens += Math.ceil(text.length / 4) + images.length * (ESTIMATED_IMAGE_CHARS / 4);
756
759
  return textResult(text, images, display);
757
760
  };
761
+ const pageError = (ctx: NativeContext, message: string, cursor?: string): never => {
762
+ pageSize(ctx, 0, 0, cursor);
763
+ pendingPageTokens += Math.ceil(message.length / 4);
764
+ throw new Error(message);
765
+ };
766
+ const notesPage = (ctx: NativeContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string) => {
767
+ const text = rows.length ? rows.join("\n") : empty;
768
+ const chars = pageSize(ctx, offset, 0);
769
+ if (offset && offset >= text.length) pageError(ctx, "Offset is past the end; restart with offset 0.");
770
+ // Reserve the complete continuation, even when one path or excerpt needs several pages.
771
+ const footer = (end: number) => `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`;
772
+ let end = Math.min(text.length, offset + chars);
773
+ let start = 0;
774
+ const starts: number[] = [];
775
+ for (const row of rows) {
776
+ if (start >= end) break;
777
+ if (start + row.length > offset) starts.push(start);
778
+ start += row.length + 1;
779
+ if (kind === "notes-search" && starts.length === 20) { end = Math.min(end, start - 1); break; }
780
+ }
781
+ // A result-count cap can require continuation even when the character cap did not.
782
+ if (end < text.length) end = Math.min(end, offset + chars - footer(text.length).length);
783
+ 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 } });
784
+ };
758
785
  pi.on("turn_start", () => {
759
786
  pendingPageTokens = 0;
760
787
  previousPageUsage = undefined;
@@ -766,9 +793,14 @@ export default function (pi: ExtensionAPI) {
766
793
  nativeContext(ctx);
767
794
  });
768
795
 
769
- pi.on("before_agent_start", (event, ctx) => ({
770
- systemPrompt: `${event.systemPrompt}\n\n${buildGuidance(nativeContext(ctx))}`,
771
- }));
796
+ pi.on("before_agent_start", (event, ctx) => {
797
+ const guidance = buildGuidance(nativeContext(ctx));
798
+ // An earlier full-prompt override makes section edits invisible to Pi.
799
+ if (event.systemPromptOptions.forceSystemPrompt !== undefined) {
800
+ return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` };
801
+ }
802
+ event.systemPromptOptions.sections.posthorse = guidance;
803
+ });
772
804
 
773
805
  pi.on("turn_end", (event, ctx) => {
774
806
  const native = nativeContext(ctx);
@@ -810,7 +842,7 @@ export default function (pi: ExtensionAPI) {
810
842
  );
811
843
  });
812
844
 
813
- // Drop reminders from another window or computed for another context size/reserve from model input.
845
+ // Filter active input only; raw reminders remain in history for deduplication and recovery.
814
846
  pi.on("context", (event, ctx) => {
815
847
  const marker = event.messages.find(
816
848
  (message) => message.role === "custom" && message.customType === "context-window",
@@ -829,7 +861,7 @@ export default function (pi: ExtensionAPI) {
829
861
  const stale = (message: (typeof event.messages)[number]) =>
830
862
  message.role === "custom" &&
831
863
  isReminderType(message.customType) &&
832
- reminderIsStale(message.customType, message.details, fingerprint, branch);
864
+ (!native.getCompactionSettings().enabled || reminderIsStale(message.customType, message.details, fingerprint, branch));
833
865
  if (event.messages.some(stale)) return { messages: event.messages.filter((message) => !stale(message)) };
834
866
  });
835
867
 
@@ -889,7 +921,7 @@ export default function (pi: ExtensionAPI) {
889
921
  label: "Context Remaining",
890
922
  ...toolCards("get_context_remaining"),
891
923
  description:
892
- "Best available native estimate of the context budget: tokens until Pi's automatic rollover line and until the model's hard limit.",
924
+ "Best available native estimate of the context budget: tokens until Pi's automatic rollover line and until the configured context limit.",
893
925
  promptSnippet: "check the remaining context budget only when needed",
894
926
  parameters: Type.Object({}),
895
927
  async execute(_id, _params, _signal, _onUpdate, ctx) {
@@ -903,11 +935,11 @@ export default function (pi: ExtensionAPI) {
903
935
  rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
904
936
  rolloverAt: budget?.rolloverAt,
905
937
  };
906
- const hard = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens until the hard context limit (${n(usage.tokens)}/${n(usage.contextWindow)} used, ${Math.round(usage.percent ?? 0)}%). Best available native estimate.`;
907
- if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${hard}`, [], display);
908
- if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${hard}`, [], display);
938
+ const configured = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens until the configured context limit (${n(usage.tokens)}/${n(usage.contextWindow)} used, ${Math.round(usage.percent ?? 0)}%). Best available native estimate.`;
939
+ if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${configured}`, [], display);
940
+ if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${configured}`, [], display);
909
941
  return textResult(
910
- `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${hard}`,
942
+ `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
911
943
  [], display,
912
944
  );
913
945
  },
@@ -918,7 +950,7 @@ export default function (pi: ExtensionAPI) {
918
950
  label: "Notes",
919
951
  ...toolCards("notes"),
920
952
  description:
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.",
953
+ "Persistent notes in .pi/notes/ that survive context resets. Ops: list/read/search (paged; repeat with offset to continue), write (create/replace; empty content clears), append. Search matches case-insensitive substrings over note lines. Git worktrees share notes: at the main checkout for conventional .git layouts, or in the common Git directory when metadata is stored separately. Old checkout-local notes are imported without overwriting shared notes.",
922
954
  promptSnippet: "save and recall durable state that survives context resets",
923
955
  promptGuidelines: [
924
956
  "Use notes for durable state too large for a new_context handoff",
@@ -932,9 +964,9 @@ export default function (pi: ExtensionAPI) {
932
964
  path: Type.Optional(Type.String({ description: "Note path relative to .pi/notes/ (read/write/append)" })),
933
965
  content: Type.Optional(Type.String({ description: "Full file content (write) or text to add (append)" })),
934
966
  query: Type.Optional(Type.String({ description: "Substring to find in notes (search)" })),
935
- offset: Type.Optional(Type.Integer({ description: "Character offset for read (default 0)", minimum: 0 })),
967
+ offset: Type.Optional(Type.Integer({ description: "Character offset for list/read/search (default 0)", minimum: 0 })),
936
968
  }),
937
- async execute(_id, params, _signal, _onUpdate, ctx) {
969
+ async execute(_id, params, signal, _onUpdate, ctx) {
938
970
  const dir = join(notesRoot(ctx.cwd), ".pi", "notes");
939
971
  const safeJoin = (path: string) => {
940
972
  const relative = normalize(path.replace(/^[/\\]+/, ""));
@@ -953,10 +985,9 @@ export default function (pi: ExtensionAPI) {
953
985
 
954
986
  switch (params.op) {
955
987
  case "list": {
956
- if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-list", count: 0 });
957
988
  const files: string[] = [];
958
- walk(dir, files);
959
- return textResult(files.length ? files.map((file) => file.slice(dir.length + 1)).join("\n") : "(no notes yet)", [], { kind: "notes-list", count: files.length });
989
+ if (existsSync(dir)) walk(dir, files);
990
+ return notesPage(nativeContext(ctx), files.map((file) => file.slice(dir.length + 1)), params.offset ?? 0, "notes-list", "(no notes yet)");
960
991
  }
961
992
  case "read": {
962
993
  const relative = requireValue(params.path, "path", params.op);
@@ -977,7 +1008,7 @@ export default function (pi: ExtensionAPI) {
977
1008
  if (params.content === undefined) throw new Error(`"content" is required for op "write" (use "" to clear a note).`);
978
1009
  const path = safeJoin(relative);
979
1010
  mkdirSync(dirname(path), { recursive: true });
980
- writeFileSync(path, params.content);
1011
+ await publishLocalFile(path, params.content, signal);
981
1012
  return textResult(`Wrote ${path}`, [], { kind: "note-write" });
982
1013
  }
983
1014
  case "append": {
@@ -995,13 +1026,11 @@ export default function (pi: ExtensionAPI) {
995
1026
  }
996
1027
  case "search": {
997
1028
  const query = requireValue(params.query, "query", params.op).toLowerCase();
998
- if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-search", count: 0 });
999
1029
  const files: string[] = [];
1000
- walk(dir, files);
1030
+ if (existsSync(dir)) walk(dir, files);
1001
1031
  const hits: string[] = [];
1002
1032
  for (const file of files) {
1003
1033
  for (const [index, line] of readFileSync(file, "utf8").split("\n").entries()) {
1004
- if (hits.length >= 20) break;
1005
1034
  const trimmed = line.trim();
1006
1035
  const match = trimmed.toLowerCase().indexOf(query);
1007
1036
  if (match !== -1) {
@@ -1009,7 +1038,7 @@ export default function (pi: ExtensionAPI) {
1009
1038
  }
1010
1039
  }
1011
1040
  }
1012
- return textResult(hits.length ? hits.join("\n") : `No notes match "${params.query}".`, [], { kind: "notes-search", count: hits.length });
1041
+ return notesPage(nativeContext(ctx), hits, params.offset ?? 0, "notes-search", `No notes match "${excerpt(params.query!, 200)}".`);
1013
1042
  }
1014
1043
  }
1015
1044
  },
@@ -1020,7 +1049,7 @@ export default function (pi: ExtensionAPI) {
1020
1049
  label: "History",
1021
1050
  ...toolCards("history"),
1022
1051
  description:
1023
- "Search or read normalized session entries, including earlier native context windows. Search prioritizes original content before recovery notes, handoffs, and history lookups; all remain searchable. Current branch by default; all=true searches every project session file. Within each group: newest-modified sessions first, newest entries per session. Reads return stored images and page long text with the next offset.",
1052
+ "Search or read normalized session entries, including earlier native context windows. Search prioritizes original content before recovery notes, handoffs, and history lookups; all remain searchable. Current branch by default; all=true searches every project session file. Within each group: newest-modified sessions first, newest entries per session. Continue searches with the returned cursor and the same query/scope; new searches see newer entries. Reads return stored images and page long text with the next offset.",
1024
1053
  promptSnippet: "recover earlier conversation that left the active context window",
1025
1054
  promptGuidelines: ["Use history search first, then history read with the returned entry id"],
1026
1055
  parameters: Type.Object({
@@ -1028,7 +1057,8 @@ export default function (pi: ExtensionAPI) {
1028
1057
  query: Type.Optional(Type.String({ description: "Case-insensitive text to find (search)" })),
1029
1058
  id: Type.Optional(Type.String({ description: "Entry id returned by search (read)" })),
1030
1059
  all: Type.Optional(Type.Boolean({ description: "Search all project sessions instead of the current branch" })),
1031
- limit: Type.Optional(Type.Integer({ description: "Maximum results (default 10, max 50)", minimum: 1, maximum: 50 })),
1060
+ limit: Type.Optional(Type.Integer({ description: "Maximum search results per page (default 10, max 50)", minimum: 1, maximum: 50 })),
1061
+ cursor: Type.Optional(Type.String({ description: "Search continuation returned by the previous page; keep query and all unchanged", maxLength: 512 })),
1032
1062
  offset: Type.Optional(Type.Integer({ description: "Character offset for read (default 0)", minimum: 0 })),
1033
1063
  }),
1034
1064
  async execute(_id, params, signal, _onUpdate, ctx) {
@@ -1037,42 +1067,91 @@ export default function (pi: ExtensionAPI) {
1037
1067
  if (params.op === "search") {
1038
1068
  const query = requireValue(params.query, "query", params.op).toLowerCase();
1039
1069
  const limit = params.limit ?? 10;
1070
+ const searchKey = createHash("sha256").update(JSON.stringify([query, params.all === true])).digest("base64url");
1071
+ let cursor: [string, 0 | 1, number, string] | undefined;
1072
+ if (params.cursor) {
1073
+ try {
1074
+ const value = JSON.parse(Buffer.from(params.cursor, "base64url").toString());
1075
+ if (!Array.isArray(value) || value.length !== 4 || typeof value[0] !== "string" || !value[0] || ![0, 1].includes(value[1]) || !Number.isSafeInteger(value[2]) || value[2] < 0 || value[3] !== searchKey) throw new Error();
1076
+ cursor = value as typeof cursor;
1077
+ } catch { pageError(nativeContext(ctx), "Invalid history cursor or changed query/scope; restart the search without it.", params.cursor); }
1078
+ }
1040
1079
  const hits: HistoryHit[][] = [[], []];
1041
- // Forks copy ancestor entries under the same ids into new session files; report each id once.
1080
+ // Mark skipped ids too: fork copies must not reappear on subsequent pages.
1042
1081
  const seen = new Set<string>();
1082
+ let found = !cursor;
1043
1083
  const addHit = (hit: HistoryHit | undefined) => {
1044
- if (!hit || seen.has(hit.id) || hits[hit.priority].length >= limit) return;
1084
+ if (!hit || seen.has(hit.id)) return;
1045
1085
  seen.add(hit.id);
1046
- hits[hit.priority].push(hit);
1086
+ if (cursor && hit.priority < cursor[1]) return;
1087
+ if (cursor && hit.priority === cursor[1] && !found) {
1088
+ if (hit.id !== cursor[0]) return;
1089
+ found = true;
1090
+ if (cursor[2] > hit.text.length) pageError(nativeContext(ctx), "History cursor is past the entry; restart the search without it.", params.cursor);
1091
+ if (cursor[2] === hit.text.length) return;
1092
+ }
1093
+ if (hits[hit.priority].length <= limit) hits[hit.priority].push(hit);
1047
1094
  };
1048
1095
 
1049
1096
  if (params.all) {
1050
1097
  for (const file of sessionFiles(manager.getSessionDir())) {
1051
1098
  const recent: HistoryHit[][] = [[], []];
1099
+ const matchedIds = new Set<string>();
1100
+ let anchorHere = false;
1052
1101
  const source = relative(manager.getSessionDir(), file);
1053
1102
  for await (const item of sessionWindowEntries(file, signal)) {
1054
1103
  if (seen.has(item.entry.id!)) continue;
1055
1104
  const hit = historyHit(item, query, source);
1056
1105
  if (!hit) continue;
1106
+ matchedIds.add(hit.id);
1107
+ const seeking = cursor && !found && hit.priority === cursor[1];
1108
+ if (seeking && anchorHere) continue;
1109
+ if (seeking && hit.id === cursor?.[0]) anchorHere = true;
1057
1110
  const group = recent[hit.priority];
1058
1111
  group.push(hit);
1059
- if (group.length > limit - hits[hit.priority].length) group.shift();
1112
+ // Keep one page plus its anchor and lookahead, not every matching excerpt.
1113
+ if (group.length > limit + 2) group.shift();
1060
1114
  }
1115
+ if (cursor && !found && !anchorHere) recent[cursor[1]] = [];
1061
1116
  for (const group of recent) for (const hit of group.reverse()) addHit(hit);
1062
- if (hits[0].length >= limit) break;
1117
+ for (const id of matchedIds) seen.add(id);
1118
+ if (hits[0].length > limit) break;
1063
1119
  }
1064
1120
  } else {
1065
1121
  const current = [...windowEntries(manager.getBranch() as EntryLike[])];
1066
1122
  for (const item of current.reverse()) {
1067
1123
  addHit(historyHit(item, query));
1068
- if (hits[0].length >= limit) break;
1124
+ if (hits[0].length > limit) break;
1069
1125
  }
1070
1126
  }
1071
- const results = hits.flat().slice(0, limit);
1072
- return textResult(results.length ? results.map((hit) => hit.text).join("\n") : `No history matches "${params.query}".`, [], {
1073
- kind: "history-search",
1074
- entries: results.map((hit) => ({ headerLength: hit.headerLength, length: hit.text.length })),
1075
- });
1127
+ const chars = pageSize(nativeContext(ctx), 0, 0, params.cursor);
1128
+ if (!found) pageError(nativeContext(ctx), "History cursor entry no longer matches; restart the search without it.", params.cursor);
1129
+ const results = hits.flat();
1130
+ const cursorFor = (hit: HistoryHit, offset: number) => Buffer.from(JSON.stringify([hit.id, hit.priority, offset, searchKey])).toString("base64url");
1131
+ const footer = (next: string) => `\n[More results; continue with cursor "${next}" and the same query/scope.]`;
1132
+ const reserve = Math.max(0, ...results.map((hit) => footer(cursorFor(hit, hit.text.length)).length));
1133
+ if (reserve >= chars) pageError(nativeContext(ctx), "History entry id is too large for pagination.", params.cursor);
1134
+ const parts: string[] = [];
1135
+ const spans: Array<{ headerLength: number; length: number }> = [];
1136
+ let available = chars - reserve;
1137
+ let next: string | undefined;
1138
+ for (const hit of results) {
1139
+ const offset = cursor?.[0] === hit.id ? cursor[2] : 0;
1140
+ if (parts.length && hit.text.length - offset > available) break;
1141
+ // A split header or excerpt must still identify the native entry on every page.
1142
+ const prefix = offset || hit.text.length > available ? `[entry ${hit.id}; from char ${offset}] ` : "";
1143
+ if (prefix.length >= available) break;
1144
+ const end = Math.min(hit.text.length, offset + available - prefix.length);
1145
+ const part = prefix + hit.text.slice(offset, end);
1146
+ parts.push(part);
1147
+ spans.push({ headerLength: prefix.length + Math.max(0, Math.min(hit.headerLength - offset, end - offset)), length: part.length });
1148
+ available -= part.length + 1;
1149
+ next = end < hit.text.length || parts.length < results.length ? cursorFor(hit, end) : undefined;
1150
+ if (available <= 0 || parts.length >= limit) break;
1151
+ }
1152
+ const body = parts.length ? parts.join("\n") : `No history matches "${excerpt(params.query!, 200)}".`;
1153
+ const more = next ? footer(next) : "";
1154
+ return pageResult(body + more, [], { kind: "history-search", entries: spans, footerLength: more.length });
1076
1155
  }
1077
1156
 
1078
1157
  const id = requireValue(params.id, "id", params.op);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
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"],
@@ -19,6 +19,8 @@
19
19
  },
20
20
  "scripts": {
21
21
  "check": "tsc --noEmit",
22
+ "check:compat": "npm run check && npm test && npm run test:native",
23
+ "test:native": "node --import ./test/pi-loader.ts --test test/native.test.mjs",
22
24
  "test": "node --import ./test/pi-loader.ts --test test/*.test.ts",
23
25
  "prepublishOnly": "npm test && npm run check"
24
26
  },
@@ -39,8 +41,8 @@
39
41
  }
40
42
  },
41
43
  "devDependencies": {
42
- "@earendil-works/pi-coding-agent": "0.85.0",
43
- "@earendil-works/pi-tui": "0.85.0",
44
+ "@earendil-works/pi-coding-agent": "0.87.0",
45
+ "@earendil-works/pi-tui": "0.87.0",
44
46
  "typebox": "1.3.7",
45
47
  "typescript": "^5.5.0"
46
48
  }
package/ui.ts CHANGED
@@ -4,10 +4,10 @@ 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 }
7
+ | { kind: "notes-list" | "notes-search"; count: number; page?: { offset: number; end: number; total: number } }
8
8
  | { kind: "note-read"; offset: number; end: number; total: number }
9
9
  | { kind: "note-write" | "note-append" | "new-context" }
10
- | { kind: "history-search"; entries: Array<{ headerLength: number; length: number }> }
10
+ | { kind: "history-search"; entries: Array<{ headerLength: number; length: number }>; footerLength?: number }
11
11
  | { kind: "history-read"; headerLength: number; offset: number; end: number; total: number };
12
12
 
13
13
  type ToolName = "notes" | "history" | "get_context_remaining" | "new_context";
@@ -43,14 +43,17 @@ function pageSummary(page: { offset: number; end: number; total: number }): stri
43
43
  function historySections(raw: string, display: PosthorseDisplay | undefined): Array<{ body: string; header: string }> | undefined {
44
44
  const spans = display?.kind === "history-search" ? display.entries : display?.kind === "history-read" ? [{ headerLength: display.headerLength, length: raw.length }] : undefined;
45
45
  if (!Array.isArray(spans) || !spans.length) return undefined;
46
+ const footerLength = display?.kind === "history-search" ? display.footerLength ?? 0 : 0;
47
+ if (!Number.isInteger(footerLength) || footerLength < 0 || footerLength > raw.length) return undefined;
48
+ const bodyLength = raw.length - footerLength;
46
49
  let start = 0;
47
50
  const sections: Array<{ body: string; header: string }> = [];
48
51
  for (const span of spans) {
49
- if (!Number.isInteger(span.headerLength) || !Number.isInteger(span.length) || span.headerLength < 0 || span.headerLength > span.length || start + span.length > raw.length) return undefined;
52
+ if (!Number.isInteger(span.headerLength) || !Number.isInteger(span.length) || span.headerLength < 0 || span.headerLength > span.length || start + span.length > bodyLength) return undefined;
50
53
  sections.push({ header: raw.slice(start, start + span.headerLength).trimEnd(), body: raw.slice(start + span.headerLength, start + span.length) });
51
54
  start += span.length + 1;
52
55
  }
53
- return start === raw.length + 1 ? sections : undefined;
56
+ return start === bodyLength + 1 ? sections : undefined;
54
57
  }
55
58
 
56
59
  export function toolCards(name: ToolName): Pick<ToolDefinition, "renderCall" | "renderResult"> {
@@ -88,22 +91,23 @@ export function toolCards(name: ToolName): Pick<ToolDefinition, "renderCall" | "
88
91
  case "context": {
89
92
  const usage = display.usage;
90
93
  if (!usage || usage.tokens == null) { summary = "Context usage unknown"; break; }
91
- const hard = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens to hard limit`;
94
+ const configured = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens to configured context limit`;
92
95
  const rollover = display.rollover === "enabled" && display.rolloverAt !== undefined ? `≈${n(Math.max(0, display.rolloverAt - usage.tokens))} tokens to rollover` : `Automatic rollover ${display.rollover ?? "unknown"}`;
93
- summary = `${rollover}\n${hard}\n≈${n(usage.tokens)} / ${n(usage.contextWindow)} used${usage.percent == null ? "" : ` (≈${Math.round(usage.percent)}%)`}`;
96
+ summary = `${rollover}\n${configured}\n≈${n(usage.tokens)} / ${n(usage.contextWindow)} used${usage.percent == null ? "" : ` (≈${Math.round(usage.percent)}%)`}`;
94
97
  if (display.rollover === "unsupported") color = "warning";
95
98
  break;
96
99
  }
97
- case "notes-list": summary = display.count ? `${n(display.count)} note${display.count === 1 ? "" : "s"}` : "No notes yet"; break;
100
+ case "notes-list": summary = display.count ? `${n(display.count)} note${display.count === 1 ? "" : "s"} returned` : "No notes yet"; break;
98
101
  case "notes-search": summary = display.count ? `${n(display.count)} matches returned` : "No matches"; break;
99
102
  case "note-read": summary = pageSummary(display); break;
100
103
  case "note-write": summary = args.content === "" ? "Cleared note" : "Saved note"; break;
101
104
  case "note-append": summary = "Appended to note"; break;
102
- case "history-search": if (Array.isArray(display.entries) && (!display.entries.length || sections)) summary = `${display.entries.length ? `${n(display.entries.length)} matches` : "No matches"} · ${args.all === true ? "all sessions" : "current branch"}`; break;
105
+ case "history-search": if (Array.isArray(display.entries) && (!display.entries.length || sections)) summary = `${display.entries.length ? `${n(display.entries.length)} matches` : "No matches"} · ${args.all === true ? "all sessions" : "current branch"}${display.footerLength ? "\nMore results; continue with cursor" : ""}`; break;
103
106
  case "history-read": summary = pageSummary(display); break;
104
107
  case "new-context": summary = "Requested for after the whole tool batch succeeds."; break;
105
108
  }
106
109
  }
110
+ if (!context.isError && !isPartial && (display?.kind === "notes-list" || display?.kind === "notes-search") && display.page && (display.page.offset || display.page.end < display.page.total)) summary += `\n${pageSummary(display.page)}`;
107
111
  if (!body.trim()) body = context.isError ? "No error details returned." : isPartial ? "Running…" : name === "notes" && args.op === "read" ? "Empty note" : "No text returned.";
108
112
  const submitted = name === "new_context" && typeof args.handoff === "string" ? `Handoff supplied:\n${args.handoff}` : name === "notes" && (args.op === "write" || args.op === "append") && typeof args.content === "string" ? `Submitted content:\n${args.content || "(empty)"}` : "";
109
113
  const images = Array.isArray(result.content) ? result.content.filter((part) => part.type === "image") : [];
@@ -121,7 +125,7 @@ export function toolCards(name: ToolName): Pick<ToolDefinition, "renderCall" | "
121
125
  invalidate() {},
122
126
  render(width) {
123
127
  if (expanded) {
124
- return [...fullText.render(width), ...attachmentText.render(width), ...(submitted ? ["", ...submittedText.render(width)] : [])];
128
+ return [...fullText.render(width), ...attachmentText.render(width), ...(sections && display?.kind === "history-search" && display.footerLength ? textBlock(raw.slice(-display.footerLength), theme, "muted").render(width) : []), ...(submitted ? ["", ...submittedText.render(width)] : [])];
125
129
  }
126
130
  const summaryRows = summaryText.render(width).slice(0, 5);
127
131
  const previewRows = summaryOnly && !context.isError && !isPartial ? [] : searchPreviews