Merge desktop memory surface into main

This brings the desktop memory worktree onto local main after main advanced with opener, IM, zoom, and autonomous goal work. The conflict resolution keeps both the selected-chat reference imports and memory event settings entrypoint in MessageList, and keeps both open-targets and memory API routes in the server router.

Constraint: main and feat/desktop-memory-surface diverged across desktop chat and server routing.
Constraint: desktop Vitest runs src-tauri tests under Vitest, so the Tauri config test cannot import bun:test.
Rejected: Fast-forward merge | main carried newer local commits not present on the memory branch.
Confidence: high
Scope-risk: moderate
Directive: Keep chat selection references, memory cards, open-target routes, and memory routes together when touching these files.
Tested: bun run check:desktop
Tested: bun run check:server
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 21:05:54 +08:00
commit f455065997
25 changed files with 2714 additions and 6 deletions

View File

@ -1,11 +1,15 @@
import { describe, expect, it } from 'bun:test'
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
const currentDir = dirname(fileURLToPath(import.meta.url))
describe('tauri security config', () => {
it('allows desktop sidecar image URLs for opener icons', () => {
const config = JSON.parse(
readFileSync(join(import.meta.dir, 'tauri.conf.json'), 'utf8'),
readFileSync(join(currentDir, 'tauri.conf.json'), 'utf8'),
) as {
app?: {
security?: {

View File

@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { MemorySettings } from '../pages/MemorySettings'
import { useMemoryStore } from '../stores/memoryStore'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
const { memoryApiMock } = vi.hoisted(() => ({
memoryApiMock: {
listProjects: vi.fn(),
listFiles: vi.fn(),
readFile: vi.fn(),
saveFile: vi.fn(),
},
}))
vi.mock('../api/memory', () => ({
memoryApi: memoryApiMock,
}))
vi.mock('../components/markdown/MarkdownRenderer', () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-preview">{content}</div>
),
}))
describe('MemorySettings', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
sessions: [
{
id: 'session-1',
title: 'Active session',
createdAt: '2026-05-01T00:00:00.000Z',
modifiedAt: '2026-05-01T00:00:00.000Z',
messageCount: 1,
projectPath: '/workspace/demo',
workDir: '/workspace/demo',
workDirExists: true,
},
],
activeSessionId: 'session-1',
})
useMemoryStore.setState({
projects: [],
files: [],
selectedProjectId: null,
selectedFile: null,
draftContent: '',
isLoadingProjects: false,
isLoadingFiles: false,
isLoadingFile: false,
isSaving: false,
error: null,
lastSavedAt: null,
})
useUIStore.setState({ pendingMemoryPath: null, pendingSettingsTab: null })
memoryApiMock.listProjects.mockResolvedValue({
projects: [
{
id: '-workspace-demo',
label: '/workspace/demo',
memoryDir: '/tmp/claude/projects/-workspace-demo/memory',
exists: true,
fileCount: 1,
isCurrent: true,
},
],
})
memoryApiMock.listFiles.mockResolvedValue({
files: [
{
path: 'MEMORY.md',
name: 'MEMORY.md',
title: 'MEMORY.md',
bytes: 18,
updatedAt: '2026-05-01T00:00:00.000Z',
type: 'project',
description: 'Project conventions.',
isIndex: true,
},
],
})
memoryApiMock.readFile.mockResolvedValue({
file: {
path: 'MEMORY.md',
content: '# Project Memory\n',
updatedAt: '2026-05-01T00:00:00.000Z',
bytes: 18,
},
})
memoryApiMock.saveFile.mockResolvedValue({
ok: true,
file: {
path: 'MEMORY.md',
updatedAt: '2026-05-01T00:01:00.000Z',
bytes: 28,
},
})
})
it('loads project-scoped markdown memory and saves manual edits', async () => {
render(<MemorySettings />)
expect(await screen.findByText('Project Memory')).toBeInTheDocument()
expect(memoryApiMock.listProjects).toHaveBeenCalledWith('/workspace/demo')
expect(await screen.findByText('/workspace/demo')).toBeInTheDocument()
expect(await screen.findByText('Project conventions.')).toBeInTheDocument()
expect(screen.queryByPlaceholderText('MEMORY.md or notes/project.md')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /create memory file/i })).not.toBeInTheDocument()
const editor = await screen.findByLabelText('Editor')
expect(editor).toHaveValue('# Project Memory\n')
fireEvent.change(editor, {
target: { value: '# Project Memory\n\n- Prefer small diffs.\n' },
})
expect(screen.getByText('Unsaved')).toBeInTheDocument()
expect(screen.getByTestId('markdown-preview')).toHaveTextContent('Prefer small diffs')
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(memoryApiMock.saveFile).toHaveBeenCalledWith({
projectId: '-workspace-demo',
path: 'MEMORY.md',
content: '# Project Memory\n\n- Prefer small diffs.\n',
})
})
})
it('filters projects by path so large memory lists are navigable', async () => {
memoryApiMock.listProjects.mockResolvedValue({
projects: [
{
id: '-workspace-alpha',
label: '/workspace/alpha',
memoryDir: '/tmp/claude/projects/-workspace-alpha/memory',
exists: true,
fileCount: 1,
isCurrent: true,
},
{
id: '-workspace-beta',
label: '/workspace/beta',
memoryDir: '/tmp/claude/projects/-workspace-beta/memory',
exists: true,
fileCount: 2,
isCurrent: false,
},
],
})
render(<MemorySettings />)
expect(await screen.findByText('workspace/alpha')).toBeInTheDocument()
expect(await screen.findByText('workspace/beta')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Search projects by path...'), {
target: { value: 'beta' },
})
expect(screen.queryByText('workspace/alpha')).not.toBeInTheDocument()
expect(screen.getByText('workspace/beta')).toBeInTheDocument()
await waitFor(() => {
expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-beta')
})
})
it('keeps frontmatter editable but removes it from the rendered preview', async () => {
memoryApiMock.readFile.mockResolvedValue({
file: {
path: 'MEMORY.md',
content: '---\ntype: project\n---\n\n# Project Memory\n',
updatedAt: '2026-05-01T00:00:00.000Z',
bytes: 39,
},
})
render(<MemorySettings />)
const editor = await screen.findByLabelText('Editor')
expect(editor).toHaveValue('---\ntype: project\n---\n\n# Project Memory\n')
expect(screen.getByTestId('markdown-preview')).toHaveTextContent('Project Memory')
expect(screen.getByTestId('markdown-preview')).not.toHaveTextContent('type: project')
})
it('opens the exact memory file requested from chat', async () => {
memoryApiMock.listProjects.mockResolvedValue({
projects: [
{
id: '-workspace-demo',
label: '/workspace/demo',
memoryDir: '/tmp/claude/projects/-workspace-demo/memory',
exists: true,
fileCount: 0,
isCurrent: true,
},
{
id: '-workspace-other',
label: '/workspace/other',
memoryDir: '/tmp/claude/projects/-workspace-other/memory',
exists: true,
fileCount: 1,
isCurrent: false,
},
],
})
memoryApiMock.listFiles.mockImplementation((projectId: string) => Promise.resolve({
files: projectId === '-workspace-other'
? [
{
path: 'preferences.md',
name: 'preferences.md',
title: 'preferences.md',
bytes: 24,
updatedAt: '2026-05-01T00:00:00.000Z',
type: 'preference',
isIndex: false,
},
]
: [],
}))
memoryApiMock.readFile.mockResolvedValue({
file: {
path: 'preferences.md',
content: '# Preferences\n',
updatedAt: '2026-05-01T00:00:00.000Z',
bytes: 24,
},
})
useUIStore.setState({
pendingMemoryPath: '/tmp/claude/projects/-workspace-other/memory/preferences.md',
})
render(<MemorySettings />)
const editor = await screen.findByLabelText('Editor')
expect(editor).toHaveValue('# Preferences\n')
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-other', 'preferences.md')
expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-other')
expect(useUIStore.getState().pendingMemoryPath).toBeNull()
})
})

