'use client' import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Loader } from '@/components/ui/loader' import { useMissionControl } from '@/store' import { createClientLogger } from '@/lib/client-logger' import { MemoryGraph } from './memory-graph' const log = createClientLogger('MemoryBrowser') interface MemoryFile { path: string name: string type: 'file' | 'directory' size?: number modified?: number children?: MemoryFile[] } function mergeDirectoryChildren(files: MemoryFile[], targetPath: string, children: MemoryFile[]): MemoryFile[] { return files.map((file) => { if (file.path === targetPath && file.type === 'directory') { return { ...file, children } } if (!file.children?.length) return file return { ...file, children: mergeDirectoryChildren(file.children, targetPath, children) } }) } interface HealthCategory { name: string status: 'healthy' | 'warning' | 'critical' score: number issues: string[] suggestions: string[] } interface HealthReport { overall: 'healthy' | 'warning' | 'critical' overallScore: number categories: HealthCategory[] generatedAt: number } interface MOCGroup { directory: string entries: { title: string; path: string; linkCount: number }[] } interface ProcessingResult { action: string filesProcessed: number changes: string[] suggestions: string[] } function formatFileSize(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] } function countFiles(files: MemoryFile[]): number { return files.reduce((acc, f) => { if (f.type === 'file') return acc + 1 return acc + countFiles(f.children || []) }, 0) } function totalSize(files: MemoryFile[]): number { return files.reduce((acc, f) => { if (f.type === 'file' && f.size) return acc + f.size return acc + totalSize(f.children || []) }, 0) } function fileIcon(name: string): string { if (name.endsWith('.md')) return '#' if (name.endsWith('.json') || name.endsWith('.jsonl')) return '{}' if (name.endsWith('.txt') || name.endsWith('.log')) return '|' return '~' } function statusColor(status: 'healthy' | 'warning' | 'critical'): string { if (status === 'healthy') return 'text-green-400' if (status === 'warning') return 'text-amber-400' return 'text-red-400' } function statusBg(status: 'healthy' | 'warning' | 'critical'): string { if (status === 'healthy') return 'bg-green-500' if (status === 'warning') return 'bg-amber-500' return 'bg-red-500' } export function MemoryBrowserPanel() { const t = useTranslations('memoryBrowser') const { memoryFiles, selectedMemoryFile, memoryContent, memoryFileLinks, memoryHealth, dashboardMode, setMemoryFiles, setSelectedMemoryFile, setMemoryContent, setMemoryFileLinks, setMemoryHealth } = useMissionControl() const isLocal = dashboardMode === 'local' const [isLoading, setIsLoading] = useState(false) const [expandedFolders, setExpandedFolders] = useState>(new Set()) const [searchResults, setSearchResults] = useState<{ path: string; name: string; matches: number }[]>([]) const [searchQuery, setSearchQuery] = useState('') const [isSearching, setIsSearching] = useState(false) const [isEditing, setIsEditing] = useState(false) const [editedContent, setEditedContent] = useState('') const [showCreateModal, setShowCreateModal] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [isSaving, setIsSaving] = useState(false) const [activeView, setActiveView] = useState<'files' | 'graph' | 'health' | 'pipeline' | 'hermes'>(!isLocal ? 'graph' : 'files') const [hermesMemory, setHermesMemory] = useState<{ agentMemory: string | null; userMemory: string | null; agentMemorySize: number; userMemorySize: number; agentMemoryEntries: number; userMemoryEntries: number } | null>(null) const [hermesInstalled, setHermesInstalled] = useState(null) const [isLoadingHermes, setIsLoadingHermes] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(true) const [fileFilter, setFileFilter] = useState<'all' | 'daily' | 'knowledge'>('all') const [schemaWarnings, setSchemaWarnings] = useState([]) const [linksOpen, setLinksOpen] = useState(false) const [healthReport, setHealthReport] = useState(null) const [isLoadingHealth, setIsLoadingHealth] = useState(false) const [pipelineResult, setPipelineResult] = useState(null) const [mocGroups, setMocGroups] = useState([]) const [isRunningPipeline, setIsRunningPipeline] = useState(false) const [isHydratingTree, setIsHydratingTree] = useState(false) const memoryFilesRef = useRef(memoryFiles) useEffect(() => { memoryFilesRef.current = memoryFiles }, [memoryFiles]) const fetchTree = useCallback(async (options?: { path?: string; depth?: number }) => { const params = new URLSearchParams({ action: 'tree' }) if (typeof options?.depth === 'number') params.set('depth', String(options.depth)) if (options?.path) params.set('path', options.path) const response = await fetch(`/api/memory?${params.toString()}`) return response.json() }, []) const loadFileTree = useCallback(async () => { setIsLoading(true) try { const data = await fetchTree({ depth: 1 }) setMemoryFiles(data.tree || []) setExpandedFolders(new Set(['daily', 'knowledge', 'memory', 'knowledge-base'])) setIsHydratingTree(true) void fetchTree() .then((fullData) => { setMemoryFiles(fullData.tree || []) }) .catch((error) => { log.error('Failed to hydrate full file tree:', error) }) .finally(() => { setIsHydratingTree(false) }) } catch (error) { log.error('Failed to load file tree:', error) } finally { setIsLoading(false) } }, [fetchTree, setMemoryFiles]) useEffect(() => { loadFileTree() }, [loadFileTree]) const filteredFiles = useMemo(() => { if (fileFilter === 'all') return memoryFiles const prefixes = fileFilter === 'daily' ? ['daily/', 'memory/'] : ['knowledge/', 'knowledge-base/'] return memoryFiles.filter((file) => { const p = `${file.path.replace(/\\/g, '/')}/` return prefixes.some((prefix) => p.startsWith(prefix)) }) }, [memoryFiles, fileFilter]) const loadFileContent = async (filePath: string) => { setIsLoading(true) try { const response = await fetch(`/api/memory?action=content&path=${encodeURIComponent(filePath)}`) const data = await response.json() if (data.content !== undefined) { setSelectedMemoryFile(filePath) setMemoryContent(data.content) setIsEditing(false) setEditedContent('') setSchemaWarnings([]) if (data.wikiLinks) { setMemoryFileLinks({ wikiLinks: data.wikiLinks, incoming: [], outgoing: [], }) fetch(`/api/memory/links?file=${encodeURIComponent(filePath)}`) .then((r) => r.json()) .then((linkData) => { setMemoryFileLinks({ wikiLinks: linkData.wikiLinks || data.wikiLinks, incoming: linkData.incoming || [], outgoing: linkData.outgoing || [], }) }) .catch(() => {}) } if (activeView === 'graph' || activeView === 'health' || activeView === 'pipeline') { setActiveView('files') } } } catch (error) { log.error('Failed to load file content:', error) } finally { setIsLoading(false) } } const searchFiles = async () => { if (!searchQuery.trim()) return setIsSearching(true) try { const response = await fetch(`/api/memory?action=search&query=${encodeURIComponent(searchQuery)}`) const data = await response.json() setSearchResults(data.results || []) } catch (error) { log.error('Search failed:', error) setSearchResults([]) } finally { setIsSearching(false) } } const toggleFolder = async (folderPath: string, needsChildren: boolean) => { if (!expandedFolders.has(folderPath) && needsChildren) { try { const data = await fetchTree({ path: folderPath, depth: 1 }) setMemoryFiles(mergeDirectoryChildren(memoryFilesRef.current, folderPath, data.tree || [])) } catch (error) { log.error('Failed to load folder children:', error) } } const next = new Set(expandedFolders) if (next.has(folderPath)) next.delete(folderPath) else next.add(folderPath) setExpandedFolders(next) } const saveFile = async () => { if (!selectedMemoryFile) return setIsSaving(true) try { const response = await fetch('/api/memory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'save', path: selectedMemoryFile, content: editedContent }) }) const data = await response.json() if (data.success) { setMemoryContent(editedContent) setIsEditing(false) setEditedContent('') setSchemaWarnings(data.schemaWarnings || []) loadFileTree() } } catch (error) { log.error('Failed to save file:', error) } finally { setIsSaving(false) } } const createNewFile = async (filePath: string, content: string = '') => { try { const response = await fetch('/api/memory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create', path: filePath, content }) }) const data = await response.json() if (data.success) { loadFileTree() loadFileContent(filePath) } } catch (error) { log.error('Failed to create file:', error) } } const deleteFile = async () => { if (!selectedMemoryFile) return try { const response = await fetch('/api/memory', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', path: selectedMemoryFile }) }) const data = await response.json() if (data.success) { setSelectedMemoryFile('') setMemoryContent('') setMemoryFileLinks(null) setShowDeleteConfirm(false) loadFileTree() } } catch (error) { log.error('Failed to delete file:', error) } } const loadHealth = useCallback(async () => { setIsLoadingHealth(true) try { const response = await fetch('/api/memory/health') const data = await response.json() if (data.categories) { setHealthReport(data) setMemoryHealth(data) } } catch (error) { log.error('Failed to load health:', error) } finally { setIsLoadingHealth(false) } }, [setMemoryHealth]) useEffect(() => { if (activeView === 'health' && !healthReport) { loadHealth() } }, [activeView, healthReport, loadHealth]) useEffect(() => { if (hermesInstalled === null) { fetch('/api/hermes').then(r => r.json()).then(d => setHermesInstalled(d.installed === true)).catch(() => setHermesInstalled(false)) } }, [hermesInstalled]) useEffect(() => { if (activeView === 'hermes' && !hermesMemory && !isLoadingHermes) { setIsLoadingHermes(true) fetch('/api/hermes/memory') .then(r => r.json()) .then(d => setHermesMemory(d)) .catch(() => {}) .finally(() => setIsLoadingHermes(false)) } }, [activeView, hermesMemory, isLoadingHermes]) const runPipelineAction = async (action: string) => { setIsRunningPipeline(true) setPipelineResult(null) setMocGroups([]) try { const response = await fetch('/api/memory/process', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }) const data = await response.json() if (action === 'generate-moc') { setMocGroups(data.groups || []) } else { setPipelineResult(data) } } catch (error) { log.error('Pipeline action failed:', error) } finally { setIsRunningPipeline(false) } } const fileCount = useMemo(() => countFiles(memoryFiles), [memoryFiles]) const sizeTotal = useMemo(() => totalSize(memoryFiles), [memoryFiles]) const navigateToWikiLink = (target: string) => { const findFile = (files: MemoryFile[]): string | null => { for (const f of files) { if (f.type === 'file') { const stem = f.name.replace(/\.[^.]+$/, '') if (stem === target || f.name === target || f.name === `${target}.md`) { return f.path } } if (f.children) { const found = findFile(f.children) if (found) return found } } return null } const found = findFile(memoryFiles) if (found) { loadFileContent(found) } } const renderTree = (files: MemoryFile[], depth = 0): React.ReactElement[] => { return files.map((file) => { const isDir = file.type === 'directory' const isExpanded = expandedFolders.has(file.path) const isSelected = selectedMemoryFile === file.path return (
void (isDir ? toggleFolder(file.path, file.children === undefined) : loadFileContent(file.path))} > {isDir ? ( ) : ( )} {isDir ? '/' : fileIcon(file.name)} {file.name} {!isDir && file.size != null && ( {formatFileSize(file.size)} )}
{isDir && isExpanded && file.children &&
{renderTree(file.children, depth + 1)}
}
) }) } const renderInline = (text: string): React.ReactNode[] => { const parts: React.ReactNode[] = [] const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[\[([^\]|]+)(?:\|([^\]]+))?\]\])/g let lastIndex = 0 let match: RegExpExecArray | null let key = 0 while ((match = pattern.exec(text)) !== null) { if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index)) const m = match[0] if (m.startsWith('[[') && m.endsWith(']]')) { const target = match[2]?.trim() || '' const display = (match[3] || match[2] || '').trim() parts.push( ) } else if (m.startsWith('`') && m.endsWith('`')) { parts.push({m.slice(1, -1)}) } else if (m.startsWith('**') && m.endsWith('**')) { parts.push({m.slice(2, -2)}) } else if (m.startsWith('*') && m.endsWith('*')) { parts.push({m.slice(1, -1)}) } lastIndex = pattern.lastIndex } if (lastIndex < text.length) parts.push(text.slice(lastIndex)) return parts } const renderMarkdown = (content: string) => { const lines = content.split('\n') const elements: React.ReactElement[] = [] const seenHeaders = new Set() for (let i = 0; i < lines.length; i++) { const line = lines[i] const trimmed = line.trim() if (trimmed.startsWith('# ')) { const text = trimmed.slice(2) const id = `h1-${text.toLowerCase().replace(/\s+/g, '-')}` if (seenHeaders.has(id)) continue seenHeaders.add(id) elements.push(

{renderInline(text)}

) } else if (trimmed.startsWith('## ')) { const text = trimmed.slice(3) const id = `h2-${text.toLowerCase().replace(/\s+/g, '-')}` if (seenHeaders.has(id)) continue seenHeaders.add(id) elements.push(

{renderInline(text)}

) } else if (trimmed.startsWith('### ')) { const text = trimmed.slice(4) const id = `h3-${text.toLowerCase().replace(/\s+/g, '-')}` if (seenHeaders.has(id)) continue seenHeaders.add(id) elements.push(

{renderInline(text)}

) } else if (trimmed.startsWith('- ')) { elements.push(
  • {renderInline(trimmed.slice(2))}
  • ) } else if (trimmed === '') { elements.push(
    ) } else if (trimmed.startsWith('```')) { const codeLang = trimmed.slice(3) const codeLines: string[] = [] let j = i + 1 while (j < lines.length && !lines[j].trim().startsWith('```')) { codeLines.push(lines[j]) j++ } elements.push(
                {codeLang && {codeLang}}
                {codeLines.join('\n')}
              
    ) i = j } else { elements.push(

    {renderInline(trimmed)}

    ) } } return elements } const viewTabs = ['files', ...(!isLocal ? ['graph'] : []), 'health', 'pipeline', ...(hermesInstalled ? ['hermes'] : [])] as const return (
    {/* Top bar */}
    {viewTabs.map((view) => ( ))}
    {healthReport && ( {healthReport.overallScore}% )} {t('fileCountSize', { count: fileCount, size: formatFileSize(sizeTotal) })} {isHydratingTree && {t('indexing')}}
    {/* Sidebar */} {sidebarOpen && (
    setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && searchFiles()} placeholder={t('searchPlaceholder')} className="w-full px-2 py-1.5 text-xs font-mono bg-[hsl(var(--surface-1))] border border-border/50 rounded text-foreground placeholder-muted-foreground/40 focus:outline-none focus:border-primary/30" />
    {(['all', 'daily', 'knowledge'] as const).map((f) => ( ))}
    {searchResults.length > 0 && (
    {t('searchResults', { count: searchResults.length })}
    {searchResults.map((r, i) => (
    { loadFileContent(r.path); setSearchResults([]) }}> {r.name} {r.matches}
    ))}
    )}
    {isLoading ? (
    ) : filteredFiles.length === 0 ? (
    {t('noFiles')}
    ) : renderTree(filteredFiles)}
    )} {/* Main content */}
    {activeView === 'graph' && !isLocal ? (
    ) : activeView === 'health' ? (
    ) : activeView === 'pipeline' ? (
    ) : activeView === 'hermes' ? (
    { setHermesMemory(null); setIsLoadingHermes(false) }} />
    ) : (
    {selectedMemoryFile && (
    {selectedMemoryFile} {memoryContent != null && ( {memoryContent.length} chars )}
    {!isEditing ? ( <> ) : ( <> )}
    )} {schemaWarnings.length > 0 && (
    {t('schemaWarnings')}
    {schemaWarnings.map((w, i) => (
    - {w}
    ))}
    )}
    {isLoading ? (
    ) : memoryContent != null && selectedMemoryFile ? (
    {isEditing ? (