pi-posthorse 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/index.ts +156 -71
- package/package.json +8 -3
- package/ui.ts +180 -0
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ Native, no-summary context windows for the [`fitchmultz/pi`](https://github.com/
|
|
|
8
8
|
|
|
9
9
|
Pi owns the persisted boundary. Posthorse owns the policy: stable window guidance, one best-effort checkpoint reminder, `new_context`, `get_context_remaining`, durable `notes`, and window-aware `history`. A rollover removes the old window from active model context while the JSONL transcript stays append-only and complete.
|
|
10
10
|
|
|
11
|
+
An isolated [Codex prototype](adapters/codex-posthorse/README.md) tests local notes and recovery with Codex's native context reset. It is not ready for everyday use and is not included in the Pi package.
|
|
12
|
+
|
|
11
13
|
## Requirements
|
|
12
14
|
|
|
13
15
|
- Node `>=22.19.0`.
|
|
@@ -40,7 +42,7 @@ pi -e git:github.com/fitchmultz/pi-posthorse # try it for one run w
|
|
|
40
42
|
|
|
41
43
|
Update with `pi update npm:pi-posthorse` or `pi update --extensions`; move a pinned Git install with `pi install git:github.com/fitchmultz/pi-posthorse@<new tag>`. Uninstall with `pi remove npm:pi-posthorse` (or the Git source you installed). Removing the package leaves `.pi/notes` and Pi's session history in place.
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
Restart Pi after installing or updating Posthorse to load the new extension code.
|
|
44
46
|
|
|
45
47
|
Keep exactly one copy loaded. `pi list` shows every package source; if an older entry such as `git:github.com/fitchmultz/pi-headroom.git` or a local checkout is still listed, `pi remove` it before installing the npm package, otherwise two copies register the same tools and compete for the same rollover hook.
|
|
46
48
|
|
|
@@ -52,7 +54,7 @@ Keep exactly one copy loaded. `pi list` shows every package source; if an older
|
|
|
52
54
|
4. **`new_context`.** Requests an atomic rollover after the complete tool batch succeeds. An optional handoff is persisted and becomes the first state of the fresh window. If a sibling tool in the same batch fails, Pi does not commit the boundary; the checkpoint reminder still applies.
|
|
53
55
|
5. **Automatic rollover without summaries.** With a supported context budget and room for a recovery record, Posthorse claims Pi's automatic threshold and overflow trigger through `session_before_auto_compact`, before Pi resolves summarization credentials or prepares a summary. Oversized first turns and tool results can then roll over even without summarization credentials. Otherwise Pi's own compaction remains in control. Manual `/compact` is unchanged.
|
|
54
56
|
6. **Bounded recovery record.** The automatic handoff keeps direct user inputs, `ask_question` outcomes, visible coordination messages, and the trailing tool batch that no model has consumed yet (call arguments, bounded result text, and the entry ids to recover the rest). A clearly labeled, possibly stale older checkpoint comes last, after the current inputs and unseen results. Older assistant prose and consumed tool results are not treated as state. Newly submitted input stays separate and is saved after the boundary, not copied into the handoff.
|
|
55
|
-
7. **`notes` and `history`.** Notes
|
|
57
|
+
7. **`notes` and `history`.** Notes are shared across linked worktrees, at the main checkout for conventional Git layouts or inside the common Git directory when metadata is stored separately. History searches normalized transcript text and returns stored images for a requested entry.
|
|
56
58
|
|
|
57
59
|
At turn end, Posthorse checks whether usage is in the reminder band before explicitly looking up the full branch. Context filtering skips its branch lookup when model input contains neither `posthorse-reminder` nor legacy `headroom-reminder` messages. History searches, reads, and recovery remain available with no new limits.
|
|
58
60
|
|
|
@@ -75,20 +77,24 @@ Only one automatic compaction or rollover policy extension should be enabled at
|
|
|
75
77
|
- `notes({ op, ... })`: `list`, `read` (paged; `offset` continues), `write` (empty content clears), `append` (one atomic newline-terminated record), `search` (excerpts centered on the match)
|
|
76
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
79
|
|
|
78
|
-
|
|
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
|
+
|
|
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
|
+
|
|
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.
|
|
79
85
|
|
|
80
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.
|
|
81
87
|
|
|
82
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.
|
|
83
89
|
|
|
84
|
-
Notes live in `.pi/notes/` at the
|
|
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.
|
|
85
91
|
|
|
86
92
|
## Data and privacy
|
|
87
93
|
|
|
88
94
|
- Posthorse makes no network requests.
|
|
89
95
|
- Notes are plaintext files under `.pi/notes`. They survive package removal and may be committed unless ignored.
|
|
90
96
|
- `history` with `all: true` scans nested JSONL files in the active Pi session directory, including subagent sessions.
|
|
91
|
-
- History can return user text, assistant text and thinking, tool arguments and results, handoffs, custom messages, and images. Direct shell entries Pi marked `excludeFromContext` come back as a placeholder only.
|
|
97
|
+
- History can return user text, assistant text and thinking, assistant failure status and error messages, tool arguments and results, handoffs, custom messages, and images. Direct shell entries Pi marked `excludeFromContext` come back as a placeholder only.
|
|
92
98
|
- Returned history content enters the currently selected model and provider context.
|
|
93
99
|
- Removing Posthorse does not remove notes or Pi session history.
|
|
94
100
|
|
|
@@ -105,4 +111,4 @@ npm run check
|
|
|
105
111
|
PI_FORK=../pi scripts/integration.sh # loads the real extension into the fork's test harness (fork built)
|
|
106
112
|
```
|
|
107
113
|
|
|
108
|
-
`npm run check` type-checks
|
|
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.
|
package/index.ts
CHANGED
|
@@ -8,18 +8,21 @@
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
appendFileSync,
|
|
11
|
+
constants,
|
|
12
|
+
copyFileSync,
|
|
11
13
|
createReadStream,
|
|
12
14
|
existsSync,
|
|
13
15
|
mkdirSync,
|
|
14
16
|
readdirSync,
|
|
15
17
|
readFileSync,
|
|
18
|
+
realpathSync,
|
|
16
19
|
statSync,
|
|
17
20
|
writeFileSync,
|
|
18
21
|
} from "node:fs";
|
|
19
|
-
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
20
|
-
import { createInterface } from "node:readline";
|
|
22
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
21
23
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
24
|
import { Type } from "typebox";
|
|
25
|
+
import { registerPosthorseMessages, toolCards, type PosthorseDisplay } from "./ui.ts";
|
|
23
26
|
|
|
24
27
|
const REMINDER_BUFFER_TOKENS = 32_000;
|
|
25
28
|
/** Absolute ceiling for handoffs and read pages; pages shrink to the live remaining budget. */
|
|
@@ -63,6 +66,7 @@ type ImageLike = { type: "image"; data: string; mimeType: string };
|
|
|
63
66
|
type MessageLike = {
|
|
64
67
|
role?: string;
|
|
65
68
|
stopReason?: string;
|
|
69
|
+
errorMessage?: string;
|
|
66
70
|
content?: unknown;
|
|
67
71
|
toolName?: string;
|
|
68
72
|
toolCallId?: string;
|
|
@@ -86,7 +90,7 @@ type EntryLike = {
|
|
|
86
90
|
};
|
|
87
91
|
|
|
88
92
|
type WindowedEntry = { entry: EntryLike; windowId: string; text: string; images: ImageLike[] };
|
|
89
|
-
type HistoryHit = { id: string; text: string; priority: 0 | 1 };
|
|
93
|
+
type HistoryHit = { id: string; text: string; headerLength: number; priority: 0 | 1 };
|
|
90
94
|
type RecoveryRecord = {
|
|
91
95
|
id: string;
|
|
92
96
|
timestamp: string;
|
|
@@ -159,25 +163,53 @@ function textOf(message: MessageLike): string {
|
|
|
159
163
|
.join("\n");
|
|
160
164
|
}
|
|
161
165
|
|
|
162
|
-
function textResult(text: string, images: ImageLike[] = []) {
|
|
163
|
-
return { content: [{ type: "text" as const, text }, ...images], details:
|
|
166
|
+
function textResult(text: string, images: ImageLike[] = [], display?: PosthorseDisplay) {
|
|
167
|
+
return { content: [{ type: "text" as const, text }, ...images], details: display };
|
|
164
168
|
}
|
|
165
169
|
|
|
166
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
/** Import old checkout-local notes without overwriting shared notes, including concurrent writes. */
|
|
171
|
+
function importLegacyNotes(source: string, target: string): void {
|
|
172
|
+
if (!existsSync(source)) return;
|
|
173
|
+
if (existsSync(target) && (!statSync(target).isDirectory() || realpathSync(source) === realpathSync(target))) return;
|
|
174
|
+
mkdirSync(target, { recursive: true });
|
|
175
|
+
for (const name of readdirSync(source)) {
|
|
176
|
+
const from = join(source, name);
|
|
177
|
+
const to = join(target, name);
|
|
178
|
+
if (statSync(from).isDirectory()) {
|
|
179
|
+
importLegacyNotes(from, to);
|
|
180
|
+
} else {
|
|
181
|
+
try {
|
|
182
|
+
copyFileSync(from, to, constants.COPYFILE_EXCL);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Conventional repos use the main checkout; separate Git directories are their own shared root. */
|
|
171
191
|
function notesRoot(cwd: string): string {
|
|
172
192
|
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
173
193
|
const marker = join(dir, ".git");
|
|
174
194
|
if (existsSync(marker)) {
|
|
195
|
+
let root: string;
|
|
175
196
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
197
|
+
let target = marker;
|
|
198
|
+
if (!statSync(marker).isDirectory()) {
|
|
199
|
+
const gitdir = readFileSync(marker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
|
|
200
|
+
if (!gitdir) return dir;
|
|
201
|
+
target = resolve(dir, gitdir);
|
|
202
|
+
}
|
|
203
|
+
target = realpathSync(target);
|
|
204
|
+
const commonFile = join(target, "commondir");
|
|
205
|
+
const common = realpathSync(existsSync(commonFile) ? resolve(target, readFileSync(commonFile, "utf8").trim()) : target);
|
|
206
|
+
root = basename(common) === ".git" ? dirname(common) : common;
|
|
207
|
+
} catch {
|
|
208
|
+
// Orphaned/copied worktrees can outlive their Git metadata; local notes still work.
|
|
209
|
+
return dir;
|
|
210
|
+
}
|
|
211
|
+
if (root !== dir) importLegacyNotes(join(dir, ".pi", "notes"), join(root, ".pi", "notes"));
|
|
212
|
+
return root;
|
|
181
213
|
}
|
|
182
214
|
if (dirname(dir) === dir) return cwd;
|
|
183
215
|
}
|
|
@@ -194,6 +226,12 @@ function excerptAround(text: string, index: number, before: number, length: numb
|
|
|
194
226
|
return `${start ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
|
|
195
227
|
}
|
|
196
228
|
|
|
229
|
+
function assistantFailure(message: MessageLike): string {
|
|
230
|
+
if (message.role !== "assistant") return "";
|
|
231
|
+
if (message.stopReason !== "error" && message.stopReason !== "aborted" && !message.errorMessage) return "";
|
|
232
|
+
return `[${message.stopReason ?? "error"}]${message.errorMessage ? ` ${message.errorMessage}` : ""}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
197
235
|
function flattenEntry(entry: EntryLike): string | undefined {
|
|
198
236
|
if (entry.type === "message") {
|
|
199
237
|
const message = entry.message ?? {};
|
|
@@ -202,7 +240,7 @@ function flattenEntry(entry: EntryLike): string | undefined {
|
|
|
202
240
|
if (message.excludeFromContext === true) return "[bashExecution] (excluded from model context by Pi)";
|
|
203
241
|
return `[bashExecution] $ ${message.command ?? ""}\n${message.output ?? ""}`;
|
|
204
242
|
}
|
|
205
|
-
return `[${message.role ?? "message"}] ${[textOf(message), imageSummary(imagesOf(message.content))].filter(Boolean).join("\n")}`;
|
|
243
|
+
return `[${message.role ?? "message"}] ${[textOf(message), assistantFailure(message), imageSummary(imagesOf(message.content))].filter(Boolean).join("\n")}`;
|
|
206
244
|
}
|
|
207
245
|
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
208
246
|
return `[${entry.type}] ${entry.summary ?? ""}`;
|
|
@@ -253,8 +291,8 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
|
|
|
253
291
|
if (partText) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${partText}`;
|
|
254
292
|
}
|
|
255
293
|
}
|
|
256
|
-
const
|
|
257
|
-
if (
|
|
294
|
+
const metadata = [assistantFailure(entry.message), imageSummary(item.images)].filter(Boolean).join("\n");
|
|
295
|
+
if (metadata) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${metadata}`;
|
|
258
296
|
if (originals[0]) originals[0] = `[assistant] ${originals[0]}`;
|
|
259
297
|
const original = originals.find((part) => part.toLowerCase().includes(query));
|
|
260
298
|
priority = original ? 0 : 1;
|
|
@@ -263,10 +301,12 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
|
|
|
263
301
|
matchIndex = text.toLowerCase().indexOf(query);
|
|
264
302
|
}
|
|
265
303
|
}
|
|
304
|
+
const header = `${source ? `${source} ` : ""}${entry.timestamp ?? ""} [window ${item.windowId}] [${entry.id}] `;
|
|
266
305
|
return {
|
|
267
306
|
id: entry.id!,
|
|
268
307
|
priority,
|
|
269
|
-
text: `${
|
|
308
|
+
text: `${header}${excerptAround(text, matchIndex, 100, 400)}`,
|
|
309
|
+
headerLength: header.length,
|
|
270
310
|
};
|
|
271
311
|
}
|
|
272
312
|
|
|
@@ -288,25 +328,30 @@ function* windowEntries(entries: Iterable<EntryLike>): Generator<WindowedEntry>
|
|
|
288
328
|
}
|
|
289
329
|
}
|
|
290
330
|
|
|
331
|
+
/** JSONL splits on LF, not the Unicode separators that Node's readline also recognizes. */
|
|
332
|
+
async function* jsonlLines(file: string, signal?: AbortSignal): AsyncGenerator<string> {
|
|
333
|
+
const stream = createReadStream(file, { encoding: "utf8", signal });
|
|
334
|
+
let pending = "";
|
|
335
|
+
for await (const chunk of stream) {
|
|
336
|
+
const lines = (pending + chunk).split("\n");
|
|
337
|
+
pending = lines.pop()!;
|
|
338
|
+
yield* lines;
|
|
339
|
+
}
|
|
340
|
+
if (pending) yield pending;
|
|
341
|
+
}
|
|
342
|
+
|
|
291
343
|
async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncGenerator<WindowedEntry> {
|
|
292
344
|
if (!existsSync(file)) return;
|
|
293
|
-
const stream = createReadStream(file, { encoding: "utf8", signal });
|
|
294
|
-
const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
|
|
295
345
|
const windows = new Map<string, string>();
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
const item = toWindowedEntry(entry, windows);
|
|
305
|
-
if (item) yield item;
|
|
346
|
+
for await (const line of jsonlLines(file, signal)) {
|
|
347
|
+
let entry: EntryLike;
|
|
348
|
+
try {
|
|
349
|
+
entry = JSON.parse(line) as EntryLike;
|
|
350
|
+
} catch {
|
|
351
|
+
continue;
|
|
306
352
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
stream.destroy();
|
|
353
|
+
const item = toWindowedEntry(entry, windows);
|
|
354
|
+
if (item) yield item;
|
|
310
355
|
}
|
|
311
356
|
}
|
|
312
357
|
|
|
@@ -633,26 +678,13 @@ function unsupportedMessage(budget: Budget): string {
|
|
|
633
678
|
return `Posthorse: unsupported configuration. The model's context window (${n(budget.contextWindow)} tokens) minus Pi's compaction.reserveTokens (${n(budget.reserveTokens)}) leaves ${n(budget.usable)} usable tokens; Posthorse needs at least ${n(MIN_USABLE_TOKENS)}. Automatic rollover and checkpoint reminders are off for this model. Lower compaction.reserveTokens in Pi settings or use a larger-context model. new_context remains available with a model-aware handoff limit.`;
|
|
634
679
|
}
|
|
635
680
|
|
|
636
|
-
/**
|
|
637
|
-
function
|
|
681
|
+
/** Capacity before Pi's automatic line (or hard limit), including the margin for page metadata and refusals. */
|
|
682
|
+
function pageCapacity(ctx: NativeContext, toolTokens: number): number {
|
|
638
683
|
const usage = ctx.getContextUsage();
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const budget = budgetFor(ctx, usage.contextWindow);
|
|
644
|
-
const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
|
|
645
|
-
chars = Math.min(
|
|
646
|
-
MAX_HANDOFF_CHARS,
|
|
647
|
-
Math.max(0, line - usage.tokens - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
if (chars < MIN_PAGE_CHARS) {
|
|
651
|
-
throw new Error(
|
|
652
|
-
`Too little context remains to read a page safely. Call new_context first, then retry with offset ${offset}.`,
|
|
653
|
-
);
|
|
654
|
-
}
|
|
655
|
-
return chars;
|
|
684
|
+
if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens) / 4 + PAGE_MARGIN_TOKENS;
|
|
685
|
+
const budget = budgetFor(ctx, usage.contextWindow);
|
|
686
|
+
const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
|
|
687
|
+
return line - usage.tokens;
|
|
656
688
|
}
|
|
657
689
|
|
|
658
690
|
function buildGuidance(ctx: NativeContext): string {
|
|
@@ -678,6 +710,7 @@ Automatic handoffs are emergency recovery records, not proof of current state. R
|
|
|
678
710
|
}
|
|
679
711
|
|
|
680
712
|
export default function (pi: ExtensionAPI) {
|
|
713
|
+
registerPosthorseMessages(pi);
|
|
681
714
|
const activeToolTokens = () => {
|
|
682
715
|
const active = new Set(pi.getActiveTools());
|
|
683
716
|
return pi
|
|
@@ -694,7 +727,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
694
727
|
);
|
|
695
728
|
};
|
|
696
729
|
|
|
730
|
+
let pendingPageTokens = 0;
|
|
731
|
+
let previousPageUsage: number | null | undefined;
|
|
732
|
+
const pageSize = (ctx: NativeContext, offset: number, imageCount: number) => {
|
|
733
|
+
const usage = ctx.getContextUsage()?.tokens;
|
|
734
|
+
// Parallel siblings are not in native usage yet; sequential results must not be counted twice.
|
|
735
|
+
if (usage != null && previousPageUsage != null) {
|
|
736
|
+
pendingPageTokens = Math.max(0, pendingPageTokens - Math.max(0, usage - previousPageUsage));
|
|
737
|
+
}
|
|
738
|
+
previousPageUsage = usage;
|
|
739
|
+
const remaining = pageCapacity(ctx, activeToolTokens()) - pendingPageTokens;
|
|
740
|
+
const chars = Math.min(
|
|
741
|
+
MAX_HANDOFF_CHARS,
|
|
742
|
+
Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
|
|
743
|
+
);
|
|
744
|
+
if (chars < MIN_PAGE_CHARS) {
|
|
745
|
+
const message = `Too little context remains to read a page safely. Call new_context first, then retry with offset ${offset}.`;
|
|
746
|
+
// Refusals consume context too. Once even the guidance no longer fits, omit repeated text;
|
|
747
|
+
// the failed call still carries its original offset and earlier refusals explain the retry.
|
|
748
|
+
const text = Math.ceil(message.length / 4) <= remaining ? message : "";
|
|
749
|
+
pendingPageTokens += Math.ceil(text.length / 4);
|
|
750
|
+
throw new Error(text);
|
|
751
|
+
}
|
|
752
|
+
return chars;
|
|
753
|
+
};
|
|
754
|
+
const pageResult = (text: string, images: ImageLike[], display: PosthorseDisplay) => {
|
|
755
|
+
pendingPageTokens += Math.ceil(text.length / 4) + images.length * (ESTIMATED_IMAGE_CHARS / 4);
|
|
756
|
+
return textResult(text, images, display);
|
|
757
|
+
};
|
|
758
|
+
pi.on("turn_start", () => {
|
|
759
|
+
pendingPageTokens = 0;
|
|
760
|
+
previousPageUsage = undefined;
|
|
761
|
+
});
|
|
762
|
+
|
|
697
763
|
pi.on("session_start", (_event, ctx) => {
|
|
764
|
+
pendingPageTokens = 0;
|
|
765
|
+
previousPageUsage = undefined;
|
|
698
766
|
nativeContext(ctx);
|
|
699
767
|
});
|
|
700
768
|
|
|
@@ -781,6 +849,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
781
849
|
pi.registerTool({
|
|
782
850
|
name: "new_context",
|
|
783
851
|
label: "New Context",
|
|
852
|
+
...toolCards("new_context"),
|
|
784
853
|
description:
|
|
785
854
|
"Start a genuinely fresh context window after this tool batch. Earlier conversation leaves active context without a generated summary but remains recoverable through history. Pass concise continuation state in handoff, or save richer state with notes first.",
|
|
786
855
|
promptSnippet: "start a fresh context window with an optional atomic handoff",
|
|
@@ -807,6 +876,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
807
876
|
return {
|
|
808
877
|
...textResult(
|
|
809
878
|
"Requested a fresh Pi context after this complete tool batch succeeds. Earlier conversation stays in session history.",
|
|
879
|
+
[],
|
|
880
|
+
{ kind: "new-context" },
|
|
810
881
|
),
|
|
811
882
|
newContext: { handoff: trimmed },
|
|
812
883
|
};
|
|
@@ -816,6 +887,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
816
887
|
pi.registerTool({
|
|
817
888
|
name: "get_context_remaining",
|
|
818
889
|
label: "Context Remaining",
|
|
890
|
+
...toolCards("get_context_remaining"),
|
|
819
891
|
description:
|
|
820
892
|
"Best available native estimate of the context budget: tokens until Pi's automatic rollover line and until the model's hard limit.",
|
|
821
893
|
promptSnippet: "check the remaining context budget only when needed",
|
|
@@ -823,14 +895,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
823
895
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
824
896
|
const native = nativeContext(ctx);
|
|
825
897
|
const usage = native.getContextUsage();
|
|
826
|
-
if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.");
|
|
898
|
+
if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.", [], { kind: "context" });
|
|
827
899
|
const n = (value: number) => value.toLocaleString("en-US");
|
|
828
900
|
const budget = budgetFor(native, usage.contextWindow);
|
|
901
|
+
const display: PosthorseDisplay = {
|
|
902
|
+
kind: "context", usage,
|
|
903
|
+
rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
|
|
904
|
+
rolloverAt: budget?.rolloverAt,
|
|
905
|
+
};
|
|
829
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.`;
|
|
830
|
-
if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${hard}
|
|
831
|
-
if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${hard}
|
|
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);
|
|
832
909
|
return textResult(
|
|
833
910
|
`≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${hard}`,
|
|
911
|
+
[], display,
|
|
834
912
|
);
|
|
835
913
|
},
|
|
836
914
|
});
|
|
@@ -838,8 +916,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
838
916
|
pi.registerTool({
|
|
839
917
|
name: "notes",
|
|
840
918
|
label: "Notes",
|
|
919
|
+
...toolCards("notes"),
|
|
841
920
|
description:
|
|
842
|
-
"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).
|
|
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.",
|
|
843
922
|
promptSnippet: "save and recall durable state that survives context resets",
|
|
844
923
|
promptGuidelines: [
|
|
845
924
|
"Use notes for durable state too large for a new_context handoff",
|
|
@@ -874,10 +953,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
874
953
|
|
|
875
954
|
switch (params.op) {
|
|
876
955
|
case "list": {
|
|
877
|
-
if (!existsSync(dir)) return textResult("(no notes yet)");
|
|
956
|
+
if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-list", count: 0 });
|
|
878
957
|
const files: string[] = [];
|
|
879
958
|
walk(dir, files);
|
|
880
|
-
return textResult(files.length ? files.map((file) => file.slice(dir.length + 1)).join("\n") : "(no notes yet)");
|
|
959
|
+
return textResult(files.length ? files.map((file) => file.slice(dir.length + 1)).join("\n") : "(no notes yet)", [], { kind: "notes-list", count: files.length });
|
|
881
960
|
}
|
|
882
961
|
case "read": {
|
|
883
962
|
const relative = requireValue(params.path, "path", params.op);
|
|
@@ -888,10 +967,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
888
967
|
if (offset && offset >= text.length) {
|
|
889
968
|
throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
|
|
890
969
|
}
|
|
891
|
-
const end = Math.min(text.length, offset +
|
|
970
|
+
const end = Math.min(text.length, offset + pageSize(nativeContext(ctx), offset, 0));
|
|
892
971
|
const more =
|
|
893
972
|
end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
|
|
894
|
-
return
|
|
973
|
+
return pageResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
|
|
895
974
|
}
|
|
896
975
|
case "write": {
|
|
897
976
|
const relative = requireValue(params.path, "path", params.op);
|
|
@@ -899,7 +978,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
899
978
|
const path = safeJoin(relative);
|
|
900
979
|
mkdirSync(dirname(path), { recursive: true });
|
|
901
980
|
writeFileSync(path, params.content);
|
|
902
|
-
return textResult(`Wrote
|
|
981
|
+
return textResult(`Wrote ${path}`, [], { kind: "note-write" });
|
|
903
982
|
}
|
|
904
983
|
case "append": {
|
|
905
984
|
const relative = requireValue(params.path, "path", params.op);
|
|
@@ -912,11 +991,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
912
991
|
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
913
992
|
const separator = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
914
993
|
appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
|
|
915
|
-
return textResult(`Appended to
|
|
994
|
+
return textResult(`Appended to ${path}`, [], { kind: "note-append" });
|
|
916
995
|
}
|
|
917
996
|
case "search": {
|
|
918
997
|
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
919
|
-
if (!existsSync(dir)) return textResult("(no notes yet)");
|
|
998
|
+
if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-search", count: 0 });
|
|
920
999
|
const files: string[] = [];
|
|
921
1000
|
walk(dir, files);
|
|
922
1001
|
const hits: string[] = [];
|
|
@@ -930,7 +1009,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
930
1009
|
}
|
|
931
1010
|
}
|
|
932
1011
|
}
|
|
933
|
-
return textResult(hits.length ? hits.join("\n") : `No notes match "${params.query}"
|
|
1012
|
+
return textResult(hits.length ? hits.join("\n") : `No notes match "${params.query}".`, [], { kind: "notes-search", count: hits.length });
|
|
934
1013
|
}
|
|
935
1014
|
}
|
|
936
1015
|
},
|
|
@@ -939,6 +1018,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
939
1018
|
pi.registerTool({
|
|
940
1019
|
name: "history",
|
|
941
1020
|
label: "History",
|
|
1021
|
+
...toolCards("history"),
|
|
942
1022
|
description:
|
|
943
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.",
|
|
944
1024
|
promptSnippet: "recover earlier conversation that left the active context window",
|
|
@@ -957,13 +1037,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
957
1037
|
if (params.op === "search") {
|
|
958
1038
|
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
959
1039
|
const limit = params.limit ?? 10;
|
|
960
|
-
const hits:
|
|
1040
|
+
const hits: HistoryHit[][] = [[], []];
|
|
961
1041
|
// Forks copy ancestor entries under the same ids into new session files; report each id once.
|
|
962
1042
|
const seen = new Set<string>();
|
|
963
1043
|
const addHit = (hit: HistoryHit | undefined) => {
|
|
964
1044
|
if (!hit || seen.has(hit.id) || hits[hit.priority].length >= limit) return;
|
|
965
1045
|
seen.add(hit.id);
|
|
966
|
-
hits[hit.priority].push(hit
|
|
1046
|
+
hits[hit.priority].push(hit);
|
|
967
1047
|
};
|
|
968
1048
|
|
|
969
1049
|
if (params.all) {
|
|
@@ -989,7 +1069,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
989
1069
|
}
|
|
990
1070
|
}
|
|
991
1071
|
const results = hits.flat().slice(0, limit);
|
|
992
|
-
return textResult(results.length ? results.join("\n") : `No history matches "${params.query}"
|
|
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
|
+
});
|
|
993
1076
|
}
|
|
994
1077
|
|
|
995
1078
|
const id = requireValue(params.id, "id", params.op);
|
|
@@ -1000,13 +1083,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1000
1083
|
}
|
|
1001
1084
|
const end = Math.min(
|
|
1002
1085
|
item.text.length,
|
|
1003
|
-
offset +
|
|
1086
|
+
offset + pageSize(nativeContext(ctx), offset, offset === 0 ? item.images.length : 0),
|
|
1004
1087
|
);
|
|
1005
1088
|
const more = end < item.text.length ? `\nMore remains; call history read with id "${id}" and offset ${end}.` : "";
|
|
1089
|
+
const header = `${source ? `${source} ` : ""}${item.entry.timestamp ?? ""} [window ${item.windowId}] [${id}] [chars ${offset}-${end} of ${item.text.length}] `;
|
|
1006
1090
|
// Stored images ride along with the first page only.
|
|
1007
|
-
return
|
|
1008
|
-
`${
|
|
1091
|
+
return pageResult(
|
|
1092
|
+
`${header}${item.text.slice(offset, end)}${more}`,
|
|
1009
1093
|
offset === 0 ? item.images : [],
|
|
1094
|
+
{ kind: "history-read", headerLength: header.length, offset, end, total: item.text.length },
|
|
1010
1095
|
);
|
|
1011
1096
|
};
|
|
1012
1097
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-posthorse",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Posthorse: fresh context, same journey. Native no-summary context windows for the fitchmultz/pi fork, with sparse budget reminders, handoffs, notes, and history.",
|
|
6
6
|
"keywords": ["pi-package", "pi-extension", "context", "posthorse", "rollover"],
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"url": "git+https://github.com/fitchmultz/pi-posthorse.git"
|
|
10
10
|
},
|
|
11
11
|
"license": "MIT",
|
|
12
|
-
"files": ["index.ts"],
|
|
12
|
+
"files": ["index.ts", "ui.ts"],
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=22.19.0"
|
|
15
15
|
},
|
|
@@ -19,23 +19,28 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"check": "tsc --noEmit",
|
|
22
|
-
"test": "node --
|
|
22
|
+
"test": "node --import ./test/pi-loader.ts --test test/*.test.ts",
|
|
23
23
|
"prepublishOnly": "npm test && npm run check"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"@earendil-works/pi-coding-agent": "*",
|
|
27
|
+
"@earendil-works/pi-tui": "*",
|
|
27
28
|
"typebox": "*"
|
|
28
29
|
},
|
|
29
30
|
"peerDependenciesMeta": {
|
|
30
31
|
"@earendil-works/pi-coding-agent": {
|
|
31
32
|
"optional": true
|
|
32
33
|
},
|
|
34
|
+
"@earendil-works/pi-tui": {
|
|
35
|
+
"optional": true
|
|
36
|
+
},
|
|
33
37
|
"typebox": {
|
|
34
38
|
"optional": true
|
|
35
39
|
}
|
|
36
40
|
},
|
|
37
41
|
"devDependencies": {
|
|
38
42
|
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
43
|
+
"@earendil-works/pi-tui": "0.85.0",
|
|
39
44
|
"typebox": "1.3.7",
|
|
40
45
|
"typescript": "^5.5.0"
|
|
41
46
|
}
|
package/ui.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { keyText, type ExtensionAPI, type MessageRenderer, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, MouseRegion, Text, stripTerminalSequences, truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/** Display-only facts and offsets into content, never a second copy of a note or history page. */
|
|
5
|
+
export type PosthorseDisplay =
|
|
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 }
|
|
8
|
+
| { kind: "note-read"; offset: number; end: number; total: number }
|
|
9
|
+
| { kind: "note-write" | "note-append" | "new-context" }
|
|
10
|
+
| { kind: "history-search"; entries: Array<{ headerLength: number; length: number }> }
|
|
11
|
+
| { kind: "history-read"; headerLength: number; offset: number; end: number; total: number };
|
|
12
|
+
|
|
13
|
+
type ToolName = "notes" | "history" | "get_context_remaining" | "new_context";
|
|
14
|
+
const titles: Record<ToolName, string> = { notes: "Notes", history: "History", get_context_remaining: "Context budget", new_context: "New context" };
|
|
15
|
+
const n = (value: number) => value.toLocaleString("en-US");
|
|
16
|
+
|
|
17
|
+
function clean(value: string): string {
|
|
18
|
+
return stripTerminalSequences(value).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\ufff9-\ufffb]/g, "");
|
|
19
|
+
}
|
|
20
|
+
function argsOf(value: unknown): Record<string, unknown> {
|
|
21
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
22
|
+
}
|
|
23
|
+
function argument(value: unknown, name: string): string {
|
|
24
|
+
return typeof value === "string" ? clean(value) : value == null ? "…" : `[invalid ${name}]`;
|
|
25
|
+
}
|
|
26
|
+
function textOf(content: unknown): string {
|
|
27
|
+
if (typeof content === "string") return content;
|
|
28
|
+
return Array.isArray(content) ? content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n") : "";
|
|
29
|
+
}
|
|
30
|
+
function textBlock(text: string, theme: Theme, color: "toolOutput" | "muted" | "dim" | "error" | "warning" = "toolOutput"): Text {
|
|
31
|
+
return new Text(text ? theme.fg(color, clean(text)) : "", 0, 0);
|
|
32
|
+
}
|
|
33
|
+
function hint(theme: Theme, width: number): string {
|
|
34
|
+
const key = keyText("app.tools.expand");
|
|
35
|
+
return truncateToWidth(theme.fg("dim", key ? `… ${key} to expand` : "… details hidden"), width);
|
|
36
|
+
}
|
|
37
|
+
function pageSummary(page: { offset: number; end: number; total: number }): string {
|
|
38
|
+
if (!page.total) return "Empty note";
|
|
39
|
+
if (!page.offset && page.end === page.total) return `${n(page.total)} chars · complete`;
|
|
40
|
+
return `Chars ${n(page.offset)}–${n(page.end)} of ${n(page.total)}${page.end < page.total ? `\nNext offset ${n(page.end)}` : " · final page"}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function historySections(raw: string, display: PosthorseDisplay | undefined): Array<{ body: string; header: string }> | undefined {
|
|
44
|
+
const spans = display?.kind === "history-search" ? display.entries : display?.kind === "history-read" ? [{ headerLength: display.headerLength, length: raw.length }] : undefined;
|
|
45
|
+
if (!Array.isArray(spans) || !spans.length) return undefined;
|
|
46
|
+
let start = 0;
|
|
47
|
+
const sections: Array<{ body: string; header: string }> = [];
|
|
48
|
+
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;
|
|
50
|
+
sections.push({ header: raw.slice(start, start + span.headerLength).trimEnd(), body: raw.slice(start + span.headerLength, start + span.length) });
|
|
51
|
+
start += span.length + 1;
|
|
52
|
+
}
|
|
53
|
+
return start === raw.length + 1 ? sections : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function toolCards(name: ToolName): Pick<ToolDefinition, "renderCall" | "renderResult"> {
|
|
57
|
+
return {
|
|
58
|
+
renderCall(rawArgs, theme, context) {
|
|
59
|
+
const args = argsOf(rawArgs);
|
|
60
|
+
const action = name === "notes" || name === "history" ? ` · ${argument(args.op, "op")}` : name === "new_context" ? " · request" : "";
|
|
61
|
+
const status = context.isError ? " · error" : context.isPartial ? context.executionStarted ? " · running" : " · preparing" : "";
|
|
62
|
+
const target = args.op === "search" ? ` · “${argument(args.query, "query")}”` : name === "notes" && args.op !== "list" ? ` · ${argument(args.path, "path")}` : name === "history" && args.op === "read" ? ` · ${argument(args.id, "id")}` : "";
|
|
63
|
+
const label = `${context.isPartial ? "…" : context.expanded ? "▾" : "▸"} ${titles[name]}${action}${status}${target}`;
|
|
64
|
+
return {
|
|
65
|
+
invalidate() {},
|
|
66
|
+
render(width) {
|
|
67
|
+
const heading = theme.fg(context.isError ? "error" : "toolTitle", theme.bold(label));
|
|
68
|
+
if (!context.expanded) return [truncateToWidth(heading.replace(/\s+/g, " "), width)];
|
|
69
|
+
const details = [
|
|
70
|
+
name === "history" && args.op === "search" ? `Scope: ${args.all === true ? "all project sessions" : "current branch"}` : "",
|
|
71
|
+
args.offset !== undefined ? `Offset: ${typeof args.offset === "number" ? n(args.offset) : "[invalid offset]"}` : "",
|
|
72
|
+
args.limit !== undefined ? `Limit: ${typeof args.limit === "number" ? n(args.limit) : "[invalid limit]"}` : "",
|
|
73
|
+
].filter(Boolean).join(" · ");
|
|
74
|
+
return [...new Text(heading, 0, 0).render(width), ...textBlock(details, theme, "muted").render(width)];
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
79
|
+
const args = argsOf(context.args);
|
|
80
|
+
const raw = textOf(result.content);
|
|
81
|
+
const display = result.details as PosthorseDisplay | undefined;
|
|
82
|
+
const sections = !context.isError && !isPartial ? historySections(raw, display) : undefined;
|
|
83
|
+
let summary = "";
|
|
84
|
+
let body = raw;
|
|
85
|
+
let color: "toolOutput" | "warning" | "error" = context.isError ? "error" : "toolOutput";
|
|
86
|
+
if (!context.isError && !isPartial) {
|
|
87
|
+
switch (display?.kind) {
|
|
88
|
+
case "context": {
|
|
89
|
+
const usage = display.usage;
|
|
90
|
+
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`;
|
|
92
|
+
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)}%)`}`;
|
|
94
|
+
if (display.rollover === "unsupported") color = "warning";
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case "notes-list": summary = display.count ? `${n(display.count)} note${display.count === 1 ? "" : "s"}` : "No notes yet"; break;
|
|
98
|
+
case "notes-search": summary = display.count ? `${n(display.count)} matches returned` : "No matches"; break;
|
|
99
|
+
case "note-read": summary = pageSummary(display); break;
|
|
100
|
+
case "note-write": summary = args.content === "" ? "Cleared note" : "Saved note"; break;
|
|
101
|
+
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;
|
|
103
|
+
case "history-read": summary = pageSummary(display); break;
|
|
104
|
+
case "new-context": summary = "Requested for after the whole tool batch succeeds."; break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!body.trim()) body = context.isError ? "No error details returned." : isPartial ? "Running…" : name === "notes" && args.op === "read" ? "Empty note" : "No text returned.";
|
|
108
|
+
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
|
+
const images = Array.isArray(result.content) ? result.content.filter((part) => part.type === "image") : [];
|
|
110
|
+
const attachment = images.length ? `${images.length} image${images.length === 1 ? "" : "s"} attached${context.showImages ? "" : " (display hidden)"}` : "";
|
|
111
|
+
const fullText = sections ? new Text(sections.map(({ body, header }) => `${theme.fg("toolOutput", clean(body))}\n${theme.fg("dim", clean(header))}`).join("\n\n"), 0, 0) : textBlock(body, theme, color);
|
|
112
|
+
const summaryText = textBlock(summary, theme, color);
|
|
113
|
+
const submittedText = textBlock(submitted, theme);
|
|
114
|
+
const attachmentText = textBlock(attachment, theme, "muted");
|
|
115
|
+
// Old results have no spans. Only the preview sheds the known history prefix.
|
|
116
|
+
const preview = sections ? sections.map(({ body }) => body).join("\n") : name === "history" && !context.isError ? body.replace(/^[^\n]*?\[window [^\]\n]+\] \[[^\]\n]+\](?: \[chars \d+-\d+ of \d+\])? /gm, "") : body;
|
|
117
|
+
const previewText = textBlock(preview, theme, color);
|
|
118
|
+
const searchPreviews = sections && display?.kind === "history-search" ? sections.slice(0, 3).map(({ body }) => theme.fg("toolOutput", clean(body).replace(/\s+/g, " "))) : undefined;
|
|
119
|
+
const summaryOnly = display?.kind === "context" || display?.kind === "new-context" || display?.kind === "note-write" || display?.kind === "note-append" || ((display?.kind === "notes-list" || display?.kind === "notes-search") && display.count === 0) || (display?.kind === "note-read" && display.total === 0) || (display?.kind === "history-search" && Array.isArray(display.entries) && display.entries.length === 0);
|
|
120
|
+
return {
|
|
121
|
+
invalidate() {},
|
|
122
|
+
render(width) {
|
|
123
|
+
if (expanded) {
|
|
124
|
+
return [...fullText.render(width), ...attachmentText.render(width), ...(submitted ? ["", ...submittedText.render(width)] : [])];
|
|
125
|
+
}
|
|
126
|
+
const summaryRows = summaryText.render(width).slice(0, 5);
|
|
127
|
+
const previewRows = summaryOnly && !context.isError && !isPartial ? [] : searchPreviews
|
|
128
|
+
? searchPreviews.map((line) => truncateToWidth(line, width))
|
|
129
|
+
: previewText.render(width);
|
|
130
|
+
const shown = previewRows.slice(0, Math.max(0, Math.min(3, 5 - summaryRows.length - (attachment ? 1 : 0))));
|
|
131
|
+
const hidden = summaryOnly || previewRows.length > shown.length || Boolean(sections || submitted);
|
|
132
|
+
return [...summaryRows, ...shown, ...(attachment ? [truncateToWidth(theme.fg("muted", attachment), width)] : []), ...(hidden ? [hint(theme, width)] : [])];
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function registerPosthorseMessages(pi: ExtensionAPI): void {
|
|
140
|
+
const states = new WeakMap<object, { global: boolean; expanded: boolean }>();
|
|
141
|
+
const renderer: MessageRenderer = (message, options, theme) => {
|
|
142
|
+
let state = states.get(message);
|
|
143
|
+
if (!state || state.global !== options.expanded) {
|
|
144
|
+
state = { global: options.expanded, expanded: options.expanded };
|
|
145
|
+
states.set(message, state);
|
|
146
|
+
}
|
|
147
|
+
const view = state;
|
|
148
|
+
const window = message.customType === "context-window";
|
|
149
|
+
const raw = textOf(message.content);
|
|
150
|
+
const handoffLabel = "\n\nHandoff from the previous window:\n";
|
|
151
|
+
const handoffAt = raw.indexOf(handoffLabel);
|
|
152
|
+
const preview = window ? handoffAt < 0 ? "" : raw.slice(handoffAt + handoffLabel.length) : raw;
|
|
153
|
+
const fullText = textBlock(raw, theme);
|
|
154
|
+
const previewText = textBlock(preview, theme);
|
|
155
|
+
const historyText = textBlock("Earlier conversation remains in history.", theme, "muted");
|
|
156
|
+
const content: Component = {
|
|
157
|
+
invalidate() {},
|
|
158
|
+
render(width) {
|
|
159
|
+
const padding = Math.min(options.outputPad, Math.max(0, Math.floor((width - 1) / 2)));
|
|
160
|
+
const inner = Math.max(1, width - padding * 2);
|
|
161
|
+
const title = `${view.expanded ? "▾" : "▸"} ${window ? "Context window started" : "Checkpoint reminder"}`;
|
|
162
|
+
const heading = truncateToWidth(theme.fg(window ? "customMessageLabel" : "warning", theme.bold(title)), inner);
|
|
163
|
+
const body = view.expanded ? fullText.render(inner) : [
|
|
164
|
+
...(window ? historyText.render(inner).slice(0, 2) : []),
|
|
165
|
+
...previewText.render(inner).slice(0, window ? 2 : 3),
|
|
166
|
+
hint(theme, inner),
|
|
167
|
+
];
|
|
168
|
+
const box = new Box(padding, 1, (line) => theme.bg("customMessageBg", line));
|
|
169
|
+
box.addChild({ render: () => [heading, ...body], invalidate() {} });
|
|
170
|
+
return box.render(width);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return new MouseRegion(content, (event) => {
|
|
174
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
175
|
+
view.expanded = !view.expanded;
|
|
176
|
+
return { handled: true };
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
for (const type of ["context-window", "posthorse-reminder", "headroom-reminder"]) pi.registerMessageRenderer(type, renderer);
|
|
180
|
+
}
|