pi-posthorse 0.10.18 → 0.10.20

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.18",
3
+ "version": "0.10.20",
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/guidance.ts CHANGED
@@ -24,7 +24,9 @@ export function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void
24
24
  if (promptOptions?.forceSystemPrompt !== undefined) {
25
25
  // ponytail: Pi applies forced prompts after this hook; keep request-local guidance
26
26
  // until the host projects forced text before context_with_system.
27
- event.messages.splice(1, 0, {
27
+ // After a compaction a second system message (the tool update) can follow the first.
28
+ const conversation = event.messages.findIndex((message) => message.role !== "system");
29
+ event.messages.splice(conversation === -1 ? event.messages.length : conversation, 0, {
28
30
  role: "custom",
29
31
  customType: "posthorse-guidance",
30
32
  content: guidance,
@@ -0,0 +1,255 @@
1
+ import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
2
+ import { contextEdits, contextStart, windowHandoff } from "./history-entries.ts";
3
+ import { toolCalls } from "./message-text.ts";
4
+ import {
5
+ formatRecord,
6
+ MAX_RECOVERY_RECORD_CHARS,
7
+ recoveryRecord,
8
+ type RecoveryRecord,
9
+ } from "./recovery-records.ts";
10
+
11
+ /** One input's room as a share of the whole record: 4,000 of the 20,000 characters a record may have. */
12
+ const RECORD_SHARE = 5;
13
+
14
+ function projectedCurrent(
15
+ entries: readonly EntryLike[],
16
+ projected: readonly ProjectedEntry[],
17
+ current: readonly EntryLike[] = entries.slice(contextStart(entries).start),
18
+ retained: ReadonlySet<string> = new Set(),
19
+ ): EntryLike[] {
20
+ const edits = contextEdits(entries);
21
+ const omittedIds = new Set(
22
+ projected
23
+ .filter(({ messages }) => messages.length === 0)
24
+ .map(({ sourceEntry }) => sourceEntry.id),
25
+ );
26
+ return current.flatMap((entry) => {
27
+ const id = entry.id ?? "";
28
+ if (retained.has(id) || omittedIds.has(id)) {
29
+ return [];
30
+ }
31
+ const edit = edits.get(id);
32
+ if (edit?.replacement === null) {
33
+ return [];
34
+ }
35
+ if (edit?.replacement === undefined) {
36
+ return [entry];
37
+ }
38
+ if (entry.type === "message") {
39
+ return [
40
+ {
41
+ ...entry,
42
+ id: edit.id,
43
+ message: { ...entry.message, content: edit.replacement.content },
44
+ },
45
+ ];
46
+ }
47
+ if (entry.type === "custom_message") {
48
+ return [{ ...entry, id: edit.id, content: edit.replacement.content }];
49
+ }
50
+ return [entry];
51
+ });
52
+ }
53
+ function ownerRecords(
54
+ entries: readonly EntryLike[],
55
+ projected: readonly ProjectedEntry[],
56
+ ownerQuestionRegistered: boolean,
57
+ ): RecoveryRecord[] {
58
+ const calls = new Map(
59
+ projected.flatMap(({ messages }) =>
60
+ messages.flatMap((message) =>
61
+ message.role === "assistant"
62
+ ? toolCalls(message.content).map((call) => [call.id, call] as const)
63
+ : [],
64
+ ),
65
+ ),
66
+ );
67
+ return entries.flatMap((entry) => {
68
+ const call = calls.get(entry.message?.toolCallId);
69
+ const record = recoveryRecord(
70
+ entry,
71
+ ownerQuestionRegistered &&
72
+ call?.name === "ask_question" &&
73
+ call.namespace === undefined,
74
+ );
75
+ return record === undefined ? [] : [record];
76
+ });
77
+ }
78
+ function anchors(records: readonly RecoveryRecord[]): {
79
+ fixed: RecoveryRecord[];
80
+ others: RecoveryRecord[];
81
+ } {
82
+ const firstOwner = records.find((record) => record.label === "owner input") ?? records[0];
83
+ const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
84
+ const latestOverall = records.at(-1);
85
+ const selected = new Set([firstOwner, latestOwner, latestOverall]);
86
+ return {
87
+ fixed: records.filter((record) => selected.has(record)),
88
+ others: records.filter((record) => !selected.has(record)),
89
+ };
90
+ }
91
+ function automaticBoundary(entry: EntryLike | undefined): boolean {
92
+ if (
93
+ entry === undefined ||
94
+ windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) !== true
95
+ ) {
96
+ return false;
97
+ }
98
+ return (
99
+ entry.type === "context_window" ||
100
+ (isRecord(entry.details) &&
101
+ entry.details.posthorse === 1 &&
102
+ (entry.details.reason === "threshold" || entry.details.reason === "overflow"))
103
+ );
104
+ }
105
+ export function automaticChainStart(entries: readonly EntryLike[]): number {
106
+ let prefix = entries;
107
+ let window = contextStart(prefix);
108
+ while (automaticBoundary(window.boundary) && window.boundary !== undefined) {
109
+ prefix = prefix.slice(0, prefix.indexOf(window.boundary));
110
+ window = contextStart(prefix);
111
+ }
112
+ return window.start;
113
+ }
114
+ function windowOwners(
115
+ entries: readonly EntryLike[],
116
+ projected: readonly ProjectedEntry[],
117
+ window: readonly EntryLike[],
118
+ ownerQuestionRegistered: boolean,
119
+ ): RecoveryRecord[] {
120
+ const restored = projectedCurrent(entries, projected, window);
121
+ const historical = restored.map((sourceEntry) => ({
122
+ sourceEntry,
123
+ messages: sourceEntry.message === undefined ? [] : [sourceEntry.message],
124
+ }));
125
+ return ownerRecords(restored, historical, ownerQuestionRegistered).filter(
126
+ (record) => record.kind === "owner",
127
+ );
128
+ }
129
+ /** The windows before the current one, newest first, as far back as automatic boundaries reach. */
130
+ function* earlierWindows(entries: readonly EntryLike[]): Generator<{
131
+ readonly closedBy: EntryLike;
132
+ readonly entries: readonly EntryLike[];
133
+ readonly openedBy: EntryLike | undefined;
134
+ }> {
135
+ let prefix = entries;
136
+ let boundary = contextStart(prefix).boundary;
137
+ while (boundary !== undefined && automaticBoundary(boundary)) {
138
+ prefix = prefix.slice(0, prefix.indexOf(boundary));
139
+ const window = contextStart(prefix);
140
+ yield {
141
+ closedBy: boundary,
142
+ entries: prefix.slice(window.start),
143
+ openedBy: window.boundary,
144
+ };
145
+ boundary = window.boundary;
146
+ }
147
+ }
148
+ /** A window's owner inputs that still fit the room, taken newest first and returned oldest first. */
149
+ function newestThatFit(
150
+ owners: readonly RecoveryRecord[],
151
+ room: number,
152
+ seen: Set<string>,
153
+ ): { readonly records: readonly RecoveryRecord[]; readonly used: number } {
154
+ const records: RecoveryRecord[] = [];
155
+ let used = 0;
156
+ for (const owner of owners.toReversed()) {
157
+ if (used >= room) {
158
+ break;
159
+ }
160
+ if (seen.has(owner.id)) {
161
+ continue;
162
+ }
163
+ const record = {
164
+ ...owner,
165
+ label: `${owner.label}; carried from an earlier window, possibly already handled; not current progress`,
166
+ };
167
+ seen.add(owner.id);
168
+ records.unshift(record);
169
+ used += formatRecord(record, Number.MAX_SAFE_INTEGER).length;
170
+ }
171
+ return { records, used };
172
+ }
173
+ /**
174
+ * Earlier windows give a record the owner's most recent inputs, newest first, until they fill the
175
+ * room of one input. Owner text the current window already holds counts against that room, so a
176
+ * short follow-up keeps the task before it and a long new request stands alone.
177
+ */
178
+ function recoveryAncestry(
179
+ entries: readonly EntryLike[],
180
+ projected: readonly ProjectedEntry[],
181
+ ownerQuestionRegistered: boolean,
182
+ options: { readonly current: readonly RecoveryRecord[]; readonly maxChars: number },
183
+ ): {
184
+ readonly checkpoint?: EntryLike | undefined;
185
+ readonly carried: readonly RecoveryRecord[];
186
+ readonly skipped: readonly string[];
187
+ } {
188
+ const held = options.current
189
+ .filter((record) => record.kind === "owner")
190
+ .reduce((chars, record) => chars + record.text.length, 0);
191
+ let room =
192
+ Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(options.maxChars / RECORD_SHARE)) - held;
193
+ const seen = new Set(options.current.map((record) => record.id));
194
+ const carried: RecoveryRecord[] = [];
195
+ const skipped: EntryLike[] = [];
196
+ let ownerWindowFound = false;
197
+ let reached = contextStart(entries).boundary;
198
+ for (const window of earlierWindows(entries)) {
199
+ if (ownerWindowFound && room <= 0) {
200
+ break;
201
+ }
202
+ if (!ownerWindowFound) {
203
+ skipped.push(window.closedBy);
204
+ }
205
+ const owners = windowOwners(entries, projected, window.entries, ownerQuestionRegistered);
206
+ ownerWindowFound ||= owners.length > 0;
207
+ const fit = newestThatFit(owners, room, seen);
208
+ carried.unshift(...fit.records);
209
+ room -= fit.used;
210
+ reached = window.openedBy;
211
+ }
212
+ return {
213
+ checkpoint: automaticBoundary(reached) ? undefined : reached,
214
+ carried,
215
+ skipped: skipped.map((boundary) => boundary.id?.slice(0, 120) ?? "unknown"),
216
+ };
217
+ }
218
+ /** The inputs a record quotes in full (oldest first), the ones it only lists, and where its walk back ended. */
219
+ export function recoveryInputs(
220
+ entries: readonly EntryLike[],
221
+ projected: readonly ProjectedEntry[],
222
+ options: {
223
+ readonly ownerQuestionRegistered: boolean;
224
+ readonly retained: ReadonlySet<string>;
225
+ readonly maxChars: number;
226
+ },
227
+ ): {
228
+ readonly fixed: readonly RecoveryRecord[];
229
+ readonly others: readonly RecoveryRecord[];
230
+ readonly checkpoint?: EntryLike | undefined;
231
+ readonly skipped: readonly string[];
232
+ } {
233
+ const { ownerQuestionRegistered, retained, maxChars } = options;
234
+ const { carried, checkpoint, skipped } = recoveryAncestry(
235
+ entries,
236
+ projected,
237
+ ownerQuestionRegistered,
238
+ {
239
+ current: ownerRecords(
240
+ projectedCurrent(entries, projected),
241
+ projected,
242
+ ownerQuestionRegistered,
243
+ ),
244
+ maxChars,
245
+ },
246
+ );
247
+ const { fixed, others } = anchors(
248
+ ownerRecords(
249
+ projectedCurrent(entries, projected, undefined, retained),
250
+ projected,
251
+ ownerQuestionRegistered,
252
+ ),
253
+ );
254
+ return { fixed: [...carried, ...fixed], others, checkpoint, skipped };
255
+ }
@@ -1,6 +1,7 @@
1
1
  import { isReminderType, type EntryLike } from "./contracts.ts";
2
2
  import { boundedBlock, imageSummary, imagesOf, textOf } from "./message-text.ts";
3
3
 
4
+ export const MAX_RECOVERY_RECORD_CHARS = 4_000;
4
5
  export type RecoveryRecord = {
5
6
  readonly id: string;
6
7
  readonly timestamp: string;
package/src/recovery.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
1
+ import { AUTO_HANDOFF_PREFIX, type EntryLike, type ProjectedEntry } from "./contracts.ts";
2
2
  import { contextEdits, contextStart, windowHandoff } from "./history-entries.ts";
3
- import { boundedBlock, excerpt, toolCalls } from "./message-text.ts";
3
+ import { boundedBlock, excerpt } from "./message-text.ts";
4
+ import { automaticChainStart, recoveryInputs } from "./recovery-inputs.ts";
4
5
  import {
5
6
  formatRecord,
6
7
  indexLine,
7
- recoveryRecord,
8
+ MAX_RECOVERY_RECORD_CHARS,
8
9
  type RecoveryRecord,
9
10
  } from "./recovery-records.ts";
10
11
  import type { NotesRecovery } from "./note-evidence.ts";
@@ -16,7 +17,6 @@ import {
16
17
  type RecoveryEntry,
17
18
  } from "./recovery-results.ts";
18
19
 
19
- const MAX_RECOVERY_RECORD_CHARS = 4_000;
20
20
  const MAX_RECOVERY_RESULT_CHARS = 1_500;
21
21
  const HANDOFF_OVERHEAD_RESERVE = 1_000;
22
22
  const MAX_NOTES_SECTION_CHARS = 1_200;
@@ -69,142 +69,6 @@ function priorCheckpoint(entry: EntryLike | undefined, limit: number): string |
69
69
  const header = `[older checkpoint; possibly stale | context-window entry ${id}]`;
70
70
  return boundedBlock(header, handoff, limit);
71
71
  }
72
- function projectedCurrent(
73
- entries: readonly EntryLike[],
74
- projected: readonly ProjectedEntry[],
75
- current: readonly EntryLike[] = entries.slice(contextStart(entries).start),
76
- retained: ReadonlySet<string> = new Set(),
77
- ): EntryLike[] {
78
- const edits = contextEdits(entries);
79
- const omittedIds = new Set(
80
- projected
81
- .filter(({ messages }) => messages.length === 0)
82
- .map(({ sourceEntry }) => sourceEntry.id),
83
- );
84
- return current.flatMap((entry) => {
85
- const id = entry.id ?? "";
86
- if (retained.has(id) || omittedIds.has(id)) {
87
- return [];
88
- }
89
- const edit = edits.get(id);
90
- if (edit?.replacement === null) {
91
- return [];
92
- }
93
- if (edit?.replacement === undefined) {
94
- return [entry];
95
- }
96
- if (entry.type === "message") {
97
- return [
98
- {
99
- ...entry,
100
- id: edit.id,
101
- message: { ...entry.message, content: edit.replacement.content },
102
- },
103
- ];
104
- }
105
- if (entry.type === "custom_message") {
106
- return [{ ...entry, id: edit.id, content: edit.replacement.content }];
107
- }
108
- return [entry];
109
- });
110
- }
111
- function ownerRecords(
112
- entries: readonly EntryLike[],
113
- projected: readonly ProjectedEntry[],
114
- ownerQuestionRegistered: boolean,
115
- ): RecoveryRecord[] {
116
- const calls = new Map(
117
- projected.flatMap(({ messages }) =>
118
- messages.flatMap((message) =>
119
- message.role === "assistant"
120
- ? toolCalls(message.content).map((call) => [call.id, call] as const)
121
- : [],
122
- ),
123
- ),
124
- );
125
- return entries.flatMap((entry) => {
126
- const call = calls.get(entry.message?.toolCallId);
127
- const record = recoveryRecord(
128
- entry,
129
- ownerQuestionRegistered &&
130
- call?.name === "ask_question" &&
131
- call.namespace === undefined,
132
- );
133
- return record === undefined ? [] : [record];
134
- });
135
- }
136
- function anchors(records: readonly RecoveryRecord[]): {
137
- fixed: RecoveryRecord[];
138
- others: RecoveryRecord[];
139
- } {
140
- const firstOwner = records.find((record) => record.label === "owner input") ?? records[0];
141
- const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
142
- const latestOverall = records.at(-1);
143
- const selected = new Set([firstOwner, latestOwner, latestOverall]);
144
- return {
145
- fixed: records.filter((record) => selected.has(record)),
146
- others: records.filter((record) => !selected.has(record)),
147
- };
148
- }
149
- function automaticBoundary(entry: EntryLike | undefined): boolean {
150
- if (
151
- entry === undefined ||
152
- windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) !== true
153
- ) {
154
- return false;
155
- }
156
- return (
157
- entry.type === "context_window" ||
158
- (isRecord(entry.details) &&
159
- entry.details.posthorse === 1 &&
160
- (entry.details.reason === "threshold" || entry.details.reason === "overflow"))
161
- );
162
- }
163
- function automaticChainStart(entries: readonly EntryLike[]): number {
164
- let prefix = entries;
165
- let window = contextStart(prefix);
166
- while (automaticBoundary(window.boundary) && window.boundary !== undefined) {
167
- prefix = prefix.slice(0, prefix.indexOf(window.boundary));
168
- window = contextStart(prefix);
169
- }
170
- return window.start;
171
- }
172
- function recoveryAncestry(
173
- entries: readonly EntryLike[],
174
- projected: readonly ProjectedEntry[],
175
- ownerQuestionRegistered: boolean,
176
- retained: ReadonlySet<string>,
177
- ): {
178
- readonly checkpoint?: EntryLike | undefined;
179
- readonly carried: readonly RecoveryRecord[];
180
- readonly skipped: readonly string[];
181
- } {
182
- let prefix = entries;
183
- let boundary = contextStart(prefix).boundary;
184
- const skipped: string[] = [];
185
- while (automaticBoundary(boundary) && boundary !== undefined) {
186
- skipped.push(boundary.id?.slice(0, 120) ?? "unknown");
187
- prefix = prefix.slice(0, prefix.indexOf(boundary));
188
- const window = contextStart(prefix);
189
- const restored = projectedCurrent(entries, projected, prefix.slice(window.start));
190
- const historical = restored.map((sourceEntry) => ({
191
- sourceEntry,
192
- messages: sourceEntry.message === undefined ? [] : [sourceEntry.message],
193
- }));
194
- const owners = ownerRecords(restored, historical, ownerQuestionRegistered).filter(
195
- (record) => record.kind === "owner",
196
- );
197
- if (owners.length > 0) {
198
- return {
199
- checkpoint: automaticBoundary(window.boundary) ? undefined : window.boundary,
200
- carried: anchors(owners.filter((record) => !retained.has(record.id))).fixed,
201
- skipped,
202
- };
203
- }
204
- boundary = window.boundary;
205
- }
206
- return { checkpoint: boundary, carried: [], skipped };
207
- }
208
72
  function skippedRecords(ids: readonly string[], limit: number): string | undefined {
209
73
  const remaining = ids.values();
210
74
  const first = remaining.next();
@@ -346,34 +210,20 @@ function renderIndex(records: readonly RecoveryRecord[], available: number): str
346
210
  ...listed,
347
211
  ].join("\n");
348
212
  }
349
- function selectedInputs(
350
- entries: readonly EntryLike[],
351
- projected: readonly ProjectedEntry[],
352
- options: {
353
- readonly ownerQuestionRegistered: boolean;
354
- readonly retained: ReadonlySet<string>;
355
- readonly carried: readonly RecoveryRecord[];
356
- },
357
- ): { readonly fixed: readonly RecoveryRecord[]; readonly others: readonly RecoveryRecord[] } {
358
- const { ownerQuestionRegistered, retained, carried } = options;
359
- const current = projectedCurrent(entries, projected);
360
- const allRecords = ownerRecords(current, projected, ownerQuestionRegistered);
361
- const records = ownerRecords(
362
- projectedCurrent(entries, projected, undefined, retained),
363
- projected,
364
- ownerQuestionRegistered,
365
- );
366
- const { fixed, others } = anchors(records);
367
- if (!allRecords.some((record) => record.kind === "owner")) {
368
- fixed.unshift(
369
- ...carried.map((record) =>
370
- Object.assign({}, record, {
371
- label: `${record.label}; carried from an earlier window, possibly already handled; not current progress`,
372
- }),
373
- ),
374
- );
213
+ /** The per-block limit at which the blocks fit the budget together; short blocks leave room to long ones. */
214
+ function sharedLimit(lengths: readonly number[], budget: number): number {
215
+ let remaining = budget;
216
+ const ascending = lengths
217
+ .map((length) => Math.min(length, MAX_RECOVERY_RECORD_CHARS))
218
+ .toSorted((a, b) => a - b);
219
+ for (const [index, length] of ascending.entries()) {
220
+ const share = Math.floor(remaining / (ascending.length - index));
221
+ if (length > share) {
222
+ return share;
223
+ }
224
+ remaining -= length;
375
225
  }
376
- return { fixed, others };
226
+ return MAX_RECOVERY_RECORD_CHARS;
377
227
  }
378
228
  function rolloverPreambleWithNotes(
379
229
  entries: readonly EntryLike[],
@@ -417,13 +267,12 @@ export function buildAutoHandoff(
417
267
  },
418
268
  ): string {
419
269
  const { ownerQuestionRegistered, retainedEntryIds: retained = new Set<string>() } = options;
420
- const history = recoveryAncestry(entries, projected, ownerQuestionRegistered, retained);
421
- const priorWindow = history.checkpoint;
422
- const { fixed, others } = selectedInputs(entries, projected, {
270
+ const { fixed, others, ...history } = recoveryInputs(entries, projected, {
423
271
  ownerQuestionRegistered,
424
272
  retained,
425
- carried: history.carried,
273
+ maxChars,
426
274
  });
275
+ const priorWindow = history.checkpoint;
427
276
  const emptyInputHeader =
428
277
  retained.size > 0
429
278
  ? "No discarded owner or visible coordination inputs were selected for this recovery record."
@@ -453,19 +302,18 @@ export function buildAutoHandoff(
453
302
  header,
454
303
  Math.max(0, maxChars - joined(minimum).length - HANDOFF_OVERHEAD_RESERVE),
455
304
  );
456
- const handoff = windowHandoff(priorWindow)?.trim();
457
- const count =
458
- fixed.length +
459
- (handoff === undefined || handoff === "" ? 0 : 1) +
460
- (history.skipped.length > 0 ? 1 : 0);
461
305
  const fixedBudget = Math.max(
462
306
  0,
463
307
  maxChars - joined([preamble, inputHeader, plan.bare]).length - HANDOFF_OVERHEAD_RESERVE,
464
308
  );
465
- const fixedLimit =
466
- count > 0
467
- ? Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(fixedBudget / count))
468
- : MAX_RECOVERY_RECORD_CHARS;
309
+ const fixedLimit = sharedLimit(
310
+ [
311
+ ...fixed.map((record) => formatRecord(record, Number.MAX_SAFE_INTEGER)),
312
+ priorCheckpoint(priorWindow, Number.MAX_SAFE_INTEGER),
313
+ skippedRecords(history.skipped, Number.MAX_SAFE_INTEGER),
314
+ ].flatMap((block) => (block === undefined ? [] : [block.length])),
315
+ fixedBudget,
316
+ );
469
317
  const prior = joined([
470
318
  priorCheckpoint(priorWindow, fixedLimit),
471
319
  skippedRecords(history.skipped, fixedLimit),