pi-posthorse 0.6.0 → 0.8.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.
Files changed (4) hide show
  1. package/README.md +39 -37
  2. package/index.ts +397 -314
  3. package/package.json +6 -6
  4. package/ui.ts +8 -7
package/index.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Posthorse — fresh context, same journey.
3
3
  *
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.
4
+ * No-summary context rollover for Pi: a retain-none public compaction whose summary is
5
+ * the handoff, on official Pi and the maintained fork.
6
6
  * Pi owns the persisted boundary. Posthorse owns the policy: sparse budget reminders, rollover
7
7
  * tools, durable notes, and history recovery.
8
8
  */
9
9
 
10
+ import { execFile } from "node:child_process";
10
11
  import { createHash, randomUUID } from "node:crypto";
11
12
  import {
12
13
  appendFileSync,
@@ -20,15 +21,18 @@ import {
20
21
  } from "node:fs";
21
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 { estimateTokens, getAgentDir, SettingsManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
24
+ import { promisify } from "node:util";
25
+ import { estimateTokens, formatSize, getAgentDir, SettingsManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
24
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 ceiling for handoffs and recovery pages; pages shrink to the live remaining budget. */
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;
@@ -42,31 +46,13 @@ const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before
42
46
  type CompactionPolicy = { enabled: boolean; reserveTokens: number };
43
47
  type ContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
44
48
 
45
- /** The fork exposes live settings on the context; official Pi gets them from the injected policy. */
49
+ /** Settings come from the injected policy; the compaction hook provides Pi's live policy. */
46
50
  type PolicyContext = {
47
- model?: { contextWindow: number };
48
51
  getCompactionSettings(): CompactionPolicy;
49
52
  getContextUsage(): ContextUsage | undefined;
50
53
  getSystemPrompt(): string;
51
54
  };
52
55
 
53
- /** Fork-only extension API; the published Pi types do not declare it. */
54
- type NativeExtensionAPI = {
55
- registerContextWindowHook(
56
- handler: (
57
- event: { contextEntries: ProjectedEntry[]; pendingMessages: MessageLike[] },
58
- ctx: ExtensionContext,
59
- ) => ReceiptEdit[] | undefined,
60
- ): void;
61
- on(
62
- event: "session_before_auto_compact",
63
- handler: (
64
- event: { reason: "overflow" | "threshold"; branchEntries: EntryLike[]; pendingMessages?: MessageLike[]; retainedToolResultIds: string[] },
65
- ctx: unknown,
66
- ) => { newContext: { handoff?: string } } | undefined,
67
- ): void;
68
- };
69
-
70
56
  type ImageLike = { type: "image"; data: string; mimeType: string };
71
57
  type ToolCallLike = {
72
58
  type?: string;
@@ -82,6 +68,7 @@ type MessageLike = {
82
68
  stopReason?: string;
83
69
  errorMessage?: string;
84
70
  content?: unknown;
71
+ summary?: string;
85
72
  toolName?: string;
86
73
  namespace?: string;
87
74
  toolCallId?: string;
@@ -109,7 +96,6 @@ type EntryLike = {
109
96
 
110
97
  type WindowedEntry = { entry: EntryLike; windowId: string; text: string; images: ImageLike[] };
111
98
  type ProjectedEntry = { sourceEntry: EntryLike; messages: MessageLike[] };
112
- type ReceiptEdit = { type: "context_edit"; targetId: string; replacement: { content: string } };
113
99
  type HistoryHit = { id: string; text: string; headerLength: number; priority: 0 | 1 };
114
100
  type RecoveryRecord = {
115
101
  id: string;
@@ -119,6 +105,7 @@ type RecoveryRecord = {
119
105
  text: string;
120
106
  };
121
107
  type ReminderFingerprint = { windowId: string; contextWindow?: number; reserveTokens?: number };
108
+ type NoteFile = { path: string; size: number; mtimeMs: number };
122
109
  type Budget = {
123
110
  contextWindow: number;
124
111
  reserveTokens: number;
@@ -128,7 +115,7 @@ type Budget = {
128
115
  supported: boolean;
129
116
  };
130
117
 
131
- /** A fork context window, or the retain-none compaction Posthorse commits on official Pi. */
118
+ /** A legacy fork context window, or the retain-none compaction Posthorse commits on both hosts. */
132
119
  function isWindow(entry: EntryLike): boolean {
133
120
  return entry.type === "context_window" || (entry.type === "compaction" && (entry.details as { posthorse?: unknown } | undefined)?.posthorse === 1);
134
121
  }
@@ -349,8 +336,22 @@ function isRecoveryCall(part: unknown): boolean {
349
336
  return block?.type === "toolCall" && isRecoveryTool(block.name);
350
337
  }
351
338
 
352
- function historyFileKey(source: string): string {
353
- return createHash("sha256").update(source).digest("base64url");
339
+ /** Ten base64url characters of SHA-256; 0.6 file keys were the whole digest, which starts with these. */
340
+ function shortKey(text: string): string {
341
+ return createHash("sha256").update(text).digest("base64url").slice(0, 10);
342
+ }
343
+
344
+ /** Nested session files are subagent runs, whose user-role input came from a parent agent. */
345
+ function sourceTag(source: string): string {
346
+ return source.includes(sep) ? " [subagent]" : "";
347
+ }
348
+
349
+ function contextEdits(entries: readonly EntryLike[]): Map<string, EntryLike> {
350
+ const edits = new Map<string, EntryLike>();
351
+ for (const entry of entries) {
352
+ if (entry.type === "context_edit" && entry.targetId) edits.set(entry.targetId, entry);
353
+ }
354
+ return edits;
354
355
  }
355
356
 
356
357
  function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit | undefined {
@@ -391,8 +392,8 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
391
392
  matchIndex = text.toLowerCase().indexOf(query);
392
393
  }
393
394
  }
394
- const id = source ? `${entry.id}@${historyFileKey(source)}` : entry.id!;
395
- const header = `${source ? `${source} ` : ""}${entry.timestamp ?? ""} [window ${item.windowId}] [${id}] `;
395
+ const id = source ? `${entry.id}@${shortKey(source)}` : entry.id!;
396
+ const header = `${entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${id}] `;
396
397
  return {
397
398
  id,
398
399
  priority,
@@ -419,8 +420,8 @@ function* windowEntries(entries: Iterable<EntryLike>): Generator<WindowedEntry>
419
420
  }
420
421
  }
421
422
 
422
- /** JSONL splits on LF, not the Unicode separators that Node's readline also recognizes. */
423
- async function* jsonlLines(file: string, signal?: AbortSignal): AsyncGenerator<string> {
423
+ /** Split on LF only: JSONL strings and notes can contain Unicode separators that Node's readline also splits on. */
424
+ async function* lfLines(file: string, signal?: AbortSignal): AsyncGenerator<string> {
424
425
  const stream = createReadStream(file, { encoding: "utf8", signal });
425
426
  let pending: string[] = [];
426
427
  for await (const chunk of stream) {
@@ -436,10 +437,56 @@ async function* jsonlLines(file: string, signal?: AbortSignal): AsyncGenerator<s
436
437
  if (last) yield last;
437
438
  }
438
439
 
440
+ /** Git's heuristic: a NUL byte in the first 8,000 bytes marks binary content. */
441
+ async function isBinaryFile(path: string): Promise<boolean> {
442
+ const file = await open(path, "r");
443
+ try {
444
+ const { buffer, bytesRead } = await file.read(Buffer.alloc(8_000), 0, 8_000, 0);
445
+ return buffer.subarray(0, bytesRead).includes(0);
446
+ } finally {
447
+ await file.close();
448
+ }
449
+ }
450
+
451
+ const execFileAsync = promisify(execFile);
452
+ /**
453
+ * ripgrep needles whose raw JSONL matches cover every normalized match, or undefined when only a full scan is exact.
454
+ * Raw JSONL escapes quotes, backslashes, and control characters; flattenEntry adds brackets, parentheses, braces,
455
+ * colons, `$`, spaces beside those labels, and label-only phrases. Image summaries ("3 images: image/png,
456
+ * image/jpeg") come only from entries with an image block, so their text needs that block instead.
457
+ */
458
+ function prefilterNeedles(query: string): string[] | undefined {
459
+ if (/["\\[\](){}:$\p{Cc}]/u.test(query) || query.trim() !== query) return undefined;
460
+ if (["call id", "execution arguments", "no handoff", "excluded from model context by pi"].some((phrase) => phrase.includes(query))) return undefined;
461
+ const image = query.includes(",") || /^\d+ ?i?m?a?g?e?s?$/.test(query) || ["images", "unknown type"].some((phrase) => phrase.includes(query));
462
+ return image ? [query, '"type":"image"'] : [query];
463
+ }
464
+
465
+ /** Session files whose raw JSONL contains any needle in any case, via Pi's managed ripgrep or PATH; undefined means scan every file. */
466
+ async function filesContaining(dir: string, needles: string[], signal?: AbortSignal): Promise<Set<string> | undefined> {
467
+ const args = [
468
+ "--files-with-matches", "--fixed-strings", "--ignore-case", "--no-ignore", "--hidden", "--no-messages",
469
+ "--glob", "*.jsonl", ...needles.flatMap((needle) => ["--regexp", needle]), dir,
470
+ ];
471
+ for (const rg of [join(getAgentDir(), "bin", "rg"), "rg"]) {
472
+ try {
473
+ const { stdout } = await execFileAsync(rg, args, { signal, maxBuffer: 64 * 1024 * 1024 });
474
+ return new Set(stdout.split("\n").filter(Boolean).map((file) => resolve(file)));
475
+ } catch (error) {
476
+ const { code } = error as { code?: unknown };
477
+ if (code === 1) return new Set();
478
+ if (code === "ENOENT") continue;
479
+ signal?.throwIfAborted();
480
+ return undefined;
481
+ }
482
+ }
483
+ return undefined;
484
+ }
485
+
439
486
  async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncGenerator<WindowedEntry> {
440
487
  if (!existsSync(file)) return;
441
488
  const windows = new Map<string, string>();
442
- for await (const line of jsonlLines(file, signal)) {
489
+ for await (const line of lfLines(file, signal)) {
443
490
  let entry: EntryLike;
444
491
  try {
445
492
  entry = JSON.parse(line) as EntryLike;
@@ -454,18 +501,15 @@ async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncG
454
501
 
455
502
  function sessionFiles(dir: string): string[] {
456
503
  if (!existsSync(dir)) return [];
457
- const files = readdirSync(dir, { recursive: true, withFileTypes: true })
504
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
458
505
  .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl") && !entry.name.includes(".intent."))
459
506
  .map((entry) => join(entry.parentPath, entry.name));
460
- if (files.length < 2) return files;
461
- return files
462
- .map((file) => ({ file, mtime: statSync(file).mtimeMs }))
463
- .sort((a, b) => b.mtime - a.mtime)
464
- .map(({ file }) => file);
465
507
  }
466
508
 
509
+ /** Project session files, newest-modified first, optionally limited to one file key or to prefiltered candidates. */
467
510
  async function* scopedSessionFiles(
468
- dir: string, cwd: string, currentFile: string | undefined, signal?: AbortSignal, fileKey?: string,
511
+ dir: string, cwd: string, currentFile: string | undefined, signal?: AbortSignal,
512
+ { fileKey, candidates }: { fileKey?: string; candidates?: Set<string> } = {},
469
513
  ): AsyncGenerator<string> {
470
514
  const files = sessionFiles(dir);
471
515
  const fileSet = new Set(files);
@@ -474,7 +518,7 @@ async function* scopedSessionFiles(
474
518
  const known = visible.get(file);
475
519
  if (known !== undefined) return known;
476
520
  let header: { cwd?: string } | null = null;
477
- for await (const line of jsonlLines(file, signal)) {
521
+ for await (const line of lfLines(file, signal)) {
478
522
  try {
479
523
  const entry = JSON.parse(line) as { type?: string; id?: unknown; cwd?: unknown } | null;
480
524
  if (!entry) continue;
@@ -501,20 +545,16 @@ async function* scopedSessionFiles(
501
545
  visible.set(file, allowed);
502
546
  return allowed;
503
547
  };
504
- for (const file of files) {
505
- if (fileKey !== undefined && historyFileKey(relative(dir, file)) !== fileKey) continue;
548
+ const selected = files
549
+ .filter((file) => (!candidates || candidates.has(resolve(file))) &&
550
+ (fileKey === undefined || fileKey.startsWith(shortKey(relative(dir, file)))))
551
+ .map((file) => ({ file, mtime: statSync(file).mtimeMs }))
552
+ .sort((a, b) => b.mtime - a.mtime);
553
+ for (const { file } of selected) {
506
554
  if (await belongs(file)) yield file;
507
555
  }
508
556
  }
509
557
 
510
- function currentWindowId(entries: readonly EntryLike[]): string {
511
- for (let i = entries.length - 1; i >= 0; i--) {
512
- const entry = entries[i];
513
- if (isWindow(entry) && entry.id) return entry.id;
514
- }
515
- return "initial";
516
- }
517
-
518
558
  function excerpt(text: string, limit: number): string {
519
559
  if (text.length <= limit) return text;
520
560
  const marker = "\n… middle omitted …\n";
@@ -528,7 +568,7 @@ function boundedBlock(header: string, text: string, limit: number): string {
528
568
  return textLimit ? `${header}\n${excerpt(text, textLimit)}` : header;
529
569
  }
530
570
 
531
- function recoveryRecord(entry: EntryLike): RecoveryRecord | undefined {
571
+ function recoveryRecord(entry: EntryLike, ownerQuestion: boolean): RecoveryRecord | undefined {
532
572
  let kind: RecoveryRecord["kind"];
533
573
  let label: string;
534
574
  let text: string;
@@ -543,6 +583,7 @@ function recoveryRecord(entry: EntryLike): RecoveryRecord | undefined {
543
583
  entry.message?.role === "toolResult" &&
544
584
  entry.message.toolName === "ask_question" &&
545
585
  entry.message.namespace === undefined &&
586
+ ownerQuestion &&
546
587
  entry.message.isError !== true
547
588
  ) {
548
589
  kind = "owner";
@@ -571,6 +612,14 @@ function formatRecoveryRecord(record: RecoveryRecord, limit: number): string {
571
612
  return boundedBlock(`[${record.label} | ${record.timestamp} | entry ${record.id}]`, record.text, limit);
572
613
  }
573
614
 
615
+ /** Owner inputs keep a one-line preview: they are direct intent, and most are short. */
616
+ function indexLine(record: RecoveryRecord): string {
617
+ const header = formatRecoveryRecord(record, 0);
618
+ if (record.kind !== "owner") return header;
619
+ const preview = record.text.replace(/\s+/g, " ").trim();
620
+ return `${header} ${preview.length > 120 ? `${preview.slice(0, 119)}…` : preview}`;
621
+ }
622
+
574
623
  function formatPriorCheckpoint(entry: EntryLike | undefined, limit: number): string | undefined {
575
624
  const handoff = windowHandoff(entry)?.trim();
576
625
  if (!entry || !handoff) return undefined;
@@ -642,13 +691,10 @@ function recoverableToolResults(
642
691
  return { callId: allLinked ? (call?.id ?? "unknown").slice(0, 120) : undefined, blocks };
643
692
  }
644
693
 
645
- function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly ProjectedEntry[], maxChars: number): string {
694
+ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly ProjectedEntry[], maxChars: number, ownerQuestionRegistered: boolean): string {
646
695
  const { start, boundary: priorWindow } = contextStart(entries);
647
696
  const current = entries.slice(start);
648
- const edits = new Map<string, EntryLike>();
649
- for (const entry of entries) {
650
- if (entry.type === "context_edit" && entry.targetId) edits.set(entry.targetId, entry);
651
- }
697
+ const edits = contextEdits(entries);
652
698
  const omittedProjectedIds = new Set(projected.filter(({ messages }) => !messages.length).map(({ sourceEntry }) => sourceEntry.id));
653
699
  const edited = current.flatMap((entry) => {
654
700
  if (entry.id && omittedProjectedIds.has(entry.id)) return [];
@@ -659,8 +705,15 @@ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly Pro
659
705
  if (entry.type === "custom_message") return [{ ...entry, id: edit.id, content: edit.replacement.content }];
660
706
  return [entry];
661
707
  });
708
+ const projectedCalls = new Map(projected.flatMap(({ messages }) => messages.flatMap((message) =>
709
+ message.role === "assistant" && Array.isArray(message.content)
710
+ ? (message.content as ToolCallLike[]).filter((call) => call.type === "toolCall").map((call) => [call.id, call] as const) : [])));
662
711
  const records = edited
663
- .map((entry) => recoveryRecord(entry))
712
+ .map((entry) => {
713
+ // Providers can omit call namespaces; both registration and projected call must prove ownership.
714
+ const call = projectedCalls.get(entry.message?.toolCallId);
715
+ return recoveryRecord(entry, ownerQuestionRegistered && call?.name === "ask_question" && call.namespace === undefined);
716
+ })
664
717
  .filter((record): record is RecoveryRecord => record !== undefined);
665
718
  const firstOwnerRequest = records.find((record) => record.label === "owner input") ?? records[0];
666
719
  const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
@@ -721,16 +774,10 @@ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly Pro
721
774
  ? Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(fixedBudget / fixedCount))
722
775
  : MAX_RECOVERY_RECORD_CHARS;
723
776
  const prior = formatPriorCheckpoint(priorWindow, fixedLimit);
724
- const formatted = new Map(
725
- records.map((record) => [
726
- record,
727
- formatRecoveryRecord(record, selected.has(record) ? fixedLimit : MAX_RECOVERY_RECORD_CHARS),
728
- ]),
729
- );
730
- const fixedParts = () => [
777
+ const fixedParts = [
731
778
  preamble,
732
779
  currentHeader,
733
- ...records.filter((record) => selected.has(record)).map((record) => formatted.get(record)!),
780
+ ...records.filter((record) => selected.has(record)).map((record) => formatRecoveryRecord(record, fixedLimit)),
734
781
  ];
735
782
 
736
783
  let batch: string | undefined;
@@ -738,11 +785,11 @@ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly Pro
738
785
  const availableText = Math.max(
739
786
  0,
740
787
  maxChars -
741
- joinedLength([...fixedParts(), bareBatch, prior]) -
788
+ joinedLength([...fixedParts, bareBatch, prior]) -
742
789
  HANDOFF_OVERHEAD_RESERVE -
743
790
  batchBlocks.length,
744
791
  );
745
- const perBlock = Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(availableText / batchBlocks.length));
792
+ const perBlock = Math.min(MAX_RECOVERY_RESULT_CHARS, Math.floor(availableText / batchBlocks.length));
746
793
  batch = [
747
794
  batchHeader,
748
795
  batchOmission,
@@ -752,21 +799,22 @@ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly Pro
752
799
  .join("\n\n");
753
800
  }
754
801
 
755
- let optionalBudget = Math.max(0, maxChars - joinedLength([...fixedParts(), batch, prior]) - HANDOFF_OVERHEAD_RESERVE);
756
- for (let i = records.length - 1; i >= 0; i--) {
757
- const record = records[i];
758
- if (selected.has(record)) continue;
759
- const length = formatted.get(record)!.length + 2;
760
- if (length > optionalBudget) continue;
761
- selected.add(record);
762
- optionalBudget -= length;
802
+ // Other inputs are indexed, newest kept first when space runs out; history read recovers their full text.
803
+ const others = records.filter((record) => !selected.has(record));
804
+ const indexHeader = "Other current-window inputs, oldest first (recover with history read):";
805
+ let indexBudget = Math.max(0, maxChars - joinedLength([...fixedParts, indexHeader, batch, prior]) - HANDOFF_OVERHEAD_RESERVE);
806
+ const listed: string[] = [];
807
+ for (let i = others.length - 1; i >= 0; i--) {
808
+ const line = indexLine(others[i]);
809
+ if (line.length + 1 > indexBudget) break;
810
+ listed.unshift(line);
811
+ indexBudget -= line.length + 1;
763
812
  }
764
-
765
- const omitted = records.filter((record) => !selected.has(record));
766
- const omission = omitted.length
767
- ? `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.`
813
+ const unlisted = others.length - listed.length;
814
+ const index = others.length
815
+ ? [indexHeader, ...(unlisted ? [`${unlisted} earlier input(s) not listed; use history search to find them.`] : []), ...listed].join("\n")
768
816
  : undefined;
769
- return [...fixedParts(), omission, batch, prior].filter((part): part is string => Boolean(part)).join("\n\n");
817
+ return [...fixedParts, index, batch, prior].filter((part): part is string => Boolean(part)).join("\n\n");
770
818
  }
771
819
 
772
820
  function reminderMatches(details: unknown, fingerprint: ReminderFingerprint): boolean {
@@ -784,7 +832,7 @@ function hasReminder(entries: readonly EntryLike[], fingerprint: ReminderFingerp
784
832
  );
785
833
  }
786
834
 
787
- function budgetFor(ctx: PolicyContext, contextWindow = ctx.model?.contextWindow): Budget | undefined {
835
+ function budgetFor(ctx: PolicyContext, contextWindow = ctx.getContextUsage()?.contextWindow): Budget | undefined {
788
836
  if (!contextWindow || contextWindow <= 0) return undefined;
789
837
  const { enabled, reserveTokens } = ctx.getCompactionSettings();
790
838
  const usable = contextWindow - reserveTokens;
@@ -792,92 +840,24 @@ function budgetFor(ctx: PolicyContext, contextWindow = ctx.model?.contextWindow)
792
840
  }
793
841
 
794
842
  /** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
795
- function freshPayloadChars(ctx: PolicyContext, toolTokens: number, pendingMessages: readonly MessageLike[] = []): number {
796
- const contextWindow = ctx.model?.contextWindow;
797
- if (!contextWindow || contextWindow <= 0) return MAX_HANDOFF_CHARS;
798
- const budget = budgetFor(ctx, contextWindow);
799
- const line = budget?.enabled && budget.supported ? budget.rolloverAt : contextWindow;
800
- const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4);
843
+ function freshPayloadChars(ctx: PolicyContext, toolTokens: number, pendingMessages: readonly MessageLike[] = [], cap = MAX_HANDOFF_CHARS): number {
844
+ const budget = budgetFor(ctx);
845
+ if (!budget) return cap;
846
+ const line = budget.enabled && budget.supported ? budget.rolloverAt : budget.contextWindow;
847
+ const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + guidanceTokens(ctx);
801
848
  const pendingTokens = pendingMessages.reduce(
802
849
  (total, message) => total + Math.ceil(textOf(message).length / 4) + imagesOf(message.content).length * (ESTIMATED_IMAGE_CHARS / 4),
803
850
  0,
804
851
  );
805
852
  return Math.min(
806
- MAX_HANDOFF_CHARS,
853
+ cap,
807
854
  Math.max(0, Math.floor((line - PAGE_MARGIN_TOKENS - promptTokens - toolTokens - pendingTokens) / 2)) * 4,
808
855
  );
809
856
  }
810
857
 
811
- /** Bound only the carried receipts; edits preserve the journal and native call/result identity. */
812
- function boundWindowReceipts(
813
- ctx: ExtensionContext & PolicyContext,
814
- projected: readonly ProjectedEntry[],
815
- pending: readonly MessageLike[],
816
- toolTokens: number,
817
- ): ReceiptEdit[] | undefined {
818
- if (!ctx.model) return undefined;
819
- const estimate = estimateTokens as unknown as (message: MessageLike) => number;
820
- const messages = projected.flatMap((entry) => entry.messages);
821
- const budget = budgetFor(ctx);
822
- const reserve = budget?.enabled && budget.supported ? budget.reserveTokens : 0;
823
- const overhead =
824
- reserve +
825
- PAGE_MARGIN_TOKENS +
826
- Math.max(
827
- Math.ceil(ctx.getSystemPrompt().length / 4) + toolTokens,
828
- messages.filter((message) => message.role === "system").reduce((sum, message) => sum + estimate(message), 0),
829
- ) +
830
- pending.reduce((sum, message) => sum + estimate(message), 0);
831
- const fixed = messages.filter((message) => message.role !== "system" && message.role !== "toolResult");
832
- const available = ctx.model.contextWindow - overhead - fixed.reduce((sum, message) => sum + estimate(message), 0);
833
- const edits = new Map<string, EntryLike>();
834
- for (const entry of ctx.sessionManager.getBranch() as EntryLike[]) {
835
- if (entry.type === "context_edit" && entry.targetId) edits.set(entry.targetId, entry);
836
- }
837
- const receipts = projected.flatMap(({ sourceEntry, messages }) =>
838
- messages.filter((message) => message.role === "toolResult").map((message) => {
839
- if (!sourceEntry.id) throw new Error("Posthorse: retained receipt has no history entry");
840
- const recoveryId = edits.get(sourceEntry.id)?.id ?? sourceEntry.id;
841
- const images = imageSummary(imagesOf(message.content));
842
- const header = `Retained ${message.isError ? "error" : "result"} excerpt. Full content: history read id ${recoveryId}.${images ? `\n${images}` : ""}\n`;
843
- const cost = estimate(message);
844
- const minimum = Math.min(cost, Math.ceil(header.length / 4));
845
- return { id: sourceEntry.id, message, header, cost, minimum };
846
- }),
847
- );
848
- if (receipts.reduce((sum, receipt) => sum + receipt.cost, 0) <= available) return undefined;
849
- let extra = available - receipts.reduce((sum, receipt) => sum + receipt.minimum, 0);
850
- if (extra < 0) throw new Error("Posthorse: fresh context cannot fit fixed input and receipt recovery references");
851
- // Like handoffs, oversized receipt excerpts leave half the remaining capacity for continued work.
852
- extra = Math.floor(extra / 2);
853
- const drafts: ReceiptEdit[] = [];
854
- receipts.sort((a, b) => a.cost - a.minimum - (b.cost - b.minimum));
855
- for (const [index, receipt] of receipts.entries()) {
856
- const allowance = receipt.minimum + Math.min(receipt.cost - receipt.minimum, Math.floor(extra / (receipts.length - index)));
857
- extra -= allowance - receipt.minimum;
858
- if (receipt.cost <= allowance) continue;
859
- const content = receipt.header + excerpt(textOf(receipt.message), allowance * 4 - receipt.header.length);
860
- drafts.push({ type: "context_edit", targetId: receipt.id, replacement: { content } });
861
- }
862
- const replacements = new Map(drafts.map((draft) => [draft.targetId, draft.replacement.content]));
863
- const total = projected.reduce(
864
- (sum, entry) =>
865
- sum + entry.messages.reduce(
866
- (tokens, message) => tokens + (message.role === "system" ? 0 : estimate(
867
- replacements.has(entry.sourceEntry.id ?? "")
868
- ? { ...message, content: replacements.get(entry.sourceEntry.id!) }
869
- : message,
870
- )),
871
- 0,
872
- ),
873
- overhead,
874
- );
875
- if (total > ctx.model.contextWindow) throw new Error("Posthorse: retained receipts exceed fresh context capacity");
876
- return drafts;
877
- }
878
-
879
- /** Official Pi cannot expose its live settings to extensions; say so wherever the persisted snapshot is used. */
858
+ /** Official Pi cannot expose its live settings to extensions; budget reports say when they used the persisted snapshot. */
880
859
  const SNAPSHOT_NOTE = "Reminder and budget policy uses available settings (a persisted CLI snapshot by default); Pi's live settings control automatic compaction.";
860
+ const CHECKPOINT_STEPS = "update the task's current-state note (goal, progress, decisions, next steps) with available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. If the note is already current, leave it unchanged. Then call new_context";
881
861
 
882
862
  function unsupportedMessage(budget: Budget): string {
883
863
  const n = (value: number) => value.toLocaleString("en-US");
@@ -887,33 +867,38 @@ function unsupportedMessage(budget: Budget): string {
887
867
  /** Capacity before Pi's automatic line (or configured context limit), including page metadata and refusals. */
888
868
  function pageCapacity(ctx: PolicyContext, toolTokens: number): number {
889
869
  const usage = ctx.getContextUsage();
890
- if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens) / 4 + PAGE_MARGIN_TOKENS;
870
+ if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens, [], MAX_PAGE_CHARS) / 4 + PAGE_MARGIN_TOKENS;
891
871
  const budget = budgetFor(ctx, usage.contextWindow);
892
872
  const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
893
- return line - usage.tokens;
873
+ return line - usage.tokens - guidanceTokens(ctx);
894
874
  }
895
875
 
896
- function buildGuidance(ctx: PolicyContext, official: boolean): string {
876
+ function buildGuidance(ctx: PolicyContext): string {
897
877
  const budget = budgetFor(ctx);
898
878
  const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
879
+ const n = (value: number) => value.toLocaleString("en-US");
880
+ const capacity = budget
881
+ ? `Configured context capacity: ${n(budget.contextWindow)} 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, and new messages consume part of it.`
882
+ : "Configured context capacity is unknown.";
899
883
  let automatic: string;
900
884
  if (!enabled) {
901
- automatic = official
902
- ? "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available."
903
- : "Pi compaction is disabled, so Posthorse sends no checkpoint reminder and performs no automatic rollover. new_context remains available.";
885
+ automatic = "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available.";
904
886
  } else if (budget && !budget.supported) {
905
887
  automatic = unsupportedMessage(budget);
906
888
  } else {
907
889
  const deadline = budget
908
890
  ? `${Math.max(1, Math.round((budget.rolloverAt / budget.contextWindow) * 100))}% used`
909
891
  : "the configured Pi context limit";
910
- automatic = `Automatic Posthorse rollover follows Pi's enabled compaction setting. At most one best-effort checkpoint reminder may appear before the rollover line (${deadline}); a large turn, overflow, restart, or smaller model can skip it.\nWhen reminded, stop normal work, save goal/progress/decisions/next steps, then call new_context now.`;
892
+ const line = budget ? ` The rollover line is ${n(budget.rolloverAt)} tokens used under the available Pi settings.` : "";
893
+ automatic = `Automatic rollover follows Pi's compaction setting.${line} At most one checkpoint reminder may arrive before the rollover line (${deadline}); when it does, stop normal work, ${CHECKPOINT_STEPS}.`;
911
894
  }
912
895
  return `## Context self-management (Posthorse)
913
- 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.${official ? `\n${SNAPSHOT_NOTE}` : ""}
896
+ ${capacity}
897
+ 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.
914
898
  ${automatic}
915
- new_context requests a fresh Pi context after the current foreground tools succeed. Background work continues across the reset. Earlier conversation remains in the session transcript and is recoverable with notes and history.
916
- Automatic handoffs are emergency recovery records, not proof of current state. Restore notes/todos/history and verify live state before continuing stateful or external work.`;
899
+ 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.
900
+ 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.
901
+ Write handoffs and notes as normal readable prose; notes and history search match literal text.`;
917
902
  }
918
903
 
919
904
  function persistedPolicy(ctx: ExtensionContext): CompactionPolicy {
@@ -923,24 +908,74 @@ function persistedPolicy(ctx: ExtensionContext): CompactionPolicy {
923
908
  return settings.getCompactionSettings(ctx.model);
924
909
  }
925
910
 
926
- /** SDK hosts on official Pi can inject their live settings instead of the persisted snapshot; the fork always uses its own. */
911
+ /** ponytail: native estimates can omit request-local guidance; reserve its full section until post-transform usage is exposed. */
912
+ function guidanceTokens(ctx: PolicyContext): number {
913
+ return estimateTokens({ role: "system", content: `<posthorse>\n${buildGuidance(ctx)}\n</posthorse>`, timestamp: 0 });
914
+ }
915
+
916
+ /** SDK hosts can inject live settings instead of the persisted CLI snapshot. */
927
917
  export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => CompactionPolicy = persistedPolicy) => (pi: ExtensionAPI) => {
928
- const nativePi = pi as unknown as NativeExtensionAPI;
929
- const native = typeof nativePi.registerContextWindowHook === "function";
930
- const policy = (ctx: ExtensionContext): PolicyContext =>
931
- native ? (ctx as ExtensionContext & PolicyContext) : {
932
- model: ctx.model,
933
- getCompactionSettings: () => getPolicy(ctx),
934
- getContextUsage: () => ctx.getContextUsage(),
918
+ const resetRequests = new Map<string, string>();
919
+ // One synchronous safety evaluation only. Never carry this snapshot across an await.
920
+ const policy = (ctx: ExtensionContext, livePolicy?: CompactionPolicy): PolicyContext => {
921
+ let usageRead = false;
922
+ let usage: ContextUsage | undefined;
923
+ let settings: CompactionPolicy | undefined;
924
+ return {
925
+ getCompactionSettings: () => settings ??= { ...(livePolicy ?? getPolicy(ctx)) },
926
+ getContextUsage: () => {
927
+ if (!usageRead) {
928
+ usage = ctx.getContextUsage();
929
+ usageRead = true;
930
+ }
931
+ return usage;
932
+ },
935
933
  getSystemPrompt: () => ctx.getSystemPrompt(),
936
934
  };
935
+ };
936
+ type ReminderState = {
937
+ manager: ExtensionContext["sessionManager"];
938
+ sessionId: string;
939
+ file: string | undefined;
940
+ leaf: EntryLike | undefined;
941
+ windowId: string;
942
+ reminders: EntryLike[];
943
+ };
944
+ let reminderState: ReminderState | undefined;
945
+ const remindersFor = (ctx: ExtensionContext): ReminderState => {
946
+ const manager = ctx.sessionManager;
947
+ const sessionId = manager.getSessionId(), file = manager.getSessionFile();
948
+ const leaf = manager.getEntry(manager.getLeafId() ?? "") as EntryLike | undefined;
949
+ const previous = reminderState?.manager === manager && reminderState.sessionId === sessionId && reminderState.file === file
950
+ ? reminderState : undefined;
951
+ if (previous && previous.leaf === leaf) return previous;
952
+ const reversed: EntryLike[] = [];
953
+ let boundary: EntryLike | undefined, window: EntryLike | undefined;
954
+ let kept = false;
955
+ for (let entry = leaf; entry; entry = entry.parentId ? manager.getEntry(entry.parentId) as EntryLike | undefined : undefined) {
956
+ // Certify append-only ancestry by entry identity, not just a reusable id.
957
+ if (!boundary && previous?.leaf === entry) {
958
+ return reminderState = { manager, sessionId, file, leaf, windowId: previous.windowId,
959
+ reminders: [...previous.reminders, ...reversed.reverse().filter((entry) => entry.type === "custom_message" && isReminderType(entry.customType))] };
960
+ }
961
+ reversed.push(entry);
962
+ if (!boundary && (entry.type === "compaction" || entry.type === "context_window")) boundary = entry;
963
+ if (!window && isWindow(entry)) window = entry;
964
+ if (boundary && (boundary.type === "context_window" || entry.id === boundary.firstKeptEntryId)) kept = true;
965
+ // An ordinary compaction can keep a tail preceding the Posthorse boundary.
966
+ // Missing kept ids or a negative window lookup must reach the root once.
967
+ if (window && kept) break;
968
+ }
969
+ const entries = reversed.reverse();
970
+ return reminderState = { manager, sessionId, file, leaf, windowId: window?.id ?? "initial",
971
+ reminders: entries.slice(contextStart(entries).start).filter((entry) => entry.type === "custom_message" && isReminderType(entry.customType)) };
972
+ };
937
973
  registerPosthorseMessages(pi);
938
974
  const activeToolTokens = () => {
939
975
  const active = new Set(pi.getActiveTools());
940
976
  return pi
941
977
  .getAllTools()
942
- // The fork identifies namespaced tools by id; official Pi only has names.
943
- .filter((tool) => active.has((tool as typeof tool & { id?: string }).id ?? tool.name))
978
+ .filter((tool) => active.has(tool.name))
944
979
  .reduce(
945
980
  (total, tool) =>
946
981
  total +
@@ -952,12 +987,6 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
952
987
  );
953
988
  };
954
989
 
955
- if (native) {
956
- nativePi.registerContextWindowHook((event, ctx) =>
957
- boundWindowReceipts(ctx as ExtensionContext & PolicyContext, event.contextEntries, event.pendingMessages, activeToolTokens()),
958
- );
959
- }
960
-
961
990
  let pendingPageTokens = 0;
962
991
  let previousPageUsage: number | null | undefined;
963
992
  const pageSize = (ctx: PolicyContext, offset: number, imageCount: number, cursor?: string, imageOffset?: number) => {
@@ -969,7 +998,7 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
969
998
  previousPageUsage = usage;
970
999
  const remaining = pageCapacity(ctx, activeToolTokens()) - pendingPageTokens;
971
1000
  const chars = Math.min(
972
- MAX_HANDOFF_CHARS,
1001
+ MAX_PAGE_CHARS,
973
1002
  Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
974
1003
  );
975
1004
  if (chars < MIN_PAGE_CHARS) {
@@ -991,61 +1020,75 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
991
1020
  pendingPageTokens += Math.ceil(message.length / 4);
992
1021
  throw new Error(message);
993
1022
  };
994
- const notesPage = (ctx: PolicyContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string) => {
1023
+ const notesPage = (ctx: PolicyContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string, header = "") => {
995
1024
  const text = rows.length ? rows.join("\n") : empty;
996
- const chars = pageSize(ctx, offset, 0);
997
- if (offset && offset >= text.length) pageError(ctx, "Offset is past the end; restart with offset 0.");
1025
+ const chars = pageSize(ctx, offset, 0) - header.length;
998
1026
  // Reserve the complete continuation, even when one path or excerpt needs several pages.
999
1027
  const footer = (end: number) => `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`;
1028
+ if (chars <= footer(text.length).length) pageError(ctx, `Too little context remains to include the notes path. Call new_context first, then retry with offset ${offset}.`);
1029
+ if (offset && offset >= text.length) pageError(ctx, "Offset is past the end; restart with offset 0.");
1000
1030
  let end = Math.min(text.length, offset + chars);
1001
1031
  let start = 0;
1002
1032
  const starts: number[] = [];
1033
+ const maxRows = kind === "notes-search" ? 20 : 100;
1003
1034
  for (const row of rows) {
1004
1035
  if (start >= end) break;
1005
1036
  if (start + row.length > offset) starts.push(start);
1006
1037
  start += row.length + 1;
1007
- if (kind === "notes-search" && starts.length === 20) { end = Math.min(end, start - 1); break; }
1038
+ if (starts.length === maxRows) { end = Math.min(end, start - 1); break; }
1008
1039
  }
1009
1040
  // A result-count cap can require continuation even when the character cap did not.
1010
1041
  if (end < text.length) end = Math.min(end, offset + chars - footer(text.length).length);
1011
- return pageResult(text.slice(offset, end) + (end < text.length ? footer(end) : ""), [], { kind, count: starts.filter((start) => start < end).length, page: { offset, end, total: text.length } });
1042
+ return pageResult(header + text.slice(offset, end) + (end < text.length ? footer(end) : ""), [], { kind, headerLength: header.length, count: starts.filter((start) => start < end).length, page: { offset, end, total: text.length } });
1012
1043
  };
1013
1044
  pi.on("turn_start", () => {
1045
+ resetRequests.clear();
1014
1046
  pendingPageTokens = 0;
1015
1047
  previousPageUsage = undefined;
1016
1048
  });
1017
1049
 
1018
1050
  pi.on("session_start", () => {
1051
+ resetRequests.clear();
1019
1052
  pendingPageTokens = 0;
1020
1053
  previousPageUsage = undefined;
1054
+ reminderState = undefined;
1021
1055
  });
1056
+ pi.on("session_tree", () => { reminderState = undefined; });
1022
1057
 
1023
- pi.on("before_agent_start", (event, ctx) => {
1024
- const guidance = buildGuidance(policy(ctx), !native);
1025
- // An earlier full-prompt override makes section edits invisible to Pi.
1026
- if (event.systemPromptOptions.forceSystemPrompt !== undefined) {
1027
- return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` };
1058
+ let runPromptOptions: { forceSystemPrompt?: string } | undefined;
1059
+ pi.on("before_agent_start", (event) => {
1060
+ runPromptOptions = event.systemPromptOptions;
1061
+ });
1062
+
1063
+ // Continuations do not rerun before_agent_start, even after a model switch or reset.
1064
+ pi.on("context_with_system", (event, ctx) => {
1065
+ const guidance = buildGuidance(policy(ctx));
1066
+ const system = event.messages.findLast((message) => message.role === "system" && typeof message.sections?.posthorse === "string")
1067
+ ?? event.messages.find((message) => message.role === "system");
1068
+ if (!system || system.role !== "system") return;
1069
+ if (runPromptOptions?.forceSystemPrompt !== undefined) {
1070
+ // ponytail: official Pi applies forced prompts after these hooks; keep guidance in
1071
+ // request-local conversation until the host projects forced text before the hooks.
1072
+ event.messages.splice(1, 0, { role: "custom", customType: "posthorse-guidance", content: guidance, display: false, timestamp: 0 });
1073
+ } else {
1074
+ system.sections = { ...system.sections, posthorse: `<posthorse>\n${guidance}\n</posthorse>` };
1028
1075
  }
1029
- event.systemPromptOptions.sections.posthorse = guidance;
1030
1076
  });
1031
1077
 
1032
1078
  pi.on("turn_end", (event, ctx) => {
1079
+ const requested = event.toolResults.find((result) => result.toolName === "new_context" && resetRequests.has(result.toolCallId));
1080
+ const summary = requested ? resetRequests.get(requested.toolCallId) : undefined;
1081
+ resetRequests.clear();
1033
1082
  const host = policy(ctx);
1034
1083
  if (
1035
1084
  event.message.role === "assistant" &&
1036
1085
  (event.message.stopReason === "error" || event.message.stopReason === "aborted")
1037
1086
  )
1038
1087
  return;
1039
- const requested = event.toolResults.find((result) => result.toolName === "new_context");
1040
1088
  // Suppress clear successes; turn_end can also include unrelated background errors.
1041
1089
  // If Pi still rolls over, context filtering below removes any stale reminder.
1042
- if (requested && !event.toolResults.some((result) => result.isError)) {
1043
- // The fork commits new_context from the tool result; official Pi needs a retain-none compaction draft.
1044
- if (native || event.outcome === "aborted" || ctx.signal?.aborted) return;
1045
- const content = (event.message as MessageLike).content;
1046
- const call = (Array.isArray(content) ? content as ToolCallLike[] : []).find((block) => block?.type === "toolCall" && block.id === requested.toolCallId);
1047
- const handoff = (call?.arguments as { handoff?: unknown } | undefined)?.handoff;
1048
- const summary = (typeof handoff === "string" && handoff.trim()) || EMPTY_HANDOFF;
1090
+ if (summary !== undefined && !event.toolResults.some((result) => result.isError)) {
1091
+ if (event.outcome === "aborted" || ctx.signal?.aborted) return;
1049
1092
  // Input steered in after execute() checked capacity can leave too little room.
1050
1093
  if (summary.length > freshPayloadChars(host, activeToolTokens(), event.context.pendingMessages)) {
1051
1094
  return { entries: [...event.entries, { type: "custom_message" as const, customType: "posthorse-reset-deferred", display: true,
@@ -1062,18 +1105,18 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1062
1105
  const remindAt = budget.rolloverAt - reminderBuffer;
1063
1106
  if (usage.tokens < remindAt) return;
1064
1107
 
1065
- const branch = ctx.sessionManager.getBranch() as EntryLike[];
1108
+ const state = remindersFor(ctx);
1066
1109
  const fingerprint: ReminderFingerprint = {
1067
- windowId: currentWindowId(branch),
1110
+ windowId: state.windowId,
1068
1111
  contextWindow: budget.contextWindow,
1069
1112
  reserveTokens: budget.reserveTokens,
1070
1113
  };
1071
1114
  // A reminder a compaction summarized away no longer reaches the model.
1072
- if (hasReminder(branch.slice(contextStart(branch).start), fingerprint)) return;
1115
+ if (hasReminder(state.reminders, fingerprint)) return;
1073
1116
  pi.sendMessage(
1074
1117
  {
1075
1118
  customType: REMINDER_TYPE,
1076
- content: `[posthorse] Checkpoint now: ${(budget.rolloverAt - usage.tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Stop normal work, save goal/progress/decisions/next steps, then call new_context now. This reminder is best-effort; a large turn, overflow, restart, or smaller model can reach rollover without one.`,
1119
+ content: `[posthorse] Checkpoint now: ${(budget.rolloverAt - usage.tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Stop normal work, ${CHECKPOINT_STEPS}.`,
1077
1120
  display: true,
1078
1121
  details: fingerprint,
1079
1122
  },
@@ -1084,88 +1127,69 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1084
1127
  // Filter active input only; raw reminders remain in history for deduplication and recovery.
1085
1128
  pi.on("context", (event, ctx) => {
1086
1129
  if (!event.messages.some((message) => message.role === "custom" && isReminderType(message.customType))) return;
1087
- let windowId: unknown;
1088
- if (native) {
1089
- const marker = event.messages.find((message) => message.role === "custom" && message.customType === "context-window");
1090
- windowId = marker?.role === "custom" ? (marker.details as { windowId?: unknown } | undefined)?.windowId : "initial";
1091
- if (typeof windowId !== "string") return;
1092
- }
1130
+ // The fork's window marker saves a branch walk, but a compaction inside the window drops it.
1131
+ const marker = event.messages.find((message) => message.role === "custom" && message.customType === "context-window");
1132
+ const windowId = marker?.role === "custom"
1133
+ ? (marker.details as { windowId?: unknown } | undefined)?.windowId
1134
+ : remindersFor(ctx).windowId;
1135
+ if (typeof windowId !== "string") return;
1093
1136
  const host = policy(ctx);
1094
1137
  const budget = budgetFor(host);
1095
1138
  const fingerprint: ReminderFingerprint = {
1096
- windowId: native ? windowId as string : currentWindowId(ctx.sessionManager.getBranch() as EntryLike[]),
1139
+ windowId,
1097
1140
  contextWindow: budget?.contextWindow,
1098
1141
  reserveTokens: budget?.reserveTokens,
1099
1142
  };
1143
+ const enabled = host.getCompactionSettings().enabled;
1100
1144
  const stale = (message: (typeof event.messages)[number]) =>
1101
1145
  message.role === "custom" &&
1102
1146
  isReminderType(message.customType) &&
1103
- (!host.getCompactionSettings().enabled || !reminderMatches(message.details, fingerprint));
1147
+ (!enabled || !reminderMatches(message.details, fingerprint));
1104
1148
  if (event.messages.some(stale)) return { messages: event.messages.filter((message) => !stale(message)) };
1105
1149
  });
1106
1150
 
1107
- if (native) {
1108
- // Claim Pi's automatic threshold/overflow trigger with a fresh window: no summary, no summarization auth.
1109
- nativePi.on("session_before_auto_compact", (event, ctx) => {
1110
- const host = policy(ctx as ExtensionContext);
1111
- const budget = budgetFor(host);
1112
- // An unsupported budget would roll over every turn; leave Pi's own behavior in place instead.
1113
- if (budget && !budget.supported) return undefined;
1114
- const limit = freshPayloadChars(host, activeToolTokens(), event.pendingMessages);
1115
- const projected = (ctx as ExtensionContext).sessionManager.buildSessionProjection().entries;
1116
- const handoff = limit >= MIN_PAGE_CHARS ? buildAutoHandoff(event.branchEntries, projected, limit) : "";
1117
- if (!handoff || handoff.length > limit) {
1118
- // Only receipts Pi will retain require shaping; consumed results must not force an empty cut.
1119
- return event.retainedToolResultIds.length ? { newContext: {} } : undefined;
1120
- }
1121
- return { newContext: { handoff } };
1122
- });
1123
- } else {
1124
- // Official Pi owns thresholds, recovery retries, and whether another request follows.
1125
- pi.on("session_before_compact", (event, ctx) => {
1126
- if (event.reason === "manual") return;
1127
- const cancelled = () => event.signal.aborted || ctx.signal?.aborted;
1151
+ // Pi owns thresholds, recovery retries, and whether another request follows.
1152
+ pi.on("session_before_compact", (event, ctx) => {
1153
+ if (event.reason === "manual") return;
1154
+ const cancelled = () => event.signal.aborted || ctx.signal?.aborted;
1155
+ if (cancelled()) return { cancel: true };
1156
+ try {
1157
+ // The event carries Pi's live settings; prefer them over the persisted snapshot.
1158
+ const host = policy(ctx, event.preparation.settings);
1159
+ // Small/unknown context windows deliberately keep Pi's own compaction.
1160
+ if (!budgetFor(host)?.supported) return;
1161
+ const limit = freshPayloadChars(host, activeToolTokens());
1162
+ const ownerQuestionRegistered = pi.getAllTools().some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
1163
+ const handoff = buildAutoHandoff(event.branchEntries, ctx.sessionManager.buildSessionProjection().entries, limit, ownerQuestionRegistered);
1164
+ if (limit < MIN_PAGE_CHARS || handoff.length > limit) throw new Error("Too little fresh context capacity for an automatic recovery record.");
1128
1165
  if (cancelled()) return { cancel: true };
1129
- try {
1130
- // The event carries Pi's live settings; prefer them over the persisted snapshot.
1131
- const host: PolicyContext = { ...policy(ctx), getCompactionSettings: () => event.preparation.settings };
1132
- // Small/unknown context windows deliberately keep Pi's own compaction.
1133
- if (!budgetFor(host)?.supported) return;
1134
- const limit = freshPayloadChars(host, activeToolTokens());
1135
- const handoff = buildAutoHandoff(ctx.sessionManager.getBranch() as EntryLike[], ctx.sessionManager.buildSessionProjection().entries, limit);
1136
- if (limit < MIN_PAGE_CHARS || handoff.length > limit) throw new Error("Too little fresh context capacity for an automatic recovery record.");
1137
- if (cancelled()) return { cancel: true };
1138
- // firstKeptEntryId must name an entry; an invisible sentinel retains no prior conversation.
1139
- pi.appendEntry("posthorse-boundary", {});
1140
- const firstKeptEntryId = ctx.sessionManager.getLeafId();
1141
- if (!firstKeptEntryId) throw new Error("Pi did not persist the context boundary.");
1142
- return { compaction: { summary: handoff, firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details: { posthorse: 1, reason: event.reason } } };
1143
- } catch (error) {
1144
- // Pi swallows handler errors and would then run its summarizer; cancel instead.
1145
- if (!cancelled()) {
1146
- const message = `Posthorse automatic rollover cancelled: ${error instanceof Error ? error.message : String(error)}`;
1147
- if (ctx.hasUI) ctx.ui.notify(message, "error");
1148
- else console.error(message);
1149
- }
1150
- return { cancel: true };
1166
+ // firstKeptEntryId must name an entry; an invisible sentinel retains no prior conversation.
1167
+ pi.appendEntry("posthorse-boundary", {});
1168
+ const firstKeptEntryId = ctx.sessionManager.getLeafId();
1169
+ if (!firstKeptEntryId) throw new Error("Pi did not persist the context boundary.");
1170
+ return { compaction: { summary: handoff, firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details: { posthorse: 1, reason: event.reason } } };
1171
+ } catch (error) {
1172
+ // Pi swallows handler errors and would then run its summarizer; cancel instead.
1173
+ if (!cancelled()) {
1174
+ const message = `Posthorse automatic rollover cancelled: ${error instanceof Error ? error.message : String(error)}`;
1175
+ if (ctx.hasUI) ctx.ui.notify(message, "error");
1176
+ else console.error(message);
1151
1177
  }
1152
- });
1153
- }
1178
+ return { cancel: true };
1179
+ }
1180
+ });
1154
1181
 
1155
1182
  pi.registerTool({
1156
1183
  name: "new_context",
1157
1184
  label: "New Context",
1158
1185
  ...toolCards("new_context"),
1159
1186
  description:
1160
- "Request a fresh context window after the current foreground tools succeed. Background work continues across the reset. Earlier conversation leaves active context without a generated summary but remains recoverable through history. Pass concise continuation state in handoff, or save richer state with notes first.",
1161
- promptSnippet: "request a fresh context window with an optional atomic handoff",
1162
- promptGuidelines: [
1163
- "Before calling new_context, pass concise continuation state in handoff or save durable goal/progress/decisions/next-steps with notes",
1164
- ],
1187
+ "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.",
1188
+ promptSnippet: "request a fresh context window with an optional handoff",
1165
1189
  parameters: Type.Object({
1166
1190
  handoff: Type.Optional(
1167
1191
  Type.String({
1168
- description: "Concise state the fresh window needs to continue correctly",
1192
+ description: "What the fresh window needs to continue, as readable prose",
1169
1193
  maxLength: MAX_HANDOFF_CHARS,
1170
1194
  }),
1171
1195
  ),
@@ -1183,8 +1207,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1183
1207
  [],
1184
1208
  { kind: "new-context" },
1185
1209
  );
1186
- // Official Pi ignores this field; its reset is committed at turn_end from the call arguments.
1187
- return native ? { ...result, newContext: { handoff: trimmed } } : result;
1210
+ resetRequests.set(_id, trimmed || EMPTY_HANDOFF);
1211
+ return result;
1188
1212
  },
1189
1213
  });
1190
1214
 
@@ -1193,8 +1217,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1193
1217
  label: "Context Remaining",
1194
1218
  ...toolCards("get_context_remaining"),
1195
1219
  description:
1196
- "Best available native estimate of the context budget: tokens until Pi's automatic rollover line and until the configured context limit.",
1197
- promptSnippet: "check the remaining context budget only when needed",
1220
+ "Estimate tokens left before Pi's automatic rollover and the configured context limit. Check before reporting remaining space or changing your work plan because of context limits. A checkpoint reminder already arrives before rollover; no routine checks are needed.",
1221
+ promptSnippet: "verify remaining context before reporting a token count or changing plans because of context limits",
1198
1222
  parameters: Type.Object({}),
1199
1223
  async execute(_id, _params, _signal, _onUpdate, ctx) {
1200
1224
  const host = policy(ctx);
@@ -1207,8 +1231,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1207
1231
  rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
1208
1232
  rolloverAt: budget?.rolloverAt,
1209
1233
  };
1210
- 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}`}`;
1211
- if (!budget?.enabled) return textResult(`Automatic rollover is disabled${native ? "" : " in the available settings"} (Pi compaction.enabled=false). ${configured}`, [], display);
1234
+ 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. ${SNAPSHOT_NOTE}`;
1235
+ if (!budget?.enabled) return textResult(`Automatic rollover is disabled in the available settings (Pi compaction.enabled=false). ${configured}`, [], display);
1212
1236
  if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${configured}`, [], display);
1213
1237
  return textResult(
1214
1238
  `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
@@ -1222,18 +1246,14 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1222
1246
  label: "Notes",
1223
1247
  ...toolCards("notes"),
1224
1248
  description:
1225
- "Persistent notes in .pi/notes/ that survive context resets. Ops: list/read/search (paged; repeat with offset to continue), write (create/replace; empty content clears), append. Search matches case-insensitive substrings over note lines. Git worktrees share notes: at the main checkout for conventional .git layouts, or in the common Git directory when metadata is stored separately.",
1249
+ "Durable notes shared across Git worktrees and context resets. Ops: list (newest first, size/date; optional folder), read, search (case-insensitive text), write (replace; empty clears), append (one newline-terminated record). list/read/search are paged; continue with the returned offset. list shows the absolute notes directory; read shows the absolute file path. Prefer available file-editing tools for changed sections of one concise current-state note; write for creation or substantial restructuring. Link fuller evidence instead of copying it.",
1226
1250
  promptSnippet: "save and recall durable state that survives context resets",
1227
- promptGuidelines: [
1228
- "Use notes for durable state too large for a new_context handoff",
1229
- "Reload relevant notes after a context rollover",
1230
- ],
1231
1251
  parameters: Type.Object({
1232
1252
  op: Type.Union(
1233
1253
  [Type.Literal("list"), Type.Literal("read"), Type.Literal("write"), Type.Literal("append"), Type.Literal("search")],
1234
1254
  { description: "Operation to perform" },
1235
1255
  ),
1236
- path: Type.Optional(Type.String({ description: "Note path relative to .pi/notes/ (read/write/append)" })),
1256
+ path: Type.Optional(Type.String({ description: "Note or folder path relative to .pi/notes/" })),
1237
1257
  content: Type.Optional(Type.String({ description: "Full file content (write) or text to add (append)" })),
1238
1258
  query: Type.Optional(Type.String({ description: "Substring to find in notes (search)" })),
1239
1259
  offset: Type.Optional(Type.Integer({ description: "Character offset for list/read/search (default 0)", minimum: 0 })),
@@ -1247,7 +1267,7 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1247
1267
  }
1248
1268
  return join(dir, relative);
1249
1269
  };
1250
- const walk = (directory: string, output: string[], ancestors = new Set<string>()) => {
1270
+ const walk = (directory: string, output: NoteFile[], ancestors = new Set<string>()) => {
1251
1271
  const root = realpathSync(directory);
1252
1272
  if (ancestors.has(root)) return;
1253
1273
  ancestors.add(root);
@@ -1256,30 +1276,51 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1256
1276
  const info = statSync(path, { throwIfNoEntry: false });
1257
1277
  if (!info) continue;
1258
1278
  if (info.isDirectory()) walk(path, output, ancestors);
1259
- else output.push(path);
1279
+ else output.push({ path, size: info.size, mtimeMs: info.mtimeMs });
1260
1280
  }
1261
1281
  ancestors.delete(root);
1262
1282
  };
1283
+ const notesUnder = (target: string): NoteFile[] => {
1284
+ const info = statSync(target, { throwIfNoEntry: false });
1285
+ if (!info) return [];
1286
+ const files: NoteFile[] = [];
1287
+ if (info.isDirectory()) walk(target, files);
1288
+ else files.push({ path: target, size: info.size, mtimeMs: info.mtimeMs });
1289
+ return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
1290
+ };
1291
+ const list = (folder?: string) => notesPage(
1292
+ policy(ctx),
1293
+ notesUnder(folder ? safeJoin(folder) : dir).map((file) =>
1294
+ `${file.path.slice(dir.length + 1)} ${formatSize(file.size)} ${new Date(file.mtimeMs).toISOString().slice(0, 16)}Z`),
1295
+ params.offset ?? 0,
1296
+ "notes-list",
1297
+ folder ? `(no notes in ${folder})` : "(no notes yet)",
1298
+ `Notes directory: ${dir}\n`,
1299
+ );
1263
1300
 
1264
1301
  switch (params.op) {
1265
- case "list": {
1266
- const files: string[] = [];
1267
- if (existsSync(dir)) walk(dir, files);
1268
- return notesPage(policy(ctx), files.map((file) => file.slice(dir.length + 1)), params.offset ?? 0, "notes-list", "(no notes yet)");
1269
- }
1302
+ case "list":
1303
+ return list(params.path);
1270
1304
  case "read": {
1271
1305
  const relative = requireValue(params.path, "path", params.op);
1272
1306
  const path = safeJoin(relative);
1273
- if (!existsSync(path)) throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
1307
+ const info = statSync(path, { throwIfNoEntry: false });
1308
+ if (!info) throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
1309
+ if (info.isDirectory()) return list(relative);
1310
+ if (await isBinaryFile(path)) throw new Error(`${relative} is a binary file (${formatSize(info.size)}); notes read returns text only.`);
1274
1311
  const text = readFileSync(path, "utf8");
1275
1312
  const offset = params.offset ?? 0;
1276
1313
  if (offset && offset >= text.length) {
1277
1314
  throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
1278
1315
  }
1279
- const end = Math.min(text.length, offset + pageSize(policy(ctx), offset, 0));
1316
+ const header = `File: ${path}\n`;
1317
+ const host = policy(ctx);
1318
+ const chars = pageSize(host, offset, 0) - header.length;
1319
+ if (chars <= 0) pageError(host, `Too little context remains to include the note path. Call new_context first, then retry with offset ${offset}.`);
1320
+ const end = Math.min(text.length, offset + chars);
1280
1321
  const more =
1281
1322
  end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
1282
- return pageResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
1323
+ return pageResult(`${header}${text.slice(offset, end)}${more}`, [], { kind: "note-read", headerLength: header.length, offset, end, total: text.length });
1283
1324
  }
1284
1325
  case "write": {
1285
1326
  const relative = requireValue(params.path, "path", params.op);
@@ -1307,15 +1348,16 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1307
1348
  }
1308
1349
  case "search": {
1309
1350
  const query = requireValue(params.query, "query", params.op).toLowerCase();
1310
- const files: string[] = [];
1311
- if (existsSync(dir)) walk(dir, files);
1312
1351
  const hits: string[] = [];
1313
- for (const file of files) {
1314
- for (const [index, line] of readFileSync(file, "utf8").split("\n").entries()) {
1352
+ for (const { path: file } of notesUnder(dir)) {
1353
+ if (await isBinaryFile(file)) continue;
1354
+ let number = 0;
1355
+ for await (const line of lfLines(file, signal)) {
1356
+ number++;
1315
1357
  const trimmed = line.trim();
1316
1358
  const match = trimmed.toLowerCase().indexOf(query);
1317
1359
  if (match !== -1) {
1318
- hits.push(`${file.slice(dir.length + 1)}:${index + 1}: ${excerptAround(trimmed, match, 50, 200)}`);
1360
+ hits.push(`${file.slice(dir.length + 1)}:${number}: ${excerptAround(trimmed, match, 50, 200)}`);
1319
1361
  }
1320
1362
  }
1321
1363
  }
@@ -1330,28 +1372,45 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1330
1372
  label: "History",
1331
1373
  ...toolCards("history"),
1332
1374
  description:
1333
- "Search or read normalized session entries, including earlier native context windows. Search prioritizes original content before recovery notes, handoffs, and history lookups; all remain searchable. Current branch by default; all=true searches sessions from this working directory and their nested subagents, including fork copies, and returns file-qualified entry ids for unambiguous reads. Within each group: newest-modified sessions first, newest entries per session. Continue searches with the returned cursor and the same query/scope; new searches see newer entries. Reads page text and stored images; continue with the returned offset and imageOffset.",
1375
+ "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.",
1334
1376
  promptSnippet: "recover earlier conversation that left the active context window",
1335
- promptGuidelines: ["Use history search first, then history read with the returned entry id"],
1377
+ promptGuidelines: ["Use history search first, then history read with the entry id exactly as printed"],
1336
1378
  parameters: Type.Object({
1337
1379
  op: Type.Union([Type.Literal("search"), Type.Literal("read")], { description: "Operation to perform" }),
1338
1380
  query: Type.Optional(Type.String({ description: "Case-insensitive text to find (search)" })),
1339
- id: Type.Optional(Type.String({ description: "Entry id returned by search (read)" })),
1340
- all: Type.Optional(Type.Boolean({ description: "Search all project sessions instead of the current branch" })),
1381
+ id: Type.Optional(Type.String({ description: "Entry id exactly as search printed it (read)" })),
1382
+ all: Type.Optional(Type.Boolean({ description: "Also search this project's other sessions and subagent runs" })),
1341
1383
  limit: Type.Optional(Type.Integer({ description: "Maximum search results per page (default 10, max 50)", minimum: 1, maximum: 50 })),
1342
- cursor: Type.Optional(Type.String({ description: "Search continuation returned by the previous page; keep query and all unchanged", maxLength: 512 })),
1384
+ cursor: Type.Optional(Type.String({ description: "Continuation from the previous search page; keep query and all unchanged", maxLength: 512 })),
1343
1385
  offset: Type.Optional(Type.Integer({ description: "Character offset for read (default 0)", minimum: 0 })),
1344
- imageOffset: Type.Optional(Type.Integer({ description: "First image to return for read (default 0 on the first text page; otherwise skips images). Use both offsets from the continuation.", minimum: 0 })),
1386
+ imageOffset: Type.Optional(Type.Integer({ description: "First image to return (read); use the value from the continuation", minimum: 0 })),
1345
1387
  }),
1346
1388
  async execute(_id, params, signal, _onUpdate, ctx) {
1347
1389
  const manager = ctx.sessionManager;
1348
1390
  const cwd = resolve(ctx.cwd);
1349
1391
  const currentFile = manager.getSessionFile?.();
1392
+ const dir = manager.getSessionDir();
1350
1393
 
1351
1394
  if (params.op === "search") {
1352
1395
  const query = requireValue(params.query, "query", params.op).toLowerCase();
1353
1396
  const limit = params.limit ?? 10;
1354
- const searchKey = createHash("sha256").update(JSON.stringify([query, params.all === true])).digest("base64url");
1397
+ const searchKey = shortKey(JSON.stringify([query, params.all === true]));
1398
+ // Projections show replacements and stripped async calls under the original id, so an entry is in
1399
+ // context only when all of its own text is visible there. Posthorse strips stale reminders from input.
1400
+ const seen = (message: MessageLike) => [
1401
+ textOf(message), assistantFailure(message), imageSummary(imagesOf(message.content)), message.summary, message.command, message.output,
1402
+ ].filter(Boolean).join("\n").toLowerCase();
1403
+ const visible = new Map((manager.buildSessionProjection().entries as readonly ProjectedEntry[]).flatMap(({ sourceEntry, messages }) =>
1404
+ sourceEntry.id && messages.length ? [[sourceEntry.id, messages.map(seen).join("\n")] as const] : []));
1405
+ const inContext = (entry: EntryLike) => {
1406
+ if (entry.type === "custom_message" && isReminderType(entry.customType)) return false;
1407
+ const own = entry.type === "message" ? entry.message ?? {}
1408
+ : entry.type === "context_edit" ? { content: entry.replacement?.content }
1409
+ : entry.type === "context_window" ? { content: entry.handoff }
1410
+ : { content: entry.content, summary: entry.summary };
1411
+ return visible.get((entry.type === "context_edit" ? entry.targetId : entry.id) ?? "")?.includes(seen(own)) === true;
1412
+ };
1413
+ let skipped = 0;
1355
1414
  let cursor: [string, 0 | 1, number, string] | undefined;
1356
1415
  if (params.cursor) {
1357
1416
  try {
@@ -1375,13 +1434,21 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1375
1434
  };
1376
1435
 
1377
1436
  if (params.all) {
1378
- for await (const file of scopedSessionFiles(manager.getSessionDir(), cwd, currentFile, signal)) {
1437
+ const needles = prefilterNeedles(query);
1438
+ const candidates = needles && await filesContaining(dir, needles, signal);
1439
+ const current = currentFile && resolve(currentFile);
1440
+ for await (const file of scopedSessionFiles(dir, cwd, currentFile, signal, { candidates })) {
1379
1441
  const recent: HistoryHit[][] = [[], []];
1380
1442
  let anchorHere = false;
1381
- const source = relative(manager.getSessionDir(), file);
1443
+ const source = relative(dir, file);
1444
+ const inCurrent = resolve(file) === current;
1382
1445
  for await (const item of sessionWindowEntries(file, signal)) {
1383
1446
  const hit = historyHit(item, query, source);
1384
1447
  if (!hit) continue;
1448
+ if (inCurrent && inContext(item.entry)) {
1449
+ skipped++;
1450
+ continue;
1451
+ }
1385
1452
  const seeking = cursor && !found && hit.priority === cursor[1];
1386
1453
  if (seeking && anchorHere) continue;
1387
1454
  if (seeking && hit.id === cursor?.[0]) anchorHere = true;
@@ -1397,20 +1464,30 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1397
1464
  } else {
1398
1465
  const current = [...windowEntries(manager.getBranch() as EntryLike[])];
1399
1466
  for (const item of current.reverse()) {
1400
- addHit(historyHit(item, query));
1467
+ const hit = historyHit(item, query);
1468
+ if (!hit) continue;
1469
+ if (inContext(item.entry)) {
1470
+ skipped++;
1471
+ continue;
1472
+ }
1473
+ addHit(hit);
1401
1474
  if (hits[0].length > limit) break;
1402
1475
  }
1403
1476
  }
1404
- const chars = pageSize(policy(ctx), 0, 0, params.cursor);
1405
- if (!found) pageError(policy(ctx), "History cursor entry no longer matches; restart the search without it.", params.cursor);
1477
+ const host = policy(ctx);
1478
+ const chars = pageSize(host, 0, 0, params.cursor);
1479
+ if (!found) pageError(host, "History cursor entry no longer matches; restart the search without it.", params.cursor);
1406
1480
  const results = hits.flat();
1407
1481
  const cursorFor = (hit: HistoryHit, offset: number) => Buffer.from(JSON.stringify([hit.id, hit.priority, offset, searchKey])).toString("base64url");
1408
1482
  const footer = (next: string) => `\n[More results; continue with cursor "${next}" and the same query/scope.]`;
1409
1483
  const reserve = Math.max(0, ...results.map((hit) => footer(cursorFor(hit, hit.text.length)).length));
1410
- if (reserve >= chars) pageError(policy(ctx), "History entry id is too large for pagination.", params.cursor);
1484
+ if (reserve >= chars) pageError(host, "History entry id is too large for pagination.", params.cursor);
1485
+ const skippedNote = skipped && !params.cursor
1486
+ ? `\n[Skipped ${skipped} match${skipped === 1 ? "" : "es"} already in your active context.]`
1487
+ : "";
1411
1488
  const parts: string[] = [];
1412
1489
  const spans: Array<{ headerLength: number; length: number }> = [];
1413
- let available = chars - reserve;
1490
+ let available = chars - reserve - skippedNote.length;
1414
1491
  let next: string | undefined;
1415
1492
  for (const hit of results) {
1416
1493
  const offset = cursor?.[0] === hit.id ? cursor[2] : 0;
@@ -1427,8 +1504,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1427
1504
  if (available <= 0 || parts.length >= limit) break;
1428
1505
  }
1429
1506
  const body = parts.length ? parts.join("\n") : `No history matches "${excerpt(params.query!, 200)}".`;
1430
- const more = next ? footer(next) : "";
1431
- return pageResult(body + more, [], { kind: "history-search", entries: spans, footerLength: more.length });
1507
+ const tail = `${skippedNote}${next ? footer(next) : ""}`;
1508
+ return pageResult(body + tail, [], { kind: "history-search", entries: spans, footerLength: tail.length, more: next !== undefined, skipped });
1432
1509
  }
1433
1510
 
1434
1511
  const id = requireValue(params.id, "id", params.op);
@@ -1436,6 +1513,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1436
1513
  const entryId = separator < 0 ? id : id.slice(0, separator);
1437
1514
  const fileKey = separator < 0 ? undefined : id.slice(separator + 1);
1438
1515
  const formatEntry = (item: WindowedEntry, source = "") => {
1516
+ // Session-file reads print the qualified id, so continuations reopen the same file.
1517
+ const shownId = source ? `${item.entry.id}@${shortKey(source)}` : id;
1439
1518
  const offset = params.offset ?? 0;
1440
1519
  const imageOffset = params.imageOffset ?? (offset === 0 ? 0 : item.images.length);
1441
1520
  if (imageOffset > item.images.length) {
@@ -1454,8 +1533,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1454
1533
  offset + chars - (images.length - firstImage) * ESTIMATED_IMAGE_CHARS,
1455
1534
  );
1456
1535
  const more = end < item.text.length || imageEnd < item.images.length
1457
- ? `\nMore remains; call history read with id "${id}" and offset ${end}${item.images.length ? ` and imageOffset ${imageEnd}` : ""}.` : "";
1458
- const header = `${source ? `${source} ` : ""}${item.entry.timestamp ?? ""} [window ${item.windowId}] [${id}] [chars ${offset}-${end} of ${item.text.length}] `;
1536
+ ? `\nMore remains; call history read with id "${shownId}" and offset ${end}${item.images.length ? ` and imageOffset ${imageEnd}` : ""}.` : "";
1537
+ const header = `${item.entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${shownId}] [chars ${offset}-${end} of ${item.text.length}] `;
1459
1538
  return pageResult(
1460
1539
  `${header}${item.text.slice(offset, end)}${more}`,
1461
1540
  images,
@@ -1468,13 +1547,17 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
1468
1547
  if (item.entry.id === entryId) return formatEntry(item);
1469
1548
  }
1470
1549
  }
1471
- for await (const file of scopedSessionFiles(manager.getSessionDir(), cwd, currentFile, signal, fileKey)) {
1472
- const fileSource = relative(manager.getSessionDir(), file);
1550
+ // Bare ids from other sessions reach here through older notes and handoffs.
1551
+ const candidates = fileKey === undefined && /^[\w-]+$/.test(entryId)
1552
+ ? await filesContaining(dir, [`"id":"${entryId}"`], signal)
1553
+ : undefined;
1554
+ for await (const file of scopedSessionFiles(dir, cwd, currentFile, signal, { fileKey, candidates })) {
1555
+ const fileSource = relative(dir, file);
1473
1556
  for await (const item of sessionWindowEntries(file, signal)) {
1474
1557
  if (item.entry.id === entryId) return formatEntry(item, fileSource);
1475
1558
  }
1476
1559
  }
1477
- throw new Error(`No history entry with id "${id}".`);
1560
+ 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.`);
1478
1561
  },
1479
1562
  });
1480
1563
  };