pi-posthorse 0.10.24 → 0.10.26

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/index.ts CHANGED
@@ -14,6 +14,7 @@ import { policySnapshot } from "./src/budget.ts";
14
14
  import type { CompactionPolicy, PolicyReader } from "./src/contracts.ts";
15
15
  import { createWindowGuidance } from "./src/guidance.ts";
16
16
  import { registerHistory } from "./src/history.ts";
17
+ import { toolFacts } from "./src/host.ts";
17
18
  import { safeJsonStringify } from "./src/message-text.ts";
18
19
  import { registerNotes } from "./src/notes.ts";
19
20
  import { createNotesStore } from "./src/note-store.ts";
@@ -22,9 +23,8 @@ import { PageReservations } from "./src/paging.ts";
22
23
  import { registerRollover } from "./src/rollover.ts";
23
24
 
24
25
  function activeToolTokens(pi: ExtensionAPI): number {
25
- const active = new Set(pi.getActiveTools());
26
- return pi
27
- .getAllTools()
26
+ const { active, tools } = toolFacts(pi);
27
+ return tools
28
28
  .filter((tool) => active.has(tool.name))
29
29
  .reduce(
30
30
  (total, tool) =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.10.24",
3
+ "version": "0.10.26",
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": [
@@ -0,0 +1,101 @@
1
+ import {
2
+ AUTO_HANDOFF_PREFIX,
3
+ POSTHORSE_COMPACTION,
4
+ isRecord,
5
+ type EntryLike,
6
+ type ProjectedEntry,
7
+ } from "./contracts.ts";
8
+
9
+ export type BoundaryKind = "fork" | "native" | "automatic" | "explicit" | "posthorse";
10
+ /** Everything the app derives from one persisted context boundary, decided in one place. */
11
+ export type Boundary = {
12
+ readonly kind: BoundaryKind;
13
+ /** Counts as a Posthorse window: labels, reminder windows, and note provenance. */
14
+ readonly window: boolean;
15
+ /** An automatic recovery record that walks through earlier windows continue past. */
16
+ readonly chainsAncestry: boolean;
17
+ /** Starts a new guidance epoch; a provider summary block keeps the previous one. */
18
+ readonly freshGuidance: boolean;
19
+ };
20
+
21
+ export function windowHandoff(entry: EntryLike | undefined): string | undefined {
22
+ return entry?.type === "compaction" ? entry.summary : entry?.handoff;
23
+ }
24
+ /** An automatic recovery record opens with this prefix; an earlier window's walk may continue past it. */
25
+ function automaticHandoff(entry: EntryLike): boolean {
26
+ return windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) === true;
27
+ }
28
+ /**
29
+ * A compaction with a provider summary block carries forward the previous window's leading system
30
+ * message unchanged, so its guidance section must stay locked to that earlier epoch too.
31
+ */
32
+ function providerSummaryBlock(entry: EntryLike): boolean {
33
+ return (
34
+ entry.type === "compaction" &&
35
+ isRecord(entry.providerSummary) &&
36
+ isRecord(entry.providerSummary.block)
37
+ );
38
+ }
39
+ function compactionKind(details: Readonly<Record<string, unknown>>): BoundaryKind {
40
+ if (details.reason === "threshold" || details.reason === "overflow") {
41
+ return "automatic";
42
+ }
43
+ return details.reason === "explicit" ? "explicit" : "posthorse";
44
+ }
45
+ export function classifyBoundary(entry?: EntryLike): Boundary | undefined {
46
+ if (entry?.type === "context_window") {
47
+ return {
48
+ kind: "fork",
49
+ window: true,
50
+ chainsAncestry: automaticHandoff(entry),
51
+ freshGuidance: true,
52
+ };
53
+ }
54
+ if (entry?.type !== "compaction") {
55
+ return undefined;
56
+ }
57
+ const details = isRecord(entry.details) ? entry.details : undefined;
58
+ const freshGuidance = !providerSummaryBlock(entry);
59
+ if (details?.posthorse !== POSTHORSE_COMPACTION) {
60
+ return { kind: "native", window: false, chainsAncestry: false, freshGuidance };
61
+ }
62
+ const kind = compactionKind(details);
63
+ return {
64
+ kind,
65
+ window: true,
66
+ chainsAncestry: kind === "automatic" && automaticHandoff(entry),
67
+ freshGuidance,
68
+ };
69
+ }
70
+ export function isWindow(entry: EntryLike): boolean {
71
+ return classifyBoundary(entry)?.window === true;
72
+ }
73
+ export function startsFreshGuidanceEpoch(entry: EntryLike): boolean {
74
+ return classifyBoundary(entry)?.freshGuidance === true;
75
+ }
76
+ export function chainsAncestry(entry?: EntryLike): boolean {
77
+ return classifyBoundary(entry)?.chainsAncestry === true;
78
+ }
79
+ /** The boundary already applied to a projection: a compaction with a visible tail. -1 when none. */
80
+ export function activeBoundaryIndex(projected: readonly ProjectedEntry[]): number {
81
+ return projected.findIndex(
82
+ (entry) => entry.sourceEntry.type === "compaction" && entry.messages.length > 0,
83
+ );
84
+ }
85
+ /** First active branch entry, including tails retained by Pi's own /compact. */
86
+ export function contextStart(entries: readonly EntryLike[]): {
87
+ start: number;
88
+ boundary?: EntryLike;
89
+ } {
90
+ for (let i = entries.length - 1; i >= 0; i--) {
91
+ const entry = entries[i];
92
+ if (entry?.type === "context_window") {
93
+ return { start: i + 1, boundary: entry };
94
+ }
95
+ if (entry?.type === "compaction") {
96
+ const kept = entries.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
97
+ return { start: kept >= 0 ? kept : i + 1, boundary: entry };
98
+ }
99
+ }
100
+ return { start: 0 };
101
+ }
package/src/budget.ts CHANGED
@@ -24,11 +24,18 @@ export const ALL_GUIDANCE_TOOLS: GuidanceTools = {
24
24
  export type Budget = {
25
25
  readonly contextWindow: number;
26
26
  readonly reserveTokens: number;
27
- readonly enabled: boolean;
27
+ /** One classification of how Posthorse may use this capacity. */
28
+ readonly state: BudgetState;
28
29
  readonly usable: number;
29
30
  readonly rolloverAt: number;
30
- readonly supported: boolean;
31
31
  };
32
+ export type BudgetState = "disabled" | "unsupported" | "enabled";
33
+ function budgetState(enabled: boolean, usable: number): BudgetState {
34
+ if (!enabled) {
35
+ return "disabled";
36
+ }
37
+ return usable >= MIN_USABLE_TOKENS ? "enabled" : "unsupported";
38
+ }
32
39
  /** A synchronous evaluation owns one snapshot; never carry it across an await. */
33
40
  export function policySnapshot(
34
41
  ctx: ExtensionContext,
@@ -73,10 +80,9 @@ export function budgetFor(
73
80
  return {
74
81
  contextWindow,
75
82
  reserveTokens,
76
- enabled,
83
+ state: budgetState(enabled, usable),
77
84
  usable,
78
85
  rolloverAt: usable + 1,
79
- supported: !enabled || usable >= MIN_USABLE_TOKENS,
80
86
  };
81
87
  }
82
88
  export function budgetGuidanceKey(ctx: PolicyContext): string {
@@ -84,9 +90,9 @@ export function budgetGuidanceKey(ctx: PolicyContext): string {
84
90
  if (budget === undefined) {
85
91
  return ctx.getCompactionSettings().enabled ? "1:unknown" : "0:unknown";
86
92
  }
87
- return budget.enabled
88
- ? `1:${budget.contextWindow}:${budget.reserveTokens}`
89
- : `0:${budget.contextWindow}`;
93
+ return budget.state === "disabled"
94
+ ? `0:${budget.contextWindow}`
95
+ : `1:${budget.contextWindow}:${budget.reserveTokens}`;
90
96
  }
91
97
  export function unsupportedMessage(budget: Budget, newContextAvailable = true): string {
92
98
  const n = (value: number) => value.toLocaleString("en-US");
@@ -104,7 +110,7 @@ function automaticGuidance(
104
110
  const reset = tools.newContext ? " new_context remains available." : "";
105
111
  return `Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder.${reset}`;
106
112
  }
107
- if (budget !== undefined && !budget.supported) {
113
+ if (budget?.state === "unsupported") {
108
114
  return unsupportedMessage(budget, tools.newContext);
109
115
  }
110
116
  const line =
@@ -120,7 +126,8 @@ export function buildGuidance(
120
126
  tools: GuidanceTools = ALL_GUIDANCE_TOOLS,
121
127
  ): string {
122
128
  const budget = budgetFor(ctx);
123
- const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
129
+ const enabled =
130
+ budget === undefined ? ctx.getCompactionSettings().enabled : budget.state !== "disabled";
124
131
  const capacity =
125
132
  budget === undefined
126
133
  ? "Configured context capacity is unknown."
@@ -148,20 +155,29 @@ function guidanceTokenCount(guidance: string): number {
148
155
  export function guidanceTokens(ctx: PolicyContext): number {
149
156
  return guidanceTokenCount(guidanceFor(ctx));
150
157
  }
158
+ /**
159
+ * Which guidance the reservation subtracts: the text rebuilt for the next request, or the host's
160
+ * effective text that is already in the prompt.
161
+ */
162
+ export type GuidanceReservation = "rebuild" | "effective";
151
163
  /** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
152
164
  export function freshPayloadChars(
153
165
  ctx: PolicyContext,
154
166
  toolTokens: number,
155
- pendingMessages: readonly MessageLike[] = [],
156
- cap = MAX_HANDOFF_CHARS,
167
+ options: {
168
+ readonly pendingMessages?: readonly MessageLike[] | undefined;
169
+ readonly cap?: number | undefined;
170
+ readonly guidance?: GuidanceReservation | undefined;
171
+ } = {},
157
172
  ): number {
173
+ const { pendingMessages = [], cap = MAX_HANDOFF_CHARS, guidance = "rebuild" } = options;
158
174
  const budget = budgetFor(ctx);
159
175
  if (budget === undefined) {
160
176
  return cap;
161
177
  }
162
- const line = budget.enabled && budget.supported ? budget.rolloverAt : budget.contextWindow;
178
+ const line = budget.state === "enabled" ? budget.rolloverAt : budget.contextWindow;
163
179
  const reservedGuidance =
164
- cap === MAX_HANDOFF_CHARS ? guidanceTokenCount(buildGuidance(ctx)) : guidanceTokens(ctx);
180
+ guidance === "rebuild" ? guidanceTokenCount(buildGuidance(ctx)) : guidanceTokens(ctx);
165
181
  const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + reservedGuidance;
166
182
  const pendingTokens = pendingMessages.reduce(
167
183
  (total, message) =>
@@ -181,10 +197,13 @@ export function freshPayloadChars(
181
197
  export function pageCapacity(ctx: PolicyContext, toolTokens: () => number): number {
182
198
  const usage = ctx.getContextUsage();
183
199
  if (usage === undefined || usage.tokens === null) {
184
- return freshPayloadChars(ctx, toolTokens(), [], MAX_PAGE_CHARS) / 4 + PAGE_MARGIN_TOKENS;
200
+ return (
201
+ freshPayloadChars(ctx, toolTokens(), { cap: MAX_PAGE_CHARS, guidance: "effective" }) /
202
+ 4 +
203
+ PAGE_MARGIN_TOKENS
204
+ );
185
205
  }
186
206
  const budget = budgetFor(ctx, usage.contextWindow);
187
- const line =
188
- budget?.enabled === true && budget.supported ? budget.rolloverAt : usage.contextWindow;
207
+ const line = budget?.state === "enabled" ? budget.rolloverAt : usage.contextWindow;
189
208
  return line - usage.tokens - guidanceTokens(ctx);
190
209
  }
@@ -12,10 +12,7 @@ import {
12
12
  } from "./contracts.ts";
13
13
 
14
14
  function rolloverStatus(budget: Budget | undefined): "disabled" | "enabled" | "unsupported" {
15
- if (budget?.enabled !== true) {
16
- return "disabled";
17
- }
18
- return budget.supported ? "enabled" : "unsupported";
15
+ return budget?.state ?? "disabled";
19
16
  }
20
17
  function contextReport(host: PolicyContext, newContextAvailable: boolean): TextResult {
21
18
  const usage = host.getContextUsage();
@@ -34,14 +31,14 @@ function contextReport(host: PolicyContext, newContextAvailable: boolean): TextR
34
31
  rolloverAt: budget?.rolloverAt,
35
32
  };
36
33
  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.`;
37
- if (budget?.enabled !== true) {
34
+ if (budget === undefined || budget.state === "disabled") {
38
35
  return textResult(
39
36
  `Automatic rollover is disabled in the available settings (Pi compaction.enabled=false). ${configured}`,
40
37
  [],
41
38
  display,
42
39
  );
43
40
  }
44
- if (!budget.supported) {
41
+ if (budget.state === "unsupported") {
45
42
  return textResult(
46
43
  `${unsupportedMessage(budget, newContextAvailable)} ${configured}`,
47
44
  [],
package/src/contracts.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { PosthorseDisplay } from "../ui.ts";
2
+ import type { PosthorseDisplay } from "./ui/display.ts";
3
3
 
4
4
  export { isRecord } from "./records.ts";
5
5
 
@@ -40,6 +40,8 @@ export type ToolCallLike = {
40
40
  };
41
41
  export type MessageLike = {
42
42
  readonly role?: string | undefined;
43
+ readonly timestamp?: number | undefined;
44
+ readonly sections?: unknown;
43
45
  readonly stopReason?: string | undefined;
44
46
  readonly errorMessage?: string | undefined;
45
47
  readonly content?: unknown;
@@ -112,12 +114,58 @@ export const REMINDER_TYPE = "posthorse-reminder";
112
114
  export const AUTO_HANDOFF_PREFIX = "Automatic context rollover recovery record.";
113
115
  export const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before continuing.";
114
116
 
117
+ /**
118
+ * Journal wording is a storage format: recovery reads sessions back, across releases, by
119
+ * matching this exact text. Producers and parsers share one vocabulary, so a wording change
120
+ * is a migration and must keep reading the previous release's journals.
121
+ */
122
+ export const BOUNDARY_TYPE = "posthorse-boundary";
123
+ export const RESET_DEFERRED_TYPE = "posthorse-reset-deferred";
124
+ export const RETAINED_TAIL_TYPE = "posthorse-retained-tail";
125
+ export const RETAINED_MARKER_PREFIX = "Context rolled over after entry ";
126
+ /** Display marker the legacy fork's context-window extension projects into messages. */
127
+ export const LEGACY_WINDOW_TYPE = "context-window";
128
+ /** The details marker every compaction Posthorse drafts carries; readers reject other values. */
129
+ export const POSTHORSE_COMPACTION = 1;
130
+
131
+ export function posthorseDetails(reason: string): {
132
+ readonly posthorse: typeof POSTHORSE_COMPACTION;
133
+ readonly reason: string;
134
+ } {
135
+ return { posthorse: POSTHORSE_COMPACTION, reason };
136
+ }
137
+
138
+ /** Receipt lines the notes tool returns; recovery matches them to record note touches. */
139
+ export const NOTE_RECEIPT = {
140
+ wrote: "Wrote ",
141
+ appended: "Appended to ",
142
+ file: "File: ",
143
+ directory: "Notes directory: ",
144
+ } as const;
145
+
146
+ export function noteWriteReceipt(path: string): string {
147
+ return `${NOTE_RECEIPT.wrote}${path}`;
148
+ }
149
+ export function noteAppendReceipt(path: string): string {
150
+ return `${NOTE_RECEIPT.appended}${path}`;
151
+ }
152
+ export function noteReadHeader(path: string): string {
153
+ return `${NOTE_RECEIPT.file}${path}\n`;
154
+ }
155
+ export function notesDirectoryHeader(directory: string): string {
156
+ return `${NOTE_RECEIPT.directory}${directory}\n`;
157
+ }
158
+
115
159
  export function unknownArray(value: unknown): readonly unknown[] | undefined {
116
160
  return Array.isArray(value) ? value : undefined;
117
161
  }
118
162
  export function optionalString(value: unknown): string | undefined {
119
163
  return typeof value === "string" ? value : undefined;
120
164
  }
165
+ /** Some models send every optional argument; an empty string means the argument was omitted. */
166
+ export function presentText(value: unknown): string | undefined {
167
+ return typeof value === "string" && value !== "" ? value : undefined;
168
+ }
121
169
  export function requireValue(value: string | undefined, name: string, op: string): string {
122
170
  if (value === undefined || value === "") {
123
171
  throw new Error(`"${name}" is required for op "${op}".`);
@@ -0,0 +1,50 @@
1
+ import { sessionIdentity, type SessionHost, type SessionIdentity } from "./host.ts";
2
+
3
+ /** A cached value with the session state it was computed from. */
4
+ export type EpochValue<Value> = {
5
+ readonly identity: SessionIdentity;
6
+ readonly value: Value;
7
+ };
8
+ export type EpochCache<Value> = {
9
+ readonly read: (ctx: SessionContext) => Value;
10
+ readonly reset: () => void;
11
+ };
12
+ export type SessionContext = { readonly sessionManager: SessionHost };
13
+
14
+ function sameSession(left: SessionIdentity, right: SessionIdentity): boolean {
15
+ return left.sessionId === right.sessionId && left.file === right.file;
16
+ }
17
+ /**
18
+ * Caches one session fact for the current leaf. A new leaf recomputes unless its walk reaches the
19
+ * cached leaf entry itself: entry identity, not a reusable id, certifies an append-only extension.
20
+ */
21
+ export function createEpochCache<Value>(
22
+ compute: (
23
+ ctx: SessionContext,
24
+ identity: SessionIdentity,
25
+ previous: EpochValue<Value> | undefined,
26
+ ) => Value,
27
+ ): EpochCache<Value> {
28
+ let cached: (EpochValue<Value> & { readonly manager: object }) | undefined;
29
+ return {
30
+ reset: () => {
31
+ cached = undefined;
32
+ },
33
+ read: (ctx) => {
34
+ const manager: object = ctx.sessionManager;
35
+ const identity = sessionIdentity(ctx.sessionManager);
36
+ const previous =
37
+ cached !== undefined &&
38
+ cached.manager === manager &&
39
+ sameSession(cached.identity, identity)
40
+ ? cached
41
+ : undefined;
42
+ if (previous !== undefined && previous.identity.leaf === identity.leaf) {
43
+ return previous.value;
44
+ }
45
+ const value = compute(ctx, identity, previous);
46
+ cached = { manager, identity, value };
47
+ return value;
48
+ },
49
+ };
50
+ }
package/src/files.ts CHANGED
@@ -86,6 +86,12 @@ async function existingNote(target: string) {
86
86
  }
87
87
  return previous;
88
88
  }
89
+ /** Reserved by publishFile; inventories hide these names without deleting their contents. */
90
+ export function isPublicationTemporaryName(name: string): boolean {
91
+ return /^\.posthorse-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/.test(
92
+ name,
93
+ );
94
+ }
89
95
  /** Same-directory replacement; pre-rename failures preserve the old note and its metadata. */
90
96
  export async function publishFile(
91
97
  path: string,
package/src/guidance.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type, type Static } from "typebox";
3
3
  import { Check } from "typebox/value";
4
4
  import {
@@ -7,7 +7,16 @@ import {
7
7
  buildGuidance,
8
8
  type GuidanceTools,
9
9
  } from "./budget.ts";
10
- import { isRecord, type EntryLike, type PolicyContext, type SnapshotReader } from "./contracts.ts";
10
+ import type { EntryLike, PolicyContext, SnapshotReader } from "./contracts.ts";
11
+ import { startsFreshGuidanceEpoch } from "./boundary.ts";
12
+ import { createEpochCache, type EpochValue, type SessionContext } from "./epoch-cache.ts";
13
+ import {
14
+ sessionEntry,
15
+ toolFacts,
16
+ type SessionHost,
17
+ type SessionIdentity,
18
+ type ToolRegistry,
19
+ } from "./host.ts";
11
20
 
12
21
  export const WINDOW_GUIDANCE_TYPE = "posthorse-window-guidance";
13
22
  const guidanceRecordSchema = Type.Object({
@@ -24,74 +33,25 @@ type MemoryEpoch = {
24
33
  readonly byBudget: Map<string, string>;
25
34
  tools: GuidanceTools | undefined;
26
35
  };
27
- type EpochState = {
28
- readonly manager: ExtensionContext["sessionManager"];
29
- readonly sessionId: string;
30
- readonly file: string | undefined;
31
- readonly leaf: EntryLike | undefined;
36
+ type GuidanceEpoch = {
32
37
  readonly anchorKey: string;
33
38
  readonly byBudget: ReadonlyMap<string, string>;
34
39
  readonly tools: GuidanceTools | undefined;
35
40
  };
36
41
  export type WindowGuidance = {
37
- readonly resolve: (ctx: ExtensionContext, host: PolicyContext) => string;
38
- readonly deliver: (ctx: ExtensionContext, host: PolicyContext) => string;
42
+ /** Only the session manager is read; the same context the epoch cache accepts. */
43
+ readonly resolve: (ctx: SessionContext, host: PolicyContext) => string;
44
+ readonly deliver: (ctx: SessionContext, host: PolicyContext) => string;
39
45
  readonly reset: () => void;
40
46
  };
41
47
 
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
- );
48
+ export function activeGuidanceTools(pi: ToolRegistry): GuidanceTools {
49
+ const { activeUnnamespaced } = toolFacts(pi);
50
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"),
51
+ newContext: activeUnnamespaced.has("new_context"),
52
+ getContextRemaining: activeUnnamespaced.has("get_context_remaining"),
54
53
  };
55
54
  }
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
55
  function collectGuidanceEntries(
96
56
  entries: readonly EntryLike[],
97
57
  baseByBudget?: ReadonlyMap<string, string>,
@@ -130,27 +90,27 @@ function withMemoryEpoch(
130
90
  return { byBudget: merged, tools: collected.tools ?? memory.tools };
131
91
  }
132
92
  function scanGuidanceEpoch(
133
- manager: ExtensionContext["sessionManager"],
134
- identity: { readonly sessionId: string; readonly leaf: EntryLike | undefined },
135
- previous: EpochState | undefined,
93
+ manager: SessionHost,
94
+ identity: SessionIdentity,
95
+ previous: EpochValue<GuidanceEpoch> | undefined,
136
96
  memoryByEpoch: ReadonlyMap<string, MemoryEpoch> | undefined,
137
- ): Pick<EpochState, "anchorKey" | "byBudget" | "tools"> {
97
+ ): GuidanceEpoch {
138
98
  const reversed: EntryLike[] = [];
139
99
  let anchor: EntryLike | undefined;
140
100
  for (
141
101
  let entry = identity.leaf;
142
102
  entry !== undefined;
143
- entry = parentEntry(manager, entry.parentId)
103
+ entry = sessionEntry(manager, entry.parentId)
144
104
  ) {
145
- if (previous !== undefined && previous.leaf === entry) {
105
+ if (previous !== undefined && previous.identity.leaf === entry) {
146
106
  const collected = collectGuidanceEntries(
147
107
  reversed.reverse(),
148
- previous.byBudget,
149
- previous.tools,
108
+ previous.value.byBudget,
109
+ previous.value.tools,
150
110
  );
151
111
  return {
152
- anchorKey: previous.anchorKey,
153
- ...withMemoryEpoch(collected, memoryByEpoch?.get(previous.anchorKey)),
112
+ anchorKey: previous.value.anchorKey,
113
+ ...withMemoryEpoch(collected, memoryByEpoch?.get(previous.value.anchorKey)),
154
114
  };
155
115
  }
156
116
  if (startsFreshGuidanceEpoch(entry)) {
@@ -169,43 +129,55 @@ function scanGuidanceEpoch(
169
129
  ...withMemoryEpoch(collected, memoryByEpoch?.get(anchorKey)),
170
130
  };
171
131
  }
172
- export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
173
- let state: EpochState | undefined;
132
+ /** The journal and tool-registry members guidance borrows; the host's full API satisfies these. */
133
+ export type GuidanceRegistry = ToolRegistry & Pick<ExtensionAPI, "appendEntry">;
134
+
135
+ export function createWindowGuidance(pi: GuidanceRegistry): WindowGuidance {
174
136
  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;
137
+ const epochs = createEpochCache<GuidanceEpoch>((ctx, identity, previous) =>
138
+ scanGuidanceEpoch(
139
+ ctx.sessionManager,
140
+ identity,
141
+ previous,
142
+ memoryByManager.get(ctx.sessionManager),
143
+ ),
144
+ );
145
+ /**
146
+ * A fresh anchor retires every earlier one on this branch: nothing reads an old epoch again
147
+ * without /tree, which resets all memory. Keep only the current anchor's delivered text.
148
+ */
149
+ const pruneRetiredEpochs = (manager: object, anchorKey: string): void => {
150
+ const byEpoch = memoryByManager.get(manager);
151
+ if (byEpoch === undefined) {
152
+ return;
153
+ }
154
+ for (const key of byEpoch.keys()) {
155
+ if (key !== anchorKey) {
156
+ byEpoch.delete(key);
157
+ }
184
158
  }
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
159
  };
199
160
  return {
200
161
  resolve(ctx, host) {
201
- const epoch = readEpoch(ctx);
162
+ const epoch = epochs.read(ctx);
202
163
  const key = budgetGuidanceKey(host);
203
164
  return (
204
165
  epoch.byBudget.get(key) ?? buildGuidance(host, epoch.tools ?? ALL_GUIDANCE_TOOLS)
205
166
  );
206
167
  },
168
+ /**
169
+ * Delivering guidance from inside the request hook must persist before the request: a
170
+ * restart after a rollover with a provider summary block has to reuse these exact bytes —
171
+ * the leading system message is sent unchanged, and in-memory delivery state does not
172
+ * survive. On the fork, the provider-summary request runs this hook mid-compaction. That
173
+ * is safe twice over: the request that reached the rollover threshold already delivered
174
+ * this budget's text, so the summary request reuses it and writes nothing; and a
175
+ * compaction with a summary block keeps the previous guidance anchor, so even a budget
176
+ * change mid-compaction appends inside the epoch the next window still reads.
177
+ */
207
178
  deliver(ctx, host) {
208
- const epoch = readEpoch(ctx);
179
+ const epoch = epochs.read(ctx);
180
+ pruneRetiredEpochs(ctx.sessionManager, epoch.anchorKey);
209
181
  const key = budgetGuidanceKey(host);
210
182
  const existing = epoch.byBudget.get(key);
211
183
  if (existing !== undefined) {
@@ -225,7 +197,7 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
225
197
  }
226
198
  memory.byBudget.set(key, text);
227
199
  memory.tools ??= tools;
228
- state = undefined;
200
+ epochs.reset();
229
201
  pi.appendEntry(WINDOW_GUIDANCE_TYPE, {
230
202
  version: 1,
231
203
  budgetKey: key,
@@ -235,13 +207,13 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
235
207
  return text;
236
208
  },
237
209
  reset() {
238
- state = undefined;
210
+ epochs.reset();
239
211
  memoryByManager = new WeakMap();
240
212
  },
241
213
  };
242
214
  }
243
215
  export function registerGuidance(
244
- pi: ExtensionAPI,
216
+ pi: Pick<ExtensionAPI, "on">,
245
217
  policy: SnapshotReader,
246
218
  windowGuidance: WindowGuidance,
247
219
  ): void {
@@ -249,6 +221,7 @@ export function registerGuidance(
249
221
  pi.on("before_agent_start", (event) => {
250
222
  promptOptions = event.systemPromptOptions;
251
223
  });
224
+
252
225
  pi.on("agent_settled", () => {
253
226
  // Prompt options belong to one run, never to later idle context evaluation.
254
227
  promptOptions = undefined;