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