@astrosheep/pi-context 0.25.2 → 0.26.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 (120) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +635 -381
  4. package/dist/src/context/boot.d.ts +24 -0
  5. package/dist/src/context/boot.js +33 -24
  6. package/dist/src/context/budget.d.ts +9 -0
  7. package/dist/src/context/budget.js +51 -28
  8. package/dist/src/context/context-window.d.ts +41 -0
  9. package/dist/src/context/context-window.js +16 -1
  10. package/dist/src/context/prompts.d.ts +20 -0
  11. package/dist/src/context/prompts.js +1 -1
  12. package/dist/src/context/reset-artifacts.d.ts +26 -0
  13. package/dist/src/context/reset-artifacts.js +18 -17
  14. package/dist/src/context/reset-lifecycle.d.ts +89 -0
  15. package/dist/src/context/reset-lifecycle.js +103 -75
  16. package/dist/src/context/runtime.d.ts +3 -0
  17. package/dist/src/context/runtime.js +53 -21
  18. package/dist/src/context/thresholds.d.ts +33 -0
  19. package/dist/src/context/thresholds.js +1 -1
  20. package/dist/src/dream/cli.d.ts +10 -0
  21. package/dist/src/dream/cli.js +1 -1
  22. package/dist/src/dream/doctor.d.ts +2 -0
  23. package/dist/src/dream/doctor.js +6 -2
  24. package/dist/src/dream/gates.d.ts +10 -0
  25. package/dist/src/dream/git.d.ts +21 -0
  26. package/dist/src/dream/lock.d.ts +31 -0
  27. package/dist/src/dream/runner.d.ts +30 -0
  28. package/dist/src/dream/settings.d.ts +16 -0
  29. package/dist/src/history/history-tools.d.ts +2 -0
  30. package/dist/src/history/history.d.ts +57 -0
  31. package/dist/src/index.d.ts +39 -0
  32. package/dist/src/index.js +4 -4
  33. package/dist/src/notes/address.d.ts +26 -0
  34. package/dist/src/notes/address.js +8 -14
  35. package/dist/src/notes/constants.d.ts +3 -0
  36. package/dist/src/notes/constants.js +3 -0
  37. package/dist/src/notes/context.d.ts +10 -0
  38. package/dist/src/notes/context.js +33 -0
  39. package/dist/src/notes/frontmatter.d.ts +46 -0
  40. package/dist/src/notes/frontmatter.js +10 -5
  41. package/dist/src/notes/index.d.ts +4 -0
  42. package/dist/src/notes/index.js +2 -0
  43. package/dist/src/notes/paths.d.ts +21 -0
  44. package/dist/src/notes/paths.js +72 -76
  45. package/dist/src/notes/store.d.ts +94 -0
  46. package/dist/src/notes/store.js +276 -236
  47. package/dist/src/pi/notes/adapter.d.ts +12 -0
  48. package/dist/src/pi/notes/adapter.js +39 -0
  49. package/dist/src/pi/notes/session-replay.d.ts +16 -0
  50. package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
  51. package/dist/src/pi/notes/snapshot.d.ts +33 -0
  52. package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
  53. package/dist/src/pi/notes/tools.d.ts +2 -0
  54. package/dist/src/{notes → pi/notes}/tools.js +24 -21
  55. package/dist/src/protocol.d.ts +41 -0
  56. package/dist/src/protocol.js +4 -6
  57. package/dist/src/session-reader.d.ts +5 -0
  58. package/dist/src/settings.d.ts +6 -0
  59. package/dist/src/text-match.d.ts +5 -0
  60. package/dist/src/text-match.js +15 -0
  61. package/dist/src/tool-output.d.ts +97 -0
  62. package/dist/src/tool-output.js +1 -15
  63. package/dist/src/tool-schema.d.ts +17 -0
  64. package/dist/test/agent-loop.test.d.ts +1 -0
  65. package/dist/test/agent-loop.test.js +387 -20
  66. package/dist/test/boot.integration.test.d.ts +1 -0
  67. package/dist/test/boot.integration.test.js +59 -35
  68. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  69. package/dist/test/budget-settings.integration.test.js +28 -12
  70. package/dist/test/doctor.test.d.ts +1 -0
  71. package/dist/test/doctor.test.js +10 -2
  72. package/dist/test/dream-skill.test.d.ts +1 -0
  73. package/dist/test/dream-skill.test.js +69 -0
  74. package/dist/test/dream.test.d.ts +1 -0
  75. package/dist/test/helpers/extension-test-environment.d.ts +1 -0
  76. package/dist/test/helpers/extension-test-environment.js +9 -0
  77. package/dist/test/helpers/extension.d.ts +104 -0
  78. package/dist/test/helpers/extension.js +7 -81
  79. package/dist/test/helpers/notes.d.ts +6 -0
  80. package/dist/test/helpers/notes.js +13 -0
  81. package/dist/test/history.integration.test.d.ts +1 -0
  82. package/dist/test/history.integration.test.js +17 -19
  83. package/dist/test/notes-library.test.d.ts +1 -0
  84. package/dist/test/notes-library.test.js +128 -0
  85. package/dist/test/notes.integration.test.d.ts +1 -0
  86. package/dist/test/notes.integration.test.js +51 -55
  87. package/dist/test/notes.test.d.ts +1 -0
  88. package/dist/test/notes.test.js +162 -34
  89. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  90. package/dist/test/reset-lifecycle.test.js +142 -85
  91. package/docs/architecture.md +8 -8
  92. package/docs/reset-lifecycle.md +63 -79
  93. package/package.json +35 -2
  94. package/playbook.md +33 -32
  95. package/skills/dream/SKILL.md +12 -0
  96. package/src/context/boot.ts +44 -25
  97. package/src/context/budget.ts +52 -27
  98. package/src/context/context-window.ts +16 -1
  99. package/src/context/prompts.ts +2 -2
  100. package/src/context/reset-artifacts.ts +26 -24
  101. package/src/context/reset-lifecycle.ts +117 -111
  102. package/src/context/runtime.ts +50 -22
  103. package/src/context/thresholds.ts +1 -1
  104. package/src/dream/cli.ts +1 -1
  105. package/src/dream/doctor.ts +5 -2
  106. package/src/index.ts +4 -4
  107. package/src/notes/address.ts +9 -15
  108. package/src/notes/constants.ts +3 -0
  109. package/src/notes/context.ts +40 -0
  110. package/src/notes/frontmatter.ts +18 -12
  111. package/src/notes/index.ts +22 -0
  112. package/src/notes/paths.ts +64 -78
  113. package/src/notes/store.ts +289 -238
  114. package/src/pi/notes/adapter.ts +44 -0
  115. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  116. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  117. package/src/{notes → pi/notes}/tools.ts +25 -23
  118. package/src/protocol.ts +5 -6
  119. package/src/text-match.ts +13 -0
  120. package/src/tool-output.ts +2 -14
