@oh-my-pi/pi-coding-agent 12.7.6 → 12.8.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 (56) hide show
  1. package/CHANGELOG.md +37 -37
  2. package/README.md +9 -1052
  3. package/package.json +7 -7
  4. package/src/cli/args.ts +1 -0
  5. package/src/cli/update-cli.ts +49 -35
  6. package/src/cli/web-search-cli.ts +3 -2
  7. package/src/commands/web-search.ts +1 -0
  8. package/src/config/model-registry.ts +6 -0
  9. package/src/config/settings-schema.ts +25 -3
  10. package/src/config/settings.ts +1 -0
  11. package/src/extensibility/extensions/wrapper.ts +20 -13
  12. package/src/extensibility/slash-commands.ts +12 -91
  13. package/src/lsp/client.ts +24 -27
  14. package/src/lsp/index.ts +92 -42
  15. package/src/mcp/config-writer.ts +33 -0
  16. package/src/mcp/config.ts +6 -1
  17. package/src/mcp/types.ts +1 -0
  18. package/src/modes/components/custom-editor.ts +8 -5
  19. package/src/modes/components/settings-defs.ts +2 -1
  20. package/src/modes/controllers/command-controller.ts +12 -6
  21. package/src/modes/controllers/input-controller.ts +21 -186
  22. package/src/modes/controllers/mcp-command-controller.ts +60 -3
  23. package/src/modes/interactive-mode.ts +2 -2
  24. package/src/modes/types.ts +1 -1
  25. package/src/sdk.ts +23 -1
  26. package/src/secrets/index.ts +116 -0
  27. package/src/secrets/obfuscator.ts +269 -0
  28. package/src/secrets/regex.ts +21 -0
  29. package/src/session/agent-session.ts +138 -21
  30. package/src/session/compaction/branch-summarization.ts +2 -2
  31. package/src/session/compaction/compaction.ts +10 -3
  32. package/src/session/compaction/utils.ts +25 -1
  33. package/src/slash-commands/builtin-registry.ts +419 -0
  34. package/src/web/scrapers/github.ts +50 -12
  35. package/src/web/search/index.ts +5 -5
  36. package/src/web/search/provider.ts +13 -2
  37. package/src/web/search/providers/brave.ts +165 -0
  38. package/src/web/search/types.ts +1 -1
  39. package/docs/compaction.md +0 -436
  40. package/docs/config-usage.md +0 -176
  41. package/docs/custom-tools.md +0 -585
  42. package/docs/environment-variables.md +0 -257
  43. package/docs/extension-loading.md +0 -106
  44. package/docs/extensions.md +0 -1342
  45. package/docs/fs-scan-cache-architecture.md +0 -50
  46. package/docs/hooks.md +0 -906
  47. package/docs/models.md +0 -234
  48. package/docs/python-repl.md +0 -110
  49. package/docs/rpc.md +0 -1173
  50. package/docs/sdk.md +0 -1039
  51. package/docs/session-tree-plan.md +0 -84
  52. package/docs/session.md +0 -368
  53. package/docs/skills.md +0 -254
  54. package/docs/theme.md +0 -696
  55. package/docs/tree.md +0 -206
  56. package/docs/tui.md +0 -487
@@ -0,0 +1,269 @@
1
+ import type { Message, TextContent } from "@oh-my-pi/pi-ai";
2
+ import { compileSecretRegex } from "./regex";
3
+
4
+ // ═══════════════════════════════════════════════════════════════════════════
5
+ // Types
6
+ // ═══════════════════════════════════════════════════════════════════════════
7
+
8
+ export interface SecretEntry {
9
+ type: "plain" | "regex";
10
+ content: string;
11
+ mode?: "obfuscate" | "replace";
12
+ replacement?: string;
13
+ flags?: string;
14
+ }
15
+
16
+ // ═══════════════════════════════════════════════════════════════════════════
17
+ // Deterministic replacement generation
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+
20
+ const REPLACEMENT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
21
+
22
+ /** Generate a deterministic same-length replacement string from a secret value. */
23
+ function generateDeterministicReplacement(secret: string): string {
24
+ // Simple hash: use Bun.hash for speed, seed from the secret bytes
25
+ const hash = BigInt(Bun.hash(secret));
26
+ const chars: string[] = [];
27
+ let h = hash;
28
+ for (let i = 0; i < secret.length; i++) {
29
+ // Mix the hash for each character position
30
+ h = h ^ (BigInt(i + 1) * 0x9e3779b97f4a7c15n);
31
+ const idx = Number((h < 0n ? -h : h) % BigInt(REPLACEMENT_CHARS.length));
32
+ chars.push(REPLACEMENT_CHARS[idx]);
33
+ }
34
+ return chars.join("");
35
+ }
36
+
37
+ // ═══════════════════════════════════════════════════════════════════════════
38
+ // Placeholder format
39
+ // ═══════════════════════════════════════════════════════════════════════════
40
+
41
+ const PLACEHOLDER_PREFIX = "<<$env:S";
42
+ const PLACEHOLDER_SUFFIX = ">>";
43
+
44
+ /** Build an obfuscation placeholder for secret index N, padded to match the secret length. */
45
+ function buildPlaceholder(index: number, secretLength: number): string {
46
+ // Minimum: <<$env:SN>> = 11 chars for single-digit index
47
+ const bare = `${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}`;
48
+ if (secretLength <= bare.length) {
49
+ return bare;
50
+ }
51
+ // Pad with '.' between index and >>
52
+ const paddingNeeded = secretLength - bare.length;
53
+ const padding = ".".repeat(paddingNeeded);
54
+ return `${PLACEHOLDER_PREFIX}${index}=${padding}${PLACEHOLDER_SUFFIX}`;
55
+ }
56
+
57
+ /** Regex to match obfuscation placeholders: <<$env:S<N>=?<padding>?>> */
58
+ const PLACEHOLDER_RE = /<<\$env:S(\d+)(?:=[.]*)?>>(?!>)/g;
59
+
60
+ // ═══════════════════════════════════════════════════════════════════════════
61
+ // SecretObfuscator
62
+ // ═══════════════════════════════════════════════════════════════════════════
63
+
64
+ export class SecretObfuscator {
65
+ /** Plain secrets: secret → index (known at construction) */
66
+ #plainMappings = new Map<string, number>();
67
+
68
+ /** Regex entries (patterns compiled at construction) */
69
+ #regexEntries: Array<{ regex: RegExp; mode: "obfuscate" | "replace"; replacement?: string }> = [];
70
+
71
+ /** All obfuscate-mode mappings: index → { secret, placeholder } */
72
+ #obfuscateMappings = new Map<number, { secret: string; placeholder: string }>();
73
+
74
+ /** Replace-mode plain mappings: secret → replacement */
75
+ #replaceMappings = new Map<string, string>();
76
+
77
+ /** Reverse lookup for deobfuscation: placeholder → secret */
78
+ #deobfuscateMap = new Map<string, string>();
79
+
80
+ /** Next available index for regex match discoveries */
81
+ #nextIndex: number;
82
+
83
+ /** Whether any secrets were configured */
84
+ #hasAny: boolean;
85
+
86
+ constructor(entries: SecretEntry[]) {
87
+ let index = 0;
88
+ for (const entry of entries) {
89
+ const mode = entry.mode ?? "obfuscate";
90
+
91
+ if (entry.type === "plain") {
92
+ if (mode === "obfuscate") {
93
+ const placeholder = buildPlaceholder(index, entry.content.length);
94
+ this.#plainMappings.set(entry.content, index);
95
+ this.#obfuscateMappings.set(index, { secret: entry.content, placeholder });
96
+ this.#deobfuscateMap.set(placeholder, entry.content);
97
+ index++;
98
+ } else {
99
+ // replace mode
100
+ const replacement = entry.replacement ?? generateDeterministicReplacement(entry.content);
101
+ this.#replaceMappings.set(entry.content, replacement);
102
+ }
103
+ } else {
104
+ // regex type — compiled here, matches discovered during obfuscate()
105
+ try {
106
+ const regex = compileSecretRegex(entry.content, entry.flags);
107
+ this.#regexEntries.push({ regex, mode, replacement: entry.replacement });
108
+ } catch {
109
+ // Invalid regex — skip silently (validation happens at load time)
110
+ }
111
+ }
112
+ }
113
+
114
+ this.#nextIndex = index;
115
+ this.#hasAny = entries.length > 0;
116
+ }
117
+
118
+ hasSecrets(): boolean {
119
+ return this.#hasAny;
120
+ }
121
+
122
+ /** Obfuscate all secrets in text. Bidirectional placeholders for obfuscate mode, one-way for replace. */
123
+ obfuscate(text: string): string {
124
+ if (!this.#hasAny) return text;
125
+ let result = text;
126
+
127
+ // 1. Process replace-mode plain secrets
128
+ for (const [secret, replacement] of [...this.#replaceMappings].sort((a, b) => b[0].length - a[0].length)) {
129
+ result = replaceAll(result, secret, replacement);
130
+ }
131
+
132
+ // 2. Process obfuscate-mode plain secrets
133
+ for (const [secret, index] of [...this.#plainMappings].sort((a, b) => b[0].length - a[0].length)) {
134
+ const mapping = this.#obfuscateMappings.get(index)!;
135
+ result = replaceAll(result, secret, mapping.placeholder);
136
+ }
137
+
138
+ // 3. Process regex entries — discover new matches
139
+ for (const entry of this.#regexEntries) {
140
+ entry.regex.lastIndex = 0;
141
+ const matches = new Set<string>();
142
+ for (;;) {
143
+ const match = entry.regex.exec(result);
144
+ if (match === null) break;
145
+ if (match[0].length === 0) {
146
+ entry.regex.lastIndex++;
147
+ continue;
148
+ }
149
+ matches.add(match[0]);
150
+ }
151
+
152
+ for (const matchValue of matches) {
153
+ if (entry.mode === "replace") {
154
+ const replacement = entry.replacement ?? generateDeterministicReplacement(matchValue);
155
+ result = replaceAll(result, matchValue, replacement);
156
+ } else {
157
+ // obfuscate mode — get or create stable index
158
+ let index = this.#findObfuscateIndex(matchValue);
159
+ if (index === undefined) {
160
+ index = this.#nextIndex++;
161
+ const placeholder = buildPlaceholder(index, matchValue.length);
162
+ this.#obfuscateMappings.set(index, { secret: matchValue, placeholder });
163
+ this.#deobfuscateMap.set(placeholder, matchValue);
164
+ }
165
+ const mapping = this.#obfuscateMappings.get(index)!;
166
+ result = replaceAll(result, matchValue, mapping.placeholder);
167
+ }
168
+ }
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+ /** Deobfuscate obfuscate-mode placeholders back to original secrets. Replace-mode is NOT reversed. */
175
+ deobfuscate(text: string): string {
176
+ if (!this.#hasAny) return text;
177
+ return text.replace(PLACEHOLDER_RE, match => {
178
+ return this.#deobfuscateMap.get(match) ?? match;
179
+ });
180
+ }
181
+
182
+ /** Deep-walk an object, deobfuscating all string values. */
183
+ deobfuscateObject<T>(obj: T): T {
184
+ if (!this.#hasAny) return obj;
185
+ return deepWalkStrings(obj, s => this.deobfuscate(s));
186
+ }
187
+
188
+ /** Find the obfuscate index for a known secret value. */
189
+ #findObfuscateIndex(secret: string): number | undefined {
190
+ // Check plain mappings first
191
+ const plainIndex = this.#plainMappings.get(secret);
192
+ if (plainIndex !== undefined) return plainIndex;
193
+
194
+ // Check regex-discovered mappings
195
+ for (const [index, mapping] of this.#obfuscateMappings) {
196
+ if (mapping.secret === secret) return index;
197
+ }
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ // ═══════════════════════════════════════════════════════════════════════════
203
+ // Message obfuscation (outbound to LLM)
204
+ // ═══════════════════════════════════════════════════════════════════════════
205
+
206
+ /** Obfuscate all text content in LLM messages (for outbound interception). */
207
+ export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] {
208
+ return messages.map(msg => {
209
+ if (!Array.isArray(msg.content)) return msg;
210
+
211
+ let changed = false;
212
+ const content = msg.content.map(block => {
213
+ if (block.type === "text") {
214
+ const obfuscated = obfuscator.obfuscate(block.text);
215
+ if (obfuscated !== block.text) {
216
+ changed = true;
217
+ return { ...block, text: obfuscated } as TextContent;
218
+ }
219
+ }
220
+ return block;
221
+ });
222
+
223
+ return changed ? ({ ...msg, content } as typeof msg) : msg;
224
+ });
225
+ }
226
+
227
+ // ═══════════════════════════════════════════════════════════════════════════
228
+ // Helpers
229
+ // ═══════════════════════════════════════════════════════════════════════════
230
+
231
+ /** Replace all occurrences of `search` in `text` with `replacement`. */
232
+ function replaceAll(text: string, search: string, replacement: string): string {
233
+ if (search.length === 0) return text;
234
+ let result = text;
235
+ let idx = result.indexOf(search);
236
+ while (idx !== -1) {
237
+ result = result.slice(0, idx) + replacement + result.slice(idx + search.length);
238
+ idx = result.indexOf(search, idx + replacement.length);
239
+ }
240
+ return result;
241
+ }
242
+
243
+ /** Deep-walk an object, transforming all string values. */
244
+ function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
245
+ if (typeof obj === "string") {
246
+ return transform(obj) as unknown as T;
247
+ }
248
+ if (Array.isArray(obj)) {
249
+ let changed = false;
250
+ const result = obj.map(item => {
251
+ const transformed = deepWalkStrings(item, transform);
252
+ if (transformed !== item) changed = true;
253
+ return transformed;
254
+ });
255
+ return (changed ? result : obj) as unknown as T;
256
+ }
257
+ if (obj !== null && typeof obj === "object") {
258
+ let changed = false;
259
+ const result: Record<string, unknown> = {};
260
+ for (const key of Object.keys(obj)) {
261
+ const value = (obj as Record<string, unknown>)[key];
262
+ const transformed = deepWalkStrings(value, transform);
263
+ if (transformed !== value) changed = true;
264
+ result[key] = transformed;
265
+ }
266
+ return (changed ? result : obj) as T;
267
+ }
268
+ return obj;
269
+ }
@@ -0,0 +1,21 @@
1
+ /** Add global flag while preserving user-provided flags. */
2
+ function enforceGlobalFlag(flags: string): string {
3
+ return flags.includes("g") ? flags : `${flags}g`;
4
+ }
5
+
6
+ /** Compile a secret regex entry with global scanning enabled by default. */
7
+ export function compileSecretRegex(pattern: string, flags?: string): RegExp {
8
+ let resolvedPattern = pattern;
9
+ let resolvedFlags = flags ?? "";
10
+
11
+ // Detect regex literal syntax: /pattern/flags
12
+ const literalMatch = /^\/((?:[^\\/]|\\.)*)\/([ gimsuy]*)$/.exec(pattern);
13
+ if (literalMatch) {
14
+ resolvedPattern = literalMatch[1];
15
+ // Merge flags from literal with explicit flags param (deduplicate)
16
+ const combined = new Set([...resolvedFlags, ...literalMatch[2]]);
17
+ resolvedFlags = [...combined].join("");
18
+ }
19
+
20
+ return new RegExp(resolvedPattern, enforceGlobalFlag(resolvedFlags));
21
+ }
@@ -78,6 +78,7 @@ import type { PlanModeState } from "../plan-mode/state";
78
78
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
79
79
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
80
80
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
81
+ import type { SecretObfuscator } from "../secrets/obfuscator";
81
82
  import { closeAllConnections } from "../ssh/connection-manager";
82
83
  import { unmountAll } from "../ssh/sshfs-mount";
83
84
  import { outputMeta } from "../tools/output-meta";
@@ -162,6 +163,8 @@ export interface AgentSessionConfig {
162
163
  ttsrManager?: TtsrManager;
163
164
  /** Force X-Initiator: agent for GitHub Copilot model selections in this session. */
164
165
  forceCopilotAgentInitiator?: boolean;
166
+ /** Secret obfuscator for deobfuscating streaming edit content */
167
+ obfuscator?: SecretObfuscator;
165
168
  }
166
169
 
167
170
  /** Options for AgentSession.prompt() */
@@ -348,6 +351,7 @@ export class AgentSession {
348
351
  #streamingEditCheckedLineCounts = new Map<string, number>();
349
352
  #streamingEditFileCache = new Map<string, string>();
350
353
  #promptInFlight = false;
354
+ #obfuscator: SecretObfuscator | undefined;
351
355
  #promptGeneration = 0;
352
356
  #providerSessionState = new Map<string, ProviderSessionState>();
353
357
 
@@ -369,6 +373,7 @@ export class AgentSession {
369
373
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
370
374
  this.#ttsrManager = config.ttsrManager;
371
375
  this.#forceCopilotAgentInitiator = config.forceCopilotAgentInitiator ?? false;
376
+ this.#obfuscator = config.obfuscator;
372
377
  this.agent.providerSessionState = this.#providerSessionState;
373
378
 
374
379
  // Always subscribe to agent events for internal handling
@@ -729,7 +734,10 @@ export class AgentSession {
729
734
  const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
730
735
  if (diffForCheck.trim().length === 0) return;
731
736
 
732
- const normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
737
+ let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
738
+ if (!normalizedDiff) return;
739
+ // Deobfuscate the diff so removed lines match real file content
740
+ if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff);
733
741
  if (!normalizedDiff) return;
734
742
  const lines = normalizedDiff.split("\n");
735
743
  const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-"));
@@ -2689,44 +2697,34 @@ Be thorough - include exact file paths, function names, error messages, and tech
2689
2697
  }
2690
2698
 
2691
2699
  /**
2692
- * Check if compaction is needed and run it.
2700
+ * Check if compaction or context promotion is needed and run it.
2693
2701
  * Called after agent_end and before prompt submission.
2694
2702
  *
2695
- * Two cases:
2696
- * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry
2697
- * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
2703
+ * Three cases (in order):
2704
+ * 1. Overflow + promotion: promote to larger model, retry without compacting
2705
+ * 2. Overflow + no promotion target: compact, auto-retry on same model
2706
+ * 3. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
2698
2707
  *
2699
2708
  * @param assistantMessage The assistant message to check
2700
2709
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
2701
2710
  */
2702
2711
  async #checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {
2703
- const compactionSettings = this.settings.getGroup("compaction");
2704
- if (!compactionSettings.enabled) return;
2705
-
2706
- const pruneResult = await this.#pruneToolOutputs();
2707
-
2708
2712
  // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
2709
2713
  if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return;
2710
-
2711
2714
  const contextWindow = this.model?.contextWindow ?? 0;
2712
-
2713
2715
  // Skip overflow check if the message came from a different model.
2714
2716
  // This handles the case where user switched from a smaller-context model (e.g. opus)
2715
2717
  // to a larger-context model (e.g. codex) - the overflow error from the old model
2716
2718
  // shouldn't trigger compaction for the new model.
2717
2719
  const sameModel =
2718
2720
  this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;
2719
-
2720
- // Skip overflow check if the error is from before a compaction in the current path.
2721
2721
  // This handles the case where an error was kept after compaction (in the "kept" region).
2722
2722
  // The error shouldn't trigger another compaction since we already compacted.
2723
- // Example: opus fails switch to codex compact switch back to opus opus error
2723
+ // Example: opus fails \u2192 switch to codex \u2192 compact \u2192 switch back to opus \u2192 opus error
2724
2724
  // is still in context but shouldn't trigger compaction again.
2725
2725
  const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
2726
2726
  const errorIsFromBeforeCompaction =
2727
2727
  compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
2728
-
2729
- // Case 1: Overflow - LLM returned context overflow error
2730
2728
  if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
2731
2729
  // Remove the error message from agent state (it IS saved to session for history,
2732
2730
  // but we don't want it in context for the retry)
@@ -2734,23 +2732,43 @@ Be thorough - include exact file paths, function names, error messages, and tech
2734
2732
  if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2735
2733
  this.agent.replaceMessages(messages.slice(0, -1));
2736
2734
  }
2737
- await this.#runAutoCompaction("overflow", true);
2735
+
2736
+ // Try context promotion first \u2014 switch to a larger model and retry without compacting
2737
+ const promoted = await this.#tryContextPromotion(assistantMessage);
2738
+ if (promoted) {
2739
+ // Retry on the promoted (larger) model without compacting
2740
+ setTimeout(() => {
2741
+ this.agent.continue().catch(() => {});
2742
+ }, 100);
2743
+ return;
2744
+ }
2745
+
2746
+ // No promotion target available \u2014 fall through to compaction
2747
+ const compactionSettings = this.settings.getGroup("compaction");
2748
+ if (compactionSettings.enabled) {
2749
+ await this.#runAutoCompaction("overflow", true);
2750
+ }
2738
2751
  return;
2739
2752
  }
2753
+ const compactionSettings = this.settings.getGroup("compaction");
2754
+ if (!compactionSettings.enabled) return;
2740
2755
 
2741
2756
  // Case 2: Threshold - turn succeeded but context is getting large
2742
2757
  // Skip if this was an error (non-overflow errors don't have usage data)
2743
2758
  if (assistantMessage.stopReason === "error") return;
2744
-
2759
+ const pruneResult = await this.#pruneToolOutputs();
2745
2760
  let contextTokens = calculateContextTokens(assistantMessage.usage);
2746
2761
  if (pruneResult) {
2747
2762
  contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
2748
2763
  }
2749
2764
  if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
2750
- await this.#runAutoCompaction("threshold", false);
2765
+ // Try promotion first — if a larger model is available, switch instead of compacting
2766
+ const promoted = await this.#tryContextPromotion(assistantMessage);
2767
+ if (!promoted) {
2768
+ await this.#runAutoCompaction("threshold", false);
2769
+ }
2751
2770
  }
2752
2771
  }
2753
-
2754
2772
  /**
2755
2773
  * Check if agent stopped with incomplete todos and prompt to continue.
2756
2774
  */
@@ -2824,10 +2842,109 @@ Be thorough - include exact file paths, function names, error messages, and tech
2824
2842
  this.agent.continue().catch(() => {});
2825
2843
  }
2826
2844
 
2845
+ /**
2846
+ * Attempt context promotion to a larger model.
2847
+ * Returns true if promotion succeeded (caller should retry without compacting).
2848
+ */
2849
+ async #tryContextPromotion(assistantMessage: AssistantMessage): Promise<boolean> {
2850
+ const promotionSettings = this.settings.getGroup("contextPromotion");
2851
+ if (!promotionSettings.enabled) return false;
2852
+ const currentModel = this.model;
2853
+ if (!currentModel) return false;
2854
+ if (assistantMessage.provider !== currentModel.provider || assistantMessage.model !== currentModel.id)
2855
+ return false;
2856
+ const contextWindow = currentModel.contextWindow ?? 0;
2857
+ if (contextWindow <= 0) return false;
2858
+ const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
2859
+ if (!targetModel) return false;
2860
+
2861
+ try {
2862
+ this.#closeProviderSessionsForModelSwitch(currentModel, targetModel);
2863
+ await this.setModelTemporary(targetModel);
2864
+ logger.debug("Context promotion switched model on overflow", {
2865
+ from: `${currentModel.provider}/${currentModel.id}`,
2866
+ to: `${targetModel.provider}/${targetModel.id}`,
2867
+ });
2868
+ return true;
2869
+ } catch (error) {
2870
+ logger.warn("Context promotion failed", {
2871
+ from: `${currentModel.provider}/${currentModel.id}`,
2872
+ to: `${targetModel.provider}/${targetModel.id}`,
2873
+ error: String(error),
2874
+ });
2875
+ return false;
2876
+ }
2877
+ }
2878
+
2879
+ async #resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
2880
+ const availableModels = this.#modelRegistry.getAvailable();
2881
+ if (availableModels.length === 0) return undefined;
2882
+
2883
+ const candidates: Model[] = [];
2884
+ const seen = new Set<string>();
2885
+ const addCandidate = (candidate: Model | undefined): void => {
2886
+ if (!candidate) return;
2887
+ const key = this.#getModelKey(candidate);
2888
+ if (seen.has(key)) return;
2889
+ seen.add(key);
2890
+ candidates.push(candidate);
2891
+ };
2892
+
2893
+ addCandidate(this.#resolveContextPromotionConfiguredTarget(currentModel, availableModels));
2894
+
2895
+ const sameProviderLarger = [...availableModels]
2896
+ .filter(
2897
+ m => m.provider === currentModel.provider && m.api === currentModel.api && m.contextWindow > contextWindow,
2898
+ )
2899
+ .sort((a, b) => a.contextWindow - b.contextWindow);
2900
+ addCandidate(sameProviderLarger[0]);
2901
+ for (const candidate of candidates) {
2902
+ if (modelsAreEqual(candidate, currentModel)) continue;
2903
+ if (candidate.contextWindow <= contextWindow) continue;
2904
+ const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
2905
+ if (!apiKey) continue;
2906
+ return candidate;
2907
+ }
2908
+
2909
+ return undefined;
2910
+ }
2911
+
2912
+ #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
2913
+ if (currentModel.api !== "openai-codex-responses" && nextModel.api !== "openai-codex-responses") return;
2914
+
2915
+ const providerKey = "openai-codex-responses";
2916
+ const state = this.#providerSessionState.get(providerKey);
2917
+ if (!state) return;
2918
+
2919
+ try {
2920
+ state.close();
2921
+ } catch (error) {
2922
+ logger.warn("Failed to close provider session state during model switch", {
2923
+ providerKey,
2924
+ error: String(error),
2925
+ });
2926
+ }
2927
+
2928
+ this.#providerSessionState.delete(providerKey);
2929
+ }
2930
+
2827
2931
  #getModelKey(model: Model): string {
2828
2932
  return `${model.provider}/${model.id}`;
2829
2933
  }
2830
2934
 
2935
+ #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
2936
+ const configuredTarget = currentModel.contextPromotionTarget?.trim();
2937
+ if (!configuredTarget) return undefined;
2938
+
2939
+ const parsed = parseModelString(configuredTarget);
2940
+ if (parsed) {
2941
+ const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
2942
+ if (explicitModel) return explicitModel;
2943
+ }
2944
+
2945
+ return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
2946
+ }
2947
+
2831
2948
  #resolveRoleModel(role: ModelRole, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
2832
2949
  const roleModelStr =
2833
2950
  role === "default"
@@ -23,9 +23,9 @@ import {
23
23
  createFileOps,
24
24
  extractFileOpsFromMessage,
25
25
  type FileOperations,
26
- formatFileOperations,
27
26
  SUMMARIZATION_SYSTEM_PROMPT,
28
27
  serializeConversation,
28
+ upsertFileOperations,
29
29
  } from "./utils";
30
30
 
31
31
  // ============================================================================
@@ -305,7 +305,7 @@ export async function generateBranchSummary(
305
305
 
306
306
  // Compute file lists and append to summary
307
307
  const { readFiles, modifiedFiles } = computeFileLists(fileOps);
308
- summary += formatFileOperations(readFiles, modifiedFiles);
308
+ summary = upsertFileOperations(summary, readFiles, modifiedFiles);
309
309
 
310
310
  return {
311
311
  summary: summary || "No summary generated",
@@ -21,9 +21,9 @@ import {
21
21
  createFileOps,
22
22
  extractFileOpsFromMessage,
23
23
  type FileOperations,
24
- formatFileOperations,
25
24
  SUMMARIZATION_SYSTEM_PROMPT,
26
25
  serializeConversation,
26
+ upsertFileOperations,
27
27
  } from "./utils";
28
28
 
29
29
  // ============================================================================
@@ -169,12 +169,19 @@ export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefine
169
169
  return undefined;
170
170
  }
171
171
 
172
+ /**
173
+ * Effective reserve: at least 15% of context window or the configured floor, whichever is larger.
174
+ */
175
+ export function effectiveReserveTokens(contextWindow: number, settings: CompactionSettings): number {
176
+ return Math.max(Math.floor(contextWindow * 0.15), settings.reserveTokens);
177
+ }
178
+
172
179
  /**
173
180
  * Check if compaction should trigger based on context usage.
174
181
  */
175
182
  export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
176
183
  if (!settings.enabled) return false;
177
- return contextTokens > contextWindow - settings.reserveTokens;
184
+ return contextTokens > contextWindow - effectiveReserveTokens(contextWindow, settings);
178
185
  }
