@astrosheep/pi-context 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +5 -3
- package/docs/reset-lifecycle.md +9 -8
- package/package.json +1 -1
- package/src/budget.ts +39 -101
- package/src/index.ts +5 -6
- package/src/prompts.ts +1 -1
- package/src/protocol.ts +12 -5
- package/src/reset-lifecycle.ts +9 -22
- package/src/thresholds.ts +78 -0
- package/src/tool-output.ts +1 -1
- package/src/tool-schema.ts +1 -1
- package/src/warning.ts +46 -0
package/docs/architecture.md
CHANGED
|
@@ -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
|
-
| `
|
|
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
|
-
|
|
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
|
|
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.
|
package/docs/reset-lifecycle.md
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
# Reset lifecycle
|
|
2
2
|
|
|
3
|
-
`src/reset-lifecycle.ts` owns requests,
|
|
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
|
-
|
|
|
9
|
-
|
|
|
10
|
-
| `
|
|
11
|
-
| `
|
|
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
|
|
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,
|
|
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
package/src/budget.ts
CHANGED
|
@@ -1,113 +1,49 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
-
import { defineTool,
|
|
3
|
-
import {
|
|
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
|
-
|
|
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) => {
|
|
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,
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 instead — visible 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
|
|
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
|
-
|
|
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
|
}));
|
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 }
|
|
6
|
-
import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE,
|
|
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,
|
|
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/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
|
|
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
|
|
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,
|
|
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
|
|
48
|
-
"
|
|
54
|
+
export const WARNING_PROMPT =
|
|
55
|
+
"Memory wipe incoming — this turn is all you get. Do not continue any task. Get that checkpoint down NOW — one note, write it or append to it: 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. Then call new_context immediately and go out clean — anything not in the note is gone.";
|
|
49
56
|
|
package/src/reset-lifecycle.ts
CHANGED
|
@@ -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"
|
|
55
|
-
// One owner for
|
|
56
|
-
//
|
|
57
|
-
const request: Attempt = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit:
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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"
|
|
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
|
+
}
|
package/src/tool-output.ts
CHANGED
|
@@ -125,7 +125,7 @@ export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
|
|
|
125
125
|
* Build a page without ever adding an item that would exceed the wire budget.
|
|
126
126
|
*
|
|
127
127
|
* 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
|
|
128
|
+
* callback and still included, with `next_cursor` advanced past it. Without that treatment
|
|
129
129
|
* an oversized item would yield an empty page forever: the cursor would keep pointing back
|
|
130
130
|
* at the same index.
|
|
131
131
|
*/
|
package/src/tool-schema.ts
CHANGED
|
@@ -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,
|
|
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
|
+
}
|