22
desktop/src/api/memory.ts Normal file
View File

@ -0,0 +1,22 @@
import { api } from './client'
import type { MemoryFile, MemoryFileDetail, MemoryProject } from '../types/memory'
export const memoryApi = {
listProjects: (cwd?: string) => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<{ projects: MemoryProject[] }>(`/api/memory/projects${query}`)
},
listFiles: (projectId: string) => {
const query = new URLSearchParams({ projectId })
return api.get<{ files: MemoryFile[] }>(`/api/memory/files?${query.toString()}`)
},
readFile: (projectId: string, path: string) => {
const query = new URLSearchParams({ projectId, path })
return api.get<{ file: MemoryFileDetail }>(`/api/memory/file?${query.toString()}`)
},
saveFile: (input: { projectId: string; path: string; content: string }) =>
api.put<{ ok: true; file: Omit<MemoryFileDetail, 'content'> }>('/api/memory/file', input),
}

View File

@ -0,0 +1,145 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const { sessionsApiMock } = vi.hoisted(() => ({
sessionsApiMock: {
getInspection: vi.fn(),
},
}))
vi.mock('../../api/sessions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api/sessions')>()
return {
...actual,
sessionsApi: {
getInspection: sessionsApiMock.getInspection,
},
}
})
import { LocalSlashCommandPanel } from './LocalSlashCommandPanel'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import type { SessionContextSnapshot, SessionInspectionResponse } from '../../api/sessions'
const baseContext: SessionContextSnapshot = {
categories: [
{
name: 'memory',
tokens: 120,
color: '#14b8a6',
},
],
totalTokens: 120,
maxTokens: 200000,
rawMaxTokens: 200000,
percentage: 0.06,
gridRows: [],
model: 'Claude Test',
memoryFiles: [],
mcpTools: [],
agents: [],
messageBreakdown: {
toolCallTokens: 0,
toolResultTokens: 0,
attachmentTokens: 0,
assistantMessageTokens: 0,
userMessageTokens: 0,
toolCallsByType: [],
attachmentsByType: [],
},
}
function inspectionWithContext(context: SessionContextSnapshot): SessionInspectionResponse {
return {
active: true,
status: {
sessionId: 'session-1',
workDir: '/workspace/demo',
permissionMode: 'default',
model: 'Claude Test',
tools: [],
mcpServers: [],
},
context,
}
}
describe('LocalSlashCommandPanel memory context', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState(useTabStore.getInitialState(), true)
useUIStore.setState({
pendingMemoryPath: null,
pendingSettingsTab: null,
})
})
it('shows loaded memory files and opens the selected project memory in settings', async () => {
sessionsApiMock.getInspection.mockResolvedValue(inspectionWithContext({
...baseContext,
memoryFiles: [
{
path: '/Users/test/.claude/projects/demo/memory/MEMORY.md',
type: 'project',
tokens: 4321,
},
{
path: '/Users/test/.claude/projects/demo/memory/feedback/reuse.md',
type: 'feedback',
tokens: 98,
},
],
}))
render(
<LocalSlashCommandPanel
command="context"
sessionId="session-1"
onClose={vi.fn()}
/>,
)
expect(await screen.findByText('Memory files')).toBeInTheDocument()
expect(screen.getByText('MEMORY.md')).toBeInTheDocument()
expect(screen.getByText('/Users/test/.claude/projects/demo/memory/MEMORY.md')).toBeInTheDocument()
expect(screen.getByText('feedback')).toBeInTheDocument()
expect(screen.getByText('4,321 tokens')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Open Memory' }))
await waitFor(() => {
expect(useUIStore.getState().pendingSettingsTab).toBe('memory')
expect(useUIStore.getState().pendingMemoryPath).toBe('/Users/test/.claude/projects/demo/memory/MEMORY.md')
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
})
})
it('keeps the memory settings entry available when no memory files are loaded', async () => {
sessionsApiMock.getInspection.mockResolvedValue(inspectionWithContext({
...baseContext,
memoryFiles: [],
}))
render(
<LocalSlashCommandPanel
command="context"
sessionId="session-1"
onClose={vi.fn()}
/>,
)
expect(await screen.findByText('No memory files are loaded in this session.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Open Memory' }))
await waitFor(() => {
expect(useUIStore.getState().pendingSettingsTab).toBe('memory')
expect(useUIStore.getState().pendingMemoryPath).toBeNull()
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
})
})
})

