cc-haha/src/server/api/memory.ts
程序员阿江(Relakkes) c125963346 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
2026-05-13 18:18:02 +08:00

553 lines
17 KiB
TypeScript

/**
* Memory REST API
*
* GET /api/memory/projects?cwd=... — list project-scoped memory dirs
* GET /api/memory/files?projectId=... — list markdown memory files
* GET /api/memory/file?projectId=...&path=...
* PUT /api/memory/file — update/create a markdown memory file
*/
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'
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'
type MemoryProject = {
id: string
label: string
memoryDir: string
exists: boolean
fileCount: number
isCurrent: boolean
}
type MemoryFile = {
path: string
name: string
bytes: number
updatedAt: string
type?: string
description?: string
title: string
isIndex: boolean
}
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,
url: URL,
segments: string[],
): Promise<Response> {
try {
const sub = segments[2]
switch (sub) {
case 'projects':
if (req.method !== 'GET') throw methodNotAllowed(req.method)
return Response.json({
projects: await listMemoryProjects(url.searchParams.get('cwd') || undefined),
})
case 'files':
if (req.method !== 'GET') throw methodNotAllowed(req.method)
return Response.json({
files: await listMemoryFiles(requireProjectId(url)),
})
case 'file':
return await handleMemoryFile(req, url)
default:
throw ApiError.notFound(`Unknown memory endpoint: ${sub}`)
}
} catch (error) {
return errorResponse(error)
}
}
async function listMemoryProjects(cwd?: string): Promise<MemoryProject[]> {
const projectsDir = getProjectsDir()
const currentCwd = cwd || getCwd()
const currentProjectId = getProjectIdForCwd(currentCwd)
const projects = new Map<string, MemoryProject>()
addProject(projects, currentProjectId, true)
let entries: import('node:fs').Dirent[]
try {
entries = await fs.readdir(projectsDir, { withFileTypes: true })
} catch {
entries = []
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
addProject(projects, entry.name, entry.name === currentProjectId)
}
const resolved = await Promise.all(
Array.from(projects.values()).map(async project => {
const [fileCount, label] = await Promise.all([
countMarkdownFiles(project.memoryDir),
resolveProjectLabel(project.id, currentCwd),
])
return {
...project,
label,
exists: fileCount > 0 || (await directoryExists(project.memoryDir)),
fileCount,
}
}),
)
return resolved
.filter((project) => project.isCurrent || project.exists || project.fileCount > 0)
.sort((a, b) => {
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1
if (a.fileCount !== b.fileCount) return b.fileCount - a.fileCount
return a.label.localeCompare(b.label)
})
}
function addProject(projects: Map<string, MemoryProject>, id: string, isCurrent: boolean) {
if (!isValidProjectId(id)) return
const existing = projects.get(id)
if (existing) {
existing.isCurrent = existing.isCurrent || isCurrent
return
}
projects.set(id, {
id,
label: unsanitizeProjectLabel(id),
memoryDir: path.join(getProjectsDir(), id, 'memory'),
exists: false,
fileCount: 0,
isCurrent,
})
}
async function listMemoryFiles(projectId: string): Promise<MemoryFile[]> {
const memoryDir = await ensureMemoryDirBoundary(projectId, { mustExist: false })
if (!(await directoryExists(memoryDir))) return []
const files: MemoryFile[] = []
async function walk(dir: string, prefix = ''): Promise<void> {
if (files.length >= MAX_MEMORY_FILES) return
let entries: import('node:fs').Dirent[]
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return
}
entries.sort((a, b) => {
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
return a.name.localeCompare(b.name)
})
for (const entry of entries) {
if (files.length >= MAX_MEMORY_FILES) break
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(fullPath, relativePath)
continue
}
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
const stat = await fs.stat(fullPath)
let type: string | undefined
let description: string | undefined
try {
if (stat.size <= MAX_MEMORY_FILE_BYTES) {
const raw = await fs.readFile(fullPath, 'utf-8')
const parsed = parseFrontmatter(raw, fullPath)
type = parseMemoryType(parsed.frontmatter.type) ?? undefined
description =
typeof parsed.frontmatter.description === 'string'
? parsed.frontmatter.description
: undefined
}
} catch {
// Metadata is best-effort. The file remains editable from the UI.
}
files.push({
path: relativePath,
name: entry.name,
bytes: stat.size,
updatedAt: stat.mtime.toISOString(),
type,
description,
title: relativePath === 'MEMORY.md' ? 'MEMORY.md' : entry.name.replace(/\.md$/, ''),
isIndex: relativePath === 'MEMORY.md',
})
}
}
await walk(memoryDir)
return files.sort((a, b) => {
if (a.isIndex !== b.isIndex) return a.isIndex ? -1 : 1
return a.path.localeCompare(b.path)
})
}
async function handleMemoryFile(req: Request, url: URL): Promise<Response> {
if (req.method === 'GET') {
const projectId = requireProjectId(url)
const relativePath = requireMemoryPath(url.searchParams.get('path'))
const fullPath = await resolveMemoryFilePath(projectId, relativePath, {
mustExist: true,
})
const stat = await fs.stat(fullPath)
if (stat.size > MAX_MEMORY_FILE_BYTES) {
throw ApiError.badRequest(`Memory file is too large to edit: ${relativePath}`)
}
return Response.json({
file: {
path: relativePath,
content: await fs.readFile(fullPath, 'utf-8'),
updatedAt: stat.mtime.toISOString(),
bytes: stat.size,
},
})
}
if (req.method === 'PUT') {
const body = await parseJsonBody(req)
const projectId = typeof body.projectId === 'string' ? body.projectId : ''
const relativePath = requireMemoryPath(
typeof body.path === 'string' ? body.path : undefined,
)
const content = typeof body.content === 'string' ? body.content : undefined
if (content === undefined) {
throw ApiError.badRequest('Missing or invalid "content" in request body')
}
if (Buffer.byteLength(content, 'utf-8') > MAX_MEMORY_FILE_BYTES) {
throw ApiError.badRequest('Memory file content exceeds 512 KB')
}
const fullPath = await resolveMemoryFilePath(projectId, relativePath, {
mustExist: false,
})
const memoryDir = await ensureMemoryDirBoundary(projectId, { mustExist: false })
await fs.mkdir(path.dirname(fullPath), { recursive: true, mode: 0o700 })
await assertWithinDirectory(path.dirname(fullPath), memoryDir, true)
if (await fileExists(fullPath)) {
await assertWithinDirectory(fullPath, memoryDir, true)
}
await fs.writeFile(fullPath, content, { encoding: 'utf-8', mode: 0o600 })
const stat = await fs.stat(fullPath)
return Response.json({
ok: true,
file: {
path: relativePath,
updatedAt: stat.mtime.toISOString(),
bytes: stat.size,
},
})
}
throw methodNotAllowed(req.method)
}
function requireProjectId(url: URL): string {
const projectId = url.searchParams.get('projectId')
if (!projectId) throw ApiError.badRequest('Missing projectId')
return projectId
}
function requireMemoryPath(value: string | null | undefined): string {
if (!value || typeof value !== 'string') {
throw ApiError.badRequest('Missing memory file path')
}
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '')
if (
normalized.length === 0 ||
normalized.includes('\0') ||
normalized.split('/').some(part => part === '' || part === '.' || part === '..') ||
!normalized.endsWith('.md')
) {
throw ApiError.badRequest('Memory path must be a relative .md file path')
}
return normalized
}
async function resolveMemoryFilePath(
projectId: string,
relativePath: string,
opts: { mustExist: boolean },
): Promise<string> {
const memoryDir = await ensureMemoryDirBoundary(projectId, {
mustExist: opts.mustExist,
})
const candidate = path.resolve(memoryDir, relativePath)
await assertWithinDirectory(candidate, memoryDir, opts.mustExist)
return candidate
}
async function ensureMemoryDirBoundary(
projectId: string,
opts: { mustExist: boolean },
): Promise<string> {
if (!isValidProjectId(projectId)) {
throw ApiError.badRequest('Invalid projectId')
}
const projectsDir = path.resolve(getProjectsDir())
const projectDir = path.resolve(projectsDir, projectId)
await assertWithinDirectory(projectDir, projectsDir, false)
const memoryDir = path.resolve(projectDir, 'memory')
if (await directoryExists(projectDir)) {
await assertWithinDirectory(projectDir, projectsDir, true)
}
if (await directoryExists(memoryDir)) {
await assertWithinDirectory(memoryDir, projectDir, true)
}
if (opts.mustExist && !(await directoryExists(memoryDir))) {
throw ApiError.notFound(`Memory project not found: ${projectId}`)
}
return memoryDir
}
async function assertWithinDirectory(
candidate: string,
directory: string,
mustExist: boolean,
): Promise<void> {
const resolvedDirectory = mustExist
? await safeRealpath(directory)
: path.resolve(directory)
const resolvedCandidate = mustExist
? await safeRealpath(candidate)
: path.resolve(candidate)
const boundary = resolvedDirectory.endsWith(path.sep)
? resolvedDirectory
: `${resolvedDirectory}${path.sep}`
if (resolvedCandidate !== resolvedDirectory && !resolvedCandidate.startsWith(boundary)) {
throw ApiError.badRequest('Path escapes memory directory')
}
}
async function safeRealpath(targetPath: string): Promise<string> {
try {
return await fs.realpath(targetPath)
} catch {
return path.resolve(targetPath)
}
}
function isValidProjectId(projectId: string): boolean {
return (
projectId.length > 0 &&
!projectId.includes('\0') &&
!projectId.includes('/') &&
!projectId.includes('\\') &&
projectId !== '.' &&
projectId !== '..'
)
}
function getProjectsDir(): string {
return path.join(getClaudeConfigHomeDir(), 'projects')
}
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)
if (sessionPath) return sessionPath
const filesystemPath = await inferProjectPathFromExistingDirectory(projectId)
return filesystemPath ?? 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()
}
}
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, '/')
}
async function directoryExists(dir: string): Promise<boolean> {
try {
const stat = await fs.stat(dir)
return stat.isDirectory()
} catch {
return false
}
}
async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath)
return stat.isFile()
} catch {
return false
}
}
async function countMarkdownFiles(dir: string): Promise<number> {
let count = 0
async function walk(current: string): Promise<void> {
if (count >= MAX_MEMORY_FILES) return
let entries: import('node:fs').Dirent[]
try {
entries = await fs.readdir(current, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (count >= MAX_MEMORY_FILES) break
if (entry.name.startsWith('.')) continue
const fullPath = path.join(current, entry.name)
if (entry.isDirectory()) {
await walk(fullPath)
} else if (entry.isFile() && entry.name.endsWith('.md')) {
count++
}
}
}
await walk(dir)
return count
}
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
try {
return (await req.json()) as Record<string, unknown>
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
}
function methodNotAllowed(method: string): ApiError {
return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
}