@astrosheep/pi-context 0.25.0 → 0.25.2

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.
@@ -0,0 +1,68 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadNotesSnapshot, type NotesSnapshot } from "../notes/notes-snapshot.js";
3
+ import { agentSlug, modelSlug } from "../notes/paths.js";
4
+ import { BOOT_TYPE } from "../protocol.js";
5
+ import { renderBootBlock } from "./prompts.js";
6
+ import { currentReset, isWindowBoot, rootWindowId } from "./context-window.js";
7
+ import { repairResetTail } from "./reset-artifacts.js";
8
+
9
+ export type IncompleteNotesNotifier = (ctx: ExtensionContext, windowId: string, snapshot: NotesSnapshot) => void;
10
+
11
+ /** The boot custom message: identity, notes snapshot, and static protocol; never reset prose. */
12
+ export type BootMessage = {
13
+ readonly customType: string;
14
+ readonly content: string;
15
+ readonly display: false;
16
+ readonly details: { windowId: string };
17
+ };
18
+
19
+ /** Render the boot block from the live context; acquisition stays with loadNotesSnapshot. */
20
+ function bootContent(ctx: ExtensionContext, currentId: string, previousId: string | undefined, notes: NotesSnapshot): string {
21
+ return renderBootBlock({
22
+ agentName: agentSlug(ctx),
23
+ modelName: modelSlug(ctx),
24
+ firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
25
+ currentWindowId: currentId,
26
+ previousWindowId: previousId,
27
+ notes,
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Acquire one notes snapshot and build the boot custom message for a window. The caller
33
+ * supplies `previousId` only when a reset boundary needs the prior window identity.
34
+ */
35
+ export function buildBootMessage(
36
+ ctx: ExtensionContext,
37
+ windowId: string,
38
+ previousId: string | undefined,
39
+ notifyIncompleteNotes?: IncompleteNotesNotifier,
40
+ ): BootMessage {
41
+ const notes = loadNotesSnapshot(ctx);
42
+ notifyIncompleteNotes?.(ctx, windowId, notes);
43
+ return { customType: BOOT_TYPE, content: bootContent(ctx, windowId, previousId, notes), display: false, details: { windowId } };
44
+ }
45
+
46
+ /** Persist one hidden boot message without triggering a model turn. */
47
+ export function sendBoot(pi: ExtensionAPI, boot: BootMessage): void {
48
+ pi.sendMessage(
49
+ { customType: boot.customType, content: boot.content, display: boot.display, details: boot.details },
50
+ { triggerTurn: false },
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Boot entry point for `session_start` / `session_tree`. Ordinary startup ensures one root
56
+ * boot; a reset marker instead asks reset-artifact repair to complete its persisted tail.
57
+ * Boot idempotence lives here: an already-projected root boot or a complete reset tail emits nothing.
58
+ */
59
+ export function ensureBoot(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): void {
60
+ const reset = currentReset(ctx);
61
+ if (reset) {
62
+ repairResetTail(pi, ctx, reset, notifyIncompleteNotes);
63
+ return;
64
+ }
65
+ const windowId = rootWindowId(ctx.sessionManager.getSessionId());
66
+ if (ctx.sessionManager.buildSessionProjection().messages.some((message) => isWindowBoot(message, windowId))) return;
67
+ sendBoot(pi, buildBootMessage(ctx, windowId, undefined, notifyIncompleteNotes));
68
+ }
@@ -38,16 +38,17 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
38
38
  return usage !== undefined && usage.tokens !== null && usage.contextWindow - usage.tokens <= thresholdsFor(ctx).reserve;
39
39
  };
40
40
  const invalidateThresholds = () => { cachedPolicy = undefined; };
41
- let pendingGuidance: { windowId: string; content: string } | undefined;
42
- let pendingWarning: { windowId: string; content: string } | undefined;
43
- let pendingNotices: Array<{ windowId: string; customType: string }> = [];
41
+ const formatRemaining = (remaining: number): string => `${Math.max(0, Math.ceil(remaining / 1000))}k`;
42
+ let pendingGuidance: { windowId: string; content: string; remaining: number } | undefined;
43
+ let pendingWarning: { windowId: string; content: string; remaining: number } | undefined;
44
+ let pendingNotices: Array<{ windowId: string; customType: string; remaining: number }> = [];
44
45
  const notifyCommittedReminders = (ctx: ExtensionContext) => {
45
46
  const windowId = currentWindowId(ctx);
46
47
  for (const notice of pendingNotices) {
47
48
  if (notice.windowId !== windowId || !hasWindowMessage(ctx, notice.customType)) continue;
48
49
  ctx.ui.notify(notice.customType === WARNING_TYPE
49
- ? "pi-context: context budget criticalfinal checkpoint warning recorded for the model."
50
- : "pi-context: context budget low — checkpoint reminder recorded for the model, kept out of the chat view.", "warning");
50
+ ? `pi-context: Context almost full${formatRemaining(notice.remaining)} remaining`
51
+ : `pi-context: Context running low — ${formatRemaining(notice.remaining)} remaining`, "warning");
51
52
  }
52
53
  pendingNotices = [];
53
54
  };
@@ -71,7 +72,7 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
71
72
  clearStaged();
72
73
  const windowId = currentWindowId(ctx);
73
74
  const drafts = staged.filter((draft): draft is NonNullable<typeof draft> => draft !== undefined && draft.windowId === windowId);
74
- pendingNotices = drafts.map(({ windowId, customType }) => ({ windowId, customType }));
75
+ pendingNotices = drafts.map(({ windowId, customType, remaining }) => ({ windowId, customType, remaining }));
75
76
  return drafts.map((draft) => ({
76
77
  type: "custom_message" as const,
77
78
  customType: draft.customType,
@@ -88,7 +89,6 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
88
89
  // lifecycle point that must discard an uncommitted draft before the next prompt.
89
90
  // UI notices follow committed reminders. Aborted requests can retry their drafts
90
91
  // without showing the same low-budget notification twice.
91
- pi.on("turn_start", (_event, ctx) => notifyCommittedReminders(ctx));
92
92
  pi.on("agent_settled", (_event, ctx) => {
93
93
  notifyCommittedReminders(ctx);
94
94
  clearStaged();
@@ -106,7 +106,7 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
106
106
  // A not-yet-committed shallow reminder is superseded by the final warning.
107
107
  pendingGuidance = undefined;
108
108
  const content = `${GUIDANCE_OPEN_TAG}\n${WARNING_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
109
- pendingWarning = { windowId, content };
109
+ pendingWarning = { windowId, content, remaining };
110
110
  const warningMessage = {
111
111
  role: "custom" as const,
112
112
  customType: WARNING_TYPE,
@@ -121,7 +121,7 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
121
121
  // Persist at turn_end, before any reset drafts. A queued sendMessage could
122
122
  // otherwise cross the marker and leak the old window's reminder forward.
123
123
  const left = Math.max(0, remaining - warning);
124
- pendingGuidance = { windowId, content: tokenBudgetGuidance(left) };
124
+ pendingGuidance = { windowId, content: tokenBudgetGuidance(left), remaining };
125
125
  }
126
126
  return undefined;
127
127
  });
@@ -45,6 +45,16 @@ export function currentWindowId(ctx: SessionReader): string {
45
45
  return currentReset(ctx)?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
46
46
  }
47
47
 
48
+ /** The durable window active just before the marker: the last earlier marker, else the root window. */
49
+ export function previousWindowId(ctx: SessionReader, markerId: string): string {
50
+ let previousId = rootWindowId(ctx.sessionManager.getSessionId());
51
+ for (const entry of ctx.sessionManager.getBranch()) {
52
+ if (entry.id === markerId) break;
53
+ if (isWindowMarker(entry)) previousId = entry.data.windowId;
54
+ }
55
+ return previousId;
56
+ }
57
+
48
58
  function hasWindowId(details: unknown, windowId: string): boolean {
49
59
  return typeof details === "object" && details !== null &&
50
60
  typeof (details as { windowId?: unknown }).windowId === "string" &&
@@ -56,6 +66,11 @@ export function isWindowBoot(message: AgentMessage, windowId?: string): boolean
56
66
  return message.role === "custom" && message.customType === BOOT_TYPE && (windowId === undefined || hasWindowId(message.details, windowId));
57
67
  }
58
68
 
69
+ /** Match a persisted boot entry by raw identity, even when a later edit hides it from projection. */
70
+ export function isWindowBootEntry(entry: SessionEntry, windowId: string): boolean {
71
+ return entry.type === "custom_message" && entry.customType === BOOT_TYPE && hasWindowId(entry.details, windowId);
72
+ }
73
+
59
74
  /**
60
75
  * The durable marker selects a boot message by identity, never by wall-clock time.
61
76
  * The boot is the first conversation message of the window. Folding only its prefix
@@ -1,5 +1,5 @@
1
1
  import type { NotesHome, NotesSnapshot } from "../notes/notes-snapshot.js";
2
- import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_AGENT_LIMIT, POCKET_HUMAN_LIMIT, POCKET_MODEL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "../protocol.js";
2
+ import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_AGENT_LIMIT, POCKET_HUMAN_LIMIT, POCKET_MODEL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "../protocol.js";
3
3
 
4
4
  /** Codex-style <context_window> identity block: the resolved agent and model names plus first/current/previous window ids. */
5
5
  function identityBlock(agentName: string, modelName: string, firstWindowId: string, currentWindowId: string, previousWindowId?: string): string {
@@ -27,8 +27,7 @@ function rowsFor(snapshot: NotesSnapshot, scope: NotesHome["scope"]) {
27
27
  function notesUnavailableNotice(snapshot: NotesSnapshot): string | undefined {
28
28
  if (snapshot.unavailable.length === 0) return undefined;
29
29
  const homes = snapshot.unavailable.map((home) => home.label).join(", ");
30
- const noun = snapshot.unavailable.length === 1 ? "home's index was" : "home indexes were";
31
- return `Notes index incomplete: ${homes} ${noun} unavailable during boot; notes_list can retry after recovery.`;
30
+ return `Notes index incomplete: index for ${homes} unavailable during boot; notes_list can retry after recovery.`;
32
31
  }
33
32
 
34
33
  /**
@@ -61,7 +60,7 @@ function notesIndex(snapshot: NotesSnapshot): string {
61
60
  ...rowsFor(snapshot, "model").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_MODEL_LIMIT),
62
61
  ];
63
62
  if (recentNotes.length > 0) {
64
- const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by home, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from this project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from your @self home, ${POCKET_MODEL_LIMIT} from the current @model home). A note's content never appears here, so its name has to say what the note is about:`];
63
+ const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
65
64
  for (const row of recentNotes) {
66
65
  lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, snapshot.openedAt)})`);
67
66
  }
@@ -71,7 +70,7 @@ function notesIndex(snapshot: NotesSnapshot): string {
71
70
  }
72
71
 
73
72
  function notesHomeBlock(): string {
74
- return "Notes_* addresses have five homes: bare <vpath> is this session, @project/<vpath> is this project, @human/<vpath> is the human's cross-project home, @self/<vpath> and @agents/<name>/<vpath> are agent homes (current vs named), and @model/<vpath> and @models/<name>/<vpath> are model homes. @self and @model resolve to who is running now; listings always show resolved names. @ means leaving home; there is no cross-home fallback. Anything else after @ or @ inside a vpath is a hard error. Any other note is a plain file — use the file tools.";
73
+ return "Note addresses: bare <vpath> is this session; @project/<vpath> is this project; @human/<vpath> is the human's cross-project notes; @self/<vpath> is your own (current agent); @model/<vpath> is the current model's. @self and @model resolve to who is running now. Any other @ prefix, or @ inside a vpath, is a hard error; there is no fallback across prefixes. Anything not matching these is a plain file — use the file tools.";
75
74
  }
76
75
 
77
76
  /**
@@ -84,13 +83,11 @@ export type BootRenderData = {
84
83
  readonly firstWindowId: string;
85
84
  readonly currentWindowId: string;
86
85
  readonly previousWindowId?: string;
87
- readonly resetLine: boolean;
88
86
  readonly notes: NotesSnapshot;
89
87
  };
90
88
 
91
89
  export function renderBootBlock(data: BootRenderData): string {
92
90
  const parts: string[] = [];
93
- if (data.resetLine) parts.push(RESET_SUMMARY);
94
91
  parts.push(identityBlock(data.agentName, data.modelName, data.firstWindowId, data.currentWindowId, data.previousWindowId));
95
92
  parts.push(notesHomeBlock());
96
93
  const incomplete = notesUnavailableNotice(data.notes);
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext, SessionBoundaryDraft, SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import { BOOT_TYPE, CONTINUATION, CONTINUATION_TYPE, RESET_MARKER_TYPE } from "../protocol.js";
4
+ import { currentWindowId, isWindowBootEntry, isWindowMarker, previousWindowId, type WindowMarker } from "./context-window.js";
5
+ import { buildBootMessage, sendBoot, type IncompleteNotesNotifier } from "./boot.js";
6
+
7
+ export type ResetTailState = { readonly boot: boolean; readonly continuation: boolean };
8
+
9
+ /** Match the hidden continuation entry that carries the one reset message. */
10
+ export function isWindowContinuationEntry(entry: SessionEntry): boolean {
11
+ return entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE;
12
+ }
13
+
14
+ /** The single continuation sender: the only reset prose persisted for a window. */
15
+ export function sendContinuation(pi: ExtensionAPI): void {
16
+ pi.sendMessage(
17
+ { customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
18
+ { triggerTurn: false },
19
+ );
20
+ }
21
+
22
+ /**
23
+ * The closed, ordered reset shape: marker, matching boot, continuation. This is the one
24
+ * source of the reset message and the one place that mints the new window identity.
25
+ */
26
+ export function buildResetDrafts(ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier) {
27
+ const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
28
+ const usedWindowIds = new Set(
29
+ ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId),
30
+ );
31
+ let windowId: string;
32
+ do {
33
+ windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
34
+ } while (usedWindowIds.has(windowId));
35
+ const boot = buildBootMessage(ctx, windowId, currentWindowId(ctx), notifyIncompleteNotes);
36
+ return [
37
+ { type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
38
+ { type: "custom_message", customType: BOOT_TYPE, content: boot.content, display: false, details: { windowId } },
39
+ { type: "custom_message", customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
40
+ ] satisfies [SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft];
41
+ }
42
+
43
+ /** Persist the marker and send both hidden reset messages; returns the new window id. */
44
+ export function persistManualReset(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): string {
45
+ const [marker, boot, continuation] = buildResetDrafts(ctx, notifyIncompleteNotes);
46
+ pi.appendEntry(marker.customType, marker.data);
47
+ pi.sendMessage(
48
+ { customType: boot.customType, content: boot.content, display: boot.display, details: boot.details },
49
+ { triggerTurn: false },
50
+ );
51
+ pi.sendMessage(
52
+ { customType: continuation.customType, content: continuation.content, display: continuation.display },
53
+ { triggerTurn: false },
54
+ );
55
+ return boot.details.windowId;
56
+ }
57
+
58
+ /**
59
+ * Inspect the persisted tail of a reset marker. It reports which reset messages are present
60
+ * only while the tail stays repairable: metadata may follow the marker, but real conversation,
61
+ * a foreign message, a later marker, or a misordered/duplicate reset artifact refuses repair.
62
+ */
63
+ export function inspectResetTail(ctx: ExtensionContext, markerId: string, windowId: string): ResetTailState | undefined {
64
+ const branch = ctx.sessionManager.getBranch();
65
+ const markerIndex = branch.findIndex((entry) => entry.id === markerId);
66
+ if (markerIndex < 0) return undefined;
67
+ let boot = false;
68
+ let continuation = false;
69
+ for (const entry of branch.slice(markerIndex + 1)) {
70
+ if (isWindowBootEntry(entry, windowId)) {
71
+ // The boot is unique and must precede the continuation; a repeat or a late
72
+ // boot would move the provider boundary or misorder the reset shape.
73
+ if (boot || continuation) return undefined;
74
+ boot = true;
75
+ continue;
76
+ }
77
+ if (isWindowContinuationEntry(entry)) {
78
+ if (continuation || !boot) return undefined;
79
+ continuation = true;
80
+ continue;
81
+ }
82
+ if (isWindowMarker(entry) || entry.type === "message" || entry.type === "custom_message" || entry.type === "compaction" || entry.type === "branch_summary") {
83
+ return undefined;
84
+ }
85
+ }
86
+ return { boot, continuation };
87
+ }
88
+
89
+ /** True once the marker's tail already carries its boot and continuation in a valid order. */
90
+ export function resetTailCommitted(ctx: ExtensionContext, markerId: string, windowId: string): boolean {
91
+ const tail = inspectResetTail(ctx, markerId, windowId);
92
+ return tail?.boot === true && tail.continuation === true;
93
+ }
94
+
95
+ /** Emit only the reset artifacts an incomplete tail is missing, in the closed order. */
96
+ export function repairResetTail(pi: ExtensionAPI, ctx: ExtensionContext, marker: WindowMarker, notifyIncompleteNotes?: IncompleteNotesNotifier): void {
97
+ const tail = inspectResetTail(ctx, marker.id, marker.data.windowId);
98
+ if (!tail || (tail.boot && tail.continuation)) return;
99
+ if (!tail.boot) sendBoot(pi, buildBootMessage(ctx, marker.data.windowId, previousWindowId(ctx, marker.id), notifyIncompleteNotes));
100
+ if (!tail.continuation) sendContinuation(pi);
101
+ }
@@ -26,23 +26,161 @@ function isOverflowLike(message: AgentMessage, ctx: ExtensionContext): boolean {
26
26
  (ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
27
27
  }
28
28
 
29
+ /**
30
+ * Reset-control state. The concern is split into two independent, explicitly typed axes so
31
+ * that no combination of independently combinable lifecycle booleans is legal by accident:
32
+ *
33
+ * - `request` is the single pending explicit wipe request ("none" or "explicit"). Repeated
34
+ * requests deduplicate while one is pending.
35
+ * - `overflow` is the bounded provider-overflow recovery chain:
36
+ * - "idle" no overflow failure is pending; recovery is available for a later chain.
37
+ * - "pending" an overflow failure is pending; recovery is still available.
38
+ * - "pending-spent" an overflow failure is pending, but recovery was already spent this chain.
39
+ * - "spent" recovery was spent and the failure is no longer pending.
40
+ *
41
+ * All eight combinations of the two axes are reachable (a request may arrive while an overflow
42
+ * chain is pending, spent, or settled), and each has a defined transition. There is no state
43
+ * whose fields silently contradict one another.
44
+ */
45
+ export type ResetRequestPhase = "none" | "explicit";
46
+ export type ResetOverflowPhase = "idle" | "pending" | "pending-spent" | "spent";
47
+
48
+ export interface ResetControlState {
49
+ readonly request: ResetRequestPhase;
50
+ readonly overflow: ResetOverflowPhase;
51
+ }
52
+
53
+ export function initialResetControl(): ResetControlState {
54
+ return { request: "none", overflow: "idle" };
55
+ }
56
+
57
+ /**
58
+ * Facts for a completed turn. The three `get`-supplied fields are guards the adapter resolves
59
+ * lazily: the reducer reads each one only in the branch where the previous adapter consulted it,
60
+ * so policy resolution and pending-message probes keep their original call ordering.
61
+ */
62
+ export interface ResetTurnEndFacts {
63
+ readonly aborted: boolean;
64
+ readonly overflow: boolean;
65
+ readonly failed: boolean;
66
+ readonly enabled: boolean;
67
+ readonly queued: boolean;
68
+ readonly automaticResetEnabled: boolean;
69
+ readonly thresholdDue: boolean;
70
+ }
71
+
72
+ /** Facts for the pre-settlement boundary; policy guards stay lazy for the same reason. */
73
+ export interface ResetBeforeSettleFacts {
74
+ readonly queued: boolean;
75
+ readonly enabled: boolean;
76
+ readonly automaticResetEnabled: boolean;
77
+ readonly aborted: boolean;
78
+ }
79
+
80
+ export type ResetControlEvent =
81
+ | { readonly type: "request" }
82
+ | { readonly type: "turn_end"; readonly facts: ResetTurnEndFacts }
83
+ | { readonly type: "before_settle"; readonly facts: ResetBeforeSettleFacts }
84
+ | { readonly type: "settled" }
85
+ | { readonly type: "abort" }
86
+ | { readonly type: "clear" };
87
+
88
+ /**
89
+ * The requested effect of a transition. Effects are named, not performed: marker/boot/
90
+ * continuation writes, continuation requests, and UI notices stay in the adapter.
91
+ *
92
+ * - "none" keep any return value to the drafts already collected for this event.
93
+ * - "requested" a new explicit reset request was recorded.
94
+ * - "already-requested" a request was already pending and was deduplicated.
95
+ * - "commit-boundary" build and commit the reset boundary now (turn_end).
96
+ * - "recover-overflow" build and commit the one bounded overflow recovery now (settle).
97
+ */
98
+ export type ResetControlEffect =
99
+ | "none"
100
+ | "requested"
101
+ | "already-requested"
102
+ | "commit-boundary"
103
+ | "recover-overflow";
104
+
105
+ export interface ResetControlResult {
106
+ readonly state: ResetControlState;
107
+ readonly effect: ResetControlEffect;
108
+ }
109
+
110
+ /**
111
+ * Pure reset-control transition. It performs no writes, no policy resolution of its own, and
112
+ * no UI work; every fact it reads is supplied by the caller. Callers can therefore drive the
113
+ * full transition table without a live Pi session.
114
+ */
115
+ export function reduceResetControl(state: ResetControlState, event: ResetControlEvent): ResetControlResult {
116
+ switch (event.type) {
117
+ case "request": {
118
+ if (state.request === "explicit") return { state, effect: "already-requested" };
119
+ return { state: { ...state, request: "explicit" }, effect: "requested" };
120
+ }
121
+ case "turn_end": {
122
+ const facts = event.facts;
123
+ const requested = state.request === "explicit";
124
+ // The explicit request is consumed by the turn boundary whether or not it commits.
125
+ const request: ResetRequestPhase = "none";
126
+ if (facts.aborted) {
127
+ // An aborted turn drops the whole boundary: no explicit request, no overflow chain.
128
+ return { state: { request, overflow: "idle" }, effect: "none" };
129
+ }
130
+ if (facts.overflow) {
131
+ // A failure that Pi may recover natively: arm (or re-arm) the bounded settle path.
132
+ // A queued message, disabled mode, or disabled automatic reset leaves it disarmed.
133
+ const pending = !facts.queued && facts.enabled && facts.automaticResetEnabled;
134
+ const spent = state.overflow === "pending-spent" || state.overflow === "spent";
135
+ const overflow: ResetOverflowPhase = pending
136
+ ? (spent ? "pending-spent" : "pending")
137
+ : (spent ? "spent" : "idle");
138
+ return { state: { request, overflow }, effect: "none" };
139
+ }
140
+ // Any non-overflow completed turn supersedes an older overflow failure. A non-overflow
141
+ // error leaves the armed overflow chain untouched for the settle boundary.
142
+ const overflow: ResetOverflowPhase = facts.failed ? state.overflow : "idle";
143
+ if (!facts.enabled || facts.failed) return { state: { request, overflow }, effect: "none" };
144
+ // Explicit and threshold resets both commit at turn_end, after incoming and budget drafts.
145
+ const commit = requested || facts.thresholdDue;
146
+ return { state: { request, overflow }, effect: commit ? "commit-boundary" : "none" };
147
+ }
148
+ case "before_settle": {
149
+ const facts = event.facts;
150
+ if (state.overflow !== "pending" && state.overflow !== "pending-spent") return { state, effect: "none" };
151
+ // Let a queued turn run first; its own turn_end settles or clears this chain.
152
+ if (facts.queued) return { state, effect: "none" };
153
+ const spent = state.overflow === "pending-spent";
154
+ if (!facts.enabled || !facts.automaticResetEnabled || facts.aborted) {
155
+ return { state: { ...state, overflow: spent ? "spent" : "idle" }, effect: "none" };
156
+ }
157
+ // The recovery is one-use per failure chain. Committing it spends the attempt.
158
+ return { state: { ...state, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
159
+ }
160
+ case "settled":
161
+ // Settlement ends the failure chain but leaves a pending explicit request armed.
162
+ return { state: { ...state, overflow: "idle" }, effect: "none" };
163
+ case "abort":
164
+ case "clear":
165
+ return { state: initialResetControl(), effect: "none" };
166
+ }
167
+ }
168
+
29
169
  /**
30
170
  * Own reset requests at Pi 0.87 boundaries. Persisted windows are custom entries, not
31
171
  * compaction summaries: turn_end commits explicit/threshold resets after a complete tool
32
172
  * batch, while agent_before_settle commits the one bounded overflow recovery after Pi's
33
173
  * native recovery attempt has been cancelled.
174
+ *
175
+ * This function is the effect adapter: it captures Pi events, translates them into pure
176
+ * reset-control transitions, and performs the resulting marker/boot/continuation writes and
177
+ * notifications. The decision of what to do lives entirely in `reduceResetControl`.
34
178
  */
35
179
  export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions) {
36
- let explicitRequested = false;
37
- let overflowPending = false;
38
- let overflowRecoveryUsed = false;
39
- let active = true;
40
-
41
- const clear = () => {
42
- explicitRequested = false;
43
- overflowPending = false;
44
- overflowRecoveryUsed = false;
45
- };
180
+ let sessionActive = true;
181
+ let control = initialResetControl();
182
+
183
+ const clear = () => { control = reduceResetControl(control, { type: "clear" }).state; };
46
184
  const resetBoundaryResult = (entries: SessionBoundaryDraft[], ctx: ExtensionContext) => {
47
185
  try {
48
186
  return { entries: [...entries, ...options.buildReset(ctx)], continue: true as const };
@@ -56,58 +194,48 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions)
56
194
  };
57
195
 
58
196
  pi.on("turn_end", (event, ctx) => {
59
- if (!active) return undefined;
60
- const requested = explicitRequested;
61
- explicitRequested = false;
197
+ if (!sessionActive) return undefined;
62
198
  const aborted = isAbort(event.message, event.outcome, ctx);
63
199
  const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
64
200
  // Lifecycle owns whether drafts are acceptable for this turn. Budget only
65
201
  // drains its instance-local staging, so aborts and disabled mode cannot commit it.
66
202
  const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
67
203
  const entries = [...(event.entries ?? []), ...budgetEntries];
68
- if (aborted) {
69
- overflowPending = false;
70
- overflowRecoveryUsed = false;
71
- return entries.length > 0 ? { entries } : undefined;
72
- }
73
- // Native overflow/length recovery is handled after turn_end through the bounded
74
- // settle path; do not turn that failed response into a threshold reset. If Pi has
75
- // already queued the next user message, the successful queued turn owns settlement
76
- // and must supersede this stale failure.
77
- if (isOverflowLike(event.message, ctx)) {
78
- const queued = event.context.pendingMessages.length > 0 || ctx.hasPendingMessages();
79
- overflowPending = !queued && options.isEnabled() && options.budget.automaticResetEnabled(ctx);
80
- return entries.length > 0 ? { entries } : undefined;
81
- }
82
- // A successful turn, including one drained from Pi's queue, supersedes any
83
- // older overflow failure before the settle boundary gets a chance to recover it.
84
- if (event.outcome !== "error") {
85
- overflowPending = false;
86
- overflowRecoveryUsed = false;
87
- }
88
- if (!options.isEnabled() || event.outcome === "error") return entries.length > 0 ? { entries } : undefined;
89
- // The completed response may be the first event whose persisted usage crosses the
90
- // reserve, so a final assistant response does not defer the reset until another prompt.
91
- const autoThreshold = options.budget.resetDue(ctx);
92
- if (!requested && !autoThreshold) return entries.length > 0 ? { entries } : undefined;
93
- return resetBoundaryResult(entries, ctx);
204
+ const decision = reduceResetControl(control, {
205
+ type: "turn_end",
206
+ facts: {
207
+ aborted,
208
+ overflow: aborted ? false : isOverflowLike(event.message, ctx),
209
+ failed: event.outcome === "error",
210
+ enabled: options.isEnabled(),
211
+ get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
212
+ get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
213
+ get thresholdDue() { return options.budget.resetDue(ctx); },
214
+ },
215
+ });
216
+ control = decision.state;
217
+ if (decision.effect === "commit-boundary") return resetBoundaryResult(entries, ctx);
218
+ return entries.length > 0 ? { entries } : undefined;
94
219
  });
95
220
 
96
221
  pi.on("agent_before_settle", (event: AgentBeforeSettleEvent, ctx) => {
97
- if (!active || !overflowPending) return undefined;
98
- // Pi invokes this boundary before settlement even when an agent_end handler has
99
- // queued user input. Let that turn run first; its successful turn_end clears the
100
- // stale failure, while another failure leaves the bounded recovery armed.
101
- if (event.context.pendingMessages.length > 0 || ctx.hasPendingMessages()) return undefined;
102
- overflowPending = false;
103
- if (!options.isEnabled() || !options.budget.automaticResetEnabled(ctx) || event.outcome === "aborted" || ctx.signal?.aborted) return undefined;
104
- if (overflowRecoveryUsed) return undefined;
105
- overflowRecoveryUsed = true;
222
+ if (!sessionActive) return undefined;
223
+ const decision = reduceResetControl(control, {
224
+ type: "before_settle",
225
+ facts: {
226
+ get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
227
+ get enabled() { return options.isEnabled(); },
228
+ get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
229
+ get aborted() { return event.outcome === "aborted" || ctx.signal?.aborted === true; },
230
+ },
231
+ });
232
+ control = decision.state;
233
+ if (decision.effect !== "recover-overflow") return undefined;
106
234
  return resetBoundaryResult(event.entries, ctx);
107
235
  });
108
236
 
109
237
  pi.on("session_before_compact", (event, ctx) => {
110
- if (!active) return undefined;
238
+ if (!sessionActive) return undefined;
111
239
  if (event.signal.aborted) return { cancel: true };
112
240
  const markerExists = currentReset(ctx) !== undefined;
113
241
  if (options.isEnabled() || markerExists) {
@@ -127,18 +255,17 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions)
127
255
  pi.on("agent_settled", () => {
128
256
  // A failed recovery chain is bounded to one reset/retry. Once Pi settles, a later
129
257
  // user prompt starts a new chain; successful continuations clear this earlier.
130
- overflowPending = false;
131
- overflowRecoveryUsed = false;
258
+ control = reduceResetControl(control, { type: "settled" }).state;
132
259
  });
133
- pi.on("session_start", () => { clear(); active = true; });
260
+ pi.on("session_start", () => { clear(); sessionActive = true; });
134
261
  pi.on("session_tree", clear);
135
- pi.on("session_shutdown", () => { clear(); options.budget.clear(); active = false; });
262
+ pi.on("session_shutdown", () => { clear(); options.budget.clear(); sessionActive = false; });
136
263
 
137
264
  return {
138
265
  request() {
139
- if (explicitRequested) return "rollover_already_pending";
140
- explicitRequested = true;
141
- return "rollover_requested";
266
+ const decision = reduceResetControl(control, { type: "request" });
267
+ control = decision.state;
268
+ return decision.effect === "already-requested" ? "rollover_already_pending" : "rollover_requested";
142
269
  },
143
270
  clear,
144
271
  };