@astrosheep/pi-context 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1951 -0
  4. package/dist/src/context/boot.js +46 -0
  5. package/dist/src/context/budget.js +150 -0
  6. package/dist/src/context/context-window.js +112 -0
  7. package/dist/src/context/prompts.js +91 -0
  8. package/dist/src/context/reset-artifacts.js +86 -0
  9. package/dist/src/context/reset-lifecycle.js +182 -0
  10. package/dist/src/context/runtime.js +151 -0
  11. package/dist/src/context/thresholds.js +62 -0
  12. package/dist/src/dream/cli.js +1 -1
  13. package/dist/src/dream/doctor.js +34 -6
  14. package/dist/src/dream/runner.js +1 -1
  15. package/dist/src/dream/settings.js +30 -0
  16. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  17. package/dist/src/{history.js → history/history.js} +8 -46
  18. package/dist/src/index.js +27 -94
  19. package/dist/src/notes/address.js +97 -16
  20. package/dist/src/notes/frontmatter.js +18 -3
  21. package/dist/src/notes/notes-snapshot.js +30 -0
  22. package/dist/src/notes/paths.js +64 -7
  23. package/dist/src/notes/session-replay.js +41 -0
  24. package/dist/src/notes/store.js +76 -22
  25. package/dist/src/notes/tools.js +7 -7
  26. package/dist/src/protocol.js +9 -9
  27. package/dist/src/settings.js +16 -0
  28. package/dist/src/tool-schema.js +1 -1
  29. package/dist/test/agent-loop.test.js +815 -221
  30. package/dist/test/boot.integration.test.js +219 -0
  31. package/dist/test/budget-settings.integration.test.js +126 -0
  32. package/dist/test/doctor.test.js +14 -36
  33. package/dist/test/dream.test.js +37 -380
  34. package/dist/test/helpers/extension.js +392 -0
  35. package/dist/test/history.integration.test.js +316 -0
  36. package/dist/test/notes.integration.test.js +270 -0
  37. package/dist/test/notes.test.js +40 -359
  38. package/dist/test/reset-lifecycle.test.js +443 -178
  39. package/docs/architecture.md +35 -18
  40. package/docs/reset-lifecycle.md +73 -14
  41. package/package.json +11 -10
  42. package/src/context/boot.ts +68 -0
  43. package/src/context/budget.ts +148 -0
  44. package/src/context/context-window.ts +118 -0
  45. package/src/context/prompts.ts +108 -0
  46. package/src/context/reset-artifacts.ts +101 -0
  47. package/src/context/reset-lifecycle.ts +272 -0
  48. package/src/context/runtime.ts +151 -0
  49. package/src/context/thresholds.ts +78 -0
  50. package/src/dream/cli.ts +1 -1
  51. package/src/dream/doctor.ts +27 -6
  52. package/src/dream/runner.ts +1 -1
  53. package/src/dream/settings.ts +32 -0
  54. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  55. package/src/{history.ts → history/history.ts} +9 -48
  56. package/src/index.ts +27 -89
  57. package/src/notes/address.ts +82 -16
  58. package/src/notes/frontmatter.ts +20 -3
  59. package/src/notes/notes-snapshot.ts +40 -0
  60. package/src/notes/paths.ts +64 -7
  61. package/src/notes/session-replay.ts +53 -0
  62. package/src/notes/store.ts +78 -25
  63. package/src/notes/tools.ts +7 -7
  64. package/src/protocol.ts +9 -10
  65. package/src/settings.ts +20 -0
  66. package/src/tool-schema.ts +1 -2
  67. package/dist/src/budget.js +0 -65
  68. package/dist/src/notes/model.js +0 -101
  69. package/dist/src/prompts.js +0 -88
  70. package/dist/src/reset-lifecycle.js +0 -155
  71. package/dist/src/thresholds.js +0 -102
  72. package/dist/src/warning.js +0 -44
  73. package/dist/test/coherence.test.js +0 -371
  74. package/dist/test/history.test.js +0 -26
  75. package/dist/test/integration.test.js +0 -1759
  76. package/dist/test/pagination.property.test.js +0 -471
  77. package/src/budget.ts +0 -67
  78. package/src/notes/model.ts +0 -109
  79. package/src/prompts.ts +0 -91
  80. package/src/reset-lifecycle.ts +0 -173
  81. package/src/thresholds.ts +0 -110
  82. package/src/warning.ts +0 -46
@@ -6,36 +6,53 @@ pi-context uses Pi's session branch as the durable source of truth. It does not
6
6
 
7
7
  | Module | Responsibility | Boundary |
