mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Surface project memory where desktop users can act on it
The CLI already emits memory_saved events and stores Markdown memory files, but the desktop app had no usable surface for seeing or editing those writes. This adds a project memory API, a Settings memory editor, chat memory event cards, and routing from /memory or /context into the memory UI. Constraint: Memory files live under Claude project storage and must remain plain Markdown editable by users. Rejected: Only expose raw filesystem links | users need an in-app review and edit flow from chat. Confidence: high Scope-risk: moderate Directive: Keep memory storage project-scoped and preserve unknown Markdown content when editing. Tested: bun test src/server/__tests__/ws-memory-events.test.ts src/server/__tests__/memory.test.ts Tested: bun run check:server Tested: bun run check:desktop Tested: agent-browser E2E for chat memory card, Open Memory navigation, and responsive Markdown editor layout Not-tested: Live model auto-memory trigger rate with real provider credentials
This commit is contained in:
parent
08747bfdd2
commit
df9775a6eb
232
desktop/src/__tests__/memorySettings.test.tsx
Normal file
232
desktop/src/__tests__/memorySettings.test.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
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()
|
||||
|
||||
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('creates a new markdown memory file with a project frontmatter template', async () => {
|
||||
memoryApiMock.listFiles
|
||||
.mockResolvedValueOnce({ files: [] })
|
||||
.mockResolvedValueOnce({
|
||||
files: [
|
||||
{
|
||||
path: 'notes/team.md',
|
||||
name: 'team.md',
|
||||
title: 'team',
|
||||
bytes: 75,
|
||||
updatedAt: '2026-05-01T00:02:00.000Z',
|
||||
type: 'project',
|
||||
isIndex: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
memoryApiMock.readFile.mockResolvedValueOnce({
|
||||
file: {
|
||||
path: 'notes/team.md',
|
||||
content: '# team\n',
|
||||
updatedAt: '2026-05-01T00:02:00.000Z',
|
||||
bytes: 75,
|
||||
},
|
||||
})
|
||||
|
||||
render(<MemorySettings />)
|
||||
|
||||
const pathInput = await screen.findByPlaceholderText('MEMORY.md or notes/project.md')
|
||||
fireEvent.change(pathInput, { target: { value: 'notes/team.md' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /new/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(memoryApiMock.saveFile).toHaveBeenCalledWith({
|
||||
projectId: '-workspace-demo',
|
||||
path: 'notes/team.md',
|
||||
content: expect.stringContaining('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()
|
||||
})
|
||||
})
|
||||
23
desktop/src/api/memory.ts
Normal file
23
desktop/src/api/memory.ts
Normal file
@ -0,0 +1,23 @@
|
||||
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),
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -6,6 +6,7 @@ import { sessionsApi } from '../../api/sessions'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import type { UIMessage } from '../../types/chat'
|
||||
import type { PerSessionState } from '../../stores/chatStore'
|
||||
|
||||
@ -46,6 +47,7 @@ describe('MessageList nested tool calls', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useUIStore.setState({ pendingSettingsTab: null })
|
||||
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
|
||||
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation(
|
||||
@ -139,6 +141,39 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(container.querySelectorAll('[data-message-shell="assistant"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders saved memory events with an entrypoint to memory settings', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'memory-1',
|
||||
type: 'memory_event',
|
||||
event: 'saved',
|
||||
files: [
|
||||
{ path: '/Users/test/.claude/projects/example/memory/preferences.md', action: 'saved' },
|
||||
],
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<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('keeps root tool runs split when nested child tool calls appear between them', () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect } from 'react'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import { ArrowDown, BookMarked, Settings } from 'lucide-react'
|
||||
import { ApiError } from '../../api/client'
|
||||
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@ -24,6 +24,7 @@ import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
|
||||
type ToolCall = Extract<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 }
|
||||
@ -241,6 +242,70 @@ function normalizeTurnCheckpoints(response: unknown): SessionTurnCheckpoint[] {
|
||||
return checkpoints.filter(isSessionTurnCheckpoint)
|
||||
}
|
||||
|
||||
function memoryFileLabel(path: string) {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
return normalized.split('/').pop() || normalized
|
||||
}
|
||||
|
||||
function openMemorySettings(path?: string) {
|
||||
const ui = useUIStore.getState()
|
||||
if (path) ui.setPendingMemoryPath(path)
|
||||
ui.setPendingSettingsTab('memory')
|
||||
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
||||
}
|
||||
|
||||
function MemoryEventCard({ message }: { message: MemoryEvent }) {
|
||||
const t = useTranslation()
|
||||
const visibleFiles = message.files.slice(0, 3)
|
||||
const hiddenCount = Math.max(0, message.files.length - visibleFiles.length)
|
||||
|
||||
return (
|
||||
<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
|
||||
@ -742,6 +807,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)]">
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -540,6 +540,30 @@ export const en = {
|
||||
'settings.skills.source.mcp': 'MCP',
|
||||
'settings.skills.source.bundled': 'Built-in',
|
||||
|
||||
// Settings > Memory
|
||||
'settings.tab.memory': 'Memory',
|
||||
'settings.memory.title': 'Project Memory',
|
||||
'settings.memory.description': 'Inspect and edit the Markdown memory files Claude writes for each project. These files are stored under ~/.claude/projects/<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.newPathPlaceholder': 'MEMORY.md or notes/project.md',
|
||||
'settings.memory.newFile': 'New',
|
||||
'settings.memory.emptyProjects': 'No memory projects found.',
|
||||
'settings.memory.emptyFiles': 'No memory files yet. Create MEMORY.md to start.',
|
||||
'settings.memory.selectFile': 'Select or create a memory file.',
|
||||
'settings.memory.selectProject': 'Select a project.',
|
||||
'settings.memory.noFileSelected': 'No file selected',
|
||||
'settings.memory.current': 'Current',
|
||||
'settings.memory.fileCount': '{count} files',
|
||||
'settings.memory.unsaved': 'Unsaved',
|
||||
'settings.memory.saved': 'Saved',
|
||||
'settings.memory.revert': 'Revert',
|
||||
'settings.memory.invalidPath': 'Use a relative .md path without .. segments.',
|
||||
'settings.memory.fileExists': 'A memory file already exists at that path.',
|
||||
|
||||
// Settings > Plugins
|
||||
'settings.plugins.title': 'Installed Plugins',
|
||||
'settings.plugins.description': 'Inspect installed plugins, see their health, and apply changes to the desktop runtime.',
|
||||
@ -892,6 +916,9 @@ export const en = {
|
||||
'slash.inspector.context.categoryTitle': 'Estimated usage by category',
|
||||
'slash.inspector.context.noCategoriesTitle': 'No context categories',
|
||||
'slash.inspector.context.noCategoriesBody': 'Context categories will appear after the CLI reports context analysis.',
|
||||
'slash.inspector.context.memoryFiles': 'Memory files',
|
||||
'slash.inspector.context.noMemoryFiles': 'No memory files are loaded in this session.',
|
||||
'slash.inspector.context.openMemory': 'Open Memory',
|
||||
'contextIndicator.ariaLabel': 'Context usage {percent}',
|
||||
'contextIndicator.pendingAria': 'Context usage not calculated',
|
||||
'contextIndicator.loadingAria': 'Context usage loading',
|
||||
@ -914,6 +941,9 @@ export const en = {
|
||||
'chat.dismiss': 'dismiss',
|
||||
'chat.stopTitle': 'Stop generation (Cmd+.)',
|
||||
'chat.jumpToLatest': 'Latest',
|
||||
'chat.memorySavedTitle': 'Saved {count} memory file(s)',
|
||||
'chat.memoryOpenSettings': 'Open Memory',
|
||||
'chat.memoryMoreFiles': '+{count} more',
|
||||
'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.',
|
||||
'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.',
|
||||
'chat.turnChangesTitle': '{count} files changed',
|
||||
|
||||
@ -542,6 +542,30 @@ 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.newPathPlaceholder': 'MEMORY.md 或 notes/project.md',
|
||||
'settings.memory.newFile': '新建',
|
||||
'settings.memory.emptyProjects': '还没有找到记忆项目。',
|
||||
'settings.memory.emptyFiles': '还没有记忆文件。可以先创建 MEMORY.md。',
|
||||
'settings.memory.selectFile': '选择或创建一个记忆文件。',
|
||||
'settings.memory.selectProject': '选择一个项目。',
|
||||
'settings.memory.noFileSelected': '未选择文件',
|
||||
'settings.memory.current': '当前',
|
||||
'settings.memory.fileCount': '{count} 个文件',
|
||||
'settings.memory.unsaved': '未保存',
|
||||
'settings.memory.saved': '已保存',
|
||||
'settings.memory.revert': '还原',
|
||||
'settings.memory.invalidPath': '请使用相对 .md 路径,且不能包含 .. 片段。',
|
||||
'settings.memory.fileExists': '该路径下已经有记忆文件。',
|
||||
|
||||
// Settings > Plugins
|
||||
'settings.plugins.title': '已安装插件',
|
||||
'settings.plugins.description': '查看已安装插件、运行状态,并把插件变更应用到桌面端运行时。',
|
||||
@ -894,6 +918,9 @@ export const zh: Record<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': '上下文用量加载中',
|
||||
@ -916,6 +943,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.dismiss': '关闭',
|
||||
'chat.stopTitle': '停止生成 (Cmd+.)',
|
||||
'chat.jumpToLatest': '回到最新',
|
||||
'chat.memorySavedTitle': '已保存 {count} 个记忆文件',
|
||||
'chat.memoryOpenSettings': '打开记忆',
|
||||
'chat.memoryMoreFiles': '还有 {count} 个',
|
||||
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
|
||||
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
|
||||
'chat.turnChangesTitle': '{count} 个文件已更改',
|
||||
|
||||
452
desktop/src/pages/MemorySettings.tsx
Normal file
452
desktop/src/pages/MemorySettings.tsx
Normal file
@ -0,0 +1,452 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import { Input } from '../components/shared/Input'
|
||||
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { formatBytes } from '../lib/formatBytes'
|
||||
import { useMemoryStore } from '../stores/memoryStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import type { MemoryFile, MemoryProject } from '../types/memory'
|
||||
|
||||
const DEFAULT_MEMORY_PATH = 'MEMORY.md'
|
||||
|
||||
export function MemorySettings() {
|
||||
const t = useTranslation()
|
||||
const {
|
||||
projects,
|
||||
files,
|
||||
selectedProjectId,
|
||||
selectedFile,
|
||||
draftContent,
|
||||
isLoadingProjects,
|
||||
isLoadingFiles,
|
||||
isLoadingFile,
|
||||
isSaving,
|
||||
error,
|
||||
lastSavedAt,
|
||||
fetchProjects,
|
||||
selectProject,
|
||||
fetchFiles,
|
||||
openFile,
|
||||
updateDraft,
|
||||
saveFile,
|
||||
createFile,
|
||||
} = useMemoryStore()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||
const pendingMemoryPath = useUIStore((s) => s.pendingMemoryPath)
|
||||
const setPendingMemoryPath = useUIStore((s) => s.setPendingMemoryPath)
|
||||
const [newPath, setNewPath] = useState('')
|
||||
const [newPathError, setNewPathError] = useState<string | null>(null)
|
||||
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((session) => session.id === activeSessionId),
|
||||
[activeSessionId, sessions],
|
||||
)
|
||||
const activeCwd = activeSession?.workDir || activeSession?.projectPath || undefined
|
||||
const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null
|
||||
const isDirty = Boolean(selectedFile && draftContent !== selectedFile.content)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchProjects(activeCwd)
|
||||
}, [activeCwd, fetchProjects])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) return
|
||||
void fetchFiles(selectedProjectId)
|
||||
}, [fetchFiles, selectedProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
|
||||
if (pendingMemoryPath) return
|
||||
const firstFile = files[0]
|
||||
if (firstFile) {
|
||||
void openFile(selectedProjectId, firstFile.path)
|
||||
}
|
||||
}, [files, isLoadingFile, isLoadingFiles, openFile, pendingMemoryPath, selectedFile, selectedProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingMemoryPath || isLoadingProjects || projects.length === 0) return
|
||||
const target = resolveMemoryFileTarget(projects, pendingMemoryPath)
|
||||
if (!target) {
|
||||
setPendingMemoryPath(null)
|
||||
return
|
||||
}
|
||||
if (selectedProjectId !== target.projectId) {
|
||||
selectProject(target.projectId)
|
||||
return
|
||||
}
|
||||
if (selectedFile?.path === target.path && !isLoadingFile) {
|
||||
setPendingMemoryPath(null)
|
||||
return
|
||||
}
|
||||
void openFile(target.projectId, target.path).then(() => {
|
||||
setPendingMemoryPath(null)
|
||||
})
|
||||
}, [
|
||||
isLoadingFile,
|
||||
isLoadingProjects,
|
||||
openFile,
|
||||
pendingMemoryPath,
|
||||
projects,
|
||||
selectProject,
|
||||
selectedFile?.path,
|
||||
selectedProjectId,
|
||||
setPendingMemoryPath,
|
||||
])
|
||||
|
||||
const handleRefresh = () => {
|
||||
void fetchProjects(activeCwd)
|
||||
if (selectedProjectId) {
|
||||
void fetchFiles(selectedProjectId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProjectSelect = (projectId: string) => {
|
||||
if (projectId === selectedProjectId) return
|
||||
selectProject(projectId)
|
||||
}
|
||||
|
||||
const handleFileOpen = (file: MemoryFile) => {
|
||||
if (!selectedProjectId || file.path === selectedFile?.path) return
|
||||
void openFile(selectedProjectId, file.path)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!selectedProjectId) return
|
||||
const path = normalizeMemoryPath(newPath || DEFAULT_MEMORY_PATH)
|
||||
if (!isValidMemoryPath(path)) {
|
||||
setNewPathError(t('settings.memory.invalidPath'))
|
||||
return
|
||||
}
|
||||
if (files.some((file) => file.path === path)) {
|
||||
setNewPathError(t('settings.memory.fileExists'))
|
||||
return
|
||||
}
|
||||
setNewPath('')
|
||||
setNewPathError(null)
|
||||
void createFile(selectedProjectId, path, buildMemoryTemplate(path))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[640px] flex-col gap-5">
|
||||
<header className="flex flex-col gap-4 border-b border-[var(--color-border)] pb-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.memory.title')}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{t('settings.memory.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
loading={isLoadingProjects || isLoadingFiles}
|
||||
icon={<span className="material-symbols-outlined text-[16px]">refresh</span>}
|
||||
>
|
||||
{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-[300px_minmax(0,1fr)]">
|
||||
<div className="grid min-h-0 content-start gap-4">
|
||||
<section className="min-h-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<PanelHeader
|
||||
title={t('settings.memory.projects')}
|
||||
meta={isLoadingProjects ? t('common.loading') : String(projects.length)}
|
||||
/>
|
||||
<div className="max-h-[240px] overflow-y-auto p-2">
|
||||
{projects.length === 0 && !isLoadingProjects ? (
|
||||
<EmptyState text={t('settings.memory.emptyProjects')} />
|
||||
) : (
|
||||
projects.map((project) => (
|
||||
<ProjectRow
|
||||
key={project.id}
|
||||
project={project}
|
||||
active={project.id === selectedProjectId}
|
||||
onSelect={() => handleProjectSelect(project.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="min-h-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<PanelHeader
|
||||
title={t('settings.memory.files')}
|
||||
meta={isLoadingFiles ? t('common.loading') : `${files.length}`}
|
||||
/>
|
||||
<div className="border-b border-[var(--color-border)] p-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newPath}
|
||||
onChange={(event) => {
|
||||
setNewPath(event.target.value)
|
||||
setNewPathError(null)
|
||||
}}
|
||||
placeholder={t('settings.memory.newPathPlaceholder')}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!selectedProjectId}
|
||||
loading={isSaving && !selectedFile}
|
||||
onClick={handleCreate}
|
||||
icon={<span className="material-symbols-outlined text-[16px]">add</span>}
|
||||
>
|
||||
{t('settings.memory.newFile')}
|
||||
</Button>
|
||||
</div>
|
||||
{newPathError && (
|
||||
<p className="mt-2 text-xs text-[var(--color-error)]">{newPathError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto p-2">
|
||||
{files.length === 0 && !isLoadingFiles ? (
|
||||
<EmptyState text={t('settings.memory.emptyFiles')} />
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<FileRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
active={file.path === selectedFile?.path}
|
||||
onSelect={() => handleFileOpen(file)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="min-h-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<div className="flex flex-col gap-3 border-b border-[var(--color-border)] p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm 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)}
|
||||
>
|
||||
{t('settings.memory.revert')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!selectedFile || !isDirty}
|
||||
loading={isSaving}
|
||||
onClick={() => void saveFile()}
|
||||
icon={<span className="material-symbols-outlined text-[16px]">save</span>}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="grid min-h-[520px] grid-rows-[minmax(300px,1fr)_minmax(240px,0.85fr)] 2xl:grid-cols-2 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-9 items-center justify-between border-b border-[var(--color-border)] 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%-36px)] w-full resize-none overflow-auto bg-transparent p-4 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-9 items-center justify-between border-b border-[var(--color-border)] 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-4">
|
||||
<MarkdownRenderer content={draftContent || ' '} variant="document" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[520px] items-center justify-center p-8">
|
||||
<EmptyState text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelHeader({ title, meta }: { title: string; meta: string }) {
|
||||
return (
|
||||
<div className="flex h-12 items-center justify-between border-b border-[var(--color-border)] px-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">{title}</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()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`mb-1 w-full rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium">{project.label}</span>
|
||||
{project.isCurrent && <Badge>{t('settings.memory.current')}</Badge>}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>{t('settings.memory.fileCount', { count: project.fileCount })}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
file,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
file: MemoryFile
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`mb-1 w-full rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium">{file.title}</span>
|
||||
{file.type && <Badge>{file.type}</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs 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>
|
||||
)}
|
||||
</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({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="px-3 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeMemoryPath(value: string): string {
|
||||
return value.trim().replace(/\\/g, '/').replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
function normalizeFsPath(value: string): string {
|
||||
return value.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function resolveMemoryFileTarget(projects: MemoryProject[], absolutePath: string): { projectId: string; path: string } | null {
|
||||
const target = normalizeFsPath(absolutePath)
|
||||
for (const project of projects) {
|
||||
const memoryDir = normalizeFsPath(project.memoryDir)
|
||||
if (!memoryDir) continue
|
||||
if (target === memoryDir) {
|
||||
return { projectId: project.id, path: DEFAULT_MEMORY_PATH }
|
||||
}
|
||||
if (target.startsWith(`${memoryDir}/`)) {
|
||||
return {
|
||||
projectId: project.id,
|
||||
path: target.slice(memoryDir.length + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isValidMemoryPath(path: string): boolean {
|
||||
return (
|
||||
path.length > 0 &&
|
||||
path.endsWith('.md') &&
|
||||
!path.includes('\0') &&
|
||||
!path.split('/').some((part) => part === '' || part === '.' || part === '..')
|
||||
)
|
||||
}
|
||||
|
||||
function buildMemoryTemplate(path: string): string {
|
||||
const parts = path.split('/')
|
||||
const title = parts[parts.length - 1]?.replace(/\.md$/, '') || 'Memory'
|
||||
return `---
|
||||
type: project
|
||||
description: Manually curated project memory.
|
||||
---
|
||||
|
||||
# ${title}
|
||||
|
||||
`
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
@ -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 />}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
139
desktop/src/stores/memoryStore.ts
Normal file
139
desktop/src/stores/memoryStore.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { create } from 'zustand'
|
||||
import { memoryApi } from '../api/memory'
|
||||
import type { MemoryFile, MemoryFileDetail, MemoryProject } from '../types/memory'
|
||||
|
||||
type MemoryStore = {
|
||||
projects: MemoryProject[]
|
||||
files: MemoryFile[]
|
||||
selectedProjectId: string | null
|
||||
selectedFile: MemoryFileDetail | null
|
||||
draftContent: string
|
||||
isLoadingProjects: boolean
|
||||
isLoadingFiles: boolean
|
||||
isLoadingFile: boolean
|
||||
isSaving: boolean
|
||||
error: string | null
|
||||
lastSavedAt: string | null
|
||||
|
||||
fetchProjects: (cwd?: string) => Promise<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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@ -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 }),
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
27
desktop/src/types/memory.ts
Normal file
27
desktop/src/types/memory.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export type MemoryProject = {
|
||||
id: string
|
||||
label: string
|
||||
memoryDir: string
|
||||
exists: boolean
|
||||
fileCount: number
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
export type MemoryFile = {
|
||||
path: string
|
||||
name: string
|
||||
bytes: number
|
||||
updatedAt: string
|
||||
type?: string
|
||||
description?: string
|
||||
title: string
|
||||
isIndex: boolean
|
||||
}
|
||||
|
||||
export type MemoryFileDetail = {
|
||||
path: string
|
||||
content: string
|
||||
updatedAt: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
146
src/server/__tests__/memory.test.ts
Normal file
146
src/server/__tests__/memory.test.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { handleMemoryApi } from '../api/memory.js'
|
||||
import { sanitizePath } from '../../utils/path.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
let originalHome: string | undefined
|
||||
let originalUserProfile: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-memory-api-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
originalHome = process.env.HOME
|
||||
originalUserProfile = process.env.USERPROFILE
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
process.env.HOME = tmpDir
|
||||
process.env.USERPROFILE = tmpDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
if (originalHome !== undefined) {
|
||||
process.env.HOME = originalHome
|
||||
} else {
|
||||
delete process.env.HOME
|
||||
}
|
||||
if (originalUserProfile !== undefined) {
|
||||
process.env.USERPROFILE = originalUserProfile
|
||||
} else {
|
||||
delete process.env.USERPROFILE
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('memory API', () => {
|
||||
it('lists current project memory files with frontmatter metadata', async () => {
|
||||
const cwd = path.join(tmpDir, 'workspace', 'app')
|
||||
const projectId = sanitizePath(cwd)
|
||||
const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory')
|
||||
await fs.mkdir(path.join(memoryDir, 'notes'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, 'MEMORY.md'),
|
||||
[
|
||||
'---',
|
||||
'type: project',
|
||||
'description: Stable project conventions.',
|
||||
'---',
|
||||
'',
|
||||
'# Project Memory',
|
||||
].join('\n'),
|
||||
)
|
||||
await fs.writeFile(path.join(memoryDir, 'notes', 'manual.md'), '# Manual')
|
||||
|
||||
const projectsRes = await request('GET', `/api/memory/projects?cwd=${encodeURIComponent(cwd)}`)
|
||||
expect(projectsRes.status).toBe(200)
|
||||
const projectsBody = await projectsRes.json() as {
|
||||
projects: Array<{ id: string; isCurrent: boolean; fileCount: number }>
|
||||
}
|
||||
expect(projectsBody.projects[0]).toMatchObject({
|
||||
id: projectId,
|
||||
isCurrent: true,
|
||||
fileCount: 2,
|
||||
})
|
||||
|
||||
const filesRes = await request('GET', `/api/memory/files?projectId=${encodeURIComponent(projectId)}`)
|
||||
expect(filesRes.status).toBe(200)
|
||||
const filesBody = await filesRes.json() as {
|
||||
files: Array<{ path: string; type?: string; description?: string; isIndex: boolean }>
|
||||
}
|
||||
expect(filesBody.files[0]).toMatchObject({
|
||||
path: 'MEMORY.md',
|
||||
type: 'project',
|
||||
description: 'Stable project conventions.',
|
||||
isIndex: true,
|
||||
})
|
||||
expect(filesBody.files.some((file) => file.path === 'notes/manual.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('reads and writes only markdown files inside the project memory directory', async () => {
|
||||
const projectId = sanitizePath(path.join(tmpDir, 'workspace', 'app'))
|
||||
|
||||
const writeRes = await request('PUT', '/api/memory/file', {
|
||||
projectId,
|
||||
path: 'notes/project.md',
|
||||
content: '# Edited Memory\n',
|
||||
})
|
||||
expect(writeRes.status).toBe(200)
|
||||
|
||||
const filePath = path.join(tmpDir, 'projects', projectId, 'memory', 'notes', 'project.md')
|
||||
expect(await fs.readFile(filePath, 'utf-8')).toBe('# Edited Memory\n')
|
||||
|
||||
const readRes = await request('GET', `/api/memory/file?projectId=${encodeURIComponent(projectId)}&path=notes%2Fproject.md`)
|
||||
expect(readRes.status).toBe(200)
|
||||
const body = await readRes.json() as { file: { path: string; content: string } }
|
||||
expect(body.file).toMatchObject({
|
||||
path: 'notes/project.md',
|
||||
content: '# Edited Memory\n',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects traversal and symlink escapes', async () => {
|
||||
const projectId = sanitizePath(path.join(tmpDir, 'workspace', 'app'))
|
||||
const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory')
|
||||
const outsideDir = path.join(tmpDir, 'outside')
|
||||
await fs.mkdir(memoryDir, { recursive: true })
|
||||
await fs.mkdir(outsideDir, { recursive: true })
|
||||
await fs.symlink(outsideDir, path.join(memoryDir, 'linked'), 'dir')
|
||||
|
||||
const traversalRes = await request('PUT', '/api/memory/file', {
|
||||
projectId,
|
||||
path: '../outside.md',
|
||||
content: 'escape',
|
||||
})
|
||||
expect(traversalRes.status).toBe(400)
|
||||
|
||||
const symlinkRes = await request('PUT', '/api/memory/file', {
|
||||
projectId,
|
||||
path: 'linked/outside.md',
|
||||
content: 'escape',
|
||||
})
|
||||
expect(symlinkRes.status).toBe(400)
|
||||
await expect(fs.readFile(path.join(outsideDir, 'outside.md'), 'utf-8')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
function request(method: string, pathname: string, body?: Record<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),
|
||||
)
|
||||
}
|
||||
|
||||
36
src/server/__tests__/ws-memory-events.test.ts
Normal file
36
src/server/__tests__/ws-memory-events.test.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
421
src/server/api/memory.ts
Normal file
421
src/server/api/memory.ts
Normal file
@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Memory REST API
|
||||
*
|
||||
* GET /api/memory/projects?cwd=... — list project-scoped memory dirs
|
||||
* GET /api/memory/files?projectId=... — list markdown memory files
|
||||
* GET /api/memory/file?projectId=...&path=...
|
||||
* PUT /api/memory/file — update/create a markdown memory file
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||
import { findCanonicalGitRoot } from '../../utils/git.js'
|
||||
import { sanitizePath } from '../../utils/path.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { parseMemoryType } from '../../memdir/memoryTypes.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
type MemoryProject = {
|
||||
id: string
|
||||
label: string
|
||||
memoryDir: string
|
||||
exists: boolean
|
||||
fileCount: number
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
type MemoryFile = {
|
||||
path: string
|
||||
name: string
|
||||
bytes: number
|
||||
updatedAt: string
|
||||
type?: string
|
||||
description?: string
|
||||
title: string
|
||||
isIndex: boolean
|
||||
}
|
||||
|
||||
const MAX_MEMORY_FILE_BYTES = 512 * 1024
|
||||
const MAX_MEMORY_FILES = 500
|
||||
|
||||
export async function handleMemoryApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[],
|
||||
): Promise<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 currentProjectId = getProjectIdForCwd(cwd || getCwd())
|
||||
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 = await countMarkdownFiles(project.memoryDir)
|
||||
return {
|
||||
...project,
|
||||
exists: fileCount > 0 || (await directoryExists(project.memoryDir)),
|
||||
fileCount,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return resolved
|
||||
.filter((project) => project.isCurrent || project.exists || project.fileCount > 0)
|
||||
.sort((a, b) => {
|
||||
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1
|
||||
if (a.fileCount !== b.fileCount) return b.fileCount - a.fileCount
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
}
|
||||
|
||||
function addProject(projects: Map<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)
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
@ -24,6 +24,7 @@ import { handleDiagnosticsApi } from './api/diagnostics.js'
|
||||
import { handleDoctorApi } from './api/doctor.js'
|
||||
import { handleH5AccessApi } from './api/h5-access.js'
|
||||
import { handleActivityStatsApi } from './api/activityStats.js'
|
||||
import { handleMemoryApi } from './api/memory.js'
|
||||
|
||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||
const path = url.pathname
|
||||
@ -107,6 +108,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'activity-stats':
|
||||
return handleActivityStatsApi(req, url, segments)
|
||||
|
||||
case 'memory':
|
||||
return handleMemoryApi(req, url, segments)
|
||||
|
||||
case 'filesystem':
|
||||
return handleFilesystemRoute(url.pathname, url)
|
||||
|
||||
|
||||
@ -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 []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user