zigmini 0.8.0-beta.5 → 0.8.0-beta.7

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 (57) hide show
  1. package/README.ko.md +78 -19
  2. package/README.md +79 -19
  3. package/assets/agent/gateway-request-rules.md +1 -0
  4. package/assets/agent/gateway-rules.md +6 -0
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/docs/manual.ko.md +25 -2
  9. package/docs/manual.md +25 -2
  10. package/host/omo-bridge/task-control.ts +4 -0
  11. package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
  12. package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
  13. package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
  14. package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
  15. package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
  16. package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +171 -8
  17. package/host/omo-check/patches/omo-5.1.35-phantom-attach.patch +31 -0
  18. package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
  19. package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
  20. package/package.json +4 -1
  21. package/scripts/secret-rules.ts +101 -0
  22. package/scripts/secret-scan.allow.json +155 -0
  23. package/scripts/secret-scan.rules.json +30 -0
  24. package/src/agent-request-rules.ts +82 -0
  25. package/src/cli/access.ts +31 -34
  26. package/src/cli/launchd.ts +30 -4
  27. package/src/cli/main.ts +13 -39
  28. package/src/cli/messages.ts +160 -0
  29. package/src/cli/pair.ts +32 -25
  30. package/src/cli/rotate-main.ts +411 -0
  31. package/src/cli/setup.ts +164 -75
  32. package/src/decisions.ts +20 -1
  33. package/src/hub.ts +140 -30
  34. package/src/main.ts +45 -4
  35. package/src/omo/client.ts +28 -0
  36. package/src/protocol.ts +33 -1
  37. package/src/reply-notifications.ts +144 -0
  38. package/src/runtime/paths.ts +2 -0
  39. package/src/security-check.ts +241 -0
  40. package/src/store.ts +71 -1
  41. package/src/turn-stats.ts +49 -0
  42. package/src/work-watch.ts +36 -10
  43. package/src/work.ts +29 -4
  44. package/web/app.js +197 -10
  45. package/web/decisions.js +3 -0
  46. package/web/gc.d.ts +2 -0
  47. package/web/gc.js +11 -0
  48. package/web/index.html +26 -10
  49. package/web/notifications.d.ts +25 -0
  50. package/web/notifications.js +118 -0
  51. package/web/style.css +16 -0
  52. package/web/tooltip.d.ts +0 -6
  53. package/web/tooltip.js +8 -11
  54. package/web/turn-stats.d.ts +26 -0
  55. package/web/turn-stats.js +58 -0
  56. package/web/update.d.ts +1 -1
  57. package/web/update.js +10 -3
