@astrosheep/pi-context 0.1.0 → 0.3.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 +4 -2
  2. package/package.json +11 -3
  3. package/src/index.ts +131 -50
package/README.md CHANGED
@@ -20,6 +20,8 @@ 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. Note: Pi's built-in auto-compaction fires when remaining context falls below `reserveTokens` (default 16,384), so raise the reminder threshold above your `reserveTokens` or the reminder never precedes compaction.
24
+ - **Runtime toggle** — `/pi-context off` disables hint injection, guidance, and reset-style compaction (Pi's default compaction, including `keepRecentTokens`, applies again). `/pi-context on` re-enables; a bare `/pi-context` reports the current state.
23
25
  - **History tools** — the model searches pre-reset conversation with case-sensitive literal substring search, exactly like Codex's `history.*` namespace.
24
26
  - **Notes tools** — persistent, session-scoped virtual files that survive window resets.
25
27
 
@@ -47,14 +49,14 @@ Pi has no public cross-agent session router. Passing `agent_name` to a history t
47
49
 
48
50
  Two extra controls compose Pi public APIs:
49
51
 
50
- - `get_context_remaining` returns `{ "remaining_tokens": number | null }`. `null` means Pi itself cannot make a reliable estimate (notably immediately after compaction).
52
+ - `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
53
  - `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
54
 
53
55
  ## Reset behavior and limits
54
56
 
55
57
  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
58
 
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.
59
+ 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. While the extension is enabled, its custom reset keeps nothing after the boundary marker, so Pi's `keepRecentTokens` setting has no effect; with `/pi-context off`, Pi's default compaction (and `keepRecentTokens`) applies again.
58
60
 
59
61
  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
62
 
package/package.json CHANGED
@@ -1,16 +1,22 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.1.0",
3
+ "version": "0.3.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";
60
62
  }
61
63
 
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";
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");
67
+ }
68
+
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,12 +264,30 @@ 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;
268
+ let enabled = true;
225
269
  const saveNote = (op: NoteOperation) => {
226
270
  // pi.appendEntry writes a custom SessionManager entry. Custom entries are persistent but excluded from LLM context.
227
271
  // ExtensionContext deliberately exposes only a readonly SessionManager, so this is the public extension write path.
228
272
  pi.appendEntry(NOTE_TYPE, op);
229
273
  };
230
274
 
275
+ pi.registerCommand("pi-context", {
276
+ description: "Toggle pi-context: context_window hint, low-budget guidance, and reset-style compaction",
277
+ getArgumentCompletions: (prefix) =>
278
+ ["on", "off"].filter((a) => a.startsWith(prefix)).map((a) => ({ value: a, label: a })),
279
+ handler: async (args, cmdCtx) => {
280
+ const arg = args.trim().toLowerCase();
281
+ if (arg === "on") enabled = true;
282
+ else if (arg === "off") enabled = false;
283
+ else if (arg !== "") {
284
+ cmdCtx.ui.notify("Usage: /pi-context [on|off]", "error");
285
+ return;
286
+ }
287
+ cmdCtx.ui.notify(`pi-context: ${enabled ? "on" : "off"}`, "info");
288
+ },
289
+ });
290
+
231
291
  pi.registerTool(defineTool({
232
292
  name: "history_list_windows",
233
293
  label: "History list windows",
@@ -348,14 +408,28 @@ export default function piContext(pi: ExtensionAPI) {
348
408
  }
349
409
 
350
410
  pi.on("context", (event, ctx) => {
411
+ if (!enabled) return undefined;
351
412
  // Rebuilt per request, so no state diffing is needed; identical to Codex's
352
413
  // context_window developer fragment rendered into each model call.
353
- const hint = {
414
+ const userText = (text: string) => ({
354
415
  role: "user" as const,
355
- content: [{ type: "text" as const, text: contextWindowHint(ctx) }],
416
+ content: [{ type: "text" as const, text }],
356
417
  timestamp: Date.now(),
357
- };
358
- return { messages: [hint, ...event.messages] };
418
+ });
419
+ const injected = [userText(contextWindowHint(ctx))];
420
+
421
+ // Codex token_budget.maybe_record parity: below the threshold, claim the
422
+ // reminder once per context window; a new window makes it eligible again.
423
+ const usage = ctx.getContextUsage();
424
+ if (usage && usage.tokens !== null) {
425
+ const remaining = Math.max(0, usage.contextWindow - usage.tokens);
426
+ const windowId = currentWindowId(ctx);
427
+ if (remaining <= REMINDER_THRESHOLD_TOKENS && reminderClaimedInWindow !== windowId) {
428
+ reminderClaimedInWindow = windowId;
429
+ injected.push(userText(tokenBudgetGuidance(remaining)));
430
+ }
431
+ }
432
+ return { messages: [...injected, ...event.messages] };
359
433
  });
360
434
 
361
435
  pi.registerTool(defineTool({
@@ -376,18 +450,24 @@ export default function piContext(pi: ExtensionAPI) {
376
450
  description: "Request a reset-style context rollover after this tool result is safely recorded. Call alone in a tool batch.",
377
451
  parameters: Type.Object({}, { additionalProperties: false }),
378
452
  async execute() {
453
+ if (!enabled) return output({ error: "pi-context is off (/pi-context on to enable)" });
379
454
  if (rollover === "idle") rollover = "requested";
380
455
  return output({ status: rollover === "requested" ? "rollover_requested" : "rollover_already_pending" }, undefined, true);
381
456
  },
382
457
  }));
383
458
 
384
459
  pi.on("agent_end", (_event, ctx) => {
460
+ if (!enabled) {
461
+ if (rollover === "requested") rollover = "idle";
462
+ return;
463
+ }
385
464
  if (rollover !== "requested") return;
386
465
  rollover = "compacting";
387
466
  ctx.compact({ onError: () => { if (rollover === "compacting") rollover = "idle"; } });
388
467
  });
389
468
 
390
469
  pi.on("session_before_compact", async (event, ctx) => {
470
+ if (!enabled) return undefined; // Default Pi compaction applies; keepRecentTokens is honored again.
391
471
  // Never let an aborted or failed custom reset fall through to Pi's default summary.
392
472
  if (event.signal.aborted) return { cancel: true };
393
473
  try {
@@ -401,6 +481,7 @@ export default function piContext(pi: ExtensionAPI) {
401
481
  });
402
482
 
403
483
  pi.on("session_compact", (event) => {
484
+ if (!enabled) return;
404
485
  // Overflow retry is already continued once by Pi core. Sending another turn would duplicate it.
405
486
  if (event.willRetry) return;
406
487
  if (rollover !== "compacting") return;
@@ -414,4 +495,4 @@ export default function piContext(pi: ExtensionAPI) {
414
495
  });
415
496
  }
416
497
 
417
- export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, lineRange, assertVirtualPath };
498
+ 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 };