pi-posthorse 0.10.21 → 0.10.24

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
@@ -1,6 +1,6 @@
1
1
  # Posthorse
2
2
 
3
- Posthorse starts fresh context windows for long [Pi](https://github.com/earendil-works/pi) tasks without an automatic model-generated summary. Stock Pi already preserves the session journal and retains a recent tail during compaction; Posthorse adds explicit notes and history retrieval, with a handoff or bounded recovery record instead of the automatic summary.
3
+ Posthorse starts fresh context windows for long [Pi](https://github.com/earendil-works/pi) tasks without an automatic model-generated summary of the work. Stock Pi already preserves the session journal and retains a recent tail during compaction; Posthorse adds explicit notes and history retrieval, with a handoff or bounded recovery record instead of the automatic summary.
4
4
 
5
5
  ![A Pi conversation rolls into a fresh context window. Task notes and the complete session transcript remain available.](https://raw.githubusercontent.com/fitchmultz/pi-posthorse/main/.github/readme/context-rollover.png)
6
6
 
@@ -25,7 +25,7 @@ Give Pi your task. For example:
25
25
 
26
26
  Posthorse can send a routine task-note reminder before rollover. The reminder tells the model to save current task state if needed, then immediately continue the prescribed work. The context window is not a task deadline. The reminder does not require `new_context` or wake an agent after its final answer. Large turns can reach rollover before the reminder. See [Reminder timing](docs/reminder-timing.md) for the trigger and its relationship to Pi's reserve and retention settings.
27
27
 
28
- Automatic rollover retains a safe recent-message tail using Pi's `compaction.keepRecentTokens`, alongside a bounded recovery record for discarded inputs and tool evidence. It never calls a summarization model. The model must restore notes and check live state before it continues. Optional `new_context` deliberately retains no prior messages; manual `/compact` uses Pi's normal summarization.
28
+ Automatic rollover retains a safe recent-message tail using Pi's `compaction.keepRecentTokens`, alongside a bounded recovery record for discarded inputs and tool evidence. No model summarizes the work. The model must restore notes and check live state before it continues. On [fitchmultz/pi](https://github.com/fitchmultz/pi), a Claude model also writes one line that points to the task note, so that Claude keeps [the reasoning of the retained replies](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/context-recovery.md#claude-reasoning-in-the-retained-tail). Optional `new_context` deliberately retains no prior messages; manual `/compact` uses Pi's normal summarization.
29
29
 
30
30
  Posthorse does not schedule rollover when a provider cache expires. An idle session below the
31
31
  rollover threshold keeps its context; use an explicit `new_context` when a deliberate clean reset
package/index.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { registerPosthorseMessages } from "./ui.ts";
13
13
  import { policySnapshot } from "./src/budget.ts";
14
14
  import type { CompactionPolicy, PolicyReader } from "./src/contracts.ts";
15
+ import { createWindowGuidance } from "./src/guidance.ts";
15
16
  import { registerHistory } from "./src/history.ts";
16
17
  import { safeJsonStringify } from "./src/message-text.ts";
17
18
  import { registerNotes } from "./src/notes.ts";
@@ -44,8 +45,11 @@ export function createPosthorse(getPolicy?: PolicyReader): (pi: ExtensionAPI) =>
44
45
  const readPolicy: PolicyReader =
45
46
  getPolicy ??
46
47
  ((ctx) => SettingsManager.inMemory(pi.getSettings()).getCompactionSettings(ctx.model));
48
+ const guidance = createWindowGuidance(pi);
47
49
  const policy = (ctx: ExtensionContext, livePolicy?: CompactionPolicy) =>
48
- policySnapshot(ctx, readPolicy, livePolicy);
50
+ policySnapshot(ctx, readPolicy, livePolicy, (target, host) =>
51
+ guidance.resolve(target, host),
52
+ );
49
53
  const toolTokens = () => activeToolTokens(pi);
50
54
  const pages = new PageReservations(toolTokens);
51
55
  registerPosthorseMessages(pi);
@@ -55,6 +59,7 @@ export function createPosthorse(getPolicy?: PolicyReader): (pi: ExtensionAPI) =>
55
59
  pages.reset();
56
60
  },
57
61
  notes: (ctx) => notes.recovery(ctx),
62
+ guidance,
58
63
  });
