wave-agent-sdk 0.15.1 → 0.15.2

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 (111) hide show
  1. package/builtin/skills/loop/SKILL.md +29 -3
  2. package/dist/agent.d.ts +7 -2
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +34 -11
  5. package/dist/constants/tools.d.ts +3 -0
  6. package/dist/constants/tools.d.ts.map +1 -1
  7. package/dist/constants/tools.js +3 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -0
  11. package/dist/managers/aiManager.d.ts +13 -1
  12. package/dist/managers/aiManager.d.ts.map +1 -1
  13. package/dist/managers/aiManager.js +69 -17
  14. package/dist/managers/hookManager.d.ts.map +1 -1
  15. package/dist/managers/hookManager.js +9 -0
  16. package/dist/managers/mcpManager.d.ts +4 -1
  17. package/dist/managers/mcpManager.d.ts.map +1 -1
  18. package/dist/managers/mcpManager.js +25 -5
  19. package/dist/managers/permissionManager.d.ts +0 -2
  20. package/dist/managers/permissionManager.d.ts.map +1 -1
  21. package/dist/managers/permissionManager.js +0 -30
  22. package/dist/managers/slashCommandManager.d.ts +1 -0
  23. package/dist/managers/slashCommandManager.d.ts.map +1 -1
  24. package/dist/managers/slashCommandManager.js +4 -0
  25. package/dist/managers/toolManager.d.ts +6 -0
  26. package/dist/managers/toolManager.d.ts.map +1 -1
  27. package/dist/managers/toolManager.js +41 -1
  28. package/dist/prompts/index.d.ts.map +1 -1
  29. package/dist/prompts/index.js +14 -4
  30. package/dist/services/initializationService.d.ts +0 -2
  31. package/dist/services/initializationService.d.ts.map +1 -1
  32. package/dist/services/initializationService.js +3 -35
  33. package/dist/services/memory.d.ts +6 -0
  34. package/dist/services/memory.d.ts.map +1 -1
  35. package/dist/services/memory.js +27 -14
  36. package/dist/tools/cronCreateTool.d.ts.map +1 -1
  37. package/dist/tools/cronCreateTool.js +71 -6
  38. package/dist/tools/cronDeleteTool.d.ts.map +1 -1
  39. package/dist/tools/cronDeleteTool.js +5 -1
  40. package/dist/tools/cronListTool.d.ts.map +1 -1
  41. package/dist/tools/cronListTool.js +5 -1
  42. package/dist/tools/enterWorktreeTool.d.ts +8 -0
  43. package/dist/tools/enterWorktreeTool.d.ts.map +1 -0
  44. package/dist/tools/enterWorktreeTool.js +144 -0
  45. package/dist/tools/exitWorktreeTool.d.ts +8 -0
  46. package/dist/tools/exitWorktreeTool.d.ts.map +1 -0
  47. package/dist/tools/exitWorktreeTool.js +184 -0
  48. package/dist/tools/taskManagementTools.d.ts.map +1 -1
  49. package/dist/tools/taskManagementTools.js +4 -0
  50. package/dist/tools/toolSearchTool.d.ts +15 -0
  51. package/dist/tools/toolSearchTool.d.ts.map +1 -0
  52. package/dist/tools/toolSearchTool.js +185 -0
  53. package/dist/tools/types.d.ts +19 -0
  54. package/dist/tools/types.d.ts.map +1 -1
  55. package/dist/tools/webFetchTool.d.ts.map +1 -1
  56. package/dist/tools/webFetchTool.js +1 -0
  57. package/dist/types/agent.d.ts +6 -1
  58. package/dist/types/agent.d.ts.map +1 -1
  59. package/dist/types/hooks.d.ts +3 -1
  60. package/dist/types/hooks.d.ts.map +1 -1
  61. package/dist/types/hooks.js +1 -0
  62. package/dist/utils/containerSetup.d.ts.map +1 -1
  63. package/dist/utils/containerSetup.js +4 -6
  64. package/dist/utils/cronToHuman.d.ts +6 -0
  65. package/dist/utils/cronToHuman.d.ts.map +1 -0
  66. package/dist/utils/cronToHuman.js +79 -0
  67. package/dist/utils/isDeferredTool.d.ts +19 -0
  68. package/dist/utils/isDeferredTool.d.ts.map +1 -0
  69. package/dist/utils/isDeferredTool.js +31 -0
  70. package/dist/utils/mcpUtils.d.ts.map +1 -1
  71. package/dist/utils/mcpUtils.js +1 -0
  72. package/dist/utils/parseCronExpression.d.ts +6 -0
  73. package/dist/utils/parseCronExpression.d.ts.map +1 -0
  74. package/dist/utils/parseCronExpression.js +74 -0
  75. package/dist/utils/worktreeSession.d.ts +26 -0
  76. package/dist/utils/worktreeSession.d.ts.map +1 -0
  77. package/dist/utils/worktreeSession.js +14 -0
  78. package/dist/utils/worktreeUtils.d.ts +42 -0
  79. package/dist/utils/worktreeUtils.d.ts.map +1 -0
  80. package/dist/utils/worktreeUtils.js +236 -0
  81. package/package.json +1 -1
  82. package/src/agent.ts +49 -12
  83. package/src/constants/tools.ts +3 -0
  84. package/src/index.ts +1 -0
  85. package/src/managers/aiManager.ts +73 -18
  86. package/src/managers/hookManager.ts +10 -0
  87. package/src/managers/mcpManager.ts +32 -6
  88. package/src/managers/permissionManager.ts +0 -42
  89. package/src/managers/slashCommandManager.ts +6 -0
  90. package/src/managers/toolManager.ts +47 -1
  91. package/src/prompts/index.ts +17 -3
  92. package/src/services/initializationService.ts +2 -41
  93. package/src/services/memory.ts +30 -17
  94. package/src/tools/cronCreateTool.ts +81 -8
  95. package/src/tools/cronDeleteTool.ts +7 -2
  96. package/src/tools/cronListTool.ts +7 -2
  97. package/src/tools/enterWorktreeTool.ts +183 -0
  98. package/src/tools/exitWorktreeTool.ts +242 -0
  99. package/src/tools/taskManagementTools.ts +4 -0
  100. package/src/tools/toolSearchTool.ts +228 -0
  101. package/src/tools/types.ts +19 -0
  102. package/src/tools/webFetchTool.ts +1 -0
  103. package/src/types/agent.ts +6 -0
  104. package/src/types/hooks.ts +4 -0
  105. package/src/utils/containerSetup.ts +7 -8
  106. package/src/utils/cronToHuman.ts +99 -0
  107. package/src/utils/isDeferredTool.ts +36 -0
  108. package/src/utils/mcpUtils.ts +1 -0
  109. package/src/utils/parseCronExpression.ts +78 -0
  110. package/src/utils/worktreeSession.ts +36 -0
  111. package/src/utils/worktreeUtils.ts +288 -0
