pi-blackhole 0.4.2 → 0.4.3

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 (87) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +11660 -0
  3. package/dist/index.js.map +1 -0
  4. package/example-config.json +1 -1
  5. package/index.ts +37 -63
  6. package/package.json +21 -9
  7. package/src/commands/cleanup.ts +279 -240
  8. package/src/commands/memory.ts +236 -184
  9. package/src/commands/pi-vcc.ts +202 -152
  10. package/src/commands/vcc-recall.ts +126 -95
  11. package/src/core/brief.ts +167 -33
  12. package/src/core/build-sections.ts +8 -2
  13. package/src/core/config-env.ts +117 -0
  14. package/src/core/content.ts +31 -7
  15. package/src/core/drill-down.ts +41 -11
  16. package/src/core/filter-noise.ts +9 -3
  17. package/src/core/format-recall.ts +15 -6
  18. package/src/core/format.ts +14 -4
  19. package/src/core/lineage.ts +9 -3
  20. package/src/core/load-messages.ts +24 -5
  21. package/src/core/normalize.ts +38 -14
  22. package/src/core/recall-scope.ts +11 -3
  23. package/src/core/render-entries.ts +22 -6
  24. package/src/core/sanitize.ts +5 -1
  25. package/src/core/search-entries.ts +111 -19
  26. package/src/core/settings.ts +1 -3
  27. package/src/core/summarize.ts +42 -21
  28. package/src/core/unified-config.ts +549 -411
  29. package/src/extract/commits.ts +4 -2
  30. package/src/extract/files.ts +10 -5
  31. package/src/extract/goals.ts +7 -2
  32. package/src/hooks/before-compact.ts +210 -88
  33. package/src/om/agents/dropper/agent.ts +380 -265
  34. package/src/om/agents/dropper/coverage.ts +102 -82
  35. package/src/om/agents/observer/agent.ts +242 -206
  36. package/src/om/agents/reflector/agent.ts +212 -153
  37. package/src/om/cleanup.ts +239 -218
  38. package/src/om/clipboard.ts +59 -51
  39. package/src/om/compaction-trigger.ts +448 -333
  40. package/src/om/config.ts +13 -6
  41. package/src/om/configure-overlay.ts +518 -355
  42. package/src/om/consolidation.ts +1460 -953
  43. package/src/om/cooldown.ts +75 -65
  44. package/src/om/debug-log.ts +86 -68
  45. package/src/om/ids.ts +1 -1
  46. package/src/om/ledger/fold.ts +89 -78
  47. package/src/om/ledger/progress.ts +181 -153
  48. package/src/om/ledger/projection.ts +248 -185
  49. package/src/om/ledger/recall.ts +247 -196
  50. package/src/om/ledger/render-summary.ts +79 -50
  51. package/src/om/ledger/types.ts +146 -117
  52. package/src/om/model-budget.ts +23 -13
  53. package/src/om/pending.ts +243 -179
  54. package/src/om/provider-stream.ts +52 -7
  55. package/src/om/retryable-error.ts +12 -16
  56. package/src/om/reverse-recall.ts +97 -91
  57. package/src/om/runtime.ts +474 -375
  58. package/src/om/serialize.ts +190 -166
  59. package/src/om/status-overlay.ts +246 -195
  60. package/src/om/tokens.ts +28 -21
  61. package/src/pi-base/blackhole-settings.ts +437 -0
  62. package/src/pi-base/config-manager.ts +440 -0
  63. package/src/pi-base/config.ts +469 -0
  64. package/src/pi-base/env.ts +43 -0
  65. package/src/pi-base/paths.ts +47 -0
  66. package/src/pi-base/settings/body.ts +1648 -0
  67. package/src/pi-base/settings/fields/action.ts +43 -0
  68. package/src/pi-base/settings/fields/boolean.ts +47 -0
  69. package/src/pi-base/settings/fields/custom.ts +72 -0
  70. package/src/pi-base/settings/fields/enum.ts +310 -0
  71. package/src/pi-base/settings/fields/index.ts +46 -0
  72. package/src/pi-base/settings/fields/model.ts +452 -0
  73. package/src/pi-base/settings/fields/string.ts +527 -0
  74. package/src/pi-base/settings/fields/text.ts +115 -0
  75. package/src/pi-base/settings/frame.ts +197 -0
  76. package/src/pi-base/settings/index.ts +77 -0
  77. package/src/pi-base/settings/inline-edit.ts +313 -0
  78. package/src/pi-base/settings/modal.ts +152 -0
  79. package/src/pi-base/settings/types.ts +500 -0
  80. package/src/pi-base/settings/validate-field.ts +113 -0
  81. package/src/pi-base/shell.ts +117 -0
  82. package/src/pi-base/types.ts +6 -0
  83. package/src/pi-base/ui.ts +32 -0
  84. package/src/tools/recall.ts +347 -225
  85. package/src/types.ts +20 -3
  86. package/tsup.config.ts +23 -0
  87. package/vitest.config.ts +15 -15
@@ -5,25 +5,33 @@ import { debugLog } from "./debug-log.js";
5
5
  import { RETRYABLE_ERROR_RE } from "./retryable-error.js";
