| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 { |
| |
| } |
|
|
| 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 { |
| |
| } |
| } |
|
|
| 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') |
| } |
|
|