import { readdir, readFile, stat, lstat, realpath } from 'fs/promises' import { existsSync } from 'fs' import { dirname, join, sep } from 'path' import { resolveWithin } from '@/lib/paths' import { config } from '@/lib/config' const DOC_ROOT_CANDIDATES = ['docs', 'knowledge-base', 'knowledge', 'memory'] export interface DocsTreeNode { path: string name: string type: 'file' | 'directory' size?: number modified?: number children?: DocsTreeNode[] } function normalizeRelativePath(value: string): string { return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '') } function isWithinBase(base: string, candidate: string): boolean { if (candidate === base) return true return candidate.startsWith(base + sep) } async function resolveSafePath(baseDir: string, relativePath: string): Promise { const baseReal = await realpath(baseDir) const fullPath = resolveWithin(baseDir, relativePath) let parentReal: string try { parentReal = await realpath(dirname(fullPath)) } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'ENOENT') throw new Error('Parent directory not found') throw err } if (!isWithinBase(baseReal, parentReal)) { throw new Error('Path escapes base directory (symlink)') } try { const st = await lstat(fullPath) if (st.isSymbolicLink()) throw new Error('Symbolic links are not allowed') const fileReal = await realpath(fullPath) if (!isWithinBase(baseReal, fileReal)) { throw new Error('Path escapes base directory (symlink)') } } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code !== 'ENOENT') throw err } return fullPath } function allowedRoots(baseDir: string): string[] { const candidateRoots = DOC_ROOT_CANDIDATES.filter((root) => existsSync(join(baseDir, root))) if (candidateRoots.length > 0) return candidateRoots const fromConfig = (config.memoryAllowedPrefixes || []) .map((prefix) => normalizeRelativePath(prefix).replace(/\/$/, '')) .filter((prefix) => prefix.length > 0) .filter((prefix) => existsSync(join(baseDir, prefix))) return fromConfig } export function listDocsRoots(): string[] { const baseDir = config.memoryDir if (!baseDir || !existsSync(baseDir)) return [] return allowedRoots(baseDir) } export function isDocsPathAllowed(relativePath: string): boolean { const normalized = normalizeRelativePath(relativePath) if (!normalized) return false const baseDir = config.memoryDir if (!baseDir || !existsSync(baseDir)) return false const roots = allowedRoots(baseDir) if (roots.length === 0) return false return roots.some((root) => normalized === root || normalized.startsWith(`${root}/`)) } async function buildTreeFrom(dirPath: string, relativeBase: string): Promise { const items = await readdir(dirPath, { withFileTypes: true }) const nodes: DocsTreeNode[] = [] for (const item of items) { if (item.isSymbolicLink()) continue const fullPath = join(dirPath, item.name) const relativePath = normalizeRelativePath(join(relativeBase, item.name)) try { const info = await stat(fullPath) if (item.isDirectory()) { const children = await buildTreeFrom(fullPath, relativePath) nodes.push({ path: relativePath, name: item.name, type: 'directory', modified: info.mtime.getTime(), children, }) } else if (item.isFile()) { nodes.push({ path: relativePath, name: item.name, type: 'file', size: info.size, modified: info.mtime.getTime(), }) } } catch { // Ignore unreadable files } } return nodes.sort((a, b) => { if (a.type !== b.type) return a.type === 'directory' ? -1 : 1 return a.name.localeCompare(b.name) }) } export async function getDocsTree(): Promise { const baseDir = config.memoryDir if (!baseDir || !existsSync(baseDir)) return [] const roots = allowedRoots(baseDir) const tree: DocsTreeNode[] = [] for (const root of roots) { const rootPath = join(baseDir, root) try { const info = await stat(rootPath) if (!info.isDirectory()) continue tree.push({ path: root, name: root, type: 'directory', modified: info.mtime.getTime(), children: await buildTreeFrom(rootPath, root), }) } catch { // Ignore unreadable roots } } return tree } export async function readDocsContent(relativePath: string): Promise<{ content: string; size: number; modified: number; path: string }> { if (!isDocsPathAllowed(relativePath)) { throw new Error('Path not allowed') } const baseDir = config.memoryDir if (!baseDir || !existsSync(baseDir)) { throw new Error('Docs directory not configured') } const safePath = await resolveSafePath(baseDir, relativePath) const content = await readFile(safePath, 'utf-8') const info = await stat(safePath) return { content, size: info.size, modified: info.mtime.getTime(), path: normalizeRelativePath(relativePath), } } function isSearchable(name: string): boolean { return name.endsWith('.md') || name.endsWith('.txt') } export async function searchDocs(query: string, limit = 100): Promise> { const baseDir = config.memoryDir if (!baseDir || !existsSync(baseDir)) return [] const roots = allowedRoots(baseDir) if (roots.length === 0) return [] const q = query.trim().toLowerCase() if (!q) return [] const results: Array<{ path: string; name: string; matches: number }> = [] const searchFile = async (fullPath: string, relativePath: string) => { try { const info = await stat(fullPath) if (info.size > 1_000_000) return const content = (await readFile(fullPath, 'utf-8')).toLowerCase() let count = 0 let idx = content.indexOf(q) while (idx !== -1) { count += 1 idx = content.indexOf(q, idx + q.length) } if (count > 0) { results.push({ path: normalizeRelativePath(relativePath), name: relativePath.split('/').pop() || relativePath, matches: count, }) } } catch { // Ignore unreadable files } } const searchDir = async (fullDir: string, relativeDir: string) => { const items = await readdir(fullDir, { withFileTypes: true }) for (const item of items) { if (item.isSymbolicLink()) continue const itemFull = join(fullDir, item.name) const itemRel = normalizeRelativePath(join(relativeDir, item.name)) if (item.isDirectory()) { await searchDir(itemFull, itemRel) } else if (item.isFile() && isSearchable(item.name.toLowerCase())) { await searchFile(itemFull, itemRel) } } } for (const root of roots) { const rootPath = join(baseDir, root) try { await searchDir(rootPath, root) } catch { // Ignore unreadable roots } } return results.sort((a, b) => b.matches - a.matches).slice(0, Math.max(1, Math.min(limit, 200))) }