@@ -24,7 +24,7 @@ interface McpConnection {
24
24
  }
25
25
 
26
26
  export interface McpManagerCallbacks {
27
- onServersChange?: (servers: McpServerStatus[]) => void;
27
+ onMcpServersChange?: (servers: McpServerStatus[]) => void;
28
28
  }
29
29
 
30
30
  import { logger } from "../utils/globalLogger.js";
@@ -32,6 +32,8 @@ import { logger } from "../utils/globalLogger.js";
32
32
  export interface McpManagerOptions {
33
33
  callbacks?: McpManagerCallbacks;
34
34
  logger?: Logger;
35
+ /** Pre-configured MCP servers passed from constructor options */
36
+ mcpServers?: Record<string, McpServerConfig>;
35
37
  }
36
38
 
37
39
  /**
@@ -96,12 +98,14 @@ export class McpManager {
96
98
  private configPath: string = "";
97
99
  private workdir: string = "";
98
100
  private callbacks: McpManagerCallbacks;
101
+ private mcpServers: Record<string, McpServerConfig> | undefined;
99
102
 
100
103
  constructor(
101
104
  private container: Container,
102
105
  options: McpManagerOptions = {},
103
106
  ) {
104
107
  this.callbacks = options.callbacks || {};
108
+ this.mcpServers = options.mcpServers;
105
109
  }
106
110
 
107
111
  /**
@@ -114,11 +118,20 @@ export class McpManager {
114
118
  this.configPath = join(workdir, ".mcp.json");
115
119
  this.workdir = workdir;
116
120
 
121
+ // Register constructor-provided servers before loading .mcp.json
122
+ if (this.mcpServers) {
123
+ for (const [name, config] of Object.entries(this.mcpServers)) {
124
+ this.addServer(name, config);
125
+ }
126
+ }
127
+
117
128
  if (autoConnect) {
118
129
  logger?.debug("Initializing MCP servers...");
119
130
 
120
- // Ensure MCP configuration is loaded
121
- const config = await this.ensureConfigLoaded();
131
+ // Load workspace MCP configuration (always read, merge with any plugin servers already added)
132
+ await this.loadConfig();
133
+
134
+ const config = this.config;
122
135
 
123
136
  if (config && config.mcpServers) {
124
137
  // Connect to all configured servers in background to avoid blocking agent initialization
@@ -145,7 +158,7 @@ export class McpManager {
145
158
 
146
159
  logger?.debug("MCP servers initialization started in background");
147
160
  // Trigger state change callback after starting initialization
148
- this.callbacks.onServersChange?.(this.getAllServers());
161
+ this.callbacks.onMcpServersChange?.(this.getAllServers());
149
162
  }
150
163
  }
151
164
 
@@ -164,7 +177,20 @@ export class McpManager {
164
177
 
165
178
  try {
166
179
  const configContent = await fs.readFile(this.configPath, "utf-8");
167
- this.config = resolveMcpConfig(JSON.parse(configContent));
180
+ const workspaceConfig = resolveMcpConfig(JSON.parse(configContent));
181
+
182
+ // Merge workspace config with any existing config (e.g., from plugins or constructor)
183
+ // Constructor-provided servers take precedence, then workspace config, then existing config
184
+ const merged: McpConfig = { mcpServers: {} };
185
+ if (this.config) {
186
+ Object.assign(merged.mcpServers, this.config.mcpServers);
187
+ }
188
+ Object.assign(merged.mcpServers, workspaceConfig.mcpServers);
189
+ // Constructor-provided servers override both for same names
190
+ if (this.mcpServers) {
191
+ Object.assign(merged.mcpServers, this.mcpServers);
192
+ }
193
+ this.config = merged;
168
194
 
169
195
  // Initialize server statuses (preserve existing status for already known servers)
170
196
  if (this.config) {
@@ -226,7 +252,7 @@ export class McpManager {
226
252
  if (server) {
227
253
  this.servers.set(name, { ...server, ...updates });
228
254
  // Trigger state change callback
229
- this.callbacks.onServersChange?.(this.getAllServers());
255
+ this.callbacks.onMcpServersChange?.(this.getAllServers());
230
256
  }
231
257
  }
232
258
 
@@ -129,8 +129,6 @@ export class PermissionManager {
129
129
  private additionalDirectories: string[] = [];
130
130
  private systemAdditionalDirectories: string[] = [];
131
131
  private planFilePath?: string;
132
- private worktreeName?: string;
133
- private mainRepoRoot?: string;
134
132
  private workdir?: string;
135
133
  private onConfiguredPermissionModeChange?: (mode: PermissionMode) => void;
136
134
  private _logger?: Logger;
@@ -151,8 +149,6 @@ export class PermissionManager {
151
149
  this.addSystemAdditionalDirectory(dir);
152
150
  }
153
151
 
154
- this.worktreeName = this.container.get<string>("WorktreeName");
155
- this.mainRepoRoot = this.container.get<string>("MainRepoRoot");
156
152
  this.workdir = this.container.get<string>("Workdir");
157
153
  }
158
154
 
@@ -438,44 +434,6 @@ export class PermissionManager {
438
434
  }
439
435
  }
440
436
 
441
- // 0. Check worktree safety for Write and Edit tools
442
- const currentWorkdir = this.getWorkdir();
443
- if (
444
- this.worktreeName &&
445
- this.mainRepoRoot &&
446
- currentWorkdir &&
447
- (context.toolName === WRITE_TOOL_NAME ||
448
- context.toolName === EDIT_TOOL_NAME)
449
- ) {
450
- const targetPath = context.toolInput?.file_path as string | undefined;
451
- if (targetPath) {
452
- const absoluteTargetPath = path.resolve(currentWorkdir, targetPath);
453
- const isInsideMainRepo = isPathInside(
454
- absoluteTargetPath,
455
- this.mainRepoRoot,
456
- );
457
- const isInsideWorktree = isPathInside(
458
- absoluteTargetPath,
459
- currentWorkdir,
460
- );
461
-
462
- // If it's inside the main repo but NOT inside the current worktree
463
- if (isInsideMainRepo && !isInsideWorktree) {
464
- logger?.warn("Worktree safety violation", {
465
- toolName: context.toolName,
466
- targetPath,
467
- worktreeName: this.worktreeName,
468
- mainRepoRoot: this.mainRepoRoot,
469
- workdir: currentWorkdir,
470
- });
471
- return {
472
- behavior: "deny",
473
- message: `Access denied: You are currently in a worktree session ("${this.worktreeName}"). Modifying files in the main repository (outside the worktree) is not allowed. Please only modify files within the worktree directory: ${currentWorkdir}`,
474
- };
475
- }
476
- }
477
- }
478
-
479
437
  // 0. Check denied rules first - Deny always takes precedence
480
438
  for (const rule of this.deniedRules) {
481
439
  if (this.matchesRule(context, rule)) {
@@ -23,6 +23,7 @@ import {
23
23
  import type { SkillManager } from "./skillManager.js";
24
24
  import type { SkillMetadata } from "../types/skills.js";
25
25
  import type { SubagentManager } from "./subagentManager.js";
26
+ import type { MemoryService } from "../services/memory.js";
26
27
 
27
28
  import { logger } from "../utils/globalLogger.js";
28
29
 
@@ -81,6 +82,10 @@ export class SlashCommandManager {
81
82
  return this.container.get<SubagentManager>("SubagentManager")!;
82
83
  }
83
84
 
85
+ private get memoryService(): MemoryService {
86
+ return this.container.get<MemoryService>("MemoryService")!;
87
+ }
88
+
84
89
  private initializeBuiltinCommands(): void {
85
90
  // Register built-in clear command
86
91
  this.registerCommand({
@@ -90,6 +95,7 @@ export class SlashCommandManager {
90
95
  handler: async () => {
91
96
  this.aiManager.abortAIMessage();
92
97
  this.messageManager.clearMessages();
98
+ this.memoryService.clearCache();
93
99
  await this.taskManager.syncWithSession();
94
100
  },
95
101
  });
@@ -23,6 +23,8 @@ import {
23
23
  taskUpdateTool,
24
24
  taskListTool,
25
25
  } from "../tools/taskManagementTools.js";
26
+ import { enterWorktreeTool } from "../tools/enterWorktreeTool.js";
27
+ import { exitWorktreeTool } from "../tools/exitWorktreeTool.js";
26
28
  import { McpManager } from "./mcpManager.js";
27
29
  import { PermissionManager } from "./permissionManager.js";
28
30
  import { ChatCompletionFunctionTool } from "openai/resources.js";
@@ -43,6 +45,8 @@ import { logger } from "../utils/globalLogger.js";
43
45
 
44
46
  import type { SubagentConfiguration } from "../utils/subagentParser.js";
45
47
  import type { SkillMetadata } from "../types/skills.js";
48
+ import { toolSearchTool } from "../tools/toolSearchTool.js";
49
+ import { isDeferredTool } from "../utils/isDeferredTool.js";
46
50
 
47
51
  export interface ToolManagerOptions {
48
52
  container: Container;
@@ -123,6 +127,9 @@ class ToolManager {
123
127
  cronDeleteTool,
124
128
  cronListTool,
125
129
  webFetchTool,
130
+ enterWorktreeTool,
131
+ exitWorktreeTool,
132
+ toolSearchTool,
126
133
  ];
127
134
 
128
135
  for (const tool of builtInTools) {
@@ -191,6 +198,7 @@ class ToolManager {
191
198
  permissionMode: effectivePermissionMode,
192
199
  canUseToolCallback,
193
200
  permissionManager,
201
+ toolManager: this, // Allow ToolSearchTool to access the tool manager
194
202
  taskManager:
195
203
  this.container.get<import("../services/taskManager.js").TaskManager>(
196
204
  "TaskManager",
@@ -224,6 +232,11 @@ class ToolManager {
224
232
  this.container.get<import("./messageManager.js").MessageManager>(
225
233
  "MessageManager",
226
234
  )!,
235
+ hookManager: this.container.has("HookManager")
236
+ ? this.container.get<import("./hookManager.js").HookManager>(
237
+ "HookManager",
238
+ )
239
+ : undefined,
227
240
  sessionId: context.sessionId,
228
241
  toolCallId: context.toolCallId,
229
242
  };
@@ -287,10 +300,13 @@ class ToolManager {
287
300
  availableSkills?: SkillMetadata[];
288
301
  workdir?: string;
289
302
  isSubagent?: boolean;
303
+ /** Set of discovered deferred tool names to include in the API call */
304
+ discoveredTools?: Set<string>;
290
305
  }): ChatCompletionFunctionTool[] {
291
306
  const permissionManager =
292
307
  this.container.get<PermissionManager>("PermissionManager");
293
308
  const effectivePermissionMode = this.getPermissionMode();
309
+ const discoveredTools = options?.discoveredTools;
294
310
  const builtInToolsConfig = Array.from(this.toolsRegistry.values())
295
311
  .filter((tool) => {
296
312
  // If tool is explicitly denied by name in permission rules, filter it out
@@ -315,6 +331,10 @@ class ToolManager {
315
331
  effectivePermissionMode !== "bypassPermissions"
316
332
  );
317
333
  }
334
+ // Exclude deferred tools that haven't been discovered yet
335
+ if (isDeferredTool(tool) && !discoveredTools?.has(tool.name)) {
336
+ return false;
337
+ }
318
338
  return true;
319
339
  })
