wave-agent-sdk 0.16.13 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/builtin/skills/settings/MCP.md +49 -4
  2. package/builtin/skills/settings/PERMISSIONS.md +31 -0
  3. package/dist/managers/aiManager.d.ts +19 -0
  4. package/dist/managers/aiManager.d.ts.map +1 -1
  5. package/dist/managers/aiManager.js +335 -209
  6. package/dist/managers/hookManager.d.ts +22 -0
  7. package/dist/managers/hookManager.d.ts.map +1 -1
  8. package/dist/managers/hookManager.js +95 -17
  9. package/dist/managers/mcpManager.d.ts.map +1 -1
  10. package/dist/managers/mcpManager.js +49 -39
  11. package/dist/managers/messageManager.d.ts +4 -0
  12. package/dist/managers/messageManager.d.ts.map +1 -1
  13. package/dist/managers/messageManager.js +9 -0
  14. package/dist/managers/permissionManager.d.ts +6 -0
  15. package/dist/managers/permissionManager.d.ts.map +1 -1
  16. package/dist/managers/permissionManager.js +14 -0
  17. package/dist/managers/planManager.d.ts.map +1 -1
  18. package/dist/managers/planManager.js +10 -0
  19. package/dist/managers/pluginManager.d.ts.map +1 -1
  20. package/dist/managers/pluginManager.js +28 -3
  21. package/dist/managers/slashCommandManager.d.ts.map +1 -1
  22. package/dist/managers/slashCommandManager.js +14 -0
  23. package/dist/managers/subagentManager.d.ts.map +1 -1
  24. package/dist/managers/subagentManager.js +4 -0
  25. package/dist/prompts/index.d.ts +0 -4
  26. package/dist/prompts/index.d.ts.map +1 -1
  27. package/dist/prompts/index.js +0 -3
  28. package/dist/prompts/planModeReminders.d.ts +6 -0
  29. package/dist/prompts/planModeReminders.d.ts.map +1 -0
  30. package/dist/prompts/planModeReminders.js +112 -0
  31. package/dist/services/aiService.d.ts +1 -0
  32. package/dist/services/aiService.d.ts.map +1 -1
  33. package/dist/services/aiService.js +3 -1
  34. package/dist/services/configurationService.d.ts.map +1 -1
  35. package/dist/services/configurationService.js +5 -3
  36. package/dist/services/initializationService.d.ts.map +1 -1
  37. package/dist/services/initializationService.js +13 -12
  38. package/dist/tools/bashTool.d.ts.map +1 -1
  39. package/dist/tools/bashTool.js +6 -8
  40. package/dist/tools/editTool.d.ts.map +1 -1
  41. package/dist/tools/editTool.js +21 -8
  42. package/dist/tools/exitPlanMode.d.ts.map +1 -1
  43. package/dist/tools/exitPlanMode.js +2 -0
  44. package/dist/types/agent.d.ts +2 -0
  45. package/dist/types/agent.d.ts.map +1 -1
  46. package/dist/types/hooks.d.ts +5 -1
  47. package/dist/types/hooks.d.ts.map +1 -1
  48. package/dist/types/hooks.js +2 -0
  49. package/dist/types/mcp.d.ts +1 -0
  50. package/dist/types/mcp.d.ts.map +1 -1
  51. package/dist/utils/editUtils.d.ts +3 -2
  52. package/dist/utils/editUtils.d.ts.map +1 -1
  53. package/dist/utils/editUtils.js +5 -3
  54. package/package.json +2 -2
  55. package/src/managers/aiManager.ts +416 -253
  56. package/src/managers/hookManager.ts +122 -18
  57. package/src/managers/mcpManager.ts +60 -47
  58. package/src/managers/messageManager.ts +10 -0
  59. package/src/managers/permissionManager.ts +18 -0
  60. package/src/managers/planManager.ts +11 -0
  61. package/src/managers/pluginManager.ts +52 -6
  62. package/src/managers/slashCommandManager.ts +17 -0
  63. package/src/managers/subagentManager.ts +4 -0
  64. package/src/prompts/index.ts +0 -8
  65. package/src/prompts/planModeReminders.ts +138 -0
  66. package/src/services/aiService.ts +4 -1
  67. package/src/services/configurationService.ts +5 -3
  68. package/src/services/initializationService.ts +16 -15
  69. package/src/tools/bashTool.ts +6 -7
  70. package/src/tools/editTool.ts +25 -8
  71. package/src/tools/exitPlanMode.ts +3 -0
  72. package/src/types/agent.ts +2 -0
  73. package/src/types/hooks.ts +9 -1
  74. package/src/types/mcp.ts +1 -0
  75. package/src/utils/editUtils.ts +6 -3
