mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
747b94825b
commit
c125963346
@ -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'))
|
||||
|
||||
|
||||
@ -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, '/')
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user