@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.
- package/README.md +52 -5
- package/dist/build-info.json +4 -0
- package/dist/extension.js +1951 -0
- package/dist/src/context/boot.js +46 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +112 -0
- package/dist/src/context/prompts.js +91 -0
- package/dist/src/context/reset-artifacts.js +86 -0
- package/dist/src/context/reset-lifecycle.js +182 -0
- package/dist/src/context/runtime.js +151 -0
- package/dist/src/context/thresholds.js +62 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.js +34 -6
- package/dist/src/dream/runner.js +1 -1
- package/dist/src/dream/settings.js +30 -0
- package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
- package/dist/src/{history.js → history/history.js} +8 -46
- package/dist/src/index.js +27 -94
- package/dist/src/notes/address.js +97 -16
- package/dist/src/notes/frontmatter.js +18 -3
- package/dist/src/notes/notes-snapshot.js +30 -0
- package/dist/src/notes/paths.js +64 -7
- package/dist/src/notes/session-replay.js +41 -0
- package/dist/src/notes/store.js +76 -22
- package/dist/src/notes/tools.js +7 -7
- package/dist/src/protocol.js +9 -9
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +815 -221
- package/dist/test/boot.integration.test.js +219 -0
- package/dist/test/budget-settings.integration.test.js +126 -0
- package/dist/test/doctor.test.js +14 -36
- package/dist/test/dream.test.js +37 -380
- package/dist/test/helpers/extension.js +392 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +270 -0
- package/dist/test/notes.test.js +40 -359
- package/dist/test/reset-lifecycle.test.js +443 -178
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +73 -14
- package/package.json +11 -10
- package/src/context/boot.ts +68 -0
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +118 -0
- package/src/context/prompts.ts +108 -0
- package/src/context/reset-artifacts.ts +101 -0
- package/src/context/reset-lifecycle.ts +272 -0
- package/src/context/runtime.ts +151 -0
- package/src/context/thresholds.ts +78 -0
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +27 -6
- package/src/dream/runner.ts +1 -1
- package/src/dream/settings.ts +32 -0
- package/src/{history-tools.ts → history/history-tools.ts} +3 -3
- package/src/{history.ts → history/history.ts} +9 -48
- package/src/index.ts +27 -89
- package/src/notes/address.ts +82 -16
- package/src/notes/frontmatter.ts +20 -3
- package/src/notes/notes-snapshot.ts +40 -0
- package/src/notes/paths.ts +64 -7
- package/src/notes/session-replay.ts +53 -0
- package/src/notes/store.ts +78 -25
- package/src/notes/tools.ts +7 -7
- package/src/protocol.ts +9 -10
- package/src/settings.ts +20 -0
- package/src/tool-schema.ts +1 -2
- package/dist/src/budget.js +0 -65
- package/dist/src/notes/model.js +0 -101
- package/dist/src/prompts.js +0 -88
- package/dist/src/reset-lifecycle.js +0 -155
- package/dist/src/thresholds.js +0 -102
- package/dist/src/warning.js +0 -44
- package/dist/test/coherence.test.js +0 -371
- package/dist/test/history.test.js +0 -26
- package/dist/test/integration.test.js +0 -1759
- package/dist/test/pagination.property.test.js +0 -471
- package/src/budget.ts +0 -67
- package/src/notes/model.ts +0 -109
- package/src/prompts.ts +0 -91
- package/src/reset-lifecycle.ts +0 -173
- package/src/thresholds.ts +0 -110
- package/src/warning.ts +0 -46
package/docs/architecture.md
CHANGED
|
@@ -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` |
|
|
10
|
-
| `
|
|
11
|
-
| `
|
|
12
|
-
| `
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
package/docs/reset-lifecycle.md
CHANGED
|
@@ -1,24 +1,83 @@
|
|
|
1
1
|
# Reset lifecycle
|
|
2
2
|
|
|
3
|
-
`src/reset-lifecycle.ts` owns reset requests,
|
|
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` |
|
|
8
|
-
|
|
|
9
|
-
|
|
|
10
|
-
|
|
|
11
|
-
|
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
|
|
|
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
|
-
|
|
16
|
+
## Reset-control state machine
|
|
17
17
|
|
|
18
|
-
|
|
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
|
-
|
|
20
|
+
State is a single value with two explicitly typed axes. There are no independently combinable lifecycle booleans.
|
|
21
21
|
|
|
22
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.25.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Codex-style context windows for Pi: reset
|
|
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
|
-
"./
|
|
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.
|
|
46
|
-
"@earendil-works/pi-ai": "^0.
|
|
47
|
-
"@earendil-works/pi-coding-agent": "^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
|
+
}
|