8
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 | `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
- | `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 |
17
- | `reset-lifecycle.ts` | Own reset requests, completion and continuation | Pi lifecycle hooks and injected boundary builder |
9
+ | `index.ts` | Thin public entrypoint: register the context runtime and tool adapters, and preserve public history/notes re-exports | Pi extension API |
10
+ | `context/runtime.ts` | Compose runtime hooks, toggle/reset commands, boot construction, marker/boot boundaries, and provider projection | Pi extension API plus context and notes modules |
11
+ | `history/history.ts` | Project branch entries into windows/items using the shared window identity | `SessionReader`, read-only branch and session ID |
12
+ | `context/context-window.ts` | Own durable window identity, select the active boot, project provider context, and account for active-window usage | `SessionReader` plus Pi context/system projection APIs |
13
+ | `notes/session-replay.ts` | Replay persisted note operations from session entries | `SessionReader`; no filesystem acquisition |
14
+ | `notes/address.ts` | Validate virtual note paths and resolve address/glob forms | Note address syntax; no session-branch selection |
15
+ | `notes/notes-snapshot.ts` | Acquire the five notes homes once into a closed boot snapshot and isolate filesystem-home failures | Filesystem-backed notes homes; no rendering or UI effects |
16
+ | `notes/store.ts` | Read and mutate the filesystem-backed homes; distinguish an absent home from a real read failure | Notes filesystem only; boot acquisition isolates one home at a time |
17
+ | `history/history-tools.ts` | Public history schemas and tool results over branch projections | Pi tool API plus read projections |
18
+ | `notes/tools.ts` | Filesystem note tool adapters; validate and perform note reads, writes, edits, listings, and searches | Pi tool API plus notes filesystem |
19
+ | `context/budget.ts` | Own the default-path per-extension-instance settings cache, read injected policy live, report usable budget, stage guidance/warning drafts, and resolve automatic reset decisions | Pi settings/context hooks plus protocol warning text |
20
+ | `context/thresholds.ts` | Read the selected public settings authority and derive reminder/reserve/warning thresholds from Pi's reserve plus the pi-context margins | Pi `SettingsManager`, read-only; no mutable cache or UI effects |
21
+ | `settings.ts` | Merge the global and project `pi-context` settings object per key | Parsed Pi settings scopes; no I/O or runtime state |
22
+ | `dream/settings.ts` | Validate and resolve the configured dreamer model from the shared settings merge | Pi `SettingsManager`; no runtime context state |
23
+ | `context/prompts.ts` | Render the static boot block, note index, and low-budget reminder from explicit data | Snapshot data and protocol text; no filesystem acquisition or UI effects |
24
+ | `context/reset-lifecycle.ts` | Own reset requests, turn-end batching, recovery and continuation | Pi lifecycle hooks and injected boundary builder |
18
25
  | `protocol.ts` | Persisted entry tags, protocol text and defaults | No imports or effects |
19
26
  | `tool-schema.ts`, `tool-output.ts` | Shared wire-schema primitives and JSON result encoding | No session state |
20
27
 
21
- 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.
28
+ Dependencies flow from the composition root and tool adapters to projections and protocol constants. The published Pi entry is `dist/extension.js`, built from `src/index.ts`; its public `pi-ai/utils/estimate` dependency is inlined so Pi's root-package aliases cannot misresolve the utility subpath. Host-owned package APIs remain external. Projections cannot send messages, compact, notify, or mutate the session. A runtime framework or generic event bus would add indirection without strengthening these boundaries.
22
29
 
23
30
  ## State and persistence
24
31
 
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).
32
+ `turn_end` has one composer in `reset-lifecycle.ts`: it accepts the incoming drafts, drains the budget instance's staged guidance/warning drafts, and only then appends reset drafts. Reset requests are committed after the complete tool batch with `continue: true`, so Pi owns queue scheduling. Repeated `wipe_memory` requests in one batch deduplicate; a later window may still request another reset. Aborts and reset-construction failures preserve already-built drafts without manufacturing a continuation. See [reset lifecycle](reset-lifecycle.md).
26
33
 
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 `wipe_memory` requests, whose `ctx.compact` route needs the completion callback.
34
+ The durable boundary is one `pi-context/reset-marker` custom entry with `{ windowId: string }`, followed by one hidden `pi-context/boot` custom message with `details.windowId` equal to the marker identity. The marker is the only window boundary. `context/context-window.ts` owns the marker predicate, active-branch scan, root/current IDs, and per-window message lookup; `history/history.ts` consumes those identity primitives while projecting entries. The scan never uses a global entry tail. Native compaction and branch-summary entries remain history items in the current window, so the old compaction-entry identity is not a window identity.
28
35
 
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.
36
+ The runtime in `context/runtime.ts` performs final context projection by selecting the active boot through `details.windowId` and folding only the dropped system prefix through Pi's `getCurrentSystemMessage`. Later prompt patches and new messages stay in order. A missing boot aborts the hook with a safe head and notice rather than silently sending raw history. Startup/tree handling repairs only a genuinely incomplete marker tail: a missing boot with no later raw message, custom message, compaction, branch summary, or authoritative raw boot. If later work exists, boot creation is refused and `/wipe-memory` is the explicit recovery path; it does not parse or migrate the legacy reset-v2 protocol.
30
37
 
31
- 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.
38
+ Boot and reminder deduplication inspect the current branch-local window. Reloading JSONL therefore does not duplicate messages, while navigation to a sibling branch cannot inherit another branch's window state. A fork/clone receives a new session ID while copying its selected path, so startup must also verify that a root boot's `details.windowId` matches the new `rootWindowId(sessionId)` before treating it as present.
32
39
 
33
- 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.
40
+ Startup and idle manual resets use `pi.sendMessage(..., { triggerTurn: false })` for the boot. Pi appends it to the session and refreshes context without initiating a model request; during streaming the same call is deferred until the completed tool batch. This is session persistence, not a promise of immediate disk durability: Pi 0.87 defers a new session file until its first assistant message, and the extension API reports send failures through extension errors rather than an awaitable result. Running resets therefore use boundary drafts for ordering and continuation, not `sendMessage`. Drafts are validated together but disk writes are not transactional.
41
+
42
+ Boot is a fixed snapshot for its window, stored as an extension custom message and converted by Pi to a user-role model message. History's `developer` classification records extension authorship, not provider instruction priority. A system-role projection is technically possible through `context_with_system`, but would change the authority of the mixed protocol/MAP content and provider-specific prompt/cache behavior; it is not a prerequisite for persistence. Boot injection is silent, including startup, reload, and boot repair. Only an actual context reset produces `pi-context: memory cleared · <windowId>` through `ctx.ui.notify`, after both marker and boot are present in the session. Manual clear is checked immediately; running resets are checked at the next turn start or final settlement. Uncommitted reset drafts never announce success. Boot and continuation messages retain `display: false`.
43
+
44
+ History reads reconstruct the selected session branch on demand without a cache, so branch navigation cannot expose history from a sibling.
45
+
46
+ The boot notes index in `notes/notes-snapshot.ts` is a closed snapshot: the current session, project, human, agent, and model homes are each loaded at most once while constructing a boot. `context/prompts.ts` then renders that explicit snapshot without reading the filesystem or consulting the clock. MAP bodies and pocket metadata are derived from the same snapshot, so a boot cannot mix two filesystem reads. A missing home (`ENOENT`) is normal. A real read failure omits only that home's index, preserves healthy homes, adds a model-facing `notes_list` recovery notice, and notifies the human once for that window. The window identity, reset/protocol text, and lifecycle boundary are still constructed through the normal runtime path; note reads never mutate files or create fallback state.
47
+
48
+ `notes/session-replay.ts` 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. Notes remain in their filesystem-backed homes, unchanged by session branch navigation.
49
+
50
+ The `/wipe-memory` command waits for idle, appends the marker and boot with Pi's public `appendEntry`/`sendMessage` APIs, and never calls a model. While enabled, `/compact` is cancelled with an actionable `/wipe-memory` notice. Disabling pi-context stops new automatic resets, but an existing marker still excludes earlier history and native compaction is still cancelled on that marked branch; a fresh root may use native Pi semantics. Threshold and warning accounting use active-window provider usage rather than pre-reset global usage. Budget policy and staged prompts are instance-owned, so concurrent sessions cannot share reserve/enablement or uncommitted drafts; default file-backed policy is cached per instance, while injected policy is resolved live on each decision and model/session transitions reset the diagnostic lifecycle.
34
51
 
