wave-agent-sdk 0.14.3 → 0.15.0

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 (84) hide show
  1. package/builtin/skills/settings/SKILLS.md +34 -6
  2. package/dist/agent.d.ts +0 -5
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +0 -15
  5. package/dist/constants/toolLimits.d.ts +10 -0
  6. package/dist/constants/toolLimits.d.ts.map +1 -0
  7. package/dist/constants/toolLimits.js +9 -0
  8. package/dist/managers/aiManager.d.ts +3 -5
  9. package/dist/managers/aiManager.d.ts.map +1 -1
  10. package/dist/managers/aiManager.js +107 -104
  11. package/dist/managers/forkedAgentManager.d.ts +1 -0
  12. package/dist/managers/forkedAgentManager.d.ts.map +1 -1
  13. package/dist/managers/forkedAgentManager.js +1 -0
  14. package/dist/managers/hookManager.d.ts +0 -4
  15. package/dist/managers/hookManager.d.ts.map +1 -1
  16. package/dist/managers/hookManager.js +0 -25
  17. package/dist/managers/permissionManager.d.ts +1 -1
  18. package/dist/managers/permissionManager.d.ts.map +1 -1
  19. package/dist/managers/permissionManager.js +5 -5
  20. package/dist/managers/subagentManager.d.ts +1 -0
  21. package/dist/managers/subagentManager.d.ts.map +1 -1
  22. package/dist/managers/subagentManager.js +1 -0
  23. package/dist/prompts/index.d.ts +0 -1
  24. package/dist/prompts/index.d.ts.map +1 -1
  25. package/dist/prompts/index.js +3 -4
  26. package/dist/services/aiService.d.ts.map +1 -1
  27. package/dist/services/aiService.js +10 -8
  28. package/dist/services/autoMemoryService.d.ts.map +1 -1
  29. package/dist/services/autoMemoryService.js +1 -0
  30. package/dist/services/hook.d.ts +0 -4
  31. package/dist/services/hook.d.ts.map +1 -1
  32. package/dist/services/hook.js +0 -10
  33. package/dist/services/session.d.ts.map +1 -1
  34. package/dist/services/session.js +4 -1
  35. package/dist/tools/bashTool.d.ts.map +1 -1
  36. package/dist/tools/bashTool.js +2 -45
  37. package/dist/tools/editTool.js +1 -1
  38. package/dist/tools/types.d.ts +0 -3
  39. package/dist/tools/types.d.ts.map +1 -1
  40. package/dist/types/agent.d.ts +0 -1
  41. package/dist/types/agent.d.ts.map +1 -1
  42. package/dist/types/hooks.d.ts +1 -5
  43. package/dist/types/hooks.d.ts.map +1 -1
  44. package/dist/types/hooks.js +0 -1
  45. package/dist/utils/constants.d.ts +2 -2
  46. package/dist/utils/constants.d.ts.map +1 -1
  47. package/dist/utils/constants.js +2 -2
  48. package/dist/utils/convertMessagesForAPI.d.ts.map +1 -1
  49. package/dist/utils/convertMessagesForAPI.js +16 -8
  50. package/dist/utils/editUtils.d.ts +5 -2
  51. package/dist/utils/editUtils.d.ts.map +1 -1
  52. package/dist/utils/editUtils.js +3 -57
  53. package/dist/utils/markdownParser.d.ts +8 -1
  54. package/dist/utils/markdownParser.d.ts.map +1 -1
  55. package/dist/utils/markdownParser.js +64 -11
  56. package/dist/utils/openaiClient.d.ts.map +1 -1
  57. package/dist/utils/openaiClient.js +0 -11
  58. package/dist/utils/stringUtils.d.ts +8 -0
  59. package/dist/utils/stringUtils.d.ts.map +1 -1
  60. package/dist/utils/stringUtils.js +45 -0
  61. package/package.json +1 -1
  62. package/src/agent.ts +0 -17
  63. package/src/constants/toolLimits.ts +12 -0
  64. package/src/managers/aiManager.ts +141 -148
  65. package/src/managers/forkedAgentManager.ts +3 -0
  66. package/src/managers/hookManager.ts +0 -32
  67. package/src/managers/permissionManager.ts +6 -6
  68. package/src/managers/subagentManager.ts +2 -0
  69. package/src/prompts/index.ts +3 -5
  70. package/src/services/aiService.ts +10 -12
  71. package/src/services/autoMemoryService.ts +1 -0
  72. package/src/services/hook.ts +0 -15
  73. package/src/services/session.ts +6 -1
  74. package/src/tools/bashTool.ts +2 -51
  75. package/src/tools/editTool.ts +1 -1
  76. package/src/tools/types.ts +0 -3
  77. package/src/types/agent.ts +0 -1
  78. package/src/types/hooks.ts +1 -7
  79. package/src/utils/constants.ts +2 -2
  80. package/src/utils/convertMessagesForAPI.ts +15 -8
  81. package/src/utils/editUtils.ts +3 -73
  82. package/src/utils/markdownParser.ts +85 -11
  83. package/src/utils/openaiClient.ts +0 -11
  84. package/src/utils/stringUtils.ts +43 -0
