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.
- package/README.md +15 -0
- package/dist/index.js +11660 -0
- package/dist/index.js.map +1 -0
- package/example-config.json +1 -1
- package/index.ts +37 -63
- package/package.json +21 -9
- package/src/commands/cleanup.ts +279 -240
- package/src/commands/memory.ts +236 -184
- package/src/commands/pi-vcc.ts +202 -152
- package/src/commands/vcc-recall.ts +126 -95
- package/src/core/brief.ts +167 -33
- package/src/core/build-sections.ts +8 -2
- package/src/core/config-env.ts +117 -0
- package/src/core/content.ts +31 -7
- package/src/core/drill-down.ts +41 -11
- package/src/core/filter-noise.ts +9 -3
- package/src/core/format-recall.ts +15 -6
- package/src/core/format.ts +14 -4
- package/src/core/lineage.ts +9 -3
- package/src/core/load-messages.ts +24 -5
- package/src/core/normalize.ts +38 -14
- package/src/core/recall-scope.ts +11 -3
- package/src/core/render-entries.ts +22 -6
- package/src/core/sanitize.ts +5 -1
- package/src/core/search-entries.ts +111 -19
- package/src/core/settings.ts +1 -3
- package/src/core/summarize.ts +42 -21
- package/src/core/unified-config.ts +549 -411
- package/src/extract/commits.ts +4 -2
- package/src/extract/files.ts +10 -5
- package/src/extract/goals.ts +7 -2
- package/src/hooks/before-compact.ts +210 -88
- package/src/om/agents/dropper/agent.ts +380 -265
- package/src/om/agents/dropper/coverage.ts +102 -82
- package/src/om/agents/observer/agent.ts +242 -206
- package/src/om/agents/reflector/agent.ts +212 -153
- package/src/om/cleanup.ts +239 -218
- package/src/om/clipboard.ts +59 -51
- package/src/om/compaction-trigger.ts +448 -333
- package/src/om/config.ts +13 -6
- package/src/om/configure-overlay.ts +518 -355
- package/src/om/consolidation.ts +1460 -953
- package/src/om/cooldown.ts +75 -65
- package/src/om/debug-log.ts +86 -68
- package/src/om/ids.ts +1 -1
- package/src/om/ledger/fold.ts +89 -78
- package/src/om/ledger/progress.ts +181 -153
- package/src/om/ledger/projection.ts +248 -185
- package/src/om/ledger/recall.ts +247 -196
- package/src/om/ledger/render-summary.ts +79 -50
- package/src/om/ledger/types.ts +146 -117
- package/src/om/model-budget.ts +23 -13
- package/src/om/pending.ts +243 -179
- package/src/om/provider-stream.ts +52 -7
- package/src/om/retryable-error.ts +12 -16
- package/src/om/reverse-recall.ts +97 -91
- package/src/om/runtime.ts +474 -375
- package/src/om/serialize.ts +190 -166
- package/src/om/status-overlay.ts +246 -195
- package/src/om/tokens.ts +28 -21
- package/src/pi-base/blackhole-settings.ts +437 -0
- package/src/pi-base/config-manager.ts +440 -0
- package/src/pi-base/config.ts +469 -0
- package/src/pi-base/env.ts +43 -0
- package/src/pi-base/paths.ts +47 -0
- package/src/pi-base/settings/body.ts +1648 -0
- package/src/pi-base/settings/fields/action.ts +43 -0
- package/src/pi-base/settings/fields/boolean.ts +47 -0
- package/src/pi-base/settings/fields/custom.ts +72 -0
- package/src/pi-base/settings/fields/enum.ts +310 -0
- package/src/pi-base/settings/fields/index.ts +46 -0
- package/src/pi-base/settings/fields/model.ts +452 -0
- package/src/pi-base/settings/fields/string.ts +527 -0
- package/src/pi-base/settings/fields/text.ts +115 -0
- package/src/pi-base/settings/frame.ts +197 -0
- package/src/pi-base/settings/index.ts +77 -0
- package/src/pi-base/settings/inline-edit.ts +313 -0
- package/src/pi-base/settings/modal.ts +152 -0
- package/src/pi-base/settings/types.ts +500 -0
- package/src/pi-base/settings/validate-field.ts +113 -0
- package/src/pi-base/shell.ts +117 -0
- package/src/pi-base/types.ts +6 -0
- package/src/pi-base/ui.ts +32 -0
- package/src/tools/recall.ts +347 -225
- package/src/types.ts +20 -3
- package/tsup.config.ts +23 -0
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
72
|
+
export function registerCompactionTrigger(
|
|
73
|
+
pi: ExtensionAPI,
|
|
74
|
+
runtime: Runtime,
|
|
75
|
+
): void {
|
|
76
|
+
pi.on("agent_start", () => {
|
|
77
|
+
// Reset the info gate — allow 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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
}
|