mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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/<sanitized>/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
This commit is contained in:
parent
e6554b9693
commit
747b94825b
@ -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<string, unknown
|
||||
url.pathname.split('/').filter(Boolean),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||
import { findCanonicalGitRoot } from '../../utils/git.js'
|
||||
import { sanitizePath } from '../../utils/path.js'
|
||||
import { extractJsonStringField } from '../../utils/sessionStoragePortable.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { parseMemoryType } from '../../memdir/memoryTypes.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
@ -39,6 +40,8 @@ type MemoryFile = {
|
||||
|
||||
const MAX_MEMORY_FILE_BYTES = 512 * 1024
|
||||
const MAX_MEMORY_FILES = 500
|
||||
const PROJECT_LABEL_SESSION_SCAN_LIMIT = 10
|
||||
const PROJECT_LABEL_HEAD_BYTES = 64 * 1024
|
||||
|
||||
export async function handleMemoryApi(
|
||||
req: Request,
|
||||
@ -74,7 +77,8 @@ export async function handleMemoryApi(
|
||||
|
||||
async function listMemoryProjects(cwd?: string): Promise<MemoryProject[]> {
|
||||
const projectsDir = getProjectsDir()
|
||||
const currentProjectId = getProjectIdForCwd(cwd || getCwd())
|
||||
const currentCwd = cwd || getCwd()
|
||||
const currentProjectId = getProjectIdForCwd(currentCwd)
|
||||
const projects = new Map<string, MemoryProject>()
|
||||
|
||||
addProject(projects, currentProjectId, true)
|
||||
@ -93,9 +97,13 @@ async function listMemoryProjects(cwd?: string): Promise<MemoryProject[]> {
|
||||
|
||||
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<string> {
|
||||
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<string | undefined> {
|
||||
const projectDir = path.join(getProjectsDir(), projectId)
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(projectDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sessionFiles: Array<{ filePath: string; mtimeMs: number }> = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
|
||||
const filePath = path.join(projectDir, entry.name)
|
||||
try {
|
||||
const stat = await fs.stat(filePath)
|
||||
sessionFiles.push({ filePath, mtimeMs: stat.mtimeMs })
|
||||
} catch {
|
||||
// A racing delete should not hide the rest of the memory projects.
|
||||
}
|
||||
}
|
||||
|
||||
sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
for (const { filePath } of sessionFiles.slice(0, PROJECT_LABEL_SESSION_SCAN_LIMIT)) {
|
||||
const head = await readFileHead(filePath, PROJECT_LABEL_HEAD_BYTES)
|
||||
const candidate =
|
||||
extractJsonStringField(head, 'cwd') ??
|
||||
extractJsonStringField(head, 'workDir') ??
|
||||
extractJsonStringField(head, 'projectPath')
|
||||
if (candidate && path.isAbsolute(candidate)) return candidate.normalize('NFC')
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function readFileHead(filePath: string, bytes: number): Promise<string> {
|
||||
const handle = await fs.open(filePath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(bytes)
|
||||
const { bytesRead } = await handle.read(buffer, 0, bytes, 0)
|
||||
return buffer.subarray(0, bytesRead).toString('utf-8')
|
||||
} catch {
|
||||
return ''
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function unsanitizeProjectLabel(projectId: string): string {
|
||||
return projectId.replace(/^-/, '/').replace(/-/g, '/')
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user