pi-posthorse 0.8.1 → 0.9.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.
package/README.md CHANGED
@@ -17,12 +17,12 @@ Required qualification targets: latest stable official Pi and latest maintained
17
17
 
18
18
  ## Install
19
19
 
20
- Posthorse `0.8.1` requires Pi `>=1.0.0`. Update the host before installing this release; do not activate it on an older live fork.
20
+ Posthorse `0.9.0` requires Pi `>=1.0.0`. Update the host before installing this release; do not activate it on an older live fork.
21
21
 
22
22
  ```bash
23
- pi install npm:pi-posthorse@0.8.1 # from npm
24
- pi install git:github.com/fitchmultz/pi-posthorse@v0.8.1 # from Git
25
- pi -e npm:pi-posthorse@0.8.1 # try it for one run without installing
23
+ pi install npm:pi-posthorse@0.9.0 # from npm
24
+ pi install git:github.com/fitchmultz/pi-posthorse@v0.9.0 # from Git
25
+ pi -e npm:pi-posthorse@0.9.0 # try it for one run without installing
26
26
  ```
27
27
 
28
28
  Omit the version or tag to follow the latest release.
@@ -37,19 +37,19 @@ Keep exactly one copy loaded. `pi list` shows every package source; if a second
37
37
 
38
38
  Both hosts get the same tools, guidance, reminders, notes, history, and recovery records. Rollover is a public compaction entry retaining no earlier conversation, with the handoff as its summary; no summary model is called. `new_context` commits at turn end only after Posthorse's own callback succeeds and every sibling tool succeeds. A foreign tool with the same name cannot authorize a rollover. Automatic rollover handles `session_before_compact`; manual `/compact` stays Pi's own summarization.
39
39
 
40
- - **Native eligibility on both 1.0 targets:** Pi's compaction preparation must succeed before the automatic hook runs. Posthorse's custom compaction result bypasses the default summarizer and its credential lookup. With default `keepRecentTokens`, tiny overflow/truncated responses and a second large result without a new user turn can miss the hook. Lowering `keepRecentTokens` narrows, but does not eliminate, that gap. Oversized initial input can reach the provider before recovery.
41
- - **Policy:** reminders and budget reports use persisted settings by default; automatic rollover uses the live settings in the event. SDK hosts can inject live policy with `createPosthorse(getPolicy)`. Handoffs appear as compaction-summary messages. Background work owned outside the foreground batch is not serialized or stopped by Posthorse.
40
+ - **Automatic eligibility on both hosts:** Pi's compaction preparation must succeed before the automatic hook runs. Posthorse's custom compaction result bypasses the default summarizer and its credential lookup. In fresh compaction windows without a subsequent projected turn-start input, Posthorse owns threshold rollover at completed turn end when known usage reaches the enabled, supported threshold; it preserves normal continuation and never requests another response after a final answer. Other threshold spans and overflow/truncated-response recovery stay Pi-owned. Tiny overflow/truncated responses can still miss native recovery, and oversized initial input can reach the provider before recovery.
41
+ - **Policy:** guidance, reminders and budget reports use Pi's live effective settings, including runtime and per-model compaction overrides, defaults and validation; the turn-end threshold fallback uses this same policy reader, while the native automatic hook uses the live settings in its event. Manual settings-file edits take effect when Pi refreshes its live settings, just like Pi's own compaction, not on Posthorse's next evaluation. SDK hosts can inject policy with `createPosthorse(getPolicy)`. Handoffs appear as compaction-summary messages. Background work owned outside the foreground batch is not serialized or stopped by Posthorse.
42
42
 
43
43
  Legacy fork `context_window` entries remain readable for history recovery. New boundaries do not use the retired native-window/async ABI. Opening legacy fork journals in official Pi can reintroduce earlier context because official Pi does not interpret those old markers; do not assume cross-host session migration.
44
44
 
45
45
  ## How it works
46
46
 
47
47
  1. **Stable guidance.** Each request gets a native system-prompt section stating the best available native context capacity and, when automatic rollover is supported and enabled, its token threshold from the available Pi settings. Fresh windows get the same guidance, refreshed if the model or available settings change during a continuing turn. For virtual models, native limits can come from the routed physical model. These are capacity and policy limits, not remaining space. The guidance distinguishes context capacity from response-token limits and reasoning budgets, and requires a `get_context_remaining` check before reporting remaining tokens or changing plans because of context limits. Unknown usage stays unknown. There is no per-request meter or routine budget check. When an extension forces an opaque full prompt, Posthorse preserves it and supplies guidance as a request-local conversation message, because official Pi applies forced text after system-context hooks. Guidance is regenerated from current state rather than persisted in the transcript.
