@astrosheep/pi-context 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +616 -370
  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 +19 -15
  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 +298 -242
  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/tool-output.d.ts +101 -0
  60. package/dist/src/tool-schema.d.ts +17 -0
  61. package/dist/test/agent-loop.test.d.ts +1 -0
  62. package/dist/test/agent-loop.test.js +318 -19
  63. package/dist/test/boot.integration.test.d.ts +1 -0
  64. package/dist/test/boot.integration.test.js +55 -29
  65. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  66. package/dist/test/budget-settings.integration.test.js +8 -7
  67. package/dist/test/doctor.test.d.ts +1 -0
  68. package/dist/test/doctor.test.js +10 -2
  69. package/dist/test/dream-skill.test.d.ts +1 -0
  70. package/dist/test/dream-skill.test.js +69 -0
  71. package/dist/test/dream.test.d.ts +1 -0
  72. package/dist/test/helpers/extension.d.ts +115 -0
  73. package/dist/test/helpers/extension.js +6 -6
  74. package/dist/test/helpers/notes.d.ts +6 -0
  75. package/dist/test/helpers/notes.js +13 -0
  76. package/dist/test/history.integration.test.d.ts +1 -0
  77. package/dist/test/notes-library.test.d.ts +1 -0
  78. package/dist/test/notes-library.test.js +111 -0
  79. package/dist/test/notes.integration.test.d.ts +1 -0
  80. package/dist/test/notes.integration.test.js +22 -24
  81. package/dist/test/notes.test.d.ts +1 -0
  82. package/dist/test/notes.test.js +137 -7
  83. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  84. package/dist/test/reset-lifecycle.test.js +142 -85
  85. package/docs/architecture.md +8 -8
  86. package/docs/reset-lifecycle.md +63 -79
  87. package/package.json +35 -2
  88. package/playbook.md +33 -32
  89. package/skills/dream/SKILL.md +12 -0
  90. package/src/context/boot.ts +44 -25
  91. package/src/context/budget.ts +25 -17
  92. package/src/context/context-window.ts +16 -1
  93. package/src/context/prompts.ts +2 -2
  94. package/src/context/reset-artifacts.ts +26 -24
  95. package/src/context/reset-lifecycle.ts +117 -111
  96. package/src/context/runtime.ts +50 -22
  97. package/src/context/thresholds.ts +1 -1
  98. package/src/dream/cli.ts +1 -1
  99. package/src/dream/doctor.ts +5 -2
  100. package/src/index.ts +4 -4
  101. package/src/notes/address.ts +9 -15
  102. package/src/notes/constants.ts +3 -0
  103. package/src/notes/context.ts +40 -0
  104. package/src/notes/frontmatter.ts +18 -12
  105. package/src/notes/index.ts +22 -0
  106. package/src/notes/paths.ts +64 -78
  107. package/src/notes/store.ts +308 -244
  108. package/src/pi/notes/adapter.ts +44 -0
  109. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  110. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  111. package/src/{notes → pi/notes}/tools.ts +25 -23
  112. package/src/protocol.ts +5 -6
@@ -20,11 +20,17 @@ export function sendContinuation(pi: ExtensionAPI): void {
20
20
  }
21
21
 
22
22
  /**
23
- * The closed, ordered reset shape: marker, matching boot, continuation. This is the one
24
- * source of the reset message and the one place that mints the new window identity.
23
+ * The closed, ordered reset shape: retain-none native checkpoint, marker, matching boot,
24
+ * continuation. The boot snapshot is acquired asynchronously after capturing this identity.
25
25
  */