179
186
 
180
187
  // ============================================================================
@@ -802,7 +809,7 @@ export async function compact(
802
809
 
803
810
  // Compute file lists and append to summary
804
811
  const { readFiles, modifiedFiles } = computeFileLists(fileOps);
805
- summary += formatFileOperations(readFiles, modifiedFiles);
812
+ summary = upsertFileOperations(summary, readFiles, modifiedFiles);
806
813
 
807
814
  if (!firstKeptEntryId) {
808
815
  throw new Error("First kept entry has no ID - session may need migration");
@@ -71,9 +71,33 @@ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]
71
71
  /**
72
72
  * Format file operations as XML tags for summary.
73
73
  */
74
+ const FILE_OPERATION_SUMMARY_LIMIT = 20;
75
+
76
+ function truncateFileList(files: string[]): string[] {
77
+ if (files.length <= FILE_OPERATION_SUMMARY_LIMIT) return files;
78
+ const omitted = files.length - FILE_OPERATION_SUMMARY_LIMIT;
79
+ return [...files.slice(0, FILE_OPERATION_SUMMARY_LIMIT), `… (${omitted} more files omitted)`];
80
+ }
81
+
82
+ function stripFileOperationTags(summary: string): string {
83
+ const withoutReadFiles = summary.replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "");
84
+ const withoutModifiedFiles = withoutReadFiles.replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "");
85
+ return withoutModifiedFiles.trimEnd();
86
+ }
74
87
  export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
75
88
  if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
76
- return renderPromptTemplate(fileOperationsTemplate, { readFiles, modifiedFiles });
89
+ return renderPromptTemplate(fileOperationsTemplate, {
90
+ readFiles: truncateFileList(readFiles),
91
+ modifiedFiles: truncateFileList(modifiedFiles),
92
+ });
93
+ }
94
+
95
+ export function upsertFileOperations(summary: string, readFiles: string[], modifiedFiles: string[]): string {
96
+ const baseSummary = stripFileOperationTags(summary);
97
+ const fileOperations = formatFileOperations(readFiles, modifiedFiles);
98
+ if (!fileOperations) return baseSummary;
99
+ if (!baseSummary) return fileOperations;
100
+ return `${baseSummary}\n\n${fileOperations}`;
77
101
  }
78
102
 
79
103
  // ============================================================================