zigmini 0.9.0-beta.2 → 1.0.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 (145) hide show
  1. package/README.ko.md +35 -5
  2. package/README.md +35 -5
  3. package/assets/agent/gateway-rules.md +1 -1
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +51 -11
  8. package/docs/manual.md +52 -12
  9. package/host/omo-bridge/agent-control.ts +274 -0
  10. package/host/omo-bridge/bridge.ts +1 -1
  11. package/host/omo-bridge/continuity-control.ts +1 -1
  12. package/host/omo-bridge/model-control.ts +15 -3
  13. package/host/omo-bridge/task-control.ts +26 -16
  14. package/host/omo-check/patches/omo-5.1.48-compaction-recovery.patch +912 -0
  15. package/host/omo-check/patches/omo-5.1.48-gateway-port.manifest.json +12 -11
  16. package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
  17. package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
  18. package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
  19. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
  20. package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
  21. package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
  22. package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
  23. package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
  24. package/package.json +3 -2
  25. package/src/agent/api.ts +215 -0
  26. package/src/agent/definition.ts +177 -0
  27. package/src/agent/fields.ts +42 -0
  28. package/src/agent/files.ts +192 -0
  29. package/src/agent/legacy-files.ts +46 -0
  30. package/src/agent/list.ts +21 -0
  31. package/src/agent-rules.ts +14 -5
  32. package/src/approvals.ts +6 -1
  33. package/src/cli/main.ts +33 -0
  34. package/src/cli/messages.ts +10 -2
  35. package/src/cli/rotate-main.ts +2 -1
  36. package/src/cli/scan.ts +414 -0
  37. package/src/cli/security-import.ts +155 -0
  38. package/src/compact.ts +22 -0
  39. package/src/decisions.ts +6 -4
  40. package/src/engine/compat.json +3 -3
  41. package/src/engine/index.ts +1 -0
  42. package/src/engine/schema-guard.ts +12 -4
  43. package/src/engine/sdk.ts +8 -0
  44. package/src/formal-verification.ts +366 -0
  45. package/src/gc.ts +37 -6
  46. package/src/hub.ts +525 -85
  47. package/src/main.ts +95 -9
  48. package/src/maintenance.ts +1 -1
  49. package/src/manual.ts +9 -2
  50. package/src/omo/client.ts +2 -0
  51. package/src/profile-prompt.ts +73 -0
  52. package/src/profile.ts +33 -10
  53. package/src/protocol.ts +11 -2
  54. package/src/question-notifications.ts +4 -1
  55. package/src/reply-notifications.ts +7 -1
  56. package/src/repo-security-setting.ts +33 -0
  57. package/src/runtime/paths.ts +16 -0
  58. package/src/security/baseline.ts +170 -0
  59. package/src/security/dex-strings.ts +242 -0
  60. package/src/security/repo-scan.ts +369 -0
  61. package/src/security/revision-probe.ts +144 -0
  62. package/src/security/terms.ts +97 -0
  63. package/src/security-allow.ts +1 -0
  64. package/src/security-baseline.ts +113 -0
  65. package/src/security-check.ts +91 -16
  66. package/src/server-text.ts +38 -0
  67. package/src/store.ts +142 -16
  68. package/src/transcript.ts +7 -1
  69. package/src/ui-language.ts +33 -0
  70. package/src/update/check.ts +7 -3
  71. package/src/update/handoff-system.ts +24 -16
  72. package/src/update/handoff.ts +23 -12
  73. package/src/update/helper.ts +72 -36
  74. package/src/update/request.ts +46 -30
  75. package/src/update/state.ts +21 -11
  76. package/src/update/strategy.ts +12 -5
  77. package/src/update/target.ts +32 -15
  78. package/src/usage.ts +43 -20
  79. package/src/version.ts +39 -16
  80. package/src/voice/agents.ts +8 -3
  81. package/src/voice/install.ts +29 -18
  82. package/src/work-watch.ts +40 -18
  83. package/web/agent-icon.js +15 -9
  84. package/web/agents.d.ts +201 -0
  85. package/web/agents.js +813 -0
  86. package/web/app-download.js +32 -19
  87. package/web/app.js +1407 -278
  88. package/web/autocopy.js +6 -1
  89. package/web/boot.js +27 -8
  90. package/web/commands.js +20 -11
  91. package/web/decisions.js +70 -49
  92. package/web/devlog.js +25 -19
  93. package/web/diagram.js +8 -3
  94. package/web/enroll.js +43 -22
  95. package/web/gc.js +32 -19
  96. package/web/i18n/agent-icon.js +24 -0
  97. package/web/i18n/agents.js +48 -0
  98. package/web/i18n/app-download.js +26 -0
  99. package/web/i18n/autocopy.js +5 -0
  100. package/web/i18n/boot.js +24 -0
  101. package/web/i18n/commands.js +34 -0
  102. package/web/i18n/decisions.js +24 -0
  103. package/web/i18n/devlog.js +60 -0
  104. package/web/i18n/diagram.js +12 -0
  105. package/web/i18n/enroll.js +22 -0
  106. package/web/i18n/gc.js +38 -0
  107. package/web/i18n/lastsent.js +10 -0
  108. package/web/i18n/main.d.ts +4 -0
  109. package/web/i18n/main.js +527 -0
  110. package/web/i18n/manual.d.ts +4 -0
  111. package/web/i18n/manual.js +14 -0
  112. package/web/i18n/memo.js +30 -0
  113. package/web/i18n/notifications.js +16 -0
  114. package/web/i18n/profile.js +13 -0
  115. package/web/i18n/reference.js +26 -0
  116. package/web/i18n/security-inbox.js +40 -0
  117. package/web/i18n/slash.js +38 -0
  118. package/web/i18n/turn-stats.js +5 -0
  119. package/web/i18n/update.js +42 -0
  120. package/web/i18n/voice.js +28 -0
  121. package/web/i18n/wake.js +16 -0
  122. package/web/i18n/workid.js +10 -0
  123. package/web/i18n/workstatus.js +20 -0
  124. package/web/i18n.d.ts +20 -0
  125. package/web/i18n.js +63 -0
  126. package/web/index.html +156 -96
  127. package/web/lastsent.js +9 -4
  128. package/web/manual.d.ts +1 -1
  129. package/web/manual.js +11 -5
  130. package/web/memo.js +18 -13
  131. package/web/notifications.js +12 -7
  132. package/web/profile.js +42 -17
  133. package/web/reference.js +20 -15
  134. package/web/security-inbox.d.ts +18 -1
  135. package/web/security-inbox.js +173 -4
  136. package/web/slash.js +23 -13
  137. package/web/style.css +132 -3
  138. package/web/tooltip.js +5 -2
  139. package/web/turn-stats.js +6 -2
  140. package/web/update.js +31 -23
  141. package/web/voice.js +18 -8
  142. package/web/wake.d.ts +4 -0
  143. package/web/wake.js +7 -3
  144. package/web/workid.js +12 -2
  145. package/web/workstatus.js +36 -32
