| import memoize from 'lodash-es/memoize.js' | |
| import { homedir } from 'os' | |
| import { isAbsolute, join, normalize, sep } from 'path' | |
| import { | |
| getIsNonInteractiveSession, | |
| getProjectRoot, | |
| } from '../bootstrap/state.js' | |
| import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' | |
| import { | |
| getClaudeConfigHomeDir, | |
| isEnvDefinedFalsy, | |
| isEnvTruthy, | |
| } from '../utils/envUtils.js' | |
| import { findCanonicalGitRoot } from '../utils/git.js' | |
| import { sanitizePath } from '../utils/path.js' | |
| import { | |
| getInitialSettings, | |
| getSettingsForSource, | |
| } from '../utils/settings/settings.js' | |
| /** | |
| * Whether auto-memory features are enabled (memdir, agent memory, past session search). | |
| * Enabled by default. Priority chain (first defined wins): | |
| * 1. CLAUDE_CODE_DISABLE_AUTO_MEMORY env var (1/true → OFF, 0/false → ON) | |
| * 2. CLAUDE_CODE_SIMPLE (--bare) → OFF | |
| * 3. CCR without persistent storage → OFF (no CLAUDE_CODE_REMOTE_MEMORY_DIR) | |
| * 4. autoMemoryEnabled in settings.json (supports project-level opt-out) | |
| * 5. Default: enabled | |
| */ | |
| export function isAutoMemoryEnabled(): boolean { | |
| const envVal = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY | |
| if (isEnvTruthy(envVal)) { | |
| return false | |
| } | |
| if (isEnvDefinedFalsy(envVal)) { | |
| return true | |
| } | |
| // --bare / SIMPLE: prompts.ts already drops the memory section from the | |
| // system prompt via its SIMPLE early-return; this gate stops the other half | |
| // (extractMemories turn-end fork, autoDream, /remember, /dream, team sync). | |
| if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { | |
| return false | |
| } | |
| if ( | |
| isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && | |
| !process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR | |
| ) { | |
| return false | |
| } | |
| const settings = getInitialSettings() | |
| if (settings.autoMemoryEnabled !== undefined) { | |
| return settings.autoMemoryEnabled | |
| } | |
| return true | |
| } | |
| /** | |
| * Whether the extract-memories background agent will run this session. | |
| * | |
| * The main agent's prompt always has full save instructions regardless of | |
| * this gate — when the main agent writes memories, the background agent | |
| * skips that range (hasMemoryWritesSince in extractMemories.ts); when it | |
| * doesn't, the background agent catches anything missed. | |
| * | |
| * Callers must also gate on feature('EXTRACT_MEMORIES') — that check cannot | |
| * live inside this helper because feature() only tree-shakes when used | |
| * directly in an `if` condition. | |
| */ | |
| export function isExtractModeActive(): boolean { | |
| if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) { | |
| return false | |
| } | |
| return ( | |
| !getIsNonInteractiveSession() || | |
| getFeatureValue_CACHED_MAY_BE_STALE('tengu_slate_thimble', false) | |
| ) | |
| } | |
| /** | |
| * Returns the base directory for persistent memory storage. | |
| * Resolution order: | |
| * 1. CLAUDE_CODE_REMOTE_MEMORY_DIR env var (explicit override, set in CCR) | |
| * 2. ~/.claude (default config home) | |
| */ | |
| export function getMemoryBaseDir(): string { | |
| if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) { | |
| return process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR | |
| } | |
| return getClaudeConfigHomeDir() | |
| } | |
| const AUTO_MEM_DIRNAME = 'memory' | |
| const AUTO_MEM_ENTRYPOINT_NAME = 'MEMORY.md' | |
| /** | |
| * Normalize and validate a candidate auto-memory directory path. | |
| * | |
| * SECURITY: Rejects paths that would be dangerous as a read-allowlist root | |
| * or that normalize() doesn't fully resolve: | |
| * - relative (!isAbsolute): "../foo" — would be interpreted relative to CWD | |
| * - root/near-root (length < 3): "/" → "" after strip; "/a" too short | |
| * - Windows drive-root (C: regex): "C:\" → "C:" after strip | |
| * - UNC paths (\\server\share): network paths — opaque trust boundary | |
| * - null byte: survives normalize(), can truncate in syscalls | |
| * | |
| * Returns the normalized path with exactly one trailing separator, | |
| * or undefined if the path is unset/empty/rejected. | |
| */ | |
| function validateMemoryPath( | |
| raw: string | undefined, | |
| expandTilde: boolean, | |
| ): string | undefined { | |
| if (!raw) { | |
| return undefined | |
| } | |
| let candidate = raw | |
| // Settings.json paths support ~/ expansion (user-friendly). The env var | |
| // override does not (it's set programmatically by Cowork/SDK, which should | |
| // always pass absolute paths). Bare "~", "~/", "~/.", "~/..", etc. are NOT | |
| // expanded — they would make isAutoMemPath() match all of $HOME or its | |
| // parent (same class of danger as "/" or "C:\"). | |
| if ( | |
| expandTilde && | |
| (candidate.startsWith('~/') || candidate.startsWith('~\\')) | |
| ) { | |
| const rest = candidate.slice(2) | |
| // Reject trivial remainders that would expand to $HOME or an ancestor. | |
| // normalize('') = '.', normalize('.') = '.', normalize('foo/..') = '.', | |
| // normalize('..') = '..', normalize('foo/../..') = '..' | |
| const restNorm = normalize(rest || '.') | |
| if (restNorm === '.' || restNorm === '..') { | |
| return undefined | |
| } | |
| candidate = join(homedir(), rest) | |
| } | |
| // normalize() may preserve a trailing separator; strip before adding | |
| // exactly one to match the trailing-sep contract of getAutoMemPath() | |
| const normalized = normalize(candidate).replace(/[/\\]+$/, '') | |
| if ( | |
| !isAbsolute(normalized) || | |
| normalized.length < 3 || | |
| /^[A-Za-z]:$/.test(normalized) || | |
| normalized.startsWith('\\\\') || | |
| normalized.startsWith('//') || | |
| normalized.includes('\0') | |
| ) { | |
| return undefined | |
| } | |
| return (normalized + sep).normalize('NFC') | |
| } | |
| /** | |
| * Direct override for the full auto-memory directory path via env var. | |
| * When set, getAutoMemPath()/getAutoMemEntrypoint() return this path directly | |
| * instead of computing `{base}/projects/{sanitized-cwd}/memory/`. | |
| * | |
| * Used by Cowork to redirect memory to a space-scoped mount where the | |
| * per-session cwd (which contains the VM process name) would otherwise | |
| * produce a different project-key for every session. | |
| */ | |
| function getAutoMemPathOverride(): string | undefined { | |
| return validateMemoryPath( | |
| process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, | |
| false, | |
| ) | |
| } | |
| /** | |
| * Settings.json override for the full auto-memory directory path. | |
| * Supports ~/ expansion for user convenience. | |
| * | |
| * SECURITY: projectSettings (.claude/settings.json committed to the repo) is | |
| * intentionally excluded — a malicious repo could otherwise set | |
| * autoMemoryDirectory: "~/.ssh" and gain silent write access to sensitive | |
| * directories via the filesystem.ts write carve-out (which fires when | |
| * isAutoMemPath() matches and hasAutoMemPathOverride() is false). This follows | |
| * the same pattern as hasSkipDangerousModePermissionPrompt() etc. | |
| */ | |
| function getAutoMemPathSetting(): string | undefined { | |
| const dir = | |
| getSettingsForSource('policySettings')?.autoMemoryDirectory ?? | |
| getSettingsForSource('flagSettings')?.autoMemoryDirectory ?? | |
| getSettingsForSource('localSettings')?.autoMemoryDirectory ?? | |
| getSettingsForSource('userSettings')?.autoMemoryDirectory | |
| return validateMemoryPath(dir, true) | |
| } | |
| /** | |
| * Check if CLAUDE_COWORK_MEMORY_PATH_OVERRIDE is set to a valid override. | |
| * Use this as a signal that the SDK caller has explicitly opted into | |
| * the auto-memory mechanics — e.g. to decide whether to inject the | |
| * memory prompt when a custom system prompt replaces the default. | |
| */ | |
| export function hasAutoMemPathOverride(): boolean { | |
| return getAutoMemPathOverride() !== undefined | |
| } | |
| /** | |
| * Returns the canonical git repo root if available, otherwise falls back to | |
| * the stable project root. Uses findCanonicalGitRoot so all worktrees of the | |
| * same repo share one auto-memory directory (anthropics/claude-code#24382). | |
| */ | |
| function getAutoMemBase(): string { | |
| return findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot() | |
| } | |
| /** | |
| * Returns the auto-memory directory path. | |
| * | |
| * Resolution order: | |
| * 1. CLAUDE_COWORK_MEMORY_PATH_OVERRIDE env var (full-path override, used by Cowork) | |
| * 2. autoMemoryDirectory in settings.json (trusted sources only: policy/local/user) | |
| * 3. <memoryBase>/projects/<sanitized-git-root>/memory/ | |
| * where memoryBase is resolved by getMemoryBaseDir() | |
| * | |
| * Memoized: render-path callers (collapseReadSearchGroups → isAutoManagedMemoryFile) | |
| * fire per tool-use message per Messages re-render; each miss costs | |
| * getSettingsForSource × 4 → parseSettingsFile (realpathSync + readFileSync). | |
| * Keyed on projectRoot so tests that change its mock mid-block recompute; | |
| * env vars / settings.json / CLAUDE_CONFIG_DIR are session-stable in | |
| * production and covered by per-test cache.clear. | |
| */ | |
| export const getAutoMemPath = memoize( | |
| (): string => { | |
| const override = getAutoMemPathOverride() ?? getAutoMemPathSetting() | |
| if (override) { | |
| return override | |
| } | |
| const projectsDir = join(getMemoryBaseDir(), 'projects') | |
| return ( | |
| join(projectsDir, sanitizePath(getAutoMemBase()), AUTO_MEM_DIRNAME) + sep | |
| ).normalize('NFC') | |
| }, | |
| () => getProjectRoot(), | |
| ) | |
| /** | |
| * Returns the daily log file path for the given date (defaults to today). | |
| * Shape: <autoMemPath>/logs/YYYY/MM/YYYY-MM-DD.md | |
| * | |
| * Used by assistant mode (feature('KAIROS')): rather than maintaining | |
| * MEMORY.md as a live index, the agent appends to a date-named log file | |
| * as it works. A separate nightly /dream skill distills these logs into | |
| * topic files + MEMORY.md. | |
| */ | |
| export function getAutoMemDailyLogPath(date: Date = new Date()): string { | |
| const yyyy = date.getFullYear().toString() | |
| const mm = (date.getMonth() + 1).toString().padStart(2, '0') | |
| const dd = date.getDate().toString().padStart(2, '0') | |
| return join(getAutoMemPath(), 'logs', yyyy, mm, `${yyyy}-${mm}-${dd}.md`) | |
| } | |
| /** | |
| * Returns the auto-memory entrypoint (MEMORY.md inside the auto-memory dir). | |
| * Follows the same resolution order as getAutoMemPath(). | |
| */ | |
| export function getAutoMemEntrypoint(): string { | |
| return join(getAutoMemPath(), AUTO_MEM_ENTRYPOINT_NAME) | |
| } | |
| /** | |
| * Check if an absolute path is within the auto-memory directory. | |
| * | |
| * When CLAUDE_COWORK_MEMORY_PATH_OVERRIDE is set, this matches against the | |
| * env-var override directory. Note that a true return here does NOT imply | |
| * write permission in that case — the filesystem.ts write carve-out is gated | |
| * on !hasAutoMemPathOverride() (it exists to bypass DANGEROUS_DIRECTORIES). | |
| * | |
| * The settings.json autoMemoryDirectory DOES get the write carve-out: it's the | |
| * user's explicit choice from a trusted settings source (projectSettings is | |
| * excluded — see getAutoMemPathSetting), and hasAutoMemPathOverride() remains | |
| * false for it. | |
| */ | |
| export function isAutoMemPath(absolutePath: string): boolean { | |
| // SECURITY: Normalize to prevent path traversal bypasses via .. segments | |
| const normalizedPath = normalize(absolutePath) | |
| return normalizedPath.startsWith(getAutoMemPath()) | |
| } | |