wave-agent-sdk 1.0.0 → 1.0.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 (41) hide show
  1. package/dist/agent.d.ts +9 -20
  2. package/dist/agent.js +23 -97
  3. package/dist/managers/aiManager.d.ts +63 -8
  4. package/dist/managers/aiManager.js +270 -80
  5. package/dist/managers/messageManager.d.ts +9 -5
  6. package/dist/managers/messageManager.js +36 -12
  7. package/dist/managers/subagentManager.d.ts +6 -0
  8. package/dist/managers/subagentManager.js +33 -22
  9. package/dist/prompts/index.d.ts +0 -1
  10. package/dist/prompts/index.js +0 -4
  11. package/dist/services/aiService.d.ts +1 -34
  12. package/dist/services/aiService.js +18 -130
  13. package/dist/services/autoMemoryService.d.ts +27 -2
  14. package/dist/services/autoMemoryService.js +124 -36
  15. package/dist/services/configurationService.js +14 -1
  16. package/dist/services/session.d.ts +13 -0
  17. package/dist/services/session.js +64 -0
  18. package/dist/types/agent.d.ts +0 -2
  19. package/dist/types/config.d.ts +7 -0
  20. package/dist/types/core.d.ts +1 -1
  21. package/dist/utils/containerSetup.js +12 -3
  22. package/package.json +1 -1
  23. package/src/agent.ts +36 -110
  24. package/src/managers/aiManager.ts +366 -105
  25. package/src/managers/messageManager.ts +51 -23
  26. package/src/managers/subagentManager.ts +36 -25
  27. package/src/prompts/index.ts +0 -4
  28. package/src/services/aiService.ts +25 -203
  29. package/src/services/autoMemoryService.ts +145 -39
  30. package/src/services/configurationService.ts +16 -1
  31. package/src/services/session.ts +68 -0
  32. package/src/types/agent.ts +0 -6
  33. package/src/types/config.ts +7 -0
  34. package/src/types/core.ts +1 -1
  35. package/src/utils/containerSetup.ts +12 -4
  36. package/dist/constants/goalPrompts.d.ts +0 -1
  37. package/dist/constants/goalPrompts.js +0 -10
  38. package/dist/managers/goalManager.d.ts +0 -42
  39. package/dist/managers/goalManager.js +0 -177
  40. package/src/constants/goalPrompts.ts +0 -10
  41. package/src/managers/goalManager.ts +0 -232