View File

@ -331,6 +331,7 @@ function UsageTab({
}
type ContextCategory = SessionContextSnapshot['categories'][number]
type ContextMemoryFile = SessionContextSnapshot['memoryFiles'][number]
function isCapacityCategory(category: ContextCategory) {
const name = category.name.toLowerCase()
@ -405,6 +406,75 @@ function CategoryBreakdown({ categories, rawMaxTokens, t }: { categories: Contex
)
}
function memoryContextFileLabel(path: string) {
const normalized = path.replace(/\\/g, '/')
return normalized.split('/').pop() || normalized
}
function MemoryFilesBreakdown({ files, t }: { files: ContextMemoryFile[]; t: Translate }) {
const setPendingSettingsTab = useUIStore((state) => state.setPendingSettingsTab)
const setPendingMemoryPath = useUIStore((state) => state.setPendingMemoryPath)
const openSettings = (path?: string) => {
if (path) setPendingMemoryPath(path)
setPendingSettingsTab('memory')
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
}
if (files.length === 0) {
return (
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-5">
<div className="flex items-center justify-between gap-3">
<InspectorSectionTitle>{t('slash.inspector.context.memoryFiles')}</InspectorSectionTitle>
<button
type="button"
onClick={() => openSettings()}
className="rounded-sm border border-[var(--color-inspector-border)] bg-[var(--color-inspector-chip)] px-2.5 py-1 text-xs font-semibold text-[var(--color-inspector-muted-strong)] hover:text-[var(--color-inspector-text)]"
>
{t('slash.inspector.context.openMemory')}
</button>
</div>
<div className="mt-4 text-sm text-[var(--color-inspector-muted)]">{t('slash.inspector.context.noMemoryFiles')}</div>
</div>
)
}
return (
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-5">
<div className="flex items-center justify-between gap-3">
<InspectorSectionTitle>{t('slash.inspector.context.memoryFiles')}</InspectorSectionTitle>
<button
type="button"
onClick={() => openSettings(files[0]?.path)}
className="rounded-sm border border-[var(--color-inspector-border)] bg-[var(--color-inspector-chip)] px-2.5 py-1 text-xs font-semibold text-[var(--color-inspector-muted-strong)] hover:text-[var(--color-inspector-text)]"
>
{t('slash.inspector.context.openMemory')}
</button>
</div>
<div className="mt-4 grid gap-2">
{files.map((file) => (
<div
key={`${file.type}:${file.path}`}
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-3 py-2"
>
<div className="min-w-0">
<div className="truncate font-mono text-sm font-semibold text-[var(--color-inspector-text)]" title={file.path}>
{memoryContextFileLabel(file.path)}
</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-[var(--color-inspector-muted)]" title={file.path}>
{file.path}
</div>
</div>
<div className="shrink-0 text-right font-mono">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--color-inspector-muted-strong)]">{file.type}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-inspector-muted)]">{formatNumber(file.tokens)} tokens</div>
</div>
</div>
))}
</div>
</div>
)
}
function ContextStatPill({ label, value, detail }: { label: string; value: string; detail?: string }) {
return (
<div className="min-w-0 font-mono">
@ -509,6 +579,7 @@ function ContextTab({
return (
<div className="space-y-6">
<ContextOverview context={context} categories={categories} t={t} />
<MemoryFilesBreakdown files={Array.isArray(context.memoryFiles) ? context.memoryFiles : []} t={t} />
<CategoryBreakdown categories={categories} rawMaxTokens={context.rawMaxTokens} t={t} />
</div>
)

View File

@ -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(<MessageList sessionId={ACTIVE_TAB} />)
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(<MessageList sessionId={ACTIVE_TAB} />)
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(<MessageList sessionId={ACTIVE_TAB} />)
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(<MessageList sessionId={ACTIVE_TAB} />)
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[] = [
{

View File

@ -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<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type MemoryEvent = Extract<UIMessage, { type: 'memory_event' }>
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 (
<div className="mb-3 flex justify-center px-3">
<div className="w-full max-w-2xl rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3.5 py-3 text-xs shadow-sm">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-brand)]">
<BookMarked size={15} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="font-medium text-[var(--color-text-primary)]">
{t('chat.memorySavedTitle', { count: message.files.length })}
</div>
<button
type="button"
onClick={() => openMemorySettings(message.files[0]?.path)}
className="inline-flex h-7 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/50 hover:text-[var(--color-text-primary)]"
>
<Settings size={13} aria-hidden="true" />
{t('chat.memoryOpenSettings')}
</button>
</div>
{message.message ? (
<div className="mt-1 text-[var(--color-text-tertiary)]">{message.message}</div>
) : null}
<div className="mt-2 flex flex-wrap gap-1.5">
{visibleFiles.map((file) => (
<span
key={file.path}
title={file.path}
className="max-w-full truncate rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-secondary)]"
>
{memoryFileLabel(file.path)}
</span>
))}
{hiddenCount > 0 ? (
<span className="rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-tertiary)]">
{t('chat.memoryMoreFiles', { count: hiddenCount })}
</span>
) : null}
</div>
</div>
</div>
</div>
</div>
)
}
type MessageListProps = {
sessionId?: string | null
compact?: boolean
@ -910,6 +975,8 @@ export const MessageBlock = memo(function MessageBlock({
}
case 'task_summary':
return <InlineTaskSummary tasks={message.tasks} />
case 'memory_event':
return <MemoryEventCard message={message} />
case 'system':
return (
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">

View File

@ -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<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
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 (
<div className="mb-2 space-y-2">
<MemoryToolActivityGroup
activity={memoryActivity}
toolCalls={memoryToolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming}
/>
<ToolCallGroupContent
toolCalls={regularToolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={isStreaming}
/>
</div>
)
}
return (
<MemoryToolActivityGroup
activity={memoryActivity}
toolCalls={memoryToolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming}
/>
)
}
return (
<ToolCallGroupContent
toolCalls={toolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={isStreaming}
/>
)
}
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<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
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 (
<div className="mb-2">
<div className="overflow-hidden rounded-lg border border-[color-mix(in_srgb,var(--color-brand)_22%,var(--color-border))] bg-[color-mix(in_srgb,var(--color-brand)_6%,var(--color-surface-container-lowest))]">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
>
{expanded ? (
<ChevronDown size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
) : (
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
)}
<BookMarked size={15} className="shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{t(titleKey, { count: activity.files.length })}
</span>
{isStreaming ? (
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-brand)] animate-pulse-dot" />
) : null}
</button>
{expanded ? (
<div className="border-t border-[var(--color-border)]/55 px-3 py-2.5">
<div className="space-y-1.5">
{visibleFiles.map((file) => (
<button
key={file.path}
type="button"
title={file.path}
onClick={() => openMemorySettings(file.path)}
className="group flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-tertiary)] group-hover:text-[var(--color-brand)]">
<Settings size={12} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{file.label}
</span>
{file.lineHint ? (
<span className="shrink-0 text-[12px] text-[var(--color-text-tertiary)]">
{file.lineHint}
</span>
) : null}
</span>
{file.preview ? (
<span className="mt-0.5 line-clamp-2 text-[12px] leading-5 text-[var(--color-text-secondary)]">
{file.preview}
</span>
) : null}
</span>
</button>
))}
{hiddenCount > 0 ? (
<div className="px-2 py-1 text-[12px] text-[var(--color-text-tertiary)]">
{t('chat.memoryMoreFiles', { count: hiddenCount })}
</div>
) : null}
</div>
<button
type="button"
onClick={() => setDetailsExpanded((value) => !value)}
className="mt-2 inline-flex h-7 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{detailsExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('chat.memoryTechnicalDetails')}
</button>
{detailsExpanded ? (
<div className="mt-2 space-y-1">
{toolCalls.map((toolCall) => (
<ToolCallTree
key={toolCall.id}
toolCall={toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
/>
))}
</div>
) : null}
</div>
) : null}
</div>
</div>
)
}
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<string, ToolResult>,
): MemoryToolActivity | null {
const filesByPath = new Map<string, MemoryToolFile>()
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<string, unknown>
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(/<system-reminder>[\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']

View File

@ -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')
})

View File

@ -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' },

View File

@ -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/<project>/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',

View File

@ -549,6 +549,33 @@ export const zh: Record<TranslationKey, string> = {
'settings.skills.source.mcp': 'MCP',
'settings.skills.source.bundled': '内置',
// Settings > Memory
'settings.tab.memory': '记忆',
'settings.memory.title': '项目记忆',
'settings.memory.description': '查看和编辑 Claude 为每个项目写入的 Markdown 记忆文件。这些文件存放在 ~/.claude/projects/<project>/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<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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} 个文件已更改',

View File

@ -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 (
<div className="flex h-full min-h-[640px] flex-col gap-4">
<header className="grid gap-4 border-b border-[var(--color-border)] pb-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="grid min-w-0 gap-2">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-brand)]">
<BookOpenText size={16} aria-hidden="true" />
</span>
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
{t('settings.memory.title')}
</h2>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-10 text-xs text-[var(--color-text-tertiary)]">
<span className="truncate font-mono">{activeCwd ? projectDisplayName(activeCwd) : '~/.claude/projects'}</span>
<span>{t('settings.memory.fileCount', { count: files.length })}</span>
</div>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleRefresh}
loading={isLoadingProjects || isLoadingFiles}
icon={<RefreshCw size={15} aria-hidden="true" />}
>
{t('settings.memory.refresh')}
</Button>
</header>
{error && (
<div className="rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-3 py-2 text-sm text-[var(--color-error)]">
{error}
</div>
)}
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[390px_minmax(0,1fr)]">
<aside className="grid min-h-0 grid-rows-[minmax(210px,0.9fr)_minmax(260px,1.1fr)] overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<section className="flex min-h-0 flex-col">
<PanelHeader
icon={<FolderGit2 size={15} aria-hidden="true" />}
title={t('settings.memory.projects')}
meta={isLoadingProjects ? t('common.loading') : String(projects.length)}
/>
<div className="px-3 py-3">
<SearchField
value={projectQuery}
onChange={setProjectQuery}
placeholder={t('settings.memory.projectSearchPlaceholder')}
ariaLabel={t('settings.memory.projectSearchPlaceholder')}
clearLabel={t('settings.memory.clearSearch')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{projects.length === 0 && !isLoadingProjects ? (
<EmptyState icon={<FolderGit2 size={18} />} text={t('settings.memory.emptyProjects')} />
) : filteredProjects.length === 0 ? (
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noProjectMatches')} />
) : (
filteredProjects.map((project) => (
<ProjectRow
key={project.id}
project={project}
active={project.id === selectedProjectId}
onSelect={() => handleProjectSelect(project.id)}
/>
))
)}
</div>
</section>
<section className="flex min-h-0 flex-col border-t border-[var(--color-border)]">
<PanelHeader
icon={<Database size={15} aria-hidden="true" />}
title={t('settings.memory.files')}
meta={isLoadingFiles ? t('common.loading') : `${files.length}`}
/>
<div className="px-3 py-3">
<SearchField
value={fileQuery}
onChange={setFileQuery}
placeholder={t('settings.memory.fileSearchPlaceholder')}
ariaLabel={t('settings.memory.fileSearchPlaceholder')}
clearLabel={t('settings.memory.clearSearch')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{files.length === 0 && !isLoadingFiles ? (
<EmptyState icon={<FileText size={18} />} text={t('settings.memory.emptyFiles')} />
) : filteredFiles.length === 0 ? (
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noFileMatches')} />
) : (
filteredFiles.map((file) => (
<FileRow
key={file.path}
file={file}
active={file.path === selectedFile?.path}
onSelect={() => handleFileOpen(file)}
/>
))
)}
</div>
</section>
</aside>
<section className="min-h-0 overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="grid gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-base font-semibold text-[var(--color-text-primary)]">
{selectedFile?.path ?? t('settings.memory.noFileSelected')}
</h3>
{isDirty && <Badge>{t('settings.memory.unsaved')}</Badge>}
{lastSavedAt && !isDirty && <Badge>{t('settings.memory.saved')}</Badge>}
</div>
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">
{selectedProject?.memoryDir ?? t('settings.memory.selectProject')}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={!selectedFile || !isDirty || isSaving}
onClick={() => selectedFile && updateDraft(selectedFile.content)}
icon={<RotateCcw size={14} aria-hidden="true" />}
>
{t('settings.memory.revert')}
</Button>
<Button
type="button"
size="sm"
disabled={!selectedFile || !isDirty}
loading={isSaving}
onClick={() => void saveFile()}
icon={<Save size={14} aria-hidden="true" />}
>
{t('common.save')}
</Button>
</div>
</div>
{selectedFile ? (
<div className="grid min-h-[560px] grid-rows-[minmax(300px,1fr)_minmax(260px,0.95fr)] 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] 2xl:grid-rows-1">
<div className="min-h-0 border-b border-[var(--color-border)] 2xl:border-b-0 2xl:border-r">
<div className="flex h-10 items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
<span>{t('settings.memory.editor')}</span>
<span>{formatBytes(selectedFile.bytes)}</span>
</div>
<textarea
aria-label={t('settings.memory.editor')}
value={draftContent}
onChange={(event) => updateDraft(event.target.value)}
spellCheck={false}
className="h-[calc(100%-40px)] w-full resize-none overflow-auto bg-transparent p-5 font-mono text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
<div className="min-h-0 overflow-y-auto">
<div className="flex h-10 items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
<span>{t('settings.memory.preview')}</span>
<span>{selectedFile.updatedAt ? formatDate(selectedFile.updatedAt) : ''}</span>
</div>
<div className="p-6">
<MarkdownRenderer content={previewContent || ' '} variant="document" />
</div>
</div>
</div>
) : (
<div className="flex min-h-[520px] items-center justify-center p-8">
<EmptyState icon={<FileText size={20} />} text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />
</div>
)}
</section>
</div>
</div>
)
}
function SearchField({
value,
onChange,
placeholder,
ariaLabel,
clearLabel,
}: {
value: string
onChange: (value: string) => void
placeholder: string
ariaLabel: string
clearLabel: string
}) {
return (
<div className="relative">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
aria-hidden="true"
/>
<input
aria-label={ariaLabel}
value={value}
onChange={(event) => 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 ? (
<button
type="button"
aria-label={clearLabel}
onClick={() => onChange('')}
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<X size={14} aria-hidden="true" />
</button>
) : null}
</div>
)
}
function PanelHeader({ icon, title, meta }: { icon?: ReactNode; title: string; meta: string }) {
return (
<div className="flex h-11 items-center justify-between border-b border-[var(--color-border)] px-3">
<h3 className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
{icon ? <span className="text-[var(--color-text-tertiary)]">{icon}</span> : null}
<span className="truncate">{title}</span>
</h3>
<span className="text-xs text-[var(--color-text-tertiary)]">{meta}</span>
</div>
)
}
function ProjectRow({
project,
active,
onSelect,
}: {
project: MemoryProject
active: boolean
onSelect: () => void
}) {
const t = useTranslation()
const display = projectDisplayName(project.label)
return (
<button
type="button"
onClick={onSelect}
title={project.label}
className={`mb-1 w-full rounded-md px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] shadow-[inset_3px_0_0_var(--color-brand)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate text-sm font-semibold">{display}</span>
{project.isCurrent && <Badge>{t('settings.memory.current')}</Badge>}
</div>
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">{project.label}</p>
<div className="mt-1.5 flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.memory.fileCount', { count: project.fileCount })}</span>
{!project.exists ? <span>{t('settings.memory.missing')}</span> : null}
</div>
</button>
)
}
function FileRow({
file,
active,
onSelect,
}: {
file: MemoryFile
active: boolean
onSelect: () => void
}) {
const t = useTranslation()
return (
<button
type="button"
onClick={onSelect}
className={`mb-1 w-full rounded-md px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] shadow-[inset_3px_0_0_var(--color-brand)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<FileText size={14} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<span className="min-w-0 truncate text-sm font-semibold">{file.title}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
{file.isIndex ? <Badge>{t('settings.memory.indexFile')}</Badge> : null}
{file.type && <Badge>{file.type}</Badge>}
</span>
</div>
<p className="mt-1 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">{file.path}</p>
{file.description && (
<p className="mt-1 truncate text-xs leading-5 text-[var(--color-text-secondary)]">
{file.description}
</p>
)}
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<span>{formatBytes(file.bytes)}</span>
{file.updatedAt ? <span>{formatDate(file.updatedAt)}</span> : null}
</div>
</button>
)
}
function Badge({ children }: { children: string }) {
return (
<span className="shrink-0 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)]">
{children}
</span>
)
}
function EmptyState({ icon, text }: { icon?: ReactNode; text: string }) {
return (
<div className="grid place-items-center gap-2 px-3 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
{icon ? (
<span className="flex h-9 w-9 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]">
{icon}
</span>
) : null}
<span>{text}</span>
</div>
)
}
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)
}

View File

@ -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() {
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
<TabButton icon="history_edu" label={t('settings.tab.memory')} active={activeTab === 'memory'} onClick={() => setActiveTab('memory')} />
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
<TabButton icon="monitoring" label={t('settings.tab.activity')} active={activeTab === 'activity'} onClick={() => setActiveTab('activity')} />
@ -114,6 +116,7 @@ export function Settings() {
{activeTab === 'mcp' && <McpSettings />}
{activeTab === 'agents' && <AgentsSettings />}
{activeTab === 'skills' && <SkillSettings />}
{activeTab === 'memory' && <MemorySettings />}
{activeTab === 'plugins' && <PluginSettings />}
{activeTab === 'computerUse' && <ComputerUseSettings />}
{activeTab === 'diagnostics' && <DiagnosticsSettings />}

View File

@ -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: {

View File

@ -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<ChatStore>((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<string, unknown>
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
}

View File

@ -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<void>
selectProject: (projectId: string) => void
fetchFiles: (projectId: string) => Promise<void>
openFile: (projectId: string, path: string) => Promise<void>
updateDraft: (content: string) => void
saveFile: () => Promise<void>
createFile: (projectId: string, path: string, content: string) => Promise<void>
}
export const useMemoryStore = create<MemoryStore>((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 })
}
},
}))

View File

@ -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<Toast, 'id'>) => void
@ -73,6 +76,7 @@ export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
activeView: 'code',
pendingSettingsTab: null,
pendingMemoryPath: null,
activeModal: null,
toasts: [],
@ -96,6 +100,7 @@ export const useUIStore = create<UIStore>((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 }),

View File

@ -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'

View File

@ -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
}