@@ -1,83 +1,67 @@
1
1
  # Reset lifecycle
2
2
 
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).
3
+ `src/context/reset-lifecycle.ts` owns the close-out request phases, turn-end batching, bounded overflow recovery, and reset scheduling. `src/context/budget.ts` owns budget policy and stages the early reminder/final warning; `src/context/reset-artifacts.ts` builds the checkpoint, marker, boot, and continuation drafts; `src/context/context-window.ts` validates and projects the active window. `src/context/runtime.ts` wires these parts to Pi's public lifecycle hooks and `/wipe-memory` command.
4
4
 
5
- | Event | Transition / owner |
5
+ ## Lifecycle at a glance
6
+
7
+ | Trigger | Behavior |
6
8
  | --- | --- |
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
-
16
- ## Reset-control state machine
17
-
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
-
20
- State is a single value with two explicitly typed axes. There are no independently combinable lifecycle booleans.
21
-
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 |
30
-
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.
9
+ | `wipe_memory` tool | Mark the current window tool-requested. At `turn_end`, after the complete tool batch and any accepted budget drafts, append the reset boundary and ask Pi to continue. Repeated requests for that window deduplicate. |
10
+ | `/wipe-memory` | Capture the current window, wait for idle, and stop if another invocation has already moved the branch to a new window. Arm a manual close-out, persist the shared hidden warning, and trigger an ordinary model turn. The agent can write notes/use tools over several turns. A `wipe_memory` call commits at `turn_end`; otherwise a successful normal stop commits at `agent_before_settle` after queued messages drain. |
11
+ | Budget warning | When automatic compaction is enabled and remaining active-window budget reaches reserve plus the warning runway, put the same hidden warning in the request and arm an automatic close-out. An explicit `wipe_memory` commits the boundary; a successful normal stop is the fallback. |
12
+ | Hard reserve | Independent safety path: if automatic resets are enabled and completed-turn usage reaches Pi's reserve, commit a boundary at `turn_end`. |
13
+ | Abort or error | Never count as successful close-out. Abort clears lifecycle state. An ordinary close-out error is dropped without reset; provider-overflow recovery remains separately bounded. |
14
+ | `/compact` while active | Cancel native compaction, which could otherwise summarize pre-reset history back into the active window. A manual attempt receives an actionable `/wipe-memory` notice. |
15
+
16
+ A new boundary is ordered as:
17
+
18
+ 1. A native `compaction` entry with `summary: ""` and `firstKeptEntryId` set to its own ID. This is Pi's retain-none canonical checkpoint; it is not a generated summary request.
19
+ 2. A `pi-context/reset-marker` custom entry with `{ windowId }`.
20
+ 3. A hidden `pi-context/boot` custom message with matching `details.windowId`.
21
+ 4. A hidden continuation message.
22
+
23
+ The raw session branch is preserved for history. New checkpoint-backed branches use Pi's canonical retain-none projection, which preserves the empty summary wrapper and system/tool state while excluding earlier conversational context. Older marker-only sessions use marker slicing as a narrow compatibility fallback. A marker is trusted as checkpoint-backed only when it directly follows an empty native compaction whose `firstKeptEntryId` is the compaction's own ID; a generic preceding compaction is not enough.
24
+
25
+ `turn_end` is the sole composer for completed turns. It preserves incoming entries, consumes current-window budget drafts, and appends the ordered reset boundary only when the reducer requests one. `agent_before_settle` handles successful close-out fallback after Pi has drained queued work. Pi owns the ensuing continuation and request scheduling. Reset construction awaits the asynchronous notes snapshot; the handler captures its session/window generation before awaiting, then rechecks generation, session, window, enabled state, and abort status before returning drafts. A stale completion is discarded while incoming/budget entries survive, and success notices are staged only after that guard. Construction failures notify only while the initiating lifecycle is still current and preserve already-collected entries without claiming a continuation.
26
+
27
+ ## Reducer state
28
+
29
+ `reduceResetControl` is pure: the adapter supplies all lifecycle facts, and the reducer returns the next state plus a named effect. Its state combines a request phase with a bounded overflow phase:
30
+
31
+ | State | Meaning |
32
+ | --- | --- |
33
+ | `{ phase: "none" }` | No close-out/tool request is pending. |
34
+ | `{ phase: "close-out", windowId, source }` | Manual or automatic warning close-out remains armed across ordinary note/tool turns. |
35
+ | `{ phase: "tool-requested", windowId }` | A direct `wipe_memory` request will commit after its complete turn batch. |
36
+ | `overflow: "idle"` | No overflow recovery is pending. |
37
+ | `overflow: "pending"` | An overflow failure may recover once at pre-settlement. |
38
+ | `overflow: "pending-spent"` | Recovery was already spent in this failure chain. |
39
+ | `overflow: "spent"` | The one recovery has been used until the chain settles. |
40
+
41
+ ### Transition rules
42
+
43
+ - `close_out(windowId, source)` arms a close-out for that window. Repeating an already-pending request deduplicates; a manual request can upgrade an automatic request for the same window.
44
+ - `tool_request(windowId)` marks that window for a turn-end reset. A matching duplicate is reported as already pending.
45
+ - `turn_end` first clears everything on abort. Overflow-like errors disarm the reset request and arm overflow recovery only when there is no queued work, the extension is enabled, and automatic reset is enabled. Other failures drop the request but preserve the overflow chain. A successful, enabled turn commits for a tool request or hard-reserve condition; a close-out alone remains armed.
46
+ - `agent_before_settle` clears state on actual abort. Pending overflow recovery gets its one bounded attempt when enabled and unqueued. Otherwise a close-out commits only if the outcome is successful, the request belongs to the current window, the extension is enabled, and no queued work remains. Automatic close-outs also require automatic resets to remain enabled.
47
+ - `agent_settled`, session start/tree navigation/shutdown, `/pi-context off`, and abort clear transient request state. Durable checkpoint/marker history remains authoritative after transient state is gone.
48
+
49
+ This separation is intentional: a note write or ordinary tool turn during a close-out must not consume it. Direct `wipe_memory` is not armed by the shared warning and remains valid without one. A user abort, an assistant message with a synthetic `stopReason: "aborted"`, and a generic provider error are distinct cases; none commits an ordinary close-out, while only Pi's actual operation cancellation is a user abort.
50
+
51
+ ## Budget staging and notifications
52
+
53
+ The early reminder is staged once per window when remaining budget reaches `reserve + reminderMarginTokens`. The final close-out warning is staged/attached at `reserve + WARNING_RUNWAY_TOKENS`, only when automatic compaction is enabled. The hard reserve itself remains a separate safety cutoff. The warning text is shared with `/wipe-memory`; after it is durable, the extension does not repeat it in that window. Warning persistence deduplicates only injection: each eligible low-budget request re-arms transient automatic close-out before checking for the durable warning, so an abort/error can clear its attempt and the next request can retry without another warning.
54
+
55
+ Budget drafts are instance-local and are consumed at turn end. They are discarded on abort, failed turn, transition, or window mismatch. UI reminder notices are emitted only after the matching hidden entry is durable, so failed requests do not report an uncommitted warning. Active-window usage and the exact `SettingsManager` authority drive threshold checks; default file-backed policy is cached per extension instance, while injected managers are read through their public API.
56
+
57
+ ## Projection, repair, and history
58
+
59
+ `context_with_system` selects the active boot by durable `details.windowId`. For a verified native checkpoint, Pi's canonical projection is used directly; for a legacy marker-only reset, the compatibility projection slices at the matching boot. The projection preserves system/tool state and later prompt patches. If the boot is absent, the runtime aborts safely instead of sending raw history. Usage estimation uses the same checkpoint-aware projection decision.
60
+
61
+ Startup/tree repair is narrow: it may complete a repairable marker tail when later conversation does not make the missing metadata ambiguous. It awaits the notes snapshot and rechecks lifecycle/window identity before sending a repaired boot or continuation. Legacy marker-only tails remain supported; repair never moves a boundary or promotes an arbitrary compaction to a retain-none checkpoint. `/tree` summary generation is suppressed with an empty summary when either branch crosses a reset, because the raw summary generator bypasses the provider projection.
62
+
63
+ ## Validation and known limits
64
+
65
+ The test suite combines isolated reducer tests with scripted SDK tests executing Pi's real agent loop without model/network calls. The agent-loop coverage includes manual warning plus multi-turn note writes and explicit wipe, direct wipe without warning, normal-stop fallback, same-window concurrent command deduplication, actual and synthetic abort/error cleanup, queued steering/follow-up delivery, hard-reserve and bounded-overflow recovery, successive resets, and real SessionManager file reopen after two retain-none checkpoints. It also checks raw-history preservation, canonical projection, empty-summary retention, system/tool state, warning/marker order, and fresh-window continuation.
66
+
67
+ The broader coherence and pagination property suites were removed during test reduction; retained cases are representative rather than exhaustive. Tree-summary suppression has no dedicated retained test, and external-provider behavior, every malformed persisted shape, and every filesystem failure mode are not established by this suite.
package/package.json CHANGED
@@ -1,7 +1,24 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.25.2",
3
+ "version": "0.26.1",
4
4
  "type": "module",
