@@ -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 791334ae..c7cdb5b8 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -7,6 +7,7 @@ import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
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'
@@ -101,6 +102,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() } })
useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true)
@@ -195,6 +197,158 @@ 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('promotes memory file writes from tool calls into a dedicated memory card', () => {
+ useChatStore.setState({
+ sessions: {
+ [ACTIVE_TAB]: makeSessionState({
+ messages: [
+ {
+ id: 'tool-write-memory',
+ type: 'tool_use',
+ toolName: 'Write',
+ toolUseId: 'write-memory',
+ input: {
+ file_path: '/Users/test/.claude/projects/example/memory/preferences.md',
+ content: '# Preferences\n',
+ },
+ timestamp: 1,
+ },
+ {
+ id: 'result-write-memory',
+ type: 'tool_result',
+ toolUseId: 'write-memory',
+ content: 'File written successfully',
+ isError: false,
+ timestamp: 2,
+ },
+ ],
+ }),
+ },
+ })
+
+ render(
)
+
+ expect(screen.getByText('Saved 1 memory item(s)')).toBeTruthy()
+ expect(screen.getByText('preferences.md')).toBeTruthy()
+ expect(screen.getByText('Tool details')).toBeTruthy()
+ })
+
+ it('promotes memory file reads into collapsible memory references', () => {
+ useChatStore.setState({
+ sessions: {
+ [ACTIVE_TAB]: makeSessionState({
+ messages: [
+ {
+ id: 'tool-read-memory-1',
+ type: 'tool_use',
+ toolName: 'Read',
+ toolUseId: 'read-memory-1',
+ input: { file_path: '/Users/test/.claude/projects/example/memory/MEMORY.md' },
+ timestamp: 1,
+ },
+ {
+ id: 'result-read-memory-1',
+ type: 'tool_result',
+ toolUseId: 'read-memory-1',
+ content: '1 # Project Memory\n2\n3 billing ledger rules',
+ isError: false,
+ timestamp: 2,
+ },
+ {
+ id: 'tool-read-memory-2',
+ type: 'tool_use',
+ toolName: 'Read',
+ toolUseId: 'read-memory-2',
+ input: { file_path: '/Users/test/.claude/projects/example/memory/workflow.md' },
+ timestamp: 3,
+ },
+ ],
+ }),
+ },
+ })
+
+ render(
)
+
+ expect(screen.getByText('2 memory reference(s)')).toBeTruthy()
+ fireEvent.click(screen.getByText('2 memory reference(s)'))
+ expect(screen.getByText('MEMORY.md')).toBeTruthy()
+ expect(screen.getByText('workflow.md')).toBeTruthy()
+ })
+
+ it('keeps non-memory tools visible when a tool group also touches memory files', () => {
+ useChatStore.setState({
+ sessions: {
+ [ACTIVE_TAB]: makeSessionState({
+ messages: [
+ {
+ id: 'tool-read-memory',
+ type: 'tool_use',
+ toolName: 'Read',
+ toolUseId: 'read-memory',
+ input: { file_path: '/Users/test/.claude/projects/example/memory/MEMORY.md' },
+ timestamp: 1,
+ },
+ {
+ id: 'tool-bash',
+ type: 'tool_use',
+ toolName: 'Bash',
+ toolUseId: 'bash-1',
+ input: { command: 'bun test' },
+ timestamp: 2,
+ },
+ {
+ id: 'result-bash',
+ type: 'tool_result',
+ toolUseId: 'bash-1',
+ content: 'ok',
+ isError: false,
+ timestamp: 3,
+ },
+ ],
+ }),
+ },
+ })
+
+ render(
)
+
+ expect(screen.getByText('1 memory reference(s)')).toBeTruthy()
+ expect(screen.getByText('Bash')).toBeTruthy()
+ expect(screen.getByText('bun test')).toBeTruthy()
+ })
+
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 0b68dc77..c95cf66b 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -1,10 +1,10 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } 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 { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
-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'
@@ -25,6 +25,7 @@ import { ConfirmDialog } from '../shared/ConfirmDialog'
type ToolCall = Extract
type ToolResult = Extract
+type MemoryEvent = Extract
type RenderItem =
| { kind: 'tool_group'; toolCalls: ToolCall[]; id: string }
@@ -393,6 +394,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
@@ -910,6 +975,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/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx
index aa2d0b3f..9239b1a4 100644
--- a/desktop/src/components/chat/ToolCallGroup.tsx
+++ b/desktop/src/components/chat/ToolCallGroup.tsx
@@ -1,14 +1,31 @@
import { useEffect, useState } from 'react'
+import { BookMarked, ChevronDown, ChevronRight, Settings } from 'lucide-react'
import { ToolCallBlock } from './ToolCallBlock'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { Modal } from '../shared/Modal'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n'
+import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
+import { useUIStore } from '../../stores/uiStore'
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { AGENT_LIFECYCLE_TYPES } from '../../types/team'
type ToolCall = Extract
type ToolResult = Extract
+type MemoryToolAction = 'saved' | 'referenced'
+
+type MemoryToolFile = {
+ path: string
+ label: string
+ action: MemoryToolAction
+ lineHint?: string
+ preview?: string
+}
+
+type MemoryToolActivity = {
+ action: MemoryToolAction
+ files: MemoryToolFile[]
+}
type Props = {
toolCalls: ToolCall[]
@@ -59,6 +76,59 @@ export function ToolCallGroup({
childToolCallsByParent,
agentTaskNotifications,
isStreaming,
+}: Props) {
+ const memoryActivity = getMemoryToolActivity(toolCalls, resultMap)
+ if (memoryActivity) {
+ const memoryToolCalls = toolCalls.filter(isMemoryToolCall)
+ const regularToolCalls = toolCalls.filter((toolCall) => !isMemoryToolCall(toolCall))
+ if (regularToolCalls.length > 0) {
+ return (
+
+
+
+
+ )
+ }
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+function ToolCallGroupContent({
+ toolCalls,
+ resultMap,
+ childToolCallsByParent,
+ agentTaskNotifications,
+ isStreaming,
}: Props) {
const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
@@ -97,6 +167,123 @@ export function ToolCallGroup({
)
}
+function MemoryToolActivityGroup({
+ activity,
+ toolCalls,
+ resultMap,
+ childToolCallsByParent,
+ isStreaming,
+}: {
+ activity: MemoryToolActivity
+ toolCalls: ToolCall[]
+ resultMap: Map
+ childToolCallsByParent: Map
+ isStreaming?: boolean
+}) {
+ const [expanded, setExpanded] = useState(activity.action === 'saved')
+ const [detailsExpanded, setDetailsExpanded] = useState(false)
+ const t = useTranslation()
+ const titleKey = activity.action === 'saved'
+ ? 'chat.memorySavedFromToolsTitle'
+ : 'chat.memoryReferencedTitle'
+ const visibleFiles = activity.files.slice(0, 4)
+ const hiddenCount = Math.max(0, activity.files.length - visibleFiles.length)
+
+ useEffect(() => {
+ if (isStreaming) setExpanded(true)
+ }, [isStreaming])
+
+ return (
+
+
+
+
+ {expanded ? (
+
+
+ {visibleFiles.map((file) => (
+
+ ))}
+ {hiddenCount > 0 ? (
+
+ {t('chat.memoryMoreFiles', { count: hiddenCount })}
+
+ ) : null}
+
+
+
+
+ {detailsExpanded ? (
+
+ {toolCalls.map((toolCall) => (
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+
+ )
+}
+
function AgentToolGroup({
toolCalls,
resultMap,
@@ -442,6 +629,108 @@ function ToolCallTree({
)
}
+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 getMemoryToolActivity(
+ toolCalls: ToolCall[],
+ resultMap: Map,
+): MemoryToolActivity | null {
+ const filesByPath = new Map()
+ let sawSave = false
+
+ for (const toolCall of toolCalls) {
+ const path = getToolFilePath(toolCall.input)
+ if (!path || !isMemoryMarkdownPath(path)) continue
+
+ const isSave = isMemoryWriteTool(toolCall.toolName)
+ const isReference = toolCall.toolName === 'Read'
+ if (!isSave && !isReference) continue
+ sawSave ||= isSave
+
+ const result = resultMap.get(toolCall.toolUseId)
+ const preview = extractMemoryPreview(result?.content)
+ const current = filesByPath.get(path)
+ filesByPath.set(path, {
+ path,
+ label: memoryFileLabel(path),
+ action: isSave ? 'saved' : (current?.action ?? 'referenced'),
+ lineHint: preview.lineHint || current?.lineHint,
+ preview: preview.text || current?.preview,
+ })
+ }
+
+ if (filesByPath.size === 0) return null
+ return {
+ action: sawSave ? 'saved' : 'referenced',
+ files: [...filesByPath.values()],
+ }
+}
+
+function isMemoryToolCall(toolCall: ToolCall): boolean {
+ const path = getToolFilePath(toolCall.input)
+ if (!path || !isMemoryMarkdownPath(path)) return false
+ return toolCall.toolName === 'Read' || isMemoryWriteTool(toolCall.toolName)
+}
+
+function isMemoryWriteTool(toolName: string): boolean {
+ return toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit'
+}
+
+function getToolFilePath(input: unknown): string | null {
+ if (!input || typeof input !== 'object') return null
+ const record = input as Record
+ const filePath = record.file_path ?? record.path
+ return typeof filePath === 'string' ? filePath : null
+}
+
+function isMemoryMarkdownPath(path: string): boolean {
+ const normalized = path.replace(/\\/g, '/')
+ return normalized.endsWith('.md') && normalized.includes('/memory/')
+}
+
+function memoryFileLabel(path: string): string {
+ const normalized = path.replace(/\\/g, '/')
+ return normalized.split('/').pop() || normalized
+}
+
+function extractMemoryPreview(content: unknown): { text?: string; lineHint?: string } {
+ const raw = extractTextContent(content)
+ if (!raw) return {}
+ const lineHint = extractLineHint(raw)
+ const lines = raw
+ .replace(/[\s\S]*?<\/system-reminder>/g, '')
+ .split(/\r?\n/)
+ .map((line) => line.replace(/^\s*\d+\s*/, '').trim())
+ .filter(Boolean)
+
+ let inFrontmatter = false
+ for (const line of lines) {
+ if (line === '---') {
+ inFrontmatter = !inFrontmatter
+ continue
+ }
+ if (inFrontmatter) continue
+ const normalized = line.replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim()
+ if (!normalized || normalized === '---') continue
+ if (/^(file|lines?|total)\b/i.test(normalized)) continue
+ return {
+ text: normalized.length > 140 ? `${normalized.slice(0, 140)}...` : normalized,
+ lineHint,
+ }
+ }
+ return { lineHint }
+}
+
+function extractLineHint(text: string): string | undefined {
+ const match = text.match(/(\d+)\s+lines?\b/i) ?? text.match(/(\d+)\s+行/)
+ return match?.[1] ? `${match[1]} lines` : undefined
+}
+
type AgentStatus = 'starting' | 'running' | 'done' | 'failed' | 'stopped'
type AgentTaskStatus = AgentTaskNotification['status']
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 be8da7be..bc566043 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -547,6 +547,33 @@ 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.emptyProjects': 'No memory projects found.',
+ 'settings.memory.emptyFiles': 'No memory files found yet.',
+ 'settings.memory.selectFile': 'Select a memory file.',
+ 'settings.memory.selectProject': 'Select a project.',
+ 'settings.memory.noFileSelected': 'No file selected',
+ 'settings.memory.current': 'Current',
+ 'settings.memory.indexFile': 'Index',
+ 'settings.memory.missing': 'Missing',
+ 'settings.memory.fileCount': '{count} files',
+ 'settings.memory.unsaved': 'Unsaved',
+ 'settings.memory.saved': 'Saved',
+ 'settings.memory.revert': 'Revert',
+ 'settings.memory.projectSearchPlaceholder': 'Search projects by path...',
+ 'settings.memory.fileSearchPlaceholder': 'Search memory files...',
+ 'settings.memory.noProjectMatches': 'No projects match this search.',
+ 'settings.memory.noFileMatches': 'No memory files match this search.',
+ 'settings.memory.clearSearch': 'Clear search',
+
// Settings > Plugins
'settings.plugins.title': 'Installed Plugins',
'settings.plugins.description': 'Inspect installed plugins, see their health, and apply changes to the desktop runtime.',
@@ -903,6 +930,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',
@@ -925,6 +955,12 @@ export const en = {
'chat.dismiss': 'dismiss',
'chat.stopTitle': 'Stop generation (Cmd+.)',
'chat.jumpToLatest': 'Latest',
+ 'chat.memorySavedTitle': 'Saved {count} memory file(s)',
+ 'chat.memorySavedFromToolsTitle': 'Saved {count} memory item(s)',
+ 'chat.memoryReferencedTitle': '{count} memory reference(s)',
+ 'chat.memoryOpenSettings': 'Open Memory',
+ 'chat.memoryMoreFiles': '+{count} more',
+ 'chat.memoryTechnicalDetails': 'Tool details',
'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 8abdc689..ccad1be7 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -549,6 +549,33 @@ 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.emptyProjects': '还没有找到记忆项目。',
+ 'settings.memory.emptyFiles': '还没有找到记忆文件。',
+ 'settings.memory.selectFile': '选择一个记忆文件。',
+ 'settings.memory.selectProject': '选择一个项目。',
+ 'settings.memory.noFileSelected': '未选择文件',
+ 'settings.memory.current': '当前',
+ 'settings.memory.indexFile': '索引',
+ 'settings.memory.missing': '目录缺失',
+ 'settings.memory.fileCount': '{count} 个文件',
+ 'settings.memory.unsaved': '未保存',
+ 'settings.memory.saved': '已保存',
+ 'settings.memory.revert': '还原',
+ 'settings.memory.projectSearchPlaceholder': '按路径搜索项目...',
+ 'settings.memory.fileSearchPlaceholder': '搜索记忆文件...',
+ 'settings.memory.noProjectMatches': '没有匹配的项目。',
+ 'settings.memory.noFileMatches': '没有匹配的记忆文件。',
+ 'settings.memory.clearSearch': '清空搜索',
+
// Settings > Plugins
'settings.plugins.title': '已安装插件',
'settings.plugins.description': '查看已安装插件、运行状态,并把插件变更应用到桌面端运行时。',
@@ -905,6 +932,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': '上下文用量加载中',
@@ -927,6 +957,12 @@ export const zh: Record = {
'chat.dismiss': '关闭',
'chat.stopTitle': '停止生成 (Cmd+.)',
'chat.jumpToLatest': '回到最新',
+ 'chat.memorySavedTitle': '已保存 {count} 个记忆文件',
+ 'chat.memorySavedFromToolsTitle': '保存了 {count} 条记忆',
+ 'chat.memoryReferencedTitle': '{count} 条记忆引用',
+ 'chat.memoryOpenSettings': '打开记忆',
+ 'chat.memoryMoreFiles': '还有 {count} 个',
+ 'chat.memoryTechnicalDetails': '工具详情',
'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..36513f29
--- /dev/null
+++ b/desktop/src/pages/MemorySettings.tsx
@@ -0,0 +1,528 @@
+import { useEffect, useMemo, useState } from 'react'
+import type { ReactNode } from 'react'
+import { BookOpenText, Database, FileText, FolderGit2, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react'
+import { Button } from '../components/shared/Button'
+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,
+ } = 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 [projectQuery, setProjectQuery] = useState('')
+ const [fileQuery, setFileQuery] = useState('')
+
+ 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)
+ const filteredProjects = useMemo(
+ () => filterProjects(projects, projectQuery),
+ [projectQuery, projects],
+ )
+ const filteredFiles = useMemo(
+ () => filterFiles(files, fileQuery),
+ [fileQuery, files],
+ )
+ const previewContent = stripMarkdownFrontmatter(draftContent)
+
+ useEffect(() => {
+ void fetchProjects(activeCwd)
+ }, [activeCwd, fetchProjects])
+
+ useEffect(() => {
+ if (!selectedProjectId) return
+ void fetchFiles(selectedProjectId)
+ }, [fetchFiles, selectedProjectId])
+
+ useEffect(() => {
+ if (!projectQuery.trim() || filteredProjects.length === 0) return
+ if (selectedProjectId && filteredProjects.some((project) => project.id === selectedProjectId)) return
+ selectProject(filteredProjects[0]!.id)
+ }, [filteredProjects, projectQuery, selectProject, 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)
+ }
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {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) : ''}
+
+
+
+
+
+
+ ) : (
+
+ } text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />
+
+ )}
+
+
+
+ )
+}
+
+function SearchField({
+ value,
+ onChange,
+ placeholder,
+ ariaLabel,
+ clearLabel,
+}: {
+ value: string
+ onChange: (value: string) => void
+ placeholder: string
+ ariaLabel: string
+ clearLabel: string
+}) {
+ return (
+
+
+ onChange(event.target.value)}
+ placeholder={placeholder}
+ className="h-10 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] pl-9 pr-9 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
+ />
+ {value ? (
+
+ ) : null}
+
+ )
+}
+
+function PanelHeader({ icon, title, meta }: { icon?: ReactNode; title: string; meta: string }) {
+ return (
+
+
+ {icon ? {icon} : null}
+ {title}
+
+ {meta}
+
+ )
+}
+
+function ProjectRow({
+ project,
+ active,
+ onSelect,
+}: {
+ project: MemoryProject
+ active: boolean
+ onSelect: () => void
+}) {
+ const t = useTranslation()
+ const display = projectDisplayName(project.label)
+ return (
+
+ )
+}
+
+function FileRow({
+ file,
+ active,
+ onSelect,
+}: {
+ file: MemoryFile
+ active: boolean
+ onSelect: () => void
+}) {
+ const t = useTranslation()
+ return (
+
+ )
+}
+
+function Badge({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function EmptyState({ icon, text }: { icon?: ReactNode; text: string }) {
+ return (
+
+ {icon ? (
+
+ {icon}
+
+ ) : null}
+ {text}
+
+ )
+}
+
+function normalizeSearch(value: string): string {
+ return value.toLowerCase().replace(/\\/g, '/').replace(/\/+/g, '/').trim()
+}
+
+function filterProjects(projects: MemoryProject[], query: string): MemoryProject[] {
+ const normalized = normalizeSearch(query)
+ if (!normalized) return projects
+ return projects.filter((project) =>
+ normalizeSearch(`${project.label} ${project.memoryDir} ${project.id}`).includes(normalized),
+ )
+}
+
+function filterFiles(files: MemoryFile[], query: string): MemoryFile[] {
+ const normalized = normalizeSearch(query)
+ if (!normalized) return files
+ return files.filter((file) =>
+ normalizeSearch(`${file.title} ${file.path} ${file.description ?? ''} ${file.type ?? ''}`).includes(normalized),
+ )
+}
+
+function projectDisplayName(label: string): string {
+ const normalized = label.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '')
+ const parts = normalized.split('/').filter(Boolean)
+ if (parts.length >= 2) return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`
+ return parts[0] ?? label
+}
+
+function stripMarkdownFrontmatter(content: string): string {
+ if (!content.startsWith('---')) return content
+ const end = content.indexOf('\n---', 3)
+ if (end < 0) return content
+ const after = content.indexOf('\n', end + 4)
+ return after < 0 ? '' : content.slice(after + 1).trimStart()
+}
+
+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 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 d2fb7da1..2aa9d64a 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..e32d08ef
--- /dev/null
+++ b/desktop/src/stores/memoryStore.ts
@@ -0,0 +1,138 @@
+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..0799c633
--- /dev/null
+++ b/desktop/src/types/memory.ts
@@ -0,0 +1,26 @@
+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..f8d4c2b1
--- /dev/null
+++ b/src/server/__tests__/memory.test.ts
@@ -0,0 +1,189 @@
+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('uses session metadata for project labels when sanitized paths contain non-ascii characters', async () => {
+ const cwd = path.join(tmpDir, '中文 项目', 'GLM', '5V', 'turbo')
+ const projectId = sanitizePath(cwd)
+ const projectDir = path.join(tmpDir, 'projects', projectId)
+ const memoryDir = path.join(projectDir, 'memory')
+ await fs.mkdir(memoryDir, { recursive: true })
+ await fs.writeFile(path.join(memoryDir, 'MEMORY.md'), '# Project Memory')
+ await fs.writeFile(
+ path.join(projectDir, 'session.jsonl'),
+ JSON.stringify({
+ type: 'user',
+ cwd,
+ message: { role: 'user', content: 'hello' },
+ }) + '\n',
+ )
+
+ const projectsRes = await request('GET', '/api/memory/projects')
+ expect(projectsRes.status).toBe(200)
+ const projectsBody = await projectsRes.json() as {
+ projects: Array<{ id: string; label: string }>
+ }
+
+ const project = projectsBody.projects.find((item) => item.id === projectId)
+ expect(project).toMatchObject({ label: cwd })
+ })
+
+ it('recovers project labels from existing directories when no session metadata exists', async () => {
+ const cwd = path.join(tmpDir, '个人自媒体', '314', 'opus4', 'PicTacticAgent')
+ const projectId = sanitizePath(cwd)
+ const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory')
+ await fs.mkdir(cwd, { recursive: true })
+ await fs.mkdir(memoryDir, { recursive: true })
+ await fs.writeFile(path.join(memoryDir, 'MEMORY.md'), '# Project Memory')
+
+ const projectsRes = await request('GET', '/api/memory/projects')
+ expect(projectsRes.status).toBe(200)
+ const projectsBody = await projectsRes.json() as {
+ projects: Array<{ id: string; label: string }>
+ }
+
+ const project = projectsBody.projects.find((item) => item.id === projectId)
+ expect(project).toMatchObject({ label: cwd })
+ })
+
+ 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..2996cbc1
--- /dev/null
+++ b/src/server/api/memory.ts
@@ -0,0 +1,552 @@
+/**
+ * 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 { homedir } from 'node:os'
+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 { extractJsonStringField } from '../../utils/sessionStoragePortable.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
+const PROJECT_LABEL_SESSION_SCAN_LIMIT = 10
+const PROJECT_LABEL_HEAD_BYTES = 64 * 1024
+const PROJECT_LABEL_FS_SEARCH_DEPTH = 24
+const PROJECT_LABEL_FS_SEARCH_NODE_LIMIT = 2000
+
+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 currentCwd = cwd || getCwd()
+ const currentProjectId = getProjectIdForCwd(currentCwd)
+ 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, label] = await Promise.all([
+ countMarkdownFiles(project.memoryDir),
+ resolveProjectLabel(project.id, currentCwd),
+ ])
+ return {
+ ...project,
+ label,
+ 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)
+}
+
+async function resolveProjectLabel(projectId: string, currentCwd: string): Promise {
+ const currentRoot = findCanonicalGitRoot(currentCwd) ?? currentCwd
+ if (sanitizePath(currentRoot) === projectId) return currentRoot
+
+ const sessionPath = await inferProjectPathFromSessionFiles(projectId)
+ if (sessionPath) return sessionPath
+
+ const filesystemPath = await inferProjectPathFromExistingDirectory(projectId)
+ return filesystemPath ?? unsanitizeProjectLabel(projectId)
+}
+
+async function inferProjectPathFromSessionFiles(projectId: string): Promise {
+ const projectDir = path.join(getProjectsDir(), projectId)
+ let entries: import('node:fs').Dirent[]
+ try {
+ entries = await fs.readdir(projectDir, { withFileTypes: true })
+ } catch {
+ return undefined
+ }
+
+ const sessionFiles: Array<{ filePath: string; mtimeMs: number }> = []
+ for (const entry of entries) {
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
+ const filePath = path.join(projectDir, entry.name)
+ try {
+ const stat = await fs.stat(filePath)
+ sessionFiles.push({ filePath, mtimeMs: stat.mtimeMs })
+ } catch {
+ // A racing delete should not hide the rest of the memory projects.
+ }
+ }
+
+ sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs)
+ for (const { filePath } of sessionFiles.slice(0, PROJECT_LABEL_SESSION_SCAN_LIMIT)) {
+ const head = await readFileHead(filePath, PROJECT_LABEL_HEAD_BYTES)
+ const candidate =
+ extractJsonStringField(head, 'cwd') ??
+ extractJsonStringField(head, 'workDir') ??
+ extractJsonStringField(head, 'projectPath')
+ if (candidate && path.isAbsolute(candidate)) return candidate.normalize('NFC')
+ }
+
+ return undefined
+}
+
+async function readFileHead(filePath: string, bytes: number): Promise {
+ const handle = await fs.open(filePath, 'r')
+ try {
+ const buffer = Buffer.alloc(bytes)
+ const { bytesRead } = await handle.read(buffer, 0, bytes, 0)
+ return buffer.subarray(0, bytesRead).toString('utf-8')
+ } catch {
+ return ''
+ } finally {
+ await handle.close()
+ }
+}
+
+async function inferProjectPathFromExistingDirectory(projectId: string): Promise {
+ const roots = Array.from(new Set([
+ homedir(),
+ process.env.HOME,
+ process.env.USERPROFILE,
+ '/private/tmp',
+ '/tmp',
+ ].filter((root): root is string => Boolean(root && path.isAbsolute(root)))))
+
+ for (const root of roots) {
+ const resolvedRoot = path.resolve(root)
+ if (!sanitizedPrefixCanMatch(projectId, sanitizePath(resolvedRoot))) continue
+ const state = { visited: 0 }
+ const match = await findDirectoryBySanitizedPath(projectId, resolvedRoot, 0, state)
+ if (match) return match.normalize('NFC')
+ }
+
+ return undefined
+}
+
+async function findDirectoryBySanitizedPath(
+ projectId: string,
+ candidate: string,
+ depth: number,
+ state: { visited: number },
+): Promise {
+ if (state.visited >= PROJECT_LABEL_FS_SEARCH_NODE_LIMIT) return undefined
+ state.visited += 1
+
+ const candidateId = sanitizePath(candidate)
+ if (candidateId === projectId) return candidate
+ if (depth >= PROJECT_LABEL_FS_SEARCH_DEPTH || !sanitizedPrefixCanMatch(projectId, candidateId)) {
+ return undefined
+ }
+
+ let entries: import('node:fs').Dirent[]
+ try {
+ entries = await fs.readdir(candidate, { withFileTypes: true })
+ } catch {
+ return undefined
+ }
+
+ entries.sort((a, b) => a.name.localeCompare(b.name))
+ for (const entry of entries) {
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
+ const child = path.join(candidate, entry.name)
+ if (!sanitizedPrefixCanMatch(projectId, sanitizePath(child))) continue
+ if (entry.isSymbolicLink() && !(await directoryExists(child))) continue
+ const match = await findDirectoryBySanitizedPath(projectId, child, depth + 1, state)
+ if (match) return match
+ }
+
+ return undefined
+}
+
+function sanitizedPrefixCanMatch(projectId: string, prefix: string): boolean {
+ if (projectId === prefix) return true
+ return prefix.endsWith('-')
+ ? projectId.startsWith(prefix)
+ : projectId.startsWith(`${prefix}-`)
+}
+
+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 7085ba36..36f81929 100644
--- a/src/server/router.ts
+++ b/src/server/router.ts
@@ -25,6 +25,7 @@ import { handleDoctorApi } from './api/doctor.js'
import { handleH5AccessApi } from './api/h5-access.js'
import { handleActivityStatsApi } from './api/activityStats.js'
import { handleOpenTargetsApi } from './api/open-targets.js'
+import { handleMemoryApi } from './api/memory.js'
export async function handleApiRequest(req: Request, url: URL): Promise {
const path = url.pathname
@@ -111,6 +112,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise