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.
- package/README.md +23 -16
- package/index.ts +128 -49
- package/package.json +5 -3
- 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
|
|
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
|
|
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
|
|
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
|
|
53
|
-
3. **`get_context_remaining`.** Reports the best available native estimate of tokens until Pi's automatic rollover line and until the
|
|
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
|
|
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.
|
|
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`
|
|
78
|
-
- `history({ op, ... })`: `search
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
526
|
+
const toolBatch = trailingToolBatch(current);
|
|
524
527
|
const batchHeader = toolBatch
|
|
525
|
-
? `
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
907
|
-
if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${
|
|
908
|
-
if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${
|
|
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)}); ${
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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)
|
|
1084
|
+
if (!hit || seen.has(hit.id)) return;
|
|
1045
1085
|
seen.add(hit.id);
|
|
1046
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1124
|
+
if (hits[0].length > limit) break;
|
|
1069
1125
|
}
|
|
1070
1126
|
}
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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.
|
|
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.
|
|
43
|
-
"@earendil-works/pi-tui": "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 >
|
|
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 ===
|
|
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
|
|
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${
|
|
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
|