279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
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())
|
||
}
|