@swarmclawai/swarmclaw 0.7.7 → 0.8.0

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.
Files changed (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -0,0 +1,128 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import os from 'os'
4
+ import { DATA_DIR } from './data-dir'
5
+ import { normalizeSkillPayload, type NormalizedSkill } from './skills-normalize'
6
+
7
+ export interface DiscoveredSkill extends NormalizedSkill {
8
+ /** Which layer this skill was found in. */
9
+ source: 'bundled' | 'workspace' | 'project'
10
+ /** Absolute path to the SKILL.md file. */
11
+ sourcePath: string
12
+ }
13
+
14
+ interface DiscoveryCache {
15
+ skills: DiscoveredSkill[]
16
+ ids: string[]
17
+ timestamp: number
18
+ cacheKey: string
19
+ }
20
+
21
+ const CACHE_TTL_MS = 5_000
22
+
23
+ let cache: DiscoveryCache | null = null
24
+
25
+ function buildCacheKey(cwd?: string): string {
26
+ return `${cwd || ''}`
27
+ }
28
+
29
+ function scanLayer(
30
+ dir: string,
31
+ source: DiscoveredSkill['source'],
32
+ ): DiscoveredSkill[] {
33
+ const results: DiscoveredSkill[] = []
34
+ let entries: string[]
35
+ try {
36
+ entries = fs.readdirSync(dir)
37
+ } catch {
38
+ return results
39
+ }
40
+
41
+ for (const entry of entries) {
42
+ const skillDir = path.join(dir, entry)
43
+ let stat: fs.Stats
44
+ try {
45
+ stat = fs.statSync(skillDir)
46
+ } catch {
47
+ continue
48
+ }
49
+ if (!stat.isDirectory()) continue
50
+
51
+ const skillFile = path.join(skillDir, 'SKILL.md')
52
+ let content: string
53
+ try {
54
+ content = fs.readFileSync(skillFile, 'utf-8')
55
+ } catch {
56
+ continue
57
+ }
58
+
59
+ const normalized = normalizeSkillPayload({
60
+ content,
61
+ filename: `${entry}.md`,
62
+ })
63
+
64
+ results.push({
65
+ ...normalized,
66
+ source,
67
+ sourcePath: skillFile,
68
+ })
69
+ }
70
+
71
+ return results
72
+ }
73
+
74
+ /**
75
+ * Discover skills from three layers:
76
+ * 1. Bundled: `data/skills/` (shipped with the app)
77
+ * 2. Workspace: `~/.swarmclaw/skills/` (user-installed)
78
+ * 3. Project: `<cwd>/skills/` (project-local)
79
+ *
80
+ * Results are cached with a 5-second TTL. Later layers override
81
+ * earlier ones when names collide (project > workspace > bundled).
82
+ */
83
+ export function discoverSkills(opts?: { cwd?: string }): DiscoveredSkill[] {
84
+ const cwd = opts?.cwd
85
+ const cacheKey = buildCacheKey(cwd)
86
+ const now = Date.now()
87
+
88
+ if (cache && cache.cacheKey === cacheKey && now - cache.timestamp < CACHE_TTL_MS) {
89
+ return cache.skills
90
+ }
91
+
92
+ // Layer 1: Bundled skills
93
+ const bundledDir = path.join(DATA_DIR, 'skills')
94
+ const bundled = scanLayer(bundledDir, 'bundled')
95
+
96
+ // Layer 2: Workspace skills (~/.swarmclaw/skills/)
97
+ const workspaceDir = path.join(os.homedir(), '.swarmclaw', 'skills')
98
+ const workspace = scanLayer(workspaceDir, 'workspace')
99
+
100
+ // Layer 3: Project-local skills (<cwd>/skills/)
101
+ let project: DiscoveredSkill[] = []
102
+ if (cwd) {
103
+ const projectDir = path.join(cwd, 'skills')
104
+ project = scanLayer(projectDir, 'project')
105
+ }
106
+
107
+ // Deduplicate: later layers win on name collision
108
+ const byName = new Map<string, DiscoveredSkill>()
109
+ for (const skill of [...bundled, ...workspace, ...project]) {
110
+ byName.set(skill.name.toLowerCase(), skill)
111
+ }
112
+ const skills = Array.from(byName.values())
113
+ const ids = skills.map((s) => s.name)
114
+
115
+ cache = { skills, ids, timestamp: now, cacheKey }
116
+ return skills
117
+ }
118
+
119
+ /**
120
+ * Return the names of all currently discovered skills (uses cache if warm).
121
+ */
122
+ export function getDiscoveredSkillIds(): string[] {
123
+ if (cache && Date.now() - cache.timestamp < CACHE_TTL_MS) {
124
+ return cache.ids
125
+ }
126
+ // Cold call without cwd — returns bundled + workspace only
127
+ return discoverSkills().map((s) => s.name)
128
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { evaluateRequirements, clearBinaryCache } from './skill-eligibility'
4
+ import type { SkillRequirements } from '@/types'
5
+
6
+ describe('evaluateRequirements', () => {
7
+ beforeEach(() => {
8
+ clearBinaryCache()
9
+ })
10
+
11
+ it('returns eligible when no requirements', () => {
12
+ const result = evaluateRequirements({})
13
+ assert.equal(result.eligible, true)
14
+ assert.equal(result.reasons.length, 0)
15
+ })
16
+
17
+ it('passes when required binary exists (node)', () => {
18
+ const result = evaluateRequirements({ bins: ['node'] })
19
+ assert.equal(result.eligible, true)
20
+ assert.equal(result.missingBins.length, 0)
21
+ })
22
+
23
+ it('fails when required binary is missing', () => {
24
+ const result = evaluateRequirements({ bins: ['nonexistent_binary_xyz_123'] })
25
+ assert.equal(result.eligible, false)
26
+ assert.deepEqual(result.missingBins, ['nonexistent_binary_xyz_123'])
27
+ assert.ok(result.reasons[0].includes('Missing binaries'))
28
+ })
29
+
30
+ it('handles anyBins groups — passes when at least one exists', () => {
31
+ const result = evaluateRequirements({
32
+ anyBins: [['node', 'nonexistent_abc']],
33
+ })
34
+ assert.equal(result.eligible, true)
35
+ assert.equal(result.missingAnyBins.length, 0)
36
+ })
37
+
38
+ it('handles anyBins groups — fails when none exist', () => {
39
+ const result = evaluateRequirements({
40
+ anyBins: [['nonexistent_a', 'nonexistent_b']],
41
+ })
42
+ assert.equal(result.eligible, false)
43
+ assert.equal(result.missingAnyBins.length, 1)
44
+ })
45
+
46
+ it('checks environment variables', () => {
47
+ const result = evaluateRequirements({ env: ['VERY_UNLIKELY_ENV_VAR_XYZ'] })
48
+ assert.equal(result.eligible, false)
49
+ assert.deepEqual(result.missingEnv, ['VERY_UNLIKELY_ENV_VAR_XYZ'])
50
+ })
51
+
52
+ it('passes env check when env var is set', () => {
53
+ // PATH is always set
54
+ const result = evaluateRequirements({ env: ['PATH'] })
55
+ assert.equal(result.eligible, true)
56
+ assert.equal(result.missingEnv.length, 0)
57
+ })
58
+
59
+ it('checks OS compatibility — passes on current platform', () => {
60
+ const result = evaluateRequirements({ os: [process.platform] })
61
+ assert.equal(result.eligible, true)
62
+ assert.equal(result.unsupportedOs, false)
63
+ })
64
+
65
+ it('checks OS compatibility — fails on wrong platform', () => {
66
+ const result = evaluateRequirements({ os: ['nonexistent_os'] })
67
+ assert.equal(result.eligible, false)
68
+ assert.equal(result.unsupportedOs, true)
69
+ })
70
+
71
+ it('combines multiple failing checks', () => {
72
+ const req: SkillRequirements = {
73
+ bins: ['nonexistent_bin_xyz'],
74
+ env: ['NONEXISTENT_ENV_ABC'],
75
+ os: ['nonexistent_os'],
76
+ }
77
+ const result = evaluateRequirements(req)
78
+ assert.equal(result.eligible, false)
79
+ assert.ok(result.reasons.length >= 3)
80
+ assert.ok(result.missingBins.length > 0)
81
+ assert.ok(result.missingEnv.length > 0)
82
+ assert.equal(result.unsupportedOs, true)
83
+ })
84
+ })
@@ -0,0 +1,95 @@
1
+ import { execSync } from 'child_process'
2
+ import type { Skill, SkillRequirements } from '@/types'
3
+
4
+ export interface SkillEligibilityResult {
5
+ eligible: boolean
6
+ missingBins: string[]
7
+ missingAnyBins: string[][]
8
+ missingEnv: string[]
9
+ unsupportedOs: boolean
10
+ reasons: string[]
11
+ }
12
+
13
+ const binaryCache = new Map<string, boolean>()
14
+
15
+ function hasBinary(name: string): boolean {
16
+ const cached = binaryCache.get(name)
17
+ if (cached !== undefined) return cached
18
+ try {
19
+ execSync(`which ${name}`, { stdio: 'ignore', timeout: 2000 })
20
+ binaryCache.set(name, true)
21
+ return true
22
+ } catch {
23
+ binaryCache.set(name, false)
24
+ return false
25
+ }
26
+ }
27
+
28
+ /** Clear the binary cache (useful for tests or after installs). */
29
+ export function clearBinaryCache(): void {
30
+ binaryCache.clear()
31
+ }
32
+
33
+ export function evaluateSkillEligibility(skill: Skill): SkillEligibilityResult {
34
+ const req = skill.skillRequirements
35
+ if (!req) return { eligible: true, missingBins: [], missingAnyBins: [], missingEnv: [], unsupportedOs: false, reasons: [] }
36
+
37
+ return evaluateRequirements(req)
38
+ }
39
+
40
+ export function evaluateRequirements(req: SkillRequirements): SkillEligibilityResult {
41
+ const reasons: string[] = []
42
+ const missingBins: string[] = []
43
+ const missingAnyBins: string[][] = []
44
+ const missingEnv: string[] = []
45
+ let unsupportedOs = false
46
+
47
+ // Check required binaries
48
+ if (req.bins?.length) {
49
+ for (const bin of req.bins) {
50
+ if (!hasBinary(bin)) {
51
+ missingBins.push(bin)
52
+ }
53
+ }
54
+ if (missingBins.length) {
55
+ reasons.push(`Missing binaries: ${missingBins.join(', ')}`)
56
+ }
57
+ }
58
+
59
+ // Check anyBins groups (at least one from each group must exist)
60
+ if (req.anyBins?.length) {
61
+ for (const group of req.anyBins) {
62
+ if (!group.some(hasBinary)) {
63
+ missingAnyBins.push(group)
64
+ reasons.push(`None of [${group.join(', ')}] found`)
65
+ }
66
+ }
67
+ }
68
+
69
+ // Check required environment variables
70
+ if (req.env?.length) {
71
+ for (const envVar of req.env) {
72
+ if (!process.env[envVar]) {
73
+ missingEnv.push(envVar)
74
+ }
75
+ }
76
+ if (missingEnv.length) {
77
+ reasons.push(`Missing env vars: ${missingEnv.join(', ')}`)
78
+ }
79
+ }
80
+
81
+ // Check OS compatibility
82
+ if (req.os?.length) {
83
+ if (!req.os.includes(process.platform)) {
84
+ unsupportedOs = true
85
+ reasons.push(`OS ${process.platform} not in [${req.os.join(', ')}]`)
86
+ }
87
+ }
88
+
89
+ const eligible = missingBins.length === 0
90
+ && missingAnyBins.length === 0
91
+ && missingEnv.length === 0
92
+ && !unsupportedOs
93
+
94
+ return { eligible, missingBins, missingAnyBins, missingEnv, unsupportedOs, reasons }
95
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { budgetSkillsForPrompt, buildSkillPromptText, MAX_SKILLS_IN_PROMPT, MAX_SKILLS_PROMPT_CHARS } from './skill-prompt-budget'
4
+ import type { Skill } from '@/types'
5
+
6
+ function makeSkill(id: string, overrides: Partial<Skill> = {}): Skill {
7
+ return {
8
+ name: id,
9
+ filename: `${id}.md`,
10
+ content: overrides.content ?? `Instructions for ${id} skill.`,
11
+ createdAt: Date.now(),
12
+ updatedAt: Date.now(),
13
+ ...overrides,
14
+ }
15
+ }
16
+
17
+ describe('budgetSkillsForPrompt', () => {
18
+ it('includes agent-bound skills first', () => {
19
+ const skills: Record<string, Skill> = {
20
+ weather: makeSkill('weather'),
21
+ github: makeSkill('github'),
22
+ coding: makeSkill('coding'),
23
+ }
24
+ const result = budgetSkillsForPrompt(skills, ['weather', 'coding'])
25
+ const included = result.filter((r) => r.included)
26
+ assert.equal(included.length, 3) // weather, coding (agent-bound) + github (eligible)
27
+ assert.equal(included[0].skill.name, 'weather')
28
+ assert.equal(included[1].skill.name, 'coding')
29
+ })
30
+
31
+ it('excludes ineligible skills (missing OS)', () => {
32
+ const skills: Record<string, Skill> = {
33
+ weather: makeSkill('weather', {
34
+ skillRequirements: { os: ['nonexistent_os'] },
35
+ }),
36
+ }
37
+ const result = budgetSkillsForPrompt(skills, ['weather'])
38
+ assert.equal(result[0].eligible, false)
39
+ assert.equal(result[0].included, false)
40
+ })
41
+
42
+ it('respects MAX_SKILLS_IN_PROMPT count limit', () => {
43
+ const skills: Record<string, Skill> = {}
44
+ const ids: string[] = []
45
+ for (let i = 0; i < MAX_SKILLS_IN_PROMPT + 10; i++) {
46
+ const id = `skill-${String(i).padStart(4, '0')}`
47
+ skills[id] = makeSkill(id, { content: 'x' })
48
+ ids.push(id)
49
+ }
50
+ const result = budgetSkillsForPrompt(skills, ids)
51
+ const included = result.filter((r) => r.included)
52
+ assert.equal(included.length, MAX_SKILLS_IN_PROMPT)
53
+ const excluded = result.filter((r) => !r.included && r.reason === 'skill count limit reached')
54
+ assert.equal(excluded.length, 10)
55
+ })
56
+
57
+ it('respects MAX_SKILLS_PROMPT_CHARS budget', () => {
58
+ // Create skills with large content that will exceed the budget
59
+ const bigContent = 'x'.repeat(10_000)
60
+ const skills: Record<string, Skill> = {
61
+ a: makeSkill('a', { content: bigContent }),
62
+ b: makeSkill('b', { content: bigContent }),
63
+ c: makeSkill('c', { content: bigContent }),
64
+ d: makeSkill('d', { content: bigContent }),
65
+ }
66
+ const result = budgetSkillsForPrompt(skills, ['a', 'b', 'c', 'd'])
67
+ const included = result.filter((r) => r.included)
68
+ // 10K * 3 = 30K, so at most 3 can fit
69
+ assert.ok(included.length <= 3)
70
+ const excluded = result.filter((r) => r.reason === 'character budget exceeded')
71
+ assert.ok(excluded.length >= 1)
72
+ })
73
+
74
+ it('prioritizes always-on skills over regular skills', () => {
75
+ const skills: Record<string, Skill> = {
76
+ regular: makeSkill('regular'),
77
+ alwaysOn: makeSkill('alwaysOn', { always: true }),
78
+ }
79
+ const result = budgetSkillsForPrompt(skills, [])
80
+ const included = result.filter((r) => r.included)
81
+ // always-on should come before regular
82
+ const alwaysIdx = included.findIndex((r) => r.skill.name === 'alwaysOn')
83
+ const regularIdx = included.findIndex((r) => r.skill.name === 'regular')
84
+ assert.ok(alwaysIdx < regularIdx, 'always-on skill should be prioritized')
85
+ })
86
+ })
87
+
88
+ describe('buildSkillPromptText', () => {
89
+ it('builds formatted prompt text', () => {
90
+ const skills: Record<string, Skill> = {
91
+ weather: makeSkill('weather', { content: 'Use wttr.in for weather queries.' }),
92
+ }
93
+ const text = buildSkillPromptText(skills, ['weather'])
94
+ assert.ok(text.includes('### weather'))
95
+ assert.ok(text.includes('Use wttr.in for weather queries.'))
96
+ })
97
+
98
+ it('returns empty string for no matching skills', () => {
99
+ const text = buildSkillPromptText({}, ['nonexistent'])
100
+ assert.equal(text, '')
101
+ })
102
+ })
@@ -0,0 +1,125 @@
1
+ import type { Skill } from '@/types'
2
+ import { evaluateSkillEligibility } from './skill-eligibility'
3
+
4
+ /** Maximum number of skills injected into the system prompt. */
5
+ export const MAX_SKILLS_IN_PROMPT = 150
6
+
7
+ /** Maximum total characters of skill content in the system prompt. */
8
+ export const MAX_SKILLS_PROMPT_CHARS = 30_000
9
+
10
+ export interface BudgetedSkill {
11
+ skill: Skill
12
+ eligible: boolean
13
+ included: boolean
14
+ reason?: string
15
+ }
16
+
17
+ /**
18
+ * Filter and budget skills for prompt injection.
19
+ * Priority order:
20
+ * 1. Agent-bound skills (skillIds) — always first
21
+ * 2. `always: true` skills — global skills marked as always-on
22
+ * 3. Other eligible skills — sorted by name
23
+ *
24
+ * Skills are filtered by eligibility (requirements met) and then by budget
25
+ * (count and character limits).
26
+ */
27
+ export function budgetSkillsForPrompt(
28
+ skills: Record<string, Skill>,
29
+ agentSkillIds: string[],
30
+ ): BudgetedSkill[] {
31
+ const results: BudgetedSkill[] = []
32
+ const included: BudgetedSkill[] = []
33
+ let totalChars = 0
34
+
35
+ // Sort skills into priority buckets
36
+ const agentBound: Skill[] = []
37
+ const alwaysOn: Skill[] = []
38
+ const rest: Skill[] = []
39
+
40
+ for (const skillId of agentSkillIds) {
41
+ const skill = skills[skillId]
42
+ if (skill) agentBound.push(skill)
43
+ }
44
+
45
+ for (const skill of Object.values(skills)) {
46
+ if (agentSkillIds.includes(skill.name || '')) continue
47
+ if (skill.always) alwaysOn.push(skill)
48
+ else rest.push(skill)
49
+ }
50
+
51
+ // Sort non-agent skills by name for deterministic ordering
52
+ alwaysOn.sort((a, b) => a.name.localeCompare(b.name))
53
+ rest.sort((a, b) => a.name.localeCompare(b.name))
54
+
55
+ const orderedSkills = [...agentBound, ...alwaysOn, ...rest]
56
+
57
+ for (const skill of orderedSkills) {
58
+ const eligibility = evaluateSkillEligibility(skill)
59
+
60
+ if (!eligibility.eligible) {
61
+ results.push({ skill, eligible: false, included: false, reason: eligibility.reasons[0] })
62
+ continue
63
+ }
64
+
65
+ const contentLen = (skill.content || '').length + (skill.name || '').length + 12 // "## Skill: " prefix
66
+ if (included.length >= MAX_SKILLS_IN_PROMPT) {
67
+ results.push({ skill, eligible: true, included: false, reason: 'skill count limit reached' })
68
+ continue
69
+ }
70
+
71
+ if (totalChars + contentLen > MAX_SKILLS_PROMPT_CHARS) {
72
+ results.push({ skill, eligible: true, included: false, reason: 'character budget exceeded' })
73
+ continue
74
+ }
75
+
76
+ totalChars += contentLen
77
+ const entry: BudgetedSkill = { skill, eligible: true, included: true }
78
+ results.push(entry)
79
+ included.push(entry)
80
+ }
81
+
82
+ return results
83
+ }
84
+
85
+ /**
86
+ * Prescriptive skill adherence header.
87
+ * This tells the model exactly when and how to use skills — the key difference
88
+ * vs OpenClaw's superior skill following (1-2 tool calls vs 3-5).
89
+ */
90
+ const SKILL_ADHERENCE_HEADER = `## Skills
91
+
92
+ Before responding, scan the skills listed below.
93
+ - If exactly one skill clearly applies to the user's request: follow its instructions directly.
94
+ - If multiple skills could apply: choose the most specific one, then follow it.
95
+ - If no skill applies: use your general tools (web search, shell, files, etc.) as normal.
96
+
97
+ When following a skill:
98
+ - Execute the skill's recommended commands or approach first, before falling back to generic tools.
99
+ - Prefer the skill's specific tool/command (e.g. \`curl wttr.in\` for weather) over generic web search.
100
+ - Aim for minimal tool calls — combine steps where possible.
101
+ - Respect rate limits: prefer fewer, larger operations over many small ones.
102
+
103
+ Available skills:`
104
+
105
+ /**
106
+ * Build the prompt text for included skills, respecting budget limits.
107
+ * Returns the text to inject into the system prompt.
108
+ */
109
+ export function buildSkillPromptText(
110
+ skills: Record<string, Skill>,
111
+ agentSkillIds: string[],
112
+ ): string {
113
+ const budgeted = budgetSkillsForPrompt(skills, agentSkillIds)
114
+ const skillParts: string[] = []
115
+
116
+ for (const entry of budgeted) {
117
+ if (!entry.included) continue
118
+ if (!entry.skill.content) continue
119
+ skillParts.push(`### ${entry.skill.name}\n${entry.skill.content}`)
120
+ }
121
+
122
+ if (skillParts.length === 0) return ''
123
+
124
+ return `${SKILL_ADHERENCE_HEADER}\n\n${skillParts.join('\n\n')}`
125
+ }
@@ -0,0 +1,54 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { normalizeSkillPayload } from './skills-normalize'
4
+
5
+ test('normalizeSkillPayload parses openclaw frontmatter metadata', () => {
6
+ const normalized = normalizeSkillPayload({
7
+ content: `---
8
+ name: github-sync
9
+ description: Sync GitHub issues into tasks.
10
+ version: 1.2.3
11
+ metadata:
12
+ openclaw:
13
+ requires:
14
+ env:
15
+ - GITHUB_TOKEN
16
+ bins:
17
+ - curl
18
+ primaryEnv: GITHUB_TOKEN
19
+ homepage: https://example.com/github-sync
20
+ install:
21
+ - kind: brew
22
+ formula: gh
23
+ bins: [gh]
24
+ ---
25
+ # Sync issues
26
+
27
+ Use the GitHub API.`,
28
+ })
29
+
30
+ assert.equal(normalized.name, 'github-sync')
31
+ assert.equal(normalized.description, 'Sync GitHub issues into tasks.')
32
+ assert.equal(normalized.version, '1.2.3')
33
+ assert.equal(normalized.primaryEnv, 'GITHUB_TOKEN')
34
+ assert.equal(normalized.homepage, 'https://example.com/github-sync')
35
+ assert.equal(normalized.sourceFormat, 'openclaw')
36
+ assert.match(normalized.content, /# Sync issues/)
37
+ assert.equal(normalized.skillRequirements?.env?.[0], 'GITHUB_TOKEN')
38
+ assert.equal(normalized.installOptions?.[0]?.kind, 'brew')
39
+ assert.equal(normalized.installOptions?.[0]?.bins?.[0], 'gh')
40
+ })
41
+
42
+ test('normalizeSkillPayload flags undeclared env vars in skill content', () => {
43
+ const normalized = normalizeSkillPayload({
44
+ content: `---
45
+ name: env-check
46
+ description: Reads process env.
47
+ ---
48
+ Run with \`process.env.GITHUB_TOKEN\` and \`process.env.OPENAI_API_KEY\`.`,
49
+ })
50
+
51
+ assert.equal(normalized.security?.level, 'high')
52
+ assert.ok(normalized.security?.missingDeclarations?.includes('GITHUB_TOKEN'))
53
+ assert.ok(normalized.security?.missingDeclarations?.includes('OPENAI_API_KEY'))
54
+ })