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/index.ts
CHANGED
|
@@ -1,1565 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Posthorse — fresh context, same journey.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Pi owns the persisted boundary. Posthorse owns the policy: sparse budget reminders, rollover
|
|
7
|
-
* tools, durable notes, and history recovery.
|
|
4
|
+
* Pi owns persisted retain-none context boundaries. Posthorse owns reminder and
|
|
5
|
+
* rollover policy, bounded recovery, durable notes, and history retrieval.
|
|
8
6
|
*/
|
|
9
|
-
|
|
10
|
-
import { execFile } from "node:child_process";
|
|
11
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
-
import {
|
|
13
|
-
appendFileSync,
|
|
14
|
-
createReadStream,
|
|
15
|
-
existsSync,
|
|
16
|
-
mkdirSync,
|
|
17
|
-
readdirSync,
|
|
18
|
-
readFileSync,
|
|
19
|
-
realpathSync,
|
|
20
|
-
statSync,
|
|
21
|
-
} from "node:fs";
|
|
22
|
-
import { access, constants, lstat, open, readlink, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
23
|
-
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
24
|
-
import { promisify } from "node:util";
|
|
25
|
-
import { estimateTokens, formatSize, getAgentDir, SettingsManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
26
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
getCompactionSettings(): CompactionPolicy;
|
|
52
|
-
getContextUsage(): ContextUsage | undefined;
|
|
53
|
-
getSystemPrompt(): string;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
type ImageLike = { type: "image"; data: string; mimeType: string };
|
|
57
|
-
type ToolCallLike = {
|
|
58
|
-
type?: string;
|
|
59
|
-
id?: string;
|
|
60
|
-
name?: string;
|
|
61
|
-
namespace?: string;
|
|
62
|
-
arguments?: unknown;
|
|
63
|
-
executionArguments?: unknown;
|
|
64
|
-
async?: boolean;
|
|
65
|
-
};
|
|
66
|
-
type MessageLike = {
|
|
67
|
-
role?: string;
|
|
68
|
-
stopReason?: string;
|
|
69
|
-
errorMessage?: string;
|
|
70
|
-
content?: unknown;
|
|
71
|
-
summary?: string;
|
|
72
|
-
toolName?: string;
|
|
73
|
-
namespace?: string;
|
|
74
|
-
toolCallId?: string;
|
|
75
|
-
isError?: boolean;
|
|
76
|
-
command?: string;
|
|
77
|
-
output?: string;
|
|
78
|
-
excludeFromContext?: boolean;
|
|
79
|
-
};
|
|
80
|
-
type EntryLike = {
|
|
81
|
-
type?: string;
|
|
82
|
-
id?: string;
|
|
83
|
-
parentId?: string | null;
|
|
84
|
-
timestamp?: string;
|
|
85
|
-
message?: MessageLike;
|
|
86
|
-
summary?: string;
|
|
87
|
-
customType?: string;
|
|
88
|
-
content?: unknown;
|
|
89
|
-
details?: unknown;
|
|
90
|
-
display?: boolean;
|
|
91
|
-
handoff?: string;
|
|
92
|
-
firstKeptEntryId?: string;
|
|
93
|
-
targetId?: string;
|
|
94
|
-
replacement?: { content: unknown } | null;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
type WindowedEntry = { entry: EntryLike; windowId: string; text: string; images: ImageLike[] };
|
|
98
|
-
type ProjectedEntry = { sourceEntry: EntryLike; messages: MessageLike[] };
|
|
99
|
-
type HistoryHit = { id: string; text: string; headerLength: number; priority: 0 | 1 };
|
|
100
|
-
type RecoveryRecord = {
|
|
101
|
-
id: string;
|
|
102
|
-
timestamp: string;
|
|
103
|
-
kind: "owner" | "coordination";
|
|
104
|
-
label: string;
|
|
105
|
-
text: string;
|
|
106
|
-
};
|
|
107
|
-
type ReminderFingerprint = { windowId: string; contextWindow?: number; reserveTokens?: number };
|
|
108
|
-
type NoteFile = { path: string; size: number; mtimeMs: number };
|
|
109
|
-
type Budget = {
|
|
110
|
-
contextWindow: number;
|
|
111
|
-
reserveTokens: number;
|
|
112
|
-
enabled: boolean;
|
|
113
|
-
usable: number;
|
|
114
|
-
rolloverAt: number;
|
|
115
|
-
supported: boolean;
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
/** A legacy fork context window, or the retain-none compaction Posthorse commits on both hosts. */
|
|
119
|
-
function isWindow(entry: EntryLike): boolean {
|
|
120
|
-
return entry.type === "context_window" || (entry.type === "compaction" && (entry.details as { posthorse?: unknown } | undefined)?.posthorse === 1);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function windowHandoff(entry: EntryLike | undefined): string | undefined {
|
|
124
|
-
return entry?.type === "compaction" ? entry.summary : entry?.handoff;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** First branch entry still in model context: after a fork window, or a compaction's kept tail (including Pi's own `/compact`). */
|
|
128
|
-
function contextStart(entries: readonly EntryLike[]): { start: number; boundary?: EntryLike } {
|
|
129
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
130
|
-
const entry = entries[i];
|
|
131
|
-
if (entry.type === "context_window") return { start: i + 1, boundary: entry };
|
|
132
|
-
if (entry.type === "compaction") {
|
|
133
|
-
const kept = entries.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
|
|
134
|
-
return { start: kept >= 0 ? kept : i + 1, boundary: entry };
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return { start: 0 };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function isReminderType(customType: unknown): boolean {
|
|
141
|
-
return customType === REMINDER_TYPE;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Resolve each link like the fork's publisher: never normalize away a path constraint. */
|
|
145
|
-
async function resolveFileTarget(absolutePath: string): Promise<string> {
|
|
146
|
-
let target = absolutePath;
|
|
147
|
-
const links = new Set<string>();
|
|
148
|
-
for (;;) {
|
|
149
|
-
// A trailing separator requires an existing directory, never a new regular file.
|
|
150
|
-
if (target.endsWith(sep) || target.endsWith("/")) {
|
|
151
|
-
await stat(target);
|
|
152
|
-
return realpath(target);
|
|
153
|
-
}
|
|
154
|
-
const parentInput = dirname(target);
|
|
155
|
-
// realpath alone can accept regular-file/.. on macOS; stat checks traversal.
|
|
156
|
-
if (!(await stat(parentInput)).isDirectory()) {
|
|
157
|
-
throw Object.assign(new Error(`Not a directory: ${parentInput}`), { code: "ENOTDIR" });
|
|
158
|
-
}
|
|
159
|
-
const parent = await realpath(parentInput);
|
|
160
|
-
target = join(parent, basename(target));
|
|
161
|
-
const info = await lstat(target).catch((error: NodeJS.ErrnoException) => {
|
|
162
|
-
if (error.code !== "ENOENT") throw error;
|
|
163
|
-
return undefined;
|
|
164
|
-
});
|
|
165
|
-
if (!info) return target;
|
|
166
|
-
if (!info.isSymbolicLink()) return realpath(target);
|
|
167
|
-
if (links.has(target)) throw Object.assign(new Error(`Symlink cycle: ${absolutePath}`), { code: "ELOOP" });
|
|
168
|
-
links.add(target);
|
|
169
|
-
const link = await readlink(target);
|
|
170
|
-
// Preserve ".." until realpath has followed any preceding directory symlinks.
|
|
171
|
-
target = isAbsolute(link) ? link : `${parent}${sep}${link}`;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Same-directory replacement: a failure before rename leaves the previous note; mode/uid/gid are kept. */
|
|
176
|
-
async function publishFile(path: string, content: string, signal?: AbortSignal): Promise<void> {
|
|
177
|
-
signal?.throwIfAborted();
|
|
178
|
-
const target = await resolveFileTarget(path);
|
|
179
|
-
const previous = await lstat(target).catch((error: NodeJS.ErrnoException) => {
|
|
180
|
-
if (error.code !== "ENOENT") throw error;
|
|
181
|
-
return undefined;
|
|
182
|
-
});
|
|
183
|
-
if (previous && !previous.isFile()) {
|
|
184
|
-
throw Object.assign(new Error(`Cannot publish to a non-regular file: ${target}`), { code: previous.isDirectory() ? "EISDIR" : "EINVAL", path: target });
|
|
185
|
-
}
|
|
186
|
-
if (previous) await access(target, constants.W_OK);
|
|
187
|
-
const temporary = join(dirname(target), `.posthorse-${randomUUID()}.tmp`);
|
|
188
|
-
const file = await open(temporary, "wx", previous ? 0o600 : 0o666);
|
|
189
|
-
try {
|
|
190
|
-
await file.writeFile(content, { signal });
|
|
191
|
-
if (previous) {
|
|
192
|
-
const staged = await file.stat();
|
|
193
|
-
if (staged.uid !== previous.uid || staged.gid !== previous.gid) await file.chown(previous.uid, previous.gid);
|
|
194
|
-
await file.chmod(previous.mode & 0o777);
|
|
195
|
-
}
|
|
196
|
-
await file.close();
|
|
197
|
-
signal?.throwIfAborted();
|
|
198
|
-
await rename(temporary, target);
|
|
199
|
-
} catch (error) {
|
|
200
|
-
await file.close().catch(() => {});
|
|
201
|
-
await unlink(temporary).catch(() => {});
|
|
202
|
-
throw error;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function isImage(part: unknown): part is ImageLike {
|
|
207
|
-
const block = part as Partial<ImageLike> | null;
|
|
208
|
-
return typeof block === "object" && block !== null && block.type === "image" && typeof block.data === "string";
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function imagesOf(content: unknown): ImageLike[] {
|
|
212
|
-
return Array.isArray(content) ? content.filter(isImage) : [];
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function imageSummary(images: ImageLike[]): string {
|
|
216
|
-
if (!images.length) return "";
|
|
217
|
-
const types = [...new Set(images.map((image) => image.mimeType || "unknown type"))].join(", ");
|
|
218
|
-
return `[${images.length} image${images.length === 1 ? "" : "s"}: ${types}]`;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function safeJsonStringify(value: unknown): string {
|
|
222
|
-
try {
|
|
223
|
-
return JSON.stringify(value) ?? "undefined";
|
|
224
|
-
} catch {
|
|
225
|
-
return "[unserializable]";
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function toolIdentity(name?: string, namespace?: string): string {
|
|
230
|
-
return `${name ?? "tool"}${namespace === undefined ? "" : ` (namespace: ${safeJsonStringify(namespace)})`}`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function executionArgumentsText(call: ToolCallLike): string {
|
|
234
|
-
return call.executionArguments === undefined ? "" : `\nExecution arguments: ${safeJsonStringify(call.executionArguments)}`;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function textOf(message: MessageLike): string {
|
|
238
|
-
if (typeof message.content === "string") return message.content;
|
|
239
|
-
if (!Array.isArray(message.content)) return "";
|
|
240
|
-
return message.content
|
|
241
|
-
.map((part) => {
|
|
242
|
-
if (!part || typeof part !== "object") return "";
|
|
243
|
-
const block = part as ToolCallLike & { text?: string; thinking?: string };
|
|
244
|
-
if (block.type === "text") return block.text ?? "";
|
|
245
|
-
if (block.type === "thinking") return block.thinking ?? "";
|
|
246
|
-
if (block.type === "toolCall") {
|
|
247
|
-
return `${toolIdentity(block.name, block.namespace)} ${safeJsonStringify(block.arguments ?? {})}${block.id === undefined ? "" : `\nCall ID: ${block.id}`}${executionArgumentsText(block)}`;
|
|
248
|
-
}
|
|
249
|
-
return "";
|
|
250
|
-
})
|
|
251
|
-
.filter(Boolean)
|
|
252
|
-
.join("\n");
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function textResult(text: string, images: ImageLike[] = [], display?: PosthorseDisplay) {
|
|
256
|
-
return { content: [{ type: "text" as const, text }, ...images], details: display };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/** Conventional repos use the main checkout; separate Git directories are their own shared root. */
|
|
260
|
-
function notesRoot(cwd: string): string {
|
|
261
|
-
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
262
|
-
const marker = join(dir, ".git");
|
|
263
|
-
if (existsSync(marker)) {
|
|
264
|
-
try {
|
|
265
|
-
let target = marker;
|
|
266
|
-
if (!statSync(marker).isDirectory()) {
|
|
267
|
-
const gitdir = readFileSync(marker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
|
|
268
|
-
if (!gitdir) return dir;
|
|
269
|
-
target = resolve(dir, gitdir);
|
|
270
|
-
}
|
|
271
|
-
target = realpathSync(target);
|
|
272
|
-
const commonFile = join(target, "commondir");
|
|
273
|
-
const common = realpathSync(existsSync(commonFile) ? resolve(target, readFileSync(commonFile, "utf8").trim()) : target);
|
|
274
|
-
return basename(common) === ".git" ? dirname(common) : common;
|
|
275
|
-
} catch {
|
|
276
|
-
// Orphaned/copied worktrees can outlive their Git metadata; local notes still work.
|
|
277
|
-
return dir;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
if (dirname(dir) === dir) return cwd;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function requireValue(value: string | undefined, name: string, op: string): string {
|
|
285
|
-
if (value === undefined || value === "") throw new Error(`"${name}" is required for op "${op}".`);
|
|
286
|
-
return value;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function excerptAround(text: string, index: number, before: number, length: number): string {
|
|
290
|
-
const start = Math.max(0, index - before);
|
|
291
|
-
const end = Math.min(text.length, start + length);
|
|
292
|
-
return `${start ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function assistantFailure(message: MessageLike): string {
|
|
296
|
-
if (message.role !== "assistant") return "";
|
|
297
|
-
if (message.stopReason !== "error" && message.stopReason !== "aborted" && !message.errorMessage) return "";
|
|
298
|
-
return `[${message.stopReason ?? "error"}]${message.errorMessage ? ` ${message.errorMessage}` : ""}`;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function flattenEntry(entry: EntryLike): string | undefined {
|
|
302
|
-
if (entry.type === "message") {
|
|
303
|
-
const message = entry.message ?? {};
|
|
304
|
-
if (message.role === "bashExecution") {
|
|
305
|
-
// Pi keeps these out of every model's input on purpose; do not smuggle them back in through history.
|
|
306
|
-
if (message.excludeFromContext === true) return "[bashExecution] (excluded from model context by Pi)";
|
|
307
|
-
return `[bashExecution] $ ${message.command ?? ""}\n${message.output ?? ""}`;
|
|
308
|
-
}
|
|
309
|
-
const resultIdentity = message.role === "toolResult"
|
|
310
|
-
? `Tool: ${toolIdentity(message.toolName, message.namespace)}${message.toolCallId === undefined ? "" : `\nCall ID: ${message.toolCallId}`}`
|
|
311
|
-
: "";
|
|
312
|
-
return `[${message.role ?? "message"}] ${[textOf(message), assistantFailure(message), imageSummary(imagesOf(message.content)), resultIdentity].filter(Boolean).join("\n")}`;
|
|
313
|
-
}
|
|
314
|
-
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
315
|
-
return `[${entry.type}] ${entry.summary ?? ""}`;
|
|
316
|
-
}
|
|
317
|
-
if (entry.type === "custom_message") {
|
|
318
|
-
return `[custom:${entry.customType ?? "unknown"}] ${[textOf({ content: entry.content }), imageSummary(imagesOf(entry.content))].filter(Boolean).join("\n")}`;
|
|
319
|
-
}
|
|
320
|
-
if (entry.type === "context_window") {
|
|
321
|
-
return `[context_window] ${entry.handoff ? `Handoff: ${entry.handoff}` : "No handoff"}`;
|
|
322
|
-
}
|
|
323
|
-
if (entry.type === "context_edit" && entry.replacement) {
|
|
324
|
-
const content = entry.replacement.content;
|
|
325
|
-
return `[context_edit] ${[textOf({ content }), imageSummary(imagesOf(content))].filter(Boolean).join("\n")}`;
|
|
326
|
-
}
|
|
327
|
-
return undefined;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function isRecoveryTool(name: unknown): boolean {
|
|
331
|
-
return name === "history" || name === "notes" || name === "new_context";
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function isRecoveryCall(part: unknown): boolean {
|
|
335
|
-
const block = part as { type?: string; name?: string } | null;
|
|
336
|
-
return block?.type === "toolCall" && isRecoveryTool(block.name);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/** Ten base64url characters of SHA-256; 0.6 file keys were the whole digest, which starts with these. */
|
|
340
|
-
function shortKey(text: string): string {
|
|
341
|
-
return createHash("sha256").update(text).digest("base64url").slice(0, 10);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** Nested session files are subagent runs, whose user-role input came from a parent agent. */
|
|
345
|
-
function sourceTag(source: string): string {
|
|
346
|
-
return source.includes(sep) ? " [subagent]" : "";
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function contextEdits(entries: readonly EntryLike[]): Map<string, EntryLike> {
|
|
350
|
-
const edits = new Map<string, EntryLike>();
|
|
351
|
-
for (const entry of entries) {
|
|
352
|
-
if (entry.type === "context_edit" && entry.targetId) edits.set(entry.targetId, entry);
|
|
353
|
-
}
|
|
354
|
-
return edits;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit | undefined {
|
|
358
|
-
const { entry } = item;
|
|
359
|
-
let text = item.text;
|
|
360
|
-
let matchIndex = text.toLowerCase().indexOf(query);
|
|
361
|
-
if (matchIndex === -1) return undefined;
|
|
362
|
-
let priority: 0 | 1 =
|
|
363
|
-
entry.type === "context_window" ||
|
|
364
|
-
entry.type === "compaction" ||
|
|
365
|
-
entry.type === "branch_summary" ||
|
|
366
|
-
(entry.type === "custom_message" && isReminderType(entry.customType)) ||
|
|
367
|
-
(entry.type === "message" && entry.message?.role === "toolResult" && isRecoveryTool(entry.message.toolName))
|
|
368
|
-
? 1
|
|
369
|
-
: 0;
|
|
370
|
-
|
|
371
|
-
if (
|
|
372
|
-
entry.type === "message" && entry.message?.role === "assistant" &&
|
|
373
|
-
Array.isArray(entry.message.content) && entry.message.content.some(isRecoveryCall)
|
|
374
|
-
) {
|
|
375
|
-
// A lookup beside a matching ordinary call or prose must not demote that original content.
|
|
376
|
-
const originals = [""];
|
|
377
|
-
for (const part of entry.message.content) {
|
|
378
|
-
if (isRecoveryCall(part)) {
|
|
379
|
-
originals.push("");
|
|
380
|
-
} else {
|
|
381
|
-
const partText = textOf({ content: [part] });
|
|
382
|
-
if (partText) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${partText}`;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
const metadata = [assistantFailure(entry.message), imageSummary(item.images)].filter(Boolean).join("\n");
|
|
386
|
-
if (metadata) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${metadata}`;
|
|
387
|
-
if (originals[0]) originals[0] = `[assistant] ${originals[0]}`;
|
|
388
|
-
const original = originals.find((part) => part.toLowerCase().includes(query));
|
|
389
|
-
priority = original ? 0 : 1;
|
|
390
|
-
if (original) {
|
|
391
|
-
text = original === originals[0] ? original : `[assistant] ${original}`;
|
|
392
|
-
matchIndex = text.toLowerCase().indexOf(query);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
const id = source ? `${entry.id}@${shortKey(source)}` : entry.id!;
|
|
396
|
-
const header = `${entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${id}] `;
|
|
397
|
-
return {
|
|
398
|
-
id,
|
|
399
|
-
priority,
|
|
400
|
-
text: `${header}${excerptAround(text, matchIndex, 100, 400)}`,
|
|
401
|
-
headerLength: header.length,
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function toWindowedEntry(entry: EntryLike, windows: Map<string, string>): WindowedEntry | undefined {
|
|
406
|
-
const inheritedWindow = entry.parentId ? (windows.get(entry.parentId) ?? "initial") : "initial";
|
|
407
|
-
const windowId = isWindow(entry) && entry.id ? entry.id : inheritedWindow;
|
|
408
|
-
if (entry.id) windows.set(entry.id, windowId);
|
|
409
|
-
const text = flattenEntry(entry);
|
|
410
|
-
if (!text || !entry.id) return undefined;
|
|
411
|
-
const images = imagesOf(entry.type === "context_edit" ? entry.replacement?.content : entry.type === "message" ? entry.message?.content : entry.content);
|
|
412
|
-
return { entry, windowId, text, images };
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function* windowEntries(entries: Iterable<EntryLike>): Generator<WindowedEntry> {
|
|
416
|
-
const windows = new Map<string, string>();
|
|
417
|
-
for (const entry of entries) {
|
|
418
|
-
const item = toWindowedEntry(entry, windows);
|
|
419
|
-
if (item) yield item;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/** Split on LF only: JSONL strings and notes can contain Unicode separators that Node's readline also splits on. */
|
|
424
|
-
async function* lfLines(file: string, signal?: AbortSignal): AsyncGenerator<string> {
|
|
425
|
-
const stream = createReadStream(file, { encoding: "utf8", signal });
|
|
426
|
-
let pending: string[] = [];
|
|
427
|
-
for await (const chunk of stream) {
|
|
428
|
-
const lines = chunk.split("\n");
|
|
429
|
-
pending.push(lines[0]);
|
|
430
|
-
if (lines.length > 1) {
|
|
431
|
-
yield pending.join("");
|
|
432
|
-
yield* lines.slice(1, -1);
|
|
433
|
-
pending = [lines.at(-1)!];
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
const last = pending.join("");
|
|
437
|
-
if (last) yield last;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/** Git's heuristic: a NUL byte in the first 8,000 bytes marks binary content. */
|
|
441
|
-
async function isBinaryFile(path: string): Promise<boolean> {
|
|
442
|
-
const file = await open(path, "r");
|
|
443
|
-
try {
|
|
444
|
-
const { buffer, bytesRead } = await file.read(Buffer.alloc(8_000), 0, 8_000, 0);
|
|
445
|
-
return buffer.subarray(0, bytesRead).includes(0);
|
|
446
|
-
} finally {
|
|
447
|
-
await file.close();
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
const execFileAsync = promisify(execFile);
|
|
452
|
-
/**
|
|
453
|
-
* ripgrep needles whose raw JSONL matches cover every normalized match, or undefined when only a full scan is exact.
|
|
454
|
-
* Raw JSONL escapes quotes, backslashes, and control characters; flattenEntry adds brackets, parentheses, braces,
|
|
455
|
-
* colons, `$`, spaces beside those labels, and label-only phrases. Image summaries ("3 images: image/png,
|
|
456
|
-
* image/jpeg") come only from entries with an image block, so their text needs that block instead.
|
|
457
|
-
*/
|
|
458
|
-
function prefilterNeedles(query: string): string[] | undefined {
|
|
459
|
-
if (/["\\[\](){}:$\p{Cc}]/u.test(query) || query.trim() !== query) return undefined;
|
|
460
|
-
if (["call id", "execution arguments", "no handoff", "excluded from model context by pi"].some((phrase) => phrase.includes(query))) return undefined;
|
|
461
|
-
const image = query.includes(",") || /^\d+ ?i?m?a?g?e?s?$/.test(query) || ["images", "unknown type"].some((phrase) => phrase.includes(query));
|
|
462
|
-
return image ? [query, '"type":"image"'] : [query];
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/** Session files whose raw JSONL contains any needle in any case, via Pi's managed ripgrep or PATH; undefined means scan every file. */
|
|
466
|
-
async function filesContaining(dir: string, needles: string[], signal?: AbortSignal): Promise<Set<string> | undefined> {
|
|
467
|
-
const args = [
|
|
468
|
-
"--files-with-matches", "--fixed-strings", "--ignore-case", "--no-ignore", "--hidden", "--no-messages",
|
|
469
|
-
"--glob", "*.jsonl", ...needles.flatMap((needle) => ["--regexp", needle]), dir,
|
|
470
|
-
];
|
|
471
|
-
for (const rg of [join(getAgentDir(), "bin", "rg"), "rg"]) {
|
|
472
|
-
try {
|
|
473
|
-
const { stdout } = await execFileAsync(rg, args, { signal, maxBuffer: 64 * 1024 * 1024 });
|
|
474
|
-
return new Set(stdout.split("\n").filter(Boolean).map((file) => resolve(file)));
|
|
475
|
-
} catch (error) {
|
|
476
|
-
const { code } = error as { code?: unknown };
|
|
477
|
-
if (code === 1) return new Set();
|
|
478
|
-
if (code === "ENOENT") continue;
|
|
479
|
-
signal?.throwIfAborted();
|
|
480
|
-
return undefined;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
return undefined;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncGenerator<WindowedEntry> {
|
|
487
|
-
if (!existsSync(file)) return;
|
|
488
|
-
const windows = new Map<string, string>();
|
|
489
|
-
for await (const line of lfLines(file, signal)) {
|
|
490
|
-
let entry: EntryLike;
|
|
491
|
-
try {
|
|
492
|
-
entry = JSON.parse(line) as EntryLike;
|
|
493
|
-
} catch {
|
|
494
|
-
continue;
|
|
495
|
-
}
|
|
496
|
-
if (!entry) continue;
|
|
497
|
-
const item = toWindowedEntry(entry, windows);
|
|
498
|
-
if (item) yield item;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function sessionFiles(dir: string): string[] {
|
|
503
|
-
if (!existsSync(dir)) return [];
|
|
504
|
-
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
505
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl") && !entry.name.includes(".intent."))
|
|
506
|
-
.map((entry) => join(entry.parentPath, entry.name));
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/** Project session files, newest-modified first, optionally limited to one file key or to prefiltered candidates. */
|
|
510
|
-
async function* scopedSessionFiles(
|
|
511
|
-
dir: string, cwd: string, currentFile: string | undefined, signal?: AbortSignal,
|
|
512
|
-
{ fileKey, candidates }: { fileKey?: string; candidates?: Set<string> } = {},
|
|
513
|
-
): AsyncGenerator<string> {
|
|
514
|
-
const files = sessionFiles(dir);
|
|
515
|
-
const fileSet = new Set(files);
|
|
516
|
-
const visible = new Map<string, boolean>();
|
|
517
|
-
const belongs = async (file: string): Promise<boolean> => {
|
|
518
|
-
const known = visible.get(file);
|
|
519
|
-
if (known !== undefined) return known;
|
|
520
|
-
let header: { cwd?: string } | null = null;
|
|
521
|
-
for await (const line of lfLines(file, signal)) {
|
|
522
|
-
try {
|
|
523
|
-
const entry = JSON.parse(line) as { type?: string; id?: unknown; cwd?: unknown } | null;
|
|
524
|
-
if (!entry) continue;
|
|
525
|
-
if (entry.type === "session" && typeof entry.id === "string") header = { cwd: typeof entry.cwd === "string" ? entry.cwd : undefined };
|
|
526
|
-
break;
|
|
527
|
-
} catch {
|
|
528
|
-
// Pi also skips malformed lines before the first parsed entry.
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
if (!header) {
|
|
532
|
-
visible.set(file, false);
|
|
533
|
-
return false;
|
|
534
|
-
}
|
|
535
|
-
const parts = relative(dir, file).split(sep);
|
|
536
|
-
let parent: string | undefined;
|
|
537
|
-
for (let i = parts.length - 1; i > 0; i--) {
|
|
538
|
-
const candidate = `${join(dir, ...parts.slice(0, i))}.jsonl`;
|
|
539
|
-
if (fileSet.has(candidate)) {
|
|
540
|
-
parent = candidate;
|
|
541
|
-
break;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
const allowed = file === currentFile || (parent ? await belongs(parent) : !!header.cwd && resolve(header.cwd) === cwd);
|
|
545
|
-
visible.set(file, allowed);
|
|
546
|
-
return allowed;
|
|
547
|
-
};
|
|
548
|
-
const selected = files
|
|
549
|
-
.filter((file) => (!candidates || candidates.has(resolve(file))) &&
|
|
550
|
-
(fileKey === undefined || fileKey.startsWith(shortKey(relative(dir, file)))))
|
|
551
|
-
.map((file) => ({ file, mtime: statSync(file).mtimeMs }))
|
|
552
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
553
|
-
for (const { file } of selected) {
|
|
554
|
-
if (await belongs(file)) yield file;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function excerpt(text: string, limit: number): string {
|
|
559
|
-
if (text.length <= limit) return text;
|
|
560
|
-
const marker = "\n… middle omitted …\n";
|
|
561
|
-
if (limit <= marker.length) return text.slice(0, limit);
|
|
562
|
-
const head = Math.floor((limit - marker.length) / 2);
|
|
563
|
-
return `${text.slice(0, head)}${marker}${text.slice(text.length - (limit - marker.length - head))}`;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
function boundedBlock(header: string, text: string, limit: number): string {
|
|
567
|
-
const textLimit = Math.max(0, limit - header.length - 1);
|
|
568
|
-
return textLimit ? `${header}\n${excerpt(text, textLimit)}` : header;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
function recoveryRecord(entry: EntryLike, ownerQuestion: boolean): RecoveryRecord | undefined {
|
|
572
|
-
let kind: RecoveryRecord["kind"];
|
|
573
|
-
let label: string;
|
|
574
|
-
let text: string;
|
|
575
|
-
let images: ImageLike[] = [];
|
|
576
|
-
if (entry.type === "message" && entry.message?.role === "user") {
|
|
577
|
-
kind = "owner";
|
|
578
|
-
label = "owner input";
|
|
579
|
-
text = textOf(entry.message).trim();
|
|
580
|
-
images = imagesOf(entry.message.content);
|
|
581
|
-
} else if (
|
|
582
|
-
entry.type === "message" &&
|
|
583
|
-
entry.message?.role === "toolResult" &&
|
|
584
|
-
entry.message.toolName === "ask_question" &&
|
|
585
|
-
entry.message.namespace === undefined &&
|
|
586
|
-
ownerQuestion &&
|
|
587
|
-
entry.message.isError !== true
|
|
588
|
-
) {
|
|
589
|
-
kind = "owner";
|
|
590
|
-
label = "owner answer via ask_question";
|
|
591
|
-
text = textOf(entry.message).trim();
|
|
592
|
-
} else if (entry.type === "custom_message" && entry.display === true && !isReminderType(entry.customType)) {
|
|
593
|
-
kind = "coordination";
|
|
594
|
-
label = `visible ${(entry.customType ?? "custom").slice(0, 80)} coordination input (not direct owner input)`;
|
|
595
|
-
text = textOf({ content: entry.content }).trim();
|
|
596
|
-
images = imagesOf(entry.content);
|
|
597
|
-
} else {
|
|
598
|
-
return undefined;
|
|
599
|
-
}
|
|
600
|
-
const id = entry.id?.slice(0, 120) ?? "unknown";
|
|
601
|
-
const summary = imageSummary(images);
|
|
602
|
-
return {
|
|
603
|
-
id,
|
|
604
|
-
timestamp: entry.timestamp?.slice(0, 80) ?? "unknown time",
|
|
605
|
-
kind,
|
|
606
|
-
label,
|
|
607
|
-
text: [text, summary && `${summary} — recover with history read id ${id}`].filter(Boolean).join("\n") || "(non-text content; recover the entry from history)",
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
function formatRecoveryRecord(record: RecoveryRecord, limit: number): string {
|
|
612
|
-
return boundedBlock(`[${record.label} | ${record.timestamp} | entry ${record.id}]`, record.text, limit);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/** Owner inputs keep a one-line preview: they are direct intent, and most are short. */
|
|
616
|
-
function indexLine(record: RecoveryRecord): string {
|
|
617
|
-
const header = formatRecoveryRecord(record, 0);
|
|
618
|
-
if (record.kind !== "owner") return header;
|
|
619
|
-
const preview = record.text.replace(/\s+/g, " ").trim();
|
|
620
|
-
return `${header} ${preview.length > 120 ? `${preview.slice(0, 119)}…` : preview}`;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function formatPriorCheckpoint(entry: EntryLike | undefined, limit: number): string | undefined {
|
|
624
|
-
const handoff = windowHandoff(entry)?.trim();
|
|
625
|
-
if (!entry || !handoff) return undefined;
|
|
626
|
-
const id = entry.id?.slice(0, 120) ?? "unknown";
|
|
627
|
-
const header = `[older checkpoint; possibly stale | context-window entry ${id}]`;
|
|
628
|
-
if (handoff.startsWith(AUTO_HANDOFF_PREFIX)) {
|
|
629
|
-
return boundedBlock(header, `Prior automatic recovery text is not nested here. Use history read with entry ${id} if needed.`, limit);
|
|
630
|
-
}
|
|
631
|
-
return boundedBlock(header, handoff, limit);
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
/** Async receipts can arrive during a later response, so its completion cannot establish receipt delivery. */
|
|
635
|
-
function recoverableToolResults(
|
|
636
|
-
entries: readonly EntryLike[],
|
|
637
|
-
): { callId?: string; blocks: Array<{ header: string; text: string }> } | undefined {
|
|
638
|
-
const calls = new Map<string, { entry: EntryLike; block: ToolCallLike }>();
|
|
639
|
-
for (const entry of entries) {
|
|
640
|
-
if (entry.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
|
|
641
|
-
for (const block of entry.message.content as ToolCallLike[]) {
|
|
642
|
-
if (block?.type === "toolCall" && typeof block.id === "string") calls.set(block.id, { entry, block });
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
let trailingStart = -1;
|
|
646
|
-
let hasTrailingResult = false;
|
|
647
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
648
|
-
const entry = entries[i];
|
|
649
|
-
const role = entry.message?.role;
|
|
650
|
-
if (role === "toolResult") {
|
|
651
|
-
hasTrailingResult = true;
|
|
652
|
-
} else if (role === "assistant") {
|
|
653
|
-
const invalid =
|
|
654
|
-
entry.message?.stopReason === "error" ||
|
|
655
|
-
entry.message?.stopReason === "aborted" ||
|
|
656
|
-
entry.message?.stopReason === "length";
|
|
657
|
-
if (!invalid || hasTrailingResult) {
|
|
658
|
-
trailingStart = i;
|
|
659
|
-
break;
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
const results = entries.filter((entry, index) => {
|
|
664
|
-
const message = entry.message;
|
|
665
|
-
if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") return false;
|
|
666
|
-
const call = calls.get(message.toolCallId);
|
|
667
|
-
// Use only projected provenance. An orphan may be async; never resurrect its raw call.
|
|
668
|
-
return index > trailingStart || !call || call.block.async === true;
|
|
669
|
-
});
|
|
670
|
-
if (!results.length) return undefined;
|
|
671
|
-
const call = calls.get(results[0].message!.toolCallId!)?.entry;
|
|
672
|
-
const allLinked = !!call && results.every((result) => calls.get(result.message!.toolCallId!)?.entry === call);
|
|
673
|
-
const blocks = results.map((result) => {
|
|
674
|
-
const message = result.message ?? {};
|
|
675
|
-
const matching = calls.get(message.toolCallId!);
|
|
676
|
-
const name = toolIdentity(message.toolName ?? matching?.block.name, message.namespace ?? matching?.block.namespace);
|
|
677
|
-
const resultId = (result.id ?? "unknown").slice(0, 120);
|
|
678
|
-
const images = imageSummary(imagesOf(message.content));
|
|
679
|
-
const output =
|
|
680
|
-
[textOf(message).trim(), images && `${images} — recover with history read id ${resultId}`]
|
|
681
|
-
.filter(Boolean)
|
|
682
|
-
.join("\n") || "(empty result)";
|
|
683
|
-
const callDetail = matching
|
|
684
|
-
? `${allLinked ? "" : `\nCall entry: ${(matching.entry.id ?? "unknown").slice(0, 120)}`}\nCall arguments: ${safeJsonStringify(matching.block.arguments ?? {})}${executionArgumentsText(matching.block)}`
|
|
685
|
-
: "\nNo matching projected call";
|
|
686
|
-
return {
|
|
687
|
-
header: `[${message.isError ? "error" : "result"} entry ${resultId}]`,
|
|
688
|
-
text: `${output}\n\nTool: ${name}\nCall ID: ${message.toolCallId}${callDetail}`,
|
|
689
|
-
};
|
|
690
|
-
});
|
|
691
|
-
return { callId: allLinked ? (call?.id ?? "unknown").slice(0, 120) : undefined, blocks };
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly ProjectedEntry[], maxChars: number, ownerQuestionRegistered: boolean): string {
|
|
695
|
-
const { start, boundary: priorWindow } = contextStart(entries);
|
|
696
|
-
const current = entries.slice(start);
|
|
697
|
-
const edits = contextEdits(entries);
|
|
698
|
-
const omittedProjectedIds = new Set(projected.filter(({ messages }) => !messages.length).map(({ sourceEntry }) => sourceEntry.id));
|
|
699
|
-
const edited = current.flatMap((entry) => {
|
|
700
|
-
if (entry.id && omittedProjectedIds.has(entry.id)) return [];
|
|
701
|
-
const edit = entry.id ? edits.get(entry.id) : undefined;
|
|
702
|
-
if (edit?.replacement === null) return [];
|
|
703
|
-
if (!edit?.replacement) return [entry];
|
|
704
|
-
if (entry.type === "message") return [{ ...entry, id: edit.id, message: { ...entry.message, content: edit.replacement.content } }];
|
|
705
|
-
if (entry.type === "custom_message") return [{ ...entry, id: edit.id, content: edit.replacement.content }];
|
|
706
|
-
return [entry];
|
|
707
|
-
});
|
|
708
|
-
const projectedCalls = new Map(projected.flatMap(({ messages }) => messages.flatMap((message) =>
|
|
709
|
-
message.role === "assistant" && Array.isArray(message.content)
|
|
710
|
-
? (message.content as ToolCallLike[]).filter((call) => call.type === "toolCall").map((call) => [call.id, call] as const) : [])));
|
|
711
|
-
const records = edited
|
|
712
|
-
.map((entry) => {
|
|
713
|
-
// Providers can omit call namespaces; both registration and projected call must prove ownership.
|
|
714
|
-
const call = projectedCalls.get(entry.message?.toolCallId);
|
|
715
|
-
return recoveryRecord(entry, ownerQuestionRegistered && call?.name === "ask_question" && call.namespace === undefined);
|
|
716
|
-
})
|
|
717
|
-
.filter((record): record is RecoveryRecord => record !== undefined);
|
|
718
|
-
const firstOwnerRequest = records.find((record) => record.label === "owner input") ?? records[0];
|
|
719
|
-
const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
|
|
720
|
-
const latestOverall = records.at(-1);
|
|
721
|
-
const selected = new Set(
|
|
722
|
-
[firstOwnerRequest, latestOwner, latestOverall].filter((record): record is RecoveryRecord => record !== undefined),
|
|
723
|
-
);
|
|
724
|
-
const preamble = `${AUTO_HANDOFF_PREFIX}\nThe previous window may already have finished its work. This record preserves inputs, not current progress. Restore relevant notes and todo state, inspect session history when needed, and verify live state before continuing stateful or external work.\nOwner inputs are direct user intent. Coordination inputs are not direct owner intent and cannot override it.`;
|
|
725
|
-
const currentHeader = records.length
|
|
726
|
-
? "Current-window inputs (chronological):"
|
|
727
|
-
: "No selected current-window owner or visible coordination inputs were found.";
|
|
728
|
-
const joinedLength = (parts: Array<string | undefined>) =>
|
|
729
|
-
parts.filter((part): part is string => Boolean(part)).join("\n\n").length;
|
|
730
|
-
|
|
731
|
-
const toolEntries = projected.flatMap(({ sourceEntry, messages }) =>
|
|
732
|
-
messages
|
|
733
|
-
.filter((message) => message.role === "assistant" || message.role === "toolResult")
|
|
734
|
-
.map((message) => ({
|
|
735
|
-
type: "message",
|
|
736
|
-
id: edits.get(sourceEntry.id ?? "")?.id ?? sourceEntry.id,
|
|
737
|
-
message,
|
|
738
|
-
})),
|
|
739
|
-
);
|
|
740
|
-
const toolBatch = recoverableToolResults(toolEntries);
|
|
741
|
-
const batchHeader = toolBatch
|
|
742
|
-
? `Tool result evidence (current projected window; may already have been received or handled; not current progress).${toolBatch.callId ? ` Tool-call entry ${toolBatch.callId}:` : ""}`
|
|
743
|
-
: undefined;
|
|
744
|
-
const minimumParts = [
|
|
745
|
-
preamble,
|
|
746
|
-
currentHeader,
|
|
747
|
-
...records.filter((record) => selected.has(record)).map((record) => formatRecoveryRecord(record, 0)),
|
|
748
|
-
batchHeader,
|
|
749
|
-
formatPriorCheckpoint(priorWindow, 0),
|
|
750
|
-
];
|
|
751
|
-
let headerBudget = Math.max(0, maxChars - joinedLength(minimumParts) - HANDOFF_OVERHEAD_RESERVE);
|
|
752
|
-
let firstBatchBlock = toolBatch?.blocks.length ?? 0;
|
|
753
|
-
while (firstBatchBlock > 0) {
|
|
754
|
-
const length = toolBatch!.blocks[firstBatchBlock - 1].header.length + 2;
|
|
755
|
-
if (length > headerBudget) break;
|
|
756
|
-
headerBudget -= length;
|
|
757
|
-
firstBatchBlock--;
|
|
758
|
-
}
|
|
759
|
-
const batchBlocks = toolBatch?.blocks.slice(firstBatchBlock) ?? [];
|
|
760
|
-
const batchOmission = firstBatchBlock
|
|
761
|
-
? `Omitted ${firstBatchBlock} earlier tool result(s) whose headers could not fit. ${toolBatch!.callId ? `Use history read with tool-call entry ${toolBatch!.callId}, then ` : "Use "}history search/read to recover them.`
|
|
762
|
-
: undefined;
|
|
763
|
-
const bareBatch = batchHeader
|
|
764
|
-
? [batchHeader, batchOmission, ...batchBlocks.map((block) => block.header)]
|
|
765
|
-
.filter((part): part is string => Boolean(part))
|
|
766
|
-
.join("\n\n")
|
|
767
|
-
: undefined;
|
|
768
|
-
const fixedCount = selected.size + (windowHandoff(priorWindow)?.trim() ? 1 : 0);
|
|
769
|
-
const fixedBudget = Math.max(
|
|
770
|
-
0,
|
|
771
|
-
maxChars - joinedLength([preamble, currentHeader, bareBatch]) - HANDOFF_OVERHEAD_RESERVE,
|
|
772
|
-
);
|
|
773
|
-
const fixedLimit = fixedCount
|
|
774
|
-
? Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(fixedBudget / fixedCount))
|
|
775
|
-
: MAX_RECOVERY_RECORD_CHARS;
|
|
776
|
-
const prior = formatPriorCheckpoint(priorWindow, fixedLimit);
|
|
777
|
-
const fixedParts = [
|
|
778
|
-
preamble,
|
|
779
|
-
currentHeader,
|
|
780
|
-
...records.filter((record) => selected.has(record)).map((record) => formatRecoveryRecord(record, fixedLimit)),
|
|
781
|
-
];
|
|
782
|
-
|
|
783
|
-
let batch: string | undefined;
|
|
784
|
-
if (batchHeader && bareBatch) {
|
|
785
|
-
const availableText = Math.max(
|
|
8
|
+
import { registerPosthorseMessages } from "./ui.ts";
|
|
9
|
+
import { persistedPolicy, policySnapshot } from "./src/budget.ts";
|
|
10
|
+
import type { CompactionPolicy, PolicyReader } from "./src/contracts.ts";
|
|
11
|
+
import { registerHistory } from "./src/history.ts";
|
|
12
|
+
import { safeJsonStringify } from "./src/message-text.ts";
|
|
13
|
+
import { registerNotes } from "./src/notes.ts";
|
|
14
|
+
import { PageReservations } from "./src/paging.ts";
|
|
15
|
+
import { registerRollover } from "./src/rollover.ts";
|
|
16
|
+
|
|
17
|
+
function activeToolTokens(pi: ExtensionAPI): number {
|
|
18
|
+
const active = new Set(pi.getActiveTools());
|
|
19
|
+
return pi
|
|
20
|
+
.getAllTools()
|
|
21
|
+
.filter((tool) => active.has(tool.name))
|
|
22
|
+
.reduce(
|
|
23
|
+
(total, tool) =>
|
|
24
|
+
total +
|
|
25
|
+
Math.ceil(
|
|
26
|
+
safeJsonStringify({
|
|
27
|
+
name: tool.name,
|
|
28
|
+
description: tool.description,
|
|
29
|
+
parameters: tool.parameters,
|
|
30
|
+
}).length / 4,
|
|
31
|
+
),
|
|
786
32
|
0,
|
|
787
|
-
maxChars -
|
|
788
|
-
joinedLength([...fixedParts, bareBatch, prior]) -
|
|
789
|
-
HANDOFF_OVERHEAD_RESERVE -
|
|
790
|
-
batchBlocks.length,
|
|
791
33
|
);
|
|
792
|
-
const perBlock = Math.min(MAX_RECOVERY_RESULT_CHARS, Math.floor(availableText / batchBlocks.length));
|
|
793
|
-
batch = [
|
|
794
|
-
batchHeader,
|
|
795
|
-
batchOmission,
|
|
796
|
-
...batchBlocks.map((block) => (perBlock ? `${block.header}\n${excerpt(block.text, perBlock)}` : block.header)),
|
|
797
|
-
]
|
|
798
|
-
.filter((part): part is string => Boolean(part))
|
|
799
|
-
.join("\n\n");
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
// Other inputs are indexed, newest kept first when space runs out; history read recovers their full text.
|
|
803
|
-
const others = records.filter((record) => !selected.has(record));
|
|
804
|
-
const indexHeader = "Other current-window inputs, oldest first (recover with history read):";
|
|
805
|
-
let indexBudget = Math.max(0, maxChars - joinedLength([...fixedParts, indexHeader, batch, prior]) - HANDOFF_OVERHEAD_RESERVE);
|
|
806
|
-
const listed: string[] = [];
|
|
807
|
-
for (let i = others.length - 1; i >= 0; i--) {
|
|
808
|
-
const line = indexLine(others[i]);
|
|
809
|
-
if (line.length + 1 > indexBudget) break;
|
|
810
|
-
listed.unshift(line);
|
|
811
|
-
indexBudget -= line.length + 1;
|
|
812
|
-
}
|
|
813
|
-
const unlisted = others.length - listed.length;
|
|
814
|
-
const index = others.length
|
|
815
|
-
? [indexHeader, ...(unlisted ? [`${unlisted} earlier input(s) not listed; use history search to find them.`] : []), ...listed].join("\n")
|
|
816
|
-
: undefined;
|
|
817
|
-
return [...fixedParts, index, batch, prior].filter((part): part is string => Boolean(part)).join("\n\n");
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
function reminderMatches(details: unknown, fingerprint: ReminderFingerprint): boolean {
|
|
821
|
-
const stored = (details ?? {}) as Partial<ReminderFingerprint>;
|
|
822
|
-
return (
|
|
823
|
-
stored.windowId === fingerprint.windowId &&
|
|
824
|
-
stored.contextWindow === fingerprint.contextWindow &&
|
|
825
|
-
stored.reserveTokens === fingerprint.reserveTokens
|
|
826
|
-
);
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
function hasReminder(entries: readonly EntryLike[], fingerprint: ReminderFingerprint): boolean {
|
|
830
|
-
return entries.some(
|
|
831
|
-
(entry) => entry.type === "custom_message" && isReminderType(entry.customType) && reminderMatches(entry.details, fingerprint),
|
|
832
|
-
);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
function budgetFor(ctx: PolicyContext, contextWindow = ctx.getContextUsage()?.contextWindow): Budget | undefined {
|
|
836
|
-
if (!contextWindow || contextWindow <= 0) return undefined;
|
|
837
|
-
const { enabled, reserveTokens } = ctx.getCompactionSettings();
|
|
838
|
-
const usable = contextWindow - reserveTokens;
|
|
839
|
-
return { contextWindow, reserveTokens, enabled, usable, rolloverAt: usable + 1, supported: !enabled || usable >= MIN_USABLE_TOKENS };
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
|
|
843
|
-
function freshPayloadChars(ctx: PolicyContext, toolTokens: number, pendingMessages: readonly MessageLike[] = [], cap = MAX_HANDOFF_CHARS): number {
|
|
844
|
-
const budget = budgetFor(ctx);
|
|
845
|
-
if (!budget) return cap;
|
|
846
|
-
const line = budget.enabled && budget.supported ? budget.rolloverAt : budget.contextWindow;
|
|
847
|
-
const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + guidanceTokens(ctx);
|
|
848
|
-
const pendingTokens = pendingMessages.reduce(
|
|
849
|
-
(total, message) => total + Math.ceil(textOf(message).length / 4) + imagesOf(message.content).length * (ESTIMATED_IMAGE_CHARS / 4),
|
|
850
|
-
0,
|
|
851
|
-
);
|
|
852
|
-
return Math.min(
|
|
853
|
-
cap,
|
|
854
|
-
Math.max(0, Math.floor((line - PAGE_MARGIN_TOKENS - promptTokens - toolTokens - pendingTokens) / 2)) * 4,
|
|
855
|
-
);
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
/** Official Pi cannot expose its live settings to extensions; budget reports say when they used the persisted snapshot. */
|
|
859
|
-
const SNAPSHOT_NOTE = "Reminder and budget policy uses available settings (a persisted CLI snapshot by default); Pi's live settings control automatic compaction.";
|
|
860
|
-
const CHECKPOINT_STEPS = "update the task's current-state note (goal, progress, decisions, next steps) with available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. If the note is already current, leave it unchanged. Then call new_context";
|
|
861
|
-
|
|
862
|
-
function unsupportedMessage(budget: Budget): string {
|
|
863
|
-
const n = (value: number) => value.toLocaleString("en-US");
|
|
864
|
-
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 Posthorse rollover and checkpoint reminders are off for this model, so Pi's own compaction applies. Lower compaction.reserveTokens in Pi settings or use a larger-context model. new_context remains available with a model-aware handoff limit.`;
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
/** Capacity before Pi's automatic line (or configured context limit), including page metadata and refusals. */
|
|
868
|
-
function pageCapacity(ctx: PolicyContext, toolTokens: number): number {
|
|
869
|
-
const usage = ctx.getContextUsage();
|
|
870
|
-
if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens, [], MAX_PAGE_CHARS) / 4 + PAGE_MARGIN_TOKENS;
|
|
871
|
-
const budget = budgetFor(ctx, usage.contextWindow);
|
|
872
|
-
const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
|
|
873
|
-
return line - usage.tokens - guidanceTokens(ctx);
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
function buildGuidance(ctx: PolicyContext): string {
|
|
877
|
-
const budget = budgetFor(ctx);
|
|
878
|
-
const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
|
|
879
|
-
const n = (value: number) => value.toLocaleString("en-US");
|
|
880
|
-
const capacity = budget
|
|
881
|
-
? `Configured context capacity: ${n(budget.contextWindow)} tokens. This is Pi's best available native limit, not remaining space or a measured provider boundary. Fresh windows use the active configuration; system instructions, tools, the handoff, and new messages consume part of it.`
|
|
882
|
-
: "Configured context capacity is unknown.";
|
|
883
|
-
let automatic: string;
|
|
884
|
-
if (!enabled) {
|
|
885
|
-
automatic = "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available.";
|
|
886
|
-
} else if (budget && !budget.supported) {
|
|
887
|
-
automatic = unsupportedMessage(budget);
|
|
888
|
-
} else {
|
|
889
|
-
const deadline = budget
|
|
890
|
-
? `${Math.max(1, Math.round((budget.rolloverAt / budget.contextWindow) * 100))}% used`
|
|
891
|
-
: "the configured Pi context limit";
|
|
892
|
-
const line = budget ? ` The rollover line is ${n(budget.rolloverAt)} tokens used under the available Pi settings.` : "";
|
|
893
|
-
automatic = `Automatic rollover follows Pi's compaction setting.${line} At most one checkpoint reminder may arrive before the rollover line (${deadline}); when it does, stop normal work, ${CHECKPOINT_STEPS}.`;
|
|
894
|
-
}
|
|
895
|
-
return `## Context self-management (Posthorse)
|
|
896
|
-
${capacity}
|
|
897
|
-
Do not guess context capacity or remaining space from response-token limits or reasoning budgets. Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining. If usage is unknown, report it as unknown. No routine budget checks are needed.
|
|
898
|
-
${automatic}
|
|
899
|
-
After a rollover, earlier conversation stays in history. Restore notes and todos, then verify live state before stateful or external work; automatic handoffs record inputs, not progress.
|
|
900
|
-
Keep one concise current-state note per task. Preserve decisions and safety constraints; link fuller evidence and history instead of copying them. Edit changed sections rather than resending unchanged content.
|
|
901
|
-
Write handoffs and notes as normal readable prose; notes and history search match literal text.`;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
function persistedPolicy(ctx: ExtensionContext): CompactionPolicy {
|
|
905
|
-
const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted: ctx.isProjectTrusted() });
|
|
906
|
-
const errors = settings.drainErrors();
|
|
907
|
-
if (errors.length) throw new Error(`Posthorse could not read persisted Pi settings: ${errors.map((error) => error.error.message).join("; ")}`);
|
|
908
|
-
return settings.getCompactionSettings(ctx.model);
|
|
909
34
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
getCompactionSettings: () => settings ??= { ...(livePolicy ?? getPolicy(ctx)) },
|
|
926
|
-
getContextUsage: () => {
|
|
927
|
-
if (!usageRead) {
|
|
928
|
-
usage = ctx.getContextUsage();
|
|
929
|
-
usageRead = true;
|
|
930
|
-
}
|
|
931
|
-
return usage;
|
|
932
|
-
},
|
|
933
|
-
getSystemPrompt: () => ctx.getSystemPrompt(),
|
|
934
|
-
};
|
|
935
|
-
};
|
|
936
|
-
type ReminderState = {
|
|
937
|
-
manager: ExtensionContext["sessionManager"];
|
|
938
|
-
sessionId: string;
|
|
939
|
-
file: string | undefined;
|
|
940
|
-
leaf: EntryLike | undefined;
|
|
941
|
-
windowId: string;
|
|
942
|
-
reminders: EntryLike[];
|
|
943
|
-
};
|
|
944
|
-
let reminderState: ReminderState | undefined;
|
|
945
|
-
const remindersFor = (ctx: ExtensionContext): ReminderState => {
|
|
946
|
-
const manager = ctx.sessionManager;
|
|
947
|
-
const sessionId = manager.getSessionId(), file = manager.getSessionFile();
|
|
948
|
-
const leaf = manager.getEntry(manager.getLeafId() ?? "") as EntryLike | undefined;
|
|
949
|
-
const previous = reminderState?.manager === manager && reminderState.sessionId === sessionId && reminderState.file === file
|
|
950
|
-
? reminderState : undefined;
|
|
951
|
-
if (previous && previous.leaf === leaf) return previous;
|
|
952
|
-
const reversed: EntryLike[] = [];
|
|
953
|
-
let boundary: EntryLike | undefined, window: EntryLike | undefined;
|
|
954
|
-
let kept = false;
|
|
955
|
-
for (let entry = leaf; entry; entry = entry.parentId ? manager.getEntry(entry.parentId) as EntryLike | undefined : undefined) {
|
|
956
|
-
// Certify append-only ancestry by entry identity, not just a reusable id.
|
|
957
|
-
if (!boundary && previous?.leaf === entry) {
|
|
958
|
-
return reminderState = { manager, sessionId, file, leaf, windowId: previous.windowId,
|
|
959
|
-
reminders: [...previous.reminders, ...reversed.reverse().filter((entry) => entry.type === "custom_message" && isReminderType(entry.customType))] };
|
|
960
|
-
}
|
|
961
|
-
reversed.push(entry);
|
|
962
|
-
if (!boundary && (entry.type === "compaction" || entry.type === "context_window")) boundary = entry;
|
|
963
|
-
if (!window && isWindow(entry)) window = entry;
|
|
964
|
-
if (boundary && (boundary.type === "context_window" || entry.id === boundary.firstKeptEntryId)) kept = true;
|
|
965
|
-
// An ordinary compaction can keep a tail preceding the Posthorse boundary.
|
|
966
|
-
// Missing kept ids or a negative window lookup must reach the root once.
|
|
967
|
-
if (window && kept) break;
|
|
968
|
-
}
|
|
969
|
-
const entries = reversed.reverse();
|
|
970
|
-
return reminderState = { manager, sessionId, file, leaf, windowId: window?.id ?? "initial",
|
|
971
|
-
reminders: entries.slice(contextStart(entries).start).filter((entry) => entry.type === "custom_message" && isReminderType(entry.customType)) };
|
|
972
|
-
};
|
|
973
|
-
registerPosthorseMessages(pi);
|
|
974
|
-
const activeToolTokens = () => {
|
|
975
|
-
const active = new Set(pi.getActiveTools());
|
|
976
|
-
return pi
|
|
977
|
-
.getAllTools()
|
|
978
|
-
.filter((tool) => active.has(tool.name))
|
|
979
|
-
.reduce(
|
|
980
|
-
(total, tool) =>
|
|
981
|
-
total +
|
|
982
|
-
Math.ceil(
|
|
983
|
-
safeJsonStringify({ name: tool.name, description: tool.description ?? "", parameters: tool.parameters })
|
|
984
|
-
.length / 4,
|
|
985
|
-
),
|
|
986
|
-
0,
|
|
987
|
-
);
|
|
988
|
-
};
|
|
989
|
-
|
|
990
|
-
let pendingPageTokens = 0;
|
|
991
|
-
let previousPageUsage: number | null | undefined;
|
|
992
|
-
const pageSize = (ctx: PolicyContext, offset: number, imageCount: number, cursor?: string, imageOffset?: number) => {
|
|
993
|
-
const usage = ctx.getContextUsage()?.tokens;
|
|
994
|
-
// Parallel siblings are not in native usage yet; sequential results must not be counted twice.
|
|
995
|
-
if (usage != null && previousPageUsage != null) {
|
|
996
|
-
pendingPageTokens = Math.max(0, pendingPageTokens - Math.max(0, usage - previousPageUsage));
|
|
997
|
-
}
|
|
998
|
-
previousPageUsage = usage;
|
|
999
|
-
const remaining = pageCapacity(ctx, activeToolTokens()) - pendingPageTokens;
|
|
1000
|
-
const chars = Math.min(
|
|
1001
|
-
MAX_PAGE_CHARS,
|
|
1002
|
-
Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
|
|
1003
|
-
);
|
|
1004
|
-
if (chars < MIN_PAGE_CHARS) {
|
|
1005
|
-
const message = `Too little context remains to read a page safely. Call new_context first, then retry ${cursor ? "with the same cursor" : `with offset ${offset}${imageOffset === undefined ? "" : ` and imageOffset ${imageOffset}`}`}.`;
|
|
1006
|
-
// Refusals consume context too. Once even the guidance no longer fits, omit repeated text;
|
|
1007
|
-
// the failed call still carries its original offset and earlier refusals explain the retry.
|
|
1008
|
-
const text = Math.ceil(message.length / 4) <= remaining ? message : "";
|
|
1009
|
-
pendingPageTokens += Math.ceil(text.length / 4);
|
|
1010
|
-
throw new Error(text);
|
|
1011
|
-
}
|
|
1012
|
-
return chars;
|
|
1013
|
-
};
|
|
1014
|
-
const pageResult = (text: string, images: ImageLike[], display: PosthorseDisplay) => {
|
|
1015
|
-
pendingPageTokens += Math.ceil(text.length / 4) + images.length * (ESTIMATED_IMAGE_CHARS / 4);
|
|
1016
|
-
return textResult(text, images, display);
|
|
1017
|
-
};
|
|
1018
|
-
const pageError = (ctx: PolicyContext, message: string, cursor?: string): never => {
|
|
1019
|
-
pageSize(ctx, 0, 0, cursor);
|
|
1020
|
-
pendingPageTokens += Math.ceil(message.length / 4);
|
|
1021
|
-
throw new Error(message);
|
|
1022
|
-
};
|
|
1023
|
-
const notesPage = (ctx: PolicyContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string, header = "") => {
|
|
1024
|
-
const text = rows.length ? rows.join("\n") : empty;
|
|
1025
|
-
const chars = pageSize(ctx, offset, 0) - header.length;
|
|
1026
|
-
// Reserve the complete continuation, even when one path or excerpt needs several pages.
|
|
1027
|
-
const footer = (end: number) => `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`;
|
|
1028
|
-
if (chars <= footer(text.length).length) pageError(ctx, `Too little context remains to include the notes path. Call new_context first, then retry with offset ${offset}.`);
|
|
1029
|
-
if (offset && offset >= text.length) pageError(ctx, "Offset is past the end; restart with offset 0.");
|
|
1030
|
-
let end = Math.min(text.length, offset + chars);
|
|
1031
|
-
let start = 0;
|
|
1032
|
-
const starts: number[] = [];
|
|
1033
|
-
const maxRows = kind === "notes-search" ? 20 : 100;
|
|
1034
|
-
for (const row of rows) {
|
|
1035
|
-
if (start >= end) break;
|
|
1036
|
-
if (start + row.length > offset) starts.push(start);
|
|
1037
|
-
start += row.length + 1;
|
|
1038
|
-
if (starts.length === maxRows) { end = Math.min(end, start - 1); break; }
|
|
1039
|
-
}
|
|
1040
|
-
// A result-count cap can require continuation even when the character cap did not.
|
|
1041
|
-
if (end < text.length) end = Math.min(end, offset + chars - footer(text.length).length);
|
|
1042
|
-
return pageResult(header + text.slice(offset, end) + (end < text.length ? footer(end) : ""), [], { kind, headerLength: header.length, count: starts.filter((start) => start < end).length, page: { offset, end, total: text.length } });
|
|
35
|
+
/** SDK hosts can inject live policy; CLI hosts use the persisted settings snapshot. */
|
|
36
|
+
export function createPosthorse(
|
|
37
|
+
getPolicy: PolicyReader = persistedPolicy,
|
|
38
|
+
): (pi: ExtensionAPI) => void {
|
|
39
|
+
return (pi) => {
|
|
40
|
+
const policy = (ctx: ExtensionContext, livePolicy?: CompactionPolicy) =>
|
|
41
|
+
policySnapshot(ctx, getPolicy, livePolicy);
|
|
42
|
+
const toolTokens = () => activeToolTokens(pi);
|
|
43
|
+
const pages = new PageReservations(toolTokens);
|
|
44
|
+
registerPosthorseMessages(pi);
|
|
45
|
+
registerRollover(pi, policy, toolTokens, () => {
|
|
46
|
+
pages.reset();
|
|
47
|
+
});
|
|
48
|
+
registerNotes(pi, policy, pages);
|
|
49
|
+
registerHistory(pi, policy, pages);
|
|
1043
50
|
};
|
|
1044
|
-
|
|
1045
|
-
resetRequests.clear();
|
|
1046
|
-
pendingPageTokens = 0;
|
|
1047
|
-
previousPageUsage = undefined;
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
pi.on("session_start", () => {
|
|
1051
|
-
resetRequests.clear();
|
|
1052
|
-
pendingPageTokens = 0;
|
|
1053
|
-
previousPageUsage = undefined;
|
|
1054
|
-
reminderState = undefined;
|
|
1055
|
-
});
|
|
1056
|
-
pi.on("session_tree", () => { reminderState = undefined; });
|
|
1057
|
-
|
|
1058
|
-
let runPromptOptions: { forceSystemPrompt?: string } | undefined;
|
|
1059
|
-
pi.on("before_agent_start", (event) => {
|
|
1060
|
-
runPromptOptions = event.systemPromptOptions;
|
|
1061
|
-
});
|
|
1062
|
-
|
|
1063
|
-
// Continuations do not rerun before_agent_start, even after a model switch or reset.
|
|
1064
|
-
pi.on("context_with_system", (event, ctx) => {
|
|
1065
|
-
const guidance = buildGuidance(policy(ctx));
|
|
1066
|
-
const system = event.messages.findLast((message) => message.role === "system" && typeof message.sections?.posthorse === "string")
|
|
1067
|
-
?? event.messages.find((message) => message.role === "system");
|
|
1068
|
-
if (!system || system.role !== "system") return;
|
|
1069
|
-
if (runPromptOptions?.forceSystemPrompt !== undefined) {
|
|
1070
|
-
// ponytail: official Pi applies forced prompts after these hooks; keep guidance in
|
|
1071
|
-
// request-local conversation until the host projects forced text before the hooks.
|
|
1072
|
-
event.messages.splice(1, 0, { role: "custom", customType: "posthorse-guidance", content: guidance, display: false, timestamp: 0 });
|
|
1073
|
-
} else {
|
|
1074
|
-
system.sections = { ...system.sections, posthorse: `<posthorse>\n${guidance}\n</posthorse>` };
|
|
1075
|
-
}
|
|
1076
|
-
});
|
|
1077
|
-
|
|
1078
|
-
pi.on("turn_end", (event, ctx) => {
|
|
1079
|
-
const requested = event.toolResults.find((result) => result.toolName === "new_context" && resetRequests.has(result.toolCallId));
|
|
1080
|
-
const summary = requested ? resetRequests.get(requested.toolCallId) : undefined;
|
|
1081
|
-
resetRequests.clear();
|
|
1082
|
-
const host = policy(ctx);
|
|
1083
|
-
if (
|
|
1084
|
-
event.message.role === "assistant" &&
|
|
1085
|
-
(event.message.stopReason === "error" || event.message.stopReason === "aborted")
|
|
1086
|
-
)
|
|
1087
|
-
return;
|
|
1088
|
-
// Suppress clear successes; turn_end can also include unrelated background errors.
|
|
1089
|
-
// If Pi still rolls over, context filtering below removes any stale reminder.
|
|
1090
|
-
if (summary !== undefined && !event.toolResults.some((result) => result.isError)) {
|
|
1091
|
-
if (event.outcome === "aborted" || ctx.signal?.aborted) return;
|
|
1092
|
-
// Input steered in after execute() checked capacity can leave too little room.
|
|
1093
|
-
if (summary.length > freshPayloadChars(host, activeToolTokens(), event.context.pendingMessages)) {
|
|
1094
|
-
return { entries: [...event.entries, { type: "custom_message" as const, customType: "posthorse-reset-deferred", display: true,
|
|
1095
|
-
content: "Posthorse reset deferred: queued messages leave too little room for the requested handoff. The current context is unchanged; process those messages, then request a shorter handoff." }] };
|
|
1096
|
-
}
|
|
1097
|
-
return { entries: [...event.entries, { type: "compaction" as const, summary, firstKeptEntryId: null, details: { posthorse: 1, reason: "explicit" } }], continue: true };
|
|
1098
|
-
}
|
|
1099
|
-
const usage = host.getContextUsage();
|
|
1100
|
-
if (!usage || usage.tokens == null || usage.contextWindow <= 0) return;
|
|
1101
|
-
const budget = budgetFor(host, usage.contextWindow);
|
|
1102
|
-
if (!budget || !budget.enabled || !budget.supported) return;
|
|
1103
|
-
if (usage.tokens >= budget.rolloverAt) return;
|
|
1104
|
-
const reminderBuffer = Math.min(REMINDER_BUFFER_TOKENS, Math.floor(budget.usable * 0.1));
|
|
1105
|
-
const remindAt = budget.rolloverAt - reminderBuffer;
|
|
1106
|
-
if (usage.tokens < remindAt) return;
|
|
1107
|
-
|
|
1108
|
-
const state = remindersFor(ctx);
|
|
1109
|
-
const fingerprint: ReminderFingerprint = {
|
|
1110
|
-
windowId: state.windowId,
|
|
1111
|
-
contextWindow: budget.contextWindow,
|
|
1112
|
-
reserveTokens: budget.reserveTokens,
|
|
1113
|
-
};
|
|
1114
|
-
// A reminder a compaction summarized away no longer reaches the model.
|
|
1115
|
-
if (hasReminder(state.reminders, fingerprint)) return;
|
|
1116
|
-
pi.sendMessage(
|
|
1117
|
-
{
|
|
1118
|
-
customType: REMINDER_TYPE,
|
|
1119
|
-
content: `[posthorse] Checkpoint now: ${(budget.rolloverAt - usage.tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Stop normal work, ${CHECKPOINT_STEPS}.`,
|
|
1120
|
-
display: true,
|
|
1121
|
-
details: fingerprint,
|
|
1122
|
-
},
|
|
1123
|
-
{ deliverAs: "steer" },
|
|
1124
|
-
);
|
|
1125
|
-
});
|
|
1126
|
-
|
|
1127
|
-
// Filter active input only; raw reminders remain in history for deduplication and recovery.
|
|
1128
|
-
pi.on("context", (event, ctx) => {
|
|
1129
|
-
if (!event.messages.some((message) => message.role === "custom" && isReminderType(message.customType))) return;
|
|
1130
|
-
// The fork's window marker saves a branch walk, but a compaction inside the window drops it.
|
|
1131
|
-
const marker = event.messages.find((message) => message.role === "custom" && message.customType === "context-window");
|
|
1132
|
-
const windowId = marker?.role === "custom"
|
|
1133
|
-
? (marker.details as { windowId?: unknown } | undefined)?.windowId
|
|
1134
|
-
: remindersFor(ctx).windowId;
|
|
1135
|
-
if (typeof windowId !== "string") return;
|
|
1136
|
-
const host = policy(ctx);
|
|
1137
|
-
const budget = budgetFor(host);
|
|
1138
|
-
const fingerprint: ReminderFingerprint = {
|
|
1139
|
-
windowId,
|
|
1140
|
-
contextWindow: budget?.contextWindow,
|
|
1141
|
-
reserveTokens: budget?.reserveTokens,
|
|
1142
|
-
};
|
|
1143
|
-
const enabled = host.getCompactionSettings().enabled;
|
|
1144
|
-
const stale = (message: (typeof event.messages)[number]) =>
|
|
1145
|
-
message.role === "custom" &&
|
|
1146
|
-
isReminderType(message.customType) &&
|
|
1147
|
-
(!enabled || !reminderMatches(message.details, fingerprint));
|
|
1148
|
-
if (event.messages.some(stale)) return { messages: event.messages.filter((message) => !stale(message)) };
|
|
1149
|
-
});
|
|
1150
|
-
|
|
1151
|
-
// Pi owns thresholds, recovery retries, and whether another request follows.
|
|
1152
|
-
pi.on("session_before_compact", (event, ctx) => {
|
|
1153
|
-
if (event.reason === "manual") return;
|
|
1154
|
-
const cancelled = () => event.signal.aborted || ctx.signal?.aborted;
|
|
1155
|
-
if (cancelled()) return { cancel: true };
|
|
1156
|
-
try {
|
|
1157
|
-
// The event carries Pi's live settings; prefer them over the persisted snapshot.
|
|
1158
|
-
const host = policy(ctx, event.preparation.settings);
|
|
1159
|
-
// Small/unknown context windows deliberately keep Pi's own compaction.
|
|
1160
|
-
if (!budgetFor(host)?.supported) return;
|
|
1161
|
-
const limit = freshPayloadChars(host, activeToolTokens());
|
|
1162
|
-
const ownerQuestionRegistered = pi.getAllTools().some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
|
|
1163
|
-
const handoff = buildAutoHandoff(event.branchEntries, ctx.sessionManager.buildSessionProjection().entries, limit, ownerQuestionRegistered);
|
|
1164
|
-
if (limit < MIN_PAGE_CHARS || handoff.length > limit) throw new Error("Too little fresh context capacity for an automatic recovery record.");
|
|
1165
|
-
if (cancelled()) return { cancel: true };
|
|
1166
|
-
// firstKeptEntryId must name an entry; an invisible sentinel retains no prior conversation.
|
|
1167
|
-
pi.appendEntry("posthorse-boundary", {});
|
|
1168
|
-
const firstKeptEntryId = ctx.sessionManager.getLeafId();
|
|
1169
|
-
if (!firstKeptEntryId) throw new Error("Pi did not persist the context boundary.");
|
|
1170
|
-
return { compaction: { summary: handoff, firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details: { posthorse: 1, reason: event.reason } } };
|
|
1171
|
-
} catch (error) {
|
|
1172
|
-
// Pi swallows handler errors and would then run its summarizer; cancel instead.
|
|
1173
|
-
if (!cancelled()) {
|
|
1174
|
-
const message = `Posthorse automatic rollover cancelled: ${error instanceof Error ? error.message : String(error)}`;
|
|
1175
|
-
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
1176
|
-
else console.error(message);
|
|
1177
|
-
}
|
|
1178
|
-
return { cancel: true };
|
|
1179
|
-
}
|
|
1180
|
-
});
|
|
1181
|
-
|
|
1182
|
-
pi.registerTool({
|
|
1183
|
-
name: "new_context",
|
|
1184
|
-
label: "New Context",
|
|
1185
|
-
...toolCards("new_context"),
|
|
1186
|
-
description:
|
|
1187
|
-
"Start a fresh context window once the current foreground tools succeed; background work continues across the reset. Earlier conversation stays recoverable through history. Put continuation state in handoff, or save fuller state in notes first.",
|
|
1188
|
-
promptSnippet: "request a fresh context window with an optional handoff",
|
|
1189
|
-
parameters: Type.Object({
|
|
1190
|
-
handoff: Type.Optional(
|
|
1191
|
-
Type.String({
|
|
1192
|
-
description: "What the fresh window needs to continue, as readable prose",
|
|
1193
|
-
maxLength: MAX_HANDOFF_CHARS,
|
|
1194
|
-
}),
|
|
1195
|
-
),
|
|
1196
|
-
}),
|
|
1197
|
-
async execute(_id, { handoff }, _signal, _onUpdate, ctx) {
|
|
1198
|
-
const trimmed = handoff?.trim() || undefined;
|
|
1199
|
-
const limit = freshPayloadChars(policy(ctx), activeToolTokens());
|
|
1200
|
-
if (trimmed && trimmed.length > limit) {
|
|
1201
|
-
throw new Error(
|
|
1202
|
-
`Handoff is too large for the active model (${trimmed.length.toLocaleString("en-US")} characters; limit ${limit.toLocaleString("en-US")}). Save fuller state in notes, then retry with a shorter handoff or no handoff.`,
|
|
1203
|
-
);
|
|
1204
|
-
}
|
|
1205
|
-
const result = textResult(
|
|
1206
|
-
"Requested a fresh Pi context after the current foreground tools succeed. Background work continues across the reset. Earlier conversation stays in session history.",
|
|
1207
|
-
[],
|
|
1208
|
-
{ kind: "new-context" },
|
|
1209
|
-
);
|
|
1210
|
-
resetRequests.set(_id, trimmed || EMPTY_HANDOFF);
|
|
1211
|
-
return result;
|
|
1212
|
-
},
|
|
1213
|
-
});
|
|
1214
|
-
|
|
1215
|
-
pi.registerTool({
|
|
1216
|
-
name: "get_context_remaining",
|
|
1217
|
-
label: "Context Remaining",
|
|
1218
|
-
...toolCards("get_context_remaining"),
|
|
1219
|
-
description:
|
|
1220
|
-
"Estimate tokens left before Pi's automatic rollover and the configured context limit. Check before reporting remaining space or changing your work plan because of context limits. A checkpoint reminder already arrives before rollover; no routine checks are needed.",
|
|
1221
|
-
promptSnippet: "verify remaining context before reporting a token count or changing plans because of context limits",
|
|
1222
|
-
parameters: Type.Object({}),
|
|
1223
|
-
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
1224
|
-
const host = policy(ctx);
|
|
1225
|
-
const usage = host.getContextUsage();
|
|
1226
|
-
if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.", [], { kind: "context" });
|
|
1227
|
-
const n = (value: number) => value.toLocaleString("en-US");
|
|
1228
|
-
const budget = budgetFor(host, usage.contextWindow);
|
|
1229
|
-
const display: PosthorseDisplay = {
|
|
1230
|
-
kind: "context", usage,
|
|
1231
|
-
rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
|
|
1232
|
-
rolloverAt: budget?.rolloverAt,
|
|
1233
|
-
};
|
|
1234
|
-
const configured = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens until the configured context limit (${n(usage.tokens)}/${n(usage.contextWindow)} used, ${Math.round(usage.percent ?? 0)}%). Best available native estimate. ${SNAPSHOT_NOTE}`;
|
|
1235
|
-
if (!budget?.enabled) return textResult(`Automatic rollover is disabled in the available settings (Pi compaction.enabled=false). ${configured}`, [], display);
|
|
1236
|
-
if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${configured}`, [], display);
|
|
1237
|
-
return textResult(
|
|
1238
|
-
`≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
|
|
1239
|
-
[], display,
|
|
1240
|
-
);
|
|
1241
|
-
},
|
|
1242
|
-
});
|
|
1243
|
-
|
|
1244
|
-
pi.registerTool({
|
|
1245
|
-
name: "notes",
|
|
1246
|
-
label: "Notes",
|
|
1247
|
-
...toolCards("notes"),
|
|
1248
|
-
description:
|
|
1249
|
-
"Durable notes shared across Git worktrees and context resets. Ops: list (newest first, size/date; optional folder), read, search (case-insensitive text), write (replace; empty clears), append (one newline-terminated record). list/read/search are paged; continue with the returned offset. list shows the absolute notes directory; read shows the absolute file path. Prefer available file-editing tools for changed sections of one concise current-state note; write for creation or substantial restructuring. Link fuller evidence instead of copying it.",
|
|
1250
|
-
promptSnippet: "save and recall durable state that survives context resets",
|
|
1251
|
-
parameters: Type.Object({
|
|
1252
|
-
op: Type.Union(
|
|
1253
|
-
[Type.Literal("list"), Type.Literal("read"), Type.Literal("write"), Type.Literal("append"), Type.Literal("search")],
|
|
1254
|
-
{ description: "Operation to perform" },
|
|
1255
|
-
),
|
|
1256
|
-
path: Type.Optional(Type.String({ description: "Note or folder path relative to .pi/notes/" })),
|
|
1257
|
-
content: Type.Optional(Type.String({ description: "Full file content (write) or text to add (append)" })),
|
|
1258
|
-
query: Type.Optional(Type.String({ description: "Substring to find in notes (search)" })),
|
|
1259
|
-
offset: Type.Optional(Type.Integer({ description: "Character offset for list/read/search (default 0)", minimum: 0 })),
|
|
1260
|
-
}),
|
|
1261
|
-
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1262
|
-
const dir = join(notesRoot(ctx.cwd), ".pi", "notes");
|
|
1263
|
-
const safeJoin = (path: string) => {
|
|
1264
|
-
const relative = normalize(path.replace(/^[/\\]+/, ""));
|
|
1265
|
-
if (relative === ".." || relative.startsWith(`..${sep}`) || isAbsolute(relative)) {
|
|
1266
|
-
throw new Error(`Invalid path "${path}": must stay inside .pi/notes/.`);
|
|
1267
|
-
}
|
|
1268
|
-
return join(dir, relative);
|
|
1269
|
-
};
|
|
1270
|
-
const walk = (directory: string, output: NoteFile[], ancestors = new Set<string>()) => {
|
|
1271
|
-
const root = realpathSync(directory);
|
|
1272
|
-
if (ancestors.has(root)) return;
|
|
1273
|
-
ancestors.add(root);
|
|
1274
|
-
for (const file of readdirSync(directory)) {
|
|
1275
|
-
const path = join(directory, file);
|
|
1276
|
-
const info = statSync(path, { throwIfNoEntry: false });
|
|
1277
|
-
if (!info) continue;
|
|
1278
|
-
if (info.isDirectory()) walk(path, output, ancestors);
|
|
1279
|
-
else output.push({ path, size: info.size, mtimeMs: info.mtimeMs });
|
|
1280
|
-
}
|
|
1281
|
-
ancestors.delete(root);
|
|
1282
|
-
};
|
|
1283
|
-
const notesUnder = (target: string): NoteFile[] => {
|
|
1284
|
-
const info = statSync(target, { throwIfNoEntry: false });
|
|
1285
|
-
if (!info) return [];
|
|
1286
|
-
const files: NoteFile[] = [];
|
|
1287
|
-
if (info.isDirectory()) walk(target, files);
|
|
1288
|
-
else files.push({ path: target, size: info.size, mtimeMs: info.mtimeMs });
|
|
1289
|
-
return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1290
|
-
};
|
|
1291
|
-
const list = (folder?: string) => notesPage(
|
|
1292
|
-
policy(ctx),
|
|
1293
|
-
notesUnder(folder ? safeJoin(folder) : dir).map((file) =>
|
|
1294
|
-
`${file.path.slice(dir.length + 1)} ${formatSize(file.size)} ${new Date(file.mtimeMs).toISOString().slice(0, 16)}Z`),
|
|
1295
|
-
params.offset ?? 0,
|
|
1296
|
-
"notes-list",
|
|
1297
|
-
folder ? `(no notes in ${folder})` : "(no notes yet)",
|
|
1298
|
-
`Notes directory: ${dir}\n`,
|
|
1299
|
-
);
|
|
1300
|
-
|
|
1301
|
-
switch (params.op) {
|
|
1302
|
-
case "list":
|
|
1303
|
-
return list(params.path);
|
|
1304
|
-
case "read": {
|
|
1305
|
-
const relative = requireValue(params.path, "path", params.op);
|
|
1306
|
-
const path = safeJoin(relative);
|
|
1307
|
-
const info = statSync(path, { throwIfNoEntry: false });
|
|
1308
|
-
if (!info) throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
|
|
1309
|
-
if (info.isDirectory()) return list(relative);
|
|
1310
|
-
if (await isBinaryFile(path)) throw new Error(`${relative} is a binary file (${formatSize(info.size)}); notes read returns text only.`);
|
|
1311
|
-
const text = readFileSync(path, "utf8");
|
|
1312
|
-
const offset = params.offset ?? 0;
|
|
1313
|
-
if (offset && offset >= text.length) {
|
|
1314
|
-
throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
|
|
1315
|
-
}
|
|
1316
|
-
const header = `File: ${path}\n`;
|
|
1317
|
-
const host = policy(ctx);
|
|
1318
|
-
const chars = pageSize(host, offset, 0) - header.length;
|
|
1319
|
-
if (chars <= 0) pageError(host, `Too little context remains to include the note path. Call new_context first, then retry with offset ${offset}.`);
|
|
1320
|
-
const end = Math.min(text.length, offset + chars);
|
|
1321
|
-
const more =
|
|
1322
|
-
end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
|
|
1323
|
-
return pageResult(`${header}${text.slice(offset, end)}${more}`, [], { kind: "note-read", headerLength: header.length, offset, end, total: text.length });
|
|
1324
|
-
}
|
|
1325
|
-
case "write": {
|
|
1326
|
-
const relative = requireValue(params.path, "path", params.op);
|
|
1327
|
-
if (params.content === undefined) throw new Error(`"content" is required for op "write" (use "" to clear a note).`);
|
|
1328
|
-
const path = safeJoin(relative);
|
|
1329
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1330
|
-
await withFileMutationQueue(path, () => publishFile(path, params.content!, signal));
|
|
1331
|
-
return textResult(`Wrote ${path}`, [], { kind: "note-write" });
|
|
1332
|
-
}
|
|
1333
|
-
case "append": {
|
|
1334
|
-
const relative = requireValue(params.path, "path", params.op);
|
|
1335
|
-
const content = requireValue(params.content, "content", params.op);
|
|
1336
|
-
const path = safeJoin(relative);
|
|
1337
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1338
|
-
// One O_APPEND write per newline-terminated record, so concurrent Pi processes appending to a
|
|
1339
|
-
// shared note never merge records. The separator only matters after a write that left no trailing
|
|
1340
|
-
// newline; a torn read of another process's in-flight append costs at most one blank line.
|
|
1341
|
-
await withFileMutationQueue(path, async () => {
|
|
1342
|
-
signal?.throwIfAborted();
|
|
1343
|
-
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
1344
|
-
const separator = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
1345
|
-
appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
|
|
1346
|
-
});
|
|
1347
|
-
return textResult(`Appended to ${path}`, [], { kind: "note-append" });
|
|
1348
|
-
}
|
|
1349
|
-
case "search": {
|
|
1350
|
-
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
1351
|
-
const hits: string[] = [];
|
|
1352
|
-
for (const { path: file } of notesUnder(dir)) {
|
|
1353
|
-
if (await isBinaryFile(file)) continue;
|
|
1354
|
-
let number = 0;
|
|
1355
|
-
for await (const line of lfLines(file, signal)) {
|
|
1356
|
-
number++;
|
|
1357
|
-
const trimmed = line.trim();
|
|
1358
|
-
const match = trimmed.toLowerCase().indexOf(query);
|
|
1359
|
-
if (match !== -1) {
|
|
1360
|
-
hits.push(`${file.slice(dir.length + 1)}:${number}: ${excerptAround(trimmed, match, 50, 200)}`);
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
return notesPage(policy(ctx), hits, params.offset ?? 0, "notes-search", `No notes match "${excerpt(params.query!, 200)}".`);
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
},
|
|
1368
|
-
});
|
|
1369
|
-
|
|
1370
|
-
pi.registerTool({
|
|
1371
|
-
name: "history",
|
|
1372
|
-
label: "History",
|
|
1373
|
-
...toolCards("history"),
|
|
1374
|
-
description:
|
|
1375
|
-
"Search or read earlier session entries, including previous context windows. search skips entries still in your active context and lists original content before recovery echoes, newest first; all=true also searches this project's other sessions and subagent runs. read takes an entry id exactly as search printed it and pages text and images.",
|
|
1376
|
-
promptSnippet: "recover earlier conversation that left the active context window",
|
|
1377
|
-
promptGuidelines: ["Use history search first, then history read with the entry id exactly as printed"],
|
|
1378
|
-
parameters: Type.Object({
|
|
1379
|
-
op: Type.Union([Type.Literal("search"), Type.Literal("read")], { description: "Operation to perform" }),
|
|
1380
|
-
query: Type.Optional(Type.String({ description: "Case-insensitive text to find (search)" })),
|
|
1381
|
-
id: Type.Optional(Type.String({ description: "Entry id exactly as search printed it (read)" })),
|
|
1382
|
-
all: Type.Optional(Type.Boolean({ description: "Also search this project's other sessions and subagent runs" })),
|
|
1383
|
-
limit: Type.Optional(Type.Integer({ description: "Maximum search results per page (default 10, max 50)", minimum: 1, maximum: 50 })),
|
|
1384
|
-
cursor: Type.Optional(Type.String({ description: "Continuation from the previous search page; keep query and all unchanged", maxLength: 512 })),
|
|
1385
|
-
offset: Type.Optional(Type.Integer({ description: "Character offset for read (default 0)", minimum: 0 })),
|
|
1386
|
-
imageOffset: Type.Optional(Type.Integer({ description: "First image to return (read); use the value from the continuation", minimum: 0 })),
|
|
1387
|
-
}),
|
|
1388
|
-
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1389
|
-
const manager = ctx.sessionManager;
|
|
1390
|
-
const cwd = resolve(ctx.cwd);
|
|
1391
|
-
const currentFile = manager.getSessionFile?.();
|
|
1392
|
-
const dir = manager.getSessionDir();
|
|
1393
|
-
|
|
1394
|
-
if (params.op === "search") {
|
|
1395
|
-
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
1396
|
-
const limit = params.limit ?? 10;
|
|
1397
|
-
const searchKey = shortKey(JSON.stringify([query, params.all === true]));
|
|
1398
|
-
// Projections show replacements and stripped async calls under the original id, so an entry is in
|
|
1399
|
-
// context only when all of its own text is visible there. Posthorse strips stale reminders from input.
|
|
1400
|
-
const seen = (message: MessageLike) => [
|
|
1401
|
-
textOf(message), assistantFailure(message), imageSummary(imagesOf(message.content)), message.summary, message.command, message.output,
|
|
1402
|
-
].filter(Boolean).join("\n").toLowerCase();
|
|
1403
|
-
const visible = new Map((manager.buildSessionProjection().entries as readonly ProjectedEntry[]).flatMap(({ sourceEntry, messages }) =>
|
|
1404
|
-
sourceEntry.id && messages.length ? [[sourceEntry.id, messages.map(seen).join("\n")] as const] : []));
|
|
1405
|
-
const inContext = (entry: EntryLike) => {
|
|
1406
|
-
if (entry.type === "custom_message" && isReminderType(entry.customType)) return false;
|
|
1407
|
-
const own = entry.type === "message" ? entry.message ?? {}
|
|
1408
|
-
: entry.type === "context_edit" ? { content: entry.replacement?.content }
|
|
1409
|
-
: entry.type === "context_window" ? { content: entry.handoff }
|
|
1410
|
-
: { content: entry.content, summary: entry.summary };
|
|
1411
|
-
return visible.get((entry.type === "context_edit" ? entry.targetId : entry.id) ?? "")?.includes(seen(own)) === true;
|
|
1412
|
-
};
|
|
1413
|
-
let skipped = 0;
|
|
1414
|
-
let cursor: [string, 0 | 1, number, string] | undefined;
|
|
1415
|
-
if (params.cursor) {
|
|
1416
|
-
try {
|
|
1417
|
-
const value = JSON.parse(Buffer.from(params.cursor, "base64url").toString());
|
|
1418
|
-
if (!Array.isArray(value) || value.length !== 4 || typeof value[0] !== "string" || !value[0] || ![0, 1].includes(value[1]) || !Number.isSafeInteger(value[2]) || value[2] < 0 || value[3] !== searchKey) throw new Error();
|
|
1419
|
-
cursor = value as typeof cursor;
|
|
1420
|
-
} catch { pageError(policy(ctx), "Invalid history cursor or changed query/scope; restart the search without it.", params.cursor); }
|
|
1421
|
-
}
|
|
1422
|
-
const hits: HistoryHit[][] = [[], []];
|
|
1423
|
-
let found = !cursor;
|
|
1424
|
-
const addHit = (hit: HistoryHit | undefined) => {
|
|
1425
|
-
if (!hit) return;
|
|
1426
|
-
if (cursor && hit.priority < cursor[1]) return;
|
|
1427
|
-
if (cursor && hit.priority === cursor[1] && !found) {
|
|
1428
|
-
if (hit.id !== cursor[0]) return;
|
|
1429
|
-
found = true;
|
|
1430
|
-
if (cursor[2] > hit.text.length) pageError(policy(ctx), "History cursor is past the entry; restart the search without it.", params.cursor);
|
|
1431
|
-
if (cursor[2] === hit.text.length) return;
|
|
1432
|
-
}
|
|
1433
|
-
if (hits[hit.priority].length <= limit) hits[hit.priority].push(hit);
|
|
1434
|
-
};
|
|
1435
|
-
|
|
1436
|
-
if (params.all) {
|
|
1437
|
-
const needles = prefilterNeedles(query);
|
|
1438
|
-
const candidates = needles && await filesContaining(dir, needles, signal);
|
|
1439
|
-
const current = currentFile && resolve(currentFile);
|
|
1440
|
-
for await (const file of scopedSessionFiles(dir, cwd, currentFile, signal, { candidates })) {
|
|
1441
|
-
const recent: HistoryHit[][] = [[], []];
|
|
1442
|
-
let anchorHere = false;
|
|
1443
|
-
const source = relative(dir, file);
|
|
1444
|
-
const inCurrent = resolve(file) === current;
|
|
1445
|
-
for await (const item of sessionWindowEntries(file, signal)) {
|
|
1446
|
-
const hit = historyHit(item, query, source);
|
|
1447
|
-
if (!hit) continue;
|
|
1448
|
-
if (inCurrent && inContext(item.entry)) {
|
|
1449
|
-
skipped++;
|
|
1450
|
-
continue;
|
|
1451
|
-
}
|
|
1452
|
-
const seeking = cursor && !found && hit.priority === cursor[1];
|
|
1453
|
-
if (seeking && anchorHere) continue;
|
|
1454
|
-
if (seeking && hit.id === cursor?.[0]) anchorHere = true;
|
|
1455
|
-
const group = recent[hit.priority];
|
|
1456
|
-
group.push(hit);
|
|
1457
|
-
// Keep one page plus its anchor and lookahead, not every matching excerpt.
|
|
1458
|
-
if (group.length > limit + 2) group.shift();
|
|
1459
|
-
}
|
|
1460
|
-
if (cursor && !found && !anchorHere) recent[cursor[1]] = [];
|
|
1461
|
-
for (const group of recent) for (const hit of group.reverse()) addHit(hit);
|
|
1462
|
-
if (hits[0].length > limit) break;
|
|
1463
|
-
}
|
|
1464
|
-
} else {
|
|
1465
|
-
const current = [...windowEntries(manager.getBranch() as EntryLike[])];
|
|
1466
|
-
for (const item of current.reverse()) {
|
|
1467
|
-
const hit = historyHit(item, query);
|
|
1468
|
-
if (!hit) continue;
|
|
1469
|
-
if (inContext(item.entry)) {
|
|
1470
|
-
skipped++;
|
|
1471
|
-
continue;
|
|
1472
|
-
}
|
|
1473
|
-
addHit(hit);
|
|
1474
|
-
if (hits[0].length > limit) break;
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
const host = policy(ctx);
|
|
1478
|
-
const chars = pageSize(host, 0, 0, params.cursor);
|
|
1479
|
-
if (!found) pageError(host, "History cursor entry no longer matches; restart the search without it.", params.cursor);
|
|
1480
|
-
const results = hits.flat();
|
|
1481
|
-
const cursorFor = (hit: HistoryHit, offset: number) => Buffer.from(JSON.stringify([hit.id, hit.priority, offset, searchKey])).toString("base64url");
|
|
1482
|
-
const footer = (next: string) => `\n[More results; continue with cursor "${next}" and the same query/scope.]`;
|
|
1483
|
-
const reserve = Math.max(0, ...results.map((hit) => footer(cursorFor(hit, hit.text.length)).length));
|
|
1484
|
-
if (reserve >= chars) pageError(host, "History entry id is too large for pagination.", params.cursor);
|
|
1485
|
-
const skippedNote = skipped && !params.cursor
|
|
1486
|
-
? `\n[Skipped ${skipped} match${skipped === 1 ? "" : "es"} already in your active context.]`
|
|
1487
|
-
: "";
|
|
1488
|
-
const parts: string[] = [];
|
|
1489
|
-
const spans: Array<{ headerLength: number; length: number }> = [];
|
|
1490
|
-
let available = chars - reserve - skippedNote.length;
|
|
1491
|
-
let next: string | undefined;
|
|
1492
|
-
for (const hit of results) {
|
|
1493
|
-
const offset = cursor?.[0] === hit.id ? cursor[2] : 0;
|
|
1494
|
-
if (parts.length && hit.text.length - offset > available) break;
|
|
1495
|
-
// A split header or excerpt must still identify the native entry on every page.
|
|
1496
|
-
const prefix = offset || hit.text.length > available ? `[entry ${hit.id}; from char ${offset}] ` : "";
|
|
1497
|
-
if (prefix.length >= available) break;
|
|
1498
|
-
const end = Math.min(hit.text.length, offset + available - prefix.length);
|
|
1499
|
-
const part = prefix + hit.text.slice(offset, end);
|
|
1500
|
-
parts.push(part);
|
|
1501
|
-
spans.push({ headerLength: prefix.length + Math.max(0, Math.min(hit.headerLength - offset, end - offset)), length: part.length });
|
|
1502
|
-
available -= part.length + 1;
|
|
1503
|
-
next = end < hit.text.length || parts.length < results.length ? cursorFor(hit, end) : undefined;
|
|
1504
|
-
if (available <= 0 || parts.length >= limit) break;
|
|
1505
|
-
}
|
|
1506
|
-
const body = parts.length ? parts.join("\n") : `No history matches "${excerpt(params.query!, 200)}".`;
|
|
1507
|
-
const tail = `${skippedNote}${next ? footer(next) : ""}`;
|
|
1508
|
-
return pageResult(body + tail, [], { kind: "history-search", entries: spans, footerLength: tail.length, more: next !== undefined, skipped });
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
const id = requireValue(params.id, "id", params.op);
|
|
1512
|
-
const separator = id.indexOf("@");
|
|
1513
|
-
const entryId = separator < 0 ? id : id.slice(0, separator);
|
|
1514
|
-
const fileKey = separator < 0 ? undefined : id.slice(separator + 1);
|
|
1515
|
-
const formatEntry = (item: WindowedEntry, source = "") => {
|
|
1516
|
-
// Session-file reads print the qualified id, so continuations reopen the same file.
|
|
1517
|
-
const shownId = source ? `${item.entry.id}@${shortKey(source)}` : id;
|
|
1518
|
-
const offset = params.offset ?? 0;
|
|
1519
|
-
const imageOffset = params.imageOffset ?? (offset === 0 ? 0 : item.images.length);
|
|
1520
|
-
if (imageOffset > item.images.length) {
|
|
1521
|
-
throw new Error(`Image offset ${imageOffset} is past the end of history entry "${id}" (${item.images.length} images).`);
|
|
1522
|
-
}
|
|
1523
|
-
if (offset > item.text.length || (offset === item.text.length && imageOffset === item.images.length)) {
|
|
1524
|
-
throw new Error(`Offset ${offset} is past the end of history entry "${id}" (${item.text.length} chars).`);
|
|
1525
|
-
}
|
|
1526
|
-
// Reserve one image first so an image-only continuation either advances or asks for fresh context.
|
|
1527
|
-
const firstImage = imageOffset < item.images.length ? 1 : 0;
|
|
1528
|
-
const chars = pageSize(policy(ctx), offset, firstImage, undefined, item.images.length ? imageOffset : undefined);
|
|
1529
|
-
const imageEnd = Math.min(item.images.length, imageOffset + firstImage + Math.floor((chars - MIN_PAGE_CHARS) / ESTIMATED_IMAGE_CHARS));
|
|
1530
|
-
const images = item.images.slice(imageOffset, imageEnd);
|
|
1531
|
-
const end = Math.min(
|
|
1532
|
-
item.text.length,
|
|
1533
|
-
offset + chars - (images.length - firstImage) * ESTIMATED_IMAGE_CHARS,
|
|
1534
|
-
);
|
|
1535
|
-
const more = end < item.text.length || imageEnd < item.images.length
|
|
1536
|
-
? `\nMore remains; call history read with id "${shownId}" and offset ${end}${item.images.length ? ` and imageOffset ${imageEnd}` : ""}.` : "";
|
|
1537
|
-
const header = `${item.entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${shownId}] [chars ${offset}-${end} of ${item.text.length}] `;
|
|
1538
|
-
return pageResult(
|
|
1539
|
-
`${header}${item.text.slice(offset, end)}${more}`,
|
|
1540
|
-
images,
|
|
1541
|
-
{ kind: "history-read", headerLength: header.length, offset, end, total: item.text.length, imageOffset, imageEnd, imageTotal: item.images.length },
|
|
1542
|
-
);
|
|
1543
|
-
};
|
|
1544
|
-
|
|
1545
|
-
if (fileKey === undefined) {
|
|
1546
|
-
for (const item of windowEntries(manager.getBranch() as EntryLike[])) {
|
|
1547
|
-
if (item.entry.id === entryId) return formatEntry(item);
|
|
1548
|
-
}
|
|
1549
|
-
}
|
|
1550
|
-
// Bare ids from other sessions reach here through older notes and handoffs.
|
|
1551
|
-
const candidates = fileKey === undefined && /^[\w-]+$/.test(entryId)
|
|
1552
|
-
? await filesContaining(dir, [`"id":"${entryId}"`], signal)
|
|
1553
|
-
: undefined;
|
|
1554
|
-
for await (const file of scopedSessionFiles(dir, cwd, currentFile, signal, { fileKey, candidates })) {
|
|
1555
|
-
const fileSource = relative(dir, file);
|
|
1556
|
-
for await (const item of sessionWindowEntries(file, signal)) {
|
|
1557
|
-
if (item.entry.id === entryId) return formatEntry(item, fileSource);
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
throw new Error(`No history entry "${id}" in this session or this project's other sessions. Pass an id exactly as history search prints it, such as 1a2f07de, or 1a2f07de@Ab3dE5fG7h from another session; search first when you only have text or a timestamp.`);
|
|
1561
|
-
},
|
|
1562
|
-
});
|
|
1563
|
-
};
|
|
1564
|
-
|
|
51
|
+
}
|
|
1565
52
|
export default createPosthorse();
|