@astrosheep/pi-context 0.1.0 → 0.2.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 +2 -1
- package/package.json +11 -3
- package/src/index.ts +106 -50
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ The extension composes Pi's public `session_before_compact` / `session_compact`
|
|
|
20
20
|
|
|
21
21
|
- **`new_context` tool** — the model requests a fresh context window. The extension waits for the current tool turn to end, compacts with a short deterministic reset message (old conversation is excluded from the new provider context but stays in the session), then sends exactly one hidden continuation turn.
|
|
22
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
|
+
- **Low-budget guidance** — when remaining context drops to 16,000 tokens or below, a `<context_window_guidance>` reminder is injected exactly once per window (re-armed after each reset), matching Codex's `token_budget` reminder semantics: it tells the model to persist state with `notes_write_file` and call `new_context` before the window closes.
|
|
23
24
|
- **History tools** — the model searches pre-reset conversation with case-sensitive literal substring search, exactly like Codex's `history.*` namespace.
|
|
24
25
|
- **Notes tools** — persistent, session-scoped virtual files that survive window resets.
|
|
25
26
|
|
|
@@ -47,7 +48,7 @@ Pi has no public cross-agent session router. Passing `agent_name` to a history t
|
|
|
47
48
|
|
|
48
49
|
Two extra controls compose Pi public APIs:
|
|
49
50
|
|
|
50
|
-
- `get_context_remaining` returns `{ "remaining_tokens": number | null }`. `null` means Pi itself cannot make a reliable estimate (notably immediately after compaction).
|
|
51
|
+
- `get_context_remaining` returns `{ "remaining_tokens": number | null }`. `null` means Pi itself cannot make a reliable estimate (notably immediately after compaction). The low-budget guidance uses the same source and stays silent when the estimate is unknown.
|
|
51
52
|
- `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
|
|
|
53
54
|
## Reset behavior and limits
|
package/package.json
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrosheep/pi-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"keywords": [
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"context-management"
|
|
11
|
+
],
|
|
8
12
|
"repository": {
|
|
9
13
|
"type": "git",
|
|
10
14
|
"url": "https://github.com/astrosheep-zero/pi-context.git"
|
|
11
15
|
},
|
|
12
16
|
"pi": {
|
|
13
|
-
"extensions": [
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./src/index.ts"
|
|
19
|
+
]
|
|
14
20
|
},
|
|
15
21
|
"files": [
|
|
16
22
|
"src",
|
|
@@ -24,10 +30,12 @@
|
|
|
24
30
|
"prepublishOnly": "npm run typecheck"
|
|
25
31
|
},
|
|
26
32
|
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-agent-core": "*",
|
|
27
34
|
"@earendil-works/pi-ai": "*",
|
|
28
35
|
"@earendil-works/pi-coding-agent": "*"
|
|
29
36
|
},
|
|
30
37
|
"devDependencies": {
|
|
38
|
+
"@earendil-works/pi-agent-core": "^0.85.1",
|
|
31
39
|
"@earendil-works/pi-ai": "^0.85.1",
|
|
32
40
|
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
33
41
|
"@types/node": "^22.19.19",
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Type } from "@earendil-works/pi-ai";
|
|
1
|
+
import { Type, type TextContent } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
3
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
|
|
4
5
|
const STATE_TYPE = "pi-context/state";
|
|
@@ -8,10 +9,12 @@ const CONTINUATION_TYPE = "pi-context/continuation";
|
|
|
8
9
|
const MAX_NOTE_BYTES = 1_000_000;
|
|
9
10
|
const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
10
11
|
const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
12
|
+
const GUIDANCE_OPEN_TAG = "<context_window_guidance>";
|
|
13
|
+
const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
|
|
14
|
+
const REMINDER_THRESHOLD_TOKENS = 16_000;
|
|
11
15
|
const RESET_SUMMARY = "Context window reset. Prior session entries remain available only through the pi-context history tools.";
|
|
12
16
|
const CONTINUATION = "This is a fresh context window. Recover only the details needed to continue with history_* and notes_*; then continue the task.";
|
|
13
17
|
|
|
14
|
-
type Json = Record<string, unknown>;
|
|
15
18
|
type NoteFile = { text: string; createdAt: number; updatedAt: number };
|
|
16
19
|
type NoteOperation = {
|
|
17
20
|
op: "write" | "append";
|
|
@@ -31,6 +34,15 @@ type HistoryItem = {
|
|
|
31
34
|
};
|
|
32
35
|
type HistoryWindow = { windowId: string; createdAt?: string; items: HistoryItem[] };
|
|
33
36
|
|
|
37
|
+
type HistoryFilter = {
|
|
38
|
+
agent_name?: string | null;
|
|
39
|
+
window_id?: string | null;
|
|
40
|
+
role?: HistoryItem["role"] | null;
|
|
41
|
+
tool_namespace?: string | null;
|
|
42
|
+
tool_name?: string | null;
|
|
43
|
+
recent_first?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
34
46
|
function json(value: unknown): string {
|
|
35
47
|
return JSON.stringify(value, null, 2);
|
|
36
48
|
}
|
|
@@ -39,37 +51,46 @@ function output(value: unknown, details: unknown = value, terminate = false) {
|
|
|
39
51
|
return { content: [{ type: "text" as const, text: json(value) }], details, terminate };
|
|
40
52
|
}
|
|
41
53
|
|
|
42
|
-
function unsupportedAgent(agentName:
|
|
54
|
+
function unsupportedAgent(agentName: string | null | undefined) {
|
|
43
55
|
return agentName !== undefined && agentName !== null
|
|
44
56
|
? { error: "Pi 0.85.1 exposes no cross-agent session routing; agent_name is unsupported and was not aliased to this session." }
|
|
45
57
|
: undefined;
|
|
46
58
|
}
|
|
47
59
|
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return JSON.stringify(part);
|
|
56
|
-
})
|
|
57
|
-
.join("\n");
|
|
58
|
-
}
|
|
59
|
-
return value === undefined || value === null ? "" : JSON.stringify(value);
|
|
60
|
+
function isTextContent(part: unknown): part is TextContent {
|
|
61
|
+
return typeof part === "object" && part !== null && (part as TextContent).type === "text" && typeof (part as TextContent).text === "string";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function contentText(content: string | unknown[]): string {
|
|
65
|
+
if (typeof content === "string") return content;
|
|
66
|
+
return content.filter(isTextContent).map((part) => part.text).join("\n");
|
|
60
67
|
}
|
|
61
68
|
|
|
62
|
-
function mapRole(role:
|
|
63
|
-
if (role === "user" || role === "assistant"
|
|
64
|
-
if (role === "toolResult" || role === "
|
|
69
|
+
function mapRole(role: AgentMessage["role"]): HistoryItem["role"] | undefined {
|
|
70
|
+
if (role === "user" || role === "assistant") return role;
|
|
71
|
+
if (role === "toolResult" || role === "bashExecution") return "tool";
|
|
72
|
+
if (role === "custom") return "user";
|
|
73
|
+
if (role === "compactionSummary" || role === "branchSummary") return "system";
|
|
65
74
|
return undefined;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
function messageContent(message: AgentMessage): string {
|
|
78
|
+
switch (message.role) {
|
|
79
|
+
case "bashExecution":
|
|
80
|
+
return message.output;
|
|
81
|
+
case "branchSummary":
|
|
82
|
+
case "compactionSummary":
|
|
83
|
+
return message.summary;
|
|
84
|
+
default:
|
|
85
|
+
return contentText(message.content);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName" | "toolNamespace"> {
|
|
90
|
+
if (message.role === "bashExecution") return { toolName: "bash", toolNamespace: undefined };
|
|
91
|
+
if (message.role !== "toolResult") return {};
|
|
92
|
+
const underscore = message.toolName.indexOf("_");
|
|
93
|
+
return { toolName: message.toolName, toolNamespace: underscore > 0 ? message.toolName.slice(0, underscore) : undefined };
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
/** Build durable, on-demand history directly from every entry on the current session branch. */
|
|
@@ -77,40 +98,39 @@ export function historyFromSession(ctx: ExtensionContext): HistoryWindow[] {
|
|
|
77
98
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
78
99
|
let window: HistoryWindow = { windowId: `pcw:${sessionId}:root`, items: [] };
|
|
79
100
|
const windows = [window];
|
|
80
|
-
for (const entry of ctx.sessionManager.getBranch()
|
|
101
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
81
102
|
if (entry.type === "compaction") {
|
|
82
|
-
window = { windowId: `pcw:${sessionId}:${
|
|
103
|
+
window = { windowId: `pcw:${sessionId}:${entry.id}`, createdAt: entry.timestamp, items: [] };
|
|
83
104
|
windows.push(window);
|
|
84
105
|
window.items.push({
|
|
85
106
|
windowId: window.windowId,
|
|
86
|
-
itemId:
|
|
107
|
+
itemId: entry.id,
|
|
87
108
|
role: "system",
|
|
88
|
-
content:
|
|
89
|
-
createdAt:
|
|
109
|
+
content: entry.summary,
|
|
110
|
+
createdAt: entry.timestamp,
|
|
90
111
|
});
|
|
91
112
|
continue;
|
|
92
113
|
}
|
|
93
114
|
if (entry.type === "message") {
|
|
94
|
-
const
|
|
95
|
-
const role = mapRole(message.role);
|
|
115
|
+
const role = mapRole(entry.message.role);
|
|
96
116
|
if (!role) continue;
|
|
97
117
|
window.items.push({
|
|
98
118
|
windowId: window.windowId,
|
|
99
|
-
itemId:
|
|
119
|
+
itemId: entry.id,
|
|
100
120
|
role,
|
|
101
|
-
content:
|
|
102
|
-
createdAt:
|
|
103
|
-
...toolInfo(message),
|
|
121
|
+
content: messageContent(entry.message),
|
|
122
|
+
createdAt: entry.timestamp,
|
|
123
|
+
...toolInfo(entry.message),
|
|
104
124
|
});
|
|
105
125
|
continue;
|
|
106
126
|
}
|
|
107
127
|
if (entry.type === "custom_message") {
|
|
108
128
|
window.items.push({
|
|
109
129
|
windowId: window.windowId,
|
|
110
|
-
itemId:
|
|
130
|
+
itemId: entry.id,
|
|
111
131
|
role: "user",
|
|
112
|
-
content:
|
|
113
|
-
createdAt:
|
|
132
|
+
content: contentText(entry.content),
|
|
133
|
+
createdAt: entry.timestamp,
|
|
114
134
|
});
|
|
115
135
|
}
|
|
116
136
|
}
|
|
@@ -133,7 +153,7 @@ function allItems(ctx: ExtensionContext) {
|
|
|
133
153
|
return historyFromSession(ctx).flatMap((window) => window.items);
|
|
134
154
|
}
|
|
135
155
|
|
|
136
|
-
function filteredItems(ctx: ExtensionContext, params:
|
|
156
|
+
function filteredItems(ctx: ExtensionContext, params: HistoryFilter): HistoryItem[] | { error: string } {
|
|
137
157
|
const agentError = unsupportedAgent(params.agent_name);
|
|
138
158
|
if (agentError) return agentError;
|
|
139
159
|
let items = allItems(ctx);
|
|
@@ -158,22 +178,34 @@ function assertVirtualPrefix(value: unknown): string | undefined {
|
|
|
158
178
|
return assertVirtualPath(value);
|
|
159
179
|
}
|
|
160
180
|
|
|
181
|
+
/** Replays only pi-context note operations from session custom entries. */
|
|
182
|
+
function isNoteOperation(data: unknown): data is NoteOperation {
|
|
183
|
+
if (typeof data !== "object" || data === null) return false;
|
|
184
|
+
const op = data as Partial<NoteOperation>;
|
|
185
|
+
return (
|
|
186
|
+
(op.op === "write" || op.op === "append") &&
|
|
187
|
+
typeof op.path === "string" &&
|
|
188
|
+
typeof op.text === "string" &&
|
|
189
|
+
typeof op.createdAt === "number" &&
|
|
190
|
+
typeof op.updatedAt === "number"
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
161
194
|
export function notesFromSession(ctx: ExtensionContext): Map<string, NoteFile> {
|
|
162
195
|
const files = new Map<string, NoteFile>();
|
|
163
|
-
for (const entry of ctx.sessionManager.getBranch()
|
|
164
|
-
if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !entry.data
|
|
165
|
-
const op = entry.data
|
|
166
|
-
if ((op.op !== "write" && op.op !== "append") || typeof op.path !== "string" || typeof op.text !== "string") continue;
|
|
196
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
197
|
+
if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data)) continue;
|
|
198
|
+
const op = entry.data;
|
|
167
199
|
try {
|
|
168
200
|
assertVirtualPath(op.path);
|
|
169
201
|
} catch {
|
|
170
202
|
continue;
|
|
171
203
|
}
|
|
172
204
|
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
205
|
const text = op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text;
|
|
176
|
-
if (Buffer.byteLength(text, "utf8") <= MAX_NOTE_BYTES)
|
|
206
|
+
if (Buffer.byteLength(text, "utf8") <= MAX_NOTE_BYTES) {
|
|
207
|
+
files.set(op.path, { text, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
|
|
208
|
+
}
|
|
177
209
|
}
|
|
178
210
|
return files;
|
|
179
211
|
}
|
|
@@ -202,6 +234,16 @@ export function contextWindowHint(ctx: ExtensionContext): string {
|
|
|
202
234
|
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
203
235
|
}
|
|
204
236
|
|
|
237
|
+
/** Codex-equivalent low-budget reminder: threshold-gated, claimed once per context window. */
|
|
238
|
+
function tokenBudgetGuidance(remaining: number): string {
|
|
239
|
+
return `${GUIDANCE_OPEN_TAG}\nYou have ${remaining} tokens left in this context window. Write durable state with notes_write_file and call new_context before the window closes.\n${GUIDANCE_CLOSE_TAG}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function currentWindowId(ctx: ExtensionContext): string | undefined {
|
|
243
|
+
const windows = historyFromSession(ctx);
|
|
244
|
+
return windows[windows.length - 1]?.windowId;
|
|
245
|
+
}
|
|
246
|
+
|
|
205
247
|
function lineRange(text: string, startValue: unknown, stopValue: unknown) {
|
|
206
248
|
const lines = text.split("\n");
|
|
207
249
|
const resolve = (value: unknown, fallback: number) => {
|
|
@@ -222,6 +264,7 @@ const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.L
|
|
|
222
264
|
|
|
223
265
|
export default function piContext(pi: ExtensionAPI) {
|
|
224
266
|
let rollover: "idle" | "requested" | "compacting" | "continued" = "idle";
|
|
267
|
+
let reminderClaimedInWindow: string | undefined;
|
|
225
268
|
const saveNote = (op: NoteOperation) => {
|
|
226
269
|
// pi.appendEntry writes a custom SessionManager entry. Custom entries are persistent but excluded from LLM context.
|
|
227
270
|
// ExtensionContext deliberately exposes only a readonly SessionManager, so this is the public extension write path.
|
|
@@ -350,12 +393,25 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
350
393
|
pi.on("context", (event, ctx) => {
|
|
351
394
|
// Rebuilt per request, so no state diffing is needed; identical to Codex's
|
|
352
395
|
// context_window developer fragment rendered into each model call.
|
|
353
|
-
const
|
|
396
|
+
const userText = (text: string) => ({
|
|
354
397
|
role: "user" as const,
|
|
355
|
-
content: [{ type: "text" as const, text
|
|
398
|
+
content: [{ type: "text" as const, text }],
|
|
356
399
|
timestamp: Date.now(),
|
|
357
|
-
};
|
|
358
|
-
|
|
400
|
+
});
|
|
401
|
+
const injected = [userText(contextWindowHint(ctx))];
|
|
402
|
+
|
|
403
|
+
// Codex token_budget.maybe_record parity: below the threshold, claim the
|
|
404
|
+
// reminder once per context window; a new window makes it eligible again.
|
|
405
|
+
const usage = ctx.getContextUsage();
|
|
406
|
+
if (usage && usage.tokens !== null) {
|
|
407
|
+
const remaining = Math.max(0, usage.contextWindow - usage.tokens);
|
|
408
|
+
const windowId = currentWindowId(ctx);
|
|
409
|
+
if (remaining <= REMINDER_THRESHOLD_TOKENS && reminderClaimedInWindow !== windowId) {
|
|
410
|
+
reminderClaimedInWindow = windowId;
|
|
411
|
+
injected.push(userText(tokenBudgetGuidance(remaining)));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return { messages: [...injected, ...event.messages] };
|
|
359
415
|
});
|
|
360
416
|
|
|
361
417
|
pi.registerTool(defineTool({
|
|
@@ -414,4 +470,4 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
414
470
|
});
|
|
415
471
|
}
|
|
416
472
|
|
|
417
|
-
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, lineRange, assertVirtualPath };
|
|
473
|
+
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, GUIDANCE_OPEN_TAG, REMINDER_THRESHOLD_TOKENS, lineRange, assertVirtualPath };
|