pi-posthorse 0.7.0 → 0.8.1

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.
@@ -0,0 +1,330 @@
1
+ import type {
2
+ BoundaryResult,
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ TurnEndEvent,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ budgetFor,
9
+ buildGuidance,
10
+ CHECKPOINT_STEPS,
11
+ freshPayloadChars,
12
+ type Budget,
13
+ } from "./budget.ts";
14
+ import {
15
+ isRecord,
16
+ isReminderType,
17
+ MIN_PAGE_CHARS,
18
+ REMINDER_TYPE,
19
+ type CompactionPolicy,
20
+ type PolicyContext,
21
+ type ReminderFingerprint,
22
+ } from "./contracts.ts";
23
+ import { registerContextTools } from "./context-tools.ts";
24
+ import { safeJsonStringify } from "./message-text.ts";
25
+ import { buildAutoHandoff } from "./recovery.ts";
26
+ import {
27
+ createReminderLookup,
28
+ hasReminder,
29
+ reminderMatches,
30
+ type ReminderLookup,
31
+ } from "./reminders.ts";
32
+
33
+ export type SnapshotReader = (
34
+ ctx: ExtensionContext,
35
+ livePolicy?: CompactionPolicy,
36
+ ) => PolicyContext;
37
+ const REMINDER_BUFFER_TOKENS = 32_000;
38
+ function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
39
+ let promptOptions: { readonly forceSystemPrompt?: string } | undefined;
40
+ pi.on("before_agent_start", (event) => {
41
+ promptOptions = event.systemPromptOptions;
42
+ });
43
+ pi.on("context_with_system", (event, ctx) => {
44
+ const guidance = buildGuidance(policy(ctx));
45
+ const system =
46
+ event.messages.findLast(
47
+ (message) =>
48
+ message.role === "system" && typeof message.sections?.posthorse === "string",
49
+ ) ?? event.messages.find((message) => message.role === "system");
50
+ if (system === undefined || system.role !== "system") {
51
+ return;
52
+ }
53
+ if (promptOptions?.forceSystemPrompt !== undefined) {
54
+ // ponytail: Pi applies forced prompts after this hook; keep request-local guidance
55
+ // until the host projects forced text before context_with_system.
56
+ event.messages.splice(1, 0, {
57
+ role: "custom",
58
+ customType: "posthorse-guidance",
59
+ content: guidance,
60
+ display: false,
61
+ timestamp: 0,
62
+ });
63
+ } else {
64
+ system.sections = {
65
+ ...system.sections,
66
+ posthorse: `<posthorse>\n${guidance}\n</posthorse>`,
67
+ };
68
+ }
69
+ });
70
+ }
71
+ function explicitReset(
72
+ event: TurnEndEvent,
73
+ ctx: ExtensionContext,
74
+ summary: string,
75
+ limit: () => number,
76
+ ): BoundaryResult | undefined {
77
+ if (event.outcome === "aborted" || ctx.signal?.aborted === true) {
78
+ return undefined;
79
+ }
80
+ if (summary.length > limit()) {
81
+ return {
82
+ entries: [
83
+ ...event.entries,
84
+ {
85
+ type: "custom_message",
86
+ customType: "posthorse-reset-deferred",
87
+ display: true,
88
+ content:
89
+ "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.",
90
+ },
91
+ ],
92
+ };
93
+ }
94
+ return {
95
+ entries: [
96
+ ...event.entries,
97
+ {
98
+ type: "compaction",
99
+ summary,
100
+ firstKeptEntryId: null,
101
+ details: { posthorse: 1, reason: "explicit" },
102
+ },
103
+ ],
104
+ continue: true,
105
+ };
106
+ }
107
+ function reminderBudget(
108
+ host: PolicyContext,
109
+ ): { readonly budget: Budget; readonly tokens: number } | undefined {
110
+ const usage = host.getContextUsage();
111
+ if (usage === undefined || usage.tokens === null || usage.contextWindow <= 0) {
112
+ return undefined;
113
+ }
114
+ const budget = budgetFor(host, usage.contextWindow);
115
+ if (
116
+ budget === undefined ||
117
+ !budget.enabled ||
118
+ !budget.supported ||
119
+ usage.tokens >= budget.rolloverAt
120
+ ) {
121
+ return undefined;
122
+ }
123
+ const buffer = Math.min(REMINDER_BUFFER_TOKENS, Math.floor(budget.usable * 0.1));
124
+ return usage.tokens < budget.rolloverAt - buffer ? undefined : { budget, tokens: usage.tokens };
125
+ }
126
+ function sendReminder(
127
+ pi: ExtensionAPI,
128
+ ctx: ExtensionContext,
129
+ available: { readonly budget: Budget; readonly tokens: number },
130
+ reminders: ReminderLookup,
131
+ ): void {
132
+ const { budget, tokens } = available;
133
+ const state = reminders.read(ctx);
134
+ const fingerprint: ReminderFingerprint = {
135
+ windowId: state.windowId,
136
+ contextWindow: budget.contextWindow,
137
+ reserveTokens: budget.reserveTokens,
138
+ };
139
+ if (hasReminder(state.reminders, fingerprint)) {
140
+ return;
141
+ }
142
+ pi.sendMessage(
143
+ {
144
+ customType: REMINDER_TYPE,
145
+ content: `[posthorse] Checkpoint now: ${(budget.rolloverAt - tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Stop normal work, ${CHECKPOINT_STEPS}.`,
146
+ display: true,
147
+ details: fingerprint,
148
+ },
149
+ { deliverAs: "steer" },
150
+ );
151
+ }
152
+ function registerReminderFilter(
153
+ pi: ExtensionAPI,
154
+ policy: SnapshotReader,
155
+ reminders: ReminderLookup,
156
+ ): void {
157
+ pi.on("context", (event, ctx) => {
158
+ if (
159
+ !event.messages.some(
160
+ (message) => message.role === "custom" && isReminderType(message.customType),
161
+ )
162
+ ) {
163
+ return;
164
+ }
165
+ const marker = event.messages.find(
166
+ (message) => message.role === "custom" && message.customType === "context-window",
167
+ );
168
+ let windowId: unknown;
169
+ if (marker?.role === "custom") {
170
+ windowId = isRecord(marker.details) ? marker.details.windowId : undefined;
171
+ } else {
172
+ windowId = reminders.read(ctx).windowId;
173
+ }
174
+ if (typeof windowId !== "string") {
175
+ return;
176
+ }
177
+ const host = policy(ctx);
178
+ const budget = budgetFor(host);
179
+ const fingerprint: ReminderFingerprint = {
180
+ windowId,
181
+ contextWindow: budget?.contextWindow,
182
+ reserveTokens: budget?.reserveTokens,
183
+ };
184
+ const enabled = host.getCompactionSettings().enabled;
185
+ const stale = (message: {
186
+ readonly role: string;
187
+ readonly customType?: string;
188
+ readonly details?: unknown;
189
+ }) =>
190
+ message.role === "custom" &&
191
+ isReminderType(message.customType) &&
192
+ (!enabled || !reminderMatches(message.details, fingerprint));
193
+ if (event.messages.some(stale)) {
194
+ return { messages: event.messages.filter((message) => !stale(message)) };
195
+ }
196
+ return;
197
+ });
198
+ }
199
+ function automaticDraft(
200
+ pi: ExtensionAPI,
201
+ ctx: ExtensionContext,
202
+ recovery: { readonly handoff: string; readonly tokensBefore: number; readonly reason: string },
203
+ ) {
204
+ // Public compaction requires an entry id. An invisible sentinel keeps no prior conversation.
205
+ pi.appendEntry("posthorse-boundary", {});
206
+ const firstKeptEntryId = ctx.sessionManager.getLeafId();
207
+ if (firstKeptEntryId === null || firstKeptEntryId === "") {
208
+ throw new Error("Pi did not persist the context boundary.");
209
+ }
210
+ return {
211
+ compaction: {
212
+ summary: recovery.handoff,
213
+ firstKeptEntryId,
214
+ tokensBefore: recovery.tokensBefore,
215
+ details: { posthorse: 1, reason: recovery.reason },
216
+ },
217
+ };
218
+ }
219
+ function reportAutomaticFailure(ctx: ExtensionContext, error: unknown): void {
220
+ const detail = error instanceof Error ? error.message : safeJsonStringify(error);
221
+ const message = `Posthorse automatic rollover cancelled: ${detail}`;
222
+ if (ctx.hasUI) {
223
+ ctx.ui.notify(message, "error");
224
+ } else {
225
+ console.error(message);
226
+ }
227
+ }
228
+ function registerAutomaticRollover(
229
+ pi: ExtensionAPI,
230
+ policy: SnapshotReader,
231
+ activeToolTokens: () => number,
232
+ ): void {
233
+ pi.on("session_before_compact", (event, ctx) => {
234
+ if (event.reason === "manual") {
235
+ return;
236
+ }
237
+ const cancelled = () => event.signal.aborted || ctx.signal?.aborted === true;
238
+ if (cancelled()) {
239
+ return { cancel: true };
240
+ }
241
+ try {
242
+ const host = policy(ctx, event.preparation.settings);
243
+ if (budgetFor(host)?.supported !== true) {
244
+ return;
245
+ }
246
+ const limit = freshPayloadChars(host, activeToolTokens());
247
+ const ownerQuestionRegistered = pi
248
+ .getAllTools()
249
+ .some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
250
+ const handoff = buildAutoHandoff(
251
+ event.branchEntries,
252
+ ctx.sessionManager.buildSessionProjection().entries,
253
+ limit,
254
+ ownerQuestionRegistered,
255
+ );
256
+ if (limit < MIN_PAGE_CHARS || handoff.length > limit) {
257
+ throw new Error(
258
+ "Too little fresh context capacity for an automatic recovery record.",
259
+ );
260
+ }
261
+ if (cancelled()) {
262
+ return { cancel: true };
263
+ }
264
+ return automaticDraft(pi, ctx, {
265
+ handoff,
266
+ tokensBefore: event.preparation.tokensBefore,
267
+ reason: event.reason,
268
+ });
269
+ } catch (error) {
270
+ // Hook errors are swallowed by Pi, which would run its summarizer; cancel instead.
271
+ if (!cancelled()) {
272
+ reportAutomaticFailure(ctx, error);
273
+ }
274
+ return { cancel: true };
275
+ }
276
+ });
277
+ }
278
+ /** Owns per-turn reset requests and leaf-certified reminder state. Pi owns persistence and scheduling. */
279
+ export function registerRollover(
280
+ pi: ExtensionAPI,
281
+ policy: SnapshotReader,
282
+ activeToolTokens: () => number,
283
+ resetPages: () => void,
284
+ ): void {
285
+ const resetRequests = new Map<string, string>();
286
+ const reminders = createReminderLookup();
287
+ const resetTurn = () => {
288
+ resetRequests.clear();
289
+ resetPages();
290
+ };
291
+ pi.on("turn_start", resetTurn);
292
+ pi.on("session_start", () => {
293
+ resetTurn();
294
+ reminders.reset();
295
+ });
296
+ pi.on("session_tree", () => {
297
+ reminders.reset();
298
+ });
299
+ registerGuidance(pi, policy);
300
+ registerReminderFilter(pi, policy, reminders);
301
+ registerAutomaticRollover(pi, policy, activeToolTokens);
302
+ pi.on("turn_end", (event, ctx) => {
303
+ const requested = event.toolResults.find(
304
+ (result) => result.toolName === "new_context" && resetRequests.has(result.toolCallId),
305
+ );
306
+ const summary =
307
+ requested === undefined ? undefined : resetRequests.get(requested.toolCallId);
308
+ resetRequests.clear();
309
+ const host = policy(ctx);
310
+ if (
311
+ event.message.role === "assistant" &&
312
+ (event.message.stopReason === "error" || event.message.stopReason === "aborted")
313
+ ) {
314
+ return;
315
+ }
316
+ if (summary !== undefined && !event.toolResults.some((result) => result.isError)) {
317
+ return explicitReset(event, ctx, summary, () =>
318
+ freshPayloadChars(host, activeToolTokens(), event.context.pendingMessages),
319
+ );
320
+ }
321
+ const available = reminderBudget(host);
322
+ if (available !== undefined) {
323
+ sendReminder(pi, ctx, available, reminders);
324
+ }
325
+ return;
326
+ });
327
+ registerContextTools(pi, policy, activeToolTokens, (id, handoff) => {
328
+ resetRequests.set(id, handoff);
329
+ });
330
+ }
@@ -0,0 +1,259 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
+ import { join, relative, resolve, sep } from "node:path";
4
+
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ isRecord,
8
+ optionalString,
9
+ type EntryLike,
10
+ type MessageLike,
11
+ type WindowedEntry,
12
+ } from "./contracts.ts";
13
+ import { lfLines } from "./files.ts";
14
+ import { windowProjection } from "./history-entries.ts";
15
+ import { shortKey } from "./message-text.ts";
16
+
17
+ function execFileText(
18
+ file: string,
19
+ args: readonly string[],
20
+ signal?: AbortSignal,
21
+ ): Promise<string> {
22
+ return new Promise((resolveOutput, reject) => {
23
+ execFile(file, args, { signal, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) => {
24
+ if (error !== null) {
25
+ const failure: Error = error;
26
+ reject(failure);
27
+ } else {
28
+ resolveOutput(stdout);
29
+ }
30
+ });
31
+ });
32
+ }
33
+ /** Raw JSONL needles must cover every match in normalized text; otherwise scan all files. */
34
+ export function prefilterNeedles(query: string): string[] | undefined {
35
+ if (/["\\[\](){}:$\p{Cc}]/u.test(query) || query.trim() !== query) {
36
+ return undefined;
37
+ }
38
+ if (
39
+ ["call id", "execution arguments", "no handoff", "excluded from model context by pi"].some(
40
+ (phrase) => phrase.includes(query),
41
+ )
42
+ ) {
43
+ return undefined;
44
+ }
45
+ const image =
46
+ query.includes(",") ||
47
+ /^\d+ ?i?m?a?g?e?s?$/.test(query) ||
48
+ ["images", "unknown type"].some((phrase) => phrase.includes(query));
49
+ return image ? [query, '"type":"image"'] : [query];
50
+ }
51
+ export async function filesContaining(
52
+ dir: string,
53
+ needles: readonly string[],
54
+ signal?: AbortSignal,
55
+ ): Promise<Set<string> | undefined> {
56
+ const args = [
57
+ "--files-with-matches",
58
+ "--fixed-strings",
59
+ "--ignore-case",
60
+ "--no-ignore",
61
+ "--hidden",
62
+ "--no-messages",
63
+ "--glob",
64
+ "*.jsonl",
65
+ ...needles.flatMap((needle) => ["--regexp", needle]),
66
+ dir,
67
+ ];
68
+ for (const rg of [join(getAgentDir(), "bin", "rg"), "rg"]) {
69
+ try {
70
+ // Try the host-managed binary first; PATH is a fallback only when it is unavailable.
71
+ // oxlint-disable-next-line no-await-in-loop
72
+ const stdout = await execFileText(rg, args, signal);
73
+ return new Set(
74
+ stdout
75
+ .split("\n")
76
+ .filter((file) => file !== "")
77
+ .map((file) => resolve(file)),
78
+ );
79
+ } catch (error) {
80
+ const code = isRecord(error) ? error.code : undefined;
81
+ if (code === 1) {
82
+ return new Set();
83
+ }
84
+ if (code === "ENOENT") {
85
+ continue;
86
+ }
87
+ signal?.throwIfAborted();
88
+ return undefined;
89
+ }
90
+ }
91
+ return;
92
+ }
93
+ function messageFrom(value: unknown): MessageLike | undefined {
94
+ if (!isRecord(value)) {
95
+ return undefined;
96
+ }
97
+ return {
98
+ role: optionalString(value.role),
99
+ stopReason: optionalString(value.stopReason),
100
+ errorMessage: optionalString(value.errorMessage),
101
+ content: value.content,
102
+ summary: optionalString(value.summary),
103
+ toolName: optionalString(value.toolName),
104
+ namespace: optionalString(value.namespace),
105
+ toolCallId: optionalString(value.toolCallId),
106
+ isError: value.isError === true,
107
+ command: optionalString(value.command),
108
+ output: optionalString(value.output),
109
+ excludeFromContext: value.excludeFromContext === true,
110
+ };
111
+ }
112
+ function parsedEntry(line: string): EntryLike | undefined {
113
+ try {
114
+ const value: unknown = JSON.parse(line);
115
+ if (!isRecord(value)) {
116
+ return undefined;
117
+ }
118
+ const replacement = isRecord(value.replacement)
119
+ ? { content: value.replacement.content }
120
+ : undefined;
121
+ return {
122
+ type: optionalString(value.type),
123
+ id: optionalString(value.id),
124
+ parentId: value.parentId === null ? null : optionalString(value.parentId),
125
+ timestamp: optionalString(value.timestamp),
126
+ message: messageFrom(value.message),
127
+ summary: optionalString(value.summary),
128
+ customType: optionalString(value.customType),
129
+ content: value.content,
130
+ details: value.details,
131
+ display: value.display === true,
132
+ handoff: optionalString(value.handoff),
133
+ firstKeptEntryId: optionalString(value.firstKeptEntryId),
134
+ targetId: optionalString(value.targetId),
135
+ replacement: value.replacement === null ? null : replacement,
136
+ };
137
+ } catch {
138
+ return undefined;
139
+ } // Pi skips malformed journal lines too.
140
+ }
141
+ export async function* sessionWindowEntries(
142
+ file: string,
143
+ signal?: AbortSignal,
144
+ ): AsyncGenerator<WindowedEntry> {
145
+ if (!existsSync(file)) {
146
+ return;
147
+ }
148
+ const project = windowProjection();
149
+ for await (const line of lfLines(file, signal)) {
150
+ const entry = parsedEntry(line);
151
+ if (entry === undefined) {
152
+ continue;
153
+ }
154
+ const item = project(entry);
155
+ if (item !== undefined) {
156
+ yield item;
157
+ }
158
+ }
159
+ }
160
+ function sessionFiles(dir: string): string[] {
161
+ if (!existsSync(dir)) {
162
+ return [];
163
+ }
164
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
165
+ .filter(
166
+ (entry) =>
167
+ entry.isFile() && entry.name.endsWith(".jsonl") && !entry.name.includes(".intent."),
168
+ )
169
+ .map((entry) => join(entry.parentPath, entry.name));
170
+ }
171
+ async function sessionHeader(
172
+ file: string,
173
+ signal?: AbortSignal,
174
+ ): Promise<{ readonly cwd?: string } | undefined> {
175
+ for await (const line of lfLines(file, signal)) {
176
+ try {
177
+ const value: unknown = JSON.parse(line);
178
+ if (value === null) {
179
+ continue;
180
+ }
181
+ return isRecord(value) && value.type === "session" && typeof value.id === "string"
182
+ ? { cwd: optionalString(value.cwd) }
183
+ : undefined;
184
+ } catch {
185
+ /* Pi skips malformed lines before the first parsed entry. */
186
+ }
187
+ }
188
+ return;
189
+ }
190
+ function parentSession(
191
+ file: string,
192
+ dir: string,
193
+ hasFile: (file: string) => boolean,
194
+ ): string | undefined {
195
+ const parts = relative(dir, file).split(sep);
196
+ for (let i = parts.length - 1; i > 0; i--) {
197
+ const candidate = `${join(dir, ...parts.slice(0, i))}.jsonl`;
198
+ if (hasFile(candidate)) {
199
+ return candidate;
200
+ }
201
+ }
202
+ return;
203
+ }
204
+ export type SessionScope = {
205
+ readonly dir: string;
206
+ readonly cwd: string;
207
+ readonly currentFile?: string;
208
+ readonly signal?: AbortSignal;
209
+ readonly fileKey?: string;
210
+ readonly candidates?: (file: string) => boolean;
211
+ };
212
+ /** Scope memoization is owned by one enumeration; nested runs inherit their parent's project. */
213
+ function membership(
214
+ scope: SessionScope,
215
+ hasFile: (file: string) => boolean,
216
+ ): (file: string) => Promise<boolean> {
217
+ const visible = new Map<string, boolean>();
218
+ const belongs = async (file: string): Promise<boolean> => {
219
+ const known = visible.get(file);
220
+ if (known !== undefined) {
221
+ return known;
222
+ }
223
+ const header = await sessionHeader(file, scope.signal);
224
+ if (header === undefined) {
225
+ visible.set(file, false);
226
+ return false;
227
+ }
228
+ const parent = parentSession(file, scope.dir, hasFile);
229
+ const project =
230
+ header.cwd !== undefined && header.cwd !== "" && resolve(header.cwd) === scope.cwd;
231
+ const allowed =
232
+ file === scope.currentFile || (parent === undefined ? project : await belongs(parent));
233
+ visible.set(file, allowed);
234
+ return allowed;
235
+ };
236
+ return belongs;
237
+ }
238
+ /** Newest modified project session files, optionally limited by file key or prefilter. */
239
+ export async function* scopedSessionFiles(scope: SessionScope): AsyncGenerator<string> {
240
+ const files = sessionFiles(scope.dir);
241
+ const fileSet = new Set(files);
242
+ const belongs = membership(scope, (file) => fileSet.has(file));
243
+ const selected = files
244
+ .filter(
245
+ (file) =>
246
+ (scope.candidates === undefined || scope.candidates(resolve(file))) &&
247
+ (scope.fileKey === undefined ||
248
+ scope.fileKey.startsWith(shortKey(relative(scope.dir, file)))),
249
+ )
250
+ .map((file) => ({ file, mtime: statSync(file).mtimeMs }))
251
+ .sort((a, b) => b.mtime - a.mtime);
252
+ for (const { file } of selected) {
253
+ // Preserve newest-first traversal while closing each header stream before opening another.
254
+ // oxlint-disable-next-line no-await-in-loop
255
+ if (await belongs(file)) {
256
+ yield file;
257
+ }
258
+ }
259
+ }