320
340
  .map((tool) => {
@@ -333,7 +353,16 @@ class ToolManager {
333
353
  });
334
354
  const mcpToolsConfig = this.mcpManager
335
355
  .getMcpToolsConfig()
336
- .filter((tool) => !permissionManager?.isToolDenied(tool.function.name));
356
+ .filter((tool) => {
357
+ if (permissionManager?.isToolDenied(tool.function.name)) {
358
+ return false;
359
+ }
360
+ // Exclude MCP tools that haven't been discovered yet
361
+ if (discoveredTools && !discoveredTools.has(tool.function.name)) {
362
+ return false;
363
+ }
364
+ return true;
365
+ });
337
366
  return [...builtInToolsConfig, ...mcpToolsConfig];
338
367
  }
339
368
 
@@ -375,6 +404,23 @@ class ToolManager {
375
404
  return this.container.get<PermissionManager>("PermissionManager");
376
405
  }
377
406
 
407
+ /**
408
+ * Get the names of all deferred tools (those that require ToolSearch to discover).
409
+ */
410
+ public getDeferredToolNames(): string[] {
411
+ const permissionManager =
412
+ this.container.get<PermissionManager>("PermissionManager");
413
+ const builtInDeferred = Array.from(this.toolsRegistry.values())
414
+ .filter((tool) => isDeferredTool(tool))
415
+ .filter((tool) => !permissionManager?.isToolDenied(tool.name))
416
+ .map((tool) => tool.name);
417
+ const mcpDeferred = this.mcpManager
418
+ .getMcpToolsConfig()
419
+ .filter((tool) => !permissionManager?.isToolDenied(tool.function.name))
420
+ .map((tool) => tool.function.name);
421
+ return [...builtInDeferred, ...mcpDeferred];
422
+ }
423
+
378
424
  /**
379
425
  * Get the task manager
380
426
  */
