@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
@@ -1,5 +1,5 @@
1
1
  import { isContextOverflow, isRecoverableLength } from "@earendil-works/pi-ai";
2
- import { currentReset } from "./context-window.js";
2
+ import { currentReset, currentWindowId } from "./context-window.js";
3
3
  function isAbort(message, outcome, ctx) {
4
4
  return outcome === "aborted" || (message.role === "assistant" && message.stopReason === "aborted") || ctx.signal?.aborted === true;
5
5
  }
@@ -9,139 +9,166 @@ function isOverflowLike(message, ctx) {
9
9
  return isContextOverflow(message, ctx.model?.contextWindow) ||
10
10
  (ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
11
11
  }
12
+ const NO_REQUEST = { phase: "none" };
12
13
  export function initialResetControl() {
13
- return { request: "none", overflow: "idle" };
14
+ return { request: NO_REQUEST, overflow: "idle" };
14
15
  }
15
- /**
16
- * Pure reset-control transition. It performs no writes, no policy resolution of its own, and
17
- * no UI work; every fact it reads is supplied by the caller. Callers can therefore drive the
18
- * full transition table without a live Pi session.
19
- */
16
+ function requestForWindow(request, windowId) {
17
+ return request.phase !== "none" && request.windowId === windowId ? request : NO_REQUEST;
18
+ }
19
+ /** Pure reset-control transitions: request phases own close-out, tool commit, and fallback. */
20
20
  export function reduceResetControl(state, event) {
21
21
  switch (event.type) {
22
- case "request": {
23
- if (state.request === "explicit")
24
- return { state, effect: "already-requested" };
25
- return { state: { ...state, request: "explicit" }, effect: "requested" };
22
+ case "close_out": {
23
+ const request = state.request;
24
+ if (request.phase === "tool-requested" && request.windowId === event.windowId) {
25
+ return { state, effect: "already-pending" };
26
+ }
27
+ if (request.phase === "close-out" && request.windowId === event.windowId) {
28
+ if (request.source === "manual" || event.source === "automatic")
29
+ return { state, effect: "already-pending" };
30
+ return { state: { ...state, request: { ...request, source: "manual" } }, effect: "close-out-armed" };
31
+ }
32
+ return { state: { ...state, request: { phase: "close-out", windowId: event.windowId, source: event.source } }, effect: "close-out-armed" };
33
+ }
34
+ case "tool_request": {
35
+ const request = state.request;
36
+ if (request.phase === "tool-requested" && request.windowId === event.windowId)
37
+ return { state, effect: "already-pending" };
38
+ return { state: { ...state, request: { phase: "tool-requested", windowId: event.windowId } }, effect: "close-out-armed" };
26
39
  }
27
40
  case "turn_end": {
28
41
  const facts = event.facts;
29
- const requested = state.request === "explicit";
30
- // The explicit request is consumed by the turn boundary whether or not it commits.
31
- const request = "none";
32
- if (facts.aborted) {
33
- // An aborted turn drops the whole boundary: no explicit request, no overflow chain.
34
- return { state: { request, overflow: "idle" }, effect: "none" };
35
- }
42
+ const request = requestForWindow(state.request, facts.windowId);
43
+ if (facts.aborted)
44
+ return { state: initialResetControl(), effect: "none" };
36
45
  if (facts.overflow) {
37
- // A failure that Pi may recover natively: arm (or re-arm) the bounded settle path.
38
- // A queued message, disabled mode, or disabled automatic reset leaves it disarmed.
39
46
  const pending = !facts.queued && facts.enabled && facts.automaticResetEnabled;
40
47
  const spent = state.overflow === "pending-spent" || state.overflow === "spent";
41
48
  const overflow = pending
42
49
  ? (spent ? "pending-spent" : "pending")
43
50
  : (spent ? "spent" : "idle");
44
- return { state: { request, overflow }, effect: "none" };
51
+ return { state: { request: NO_REQUEST, overflow }, effect: "none" };
52
+ }
53
+ if (facts.failed)
54
+ return { state: { request: NO_REQUEST, overflow: state.overflow }, effect: "none" };
55
+ if (!facts.enabled) {
56
+ return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "none" };
45
57
  }
46
- // Any non-overflow completed turn supersedes an older overflow failure. A non-overflow
47
- // error leaves the armed overflow chain untouched for the settle boundary.
48
- const overflow = facts.failed ? state.overflow : "idle";
49
- if (!facts.enabled || facts.failed)
50
- return { state: { request, overflow }, effect: "none" };
51
- // Explicit and threshold resets both commit at turn_end, after incoming and budget drafts.
52
- const commit = requested || facts.thresholdDue;
53
- return { state: { request, overflow }, effect: commit ? "commit-boundary" : "none" };
58
+ // A direct tool request commits after the whole tool batch. The budget cutoff is
59
+ // a separate hard-reserve safety path; ordinary close-out waits for settlement.
60
+ if (request.phase === "tool-requested" || facts.hardReserveDue) {
61
+ return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "commit-boundary" };
62
+ }
63
+ return { state: { request, overflow: "idle" }, effect: "none" };
54
64
  }
55
65
  case "before_settle": {
56
66
  const facts = event.facts;
57
- if (state.overflow !== "pending" && state.overflow !== "pending-spent")
58
- return { state, effect: "none" };
59
- // Let a queued turn run first; its own turn_end settles or clears this chain.
67
+ if (facts.aborted)
68
+ return { state: initialResetControl(), effect: "none" };
69
+ const request = requestForWindow(state.request, facts.windowId);
70
+ if (state.overflow === "pending" || state.overflow === "pending-spent") {
71
+ if (facts.queued) {
72
+ return { state: { ...state, request: facts.failed ? NO_REQUEST : request }, effect: "none" };
73
+ }
74
+ const spent = state.overflow === "pending-spent";
75
+ if (!facts.enabled || !facts.automaticResetEnabled) {
76
+ return { state: { request: facts.failed ? NO_REQUEST : request, overflow: spent ? "spent" : "idle" }, effect: "none" };
77
+ }
78
+ return { state: { request: NO_REQUEST, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
79
+ }
80
+ if (facts.failed || !facts.enabled || request.phase !== "close-out") {
81
+ return { state: { ...state, request: NO_REQUEST }, effect: "none" };
82
+ }
60
83
  if (facts.queued)
61
- return { state, effect: "none" };
62
- const spent = state.overflow === "pending-spent";
63
- if (!facts.enabled || !facts.automaticResetEnabled || facts.aborted) {
64
- return { state: { ...state, overflow: spent ? "spent" : "idle" }, effect: "none" };
84
+ return { state: { ...state, request }, effect: "none" };
85
+ if (request.source === "automatic" && !facts.automaticResetEnabled) {
86
+ return { state: { ...state, request: NO_REQUEST }, effect: "none" };
65
87
  }
66
- // The recovery is one-use per failure chain. Committing it spends the attempt.
67
- return { state: { ...state, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
88
+ return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "commit-boundary" };
68
89
  }
69
90
  case "settled":
70
- // Settlement ends the failure chain but leaves a pending explicit request armed.
71
- return { state: { ...state, overflow: "idle" }, effect: "none" };
91
+ return { state: initialResetControl(), effect: "none" };
72
92
  case "abort":
73
93
  case "clear":
74
94
  return { state: initialResetControl(), effect: "none" };
75
95
  }
76
96
  }
77
97
  /**
78
- * Own reset requests at Pi 0.87 boundaries. Persisted windows are custom entries, not
79
- * compaction summaries: turn_end commits explicit/threshold resets after a complete tool
80
- * batch, while agent_before_settle commits the one bounded overflow recovery after Pi's
81
- * native recovery attempt has been cancelled.
82
- *
83
- * This function is the effect adapter: it captures Pi events, translates them into pure
84
- * reset-control transitions, and performs the resulting marker/boot/continuation writes and
85
- * notifications. The decision of what to do lives entirely in `reduceResetControl`.
98
+ * Own close-out requests at Pi's public turn and pre-settlement boundaries. Tool-requested
99
+ * resets commit at turn_end; manual and budget close-outs remain armed across note/tool turns
100
+ * and fall back at a successful agent_before_settle. Overflow recovery remains bounded.
86
101
  */
87
102
  export function registerResetLifecycle(pi, options) {
88
103
  let sessionActive = true;
104
+ let lifecycleGeneration = 0;
89
105
  let control = initialResetControl();
90
106
  const clear = () => { control = reduceResetControl(control, { type: "clear" }).state; };
91
- const resetBoundaryResult = (entries, ctx) => {
107
+ const resetBoundaryResult = async (entries, ctx) => {
108
+ const generation = lifecycleGeneration;
109
+ const outerGeneration = options.getLifecycleGeneration?.();
110
+ const sessionId = ctx.sessionManager.getSessionId();
111
+ const windowId = currentWindowId(ctx);
112
+ const isCurrent = () => sessionActive && lifecycleGeneration === generation &&
113
+ (outerGeneration === undefined || options.getLifecycleGeneration?.() === outerGeneration) && options.isEnabled() &&
114
+ ctx.signal?.aborted !== true && ctx.sessionManager.getSessionId() === sessionId && currentWindowId(ctx) === windowId;
115
+ let resetDrafts;
92
116
  try {
93
- return { entries: [...entries, ...options.buildReset(ctx)], continue: true };
117
+ resetDrafts = await options.buildReset(ctx, isCurrent);
94
118
  }
95
119
  catch (error) {
96
- ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
97
- // The incoming drafts and budget drafts are already valid work from this
98
- // boundary. Preserve them, but do not claim a continuation when reset
99
- // construction failed.
120
+ if (isCurrent())
121
+ ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
100
122
  return entries.length > 0 ? { entries } : undefined;
101
123
  }
124
+ if (!isCurrent())
125
+ return entries.length > 0 ? { entries } : undefined;
126
+ options.onResetReady?.(ctx, resetDrafts);
127
+ return { entries: [...entries, ...resetDrafts], continue: true };
102
128
  };
103
- pi.on("turn_end", (event, ctx) => {
129
+ pi.on("turn_end", async (event, ctx) => {
104
130
  if (!sessionActive)
105
131
  return undefined;
106
132
  const aborted = isAbort(event.message, event.outcome, ctx);
107
133
  const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
108
- // Lifecycle owns whether drafts are acceptable for this turn. Budget only
109
- // drains its instance-local staging, so aborts and disabled mode cannot commit it.
110
- const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
134
+ const budgetEntries = options.isEnabled() && !aborted && event.outcome !== "error" ? stagedBudgetEntries : [];
111
135
  const entries = [...(event.entries ?? []), ...budgetEntries];
112
136
  const decision = reduceResetControl(control, {
113
137
  type: "turn_end",
114
138
  facts: {
139
+ windowId: currentWindowId(ctx),
115
140
  aborted,
116
141
  overflow: aborted ? false : isOverflowLike(event.message, ctx),
117
142
  failed: event.outcome === "error",
118
143
  enabled: options.isEnabled(),
119
144
  get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
120
145
  get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
121
- get thresholdDue() { return options.budget.resetDue(ctx); },
146
+ get hardReserveDue() { return options.budget.hardReserveDue(ctx); },
122
147
  },
123
148
  });
124
149
  control = decision.state;
125
150
  if (decision.effect === "commit-boundary")
126
- return resetBoundaryResult(entries, ctx);
151
+ return await resetBoundaryResult(entries, ctx);
127
152
  return entries.length > 0 ? { entries } : undefined;
128
153
  });
129
- pi.on("agent_before_settle", (event, ctx) => {
154
+ pi.on("agent_before_settle", async (event, ctx) => {
130
155
  if (!sessionActive)
131
156
  return undefined;
132
157
  const decision = reduceResetControl(control, {
133
158
  type: "before_settle",
134
159
  facts: {
160
+ windowId: currentWindowId(ctx),
135
161
  get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
136
162
  get enabled() { return options.isEnabled(); },
137
163
  get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
138
- get aborted() { return event.outcome === "aborted" || ctx.signal?.aborted === true; },
164
+ aborted: event.outcome === "aborted" || ctx.signal?.aborted === true,
165
+ failed: event.outcome === "error",
139
166
  },
140
167
  });
141
168
  control = decision.state;
142
- if (decision.effect !== "recover-overflow")
169
+ if (decision.effect !== "commit-boundary" && decision.effect !== "recover-overflow")
143
170
  return undefined;
144
- return resetBoundaryResult(event.entries, ctx);
171
+ return await resetBoundaryResult(event.entries, ctx);
145
172
  });
146
173
  pi.on("session_before_compact", (event, ctx) => {
147
174
  if (!sessionActive)
@@ -153,29 +180,30 @@ export function registerResetLifecycle(pi, options) {
153
180
  if (event.reason === "manual") {
154
181
  ctx.ui.notify("pi-context: /compact is disabled while context windows are active; use /wipe-memory to start a fresh window.", "warning");
155
182
  }
156
- // Native compaction is cancelled here. Threshold resets are decided solely from
157
- // completed-turn usage at turn_end, never from canonical pre-request history.
158
183
  return { cancel: true };
159
184
  }
160
185
  return undefined;
161
186
  });
162
187
  pi.on("agent_end", (_event, ctx) => {
163
188
  if (ctx.signal?.aborted)
164
- clear();
189
+ control = reduceResetControl(control, { type: "abort" }).state;
165
190
  });
166
191
  pi.on("agent_settled", () => {
167
- // A failed recovery chain is bounded to one reset/retry. Once Pi settles, a later
168
- // user prompt starts a new chain; successful continuations clear this earlier.
169
192
  control = reduceResetControl(control, { type: "settled" }).state;
170
193
  });
171
- pi.on("session_start", () => { clear(); sessionActive = true; });
172
- pi.on("session_tree", clear);
173
- pi.on("session_shutdown", () => { clear(); options.budget.clear(); sessionActive = false; });
194
+ pi.on("session_start", () => { lifecycleGeneration++; clear(); sessionActive = true; });
195
+ pi.on("session_tree", () => { lifecycleGeneration++; clear(); });
196
+ pi.on("session_shutdown", () => { lifecycleGeneration++; clear(); options.budget.clear(); sessionActive = false; });
174
197
  return {
175
- request() {
176
- const decision = reduceResetControl(control, { type: "request" });
198
+ closeOut(windowId, source) {
199
+ const decision = reduceResetControl(control, { type: "close_out", windowId, source });
200
+ control = decision.state;
201
+ return decision.effect;
202
+ },
203
+ request(windowId) {
204
+ const decision = reduceResetControl(control, { type: "tool_request", windowId });
177
205
  control = decision.state;
178
- return decision.effect === "already-requested" ? "rollover_already_pending" : "rollover_requested";
206
+ return decision.effect === "already-pending" ? "rollover_already_pending" : "rollover_requested";
179
207
  },
180
208
  clear,
181
209
  };
@@ -0,0 +1,3 @@
1
+ import { type ExtensionAPI, type SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ /** Register the context-window runtime and its context-owned commands/tools. */
3
+ export declare function registerContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void;
@@ -2,10 +2,11 @@ import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
2
2
  import { VERSION, defineTool } 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 } 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 } from "./boot.js";
10
11
  // The bundle captures its identity; direct source loads must not claim a built hash.
11
12
  const buildLabel = typeof __PI_CONTEXT_BUILD__ === "undefined"
@@ -17,6 +18,7 @@ function branchHasWindowMarker(ctx, fromId) {
17
18
  /** Register the context-window runtime and its context-owned commands/tools. */
18
19
  export function registerContext(pi, settingsManager) {
19
20
  let enabled = true;
21
+ let lifecycleGeneration = 0;
20
22
  let missingBootNotice;
21
23
  const incompleteNotesNotified = new Set();
22
24
  const pendingResetNotices = new Set();
@@ -30,7 +32,7 @@ export function registerContext(pi, settingsManager) {
30
32
  const branch = ctx.sessionManager.getBranch();
31
33
  for (const windowId of pendingResetNotices) {
32
34
  const marker = branch.find((entry) => isWindowMarker(entry) && entry.data.windowId === windowId);
33
- if (!marker || !resetTailCommitted(ctx, marker.id, windowId))
35
+ if (!marker || !isCheckpointBackedReset(ctx, marker) || !resetTailCommitted(ctx, marker.id, windowId))
34
36
  continue;
35
37
  pendingResetNotices.delete(windowId);
36
38
  ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
@@ -51,19 +53,25 @@ export function registerContext(pi, settingsManager) {
51
53
  const migrationWarning = migrateLegacyHomes();
52
54
  if (migrationWarning)
53
55
  console.warn(`pi-context: ${migrationWarning}`);
54
- const budget = registerBudget(pi, () => enabled, settingsManager);
55
- pi.on("session_start", (_event, ctx) => {
56
+ const budget = registerBudget(pi, () => enabled, settingsManager, (windowId) => resets.closeOut(windowId, "automatic"));
57
+ pi.on("session_start", async (_event, ctx) => {
58
+ const generation = ++lifecycleGeneration;
56
59
  if (!enabled)
57
60
  return;
58
61
  missingBootNotice = undefined;
59
62
  pendingResetNotices.clear();
60
- ensureBoot(pi, ctx, notifyIncompleteNotes);
63
+ await ensureBoot(pi, ctx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
61
64
  });
62
- pi.on("session_tree", (_event, ctx) => {
65
+ pi.on("session_tree", async (_event, ctx) => {
66
+ const generation = ++lifecycleGeneration;
63
67
  missingBootNotice = undefined;
64
68
  pendingResetNotices.clear();
65
69
  if (enabled)
66
- ensureBoot(pi, ctx, notifyIncompleteNotes);
70
+ await ensureBoot(pi, ctx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
71
+ });
72
+ pi.on("session_shutdown", () => {
73
+ lifecycleGeneration++;
74
+ pendingResetNotices.clear();
67
75
  });
68
76
  // Pi's branch summarizer receives raw entries and bypasses context_with_system. Do not
69
77
  // let a summary of a reset branch smuggle erased history back into the destination.
@@ -81,7 +89,12 @@ export function registerContext(pi, settingsManager) {
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)))
94
+ throw new Error(`Missing boot for context window ${windowId}`);
95
+ return { messages: isCheckpointBackedReset(ctx, reset) ? event.messages : projectWindow(event.messages, windowId) };
96
+ }
97
+ return { messages: projectRootWindow(event.messages, windowId) };
85
98
  }
86
99
  catch (error) {
87
100
  if (missingBootNotice !== windowId) {
@@ -100,10 +113,13 @@ export function registerContext(pi, settingsManager) {
100
113
  const arg = args.trim().toLowerCase();
101
114
  if (arg === "on") {
102
115
  enabled = true;
103
- ensureBoot(pi, cmdCtx, notifyIncompleteNotes);
116
+ const generation = ++lifecycleGeneration;
117
+ await ensureBoot(pi, cmdCtx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
104
118
  }
105
119
  else if (arg === "off") {
106
120
  enabled = false;
121
+ lifecycleGeneration++;
122
+ pendingResetNotices.clear();
107
123
  budget.clear();
108
124
  resets.clear();
109
125
  }
@@ -115,17 +131,28 @@ export function registerContext(pi, settingsManager) {
115
131
  },
116
132
  });
117
133
  pi.registerCommand("wipe-memory", {
118
- description: "Persist a fresh context window without calling the model",
134
+ description: "Ask the agent to close out its notes, then start a fresh context window",
119
135
  handler: async (_args, cmdCtx) => {
120
136
  if (!enabled) {
121
137
  cmdCtx.ui.notify("pi-context: /wipe-memory requires /pi-context on.", "error");
122
138
  return;
123
139
  }
140
+ const requestedWindowId = currentWindowId(cmdCtx);
124
141
  await cmdCtx.waitForIdle();
125
- if (!enabled)
142
+ if (!enabled || currentWindowId(cmdCtx) !== requestedWindowId)
143
+ return;
144
+ const armed = resets.closeOut(requestedWindowId, "manual");
145
+ if (armed === "already-pending")
126
146
  return;
127
- resets.clear();
128
- notifyCommittedResets(cmdCtx, persistManualReset(pi, cmdCtx, notifyIncompleteNotes));
147
+ try {
148
+ pi.sendMessage({ customType: WARNING_TYPE, content: WARNING_CONTENT, display: false }, { triggerTurn: true });
149
+ }
150
+ catch (error) {
151
+ resets.clear();
152
+ cmdCtx.ui.notify(`pi-context: could not start manual close-out (${String(error)}).`, "error");
153
+ return;
154
+ }
155
+ await cmdCtx.waitForIdle();
129
156
  },
130
157
  });
131
158
  pi.registerTool(defineTool({
@@ -133,18 +160,23 @@ export function registerContext(pi, settingsManager) {
133
160
  label: "Wipe memory",
134
161
  description: "Wipe your in-context memory and start a fresh context window. Your session, notes, and history survive.",
135
162
  parameters: Type.Object({}, { additionalProperties: false }),
136
- async execute() {
163
+ async execute(_id, _params, _signal, _update, ctx) {
137
164
  if (!enabled)
138
165
  return output({ error: "pi-context is off (/pi-context on to enable)" });
139
- return output({ status: resets.request() }, undefined, true);
166
+ return output({ status: resets.request(currentWindowId(ctx)) }, undefined, true);
140
167
  },
141
168
  }));
142
169
  const resets = registerResetLifecycle(pi, {
143
170
  isEnabled: () => enabled,
144
- buildReset: (ctx) => {
145
- const drafts = buildResetDrafts(ctx, notifyIncompleteNotes);
146
- pendingResetNotices.add(drafts[1].details.windowId);
147
- return drafts;
171
+ buildReset: (ctx, isCurrent) => buildResetDrafts(ctx, notifyIncompleteNotes, isCurrent),
172
+ getLifecycleGeneration: () => lifecycleGeneration,
173
+ onResetReady: (_ctx, drafts) => {
174
+ const boot = drafts.find((draft) => draft.type === "custom_message" && draft.customType === BOOT_TYPE);
175
+ if (boot?.type === "custom_message" && boot.details && typeof boot.details === "object") {
176
+ const windowId = boot.details.windowId;
177
+ if (typeof windowId === "string")
178
+ pendingResetNotices.add(windowId);
179
+ }
148
180
  },
149
181
  budget,
150
182
  });
@@ -0,0 +1,33 @@
1
+ import { SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type PiContextSettings } from "../settings.js";
3
+ export type ResolvedThresholds = {
4
+ reminder: number;
5
+ reserve: number;
6
+ warning: number;
7
+ };
8
+ /**
9
+ * Pure derivation of the thresholds from Pi's reserve: the reminder fires at reserve
10
+ * plus the pi-context margin, the warning steer at reserve plus WARNING_RUNWAY_TOKENS.
11
+ * An invalid margin degrades to the default and reports one warning. Automatic
12
+ * threshold/overflow handling is represented by reset lifecycle boundary drafts;
13
+ * no compaction summary is generated.
14
+ */
15
+ export declare function deriveThresholds(reserveTokens: number, margins: PiContextSettings): {
16
+ thresholds: ResolvedThresholds;
17
+ warnings: string[];
18
+ };
19
+ /**
20
+ * Read the active compaction reserve, enablement, and pi-context margin settings. This
21
+ * function deliberately has no cache: the budget owner supplies the invocation-scoped
22
+ * cache so two live piContext instances cannot share mutable policy state.
23
+ */
24
+ export type ThresholdSettingsResolution = {
25
+ thresholds: ResolvedThresholds;
26
+ automatic: boolean;
27
+ warnings: string[];
28
+ };
29
+ /**
30
+ * Resolve policy from either the explicitly supplied SDK authority or Pi's default
31
+ * file-backed settings. The caller owns diagnostics and any lifecycle caching.
32
+ */
33
+ export declare function readThresholdSettings(ctx: ExtensionContext, settingsManager?: SettingsManager): ThresholdSettingsResolution;
@@ -23,7 +23,7 @@ export function deriveThresholds(reserveTokens, margins) {
23
23
  else {
24
24
  const parsed = validMargin(margins.reminderMarginTokens);
25
25
  if (parsed === undefined) {
26
- warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
26
+ warnings.push(`pi-context: ${reminderKey} must be a positive integer; using the default reminder margin.`);
27
27
  reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
28
28
  }
29
29
  else
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { runDreamer, type DreamerSessionFactory } from "./runner.js";
3
+ import { type DreamerSetting } from "./settings.js";
4
+ /** Injection seams used by tests; production uses the defaults. */
5
+ export type DreamDependencies = {
6
+ sessionFactory?: DreamerSessionFactory;
7
+ dreamerSettings?: (cwd?: string) => DreamerSetting;
8
+ runDreamer?: typeof runDreamer;
9
+ };
10
+ export declare function main(argv?: string[], deps?: DreamDependencies): Promise<number>;
@@ -8,7 +8,7 @@ import { loadPlaybook, runDreamer } from "./runner.js";
8
8
  import { gitCommit } from "./git.js";
9
9
  import { readDreamerSettings } 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
  function args(argv) { const out = {}; for (let i = 0; i < argv.length; i++) {
13
13
  const a = argv[i];
14
14
  if (a === "--force" || a === "--help")
@@ -0,0 +1,2 @@
1
+ /** Read-only diagnostics. Never follows symlinks or acquires/removes a dream lock. */
2
+ export declare function doctor(home: string): string[];
@@ -37,11 +37,15 @@ export function doctor(home) {
37
37
  report(path, `duplicate metadata key ${field[1]}; keep one value`);
38
38
  fields.set(field[1], field[2].replace(/^(["'])(.*)\1$/, "$2"));
39
39
  }
40
- for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, access_count: /^\d+$/ })) {
40
+ for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, accessCount: /^\d+$/ })) {
41
41
  if (!valid.test(fields.get(key) ?? ""))
42
42
  report(path, `missing/invalid ${key}; repair frontmatter`);
43
43
  }
44
- for (const key of ["created_at", "updated_at", "last_accessed"]) {
44
+ 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"]]) {
45
+ if (fields.has(legacy))
46
+ report(path, `legacy metadata key ${legacy}; manually migrate to ${current}`);
47
+ }
48
+ for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
45
49
  const value = fields.get(key);
46
50
  if (!value || !Number.isFinite(Date.parse(value)))
47
51
  report(path, `missing/invalid ${key}; use an ISO timestamp`);
@@ -0,0 +1,10 @@
1
+ export type GateResult = {
2
+ ok: boolean;
3
+ reason: string;
4
+ };
5
+ /**
6
+ * The scheduler reads the last-run sidecar, not the lock: the lock's lifetime says
7
+ * nothing about when the last dream ran, while the sidecar records exactly that.
8
+ */
9
+ export declare function timeGate(stampPath: string, minHours: number, now?: number): GateResult;
10
+ export declare function materialGate(home: string, sinceMtime: number, minSessions: number): GateResult;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Outcome of one audit commit. `ok: true` always carries a real `commit` snapshot;
3
+ * `empty: true` only means no files changed (an empty baseline commit or no commit was
4
+ * needed). `ok: false` means the audit layer could not guarantee a snapshot.
5
+ */
6
+ export type AuditResult = {
7
+ ok: true;
8
+ commit: string;
9
+ empty: boolean;
10
+ } | {
11
+ ok: false;
12
+ error: string;
13
+ };
14
+ /**
15
+ * Git audit layer for a dream run: one commit before (baseline) and one after (dream),
16
+ * so the human gate reviews `git show` instead of trusting a report, and rollback is
17
+ * `git revert`. The caller decides how loud a failure is; this function only reports it.
18
+ * A clean tree on an established repository commits nothing; a repository with no HEAD
19
+ * gets an empty baseline commit, because an audit run with no snapshot is not a success.
20
+ */
21
+ export declare function gitCommit(home: string, message: string): AuditResult;
@@ -0,0 +1,31 @@
1
+ export type LockState = {
2
+ path: string;
3
+ held: boolean;
4
+ reason?: string;
5
+ startedAt: number;
6
+ /** mtime of the last-run sidecar before this run took the lock; failLock restores it. */
7
+ priorStampMtime?: number;
8
+ /** Random identity written into the lock file; cleanup only removes the lock it wrote. */
9
+ token?: string;
10
+ };
11
+ /**
12
+ * The scheduler's last-run timestamp lives in a sidecar beside the lock, never in the
13
+ * lock file itself: acquiring, releasing or cleaning up the lock touches only the PID
14
+ * marker, so lock lifecycle does not destroy the timestamp the time gate reads.
15
+ */
16
+ export declare function lastRunPath(lockPath: string): string;
17
+ /**
18
+ * Acquire the dream lock with Git-style exclusive existence locking: one O_CREAT|O_EXCL
19
+ * creation. An existing path refuses acquisition regardless of its contents, PID, or age,
20
+ * and is never read for permission, replaced, or removed. There is no automatic stale
21
+ * recovery; a crash-left lock is human cleanup after confirming no dream is running.
22
+ */
23
+ export declare function acquireLock(path: string): LockState;
24
+ /** Release only the lock this run acquired. Idempotent: repeated cleanup does nothing. */
25
+ export declare function releaseLock(lock: LockState): void;
26
+ /**
27
+ * A failed run must not advance the scheduler: restore the previous timestamp, or remove
28
+ * the one this run wrote when there was none. Only this run's own marker is removed, and
29
+ * the state is marked released so a later cleanup attempt is harmless.
30
+ */
31
+ export declare function failLock(lock: LockState): void;
@@ -0,0 +1,30 @@
1
+ import { type AgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ export type DreamWrite = {
3
+ tool: "write" | "edit";
4
+ path: string;
5
+ };
6
+ export type DreamResult = {
7
+ report: string;
8
+ writes: DreamWrite[];
9
+ error?: string;
10
+ };
11
+ export type DreamerSession = Pick<AgentSession, "prompt" | "subscribe" | "dispose">;
12
+ export type DreamerSessionFactory = (options: {
13
+ cwd: string;
14
+ modelPattern?: string;
15
+ tools: string[];
16
+ }) => Promise<DreamerSession>;
17
+ export declare const DREAMER_TOOLS: string[];
18
+ /** The only custom definitions in the dream session replace the two built-ins with jailed versions. */
19
+ export declare function dreamerWriteToolDefinitions(notesHome: string): ToolDefinition<any, any, any>[];
20
+ export declare const defaultDreamerSessionFactory: DreamerSessionFactory;
21
+ /**
22
+ * Run one dream turn. A dreamer failure is returned as `error` together with the partial
23
+ * writes observed so far, so the caller can record partial state instead of losing it;
24
+ * only a failure to even start the session throws.
25
+ */
26
+ export declare function runDreamer(playbook: string, cwd: string, options?: {
27
+ modelPattern?: string;
28
+ sessionFactory?: DreamerSessionFactory;
29
+ }): Promise<DreamResult>;
30
+ export declare function loadPlaybook(path: string): string;