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