@astrosheep/pi-context 0.13.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/README.md +27 -60
- package/docs/architecture.md +6 -4
- package/docs/reset-lifecycle.md +9 -8
- package/package.json +2 -2
- package/src/budget.ts +39 -101
- package/src/history-tools.ts +39 -18
- package/src/history.ts +13 -3
- package/src/index.ts +6 -7
- package/src/note-tools.ts +87 -41
- package/src/notes.ts +34 -13
- package/src/prompts.ts +1 -1
- package/src/protocol.ts +16 -5
- package/src/reset-lifecycle.ts +9 -22
- package/src/thresholds.ts +78 -0
- package/src/tool-output.ts +78 -1
- package/src/tool-schema.ts +18 -2
- package/src/warning.ts +46 -0
package/README.md
CHANGED
|
@@ -14,46 +14,13 @@ Or load it for a single invocation without installing:
|
|
|
14
14
|
pi -e npm:@astrosheep/pi-context
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## What you get
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
- **
|
|
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
|
-
- **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
|
-
- **History tools** — the model searches pre-reset conversation with case-sensitive literal substring search, exactly like Codex's `history.*` namespace.
|
|
27
|
-
- **Notes tools** — persistent, session-scoped virtual files that survive window resets.
|
|
28
|
-
|
|
29
|
-
## Reminder timing
|
|
30
|
-
|
|
31
|
-
The reminder threshold derives from Pi's compaction reserve plus a margin configured in `settings.json` under the top-level `pi-context` key:
|
|
32
|
-
|
|
33
|
-
```json
|
|
34
|
-
{
|
|
35
|
-
"compaction": { "reserveTokens": 16384 },
|
|
36
|
-
"pi-context": {
|
|
37
|
-
"reminderMarginTokens": 24576
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
The margin is measured in **remaining context tokens** added on top of Pi's `compaction.reserveTokens`:
|
|
43
|
-
|
|
44
|
-
- `reminder = reserveTokens + reminderMarginTokens` (default margin `24576`)
|
|
45
|
-
|
|
46
|
-
Put the key in the global settings (`~/.pi/agent/settings.json`) or the project settings (`<cwd>/.pi/settings.json`); project values win per key, mirroring Pi's own settings merge. With Pi's default `reserveTokens: 16384` the default gives reminder `40960`, leaving 24,576 tokens of working room above Pi's reset line no matter how you set `reserveTokens`. The reminder is an early warning; once fallback guidance has been persisted in the current window, no later early reminder is appended. This also applies after session reload. The borrowed fallback turn needs no threshold of its own: it is driven by Pi's automatic `threshold`/`overflow` compaction request, so `before_agent_start`/`turn_end` send nothing and no `fallbackMarginTokens` setting exists.
|
|
47
|
-
|
|
48
|
-
Pi's `reserveTokens` and the `pi-context` reminder margin are re-read from disk at every `session_start` and cached for that session. An invalid margin — not a positive integer — is ignored with one TUI warning naming the key and the default used instead; session handling never throws.
|
|
49
|
-
|
|
50
|
-
This is a file-backed read through Pi's public `SettingsManager`. It sees committed `settings.json` only: SDK-level ephemeral `applyOverrides()` calls and the unreleased `compaction.modelOverrides` are not seen by this extension.
|
|
51
|
-
|
|
52
|
-
The extension does not change Pi settings or reserve additional context. Large tool outputs or user inputs can jump over the reminder. For small context windows, tune the reminder margin (or Pi's reserve) to fit the model.
|
|
53
|
-
|
|
54
|
-
## Tools
|
|
55
|
-
|
|
56
|
-
The nine Codex History/Notes actions are flattened because Pi tools have one global name space:
|
|
19
|
+
- **`new_context`** — the model can start a fresh context window. The old conversation leaves the provider context but stays in the session, so nothing is lost. Call it on its own, not inside a parallel tool batch.
|
|
20
|
+
- **A boot block at every window head** — static once-per-window content (cache-stable) carrying the window identity, the recent-notes index, and a short protocol that teaches the model how to recover: notes for its own bookkeeping, history tools for everything before the reset.
|
|
21
|
+
- **Low-budget guidance** — one persisted early warning per window when the estimated remaining budget crosses the reminder line, so the model checkpoints before the lights go out.
|
|
22
|
+
- **`get_context_remaining`** — the live, reserve-adjusted estimate of the context budget left before Pi's compaction reserve.
|
|
23
|
+
- **Nine history/notes tools** — Codex's History/Notes actions flattened into Pi's single tool namespace:
|
|
57
24
|
|
|
58
25
|
| Codex action | Pi tool |
|
|
59
26
|
| --- | --- |
|
|
@@ -61,42 +28,42 @@ The nine Codex History/Notes actions are flattened because Pi tools have one glo
|
|
|
61
28
|
| `history.list_items` | `history_list_items` |
|
|
62
29
|
| `history.read_item` | `history_read_item` |
|
|
63
30
|
| `history.search_contents` | `history_search_contents` |
|
|
64
|
-
| `notes.
|
|
31
|
+
| `notes.list_files` | `notes_list_files` |
|
|
65
32
|
| `notes.read_file` | `notes_read_file` |
|
|
66
33
|
| `notes.search_contents` | `notes_search_contents` |
|
|
67
34
|
| `notes.append_to_file` | `notes_append_to_file` |
|
|
68
35
|
| `notes.write_file` | `notes_write_file` |
|
|
69
36
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`history_*` and paged `notes_*` read/search results share a **32 KiB (32 * 1024 UTF-8 bytes) per-result budget**. List/search tools return whole items only and include `next_cursor` (an integer cursor, `null` only when the set is exhausted); pass it back unchanged as the `cursor` parameter, with the same filters and ordering, to continue. Page caps (`limit`, `max_results`, `max_files`) bound a single page, never the enumerable set; with no cap, a page holds as many items as the budget fits. A live window keeps growing while it is paged, so enumerate a closed window when a stable, complete set is needed. `history_read_item` defaults to `limit_chars: 12000`, accepts at most 50000, and returns `total_chars` plus `next_offset_chars` when more content remains. `notes_read_file` returns whole lines only, includes `total_lines`, and uses `next_start_line` for a bounded continuation. When one indivisible unit is larger than the whole budget (a single note line, search match, or history item), it is returned middle-truncated — head and tail joined by a `…[truncated N chars]…` marker, mirroring Codex's `truncate_middle` — instead of being dropped, so a page is never empty and its cursor always advances. `max_chars_per_item` limits Unicode code points including the truncation ellipsis (`…`). The 32 KiB choice matches Aider's default and Claude Code's roughly 30k-character limit while keeping worst-case CJK output (about 11k tokens) below Pi's default `reserveTokens` of 16384.
|
|
73
|
-
|
|
74
|
-
`notes_*` stores operation entries in the same append-only Pi session under `pi-context/note`. They are session-scoped, survive JSONL reload, never enter provider context, and use safe relative virtual paths only (no absolute paths, `..`, `.`, empty components, or backslashes). Searches are literal and case-sensitive. `notes_read_file` accepts inclusive 1-based line ranges; negative line numbers count from the last line. Staleness is explicit and never inferred from timestamps. `notes_write_file` and `notes_append_to_file` accept an optional `mark_stale` boolean, and `notes_list_files_by_prefix` reports each file's `stale` flag. Stale notes leave the boot notes index — which omits itself once no fresh note remains — but stay listed, readable, and searchable. Each call must carry `text`, `mark_stale`, or both: `mark_stale: true` alone flags a note without touching its content, `mark_stale: false` alone clears the flag without touching content, and either call may carry `text` too — so final content can be written and marked stale in one call, and a closing log line can be appended and marked stale in one call. Carrying `text` without `mark_stale` revives a stale note, and marking a path that does not exist is an error. `notes_read_file` success results and every matched file object from `notes_search_contents` also carry `created_at` and `updated_at`, the same fields `notes_list_files_by_prefix` returns. All note timestamps are local-time ISO 8601 strings with an explicit UTC offset (for example `2026-09-15T17:31:45.392+08:00`; a UTC host renders `+00:00`, never `Z`), while the persisted `NoteFile`/`NoteOperation` metadata keeps plain epoch milliseconds. Error results carry no timestamps. Writes are capped at 1,000,000 UTF-8 bytes. `notes_write_file` and `notes_append_to_file` declare `executionMode: "sequential"` (the Pi equivalent of Codex's `supports_parallel_tool_calls = false`), so a tool batch containing either runs its calls one at a time and note read-modify-write cannot race with itself.
|
|
75
|
-
|
|
76
|
-
Unlike Codex, the history tools do not advertise `agent_name`: Pi has no cross-agent session routing, so the parameter is omitted from the schemas entirely (strict `additionalProperties: false` still rejects it) instead of costing schema tokens on every request.
|
|
77
|
-
|
|
78
|
-
Two extra controls compose Pi public APIs:
|
|
37
|
+
The tool descriptions the model sees are the behavioral documentation: search is case-sensitive literal substring; reads are character windows whose cursors reconstruct the original exactly; anything a response does not deliver is named by an explicit field.
|
|
79
38
|
|
|
80
|
-
-
|
|
81
|
-
- `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.
|
|
39
|
+
- **Runtime toggle** — `/pi-context off` restores Pi's default compaction (including `keepRecentTokens`); `/pi-context on` re-enables; bare `/pi-context` reports the current state.
|
|
82
40
|
|
|
83
|
-
##
|
|
41
|
+
## Configuration
|
|
84
42
|
|
|
85
|
-
|
|
43
|
+
The reminder threshold is Pi's compaction reserve plus a margin, configured under the top-level `pi-context` key in `~/.pi/agent/settings.json` or `<cwd>/.pi/settings.json` (project values win per key):
|
|
86
44
|
|
|
87
|
-
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"compaction": { "reserveTokens": 16384 },
|
|
48
|
+
"pi-context": { "reminderMarginTokens": 24576 }
|
|
49
|
+
}
|
|
50
|
+
```
|
|
88
51
|
|
|
89
|
-
|
|
52
|
+
`reminder = reserveTokens + reminderMarginTokens`; with the defaults the early warning fires 24,576 tokens above Pi's reset line.
|
|
90
53
|
|
|
91
|
-
|
|
54
|
+
## Documentation
|
|
92
55
|
|
|
93
|
-
|
|
56
|
+
Implementation architecture and the reset lifecycle live in [docs/](docs/).
|
|
94
57
|
|
|
95
|
-
##
|
|
58
|
+
## Development
|
|
96
59
|
|
|
97
60
|
```sh
|
|
61
|
+
npm test # build from a clean dist, then run the suite
|
|
98
62
|
npm run typecheck
|
|
99
|
-
npm test
|
|
100
63
|
```
|
|
101
64
|
|
|
102
|
-
The
|
|
65
|
+
The harness runs against the real installed Pi `SessionManager`/`SettingsManager` in temporary directories with fake credentials — no model or network calls, and the real `~/.pi` is never touched.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
package/docs/architecture.md
CHANGED
|
@@ -8,10 +8,12 @@ pi-context uses Pi's session branch as the durable source of truth. It does not
|
|
|
8
8
|
| --- | --- | --- |
|
|
9
9
|
| `index.ts` | Compose features, expose toggle/reset tool, construct reset boundary | Pi extension API |
|
|
10
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
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrosheep/pi-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
|
-
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
29
29
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
30
30
|
"test": "npm run build && node --test dist/test/*.test.js",
|
|
31
31
|
"prepublishOnly": "npm run typecheck"
|
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/history-tools.ts
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
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,
|
|
4
|
-
import { positiveInteger, recentFirst, nullableString, role, cursor } from "./tool-schema.js";
|
|
3
|
+
import { output, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow } from "./tool-output.js";
|
|
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
|
|
|
7
|
+
/**
|
|
8
|
+
* Shrink one page item to fit the wire budget. `truncated`/`total_chars` stay honest: the
|
|
9
|
+
* payload is only ever cut to a plain prefix of itself, never filled with a marker, and the
|
|
10
|
+
* flag flips on whenever a shrink actually removed characters. `tool_name` is metadata, not a
|
|
11
|
+
* payload, and keeps its visible middle-truncation marker. `item_id`, `window_id`, `role`, and
|
|
12
|
+
* `tool_namespace` are identity or tiny metadata and are never touched.
|
|
13
|
+
*/
|
|
14
|
+
function truncateHistoryItem<T extends { truncated_content: string; truncated: boolean; tool_name: string | null }>(item: T, fits: (candidate: T) => boolean): T {
|
|
15
|
+
if (fits(item)) return item;
|
|
16
|
+
const shrinkContent = (base: T): T => ({
|
|
17
|
+
...base,
|
|
18
|
+
truncated: true,
|
|
19
|
+
truncated_content: prefixFit(base.truncated_content, (candidate) => fits({ ...base, truncated: true, truncated_content: candidate } as T)),
|
|
20
|
+
});
|
|
21
|
+
const withContent = shrinkContent(item);
|
|
22
|
+
if (fits(withContent)) return withContent;
|
|
23
|
+
if (item.tool_name === null) return withContent;
|
|
24
|
+
// Content could not help even as an empty prefix: tool_name is oversized, so keep the
|
|
25
|
+
// original payload and truncate the metadata as the last resort.
|
|
26
|
+
const withName = { ...item, tool_name: middleTruncate(item.tool_name, (candidate) => fits({ ...item, tool_name: candidate } as T)) } as T;
|
|
27
|
+
if (fits(withName)) return withName;
|
|
28
|
+
// Both fields are oversized: dissolve the payload against the truncated metadata.
|
|
29
|
+
return shrinkContent(withName);
|
|
30
|
+
}
|
|
31
|
+
|
|
7
32
|
export function registerHistoryTools(pi: ExtensionAPI) {
|
|
8
33
|
pi.registerTool(defineTool({
|
|
9
34
|
name: "history_list_windows",
|
|
@@ -21,41 +46,37 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
21
46
|
pi.registerTool(defineTool({
|
|
22
47
|
name: "history_list_items",
|
|
23
48
|
label: "History list items",
|
|
24
|
-
description: "List durable session items, including items before compaction, using opaque item and window IDs.",
|
|
49
|
+
description: "List durable session items, including items before compaction, using opaque item and window IDs. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read_item.",
|
|
25
50
|
parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
|
|
26
51
|
async execute(_id, params, _signal, _update, ctx) {
|
|
27
52
|
const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
|
|
28
|
-
return output(page(items, params.cursor ?? 0, "items", params.limit,
|
|
53
|
+
return output(page(items, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
|
|
29
54
|
},
|
|
30
55
|
}));
|
|
31
56
|
|
|
32
57
|
pi.registerTool(defineTool({
|
|
33
58
|
name: "history_read_item",
|
|
34
59
|
label: "History read item",
|
|
35
|
-
description: "Read a bounded character range from one
|
|
36
|
-
parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({
|
|
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).",
|
|
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 }),
|
|
37
62
|
async execute(_id, params, _signal, _update, ctx) {
|
|
38
63
|
const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
|
|
39
64
|
if (!item) return output({ error: "unknown item_id or window_id" });
|
|
40
|
-
|
|
41
|
-
const offset = params.offset_chars ?? 0;
|
|
42
|
-
const limit = Math.min(params.limit_chars ?? 12000, 50000);
|
|
43
|
-
const result = (content: string) => ({ window_id: item.windowId, item_id: item.itemId, offset_chars: offset, content, total_chars: chars.length, next_offset_chars: offset + limit < chars.length ? offset + limit : null });
|
|
44
|
-
// One clean middle-truncation replaces the old 0.9 shrink loop: a requested window larger
|
|
45
|
-
// than the budget comes back with its middle elided, never empty, and the cursor advances.
|
|
46
|
-
const content = middleTruncate(chars.slice(offset, offset + limit).join(""), (candidate) => withinBudget(result(candidate)));
|
|
47
|
-
return output(result(content));
|
|
65
|
+
return output(readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => ({ window_id: item.windowId, item_id: item.itemId, ...window })));
|
|
48
66
|
},
|
|
49
67
|
}));
|
|
50
68
|
|
|
51
69
|
pi.registerTool(defineTool({
|
|
52
70
|
name: "history_search_contents",
|
|
53
71
|
label: "History search",
|
|
54
|
-
description: "Case-sensitive literal substring search over durable Pi session history;
|
|
55
|
-
parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query:
|
|
72
|
+
description: "Case-sensitive literal substring search over durable Pi session history; query accepts one string or an array of strings, an item matches when it contains any of them (OR), and each item appears once. No semantic search. Each hit carries truncated and total_chars plus match_offset_chars: the code-point offset of the earliest query occurrence in the item's full content. With max_chars_per_item: 1 the page is an address list; resolve an address with history_read_item at match_offset_chars.",
|
|
73
|
+
parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
|
|
56
74
|
async execute(_id, params, _signal, _update, ctx) {
|
|
57
|
-
const
|
|
58
|
-
|
|
75
|
+
const queries = searchQueries(params.query);
|
|
76
|
+
const matching = filteredItems(ctx, params)
|
|
77
|
+
.filter((item) => queries.some((query) => item.content.includes(query)))
|
|
78
|
+
.map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? 1200), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
|
|
79
|
+
return output(page(matching, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
|
|
59
80
|
},
|
|
60
81
|
}));
|
|
61
82
|
|
package/src/history.ts
CHANGED
|
@@ -39,6 +39,9 @@ function mapRole(role: AgentMessage["role"]): HistoryItem["role"] | undefined {
|
|
|
39
39
|
return undefined;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** This extension's own custom-entry namespace; entries under it are authored by pi-context. */
|
|
43
|
+
const PI_CONTEXT_ENTRY_PREFIX = "pi-context/";
|
|
44
|
+
|
|
42
45
|
function messageContent(message: AgentMessage): string {
|
|
43
46
|
switch (message.role) {
|
|
44
47
|
case "bashExecution":
|
|
@@ -83,7 +86,8 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
|
|
|
83
86
|
window.items.push({
|
|
84
87
|
windowId: window.windowId,
|
|
85
88
|
itemId: entry.id,
|
|
86
|
-
|
|
89
|
+
// A reset-v2 compaction is authored by this extension; a native Pi compaction is not.
|
|
90
|
+
role: resetV2WindowId(entry.details) === undefined ? "system" : "developer",
|
|
87
91
|
content: entry.summary,
|
|
88
92
|
createdAt: entry.timestamp,
|
|
89
93
|
});
|
|
@@ -106,7 +110,8 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
|
|
|
106
110
|
window.items.push({
|
|
107
111
|
windowId: window.windowId,
|
|
108
112
|
itemId: entry.id,
|
|
109
|
-
|
|
113
|
+
// Only entries this extension wrote are its own; every foreign custom message stays a user turn.
|
|
114
|
+
role: entry.customType.startsWith(PI_CONTEXT_ENTRY_PREFIX) ? "developer" : "user",
|
|
110
115
|
content: contentText(entry.content),
|
|
111
116
|
createdAt: entry.timestamp,
|
|
112
117
|
});
|
|
@@ -117,13 +122,18 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
|
|
|
117
122
|
|
|
118
123
|
export function visibleItem(item: HistoryItem, maxChars = 1200) {
|
|
119
124
|
const characters = Array.from(item.content);
|
|
125
|
+
const truncated = characters.length > maxChars;
|
|
120
126
|
return {
|
|
121
127
|
window_id: item.windowId,
|
|
122
128
|
item_id: item.itemId,
|
|
123
129
|
role: item.role,
|
|
124
130
|
tool_namespace: item.toolNamespace ?? null,
|
|
125
131
|
tool_name: item.toolName ?? null,
|
|
126
|
-
|
|
132
|
+
truncated,
|
|
133
|
+
total_chars: characters.length,
|
|
134
|
+
// A truncated payload is a plain prefix: no synthetic marker is appended, and
|
|
135
|
+
// `total_chars` names exactly how many code points were left out.
|
|
136
|
+
truncated_content: truncated ? characters.slice(0, maxChars).join("") : item.content,
|
|
127
137
|
};
|
|
128
138
|
}
|
|
129
139
|
|