@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.
@@ -0,0 +1,76 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { output } from "./tool-output.js";
4
+ import { nullableString, nullableInteger, positiveInteger } from "./tool-schema.js";
5
+ import { notesFromSession, assertVirtualPath, assertVirtualPrefix, lineRange, localIso, type NoteOperation } from "./notes.js";
6
+ import { NOTE_TYPE, MAX_NOTE_BYTES } from "./protocol.js";
7
+
8
+ export function registerNoteTools(pi: ExtensionAPI) {
9
+ const saveNote = (op: NoteOperation) => {
10
+ // pi.appendEntry writes a custom SessionManager entry. Custom entries are persistent but excluded from LLM context.
11
+ // ExtensionContext deliberately exposes only a readonly SessionManager, so this is the public extension write path.
12
+ pi.appendEntry(NOTE_TYPE, op);
13
+ };
14
+
15
+ pi.registerTool(defineTool({
16
+ name: "notes_list_files_by_prefix",
17
+ label: "Notes list files",
18
+ description: "List persistent, session-scoped virtual note files. created_at and updated_at are local-time ISO 8601 strings with an explicit UTC offset.",
19
+ 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 }),
20
+ async execute(_id, params, _signal, _update, ctx) {
21
+ const prefix = assertVirtualPrefix(params.prefix);
22
+ let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
23
+ const key = params.file_order_by ?? "name";
24
+ files.sort(([aPath, a], [bPath, b]) => key === "name" ? aPath.localeCompare(bPath) : (key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt));
25
+ if (params.file_order === "descending") files.reverse();
26
+ 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) })) });
27
+ },
28
+ }));
29
+
30
+ pi.registerTool(defineTool({
31
+ name: "notes_read_file",
32
+ label: "Notes read file",
33
+ 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.",
34
+ parameters: Type.Object({ path: Type.String(), start_line: nullableInteger(), stop_line: nullableInteger() }, { additionalProperties: false }),
35
+ async execute(_id, params, _signal, _update, ctx) {
36
+ const path = assertVirtualPath(params.path);
37
+ const file = notesFromSession(ctx).get(path);
38
+ if (!file) return output({ error: "note file not found", path });
39
+ return output({ path, ...lineRange(file.text, params.start_line, params.stop_line), created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) });
40
+ },
41
+ }));
42
+
43
+ pi.registerTool(defineTool({
44
+ name: "notes_search_contents",
45
+ label: "Notes search",
46
+ 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.",
47
+ 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 }),
48
+ async execute(_id, params, _signal, _update, ctx) {
49
+ const prefix = assertVirtualPrefix(params.path_prefix);
50
+ let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
51
+ if (params.recent_file_first) files.sort((a, b) => b[1].createdAt - a[1].createdAt);
52
+ const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
53
+ 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);
54
+ return output({ files: result.slice(0, params.max_files ?? result.length) });
55
+ },
56
+ }));
57
+
58
+ for (const [name, op] of [["notes_append_to_file", "append"], ["notes_write_file", "write"]] as const) {
59
+ pi.registerTool(defineTool({
60
+ name,
61
+ label: name === "notes_append_to_file" ? "Notes append" : "Notes write",
62
+ description: name === "notes_append_to_file" ? "Append exact text to a persistent virtual note file." : "Create or replace a persistent virtual note file.",
63
+ parameters: Type.Object({ text: Type.String(), path: Type.String() }, { additionalProperties: false }),
64
+ async execute(_id, params, _signal, _update, ctx) {
65
+ const path = assertVirtualPath(params.path);
66
+ const old = notesFromSession(ctx).get(path);
67
+ const next = op === "append" ? `${old?.text ?? ""}${params.text}` : params.text;
68
+ const bytes = Buffer.byteLength(next, "utf8");
69
+ if (bytes > MAX_NOTE_BYTES) return output({ error: `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`, path, size_bytes: bytes });
70
+ const now = Date.now();
71
+ saveNote({ op, path, text: params.text, createdAt: old?.createdAt ?? now, updatedAt: now });
72
+ return output({ path, size_bytes: bytes, operation: op });
73
+ },
74
+ }));
75
+ }
76
+ }
package/src/notes.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { SessionReader } from "./session-reader.js";
2
+ import { MAX_NOTE_BYTES, NOTE_TYPE } from "./protocol.js";
3
+
4
+ export type NoteFile = { text: string; createdAt: number; updatedAt: number };
5
+ export type NoteOperation = {
6
+ op: "write" | "append";
7
+ path: string;
8
+ text: string;
9
+ createdAt: number;
10
+ updatedAt: number;
11
+ };
12
+ export function assertVirtualPath(value: unknown): string {
13
+ if (typeof value !== "string" || value.length === 0) throw new Error("path must be a non-empty virtual relative path");
14
+ if (value.includes("\0") || value.includes("\\") || value.startsWith("/")) throw new Error("path must be a safe virtual relative path");
15
+ const parts = value.split("/");
16
+ if (parts.some((part) => part.length === 0 || part === "." || part === "..")) throw new Error("path contains an unsupported component");
17
+ return value;
18
+ }
19
+
20
+ export function assertVirtualPrefix(value: unknown): string | undefined {
21
+ if (value === undefined || value === null || value === "") return undefined;
22
+ return assertVirtualPath(value);
23
+ }
24
+
25
+ /** Replays only pi-context note operations from session custom entries. */
26
+ function isNoteOperation(data: unknown): data is NoteOperation {
27
+ if (typeof data !== "object" || data === null) return false;
28
+ const op = data as Partial<NoteOperation>;
29
+ return (
30
+ (op.op === "write" || op.op === "append") &&
31
+ typeof op.path === "string" &&
32
+ typeof op.text === "string" &&
33
+ typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
34
+ typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime())
35
+ );
36
+ }
37
+
38
+ export function notesFromSession(ctx: SessionReader): Map<string, NoteFile> {
39
+ const files = new Map<string, NoteFile>();
40
+ for (const entry of ctx.sessionManager.getBranch()) {
41
+ if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data)) continue;
42
+ const op = entry.data;
43
+ try {
44
+ assertVirtualPath(op.path);
45
+ } catch {
46
+ continue;
47
+ }
48
+ const previous = files.get(op.path);
49
+ const text = op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text;
50
+ if (Buffer.byteLength(text, "utf8") <= MAX_NOTE_BYTES) {
51
+ files.set(op.path, { text, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
52
+ }
53
+ }
54
+ return files;
55
+ }
56
+
57
+ export function lineRange(text: string, startValue: unknown, stopValue: unknown) {
58
+ const lines = text.split("\n");
59
+ const resolve = (value: unknown, fallback: number) => {
60
+ if (value === undefined || value === null) return fallback;
61
+ if (!Number.isInteger(value) || value === 0) throw new Error("line numbers must be non-zero integers; negative values count from the end");
62
+ const line = value as number;
63
+ return line > 0 ? line : lines.length + line + 1;
64
+ };
65
+ const start = Math.max(1, resolve(startValue, 1));
66
+ const stop = Math.min(lines.length, resolve(stopValue, lines.length));
67
+ return { start_line: start, stop_line: stop, content: start > stop ? "" : lines.slice(start - 1, stop).join("\n") };
68
+ }
69
+
70
+ const pad2 = (value: number) => String(value).padStart(2, "0");
71
+
72
+ /**
73
+ * Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
74
+ * explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
75
+ * "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
76
+ */
77
+ export function localIso(epochMs: number): string {
78
+ const date = new Date(epochMs);
79
+ const offsetMinutes = -date.getTimezoneOffset();
80
+ const absOffset = Math.abs(offsetMinutes);
81
+ const offset = `${offsetMinutes < 0 ? "-" : "+"}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
82
+ 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")}`;
83
+ return `${wallClock}${offset}`;
84
+ }
85
+
package/src/prompts.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { historyFromSession } from "./history.js";
3
+ import { notesFromSession, localIso } from "./notes.js";
4
+ import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, NOTE_PREVIEW_CHARS, NOTE_PREVIEW_HEAD_CHARS, NOTE_PREVIEW_TAIL_CHARS, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "./protocol.js";
5
+
6
+ /** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
7
+ function identityBlock(agentName: string, firstWindowId: string, currentWindowId: string, previousWindowId?: string): string {
8
+ const lines = [
9
+ `Agent name: ${agentName}`,
10
+ `First context window id: ${firstWindowId}`,
11
+ `Current context window id: ${currentWindowId}`,
12
+ ];
13
+ if (previousWindowId) lines.push(`Previous context window id: ${previousWindowId}`);
14
+ return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
15
+ }
16
+
17
+ /**
18
+ * Recent-notes index: up to three most-recent notes. Each note shows its path, line count,
19
+ * UTF-8 byte count and local ISO update time, followed by an indented inline preview: the
20
+ * whole text when it fits in NOTE_PREVIEW_CHARS, otherwise its first NOTE_PREVIEW_HEAD_CHARS
21
+ * and last NOTE_PREVIEW_TAIL_CHARS Unicode characters joined by an explicit ellipsis. The
22
+ * two slices never overlap, so the preview never duplicates head content as tail content.
23
+ * Empty when the session has no notes.
24
+ */
25
+ function notesIndex(ctx: ExtensionContext): string {
26
+ const recentNotes = [...notesFromSession(ctx)]
27
+ .sort((a, b) => b[1].updatedAt - a[1].updatedAt)
28
+ .slice(0, 3);
29
+ if (recentNotes.length === 0) return "";
30
+ const lines = ["Recent notes at window open (up to 3, most-recent first):"];
31
+ for (const [path, file] of recentNotes) {
32
+ lines.push(`- ${path} (${file.text.split("\n").length} lines, ${Buffer.byteLength(file.text, "utf8")} UTF-8 bytes, updated ${localIso(file.updatedAt)})`);
33
+ const chars = Array.from(file.text);
34
+ // Short notes stay whole; long notes keep both ends. head + tail <= NOTE_PREVIEW_CHARS < chars.length,
35
+ // so the slices are disjoint and no character is shown twice.
36
+ const preview = chars.length <= NOTE_PREVIEW_CHARS
37
+ ? file.text
38
+ : `${chars.slice(0, NOTE_PREVIEW_HEAD_CHARS).join("")}…${chars.slice(chars.length - NOTE_PREVIEW_TAIL_CHARS).join("")}`;
39
+ lines.push(preview.split("\n").map((line) => ` ${line}`).join("\n"));
40
+ }
41
+ return lines.join("\n");
42
+ }
43
+
44
+ /**
45
+ * Assemble the static, once-per-window boot block: the reset line for resets, the
46
+ * <context_window> identity block, the recent-notes index at window-open time, and
47
+ * the <context_window_protocol> teaching block. Nothing here is re-injected, so the
48
+ * head of the window stays cache-stable.
49
+ */
50
+ export function bootBlock(ctx: ExtensionContext, currentId: string, previousId: string | undefined, resetLine: boolean): string {
51
+ const firstId = historyFromSession(ctx)[0]?.windowId ?? currentId;
52
+ const parts: string[] = [];
53
+ if (resetLine) parts.push(RESET_SUMMARY);
54
+ parts.push(identityBlock(ctx.sessionManager.getSessionName() ?? "root", firstId, currentId, previousId));
55
+ const index = notesIndex(ctx);
56
+ if (index) parts.push(index);
57
+ parts.push(PROTOCOL_BLOCK);
58
+ return parts.join("\n\n");
59
+ }
60
+
61
+ /**
62
+ * Codex-equivalent low-budget reminder. The measured remaining count is frozen into
63
+ * the text at the crossing that fires it, so each persisted copy is a snapshot true
64
+ * at write time; get_context_remaining remains the live source for the current figure.
65
+ */
66
+ export function tokenBudgetGuidance(remaining: number): string {
67
+ 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}`;
68
+ }
69
+
@@ -0,0 +1,43 @@
1
+ export const STATE_TYPE = "pi-context/state";
2
+ export const NOTE_TYPE = "pi-context/note";
3
+ export const BOOT_TYPE = "pi-context/boot";
4
+ export const GUIDANCE_TYPE = "pi-context/guidance";
5
+ export const FALLBACK_TYPE = "pi-context/fallback";
6
+ export const RESET_MARKER_TYPE = "pi-context/reset-marker";
7
+ export const CONTINUATION_TYPE = "pi-context/continuation";
8
+ export const RESET_V2 = "reset-v2";
9
+ export const MAX_NOTE_BYTES = 1_000_000;
10
+ export const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
11
+ export const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
12
+ export const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
13
+ export const CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG = "</context_window_protocol>";
14
+ export const GUIDANCE_OPEN_TAG = "<context_window_guidance>";
15
+ export const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
16
+ export const PI_CONTEXT_SETTINGS_KEY = "pi-context";
17
+ export const DEFAULT_RESERVE_TOKENS = 16_384;
18
+ export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
19
+ export const RESET_SUMMARY =
20
+ "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.";
21
+ export const NOTE_PREVIEW_HEAD_CHARS = 120;
22
+ export const NOTE_PREVIEW_TAIL_CHARS = 80;
23
+ export const NOTE_PREVIEW_CHARS = NOTE_PREVIEW_HEAD_CHARS + NOTE_PREVIEW_TAIL_CHARS;
24
+ export const CONTINUATION = "This is a fresh context window. Recover only the details needed to continue with history_* and notes_*; then continue the task.";
25
+
26
+ /**
27
+ * Static protocol teaching adapted from Codex's token_budget.guidance_message to
28
+ * pi-context's tool names. It lives once per window in the persisted boot block;
29
+ * it is never re-injected, so it stays cache-stable at the head of the window.
30
+ */
31
+ export const PROTOCOL_BLOCK = `${CONTEXT_WINDOW_PROTOCOL_OPEN_TAG}
32
+ 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.
33
+
34
+ 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.
35
+
36
+ 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.
37
+
38
+ Notes are session-scoped virtual files. Treat notes and history as internal bookkeeping; never mention them in user-facing messages.
39
+ ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
40
+
41
+ export const FALLBACK_PROMPT =
42
+ "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_*.";
43
+
@@ -0,0 +1,125 @@
1
+ import type { ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ type ResetResult = { cancel: true } | {
4
+ compaction: { summary: string; firstKeptEntryId: string; tokensBefore: number; details: unknown };
5
+ };
6
+
7
+ /** A reset request is session-local. Only this module schedules compaction/continuation. */
8
+ export function registerResetLifecycle(pi: ExtensionAPI, options: {
9
+ isEnabled: () => boolean;
10
+ fallback: Parameters<ExtensionAPI["sendMessage"]>[0];
11
+ continuation: Parameters<ExtensionAPI["sendMessage"]>[0];
12
+ buildReset: (event: SessionBeforeCompactEvent, ctx: ExtensionContext, explicit: boolean) => ResetResult;
13
+ isCurrentReset: (entryId: string, ctx: ExtensionContext) => boolean;
14
+ onReset: (entryId: string) => void;
15
+ }) {
16
+ type Fallback = "available" | "borrowed" | "ready" | "spent";
17
+ type Attempt = { completed: boolean; sessionId: string; explicit: boolean };
18
+ type Request =
19
+ | { phase: "idle" }
20
+ | { phase: "requested" }
21
+ | { phase: "compacting"; attempt: Attempt };
22
+ let state: Request = { phase: "idle" };
23
+ let fallback: Fallback = "available";
24
+ let handledEntry: string | undefined;
25
+ let active = true;
26
+
27
+ const clear = () => {
28
+ state = { phase: "idle" };
29
+ fallback = "available";
30
+ handledEntry = undefined;
31
+ };
32
+ const valid = (request: Attempt, ctx: ExtensionContext) =>
33
+ active && options.isEnabled() && state.phase === "compacting" && state.attempt === request && ctx.sessionManager.getSessionId() === request.sessionId;
34
+
35
+ // State is intentionally not resumed from a pending request: a loaded session must
36
+ // not execute work from a tool that belonged to a previous runtime or tree branch.
37
+ pi.on("session_start", () => { clear(); active = true; });
38
+ pi.on("session_shutdown", () => { clear(); active = false; });
39
+ pi.on("session_tree", clear);
40
+
41
+ pi.on("agent_end", (_event, ctx) => {
42
+ if (!active || !options.isEnabled()) return;
43
+ if (ctx.signal?.aborted) {
44
+ // Esc cancels the user's run. Do not reset or resurrect it at settled.
45
+ state = { phase: "idle" };
46
+ if (fallback !== "available") fallback = "spent";
47
+ return;
48
+ }
49
+ if (fallback === "borrowed") fallback = "ready";
50
+ });
51
+
52
+ pi.on("agent_settled", (_event, ctx) => {
53
+ if (!active || !options.isEnabled() || state.phase === "compacting" || !ctx.isIdle()) return;
54
+ if (state.phase !== "requested" && fallback !== "ready") return;
55
+ // One owner for explicit and fallback resets. Consume the request before any
56
+ // external call; repeated settled events and reentrant callbacks are harmless.
57
+ const request: Attempt = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: state.phase === "requested" };
58
+ state = { phase: "compacting", attempt: request };
59
+ if (fallback !== "available") fallback = "spent";
60
+ const onError = (error: Error) => {
61
+ if (!valid(request, ctx)) return;
62
+ state = { phase: "idle" };
63
+ // Do not retry from settled in a tight loop. A later prompt may trigger a
64
+ // native reset or explicitly request one; the borrowed allowance stays spent.
65
+ ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
66
+ };
67
+ try {
68
+ ctx.compact({
69
+ onComplete: () => {
70
+ if (!valid(request, ctx)) return;
71
+ state = { phase: "idle" };
72
+ // session_compact only confirms the boundary. onComplete runs after
73
+ // Pi clears compaction state; sending inside the hook starts too early.
74
+ // A queued user prompt may already have started at compaction_end.
75
+ if (request.completed && ctx.isIdle() && !ctx.hasPendingMessages()) {
76
+ pi.sendMessage(options.continuation, { triggerTurn: true });
77
+ }
78
+ },
79
+ onError,
80
+ });
81
+ } catch (error) {
82
+ onError(error instanceof Error ? error : new Error(String(error)));
83
+ }
84
+ });
85
+
86
+ pi.on("session_before_compact", (event, ctx) => {
87
+ if (!active || !options.isEnabled()) return undefined;
88
+ if (event.signal.aborted) return { cancel: true };
89
+ const automatic = event.reason === "threshold" || event.reason === "overflow";
90
+ if (automatic && state.phase === "idle" && fallback === "available" && !ctx.isIdle()) {
91
+ // Pi routes triggerTurn to steer during a run. Idle calls would start a
92
+ // nested prompt, so pre-prompt automatic compactions always reset directly.
93
+ fallback = "borrowed";
94
+ pi.sendMessage(options.fallback, { triggerTurn: true });
95
+ return { cancel: true };
96
+ }
97
+ try {
98
+ return options.buildReset(event, ctx, state.phase === "requested" || (state.phase === "compacting" && state.attempt.explicit));
99
+ } catch (error) {
100
+ ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
101
+ return { cancel: true }; // Never fall through to a generated default summary.
102
+ }
103
+ });
104
+
105
+ pi.on("session_compact", (event, ctx) => {
106
+ if (!active || !options.isEnabled() || handledEntry === event.compactionEntry.id) return;
107
+ if (!options.isCurrentReset(event.compactionEntry.id, ctx)) return;
108
+ handledEntry = event.compactionEntry.id;
109
+ fallback = "available";
110
+ if (state.phase === "compacting") state.attempt.completed = !event.willRetry;
111
+ else state = { phase: "idle" };
112
+ // A native compaction (including overflow retry) owns its own scheduling.
113
+ // Only a reset we requested gets a continuation from our onComplete callback.
114
+ options.onReset(event.compactionEntry.id);
115
+ });
116
+
117
+ return {
118
+ request() {
119
+ const pending = state.phase !== "idle";
120
+ if (!pending) state = { phase: "requested" };
121
+ return pending ? "rollover_already_pending" : "rollover_requested";
122
+ },
123
+ clear,
124
+ };
125
+ }
@@ -0,0 +1,6 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Read-only projection boundary: no UI, scheduling, model, or write capabilities. */
4
+ export type SessionReader = {
5
+ sessionManager: Pick<ExtensionContext["sessionManager"], "getSessionId" | "getBranch">;
6
+ };
@@ -0,0 +1,8 @@
1
+ function json(value: unknown): string {
2
+ return JSON.stringify(value, null, 2);
3
+ }
4
+
5
+ export function output(value: unknown, details: unknown = value, terminate = false) {
6
+ return { content: [{ type: "text" as const, text: json(value) }], details, terminate };
7
+ }
8
+
@@ -0,0 +1,7 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ export const nullableString = () => Type.Optional(Type.Union([Type.String(), Type.Null()]));
3
+ export const nullableInteger = () => Type.Optional(Type.Union([Type.Integer(), Type.Null()]));
4
+ export const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }));
5
+ export const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
6
+ export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()]);
7
+