6
6
 
7
7
  function getErrorMessage(error: unknown): string {
8
- if (error instanceof Error) return error.message;
9
- if (error && typeof error === "object" && "message" in error) {
10
- return String((error as { message: unknown }).message);
11
- }
12
- return String(error);
8
+ if (error instanceof Error) return error.message;
9
+ if (error && typeof error === "object" && "message" in error) {
10
+ return String((error as { message: unknown }).message);
11
+ }
12
+ return String(error);
13
13
  }
14
14
 
15
15
  function isStaleExtensionContextError(error: unknown): boolean {
16
- const message = getErrorMessage(error);
17
- return message.includes("extension ctx is stale") || message.includes("ctx is stale");
16
+ const message = getErrorMessage(error);
17
+ return (
18
+ message.includes("extension ctx is stale") ||
19
+ message.includes("ctx is stale")
20
+ );
18
21
  }
19
22
 
20
- function notifySafely(hasUI: boolean, ui: any, message: string, level: "info" | "warning" | "error"): void {
21
- if (!hasUI) return;
22
- try {
23
- ui?.notify(message, level);
24
- } catch (error) {
25
- if (!isStaleExtensionContextError(error)) throw error;
26
- }
23
+ function notifySafely(
24
+ hasUI: boolean,
25
+ ui: any,
26
+ message: string,
27
+ level: "info" | "warning" | "error",
28
+ ): void {
29
+ if (!hasUI) return;
30
+ try {
31
+ ui?.notify(message, level);
32
+ } catch (error) {
33
+ if (!isStaleExtensionContextError(error)) throw error;
34
+ }
27
35
  }
28
36
 
29
37
  /**
@@ -33,337 +41,444 @@ function notifySafely(hasUI: boolean, ui: any, message: string, level: "info" |
33
41
  */
34
42
  export const MID_RUN_RESUME_CUSTOM_TYPE = "blackhole-resume";
35
43
  export const MID_RUN_RESUME_MESSAGE =
36
- "Context was auto-compacted mid-task to stay under the token threshold. "
37
- + "The summary above preserves prior progress. Continue the task from where you left off.";
44
+ "Context was auto-compacted mid-task to stay under the token threshold. " +
45
+ "The summary above preserves prior progress. Continue the task from where you left off.";
38
46
 
39
47
  /**
40
48
  * Shared config gating for auto-compaction (agent_end and turn_end paths).
41
49
  * Returns null when compaction may proceed, or a skip reason string.
42
50
  */
43
51
  function autoCompactionSkipReason(runtime: Runtime): string | null {
44
- if (runtime.config.compaction === "off") return "compaction_off";
45
- if (runtime.config.compaction === "manual") return "compaction_manual";
46
- if (runtime.config.compactionEngine === "pi-default") return "compactionEngine_pi_default";
47
- // NOTE: memory does not gate compaction — memory:false + compaction:auto = compact without OM
48
-
49
- // LEGACY: old config key guards — only apply when new keys are absent (unmigrated config)
50
- if (runtime.config.compaction === undefined && runtime.config.compactionEngine === undefined) {
51
- if (runtime.config.passive === true) return "passive";
52
- if (runtime.config.noAutoCompact === true) return "noAutoCompact";
53
- // Don't force Pi to compact unless the user explicitly opted into blackhole's pipeline.
54
- if (runtime.config.overrideDefaultCompaction === false) return "overrideDefaultCompaction_false";
55
- }
56
- return null;
52
+ if (runtime.config.compaction === "off") return "compaction_off";
53
+ if (runtime.config.compaction === "manual") return "compaction_manual";
54
+ if (runtime.config.compactionEngine === "pi-default")
55
+ return "compactionEngine_pi_default";
56
+ // NOTE: memory does not gate compaction — memory:false + compaction:auto = compact without OM
57
+
58
+ // LEGACY: old config key guards only apply when new keys are absent (unmigrated config)
59
+ if (
60
+ runtime.config.compaction === undefined &&
61
+ runtime.config.compactionEngine === undefined
62
+ ) {
63
+ if (runtime.config.passive === true) return "passive";
64
+ if (runtime.config.noAutoCompact === true) return "manual";
65
+ // Don't force Pi to compact unless the user explicitly opted into blackhole's pipeline.
66
+ if (runtime.config.overrideDefaultCompaction === false)
67
+ return "overrideDefaultCompaction_false";
68
+ }
69
+ return null;
57
70
  }
58
71
 
59
- export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
60
- pi.on("agent_start", () => {
61
- // Reset the info gate — allow one info notification during the new turn.
62
- runtime.resetInfoGate();
63
-
64
- // A new turn is starting abort any pending auto-compaction wait.
65
- // The new turn's own agent_end will re-evaluate the threshold and
66
- // schedule a fresh wait if compaction is still needed.
67
- if (runtime.autoCompactionController) {
68
- runtime.autoCompactionController.abort();
69
- runtime.autoCompactionController = null;
70
- runtime.compactInFlight = false;
71
- }
72
- });
73
-
74
- pi.on("agent_end", (event: any, ctx: any) => {
75
- try {
76
- handleAgentEnd(event, ctx, runtime);
77
- } catch (error) {
78
- if (isStaleExtensionContextError(error)) return;
79
- throw error;
80
- }
81
- });
82
-
83
- // Mid-run trigger: turn_end fires after every assistant-message + tool-execution
84
- // cycle while the agent is still working. agent_end only fires when the run
85
- // exits, so during long tool loops the threshold would otherwise never be
86
- // evaluated (the configured compactAfterTokens could be exceeded many times
87
- // over before compaction had a chance to run).
88
- pi.on("turn_end", (_event: any, ctx: any) => {
89
- try {
90
- handleTurnEnd(ctx, runtime, pi);
91
- } catch (error) {
92
- if (isStaleExtensionContextError(error)) return;
93
- throw error;
94
- }
95
- });
72
+ export function registerCompactionTrigger(
73
+ pi: ExtensionAPI,
74
+ runtime: Runtime,
75
+ ): void {
76
+ pi.on("agent_start", () => {
77
+ // Reset the info gateallow one info notification during the new turn.
78
+ runtime.resetInfoGate();
79
+
80
+ // A new turn is starting — abort any pending auto-compaction wait.
81
+ // The new turn's own agent_end will re-evaluate the threshold and
82
+ // schedule a fresh wait if compaction is still needed.
83
+ if (runtime.autoCompactionController) {
84
+ runtime.autoCompactionController.abort();
85
+ runtime.autoCompactionController = null;
86
+ runtime.compactInFlight = false;
87
+ }
88
+ });
89
+
90
+ pi.on("agent_end", (event: any, ctx: any) => {
91
+ try {
92
+ handleAgentEnd(event, ctx, runtime);
93
+ } catch (error) {
94
+ if (isStaleExtensionContextError(error)) return;
95
+ throw error;
96
+ }
97
+ });
98
+
99
+ // Mid-run trigger: turn_end fires after every assistant-message + tool-execution
100
+ // cycle while the agent is still working. agent_end only fires when the run
101
+ // exits, so during long tool loops the threshold would otherwise never be
102
+ // evaluated (the configured compactAfterTokens could be exceeded many times
103
+ // over before compaction had a chance to run).
104
+ pi.on("turn_end", (_event: any, ctx: any) => {
105
+ try {
106
+ handleTurnEnd(ctx, runtime, pi);
107
+ } catch (error) {
108
+ if (isStaleExtensionContextError(error)) return;
109
+ throw error;
110
+ }
111
+ });
96
112
  }
97
113
 
98
114
  function handleTurnEnd(ctx: any, runtime: Runtime, pi: ExtensionAPI): void {
99
- runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
100
- const dbg = (ev: string, d?: Record<string, unknown>) => debugLog(ev, d, runtime.config.debugLog === true);
101
-
102
- const mode = runtime.config.midRunCompaction ?? "off";
103
- if (mode === "off") {
104
- dbg("compaction_trigger.turn_end.skip", { reason: "midRunCompaction_off" });
105
- return;
106
- }
107
- const skipReason = autoCompactionSkipReason(runtime);
108
- if (skipReason) {
109
- dbg("compaction_trigger.turn_end.skip", { reason: skipReason });
110
- return;
111
- }
112
- if (runtime.compactInFlight) {
113
- dbg("compaction_trigger.turn_end.skip", { reason: "compactInFlight" });
114
- return;
115
- }
116
-
117
- const entries = ctx.sessionManager.getBranch() as Entry[];
118
- const tokens = rawTokensSinceLastCompaction(entries);
119
- if (tokens < runtime.config.compactAfterTokens) {
120
- // Pressure relieved (a compaction ran) — lift any failure suspension.
121
- runtime.midRunCompactionSuspended = false;
122
- return;
123
- }
124
- if (runtime.midRunCompactionSuspended) {
125
- // A previous mid-run attempt failed/cancelled at this pressure level.
126
- // Re-triggering every turn would thrash (each attempt aborts the run).
127
- // Stay suspended until a compaction lowers pressure below the threshold.
128
- dbg("compaction_trigger.turn_end.skip", { reason: "suspended_after_failure", tokens });
129
- return;
130
- }
131
-
132
- const hasUI = ctx.hasUI;
133
- const ui = ctx.ui;
134
- dbg("compaction_trigger.turn_end.threshold_reached", { tokens, threshold: runtime.config.compactAfterTokens, mode });
135
- runtime.tryEmitInfo(hasUI, ui,
136
- `Observational memory: compaction threshold reached mid-run (~${tokens.toLocaleString()} tokens); compacting${mode === "resume" ? " and resuming" : ""}`);
137
-
138
- // ctx.compact() aborts the in-flight agent run before compacting — that is
139
- // the intended trade: turn_end is a clean boundary (tool results are already
140
- // persisted; at most one just-started LLM call is wasted).
141
- runtime.compactInFlight = true;
142
- ctx.compact({
143
- onComplete: (_result: any) => {
144
- runtime.compactInFlight = false;
145
- dbg("compaction_trigger.turn_end.onComplete", { mode });
146
- if (mode === "resume") {
147
- try {
148
- pi.sendMessage(
149
- { customType: MID_RUN_RESUME_CUSTOM_TYPE, content: MID_RUN_RESUME_MESSAGE, display: true },
150
- { triggerTurn: true },
151
- );
152
- } catch (error) {
153
- if (!isStaleExtensionContextError(error)) throw error;
154
- }
155
- }
156
- runtime.tryEmitInfo(hasUI, ui, "Observational memory: mid-run compaction complete");
157
- },
158
- onError: (error: { message: string }) => {
159
- runtime.compactInFlight = false;
160
- // Don't retry at this pressure level — the next attempt would abort the
161
- // run again just to fail the same way. Cleared when pressure drops.
162
- runtime.midRunCompactionSuspended = true;
163
- dbg("compaction_trigger.turn_end.onError", { message: error?.message ?? String(error) });
164
- if (error.message !== "Compaction cancelled") {
165
- notifySafely(hasUI, ui, `Observational memory: mid-run compaction failed: ${error.message}`, "error");
166
- }
167
- // ctx.compact() already aborted the run. In resume mode the agent must
168
- // continue regardless of the failed compaction — otherwise it stalls
169
- // mid-task with no one at the wheel.
170
- if (mode === "resume") {
171
- try {
172
- pi.sendMessage(
173
- { customType: MID_RUN_RESUME_CUSTOM_TYPE, content: MID_RUN_RESUME_MESSAGE, display: true },
174
- { triggerTurn: true },
175
- );
176
- } catch (sendError) {
177
- if (!isStaleExtensionContextError(sendError)) throw sendError;
178
- }
179
- }
180
- },
181
- });
115
+ runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
116
+ const dbg = (ev: string, d?: Record<string, unknown>) =>
117
+ debugLog(ev, d, runtime.config.debugLog === true);
118
+
119
+ const mode = runtime.config.midRunCompaction ?? "off";
120
+ if (mode === "off") {
121
+ dbg("compaction_trigger.turn_end.skip", { reason: "midRunCompaction_off" });
122
+ return;
123
+ }
124
+ const skipReason = autoCompactionSkipReason(runtime);
125
+ if (skipReason) {
126
+ dbg("compaction_trigger.turn_end.skip", { reason: skipReason });
127
+ return;
128
+ }
129
+ if (runtime.compactInFlight) {
130
+ dbg("compaction_trigger.turn_end.skip", { reason: "compactInFlight" });
131
+ return;
132
+ }
133
+
134
+ const entries = ctx.sessionManager.getBranch() as Entry[];
135
+ const tokens = rawTokensSinceLastCompaction(entries);
136
+ if (tokens < runtime.config.compactAfterTokens) {
137
+ // Pressure relieved (a compaction ran) — lift any failure suspension.
138
+ runtime.midRunCompactionSuspended = false;
139
+ return;
140
+ }
141
+ if (runtime.midRunCompactionSuspended) {
142
+ // A previous mid-run attempt failed/cancelled at this pressure level.
143
+ // Re-triggering every turn would thrash (each attempt aborts the run).
144
+ // Stay suspended until a compaction lowers pressure below the threshold.
145
+ dbg("compaction_trigger.turn_end.skip", {
146
+ reason: "suspended_after_failure",
147
+ tokens,
148
+ });
149
+ return;
150
+ }
151
+
152
+ const hasUI = ctx.hasUI;
153
+ const ui = ctx.ui;
154
+ dbg("compaction_trigger.turn_end.threshold_reached", {
155
+ tokens,
156
+ threshold: runtime.config.compactAfterTokens,
157
+ mode,
158
+ });
159
+ runtime.tryEmitInfo(
160
+ hasUI,
161
+ ui,
162
+ `Observational memory: compaction threshold reached mid-run (~${tokens.toLocaleString()} tokens); compacting${mode === "resume" ? " and resuming" : ""}`,
163
+ );
164
+
165
+ // ctx.compact() aborts the in-flight agent run before compacting — that is
166
+ // the intended trade: turn_end is a clean boundary (tool results are already
167
+ // persisted; at most one just-started LLM call is wasted).
168
+ runtime.compactInFlight = true;
169
+ ctx.compact({
170
+ onComplete: (_result: any) => {
171
+ runtime.compactInFlight = false;
172
+ dbg("compaction_trigger.turn_end.onComplete", { mode });
173
+ if (mode === "resume") {
174
+ try {
175
+ pi.sendMessage(
176
+ {
177
+ customType: MID_RUN_RESUME_CUSTOM_TYPE,
178
+ content: MID_RUN_RESUME_MESSAGE,
179
+ display: true,
180
+ },
181
+ { triggerTurn: true },
182
+ );
183
+ } catch (error) {
184
+ if (!isStaleExtensionContextError(error)) throw error;
185
+ }
186
+ }
187
+ runtime.tryEmitInfo(
188
+ hasUI,
189
+ ui,
190
+ "Observational memory: mid-run compaction complete",
191
+ );
192
+ },
193
+ onError: (error: { message: string }) => {
194
+ runtime.compactInFlight = false;
195
+ // Don't retry at this pressure level — the next attempt would abort the
196
+ // run again just to fail the same way. Cleared when pressure drops.
197
+ runtime.midRunCompactionSuspended = true;
198
+ dbg("compaction_trigger.turn_end.onError", {
199
+ message: error?.message ?? String(error),
200
+ });
201
+ if (error.message !== "Compaction cancelled") {
202
+ notifySafely(
203
+ hasUI,
204
+ ui,
205
+ `Observational memory: mid-run compaction failed: ${error.message}`,
206
+ "error",
207
+ );
208
+ }
209
+ // ctx.compact() already aborted the run. In resume mode the agent must
210
+ // continue regardless of the failed compaction — otherwise it stalls
211
+ // mid-task with no one at the wheel.
212
+ if (mode === "resume") {
213
+ try {
214
+ pi.sendMessage(
215
+ {
216
+ customType: MID_RUN_RESUME_CUSTOM_TYPE,
217
+ content: MID_RUN_RESUME_MESSAGE,
218
+ display: true,
219
+ },
220
+ { triggerTurn: true },
221
+ );
222
+ } catch (sendError) {
223
+ if (!isStaleExtensionContextError(sendError)) throw sendError;
224
+ }
225
+ }
226
+ },
227
+ });
182
228
  }
183
229
 
184
230
  function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
185
- runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
186
- // Reset the info gate — allow one notification during agent_end.
187
- runtime.resetInfoGate();
188
-
189
- // Pass the config flag explicitly — this handler runs outside ALS context
190
- // (agent_end events don't flow through consolidation's withDebugLogContext),
191
- // and the setTimeout callback would lose ALS context anyway.
192
- const dbg = (ev: string, d?: Record<string, unknown>) => debugLog(ev, d, runtime.config.debugLog === true);
193
-
194
- dbg("compaction_trigger.agent_end", {
195
- passive: runtime.config.passive,
196
- memory: runtime.config.memory,
197
- noAutoCompact: runtime.config.noAutoCompact,
198
- overrideDefaultCompaction: runtime.config.overrideDefaultCompaction,
199
- compactInFlight: runtime.compactInFlight,
200
- compactAfterTokens: runtime.config.compactAfterTokens,
201
- });
202
-
203
- // Unified + legacy compaction guards (shared with the turn_end path)
204
- const skipReason = autoCompactionSkipReason(runtime);
205
- if (skipReason) {
206
- dbg("compaction_trigger.skip", { reason: skipReason });
207
- return;
208
- }
209
- if (runtime.compactInFlight) {
210
- dbg("compaction_trigger.skip", { reason: "compactInFlight" });
211
- return;
212
- }
213
-
214
- // Don't trigger compaction if Pi will auto-retry — the agent hasn't truly finished.
215
- // Pi emits agent_end before its own retry check, so we must detect this ourselves.
216
- // The next agent_end (after retry succeeds or exhausts attempts) will re-evaluate.
217
- const lastAssistant = [...event.messages].reverse().find(
218
- (m): m is Extract<typeof m, { role: "assistant" }> => m.role === "assistant",
219
- );
220
- if (
221
- lastAssistant
222
- && lastAssistant.stopReason === "error"
223
- && lastAssistant.errorMessage
224
- && RETRYABLE_ERROR_RE.test(lastAssistant.errorMessage)
225
- ) {
226
- return;
227
- }
228
-
229
- const entries = ctx.sessionManager.getBranch() as Entry[];
230
- dbg("compaction_trigger.branch_check", { branchLength: entries.length, hasLastEntry: entries.length > 0, lastEntryType: entries.length > 0 ? entries[entries.length - 1].type : "none" });
231
-
232
- const tokens = rawTokensSinceLastCompaction(entries);
233
- dbg("compaction_trigger.tokens", { tokens, compactAfterTokens: runtime.config.compactAfterTokens, branchLength: entries.length });
234
- if (tokens < runtime.config.compactAfterTokens) {
235
- dbg("compaction_trigger.skip", { reason: "below_threshold", tokens, threshold: runtime.config.compactAfterTokens });
236
- return;
237
- }
238
-
239
- // Capture ctx properties synchronously — the deferred callback below
240
- // may outlive the extension ctx (stale after session replacement/reload).
241
- const hasUI = ctx.hasUI;
242
- const ui = ctx.ui;
243
- const sessionId = ctx.sessionManager.getSessionId();
244
-
245
- dbg("compaction_trigger.threshold_reached", { tokens, sessionId, hasUI });
246
-
247
- runtime.tryEmitInfo(hasUI, ui,
248
- `Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`);
249
-
250
- runtime.compactInFlight = true;
251
- const controller = new AbortController();
252
- runtime.autoCompactionController = controller;
253
- const signal = controller.signal;
254
- dbg("compaction_trigger.scheduled", { compactInFlight: runtime.compactInFlight });
255
-
256
- // Issue #31: keep waiting for the agent to become idle instead of bailing
257
- // after the first non-idle check. The agent may need a few hundred ms to
258
- // finish async work from other extension handlers (e.g. pi-rewind's
259
- // checkpoint I/O) before it is truly idle. The only legitimate cancellation
260
- // is the agent_start handler above aborting the controller.
261
- void (async () => {
262
- try {
263
- // Yield to the event loop first — matches the historical
264
- // setTimeout(0) deferral that lets other agent_end listeners run.
265
- await new Promise((resolve) => setTimeout(resolve, 0));
266
-
267
- // Poll isIdle() every 200ms until it returns true. No max-retries
268
- // cap: the user can be reading the response for arbitrarily long.
269
- // ctx.compact() itself aborts any in-flight agent operation, so we
270
- // must wait until the agent is truly idle.
271
- let isIdle = false;
272
- while (!isIdle) {
273
- if (signal.aborted) {
274
- dbg("compaction_trigger.microtask.bail", { reason: "aborted_agent_start" });
275
- return;
276
- }
277
-
278
- // Validate session identity — bail if the session was replaced/reloaded.
279
- let currentSessionId: string;
280
- try {
281
- currentSessionId = ctx.sessionManager.getSessionId();
282
- } catch (error) {
283
- if (isStaleExtensionContextError(error)) {
284
- runtime.compactInFlight = false;
285
- runtime.autoCompactionController = null;
286
- dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx" });
287
- return;
288
- }
289
- throw error;
290
- }
291
- dbg("compaction_trigger.microtask.session_check", { currentSessionId, expectedSessionId: sessionId, match: currentSessionId === sessionId });
292
- if (currentSessionId !== sessionId) {
293
- runtime.compactInFlight = false;
294
- runtime.autoCompactionController = null;
295
- dbg("compaction_trigger.microtask.bail", { reason: "session_changed" });
296
- runtime.tryEmitInfo(hasUI, ui,
297
- "Observational memory: compaction cancelled — session changed before compaction");
298
- return;
299
- }
300
-
301
- isIdle = ctx.isIdle();
302
- dbg("compaction_trigger.microtask.idle_check", { isIdle });
303
- if (!isIdle) {
304
- // Sleep in 50ms slices so agent_start aborts are noticed quickly.
305
- // A single 200ms await would let the loop run for 200ms after
306
- // the user typed — too long, since we want compaction to wait
307
- // only for the agent to settle, not for a full tick.
308
- const sliceMs = 50;
309
- const end = Date.now() + 200;
310
- while (Date.now() < end) {
311
- if (signal.aborted) {
312
- dbg("compaction_trigger.microtask.bail", { reason: "aborted_agent_start" });
313
- return;
314
- }
315
- await new Promise((resolve) => setTimeout(resolve, sliceMs));
316
- }
317
- }
318
- }
319
-
320
- if (signal.aborted) {
321
- dbg("compaction_trigger.microtask.bail", { reason: "aborted_agent_start" });
322
- return;
323
- }
324
-
325
- const currentEntries = ctx.sessionManager.getBranch() as Entry[];
326
- const currentTokens = rawTokensSinceLastCompaction(currentEntries);
327
- dbg("compaction_trigger.microtask.recheck_tokens", { currentTokens, threshold: runtime.config.compactAfterTokens, ok: currentTokens >= runtime.config.compactAfterTokens });
328
- if (currentTokens < runtime.config.compactAfterTokens) {
329
- runtime.compactInFlight = false;
330
- runtime.autoCompactionController = null;
331
- dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
332
- runtime.tryEmitInfo(hasUI, ui,
333
- "Observational memory: compaction skipped — another compaction already ran before deferred compaction");
334
- return;
335
- }
336
-
337
- dbg("compaction_trigger.microtask.calling_compact", {});
338
- // Compaction is now actually starting — clear the controller so
339
- // agent_start doesn't abort an in-progress compact.
340
- runtime.autoCompactionController = null;
341
- ctx.compact({
342
- onComplete: (result: any) => {
343
- runtime.compactInFlight = false;
344
- dbg("compaction_trigger.onComplete", { result: !!result });
345
- runtime.tryEmitInfo(hasUI, ui, "Observational memory: compaction complete");
346
- },
347
- onError: (error: { message: string }) => {
348
- runtime.compactInFlight = false;
349
- dbg("compaction_trigger.onError", { message: error?.message ?? String(error) });
350
- if (error.message === "Compaction cancelled") {
351
- // We already notified the user with the real reason before returning { cancel: true }.
352
- return;
353
- }
354
- notifySafely(hasUI, ui, `Observational memory: ${error.message}`, "error");
355
- },
356
- });
357
- } catch (error) {
358
- runtime.compactInFlight = false;
359
- runtime.autoCompactionController = null;
360
- const msg = getErrorMessage(error);
361
- if (isStaleExtensionContextError(error)) {
362
- dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx", message: msg });
363
- return;
364
- }
365
- dbg("compaction_trigger.microtask.error", { message: msg });
366
- notifySafely(hasUI, ui, `Observational memory: compact threw: ${msg}`, "error");
367
- }
368
- })();
231
+ runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
232
+ // Reset the info gate — allow one notification during agent_end.
233
+ runtime.resetInfoGate();
234
+
235
+ // Pass the config flag explicitly — this handler runs outside ALS context
236
+ // (agent_end events don't flow through consolidation's withDebugLogContext),
237
+ // and the setTimeout callback would lose ALS context anyway.
238
+ const dbg = (ev: string, d?: Record<string, unknown>) =>
239
+ debugLog(ev, d, runtime.config.debugLog === true);
240
+
241
+ dbg("compaction_trigger.agent_end", {
242
+ passive: runtime.config.passive,
243
+ memory: runtime.config.memory,
244
+ manualMode:
245
+ runtime.config.compaction === "manual" ||
246
+ runtime.config.noAutoCompact === true,
247
+ overrideDefaultCompaction: runtime.config.overrideDefaultCompaction,
248
+ compactInFlight: runtime.compactInFlight,
249
+ compactAfterTokens: runtime.config.compactAfterTokens,
250
+ });
251
+
252
+ // Unified + legacy compaction guards (shared with the turn_end path)
253
+ const skipReason = autoCompactionSkipReason(runtime);
254
+ if (skipReason) {
255
+ dbg("compaction_trigger.skip", { reason: skipReason });
256
+ return;
257
+ }
258
+ if (runtime.compactInFlight) {
259
+ dbg("compaction_trigger.skip", { reason: "compactInFlight" });
260
+ return;
261
+ }
262
+
263
+ // Don't trigger compaction if Pi will auto-retry — the agent hasn't truly finished.
264
+ // Pi emits agent_end before its own retry check, so we must detect this ourselves.
265
+ // The next agent_end (after retry succeeds or exhausts attempts) will re-evaluate.
266
+ const lastAssistant = [...event.messages]
267
+ .reverse()
268
+ .find(
269
+ (m): m is Extract<typeof m, { role: "assistant" }> =>
270
+ m.role === "assistant",
271
+ );
272
+ if (
273
+ lastAssistant &&
274
+ lastAssistant.stopReason === "error" &&
275
+ lastAssistant.errorMessage &&
276
+ RETRYABLE_ERROR_RE.test(lastAssistant.errorMessage)
277
+ ) {
278
+ return;
279
+ }
280
+
281
+ const entries = ctx.sessionManager.getBranch() as Entry[];
282
+ dbg("compaction_trigger.branch_check", {
283
+ branchLength: entries.length,
284
+ hasLastEntry: entries.length > 0,
285
+ lastEntryType:
286
+ entries.length > 0 ? entries[entries.length - 1].type : "none",
287
+ });
288
+
289
+ const tokens = rawTokensSinceLastCompaction(entries);
290
+ dbg("compaction_trigger.tokens", {
291
+ tokens,
292
+ compactAfterTokens: runtime.config.compactAfterTokens,
293
+ branchLength: entries.length,
294
+ });
295
+ if (tokens < runtime.config.compactAfterTokens) {
296
+ dbg("compaction_trigger.skip", {
297
+ reason: "below_threshold",
298
+ tokens,
299
+ threshold: runtime.config.compactAfterTokens,
300
+ });
301
+ return;
302
+ }
303
+
304
+ // Capture ctx properties synchronously the deferred callback below
305
+ // may outlive the extension ctx (stale after session replacement/reload).
306
+ const hasUI = ctx.hasUI;
307
+ const ui = ctx.ui;
308
+ const sessionId = ctx.sessionManager.getSessionId();
309
+
310
+ dbg("compaction_trigger.threshold_reached", { tokens, sessionId, hasUI });
311
+
312
+ runtime.tryEmitInfo(
313
+ hasUI,
314
+ ui,
315
+ `Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
316
+ );
317
+
318
+ runtime.compactInFlight = true;
319
+ const controller = new AbortController();
320
+ runtime.autoCompactionController = controller;
321
+ const signal = controller.signal;
322
+ dbg("compaction_trigger.scheduled", {
323
+ compactInFlight: runtime.compactInFlight,
324
+ });
325
+
326
+ // Issue #31: keep waiting for the agent to become idle instead of bailing
327
+ // after the first non-idle check. The agent may need a few hundred ms to
328
+ // finish async work from other extension handlers (e.g. pi-rewind's
329
+ // checkpoint I/O) before it is truly idle. The only legitimate cancellation
330
+ // is the agent_start handler above aborting the controller.
331
+ void (async () => {
332
+ try {
333
+ // Yield to the event loop first — matches the historical
334
+ // setTimeout(0) deferral that lets other agent_end listeners run.
335
+ await new Promise((resolve) => setTimeout(resolve, 0));
336
+
337
+ // Poll isIdle() every 200ms until it returns true. No max-retries
338
+ // cap: the user can be reading the response for arbitrarily long.
339
+ // ctx.compact() itself aborts any in-flight agent operation, so we
340
+ // must wait until the agent is truly idle.
341
+ let isIdle = false;
342
+ while (!isIdle) {
343
+ if (signal.aborted) {
344
+ dbg("compaction_trigger.microtask.bail", {
345
+ reason: "aborted_agent_start",
346
+ });
347
+ return;
348
+ }
349
+
350
+ // Validate session identity bail if the session was replaced/reloaded.
351
+ let currentSessionId: string;
352
+ try {
353
+ currentSessionId = ctx.sessionManager.getSessionId();
354
+ } catch (error) {
355
+ if (isStaleExtensionContextError(error)) {
356
+ runtime.compactInFlight = false;
357
+ runtime.autoCompactionController = null;
358
+ dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx" });
359
+ return;
360
+ }
361
+ throw error;
362
+ }
363
+ dbg("compaction_trigger.microtask.session_check", {
364
+ currentSessionId,
365
+ expectedSessionId: sessionId,
366
+ match: currentSessionId === sessionId,
367
+ });
368
+ if (currentSessionId !== sessionId) {
369
+ runtime.compactInFlight = false;
370
+ runtime.autoCompactionController = null;
371
+ dbg("compaction_trigger.microtask.bail", {
372
+ reason: "session_changed",
373
+ });
374
+ runtime.tryEmitInfo(
375
+ hasUI,
376
+ ui,
377
+ "Observational memory: compaction cancelled session changed before compaction",
378
+ );
379
+ return;
380
+ }
381
+
382
+ isIdle = ctx.isIdle();
383
+ dbg("compaction_trigger.microtask.idle_check", { isIdle });
384
+ if (!isIdle) {
385
+ // Sleep in 50ms slices so agent_start aborts are noticed quickly.
386
+ // A single 200ms await would let the loop run for 200ms after
387
+ // the user typed — too long, since we want compaction to wait
388
+ // only for the agent to settle, not for a full tick.
389
+ const sliceMs = 50;
390
+ const end = Date.now() + 200;
391
+ while (Date.now() < end) {
392
+ if (signal.aborted) {
393
+ dbg("compaction_trigger.microtask.bail", {
394
+ reason: "aborted_agent_start",
395
+ });
396
+ return;
397
+ }
398
+ await new Promise((resolve) => setTimeout(resolve, sliceMs));
399
+ }
400
+ }
401
+ }
402
+
403
+ if (signal.aborted) {
404
+ dbg("compaction_trigger.microtask.bail", {
405
+ reason: "aborted_agent_start",
406
+ });
407
+ return;
408
+ }
409
+
410
+ const currentEntries = ctx.sessionManager.getBranch() as Entry[];
411
+ const currentTokens = rawTokensSinceLastCompaction(currentEntries);
412
+ dbg("compaction_trigger.microtask.recheck_tokens", {
413
+ currentTokens,
414
+ threshold: runtime.config.compactAfterTokens,
415
+ ok: currentTokens >= runtime.config.compactAfterTokens,
416
+ });
417
+ if (currentTokens < runtime.config.compactAfterTokens) {
418
+ runtime.compactInFlight = false;
419
+ runtime.autoCompactionController = null;
420
+ dbg("compaction_trigger.microtask.bail", {
421
+ reason: "pressure_relieved",
422
+ currentTokens,
423
+ threshold: runtime.config.compactAfterTokens,
424
+ });
425
+ runtime.tryEmitInfo(
426
+ hasUI,
427
+ ui,
428
+ "Observational memory: compaction skipped — another compaction already ran before deferred compaction",
429
+ );
430
+ return;
431
+ }
432
+
433
+ dbg("compaction_trigger.microtask.calling_compact", {});
434
+ // Compaction is now actually starting — clear the controller so
435
+ // agent_start doesn't abort an in-progress compact.
436
+ runtime.autoCompactionController = null;
437
+ ctx.compact({
438
+ onComplete: (result: any) => {
439
+ runtime.compactInFlight = false;
440
+ dbg("compaction_trigger.onComplete", { result: !!result });
441
+ runtime.tryEmitInfo(
442
+ hasUI,
443
+ ui,
444
+ "Observational memory: compaction complete",
445
+ );
446
+ },
447
+ onError: (error: { message: string }) => {
448
+ runtime.compactInFlight = false;
449
+ dbg("compaction_trigger.onError", {
450
+ message: error?.message ?? String(error),
451
+ });
452
+ if (error.message === "Compaction cancelled") {
453
+ // We already notified the user with the real reason before returning { cancel: true }.
454
+ return;
455
+ }
456
+ notifySafely(
457
+ hasUI,
458
+ ui,
459
+ `Observational memory: ${error.message}`,
460
+ "error",
461
+ );
462
+ },
463
+ });
464
+ } catch (error) {
465
+ runtime.compactInFlight = false;
466
+ runtime.autoCompactionController = null;
467
+ const msg = getErrorMessage(error);
468
+ if (isStaleExtensionContextError(error)) {
469
+ dbg("compaction_trigger.microtask.bail", {
470
+ reason: "stale_ctx",
471
+ message: msg,
472
+ });
473
+ return;
474
+ }
475
+ dbg("compaction_trigger.microtask.error", { message: msg });
476
+ notifySafely(
477
+ hasUI,
478
+ ui,
479
+ `Observational memory: compact threw: ${msg}`,
480
+ "error",
481
+ );
482
+ }
483
+ })();
369
484
  }