wave-agent-sdk 0.14.4 → 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 (62) hide show
  1. package/builtin/skills/settings/SKILLS.md +31 -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 +0 -5
  9. package/dist/managers/aiManager.d.ts.map +1 -1
  10. package/dist/managers/aiManager.js +0 -22
  11. package/dist/managers/hookManager.d.ts +0 -4
  12. package/dist/managers/hookManager.d.ts.map +1 -1
  13. package/dist/managers/hookManager.js +0 -25
  14. package/dist/managers/permissionManager.d.ts +1 -1
  15. package/dist/managers/permissionManager.d.ts.map +1 -1
  16. package/dist/managers/permissionManager.js +5 -5
  17. package/dist/prompts/index.d.ts +0 -1
  18. package/dist/prompts/index.d.ts.map +1 -1
  19. package/dist/prompts/index.js +3 -4
  20. package/dist/services/aiService.d.ts.map +1 -1
  21. package/dist/services/aiService.js +10 -8
  22. package/dist/services/hook.d.ts +0 -4
  23. package/dist/services/hook.d.ts.map +1 -1
  24. package/dist/services/hook.js +0 -10
  25. package/dist/services/session.d.ts.map +1 -1
  26. package/dist/services/session.js +4 -1
  27. package/dist/tools/bashTool.d.ts.map +1 -1
  28. package/dist/tools/bashTool.js +2 -45
  29. package/dist/tools/editTool.js +1 -1
  30. package/dist/tools/types.d.ts +0 -3
  31. package/dist/tools/types.d.ts.map +1 -1
  32. package/dist/types/agent.d.ts +0 -1
  33. package/dist/types/agent.d.ts.map +1 -1
  34. package/dist/types/hooks.d.ts +1 -5
  35. package/dist/types/hooks.d.ts.map +1 -1
  36. package/dist/types/hooks.js +0 -1
  37. package/dist/utils/editUtils.d.ts +5 -2
  38. package/dist/utils/editUtils.d.ts.map +1 -1
  39. package/dist/utils/editUtils.js +3 -57
  40. package/dist/utils/markdownParser.d.ts +8 -1
  41. package/dist/utils/markdownParser.d.ts.map +1 -1
  42. package/dist/utils/markdownParser.js +64 -11
  43. package/dist/utils/openaiClient.d.ts.map +1 -1
  44. package/dist/utils/openaiClient.js +0 -11
  45. package/package.json +1 -1
  46. package/src/agent.ts +0 -17
  47. package/src/constants/toolLimits.ts +12 -0
  48. package/src/managers/aiManager.ts +0 -38
  49. package/src/managers/hookManager.ts +0 -32
  50. package/src/managers/permissionManager.ts +6 -6
  51. package/src/prompts/index.ts +3 -5
  52. package/src/services/aiService.ts +10 -12
  53. package/src/services/hook.ts +0 -15
  54. package/src/services/session.ts +6 -1
  55. package/src/tools/bashTool.ts +2 -51
  56. package/src/tools/editTool.ts +1 -1
  57. package/src/tools/types.ts +0 -3
  58. package/src/types/agent.ts +0 -1
  59. package/src/types/hooks.ts +1 -7
  60. package/src/utils/editUtils.ts +3 -73
  61. package/src/utils/markdownParser.ts +85 -11
  62. package/src/utils/openaiClient.ts +0 -11
@@ -669,7 +669,6 @@ export class HookManager {
669
669
  event === "Stop" ||
670
670
  event === "SubagentStop" ||
671
671
  event === "WorktreeCreate" ||
672
- event === "CwdChanged" ||
673
672
  event === "SessionStart" ||
674
673
  event === "SessionEnd"
675
674
  ) {
@@ -773,7 +772,6 @@ export class HookManager {
773
772
  SubagentStop: 0,
774
773
  PermissionRequest: 0,
775
774
  WorktreeCreate: 0,
776
- CwdChanged: 0,
777
775
  SessionStart: 0,
778
776
  SessionEnd: 0,
779
777
  },
@@ -788,7 +786,6 @@ export class HookManager {
788
786
  SubagentStop: 0,
789
787
  PermissionRequest: 0,
790
788
  WorktreeCreate: 0,
791
- CwdChanged: 0,
792
789
  SessionStart: 0,
793
790
  SessionEnd: 0,
794
791
  };
@@ -815,35 +812,6 @@ export class HookManager {
815
812
  };
816
813
  }
817
814
 
818
- /**
819
- * Execute CwdChanged hooks.
820
- */
821
- async executeCwdChangedHooks(
822
- oldCwd: string,
823
- newCwd: string,
824
- sessionId: string,
825
- transcriptPath: string,
826
- env: Record<string, string>,
827
- ): Promise<HookExecutionResult[]> {
828
- const context: ExtendedHookExecutionContext = {
829
- event: "CwdChanged",
830
- projectDir: this.workdir,
831
- timestamp: new Date(),
832
- sessionId,
833
- transcriptPath,
834
- cwd: newCwd,
835
- oldCwd,
836
- newCwd,
837
- env,
838
- };
839
- const results = await this.executeHooks("CwdChanged", context);
840
- if (results.length > 0) {
841
- // For CwdChanged hooks, we don't block, just log errors
842
- this.processHookResults("CwdChanged", results);
843
- }
844
- return results;
845
- }
846
-
847
815
  /**
848
816
  * Register hooks provided by a plugin
849
817
  */
