@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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/package.json +11 -3
  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.1.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": ["pi-package", "pi-extension", "context-management"],
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": ["./src/index.ts"]
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: unknown) {
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 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
+ 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: unknown): HistoryItem["role"] | undefined {
63
- if (role === "user" || role === "assistant" || role === "system" || role === "developer") return role;
64
- if (role === "toolResult" || role === "tool") return "tool";
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 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 };
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() as unknown as Array<Json>) {
101
+ for (const entry of ctx.sessionManager.getBranch()) {
81
102
  if (entry.type === "compaction") {
82
- window = { windowId: `pcw:${sessionId}:${String(entry.id)}`, createdAt: typeof entry.timestamp === "string" ? entry.timestamp : undefined, items: [] };
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: String(entry.id),
107
+ itemId: entry.id,
87
108
  role: "system",
88
- content: typeof entry.summary === "string" ? entry.summary : "",
89
- createdAt: typeof entry.timestamp === "string" ? entry.timestamp : undefined,
109
+ content: entry.summary,
110
+ createdAt: entry.timestamp,
90
111
  });
91
112
  continue;
92
113
  }
93
114
  if (entry.type === "message") {
94
- const message = entry.message as Json;
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: String(entry.id),
119
+ itemId: entry.id,
100
120
  role,
101
- content: toText(message.content),
102
- createdAt: typeof entry.timestamp === "string" ? entry.timestamp : undefined,
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: String(entry.id),
130
+ itemId: entry.id,
111
131
  role: "user",
112
- content: toText(entry.content),
113
- createdAt: typeof entry.timestamp === "string" ? entry.timestamp : undefined,
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: Json): HistoryItem[] | { error: string } {
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() 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;
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) files.set(op.path, { text, createdAt, updatedAt });
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 hint = {
396
+ const userText = (text: string) => ({
354
397
  role: "user" as const,
355
- content: [{ type: "text" as const, text: contextWindowHint(ctx) }],
398
+ content: [{ type: "text" as const, text }],
356
399
  timestamp: Date.now(),
357
- };
358
- return { messages: [hint, ...event.messages] };
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 };