35
52
  ## Evidence and limits
36
53
 
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.
54
+ The retained integration tests use real SessionManager and SettingsManager instances. They check reset-window history retention, bounded history/notes reads with resumable cursors, notes-home isolation, and settings precedence. The suite is a representative regression set; the standalone coherence, pagination property, history, and threshold suites were removed during test reduction.
38
55
 
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.
56
+ Scripted SDK tests execute the real Pi agent loop with no model network request. They inspect provider contexts and durable entries for reset boundaries, mixed-tool completion, queued steering/follow-up delivery, overflow recovery, and settings authority. Smaller dream tests retain lock exclusion/ownership, jailed writes, CLI audit/report failure propagation, and read-only doctor behavior. Removing duplicate scenarios and edge-case matrices reduces coverage; the remaining tests do not establish every malformed-input case, branch interleaving, external-provider behavior, or filesystem failure mode.
40
57
 
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.
58
+ Mixed tool batches finish before the marker/boot boundary. Queued steering/follow-up messages are delivered exactly once in the new window; they are neither dropped nor replayed. Runtime overflow recovery is bounded to one reset/retry per failure chain, while ordinary retryable provider errors remain Pi-owned. When either the source or destination branch has a reset marker, `/tree` navigation still succeeds but its generated summary is replaced by an empty summary plus a notice; raw history and branch selection remain available. If neither branch has a marker, Pi's native tree summary is retained.
@@ -1,24 +1,83 @@
1
1
  # Reset lifecycle
2
2
 
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).
3
+ `src/context/reset-lifecycle.ts` owns reset requests, turn-end batching, recovery, and continuation. It is the sole `turn_end` composer: incoming drafts, budget drafts, and reset drafts are ordered here. `src/context/budget.ts` owns the default-path instance-local policy cache, resolves injected policy live, stages guidance/warning drafts, and keeps the final warning text in the budget/protocol path; `src/context/thresholds.ts` only reads settings and derives values, using the shared merge in `src/settings.ts`. `src/index.ts` is the thin public entrypoint; `src/context/runtime.ts` composes the runtime hooks and constructs marker/boot boundaries. `src/notes/notes-snapshot.ts` acquires the notes snapshot, while `src/context/prompts.ts` only renders the explicit boot data and low-budget reminder. Projections and tools have separate modules described in [Architecture](architecture.md).
4
4
 
5
5
  | Event | Transition / owner |
6
6
  | --- | --- |