@@ -131,7 +131,7 @@ export class PermissionManager {
131
131
  private planFilePath?: string;
132
132
  private worktreeName?: string;
133
133
  private mainRepoRoot?: string;
134
- private originalWorkdir?: string;
134
+ private workdir?: string;
135
135
  private onConfiguredPermissionModeChange?: (mode: PermissionMode) => void;
136
136
  private _logger?: Logger;
137
137
 
@@ -153,7 +153,7 @@ export class PermissionManager {
153
153
 
154
154
  this.worktreeName = this.container.get<string>("WorktreeName");
155
155
  this.mainRepoRoot = this.container.get<string>("MainRepoRoot");
156
- this.originalWorkdir = this.container.get<string>("Workdir");
156
+ this.workdir = this.container.get<string>("Workdir");
157
157
  }
158
158
 
159
159
  /**
@@ -277,7 +277,7 @@ export class PermissionManager {
277
277
  * Update the additional directories (e.g., when configuration reloads)
278
278
  */
279
279
  updateAdditionalDirectories(directories: string[]): void {
280
- const workdir = this.originalWorkdir;
280
+ const workdir = this.workdir;
281
281
  this.additionalDirectories = directories.map((dir) => {
282
282
  if (workdir && !path.isAbsolute(dir)) {
283
283
  return path.resolve(workdir, dir);
@@ -290,7 +290,7 @@ export class PermissionManager {
290
290
  * Add a system-level additional directory that is persistent across configuration reloads
291
291
  */
292
292
  public addSystemAdditionalDirectory(directory: string): void {
293
- const workdir = this.originalWorkdir;
293
+ const workdir = this.workdir;
294
294
  const resolvedPath =
295
295
  workdir && !path.isAbsolute(directory)
296
296
  ? path.resolve(workdir, directory)
@@ -329,7 +329,7 @@ export class PermissionManager {
329
329
  targetPath: string,
330
330
  workdir?: string,
331
331
  ): { isInside: boolean; resolvedPath: string } {
332
- const effectiveWorkdir = this.originalWorkdir || workdir;
332
+ const effectiveWorkdir = this.workdir || workdir;
333
333
 
334
334
  // Resolve the target path relative to effectiveWorkdir if it's not absolute
335
335
  const absolutePath =
@@ -1068,7 +1068,7 @@ export class PermissionManager {
1068
1068
  * @param rule - The rule to add (e.g., "Bash(ls)")
1069
1069
  */
1070
1070
  public async addPermissionRule(rule: string): Promise<void> {
1071
- const workdir = this.originalWorkdir;
1071
+ const workdir = this.workdir;
1072
1072
  if (!workdir) {
1073
1073
  throw new Error("Working directory not set in PermissionManager");
1074
1074
  }
@@ -238,7 +238,6 @@ export function buildSystemPrompt(
238
238
  tools: ToolPlugin[],
239
239
  options: {
240
240
  workdir?: string;
241
- originalWorkdir?: string;
242
241
  memory?: string;
243
242
  language?: string;
244
243
  isSubagent?: boolean;
@@ -276,9 +275,8 @@ export function buildSystemPrompt(
276
275
  prompt += `\n\n${buildPlanModePrompt(options.planMode.planFilePath, options.planMode.planExists, options.isSubagent)}`;
277
276
  }
278
277
 
279
- const workdirForPrompt = options.originalWorkdir || options.workdir;
280
- if (workdirForPrompt) {
281
- const isGitRepo = isGitRepository(workdirForPrompt);
278
+ if (options.workdir) {
279
+ const isGitRepo = isGitRepository(options.workdir);
282
280
  const platform = os.platform();
283
281
  const osVersion = `${os.type()} ${os.release()}`;
284
282
  const today = new Date().toISOString().split("T")[0];
@@ -293,7 +291,7 @@ export function buildSystemPrompt(
293
291
 
294
292
  Here is useful information about the environment you are running in:
295
293
  <env>
296
- Working directory: ${workdirForPrompt}
294
+ Working directory: ${options.workdir}
297
295
  Is directory a git repo: ${isGitRepo}
298
296
  Platform: ${platform}
299
297
  Shell: ${shellName}
@@ -377,10 +377,7 @@ export async function callAgent(
377
377
  result.content = finalContent;
378
378
  }
379
379
 
380
- if (
381
- typeof finalReasoningContent === "string" &&
382
- finalReasoningContent.length > 0
383
- ) {
380
+ if (typeof finalReasoningContent === "string") {
384
381
  result.reasoning_content = finalReasoningContent;
385
382
  }
386
383
 
@@ -544,6 +541,7 @@ async function processStreamingResponse(
544
541
  ): Promise<CallAgentResult> {
545
542
  let accumulatedContent = "";
546
543
  let accumulatedReasoningContent = "";
544
+ let hasReasoningContent = false;
547
545
  const toolCalls: {
548
546
  id: string;
549
547
  type: "function";
@@ -618,13 +616,13 @@ async function processStreamingResponse(
618
616
  }
619
617
  }
620
618
 
621
- if (
622
- typeof reasoning_content === "string" &&
623
- reasoning_content.length > 0
624
- ) {
625
- accumulatedReasoningContent += reasoning_content;
626
- if (onReasoningUpdate) {
627
- onReasoningUpdate(accumulatedReasoningContent);
619
+ if (typeof reasoning_content === "string") {
620
+ hasReasoningContent = true;
621
+ if (reasoning_content.length > 0) {
622
+ accumulatedReasoningContent += reasoning_content;
623
+ if (onReasoningUpdate) {
624
+ onReasoningUpdate(accumulatedReasoningContent);
625
+ }
628
626
  }
629
627
  }
630
628
 
@@ -716,7 +714,7 @@ async function processStreamingResponse(
716
714
  result.content = accumulatedContent.trim();
717
715
  }
718
716
 
719
- if (accumulatedReasoningContent) {
717
+ if (hasReasoningContent) {
720
718
  result.reasoning_content = accumulatedReasoningContent.trim();
721
719
  }
722
720
 
@@ -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)
@@ -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;