clawport-ui 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -129,10 +129,16 @@ export async function POST(
129
129
  Connection: 'keep-alive',
130
130
  },
131
131
  })
132
- } catch (err) {
132
+ } catch (err: unknown) {
133
133
  console.error('Chat API error:', err)
134
+
135
+ let userMessage = 'Chat failed. Make sure OpenClaw gateway is running.'
136
+ if (err instanceof Error && 'status' in err && (err as { status: number }).status === 405) {
137
+ userMessage = 'Gateway returned 405. Enable the HTTP endpoint: set gateway.http.endpoints.chatCompletions.enabled = true in ~/.openclaw/openclaw.json, then restart the gateway.'
138
+ }
139
+
134
140
  return new Response(
135
- JSON.stringify({ error: 'Chat failed. Make sure OpenClaw gateway is running.' }),
141
+ JSON.stringify({ error: userMessage }),
136
142
  { status: 500, headers: { 'Content-Type': 'application/json' } }
137
143
  )
138
144
  }
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync, readdirSync } from 'fs'
2
- import { join } from 'path'
2
+ import { join, basename } from 'path'
3
3
  import bundledRegistry from '@/lib/agents.json'
4
4
  import type { Agent } from '@/lib/types'
5
5
 
@@ -13,13 +13,60 @@ const DISCOVER_COLORS = [
13
13
  '#06b6d4', '#ec4899', '#84cc16', '#8b5cf6', '#ef4444',
14
14
  ]
15
15
 
16
+ // ---------------------------------------------------------------------------
17
+ // Heading / identity extraction helpers (exported for testing)
18
+ // ---------------------------------------------------------------------------
19
+
16
20
  /**
17
- * Extract a display name from SOUL.md content.
18
- * Returns the text of the first `# Heading` line, or null.
21
+ * Parse a SOUL.md heading into a clean agent name and optional title.
22
+ *
23
+ * Handles all observed heading formats:
24
+ * "# SOUL.md — VERA" → name "VERA"
25
+ * "# SOUL.md — KAZE, Flight Research Agent" → name "KAZE", title "Flight Research Agent"
26
+ * "# ECHO — Community Voice Monitor" → name "ECHO", title "Community Voice Monitor"
27
+ * "# SOUL.md - Who You Are" → name null (not a useful agent name)
28
+ * "# QUILL — Herald's Content Writer" → name "QUILL", title "Herald's Content Writer"
19
29
  */