@@ -0,0 +1,1063 @@
1
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js
2
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js
3
+ @@ -33,0 +33,1 @@
4
+ + this.failure = { kind: refused ? "refused" : failureKind === "upstream-stream-truncated" ? "incomplete" : failureKind ?? "provider" };
5
+ @@ -35,15 +36,26 @@
6
+ -const UPSTREAM_STREAM_TRUNCATED_PATTERN = /(?:^|[^A-Za-z0-9_])upstream_stream_truncated(?:[^A-Za-z0-9_]|$)/;
7
+ -/**
8
+ - * Only failures with no cheaper recovery earn another billed request.
9
+ - *
10
+ - * Every class that `classifyRequiredCompactionFallbackFailure` recognizes
11
+ - * (watchdog timeouts, the compaction-wide total budget, terminal provider and
12
+ - * credential failures, `upstream-stream-truncated`, overflow exhaustion,
13
+ - * empty-summary generation failures) already
14
+ - * has a deterministic zero-LLM recovery, and context overflow is answered by
15
+ - * shrinking the input in the surrounding loop - replaying those would pay for a
16
+ - * summarization the fallback can rebuild for free. The classes checked here are
17
+ - * mirrored from that classifier rather than imported, because
18
+ - * `deterministic-fallback.ts` imports this module.
19
+ - */
20
+ -function isRetryableSummaryAttempt(error) {
21
+ +export function compactionFailure(error, model) {
22
+ + const kind = error?.refused ? "refused" : error?.failure?.kind ?? error?.kind;
23
+ + const known = ["invalid_model", "auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable", "refused", "timeout", "incomplete", "empty", "context_budget", "stale", "provider"];
24
+ + return { kind: known.includes(kind) ? kind
25
+ + : error instanceof StreamDurationBudgetError || error instanceof StreamIdleTimeoutError || error instanceof SummarizationTotalBudgetError || error?.name === "TimeoutError" ? "timeout"
26
+ + : error instanceof SummarizationOverflowExhaustedError ? "context_budget" : "provider",
27
+ + ...(model ? { provider: model.provider, model: model.id } : {}),
28
+ + ...(error?.failure?.provider ? { provider: error.failure.provider } : {}),
29
+ + ...(error?.failure?.model ? { model: error.failure.model } : {}) };
30
+ +}
31
+ +export function validateSummaryResponse(response) {
32
+ + if (response && isRefusalStop(response))
33
+ + throw new SummaryRequestError("Compaction summary refused", false, undefined, true);
34
+ + if (!response || !isAssistantMessage(response) || response.stopReason !== "stop" ||
35
+ + response.content.some((block) => block.type === "toolCall" || block.type === "toolUse"))
36
+ + throw new SummaryGenerationError("incomplete", "Compaction summary did not finish normally");
37
+ + const parsed = extractTaskIntent(getSummaryText(response));
38
+ + if (!parsed.summaryText.trim())
39
+ + throw new SummaryGenerationError("empty", "Compaction summary body is empty");
40
+ + return parsed;
41
+ +}
42
+ +const UPSTREAM_STREAM_TRUNCATED_PATTERN = /(?:^|[^A-Za-z0-9_])upstream_stream_truncated(?:[^A-Za-z0-9_]|$)/;
43
+ +/** Only pre-output transient failures may retry the same selected model. */
44
+ +function isRetryableSummaryAttempt(error) {
45
+ + if (error?.failure?.kind && error.failure.kind !== "provider")
46
+ + return false;
47
+ @@ -58,5 +70,0 @@
48
+ - // Mirrors the `summarization-provider-failure` class: credential rotation has
49
+ - // already spent every slot it may spend, and the marker means output was
50
+ - // committed, so another billed attempt buys nothing the fallback cannot
51
+ - // rebuild for free. Message text alone must not re-authorize it - the wrapped
52
+ - // provider detail can read as transient (#1741).
53
+ @@ -68,1 +75,1 @@
54
+ - return error.failureKind === undefined && error.transient;
55
+ + return !error.refused && error.failureKind === undefined && error.transient;
56
+ @@ -79,0 +86,3 @@
57
+ + const kind = response.failure?.kind;
58
+ + if (["auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable", "timeout", "incomplete", "context_budget"].includes(kind))
59
+ + return kind;
60
+ @@ -86,0 +96,1 @@
61
+ + this.failure = { kind };
62
+ @@ -134,1 +145,1 @@
63
+ - * when the request was aborted (caller signal or an aborted stream); every
64
+ + * when the caller signal was aborted; every
65
+ @@ -191,4 +202,0 @@
66
+ - const summarizationToolsOffered = (requestSnapshot.tools?.length ?? 0) > 0;
67
+ - const reasoningOverrideOffered = hasSummarizationReasoningOverride(requestSnapshot.model);
68
+ - let toolUseRetrySpent = false;
69
+ - let reasoningOverrideRetrySpent = false;
70
+ @@ -220,1 +227,1 @@
71
+ - forbidToolCalls: toolUseRetrySpent,
72
+ + forbidToolCalls: true,
73
+ @@ -235,1 +242,0 @@
74
+ - ...(reasoningOverrideRetrySpent ? { omitReasoningOptions: true } : {}),
75
+ @@ -239,1 +245,1 @@
76
+ - attempt.stopReason === "error" &&
77
+ + (attempt.stopReason === "error" || isRefusalStop(attempt)) &&
78
+ @@ -242,1 +248,1 @@
79
+ - throw new SummaryRequestError(attempt.errorMessage || "Compaction summary request failed", failureKind !== undefined || isRetryableAssistantError(attempt), failureKind, isRefusalStop(attempt));
80
+ + throw new SummaryRequestError(attempt.errorMessage || "Compaction summary request failed", attempt.content.length === 0 && failureKind === undefined && isRetryableAssistantError(attempt), failureKind, isRefusalStop(attempt));
81
+ @@ -255,3 +261,3 @@
82
+ - if (!response)
83
+ - return undefined;
84
+ - if (isAssistantMessage(response) && isContextOverflow(response, requestSnapshot.contextWindow)) {
85
+ + if (!response && signal?.aborted)
86
+ + return undefined;
87
+ + if (response && isAssistantMessage(response) && isContextOverflow(response, requestSnapshot.contextWindow)) {
88
+ @@ -261,40 +267,2 @@
89
+ - if (isAssistantMessage(response) && response.stopReason === "aborted") {
90
+ - // A partial summary from an aborted stream must never be applied.
91
+ - return undefined;
92
+ - }
93
+ - if (isAssistantMessage(response) && response.stopReason === "error") {
94
+ - // Surface the real provider failure instead of silently degrading
95
+ - // into a generic "Compaction cancelled". Preserve the structured
96
+ - // truncation class here so downstream recovery never trusts arbitrary
97
+ - // thrown error text as authorization for destructive context reduction.
98
+ - const failureKind = summaryRequestFailureKind(response);
99
+ - throw new SummaryRequestError(response.errorMessage || "Compaction summary request failed", failureKind !== undefined || isRetryableAssistantError(response), failureKind, isRefusalStop(response));
100
+ - }
101
+ - const summary = getSummaryText(response);
102
+ - if (!summary) {
103
+ - const stopReason = isAssistantMessage(response) ? response.stopReason : "unknown";
104
+ - // A summarizer can hijack the forwarded agent tools and answer with a
105
+ - // bare tool call (observed on chatgpt-subscription gpt-5.6-sol, 2026-08-31),
106
+ - // which used to surface as a terminal empty-summary failure. Spend one
107
+ - // retry with tool calling forbidden; the tools stay in the request
108
+ - // because Anthropic rejects tool_use history without the tools param.
109
+ - if (stopReason === "toolUse" && summarizationToolsOffered && !toolUseRetrySpent) {
110
+ - toolUseRetrySpent = true;
111
+ - continue;
112
+ - }
113
+ - // Some OpenAI-completions relays complete with a normal stop but zero
114
+ - // text when the summarization prompt pins an explicit reasoning effort
115
+ - // (GLM-5.3-flash behind a custom relay, 2026-09-17: HTTP 200 carrying
116
+ - // only the role prelude). Spend one retry without the reasoning
117
+ - // override, but only when the first attempt carried one: a model with
118
+ - // no override would just replay the identical prompt. On Anthropic the
119
+ - // override is `thinkingEnabled: false`, so the retry runs with the
120
+ - // provider's default thinking, clamped by the compaction deadline.
121
+ - // Persistent emptiness still throws and the deterministic fallback
122
+ - // owns recovery.
123
+ - if (stopReason === "stop" && reasoningOverrideOffered && !reasoningOverrideRetrySpent) {
124
+ - reasoningOverrideRetrySpent = true;
125
+ - continue;
126
+ - }
127
+ - throw new SummaryGenerationError("empty-summary", `summarization response contained no text (stopReason: ${stopReason})`);
128
+ - }
129
+ + const parsedSummary = validateSummaryResponse(response);
130
+ + const summary = parsedSummary.summaryText;
131
+ @@ -305,1 +273,0 @@
132
+ - const parsedSummary = extractTaskIntent(summary);
133
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/index.js
134
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/index.js
135
+ @@ -2,1 +2,0 @@
136
+ -import { AccountUsageLimitError } from "../../../retry-fallback/usage-limit.js";
137
+ @@ -7,1 +6,0 @@
138
+ -import { classifyRequiredCompactionFallbackFailure, createRequiredCompactionFallback, formatRequiredCompactionFallbackNotice, formatRequiredCompactionFallbackRejection, stripTurnRetrySuppressionPrefix, UnsafeRetainedSuffixError, } from "./deterministic-fallback.js";
139
+ @@ -9,1 +7,0 @@
140
+ -import * as idleRetry from "./idle-retry.js";
141
+ @@ -20,1 +17,1 @@
142
+ -import { applyGeneratedCompaction, createEmergencyPruneLatch, createSpeculativeCompactionSnapshot, getPromptVariant, runExtensionCompaction, SummaryGenerationError, } from "./speculative.js";
143
+ +import { applyGeneratedCompaction, createEmergencyPruneLatch, createSpeculativeCompactionSnapshot, getPromptVariant, runExtensionCompaction, SummaryGenerationError, compactionFailure, } from "./speculative.js";
144
+ @@ -63,1 +60,0 @@
145
+ - let idleWarmupAttempt = 0;
146
+ @@ -98,62 +94,0 @@
147
+ - function armIdleWarmupRetry(ctx) {
148
+ - const job = speculativeJob;
149
+ - if (!job)
150
+ - return;
151
+ - void job.failure.then((failure) => {
152
+ - if (failure === undefined) {
153
+ - idleWarmupAttempt = 0;
154
+ - return;
155
+ - }
156
+ - if (speculativeJob !== job)
157
+ - return;
158
+ - // A reload between arming and this continuation retires the context.
159
+ - if (isContextRetired(ctx)) {
160
+ - cancelIdleWarmupRetry();
161
+ - return;
162
+ - }
163
+ - const usage = ctx.getContextUsage();
164
+ - const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
165
+ - const settings = ctx.getCompactionSettings();
166
+ - const breakerTripped = breaker.isTripped(state, Date.now());
167
+ - const retryDecision = {
168
+ - attempt: idleWarmupAttempt,
169
+ - transient: isTransientSummarizationFailure(failure, failure.message),
170
+ - isIdle: ctx.isIdle(),
171
+ - breakerTripped,
172
+ - stillWarmEligible: idle.shouldWarmAtIdle({
173
+ - willRetry: false,
174
+ - aborted: false,
175
+ - settings,
176
+ - usage,
177
+ - contextWindow,
178
+ - breakerTripped,
179
+ - lastYield: state.lastYield ?? undefined,
180
+ - mode: ctx.mode,
181
+ - }),
182
+ - };
183
+ - if (!idleRetry.shouldRetryIdleWarmup(retryDecision))
184
+ - return;
185
+ - cancelIdleWarmupRetry();
186
+ - idleWarmupTimer = setTimeout(() => {
187
+ - idleWarmupTimer = undefined;
188
+ - if (speculativeJob !== job)
189
+ - return;
190
+ - // The reload may land after the timer was armed; a throw here would
191
+ - // escape into the timer queue as an uncaughtException.
192
+ - if (isContextRetired(ctx))
193
+ - return;
194
+ - if (!ctx.isIdle())
195
+ - return;
196
+ - idleWarmupAttempt += 1;
197
+ - getLogger(ctx).debug("idle_trigger", {
198
+ - contextWindow,
199
+ - tokens: usage?.tokens ?? 0,
200
+ - count: idleWarmupAttempt,
201
+ - });
202
+ - invalidateSpeculativeCompaction(ctx);
203
+ - startSpeculativeCompaction(ctx, idle.IDLE_COMPACTION_INSTRUCTIONS);
204
+ - armIdleWarmupRetry(ctx);
205
+ - armIdleApply(ctx);
206
+ - }, idleRetry.IDLE_WARMUP_RETRY_DELAY_MS);
207
+ - });
208
+ - }
209
+ @@ -303,21 +237,1 @@
210
+ - const compaction = await runExtensionCompaction(ctx, snapshot, signal, onProgress);
211
+ - if (!compaction || signal?.aborted)
212
+ - return compaction;
213
+ - if (!wouldCompactionOverflow(snapshot.branchEntries ?? [], compaction, true, snapshot.model, snapshot.preparation.settings))
214
+ - return compaction;
215
+ - if (!wouldCompactionOverflow(snapshot.branchEntries ?? [], { ...compaction, summary: "" }, true, snapshot.model, snapshot.preparation.settings))
216
+ - return compaction;
217
+ - // #3061: only a SUCCESSFUL summary can establish a terminal context fault.
218
+ - // A failed summary plus a rejected emergency checkpoint says nothing about
219
+ - // whether a recovered provider could produce a smaller, usable summary.
220
+ - // Probe with zero summary text so marker/previous-summary overhead alone
221
+ - // cannot turn a provider failure or an oversized summary into a durable latch.
222
+ - const diagnostics = {};
223
+ - const viable = createRequiredCompactionFallback(snapshot.preparation, snapshot.contextWindow, "unsafe-retained-content", { summary: "" }, snapshot.branchEntries, diagnostics);
224
+ - if (!viable &&
225
+ - (diagnostics.rejectionReason === "unsafe-retained-content" ||
226
+ - diagnostics.rejectionReason === "atomic-tool-chain-cut" ||
227
+ - diagnostics.rejectionReason === "retained-token-budget-exceeded")) {
228
+ - throw new UnsafeRetainedSuffixError(diagnostics);
229
+ - }
230
+ - return compaction;
231
+ + return await runExtensionCompaction(ctx, snapshot, signal, onProgress);
232
+ @@ -325,27 +239,0 @@
233
+ - function recoverRequiredCompaction(ctx, snapshot, failureKind, cause) {
234
+ - const terminal = cause instanceof UnsafeRetainedSuffixError;
235
+ - const diagnostics = terminal ? cause.diagnostics : {};
236
+ - const compaction = terminal
237
+ - ? undefined
238
+ - : createRequiredCompactionFallback(snapshot.preparation, snapshot.contextWindow, failureKind, { taskIntent: resolveInheritedTaskIntent(snapshot.branchEntries ?? []) }, snapshot.branchEntries, diagnostics);
239
+ - if (!compaction) {
240
+ - const branch = snapshot.branchEntries ?? [];
241
+ - // Provider/timeout/abort/empty-summary failures never create or replace
242
+ - // this record, even when their opportunistic deterministic fallback fails.
243
+ - if (terminal && !isAutomaticCompactionBlocked(branch, snapshot.model, ctx.getCompactionSettings())) {
244
+ - pi.appendEntry(REJECTED_RECOVERY_ENTRY, {
245
+ - failureKind: "unsafe-retained-content",
246
+ - stateKey: compactionRecoveryStateKey(branch, snapshot.model, ctx.getCompactionSettings()),
247
+ - reason: diagnostics.rejectionReason,
248
+ - entryId: diagnostics.candidateRejections?.at(-1)?.unsafeEntryId ??
249
+ - diagnostics.candidateRejections?.at(-1)?.firstKeptEntryId,
250
+ - });
251
+ - }
252
+ - return { rejectionReason: formatRequiredCompactionFallbackRejection(diagnostics, terminal) };
253
+ - }
254
+ - // The compaction itself succeeds, so nothing else tells the user their
255
+ - // transcript was reduced without a provider summary. Say it plainly here,
256
+ - // and never with the internal retry-suppression marker (#1741).
257
+ - ctx.ui.notify(formatRequiredCompactionFallbackNotice(failureKind, cause), "warning");
258
+ - return { compaction };
259
+ - }
260
+ @@ -353,17 +240,1 @@
261
+ - if (isAutomaticCompactionBlocked(ctx.sessionManager.getBranch(), ctx.model, ctx.getCompactionSettings())) {
262
+ - return { applied: false, reason: "rejected" };
263
+ - }
264
+ - const provider = ctx.model?.provider;
265
+ - if ((provider === "cursor" || provider === "cursor-cli-oauth") && !ctx.isIdle()) {
266
+ - getLogger(ctx).debug("skip_cursor_mid_turn", { route: "blocking" });
267
+ - return { applied: false, reason: "rejected" };
268
+ - }
269
+ - if (breaker.isTripped(state, Date.now())) {
270
+ - getLogger(ctx).debug("skip_breaker", { route: "blocking" });
271
+ - return { applied: false, reason: "rejected" };
272
+ - }
273
+ - if (cap.shouldRejectByCap(state).cancel) {
274
+ - getLogger(ctx).debug("skip_cap", { route: "blocking" });
275
+ - return { applied: false, reason: "rejected" };
276
+ - }
277
+ - let feedbackSignal = ctx.beginCompaction?.({ reason: "extension" });
278
+ + const feedbackSignal = ctx.beginCompaction?.({ reason: "extension" });
279
+ @@ -371,35 +242,6 @@
280
+ - let remoteFallbackReason;
281
+ - if (isOpenAiRemoteCompactionModel(ctx.model) && (ctx.getCompactionModel?.() ?? ctx.model) === ctx.model) {
282
+ - const remoteGeneration = speculativeGeneration + 1;
283
+ - const remoteSnapshot = createSpeculativeCompactionSnapshot(ctx, {
284
+ - generation: remoteGeneration,
285
+ - customInstructions,
286
+ - origin: "blocking",
287
+ - });
288
+ - getLogger(ctx).debug("blocking_started", { generation: remoteGeneration, origin: "blocking" });
289
+ - if (remoteSnapshot) {
290
+ - const remoteSignal = feedbackSignal ?? new AbortController().signal;
291
+ - const remoteCompaction = await runOpenAiRemoteCompaction(ctx, createBlockingRemoteCompactionEvent(ctx, remoteSnapshot, customInstructions, remoteSignal), (data) => {
292
+ - if (data?.action === "remote_fallback" && typeof data.reason === "string") {
293
+ - remoteFallbackReason = data.reason;
294
+ - }
295
+ - reportRemoteCompactionTimeout(ctx, "extension", feedbackSignal, data);
296
+ - pi.events.emit(SENPI_COMPACTION_EVENT, data);
297
+ - }, remoteCompactionDependencies);
298
+ - if (remoteCompaction) {
299
+ - if (speculativeGeneration !== remoteGeneration - 1) {
300
+ - const result = { applied: false, reason: "stale" };
301
+ - getLogger(ctx).debug("speculative_stale", { generation: remoteGeneration });
302
+ - endCompactionFeedback(ctx, feedbackSignal, result);
303
+ - return result;
304
+ - }
305
+ - speculativeGeneration = remoteGeneration;
306
+ - speculativeJob?.controller.abort();
307
+ - speculativeJob = undefined;
308
+ - const result = await applyGeneratedCompaction(ctx, remoteSnapshot, () => speculativeGeneration, remoteCompaction, feedbackSignal);
309
+ - getLogger(ctx).debug("speculative_applied", { generation: remoteGeneration, origin: "blocking" });
310
+ - endCompactionFeedback(ctx, feedbackSignal, result);
311
+ - return result;
312
+ - }
313
+ - }
314
+ - }
315
+ + const provider = ctx.model?.provider;
316
+ + if ((provider === "cursor" || provider === "cursor-cli-oauth") && !ctx.isIdle())
317
+ + throw new SummaryGenerationError("provider", "Compaction unavailable while provider is busy");
318
+ + if (isAutomaticCompactionBlocked(ctx.sessionManager.getBranch(), ctx.model, ctx.getCompactionSettings()) ||
319
+ + breaker.isTripped(state, Date.now()) || cap.shouldRejectByCap(state).cancel)
320
+ + throw new SummaryGenerationError("provider", "Compaction is blocked after an earlier failure");
321
+ @@ -407,0 +249,3 @@
322
+ + speculativeJob = undefined;
323
+ + let snapshot;
324
+ + let compaction;
325
+ @@ -408,3 +253,2 @@
326
+ - const unlinkAbort = linkAbortSignal(feedbackSignal, pendingJob.controller);
327
+ - let compaction;
328
+ - let inheritedFailure;
329
+ + snapshot = pendingJob.snapshot;
330
+ + const unlink = linkAbortSignal(feedbackSignal, pendingJob.controller);
331
+ @@ -413,3 +257,3 @@
332
+ - inheritedFailure = await pendingJob.failure;
333
+ - if (compaction)
334
+ - getLogger(ctx).debug("warm_consumed", { generation: pendingJob.generation, route: "speculative" });
335
+ + const failure = await pendingJob.failure;
336
+ + if (failure !== undefined)
337
+ + throw failure;
338
+ @@ -417,79 +261,16 @@
339
+ - finally {
340
+ - unlinkAbort();
341
+ - }
342
+ - if (inheritedFailure !== undefined) {
343
+ - speculativeJob = undefined;
344
+ - const failureKind = classifyRequiredCompactionFallbackFailure(inheritedFailure);
345
+ - if (failureKind !== undefined &&
346
+ - !feedbackSignal?.aborted &&
347
+ - pendingJob.snapshot.generation === speculativeGeneration &&
348
+ - pendingJob.snapshot.expectedRevision === ctx.getMessageRevision()) {
349
+ - const recovery = recoverRequiredCompaction(ctx, pendingJob.snapshot, failureKind, inheritedFailure);
350
+ - compaction = recovery.compaction;
351
+ - if (!compaction) {
352
+ - const result = { applied: false, reason: "failed" };
353
+ - endCompactionFeedback(ctx, feedbackSignal, result, recovery.rejectionReason);
354
+ - return result;
355
+ - }
356
+ - }
357
+ - else if (failureKind === undefined &&
358
+ - isTransientSummarizationFailure(inheritedFailure, inheritedFailure.message)) {
359
+ - ctx.endCompaction?.({
360
+ - reason: "extension",
361
+ - signal: feedbackSignal,
362
+ - aborted: feedbackSignal?.aborted,
363
+ - errorMessage: `Compaction failed: ${stripTurnRetrySuppressionPrefix(inheritedFailure.message)}`,
364
+ - });
365
+ - state = breaker.recordFailure(state, Date.now(), { route: "extension" });
366
+ - return { applied: false, reason: "failed" };
367
+ - }
368
+ - }
369
+ - const result = await applyGeneratedCompaction(ctx, pendingJob.snapshot, () => speculativeGeneration, compaction, feedbackSignal);
370
+ - if (result.applied) {
371
+ - speculativeJob = undefined;
372
+ - getLogger(ctx).debug("speculative_applied", {
373
+ - generation: pendingJob.generation,
374
+ - origin: "speculative",
375
+ - });
376
+ - endCompactionFeedback(ctx, feedbackSignal, result);
377
+ - return result;
378
+ - }
379
+ - if (result.reason === "stale") {
380
+ - getLogger(ctx).debug("speculative_stale", { generation: pendingJob.generation });
381
+ - }
382
+ - else if (result.reason === "rejected") {
383
+ - feedbackSignal = ctx.beginCompaction?.({ reason: "extension" });
384
+ - }
385
+ - speculativeJob = undefined;
386
+ - }
387
+ - const generation = ++speculativeGeneration;
388
+ - const snapshot = createSpeculativeCompactionSnapshot(ctx, {
389
+ - generation,
390
+ - customInstructions,
391
+ - origin: "core-route",
392
+ - tools: getSummarizationTools(),
393
+ - });
394
+ - if (!snapshot) {
395
+ - const result = { applied: false, reason: "unavailable" };
396
+ - getLogger(ctx).debug("summary_failed", { reason: "unavailable" });
397
+ - endCompactionFeedback(ctx, feedbackSignal, result, remoteFallbackReason);
398
+ - return result;
399
+ - }
400
+ - let compaction;
401
+ - try {
402
+ - compaction = await generateCompaction(ctx, snapshot, feedbackSignal, (delta) => ctx.updateCompaction?.({
403
+ - reason: "extension",
404
+ - signal: feedbackSignal,
405
+ - delta,
406
+ - }));
407
+ - }
408
+ - catch (error) {
409
+ - const failureKind = classifyRequiredCompactionFallbackFailure(error);
410
+ - if (failureKind !== undefined && !feedbackSignal?.aborted) {
411
+ - const recovery = recoverRequiredCompaction(ctx, snapshot, failureKind, error);
412
+ - compaction = recovery.compaction;
413
+ - if (!compaction) {
414
+ - const result = { applied: false, reason: "failed" };
415
+ - endCompactionFeedback(ctx, feedbackSignal, result, recovery.rejectionReason);
416
+ - return result;
417
+ - }
418
+ + finally { unlink(); }
419
+ + }
420
+ + else {
421
+ + snapshot = createSpeculativeCompactionSnapshot(ctx, {
422
+ + generation: ++speculativeGeneration, customInstructions, origin: "blocking", tools: getSummarizationTools(),
423
+ + });
424
+ + if (!snapshot)
425
+ + throw new SummaryGenerationError("empty", "No compaction source available");
426
+ + if (isOpenAiRemoteCompactionModel(ctx.model) && (ctx.getCompactionModel?.() ?? ctx.model) === ctx.model) {
427
+ + const remote = await runOpenAiRemoteCompaction(ctx,
428
+ + createBlockingRemoteCompactionEvent(ctx, snapshot, customInstructions, feedbackSignal ?? new AbortController().signal),
429
+ + (data) => pi.events.emit(SENPI_COMPACTION_EVENT, data), remoteCompactionDependencies);
430
+ + if (remote.status === "failed")
431
+ + throw Object.assign(new Error("Remote compaction failed"), { failure: remote.failure });
432
+ + if (remote.status === "accepted")
433
+ + compaction = remote.compaction;
434
+ @@ -497,5 +278,3 @@
435
+ - else {
436
+ - if (!(error instanceof SummaryGenerationError))
437
+ - throw error;
438
+ - getLogger(ctx).debug("summary_failed", { reason: error.kind });
439
+ - }
440
+ + if (!compaction)
441
+ + compaction = await generateCompaction(ctx, snapshot, feedbackSignal,
442
+ + (delta) => ctx.updateCompaction?.({ reason: "extension", signal: feedbackSignal, delta }));
443
+ @@ -504,1 +283,1 @@
444
+ - endCompactionFeedback(ctx, feedbackSignal, result, remoteFallbackReason);
445
+ + endCompactionFeedback(ctx, feedbackSignal, result);
446
+ @@ -508,18 +287,5 @@
447
+ - if (feedbackSignal?.aborted) {
448
+ - // An aborted blocking compaction is a cancellation, not a failure: no
449
+ - // red "Compaction failed" line, no breaker debit, no rethrow through
450
+ - // the before_agent_start handler (issue #886). The faux-route flavor
451
+ - // of this contract is pinned by blocking-compaction-review-hardening.
452
+ - getLogger(ctx).debug("blocking_aborted", { route: "blocking" });
453
+ - ctx.endCompaction?.({ reason: "extension", signal: feedbackSignal, aborted: true });
454
+ - return { applied: false, reason: "rejected" };
455
+ - }
456
+ - const message = error instanceof Error ? error.message : String(error);
457
+ - ctx.endCompaction?.({
458
+ - reason: "extension",
459
+ - signal: feedbackSignal,
460
+ - aborted: feedbackSignal?.aborted,
461
+ - errorMessage: `Compaction failed: ${stripTurnRetrySuppressionPrefix(message)}`,
462
+ - });
463
+ - const transient = isTransientSummarizationFailure(error, message);
464
+ - if (transient) {
465
+ + const aborted = feedbackSignal?.aborted === true;
466
+ + const failure = aborted ? undefined : compactionFailure(error, ctx.model);
467
+ + ctx.endCompaction?.({ reason: "extension", signal: feedbackSignal, aborted,
468
+ + errorMessage: aborted ? undefined : "Compaction failed; previous context retained", failure });
469
+ + if (!aborted)
470
+ @@ -527,3 +293,1 @@
471
+ - return { applied: false, reason: "failed" };
472
+ - }
473
+ - throw error;
474
+ + return { applied: false, reason: "failed", failure };
475
+ @@ -533,3 +297,0 @@
476
+ - // A pre-aborted request (superseded admission or user cancel) must stand
477
+ - // down before touching warm-job ownership: core's own post-emit abort
478
+ - // checks turn it into a clean cancellation (issue #886).
479
+ @@ -537,1 +298,1 @@
480
+ - return undefined;
481
+ + return { cancel: true };
482
+ @@ -539,4 +300,0 @@
483
+ - // The claim detaches the job without aborting it, so ownership must survive every
484
+ - // exit from this handler - including a throw - or the request it already paid for
485
+ - // keeps streaming for a result nobody will read.
486
+ - let warmJobConsumed = false;
487
+ @@ -544,1 +301,0 @@
488
+ - const claimedGeneration = speculativeGeneration;
489
+ @@ -546,25 +302,6 @@
490
+ - if (!lanePolicy.ownsCompaction(ctx, event.reason)) {
491
+ - return {
492
+ - cancel: true,
493
+ - rejectionCause: "external-owner",
494
+ - reason: SDK_NATIVE_LANE_REJECTION_REASON,
495
+ - };
496
+ - }
497
+ - if (cap.shouldRejectByCap(state).cancel) {
498
+ - getLogger(ctx).debug("skip_cap", { reason: event.reason, count: state.acceptedAbsolute });
499
+ - return {
500
+ - cancel: true,
501
+ - rejectionCause: "per-turn-cap",
502
+ - reason: "absolute compaction cap reached for this session",
503
+ - };
504
+ - }
505
+ - const now = Date.now();
506
+ - if (breaker.isTripped(state, now) && !breaker.shouldBypass(state, { reason: event.reason })) {
507
+ - const remainingMs = state.trippedAt !== null ? Math.max(0, state.trippedAt + breaker.COOLDOWN_MS - now) : 0;
508
+ - getLogger(ctx).debug("skip_breaker", { reason: event.reason, remainingSec: Math.ceil(remainingMs / 1000) });
509
+ - return {
510
+ - cancel: true,
511
+ - rejectionCause: "circuit-breaker",
512
+ - reason: `compaction circuit breaker cooling down (${Math.ceil(remainingMs / 1000)}s left)`,
513
+ - };
514
+ - }
515
+ + if (!lanePolicy.ownsCompaction(ctx, event.reason))
516
+ + return { cancel: true, rejectionCause: "external-owner", reason: SDK_NATIVE_LANE_REJECTION_REASON };
517
+ + if (cap.shouldRejectByCap(state).cancel)
518
+ + return { cancel: true, rejectionCause: "per-turn-cap", failure: { kind: "provider" } };
519
+ + if (breaker.isTripped(state, Date.now()) && !breaker.shouldBypass(state, { reason: event.reason }))
520
+ + return { cancel: true, rejectionCause: "circuit-breaker", failure: { kind: "provider" } };
521
+ @@ -573,31 +310,2 @@
522
+ - if (!model) {
523
+ - return undefined;
524
+ - }
525
+ - let remoteCompaction;
526
+ - try {
527
+ - // Native remote compaction is tied to the active provider's replay origin.
528
+ - // A different summary model must use the bounded local summary route.
529
+ - remoteCompaction =
530
+ - (ctx.getCompactionModel?.() ?? model) !== model
531
+ - ? undefined
532
+ - : await runOpenAiRemoteCompaction(ctx, event, (data) => {
533
+ - reportRemoteCompactionTimeout(ctx, event.reason, event.signal, data);
534
+ - pi.events.emit(SENPI_COMPACTION_EVENT, data);
535
+ - }, remoteCompactionDependencies);
536
+ - }
537
+ - catch (error) {
538
+ - if (error instanceof AccountUsageLimitError)
539
+ - return { cancel: true, reason: error.message };
540
+ - // The remote route deliberately rethrows once its signal aborts; the
541
+ - // handler converts that into a silent stand-down so the abort is not
542
+ - // reported as an extension error with a stack (issue #886).
543
+ - if (!event.signal.aborted)
544
+ - throw error;
545
+ - getLogger(ctx).debug("remote_aborted", { route: "core-route", requestId: event.requestId });
546
+ - return undefined;
547
+ - }
548
+ - if (remoteCompaction) {
549
+ - getLogger(ctx).debug("core_route_generated", { route: "core-route", requestId: event.requestId });
550
+ - return { compaction: remoteCompaction };
551
+ - }
552
+ - let inheritedWarmFailure;
553
+ + if (!model)
554
+ + throw new SummaryGenerationError("model_unavailable", "Compaction model unavailable");
555
+ @@ -605,3 +313,1 @@
556
+ - const unlinkAbort = linkAbortSignal(event.signal, claimedWarmJob.controller);
557
+ - let warmCompaction;
558
+ - let warmFailure;
559
+ + const unlink = linkAbortSignal(event.signal, claimedWarmJob.controller);
560
+ @@ -609,2 +315,7 @@
561
+ - warmCompaction = await claimedWarmJob.promise;
562
+ - warmFailure = await claimedWarmJob.failure;
563
+ + const compaction = await claimedWarmJob.promise;
564
+ + const failure = await claimedWarmJob.failure;
565
+ + if (failure !== undefined)
566
+ + throw failure;
567
+ + if (!compaction)
568
+ + throw new SummaryGenerationError("incomplete", "Warm compaction returned no result");
569
+ + return { compaction };
570
+ @@ -612,16 +323,1 @@
571
+ - finally {
572
+ - unlinkAbort();
573
+ - }
574
+ - if (warmFailure === undefined && warmCompaction && !event.signal.aborted) {
575
+ - getLogger(ctx).debug("warm_consumed", { generation: claimedWarmJob.generation, route: "core-route" });
576
+ - warmJobConsumed = true;
577
+ - return { compaction: warmCompaction };
578
+ - }
579
+ - if (warmFailure !== undefined &&
580
+ - requiresDeterministicCompactionFallback(event, ctx.getContextUsage()) &&
581
+ - classifyRequiredCompactionFallbackFailure(warmFailure) !== undefined &&
582
+ - !event.signal.aborted &&
583
+ - speculativeGeneration === claimedGeneration &&
584
+ - claimedWarmJob.snapshot.expectedRevision === ctx.getMessageRevision()) {
585
+ - inheritedWarmFailure = warmFailure;
586
+ - }
587
+ + finally { unlink(); }
588
+ @@ -629,0 +325,8 @@
589
+ + if ((ctx.getCompactionModel?.() ?? model) === model) {
590
+ + const remote = await runOpenAiRemoteCompaction(ctx, event,
591
+ + (data) => pi.events.emit(SENPI_COMPACTION_EVENT, data), remoteCompactionDependencies);
592
+ + if (remote.status === "failed")
593
+ + throw Object.assign(new Error("Remote compaction failed"), { failure: remote.failure });
594
+ + if (remote.status === "accepted")
595
+ + return { compaction: remote.compaction };
596
+ + }
597
+ @@ -630,3 +334,1 @@
598
+ - generation: ++speculativeGeneration,
599
+ - expectedRevision: ctx.getMessageRevision(),
600
+ - model,
601
+ + generation: ++speculativeGeneration, expectedRevision: ctx.getMessageRevision(), model,
602
+ @@ -634,45 +336,3 @@
603
+ - preparation: event.preparation,
604
+ - branchEntries: event.branchEntries,
605
+ - promptVariant: getPromptVariant(event),
606
+ - origin: "core-route",
607
+ - customInstructions: event.customInstructions,
608
+ - systemPrompt: ctx.getSystemPrompt(),
609
+ - tools: getSummarizationTools(),
610
+ - };
611
+ - let compaction;
612
+ - try {
613
+ - if (inheritedWarmFailure)
614
+ - throw inheritedWarmFailure;
615
+ - compaction = await generateCompaction(ctx, snapshot, event.signal, (delta) => ctx.updateCompaction?.({ reason: event.reason, signal: event.signal, delta }));
616
+ - }
617
+ - catch (error) {
618
+ - const message = error instanceof Error ? error.message : String(error);
619
+ - const failureKind = classifyRequiredCompactionFallbackFailure(error);
620
+ - if ((requiresDeterministicCompactionFallback(event, ctx.getContextUsage()) ||
621
+ - failureKind === "unsafe-retained-content") &&
622
+ - failureKind !== undefined &&
623
+ - !event.signal.aborted) {
624
+ - const recovery = recoverRequiredCompaction(ctx, snapshot, failureKind, error);
625
+ - if (recovery.compaction)
626
+ - return { compaction: recovery.compaction };
627
+ - pendingMetadata.delete(event.requestId);
628
+ - return {
629
+ - cancel: true,
630
+ - reason: recovery.rejectionReason,
631
+ - };
632
+ - }
633
+ - pendingMetadata.delete(event.requestId);
634
+ - if (error instanceof SummaryGenerationError) {
635
+ - return { cancel: true, reason: stripTurnRetrySuppressionPrefix(error.message) };
636
+ - }
637
+ - return { cancel: true, reason: `compaction generator failed: ${stripTurnRetrySuppressionPrefix(message)}` };
638
+ - }
639
+ - if (!compaction) {
640
+ - pendingMetadata.delete(event.requestId);
641
+ - if (event.signal.aborted) {
642
+ - return { cancel: true };
643
+ - }
644
+ - return { cancel: true, reason: "compaction generator returned no summary" };
645
+ - }
646
+ - return {
647
+ - compaction,
648
+ + preparation: event.preparation, branchEntries: event.branchEntries, promptVariant: getPromptVariant(event),
649
+ + origin: "core-route", customInstructions: event.customInstructions,
650
+ + systemPrompt: ctx.getSystemPrompt(), tools: getSummarizationTools(),
651
+ @@ -680,0 +340,5 @@
652
+ + const compaction = await generateCompaction(ctx, snapshot, event.signal,
653
+ + (delta) => ctx.updateCompaction?.({ reason: event.reason, signal: event.signal, delta }));
654
+ + if (!compaction)
655
+ + throw new SummaryGenerationError("incomplete", "Compaction returned no summary");
656
+ + return { compaction };
657
+ @@ -681,3 +346,5 @@
658
+ - finally {
659
+ - if (!warmJobConsumed)
660
+ - claimedWarmJob?.controller.abort();
661
+ + catch (error) {
662
+ + pendingMetadata.delete(event.requestId);
663
+ + return { cancel: true, ...(event.signal.aborted ? {} : {
664
+ + reason: "Compaction failed; previous context retained", failure: compactionFailure(error, ctx.model),
665
+ + }) };
666
+ @@ -685,0 +352,1 @@
667
+ + finally { claimedWarmJob?.controller.abort(); }
668
+ @@ -785,1 +453,2 @@
669
+ - await applyBlockingCompaction(ctx, EMERGENCY_COMPACTION_INSTRUCTIONS);
670
+ + const result = await applyBlockingCompaction(ctx, EMERGENCY_COMPACTION_INSTRUCTIONS);
671
+ + if (!result.applied) { ctx.abort("system"); return; }
672
+ @@ -804,1 +473,2 @@
673
+ - await applyBlockingCompaction(ctx, PROACTIVE_COMPACTION_INSTRUCTIONS);
674
+ + const result = await applyBlockingCompaction(ctx, PROACTIVE_COMPACTION_INSTRUCTIONS);
675
+ + if (!result.applied) { ctx.abort("system"); return; }
676
+ @@ -915,1 +585,0 @@
677
+ - idleWarmupAttempt = 0;
678
+ @@ -918,1 +587,0 @@
679
+ - armIdleWarmupRetry(ctx);
680
+ @@ -943,1 +611,0 @@
681
+ - idleWarmupAttempt = 0;
682
+ @@ -946,2 +613,1 @@
683
+ - armIdleWarmupRetry(ctx);
684
+ - armIdleApply(ctx);
685
+ + armIdleApply(ctx);
686
+ @@ -977,1 +643,0 @@
687
+ - idleWarmupAttempt = 0;
688
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-lifecycle.js
689
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-lifecycle.js
690
+ @@ -49,0 +49,1 @@
691
+ + pi.on("before_agent_start", () => handoff.reset());
692
+ @@ -109,1 +110,1 @@
693
+ - const managed = !policy.laneOwnsCompaction && !policy.appendOnlyTranscript && !isOpenAiRemoteCompactionModel(ctx.model);
694
+ + const managed = !policy.laneOwnsCompaction;
695
+ @@ -121,1 +122,4 @@
696
+ - handoff.request(ctx);
697
+ + if (policy.breakerTripped)
698
+ + ctx.abort("system");
699
+ + else
700
+ + handoff.request(ctx);
701
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-pipeline.js
702
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-pipeline.js
703
+ @@ -2,2 +2,0 @@
704
+ -import { BUILTIN_CONTEXT_REDUCTION_OPTIONS, reduceContextMessages } from "./context-reduction.js";
705
+ -import { reduceContextWithFrontier } from "./context-reduction-frontier.js";
706
+ @@ -6,2 +4,1 @@
707
+ -import { isOpenAiRemoteCompactionModel } from "./openai-remote-model.js";
708
+ -import { admitContextToolResults, injectTokenBudgetReminder } from "./orchestration.js";
709
+ +import { injectTokenBudgetReminder } from "./orchestration.js";
710
+ @@ -10,1 +7,0 @@
711
+ -import { hardLimitEmergencyPrune } from "./speculative.js";
712
+ @@ -16,46 +12,7 @@
713
+ - const admittedMessages = admitContextToolResults(input.event.messages, input.contextWindow, input.toolAdmissionEnabled);
714
+ - const state = input.contextReductionState ?? createContextReductionState();
715
+ - const native = isOpenAiRemoteCompactionModel(input.ctx.model);
716
+ - const managed = input.appendOnlyTranscript !== true && !native;
717
+ - const sourceMessages = managed
718
+ - ? reduceContextWithFrontier(admittedMessages, state, {
719
+ - unreducedMessages: input.event.messages,
720
+ - contextWindow: input.contextWindow,
721
+ - ceilingTokens: input.reductionCeilingTokens ?? input.promptContextWindow,
722
+ - overheadTokens: input.contextOverheadTokens ?? 0,
723
+ - blockBudgetRatio: input.contextReductionBlockBudgetRatio,
724
+ - force: input.breakerFallback,
725
+ - })
726
+ - : native && input.breakerFallback && input.appendOnlyTranscript !== true
727
+ - ? reduceContextMessages(admittedMessages, BUILTIN_CONTEXT_REDUCTION_OPTIONS).messages
728
+ - : admittedMessages;
729
+ - const emergency = input.laneOwnsCompaction
730
+ - ? { messages: sourceMessages, needsAggressiveCompaction: false }
731
+ - : hardLimitEmergencyPrune(sourceMessages, managed && state.engaged
732
+ - ? Math.max(0, input.promptContextWindow - (input.contextOverheadTokens ?? 0))
733
+ - : input.promptContextWindow, input.emergencyPruneLatch);
734
+ - // This is the ONE site that actually prunes, so the emergency_prune counter must be
735
+ - // emitted here. Engagement shows up either as a rewritten message array or as
736
+ - // needsAggressiveCompaction when nothing prunable remained.
737
+ - if (!input.laneOwnsCompaction && (emergency.needsAggressiveCompaction || emergency.messages !== sourceMessages)) {
738
+ - input.logEmergencyPrune?.({
739
+ - route: "context-event",
740
+ - tokensBefore: estimateTotalTokens(sourceMessages),
741
+ - tokens: estimateTotalTokens(emergency.messages),
742
+ - });
743
+ - }
744
+ - const marked = markOpenAiRemoteReplayBoundary(emergency.messages, {
745
+ - model: input.ctx.model,
746
+ - branchEntries: input.ctx.sessionManager.getBranch(),
747
+ - });
748
+ - const reminded = injectTokenBudgetReminder(marked, input.reminder);
749
+ - const outgoing = repairOrphanedToolResults(convertToLlm(reminded));
750
+ - const overhead = input.contextOverheadTokens ?? 0;
751
+ - const beforePrune = estimateTotalTokens(sourceMessages) + overhead;
752
+ - const afterPrune = estimateTotalTokens(outgoing) + overhead;
753
+ - if (managed &&
754
+ - (state.engaged || emergency.messages !== sourceMessages) &&
755
+ - (emergency.needsAggressiveCompaction ||
756
+ - afterPrune > input.promptContextWindow ||
757
+ - (afterPrune < beforePrune && input.emergencyPruneLatch.engaged)))
758
+ - state.compactionRequired = true;
759
+ + const state = input.contextReductionState ?? createContextReductionState();
760
+ + const marked = markOpenAiRemoteReplayBoundary(input.event.messages, {
761
+ + model: input.ctx.model, branchEntries: input.ctx.sessionManager.getBranch(),
762
+ + });
763
+ + const outgoing = repairOrphanedToolResults(convertToLlm(injectTokenBudgetReminder(marked, input.reminder)));
764
+ + const ceiling = input.reductionCeilingTokens ?? input.promptContextWindow;
765
+ + state.compactionRequired = estimateTotalTokens(outgoing) + (input.contextOverheadTokens ?? 0) > ceiling;
766
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/emergency-prune.js
767
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/emergency-prune.js
768
+ @@ -56,8 +56,1 @@
769
+ - const noLlmPruned = truncateContextMessages(pruneToolResults(messages, contextWindow, SUMMARIZATION_INPUT_BUDGET_RATIO));
770
+ - if (estimateTotalTokens(noLlmPruned) <= targetTokens) {
771
+ - return { messages: noLlmPruned, needsAggressiveCompaction: false };
772
+ - }
773
+ - return {
774
+ - messages: pruneOldMessagesToBudget(noLlmPruned, targetTokens),
775
+ - needsAggressiveCompaction: true,
776
+ - };
777
+ + return { messages, needsAggressiveCompaction: true };
778
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote.js
779
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote.js
780
+ @@ -3,0 +3,1 @@
781
+ +import { compactionFailure } from "./speculative.js";
782
+ @@ -179,0 +180,2 @@
783
+ + streamKind: "auxiliary",
784
+ + maxRetries: 0,
785
+ @@ -181,2 +184,4 @@
786
+ - if (response.stopReason === "error" || response.stopReason === "aborted") {
787
+ - return undefined;
788
+ + if (response.stopReason !== "stop" || response.stopDetails?.type === "refusal") {
789
+ + throw Object.assign(new Error("Remote compaction did not finish normally"), {
790
+ + failure: response.stopDetails?.type === "refusal" ? { kind: "refused" } : response.failure ?? { kind: "incomplete" },
791
+ + });
792
+ @@ -223,0 +228,1 @@
793
+ + failure: compactionFailure(error, options?.model),
794
+ @@ -235,0 +241,1 @@
795
+ + failure: { kind: response.status === 429 ? "quota" : response.status === 401 ? "auth_expired" : response.status === 403 ? "org_denied" : "provider" },
796
+ @@ -251,0 +258,1 @@
797
+ + failure: compactionFailure(error, options?.model),
798
+ @@ -292,0 +300,1 @@
799
+ + failure: compactionFailure(error, options?.model),
800
+ @@ -325,0 +334,23 @@
801
+ + let dispatched = false;
802
+ + let failure;
803
+ + try {
804
+ + const compaction = await runRemoteAttempt(ctx, event, (data) => {
805
+ + if (data.action === "remote_started")
806
+ + dispatched = true;
807
+ + if (data.failure)
808
+ + failure = data.failure;
809
+ + if (data.reason === REMOTE_COMPACTION_TIMEOUT_REASON || data.reason === REMOTE_COMPACTION_BUDGET_EXHAUSTED_REASON)
810
+ + failure = { kind: "timeout" };
811
+ + emit?.(data);
812
+ + }, dependencies);
813
+ + if (compaction)
814
+ + return { status: "accepted", compaction };
815
+ + return dispatched ? { status: "failed", failure: failure ?? { kind: "provider" } } : { status: "ineligible" };
816
+ + }
817
+ + catch (error) {
818
+ + if (event.signal.aborted)
819
+ + throw error;
820
+ + return { status: "failed", failure: compactionFailure(error, ctx.model) };
821
+ + }
822
+ +}
823
+ +async function runRemoteAttempt(ctx, event, emit, dependencies) {
824
+ @@ -383,1 +415,1 @@
825
+ - next: remoteDeadline.remainingMs() > 0 ? next : "local-summary",
826
+ + next: "stop",
827
+ @@ -456,2 +488,1 @@
828
+ - if (result || requestModel.api === "openai-codex-responses")
829
+ - return result;
830
+ + return result;
831
+ @@ -531,0 +562,1 @@
832
+ + failure: compactionFailure(error, requestModel),
833
+ @@ -534,0 +566,1 @@
834
+ + return undefined;
835
+ --- a/@code-yeongyu/senpi/dist/core/agent-session.js
836
+ +++ b/@code-yeongyu/senpi/dist/core/agent-session.js
837
+ @@ -43,0 +43,1 @@
838
+ +import { compactionFailure, validateSummaryResponse } from "./extensions/builtin/compaction/speculative.js";
839
+ @@ -201,2 +202,2 @@
840
+ - constructor(rejectionCause) {
841
+ - super(rejectionCause === "cancelled-by-extension"
842
+ + constructor(rejectionCause, failure) {
843
+ + super(failure ? "Compaction failed; previous context retained" : rejectionCause === "cancelled-by-extension"
844
+ @@ -207,0 +208,1 @@
845
+ + this.failure = failure;
846
+ @@ -226,0 +228,1 @@
847
+ + this.failure = aborted ? undefined : compactionFailure(error);
848
+ @@ -6041,0 +6044,13 @@
849
+ + const checkedStream = async (model, context, options) => {
850
+ + const stream = await this.agent.streamFunction(model, context, {
851
+ + ...options, refusalFallbacks: undefined, streamKind: "auxiliary",
852
+ + });
853
+ + return {
854
+ + [Symbol.asyncIterator]: () => stream[Symbol.asyncIterator](),
855
+ + result: async () => {
856
+ + const response = await stream.result();
857
+ + validateSummaryResponse(response);
858
+ + return response;
859
+ + },
860
+ + };
861
+ + };
862
+ @@ -6043,1 +6059,1 @@
863
+ - thinkingLevel, this.agent.streamFunction, env, this.agent.transformContext, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason }), this.sessionManager.getSessionId(), cacheFriendly);
864
+ + thinkingLevel, checkedStream, env, this.agent.transformContext, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason }), this.sessionManager.getSessionId(), cacheFriendly);
865
+ @@ -6115,1 +6131,1 @@
866
+ - throw new CompactionRejectedError(execution.rejectionCause);
867
+ + throw new CompactionRejectedError(execution.rejectionCause, execution.failure);
868
+ @@ -6124,1 +6140,1 @@
869
+ - throw new Error(error.message);
870
+ + throw Object.assign(new Error(error.message), { failure: error.failure });
871
+ @@ -6279,0 +6295,2 @@
872
+ + accepted: false,
873
+ + failure: aborted ? undefined : options.failure ?? { kind: "provider" },
874
+ @@ -6282,2 +6300,1 @@
875
+ - if (!aborted)
876
+ - this._resumeQueuedMessagesAfterCompaction();
877
+ +
878
+ @@ -6349,1 +6366,1 @@
879
+ - return await this._rejectCompaction(request, requestId, operationId, extensionResult.rejectionCause ?? "cancelled-by-extension", true, extensionResult.reason);
880
+ + return await this._rejectCompaction(request, requestId, operationId, extensionResult.rejectionCause ?? "cancelled-by-extension", signal.aborted, extensionResult.reason, extensionResult.failure);
881
+ @@ -6394,1 +6411,5 @@
882
+ - if (await this._wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, model)) {
883
+ + if (typeof compactionResult.summary !== "string" || !compactionResult.summary.trim())
884
+ + return await this._rejectCompaction(request, requestId, operationId, "cancelled-by-extension", false,
885
+ + "Compaction summary body is empty", { kind: "empty" });
886
+ + const fitModel = this._limitsModel() ?? model;
887
+ + if (await this._wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, fitModel)) {
888
+ @@ -6397,15 +6418,7 @@
889
+ - const latestConfigurationEffort = this.sessionManager
890
+ - .getBranch()
891
+ - .findLast((entry) => entry.type === "configuration_update")?.reasoning.effort;
892
+ - const compactionEntryId = this.sessionManager.appendCompaction(compactionResult.summary, compactionResult.firstKeptEntryId, compactionResult.tokensBefore, compactionResult.details, fromExtension, compactionResult.usage);
893
+ - const savedEntry = this.sessionManager.getEntry(compactionEntryId);
894
+ - if (savedEntry?.type !== "compaction") {
895
+ - throw new Error("Compaction entry was not saved");
896
+ - }
897
+ - const modelAfterCompaction = this.model;
898
+ - if (latestConfigurationEffort !== undefined &&
899
+ - modelAfterCompaction !== undefined &&
900
+ - supportsConfigurationUpdate(modelAfterCompaction)) {
901
+ - this.sessionManager.appendConfigurationUpdate(latestConfigurationEffort);
902
+ - }
903
+ - const sessionContext = this.sessionManager.buildSessionContext();
904
+ + if (signal.aborted)
905
+ + throw new CompactionCancelledError();
906
+ + if (this._limitsModel() !== fitModel || this._messageRevision !== lifecycleState.startedRevision + compactionOwnedDiagnosticMessages.size ||
907
+ + !agentMessagesAtStart.every((message, index) => this.agent.state.messages[index] === message) ||
908
+ + !this.agent.state.messages.slice(agentMessagesAtStart.length).every((message) =>
909
+ + this._messageEndsAwaitingPersistence.has(message) || compactionOwnedDiagnosticMessages.has(message)))
910
+ + return await this._rejectCompaction(request, requestId, operationId, "stale-revision", false);
911
+ @@ -6428,0 +6441,18 @@
912
+ + if (preservedPendingMessages.length > 0 &&
913
+ + wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, fitModel, this._getCompactionSettings(), preservedPendingMessages))
914
+ + return await this._rejectCompaction(request, requestId, operationId, "would-overflow", false);
915
+ + const latestConfigurationEffort = this.sessionManager
916
+ + .getBranch()
917
+ + .findLast((entry) => entry.type === "configuration_update")?.reasoning.effort;
918
+ + const compactionEntryId = this.sessionManager.appendCompaction(compactionResult.summary, compactionResult.firstKeptEntryId, compactionResult.tokensBefore, compactionResult.details, fromExtension, compactionResult.usage);
919
+ + const savedEntry = this.sessionManager.getEntry(compactionEntryId);
920
+ + if (savedEntry?.type !== "compaction") {
921
+ + throw new Error("Compaction entry was not saved");
922
+ + }
923
+ + const modelAfterCompaction = this.model;
924
+ + if (latestConfigurationEffort !== undefined &&
925
+ + modelAfterCompaction !== undefined &&
926
+ + supportsConfigurationUpdate(modelAfterCompaction)) {
927
+ + this.sessionManager.appendConfigurationUpdate(latestConfigurationEffort);
928
+ + }
929
+ + const sessionContext = this.sessionManager.buildSessionContext();
930
+ @@ -6435,1 +6466,1 @@
931
+ - compactionResult.estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
932
+ + compactionResult.estimatedTokensAfter = estimateMessagesTokens(this.agent.state.messages);
933
+ @@ -6484,9 +6515,4 @@
934
+ - const aborted = signal.aborted || isCompactionExecutionAborted(error);
935
+ - if (ownsTerminalTransition && lifecycleState.status === "running") {
936
+ - this._compactionLifecycle.finish({
937
+ - operationId,
938
+ - status: aborted ? "aborted" : "failed",
939
+ - endedRevision: this._messageRevision,
940
+ - errorMessage: error instanceof Error ? error.message : String(error),
941
+ - });
942
+ - }
943
+ + const aborted = signal.aborted;
944
+ + if (ownsTerminalTransition && lifecycleState.status === "running")
945
+ + return await this._rejectCompaction(request, requestId, operationId, "cancelled-by-extension", aborted,
946
+ + aborted ? undefined : "Compaction failed; previous context retained", aborted ? undefined : compactionFailure(error, model));
947
+ @@ -6540,1 +6566,2 @@
948
+ - async _rejectCompaction(request, requestId, operationId, rejectionCause, aborted, extensionReason) {
949
+ + async _rejectCompaction(request, requestId, operationId, rejectionCause, aborted, extensionReason, failure) {
950
+ + failure = aborted || rejectionCause === "external-owner" ? undefined : failure ?? { kind: rejectionCause === "stale-revision" ? "stale" : rejectionCause === "would-overflow" ? "context_budget" : "provider" };
951
+ @@ -6557,1 +6584,1 @@
952
+ - status: "failed",
953
+ + status: aborted ? "aborted" : "failed",
954
+ @@ -6573,0 +6600,1 @@
955
+ + failure,
956
+ @@ -6581,0 +6609,1 @@
957
+ + failure,
958
+ @@ -6584,1 +6613,1 @@
959
+ - return { accepted: false, requestId, rejectionCause };
960
+ + return { accepted: false, requestId, rejectionCause, aborted, failure };
961
+ @@ -7440,0 +7469,2 @@
962
+ + accepted: false,
963
+ + failure: aborted ? undefined : compactionFailure(error, model),
964
+ --- a/@code-yeongyu/senpi/dist/core/extensions/types.d.ts
965
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/types.d.ts
966
+ @@ -325,1 +325,7 @@
967
+ -export interface EndCompactionOptions {
968
+ +export interface CompactionFailure {
969
+ + kind: "invalid_model" | "auth_missing" | "auth_expired" | "quota" | "org_denied" | "model_unavailable" | "refused" | "timeout" | "incomplete" | "empty" | "context_budget" | "stale" | "provider";
970
+ + provider?: string;
971
+ + model?: string;
972
+ +}
973
+ +export interface EndCompactionOptions {
974
+ + failure?: CompactionFailure;
975
+ @@ -1039,0 +1045,1 @@
976
+ + failure?: CompactionFailure;
977
+ @@ -1667,0 +1674,1 @@
978
+ + failure?: CompactionFailure;
979
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-handoff.js
980
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-handoff.js
981
+ @@ -20,1 +20,1 @@
982
+ - active = undefined;
983
+ + ctx.abort("system");
984
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-responses-v2.js
985
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-responses-v2.js
986
+ @@ -1,0 +1,1 @@
987
+ +import { compactionFailure } from "./speculative.js";
988
+ @@ -63,1 +64,2 @@
989
+ - ...(args.model.api === "openai-codex-responses" ? { maxRetries: 0 } : {}),
990
+ + maxRetries: 0,
991
+ + streamKind: "auxiliary",
992
+ @@ -87,2 +89,4 @@
993
+ - if (assistant.stopReason === "error" || assistant.stopReason === "aborted")
994
+ - return undefined;
995
+ + if (assistant.stopReason !== "stop" || assistant.stopDetails?.type === "refusal")
996
+ + throw Object.assign(new Error("Remote compaction did not finish normally"), {
997
+ + failure: assistant.stopDetails?.type === "refusal" ? { kind: "refused" } : assistant.failure ?? { kind: "incomplete" },
998
+ + });
999
+ @@ -173,0 +177,1 @@
1000
+ + failure: compactionFailure(error, args.model),
1001
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-timeout.js
1002
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-timeout.js
1003
+ @@ -42,0 +42,1 @@
1004
+ + stop: "stopping with previous context retained",
1005
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/extension-wiring.js
1006
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/extension-wiring.js
1007
+ @@ -66,1 +66,2 @@
1008
+ - ctx.endCompaction?.({ reason: "extension", signal, aborted: signal?.aborted, errorMessage });
1009
+ + ctx.endCompaction?.({ reason: "extension", signal, aborted: signal?.aborted, errorMessage,
1010
+ + failure: signal?.aborted ? undefined : { kind: result.reason === "stale" ? "stale" : "provider" } });
1011
+ --- a/@code-yeongyu/senpi/dist/core/agent-session.d.ts
1012
+ +++ b/@code-yeongyu/senpi/dist/core/agent-session.d.ts
1013
+ @@ -31,1 +31,1 @@
1014
+ -import type { ApplyCompactionOptions, ApplyCompactionResult, CompactionReason, CompactionRejectionCause, ModelSelectSource } from "./extensions/types.ts";
1015
+ +import type { ApplyCompactionOptions, ApplyCompactionResult, CompactionReason, CompactionRejectionCause, CompactionFailure, ModelSelectSource } from "./extensions/types.ts";
1016
+ @@ -230,0 +230,1 @@
1017
+ + failure?: CompactionFailure;
1018
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.d.ts
1019
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.d.ts
1020
+ @@ -7,1 +7,1 @@
1021
+ -import type { ApplyCompactionResult, ContextUsage, ProviderRequestPreparation } from "../../types.ts";
1022
+ +import type { CompactionFailure, ApplyCompactionResult, ContextUsage, ProviderRequestPreparation } from "../../types.ts";
1023
+ @@ -17,1 +17,1 @@
1024
+ - getCompactionModel?(): Model<any> | undefined;
1025
+ + getCompactionModel?(inputTokens?: number): Model<any> | undefined;
1026
+ @@ -59,1 +59,1 @@
1027
+ -export type SummaryRequestFailureKind = "upstream-stream-truncated";
1028
+ +export type SummaryRequestFailureKind = "upstream-stream-truncated" | CompactionFailure["kind"];
1029
+ @@ -73,3 +73,6 @@
1030
+ - readonly kind: "auth" | "empty-summary";
1031
+ - constructor(kind: "auth" | "empty-summary", message: string);
1032
+ -}
1033
+ + readonly kind: CompactionFailure["kind"];
1034
+ + readonly failure: CompactionFailure;
1035
+ + constructor(kind: CompactionFailure["kind"], message: string);
1036
+ +}
1037
+ +export declare function compactionFailure(error: unknown, model?: { provider: string; id: string }): CompactionFailure;
1038
+ +export declare function validateSummaryResponse(response: unknown): { summaryText: string; taskIntent?: string };
1039
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote.d.ts
1040
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote.d.ts
1041
+ @@ -5,1 +5,1 @@
1042
+ -import type { ProviderRequestPreparation, ServiceTier, SessionBeforeCompactEvent } from "../../types.ts";
1043
+ +import type { CompactionFailure, ProviderRequestPreparation, ServiceTier, SessionBeforeCompactEvent } from "../../types.ts";
1044
+ @@ -123,1 +123,1 @@
1045
+ -export declare function runOpenAiRemoteCompaction(ctx: OpenAiRemoteCompactionContext, event: SessionBeforeCompactEvent, emit?: EmitCompactionEvent, dependencies?: OpenAiRemoteCompactionDependencies): Promise<OpenAiRemoteCompactionResult | undefined>;
1046
+ +export declare function runOpenAiRemoteCompaction(ctx: OpenAiRemoteCompactionContext, event: SessionBeforeCompactEvent, emit?: EmitCompactionEvent, dependencies?: OpenAiRemoteCompactionDependencies): Promise<{ status: "ineligible" } | { status: "failed"; failure: CompactionFailure } | { status: "accepted"; compaction: OpenAiRemoteCompactionResult }>;
1047
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-timeout.d.ts
1048
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-timeout.d.ts
1049
+ @@ -14,1 +14,1 @@
1050
+ -export type RemoteCompactionTimeoutNextStep = "websocket" | "compact-endpoint" | "local-summary";
1051
+ +export type RemoteCompactionTimeoutNextStep = "websocket" | "compact-endpoint" | "local-summary" | "stop";
1052
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/rejected-recovery.js
1053
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/rejected-recovery.js
1054
+ @@ -9,1 +9,1 @@
1055
+ -export function wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, model, settings) {
1056
+ +export function wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, model, settings, pendingMessages = []) {
1057
+ @@ -32,0 +32,1 @@
1058
+ + messages = [...messages, ...pendingMessages];
1059
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/rejected-recovery.d.ts
1060
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/rejected-recovery.d.ts
1061
+ @@ -6,1 +6,1 @@
1062
+ -export declare function wouldCompactionOverflow(pathEntries: SessionEntry[], compactionResult: CompactionResult, fromExtension: boolean, model: Model<string>, settings: CompactionPreparation["settings"]): boolean;
1063
+ +export declare function wouldCompactionOverflow(pathEntries: SessionEntry[], compactionResult: CompactionResult, fromExtension: boolean, model: Model<string>, settings: CompactionPreparation["settings"], pendingMessages?: CompactionPreparation["messagesToSummarize"]): boolean;