@@ -0,0 +1,911 @@
1
+ --- a/@code-yeongyu/senpi/dist/core/agent-session.js
2
+ +++ b/@code-yeongyu/senpi/dist/core/agent-session.js
3
+ @@ -43,7 +43,7 @@ import { executeBashWithOperations } from "./bash-executor.js";
4
+ import { envValue } from "./brand.js";
5
+ import { clientMessageIdentity, readClientMessageIdentity, } from "./client-message-identity.js";
6
+ import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, estimateProjectedContextTokens, estimateTokens, generateBranchSummary, prepareCompaction, resolveThresholdContextTokens, shouldCompact, } from "./compaction/index.js";
7
+ -import { compactionFailure, validateSummaryResponse } from "./extensions/builtin/compaction/speculative.js";
8
+ +import { compactionFailure, validateSummaryResponse, summaryUnavailable, summaryFailureDetails, noteSummaryUnavailable } from "./extensions/builtin/compaction/speculative.js";
9
+ import { CompactionLifecycleCoordinator } from "./compaction/lifecycle.js";
10
+ import { isTurnStuckOnContextOverflow } from "./compaction/stuck-overflow.js";
11
+ import { isWarmSummaryAnchorValid } from "./compaction/warm-anchor.js";
12
+ @@ -1042,6 +1042,11 @@ export class AgentSession {
13
+ : "No usable compaction model within the active provider."),
14
+ { failure: { kind, provider: model.provider, model: model.id } });
15
+ };
16
+ + const admit = (model) => {
17
+ + const unavailable = summaryUnavailable(this.sessionManager, model);
18
+ + if (unavailable) throw Object.assign(new Error("Compaction summary provider unavailable"), { failure: { ...unavailable, inputTokens, inputTokensSource: "estimate", requestSent: false } });
19
+ + return model;
20
+ + };
21
+ const chat = this._modelRuntime.getModels();
22
+ const available = this._modelRuntime.getAvailableSnapshot();
23
+ const same = (left, right) => left.provider === right.provider && left.id === right.id;
24
+ @@ -1065,7 +1070,7 @@ export class AgentSession {
25
+ return fail("model_unavailable", configured);
26
+ if (!fits(configured))
27
+ return fail("context_budget", configured);
28
+ - return configured;
29
+ + return admit(configured);
30
+ }
31
+ if (this._retryFallback.isProviderSpent(sessionModel.provider))
32
+ return fail("quota");
33
+ @@ -1078,7 +1083,7 @@ export class AgentSession {
34
+ const candidates = [...(family?.test(sessionModel.id) ? [sessionModel] : []), ...preferred, sessionModel];
35
+ for (const candidate of candidates) {
36
+ if (candidate.provider === sessionModel.provider && usable(candidate) && fits(candidate))
37
+ - return candidate;
38
+ + return admit(candidate);
39
+ }
40
+ return fail(usable(sessionModel) ? "context_budget" : "model_unavailable");
41
+ }
42
+ @@ -1094,6 +1099,8 @@ export class AgentSession {
43
+ });
44
+ }
45
+ async _getCompactionRequestAuth(model, signal, callbacks) {
46
+ + if (this._modelRuntime.couldRotateCredentials?.(model, { signal }))
47
+ + return { model, thinkingLevel: this.thinkingLevel };
48
+ const prepared = await retryTransientCall(() => this._modelRuntime.prepareRequest(model, { signal }),
49
+ isOAuthRefreshUnavailableError, this.settingsManager.getRetrySettings(), signal,
50
+ callbacks ?? this._summarizationRetryCallbacks({ source: "compaction" }));
51
+ @@ -1711,6 +1718,15 @@ export class AgentSession {
52
+ this._emitEntryAppended(entry.id);
53
+ }
54
+ _emit(event, captureSource = event) {
55
+ + if (event.type === "compaction_end" && !event.aborted && (event.failure || event.errorMessage || event.accepted === false)) {
56
+ + const failure = compactionFailure({ failure: event.failure }, this._activeCompactionModel ?? this.model);
57
+ + const catalog = this._modelRuntime.getModels();
58
+ + if (!catalog.some((model) => model.provider === failure.provider && model.id === failure.model)) {
59
+ + delete failure.provider;
60
+ + delete failure.model;
61
+ + }
62
+ + event = { ...event, failure, errorMessage: "Compaction failed; previous context retained" };
63
+ + }
64
+ if (event.type === "session_paused" ||
65
+ event.type === "session_resumed" ||
66
+ event.type === "compaction_start" ||
67
+ @@ -6903,18 +6919,34 @@ export class AgentSession {
68
+ * fits is allowed to carry the switch. The deterministic slice is the floor when
69
+ * summarization did not get there, so a repairable switch cannot strand itself.
70
+ */
71
+ + _summaryUnavailableForSwitch() {
72
+ + if (!this.model) return false;
73
+ + try {
74
+ + return summaryUnavailable(this.sessionManager, this._resolveCompactionModel(this.model)) !== undefined;
75
+ + } catch (error) {
76
+ + if (["quota", "account_blocked"].includes(error?.failure?.kind)) return true;
77
+ + throw error;
78
+ + }
79
+ + }
80
+ async _applyPendingModelSwitch(assistantMessage, skipAbortedCheck, inlineReason) {
81
+ const pending = this._pendingModelSwitch;
82
+ if (pending === undefined)
83
+ return false;
84
+ if (!this.model)
85
+ throw new RequiredCompactionError();
86
+ - await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck, inlineReason, false, false, pendingSwitchKeepRecentTokens(pending.projection));
87
+ + if (skipAbortedCheck && assistantMessage?.stopReason === "aborted") return false;
88
+ + const abortGeneration = this._userAbortGeneration;
89
+ let projection = this._projectSwitchFit(pending.model);
90
+ + if (!projection.usable && !this._summaryUnavailableForSwitch()) {
91
+ + await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck, inlineReason, false, false, pendingSwitchKeepRecentTokens(pending.projection));
92
+ + if (this._pendingModelSwitch !== pending || this._userAbortGeneration !== abortGeneration || this._lastPrePromptCompactionAborted) return false;
93
+ + projection = this._projectSwitchFit(pending.model);
94
+ + }
95
+ + if (this._pendingModelSwitch !== pending) return false;
96
+ if (!projection.usable) {
97
+ const plan = planResumeSlice({ entries: this.sessionManager.getBranch(), projection });
98
+ if (plan) {
99
+ - this.applyResumeSlice(plan);
100
+ + this.applyResumeSlice(plan, "explicit-model-switch");
101
+ projection = this._projectSwitchFit(pending.model);
102
+ }
103
+ }
104
+ @@ -6924,6 +6956,10 @@ export class AgentSession {
105
+ this._recordRejectedModelChange(pending.model, error);
106
+ throw error;
107
+ }
108
+ + const hadActiveFallback = this._retryFallback.activeState !== undefined;
109
+ + this._probeBackScheduler.cancel("manual-model-change");
110
+ + this._retryFallback.clearForManualModelChange(pending.model);
111
+ + if (hadActiveFallback && this._retryAbortController) this.abortRetry();
112
+ this._pendingModelSwitch = undefined;
113
+ try {
114
+ await this._switchActiveModel(pending.model, {
115
+ @@ -6975,10 +7011,11 @@ export class AgentSession {
116
+ this._resumeCompactionRequirement = createResumeCompactionRequirement(projection);
117
+ }
118
+ /** Reduce an over-window restored context deterministically while the recorded transcript stays intact. */
119
+ - applyResumeSlice(plan) {
120
+ + applyResumeSlice(plan, recoveryOrigin) {
121
+ this.sessionManager.appendCompaction(plan.summary, plan.firstKeptEntryId, plan.tokensBefore, {
122
+ schema: RESUME_SLICE_SCHEMA,
123
+ origin: RESUME_SLICE_ORIGIN,
124
+ + ...(recoveryOrigin ? { recoveryOrigin, originalLeafId: this.sessionManager.getLeafId() } : {}),
125
+ });
126
+ this.agent.state.messages = this.sessionManager.buildSessionContext().messages;
127
+ this._resumeSlice = plan;
128
+ @@ -7109,6 +7146,8 @@ export class AgentSession {
129
+ // surface its failure a whole message later.
130
+ if (deferral !== undefined) {
131
+ this._admitSwitchCompactionRequired(model, deferral, updateGlobalDefaults, origin);
132
+ + if (this.isIdle && !this.isCompacting && this._summaryUnavailableForSwitch())
133
+ + await this._applyPendingModelSwitch(undefined, false, "pre_prompt");
134
+ return undefined;
135
+ }
136
+ // A manual model change abandons any active fallback window; if a fallback
137
+ @@ -7814,6 +7853,9 @@ export class AgentSession {
138
+ }
139
+ }
140
+ const checkedStream = async (model, context, options) => {
141
+ + const unavailable = summaryUnavailable(this.sessionManager, model);
142
+ + if (unavailable) throw Object.assign(new Error("Compaction summary provider unavailable"), { failure: { ...unavailable, requestSent: false } });
143
+ + const inputTokens = estimateMessagesTokens(context.messages) + Math.ceil(JSON.stringify([context.systemPrompt, context.tools]).length / 4);
144
+ const stream = await this.agent.streamFunction(model, context, {
145
+ ...options, refusalFallbacks: undefined, streamKind: "auxiliary",
146
+ });
147
+ @@ -7821,7 +7863,13 @@ export class AgentSession {
148
+ [Symbol.asyncIterator]: () => stream[Symbol.asyncIterator](),
149
+ result: async () => {
150
+ const response = await stream.result();
151
+ - validateSummaryResponse(response);
152
+ + if (response?.stopReason === "error") {
153
+ + const failure = summaryFailureDetails(response, model, inputTokens);
154
+ + noteSummaryUnavailable(this.sessionManager, model, failure);
155
+ + throw Object.assign(new Error("Compaction summary failed"), { failure });
156
+ + }
157
+ + try { validateSummaryResponse(response); }
158
+ + catch (error) { error.failure = { ...summaryFailureDetails(response, model, inputTokens), kind: error.failure?.kind ?? "provider" }; throw error; }
159
+ return response;
160
+ },
161
+ };
162
+ @@ -7916,7 +7964,7 @@ export class AgentSession {
163
+ }
164
+ const message = error instanceof Error ? error.message : String(error);
165
+ const aborted = isCompactionExecutionAborted(error);
166
+ - const errorMessage = aborted ? undefined : `Compaction failed: ${message}`;
167
+ + const errorMessage = aborted ? undefined : "Compaction failed; previous context retained";
168
+ this._emit({
169
+ type: "compaction_end",
170
+ reason: "manual",
171
+ @@ -7925,6 +7973,7 @@ export class AgentSession {
172
+ willRetry: false,
173
+ requestId,
174
+ errorMessage,
175
+ + failure: aborted ? undefined : compactionFailure(error, this._activeCompactionModel ?? this.model),
176
+ });
177
+ await this._emitSessionCompactFailed({
178
+ reason: "manual",
179
+ @@ -8006,7 +8055,8 @@ export class AgentSession {
180
+ aborted,
181
+ willRetry: false,
182
+ requestId,
183
+ - errorMessage: aborted ? undefined : `Compaction failed: ${message}`,
184
+ + errorMessage: aborted ? undefined : "Compaction failed; previous context retained",
185
+ + failure: aborted ? undefined : compactionFailure(error, this._activeCompactionModel ?? this.model),
186
+ });
187
+ return { applied: false, reason: "rejected" };
188
+ }
189
+ @@ -8076,6 +8126,7 @@ export class AgentSession {
190
+ }
191
+ async _executeCompaction(request) {
192
+ const model = this.model;
193
+ + this._activeCompactionModel = model;
194
+ if (!model)
195
+ throw new Error(formatNoModelSelectedMessage());
196
+ const controller = request.controller;
197
+ @@ -8152,8 +8203,20 @@ export class AgentSession {
198
+ Math.ceil(JSON.stringify([this.agent.state.systemPrompt, this.agent.state.tools,
199
+ preparation.previousSummary, request.customInstructions]).length / 4) + 8192;
200
+ const compactionModel = this._resolveCompactionModel(model, inputTokens);
201
+ - const { model: requestModel, apiKey, headers, extraBody, env, thinkingLevel, } = await this._getCompactionRequestAuth(compactionModel, signal, this._summarizationRetryCallbacks({ source: "compaction", reason: request.reason }));
202
+ - compactionResult = await this._runDefaultCompaction(preparation, requestModel, apiKey, headers, extraBody, request.customInstructions, signal, env, request.reason, thinkingLevel);
203
+ + this._activeCompactionModel = compactionModel;
204
+ + let authReady = false;
205
+ + try {
206
+ + const { model: requestModel, apiKey, headers, extraBody, env, thinkingLevel, } = await this._getCompactionRequestAuth(compactionModel, signal, this._summarizationRetryCallbacks({ source: "compaction", reason: request.reason }));
207
+ + authReady = true;
208
+ + compactionResult = await this._runDefaultCompaction(preparation, requestModel, apiKey, headers, extraBody, request.customInstructions, signal, env, request.reason, thinkingLevel);
209
+ + } catch (error) {
210
+ + if (signal.aborted) throw error;
211
+ + const failure = compactionFailure(error, compactionModel);
212
+ + if (!failure.inputTokens) failure.inputTokens = inputTokens;
213
+ + if (!authReady) failure.requestSent = false;
214
+ + noteSummaryUnavailable(this.sessionManager, compactionModel, failure);
215
+ + throw Object.assign(error, { failure });
216
+ + }
217
+ compactionResult.details = { ...compactionResult.details,
218
+ selectionMode: settings.model === undefined || settings.model === "auto" ? "auto" : "explicit",
219
+ requestedProvider: compactionModel.provider, requestedModel: compactionModel.id };
220
+ @@ -8291,7 +8354,7 @@ export class AgentSession {
221
+ const aborted = signal.aborted;
222
+ if (ownsTerminalTransition && lifecycleState.status === "running")
223
+ return await this._rejectCompaction(request, requestId, operationId, "cancelled-by-extension", aborted,
224
+ - aborted ? undefined : "Compaction failed; previous context retained", aborted ? undefined : compactionFailure(error, model));
225
+ + aborted ? undefined : "Compaction failed; previous context retained", aborted ? undefined : compactionFailure(error, this._activeCompactionModel ?? model));
226
+ throw new CompactionExecutionError(error, ownsTerminalTransition, aborted);
227
+ }
228
+ finally {
229
+ @@ -8424,6 +8487,8 @@ export class AgentSession {
230
+ * deeper in compaction or the provider request.
231
+ */
232
+ async _assertModelReadyForTurn() {
233
+ + if (this._pendingModelSwitch !== undefined && this._summaryUnavailableForSwitch())
234
+ + await this._applyPendingModelSwitch(undefined, false, "pre_prompt");
235
+ if (!this.model) {
236
+ throw new ModelNotReadyError(formatNoModelSelectedMessage());
237
+ }
238
+ @@ -8461,6 +8526,11 @@ export class AgentSession {
239
+ * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages
240
+ */
241
+ async _enforceCompactionBeforeProvider(assistantMessage, skipAbortedCheck, inlineReason, retryAfterCompaction = false) {
242
+ + if (this._pendingModelSwitch !== undefined) {
243
+ + const applied = await this._applyPendingModelSwitch(assistantMessage, skipAbortedCheck, inlineReason);
244
+ + this._releaseBlockedPostCompactionAdmissionIfReduced();
245
+ + return applied;
246
+ + }
247
+ this._releaseBlockedPostCompactionAdmissionIfReduced();
248
+ const blockedAdmission = this._blockedPostCompactionAssistant;
249
+ if (blockedAdmission !== undefined && blockedAdmission.assistant === assistantMessage) {
250
+ @@ -8468,9 +8538,6 @@ export class AgentSession {
251
+ }
252
+ const settings = this._getCompactionSettings();
253
+ const model = this.model;
254
+ - if (this._pendingModelSwitch !== undefined) {
255
+ - return await this._applyPendingModelSwitch(assistantMessage, skipAbortedCheck, inlineReason);
256
+ - }
257
+ if (this._resumeCompactionRequirement !== undefined) {
258
+ if (!model)
259
+ throw new RequiredCompactionError();
260
+ @@ -8854,6 +8921,7 @@ export class AgentSession {
261
+ // The flag reports THIS attempt's "nothing to compact" outcome only; a value left by an earlier attempt
262
+ // must not make a caller treat a cancelled or rejected compaction as too small (senpi#2925 review).
263
+ this._compactionSkippedTooSmall = false;
264
+ + this._lastPrePromptCompactionAborted = false;
265
+ if (isAutomaticCompactionBlocked(this.sessionManager.getBranch(), this.model, this._getCompactionSettings())) {
266
+ return false;
267
+ }
268
+ @@ -8883,9 +8951,11 @@ export class AgentSession {
269
+ isContextOverflow(lastAssistantMessage, this.model?.contextWindow ?? 0)) {
270
+ this._overflowRecoveryRungs = 0;
271
+ }
272
+ + this._lastPrePromptCompactionAborted = controller.signal.aborted || execution.aborted || ["stale-revision", "external-owner"].includes(execution.rejectionCause);
273
+ return execution.accepted;
274
+ }
275
+ catch (error) {
276
+ + this._lastPrePromptCompactionAborted = isCompactionExecutionAborted(error) || !compactionExecutionOwnsTerminalTransition(error);
277
+ if (!compactionExecutionOwnsTerminalTransition(error)) {
278
+ return false;
279
+ }
280
+ @@ -8902,7 +8972,8 @@ export class AgentSession {
281
+ aborted,
282
+ willRetry: false,
283
+ requestId,
284
+ - errorMessage: aborted ? undefined : `Pre-prompt compaction failed: ${errorMessage}`,
285
+ + errorMessage: aborted ? undefined : "Compaction failed; previous context retained",
286
+ + failure: aborted ? undefined : compactionFailure(error, this._activeCompactionModel ?? this.model),
287
+ });
288
+ return false;
289
+ }
290
+ @@ -9552,6 +9623,7 @@ export class AgentSession {
291
+ }, {
292
+ offload: (id, args, options) => offload(this._toolDispatcher, this._extensionRunner.getAllChildModules(), id, args, options),
293
+ getModel: () => this.model,
294
+ + getModelSwitchState: () => ({ current: this.model, pending: this._pendingModelSwitch?.model }),
295
+ getServiceTier: () => this.serviceTier,
296
+ getEffectiveServiceTier: () => this.effectiveServiceTier,
297
+ getScopedModels: () => this._scopedModels,
298
+ @@ -9700,7 +9772,8 @@ export class AgentSession {
299
+ aborted,
300
+ willRetry: false,
301
+ requestId,
302
+ - errorMessage: aborted ? undefined : `Compaction failed: ${message}`,
303
+ + errorMessage: aborted ? undefined : "Compaction failed; previous context retained",
304
+ + failure: aborted ? undefined : compactionFailure(error, this._activeCompactionModel ?? this.model),
305
+ });
306
+ const err = error instanceof Error ? error : new Error(String(error));
307
+ options?.onError?.(err);
308
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/anthropic-subscription/stream.js
309
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/anthropic-subscription/stream.js
310
+ @@ -1,6 +1,7 @@
311
+ import { createAssistantMessageDiagnostic, createAssistantMessageEventStream, } from "@earendil-works/pi-ai";
312
+ import { getSessionClaudeAccountPin } from "./account-command.js";
313
+ import { queryWithAuthLane } from "./auth-lane.js";
314
+ +import { AllAccountsBlockedError } from "./affinity.js";
315
+ import { markColdSeedOverflow } from "./cold-seed-budget.js";
316
+ import { buildCustomToolServers } from "./custom-tools.js";
317
+ import { sdkAssistantFailure, sdkResultFailure, sdkResultFailureUsage } from "./errors.js";
318
+ @@ -166,6 +167,7 @@ export function streamAnthropicSubscription(model, context, options) {
319
+ buildOptions,
320
+ });
321
+ for await (const message of messages) {
322
+ + if (message.type === "assistant" || message.type === "result" || message.type === "stream_event") output.requestSent = true;
323
+ const refusal = refusalError(message);
324
+ if (refusal)
325
+ throw refusal;
326
+ @@ -265,6 +267,14 @@ export function streamAnthropicSubscription(model, context, options) {
327
+ if (billed)
328
+ updateUsage(model, output, billed, "query");
329
+ output.errorMessage = withAuthGuidance(error, errorMessage(error), claudeCodeRun);
330
+ + if (error instanceof AllAccountsBlockedError) {
331
+ + output.failure = { kind: "account_blocked",
332
+ + ...(Number.isFinite(error.soonestUnblockAt) ? { retryAt: error.soonestUnblockAt } : {}),
333
+ + ...(output.requestSent === true ? { requestSent: true } : sdkQuery === undefined ? { requestSent: false } : {}) };
334
+ + }
335
+ + if (error?.name === "AccountUsageLimitError" || error?.errorCause?.type === "usage_limit") {
336
+ + output.failure = { kind: "quota", ...(Number.isFinite(error.retryAt) ? { retryAt: error.retryAt } : {}) };
337
+ + }
338
+ markColdSeedOverflow(output, model, coldSeedAttempt, coldSeedEstimate, options?.sessionId);
339
+ stream.push({ type: "error", reason: output.stopReason, error: output });
340
+ }
341
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-lifecycle.js
342
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-lifecycle.js
343
+ @@ -1,3 +1,4 @@
344
+ +import { buildSessionContext, getSessionContextEntryId } from "../../../session-manager.js";
345
+ import { buildCompactionContext } from "./context-pipeline.js";
346
+ import { createReductionHandoff } from "./context-reduction-handoff.js";
347
+ import { CONTEXT_REDUCTION_ENTRY_TYPE, clearReductionAnchor, createContextReductionState, readContextReductionState, recordReductionUsage, snapshotReductionState, } from "./context-reduction-state.js";
348
+ @@ -63,7 +64,7 @@ export function registerContextReductionLifecycle(pi, options) {
349
+ pending = undefined;
350
+ persist(before);
351
+ });
352
+ - pi.on("context", (event, ctx) => {
353
+ + pi.on("context", async (event, ctx) => {
354
+ restore(ctx);
355
+ // The journal may not yet contain the current user/tool message.
356
+ // The dispatcher, not transcript equality, owns request provenance.
357
+ @@ -78,6 +79,7 @@ export function registerContextReductionLifecycle(pi, options) {
358
+ const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 200_000;
359
+ const settings = ctx.getCompactionSettings();
360
+ const policy = options.getPolicy(ctx);
361
+ + const automatic = settings.enabled !== false && !options.isSummaryUnavailable?.(ctx);
362
+ const overheadTokens = estimateTotalTokens([
363
+ { role: "user", content: `${ctx.getSystemPrompt()}\n${JSON.stringify(options.getTools())}`, timestamp: 0 },
364
+ ]);
365
+ @@ -94,21 +96,22 @@ export function registerContextReductionLifecycle(pi, options) {
366
+ usage.tokens >= contextWindow * computeEffectiveThreshold(contextWindow, policy.lastYield);
367
+ if (breakerFallback)
368
+ options.logBreakerFallback(ctx, usage.tokens ?? 0);
369
+ - const messages = buildCompactionContext({
370
+ + const buildInput = {
371
+ event,
372
+ ctx,
373
+ contextWindow,
374
+ promptContextWindow,
375
+ contextReductionState: working,
376
+ contextOverheadTokens: overheadTokens,
377
+ - reductionCeilingTokens: Math.min(promptContextWindow, contextWindow - reserveTokens, thresholdTokens),
378
+ + reductionCeilingTokens: Math.min(promptContextWindow, contextWindow - reserveTokens, automatic ? thresholdTokens : Infinity),
379
+ toolAdmissionEnabled: settings.toolAdmissionEnabled !== false,
380
+ breakerFallback,
381
+ laneOwnsCompaction: policy.laneOwnsCompaction,
382
+ appendOnlyTranscript: policy.appendOnlyTranscript,
383
+ emergencyPruneLatch: live ? options.emergencyPruneLatch : { ...options.emergencyPruneLatch },
384
+ logEmergencyPrune: (fields) => options.logEmergencyPrune(ctx, fields),
385
+ - });
386
+ + };
387
+ + let messages = buildCompactionContext(buildInput);
388
+ const managed = !policy.laneOwnsCompaction;
389
+ if (!live)
390
+ return { messages };
391
+ @@ -116,6 +119,21 @@ export function registerContextReductionLifecycle(pi, options) {
392
+ pending = undefined;
393
+ return { messages };
394
+ }
395
+ + if (state.compactionRequired && !automatic && options.reduceContext) {
396
+ + const ids = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
397
+ + const additions = event.messages.filter((message) => !ids.has(getSessionContextEntryId(message)));
398
+ + const result = await options.reduceContext(ctx, estimateTotalTokens(additions));
399
+ + if (!result.applied) {
400
+ + ctx.abort("system");
401
+ + return { messages };
402
+ + }
403
+ + messages = buildCompactionContext({ ...buildInput, contextReductionState: state,
404
+ + event: { ...event, messages: [...buildSessionContext(ctx.sessionManager.getBranch()).messages, ...additions] } });
405
+ + if (state.compactionRequired) {
406
+ + ctx.abort("system");
407
+ + return { messages };
408
+ + }
409
+ + }
410
+ if (state.compactionRequired) {
411
+ pending = undefined;
412
+ persist(before);
413
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/index.js
414
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/index.js
415
+ @@ -14,7 +14,9 @@ import * as cap from "./per-turn-cap.js";
416
+ import * as policy from "./policy.js";
417
+ import { compactionRecoveryStateKey, isAutomaticCompactionBlocked, REJECTED_RECOVERY_ENTRY, wouldCompactionOverflow, } from "./rejected-recovery.js";
418
+ import * as restoration from "./restoration-tracker.js";
419
+ -import { applyGeneratedCompaction, createEmergencyPruneLatch, createSpeculativeCompactionSnapshot, getPromptVariant, runExtensionCompaction, SummaryGenerationError, compactionFailure, } from "./speculative.js";
420
+ +import { applyGeneratedCompaction, createEmergencyPruneLatch, createSpeculativeCompactionSnapshot, getPromptVariant, runExtensionCompaction, SummaryGenerationError, compactionFailure, canStartSummary, summaryUnavailable, } from "./speculative.js";
421
+ +import { planResumeSlice } from "./resume-slice.js";
422
+ +import { projectModelUsabilityBudget } from "./model-usability-budget.js";
423
+ import { trackSpeculativeJob } from "./speculative-job.js";
424
+ import { createInitialState, resetTurnCounter } from "./state.js";
425
+ import { resolveInheritedTaskIntent } from "./task-intent.js";
426
+ @@ -121,6 +123,8 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
427
+ return;
428
+ if (isContextRetired(ctx))
429
+ return;
430
+ + if (ctx.getCompactionSettings().enabled === false)
431
+ + return;
432
+ if (!ctx.isIdle())
433
+ return;
434
+ if (lanePolicy.disablesSenpiCompaction(ctx))
435
+ @@ -188,19 +192,22 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
436
+ speculativeJob = undefined;
437
+ }
438
+ function startSpeculativeCompaction(ctx, customInstructions) {
439
+ + if (ctx.getCompactionSettings().enabled === false)
440
+ + return;
441
+ if (speculativeJob)
442
+ return;
443
+ if (isAutomaticCompactionBlocked(ctx.sessionManager.getBranch(), ctx.model, ctx.getCompactionSettings()))
444
+ return;
445
+ - const generation = ++speculativeGeneration;
446
+ + const generation = speculativeGeneration + 1;
447
+ const snapshot = createSpeculativeCompactionSnapshot(ctx, {
448
+ generation,
449
+ customInstructions,
450
+ origin: "speculative",
451
+ tools: getSummarizationTools(),
452
+ });
453
+ - if (!snapshot)
454
+ + if (!snapshot || !canStartSummary(ctx, snapshot))
455
+ return;
456
+ + speculativeGeneration = generation;
457
+ getLogger(ctx).debug("speculative_started", { generation, origin: "speculative" });
458
+ const controller = new AbortController();
459
+ const settled = generateCompaction(ctx, snapshot, controller.signal).then((result) => ({ result, error: undefined }), (error) => ({ result: undefined, error: error instanceof Error ? error : new Error(String(error)) }));
460
+ @@ -236,9 +243,43 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
461
+ async function generateCompaction(ctx, snapshot, signal, onProgress) {
462
+ return await runExtensionCompaction(ctx, snapshot, signal, onProgress);
463
+ }
464
+ - async function applyBlockingCompaction(ctx, customInstructions) {
465
+ + function summaryIsUnavailable(ctx) {
466
+ + try {
467
+ + return summaryUnavailable(ctx.sessionManager, ctx.getCompactionModel?.() ?? ctx.model) !== undefined;
468
+ + }
469
+ + catch (error) {
470
+ + if (error?.failure?.kind === "quota" || error?.failure?.kind === "account_blocked")
471
+ + return true;
472
+ + throw error;
473
+ + }
474
+ + }
475
+ + function deterministicCompaction(ctx, pendingTokens = 0) {
476
+ + const entries = ctx.sessionManager.getBranch();
477
+ + const projection = projectModelUsabilityBudget({
478
+ + model: ctx.model,
479
+ + liveContextTokens: ctx.getContextUsage()?.tokens ?? 0,
480
+ + systemPrompt: ctx.getSystemPrompt(), tools: getSummarizationTools(),
481
+ + compaction: { ...ctx.getCompactionSettings(), enabled: true, speculativeEnabled: false },
482
+ + includeSpeculationLead: false,
483
+ + });
484
+ + projection.safetyMarginTokens += pendingTokens;
485
+ + const plan = planResumeSlice({ entries, projection });
486
+ + if (!plan)
487
+ + throw new SummaryGenerationError("context_budget", "No replay-safe context suffix fits the request budget");
488
+ + return { ...plan, details: { schema: "senpi.compaction.resume-slice.v1", recoveryOrigin: "turn-start",
489
+ + originalLeafId: ctx.sessionManager.getLeafId() } };
490
+ + }
491
+ + async function applyBlockingCompaction(ctx, customInstructions, pendingTokens = 0) {
492
+ const feedbackSignal = ctx.beginCompaction?.({ reason: "extension" });
493
+ try {
494
+ + if (ctx.getCompactionSettings().enabled === false || summaryIsUnavailable(ctx)) {
495
+ + invalidateSpeculativeCompaction(ctx);
496
+ + const result = await ctx.applyCompaction(deterministicCompaction(ctx, pendingTokens), {
497
+ + reason: "extension", blocking: true, expectedRevision: ctx.getMessageRevision(), signal: feedbackSignal,
498
+ + });
499
+ + endCompactionFeedback(ctx, feedbackSignal, result);
500
+ + return result;
501
+ + }
502
+ const provider = ctx.model?.provider;
503
+ if ((provider === "cursor" || provider === "cursor-cli-oauth") && !ctx.isIdle())
504
+ throw new SummaryGenerationError("provider", "Compaction unavailable while provider is busy");
505
+ @@ -306,6 +347,8 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
506
+ if (breaker.isTripped(state, Date.now()) && !breaker.shouldBypass(state, { reason: event.reason }))
507
+ return { cancel: true, rejectionCause: "circuit-breaker", failure: { kind: "provider" } };
508
+ capturePendingMetadata(event.requestId, ctx);
509
+ + if (event.reason !== "manual" && (ctx.getCompactionSettings().enabled === false || summaryIsUnavailable(ctx)))
510
+ + return { compaction: deterministicCompaction(ctx) };
511
+ const model = ctx.model;
512
+ if (!model)
513
+ throw new SummaryGenerationError("model_unavailable", "Compaction model unavailable");
514
+ @@ -436,7 +479,7 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
515
+ // The SDK owns this lane's context entirely, so even the hard-limit valve stands down;
516
+ // the circuit breaker never blocks that valve for senpi-owned lanes.
517
+ const laneOwnsCompaction = lanePolicy.disablesSenpiCompaction(ctx);
518
+ - const breakerCoolingDown = breaker.isTripped(state, Date.now()) || laneOwnsCompaction;
519
+ + const breakerCoolingDown = breaker.isTripped(state, Date.now()) || laneOwnsCompaction || settings.enabled === false || summaryIsUnavailable(ctx);
520
+ const { reserveTokens, thresholdTokens, leadTokens } = resolveCompactionGeometry({
521
+ contextWindow,
522
+ settings,
523
+ @@ -450,7 +493,7 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
524
+ tokens: usage.tokens ?? 0,
525
+ threshold: settings.reserveTokens,
526
+ });
527
+ - const result = await applyBlockingCompaction(ctx, EMERGENCY_COMPACTION_INSTRUCTIONS);
528
+ + const result = await applyBlockingCompaction(ctx, EMERGENCY_COMPACTION_INSTRUCTIONS, pendingPromptTokens);
529
+ if (!result.applied) { ctx.abort("system"); return; }
530
+ }
531
+ else if (!breakerCoolingDown &&
532
+ @@ -512,6 +555,8 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
533
+ pi.on("before_agent_start", onBeforeAgentStart, { previewSafe: true });
534
+ const contextReduction = registerContextReductionLifecycle(pi, {
535
+ emergencyPruneLatch,
536
+ + isSummaryUnavailable: summaryIsUnavailable,
537
+ + reduceContext: (ctx, pendingTokens) => applyBlockingCompaction(ctx, EMERGENCY_COMPACTION_INSTRUCTIONS, pendingTokens),
538
+ emergencyInstructions: EMERGENCY_COMPACTION_INSTRUCTIONS,
539
+ getTools: getSummarizationTools,
540
+ getPolicy: (ctx) => ({
541
+ @@ -545,7 +590,7 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
542
+ });
543
+ pi.on("turn_end", async (_event, ctx) => {
544
+ try {
545
+ - if (lanePolicy.disablesSenpiCompaction(ctx))
546
+ + if (lanePolicy.disablesSenpiCompaction(ctx) || ctx.getCompactionSettings().enabled === false)
547
+ return;
548
+ handleTurnEnd(degradationState);
549
+ if (degradationState.recoveryTriggeredThisCycle)
550
+ @@ -560,7 +605,7 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
551
+ });
552
+ pi.on("agent_end", async (event, ctx) => {
553
+ state = resetTurnCounter(state, "");
554
+ - if (lanePolicy.disablesSenpiCompaction(ctx))
555
+ + if (lanePolicy.disablesSenpiCompaction(ctx) || ctx.getCompactionSettings().enabled === false)
556
+ return;
557
+ const usage = ctx.getContextUsage();
558
+ const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
559
+ @@ -615,7 +660,7 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
560
+ if (isAbortedAssistantMessage(event)) {
561
+ invalidateSpeculativeCompaction(ctx);
562
+ }
563
+ - if (isMonitorableMessageEvent(event) && !lanePolicy.disablesSenpiCompaction(ctx)) {
564
+ + if (ctx.getCompactionSettings().enabled !== false && isMonitorableMessageEvent(event) && !lanePolicy.disablesSenpiCompaction(ctx)) {
565
+ await handleMessageEnd(degradationState, event, {
566
+ applyCompaction: async (options) => {
567
+ return await applyBlockingCompaction(ctx, options.customInstructions);
568
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative-summary.js
569
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative-summary.js
570
+ @@ -83,6 +83,8 @@ export async function generateSummaryMessage(options) {
571
+ // Request-local controller: the idle watchdog must be able to tear down a
572
+ // stalled summarization request without aborting the caller's own signal.
573
+ const requestController = new AbortController();
574
+ + let requestSent;
575
+ + let requestInputTokens;
576
+ const onCallerAbort = () => requestController.abort(options.signal?.reason);
577
+ if (options.signal) {
578
+ if (options.signal.aborted)
579
+ @@ -112,9 +114,10 @@ export async function generateSummaryMessage(options) {
580
+ { role: "user", content: requestContext.systemPrompt },
581
+ { role: "user", content: JSON.stringify(requestContext.tools ?? []) },
582
+ ]);
583
+ + requestInputTokens = inputTokens;
584
+ if (inputTokens + summaryMaxTokens(options.snapshot.model, options.snapshot.contextWindow) > options.snapshot.contextWindow) {
585
+ throw Object.assign(new Error("Compaction request exceeds the selected model context budget"),
586
+ - { failure: { kind: "context_budget", provider: options.snapshot.model.provider, model: options.snapshot.model.id } });
587
+ + { failure: { kind: "context_budget", provider: options.snapshot.model.provider, model: options.snapshot.model.id, error: "Compaction request exceeds the selected model context budget", inputTokens, inputTokensSource: "estimate", requestSent: false } });
588
+ }
589
+ const headers = providerRequest
590
+ ? await providerRequest.transformHeaders(options.auth.headers ?? {})
591
+ @@ -126,6 +129,7 @@ export async function generateSummaryMessage(options) {
592
+ env: options.auth.env,
593
+ cwd: options.context.cwd,
594
+ streamKind: "auxiliary",
595
+ + onResponse: () => { requestSent = true; },
596
+ onPayload: async (payload, model) => {
597
+ const sanitized = model.api === "anthropic-messages" ? sanitizeAnthropicPayload(payload) : payload;
598
+ return providerRequest ? await providerRequest.transformPayload(sanitized) : sanitized;
599
+ @@ -137,7 +141,7 @@ export async function generateSummaryMessage(options) {
600
+ });
601
+ // Settlement rides inside the watchdog: a provider whose iterator ends
602
+ // without a terminal event used to park here with every timer cleared.
603
+ - return await consumeStreamWithIdleTimeout(responseStream, {
604
+ + const response = await consumeStreamWithIdleTimeout(responseStream, {
605
+ idleTimeoutMs: DEFAULT_SUMMARIZATION_IDLE_TIMEOUT_MS,
606
+ maxDurationMs,
607
+ abort: () => requestController.abort(),
608
+ @@ -149,6 +153,17 @@ export async function generateSummaryMessage(options) {
609
+ },
610
+ settle: async () => await responseStream.result(),
611
+ });
612
+ + if (response) {
613
+ + response.failure = { ...response.failure, inputTokens, inputTokensSource: "estimate",
614
+ + ...(requestSent === true ? { requestSent: true } : {}) };
615
+ + }
616
+ + return response;
617
+ + }
618
+ + catch (error) {
619
+ + error.failure = { ...error.failure, provider: options.snapshot.model.provider, model: options.snapshot.model.id,
620
+ + ...(requestInputTokens === undefined ? {} : { inputTokens: requestInputTokens, inputTokensSource: "estimate" }),
621
+ + ...(requestSent === true ? { requestSent: true } : {}) };
622
+ + throw error;
623
+ }
624
+ finally {
625
+ if (options.signal)
626
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js
627
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js
628
+ @@ -13,6 +13,76 @@ import { allowSummarizationRetry, DEFAULT_SUMMARIZATION_RETRY_POLICY } from "./s
629
+ import { extractTaskIntent, resolveInheritedTaskIntent, taskIntentSummary, taskIntentMetadata } from "./task-intent.js";
630
+ export { createEmergencyPruneLatch, hardLimitEmergencyPrune, truncateContextMessages, } from "./emergency-prune.js";
631
+ import { computeStructuralYield } from "./yield.js";
632
+ +import { usageLimitResetMs } from "../../../credential-pool/reset-time.js";
633
+ +import { homedir } from "node:os";
634
+ +// Availability belongs to the session, not a speculative generation. Stable session files
635
+ +// retain it through extension reloads; fixture-only managers use their object identity.
636
+ +const summaryAvailability = new Map();
637
+ +function summaryBlocks(manager) {
638
+ + const key = manager.getSessionFile?.() ?? manager.getSessionId?.() ?? manager;
639
+ + let blocks = summaryAvailability.get(key);
640
+ + if (!blocks) summaryAvailability.set(key, blocks = new Map());
641
+ + return blocks;
642
+ +}
643
+ +export function summaryUnavailable(manager, model, now = Date.now()) {
644
+ + if (!model) return undefined;
645
+ + const key = model.provider + "/" + model.id;
646
+ + const failure = summaryBlocks(manager).get(key);
647
+ + if (!failure || now >= failure.retryAt) return undefined;
648
+ + return failure;
649
+ +}
650
+ +export function noteSummaryUnavailable(manager, model, failure, now = Date.now()) {
651
+ + if (failure.kind !== "quota" && failure.kind !== "account_blocked") return;
652
+ + const retryAt = failure.retryAt ?? now + 30 * 60_000;
653
+ + summaryBlocks(manager).set(model.provider + "/" + model.id, { ...failure, retryAt });
654
+ +}
655
+ +export function sanitizeSummaryDiagnostic(value) {
656
+ + if (typeof value !== "string") return "Compaction provider failed";
657
+ + // Read only the provider's message, never nested errors, payloads, or stacks.
658
+ + const body = /(?:\r?\n\s*at\s+|\b(?:request[ _-]?body|response[ _-]?body|request[ _-]?payload|response[ _-]?payload|prompt|summary|conversation)\s*[:=])/i.exec(value);
659
+ + let text = body ? value.slice(0, body.index) + "[redacted]" : value;
660
+ + text = text.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, "[redacted]")
661
+ + .replace(/(\bauthorization\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\r\n,;]+)/gi, "$1[redacted]")
662
+ + .replace(/\b(?:Bearer|Basic)\s+[^\s"'<>]+/gi, "[redacted]")
663
+ + .replace(/(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|secret)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, "$1[redacted]")
664
+ + .replace(/\bsk-[A-Za-z0-9_-]+\b/g, "[redacted]")
665
+ + .replace(/\b[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g, "[redacted]")
666
+ + .replace(/\b(?:eyJ[A-Za-z0-9_-]*|e30)\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*(?=$|[^A-Za-z0-9_-])/g, "[redacted]")
667
+ + .replace(/\/(?:Users|home)\/[^/\s"']+(?=\/|\s|$)/g, "~")
668
+ + .split(homedir()).join("~")
669
+ + .replace(/(?<![A-Za-z0-9+/_-])[A-Za-z0-9+/_-]{24,}={0,2}/g, "[redacted]")
670
+ + .replace(/[A-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted]")
671
+ + .replace(/(https?:\/\/)[^/@\s]+:[^/@\s]+@/gi, "$1[redacted]@")
672
+ + .replace(/(https?:\/\/[^\s?"'<>]+)\?[^\s"'<>]*/gi, "$1")
673
+ + .replace(/(\baccount(?:[ _-]?(?:name|id))?\s*(?:[:=]\s*)?)(?:'[^']*'|"[^"]*")/gi, "$1[redacted]")
674
+ + .replace(/(\baccount[ _-]?(?:name|id)\s*[:=]\s*)[^\s,;]+/gi, "$1[redacted]");
675
+ + return text.slice(0, 512);
676
+ +}
677
+ +const WITHHELD = "Compaction provider failed";
678
+ +const CHATGPT_LIMIT = "You have hit your ChatGPT usage limit.";
679
+ +const POOL_REFUSAL = "No request was sent: the account pool refused it locally.";
680
+ +const LOCAL_BUDGET = "Compaction request exceeds the selected model context budget";
681
+ +export function summaryFailureDetails(value, model, inputTokens = 0) {
682
+ + const message = value?.errorMessage ?? value?.message ?? "";
683
+ + const pool = value?.name === "AllAccountsBlockedError" ||
684
+ + (model?.provider === "anthropic-subscription" && message.includes(POOL_REFUSAL));
685
+ + const chatgpt = model?.provider === "chatgpt-subscription" && message.startsWith("You have hit your ChatGPT usage limit");
686
+ + const kind = pool ? "account_blocked" : chatgpt || value?.name === "AccountUsageLimitError" || value?.errorCause?.type === "usage_limit" ? "quota" : value?.failure?.kind ?? value?.kind;
687
+ + const reset = (pool || chatgpt || kind === "quota") ? usageLimitResetMs(message, Date.now()) : undefined;
688
+ + const retryAt = value?.failure?.retryAt ?? value?.retryAt ?? value?.soonestUnblockAt ?? (reset === undefined ? undefined : Date.now() + reset);
689
+ + const usage = value?.usage?.contextUsage;
690
+ + const measured = typeof usage?.input === "number" && Number.isFinite(usage.input) && usage.input >= 0;
691
+ + return {
692
+ + kind: kind ?? "provider",
693
+ + ...(model ? { provider: model.provider, model: model.id } : {}),
694
+ + error: sanitizeSummaryDiagnostic(message || value?.failure?.error || WITHHELD),
695
+ + inputTokens: measured ? usage.input : value?.failure?.inputTokens ?? inputTokens,
696
+ + inputTokensSource: measured ? "provider" : "estimate",
697
+ + ...(typeof value?.failure?.requestSent === "boolean" ? { requestSent: value.failure.requestSent }
698
+ + : typeof value?.requestSent === "boolean" ? { requestSent: value.requestSent } : {}),
699
+ + ...(Number.isFinite(retryAt) ? { retryAt } : {}),
700
+ + };
701
+ +}
702
+ const DEFAULT_CONTEXT_WINDOW = 200_000;
703
+ // Hysteresis: the emergency prune engages at EMERGENCY_CONTEXT_TARGET_RATIO but only
704
+ // releases once the context falls below this lower ratio. A single threshold makes a
705
+ @@ -34,14 +104,22 @@ export class SummaryRequestError extends Error {
706
+ }
707
+ }
708
+ export function compactionFailure(error, model) {
709
+ - const kind = error?.refused ? "refused" : error?.failure?.kind ?? error?.kind;
710
+ - const known = ["invalid_model", "auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable", "refused", "timeout", "incomplete", "empty", "context_budget", "stale", "provider"];
711
+ + const details = error?.failure ?? summaryFailureDetails(error, model);
712
+ + const kind = error?.refused ? "refused" : details.kind;
713
+ + const known = ["invalid_model", "auth_missing", "auth_expired", "quota", "account_blocked", "org_denied", "model_unavailable", "refused", "timeout", "incomplete", "empty", "context_budget", "stale", "provider"];
714
+ + const diagnostic = sanitizeSummaryDiagnostic(details.error ?? error?.message ?? WITHHELD);
715
+ return { kind: known.includes(kind) ? kind
716
+ : error instanceof StreamDurationBudgetError || error instanceof StreamIdleTimeoutError || error instanceof SummarizationTotalBudgetError || error?.name === "TimeoutError" ? "timeout"
717
+ : error instanceof SummarizationOverflowExhaustedError ? "context_budget" : "provider",
718
+ ...(model ? { provider: model.provider, model: model.id } : {}),
719
+ - ...(error?.failure?.provider ? { provider: error.failure.provider } : {}),
720
+ - ...(error?.failure?.model ? { model: error.failure.model } : {}) };
721
+ + ...(details.provider ? { provider: details.provider } : {}),
722
+ + ...(details.model ? { model: details.model } : {}),
723
+ + error: diagnostic,
724
+ + inputTokens: Number.isFinite(details.inputTokens) && details.inputTokens >= 0 ? details.inputTokens : 0,
725
+ + inputTokensSource: details.inputTokensSource === "provider" ? "provider" : "estimate",
726
+ + ...(typeof details.requestSent === "boolean" ? { requestSent: details.requestSent } : {}),
727
+ + ...(Number.isFinite(details.retryAt) ? { retryAt: details.retryAt } : {}),
728
+ + };
729
+ }
730
+ export function validateSummaryResponse(response) {
731
+ if (response && isRefusalStop(response))
732
+ @@ -80,11 +158,11 @@ function isRetryableSummaryAttempt(error) {
733
+ function isRefusalStop(response) {
734
+ return response.stopDetails?.type === "refusal" || response.stopDetails?.type === "sensitive";
735
+ }
736
+ -function summaryRequestFailureKind(response) {
737
+ +function summaryRequestFailureKind(response, model) {
738
+ if (isRefusalStop(response))
739
+ return undefined;
740
+ - const kind = response.failure?.kind;
741
+ - if (["auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable", "timeout", "incomplete", "context_budget"].includes(kind))
742
+ + const kind = summaryFailureDetails(response, model).kind;
743
+ + if (["auth_missing", "auth_expired", "quota", "account_blocked", "org_denied", "model_unavailable", "timeout", "incomplete", "context_budget"].includes(kind))
744
+ return kind;
745
+ return UPSTREAM_STREAM_TRUNCATED_PATTERN.test(response.errorMessage ?? "") ? "upstream-stream-truncated" : undefined;
746
+ }
747
+ @@ -148,9 +226,7 @@ export function createSpeculativeCompactionSnapshot(context, options) {
748
+ * before and after credential resolution so a cancelled request never
749
+ * misreports as an auth failure.
750
+ */
751
+ -export async function runExtensionCompaction(context, snapshot, signal, onProgress) {
752
+ - if (signal?.aborted)
753
+ - return undefined;
754
+ +export function selectSummaryModel(context, snapshot) {
755
+ const prompt = buildPrompt({
756
+ variant: snapshot.promptVariant,
757
+ previousSummary: snapshot.preparation.previousSummary,
758
+ @@ -165,11 +241,33 @@ export async function runExtensionCompaction(context, snapshot, signal, onProgre
759
+ { role: "user", content: JSON.stringify(snapshot.tools ?? []) },
760
+ ]);
761
+ const selectedModel = context.getCompactionModel?.(inputTokens) ?? snapshot.model;
762
+ + return { prompt, messages, inputTokens, selectedModel };
763
+ +}
764
+ +export function canStartSummary(context, snapshot) {
765
+ + try {
766
+ + const { selectedModel } = selectSummaryModel(context, snapshot);
767
+ + return !summaryUnavailable(context.sessionManager, selectedModel);
768
+ + } catch (error) {
769
+ + if (["quota", "account_blocked"].includes(error?.failure?.kind)) return false;
770
+ + return true;
771
+ + }
772
+ +}
773
+ +export async function runExtensionCompaction(context, snapshot, signal, onProgress) {
774
+ + if (signal?.aborted)
775
+ + return undefined;
776
+ + const { prompt, messages, inputTokens, selectedModel } = selectSummaryModel(context, snapshot);
777
+ + const unavailable = summaryUnavailable(context.sessionManager, selectedModel);
778
+ + if (unavailable) throw Object.assign(new SummaryGenerationError(unavailable.kind, "Compaction summary provider unavailable"), { failure: { ...unavailable, inputTokens, inputTokensSource: "estimate", requestSent: false } });
779
+ const selectedSnapshot = { ...snapshot, model: selectedModel, contextWindow: selectedModel.contextWindow, selectionMode: context.getCompactionSettings?.().model === undefined || context.getCompactionSettings?.().model === "auto" ? "auto" : "explicit" };
780
+ let auth;
781
+ try {
782
+ const runtime = context.modelRegistry?.modelRuntime;
783
+ - if (runtime) {
784
+ + if (runtime?.couldRotateCredentials?.(selectedModel, { signal })) {
785
+ + // Let the runtime select and authenticate a healthy slot. A resolved OAuth
786
+ + // token here would become an explicit request key and disable rotation.
787
+ + auth = { ok: true, ambient: true };
788
+ + }
789
+ + else if (runtime) {
790
+ const prepared = await runtime.prepareRequest(selectedModel, { signal });
791
+ auth = { ok: true, ...prepared.options, baseUrl: prepared.model.baseUrl, ambient: true };
792
+ }
793
+ @@ -180,16 +278,17 @@ export async function runExtensionCompaction(context, snapshot, signal, onProgre
794
+ catch (error) {
795
+ if (signal?.aborted)
796
+ return undefined;
797
+ - const kind = error?.failure?.kind ?? error?.kind;
798
+ - const known = ["auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable"];
799
+ - const failure = { kind: kind === "auth" ? "auth_missing" : known.includes(kind) ? kind : "provider",
800
+ - provider: selectedModel.provider, model: selectedModel.id };
801
+ + const details = summaryFailureDetails(error, selectedModel, inputTokens);
802
+ + const kind = details.kind;
803
+ + const known = ["auth_missing", "auth_expired", "quota", "account_blocked", "org_denied", "model_unavailable"];
804
+ + const failure = { ...details, kind: kind === "auth" ? "auth_missing" : known.includes(kind) ? kind : "provider", requestSent: false };
805
+ + noteSummaryUnavailable(context.sessionManager, selectedModel, failure);
806
+ throw Object.assign(new SummaryGenerationError(failure.kind, "Compaction request authentication failed"), { failure, cause: error });
807
+ }
808
+ if (signal?.aborted)
809
+ return undefined;
810
+ if (!auth?.ok || !(auth.apiKey || hasCredentialHeaders(auth.headers) || auth.ambient)) {
811
+ - const failure = { kind: "auth_missing", provider: selectedModel.provider, model: selectedModel.id };
812
+ + const failure = { ...summaryFailureDetails(undefined, selectedModel, inputTokens), kind: "auth_missing", requestSent: false };
813
+ throw Object.assign(new SummaryGenerationError(failure.kind, "Compaction request authentication unavailable"), { failure });
814
+ }
815
+ const requestSnapshot = auth.baseUrl
816
+ @@ -222,6 +321,8 @@ export async function runExtensionCompaction(context, snapshot, signal, onProgre
817
+ // surrounding loop keeps overflow shrinking and abort handling, which
818
+ // must never be answered by replaying the same request.
819
+ response = await retryTransientCall(async () => {
820
+ + const blocked = summaryUnavailable(context.sessionManager, selectedModel);
821
+ + if (blocked) throw Object.assign(new SummaryGenerationError(blocked.kind, "Compaction summary provider unavailable"), { failure: { ...blocked, inputTokens, inputTokensSource: "estimate", requestSent: false } });
822
+ const attempt = await generateSummaryMessage({
823
+ context,
824
+ forbidToolCalls: true,
825
+ @@ -244,8 +345,10 @@ export async function runExtensionCompaction(context, snapshot, signal, onProgre
826
+ isAssistantMessage(attempt) &&
827
+ (attempt.stopReason === "error" || isRefusalStop(attempt)) &&
828
+ !isContextOverflow(attempt, requestSnapshot.contextWindow)) {
829
+ - const failureKind = summaryRequestFailureKind(attempt);
830
+ - throw new SummaryRequestError(attempt.errorMessage || "Compaction summary request failed", attempt.content.length === 0 && failureKind === undefined && isRetryableAssistantError(attempt), failureKind, isRefusalStop(attempt));
831
+ + const failureKind = summaryRequestFailureKind(attempt, selectedModel);
832
+ + const error = new SummaryRequestError(attempt.errorMessage || "Compaction summary request failed", attempt.content.length === 0 && failureKind === undefined && isRetryableAssistantError(attempt), failureKind, isRefusalStop(attempt));
833
+ + error.failure = { ...summaryFailureDetails(attempt, selectedModel, inputTokens), kind: error.failure.kind };
834
+ + throw error;
835
+ }
836
+ return attempt;
837
+ }, (error) => retryEligible &&
838
+ @@ -256,15 +359,23 @@ export async function runExtensionCompaction(context, snapshot, signal, onProgre
839
+ catch (error) {
840
+ if (signal?.aborted)
841
+ return undefined;
842
+ + error.failure = compactionFailure(error, selectedModel);
843
+ + if (!error.failure.inputTokens) error.failure.inputTokens = inputTokens;
844
+ + noteSummaryUnavailable(context.sessionManager, selectedModel, error.failure);
845
+ throw error;
846
+ }
847
+ if (!response && signal?.aborted)
848
+ return undefined;
849
+ if (response && isAssistantMessage(response) && isContextOverflow(response, requestSnapshot.contextWindow)) {
850
+ - const failure = { kind: "context_budget", provider: selectedModel.provider, model: selectedModel.id };
851
+ + const failure = { ...summaryFailureDetails(response, selectedModel, inputTokens), kind: "context_budget" };
852
+ throw Object.assign(new SummaryGenerationError(failure.kind, "Compaction input exceeds the provider context budget"), { failure });
853
+ }
854
+ - const parsedSummary = validateSummaryResponse(response);
855
+ + let parsedSummary;
856
+ + try { parsedSummary = validateSummaryResponse(response); }
857
+ + catch (error) {
858
+ + error.failure = { ...summaryFailureDetails(response, selectedModel, inputTokens), kind: error.failure?.kind ?? "provider" };
859
+ + throw error;
860
+ + }
861
+ const inheritedIntent = resolveInheritedTaskIntent(snapshot.branchEntries ?? []);
862
+ if (inheritedIntent && parsedSummary.taskIntent && parsedSummary.taskIntent !== inheritedIntent)
863
+ throw new SummaryGenerationError("incomplete", "Compaction changed protected task intent");
864
+ --- a/@code-yeongyu/senpi/dist/core/extensions/runner.js
865
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/runner.js
866
+ @@ -397,6 +397,7 @@ export class ExtensionRunner {
867
+ this.offloadFn = contextActions.offload;
868
+ // Context actions (required)
869
+ this.getModel = contextActions.getModel;
870
+ + this.getModelSwitchState = contextActions.getModelSwitchState;
871
+ this.getServiceTier = contextActions.getServiceTier;
872
+ this.getEffectiveServiceTier = contextActions.getEffectiveServiceTier ?? contextActions.getServiceTier;
873
+ this.getScopedModels = contextActions.getScopedModels;
874
+ @@ -1119,6 +1120,10 @@ export class ExtensionRunner {
875
+ runner.assertActive();
876
+ return getModel();
877
+ },
878
+ + getModelSwitchState: () => {
879
+ + runner.assertActive();
880
+ + return runner.getModelSwitchState?.() ?? { current: getModel(), pending: undefined };
881
+ + },
882
+ get serviceTier() {
883
+ runner.assertActive();
884
+ return getServiceTier();
885
+ --- a/@code-yeongyu/senpi/dist/core/extensions/types.d.ts
886
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/types.d.ts
887
+ @@ -363,9 +363,14 @@ export interface UpdateCompactionOptions {
888
+ text?: string;
889
+ }
890
+ export interface CompactionFailure {
891
+ - kind: "invalid_model" | "auth_missing" | "auth_expired" | "quota" | "org_denied" | "model_unavailable" | "refused" | "timeout" | "incomplete" | "empty" | "context_budget" | "stale" | "provider";
892
+ + kind: "invalid_model" | "auth_missing" | "auth_expired" | "quota" | "account_blocked" | "org_denied" | "model_unavailable" | "refused" | "timeout" | "incomplete" | "empty" | "context_budget" | "stale" | "provider";
893
+ provider?: string;
894
+ model?: string;
895
+ + error?: string;
896
+ + inputTokens?: number;
897
+ + inputTokensSource?: "estimate" | "provider";
898
+ + requestSent?: boolean;
899
+ + retryAt?: number;
900
+ }
901
+ export interface EndCompactionOptions {
902
+ failure?: CompactionFailure;
903
+ @@ -521,6 +526,8 @@ export interface ExtensionContext {
904
+ getCompactionSettings(): CompactionPreparation["settings"];
905
+ /** Select a budget-fitting summary model; auto stays within the active provider and explicit failures never fall back. */
906
+ getCompactionModel?(inputTokens?: number): Model<Api> | undefined;
907
+ + /** Actual current and held model identities, resolved lazily from the session. */
908
+ + getModelSwitchState?(): { current: Model<Api> | undefined; pending: Model<Api> | undefined };
909
+ /**
910
+ * Longest a tool may block in the foreground before the active model's prompt
911
+ * cache expires, or `undefined` when no cache-derived budget applies. Reads the