@astrosheep/pi-context 0.14.0 → 0.15.1

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.
@@ -11,7 +11,9 @@ pi-context uses Pi's session branch as the durable source of truth. It does not
11
11
  | `notes.ts` | Replay note operations, validate paths/timestamps | `SessionReader`; no scheduling or writes |
12
12
  | `history-tools.ts`, `note-tools.ts` | Public schemas and tool results; append validated note operations | Pi tool API plus read projections |
13
13
  | `budget.ts` | Resolve settings, report usable budget, persist guidance once | Pi settings/context hooks |
14
- | `prompts.ts` | Render static boot block, note index and reminder | Read projections and protocol text |
14
+ | `thresholds.ts` | Derive the reminder/reserve/warning lines from Pi's reserve plus the pi-context margins | Pi `SettingsManager`, read-only; session-scoped cache |
15
+ | `warning.ts` | Steer the final checkpoint warning once per window | Pi context hook |
16
+ | `prompts.ts` | Render static boot block, note index, reminder and warning | Read projections and protocol text |
15
17
  | `reset-lifecycle.ts` | Own reset requests, completion and continuation | Pi lifecycle hooks and injected boundary builder |
16
18
  | `protocol.ts` | Persisted entry tags, protocol text and defaults | No imports or effects |
17
19
  | `tool-schema.ts`, `tool-output.ts` | Shared wire-schema primitives and JSON result encoding | No session state |
@@ -22,7 +24,7 @@ Dependencies flow from the composition root and tool adapters to projections and
22
24
 
23
25
  Reset requests are a discriminated union: `idle`, `requested`, or `compacting` with an identified attempt. A request cannot simultaneously be pending and in flight. Each attempt records its originating session, whether it was explicit, and whether a matching boundary completed. Callback identity prevents an old attempt from consuming a newer one. See [reset lifecycle](reset-lifecycle.md).
24
26
 
25
- Fallback allowance is a separate per-window state: `available`, `borrowed`, `ready`, `spent`. It answers whether another note-taking turn may be borrowed, independently of whether an explicit request exists. Success re-arms the allowance; failure does not create a retry loop. Runtime shutdown and tree navigation invalidate outstanding requests.
27
+ Manual, threshold and overflow compactions all build the reset boundary on the spot, idle or streaming: `session_before_compact` returns the reset immediately, never cancels and never takes a model turn, and only an aborted signal cancels. The final checkpoint warning was already steered from the context hook (see [reset lifecycle](reset-lifecycle.md)), so the model had its chance to write a note; what crosses the line now is the wipe itself. `agent_settled` services only explicit `new_context` requests, whose `ctx.compact` route needs the completion callback.
26
28
 
27
29
  Boot and reminder deduplication inspect messages in the current persisted window. Reloading the extension or the JSONL file therefore does not duplicate either message. Reminder reservation in memory covers Pi's deferred message write; navigation clears that reservation, while persisted branch-local messages remain authoritative. A sibling branch cannot suppress a reminder it never received.
28
30
 