@@ -538,18 +538,21 @@ export class SubagentManager {
538
538
  createSubagentCallbacks(subagentId) {
539
539
  return {
540
540
  onUserMessageAdded: (params) => {
541
+ this.refreshSubagentState(subagentId);
541
542
  // Forward user message events to parent via SubagentManager callbacks
542
543
  if (this.callbacks?.onSubagentUserMessageAdded) {
543
544
  this.callbacks.onSubagentUserMessageAdded(subagentId, params);
544
545
  }
545
546
  },
546
547
  onAssistantMessageAdded: (messageId) => {
548
+ this.refreshSubagentState(subagentId);
547
549
  // Forward assistant message events to parent via SubagentManager callbacks
548
550
  if (this.callbacks?.onSubagentAssistantMessageAdded) {
549
551
  this.callbacks.onSubagentAssistantMessageAdded(subagentId, messageId);
550
552
  }
551
553
  },
552
554
  onAssistantContentUpdated: (params) => {
555
+ this.refreshSubagentState(subagentId);
553
556
  // Forward assistant content updates to parent via SubagentManager callbacks
554
557
  if (this.callbacks?.onSubagentAssistantContentUpdated) {
555
558
  this.callbacks.onSubagentAssistantContentUpdated({
@@ -559,6 +562,7 @@ export class SubagentManager {
559
562
  }
560
563
  },
561
564
  onAssistantReasoningUpdated: (params) => {
565
+ this.refreshSubagentState(subagentId);
562
566
  // Forward assistant reasoning updates to parent via SubagentManager callbacks
563
567
  if (this.callbacks?.onSubagentAssistantReasoningUpdated) {
564
568
  this.callbacks.onSubagentAssistantReasoningUpdated({
@@ -568,6 +572,7 @@ export class SubagentManager {
568
572
  }
569
573
  },
570
574
  onToolBlockUpdated: (params) => {
575
+ this.refreshSubagentState(subagentId);
571
576
  const instance = this.instances.get(subagentId);
572
577
  if (instance) {
573
578
  // Log tool execution to file only when finalized
@@ -582,28 +587,6 @@ export class SubagentManager {
582
587
  this.callbacks.onSubagentToolBlockUpdated(subagentId, params);
583
588
  }
584
589
  },
585
- // These callbacks will be handled by the parent agent
586
- onMessagesChange: (messages) => {
587
- const instance = this.instances.get(subagentId);
588
- if (instance) {
589
- instance.messages = messages;
590
- // Compute usedTools from messages (last 2 tool blocks)
591
- const toolBlocks = messages.flatMap((m) => m.blocks?.filter((b) => b.type === "tool") ?? []);
592
- const last2 = toolBlocks.slice(-2);
593
- instance.usedTools = last2.map((tb) => ({
594
- name: tb.name ?? "",
595
- parameters: tb.parameters ?? "",
596
- compactParams: tb.compactParams,
597
- stage: tb.stage,
598
- }));
599
- // Trigger the onUpdate callback if provided
600
- instance.onUpdate?.();
601
- // Forward subagent message changes to parent via callbacks
602
- if (this.callbacks?.onSubagentMessagesChange) {
603
- this.callbacks.onSubagentMessagesChange(subagentId, messages);
604
- }
605
- }
606
- },
607
590
  onLatestTotalTokensChange: (tokens) => {
608
591
  const instance = this.instances.get(subagentId);
609
592
  if (instance) {
@@ -616,6 +599,7 @@ export class SubagentManager {
616
599
  }
617
600
  },
618
601
  onErrorBlockAdded: (error) => {
602
+ this.refreshSubagentState(subagentId);
619
603
  const instance = this.instances.get(subagentId);
620
604
  if (instance?.logStream) {
621
605
  instance.logStream.write(`[${new Date().toISOString()}] Error: ${error}\n`);
@@ -623,4 +607,31 @@ export class SubagentManager {
623
607
  },
624
608
  };
625
609
  }
610
+ /**
611
+ * Pull the latest messages from the subagent instance's MessageManager and
612
+ * refresh the instance's cached messages, usedTools, onUpdate and the
613
+ * onSubagentMessagesChange forwarding. Triggered by incremental callbacks.
614
+ */
615
+ refreshSubagentState(subagentId) {
616
+ const instance = this.instances.get(subagentId);
617
+ if (!instance)
618
+ return;
619
+ const messages = instance.messageManager.getMessages();
620
+ instance.messages = messages;
621
+ // Compute usedTools from messages (last 2 tool blocks)
622
+ const toolBlocks = messages.flatMap((m) => m.blocks?.filter((b) => b.type === "tool") ?? []);
623
+ const last2 = toolBlocks.slice(-2);
624
+ instance.usedTools = last2.map((tb) => ({
625
+ name: tb.name ?? "",
626
+ parameters: tb.parameters ?? "",
627
+ compactParams: tb.compactParams,
628
+ stage: tb.stage,
629
+ }));
630
+ // Trigger the onUpdate callback if provided
631
+ instance.onUpdate?.();
632
+ // Forward subagent message changes to parent via callbacks
633
+ if (this.callbacks?.onSubagentMessagesChange) {
634
+ this.callbacks.onSubagentMessagesChange(subagentId, messages);
635
+ }
636
+ }
626
637
  }
@@ -34,7 +34,6 @@ export declare function getCompactPrompt(customInstructions?: string): string;
34
34
  */
35
35
  export declare function formatCompactSummary(summary: string): string;
36
36
  export declare const WEB_CONTENT_SYSTEM_PROMPT = "You are a helpful assistant that extracts information from web content. The content is provided in Markdown format.";
37
- export declare const BTW_SYSTEM_PROMPT = "You are a helpful assistant. Answer the user's side question based on the conversation history. \nDo NOT say things like \"Let me try...\", \"I'll now...\", \"Let me check...\", or promise to take any action. \nIf you don't know the answer, say so - do not offer to look it up or investigate. \nSimply answer the question with the information you have.";
38
37
  export declare function buildSystemPrompt(basePrompt: string | undefined, tools: ToolPlugin[], options?: {
39
38
  workdir?: string;
40
39
  originalWorkdir?: string;
@@ -285,10 +285,6 @@ export function formatCompactSummary(summary) {
285
285
  return formattedSummary.trim();
286
286
  }
287
287
  export const WEB_CONTENT_SYSTEM_PROMPT = `You are a helpful assistant that extracts information from web content. The content is provided in Markdown format.`;
288
- export const BTW_SYSTEM_PROMPT = `You are a helpful assistant. Answer the user's side question based on the conversation history.
289
- Do NOT say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action.
290
- If you don't know the answer, say so - do not offer to look it up or investigate.
291
- Simply answer the question with the information you have.`;
292
288
  export function buildSystemPrompt(basePrompt, tools, options = {}) {
293
289
  // --- Static block (cacheable) ---
294
290
  let staticText = basePrompt || DEFAULT_SYSTEM_PROMPT;
@@ -34,6 +34,7 @@ export interface CallAgentOptions {
34
34
  stage?: "start" | "streaming" | "running" | "end";
35
35
  }) => void;
36
36
  onReasoningUpdate?: (content: string) => void;
37
+ disableThinkingOptions?: Record<string, unknown>;
37
38
  }
38
39
  export interface CallAgentResult {
39
40
  content?: string;
@@ -62,37 +63,3 @@ export interface ProcessWebContentResult {
62
63
  };
63
64
  }
64
65
  export declare function processWebContent(options: ProcessWebContentOptions): Promise<ProcessWebContentResult>;
65
- export interface BtwOptions {
66
- gatewayConfig: GatewayConfig;
67
- modelConfig: ModelConfig;
68
- messages: ChatCompletionMessageParam[];
69
- question: string;
70
- abortSignal?: AbortSignal;
71
- model?: string;
72
- }
73
- export interface BtwResult {
74
- content: string;
75
- usage?: {
76
- prompt_tokens: number;
77
- completion_tokens: number;
78
- total_tokens: number;
79
- };
80
- }
81
- export declare function btw(options: BtwOptions): Promise<BtwResult>;
82
- export interface EvaluateGoalOptions {
83
- gatewayConfig: GatewayConfig;
84
- modelConfig: ModelConfig;
85
- model: string;
86
- goalCondition: string;
87
- messages: ChatCompletionMessageParam[];
88
- abortSignal?: AbortSignal;
89
- }
90
- export interface EvaluateGoalResult {
91
- content: string;
92
- usage?: {
93
- prompt_tokens: number;
94
- completion_tokens: number;
95
- total_tokens: number;
96
- };
97
- }
98
- export declare function evaluateGoal(options: EvaluateGoalOptions): Promise<EvaluateGoalResult>;
@@ -7,8 +7,7 @@ import { supportsPromptCaching } from "../utils/modelCapabilities.js";
7
7
  import * as os from "os";
8
8
  import * as fs from "fs";
9
9
  import * as path from "path";
10
- import { WEB_CONTENT_SYSTEM_PROMPT, BTW_SYSTEM_PROMPT, } from "../prompts/index.js";
11
- import { GOAL_EVALUATION_SYSTEM_PROMPT } from "../constants/goalPrompts.js";
10
+ import { WEB_CONTENT_SYSTEM_PROMPT, } from "../prompts/index.js";
12
11
  // Global rate limiter state for 1 QPS
13
12
  let nextAllowedTime = 0;
14
13
  const MIN_INTERVAL = 1000; // 1 second for 1 QPS
@@ -66,6 +65,15 @@ function getModelConfig(modelName, baseConfig = {}) {
66
65
  }
67
66
  return config;
68
67
  }
68
+ /**
69
+ * Effective disable-thinking params for a model config. No default: these
70
+ * params are only sent when the user explicitly configures
71
+ * `models[X].disableThinkingOptions` (an empty object clears them), so a
72
+ * gateway that doesn't understand the params is never hit with them.
73
+ */
74
+ function effectiveDisableThinkingOptions(modelConfig) {
75
+ return modelConfig.disableThinkingOptions;
76
+ }
69
77
  function validateModelConfig(modelConfig) {
70
78
  if (!modelConfig.model) {
71
79
  throw new ConfigurationError(CONFIG_ERRORS.MISSING_MODEL, "model", {
@@ -81,7 +89,7 @@ function validateModelConfig(modelConfig) {
81
89
  }
82
90
  }
83
91
  export async function callAgent(options) {
84
- const { gatewayConfig, modelConfig, messages, abortSignal, workdir, tools, model, systemPrompt, onContentUpdate, onToolUpdate, onReasoningUpdate, } = options;
92
+ const { gatewayConfig, modelConfig, messages, abortSignal, workdir, tools, model, systemPrompt, onContentUpdate, onToolUpdate, onReasoningUpdate, disableThinkingOptions, } = options;
85
93
  // Validate model config at call time
86
94
  validateModelConfig(modelConfig);
87
95
  // Apply global 1 QPS rate limit
@@ -147,6 +155,7 @@ export async function callAgent(options) {
147
155
  const openaiModelConfig = getModelConfig(model || modelConfig.model, {
148
156
  max_tokens: resolvedMaxTokens,
149
157
  ...(modelConfig.options || {}),
158
+ ...(disableThinkingOptions ?? {}),
150
159
  });
151
160
  // Determine if streaming is needed
152
161
  const isStreaming = options.stream === true ||
@@ -509,10 +518,16 @@ export async function processWebContent(options) {
509
518
  const activeExtraParams = options.model
510
519
  ? modelConfig.fastModelOptions || {}
511
520
  : modelConfig.options || {};
521
+ // Disable-thinking params only apply to the fast-model override path;
522
+ // the agent-model path is untouched.
523
+ const disableThinking = options.model
524
+ ? effectiveDisableThinkingOptions(modelConfig)
525
+ : undefined;
512
526
  const openaiModelConfig = getModelConfig(options.model || modelConfig.model, {
513
527
  temperature: 0.1,
514
528
  max_tokens: 4096,
515
529
  ...activeExtraParams,
530
+ ...(disableThinking || {}),
516
531
  });
517
532
  try {
518
533
  const response = await openai.chat.completions.create({
@@ -555,130 +570,3 @@ export async function processWebContent(options) {
555
570
  throw error;
556
571
  }
557
572
  }
558
- export async function btw(options) {
559
- const { gatewayConfig, modelConfig, messages, question, abortSignal } = options;
560
- // Validate model config at call time
561
- validateModelConfig(modelConfig);
562
- // Apply global 1 QPS rate limit
563
- if (process.env.NODE_ENV !== "test" ||
564
- modelConfig.model === "rate-limit-test") {
565
- await acquireSlot(abortSignal);
566
- }
567
- // Create OpenAI client with injected configuration
568
- const openai = new OpenAIClient({
569
- apiKey: gatewayConfig.apiKey,
570
- baseURL: gatewayConfig.baseURL,
571
- defaultHeaders: gatewayConfig.defaultHeaders,
572
- fetchOptions: gatewayConfig.fetchOptions,
573
- fetch: gatewayConfig.fetch,
574
- });
575
- const openaiModelConfig = getModelConfig(options.model || modelConfig.model, {
576
- temperature: 0.1,
577
- max_tokens: 4096,
578
- ...(modelConfig.options || {}),
579
- });
580
- try {
581
- const response = await openai.chat.completions.create({
582
- ...openaiModelConfig,
583
- messages: [
584
- {
585
- role: "system",
586
- content: BTW_SYSTEM_PROMPT,
587
- },
588
- ...messages,
589
- {
590
- role: "user",
591
- content: question,
592
- },
593
- ],
594
- }, {
595
- signal: abortSignal,
596
- });
597
- const result = response.choices[0]?.message?.content?.trim();
598
- if (!result) {
599
- throw new Error("Failed to process side question: Empty response from AI");
600
- }
601
- const usage = response.usage
602
- ? {
603
- prompt_tokens: response.usage.prompt_tokens,
604
- completion_tokens: response.usage.completion_tokens,
605
- total_tokens: response.usage.total_tokens,
606
- }
607
- : undefined;
608
- return {
609
- content: result,
610
- usage,
611
- };
612
- }
613
- catch (error) {
614
- if (error.name === "AbortError") {
615
- logger.info("Side question request was aborted");
616
- throw new Error("Side question request was aborted");
617
- }
618
- logger.error("Failed to process side question:", error);
619
- throw error;
620
- }
621
- }
622
- export async function evaluateGoal(options) {
623
- const { gatewayConfig, modelConfig, model, goalCondition, messages, abortSignal, } = options;
624
- // Create OpenAI client with injected configuration (no rate limiter — bypasses 1 QPS)
625
- const openai = new OpenAIClient({
626
- apiKey: gatewayConfig.apiKey,
627
- baseURL: gatewayConfig.baseURL,
628
- defaultHeaders: gatewayConfig.defaultHeaders,
629
- fetchOptions: gatewayConfig.fetchOptions,
630
- fetch: gatewayConfig.fetch,
631
- });
632
- const openaiModelConfig = getModelConfig(model, {
633
- temperature: 0,
634
- max_tokens: 200,
635
- ...(modelConfig.fastModelOptions || {}),
636
- });
637
- // Strip images from messages to reduce token usage (same as compact)
638
- const cleanedMessages = messages.map((msg) => {
639
- if (Array.isArray(msg.content)) {
640
- const textParts = msg.content.filter((part) => part.type === "text");
641
- const text = textParts.map((p) => p.text).join("\n");
642
- return { ...msg, content: text || "(empty message)" };
643
- }
644
- return msg;
645
- });
646
- try {
647
- const response = await openai.chat.completions.create({
648
- ...openaiModelConfig,
649
- messages: [
650
- {
651
- role: "system",
652
- content: GOAL_EVALUATION_SYSTEM_PROMPT,
653
- },
654
- ...cleanedMessages,
655
- {
656
- role: "user",
657
- content: `Goal condition: ${goalCondition}\n\nHas this goal been achieved based on the conversation above?`,
658
- },
659
- ],
660
- }, {
661
- signal: abortSignal,
662
- });
663
- const result = response.choices[0]?.message?.content?.trim();
664
- if (!result) {
665
- throw new Error("Goal evaluation returned empty response");
666
- }
667
- const usage = response.usage
668
- ? {
669
- prompt_tokens: response.usage.prompt_tokens,
670
- completion_tokens: response.usage.completion_tokens,
671
- total_tokens: response.usage.total_tokens,
672
- }
673
- : undefined;
674
- return { content: result, usage };
675
- }
676
- catch (error) {
677
- if (error.name === "AbortError") {
678
- logger.info("Goal evaluation was aborted");
679
- throw new Error("Goal evaluation was aborted");
680
- }
681
- logger.error("Goal evaluation failed:", error);
682
- throw error;
683
- }
684
- }
@@ -7,9 +7,11 @@ export declare class AutoMemoryService {
7
7
  private container;
8
8
  private lastMemoryMessageId;
9
9
  private turnsSinceLastExtraction;
10
+ private extractionInProgress;
11
+ private pendingExtraction;
10
12
  constructor(container: Container);
11
13
  private get messageManager();
12
- private get forkedAgentManager();
14
+ private get aiManager();
13
15
  private get memoryService();
14
16
  private get configurationService();
15
17
  /**
@@ -17,7 +19,30 @@ export declare class AutoMemoryService {
17
19
  */
18
20
  onTurnEnd(workdir: string): Promise<void>;
19
21
  /**
20
- * Initialize and execute the background extraction subagent.
22
+ * Wait for an in-flight extraction to settle. Called from Agent.dispose so
23
+ * the process doesn't exit while the extraction fork is mid-flight.
24
+ */
25
+ drain(): Promise<void>;
26
+ /**
27
+ * Initialize and execute the extraction in a perfect fork: same system
28
+ * prompt, tools, model, and message prefix as the main conversation, so the
29
+ * prompt cache is reused. A tool gate confines the fork to read-only
30
+ * inspection and memory-directory writes. Runs in-process; callers treat it
31
+ * as fire-and-forget.
21
32
  */
22
33
  private runExtraction;
34
+ /**
35
+ * Tool gate for the extraction fork: Read/Grep/Glob are always allowed;
36
+ * Write/Edit only when the target path is inside the memory directory; Bash
37
+ * only for read-only commands (aligned with the permission manager's
38
+ * read-only bash classification). Everything else — Bash rm, MCP tools,
39
+ * Agent, out-of-dir writes — is denied.
40
+ */
41
+ private isAllowedForkTool;
42
+ /**
43
+ * A bash command is read-only when every part is a READ_ONLY_COMMANDS entry
44
+ * without write redirections, command/process substitution, sed -i, or
45
+ * dangerous find flags. Mirrors PermissionManager.isAutoAllowedPart.
46
+ */
47
+ private isReadOnlyBashCommand;
23
48
  }
@@ -3,6 +3,14 @@ import * as fs from "node:fs/promises";
3
3
  import { logger } from "../utils/globalLogger.js";
4
4
  import { isPathInside } from "../utils/pathSafety.js";
5
5
  import { buildAutoMemoryExtractionPrompt } from "../prompts/autoMemoryExtraction.js";
6
+ import { READ_ONLY_COMMANDS, splitBashCommand, hasWriteRedirections, hasCommandSubstitution, hasProcessSubstitution, hasSedInPlace, isDangerousFind, } from "../utils/bashParser.js";
7
+ /**
8
+ * Message fed back to the extraction fork when the model requests a tool
9
+ * outside the allowed set (Bash rm, MCP tools, Agent, out-of-dir writes...).
10
+ */
11
+ const DENIED_TOOL_MESSAGE = "This tool call was denied during auto-memory extraction. Available tools: " +
12
+ "Read, Grep, Glob, read-only Bash commands, and Write/Edit inside the " +
13
+ "memory directory only.";
6
14
  /**
7
15
  * Service responsible for managing the auto-memory background agent lifecycle.
8
16
  * Extracts and updates persistent project-level memory from conversation history.
@@ -12,12 +20,14 @@ export class AutoMemoryService {
12
20
  this.container = container;
13
21
  this.lastMemoryMessageId = null;
14
22
  this.turnsSinceLastExtraction = 0;
23
+ this.extractionInProgress = false;
24
+ this.pendingExtraction = null;
15
25
  }
16
26
  get messageManager() {
17
27
  return this.container.get("MessageManager");
18
28
  }
19
- get forkedAgentManager() {
20
- return this.container.get("ForkedAgentManager");
29
+ get aiManager() {
30
+ return this.container.get("AIManager");
21
31
  }
22
32
  get memoryService() {
23
33
  return this.container.get("MemoryService");
@@ -54,7 +64,12 @@ export class AutoMemoryService {
54
64
  const recentMessages = messages.slice(startIndex);
55
65
  const hasManualMemoryWrite = recentMessages.some((m) => m.role === "assistant" &&
56
66
  m.blocks.some((b) => {
57
- if (b.type === "tool" && (b.name === "Write" || b.name === "Edit")) {
67
+ if (b.type === "tool" &&
68
+ (b.name === "Write" || b.name === "Edit") &&
69
+ // Only a successful manual write counts as a manual update. A
70
+ // denied/failed write didn't touch memory, so the extraction fork
71
+ // must still run or the information is lost.
72
+ b.success !== false) {
58
73
  try {
59
74
  const params = b.parameters ? JSON.parse(b.parameters) : null;
60
75
  const filePath = params?.file_path || params?.path;
@@ -77,20 +92,51 @@ export class AutoMemoryService {
77
92
  this.turnsSinceLastExtraction = 0;
78
93
  return;
79
94
  }
80
- // 3. Trigger background extraction using a forked subagent
81
- try {
82
- await this.runExtraction(workdir, messages);
83
- this.lastMemoryMessageId = messages[messages.length - 1].id || null;
84
- this.turnsSinceLastExtraction = 0;
85
- }
86
- catch (error) {
87
- logger.error("Auto-memory extraction failed to trigger:", error);
95
+ // 3. Concurrency guard: if an extraction is already in flight, skip this
96
+ // turn. turnsSinceLastExtraction is intentionally NOT reset so the next
97
+ // eligible turn retriggers the extraction.
98
+ if (this.extractionInProgress) {
99
+ logger.debug("Skipping auto-memory extraction: another extraction is still in progress.");
100
+ return;
88
101
  }
102
+ // 4. Trigger the perfect-fork extraction fire-and-forget. The message
103
+ // snapshot and new-message count are computed now; lastMemoryMessageId
104
+ // advances at trigger time so the next extraction starts from a later
105
+ // window even while this one runs.
106
+ const lastExtractedIndex = this.lastMemoryMessageId
107
+ ? messages.findIndex((m) => m.id === this.lastMemoryMessageId)
108
+ : -1;
109
+ const newMessageCount = lastExtractedIndex === -1
110
+ ? messages.length
111
+ : messages.length - 1 - lastExtractedIndex;
112
+ this.turnsSinceLastExtraction = 0;
113
+ this.lastMemoryMessageId = messages[messages.length - 1].id || null;
114
+ this.extractionInProgress = true;
115
+ const extraction = this.runExtraction(workdir, messages, newMessageCount)
116
+ .catch((error) => {
117
+ logger.error("Auto-memory extraction failed:", error);
118
+ })
119
+ .finally(() => {
120
+ this.extractionInProgress = false;
121
+ this.pendingExtraction = null;
122
+ });
123
+ this.pendingExtraction = extraction;
124
+ }
125
+ /**
126
+ * Wait for an in-flight extraction to settle. Called from Agent.dispose so
127
+ * the process doesn't exit while the extraction fork is mid-flight.
128
+ */
129
+ async drain() {
130
+ await this.pendingExtraction;
89
131
  }
90
132
  /**
91
- * Initialize and execute the background extraction subagent.
133
+ * Initialize and execute the extraction in a perfect fork: same system
134
+ * prompt, tools, model, and message prefix as the main conversation, so the
135
+ * prompt cache is reused. A tool gate confines the fork to read-only
136
+ * inspection and memory-directory writes. Runs in-process; callers treat it
137
+ * as fire-and-forget.
92
138
  */
93
- async runExtraction(workdir, messages) {
139
+ async runExtraction(workdir, messages, newMessageCount) {
94
140
  const memoryDir = this.memoryService.getAutoMemoryDirectory(workdir);
95
141
  // Ensure memory directory exists before starting
96
142
  await this.memoryService.ensureAutoMemoryDirectory(workdir);
@@ -106,30 +152,72 @@ export class AutoMemoryService {
106
152
  catch {
107
153
  // Ignore if directory doesn't exist yet
108
154
  }
109
- // Calculate how many new messages to analyze
110
- let newMessageCount = messages.length;
111
- if (this.lastMemoryMessageId) {
112
- const lastIndex = messages.findIndex((m) => m.id === this.lastMemoryMessageId);
113
- if (lastIndex !== -1) {
114
- newMessageCount = messages.length - 1 - lastIndex;
115
- }
116
- }
117
155
  const prompt = buildAutoMemoryExtractionPrompt(newMessageCount, existingMemoriesManifest);
118
- // Execute the forked agent in background (fire-and-forget, decoupled from BackgroundTaskManager)
119
- await this.forkedAgentManager.forkAndExecute("general-purpose", messages, {
120
- description: "Auto-memory extraction background agent",
121
- allowedTools: [
122
- "Read",
123
- "Glob",
124
- "Grep",
125
- `Write(${memoryDir}/**/*)`,
126
- `Edit(${memoryDir}/**/*)`,
127
- `Bash(rm ${memoryDir}/**/*)`,
128
- ],
129
- model: "fastModel", // Use fast model for background tasks to reduce latency and cost
130
- permissionModeOverride: "dontAsk", // Auto-deny out-of-scope writes without prompting user
156
+ await this.aiManager.runAutoMemoryFork(messages, `${prompt}\n\nThe memory directory for this project is: ${memoryDir}`, {
131
157
  maxTurns: 5, // Limit turns to prevent verification rabbit-holes
132
- }, `${prompt}\n\nThe memory directory for this project is: ${memoryDir}`);
133
- logger.debug("Auto-memory extraction started in background.");
158
+ canUseTool: (name, args) => this.isAllowedForkTool(name, args, memoryDir, workdir),
159
+ deniedToolMessage: DENIED_TOOL_MESSAGE,
160
+ });
161
+ logger.debug("Auto-memory extraction completed.");
162
+ }
163
+ /**
164
+ * Tool gate for the extraction fork: Read/Grep/Glob are always allowed;
165
+ * Write/Edit only when the target path is inside the memory directory; Bash
166
+ * only for read-only commands (aligned with the permission manager's
167
+ * read-only bash classification). Everything else — Bash rm, MCP tools,
168
+ * Agent, out-of-dir writes — is denied.
169
+ */
170
+ isAllowedForkTool(name, args, memoryDir, workdir) {
171
+ if (name === "Read" || name === "Grep" || name === "Glob") {
172
+ return true;
173
+ }
174
+ if (name === "Write" || name === "Edit") {
175
+ const filePath = args.file_path ?? args.path;
176
+ if (typeof filePath !== "string" || !filePath)
177
+ return false;
178
+ const absolutePath = path.isAbsolute(filePath)
179
+ ? filePath
180
+ : path.resolve(workdir, filePath);
181
+ return isPathInside(absolutePath, memoryDir);
182
+ }
183
+ if (name === "Bash") {
184
+ const command = typeof args.command === "string" ? args.command : "";
185
+ return this.isReadOnlyBashCommand(command);
186
+ }
187
+ return false;
188
+ }
189
+ /**
190
+ * A bash command is read-only when every part is a READ_ONLY_COMMANDS entry
191
+ * without write redirections, command/process substitution, sed -i, or
192
+ * dangerous find flags. Mirrors PermissionManager.isAutoAllowedPart.
193
+ */
194
+ isReadOnlyBashCommand(command) {
195
+ if (!command.trim())
196
+ return false;
197
+ if (hasWriteRedirections(command))
198
+ return false;
199
+ if (hasCommandSubstitution(command))
200
+ return false;
201
+ if (hasProcessSubstitution(command))
202
+ return false;
203
+ if (hasSedInPlace(command))
204
+ return false;
205
+ const parts = splitBashCommand(command);
206
+ if (parts.length === 0)
207
+ return false;
208
+ return parts.every((part) => {
209
+ const trimmed = part.trim();
210
+ if (!trimmed)
211
+ return true;
212
+ const commandMatch = trimmed.match(/^(\w+)(\s+.*)?$/);
213
+ if (!commandMatch)
214
+ return false;
215
+ const cmd = commandMatch[1];
216
+ if (!READ_ONLY_COMMANDS.includes(cmd))
217
+ return false;
218
+ if (cmd === "find" && isDangerousFind(part))
219
+ return false;
220
+ return true;
221
+ });
134
222
  }
135
223
  }
@@ -488,14 +488,27 @@ export class ConfigurationService {
488
488
  if (fastModelSource && fastModelSource.options) {
489
489
  baseConfig.fastModelOptions = fastModelSource.options;
490
490
  }
491
+ // Resolve fast-model disable-thinking params from models[fastModel].disableThinkingOptions
492
+ const fastModelDisableThinking = fastModelSource && fastModelSource.disableThinkingOptions
493
+ ? fastModelSource.disableThinkingOptions
494
+ : undefined;
495
+ if (fastModelDisableThinking) {
496
+ baseConfig.disableThinkingOptions = fastModelDisableThinking;
497
+ }
491
498
  // Merge model-specific settings from configuration
492
499
  const modelSpecificConfig = resolvedAgentModel &&
493
500
  this.currentConfiguration?.models?.[resolvedAgentModel];
494
501
  if (modelSpecificConfig) {
495
- return {
502
+ const resolved = {
496
503
  ...baseConfig,
497
504
  ...modelSpecificConfig,
498
505
  };
506
+ // Re-apply after the spread so the agent model's own
507
+ // disableThinkingOptions cannot clobber the fast-model value.
508
+ if (fastModelDisableThinking) {
509
+ resolved.disableThinkingOptions = fastModelDisableThinking;
510
+ }
511
+ return resolved;
499
512
  }
500
513
  return baseConfig;
501
514
  }
@@ -139,6 +139,19 @@ export declare function cleanupExpiredSessionsFromJsonl(workdir: string): Promis
139
139
  * Clean up empty project directories in the session directory
140
140
  */
141
141
  export declare function cleanupEmptyProjectDirectories(): Promise<void>;
142
+ /**
143
+ * Clean up "ghost" session files that contain only meta messages
144
+ * (isMeta: true) — e.g. sessions where a SessionStart hook injected a
145
+ * system-reminder but no real user/assistant message was ever sent.
146
+ *
147
+ * Such sessions are no longer created thanks to lazy materialization in
148
+ * saveSession(); this one-time sweep removes files that predate that change
149
+ * so they stop showing up as "0 tokens / No content" entries in the resume
150
+ * list.
151
+ *
152
+ * @returns Promise that resolves to the number of files deleted
153
+ */
154
+ export declare function cleanupMetaOnlySessions(): Promise<number>;
142
155
  /**
143
156
  * Check if a session exists in JSONL storage (new approach)
144
157
  *