diff --git a/src/app/api/cleanup/route.ts b/src/app/api/cleanup/route.ts index c590ea3..3cd13fc 100644 --- a/src/app/api/cleanup/route.ts +++ b/src/app/api/cleanup/route.ts @@ -3,6 +3,7 @@ import { requireRole } from '@/lib/auth' import { getDatabase, logAuditEvent } from '@/lib/db' import { config } from '@/lib/config' import { heavyLimiter } from '@/lib/rate-limit' +import { countStaleGatewaySessions, pruneGatewaySessionsOlderThan } from '@/lib/sessions' interface CleanupResult { table: string @@ -59,6 +60,17 @@ export async function GET(request: NextRequest) { preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' }) } + if (ret.gatewaySessions > 0) { + preview.push({ + table: 'Gateway Session Store', + retention_days: ret.gatewaySessions, + stale_count: countStaleGatewaySessions(ret.gatewaySessions), + note: 'Stored under ~/.openclaw/agents/*/sessions/sessions.json', + }) + } else { + preview.push({ table: 'Gateway Session Store', retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' }) + } + return NextResponse.json({ retention: config.retention, preview }) } @@ -137,6 +149,19 @@ export async function POST(request: NextRequest) { } } + if (ret.gatewaySessions > 0) { + const sessionPrune = dryRun + ? { deleted: countStaleGatewaySessions(ret.gatewaySessions), filesTouched: 0 } + : pruneGatewaySessionsOlderThan(ret.gatewaySessions) + results.push({ + table: 'Gateway Session Store', + deleted: sessionPrune.deleted, + cutoff_date: new Date(Date.now() - ret.gatewaySessions * 86400000).toISOString().split('T')[0], + retention_days: ret.gatewaySessions, + }) + totalDeleted += sessionPrune.deleted + } + if (!dryRun && totalDeleted > 0) { const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' logAuditEvent({ diff --git a/src/app/api/memory/route.ts b/src/app/api/memory/route.ts index 6615d35..a1aa609 100644 --- a/src/app/api/memory/route.ts +++ b/src/app/api/memory/route.ts @@ -9,6 +9,7 @@ import { readLimiter, mutationLimiter } from '@/lib/rate-limit' import { logger } from '@/lib/logger' const MEMORY_PATH = config.memoryDir +const MEMORY_ALLOWED_PREFIXES = (config.memoryAllowedPrefixes || []).map((p) => p.replace(/\\/g, '/')) // Ensure memory directory exists on startup if (MEMORY_PATH && !existsSync(MEMORY_PATH)) { @@ -24,6 +25,16 @@ interface MemoryFile { children?: MemoryFile[] } +function normalizeRelativePath(value: string): string { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '') +} + +function isPathAllowed(relativePath: string): boolean { + if (!MEMORY_ALLOWED_PREFIXES.length) return true + const normalized = normalizeRelativePath(relativePath) + return MEMORY_ALLOWED_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix)) +} + function isWithinBase(base: string, candidate: string): boolean { if (candidate === base) return true return candidate.startsWith(base + sep) @@ -137,12 +148,37 @@ export async function GET(request: NextRequest) { if (!MEMORY_PATH) { return NextResponse.json({ tree: [] }) } + if (MEMORY_ALLOWED_PREFIXES.length) { + const tree: MemoryFile[] = [] + for (const prefix of MEMORY_ALLOWED_PREFIXES) { + const folder = prefix.replace(/\/$/, '') + const fullPath = join(MEMORY_PATH, folder) + if (!existsSync(fullPath)) continue + try { + const stats = await stat(fullPath) + if (!stats.isDirectory()) continue + tree.push({ + path: folder, + name: folder, + type: 'directory', + modified: stats.mtime.getTime(), + children: await buildFileTree(fullPath, folder), + }) + } catch { + // Skip unreadable roots + } + } + return NextResponse.json({ tree }) + } const tree = await buildFileTree(MEMORY_PATH) return NextResponse.json({ tree }) } if (action === 'content' && path) { // Return file content + if (!isPathAllowed(path)) { + return NextResponse.json({ error: 'Path not allowed' }, { status: 403 }) + } if (!MEMORY_PATH) { return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 }) } @@ -227,7 +263,16 @@ export async function GET(request: NextRequest) { } } - await searchDirectory(MEMORY_PATH) + if (MEMORY_ALLOWED_PREFIXES.length) { + for (const prefix of MEMORY_ALLOWED_PREFIXES) { + const folder = prefix.replace(/\/$/, '') + const fullPath = join(MEMORY_PATH, folder) + if (!existsSync(fullPath)) continue + await searchDirectory(fullPath, folder) + } + } else { + await searchDirectory(MEMORY_PATH) + } return NextResponse.json({ query, @@ -256,6 +301,9 @@ export async function POST(request: NextRequest) { if (!path) { return NextResponse.json({ error: 'Path is required' }, { status: 400 }) } + if (!isPathAllowed(path)) { + return NextResponse.json({ error: 'Path not allowed' }, { status: 403 }) + } if (!MEMORY_PATH) { return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 }) @@ -316,6 +364,9 @@ export async function DELETE(request: NextRequest) { if (!path) { return NextResponse.json({ error: 'Path is required' }, { status: 400 }) } + if (!isPathAllowed(path)) { + return NextResponse.json({ error: 'Path not allowed' }, { status: 403 }) + } if (!MEMORY_PATH) { return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 }) diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index a6be8d4..d88130f 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -23,6 +23,7 @@ const settingDefinitions: Record} onNavigate={navigateToPanel} /> ) : ( - } onNavigate={navigateToPanel} /> + } onNavigate={navigateToPanel} /> )} diff --git a/src/components/panels/memory-browser-panel.tsx b/src/components/panels/memory-browser-panel.tsx index 61c9229..48022b5 100644 --- a/src/components/panels/memory-browser-panel.tsx +++ b/src/components/panels/memory-browser-panel.tsx @@ -47,7 +47,7 @@ export function MemoryBrowserPanel() { setMemoryFiles(data.tree || []) // Auto-expand some common directories - setExpandedFolders(new Set(['daily', 'knowledge'])) + setExpandedFolders(new Set(['daily', 'knowledge', 'memory', 'knowledge-base'])) } catch (error) { log.error('Failed to load file tree:', error) } finally { @@ -61,15 +61,14 @@ export function MemoryBrowserPanel() { const getFilteredFiles = () => { if (activeTab === 'all') return memoryFiles - - return memoryFiles.filter(file => { - if (activeTab === 'daily') { - return file.name === 'daily' || file.path.includes('daily/') - } - if (activeTab === 'knowledge') { - return file.name === 'knowledge' || file.path.includes('knowledge/') - } - return true + + const tabPrefixes = activeTab === 'daily' + ? ['daily/', 'memory/'] + : ['knowledge/', 'knowledge-base/'] + + return memoryFiles.filter((file) => { + const normalizedPath = `${file.path.replace(/\\/g, '/')}/` + return tabPrefixes.some((prefix) => normalizedPath.startsWith(prefix)) }) } @@ -731,6 +730,8 @@ function CreateFileModal({ onChange={(e) => setFilePath(e.target.value)} className="w-full px-3 py-2 bg-surface-1 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50" > + + diff --git a/src/components/panels/office-panel.tsx b/src/components/panels/office-panel.tsx index 521301b..ed0ada5 100644 --- a/src/components/panels/office-panel.tsx +++ b/src/components/panels/office-panel.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { useMissionControl, Agent } from '@/store' type ViewMode = 'office' | 'org-chart' +type OrgSegmentMode = 'category' | 'role' | 'status' interface Desk { agent: Agent @@ -75,6 +76,7 @@ export function OfficePanel() { const { agents } = useMissionControl() const [localAgents, setLocalAgents] = useState([]) const [viewMode, setViewMode] = useState('office') + const [orgSegmentMode, setOrgSegmentMode] = useState('category') const [selectedAgent, setSelectedAgent] = useState(null) const [loading, setLoading] = useState(true) @@ -123,6 +125,64 @@ export function OfficePanel() { return groups }, [displayAgents]) + const categoryGroups = useMemo(() => { + const groups = new Map() + const getCategory = (agent: Agent): string => { + const name = (agent.name || '').toLowerCase() + if (name.startsWith('habi-')) return 'Habi Lanes' + if (name.startsWith('ops-')) return 'Ops Automation' + if (name.includes('canary')) return 'Canary' + if (name.startsWith('main')) return 'Core' + if (name.startsWith('remote-')) return 'Remote' + return 'Other' + } + + for (const a of displayAgents) { + const category = getCategory(a) + if (!groups.has(category)) groups.set(category, []) + groups.get(category)!.push(a) + } + + const order = ['Habi Lanes', 'Ops Automation', 'Core', 'Canary', 'Remote', 'Other'] + return new Map( + [...groups.entries()].sort(([a], [b]) => { + const ai = order.indexOf(a) + const bi = order.indexOf(b) + const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai + const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi + if (av !== bv) return av - bv + return a.localeCompare(b) + }) + ) + }, [displayAgents]) + + const statusGroups = useMemo(() => { + const groups = new Map() + for (const a of displayAgents) { + const key = statusLabel[a.status] || a.status + if (!groups.has(key)) groups.set(key, []) + groups.get(key)!.push(a) + } + + const order = ['Working', 'Available', 'Error', 'Away'] + return new Map( + [...groups.entries()].sort(([a], [b]) => { + const ai = order.indexOf(a) + const bi = order.indexOf(b) + const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai + const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi + if (av !== bv) return av - bv + return a.localeCompare(b) + }) + ) + }, [displayAgents]) + + const orgGroups = useMemo(() => { + if (orgSegmentMode === 'role') return roleGroups + if (orgSegmentMode === 'status') return statusGroups + return categoryGroups + }, [categoryGroups, orgSegmentMode, roleGroups, statusGroups]) + if (loading && displayAgents.length === 0) { return (
@@ -237,11 +297,40 @@ export function OfficePanel() {
) : (
- {[...roleGroups.entries()].map(([role, members]) => ( -
+
+
+ Segmented by{' '} + + {orgSegmentMode === 'category' ? 'category' : orgSegmentMode} + +
+
+ + + +
+
+ + {[...orgGroups.entries()].map(([segment, members]) => ( +
-

{role}

+

{segment}

({members.length})
diff --git a/src/lib/config.ts b/src/lib/config.ts index 54214c1..91a0775 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -21,6 +21,23 @@ const openclawStateDir = const openclawConfigPath = explicitOpenClawConfigPath || path.join(openclawStateDir, 'openclaw.json') +const openclawWorkspaceDir = + process.env.OPENCLAW_WORKSPACE_DIR || + process.env.MISSION_CONTROL_WORKSPACE_DIR || + (openclawStateDir ? path.join(openclawStateDir, 'workspace') : '') +const defaultMemoryDir = (() => { + if (process.env.OPENCLAW_MEMORY_DIR) return process.env.OPENCLAW_MEMORY_DIR + // Prefer OpenClaw workspace memory context (daily notes + knowledge-base) + // when available; fallback to legacy sqlite memory path. + if ( + openclawWorkspaceDir && + (fs.existsSync(path.join(openclawWorkspaceDir, 'memory')) || + fs.existsSync(path.join(openclawWorkspaceDir, 'knowledge-base'))) + ) { + return openclawWorkspaceDir + } + return (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory') +})() export const config = { claudeHome: @@ -45,10 +62,11 @@ export const config = { process.env.OPENCLAW_LOG_DIR || (openclawStateDir ? path.join(openclawStateDir, 'logs') : ''), tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '', - memoryDir: - process.env.OPENCLAW_MEMORY_DIR || - (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || - path.join(defaultDataDir, 'memory'), + memoryDir: defaultMemoryDir, + memoryAllowedPrefixes: + defaultMemoryDir === openclawWorkspaceDir + ? ['memory/', 'knowledge-base/'] + : [], soulTemplatesDir: process.env.OPENCLAW_SOUL_TEMPLATES_DIR || (openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''), @@ -61,6 +79,7 @@ export const config = { notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'), pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'), tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'), + gatewaySessions: Number(process.env.MC_RETAIN_GATEWAY_SESSIONS_DAYS || '90'), }, } diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts index 2fe658d..3845111 100644 --- a/src/lib/scheduler.ts +++ b/src/lib/scheduler.ts @@ -6,6 +6,7 @@ import { readdirSync, statSync, unlinkSync } from 'fs' import { logger } from './logger' import { processWebhookRetries } from './webhooks' import { syncClaudeSessions } from './claude-sessions' +import { pruneGatewaySessionsOlderThan } from './sessions' const BACKUP_DIR = join(dirname(config.dbPath), 'backups') @@ -130,6 +131,11 @@ async function runCleanup(): Promise<{ ok: boolean; message: string }> { } } + if (ret.gatewaySessions > 0) { + const sessionCleanup = pruneGatewaySessionsOlderThan(ret.gatewaySessions) + totalDeleted += sessionCleanup.deleted + } + if (totalDeleted > 0) { logAuditEvent({ action: 'auto_cleanup', diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 17421fd..1fb440f 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -19,6 +19,32 @@ export interface GatewaySession { active: boolean } +function getGatewaySessionStoreFiles(): string[] { + const openclawStateDir = config.openclawStateDir + if (!openclawStateDir) return [] + + const agentsDir = path.join(openclawStateDir, 'agents') + if (!fs.existsSync(agentsDir)) return [] + + let agentDirs: string[] + try { + agentDirs = fs.readdirSync(agentsDir) + } catch { + return [] + } + + const files: string[] = [] + for (const agentName of agentDirs) { + const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json') + try { + if (fs.statSync(sessionsFile).isFile()) files.push(sessionsFile) + } catch { + // Skip missing or unreadable session stores. + } + } + return files +} + /** * Read all sessions from OpenClaw agent session stores on disk. * @@ -29,26 +55,11 @@ export interface GatewaySession { * with session metadata as values. */ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] { - const openclawStateDir = config.openclawStateDir - if (!openclawStateDir) return [] - - const agentsDir = path.join(openclawStateDir, 'agents') - if (!fs.existsSync(agentsDir)) return [] - const sessions: GatewaySession[] = [] const now = Date.now() - - let agentDirs: string[] - try { - agentDirs = fs.readdirSync(agentsDir) - } catch { - return [] - } - - for (const agentName of agentDirs) { - const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json') + for (const sessionsFile of getGatewaySessionStoreFiles()) { + const agentName = path.basename(path.dirname(path.dirname(sessionsFile))) try { - if (!fs.statSync(sessionsFile).isFile()) continue const raw = fs.readFileSync(sessionsFile, 'utf-8') const data = JSON.parse(raw) @@ -80,6 +91,64 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS return sessions } +export function countStaleGatewaySessions(retentionDays: number): number { + if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0 + const cutoff = Date.now() - retentionDays * 86400000 + let stale = 0 + + for (const sessionsFile of getGatewaySessionStoreFiles()) { + try { + const raw = fs.readFileSync(sessionsFile, 'utf-8') + const data = JSON.parse(raw) as Record + for (const entry of Object.values(data)) { + const updatedAt = Number((entry as any)?.updatedAt || 0) + if (updatedAt > 0 && updatedAt < cutoff) stale += 1 + } + } catch { + // Ignore malformed session stores. + } + } + + return stale +} + +export function pruneGatewaySessionsOlderThan(retentionDays: number): { deleted: number; filesTouched: number } { + if (!Number.isFinite(retentionDays) || retentionDays <= 0) return { deleted: 0, filesTouched: 0 } + const cutoff = Date.now() - retentionDays * 86400000 + let deleted = 0 + let filesTouched = 0 + + for (const sessionsFile of getGatewaySessionStoreFiles()) { + try { + const raw = fs.readFileSync(sessionsFile, 'utf-8') + const data = JSON.parse(raw) as Record + const nextEntries: Record = {} + let fileDeleted = 0 + + for (const [key, entry] of Object.entries(data)) { + const updatedAt = Number((entry as any)?.updatedAt || 0) + if (updatedAt > 0 && updatedAt < cutoff) { + fileDeleted += 1 + continue + } + nextEntries[key] = entry + } + + if (fileDeleted > 0) { + const tempPath = `${sessionsFile}.tmp` + fs.writeFileSync(tempPath, `${JSON.stringify(nextEntries, null, 2)}\n`, 'utf-8') + fs.renameSync(tempPath, sessionsFile) + deleted += fileDeleted + filesTouched += 1 + } + } catch { + // Ignore malformed/unwritable session stores. + } + } + + return { deleted, filesTouched } +} + /** * Derive agent active/idle/offline status from their sessions. * Returns a map of agentName -> { status, lastActivity, channel } diff --git a/src/lib/websocket.ts b/src/lib/websocket.ts index 7c02e56..df6c9e9 100644 --- a/src/lib/websocket.ts +++ b/src/lib/websocket.ts @@ -358,6 +358,13 @@ export function useWebSocket() { // Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive) if (frame.type === 'res' && frame.id?.startsWith('ping-')) { + const rawPingError = frame.error?.message || JSON.stringify(frame.error || '') + if (!frame.ok && /unknown method:\s*ping/i.test(rawPingError)) { + gatewaySupportsPingRef.current = false + missedPongsRef.current = 0 + pingSentTimestamps.current.clear() + log.info('Gateway ping RPC unavailable; using passive heartbeat mode') + } handlePong(frame.id) return }