@astrosheep/pi-context 0.9.0 → 0.10.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 +9 -7
- package/docs/architecture.md +39 -0
- package/docs/reset-lifecycle.md +21 -0
- package/package.json +3 -2
- package/src/budget.ts +122 -0
- package/src/history-tools.ts +59 -0
- package/src/history.ts +166 -0
- package/src/index.ts +37 -693
- package/src/note-tools.ts +76 -0
- package/src/notes.ts +85 -0
- package/src/prompts.ts +69 -0
- package/src/protocol.ts +43 -0
- package/src/reset-lifecycle.ts +125 -0
- package/src/session-reader.ts +6 -0
- package/src/tool-output.ts +8 -0
- package/src/tool-schema.ts +7 -0
package/src/index.ts
CHANGED
|
@@ -1,461 +1,33 @@
|
|
|
1
|
+
import { registerHistoryTools } from "./history-tools.js";
|
|
2
|
+
import { registerNoteTools } from "./note-tools.js";
|
|
3
|
+
import { registerBudget, deriveThresholds, mergePiContextSettings } from "./budget.js";
|
|
4
|
+
import { output } from "./tool-output.js";
|
|
5
|
+
export { deriveThresholds, mergePiContextSettings } from "./budget.js";
|
|
6
|
+
import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, FALLBACK_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, RESET_SUMMARY, CONTINUATION, FALLBACK_PROMPT } from "./protocol.js";
|
|
7
|
+
import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId } from "./history.js";
|
|
8
|
+
import { assertVirtualPath, lineRange } from "./notes.js";
|
|
9
|
+
import { bootBlock } from "./prompts.js";
|
|
10
|
+
export { historyFromSession } from "./history.js";
|
|
11
|
+
export { notesFromSession } from "./notes.js";
|
|
12
|
+
import { registerResetLifecycle } from "./reset-lifecycle.js";
|
|
1
13
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { Type
|
|
3
|
-
import type
|
|
4
|
-
import { defineTool, SettingsManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
|
|
6
|
-
const STATE_TYPE = "pi-context/state";
|
|
7
|
-
const NOTE_TYPE = "pi-context/note";
|
|
8
|
-
const BOOT_TYPE = "pi-context/boot";
|
|
9
|
-
const GUIDANCE_TYPE = "pi-context/guidance";
|
|
10
|
-
const FALLBACK_TYPE = "pi-context/fallback";
|
|
11
|
-
const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
12
|
-
const CONTINUATION_TYPE = "pi-context/continuation";
|
|
13
|
-
const RESET_V2 = "reset-v2";
|
|
14
|
-
const MAX_NOTE_BYTES = 1_000_000;
|
|
15
|
-
const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
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>";
|
|
19
|
-
const GUIDANCE_OPEN_TAG = "<context_window_guidance>";
|
|
20
|
-
const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
|
|
21
|
-
const PI_CONTEXT_SETTINGS_KEY = "pi-context";
|
|
22
|
-
const DEFAULT_RESERVE_TOKENS = 16_384;
|
|
23
|
-
const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
|
|
24
|
-
const RESET_SUMMARY =
|
|
25
|
-
"Context window reset: this is a fresh window. The previous conversation is not included and no summary was generated. Notes and durable session history persist across windows.";
|
|
26
|
-
const NOTE_PREVIEW_HEAD_CHARS = 120;
|
|
27
|
-
const NOTE_PREVIEW_TAIL_CHARS = 80;
|
|
28
|
-
const NOTE_PREVIEW_CHARS = NOTE_PREVIEW_HEAD_CHARS + NOTE_PREVIEW_TAIL_CHARS;
|
|
29
|
-
const CONTINUATION = "This is a fresh context window. Recover only the details needed to continue with history_* and notes_*; then continue the task.";
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Static protocol teaching adapted from Codex's token_budget.guidance_message to
|
|
33
|
-
* pi-context's tool names. It lives once per window in the persisted boot block;
|
|
34
|
-
* it is never re-injected, so it stays cache-stable at the head of the window.
|
|
35
|
-
*/
|
|
36
|
-
const PROTOCOL_BLOCK = `${CONTEXT_WINDOW_PROTOCOL_OPEN_TAG}
|
|
37
|
-
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.
|
|
38
|
-
|
|
39
|
-
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.
|
|
40
|
-
|
|
41
|
-
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.
|
|
42
|
-
|
|
43
|
-
Notes are session-scoped virtual files. Treat notes and history as internal bookkeeping; never mention them in user-facing messages.
|
|
44
|
-
${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
|
|
45
|
-
|
|
46
|
-
const FALLBACK_PROMPT =
|
|
47
|
-
"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_*.";
|
|
48
|
-
|
|
49
|
-
type ResolvedThresholds = { reminder: number };
|
|
50
|
-
type PiContextMargins = { reminderMarginTokens: unknown };
|
|
51
|
-
|
|
52
|
-
type NoteFile = { text: string; createdAt: number; updatedAt: number };
|
|
53
|
-
type NoteOperation = {
|
|
54
|
-
op: "write" | "append";
|
|
55
|
-
path: string;
|
|
56
|
-
text: string;
|
|
57
|
-
createdAt: number;
|
|
58
|
-
updatedAt: number;
|
|
59
|
-
};
|
|
60
|
-
type HistoryItem = {
|
|
61
|
-
windowId: string;
|
|
62
|
-
itemId: string;
|
|
63
|
-
role: "user" | "assistant" | "tool" | "system" | "developer";
|
|
64
|
-
content: string;
|
|
65
|
-
createdAt: string | undefined;
|
|
66
|
-
toolName?: string;
|
|
67
|
-
toolNamespace?: string;
|
|
68
|
-
};
|
|
69
|
-
type HistoryWindow = { windowId: string; createdAt?: string; items: HistoryItem[] };
|
|
70
|
-
|
|
71
|
-
type HistoryFilter = {
|
|
72
|
-
window_id?: string | null;
|
|
73
|
-
role?: HistoryItem["role"] | null;
|
|
74
|
-
tool_namespace?: string | null;
|
|
75
|
-
tool_name?: string | null;
|
|
76
|
-
recent_first?: boolean;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
function json(value: unknown): string {
|
|
80
|
-
return JSON.stringify(value, null, 2);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function output(value: unknown, details: unknown = value, terminate = false) {
|
|
84
|
-
return { content: [{ type: "text" as const, text: json(value) }], details, terminate };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function isTextContent(part: unknown): part is TextContent {
|
|
88
|
-
return typeof part === "object" && part !== null && (part as TextContent).type === "text" && typeof (part as TextContent).text === "string";
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function contentText(content: string | unknown[]): string {
|
|
92
|
-
if (typeof content === "string") return content;
|
|
93
|
-
return content.filter(isTextContent).map((part) => part.text).join("\n");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function mapRole(role: AgentMessage["role"]): HistoryItem["role"] | undefined {
|
|
97
|
-
if (role === "user" || role === "assistant") return role;
|
|
98
|
-
if (role === "toolResult" || role === "bashExecution") return "tool";
|
|
99
|
-
if (role === "custom") return "user";
|
|
100
|
-
if (role === "compactionSummary" || role === "branchSummary") return "system";
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function messageContent(message: AgentMessage): string {
|
|
105
|
-
switch (message.role) {
|
|
106
|
-
case "bashExecution":
|
|
107
|
-
return message.output;
|
|
108
|
-
case "branchSummary":
|
|
109
|
-
case "compactionSummary":
|
|
110
|
-
return message.summary;
|
|
111
|
-
default:
|
|
112
|
-
return contentText(message.content);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName" | "toolNamespace"> {
|
|
117
|
-
if (message.role === "bashExecution") return { toolName: "bash", toolNamespace: undefined };
|
|
118
|
-
if (message.role !== "toolResult") return {};
|
|
119
|
-
const underscore = message.toolName.indexOf("_");
|
|
120
|
-
return { toolName: message.toolName, toolNamespace: underscore > 0 ? message.toolName.slice(0, underscore) : undefined };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** The extension-owned window id baked onto a reset-v2 compaction entry, if present. */
|
|
124
|
-
function resetV2WindowId(details: unknown): string | undefined {
|
|
125
|
-
if (typeof details !== "object" || details === null) return undefined;
|
|
126
|
-
const candidate = details as { piContext?: unknown; windowId?: unknown };
|
|
127
|
-
if (candidate.piContext !== RESET_V2 || typeof candidate.windowId !== "string") return undefined;
|
|
128
|
-
return candidate.windowId;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
|
|
132
|
-
function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
|
|
133
|
-
return resetV2WindowId(entry.details) ?? `pcw:${sessionId}:${entry.id}`;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Build durable, on-demand history directly from every entry on the current session branch. */
|
|
137
|
-
export function historyFromSession(ctx: ExtensionContext): HistoryWindow[] {
|
|
138
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
139
|
-
let window: HistoryWindow = { windowId: `pcw:${sessionId}:root`, items: [] };
|
|
140
|
-
const windows = [window];
|
|
141
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
142
|
-
if (entry.type === "compaction") {
|
|
143
|
-
window = { windowId: windowIdOf(sessionId, entry), createdAt: entry.timestamp, items: [] };
|
|
144
|
-
windows.push(window);
|
|
145
|
-
window.items.push({
|
|
146
|
-
windowId: window.windowId,
|
|
147
|
-
itemId: entry.id,
|
|
148
|
-
role: "system",
|
|
149
|
-
content: entry.summary,
|
|
150
|
-
createdAt: entry.timestamp,
|
|
151
|
-
});
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
if (entry.type === "message") {
|
|
155
|
-
const role = mapRole(entry.message.role);
|
|
156
|
-
if (!role) continue;
|
|
157
|
-
window.items.push({
|
|
158
|
-
windowId: window.windowId,
|
|
159
|
-
itemId: entry.id,
|
|
160
|
-
role,
|
|
161
|
-
content: messageContent(entry.message),
|
|
162
|
-
createdAt: entry.timestamp,
|
|
163
|
-
...toolInfo(entry.message),
|
|
164
|
-
});
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
if (entry.type === "custom_message") {
|
|
168
|
-
window.items.push({
|
|
169
|
-
windowId: window.windowId,
|
|
170
|
-
itemId: entry.id,
|
|
171
|
-
role: "user",
|
|
172
|
-
content: contentText(entry.content),
|
|
173
|
-
createdAt: entry.timestamp,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return windows;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function visibleItem(item: HistoryItem, maxChars = 1200) {
|
|
181
|
-
const characters = Array.from(item.content);
|
|
182
|
-
return {
|
|
183
|
-
window_id: item.windowId,
|
|
184
|
-
item_id: item.itemId,
|
|
185
|
-
role: item.role,
|
|
186
|
-
tool_namespace: item.toolNamespace ?? null,
|
|
187
|
-
tool_name: item.toolName ?? null,
|
|
188
|
-
truncated_content: characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}…` : item.content,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function allItems(ctx: ExtensionContext) {
|
|
193
|
-
return historyFromSession(ctx).flatMap((window) => window.items);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function filteredItems(ctx: ExtensionContext, params: HistoryFilter): HistoryItem[] {
|
|
197
|
-
let items = allItems(ctx);
|
|
198
|
-
if (typeof params.window_id === "string") items = items.filter((item) => item.windowId === params.window_id);
|
|
199
|
-
if (typeof params.role === "string") items = items.filter((item) => item.role === params.role);
|
|
200
|
-
if (typeof params.tool_namespace === "string") items = items.filter((item) => item.toolNamespace === params.tool_namespace);
|
|
201
|
-
if (typeof params.tool_name === "string") items = items.filter((item) => item.toolName === params.tool_name);
|
|
202
|
-
if (params.recent_first !== false) items.reverse();
|
|
203
|
-
return items;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function assertVirtualPath(value: unknown): string {
|
|
207
|
-
if (typeof value !== "string" || value.length === 0) throw new Error("path must be a non-empty virtual relative path");
|
|
208
|
-
if (value.includes("\0") || value.includes("\\") || value.startsWith("/")) throw new Error("path must be a safe virtual relative path");
|
|
209
|
-
const parts = value.split("/");
|
|
210
|
-
if (parts.some((part) => part.length === 0 || part === "." || part === "..")) throw new Error("path contains an unsupported component");
|
|
211
|
-
return value;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function assertVirtualPrefix(value: unknown): string | undefined {
|
|
215
|
-
if (value === undefined || value === null || value === "") return undefined;
|
|
216
|
-
return assertVirtualPath(value);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Replays only pi-context note operations from session custom entries. */
|
|
220
|
-
function isNoteOperation(data: unknown): data is NoteOperation {
|
|
221
|
-
if (typeof data !== "object" || data === null) return false;
|
|
222
|
-
const op = data as Partial<NoteOperation>;
|
|
223
|
-
return (
|
|
224
|
-
(op.op === "write" || op.op === "append") &&
|
|
225
|
-
typeof op.path === "string" &&
|
|
226
|
-
typeof op.text === "string" &&
|
|
227
|
-
typeof op.createdAt === "number" &&
|
|
228
|
-
typeof op.updatedAt === "number"
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export function notesFromSession(ctx: ExtensionContext): Map<string, NoteFile> {
|
|
233
|
-
const files = new Map<string, NoteFile>();
|
|
234
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
235
|
-
if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data)) continue;
|
|
236
|
-
const op = entry.data;
|
|
237
|
-
try {
|
|
238
|
-
assertVirtualPath(op.path);
|
|
239
|
-
} catch {
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
const previous = files.get(op.path);
|
|
243
|
-
const text = op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text;
|
|
244
|
-
if (Buffer.byteLength(text, "utf8") <= MAX_NOTE_BYTES) {
|
|
245
|
-
files.set(op.path, { text, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
return files;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
|
|
252
|
-
function identityBlock(agentName: string, firstWindowId: string, currentWindowId: string, previousWindowId?: string): string {
|
|
253
|
-
const lines = [
|
|
254
|
-
`Agent name: ${agentName}`,
|
|
255
|
-
`First context window id: ${firstWindowId}`,
|
|
256
|
-
`Current context window id: ${currentWindowId}`,
|
|
257
|
-
];
|
|
258
|
-
if (previousWindowId) lines.push(`Previous context window id: ${previousWindowId}`);
|
|
259
|
-
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Recent-notes index: up to three most-recent notes. Each note shows its path, line count,
|
|
264
|
-
* UTF-8 byte count and local ISO update time, followed by an indented inline preview: the
|
|
265
|
-
* whole text when it fits in NOTE_PREVIEW_CHARS, otherwise its first NOTE_PREVIEW_HEAD_CHARS
|
|
266
|
-
* and last NOTE_PREVIEW_TAIL_CHARS Unicode characters joined by an explicit ellipsis. The
|
|
267
|
-
* two slices never overlap, so the preview never duplicates head content as tail content.
|
|
268
|
-
* Empty when the session has no notes.
|
|
269
|
-
*/
|
|
270
|
-
function notesIndex(ctx: ExtensionContext): string {
|
|
271
|
-
const recentNotes = [...notesFromSession(ctx)]
|
|
272
|
-
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
|
273
|
-
.slice(0, 3);
|
|
274
|
-
if (recentNotes.length === 0) return "";
|
|
275
|
-
const lines = ["Recent notes at window open (up to 3, most-recent first):"];
|
|
276
|
-
for (const [path, file] of recentNotes) {
|
|
277
|
-
lines.push(`- ${path} (${file.text.split("\n").length} lines, ${Buffer.byteLength(file.text, "utf8")} UTF-8 bytes, updated ${localIso(file.updatedAt)})`);
|
|
278
|
-
const chars = Array.from(file.text);
|
|
279
|
-
// Short notes stay whole; long notes keep both ends. head + tail <= NOTE_PREVIEW_CHARS < chars.length,
|
|
280
|
-
// so the slices are disjoint and no character is shown twice.
|
|
281
|
-
const preview = chars.length <= NOTE_PREVIEW_CHARS
|
|
282
|
-
? file.text
|
|
283
|
-
: `${chars.slice(0, NOTE_PREVIEW_HEAD_CHARS).join("")}…${chars.slice(chars.length - NOTE_PREVIEW_TAIL_CHARS).join("")}`;
|
|
284
|
-
lines.push(preview.split("\n").map((line) => ` ${line}`).join("\n"));
|
|
285
|
-
}
|
|
286
|
-
return lines.join("\n");
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Assemble the static, once-per-window boot block: the reset line for resets, the
|
|
291
|
-
* <context_window> identity block, the recent-notes index at window-open time, and
|
|
292
|
-
* the <context_window_protocol> teaching block. Nothing here is re-injected, so the
|
|
293
|
-
* head of the window stays cache-stable.
|
|
294
|
-
*/
|
|
295
|
-
function bootBlock(ctx: ExtensionContext, currentId: string, previousId: string | undefined, resetLine: boolean): string {
|
|
296
|
-
const firstId = historyFromSession(ctx)[0]?.windowId ?? currentId;
|
|
297
|
-
const parts: string[] = [];
|
|
298
|
-
if (resetLine) parts.push(RESET_SUMMARY);
|
|
299
|
-
parts.push(identityBlock(ctx.sessionManager.getSessionName() ?? "root", firstId, currentId, previousId));
|
|
300
|
-
const index = notesIndex(ctx);
|
|
301
|
-
if (index) parts.push(index);
|
|
302
|
-
parts.push(PROTOCOL_BLOCK);
|
|
303
|
-
return parts.join("\n\n");
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Codex-equivalent low-budget reminder. The measured remaining count is frozen into
|
|
308
|
-
* the text at the crossing that fires it, so each persisted copy is a snapshot true
|
|
309
|
-
* at write time; get_context_remaining remains the live source for the current figure.
|
|
310
|
-
*/
|
|
311
|
-
function tokenBudgetGuidance(remaining: number): string {
|
|
312
|
-
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}`;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** Cheap current-window lookup: scan the branch tail for the latest compaction entry. */
|
|
316
|
-
function currentWindowId(ctx: ExtensionContext): string {
|
|
317
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
318
|
-
const branch = ctx.sessionManager.getBranch();
|
|
319
|
-
for (let i = branch.length - 1; i >= 0; i--) {
|
|
320
|
-
const entry = branch[i];
|
|
321
|
-
if (entry?.type === "compaction") return windowIdOf(sessionId, entry);
|
|
322
|
-
}
|
|
323
|
-
return `pcw:${sessionId}:root`;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function lineRange(text: string, startValue: unknown, stopValue: unknown) {
|
|
327
|
-
const lines = text.split("\n");
|
|
328
|
-
const resolve = (value: unknown, fallback: number) => {
|
|
329
|
-
if (value === undefined || value === null) return fallback;
|
|
330
|
-
if (!Number.isInteger(value) || value === 0) throw new Error("line numbers must be non-zero integers; negative values count from the end");
|
|
331
|
-
const line = value as number;
|
|
332
|
-
return line > 0 ? line : lines.length + line + 1;
|
|
333
|
-
};
|
|
334
|
-
const start = Math.max(1, resolve(startValue, 1));
|
|
335
|
-
const stop = Math.min(lines.length, resolve(stopValue, lines.length));
|
|
336
|
-
return { start_line: start, stop_line: stop, content: start > stop ? "" : lines.slice(start - 1, stop).join("\n") };
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const pad2 = (value: number) => String(value).padStart(2, "0");
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
|
|
343
|
-
* explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
|
|
344
|
-
* "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
|
|
345
|
-
*/
|
|
346
|
-
function localIso(epochMs: number): string {
|
|
347
|
-
const date = new Date(epochMs);
|
|
348
|
-
const offsetMinutes = -date.getTimezoneOffset();
|
|
349
|
-
const absOffset = Math.abs(offsetMinutes);
|
|
350
|
-
const offset = `${offsetMinutes < 0 ? "-" : "+"}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
|
|
351
|
-
const wallClock = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`;
|
|
352
|
-
return `${wallClock}${offset}`;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const nullableString = () => Type.Optional(Type.Union([Type.String(), Type.Null()]));
|
|
356
|
-
const nullableInteger = () => Type.Optional(Type.Union([Type.Integer(), Type.Null()]));
|
|
357
|
-
const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }));
|
|
358
|
-
const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
|
|
359
|
-
const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()]);
|
|
360
|
-
|
|
361
|
-
function isSettingsObject(value: unknown): value is Record<string, unknown> {
|
|
362
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/** Read the raw "pi-context" object from one parsed settings scope. */
|
|
366
|
-
function piContextSettings(settings: unknown): Record<string, unknown> {
|
|
367
|
-
if (!isSettingsObject(settings)) return {};
|
|
368
|
-
const value = settings[PI_CONTEXT_SETTINGS_KEY];
|
|
369
|
-
return isSettingsObject(value) ? value : {};
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
|
|
373
|
-
export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextMargins {
|
|
374
|
-
const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
|
|
375
|
-
return { reminderMarginTokens: merged.reminderMarginTokens };
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** A margin is usable only as a positive integer; anything else is ignored. */
|
|
379
|
-
function validMargin(raw: unknown): number | undefined {
|
|
380
|
-
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return undefined;
|
|
381
|
-
return raw;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Pure derivation of the reminder threshold from Pi's reserve plus the pi-context
|
|
386
|
-
* reminder margin. An invalid margin degrades to the default and reports one warning.
|
|
387
|
-
* The borrowed fallback turn has no token threshold of its own: it is driven by Pi's
|
|
388
|
-
* automatic threshold/overflow compaction request (see session_before_compact).
|
|
389
|
-
*/
|
|
390
|
-
export function deriveThresholds(reserveTokens: number, margins: PiContextMargins): { thresholds: ResolvedThresholds; warnings: string[] } {
|
|
391
|
-
const warnings: string[] = [];
|
|
392
|
-
const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
|
|
393
|
-
let reminderMargin: number;
|
|
394
|
-
if (margins.reminderMarginTokens === undefined) reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
395
|
-
else {
|
|
396
|
-
const parsed = validMargin(margins.reminderMarginTokens);
|
|
397
|
-
if (parsed === undefined) {
|
|
398
|
-
warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
|
|
399
|
-
reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
400
|
-
} else reminderMargin = parsed;
|
|
401
|
-
}
|
|
402
|
-
return { thresholds: { reminder: reserveTokens + reminderMargin }, warnings };
|
|
403
|
-
}
|
|
14
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
15
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
404
16
|
|
|
405
17
|
export default function piContext(pi: ExtensionAPI) {
|
|
406
|
-
let rollover: "idle" | "requested" | "compacting" = "idle";
|
|
407
18
|
let enabled = true;
|
|
408
|
-
|
|
409
|
-
let handledCompactionId: string | undefined;
|
|
410
|
-
// Two-phase main-line fallback. The first automatic threshold/overflow compaction
|
|
411
|
-
// borrows one final note-taking turn (cancel + steer) instead of resetting at once;
|
|
412
|
-
// the next compaction request is allowed through. The phase only returns to "idle"
|
|
413
|
-
// after a completed reset, so the cancel happens at most once per window and a failed
|
|
414
|
-
// reset retries the real compaction instead of borrowing another turn.
|
|
415
|
-
let fallbackPhase: "idle" | "steered" | "allow" | "requested" = "idle";
|
|
416
|
-
let thresholds: ResolvedThresholds | undefined;
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Resolve the thresholds for this session from Pi's compaction reserve plus the
|
|
420
|
-
* settings.json "pi-context" margins. The file-backed read is cached until the next
|
|
421
|
-
* session_start; invalid configuration degrades per offending key with one warning
|
|
422
|
-
* and never throws during session operation.
|
|
423
|
-
*/
|
|
424
|
-
const resolveThresholds = (ctx: ExtensionContext): ResolvedThresholds => {
|
|
425
|
-
if (thresholds) return thresholds;
|
|
426
|
-
try {
|
|
427
|
-
const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
|
|
428
|
-
const derived = deriveThresholds(
|
|
429
|
-
settingsManager.getCompactionSettings().reserveTokens,
|
|
430
|
-
mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()),
|
|
431
|
-
);
|
|
432
|
-
for (const warning of derived.warnings) ctx.ui.notify(warning, "warning");
|
|
433
|
-
thresholds = derived.thresholds;
|
|
434
|
-
} catch (error) {
|
|
435
|
-
ctx.ui.notify(`pi-context: could not read settings; using defaults (${String(error)}).`, "warning");
|
|
436
|
-
thresholds = { reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS };
|
|
437
|
-
}
|
|
438
|
-
return thresholds;
|
|
439
|
-
};
|
|
19
|
+
registerBudget(pi, () => enabled);
|
|
440
20
|
|
|
441
21
|
pi.on("session_start", (_event, ctx) => {
|
|
442
|
-
// Re-read settings.json on every session start; the resolved values are cached for the session.
|
|
443
|
-
thresholds = undefined;
|
|
444
|
-
resolveThresholds(ctx);
|
|
445
22
|
if (!enabled) return;
|
|
446
23
|
// The root window has no compaction entry to carry the boot block, so persist
|
|
447
24
|
// it once as a visible custom message. Reset windows already carry theirs at
|
|
448
25
|
// position 0 in the compaction summary, so a resumed session adds nothing.
|
|
449
26
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
450
27
|
const rootId = `pcw:${sessionId}:root`;
|
|
451
|
-
if (currentWindowId(ctx) !== rootId) return;
|
|
28
|
+
if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE)) return;
|
|
452
29
|
pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: true }, { triggerTurn: false });
|
|
453
30
|
});
|
|
454
|
-
const saveNote = (op: NoteOperation) => {
|
|
455
|
-
// pi.appendEntry writes a custom SessionManager entry. Custom entries are persistent but excluded from LLM context.
|
|
456
|
-
// ExtensionContext deliberately exposes only a readonly SessionManager, so this is the public extension write path.
|
|
457
|
-
pi.appendEntry(NOTE_TYPE, op);
|
|
458
|
-
};
|
|
459
31
|
|
|
460
32
|
pi.registerCommand("pi-context", {
|
|
461
33
|
description: "Toggle pi-context: context_window boot block, low-budget guidance, and reset-style compaction",
|
|
@@ -464,7 +36,7 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
464
36
|
handler: async (args, cmdCtx) => {
|
|
465
37
|
const arg = args.trim().toLowerCase();
|
|
466
38
|
if (arg === "on") enabled = true;
|
|
467
|
-
else if (arg === "off") enabled = false;
|
|
39
|
+
else if (arg === "off") { enabled = false; resets.clear(); }
|
|
468
40
|
else if (arg !== "") {
|
|
469
41
|
cmdCtx.ui.notify("Usage: /pi-context [on|off]", "error");
|
|
470
42
|
return;
|
|
@@ -473,158 +45,11 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
473
45
|
},
|
|
474
46
|
});
|
|
475
47
|
|
|
476
|
-
pi
|
|
477
|
-
|
|
478
|
-
label: "History list windows",
|
|
479
|
-
description: "List durable Pi session-history windows.",
|
|
480
|
-
parameters: Type.Object({ limit: positiveInteger(), recent_first: recentFirst() }, { additionalProperties: false }),
|
|
481
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
482
|
-
let windows = historyFromSession(ctx);
|
|
483
|
-
if (params.recent_first !== false) windows = [...windows].reverse();
|
|
484
|
-
const limit = params.limit ?? windows.length;
|
|
485
|
-
return output({ windows: windows.slice(0, limit).map((window) => ({ window_id: window.windowId, item_count: window.items.length })) });
|
|
486
|
-
},
|
|
487
|
-
}));
|
|
488
|
-
|
|
489
|
-
pi.registerTool(defineTool({
|
|
490
|
-
name: "history_list_items",
|
|
491
|
-
label: "History list items",
|
|
492
|
-
description: "List durable session items, including items before compaction, using opaque item and window IDs.",
|
|
493
|
-
parameters: Type.Object({ limit: positiveInteger(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
|
|
494
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
495
|
-
const items = filteredItems(ctx, params);
|
|
496
|
-
return output({ items: items.slice(0, params.limit ?? items.length).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200)) });
|
|
497
|
-
},
|
|
498
|
-
}));
|
|
499
|
-
|
|
500
|
-
pi.registerTool(defineTool({
|
|
501
|
-
name: "history_read_item",
|
|
502
|
-
label: "History read item",
|
|
503
|
-
description: "Read a bounded character range from one durable session item.",
|
|
504
|
-
parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ minimum: 0 })), limit_chars: positiveInteger(), window_id: Type.String() }, { additionalProperties: false }),
|
|
505
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
506
|
-
const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
|
|
507
|
-
if (!item) return output({ error: "unknown item_id or window_id" });
|
|
508
|
-
const chars = Array.from(item.content);
|
|
509
|
-
const offset = params.offset_chars ?? 0;
|
|
510
|
-
const limit = params.limit_chars ?? chars.length;
|
|
511
|
-
return output({ window_id: item.windowId, item_id: item.itemId, offset_chars: offset, content: chars.slice(offset, offset + limit).join("") });
|
|
512
|
-
},
|
|
513
|
-
}));
|
|
514
|
-
|
|
515
|
-
pi.registerTool(defineTool({
|
|
516
|
-
name: "history_search_contents",
|
|
517
|
-
label: "History search",
|
|
518
|
-
description: "Case-sensitive literal substring search over durable Pi session history; no semantic search.",
|
|
519
|
-
parameters: Type.Object({ limit: positiveInteger(), query: Type.String(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString() }, { additionalProperties: false }),
|
|
520
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
521
|
-
const items = filteredItems(ctx, params);
|
|
522
|
-
const matching = items.filter((item) => item.content.includes(params.query));
|
|
523
|
-
return output({ items: matching.slice(0, params.limit ?? matching.length).map((item) => visibleItem(item)) });
|
|
524
|
-
},
|
|
525
|
-
}));
|
|
526
|
-
|
|
527
|
-
pi.registerTool(defineTool({
|
|
528
|
-
name: "notes_list_files_by_prefix",
|
|
529
|
-
label: "Notes list files",
|
|
530
|
-
description: "List persistent, session-scoped virtual note files. created_at and updated_at are local-time ISO 8601 strings with an explicit UTC offset.",
|
|
531
|
-
parameters: Type.Object({ prefix: nullableString(), max_results: positiveInteger(), file_order_by: Type.Optional(Type.Union([Type.Literal("name"), Type.Literal("created_at"), Type.Literal("updated_at")])), file_order: Type.Optional(Type.Union([Type.Literal("ascending"), Type.Literal("descending")])) }, { additionalProperties: false }),
|
|
532
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
533
|
-
const prefix = assertVirtualPrefix(params.prefix);
|
|
534
|
-
let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
|
|
535
|
-
const key = params.file_order_by ?? "name";
|
|
536
|
-
files.sort(([aPath, a], [bPath, b]) => key === "name" ? aPath.localeCompare(bPath) : (key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt));
|
|
537
|
-
if (params.file_order === "descending") files.reverse();
|
|
538
|
-
return output({ files: files.slice(0, params.max_results ?? files.length).map(([path, file]) => ({ path, size_bytes: Buffer.byteLength(file.text, "utf8"), created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) })) });
|
|
539
|
-
},
|
|
540
|
-
}));
|
|
541
|
-
|
|
542
|
-
pi.registerTool(defineTool({
|
|
543
|
-
name: "notes_read_file",
|
|
544
|
-
label: "Notes read file",
|
|
545
|
-
description: "Read a virtual note file, optionally by inclusive 1-based line range; negative lines count from the end. Success results carry created_at and updated_at as local-time ISO 8601 strings with an explicit UTC offset.",
|
|
546
|
-
parameters: Type.Object({ path: Type.String(), start_line: nullableInteger(), stop_line: nullableInteger() }, { additionalProperties: false }),
|
|
547
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
548
|
-
const path = assertVirtualPath(params.path);
|
|
549
|
-
const file = notesFromSession(ctx).get(path);
|
|
550
|
-
if (!file) return output({ error: "note file not found", path });
|
|
551
|
-
return output({ path, ...lineRange(file.text, params.start_line, params.stop_line), created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) });
|
|
552
|
-
},
|
|
553
|
-
}));
|
|
554
|
-
|
|
555
|
-
pi.registerTool(defineTool({
|
|
556
|
-
name: "notes_search_contents",
|
|
557
|
-
label: "Notes search",
|
|
558
|
-
description: "Case-sensitive literal substring search over virtual note lines; no semantic search. Each matched file carries created_at and updated_at as local-time ISO 8601 strings with an explicit UTC offset.",
|
|
559
|
-
parameters: Type.Object({ max_matches_per_file: positiveInteger(), query: Type.String(), recent_file_first: Type.Optional(Type.Boolean()), max_files: positiveInteger(), path_prefix: nullableString() }, { additionalProperties: false }),
|
|
560
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
561
|
-
const prefix = assertVirtualPrefix(params.path_prefix);
|
|
562
|
-
let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
|
|
563
|
-
if (params.recent_file_first) files.sort((a, b) => b[1].createdAt - a[1].createdAt);
|
|
564
|
-
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
565
|
-
const result = files.map(([path, file]) => ({ path, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt), matches: file.text.split("\n").flatMap((line, index) => line.includes(params.query) ? [{ line: index + 1, text: line }] : []).slice(0, maxPerFile) })).filter((file) => file.matches.length > 0);
|
|
566
|
-
return output({ files: result.slice(0, params.max_files ?? result.length) });
|
|
567
|
-
},
|
|
568
|
-
}));
|
|
569
|
-
|
|
570
|
-
for (const [name, op] of [["notes_append_to_file", "append"], ["notes_write_file", "write"]] as const) {
|
|
571
|
-
pi.registerTool(defineTool({
|
|
572
|
-
name,
|
|
573
|
-
label: name === "notes_append_to_file" ? "Notes append" : "Notes write",
|
|
574
|
-
description: name === "notes_append_to_file" ? "Append exact text to a persistent virtual note file." : "Create or replace a persistent virtual note file.",
|
|
575
|
-
parameters: Type.Object({ text: Type.String(), path: Type.String() }, { additionalProperties: false }),
|
|
576
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
577
|
-
const path = assertVirtualPath(params.path);
|
|
578
|
-
const old = notesFromSession(ctx).get(path);
|
|
579
|
-
const next = op === "append" ? `${old?.text ?? ""}${params.text}` : params.text;
|
|
580
|
-
const bytes = Buffer.byteLength(next, "utf8");
|
|
581
|
-
if (bytes > MAX_NOTE_BYTES) return output({ error: `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`, path, size_bytes: bytes });
|
|
582
|
-
const now = Date.now();
|
|
583
|
-
saveNote({ op, path, text: params.text, createdAt: old?.createdAt ?? now, updatedAt: now });
|
|
584
|
-
return output({ path, size_bytes: bytes, operation: op });
|
|
585
|
-
},
|
|
586
|
-
}));
|
|
587
|
-
}
|
|
48
|
+
registerHistoryTools(pi);
|
|
49
|
+
registerNoteTools(pi);
|
|
588
50
|
|
|
589
51
|
const fallbackGuidance = () => `${GUIDANCE_OPEN_TAG}\n${FALLBACK_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
|
|
590
52
|
|
|
591
|
-
pi.on("context", (_event, ctx) => {
|
|
592
|
-
if (!enabled) return undefined;
|
|
593
|
-
// This hook does exactly one thing: persist the once-per-window low-budget
|
|
594
|
-
// reminder the first time remaining context crosses the reminder threshold.
|
|
595
|
-
// It never injects messages into the request.
|
|
596
|
-
const usage = ctx.getContextUsage();
|
|
597
|
-
if (usage && usage.tokens !== null) {
|
|
598
|
-
const remaining = Math.max(0, usage.contextWindow - usage.tokens);
|
|
599
|
-
const windowId = currentWindowId(ctx);
|
|
600
|
-
if (remaining <= resolveThresholds(ctx).reminder && guidancePersistedInWindow !== windowId) {
|
|
601
|
-
guidancePersistedInWindow = windowId;
|
|
602
|
-
// Persist once per window — no transient copy. A transient bridge would
|
|
603
|
-
// cover the crossing request, but history would record the reminder after
|
|
604
|
-
// that request's assistant reply, so across the boundary the model would
|
|
605
|
-
// meet the same text twice at shifted positions. The reminder is an early
|
|
606
|
-
// warning, not a per-request instruction: arriving from the next request
|
|
607
|
-
// on (sendMessage defers safely to end of turn while streaming, queueing
|
|
608
|
-
// instead of splitting a tool call/result pair) costs nothing, and the
|
|
609
|
-
// model's view stays identical to recorded history, Codex-style.
|
|
610
|
-
pi.sendMessage({ customType: GUIDANCE_TYPE, content: tokenBudgetGuidance(remaining), display: true }, { triggerTurn: false });
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
return undefined;
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
pi.registerTool(defineTool({
|
|
617
|
-
name: "get_context_remaining",
|
|
618
|
-
label: "Get context remaining",
|
|
619
|
-
description: "Return remaining context tokens when Pi can estimate them, otherwise null.",
|
|
620
|
-
parameters: Type.Object({}, { additionalProperties: false }),
|
|
621
|
-
async execute(_id, _params, _signal, _update, ctx) {
|
|
622
|
-
const usage = ctx.getContextUsage();
|
|
623
|
-
const remaining = usage?.tokens === null || usage === undefined ? null : Math.max(0, usage.contextWindow - usage.tokens);
|
|
624
|
-
return output({ remaining_tokens: remaining });
|
|
625
|
-
},
|
|
626
|
-
}));
|
|
627
|
-
|
|
628
53
|
pi.registerTool(defineTool({
|
|
629
54
|
name: "new_context",
|
|
630
55
|
label: "New context",
|
|
@@ -632,85 +57,31 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
632
57
|
parameters: Type.Object({}, { additionalProperties: false }),
|
|
633
58
|
async execute() {
|
|
634
59
|
if (!enabled) return output({ error: "pi-context is off (/pi-context on to enable)" });
|
|
635
|
-
|
|
636
|
-
return output({ status: rollover === "requested" ? "rollover_requested" : "rollover_already_pending" }, undefined, true);
|
|
60
|
+
return output({ status: resets.request() }, undefined, true);
|
|
637
61
|
},
|
|
638
62
|
}));
|
|
639
63
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
rollover = "compacting";
|
|
651
|
-
ctx.compact({ onError: () => { if (rollover === "compacting") rollover = "idle"; } });
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
// Request the reset after the borrowed run settles, unless another compaction has
|
|
655
|
-
// already completed. Both threshold and overflow need this path: the agent loop
|
|
656
|
-
// can end without another threshold check, and overflow has a one-shot recovery
|
|
657
|
-
// guard. Waiting for agent_settled avoids requesting this reset from agent_end
|
|
658
|
-
// while Pi is still finishing the active run.
|
|
659
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
660
|
-
if (!enabled) {
|
|
661
|
-
fallbackPhase = "idle";
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
if (fallbackPhase === "steered") {
|
|
665
|
-
// The borrowed turn has not been observed yet. Stay armed: the next automatic
|
|
666
|
-
// request is still allowed through, and re-arming from idle here would let an
|
|
667
|
-
// undeliverable steer cancel forever.
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
if (fallbackPhase !== "allow") return;
|
|
671
|
-
fallbackPhase = "requested";
|
|
672
|
-
ctx.compact({ onError: () => { /* the allowance stays armed so the real reset is retried */ } });
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
pi.on("session_before_compact", async (event, ctx) => {
|
|
676
|
-
if (!enabled) {
|
|
677
|
-
fallbackPhase = "idle";
|
|
678
|
-
return undefined; // Default Pi compaction applies; keepRecentTokens is honored again.
|
|
679
|
-
}
|
|
680
|
-
// Never let an aborted or failed custom reset fall through to Pi's default summary.
|
|
681
|
-
if (event.signal.aborted) return { cancel: true };
|
|
682
|
-
// Manual /compact and new_context bypass the borrowed-turn phase entirely.
|
|
683
|
-
const selfRequested = rollover === "requested" || rollover === "compacting";
|
|
684
|
-
const automatic = event.reason === "threshold" || event.reason === "overflow";
|
|
685
|
-
// Phase 1: the first automatic crossing of the reserve line borrows one final
|
|
686
|
-
// note-taking turn instead of resetting immediately. This is only safe while Pi is
|
|
687
|
-
// streaming: there `pi.sendMessage(..., triggerTurn)` routes to agent.steer() and is
|
|
688
|
-
// queued synchronously, so it reaches the model before pending user input with no
|
|
689
|
-
// text/images copied, intercepted, or replayed (no input hook is registered). While
|
|
690
|
-
// idle, the same call would instead start a nested agent run (AgentSession's
|
|
691
|
-
// sendCustomMessage -> _runAgentPrompt), and the prompt that triggered this idle
|
|
692
|
-
// pre-flight check would then fail with "Agent is already processing a prompt"
|
|
693
|
-
// (Agent.prompt rejects while activeRun exists). So idle crossings reset directly.
|
|
694
|
-
if (automatic && !selfRequested && fallbackPhase === "idle" && !ctx.isIdle()) {
|
|
695
|
-
fallbackPhase = "steered";
|
|
696
|
-
pi.sendMessage({ customType: FALLBACK_TYPE, content: fallbackGuidance(), display: true }, { triggerTurn: true });
|
|
697
|
-
return { cancel: true };
|
|
698
|
-
}
|
|
699
|
-
// Phase 2 (or manual/new_context): perform the real reset. fallbackPhase deliberately
|
|
700
|
-
// stays armed until session_compact confirms success, so a failed reset is retried
|
|
701
|
-
// without borrowing another turn.
|
|
702
|
-
try {
|
|
64
|
+
const resets = registerResetLifecycle(pi, {
|
|
65
|
+
isEnabled: () => enabled,
|
|
66
|
+
fallback: { customType: FALLBACK_TYPE, content: fallbackGuidance(), display: true },
|
|
67
|
+
continuation: { customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
|
|
68
|
+
isCurrentReset: (entryId, ctx) => {
|
|
69
|
+
const entry = ctx.sessionManager.getEntry(entryId);
|
|
70
|
+
return entry?.type === "compaction" && resetV2WindowId(entry.details) === currentWindowId(ctx);
|
|
71
|
+
},
|
|
72
|
+
onReset: (entryId) => pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: entryId }),
|
|
73
|
+
buildReset: (event, ctx, explicit) => {
|
|
703
74
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
|
|
75
|
+
// Window IDs are independent of Pi entry IDs. Avoid reusing a window
|
|
76
|
+
// identity already present on this branch.
|
|
77
|
+
const windows = historyFromSession(ctx);
|
|
78
|
+
const usedIds = new Set(windows.map((window) => window.windowId));
|
|
707
79
|
let minted = randomUUID().slice(0, 8);
|
|
708
|
-
while (
|
|
80
|
+
while (usedIds.has(`pcw:${sessionId}:${minted}`)) minted = randomUUID().slice(0, 8);
|
|
709
81
|
const windowId = `pcw:${sessionId}:${minted}`;
|
|
710
|
-
const windows = historyFromSession(ctx);
|
|
711
82
|
const previousId = windows[windows.length - 1]?.windowId ?? `pcw:${sessionId}:root`;
|
|
712
83
|
// The reset marker stays as firstKeptEntryId; it no longer names the window.
|
|
713
|
-
pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested:
|
|
84
|
+
pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
|
|
714
85
|
const markerId = ctx.sessionManager.getLeafId();
|
|
715
86
|
if (!markerId) return { cancel: true };
|
|
716
87
|
return {
|
|
@@ -721,34 +92,7 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
721
92
|
details: { piContext: RESET_V2, windowId },
|
|
722
93
|
},
|
|
723
94
|
};
|
|
724
|
-
}
|
|
725
|
-
return { cancel: true };
|
|
726
|
-
}
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
pi.on("session_compact", (event, ctx) => {
|
|
730
|
-
if (!enabled) {
|
|
731
|
-
rollover = "idle";
|
|
732
|
-
fallbackPhase = "idle";
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
fallbackPhase = "idle"; // A completed reset re-arms the borrowed-turn phase for the next window.
|
|
736
|
-
const entry = ctx.sessionManager.getEntry(event.compactionEntry.id);
|
|
737
|
-
if (entry?.type !== "compaction" || resetV2WindowId(entry.details) === undefined) return;
|
|
738
|
-
if (handledCompactionId === entry.id) return;
|
|
739
|
-
handledCompactionId = entry.id;
|
|
740
|
-
// Only explicit new_context needs an extension-owned continuation.
|
|
741
|
-
// Automatic resets/retries and user /compact keep Pi's native scheduling.
|
|
742
|
-
const shouldContinue = rollover === "compacting" && !event.willRetry;
|
|
743
|
-
rollover = "idle";
|
|
744
|
-
pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: entry.id });
|
|
745
|
-
if (shouldContinue) {
|
|
746
|
-
pi.sendMessage({ customType: CONTINUATION_TYPE, content: CONTINUATION, display: false }, { triggerTurn: true });
|
|
747
|
-
}
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
pi.on("session_compact_failed", () => {
|
|
751
|
-
if (rollover === "compacting") rollover = "idle";
|
|
95
|
+
},
|
|
752
96
|
});
|
|
753
97
|
}
|
|
754
98
|
|