pi-posthorse 0.10.24 → 0.10.25

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.25",
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,95 @@
1
+ import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
2
+
3
+ export type BoundaryKind = "fork" | "native" | "automatic" | "explicit" | "posthorse";
4
+ /** Everything the app derives from one persisted context boundary, decided in one place. */
5
+ export type Boundary = {
6
+ readonly kind: BoundaryKind;
7
+ /** Counts as a Posthorse window: labels, reminder windows, and note provenance. */
8
+ readonly window: boolean;
9
+ /** An automatic recovery record that walks through earlier windows continue past. */
10
+ readonly chainsAncestry: boolean;
11
+ /** Starts a new guidance epoch; a provider summary block keeps the previous one. */
12
+ readonly freshGuidance: boolean;
13
+ };
14
+
15
+ export function windowHandoff(entry: EntryLike | undefined): string | undefined {
16
+ return entry?.type === "compaction" ? entry.summary : entry?.handoff;
17
+ }
18
+ /** An automatic recovery record opens with this prefix; an earlier window's walk may continue past it. */
19
+ function automaticHandoff(entry: EntryLike): boolean {
20
+ return windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) === true;
21
+ }
22
+ /**
23
+ * A compaction with a provider summary block carries forward the previous window's leading system
24
+ * message unchanged, so its guidance section must stay locked to that earlier epoch too.
25
+ */
26
+ function providerSummaryBlock(entry: EntryLike): boolean {
27
+ return (
28
+ entry.type === "compaction" &&
29
+ isRecord(entry.providerSummary) &&
30
+ isRecord(entry.providerSummary.block)
31
+ );
32
+ }
33
+ function compactionKind(details: Readonly<Record<string, unknown>>): BoundaryKind {
34
+ if (details.reason === "threshold" || details.reason === "overflow") {
35
+ return "automatic";
36
+ }
37
+ return details.reason === "explicit" ? "explicit" : "posthorse";
38
+ }
39
+ export function classifyBoundary(entry?: EntryLike): Boundary | undefined {
40
+ if (entry?.type === "context_window") {
41
+ return {
42
+ kind: "fork",
43
+ window: true,
44
+ chainsAncestry: automaticHandoff(entry),
45
+ freshGuidance: true,
46
+ };
47
+ }
48
+ if (entry?.type !== "compaction") {
49
+ return undefined;
50
+ }
51
+ const details = isRecord(entry.details) ? entry.details : undefined;
52
+ const freshGuidance = !providerSummaryBlock(entry);
53
+ if (details?.posthorse !== 1) {
54
+ return { kind: "native", window: false, chainsAncestry: false, freshGuidance };
55
+ }
56
+ const kind = compactionKind(details);
57
+ return {
58
+ kind,
59
+ window: true,
60
+ chainsAncestry: kind === "automatic" && automaticHandoff(entry),
61
+ freshGuidance,
62
+ };
63
+ }
64
+ export function isWindow(entry: EntryLike): boolean {
65
+ return classifyBoundary(entry)?.window === true;
66
+ }
67
+ export function startsFreshGuidanceEpoch(entry: EntryLike): boolean {
68
+ return classifyBoundary(entry)?.freshGuidance === true;
69
+ }
70
+ export function chainsAncestry(entry?: EntryLike): boolean {
71
+ return classifyBoundary(entry)?.chainsAncestry === true;
72
+ }
73
+ /** The boundary already applied to a projection: a compaction with a visible tail. -1 when none. */
74
+ export function activeBoundaryIndex(projected: readonly ProjectedEntry[]): number {
75
+ return projected.findIndex(
76
+ (entry) => entry.sourceEntry.type === "compaction" && entry.messages.length > 0,
77
+ );
78
+ }
79
+ /** First active branch entry, including tails retained by Pi's own /compact. */
80
+ export function contextStart(entries: readonly EntryLike[]): {
81
+ start: number;
82
+ boundary?: EntryLike;
83
+ } {
84
+ for (let i = entries.length - 1; i >= 0; i--) {
85
+ const entry = entries[i];
86
+ if (entry?.type === "context_window") {
87
+ return { start: i + 1, boundary: entry };
88
+ }
89
+ if (entry?.type === "compaction") {
90
+ const kept = entries.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
91
+ return { start: kept >= 0 ? kept : i + 1, boundary: entry };
92
+ }
93
+ }
94
+ return { start: 0 };
95
+ }
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
@@ -118,6 +118,10 @@ export function unknownArray(value: unknown): readonly unknown[] | undefined {
118
118
  export function optionalString(value: unknown): string | undefined {
119
119
  return typeof value === "string" ? value : undefined;
120
120
  }
121
+ /** Some models send every optional argument; an empty string means the argument was omitted. */
122
+ export function presentText(value: unknown): string | undefined {
123
+ return typeof value === "string" && value !== "" ? value : undefined;
124
+ }
121
125
  export function requireValue(value: string | undefined, name: string, op: string): string {
122
126
  if (value === undefined || value === "") {
123
127
  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/guidance.ts CHANGED
@@ -7,7 +7,10 @@ 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 } from "./epoch-cache.ts";
13
+ import { sessionEntry, toolFacts, type SessionHost, type SessionIdentity } from "./host.ts";
11
14
 
12
15
  export const WINDOW_GUIDANCE_TYPE = "posthorse-window-guidance";
13
16
  const guidanceRecordSchema = Type.Object({
@@ -24,11 +27,7 @@ type MemoryEpoch = {
24
27
  readonly byBudget: Map<string, string>;
25
28
  tools: GuidanceTools | undefined;
26
29
  };
27
- type EpochState = {
28
- readonly manager: ExtensionContext["sessionManager"];
29
- readonly sessionId: string;
30
- readonly file: string | undefined;
31
- readonly leaf: EntryLike | undefined;
30
+ type GuidanceEpoch = {
32
31
  readonly anchorKey: string;
33
32
  readonly byBudget: ReadonlyMap<string, string>;
34
33
  readonly tools: GuidanceTools | undefined;
@@ -40,58 +39,12 @@ export type WindowGuidance = {
40
39
  };
41
40
 
42
41
  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
- );
42
+ const { activeUnnamespaced } = toolFacts(pi);
50
43
  return {
51
- newContext: active.has("new_context") && unnamespaced.has("new_context"),
52
- getContextRemaining:
53
- active.has("get_context_remaining") && unnamespaced.has("get_context_remaining"),
44
+ newContext: activeUnnamespaced.has("new_context"),
45
+ getContextRemaining: activeUnnamespaced.has("get_context_remaining"),
54
46
  };
55
47
  }
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
48
  function collectGuidanceEntries(
96
49
  entries: readonly EntryLike[],
97
50
  baseByBudget?: ReadonlyMap<string, string>,
@@ -130,27 +83,27 @@ function withMemoryEpoch(
130
83
  return { byBudget: merged, tools: collected.tools ?? memory.tools };
131
84
  }
132
85
  function scanGuidanceEpoch(
133
- manager: ExtensionContext["sessionManager"],
134
- identity: { readonly sessionId: string; readonly leaf: EntryLike | undefined },
135
- previous: EpochState | undefined,
86
+ manager: SessionHost,
87
+ identity: SessionIdentity,
88
+ previous: EpochValue<GuidanceEpoch> | undefined,
136
89
  memoryByEpoch: ReadonlyMap<string, MemoryEpoch> | undefined,
137
- ): Pick<EpochState, "anchorKey" | "byBudget" | "tools"> {
90
+ ): GuidanceEpoch {
138
91
  const reversed: EntryLike[] = [];
139
92
  let anchor: EntryLike | undefined;
140
93
  for (
141
94
  let entry = identity.leaf;
142
95
  entry !== undefined;
143
- entry = parentEntry(manager, entry.parentId)
96
+ entry = sessionEntry(manager, entry.parentId)
144
97
  ) {
145
- if (previous !== undefined && previous.leaf === entry) {
98
+ if (previous !== undefined && previous.identity.leaf === entry) {
146
99
  const collected = collectGuidanceEntries(
147
100
  reversed.reverse(),
148
- previous.byBudget,
149
- previous.tools,
101
+ previous.value.byBudget,
102
+ previous.value.tools,
150
103
  );
151
104
  return {
152
- anchorKey: previous.anchorKey,
153
- ...withMemoryEpoch(collected, memoryByEpoch?.get(previous.anchorKey)),
105
+ anchorKey: previous.value.anchorKey,
106
+ ...withMemoryEpoch(collected, memoryByEpoch?.get(previous.value.anchorKey)),
154
107
  };
155
108
  }
156
109
  if (startsFreshGuidanceEpoch(entry)) {
@@ -170,42 +123,25 @@ function scanGuidanceEpoch(
170
123
  };
171
124
  }
172
125
  export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
173
- let state: EpochState | undefined;
174
126
  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
- };
127
+ const epochs = createEpochCache<GuidanceEpoch>((ctx, identity, previous) =>
128
+ scanGuidanceEpoch(
129
+ ctx.sessionManager,
130
+ identity,
131
+ previous,
132
+ memoryByManager.get(ctx.sessionManager),
133
+ ),
134
+ );
199
135
  return {
200
136
  resolve(ctx, host) {
201
- const epoch = readEpoch(ctx);
137
+ const epoch = epochs.read(ctx);
202
138
  const key = budgetGuidanceKey(host);
203
139
  return (
204
140
  epoch.byBudget.get(key) ?? buildGuidance(host, epoch.tools ?? ALL_GUIDANCE_TOOLS)
205
141
  );
206
142
  },
207
143
  deliver(ctx, host) {
208
- const epoch = readEpoch(ctx);
144
+ const epoch = epochs.read(ctx);
209
145
  const key = budgetGuidanceKey(host);
210
146
  const existing = epoch.byBudget.get(key);
211
147
  if (existing !== undefined) {
@@ -225,7 +161,7 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
225
161
  }
226
162
  memory.byBudget.set(key, text);
227
163
  memory.tools ??= tools;
228
- state = undefined;
164
+ epochs.reset();
229
165
  pi.appendEntry(WINDOW_GUIDANCE_TYPE, {
230
166
  version: 1,
231
167
  budgetKey: key,
@@ -235,7 +171,7 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
235
171
  return text;
236
172
  },
237
173
  reset() {
238
- state = undefined;
174
+ epochs.reset();
239
175
  memoryByManager = new WeakMap();
240
176
  },
241
177
  };
@@ -0,0 +1,43 @@
1
+ import { presentText, unknownArray } from "./contracts.ts";
2
+
3
+ /** Where a search page stopped: the anchor entry, its rank, its offset, and the search it belongs to. */
4
+ export type HistoryCursor = readonly [id: string, priority: 0 | 1, offset: number, key: string];
5
+
6
+ export function encodeCursor(cursor: HistoryCursor): string {
7
+ return Buffer.from(JSON.stringify(cursor)).toString("base64url");
8
+ }
9
+ export function cursorFor(
10
+ key: string,
11
+ hit: { readonly id: string; readonly priority: 0 | 1 },
12
+ offset: number,
13
+ ): string {
14
+ return encodeCursor([hit.id, hit.priority, offset, key]);
15
+ }
16
+ function cursorOffset(value: unknown, storedKey: unknown, key: string): number {
17
+ if (
18
+ typeof value !== "number" ||
19
+ !Number.isSafeInteger(value) ||
20
+ value < 0 ||
21
+ storedKey !== key
22
+ ) {
23
+ throw new Error("Invalid cursor offset or scope.");
24
+ }
25
+ return value;
26
+ }
27
+ /** A cursor is anonymous unless it decodes to the same query and scope that produced it. */
28
+ export function decodeCursor(encoded: string | undefined, key: string): HistoryCursor | undefined {
29
+ const cursor = presentText(encoded);
30
+ if (cursor === undefined) {
31
+ return undefined;
32
+ }
33
+ const value: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString());
34
+ const parts = unknownArray(value);
35
+ if (parts === undefined || parts.length !== 4) {
36
+ throw new Error("Invalid cursor tuple.");
37
+ }
38
+ const [id, priority, offset, storedKey] = parts;
39
+ if (typeof id !== "string" || id === "" || (priority !== 0 && priority !== 1)) {
40
+ throw new Error("Invalid cursor anchor.");
41
+ }
42
+ return [id, priority, cursorOffset(offset, storedKey, key), key];
43
+ }
@@ -1,5 +1,4 @@
1
1
  import {
2
- isRecord,
3
2
  isReminderType,
4
3
  unknownArray,
5
4
  type EntryLike,
@@ -7,6 +6,7 @@ import {
7
6
  type MessageLike,
8
7
  type WindowedEntry,
9
8
  } from "./contracts.ts";
9
+ import { isWindow } from "./boundary.ts";
10
10
  import {
11
11
  assistantFailure,
12
12
  excerptAround,
@@ -20,32 +20,6 @@ import {
20
20
  toolIdentity,
21
21
  } from "./message-text.ts";
22
22
 
23
- export function isWindow(entry: EntryLike): boolean {
24
- return (
25
- entry.type === "context_window" ||
26
- (entry.type === "compaction" && isRecord(entry.details) && entry.details.posthorse === 1)
27
- );
28
- }
29
- export function windowHandoff(entry: EntryLike | undefined): string | undefined {
30
- return entry?.type === "compaction" ? entry.summary : entry?.handoff;
31
- }
32
- /** First active branch entry, including tails retained by Pi's own /compact. */
33
- export function contextStart(entries: readonly EntryLike[]): {
34
- start: number;
35
- boundary?: EntryLike;
36
- } {
37
- for (let i = entries.length - 1; i >= 0; i--) {
38
- const entry = entries[i];
39
- if (entry?.type === "context_window") {
40
- return { start: i + 1, boundary: entry };
41
- }
42
- if (entry?.type === "compaction") {
43
- const kept = entries.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
44
- return { start: kept >= 0 ? kept : i + 1, boundary: entry };
45
- }
46
- }
47
- return { start: 0 };
48
- }
49
23
  export function contextEdits(entries: readonly EntryLike[]): Map<string, EntryLike> {
50
24
  return new Map(
51
25
  entries.flatMap((entry) =>
@@ -7,6 +7,7 @@ import {
7
7
  type WindowedEntry,
8
8
  } from "./contracts.ts";
9
9
  import { excerpt, shortKey, sourceTag } from "./message-text.ts";
10
+ import { cursorFor as cursorAt } from "./history-cursor.ts";
10
11
  import type { HistoryMatches, HistoryParams } from "./history-search.ts";
11
12
  import type { PageAccess } from "./paging.ts";
12
13
 
@@ -18,11 +19,6 @@ type SearchPage = {
18
19
  readonly spans: readonly { readonly headerLength: number; readonly length: number }[];
19
20
  readonly continuation: Continuation<string>;
20
21
  };
21
- function cursorFor(matches: HistoryMatches, hit: HistoryHit, offset: number): string {
22
- return Buffer.from(JSON.stringify([hit.id, hit.priority, offset, matches.key])).toString(
23
- "base64url",
24
- );
25
- }
26
22
  function searchFooter(next: string): string {
27
23
  return `\n[More results; continue with cursor "${next}" and the same query/scope.]`;
28
24
  }
@@ -70,7 +66,7 @@ function searchParts(matches: HistoryMatches, chars: number): SearchPage {
70
66
  available -= fragment.part.length + 1;
71
67
  continuation =
72
68
  fragment.end < hit.text.length || parts.length < matches.hits.length
73
- ? { kind: "more", next: cursorFor(matches, hit, fragment.end) }
69
+ ? { kind: "more", next: cursorAt(matches.key, hit, fragment.end) }
74
70
  : { kind: "complete" };
75
71
  if (available <= 0 || parts.length >= matches.limit) {
76
72
  break;
@@ -87,7 +83,9 @@ export function searchPage(
87
83
  const chars = pages.size(host);
88
84
  const reserve = Math.max(
89
85
  0,
90
- ...matches.hits.map((hit) => searchFooter(cursorFor(matches, hit, hit.text.length)).length),
86
+ ...matches.hits.map(
87
+ (hit) => searchFooter(cursorAt(matches.key, hit, hit.text.length)).length,
88
+ ),
91
89
  );
92
90
  if (reserve >= chars) {
93
91
  pages.error(host, "History entry id is too large for pagination.");