pi-posthorse 0.8.0 → 0.8.2
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 +20 -6
- package/index.ts +42 -1555
- package/package.json +33 -7
- package/src/budget.ts +168 -0
- package/src/context-tools.ts +113 -0
- package/src/contracts.ts +124 -0
- package/src/files.ts +182 -0
- package/src/history-entries.ts +251 -0
- package/src/history-pages.ts +173 -0
- package/src/history-search.ts +308 -0
- package/src/history.ts +146 -0
- package/src/message-text.ts +139 -0
- package/src/notes.ts +314 -0
- package/src/paging.ts +156 -0
- package/src/recovery-results.ts +111 -0
- package/src/recovery.ts +377 -0
- package/src/reminders.ts +116 -0
- package/src/rollover.ts +330 -0
- package/src/session-files.ts +264 -0
- package/src/ui/cards.ts +355 -0
- package/src/ui/display.ts +312 -0
- package/src/ui/text.ts +53 -0
- package/ui.ts +44 -163
package/src/ui/cards.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
displayOf,
|
|
5
|
+
displaySummary,
|
|
6
|
+
historySections,
|
|
7
|
+
pageSummary,
|
|
8
|
+
summaryOnly,
|
|
9
|
+
type HistorySection,
|
|
10
|
+
type PosthorseDisplay,
|
|
11
|
+
} from "./display.ts";
|
|
12
|
+
import { argsOf, argument, clean, hint, textBlock, textOf, type OutputColor } from "./text.ts";
|
|
13
|
+
|
|
14
|
+
export type ToolName = "notes" | "history" | "get_context_remaining" | "new_context";
|
|
15
|
+
const titles: Readonly<Record<ToolName, string>> = {
|
|
16
|
+
notes: "Notes",
|
|
17
|
+
history: "History",
|
|
18
|
+
get_context_remaining: "Context budget",
|
|
19
|
+
new_context: "New context",
|
|
20
|
+
};
|
|
21
|
+
const n = (value: number): string => value.toLocaleString("en-US");
|
|
22
|
+
|
|
23
|
+
interface CallState {
|
|
24
|
+
readonly isError: boolean;
|
|
25
|
+
readonly isPartial: boolean;
|
|
26
|
+
readonly executionStarted: boolean;
|
|
27
|
+
readonly expanded: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function callTarget(name: ToolName, args: Readonly<Record<string, unknown>>): string {
|
|
31
|
+
if (args.op === "search") {
|
|
32
|
+
return ` · “${argument(args.query, "query")}”`;
|
|
33
|
+
}
|
|
34
|
+
if (name === "notes" && (args.op !== "list" || args.path !== undefined)) {
|
|
35
|
+
return ` · ${argument(args.path, "path")}`;
|
|
36
|
+
}
|
|
37
|
+
return name === "history" && args.op === "read" ? ` · ${argument(args.id, "id")}` : "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function callStatus(state: CallState): string {
|
|
41
|
+
if (state.isError) {
|
|
42
|
+
return " · error";
|
|
43
|
+
}
|
|
44
|
+
if (!state.isPartial) {
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
return state.executionStarted ? " · running" : " · preparing";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function callDetails(name: ToolName, args: Readonly<Record<string, unknown>>): string {
|
|
51
|
+
const scope =
|
|
52
|
+
name === "history" && args.op === "search"
|
|
53
|
+
? `Scope: ${args.all === true ? "all project sessions" : "current branch"}`
|
|
54
|
+
: "";
|
|
55
|
+
const offsets = [
|
|
56
|
+
["offset", "Offset"],
|
|
57
|
+
["imageOffset", "Image offset"],
|
|
58
|
+
["limit", "Limit"],
|
|
59
|
+
].map(([key, label]) => {
|
|
60
|
+
const value = args[key];
|
|
61
|
+
return value === undefined
|
|
62
|
+
? ""
|
|
63
|
+
: `${label}: ${typeof value === "number" ? n(value) : `[invalid ${key}]`}`;
|
|
64
|
+
});
|
|
65
|
+
return [scope, ...offsets].filter((part) => part.length > 0).join(" · ");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function callComponent(
|
|
69
|
+
name: ToolName,
|
|
70
|
+
args: Readonly<Record<string, unknown>>,
|
|
71
|
+
theme: Theme,
|
|
72
|
+
state: CallState,
|
|
73
|
+
): Component {
|
|
74
|
+
let action = "";
|
|
75
|
+
if (name === "notes" || name === "history") {
|
|
76
|
+
action = ` · ${argument(args.op, "op")}`;
|
|
77
|
+
} else if (name === "new_context") {
|
|
78
|
+
action = " · request";
|
|
79
|
+
}
|
|
80
|
+
let marker = "▸";
|
|
81
|
+
if (state.isPartial) {
|
|
82
|
+
marker = "…";
|
|
83
|
+
} else if (state.expanded) {
|
|
84
|
+
marker = "▾";
|
|
85
|
+
}
|
|
86
|
+
const label = `${marker} ${titles[name]}${action}${callStatus(state)}${callTarget(name, args)}`;
|
|
87
|
+
return {
|
|
88
|
+
invalidate() {
|
|
89
|
+
// Stateless layout is recomputed with the current width on every render.
|
|
90
|
+
},
|
|
91
|
+
render(width) {
|
|
92
|
+
const heading = theme.fg(state.isError ? "error" : "toolTitle", theme.bold(label));
|
|
93
|
+
if (!state.expanded) {
|
|
94
|
+
return [truncateToWidth(heading.replace(/\s+/g, " "), width)];
|
|
95
|
+
}
|
|
96
|
+
return [
|
|
97
|
+
...new Text(heading, 0, 0).render(width),
|
|
98
|
+
...textBlock(callDetails(name, args), theme, "muted").render(width),
|
|
99
|
+
];
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface ResultFacts {
|
|
105
|
+
readonly name: ToolName;
|
|
106
|
+
readonly raw: string;
|
|
107
|
+
readonly args: Readonly<Record<string, unknown>>;
|
|
108
|
+
readonly display: PosthorseDisplay | undefined;
|
|
109
|
+
readonly isError: boolean;
|
|
110
|
+
readonly isPartial: boolean;
|
|
111
|
+
readonly expanded: boolean;
|
|
112
|
+
readonly imageCount: number;
|
|
113
|
+
readonly showImages: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function submittedText(facts: ResultFacts): string {
|
|
117
|
+
const { name, args } = facts;
|
|
118
|
+
if (name === "new_context" && typeof args.handoff === "string") {
|
|
119
|
+
return `Handoff supplied:\n${args.handoff}`;
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
name === "notes" &&
|
|
123
|
+
(args.op === "write" || args.op === "append") &&
|
|
124
|
+
typeof args.content === "string"
|
|
125
|
+
) {
|
|
126
|
+
return `Submitted content:\n${args.content.length > 0 ? args.content : "(empty)"}`;
|
|
127
|
+
}
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resultSummary(
|
|
132
|
+
facts: ResultFacts,
|
|
133
|
+
sections: readonly HistorySection[] | undefined,
|
|
134
|
+
): string {
|
|
135
|
+
if (facts.isError || facts.isPartial) {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
const { display } = facts;
|
|
139
|
+
const summary = displaySummary(display, facts.args, sections !== undefined);
|
|
140
|
+
if (
|
|
141
|
+
(display?.kind === "notes-list" || display?.kind === "notes-search") &&
|
|
142
|
+
display.page !== undefined &&
|
|
143
|
+
(display.page.offset > 0 || display.page.end < display.page.total)
|
|
144
|
+
) {
|
|
145
|
+
return `${summary}\n${pageSummary(display.page, display.more)}`;
|
|
146
|
+
}
|
|
147
|
+
return summary;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resultColor(facts: ResultFacts): OutputColor {
|
|
151
|
+
if (facts.isError) {
|
|
152
|
+
return "error";
|
|
153
|
+
}
|
|
154
|
+
const { display } = facts;
|
|
155
|
+
if (facts.isPartial || display?.kind !== "context" || display.rollover !== "unsupported") {
|
|
156
|
+
return "toolOutput";
|
|
157
|
+
}
|
|
158
|
+
const tokens = display.usage?.tokens;
|
|
159
|
+
return tokens !== undefined && tokens !== null ? "warning" : "toolOutput";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resultBody(facts: ResultFacts): string {
|
|
163
|
+
if (facts.raw.trim().length > 0) {
|
|
164
|
+
return facts.raw;
|
|
165
|
+
}
|
|
166
|
+
if (facts.isError) {
|
|
167
|
+
return "No error details returned.";
|
|
168
|
+
}
|
|
169
|
+
if (facts.isPartial) {
|
|
170
|
+
return "Running…";
|
|
171
|
+
}
|
|
172
|
+
return facts.name === "notes" && facts.args.op === "read" ? "Empty note" : "No text returned.";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function noteHeaderLength(facts: ResultFacts): number {
|
|
176
|
+
const { display } = facts;
|
|
177
|
+
return !facts.isError && (display?.kind === "note-read" || display?.kind === "notes-list")
|
|
178
|
+
? (display.headerLength ?? 0)
|
|
179
|
+
: 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function previewBody(facts: ResultFacts, sections: readonly HistorySection[] | undefined): string {
|
|
183
|
+
if (sections !== undefined) {
|
|
184
|
+
return sections.map(({ body }) => body).join("\n");
|
|
185
|
+
}
|
|
186
|
+
const body = resultBody(facts);
|
|
187
|
+
const headerLength = noteHeaderLength(facts);
|
|
188
|
+
if (headerLength > 0) {
|
|
189
|
+
return body.slice(headerLength);
|
|
190
|
+
}
|
|
191
|
+
// Old results have no spans. Only the preview sheds the known history prefix.
|
|
192
|
+
return facts.name === "history" && !facts.isError
|
|
193
|
+
? body.replace(
|
|
194
|
+
/^[^\n]*?\[window [^\]\n]+\] \[[^\]\n]+\](?: \[chars \d+-\d+ of \d+\])? /gm,
|
|
195
|
+
"",
|
|
196
|
+
)
|
|
197
|
+
: body;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface ResultLayout {
|
|
201
|
+
readonly full: Component;
|
|
202
|
+
readonly summary: Component;
|
|
203
|
+
readonly preview: Component;
|
|
204
|
+
readonly submitted: Component;
|
|
205
|
+
readonly attachment: string;
|
|
206
|
+
readonly attachmentBlock: Component;
|
|
207
|
+
readonly footer: Component;
|
|
208
|
+
readonly searchPreviews: readonly string[] | undefined;
|
|
209
|
+
readonly hasDetails: boolean;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resultLayout(
|
|
213
|
+
facts: ResultFacts,
|
|
214
|
+
sections: readonly HistorySection[] | undefined,
|
|
215
|
+
theme: Theme,
|
|
216
|
+
): ResultLayout {
|
|
217
|
+
const color = resultColor(facts);
|
|
218
|
+
const submission = submittedText(facts);
|
|
219
|
+
const attachment = attachmentSummary(facts);
|
|
220
|
+
const { display } = facts;
|
|
221
|
+
const footerLength = display?.kind === "history-search" ? (display.footerLength ?? 0) : 0;
|
|
222
|
+
return {
|
|
223
|
+
full: fullBody(facts, sections, theme),
|
|
224
|
+
summary: textBlock(resultSummary(facts, sections), theme, color),
|
|
225
|
+
preview: textBlock(previewBody(facts, sections), theme, color),
|
|
226
|
+
submitted: textBlock(submission, theme),
|
|
227
|
+
attachment,
|
|
228
|
+
attachmentBlock: textBlock(attachment, theme, "muted"),
|
|
229
|
+
footer: textBlock(
|
|
230
|
+
sections !== undefined && footerLength > 0 ? facts.raw.slice(-footerLength) : "",
|
|
231
|
+
theme,
|
|
232
|
+
"muted",
|
|
233
|
+
),
|
|
234
|
+
searchPreviews: searchPreviews(facts, sections, theme),
|
|
235
|
+
hasDetails: sections !== undefined || submission.length > 0 || noteHeaderLength(facts) > 0,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function attachmentSummary(facts: ResultFacts): string {
|
|
240
|
+
if (facts.imageCount === 0) {
|
|
241
|
+
return "";
|
|
242
|
+
}
|
|
243
|
+
return `${facts.imageCount} image${facts.imageCount === 1 ? "" : "s"} attached${facts.showImages ? "" : " (display hidden)"}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function fullBody(
|
|
247
|
+
facts: ResultFacts,
|
|
248
|
+
sections: readonly HistorySection[] | undefined,
|
|
249
|
+
theme: Theme,
|
|
250
|
+
): Component {
|
|
251
|
+
if (sections === undefined) {
|
|
252
|
+
return textBlock(resultBody(facts), theme, resultColor(facts));
|
|
253
|
+
}
|
|
254
|
+
return new Text(
|
|
255
|
+
sections
|
|
256
|
+
.map(
|
|
257
|
+
({ body, header }) =>
|
|
258
|
+
`${theme.fg("toolOutput", clean(body))}\n${theme.fg("dim", clean(header))}`,
|
|
259
|
+
)
|
|
260
|
+
.join("\n\n"),
|
|
261
|
+
0,
|
|
262
|
+
0,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function searchPreviews(
|
|
267
|
+
facts: ResultFacts,
|
|
268
|
+
sections: readonly HistorySection[] | undefined,
|
|
269
|
+
theme: Theme,
|
|
270
|
+
): readonly string[] | undefined {
|
|
271
|
+
return sections !== undefined && facts.display?.kind === "history-search"
|
|
272
|
+
? sections
|
|
273
|
+
.slice(0, 3)
|
|
274
|
+
.map(({ body }) => theme.fg("toolOutput", clean(body).replace(/\s+/g, " ")))
|
|
275
|
+
: undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function previewRows(facts: ResultFacts, layout: ResultLayout, width: number): string[] {
|
|
279
|
+
if (summaryOnly(facts.display) && !facts.isError && !facts.isPartial) {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
return (
|
|
283
|
+
layout.searchPreviews?.map((line) => truncateToWidth(line, width)) ??
|
|
284
|
+
layout.preview.render(width)
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function collapsedRows(
|
|
289
|
+
facts: ResultFacts,
|
|
290
|
+
layout: ResultLayout,
|
|
291
|
+
theme: Theme,
|
|
292
|
+
width: number,
|
|
293
|
+
): string[] {
|
|
294
|
+
const summaryRows = layout.summary.render(width).slice(0, 5);
|
|
295
|
+
const previews = previewRows(facts, layout, width);
|
|
296
|
+
const shown = previews.slice(
|
|
297
|
+
0,
|
|
298
|
+
Math.max(0, Math.min(3, 5 - summaryRows.length - (layout.attachment.length > 0 ? 1 : 0))),
|
|
299
|
+
);
|
|
300
|
+
const hidden =
|
|
301
|
+
summaryOnly(facts.display) || previews.length > shown.length || layout.hasDetails;
|
|
302
|
+
const attachments =
|
|
303
|
+
layout.attachment.length > 0
|
|
304
|
+
? [truncateToWidth(theme.fg("muted", layout.attachment), width)]
|
|
305
|
+
: [];
|
|
306
|
+
return [...summaryRows, ...shown, ...attachments, ...(hidden ? [hint(theme, width)] : [])];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function resultComponent(facts: ResultFacts, theme: Theme): Component {
|
|
310
|
+
const sections =
|
|
311
|
+
!facts.isError && !facts.isPartial ? historySections(facts.raw, facts.display) : undefined;
|
|
312
|
+
const layout = resultLayout(facts, sections, theme);
|
|
313
|
+
return {
|
|
314
|
+
invalidate() {
|
|
315
|
+
// Pi creates this presentation for each result/theme update; it keeps no width cache.
|
|
316
|
+
},
|
|
317
|
+
render(width) {
|
|
318
|
+
if (facts.expanded) {
|
|
319
|
+
const submitted =
|
|
320
|
+
submittedText(facts).length > 0 ? ["", ...layout.submitted.render(width)] : [];
|
|
321
|
+
return [
|
|
322
|
+
...layout.full.render(width),
|
|
323
|
+
...layout.attachmentBlock.render(width),
|
|
324
|
+
...layout.footer.render(width),
|
|
325
|
+
...submitted,
|
|
326
|
+
];
|
|
327
|
+
}
|
|
328
|
+
return collapsedRows(facts, layout, theme, width);
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function toolCards(name: ToolName): Pick<ToolDefinition, "renderCall" | "renderResult"> {
|
|
334
|
+
return {
|
|
335
|
+
renderCall(rawArgs, theme, context) {
|
|
336
|
+
return callComponent(name, argsOf(rawArgs), theme, context);
|
|
337
|
+
},
|
|
338
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
339
|
+
return resultComponent(
|
|
340
|
+
{
|
|
341
|
+
name,
|
|
342
|
+
raw: textOf(result.content),
|
|
343
|
+
args: argsOf(context.args),
|
|
344
|
+
display: displayOf(result.details),
|
|
345
|
+
isError: context.isError,
|
|
346
|
+
isPartial,
|
|
347
|
+
expanded,
|
|
348
|
+
imageCount: result.content.filter((part) => part.type === "image").length,
|
|
349
|
+
showImages: context.showImages,
|
|
350
|
+
},
|
|
351
|
+
theme,
|
|
352
|
+
);
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { isRecord } from "./text.ts";
|
|
2
|
+
|
|
3
|
+
export interface PageRange {
|
|
4
|
+
readonly offset: number;
|
|
5
|
+
readonly end: number;
|
|
6
|
+
readonly total: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface HistorySpan {
|
|
10
|
+
readonly headerLength: number;
|
|
11
|
+
readonly length: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Display-only facts and offsets into content, never a second copy of a note or history page. */
|
|
15
|
+
export type PosthorseDisplay =
|
|
16
|
+
| {
|
|
17
|
+
readonly kind: "context";
|
|
18
|
+
readonly usage?: {
|
|
19
|
+
readonly tokens: number | null;
|
|
20
|
+
readonly contextWindow: number;
|
|
21
|
+
readonly percent: number | null;
|
|
22
|
+
};
|
|
23
|
+
readonly rollover?: "enabled" | "disabled" | "unsupported";
|
|
24
|
+
readonly rolloverAt?: number;
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
readonly kind: "notes-list" | "notes-search";
|
|
28
|
+
readonly count: number;
|
|
29
|
+
readonly headerLength?: number;
|
|
30
|
+
readonly page?: PageRange;
|
|
31
|
+
/** More matches follow; `page.total` counts only those found so far. */
|
|
32
|
+
readonly more?: boolean;
|
|
33
|
+
}
|
|
34
|
+
| ({ readonly kind: "note-read"; readonly headerLength?: number } & PageRange)
|
|
35
|
+
| { readonly kind: "note-write" | "note-append" | "new-context" }
|
|
36
|
+
| {
|
|
37
|
+
readonly kind: "history-search";
|
|
38
|
+
readonly entries: readonly HistorySpan[];
|
|
39
|
+
readonly footerLength?: number;
|
|
40
|
+
readonly more?: boolean;
|
|
41
|
+
readonly skipped?: number;
|
|
42
|
+
}
|
|
43
|
+
| ({
|
|
44
|
+
readonly kind: "history-read";
|
|
45
|
+
readonly headerLength: number;
|
|
46
|
+
readonly imageOffset?: number;
|
|
47
|
+
readonly imageEnd?: number;
|
|
48
|
+
readonly imageTotal?: number;
|
|
49
|
+
} & PageRange);
|
|
50
|
+
|
|
51
|
+
function optionalNumbers(
|
|
52
|
+
value: Readonly<Record<string, unknown>>,
|
|
53
|
+
keys: readonly string[],
|
|
54
|
+
): boolean {
|
|
55
|
+
return keys.every((key) => value[key] === undefined || typeof value[key] === "number");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPage(value: unknown): value is PageRange {
|
|
59
|
+
return (
|
|
60
|
+
isRecord(value) &&
|
|
61
|
+
typeof value.offset === "number" &&
|
|
62
|
+
typeof value.end === "number" &&
|
|
63
|
+
typeof value.total === "number"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isSpan(value: unknown): value is HistorySpan {
|
|
68
|
+
return (
|
|
69
|
+
isRecord(value) &&
|
|
70
|
+
typeof value.headerLength === "number" &&
|
|
71
|
+
typeof value.length === "number"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isContextDisplay(value: Readonly<Record<string, unknown>>): boolean {
|
|
76
|
+
const usage = value.usage;
|
|
77
|
+
const validUsage = usage === undefined || isUsage(usage);
|
|
78
|
+
return (
|
|
79
|
+
validUsage &&
|
|
80
|
+
optionalNumbers(value, ["rolloverAt"]) &&
|
|
81
|
+
(value.rollover === undefined ||
|
|
82
|
+
value.rollover === "enabled" ||
|
|
83
|
+
value.rollover === "disabled" ||
|
|
84
|
+
value.rollover === "unsupported")
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isUsage(value: unknown): boolean {
|
|
89
|
+
return (
|
|
90
|
+
isRecord(value) &&
|
|
91
|
+
(value.tokens === null || typeof value.tokens === "number") &&
|
|
92
|
+
typeof value.contextWindow === "number" &&
|
|
93
|
+
(value.percent === null || typeof value.percent === "number")
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isSearchDisplay(value: Readonly<Record<string, unknown>>): boolean {
|
|
98
|
+
return (
|
|
99
|
+
Array.isArray(value.entries) &&
|
|
100
|
+
value.entries.every(isSpan) &&
|
|
101
|
+
optionalNumbers(value, ["footerLength", "skipped"]) &&
|
|
102
|
+
(value.more === undefined || typeof value.more === "boolean")
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isNotesDisplay(value: Readonly<Record<string, unknown>>): boolean {
|
|
107
|
+
return (
|
|
108
|
+
typeof value.count === "number" &&
|
|
109
|
+
optionalNumbers(value, ["headerLength"]) &&
|
|
110
|
+
(value.page === undefined || isPage(value.page)) &&
|
|
111
|
+
(value.more === undefined || typeof value.more === "boolean")
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isDisplay(value: unknown): value is PosthorseDisplay {
|
|
116
|
+
if (!isRecord(value)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
switch (value.kind) {
|
|
120
|
+
case "context":
|
|
121
|
+
return isContextDisplay(value);
|
|
122
|
+
case "notes-list":
|
|
123
|
+
case "notes-search":
|
|
124
|
+
return isNotesDisplay(value);
|
|
125
|
+
case "note-read":
|
|
126
|
+
return isPage(value) && optionalNumbers(value, ["headerLength"]);
|
|
127
|
+
case "history-read":
|
|
128
|
+
return (
|
|
129
|
+
isPage(value) &&
|
|
130
|
+
typeof value.headerLength === "number" &&
|
|
131
|
+
optionalNumbers(value, ["imageOffset", "imageEnd", "imageTotal"])
|
|
132
|
+
);
|
|
133
|
+
case "history-search":
|
|
134
|
+
return isSearchDisplay(value);
|
|
135
|
+
case "note-write":
|
|
136
|
+
case "note-append":
|
|
137
|
+
case "new-context":
|
|
138
|
+
return true;
|
|
139
|
+
default:
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Saved sessions and streamed tool results can predate the display contract. */
|
|
145
|
+
export function displayOf(value: unknown): PosthorseDisplay | undefined {
|
|
146
|
+
return isDisplay(value) ? value : undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface HistorySection {
|
|
150
|
+
readonly body: string;
|
|
151
|
+
readonly header: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function spansOf(
|
|
155
|
+
raw: string,
|
|
156
|
+
display: PosthorseDisplay | undefined,
|
|
157
|
+
): readonly HistorySpan[] | undefined {
|
|
158
|
+
if (display?.kind === "history-search") {
|
|
159
|
+
return display.entries;
|
|
160
|
+
}
|
|
161
|
+
if (display?.kind === "history-read") {
|
|
162
|
+
return [{ headerLength: display.headerLength, length: raw.length }];
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function historySections(
|
|
168
|
+
raw: string,
|
|
169
|
+
display: PosthorseDisplay | undefined,
|
|
170
|
+
): readonly HistorySection[] | undefined {
|
|
171
|
+
const spans = spansOf(raw, display);
|
|
172
|
+
if (spans === undefined || spans.length === 0) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const footerLength = display?.kind === "history-search" ? (display.footerLength ?? 0) : 0;
|
|
176
|
+
if (!validFooter(footerLength, raw.length)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const bodyLength = raw.length - footerLength;
|
|
180
|
+
let start = 0;
|
|
181
|
+
const sections: HistorySection[] = [];
|
|
182
|
+
for (const span of spans) {
|
|
183
|
+
if (!validSpan(span, start, bodyLength)) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
sections.push({
|
|
187
|
+
header: raw.slice(start, start + span.headerLength).trimEnd(),
|
|
188
|
+
body: raw.slice(start + span.headerLength, start + span.length),
|
|
189
|
+
});
|
|
190
|
+
start += span.length + 1;
|
|
191
|
+
}
|
|
192
|
+
return start === bodyLength + 1 ? sections : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function validFooter(length: number, rawLength: number): boolean {
|
|
196
|
+
return Number.isInteger(length) && length >= 0 && length <= rawLength;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validSpan(span: HistorySpan, start: number, bodyLength: number): boolean {
|
|
200
|
+
return (
|
|
201
|
+
Number.isInteger(span.headerLength) &&
|
|
202
|
+
Number.isInteger(span.length) &&
|
|
203
|
+
span.headerLength >= 0 &&
|
|
204
|
+
span.headerLength <= span.length &&
|
|
205
|
+
start + span.length <= bodyLength
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const n = (value: number): string => value.toLocaleString("en-US");
|
|
210
|
+
|
|
211
|
+
export function pageSummary(page: PageRange, more = false): string {
|
|
212
|
+
if (page.total === 0) {
|
|
213
|
+
return "Empty note";
|
|
214
|
+
}
|
|
215
|
+
if (page.offset === 0 && page.end === page.total) {
|
|
216
|
+
return `${n(page.total)} chars · complete`;
|
|
217
|
+
}
|
|
218
|
+
return `Chars ${n(page.offset)}–${n(page.end)} of ${n(page.total)}${more ? "+" : ""}${page.end < page.total ? `\nNext offset ${n(page.end)}` : " · final page"}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function contextSummary(display: Extract<PosthorseDisplay, { readonly kind: "context" }>): string {
|
|
222
|
+
const usage = display.usage;
|
|
223
|
+
if (usage === undefined || usage.tokens === null) {
|
|
224
|
+
return "Context usage unknown";
|
|
225
|
+
}
|
|
226
|
+
const configured = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens to configured context limit`;
|
|
227
|
+
const rollover =
|
|
228
|
+
display.rollover === "enabled" && display.rolloverAt !== undefined
|
|
229
|
+
? `≈${n(Math.max(0, display.rolloverAt - usage.tokens))} tokens to rollover`
|
|
230
|
+
: `Automatic rollover ${display.rollover ?? "unknown"}`;
|
|
231
|
+
return `${rollover}\n${configured}\n≈${n(usage.tokens)} / ${n(usage.contextWindow)} used${usage.percent === null ? "" : ` (≈${Math.round(usage.percent)}%)`}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function historyReadSummary(
|
|
235
|
+
display: Extract<PosthorseDisplay, { readonly kind: "history-read" }>,
|
|
236
|
+
): string {
|
|
237
|
+
if (
|
|
238
|
+
display.imageTotal === undefined ||
|
|
239
|
+
display.imageTotal === 0 ||
|
|
240
|
+
display.imageEnd === undefined
|
|
241
|
+
) {
|
|
242
|
+
return pageSummary(display);
|
|
243
|
+
}
|
|
244
|
+
const summary = `${pageSummary(display).split("\n")[0]}\nImages ${n(display.imageOffset ?? 0)}–${n(display.imageEnd)} of ${n(display.imageTotal)}`;
|
|
245
|
+
return display.end < display.total || display.imageEnd < display.imageTotal
|
|
246
|
+
? `Next offset ${n(display.end)}\nimageOffset ${n(display.imageEnd)}\n${summary}`
|
|
247
|
+
: summary;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function historySearchSummary(
|
|
251
|
+
display: Extract<PosthorseDisplay, { readonly kind: "history-search" }>,
|
|
252
|
+
all: boolean,
|
|
253
|
+
validSections: boolean,
|
|
254
|
+
): string {
|
|
255
|
+
if (display.entries.length > 0 && !validSections) {
|
|
256
|
+
return "";
|
|
257
|
+
}
|
|
258
|
+
const skipped = display.skipped ?? 0;
|
|
259
|
+
const more = display.more ?? (display.footerLength ?? 0) > 0;
|
|
260
|
+
return `${display.entries.length > 0 ? `${n(display.entries.length)} matches` : "No matches"} · ${all ? "all sessions" : "current branch"}${skipped > 0 ? ` · ${n(skipped)} in context skipped` : ""}${more ? "\nMore results; continue with cursor" : ""}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function displaySummary(
|
|
264
|
+
display: PosthorseDisplay | undefined,
|
|
265
|
+
args: Readonly<Record<string, unknown>>,
|
|
266
|
+
validSections: boolean,
|
|
267
|
+
): string {
|
|
268
|
+
switch (display?.kind) {
|
|
269
|
+
case "context":
|
|
270
|
+
return contextSummary(display);
|
|
271
|
+
case "notes-list":
|
|
272
|
+
return display.count > 0
|
|
273
|
+
? `${n(display.count)} note${display.count === 1 ? "" : "s"} returned`
|
|
274
|
+
: "No notes yet";
|
|
275
|
+
case "notes-search":
|
|
276
|
+
return display.count > 0 ? `${n(display.count)} matches returned` : "No matches";
|
|
277
|
+
case "note-read":
|
|
278
|
+
return pageSummary(display);
|
|
279
|
+
case "note-write":
|
|
280
|
+
return args.content === "" ? "Cleared note" : "Saved note";
|
|
281
|
+
case "note-append":
|
|
282
|
+
return "Appended to note";
|
|
283
|
+
case "history-search":
|
|
284
|
+
return historySearchSummary(display, args.all === true, validSections);
|
|
285
|
+
case "history-read":
|
|
286
|
+
return historyReadSummary(display);
|
|
287
|
+
case "new-context":
|
|
288
|
+
return "Requested after foreground tools succeed.\nBackground work continues.";
|
|
289
|
+
case undefined:
|
|
290
|
+
return "";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function summaryOnly(display: PosthorseDisplay | undefined): boolean {
|
|
295
|
+
switch (display?.kind) {
|
|
296
|
+
case "context":
|
|
297
|
+
case "new-context":
|
|
298
|
+
case "note-write":
|
|
299
|
+
case "note-append":
|
|
300
|
+
return true;
|
|
301
|
+
case "notes-list":
|
|
302
|
+
case "notes-search":
|
|
303
|
+
return display.count === 0;
|
|
304
|
+
case "note-read":
|
|
305
|
+
return display.total === 0;
|
|
306
|
+
case "history-search":
|
|
307
|
+
return display.entries.length === 0;
|
|
308
|
+
case "history-read":
|
|
309
|
+
case undefined:
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|