pi-posthorse 0.9.3 → 0.10.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.
package/src/rollover.ts CHANGED
@@ -14,7 +14,6 @@ import {
14
14
  import {
15
15
  isRecord,
16
16
  isReminderType,
17
- MIN_PAGE_CHARS,
18
17
  REMINDER_TYPE,
19
18
  type CompactionPolicy,
20
19
  type PolicyContext,
@@ -23,7 +22,7 @@ import {
23
22
  } from "./contracts.ts";
24
23
  import { registerContextTools } from "./context-tools.ts";
25
24
  import { safeJsonStringify } from "./message-text.ts";
26
- import { buildAutoHandoff } from "./recovery.ts";
25
+ import { automaticRecovery } from "./retention.ts";
27
26
  import {
28
27
  createReminderLookup,
29
28
  hasReminder,
@@ -42,7 +41,7 @@ function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
42
41
  promptOptions = event.systemPromptOptions;
43
42
  });
44
43
  pi.on("agent_settled", () => {
45
- // Older hosts skip the start hook for custom runs; do not reuse a settled run's options.
44
+ // Prompt options belong to one run, never to later idle context evaluation.
46
45
  promptOptions = undefined;
47
46
  });
48
47
  pi.on("context_with_system", (event, ctx) => {
@@ -175,7 +174,7 @@ function sendReminder(
175
174
  pi.sendMessage(
176
175
  {
177
176
  customType: REMINDER_TYPE,
178
- content: `[posthorse] Checkpoint now: ${(budget.rolloverAt - tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Stop normal work, ${CHECKPOINT_STEPS}.`,
177
+ content: `[posthorse] Notes checkpoint: ${(budget.rolloverAt - tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Please ${CHECKPOINT_STEPS}.`,
179
178
  display: true,
180
179
  details: fingerprint,
181
180
  },
@@ -232,12 +231,20 @@ function registerReminderFilter(
232
231
  function automaticDraft(
233
232
  pi: ExtensionAPI,
234
233
  ctx: ExtensionContext,
235
- recovery: { readonly handoff: string; readonly tokensBefore: number; readonly reason: string },
234
+ recovery: {
235
+ readonly handoff: string;
236
+ readonly tokensBefore: number;
237
+ readonly reason: string;
238
+ readonly firstKeptEntryId?: string;
239
+ },
236
240
  ) {
237
- // Public compaction requires an entry id. An invisible sentinel keeps no prior conversation.
238
- pi.appendEntry("posthorse-boundary", {});
239
- const firstKeptEntryId = ctx.sessionManager.getLeafId();
240
- if (firstKeptEntryId === null || firstKeptEntryId === "") {
241
+ let firstKeptEntryId = recovery.firstKeptEntryId;
242
+ if (firstKeptEntryId === undefined) {
243
+ // Public compaction requires an entry id even when no prior conversation safely fits.
244
+ pi.appendEntry("posthorse-boundary", {});
245
+ firstKeptEntryId = ctx.sessionManager.getLeafId() ?? undefined;
246
+ }
247
+ if (firstKeptEntryId === undefined || firstKeptEntryId === "") {
241
248
  throw new Error("Pi did not persist the context boundary.");
242
249
  }
243
250
  return {
@@ -316,17 +323,16 @@ function thresholdFallback(
316
323
  return undefined;
317
324
  }
318
325
  const projected = event.context.contextEntries;
319
- const limit = freshPayloadChars(host, toolTokens(), event.context.pendingMessages);
320
326
  const ownerQuestionRegistered = pi
321
327
  .getAllTools()
322
328
  .some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
323
- const summary = buildAutoHandoff(
329
+ const recovery = automaticRecovery(
324
330
  ctx.sessionManager.getBranch(),
325
331
  projected,
326
- limit,
332
+ { host, toolTokens: toolTokens(), pendingMessages: event.context.pendingMessages },
327
333
  ownerQuestionRegistered,
328
334
  );
329
- if (limit < MIN_PAGE_CHARS || summary.length > limit) {
335
+ if (recovery === undefined) {
330
336
  return undefined;
331
337
  }
332
338
  // ponytail: turn_end drafts are eager. Later projection editors must replace/remove this draft;
@@ -336,8 +342,8 @@ function thresholdFallback(
336
342
  ...event.entries,
337
343
  {
338
344
  type: "compaction",
339
- summary,
340
- firstKeptEntryId: null,
345
+ summary: recovery.handoff,
346
+ firstKeptEntryId: recovery.firstKeptEntryId ?? null,
341
347
  details: { posthorse: 1, reason: "threshold" },
342
348
  },
343
349
  ],
@@ -362,17 +368,20 @@ function registerAutomaticRollover(
362
368
  if (budgetFor(host)?.supported !== true) {
363
369
  return;
364
370
  }
365
- const limit = freshPayloadChars(host, activeToolTokens());
366
371
  const ownerQuestionRegistered = pi
367
372
  .getAllTools()
368
373
  .some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
369
- const handoff = buildAutoHandoff(
374
+ const recovery = automaticRecovery(
370
375
  event.branchEntries,
371
376
  ctx.sessionManager.buildSessionProjection().entries,
372
- limit,
377
+ {
378
+ host,
379
+ toolTokens: activeToolTokens(),
380
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
381
+ },
373
382
  ownerQuestionRegistered,
374
383
  );
375
- if (limit < MIN_PAGE_CHARS || handoff.length > limit) {
384
+ if (recovery === undefined) {
376
385
  throw new Error(
377
386
  "Too little fresh context capacity for an automatic recovery record.",
378
387
  );
@@ -381,7 +390,7 @@ function registerAutomaticRollover(
381
390
  return { cancel: true };
382
391
  }
383
392
  return automaticDraft(pi, ctx, {
384
- handoff,
393
+ ...recovery,
385
394
  tokensBefore: event.preparation.tokensBefore,
386
395
  reason: event.reason,
387
396
  });
@@ -12,6 +12,7 @@ import {
12
12
  type WindowedEntry,
13
13
  } from "./contracts.ts";
14
14
  import { lfLines } from "./files.ts";
15
+ import { sessionLines, type LineMatcher, type SessionLine } from "./session-lines.ts";
15
16
  import { toWindowedEntry, windowProjection } from "./history-entries.ts";
16
17
  import { shortKey } from "./message-text.ts";
17
18
 
@@ -78,11 +79,11 @@ const ASCII_FOLDS: Readonly<Partial<Record<string, string>>> = {
78
79
  k: "\u00e2\u0084\u00aa",
79
80
  s: "\u00c5\u00bf",
80
81
  };
81
- const DOTTED_I = Buffer.from("\u0130");
82
- /** The read capabilities line filters need from a raw journal line. */
83
- export type RawLine = Readonly<Pick<Buffer, "includes" | "toString">>;
84
- /** Whether a raw line contains one of prefilterNeedles' needles, ignoring case like its text would. */
85
- export function needleLines(needles: readonly string[]): (bytes: RawLine) => boolean {
82
+ const DOTTED_I = Buffer.from("\u0130").toString("latin1");
83
+ /** Raw-line matcher for prefilterNeedles, ignoring case like the rendered text would. */
84
+ export function needleLines(needles: readonly string[]): LineMatcher {
85
+ const overlap =
86
+ 4 * needles.reduce((longest, needle) => Math.max(longest, needle.length), 0) + 4;
86
87
  const others = needles.filter((needle) => needle !== "\u0130");
87
88
  const dotted = others.length < needles.length;
88
89
  if (others.every((needle) => /^[\0-\x7f]*$/u.test(needle))) {
@@ -97,11 +98,14 @@ export function needleLines(needles: readonly string[]): (bytes: RawLine) => boo
97
98
  .join(""),
98
99
  );
99
100
  const pattern = new RegExp(latin1.join("|"), "i");
100
- return (bytes) =>
101
- (dotted && bytes.includes(DOTTED_I)) || pattern.test(bytes.toString("latin1"));
101
+ return {
102
+ test: (text) => (dotted && text.includes(DOTTED_I)) || pattern.test(text),
103
+ encoding: "latin1",
104
+ overlap,
105
+ };
102
106
  }
103
107
  const pattern = new RegExp(needles.map((needle) => RegExp.escape(needle)).join("|"), "iu");
104
- return (bytes) => pattern.test(bytes.toString("utf8"));
108
+ return { test: (text) => pattern.test(text), encoding: "utf8", overlap };
105
109
  }
106
110
  /** File lists that each fit one ripgrep command line. */
107
111
  function commandGroups(paths: readonly string[]): string[][] {
@@ -210,8 +214,8 @@ function parsedEntry(line: string): EntryLike | undefined {
210
214
  } // Pi skips malformed journal lines too.
211
215
  }
212
216
  /** Window metadata of a Pi-ordered line that cannot open a window, without parsing it. */
213
- function entryHead(bytes: RawLine): EntryLike | undefined {
214
- const head = ENTRY_HEAD.exec(bytes.toString("utf8", 0, 512));
217
+ function entryHead(prefix: string): EntryLike | undefined {
218
+ const head = ENTRY_HEAD.exec(prefix);
215
219
  if (head === null || head[1] === "compaction" || head[1] === "context_window") {
216
220
  return undefined;
217
221
  }
@@ -222,31 +226,40 @@ function entryHead(bytes: RawLine): EntryLike | undefined {
222
226
  };
223
227
  }
224
228
  /** Whether a caller may need a line; other lines only advance window ancestry. */
225
- type LineFilter = (bytes: RawLine, entry: EntryLike) => boolean;
229
+ type LineFilter = (entry: EntryLike) => boolean;
230
+ async function readEntry(
231
+ line: SessionLine,
232
+ wanted: LineFilter,
233
+ ): Promise<{ readonly entry: EntryLike; readonly selected: boolean } | undefined> {
234
+ const head = entryHead(line.head);
235
+ if (head !== undefined && (!line.matches || !wanted(head))) {
236
+ return { entry: head, selected: false };
237
+ }
238
+ const entry = parsedEntry(await line.text());
239
+ return entry === undefined
240
+ ? undefined
241
+ : {
242
+ entry,
243
+ selected: head !== undefined || (line.matches && wanted(entry)),
244
+ };
245
+ }
226
246
  export async function* sessionWindowEntries(
227
247
  file: string,
228
248
  signal?: AbortSignal,
229
249
  wanted: LineFilter = () => true,
250
+ matcher?: LineMatcher,
230
251
  ): AsyncGenerator<WindowedEntry> {
231
252
  if (!existsSync(file)) {
232
253
  return;
233
254
  }
234
255
  const project = windowProjection();
235
- for await (const bytes of lfLines(file, signal, 1024 * 1024)) {
236
- const head = entryHead(bytes);
237
- if (head !== undefined && !wanted(bytes, head)) {
238
- project(head);
256
+ for await (const line of sessionLines(file, signal, matcher)) {
257
+ const candidate = await readEntry(line, wanted);
258
+ if (candidate === undefined) {
239
259
  continue;
240
260
  }
241
- const entry = parsedEntry(bytes.toString("utf8"));
242
- if (entry === undefined) {
243
- continue;
244
- }
245
- const windowId = project(entry);
246
- const item =
247
- head !== undefined || wanted(bytes, entry)
248
- ? toWindowedEntry(entry, windowId)
249
- : undefined;
261
+ const windowId = project(candidate.entry);
262
+ const item = candidate.selected ? toWindowedEntry(candidate.entry, windowId) : undefined;
250
263
  if (item !== undefined) {
251
264
  yield item;
252
265
  }
@@ -0,0 +1,108 @@
1
+ import { open, type FileHandle } from "node:fs/promises";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ import { lfChunks } from "./files.ts";
4
+
5
+ export type LineMatcher = {
6
+ readonly test: (text: string) => boolean;
7
+ readonly encoding: "latin1" | "utf8";
8
+ /** Enough characters to include every case-fold alternative of any needle. */
9
+ readonly overlap: number;
10
+ };
11
+ export type SessionLine = {
12
+ readonly head: string;
13
+ readonly matches: boolean;
14
+ readonly text: () => Promise<string>;
15
+ };
16
+ type LineRange = {
17
+ readonly start: number;
18
+ readonly end: number;
19
+ readonly head: string;
20
+ readonly matches: boolean;
21
+ readonly bytes?: Readonly<Pick<Buffer, "toString">>;
22
+ };
23
+ async function* lineRanges(
24
+ file: string,
25
+ handle: Readonly<Pick<FileHandle, "createReadStream">>,
26
+ signal: AbortSignal | undefined,
27
+ matcher: LineMatcher | undefined,
28
+ ): AsyncGenerator<LineRange> {
29
+ let start = 0;
30
+ let position = 0;
31
+ let head = Buffer.alloc(0);
32
+ let tail = "";
33
+ let decoder = new StringDecoder(matcher?.encoding);
34
+ let matches = matcher === undefined;
35
+ for await (const { bytes, end } of lfChunks(file, signal, 1024 * 1024, handle)) {
36
+ if (head.length < 512) {
37
+ head = Buffer.concat([head, bytes.subarray(0, 512 - head.length)]);
38
+ }
39
+ if (!matches && matcher !== undefined) {
40
+ const window = tail + decoder.write(bytes) + (end ? decoder.end() : "");
41
+ matches = matcher.test(window);
42
+ tail = window.slice(-matcher.overlap);
43
+ }
44
+ position += bytes.length;
45
+ if (end) {
46
+ yield {
47
+ start,
48
+ end: position,
49
+ head: head.toString("utf8"),
50
+ matches,
51
+ bytes: position - start === bytes.length ? bytes : undefined,
52
+ };
53
+ position++;
54
+ start = position;
55
+ head = Buffer.alloc(0);
56
+ tail = "";
57
+ decoder = new StringDecoder(matcher?.encoding);
58
+ matches = matcher === undefined;
59
+ }
60
+ }
61
+ }
62
+ async function rangeText(
63
+ handle: Readonly<Pick<FileHandle, "read">>,
64
+ line: LineRange,
65
+ signal?: AbortSignal,
66
+ ): Promise<string> {
67
+ signal?.throwIfAborted();
68
+ if (line.bytes !== undefined) {
69
+ return line.bytes.toString("utf8");
70
+ }
71
+ const buffer = Buffer.alloc(line.end - line.start);
72
+ let offset = 0;
73
+ while (offset < buffer.length) {
74
+ signal?.throwIfAborted();
75
+ // Positioned reads may be short; each continues the same selected record.
76
+ // oxlint-disable-next-line no-await-in-loop
77
+ const { bytesRead } = await handle.read(
78
+ buffer,
79
+ offset,
80
+ buffer.length - offset,
81
+ line.start + offset,
82
+ );
83
+ if (bytesRead === 0) {
84
+ throw new Error("Session file changed while reading a history entry.");
85
+ }
86
+ offset += bytesRead;
87
+ }
88
+ return buffer.toString("utf8");
89
+ }
90
+ /** Irrelevant lines retain only their prefix, byte range and needle overlap, not their payload. */
91
+ export async function* sessionLines(
92
+ file: string,
93
+ signal?: AbortSignal,
94
+ matcher?: LineMatcher,
95
+ ): AsyncGenerator<SessionLine> {
96
+ const handle = await open(file, "r");
97
+ try {
98
+ for await (const line of lineRanges(file, handle, signal, matcher)) {
99
+ yield {
100
+ head: line.head,
101
+ matches: line.matches,
102
+ text: () => rangeText(handle, line, signal),
103
+ };
104
+ }
105
+ } finally {
106
+ await handle.close();
107
+ }
108
+ }