@astrosheep/pi-context 0.23.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -5
- package/dist/build-info.json +4 -0
- package/dist/extension.js +1861 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +97 -0
- package/dist/src/context/prompts.js +94 -0
- package/dist/src/context/reset-lifecycle.js +134 -0
- package/dist/src/context/runtime.js +236 -0
- package/dist/src/context/thresholds.js +62 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.js +34 -6
- package/dist/src/dream/runner.js +1 -1
- package/dist/src/dream/settings.js +30 -0
- package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
- package/dist/src/{history.js → history/history.js} +8 -46
- package/dist/src/index.js +27 -94
- package/dist/src/notes/address.js +97 -16
- package/dist/src/notes/frontmatter.js +18 -3
- package/dist/src/notes/notes-snapshot.js +30 -0
- package/dist/src/notes/paths.js +64 -7
- package/dist/src/notes/session-replay.js +41 -0
- package/dist/src/notes/store.js +76 -22
- package/dist/src/notes/tools.js +7 -7
- package/dist/src/protocol.js +11 -9
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +813 -213
- package/dist/test/boot.integration.test.js +167 -0
- package/dist/test/budget-settings.integration.test.js +126 -0
- package/dist/test/doctor.test.js +14 -36
- package/dist/test/dream.test.js +37 -380
- package/dist/test/helpers/extension.js +393 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +273 -0
- package/dist/test/notes.test.js +40 -370
- package/dist/test/reset-lifecycle.test.js +248 -180
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +16 -14
- package/package.json +11 -10
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +103 -0
- package/src/context/prompts.ts +111 -0
- package/src/context/reset-lifecycle.ts +145 -0
- package/src/context/runtime.ts +246 -0
- package/src/context/thresholds.ts +78 -0
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +27 -6
- package/src/dream/runner.ts +1 -1
- package/src/dream/settings.ts +32 -0
- package/src/{history-tools.ts → history/history-tools.ts} +3 -3
- package/src/{history.ts → history/history.ts} +9 -48
- package/src/index.ts +27 -89
- package/src/notes/address.ts +82 -16
- package/src/notes/frontmatter.ts +20 -3
- package/src/notes/notes-snapshot.ts +40 -0
- package/src/notes/paths.ts +64 -7
- package/src/notes/session-replay.ts +53 -0
- package/src/notes/store.ts +78 -25
- package/src/notes/tools.ts +7 -7
- package/src/protocol.ts +11 -9
- package/src/settings.ts +20 -0
- package/src/tool-schema.ts +1 -2
- package/dist/src/budget.js +0 -65
- package/dist/src/notes/model.js +0 -101
- package/dist/src/prompts.js +0 -88
- package/dist/src/reset-lifecycle.js +0 -155
- package/dist/src/thresholds.js +0 -102
- package/dist/src/warning.js +0 -44
- package/dist/test/coherence.test.js +0 -371
- package/dist/test/history.test.js +0 -26
- package/dist/test/integration.test.js +0 -1775
- package/dist/test/pagination.property.test.js +0 -471
- package/src/budget.ts +0 -67
- package/src/notes/model.ts +0 -109
- package/src/prompts.ts +0 -91
- package/src/reset-lifecycle.ts +0 -173
- package/src/thresholds.ts +0 -110
- package/src/warning.ts +0 -46
package/docs/reset-lifecycle.md
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
# Reset lifecycle
|
|
2
2
|
|
|
3
|
-
`src/reset-lifecycle.ts` owns reset requests,
|
|
3
|
+
`src/context/reset-lifecycle.ts` owns reset requests, turn-end batching, recovery, and continuation. It is the sole `turn_end` composer: incoming drafts, budget drafts, and reset drafts are ordered here. `src/context/budget.ts` owns the default-path instance-local policy cache, resolves injected policy live, stages guidance/warning drafts, and keeps the final warning text in the budget/protocol path; `src/context/thresholds.ts` only reads settings and derives values, using the shared merge in `src/settings.ts`. `src/index.ts` is the thin public entrypoint; `src/context/runtime.ts` composes the runtime hooks and constructs marker/boot boundaries. `src/notes/notes-snapshot.ts` acquires the notes snapshot, while `src/context/prompts.ts` only renders the explicit boot data and low-budget reminder. Projections and tools have separate modules described in [Architecture](architecture.md).
|
|
4
4
|
|
|
5
5
|
| Event | Transition / owner |
|
|
6
6
|
| --- | --- |
|
|
7
|
-
| `
|
|
8
|
-
|
|
|
9
|
-
|
|
|
10
|
-
|
|
|
11
|
-
|
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
|
|
|
7
|
+
| `wipe_memory` | Record an explicit reset request. Repeated calls in one tool batch deduplicate; the tool returns terminal output. |
|
|
8
|
+
| `turn_end` | The sole composer drains current-window budget drafts after the event entries, then appends reset drafts: one `pi-context/reset-marker` with `{ windowId }`, one hidden boot message with matching `details.windowId`, and the continuation marker; continue the turn through Pi's public queue. |
|
|
9
|
+
| Abort before the boundary | Drop the pending boundary. Never manufacture a continuation for an aborted turn. |
|
|
10
|
+
| Threshold / provider overflow | Request the same marker/boot boundary for the active provider window. Actual overflow/length recovery retries at most once per failure chain; ordinary retryable provider errors stay Pi-owned. |
|
|
11
|
+
| `/wipe-memory` | Wait for idle, append marker and boot through public session APIs, and do not call a model. |
|
|
12
|
+
| `/compact` while enabled | Cancel with an actionable `/wipe-memory` notice so native compaction cannot summarize erased canonical history back into the active window. |
|
|
13
|
+
| Startup / tree / partial append | Repair only a marker followed by an otherwise empty metadata tail; refuse hidden/absent boots once later work exists, leaving `/wipe-memory` as the explicit recovery path. Do not interpret legacy reset-v2 details. |
|
|
14
|
+
| `/pi-context off` | Stop new automatic resets, but retain the boundary of an existing marker. Marked branches still cancel native compaction; a fresh root may use Pi's native semantics. |
|
|
15
15
|
|
|
16
|
-
The final checkpoint warning is
|
|
16
|
+
The budget owner stages the early guidance and final checkpoint warning from active-window usage. The warning is visible in the current provider request, while both drafts are committed only by the lifecycle composer and are discarded on abort, settlement without a `turn_end`, transition, or window mismatch. After the warning, the model either writes its note and calls `wipe_memory`, or runtime recovery requests the same marker/boot boundary. Guidance and warning drafts precede reset drafts so stale reminders cannot be queued into the new window; durable entries remain the authority for redelivery. Their UI notices are emitted at the next turn start or settlement only after the matching reminder is committed in the active window, so aborted requests and retries cannot repeat an uncommitted reminder's notification.
|
|
17
17
|
|
|
18
|
-
The
|
|
18
|
+
The turn-end commit is the scheduling boundary: Pi receives the finished tool batch and then the marker/boot drafts as one append operation. Pi owns queue scheduling and deduplication of the next request; the extension does not run a parallel compaction state machine or use a compaction completion callback.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Boot construction reads the five note homes into one closed snapshot before rendering the hidden boot. An absent home is empty; a real read failure removes only that home's MAP/pocket rows. The boot retains its identity, reset line, notes-home instructions, and recovery protocol, and includes a concise `notes_list` retry notice. The human receives one incomplete-index notification per boot. This notes failure isolation does not bypass the marker/boot commit, alter raw history, or turn a partial boot into an empty fallback.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
The hidden boot is selected by its durable `details.windowId`, not by timestamp or content equality. The context hook folds only the dropped system prefix before that boot and preserves later prompt patches and messages in order. If the boot is missing, the hook aborts with a safe head and notice rather than sending raw history. Startup/tree repair is deliberately narrow: it appends a boot only when the marker tail is otherwise incomplete metadata; later conversation or an authoritative raw boot causes safe refusal. `/wipe-memory` is the recovery path for that refused branch. A fork/clone creates a new session ID while copying branch entries, so startup refreshes the root boot identity while retaining copied root messages.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Public APIs let a mixed tool batch finish before the marker/boot boundary. Queued steering/follow-up messages are delivered exactly once in the new window, neither dropped nor replayed. `/tree` navigation remains available, but when either source or destination branch contains a reset marker, Pi's raw summary generator is bypassed: the summary is empty and a notice explains why, preventing erased history from re-entering through a path outside the context hook. With no marker on either branch, native summaries remain unchanged.
|
|
25
|
+
|
|
26
|
+
Validation uses a reduced set of persisted-data integration tests, isolated lifecycle event tests, and scripted SDK tests running Pi's actual agent loop. Representative cases check reset-window history retention, resumable reads, native-compaction cancellation, mixed-tool completion, steering/follow-up delivery, bounded overflow recovery, and settings authority. Tree-summary suppression no longer has a dedicated retained test. The separate coherence and pagination property suites have been removed; the retained cases do not cover the previous full matrix of branch, cancellation, malformed-input, and pagination edges.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrosheep/pi-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Codex-style context windows for Pi: reset
|
|
5
|
+
"description": "Codex-style context windows for Pi: durable reset windows, session history tools, and persistent notes.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"pi-package",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"pi": {
|
|
17
17
|
"extensions": [
|
|
18
|
-
"./
|
|
18
|
+
"./dist/extension.js"
|
|
19
19
|
]
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
@@ -30,22 +30,23 @@
|
|
|
30
30
|
"dream": "dist/src/dream/cli.js"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/src/dream/cli.js', 0o755)\"",
|
|
33
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node scripts/build-extension.mjs && node -e \"require('node:fs').chmodSync('dist/src/dream/cli.js', 0o755)\"",
|
|
34
34
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
35
35
|
"test": "npm run build && node --test dist/test/*.test.js",
|
|
36
36
|
"prepublishOnly": "npm run typecheck",
|
|
37
37
|
"prepack": "npm run build"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@earendil-works/pi-agent-core": "
|
|
41
|
-
"@earendil-works/pi-ai": "
|
|
42
|
-
"@earendil-works/pi-coding-agent": "
|
|
40
|
+
"@earendil-works/pi-agent-core": "^0.87.0",
|
|
41
|
+
"@earendil-works/pi-ai": "^0.87.0",
|
|
42
|
+
"@earendil-works/pi-coding-agent": "^0.87.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
46
|
-
"@earendil-works/pi-ai": "^0.
|
|
47
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
45
|
+
"@earendil-works/pi-agent-core": "^0.87.0",
|
|
46
|
+
"@earendil-works/pi-ai": "^0.87.0",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
48
48
|
"@types/node": "^22.19.19",
|
|
49
|
+
"esbuild": "^0.28.2",
|
|
49
50
|
"typescript": "^5.9.3"
|
|
50
51
|
},
|
|
51
52
|
"publishConfig": {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool, type ExtensionAPI, type ExtensionContext, type SessionBoundaryDraft, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { GUIDANCE_CLOSE_TAG, GUIDANCE_OPEN_TAG, GUIDANCE_TYPE, WARNING_PROMPT, WARNING_TYPE } from "../protocol.js";
|
|
4
|
+
import { readThresholdSettings, type ResolvedThresholds, type ThresholdSettingsResolution } from "./thresholds.js";
|
|
5
|
+
import { currentWindowId, hasWindowMessage, windowUsage } from "./context-window.js";
|
|
6
|
+
import { tokenBudgetGuidance } from "./prompts.js";
|
|
7
|
+
import { output } from "../tool-output.js";
|
|
8
|
+
|
|
9
|
+
/** Remaining tokens in the provider's active window, or null without a usable estimate. */
|
|
10
|
+
export function remainingTokens(ctx: Pick<ExtensionContext, "sessionManager" | "getContextUsage" | "model">): number | null {
|
|
11
|
+
const usage = windowUsage(ctx);
|
|
12
|
+
return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, settingsManager?: SettingsManager) {
|
|
16
|
+
let cachedPolicy: { thresholds: ResolvedThresholds; automatic: boolean } | undefined;
|
|
17
|
+
const notifiedWarnings = new Set<string>();
|
|
18
|
+
const resolvePolicy = (ctx: ExtensionContext): ThresholdSettingsResolution => {
|
|
19
|
+
if (!settingsManager && cachedPolicy) return { ...cachedPolicy, warnings: [] };
|
|
20
|
+
const resolution = readThresholdSettings(ctx, settingsManager);
|
|
21
|
+
for (const warning of resolution.warnings) {
|
|
22
|
+
if (notifiedWarnings.has(warning)) continue;
|
|
23
|
+
notifiedWarnings.add(warning);
|
|
24
|
+
ctx.ui.notify(warning, "warning");
|
|
25
|
+
}
|
|
26
|
+
if (!settingsManager) cachedPolicy = { thresholds: resolution.thresholds, automatic: resolution.automatic };
|
|
27
|
+
return resolution;
|
|
28
|
+
};
|
|
29
|
+
const thresholdsFor = (ctx: ExtensionContext): ResolvedThresholds => {
|
|
30
|
+
return resolvePolicy(ctx).thresholds;
|
|
31
|
+
};
|
|
32
|
+
const automaticResetEnabled = (ctx: ExtensionContext): boolean => {
|
|
33
|
+
return resolvePolicy(ctx).automatic;
|
|
34
|
+
};
|
|
35
|
+
const resetDue = (ctx: ExtensionContext): boolean => {
|
|
36
|
+
if (!automaticResetEnabled(ctx)) return false;
|
|
37
|
+
const usage = windowUsage(ctx);
|
|
38
|
+
return usage !== undefined && usage.tokens !== null && usage.contextWindow - usage.tokens <= thresholdsFor(ctx).reserve;
|
|
39
|
+
};
|
|
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 }> = [];
|
|
44
|
+
const notifyCommittedReminders = (ctx: ExtensionContext) => {
|
|
45
|
+
const windowId = currentWindowId(ctx);
|
|
46
|
+
for (const notice of pendingNotices) {
|
|
47
|
+
if (notice.windowId !== windowId || !hasWindowMessage(ctx, notice.customType)) continue;
|
|
48
|
+
ctx.ui.notify(notice.customType === WARNING_TYPE
|
|
49
|
+
? "pi-context: context budget critical — final 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");
|
|
51
|
+
}
|
|
52
|
+
pendingNotices = [];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const clearStaged = () => {
|
|
56
|
+
pendingGuidance = undefined;
|
|
57
|
+
pendingWarning = undefined;
|
|
58
|
+
};
|
|
59
|
+
const resetForTransition = () => {
|
|
60
|
+
clearStaged();
|
|
61
|
+
pendingNotices = [];
|
|
62
|
+
invalidateThresholds();
|
|
63
|
+
notifiedWarnings.clear();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const consumeTurnEnd = (ctx: ExtensionContext): SessionBoundaryDraft[] => {
|
|
67
|
+
const staged = [
|
|
68
|
+
pendingGuidance ? { ...pendingGuidance, customType: GUIDANCE_TYPE } : undefined,
|
|
69
|
+
pendingWarning ? { ...pendingWarning, customType: WARNING_TYPE } : undefined,
|
|
70
|
+
];
|
|
71
|
+
clearStaged();
|
|
72
|
+
const windowId = currentWindowId(ctx);
|
|
73
|
+
const drafts = staged.filter((draft): draft is NonNullable<typeof draft> => draft !== undefined && draft.windowId === windowId);
|
|
74
|
+
pendingNotices = drafts.map(({ windowId, customType }) => ({ windowId, customType }));
|
|
75
|
+
return drafts.map((draft) => ({
|
|
76
|
+
type: "custom_message" as const,
|
|
77
|
+
customType: draft.customType,
|
|
78
|
+
content: draft.content,
|
|
79
|
+
display: false,
|
|
80
|
+
}));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
pi.on("session_start", (_event, ctx) => { resetForTransition(); thresholdsFor(ctx); });
|
|
84
|
+
pi.on("session_tree", resetForTransition);
|
|
85
|
+
pi.on("model_select", resetForTransition);
|
|
86
|
+
pi.on("session_shutdown", resetForTransition);
|
|
87
|
+
// A request can fail before Pi emits turn_end. agent_settled is the public
|
|
88
|
+
// lifecycle point that must discard an uncommitted draft before the next prompt.
|
|
89
|
+
// UI notices follow committed reminders. Aborted requests can retry their drafts
|
|
90
|
+
// without showing the same low-budget notification twice.
|
|
91
|
+
pi.on("turn_start", (_event, ctx) => notifyCommittedReminders(ctx));
|
|
92
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
93
|
+
notifyCommittedReminders(ctx);
|
|
94
|
+
clearStaged();
|
|
95
|
+
});
|
|
96
|
+
pi.on("context", (_event, ctx) => {
|
|
97
|
+
if (!isEnabled()) return undefined;
|
|
98
|
+
// The early reminder persists once per window the first time remaining crosses
|
|
99
|
+
// reserve+margin. It never edits the outgoing request.
|
|
100
|
+
const remaining = remainingTokens(ctx);
|
|
101
|
+
if (remaining === null) return undefined;
|
|
102
|
+
const windowId = currentWindowId(ctx);
|
|
103
|
+
const { reminder, warning } = thresholdsFor(ctx);
|
|
104
|
+
if (hasWindowMessage(ctx, WARNING_TYPE) || pendingWarning?.windowId === windowId) return undefined;
|
|
105
|
+
if (remaining <= warning) {
|
|
106
|
+
// A not-yet-committed shallow reminder is superseded by the final warning.
|
|
107
|
+
pendingGuidance = undefined;
|
|
108
|
+
const content = `${GUIDANCE_OPEN_TAG}\n${WARNING_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
|
|
109
|
+
pendingWarning = { windowId, content };
|
|
110
|
+
const warningMessage = {
|
|
111
|
+
role: "custom" as const,
|
|
112
|
+
customType: WARNING_TYPE,
|
|
113
|
+
content,
|
|
114
|
+
display: false,
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
};
|
|
117
|
+
return { messages: [..._event.messages, warningMessage] };
|
|
118
|
+
}
|
|
119
|
+
if (hasWindowMessage(ctx, GUIDANCE_TYPE) || pendingGuidance?.windowId === windowId) return undefined;
|
|
120
|
+
if (remaining <= reminder) {
|
|
121
|
+
// Persist at turn_end, before any reset drafts. A queued sendMessage could
|
|
122
|
+
// otherwise cross the marker and leak the old window's reminder forward.
|
|
123
|
+
const left = Math.max(0, remaining - warning);
|
|
124
|
+
pendingGuidance = { windowId, content: tokenBudgetGuidance(left) };
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
pi.registerTool(defineTool({
|
|
130
|
+
name: "get_context_remaining",
|
|
131
|
+
label: "Get context remaining",
|
|
132
|
+
description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
|
|
133
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
134
|
+
async execute(_id, _params, _signal, _update, ctx) {
|
|
135
|
+
// The countdown the model sees ends at the warning line (reserve + runway);
|
|
136
|
+
// the runway below it is overdraft the model never sees. See protocol.ts.
|
|
137
|
+
const remaining = remainingTokens(ctx);
|
|
138
|
+
return output({ remaining_tokens: remaining === null ? null : Math.max(0, remaining - thresholdsFor(ctx as ExtensionContext).warning) });
|
|
139
|
+
},
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
automaticResetEnabled,
|
|
144
|
+
resetDue,
|
|
145
|
+
consumeTurnEnd,
|
|
146
|
+
clear: () => { clearStaged(); pendingNotices = []; },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
|
|
4
|
+
import { convertToLlm, type CustomEntry, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { BOOT_TYPE, RESET_MARKER_TYPE } from "../protocol.js";
|
|
6
|
+
import type { SessionReader } from "../session-reader.js";
|
|
7
|
+
|
|
8
|
+
export type WindowMarker = CustomEntry<{ windowId: string }> & { data: { windowId: string } };
|
|
9
|
+
|
|
10
|
+
export function isWindowMarker(entry: SessionEntry): entry is WindowMarker {
|
|
11
|
+
return entry.type === "custom" && entry.customType === RESET_MARKER_TYPE &&
|
|
12
|
+
typeof entry.data === "object" && entry.data !== null &&
|
|
13
|
+
typeof (entry.data as { windowId?: unknown }).windowId === "string" &&
|
|
14
|
+
(entry.data as { windowId: string }).windowId.length > 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Only the active branch can supply a window boundary. */
|
|
18
|
+
export function currentReset(ctx: SessionReader): WindowMarker | undefined {
|
|
19
|
+
const branch = ctx.sessionManager.getBranch();
|
|
20
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
21
|
+
const entry = branch[i];
|
|
22
|
+
if (entry && isWindowMarker(entry)) return entry;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Mint the durable identity of a session's root history window. */
|
|
28
|
+
export function rootWindowId(sessionId: string): string {
|
|
29
|
+
return `pcw:${sessionId.slice(0, 8)}:root`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Persisted messages in the active window, excluding earlier windows on this branch. */
|
|
33
|
+
export function hasWindowMessage(ctx: SessionReader, customType: string): boolean {
|
|
34
|
+
const branch = ctx.sessionManager.getBranch();
|
|
35
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
36
|
+
const entry = branch[i];
|
|
37
|
+
if (isWindowMarker(entry)) break;
|
|
38
|
+
if (entry.type === "custom_message" && entry.customType === customType) return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The root or latest durable marker on the active branch. */
|
|
44
|
+
export function currentWindowId(ctx: SessionReader): string {
|
|
45
|
+
return currentReset(ctx)?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasWindowId(details: unknown, windowId: string): boolean {
|
|
49
|
+
return typeof details === "object" && details !== null &&
|
|
50
|
+
typeof (details as { windowId?: unknown }).windowId === "string" &&
|
|
51
|
+
(details as { windowId: string }).windowId === windowId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Match a provider-facing boot message, optionally by window identity. */
|
|
55
|
+
export function isWindowBoot(message: AgentMessage, windowId?: string): boolean {
|
|
56
|
+
return message.role === "custom" && message.customType === BOOT_TYPE && (windowId === undefined || hasWindowId(message.details, windowId));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The durable marker selects a boot message by identity, never by wall-clock time.
|
|
61
|
+
* The boot is the first conversation message of the window. Folding only its prefix
|
|
62
|
+
* preserves later prompt/tool patches in place, including their cacheable ordering.
|
|
63
|
+
*/
|
|
64
|
+
export function projectWindow(messages: AgentMessage[], windowId: string): AgentMessage[] {
|
|
65
|
+
const cut = messages.findIndex((message) => isWindowBoot(message, windowId));
|
|
66
|
+
if (cut < 0) throw new Error(`Missing boot for context window ${windowId}`);
|
|
67
|
+
const head = getCurrentSystemMessage(messages.slice(0, cut));
|
|
68
|
+
const suffix = messages.slice(cut);
|
|
69
|
+
return head ? [head, ...suffix] : suffix;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Root windows are not reset boundaries. A forked session can copy a root boot whose
|
|
74
|
+
* details name the source session; refresh that boot in-place in the provider projection
|
|
75
|
+
* while retaining every user/assistant/tool message from the copied root transcript.
|
|
76
|
+
*/
|
|
77
|
+
export function projectRootWindow(messages: AgentMessage[], windowId: string): AgentMessage[] {
|
|
78
|
+
const matching = messages.filter((message) => isWindowBoot(message, windowId));
|
|
79
|
+
if (matching.length === 0) return messages;
|
|
80
|
+
const activeBoot = matching[matching.length - 1];
|
|
81
|
+
const firstBoot = messages.findIndex((message) => isWindowBoot(message));
|
|
82
|
+
const withoutBoots = messages.filter((message) => !isWindowBoot(message));
|
|
83
|
+
return [...withoutBoots.slice(0, firstBoot), activeBoot, ...withoutBoots.slice(firstBoot)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Usage for the selected window, excluding provider usage recorded before its marker. */
|
|
87
|
+
export function windowUsage(ctx: Pick<ExtensionContext, "sessionManager" | "getContextUsage" | "model">) {
|
|
88
|
+
const reset = currentReset(ctx);
|
|
89
|
+
if (!reset) return ctx.getContextUsage();
|
|
90
|
+
const contextWindow = ctx.model?.contextWindow ?? ctx.getContextUsage()?.contextWindow;
|
|
91
|
+
if (!contextWindow) return undefined;
|
|
92
|
+
const windowId = reset.data.windowId;
|
|
93
|
+
try {
|
|
94
|
+
const messages: AgentMessage[] = projectWindow(ctx.sessionManager.buildSessionProjection().messages, windowId);
|
|
95
|
+
const { tokens } = estimateContextTokens(convertToLlm(messages));
|
|
96
|
+
return { tokens, contextWindow, percent: tokens / contextWindow * 100 };
|
|
97
|
+
} catch {
|
|
98
|
+
// A marker can be durable before its boot when a process stops between the two
|
|
99
|
+
// public writes. Startup/tree repair will append the missing boot; until then the
|
|
100
|
+
// budget hook must not turn a recoverable partial append into a swallowed error.
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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";
|
|
3
|
+
|
|
4
|
+
/** Codex-style <context_window> identity block: the resolved agent and model names plus first/current/previous window ids. */
|
|
5
|
+
function identityBlock(agentName: string, modelName: string, firstWindowId: string, currentWindowId: string, previousWindowId?: string): string {
|
|
6
|
+
const lines = [
|
|
7
|
+
`Agent name: ${agentName} (brain: ${modelName})`,
|
|
8
|
+
`First context window id: ${firstWindowId}`,
|
|
9
|
+
`Current context window id: ${currentWindowId}`,
|
|
10
|
+
];
|
|
11
|
+
if (previousWindowId) lines.push(`Previous context window id: ${previousWindowId}`);
|
|
12
|
+
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function relativeTime(timestamp: number, now: number): string {
|
|
16
|
+
const seconds = Math.trunc((timestamp - now) / 1000);
|
|
17
|
+
const [unit, size] = ([["d", 86400], ["h", 3600], ["m", 60], ["s", 1]] as const)
|
|
18
|
+
.find(([unit, size]) => Math.abs(seconds) >= size || unit === "s")!;
|
|
19
|
+
const amount = `${Math.abs(Math.trunc(seconds / size))}${unit}`;
|
|
20
|
+
return seconds > 0 ? `in ${amount}` : `${amount} ago`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function rowsFor(snapshot: NotesSnapshot, scope: NotesHome["scope"]) {
|
|
24
|
+
return snapshot.homes.get(scope) ?? [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function notesUnavailableNotice(snapshot: NotesSnapshot): string | undefined {
|
|
28
|
+
if (snapshot.unavailable.length === 0) return undefined;
|
|
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.`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the human, project,
|
|
36
|
+
* own-agent, and current-model homes are all injected, broadest first; stale maps are skipped
|
|
37
|
+
* per home, and the session home is never peeked — a session MAP.md is an ordinary note. The
|
|
38
|
+
* pocket then lists recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT /
|
|
39
|
+
* POCKET_PROJECT_LIMIT / POCKET_HUMAN_LIMIT / POCKET_AGENT_LIMIT / POCKET_MODEL_LIMIT),
|
|
40
|
+
* most-recently-updated first within each home, one metadata line each: address, line count,
|
|
41
|
+
* UTF-8 byte count, relative update time at window open. Bodies never render
|
|
42
|
+
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
43
|
+
*/
|
|
44
|
+
function notesIndex(snapshot: NotesSnapshot): string {
|
|
45
|
+
const sections: string[] = [];
|
|
46
|
+
// Map residency ("地图在场"): scope-native maps, fresh ones injected broadest-first.
|
|
47
|
+
// A session MAP.md is an ordinary note, never resident; stale maps skip independently.
|
|
48
|
+
for (const scope of ["human", "project", "agent", "model"] as const) {
|
|
49
|
+
const toc = rowsFor(snapshot, scope).find((row) => row.path === "MAP.md");
|
|
50
|
+
if (toc && !toc.meta.stale) {
|
|
51
|
+
if (toc.body.length > 0) sections.push(toc.body);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// listNotes is most-recently-updated first within each home. Per-home quotas keep session
|
|
55
|
+
// churn from evicting the durable homes; maps never take pocket seats.
|
|
56
|
+
const recentNotes = [
|
|
57
|
+
...rowsFor(snapshot, "session").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
|
|
58
|
+
...rowsFor(snapshot, "project").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
|
|
59
|
+
...rowsFor(snapshot, "human").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_HUMAN_LIMIT),
|
|
60
|
+
...rowsFor(snapshot, "agent").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_AGENT_LIMIT),
|
|
61
|
+
...rowsFor(snapshot, "model").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_MODEL_LIMIT),
|
|
62
|
+
];
|
|
63
|
+
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:`];
|
|
65
|
+
for (const row of recentNotes) {
|
|
66
|
+
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, snapshot.openedAt)})`);
|
|
67
|
+
}
|
|
68
|
+
sections.push(lines.join("\n"));
|
|
69
|
+
}
|
|
70
|
+
return sections.join("\n\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
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.";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Render a static, once-per-window boot block from explicit data. This function does not read
|
|
79
|
+
* notes or call runtime UI APIs; acquisition belongs to loadNotesSnapshot and its caller.
|
|
80
|
+
*/
|
|
81
|
+
export type BootRenderData = {
|
|
82
|
+
readonly agentName: string;
|
|
83
|
+
readonly modelName: string;
|
|
84
|
+
readonly firstWindowId: string;
|
|
85
|
+
readonly currentWindowId: string;
|
|
86
|
+
readonly previousWindowId?: string;
|
|
87
|
+
readonly resetLine: boolean;
|
|
88
|
+
readonly notes: NotesSnapshot;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function renderBootBlock(data: BootRenderData): string {
|
|
92
|
+
const parts: string[] = [];
|
|
93
|
+
if (data.resetLine) parts.push(RESET_SUMMARY);
|
|
94
|
+
parts.push(identityBlock(data.agentName, data.modelName, data.firstWindowId, data.currentWindowId, data.previousWindowId));
|
|
95
|
+
parts.push(notesHomeBlock());
|
|
96
|
+
const incomplete = notesUnavailableNotice(data.notes);
|
|
97
|
+
if (incomplete) parts.push(incomplete);
|
|
98
|
+
const index = notesIndex(data.notes);
|
|
99
|
+
if (index) parts.push(index);
|
|
100
|
+
parts.push(PROTOCOL_BLOCK);
|
|
101
|
+
return parts.join("\n\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Codex-equivalent low-budget reminder. The measured remaining count is frozen into
|
|
106
|
+
* the text at the crossing that fires it, so each persisted copy is a snapshot true
|
|
107
|
+
* at write time; get_context_remaining remains the live source for the current figure.
|
|
108
|
+
*/
|
|
109
|
+
export function tokenBudgetGuidance(remaining: number): string {
|
|
110
|
+
return `${GUIDANCE_OPEN_TAG}\nYour brain is almost out of room — ${remaining} tokens left, and then your memory gets wiped. The wipe is automatic: there is no final turn to write then. Grab the notebook now — the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Replacing an older checkpoint? Mark it stale. Then call wipe_memory yourself — anything you do after the checkpoint isn't in it.\n${GUIDANCE_CLOSE_TAG}`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { isContextOverflow, isRecoverableLength } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { AgentBeforeSettleEvent, ExtensionAPI, ExtensionContext, SessionBoundaryDraft } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { currentReset } from "./context-window.js";
|
|
5
|
+
|
|
6
|
+
type BudgetOwner = {
|
|
7
|
+
automaticResetEnabled: (ctx: ExtensionContext) => boolean;
|
|
8
|
+
resetDue: (ctx: ExtensionContext) => boolean;
|
|
9
|
+
consumeTurnEnd: (ctx: ExtensionContext) => SessionBoundaryDraft[];
|
|
10
|
+
clear: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ResetOptions = {
|
|
14
|
+
isEnabled: () => boolean;
|
|
15
|
+
budget: BudgetOwner;
|
|
16
|
+
buildReset: (ctx: ExtensionContext) => SessionBoundaryDraft[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isAbort(message: AgentMessage, outcome: string | undefined, ctx: ExtensionContext): boolean {
|
|
20
|
+
return outcome === "aborted" || (message.role === "assistant" && message.stopReason === "aborted") || ctx.signal?.aborted === true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isOverflowLike(message: AgentMessage, ctx: ExtensionContext): boolean {
|
|
24
|
+
if (message.role !== "assistant") return false;
|
|
25
|
+
return isContextOverflow(message, ctx.model?.contextWindow) ||
|
|
26
|
+
(ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Own reset requests at Pi 0.87 boundaries. Persisted windows are custom entries, not
|
|
31
|
+
* compaction summaries: turn_end commits explicit/threshold resets after a complete tool
|
|
32
|
+
* batch, while agent_before_settle commits the one bounded overflow recovery after Pi's
|
|
33
|
+
* native recovery attempt has been cancelled.
|
|
34
|
+
*/
|
|
35
|
+
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
|
+
};
|
|
46
|
+
const resetBoundaryResult = (entries: SessionBoundaryDraft[], ctx: ExtensionContext) => {
|
|
47
|
+
try {
|
|
48
|
+
return { entries: [...entries, ...options.buildReset(ctx)], continue: true as const };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
|
|
51
|
+
// The incoming drafts and budget drafts are already valid work from this
|
|
52
|
+
// boundary. Preserve them, but do not claim a continuation when reset
|
|
53
|
+
// construction failed.
|
|
54
|
+
return entries.length > 0 ? { entries } : undefined;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
pi.on("turn_end", (event, ctx) => {
|
|
59
|
+
if (!active) return undefined;
|
|
60
|
+
const requested = explicitRequested;
|
|
61
|
+
explicitRequested = false;
|
|
62
|
+
const aborted = isAbort(event.message, event.outcome, ctx);
|
|
63
|
+
const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
|
|
64
|
+
// Lifecycle owns whether drafts are acceptable for this turn. Budget only
|
|
65
|
+
// drains its instance-local staging, so aborts and disabled mode cannot commit it.
|
|
66
|
+
const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
|
|
67
|
+
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);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
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;
|
|
106
|
+
return resetBoundaryResult(event.entries, ctx);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
pi.on("session_before_compact", (event, ctx) => {
|
|
110
|
+
if (!active) return undefined;
|
|
111
|
+
if (event.signal.aborted) return { cancel: true };
|
|
112
|
+
const markerExists = currentReset(ctx) !== undefined;
|
|
113
|
+
if (options.isEnabled() || markerExists) {
|
|
114
|
+
if (event.reason === "manual") {
|
|
115
|
+
ctx.ui.notify("pi-context: /compact is disabled while context windows are active; use /wipe-memory to start a fresh window.", "warning");
|
|
116
|
+
}
|
|
117
|
+
// Native compaction is cancelled here. Threshold resets are decided solely from
|
|
118
|
+
// completed-turn usage at turn_end, never from canonical pre-request history.
|
|
119
|
+
return { cancel: true };
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
125
|
+
if (ctx.signal?.aborted) clear();
|
|
126
|
+
});
|
|
127
|
+
pi.on("agent_settled", () => {
|
|
128
|
+
// A failed recovery chain is bounded to one reset/retry. Once Pi settles, a later
|
|
129
|
+
// user prompt starts a new chain; successful continuations clear this earlier.
|
|
130
|
+
overflowPending = false;
|
|
131
|
+
overflowRecoveryUsed = false;
|
|
132
|
+
});
|
|
133
|
+
pi.on("session_start", () => { clear(); active = true; });
|
|
134
|
+
pi.on("session_tree", clear);
|
|
135
|
+
pi.on("session_shutdown", () => { clear(); options.budget.clear(); active = false; });
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
request() {
|
|
139
|
+
if (explicitRequested) return "rollover_already_pending";
|
|
140
|
+
explicitRequested = true;
|
|
141
|
+
return "rollover_requested";
|
|
142
|
+
},
|
|
143
|
+
clear,
|
|
144
|
+
};
|
|
145
|
+
}
|