48
- 2. **One best-effort checkpoint.** While Pi compaction is enabled, one reminder may appear shortly before Pi's rollover line, asking the model to update its current-state note with available file-editing tools and call `new_context`. Already-current notes need no rewrite; `notes write` remains the fallback for creation, substantial restructuring, or unavailable editing tools. A large turn, overflow, restart, or smaller model can reach rollover without it. Reminders are fingerprinted by window, context size, and reserve, so switching to a different context size gets a fresh reminder. Stale reminders and reminders left after disabling compaction are filtered from model input while remaining in history.
48
+ 2. **One best-effort checkpoint.** While Pi compaction is enabled, one reminder may arrive at the end of a working turn inside the checkpoint band before Pi's rollover line, never on a turn that ends the run. It cannot wake a finished agent or replace its final answer. The reminder asks the model to update its current-state note with available file-editing tools and call `new_context`. Already-current notes need no rewrite; `notes write` remains the fallback for creation, substantial restructuring, or unavailable editing tools. A large turn, overflow, restart, or smaller model can reach rollover without it. Reminders are fingerprinted by window, context size, and reserve, so switching to a different context size gets a fresh reminder. Stale reminders and reminders left after disabling compaction are filtered from model input while remaining in history.
49
49
  3. **`get_context_remaining`.** Reports the best available native estimate of tokens until Pi's automatic rollover line and until the configured context limit. That configured limit is not a measured provider rejection boundary. Pi's value is an estimate until the active model reports usage. Check before claiming a remaining-token count or changing plans because of context limits. The checkpoint reminder marks the deadline, so routine budget checks are unnecessary.
50
50
  4. **`new_context`.** Requests an atomic retain-none rollover once the current foreground tool batch succeeds. An optional handoff becomes the first state of the fresh window. Failed sibling writes, aborted turns, or a handoff that no longer fits after queued input prevent commitment. The request card is not proof that the boundary committed. Own successful callback IDs and admitted handoffs are consumed once at settlement; transcript names alone cannot request a reset.
51
51
  5. **Automatic rollover without summaries.** When Pi reaches the public automatic hook with a supported budget, Posthorse builds a bounded recovery record and retains only an invisible boundary sentinel. Cancellation or recovery construction failure leaves active context intact rather than silently invoking a summarizer. Unsupported small-model budgets retain Pi's native fallback. Manual `/compact` is unchanged. Native eligibility limits are listed above.
52
- 6. **Bounded recovery record.** The automatic handoff quotes the window's first owner request, its latest owner input (a direct user input or `ask_question` outcome), and its latest input of any kind, including visible coordination messages. It adds tool result evidence: tool names and namespaces, call ids, requested and admitted execution arguments when available, result excerpts of up to 1,500 characters, and entry ids to recover the rest. Every other current-window input gets one index line with its label, time, and entry id; owner inputs keep a one-line preview there. Only a successful result positively linked to a projected unnamespaced `ask_question` call **and** an effective registered unnamespaced `ask_question` definition is an owner answer. Providers can omit call namespaces, so call metadata alone is insufficient. Namespaced, unregistered or unresolved-provenance results remain tool evidence, never owner approval. Legacy async results still visible in a projected journal remain eligible even across later complete assistant responses; this is history compatibility, not a new native-async execution path. A result Pi still projects is also eligible when its call was edited out or predates the window, without attributing it to another call. These results may already have been received or handled; they are not proof of current progress. Synchronous results with projected call provenance are kept only from the trailing batch without a later complete response. Pi context edits also apply to these inputs and results: omitted entries stay out, and replacements supersede their original content. A clearly labeled, possibly stale older checkpoint comes last. Older assistant prose is not treated as current state. Newly submitted input stays separate and is saved after the boundary, not copied into the handoff.
52
+ 6. **Bounded recovery record.** The automatic handoff quotes the window's first owner request, its latest owner input (a direct user input or `ask_question` outcome), and its latest input of any kind, including visible coordination messages. Across consecutive automatic boundaries, a window with no owner inputs carries the same first/latest owner anchors from the nearest earlier owner window, labeled as earlier, possibly already handled, and not current progress. The walk stops at that window or an explicit/native compaction checkpoint; context edits still omit or replace carried inputs, and `ask_question` answers keep the same provenance checks. The newest non-automatic checkpoint reached replaces nested automatic text, with bounded history pointers to skipped automatic record ids. Carried anchors share the existing payload budget; earlier automatic tool evidence is never nested. It adds tool result evidence: tool names and namespaces, call ids, requested and admitted execution arguments when available, result excerpts of up to 1,500 characters, and entry ids to recover the rest. Every other current-window input gets one index line with its label, time, and entry id; owner inputs keep a one-line preview there. Only a successful result positively linked to a projected unnamespaced `ask_question` call **and** an effective registered unnamespaced `ask_question` definition is an owner answer. Providers can omit call namespaces, so call metadata alone is insufficient. Namespaced, unregistered or unresolved-provenance results remain tool evidence, never owner approval. Legacy async results still visible in a projected journal remain eligible even across later complete assistant responses; this is history compatibility, not a new native-async execution path. A result Pi still projects is also eligible when its call was edited out or predates the window, without attributing it to another call. These results may already have been received or handled; they are not proof of current progress. Synchronous results with projected call provenance are kept only from the trailing batch without a later complete response. Pi context edits also apply to these inputs and results: omitted entries stay out, and replacements supersede their original content. A clearly labeled, possibly stale older checkpoint comes last. Older assistant prose is not treated as current state. Newly submitted input stays separate and is saved after the boundary, not copied into the handoff.
53
53
  7. **`notes` and `history`.** Notes are shared across linked worktrees, at the main checkout for conventional Git layouts or inside the common Git directory when metadata is stored separately. History searches normalized transcript text, skips entries whose full text the model already sees in its active context (and says how many), and returns stored images for a requested entry. Edited-away originals and checkpoint reminders stay searchable. Tool calls and results retain names, namespaces, and call ids; admitted execution arguments are labeled separately from requested arguments when present. When a handoff carries edited content, its entry ID points to the replacement; `history read` of that ID returns the replacement text or images. Reading the original entry ID still returns the raw journal content.
