@astrosheep/pi-context 0.6.0 → 0.7.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 +38 -8
- package/package.json +1 -1
- package/src/index.ts +285 -105
package/README.md
CHANGED
|
@@ -19,13 +19,41 @@ pi -e npm:@astrosheep/pi-context
|
|
|
19
19
|
The extension composes Pi's public `session_before_compact` / `session_compact` hooks, custom session entries, and the `context` hook to approximate Codex's experimental context management:
|
|
20
20
|
|
|
21
21
|
- **`new_context` tool** — the model requests a fresh context window. The extension waits for the current tool turn to end, compacts with a short deterministic reset message (old conversation is excluded from the new provider context but stays in the session), then sends exactly one hidden continuation turn.
|
|
22
|
-
- **`<context_window>`
|
|
23
|
-
- **Low-budget guidance** — when remaining context first drops to
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
22
|
+
- **`<context_window>` boot block** — the head of every fresh window. For a reset it IS the summary returned from `session_before_compact` (position 0, persisted, no extra message); for the root window `session_start` persists it once as a visible custom message. It carries the agent name and first/current/previous window IDs, the recent-notes index, and a `<context_window_protocol>` teaching block. The notes index is a window-open snapshot with the same frozen-at-write semantics as the reminder count. Nothing is injected transiently per request: the boot block is static once-per-window content, so the head of the window stays cache-stable. Codex diverges here — its `<context_window>` block carries only the agent path and window IDs, while the notes index is our own addition.
|
|
23
|
+
- **Low-budget guidance** — when estimated remaining context first drops to the reminder threshold (by default **65,536 tokens**: Pi's default 16,384 `reserveTokens` plus a 49,152 reminder margin; see [Reminder timing](#reminder-timing)), a `<context_window_guidance>` reminder is **persisted once per window** into history (TUI-visible, no extra turn; `sendMessage` safely defers mid-stream). There is deliberately no transient copy: a bridge would make the model meet the same text twice at shifted positions, because history records the persisted copy after the crossing request's assistant reply. The reminder is an early warning, so arriving from the next request on costs nothing and keeps the model's view identical to recorded history. The measured remaining count is frozen into the text at the threshold crossing, so the persisted reminder is a snapshot true at write time; `get_context_remaining` remains the live source for the current figure. The text is appended rather than prepended; existing history is not rewritten.
|
|
24
|
+
- **Direct automatic reset** — every automatic compaction immediately uses the same reset handler. No cancellation to obtain a fallback turn, no input interception or replay, and no special idle/streaming scheduling. The reminder asks the model to write notes early; if it misses that opportunity, old history remains searchable. Pi owns automatic continuation, queued inputs, and overflow retry.
|
|
25
|
+
- **Graceful fallback** — when estimated remaining context reaches the fallback threshold (by default **40,960 tokens**: Pi's default 16,384 `reserveTokens` plus a 24,576 fallback margin; see [Reminder timing](#reminder-timing)), the extension inserts one final note-taking instruction once per window. Before a fresh user turn, it is persisted through `before_agent_start`; after a running tool turn (only while the agent is still streaming), it is sent at the ordinary `turn_end` boundary with `triggerTurn: true`, which Pi routes to `agent.steer()`: the message is drained after the turn end and injected before the next LLM call, extending the current run by one note-taking turn while a queued user prompt (follow-up) waits until the agent would stop. It never copies, handles, or replays user input and never cancels Pi's compaction. Pi's automatic compaction still performs the reset afterward.
|
|
26
|
+
- **Runtime toggle** — `/pi-context off` disables the boot block, guidance, and reset-style compaction (Pi's default compaction, including `keepRecentTokens`, applies again). `/pi-context on` re-enables; a bare `/pi-context` reports the current state.
|
|
26
27
|
- **History tools** — the model searches pre-reset conversation with case-sensitive literal substring search, exactly like Codex's `history.*` namespace.
|
|
27
28
|
- **Notes tools** — persistent, session-scoped virtual files that survive window resets.
|
|
28
29
|
|
|
30
|
+
## Reminder timing
|
|
31
|
+
|
|
32
|
+
Reminder and fallback thresholds derive from Pi's compaction reserve plus margins configured in `settings.json` under the top-level `pi-context` key:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"compaction": { "reserveTokens": 16384 },
|
|
37
|
+
"pi-context": {
|
|
38
|
+
"reminderMarginTokens": 49152,
|
|
39
|
+
"fallbackMarginTokens": 24576
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Both margins are measured in **remaining context tokens** added on top of Pi's `compaction.reserveTokens`:
|
|
45
|
+
|
|
46
|
+
- `fallback = reserveTokens + fallbackMarginTokens` (default margin `24576`)
|
|
47
|
+
- `reminder = reserveTokens + reminderMarginTokens` (default margin `49152`)
|
|
48
|
+
|
|
49
|
+
Put the key in the global settings (`~/.pi/agent/settings.json`) or the project settings (`<cwd>/.pi/settings.json`); project values win per key, mirroring Pi's own settings merge. With Pi's default `reserveTokens: 16384` the default margins reproduce the historical absolute thresholds exactly: reminder `65536`, fallback `40960`. That keeps roughly 24.5k tokens between the early reminder and the fallback, and another 24.5k between the fallback and Pi's reset line, no matter how you set `reserveTokens`.
|
|
50
|
+
|
|
51
|
+
Pi's `reserveTokens` and the `pi-context` margins are re-read from disk at every `session_start` and cached for that session. Invalid values — a margin that is not a positive integer, or a `reminderMarginTokens` that does not clear `fallbackMarginTokens` — are ignored per offending key with one TUI warning naming the key and the default used instead; session handling never throws. If `fallbackMarginTokens` still leaves the reminder below the fallback after the reminder's default is applied, that key degrades too, with its own warning.
|
|
52
|
+
|
|
53
|
+
This is a file-backed read through Pi's public `SettingsManager`. It sees committed `settings.json` only: SDK-level ephemeral `applyOverrides()` calls and the unreleased `compaction.modelOverrides` are not seen by this extension.
|
|
54
|
+
|
|
55
|
+
The extension does not change Pi settings or reserve additional context. Large tool outputs or user inputs can jump over one or both reminders; overflow recovery still resets immediately rather than forcing a doomed extra turn. For small context windows, tune the margins (or Pi's reserve) to fit the model.
|
|
56
|
+
|
|
29
57
|
## Tools
|
|
30
58
|
|
|
31
59
|
The nine Codex History/Notes actions are flattened because Pi tools have one global name space:
|
|
@@ -42,11 +70,11 @@ The nine Codex History/Notes actions are flattened because Pi tools have one glo
|
|
|
42
70
|
| `notes.append_to_file` | `notes_append_to_file` |
|
|
43
71
|
| `notes.write_file` | `notes_write_file` |
|
|
44
72
|
|
|
45
|
-
`history_*` reads the current branch's actual Pi session entries, including entries hidden by earlier compaction. Window IDs are
|
|
73
|
+
`history_*` reads the current branch's actual Pi session entries, including entries hidden by earlier compaction. Window IDs are extension-owned: the root window is `pcw:<session-id>:root`, and each reset mints an 8-hex id baked into the compaction entry's `details.windowId` as `pcw:<session-id>:<minted>`. Pi-native compactions (extension toggled off) fall back to `pcw:<session-id>:<compaction-entry-id>`. Item IDs are the persisted Pi entry IDs. No transcript copy or volatile archive is maintained.
|
|
46
74
|
|
|
47
75
|
`notes_*` stores operation entries in the same append-only Pi session under `pi-context/note`. They are session-scoped, survive JSONL reload, never enter provider context, and use safe relative virtual paths only (no absolute paths, `..`, `.`, empty components, or backslashes). Searches are literal and case-sensitive. `notes_read_file` accepts inclusive 1-based line ranges; negative line numbers count from the last line. Writes are capped at 1,000,000 UTF-8 bytes.
|
|
48
76
|
|
|
49
|
-
Pi has no
|
|
77
|
+
Unlike Codex, the history tools do not advertise `agent_name`: Pi has no cross-agent session routing, so the parameter is omitted from the schemas entirely (strict `additionalProperties: false` still rejects it) instead of costing schema tokens on every request.
|
|
50
78
|
|
|
51
79
|
Two extra controls compose Pi public APIs:
|
|
52
80
|
|
|
@@ -55,7 +83,9 @@ Two extra controls compose Pi public APIs:
|
|
|
55
83
|
|
|
56
84
|
## Reset behavior and limits
|
|
57
85
|
|
|
58
|
-
On `session_before_compact`, the extension appends a persistent custom reset marker through public `pi.appendEntry`, reads that real marker ID from the readonly session manager, and returns it as `firstKeptEntryId`. Pi's `buildContextEntries()` then keeps the compaction envelope plus that custom marker; custom markers are excluded from LLM context. Thus the subsequent provider context contains the
|
|
86
|
+
On `session_before_compact`, the extension appends a persistent custom reset marker through public `pi.appendEntry`, reads that real marker ID from the readonly session manager, and returns it as `firstKeptEntryId`. Pi's `buildContextEntries()` then keeps the compaction envelope plus that custom marker; custom markers are excluded from LLM context. Thus the subsequent provider context contains the boot block summary and the marker, not old conversation messages. The boot summary also carries the extension-minted window id in its `details.windowId`, so the fresh window names itself with an id Pi could not have supplied at bake time. Only an explicit `new_context` adds a hidden continuation; automatic resets and user `/compact` keep Pi's native scheduling. The old entries remain only in the session tree for `history_*`.
|
|
87
|
+
|
|
88
|
+
Pi's built-in “compacted into the following summary” envelope is left intact. Its content explicitly says: “Context window reset. No summary was generated. Retrieve prior details through history_* and notes_*.” No context filtering or TUI override is used to hide that envelope.
|
|
59
89
|
|
|
60
90
|
The same handler is used for native automatic compaction. When Pi marks an overflow compaction `willRetry`, Pi core performs its single retry itself and this extension deliberately sends no second continuation. While the extension is enabled, its custom reset keeps nothing after the boundary marker, so Pi's `keepRecentTokens` setting has no effect; with `/pi-context off`, Pi's default compaction (and `keepRecentTokens`) applies again.
|
|
61
91
|
|
|
@@ -68,4 +98,4 @@ npm run typecheck
|
|
|
68
98
|
npm test
|
|
69
99
|
```
|
|
70
100
|
|
|
71
|
-
The integration harness uses the installed Pi `SessionManager
|
|
101
|
+
The integration harness uses the installed Pi `SessionManager` and `SettingsManager` (global and project settings fixtures in temp directories, so the real `~/.pi` is never touched), including an on-disk JSONL reload. It verifies note persistence/Unicode/path rules, the boot block contents and extension-minted window ids on reset and root paths, that the `context` hook never injects, provider context exclusion after the real `firstKeptEntryId` boundary while history remains searchable, completed tool-result boundary placement, settings-derived threshold resolution and margin validation, one early reminder and one final fallback per window, one continuation only, and cancellation/failure/no-double-retry behavior. It uses no model or network call.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,26 +1,50 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { Type, type TextContent } from "@earendil-works/pi-ai";
|
|
2
3
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
-
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { defineTool, SettingsManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
|
|
5
6
|
const STATE_TYPE = "pi-context/state";
|
|
6
7
|
const NOTE_TYPE = "pi-context/note";
|
|
7
|
-
const
|
|
8
|
+
const BOOT_TYPE = "pi-context/boot";
|
|
8
9
|
const GUIDANCE_TYPE = "pi-context/guidance";
|
|
9
10
|
const FALLBACK_TYPE = "pi-context/fallback";
|
|
10
11
|
const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
11
12
|
const CONTINUATION_TYPE = "pi-context/continuation";
|
|
13
|
+
const RESET_V2 = "reset-v2";
|
|
12
14
|
const MAX_NOTE_BYTES = 1_000_000;
|
|
13
15
|
const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
14
16
|
const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
17
|
+
const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
|
|
18
|
+
const CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG = "</context_window_protocol>";
|
|
15
19
|
const GUIDANCE_OPEN_TAG = "<context_window_guidance>";
|
|
16
20
|
const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
|
|
17
|
-
const
|
|
18
|
-
const
|
|
21
|
+
const PI_CONTEXT_SETTINGS_KEY = "pi-context";
|
|
22
|
+
const DEFAULT_RESERVE_TOKENS = 16_384;
|
|
23
|
+
const DEFAULT_REMINDER_MARGIN_TOKENS = 49_152;
|
|
24
|
+
const DEFAULT_FALLBACK_MARGIN_TOKENS = 24_576;
|
|
25
|
+
const RESET_SUMMARY = "Context window reset. No summary was generated. Retrieve prior details through history_* and notes_*.";
|
|
19
26
|
const CONTINUATION = "This is a fresh context window. Recover only the details needed to continue with history_* and notes_*; then continue the task.";
|
|
20
27
|
|
|
21
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Static protocol teaching adapted from Codex's token_budget.guidance_message to
|
|
30
|
+
* pi-context's tool names. It lives once per window in the persisted boot block;
|
|
31
|
+
* it is never re-injected, so it stays cache-stable at the head of the window.
|
|
32
|
+
*/
|
|
33
|
+
const PROTOCOL_BLOCK = `${CONTEXT_WINDOW_PROTOCOL_OPEN_TAG}
|
|
34
|
+
For tasks that may span context windows, use notes_write_file and notes_append_to_file to maintain a concise checkpoint of the goal, decisions, progress, learnings, and next steps. Include the window ID and item ID of every relevant user request you are currently solving, plus important actions and tool calls. The read-only history_* tools can look up details from those references later. Every non-assistant item (user, tool result) has an item ID returned by history_list_items.
|
|
35
|
+
|
|
36
|
+
Take incremental notes while you work so you do not lose important information. Use get_context_remaining to check the live remaining token budget for planning. Once the token budget is exhausted you lose access to the current window and continue in a fresh context window; you can recover only through notes_* and history_*. Do not over-run the context window without documentation.
|
|
37
|
+
|
|
38
|
+
If a Previous context window id is present in <context_window>, a context reset occurred and this is a fresh window. The old conversation is not automatically included. After a reset, read your note checkpoint and use the read-only history_* tools to recover missing details. When a window ID and item ID are known, prefer history_read_item directly; when they are missing or uncertain, use history_list_items, or history_search_contents to locate the item first.
|
|
39
|
+
|
|
40
|
+
Notes are session-scoped virtual files. Treat notes and history as internal bookkeeping; never mention them in user-facing messages.
|
|
41
|
+
${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
|
|
42
|
+
|
|
22
43
|
const FALLBACK_PROMPT =
|
|
23
|
-
"Context
|
|
44
|
+
"Context budget is almost exhausted. This is the final fallback turn before the window resets automatically. Write task state, decisions, open issues, and next steps with notes_write_file now. Do not start new work; old conversation remains searchable through history_*.";
|
|
45
|
+
|
|
46
|
+
type ResolvedThresholds = { reminder: number; fallback: number };
|
|
47
|
+
type PiContextMargins = { reminderMarginTokens: unknown; fallbackMarginTokens: unknown };
|
|
24
48
|
|
|
25
49
|
type NoteFile = { text: string; createdAt: number; updatedAt: number };
|
|
26
50
|
type NoteOperation = {
|
|
@@ -42,7 +66,6 @@ type HistoryItem = {
|
|
|
42
66
|
type HistoryWindow = { windowId: string; createdAt?: string; items: HistoryItem[] };
|
|
43
67
|
|
|
44
68
|
type HistoryFilter = {
|
|
45
|
-
agent_name?: string | null;
|
|
46
69
|
window_id?: string | null;
|
|
47
70
|
role?: HistoryItem["role"] | null;
|
|
48
71
|
tool_namespace?: string | null;
|
|
@@ -58,12 +81,6 @@ function output(value: unknown, details: unknown = value, terminate = false) {
|
|
|
58
81
|
return { content: [{ type: "text" as const, text: json(value) }], details, terminate };
|
|
59
82
|
}
|
|
60
83
|
|
|
61
|
-
function unsupportedAgent(agentName: string | null | undefined) {
|
|
62
|
-
return agentName !== undefined && agentName !== null
|
|
63
|
-
? { error: "Pi 0.85.1 exposes no cross-agent session routing; agent_name is unsupported and was not aliased to this session." }
|
|
64
|
-
: undefined;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
84
|
function isTextContent(part: unknown): part is TextContent {
|
|
68
85
|
return typeof part === "object" && part !== null && (part as TextContent).type === "text" && typeof (part as TextContent).text === "string";
|
|
69
86
|
}
|
|
@@ -100,6 +117,19 @@ function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName" | "toolNa
|
|
|
100
117
|
return { toolName: message.toolName, toolNamespace: underscore > 0 ? message.toolName.slice(0, underscore) : undefined };
|
|
101
118
|
}
|
|
102
119
|
|
|
120
|
+
/** The extension-owned window id baked onto a reset-v2 compaction entry, if present. */
|
|
121
|
+
function resetV2WindowId(details: unknown): string | undefined {
|
|
122
|
+
if (typeof details !== "object" || details === null) return undefined;
|
|
123
|
+
const candidate = details as { piContext?: unknown; windowId?: unknown };
|
|
124
|
+
if (candidate.piContext !== RESET_V2 || typeof candidate.windowId !== "string") return undefined;
|
|
125
|
+
return candidate.windowId;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
|
|
129
|
+
function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
|
|
130
|
+
return resetV2WindowId(entry.details) ?? `pcw:${sessionId}:${entry.id}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
103
133
|
/** Build durable, on-demand history directly from every entry on the current session branch. */
|
|
104
134
|
export function historyFromSession(ctx: ExtensionContext): HistoryWindow[] {
|
|
105
135
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
@@ -107,7 +137,7 @@ export function historyFromSession(ctx: ExtensionContext): HistoryWindow[] {
|
|
|
107
137
|
const windows = [window];
|
|
108
138
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
109
139
|
if (entry.type === "compaction") {
|
|
110
|
-
window = { windowId:
|
|
140
|
+
window = { windowId: windowIdOf(sessionId, entry), createdAt: entry.timestamp, items: [] };
|
|
111
141
|
windows.push(window);
|
|
112
142
|
window.items.push({
|
|
113
143
|
windowId: window.windowId,
|
|
@@ -160,9 +190,7 @@ function allItems(ctx: ExtensionContext) {
|
|
|
160
190
|
return historyFromSession(ctx).flatMap((window) => window.items);
|
|
161
191
|
}
|
|
162
192
|
|
|
163
|
-
function filteredItems(ctx: ExtensionContext, params: HistoryFilter): HistoryItem[]
|
|
164
|
-
const agentError = unsupportedAgent(params.agent_name);
|
|
165
|
-
if (agentError) return agentError;
|
|
193
|
+
function filteredItems(ctx: ExtensionContext, params: HistoryFilter): HistoryItem[] {
|
|
166
194
|
let items = allItems(ctx);
|
|
167
195
|
if (typeof params.window_id === "string") items = items.filter((item) => item.windowId === params.window_id);
|
|
168
196
|
if (typeof params.role === "string") items = items.filter((item) => item.role === params.role);
|
|
@@ -217,36 +245,54 @@ export function notesFromSession(ctx: ExtensionContext): Map<string, NoteFile> {
|
|
|
217
245
|
return files;
|
|
218
246
|
}
|
|
219
247
|
|
|
220
|
-
/** Codex-
|
|
221
|
-
|
|
222
|
-
const windows = historyFromSession(ctx);
|
|
223
|
-
const first = windows[0];
|
|
224
|
-
const current = windows[windows.length - 1];
|
|
225
|
-
const previous = windows.length > 1 ? windows[windows.length - 2] : undefined;
|
|
248
|
+
/** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
|
|
249
|
+
function identityBlock(agentName: string, firstWindowId: string, currentWindowId: string, previousWindowId?: string): string {
|
|
226
250
|
const lines = [
|
|
227
|
-
`Agent name: ${
|
|
228
|
-
`First context window id: ${
|
|
229
|
-
`Current context window id: ${
|
|
251
|
+
`Agent name: ${agentName}`,
|
|
252
|
+
`First context window id: ${firstWindowId}`,
|
|
253
|
+
`Current context window id: ${currentWindowId}`,
|
|
230
254
|
];
|
|
231
|
-
if (
|
|
255
|
+
if (previousWindowId) lines.push(`Previous context window id: ${previousWindowId}`);
|
|
256
|
+
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Recent-notes index with the existing wording; empty when the session has no notes. */
|
|
260
|
+
function notesIndex(ctx: ExtensionContext): string {
|
|
232
261
|
const recentNotes = [...notesFromSession(ctx)]
|
|
233
262
|
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
|
234
263
|
.slice(0, 5);
|
|
235
|
-
if (recentNotes.length
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
264
|
+
if (recentNotes.length === 0) return "";
|
|
265
|
+
const lines = ["Recent notes (up to 5, most-recent first):"];
|
|
266
|
+
for (const [path, file] of recentNotes) {
|
|
267
|
+
lines.push(`- ${path} (${file.text.split("\n").length} lines, ${Buffer.byteLength(file.text, "utf8")} UTF-8 bytes)`);
|
|
240
268
|
}
|
|
241
|
-
return
|
|
269
|
+
return lines.join("\n");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Assemble the static, once-per-window boot block: the reset line for resets, the
|
|
274
|
+
* <context_window> identity block, the recent-notes index at window-open time, and
|
|
275
|
+
* the <context_window_protocol> teaching block. Nothing here is re-injected, so the
|
|
276
|
+
* head of the window stays cache-stable.
|
|
277
|
+
*/
|
|
278
|
+
function bootBlock(ctx: ExtensionContext, currentId: string, previousId: string | undefined, resetLine: boolean): string {
|
|
279
|
+
const firstId = historyFromSession(ctx)[0]?.windowId ?? currentId;
|
|
280
|
+
const parts: string[] = [];
|
|
281
|
+
if (resetLine) parts.push(RESET_SUMMARY);
|
|
282
|
+
parts.push(identityBlock(ctx.sessionManager.getSessionName() ?? "root", firstId, currentId, previousId));
|
|
283
|
+
const index = notesIndex(ctx);
|
|
284
|
+
if (index) parts.push(index);
|
|
285
|
+
parts.push(PROTOCOL_BLOCK);
|
|
286
|
+
return parts.join("\n\n");
|
|
242
287
|
}
|
|
243
288
|
|
|
244
289
|
/**
|
|
245
|
-
* Codex-equivalent low-budget reminder.
|
|
246
|
-
*
|
|
290
|
+
* Codex-equivalent low-budget reminder. The measured remaining count is frozen into
|
|
291
|
+
* the text at the crossing that fires it, so each persisted copy is a snapshot true
|
|
292
|
+
* at write time; get_context_remaining remains the live source for the current figure.
|
|
247
293
|
*/
|
|
248
|
-
function tokenBudgetGuidance(): string {
|
|
249
|
-
return `${GUIDANCE_OPEN_TAG}\nContext budget is
|
|
294
|
+
function tokenBudgetGuidance(remaining: number): string {
|
|
295
|
+
return `${GUIDANCE_OPEN_TAG}\nContext budget is running low: only ${remaining} tokens remained when this reminder was recorded. Persist task state, decisions, open issues, and next steps with notes_write_file, including the window ID and item ID of relevant user requests for history_* lookups; call new_context when ready to continue in a fresh window. Automatic reset does not guarantee another note-taking turn. get_context_remaining reports the current remaining tokens.\n${GUIDANCE_CLOSE_TAG}`;
|
|
250
296
|
}
|
|
251
297
|
|
|
252
298
|
/** Cheap current-window lookup: scan the branch tail for the latest compaction entry. */
|
|
@@ -255,7 +301,7 @@ function currentWindowId(ctx: ExtensionContext): string {
|
|
|
255
301
|
const branch = ctx.sessionManager.getBranch();
|
|
256
302
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
257
303
|
const entry = branch[i];
|
|
258
|
-
if (entry?.type === "compaction") return
|
|
304
|
+
if (entry?.type === "compaction") return windowIdOf(sessionId, entry);
|
|
259
305
|
}
|
|
260
306
|
return `pcw:${sessionId}:root`;
|
|
261
307
|
}
|
|
@@ -278,21 +324,113 @@ const nullableInteger = () => Type.Optional(Type.Union([Type.Integer(), Type.Nul
|
|
|
278
324
|
const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }));
|
|
279
325
|
const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()]);
|
|
280
326
|
|
|
327
|
+
function isSettingsObject(value: unknown): value is Record<string, unknown> {
|
|
328
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Read the raw "pi-context" object from one parsed settings scope. */
|
|
332
|
+
function piContextSettings(settings: unknown): Record<string, unknown> {
|
|
333
|
+
if (!isSettingsObject(settings)) return {};
|
|
334
|
+
const value = settings[PI_CONTEXT_SETTINGS_KEY];
|
|
335
|
+
return isSettingsObject(value) ? value : {};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
|
|
339
|
+
export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextMargins {
|
|
340
|
+
const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
|
|
341
|
+
return { reminderMarginTokens: merged.reminderMarginTokens, fallbackMarginTokens: merged.fallbackMarginTokens };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** A margin is usable only as a positive integer; anything else is ignored. */
|
|
345
|
+
function validMargin(raw: unknown): number | undefined {
|
|
346
|
+
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return undefined;
|
|
347
|
+
return raw;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Pure derivation of the effective thresholds from Pi's reserve plus the pi-context
|
|
352
|
+
* margins. Invalid margins and a reminder that does not clear the fallback degrade
|
|
353
|
+
* to defaults per offending key and report one warning each.
|
|
354
|
+
*/
|
|
355
|
+
export function deriveThresholds(reserveTokens: number, margins: PiContextMargins): { thresholds: ResolvedThresholds; warnings: string[] } {
|
|
356
|
+
const warnings: string[] = [];
|
|
357
|
+
const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
|
|
358
|
+
const fallbackKey = `${PI_CONTEXT_SETTINGS_KEY}.fallbackMarginTokens`;
|
|
359
|
+
const parsedReminder = validMargin(margins.reminderMarginTokens);
|
|
360
|
+
const parsedFallback = validMargin(margins.fallbackMarginTokens);
|
|
361
|
+
|
|
362
|
+
let reminderMargin: number;
|
|
363
|
+
if (margins.reminderMarginTokens === undefined) reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
364
|
+
else if (parsedReminder === undefined) {
|
|
365
|
+
warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
|
|
366
|
+
reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
367
|
+
} else reminderMargin = parsedReminder;
|
|
368
|
+
|
|
369
|
+
let fallbackMargin: number;
|
|
370
|
+
if (margins.fallbackMarginTokens === undefined) fallbackMargin = DEFAULT_FALLBACK_MARGIN_TOKENS;
|
|
371
|
+
else if (parsedFallback === undefined) {
|
|
372
|
+
warnings.push(`pi-context: ${fallbackKey} must be a positive integer; using default ${DEFAULT_FALLBACK_MARGIN_TOKENS}.`);
|
|
373
|
+
fallbackMargin = DEFAULT_FALLBACK_MARGIN_TOKENS;
|
|
374
|
+
} else fallbackMargin = parsedFallback;
|
|
375
|
+
|
|
376
|
+
if (reminderMargin <= fallbackMargin) {
|
|
377
|
+
warnings.push(`pi-context: ${reminderKey} must exceed ${fallbackKey}; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
|
|
378
|
+
reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
379
|
+
if (reminderMargin <= fallbackMargin) {
|
|
380
|
+
warnings.push(`pi-context: ${fallbackKey} must be below ${reminderKey}; using default ${DEFAULT_FALLBACK_MARGIN_TOKENS}.`);
|
|
381
|
+
fallbackMargin = DEFAULT_FALLBACK_MARGIN_TOKENS;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { thresholds: { reminder: reserveTokens + reminderMargin, fallback: reserveTokens + fallbackMargin }, warnings };
|
|
386
|
+
}
|
|
387
|
+
|
|
281
388
|
export default function piContext(pi: ExtensionAPI) {
|
|
282
|
-
let rollover: "idle" | "requested" | "compacting"
|
|
389
|
+
let rollover: "idle" | "requested" | "compacting" = "idle";
|
|
283
390
|
let enabled = true;
|
|
284
391
|
let guidancePersistedInWindow: string | undefined;
|
|
285
|
-
let
|
|
286
|
-
let
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
392
|
+
let fallbackPersistedInWindow: string | undefined;
|
|
393
|
+
let handledCompactionId: string | undefined;
|
|
394
|
+
let thresholds: ResolvedThresholds | undefined;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Resolve the thresholds for this session from Pi's compaction reserve plus the
|
|
398
|
+
* settings.json "pi-context" margins. The file-backed read is cached until the next
|
|
399
|
+
* session_start; invalid configuration degrades per offending key with one warning
|
|
400
|
+
* and never throws during session operation.
|
|
401
|
+
*/
|
|
402
|
+
const resolveThresholds = (ctx: ExtensionContext): ResolvedThresholds => {
|
|
403
|
+
if (thresholds) return thresholds;
|
|
404
|
+
try {
|
|
405
|
+
const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
|
|
406
|
+
const derived = deriveThresholds(
|
|
407
|
+
settingsManager.getCompactionSettings().reserveTokens,
|
|
408
|
+
mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()),
|
|
409
|
+
);
|
|
410
|
+
for (const warning of derived.warnings) ctx.ui.notify(warning, "warning");
|
|
411
|
+
thresholds = derived.thresholds;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
ctx.ui.notify(`pi-context: could not read settings; using defaults (${String(error)}).`, "warning");
|
|
414
|
+
thresholds = {
|
|
415
|
+
reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS,
|
|
416
|
+
fallback: DEFAULT_RESERVE_TOKENS + DEFAULT_FALLBACK_MARGIN_TOKENS,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return thresholds;
|
|
291
420
|
};
|
|
292
421
|
|
|
293
422
|
pi.on("session_start", (_event, ctx) => {
|
|
423
|
+
// Re-read settings.json on every session start; the resolved values are cached for the session.
|
|
424
|
+
thresholds = undefined;
|
|
425
|
+
resolveThresholds(ctx);
|
|
294
426
|
if (!enabled) return;
|
|
295
|
-
|
|
427
|
+
// The root window has no compaction entry to carry the boot block, so persist
|
|
428
|
+
// it once as a visible custom message. Reset windows already carry theirs at
|
|
429
|
+
// position 0 in the compaction summary, so a resumed session adds nothing.
|
|
430
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
431
|
+
const rootId = `pcw:${sessionId}:root`;
|
|
432
|
+
if (currentWindowId(ctx) !== rootId) return;
|
|
433
|
+
pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: true }, { triggerTurn: false });
|
|
296
434
|
});
|
|
297
435
|
const saveNote = (op: NoteOperation) => {
|
|
298
436
|
// pi.appendEntry writes a custom SessionManager entry. Custom entries are persistent but excluded from LLM context.
|
|
@@ -301,7 +439,7 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
301
439
|
};
|
|
302
440
|
|
|
303
441
|
pi.registerCommand("pi-context", {
|
|
304
|
-
description: "Toggle pi-context: context_window
|
|
442
|
+
description: "Toggle pi-context: context_window boot block, low-budget guidance, and reset-style compaction",
|
|
305
443
|
getArgumentCompletions: (prefix) =>
|
|
306
444
|
["on", "off"].filter((a) => a.startsWith(prefix)).map((a) => ({ value: a, label: a })),
|
|
307
445
|
handler: async (args, cmdCtx) => {
|
|
@@ -319,11 +457,9 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
319
457
|
pi.registerTool(defineTool({
|
|
320
458
|
name: "history_list_windows",
|
|
321
459
|
label: "History list windows",
|
|
322
|
-
description: "List durable Pi session-history windows.
|
|
323
|
-
parameters: Type.Object({ limit: positiveInteger(),
|
|
460
|
+
description: "List durable Pi session-history windows.",
|
|
461
|
+
parameters: Type.Object({ limit: positiveInteger(), recent_first: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
324
462
|
async execute(_id, params, _signal, _update, ctx) {
|
|
325
|
-
const agentError = unsupportedAgent(params.agent_name);
|
|
326
|
-
if (agentError) return output(agentError);
|
|
327
463
|
let windows = historyFromSession(ctx);
|
|
328
464
|
if (params.recent_first) windows = [...windows].reverse();
|
|
329
465
|
const limit = params.limit ?? windows.length;
|
|
@@ -335,10 +471,9 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
335
471
|
name: "history_list_items",
|
|
336
472
|
label: "History list items",
|
|
337
473
|
description: "List durable session items, including items before compaction, using opaque item and window IDs.",
|
|
338
|
-
parameters: Type.Object({ limit: positiveInteger(), recent_first: Type.Optional(Type.Boolean()), tool_namespace: nullableString(), role: Type.Optional(role),
|
|
474
|
+
parameters: Type.Object({ limit: positiveInteger(), recent_first: Type.Optional(Type.Boolean()), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
|
|
339
475
|
async execute(_id, params, _signal, _update, ctx) {
|
|
340
476
|
const items = filteredItems(ctx, params);
|
|
341
|
-
if ("error" in items) return output(items);
|
|
342
477
|
return output({ items: items.slice(0, params.limit ?? items.length).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200)) });
|
|
343
478
|
},
|
|
344
479
|
}));
|
|
@@ -347,10 +482,8 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
347
482
|
name: "history_read_item",
|
|
348
483
|
label: "History read item",
|
|
349
484
|
description: "Read a bounded character range from one durable session item.",
|
|
350
|
-
parameters: Type.Object({
|
|
485
|
+
parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ minimum: 0 })), limit_chars: positiveInteger(), window_id: Type.String() }, { additionalProperties: false }),
|
|
351
486
|
async execute(_id, params, _signal, _update, ctx) {
|
|
352
|
-
const agentError = unsupportedAgent(params.agent_name);
|
|
353
|
-
if (agentError) return output(agentError);
|
|
354
487
|
const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
|
|
355
488
|
if (!item) return output({ error: "unknown item_id or window_id" });
|
|
356
489
|
const chars = Array.from(item.content);
|
|
@@ -364,10 +497,9 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
364
497
|
name: "history_search_contents",
|
|
365
498
|
label: "History search",
|
|
366
499
|
description: "Case-sensitive literal substring search over durable Pi session history; no semantic search.",
|
|
367
|
-
parameters: Type.Object({ limit: positiveInteger(), query: Type.String(), recent_first: Type.Optional(Type.Boolean()), tool_namespace: nullableString(), role: Type.Optional(role),
|
|
500
|
+
parameters: Type.Object({ limit: positiveInteger(), query: Type.String(), recent_first: Type.Optional(Type.Boolean()), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString() }, { additionalProperties: false }),
|
|
368
501
|
async execute(_id, params, _signal, _update, ctx) {
|
|
369
502
|
const items = filteredItems(ctx, params);
|
|
370
|
-
if ("error" in items) return output(items);
|
|
371
503
|
const matching = items.filter((item) => item.content.includes(params.query));
|
|
372
504
|
return output({ items: matching.slice(0, params.limit ?? matching.length).map((item) => visibleItem(item)) });
|
|
373
505
|
},
|
|
@@ -435,28 +567,72 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
435
567
|
}));
|
|
436
568
|
}
|
|
437
569
|
|
|
438
|
-
|
|
570
|
+
const fallbackGuidance = () => `${GUIDANCE_OPEN_TAG}\n${FALLBACK_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
|
|
571
|
+
|
|
572
|
+
pi.on("context", (_event, ctx) => {
|
|
573
|
+
if (!enabled) return undefined;
|
|
574
|
+
// This hook does exactly one thing: persist the once-per-window low-budget
|
|
575
|
+
// reminder the first time remaining context crosses the reminder threshold.
|
|
576
|
+
// It never injects messages into the request.
|
|
577
|
+
const usage = ctx.getContextUsage();
|
|
578
|
+
if (usage && usage.tokens !== null) {
|
|
579
|
+
const remaining = Math.max(0, usage.contextWindow - usage.tokens);
|
|
580
|
+
const windowId = currentWindowId(ctx);
|
|
581
|
+
if (remaining <= resolveThresholds(ctx).reminder && guidancePersistedInWindow !== windowId) {
|
|
582
|
+
guidancePersistedInWindow = windowId;
|
|
583
|
+
// Persist once per window — no transient copy. A transient bridge would
|
|
584
|
+
// cover the crossing request, but history would record the reminder after
|
|
585
|
+
// that request's assistant reply, so across the boundary the model would
|
|
586
|
+
// meet the same text twice at shifted positions. The reminder is an early
|
|
587
|
+
// warning, not a per-request instruction: arriving from the next request
|
|
588
|
+
// on (sendMessage defers safely to end of turn while streaming, queueing
|
|
589
|
+
// instead of splitting a tool call/result pair) costs nothing, and the
|
|
590
|
+
// model's view stays identical to recorded history, Codex-style.
|
|
591
|
+
pi.sendMessage({ customType: GUIDANCE_TYPE, content: tokenBudgetGuidance(remaining), display: true }, { triggerTurn: false });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return undefined;
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Graceful fallback without intercepting user input: before a fresh prompt, if
|
|
598
|
+
// remaining context has entered the buffer between this threshold and Pi's
|
|
599
|
+
// reserve line, append a persistent user-level final-call instruction. Pi then
|
|
600
|
+
// runs that turn with the user's queued prompt still present and runs its own
|
|
601
|
+
// automatic compaction before the following prompt. Overflow is excluded: Pi
|
|
602
|
+
// already owns its one-shot compact-and-retry recovery.
|
|
603
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
439
604
|
if (!enabled) return undefined;
|
|
440
605
|
const usage = ctx.getContextUsage();
|
|
441
606
|
if (!usage || usage.tokens === null) return undefined;
|
|
442
607
|
const remaining = Math.max(0, usage.contextWindow - usage.tokens);
|
|
443
|
-
if (remaining >
|
|
608
|
+
if (remaining > resolveThresholds(ctx).fallback) return undefined;
|
|
609
|
+
const windowId = currentWindowId(ctx);
|
|
610
|
+
if (fallbackPersistedInWindow === windowId) return undefined;
|
|
611
|
+
fallbackPersistedInWindow = windowId;
|
|
612
|
+
return { message: { customType: FALLBACK_TYPE, content: FALLBACK_PROMPT, display: true } };
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
616
|
+
if (!enabled) return undefined;
|
|
617
|
+
// Streaming case only: while the agent is streaming, triggerTurn:true routes
|
|
618
|
+
// to agent.steer() — Pi drains the steering queue after this turn_end and
|
|
619
|
+
// injects the message before the next LLM call, extending the current run by
|
|
620
|
+
// one note-taking turn. A queued user prompt (follow-up) drains only when the
|
|
621
|
+
// agent would stop, so it is processed after the notes turn. (Defensive: in
|
|
622
|
+
// v0.85.1 turn_end always fires inside an active run, so isIdle is never
|
|
623
|
+
// true here; the idle pre-prompt case is owned by before_agent_start above.)
|
|
624
|
+
if (ctx.isIdle()) return undefined;
|
|
625
|
+
const usage = ctx.getContextUsage();
|
|
626
|
+
if (!usage || usage.tokens === null) return;
|
|
627
|
+
const remaining = Math.max(0, usage.contextWindow - usage.tokens);
|
|
628
|
+
if (remaining > resolveThresholds(ctx).fallback) return;
|
|
444
629
|
const windowId = currentWindowId(ctx);
|
|
445
|
-
if (
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
// appended, static, and one-shot, so the cached prefix survives.
|
|
452
|
-
const text = tokenBudgetGuidance();
|
|
453
|
-
pi.sendMessage({ customType: GUIDANCE_TYPE, content: text, display: true }, { triggerTurn: false });
|
|
454
|
-
const guidance = {
|
|
455
|
-
role: "user" as const,
|
|
456
|
-
content: [{ type: "text" as const, text }],
|
|
457
|
-
timestamp: Date.now(),
|
|
458
|
-
};
|
|
459
|
-
return { messages: [...event.messages, guidance] };
|
|
630
|
+
if (fallbackPersistedInWindow === windowId) return;
|
|
631
|
+
if (rollover !== "idle") return;
|
|
632
|
+
fallbackPersistedInWindow = windowId;
|
|
633
|
+
// The steered message reaches the model before the pending user input and no
|
|
634
|
+
// input text/images are copied or replayed.
|
|
635
|
+
pi.sendMessage({ customType: FALLBACK_TYPE, content: fallbackGuidance(), display: true }, { triggerTurn: true });
|
|
460
636
|
});
|
|
461
637
|
|
|
462
638
|
pi.registerTool(defineTool({
|
|
@@ -497,27 +673,30 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
497
673
|
if (!enabled) return undefined; // Default Pi compaction applies; keepRecentTokens is honored again.
|
|
498
674
|
// Never let an aborted or failed custom reset fall through to Pi's default summary.
|
|
499
675
|
if (event.signal.aborted) return { cancel: true };
|
|
500
|
-
//
|
|
501
|
-
// -
|
|
502
|
-
// note-taking turn in; _runAutoCompaction then returns hasQueuedMessages() and the
|
|
503
|
-
// post-run loop delivers it via agent.continue(). Safe, intended path.
|
|
504
|
-
// - threshold, pre-prompt (idle): cancelling still sends the user prompt with an
|
|
505
|
-
// over-threshold context, and sendMessage would race _runAgentPrompt. Reset instead.
|
|
506
|
-
// - overflow: never cancel; that would abandon Pi's one-shot compact-and-retry recovery.
|
|
507
|
-
if (event.reason === "threshold" && !ctx.isIdle()) {
|
|
508
|
-
const windowId = currentWindowId(ctx);
|
|
509
|
-
if (fallbackSentInWindow !== windowId) {
|
|
510
|
-
fallbackSentInWindow = windowId;
|
|
511
|
-
continueAfterFallback = true;
|
|
512
|
-
pi.sendMessage({ customType: FALLBACK_TYPE, content: FALLBACK_PROMPT, display: true }, { triggerTurn: true });
|
|
513
|
-
return { cancel: true };
|
|
514
|
-
}
|
|
515
|
-
}
|
|
676
|
+
// Every compaction uses the same reset path. Never cancel to borrow a
|
|
677
|
+
// note-taking turn: Pi owns user input, queued work, and overflow recovery.
|
|
516
678
|
try {
|
|
679
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
680
|
+
// Pi mints the compaction entry id only after this hook returns, so the
|
|
681
|
+
// extension mints and owns the window id now, avoiding collisions with any
|
|
682
|
+
// existing entry id, and bakes it into the summary and details.
|
|
683
|
+
let minted = randomUUID().slice(0, 8);
|
|
684
|
+
while (ctx.sessionManager.getEntry(minted)) minted = randomUUID().slice(0, 8);
|
|
685
|
+
const windowId = `pcw:${sessionId}:${minted}`;
|
|
686
|
+
const windows = historyFromSession(ctx);
|
|
687
|
+
const previousId = windows[windows.length - 1]?.windowId ?? `pcw:${sessionId}:root`;
|
|
688
|
+
// The reset marker stays as firstKeptEntryId; it no longer names the window.
|
|
517
689
|
pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: rollover === "compacting" });
|
|
518
690
|
const markerId = ctx.sessionManager.getLeafId();
|
|
519
691
|
if (!markerId) return { cancel: true };
|
|
520
|
-
return {
|
|
692
|
+
return {
|
|
693
|
+
compaction: {
|
|
694
|
+
summary: bootBlock(ctx, windowId, previousId, true),
|
|
695
|
+
firstKeptEntryId: markerId,
|
|
696
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
697
|
+
details: { piContext: RESET_V2, windowId },
|
|
698
|
+
},
|
|
699
|
+
};
|
|
521
700
|
} catch {
|
|
522
701
|
return { cancel: true };
|
|
523
702
|
}
|
|
@@ -525,20 +704,21 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
525
704
|
|
|
526
705
|
pi.on("session_compact", (event, ctx) => {
|
|
527
706
|
if (!enabled) {
|
|
528
|
-
|
|
707
|
+
rollover = "idle";
|
|
529
708
|
return;
|
|
530
709
|
}
|
|
531
|
-
|
|
532
|
-
if (
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId:
|
|
540
|
-
|
|
541
|
-
|
|
710
|
+
const entry = ctx.sessionManager.getEntry(event.compactionEntry.id);
|
|
711
|
+
if (entry?.type !== "compaction" || resetV2WindowId(entry.details) === undefined) return;
|
|
712
|
+
if (handledCompactionId === entry.id) return;
|
|
713
|
+
handledCompactionId = entry.id;
|
|
714
|
+
// Only explicit new_context needs an extension-owned continuation.
|
|
715
|
+
// Automatic resets/retries and user /compact keep Pi's native scheduling.
|
|
716
|
+
const shouldContinue = rollover === "compacting" && !event.willRetry;
|
|
717
|
+
rollover = "idle";
|
|
718
|
+
pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: entry.id });
|
|
719
|
+
if (shouldContinue) {
|
|
720
|
+
pi.sendMessage({ customType: CONTINUATION_TYPE, content: CONTINUATION, display: false }, { triggerTurn: true });
|
|
721
|
+
}
|
|
542
722
|
});
|
|
543
723
|
|
|
544
724
|
pi.on("session_compact_failed", () => {
|
|
@@ -546,4 +726,4 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
546
726
|
});
|
|
547
727
|
}
|
|
548
728
|
|
|
549
|
-
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE,
|
|
729
|
+
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, FALLBACK_TYPE, FALLBACK_PROMPT, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, DEFAULT_FALLBACK_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, lineRange, assertVirtualPath };
|