@@ -277,21 +277,6 @@ export async function executeCommands(
277
277
  return results;
278
278
  }
279
279
 
280
- /**
281
- * Execute a CwdChanged hook
282
- */
283
- export async function executeCwdChangedHooks(
284
- oldCwd: string,
285
- newCwd: string,
286
- context: ExtendedHookExecutionContext,
287
- ): Promise<HookExecutionResult[]> {
288
- // CwdChanged hooks are executed through HookManager.executeCwdChangedHooks()
289
- void context;
290
- void oldCwd;
291
- void newCwd;
292
- return [];
293
- }
294
-
295
280
  /**
296
281
  * Validate command safety (basic checks)
297
282
  */
@@ -953,7 +953,12 @@ export async function handleSessionRestoration(
953
953
  // Use only JSONL format - no legacy support
954
954
  sessionToRestore = await loadSessionFromJsonl(restoreSessionId, workdir);
955
955
  if (!sessionToRestore) {
956
- throw new Error(`Session not found: ${restoreSessionId}`);
956
+ // Session doesn't exist on disk (e.g. new project with no messages saved yet).
957
+ // Gracefully fall back to starting fresh instead of throwing.
958
+ logger?.warn(
959
+ `Session ${restoreSessionId} not found on disk, starting fresh session`,
960
+ );
961
+ return;
957
962
  }
958
963
  } else if (continueLastSession) {
959
964
  // Use only JSONL format - no legacy support
@@ -240,14 +240,7 @@ Try to maintain your current working directory throughout the session by using a
240
240
 
241
241
  // Foreground execution (original behavior)
242
242
  return new Promise((resolve) => {
243
- // Create a temporary file to store the CWD
244
- const tempCwdFile = path.join(
245
- os.tmpdir(),
246
- `wave_cwd_${Date.now()}_${Math.random().toString(36).substring(2, 11)}.tmp`,
247
- );
248
- const wrappedCommand = `${command} && pwd -P >| ${tempCwdFile}`;
249
-
250
- const child: ChildProcess = spawn(wrappedCommand, {
243
+ const child: ChildProcess = spawn(command, {
251
244
  shell: true,
252
245
  stdio: "pipe",
253
246
  cwd: context.workdir,
@@ -431,55 +424,13 @@ Try to maintain your current working directory throughout the session by using a
431
424
  clearTimeout(timeoutHandle);
432
425
  }
433
426
 
434
- // Read the new CWD from the temporary file
435
- let newCwd: string | undefined;
436
- try {
437
- if (fs.existsSync(tempCwdFile)) {
438
- newCwd = fs.readFileSync(tempCwdFile, "utf8").trim();
439
- // Validate the path exists before calling the callback
440
- fs.accessSync(newCwd, fs.constants.F_OK);
441
- }
442
- } catch (fileError) {
443
- logger.warn(
444
- `Could not read or validate new CWD from temp file ${tempCwdFile}:`,
445
- fileError,
446
- );
447
- newCwd = undefined;
448
- } finally {
449
- // Ensure temp file is cleaned up even if reading fails
450
- try {
451
- if (fs.existsSync(tempCwdFile)) {
452
- fs.unlinkSync(tempCwdFile);
453
- }
454
- } catch (fileError) {
455
- logger.error("Failed to clean up temp CWD file:", fileError);
456
- }
457
- }
458
-
459
- // If CWD changed, call the onCwdChange callback and add notification
460
- let cwdChangedNotification = "";
461
- if (newCwd && newCwd !== context.workdir && context.onCwdChange) {
462
- const isInSafeZone =
463
- context.permissionManager?.isPathInSafeZone?.(newCwd) ?? true;
464
-
465
- if (isInSafeZone) {
466
- context.onCwdChange(newCwd);
467
- } else if (context.originalWorkdir) {
468
- context.onCwdChange(context.originalWorkdir);
469
- cwdChangedNotification = `Shell cwd was reset to ${context.originalWorkdir}\n`;
470
- } else {
471
- context.onCwdChange(newCwd);
472
- }
473
- }
474
-
475
427
  const exitCode = code ?? 0;
476
428
  const combinedOutput =
477
429
  outputBuffer + (errorBuffer ? "\n" + errorBuffer : "");
478
430
 
479
431
  // Handle large output by truncation and persistence if needed
480
432
  const finalOutput =
481
- cwdChangedNotification +
482
- (combinedOutput || `Command executed with exit code: ${exitCode}`);
433
+ combinedOutput || `Command executed with exit code: ${exitCode}`;
483
434
  const content = processOutput(finalOutput);
484
435
 
485
436
  const lines = combinedOutput.trim().split("\n");
@@ -136,7 +136,7 @@ Usage:
136
136
  return {
137
137
  success: false,
138
138
  content: "",
139
- error: analyzeEditMismatch(originalContent, oldString),
139
+ error: analyzeEditMismatch(),
140
140
  };
141
141
  }
142
142
 
@@ -58,7 +58,6 @@ export interface ToolContext {
58
58
  abortSignal?: AbortSignal;
59
59
  backgroundTaskManager?: import("../managers/backgroundTaskManager.js").BackgroundTaskManager;
60
60
  workdir: string;
61
- originalWorkdir?: string;
62
61
  /** Permission mode for this tool execution */
63
62
  permissionMode?: PermissionMode;
64
63
  /** Custom permission callback */
@@ -104,6 +103,4 @@ export interface ToolContext {
104
103
  };
105
104
  /** State of files read in the current session for deduplication */
106
105
  readFileState?: Map<string, { mtime: number; hash: string }>;
107
- /** Callback to notify when the current working directory changes */
108
- onCwdChange?: (newCwd: string) => void;
109
106
  }
@@ -98,6 +98,5 @@ export interface AgentCallbacks
98
98
  onConfiguredModelsChange?: (models: string[]) => void;
99
99
  onLoadingChange?: (loading: boolean) => void;
100
100
  onCommandRunningChange?: (running: boolean) => void;
101
- onWorkdirChange?: (newCwd: string) => void;
102
101
  onQueuedMessagesChange?: (messages: QueuedMessage[]) => void;
103
102
  }
@@ -21,7 +21,6 @@ export type HookEvent =
21
21
  | "SubagentStop"
22
22
  | "PermissionRequest"
23
23
  | "WorktreeCreate"
24
- | "CwdChanged"
25
24
  | "SessionStart"
26
25
  | "SessionEnd";
27
26
 
@@ -110,7 +109,6 @@ export function isValidHookEvent(event: string): event is HookEvent {
110
109
  "SubagentStop",
111
110
  "PermissionRequest",
112
111
  "WorktreeCreate",
113
- "CwdChanged",
114
112
  "SessionStart",
115
113
  "SessionEnd",
116
114
  ].includes(event);
@@ -169,7 +167,7 @@ export interface HookJsonInput {
169
167
  session_id: string; // Format: "wave_session_{uuid}_{shortId}"
170
168
  transcript_path: string; // Format: "~/.wave/sessions/session_{shortId}.json"
171
169
  cwd: string; // Absolute path to current working directory
172
- hook_event_name: HookEvent; // "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop" | "SubagentStop" | "PermissionRequest" | "WorktreeCreate" | "CwdChanged" | "SessionStart"
170
+ hook_event_name: HookEvent; // "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop" | "SubagentStop" | "PermissionRequest" | "WorktreeCreate" | "SessionStart"
173
171
 
174
172
  // Optional fields based on event type
175
173
  tool_name?: string; // Present for PreToolUse, PostToolUse, PermissionRequest
@@ -178,8 +176,6 @@ export interface HookJsonInput {
178
176
  user_prompt?: string; // Present for UserPromptSubmit only
179
177
  subagent_type?: string; // Present when hook is executed by a subagent
180
178
  name?: string; // Present for WorktreeCreate events
181
- old_cwd?: string; // Present for CwdChanged events
182
- new_cwd?: string; // Present for CwdChanged events
183
179
  source?: SessionStartSource; // Present for SessionStart events
184
180
  agent_type?: string; // Present for SessionStart events
185
181
  end_source?: SessionEndSource; // Present for SessionEnd events
@@ -196,8 +192,6 @@ export interface ExtendedHookExecutionContext extends HookExecutionContext {
196
192
  userPrompt?: string; // User prompt text (UserPromptSubmit only)
197
193
  subagentType?: string; // Subagent type when hook is executed by a subagent
198
194
  worktreeName?: string; // Worktree name (WorktreeCreate only)
199
- oldCwd?: string; // Previous working directory (CwdChanged only)
200
- newCwd?: string; // New working directory (CwdChanged only)
201
195
  source?: SessionStartSource; // Session start source (SessionStart only)
202
196
  agentType?: string; // Agent type identifier (SessionStart only)
203
197
  endSource?: SessionEndSource; // Session end source (SessionEnd only)
@@ -29,5 +29,5 @@ export const USER_MEMORY_FILE = path.join(DATA_DIRECTORY, "AGENTS.md");
29
29
  /**
30
30
  * AI related constants
31
31
  */
32
- export const DEFAULT_WAVE_MAX_INPUT_TOKENS = 96000; // Default token limit
33
- export const DEFAULT_WAVE_MAX_OUTPUT_TOKENS = 8192; // Default output token limit
32
+ export const DEFAULT_WAVE_MAX_INPUT_TOKENS = 128000; // Default token limit
33
+ export const DEFAULT_WAVE_MAX_OUTPUT_TOKENS = 16384; // Default output token limit
@@ -2,7 +2,7 @@ import type { Message } from "../types/index.js";
2
2
  import { convertImageToBase64 } from "./messageOperations.js";
3
3
  import { taskNotificationToXml } from "./notificationXml.js";
4
4
  import { ChatCompletionMessageToolCall } from "openai/resources";
5
- import { stripAnsiColors } from "./stringUtils.js";
5
+ import { recoverTruncatedJson, stripAnsiColors } from "./stringUtils.js";
6
6
  import {
7
7
  ChatCompletionContentPart,
8
8
  ChatCompletionMessageParam,
@@ -10,7 +10,8 @@ import {
10
10
  import { logger } from "./globalLogger.js";
11
11
 
12
12
  /**
13
- * Safely handle tool call parameters, ensuring a legal JSON string is returned
13
+ * Safely handle tool call parameters, ensuring a legal JSON string is returned.
14
+ * Attempts to recover truncated JSON (e.g., missing closing braces).
14
15
  * @param args Tool call parameters
15
16
  * @returns Legal JSON string
16
17
  */
@@ -23,12 +24,18 @@ function safeToolArguments(args: string): string {
23
24
  // Try to parse as JSON to validate format
24
25
  JSON.parse(args);
25
26
  return args;
26
- } catch (error) {
27
- logger.error(`Invalid tool arguments: ${args}`, error);
28
- // If not valid JSON, return a fallback empty object with the original string as a comment or property
29
- return JSON.stringify({
30
- invalid_arguments: args,
31
- });
27
+ } catch {
28
+ // Attempt to recover truncated JSON
29
+ const recovered = recoverTruncatedJson(args);
30
+ try {
31
+ JSON.parse(recovered);
32
+ return recovered;
33
+ } catch {
34
+ // Truly malformed JSON — return sanitized fallback
35
+ return JSON.stringify({
36
+ invalid_arguments: args,
37
+ });
38
+ }
32
39
  }
33
40
  }
34
41
 
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Utility functions for file editing tools
3
3
  */
4
- import { formatLineNumberPrefix } from "./stringUtils.js";
5
4
 
6
5
  /**
7
6
  * Escape regular expression special characters
@@ -11,77 +10,8 @@ export function escapeRegExp(string: string): string {
11
10
  }
12
11
 
13
12
  /**
14
- * Analyze why an edit failed by finding the best partial match and highlighting mismatches.
13
+ * Returns a generic error message when old_string is not found.
15
14
  */
16
- export function analyzeEditMismatch(
17
- content: string,
18
- searchString: string,
19
- ): string {
20
- const contentLines = content.split("\n");
21
- const searchLines = searchString.split("\n");
22
-
23
- if (searchLines.length === 0 || contentLines.length === 0) {
24
- return "old_string not found in file (empty search or content)";
25
- }
26
-
27
- let bestMatchIndex = -1;
28
- let bestMatchScore = -1;
29
-
30
- // Sliding window to find the best partial match
31
- for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
32
- let currentScore = 0;
33
- for (let j = 0; j < searchLines.length; j++) {
34
- if (contentLines[i + j] === searchLines[j]) {
35
- currentScore++;
36
- }
37
- }
38
-
39
- // Heuristic: prioritize matches where first or last lines match
40
- if (contentLines[i] === searchLines[0]) currentScore += 0.5;
41
- if (
42
- contentLines[i + searchLines.length - 1] ===
43
- searchLines[searchLines.length - 1]
44
- )
45
- currentScore += 0.5;
46
-
47
- // Also consider trimmed matches to catch indentation issues
48
- for (let j = 0; j < searchLines.length; j++) {
49
- if (
50
- contentLines[i + j].trim() === searchLines[j].trim() &&
51
- contentLines[i + j] !== searchLines[j]
52
- ) {
53
- currentScore += 0.1;
54
- }
55
- }
56
-
57
- if (currentScore > bestMatchScore) {
58
- bestMatchScore = currentScore;
59
- bestMatchIndex = i;
60
- }
61
- }
62
-
63
- // If no decent match found (score <= 0), return generic message
64
- if (bestMatchScore <= 0) {
65
- return "old_string not found in file (no similar block found)";
66
- }
67
-
68
- // Generate detailed report
69
- const reportLines: string[] = [
70
- `old_string not found in file. Best partial match found at line ${bestMatchIndex + 1}:`,
71
- ];
72
-
73
- for (let j = 0; j < searchLines.length; j++) {
74
- const lineNum = bestMatchIndex + j + 1;
75
- const actualLine = contentLines[bestMatchIndex + j];
76
- const expectedLine = searchLines[j];
77
-
78
- if (actualLine === expectedLine) {
79
- reportLines.push(`${formatLineNumberPrefix(lineNum)}${actualLine}`);
80
- } else {
81
- reportLines.push(`${formatLineNumberPrefix(lineNum)}- ${expectedLine}`);
82
- reportLines.push(`${formatLineNumberPrefix(lineNum)}+ ${actualLine}`);
83
- }
84
- }
85
-
86
- return reportLines.join("\n");
15
+ export function analyzeEditMismatch(): string {
16
+ return "old_string not found in file";
87
17
  }
@@ -1,7 +1,13 @@
1
- import { readFileSync } from "fs";
1
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
2
2
  import { exec } from "child_process";
3
3
  import { promisify } from "util";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
4
6
  import type { CustomSlashCommandConfig } from "../types/index.js";
7
+ import {
8
+ SKILL_BASH_MAX_OUTPUT_CHARS,
9
+ PREVIEW_SIZE_BYTES,
10
+ } from "../constants/toolLimits.js";
5
11
 
6
12
  const execAsync = promisify(exec);
7
13
 
@@ -138,17 +144,52 @@ export interface BashCommandResult {
138
144
  exitCode: number;
139
145
  }
140
146
 
147
+ /**
148
+ * Block syntax pattern: ```! command ```
149
+ */
150
+ const BLOCK_BASH_REGEX = /```!\s*\n?([\s\S]*?)\n?```/g;
151
+
152
+ /**
153
+ * Inline syntax pattern: !`command`
154
+ */
155
+ const INLINE_BASH_REGEX = /!`([^`]+)`/g;
156
+
141
157
  export function parseBashCommands(content: string): {
142
158
  commands: string[];
143
159
  processedContent: string;
144
160
  } {
145
- const bashCommandRegex = /!`([^`]+)`/g;
161
+ // Performance gate: skip expensive regex if no bash pattern exists
162
+ // Covers the common case where 93% of skills have no bash substitution
163
+ if (!content.includes("!`") && !content.includes("```!")) {
164
+ return { commands: [], processedContent: content };
165
+ }
166
+
146
167
  const commands: string[] = [];
147
- let match;
148
168
 
149
- // Extract all bash commands
150
- while ((match = bashCommandRegex.exec(content)) !== null) {
151
- commands.push(match[1]);
169
+ // Extract block commands
170
+ let blockMatch;
171
+ const blockRegex = new RegExp(
172
+ BLOCK_BASH_REGEX.source,
173
+ BLOCK_BASH_REGEX.flags,
174
+ );
175
+ while ((blockMatch = blockRegex.exec(content)) !== null) {
176
+ const cmd = blockMatch[1].trim();
177
+ if (cmd) {
178
+ commands.push(cmd);
179
+ }
180
+ }
181
+
182
+ // Extract inline commands
183
+ let inlineMatch;
184
+ const inlineRegex = new RegExp(
185
+ INLINE_BASH_REGEX.source,
186
+ INLINE_BASH_REGEX.flags,
187
+ );
188
+ while ((inlineMatch = inlineRegex.exec(content)) !== null) {
189
+ const cmd = inlineMatch[1].trim();
190
+ if (cmd) {
191
+ commands.push(cmd);
192
+ }
152
193
  }
153
194
 
154
195
  // For now, return the content as-is. The actual command execution
@@ -160,22 +201,55 @@ export function parseBashCommands(content: string): {
160
201
  }
161
202
 
162
203
  /**
163
- * Replace bash command placeholders with their outputs
204
+ * Truncate output if it exceeds the size limit.
205
+ * Writes to a temp file and returns a preview + file path if truncated.
206
+ */
207
+ export function truncateOutput(output: string): string {
208
+ if (output.length <= SKILL_BASH_MAX_OUTPUT_CHARS) {
209
+ return output;
210
+ }
211
+
212
+ const preview = output.slice(0, PREVIEW_SIZE_BYTES);
213
+ const tempDir = join(tmpdir(), "wave-skill-bash");
214
+ mkdirSync(tempDir, { recursive: true });
215
+
216
+ const tempFile = join(
217
+ tempDir,
218
+ `output-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`,
219
+ );
220
+ writeFileSync(tempFile, output, "utf-8");
221
+
222
+ return `${preview}\n\n[Output truncated (${output.length} chars). Full output saved to: ${tempFile}]`;
223
+ }
224
+
225
+ /**
226
+ * Replace bash command placeholders with their outputs.
227
+ * Uses function replacer to avoid $$, $&, $' corruption in shell output.
228
+ * Handles both inline (!`cmd`) and block (```! cmd ```) syntax.
164
229
  */
165
230
  export function replaceBashCommandsWithOutput(
166
231
  content: string,
167
232
  results: BashCommandResult[],
168
233
  ): string {
169
- const bashCommandRegex = /!`([^`]+)`/g;
170
234
  let processedContent = content;
171
235
  let commandIndex = 0;
172
236
 
173
- processedContent = processedContent.replace(bashCommandRegex, (match) => {
237
+ // Replace block syntax first: ```! command ```
238
+ processedContent = processedContent.replace(BLOCK_BASH_REGEX, () => {
239
+ if (commandIndex < results.length) {
240
+ const result = results[commandIndex++];
241
+ return truncateOutput(result.output);
242
+ }
243
+ return "";
244
+ });
245
+
246
+ // Replace inline syntax: !`command`
247
+ processedContent = processedContent.replace(INLINE_BASH_REGEX, () => {
174
248
  if (commandIndex < results.length) {
175
249
  const result = results[commandIndex++];
176
- return result.output;
250
+ return truncateOutput(result.output);
177
251
  }
178
- return match;
252
+ return "";
179
253
  });
180
254
 
181
255
  return processedContent;
@@ -177,28 +177,17 @@ export class OpenAIClient {
177
177
  error.body = errorBody;
178
178
 
179
179
  if (response.status === 429 && attempt < maxRetries) {
180
- const responseHeaders: Record<string, string> = {};
181
- response.headers.forEach((value, key) => {
182
- responseHeaders[key] = value;
183
- });
184
180
  logger.warn("OpenAI API 429 Too Many Requests, retrying...", {
185
181
  attempt: attempt + 1,
186
182
  status: response.status,
187
- responseHeaders,
188
183
  });
189
184
  lastError = error;
190
185
  continue;
191
186
  }
192
187
 
193
- const responseHeaders: Record<string, string> = {};
194
- response.headers.forEach((value, key) => {
195
- responseHeaders[key] = value;
196
- });
197
188
  logger.error("OpenAI API Error:", {
198
189
  status: response.status,
199
190
  statusText: response.statusText,
200
- requestHeaders: headers,
201
- responseHeaders,
202
191
  errorBody,
203
192
  });
204
193
  throw error;
@@ -92,6 +92,49 @@ export function formatLineNumberPrefix(lineNumber: number): string {
92
92
  return `${lineNumber.toString().padStart(6)}\t`;
93
93
  }
94
94
 
95
+ /**
96
+ * Attempt to recover truncated JSON (e.g., missing closing braces due to max tokens).
97
+ * Tracks brace depth and only recovers if there are unclosed `{` braces.
98
+ * Will NOT recover if there are unclosed `[` brackets (can't guess the content).
99
+ * @param jsonStr Potentially truncated JSON string
100
+ * @returns Recovered JSON string, or the original if unrecoverable
101
+ */
102
+ export function recoverTruncatedJson(jsonStr: string): string {
103
+ let braceDepth = 0;
104
+ let bracketDepth = 0;
105
+ let inString = false;
106
+ let escaped = false;
107
+
108
+ for (const ch of jsonStr) {
109
+ if (escaped) {
110
+ escaped = false;
111
+ continue;
112
+ }
113
+ if (ch === "\\" && inString) {
114
+ escaped = true;
115
+ continue;
116
+ }
117
+ if (ch === '"') {
118
+ inString = !inString;
119
+ continue;
120
+ }
121
+ if (!inString) {
122
+ if (ch === "{") braceDepth++;
123
+ if (ch === "}") braceDepth--;
124
+ if (ch === "[") bracketDepth++;
125
+ if (ch === "]") bracketDepth--;
126
+ }
127
+ }
128
+
129
+ // Build recovery suffix
130
+ let suffix = "";
131
+ if (inString) suffix += '"'; // Close unclosed string
132
+ if (braceDepth > 0 && bracketDepth === 0) {
133
+ suffix += "}".repeat(braceDepth);
134
+ }
135
+ return suffix ? jsonStr + suffix : jsonStr;
136
+ }
137
+
95
138
  /**
96
139
  * Efficiently get the last N lines of a string without splitting the whole string.
97
140
  */