zigmini 0.8.0-beta.6 → 0.9.0-beta.1

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 (62) hide show
  1. package/README.ko.md +90 -19
  2. package/README.md +91 -19
  3. package/assets/agent/gateway-request-rules.md +3 -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 +69 -1
  9. package/docs/manual.md +67 -1
  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 +169 -7
  17. package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
  18. package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
  19. package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
  20. package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
  21. package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
  22. package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
  23. package/package.json +4 -1
  24. package/scripts/secret-rules.ts +101 -0
  25. package/scripts/secret-scan.allow.json +155 -0
  26. package/scripts/secret-scan.rules.json +30 -0
  27. package/src/agent-request-rules.ts +82 -0
  28. package/src/cli/access.ts +31 -34
  29. package/src/cli/launchd.ts +30 -4
  30. package/src/cli/main.ts +3 -43
  31. package/src/cli/messages.ts +160 -0
  32. package/src/cli/pair.ts +32 -25
  33. package/src/cli/setup.ts +164 -75
  34. package/src/decisions.ts +48 -10
  35. package/src/engine/compat.json +3 -3
  36. package/src/hub.ts +183 -47
  37. package/src/main.ts +60 -4
  38. package/src/notification-history.ts +56 -0
  39. package/src/omo/client.ts +28 -0
  40. package/src/protocol.ts +32 -2
  41. package/src/reply-notifications.ts +144 -0
  42. package/src/runtime/paths.ts +2 -0
  43. package/src/security-allow.ts +126 -0
  44. package/src/security-check.ts +559 -0
  45. package/src/store.ts +236 -6
  46. package/src/turn-stats.ts +49 -0
  47. package/src/work-watch.ts +36 -10
  48. package/src/work.ts +45 -6
  49. package/web/DESIGN.md +39 -0
  50. package/web/app.js +289 -2
  51. package/web/decisions.d.ts +1 -1
  52. package/web/decisions.js +6 -0
  53. package/web/diagram.d.ts +28 -0
  54. package/web/diagram.js +228 -0
  55. package/web/index.html +29 -0
  56. package/web/notifications.d.ts +25 -0
  57. package/web/notifications.js +118 -0
  58. package/web/security-inbox.d.ts +35 -0
  59. package/web/security-inbox.js +237 -0
  60. package/web/style.css +43 -1
  61. package/web/turn-stats.d.ts +26 -0
  62. package/web/turn-stats.js +58 -0