20
- function extractNameFromSoul(content: string): string | null {
30
+ export function parseSoulHeading(content: string): { name: string | null; title: string | null } {
21
31
  const match = content.match(/^#\s+(.+)/m)
22
- return match ? match[1].trim() : null
32
+ if (!match) return { name: null, title: null }
33
+
34
+ let heading = match[1].trim()
35
+
36
+ // Strip "SOUL.md — ", "SOUL.md - ", "SOUL.md:" prefixes
37
+ heading = heading.replace(/^SOUL\.md\s*[—–\-:]\s*/i, '')
38
+
39
+ // If the heading is generic (e.g. "Who You Are"), it's not a useful agent name
40
+ if (/^who\s+you\s+are/i.test(heading)) return { name: null, title: null }
41
+
42
+ // Split on em-dash / en-dash to separate name from description
43
+ const dashParts = heading.split(/\s*[—–]\s*/)
44
+ const nameAndMaybeTitle = dashParts[0].trim()
45
+ const descAfterDash = dashParts.length > 1 ? dashParts.slice(1).join(' — ').trim() : null
46
+
47
+ // The name might contain a comma: "LUMEN, SEO Team Director"
48
+ const commaParts = nameAndMaybeTitle.split(/,\s*/)
49
+ const name = commaParts[0].trim() || null
50
+ const titleFromComma = commaParts.length > 1 ? commaParts.slice(1).join(', ').trim() : null
51
+
52
+ const title = titleFromComma || descAfterDash || null
53
+ return { name, title }
54
+ }
55
+
56
+ /**
57
+ * Parse an IDENTITY.md file for agent name and emoji.
58
+ *
59
+ * Expected format (generated by OpenClaw bootstrap):
60
+ * - **Name:** Jarvis
61
+ * - **Emoji:** 🤖
62
+ */
63
+ export function parseIdentity(content: string): { name: string | null; emoji: string | null } {
64
+ const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/i)
65
+ const emojiMatch = content.match(/\*\*Emoji:\*\*\s*(\S+)/i)
66
+ return {
67
+ name: nameMatch ? nameMatch[1].trim() : null,
68
+ emoji: emojiMatch ? emojiMatch[1].trim() : null,
69
+ }
23
70
  }
24
71
 
25
72
  /**
@@ -32,93 +79,215 @@ function slugToName(slug: string): string {
32
79
  .join(' ')
33
80
  }
34
81
 
82
+ /**
83
+ * Read a file and return its content, or null on any error.
84
+ */
85
+ function safeRead(filePath: string): string | null {
86
+ try {
87
+ if (!existsSync(filePath)) return null
88
+ return readFileSync(filePath, 'utf-8')
89
+ } catch {
90
+ return null
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Scan a directory for sub-agent .md files (e.g. sub-agents/QUILL.md, members/WRITER.md).
96
+ * Returns an array of { fileName, content } for each .md file found.
97
+ */
98
+ function scanSubAgentDir(dirPath: string): { fileName: string; content: string }[] {
99
+ if (!existsSync(dirPath)) return []
100
+ try {
101
+ return readdirSync(dirPath)
102
+ .filter(f => f.endsWith('.md'))
103
+ .map(f => {
104
+ const content = safeRead(join(dirPath, f))
105
+ return content ? { fileName: f, content } : null
106
+ })
107
+ .filter((x): x is { fileName: string; content: string } => x !== null)
108
+ } catch {
109
+ return []
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Auto-discovery
115
+ // ---------------------------------------------------------------------------
116
+
35
117
  /**
36
118
  * Auto-discover agents from $WORKSPACE_PATH.
37
119
  *
38
- * Scans the workspace for a root SOUL.md and agent subdirectories
39
- * under agents/. Each subdirectory with a SOUL.md becomes an agent.
120
+ * Scans the workspace for:
121
+ * - IDENTITY.md at root (for root agent name/emoji)
122
+ * - SOUL.md at root (root orchestrator)
123
+ * - agents/<dir>/SOUL.md (top-level agents)
124
+ * - agents/<dir>/sub-agents/*.md (nested sub-agents)
125
+ * - agents/<dir>/members/*.md (team member agents)
126
+ *
40
127
  * Returns null if nothing can be discovered.
41
128
  */
42
129
  function discoverAgents(workspacePath: string): AgentEntry[] | null {
43
130
  const agentsDir = join(workspacePath, 'agents')
44
131
  const rootSoulPath = join(workspacePath, 'SOUL.md')
132
+ const identityPath = join(workspacePath, 'IDENTITY.md')
45
133
  const hasRoot = existsSync(rootSoulPath)
46
134
 
47
- // Scan agents/ directory for subdirectories with SOUL.md
48
- let agentDirs: string[] = []
135
+ // Scan agents/ directory for all subdirectories (even without SOUL.md, may have sub-agents)
136
+ let allAgentDirs: string[] = []
49
137
  if (existsSync(agentsDir)) {
50
138
  try {
51
139
  const entries = readdirSync(agentsDir, { withFileTypes: true })
52
- agentDirs = entries
53
- .filter(e => e.isDirectory() && existsSync(join(agentsDir, e.name, 'SOUL.md')))
54
- .map(e => e.name)
55
- } catch {
56
- // Can't read directory
57
- }
140
+ allAgentDirs = entries.filter(e => e.isDirectory()).map(e => e.name)
141
+ } catch {}
58
142
  }
59
143
 
60
- // Need at least a root or one agent to return a discovered registry
61
- if (!hasRoot && agentDirs.length === 0) return null
144
+ // Need at least a root or agent directories to discover
145
+ if (!hasRoot && allAgentDirs.length === 0) return null
62
146
 
63
147
  const discovered: AgentEntry[] = []
148
+ let colorIndex = 0
64
149
 
65
- // Build root agent from workspace SOUL.md
150
+ // --- Root agent ---
66
151
  let rootId = 'main'
67
152
  let rootName = 'Main'
153
+ let rootEmoji = '\u{1F916}'
154
+
68
155
  if (hasRoot) {
69
- try {
70
- const content = readFileSync(rootSoulPath, 'utf-8')
71
- const extracted = extractNameFromSoul(content)
72
- if (extracted) {
73
- rootName = extracted
74
- rootId = extracted.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'main'
156
+ // Try IDENTITY.md first (most reliable source for name/emoji)
157
+ const identityContent = safeRead(identityPath)
158
+ if (identityContent) {
159
+ const identity = parseIdentity(identityContent)
160
+ if (identity.name) {
161
+ rootName = identity.name
162
+ rootId = identity.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'main'
75
163
  }
76
- } catch {}
164
+ if (identity.emoji) rootEmoji = identity.emoji
165
+ }
166
+
167
+ // Fall back to SOUL.md heading if IDENTITY.md didn't provide a name
168
+ if (rootName === 'Main') {
169
+ const soulContent = safeRead(rootSoulPath)
170
+ if (soulContent) {
171
+ const parsed = parseSoulHeading(soulContent)
172
+ if (parsed.name) {
173
+ rootName = parsed.name
174
+ rootId = parsed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'main'
175
+ }
176
+ }
177
+ }
178
+
179
+ // Collect all agent IDs that report to root (including those without SOUL.md but with sub-agents)
180
+ const directReportIds: string[] = []
77
181
 
78
182
  discovered.push({
79
183
  id: rootId,
80
184
  name: rootName,
81
185
  title: 'Orchestrator',
82
186
  reportsTo: null,
83
- directReports: agentDirs,
187
+ directReports: directReportIds, // populated below
84
188
  soulPath: 'SOUL.md',
85
189
  voiceId: null,
86
- color: DISCOVER_COLORS[0],
87
- emoji: '\u{1F916}',
190
+ color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
191
+ emoji: rootEmoji,
88
192
  tools: ['read', 'write', 'exec', 'message'],
89
193
  memoryPath: null,
90
194
  description: 'Top-level orchestrator.',
91
195
  })
196
+
197
+ // We'll fill directReportIds as we discover agents below
198
+ for (const dirName of allAgentDirs) {
199
+ const hasSoul = existsSync(join(agentsDir, dirName, 'SOUL.md'))
200
+ const hasSubAgents = existsSync(join(agentsDir, dirName, 'sub-agents'))
201
+ const hasMembers = existsSync(join(agentsDir, dirName, 'members'))
202
+ if (hasSoul || hasSubAgents || hasMembers) {
203
+ directReportIds.push(dirName)
204
+ }
205
+ }
92
206
  }
93
207
 
94
- // Build child agents from agents/ subdirectories
95
- agentDirs.forEach((dirName, i) => {
208
+ // --- Top-level agents ---
209
+ for (const dirName of allAgentDirs) {
210
+ const soulFile = join(agentsDir, dirName, 'SOUL.md')
211
+ const hasSoul = existsSync(soulFile)
212
+ const subAgentsDir = join(agentsDir, dirName, 'sub-agents')
213
+ const membersDir = join(agentsDir, dirName, 'members')
214
+ const hasSubAgents = existsSync(subAgentsDir)
215
+ const hasMembers = existsSync(membersDir)
216
+
217
+ // Skip directories with nothing useful
218
+ if (!hasSoul && !hasSubAgents && !hasMembers) continue
219
+
96
220
  let name = slugToName(dirName)
97
221
  let title = 'Agent'
222
+ const subAgentIds: string[] = []
98
223
 
99
- try {
100
- const content = readFileSync(join(agentsDir, dirName, 'SOUL.md'), 'utf-8')
101
- const extracted = extractNameFromSoul(content)
102
- if (extracted) name = extracted
103
- const roleMatch = content.match(/^(?:Role|Title):\s*(.+)/mi)
104
- if (roleMatch) title = roleMatch[1].trim()
105
- } catch {}
224
+ // Parse SOUL.md for name and title
225
+ if (hasSoul) {
226
+ const content = safeRead(soulFile)
227
+ if (content) {
228
+ const parsed = parseSoulHeading(content)
229
+ if (parsed.name) name = parsed.name
230
+ if (parsed.title) title = parsed.title
231
+
232
+ // Also check for Role: or Title: lines as fallback
233
+ if (title === 'Agent') {
234
+ const roleMatch = content.match(/^##\s*Role\b/mi)
235
+ if (roleMatch) {
236
+ // Try to get first line after ## Role
237
+ const afterRole = content.slice(roleMatch.index! + roleMatch[0].length)
238
+ const firstLine = afterRole.match(/\n\s*\n(.+)/)?.[1]?.trim()
239
+ // Don't use long role descriptions as titles
240
+ if (firstLine && firstLine.length < 80) title = firstLine
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ // Discover sub-agents (sub-agents/ and members/ directories)
247
+ const subAgentFiles = [
248
+ ...scanSubAgentDir(subAgentsDir),
249
+ ...scanSubAgentDir(membersDir),
250
+ ]
251
+
252
+ for (const sub of subAgentFiles) {
253
+ const subId = `${dirName}-${basename(sub.fileName, '.md').toLowerCase()}`
254
+ subAgentIds.push(subId)
255
+
256
+ const subParsed = parseSoulHeading(sub.content)
257
+ const subName = subParsed.name || slugToName(basename(sub.fileName, '.md'))
258
+ const subTitle = subParsed.title || 'Agent'
259
+
260
+ discovered.push({
261
+ id: subId,
262
+ name: subName,
263
+ title: subTitle,
264
+ reportsTo: dirName,
265
+ directReports: [],
266
+ soulPath: null, // sub-agents use non-standard paths, soul loaded differently
267
+ voiceId: null,
268
+ color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
269
+ emoji: subName.charAt(0).toUpperCase(),
270
+ tools: ['read', 'write'],
271
+ memoryPath: null,
272
+ description: `${subName} agent.`,
273
+ })
274
+ }
106
275
 
107
276
  discovered.push({
108
277
  id: dirName,
109
278
  name,
110
279
  title,
111
280
  reportsTo: hasRoot ? rootId : null,
112
- directReports: [],
113
- soulPath: `agents/${dirName}/SOUL.md`,
281
+ directReports: subAgentIds,
282
+ soulPath: hasSoul ? `agents/${dirName}/SOUL.md` : null,
114
283
  voiceId: null,
115
- color: DISCOVER_COLORS[(i + 1) % DISCOVER_COLORS.length],
284
+ color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
116
285
  emoji: name.charAt(0).toUpperCase(),
117
286
  tools: ['read', 'write'],
118
287
  memoryPath: null,
119
288
  description: `${name} agent.`,
120
289
  })
121
- })
290
+ }
122
291
 
123
292
  return discovered.length > 0 ? discovered : null
124
293
  }
@@ -122,6 +122,7 @@ vi.mock('@/lib/agents.json', () => ({
122
122
 
123
123
  // We need to import AFTER mocks are set up
124
124
  import { getAgents, getAgent } from './agents'
125
+ import { parseSoulHeading, parseIdentity } from './agents-registry'
125
126
 
126
127
  beforeEach(() => {
127
128
  vi.clearAllMocks()
@@ -131,15 +132,105 @@ beforeEach(() => {
131
132
  mockReaddirSync.mockReturnValue([])
132
133
  })
133
134
 
134
- /** Helper: block auto-discovery paths so bundled registry is used */
135
- function blockDiscovery(ws: string) {
136
- return (p: string) => {
137
- if (p === `${ws}/clawport/agents.json`) return false
138
- if (p === `${ws}/SOUL.md`) return false
139
- if (p === `${ws}/agents`) return false
140
- return false
141
- }
142
- }
135
+ // ---------------------------------------------------------------------------
136
+ // parseSoulHeading -- heading extraction unit tests
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe('parseSoulHeading', () => {
140
+ it('strips "SOUL.md — " prefix', () => {
141
+ const result = parseSoulHeading('# SOUL.md — VERA\nContent here')
142
+ expect(result.name).toBe('VERA')
143
+ expect(result.title).toBeNull()
144
+ })
145
+
146
+ it('strips "SOUL.md - " prefix (regular dash)', () => {
147
+ const result = parseSoulHeading('# SOUL.md - HERALD\nContent')
148
+ expect(result.name).toBe('HERALD')
149
+ })
150
+
151
+ it('returns null name for generic "Who You Are" heading', () => {
152
+ const result = parseSoulHeading('# SOUL.md - Who You Are\nContent')
153
+ expect(result.name).toBeNull()
154
+ expect(result.title).toBeNull()
155
+ })
156
+
157
+ it('parses name and title from em-dash separated heading', () => {
158
+ const result = parseSoulHeading('# ECHO — Community Voice Monitor\nContent')
159
+ expect(result.name).toBe('ECHO')
160
+ expect(result.title).toBe('Community Voice Monitor')
161
+ })
162
+
163
+ it('parses name and title from comma-separated heading after prefix strip', () => {
164
+ const result = parseSoulHeading('# SOUL.md — KAZE, Flight Research Agent\nContent')
165
+ expect(result.name).toBe('KAZE')
166
+ expect(result.title).toBe('Flight Research Agent')
167
+ })
168
+
169
+ it('parses name and title from "LUMEN, SEO Team Director" after prefix strip', () => {
170
+ const result = parseSoulHeading('# SOUL.md — LUMEN, SEO Team Director\nContent')
171
+ expect(result.name).toBe('LUMEN')
172
+ expect(result.title).toBe('SEO Team Director')
173
+ })
174
+
175
+ it('parses clean heading without prefix', () => {
176
+ const result = parseSoulHeading('# CARTOGRAPHER — Keyword Territory Agent\nContent')
177
+ expect(result.name).toBe('CARTOGRAPHER')
178
+ expect(result.title).toBe('Keyword Territory Agent')
179
+ })
180
+
181
+ it('parses sub-agent heading', () => {
182
+ const result = parseSoulHeading('# QUILL — Herald\'s Content Writer\n## Role')
183
+ expect(result.name).toBe('QUILL')
184
+ expect(result.title).toBe('Herald\'s Content Writer')
185
+ })
186
+
187
+ it('returns null for content with no heading', () => {
188
+ const result = parseSoulHeading('No heading here, just text.')
189
+ expect(result.name).toBeNull()
190
+ expect(result.title).toBeNull()
191
+ })
192
+
193
+ it('handles en-dash separator', () => {
194
+ const result = parseSoulHeading('# AGENT – Task Runner\nContent')
195
+ expect(result.name).toBe('AGENT')
196
+ expect(result.title).toBe('Task Runner')
197
+ })
198
+
199
+ it('handles simple name with no separator', () => {
200
+ const result = parseSoulHeading('# MyBot\nContent')
201
+ expect(result.name).toBe('MyBot')
202
+ expect(result.title).toBeNull()
203
+ })
204
+ })
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // parseIdentity
208
+ // ---------------------------------------------------------------------------
209
+
210
+ describe('parseIdentity', () => {
211
+ it('extracts name and emoji from IDENTITY.md format', () => {
212
+ const content = `# IDENTITY.md - Who Am I?
213
+
214
+ - **Name:** Jarvis
215
+ - **Creature:** AI assistant
216
+ - **Emoji:** 🤖`
217
+ const result = parseIdentity(content)
218
+ expect(result.name).toBe('Jarvis')
219
+ expect(result.emoji).toBe('🤖')
220
+ })
221
+
222
+ it('returns nulls when fields are missing', () => {
223
+ const result = parseIdentity('# Just a heading\nNo identity fields here.')
224
+ expect(result.name).toBeNull()
225
+ expect(result.emoji).toBeNull()
226
+ })
227
+
228
+ it('handles name without emoji', () => {
229
+ const result = parseIdentity('- **Name:** CustomBot')
230
+ expect(result.name).toBe('CustomBot')
231
+ expect(result.emoji).toBeNull()
232
+ })
233
+ })
143
234
 
144
235
  // ---------------------------------------------------------------------------
145
236
  // Registry loading: bundled fallback vs workspace override vs auto-discovery
@@ -212,7 +303,6 @@ describe('agent registry loading', () => {
212
303
  })
213
304
 
214
305
  const agents = await getAgents()
215
- // Should fall back to bundled agents, not crash
216
306
  expect(agents.length).toBe(bundledAgents.length)
217
307
  expect(agents.map(a => a.id)).toContain('jarvis')
218
308
  })
@@ -244,7 +334,6 @@ describe('agent registry loading', () => {
244
334
 
245
335
  mockExistsSync.mockImplementation((p: string) => {
246
336
  if (p === '/tmp/ws/clawport/agents.json') return true
247
- // These would trigger discovery, but override takes priority
248
337
  if (p === '/tmp/ws/SOUL.md') return true
249
338
  if (p === '/tmp/ws/agents') return true
250
339
  return false
@@ -265,50 +354,214 @@ describe('agent registry loading', () => {
265
354
  // ---------------------------------------------------------------------------
266
355
 
267
356
  describe('auto-discovery from workspace', () => {
268
- it('discovers agents from workspace agents/ directory', async () => {
357
+ it('discovers agents and uses IDENTITY.md for root name/emoji', async () => {
269
358
  vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
270
359
 
271
360
  mockExistsSync.mockImplementation((p: string) => {
272
361
  if (p === '/tmp/ws/clawport/agents.json') return false
273
362
  if (p === '/tmp/ws/SOUL.md') return true
363
+ if (p === '/tmp/ws/IDENTITY.md') return true
274
364
  if (p === '/tmp/ws/agents') return true
275
- if (p === '/tmp/ws/agents/bot-a/SOUL.md') return true
276
- if (p === '/tmp/ws/agents/bot-b/SOUL.md') return true
365
+ if (p === '/tmp/ws/agents/echo/SOUL.md') return true
366
+ if (p === '/tmp/ws/agents/echo/sub-agents') return false
367
+ if (p === '/tmp/ws/agents/echo/members') return false
277
368
  return false
278
369
  })
279
370
 
280
371
  mockReaddirSync.mockReturnValue([
281
- { name: 'bot-a', isDirectory: () => true },
282
- { name: 'bot-b', isDirectory: () => true },
283
- { name: 'README.md', isDirectory: () => false },
372
+ { name: 'echo', isDirectory: () => true },
284
373
  ])
285
374
 
286
375
  mockReadFileSync.mockImplementation((p: string) => {
287
- if (p === '/tmp/ws/SOUL.md') return '# MyOrchestrator'
288
- if (p === '/tmp/ws/agents/bot-a/SOUL.md') return '# Bot Alpha\nRole: Data Analyst'
289
- if (p === '/tmp/ws/agents/bot-b/SOUL.md') return '# Bot Beta'
376
+ if (p === '/tmp/ws/IDENTITY.md') return '- **Name:** Jarvis\n- **Emoji:** 🤖'
377
+ if (p === '/tmp/ws/SOUL.md') return '# SOUL.md - Who You Are\nContent'
378
+ if (p === '/tmp/ws/agents/echo/SOUL.md') return '# ECHO — Community Voice Monitor\nContent'
290
379
  throw new Error('ENOENT')
291
380
  })
292
381
 
293
382
  const agents = await getAgents()
294
- expect(agents).toHaveLength(3) // root + 2 agents
383
+ const root = agents.find(a => a.reportsTo === null)!
384
+ expect(root.name).toBe('Jarvis')
385
+ expect(root.id).toBe('jarvis')
386
+ expect(root.emoji).toBe('🤖')
387
+ expect(root.directReports).toContain('echo')
388
+
389
+ const echo = agents.find(a => a.id === 'echo')!
390
+ expect(echo.name).toBe('ECHO')
391
+ expect(echo.title).toBe('Community Voice Monitor')
392
+ expect(echo.reportsTo).toBe('jarvis')
393
+ })
394
+
395
+ it('falls back to SOUL.md heading when IDENTITY.md is missing', async () => {
396
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
397
+
398
+ mockExistsSync.mockImplementation((p: string) => {
399
+ if (p === '/tmp/ws/clawport/agents.json') return false
400
+ if (p === '/tmp/ws/IDENTITY.md') return false
401
+ if (p === '/tmp/ws/SOUL.md') return true
402
+ if (p === '/tmp/ws/agents') return true
403
+ if (p === '/tmp/ws/agents/bot/SOUL.md') return true
404
+ if (p === '/tmp/ws/agents/bot/sub-agents') return false
405
+ if (p === '/tmp/ws/agents/bot/members') return false
406
+ return false
407
+ })
408
+
409
+ mockReaddirSync.mockReturnValue([
410
+ { name: 'bot', isDirectory: () => true },
411
+ ])
412
+
413
+ mockReadFileSync.mockImplementation((p: string) => {
414
+ if (p === '/tmp/ws/SOUL.md') return '# SOUL.md — MyBot\nContent'
415
+ if (p === '/tmp/ws/agents/bot/SOUL.md') return '# Bot Agent\nContent'
416
+ throw new Error('ENOENT')
417
+ })
418
+
419
+ const agents = await getAgents()
420
+ const root = agents.find(a => a.reportsTo === null)!
421
+ expect(root.name).toBe('MyBot')
422
+ expect(root.id).toBe('mybot')
423
+ })
424
+
425
+ it('uses "Main" as root name when SOUL.md heading is generic', async () => {
426
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
427
+
428
+ mockExistsSync.mockImplementation((p: string) => {
429
+ if (p === '/tmp/ws/clawport/agents.json') return false
430
+ if (p === '/tmp/ws/IDENTITY.md') return false
431
+ if (p === '/tmp/ws/SOUL.md') return true
432
+ if (p === '/tmp/ws/agents') return true
433
+ if (p === '/tmp/ws/agents/bot/SOUL.md') return true
434
+ if (p === '/tmp/ws/agents/bot/sub-agents') return false
435
+ if (p === '/tmp/ws/agents/bot/members') return false
436
+ return false
437
+ })
438
+
439
+ mockReaddirSync.mockReturnValue([
440
+ { name: 'bot', isDirectory: () => true },
441
+ ])
295
442
 
443
+ mockReadFileSync.mockImplementation((p: string) => {
444
+ if (p === '/tmp/ws/SOUL.md') return '# SOUL.md - Who You Are\nContent'
445
+ if (p === '/tmp/ws/agents/bot/SOUL.md') return '# Bot\nContent'
446
+ throw new Error('ENOENT')
447
+ })
448
+
449
+ const agents = await getAgents()
296
450
  const root = agents.find(a => a.reportsTo === null)!
297
- expect(root.name).toBe('MyOrchestrator')
298
- expect(root.soulPath).toBe('SOUL.md')
299
- expect(root.soul).toBe('# MyOrchestrator')
300
- expect(root.directReports).toEqual(['bot-a', 'bot-b'])
451
+ expect(root.name).toBe('Main')
452
+ expect(root.id).toBe('main')
453
+ })
454
+
455
+ it('discovers sub-agents in sub-agents/ directory', async () => {
456
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
457
+
458
+ mockExistsSync.mockImplementation((p: string) => {
459
+ if (p === '/tmp/ws/clawport/agents.json') return false
460
+ if (p === '/tmp/ws/SOUL.md') return true
461
+ if (p === '/tmp/ws/IDENTITY.md') return false
462
+ if (p === '/tmp/ws/agents') return true
463
+ if (p === '/tmp/ws/agents/herald/SOUL.md') return true
464
+ if (p === '/tmp/ws/agents/herald/sub-agents') return true
465
+ if (p === '/tmp/ws/agents/herald/sub-agents/QUILL.md') return true
466
+ if (p === '/tmp/ws/agents/herald/sub-agents/MAVEN.md') return true
467
+ if (p === '/tmp/ws/agents/herald/members') return false
468
+ return false
469
+ })
470
+
471
+ // First call: agents/ dir, second call: sub-agents/ dir
472
+ mockReaddirSync
473
+ .mockReturnValueOnce([{ name: 'herald', isDirectory: () => true }])
474
+ .mockReturnValueOnce(['QUILL.md', 'MAVEN.md'])
475
+
476
+ mockReadFileSync.mockImplementation((p: string) => {
477
+ if (p === '/tmp/ws/SOUL.md') return '# SOUL.md - Who You Are'
478
+ if (p === '/tmp/ws/agents/herald/SOUL.md') return '# SOUL.md — HERALD\n## Role'
479
+ if (p === '/tmp/ws/agents/herald/sub-agents/QUILL.md') return '# QUILL — Content Writer'
480
+ if (p === '/tmp/ws/agents/herald/sub-agents/MAVEN.md') return '# MAVEN — LinkedIn Strategist'
481
+ throw new Error('ENOENT')
482
+ })
483
+
484
+ const agents = await getAgents()
485
+ const herald = agents.find(a => a.id === 'herald')!
486
+ expect(herald.name).toBe('HERALD')
487
+ expect(herald.directReports).toContain('herald-quill')
488
+ expect(herald.directReports).toContain('herald-maven')
489
+
490
+ const quill = agents.find(a => a.id === 'herald-quill')!
491
+ expect(quill.name).toBe('QUILL')
492
+ expect(quill.title).toBe('Content Writer')
493
+ expect(quill.reportsTo).toBe('herald')
494
+
495
+ const maven = agents.find(a => a.id === 'herald-maven')!
496
+ expect(maven.name).toBe('MAVEN')
497
+ expect(maven.title).toBe('LinkedIn Strategist')
498
+ expect(maven.reportsTo).toBe('herald')
499
+ })
500
+
501
+ it('discovers members/ directory agents', async () => {
502
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
503
+
504
+ mockExistsSync.mockImplementation((p: string) => {
505
+ if (p === '/tmp/ws/clawport/agents.json') return false
506
+ if (p === '/tmp/ws/SOUL.md') return false
507
+ if (p === '/tmp/ws/agents') return true
508
+ if (p === '/tmp/ws/agents/seo-team/SOUL.md') return true
509
+ if (p === '/tmp/ws/agents/seo-team/sub-agents') return false
510
+ if (p === '/tmp/ws/agents/seo-team/members') return true
511
+ if (p === '/tmp/ws/agents/seo-team/members/WRITER.md') return true
512
+ return false
513
+ })
514
+
515
+ mockReaddirSync
516
+ .mockReturnValueOnce([{ name: 'seo-team', isDirectory: () => true }])
517
+ .mockReturnValueOnce(['WRITER.md'])
518
+
519
+ mockReadFileSync.mockImplementation((p: string) => {
520
+ if (p === '/tmp/ws/agents/seo-team/SOUL.md') return '# SOUL.md — LUMEN, SEO Team Director'
521
+ if (p === '/tmp/ws/agents/seo-team/members/WRITER.md') return '# WRITER — Conversion-Focused Content Creator'
522
+ throw new Error('ENOENT')
523
+ })
524
+
525
+ const agents = await getAgents()
526
+ const lumen = agents.find(a => a.id === 'seo-team')!
527
+ expect(lumen.name).toBe('LUMEN')
528
+ expect(lumen.title).toBe('SEO Team Director')
529
+ expect(lumen.directReports).toContain('seo-team-writer')
530
+
531
+ const writer = agents.find(a => a.id === 'seo-team-writer')!
532
+ expect(writer.name).toBe('WRITER')
533
+ expect(writer.title).toBe('Conversion-Focused Content Creator')
534
+ expect(writer.reportsTo).toBe('seo-team')
535
+ })
536
+
537
+ it('discovers dirs without SOUL.md if they have sub-agents', async () => {
538
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
539
+
540
+ mockExistsSync.mockImplementation((p: string) => {
541
+ if (p === '/tmp/ws/clawport/agents.json') return false
542
+ if (p === '/tmp/ws/SOUL.md') return false
543
+ if (p === '/tmp/ws/agents') return true
544
+ if (p === '/tmp/ws/agents/team/SOUL.md') return false // no SOUL.md!
545
+ if (p === '/tmp/ws/agents/team/sub-agents') return true
546
+ if (p === '/tmp/ws/agents/team/sub-agents/WORKER.md') return true
547
+ if (p === '/tmp/ws/agents/team/members') return false
548
+ return false
549
+ })
301
550
 
302
- const botA = agents.find(a => a.id === 'bot-a')!
303
- expect(botA.name).toBe('Bot Alpha')
304
- expect(botA.title).toBe('Data Analyst')
305
- expect(botA.reportsTo).toBe(root.id)
306
- expect(botA.soulPath).toBe('agents/bot-a/SOUL.md')
307
- expect(botA.soul).toBe('# Bot Alpha\nRole: Data Analyst')
551
+ mockReaddirSync
552
+ .mockReturnValueOnce([{ name: 'team', isDirectory: () => true }])
553
+ .mockReturnValueOnce(['WORKER.md'])
554
+
555
+ mockReadFileSync.mockImplementation((p: string) => {
556
+ if (p === '/tmp/ws/agents/team/sub-agents/WORKER.md') return '# WORKER Task Runner'
557
+ throw new Error('ENOENT')
558
+ })
308
559
 
309
- const botB = agents.find(a => a.id === 'bot-b')!
310
- expect(botB.name).toBe('Bot Beta')
311
- expect(botB.reportsTo).toBe(root.id)
560
+ const agents = await getAgents()
561
+ const team = agents.find(a => a.id === 'team')!
562
+ expect(team.name).toBe('Team')
563
+ expect(team.soulPath).toBeNull()
564
+ expect(team.directReports).toContain('team-worker')
312
565
  })
313
566
 
314
567
  it('discovers agents without root SOUL.md (flat structure)', async () => {
@@ -319,6 +572,8 @@ describe('auto-discovery from workspace', () => {
319
572
  if (p === '/tmp/ws/SOUL.md') return false
320
573
  if (p === '/tmp/ws/agents') return true
321
574
  if (p === '/tmp/ws/agents/worker/SOUL.md') return true
575
+ if (p === '/tmp/ws/agents/worker/sub-agents') return false
576
+ if (p === '/tmp/ws/agents/worker/members') return false
322
577
  return false
323
578
  })
324
579
 
@@ -345,59 +600,69 @@ describe('auto-discovery from workspace', () => {
345
600
  expect(agents.length).toBe(bundledAgents.length)
346
601
  })
347
602
 
348
- it('skips directories without SOUL.md', async () => {
603
+ it('uses directory slug as name when SOUL.md has no heading', async () => {
349
604
  vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
350
605
 
351
606
  mockExistsSync.mockImplementation((p: string) => {
352
607
  if (p === '/tmp/ws/clawport/agents.json') return false
353
- if (p === '/tmp/ws/SOUL.md') return true
608
+ if (p === '/tmp/ws/SOUL.md') return false
354
609
  if (p === '/tmp/ws/agents') return true
355
- if (p === '/tmp/ws/agents/valid/SOUL.md') return true
356
- if (p === '/tmp/ws/agents/empty/SOUL.md') return false // no SOUL.md
610
+ if (p === '/tmp/ws/agents/my-bot/SOUL.md') return true
611
+ if (p === '/tmp/ws/agents/my-bot/sub-agents') return false
612
+ if (p === '/tmp/ws/agents/my-bot/members') return false
357
613
  return false
358
614
  })
359
615
 
360
616
  mockReaddirSync.mockReturnValue([
361
- { name: 'valid', isDirectory: () => true },
362
- { name: 'empty', isDirectory: () => true },
617
+ { name: 'my-bot', isDirectory: () => true },
363
618
  ])
364
619
 
365
620
  mockReadFileSync.mockImplementation((p: string) => {
366
- if (p === '/tmp/ws/SOUL.md') return '# Root'
367
- if (p === '/tmp/ws/agents/valid/SOUL.md') return '# Valid Agent'
621
+ if (p === '/tmp/ws/agents/my-bot/SOUL.md') return 'No heading here, just text.'
368
622
  throw new Error('ENOENT')
369
623
  })
370
624
 
371
625
  const agents = await getAgents()
372
- // root + 1 valid agent (empty skipped)
373
- expect(agents).toHaveLength(2)
374
- expect(agents.map(a => a.id)).not.toContain('empty')
626
+ expect(agents).toHaveLength(1)
627
+ expect(agents[0].id).toBe('my-bot')
628
+ expect(agents[0].name).toBe('My Bot') // slugToName
375
629
  })
376
630
 
377
- it('uses directory name as fallback when SOUL.md has no heading', async () => {
631
+ it('strips SOUL.md prefix from agent names in discovery', async () => {
378
632
  vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
379
633
 
380
634
  mockExistsSync.mockImplementation((p: string) => {
381
635
  if (p === '/tmp/ws/clawport/agents.json') return false
382
636
  if (p === '/tmp/ws/SOUL.md') return false
383
637
  if (p === '/tmp/ws/agents') return true
384
- if (p === '/tmp/ws/agents/my-bot/SOUL.md') return true
638
+ if (p === '/tmp/ws/agents/vera/SOUL.md') return true
639
+ if (p === '/tmp/ws/agents/vera/sub-agents') return false
640
+ if (p === '/tmp/ws/agents/vera/members') return false
641
+ if (p === '/tmp/ws/agents/kaze/SOUL.md') return true
642
+ if (p === '/tmp/ws/agents/kaze/sub-agents') return false
643
+ if (p === '/tmp/ws/agents/kaze/members') return false
385
644
  return false
386
645
  })
387
646
 
388
647
  mockReaddirSync.mockReturnValue([
389
- { name: 'my-bot', isDirectory: () => true },
648
+ { name: 'vera', isDirectory: () => true },
649
+ { name: 'kaze', isDirectory: () => true },
390
650
  ])
391
651
 
392
652
  mockReadFileSync.mockImplementation((p: string) => {
393
- if (p === '/tmp/ws/agents/my-bot/SOUL.md') return 'No heading here, just text.'
653
+ if (p === '/tmp/ws/agents/vera/SOUL.md') return '# SOUL.md VERA'
654
+ if (p === '/tmp/ws/agents/kaze/SOUL.md') return '# SOUL.md — KAZE, Flight Research Agent'
394
655
  throw new Error('ENOENT')
395
656
  })
396
657
 
397
658
  const agents = await getAgents()
398
- expect(agents).toHaveLength(1)
399
- expect(agents[0].id).toBe('my-bot')
400
- expect(agents[0].name).toBe('My Bot') // slugToName
659
+ const vera = agents.find(a => a.id === 'vera')!
660
+ expect(vera.name).toBe('VERA')
661
+ expect(vera.title).toBe('Agent') // no title from heading alone
662
+
663
+ const kaze = agents.find(a => a.id === 'kaze')!
664
+ expect(kaze.name).toBe('KAZE')
665
+ expect(kaze.title).toBe('Flight Research Agent')
401
666
  })
402
667
  })
403
668
 
@@ -459,12 +724,12 @@ describe('getAgents', () => {
459
724
  // Block auto-discovery, allow SOUL file reads for bundled agents
460
725
  if (p === '/tmp/ws/clawport/agents.json') return false
461
726
  if (p === '/tmp/ws/SOUL.md') return false
727
+ if (p === '/tmp/ws/IDENTITY.md') return false
462
728
  if (p === '/tmp/ws/agents') return false
463
729
  return true
464
730
  })
465
731
  mockReadFileSync.mockReturnValue('# Agent SOUL content')
466
732
  const agents = await getAgents()
467
- // vera has soulPath: 'agents/vera/SOUL.md' which won't conflict with discovery
468
733
  const vera = agents.find(a => a.id === 'vera')!
469
734
  expect(vera.soul).toBe('# Agent SOUL content')
470
735
  })
@@ -474,6 +739,7 @@ describe('getAgents', () => {
474
739
  mockExistsSync.mockImplementation((p: string) => {
475
740
  if (p === '/tmp/ws/clawport/agents.json') return false
476
741
  if (p === '/tmp/ws/SOUL.md') return false
742
+ if (p === '/tmp/ws/IDENTITY.md') return false
477
743
  if (p === '/tmp/ws/agents') return false
478
744
  return true
479
745
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawport-ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Open-source dashboard for managing, monitoring, and chatting with your OpenClaw AI agents.",
5
5
  "homepage": "https://clawport.dev",
6
6
  "repository": {
package/scripts/setup.mjs CHANGED
@@ -64,6 +64,34 @@ function detectGatewayToken() {
64
64
  }
65
65
  }
66
66
 
67
+ function checkHttpEndpointEnabled() {
68
+ const configPath = join(homedir(), '.openclaw', 'openclaw.json')
69
+ if (!existsSync(configPath)) return null // can't check
70
+ try {
71
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'))
72
+ return config?.gateway?.http?.endpoints?.chatCompletions?.enabled === true
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ function enableHttpEndpoint() {
79
+ const configPath = join(homedir(), '.openclaw', 'openclaw.json')
80
+ if (!existsSync(configPath)) return false
81
+ try {
82
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'))
83
+ if (!config.gateway) config.gateway = {}
84
+ if (!config.gateway.http) config.gateway.http = {}
85
+ if (!config.gateway.http.endpoints) config.gateway.http.endpoints = {}
86
+ if (!config.gateway.http.endpoints.chatCompletions) config.gateway.http.endpoints.chatCompletions = {}
87
+ config.gateway.http.endpoints.chatCompletions.enabled = true
88
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
89
+ return true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
67
95
  async function checkGatewayRunning() {
68
96
  try {
69
97
  const res = await fetch('http://127.0.0.1:18789/', {
@@ -123,6 +151,24 @@ async function main() {
123
151
  console.log(` ${yellow('!')} Gateway not responding at localhost:18789`)
124
152
  console.log(` ${dim('Start it with: openclaw gateway run')}`)
125
153
  }
154
+
155
+ // Check HTTP chat completions endpoint
156
+ const httpEnabled = checkHttpEndpointEnabled()
157
+ if (httpEnabled === true) {
158
+ console.log(` ${green('+')} HTTP chat completions endpoint ${dim('enabled')}`)
159
+ } else if (httpEnabled === false) {
160
+ console.log(` ${yellow('!')} HTTP chat completions endpoint is ${bold('disabled')}`)
161
+ console.log(` ${dim('ClawPort needs this to chat with agents.')}`)
162
+ const enable = await ask(` ${yellow('?')} Enable it in openclaw.json? (Y/n) `)
163
+ if (enable.toLowerCase() !== 'n') {
164
+ if (enableHttpEndpoint()) {
165
+ console.log(` ${green('+')} Enabled! ${dim('Restart the gateway for this to take effect.')}`)
166
+ } else {
167
+ console.log(` ${red('x')} Could not update openclaw.json. Enable it manually:`)
168
+ console.log(` ${dim('Set gateway.http.endpoints.chatCompletions.enabled = true in ~/.openclaw/openclaw.json')}`)
169
+ }
170
+ }
171
+ }
126
172
  console.log()
127
173
 
128
174
  // Handle missing values