54
54
 
55
55
  Retain-none compaction removes prior tool receipts from active input; recovery records carry bounded excerpts and history IDs, while complete raw or edited receipts remain recoverable from the append-only journal. Posthorse no longer registers a native receipt-bounding hook. The host owns physical request sizing and output clamping; custom transports and raw payload replacements remain responsible for their request sizes.
@@ -70,6 +70,8 @@ Explicit and automatic handoffs are capped at 20,000 characters and half the nat
70
70
 
71
71
  Only one automatic compaction or rollover policy extension should be enabled at a time. Pi keeps the last non-cancel result from multiple handlers of the same hook, so load order would otherwise decide which policy wins.
72
72
 
73
+ The fresh-window threshold fallback builds its recovery record from the boundary preview when its `turn_end` handler runs. Earlier turn-start drafts keep compaction Pi-owned; earlier context-edit drafts defer the fallback until usage can be re-evaluated after commit. Unlike the native automatic hook (which runs after all turn-end handlers), the eager fallback cannot observe later handlers' edits. Later projection-editing handlers must explicitly replace/remove its drafted compaction if they change its source evidence; ordering an editor before Posthorse lets its drafts defer rollover safely. A host post-boundary hook would be needed to include arbitrary later same-phase edits automatically.
74
+
73
75
  ## Tools
74
76
 
75
77
  - `new_context({ handoff? })`
package/index.ts CHANGED
@@ -4,9 +4,13 @@
4
4
  * Pi owns persisted retain-none context boundaries. Posthorse owns reminder and
5
5
  * rollover policy, bounded recovery, durable notes, and history retrieval.
6
6
  */
7
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ SettingsManager,
9
+ type ExtensionAPI,
10
+ type ExtensionContext,
11
+ } from "@earendil-works/pi-coding-agent";
8
12
  import { registerPosthorseMessages } from "./ui.ts";
9
- import { persistedPolicy, policySnapshot } from "./src/budget.ts";
13
+ import { policySnapshot } from "./src/budget.ts";
10
14
  import type { CompactionPolicy, PolicyReader } from "./src/contracts.ts";
11
15
  import { registerHistory } from "./src/history.ts";
12
16
  import { safeJsonStringify } from "./src/message-text.ts";
@@ -32,13 +36,14 @@ function activeToolTokens(pi: ExtensionAPI): number {
32
36
  0,
33
37
  );
34
38
  }