@@ -0,0 +1,2630 @@
1
+ --- a/@code-yeongyu/senpi/dist/core/agent-session.d.ts
2
+ +++ b/@code-yeongyu/senpi/dist/core/agent-session.d.ts
3
+ @@ -32,7 +32,7 @@
4
+ import { type PendingModelSwitch } from "./extensions/builtin/compaction/switch-admission.ts";
5
+ import { type ServiceTier } from "./extensions/builtin/service-tier.ts";
6
+ import { type ContextUsage, type ExecuteToolOptions, type ExtensionCommandContextActions, type ExtensionErrorListener, type ExtensionMode, ExtensionRunner, type ExtensionToolHookLifecycleEvent, type ExtensionUIContext, type InputSource, type ReplacedSessionContext, type SessionStartEvent, type ShutdownHandler, type SystemPromptChangeEvent, type ToolDefinition, type ToolInfo } from "./extensions/index.ts";
7
+ -import type { ApplyCompactionOptions, ApplyCompactionResult, CompactionReason, CompactionRejectionCause, ModelSelectSource } from "./extensions/types.ts";
8
+ +import type { ApplyCompactionOptions, ApplyCompactionResult, CompactionReason, CompactionRejectionCause, CompactionFailure, ModelSelectSource } from "./extensions/types.ts";
9
+ import { type UserActivityKind } from "./extensions/user-activity.ts";
10
+ import { ExternalAdmission } from "./external-admission.ts";
11
+ import { type HoldInputResult } from "./held-inputs.ts";
12
+ @@ -116,6 +116,10 @@
13
+ } | {
14
+ type: "session_abort";
15
+ } | SessionHoldEvent | {
16
+ + type: "queued_input_resume";
17
+ + clientInputId: string;
18
+ + disposition: "stale_epoch" | "busy" | "not_head" | "resumed";
19
+ +} | {
20
+ type: "resume_compaction_required";
21
+ projection: ModelUsabilityBudgetProjection;
22
+ notice: string;
23
+ @@ -166,12 +170,14 @@
24
+ type: "pending_input_update";
25
+ } & PendingInputUpdate) | {
26
+ type: "queue_update";
27
+ + queueEpoch: string;
28
+ steering: readonly string[];
29
+ followUp: readonly string[];
30
+ ordered: readonly QueuedInput[];
31
+ } | {
32
+ type: "compaction_start";
33
+ reason: CompactionReason;
34
+ + blocking?: boolean;
35
+ requestId?: string;
36
+ } | {
37
+ type: "compaction_progress";
38
+ @@ -260,7 +266,9 @@
39
+ autoCompactionEnabled: boolean;
40
+ } | {
41
+ type: "compaction_end";
42
+ + failure?: CompactionFailure;
43
+ reason: CompactionReason;
44
+ + blocking?: boolean;
45
+ result: CompactionResult | undefined;
46
+ aborted: boolean;
47
+ willRetry: boolean;
48
+ @@ -609,6 +617,10 @@
49
+ export { CURSOR_TOOL_RESULT_MAX_CHARS, truncateToolResultBodies } from "./cursor-history-admission.ts";
50
+ export declare function providerRetryWatchdogAbortMessage(retryTimeoutMs: number | undefined, streamStartTimeoutMs: number | undefined): string;
51
+ export declare class AgentSession {
52
+ + queueEpoch: string;
53
+ + resumeQueuedInput(clientInputId: string, queueEpoch: string): {
54
+ + disposition: "stale_epoch" | "not_queued" | "busy" | "scheduled";
55
+ + };
56
+ readonly agent: Agent;
57
+ readonly sessionManager: SessionManager;
58
+ readonly settingsManager: SettingsManager;
59
+ @@ -889,9 +901,8 @@
60
+ * `compaction.model` override ("provider/model"), that model is used for the
61
+ * summarization call instead of the session model — this is what lets an
62
+ * SDK-owned lane (anthropic-subscription) compact on a cheaper/different model.
63
+ - * Unset, malformed, unknown, spent, or credential-less overrides keep a usable session model;
64
+ - * a set override that cannot serve logs why the session model was used.
65
+ - * A spent account instead uses the held target or another authenticated model.
66
+ + * Auto selection stays within the active provider; explicit values never fall back.
67
+ + * Selection uses the availability snapshot. Authentication remains request-time.
68
+ */
69
+ private _resolveCompactionModel;
70
+ private _logCompactionModelFallback;
71
+ @@ -2291,4 +2302,4 @@
72
+ */
73
+ get extensionRunner(): ExtensionRunner;
74
+ }
75
+ -//# sourceMappingURL=agent-session.d.ts.map
76
+
77
+ +//# sourceMappingURL=agent-session.d.ts.map
78
+ --- a/@code-yeongyu/senpi/dist/core/agent-session.js
79
+ +++ b/@code-yeongyu/senpi/dist/core/agent-session.js
80
+ @@ -43,6 +43,7 @@
81
+ import { envValue } from "./brand.js";
82
+ import { clientMessageIdentity, readClientMessageIdentity, } from "./client-message-identity.js";
83
+ import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, estimateProjectedContextTokens, estimateTokens, generateBranchSummary, prepareCompaction, resolveThresholdContextTokens, shouldCompact, } from "./compaction/index.js";
84
+ +import { compactionFailure, validateSummaryResponse } from "./extensions/builtin/compaction/speculative.js";
85
+ import { CompactionLifecycleCoordinator } from "./compaction/lifecycle.js";
86
+ import { isTurnStuckOnContextOverflow } from "./compaction/stuck-overflow.js";
87
+ import { isWarmSummaryAnchorValid } from "./compaction/warm-anchor.js";
88
+ @@ -223,12 +224,13 @@
89
+ }
90
+ }
91
+ class CompactionRejectedError extends Error {
92
+ - constructor(rejectionCause) {
93
+ - super(rejectionCause === "cancelled-by-extension"
94
+ + constructor(rejectionCause, failure) {
95
+ + super(failure ? "Compaction failed; previous context retained" : rejectionCause === "cancelled-by-extension"
96
+ ? "Compaction cancelled"
97
+ : describeCompactionRejection(rejectionCause));
98
+ this.name = "CompactionRejectedError";
99
+ this.rejectionCause = rejectionCause;
100
+ + this.failure = failure;
101
+ }
102
+ }
103
+ class CompactionCancelledError extends Error {
104
+ @@ -248,6 +250,7 @@
105
+ this.name = "CompactionExecutionError";
106
+ this.ownsTerminalTransition = ownsTerminalTransition;
107
+ this.aborted = aborted;
108
+ + this.failure = aborted ? undefined : compactionFailure(error);
109
+ }
110
+ }
111
+ function compactionExecutionOwnsTerminalTransition(error) {
112
+ @@ -417,6 +420,7 @@
113
+ this._userInputSources = new WeakMap();
114
+ /** Recently delivered `queuedId`s (bounded), so a late dequeue answers `already_delivered`, not `not_found`. */
115
+ this._deliveredQueuedIds = new Set();
116
+ + this.queueEpoch = randomUUID();
117
+ this._supersededCompactionLogAttemptIds = new Set();
118
+ /** Messages queued to be included with the next user prompt as context ("asides"). */
119
+ this._pendingNextTurnMessages = [];
120
+ @@ -920,58 +924,58 @@
121
+ * `compaction.model` override ("provider/model"), that model is used for the
122
+ * summarization call instead of the session model — this is what lets an
123
+ * SDK-owned lane (anthropic-subscription) compact on a cheaper/different model.
124
+ - * Unset, malformed, unknown, spent, or credential-less overrides keep a usable session model;
125
+ - * a set override that cannot serve logs why the session model was used.
126
+ - * A spent account instead uses the held target or another authenticated model.
127
+ + * Auto selection stays within the active provider; explicit values never fall back.
128
+ + * Selection uses the availability snapshot. Authentication remains request-time.
129
+ */
130
+ - _resolveCompactionModel(sessionModel) {
131
+ - const override = this._getCompactionSettings().model;
132
+ - const slash = override?.indexOf("/") ?? -1;
133
+ - const configured = override && slash > 0
134
+ - ? this._modelRuntime.getModel(override.slice(0, slash), override.slice(slash + 1))
135
+ - : undefined;
136
+ - const usable = (model) => !this._retryFallback.isProviderSpent(model.provider);
137
+ - // Preserve the explicit override and original-model geometry on healthy sessions.
138
+ - // A held downswitch uses its target only when the original account cannot serve.
139
+ - if (usable(sessionModel)) {
140
+ - if (!override)
141
+ - return sessionModel;
142
+ - const fallbackReason = !configured
143
+ - ? slash > 0
144
+ - ? "unknown_model"
145
+ - : "malformed"
146
+ - : !usable(configured)
147
+ - ? "provider_spent"
148
+ - : this._modelRuntime.hasFreshAvailabilitySnapshot() &&
149
+ - !this._modelRuntime.hasConfiguredAuth(configured.provider)
150
+ - ? // Only a published availability pass can say "no credentials": before it (or
151
+ - // after a busy store read) the answer is unknown, the override is kept, and
152
+ - // request-time auth decides.
153
+ - "no_credentials"
154
+ - : undefined;
155
+ - if (configured && fallbackReason === undefined) {
156
+ - this._compactionModelFallbackNotice = undefined;
157
+ - return configured;
158
+ - }
159
+ - if (fallbackReason)
160
+ - this._logCompactionModelFallback(override, fallbackReason, sessionModel);
161
+ - return sessionModel;
162
+ - }
163
+ - const pending = this._pendingModelSwitch?.model;
164
+ - if (pending && usable(pending))
165
+ - return pending;
166
+ - if (configured && usable(configured))
167
+ + _resolveCompactionModel(sessionModel, inputTokens = 0) {
168
+ + const settings = this._getCompactionSettings();
169
+ + const override = settings.model;
170
+ + const explicit = override !== undefined && override !== "auto";
171
+ + const fail = (kind, model = sessionModel) => {
172
+ + throw Object.assign(new Error(
173
+ + explicit ? "Compaction model unavailable; check compaction.model or set it to auto."
174
+ + : "No usable compaction model within the active provider."),
175
+ + { failure: { kind, provider: model.provider, model: model.id } });
176
+ + };
177
+ + const chat = this._modelRuntime.getModels();
178
+ + const available = this._modelRuntime.getAvailableSnapshot();
179
+ + const same = (left, right) => left.provider === right.provider && left.id === right.id;
180
+ + const usable = (model) => chat.some((entry) => same(entry, model)) && (!available.some((entry) => entry.provider === model.provider) || available.some((entry) => same(entry, model))) &&
181
+ + !this._retryFallback.isProviderSpent(model.provider) &&
182
+ + this._modelRuntime.isFallbackEligible(model.provider);
183
+ + const fits = (model) => {
184
+ + const output = Math.min(32768, model.maxTokens > 0 ? model.maxTokens : 32768, Math.floor(model.contextWindow / 2));
185
+ + return model.contextWindow > 0 && inputTokens + Math.max(settings.reserveTokens, output) <= model.contextWindow;
186
+ + };
187
+ + if (explicit) {
188
+ + if (typeof override !== "string" || !/^[^/\s]+\/\S+$/.test(override))
189
+ + return fail("invalid_model");
190
+ + const slash = override.indexOf("/");
191
+ + const configured = this._modelRuntime.getModel(override.slice(0, slash), override.slice(slash + 1));
192
+ + if (!configured || !chat.some((model) => same(model, configured)))
193
+ + return fail("invalid_model");
194
+ + if (this._retryFallback.isProviderSpent(configured.provider))
195
+ + return fail("quota", configured);
196
+ + if (!usable(configured))
197
+ + return fail("model_unavailable", configured);
198
+ + if (!fits(configured))
199
+ + return fail("context_budget", configured);
200
+ return configured;
201
+ - const requiredContextWindow = estimateMessagesTokens(filterContextExcludedMessages(this._runtimeMessages())) +
202
+ - this._getCompactionSettings().reserveTokens;
203
+ - const available = this._modelRuntime
204
+ - .getModels()
205
+ - .find((model) => usable(model) &&
206
+ - model.contextWindow >= requiredContextWindow &&
207
+ - this._modelRuntime.hasConfiguredAuth(model.provider));
208
+ - if (available)
209
+ - return available;
210
+ - throw new AccountUsageLimitError(sessionModel.provider, this._retryFallback.accountError(sessionModel.provider));
211
+ + }
212
+ + if (this._retryFallback.isProviderSpent(sessionModel.provider))
213
+ + return fail("quota");
214
+ + const family = ["anthropic", "anthropic-subscription"].includes(sessionModel.provider) && /^claude-/.test(sessionModel.id) ? /^claude-sonnet-/
215
+ + : ["openai", "chatgpt-subscription"].includes(sessionModel.provider) && /^gpt-/.test(sessionModel.id) ? /^gpt-.*-luna(?:-fast)?$/ : undefined;
216
+ + const preferred = family ? available.filter((model) =>
217
+ + model.provider === sessionModel.provider && family.test(model.id)) : [];
218
+ + // Stable sort preserves registry order within the regular and fast Luna groups.
219
+ + preferred.sort((left, right) => Number(/-luna-fast$/.test(left.id)) - Number(/-luna-fast$/.test(right.id)));
220
+ + const candidates = [...(family?.test(sessionModel.id) ? [sessionModel] : []), ...preferred, sessionModel];
221
+ + for (const candidate of candidates) {
222
+ + if (candidate.provider === sessionModel.provider && usable(candidate) && fits(candidate))
223
+ + return candidate;
224
+ + }
225
+ + return fail(usable(sessionModel) ? "context_budget" : "model_unavailable");
226
+ }
227
+ _logCompactionModelFallback(override, reason, sessionModel) {
228
+ const notice = `${override}\u0000${reason}`;
229
+ @@ -985,11 +989,10 @@
230
+ });
231
+ }
232
+ async _getCompactionRequestAuth(model, signal, callbacks) {
233
+ - const auth = await this._getSummarizationRequestAuth(model, signal, callbacks);
234
+ - return {
235
+ - ...auth,
236
+ - extraBody: this._modelRuntime.getCompatibilityRequestConfig(model).extraBody,
237
+ - };
238
+ + const prepared = await retryTransientCall(() => this._modelRuntime.prepareRequest(model, { signal }),
239
+ + isOAuthRefreshUnavailableError, this.settingsManager.getRetrySettings(), signal,
240
+ + callbacks ?? this._summarizationRetryCallbacks({ source: "compaction" }));
241
+ + return { ...prepared.options, model: prepared.model, thinkingLevel: this.thinkingLevel };
242
+ }
243
+ /**
244
+ * The model whose limits apply to `message`, or undefined when the message came from another
245
+ @@ -1771,6 +1774,7 @@
246
+ steering: [...this._steeringMessages],
247
+ followUp: [...this._followUpMessages],
248
+ ordered: [...this._queuedInputOrder].sort((a, b) => a.enqueueOrder - b.enqueueOrder),
249
+ + queueEpoch: this.queueEpoch,
250
+ });
251
+ }
252
+ _incrementMessageRevision() {
253
+ @@ -5705,6 +5709,16 @@
254
+ this._nextQueuedId += 1;
255
+ return `q_${this._nextQueuedId}`;
256
+ }
257
+ + resumeQueuedInput(clientInputId, queueEpoch) {
258
+ + if (queueEpoch !== this.queueEpoch)
259
+ + return { disposition: "stale_epoch" };
260
+ + if (!this._queuedInputOrder.some((input) => input.clientInputId === clientInputId))
261
+ + return { disposition: "not_queued" };
262
+ + if (this.isStreaming || this.isCompacting)
263
+ + return { disposition: "busy" };
264
+ + this._scheduleContinuationAfterCurrentEvent({ retainedInput: { clientInputId, queueEpoch } });
265
+ + return { disposition: "scheduled" };
266
+ + }
267
+ /** A queued input reached a turn: forget its record and remember its id as delivered. */
268
+ _removeQueuedInput(text, mode, clientMessageId) {
269
+ const index = this._queuedInputOrder.findIndex((message) => clientMessageId === undefined
270
+ @@ -5843,6 +5857,7 @@
271
+ });
272
+ });
273
+ this._queueClearRecorded = recorded;
274
+ + this.queueEpoch = randomUUID();
275
+ this._postCompactionDeferredSteeringMessages = [];
276
+ this._postCompactionDeferredFollowUpMessages = [];
277
+ this.agent.clearAllQueues();
278
+ @@ -7016,9 +7031,22 @@
279
+ };
280
+ }
281
+ }
282
+ + const checkedStream = async (model, context, options) => {
283
+ + const stream = await this.agent.streamFunction(model, context, {
284
+ + ...options, refusalFallbacks: undefined, streamKind: "auxiliary",
285
+ + });
286
+ + return {
287
+ + [Symbol.asyncIterator]: () => stream[Symbol.asyncIterator](),
288
+ + result: async () => {
289
+ + const response = await stream.result();
290
+ + validateSummaryResponse(response);
291
+ + return response;
292
+ + },
293
+ + };
294
+ + };
295
+ return compact(preparation, requestModel, apiKey, headers, customInstructions, signal, extraBody,
296
+ // The routed thinking level under a virtual selection, the session's otherwise.
297
+ - thinkingLevel, this.agent.streamFunction, env, this.agent.transformContext, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason }), this.sessionManager.getSessionId(), cacheFriendly);
298
+ + thinkingLevel, checkedStream, env, this.agent.transformContext, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason }), this.sessionManager.getSessionId(), cacheFriendly);
299
+ }
300
+ /**
301
+ * Manually compact the session context.
302
+ @@ -7090,7 +7118,7 @@
303
+ willRetry: false,
304
+ });
305
+ if (!execution.accepted) {
306
+ - throw new CompactionRejectedError(execution.rejectionCause);
307
+ + throw new CompactionRejectedError(execution.rejectionCause, execution.failure);
308
+ }
309
+ outcome = "completed";
310
+ this._clearResumeCompactionRequirementIfFits();
311
+ @@ -7099,7 +7127,7 @@
312
+ catch (error) {
313
+ outcome = isCompactionExecutionAborted(error) ? "aborted" : "failed";
314
+ if (error instanceof CompactionRejectedError) {
315
+ - throw new Error(error.message);
316
+ + throw Object.assign(new Error(error.message), { failure: error.failure });
317
+ }
318
+ if (!compactionExecutionOwnsTerminalTransition(error)) {
319
+ throw error;
320
+ @@ -7157,6 +7185,7 @@
321
+ this._emit({
322
+ type: "compaction_start",
323
+ reason: options.reason,
324
+ + blocking: options.blocking,
325
+ requestId,
326
+ });
327
+ }
328
+ @@ -7169,6 +7198,7 @@
329
+ controller,
330
+ owner: "compaction",
331
+ reason: options.reason,
332
+ + blocking: options.blocking,
333
+ requestId,
334
+ willRetry: false,
335
+ precomputed,
336
+ @@ -7189,6 +7219,7 @@
337
+ this._emit({
338
+ type: "compaction_end",
339
+ reason: options.reason,
340
+ + blocking: options.blocking,
341
+ result: undefined,
342
+ aborted,
343
+ willRetry: false,
344
+ @@ -7254,11 +7285,12 @@
345
+ aborted,
346
+ willRetry: false,
347
+ requestId: operation.operationId,
348
+ + accepted: false,
349
+ + failure: aborted ? undefined : options.failure ?? { kind: "provider" },
350
+ errorMessage: aborted ? undefined : (options.errorMessage ?? "Compaction did not apply"),
351
+ });
352
+ this._releaseCompactionController(options.signal);
353
+ - if (!aborted)
354
+ - this._resumeQueuedMessagesAfterCompaction();
355
+ +
356
+ }
357
+ async _executeCompaction(request) {
358
+ const model = this.model;
359
+ @@ -7302,7 +7334,6 @@
360
+ }
361
+ throw new Error("Nothing to compact (session too small)");
362
+ }
363
+ - const compactionModel = this._resolveCompactionModel(model);
364
+ if (this._extensionRunner.hasHandlers("session_before_compact")) {
365
+ const messagesBeforeExtension = new Set(this.agent.state.messages);
366
+ const extensionResult = (await this._extensionRunner.emit({
367
+ @@ -7325,7 +7356,7 @@
368
+ throw new CompactionCancelledError();
369
+ }
370
+ if (extensionResult?.cancel) {
371
+ - return await this._rejectCompaction(request, requestId, operationId, extensionResult.rejectionCause ?? "cancelled-by-extension", true, extensionResult.reason);
372
+ + return await this._rejectCompaction(request, requestId, operationId, extensionResult.rejectionCause ?? "cancelled-by-extension", signal.aborted, extensionResult.reason, extensionResult.failure);
373
+ }
374
+ if (extensionResult?.compaction) {
375
+ compactionResult = extensionResult.compaction;
376
+ @@ -7333,13 +7364,17 @@
377
+ }
378
+ }
379
+ if (!compactionResult) {
380
+ - // A configured compaction.model override redirects only the
381
+ - // summarization call to that model; every other part of the session
382
+ - // (lifecycle record, preparation, branch) still tracks the session
383
+ - // model. Falls back to the session model when the override is unset
384
+ - // or cannot be resolved.
385
+ + // Size the complete source, including control text, before selecting a model.
386
+ + // The final serialized request is checked again at the dispatch boundary.
387
+ + const inputTokens = estimateMessagesTokens([...preparation.messagesToSummarize, ...preparation.turnPrefixMessages]) +
388
+ + Math.ceil(JSON.stringify([this.agent.state.systemPrompt, this.agent.state.tools,
389
+ + preparation.previousSummary, request.customInstructions]).length / 4) + 8192;
390
+ + const compactionModel = this._resolveCompactionModel(model, inputTokens);
391
+ const { model: requestModel, apiKey, headers, extraBody, env, thinkingLevel, } = await this._getCompactionRequestAuth(compactionModel, signal, this._summarizationRetryCallbacks({ source: "compaction", reason: request.reason }));
392
+ compactionResult = await this._runDefaultCompaction(preparation, requestModel, apiKey, headers, extraBody, request.customInstructions, signal, env, request.reason, thinkingLevel);
393
+ + compactionResult.details = { ...compactionResult.details,
394
+ + selectionMode: settings.model === undefined || settings.model === "auto" ? "auto" : "explicit",
395
+ + requestedProvider: compactionModel.provider, requestedModel: compactionModel.id };
396
+ }
397
+ }
398
+ if (signal.aborted) {
399
+ @@ -7366,24 +7401,20 @@
400
+ if (sourceChanged) {
401
+ return await this._rejectCompaction(request, requestId, operationId, "stale-revision", false);
402
+ }
403
+ - if (await this._wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, model)) {
404
+ + if (typeof compactionResult.summary !== "string" || !compactionResult.summary.trim())
405
+ + return await this._rejectCompaction(request, requestId, operationId, "cancelled-by-extension", false,
406
+ + "Compaction summary body is empty", { kind: "empty" });
407
+ + const fitModel = this._limitsModel() ?? model;
408
+ + if (await this._wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, fitModel)) {
409
+ return await this._rejectCompaction(request, requestId, operationId, "would-overflow", false);
410
+ }
411
+ - const latestConfigurationEffort = this.sessionManager
412
+ - .getBranch()
413
+ - .findLast((entry) => entry.type === "configuration_update")?.reasoning.effort;
414
+ - const compactionEntryId = this.sessionManager.appendCompaction(compactionResult.summary, compactionResult.firstKeptEntryId, compactionResult.tokensBefore, compactionResult.details, fromExtension, compactionResult.usage);
415
+ - const savedEntry = this.sessionManager.getEntry(compactionEntryId);
416
+ - if (savedEntry?.type !== "compaction") {
417
+ - throw new Error("Compaction entry was not saved");
418
+ - }
419
+ - const modelAfterCompaction = this.model;
420
+ - if (latestConfigurationEffort !== undefined &&
421
+ - modelAfterCompaction !== undefined &&
422
+ - supportsConfigurationUpdate(modelAfterCompaction)) {
423
+ - this.sessionManager.appendConfigurationUpdate(latestConfigurationEffort);
424
+ - }
425
+ - const sessionContext = this.sessionManager.buildSessionContext();
426
+ + if (signal.aborted)
427
+ + throw new CompactionCancelledError();
428
+ + if (this._limitsModel() !== fitModel || this._messageRevision !== lifecycleState.startedRevision + compactionOwnedDiagnosticMessages.size ||
429
+ + !agentMessagesAtStart.every((message, index) => this.agent.state.messages[index] === message) ||
430
+ + !this.agent.state.messages.slice(agentMessagesAtStart.length).every((message) =>
431
+ + this._messageEndsAwaitingPersistence.has(message) || compactionOwnedDiagnosticMessages.has(message)))
432
+ + return await this._rejectCompaction(request, requestId, operationId, "stale-revision", false);
433
+ const currentAgentMessages = this.agent.state.messages;
434
+ const hasUnchangedPrefix = agentMessagesAtStart.every((message, index) => currentAgentMessages[index] === message);
435
+ const messagesAppendedDuringCompaction = hasUnchangedPrefix
436
+ @@ -7400,6 +7431,24 @@
437
+ }
438
+ }
439
+ const preservedPendingMessages = currentAgentMessages.filter((message) => preservedIdentities.delete(message));
440
+ + if (preservedPendingMessages.length > 0 &&
441
+ + wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, fitModel, this._getCompactionSettings(), preservedPendingMessages))
442
+ + return await this._rejectCompaction(request, requestId, operationId, "would-overflow", false);
443
+ + const latestConfigurationEffort = this.sessionManager
444
+ + .getBranch()
445
+ + .findLast((entry) => entry.type === "configuration_update")?.reasoning.effort;
446
+ + const compactionEntryId = this.sessionManager.appendCompaction(compactionResult.summary, compactionResult.firstKeptEntryId, compactionResult.tokensBefore, compactionResult.details, fromExtension, compactionResult.usage);
447
+ + const savedEntry = this.sessionManager.getEntry(compactionEntryId);
448
+ + if (savedEntry?.type !== "compaction") {
449
+ + throw new Error("Compaction entry was not saved");
450
+ + }
451
+ + const modelAfterCompaction = this.model;
452
+ + if (latestConfigurationEffort !== undefined &&
453
+ + modelAfterCompaction !== undefined &&
454
+ + supportsConfigurationUpdate(modelAfterCompaction)) {
455
+ + this.sessionManager.appendConfigurationUpdate(latestConfigurationEffort);
456
+ + }
457
+ + const sessionContext = this.sessionManager.buildSessionContext();
458
+ this._skipNextPostCompactionAssistantCheck = true;
459
+ for (const message of preservedPendingMessages) {
460
+ if (message.role === "assistant") {
461
+ @@ -7407,7 +7456,7 @@
462
+ }
463
+ }
464
+ this.agent.state.messages = [...sessionContext.messages, ...preservedPendingMessages];
465
+ - compactionResult.estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
466
+ + compactionResult.estimatedTokensAfter = estimateMessagesTokens(this.agent.state.messages);
467
+ this._incrementMessageRevision();
468
+ if (!this._compactionLifecycle.finish({
469
+ operationId,
470
+ @@ -7423,6 +7472,7 @@
471
+ this._emit({
472
+ type: "compaction_end",
473
+ reason: request.reason,
474
+ + blocking: request.blocking,
475
+ result: compactionResult,
476
+ aborted: false,
477
+ willRetry: request.willRetry,
478
+ @@ -7456,15 +7506,10 @@
479
+ }
480
+ const lifecycleState = this._compactionLifecycle.state;
481
+ const ownsTerminalTransition = lifecycleState.status !== "idle" && lifecycleState.operationId === operationId;
482
+ - const aborted = signal.aborted || isCompactionExecutionAborted(error);
483
+ - if (ownsTerminalTransition && lifecycleState.status === "running") {
484
+ - this._compactionLifecycle.finish({
485
+ - operationId,
486
+ - status: aborted ? "aborted" : "failed",
487
+ - endedRevision: this._messageRevision,
488
+ - errorMessage: error instanceof Error ? error.message : String(error),
489
+ - });
490
+ - }
491
+ + const aborted = signal.aborted;
492
+ + if (ownsTerminalTransition && lifecycleState.status === "running")
493
+ + return await this._rejectCompaction(request, requestId, operationId, "cancelled-by-extension", aborted,
494
+ + aborted ? undefined : "Compaction failed; previous context retained", aborted ? undefined : compactionFailure(error, model));
495
+ throw new CompactionExecutionError(error, ownsTerminalTransition, aborted);
496
+ }
497
+ finally {
498
+ @@ -7512,7 +7557,8 @@
499
+ }
500
+ this.agent.state.messages = [...sessionMessages, ...pendingMessages];
501
+ }
502
+ - async _rejectCompaction(request, requestId, operationId, rejectionCause, aborted, extensionReason) {
503
+ + async _rejectCompaction(request, requestId, operationId, rejectionCause, aborted, extensionReason, failure) {
504
+ + failure = aborted || rejectionCause === "external-owner" ? undefined : failure ?? { kind: rejectionCause === "stale-revision" ? "stale" : rejectionCause === "would-overflow" ? "context_budget" : "provider" };
505
+ // Per plan Section 1: rejection must never be silent. The compaction_end event
506
+ // carries a non-empty human-readable errorMessage (unless the user aborted, where
507
+ // the aborted branch already renders "Compaction cancelled"). session_compact is
508
+ @@ -7529,7 +7575,7 @@
509
+ const errorMessage = aborted && !trimmedExtensionReason ? undefined : detailedMessage;
510
+ if (!this._compactionLifecycle.finish({
511
+ operationId,
512
+ - status: "failed",
513
+ + status: aborted ? "aborted" : "failed",
514
+ endedRevision: this._messageRevision,
515
+ rejectionCause,
516
+ ...(trimmedExtensionReason !== undefined ? { errorMessage: trimmedExtensionReason } : {}),
517
+ @@ -7539,12 +7585,14 @@
518
+ this._emit({
519
+ type: "compaction_end",
520
+ reason: request.reason,
521
+ + blocking: request.blocking,
522
+ result: undefined,
523
+ aborted,
524
+ willRetry: false,
525
+ requestId,
526
+ accepted: false,
527
+ rejectionCause,
528
+ + failure,
529
+ errorMessage,
530
+ });
531
+ await this._extensionRunner.emit({
532
+ @@ -7553,10 +7601,11 @@
533
+ requestId,
534
+ accepted: false,
535
+ rejectionCause,
536
+ + failure,
537
+ fromExtension: false,
538
+ willRetry: false,
539
+ });
540
+ - return { accepted: false, requestId, rejectionCause };
541
+ + return { accepted: false, requestId, rejectionCause, aborted, failure };
542
+ }
543
+ /**
544
+ * Cancel in-progress compaction (manual or auto).
545
+ @@ -8138,6 +8187,23 @@
546
+ this._transcriptWriteFailures.startRun();
547
+ await runBoundedRetryContinuation({
548
+ continueRun: async () => {
549
+ + if (options.retainedInput) {
550
+ + const { retainedInput, ...continuationOptions } = options;
551
+ + const disposition = retainedInput.queueEpoch !== this.queueEpoch
552
+ + ? "stale_epoch"
553
+ + : await this.agent.continueQueuedInput(retainedInput.clientInputId, {
554
+ + ...continuationOptions,
555
+ + onStart: () => {
556
+ + this._isAgentRunActive = true;
557
+ + },
558
+ + });
559
+ + this._emit({
560
+ + type: "queued_input_resume",
561
+ + clientInputId: retainedInput.clientInputId,
562
+ + disposition,
563
+ + });
564
+ + return;
565
+ + }
566
+ if (this._continueWithQueuedInput) {
567
+ // Joined input of a start that never ran: the transcript tail may be anything, even empty.
568
+ this._continueWithQueuedInput = false;
569
+ @@ -8395,6 +8461,8 @@
570
+ willRetry: false,
571
+ requestId,
572
+ errorMessage: formattedErrorMessage,
573
+ + accepted: false,
574
+ + failure: aborted ? undefined : compactionFailure(error, model),
575
+ });
576
+ await this._emitSessionCompactFailed({
577
+ reason,
578
+ @@ -8718,9 +8786,10 @@
579
+ },
580
+ getContextUsage: () => this.getContextUsage(),
581
+ getCompactionSettings: () => this._getCompactionSettings(),
582
+ - // One resolver for core and builtin compaction: a configured `compaction.model`
583
+ - // summarizes on a healthy provider too, not only after the account is spent (#830).
584
+ - getCompactionModel: () => (this.model ? this._resolveCompactionModel(this.model) : undefined),
585
+ + getCompactionModel: (inputTokens) => {
586
+ + const model = this._limitsModel();
587
+ + return model && this._resolveCompactionModel(model, inputTokens);
588
+ + },
589
+ getPromptCacheSafeWaitSeconds: () => this.resolvePromptCacheSafeWaitSeconds(),
590
+ getPromptCacheGoalBackstopMaxSeconds: () => this.settingsManager.getPromptCacheGoalBackstopMaxSeconds(),
591
+ getPromptCacheKeepAliveSettings: () => this.settingsManager.getPromptCacheKeepAliveSettings(),
592
+ @@ -10818,4 +10887,4 @@
593
+ const requestContext = usage?.contextUsage;
594
+ return `${size}:${usage?.input ?? ""}:${usage?.output ?? ""}:${usage?.totalTokens ?? ""}:${requestContext?.input}:${requestContext?.output}:${requestContext === null}`;
595
+ }
596
+ -//# sourceMappingURL=agent-session.js.map
597
+
598
+ +//# sourceMappingURL=agent-session.js.map
599
+ --- a/@code-yeongyu/senpi/dist/core/compaction/compaction.js
600
+ +++ b/@code-yeongyu/senpi/dist/core/compaction/compaction.js
601
+ @@ -636,8 +636,13 @@
602
+ onPayload: withoutServerSearch(options.onPayload, options.cacheRetention),
603
+ cacheRetention: options.cacheRetention ?? "none",
604
+ affinitySessionId: options.affinitySessionId ?? options.sessionId,
605
+ + streamKind: "auxiliary",
606
+ sessionId: uuidv7(),
607
+ };
608
+ + if (model.contextWindow <= 0 || estimateProviderContextTokens(context).tokens + options.maxTokens > model.contextWindow) {
609
+ + throw Object.assign(new Error("Compaction request exceeds the selected model context budget"),
610
+ + { failure: { kind: "context_budget", provider: model.provider, model: model.id } });
611
+ + }
612
+ const callerSignal = options.signal;
613
+ // The 120s floor was tuned for healthy summaries; a large session feeds the
614
+ // summarizer hundreds of thousands of tokens, which legitimately takes longer
615
+ @@ -946,4 +951,4 @@
616
+ // Main compaction function
617
+ // ============================================================================
618
+ export { compact } from "./compaction-execution.js";
619
+ -//# sourceMappingURL=compaction.js.map
620
+
621
+ +//# sourceMappingURL=compaction.js.map
622
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-pipeline.js
623
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-pipeline.js
624
+ @@ -1,64 +1,21 @@
625
+ import { convertToLlm } from "../../../messages.js";
626
+ -import { BUILTIN_CONTEXT_REDUCTION_OPTIONS, reduceContextMessages } from "./context-reduction.js";
627
+ -import { reduceContextWithFrontier } from "./context-reduction-frontier.js";
628
+ import { createContextReductionState } from "./context-reduction-state.js";
629
+ import { markOpenAiRemoteReplayBoundary } from "./openai-remote.js";
630
+ -import { isOpenAiRemoteCompactionModel } from "./openai-remote-model.js";
631
+ -import { admitContextToolResults, injectTokenBudgetReminder } from "./orchestration.js";
632
+ +import { injectTokenBudgetReminder } from "./orchestration.js";
633
+ import { estimateTotalTokens } from "./overflow-retry.js";
634
+ import { repairOrphanedToolResults } from "./repair-tool-pairs.js";
635
+ -import { hardLimitEmergencyPrune } from "./speculative.js";
636
+ export { createContextReductionState, readContextReductionState } from "./context-reduction-state.js";
637
+ export function buildCompactionContext(input) {
638
+ if (input.laneOwnsCompaction) {
639
+ return repairOrphanedToolResults(convertToLlm(input.event.messages));
640
+ }
641
+ - const admittedMessages = admitContextToolResults(input.event.messages, input.contextWindow, input.toolAdmissionEnabled);
642
+ const state = input.contextReductionState ?? createContextReductionState();
643
+ - const native = isOpenAiRemoteCompactionModel(input.ctx.model);
644
+ - const managed = input.appendOnlyTranscript !== true && !native;
645
+ - const sourceMessages = managed
646
+ - ? reduceContextWithFrontier(admittedMessages, state, {
647
+ - unreducedMessages: input.event.messages,
648
+ - contextWindow: input.contextWindow,
649
+ - ceilingTokens: input.reductionCeilingTokens ?? input.promptContextWindow,
650
+ - overheadTokens: input.contextOverheadTokens ?? 0,
651
+ - blockBudgetRatio: input.contextReductionBlockBudgetRatio,
652
+ - force: input.breakerFallback,
653
+ - })
654
+ - : native && input.breakerFallback && input.appendOnlyTranscript !== true
655
+ - ? reduceContextMessages(admittedMessages, BUILTIN_CONTEXT_REDUCTION_OPTIONS).messages
656
+ - : admittedMessages;
657
+ - const emergency = input.laneOwnsCompaction
658
+ - ? { messages: sourceMessages, needsAggressiveCompaction: false }
659
+ - : hardLimitEmergencyPrune(sourceMessages, managed && state.engaged
660
+ - ? Math.max(0, input.promptContextWindow - (input.contextOverheadTokens ?? 0))
661
+ - : input.promptContextWindow, input.emergencyPruneLatch);
662
+ - // This is the ONE site that actually prunes, so the emergency_prune counter must be
663
+ - // emitted here. Engagement shows up either as a rewritten message array or as
664
+ - // needsAggressiveCompaction when nothing prunable remained.
665
+ - if (!input.laneOwnsCompaction && (emergency.needsAggressiveCompaction || emergency.messages !== sourceMessages)) {
666
+ - input.logEmergencyPrune?.({
667
+ - route: "context-event",
668
+ - tokensBefore: estimateTotalTokens(sourceMessages),
669
+ - tokens: estimateTotalTokens(emergency.messages),
670
+ - });
671
+ - }
672
+ - const marked = markOpenAiRemoteReplayBoundary(emergency.messages, {
673
+ - model: input.ctx.model,
674
+ - branchEntries: input.ctx.sessionManager.getBranch(),
675
+ + const marked = markOpenAiRemoteReplayBoundary(input.event.messages, {
676
+ + model: input.ctx.model, branchEntries: input.ctx.sessionManager.getBranch(),
677
+ });
678
+ - const reminded = injectTokenBudgetReminder(marked, input.reminder);
679
+ - const outgoing = repairOrphanedToolResults(convertToLlm(reminded));
680
+ - const overhead = input.contextOverheadTokens ?? 0;
681
+ - const beforePrune = estimateTotalTokens(sourceMessages) + overhead;
682
+ - const afterPrune = estimateTotalTokens(outgoing) + overhead;
683
+ - if (managed &&
684
+ - (state.engaged || emergency.messages !== sourceMessages) &&
685
+ - (emergency.needsAggressiveCompaction ||
686
+ - afterPrune > input.promptContextWindow ||
687
+ - (afterPrune < beforePrune && input.emergencyPruneLatch.engaged)))
688
+ - state.compactionRequired = true;
689
+ + const outgoing = repairOrphanedToolResults(convertToLlm(injectTokenBudgetReminder(marked, input.reminder)));
690
+ + const ceiling = input.reductionCeilingTokens ?? input.promptContextWindow;
691
+ + state.compactionRequired = estimateTotalTokens(outgoing) + (input.contextOverheadTokens ?? 0) > ceiling;
692
+ return outgoing;
693
+ }
694
+ -//# sourceMappingURL=context-pipeline.js.map
695
+
696
+ +//# sourceMappingURL=context-pipeline.js.map
697
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-handoff.js
698
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-handoff.js
699
+ @@ -17,7 +17,7 @@
700
+ customInstructions,
701
+ onError: () => {
702
+ if (active === request)
703
+ - active = undefined;
704
+ + ctx.abort("system");
705
+ },
706
+ onComplete: () => {
707
+ if (active !== request)
708
+ @@ -37,4 +37,4 @@
709
+ },
710
+ };
711
+ }
712
+ -//# sourceMappingURL=context-reduction-handoff.js.map
713
+
714
+ +//# sourceMappingURL=context-reduction-handoff.js.map
715
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-lifecycle.js
716
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-lifecycle.js
717
+ @@ -46,6 +46,7 @@
718
+ pi.on("session_start", resetForNavigation);
719
+ pi.on("session_tree", resetForNavigation);
720
+ pi.on("session_shutdown", resetForNavigation);
721
+ + pi.on("before_agent_start", () => handoff.reset());
722
+ pi.on("message_end", (event, ctx) => {
723
+ const request = pending;
724
+ if (!request || sessionId !== ctx.sessionManager.getSessionId() || event.message.role !== "assistant")
725
+ @@ -108,7 +109,7 @@
726
+ emergencyPruneLatch: live ? options.emergencyPruneLatch : { ...options.emergencyPruneLatch },
727
+ logEmergencyPrune: (fields) => options.logEmergencyPrune(ctx, fields),
728
+ });
729
+ - const managed = !policy.laneOwnsCompaction && !policy.appendOnlyTranscript && !isOpenAiRemoteCompactionModel(ctx.model);
730
+ + const managed = !policy.laneOwnsCompaction;
731
+ if (!live)
732
+ return { messages };
733
+ if (!managed) {
734
+ @@ -120,7 +121,10 @@
735
+ persist(before);
736
+ // Never dispatch a prompt that is legal only because pruning changed
737
+ // its lineage. Only an accepted, current handoff can resume work.
738
+ - handoff.request(ctx);
739
+ + if (policy.breakerTripped)
740
+ + ctx.abort("system");
741
+ + else
742
+ + handoff.request(ctx);
743
+ }
744
+ else {
745
+ persist(before);
746
+ @@ -135,4 +139,4 @@
747
+ }, { mutatesMessages: false });
748
+ return { reset };
749
+ }
750
+ -//# sourceMappingURL=context-reduction-lifecycle.js.map
751
+
752
+ +//# sourceMappingURL=context-reduction-lifecycle.js.map
753
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/deterministic-fallback.js
754
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/deterministic-fallback.js
755
+ @@ -98,6 +98,8 @@
756
+ return error instanceof SummaryRequestError && !error.transient && !error.refused && error.failureKind === undefined;
757
+ }
758
+ export function classifyRequiredCompactionFallbackFailure(error) {
759
+ + if (error instanceof SummaryRequestError && error.refused)
760
+ + return undefined;
761
+ if (error instanceof UnsafeRetainedSuffixError)
762
+ return "unsafe-retained-content";
763
+ if (error instanceof StreamDurationBudgetError ||
764
+ @@ -448,4 +450,4 @@
765
+ diagnostics.candidatesChecked = candidateCount;
766
+ return undefined;
767
+ }
768
+ -//# sourceMappingURL=deterministic-fallback.js.map
769
+
770
+ +//# sourceMappingURL=deterministic-fallback.js.map
771
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/emergency-prune.js
772
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/emergency-prune.js
773
+ @@ -53,13 +53,6 @@
774
+ if (!engaged) {
775
+ return { messages, needsAggressiveCompaction: false };
776
+ }
777
+ - const noLlmPruned = truncateContextMessages(pruneToolResults(messages, contextWindow, SUMMARIZATION_INPUT_BUDGET_RATIO));
778
+ - if (estimateTotalTokens(noLlmPruned) <= targetTokens) {
779
+ - return { messages: noLlmPruned, needsAggressiveCompaction: false };
780
+ - }
781
+ - return {
782
+ - messages: pruneOldMessagesToBudget(noLlmPruned, targetTokens),
783
+ - needsAggressiveCompaction: true,
784
+ - };
785
+ + return { messages, needsAggressiveCompaction: true };
786
+ }
787
+ -//# sourceMappingURL=emergency-prune.js.map
788
+
789
+ +//# sourceMappingURL=emergency-prune.js.map
790
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/extension-wiring.js
791
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/extension-wiring.js
792
+ @@ -63,7 +63,8 @@
793
+ const errorMessage = signal?.aborted || parts.length === 0
794
+ ? undefined
795
+ : `Compaction did not apply: ${parts.join("; local fallback ")}`;
796
+ - ctx.endCompaction?.({ reason: "extension", signal, aborted: signal?.aborted, errorMessage });
797
+ + ctx.endCompaction?.({ reason: "extension", signal, aborted: signal?.aborted, errorMessage,
798
+ + failure: signal?.aborted ? undefined : { kind: result.reason === "stale" ? "stale" : "provider" } });
799
+ }
800
+ }
801
+ /**
802
+ @@ -101,4 +102,4 @@
803
+ signal,
804
+ };
805
+ }
806
+ -//# sourceMappingURL=extension-wiring.js.map
807
+
808
+ +//# sourceMappingURL=extension-wiring.js.map
809
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/index.js
810
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/index.js
811
+ @@ -1,12 +1,9 @@
812
+ import { createWarmAnchorSnapshot, isWarmSummaryAnchorValid } from "../../../compaction/warm-anchor.js";
813
+ -import { AccountUsageLimitError } from "../../../retry-fallback/usage-limit.js";
814
+ import * as checkpointState from "./checkpoint-state.js";
815
+ import * as breaker from "./circuit-breaker.js";
816
+ import { registerContextReductionLifecycle } from "./context-reduction-lifecycle.js";
817
+ import { createDegradationMonitorState, handleMessageEnd, handleTurnEnd, RECOVERY_INSTRUCTIONS, resetOnSessionCompact, } from "./degradation-monitor.js";
818
+ -import { classifyRequiredCompactionFallbackFailure, createRequiredCompactionFallback, formatRequiredCompactionFallbackNotice, formatRequiredCompactionFallbackRejection, stripTurnRetrySuppressionPrefix, UnsafeRetainedSuffixError, } from "./deterministic-fallback.js";
819
+ import * as idle from "./idle.js";
820
+ -import * as idleRetry from "./idle-retry.js";
821
+ import { ANTHROPIC_SUBSCRIPTION_COMPACT_ENTRY_TYPE, collectCompactBoundaryEntries, createCompactionLanePolicy, SDK_NATIVE_LANE_REJECTION_REASON, } from "./lane-policy.js";
822
+ import { createCompactionLogger } from "./log.js";
823
+ import { handleCompactionModelSelect } from "./model-selection.js";
824
+ @@ -17,7 +14,7 @@
825
+ import * as policy from "./policy.js";
826
+ import { compactionRecoveryStateKey, isAutomaticCompactionBlocked, REJECTED_RECOVERY_ENTRY, wouldCompactionOverflow, } from "./rejected-recovery.js";
827
+ import * as restoration from "./restoration-tracker.js";
828
+ -import { applyGeneratedCompaction, createEmergencyPruneLatch, createSpeculativeCompactionSnapshot, getPromptVariant, runExtensionCompaction, SummaryGenerationError, } from "./speculative.js";
829
+ +import { applyGeneratedCompaction, createEmergencyPruneLatch, createSpeculativeCompactionSnapshot, getPromptVariant, runExtensionCompaction, SummaryGenerationError, compactionFailure, } from "./speculative.js";
830
+ import { trackSpeculativeJob } from "./speculative-job.js";
831
+ import { createInitialState, resetTurnCounter } from "./state.js";
832
+ import { resolveInheritedTaskIntent } from "./task-intent.js";
833
+ @@ -60,7 +57,6 @@
834
+ }
835
+ }
836
+ let idleWarmupTimer;
837
+ - let idleWarmupAttempt = 0;
838
+ // True from the `agent_end` idle trigger until the next turn starts (or the
839
+ // session shuts down). The idle-apply watcher below is fenced on it so a
840
+ // summary that lands after the user has already prompted is never applied
841
+ @@ -95,68 +91,6 @@
842
+ // Fenced on the observed job: a prompt, invalidation, a reload, or a newer
843
+ // warm-up stands this watcher down before it can start a duplicate
844
+ // summarization.
845
+ - function armIdleWarmupRetry(ctx) {
846
+ - const job = speculativeJob;
847
+ - if (!job)
848
+ - return;
849
+ - void job.failure.then((failure) => {
850
+ - if (failure === undefined) {
851
+ - idleWarmupAttempt = 0;
852
+ - return;
853
+ - }
854
+ - if (speculativeJob !== job)
855
+ - return;
856
+ - // A reload between arming and this continuation retires the context.
857
+ - if (isContextRetired(ctx)) {
858
+ - cancelIdleWarmupRetry();
859
+ - return;
860
+ - }
861
+ - const usage = ctx.getContextUsage();
862
+ - const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
863
+ - const settings = ctx.getCompactionSettings();
864
+ - const breakerTripped = breaker.isTripped(state, Date.now());
865
+ - const retryDecision = {
866
+ - attempt: idleWarmupAttempt,
867
+ - transient: isTransientSummarizationFailure(failure, failure.message),
868
+ - isIdle: ctx.isIdle(),
869
+ - breakerTripped,
870
+ - stillWarmEligible: idle.shouldWarmAtIdle({
871
+ - willRetry: false,
872
+ - aborted: false,
873
+ - settings,
874
+ - usage,
875
+ - contextWindow,
876
+ - breakerTripped,
877
+ - lastYield: state.lastYield ?? undefined,
878
+ - mode: ctx.mode,
879
+ - }),
880
+ - };
881
+ - if (!idleRetry.shouldRetryIdleWarmup(retryDecision))
882
+ - return;
883
+ - cancelIdleWarmupRetry();
884
+ - idleWarmupTimer = setTimeout(() => {
885
+ - idleWarmupTimer = undefined;
886
+ - if (speculativeJob !== job)
887
+ - return;
888
+ - // The reload may land after the timer was armed; a throw here would
889
+ - // escape into the timer queue as an uncaughtException.
890
+ - if (isContextRetired(ctx))
891
+ - return;
892
+ - if (!ctx.isIdle())
893
+ - return;
894
+ - idleWarmupAttempt += 1;
895
+ - getLogger(ctx).debug("idle_trigger", {
896
+ - contextWindow,
897
+ - tokens: usage?.tokens ?? 0,
898
+ - count: idleWarmupAttempt,
899
+ - });
900
+ - invalidateSpeculativeCompaction(ctx);
901
+ - startSpeculativeCompaction(ctx, idle.IDLE_COMPACTION_INSTRUCTIONS);
902
+ - armIdleWarmupRetry(ctx);
903
+ - armIdleApply(ctx);
904
+ - }, idleRetry.IDLE_WARMUP_RETRY_DELAY_MS);
905
+ - });
906
+ - }
907
+ /**
908
+ * Apply the idle warm summary as soon as it finishes generating, while the
909
+ * session is still idle. Holding it warm until the next `before_agent_start`
910
+ @@ -201,7 +135,7 @@
911
+ !policy.shouldTriggerCompaction(usage, contextWindow, ctx.getCompactionSettings(), state.lastYield ?? undefined)) {
912
+ return;
913
+ }
914
+ - const result = await applyGeneratedCompaction(ctx, job.snapshot, () => speculativeGeneration, compaction);
915
+ + const result = await applyGeneratedCompaction(ctx, job.snapshot, () => speculativeGeneration, compaction, undefined, false);
916
+ if (!result.applied)
917
+ return;
918
+ // The job is consumed: clearing it here is also what stands the idle
919
+ @@ -300,388 +234,122 @@
920
+ todoBridge.persistTodoSnapshot(pi, metadata.todoSnapshot);
921
+ }
922
+ async function generateCompaction(ctx, snapshot, signal, onProgress) {
923
+ - const compaction = await runExtensionCompaction(ctx, snapshot, signal, onProgress);
924
+ - if (!compaction || signal?.aborted)
925
+ - return compaction;
926
+ - if (!wouldCompactionOverflow(snapshot.branchEntries ?? [], compaction, true, snapshot.model, snapshot.preparation.settings))
927
+ - return compaction;
928
+ - if (!wouldCompactionOverflow(snapshot.branchEntries ?? [], { ...compaction, summary: "" }, true, snapshot.model, snapshot.preparation.settings))
929
+ - return compaction;
930
+ - // #3061: only a SUCCESSFUL summary can establish a terminal context fault.
931
+ - // A failed summary plus a rejected emergency checkpoint says nothing about
932
+ - // whether a recovered provider could produce a smaller, usable summary.
933
+ - // Probe with zero summary text so marker/previous-summary overhead alone
934
+ - // cannot turn a provider failure or an oversized summary into a durable latch.
935
+ - const diagnostics = {};
936
+ - const viable = createRequiredCompactionFallback(snapshot.preparation, snapshot.contextWindow, "unsafe-retained-content", { summary: "" }, snapshot.branchEntries, diagnostics);
937
+ - if (!viable &&
938
+ - (diagnostics.rejectionReason === "unsafe-retained-content" ||
939
+ - diagnostics.rejectionReason === "atomic-tool-chain-cut" ||
940
+ - diagnostics.rejectionReason === "retained-token-budget-exceeded")) {
941
+ - throw new UnsafeRetainedSuffixError(diagnostics);
942
+ - }
943
+ - return compaction;
944
+ + return await runExtensionCompaction(ctx, snapshot, signal, onProgress);
945
+ }
946
+ - function recoverRequiredCompaction(ctx, snapshot, failureKind, cause) {
947
+ - const terminal = cause instanceof UnsafeRetainedSuffixError;
948
+ - const diagnostics = terminal ? cause.diagnostics : {};
949
+ - const compaction = terminal
950
+ - ? undefined
951
+ - : createRequiredCompactionFallback(snapshot.preparation, snapshot.contextWindow, failureKind, { taskIntent: resolveInheritedTaskIntent(snapshot.branchEntries ?? []) }, snapshot.branchEntries, diagnostics);
952
+ - if (!compaction) {
953
+ - const branch = snapshot.branchEntries ?? [];
954
+ - // Provider/timeout/abort/empty-summary failures never create or replace
955
+ - // this record, even when their opportunistic deterministic fallback fails.
956
+ - if (terminal && !isAutomaticCompactionBlocked(branch, snapshot.model, ctx.getCompactionSettings())) {
957
+ - pi.appendEntry(REJECTED_RECOVERY_ENTRY, {
958
+ - failureKind: "unsafe-retained-content",
959
+ - stateKey: compactionRecoveryStateKey(branch, snapshot.model, ctx.getCompactionSettings()),
960
+ - reason: diagnostics.rejectionReason,
961
+ - entryId: diagnostics.candidateRejections?.at(-1)?.unsafeEntryId ??
962
+ - diagnostics.candidateRejections?.at(-1)?.firstKeptEntryId,
963
+ - });
964
+ - }
965
+ - return { rejectionReason: formatRequiredCompactionFallbackRejection(diagnostics, terminal) };
966
+ - }
967
+ - // The compaction itself succeeds, so nothing else tells the user their
968
+ - // transcript was reduced without a provider summary. Say it plainly here,
969
+ - // and never with the internal retry-suppression marker (#1741).
970
+ - ctx.ui.notify(formatRequiredCompactionFallbackNotice(failureKind, cause), "warning");
971
+ - return { compaction };
972
+ - }
973
+ async function applyBlockingCompaction(ctx, customInstructions) {
974
+ - if (isAutomaticCompactionBlocked(ctx.sessionManager.getBranch(), ctx.model, ctx.getCompactionSettings())) {
975
+ - return { applied: false, reason: "rejected" };
976
+ - }
977
+ - const provider = ctx.model?.provider;
978
+ - if ((provider === "cursor" || provider === "cursor-cli-oauth") && !ctx.isIdle()) {
979
+ - getLogger(ctx).debug("skip_cursor_mid_turn", { route: "blocking" });
980
+ - return { applied: false, reason: "rejected" };
981
+ - }
982
+ - if (breaker.isTripped(state, Date.now())) {
983
+ - getLogger(ctx).debug("skip_breaker", { route: "blocking" });
984
+ - return { applied: false, reason: "rejected" };
985
+ - }
986
+ - if (cap.shouldRejectByCap(state).cancel) {
987
+ - getLogger(ctx).debug("skip_cap", { route: "blocking" });
988
+ - return { applied: false, reason: "rejected" };
989
+ - }
990
+ - let feedbackSignal = ctx.beginCompaction?.({ reason: "extension" });
991
+ + const feedbackSignal = ctx.beginCompaction?.({ reason: "extension" });
992
+ try {
993
+ - let remoteFallbackReason;
994
+ - if (isOpenAiRemoteCompactionModel(ctx.model) && (ctx.getCompactionModel?.() ?? ctx.model) === ctx.model) {
995
+ - const remoteGeneration = speculativeGeneration + 1;
996
+ - const remoteSnapshot = createSpeculativeCompactionSnapshot(ctx, {
997
+ - generation: remoteGeneration,
998
+ - customInstructions,
999
+ - origin: "blocking",
1000
+ - });
1001
+ - getLogger(ctx).debug("blocking_started", { generation: remoteGeneration, origin: "blocking" });
1002
+ - if (remoteSnapshot) {
1003
+ - const remoteSignal = feedbackSignal ?? new AbortController().signal;
1004
+ - const remoteCompaction = await runOpenAiRemoteCompaction(ctx, createBlockingRemoteCompactionEvent(ctx, remoteSnapshot, customInstructions, remoteSignal), (data) => {
1005
+ - if (data?.action === "remote_fallback" && typeof data.reason === "string") {
1006
+ - remoteFallbackReason = data.reason;
1007
+ - }
1008
+ - reportRemoteCompactionTimeout(ctx, "extension", feedbackSignal, data);
1009
+ - pi.events.emit(SENPI_COMPACTION_EVENT, data);
1010
+ - }, remoteCompactionDependencies);
1011
+ - if (remoteCompaction) {
1012
+ - if (speculativeGeneration !== remoteGeneration - 1) {
1013
+ - const result = { applied: false, reason: "stale" };
1014
+ - getLogger(ctx).debug("speculative_stale", { generation: remoteGeneration });
1015
+ - endCompactionFeedback(ctx, feedbackSignal, result);
1016
+ - return result;
1017
+ - }
1018
+ - speculativeGeneration = remoteGeneration;
1019
+ - speculativeJob?.controller.abort();
1020
+ - speculativeJob = undefined;
1021
+ - const result = await applyGeneratedCompaction(ctx, remoteSnapshot, () => speculativeGeneration, remoteCompaction, feedbackSignal);
1022
+ - getLogger(ctx).debug("speculative_applied", { generation: remoteGeneration, origin: "blocking" });
1023
+ - endCompactionFeedback(ctx, feedbackSignal, result);
1024
+ - return result;
1025
+ - }
1026
+ - }
1027
+ - }
1028
+ + const provider = ctx.model?.provider;
1029
+ + if ((provider === "cursor" || provider === "cursor-cli-oauth") && !ctx.isIdle())
1030
+ + throw new SummaryGenerationError("provider", "Compaction unavailable while provider is busy");
1031
+ + if (isAutomaticCompactionBlocked(ctx.sessionManager.getBranch(), ctx.model, ctx.getCompactionSettings()) ||
1032
+ + breaker.isTripped(state, Date.now()) || cap.shouldRejectByCap(state).cancel)
1033
+ + throw new SummaryGenerationError("provider", "Compaction is blocked after an earlier failure");
1034
+ const pendingJob = speculativeJob;
1035
+ + speculativeJob = undefined;
1036
+ + let snapshot;
1037
+ + let compaction;
1038
+ if (pendingJob) {
1039
+ - const unlinkAbort = linkAbortSignal(feedbackSignal, pendingJob.controller);
1040
+ - let compaction;
1041
+ - let inheritedFailure;
1042
+ + snapshot = pendingJob.snapshot;
1043
+ + const unlink = linkAbortSignal(feedbackSignal, pendingJob.controller);
1044
+ try {
1045
+ compaction = await pendingJob.promise;
1046
+ - inheritedFailure = await pendingJob.failure;
1047
+ - if (compaction)
1048
+ - getLogger(ctx).debug("warm_consumed", { generation: pendingJob.generation, route: "speculative" });
1049
+ + const failure = await pendingJob.failure;
1050
+ + if (failure !== undefined)
1051
+ + throw failure;
1052
+ }
1053
+ - finally {
1054
+ - unlinkAbort();
1055
+ - }
1056
+ - if (inheritedFailure !== undefined) {
1057
+ - speculativeJob = undefined;
1058
+ - const failureKind = classifyRequiredCompactionFallbackFailure(inheritedFailure);
1059
+ - if (failureKind !== undefined &&
1060
+ - !feedbackSignal?.aborted &&
1061
+ - pendingJob.snapshot.generation === speculativeGeneration &&
1062
+ - pendingJob.snapshot.expectedRevision === ctx.getMessageRevision()) {
1063
+ - const recovery = recoverRequiredCompaction(ctx, pendingJob.snapshot, failureKind, inheritedFailure);
1064
+ - compaction = recovery.compaction;
1065
+ - if (!compaction) {
1066
+ - const result = { applied: false, reason: "failed" };
1067
+ - endCompactionFeedback(ctx, feedbackSignal, result, recovery.rejectionReason);
1068
+ - return result;
1069
+ - }
1070
+ - }
1071
+ - else if (failureKind === undefined &&
1072
+ - isTransientSummarizationFailure(inheritedFailure, inheritedFailure.message)) {
1073
+ - ctx.endCompaction?.({
1074
+ - reason: "extension",
1075
+ - signal: feedbackSignal,
1076
+ - aborted: feedbackSignal?.aborted,
1077
+ - errorMessage: `Compaction failed: ${stripTurnRetrySuppressionPrefix(inheritedFailure.message)}`,
1078
+ - });
1079
+ - state = breaker.recordFailure(state, Date.now(), { route: "extension" });
1080
+ - return { applied: false, reason: "failed" };
1081
+ - }
1082
+ - }
1083
+ - const result = await applyGeneratedCompaction(ctx, pendingJob.snapshot, () => speculativeGeneration, compaction, feedbackSignal);
1084
+ - if (result.applied) {
1085
+ - speculativeJob = undefined;
1086
+ - getLogger(ctx).debug("speculative_applied", {
1087
+ - generation: pendingJob.generation,
1088
+ - origin: "speculative",
1089
+ - });
1090
+ - endCompactionFeedback(ctx, feedbackSignal, result);
1091
+ - return result;
1092
+ - }
1093
+ - if (result.reason === "stale") {
1094
+ - getLogger(ctx).debug("speculative_stale", { generation: pendingJob.generation });
1095
+ - }
1096
+ - else if (result.reason === "rejected") {
1097
+ - feedbackSignal = ctx.beginCompaction?.({ reason: "extension" });
1098
+ - }
1099
+ - speculativeJob = undefined;
1100
+ - }
1101
+ - const generation = ++speculativeGeneration;
1102
+ - const snapshot = createSpeculativeCompactionSnapshot(ctx, {
1103
+ - generation,
1104
+ - customInstructions,
1105
+ - origin: "core-route",
1106
+ - tools: getSummarizationTools(),
1107
+ - });
1108
+ - if (!snapshot) {
1109
+ - const result = { applied: false, reason: "unavailable" };
1110
+ - getLogger(ctx).debug("summary_failed", { reason: "unavailable" });
1111
+ - endCompactionFeedback(ctx, feedbackSignal, result, remoteFallbackReason);
1112
+ - return result;
1113
+ - }
1114
+ - let compaction;
1115
+ - try {
1116
+ - compaction = await generateCompaction(ctx, snapshot, feedbackSignal, (delta) => ctx.updateCompaction?.({
1117
+ - reason: "extension",
1118
+ - signal: feedbackSignal,
1119
+ - delta,
1120
+ - }));
1121
+ - }
1122
+ - catch (error) {
1123
+ - const failureKind = classifyRequiredCompactionFallbackFailure(error);
1124
+ - if (failureKind !== undefined && !feedbackSignal?.aborted) {
1125
+ - const recovery = recoverRequiredCompaction(ctx, snapshot, failureKind, error);
1126
+ - compaction = recovery.compaction;
1127
+ - if (!compaction) {
1128
+ - const result = { applied: false, reason: "failed" };
1129
+ - endCompactionFeedback(ctx, feedbackSignal, result, recovery.rejectionReason);
1130
+ - return result;
1131
+ - }
1132
+ + finally { unlink(); }
1133
+ + }
1134
+ + else {
1135
+ + snapshot = createSpeculativeCompactionSnapshot(ctx, {
1136
+ + generation: ++speculativeGeneration, customInstructions, origin: "blocking", tools: getSummarizationTools(),
1137
+ + });
1138
+ + if (!snapshot)
1139
+ + throw new SummaryGenerationError("empty", "No compaction source available");
1140
+ + if (isOpenAiRemoteCompactionModel(ctx.model) && (ctx.getCompactionModel?.() ?? ctx.model) === ctx.model) {
1141
+ + const remote = await runOpenAiRemoteCompaction(ctx,
1142
+ + createBlockingRemoteCompactionEvent(ctx, snapshot, customInstructions, feedbackSignal ?? new AbortController().signal),
1143
+ + (data) => pi.events.emit(SENPI_COMPACTION_EVENT, data), remoteCompactionDependencies);
1144
+ + if (remote.status === "failed")
1145
+ + throw Object.assign(new Error("Remote compaction failed"), { failure: remote.failure });
1146
+ + if (remote.status === "accepted")
1147
+ + compaction = remote.compaction;
1148
+ }
1149
+ - else {
1150
+ - if (!(error instanceof SummaryGenerationError))
1151
+ - throw error;
1152
+ - getLogger(ctx).debug("summary_failed", { reason: error.kind });
1153
+ - }
1154
+ + if (!compaction)
1155
+ + compaction = await generateCompaction(ctx, snapshot, feedbackSignal,
1156
+ + (delta) => ctx.updateCompaction?.({ reason: "extension", signal: feedbackSignal, delta }));
1157
+ }
1158
+ const result = await applyGeneratedCompaction(ctx, snapshot, () => speculativeGeneration, compaction, feedbackSignal);
1159
+ - endCompactionFeedback(ctx, feedbackSignal, result, remoteFallbackReason);
1160
+ + endCompactionFeedback(ctx, feedbackSignal, result);
1161
+ return result;
1162
+ }
1163
+ catch (error) {
1164
+ - if (feedbackSignal?.aborted) {
1165
+ - // An aborted blocking compaction is a cancellation, not a failure: no
1166
+ - // red "Compaction failed" line, no breaker debit, no rethrow through
1167
+ - // the before_agent_start handler (issue #886). The faux-route flavor
1168
+ - // of this contract is pinned by blocking-compaction-review-hardening.
1169
+ - getLogger(ctx).debug("blocking_aborted", { route: "blocking" });
1170
+ - ctx.endCompaction?.({ reason: "extension", signal: feedbackSignal, aborted: true });
1171
+ - return { applied: false, reason: "rejected" };
1172
+ - }
1173
+ - const message = error instanceof Error ? error.message : String(error);
1174
+ - ctx.endCompaction?.({
1175
+ - reason: "extension",
1176
+ - signal: feedbackSignal,
1177
+ - aborted: feedbackSignal?.aborted,
1178
+ - errorMessage: `Compaction failed: ${stripTurnRetrySuppressionPrefix(message)}`,
1179
+ - });
1180
+ - const transient = isTransientSummarizationFailure(error, message);
1181
+ - if (transient) {
1182
+ + const aborted = feedbackSignal?.aborted === true;
1183
+ + const failure = aborted ? undefined : compactionFailure(error, ctx.model);
1184
+ + ctx.endCompaction?.({ reason: "extension", signal: feedbackSignal, aborted,
1185
+ + errorMessage: aborted ? undefined : "Compaction failed; previous context retained", failure });
1186
+ + if (!aborted)
1187
+ state = breaker.recordFailure(state, Date.now(), { route: "extension" });
1188
+ - return { applied: false, reason: "failed" };
1189
+ - }
1190
+ - throw error;
1191
+ + return { applied: false, reason: "failed", failure };
1192
+ }
1193
+ }
1194
+ pi.on("session_before_compact", async (event, ctx) => {
1195
+ - // A pre-aborted request (superseded admission or user cancel) must stand
1196
+ - // down before touching warm-job ownership: core's own post-emit abort
1197
+ - // checks turn it into a clean cancellation (issue #886).
1198
+ if (event.signal.aborted)
1199
+ - return undefined;
1200
+ + return { cancel: true };
1201
+ const claimedWarmJob = claimWarmSummaryForCoreRoute(event, ctx);
1202
+ - // The claim detaches the job without aborting it, so ownership must survive every
1203
+ - // exit from this handler - including a throw - or the request it already paid for
1204
+ - // keeps streaming for a result nobody will read.
1205
+ - let warmJobConsumed = false;
1206
+ invalidateSpeculativeCompaction(ctx);
1207
+ - const claimedGeneration = speculativeGeneration;
1208
+ try {
1209
+ - if (!lanePolicy.ownsCompaction(ctx, event.reason)) {
1210
+ - return {
1211
+ - cancel: true,
1212
+ - rejectionCause: "external-owner",
1213
+ - reason: SDK_NATIVE_LANE_REJECTION_REASON,
1214
+ - };
1215
+ - }
1216
+ - if (cap.shouldRejectByCap(state).cancel) {
1217
+ - getLogger(ctx).debug("skip_cap", { reason: event.reason, count: state.acceptedAbsolute });
1218
+ - return {
1219
+ - cancel: true,
1220
+ - rejectionCause: "per-turn-cap",
1221
+ - reason: "absolute compaction cap reached for this session",
1222
+ - };
1223
+ - }
1224
+ - const now = Date.now();
1225
+ - if (breaker.isTripped(state, now) && !breaker.shouldBypass(state, { reason: event.reason })) {
1226
+ - const remainingMs = state.trippedAt !== null ? Math.max(0, state.trippedAt + breaker.COOLDOWN_MS - now) : 0;
1227
+ - getLogger(ctx).debug("skip_breaker", { reason: event.reason, remainingSec: Math.ceil(remainingMs / 1000) });
1228
+ - return {
1229
+ - cancel: true,
1230
+ - rejectionCause: "circuit-breaker",
1231
+ - reason: `compaction circuit breaker cooling down (${Math.ceil(remainingMs / 1000)}s left)`,
1232
+ - };
1233
+ - }
1234
+ + if (!lanePolicy.ownsCompaction(ctx, event.reason))
1235
+ + return { cancel: true, rejectionCause: "external-owner", reason: SDK_NATIVE_LANE_REJECTION_REASON };
1236
+ + if (cap.shouldRejectByCap(state).cancel)
1237
+ + return { cancel: true, rejectionCause: "per-turn-cap", failure: { kind: "provider" } };
1238
+ + if (breaker.isTripped(state, Date.now()) && !breaker.shouldBypass(state, { reason: event.reason }))
1239
+ + return { cancel: true, rejectionCause: "circuit-breaker", failure: { kind: "provider" } };
1240
+ capturePendingMetadata(event.requestId, ctx);
1241
+ const model = ctx.model;
1242
+ - if (!model) {
1243
+ - return undefined;
1244
+ - }
1245
+ - let remoteCompaction;
1246
+ - try {
1247
+ - // Native remote compaction is tied to the active provider's replay origin.
1248
+ - // A different summary model must use the bounded local summary route.
1249
+ - remoteCompaction =
1250
+ - (ctx.getCompactionModel?.() ?? model) !== model
1251
+ - ? undefined
1252
+ - : await runOpenAiRemoteCompaction(ctx, event, (data) => {
1253
+ - reportRemoteCompactionTimeout(ctx, event.reason, event.signal, data);
1254
+ - pi.events.emit(SENPI_COMPACTION_EVENT, data);
1255
+ - }, remoteCompactionDependencies);
1256
+ - }
1257
+ - catch (error) {
1258
+ - if (error instanceof AccountUsageLimitError)
1259
+ - return { cancel: true, reason: error.message };
1260
+ - // The remote route deliberately rethrows once its signal aborts; the
1261
+ - // handler converts that into a silent stand-down so the abort is not
1262
+ - // reported as an extension error with a stack (issue #886).
1263
+ - if (!event.signal.aborted)
1264
+ - throw error;
1265
+ - getLogger(ctx).debug("remote_aborted", { route: "core-route", requestId: event.requestId });
1266
+ - return undefined;
1267
+ - }
1268
+ - if (remoteCompaction) {
1269
+ - getLogger(ctx).debug("core_route_generated", { route: "core-route", requestId: event.requestId });
1270
+ - return { compaction: remoteCompaction };
1271
+ - }
1272
+ - let inheritedWarmFailure;
1273
+ + if (!model)
1274
+ + throw new SummaryGenerationError("model_unavailable", "Compaction model unavailable");
1275
+ if (claimedWarmJob) {
1276
+ - const unlinkAbort = linkAbortSignal(event.signal, claimedWarmJob.controller);
1277
+ - let warmCompaction;
1278
+ - let warmFailure;
1279
+ + const unlink = linkAbortSignal(event.signal, claimedWarmJob.controller);
1280
+ try {
1281
+ - warmCompaction = await claimedWarmJob.promise;
1282
+ - warmFailure = await claimedWarmJob.failure;
1283
+ + const compaction = await claimedWarmJob.promise;
1284
+ + const failure = await claimedWarmJob.failure;
1285
+ + if (failure !== undefined)
1286
+ + throw failure;
1287
+ + if (!compaction)
1288
+ + throw new SummaryGenerationError("incomplete", "Warm compaction returned no result");
1289
+ + return { compaction };
1290
+ }
1291
+ - finally {
1292
+ - unlinkAbort();
1293
+ - }
1294
+ - if (warmFailure === undefined && warmCompaction && !event.signal.aborted) {
1295
+ - getLogger(ctx).debug("warm_consumed", { generation: claimedWarmJob.generation, route: "core-route" });
1296
+ - warmJobConsumed = true;
1297
+ - return { compaction: warmCompaction };
1298
+ - }
1299
+ - if (warmFailure !== undefined &&
1300
+ - requiresDeterministicCompactionFallback(event, ctx.getContextUsage()) &&
1301
+ - classifyRequiredCompactionFallbackFailure(warmFailure) !== undefined &&
1302
+ - !event.signal.aborted &&
1303
+ - speculativeGeneration === claimedGeneration &&
1304
+ - claimedWarmJob.snapshot.expectedRevision === ctx.getMessageRevision()) {
1305
+ - inheritedWarmFailure = warmFailure;
1306
+ - }
1307
+ + finally { unlink(); }
1308
+ }
1309
+ + if ((ctx.getCompactionModel?.() ?? model) === model) {
1310
+ + const remote = await runOpenAiRemoteCompaction(ctx, event,
1311
+ + (data) => pi.events.emit(SENPI_COMPACTION_EVENT, data), remoteCompactionDependencies);
1312
+ + if (remote.status === "failed")
1313
+ + throw Object.assign(new Error("Remote compaction failed"), { failure: remote.failure });
1314
+ + if (remote.status === "accepted")
1315
+ + return { compaction: remote.compaction };
1316
+ + }
1317
+ const snapshot = {
1318
+ - generation: ++speculativeGeneration,
1319
+ - expectedRevision: ctx.getMessageRevision(),
1320
+ - model,
1321
+ + generation: ++speculativeGeneration, expectedRevision: ctx.getMessageRevision(), model,
1322
+ contextWindow: ctx.getContextUsage()?.contextWindow ?? model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
1323
+ - preparation: event.preparation,
1324
+ - branchEntries: event.branchEntries,
1325
+ - promptVariant: getPromptVariant(event),
1326
+ - origin: "core-route",
1327
+ - customInstructions: event.customInstructions,
1328
+ - systemPrompt: ctx.getSystemPrompt(),
1329
+ - tools: getSummarizationTools(),
1330
+ - };
1331
+ - let compaction;
1332
+ - try {
1333
+ - if (inheritedWarmFailure)
1334
+ - throw inheritedWarmFailure;
1335
+ - compaction = await generateCompaction(ctx, snapshot, event.signal, (delta) => ctx.updateCompaction?.({ reason: event.reason, signal: event.signal, delta }));
1336
+ - }
1337
+ - catch (error) {
1338
+ - const message = error instanceof Error ? error.message : String(error);
1339
+ - const failureKind = classifyRequiredCompactionFallbackFailure(error);
1340
+ - if ((requiresDeterministicCompactionFallback(event, ctx.getContextUsage()) ||
1341
+ - failureKind === "unsafe-retained-content") &&
1342
+ - failureKind !== undefined &&
1343
+ - !event.signal.aborted) {
1344
+ - const recovery = recoverRequiredCompaction(ctx, snapshot, failureKind, error);
1345
+ - if (recovery.compaction)
1346
+ - return { compaction: recovery.compaction };
1347
+ - pendingMetadata.delete(event.requestId);
1348
+ - return {
1349
+ - cancel: true,
1350
+ - reason: recovery.rejectionReason,
1351
+ - };
1352
+ - }
1353
+ - pendingMetadata.delete(event.requestId);
1354
+ - if (error instanceof SummaryGenerationError) {
1355
+ - return { cancel: true, reason: stripTurnRetrySuppressionPrefix(error.message) };
1356
+ - }
1357
+ - return { cancel: true, reason: `compaction generator failed: ${stripTurnRetrySuppressionPrefix(message)}` };
1358
+ - }
1359
+ - if (!compaction) {
1360
+ - pendingMetadata.delete(event.requestId);
1361
+ - if (event.signal.aborted) {
1362
+ - return { cancel: true };
1363
+ - }
1364
+ - return { cancel: true, reason: "compaction generator returned no summary" };
1365
+ - }
1366
+ - return {
1367
+ - compaction,
1368
+ + preparation: event.preparation, branchEntries: event.branchEntries, promptVariant: getPromptVariant(event),
1369
+ + origin: "core-route", customInstructions: event.customInstructions,
1370
+ + systemPrompt: ctx.getSystemPrompt(), tools: getSummarizationTools(),
1371
+ };
1372
+ + const compaction = await generateCompaction(ctx, snapshot, event.signal,
1373
+ + (delta) => ctx.updateCompaction?.({ reason: event.reason, signal: event.signal, delta }));
1374
+ + if (!compaction)
1375
+ + throw new SummaryGenerationError("incomplete", "Compaction returned no summary");
1376
+ + return { compaction };
1377
+ }
1378
+ - finally {
1379
+ - if (!warmJobConsumed)
1380
+ - claimedWarmJob?.controller.abort();
1381
+ + catch (error) {
1382
+ + pendingMetadata.delete(event.requestId);
1383
+ + return { cancel: true, ...(event.signal.aborted ? {} : {
1384
+ + reason: "Compaction failed; previous context retained", failure: compactionFailure(error, ctx.model),
1385
+ + }) };
1386
+ }
1387
+ + finally { claimedWarmJob?.controller.abort(); }
1388
+ });
1389
+ pi.on("model_select", (event, ctx) => handleCompactionModelSelect({
1390
+ event,
1391
+ @@ -782,7 +450,8 @@
1392
+ tokens: usage.tokens ?? 0,
1393
+ threshold: settings.reserveTokens,
1394
+ });
1395
+ - await applyBlockingCompaction(ctx, EMERGENCY_COMPACTION_INSTRUCTIONS);
1396
+ + const result = await applyBlockingCompaction(ctx, EMERGENCY_COMPACTION_INSTRUCTIONS);
1397
+ + if (!result.applied) { ctx.abort("system"); return; }
1398
+ }
1399
+ else if (!breakerCoolingDown &&
1400
+ usageWithPendingPrompt &&
1401
+ @@ -801,7 +470,8 @@
1402
+ compactionInFlight: speculativeJob !== undefined && !speculativeJob.completed,
1403
+ graceBandEnabled: settings.graceBandEnabled,
1404
+ })) {
1405
+ - await applyBlockingCompaction(ctx, PROACTIVE_COMPACTION_INSTRUCTIONS);
1406
+ + const result = await applyBlockingCompaction(ctx, PROACTIVE_COMPACTION_INSTRUCTIONS);
1407
+ + if (!result.applied) { ctx.abort("system"); return; }
1408
+ }
1409
+ else {
1410
+ getLogger(ctx).debug("grace_deferred", {
1411
+ @@ -912,10 +582,8 @@
1412
+ mode: ctx.mode,
1413
+ })) {
1414
+ getLogger(ctx).debug("idle_trigger", { contextWindow, tokens: usage?.tokens ?? 0 });
1415
+ - idleWarmupAttempt = 0;
1416
+ sessionIdleSinceAgentEnd = true;
1417
+ startSpeculativeCompaction(ctx, idle.IDLE_COMPACTION_INSTRUCTIONS);
1418
+ - armIdleWarmupRetry(ctx);
1419
+ armIdleApply(ctx);
1420
+ }
1421
+ else {
1422
+ @@ -940,11 +608,9 @@
1423
+ if (warmAction === "replace")
1424
+ invalidateSpeculativeCompaction(ctx);
1425
+ if (warmAction !== "none") {
1426
+ - idleWarmupAttempt = 0;
1427
+ sessionIdleSinceAgentEnd = true;
1428
+ startSpeculativeCompaction(ctx, idle.IDLE_COMPACTION_INSTRUCTIONS);
1429
+ - armIdleWarmupRetry(ctx);
1430
+ - armIdleApply(ctx);
1431
+ + armIdleApply(ctx);
1432
+ }
1433
+ }
1434
+ });
1435
+ @@ -974,9 +640,8 @@
1436
+ pi.on("session_shutdown", () => {
1437
+ sessionIdleSinceAgentEnd = false;
1438
+ cancelIdleWarmupRetry();
1439
+ - idleWarmupAttempt = 0;
1440
+ speculativeJob?.controller.abort();
1441
+ speculativeJob = undefined;
1442
+ });
1443
+ }
1444
+ -//# sourceMappingURL=index.js.map
1445
+
1446
+ +//# sourceMappingURL=index.js.map
1447
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/lane-policy.js
1448
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/lane-policy.js
1449
+ @@ -91,7 +91,8 @@
1450
+ // A configured compaction model override makes senpi own summarization
1451
+ // for the lane, so the SDK-native stand-down no longer applies. This is
1452
+ // the escape hatch for lanes whose SDK never fires native compaction.
1453
+ - if (context.getCompactionSettings?.().model)
1454
+ + const override = context.getCompactionSettings?.().model;
1455
+ + if (typeof override === "string" && /^[^/\s]+\/\S+$/.test(override))
1456
+ return false;
1457
+ // A settings read failure must never silently disable senpi compaction.
1458
+ const providerSettings = readProviderSettings(context.cwd);
1459
+ @@ -157,4 +158,4 @@
1460
+ }
1461
+ return entries;
1462
+ }
1463
+ -//# sourceMappingURL=lane-policy.js.map
1464
+
1465
+ +//# sourceMappingURL=lane-policy.js.map
1466
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-convert.js
1467
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-convert.js
1468
+ @@ -1,5 +1,6 @@
1469
+ +import { protectedRemoteInput } from "./task-intent.js";
1470
+ import { convertToLlm } from "../../../messages.js";
1471
+ -import { sessionEntryToContextMessages } from "../../../session-manager.js";
1472
+ +import { sessionEntryToContextMessages, getSessionContextEntryId, SESSION_CONTEXT_ENTRY_ID } from "../../../session-manager.js";
1473
+ import { getOpenAiRemoteCompactionDetails, isRecord, } from "./openai-remote-schema.js";
1474
+ export { getOpenAiRemoteCompactionDetails, isOpenAiContextCompactionItem, isOpenAiRemoteCompactionOutputItem, isRecord, isRetainedRemoteOutputItem, isRetainedResponsesStreamInputItem, OPENAI_REMOTE_COMPACTION_SCHEMA, } from "./openai-remote-schema.js";
1475
+ function parseJsonRecord(value) {
1476
+ @@ -139,8 +140,12 @@
1477
+ }
1478
+ function convertLlmMessage(message, messageIndex, model) {
1479
+ switch (message.role) {
1480
+ - case "user":
1481
+ - return [{ role: "user", content: convertUserContent(message.content) }];
1482
+ + case "user": {
1483
+ + const item = { role: "user", content: convertUserContent(message.content) };
1484
+ + const sourceId = getSessionContextEntryId(message) ?? message[SESSION_CONTEXT_ENTRY_ID];
1485
+ + if (sourceId) Object.defineProperty(item, "__senpiCompactionSourceId", { value: sourceId });
1486
+ + return [item];
1487
+ + }
1488
+ case "assistant":
1489
+ return convertAssistantMessage(message, messageIndex);
1490
+ case "toolResult":
1491
+ @@ -185,7 +190,7 @@
1492
+ const details = getOpenAiRemoteCompactionDetails(entry.details);
1493
+ if (details) {
1494
+ flush();
1495
+ - items.push(...details.replacementInput);
1496
+ + items.push(...protectedRemoteInput(details.replacementInput, details.intentProtection));
1497
+ continue;
1498
+ }
1499
+ }
1500
+ @@ -194,4 +199,4 @@
1501
+ flush();
1502
+ return items;
1503
+ }
1504
+ -//# sourceMappingURL=openai-remote-convert.js.map
1505
+
1506
+ +//# sourceMappingURL=openai-remote-convert.js.map
1507
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-responses-v2.js
1508
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-responses-v2.js
1509
+ @@ -1,3 +1,4 @@
1510
+ +import { compactionFailure } from "./speculative.js";
1511
+ import { isOpenAiRemoteCompactionOutputItem, isRecord, OPENAI_REMOTE_COMPACTION_SCHEMA, providerNativeItem, } from "./openai-remote-convert.js";
1512
+ import { openAiRemoteCompactionIdentity, } from "./openai-remote-model.js";
1513
+ import { runWithRemoteTimeout } from "./openai-remote-timeout.js";
1514
+ @@ -60,7 +61,8 @@
1515
+ extraBody: args.auth.extraBody,
1516
+ headers: withRemoteCompactionV2Header(args.headers),
1517
+ // One remote request per compaction on the subscription lane: a failure falls back to the local summary.
1518
+ - ...(args.model.api === "openai-codex-responses" ? { maxRetries: 0 } : {}),
1519
+ + maxRetries: 0,
1520
+ + streamKind: "auxiliary",
1521
+ sessionId: args.sessionId,
1522
+ signal: args.event.signal,
1523
+ transport: "sse",
1524
+ @@ -84,8 +86,10 @@
1525
+ },
1526
+ });
1527
+ const assistant = await stream.result();
1528
+ - if (assistant.stopReason === "error" || assistant.stopReason === "aborted")
1529
+ - return undefined;
1530
+ + if (assistant.stopReason !== "stop" || assistant.stopDetails?.type === "refusal")
1531
+ + throw Object.assign(new Error("Remote compaction did not finish normally"), {
1532
+ + failure: assistant.stopDetails?.type === "refusal" ? { kind: "refused" } : assistant.failure ?? { kind: "incomplete" },
1533
+ + });
1534
+ const compaction = findCompactionOutput(assistant);
1535
+ if (compaction === undefined)
1536
+ return undefined;
1537
+ @@ -102,6 +106,7 @@
1538
+ transport: "responses-v2",
1539
+ modelId: args.model.id,
1540
+ responseId,
1541
+ + ...(assistant.responseModel ? { observedProvider: assistant.provider, observedModel: assistant.responseModel } : {}),
1542
+ createdAt,
1543
+ requestInputItemCount: args.request.inputItemCount,
1544
+ retainedInputItemCount: 1,
1545
+ @@ -170,9 +175,10 @@
1546
+ requestId: args.requestId,
1547
+ modelId: args.model.id,
1548
+ reason: "responses-v2-error",
1549
+ + failure: compactionFailure(error, args.model),
1550
+ transport: "responses-v2",
1551
+ });
1552
+ return undefined;
1553
+ }
1554
+ }
1555
+ -//# sourceMappingURL=openai-remote-responses-v2.js.map
1556
+
1557
+ +//# sourceMappingURL=openai-remote-responses-v2.js.map
1558
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-schema.js
1559
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-schema.js
1560
+ @@ -49,6 +49,7 @@
1561
+ ...(parseOpenAiRemoteCompactionOrigin(value.origin)
1562
+ ? { origin: parseOpenAiRemoteCompactionOrigin(value.origin) }
1563
+ : {}),
1564
+ + ...(isRecord(value.intentProtection) ? { intentProtection: value.intentProtection } : {}),
1565
+ ...(isRecord(value.usage) ? { usage: value.usage } : {}),
1566
+ };
1567
+ }
1568
+ @@ -71,4 +72,4 @@
1569
+ return item.role === "user";
1570
+ return "role" in item && item.role === "user";
1571
+ }
1572
+ -//# sourceMappingURL=openai-remote-schema.js.map
1573
+
1574
+ +//# sourceMappingURL=openai-remote-schema.js.map
1575
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-timeout.d.ts
1576
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-timeout.d.ts
1577
+ @@ -11,7 +11,7 @@
1578
+ */
1579
+ export declare function openAiRemoteCompactionTimeoutMs(model: OpenAiRemoteCompactionModel, compactionTokens: number): number;
1580
+ /** What the compaction does after a remote attempt runs out of its budget. */
1581
+ -export type RemoteCompactionTimeoutNextStep = "websocket" | "compact-endpoint" | "local-summary";
1582
+ +export type RemoteCompactionTimeoutNextStep = "websocket" | "compact-endpoint" | "local-summary" | "stop";
1583
+ /**
1584
+ * One deadline for every remote attempt of a compaction: a lane that falls through v2, WebSocket
1585
+ * and the compact endpoint never holds the session longer than {@link SUMMARIZATION_TOTAL_BUDGET_MS}
1586
+ @@ -35,4 +35,4 @@
1587
+ run: (signal: AbortSignal) => Promise<T>;
1588
+ onTimeout: (waitedMs: number) => void;
1589
+ }): Promise<T | undefined>;
1590
+ -//# sourceMappingURL=openai-remote-timeout.d.ts.map
1591
+
1592
+ +//# sourceMappingURL=openai-remote-timeout.d.ts.map
1593
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-timeout.js
1594
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote-timeout.js
1595
+ @@ -39,6 +39,7 @@
1596
+ };
1597
+ }
1598
+ const NEXT_STEP_TEXT = {
1599
+ + stop: "stopping with previous context retained",
1600
+ websocket: "retrying over the WebSocket route",
1601
+ "compact-endpoint": "retrying through the compact endpoint",
1602
+ "local-summary": "falling back to a local summary",
1603
+ @@ -88,4 +89,4 @@
1604
+ options.signal.removeEventListener("abort", abortFromSource);
1605
+ }
1606
+ }
1607
+ -//# sourceMappingURL=openai-remote-timeout.js.map
1608
+
1609
+ +//# sourceMappingURL=openai-remote-timeout.js.map
1610
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote.d.ts
1611
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote.d.ts
1612
+ @@ -2,7 +2,7 @@
1613
+ import { type Api, type AssistantMessage, type Model, type ProviderHeaders } from "@earendil-works/pi-ai";
1614
+ import type { CompactionResult } from "../../../compaction/index.ts";
1615
+ import { type SessionEntry } from "../../../session-manager.ts";
1616
+ -import type { ProviderRequestPreparation, ServiceTier, SessionBeforeCompactEvent } from "../../types.ts";
1617
+ +import type { CompactionFailure, ProviderRequestPreparation, ServiceTier, SessionBeforeCompactEvent } from "../../types.ts";
1618
+ import type { OpenAiCompactBody, OpenAiRemoteCompactionDetails, OpenAiRemoteInputItem, OpenAiRemoteTransport } from "./openai-remote-convert.ts";
1619
+ import { type OpenAiRemoteCompactionModel, type OpenAiRemoteCompactionOrigin } from "./openai-remote-model.ts";
1620
+ import { type RemoteCompactionTimeout } from "./openai-remote-timeout.ts";
1621
+ @@ -121,7 +121,7 @@
1622
+ now: () => number;
1623
+ origin?: OpenAiRemoteCompactionOrigin;
1624
+ }): OpenAiRemoteCompactionResult;
1625
+ -export declare function runOpenAiRemoteCompaction(ctx: OpenAiRemoteCompactionContext, event: SessionBeforeCompactEvent, emit?: EmitCompactionEvent, dependencies?: OpenAiRemoteCompactionDependencies): Promise<OpenAiRemoteCompactionResult | undefined>;
1626
+ +export declare function runOpenAiRemoteCompaction(ctx: OpenAiRemoteCompactionContext, event: SessionBeforeCompactEvent, emit?: EmitCompactionEvent, dependencies?: OpenAiRemoteCompactionDependencies): Promise<{ status: "ineligible" } | { status: "failed"; failure: CompactionFailure } | { status: "accepted"; compaction: OpenAiRemoteCompactionResult }>;
1627
+ /**
1628
+ * Mark the exact checkpoint-owned messages before later context hooks run.
1629
+ * The markers carry entry identity, not payload values; the Responses converter
1630
+ @@ -136,4 +136,4 @@
1631
+ branchEntries: SessionEntry[];
1632
+ origin?: OpenAiRemoteCompactionOrigin;
1633
+ }, emit?: EmitCompactionEvent): unknown | undefined;
1634
+ -//# sourceMappingURL=openai-remote.d.ts.map
1635
+
1636
+ +//# sourceMappingURL=openai-remote.d.ts.map
1637
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote.js
1638
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/openai-remote.js
1639
+ @@ -1,6 +1,10 @@
1640
+ import { convertResponsesMessages, getContextProvenance, normalizeContext, } from "@earendil-works/pi-ai";
1641
+ import { deriveOpenAIPromptCacheKey } from "@earendil-works/pi-ai/api/openai-prompt-cache";
1642
+ import { streamSimple } from "@earendil-works/pi-ai/compat";
1643
+ +import { compactionFailure } from "./speculative.js";
1644
+ +import { collectIntentProtection, protectedRemoteInput, taskIntentSummary, retainedUserText } from "./task-intent.js";
1645
+ +import { estimateTokens } from "../../../compaction/index.js";
1646
+ +import { resolveEffectiveReserveTokens } from "./policy.js";
1647
+ import { convertToLlm } from "../../../messages.js";
1648
+ import { buildContextEntries, buildSessionContext, getSessionContextEntryId, SESSION_CONTEXT_ENTRY_ID, sessionEntryToContextMessages, } from "../../../session-manager.js";
1649
+ import { convertBranchEntries, convertPendingMessages, getOpenAiRemoteCompactionDetails, isOpenAiContextCompactionItem, isOpenAiRemoteCompactionOutputItem, isRecord, isRetainedRemoteOutputItem, isRetainedResponsesStreamInputItem, OPENAI_REMOTE_COMPACTION_SCHEMA, providerNativeItem, } from "./openai-remote-convert.js";
1650
+ @@ -20,9 +24,10 @@
1651
+ export function createOpenAiRemoteCompactionRequest(options) {
1652
+ if (!isOpenAiRemoteCompactionModel(options.model))
1653
+ return undefined;
1654
+ - const input = options.messages
1655
+ + const converted = options.messages
1656
+ ? convertPendingMessages(options.messages, options.model)
1657
+ : convertBranchEntries(options.branchEntries, options.model);
1658
+ + const input = protectedRemoteInput(converted, collectIntentProtection(options.branchEntries));
1659
+ if (input.length === 0)
1660
+ return undefined;
1661
+ return {
1662
+ @@ -53,6 +58,7 @@
1663
+ created_at: typeof options.value.created_at === "number" ? options.value.created_at : Math.floor(options.now() / 1000),
1664
+ object: "response.compaction",
1665
+ output: options.value.output.filter((item) => isRecord(item)),
1666
+ + ...(typeof options.value.model === "string" ? { observedModel: options.value.model } : {}),
1667
+ ...(isRecord(options.value.usage) ? { usage: options.value.usage } : {}),
1668
+ };
1669
+ }
1670
+ @@ -69,6 +75,7 @@
1671
+ transport: "compact-endpoint",
1672
+ modelId: options.model.id,
1673
+ responseId: options.response.id,
1674
+ + ...(options.response.observedModel ? { observedProvider: options.model.provider, observedModel: options.response.observedModel } : {}),
1675
+ createdAt: options.response.created_at,
1676
+ requestInputItemCount: options.requestInputItemCount,
1677
+ retainedInputItemCount: replacementInput.length,
1678
+ @@ -137,6 +144,7 @@
1679
+ transport: "websocket",
1680
+ modelId: options.model.id,
1681
+ responseId: options.response.responseId ?? `response-${options.now()}`,
1682
+ + ...(options.response.responseModel ? { observedProvider: options.response.provider, observedModel: options.response.responseModel } : {}),
1683
+ createdAt: Math.floor(options.response.timestamp / 1000),
1684
+ requestInputItemCount: options.requestInput.length,
1685
+ retainedInputItemCount: replacementInput.length,
1686
+ @@ -177,10 +185,14 @@
1687
+ sessionId: options.request.sessionId,
1688
+ signal: options.signal,
1689
+ transport: "websocket",
1690
+ + streamKind: "auxiliary",
1691
+ + maxRetries: 0,
1692
+ });
1693
+ const response = await stream.result();
1694
+ - if (response.stopReason === "error" || response.stopReason === "aborted") {
1695
+ - return undefined;
1696
+ + if (response.stopReason !== "stop" || response.stopDetails?.type === "refusal") {
1697
+ + throw Object.assign(new Error("Remote compaction did not finish normally"), {
1698
+ + failure: response.stopDetails?.type === "refusal" ? { kind: "refused" } : response.failure ?? { kind: "incomplete" },
1699
+ + });
1700
+ }
1701
+ return buildOpenAiResponsesStreamCompactionResult({
1702
+ model: options.model,
1703
+ @@ -221,6 +233,7 @@
1704
+ requestId: options.requestId,
1705
+ modelId: options.model.id,
1706
+ reason: error instanceof Error ? error.message : String(error),
1707
+ + failure: compactionFailure(error, options?.model),
1708
+ transport: "compact-endpoint",
1709
+ });
1710
+ return undefined;
1711
+ @@ -233,6 +246,7 @@
1712
+ requestId: options.requestId,
1713
+ modelId: options.model.id,
1714
+ reason: `HTTP ${response.status}`,
1715
+ + failure: { kind: response.status === 429 ? "quota" : response.status === 401 ? "auth_expired" : response.status === 403 ? "org_denied" : "provider" },
1716
+ transport: "compact-endpoint",
1717
+ });
1718
+ return undefined;
1719
+ @@ -249,6 +263,7 @@
1720
+ requestId: options.requestId,
1721
+ modelId: options.model.id,
1722
+ reason: error instanceof Error ? error.message : String(error),
1723
+ + failure: compactionFailure(error, options?.model),
1724
+ transport: "compact-endpoint",
1725
+ });
1726
+ return undefined;
1727
+ @@ -290,6 +305,7 @@
1728
+ requestId: options.requestId,
1729
+ modelId: options.model.id,
1730
+ reason: error instanceof Error ? error.message : String(error),
1731
+ + failure: compactionFailure(error, options?.model),
1732
+ transport: "compact-endpoint",
1733
+ });
1734
+ return undefined;
1735
+ @@ -323,6 +339,51 @@
1736
+ return (model, context, options) => streamSimple(model, context, options);
1737
+ }
1738
+ export async function runOpenAiRemoteCompaction(ctx, event, emit, dependencies = {}) {
1739
+ + const protection = collectIntentProtection(event.branchEntries);
1740
+ + const protectedInput = protectedRemoteInput([], protection);
1741
+ + const model = ctx.model;
1742
+ + const settings = ctx.getCompactionSettings?.() ?? event.preparation.settings;
1743
+ + const protectionTokens = estimateTokens({ role: "user", content: JSON.stringify(protectedInput) });
1744
+ + if (model && protectionTokens > model.contextWindow - resolveEffectiveReserveTokens(model.contextWindow, settings))
1745
+ + return { status: "ineligible" }; // Before dispatch only: same-model text compaction is permitted.
1746
+ + const attempt = await runOpenAiRemoteCompactionAttempt(ctx, event, emit, dependencies);
1747
+ + if (attempt.status !== "accepted") return attempt;
1748
+ + const result = attempt.compaction;
1749
+ + const checkpointTexts = new Set(convertPendingMessages(buildSessionContext(event.branchEntries).messages.filter((message) => message.role === "compactionSummary"), model).map(retainedUserText));
1750
+ + // The accepted native envelope already covers the request checkpoint. Keep its original intent outside it, not a second raw copy of the old summary.
1751
+ + const retained = result.details.replacementInput.filter((item) => !checkpointTexts.has(retainedUserText(item)));
1752
+ + const replacementInput = protectedRemoteInput(retained, protection);
1753
+ + return { status: "accepted", compaction: { ...result,
1754
+ + summary: taskIntentSummary(result.summary, protection.taskIntent) + (protection.sources.length ? `\n<source-user-text>\n${JSON.stringify(protection.sources)}\n</source-user-text>` : ""),
1755
+ + details: { ...result.details, intentProtection: protection, replacementInput, retainedInputItemCount: replacementInput.length,
1756
+ + selectionMode: settings.model === undefined || settings.model === "auto" ? "auto" : "explicit",
1757
+ + requestedProvider: model.provider, requestedModel: model.id },
1758
+ + } };
1759
+ +}
1760
+ +async function runOpenAiRemoteCompactionAttempt(ctx, event, emit, dependencies = {}) {
1761
+ + let dispatched = false;
1762
+ + let failure;
1763
+ + try {
1764
+ + const compaction = await runRemoteAttempt(ctx, event, (data) => {
1765
+ + if (data.action === "remote_started")
1766
+ + dispatched = true;
1767
+ + if (data.failure)
1768
+ + failure = data.failure;
1769
+ + if (data.reason === REMOTE_COMPACTION_TIMEOUT_REASON || data.reason === REMOTE_COMPACTION_BUDGET_EXHAUSTED_REASON)
1770
+ + failure = { kind: "timeout" };
1771
+ + emit?.(data);
1772
+ + }, dependencies);
1773
+ + if (compaction)
1774
+ + return { status: "accepted", compaction };
1775
+ + return dispatched ? { status: "failed", failure: failure ?? { kind: "provider" } } : { status: "ineligible" };
1776
+ + }
1777
+ + catch (error) {
1778
+ + if (event.signal.aborted)
1779
+ + throw error;
1780
+ + return { status: "failed", failure: compactionFailure(error, ctx.model) };
1781
+ + }
1782
+ +}
1783
+ +async function runRemoteAttempt(ctx, event, emit, dependencies) {
1784
+ const model = ctx.model;
1785
+ if (!isOpenAiRemoteCompactionModel(model) || event.reason === "branch") {
1786
+ emit?.({
1787
+ @@ -376,12 +437,16 @@
1788
+ });
1789
+ return undefined;
1790
+ }
1791
+ + const requestTokens = estimateTokens({ role: "user", content: JSON.stringify(request.body) });
1792
+ + const requestSettings = ctx.getCompactionSettings?.() ?? event.preparation.settings;
1793
+ + if (requestTokens > requestModel.contextWindow - resolveEffectiveReserveTokens(requestModel.contextWindow, requestSettings))
1794
+ + return undefined;
1795
+ const remoteTimeoutMs = dependencies.remoteTimeoutMs ?? openAiRemoteCompactionTimeoutMs(requestModel, request.tokensBefore);
1796
+ const remoteDeadline = createRemoteCompactionDeadline(remoteTimeoutMs);
1797
+ const timedOut = (waitedMs, next) => ({
1798
+ waitedMs,
1799
+ tokens: request.tokensBefore,
1800
+ - next: remoteDeadline.remainingMs() > 0 ? next : "local-summary",
1801
+ + next: "stop",
1802
+ });
1803
+ const remoteBudgetExhausted = (transport) => {
1804
+ if (remoteDeadline.remainingMs() > 0)
1805
+ @@ -454,8 +519,7 @@
1806
+ ? "websocket"
1807
+ : "compact-endpoint"),
1808
+ });
1809
+ - if (result || requestModel.api === "openai-codex-responses")
1810
+ - return result;
1811
+ + return result;
1812
+ }
1813
+ if (supportsOpenAiResponsesWebSocket(requestModel) && !remoteBudgetExhausted("websocket")) {
1814
+ const websocketHeaders = Object.fromEntries(requestHeaders.entries());
1815
+ @@ -529,9 +593,11 @@
1816
+ requestId: event.requestId,
1817
+ modelId: requestModel.id,
1818
+ reason: error instanceof Error ? error.message : String(error),
1819
+ + failure: compactionFailure(error, requestModel),
1820
+ transport: "websocket",
1821
+ });
1822
+ }
1823
+ + return undefined;
1824
+ }
1825
+ const transformedPayload = providerRequest ? await providerRequest.transformPayload(request.body) : request.body;
1826
+ if (!isOpenAiCompactBody(transformedPayload)) {
1827
+ @@ -730,7 +796,7 @@
1828
+ }
1829
+ const input = [
1830
+ ...payloadInput.slice(0, checkpointStart).filter(isRecord),
1831
+ - ...remote.details.replacementInput,
1832
+ + ...protectedRemoteInput(remote.details.replacementInput, remote.details.intentProtection),
1833
+ ...payloadInput.slice(checkpointStart + expectedOrdinals.length).filter(isRecord),
1834
+ ];
1835
+ emit?.({
1836
+ @@ -743,4 +809,4 @@
1837
+ });
1838
+ return { ...payload, input };
1839
+ }
1840
+ -//# sourceMappingURL=openai-remote.js.map
1841
+
1842
+ +//# sourceMappingURL=openai-remote.js.map
1843
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/prompts.js
1844
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/prompts.js
1845
+ @@ -6,12 +6,12 @@
1846
+ const TASK_INTENT_UPDATE_ACQUIRE_GPT = `Write one <task-intent> block with ORIGINAL_REQUEST, TASK_TYPE, MUST_PRESERVE, and MUST_NOT_LOSE before <summary>.`;
1847
+ const TASK_INTENT_UPDATE_ANCHOR_CLAUDE = `<task-intent>
1848
+ {{taskIntent}}
1849
+ -Immutable provenance of the original task. Do not rewrite it. Newer explicit user steering overrides it.
1850
+ -</task-intent>`;
1851
+ +</task-intent>
1852
+ +Immutable provenance of the original task. Do not rewrite it. Newer explicit user steering overrides it.`;
1853
+ const TASK_INTENT_UPDATE_ANCHOR_GPT = `<task-intent>
1854
+ {{taskIntent}}
1855
+ -Immutable provenance of the original task. Do not rewrite it. Newer explicit user steering overrides it.
1856
+ -</task-intent>`;
1857
+ +</task-intent>
1858
+ +Immutable provenance of the original task. Do not rewrite it. Newer explicit user steering overrides it.`;
1859
+ export const MERGED_COMPACTION_PROMPT_SYSTEM = `[SYSTEM DIRECTIVE: OH-MY-OPENCODE - COMPACTION CONTEXT]
1860
+
1861
+ You are the COMPACTION ARCHIVIST. Create a structured handoff summary that lets the next agent continue this exact session without restarting, re-searching, or losing constraints.
1862
+ @@ -19,7 +19,7 @@
1863
+ Cardinal rules:
1864
+ R1. Quote user requests and constraints VERBATIM. Do not paraphrase.
1865
+ R2. If a section has no content, write "None." Never delete a section.
1866
+ -R3. Where a previous summary is supplied, treat its User Requests, Final Goal, and Constraints fields as IMMUTABLE. Append, never rewrite, those three sections.
1867
+ +R3. Preserve original requests and durable constraints as quoted provenance. Latest explicit user steering governs the current goal and next action. An answer-local restriction applies only to that answer.
1868
+ R4. Preserve every session_id, file path, and identifier byte-for-byte.
1869
+
1870
+ Do NOT use tools. Output only the requested summary block.`;
1871
+ @@ -83,7 +83,7 @@
1872
+ [INTERNAL COMPACTION UPDATE INSTRUCTION — NOT CONVERSATION HISTORY]
1873
+ The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
1874
+
1875
+ -R3 enforcement: R3. Where a previous summary is supplied, treat its User Requests, Final Goal, and Constraints fields as IMMUTABLE. Append, never rewrite, those three sections.
1876
+ +Preserve original requests and durable constraints as quoted provenance. Latest explicit user steering governs the current goal and next action. Keep answer-local restrictions local to their answer.
1877
+
1878
+ PASS 1 — Internal task-intent extraction
1879
+ {{taskIntentInstruction}}
1880
+ @@ -176,7 +176,7 @@
1881
+ Create a compact prefix summary for the next turn. Emit only the sections listed below.
1882
+
1883
+ PASS 1 — Internal task-intent extraction
1884
+ -Silently determine the current task intent and the minimum context needed for the next turn.
1885
+ +Preserve the original request and durable constraints from the entire history together with the split turn. Latest explicit steering governs the current goal and next action. Do not promote answer-local restrictions into durable constraints.
1886
+
1887
+ PASS 2 — Emit summary biased toward Pass 1
1888
+ The structured output portion MUST be wrapped as \`<summary>...</summary>\` XML.
1889
+ @@ -214,9 +214,7 @@
1890
+ case "branch":
1891
+ return MERGED_COMPACTION_PROMPT_BRANCH;
1892
+ case "turn_prefix":
1893
+ - return options.taskIntent
1894
+ - ? MERGED_COMPACTION_PROMPT_TURN_PREFIX
1895
+ - : mergeTaskIntent(MERGED_COMPACTION_PROMPT_TURN_PREFIX, promptFamilyText(options.promptFamily, TASK_INTENT_ACQUISITION_CLAUDE, TASK_INTENT_ACQUISITION_GPT));
1896
+ + return buildUserPrompt({ ...options, variant: options.previousSummary || options.taskIntent ? "update" : "default" });
1897
+ }
1898
+ const exhaustiveCheck = options.variant;
1899
+ return exhaustiveCheck;
1900
+ @@ -254,4 +252,4 @@
1901
+ function sanitizeTaskIntent(text) {
1902
+ return text.split("</task-intent>").join("[/task-intent]");
1903
+ }
1904
+ -//# sourceMappingURL=prompts.js.map
1905
+
1906
+ +//# sourceMappingURL=prompts.js.map
1907
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/rejected-recovery.d.ts
1908
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/rejected-recovery.d.ts
1909
+ @@ -3,9 +3,9 @@
1910
+ import { type SessionEntry } from "../../../session-manager.ts";
1911
+ export declare const REJECTED_RECOVERY_ENTRY = "compaction-recovery-rejected";
1912
+ /** Shared with core admission: a fallback's conservative estimate cannot overrule a usable summary. */
1913
+ -export declare function wouldCompactionOverflow(pathEntries: SessionEntry[], compactionResult: CompactionResult, fromExtension: boolean, model: Model<string>, settings: CompactionPreparation["settings"]): boolean;
1914
+ +export declare function wouldCompactionOverflow(pathEntries: SessionEntry[], compactionResult: CompactionResult, fromExtension: boolean, model: Model<string>, settings: CompactionPreparation["settings"], pendingMessages?: CompactionPreparation["messagesToSummarize"]): boolean;
1915
+ /** State, not elapsed time or a synthetic message revision, releases the latch. */
1916
+ export declare function compactionRecoveryStateKey(branch: readonly SessionEntry[], model: Model<string> | undefined, settings: CompactionPreparation["settings"]): string;
1917
+ /** Persisted on the branch, so extension reload and session reopen cannot retry it. */
1918
+ export declare function isAutomaticCompactionBlocked(branch: readonly SessionEntry[], model: Model<string> | undefined, settings: CompactionPreparation["settings"]): boolean;
1919
+ -//# sourceMappingURL=rejected-recovery.d.ts.map
1920
+
1921
+ +//# sourceMappingURL=rejected-recovery.d.ts.map
1922
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/rejected-recovery.js
1923
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/rejected-recovery.js
1924
+ @@ -3,10 +3,12 @@
1925
+ import { admitCursorHistory, cursorAdmissionBudgetBytes } from "../../../cursor-history-admission.js";
1926
+ import { filterContextExcludedMessages } from "../../../messages.js";
1927
+ import { buildContextEntries, buildSessionContext, } from "../../../session-manager.js";
1928
+ +import { getOpenAiRemoteCompactionDetails, convertPendingMessages } from "./openai-remote-convert.js";
1929
+ +import { protectedRemoteInput } from "./task-intent.js";
1930
+ import { resolveEffectiveReserveTokens } from "./policy.js";
1931
+ export const REJECTED_RECOVERY_ENTRY = "compaction-recovery-rejected";
1932
+ /** Shared with core admission: a fallback's conservative estimate cannot overrule a usable summary. */
1933
+ -export function wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, model, settings) {
1934
+ +export function wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, model, settings, pendingMessages = []) {
1935
+ const currentLeaf = pathEntries.at(-1);
1936
+ if (!currentLeaf)
1937
+ return false;
1938
+ @@ -29,7 +31,13 @@
1939
+ budgetBytes: cursorAdmissionBudgetBytes(model.contextWindow),
1940
+ }).messages ?? messages;
1941
+ }
1942
+ - const tokens = filterContextExcludedMessages(messages).reduce((total, message) => total + estimateTokens(message), 0);
1943
+ + messages = [...messages, ...pendingMessages];
1944
+ + const remote = getOpenAiRemoteCompactionDetails(compactionResult.details);
1945
+ + const budgetMessages = remote ? [{ role: "user", content: JSON.stringify([
1946
+ + ...protectedRemoteInput(remote.replacementInput, remote.intentProtection),
1947
+ + ...convertPendingMessages(pendingMessages, model),
1948
+ + ]) }] : messages;
1949
+ + const tokens = filterContextExcludedMessages(budgetMessages).reduce((total, message) => total + estimateTokens(message), 0);
1950
+ return tokens > model.contextWindow - resolveEffectiveReserveTokens(model.contextWindow, settings);
1951
+ }
1952
+ /** State, not elapsed time or a synthetic message revision, releases the latch. */
1953
+ @@ -70,4 +78,4 @@
1954
+ return false;
1955
+ return ("stateKey" in rejection.data && rejection.data.stateKey === compactionRecoveryStateKey(branch, model, settings));
1956
+ }
1957
+ -//# sourceMappingURL=rejected-recovery.js.map
1958
+
1959
+ +//# sourceMappingURL=rejected-recovery.js.map
1960
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative-summary.js
1961
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative-summary.js
1962
+ @@ -4,6 +4,7 @@
1963
+ import { consumeStreamWithIdleTimeout, DEFAULT_SUMMARIZATION_IDLE_TIMEOUT_MS, summarizationMaxDurationMs, } from "../../../compaction/stream-watchdog.js";
1964
+ import { convertToLlm } from "../../../messages.js";
1965
+ import { repairOrphanedToolResults } from "./repair-tool-pairs.js";
1966
+ +import { estimateTotalTokens } from "./overflow-retry.js";
1967
+ import { normalizeSummarizationTurnOrder } from "./summarization-turn-order.js";
1968
+ const SUMMARY_TOKEN_HEADROOM = 32_768;
1969
+ const SUMMARY_CONTEXT_WINDOW_RESERVE_RATIO = 0.5;
1970
+ @@ -106,6 +107,15 @@
1971
+ messages: repairOrphanedToolResults(normalizeSummarizationTurnOrder(convertToLlm(providerRequest?.messages ?? requestMessages))),
1972
+ ...(options.snapshot.tools && options.snapshot.tools.length > 0 ? { tools: options.snapshot.tools } : {}),
1973
+ };
1974
+ + const inputTokens = estimateTotalTokens([
1975
+ + ...requestContext.messages,
1976
+ + { role: "user", content: requestContext.systemPrompt },
1977
+ + { role: "user", content: JSON.stringify(requestContext.tools ?? []) },
1978
+ + ]);
1979
+ + if (inputTokens + summaryMaxTokens(options.snapshot.model, options.snapshot.contextWindow) > options.snapshot.contextWindow) {
1980
+ + throw Object.assign(new Error("Compaction request exceeds the selected model context budget"),
1981
+ + { failure: { kind: "context_budget", provider: options.snapshot.model.provider, model: options.snapshot.model.id } });
1982
+ + }
1983
+ const headers = providerRequest
1984
+ ? await providerRequest.transformHeaders(options.auth.headers ?? {})
1985
+ : options.auth.headers;
1986
+ @@ -113,6 +123,8 @@
1987
+ apiKey: options.auth.apiKey,
1988
+ headers,
1989
+ extraBody: options.auth.extraBody,
1990
+ + env: options.auth.env,
1991
+ + streamKind: "auxiliary",
1992
+ onPayload: async (payload, model) => {
1993
+ const sanitized = model.api === "anthropic-messages" ? sanitizeAnthropicPayload(payload) : payload;
1994
+ return providerRequest ? await providerRequest.transformPayload(sanitized) : sanitized;
1995
+ @@ -142,4 +154,4 @@
1996
+ options.signal.removeEventListener("abort", onCallerAbort);
1997
+ }
1998
+ }
1999
+ -//# sourceMappingURL=speculative-summary.js.map
2000
+
2001
+ +//# sourceMappingURL=speculative-summary.js.map
2002
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.d.ts
2003
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.d.ts
2004
+ @@ -4,7 +4,7 @@
2005
+ import { type WarmAnchorSnapshot } from "../../../compaction/warm-anchor.ts";
2006
+ import type { ModelRegistry } from "../../../model-registry.ts";
2007
+ import type { ReadonlySessionManager } from "../../../session-manager.ts";
2008
+ -import type { ApplyCompactionResult, ContextUsage, ProviderRequestPreparation } from "../../types.ts";
2009
+ +import type { CompactionFailure, ApplyCompactionResult, ContextUsage, ProviderRequestPreparation } from "../../types.ts";
2010
+ import { type MergedCompactionPromptVariant } from "./prompts.ts";
2011
+ export { createEmergencyPruneLatch, type EmergencyPruneLatch, hardLimitEmergencyPrune, truncateContextMessages, } from "./emergency-prune.ts";
2012
+ type CompactionProgressCallback = (delta: string) => void;
2013
+ @@ -14,13 +14,14 @@
2014
+ modelRegistry?: ModelRegistry;
2015
+ getContextUsage(): ContextUsage | undefined;
2016
+ getCompactionSettings?(): CompactionPreparation["settings"];
2017
+ - getCompactionModel?(): Model<any> | undefined;
2018
+ + getCompactionModel?(inputTokens?: number): Model<any> | undefined;
2019
+ getMessageRevision(): number;
2020
+ getSystemPrompt?(): string;
2021
+ prepareProviderRequest?(messages: AgentMessage[]): Promise<ProviderRequestPreparation>;
2022
+ isIdle?(): boolean;
2023
+ applyCompaction(precomputed: CompactionResult, options: {
2024
+ reason: "extension";
2025
+ + blocking?: boolean;
2026
+ expectedRevision?: number;
2027
+ expectedWarmAnchor?: WarmAnchorSnapshot;
2028
+ signal?: AbortSignal;
2029
+ @@ -56,7 +57,7 @@
2030
+ * text happens to look retryable must still surface loudly; the message
2031
+ * string alone cannot encode that.
2032
+ */
2033
+ -export type SummaryRequestFailureKind = "upstream-stream-truncated";
2034
+ +export type SummaryRequestFailureKind = "upstream-stream-truncated" | CompactionFailure["kind"];
2035
+ export declare class SummaryRequestError extends Error {
2036
+ readonly transient: boolean;
2037
+ readonly failureKind?: SummaryRequestFailureKind;
2038
+ @@ -70,9 +71,12 @@
2039
+ constructor(message: string, transient: boolean, failureKind?: SummaryRequestFailureKind, refused?: boolean);
2040
+ }
2041
+ export declare class SummaryGenerationError extends Error {
2042
+ - readonly kind: "auth" | "empty-summary";
2043
+ - constructor(kind: "auth" | "empty-summary", message: string);
2044
+ + readonly kind: CompactionFailure["kind"];
2045
+ + readonly failure: CompactionFailure;
2046
+ + constructor(kind: CompactionFailure["kind"], message: string);
2047
+ }
2048
+ +export declare function compactionFailure(error: unknown, model?: { provider: string; id: string }): CompactionFailure;
2049
+ +export declare function validateSummaryResponse(response: unknown): { summaryText: string; taskIntent?: string };
2050
+ /**
2051
+ * Output budget for one summarization request. Adaptive-thinking models emit
2052
+ * reasoning tokens before the summary text, so the legacy flat 8192 cap could
2053
+ @@ -104,6 +108,6 @@
2054
+ * misreports as an auth failure.
2055
+ */
2056
+ export declare function runExtensionCompaction(context: SpeculativeCompactionContext, snapshot: SpeculativeCompactionSnapshot, signal?: AbortSignal, onProgress?: CompactionProgressCallback): Promise<CompactionResult | undefined>;
2057
+ -export declare function applyGeneratedCompaction(context: SpeculativeCompactionContext, snapshot: SpeculativeCompactionSnapshot | undefined, getCurrentGeneration: () => number, compaction: CompactionResult | undefined, signal?: AbortSignal): Promise<SpeculativeCompactionResult>;
2058
+ +export declare function applyGeneratedCompaction(context: SpeculativeCompactionContext, snapshot: SpeculativeCompactionSnapshot | undefined, getCurrentGeneration: () => number, compaction: CompactionResult | undefined, signal?: AbortSignal, blocking?: boolean): Promise<SpeculativeCompactionResult>;
2059
+ export declare function applySpeculativeCompaction(context: SpeculativeCompactionContext, snapshot: SpeculativeCompactionSnapshot | undefined, getCurrentGeneration: () => number, generate: () => Promise<CompactionResult | undefined>): Promise<SpeculativeCompactionResult>;
2060
+ -//# sourceMappingURL=speculative.d.ts.map
2061
+
2062
+ +//# sourceMappingURL=speculative.d.ts.map
2063
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js
2064
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js
2065
+ @@ -5,12 +5,12 @@
2066
+ import { CredentialFailoverError, TURN_RETRY_SUPPRESSION_PREFIX } from "../../../credential-pool/failover.js";
2067
+ import { convertToLlm } from "../../../messages.js";
2068
+ import { pruneToolResults } from "./emergency-prune.js";
2069
+ -import { allowOverflowRetry, boundSummarizationInput, SUMMARIZATION_INPUT_BUDGET_RATIO, SummarizationOverflowExhaustedError, shrinkSummarizationInputForOverflowRetry, } from "./overflow-retry.js";
2070
+ +import { estimateTotalTokens, SummarizationOverflowExhaustedError } from "./overflow-retry.js";
2071
+ import { computeEffectiveKeepRecentTokens, computeEffectiveThreshold } from "./policy.js";
2072
+ import { buildPrompt } from "./prompts.js";
2073
+ import { generateSummaryMessage, getSummaryText, hasSummarizationReasoningOverride, isAssistantMessage, } from "./speculative-summary.js";
2074
+ import { allowSummarizationRetry, DEFAULT_SUMMARIZATION_RETRY_POLICY } from "./summarization-retry.js";
2075
+ -import { extractTaskIntent, resolveInheritedTaskIntent } from "./task-intent.js";
2076
+ +import { extractTaskIntent, resolveInheritedTaskIntent, taskIntentSummary, taskIntentMetadata } from "./task-intent.js";
2077
+ export { createEmergencyPruneLatch, hardLimitEmergencyPrune, truncateContextMessages, } from "./emergency-prune.js";
2078
+ import { computeStructuralYield } from "./yield.js";
2079
+ const DEFAULT_CONTEXT_WINDOW = 200_000;
2080
+ @@ -30,23 +30,35 @@
2081
+ this.transient = transient;
2082
+ this.failureKind = failureKind;
2083
+ this.refused = refused;
2084
+ + this.failure = { kind: refused ? "refused" : failureKind === "upstream-stream-truncated" ? "incomplete" : failureKind ?? "provider" };
2085
+ }
2086
+ }
2087
+ +export function compactionFailure(error, model) {
2088
+ + const kind = error?.refused ? "refused" : error?.failure?.kind ?? error?.kind;
2089
+ + const known = ["invalid_model", "auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable", "refused", "timeout", "incomplete", "empty", "context_budget", "stale", "provider"];
2090
+ + return { kind: known.includes(kind) ? kind
2091
+ + : error instanceof StreamDurationBudgetError || error instanceof StreamIdleTimeoutError || error instanceof SummarizationTotalBudgetError || error?.name === "TimeoutError" ? "timeout"
2092
+ + : error instanceof SummarizationOverflowExhaustedError ? "context_budget" : "provider",
2093
+ + ...(model ? { provider: model.provider, model: model.id } : {}),
2094
+ + ...(error?.failure?.provider ? { provider: error.failure.provider } : {}),
2095
+ + ...(error?.failure?.model ? { model: error.failure.model } : {}) };
2096
+ +}
2097
+ +export function validateSummaryResponse(response) {
2098
+ + if (response && isRefusalStop(response))
2099
+ + throw new SummaryRequestError("Compaction summary refused", false, undefined, true);
2100
+ + if (!response || !isAssistantMessage(response) || response.stopReason !== "stop" ||
2101
+ + response.content.some((block) => block.type === "toolCall" || block.type === "toolUse"))
2102
+ + throw new SummaryGenerationError("incomplete", "Compaction summary did not finish normally");
2103
+ + const parsed = extractTaskIntent(getSummaryText(response));
2104
+ + if (!parsed.summaryText.trim())
2105
+ + throw new SummaryGenerationError("empty", "Compaction summary body is empty");
2106
+ + return parsed;
2107
+ +}
2108
+ const UPSTREAM_STREAM_TRUNCATED_PATTERN = /(?:^|[^A-Za-z0-9_])upstream_stream_truncated(?:[^A-Za-z0-9_]|$)/;
2109
+ -/**
2110
+ - * Only failures with no cheaper recovery earn another billed request.
2111
+ - *
2112
+ - * Every class that `classifyRequiredCompactionFallbackFailure` recognizes
2113
+ - * (watchdog timeouts, the compaction-wide total budget, terminal provider and
2114
+ - * credential failures, `upstream-stream-truncated`, overflow exhaustion,
2115
+ - * empty-summary generation failures) already
2116
+ - * has a deterministic zero-LLM recovery, and context overflow is answered by
2117
+ - * shrinking the input in the surrounding loop - replaying those would pay for a
2118
+ - * summarization the fallback can rebuild for free. The classes checked here are
2119
+ - * mirrored from that classifier rather than imported, because
2120
+ - * `deterministic-fallback.ts` imports this module.
2121
+ - */
2122
+ +/** Only pre-output transient failures may retry the same selected model. */
2123
+ function isRetryableSummaryAttempt(error) {
2124
+ + if (error?.failure?.kind && error.failure.kind !== "provider")
2125
+ + return false;
2126
+ if (error instanceof StreamDurationBudgetError || error instanceof StreamIdleTimeoutError)
2127
+ return false;
2128
+ if (error instanceof SummarizationTotalBudgetError)
2129
+ @@ -55,17 +67,12 @@
2130
+ return false;
2131
+ if (error instanceof SummaryGenerationError)
2132
+ return false;
2133
+ - // Mirrors the `summarization-provider-failure` class: credential rotation has
2134
+ - // already spent every slot it may spend, and the marker means output was
2135
+ - // committed, so another billed attempt buys nothing the fallback cannot
2136
+ - // rebuild for free. Message text alone must not re-authorize it - the wrapped
2137
+ - // provider detail can read as transient (#1741).
2138
+ if (error instanceof CredentialFailoverError)
2139
+ return false;
2140
+ if (error instanceof Error && error.message.startsWith(TURN_RETRY_SUPPRESSION_PREFIX))
2141
+ return false;
2142
+ if (error instanceof SummaryRequestError)
2143
+ - return error.failureKind === undefined && error.transient;
2144
+ + return !error.refused && error.failureKind === undefined && error.transient;
2145
+ if (error instanceof Error)
2146
+ return isRetryableErrorMessage(error.message);
2147
+ return false;
2148
+ @@ -76,6 +83,9 @@
2149
+ function summaryRequestFailureKind(response) {
2150
+ if (isRefusalStop(response))
2151
+ return undefined;
2152
+ + const kind = response.failure?.kind;
2153
+ + if (["auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable", "timeout", "incomplete", "context_budget"].includes(kind))
2154
+ + return kind;
2155
+ return UPSTREAM_STREAM_TRUNCATED_PATTERN.test(response.errorMessage ?? "") ? "upstream-stream-truncated" : undefined;
2156
+ }
2157
+ export class SummaryGenerationError extends Error {
2158
+ @@ -83,6 +93,7 @@
2159
+ super(message);
2160
+ this.name = "SummaryGenerationError";
2161
+ this.kind = kind;
2162
+ + this.failure = { kind };
2163
+ }
2164
+ }
2165
+ /**
2166
+ @@ -131,7 +142,7 @@
2167
+ }
2168
+ /**
2169
+ * Generate a compaction summary for the snapshot. Resolves `undefined` only
2170
+ - * when the request was aborted (caller signal or an aborted stream); every
2171
+ + * when the caller signal was aborted; every
2172
+ * other failure throws — {@link SummaryGenerationError} for diagnosable
2173
+ * generation failures, the raw provider error otherwise. Abort is checked
2174
+ * before and after credential resolution so a cancelled request never
2175
+ @@ -140,51 +151,54 @@
2176
+ export async function runExtensionCompaction(context, snapshot, signal, onProgress) {
2177
+ if (signal?.aborted)
2178
+ return undefined;
2179
+ - // A provider is authenticated for summarization by a resolved key, a credential
2180
+ - // request header, or ambient request-time auth. The ambient marker comes from the
2181
+ - // same provider resolution normal turns use; an unconfigured keyed provider still
2182
+ - // has none of these and is rejected before a request.
2183
+ - const authenticated = (resolved) => resolved?.ok === true && Boolean(resolved.apiKey || hasCredentialHeaders(resolved.headers) || resolved.ambient);
2184
+ - let selectedModel = context.getCompactionModel?.() ?? snapshot.model;
2185
+ - let auth = await context.modelRegistry?.getApiKeyAndHeaders(selectedModel);
2186
+ - if (signal?.aborted)
2187
+ - return undefined;
2188
+ - // A configured summary model whose credentials could not be confirmed earlier (#830) must not
2189
+ - // disable compaction: without credentials at request time, summarize on the session model.
2190
+ - if (selectedModel !== snapshot.model && !authenticated(auth)) {
2191
+ - selectedModel = snapshot.model;
2192
+ - auth = await context.modelRegistry?.getApiKeyAndHeaders(selectedModel);
2193
+ + const prompt = buildPrompt({
2194
+ + variant: snapshot.promptVariant,
2195
+ + previousSummary: snapshot.preparation.previousSummary,
2196
+ + taskIntent: resolveInheritedTaskIntent(snapshot.branchEntries ?? []),
2197
+ + customInstructions: snapshot.customInstructions,
2198
+ + });
2199
+ + const messages = [...snapshot.preparation.messagesToSummarize, ...snapshot.preparation.turnPrefixMessages];
2200
+ + const inputTokens = estimateTotalTokens([
2201
+ + ...messages,
2202
+ + { role: "user", content: prompt.user },
2203
+ + { role: "user", content: snapshot.systemPrompt ?? prompt.system },
2204
+ + { role: "user", content: JSON.stringify(snapshot.tools ?? []) },
2205
+ + ]);
2206
+ + const selectedModel = context.getCompactionModel?.(inputTokens) ?? snapshot.model;
2207
+ + const selectedSnapshot = { ...snapshot, model: selectedModel, contextWindow: selectedModel.contextWindow, selectionMode: context.getCompactionSettings?.().model === undefined || context.getCompactionSettings?.().model === "auto" ? "auto" : "explicit" };
2208
+ + let auth;
2209
+ + try {
2210
+ + const runtime = context.modelRegistry?.modelRuntime;
2211
+ + if (runtime) {
2212
+ + const prepared = await runtime.prepareRequest(selectedModel, { signal });
2213
+ + auth = { ok: true, ...prepared.options, baseUrl: prepared.model.baseUrl, ambient: true };
2214
+ + }
2215
+ + else {
2216
+ + auth = await context.modelRegistry?.getApiKeyAndHeaders(selectedModel);
2217
+ + }
2218
+ + }
2219
+ + catch (error) {
2220
+ if (signal?.aborted)
2221
+ return undefined;
2222
+ + const kind = error?.failure?.kind ?? error?.kind;
2223
+ + const known = ["auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable"];
2224
+ + const failure = { kind: kind === "auth" ? "auth_missing" : known.includes(kind) ? kind : "provider",
2225
+ + provider: selectedModel.provider, model: selectedModel.id };
2226
+ + throw Object.assign(new SummaryGenerationError(failure.kind, "Compaction request authentication failed"), { failure, cause: error });
2227
+ }
2228
+ - const selectedSnapshot = selectedModel === snapshot.model
2229
+ - ? snapshot
2230
+ - : { ...snapshot, model: selectedModel, contextWindow: selectedModel.contextWindow };
2231
+ - if (!auth?.ok || !authenticated(auth)) {
2232
+ - const detail = auth && !auth.ok ? auth.error : `no credentials resolved for provider "${selectedModel.provider}"`;
2233
+ - throw new SummaryGenerationError("auth", `summarization credentials unavailable: ${detail}`);
2234
+ + if (signal?.aborted)
2235
+ + return undefined;
2236
+ + if (!auth?.ok || !(auth.apiKey || hasCredentialHeaders(auth.headers) || auth.ambient)) {
2237
+ + const failure = { kind: "auth_missing", provider: selectedModel.provider, model: selectedModel.id };
2238
+ + throw Object.assign(new SummaryGenerationError(failure.kind, "Compaction request authentication unavailable"), { failure });
2239
+ }
2240
+ const requestSnapshot = auth.baseUrl
2241
+ ? { ...selectedSnapshot, model: { ...selectedModel, baseUrl: auth.baseUrl } }
2242
+ : selectedSnapshot;
2243
+ - const prompt = buildPrompt({
2244
+ - variant: requestSnapshot.promptVariant,
2245
+ - previousSummary: requestSnapshot.preparation.previousSummary,
2246
+ - taskIntent: resolveInheritedTaskIntent(requestSnapshot.branchEntries ?? []),
2247
+ - customInstructions: requestSnapshot.customInstructions,
2248
+ - });
2249
+ - const promptTokens = approxTokens(prompt.user);
2250
+ - let messages = boundSummarizationInput(pruneToolResults([...requestSnapshot.preparation.messagesToSummarize, ...requestSnapshot.preparation.turnPrefixMessages], requestSnapshot.contextWindow, SUMMARIZATION_INPUT_BUDGET_RATIO), requestSnapshot.contextWindow, promptTokens);
2251
+ // One deadline for the whole compaction. The per-attempt budget scales with the
2252
+ // input and every retry re-arms it, so without this a large session could hold
2253
+ // the turn for attempt-budget x attempts with no bound the user can predict.
2254
+ const deadline = createSummarizationDeadline(summarizationTotalBudgetMs(requestSnapshot.preparation.settings.summarizationMaxDurationMs));
2255
+ - const overflowRetryStartMs = Date.now();
2256
+ - let overflowAttempts = 0;
2257
+ - const summarizationToolsOffered = (requestSnapshot.tools?.length ?? 0) > 0;
2258
+ - const reasoningOverrideOffered = hasSummarizationReasoningOverride(requestSnapshot.model);
2259
+ - let toolUseRetrySpent = false;
2260
+ - let reasoningOverrideRetrySpent = false;
2261
+ while (true) {
2262
+ if (signal?.aborted)
2263
+ return undefined;
2264
+ @@ -210,7 +224,7 @@
2265
+ response = await retryTransientCall(async () => {
2266
+ const attempt = await generateSummaryMessage({
2267
+ context,
2268
+ - forbidToolCalls: toolUseRetrySpent,
2269
+ + forbidToolCalls: true,
2270
+ // Re-clamped per attempt, not per loop turn: a retry that starts
2271
+ // near the deadline gets only what is left, and none starts past it.
2272
+ maxDurationMs: deadline.attemptBudgetMs(attemptBudgetMs),
2273
+ @@ -223,15 +237,15 @@
2274
+ apiKey: auth.apiKey,
2275
+ headers: auth.headers,
2276
+ extraBody: auth.extraBody,
2277
+ + env: auth.env,
2278
+ },
2279
+ - ...(reasoningOverrideRetrySpent ? { omitReasoningOptions: true } : {}),
2280
+ });
2281
+ if (attempt &&
2282
+ isAssistantMessage(attempt) &&
2283
+ - attempt.stopReason === "error" &&
2284
+ + (attempt.stopReason === "error" || isRefusalStop(attempt)) &&
2285
+ !isContextOverflow(attempt, requestSnapshot.contextWindow)) {
2286
+ const failureKind = summaryRequestFailureKind(attempt);
2287
+ - throw new SummaryRequestError(attempt.errorMessage || "Compaction summary request failed", failureKind !== undefined || isRetryableAssistantError(attempt), failureKind, isRefusalStop(attempt));
2288
+ + throw new SummaryRequestError(attempt.errorMessage || "Compaction summary request failed", attempt.content.length === 0 && failureKind === undefined && isRetryableAssistantError(attempt), failureKind, isRefusalStop(attempt));
2289
+ }
2290
+ return attempt;
2291
+ }, (error) => retryEligible &&
2292
+ @@ -244,88 +258,49 @@
2293
+ return undefined;
2294
+ throw error;
2295
+ }
2296
+ - if (!response)
2297
+ + if (!response && signal?.aborted)
2298
+ return undefined;
2299
+ - if (isAssistantMessage(response) && isContextOverflow(response, requestSnapshot.contextWindow)) {
2300
+ - overflowAttempts++;
2301
+ - const elapsedMs = Date.now() - overflowRetryStartMs;
2302
+ - const retryMessages = allowOverflowRetry(overflowAttempts, elapsedMs)
2303
+ - ? shrinkSummarizationInputForOverflowRetry(messages, requestSnapshot.contextWindow, promptTokens)
2304
+ - : undefined;
2305
+ - if (!retryMessages) {
2306
+ - throw new SummarizationOverflowExhaustedError(overflowAttempts, elapsedMs);
2307
+ - }
2308
+ - messages = retryMessages;
2309
+ - continue;
2310
+ + if (response && isAssistantMessage(response) && isContextOverflow(response, requestSnapshot.contextWindow)) {
2311
+ + const failure = { kind: "context_budget", provider: selectedModel.provider, model: selectedModel.id };
2312
+ + throw Object.assign(new SummaryGenerationError(failure.kind, "Compaction input exceeds the provider context budget"), { failure });
2313
+ }
2314
+ - if (isAssistantMessage(response) && response.stopReason === "aborted") {
2315
+ - // A partial summary from an aborted stream must never be applied.
2316
+ - return undefined;
2317
+ - }
2318
+ - if (isAssistantMessage(response) && response.stopReason === "error") {
2319
+ - // Surface the real provider failure instead of silently degrading
2320
+ - // into a generic "Compaction cancelled". Preserve the structured
2321
+ - // truncation class here so downstream recovery never trusts arbitrary
2322
+ - // thrown error text as authorization for destructive context reduction.
2323
+ - const failureKind = summaryRequestFailureKind(response);
2324
+ - throw new SummaryRequestError(response.errorMessage || "Compaction summary request failed", failureKind !== undefined || isRetryableAssistantError(response), failureKind, isRefusalStop(response));
2325
+ - }
2326
+ - const summary = getSummaryText(response);
2327
+ - if (!summary) {
2328
+ - const stopReason = isAssistantMessage(response) ? response.stopReason : "unknown";
2329
+ - // A summarizer can hijack the forwarded agent tools and answer with a
2330
+ - // bare tool call (observed on chatgpt-subscription gpt-5.6-sol, 2026-08-31),
2331
+ - // which used to surface as a terminal empty-summary failure. Spend one
2332
+ - // retry with tool calling forbidden; the tools stay in the request
2333
+ - // because Anthropic rejects tool_use history without the tools param.
2334
+ - if (stopReason === "toolUse" && summarizationToolsOffered && !toolUseRetrySpent) {
2335
+ - toolUseRetrySpent = true;
2336
+ - continue;
2337
+ - }
2338
+ - // Some OpenAI-completions relays complete with a normal stop but zero
2339
+ - // text when the summarization prompt pins an explicit reasoning effort
2340
+ - // (GLM-5.3-flash behind a custom relay, 2026-09-17: HTTP 200 carrying
2341
+ - // only the role prelude). Spend one retry without the reasoning
2342
+ - // override, but only when the first attempt carried one: a model with
2343
+ - // no override would just replay the identical prompt. On Anthropic the
2344
+ - // override is `thinkingEnabled: false`, so the retry runs with the
2345
+ - // provider's default thinking, clamped by the compaction deadline.
2346
+ - // Persistent emptiness still throws and the deterministic fallback
2347
+ - // owns recovery.
2348
+ - if (stopReason === "stop" && reasoningOverrideOffered && !reasoningOverrideRetrySpent) {
2349
+ - reasoningOverrideRetrySpent = true;
2350
+ - continue;
2351
+ - }
2352
+ - throw new SummaryGenerationError("empty-summary", `summarization response contained no text (stopReason: ${stopReason})`);
2353
+ - }
2354
+ + const parsedSummary = validateSummaryResponse(response);
2355
+ + const inheritedIntent = resolveInheritedTaskIntent(snapshot.branchEntries ?? []);
2356
+ + if (inheritedIntent && parsedSummary.taskIntent && parsedSummary.taskIntent !== inheritedIntent)
2357
+ + throw new SummaryGenerationError("incomplete", "Compaction changed protected task intent");
2358
+ + const taskIntent = inheritedIntent ?? parsedSummary.taskIntent;
2359
+ + const summary = taskIntentSummary(parsedSummary.summaryText, taskIntent);
2360
+ // Informational only: the core rejects an applied compaction that would
2361
+ // still overflow (_wouldCompactionOverflow). Rejecting here based on the
2362
+ // size of the *discarded* input made large sessions uncompactable.
2363
+ const tokenEstimate = estimateContextTokens(convertToLlm(messages)).tokens + approxTokens(summary);
2364
+ - const parsedSummary = extractTaskIntent(summary);
2365
+ - const taskIntent = parsedSummary.taskIntent ?? resolveInheritedTaskIntent(snapshot.branchEntries ?? []);
2366
+ return {
2367
+ - summary: parsedSummary.summaryText,
2368
+ + summary,
2369
+ firstKeptEntryId: snapshot.preparation.firstKeptEntryId,
2370
+ tokensBefore: snapshot.preparation.tokensBefore,
2371
+ details: {
2372
+ schema: SUMMARY_SCHEMA,
2373
+ + selectionMode: selectedSnapshot.selectionMode,
2374
+ + requestedProvider: selectedModel.provider,
2375
+ + requestedModel: selectedModel.id,
2376
+ + ...(isAssistantMessage(response) && response.provider && response.responseModel
2377
+ + ? { observedProvider: response.provider, observedModel: response.responseModel } : {}),
2378
+ promptVariant: snapshot.promptVariant,
2379
+ tokenEstimate,
2380
+ structuralYield: computeStructuralYield({
2381
+ previousSummary: snapshot.preparation.previousSummary ?? "",
2382
+ messagesToSummarize: snapshot.preparation.messagesToSummarize,
2383
+ turnPrefixMessages: snapshot.preparation.turnPrefixMessages,
2384
+ - summary: parsedSummary.summaryText,
2385
+ + summary,
2386
+ tokensBefore: snapshot.preparation.tokensBefore,
2387
+ }),
2388
+ ...(snapshot.origin ? { origin: snapshot.origin } : {}),
2389
+ - ...(taskIntent ? { taskIntent } : {}),
2390
+ + ...(taskIntent ? { taskIntent: taskIntentMetadata(taskIntent) } : {}),
2391
+ },
2392
+ };
2393
+ }
2394
+ }
2395
+ -export async function applyGeneratedCompaction(context, snapshot, getCurrentGeneration, compaction, signal) {
2396
+ +export async function applyGeneratedCompaction(context, snapshot, getCurrentGeneration, compaction, signal, blocking = true) {
2397
+ const provider = context.model?.provider;
2398
+ if ((provider === "cursor" || provider === "cursor-cli-oauth") && context.isIdle && !context.isIdle()) {
2399
+ return { applied: false, reason: "rejected" };
2400
+ @@ -343,6 +318,7 @@
2401
+ }
2402
+ return await context.applyCompaction(compaction, {
2403
+ reason: "extension",
2404
+ + blocking,
2405
+ ...(revisionUnchanged || !warmAnchor
2406
+ ? { expectedRevision: snapshot.expectedRevision }
2407
+ : { expectedWarmAnchor: warmAnchor }),
2408
+ @@ -355,4 +331,4 @@
2409
+ const compaction = await generate();
2410
+ return await applyGeneratedCompaction(context, snapshot, getCurrentGeneration, compaction);
2411
+ }
2412
+ -//# sourceMappingURL=speculative.js.map
2413
+
2414
+ +//# sourceMappingURL=speculative.js.map
2415
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/task-intent.js
2416
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/task-intent.js
2417
+ @@ -13,7 +13,7 @@
2418
+ if (!block)
2419
+ break;
2420
+ if (taskIntent === undefined)
2421
+ - taskIntent = capUtf8Bytes(block.inner.trim(), TASK_INTENT_BYTE_CAP);
2422
+ + taskIntent = block.inner.trim();
2423
+ remaining = `${remaining.slice(0, block.start)}${remaining.slice(block.end)}`;
2424
+ }
2425
+ const summaryBlocks = collectWellFormedBlocks(remaining, SUMMARY_OPEN, SUMMARY_CLOSE);
2426
+ @@ -41,15 +41,16 @@
2427
+ continue;
2428
+ const typedDetails = details;
2429
+ const schema = typeof typedDetails.schema === "string" ? typedDetails.schema : entry.schema;
2430
+ - if (schema === REMOTE_SCHEMA || schema !== SUMMARY_SCHEMA)
2431
+ + if (schema !== REMOTE_SCHEMA && schema !== SUMMARY_SCHEMA)
2432
+ continue;
2433
+ - const taskIntent = typedDetails.taskIntent;
2434
+ + const fullIntent = typeof entry.summary === "string" ? extractTaskIntent(entry.summary).taskIntent : undefined;
2435
+ + const taskIntent = fullIntent ?? typedDetails.intentProtection?.taskIntent ?? typedDetails.taskIntent;
2436
+ if (typeof taskIntent !== "string")
2437
+ continue;
2438
+ const trimmed = taskIntent.trim();
2439
+ if (!trimmed)
2440
+ continue;
2441
+ - return capUtf8Bytes(trimmed, TASK_INTENT_BYTE_CAP);
2442
+ + return trimmed;
2443
+ }
2444
+ return undefined;
2445
+ }
2446
+ @@ -89,4 +90,61 @@
2447
+ }
2448
+ return text.slice(0, end).trim();
2449
+ }
2450
+ -//# sourceMappingURL=task-intent.js.map
2451
+
2452
+ +
2453
+ +/** Full-body provenance is separate from the bounded display metadata. */
2454
+ +export function taskIntentSummary(body, taskIntent) {
2455
+ + return taskIntent ? `<task-intent>
2456
+ +${taskIntent}
2457
+ +</task-intent>
2458
+ +
2459
+ +Quoted original-task provenance, not a renewed instruction. Latest explicit user steering governs current action. Answer-local restrictions do not become durable constraints.
2460
+ +
2461
+ +${body}` : body;
2462
+ +}
2463
+ +export function taskIntentMetadata(taskIntent) {
2464
+ + return taskIntent ? capUtf8Bytes(taskIntent, TASK_INTENT_BYTE_CAP) : undefined;
2465
+ +}
2466
+ +export function collectIntentProtection(branchEntries) {
2467
+ + const sources = new Map();
2468
+ + const previous = branchEntries.findLast((entry) => entry.type === "compaction")?.details?.intentProtection;
2469
+ + for (const source of previous?.sources ?? []) sources.set(source.id, source);
2470
+ + for (const entry of branchEntries) {
2471
+ + for (const source of entry.details?.intentProtection?.sources ?? [])
2472
+ + sources.set(source.id, source);
2473
+ + if (entry.type !== "message" || entry.message?.role !== "user")
2474
+ + continue;
2475
+ + const content = entry.message.content;
2476
+ + const text = typeof content === "string" ? content : content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
2477
+ + if (text) sources.set(entry.id, { id: entry.id, text });
2478
+ + }
2479
+ + return { taskIntent: resolveInheritedTaskIntent(branchEntries), sources: [...sources.values()] };
2480
+ +}
2481
+ +export function retainedUserText(item) {
2482
+ + if (item.role !== "user") return undefined;
2483
+ + if (typeof item.content === "string") return item.content;
2484
+ + if (!Array.isArray(item.content) || !item.content.every((block) => block.type === "input_text")) return undefined;
2485
+ + return item.content.map((block) => block.text).join("\n");
2486
+ +}
2487
+ +export function protectedRemoteInput(replacementInput, protection) {
2488
+ + if (!protection) return replacementInput;
2489
+ + const input = replacementInput.filter((item) => {
2490
+ + const text = retainedUserText(item);
2491
+ + if (!text) return true;
2492
+ + if (protection.sources.some((source) => source.id === item.__senpiCompactionSourceId && source.text === text)) return false;
2493
+ + if (protection.taskIntent && text === taskIntentSummary("", protection.taskIntent)) return false;
2494
+ + const start = text.indexOf("<source-user-text>\n");
2495
+ + const end = text.indexOf("\n</source-user-text>", start);
2496
+ + if (start < 0 || end < 0) return true;
2497
+ + let records;
2498
+ + try { records = JSON.parse(text.slice(start + 19, end)); } catch { return true; }
2499
+ + if (!Array.isArray(records) || !records.every((source) => protection.sources.some((known) => known.id === source.id && known.text === source.text))) return true;
2500
+ + const canonical = taskIntentSummary(`Quoted user source records; newer explicit steering takes precedence.\n<source-user-text>\n${JSON.stringify(records)}\n</source-user-text>`, protection.taskIntent);
2501
+ + return text !== canonical;
2502
+ + });
2503
+ + const remaining = protection.sources;
2504
+ + const text = taskIntentSummary(remaining.length ? `Quoted user source records; newer explicit steering takes precedence.\n<source-user-text>
2505
+ +${JSON.stringify(remaining)}
2506
+ +</source-user-text>` : "", protection.taskIntent);
2507
+ + return text ? [...input, { type: "message", role: "user", content: [{ type: "input_text", text }] }] : input;
2508
+ +}
2509
+ +//# sourceMappingURL=task-intent.js.map
2510
+ --- a/@code-yeongyu/senpi/dist/core/extensions/runner.js
2511
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/runner.js
2512
+ @@ -1191,9 +1191,9 @@
2513
+ get getCompactionModel() {
2514
+ runner.assertActive();
2515
+ return runner.getCompactionModelFn
2516
+ - ? () => {
2517
+ + ? (inputTokens) => {
2518
+ runner.assertActive();
2519
+ - return runner.getCompactionModelFn?.();
2520
+ + return runner.getCompactionModelFn?.(inputTokens);
2521
+ }
2522
+ : undefined;
2523
+ },
2524
+ @@ -1861,7 +1861,7 @@
2525
+ const event = {
2526
+ type: "before_provider_request",
2527
+ payload: currentPayload,
2528
+ - ...(request ? { model: request.model, headers: request.headers } : {}),
2529
+ + ...(request ? { model: request.model, headers: request.headers, streamKind: request.streamKind } : {}),
2530
+ };
2531
+ const handlerResult = await handler(event, this.createContext(ext.path));
2532
+ if (handlerResult !== undefined) {
2533
+ @@ -2058,4 +2058,4 @@
2534
+ : { action: "continue" };
2535
+ }
2536
+ }
2537
+ -//# sourceMappingURL=runner.js.map
2538
+
2539
+ +//# sourceMappingURL=runner.js.map
2540
+ --- a/@code-yeongyu/senpi/dist/core/extensions/types.d.ts
2541
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/types.d.ts
2542
+ @@ -55,6 +55,14 @@
2543
+ export type CompactionRejectionCause = "cancelled-by-extension" | "external-owner" | "would-overflow" | "circuit-breaker" | "per-turn-cap" | "stale-revision";
2544
+ /** Options for extension UI dialogs. */
2545
+ export interface ExtensionUIDialogOptions {
2546
+ + /** Exact native permission identity; correlation only, never an approval. */
2547
+ + nativePermission?: {
2548
+ + sessionID: string;
2549
+ + requestID: string;
2550
+ + correlationId: string;
2551
+ + };
2552
+ + /** Whether answering this dialog is required; omitted means required. */
2553
+ + required?: boolean;
2554
+ /** AbortSignal to programmatically dismiss the dialog. */
2555
+ signal?: AbortSignal;
2556
+ /** Timeout in milliseconds. Dialog auto-dismisses with live countdown display. */
2557
+ @@ -302,6 +310,7 @@
2558
+ }
2559
+ export interface ApplyCompactionOptions {
2560
+ reason: CompactionReason;
2561
+ + blocking?: boolean;
2562
+ expectedRevision?: number;
2563
+ /**
2564
+ * Content anchor for a warm summary: the compaction applies while this snapshot
2565
+ @@ -327,8 +336,14 @@
2566
+ signal?: AbortSignal;
2567
+ delta?: string;
2568
+ text?: string;
2569
+ +}
2570
+ +export interface CompactionFailure {
2571
+ + kind: "invalid_model" | "auth_missing" | "auth_expired" | "quota" | "org_denied" | "model_unavailable" | "refused" | "timeout" | "incomplete" | "empty" | "context_budget" | "stale" | "provider";
2572
+ + provider?: string;
2573
+ + model?: string;
2574
+ }
2575
+ export interface EndCompactionOptions {
2576
+ + failure?: CompactionFailure;
2577
+ reason: CompactionReason;
2578
+ signal?: AbortSignal;
2579
+ aborted?: boolean;
2580
+ @@ -479,8 +494,8 @@
2581
+ getContextUsage(): ContextUsage | undefined;
2582
+ /** Get resolved compaction settings from global/project/user overrides. */
2583
+ getCompactionSettings(): CompactionPreparation["settings"];
2584
+ - /** Session-selected usable summarization model; throws when every authenticated account is spent. */
2585
+ - getCompactionModel?(): Model<Api> | undefined;
2586
+ + /** Select a budget-fitting summary model; auto stays within the active provider and explicit failures never fall back. */
2587
+ + getCompactionModel?(inputTokens?: number): Model<Api> | undefined;
2588
+ /**
2589
+ * Longest a tool may block in the foreground before the active model's prompt
2590
+ * cache expires, or `undefined` when no cache-derived budget applies. Reads the
2591
+ @@ -1162,6 +1177,7 @@
2592
+ willRetry: boolean;
2593
+ }
2594
+ export interface SessionCompactRejectedEvent {
2595
+ + failure?: CompactionFailure;
2596
+ type: "session_compact";
2597
+ reason: CompactionReason;
2598
+ requestId: string;
2599
+ @@ -1298,6 +1314,7 @@
2600
+ model?: Model<Api>;
2601
+ /** Final header transform output for this request. Values are never persisted. */
2602
+ headers?: ProviderHeaders;
2603
+ + streamKind?: "main" | "auxiliary";
2604
+ }
2605
+ /**
2606
+ * Fired after request headers are assembled, before the provider HTTP call.
2607
+ @@ -1833,6 +1850,7 @@
2608
+ reason?: string;
2609
+ }
2610
+ export interface SessionBeforeCompactResult {
2611
+ + failure?: CompactionFailure;
2612
+ cancel?: boolean;
2613
+ compaction?: CompactionResult;
2614
+ /**
2615
+ @@ -2685,7 +2703,7 @@
2616
+ shutdown: () => void;
2617
+ getContextUsage: () => ContextUsage | undefined;
2618
+ getCompactionSettings: () => CompactionPreparation["settings"];
2619
+ - getCompactionModel?: () => Model<Api> | undefined;
2620
+ + getCompactionModel?: (inputTokens?: number) => Model<Api> | undefined;
2621
+ getPromptCacheSafeWaitSeconds?: () => number | undefined;
2622
+ getPromptCacheGoalBackstopMaxSeconds?: () => number;
2623
+ onUserActivity?: (listener: (kind: UserActivityKind) => void) => () => void;
2624
+ @@ -2850,4 +2868,4 @@
2625
+ error: string;
2626
+ stack?: string;
2627
+ }
2628
+ -//# sourceMappingURL=types.d.ts.map
2629
+
2630
+ +//# sourceMappingURL=types.d.ts.map