pi-blackhole 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-blackhole",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "packageManager": "pnpm@11.2.2",
5
5
  "description": "Unified compaction + observational memory extension for Pi — compresses conversation context while preserving durable observations and reflections",
6
6
  "license": "MIT",
@@ -27,6 +27,17 @@ function notifySafely(hasUI: boolean, ui: any, message: string, level: "info" |
27
27
  }
28
28
 
29
29
  export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
30
+ pi.on("agent_start", () => {
31
+ // A new turn is starting — abort any pending auto-compaction wait.
32
+ // The new turn's own agent_end will re-evaluate the threshold and
33
+ // schedule a fresh wait if compaction is still needed.
34
+ if (runtime.autoCompactionController) {
35
+ runtime.autoCompactionController.abort();
36
+ runtime.autoCompactionController = null;
37
+ runtime.compactInFlight = false;
38
+ }
39
+ });
40
+
30
41
  pi.on("agent_end", (event: any, ctx: any) => {
31
42
  try {
32
43
  handleAgentEnd(event, ctx, runtime);
@@ -132,17 +143,51 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
132
143
  "info",
133
144
  );
134
145
 
135
- runtime.compactInFlight = true;
136
- dbg("compaction_trigger.scheduled", { compactInFlight: runtime.compactInFlight });
146
+ runtime.compactInFlight = true;
147
+ const controller = new AbortController();
148
+ runtime.autoCompactionController = controller;
149
+ const signal = controller.signal;
150
+ dbg("compaction_trigger.scheduled", { compactInFlight: runtime.compactInFlight });
151
+
152
+ // Issue #31: keep waiting for the agent to become idle instead of bailing
153
+ // after the first non-idle check. The agent may need a few hundred ms to
154
+ // finish async work from other extension handlers (e.g. pi-rewind's
155
+ // checkpoint I/O) before it is truly idle. The only legitimate cancellation
156
+ // is the agent_start handler above aborting the controller.
157
+ void (async () => {
158
+ try {
159
+ // Yield to the event loop first — matches the historical
160
+ // setTimeout(0) deferral that lets other agent_end listeners run.
161
+ await new Promise((resolve) => setTimeout(resolve, 0));
162
+
163
+ // Poll isIdle() every 200ms until it returns true. No max-retries
164
+ // cap: the user can be reading the response for arbitrarily long.
165
+ // ctx.compact() itself aborts any in-flight agent operation, so we
166
+ // must wait until the agent is truly idle.
167
+ let isIdle = false;
168
+ while (!isIdle) {
169
+ if (signal.aborted) {
170
+ dbg("compaction_trigger.microtask.bail", { reason: "aborted_agent_start" });
171
+ return;
172
+ }
137
173
 
138
- setTimeout(() => {
139
- dbg("compaction_trigger.microtask.enter", {});
140
- try {
141
174
  // Validate session identity — bail if the session was replaced/reloaded.
142
- const currentSessionId = ctx.sessionManager.getSessionId();
175
+ let currentSessionId: string;
176
+ try {
177
+ currentSessionId = ctx.sessionManager.getSessionId();
178
+ } catch (error) {
179
+ if (isStaleExtensionContextError(error)) {
180
+ runtime.compactInFlight = false;
181
+ runtime.autoCompactionController = null;
182
+ dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx" });
183
+ return;
184
+ }
185
+ throw error;
186
+ }
143
187
  dbg("compaction_trigger.microtask.session_check", { currentSessionId, expectedSessionId: sessionId, match: currentSessionId === sessionId });
144
188
  if (currentSessionId !== sessionId) {
145
189
  runtime.compactInFlight = false;
190
+ runtime.autoCompactionController = null;
146
191
  dbg("compaction_trigger.microtask.bail", { reason: "session_changed" });
147
192
  notifySafely(
148
193
  hasUI,
@@ -153,60 +198,76 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
153
198
  return;
154
199
  }
155
200
 
156
- const isIdle = ctx.isIdle();
201
+ isIdle = ctx.isIdle();
157
202
  dbg("compaction_trigger.microtask.idle_check", { isIdle });
158
203
  if (!isIdle) {
159
- runtime.compactInFlight = false;
160
- dbg("compaction_trigger.microtask.bail", { reason: "not_idle" });
161
- notifySafely(
162
- hasUI,
163
- ui,
164
- "Observational memory: compaction deferred agent became busy before compaction",
165
- "info",
166
- );
167
- return;
168
- }
169
- const currentEntries = ctx.sessionManager.getBranch() as Entry[];
170
- const currentTokens = rawTokensSinceLastCompaction(currentEntries);
171
- dbg("compaction_trigger.microtask.recheck_tokens", { currentTokens, threshold: runtime.config.compactAfterTokens, ok: currentTokens >= runtime.config.compactAfterTokens });
172
- if (currentTokens < runtime.config.compactAfterTokens) {
173
- runtime.compactInFlight = false;
174
- dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
175
- notifySafely(
176
- hasUI,
177
- ui,
178
- "Observational memory: compaction skipped — another compaction already ran before deferred compaction",
179
- "info",
180
- );
181
- return;
182
- }
183
-
184
- dbg("compaction_trigger.microtask.calling_compact", {});
185
- ctx.compact({
186
- onComplete: (result: any) => {
187
- runtime.compactInFlight = false;
188
- dbg("compaction_trigger.onComplete", { result: !!result });
189
- notifySafely(hasUI, ui, "Observational memory: compaction complete", "info");
190
- },
191
- onError: (error: { message: string }) => {
192
- runtime.compactInFlight = false;
193
- dbg("compaction_trigger.onError", { message: error?.message ?? String(error) });
194
- if (error.message === "Compaction cancelled") {
195
- // We already notified the user with the real reason before returning { cancel: true }.
204
+ // Sleep in 50ms slices so agent_start aborts are noticed quickly.
205
+ // A single 200ms await would let the loop run for 200ms after
206
+ // the user typed — too long, since we want compaction to wait
207
+ // only for the agent to settle, not for a full tick.
208
+ const sliceMs = 50;
209
+ const end = Date.now() + 200;
210
+ while (Date.now() < end) {
211
+ if (signal.aborted) {
212
+ dbg("compaction_trigger.microtask.bail", { reason: "aborted_agent_start" });
196
213
  return;
197
214
  }
198
- notifySafely(hasUI, ui, `Observational memory: ${error.message}`, "error");
199
- },
200
- });
201
- } catch (error) {
202
- runtime.compactInFlight = false;
203
- const msg = getErrorMessage(error);
204
- if (isStaleExtensionContextError(error)) {
205
- dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx", message: msg });
206
- return;
215
+ await new Promise((resolve) => setTimeout(resolve, sliceMs));
216
+ }
207
217
  }
208
- dbg("compaction_trigger.microtask.error", { message: msg });
209
- notifySafely(hasUI, ui, `Observational memory: compact threw: ${msg}`, "error");
210
218
  }
211
- }, 0);
219
+
220
+ if (signal.aborted) {
221
+ dbg("compaction_trigger.microtask.bail", { reason: "aborted_agent_start" });
222
+ return;
223
+ }
224
+
225
+ const currentEntries = ctx.sessionManager.getBranch() as Entry[];
226
+ const currentTokens = rawTokensSinceLastCompaction(currentEntries);
227
+ dbg("compaction_trigger.microtask.recheck_tokens", { currentTokens, threshold: runtime.config.compactAfterTokens, ok: currentTokens >= runtime.config.compactAfterTokens });
228
+ if (currentTokens < runtime.config.compactAfterTokens) {
229
+ runtime.compactInFlight = false;
230
+ runtime.autoCompactionController = null;
231
+ dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
232
+ notifySafely(
233
+ hasUI,
234
+ ui,
235
+ "Observational memory: compaction skipped — another compaction already ran before deferred compaction",
236
+ "info",
237
+ );
238
+ return;
239
+ }
240
+
241
+ dbg("compaction_trigger.microtask.calling_compact", {});
242
+ // Compaction is now actually starting — clear the controller so
243
+ // agent_start doesn't abort an in-progress compact.
244
+ runtime.autoCompactionController = null;
245
+ ctx.compact({
246
+ onComplete: (result: any) => {
247
+ runtime.compactInFlight = false;
248
+ dbg("compaction_trigger.onComplete", { result: !!result });
249
+ notifySafely(hasUI, ui, "Observational memory: compaction complete", "info");
250
+ },
251
+ onError: (error: { message: string }) => {
252
+ runtime.compactInFlight = false;
253
+ dbg("compaction_trigger.onError", { message: error?.message ?? String(error) });
254
+ if (error.message === "Compaction cancelled") {
255
+ // We already notified the user with the real reason before returning { cancel: true }.
256
+ return;
257
+ }
258
+ notifySafely(hasUI, ui, `Observational memory: ${error.message}`, "error");
259
+ },
260
+ });
261
+ } catch (error) {
262
+ runtime.compactInFlight = false;
263
+ runtime.autoCompactionController = null;
264
+ const msg = getErrorMessage(error);
265
+ if (isStaleExtensionContextError(error)) {
266
+ dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx", message: msg });
267
+ return;
268
+ }
269
+ dbg("compaction_trigger.microtask.error", { message: msg });
270
+ notifySafely(hasUI, ui, `Observational memory: compact threw: ${msg}`, "error");
271
+ }
272
+ })();
212
273
  }
package/src/om/runtime.ts CHANGED
@@ -69,6 +69,10 @@ export class Runtime {
69
69
  failedInCycle: Set<string> = new Set();
70
70
  compactInFlight = false;
71
71
  compactHookInFlight = false;
72
+ /** AbortController for the pending auto-compaction wait loop, or null if none.
73
+ * Set when handleAgentEnd schedules a wait; cleared on abort, success, or terminal bail.
74
+ * agent_start handlers read this to abort the pending wait when a new turn starts. */
75
+ autoCompactionController: AbortController | null = null;
72
76
  resolveFailureNotified = false;
73
77
  lastObserverError: string | undefined;
74
78
  lastReflectorError: string | undefined;