59
64
  pi.on("session_start", async (_event, ctx) => {
60
65
  await notes.get(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.10.21",
3
+ "version": "0.10.24",
4
4
  "type": "module",
5
5
  "description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
6
6
  "keywords": [
package/src/budget.ts CHANGED
@@ -13,6 +13,14 @@ import {
13
13
  import { imagesOf, textOf } from "./message-text.ts";
14
14
 
15
15
  const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
16
+ export type GuidanceTools = {
17
+ readonly newContext: boolean;
18
+ readonly getContextRemaining: boolean;
19
+ };
20
+ export const ALL_GUIDANCE_TOOLS: GuidanceTools = {
21
+ newContext: true,
22
+ getContextRemaining: true,
23
+ };
16
24
  export type Budget = {
17
25
  readonly contextWindow: number;
18
26
  readonly reserveTokens: number;
@@ -26,11 +34,13 @@ export function policySnapshot(
26
34
  ctx: ExtensionContext,
27
35
  getPolicy: PolicyReader,
28
36
  livePolicy?: CompactionPolicy,
37
+ resolveGuidance?: (ctx: ExtensionContext, host: PolicyContext) => string,
29
38
  ): PolicyContext {
30
39
  let usageRead = false;
31
40
  let usage: ContextUsage | undefined;
32
41
  let settings: CompactionPolicy | undefined;
33
- return {
42
+ let guidance: string | undefined;
43
+ const host: PolicyContext = {
34
44
  getCompactionSettings: () => {
35
45
  settings ??= { ...(livePolicy ?? getPolicy(ctx)) };
36
46
  return settings;
@@ -43,7 +53,13 @@ export function policySnapshot(
43
53
  return usage;
44
54
  },
45
55
  getSystemPrompt: () => ctx.getSystemPrompt(),
56
+ getGuidance: () => {
57
+ guidance ??=
58
+ resolveGuidance !== undefined ? resolveGuidance(ctx, host) : buildGuidance(host);
59
+ return guidance;
60
+ },
46
61
  };
62
+ return host;
47
63
  }
48
64
  export function budgetFor(
49
65
  ctx: PolicyContext,
@@ -63,45 +79,75 @@ export function budgetFor(
63
79
  supported: !enabled || usable >= MIN_USABLE_TOKENS,
64
80
  };
65
81
  }
66
- export function unsupportedMessage(budget: Budget): string {
82
+ export function budgetGuidanceKey(ctx: PolicyContext): string {
83
+ const budget = budgetFor(ctx);
84
+ if (budget === undefined) {
85
+ return ctx.getCompactionSettings().enabled ? "1:unknown" : "0:unknown";
86
+ }
87
+ return budget.enabled
88
+ ? `1:${budget.contextWindow}:${budget.reserveTokens}`
89
+ : `0:${budget.contextWindow}`;
90
+ }
91
+ export function unsupportedMessage(budget: Budget, newContextAvailable = true): string {
67
92
  const n = (value: number) => value.toLocaleString("en-US");
68
- return `Posthorse: unsupported configuration. The model's context window (${n(budget.contextWindow)} tokens) minus Pi's compaction.reserveTokens (${n(budget.reserveTokens)}) leaves ${n(budget.usable)} usable tokens; Posthorse needs at least ${n(MIN_USABLE_TOKENS)}. Automatic Posthorse rollover and checkpoint reminders are off for this model, so Pi's own compaction applies. Lower compaction.reserveTokens in Pi settings or use a larger-context model. new_context remains available with a model-aware handoff limit.`;
93
+ const reset = newContextAvailable
94
+ ? " new_context remains available with a model-aware handoff limit."
95
+ : "";
96
+ return `Posthorse: unsupported configuration. The model's context window (${n(budget.contextWindow)} tokens) minus Pi's compaction.reserveTokens (${n(budget.reserveTokens)}) leaves ${n(budget.usable)} usable tokens; Posthorse needs at least ${n(MIN_USABLE_TOKENS)}. Automatic Posthorse rollover and checkpoint reminders are off for this model, so Pi's own compaction applies. Lower compaction.reserveTokens in Pi settings or use a larger-context model.${reset}`;
69
97
  }
70
- function automaticGuidance(budget: Budget | undefined, enabled: boolean): string {
98
+ function automaticGuidance(
99
+ budget: Budget | undefined,
100
+ enabled: boolean,
101
+ tools: GuidanceTools,
102
+ ): string {
71
103
  if (!enabled) {
72
- return "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available.";
104
+ const reset = tools.newContext ? " new_context remains available." : "";
105
+ return `Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder.${reset}`;
73
106
  }
74
107
  if (budget !== undefined && !budget.supported) {
75
- return unsupportedMessage(budget);
108
+ return unsupportedMessage(budget, tools.newContext);
76
109
  }
77
110
  const line =
78
111
  budget === undefined
79
112
  ? ""
80
113
  : `The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used. `;
114
+ const reset = tools.newContext ? " new_context is an optional clean reset." : "";
81
115
  return `Pi rolls context over automatically, so the context window is not a deadline. Do not reduce scope, skip steps or verification, or write a final summary because rollover is near, and do not wait for the user to say "continue". Finish when the requested work and its verification are complete, or report a genuine blocker.
82
- ${line}Rollover keeps a recent-message tail without summarizing. One task-note reminder may arrive at the end of a working turn near the line: update the note only if it is stale, or write one if the task has none, then continue with the next step in the same run. new_context is an optional clean reset.`;
116
+ ${line}Rollover keeps a recent-message tail without summarizing. One task-note reminder may arrive at the end of a working turn near the line: update the note only if it is stale, or write one if the task has none, then continue with the next step in the same run.${reset}`;
83
117
  }
84
- export function buildGuidance(ctx: PolicyContext): string {
118
+ export function buildGuidance(
119
+ ctx: PolicyContext,
120
+ tools: GuidanceTools = ALL_GUIDANCE_TOOLS,
121
+ ): string {
85
122
  const budget = budgetFor(ctx);
86
123
  const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
87
124
  const capacity =
88
125
  budget === undefined
89
126
  ? "Configured context capacity is unknown."
90
127
  : `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens (not remaining space).`;
128
+ const checkRemaining = tools.getContextRemaining
129
+ ? " Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining."
130
+ : "";
91
131
  return `## Automatic context management (Posthorse)
92
- ${automaticGuidance(budget, enabled)}
93
- ${capacity} Do not guess capacity or remaining space from response-token or reasoning limits. Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining. If usage is unknown, report it as unknown. No routine budget checks are needed.
132
+ ${automaticGuidance(budget, enabled, tools)}
133
+ ${capacity} Do not guess capacity or remaining space from response-token or reasoning limits.${checkRemaining} If usage is unknown, report it as unknown. No routine budget checks are needed.
94
134
  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.
95
135
  Keep one concise current-state note per task: goal, progress, decisions, and next steps. Preserve decisions and safety constraints, link fuller evidence instead of copying it, and edit only changed sections.`;
96
136
  }
97
- /** ponytail: native estimates omit request-local guidance; reserve it until post-transform usage is exposed. */
98
- export function guidanceTokens(ctx: PolicyContext): number {
137
+ export function guidanceFor(ctx: PolicyContext): string {
138
+ return ctx.getGuidance !== undefined ? ctx.getGuidance() : buildGuidance(ctx);
139
+ }
140
+ function guidanceTokenCount(guidance: string): number {
99
141
  return estimateTokens({
100
142
  role: "system",
101
- content: `<posthorse>\n${buildGuidance(ctx)}\n</posthorse>`,
143
+ content: `<posthorse>\n${guidance}\n</posthorse>`,
102
144
  timestamp: 0,
103
145
  });
104
146
  }
147
+ /** ponytail: native estimates omit request-local guidance; reserve it until post-transform usage is exposed. */
148
+ export function guidanceTokens(ctx: PolicyContext): number {
149
+ return guidanceTokenCount(guidanceFor(ctx));
150
+ }
105
151
  /** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
106
152
  export function freshPayloadChars(
107
153
  ctx: PolicyContext,
@@ -114,7 +160,9 @@ export function freshPayloadChars(
114
160
  return cap;
115
161
  }
116
162
  const line = budget.enabled && budget.supported ? budget.rolloverAt : budget.contextWindow;
117
- const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + guidanceTokens(ctx);
163
+ const reservedGuidance =
164
+ cap === MAX_HANDOFF_CHARS ? guidanceTokenCount(buildGuidance(ctx)) : guidanceTokens(ctx);
165
+ const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + reservedGuidance;
118
166
  const pendingTokens = pendingMessages.reduce(
119
167
  (total, message) =>
120
168
  total +
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { Type } from "typebox";
3
3
  import { toolCards, type PosthorseDisplay } from "../ui.ts";
4
4
  import { budgetFor, freshPayloadChars, unsupportedMessage, type Budget } from "./budget.ts";
5
+ import { activeGuidanceTools } from "./guidance.ts";
5
6
  import {
6
7
  EMPTY_HANDOFF,
7
8
  MAX_HANDOFF_CHARS,
@@ -16,7 +17,7 @@ function rolloverStatus(budget: Budget | undefined): "disabled" | "enabled" | "u
16
17
  }
17
18
  return budget.supported ? "enabled" : "unsupported";
18
19
  }
19
- function contextReport(host: PolicyContext): TextResult {
20
+ function contextReport(host: PolicyContext, newContextAvailable: boolean): TextResult {
20
21
  const usage = host.getContextUsage();
21
22
  if (usage === undefined || usage.tokens === null) {
22
23
  return textResult("Context usage is not known until the next model response.", [], {
@@ -41,7 +42,11 @@ function contextReport(host: PolicyContext): TextResult {
41
42
  );
42
43
  }
43
44
  if (!budget.supported) {
44
- return textResult(`${unsupportedMessage(budget)} ${configured}`, [], display);
45
+ return textResult(
46
+ `${unsupportedMessage(budget, newContextAvailable)} ${configured}`,
47
+ [],
48
+ display,
49
+ );
45
50
  }
46
51
  return textResult(
47
52
  `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
@@ -97,7 +102,7 @@ export function registerContextTools(
97
102
  "verify remaining context before reporting a token count or changing plans because of context limits",
98
103
  parameters: Type.Object({}),
99
104
  async execute(...[, , , , ctx]) {
100
- return contextReport(policy(ctx));
105
+ return contextReport(policy(ctx), activeGuidanceTools(pi).newContext);
101
106
  },
102
107
  });
103
108
  }
package/src/contracts.ts CHANGED
@@ -17,6 +17,7 @@ export type PolicyContext = {
17
17
  readonly getCompactionSettings: () => CompactionPolicy;
18
18
  readonly getContextUsage: () => ContextUsage | undefined;
19
19
  readonly getSystemPrompt: () => string;
20
+ readonly getGuidance?: () => string;
20
21
  };
21
22
  export type PolicyReader = (ctx: ExtensionContext) => CompactionPolicy;
22
23
  export type SnapshotReader = (
@@ -70,6 +71,7 @@ export type EntryLike = {
70
71
  readonly parentSession?: string | undefined;
71
72
  readonly handoff?: string | undefined;
72
73
  readonly firstKeptEntryId?: string | null | undefined;
74
+ readonly providerSummary?: unknown;
73
75
  readonly targetId?: string | undefined;
74
76
  readonly replacement?: { readonly content: unknown } | null | undefined;
75
77
  };
package/src/guidance.ts CHANGED
@@ -1,8 +1,250 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { buildGuidance } from "./budget.ts";
3
- import type { SnapshotReader } from "./contracts.ts";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type, type Static } from "typebox";
3
+ import { Check } from "typebox/value";
4
+ import {
5
+ ALL_GUIDANCE_TOOLS,
6
+ budgetGuidanceKey,
7
+ buildGuidance,
8
+ type GuidanceTools,
9
+ } from "./budget.ts";
10
+ import { isRecord, type EntryLike, type PolicyContext, type SnapshotReader } from "./contracts.ts";
4
11
 
5
- export function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
12
+ export const WINDOW_GUIDANCE_TYPE = "posthorse-window-guidance";
13
+ const guidanceRecordSchema = Type.Object({
14
+ version: Type.Literal(1),
15
+ budgetKey: Type.String(),
16
+ text: Type.String(),
17
+ tools: Type.Object({
18
+ newContext: Type.Boolean(),
19
+ getContextRemaining: Type.Boolean(),
20
+ }),
21
+ });
22
+ type WindowGuidanceRecord = Static<typeof guidanceRecordSchema>;
23
+ type MemoryEpoch = {
24
+ readonly byBudget: Map<string, string>;
25
+ tools: GuidanceTools | undefined;
26
+ };
27
+ type EpochState = {
28
+ readonly manager: ExtensionContext["sessionManager"];
29
+ readonly sessionId: string;
30
+ readonly file: string | undefined;
31
+ readonly leaf: EntryLike | undefined;
32
+ readonly anchorKey: string;
33
+ readonly byBudget: ReadonlyMap<string, string>;
34
+ readonly tools: GuidanceTools | undefined;
35
+ };
36
+ export type WindowGuidance = {
37
+ readonly resolve: (ctx: ExtensionContext, host: PolicyContext) => string;
38
+ readonly deliver: (ctx: ExtensionContext, host: PolicyContext) => string;
39
+ readonly reset: () => void;
40
+ };
41
+
42
+ export function activeGuidanceTools(pi: ExtensionAPI): GuidanceTools {
43
+ const active = new Set(pi.getActiveTools());
44
+ const unnamespaced = new Set(
45
+ pi
46
+ .getAllTools()
47
+ .filter((tool) => tool.namespace === undefined)
48
+ .map((tool) => tool.name),
49
+ );
50
+ return {
51
+ newContext: active.has("new_context") && unnamespaced.has("new_context"),
52
+ getContextRemaining:
53
+ active.has("get_context_remaining") && unnamespaced.has("get_context_remaining"),
54
+ };
55
+ }
56
+ /**
57
+ * A compaction with a provider summary block carries forward the previous window's leading system
58
+ * message unchanged, so its guidance section must stay locked to that earlier epoch too.
59
+ */
60
+ function startsFreshGuidanceEpoch(entry: EntryLike): boolean {
61
+ if (entry.type === "context_window") {
62
+ return true;
63
+ }
64
+ return (
65
+ entry.type === "compaction" &&
66
+ !(isRecord(entry.providerSummary) && isRecord(entry.providerSummary.block))
67
+ );
68
+ }
69
+ function parentEntry(
70
+ manager: ExtensionContext["sessionManager"],
71
+ id: string | null | undefined,
72
+ ): EntryLike | undefined {
73
+ const raw: unknown = manager;
74
+ if (id === undefined || id === null || id === "" || !isRecord(raw)) {
75
+ return undefined;
76
+ }
77
+ return typeof raw.getEntry === "function" ? manager.getEntry(id) : undefined;
78
+ }
79
+ function sessionIdentityOf(manager: ExtensionContext["sessionManager"]): {
80
+ readonly sessionId: string;
81
+ readonly file: string | undefined;
82
+ readonly leaf: EntryLike | undefined;
83
+ } {
84
+ const raw: unknown = manager;
85
+ const sessionId =
86
+ isRecord(raw) && typeof raw.getSessionId === "function" ? manager.getSessionId() : "";
87
+ const file =
88
+ isRecord(raw) && typeof raw.getSessionFile === "function"
89
+ ? manager.getSessionFile()
90
+ : undefined;
91
+ const leafId =
92
+ isRecord(raw) && typeof raw.getLeafId === "function" ? manager.getLeafId() : null;
93
+ return { sessionId, file, leaf: parentEntry(manager, leafId) };
94
+ }
95
+ function collectGuidanceEntries(
96
+ entries: readonly EntryLike[],
97
+ baseByBudget?: ReadonlyMap<string, string>,
98
+ baseTools?: GuidanceTools,
99
+ ): { readonly byBudget: Map<string, string>; readonly tools: GuidanceTools | undefined } {
100
+ const byBudget = new Map<string, string>(baseByBudget);
101
+ let tools = baseTools;
102
+ for (const entry of entries) {
103
+ if (
104
+ entry.type === "custom" &&
105
+ entry.customType === WINDOW_GUIDANCE_TYPE &&
106
+ Check(guidanceRecordSchema, entry.data)
107
+ ) {
108
+ byBudget.set(entry.data.budgetKey, entry.data.text);
109
+ tools ??= entry.data.tools;
110
+ }
111
+ }
112
+ return { byBudget, tools };
113
+ }
114
+ function withMemoryEpoch(
115
+ collected: {
116
+ readonly byBudget: Map<string, string>;
117
+ readonly tools: GuidanceTools | undefined;
118
+ },
119
+ memory: MemoryEpoch | undefined,
120
+ ): { readonly byBudget: ReadonlyMap<string, string>; readonly tools: GuidanceTools | undefined } {
121
+ if (memory === undefined) {
122
+ return collected;
123
+ }
124
+ const merged = new Map(collected.byBudget);
125
+ for (const [key, text] of memory.byBudget) {
126
+ if (!merged.has(key)) {
127
+ merged.set(key, text);
128
+ }
129
+ }
130
+ return { byBudget: merged, tools: collected.tools ?? memory.tools };
131
+ }
132
+ function scanGuidanceEpoch(
133
+ manager: ExtensionContext["sessionManager"],
134
+ identity: { readonly sessionId: string; readonly leaf: EntryLike | undefined },
135
+ previous: EpochState | undefined,
136
+ memoryByEpoch: ReadonlyMap<string, MemoryEpoch> | undefined,
137
+ ): Pick<EpochState, "anchorKey" | "byBudget" | "tools"> {
138
+ const reversed: EntryLike[] = [];
139
+ let anchor: EntryLike | undefined;
140
+ for (
141
+ let entry = identity.leaf;
142
+ entry !== undefined;
143
+ entry = parentEntry(manager, entry.parentId)
144
+ ) {
145
+ if (previous !== undefined && previous.leaf === entry) {
146
+ const collected = collectGuidanceEntries(
147
+ reversed.reverse(),
148
+ previous.byBudget,
149
+ previous.tools,
150
+ );
151
+ return {
152
+ anchorKey: previous.anchorKey,
153
+ ...withMemoryEpoch(collected, memoryByEpoch?.get(previous.anchorKey)),
154
+ };
155
+ }
156
+ if (startsFreshGuidanceEpoch(entry)) {
157
+ anchor = entry;
158
+ break;
159
+ }
160
+ reversed.push(entry);
161
+ }
162
+ const anchorKey =
163
+ anchor === undefined
164
+ ? `${identity.sessionId}:root`
165
+ : `${identity.sessionId}:${anchor.id ?? "boundary"}`;
166
+ const collected = collectGuidanceEntries(reversed.reverse());
167
+ return {
168
+ anchorKey,
169
+ ...withMemoryEpoch(collected, memoryByEpoch?.get(anchorKey)),
170
+ };
171
+ }
172
+ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
173
+ let state: EpochState | undefined;
174
+ let memoryByManager = new WeakMap<object, Map<string, MemoryEpoch>>();
175
+ const readEpoch = (ctx: ExtensionContext): EpochState => {
176
+ const manager = ctx.sessionManager;
177
+ const { sessionId, file, leaf } = sessionIdentityOf(manager);
178
+ const previous =
179
+ state?.manager === manager && state.sessionId === sessionId && state.file === file
180
+ ? state
181
+ : undefined;
182
+ if (previous !== undefined && previous.leaf === leaf) {
183
+ return previous;
184
+ }
185
+ state = {
186
+ manager,
187
+ sessionId,
188
+ file,
189
+ leaf,
190
+ ...scanGuidanceEpoch(
191
+ manager,
192
+ { sessionId, leaf },
193
+ previous,
194
+ memoryByManager.get(manager),
195
+ ),
196
+ };
197
+ return state;
198
+ };
199
+ return {
200
+ resolve(ctx, host) {
201
+ const epoch = readEpoch(ctx);
202
+ const key = budgetGuidanceKey(host);
203
+ return (
204
+ epoch.byBudget.get(key) ?? buildGuidance(host, epoch.tools ?? ALL_GUIDANCE_TOOLS)
205
+ );
206
+ },
207
+ deliver(ctx, host) {
208
+ const epoch = readEpoch(ctx);
209
+ const key = budgetGuidanceKey(host);
210
+ const existing = epoch.byBudget.get(key);
211
+ if (existing !== undefined) {
212
+ return existing;
213
+ }
214
+ const tools = epoch.tools ?? activeGuidanceTools(pi);
215
+ const text = buildGuidance(host, tools);
216
+ let byEpoch = memoryByManager.get(ctx.sessionManager);
217
+ if (byEpoch === undefined) {
218
+ byEpoch = new Map();
219
+ memoryByManager.set(ctx.sessionManager, byEpoch);
220
+ }
221
+ let memory = byEpoch.get(epoch.anchorKey);
222
+ if (memory === undefined) {
223
+ memory = { byBudget: new Map(), tools };
224
+ byEpoch.set(epoch.anchorKey, memory);
225
+ }
226
+ memory.byBudget.set(key, text);
227
+ memory.tools ??= tools;
228
+ state = undefined;
229
+ pi.appendEntry(WINDOW_GUIDANCE_TYPE, {
230
+ version: 1,
231
+ budgetKey: key,
232
+ text,
233
+ tools,
234
+ } satisfies WindowGuidanceRecord);
235
+ return text;
236
+ },
237
+ reset() {
238
+ state = undefined;
239
+ memoryByManager = new WeakMap();
240
+ },
241
+ };
242
+ }
243
+ export function registerGuidance(
244
+ pi: ExtensionAPI,
245
+ policy: SnapshotReader,
246
+ windowGuidance: WindowGuidance,
247
+ ): void {
6
248
  let promptOptions: { readonly forceSystemPrompt?: string } | undefined;
7
249
  pi.on("before_agent_start", (event) => {
8
250
  promptOptions = event.systemPromptOptions;
@@ -12,7 +254,7 @@ export function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void
12
254
  promptOptions = undefined;
13
255
  });
14
256
  pi.on("context_with_system", (event, ctx) => {
15
- const guidance = buildGuidance(policy(ctx));
257
+ const guidance = windowGuidance.deliver(ctx, policy(ctx));
16
258
  const system =
17
259
  event.messages.findLast(
18
260
  (message) =>
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { statSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { isRecord, type EntryLike } from "./contracts.ts";
@@ -115,10 +115,22 @@ async function sourceScope(
115
115
  source.file === undefined ? manager.getEntries() : await journalMetadata(source.file, true);
116
116
  return legacySource(legacy.length > 0 ? legacy : manager.getEntries());
117
117
  }
118
+ function hasReferencedTarget(
119
+ current: NoteScope,
120
+ path: string,
121
+ includeDirectories: boolean,
122
+ ): boolean {
123
+ const info = statSync(notePath(current, path), { throwIfNoEntry: false });
124
+ if (info === undefined) {
125
+ return false;
126
+ }
127
+ return info.isFile() || (includeDirectories && info.isDirectory());
128
+ }
118
129
  export async function resolveReference(
119
130
  scope: NoteScope,
120
131
  path: string,
121
132
  entries: readonly EntryLike[],
133
+ options: { readonly includeDirectories?: boolean | undefined } = {},
122
134
  ): Promise<NoteAddress | undefined> {
123
135
  let source: NoteScope | null = scope;
124
136
  const bindings = bindingsOf(entries);
@@ -130,7 +142,7 @@ export async function resolveReference(
130
142
  throw new Error(`Cyclic note provenance at ${directory}.`);
131
143
  }
132
144
  visited.add(directory);
133
- if (existsSync(notePath(current, path))) {
145
+ if (hasReferencedTarget(current, path, options.includeDirectories === true)) {
134
146
  return addressIn(current, path);
135
147
  }
136
148
  // A manifest includes later adoptions; the journal includes later writes and deletions.
@@ -113,6 +113,36 @@ export function noteRelative(path: string): string {
113
113
  }
114
114
  return normalized;
115
115
  }
116
+ function ancestorsOf(path: string): string[] {
117
+ const ancestors: string[] = [];
118
+ let current = dirname(path);
119
+ while (true) {
120
+ ancestors.push(current);
121
+ const parent = dirname(current);
122
+ if (parent === current) {
123
+ return ancestors.toReversed();
124
+ }
125
+ current = parent;
126
+ }
127
+ }
128
+ export function scopeNoteRelative(scope: NoteScope, path: string): string {
129
+ if (
130
+ path !== "" &&
131
+ isAbsolute(path) &&
132
+ !path.includes("\0") &&
133
+ !path.split(/[/\\]/u).includes("..")
134
+ ) {
135
+ const targetDirectory = canonicalPath(scopeDirectory(scope));
136
+ const resolved = resolve(path);
137
+ const matched = ancestorsOf(resolved).find(
138
+ (ancestor) => canonicalPath(ancestor) === targetDirectory,
139
+ );
140
+ if (matched !== undefined) {
141
+ return noteRelative(relative(matched, resolved));
142
+ }
143
+ }
144
+ return noteRelative(path);
145
+ }
116
146
  /** Resolve existing ancestors too, so aliases to files not created yet have one owner. */
117
147
  export function canonicalPath(path: string): string {
118
148
  const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`;
package/src/note-store.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  sameOwner,
24
24
  sameScope,
25
25
  scopeDirectory,
26
+ scopeNoteRelative,
26
27
  type NoteAddress,
27
28
  type NoteBinding,
28
29
  type NoteScope,
@@ -133,16 +134,24 @@ export class SessionNotes {
133
134
  `Unknown notes scope ${JSON.stringify(selector)}. Omit scope for this session's notes, or copy a scope selector from notes list/search.`,
134
135
  );
135
136
  }
136
- async #reference(path: string, signal?: AbortSignal): Promise<NoteAddress | undefined> {
137
+ async #reference(
138
+ path: string,
139
+ options: {
140
+ readonly includeDirectories?: boolean | undefined;
141
+ readonly signal?: AbortSignal | undefined;
142
+ } = {},
143
+ ): Promise<NoteAddress | undefined> {
137
144
  if (this.binding.kind !== "fork" || this.binding.source === null) {
138
145
  return;
139
146
  }
140
147
  const entries = [
141
148
  ...this.#manager.getEntries(),
142
149
  ...this.#source.entries,
143
- ...(await sourceEntries(this.#source.file, signal)),
150
+ ...(await sourceEntries(this.#source.file, options.signal)),
144
151
  ];
145
- return resolveReference(this.binding.source, path, entries);
152
+ return resolveReference(this.binding.source, path, entries, {
153
+ includeDirectories: options.includeDirectories,
154
+ });
146
155
  }
147
156
  async read(
148
157
  path: string,
@@ -151,8 +160,8 @@ export class SessionNotes {
151
160
  readonly signal?: AbortSignal | undefined;
152
161
  } = {},
153
162
  ): Promise<NoteRead> {
154
- const relative = noteRelative(path);
155
163
  if (options.scope !== undefined) {
164
+ const relative = noteRelative(path);
156
165
  const scope = await this.selectScope(options.scope);
157
166
  return {
158
167
  address: addressIn(scope, relative),
@@ -160,9 +169,13 @@ export class SessionNotes {
160
169
  writable: sameScope(scope, this.binding),
161
170
  };
162
171
  }
172
+ const relative = scopeNoteRelative(this.binding, path);
163
173
  const reference = this.owns(relative)
164
174
  ? undefined
165
- : await this.#reference(relative, options.signal);
175
+ : await this.#reference(relative, {
176
+ includeDirectories: true,
177
+ signal: options.signal,
178
+ });
166
179
  const address = reference ?? addressIn(this.binding, relative);
167
180
  return { address, path: notePath(address, relative), writable: reference === undefined };
168
181
  }
package/src/notes.ts CHANGED
@@ -92,15 +92,20 @@ async function readNote(
92
92
  access: NotesAccess,
93
93
  ): Promise<TextResult> {
94
94
  const { pages } = access;
95
- const relative = requireValue(params.path, "path", params.op);
96
- const target = await session.read(relative, { scope: params.scope, signal: access.signal });
95
+ const requested = requireValue(params.path, "path", params.op);
96
+ const target = await session.read(requested, { scope: params.scope, signal: access.signal });
97
+ const relative = target.address.path;
97
98
  const path = target.path;
98
99
  const info = statSync(path, { throwIfNoEntry: false });
99
100
  if (info === undefined) {
100
101
  throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
101
102
  }
102
103
  if (info.isDirectory()) {
103
- return listNotes(session, { ...params, scope: scopeDirectory(target.address) }, access);
104
+ return listNotes(
105
+ session,
106
+ { ...params, path: relative, scope: scopeDirectory(target.address) },
107
+ access,
108
+ );
104
109
  }
105
110
  if (await isBinaryFile(path)) {
106
111
  throw new Error(
@@ -283,6 +288,40 @@ function withoutEmptyScope(params: NotesParams): NotesParams {
283
288
  const { scope, ...unscoped } = params;
284
289
  return scope === "" ? unscoped : params;
285
290
  }
291
+ const notesParameters = Type.Object({
292
+ op: Type.Union(
293
+ [
294
+ Type.Literal("list"),
295
+ Type.Literal("read"),
296
+ Type.Literal("write"),
297
+ Type.Literal("append"),
298
+ Type.Literal("search"),
299
+ ],
300
+ { description: "Operation to perform" },
301
+ ),
302
+ path: Type.Optional(
303
+ Type.String({
304
+ description:
305
+ "Note or folder path relative to the notes directory (read also accepts a path in this session's notes directory)",
306
+ }),
307
+ ),
308
+ scope: Type.Optional(
309
+ Type.String({
310
+ description:
311
+ "Absolute scope selector from list/search; only for read/list/search. Omit for current notes and project-wide discovery.",
312
+ }),
313
+ ),
314
+ content: Type.Optional(
315
+ Type.String({ description: "Full file content (write) or text to add (append)" }),
316
+ ),
317
+ query: Type.Optional(Type.String({ description: "Substring to find in notes (search)" })),
318
+ offset: Type.Optional(
319
+ Type.Integer({
320
+ description: "Character offset for list/read/search (default 0)",
321
+ minimum: 0,
322
+ }),
323
+ ),
324
+ });
286
325
  export function registerNotes(
287
326
  pi: ExtensionAPI,
288
327
  policy: (ctx: ExtensionContext) => PolicyContext,
@@ -297,39 +336,7 @@ export function registerNotes(
297
336
  description:
298
337
  "Durable task notes shared across Git worktrees and context resets; forks get private copies. Ops: list (newest first), read, search (case-insensitive), write (replace; empty clears), append (one record). list/search also cover other sessions' note scopes and show their selectors; pass scope to read, list, or search one of them. list/read/search are paged; continue with the returned offset. A read of a current note returns its file path.",
299
338
  promptSnippet: "save and recall durable state with private notes for forks",
300
- parameters: Type.Object({
301
- op: Type.Union(
302
- [
303
- Type.Literal("list"),
304
- Type.Literal("read"),
305
- Type.Literal("write"),
306
- Type.Literal("append"),
307
- Type.Literal("search"),
308
- ],
309
- { description: "Operation to perform" },
310
- ),
311
- path: Type.Optional(
312
- Type.String({ description: "Note or folder path relative to the notes directory" }),
313
- ),
314
- scope: Type.Optional(
315
- Type.String({
316
- description:
317
- "Absolute scope selector from list/search; only for read/list/search. Omit for current notes and project-wide discovery.",
318
- }),
319
- ),
320
- content: Type.Optional(
321
- Type.String({ description: "Full file content (write) or text to add (append)" }),
322
- ),
323
- query: Type.Optional(
324
- Type.String({ description: "Substring to find in notes (search)" }),
325
- ),
326
- offset: Type.Optional(
327
- Type.Integer({
328
- description: "Character offset for list/read/search (default 0)",
329
- minimum: 0,
330
- }),
331
- ),
332
- }),
339
+ parameters: notesParameters,
333
340
  async execute(...[_id, received, signal, , ctx]) {
334
341
  const params = withoutEmptyScope(received);
335
342
  // Migrate newly returned legacy package notes without changing this session’s binding.
@@ -0,0 +1,53 @@
1
+ import * as host from "@earendil-works/pi-ai";
2
+
3
+ /**
4
+ * A one-line summary block written by the provider at an automatic rollover.
5
+ *
6
+ * A host that implements `providerSummary` on a compaction asks the session's model for the block
7
+ * and sends it ahead of the retained tail, which keeps the reasoning of the tail's replies valid.
8
+ * The block carries no task state; the recovery record after the tail and the task note do.
9
+ */
10
+ export type ProviderSummaryRequest = { readonly instructions: string };
11
+ type SupportProbe = (model: unknown) => unknown;
12
+ function isProbe(value: unknown): value is SupportProbe {
13
+ return typeof value === "function";
14
+ }
15
+ /** Exported by a host that can make the block; it knows which models return one. */
16
+ const hostSupport: unknown = Reflect.get(host, "supportsProviderSummary");
17
+ export function hostMakesProviderSummary(model: unknown): boolean {
18
+ return isProbe(hostSupport) && model !== undefined && hostSupport(model) === true;
19
+ }
20
+ /** Measured size of a one-line block is 59 to 77 tokens; Pi's estimate does not count it. */
21
+ export const PROVIDER_SUMMARY_TOKENS = 100;
22
+ /**
23
+ * The model answers a request for a one-line summary. It refuses a request to reply with a fixed
24
+ * line or with the path only, and one that tells the summary to speak about "the recent messages",
25
+ * so the wording asks for a summary that points to the note, or to the record when there is none.
26
+ */
27
+ export function providerSummaryInstructions(notePath: string | undefined): string {
28
+ const pointer =
29
+ notePath === undefined
30
+ ? "state that the details are in the recovery record below"
31
+ : `point to the task note \`${notePath}\` and say to read that note and proceed from there`;
32
+ return `Write a summary of one line, at most 20 words: name the task in a few words, then ${pointer}. Do not mention progress, a pending step or a next step. Do not call any tools; respond with the summary text only.`;
33
+ }
34
+ type MessageLike = { readonly role: string };
35
+ /**
36
+ * How many leading messages Pi sends exactly as the provider last saw them. With a block, those
37
+ * are the leading system message and the retained tail up to the record; changing, removing or
38
+ * reordering one makes the provider drop the reasoning of every reply after it. A tail that
39
+ * reaches back across an earlier rollover holds that rollover's record too, so the boundary is
40
+ * the last record.
41
+ */
42
+ export function messagesHeldByProviderSummary(messages: readonly MessageLike[]): number {
43
+ const [first] = messages;
44
+ if (
45
+ first?.role !== "system" ||
46
+ !("providerSummary" in first) ||
47
+ first.providerSummary === undefined
48
+ ) {
49
+ return 0;
50
+ }
51
+ const record = messages.findLastIndex((message) => message.role === "compactionSummary");
52
+ return record < 0 ? messages.length : record;
53
+ }
@@ -244,11 +244,24 @@ function notesCallKind(args: Readonly<Record<string, unknown>>): ContentTouchKin
244
244
  return args.op === "read" ? "read" : undefined;
245
245
  }
246
246
 
247
+ function preferredNotePath(
248
+ callTarget: NoteTarget | undefined,
249
+ resTarget: NoteTarget | undefined,
250
+ ): string | undefined {
251
+ if (callTarget === undefined) {
252
+ return resTarget?.notePath;
253
+ }
254
+ if (callTarget.absPath !== undefined && resTarget !== undefined) {
255
+ return resTarget.notePath;
256
+ }
257
+ return callTarget.notePath;
258
+ }
259
+
247
260
  function mergeNoteTargets(
248
261
  callTarget: NoteTarget | undefined,
249
262
  resTarget: NoteTarget | undefined,
250
263
  ): NoteTarget | undefined {
251
- const notePath = callTarget?.notePath ?? resTarget?.notePath;
264
+ const notePath = preferredNotePath(callTarget, resTarget);
252
265
  return notePath === undefined
253
266
  ? undefined
254
267
  : { notePath, absPath: resTarget?.absPath ?? callTarget?.absPath };
@@ -172,34 +172,62 @@ function trackedNotes(
172
172
  }
173
173
  return [...tracked.values()].sort((a, b) => b.order - a.order);
174
174
  }
175
- export function renderSessionNotes(
175
+ type NoteSources = {
176
+ readonly retained: ReadonlySet<string>;
177
+ readonly notes?: NotesRecovery | undefined;
178
+ };
179
+ function sessionNotes(
176
180
  chainEntries: readonly EntryLike[],
177
181
  allEntries: readonly EntryLike[],
178
182
  projected: readonly ProjectedEntry[],
179
- options: {
180
- readonly retained: ReadonlySet<string>;
181
- readonly span: RetainedSpan | undefined;
182
- readonly maxChars: number;
183
- readonly notes?: NotesRecovery | undefined;
184
- },
185
- ): string | undefined {
186
- const { retained, span, maxChars } = options;
183
+ sources: NoteSources,
184
+ ): { readonly all: readonly TrackedNote[]; readonly notesDirectory: string | undefined } {
187
185
  const collected = sessionNoteTouches({
188
186
  branch: chainEntries,
189
- allEntries: options.notes?.entries ?? allEntries,
187
+ allEntries: sources.notes?.entries ?? allEntries,
190
188
  projected,
191
- retained,
189
+ retained: sources.retained,
192
190
  });
193
- const touches = scopeRecoveryTouches(collected.touches, options.notes);
191
+ const touches = scopeRecoveryTouches(collected.touches, sources.notes);
194
192
  const { defaultDir } = collected;
195
- const all = trackedNotes(touches, defaultDir);
193
+ return {
194
+ all: trackedNotes(touches, defaultDir),
195
+ notesDirectory:
196
+ sources.notes === undefined ? defaultDir : scopeDirectory(sources.notes.binding),
197
+ };
198
+ }
199
+ /**
200
+ * The file path of the note that the record names first, when this session wrote one. The path is
201
+ * absolute whenever the notes directory is known, so the `read` tool opens it from any directory.
202
+ */
203
+ export function primaryNotePath(
204
+ chainEntries: readonly EntryLike[],
205
+ allEntries: readonly EntryLike[],
206
+ projected: readonly ProjectedEntry[],
207
+ sources: NoteSources,
208
+ ): string | undefined {
209
+ const { all, notesDirectory } = sessionNotes(chainEntries, allEntries, projected, sources);
210
+ const primary = all.find((note) => note.kind === "written");
211
+ return primary === undefined
212
+ ? undefined
213
+ : (pathElsewhere(primary, notesDirectory) ?? primary.absPath ?? primary.notePath);
214
+ }
215
+ export function renderSessionNotes(
216
+ chainEntries: readonly EntryLike[],
217
+ allEntries: readonly EntryLike[],
218
+ projected: readonly ProjectedEntry[],
219
+ options: NoteSources & {
220
+ readonly span: RetainedSpan | undefined;
221
+ readonly maxChars: number;
222
+ },
223
+ ): string | undefined {
224
+ const { span, maxChars } = options;
225
+ const { all, notesDirectory } = sessionNotes(chainEntries, allEntries, projected, options);
196
226
  if (all.length === 0) {
197
227
  return;
198
228
  }
199
229
  const written = all.filter((note) => note.kind === "written").slice(0, MAX_WRITTEN_NOTES);
200
230
  const readOnly = all.filter((note) => note.kind === "read").slice(0, MAX_READ_NOTES);
201
- const notesDirectory =
202
- options.notes === undefined ? defaultDir : scopeDirectory(options.notes.binding);
203
231
  const lines = [
204
232
  ...written.map((note) => formatWrittenNoteLine(note, span, notesDirectory)),
205
233
  ...readOnly.map((note) => formatReadNoteLine(note, span, notesDirectory)),
package/src/recovery.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  type RecoveryRecord,
10
10
  } from "./recovery-records.ts";
11
11
  import type { NotesRecovery } from "./note-evidence.ts";
12
- import { renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
12
+ import { primaryNotePath, renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
13
13
  import {
14
14
  recoverableToolResults,
15
15
  type RecoveryBatch,
@@ -255,6 +255,20 @@ function rolloverPreambleWithNotes(
255
255
  );
256
256
  return joined([basePreamble, notesSection]);
257
257
  }
258
+ /** The note the automatic record tells the agent to read, for text that names it outside the record. */
259
+ export function recoveryNotePath(
260
+ entries: readonly EntryLike[],
261
+ projected: readonly ProjectedEntry[],
262
+ options: {
263
+ readonly retainedEntryIds: ReadonlySet<string>;
264
+ readonly notes?: NotesRecovery | undefined;
265
+ },
266
+ ): string | undefined {
267
+ return primaryNotePath(entries.slice(automaticChainStart(entries)), entries, projected, {
268
+ retained: options.retainedEntryIds,
269
+ notes: options.notes,
270
+ });
271
+ }
258
272
  /** Raw owner anchors and projected tool evidence have distinct provenance and share one bounded payload. */
259
273
  export function buildAutoHandoff(
260
274
  entries: readonly EntryLike[],
package/src/retention.ts CHANGED
@@ -15,7 +15,12 @@ import {
15
15
  type PolicyContext,
16
16
  } from "./contracts.ts";
17
17
  import type { NotesRecovery } from "./note-evidence.ts";
18
- import { buildAutoHandoff } from "./recovery.ts";
18
+ import {
19
+ PROVIDER_SUMMARY_TOKENS,
20
+ providerSummaryInstructions,
21
+ type ProviderSummaryRequest,
22
+ } from "./provider-summary.ts";
23
+ import { buildAutoHandoff, recoveryNotePath } from "./recovery.ts";
19
24
 
20
25
  type RecoveryCapacity = {
21
26
  readonly host: PolicyContext;
@@ -23,6 +28,8 @@ type RecoveryCapacity = {
23
28
  readonly pendingMessages?: readonly MessageLike[];
24
29
  readonly firstKeptEntryId?: string | undefined;
25
30
  readonly notes?: NotesRecovery | undefined;
31
+ /** The host will ask the provider for a summary block, which needs an assistant message first in the tail. */
32
+ readonly providerSummary?: boolean | undefined;
26
33
  };
27
34
  type RetainedTail = {
28
35
  readonly firstKeptEntryId?: string | undefined;
@@ -53,6 +60,19 @@ function safeStart(entry: ProjectionEntry): boolean {
53
60
  visibleMessages(entry).some((message) => CUT_ROLES.has(message.role))
54
61
  );
55
62
  }
63
+ /**
64
+ * A provider summary block stands for the request that produced the tail's first message, so
65
+ * that message must be a reply the provider completed.
66
+ */
67
+ function assistantStart(entry: ProjectionEntry): boolean {
68
+ const [first] = visibleMessages(entry);
69
+ return (
70
+ entry.sourceEntry.type !== "compaction" &&
71
+ first?.role === "assistant" &&
72
+ first.stopReason !== "error" &&
73
+ first.stopReason !== "aborted"
74
+ );
75
+ }
56
76
  function nativeMessage(
57
77
  message: ProjectionEntry["messages"][number],
58
78
  ): SessionMessageEntry["message"] {
@@ -129,6 +149,7 @@ function boundedTail(
129
149
  entries: readonly ProjectionEntry[],
130
150
  start: number,
131
151
  maxTokens: number,
152
+ startsTail: (entry: ProjectionEntry) => boolean,
132
153
  ): RetainedTail {
133
154
  const costs = entries.slice(start).map((entry) => ({
134
155
  entry,
@@ -143,7 +164,7 @@ function boundedTail(
143
164
  let tokens = costs.reduce((sum, { cost }) => sum + cost, 0);
144
165
  for (const [offset, { entry, cost }] of costs.entries()) {
145
166
  const index = start + offset;
146
- if (tokens > 0 && tokens <= maxTokens && safeStart(entry)) {
167
+ if (tokens > 0 && tokens <= maxTokens && startsTail(entry)) {
147
168
  const tail = entries.slice(index);
148
169
  if (pairedTail(tail)) {
149
170
  return {
@@ -178,7 +199,14 @@ function retainedTail(
178
199
  (entry) => entry.sourceEntry.id === capacity.firstKeptEntryId,
179
200
  );
180
201
  const start = prepared >= 0 ? prepared : projectedCut(entries, keep);
181
- return start < 0 ? { ids: new Set() } : boundedTail(entries, start, maxTokens);
202
+ return start < 0
203
+ ? { ids: new Set() }
204
+ : boundedTail(
205
+ entries,
206
+ start,
207
+ maxTokens,
208
+ capacity.providerSummary === true ? assistantStart : safeStart,
209
+ );
182
210
  }
183
211
  /** Recent messages and recovery share half the fresh operational budget, leaving work headroom. */
184
212
  export function automaticRecovery(
@@ -186,7 +214,13 @@ export function automaticRecovery(
186
214
  projected: readonly ProjectionEntry[],
187
215
  capacity: RecoveryCapacity,
188
216
  ownerQuestionRegistered: boolean,
189
- ): { readonly handoff: string; readonly firstKeptEntryId?: string | undefined } | undefined {
217
+ ):
218
+ | {
219
+ readonly handoff: string;
220
+ readonly firstKeptEntryId?: string | undefined;
221
+ readonly providerSummary?: ProviderSummaryRequest | undefined;
222
+ }
223
+ | undefined {
190
224
  const payload = freshPayloadChars(
191
225
  capacity.host,
192
226
  capacity.toolTokens,
@@ -197,13 +231,34 @@ export function automaticRecovery(
197
231
  if (limit < MIN_PAGE_CHARS) {
198
232
  return undefined;
199
233
  }
200
- const retained = retainedTail(projected, capacity, Math.floor((payload - limit) / 4));
234
+ const asksProvider = capacity.providerSummary === true;
235
+ const retained = retainedTail(
236
+ projected,
237
+ capacity,
238
+ Math.floor((payload - limit) / 4) - (asksProvider ? PROVIDER_SUMMARY_TOKENS : 0),
239
+ );
201
240
  const handoff = buildAutoHandoff(entries, projected, limit, {
202
241
  ownerQuestionRegistered,
203
242
  retainedEntryIds: retained.ids,
204
243
  notes: capacity.notes,
205
244
  });
206
- return handoff.length > limit
207
- ? undefined
208
- : { handoff, firstKeptEntryId: retained.firstKeptEntryId };
245
+ if (handoff.length > limit) {
246
+ return undefined;
247
+ }
248
+ return {
249
+ handoff,
250
+ firstKeptEntryId: retained.firstKeptEntryId,
251
+ // Without a tail there is no reasoning to keep, so the host is not asked for a block.
252
+ providerSummary:
253
+ !asksProvider || retained.firstKeptEntryId === undefined
254
+ ? undefined
255
+ : {
256
+ instructions: providerSummaryInstructions(
257
+ recoveryNotePath(entries, projected, {
258
+ retainedEntryIds: retained.ids,
259
+ notes: capacity.notes,
260
+ }),
261
+ ),
262
+ },
263
+ };
209
264
  }
package/src/rollover.ts CHANGED
@@ -17,8 +17,13 @@ import {
17
17
  type SnapshotReader,
18
18
  } from "./contracts.ts";
19
19
  import { registerContextTools } from "./context-tools.ts";
20
- import { registerGuidance } from "./guidance.ts";
20
+ import { registerGuidance, type WindowGuidance } from "./guidance.ts";
21
21
  import { safeJsonStringify } from "./message-text.ts";
22
+ import {
23
+ hostMakesProviderSummary,
24
+ messagesHeldByProviderSummary,
25
+ type ProviderSummaryRequest,
26
+ } from "./provider-summary.ts";
22
27
  import { hasRetainedRolloverMarker } from "./recovery.ts";
23
28
  import { automaticRecovery } from "./retention.ts";
24
29
  import { ResetRequests } from "./reset-requests.ts";
@@ -26,6 +31,7 @@ import type { NotesRecovery } from "./note-evidence.ts";
26
31
  type RolloverResources = {
27
32
  readonly resetPages: () => void;
28
33
  readonly notes: (ctx: ExtensionContext) => NotesRecovery | undefined;
34
+ readonly guidance: WindowGuidance;
29
35
  };
30
36
  import {
31
37
  countRetainedMessages,
@@ -157,8 +163,11 @@ function registerReminderFilter(
157
163
  ): void {
158
164
  pi.on("context_with_system", (event, ctx) => {
159
165
  const stale = staleReminderPredicate(event.messages, ctx, policy, reminders);
160
- const filtered = event.messages.some(stale)
161
- ? event.messages.filter((message) => !stale(message))
166
+ const held = messagesHeldByProviderSummary(event.messages);
167
+ const removable = (message: (typeof event.messages)[number], index: number) =>
168
+ index >= held && stale(message);
169
+ const filtered = event.messages.some(removable)
170
+ ? event.messages.filter((message, index) => !removable(message, index))
162
171
  : event.messages;
163
172
  const ordered = chronologicalRetainedTail(filtered, ctx, stale);
164
173
  if (ordered !== event.messages) {
@@ -167,6 +176,10 @@ function registerReminderFilter(
167
176
  return;
168
177
  });
169
178
  }
179
+ /** A host without the capability ignores the field; one that has it stores why no block was made. */
180
+ function providerSummaryField(request: ProviderSummaryRequest | undefined) {
181
+ return request === undefined ? {} : { providerSummary: request };
182
+ }
170
183
  function automaticDraft(
171
184
  pi: ExtensionAPI,
172
185
  ctx: ExtensionContext,
@@ -175,6 +188,7 @@ function automaticDraft(
175
188
  readonly tokensBefore: number;
176
189
  readonly reason: string;
177
190
  readonly firstKeptEntryId?: string | undefined;
191
+ readonly providerSummary?: ProviderSummaryRequest | undefined;
178
192
  },
179
193
  ) {
180
194
  let firstKeptEntryId = recovery.firstKeptEntryId;
@@ -192,6 +206,7 @@ function automaticDraft(
192
206
  firstKeptEntryId,
193
207
  tokensBefore: recovery.tokensBefore,
194
208
  details: { posthorse: 1, reason: recovery.reason },
209
+ ...providerSummaryField(recovery.providerSummary),
195
210
  },
196
211
  };
197
212
  }
@@ -280,6 +295,7 @@ function thresholdFallback(
280
295
  toolTokens: toolTokens(),
281
296
  pendingMessages: event.context.pendingMessages,
282
297
  notes: capacity.notes(ctx),
298
+ providerSummary: hostMakesProviderSummary(ctx.model),
283
299
  },
284
300
  ownerQuestionRegistered,
285
301
  );
@@ -298,6 +314,7 @@ function thresholdFallback(
298
314
  summary: recovery.handoff,
299
315
  firstKeptEntryId: recovery.firstKeptEntryId ?? null,
300
316
  details: { posthorse: 1, reason: "threshold" },
317
+ ...providerSummaryField(recovery.providerSummary),
301
318
  },
302
319
  ],
303
320
  continue: event.continue,
@@ -341,6 +358,8 @@ function prepareAutomatic(
341
358
  toolTokens: capacity.toolTokens(),
342
359
  firstKeptEntryId: event.preparation.firstKeptEntryId,
343
360
  notes: capacity.notes(ctx),
361
+ // Pi makes no block at an overflow, so the tail keeps its usual start there.
362
+ providerSummary: event.reason !== "overflow" && hostMakesProviderSummary(ctx.model),
344
363
  },
345
364
  ownerQuestionRegistered,
346
365
  );
@@ -410,12 +429,14 @@ export function registerRollover(
410
429
  pi.on("session_start", () => {
411
430
  resetTurn();
412
431
  reminders.reset();
432
+ resources.guidance.reset();
413
433
  });
414
434
  pi.on("session_tree", () => {
415
435
  reminders.reset();
436
+ resources.guidance.reset();
416
437
  });
417
438
  registerReminderFilter(pi, policy, reminders);
418
- registerGuidance(pi, policy);
439
+ registerGuidance(pi, policy, resources.guidance);
419
440
  registerAutomaticRollover(pi, policy, activeToolTokens, resources.notes);
420
441
  pi.on("turn_end", (event, ctx) => {
421
442
  const summary = resetRequests.take(event.toolResults);