Recover legacy memory labels from existing paths

Some memory-only project directories have no session JSONL metadata, so the previous label recovery still fell back to reversing sanitizePath(). That reverse mapping is lossy for Chinese characters, spaces, and punctuation, leaving paths rendered with repeated slashes. The memory API now performs a bounded filesystem-guided recovery: it walks only roots whose sanitized prefix can still match the project id and only descends through candidate directories that preserve that prefix match.

Constraint: Do not change the existing ~/.claude/projects/<sanitized>/memory storage layout.

Rejected: Full-home recursive search | too expensive and unnecessary because sanitizePath prefix matching gives a tight traversal boundary.

Confidence: high

Scope-risk: narrow

Directive: Keep project ids as opaque storage keys; recover display labels from real metadata or existing directories first.

Tested: bun test src/server/__tests__/memory.test.ts

Tested: local server curl /api/memory/projects against the real PicTacticAgent legacy memory project
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 18:18:02 +08:00
parent 747b94825b
commit c125963346
2 changed files with 87 additions and 1 deletions

View File

@ -109,6 +109,24 @@ describe('memory API', () => {
expect(project).toMatchObject({ label: cwd })
})
it('recovers project labels from existing directories when no session metadata exists', async () => {
const cwd = path.join(tmpDir, '个人自媒体', '314', 'opus4', 'PicTacticAgent')
const projectId = sanitizePath(cwd)
const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory')
await fs.mkdir(cwd, { recursive: true })
await fs.mkdir(memoryDir, { recursive: true })
await fs.writeFile(path.join(memoryDir, 'MEMORY.md'), '# Project Memory')
const projectsRes = await request('GET', '/api/memory/projects')
expect(projectsRes.status).toBe(200)
const projectsBody = await projectsRes.json() as {
projects: Array<{ id: string; label: string }>
}
const project = projectsBody.projects.find((item) => item.id === projectId)
expect(project).toMatchObject({ label: cwd })
})
it('reads and writes only markdown files inside the project memory directory', async () => {
const projectId = sanitizePath(path.join(tmpDir, 'workspace', 'app'))

View File

@ -8,6 +8,7 @@
*/
import * as fs from 'node:fs/promises'
import { homedir } from 'node:os'
import * as path from 'node:path'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
@ -42,6 +43,8 @@ const MAX_MEMORY_FILE_BYTES = 512 * 1024
const MAX_MEMORY_FILES = 500
const PROJECT_LABEL_SESSION_SCAN_LIMIT = 10
const PROJECT_LABEL_HEAD_BYTES = 64 * 1024
const PROJECT_LABEL_FS_SEARCH_DEPTH = 24
const PROJECT_LABEL_FS_SEARCH_NODE_LIMIT = 2000
export async function handleMemoryApi(
req: Request,
@ -374,7 +377,10 @@ async function resolveProjectLabel(projectId: string, currentCwd: string): Promi
if (sanitizePath(currentRoot) === projectId) return currentRoot
const sessionPath = await inferProjectPathFromSessionFiles(projectId)
return sessionPath ?? unsanitizeProjectLabel(projectId)
if (sessionPath) return sessionPath
const filesystemPath = await inferProjectPathFromExistingDirectory(projectId)
return filesystemPath ?? unsanitizeProjectLabel(projectId)
}
async function inferProjectPathFromSessionFiles(projectId: string): Promise<string | undefined> {
@ -424,6 +430,68 @@ async function readFileHead(filePath: string, bytes: number): Promise<string> {
}
}
async function inferProjectPathFromExistingDirectory(projectId: string): Promise<string | undefined> {
const roots = Array.from(new Set([
homedir(),
process.env.HOME,
process.env.USERPROFILE,
'/private/tmp',
'/tmp',
].filter((root): root is string => Boolean(root && path.isAbsolute(root)))))
for (const root of roots) {
const resolvedRoot = path.resolve(root)
if (!sanitizedPrefixCanMatch(projectId, sanitizePath(resolvedRoot))) continue
const state = { visited: 0 }
const match = await findDirectoryBySanitizedPath(projectId, resolvedRoot, 0, state)
if (match) return match.normalize('NFC')
}
return undefined
}
async function findDirectoryBySanitizedPath(
projectId: string,
candidate: string,
depth: number,
state: { visited: number },
): Promise<string | undefined> {
if (state.visited >= PROJECT_LABEL_FS_SEARCH_NODE_LIMIT) return undefined
state.visited += 1
const candidateId = sanitizePath(candidate)
if (candidateId === projectId) return candidate
if (depth >= PROJECT_LABEL_FS_SEARCH_DEPTH || !sanitizedPrefixCanMatch(projectId, candidateId)) {
return undefined
}
let entries: import('node:fs').Dirent[]
try {
entries = await fs.readdir(candidate, { withFileTypes: true })
} catch {
return undefined
}
entries.sort((a, b) => a.name.localeCompare(b.name))
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const child = path.join(candidate, entry.name)
if (!sanitizedPrefixCanMatch(projectId, sanitizePath(child))) continue
if (entry.isSymbolicLink() && !(await directoryExists(child))) continue
const match = await findDirectoryBySanitizedPath(projectId, child, depth + 1, state)
if (match) return match
}
return undefined
}
function sanitizedPrefixCanMatch(projectId: string, prefix: string): boolean {
if (projectId === prefix) return true
return prefix.endsWith('-')
? projectId.startsWith(prefix)
: projectId.startsWith(`${prefix}-`)
}
function unsanitizeProjectLabel(projectId: string): string {
return projectId.replace(/^-/, '/').replace(/-/g, '/')
}