@astrosheep/pi-context 0.1.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/LICENSE +21 -0
- package/README.md +68 -0
- package/package.json +39 -0
- package/src/index.ts +417 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 astrosheep-zero
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# pi-context
|
|
2
|
+
|
|
3
|
+
Codex-style context windows for [Pi](https://github.com/earendil-works/pi-mono): reset-style compaction, durable session-history tools, and persistent notes — implemented entirely with public extension APIs. No Pi core modification required.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@astrosheep/pi-context
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or load it for a single invocation without installing:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pi -e npm:@astrosheep/pi-context
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
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
|
+
|
|
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>` hint** — every model request carries a Codex-equivalent fragment with the agent name, first/current/previous window IDs, and the 5 most recently updated notes. The model gets recovery entry points, not a bare "go search" message.
|
|
23
|
+
- **History tools** — the model searches pre-reset conversation with case-sensitive literal substring search, exactly like Codex's `history.*` namespace.
|
|
24
|
+
- **Notes tools** — persistent, session-scoped virtual files that survive window resets.
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
The nine Codex History/Notes actions are flattened because Pi tools have one global name space:
|
|
29
|
+
|
|
30
|
+
| Codex action | Pi tool |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `history.list_windows` | `history_list_windows` |
|
|
33
|
+
| `history.list_items` | `history_list_items` |
|
|
34
|
+
| `history.read_item` | `history_read_item` |
|
|
35
|
+
| `history.search_contents` | `history_search_contents` |
|
|
36
|
+
| `notes.list_files_by_prefix` | `notes_list_files_by_prefix` |
|
|
37
|
+
| `notes.read_file` | `notes_read_file` |
|
|
38
|
+
| `notes.search_contents` | `notes_search_contents` |
|
|
39
|
+
| `notes.append_to_file` | `notes_append_to_file` |
|
|
40
|
+
| `notes.write_file` | `notes_write_file` |
|
|
41
|
+
|
|
42
|
+
`history_*` reads the current branch's actual Pi session entries, including entries hidden by earlier compaction. Window IDs are stable `pcw:<session-id>:root` or `pcw:<session-id>:<compaction-entry-id>` identifiers; item IDs are the persisted Pi entry IDs. No transcript copy or volatile archive is maintained.
|
|
43
|
+
|
|
44
|
+
`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.
|
|
45
|
+
|
|
46
|
+
Pi has no public cross-agent session router. Passing `agent_name` to a history tool returns an explicit unsupported error; it is never silently redirected to the current session.
|
|
47
|
+
|
|
48
|
+
Two extra controls compose Pi public APIs:
|
|
49
|
+
|
|
50
|
+
- `get_context_remaining` returns `{ "remaining_tokens": number | null }`. `null` means Pi itself cannot make a reliable estimate (notably immediately after compaction).
|
|
51
|
+
- `new_context` returns terminal tool output, then waits for Pi's `agent_end`, triggers public `ctx.compact()`, installs a short deterministic reset compaction, and sends exactly one hidden continuation turn after compaction succeeds. Call it by itself in a tool batch. Pi only ends a tool turn when every parallel tool result is terminal, so Pi 0.85.1 cannot force an atomic rollover from the middle of a mixed parallel tool batch.
|
|
52
|
+
|
|
53
|
+
## Reset behavior and limits
|
|
54
|
+
|
|
55
|
+
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 short reset result and hidden continuation, not old conversation messages. The old entries remain only in the session tree for `history_*`.
|
|
56
|
+
|
|
57
|
+
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.
|
|
58
|
+
|
|
59
|
+
This is a composition of public `session_before_compact`, `session_compact`, `pi.appendEntry`, `ctx.compact`, and `pi.sendMessage`; it is not a Pi-core `newSession` call. A manual Pi compaction is only eligible when Pi's own `prepareCompaction()` accepts the session. Therefore a `new_context` request in a too-small/uncompactable session fails cleanly without default-summary fallback or continuation. A core change would be needed only to guarantee a force-reset at arbitrary small context sizes or to atomically interrupt a mixed parallel tool batch.
|
|
60
|
+
|
|
61
|
+
## Verification
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
npm run typecheck
|
|
65
|
+
npm test
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The integration harness uses the installed Pi `SessionManager`, including an on-disk JSONL reload. It verifies note persistence/Unicode/path rules, provider context exclusion after the real `firstKeptEntryId` boundary while history remains searchable, completed tool-result boundary placement, one continuation only, and cancellation/failure/no-double-retry behavior. It uses no model or network call.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@astrosheep/pi-context",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["pi-package", "pi-extension", "context-management"],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/astrosheep-zero/pi-context.git"
|
|
11
|
+
},
|
|
12
|
+
"pi": {
|
|
13
|
+
"extensions": ["./src/index.ts"]
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
+
"test": "npm run build && node --test dist/test/integration.test.js",
|
|
24
|
+
"prepublishOnly": "npm run typecheck"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-ai": "*",
|
|
28
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@earendil-works/pi-ai": "^0.85.1",
|
|
32
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
33
|
+
"@types/node": "^22.19.19",
|
|
34
|
+
"typescript": "^5.9.3"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
const STATE_TYPE = "pi-context/state";
|
|
5
|
+
const NOTE_TYPE = "pi-context/note";
|
|
6
|
+
const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
7
|
+
const CONTINUATION_TYPE = "pi-context/continuation";
|
|
8
|
+
const MAX_NOTE_BYTES = 1_000_000;
|
|
9
|
+
const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
10
|
+
const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
11
|
+
const RESET_SUMMARY = "Context window reset. Prior session entries remain available only through the pi-context history tools.";
|
|
12
|
+
const CONTINUATION = "This is a fresh context window. Recover only the details needed to continue with history_* and notes_*; then continue the task.";
|
|
13
|
+
|
|
14
|
+
type Json = Record<string, unknown>;
|
|
15
|
+
type NoteFile = { text: string; createdAt: number; updatedAt: number };
|
|
16
|
+
type NoteOperation = {
|
|
17
|
+
op: "write" | "append";
|
|
18
|
+
path: string;
|
|
19
|
+
text: string;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
};
|
|
23
|
+
type HistoryItem = {
|
|
24
|
+
windowId: string;
|
|
25
|
+
itemId: string;
|
|
26
|
+
role: "user" | "assistant" | "tool" | "system" | "developer";
|
|
27
|
+
content: string;
|
|
28
|
+
createdAt: string | undefined;
|
|
29
|
+
toolName?: string;
|
|
30
|
+
toolNamespace?: string;
|
|
31
|
+
};
|
|
32
|
+
type HistoryWindow = { windowId: string; createdAt?: string; items: HistoryItem[] };
|
|
33
|
+
|
|
34
|
+
function json(value: unknown): string {
|
|
35
|
+
return JSON.stringify(value, null, 2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function output(value: unknown, details: unknown = value, terminate = false) {
|
|
39
|
+
return { content: [{ type: "text" as const, text: json(value) }], details, terminate };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unsupportedAgent(agentName: unknown) {
|
|
43
|
+
return agentName !== undefined && agentName !== null
|
|
44
|
+
? { error: "Pi 0.85.1 exposes no cross-agent session routing; agent_name is unsupported and was not aliased to this session." }
|
|
45
|
+
: undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toText(value: unknown): string {
|
|
49
|
+
if (typeof value === "string") return value;
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
return value
|
|
52
|
+
.map((part) => {
|
|
53
|
+
if (typeof part === "string") return part;
|
|
54
|
+
if (part && typeof part === "object" && typeof (part as Json).text === "string") return (part as Json).text as string;
|
|
55
|
+
return JSON.stringify(part);
|
|
56
|
+
})
|
|
57
|
+
.join("\n");
|
|
58
|
+
}
|
|
59
|
+
return value === undefined || value === null ? "" : JSON.stringify(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mapRole(role: unknown): HistoryItem["role"] | undefined {
|
|
63
|
+
if (role === "user" || role === "assistant" || role === "system" || role === "developer") return role;
|
|
64
|
+
if (role === "toolResult" || role === "tool") return "tool";
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toolInfo(message: Json): Pick<HistoryItem, "toolName" | "toolNamespace"> {
|
|
69
|
+
const name = typeof message.toolName === "string" ? message.toolName : undefined;
|
|
70
|
+
if (!name) return {};
|
|
71
|
+
const underscore = name.indexOf("_");
|
|
72
|
+
return { toolName: name, toolNamespace: underscore > 0 ? name.slice(0, underscore) : undefined };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build durable, on-demand history directly from every entry on the current session branch. */
|
|
76
|
+
export function historyFromSession(ctx: ExtensionContext): HistoryWindow[] {
|
|
77
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
78
|
+
let window: HistoryWindow = { windowId: `pcw:${sessionId}:root`, items: [] };
|
|
79
|
+
const windows = [window];
|
|
80
|
+
for (const entry of ctx.sessionManager.getBranch() as unknown as Array<Json>) {
|
|
81
|
+
if (entry.type === "compaction") {
|
|
82
|
+
window = { windowId: `pcw:${sessionId}:${String(entry.id)}`, createdAt: typeof entry.timestamp === "string" ? entry.timestamp : undefined, items: [] };
|
|
83
|
+
windows.push(window);
|
|
84
|
+
window.items.push({
|
|
85
|
+
windowId: window.windowId,
|
|
86
|
+
itemId: String(entry.id),
|
|
87
|
+
role: "system",
|
|
88
|
+
content: typeof entry.summary === "string" ? entry.summary : "",
|
|
89
|
+
createdAt: typeof entry.timestamp === "string" ? entry.timestamp : undefined,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (entry.type === "message") {
|
|
94
|
+
const message = entry.message as Json;
|
|
95
|
+
const role = mapRole(message.role);
|
|
96
|
+
if (!role) continue;
|
|
97
|
+
window.items.push({
|
|
98
|
+
windowId: window.windowId,
|
|
99
|
+
itemId: String(entry.id),
|
|
100
|
+
role,
|
|
101
|
+
content: toText(message.content),
|
|
102
|
+
createdAt: typeof entry.timestamp === "string" ? entry.timestamp : undefined,
|
|
103
|
+
...toolInfo(message),
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (entry.type === "custom_message") {
|
|
108
|
+
window.items.push({
|
|
109
|
+
windowId: window.windowId,
|
|
110
|
+
itemId: String(entry.id),
|
|
111
|
+
role: "user",
|
|
112
|
+
content: toText(entry.content),
|
|
113
|
+
createdAt: typeof entry.timestamp === "string" ? entry.timestamp : undefined,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return windows;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function visibleItem(item: HistoryItem, maxChars = 1200) {
|
|
121
|
+
const characters = Array.from(item.content);
|
|
122
|
+
return {
|
|
123
|
+
window_id: item.windowId,
|
|
124
|
+
item_id: item.itemId,
|
|
125
|
+
role: item.role,
|
|
126
|
+
tool_namespace: item.toolNamespace ?? null,
|
|
127
|
+
tool_name: item.toolName ?? null,
|
|
128
|
+
truncated_content: characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}…` : item.content,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function allItems(ctx: ExtensionContext) {
|
|
133
|
+
return historyFromSession(ctx).flatMap((window) => window.items);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function filteredItems(ctx: ExtensionContext, params: Json): HistoryItem[] | { error: string } {
|
|
137
|
+
const agentError = unsupportedAgent(params.agent_name);
|
|
138
|
+
if (agentError) return agentError;
|
|
139
|
+
let items = allItems(ctx);
|
|
140
|
+
if (typeof params.window_id === "string") items = items.filter((item) => item.windowId === params.window_id);
|
|
141
|
+
if (typeof params.role === "string") items = items.filter((item) => item.role === params.role);
|
|
142
|
+
if (typeof params.tool_namespace === "string") items = items.filter((item) => item.toolNamespace === params.tool_namespace);
|
|
143
|
+
if (typeof params.tool_name === "string") items = items.filter((item) => item.toolName === params.tool_name);
|
|
144
|
+
if (params.recent_first === true) items.reverse();
|
|
145
|
+
return items;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assertVirtualPath(value: unknown): string {
|
|
149
|
+
if (typeof value !== "string" || value.length === 0) throw new Error("path must be a non-empty virtual relative path");
|
|
150
|
+
if (value.includes("\0") || value.includes("\\") || value.startsWith("/")) throw new Error("path must be a safe virtual relative path");
|
|
151
|
+
const parts = value.split("/");
|
|
152
|
+
if (parts.some((part) => part.length === 0 || part === "." || part === "..")) throw new Error("path contains an unsupported component");
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function assertVirtualPrefix(value: unknown): string | undefined {
|
|
157
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
158
|
+
return assertVirtualPath(value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function notesFromSession(ctx: ExtensionContext): Map<string, NoteFile> {
|
|
162
|
+
const files = new Map<string, NoteFile>();
|
|
163
|
+
for (const entry of ctx.sessionManager.getBranch() as unknown as Array<Json>) {
|
|
164
|
+
if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !entry.data || typeof entry.data !== "object") continue;
|
|
165
|
+
const op = entry.data as Partial<NoteOperation>;
|
|
166
|
+
if ((op.op !== "write" && op.op !== "append") || typeof op.path !== "string" || typeof op.text !== "string") continue;
|
|
167
|
+
try {
|
|
168
|
+
assertVirtualPath(op.path);
|
|
169
|
+
} catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const previous = files.get(op.path);
|
|
173
|
+
const createdAt = typeof op.createdAt === "number" ? op.createdAt : previous?.createdAt ?? 0;
|
|
174
|
+
const updatedAt = typeof op.updatedAt === "number" ? op.updatedAt : createdAt;
|
|
175
|
+
const text = op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text;
|
|
176
|
+
if (Buffer.byteLength(text, "utf8") <= MAX_NOTE_BYTES) files.set(op.path, { text, createdAt, updatedAt });
|
|
177
|
+
}
|
|
178
|
+
return files;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Codex-equivalent <context_window> hint: window identity plus recent-notes entry points. */
|
|
182
|
+
export function contextWindowHint(ctx: ExtensionContext): string {
|
|
183
|
+
const windows = historyFromSession(ctx);
|
|
184
|
+
const first = windows[0];
|
|
185
|
+
const current = windows[windows.length - 1];
|
|
186
|
+
const previous = windows.length > 1 ? windows[windows.length - 2] : undefined;
|
|
187
|
+
const lines = [
|
|
188
|
+
`Agent name: ${ctx.sessionManager.getSessionName() ?? "root"}`,
|
|
189
|
+
`First context window id: ${first?.windowId ?? "unknown"}`,
|
|
190
|
+
`Current context window id: ${current?.windowId ?? "unknown"}`,
|
|
191
|
+
];
|
|
192
|
+
if (previous) lines.push(`Previous context window id: ${previous.windowId}`);
|
|
193
|
+
const recentNotes = [...notesFromSession(ctx)]
|
|
194
|
+
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
|
195
|
+
.slice(0, 5);
|
|
196
|
+
if (recentNotes.length > 0) {
|
|
197
|
+
lines.push("Recent notes (up to 5, most-recent first):");
|
|
198
|
+
for (const [path, file] of recentNotes) {
|
|
199
|
+
lines.push(`- ${path} (${file.text.split("\n").length} lines, ${Buffer.byteLength(file.text, "utf8")} UTF-8 bytes)`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function lineRange(text: string, startValue: unknown, stopValue: unknown) {
|
|
206
|
+
const lines = text.split("\n");
|
|
207
|
+
const resolve = (value: unknown, fallback: number) => {
|
|
208
|
+
if (value === undefined || value === null) return fallback;
|
|
209
|
+
if (!Number.isInteger(value) || value === 0) throw new Error("line numbers must be non-zero integers; negative values count from the end");
|
|
210
|
+
const line = value as number;
|
|
211
|
+
return line > 0 ? line : lines.length + line + 1;
|
|
212
|
+
};
|
|
213
|
+
const start = Math.max(1, resolve(startValue, 1));
|
|
214
|
+
const stop = Math.min(lines.length, resolve(stopValue, lines.length));
|
|
215
|
+
return { start_line: start, stop_line: stop, content: start > stop ? "" : lines.slice(start - 1, stop).join("\n") };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const nullableString = () => Type.Optional(Type.Union([Type.String(), Type.Null()]));
|
|
219
|
+
const nullableInteger = () => Type.Optional(Type.Union([Type.Integer(), Type.Null()]));
|
|
220
|
+
const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }));
|
|
221
|
+
const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()]);
|
|
222
|
+
|
|
223
|
+
export default function piContext(pi: ExtensionAPI) {
|
|
224
|
+
let rollover: "idle" | "requested" | "compacting" | "continued" = "idle";
|
|
225
|
+
const saveNote = (op: NoteOperation) => {
|
|
226
|
+
// pi.appendEntry writes a custom SessionManager entry. Custom entries are persistent but excluded from LLM context.
|
|
227
|
+
// ExtensionContext deliberately exposes only a readonly SessionManager, so this is the public extension write path.
|
|
228
|
+
pi.appendEntry(NOTE_TYPE, op);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
pi.registerTool(defineTool({
|
|
232
|
+
name: "history_list_windows",
|
|
233
|
+
label: "History list windows",
|
|
234
|
+
description: "List durable Pi session-history windows. agent_name is explicitly unsupported because Pi has no cross-agent session routing.",
|
|
235
|
+
parameters: Type.Object({ limit: positiveInteger(), agent_name: nullableString(), recent_first: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
236
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
237
|
+
const agentError = unsupportedAgent(params.agent_name);
|
|
238
|
+
if (agentError) return output(agentError);
|
|
239
|
+
let windows = historyFromSession(ctx);
|
|
240
|
+
if (params.recent_first) windows = [...windows].reverse();
|
|
241
|
+
const limit = params.limit ?? windows.length;
|
|
242
|
+
return output({ windows: windows.slice(0, limit).map((window) => ({ window_id: window.windowId, item_count: window.items.length })) });
|
|
243
|
+
},
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
pi.registerTool(defineTool({
|
|
247
|
+
name: "history_list_items",
|
|
248
|
+
label: "History list items",
|
|
249
|
+
description: "List durable session items, including items before compaction, using opaque item and window IDs.",
|
|
250
|
+
parameters: Type.Object({ limit: positiveInteger(), recent_first: Type.Optional(Type.Boolean()), tool_namespace: nullableString(), role: Type.Optional(role), agent_name: nullableString(), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
|
|
251
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
252
|
+
const items = filteredItems(ctx, params);
|
|
253
|
+
if ("error" in items) return output(items);
|
|
254
|
+
return output({ items: items.slice(0, params.limit ?? items.length).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200)) });
|
|
255
|
+
},
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
pi.registerTool(defineTool({
|
|
259
|
+
name: "history_read_item",
|
|
260
|
+
label: "History read item",
|
|
261
|
+
description: "Read a bounded character range from one durable session item.",
|
|
262
|
+
parameters: Type.Object({ agent_name: nullableString(), item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ minimum: 0 })), limit_chars: positiveInteger(), window_id: Type.String() }, { additionalProperties: false }),
|
|
263
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
264
|
+
const agentError = unsupportedAgent(params.agent_name);
|
|
265
|
+
if (agentError) return output(agentError);
|
|
266
|
+
const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
|
|
267
|
+
if (!item) return output({ error: "unknown item_id or window_id" });
|
|
268
|
+
const chars = Array.from(item.content);
|
|
269
|
+
const offset = params.offset_chars ?? 0;
|
|
270
|
+
const limit = params.limit_chars ?? chars.length;
|
|
271
|
+
return output({ window_id: item.windowId, item_id: item.itemId, offset_chars: offset, content: chars.slice(offset, offset + limit).join("") });
|
|
272
|
+
},
|
|
273
|
+
}));
|
|
274
|
+
|
|
275
|
+
pi.registerTool(defineTool({
|
|
276
|
+
name: "history_search_contents",
|
|
277
|
+
label: "History search",
|
|
278
|
+
description: "Case-sensitive literal substring search over durable Pi session history; no semantic search.",
|
|
279
|
+
parameters: Type.Object({ limit: positiveInteger(), query: Type.String(), recent_first: Type.Optional(Type.Boolean()), tool_namespace: nullableString(), role: Type.Optional(role), agent_name: nullableString(), tool_name: nullableString(), window_id: nullableString() }, { additionalProperties: false }),
|
|
280
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
281
|
+
const items = filteredItems(ctx, params);
|
|
282
|
+
if ("error" in items) return output(items);
|
|
283
|
+
const matching = items.filter((item) => item.content.includes(params.query));
|
|
284
|
+
return output({ items: matching.slice(0, params.limit ?? matching.length).map((item) => visibleItem(item)) });
|
|
285
|
+
},
|
|
286
|
+
}));
|
|
287
|
+
|
|
288
|
+
pi.registerTool(defineTool({
|
|
289
|
+
name: "notes_list_files_by_prefix",
|
|
290
|
+
label: "Notes list files",
|
|
291
|
+
description: "List persistent, session-scoped virtual note files.",
|
|
292
|
+
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 }),
|
|
293
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
294
|
+
const prefix = assertVirtualPrefix(params.prefix);
|
|
295
|
+
let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
|
|
296
|
+
const key = params.file_order_by ?? "name";
|
|
297
|
+
files.sort(([aPath, a], [bPath, b]) => key === "name" ? aPath.localeCompare(bPath) : (key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt));
|
|
298
|
+
if (params.file_order === "descending") files.reverse();
|
|
299
|
+
return output({ files: files.slice(0, params.max_results ?? files.length).map(([path, file]) => ({ path, size_bytes: Buffer.byteLength(file.text, "utf8"), created_at: file.createdAt, updated_at: file.updatedAt })) });
|
|
300
|
+
},
|
|
301
|
+
}));
|
|
302
|
+
|
|
303
|
+
pi.registerTool(defineTool({
|
|
304
|
+
name: "notes_read_file",
|
|
305
|
+
label: "Notes read file",
|
|
306
|
+
description: "Read a virtual note file, optionally by inclusive 1-based line range; negative lines count from the end.",
|
|
307
|
+
parameters: Type.Object({ path: Type.String(), start_line: nullableInteger(), stop_line: nullableInteger() }, { additionalProperties: false }),
|
|
308
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
309
|
+
const path = assertVirtualPath(params.path);
|
|
310
|
+
const file = notesFromSession(ctx).get(path);
|
|
311
|
+
if (!file) return output({ error: "note file not found", path });
|
|
312
|
+
return output({ path, ...lineRange(file.text, params.start_line, params.stop_line) });
|
|
313
|
+
},
|
|
314
|
+
}));
|
|
315
|
+
|
|
316
|
+
pi.registerTool(defineTool({
|
|
317
|
+
name: "notes_search_contents",
|
|
318
|
+
label: "Notes search",
|
|
319
|
+
description: "Case-sensitive literal substring search over virtual note lines; no semantic search.",
|
|
320
|
+
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 }),
|
|
321
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
322
|
+
const prefix = assertVirtualPrefix(params.path_prefix);
|
|
323
|
+
let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
|
|
324
|
+
if (params.recent_file_first) files.sort((a, b) => b[1].createdAt - a[1].createdAt);
|
|
325
|
+
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
326
|
+
const result = files.map(([path, file]) => ({ path, 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);
|
|
327
|
+
return output({ files: result.slice(0, params.max_files ?? result.length) });
|
|
328
|
+
},
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
for (const [name, op] of [["notes_append_to_file", "append"], ["notes_write_file", "write"]] as const) {
|
|
332
|
+
pi.registerTool(defineTool({
|
|
333
|
+
name,
|
|
334
|
+
label: name === "notes_append_to_file" ? "Notes append" : "Notes write",
|
|
335
|
+
description: name === "notes_append_to_file" ? "Append exact text to a persistent virtual note file." : "Create or replace a persistent virtual note file.",
|
|
336
|
+
parameters: Type.Object({ text: Type.String(), path: Type.String() }, { additionalProperties: false }),
|
|
337
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
338
|
+
const path = assertVirtualPath(params.path);
|
|
339
|
+
const old = notesFromSession(ctx).get(path);
|
|
340
|
+
const next = op === "append" ? `${old?.text ?? ""}${params.text}` : params.text;
|
|
341
|
+
const bytes = Buffer.byteLength(next, "utf8");
|
|
342
|
+
if (bytes > MAX_NOTE_BYTES) return output({ error: `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`, path, size_bytes: bytes });
|
|
343
|
+
const now = Date.now();
|
|
344
|
+
saveNote({ op, path, text: params.text, createdAt: old?.createdAt ?? now, updatedAt: now });
|
|
345
|
+
return output({ path, size_bytes: bytes, operation: op });
|
|
346
|
+
},
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
pi.on("context", (event, ctx) => {
|
|
351
|
+
// Rebuilt per request, so no state diffing is needed; identical to Codex's
|
|
352
|
+
// context_window developer fragment rendered into each model call.
|
|
353
|
+
const hint = {
|
|
354
|
+
role: "user" as const,
|
|
355
|
+
content: [{ type: "text" as const, text: contextWindowHint(ctx) }],
|
|
356
|
+
timestamp: Date.now(),
|
|
357
|
+
};
|
|
358
|
+
return { messages: [hint, ...event.messages] };
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
pi.registerTool(defineTool({
|
|
362
|
+
name: "get_context_remaining",
|
|
363
|
+
label: "Get context remaining",
|
|
364
|
+
description: "Return remaining context tokens when Pi can estimate them, otherwise null.",
|
|
365
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
366
|
+
async execute(_id, _params, _signal, _update, ctx) {
|
|
367
|
+
const usage = ctx.getContextUsage();
|
|
368
|
+
const remaining = usage?.tokens === null || usage === undefined ? null : Math.max(0, usage.contextWindow - usage.tokens);
|
|
369
|
+
return output({ remaining_tokens: remaining });
|
|
370
|
+
},
|
|
371
|
+
}));
|
|
372
|
+
|
|
373
|
+
pi.registerTool(defineTool({
|
|
374
|
+
name: "new_context",
|
|
375
|
+
label: "New context",
|
|
376
|
+
description: "Request a reset-style context rollover after this tool result is safely recorded. Call alone in a tool batch.",
|
|
377
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
378
|
+
async execute() {
|
|
379
|
+
if (rollover === "idle") rollover = "requested";
|
|
380
|
+
return output({ status: rollover === "requested" ? "rollover_requested" : "rollover_already_pending" }, undefined, true);
|
|
381
|
+
},
|
|
382
|
+
}));
|
|
383
|
+
|
|
384
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
385
|
+
if (rollover !== "requested") return;
|
|
386
|
+
rollover = "compacting";
|
|
387
|
+
ctx.compact({ onError: () => { if (rollover === "compacting") rollover = "idle"; } });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
391
|
+
// Never let an aborted or failed custom reset fall through to Pi's default summary.
|
|
392
|
+
if (event.signal.aborted) return { cancel: true };
|
|
393
|
+
try {
|
|
394
|
+
pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: rollover === "compacting" });
|
|
395
|
+
const markerId = ctx.sessionManager.getLeafId();
|
|
396
|
+
if (!markerId) return { cancel: true };
|
|
397
|
+
return { compaction: { summary: RESET_SUMMARY, firstKeptEntryId: markerId, tokensBefore: event.preparation.tokensBefore, details: { piContext: "reset-v1" } } };
|
|
398
|
+
} catch {
|
|
399
|
+
return { cancel: true };
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
pi.on("session_compact", (event) => {
|
|
404
|
+
// Overflow retry is already continued once by Pi core. Sending another turn would duplicate it.
|
|
405
|
+
if (event.willRetry) return;
|
|
406
|
+
if (rollover !== "compacting") return;
|
|
407
|
+
rollover = "continued";
|
|
408
|
+
pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: event.compactionEntry.id });
|
|
409
|
+
pi.sendMessage({ customType: CONTINUATION_TYPE, content: CONTINUATION, display: false }, { triggerTurn: true });
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
pi.on("session_compact_failed", () => {
|
|
413
|
+
if (rollover === "compacting") rollover = "idle";
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, lineRange, assertVirtualPath };
|