35
- /** SDK hosts can inject live policy; CLI hosts use the persisted settings snapshot. */
36
- export function createPosthorse(
37
- getPolicy: PolicyReader = persistedPolicy,
38
- ): (pi: ExtensionAPI) => void {
39
+ /** Use Pi's live effective settings by default; SDK hosts can inject a policy reader. */
40
+ export function createPosthorse(getPolicy?: PolicyReader): (pi: ExtensionAPI) => void {
39
41
  return (pi) => {
42
+ const readPolicy: PolicyReader =
43
+ getPolicy ??
44
+ ((ctx) => SettingsManager.inMemory(pi.getSettings()).getCompactionSettings(ctx.model));
40
45
  const policy = (ctx: ExtensionContext, livePolicy?: CompactionPolicy) =>
41
- policySnapshot(ctx, getPolicy, livePolicy);
46
+ policySnapshot(ctx, readPolicy, livePolicy);
42
47
  const toolTokens = () => activeToolTokens(pi);
43
48
  const pages = new PageReservations(toolTokens);
44
49
  registerPosthorseMessages(pi);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
6
6
  "keywords": [
package/src/budget.ts CHANGED
@@ -1,9 +1,4 @@
1
- import {
2
- estimateTokens,
3
- getAgentDir,
4
- SettingsManager,
5
- type ExtensionContext,
6
- } from "@earendil-works/pi-coding-agent";
1
+ import { estimateTokens, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
2
  import {
8
3
  ESTIMATED_IMAGE_CHARS,
9
4
  MAX_HANDOFF_CHARS,
@@ -18,8 +13,6 @@ import {
18
13
  import { imagesOf, textOf } from "./message-text.ts";
19
14
 
20
15
  const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
21
- export const SNAPSHOT_NOTE =
22
- "Reminder and budget policy uses available settings (a persisted CLI snapshot by default); Pi's live settings control automatic compaction.";
23
16
  export const CHECKPOINT_STEPS =
24
17
  "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";
25
18
  export type Budget = {
@@ -30,18 +23,6 @@ export type Budget = {
30
23
  readonly rolloverAt: number;
31
24
  readonly supported: boolean;
32
25
  };
33
- export function persistedPolicy(ctx: ExtensionContext): CompactionPolicy {
34
- const settings = SettingsManager.create(ctx.cwd, getAgentDir(), {
35
- projectTrusted: ctx.isProjectTrusted(),
36
- });
37
- const errors = settings.drainErrors();
38
- if (errors.length > 0) {
39
- throw new Error(
40
- `Posthorse could not read persisted Pi settings: ${errors.map((error) => error.error.message).join("; ")}`,
41
- );
42
- }
43
- return settings.getCompactionSettings(ctx.model);
44
- }
45
26
  /** A synchronous evaluation owns one snapshot; never carry it across an await. */
46
27
  export function policySnapshot(
47
28
  ctx: ExtensionContext,
@@ -103,7 +84,7 @@ function automaticGuidance(budget: Budget | undefined, enabled: boolean): string
103
84
  budget === undefined
104
85
  ? ""
105
86
  : ` The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used under the available Pi settings.`;
106
- return `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}.`;
87
+ return `Automatic rollover follows Pi's compaction setting.${line} At most one checkpoint reminder may arrive at the end of a working turn before the rollover line (${deadline}), never on a turn that ends the run; when it does, stop normal work, ${CHECKPOINT_STEPS}.`;
107
88
  }
108
89
  export function buildGuidance(ctx: PolicyContext): string {
109
90
  const budget = budgetFor(ctx);
@@ -1,13 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { toolCards, type PosthorseDisplay } from "../ui.ts";
4
- import {
5
- budgetFor,
6
- freshPayloadChars,
7
- SNAPSHOT_NOTE,
8
- unsupportedMessage,
9
- type Budget,
10
- } from "./budget.ts";
4
+ import { budgetFor, freshPayloadChars, unsupportedMessage, type Budget } from "./budget.ts";
11
5
  import {
12
6
  EMPTY_HANDOFF,
13
7
  MAX_HANDOFF_CHARS,
@@ -38,7 +32,7 @@ function contextReport(host: PolicyContext): TextResult {
38
32
  rollover,
39
33
  rolloverAt: budget?.rolloverAt,
40
34
  };
41
- 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}`;
35
+ 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.`;
42
36
  if (budget?.enabled !== true) {
43
37
  return textResult(
44
38
  `Automatic rollover is disabled in the available settings (Pi compaction.enabled=false). ${configured}`,
@@ -100,7 +94,7 @@ export function registerContextTools(
100
94
  label: "Context Remaining",
101
95
  ...toolCards("get_context_remaining"),
102
96
  description:
103
- "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.",
97
+ "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 may arrive before rollover on a working turn; no routine checks are needed.",
104
98
  promptSnippet:
105
99
  "verify remaining context before reporting a token count or changing plans because of context limits",
106
100
  parameters: Type.Object({}),
@@ -213,7 +213,8 @@ export function toWindowedEntry(
213
213
  images: imagesOf(entryContent(entry)),
214
214
  };
215
215
  }
216
- export function windowProjection(): (entry: EntryLike) => WindowedEntry | undefined {
216
+ /** Window ancestry is metadata-only; extracting text/images is a separate operation. */
217
+ export function windowProjection(): (entry: EntryLike) => string {
217
218
  const windows = new Map<string, string>();
218
219
  return (entry) => {
219
220
  const inherited = windows.get(entry.parentId ?? "") ?? "initial";
@@ -221,13 +222,15 @@ export function windowProjection(): (entry: EntryLike) => WindowedEntry | undefi
221
222
  if (entry.id !== undefined && entry.id !== "") {
222
223
  windows.set(entry.id, windowId);
223
224
  }
224
- return toWindowedEntry(entry, windowId);
225
+ return windowId;
225
226
  };
226
227
  }
227
- export function* windowEntries(entries: readonly EntryLike[]): Generator<WindowedEntry> {
228
- const project = windowProjection();
229
- for (const entry of entries) {
230
- const item = project(entry);
228
+ export function* reverseWindowEntries(entries: readonly EntryLike[]): Generator<WindowedEntry> {
229
+ // Preserve parent-relative window labels without normalizing the whole branch.
230
+ // ponytail: ancestry still scans metadata; a native window-aware iterator could remove that cost.
231
+ const windows = entries.map(windowProjection());
232
+ for (let i = entries.length - 1; i >= 0; i--) {
233
+ const item = toWindowedEntry(entries[i], windows[i]);
231
234
  if (item !== undefined) {
232
235
  yield item;
233
236
  }
@@ -7,7 +7,7 @@ import {
7
7
  type HistoryHit,
8
8
  type ProjectedEntry,
9
9
  } from "./contracts.ts";
10
- import { historyHit, seenText, windowEntries } from "./history-entries.ts";
10
+ import { historyHit, seenText, reverseWindowEntries } from "./history-entries.ts";
11
11
  import { shortKey } from "./message-text.ts";
12
12
  import {
13
13
  filesContaining,
@@ -267,7 +267,7 @@ function branchMatches(
267
267
  accumulator: SearchProgress,
268
268
  ): number {
269
269
  let skipped = 0;
270
- for (const item of [...windowEntries(source.branch())].reverse()) {
270
+ for (const item of reverseWindowEntries(source.branch())) {
271
271
  const hit = historyHit(item, query);
272
272
  if (hit === undefined) {
273
273
  continue;
package/src/history.ts CHANGED
@@ -3,7 +3,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import { Type } from "typebox";
4
4
  import { toolCards } from "../ui.ts";
5
5
  import { requireValue, type PolicyContext, type WindowedEntry } from "./contracts.ts";
6
- import { windowEntries } from "./history-entries.ts";
6
+ import { toWindowedEntry, windowProjection } from "./history-entries.ts";
7
7
  import { historyReadPage, searchPage } from "./history-pages.ts";
8
8
  import { historyMatches, type HistorySource } from "./history-search.ts";
9
9
  import type { PageAccess } from "./paging.ts";
@@ -17,18 +17,26 @@ function entryReference(id: string): { readonly entryId: string; readonly fileKe
17
17
  fileKey: separator < 0 ? undefined : id.slice(separator + 1),
18
18
  };
19
19
  }
20
+ function branchEntry(source: HistorySource, id: string): WindowedEntry | undefined {
21
+ const project = windowProjection();
22
+ for (const entry of source.branch()) {
23
+ const windowId = project(entry);
24
+ const item = entry.id === id ? toWindowedEntry(entry, windowId) : undefined;
25
+ if (item !== undefined) {
26
+ return item;
27
+ }
28
+ }
29
+ return undefined;
30
+ }
20
31
  async function findEntry(
21
32
  source: HistorySource,
22
33
  id: string,
23
34
  signal?: AbortSignal,
24
35
  ): Promise<LocatedEntry> {
25
36
  const { entryId, fileKey } = entryReference(id);
26
- if (fileKey === undefined) {
27
- for (const item of windowEntries(source.branch())) {
28
- if (item.entry.id === entryId) {
29
- return { item, source: "" };
30
- }
31
- }
37
+ const current = fileKey === undefined ? branchEntry(source, entryId) : undefined;
38
+ if (current !== undefined) {
39
+ return { item: current, source: "" };
32
40
  }
33
41
  const candidates =
34
42
  fileKey === undefined && /^[\w-]+$/.test(entryId)
@@ -42,10 +50,8 @@ async function findEntry(
42
50
  fileKey,
43
51
  candidates: candidates === undefined ? undefined : (candidate) => candidates.has(candidate),
44
52
  })) {
45
- for await (const item of sessionWindowEntries(file, signal)) {
46
- if (item.entry.id === entryId) {
47
- return { item, source: relative(source.dir, file) };
48
- }
53
+ for await (const item of sessionWindowEntries(file, signal, entryId)) {
54
+ return { item, source: relative(source.dir, file) };
49
55
  }
50
56
  }
51
57
  throw new Error(
package/src/recovery.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  AUTO_HANDOFF_PREFIX,
3
+ isRecord,
3
4
  isReminderType,
4
5
  type EntryLike,
5
6
  type ProjectedEntry,
@@ -114,19 +115,13 @@ function priorCheckpoint(entry: EntryLike | undefined, limit: number): string |
114
115
  }
115
116
  const id = entry.id?.slice(0, 120) ?? "unknown";
116
117
  const header = `[older checkpoint; possibly stale | context-window entry ${id}]`;
117
- return boundedBlock(
118
- header,
119
- handoff.startsWith(AUTO_HANDOFF_PREFIX)
120
- ? `Prior automatic recovery text is not nested here. Use history read with entry ${id} if needed.`
121
- : handoff,
122
- limit,
123
- );
118
+ return boundedBlock(header, handoff, limit);
124
119
  }
125
120
  function projectedCurrent(
126
121
  entries: readonly EntryLike[],
127
122
  projected: readonly ProjectedEntry[],
123
+ current: readonly EntryLike[] = entries.slice(contextStart(entries).start),
128
124
  ): EntryLike[] {
129
- const current = entries.slice(contextStart(entries).start);
130
125
  const edits = contextEdits(entries);
131
126
  const omittedIds = new Set(
132
127
  projected
@@ -198,6 +193,72 @@ function anchors(records: readonly RecoveryRecord[]): {
198
193
  others: records.filter((record) => !selected.has(record)),
199
194
  };
200
195
  }
196
+ function automaticBoundary(entry: EntryLike | undefined): boolean {
197
+ if (
198
+ entry === undefined ||
199
+ windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) !== true
200
+ ) {
201
+ return false;
202
+ }
203
+ return (
204
+ entry.type === "context_window" ||
205
+ (isRecord(entry.details) &&
206
+ entry.details.posthorse === 1 &&
207
+ (entry.details.reason === "threshold" || entry.details.reason === "overflow"))
208
+ );
209
+ }
210
+ function recoveryAncestry(
211
+ entries: readonly EntryLike[],
212
+ projected: readonly ProjectedEntry[],
213
+ ownerQuestionRegistered: boolean,
214
+ ): {
215
+ readonly checkpoint?: EntryLike;
216
+ readonly carried: readonly RecoveryRecord[];
217
+ readonly skipped: readonly string[];
218
+ } {
219
+ let prefix = entries;
220
+ let boundary = contextStart(prefix).boundary;
221
+ const skipped: string[] = [];
222
+ while (automaticBoundary(boundary) && boundary !== undefined) {
223
+ skipped.push(boundary.id?.slice(0, 120) ?? "unknown");
224
+ prefix = prefix.slice(0, prefix.indexOf(boundary));
225
+ const window = contextStart(prefix);
226
+ const restored = projectedCurrent(entries, projected, prefix.slice(window.start));
227
+ const historical = restored.map((sourceEntry) => ({
228
+ sourceEntry,
229
+ messages: sourceEntry.message === undefined ? [] : [sourceEntry.message],
230
+ }));
231
+ const owners = ownerRecords(restored, historical, ownerQuestionRegistered).filter(
232
+ (record) => record.kind === "owner",
233
+ );
234
+ if (owners.length > 0) {
235
+ return {
236
+ checkpoint: automaticBoundary(window.boundary) ? undefined : window.boundary,
237
+ carried: anchors(owners).fixed,
238
+ skipped,
239
+ };
240
+ }
241
+ boundary = window.boundary;
242
+ }
243
+ return { checkpoint: boundary, carried: [], skipped };
244
+ }
245
+ function skippedRecords(ids: readonly string[], limit: number): string | undefined {
246
+ if (ids.length === 0) {
247
+ return undefined;
248
+ }
249
+ let names = ids[0];
250
+ let count = 1;
251
+ while (count < ids.length) {
252
+ const next = `, ${ids[count]}`;
253
+ if (names.length + next.length > limit) {
254
+ break;
255
+ }
256
+ names += next;
257
+ count++;
258
+ }
259
+ const omitted = ids.length - count;
260
+ return `Earlier automatic records skipped (not nested; use history read): ${names}${omitted > 0 ? `; ${omitted} earlier record(s) not listed.` : ""}`;
261
+ }
201
262
  function projectedToolEntries(
202
263
  entries: readonly EntryLike[],
203
264
  projected: readonly ProjectedEntry[],
@@ -324,25 +385,35 @@ export function buildAutoHandoff(
324
385
  maxChars: number,
325
386
  ownerQuestionRegistered: boolean,
326
387
  ): string {
327
- const priorWindow = contextStart(entries).boundary;
388
+ const history = recoveryAncestry(entries, projected, ownerQuestionRegistered);
389
+ const priorWindow = history.checkpoint;
328
390
  const records = ownerRecords(
329
391
  projectedCurrent(entries, projected),
330
392
  projected,
331
393
  ownerQuestionRegistered,
332
394
  );
333
395
  const { fixed, others } = anchors(records);
334
- const currentHeader =
335
- records.length > 0
336
- ? "Current-window inputs (chronological):"
396
+ if (!records.some((record) => record.kind === "owner")) {
397
+ fixed.unshift(
398
+ ...history.carried.map((record) => ({
399
+ ...record,
400
+ label: `${record.label}; carried from an earlier window, possibly already handled; not current progress`,
401
+ })),
402
+ );
403
+ }
404
+ const inputHeader =
405
+ fixed.length > 0
406
+ ? "Selected owner and coordination inputs (chronological):"
337
407
  : "No selected current-window owner or visible coordination inputs were found.";
338
408
  const batch = recoverableToolResults(projectedToolEntries(entries, projected));
339
409
  const header = batchHeader(batch);
340
410
  const minimum = [
341
411
  PREAMBLE,
342
- currentHeader,
412
+ inputHeader,
343
413
  ...fixed.map((record) => formatRecord(record, 0)),
344
414
  header,
345
415
  priorCheckpoint(priorWindow, 0),
416
+ skippedRecords(history.skipped, 0),
346
417
  ];
347
418
  const plan = planBatch(
348
419
  batch,
@@ -350,19 +421,25 @@ export function buildAutoHandoff(
350
421
  Math.max(0, maxChars - joined(minimum).length - HANDOFF_OVERHEAD_RESERVE),
351
422
  );
352
423
  const handoff = windowHandoff(priorWindow)?.trim();
353
- const count = fixed.length + (handoff === undefined || handoff === "" ? 0 : 1);
424
+ const count =
425
+ fixed.length +
426
+ (handoff === undefined || handoff === "" ? 0 : 1) +
427
+ (history.skipped.length > 0 ? 1 : 0);
354
428
  const fixedBudget = Math.max(
355
429
  0,
356
- maxChars - joined([PREAMBLE, currentHeader, plan.bare]).length - HANDOFF_OVERHEAD_RESERVE,
430
+ maxChars - joined([PREAMBLE, inputHeader, plan.bare]).length - HANDOFF_OVERHEAD_RESERVE,
357
431
  );
358
432
  const fixedLimit =
359
433
  count > 0
360
434
  ? Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(fixedBudget / count))
361
435
  : MAX_RECOVERY_RECORD_CHARS;
362
- const prior = priorCheckpoint(priorWindow, fixedLimit);
436
+ const prior = joined([
437
+ priorCheckpoint(priorWindow, fixedLimit),
438
+ skippedRecords(history.skipped, fixedLimit),
439
+ ]);
363
440
  const fixedParts = [
364
441
  PREAMBLE,
365
- currentHeader,
442
+ inputHeader,
366
443
  ...fixed.map((record) => formatRecord(record, fixedLimit)),
367
444
  ];
368
445
  const renderedBatch = renderBatch(plan, fixedParts, prior, maxChars);
package/src/rollover.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  REMINDER_TYPE,
19
19
  type CompactionPolicy,
20
20
  type PolicyContext,
21
+ type ProjectedEntry,
21
22
  type ReminderFingerprint,
22
23
  } from "./contracts.ts";
23
24
  import { registerContextTools } from "./context-tools.ts";
@@ -104,6 +105,18 @@ function explicitReset(
104
105
  continue: true,
105
106
  };
106
107
  }
108
+ function endsRun(
109
+ event: TurnEndEvent,
110
+ ctx: { readonly hasPendingMessages?: () => boolean },
111
+ ): boolean {
112
+ return (
113
+ event.message.role === "assistant" &&
114
+ !event.message.content.some((part) => part.type === "toolCall") &&
115
+ !event.continue &&
116
+ event.context.pendingMessages.length === 0 &&
117
+ ctx.hasPendingMessages?.() !== true
118
+ );
119
+ }
107
120
  function reminderBudget(
108
121
  host: PolicyContext,
109
122
  ): { readonly budget: Budget; readonly tokens: number } | undefined {
@@ -126,7 +139,15 @@ function reminderBudget(
126
139
  function sendReminder(
127
140
  pi: ExtensionAPI,
128
141
  ctx: ExtensionContext,
129
- available: { readonly budget: Budget; readonly tokens: number },
142
+ available: {
143
+ readonly budget: Budget;
144
+ readonly tokens: number;
145
+ readonly pendingMessages: readonly {
146
+ readonly role: string;
147
+ readonly customType?: string;
148
+ readonly details?: unknown;
149
+ }[];
150
+ },
130
151
  reminders: ReminderLookup,
131
152
  ): void {
132
153
  const { budget, tokens } = available;
@@ -136,7 +157,15 @@ function sendReminder(
136
157
  contextWindow: budget.contextWindow,
137
158
  reserveTokens: budget.reserveTokens,
138
159
  };
139
- if (hasReminder(state.reminders, fingerprint)) {
160
+ if (
161
+ hasReminder(state.reminders, fingerprint) ||
162
+ available.pendingMessages.some(
163
+ (message) =>
164
+ message.role === "custom" &&
165
+ isReminderType(message.customType) &&
166
+ reminderMatches(message.details, fingerprint),
167
+ )
168
+ ) {
140
169
  return;
141
170
  }
142
171
  pi.sendMessage(
@@ -225,6 +254,92 @@ function reportAutomaticFailure(ctx: ExtensionContext, error: unknown): void {
225
254
  console.error(message);
226
255
  }
227
256
  }
257
+ function freshWindowWithoutInput(projected: readonly ProjectedEntry[]): boolean {
258
+ const boundary = projected.findIndex(
259
+ (entry) => entry.sourceEntry.type === "compaction" && entry.messages.length > 0,
260
+ );
261
+ if (boundary < 0) {
262
+ return false;
263
+ }
264
+ const turnStarts = new Set([
265
+ "user",
266
+ "custom",
267
+ "bashExecution",
268
+ "branchSummary",
269
+ "compactionSummary",
270
+ ]);
271
+ return !projected
272
+ .slice(boundary + 1)
273
+ .some(
274
+ (entry) =>
275
+ entry.sourceEntry.type !== "compaction" &&
276
+ entry.messages.some((message) => turnStarts.has(message.role ?? "")),
277
+ );
278
+ }
279
+ function reachedThreshold(host: PolicyContext): boolean {
280
+ const usage = host.getContextUsage();
281
+ const budget = budgetFor(host);
282
+ return (
283
+ budget?.supported === true &&
284
+ budget.enabled &&
285
+ usage !== undefined &&
286
+ usage.tokens !== null &&
287
+ usage.tokens >= budget.rolloverAt
288
+ );
289
+ }
290
+ function fallbackTurnEligible(event: TurnEndEvent, ctx: ExtensionContext): boolean {
291
+ return (
292
+ event.outcome === "completed" &&
293
+ !(event.message.role === "assistant" && event.message.stopReason === "length") &&
294
+ ctx.signal?.aborted !== true &&
295
+ !event.entries.some((entry) => entry.type === "compaction" || entry.type === "context_edit")
296
+ );
297
+ }
298
+ function thresholdFallback(
299
+ pi: ExtensionAPI,
300
+ event: TurnEndEvent,
301
+ ctx: ExtensionContext,
302
+ capacity: { readonly host: PolicyContext; readonly toolTokens: () => number },
303
+ ): BoundaryResult | undefined {
304
+ if (!fallbackTurnEligible(event, ctx)) {
305
+ return undefined;
306
+ }
307
+ const { host, toolTokens } = capacity;
308
+ if (!reachedThreshold(host)) {
309
+ return undefined;
310
+ }
311
+ if (!freshWindowWithoutInput(event.context.contextEntries)) {
312
+ return undefined;
313
+ }
314
+ const projected = event.context.contextEntries;
315
+ const limit = freshPayloadChars(host, toolTokens(), event.context.pendingMessages);
316
+ const ownerQuestionRegistered = pi
317
+ .getAllTools()
318
+ .some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
319
+ const summary = buildAutoHandoff(
320
+ ctx.sessionManager.getBranch(),
321
+ projected,
322
+ limit,
323
+ ownerQuestionRegistered,
324
+ );
325
+ if (limit < MIN_PAGE_CHARS || summary.length > limit) {
326
+ return undefined;
327
+ }
328
+ // ponytail: turn_end drafts are eager. Later projection editors must replace/remove this draft;
329
+ // upgrade to a host post-boundary hook if recovery must include later handler edits.
330
+ return {
331
+ entries: [
332
+ ...event.entries,
333
+ {
334
+ type: "compaction",
335
+ summary,
336
+ firstKeptEntryId: null,
337
+ details: { posthorse: 1, reason: "threshold" },
338
+ },
339
+ ],
340
+ continue: event.continue,
341
+ };
342
+ }
228
343
  function registerAutomaticRollover(
229
344
  pi: ExtensionAPI,
230
345
  policy: SnapshotReader,
@@ -318,9 +433,24 @@ export function registerRollover(
318
433
  freshPayloadChars(host, activeToolTokens(), event.context.pendingMessages),
319
434
  );
320
435
  }
436
+ const fallback = thresholdFallback(pi, event, ctx, {
437
+ host,
438
+ toolTokens: activeToolTokens,
439
+ });
440
+ if (fallback !== undefined) {
441
+ return fallback;
442
+ }
443
+ if (endsRun(event, ctx)) {
444
+ return;
445
+ }
321
446
  const available = reminderBudget(host);
322
447
  if (available !== undefined) {
323
- sendReminder(pi, ctx, available, reminders);
448
+ sendReminder(
449
+ pi,
450
+ ctx,
451
+ { ...available, pendingMessages: event.context.pendingMessages },
452
+ reminders,
453
+ );
324
454
  }
325
455
  return;
326
456
  });
@@ -11,7 +11,7 @@ import {
11
11
  type WindowedEntry,
12
12
  } from "./contracts.ts";
13
13
  import { lfLines } from "./files.ts";
14
- import { windowProjection } from "./history-entries.ts";
14
+ import { toWindowedEntry, windowProjection } from "./history-entries.ts";
15
15
  import { shortKey } from "./message-text.ts";
16
16
 
17
17
  function execFileText(
@@ -141,6 +141,7 @@ function parsedEntry(line: string): EntryLike | undefined {
141
141
  export async function* sessionWindowEntries(
142
142
  file: string,
143
143
  signal?: AbortSignal,
144
+ entryId?: string,
144
145
  ): AsyncGenerator<WindowedEntry> {
145
146
  if (!existsSync(file)) {
146
147
  return;
@@ -151,7 +152,11 @@ export async function* sessionWindowEntries(
151
152
  if (entry === undefined) {
152
153
  continue;
153
154
  }
154
- const item = project(entry);
155
+ const windowId = project(entry);
156
+ if (entryId !== undefined && entry.id !== entryId) {
157
+ continue;
158
+ }
159
+ const item = toWindowedEntry(entry, windowId);
155
160
  if (item !== undefined) {
156
161
  yield item;
157
162
  }