26
- export function buildResetDrafts(ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier) {
27
- const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
26
+ export async function buildResetDrafts(
27
+ ctx: ExtensionContext,
28
+ notifyIncompleteNotes?: IncompleteNotesNotifier,
29
+ isCurrent: () => boolean = () => true,
30
+ ): Promise<[SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft]> {
31
+ const sessionId = ctx.sessionManager.getSessionId();
32
+ const previousId = currentWindowId(ctx);
33
+ const sessionPrefix = sessionId.slice(0, 8);
28
34
  const usedWindowIds = new Set(
29
35
  ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId),
30
36
  );
@@ -32,27 +38,13 @@ export function buildResetDrafts(ctx: ExtensionContext, notifyIncompleteNotes?:
32
38
  do {
33
39
  windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
34
40
  } while (usedWindowIds.has(windowId));
35
- const boot = buildBootMessage(ctx, windowId, currentWindowId(ctx), notifyIncompleteNotes);
41
+ const boot = await buildBootMessage(ctx, windowId, previousId, notifyIncompleteNotes, isCurrent);
36
42
  return [
43
+ { type: "compaction", summary: "", firstKeptEntryId: null },
37
44
  { type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
38
45
  { type: "custom_message", customType: BOOT_TYPE, content: boot.content, display: false, details: { windowId } },
39
46
  { type: "custom_message", customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
40
- ] satisfies [SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft];
41
- }
42
-
43
- /** Persist the marker and send both hidden reset messages; returns the new window id. */
44
- export function persistManualReset(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): string {
45
- const [marker, boot, continuation] = buildResetDrafts(ctx, notifyIncompleteNotes);
46
- pi.appendEntry(marker.customType, marker.data);
47
- pi.sendMessage(
48
- { customType: boot.customType, content: boot.content, display: boot.display, details: boot.details },
49
- { triggerTurn: false },
50
- );
51
- pi.sendMessage(
52
- { customType: continuation.customType, content: continuation.content, display: continuation.display },
53
- { triggerTurn: false },
54
- );
55
- return boot.details.windowId;
47
+ ];
56
48
  }
57
49
 
58
50
  /**
@@ -93,9 +85,19 @@ export function resetTailCommitted(ctx: ExtensionContext, markerId: string, wind
93
85
  }
94
86
 
95
87
  /** Emit only the reset artifacts an incomplete tail is missing, in the closed order. */
96
- export function repairResetTail(pi: ExtensionAPI, ctx: ExtensionContext, marker: WindowMarker, notifyIncompleteNotes?: IncompleteNotesNotifier): void {
88
+ export async function repairResetTail(
89
+ pi: ExtensionAPI,
90
+ ctx: ExtensionContext,
91
+ marker: WindowMarker,
92
+ notifyIncompleteNotes?: IncompleteNotesNotifier,
93
+ isCurrent: () => boolean = () => true,
94
+ ): Promise<void> {
97
95
  const tail = inspectResetTail(ctx, marker.id, marker.data.windowId);
98
96
  if (!tail || (tail.boot && tail.continuation)) return;
99
- if (!tail.boot) sendBoot(pi, buildBootMessage(ctx, marker.data.windowId, previousWindowId(ctx, marker.id), notifyIncompleteNotes));
100
- if (!tail.continuation) sendContinuation(pi);
97
+ const boot = tail.boot ? undefined : await buildBootMessage(ctx, marker.data.windowId, previousWindowId(ctx, marker.id), notifyIncompleteNotes, isCurrent);
98
+ if (!isCurrent()) return;
99
+ const latest = inspectResetTail(ctx, marker.id, marker.data.windowId);
100
+ if (!latest) return;
101
+ if (!latest.boot && boot) sendBoot(pi, boot);
102
+ if (!latest.continuation && (latest.boot || boot)) sendContinuation(pi);
101
103
  }
@@ -1,11 +1,11 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
2
  import { isContextOverflow, isRecoverableLength } from "@earendil-works/pi-ai";
3
3
  import type { AgentBeforeSettleEvent, ExtensionAPI, ExtensionContext, SessionBoundaryDraft } from "@earendil-works/pi-coding-agent";
4
- import { currentReset } from "./context-window.js";
4
+ import { currentReset, currentWindowId } from "./context-window.js";
5
5
 
6
6
  type BudgetOwner = {
7
7
  automaticResetEnabled: (ctx: ExtensionContext) => boolean;
8
- resetDue: (ctx: ExtensionContext) => boolean;
8
+ hardReserveDue: (ctx: ExtensionContext) => boolean;
9
9
  consumeTurnEnd: (ctx: ExtensionContext) => SessionBoundaryDraft[];
10
10
  clear: () => void;
11
11
  };
@@ -13,7 +13,9 @@ type BudgetOwner = {
13
13
  type ResetOptions = {
14
14
  isEnabled: () => boolean;
15
15
  budget: BudgetOwner;
16
- buildReset: (ctx: ExtensionContext) => SessionBoundaryDraft[];
16
+ buildReset: (ctx: ExtensionContext, isCurrent: () => boolean) => SessionBoundaryDraft[] | Promise<SessionBoundaryDraft[]>;
17
+ getLifecycleGeneration?: () => number;
18
+ onResetReady?: (ctx: ExtensionContext, drafts: readonly SessionBoundaryDraft[]) => void;
17
19
  };
18
20
 
19
21
  function isAbort(message: AgentMessage, outcome: string | undefined, ctx: ExtensionContext): boolean {
@@ -26,79 +28,59 @@ function isOverflowLike(message: AgentMessage, ctx: ExtensionContext): boolean {
26
28
  (ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
27
29
  }
28
30
 
29
- /**
30
- * Reset-control state. The concern is split into two independent, explicitly typed axes so
31
- * that no combination of independently combinable lifecycle booleans is legal by accident:
32
- *
33
- * - `request` is the single pending explicit wipe request ("none" or "explicit"). Repeated
34
- * requests deduplicate while one is pending.
35
- * - `overflow` is the bounded provider-overflow recovery chain:
36
- * - "idle" no overflow failure is pending; recovery is available for a later chain.
37
- * - "pending" an overflow failure is pending; recovery is still available.
38
- * - "pending-spent" an overflow failure is pending, but recovery was already spent this chain.
39
- * - "spent" recovery was spent and the failure is no longer pending.
40
- *
41
- * All eight combinations of the two axes are reachable (a request may arrive while an overflow
42
- * chain is pending, spent, or settled), and each has a defined transition. There is no state
43
- * whose fields silently contradict one another.
44
- */
45
- export type ResetRequestPhase = "none" | "explicit";
31
+ export type ResetRequestSource = "manual" | "automatic";
32
+ export type ResetRequest =
33
+ | { readonly phase: "none" }
34
+ | { readonly phase: "close-out"; readonly windowId: string; readonly source: ResetRequestSource }
35
+ | { readonly phase: "tool-requested"; readonly windowId: string };
46
36
  export type ResetOverflowPhase = "idle" | "pending" | "pending-spent" | "spent";
47
37
 
48
38
  export interface ResetControlState {
49
- readonly request: ResetRequestPhase;
39
+ readonly request: ResetRequest;
50
40
  readonly overflow: ResetOverflowPhase;
51
41
  }
52
42
 
43
+ const NO_REQUEST: ResetRequest = { phase: "none" };
44
+
53
45
  export function initialResetControl(): ResetControlState {
54
- return { request: "none", overflow: "idle" };
46
+ return { request: NO_REQUEST, overflow: "idle" };
55
47
  }
56
48
 
57
- /**
58
- * Facts for a completed turn. The three `get`-supplied fields are guards the adapter resolves
59
- * lazily: the reducer reads each one only in the branch where the previous adapter consulted it,
60
- * so policy resolution and pending-message probes keep their original call ordering.
61
- */
49
+ /** Facts for a completed turn. Guard values stay lazy to preserve policy resolution order. */
62
50
  export interface ResetTurnEndFacts {
51
+ readonly windowId: string;
63
52
  readonly aborted: boolean;
64
53
  readonly overflow: boolean;
65
54
  readonly failed: boolean;
66
55
  readonly enabled: boolean;
67
56
  readonly queued: boolean;
68
57
  readonly automaticResetEnabled: boolean;
69
- readonly thresholdDue: boolean;
58
+ readonly hardReserveDue: boolean;
70
59
  }
71
60
 
72
61
  /** Facts for the pre-settlement boundary; policy guards stay lazy for the same reason. */
73
62
  export interface ResetBeforeSettleFacts {
63
+ readonly windowId: string;
74
64
  readonly queued: boolean;
75
65
  readonly enabled: boolean;
76
66
  readonly automaticResetEnabled: boolean;
77
67
  readonly aborted: boolean;
68
+ readonly failed: boolean;
78
69
  }
79
70
 
80
71
  export type ResetControlEvent =
81
- | { readonly type: "request" }
72
+ | { readonly type: "close_out"; readonly windowId: string; readonly source: ResetRequestSource }
73
+ | { readonly type: "tool_request"; readonly windowId: string }
82
74
  | { readonly type: "turn_end"; readonly facts: ResetTurnEndFacts }
83
75
  | { readonly type: "before_settle"; readonly facts: ResetBeforeSettleFacts }
84
76
  | { readonly type: "settled" }
85
77
  | { readonly type: "abort" }
86
78
  | { readonly type: "clear" };
87
79
 
88
- /**
89
- * The requested effect of a transition. Effects are named, not performed: marker/boot/
90
- * continuation writes, continuation requests, and UI notices stay in the adapter.
91
- *
92
- * - "none" keep any return value to the drafts already collected for this event.
93
- * - "requested" a new explicit reset request was recorded.
94
- * - "already-requested" a request was already pending and was deduplicated.
95
- * - "commit-boundary" build and commit the reset boundary now (turn_end).
96
- * - "recover-overflow" build and commit the one bounded overflow recovery now (settle).
97
- */
98
80
  export type ResetControlEffect =
99
81
  | "none"
100
- | "requested"
101
- | "already-requested"
82
+ | "close-out-armed"
83
+ | "already-pending"
102
84
  | "commit-boundary"
103
85
  | "recover-overflow";
104
86
 
@@ -107,59 +89,77 @@ export interface ResetControlResult {
107
89
  readonly effect: ResetControlEffect;
108
90
  }
109
91
 
110
- /**
111
- * Pure reset-control transition. It performs no writes, no policy resolution of its own, and
112
- * no UI work; every fact it reads is supplied by the caller. Callers can therefore drive the
113
- * full transition table without a live Pi session.
114
- */
92
+ function requestForWindow(request: ResetRequest, windowId: string): ResetRequest {
93
+ return request.phase !== "none" && request.windowId === windowId ? request : NO_REQUEST;
94
+ }
95
+
96
+ /** Pure reset-control transitions: request phases own close-out, tool commit, and fallback. */
115
97
  export function reduceResetControl(state: ResetControlState, event: ResetControlEvent): ResetControlResult {
116
98
  switch (event.type) {
117
- case "request": {
118
- if (state.request === "explicit") return { state, effect: "already-requested" };
119
- return { state: { ...state, request: "explicit" }, effect: "requested" };
99
+ case "close_out": {
100
+ const request = state.request;
101
+ if (request.phase === "tool-requested" && request.windowId === event.windowId) {
102
+ return { state, effect: "already-pending" };
103
+ }
104
+ if (request.phase === "close-out" && request.windowId === event.windowId) {
105
+ if (request.source === "manual" || event.source === "automatic") return { state, effect: "already-pending" };
106
+ return { state: { ...state, request: { ...request, source: "manual" } }, effect: "close-out-armed" };
107
+ }
108
+ return { state: { ...state, request: { phase: "close-out", windowId: event.windowId, source: event.source } }, effect: "close-out-armed" };
109
+ }
110
+ case "tool_request": {
111
+ const request = state.request;
112
+ if (request.phase === "tool-requested" && request.windowId === event.windowId) return { state, effect: "already-pending" };
113
+ return { state: { ...state, request: { phase: "tool-requested", windowId: event.windowId } }, effect: "close-out-armed" };
120
114
  }
121
115
  case "turn_end": {
122
116
  const facts = event.facts;
123
- const requested = state.request === "explicit";
124
- // The explicit request is consumed by the turn boundary whether or not it commits.
125
- const request: ResetRequestPhase = "none";
126
- if (facts.aborted) {
127
- // An aborted turn drops the whole boundary: no explicit request, no overflow chain.
128
- return { state: { request, overflow: "idle" }, effect: "none" };
129
- }
117
+ const request = requestForWindow(state.request, facts.windowId);
118
+ if (facts.aborted) return { state: initialResetControl(), effect: "none" };
130
119
  if (facts.overflow) {
131
- // A failure that Pi may recover natively: arm (or re-arm) the bounded settle path.
132
- // A queued message, disabled mode, or disabled automatic reset leaves it disarmed.
133
120
  const pending = !facts.queued && facts.enabled && facts.automaticResetEnabled;
134
121
  const spent = state.overflow === "pending-spent" || state.overflow === "spent";
135
122
  const overflow: ResetOverflowPhase = pending
136
123
  ? (spent ? "pending-spent" : "pending")
137
124
  : (spent ? "spent" : "idle");
138
- return { state: { request, overflow }, effect: "none" };
125
+ return { state: { request: NO_REQUEST, overflow }, effect: "none" };
126
+ }
127
+ if (facts.failed) return { state: { request: NO_REQUEST, overflow: state.overflow }, effect: "none" };
128
+ if (!facts.enabled) {
129
+ return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "none" };
130
+ }
131
+ // A direct tool request commits after the whole tool batch. The budget cutoff is
132
+ // a separate hard-reserve safety path; ordinary close-out waits for settlement.
133
+ if (request.phase === "tool-requested" || facts.hardReserveDue) {
134
+ return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "commit-boundary" };
139
135
  }
140
- // Any non-overflow completed turn supersedes an older overflow failure. A non-overflow
141
- // error leaves the armed overflow chain untouched for the settle boundary.
142
- const overflow: ResetOverflowPhase = facts.failed ? state.overflow : "idle";
143
- if (!facts.enabled || facts.failed) return { state: { request, overflow }, effect: "none" };
144
- // Explicit and threshold resets both commit at turn_end, after incoming and budget drafts.
145
- const commit = requested || facts.thresholdDue;
146
- return { state: { request, overflow }, effect: commit ? "commit-boundary" : "none" };
136
+ return { state: { request, overflow: "idle" }, effect: "none" };
147
137
  }
148
138
  case "before_settle": {
149
139
  const facts = event.facts;
150
- if (state.overflow !== "pending" && state.overflow !== "pending-spent") return { state, effect: "none" };
151
- // Let a queued turn run first; its own turn_end settles or clears this chain.
152
- if (facts.queued) return { state, effect: "none" };
153
- const spent = state.overflow === "pending-spent";
154
- if (!facts.enabled || !facts.automaticResetEnabled || facts.aborted) {
155
- return { state: { ...state, overflow: spent ? "spent" : "idle" }, effect: "none" };
140
+ if (facts.aborted) return { state: initialResetControl(), effect: "none" };
141
+ const request = requestForWindow(state.request, facts.windowId);
142
+ if (state.overflow === "pending" || state.overflow === "pending-spent") {
143
+ if (facts.queued) {
144
+ return { state: { ...state, request: facts.failed ? NO_REQUEST : request }, effect: "none" };
145
+ }
146
+ const spent = state.overflow === "pending-spent";
147
+ if (!facts.enabled || !facts.automaticResetEnabled) {
148
+ return { state: { request: facts.failed ? NO_REQUEST : request, overflow: spent ? "spent" : "idle" }, effect: "none" };
149
+ }
150
+ return { state: { request: NO_REQUEST, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
156
151
  }
157
- // The recovery is one-use per failure chain. Committing it spends the attempt.
158
- return { state: { ...state, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
152
+ if (facts.failed || !facts.enabled || request.phase !== "close-out") {
153
+ return { state: { ...state, request: NO_REQUEST }, effect: "none" };
154
+ }
155
+ if (facts.queued) return { state: { ...state, request }, effect: "none" };
156
+ if (request.source === "automatic" && !facts.automaticResetEnabled) {
157
+ return { state: { ...state, request: NO_REQUEST }, effect: "none" };
158
+ }
159
+ return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "commit-boundary" };
159
160
  }
160
161
  case "settled":
161
- // Settlement ends the failure chain but leaves a pending explicit request armed.
162
- return { state: { ...state, overflow: "idle" }, effect: "none" };
162
+ return { state: initialResetControl(), effect: "none" };
163
163
  case "abort":
164
164
  case "clear":
165
165
  return { state: initialResetControl(), effect: "none" };
@@ -167,71 +167,76 @@ export function reduceResetControl(state: ResetControlState, event: ResetControl
167
167
  }
168
168
 
169
169
  /**
170
- * Own reset requests at Pi 0.87 boundaries. Persisted windows are custom entries, not
171
- * compaction summaries: turn_end commits explicit/threshold resets after a complete tool
172
- * batch, while agent_before_settle commits the one bounded overflow recovery after Pi's
173
- * native recovery attempt has been cancelled.
174
- *
175
- * This function is the effect adapter: it captures Pi events, translates them into pure
176
- * reset-control transitions, and performs the resulting marker/boot/continuation writes and
177
- * notifications. The decision of what to do lives entirely in `reduceResetControl`.
170
+ * Own close-out requests at Pi's public turn and pre-settlement boundaries. Tool-requested
171
+ * resets commit at turn_end; manual and budget close-outs remain armed across note/tool turns
172
+ * and fall back at a successful agent_before_settle. Overflow recovery remains bounded.
178
173
  */
179
174
  export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions) {
180
175
  let sessionActive = true;
176
+ let lifecycleGeneration = 0;
181
177
  let control = initialResetControl();
182
178
 
183
179
  const clear = () => { control = reduceResetControl(control, { type: "clear" }).state; };
184
- const resetBoundaryResult = (entries: SessionBoundaryDraft[], ctx: ExtensionContext) => {
180
+ const resetBoundaryResult = async (entries: SessionBoundaryDraft[], ctx: ExtensionContext) => {
181
+ const generation = lifecycleGeneration;
182
+ const outerGeneration = options.getLifecycleGeneration?.();
183
+ const sessionId = ctx.sessionManager.getSessionId();
184
+ const windowId = currentWindowId(ctx);
185
+ const isCurrent = () => sessionActive && lifecycleGeneration === generation &&
186
+ (outerGeneration === undefined || options.getLifecycleGeneration?.() === outerGeneration) && options.isEnabled() &&
187
+ ctx.signal?.aborted !== true && ctx.sessionManager.getSessionId() === sessionId && currentWindowId(ctx) === windowId;
188
+ let resetDrafts: SessionBoundaryDraft[];
185
189
  try {
186
- return { entries: [...entries, ...options.buildReset(ctx)], continue: true as const };
190
+ resetDrafts = await options.buildReset(ctx, isCurrent);
187
191
  } catch (error) {
188
- ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
189
- // The incoming drafts and budget drafts are already valid work from this
190
- // boundary. Preserve them, but do not claim a continuation when reset
191
- // construction failed.
192
+ if (isCurrent()) ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
192
193
  return entries.length > 0 ? { entries } : undefined;
193
194
  }
195
+ if (!isCurrent()) return entries.length > 0 ? { entries } : undefined;
196
+ options.onResetReady?.(ctx, resetDrafts);
197
+ return { entries: [...entries, ...resetDrafts], continue: true as const };
194
198
  };
195
199
 
196
- pi.on("turn_end", (event, ctx) => {
200
+ pi.on("turn_end", async (event, ctx) => {
197
201
  if (!sessionActive) return undefined;
198
202
  const aborted = isAbort(event.message, event.outcome, ctx);
199
203
  const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
200
- // Lifecycle owns whether drafts are acceptable for this turn. Budget only
201
- // drains its instance-local staging, so aborts and disabled mode cannot commit it.
202
- const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
204
+ const budgetEntries = options.isEnabled() && !aborted && event.outcome !== "error" ? stagedBudgetEntries : [];
203
205
  const entries = [...(event.entries ?? []), ...budgetEntries];
204
206
  const decision = reduceResetControl(control, {
205
207
  type: "turn_end",
206
208
  facts: {
209
+ windowId: currentWindowId(ctx),
207
210
  aborted,
208
211
  overflow: aborted ? false : isOverflowLike(event.message, ctx),
209
212
  failed: event.outcome === "error",
210
213
  enabled: options.isEnabled(),
211
214
  get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
212
215
  get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
213
- get thresholdDue() { return options.budget.resetDue(ctx); },
216
+ get hardReserveDue() { return options.budget.hardReserveDue(ctx); },
214
217
  },
215
218
  });
216
219
  control = decision.state;
217
- if (decision.effect === "commit-boundary") return resetBoundaryResult(entries, ctx);
220
+ if (decision.effect === "commit-boundary") return await resetBoundaryResult(entries, ctx);
218
221
  return entries.length > 0 ? { entries } : undefined;
219
222
  });
220
223
 
221
- pi.on("agent_before_settle", (event: AgentBeforeSettleEvent, ctx) => {
224
+ pi.on("agent_before_settle", async (event: AgentBeforeSettleEvent, ctx) => {
222
225
  if (!sessionActive) return undefined;
223
226
  const decision = reduceResetControl(control, {
224
227
  type: "before_settle",
225
228
  facts: {
229
+ windowId: currentWindowId(ctx),
226
230
  get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
227
231
  get enabled() { return options.isEnabled(); },
228
232
  get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
229
- get aborted() { return event.outcome === "aborted" || ctx.signal?.aborted === true; },
233
+ aborted: event.outcome === "aborted" || ctx.signal?.aborted === true,
234
+ failed: event.outcome === "error",
230
235
  },
231
236
  });
232
237
  control = decision.state;
233
- if (decision.effect !== "recover-overflow") return undefined;
234
- return resetBoundaryResult(event.entries, ctx);
238
+ if (decision.effect !== "commit-boundary" && decision.effect !== "recover-overflow") return undefined;
239
+ return await resetBoundaryResult(event.entries, ctx);
235
240
  });
236
241
 
237
242
  pi.on("session_before_compact", (event, ctx) => {
@@ -242,30 +247,31 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions)
242
247
  if (event.reason === "manual") {
243
248
  ctx.ui.notify("pi-context: /compact is disabled while context windows are active; use /wipe-memory to start a fresh window.", "warning");
244
249
  }
245
- // Native compaction is cancelled here. Threshold resets are decided solely from
246
- // completed-turn usage at turn_end, never from canonical pre-request history.
247
250
  return { cancel: true };
248
251
  }
249
252
  return undefined;
250
253
  });
251
254
 
252
255
  pi.on("agent_end", (_event, ctx) => {
253
- if (ctx.signal?.aborted) clear();
256
+ if (ctx.signal?.aborted) control = reduceResetControl(control, { type: "abort" }).state;
254
257
  });
255
258
  pi.on("agent_settled", () => {
256
- // A failed recovery chain is bounded to one reset/retry. Once Pi settles, a later
257
- // user prompt starts a new chain; successful continuations clear this earlier.
258
259
  control = reduceResetControl(control, { type: "settled" }).state;
259
260
  });
260
- pi.on("session_start", () => { clear(); sessionActive = true; });
261
- pi.on("session_tree", clear);
262
- pi.on("session_shutdown", () => { clear(); options.budget.clear(); sessionActive = false; });
261
+ pi.on("session_start", () => { lifecycleGeneration++; clear(); sessionActive = true; });
262
+ pi.on("session_tree", () => { lifecycleGeneration++; clear(); });
263
+ pi.on("session_shutdown", () => { lifecycleGeneration++; clear(); options.budget.clear(); sessionActive = false; });
263
264
 
264
265
  return {
265
- request() {
266
- const decision = reduceResetControl(control, { type: "request" });
266
+ closeOut(windowId: string, source: ResetRequestSource) {
267
+ const decision = reduceResetControl(control, { type: "close_out", windowId, source });
268
+ control = decision.state;
269
+ return decision.effect;
270
+ },
271
+ request(windowId: string) {
272
+ const decision = reduceResetControl(control, { type: "tool_request", windowId });
267
273
  control = decision.state;
268
- return decision.effect === "already-requested" ? "rollover_already_pending" : "rollover_requested";
274
+ return decision.effect === "already-pending" ? "rollover_already_pending" : "rollover_requested";
269
275
  },
270
276
  clear,
271
277
  };
@@ -2,10 +2,11 @@ import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
2
2
  import { VERSION, defineTool, type ExtensionAPI, type ExtensionContext, type SettingsManager } from "@earendil-works/pi-coding-agent";
3
3
  import { registerBudget } from "./budget.js";
4
4
  import { output } from "../tool-output.js";
5
- import { migrateLegacyHomes } from "../notes/paths.js";
6
- import { currentReset, isWindowMarker, projectRootWindow, projectWindow, rootWindowId } from "./context-window.js";
5
+ import { migrateLegacyHomes } from "../pi/notes/adapter.js";
6
+ import { currentReset, currentWindowId, isCheckpointBackedReset, isWindowBoot, isWindowMarker, projectRootWindow, projectWindow, rootWindowId, type WindowMarker } from "./context-window.js";
7
7
  import { registerResetLifecycle } from "./reset-lifecycle.js";
8
- import { buildResetDrafts, persistManualReset, resetTailCommitted } from "./reset-artifacts.js";
8
+ import { buildResetDrafts, resetTailCommitted } from "./reset-artifacts.js";
9
+ import { BOOT_TYPE, WARNING_CONTENT, WARNING_TYPE } from "../protocol.js";
9
10
  import { ensureBoot, type IncompleteNotesNotifier } from "./boot.js";
10
11
 
11
12
  declare const __PI_CONTEXT_BUILD__: { version: string; sourceHash: string };
@@ -22,6 +23,7 @@ function branchHasWindowMarker(ctx: ExtensionContext, fromId?: string): boolean
22
23
  /** Register the context-window runtime and its context-owned commands/tools. */
23
24
  export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
24
25
  let enabled = true;
26
+ let lifecycleGeneration = 0;
25
27
  let missingBootNotice: string | undefined;
26
28
  const incompleteNotesNotified = new Set<string>();
27
29
  const pendingResetNotices = new Set<string>();
@@ -32,8 +34,8 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
32
34
  if (pendingResetNotices.size === 0) return;
33
35
  const branch = ctx.sessionManager.getBranch();
34
36
  for (const windowId of pendingResetNotices) {
35
- const marker = branch.find((entry) => isWindowMarker(entry) && entry.data.windowId === windowId);
36
- if (!marker || !resetTailCommitted(ctx, marker.id, windowId)) continue;
37
+ const marker = branch.find((entry): entry is WindowMarker => isWindowMarker(entry) && entry.data.windowId === windowId);
38
+ if (!marker || !isCheckpointBackedReset(ctx, marker) || !resetTailCommitted(ctx, marker.id, windowId)) continue;
37
39
  pendingResetNotices.delete(windowId);
38
40
  ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
39
41
  }
@@ -52,18 +54,24 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
52
54
  const migrationWarning = migrateLegacyHomes();
53
55
  if (migrationWarning) console.warn(`pi-context: ${migrationWarning}`);
54
56
 
55
- const budget = registerBudget(pi, () => enabled, settingsManager);
57
+ const budget = registerBudget(pi, () => enabled, settingsManager, (windowId) => resets.closeOut(windowId, "automatic"));
56
58
 
57
- pi.on("session_start", (_event, ctx) => {
59
+ pi.on("session_start", async (_event, ctx) => {
60
+ const generation = ++lifecycleGeneration;
58
61
  if (!enabled) return;
59
62
  missingBootNotice = undefined;
60
63
  pendingResetNotices.clear();
61
- ensureBoot(pi, ctx, notifyIncompleteNotes);
64
+ await ensureBoot(pi, ctx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
62
65
  });
63
- pi.on("session_tree", (_event, ctx) => {
66
+ pi.on("session_tree", async (_event, ctx) => {
67
+ const generation = ++lifecycleGeneration;
64
68
  missingBootNotice = undefined;
65
69
  pendingResetNotices.clear();
66
- if (enabled) ensureBoot(pi, ctx, notifyIncompleteNotes);
70
+ if (enabled) await ensureBoot(pi, ctx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
71
+ });
72
+ pi.on("session_shutdown", () => {
73
+ lifecycleGeneration++;
74
+ pendingResetNotices.clear();
67
75
  });
68
76
 
69
77
  // Pi's branch summarizer receives raw entries and bypasses context_with_system. Do not
@@ -81,7 +89,11 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
81
89
  const reset = currentReset(ctx);
82
90
  const windowId = reset?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
83
91
  try {
84
- return { messages: reset ? projectWindow(event.messages, windowId) : projectRootWindow(event.messages, windowId) };
92
+ if (reset) {
93
+ if (!event.messages.some((message) => isWindowBoot(message, windowId))) throw new Error(`Missing boot for context window ${windowId}`);
94
+ return { messages: isCheckpointBackedReset(ctx, reset) ? event.messages : projectWindow(event.messages, windowId) };
95
+ }
96
+ return { messages: projectRootWindow(event.messages, windowId) };
85
97
  } catch (error) {
86
98
  if (missingBootNotice !== windowId) {
87
99
  missingBootNotice = windowId;
@@ -101,9 +113,12 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
101
113
  const arg = args.trim().toLowerCase();
102
114
  if (arg === "on") {
103
115
  enabled = true;
104
- ensureBoot(pi, cmdCtx, notifyIncompleteNotes);
116
+ const generation = ++lifecycleGeneration;
117
+ await ensureBoot(pi, cmdCtx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
105
118
  } else if (arg === "off") {
106
119
  enabled = false;
120
+ lifecycleGeneration++;
121
+ pendingResetNotices.clear();
107
122
  budget.clear();
108
123
  resets.clear();
109
124
  } else if (arg !== "") {
@@ -115,16 +130,25 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
115
130
  });
116
131
 
117
132
  pi.registerCommand("wipe-memory", {
118
- description: "Persist a fresh context window without calling the model",
133
+ description: "Ask the agent to close out its notes, then start a fresh context window",
119
134
  handler: async (_args, cmdCtx) => {
120
135
  if (!enabled) {
121
136
  cmdCtx.ui.notify("pi-context: /wipe-memory requires /pi-context on.", "error");
122
137
  return;
123
138
  }
139
+ const requestedWindowId = currentWindowId(cmdCtx);
140
+ await cmdCtx.waitForIdle();
141
+ if (!enabled || currentWindowId(cmdCtx) !== requestedWindowId) return;
142
+ const armed = resets.closeOut(requestedWindowId, "manual");
143
+ if (armed === "already-pending") return;
144
+ try {
145
+ pi.sendMessage({ customType: WARNING_TYPE, content: WARNING_CONTENT, display: false }, { triggerTurn: true });
146
+ } catch (error) {
147
+ resets.clear();
148
+ cmdCtx.ui.notify(`pi-context: could not start manual close-out (${String(error)}).`, "error");
149
+ return;
150
+ }
124
151
  await cmdCtx.waitForIdle();
125
- if (!enabled) return;
126
- resets.clear();
127
- notifyCommittedResets(cmdCtx, persistManualReset(pi, cmdCtx, notifyIncompleteNotes));
128
152
  },
129
153
  });
130
154
 
@@ -133,18 +157,22 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
133
157
  label: "Wipe memory",
134
158
  description: "Wipe your in-context memory and start a fresh context window. Your session, notes, and history survive.",
135
159
  parameters: Type.Object({}, { additionalProperties: false }),
136
- async execute() {
160
+ async execute(_id, _params, _signal, _update, ctx) {
137
161
  if (!enabled) return output({ error: "pi-context is off (/pi-context on to enable)" });
138
- return output({ status: resets.request() }, undefined, true);
162
+ return output({ status: resets.request(currentWindowId(ctx)) }, undefined, true);
139
163
  },
140
164
  }));
141
165
 
142
166
  const resets = registerResetLifecycle(pi, {
143
167
  isEnabled: () => enabled,
144
- buildReset: (ctx) => {
145
- const drafts = buildResetDrafts(ctx, notifyIncompleteNotes);
146
- pendingResetNotices.add(drafts[1].details.windowId);
147
- return drafts;
168
+ buildReset: (ctx, isCurrent) => buildResetDrafts(ctx, notifyIncompleteNotes, isCurrent),
169
+ getLifecycleGeneration: () => lifecycleGeneration,
170
+ onResetReady: (_ctx, drafts) => {
171
+ const boot = drafts.find((draft) => draft.type === "custom_message" && draft.customType === BOOT_TYPE);
172
+ if (boot?.type === "custom_message" && boot.details && typeof boot.details === "object") {
173
+ const windowId = (boot.details as { windowId?: unknown }).windowId;
174
+ if (typeof windowId === "string") pendingResetNotices.add(windowId);
175
+ }
148
176
  },
149
177
  budget,
150
178
  });
@@ -24,7 +24,7 @@ export function deriveThresholds(reserveTokens: number, margins: PiContextSettin
24
24
  else {
25
25
  const parsed = validMargin(margins.reminderMarginTokens);
26
26
  if (parsed === undefined) {
27
- warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
27
+ warnings.push(`pi-context: ${reminderKey} must be a positive integer; using the default reminder margin.`);
28
28
  reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
29
29
  } else reminderMargin = parsed;
30
30
  }
package/src/dream/cli.ts CHANGED
@@ -8,7 +8,7 @@ import { loadPlaybook, runDreamer, type DreamerSessionFactory, type DreamResult,
8
8
  import { gitCommit } from "./git.js";
9
9
  import { readDreamerSettings, type DreamerSetting } from "./settings.js";
10
10
  import { doctor } from "./doctor.js";
11
- import { notesRoot } from "../notes/paths.js";
11
+ import { notesRoot } from "../pi/notes/adapter.js";
12
12
 
13
13
  function args(argv: string[]) { const out: Record<string, string | boolean> = {}; for (let i=0;i<argv.length;i++) { const a=argv[i]!; if (a === "--force" || a === "--help") out[a.slice(2)] = true; else if (a.startsWith("--")) out[a.slice(2)] = argv[++i] ?? ""; } return out; }
14
14
  function packageRoot(): string {
@@ -27,10 +27,13 @@ export function doctor(home: string): string[] {
27
27
  if (fields.has(field[1]!)) report(path, `duplicate metadata key ${field[1]}; keep one value`);
28
28
  fields.set(field[1]!, field[2]!.replace(/^(["'])(.*)\1$/, "$2"));
29
29
  }
30
- for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, access_count: /^\d+$/ })) {
30
+ for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, accessCount: /^\d+$/ })) {
31
31
  if (!valid.test(fields.get(key) ?? "")) report(path, `missing/invalid ${key}; repair frontmatter`);
32
32
  }
33
- for (const key of ["created_at", "updated_at", "last_accessed"]) {
33
+ for (const [legacy, current] of [["created_at", "createdAt"], ["updated_at", "updatedAt"], ["last_accessed", "lastAccessed"], ["access_count", "accessCount"], ["source_window", "sourceWindow"], ["recurrence_count", "recurrenceCount"], ["recurrence_windows", "recurrenceWindows"]]) {
34
+ if (fields.has(legacy)) report(path, `legacy metadata key ${legacy}; manually migrate to ${current}`);
35
+ }
36
+ for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
34
37
  const value = fields.get(key);
35
38
  if (!value || !Number.isFinite(Date.parse(value))) report(path, `missing/invalid ${key}; use an ISO timestamp`);
36
39
  }