pi-posthorse 0.10.13 → 0.10.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
4
4
  "type": "module",
5
5
  "description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
6
6
  "keywords": [
package/src/budget.ts CHANGED
@@ -13,13 +13,6 @@ import {
13
13
  import { imagesOf, textOf } from "./message-text.ts";
14
14
 
15
15
  const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
16
- export const CHECKPOINT_GUIDANCE = `Keep working on the assigned task; Pi handles context rollover automatically.
17
-
18
- The current context window is not a deadline for finishing your task. Do not reduce scope, skip prescribed steps or verification, or issue a final progress summary because rollover is approaching.
19
-
20
- Update the task's current-state note if needed: goal, progress, decisions, and next steps. Use available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. Leave an already-current note unchanged. Then immediately execute the next prescribed task step in this run. No new_context call, waiting for rollover, or user "continue" is needed.
21
-
22
- Finish when the requested work and required verification are complete, or report a genuine blocker. Approaching context rollover is not a blocker.`;
23
16
  export type Budget = {
24
17
  readonly contextWindow: number;
25
18
  readonly reserveTokens: number;
@@ -84,8 +77,9 @@ function automaticGuidance(budget: Budget | undefined, enabled: boolean): string
84
77
  const line =
85
78
  budget === undefined
86
79
  ? ""
87
- : ` The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used under the available Pi settings.`;
88
- return `${CHECKPOINT_GUIDANCE}\nAutomatic rollover follows Pi's compaction setting and retains a safe recent-message tail using compaction.keepRecentTokens, without model summarization.${line} At most one routine task-note reminder may arrive at the end of a working turn, never on a turn that ends the run. new_context is an optional deliberate clean reset, not a checkpoint requirement.`;
80
+ : `The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used. `;
81
+ return `Pi rolls context over automatically, so the context window is not a deadline. Do not reduce scope, skip steps or verification, or write a final summary because rollover is near, and do not wait for the user to say "continue". Finish when the requested work and its verification are complete, or report a genuine blocker.
82
+ ${line}Rollover keeps a recent-message tail without summarizing. One task-note reminder may arrive at the end of a working turn near the line: update the note only if it is stale, then continue with the next step in the same run. new_context is an optional clean reset.`;
89
83
  }
90
84
  export function buildGuidance(ctx: PolicyContext): string {
91
85
  const budget = budgetFor(ctx);
@@ -93,14 +87,12 @@ export function buildGuidance(ctx: PolicyContext): string {
93
87
  const capacity =
94
88
  budget === undefined
95
89
  ? "Configured context capacity is unknown."
96
- : `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens. This is Pi's best available native limit, not remaining space or a measured provider boundary. Fresh windows use the active configuration; system instructions, tools, the handoff, retained recent messages, and new messages consume part of it.`;
90
+ : `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens (not remaining space).`;
97
91
  return `## Automatic context management (Posthorse)
98
92
  ${automaticGuidance(budget, enabled)}
99
- ${capacity}
100
- Do not guess context capacity or remaining space from response-token limits or reasoning budgets. Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining. If usage is unknown, report it as unknown. No routine budget checks are needed.
93
+ ${capacity} Do not guess capacity or remaining space from response-token or reasoning limits. Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining. If usage is unknown, report it as unknown. No routine budget checks are needed.
101
94
  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.
102
- Keep one concise current-state note per task. Preserve decisions and safety constraints; link fuller evidence and history instead of copying them. Edit changed sections rather than resending unchanged content.
103
- Write handoffs and notes as normal readable prose; notes and history search match literal text.`;
95
+ Keep one concise current-state note per task: goal, progress, decisions, and next steps. Preserve decisions and safety constraints, link fuller evidence instead of copying it, and edit only changed sections.`;
104
96
  }
105
97
  /** ponytail: native estimates omit request-local guidance; reserve it until post-transform usage is exposed. */
106
98
  export function guidanceTokens(ctx: PolicyContext): number {
@@ -84,13 +84,13 @@ export function searchPage(
84
84
  host: PolicyContext,
85
85
  pages: PageAccess,
86
86
  ): TextResult {
87
- const chars = pages.size(host, { offset: 0, cursor: params.cursor });
87
+ const chars = pages.size(host);
88
88
  const reserve = Math.max(
89
89
  0,
90
90
  ...matches.hits.map((hit) => searchFooter(cursorFor(matches, hit, hit.text.length)).length),
91
91
  );
92
92
  if (reserve >= chars) {
93
- pages.error(host, "History entry id is too large for pagination.", params.cursor);
93
+ pages.error(host, "History entry id is too large for pagination.");
94
94
  }
95
95
  const skippedNote =
96
96
  matches.skipped > 0 && (params.cursor === undefined || params.cursor === "")
@@ -146,11 +146,7 @@ export function historyReadPage(
146
146
  const shownId = shownEntryId(item, params.id, source);
147
147
  const { offset, imageOffset } = readOffsets(item, params);
148
148
  const firstImage = imageOffset < item.images.length ? 1 : 0;
149
- const chars = access.pages.size(access.host, {
150
- offset,
151
- imageCount: firstImage,
152
- imageOffset: item.images.length > 0 ? imageOffset : undefined,
153
- });
149
+ const chars = access.pages.size(access.host, { imageCount: firstImage });
154
150
  const imageEnd = Math.min(
155
151
  item.images.length,
156
152
  imageOffset + firstImage + Math.floor((chars - MIN_PAGE_CHARS) / ESTIMATED_IMAGE_CHARS),
package/src/history.ts CHANGED
@@ -130,8 +130,7 @@ export function registerHistory(
130
130
  async execute(...[, params, signal, , ctx]) {
131
131
  const source = historySource(ctx);
132
132
  if (params.op === "search") {
133
- const fail = (message: string): never =>
134
- pages.error(policy(ctx), message, params.cursor);
133
+ const fail = (message: string): never => pages.error(policy(ctx), message);
135
134
  const matches = await historyMatches(source, params, signal, fail);
136
135
  return searchPage(matches, params, policy(ctx), pages);
137
136
  }
package/src/notes.ts CHANGED
@@ -115,15 +115,10 @@ async function readNote(
115
115
  const header = target.writable
116
116
  ? `File: ${path}\n`
117
117
  : `Read-only live reference: notes read ${JSON.stringify({ scope: scopeDirectory(target.address), path: target.address.path })}\n`;
118
- const host = access.host();
119
- const chars = pages.size(host, { offset }) - header.length;
120
- if (chars <= 0) {
121
- pages.error(
122
- host,
123
- `Too little context remains to include the note path. Call new_context first, then retry with offset ${offset}.`,
124
- );
125
- }
126
- const end = Math.min(text.length, offset + chars);
118
+ const end = Math.min(
119
+ text.length,
120
+ offset + pages.size(access.host(), { headerChars: header.length }),
121
+ );
127
122
  const more =
128
123
  end < text.length
129
124
  ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`
package/src/paging.ts CHANGED
@@ -11,11 +11,10 @@ import {
11
11
  type TextResult,
12
12
  } from "./contracts.ts";
13
13
 
14
+ /** What else the page carries: images share the budget, and the header comes out of the body. */
14
15
  export type PageRequest = {
15
- readonly offset: number;
16
16
  readonly imageCount?: number;
17
- readonly cursor?: string | undefined;
18
- readonly imageOffset?: number | undefined;
17
+ readonly headerChars?: number;
19
18
  };
20
19
  export type NotesPage = {
21
20
  readonly rows: readonly string[];
@@ -27,13 +26,13 @@ export type NotesPage = {
27
26
  readonly more?: boolean;
28
27
  };
29
28
  export type PageAccess = {
30
- readonly size: (ctx: PolicyContext, request: PageRequest) => number;
29
+ readonly size: (ctx: PolicyContext, request?: PageRequest) => number;
31
30
  readonly result: (
32
31
  text: string,
33
32
  images: readonly ImageLike[],
34
33
  display: PosthorseDisplay,
35
34
  ) => TextResult;
36
- readonly error: (ctx: PolicyContext, message: string, cursor?: string) => never;
35
+ readonly error: (ctx: PolicyContext, message: string) => never;
37
36
  readonly notes: (ctx: PolicyContext, page: NotesPage) => TextResult;
38
37
  };
39
38
  type PageAccounting = {
@@ -58,18 +57,22 @@ export class PageReservations {
58
57
  reset(): void {
59
58
  this.accounting = { pendingTokens: 0, usage: undefined };
60
59
  }
61
- size(ctx: PolicyContext, request: PageRequest): number {
62
- this.accounting = reconcile(this.accounting, ctx.getContextUsage()?.tokens);
60
+ /**
61
+ * Body characters for the next page. Near the rollover line pages shrink to MIN_PAGE_CHARS
62
+ * instead of refusing, so reading keeps making progress; Pi's rollover handles the overshoot.
63
+ */
64
+ size(ctx: PolicyContext, request: PageRequest = {}): number {
65
+ this.settle(ctx);
63
66
  const remaining = pageCapacity(ctx, this.toolTokens) - this.accounting.pendingTokens;
64
- const chars = Math.min(
67
+ const budget = Math.min(
65
68
  MAX_PAGE_CHARS,
66
69
  Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 -
67
70
  (request.imageCount ?? 0) * ESTIMATED_IMAGE_CHARS,
68
71
  );
69
- if (chars < MIN_PAGE_CHARS) {
70
- this.refuse(remaining, request);
71
- }
72
- return chars;
72
+ return Math.max(MIN_PAGE_CHARS, budget - (request.headerChars ?? 0));
73
+ }
74
+ private settle(ctx: PolicyContext): void {
75
+ this.accounting = reconcile(this.accounting, ctx.getContextUsage()?.tokens);
73
76
  }
74
77
  private reserve(text: string, imageCount = 0): void {
75
78
  this.accounting = {
@@ -80,38 +83,21 @@ export class PageReservations {
80
83
  imageCount * (ESTIMATED_IMAGE_CHARS / 4),
81
84
  };
82
85
  }
83
- private refuse(remaining: number, request: PageRequest): never {
84
- const position =
85
- request.cursor !== undefined && request.cursor !== ""
86
- ? "with the same cursor"
87
- : `with offset ${request.offset}${request.imageOffset === undefined ? "" : ` and imageOffset ${request.imageOffset}`}`;
88
- const message = `Too little context remains to read a page safely. Call new_context first, then retry ${position}.`;
89
- // Even refusals consume context; omit repeated guidance when it no longer fits.
90
- const text = Math.ceil(message.length / 4) <= remaining ? message : "";
91
- this.reserve(text);
92
- throw new Error(text);
93
- }
94
86
  result(text: string, images: readonly ImageLike[], display: PosthorseDisplay): TextResult {
95
87
  this.reserve(text, images.length);
96
88
  return textResult(text, images, display);
97
89
  }
98
- error(ctx: PolicyContext, message: string, cursor?: string): never {
99
- this.size(ctx, { offset: 0, cursor });
90
+ error(ctx: PolicyContext, message: string): never {
91
+ this.settle(ctx);
100
92
  this.reserve(message);
101
93
  throw new Error(message);
102
94
  }
103
95
  notes(ctx: PolicyContext, page: NotesPage): TextResult {
104
96
  const { rows, offset, kind, empty, header = "", more } = page;
105
97
  const text = rows.length > 0 ? rows.join("\n") : empty;
106
- const chars = this.size(ctx, { offset }) - header.length;
98
+ const chars = this.size(ctx, { headerChars: header.length });
107
99
  const footer = (end: number) =>
108
100
  `\n[chars ${offset}-${end}${more === true ? "; more matches follow" : ` of ${text.length}`}; continue with offset ${end}]`;
109
- if (chars <= footer(text.length).length) {
110
- this.error(
111
- ctx,
112
- `Too little context remains to include the notes path. Call new_context first, then retry with offset ${offset}.`,
113
- );
114
- }
115
101
  if (offset > 0 && offset >= text.length) {
116
102
  this.error(ctx, "Offset is past the end; restart with offset 0.");
117
103
  }
package/src/reminders.ts CHANGED
@@ -231,10 +231,8 @@ function countRetainedFromProjected(
231
231
  continue;
232
232
  }
233
233
  for (const msg of item.messages) {
234
- const role = msg.role ?? "";
235
234
  if (
236
- role !== "system" &&
237
- !stale({ role, customType: msg.customType, details: msg.details })
235
+ !stale({ role: msg.role ?? "", customType: msg.customType, details: msg.details })
238
236
  ) {
239
237
  count += 1;
240
238
  }
package/src/rollover.ts CHANGED
@@ -6,7 +6,7 @@ import type {
6
6
  SessionBeforeCompactEvent,
7
7
  TurnEndEvent,
8
8
  } from "@earendil-works/pi-coding-agent";
9
- import { budgetFor, CHECKPOINT_GUIDANCE, freshPayloadChars } from "./budget.ts";
9
+ import { budgetFor, freshPayloadChars } from "./budget.ts";
10
10
  import {
11
11
  isRecord,
12
12
  isReminderType,
@@ -113,22 +113,6 @@ function staleReminderPredicate(
113
113
  isReminderType(message.customType) &&
114
114
  (!enabled || !reminderMatches(message.details, fingerprint));
115
115
  }
116
- function retainedTailEnd(
117
- messages: ContextWithSystemEvent["messages"],
118
- start: number,
119
- count: number,
120
- ): number | undefined {
121
- let remaining = count;
122
- for (let index = start; index < messages.length; index += 1) {
123
- if (messages[index]?.role !== "system") {
124
- remaining -= 1;
125
- }
126
- if (remaining === 0) {
127
- return index + 1;
128
- }
129
- }
130
- return undefined;
131
- }
132
116
  function chronologicalRetainedTail(
133
117
  messages: ContextWithSystemEvent["messages"],
134
118
  ctx: ExtensionContext,
@@ -137,9 +121,12 @@ function chronologicalRetainedTail(
137
121
  const summaryIndex = messages.findIndex((message) => message.role !== "system");
138
122
  const first = messages[summaryIndex];
139
123
  if (first?.role === "compactionSummary" && hasRetainedRolloverMarker(first.summary)) {
124
+ // Pi projects no system messages into the retained tail, but an earlier handler can
125
+ // present a retained message as one (pi-change-working-dir's directory snapshots).
126
+ // Count positions, not roles, so the record never lands inside the next tool batch.
140
127
  const count = countRetainedMessages(ctx, stale);
141
- const end = count > 0 ? retainedTailEnd(messages, summaryIndex + 1, count) : undefined;
142
- if (end !== undefined) {
128
+ const end = summaryIndex + 1 + count;
129
+ if (count > 0 && end <= messages.length) {
143
130
  const prefix = messages.slice(0, summaryIndex);
144
131
  const retainedSlice = messages.slice(summaryIndex + 1, end);
145
132
  const afterSlice = messages.slice(end);
@@ -457,7 +444,8 @@ export function registerRollover(
457
444
  pi.sendMessage(
458
445
  {
459
446
  customType: REMINDER_TYPE,
460
- content: `[posthorse] Routine task-note update.\n${CHECKPOINT_GUIDANCE}`,
447
+ content:
448
+ "[posthorse] Routine task-note update. Update the task's current-state note if it is stale, then continue with the next step in this run.",
461
449
  display: true,
462
450
  details: reminder.fingerprint,
463
451
  },