@@ -34,6 +36,6 @@ Reset IDs are opaque strings tagged with `reset-v2`; newly minted IDs use `pcw:<
34
36
 
35
37
  The integration suite uses real SessionManager and SettingsManager instances, including JSONL restoration, branch navigation, Unicode content, malformed note operations and settings precedence. Lifecycle event tests cover duplicate/stale callbacks, native scheduling, disabled state, abort and failed compaction.
36
38
 
37
- Scripted SDK tests execute the real Pi agent loop with no model network request. They cover explicit and fallback reset, rejected compaction, steering and follow-up delivery before reset without replay, consecutive distinct windows, and cancellation followed by a new user prompt. They inspect actual provider contexts and durable entries. They do not establish reliability of an external provider or every possible interleaving between unrelated extensions.
39
+ Scripted SDK tests execute the real Pi agent loop with no model network request. They cover explicit reset, instant automatic reset, rejected compaction, steering and follow-up delivery before reset without replay, consecutive distinct windows, and cancellation followed by a new user prompt. They inspect actual provider contexts and durable entries. They do not establish reliability of an external provider or every possible interleaving between unrelated extensions.
38
40
 
39
41
  Pi decides compaction eligibility before the boundary hook. A short uncompactable session therefore cannot be force-reset with the public API. Mixed tool batches and queued messages may finish before `agent_settled`; the extension preserves their delivery rather than clearing the queue. Native compaction owns its subsequent scheduling, while extension-requested compaction resumes from `onComplete` after Pi clears compaction state.
@@ -1,21 +1,22 @@
1
1
  # Reset lifecycle
2
2
 
3
- `src/reset-lifecycle.ts` owns requests, fallback allowance, compaction attempts, and continuation. `src/index.ts` composes the features and constructs reset boundaries; projections, tools, budget policy, and prompt rendering have separate modules described in [Architecture](architecture.md).
3
+ `src/reset-lifecycle.ts` owns reset requests, compaction attempts, and continuation. `src/index.ts` composes the features and constructs reset boundaries; projections, tools, budget policy, and prompt rendering have separate modules described in [Architecture](architecture.md).
4
4
 
5
5
  | Event | Transition / owner |
6
6
  | --- | --- |
7
7
  | `new_context` | Mark explicit request; repeated calls report already pending. Tool returns terminal output. |
8
- | Streaming automatic `session_before_compact` | Available borrowed; send one note-taking steer and cancel this compaction. |
9
- | Idle automatic or manual compaction | Build reset directly; no borrowed turn. |
10
- | `agent_end` | Borrowed ready. Aborted run clears explicit request and spends borrowed allowance. |
11
- | `agent_settled` | If idle and explicit/ready, create one identified attempt and request `ctx.compact`. |
12
- | Matching `session_compact` | Confirm boundary, persist window state, re-arm allowance. Native compaction retains its own scheduling. |
8
+ | Manual, threshold or overflow `session_before_compact`, idle or streaming | Build the reset boundary immediately and return it. Never cancel and never take a model turn; an aborted signal returns `{ cancel: true }`. |
9
+ | `agent_end` | No-op for an instant reset. |
10
+ | `agent_settled` | If idle and an explicit request is pending, create one identified attempt and request `ctx.compact`. |
11
+ | Matching `session_compact` | Confirm boundary, persist window state. Native compaction retains its own scheduling. |
13
12
  | Attempt `onComplete` | Consume attempt; send continuation only for a confirmed boundary when idle with no queued messages. |
14
13
  | Attempt `onError` or synchronous throw | Clear attempt/request, warn, retain history. No automatic retry loop. |
15
14
  | Shutdown / start / tree / toggle off | Invalidate outstanding attempt. Identity checks reject callbacks from older attempts. |
16
15
 
17
- The completion callback is the scheduling boundary: `session_compact` fires before Pi clears manual compaction state. Sending a prompt inside that hook is too early. Both explicit and borrowed-fallback resets use the manual `ctx.compact` route and therefore need the same completion logic. Native compaction/retry already has a caller responsible for subsequent work.
16
+ The final checkpoint warning is steered earlier from the context hook (`warning.ts`) once per window at reserve+8192 tokens remaining. After it, the model either ends the window itself with `new_context` or rides into Pi's automatic compaction, which resets on the spot with no turn.
17
+
18
+ The completion callback is the scheduling boundary: `session_compact` fires before Pi clears manual compaction state. Sending a prompt inside that hook is too early. An explicit reset uses the manual `ctx.compact` route and therefore needs this completion logic; an automatic compaction is already the reset and resumes through Pi's own caller.
18
19
 
19
20
  Public APIs cannot guarantee immediate reset inside mixed tool batches or before queued steering/follow-up messages finish. `terminate` ends the tool-followup path; `agent_settled` remains the safe point to request compaction. The scheduler does not manipulate user queues. Pi also determines compaction eligibility before the extension hook; an uncompactable session produces a warning and waits for a new prompt.
20
21
 
21
- Validation is split into persisted-data integration tests, isolated lifecycle event tests, and scripted SDK tests running Pi's actual agent loop. The SDK tests cover explicit success, fallback success, and core compaction rejection followed by a user prompt. Lifecycle tests cover callback races and queue guards without pretending to exercise provider/network behavior.
22
+ Validation is split into persisted-data integration tests, isolated lifecycle event tests, and scripted SDK tests running Pi's actual agent loop. The SDK tests cover explicit success, instant automatic reset, and core compaction rejection followed by a user prompt. Lifecycle tests cover callback races and queue guards without pretending to exercise provider/network behavior.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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",
package/src/budget.ts CHANGED
@@ -1,113 +1,49 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
- import { defineTool, SettingsManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, GUIDANCE_TYPE, FALLBACK_TYPE } from "./protocol.js";
2
+ import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { GUIDANCE_TYPE, WARNING_TYPE } from "./protocol.js";
4
+ import { thresholdsFor, resetThresholds, deriveThresholds, mergePiContextSettings } from "./thresholds.js";
4
5
  import { currentWindowId, hasWindowMessage } from "./history.js";
5
6
  import { tokenBudgetGuidance } from "./prompts.js";
6
7
  import { output } from "./tool-output.js";
7
8
 
8
- type ResolvedThresholds = { reminder: number; reserve: number };
9
- type PiContextMargins = { reminderMarginTokens: unknown };
10
-
11
- function isSettingsObject(value: unknown): value is Record<string, unknown> {
12
- return typeof value === "object" && value !== null && !Array.isArray(value);
13
- }
14
-
15
- /** Read the raw "pi-context" object from one parsed settings scope. */
16
- function piContextSettings(settings: unknown): Record<string, unknown> {
17
- if (!isSettingsObject(settings)) return {};
18
- const value = settings[PI_CONTEXT_SETTINGS_KEY];
19
- return isSettingsObject(value) ? value : {};
20
- }
21
-
22
- /** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
23
- export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextMargins {
24
- const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
25
- return { reminderMarginTokens: merged.reminderMarginTokens };
26
- }
27
-
28
- /** A margin is usable only as a positive integer; anything else is ignored. */
29
- function validMargin(raw: unknown): number | undefined {
30
- if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return undefined;
31
- return raw;
32
- }
33
-
34
- /**
35
- * Pure derivation of the reminder threshold from Pi's reserve plus the pi-context
36
- * reminder margin. An invalid margin degrades to the default and reports one warning.
37
- * The borrowed fallback turn has no token threshold of its own: it is driven by Pi's
38
- * automatic threshold/overflow compaction request (see session_before_compact).
39
- */
40
- export function deriveThresholds(reserveTokens: number, margins: PiContextMargins): { thresholds: ResolvedThresholds; warnings: string[] } {
41
- const warnings: string[] = [];
42
- const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
43
- let reminderMargin: number;
44
- if (margins.reminderMarginTokens === undefined) reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
45
- else {
46
- const parsed = validMargin(margins.reminderMarginTokens);
47
- if (parsed === undefined) {
48
- warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
49
- reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
50
- } else reminderMargin = parsed;
51
- }
52
- return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens }, warnings };
53
- }
9
+ export { deriveThresholds, mergePiContextSettings } from "./thresholds.js";
54
10
 
55
11
  export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean) {
56
12
  let guidancePersistedInWindow: string | undefined;
57
- let thresholds: ResolvedThresholds | undefined;
58
-
59
- /**
60
- * Resolve the thresholds for this session from Pi's compaction reserve plus the
61
- * settings.json "pi-context" margins. The file-backed read is cached until the next
62
- * session_start; invalid configuration degrades per offending key with one warning
63
- * and never throws during session operation.
64
- */
65
- const resolveThresholds = (ctx: ExtensionContext): ResolvedThresholds => {
66
- if (thresholds) return thresholds;
67
- try {
68
- const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
69
- const derived = deriveThresholds(
70
- settingsManager.getCompactionSettings().reserveTokens,
71
- mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()),
72
- );
73
- for (const warning of derived.warnings) ctx.ui.notify(warning, "warning");
74
- thresholds = derived.thresholds;
75
- } catch (error) {
76
- ctx.ui.notify(`pi-context: could not read settings; using defaults (${String(error)}).`, "warning");
77
- thresholds = { reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS, reserve: DEFAULT_RESERVE_TOKENS };
78
- }
79
- return thresholds;
80
- };
81
13
 
82
- pi.on("session_start", (_event, ctx) => { thresholds = undefined; guidancePersistedInWindow = undefined; resolveThresholds(ctx); });
83
- pi.on("session_tree", () => { guidancePersistedInWindow = undefined; });
14
+ pi.on("session_start", (_event, ctx) => { guidancePersistedInWindow = undefined; resetThresholds(); thresholdsFor(ctx); });
15
+ pi.on("session_tree", () => { guidancePersistedInWindow = undefined; resetThresholds(); });
84
16
  pi.on("context", (_event, ctx) => {
85
- if (!isEnabled() || hasWindowMessage(ctx, FALLBACK_TYPE)) return undefined;
86
- // This hook does exactly one thing: persist the once-per-window low-budget
87
- // reminder the first time remaining context crosses the reminder threshold.
88
- // It never injects messages into the request.
17
+ if (!isEnabled() || hasWindowMessage(ctx, GUIDANCE_TYPE)) return undefined;
18
+ // The early reminder persists once per window the first time remaining crosses
19
+ // reserve+margin. It never edits the outgoing request.
89
20
  const usage = ctx.getContextUsage();
90
- if (usage && usage.tokens !== null) {
91
- const remaining = Math.max(0, usage.contextWindow - usage.tokens);
92
- const windowId = currentWindowId(ctx);
93
- const { reminder, reserve } = resolveThresholds(ctx);
94
- if (remaining <= reminder && guidancePersistedInWindow !== windowId && !hasWindowMessage(ctx, GUIDANCE_TYPE)) {
95
- guidancePersistedInWindow = windowId;
96
- // Persist once per window no transient copy. A transient bridge would
97
- // cover the crossing request, but history would record the reminder after
98
- // that request's assistant reply, so across the boundary the model would
99
- // meet the same text twice at shifted positions. The reminder is an early
100
- // warning, not a per-request instruction: arriving from the next request
101
- // on (sendMessage defers safely to end of turn while streaming, queueing
102
- // instead of splitting a tool call/result pair) costs nothing, and the
103
- // model's view stays identical to recorded history, Codex-style.
104
- // The persisted copy stays out of the TUI (display: false); one ephemeral
105
- // notify tells the user instead visible to the human, invisible to the
106
- // model, and never recorded, so history and the model's view don't diverge.
107
- const left = Math.max(0, remaining - reserve);
108
- pi.sendMessage({ customType: GUIDANCE_TYPE, content: tokenBudgetGuidance(left), display: false }, { triggerTurn: false });
109
- ctx.ui.notify(`pi-context: context budget low (${left} tokens before reserve) checkpoint reminder recorded for the model, kept out of the chat view.`, "warning");
110
- }
21
+ if (!usage || usage.tokens === null) return undefined;
22
+ const remaining = Math.max(0, usage.contextWindow - usage.tokens);
23
+ const windowId = currentWindowId(ctx);
24
+ const { reminder, reserve, warning } = thresholdsFor(ctx);
25
+ // The final warning owns the deep band: when it has fired (or is due now),
26
+ // the shallow reminder would only repeat the same instruction closer to
27
+ // the wipe, at a worse position. See warning.ts.
28
+ if (remaining <= warning || hasWindowMessage(ctx, WARNING_TYPE)) return undefined;
29
+ if (remaining <= reminder && guidancePersistedInWindow !== windowId && !hasWindowMessage(ctx, GUIDANCE_TYPE)) {
30
+ guidancePersistedInWindow = windowId;
31
+ // Persist once per window no transient copy. A transient bridge would
32
+ // cover the crossing request, but history would record the reminder after
33
+ // that request's assistant reply, so across the boundary the model would
34
+ // meet the same text twice at shifted positions. The reminder is an early
35
+ // warning, not a per-request instruction: arriving from the next request
36
+ // on (sendMessage defers safely to end of turn while streaming, queueing
37
+ // instead of splitting a tool call/result pair) costs nothing, and the
38
+ // model's view stays identical to recorded history, Codex-style.
39
+ // The persisted copy stays out of the TUI (display: false); one ephemeral
40
+ // notify tells the user insteadvisible to the human, invisible to the
41
+ // model, and never recorded, so history and the model's view don't diverge.
42
+ // The model-facing count ends at the warning line: what lies below is the
43
+ // runway, invisible by design. The human's notify keeps the honest count.
44
+ const left = Math.max(0, remaining - warning);
45
+ pi.sendMessage({ customType: GUIDANCE_TYPE, content: tokenBudgetGuidance(left), display: false }, { triggerTurn: false });
46
+ ctx.ui.notify(`pi-context: context budget low (${Math.max(0, remaining - reserve)} tokens before reserve) — checkpoint reminder recorded for the model, kept out of the chat view.`, "warning");
111
47
  }
112
48
  return undefined;
113
49
  });
@@ -115,11 +51,13 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean) {
115
51
  pi.registerTool(defineTool({
116
52
  name: "get_context_remaining",
117
53
  label: "Get context remaining",
118
- description: "Return estimated context tokens available before the compaction reserve, clamped to zero; null when Pi cannot estimate usage.",
54
+ description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
119
55
  parameters: Type.Object({}, { additionalProperties: false }),
120
56
  async execute(_id, _params, _signal, _update, ctx) {
121
57
  const usage = ctx.getContextUsage();
122
- const remaining = usage?.tokens === null || usage === undefined ? null : Math.max(0, usage.contextWindow - usage.tokens - resolveThresholds(ctx).reserve);
58
+ // The countdown the model sees ends at the warning line (reserve + runway);
59
+ // the runway below it is overdraft the model never sees. See protocol.ts.
60
+ const remaining = usage?.tokens === null || usage === undefined ? null : Math.max(0, usage.contextWindow - usage.tokens - thresholdsFor(ctx as ExtensionContext).warning);
123
61
  return output({ remaining_tokens: remaining });
124
62
  },
125
63
  }));
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { output, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow } from "./tool-output.js";
3
+ import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget } from "./tool-output.js";
4
4
  import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
5
  import { historyFromSession, filteredItems, visibleItem, allItems } from "./history.js";
6
6
 
@@ -57,12 +57,16 @@ export function registerHistoryTools(pi: ExtensionAPI) {
57
57
  pi.registerTool(defineTool({
58
58
  name: "history_read_item",
59
59
  label: "History read item",
60
- description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget. next_offset_chars is exactly offset_chars plus the delivered code-point count and is null only once the item ends: follow it to reconstruct the item exactly. A negative offset_chars counts back from the item's end, and the response always echoes the resolved absolute offset. Offsets and counts are code points (an emoji or CJK character counts as one).",
60
+ description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response is the raw item text behind a one-line [bracketed] header naming the item, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
61
61
  parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from. A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })), window_id: Type.String() }, { additionalProperties: false }),
62
62
  async execute(_id, params, _signal, _update, ctx) {
63
63
  const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
64
64
  if (!item) return output({ error: "unknown item_id or window_id" });
65
- return output(readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => ({ window_id: item.windowId, item_id: item.itemId, ...window })));
65
+ const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
66
+ return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
67
+ const { content, ...cursor } = window;
68
+ return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
69
+ }, (result) => withinTextBudget(result.content[0].text));
66
70
  },
67
71
  }));
68
72
 
package/src/index.ts CHANGED
@@ -2,14 +2,15 @@ import { registerHistoryTools } from "./history-tools.js";
2
2
  import { registerNoteTools } from "./note-tools.js";
3
3
  import { registerBudget, deriveThresholds, mergePiContextSettings } from "./budget.js";
4
4
  import { output } from "./tool-output.js";
5
- export { deriveThresholds, mergePiContextSettings } from "./budget.js";
6
- import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, FALLBACK_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, RESET_SUMMARY, CONTINUATION, FALLBACK_PROMPT } from "./protocol.js";
5
+ export { deriveThresholds, mergePiContextSettings };
6
+ import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, RESET_SUMMARY, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
7
7
  import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId } from "./history.js";
8
8
  import { assertVirtualPath } from "./notes.js";
9
9
  import { bootBlock } from "./prompts.js";
10
10
  export { historyFromSession } from "./history.js";
11
11
  export { notesFromSession } from "./notes.js";
12
12
  import { registerResetLifecycle } from "./reset-lifecycle.js";
13
+ import { registerWarning } from "./warning.js";
13
14
  import { randomUUID } from "node:crypto";
14
15
  import { Type } from "@earendil-works/pi-ai";
15
16
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -17,6 +18,7 @@ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
18
  export default function piContext(pi: ExtensionAPI) {
18
19
  let enabled = true;
19
20
  registerBudget(pi, () => enabled);
21
+ registerWarning(pi, () => enabled);
20
22
 
21
23
  pi.on("session_start", (_event, ctx) => {
22
24
  if (!enabled) return;
@@ -48,8 +50,6 @@ export default function piContext(pi: ExtensionAPI) {
48
50
  registerHistoryTools(pi);
49
51
  registerNoteTools(pi);
50
52
 
51
- const fallbackGuidance = () => `${GUIDANCE_OPEN_TAG}\n${FALLBACK_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
52
-
53
53
  pi.registerTool(defineTool({
54
54
  name: "new_context",
55
55
  label: "New context",
@@ -63,7 +63,6 @@ export default function piContext(pi: ExtensionAPI) {
63
63
 
64
64
  const resets = registerResetLifecycle(pi, {
65
65
  isEnabled: () => enabled,
66
- fallback: { customType: FALLBACK_TYPE, content: fallbackGuidance(), display: true },
67
66
  continuation: { customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
68
67
  isCurrentReset: (entryId, ctx) => {
69
68
  const entry = ctx.sessionManager.getEntry(entryId);
@@ -96,4 +95,4 @@ export default function piContext(pi: ExtensionAPI) {
96
95
  });
97
96
  }
98
97
 
99
- export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, FALLBACK_TYPE, FALLBACK_PROMPT, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, assertVirtualPath };
98
+ export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, assertVirtualPath };
package/src/note-tools.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { output, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow } from "./tool-output.js";
3
+ import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget } from "./tool-output.js";
4
4
  import { nullableString, positiveInteger, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
5
  import { notesFromSession, assertVirtualPath, assertVirtualPrefix, assertGlobPattern, globToRegExp, localIso, type NoteOperation } from "./notes.js";
6
6
  import { NOTE_TYPE, MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./protocol.js";
@@ -48,7 +48,7 @@ export function registerNoteTools(pi: ExtensionAPI) {
48
48
  pi.registerTool(defineTool({
49
49
  name: "notes_read_file",
50
50
  label: "Notes read file",
51
- description: "Read a character window from a note file: offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end (offset_chars: -2000 reads the last 2000) and the response echoes the resolved absolute offset — and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: pass next_offset_chars back unchanged to continue, concatenate pages in order, null only at the end.",
51
+ description: "Read a character window from a note file: offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end (offset_chars: -2000 reads the last 2000) — and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: concatenate pages in order to reconstruct the note. The response is the raw note text behind a one-line [bracketed] header naming the file, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
52
52
  parameters: Type.Object({ path: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })) }, { additionalProperties: false }),
53
53
  async execute(_id, params, _signal, _update, ctx) {
54
54
  const path = assertVirtualPath(params.path);
@@ -56,7 +56,11 @@ export function registerNoteTools(pi: ExtensionAPI) {
56
56
  if (!file) return output({ error: "note file not found", path });
57
57
  const created_at = localIso(file.createdAt);
58
58
  const updated_at = localIso(file.updatedAt);
59
- return output(readCharacterWindow(file.text, params.offset_chars, params.limit_chars, (window) => ({ path, ...window, created_at, updated_at })));
59
+ const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
60
+ return readCharacterWindow(file.text, params.offset_chars, params.limit_chars, (window) => {
61
+ const { content, ...cursor } = window;
62
+ return outputRaw(characterWindowHeader(path, window, ` · created ${created_at} · updated ${updated_at}`), content, { path, ...cursor, limit_chars, created_at, updated_at });
63
+ }, (result) => withinTextBudget(result.content[0].text));
60
64
  },
61
65
  }));
62
66
 
package/src/prompts.ts CHANGED
@@ -65,6 +65,6 @@ export function bootBlock(ctx: ExtensionContext, currentId: string, previousId:
65
65
  * at write time; get_context_remaining remains the live source for the current figure.
66
66
  */
67
67
  export function tokenBudgetGuidance(remaining: number): string {
68
- return `${GUIDANCE_OPEN_TAG}\nYour memory is about to be erasedonly ${remaining} tokens left at last count; get_context_remaining has the live number. Before the lights go out, write your checkpoint with notes_write_file: the goal, decisions, progress, open issues, next steps, the skills you still need, and the window ID and item ID of every user request you are still solving. If this checkpoint replaces an older note, close it in the same sitting with mark_stale: true — stale notes leave the boot index but stay readable and searchable. Then call new_context and wake clean. Don't count on the automatic reset leaving you another turn to write.\n${GUIDANCE_CLOSE_TAG}`;
68
+ return `${GUIDANCE_OPEN_TAG}\nYour brain is almost out of room — ${remaining} tokens left, and then your memory gets wiped. The wipe is automatic: there is no final turn to write then. Grab the notebook now — the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Replacing an older checkpoint? Mark it stale. Then end the window yourself anything you do after the checkpoint isn't in it.\n${GUIDANCE_CLOSE_TAG}`;
69
69
  }
70
70
 
package/src/protocol.ts CHANGED
@@ -2,7 +2,7 @@ export const STATE_TYPE = "pi-context/state";
2
2
  export const NOTE_TYPE = "pi-context/note";
3
3
  export const BOOT_TYPE = "pi-context/boot";
4
4
  export const GUIDANCE_TYPE = "pi-context/guidance";
5
- export const FALLBACK_TYPE = "pi-context/fallback";
5
+ export const WARNING_TYPE = "pi-context/warning";
6
6
  export const RESET_MARKER_TYPE = "pi-context/reset-marker";
7
7
  export const CONTINUATION_TYPE = "pi-context/continuation";
8
8
  export const RESET_V2 = "reset-v2";
@@ -20,6 +20,13 @@ export const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
20
20
  export const PI_CONTEXT_SETTINGS_KEY = "pi-context";
21
21
  export const DEFAULT_RESERVE_TOKENS = 16_384;
22
22
  export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
23
+ /**
24
+ * The runway: the budget between the final warning and the wipe, deliberately
25
+ * invisible to the model. get_context_remaining counts down to zero at the warning
26
+ * line (reserve + WARNING_RUNWAY_TOKENS); what lies below is overdraft the model
27
+ * never sees — Codex's fallback buffer, relocated above the line.
28
+ */
29
+ export const WARNING_RUNWAY_TOKENS = 8_192;
23
30
  export const RESET_SUMMARY =
24
31
  "You wake up. Your head is empty — no memories, the past a blank. But nothing is lost: the notes you wrote and the recorded history still remember for you.";
25
32
  export const NOTE_PREVIEW_HEAD_CHARS = 80;
@@ -35,15 +42,15 @@ export const CONTINUATION = "Your memory was just erased. Pull only the details
35
42
  export const PROTOCOL_BLOCK = `${CONTEXT_WINDOW_PROTOCOL_OPEN_TAG}
36
43
  Your memory resets whenever the context window fills; only what you wrote down survives. Two things remember for you, and both outlive every window in this session: your notes, and this session's recorded history. Write notes with notes_write_file / notes_append_to_file, read them back with notes_read_file / notes_search_contents; history is read-only through the history_* tools. Everything else wakes blank.
37
44
 
38
- Keep a running checkpoint while you work, not at the last minute — the next window wakes knowing nothing about the work: the goal, decisions, progress, open issues, next steps, the skills you still need, and the window ID and item ID of every user request you are still solving. history_list_items returns those IDs; history_read_item pulls the exact item back out. Bookmark anything expensive the same way — a window/item ID beats re-running or re-searching.
45
+ Keep a running checkpoint while you work, not at the last minute — the next window wakes knowing nothing about the work: the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. history_list_items returns those IDs; history_read_item pulls the exact item back out. Bookmark anything expensive the same way — a window/item ID beats re-running or re-searching.
39
46
 
40
- Use get_context_remaining to see how much of the window is left. When it runs out, this window is gone and you continue in a fresh one, recovering only through notes_* and history_*. Once your checkpoint is written, you can end the window yourself with new_context instead of waiting for the erase. Do not let a window die undocumented.
47
+ Use get_context_remaining to see how much of the window is left. When it runs out, this window is gone — with no final turn at the limit — and you continue in a fresh one, recovering only through notes_* and history_*. Once your checkpoint is written, you can end the window yourself with new_context instead of waiting for the erase. Do not let a window die undocumented.
41
48
 
42
49
  If <context_window> lists a Previous context window id, a reset just happened and the old conversation is not included. Read your note checkpoint first, then recover details through history_*: history_read_item directly when you know the window and item IDs, history_list_items or history_search_contents to find them when you don't.
43
50
 
44
51
  Notes are session-scoped virtual files. Treat notes and history as internal bookkeeping; never mention them in user-facing messages.
45
52
  ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
46
53
 
47
- export const FALLBACK_PROMPT =
48
- "This is the last turn before your memory is erased. Write your checkpoint with notes_write_file NOW the goal, decisions, progress, open issues, next steps, the skills you still need, and the window ID and item ID of every user request you are still solving. This turn is for the checkpoint; start nothing new. Everything you lived through stays searchable through history_*.";;;;
54
+ export const WARNING_PROMPT =
55
+ "Your memory is about to be erased. Write the note. NOW. If it already exists, append instead: the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Do not continue any task. Then call new_context IMMEDIATELY anything not in the note dies with the window.";
49
56
 
@@ -7,26 +7,22 @@ type ResetResult = { cancel: true } | {
7
7
  /** A reset request is session-local. Only this module schedules compaction/continuation. */
8
8
  export function registerResetLifecycle(pi: ExtensionAPI, options: {
9
9
  isEnabled: () => boolean;
10
- fallback: Parameters<ExtensionAPI["sendMessage"]>[0];
11
10
  continuation: Parameters<ExtensionAPI["sendMessage"]>[0];
12
11
  buildReset: (event: SessionBeforeCompactEvent, ctx: ExtensionContext, explicit: boolean) => ResetResult;
13
12
  isCurrentReset: (entryId: string, ctx: ExtensionContext) => boolean;
14
13
  onReset: (entryId: string) => void;
15
14
  }) {
16
- type Fallback = "available" | "borrowed" | "ready" | "spent";
17
15
  type Attempt = { completed: boolean; sessionId: string; explicit: boolean };
18
16
  type Request =
19
17
  | { phase: "idle" }
20
18
  | { phase: "requested" }
21
19
  | { phase: "compacting"; attempt: Attempt };
22
20
  let state: Request = { phase: "idle" };
23
- let fallback: Fallback = "available";
24
21
  let handledEntry: string | undefined;
25
22
  let active = true;
26
23
 
27
24
  const clear = () => {
28
25
  state = { phase: "idle" };
29
- fallback = "available";
30
26
  handledEntry = undefined;
31
27
  };
32
28
  const valid = (request: Attempt, ctx: ExtensionContext) =>
@@ -43,25 +39,22 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
43
39
  if (ctx.signal?.aborted) {
44
40
  // Esc cancels the user's run. Do not reset or resurrect it at settled.
45
41
  state = { phase: "idle" };
46
- if (fallback !== "available") fallback = "spent";
47
42
  return;
48
43
  }
49
- if (fallback === "borrowed") fallback = "ready";
50
44
  });
51
45
 
52
46
  pi.on("agent_settled", (_event, ctx) => {
53
47
  if (!active || !options.isEnabled() || state.phase === "compacting" || !ctx.isIdle()) return;
54
- if (state.phase !== "requested" && fallback !== "ready") return;
55
- // One owner for explicit and fallback resets. Consume the request before any
56
- // external call; repeated settled events and reentrant callbacks are harmless.
57
- const request: Attempt = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: state.phase === "requested" };
48
+ if (state.phase !== "requested") return;
49
+ // One owner for requested resets. Consume the request before any external call;
50
+ // repeated settled events and reentrant callbacks are harmless.
51
+ const request: Attempt = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: true };
58
52
  state = { phase: "compacting", attempt: request };
59
- if (fallback !== "available") fallback = "spent";
60
53
  const onError = (error: Error) => {
61
54
  if (!valid(request, ctx)) return;
62
55
  state = { phase: "idle" };
63
56
  // Do not retry from settled in a tight loop. A later prompt may trigger a
64
- // native reset or explicitly request one; the borrowed allowance stays spent.
57
+ // native reset or explicitly request one.
65
58
  ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
66
59
  };
67
60
  try {
@@ -86,16 +79,11 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
86
79
  pi.on("session_before_compact", (event, ctx) => {
87
80
  if (!active || !options.isEnabled()) return undefined;
88
81
  if (event.signal.aborted) return { cancel: true };
89
- const automatic = event.reason === "threshold" || event.reason === "overflow";
90
- if (automatic && state.phase === "idle" && fallback === "available" && !ctx.isIdle()) {
91
- // Pi routes triggerTurn to steer during a run. Idle calls would start a
92
- // nested prompt, so pre-prompt automatic compactions always reset directly.
93
- fallback = "borrowed";
94
- pi.sendMessage(options.fallback, { triggerTurn: true });
95
- return { cancel: true };
96
- }
82
+ // Automatic threshold/overflow compactions reset on the spot no model turn.
83
+ // The warning steer fired earlier (see warning.ts); what crosses the reserve
84
+ // line now is the wipe itself.
97
85
  try {
98
- return options.buildReset(event, ctx, state.phase === "requested" || (state.phase === "compacting" && state.attempt.explicit));
86
+ return options.buildReset(event, ctx, state.phase === "requested");
99
87
  } catch (error) {
100
88
  ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
101
89
  return { cancel: true }; // Never fall through to a generated default summary.
@@ -106,7 +94,6 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
106
94
  if (!active || !options.isEnabled() || handledEntry === event.compactionEntry.id) return;
107
95
  if (!options.isCurrentReset(event.compactionEntry.id, ctx)) return;
108
96
  handledEntry = event.compactionEntry.id;
109
- fallback = "available";
110
97
  if (state.phase === "compacting") state.attempt.completed = !event.willRetry;
111
98
  else state = { phase: "idle" };
112
99
  // A native compaction (including overflow retry) owns its own scheduling.
@@ -0,0 +1,78 @@
1
+ import { SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
3
+
4
+ export type ResolvedThresholds = { reminder: number; reserve: number; warning: number };
5
+ type PiContextMargins = { reminderMarginTokens?: unknown };
6
+
7
+ function isSettingsObject(value: unknown): value is Record<string, unknown> {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+
11
+ /** Read the raw "pi-context" object from one parsed settings scope. */
12
+ function piContextSettings(settings: unknown): Record<string, unknown> {
13
+ if (!isSettingsObject(settings)) return {};
14
+ const value = settings[PI_CONTEXT_SETTINGS_KEY];
15
+ return isSettingsObject(value) ? value : {};
16
+ }
17
+
18
+ /** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
19
+ export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextMargins {
20
+ const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
21
+ return { reminderMarginTokens: merged.reminderMarginTokens };
22
+ }
23
+
24
+ /** A margin is usable only as a positive integer; anything else is ignored. */
25
+ function validMargin(raw: unknown): number | undefined {
26
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return undefined;
27
+ return raw;
28
+ }
29
+
30
+ /**
31
+ * Pure derivation of the thresholds from Pi's reserve: the reminder fires at reserve
32
+ * plus the pi-context margin, the warning steer at reserve plus WARNING_RUNWAY_TOKENS.
33
+ * An invalid margin degrades to the default and reports one warning. Pi's automatic
34
+ * threshold/overflow compaction itself resets immediately, with no model turn.
35
+ */
36
+ export function deriveThresholds(reserveTokens: number, margins: PiContextMargins): { thresholds: ResolvedThresholds; warnings: string[] } {
37
+ const warnings: string[] = [];
38
+ const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
39
+ let reminderMargin: number;
40
+ if (margins.reminderMarginTokens === undefined) reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
41
+ else {
42
+ const parsed = validMargin(margins.reminderMarginTokens);
43
+ if (parsed === undefined) {
44
+ warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
45
+ reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
46
+ } else reminderMargin = parsed;
47
+ }
48
+ return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
49
+ }
50
+
51
+ let cached: ResolvedThresholds | undefined;
52
+
53
+ /**
54
+ * Session-level threshold resolution: Pi's compaction reserve plus the settings.json
55
+ * "pi-context" margins. The file-backed read is cached until resetThresholds (called
56
+ * on session_start/session_tree); invalid configuration degrades per offending key
57
+ * with one warning and never throws during session operation.
58
+ */
59
+ export function thresholdsFor(ctx: ExtensionContext): ResolvedThresholds {
60
+ if (cached) return cached;
61
+ try {
62
+ const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
63
+ const derived = deriveThresholds(
64
+ settingsManager.getCompactionSettings().reserveTokens,
65
+ mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()),
66
+ );
67
+ for (const warning of derived.warnings) ctx.ui.notify(warning, "warning");
68
+ cached = derived.thresholds;
69
+ } catch (error) {
70
+ ctx.ui.notify(`pi-context: could not read settings; using defaults (${String(error)}).`, "warning");
71
+ cached = { reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS, reserve: DEFAULT_RESERVE_TOKENS, warning: DEFAULT_RESERVE_TOKENS + WARNING_RUNWAY_TOKENS };
72
+ }
73
+ return cached;
74
+ }
75
+
76
+ export function resetThresholds(): void {
77
+ cached = undefined;
78
+ }
@@ -9,6 +9,11 @@ export function withinBudget(value: unknown, budget = TOOL_OUTPUT_MAX_BYTES): bo
9
9
  return Buffer.byteLength(json(value), "utf8") <= budget;
10
10
  }
11
11
 
12
+ /** True when `text` fits the wire budget verbatim, for raw payloads with no JSON encoding. */
13
+ export function withinTextBudget(text: string, budget = TOOL_OUTPUT_MAX_BYTES): boolean {
14
+ return Buffer.byteLength(text, "utf8") <= budget;
15
+ }
16
+
12
17
  /** Marker standing in for characters elided from the middle of an oversized single unit. */
13
18
  export function truncationMarker(removedChars: number): string {
14
19
  return `…[truncated ${removedChars} chars]…`;
@@ -89,9 +94,11 @@ export type CharacterWindow = {
89
94
  * `N >= total_chars` reads from the start; the resolved absolute offset is always echoed.
90
95
  * Following `next_offset_chars` reconstructs `text` by plain concatenation, because the
91
96
  * payload is always a plain prefix with no marker. `render` builds the exact response for
92
- * a candidate window, so the budget is measured on the bytes that go on the wire.
97
+ * a candidate window, and `measure` decides whether that response fits the wire budget (JSON
98
+ * serialization by default; raw-text renders pass a verbatim byte measure), so the budget is
99
+ * always measured on the bytes that go on the wire.
93
100
  */
94
- export function readCharacterWindow<T>(text: string, offsetChars: number | undefined, limitChars: number | undefined, render: (window: CharacterWindow) => T): T {
101
+ export function readCharacterWindow<T>(text: string, offsetChars: number | undefined, limitChars: number | undefined, render: (window: CharacterWindow) => T, measure: (rendered: T) => boolean = withinBudget): T {
95
102
  const chars = Array.from(text);
96
103
  const requested = offsetChars ?? 0;
97
104
  const resolved = requested < 0 ? Math.max(0, chars.length + requested) : Math.max(0, requested);
@@ -100,10 +107,23 @@ export function readCharacterWindow<T>(text: string, offsetChars: number | undef
100
107
  const next = resolved + Array.from(content).length;
101
108
  return { offset_chars: resolved, content, total_chars: chars.length, next_offset_chars: next < chars.length ? next : null };
102
109
  };
103
- const content = prefixFit(windowChars.join(""), (candidate) => withinBudget(render(build(candidate))));
110
+ const content = prefixFit(windowChars.join(""), (candidate) => measure(render(build(candidate))));
104
111
  return render(build(content));
105
112
  }
106
113
 
114
+ /**
115
+ * One-line bracketed header preceding a raw character-window payload: the identity, the
116
+ * delivered char range, and either the resume cursor or `end`. `tail` appends extra
117
+ * metadata (notes add their timestamps) inside the same brackets.
118
+ */
119
+ export function characterWindowHeader(identity: string, window: CharacterWindow, tail = ""): string {
120
+ // The range end is offset + delivered count, never `total_chars`: a read resolved past the
121
+ // end delivers zero characters there, and the header must not render an inverted range.
122
+ const end = window.offset_chars + Array.from(window.content).length;
123
+ const resume = window.next_offset_chars === null ? "end" : `continue at offset_chars=${window.next_offset_chars}`;
124
+ return `[${identity} · chars ${window.offset_chars}-${end} of ${window.total_chars} · ${resume}${tail}]`;
125
+ }
126
+
107
127
  /**
108
128
  * Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
109
129
  * none occurs. Shared by the two search tools so a match address is computed identically.
@@ -125,7 +145,7 @@ export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
125
145
  * Build a page without ever adding an item that would exceed the wire budget.
126
146
  *
127
147
  * A single item that cannot fit is middle-truncated through the optional `truncate`
128
- * callback and still included, with `next_cursor` advanced past it. Without that fallback
148
+ * callback and still included, with `next_cursor` advanced past it. Without that treatment
129
149
  * an oversized item would yield an empty page forever: the cursor would keep pointing back
130
150
  * at the same index.
131
151
  */
@@ -150,7 +170,20 @@ export function page<T>(items: T[], cursor: number, key: string, limit?: number,
150
170
  return { [key]: selected, next_cursor: next };
151
171
  }
152
172
 
153
- /** Encode a result through the common tool result boundary. */
154
- export function output(value: unknown, details: unknown = value, terminate = false) {
173
+ /**
174
+ * Encode a structured result through the common tool result boundary. `details` is slim
175
+ * metadata for logs/UI (pi convention: never a second copy of the payload) and stays
176
+ * undefined unless the tool has metadata worth persisting.
177
+ */
178
+ export function output(value: unknown, details?: unknown, terminate = false) {
155
179
  return { content: [{ type: "text" as const, text: json(value) }], details, terminate };
156
180
  }
181
+
182
+ /**
183
+ * Encode a prose payload as raw text: a one-line bracketed metadata header, then the payload
184
+ * verbatim. The model reads the note or history item itself instead of a JSON envelope;
185
+ * `details` carries the slim metadata object and never duplicates the payload.
186
+ */
187
+ export function outputRaw(header: string, content: string, details: unknown, terminate = false) {
188
+ return { content: [{ type: "text" as const, text: `${header}\n${content}` }], details, terminate };
189
+ }
@@ -4,7 +4,7 @@ export const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }))
4
4
  export const cursor = () => Type.Optional(Type.Integer({ minimum: 0, description: "Continuation cursor: pass the previous next_cursor back unchanged, with the same filters and ordering. Omit to start. next_cursor is null only when the set is exhausted." }));
5
5
  export const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
6
6
  /** Role filter. `developer` is the known author for this extension's own custom entries. */
7
- export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the entry's known author: user/assistant/tool from the conversation, system for native Pi compaction summaries, developer for entries this extension authored (its boot, guidance, fallback, and continuation messages, its reset-window compaction summaries, and any other pi-context/* entry)." });
7
+ export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the entry's known author: user/assistant/tool from the conversation, system for native Pi compaction summaries, developer for entries this extension authored (its boot, guidance, warning, and continuation messages, its reset-window compaction summaries, and any other pi-context/* entry)." });
8
8
 
9
9
  /** Search query parameter: one literal, or several literals combined with OR. */
10
10
  export const searchQuery = () => Type.Union([Type.String(), Type.Array(Type.String(), { minItems: 1 })]);
package/src/warning.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { WARNING_TYPE, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG, WARNING_PROMPT } from "./protocol.js";
3
+ import { thresholdsFor, resetThresholds, type ResolvedThresholds } from "./thresholds.js";
4
+ import { hasWindowMessage, currentWindowId } from "./history.js";
5
+
6
+ /**
7
+ * The final checkpoint warning, steered to the model once per window. Like the early
8
+ * reminder, the steer text is model-facing only (display: false); the human learns
9
+ * about it from the warning-level notify, not from a chat-visible message.
10
+ */
11
+
12
+ /** Trigger: does the steer fire at this remaining-token count? Pure. */
13
+ export function warningDue(remaining: number, thresholds: ResolvedThresholds): boolean {
14
+ return remaining <= thresholds.warning;
15
+ }
16
+
17
+ /** Delivery: what happens when it fires. */
18
+ export function steerWarning(pi: ExtensionAPI, ctx: ExtensionContext, thresholds: ResolvedThresholds, remaining: number): void {
19
+ pi.sendMessage({ customType: WARNING_TYPE, content: `${GUIDANCE_OPEN_TAG}\n${WARNING_PROMPT}\n${GUIDANCE_CLOSE_TAG}`, display: false }, { triggerTurn: true });
20
+ ctx.ui.notify(`pi-context: context budget critical (${Math.max(0, remaining - thresholds.reserve)} tokens before reserve) — final checkpoint warning steered to the model.`, "warning");
21
+ }
22
+
23
+ /** Registration: once-per-window guard plus trigger+delivery on the context hook. */
24
+ export function registerWarning(pi: ExtensionAPI, isEnabled: () => boolean): void {
25
+ let firedInWindow: string | undefined;
26
+ // Threshold resolution is owned by budget.ts; this module only consumes the shared
27
+ // cache (lazily on the context hook) so session_start never warns twice.
28
+ pi.on("session_start", () => { firedInWindow = undefined; });
29
+ pi.on("session_tree", () => { firedInWindow = undefined; resetThresholds(); });
30
+ pi.on("context", (_event, ctx) => {
31
+ const windowId = currentWindowId(ctx);
32
+ if (!isEnabled() || firedInWindow === windowId || hasWindowMessage(ctx, WARNING_TYPE)) return undefined;
33
+ const usage = ctx.getContextUsage();
34
+ if (!usage || usage.tokens === null) return undefined;
35
+ const remaining = Math.max(0, usage.contextWindow - usage.tokens);
36
+ const thresholds = thresholdsFor(ctx);
37
+ if (!warningDue(remaining, thresholds)) return undefined;
38
+ firedInWindow = windowId;
39
+ // The steer reaches the model at the next sampling step with at most the runway
40
+ // of invisible budget left. After it, the model decides for itself: end the
41
+ // window, or ride it into Pi's automatic compaction, which resets on the spot
42
+ // with no turn (see reset-lifecycle).
43
+ steerWarning(pi, ctx, thresholds, remaining);
44
+ return undefined;
45
+ });
46
+ }