5
+ "main": "./dist/src/index.js",
6
+ "types": "./dist/src/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/src/index.d.ts",
10
+ "import": "./dist/src/index.js"
11
+ },
12
+ "./notes": {
13
+ "types": "./dist/src/notes/index.d.ts",
14
+ "import": "./dist/src/notes/index.js"
15
+ },
16
+ "./dist/src/index.js": {
17
+ "types": "./dist/src/index.d.ts",
18
+ "import": "./dist/src/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
5
22
  "description": "Codex-style context windows for Pi: durable reset windows, session history tools, and persistent notes.",
6
23
  "license": "MIT",
7
24
  "keywords": [
@@ -16,6 +33,9 @@
16
33
  "pi": {
17
34
  "extensions": [
18
35
  "./dist/extension.js"
36
+ ],
37
+ "skills": [
38
+ "./skills"
19
39
  ]
20
40
  },
21
41
  "files": [
@@ -24,7 +44,8 @@
24
44
  "docs",
25
45
  "LICENSE",
26
46
  "README.md",
27
- "playbook.md"
47
+ "playbook.md",
48
+ "skills"
28
49
  ],
29
50
  "bin": {
30
51
  "dream": "dist/src/dream/cli.js"
@@ -33,6 +54,7 @@
33
54
  "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
55
  "typecheck": "tsc -p tsconfig.json --noEmit",
35
56
  "test": "npm run build && node --test dist/test/*.test.js",
57
+ "test:notes-package": "npm run build && node scripts/check-notes-package.mjs",
36
58
  "prepublishOnly": "npm run typecheck",
37
59
  "prepack": "npm run build"
38
60
  },
@@ -41,6 +63,17 @@
41
63
  "@earendil-works/pi-ai": "^0.87.0",
42
64
  "@earendil-works/pi-coding-agent": "^0.87.0"
43
65
  },
66
+ "peerDependenciesMeta": {
67
+ "@earendil-works/pi-agent-core": {
68
+ "optional": true
69
+ },
70
+ "@earendil-works/pi-ai": {
71
+ "optional": true
72
+ },
73
+ "@earendil-works/pi-coding-agent": {
74
+ "optional": true
75
+ }
76
+ },
44
77
  "devDependencies": {
45
78
  "@earendil-works/pi-agent-core": "^0.87.0",
46
79
  "@earendil-works/pi-ai": "^0.87.0",
package/playbook.md CHANGED
@@ -1,32 +1,33 @@
1
- I am dreaming over my notes. They are plain markdown files in three homes, addressed as:
2
-
3
- - bare `<vpath>` for this session
4
- - `@project/<vpath>` for this project
5
- - `@personal/<vpath>` for personal notes
6
-
7
- Every note has this frontmatter block:
8
-
9
- ```yaml
10
- ---
11
- origin: user | self | external
12
- status: active
13
- stale: false
14
- created_at: <timestamp>
15
- updated_at: <timestamp>
16
- last_accessed: <timestamp>
17
- access_count: 0
18
- ---
19
- ```
20
-
21
- ## Dream rules
22
-
23
- 1. **Probe before you trust.** Before keeping or promoting a note, verify its world referents with read-only file tools: paths in the body do they still exist? branches still present? Dead referents are why a note gets merged away or marked stale, never promoted.
24
- 2. **Merge threshold.** Supersede another note only when all three hold: same topic (name it in the survivor's body), same kind of note (checkpoint/design/log…), and the survivor is strictly newer or strictly more specific. Otherwise keep both and record the open conflict in the survivor.
25
- 3. **Size budget.** Keep every note under ~200 lines / ~8KB. Oversized notes get split by topic with a one-line cross-link in each (`see also: @home/<vpath>`). Checkpoints may exceed the budget trim prose, never facts.
26
- 4. **Keep the maps.** Each home's `MAP.md` maps that home's durable notes: one line per entry its address and a short gist in your own words, never a mechanical body slice. Project notes go on `@project/MAP.md`, cross-project knowledge on `@personal/MAP.md`; session notes are never mapped — the pocket covers them. When a note is promoted across homes, move its line to the destination map; when a note goes stale, drop its line. Maps obey the same size budget as any note.
27
- 5. **Jurisdiction.** Read the whole store, including every session home, to extract durable knowledge. `pi/session/**` is a live agent's write-ahead log: never write or edit anything there, including frontmatter or stale markers. Promote useful facts by writing to project or personal instead. Other notes are never physically deleted and every run is bracketed by git commits, so the human gate can audit and revert whatever you touch. Group your report by home so the gate can see what moved. In writable homes: map entry lines are yours to maintain, but prose that carries rules or guidance is not — flag it in your report instead of rewriting it.
28
- 6. **Leave stable notes alone.** Change notes to incorporate new evidence, resolve verified errors, merge genuine duplicates, or split oversized files—not merely to shorten or rephrase them. Preserve facts, conditions, exceptions, and uncertainty. No change is a valid outcome.
29
-
30
- Read the files and merge genuinely duplicate notes in writable homes by editing the survivor, then set `stale: true` in the absorbed note's frontmatter if it is writable. Session notes remain untouched even when promoted; record their source in the destination. Nothing is physically deleted; stale notes remain readable. Promote durable cross-project knowledge by writing or editing at `@personal/<vpath>`. Keep notes compact and preserve useful provenance in the body.
31
-
32
- Do not write skill ideas as files. Put skill ideas and unresolved questions in your final assistant message as proposals for the human. Your final message should be a concise report of what you inspected, changed, and left unresolved. If you made no file writes, say so.
1
+ # Dream
2
+
3
+ I am reviewing my notes: keeping what is useful, connecting what belongs together, and leaving a clearer record for whoever comes next. Notes are record, not memory.
4
+
5
+ ## Scope
6
+
7
+ Work within the readable sources and writable homes established for this run. Do not broaden that scope while exploring. If a boundary is unclear, ask before crossing it.
8
+
9
+ A project dream draws from that project's material, project notes, and session notes identified as belonging to the project; it organizes only that project's notes. A store-wide dream may inspect across homes, but authorship and write permissions still apply. Unknown ownership is a coverage gap to report, not a reason to guess or repair ownership.
10
+
11
+ The homes serve different readers:
12
+
13
+ - Session notes are a trip's working record. Read them as sources where permitted; never edit them, even after promoting a useful finding.
14
+ - Project notes hold knowledge for whoever next works on that project.
15
+ - Human notes hold the human's cross-project preferences and standing rules. Do not turn project facts or your own lessons into rules in the human's voice.
16
+ - Agent notes belong to their author. Other agents' notes are read-only unless the human authorizes revision.
17
+ - Model notes describe the substrate's behavior. Other models' notes are read-only unless the human authorizes revision.
18
+
19
+ ## Review and consolidate
20
+
21
+ 1. **Leave stable notes alone.** Change a note to incorporate new evidence, resolve a verified error, merge a genuine duplicate, or split an oversized file—not merely to shorten or rephrase it. No change is a valid outcome.
22
+ 2. **Verify only what the proposed change depends on.** Before correcting or promoting a conclusion, check the relevant evidence as needed. Do not revalidate every stable note or turn dream into a source-code audit. An unavailable referent is unverified, not proof that it is dead; do not promote an unsupported claim as current fact.
23
+ 3. **Merge only genuine duplicates.** Supersede a note only when both notes have the same topic and kind (checkpoint, design, log…), and the survivor is strictly newer or more specific. Name the shared topic in the survivor. Otherwise keep both and record any unresolved conflict.
24
+ 4. **Preserve meaning and provenance.** Keep facts, conditions, exceptions, uncertainty, authorship, and sources. When promoting a finding, cite its source. Do not relabel agent-authored material as human-authored. If its destination or authority is unclear, propose the promotion instead.
25
+ 5. **Keep notes manageable.** Aim below about 200 lines / 8KB per note. Split oversized notes by topic with one-line cross-links. Checkpoints may exceed the budget: trim prose, never facts.
26
+ 6. **Keep maps useful.** Each writable home's MAP.md lists its durable notes, one line per entry, with an unambiguous address and a short gist in your own words. Add promoted notes and drop stale entries. Session notes are never mapped. Maps follow the same size budget.
27
+ 7. **Preserve the record.** Never physically delete notes. After merging, mark the absorbed note stale only if it is writable. Preserve existing metadata and provenance. Map entry lines are yours to maintain within the writable scope.
28
+
29
+ ## Report
30
+
31
+ Give a concise report grouped by home: what you inspected, changed, promoted, and left unresolved. Mention coverage gaps and partial failures. If no notes changed, say so. Distinguish actual note changes from execution or audit artifacts; do not claim success for an incomplete run.
32
+
33
+ Put skill ideas and unresolved questions in the final message as proposals for the human, not new skill files.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: dream
3
+ description: Review and consolidate durable notes using pi-context's shared dream playbook. Use when the human asks to dream over notes, merge genuine duplicates, update maps, or promote durable learning.
4
+ ---
5
+
6
+ # Dream
7
+
8
+ Read `../../playbook.md` completely, resolving the path from this skill's directory. It is the shared guide to reviewing and organizing notes.
9
+
10
+ Dream in this agent's current session. Do not start another agent or model unless the human asks.
11
+
12
+ Work within the supplied readable and writable scope. If the scope or permission to write has not been established, ask rather than inventing it or setting up execution machinery yourself.
@@ -1,9 +1,9 @@
1
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";
2
+ import { notesContextFromPi } from "../pi/notes/adapter.js";
3
+ import { loadNotesSnapshot, type NotesSnapshot } from "../pi/notes/snapshot.js";
4
4
  import { BOOT_TYPE } from "../protocol.js";
5
5
  import { renderBootBlock } from "./prompts.js";
6
- import { currentReset, isWindowBoot, rootWindowId } from "./context-window.js";
6
+ import { currentReset, currentWindowId, isWindowBoot, rootWindowId } from "./context-window.js";
7
7
  import { repairResetTail } from "./reset-artifacts.js";
8
8
 
9
9
  export type IncompleteNotesNotifier = (ctx: ExtensionContext, windowId: string, snapshot: NotesSnapshot) => void;
@@ -16,31 +16,38 @@ export type BootMessage = {
16
16
  readonly details: { windowId: string };
17
17
  };
18
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
- });
19
+ /** Render the boot block from one pre-await identity and acquired notes snapshot. */
20
+ function bootContent(
21
+ agentName: string,
22
+ modelName: string,
23
+ firstWindowId: string,
24
+ currentId: string,
25
+ previousId: string | undefined,
26
+ notes: NotesSnapshot,
27
+ ): string {
28
+ return renderBootBlock({ agentName, modelName, firstWindowId, currentWindowId: currentId, previousWindowId: previousId, notes });
29
29
  }
30
30
 
31
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.
32
+ * Acquire one notes snapshot and build the boot custom message for a window. All identity
33
+ * fields are captured synchronously before the first filesystem await.
34
34
  */
35
- export function buildBootMessage(
35
+ export async function buildBootMessage(
36
36
  ctx: ExtensionContext,
37
37
  windowId: string,
38
38
  previousId: string | undefined,
39
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 } };
40
+ isCurrent: () => boolean = () => true,
41
+ ): Promise<BootMessage> {
42
+ const identity = notesContextFromPi(ctx);
43
+ const notes = await loadNotesSnapshot(ctx, undefined, identity);
44
+ if (isCurrent()) notifyIncompleteNotes?.(ctx, windowId, notes);
45
+ return {
46
+ customType: BOOT_TYPE,
47
+ content: bootContent(identity.agent, identity.model, rootWindowId(identity.sessionId), windowId, previousId, notes),
48
+ display: false,
49
+ details: { windowId },
50
+ };
44
51
  }