7
- | `wipe_memory` | Mark explicit request; repeated calls report already pending. Tool returns terminal output. |
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`. The originating handler owns and awaits that attempt through its continuation's settlement. |
11
- | Matching `session_compact` | Confirm boundary, persist window state. Native compaction retains its own scheduling. |
12
- | Attempt `onComplete` | Consume attempt; for a confirmed boundary when idle with no queued messages, register continuation ownership before sending it. Its nested `agent_settled` settles that owner; a reset requested by the continuation completes its own handoff before releasing its predecessor. |
13
- | Attempt `onError` or synchronous throw | Clear attempt/request, warn, retain history. No automatic retry loop. |
14
- | Shutdown / start / tree / toggle off | Invalidate outstanding attempt. Identity checks reject callbacks from older attempts. |
7
+ | `wipe_memory` | Record an explicit reset request. Repeated calls in one tool batch deduplicate; the tool returns terminal output. |
8
+ | `turn_end` | The sole composer drains current-window budget drafts after the event entries, then appends reset drafts: one `pi-context/reset-marker` with `{ windowId }`, one hidden boot message with matching `details.windowId`, and the continuation marker; continue the turn through Pi's public queue. |
9
+ | Abort before the boundary | Drop the pending boundary. Never manufacture a continuation for an aborted turn. |
10
+ | Threshold / provider overflow | Request the same marker/boot boundary for the active provider window. Actual overflow/length recovery retries at most once per failure chain; ordinary retryable provider errors stay Pi-owned. |
11
+ | `/wipe-memory` | Wait for idle, append marker and boot through public session APIs, and do not call a model. |
12
+ | `/compact` while enabled | Cancel with an actionable `/wipe-memory` notice so native compaction cannot summarize erased canonical history back into the active window. |
13
+ | Startup / tree / partial append | Repair only a marker followed by an otherwise empty metadata tail; refuse hidden/absent boots once later work exists, leaving `/wipe-memory` as the explicit recovery path. Do not interpret legacy reset-v2 details. |
14
+ | `/pi-context off` | Stop new automatic resets, but retain the boundary of an existing marker. Marked branches still cancel native compaction; a fresh root may use Pi's native semantics. |
15
15
 
16
- The final checkpoint warning is steered earlier from the context hook (`warning.ts`) once per window at reserve+12288 tokens remaining. After it, the model either ends the window itself with `wipe_memory` or rides into Pi's automatic compaction, which resets on the spot with no turn.
16
+ ## Reset-control state machine
17
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
+ `registerResetLifecycle` is the effect adapter. `reduceResetControl` in `src/context/reset-lifecycle.ts` is the pure transition surface: given the current state and one lifecycle event it returns the next state and one named effect, performing no writes, policy resolution, or UI work of its own. The adapter captures Pi's events, supplies the guards only in the branches that consult them, then performs the marker/boot/continuation writes, continuation requests, and notices.
19
19
 
20
- `sendMessage(..., { triggerTurn: true })` starts its run detached from the extension API. The explicit attempt therefore retains an attempt-owned waiter before sending it, and its originating `agent_settled` handler awaits that waiter. The continuation's `agent_settled` releases the waiter without awaiting itself. If that continuation calls `wipe_memory`, its settled handler starts and awaits the next attempt before it releases the prior waiter, forming a bounded reset chain. Failure, cancellation, shutdown, tree navigation, and toggling off release the relevant waiter exactly once.
20
+ State is a single value with two explicitly typed axes. There are no independently combinable lifecycle booleans.
21
21
 
22
- 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.
22
+ | Axis | Value | Meaning |
23
+ | --- | --- | --- |
24
+ | `request` | `none` | no explicit wipe request pending |
25
+ | | `explicit` | one explicit request is pending and will commit at the next `turn_end` |
26
+ | `overflow` | `idle` | no overflow failure pending; a future recovery is available |
27
+ | | `pending` | active-provider overflow failure pending; recovery still available |
28
+ | | `pending-spent` | overflow failure pending, but its one recovery was already spent |
29
+ | | `spent` | recovery spent and the failure is no longer pending |
23
30
 
24
- 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.
31
+ All eight combinations of the two axes are reachable and each has a defined transition.
32
+
33
+ | Event | Guard | Next state | Effect |
34
+ | --- | --- | --- | --- |
35
+ | `request` | `request=none` | `request=explicit`, overflow unchanged | `requested` |
36
+ | `request` | `request=explicit` | unchanged | `already-requested` |
37
+ | `turn_end` | turn aborted | `(request=none, overflow=idle)` | `none` |
38
+ | `turn_end` | overflow-like and `!queued && enabled && automaticResetEnabled` | `request=none`; overflow `pending`, or `pending-spent` if already spent | `none` |
39
+ | `turn_end` | overflow-like and (`queued \|\| !enabled \|\| !automaticResetEnabled`) | `request=none`; overflow `idle`, or `spent` if already spent | `none` |
40
+ | `turn_end` | completed, `enabled`, and (`explicit` requested or `thresholdDue`) | `(request=none, overflow=idle)` | `commit-boundary` |
41
+ | `turn_end` | completed, `enabled`, but neither trigger | `(request=none, overflow=idle)` | `none` |
42
+ | `turn_end` | non-overflow `failed` | `request=none`; overflow unchanged | `none` |
43
+ | `turn_end` | `!enabled` | `request=none`; completed clears overflow, error keeps it | `none` |
44
+ | `before_settle` | `overflow=pending`, not queued, `enabled`, `automaticResetEnabled`, not aborted | `overflow=spent` | `recover-overflow` |
45
+ | `before_settle` | `overflow=pending-spent` | `overflow=spent` | `none` |
46
+ | `before_settle` | `overflow` not pending, or queued | unchanged | `none` |
47
+ | `before_settle` | pending but `!enabled`, `!automaticResetEnabled`, or aborted | disarms to `idle` (`spent` if already spent) | `none` |
48
+ | `settled` | — | `overflow=idle`; `request` unchanged | `none` |
49
+ | `abort` / `clear` | — | `(request=none, overflow=idle)` | `none` |
50
+
51
+ ### Invariants
52
+
53
+ 1. At most one explicit request is pending at a time; duplicate requests in one tool batch dedupe and never queue a second boundary.
54
+ 2. Every `turn_end` consumes a pending explicit request, whether or not that turn commits a reset.
55
+ 3. A committed reset is exactly `marker -> boot -> continuation`, and ordinary and budget drafts are ordered before the marker. Budget staging is drained once per turn and discarded on abort or disabled mode.
56
+ 4. Reset drafts are built once per commit. If construction throws, already-built incoming and budget drafts survive and no continuation is requested.
57
+ 5. Overflow recovery is one attempt per failure chain: a failure arms `pending` only when nothing is queued, the extension is enabled, and automatic reset is enabled; a settle spends it to `spent`; a later failure becomes `pending-spent` and cannot recover again until settlement ends the chain.
58
+ 6. Aborted turns never manufacture a continuation and clear both axes. Non-overflow errors keep the armed overflow chain for the settle boundary.
59
+ 7. A successful non-overflow turn clears the overflow chain first, so a queued success supersedes a stale overflow failure before settle recovery can act.
60
+ 8. `settled` ends the failure chain but preserves a pending explicit request; `clear` (session start, tree navigation, shutdown, `/pi-context off`, `/wipe-memory`) resets both axes.
61
+ 9. Disabled mode never commits a reset and never arms or spends overflow recovery; an existing persisted marker stays authoritative for native-compaction cancellation.
62
+ 10. Policy guards (`queued`, `automaticResetEnabled`, `thresholdDue`) are consulted only in the branch that needs them, preserving the adapter's original resolution order.
63
+
64
+ ### Critical sequences
65
+
66
+ - **Normal reset.** A completed, enabled turn whose explicit request is pending or whose usage is due returns `commit-boundary`. The adapter drains budget drafts, keeps incoming drafts in order, appends `marker -> boot -> continuation`, and requests continuation. A second request in the same batch returns `already-requested`; the next turn commits it alone.
67
+ - **Overflow recovery.** An overflow-like `turn_end` with no queued message, enabled mode, and automatic reset arms `pending`; a `before_settle` with no queued message returns `recover-overflow` and spends the attempt to `spent`. A repeated settle is a no-op, and a later overflow failure re-arms only `pending-spent`.
68
+ - **Abort.** An aborted `turn_end` returns only the drafts already collected for that boundary, clears both axes, and never appends reset drafts.
69
+ - **Queued success.** A queued message defers `before_settle`; the queued turn's successful `turn_end` clears the stale overflow chain, so no recovery fires.
70
+ - **Process interruption.** If Pi stops between the marker and the later reset messages, the marker remains the authoritative boundary. On the next start, startup repair may append only the missing boot and continuation when the tail is otherwise repairable; it never moves or reinterprets the boundary.
71
+ - **Startup tail repair.** On `session_start` / `session_tree`, `ensureBoot` asks `repairResetTail` to inspect the marker tail and emit only the missing artifacts in the closed order: the boot, then the continuation. A boot/continuation sequence that is already complete, or a tail containing real conversation, a foreign message, a later marker, or a misordered/duplicate artifact, refuses repair and leaves `/wipe-memory` as the explicit recovery path.
72
+
73
+ The budget owner stages the early guidance and final checkpoint warning from active-window usage. The warning is visible in the current provider request, while both drafts are committed only by the lifecycle composer and are discarded on abort, settlement without a `turn_end`, transition, or window mismatch. After the warning, the model either writes its note and calls `wipe_memory`, or runtime recovery requests the same marker/boot boundary. Guidance and warning drafts precede reset drafts so stale reminders cannot be queued into the new window; durable entries remain the authority for redelivery. Their UI notices are emitted at the next turn start or settlement only after the matching reminder is committed in the active window, so aborted requests and retries cannot repeat an uncommitted reminder's notification.
74
+
75
+ The turn-end commit is the scheduling boundary: Pi receives the finished tool batch and then the marker/boot drafts as one append operation. Pi owns queue scheduling and deduplication of the next request; the extension does not run a parallel compaction state machine or use a compaction completion callback.
76
+
77
+ Boot construction reads the five note homes into one closed snapshot before rendering the hidden boot. An absent home is empty; a real read failure removes only that home's MAP/pocket rows. The boot retains its identity, reset line, notes-home instructions, and recovery protocol, and includes a concise `notes_list` retry notice. The human receives one incomplete-index notification per boot. This notes failure isolation does not bypass the marker/boot commit, alter raw history, or turn a partial boot into an empty fallback.
78
+
79
+ The hidden boot is selected by its durable `details.windowId`, not by timestamp or content equality. The context hook folds only the dropped system prefix before that boot and preserves later prompt patches and messages in order. If the boot is missing, the hook aborts with a safe head and notice rather than sending raw history. Startup/tree repair is deliberately narrow: it appends a boot only when the marker tail is otherwise incomplete metadata; later conversation or an authoritative raw boot causes safe refusal. `/wipe-memory` is the recovery path for that refused branch. A fork/clone creates a new session ID while copying branch entries, so startup refreshes the root boot identity while retaining copied root messages.
80
+
81
+ Public APIs let a mixed tool batch finish before the marker/boot boundary. Queued steering/follow-up messages are delivered exactly once in the new window, neither dropped nor replayed. `/tree` navigation remains available, but when either source or destination branch contains a reset marker, Pi's raw summary generator is bypassed: the summary is empty and a notice explains why, preventing erased history from re-entering through a path outside the context hook. With no marker on either branch, native summaries remain unchanged.
82
+
83
+ Validation uses a reduced set of persisted-data integration tests, isolated lifecycle event tests, and scripted SDK tests running Pi's actual agent loop. Representative cases check reset-window history retention, resumable reads, native-compaction cancellation, mixed-tool completion, steering/follow-up delivery, bounded overflow recovery, and settings authority. Tree-summary suppression no longer has a dedicated retained test. The separate coherence and pagination property suites have been removed; the retained cases do not cover the previous full matrix of branch, cancellation, malformed-input, and pagination edges.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "type": "module",
5
- "description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
5
+ "description": "Codex-style context windows for Pi: durable reset windows, session history tools, and persistent notes.",
6
6
  "license": "MIT",
7
7
  "keywords": [
8
8
  "pi-package",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "pi": {
17
17
  "extensions": [
18
- "./src/index.ts"
18
+ "./dist/extension.js"
19
19
  ]
20
20
  },
21
21
  "files": [
@@ -30,22 +30,23 @@
30
30
  "dream": "dist/src/dream/cli.js"
31
31
  },
32
32
  "scripts": {
33
- "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/src/dream/cli.js', 0o755)\"",
33
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node scripts/build-extension.mjs && node -e \"require('node:fs').chmodSync('dist/src/dream/cli.js', 0o755)\"",
34
34
  "typecheck": "tsc -p tsconfig.json --noEmit",
35
35
  "test": "npm run build && node --test dist/test/*.test.js",
36
36
  "prepublishOnly": "npm run typecheck",
37
37
  "prepack": "npm run build"
38
38
  },
39
39
  "peerDependencies": {
40
- "@earendil-works/pi-agent-core": "*",
41
- "@earendil-works/pi-ai": "*",
42
- "@earendil-works/pi-coding-agent": "*"
40
+ "@earendil-works/pi-agent-core": "^0.87.0",
41
+ "@earendil-works/pi-ai": "^0.87.0",
42
+ "@earendil-works/pi-coding-agent": "^0.87.0"
43
43
  },
44
44
  "devDependencies": {
45
- "@earendil-works/pi-agent-core": "^0.86.0",
46
- "@earendil-works/pi-ai": "^0.86.0",
47
- "@earendil-works/pi-coding-agent": "^0.86.0",
45
+ "@earendil-works/pi-agent-core": "^0.87.0",
46
+ "@earendil-works/pi-ai": "^0.87.0",
47
+ "@earendil-works/pi-coding-agent": "^0.87.0",
48
48
  "@types/node": "^22.19.19",
49
+ "esbuild": "^0.28.2",
49
50
  "typescript": "^5.9.3"
50
51
  },
51
52
  "publishConfig": {
@@ -0,0 +1,68 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadNotesSnapshot, type NotesSnapshot } from "../notes/notes-snapshot.js";
3
+ import { agentSlug, modelSlug } from "../notes/paths.js";
4
+ import { BOOT_TYPE } from "../protocol.js";
5
+ import { renderBootBlock } from "./prompts.js";
6
+ import { currentReset, isWindowBoot, rootWindowId } from "./context-window.js";
7
+ import { repairResetTail } from "./reset-artifacts.js";
8
+
9
+ export type IncompleteNotesNotifier = (ctx: ExtensionContext, windowId: string, snapshot: NotesSnapshot) => void;
10
+
11
+ /** The boot custom message: identity, notes snapshot, and static protocol; never reset prose. */
12
+ export type BootMessage = {
13
+ readonly customType: string;
14
+ readonly content: string;
15
+ readonly display: false;
16
+ readonly details: { windowId: string };
17
+ };
18
+
19
+ /** Render the boot block from the live context; acquisition stays with loadNotesSnapshot. */
20
+ function bootContent(ctx: ExtensionContext, currentId: string, previousId: string | undefined, notes: NotesSnapshot): string {
21
+ return renderBootBlock({
22
+ agentName: agentSlug(ctx),
23
+ modelName: modelSlug(ctx),
24
+ firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
25
+ currentWindowId: currentId,
26
+ previousWindowId: previousId,
27
+ notes,
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Acquire one notes snapshot and build the boot custom message for a window. The caller
33
+ * supplies `previousId` only when a reset boundary needs the prior window identity.
34
+ */
35
+ export function buildBootMessage(
36
+ ctx: ExtensionContext,
37
+ windowId: string,
38
+ previousId: string | undefined,
39
+ notifyIncompleteNotes?: IncompleteNotesNotifier,
40
+ ): BootMessage {
41
+ const notes = loadNotesSnapshot(ctx);
42
+ notifyIncompleteNotes?.(ctx, windowId, notes);
43
+ return { customType: BOOT_TYPE, content: bootContent(ctx, windowId, previousId, notes), display: false, details: { windowId } };
44
+ }
45
+
46
+ /** Persist one hidden boot message without triggering a model turn. */
47
+ export function sendBoot(pi: ExtensionAPI, boot: BootMessage): void {
48
+ pi.sendMessage(
49
+ { customType: boot.customType, content: boot.content, display: boot.display, details: boot.details },
50
+ { triggerTurn: false },
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Boot entry point for `session_start` / `session_tree`. Ordinary startup ensures one root
56
+ * boot; a reset marker instead asks reset-artifact repair to complete its persisted tail.
57
+ * Boot idempotence lives here: an already-projected root boot or a complete reset tail emits nothing.
58
+ */
59
+ export function ensureBoot(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): void {
60
+ const reset = currentReset(ctx);
61
+ if (reset) {
62
+ repairResetTail(pi, ctx, reset, notifyIncompleteNotes);
63
+ return;
64
+ }
65
+ const windowId = rootWindowId(ctx.sessionManager.getSessionId());
66
+ if (ctx.sessionManager.buildSessionProjection().messages.some((message) => isWindowBoot(message, windowId))) return;
67
+ sendBoot(pi, buildBootMessage(ctx, windowId, undefined, notifyIncompleteNotes));
68
+ }
@@ -0,0 +1,148 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool, type ExtensionAPI, type ExtensionContext, type SessionBoundaryDraft, type SettingsManager } from "@earendil-works/pi-coding-agent";
3
+ import { GUIDANCE_CLOSE_TAG, GUIDANCE_OPEN_TAG, GUIDANCE_TYPE, WARNING_PROMPT, WARNING_TYPE } from "../protocol.js";
4
+ import { readThresholdSettings, type ResolvedThresholds, type ThresholdSettingsResolution } from "./thresholds.js";
5
+ import { currentWindowId, hasWindowMessage, windowUsage } from "./context-window.js";
6
+ import { tokenBudgetGuidance } from "./prompts.js";
7
+ import { output } from "../tool-output.js";
8
+
9
+ /** Remaining tokens in the provider's active window, or null without a usable estimate. */
10
+ export function remainingTokens(ctx: Pick<ExtensionContext, "sessionManager" | "getContextUsage" | "model">): number | null {
11
+ const usage = windowUsage(ctx);
12
+ return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
13
+ }
14
+
15
+ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, settingsManager?: SettingsManager) {
16
+ let cachedPolicy: { thresholds: ResolvedThresholds; automatic: boolean } | undefined;
17
+ const notifiedWarnings = new Set<string>();
18
+ const resolvePolicy = (ctx: ExtensionContext): ThresholdSettingsResolution => {
19
+ if (!settingsManager && cachedPolicy) return { ...cachedPolicy, warnings: [] };
20
+ const resolution = readThresholdSettings(ctx, settingsManager);
21
+ for (const warning of resolution.warnings) {
22
+ if (notifiedWarnings.has(warning)) continue;
23
+ notifiedWarnings.add(warning);
24
+ ctx.ui.notify(warning, "warning");
25
+ }
26
+ if (!settingsManager) cachedPolicy = { thresholds: resolution.thresholds, automatic: resolution.automatic };
27
+ return resolution;
28
+ };
29
+ const thresholdsFor = (ctx: ExtensionContext): ResolvedThresholds => {
30
+ return resolvePolicy(ctx).thresholds;
31
+ };
32
+ const automaticResetEnabled = (ctx: ExtensionContext): boolean => {
33
+ return resolvePolicy(ctx).automatic;
34
+ };
35
+ const resetDue = (ctx: ExtensionContext): boolean => {
36
+ if (!automaticResetEnabled(ctx)) return false;
37
+ const usage = windowUsage(ctx);
38
+ return usage !== undefined && usage.tokens !== null && usage.contextWindow - usage.tokens <= thresholdsFor(ctx).reserve;
39
+ };
40
+ const invalidateThresholds = () => { cachedPolicy = undefined; };
41
+ let pendingGuidance: { windowId: string; content: string } | undefined;
42
+ let pendingWarning: { windowId: string; content: string } | undefined;
43
+ let pendingNotices: Array<{ windowId: string; customType: string }> = [];
44
+ const notifyCommittedReminders = (ctx: ExtensionContext) => {
45
+ const windowId = currentWindowId(ctx);
46
+ for (const notice of pendingNotices) {
47
+ if (notice.windowId !== windowId || !hasWindowMessage(ctx, notice.customType)) continue;
48
+ ctx.ui.notify(notice.customType === WARNING_TYPE
49
+ ? "pi-context: context budget critical — final checkpoint warning recorded for the model."
50
+ : "pi-context: context budget low — checkpoint reminder recorded for the model, kept out of the chat view.", "warning");
51
+ }
52
+ pendingNotices = [];
53
+ };
54
+
55
+ const clearStaged = () => {
56
+ pendingGuidance = undefined;
57
+ pendingWarning = undefined;
58
+ };
59
+ const resetForTransition = () => {
60
+ clearStaged();
61
+ pendingNotices = [];
62
+ invalidateThresholds();
63
+ notifiedWarnings.clear();
64
+ };
65
+
66
+ const consumeTurnEnd = (ctx: ExtensionContext): SessionBoundaryDraft[] => {
67
+ const staged = [
68
+ pendingGuidance ? { ...pendingGuidance, customType: GUIDANCE_TYPE } : undefined,
69
+ pendingWarning ? { ...pendingWarning, customType: WARNING_TYPE } : undefined,
70
+ ];
71
+ clearStaged();
72
+ const windowId = currentWindowId(ctx);
73
+ const drafts = staged.filter((draft): draft is NonNullable<typeof draft> => draft !== undefined && draft.windowId === windowId);
74
+ pendingNotices = drafts.map(({ windowId, customType }) => ({ windowId, customType }));
75
+ return drafts.map((draft) => ({
76
+ type: "custom_message" as const,
77
+ customType: draft.customType,
78
+ content: draft.content,
79
+ display: false,
80
+ }));
81
+ };
82
+
83
+ pi.on("session_start", (_event, ctx) => { resetForTransition(); thresholdsFor(ctx); });
84
+ pi.on("session_tree", resetForTransition);
85
+ pi.on("model_select", resetForTransition);
86
+ pi.on("session_shutdown", resetForTransition);
87
+ // A request can fail before Pi emits turn_end. agent_settled is the public
88
+ // lifecycle point that must discard an uncommitted draft before the next prompt.
89
+ // UI notices follow committed reminders. Aborted requests can retry their drafts
90
+ // without showing the same low-budget notification twice.
91
+ pi.on("turn_start", (_event, ctx) => notifyCommittedReminders(ctx));
92
+ pi.on("agent_settled", (_event, ctx) => {
93
+ notifyCommittedReminders(ctx);
94
+ clearStaged();
95
+ });
96
+ pi.on("context", (_event, ctx) => {
97
+ if (!isEnabled()) return undefined;
98
+ // The early reminder persists once per window the first time remaining crosses
99
+ // reserve+margin. It never edits the outgoing request.
100
+ const remaining = remainingTokens(ctx);
101
+ if (remaining === null) return undefined;
102
+ const windowId = currentWindowId(ctx);
103
+ const { reminder, warning } = thresholdsFor(ctx);
104
+ if (hasWindowMessage(ctx, WARNING_TYPE) || pendingWarning?.windowId === windowId) return undefined;
105
+ if (remaining <= warning) {
106
+ // A not-yet-committed shallow reminder is superseded by the final warning.
107
+ pendingGuidance = undefined;
108
+ const content = `${GUIDANCE_OPEN_TAG}\n${WARNING_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
109
+ pendingWarning = { windowId, content };
110
+ const warningMessage = {
111
+ role: "custom" as const,
112
+ customType: WARNING_TYPE,
113
+ content,
114
+ display: false,
115
+ timestamp: Date.now(),
116
+ };
117
+ return { messages: [..._event.messages, warningMessage] };
118
+ }
119
+ if (hasWindowMessage(ctx, GUIDANCE_TYPE) || pendingGuidance?.windowId === windowId) return undefined;
120
+ if (remaining <= reminder) {
121
+ // Persist at turn_end, before any reset drafts. A queued sendMessage could
122
+ // otherwise cross the marker and leak the old window's reminder forward.
123
+ const left = Math.max(0, remaining - warning);
124
+ pendingGuidance = { windowId, content: tokenBudgetGuidance(left) };
125
+ }
126
+ return undefined;
127
+ });
128
+
129
+ pi.registerTool(defineTool({
130
+ name: "get_context_remaining",
131
+ label: "Get context remaining",
132
+ description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
133
+ parameters: Type.Object({}, { additionalProperties: false }),
134
+ async execute(_id, _params, _signal, _update, ctx) {
135
+ // The countdown the model sees ends at the warning line (reserve + runway);
136
+ // the runway below it is overdraft the model never sees. See protocol.ts.
137
+ const remaining = remainingTokens(ctx);
138
+ return output({ remaining_tokens: remaining === null ? null : Math.max(0, remaining - thresholdsFor(ctx as ExtensionContext).warning) });
139
+ },
140
+ }));
141
+
142
+ return {
143
+ automaticResetEnabled,
144
+ resetDue,
145
+ consumeTurnEnd,
146
+ clear: () => { clearStaged(); pendingNotices = []; },
147
+ };
148
+ }
@@ -0,0 +1,118 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
3
+ import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
4
+ import { convertToLlm, type CustomEntry, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
5
+ import { BOOT_TYPE, RESET_MARKER_TYPE } from "../protocol.js";
6
+ import type { SessionReader } from "../session-reader.js";
7
+
8
+ export type WindowMarker = CustomEntry<{ windowId: string }> & { data: { windowId: string } };
9
+
10
+ export function isWindowMarker(entry: SessionEntry): entry is WindowMarker {
11
+ return entry.type === "custom" && entry.customType === RESET_MARKER_TYPE &&
12
+ typeof entry.data === "object" && entry.data !== null &&
13
+ typeof (entry.data as { windowId?: unknown }).windowId === "string" &&
14
+ (entry.data as { windowId: string }).windowId.length > 0;
15
+ }
16
+
17
+ /** Only the active branch can supply a window boundary. */
18
+ export function currentReset(ctx: SessionReader): WindowMarker | undefined {
19
+ const branch = ctx.sessionManager.getBranch();
20
+ for (let i = branch.length - 1; i >= 0; i--) {
21
+ const entry = branch[i];
22
+ if (entry && isWindowMarker(entry)) return entry;
23
+ }
24
+ return undefined;
25
+ }
26
+
27
+ /** Mint the durable identity of a session's root history window. */
28
+ export function rootWindowId(sessionId: string): string {
29
+ return `pcw:${sessionId.slice(0, 8)}:root`;
30
+ }
31
+
32
+ /** Persisted messages in the active window, excluding earlier windows on this branch. */
33
+ export function hasWindowMessage(ctx: SessionReader, customType: string): boolean {
34
+ const branch = ctx.sessionManager.getBranch();
35
+ for (let i = branch.length - 1; i >= 0; i--) {
36
+ const entry = branch[i];
37
+ if (isWindowMarker(entry)) break;
38
+ if (entry.type === "custom_message" && entry.customType === customType) return true;
39
+ }
40
+ return false;
41
+ }
42
+
43
+ /** The root or latest durable marker on the active branch. */
44
+ export function currentWindowId(ctx: SessionReader): string {
45
+ return currentReset(ctx)?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
46
+ }
47
+
48
+ /** The durable window active just before the marker: the last earlier marker, else the root window. */
49
+ export function previousWindowId(ctx: SessionReader, markerId: string): string {
50
+ let previousId = rootWindowId(ctx.sessionManager.getSessionId());
51
+ for (const entry of ctx.sessionManager.getBranch()) {
52
+ if (entry.id === markerId) break;
53
+ if (isWindowMarker(entry)) previousId = entry.data.windowId;
54
+ }
55
+ return previousId;
56
+ }
57
+
58
+ function hasWindowId(details: unknown, windowId: string): boolean {
59
+ return typeof details === "object" && details !== null &&
60
+ typeof (details as { windowId?: unknown }).windowId === "string" &&
61
+ (details as { windowId: string }).windowId === windowId;
62
+ }
63
+
64
+ /** Match a provider-facing boot message, optionally by window identity. */
65
+ export function isWindowBoot(message: AgentMessage, windowId?: string): boolean {
66
+ return message.role === "custom" && message.customType === BOOT_TYPE && (windowId === undefined || hasWindowId(message.details, windowId));
67
+ }
68
+
69
+ /** Match a persisted boot entry by raw identity, even when a later edit hides it from projection. */
70
+ export function isWindowBootEntry(entry: SessionEntry, windowId: string): boolean {
71
+ return entry.type === "custom_message" && entry.customType === BOOT_TYPE && hasWindowId(entry.details, windowId);
72
+ }
73
+
74
+ /**
75
+ * The durable marker selects a boot message by identity, never by wall-clock time.
76
+ * The boot is the first conversation message of the window. Folding only its prefix
77
+ * preserves later prompt/tool patches in place, including their cacheable ordering.
78
+ */
79
+ export function projectWindow(messages: AgentMessage[], windowId: string): AgentMessage[] {
80
+ const cut = messages.findIndex((message) => isWindowBoot(message, windowId));
81
+ if (cut < 0) throw new Error(`Missing boot for context window ${windowId}`);
82
+ const head = getCurrentSystemMessage(messages.slice(0, cut));
83
+ const suffix = messages.slice(cut);
84
+ return head ? [head, ...suffix] : suffix;
85
+ }
86
+
87
+ /**
88
+ * Root windows are not reset boundaries. A forked session can copy a root boot whose
89
+ * details name the source session; refresh that boot in-place in the provider projection
90
+ * while retaining every user/assistant/tool message from the copied root transcript.
91
+ */
92
+ export function projectRootWindow(messages: AgentMessage[], windowId: string): AgentMessage[] {
93
+ const matching = messages.filter((message) => isWindowBoot(message, windowId));
94
+ if (matching.length === 0) return messages;
95
+ const activeBoot = matching[matching.length - 1];
96
+ const firstBoot = messages.findIndex((message) => isWindowBoot(message));
97
+ const withoutBoots = messages.filter((message) => !isWindowBoot(message));
98
+ return [...withoutBoots.slice(0, firstBoot), activeBoot, ...withoutBoots.slice(firstBoot)];
99
+ }
100
+
101
+ /** Usage for the selected window, excluding provider usage recorded before its marker. */
102
+ export function windowUsage(ctx: Pick<ExtensionContext, "sessionManager" | "getContextUsage" | "model">) {
103
+ const reset = currentReset(ctx);
104
+ if (!reset) return ctx.getContextUsage();
105
+ const contextWindow = ctx.model?.contextWindow ?? ctx.getContextUsage()?.contextWindow;
106
+ if (!contextWindow) return undefined;
107
+ const windowId = reset.data.windowId;
108
+ try {
109
+ const messages: AgentMessage[] = projectWindow(ctx.sessionManager.buildSessionProjection().messages, windowId);
110
+ const { tokens } = estimateContextTokens(convertToLlm(messages));
111
+ return { tokens, contextWindow, percent: tokens / contextWindow * 100 };
112
+ } catch {
113
+ // A marker can be durable before its boot when a process stops between the two
114
+ // public writes. Startup/tree repair will append the missing boot; until then the
115
+ // budget hook must not turn a recoverable partial append into a swallowed error.
116
+ return undefined;
117
+ }
118
+ }