@@ -5,6 +5,7 @@ import { microcompactMessages } from "../utils/microcompact.js";
5
5
  import { parseTaskNotificationXml } from "../utils/notificationXml.js";
6
6
  import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js";
7
7
  import * as fs from "node:fs/promises";
8
+ import { existsSync } from "node:fs";
8
9
  import type {
9
10
  GatewayConfig,
10
11
  ModelConfig,
@@ -23,6 +24,12 @@ import type { PermissionManager } from "./permissionManager.js";
23
24
  import type { SubagentManager } from "./subagentManager.js";
24
25
  import type { SkillManager } from "./skillManager.js";
25
26
  import { buildSystemPrompt } from "../prompts/index.js";
27
+ import {
28
+ buildPlanModeReminder,
29
+ buildPlanModeSparseReminder,
30
+ buildPlanModeReEntryReminder,
31
+ buildExitedPlanModeReminder,
32
+ } from "../prompts/planModeReminders.js";
26
33
  import { Container } from "../utils/container.js";
27
34
  import { recoverTruncatedJson } from "../utils/stringUtils.js";
28
35
  import { ConfigurationService } from "../services/configurationService.js";
@@ -217,6 +224,119 @@ export class AIManager {
217
224
  });
218
225
  }
219
226
 
227
+ /**
228
+ * Build plan mode system-reminder messages to inject into the API message stream.
229
+ * These are transient messages not stored in the message history.
230
+ * This preserves prompt caching by keeping the system prompt constant.
231
+ */
232
+ private buildPlanModeMessages(
233
+ currentMode: PermissionMode | undefined,
234
+ ): import("openai/resources.js").ChatCompletionMessageParam[] {
235
+ const messages: import("openai/resources.js").ChatCompletionMessageParam[] =
236
+ [];
237
+ if (!this.permissionManager) return messages;
238
+
239
+ // Handle exit notification (one-time after leaving plan mode)
240
+ if (this.permissionManager.getNeedsPlanModeExitAttachment()) {
241
+ const planFilePath = this.permissionManager.getPlanFilePath();
242
+ const planExists = planFilePath ? existsSync(planFilePath) : false;
243
+ messages.push({
244
+ role: "user",
245
+ content: buildExitedPlanModeReminder(planFilePath, planExists),
246
+ });
247
+ this.permissionManager.setNeedsPlanModeExitAttachment(false);
248
+ }
249
+
250
+ // Handle plan mode reminders
251
+ if (currentMode !== "plan") return messages;
252
+
253
+ const planFilePath = this.permissionManager.getPlanFilePath();
254
+ if (!planFilePath) return messages;
255
+
256
+ const planExists = existsSync(planFilePath);
257
+
258
+ // Check for re-entry: flag is set AND plan file exists
259
+ if (this.permissionManager.hasExitedPlanModeInSession() && planExists) {
260
+ messages.push({
261
+ role: "user",
262
+ content: buildPlanModeReEntryReminder(planFilePath),
263
+ });
264
+ this.permissionManager.setHasExitedPlanMode(false); // One-time
265
+ }
266
+
267
+ // Count plan_mode system-reminders in existing messages to determine full vs sparse
268
+ // and count human turns since last reminder for throttling
269
+ const recentApiMessages = this.messageManager.getMessages();
270
+ let planModeReminderCount = 0;
271
+ let humanTurnsSinceLastReminder = 0;
272
+ let foundLastReminder = false;
273
+
274
+ for (let i = recentApiMessages.length - 1; i >= 0; i--) {
275
+ const msg = recentApiMessages[i];
276
+ if (msg.role === "user" && !msg.isMeta) {
277
+ // Count human turns (non-meta user messages without tool results)
278
+ const hasToolResult = msg.blocks?.some(
279
+ (b: { type: string }) => b.type === "tool",
280
+ );
281
+ if (!hasToolResult) {
282
+ if (!foundLastReminder) {
283
+ humanTurnsSinceLastReminder++;
284
+ }
285
+ }
286
+ }
287
+ // Check for existing plan mode system-reminders
288
+ if (msg.role === "user" && msg.isMeta) {
289
+ const textContent = msg.blocks
290
+ ?.filter((b) => b.type === "text")
291
+ .map((b) => ("content" in b ? b.content : ""))
292
+ .join("");
293
+ if (
294
+ textContent?.includes("Plan mode is active") ||
295
+ textContent?.includes("Plan mode still active")
296
+ ) {
297
+ planModeReminderCount++;
298
+ if (!foundLastReminder) {
299
+ foundLastReminder = true;
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ // Throttle: only inject every 5 human turns (but always inject on first turn)
306
+ const TURNS_BETWEEN_REMINDERS = 5;
307
+ const FULL_REMINDER_EVERY_N = 5;
308
+
309
+ if (
310
+ foundLastReminder &&
311
+ humanTurnsSinceLastReminder < TURNS_BETWEEN_REMINDERS
312
+ ) {
313
+ return messages; // Throttled — skip reminder
314
+ }
315
+
316
+ // Determine full vs sparse
317
+ // Every 5th reminder is full; rest are sparse
318
+ const reminderNumber = planModeReminderCount + 1;
319
+ const isFull = reminderNumber % FULL_REMINDER_EVERY_N === 1;
320
+
321
+ if (isFull) {
322
+ messages.push({
323
+ role: "user",
324
+ content: buildPlanModeReminder(
325
+ planFilePath,
326
+ planExists,
327
+ !!this.subagentType,
328
+ ),
329
+ });
330
+ } else {
331
+ messages.push({
332
+ role: "user",
333
+ content: buildPlanModeSparseReminder(planFilePath),
334
+ });
335
+ }
336
+
337
+ return messages;
338
+ }
339
+
220
340
  public setIsLoading(isLoading: boolean): void {
221
341
  this.isLoading = isLoading;
222
342
  this.onLoadingChange?.(isLoading);
@@ -294,272 +414,327 @@ export class AIManager {
294
414
  `Token usage exceeded ${this.getMaxInputTokens()}, compacting messages...`,
295
415
  );
296
416
 
297
- // Check if messages need compaction
298
417
  const messagesToCompact = this.messageManager.getMessages();
418
+ if (messagesToCompact.length === 0) return;
299
419
 
300
- // If there are messages to compact, perform compaction
301
- if (messagesToCompact.length > 0) {
302
- // Circuit breaker: skip compaction after 3 consecutive failures
303
- if (this.consecutiveCompactionFailures >= 3) {
304
- logger?.warn(
305
- `Skipping compaction: ${this.consecutiveCompactionFailures} consecutive failures`,
306
- );
307
- return;
308
- }
420
+ // Circuit breaker: skip compaction after 3 consecutive failures
421
+ if (this.consecutiveCompactionFailures >= 3) {
422
+ logger?.warn(
423
+ `Skipping compaction: ${this.consecutiveCompactionFailures} consecutive failures`,
424
+ );
425
+ return;
426
+ }
309
427
 
310
- const recentChatMessages = convertMessagesForAPI(messagesToCompact);
428
+ await this.compactConversation({
429
+ abortSignal: abortController.signal,
430
+ });
431
+ }
432
+ }
311
433
 
312
- // Save session before compaction to preserve original messages
313
- await this.messageManager.saveSession();
434
+ /**
435
+ * Manually compact the conversation history.
436
+ * Called by /compact slash command or auto-compaction trigger.
437
+ */
438
+ public async compactConversation(
439
+ options: {
440
+ customInstructions?: string;
441
+ abortSignal?: AbortSignal;
442
+ } = {},
443
+ ): Promise<void> {
444
+ const messagesToCompact = this.messageManager.getMessages();
445
+ if (messagesToCompact.length === 0) {
446
+ logger?.debug("No messages to compact");
447
+ return;
448
+ }
314
449
 
315
- this.setIsCompacting(true);
316
- try {
317
- const compactResult = await aiService.compactMessages({
318
- gatewayConfig: this.getGatewayConfig(),
319
- modelConfig: this.getModelConfig(),
320
- messages: recentChatMessages,
321
- abortSignal: abortController.signal,
322
- model: this.getModelConfig().fastModel,
323
- });
450
+ // Circuit breaker: skip if already compacting
451
+ if (this.isCompacting) {
452
+ logger?.warn("Compaction already in progress");
453
+ return;
454
+ }
324
455
 
325
- // Handle usage tracking for compaction operations
326
- let compactUsage: Usage | undefined;
327
- if (compactResult.usage) {
328
- compactUsage = {
329
- prompt_tokens: compactResult.usage.prompt_tokens,
330
- completion_tokens: compactResult.usage.completion_tokens,
331
- total_tokens: compactResult.usage.total_tokens,
332
- model: this.getModelConfig().fastModel,
333
- operation_type: "compact",
334
- };
335
- }
456
+ // 1. Run PreCompact hooks
457
+ let hookInstructions: string | undefined;
458
+ if (this.hookManager) {
459
+ try {
460
+ const preResult = await this.hookManager.executePreCompactHooks(
461
+ this.messageManager.getSessionId(),
462
+ this.messageManager.getTranscriptPath(),
463
+ options.customInstructions,
464
+ );
465
+ hookInstructions = preResult.additionalInstructions;
466
+ } catch (error) {
467
+ logger?.warn(`PreCompact hooks failed: ${(error as Error).message}`);
468
+ }
469
+ }
336
470
 
337
- // Build post-compact context restoration
338
- const POST_COMPACT_TOKEN_BUDGET = 50_000;
339
- const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000;
340
- const POST_COMPACT_MAX_FILES_TO_RESTORE = 5;
341
- const contextParts: string[] = [];
471
+ // 2. Merge custom instructions
472
+ const mergedInstructions =
473
+ [options.customInstructions, hookInstructions]
474
+ .filter(Boolean)
475
+ .join("\n") || undefined;
342
476
 
343
- // 1. File context restoration
344
- const recentFiles = this.messageManager.getRecentFileReads(
345
- POST_COMPACT_MAX_FILES_TO_RESTORE,
346
- POST_COMPACT_MAX_TOKENS_PER_FILE,
347
- );
348
- let usedTokens = 0;
349
- for (const file of recentFiles) {
350
- const fileTokens = Math.ceil(file.content.length / 4);
351
- if (usedTokens + fileTokens > POST_COMPACT_MAX_TOKENS_PER_FILE)
352
- continue;
353
- if (fileTokens > 0) usedTokens += fileTokens;
354
- contextParts.push(
355
- `\n\n## ${file.path}\n\`\`\`\n${file.content}\n\`\`\``,
356
- );
357
- if (contextParts.length >= POST_COMPACT_MAX_FILES_TO_RESTORE) break;
358
- if (usedTokens >= POST_COMPACT_TOKEN_BUDGET) break;
359
- }
477
+ // 3. Save session before compaction
478
+ await this.messageManager.saveSession();
360
479
 
361
- // 2. Working directory
362
- contextParts.push(
363
- `\n\n[Working Directory]\nCurrent working directory: ${this.getWorkdir()}`,
364
- );
480
+ this.setIsCompacting(true);
481
+ try {
482
+ const recentChatMessages = convertMessagesForAPI(messagesToCompact);
365
483
 
366
- // 3. Plan mode context
367
- const currentMode = this.permissionManager?.getCurrentEffectiveMode(
368
- this.getModelConfig().permissionMode,
369
- );
370
- if (currentMode === "plan") {
371
- const planFilePath = this.permissionManager?.getPlanFilePath();
372
- if (planFilePath) {
373
- let planExists = false;
374
- try {
375
- await fs.access(planFilePath);
376
- planExists = true;
377
- } catch {
378
- // Plan file doesn't exist yet
379
- }
380
- contextParts.push(
381
- `\n\n[Plan Mode]\nYou are in plan mode. Plan file: ${planFilePath} (exists: ${planExists})`,
382
- );
383
- }
384
- }
484
+ // 4. Call compactMessages with optional custom instructions
485
+ const compactResult = await aiService.compactMessages({
486
+ gatewayConfig: this.getGatewayConfig(),
487
+ modelConfig: this.getModelConfig(),
488
+ messages: recentChatMessages,
489
+ abortSignal: options.abortSignal,
490
+ model: this.getModelConfig().fastModel,
491
+ customInstructions: mergedInstructions,
492
+ });
385
493
 
386
- // 4. Invoked skills context (with token budget, matching Claude Code)
387
- const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000;
388
- const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000;
389
- const invokedSkillNames =
390
- this.messageManager.getInvokedSkillNames(10);
391
- if (invokedSkillNames.length > 0 && this.skillManager) {
392
- const invokedSkillParts: string[] = [];
393
- let skillsUsedTokens = 0;
394
- for (const skillName of invokedSkillNames) {
395
- try {
396
- const skill = await this.skillManager.loadSkill(skillName);
397
- if (!skill) continue;
494
+ // 5. Handle usage tracking
495
+ let compactUsage: Usage | undefined;
496
+ if (compactResult.usage) {
497
+ compactUsage = {
498
+ prompt_tokens: compactResult.usage.prompt_tokens,
499
+ completion_tokens: compactResult.usage.completion_tokens,
500
+ total_tokens: compactResult.usage.total_tokens,
501
+ model: this.getModelConfig().fastModel,
502
+ operation_type: "compact",
503
+ };
504
+ }
398
505
 
399
- // Extract content after frontmatter (matching prepareSkillContent pattern)
400
- const contentMatch = skill.content.match(
401
- /^---\n[\s\S]*?\n---\n([\s\S]*)$/,
402
- );
403
- let skillContent = contentMatch
404
- ? contentMatch[1].trim()
405
- : skill.content;
406
-
407
- // Per-skill token budget enforcement (~4 chars per token)
408
- const maxSkillChars = POST_COMPACT_MAX_TOKENS_PER_SKILL * 4;
409
- if (skillContent.length > maxSkillChars) {
410
- skillContent =
411
- skillContent.slice(0, maxSkillChars) +
412
- "\n\n...[truncated]...";
413
- }
506
+ // 6. Build post-compact context restoration
507
+ const enhancedSummary = await this.buildPostCompactContext(
508
+ compactResult.content,
509
+ );
414
510
 
415
- const skillTokens = Math.ceil(skillContent.length / 4);
416
- if (
417
- skillsUsedTokens + skillTokens >
418
- POST_COMPACT_SKILLS_TOKEN_BUDGET
419
- )
420
- break;
421
- skillsUsedTokens += skillTokens;
511
+ // 7. Execute message reconstruction
512
+ this.messageManager.compactMessagesAndUpdateSession(
513
+ enhancedSummary,
514
+ compactUsage,
515
+ );
422
516
 
423
- invokedSkillParts.push(
424
- `\n\n## ${skill.name}\n${skill.description ? `*${skill.description}*\n\n` : ""}\`\`\`\n${skillContent}\n\`\`\``,
425
- );
426
- } catch {
427
- // Skip skills that can't be loaded
428
- }
429
- }
430
- if (invokedSkillParts.length > 0) {
431
- contextParts.push(
432
- `\n\n[Invoked Skills]\n${invokedSkillParts.join("")}`,
433
- );
434
- }
435
- }
517
+ // 8. Track usage
518
+ if (compactUsage && this.callbacks?.onUsageAdded) {
519
+ this.callbacks.onUsageAdded(compactUsage);
520
+ }
436
521
 
437
- // 5. Background subagent status (shell tasks excluded, matching Claude Code's createAsyncAgentAttachmentsIfNeeded)
438
- const agents =
439
- this.backgroundTaskManager
440
- ?.getAllTasks()
441
- .filter((a) => a.type === "subagent") || [];
442
- if (agents.length > 0) {
443
- const agentParts: string[] = [];
444
- for (const a of agents) {
445
- if (a.status === "killed") {
446
- agentParts.push(
447
- `Task "${a.description}" (${a.id}) was stopped by the user.`,
448
- );
449
- } else if (a.status === "running") {
450
- const parts = [
451
- `Background agent "${a.description}" (${a.id}) is still running.`,
452
- `Do NOT spawn a duplicate. You will be notified when it completes.`,
453
- ];
454
- if (a.outputPath) {
455
- parts.push(`You can read partial output at ${a.outputPath}.`);
456
- }
457
- agentParts.push(parts.join(" "));
458
- } else {
459
- // completed or failed
460
- const parts = [
461
- `Task ${a.id} (status: ${a.status}) (description: ${a.description}).`,
462
- ];
463
- const deltaText = a.status === "failed" ? a.stderr : a.stdout;
464
- if (deltaText && deltaText.length > 0) {
465
- const summary =
466
- deltaText.length > 500
467
- ? deltaText.slice(0, 500) + "..."
468
- : deltaText;
469
- parts.push(`Delta: ${summary}`);
470
- }
471
- if (a.outputPath) {
472
- parts.push(
473
- `Read the output file to retrieve the result: ${a.outputPath}.`,
474
- );
475
- }
476
- agentParts.push(parts.join(" "));
477
- }
478
- }
479
- if (agentParts.length > 0) {
480
- contextParts.push(
481
- `\n\n[Background Tasks]\n${agentParts.join("\n")}`,
482
- );
483
- }
484
- }
522
+ this.consecutiveCompactionFailures = 0;
485
523
 
486
- // Merge context restoration into summary
487
- const enhancedSummary =
488
- compactResult.content +
489
- (contextParts.length > 0
490
- ? `\n\n[Context Restoration]` + contextParts.join("")
491
- : "");
492
-
493
- // Execute message reconstruction and sessionId update after compaction
494
- this.messageManager.compactMessagesAndUpdateSession(
495
- enhancedSummary,
496
- compactUsage,
497
- );
524
+ // 9. Log OTEL event
525
+ logOTelEvent("compaction", {
526
+ beforeTokens: String(messagesToCompact.length),
527
+ afterTokens: "1",
528
+ model: this.getModelConfig().fastModel,
529
+ }).catch(() => {});
498
530
 
499
- // Notify Agent to add to usage tracking
500
- if (compactUsage && this.callbacks?.onUsageAdded) {
501
- this.callbacks.onUsageAdded(compactUsage);
531
+ // 10. Run SessionStart hooks (existing behavior)
532
+ if (this.hookManager) {
533
+ try {
534
+ const newSessionId = this.messageManager.getSessionId();
535
+ const sessionStartResult =
536
+ await this.hookManager.executeSessionStartHooks(
537
+ "compact",
538
+ newSessionId,
539
+ this.messageManager.getTranscriptPath(),
540
+ this.subagentType,
541
+ );
542
+ if (sessionStartResult.additionalContext) {
543
+ this.messageManager.addUserMessage({
544
+ content: `<system-reminder>\nSessionStart hook additional context: ${sessionStartResult.additionalContext}\n</system-reminder>`,
545
+ isMeta: true,
546
+ });
502
547
  }
548
+ if (sessionStartResult.initialUserMessage) {
549
+ this.messageManager.addUserMessage({
550
+ content: sessionStartResult.initialUserMessage,
551
+ isMeta: true,
552
+ });
553
+ }
554
+ } catch (error) {
555
+ logger?.warn(
556
+ `SessionStart hooks on compact failed: ${(error as Error).message}`,
557
+ );
558
+ }
559
+ }
503
560
 
504
- logger?.debug(
505
- `Successfully compacted ${messagesToCompact.length} messages and updated session`,
561
+ // 11. Run PostCompact hooks
562
+ if (this.hookManager) {
563
+ try {
564
+ await this.hookManager.executePostCompactHooks(
565
+ this.messageManager.getSessionId(),
566
+ this.messageManager.getTranscriptPath(),
567
+ compactResult.content,
506
568
  );
507
- this.consecutiveCompactionFailures = 0;
569
+ } catch (error) {
570
+ logger?.warn(`PostCompact hooks failed: ${(error as Error).message}`);
571
+ }
572
+ }
508
573
 
509
- // Log compaction event
510
- logOTelEvent("compaction", {
511
- beforeTokens: String(messagesToCompact.length),
512
- afterTokens: "1",
513
- model: this.getModelConfig().fastModel,
514
- }).catch(() => {});
574
+ logger?.debug(
575
+ `Successfully compacted ${messagesToCompact.length} messages`,
576
+ );
577
+ } catch (compactError) {
578
+ this.consecutiveCompactionFailures++;
579
+ logger?.error(
580
+ `Failed to compact messages (${this.consecutiveCompactionFailures} consecutive):`,
581
+ compactError,
582
+ );
583
+ this.messageManager.addErrorBlock(
584
+ `Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`,
585
+ );
586
+ } finally {
587
+ this.setIsCompacting(false);
588
+ }
589
+ }
515
590
 
516
- // Run SessionStart hooks after compaction to restore context
517
- if (this.hookManager) {
518
- try {
519
- const newSessionId = this.messageManager.getSessionId();
520
- const sessionStartResult =
521
- await this.hookManager.executeSessionStartHooks(
522
- "compact",
523
- newSessionId,
524
- this.messageManager.getTranscriptPath(),
525
- this.subagentType,
526
- );
591
+ /**
592
+ * Build post-compact context restoration content.
593
+ * Restores file reads, working directory, plan mode, skills, and background tasks.
594
+ */
595
+ private async buildPostCompactContext(summary: string): Promise<string> {
596
+ const POST_COMPACT_TOKEN_BUDGET = 50_000;
597
+ const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000;
598
+ const POST_COMPACT_MAX_FILES_TO_RESTORE = 5;
599
+ const contextParts: string[] = [];
600
+
601
+ // 1. File context restoration
602
+ const recentFiles = this.messageManager.getRecentFileReads(
603
+ POST_COMPACT_MAX_FILES_TO_RESTORE,
604
+ POST_COMPACT_MAX_TOKENS_PER_FILE,
605
+ );
606
+ let usedTokens = 0;
607
+ for (const file of recentFiles) {
608
+ const fileTokens = Math.ceil(file.content.length / 4);
609
+ if (usedTokens + fileTokens > POST_COMPACT_MAX_TOKENS_PER_FILE) continue;
610
+ if (fileTokens > 0) usedTokens += fileTokens;
611
+ contextParts.push(`\n\n## ${file.path}\n\`\`\`\n${file.content}\n\`\`\``);
612
+ if (contextParts.length >= POST_COMPACT_MAX_FILES_TO_RESTORE) break;
613
+ if (usedTokens >= POST_COMPACT_TOKEN_BUDGET) break;
614
+ }
527
615
 
528
- // Inject additionalContext as a meta user message
529
- if (sessionStartResult.additionalContext) {
530
- this.messageManager.addUserMessage({
531
- content: `<system-reminder>\nSessionStart hook additional context: ${sessionStartResult.additionalContext}\n</system-reminder>`,
532
- isMeta: true,
533
- });
534
- }
616
+ // 2. Working directory
617
+ contextParts.push(
618
+ `\n\n[Working Directory]\nCurrent working directory: ${this.getWorkdir()}`,
619
+ );
535
620
 
536
- // Inject initialUserMessage as a meta user message
537
- if (sessionStartResult.initialUserMessage) {
538
- this.messageManager.addUserMessage({
539
- content: sessionStartResult.initialUserMessage,
540
- isMeta: true,
541
- });
542
- }
543
- } catch (error) {
544
- logger?.warn(
545
- `SessionStart hooks on compact failed: ${(error as Error).message}`,
546
- );
547
- }
621
+ // 3. Plan mode context
622
+ const currentMode = this.permissionManager?.getCurrentEffectiveMode(
623
+ this.getModelConfig().permissionMode,
624
+ );
625
+ if (currentMode === "plan") {
626
+ const planFilePath = this.permissionManager?.getPlanFilePath();
627
+ if (planFilePath) {
628
+ let planExists = false;
629
+ try {
630
+ await fs.access(planFilePath);
631
+ planExists = true;
632
+ } catch {
633
+ // Plan file doesn't exist yet
634
+ }
635
+ contextParts.push(
636
+ `\n\n${buildPlanModeReminder(planFilePath, planExists, !!this.subagentType)}`,
637
+ );
638
+ }
639
+ }
640
+
641
+ // 4. Invoked skills context (with token budget, matching Claude Code)
642
+ const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000;
643
+ const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000;
644
+ const invokedSkillNames = this.messageManager.getInvokedSkillNames(10);
645
+ if (invokedSkillNames.length > 0 && this.skillManager) {
646
+ const invokedSkillParts: string[] = [];
647
+ let skillsUsedTokens = 0;
648
+ for (const skillName of invokedSkillNames) {
649
+ try {
650
+ const skill = await this.skillManager.loadSkill(skillName);
651
+ if (!skill) continue;
652
+
653
+ const contentMatch = skill.content.match(
654
+ /^---\n[\s\S]*?\n---\n([\s\S]*)$/,
655
+ );
656
+ let skillContent = contentMatch
657
+ ? contentMatch[1].trim()
658
+ : skill.content;
659
+
660
+ const maxSkillChars = POST_COMPACT_MAX_TOKENS_PER_SKILL * 4;
661
+ if (skillContent.length > maxSkillChars) {
662
+ skillContent =
663
+ skillContent.slice(0, maxSkillChars) + "\n\n...[truncated]...";
548
664
  }
549
- } catch (compactError) {
550
- this.consecutiveCompactionFailures++;
551
- logger?.error(
552
- `Failed to compact messages (${this.consecutiveCompactionFailures} consecutive):`,
553
- compactError,
665
+
666
+ const skillTokens = Math.ceil(skillContent.length / 4);
667
+ if (skillsUsedTokens + skillTokens > POST_COMPACT_SKILLS_TOKEN_BUDGET)
668
+ break;
669
+ skillsUsedTokens += skillTokens;
670
+
671
+ invokedSkillParts.push(
672
+ `\n\n## ${skill.name}\n${skill.description ? `*${skill.description}*\n\n` : ""}\`\`\`\n${skillContent}\n\`\`\``,
554
673
  );
555
- this.messageManager.addErrorBlock(
556
- `Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`,
674
+ } catch {
675
+ // Skip skills that can't be loaded
676
+ }
677
+ }
678
+ if (invokedSkillParts.length > 0) {
679
+ contextParts.push(
680
+ `\n\n[Invoked Skills]\n${invokedSkillParts.join("")}`,
681
+ );
682
+ }
683
+ }
684
+
685
+ // 5. Background subagent status (shell tasks excluded, matching Claude Code's createAsyncAgentAttachmentsIfNeeded)
686
+ const agents =
687
+ this.backgroundTaskManager
688
+ ?.getAllTasks()
689
+ .filter((a) => a.type === "subagent") || [];
690
+ if (agents.length > 0) {
691
+ const agentParts: string[] = [];
692
+ for (const a of agents) {
693
+ if (a.status === "killed") {
694
+ agentParts.push(
695
+ `Task "${a.description}" (${a.id}) was stopped by the user.`,
557
696
  );
558
- } finally {
559
- this.setIsCompacting(false);
697
+ } else if (a.status === "running") {
698
+ const parts = [
699
+ `Background agent "${a.description}" (${a.id}) is still running.`,
700
+ `Do NOT spawn a duplicate. You will be notified when it completes.`,
701
+ ];
702
+ if (a.outputPath) {
703
+ parts.push(`You can read partial output at ${a.outputPath}.`);
704
+ }
705
+ agentParts.push(parts.join(" "));
706
+ } else {
707
+ // completed or failed
708
+ const parts = [
709
+ `Task ${a.id} (status: ${a.status}) (description: ${a.description}).`,
710
+ ];
711
+ const deltaText = a.status === "failed" ? a.stderr : a.stdout;
712
+ if (deltaText && deltaText.length > 0) {
713
+ const summary =
714
+ deltaText.length > 500
715
+ ? deltaText.slice(0, 500) + "..."
716
+ : deltaText;
717
+ parts.push(`Delta: ${summary}`);
718
+ }
719
+ if (a.outputPath) {
720
+ parts.push(
721
+ `Read the output file to retrieve the result: ${a.outputPath}.`,
722
+ );
723
+ }
724
+ agentParts.push(parts.join(" "));
560
725
  }
561
726
  }
727
+ if (agentParts.length > 0) {
728
+ contextParts.push(`\n\n[Background Tasks]\n${agentParts.join("\n")}`);
729
+ }
562
730
  }
731
+
732
+ return (
733
+ summary +
734
+ (contextParts.length > 0
735
+ ? `\n\n[Context Restoration]` + contextParts.join("")
736
+ : "")
737
+ );
563
738
  }
564
739
 
565
740
  public getIsCompacting(): boolean {
@@ -685,22 +860,11 @@ export class AIManager {
685
860
  .getTools()
686
861
  .filter((t) => toolNames.has(t.name));
687
862
 
688
- let planModeOptions:
689
- | { planFilePath: string; planExists: boolean }
690
- | undefined;
691
-
692
- if (currentMode === "plan") {
693
- const planFilePath = this.permissionManager?.getPlanFilePath();
694
- if (planFilePath) {
695
- let planExists = false;
696
- try {
697
- await fs.access(planFilePath);
698
- planExists = true;
699
- } catch {
700
- planExists = false;
701
- }
702
- planModeOptions = { planFilePath, planExists };
703
- }
863
+ // Inject plan mode system-reminder messages (not system prompt)
864
+ // This preserves prompt caching by keeping the system prompt constant
865
+ const planModeMessages = this.buildPlanModeMessages(currentMode);
866
+ if (planModeMessages.length > 0) {
867
+ recentMessages.push(...planModeMessages);
704
868
  }
705
869
 
706
870
  let autoMemoryOptions: { directory: string; content: string } | undefined;
@@ -733,7 +897,6 @@ export class AIManager {
733
897
  memory: combinedMemory,
734
898
  language: this.getLanguage(),
735
899
  isSubagent: !!this.subagentType,
736
- planMode: planModeOptions,
737
900
  autoMemory: autoMemoryOptions,
738
901
  permissionMode: currentMode,
739
902
  },