@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.
- package/CHANGELOG.md +37 -37
- package/README.md +9 -1052
- package/package.json +7 -7
- package/src/cli/args.ts +1 -0
- package/src/cli/update-cli.ts +49 -35
- package/src/cli/web-search-cli.ts +3 -2
- package/src/commands/web-search.ts +1 -0
- package/src/config/model-registry.ts +6 -0
- package/src/config/settings-schema.ts +25 -3
- package/src/config/settings.ts +1 -0
- package/src/extensibility/extensions/wrapper.ts +20 -13
- package/src/extensibility/slash-commands.ts +12 -91
- package/src/lsp/client.ts +24 -27
- package/src/lsp/index.ts +92 -42
- package/src/mcp/config-writer.ts +33 -0
- package/src/mcp/config.ts +6 -1
- package/src/mcp/types.ts +1 -0
- package/src/modes/components/custom-editor.ts +8 -5
- package/src/modes/components/settings-defs.ts +2 -1
- package/src/modes/controllers/command-controller.ts +12 -6
- package/src/modes/controllers/input-controller.ts +21 -186
- package/src/modes/controllers/mcp-command-controller.ts +60 -3
- package/src/modes/interactive-mode.ts +2 -2
- package/src/modes/types.ts +1 -1
- package/src/sdk.ts +23 -1
- package/src/secrets/index.ts +116 -0
- package/src/secrets/obfuscator.ts +269 -0
- package/src/secrets/regex.ts +21 -0
- package/src/session/agent-session.ts +138 -21
- package/src/session/compaction/branch-summarization.ts +2 -2
- package/src/session/compaction/compaction.ts +10 -3
- package/src/session/compaction/utils.ts +25 -1
- package/src/slash-commands/builtin-registry.ts +419 -0
- package/src/web/scrapers/github.ts +50 -12
- package/src/web/search/index.ts +5 -5
- package/src/web/search/provider.ts +13 -2
- package/src/web/search/providers/brave.ts +165 -0
- package/src/web/search/types.ts +1 -1
- package/docs/compaction.md +0 -436
- package/docs/config-usage.md +0 -176
- package/docs/custom-tools.md +0 -585
- package/docs/environment-variables.md +0 -257
- package/docs/extension-loading.md +0 -106
- package/docs/extensions.md +0 -1342
- package/docs/fs-scan-cache-architecture.md +0 -50
- package/docs/hooks.md +0 -906
- package/docs/models.md +0 -234
- package/docs/python-repl.md +0 -110
- package/docs/rpc.md +0 -1173
- package/docs/sdk.md +0 -1039
- package/docs/session-tree-plan.md +0 -84
- package/docs/session.md +0 -368
- package/docs/skills.md +0 -254
- package/docs/theme.md +0 -696
- package/docs/tree.md +0 -206
- 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
|
-
|
|
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
|
-
*
|
|
2696
|
-
* 1. Overflow:
|
|
2697
|
-
* 2.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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, {
|
|
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
|
// ============================================================================
|