From 747b94825bd0c3b0b275cb49db2baceadd805fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 13 May 2026 18:03:25 +0800 Subject: [PATCH] Recover real paths for memory project labels The CLI stores project directories with sanitizePath(), which intentionally replaces every non-alphanumeric byte with '-' for cross-platform safety. That storage key is lossy, so the desktop memory page must not treat it as the only source of display truth. The memory API now prefers the current cwd or the cwd/workDir/projectPath recorded in recent session JSONL files, and only falls back to the lossy reverse mapping when no metadata exists. Constraint: Keep the existing ~/.claude/projects//memory layout unchanged for CLI compatibility. Rejected: Change sanitizePath to preserve Unicode | would break existing transcript and memory directory lookup semantics. Confidence: high Scope-risk: narrow Directive: Treat project ids as storage keys, not user-facing path labels. Tested: bun test src/server/__tests__/memory.test.ts Tested: cd desktop && bunx vitest run src/__tests__/memorySettings.test.tsx Tested: local server curl /api/memory/projects with a Chinese-space project path Not-tested: bun run check:server still has two unrelated cron scheduler launcher timeout failures in src/server/__tests__/cron-scheduler-launcher.test.ts --- src/server/__tests__/memory.test.ts | 27 +++++++++++- src/server/api/memory.ts | 67 ++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/server/__tests__/memory.test.ts b/src/server/__tests__/memory.test.ts index 17e38900..b79a5750 100644 --- a/src/server/__tests__/memory.test.ts +++ b/src/server/__tests__/memory.test.ts @@ -83,6 +83,32 @@ describe('memory API', () => { 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('reads and writes only markdown files inside the project memory directory', async () => { const projectId = sanitizePath(path.join(tmpDir, 'workspace', 'app')) @@ -143,4 +169,3 @@ function request(method: string, pathname: string, body?: Record { const projectsDir = getProjectsDir() - const currentProjectId = getProjectIdForCwd(cwd || getCwd()) + const currentCwd = cwd || getCwd() + const currentProjectId = getProjectIdForCwd(currentCwd) const projects = new Map() addProject(projects, currentProjectId, true) @@ -93,9 +97,13 @@ async function listMemoryProjects(cwd?: string): Promise { const resolved = await Promise.all( Array.from(projects.values()).map(async project => { - const fileCount = await countMarkdownFiles(project.memoryDir) + const [fileCount, label] = await Promise.all([ + countMarkdownFiles(project.memoryDir), + resolveProjectLabel(project.id, currentCwd), + ]) return { ...project, + label, exists: fileCount > 0 || (await directoryExists(project.memoryDir)), fileCount, } @@ -361,6 +369,61 @@ function getProjectIdForCwd(cwd: string): string { return sanitizePath(findCanonicalGitRoot(cwd) ?? cwd) } +async function resolveProjectLabel(projectId: string, currentCwd: string): Promise { + const currentRoot = findCanonicalGitRoot(currentCwd) ?? currentCwd + if (sanitizePath(currentRoot) === projectId) return currentRoot + + const sessionPath = await inferProjectPathFromSessionFiles(projectId) + return sessionPath ?? unsanitizeProjectLabel(projectId) +} + +async function inferProjectPathFromSessionFiles(projectId: string): Promise { + const projectDir = path.join(getProjectsDir(), projectId) + let entries: import('node:fs').Dirent[] + try { + entries = await fs.readdir(projectDir, { withFileTypes: true }) + } catch { + return undefined + } + + const sessionFiles: Array<{ filePath: string; mtimeMs: number }> = [] + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue + const filePath = path.join(projectDir, entry.name) + try { + const stat = await fs.stat(filePath) + sessionFiles.push({ filePath, mtimeMs: stat.mtimeMs }) + } catch { + // A racing delete should not hide the rest of the memory projects. + } + } + + sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs) + for (const { filePath } of sessionFiles.slice(0, PROJECT_LABEL_SESSION_SCAN_LIMIT)) { + const head = await readFileHead(filePath, PROJECT_LABEL_HEAD_BYTES) + const candidate = + extractJsonStringField(head, 'cwd') ?? + extractJsonStringField(head, 'workDir') ?? + extractJsonStringField(head, 'projectPath') + if (candidate && path.isAbsolute(candidate)) return candidate.normalize('NFC') + } + + return undefined +} + +async function readFileHead(filePath: string, bytes: number): Promise { + const handle = await fs.open(filePath, 'r') + try { + const buffer = Buffer.alloc(bytes) + const { bytesRead } = await handle.read(buffer, 0, bytes, 0) + return buffer.subarray(0, bytesRead).toString('utf-8') + } catch { + return '' + } finally { + await handle.close() + } +} + function unsanitizeProjectLabel(projectId: string): string { return projectId.replace(/^-/, '/').replace(/-/g, '/') }