@@ -1,6 +1,7 @@
1
1
  import * as os from "node:os";
2
2
  import { ToolPlugin } from "../tools/types.js";
3
3
  import { isGitRepository } from "../utils/gitUtils.js";
4
+ import { getCurrentWorktreeSession } from "../utils/worktreeSession.js";
4
5
  import { buildAutoMemoryPrompt } from "./autoMemory.js";
5
6
  import { PermissionMode } from "../types/permissions.js";
6
7
  import {
@@ -17,7 +18,9 @@ import {
17
18
  READ_TOOL_NAME,
18
19
  GLOB_TOOL_NAME,
19
20
  GREP_TOOL_NAME,
21
+ TOOL_SEARCH_TOOL_NAME,
20
22
  } from "../constants/tools.js";
23
+ import { isDeferredTool } from "../utils/isDeferredTool.js";
21
24
 
22
25
  export const BASE_SYSTEM_PROMPT = `You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.`;
23
26
 
@@ -257,6 +260,13 @@ export function buildSystemPrompt(
257
260
  prompt += `\n\n${TOOL_POLICY}`;
258
261
  }
259
262
 
263
+ // List available deferred tool names so the model knows they exist
264
+ // Matching Claude Code: deferred tools appear by name, not loaded until fetched.
265
+ const deferredToolNames = tools.filter(isDeferredTool).map((t) => t.name);
266
+ if (deferredToolNames.length > 0) {
267
+ prompt += `\n\n<available-deferred-tools>${deferredToolNames.join(" ")}\nThese tools are NOT loaded yet — call ${TOOL_SEARCH_TOOL_NAME} first to discover their schemas before invoking them.</available-deferred-tools>`;
268
+ }
269
+
260
270
  prompt += `\n\n${OUTPUT_EFFICIENCY_PROMPT}`;
261
271
  prompt += `\n\n${TONE_AND_STYLE_PROMPT}`;
262
272
 
@@ -284,11 +294,13 @@ export function buildSystemPrompt(
284
294
  ? "bash"
285
295
  : shell;
286
296
 
297
+ const worktreeSession = getCurrentWorktreeSession();
298
+
287
299
  prompt += `
288
300
 
289
301
  Here is useful information about the environment you are running in:
290
302
  <env>
291
- Working directory: ${options.workdir}
303
+ Working directory: ${options.workdir}${worktreeSession ? `\nThis is a git worktree — an isolated copy of the repository. Run all commands from this directory. Do NOT \`cd\` to the original repository root at ${worktreeSession.originalCwd}.` : ""}
292
304
  Is directory a git repo: ${isGitRepo}
293
305
  Platform: ${platform}
294
306
  Shell: ${shellName}
@@ -327,8 +339,10 @@ export function enhanceSystemPromptWithEnvDetails(
327
339
  ? "bash"
328
340
  : shell;
329
341
 
342
+ const worktreeSession = getCurrentWorktreeSession();
343
+
330
344
  const notes = `Notes:
331
- - Agent threads always have their cwd reset between bash calls, as a result please only use absolute file paths.
345
+ - Agent threads always have their cwd reset between bash calls, as a result please only use absolute file paths.${worktreeSession ? `\n- You are in a git worktree at ${worktreeSession.worktreePath} (branch: ${worktreeSession.worktreeBranch}). Absolute paths from prior context may refer to the original repo at ${worktreeSession.originalCwd}; translate them to your worktree. Do NOT edit files outside this worktree.` : ""}
332
346
  - In your final response, share file paths (always absolute, never relative) that are relevant to the task. Include code snippets only when the exact text is load-bearing (e.g., a bug you found, a function signature the caller asked for) — do not recap code you merely read.
333
347
  - For clear communication with the user the assistant MUST avoid using emojis.
334
348
  - Do not use a colon before tool calls. Text like "Let me read the file:" followed by a read tool call should just be "Let me read the file." with a period.`;
@@ -339,7 +353,7 @@ ${notes}
339
353
 
340
354
  Here is useful information about the environment you are running in:
341
355
  <env>
342
- Working directory: ${workdir}
356
+ Working directory: ${workdir}${worktreeSession ? `\nThis is a git worktree — an isolated copy of the repository. Run all commands from this directory. Do NOT \`cd\` to the original repository root at ${worktreeSession.originalCwd}.` : ""}
343
357
  Is directory a git repo: ${isGitRepo}
344
358
  Platform: ${platform}
345
359
  Shell: ${shellName}
@@ -22,7 +22,6 @@ import type { MemoryRuleManager } from "../managers/MemoryRuleManager.js";
22
22
  import type { LiveConfigManager } from "../managers/liveConfigManager.js";
23
23
  import type { TaskManager } from "./taskManager.js";
24
24
  import type { PermissionManager } from "../managers/permissionManager.js";
25
- import type { MemoryService } from "./memory.js";
26
25
 
27
26
  export interface InitializationContext {
28
27
  skillManager: SkillManager;
@@ -42,8 +41,6 @@ export interface InitializationContext {
42
41
  memoryRuleManager: MemoryRuleManager;
43
42
  liveConfigManager: LiveConfigManager;
44
43
  taskManager: TaskManager;
45
- setProjectMemory: (content: string) => void;
46
- setUserMemory: (content: string) => void;
47
44
  resolveAndValidateConfig: () => void;
48
45
  }
49
46
 
@@ -74,8 +71,6 @@ export class InitializationService {
74
71
  memoryRuleManager,
75
72
  liveConfigManager,
76
73
  taskManager,
77
- setProjectMemory,
78
- setUserMemory,
79
74
  resolveAndValidateConfig,
80
75
  } = context;
81
76
 
@@ -293,42 +288,8 @@ export class InitializationService {
293
288
  // Don't throw error to prevent app startup failure - continue without live reload
294
289
  }
295
290
 
296
- // Load memory files during initialization
297
- try {
298
- const phaseStart = performance.now();
299
- const memoryService = container.get<MemoryService>("MemoryService");
300
- if (!memoryService) {
301
- throw new Error("MemoryService not found in container");
302
- }
303
-
304
- // Load project memory from AGENTS.md
305
- try {
306
- const projectMemoryContent =
307
- await memoryService.readMemoryFile(workdir);
308
- setProjectMemory(projectMemoryContent);
309
- } catch (error) {
310
- logger?.warn("Failed to load project memory file:", error);
311
- setProjectMemory("");
312
- }
313
-
314
- // Load user memory
315
- try {
316
- const userMemoryContent = await memoryService.getUserMemoryContent();
317
- setUserMemory(userMemoryContent);
318
- } catch (error) {
319
- logger?.warn("Failed to load user memory file:", error);
320
- setUserMemory("");
321
- }
322
- logger?.debug(
323
- `Initialization Phase [Memory Files Loading] took ${(performance.now() - phaseStart).toFixed(2)}ms`,
324
- );
325
- } catch (error) {
326
- // Ensure memory is always initialized even if loading fails
327
- setProjectMemory("");
328
- setUserMemory("");
329
- logger?.error("Failed to load memory files:", error);
330
- // Don't throw error to prevent app startup failure
331
- }
291
+ // Memory is lazy-cached on first getCombinedMemoryContent call
292
+ // No explicit loading needed during initialization
332
293
 
333
294
  // Handle session restoration or set provided messages
334
295
  const sessionPhaseStart = performance.now();
@@ -8,8 +8,26 @@ import { getGitCommonDir } from "../utils/gitUtils.js";
8
8
  import { pathEncoder } from "../utils/pathEncoder.js";
9
9
 
10
10
  export class MemoryService {
11
+ private _cachedProjectMemory: string = "";
12
+ private _cachedUserMemory: string = "";
13
+ private _cachedCombinedMemory: string | null = null;
14
+
11
15
  constructor(private container: Container) {}
12
16
 
17
+ public get cachedProjectMemory(): string {
18
+ return this._cachedProjectMemory;
19
+ }
20
+
21
+ public get cachedUserMemory(): string {
22
+ return this._cachedUserMemory;
23
+ }
24
+
25
+ public clearCache(): void {
26
+ this._cachedProjectMemory = "";
27
+ this._cachedUserMemory = "";
28
+ this._cachedCombinedMemory = null;
29
+ }
30
+
13
31
  /**
14
32
  * Get the project-specific auto-memory directory.
15
33
  * Uses the git common directory to ensure worktrees share the same memory.
@@ -143,24 +161,19 @@ export class MemoryService {
143
161
  }
144
162
 
145
163
  async getCombinedMemoryContent(workdir: string): Promise<string> {
146
- // Read memory file content
147
- const memoryContent = await this.readMemoryFile(workdir);
148
-
149
- // Read user-level memory content
150
- const userMemoryContent = await this.getUserMemoryContent();
151
-
152
- // Merge project memory and user memory
153
- let combinedMemory = "";
154
- if (memoryContent.trim()) {
155
- combinedMemory += memoryContent;
164
+ if (this._cachedCombinedMemory !== null) {
165
+ return this._cachedCombinedMemory;
156
166
  }
157
- if (userMemoryContent.trim()) {
158
- if (combinedMemory) {
159
- combinedMemory += "\n\n";
160
- }
161
- combinedMemory += userMemoryContent;
167
+ this._cachedProjectMemory = await this.readMemoryFile(workdir);
168
+ this._cachedUserMemory = await this.getUserMemoryContent();
169
+
170
+ let combined = "";
171
+ if (this._cachedProjectMemory.trim()) combined += this._cachedProjectMemory;
172
+ if (this._cachedUserMemory.trim()) {
173
+ if (combined) combined += "\n\n";
174
+ combined += this._cachedUserMemory;
162
175
  }
163
-
164
- return combinedMemory;
176
+ this._cachedCombinedMemory = combined;
177
+ return combined;
165
178
  }
166
179
  }
@@ -1,30 +1,73 @@
1
1
  import { ToolPlugin, ToolResult, ToolContext } from "./types.js";
2
2
  import { CRON_CREATE_TOOL_NAME } from "../constants/tools.js";
3
+ import { cronToHuman } from "../utils/cronToHuman.js";
4
+ import { parseCronExpression } from "../utils/parseCronExpression.js";
5
+
6
+ const DEFAULT_MAX_AGE_DAYS = 7;
7
+ const MAX_JOBS = 50;
8
+
9
+ const CRON_CREATE_DESCRIPTION = `Schedule a prompt to run at a future time within this Wave session — either recurring on a cron schedule, or once at a specific time.`;
10
+
11
+ const CRON_CREATE_PROMPT = `Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.
12
+
13
+ Uses standard 5-field cron in the user's local timezone: minute hour day-of-month month day-of-week. "0 9 * * *" means 9am local — no timezone conversion needed.
14
+
15
+ ## One-shot tasks (recurring: false)
16
+
17
+ For "remind me at X" or "at <time>, do Y" requests — fire once then auto-delete.
18
+ Pin minute/hour/day-of-month/month to specific values:
19
+ "remind me at 2:30pm today to check the deploy" → cron: "30 14 <today_dom> <today_month> *", recurring: false
20
+ "tomorrow morning, run the smoke test" → cron: "57 8 <tomorrow_dom> <tomorrow_month> *", recurring: false
21
+
22
+ ## Recurring jobs (recurring: true, the default)
23
+
24
+ For "every N minutes" / "every hour" / "weekdays at 9am" requests:
25
+ "*/5 * * * *" (every 5 min), "0 * * * *" (hourly), "0 9 * * 1-5" (weekdays at 9am local)
26
+
27
+ ## Avoid the :00 and :30 minute marks when the task allows it
28
+
29
+ Every user who asks for "9am" gets \`0 9\`, and every user who asks for "hourly" gets \`0 *\` — which means requests from across the planet land on the API at the same instant. When the user's request is approximate, pick a minute that is NOT 0 or 30:
30
+ "every morning around 9" → "57 8 * * *" or "3 9 * * *" (not "0 9 * * *")
31
+ "hourly" → "7 * * * *" (not "0 * * * *")
32
+ "in an hour or so, remind me to..." → pick whatever minute you land on, don't round
33
+
34
+ Only use minute 0 or 30 when the user names that exact time and clearly means it ("at 9:00 sharp", "at half past", coordinating with a meeting). When in doubt, nudge a few minutes early or late — the user will not notice, and the fleet will.
35
+
36
+ ## Session-only
37
+
38
+ Jobs live only in this Wave session — nothing is written to disk, and the job is gone when Wave exits.
39
+
40
+ ## Runtime behavior
41
+
42
+ Jobs only fire while the REPL is idle (not mid-query). The scheduler adds a small deterministic jitter on top of whatever you pick: recurring tasks fire up to 10% of their period late (max 15 min); one-shot tasks landing on :00 or :30 fire up to 90s early. Picking an off-minute is still the bigger lever.
43
+
44
+ Recurring tasks auto-expire after ${DEFAULT_MAX_AGE_DAYS} days — they fire one final time, then are deleted. This bounds session lifetime. Tell the user about the ${DEFAULT_MAX_AGE_DAYS}-day limit when scheduling recurring jobs.
45
+
46
+ Returns a job ID you can pass to CronDelete.`;
3
47
 
4
48
  export const cronCreateTool: ToolPlugin = {
5
49
  name: CRON_CREATE_TOOL_NAME,
50
+ shouldDefer: true,
6
51
  config: {
7
52
  type: "function",
8
53
  function: {
9
54
  name: CRON_CREATE_TOOL_NAME,
10
- description:
11
- "Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.",
55
+ description: CRON_CREATE_DESCRIPTION,
12
56
  parameters: {
13
57
  type: "object",
14
58
  properties: {
15
59
  cron: {
16
60
  type: "string",
17
61
  description:
18
- 'Standard 5-field cron expression in local time: "M H DoM Mon DoW"',
62
+ 'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes, "30 14 28 2 *" = Feb 28 at 2:30pm local once).',
19
63
  },
20
64
  prompt: {
21
65
  type: "string",
22
- description: "The prompt to enqueue at each fire time",
66
+ description: "The prompt to enqueue at each fire time.",
23
67
  },
24
68
  recurring: {
25
69
  type: "boolean",
26
- description:
27
- "Default: true. true = fire on every cron match until deleted or auto-expired after 7 days. false = fire once at the next match, then auto-delete",
70
+ description: `true (default) = fire on every cron match until deleted or auto-expired after ${DEFAULT_MAX_AGE_DAYS} days. false = fire once at the next match, then auto-delete. Use false for "remind me at X" one-shot requests with pinned minute/hour/dom/month.`,
28
71
  default: true,
29
72
  },
30
73
  },
@@ -32,6 +75,7 @@ export const cronCreateTool: ToolPlugin = {
32
75
  },
33
76
  },
34
77
  },
78
+ prompt: () => CRON_CREATE_PROMPT,
35
79
  execute: async (
36
80
  args: Record<string, unknown>,
37
81
  context: ToolContext,
@@ -50,6 +94,25 @@ export const cronCreateTool: ToolPlugin = {
50
94
  };
51
95
  }
52
96
 
97
+ // Validate cron expression
98
+ if (!parseCronExpression(cron)) {
99
+ return {
100
+ success: false,
101
+ content: "",
102
+ error: `Invalid cron expression '${cron}'. Expected 5 fields: M H DoM Mon DoW.`,
103
+ };
104
+ }
105
+
106
+ // Check max jobs limit
107
+ const existingJobs = context.cronManager.listJobs();
108
+ if (existingJobs.length >= MAX_JOBS) {
109
+ return {
110
+ success: false,
111
+ content: "",
112
+ error: `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`,
113
+ };
114
+ }
115
+
53
116
  try {
54
117
  const job = context.cronManager.createJob({
55
118
  cron,
@@ -57,10 +120,20 @@ export const cronCreateTool: ToolPlugin = {
57
120
  recurring,
58
121
  });
59
122
 
123
+ const humanSchedule = cronToHuman(cron);
124
+ const where = "Session-only (not written to disk, dies when Wave exits)";
125
+ const resultMessage = recurring
126
+ ? `Scheduled recurring job ${job.id} (${humanSchedule}). ${where}. Auto-expires after ${DEFAULT_MAX_AGE_DAYS} days. Use CronDelete to cancel sooner.`
127
+ : `Scheduled one-shot task ${job.id} (${humanSchedule}). ${where}. It will fire once then auto-delete.`;
128
+
60
129
  return {
61
130
  success: true,
62
- content: JSON.stringify({ id: job.id }, null, 2),
63
- shortResult: `Scheduled job ${job.id}`,
131
+ content: JSON.stringify(
132
+ { id: job.id, humanSchedule, recurring },
133
+ null,
134
+ 2,
135
+ ),
136
+ shortResult: resultMessage,
64
137
  };
65
138
  } catch (error) {
66
139
  return {
@@ -1,14 +1,18 @@
1
1
  import { ToolPlugin, ToolResult, ToolContext } from "./types.js";
2
2
  import { CRON_DELETE_TOOL_NAME } from "../constants/tools.js";
3
3
 
4
+ const CRON_DELETE_DESCRIPTION = "Cancel a scheduled cron job by ID";
5
+
6
+ const CRON_DELETE_PROMPT = `Cancel a cron job previously scheduled with CronCreate. Removes it from the in-memory session store.`;
7
+
4
8
  export const cronDeleteTool: ToolPlugin = {
5
9
  name: CRON_DELETE_TOOL_NAME,
10
+ shouldDefer: true,
6
11
  config: {
7
12
  type: "function",
8
13
  function: {
9
14
  name: CRON_DELETE_TOOL_NAME,
10
- description:
11
- "Cancel a cron job previously scheduled with CronCreate. Removes it from the in-memory session store.",
15
+ description: CRON_DELETE_DESCRIPTION,
12
16
  parameters: {
13
17
  type: "object",
14
18
  properties: {
@@ -21,6 +25,7 @@ export const cronDeleteTool: ToolPlugin = {
21
25
  },
22
26
  },
23
27
  },
28
+ prompt: () => CRON_DELETE_PROMPT,
24
29
  execute: async (
25
30
  args: Record<string, unknown>,
26
31
  context: ToolContext,