+
+ {t('slash.inspector.context.memoryFiles')}
+
+
+
{t('slash.inspector.context.noMemoryFiles')}
+
+ )
+ }
+
+ return (
+
@@ -509,6 +579,7 @@ function ContextTab({
return (
+
)
diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index 36a1611b..b243fb86 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -6,6 +6,7 @@ import { sessionsApi } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
+import { useUIStore } from '../../stores/uiStore'
import type { UIMessage } from '../../types/chat'
import type { PerSessionState } from '../../stores/chatStore'
@@ -46,6 +47,7 @@ describe('MessageList nested tool calls', () => {
beforeEach(() => {
vi.restoreAllMocks()
useSettingsStore.setState({ locale: 'en' })
+ useUIStore.setState({ pendingSettingsTab: null })
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation(
@@ -139,6 +141,39 @@ describe('MessageList nested tool calls', () => {
expect(container.querySelectorAll('[data-message-shell="assistant"]')).toHaveLength(0)
})
+ it('renders saved memory events with an entrypoint to memory settings', () => {
+ useChatStore.setState({
+ sessions: {
+ [ACTIVE_TAB]: makeSessionState({
+ messages: [
+ {
+ id: 'memory-1',
+ type: 'memory_event',
+ event: 'saved',
+ files: [
+ { path: '/Users/test/.claude/projects/example/memory/preferences.md', action: 'saved' },
+ ],
+ timestamp: 1,
+ },
+ ],
+ }),
+ },
+ })
+
+ render(
)
+
+ expect(screen.getByText('Saved 1 memory file(s)')).toBeTruthy()
+ expect(screen.getByText('preferences.md')).toBeTruthy()
+
+ const openButton = screen.getByText('Open Memory').closest('button')
+ expect(openButton).toBeTruthy()
+ fireEvent.click(openButton!)
+
+ expect(useUIStore.getState().pendingSettingsTab).toBe('memory')
+ expect(useUIStore.getState().pendingMemoryPath).toBe('/Users/test/.claude/projects/example/memory/preferences.md')
+ expect(useTabStore.getState().activeTabId).toBe('__settings__')
+ })
+
it('keeps root tool runs split when nested child tool calls appear between them', () => {
const messages: UIMessage[] = [
{
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index 914e8b27..2b2dc1a8 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -1,9 +1,9 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect } from 'react'
-import { ArrowDown } from 'lucide-react'
+import { ArrowDown, BookMarked, Settings } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
-import { useTabStore } from '../../stores/tabStore'
+import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
@@ -24,6 +24,7 @@ import { ConfirmDialog } from '../shared/ConfirmDialog'
type ToolCall = Extract
type ToolResult = Extract
+type MemoryEvent = Extract
type RenderItem =
| { kind: 'tool_group'; toolCalls: ToolCall[]; id: string }
@@ -241,6 +242,70 @@ function normalizeTurnCheckpoints(response: unknown): SessionTurnCheckpoint[] {
return checkpoints.filter(isSessionTurnCheckpoint)
}
+function memoryFileLabel(path: string) {
+ const normalized = path.replace(/\\/g, '/')
+ return normalized.split('/').pop() || normalized
+}
+
+function openMemorySettings(path?: string) {
+ const ui = useUIStore.getState()
+ if (path) ui.setPendingMemoryPath(path)
+ ui.setPendingSettingsTab('memory')
+ useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
+}
+
+function MemoryEventCard({ message }: { message: MemoryEvent }) {
+ const t = useTranslation()
+ const visibleFiles = message.files.slice(0, 3)
+ const hiddenCount = Math.max(0, message.files.length - visibleFiles.length)
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t('chat.memorySavedTitle', { count: message.files.length })}
+
+
+
+ {message.message ? (
+
{message.message}
+ ) : null}
+
+ {visibleFiles.map((file) => (
+
+ {memoryFileLabel(file.path)}
+
+ ))}
+ {hiddenCount > 0 ? (
+
+ {t('chat.memoryMoreFiles', { count: hiddenCount })}
+
+ ) : null}
+
+
+
+
+
+ )
+}
+
type MessageListProps = {
sessionId?: string | null
compact?: boolean
@@ -742,6 +807,8 @@ export const MessageBlock = memo(function MessageBlock({
}
case 'task_summary':
return
+ case 'memory_event':
+ return
case 'system':
return (
diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts
index 2900a66a..79c47166 100644
--- a/desktop/src/components/chat/composerUtils.test.ts
+++ b/desktop/src/components/chat/composerUtils.test.ts
@@ -57,8 +57,10 @@ describe('composerUtils', () => {
it('resolves hidden settings aliases without displaying duplicate fallback rows', () => {
expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' })
+ expect(resolveSlashUiAction('memory')).toEqual({ type: 'settings', tab: 'memory' })
expect(resolveSlashUiAction('doctor')).toEqual({ type: 'settings', tab: 'diagnostics' })
expect(mergeSlashCommands([]).map((command) => command.name)).toContain('plugin')
+ expect(mergeSlashCommands([]).map((command) => command.name)).toContain('memory')
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('plugins')
})
diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts
index 7a896690..78485172 100644
--- a/desktop/src/components/chat/composerUtils.ts
+++ b/desktop/src/components/chat/composerUtils.ts
@@ -11,6 +11,7 @@ export const PANEL_SLASH_COMMANDS = [
export const SETTINGS_SLASH_COMMANDS = [
{ name: 'plugin', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const },
+ { name: 'memory', description: 'Open project memory files in Settings', tab: 'memory' as const },
{ name: 'doctor', description: 'Open Doctor in Diagnostics', tab: 'diagnostics' as const },
] as const
@@ -31,7 +32,6 @@ export const FALLBACK_SLASH_COMMANDS = [
{ name: 'config', description: 'Open configuration' },
{ name: 'login', description: 'Switch Anthropic accounts' },
{ name: 'logout', description: 'Sign out of current account' },
- { name: 'memory', description: 'Edit CLAUDE.md memory files' },
{ name: 'model', description: 'Switch AI model' },
{ name: 'permissions', description: 'View or manage tool permissions' },
{ name: 'terminal-setup', description: 'Set up terminal integration' },
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index e1a33b42..127be692 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -540,6 +540,30 @@ export const en = {
'settings.skills.source.mcp': 'MCP',
'settings.skills.source.bundled': 'Built-in',
+ // Settings > Memory
+ 'settings.tab.memory': 'Memory',
+ 'settings.memory.title': 'Project Memory',
+ 'settings.memory.description': 'Inspect and edit the Markdown memory files Claude writes for each project. These files are stored under ~/.claude/projects/
/memory/ and are loaded by the CLI runtime.',
+ 'settings.memory.refresh': 'Refresh',
+ 'settings.memory.projects': 'Projects',
+ 'settings.memory.files': 'Memory files',
+ 'settings.memory.editor': 'Editor',
+ 'settings.memory.preview': 'Preview',
+ 'settings.memory.newPathPlaceholder': 'MEMORY.md or notes/project.md',
+ 'settings.memory.newFile': 'New',
+ 'settings.memory.emptyProjects': 'No memory projects found.',
+ 'settings.memory.emptyFiles': 'No memory files yet. Create MEMORY.md to start.',
+ 'settings.memory.selectFile': 'Select or create a memory file.',
+ 'settings.memory.selectProject': 'Select a project.',
+ 'settings.memory.noFileSelected': 'No file selected',
+ 'settings.memory.current': 'Current',
+ 'settings.memory.fileCount': '{count} files',
+ 'settings.memory.unsaved': 'Unsaved',
+ 'settings.memory.saved': 'Saved',
+ 'settings.memory.revert': 'Revert',
+ 'settings.memory.invalidPath': 'Use a relative .md path without .. segments.',
+ 'settings.memory.fileExists': 'A memory file already exists at that path.',
+
// Settings > Plugins
'settings.plugins.title': 'Installed Plugins',
'settings.plugins.description': 'Inspect installed plugins, see their health, and apply changes to the desktop runtime.',
@@ -892,6 +916,9 @@ export const en = {
'slash.inspector.context.categoryTitle': 'Estimated usage by category',
'slash.inspector.context.noCategoriesTitle': 'No context categories',
'slash.inspector.context.noCategoriesBody': 'Context categories will appear after the CLI reports context analysis.',
+ 'slash.inspector.context.memoryFiles': 'Memory files',
+ 'slash.inspector.context.noMemoryFiles': 'No memory files are loaded in this session.',
+ 'slash.inspector.context.openMemory': 'Open Memory',
'contextIndicator.ariaLabel': 'Context usage {percent}',
'contextIndicator.pendingAria': 'Context usage not calculated',
'contextIndicator.loadingAria': 'Context usage loading',
@@ -914,6 +941,9 @@ export const en = {
'chat.dismiss': 'dismiss',
'chat.stopTitle': 'Stop generation (Cmd+.)',
'chat.jumpToLatest': 'Latest',
+ 'chat.memorySavedTitle': 'Saved {count} memory file(s)',
+ 'chat.memoryOpenSettings': 'Open Memory',
+ 'chat.memoryMoreFiles': '+{count} more',
'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.',
'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.',
'chat.turnChangesTitle': '{count} files changed',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 7fc9a244..809799d1 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -542,6 +542,30 @@ export const zh: Record = {
'settings.skills.source.mcp': 'MCP',
'settings.skills.source.bundled': '内置',
+ // Settings > Memory
+ 'settings.tab.memory': '记忆',
+ 'settings.memory.title': '项目记忆',
+ 'settings.memory.description': '查看和编辑 Claude 为每个项目写入的 Markdown 记忆文件。这些文件存放在 ~/.claude/projects//memory/,会被 CLI 运行时加载。',
+ 'settings.memory.refresh': '刷新',
+ 'settings.memory.projects': '项目',
+ 'settings.memory.files': '记忆文件',
+ 'settings.memory.editor': '编辑',
+ 'settings.memory.preview': '预览',
+ 'settings.memory.newPathPlaceholder': 'MEMORY.md 或 notes/project.md',
+ 'settings.memory.newFile': '新建',
+ 'settings.memory.emptyProjects': '还没有找到记忆项目。',
+ 'settings.memory.emptyFiles': '还没有记忆文件。可以先创建 MEMORY.md。',
+ 'settings.memory.selectFile': '选择或创建一个记忆文件。',
+ 'settings.memory.selectProject': '选择一个项目。',
+ 'settings.memory.noFileSelected': '未选择文件',
+ 'settings.memory.current': '当前',
+ 'settings.memory.fileCount': '{count} 个文件',
+ 'settings.memory.unsaved': '未保存',
+ 'settings.memory.saved': '已保存',
+ 'settings.memory.revert': '还原',
+ 'settings.memory.invalidPath': '请使用相对 .md 路径,且不能包含 .. 片段。',
+ 'settings.memory.fileExists': '该路径下已经有记忆文件。',
+
// Settings > Plugins
'settings.plugins.title': '已安装插件',
'settings.plugins.description': '查看已安装插件、运行状态,并把插件变更应用到桌面端运行时。',
@@ -894,6 +918,9 @@ export const zh: Record = {
'slash.inspector.context.categoryTitle': '按类别估算',
'slash.inspector.context.noCategoriesTitle': '暂无上下文分类',
'slash.inspector.context.noCategoriesBody': 'CLI 返回上下文分析后,这里会显示分类数据。',
+ 'slash.inspector.context.memoryFiles': '记忆文件',
+ 'slash.inspector.context.noMemoryFiles': '当前会话没有加载记忆文件。',
+ 'slash.inspector.context.openMemory': '打开记忆',
'contextIndicator.ariaLabel': '上下文用量 {percent}',
'contextIndicator.pendingAria': '上下文用量待计算',
'contextIndicator.loadingAria': '上下文用量加载中',
@@ -916,6 +943,9 @@ export const zh: Record = {
'chat.dismiss': '关闭',
'chat.stopTitle': '停止生成 (Cmd+.)',
'chat.jumpToLatest': '回到最新',
+ 'chat.memorySavedTitle': '已保存 {count} 个记忆文件',
+ 'chat.memoryOpenSettings': '打开记忆',
+ 'chat.memoryMoreFiles': '还有 {count} 个',
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
'chat.turnChangesTitle': '{count} 个文件已更改',
diff --git a/desktop/src/pages/MemorySettings.tsx b/desktop/src/pages/MemorySettings.tsx
new file mode 100644
index 00000000..7a4cb319
--- /dev/null
+++ b/desktop/src/pages/MemorySettings.tsx
@@ -0,0 +1,452 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Button } from '../components/shared/Button'
+import { Input } from '../components/shared/Input'
+import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
+import { useTranslation } from '../i18n'
+import { formatBytes } from '../lib/formatBytes'
+import { useMemoryStore } from '../stores/memoryStore'
+import { useSessionStore } from '../stores/sessionStore'
+import { useUIStore } from '../stores/uiStore'
+import type { MemoryFile, MemoryProject } from '../types/memory'
+
+const DEFAULT_MEMORY_PATH = 'MEMORY.md'
+
+export function MemorySettings() {
+ const t = useTranslation()
+ const {
+ projects,
+ files,
+ selectedProjectId,
+ selectedFile,
+ draftContent,
+ isLoadingProjects,
+ isLoadingFiles,
+ isLoadingFile,
+ isSaving,
+ error,
+ lastSavedAt,
+ fetchProjects,
+ selectProject,
+ fetchFiles,
+ openFile,
+ updateDraft,
+ saveFile,
+ createFile,
+ } = useMemoryStore()
+ const sessions = useSessionStore((s) => s.sessions)
+ const activeSessionId = useSessionStore((s) => s.activeSessionId)
+ const pendingMemoryPath = useUIStore((s) => s.pendingMemoryPath)
+ const setPendingMemoryPath = useUIStore((s) => s.setPendingMemoryPath)
+ const [newPath, setNewPath] = useState('')
+ const [newPathError, setNewPathError] = useState(null)
+
+ const activeSession = useMemo(
+ () => sessions.find((session) => session.id === activeSessionId),
+ [activeSessionId, sessions],
+ )
+ const activeCwd = activeSession?.workDir || activeSession?.projectPath || undefined
+ const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null
+ const isDirty = Boolean(selectedFile && draftContent !== selectedFile.content)
+
+ useEffect(() => {
+ void fetchProjects(activeCwd)
+ }, [activeCwd, fetchProjects])
+
+ useEffect(() => {
+ if (!selectedProjectId) return
+ void fetchFiles(selectedProjectId)
+ }, [fetchFiles, selectedProjectId])
+
+ useEffect(() => {
+ if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
+ if (pendingMemoryPath) return
+ const firstFile = files[0]
+ if (firstFile) {
+ void openFile(selectedProjectId, firstFile.path)
+ }
+ }, [files, isLoadingFile, isLoadingFiles, openFile, pendingMemoryPath, selectedFile, selectedProjectId])
+
+ useEffect(() => {
+ if (!pendingMemoryPath || isLoadingProjects || projects.length === 0) return
+ const target = resolveMemoryFileTarget(projects, pendingMemoryPath)
+ if (!target) {
+ setPendingMemoryPath(null)
+ return
+ }
+ if (selectedProjectId !== target.projectId) {
+ selectProject(target.projectId)
+ return
+ }
+ if (selectedFile?.path === target.path && !isLoadingFile) {
+ setPendingMemoryPath(null)
+ return
+ }
+ void openFile(target.projectId, target.path).then(() => {
+ setPendingMemoryPath(null)
+ })
+ }, [
+ isLoadingFile,
+ isLoadingProjects,
+ openFile,
+ pendingMemoryPath,
+ projects,
+ selectProject,
+ selectedFile?.path,
+ selectedProjectId,
+ setPendingMemoryPath,
+ ])
+
+ const handleRefresh = () => {
+ void fetchProjects(activeCwd)
+ if (selectedProjectId) {
+ void fetchFiles(selectedProjectId)
+ }
+ }
+
+ const handleProjectSelect = (projectId: string) => {
+ if (projectId === selectedProjectId) return
+ selectProject(projectId)
+ }
+
+ const handleFileOpen = (file: MemoryFile) => {
+ if (!selectedProjectId || file.path === selectedFile?.path) return
+ void openFile(selectedProjectId, file.path)
+ }
+
+ const handleCreate = () => {
+ if (!selectedProjectId) return
+ const path = normalizeMemoryPath(newPath || DEFAULT_MEMORY_PATH)
+ if (!isValidMemoryPath(path)) {
+ setNewPathError(t('settings.memory.invalidPath'))
+ return
+ }
+ if (files.some((file) => file.path === path)) {
+ setNewPathError(t('settings.memory.fileExists'))
+ return
+ }
+ setNewPath('')
+ setNewPathError(null)
+ void createFile(selectedProjectId, path, buildMemoryTemplate(path))
+ }
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ {projects.length === 0 && !isLoadingProjects ? (
+
+ ) : (
+ projects.map((project) => (
+
handleProjectSelect(project.id)}
+ />
+ ))
+ )}
+
+
+
+
+
+
+
+ {
+ setNewPath(event.target.value)
+ setNewPathError(null)
+ }}
+ placeholder={t('settings.memory.newPathPlaceholder')}
+ className="min-w-0 flex-1"
+ />
+
+
+ {newPathError && (
+
{newPathError}
+ )}
+
+
+ {files.length === 0 && !isLoadingFiles ? (
+
+ ) : (
+ files.map((file) => (
+ handleFileOpen(file)}
+ />
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+ {selectedFile?.path ?? t('settings.memory.noFileSelected')}
+
+ {isDirty && {t('settings.memory.unsaved')}}
+ {lastSavedAt && !isDirty && {t('settings.memory.saved')}}
+
+
+ {selectedProject?.memoryDir ?? t('settings.memory.selectProject')}
+
+
+
+
+
+
+
+
+ {selectedFile ? (
+
+
+
+ {t('settings.memory.editor')}
+ {formatBytes(selectedFile.bytes)}
+
+
+
+
+ {t('settings.memory.preview')}
+ {selectedFile.updatedAt ? formatDate(selectedFile.updatedAt) : ''}
+
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ )
+}
+
+function PanelHeader({ title, meta }: { title: string; meta: string }) {
+ return (
+
+
{title}
+ {meta}
+
+ )
+}
+
+function ProjectRow({
+ project,
+ active,
+ onSelect,
+}: {
+ project: MemoryProject
+ active: boolean
+ onSelect: () => void
+}) {
+ const t = useTranslation()
+ return (
+
+ )
+}
+
+function FileRow({
+ file,
+ active,
+ onSelect,
+}: {
+ file: MemoryFile
+ active: boolean
+ onSelect: () => void
+}) {
+ return (
+
+ )
+}
+
+function Badge({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function EmptyState({ text }: { text: string }) {
+ return (
+
+ {text}
+
+ )
+}
+
+function normalizeMemoryPath(value: string): string {
+ return value.trim().replace(/\\/g, '/').replace(/^\/+/, '')
+}
+
+function normalizeFsPath(value: string): string {
+ return value.replace(/\\/g, '/').replace(/\/+$/, '')
+}
+
+function resolveMemoryFileTarget(projects: MemoryProject[], absolutePath: string): { projectId: string; path: string } | null {
+ const target = normalizeFsPath(absolutePath)
+ for (const project of projects) {
+ const memoryDir = normalizeFsPath(project.memoryDir)
+ if (!memoryDir) continue
+ if (target === memoryDir) {
+ return { projectId: project.id, path: DEFAULT_MEMORY_PATH }
+ }
+ if (target.startsWith(`${memoryDir}/`)) {
+ return {
+ projectId: project.id,
+ path: target.slice(memoryDir.length + 1),
+ }
+ }
+ }
+ return null
+}
+
+function isValidMemoryPath(path: string): boolean {
+ return (
+ path.length > 0 &&
+ path.endsWith('.md') &&
+ !path.includes('\0') &&
+ !path.split('/').some((part) => part === '' || part === '.' || part === '..')
+ )
+}
+
+function buildMemoryTemplate(path: string): string {
+ const parts = path.split('/')
+ const title = parts[parts.length - 1]?.replace(/\.md$/, '') || 'Memory'
+ return `---
+type: project
+description: Manually curated project memory.
+---
+
+# ${title}
+
+`
+}
+
+function formatDate(value: string): string {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return ''
+ return new Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(date)
+}
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx
index 4ea0329d..4c1ccd6f 100644
--- a/desktop/src/pages/Settings.tsx
+++ b/desktop/src/pages/Settings.tsx
@@ -29,6 +29,7 @@ import { McpSettings } from './McpSettings'
import { TerminalSettings } from './TerminalSettings'
import { DiagnosticsSettings } from './DiagnosticsSettings'
import { ActivitySettings } from './ActivitySettings'
+import { MemorySettings } from './MemorySettings'
import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { useUpdateStore } from '../stores/updateStore'
@@ -92,6 +93,7 @@ export function Settings() {
setActiveTab('mcp')} />
setActiveTab('agents')} />
setActiveTab('skills')} />
+ setActiveTab('memory')} />
setActiveTab('plugins')} />
setActiveTab('computerUse')} />
setActiveTab('activity')} />
@@ -114,6 +116,7 @@ export function Settings() {
{activeTab === 'mcp' && }
{activeTab === 'agents' && }
{activeTab === 'skills' && }
+ {activeTab === 'memory' && }
{activeTab === 'plugins' && }
{activeTab === 'computerUse' && }
{activeTab === 'diagnostics' && }
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index 7a0de26e..d4f88329 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -219,6 +219,37 @@ describe('chatStore history mapping', () => {
expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' })
})
+ it('restores saved memory system events from transcript history', () => {
+ const messages: MessageEntry[] = [
+ {
+ id: 'memory-1',
+ type: 'system',
+ timestamp: '2026-04-06T00:00:00.000Z',
+ content: {
+ subtype: 'memory_saved',
+ writtenPaths: ['/Users/test/.claude/projects/example/memory/preferences.md'],
+ teamCount: 0,
+ },
+ },
+ ]
+
+ const mapped = mapHistoryMessagesToUiMessages(messages)
+
+ expect(mapped).toMatchObject([
+ {
+ id: 'memory-1',
+ type: 'memory_event',
+ event: 'saved',
+ files: [
+ {
+ path: '/Users/test/.claude/projects/example/memory/preferences.md',
+ action: 'saved',
+ },
+ ],
+ },
+ ])
+ })
+
it('merges consecutive assistant text blocks when restoring transcript history', () => {
const messages: MessageEntry[] = [
{
@@ -1084,6 +1115,43 @@ describe('chatStore history mapping', () => {
])
})
+ it('renders memory saved notifications as chat memory events', () => {
+ useChatStore.setState({
+ sessions: {
+ [TEST_SESSION_ID]: makeSession({
+ messages: [],
+ chatState: 'idle',
+ }),
+ },
+ })
+
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'system_notification',
+ subtype: 'memory_saved',
+ message: 'Saved 2 memories',
+ data: {
+ writtenPaths: [
+ '/Users/test/.claude/projects/example/memory/preferences.md',
+ '/Users/test/.claude/projects/example/memory/team/MEMORY.md',
+ ],
+ teamCount: 1,
+ },
+ })
+
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
+ {
+ type: 'memory_event',
+ event: 'saved',
+ message: 'Saved 2 memories',
+ teamCount: 1,
+ files: [
+ { path: '/Users/test/.claude/projects/example/memory/preferences.md', action: 'saved' },
+ { path: '/Users/test/.claude/projects/example/memory/team/MEMORY.md', action: 'saved' },
+ ],
+ },
+ ])
+ })
+
it('flushes the previous assistant draft before starting a new user turn', () => {
useChatStore.setState({
sessions: {
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index 9292e662..daaf7b54 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -19,6 +19,7 @@ import type {
ChatState,
ComputerUsePermissionRequest,
ComputerUsePermissionResponse,
+ MemoryEventFile,
UIAttachment,
UIMessage,
ServerMessage,
@@ -210,6 +211,23 @@ function appendAssistantTextMessage(
]
}
+function normalizeMemoryEventFiles(data: unknown): MemoryEventFile[] {
+ if (!data || typeof data !== 'object') return []
+ const writtenPaths = (data as { writtenPaths?: unknown }).writtenPaths
+ if (!Array.isArray(writtenPaths)) return []
+ return writtenPaths
+ .filter((path): path is string => typeof path === 'string' && path.trim().length > 0)
+ .map((path) => ({ path, action: 'saved' as const }))
+}
+
+function normalizeMemoryTeamCount(data: unknown): number | undefined {
+ if (!data || typeof data !== 'object') return undefined
+ const teamCount = (data as { teamCount?: unknown }).teamCount
+ return typeof teamCount === 'number' && Number.isFinite(teamCount)
+ ? teamCount
+ : undefined
+}
+
function normalizeNotificationPreview(content: string): string {
return content
.replace(/```[\s\S]*?```/g, ' code block ')
@@ -922,6 +940,25 @@ export const useChatStore = create((set, get) => ({
],
}))
}
+ if (msg.subtype === 'memory_saved') {
+ const files = normalizeMemoryEventFiles(msg.data)
+ if (files.length > 0) {
+ update((session) => ({
+ messages: [
+ ...session.messages,
+ {
+ id: nextId(),
+ type: 'memory_event',
+ event: 'saved',
+ files,
+ message: msg.message,
+ teamCount: normalizeMemoryTeamCount(msg.data),
+ timestamp: Date.now(),
+ },
+ ],
+ }))
+ }
+ }
if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') {
const data = msg.data as Record
const toolUseId =
@@ -1338,6 +1375,25 @@ export function mapHistoryMessagesToUiMessages(
})
}
}
+ if (msg.type === 'system' && msg.content && typeof msg.content === 'object') {
+ const subtype = (msg.content as { subtype?: unknown }).subtype
+ if (subtype === 'memory_saved') {
+ const files = normalizeMemoryEventFiles(msg.content)
+ if (files.length > 0) {
+ uiMessages.push({
+ id: msg.id || nextId(),
+ type: 'memory_event',
+ event: 'saved',
+ files,
+ message: typeof (msg.content as { message?: unknown }).message === 'string'
+ ? (msg.content as { message: string }).message
+ : undefined,
+ teamCount: normalizeMemoryTeamCount(msg.content),
+ timestamp,
+ })
+ }
+ }
+ }
}
return uiMessages
}
diff --git a/desktop/src/stores/memoryStore.ts b/desktop/src/stores/memoryStore.ts
new file mode 100644
index 00000000..cc1524c4
--- /dev/null
+++ b/desktop/src/stores/memoryStore.ts
@@ -0,0 +1,139 @@
+import { create } from 'zustand'
+import { memoryApi } from '../api/memory'
+import type { MemoryFile, MemoryFileDetail, MemoryProject } from '../types/memory'
+
+type MemoryStore = {
+ projects: MemoryProject[]
+ files: MemoryFile[]
+ selectedProjectId: string | null
+ selectedFile: MemoryFileDetail | null
+ draftContent: string
+ isLoadingProjects: boolean
+ isLoadingFiles: boolean
+ isLoadingFile: boolean
+ isSaving: boolean
+ error: string | null
+ lastSavedAt: string | null
+
+ fetchProjects: (cwd?: string) => Promise
+ selectProject: (projectId: string) => void
+ fetchFiles: (projectId: string) => Promise
+ openFile: (projectId: string, path: string) => Promise
+ updateDraft: (content: string) => void
+ saveFile: () => Promise
+ createFile: (projectId: string, path: string, content: string) => Promise
+}
+
+export const useMemoryStore = create((set, get) => ({
+ projects: [],
+ files: [],
+ selectedProjectId: null,
+ selectedFile: null,
+ draftContent: '',
+ isLoadingProjects: false,
+ isLoadingFiles: false,
+ isLoadingFile: false,
+ isSaving: false,
+ error: null,
+ lastSavedAt: null,
+
+ fetchProjects: async (cwd) => {
+ set({ isLoadingProjects: true, error: null })
+ try {
+ const { projects } = await memoryApi.listProjects(cwd)
+ const current = projects.find((project) => project.isCurrent)
+ const selectedProjectId =
+ get().selectedProjectId && projects.some((project) => project.id === get().selectedProjectId)
+ ? get().selectedProjectId
+ : current?.id ?? projects[0]?.id ?? null
+ set({ projects, selectedProjectId, isLoadingProjects: false })
+ } catch (err) {
+ set({ error: (err as Error).message, isLoadingProjects: false })
+ }
+ },
+
+ selectProject: (projectId) => {
+ set({
+ selectedProjectId: projectId,
+ files: [],
+ selectedFile: null,
+ draftContent: '',
+ error: null,
+ lastSavedAt: null,
+ })
+ },
+
+ fetchFiles: async (projectId) => {
+ set({ isLoadingFiles: true, error: null })
+ try {
+ const { files } = await memoryApi.listFiles(projectId)
+ set((state) => {
+ const stillSelected = state.selectedFile && files.some((file) => file.path === state.selectedFile?.path)
+ return {
+ files,
+ selectedFile: stillSelected ? state.selectedFile : null,
+ draftContent: stillSelected ? state.draftContent : '',
+ isLoadingFiles: false,
+ }
+ })
+ } catch (err) {
+ set({ error: (err as Error).message, isLoadingFiles: false })
+ }
+ },
+
+ openFile: async (projectId, path) => {
+ set({ isLoadingFile: true, error: null })
+ try {
+ const { file } = await memoryApi.readFile(projectId, path)
+ set({
+ selectedFile: file,
+ draftContent: file.content,
+ isLoadingFile: false,
+ lastSavedAt: null,
+ })
+ } catch (err) {
+ set({ error: (err as Error).message, isLoadingFile: false })
+ }
+ },
+
+ updateDraft: (content) => set({ draftContent: content }),
+
+ saveFile: async () => {
+ const { selectedProjectId, selectedFile, draftContent } = get()
+ if (!selectedProjectId || !selectedFile) return
+ set({ isSaving: true, error: null })
+ try {
+ const { file } = await memoryApi.saveFile({
+ projectId: selectedProjectId,
+ path: selectedFile.path,
+ content: draftContent,
+ })
+ set({
+ selectedFile: {
+ ...selectedFile,
+ updatedAt: file.updatedAt,
+ bytes: file.bytes,
+ content: draftContent,
+ },
+ isSaving: false,
+ lastSavedAt: file.updatedAt,
+ })
+ await get().fetchFiles(selectedProjectId)
+ } catch (err) {
+ set({ error: (err as Error).message, isSaving: false })
+ }
+ },
+
+ createFile: async (projectId, path, content) => {
+ set({ isSaving: true, error: null })
+ try {
+ await memoryApi.saveFile({ projectId, path, content })
+ set({ isSaving: false })
+ await get().fetchFiles(projectId)
+ await get().openFile(projectId, path)
+ } catch (err) {
+ set({ error: (err as Error).message, isSaving: false })
+ }
+ },
+}))
+
diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts
index 12138d5a..5ecc96ca 100644
--- a/desktop/src/stores/uiStore.ts
+++ b/desktop/src/stores/uiStore.ts
@@ -39,6 +39,7 @@ export type SettingsTab =
| 'mcp'
| 'agents'
| 'skills'
+ | 'memory'
| 'plugins'
| 'computerUse'
| 'diagnostics'
@@ -51,6 +52,7 @@ type UIStore = {
sidebarOpen: boolean
activeView: ActiveView
pendingSettingsTab: SettingsTab | null
+ pendingMemoryPath: string | null
activeModal: string | null
toasts: Toast[]
@@ -60,6 +62,7 @@ type UIStore = {
setSidebarOpen: (open: boolean) => void
setActiveView: (view: ActiveView) => void
setPendingSettingsTab: (tab: SettingsTab | null) => void
+ setPendingMemoryPath: (path: string | null) => void
openModal: (id: string) => void
closeModal: () => void
addToast: (toast: Omit) => void
@@ -73,6 +76,7 @@ export const useUIStore = create((set) => ({
sidebarOpen: true,
activeView: 'code',
pendingSettingsTab: null,
+ pendingMemoryPath: null,
activeModal: null,
toasts: [],
@@ -96,6 +100,7 @@ export const useUIStore = create((set) => ({
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setActiveView: (view) => set({ activeView: view }),
setPendingSettingsTab: (tab) => set({ pendingSettingsTab: tab }),
+ setPendingMemoryPath: (path) => set({ pendingMemoryPath: path }),
openModal: (id) => set({ activeModal: id }),
closeModal: () => set({ activeModal: null }),
diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts
index b1f073de..be0500d3 100644
--- a/desktop/src/types/chat.ts
+++ b/desktop/src/types/chat.ts
@@ -158,6 +158,12 @@ export type AgentTaskNotification = {
outputFile?: string
}
+export type MemoryEventFile = {
+ path: string
+ action?: 'saved' | 'updated' | 'created' | 'deleted' | 'loaded' | 'failed'
+ summary?: string
+}
+
// ─── UI Message model (rendered in MessageList) ───────────────────
export type TaskSummaryItem = {
@@ -174,6 +180,15 @@ export type UIMessage =
| { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string }
| { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string }
| { id: string; type: 'system'; content: string; timestamp: number }
+ | {
+ id: string
+ type: 'memory_event'
+ event: 'saved' | 'updated' | 'loaded' | 'failed'
+ files: MemoryEventFile[]
+ message?: string
+ teamCount?: number
+ timestamp: number
+ }
| {
id: string
type: 'permission_request'
diff --git a/desktop/src/types/memory.ts b/desktop/src/types/memory.ts
new file mode 100644
index 00000000..be6e139f
--- /dev/null
+++ b/desktop/src/types/memory.ts
@@ -0,0 +1,27 @@
+export type MemoryProject = {
+ id: string
+ label: string
+ memoryDir: string
+ exists: boolean
+ fileCount: number
+ isCurrent: boolean
+}
+
+export type MemoryFile = {
+ path: string
+ name: string
+ bytes: number
+ updatedAt: string
+ type?: string
+ description?: string
+ title: string
+ isIndex: boolean
+}
+
+export type MemoryFileDetail = {
+ path: string
+ content: string
+ updatedAt: string
+ bytes: number
+}
+
diff --git a/src/server/__tests__/memory.test.ts b/src/server/__tests__/memory.test.ts
new file mode 100644
index 00000000..17e38900
--- /dev/null
+++ b/src/server/__tests__/memory.test.ts
@@ -0,0 +1,146 @@
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+import * as fs from 'node:fs/promises'
+import * as os from 'node:os'
+import * as path from 'node:path'
+import { handleMemoryApi } from '../api/memory.js'
+import { sanitizePath } from '../../utils/path.js'
+
+let tmpDir: string
+let originalConfigDir: string | undefined
+let originalHome: string | undefined
+let originalUserProfile: string | undefined
+
+beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-memory-api-'))
+ originalConfigDir = process.env.CLAUDE_CONFIG_DIR
+ originalHome = process.env.HOME
+ originalUserProfile = process.env.USERPROFILE
+ process.env.CLAUDE_CONFIG_DIR = tmpDir
+ process.env.HOME = tmpDir
+ process.env.USERPROFILE = tmpDir
+})
+
+afterEach(async () => {
+ if (originalConfigDir !== undefined) {
+ process.env.CLAUDE_CONFIG_DIR = originalConfigDir
+ } else {
+ delete process.env.CLAUDE_CONFIG_DIR
+ }
+ if (originalHome !== undefined) {
+ process.env.HOME = originalHome
+ } else {
+ delete process.env.HOME
+ }
+ if (originalUserProfile !== undefined) {
+ process.env.USERPROFILE = originalUserProfile
+ } else {
+ delete process.env.USERPROFILE
+ }
+ await fs.rm(tmpDir, { recursive: true, force: true })
+})
+
+describe('memory API', () => {
+ it('lists current project memory files with frontmatter metadata', async () => {
+ const cwd = path.join(tmpDir, 'workspace', 'app')
+ const projectId = sanitizePath(cwd)
+ const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory')
+ await fs.mkdir(path.join(memoryDir, 'notes'), { recursive: true })
+ await fs.writeFile(
+ path.join(memoryDir, 'MEMORY.md'),
+ [
+ '---',
+ 'type: project',
+ 'description: Stable project conventions.',
+ '---',
+ '',
+ '# Project Memory',
+ ].join('\n'),
+ )
+ await fs.writeFile(path.join(memoryDir, 'notes', 'manual.md'), '# Manual')
+
+ const projectsRes = await request('GET', `/api/memory/projects?cwd=${encodeURIComponent(cwd)}`)
+ expect(projectsRes.status).toBe(200)
+ const projectsBody = await projectsRes.json() as {
+ projects: Array<{ id: string; isCurrent: boolean; fileCount: number }>
+ }
+ expect(projectsBody.projects[0]).toMatchObject({
+ id: projectId,
+ isCurrent: true,
+ fileCount: 2,
+ })
+
+ const filesRes = await request('GET', `/api/memory/files?projectId=${encodeURIComponent(projectId)}`)
+ expect(filesRes.status).toBe(200)
+ const filesBody = await filesRes.json() as {
+ files: Array<{ path: string; type?: string; description?: string; isIndex: boolean }>
+ }
+ expect(filesBody.files[0]).toMatchObject({
+ path: 'MEMORY.md',
+ type: 'project',
+ description: 'Stable project conventions.',
+ isIndex: true,
+ })
+ expect(filesBody.files.some((file) => file.path === 'notes/manual.md')).toBe(true)
+ })
+
+ it('reads and writes only markdown files inside the project memory directory', async () => {
+ const projectId = sanitizePath(path.join(tmpDir, 'workspace', 'app'))
+
+ const writeRes = await request('PUT', '/api/memory/file', {
+ projectId,
+ path: 'notes/project.md',
+ content: '# Edited Memory\n',
+ })
+ expect(writeRes.status).toBe(200)
+
+ const filePath = path.join(tmpDir, 'projects', projectId, 'memory', 'notes', 'project.md')
+ expect(await fs.readFile(filePath, 'utf-8')).toBe('# Edited Memory\n')
+
+ const readRes = await request('GET', `/api/memory/file?projectId=${encodeURIComponent(projectId)}&path=notes%2Fproject.md`)
+ expect(readRes.status).toBe(200)
+ const body = await readRes.json() as { file: { path: string; content: string } }
+ expect(body.file).toMatchObject({
+ path: 'notes/project.md',
+ content: '# Edited Memory\n',
+ })
+ })
+
+ it('rejects traversal and symlink escapes', async () => {
+ const projectId = sanitizePath(path.join(tmpDir, 'workspace', 'app'))
+ const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory')
+ const outsideDir = path.join(tmpDir, 'outside')
+ await fs.mkdir(memoryDir, { recursive: true })
+ await fs.mkdir(outsideDir, { recursive: true })
+ await fs.symlink(outsideDir, path.join(memoryDir, 'linked'), 'dir')
+
+ const traversalRes = await request('PUT', '/api/memory/file', {
+ projectId,
+ path: '../outside.md',
+ content: 'escape',
+ })
+ expect(traversalRes.status).toBe(400)
+
+ const symlinkRes = await request('PUT', '/api/memory/file', {
+ projectId,
+ path: 'linked/outside.md',
+ content: 'escape',
+ })
+ expect(symlinkRes.status).toBe(400)
+ await expect(fs.readFile(path.join(outsideDir, 'outside.md'), 'utf-8')).rejects.toThrow()
+ })
+})
+
+function request(method: string, pathname: string, body?: Record): Promise {
+ const url = new URL(pathname, 'http://localhost:3456')
+ const init: RequestInit = { method }
+ if (body) {
+ init.headers = { 'Content-Type': 'application/json' }
+ init.body = JSON.stringify(body)
+ }
+ return handleMemoryApi(
+ new Request(url.toString(), init),
+ url,
+ url.pathname.split('/').filter(Boolean),
+ )
+}
+
diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts
new file mode 100644
index 00000000..5d3ec678
--- /dev/null
+++ b/src/server/__tests__/ws-memory-events.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'bun:test'
+import { translateCliMessage } from '../ws/handler.js'
+
+describe('WebSocket memory events', () => {
+ it('forwards CLI memory_saved system messages to the desktop client', () => {
+ const messages = translateCliMessage(
+ {
+ type: 'system',
+ subtype: 'memory_saved',
+ writtenPaths: [
+ '/Users/test/.claude/projects/example/memory/preferences.md',
+ '/Users/test/.claude/projects/example/memory/team/MEMORY.md',
+ ],
+ teamCount: 1,
+ verb: 'Saved',
+ },
+ 'session-1',
+ )
+
+ expect(messages).toEqual([
+ {
+ type: 'system_notification',
+ subtype: 'memory_saved',
+ message: undefined,
+ data: {
+ writtenPaths: [
+ '/Users/test/.claude/projects/example/memory/preferences.md',
+ '/Users/test/.claude/projects/example/memory/team/MEMORY.md',
+ ],
+ teamCount: 1,
+ verb: 'Saved',
+ },
+ },
+ ])
+ })
+})
diff --git a/src/server/api/memory.ts b/src/server/api/memory.ts
new file mode 100644
index 00000000..6f28e78a
--- /dev/null
+++ b/src/server/api/memory.ts
@@ -0,0 +1,421 @@
+/**
+ * Memory REST API
+ *
+ * GET /api/memory/projects?cwd=... — list project-scoped memory dirs
+ * GET /api/memory/files?projectId=... — list markdown memory files
+ * GET /api/memory/file?projectId=...&path=...
+ * PUT /api/memory/file — update/create a markdown memory file
+ */
+
+import * as fs from 'node:fs/promises'
+import * as path from 'node:path'
+import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
+import { parseFrontmatter } from '../../utils/frontmatterParser.js'
+import { findCanonicalGitRoot } from '../../utils/git.js'
+import { sanitizePath } from '../../utils/path.js'
+import { getCwd } from '../../utils/cwd.js'
+import { parseMemoryType } from '../../memdir/memoryTypes.js'
+import { ApiError, errorResponse } from '../middleware/errorHandler.js'
+
+type MemoryProject = {
+ id: string
+ label: string
+ memoryDir: string
+ exists: boolean
+ fileCount: number
+ isCurrent: boolean
+}
+
+type MemoryFile = {
+ path: string
+ name: string
+ bytes: number
+ updatedAt: string
+ type?: string
+ description?: string
+ title: string
+ isIndex: boolean
+}
+
+const MAX_MEMORY_FILE_BYTES = 512 * 1024
+const MAX_MEMORY_FILES = 500
+
+export async function handleMemoryApi(
+ req: Request,
+ url: URL,
+ segments: string[],
+): Promise {
+ try {
+ const sub = segments[2]
+
+ switch (sub) {
+ case 'projects':
+ if (req.method !== 'GET') throw methodNotAllowed(req.method)
+ return Response.json({
+ projects: await listMemoryProjects(url.searchParams.get('cwd') || undefined),
+ })
+
+ case 'files':
+ if (req.method !== 'GET') throw methodNotAllowed(req.method)
+ return Response.json({
+ files: await listMemoryFiles(requireProjectId(url)),
+ })
+
+ case 'file':
+ return await handleMemoryFile(req, url)
+
+ default:
+ throw ApiError.notFound(`Unknown memory endpoint: ${sub}`)
+ }
+ } catch (error) {
+ return errorResponse(error)
+ }
+}
+
+async function listMemoryProjects(cwd?: string): Promise {
+ const projectsDir = getProjectsDir()
+ const currentProjectId = getProjectIdForCwd(cwd || getCwd())
+ const projects = new Map()
+
+ addProject(projects, currentProjectId, true)
+
+ let entries: import('node:fs').Dirent[]
+ try {
+ entries = await fs.readdir(projectsDir, { withFileTypes: true })
+ } catch {
+ entries = []
+ }
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue
+ addProject(projects, entry.name, entry.name === currentProjectId)
+ }
+
+ const resolved = await Promise.all(
+ Array.from(projects.values()).map(async project => {
+ const fileCount = await countMarkdownFiles(project.memoryDir)
+ return {
+ ...project,
+ exists: fileCount > 0 || (await directoryExists(project.memoryDir)),
+ fileCount,
+ }
+ }),
+ )
+
+ return resolved
+ .filter((project) => project.isCurrent || project.exists || project.fileCount > 0)
+ .sort((a, b) => {
+ if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1
+ if (a.fileCount !== b.fileCount) return b.fileCount - a.fileCount
+ return a.label.localeCompare(b.label)
+ })
+}
+
+function addProject(projects: Map, id: string, isCurrent: boolean) {
+ if (!isValidProjectId(id)) return
+ const existing = projects.get(id)
+ if (existing) {
+ existing.isCurrent = existing.isCurrent || isCurrent
+ return
+ }
+ projects.set(id, {
+ id,
+ label: unsanitizeProjectLabel(id),
+ memoryDir: path.join(getProjectsDir(), id, 'memory'),
+ exists: false,
+ fileCount: 0,
+ isCurrent,
+ })
+}
+
+async function listMemoryFiles(projectId: string): Promise {
+ const memoryDir = await ensureMemoryDirBoundary(projectId, { mustExist: false })
+ if (!(await directoryExists(memoryDir))) return []
+
+ const files: MemoryFile[] = []
+
+ async function walk(dir: string, prefix = ''): Promise {
+ if (files.length >= MAX_MEMORY_FILES) return
+
+ let entries: import('node:fs').Dirent[]
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true })
+ } catch {
+ return
+ }
+
+ entries.sort((a, b) => {
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
+ return a.name.localeCompare(b.name)
+ })
+
+ for (const entry of entries) {
+ if (files.length >= MAX_MEMORY_FILES) break
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
+
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name
+ const fullPath = path.join(dir, entry.name)
+ if (entry.isDirectory()) {
+ await walk(fullPath, relativePath)
+ continue
+ }
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
+
+ const stat = await fs.stat(fullPath)
+ let type: string | undefined
+ let description: string | undefined
+ try {
+ if (stat.size <= MAX_MEMORY_FILE_BYTES) {
+ const raw = await fs.readFile(fullPath, 'utf-8')
+ const parsed = parseFrontmatter(raw, fullPath)
+ type = parseMemoryType(parsed.frontmatter.type) ?? undefined
+ description =
+ typeof parsed.frontmatter.description === 'string'
+ ? parsed.frontmatter.description
+ : undefined
+ }
+ } catch {
+ // Metadata is best-effort. The file remains editable from the UI.
+ }
+
+ files.push({
+ path: relativePath,
+ name: entry.name,
+ bytes: stat.size,
+ updatedAt: stat.mtime.toISOString(),
+ type,
+ description,
+ title: relativePath === 'MEMORY.md' ? 'MEMORY.md' : entry.name.replace(/\.md$/, ''),
+ isIndex: relativePath === 'MEMORY.md',
+ })
+ }
+ }
+
+ await walk(memoryDir)
+ return files.sort((a, b) => {
+ if (a.isIndex !== b.isIndex) return a.isIndex ? -1 : 1
+ return a.path.localeCompare(b.path)
+ })
+}
+
+async function handleMemoryFile(req: Request, url: URL): Promise {
+ if (req.method === 'GET') {
+ const projectId = requireProjectId(url)
+ const relativePath = requireMemoryPath(url.searchParams.get('path'))
+ const fullPath = await resolveMemoryFilePath(projectId, relativePath, {
+ mustExist: true,
+ })
+ const stat = await fs.stat(fullPath)
+ if (stat.size > MAX_MEMORY_FILE_BYTES) {
+ throw ApiError.badRequest(`Memory file is too large to edit: ${relativePath}`)
+ }
+ return Response.json({
+ file: {
+ path: relativePath,
+ content: await fs.readFile(fullPath, 'utf-8'),
+ updatedAt: stat.mtime.toISOString(),
+ bytes: stat.size,
+ },
+ })
+ }
+
+ if (req.method === 'PUT') {
+ const body = await parseJsonBody(req)
+ const projectId = typeof body.projectId === 'string' ? body.projectId : ''
+ const relativePath = requireMemoryPath(
+ typeof body.path === 'string' ? body.path : undefined,
+ )
+ const content = typeof body.content === 'string' ? body.content : undefined
+ if (content === undefined) {
+ throw ApiError.badRequest('Missing or invalid "content" in request body')
+ }
+ if (Buffer.byteLength(content, 'utf-8') > MAX_MEMORY_FILE_BYTES) {
+ throw ApiError.badRequest('Memory file content exceeds 512 KB')
+ }
+
+ const fullPath = await resolveMemoryFilePath(projectId, relativePath, {
+ mustExist: false,
+ })
+ const memoryDir = await ensureMemoryDirBoundary(projectId, { mustExist: false })
+ await fs.mkdir(path.dirname(fullPath), { recursive: true, mode: 0o700 })
+ await assertWithinDirectory(path.dirname(fullPath), memoryDir, true)
+ if (await fileExists(fullPath)) {
+ await assertWithinDirectory(fullPath, memoryDir, true)
+ }
+ await fs.writeFile(fullPath, content, { encoding: 'utf-8', mode: 0o600 })
+ const stat = await fs.stat(fullPath)
+ return Response.json({
+ ok: true,
+ file: {
+ path: relativePath,
+ updatedAt: stat.mtime.toISOString(),
+ bytes: stat.size,
+ },
+ })
+ }
+
+ throw methodNotAllowed(req.method)
+}
+
+function requireProjectId(url: URL): string {
+ const projectId = url.searchParams.get('projectId')
+ if (!projectId) throw ApiError.badRequest('Missing projectId')
+ return projectId
+}
+
+function requireMemoryPath(value: string | null | undefined): string {
+ if (!value || typeof value !== 'string') {
+ throw ApiError.badRequest('Missing memory file path')
+ }
+ const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '')
+ if (
+ normalized.length === 0 ||
+ normalized.includes('\0') ||
+ normalized.split('/').some(part => part === '' || part === '.' || part === '..') ||
+ !normalized.endsWith('.md')
+ ) {
+ throw ApiError.badRequest('Memory path must be a relative .md file path')
+ }
+ return normalized
+}
+
+async function resolveMemoryFilePath(
+ projectId: string,
+ relativePath: string,
+ opts: { mustExist: boolean },
+): Promise {
+ const memoryDir = await ensureMemoryDirBoundary(projectId, {
+ mustExist: opts.mustExist,
+ })
+ const candidate = path.resolve(memoryDir, relativePath)
+ await assertWithinDirectory(candidate, memoryDir, opts.mustExist)
+ return candidate
+}
+
+async function ensureMemoryDirBoundary(
+ projectId: string,
+ opts: { mustExist: boolean },
+): Promise {
+ if (!isValidProjectId(projectId)) {
+ throw ApiError.badRequest('Invalid projectId')
+ }
+ const projectsDir = path.resolve(getProjectsDir())
+ const projectDir = path.resolve(projectsDir, projectId)
+ await assertWithinDirectory(projectDir, projectsDir, false)
+ const memoryDir = path.resolve(projectDir, 'memory')
+ if (await directoryExists(projectDir)) {
+ await assertWithinDirectory(projectDir, projectsDir, true)
+ }
+ if (await directoryExists(memoryDir)) {
+ await assertWithinDirectory(memoryDir, projectDir, true)
+ }
+ if (opts.mustExist && !(await directoryExists(memoryDir))) {
+ throw ApiError.notFound(`Memory project not found: ${projectId}`)
+ }
+ return memoryDir
+}
+
+async function assertWithinDirectory(
+ candidate: string,
+ directory: string,
+ mustExist: boolean,
+): Promise {
+ const resolvedDirectory = mustExist
+ ? await safeRealpath(directory)
+ : path.resolve(directory)
+ const resolvedCandidate = mustExist
+ ? await safeRealpath(candidate)
+ : path.resolve(candidate)
+ const boundary = resolvedDirectory.endsWith(path.sep)
+ ? resolvedDirectory
+ : `${resolvedDirectory}${path.sep}`
+ if (resolvedCandidate !== resolvedDirectory && !resolvedCandidate.startsWith(boundary)) {
+ throw ApiError.badRequest('Path escapes memory directory')
+ }
+}
+
+async function safeRealpath(targetPath: string): Promise {
+ try {
+ return await fs.realpath(targetPath)
+ } catch {
+ return path.resolve(targetPath)
+ }
+}
+
+function isValidProjectId(projectId: string): boolean {
+ return (
+ projectId.length > 0 &&
+ !projectId.includes('\0') &&
+ !projectId.includes('/') &&
+ !projectId.includes('\\') &&
+ projectId !== '.' &&
+ projectId !== '..'
+ )
+}
+
+function getProjectsDir(): string {
+ return path.join(getClaudeConfigHomeDir(), 'projects')
+}
+
+function getProjectIdForCwd(cwd: string): string {
+ return sanitizePath(findCanonicalGitRoot(cwd) ?? cwd)
+}
+
+function unsanitizeProjectLabel(projectId: string): string {
+ return projectId.replace(/^-/, '/').replace(/-/g, '/')
+}
+
+async function directoryExists(dir: string): Promise {
+ try {
+ const stat = await fs.stat(dir)
+ return stat.isDirectory()
+ } catch {
+ return false
+ }
+}
+
+async function fileExists(filePath: string): Promise {
+ try {
+ const stat = await fs.stat(filePath)
+ return stat.isFile()
+ } catch {
+ return false
+ }
+}
+
+async function countMarkdownFiles(dir: string): Promise {
+ let count = 0
+ async function walk(current: string): Promise {
+ if (count >= MAX_MEMORY_FILES) return
+ let entries: import('node:fs').Dirent[]
+ try {
+ entries = await fs.readdir(current, { withFileTypes: true })
+ } catch {
+ return
+ }
+ for (const entry of entries) {
+ if (count >= MAX_MEMORY_FILES) break
+ if (entry.name.startsWith('.')) continue
+ const fullPath = path.join(current, entry.name)
+ if (entry.isDirectory()) {
+ await walk(fullPath)
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
+ count++
+ }
+ }
+ }
+ await walk(dir)
+ return count
+}
+
+async function parseJsonBody(req: Request): Promise> {
+ try {
+ return (await req.json()) as Record
+ } catch {
+ throw ApiError.badRequest('Invalid JSON body')
+ }
+}
+
+function methodNotAllowed(method: string): ApiError {
+ return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
+}
diff --git a/src/server/router.ts b/src/server/router.ts
index 5db9be15..853e93fd 100644
--- a/src/server/router.ts
+++ b/src/server/router.ts
@@ -24,6 +24,7 @@ import { handleDiagnosticsApi } from './api/diagnostics.js'
import { handleDoctorApi } from './api/doctor.js'
import { handleH5AccessApi } from './api/h5-access.js'
import { handleActivityStatsApi } from './api/activityStats.js'
+import { handleMemoryApi } from './api/memory.js'
export async function handleApiRequest(req: Request, url: URL): Promise {
const path = url.pathname
@@ -107,6 +108,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise