ResistanceDown Jeremy Phelps commited on
Commit
ecae447
·
unverified ·
1 Parent(s): a288671

Mission Control: Habi readiness wiring + office segmentation (#187)

Browse files

* fix mission control wiring for habi memory/orchestration/retention

* feat office org-chart segmentation controls

---------

Co-authored-by: Jeremy Phelps <kokoro@Kokoro.local>

src/app/api/cleanup/route.ts CHANGED
@@ -3,6 +3,7 @@ import { requireRole } from '@/lib/auth'
3
  import { getDatabase, logAuditEvent } from '@/lib/db'
4
  import { config } from '@/lib/config'
5
  import { heavyLimiter } from '@/lib/rate-limit'
 
6
 
7
  interface CleanupResult {
8
  table: string
@@ -59,6 +60,17 @@ export async function GET(request: NextRequest) {
59
  preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
60
  }
61
 
 
 
 
 
 
 
 
 
 
 
 
62
  return NextResponse.json({ retention: config.retention, preview })
63
  }
64
 
@@ -137,6 +149,19 @@ export async function POST(request: NextRequest) {
137
  }
138
  }
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  if (!dryRun && totalDeleted > 0) {
141
  const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
142
  logAuditEvent({
 
3
  import { getDatabase, logAuditEvent } from '@/lib/db'
4
  import { config } from '@/lib/config'
5
  import { heavyLimiter } from '@/lib/rate-limit'
6
+ import { countStaleGatewaySessions, pruneGatewaySessionsOlderThan } from '@/lib/sessions'
7
 
8
  interface CleanupResult {
9
  table: string
 
60
  preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
61
  }
62
 
63
+ if (ret.gatewaySessions > 0) {
64
+ preview.push({
65
+ table: 'Gateway Session Store',
66
+ retention_days: ret.gatewaySessions,
67
+ stale_count: countStaleGatewaySessions(ret.gatewaySessions),
68
+ note: 'Stored under ~/.openclaw/agents/*/sessions/sessions.json',
69
+ })
70
+ } else {
71
+ preview.push({ table: 'Gateway Session Store', retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' })
72
+ }
73
+
74
  return NextResponse.json({ retention: config.retention, preview })
75
  }
76
 
 
149
  }
150
  }
151
 
152
+ if (ret.gatewaySessions > 0) {
153
+ const sessionPrune = dryRun
154
+ ? { deleted: countStaleGatewaySessions(ret.gatewaySessions), filesTouched: 0 }
155
+ : pruneGatewaySessionsOlderThan(ret.gatewaySessions)
156
+ results.push({
157
+ table: 'Gateway Session Store',
158
+ deleted: sessionPrune.deleted,
159
+ cutoff_date: new Date(Date.now() - ret.gatewaySessions * 86400000).toISOString().split('T')[0],
160
+ retention_days: ret.gatewaySessions,
161
+ })
162
+ totalDeleted += sessionPrune.deleted
163
+ }
164
+
165
  if (!dryRun && totalDeleted > 0) {
166
  const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
167
  logAuditEvent({
src/app/api/memory/route.ts CHANGED
@@ -9,6 +9,7 @@ import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
9
  import { logger } from '@/lib/logger'
10
 
11
  const MEMORY_PATH = config.memoryDir
 
12
 
13
  // Ensure memory directory exists on startup
14
  if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
@@ -24,6 +25,16 @@ interface MemoryFile {
24
  children?: MemoryFile[]
25
  }
26
 
 
 
 
 
 
 
 
 
 
 
27
  function isWithinBase(base: string, candidate: string): boolean {
28
  if (candidate === base) return true
29
  return candidate.startsWith(base + sep)
@@ -137,12 +148,37 @@ export async function GET(request: NextRequest) {
137
  if (!MEMORY_PATH) {
138
  return NextResponse.json({ tree: [] })
139
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  const tree = await buildFileTree(MEMORY_PATH)
141
  return NextResponse.json({ tree })
142
  }
143
 
144
  if (action === 'content' && path) {
145
  // Return file content
 
 
 
146
  if (!MEMORY_PATH) {
147
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
148
  }
@@ -227,7 +263,16 @@ export async function GET(request: NextRequest) {
227
  }
228
  }
229
 
230
- await searchDirectory(MEMORY_PATH)
 
 
 
 
 
 
 
 
 
231
 
232
  return NextResponse.json({
233
  query,
@@ -256,6 +301,9 @@ export async function POST(request: NextRequest) {
256
  if (!path) {
257
  return NextResponse.json({ error: 'Path is required' }, { status: 400 })
258
  }
 
 
 
259
 
260
  if (!MEMORY_PATH) {
261
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
@@ -316,6 +364,9 @@ export async function DELETE(request: NextRequest) {
316
  if (!path) {
317
  return NextResponse.json({ error: 'Path is required' }, { status: 400 })
318
  }
 
 
 
319
 
320
  if (!MEMORY_PATH) {
321
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
 
9
  import { logger } from '@/lib/logger'
10
 
11
  const MEMORY_PATH = config.memoryDir
12
+ const MEMORY_ALLOWED_PREFIXES = (config.memoryAllowedPrefixes || []).map((p) => p.replace(/\\/g, '/'))
13
 
14
  // Ensure memory directory exists on startup
15
  if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
 
25
  children?: MemoryFile[]
26
  }
27
 
28
+ function normalizeRelativePath(value: string): string {
29
+ return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '')
30
+ }
31
+
32
+ function isPathAllowed(relativePath: string): boolean {
33
+ if (!MEMORY_ALLOWED_PREFIXES.length) return true
34
+ const normalized = normalizeRelativePath(relativePath)
35
+ return MEMORY_ALLOWED_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix))
36
+ }
37
+
38
  function isWithinBase(base: string, candidate: string): boolean {
39
  if (candidate === base) return true
40
  return candidate.startsWith(base + sep)
 
148
  if (!MEMORY_PATH) {
149
  return NextResponse.json({ tree: [] })
150
  }
151
+ if (MEMORY_ALLOWED_PREFIXES.length) {
152
+ const tree: MemoryFile[] = []
153
+ for (const prefix of MEMORY_ALLOWED_PREFIXES) {
154
+ const folder = prefix.replace(/\/$/, '')
155
+ const fullPath = join(MEMORY_PATH, folder)
156
+ if (!existsSync(fullPath)) continue
157
+ try {
158
+ const stats = await stat(fullPath)
159
+ if (!stats.isDirectory()) continue
160
+ tree.push({
161
+ path: folder,
162
+ name: folder,
163
+ type: 'directory',
164
+ modified: stats.mtime.getTime(),
165
+ children: await buildFileTree(fullPath, folder),
166
+ })
167
+ } catch {
168
+ // Skip unreadable roots
169
+ }
170
+ }
171
+ return NextResponse.json({ tree })
172
+ }
173
  const tree = await buildFileTree(MEMORY_PATH)
174
  return NextResponse.json({ tree })
175
  }
176
 
177
  if (action === 'content' && path) {
178
  // Return file content
179
+ if (!isPathAllowed(path)) {
180
+ return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
181
+ }
182
  if (!MEMORY_PATH) {
183
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
184
  }
 
263
  }
264
  }
265
 
266
+ if (MEMORY_ALLOWED_PREFIXES.length) {
267
+ for (const prefix of MEMORY_ALLOWED_PREFIXES) {
268
+ const folder = prefix.replace(/\/$/, '')
269
+ const fullPath = join(MEMORY_PATH, folder)
270
+ if (!existsSync(fullPath)) continue
271
+ await searchDirectory(fullPath, folder)
272
+ }
273
+ } else {
274
+ await searchDirectory(MEMORY_PATH)
275
+ }
276
 
277
  return NextResponse.json({
278
  query,
 
301
  if (!path) {
302
  return NextResponse.json({ error: 'Path is required' }, { status: 400 })
303
  }
304
+ if (!isPathAllowed(path)) {
305
+ return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
306
+ }
307
 
308
  if (!MEMORY_PATH) {
309
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
 
364
  if (!path) {
365
  return NextResponse.json({ error: 'Path is required' }, { status: 400 })
366
  }
367
+ if (!isPathAllowed(path)) {
368
+ return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
369
+ }
370
 
371
  if (!MEMORY_PATH) {
372
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
src/app/api/settings/route.ts CHANGED
@@ -23,6 +23,7 @@ const settingDefinitions: Record<string, { category: string; description: string
23
  'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
24
  'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
25
  'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
 
26
 
27
  // Gateway
28
  'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
 
23
  'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
24
  'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
25
  'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
26
+ 'retention.gateway_sessions_days': { category: 'retention', description: 'Days to keep inactive gateway session metadata', default: String(config.retention.gatewaySessions) },
27
 
28
  // Gateway
29
  'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
src/components/dashboard/dashboard.tsx CHANGED
@@ -522,7 +522,7 @@ export function Dashboard() {
522
  {isLocal ? (
523
  <QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
524
  ) : (
525
- <QuickAction label="Orchestration" desc="Workflows & pipelines" tab="orchestration" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
526
  )}
527
  </div>
528
  </div>
 
522
  {isLocal ? (
523
  <QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
524
  ) : (
525
+ <QuickAction label="Orchestration" desc="Workflows & pipelines" tab="agents" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
526
  )}
527
  </div>
528
  </div>
src/components/panels/memory-browser-panel.tsx CHANGED
@@ -47,7 +47,7 @@ export function MemoryBrowserPanel() {
47
  setMemoryFiles(data.tree || [])
48
 
49
  // Auto-expand some common directories
50
- setExpandedFolders(new Set(['daily', 'knowledge']))
51
  } catch (error) {
52
  log.error('Failed to load file tree:', error)
53
  } finally {
@@ -61,15 +61,14 @@ export function MemoryBrowserPanel() {
61
 
62
  const getFilteredFiles = () => {
63
  if (activeTab === 'all') return memoryFiles
64
-
65
- return memoryFiles.filter(file => {
66
- if (activeTab === 'daily') {
67
- return file.name === 'daily' || file.path.includes('daily/')
68
- }
69
- if (activeTab === 'knowledge') {
70
- return file.name === 'knowledge' || file.path.includes('knowledge/')
71
- }
72
- return true
73
  })
74
  }
75
 
@@ -731,6 +730,8 @@ function CreateFileModal({
731
  onChange={(e) => setFilePath(e.target.value)}
732
  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"
733
  >
 
 
734
  <option value="knowledge/">knowledge/</option>
735
  <option value="daily/">daily/</option>
736
  <option value="logs/">logs/</option>
 
47
  setMemoryFiles(data.tree || [])
48
 
49
  // Auto-expand some common directories
50
+ setExpandedFolders(new Set(['daily', 'knowledge', 'memory', 'knowledge-base']))
51
  } catch (error) {
52
  log.error('Failed to load file tree:', error)
53
  } finally {
 
61
 
62
  const getFilteredFiles = () => {
63
  if (activeTab === 'all') return memoryFiles
64
+
65
+ const tabPrefixes = activeTab === 'daily'
66
+ ? ['daily/', 'memory/']
67
+ : ['knowledge/', 'knowledge-base/']
68
+
69
+ return memoryFiles.filter((file) => {
70
+ const normalizedPath = `${file.path.replace(/\\/g, '/')}/`
71
+ return tabPrefixes.some((prefix) => normalizedPath.startsWith(prefix))
 
72
  })
73
  }
74
 
 
730
  onChange={(e) => setFilePath(e.target.value)}
731
  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"
732
  >
733
+ <option value="knowledge-base/">knowledge-base/</option>
734
+ <option value="memory/">memory/</option>
735
  <option value="knowledge/">knowledge/</option>
736
  <option value="daily/">daily/</option>
737
  <option value="logs/">logs/</option>
src/components/panels/office-panel.tsx CHANGED
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
4
  import { useMissionControl, Agent } from '@/store'
5
 
6
  type ViewMode = 'office' | 'org-chart'
 
7
 
8
  interface Desk {
9
  agent: Agent
@@ -75,6 +76,7 @@ export function OfficePanel() {
75
  const { agents } = useMissionControl()
76
  const [localAgents, setLocalAgents] = useState<Agent[]>([])
77
  const [viewMode, setViewMode] = useState<ViewMode>('office')
 
78
  const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
79
  const [loading, setLoading] = useState(true)
80
 
@@ -123,6 +125,64 @@ export function OfficePanel() {
123
  return groups
124
  }, [displayAgents])
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  if (loading && displayAgents.length === 0) {
127
  return (
128
  <div className="flex items-center justify-center h-64">
@@ -237,11 +297,40 @@ export function OfficePanel() {
237
  </div>
238
  ) : (
239
  <div className="space-y-6">
240
- {[...roleGroups.entries()].map(([role, members]) => (
241
- <div key={role} className="bg-card border border-border rounded-xl p-5">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  <div className="flex items-center gap-2 mb-4">
243
  <div className="w-1 h-6 bg-primary rounded-full" />
244
- <h3 className="font-semibold text-foreground">{role}</h3>
245
  <span className="text-xs text-muted-foreground ml-1">({members.length})</span>
246
  </div>
247
  <div className="flex flex-wrap gap-3">
 
4
  import { useMissionControl, Agent } from '@/store'
5
 
6
  type ViewMode = 'office' | 'org-chart'
7
+ type OrgSegmentMode = 'category' | 'role' | 'status'
8
 
9
  interface Desk {
10
  agent: Agent
 
76
  const { agents } = useMissionControl()
77
  const [localAgents, setLocalAgents] = useState<Agent[]>([])
78
  const [viewMode, setViewMode] = useState<ViewMode>('office')
79
+ const [orgSegmentMode, setOrgSegmentMode] = useState<OrgSegmentMode>('category')
80
  const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
81
  const [loading, setLoading] = useState(true)
82
 
 
125
  return groups
126
  }, [displayAgents])
127
 
128
+ const categoryGroups = useMemo(() => {
129
+ const groups = new Map<string, Agent[]>()
130
+ const getCategory = (agent: Agent): string => {
131
+ const name = (agent.name || '').toLowerCase()
132
+ if (name.startsWith('habi-')) return 'Habi Lanes'
133
+ if (name.startsWith('ops-')) return 'Ops Automation'
134
+ if (name.includes('canary')) return 'Canary'
135
+ if (name.startsWith('main')) return 'Core'
136
+ if (name.startsWith('remote-')) return 'Remote'
137
+ return 'Other'
138
+ }
139
+
140
+ for (const a of displayAgents) {
141
+ const category = getCategory(a)
142
+ if (!groups.has(category)) groups.set(category, [])
143
+ groups.get(category)!.push(a)
144
+ }
145
+
146
+ const order = ['Habi Lanes', 'Ops Automation', 'Core', 'Canary', 'Remote', 'Other']
147
+ return new Map(
148
+ [...groups.entries()].sort(([a], [b]) => {
149
+ const ai = order.indexOf(a)
150
+ const bi = order.indexOf(b)
151
+ const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
152
+ const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
153
+ if (av !== bv) return av - bv
154
+ return a.localeCompare(b)
155
+ })
156
+ )
157
+ }, [displayAgents])
158
+
159
+ const statusGroups = useMemo(() => {
160
+ const groups = new Map<string, Agent[]>()
161
+ for (const a of displayAgents) {
162
+ const key = statusLabel[a.status] || a.status
163
+ if (!groups.has(key)) groups.set(key, [])
164
+ groups.get(key)!.push(a)
165
+ }
166
+
167
+ const order = ['Working', 'Available', 'Error', 'Away']
168
+ return new Map(
169
+ [...groups.entries()].sort(([a], [b]) => {
170
+ const ai = order.indexOf(a)
171
+ const bi = order.indexOf(b)
172
+ const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
173
+ const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
174
+ if (av !== bv) return av - bv
175
+ return a.localeCompare(b)
176
+ })
177
+ )
178
+ }, [displayAgents])
179
+
180
+ const orgGroups = useMemo(() => {
181
+ if (orgSegmentMode === 'role') return roleGroups
182
+ if (orgSegmentMode === 'status') return statusGroups
183
+ return categoryGroups
184
+ }, [categoryGroups, orgSegmentMode, roleGroups, statusGroups])
185
+
186
  if (loading && displayAgents.length === 0) {
187
  return (
188
  <div className="flex items-center justify-center h-64">
 
297
  </div>
298
  ) : (
299
  <div className="space-y-6">
300
+ <div className="flex items-center justify-between">
301
+ <div className="text-sm text-muted-foreground">
302
+ Segmented by{' '}
303
+ <span className="font-medium text-foreground">
304
+ {orgSegmentMode === 'category' ? 'category' : orgSegmentMode}
305
+ </span>
306
+ </div>
307
+ <div className="flex rounded-md overflow-hidden border border-border">
308
+ <button
309
+ onClick={() => setOrgSegmentMode('category')}
310
+ className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'category' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
311
+ >
312
+ Category
313
+ </button>
314
+ <button
315
+ onClick={() => setOrgSegmentMode('role')}
316
+ className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'role' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
317
+ >
318
+ Role
319
+ </button>
320
+ <button
321
+ onClick={() => setOrgSegmentMode('status')}
322
+ className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'status' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
323
+ >
324
+ Status
325
+ </button>
326
+ </div>
327
+ </div>
328
+
329
+ {[...orgGroups.entries()].map(([segment, members]) => (
330
+ <div key={segment} className="bg-card border border-border rounded-xl p-5">
331
  <div className="flex items-center gap-2 mb-4">
332
  <div className="w-1 h-6 bg-primary rounded-full" />
333
+ <h3 className="font-semibold text-foreground">{segment}</h3>
334
  <span className="text-xs text-muted-foreground ml-1">({members.length})</span>
335
  </div>
336
  <div className="flex flex-wrap gap-3">
src/lib/config.ts CHANGED
@@ -21,6 +21,23 @@ const openclawStateDir =
21
  const openclawConfigPath =
22
  explicitOpenClawConfigPath ||
23
  path.join(openclawStateDir, 'openclaw.json')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  export const config = {
26
  claudeHome:
@@ -45,10 +62,11 @@ export const config = {
45
  process.env.OPENCLAW_LOG_DIR ||
46
  (openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
47
  tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
48
- memoryDir:
49
- process.env.OPENCLAW_MEMORY_DIR ||
50
- (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') ||
51
- path.join(defaultDataDir, 'memory'),
 
52
  soulTemplatesDir:
53
  process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
54
  (openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
@@ -61,6 +79,7 @@ export const config = {
61
  notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
62
  pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
63
  tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
 
64
  },
65
  }
66
 
 
21
  const openclawConfigPath =
22
  explicitOpenClawConfigPath ||
23
  path.join(openclawStateDir, 'openclaw.json')
24
+ const openclawWorkspaceDir =
25
+ process.env.OPENCLAW_WORKSPACE_DIR ||
26
+ process.env.MISSION_CONTROL_WORKSPACE_DIR ||
27
+ (openclawStateDir ? path.join(openclawStateDir, 'workspace') : '')
28
+ const defaultMemoryDir = (() => {
29
+ if (process.env.OPENCLAW_MEMORY_DIR) return process.env.OPENCLAW_MEMORY_DIR
30
+ // Prefer OpenClaw workspace memory context (daily notes + knowledge-base)
31
+ // when available; fallback to legacy sqlite memory path.
32
+ if (
33
+ openclawWorkspaceDir &&
34
+ (fs.existsSync(path.join(openclawWorkspaceDir, 'memory')) ||
35
+ fs.existsSync(path.join(openclawWorkspaceDir, 'knowledge-base')))
36
+ ) {
37
+ return openclawWorkspaceDir
38
+ }
39
+ return (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory')
40
+ })()
41
 
42
  export const config = {
43
  claudeHome:
 
62
  process.env.OPENCLAW_LOG_DIR ||
63
  (openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
64
  tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
65
+ memoryDir: defaultMemoryDir,
66
+ memoryAllowedPrefixes:
67
+ defaultMemoryDir === openclawWorkspaceDir
68
+ ? ['memory/', 'knowledge-base/']
69
+ : [],
70
  soulTemplatesDir:
71
  process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
72
  (openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
 
79
  notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
80
  pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
81
  tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
82
+ gatewaySessions: Number(process.env.MC_RETAIN_GATEWAY_SESSIONS_DAYS || '90'),
83
  },
84
  }
85
 
src/lib/scheduler.ts CHANGED
@@ -6,6 +6,7 @@ import { readdirSync, statSync, unlinkSync } from 'fs'
6
  import { logger } from './logger'
7
  import { processWebhookRetries } from './webhooks'
8
  import { syncClaudeSessions } from './claude-sessions'
 
9
 
10
  const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
11
 
@@ -130,6 +131,11 @@ async function runCleanup(): Promise<{ ok: boolean; message: string }> {
130
  }
131
  }
132
 
 
 
 
 
 
133
  if (totalDeleted > 0) {
134
  logAuditEvent({
135
  action: 'auto_cleanup',
 
6
  import { logger } from './logger'
7
  import { processWebhookRetries } from './webhooks'
8
  import { syncClaudeSessions } from './claude-sessions'
9
+ import { pruneGatewaySessionsOlderThan } from './sessions'
10
 
11
  const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
12
 
 
131
  }
132
  }
133
 
134
+ if (ret.gatewaySessions > 0) {
135
+ const sessionCleanup = pruneGatewaySessionsOlderThan(ret.gatewaySessions)
136
+ totalDeleted += sessionCleanup.deleted
137
+ }
138
+
139
  if (totalDeleted > 0) {
140
  logAuditEvent({
141
  action: 'auto_cleanup',
src/lib/sessions.ts CHANGED
@@ -19,25 +19,13 @@ export interface GatewaySession {
19
  active: boolean
20
  }
21
 
22
- /**
23
- * Read all sessions from OpenClaw agent session stores on disk.
24
- *
25
- * OpenClaw stores sessions per-agent at:
26
- * {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json
27
- *
28
- * Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
29
- * with session metadata as values.
30
- */
31
- export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
32
  const openclawStateDir = config.openclawStateDir
33
  if (!openclawStateDir) return []
34
 
35
  const agentsDir = path.join(openclawStateDir, 'agents')
36
  if (!fs.existsSync(agentsDir)) return []
37
 
38
- const sessions: GatewaySession[] = []
39
- const now = Date.now()
40
-
41
  let agentDirs: string[]
42
  try {
43
  agentDirs = fs.readdirSync(agentsDir)
@@ -45,10 +33,33 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
45
  return []
46
  }
47
 
 
48
  for (const agentName of agentDirs) {
49
  const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
50
  try {
51
- if (!fs.statSync(sessionsFile).isFile()) continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  const raw = fs.readFileSync(sessionsFile, 'utf-8')
53
  const data = JSON.parse(raw)
54
 
@@ -80,6 +91,64 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
80
  return sessions
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  /**
84
  * Derive agent active/idle/offline status from their sessions.
85
  * Returns a map of agentName -> { status, lastActivity, channel }
 
19
  active: boolean
20
  }
21
 
22
+ function getGatewaySessionStoreFiles(): string[] {
 
 
 
 
 
 
 
 
 
23
  const openclawStateDir = config.openclawStateDir
24
  if (!openclawStateDir) return []
25
 
26
  const agentsDir = path.join(openclawStateDir, 'agents')
27
  if (!fs.existsSync(agentsDir)) return []
28
 
 
 
 
29
  let agentDirs: string[]
30
  try {
31
  agentDirs = fs.readdirSync(agentsDir)
 
33
  return []
34
  }
35
 
36
+ const files: string[] = []
37
  for (const agentName of agentDirs) {
38
  const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
39
  try {
40
+ if (fs.statSync(sessionsFile).isFile()) files.push(sessionsFile)
41
+ } catch {
42
+ // Skip missing or unreadable session stores.
43
+ }
44
+ }
45
+ return files
46
+ }
47
+
48
+ /**
49
+ * Read all sessions from OpenClaw agent session stores on disk.
50
+ *
51
+ * OpenClaw stores sessions per-agent at:
52
+ * {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json
53
+ *
54
+ * Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
55
+ * with session metadata as values.
56
+ */
57
+ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
58
+ const sessions: GatewaySession[] = []
59
+ const now = Date.now()
60
+ for (const sessionsFile of getGatewaySessionStoreFiles()) {
61
+ const agentName = path.basename(path.dirname(path.dirname(sessionsFile)))
62
+ try {
63
  const raw = fs.readFileSync(sessionsFile, 'utf-8')
64
  const data = JSON.parse(raw)
65
 
 
91
  return sessions
92
  }
93
 
94
+ export function countStaleGatewaySessions(retentionDays: number): number {
95
+ if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0
96
+ const cutoff = Date.now() - retentionDays * 86400000
97
+ let stale = 0
98
+
99
+ for (const sessionsFile of getGatewaySessionStoreFiles()) {
100
+ try {
101
+ const raw = fs.readFileSync(sessionsFile, 'utf-8')
102
+ const data = JSON.parse(raw) as Record<string, any>
103
+ for (const entry of Object.values(data)) {
104
+ const updatedAt = Number((entry as any)?.updatedAt || 0)
105
+ if (updatedAt > 0 && updatedAt < cutoff) stale += 1
106
+ }
107
+ } catch {
108
+ // Ignore malformed session stores.
109
+ }
110
+ }
111
+
112
+ return stale
113
+ }
114
+
115
+ export function pruneGatewaySessionsOlderThan(retentionDays: number): { deleted: number; filesTouched: number } {
116
+ if (!Number.isFinite(retentionDays) || retentionDays <= 0) return { deleted: 0, filesTouched: 0 }
117
+ const cutoff = Date.now() - retentionDays * 86400000
118
+ let deleted = 0
119
+ let filesTouched = 0
120
+
121
+ for (const sessionsFile of getGatewaySessionStoreFiles()) {
122
+ try {
123
+ const raw = fs.readFileSync(sessionsFile, 'utf-8')
124
+ const data = JSON.parse(raw) as Record<string, any>
125
+ const nextEntries: Record<string, any> = {}
126
+ let fileDeleted = 0
127
+
128
+ for (const [key, entry] of Object.entries(data)) {
129
+ const updatedAt = Number((entry as any)?.updatedAt || 0)
130
+ if (updatedAt > 0 && updatedAt < cutoff) {
131
+ fileDeleted += 1
132
+ continue
133
+ }
134
+ nextEntries[key] = entry
135
+ }
136
+
137
+ if (fileDeleted > 0) {
138
+ const tempPath = `${sessionsFile}.tmp`
139
+ fs.writeFileSync(tempPath, `${JSON.stringify(nextEntries, null, 2)}\n`, 'utf-8')
140
+ fs.renameSync(tempPath, sessionsFile)
141
+ deleted += fileDeleted
142
+ filesTouched += 1
143
+ }
144
+ } catch {
145
+ // Ignore malformed/unwritable session stores.
146
+ }
147
+ }
148
+
149
+ return { deleted, filesTouched }
150
+ }
151
+
152
  /**
153
  * Derive agent active/idle/offline status from their sessions.
154
  * Returns a map of agentName -> { status, lastActivity, channel }
src/lib/websocket.ts CHANGED
@@ -358,6 +358,13 @@ export function useWebSocket() {
358
 
359
  // Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
360
  if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
 
 
 
 
 
 
 
361
  handlePong(frame.id)
362
  return
363
  }
 
358
 
359
  // Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
360
  if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
361
+ const rawPingError = frame.error?.message || JSON.stringify(frame.error || '')
362
+ if (!frame.ok && /unknown method:\s*ping/i.test(rawPingError)) {
363
+ gatewaySupportsPingRef.current = false
364
+ missedPongsRef.current = 0
365
+ pingSentTimestamps.current.clear()
366
+ log.info('Gateway ping RPC unavailable; using passive heartbeat mode')
367
+ }
368
  handlePong(frame.id)
369
  return
370
  }