View File

@ -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<string, unknown>): Promise<Response> {
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),
)
}

View File

@ -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',
},
},
])
})
})

552
src/server/api/memory.ts Normal file
View File

@ -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<Response> {
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<MemoryProject[]> {
const projectsDir = getProjectsDir()
const currentCwd = cwd || getCwd()
const currentProjectId = getProjectIdForCwd(currentCwd)
const projects = new Map<string, MemoryProject>()
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<string, MemoryProject>, 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<MemoryFile[]> {
const memoryDir = await ensureMemoryDirBoundary(projectId, { mustExist: false })
if (!(await directoryExists(memoryDir))) return []
const files: MemoryFile[] = []
async function walk(dir: string, prefix = ''): Promise<void> {
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<Response> {
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<string> {
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<string> {
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<void> {
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<string> {
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<string> {
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<string | undefined> {
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<string> {
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<string | undefined> {
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<string | undefined> {
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<boolean> {
try {
const stat = await fs.stat(dir)
return stat.isDirectory()
} catch {
return false
}
}
async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath)
return stat.isFile()
} catch {
return false
}
}
async function countMarkdownFiles(dir: string): Promise<number> {
let count = 0
async function walk(current: string): Promise<void> {
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<Record<string, unknown>> {
try {
return (await req.json()) as Record<string, unknown>
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
}
function methodNotAllowed(method: string): ApiError {
return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
}

View File

@ -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<Response> {
const path = url.pathname
@ -111,6 +112,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'open-targets':
return handleOpenTargetsApi(req, url, segments)
case 'memory':
return handleMemoryApi(req, url, segments)
case 'filesystem':
return handleFilesystemRoute(url.pathname, url)

View File

@ -884,7 +884,7 @@ async function ensureCliSessionStarted(
}
}
function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
const streamState = getStreamState(sessionId)
switch (cliMsg.type) {
case 'assistant': {
@ -1183,6 +1183,18 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
}
return messages
}
if (subtype === 'memory_saved') {
return [{
type: 'system_notification',
subtype: 'memory_saved',
message: cliMsg.message,
data: {
writtenPaths: Array.isArray(cliMsg.writtenPaths) ? cliMsg.writtenPaths : [],
teamCount: typeof cliMsg.teamCount === 'number' ? cliMsg.teamCount : undefined,
verb: typeof cliMsg.verb === 'string' ? cliMsg.verb : undefined,
},
}]
}
if (subtype === 'hook_started' || subtype === 'hook_response') {
// Hook 执行中 — 不转发给前端
return []