pi-posthorse 0.4.3 → 0.4.5
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 +9 -3
- package/index.ts +45 -22
- package/package.json +8 -3
- package/ui.ts +180 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Pi owns the persisted boundary. Posthorse owns the policy: stable window guidanc
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
13
13
|
- Node `>=22.19.0`.
|
|
14
|
-
- The `fitchmultz/pi` fork. The
|
|
14
|
+
- The `fitchmultz/pi` fork. The CI baseline is `f9b06177e565f70cd243a785d088d1c491830dbd` (Pi `0.85.0`). Posthorse needs the fork's native `context_window` entries, its `session_before_auto_compact` hook, and `ctx.getCompactionSettings()`.
|
|
15
15
|
- Official, unpatched Pi is unsupported. Posthorse reports a clear extension error at session start and cannot operate; Pi itself keeps running.
|
|
16
16
|
|
|
17
17
|
## Install
|
|
@@ -40,7 +40,7 @@ pi -e git:github.com/fitchmultz/pi-posthorse # try it for one run w
|
|
|
40
40
|
|
|
41
41
|
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
42
|
|
|
43
|
-
|
|
43
|
+
Restart Pi after installing or updating Posthorse to load the new extension code.
|
|
44
44
|
|
|
45
45
|
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
46
|
|
|
@@ -54,6 +54,8 @@ Keep exactly one copy loaded. `pi list` shows every package source; if an older
|
|
|
54
54
|
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
55
|
7. **`notes` and `history`.** Notes live with the repository root, shared across linked worktrees. History searches normalized transcript text and returns stored images for a requested entry.
|
|
56
56
|
|
|
57
|
+
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
|
+
|
|
57
59
|
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.
|
|
58
60
|
|
|
59
61
|
## Settings
|
|
@@ -73,6 +75,10 @@ Only one automatic compaction or rollover policy extension should be enabled at
|
|
|
73
75
|
- `notes({ op, ... })`: `list`, `read` (paged; `offset` continues), `write` (empty content clears), `append` (one atomic newline-terminated record), `search` (excerpts centered on the match)
|
|
74
76
|
- `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
|
|
75
77
|
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
Committed context-window messages and checkpoint reminders are compact, expandable cards too. The `new_context` tool card describes a request; only the committed context-window message says a fresh window has started.
|
|
81
|
+
|
|
76
82
|
Read pages, including returned images, shrink to the context that is actually left. Before usage is known, they reserve prompt/tool overhead and leave half the rest free. Unsafe pages are refused with the offset preserved; call `new_context` and retry.
|
|
77
83
|
|
|
78
84
|
`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.
|
|
@@ -103,4 +109,4 @@ npm run check
|
|
|
103
109
|
PI_FORK=../pi scripts/integration.sh # loads the real extension into the fork's test harness (fork built)
|
|
104
110
|
```
|
|
105
111
|
|
|
106
|
-
`npm run check` type-checks
|
|
112
|
+
`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
|
@@ -20,6 +20,7 @@ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "no
|
|
|
20
20
|
import { createInterface } from "node:readline";
|
|
21
21
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
22
|
import { Type } from "typebox";
|
|
23
|
+
import { registerPosthorseMessages, toolCards, type PosthorseDisplay } from "./ui.ts";
|
|
23
24
|
|
|
24
25
|
const REMINDER_BUFFER_TOKENS = 32_000;
|
|
25
26
|
/** Absolute ceiling for handoffs and read pages; pages shrink to the live remaining budget. */
|
|
@@ -86,7 +87,7 @@ type EntryLike = {
|
|
|
86
87
|
};
|
|
87
88
|
|
|
88
89
|
type WindowedEntry = { entry: EntryLike; windowId: string; text: string; images: ImageLike[] };
|
|
89
|
-
type HistoryHit = { id: string; text: string; priority: 0 | 1 };
|
|
90
|
+
type HistoryHit = { id: string; text: string; headerLength: number; priority: 0 | 1 };
|
|
90
91
|
type RecoveryRecord = {
|
|
91
92
|
id: string;
|
|
92
93
|
timestamp: string;
|
|
@@ -159,8 +160,8 @@ function textOf(message: MessageLike): string {
|
|
|
159
160
|
.join("\n");
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
function textResult(text: string, images: ImageLike[] = []) {
|
|
163
|
-
return { content: [{ type: "text" as const, text }, ...images], details:
|
|
163
|
+
function textResult(text: string, images: ImageLike[] = [], display?: PosthorseDisplay) {
|
|
164
|
+
return { content: [{ type: "text" as const, text }, ...images], details: display };
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
/**
|
|
@@ -263,10 +264,12 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
|
|
|
263
264
|
matchIndex = text.toLowerCase().indexOf(query);
|
|
264
265
|
}
|
|
265
266
|
}
|
|
267
|
+
const header = `${source ? `${source} ` : ""}${entry.timestamp ?? ""} [window ${item.windowId}] [${entry.id}] `;
|
|
266
268
|
return {
|
|
267
269
|
id: entry.id!,
|
|
268
270
|
priority,
|
|
269
|
-
text: `${
|
|
271
|
+
text: `${header}${excerptAround(text, matchIndex, 100, 400)}`,
|
|
272
|
+
headerLength: header.length,
|
|
270
273
|
};
|
|
271
274
|
}
|
|
272
275
|
|
|
@@ -678,6 +681,7 @@ Automatic handoffs are emergency recovery records, not proof of current state. R
|
|
|
678
681
|
}
|
|
679
682
|
|
|
680
683
|
export default function (pi: ExtensionAPI) {
|
|
684
|
+
registerPosthorseMessages(pi);
|
|
681
685
|
const activeToolTokens = () => {
|
|
682
686
|
const active = new Set(pi.getActiveTools());
|
|
683
687
|
return pi
|
|
@@ -719,6 +723,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
719
723
|
if (!usage || usage.tokens == null || usage.contextWindow <= 0) return;
|
|
720
724
|
const budget = budgetFor(native, usage.contextWindow);
|
|
721
725
|
if (!budget || !budget.enabled || !budget.supported) return;
|
|
726
|
+
if (usage.tokens >= budget.rolloverAt) return;
|
|
727
|
+
const reminderBuffer = Math.min(REMINDER_BUFFER_TOKENS, Math.floor(budget.usable * 0.1));
|
|
728
|
+
const remindAt = budget.rolloverAt - reminderBuffer;
|
|
729
|
+
if (usage.tokens < remindAt) return;
|
|
722
730
|
|
|
723
731
|
const branch = ctx.sessionManager.getBranch() as EntryLike[];
|
|
724
732
|
const fingerprint: ReminderFingerprint = {
|
|
@@ -726,10 +734,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
726
734
|
contextWindow: budget.contextWindow,
|
|
727
735
|
reserveTokens: budget.reserveTokens,
|
|
728
736
|
};
|
|
729
|
-
if (
|
|
730
|
-
const reminderBuffer = Math.min(REMINDER_BUFFER_TOKENS, Math.floor(budget.usable * 0.1));
|
|
731
|
-
const remindAt = budget.rolloverAt - reminderBuffer;
|
|
732
|
-
if (usage.tokens < remindAt || hasReminder(branch, fingerprint)) return;
|
|
737
|
+
if (hasReminder(branch, fingerprint)) return;
|
|
733
738
|
pi.sendMessage(
|
|
734
739
|
{
|
|
735
740
|
customType: REMINDER_TYPE,
|
|
@@ -749,6 +754,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
749
754
|
const windowId = marker?.role === "custom" ? (marker.details as { windowId?: unknown } | undefined)?.windowId : "initial";
|
|
750
755
|
if (typeof windowId !== "string") return;
|
|
751
756
|
const native = nativeContext(ctx);
|
|
757
|
+
if (!event.messages.some((message) => message.role === "custom" && isReminderType(message.customType))) return;
|
|
752
758
|
const budget = budgetFor(native);
|
|
753
759
|
const branch = ctx.sessionManager.getBranch() as EntryLike[];
|
|
754
760
|
const fingerprint: ReminderFingerprint = {
|
|
@@ -779,6 +785,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
779
785
|
pi.registerTool({
|
|
780
786
|
name: "new_context",
|
|
781
787
|
label: "New Context",
|
|
788
|
+
...toolCards("new_context"),
|
|
782
789
|
description:
|
|
783
790
|
"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.",
|
|
784
791
|
promptSnippet: "start a fresh context window with an optional atomic handoff",
|
|
@@ -805,6 +812,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
805
812
|
return {
|
|
806
813
|
...textResult(
|
|
807
814
|
"Requested a fresh Pi context after this complete tool batch succeeds. Earlier conversation stays in session history.",
|
|
815
|
+
[],
|
|
816
|
+
{ kind: "new-context" },
|
|
808
817
|
),
|
|
809
818
|
newContext: { handoff: trimmed },
|
|
810
819
|
};
|
|
@@ -814,6 +823,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
814
823
|
pi.registerTool({
|
|
815
824
|
name: "get_context_remaining",
|
|
816
825
|
label: "Context Remaining",
|
|
826
|
+
...toolCards("get_context_remaining"),
|
|
817
827
|
description:
|
|
818
828
|
"Best available native estimate of the context budget: tokens until Pi's automatic rollover line and until the model's hard limit.",
|
|
819
829
|
promptSnippet: "check the remaining context budget only when needed",
|
|
@@ -821,14 +831,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
821
831
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
822
832
|
const native = nativeContext(ctx);
|
|
823
833
|
const usage = native.getContextUsage();
|
|
824
|
-
if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.");
|
|
834
|
+
if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.", [], { kind: "context" });
|
|
825
835
|
const n = (value: number) => value.toLocaleString("en-US");
|
|
826
836
|
const budget = budgetFor(native, usage.contextWindow);
|
|
837
|
+
const display: PosthorseDisplay = {
|
|
838
|
+
kind: "context", usage,
|
|
839
|
+
rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
|
|
840
|
+
rolloverAt: budget?.rolloverAt,
|
|
841
|
+
};
|
|
827
842
|
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.`;
|
|
828
|
-
if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${hard}
|
|
829
|
-
if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${hard}
|
|
843
|
+
if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${hard}`, [], display);
|
|
844
|
+
if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${hard}`, [], display);
|
|
830
845
|
return textResult(
|
|
831
846
|
`≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${hard}`,
|
|
847
|
+
[], display,
|
|
832
848
|
);
|
|
833
849
|
},
|
|
834
850
|
});
|
|
@@ -836,6 +852,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
836
852
|
pi.registerTool({
|
|
837
853
|
name: "notes",
|
|
838
854
|
label: "Notes",
|
|
855
|
+
...toolCards("notes"),
|
|
839
856
|
description:
|
|
840
857
|
"Persistent notes in .pi/notes/ that survive context resets. Ops: list, read (paged; pass offset to continue), write (create/replace; empty content clears), append, search (case-insensitive substring over note lines). Inside a Git repository, including nested directories and linked worktrees, notes belong to the main checkout.",
|
|
841
858
|
promptSnippet: "save and recall durable state that survives context resets",
|
|
@@ -872,10 +889,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
872
889
|
|
|
873
890
|
switch (params.op) {
|
|
874
891
|
case "list": {
|
|
875
|
-
if (!existsSync(dir)) return textResult("(no notes yet)");
|
|
892
|
+
if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-list", count: 0 });
|
|
876
893
|
const files: string[] = [];
|
|
877
894
|
walk(dir, files);
|
|
878
|
-
return textResult(files.length ? files.map((file) => file.slice(dir.length + 1)).join("\n") : "(no notes yet)");
|
|
895
|
+
return textResult(files.length ? files.map((file) => file.slice(dir.length + 1)).join("\n") : "(no notes yet)", [], { kind: "notes-list", count: files.length });
|
|
879
896
|
}
|
|
880
897
|
case "read": {
|
|
881
898
|
const relative = requireValue(params.path, "path", params.op);
|
|
@@ -889,7 +906,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
889
906
|
const end = Math.min(text.length, offset + requirePage(nativeContext(ctx), offset, 0, activeToolTokens()));
|
|
890
907
|
const more =
|
|
891
908
|
end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
|
|
892
|
-
return textResult(`${text.slice(offset, end)}${more}
|
|
909
|
+
return textResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
|
|
893
910
|
}
|
|
894
911
|
case "write": {
|
|
895
912
|
const relative = requireValue(params.path, "path", params.op);
|
|
@@ -897,7 +914,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
897
914
|
const path = safeJoin(relative);
|
|
898
915
|
mkdirSync(dirname(path), { recursive: true });
|
|
899
916
|
writeFileSync(path, params.content);
|
|
900
|
-
return textResult(`Wrote .pi/notes/${relative}
|
|
917
|
+
return textResult(`Wrote .pi/notes/${relative}`, [], { kind: "note-write" });
|
|
901
918
|
}
|
|
902
919
|
case "append": {
|
|
903
920
|
const relative = requireValue(params.path, "path", params.op);
|
|
@@ -910,11 +927,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
910
927
|
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
911
928
|
const separator = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
912
929
|
appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
|
|
913
|
-
return textResult(`Appended to .pi/notes/${relative}
|
|
930
|
+
return textResult(`Appended to .pi/notes/${relative}`, [], { kind: "note-append" });
|
|
914
931
|
}
|
|
915
932
|
case "search": {
|
|
916
933
|
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
917
|
-
if (!existsSync(dir)) return textResult("(no notes yet)");
|
|
934
|
+
if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-search", count: 0 });
|
|
918
935
|
const files: string[] = [];
|
|
919
936
|
walk(dir, files);
|
|
920
937
|
const hits: string[] = [];
|
|
@@ -928,7 +945,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
928
945
|
}
|
|
929
946
|
}
|
|
930
947
|
}
|
|
931
|
-
return textResult(hits.length ? hits.join("\n") : `No notes match "${params.query}"
|
|
948
|
+
return textResult(hits.length ? hits.join("\n") : `No notes match "${params.query}".`, [], { kind: "notes-search", count: hits.length });
|
|
932
949
|
}
|
|
933
950
|
}
|
|
934
951
|
},
|
|
@@ -937,6 +954,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
937
954
|
pi.registerTool({
|
|
938
955
|
name: "history",
|
|
939
956
|
label: "History",
|
|
957
|
+
...toolCards("history"),
|
|
940
958
|
description:
|
|
941
959
|
"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.",
|
|
942
960
|
promptSnippet: "recover earlier conversation that left the active context window",
|
|
@@ -955,13 +973,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
955
973
|
if (params.op === "search") {
|
|
956
974
|
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
957
975
|
const limit = params.limit ?? 10;
|
|
958
|
-
const hits:
|
|
976
|
+
const hits: HistoryHit[][] = [[], []];
|
|
959
977
|
// Forks copy ancestor entries under the same ids into new session files; report each id once.
|
|
960
978
|
const seen = new Set<string>();
|
|
961
979
|
const addHit = (hit: HistoryHit | undefined) => {
|
|
962
980
|
if (!hit || seen.has(hit.id) || hits[hit.priority].length >= limit) return;
|
|
963
981
|
seen.add(hit.id);
|
|
964
|
-
hits[hit.priority].push(hit
|
|
982
|
+
hits[hit.priority].push(hit);
|
|
965
983
|
};
|
|
966
984
|
|
|
967
985
|
if (params.all) {
|
|
@@ -987,7 +1005,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
987
1005
|
}
|
|
988
1006
|
}
|
|
989
1007
|
const results = hits.flat().slice(0, limit);
|
|
990
|
-
return textResult(results.length ? results.join("\n") : `No history matches "${params.query}"
|
|
1008
|
+
return textResult(results.length ? results.map((hit) => hit.text).join("\n") : `No history matches "${params.query}".`, [], {
|
|
1009
|
+
kind: "history-search",
|
|
1010
|
+
entries: results.map((hit) => ({ headerLength: hit.headerLength, length: hit.text.length })),
|
|
1011
|
+
});
|
|
991
1012
|
}
|
|
992
1013
|
|
|
993
1014
|
const id = requireValue(params.id, "id", params.op);
|
|
@@ -1001,10 +1022,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1001
1022
|
offset + requirePage(nativeContext(ctx), offset, offset === 0 ? item.images.length : 0, activeToolTokens()),
|
|
1002
1023
|
);
|
|
1003
1024
|
const more = end < item.text.length ? `\nMore remains; call history read with id "${id}" and offset ${end}.` : "";
|
|
1025
|
+
const header = `${source ? `${source} ` : ""}${item.entry.timestamp ?? ""} [window ${item.windowId}] [${id}] [chars ${offset}-${end} of ${item.text.length}] `;
|
|
1004
1026
|
// Stored images ride along with the first page only.
|
|
1005
1027
|
return textResult(
|
|
1006
|
-
`${
|
|
1028
|
+
`${header}${item.text.slice(offset, end)}${more}`,
|
|
1007
1029
|
offset === 0 ? item.images : [],
|
|
1030
|
+
{ kind: "history-read", headerLength: header.length, offset, end, total: item.text.length },
|
|
1008
1031
|
);
|
|
1009
1032
|
};
|
|
1010
1033
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-posthorse",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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
|
+
}
|