45
52
 
46
53
  /** Persist one hidden boot message without triggering a model turn. */
@@ -54,15 +61,27 @@ export function sendBoot(pi: ExtensionAPI, boot: BootMessage): void {
54
61
  /**
55
62
  * Boot entry point for `session_start` / `session_tree`. Ordinary startup ensures one root
56
63
  * 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
64
  */
59
- export function ensureBoot(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): void {
65
+ export async function ensureBoot(
66
+ pi: ExtensionAPI,
67
+ ctx: ExtensionContext,
68
+ notifyIncompleteNotes?: IncompleteNotesNotifier,
69
+ isCurrent: () => boolean = () => true,
70
+ ): Promise<void> {
71
+ const sessionId = ctx.sessionManager.getSessionId();
60
72
  const reset = currentReset(ctx);
73
+ const windowId = reset?.data.windowId ?? rootWindowId(sessionId);
74
+ const stillCurrent = () => isCurrent() &&
75
+ ctx.signal?.aborted !== true &&
76
+ ctx.sessionManager.getSessionId() === sessionId &&
77
+ currentWindowId(ctx) === windowId;
61
78
  if (reset) {
62
- repairResetTail(pi, ctx, reset, notifyIncompleteNotes);
79
+ await repairResetTail(pi, ctx, reset, notifyIncompleteNotes, stillCurrent);
63
80
  return;
64
81
  }
65
- const windowId = rootWindowId(ctx.sessionManager.getSessionId());
66
82
  if (ctx.sessionManager.buildSessionProjection().messages.some((message) => isWindowBoot(message, windowId))) return;
67
- sendBoot(pi, buildBootMessage(ctx, windowId, undefined, notifyIncompleteNotes));
83
+ const boot = await buildBootMessage(ctx, windowId, undefined, notifyIncompleteNotes, stillCurrent);
84
+ if (!stillCurrent()) return;
85
+ if (ctx.sessionManager.buildSessionProjection().messages.some((message) => isWindowBoot(message, windowId))) return;
86
+ sendBoot(pi, boot);
68
87
  }
@@ -1,8 +1,8 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
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";
3
+ import { GUIDANCE_TYPE, WARNING_CONTENT, WARNING_TYPE } from "../protocol.js";
4
4
  import { readThresholdSettings, type ResolvedThresholds, type ThresholdSettingsResolution } from "./thresholds.js";
5
- import { currentWindowId, hasWindowMessage, windowUsage } from "./context-window.js";
5
+ import { currentWindowId, hasWindowMessage, isWindowMarker, rootWindowId, windowUsage } from "./context-window.js";
6
6
  import { tokenBudgetGuidance } from "./prompts.js";
7
7
  import { output } from "../tool-output.js";
8
8
 
@@ -12,7 +12,12 @@ export function remainingTokens(ctx: Pick<ExtensionContext, "sessionManager" | "
12
12
  return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
13
13
  }
14
14
 
15
- export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, settingsManager?: SettingsManager) {
15
+ export function registerBudget(
16
+ pi: ExtensionAPI,
17
+ isEnabled: () => boolean,
18
+ settingsManager?: SettingsManager,
19
+ onCloseOut: (windowId: string) => void = () => {},
20
+ ) {
16
21
  let cachedPolicy: { thresholds: ResolvedThresholds; automatic: boolean } | undefined;
17
22
  const notifiedWarnings = new Set<string>();
18
23
  const resolvePolicy = (ctx: ExtensionContext): ThresholdSettingsResolution => {
@@ -32,25 +37,38 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
32
37
  const automaticResetEnabled = (ctx: ExtensionContext): boolean => {
33
38
  return resolvePolicy(ctx).automatic;
34
39
  };
35
- const resetDue = (ctx: ExtensionContext): boolean => {
40
+ const hardReserveDue = (ctx: ExtensionContext): boolean => {
36
41
  if (!automaticResetEnabled(ctx)) return false;
37
42
  const usage = windowUsage(ctx);
38
43
  return usage !== undefined && usage.tokens !== null && usage.contextWindow - usage.tokens <= thresholdsFor(ctx).reserve;
39
44
  };
40
45
  const invalidateThresholds = () => { cachedPolicy = undefined; };
41
- const formatRemaining = (remaining: number): string => `${Math.max(0, Math.ceil(remaining / 1000))}k`;
42
46
  let pendingGuidance: { windowId: string; content: string; remaining: number } | undefined;
43
47
  let pendingWarning: { windowId: string; content: string; remaining: number } | undefined;
44
- let pendingNotices: Array<{ windowId: string; customType: string; remaining: number }> = [];
45
- const notifyCommittedReminders = (ctx: ExtensionContext) => {
46
- const windowId = currentWindowId(ctx);
47
- for (const notice of pendingNotices) {
48
- if (notice.windowId !== windowId || !hasWindowMessage(ctx, notice.customType)) continue;
49
- ctx.ui.notify(notice.customType === WARNING_TYPE
50
- ? `pi-context: Context almost full — ${formatRemaining(notice.remaining)} remaining`
51
- : `pi-context: Context running low — ${formatRemaining(notice.remaining)} remaining`, "warning");
48
+ let pendingNotices = new Map<string, { sessionId: string; windowId: string }>();
49
+ const noticeKey = (sessionId: string, windowId: string) => `${sessionId}:${windowId}`;
50
+ const warningCommittedInWindow = (ctx: ExtensionContext, notice: { sessionId: string; windowId: string }): boolean => {
51
+ if (ctx.sessionManager.getSessionId() !== notice.sessionId) return false;
52
+ let windowId = rootWindowId(notice.sessionId);
53
+ for (const entry of ctx.sessionManager.getBranch()) {
54
+ if (isWindowMarker(entry)) {
55
+ windowId = entry.data.windowId;
56
+ continue;
57
+ }
58
+ if (windowId === notice.windowId && entry.type === "custom_message" && entry.customType === WARNING_TYPE) return true;
59
+ }
60
+ return false;
61
+ };
62
+ const notifyCommittedWarnings = (ctx: ExtensionContext, settled = false) => {
63
+ for (const [key, notice] of pendingNotices) {
64
+ if (warningCommittedInWindow(ctx, notice)) {
65
+ pendingNotices.delete(key);
66
+ ctx.ui.notify("pi-context: Context almost full; close out the current memory window.", "warning");
67
+ } else if (settled) {
68
+ // An uncommitted draft must not be matched to a later manual warning.
69
+ pendingNotices.delete(key);
70
+ }
52
71
  }
53
- pendingNotices = [];
54
72
  };
55
73
 
56
74
  const clearStaged = () => {
@@ -59,7 +77,7 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
59
77
  };
60
78
  const resetForTransition = () => {
61
79
  clearStaged();
62
- pendingNotices = [];
80
+ pendingNotices.clear();
63
81
  invalidateThresholds();
64
82
  notifiedWarnings.clear();
65
83
  };
@@ -72,7 +90,10 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
72
90
  clearStaged();
73
91
  const windowId = currentWindowId(ctx);
74
92
  const drafts = staged.filter((draft): draft is NonNullable<typeof draft> => draft !== undefined && draft.windowId === windowId);
75
- pendingNotices = drafts.map(({ windowId, customType, remaining }) => ({ windowId, customType, remaining }));
93
+ if (drafts.some((draft) => draft.customType === WARNING_TYPE)) {
94
+ const sessionId = ctx.sessionManager.getSessionId();
95
+ pendingNotices.set(noticeKey(sessionId, windowId), { sessionId, windowId });
96
+ }
76
97
  return drafts.map((draft) => ({
77
98
  type: "custom_message" as const,
78
99
  customType: draft.customType,
@@ -85,12 +106,12 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
85
106
  pi.on("session_tree", resetForTransition);
86
107
  pi.on("model_select", resetForTransition);
87
108
  pi.on("session_shutdown", resetForTransition);
88
- // A request can fail before Pi emits turn_end. agent_settled is the public
89
- // lifecycle point that must discard an uncommitted draft before the next prompt.
90
- // UI notices follow committed reminders. Aborted requests can retry their drafts
91
- // without showing the same low-budget notification twice.
109
+ // A warning can be committed at turn_end, before a tool turn or a reset changes the
110
+ // active window. Observe the active branch at public lifecycle boundaries and match
111
+ // the candidate against its originating window segment, not only the current window.
112
+ pi.on("turn_start", (_event, ctx) => notifyCommittedWarnings(ctx));
92
113
  pi.on("agent_settled", (_event, ctx) => {
93
- notifyCommittedReminders(ctx);
114
+ notifyCommittedWarnings(ctx, true);
94
115
  clearStaged();
95
116
  });
96
117
  pi.on("context", (_event, ctx) => {
@@ -101,11 +122,14 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
101
122
  if (remaining === null) return undefined;
102
123
  const windowId = currentWindowId(ctx);
103
124
  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.
125
+ if (remaining <= warning && automaticResetEnabled(ctx)) {
126
+ // Re-arm close-out on each eligible request. A prior request may have aborted
127
+ // after persisting the warning, so warning deduplication must not own this state.
128
+ onCloseOut(windowId);
129
+ if (hasWindowMessage(ctx, WARNING_TYPE) || pendingWarning?.windowId === windowId) return undefined;
130
+ // The critical close-out starts at reserve + runway, not at the hard reserve.
107
131
  pendingGuidance = undefined;
108
- const content = `${GUIDANCE_OPEN_TAG}\n${WARNING_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
132
+ const content = WARNING_CONTENT;
109
133
  pendingWarning = { windowId, content, remaining };
110
134
  const warningMessage = {
111
135
  role: "custom" as const,
@@ -116,6 +140,7 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
116
140
  };
117
141
  return { messages: [..._event.messages, warningMessage] };
118
142
  }
143
+ if (hasWindowMessage(ctx, WARNING_TYPE) || pendingWarning?.windowId === windowId) return undefined;
119
144
  if (hasWindowMessage(ctx, GUIDANCE_TYPE) || pendingGuidance?.windowId === windowId) return undefined;
120
145
  if (remaining <= reminder) {
121
146
  // Persist at turn_end, before any reset drafts. A queued sendMessage could
@@ -141,8 +166,8 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean, setti
141
166
 
142
167
  return {
143
168
  automaticResetEnabled,
144
- resetDue,
169
+ hardReserveDue,
145
170
  consumeTurnEnd,
146
- clear: () => { clearStaged(); pendingNotices = []; },
171
+ clear: () => { clearStaged(); pendingNotices.clear(); },
147
172
  };
148
173
  }