@astrosheep/pi-context 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/docs/architecture.md +39 -0
- package/docs/reset-lifecycle.md +21 -0
- package/package.json +3 -2
- package/src/budget.ts +122 -0
- package/src/history-tools.ts +59 -0
- package/src/history.ts +166 -0
- package/src/index.ts +37 -693
- package/src/note-tools.ts +76 -0
- package/src/notes.ts +85 -0
- package/src/prompts.ts +69 -0
- package/src/protocol.ts +43 -0
- package/src/reset-lifecycle.ts +125 -0
- package/src/session-reader.ts +6 -0
- package/src/tool-output.ts +8 -0
- package/src/tool-schema.ts +7 -0
package/README.md
CHANGED
|
@@ -18,10 +18,10 @@ pi -e npm:@astrosheep/pi-context
|
|
|
18
18
|
|
|
19
19
|
The extension composes Pi's public `session_before_compact` / `session_compact` hooks, custom session entries, and the `context` hook to approximate Codex's experimental context management:
|
|
20
20
|
|
|
21
|
-
- **`new_context` tool** — the model requests a fresh context window. The extension waits for the current
|
|
21
|
+
- **`new_context` tool** — the model requests a fresh context window. The extension waits for the current run to settle, compacts with a short deterministic reset message (old conversation is excluded from the new provider context but stays in the session), then sends one hidden continuation turn once compaction has fully completed, unless another prompt is already queued or running.
|
|
22
22
|
- **`<context_window>` boot block** — the head of every fresh window. For a reset it IS the summary returned from `session_before_compact` (position 0, persisted, no extra message); for the root window `session_start` persists it once as a visible custom message. It carries the agent name and first/current/previous window IDs, the recent-notes index, and a `<context_window_protocol>` teaching block. The notes index lists up to three most-recent notes, each with its `X lines, Y UTF-8 bytes` metadata plus a local-time ISO 8601 `updated` timestamp (explicit UTC offset, never `Z`) and an inline preview. A note of 200 Unicode characters or fewer is shown whole; a longer one shows its first 120 and last 80 Unicode characters joined by an ellipsis, so the two ends never overlap and no text is repeated. The notes index is a window-open snapshot with the same frozen-at-write semantics as the reminder count. Nothing is injected transiently per request: the boot block is static once-per-window content, so the head of the window stays cache-stable. Codex diverges here — its `<context_window>` block carries only the agent path and window IDs, while the notes index is our own addition.
|
|
23
|
-
- **Low-budget guidance** — when estimated remaining context first drops to the reminder threshold (by default **40,960 tokens**: Pi's default 16,384 `reserveTokens` plus a 24,576 reminder margin; see [Reminder timing](#reminder-timing)), a `<context_window_guidance>` reminder is **persisted once per window** into history (TUI-visible, no extra turn; `sendMessage` safely defers mid-stream). There is deliberately no transient copy: a bridge would make the model meet the same text twice at shifted positions, because history records the persisted copy after the crossing request's assistant reply. The reminder is an early warning, so arriving from the next request on costs nothing and keeps the model's view identical to recorded history. The measured remaining
|
|
24
|
-
- **Two-phase automatic fallback** — while Pi is streaming, the first automatic `threshold`/`overflow` crossing of the reserve line does not reset immediately. `session_before_compact` queues the final note-taking instruction with `pi.sendMessage(..., { triggerTurn: true })` — which Pi routes to `agent.steer()` while streaming, so the message is queued synchronously and reaches the model before any pending user input — and returns `{ cancel: true }`. Pi records that as an aborted compaction, spends no summary, and continues the same run with the borrowed turn; no user text or images are copied, intercepted, or replayed, and no `input` handler is registered. Once the borrowed run has finished, `agent_end` arms the real reset and `agent_settled` requests it through `ctx.compact()` for both `threshold` and `overflow`, unless another compaction has already completed.
|
|
23
|
+
- **Low-budget guidance** — when estimated remaining context first drops to the reminder threshold (by default **40,960 tokens**: Pi's default 16,384 `reserveTokens` plus a 24,576 reminder margin; see [Reminder timing](#reminder-timing)), a `<context_window_guidance>` reminder is **persisted once per window** into history (TUI-visible, no extra turn; `sendMessage` safely defers mid-stream). There is deliberately no transient copy: a bridge would make the model meet the same text twice at shifted positions, because history records the persisted copy after the crossing request's assistant reply. The reminder is an early warning, so arriving from the next request on costs nothing and keeps the model's view identical to recorded history. The measured remaining budget excludes `reserveTokens` and is frozen into the text at the threshold crossing, so the persisted reminder is a snapshot true at write time; `get_context_remaining` remains the live source for the current figure. The text is appended rather than prepended; existing history is not rewritten.
|
|
24
|
+
- **Two-phase automatic fallback** — while Pi is streaming, the first automatic `threshold`/`overflow` crossing of the reserve line does not reset immediately. `session_before_compact` queues the final note-taking instruction with `pi.sendMessage(..., { triggerTurn: true })` — which Pi routes to `agent.steer()` while streaming, so the message is queued synchronously and reaches the model before any pending user input — and returns `{ cancel: true }`. Pi records that as an aborted compaction, spends no summary, and continues the same run with the borrowed turn; no user text or images are copied, intercepted, or replayed, and no `input` handler is registered. Once the borrowed run has finished, `agent_end` arms the real reset and `agent_settled` requests it through `ctx.compact()` for both `threshold` and `overflow`, unless another compaction has already completed. Pi can perform another automatic check after a run, and overflow recovery has a one-shot guard. The settled scheduler ensures the borrowed turn has one reset owner even when no native check resets it. Requesting the reset after the run settles avoids issuing it from `agent_end` while Pi is still finishing the run. A phase flag makes the cancel happen at most once per window — it re-arms only after a completed reset starts a fresh window. A failed reset clears the pending request, warns the user, and leaves history intact; another prompt can retry without borrowing another turn. Extension-requested fallback resets send a continuation after the compaction completion callback. The borrow is skipped when the crossing arrives while Pi is idle (the pre-prompt check in `AgentSession.prompt()`, where `triggerTurn` would start a nested run and make the pending `Agent.prompt()` reject); that crossing resets directly. `manual` `/compact` and `new_context` never take the borrowed-turn path. The reminder asks the model to write notes early; if it misses that opportunity, old history remains searchable.
|
|
25
25
|
- **Runtime toggle** — `/pi-context off` disables the boot block, guidance, and reset-style compaction (Pi's default compaction, including `keepRecentTokens`, applies again). `/pi-context on` re-enables; a bare `/pi-context` reports the current state.
|
|
26
26
|
- **History tools** — the model searches pre-reset conversation with case-sensitive literal substring search, exactly like Codex's `history.*` namespace.
|
|
27
27
|
- **Notes tools** — persistent, session-scoped virtual files that survive window resets.
|
|
@@ -75,12 +75,14 @@ Unlike Codex, the history tools do not advertise `agent_name`: Pi has no cross-a
|
|
|
75
75
|
|
|
76
76
|
Two extra controls compose Pi public APIs:
|
|
77
77
|
|
|
78
|
-
- `get_context_remaining` returns `{ "remaining_tokens": number | null }
|
|
79
|
-
- `new_context` returns terminal tool output, then waits for Pi's `
|
|
78
|
+
- `get_context_remaining` returns `{ "remaining_tokens": number | null }`, computed as `max(0, contextWindow - usedTokens - reserveTokens)`: the estimated budget available before Pi's compaction reserve. `null` means Pi itself cannot make a reliable estimate (notably immediately after compaction). The low-budget guidance reports this same reserve-adjusted budget and stays silent when the estimate is unknown. Its trigger still compares physical remaining capacity against `reserveTokens + reminderMarginTokens`, so subtracting reserve from the reported number does not change reminder timing.
|
|
79
|
+
- `new_context` returns terminal tool output, then waits for Pi's `agent_settled`, triggers public `ctx.compact()`, installs a short deterministic reset compaction, and sends exactly one hidden continuation turn after compaction succeeds. Call it by itself in a tool batch. Pi only ends a tool turn when every parallel tool result is terminal, so Pi 0.85.1 cannot force an atomic rollover from the middle of a mixed parallel tool batch.
|
|
80
80
|
|
|
81
81
|
## Reset behavior and limits
|
|
82
82
|
|
|
83
|
-
On `session_before_compact`, the extension appends a persistent custom reset marker through public `pi.appendEntry`, reads that real marker ID from the readonly session manager, and returns it as `firstKeptEntryId`. Pi's `buildContextEntries()` then keeps the compaction envelope plus that custom marker; custom markers are excluded from LLM context. Thus the subsequent provider context contains the boot block summary and the marker, not old conversation messages. The boot summary also carries the extension-minted window id in its `details.windowId`, so the fresh window names itself with an id Pi could not have supplied at bake time.
|
|
83
|
+
On `session_before_compact`, the extension appends a persistent custom reset marker through public `pi.appendEntry`, reads that real marker ID from the readonly session manager, and returns it as `firstKeptEntryId`. Pi's `buildContextEntries()` then keeps the compaction envelope plus that custom marker; custom markers are excluded from LLM context. Thus the subsequent provider context contains the boot block summary and the marker, not old conversation messages. The boot summary also carries the extension-minted window id in its `details.windowId`, so the fresh window names itself with an id Pi could not have supplied at bake time. Both explicit `new_context` and the reset requested after a borrowed fallback run add a hidden continuation from `ctx.compact`'s `onComplete` callback. Native automatic resets and user `/compact` keep Pi's native scheduling. Explicit and fallback reset requests share the `agent_settled` scheduler: if the model calls `new_context` in a borrowed fallback run, the explicit request consumes that fallback allowance, producing one reset and at most one continuation. Continuation is suppressed if another prompt is queued or already running. The old entries remain only in the session tree for `history_*`.
|
|
84
|
+
|
|
85
|
+
The scheduler lives in [`src/reset-lifecycle.ts`](src/reset-lifecycle.ts); `src/index.ts` composes the features and builds reset boundaries. History and notes projections, tool adapters, budget policy, and prompt rendering have separate ownership described in [Architecture](docs/architecture.md). See the [lifecycle event table](docs/reset-lifecycle.md) for ownership and cancellation rules. Shutdown, tree navigation, and toggling off invalidate outstanding callbacks. An aborted run clears pending rollover work. Queued steering/follow-up messages may continue before the run settles; the extension does not clear or replay that queue.
|
|
84
86
|
|
|
85
87
|
Pi's built-in “compacted into the following summary” envelope is left intact. Its content explicitly says: “Context window reset: this is a fresh window. The previous conversation is not included and no summary was generated. Notes and durable session history persist across windows.” No context filtering or TUI override is used to hide that envelope.
|
|
86
88
|
|
|
@@ -95,4 +97,4 @@ npm run typecheck
|
|
|
95
97
|
npm test
|
|
96
98
|
```
|
|
97
99
|
|
|
98
|
-
The integration harness uses the installed Pi `SessionManager` and `SettingsManager` (global and project settings fixtures in temp directories, so the real `~/.pi` is never touched), including an on-disk JSONL reload. It verifies note persistence/Unicode/path rules, the boot block contents and extension-minted window ids on reset and root paths, that the `context` hook never injects, provider context exclusion after the real `firstKeptEntryId` boundary while history remains searchable, completed tool-result boundary placement, settings-derived reminder threshold resolution and margin validation, that the removed `before_agent_start`/`turn_end` fallback is gone, one early reminder per window, one continuation only, the two-phase automatic ordering (one cancel, one steer, one real reset, re-armed per window, `ctx.compact()` for both threshold and overflow, idle crossings and manual/`new_context` resets bypassing the borrow), and cancellation/failure/no-double-retry behavior.
|
|
100
|
+
The integration harness uses the installed Pi `SessionManager` and `SettingsManager` (global and project settings fixtures in temp directories, so the real `~/.pi` is never touched), including an on-disk JSONL reload. It verifies note persistence/Unicode/path rules, the boot block contents and extension-minted window ids on reset and root paths, that the `context` hook never injects, provider context exclusion after the real `firstKeptEntryId` boundary while history remains searchable, completed tool-result boundary placement, settings-derived reminder threshold resolution and margin validation, that the removed `before_agent_start`/`turn_end` fallback is gone, one early reminder per window, one continuation only, the two-phase automatic ordering (one cancel, one steer, one real reset, re-armed per window, `ctx.compact()` for both threshold and overflow, idle crossings and manual/`new_context` resets bypassing the borrow), and cancellation/failure/no-double-retry behavior. Additional lifecycle tests cover duplicate callbacks, stale callback identity, shutdown/tree/toggle changes, user abort, synchronous errors, native retry ownership, and queued prompts. SDK tests run the real Pi agent loop, extension hooks, manual compaction, and continuation with scripted provider responses: explicit and fallback resets each produce one boundary, exclude old provider context, and preserve durable history. A real uncompactable-session case verifies no automatic retry and recovery on the next user prompt. Further SDK cases verify steering/follow-up delivery before reset without replay, consecutive distinct windows, and user cancellation without automatic continuation. Persisted-data tests verify boot/reminder deduplication after JSONL reload and branch navigation, and reject malformed note timestamps without overwriting valid notes. These tests use temporary settings and fake credentials and make no model or network call.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
pi-context uses Pi's session branch as the durable source of truth. It does not maintain a second transcript or a hidden prompt overlay. Runtime state exists only to schedule work and reserve a reminder until Pi persists it.
|
|
4
|
+
|
|
5
|
+
## Ownership
|
|
6
|
+
|
|
7
|
+
| Module | Responsibility | Boundary |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `index.ts` | Compose features, expose toggle/reset tool, construct reset boundary | Pi extension API |
|
|
10
|
+
| `history.ts` | Project branch entries into windows/items; identify active window | `SessionReader`, read-only branch and session ID |
|
|
11
|
+
| `notes.ts` | Replay note operations, validate paths/timestamps, slice lines | `SessionReader`; no scheduling or writes |
|
|
12
|
+
| `history-tools.ts`, `note-tools.ts` | Public schemas and tool results; append validated note operations | Pi tool API plus read projections |
|
|
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 |
|
|
15
|
+
| `reset-lifecycle.ts` | Own reset requests, completion and continuation | Pi lifecycle hooks and injected boundary builder |
|
|
16
|
+
| `protocol.ts` | Persisted entry tags, protocol text and defaults | No imports or effects |
|
|
17
|
+
| `tool-schema.ts`, `tool-output.ts` | Shared wire-schema primitives and JSON result encoding | No session state |
|
|
18
|
+
|
|
19
|
+
Dependencies flow from the composition root and tool adapters to projections and protocol constants. Projections cannot send messages, compact, notify, or mutate the session. A runtime framework or generic event bus would add indirection without strengthening these boundaries.
|
|
20
|
+
|
|
21
|
+
## State and persistence
|
|
22
|
+
|
|
23
|
+
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
|
+
|
|
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.
|
|
26
|
+
|
|
27
|
+
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
|
+
|
|
29
|
+
Note replay accepts only supported operations, safe virtual paths, representable timestamps and results within the UTF-8 size limit. Invalid operations are ignored; they cannot replace a valid note. Reads reconstruct the current branch without a cache, so navigating a branch cannot expose notes from a sibling.
|
|
30
|
+
|
|
31
|
+
Reset IDs are opaque strings tagged with `reset-v2`; newly minted IDs use `pcw:<session>:<8 lowercase hex digits>`. Unsupported details fall back to Pi's compaction-entry identity for history lookup. Minting checks existing branch window IDs, which are independent of Pi entry IDs. A reset adds a marker via the public append API, then uses its real entry ID as `firstKeptEntryId`; old conversation remains searchable in the durable branch.
|
|
32
|
+
|
|
33
|
+
## Evidence and limits
|
|
34
|
+
|
|
35
|
+
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
|
+
|
|
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.
|
|
38
|
+
|
|
39
|
+
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.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Reset lifecycle
|
|
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).
|
|
4
|
+
|
|
5
|
+
| Event | Transition / owner |
|
|
6
|
+
| --- | --- |
|
|
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. |
|
|
13
|
+
| Attempt `onComplete` | Consume attempt; send continuation only for a confirmed boundary when idle with no queued messages. |
|
|
14
|
+
| Attempt `onError` or synchronous throw | Clear attempt/request, warn, retain history. No automatic retry loop. |
|
|
15
|
+
| Shutdown / start / tree / toggle off | Invalidate outstanding attempt. Identity checks reject callbacks from older attempts. |
|
|
16
|
+
|
|
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.
|
|
18
|
+
|
|
19
|
+
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
|
+
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrosheep/pi-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,13 +20,14 @@
|
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
22
|
"src",
|
|
23
|
+
"docs",
|
|
23
24
|
"LICENSE",
|
|
24
25
|
"README.md"
|
|
25
26
|
],
|
|
26
27
|
"scripts": {
|
|
27
28
|
"build": "tsc -p tsconfig.json",
|
|
28
29
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
29
|
-
"test": "npm run build && node --test dist/test
|
|
30
|
+
"test": "npm run build && node --test dist/test/*.test.js",
|
|
30
31
|
"prepublishOnly": "npm run typecheck"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
package/src/budget.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
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 } from "./protocol.js";
|
|
4
|
+
import { currentWindowId, hasWindowMessage } from "./history.js";
|
|
5
|
+
import { tokenBudgetGuidance } from "./prompts.js";
|
|
6
|
+
import { output } from "./tool-output.js";
|
|
7
|
+
|
|
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
|
+
}
|
|
54
|
+
|
|
55
|
+
export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean) {
|
|
56
|
+
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
|
+
|
|
82
|
+
pi.on("session_start", (_event, ctx) => { thresholds = undefined; guidancePersistedInWindow = undefined; resolveThresholds(ctx); });
|
|
83
|
+
pi.on("session_tree", () => { guidancePersistedInWindow = undefined; });
|
|
84
|
+
pi.on("context", (_event, ctx) => {
|
|
85
|
+
if (!isEnabled()) 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.
|
|
89
|
+
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
|
+
pi.sendMessage({ customType: GUIDANCE_TYPE, content: tokenBudgetGuidance(Math.max(0, remaining - reserve)), display: true }, { triggerTurn: false });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
pi.registerTool(defineTool({
|
|
111
|
+
name: "get_context_remaining",
|
|
112
|
+
label: "Get context remaining",
|
|
113
|
+
description: "Return estimated context tokens available before the compaction reserve, clamped to zero; null when Pi cannot estimate usage.",
|
|
114
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
115
|
+
async execute(_id, _params, _signal, _update, ctx) {
|
|
116
|
+
const usage = ctx.getContextUsage();
|
|
117
|
+
const remaining = usage?.tokens === null || usage === undefined ? null : Math.max(0, usage.contextWindow - usage.tokens - resolveThresholds(ctx).reserve);
|
|
118
|
+
return output({ remaining_tokens: remaining });
|
|
119
|
+
},
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { output } from "./tool-output.js";
|
|
4
|
+
import { positiveInteger, recentFirst, nullableString, role } from "./tool-schema.js";
|
|
5
|
+
import { historyFromSession, filteredItems, visibleItem, allItems } from "./history.js";
|
|
6
|
+
|
|
7
|
+
export function registerHistoryTools(pi: ExtensionAPI) {
|
|
8
|
+
pi.registerTool(defineTool({
|
|
9
|
+
name: "history_list_windows",
|
|
10
|
+
label: "History list windows",
|
|
11
|
+
description: "List durable Pi session-history windows.",
|
|
12
|
+
parameters: Type.Object({ limit: positiveInteger(), recent_first: recentFirst() }, { additionalProperties: false }),
|
|
13
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
14
|
+
let windows = historyFromSession(ctx);
|
|
15
|
+
if (params.recent_first !== false) windows = [...windows].reverse();
|
|
16
|
+
const limit = params.limit ?? windows.length;
|
|
17
|
+
return output({ windows: windows.slice(0, limit).map((window) => ({ window_id: window.windowId, item_count: window.items.length })) });
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
pi.registerTool(defineTool({
|
|
22
|
+
name: "history_list_items",
|
|
23
|
+
label: "History list items",
|
|
24
|
+
description: "List durable session items, including items before compaction, using opaque item and window IDs.",
|
|
25
|
+
parameters: Type.Object({ limit: positiveInteger(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
|
|
26
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
27
|
+
const items = filteredItems(ctx, params);
|
|
28
|
+
return output({ items: items.slice(0, params.limit ?? items.length).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200)) });
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
pi.registerTool(defineTool({
|
|
33
|
+
name: "history_read_item",
|
|
34
|
+
label: "History read item",
|
|
35
|
+
description: "Read a bounded character range from one durable session item.",
|
|
36
|
+
parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ minimum: 0 })), limit_chars: positiveInteger(), window_id: Type.String() }, { additionalProperties: false }),
|
|
37
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
38
|
+
const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
|
|
39
|
+
if (!item) return output({ error: "unknown item_id or window_id" });
|
|
40
|
+
const chars = Array.from(item.content);
|
|
41
|
+
const offset = params.offset_chars ?? 0;
|
|
42
|
+
const limit = params.limit_chars ?? chars.length;
|
|
43
|
+
return output({ window_id: item.windowId, item_id: item.itemId, offset_chars: offset, content: chars.slice(offset, offset + limit).join("") });
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
pi.registerTool(defineTool({
|
|
48
|
+
name: "history_search_contents",
|
|
49
|
+
label: "History search",
|
|
50
|
+
description: "Case-sensitive literal substring search over durable Pi session history; no semantic search.",
|
|
51
|
+
parameters: Type.Object({ limit: positiveInteger(), query: Type.String(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString() }, { additionalProperties: false }),
|
|
52
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
53
|
+
const items = filteredItems(ctx, params);
|
|
54
|
+
const matching = items.filter((item) => item.content.includes(params.query));
|
|
55
|
+
return output({ items: matching.slice(0, params.limit ?? matching.length).map((item) => visibleItem(item)) });
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { TextContent } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { SessionReader } from "./session-reader.js";
|
|
4
|
+
import { RESET_V2 } from "./protocol.js";
|
|
5
|
+
|
|
6
|
+
type HistoryItem = {
|
|
7
|
+
windowId: string;
|
|
8
|
+
itemId: string;
|
|
9
|
+
role: "user" | "assistant" | "tool" | "system" | "developer";
|
|
10
|
+
content: string;
|
|
11
|
+
createdAt: string | undefined;
|
|
12
|
+
toolName?: string;
|
|
13
|
+
toolNamespace?: string;
|
|
14
|
+
};
|
|
15
|
+
type HistoryWindow = { windowId: string; createdAt?: string; items: HistoryItem[] };
|
|
16
|
+
|
|
17
|
+
type HistoryFilter = {
|
|
18
|
+
window_id?: string | null;
|
|
19
|
+
role?: HistoryItem["role"] | null;
|
|
20
|
+
tool_namespace?: string | null;
|
|
21
|
+
tool_name?: string | null;
|
|
22
|
+
recent_first?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function isTextContent(part: unknown): part is TextContent {
|
|
26
|
+
return typeof part === "object" && part !== null && (part as TextContent).type === "text" && typeof (part as TextContent).text === "string";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function contentText(content: string | unknown[]): string {
|
|
30
|
+
if (typeof content === "string") return content;
|
|
31
|
+
return content.filter(isTextContent).map((part) => part.text).join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mapRole(role: AgentMessage["role"]): HistoryItem["role"] | undefined {
|
|
35
|
+
if (role === "user" || role === "assistant") return role;
|
|
36
|
+
if (role === "toolResult" || role === "bashExecution") return "tool";
|
|
37
|
+
if (role === "custom") return "user";
|
|
38
|
+
if (role === "compactionSummary" || role === "branchSummary") return "system";
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function messageContent(message: AgentMessage): string {
|
|
43
|
+
switch (message.role) {
|
|
44
|
+
case "bashExecution":
|
|
45
|
+
return message.output;
|
|
46
|
+
case "branchSummary":
|
|
47
|
+
case "compactionSummary":
|
|
48
|
+
return message.summary;
|
|
49
|
+
default:
|
|
50
|
+
return contentText(message.content);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName" | "toolNamespace"> {
|
|
55
|
+
if (message.role === "bashExecution") return { toolName: "bash", toolNamespace: undefined };
|
|
56
|
+
if (message.role !== "toolResult") return {};
|
|
57
|
+
const underscore = message.toolName.indexOf("_");
|
|
58
|
+
return { toolName: message.toolName, toolNamespace: underscore > 0 ? message.toolName.slice(0, underscore) : undefined };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The extension-owned window id baked onto a reset-v2 compaction entry, if present. */
|
|
62
|
+
export function resetV2WindowId(details: unknown): string | undefined {
|
|
63
|
+
if (typeof details !== "object" || details === null) return undefined;
|
|
64
|
+
const candidate = details as { piContext?: unknown; windowId?: unknown };
|
|
65
|
+
if (candidate.piContext !== RESET_V2 || typeof candidate.windowId !== "string") return undefined;
|
|
66
|
+
return candidate.windowId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
|
|
70
|
+
function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
|
|
71
|
+
return resetV2WindowId(entry.details) ?? `pcw:${sessionId}:${entry.id}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build durable, on-demand history directly from every entry on the current session branch. */
|
|
75
|
+
export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
|
|
76
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
77
|
+
let window: HistoryWindow = { windowId: `pcw:${sessionId}:root`, items: [] };
|
|
78
|
+
const windows = [window];
|
|
79
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
80
|
+
if (entry.type === "compaction") {
|
|
81
|
+
window = { windowId: windowIdOf(sessionId, entry), createdAt: entry.timestamp, items: [] };
|
|
82
|
+
windows.push(window);
|
|
83
|
+
window.items.push({
|
|
84
|
+
windowId: window.windowId,
|
|
85
|
+
itemId: entry.id,
|
|
86
|
+
role: "system",
|
|
87
|
+
content: entry.summary,
|
|
88
|
+
createdAt: entry.timestamp,
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (entry.type === "message") {
|
|
93
|
+
const role = mapRole(entry.message.role);
|
|
94
|
+
if (!role) continue;
|
|
95
|
+
window.items.push({
|
|
96
|
+
windowId: window.windowId,
|
|
97
|
+
itemId: entry.id,
|
|
98
|
+
role,
|
|
99
|
+
content: messageContent(entry.message),
|
|
100
|
+
createdAt: entry.timestamp,
|
|
101
|
+
...toolInfo(entry.message),
|
|
102
|
+
});
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (entry.type === "custom_message") {
|
|
106
|
+
window.items.push({
|
|
107
|
+
windowId: window.windowId,
|
|
108
|
+
itemId: entry.id,
|
|
109
|
+
role: "user",
|
|
110
|
+
content: contentText(entry.content),
|
|
111
|
+
createdAt: entry.timestamp,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return windows;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function visibleItem(item: HistoryItem, maxChars = 1200) {
|
|
119
|
+
const characters = Array.from(item.content);
|
|
120
|
+
return {
|
|
121
|
+
window_id: item.windowId,
|
|
122
|
+
item_id: item.itemId,
|
|
123
|
+
role: item.role,
|
|
124
|
+
tool_namespace: item.toolNamespace ?? null,
|
|
125
|
+
tool_name: item.toolName ?? null,
|
|
126
|
+
truncated_content: characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}…` : item.content,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function allItems(ctx: SessionReader) {
|
|
131
|
+
return historyFromSession(ctx).flatMap((window) => window.items);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function filteredItems(ctx: SessionReader, params: HistoryFilter): HistoryItem[] {
|
|
135
|
+
let items = allItems(ctx);
|
|
136
|
+
if (typeof params.window_id === "string") items = items.filter((item) => item.windowId === params.window_id);
|
|
137
|
+
if (typeof params.role === "string") items = items.filter((item) => item.role === params.role);
|
|
138
|
+
if (typeof params.tool_namespace === "string") items = items.filter((item) => item.toolNamespace === params.tool_namespace);
|
|
139
|
+
if (typeof params.tool_name === "string") items = items.filter((item) => item.toolName === params.tool_name);
|
|
140
|
+
if (params.recent_first !== false) items.reverse();
|
|
141
|
+
return items;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
/** Persisted messages in the active window, excluding earlier windows on this branch. */
|
|
146
|
+
export function hasWindowMessage(ctx: SessionReader, customType: string): boolean {
|
|
147
|
+
const branch = ctx.sessionManager.getBranch();
|
|
148
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
149
|
+
const entry = branch[i];
|
|
150
|
+
if (entry.type === "compaction") break;
|
|
151
|
+
if (entry.type === "custom_message" && entry.customType === customType) return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Cheap current-window lookup: scan the branch tail for the latest compaction entry. */
|
|
157
|
+
export function currentWindowId(ctx: SessionReader): string {
|
|
158
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
159
|
+
const branch = ctx.sessionManager.getBranch();
|
|
160
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
161
|
+
const entry = branch[i];
|
|
162
|
+
if (entry?.type === "compaction") return windowIdOf(sessionId, entry);
|
|
163
|
+
}
|
|
164
|
+
return `pcw:${sessionId}:root`;
|
|
165
|
+
}
|
|
166
|
+
|