/ tools / AgentTool / agentMemory.ts
agentMemory.ts
  1  import { join, normalize, sep } from 'path'
  2  import { getProjectRoot } from '../../bootstrap/state.js'
  3  import {
  4    buildMemoryPrompt,
  5    ensureMemoryDirExists,
  6  } from '../../memdir/memdir.js'
  7  import { getMemoryBaseDir } from '../../memdir/paths.js'
  8  import { getCwd } from '../../utils/cwd.js'
  9  import { findCanonicalGitRoot } from '../../utils/git.js'
 10  import { sanitizePath } from '../../utils/path.js'
 11  
 12  // Persistent agent memory scope: 'user' (~/.claude/agent-memory/), 'project' (.claude/agent-memory/), or 'local' (.claude/agent-memory-local/)
 13  export type AgentMemoryScope = 'user' | 'project' | 'local'
 14  
 15  /**
 16   * Sanitize an agent type name for use as a directory name.
 17   * Replaces colons (invalid on Windows, used in plugin-namespaced agent
 18   * types like "my-plugin:my-agent") with dashes.
 19   */
 20  function sanitizeAgentTypeForPath(agentType: string): string {
 21    return agentType.replace(/:/g, '-')
 22  }
 23  
 24  /**
 25   * Returns the local agent memory directory, which is project-specific and not checked into VCS.
 26   * When CLAUDE_CODE_REMOTE_MEMORY_DIR is set, persists to the mount with project namespacing.
 27   * Otherwise, uses <cwd>/.claude/agent-memory-local/<agentType>/.
 28   */
 29  function getLocalAgentMemoryDir(dirName: string): string {
 30    if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
 31      return (
 32        join(
 33          process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR,
 34          'projects',
 35          sanitizePath(
 36            findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot(),
 37          ),
 38          'agent-memory-local',
 39          dirName,
 40        ) + sep
 41      )
 42    }
 43    return join(getCwd(), '.claude', 'agent-memory-local', dirName) + sep
 44  }
 45  
 46  /**
 47   * Returns the agent memory directory for a given agent type and scope.
 48   * - 'user' scope: <memoryBase>/agent-memory/<agentType>/
 49   * - 'project' scope: <cwd>/.claude/agent-memory/<agentType>/
 50   * - 'local' scope: see getLocalAgentMemoryDir()
 51   */
 52  export function getAgentMemoryDir(
 53    agentType: string,
 54    scope: AgentMemoryScope,
 55  ): string {
 56    const dirName = sanitizeAgentTypeForPath(agentType)
 57    switch (scope) {
 58      case 'project':
 59        return join(getCwd(), '.claude', 'agent-memory', dirName) + sep
 60      case 'local':
 61        return getLocalAgentMemoryDir(dirName)
 62      case 'user':
 63        return join(getMemoryBaseDir(), 'agent-memory', dirName) + sep
 64    }
 65  }
 66  
 67  // Check if file is within an agent memory directory (any scope).
 68  export function isAgentMemoryPath(absolutePath: string): boolean {
 69    // SECURITY: Normalize to prevent path traversal bypasses via .. segments
 70    const normalizedPath = normalize(absolutePath)
 71    const memoryBase = getMemoryBaseDir()
 72  
 73    // User scope: check memory base (may be custom dir or config home)
 74    if (normalizedPath.startsWith(join(memoryBase, 'agent-memory') + sep)) {
 75      return true
 76    }
 77  
 78    // Project scope: always cwd-based (not redirected)
 79    if (
 80      normalizedPath.startsWith(join(getCwd(), '.claude', 'agent-memory') + sep)
 81    ) {
 82      return true
 83    }
 84  
 85    // Local scope: persisted to mount when CLAUDE_CODE_REMOTE_MEMORY_DIR is set, otherwise cwd-based
 86    if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
 87      if (
 88        normalizedPath.includes(sep + 'agent-memory-local' + sep) &&
 89        normalizedPath.startsWith(
 90          join(process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR, 'projects') + sep,
 91        )
 92      ) {
 93        return true
 94      }
 95    } else if (
 96      normalizedPath.startsWith(
 97        join(getCwd(), '.claude', 'agent-memory-local') + sep,
 98      )
 99    ) {
100      return true
101    }
102  
103    return false
104  }
105  
106  /**
107   * Returns the agent memory file path for a given agent type and scope.
108   */
109  export function getAgentMemoryEntrypoint(
110    agentType: string,
111    scope: AgentMemoryScope,
112  ): string {
113    return join(getAgentMemoryDir(agentType, scope), 'MEMORY.md')
114  }
115  
116  export function getMemoryScopeDisplay(
117    memory: AgentMemoryScope | undefined,
118  ): string {
119    switch (memory) {
120      case 'user':
121        return `User (${join(getMemoryBaseDir(), 'agent-memory')}/)`
122      case 'project':
123        return 'Project (.claude/agent-memory/)'
124      case 'local':
125        return `Local (${getLocalAgentMemoryDir('...')})`
126      default:
127        return 'None'
128    }
129  }
130  
131  /**
132   * Load persistent memory for an agent with memory enabled.
133   * Creates the memory directory if needed and returns a prompt with memory contents.
134   *
135   * @param agentType The agent's type name (used as directory name)
136   * @param scope 'user' for ~/.claude/agent-memory/ or 'project' for .claude/agent-memory/
137   */
138  export function loadAgentMemoryPrompt(
139    agentType: string,
140    scope: AgentMemoryScope,
141  ): string {
142    let scopeNote: string
143    switch (scope) {
144      case 'user':
145        scopeNote =
146          '- Since this memory is user-scope, keep learnings general since they apply across all projects'
147        break
148      case 'project':
149        scopeNote =
150          '- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project'
151        break
152      case 'local':
153        scopeNote =
154          '- Since this memory is local-scope (not checked into version control), tailor your memories to this project and machine'
155        break
156    }
157  
158    const memoryDir = getAgentMemoryDir(agentType, scope)
159  
160    // Fire-and-forget: this runs at agent-spawn time inside a sync
161    // getSystemPrompt() callback (called from React render in AgentDetail.tsx,
162    // so it cannot be async). The spawned agent won't try to Write until after
163    // a full API round-trip, by which time mkdir will have completed. Even if
164    // it hasn't, FileWriteTool does its own mkdir of the parent directory.
165    void ensureMemoryDirExists(memoryDir)
166  
167    const coworkExtraGuidelines =
168      process.env.CLAUDE_COWORK_MEMORY_EXTRA_GUIDELINES
169    return buildMemoryPrompt({
170      displayName: 'Persistent Agent Memory',
171      memoryDir,
172      extraGuidelines:
173        coworkExtraGuidelines && coworkExtraGuidelines.trim().length > 0
174          ? [scopeNote, coworkExtraGuidelines]
175          : [scopeNote],
176    })
177  }