@hybridaione/hybridclaw 0.1.5

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 (196) hide show
  1. package/.env.example +14 -0
  2. package/.github/workflows/pages.yml +41 -0
  3. package/AGENTS.md +24 -0
  4. package/CHANGELOG.md +113 -0
  5. package/LICENSE +21 -0
  6. package/README.md +226 -0
  7. package/SECURITY.md +67 -0
  8. package/config.example.json +62 -0
  9. package/container/Dockerfile +23 -0
  10. package/container/package-lock.json +246 -0
  11. package/container/package.json +17 -0
  12. package/container/src/hybridai-client.ts +38 -0
  13. package/container/src/index.ts +198 -0
  14. package/container/src/ipc.ts +37 -0
  15. package/container/src/tools.ts +1008 -0
  16. package/container/src/types.ts +74 -0
  17. package/container/src/web-fetch.ts +389 -0
  18. package/container/tsconfig.json +16 -0
  19. package/dist/agent.d.ts +3 -0
  20. package/dist/agent.d.ts.map +1 -0
  21. package/dist/agent.js +21 -0
  22. package/dist/agent.js.map +1 -0
  23. package/dist/cli.d.ts +3 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +32 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/config.d.ts +38 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +107 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/container-runner.d.ts +11 -0
  32. package/dist/container-runner.d.ts.map +1 -0
  33. package/dist/container-runner.js +246 -0
  34. package/dist/container-runner.js.map +1 -0
  35. package/dist/conversation.d.ts +18 -0
  36. package/dist/conversation.d.ts.map +1 -0
  37. package/dist/conversation.js +29 -0
  38. package/dist/conversation.js.map +1 -0
  39. package/dist/db.d.ts +29 -0
  40. package/dist/db.d.ts.map +1 -0
  41. package/dist/db.js +205 -0
  42. package/dist/db.js.map +1 -0
  43. package/dist/discord.d.ts +17 -0
  44. package/dist/discord.d.ts.map +1 -0
  45. package/dist/discord.js +115 -0
  46. package/dist/discord.js.map +1 -0
  47. package/dist/env.d.ts +6 -0
  48. package/dist/env.d.ts.map +1 -0
  49. package/dist/env.js +36 -0
  50. package/dist/env.js.map +1 -0
  51. package/dist/gateway-client.d.ts +8 -0
  52. package/dist/gateway-client.d.ts.map +1 -0
  53. package/dist/gateway-client.js +57 -0
  54. package/dist/gateway-client.js.map +1 -0
  55. package/dist/gateway-service.d.ts +23 -0
  56. package/dist/gateway-service.d.ts.map +1 -0
  57. package/dist/gateway-service.js +360 -0
  58. package/dist/gateway-service.js.map +1 -0
  59. package/dist/gateway-types.d.ts +40 -0
  60. package/dist/gateway-types.d.ts.map +1 -0
  61. package/dist/gateway-types.js +6 -0
  62. package/dist/gateway-types.js.map +1 -0
  63. package/dist/gateway.d.ts +2 -0
  64. package/dist/gateway.d.ts.map +1 -0
  65. package/dist/gateway.js +138 -0
  66. package/dist/gateway.js.map +1 -0
  67. package/dist/hatch.d.ts +7 -0
  68. package/dist/hatch.d.ts.map +1 -0
  69. package/dist/hatch.js +99 -0
  70. package/dist/hatch.js.map +1 -0
  71. package/dist/health.d.ts +2 -0
  72. package/dist/health.d.ts.map +1 -0
  73. package/dist/health.js +169 -0
  74. package/dist/health.js.map +1 -0
  75. package/dist/heartbeat.d.ts +3 -0
  76. package/dist/heartbeat.d.ts.map +1 -0
  77. package/dist/heartbeat.js +103 -0
  78. package/dist/heartbeat.js.map +1 -0
  79. package/dist/hybridai-bots.d.ts +5 -0
  80. package/dist/hybridai-bots.d.ts.map +1 -0
  81. package/dist/hybridai-bots.js +34 -0
  82. package/dist/hybridai-bots.js.map +1 -0
  83. package/dist/index.d.ts +2 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +60 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/ipc.d.ts +33 -0
  88. package/dist/ipc.d.ts.map +1 -0
  89. package/dist/ipc.js +142 -0
  90. package/dist/ipc.js.map +1 -0
  91. package/dist/logger.d.ts +3 -0
  92. package/dist/logger.d.ts.map +1 -0
  93. package/dist/logger.js +21 -0
  94. package/dist/logger.js.map +1 -0
  95. package/dist/mount-security.d.ts +28 -0
  96. package/dist/mount-security.d.ts.map +1 -0
  97. package/dist/mount-security.js +187 -0
  98. package/dist/mount-security.js.map +1 -0
  99. package/dist/onboarding.d.ts +7 -0
  100. package/dist/onboarding.d.ts.map +1 -0
  101. package/dist/onboarding.js +445 -0
  102. package/dist/onboarding.js.map +1 -0
  103. package/dist/prompt-hooks.d.ts +17 -0
  104. package/dist/prompt-hooks.d.ts.map +1 -0
  105. package/dist/prompt-hooks.js +83 -0
  106. package/dist/prompt-hooks.js.map +1 -0
  107. package/dist/runtime-config.d.ts +78 -0
  108. package/dist/runtime-config.d.ts.map +1 -0
  109. package/dist/runtime-config.js +471 -0
  110. package/dist/runtime-config.js.map +1 -0
  111. package/dist/scheduled-task-runner.d.ts +11 -0
  112. package/dist/scheduled-task-runner.d.ts.map +1 -0
  113. package/dist/scheduled-task-runner.js +16 -0
  114. package/dist/scheduled-task-runner.js.map +1 -0
  115. package/dist/scheduler.d.ts +11 -0
  116. package/dist/scheduler.d.ts.map +1 -0
  117. package/dist/scheduler.js +165 -0
  118. package/dist/scheduler.js.map +1 -0
  119. package/dist/session-maintenance.d.ts +9 -0
  120. package/dist/session-maintenance.d.ts.map +1 -0
  121. package/dist/session-maintenance.js +168 -0
  122. package/dist/session-maintenance.js.map +1 -0
  123. package/dist/session-transcripts.d.ts +11 -0
  124. package/dist/session-transcripts.d.ts.map +1 -0
  125. package/dist/session-transcripts.js +32 -0
  126. package/dist/session-transcripts.js.map +1 -0
  127. package/dist/side-effects.d.ts +3 -0
  128. package/dist/side-effects.d.ts.map +1 -0
  129. package/dist/side-effects.js +30 -0
  130. package/dist/side-effects.js.map +1 -0
  131. package/dist/skills.d.ts +32 -0
  132. package/dist/skills.d.ts.map +1 -0
  133. package/dist/skills.js +376 -0
  134. package/dist/skills.js.map +1 -0
  135. package/dist/tui.d.ts +2 -0
  136. package/dist/tui.d.ts.map +1 -0
  137. package/dist/tui.js +305 -0
  138. package/dist/tui.js.map +1 -0
  139. package/dist/types.d.ts +132 -0
  140. package/dist/types.d.ts.map +1 -0
  141. package/dist/types.js +3 -0
  142. package/dist/types.js.map +1 -0
  143. package/dist/workspace.d.ts +25 -0
  144. package/dist/workspace.d.ts.map +1 -0
  145. package/dist/workspace.js +154 -0
  146. package/dist/workspace.js.map +1 -0
  147. package/docs/chat.html +929 -0
  148. package/docs/hai_logo_free.png +0 -0
  149. package/docs/hero.png +0 -0
  150. package/docs/index.html +1213 -0
  151. package/package.json +34 -0
  152. package/skills/current-time/SKILL.md +26 -0
  153. package/skills/iss-position/SKILL.md +46 -0
  154. package/skills/iss-position/agents/openai.yaml +3 -0
  155. package/skills/iss-position/scripts/get_iss_position.py +107 -0
  156. package/skills/repo-orientation/SKILL.md +74 -0
  157. package/src/agent.ts +35 -0
  158. package/src/cli.ts +35 -0
  159. package/src/config.ts +137 -0
  160. package/src/container-runner.ts +305 -0
  161. package/src/conversation.ts +49 -0
  162. package/src/db.ts +290 -0
  163. package/src/discord.ts +156 -0
  164. package/src/env.ts +36 -0
  165. package/src/gateway-client.ts +73 -0
  166. package/src/gateway-service.ts +456 -0
  167. package/src/gateway-types.ts +47 -0
  168. package/src/gateway.ts +199 -0
  169. package/src/health.ts +189 -0
  170. package/src/heartbeat.ts +121 -0
  171. package/src/hybridai-bots.ts +48 -0
  172. package/src/ipc.ts +163 -0
  173. package/src/logger.ts +26 -0
  174. package/src/mount-security.ts +216 -0
  175. package/src/onboarding.ts +569 -0
  176. package/src/prompt-hooks.ts +113 -0
  177. package/src/runtime-config.ts +588 -0
  178. package/src/scheduled-task-runner.ts +26 -0
  179. package/src/scheduler.ts +196 -0
  180. package/src/session-maintenance.ts +263 -0
  181. package/src/session-transcripts.ts +45 -0
  182. package/src/side-effects.ts +41 -0
  183. package/src/skills.ts +429 -0
  184. package/src/tui.ts +327 -0
  185. package/src/types.ts +135 -0
  186. package/src/workspace.ts +171 -0
  187. package/templates/AGENTS.md +104 -0
  188. package/templates/BOOT.md +3 -0
  189. package/templates/BOOTSTRAP.md +54 -0
  190. package/templates/HEARTBEAT.md +5 -0
  191. package/templates/IDENTITY.md +18 -0
  192. package/templates/MEMORY.md +19 -0
  193. package/templates/SOUL.md +36 -0
  194. package/templates/TOOLS.md +22 -0
  195. package/templates/USER.md +17 -0
  196. package/tsconfig.json +18 -0
package/src/skills.ts ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Skills — CLAUDE/OpenClaw-compatible SKILL.md discovery.
3
+ * The system prompt only includes skill metadata + location; the model reads
4
+ * SKILL.md on demand with the `read` tool.
5
+ */
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import { createHash } from 'crypto';
10
+
11
+ import { agentWorkspaceDir } from './ipc.js';
12
+ import { logger } from './logger.js';
13
+
14
+ type SkillSource = 'workspace' | 'project' | 'codex' | 'claude';
15
+
16
+ interface SkillCandidate {
17
+ name: string;
18
+ description: string;
19
+ userInvocable: boolean;
20
+ disableModelInvocation: boolean;
21
+ filePath: string;
22
+ baseDir: string;
23
+ source: SkillSource;
24
+ }
25
+
26
+ export interface Skill {
27
+ name: string;
28
+ description: string;
29
+ userInvocable: boolean;
30
+ disableModelInvocation: boolean;
31
+ filePath: string;
32
+ baseDir: string;
33
+ source: SkillSource;
34
+ location: string;
35
+ }
36
+
37
+ const PROJECT_SKILLS_DIR = path.join(process.cwd(), 'skills');
38
+ const SYNCED_SKILLS_DIR = '.synced-skills';
39
+ const MAX_SKILLS_IN_PROMPT = 150;
40
+ const MAX_SKILLS_PROMPT_CHARS = 30_000;
41
+ const MAX_INVOKED_SKILL_CHARS = 35_000;
42
+
43
+ function normalizeLineEndings(raw: string): string {
44
+ return raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
45
+ }
46
+
47
+ function stripQuotes(value: string): string {
48
+ if (
49
+ (value.startsWith('"') && value.endsWith('"')) ||
50
+ (value.startsWith("'") && value.endsWith("'"))
51
+ ) {
52
+ return value.slice(1, -1);
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function parseFrontmatter(raw: string): { meta: Record<string, string>; body: string } {
58
+ const normalized = normalizeLineEndings(raw);
59
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n?/);
60
+ if (!match) {
61
+ return { meta: {}, body: normalized.trim() };
62
+ }
63
+
64
+ const block = match[1] || '';
65
+ const body = normalized.slice(match[0].length).trim();
66
+ const meta: Record<string, string> = {};
67
+
68
+ for (const line of block.split('\n')) {
69
+ const m = line.match(/^([\w-]+)\s*:\s*(.*)$/);
70
+ if (!m) continue;
71
+ const key = m[1].trim();
72
+ const value = stripQuotes((m[2] || '').trim());
73
+ if (!key || !value) continue;
74
+ meta[key] = value;
75
+ }
76
+
77
+ return { meta, body };
78
+ }
79
+
80
+ function parseBool(raw: string | undefined, fallback: boolean): boolean {
81
+ if (!raw) return fallback;
82
+ const normalized = raw.trim().toLowerCase();
83
+ if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true;
84
+ if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false;
85
+ return fallback;
86
+ }
87
+
88
+ function escapeXml(text: string): string {
89
+ return text
90
+ .replace(/&/g, '&amp;')
91
+ .replace(/</g, '&lt;')
92
+ .replace(/>/g, '&gt;')
93
+ .replace(/"/g, '&quot;')
94
+ .replace(/'/g, '&apos;');
95
+ }
96
+
97
+ function toPosixPath(p: string): string {
98
+ return p.split(path.sep).join('/');
99
+ }
100
+
101
+ function pathWithin(root: string, target: string): boolean {
102
+ const rel = path.relative(root, target);
103
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
104
+ }
105
+
106
+ function asContainerPath(workspaceDir: string, absolutePath: string): string | null {
107
+ if (!pathWithin(workspaceDir, absolutePath)) return null;
108
+ const rel = toPosixPath(path.relative(workspaceDir, absolutePath));
109
+ return rel ? `/workspace/${rel}` : '/workspace';
110
+ }
111
+
112
+ function resolveManagedSkillsDirs(): Array<{ source: SkillSource; dir: string }> {
113
+ const home = os.homedir();
114
+ const dirs: Array<{ source: SkillSource; dir: string }> = [
115
+ { source: 'codex', dir: path.join(home, '.codex', 'skills') },
116
+ { source: 'claude', dir: path.join(home, '.claude', 'skills') },
117
+ ];
118
+
119
+ const codexHome = process.env.CODEX_HOME?.trim();
120
+ if (codexHome) {
121
+ dirs.unshift({ source: 'codex', dir: path.join(codexHome, 'skills') });
122
+ }
123
+
124
+ const seen = new Set<string>();
125
+ return dirs.filter(({ dir }) => {
126
+ const resolved = path.resolve(dir);
127
+ if (seen.has(resolved)) return false;
128
+ seen.add(resolved);
129
+ return true;
130
+ });
131
+ }
132
+
133
+ function scanSkillsDir(dir: string, source: SkillSource): SkillCandidate[] {
134
+ if (!fs.existsSync(dir)) return [];
135
+
136
+ const skills: SkillCandidate[] = [];
137
+
138
+ try {
139
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
140
+ if (!entry.isDirectory()) continue;
141
+
142
+ const baseDir = path.join(dir, entry.name);
143
+ const skillFile = path.join(baseDir, 'SKILL.md');
144
+ if (!fs.existsSync(skillFile)) continue;
145
+
146
+ try {
147
+ const raw = fs.readFileSync(skillFile, 'utf-8');
148
+ const { meta } = parseFrontmatter(raw);
149
+ const name = (meta.name || entry.name).trim();
150
+ if (!name) continue;
151
+
152
+ skills.push({
153
+ name,
154
+ description: (meta.description || '').trim(),
155
+ userInvocable: parseBool(meta['user-invocable'], true),
156
+ disableModelInvocation: parseBool(meta['disable-model-invocation'], false),
157
+ filePath: skillFile,
158
+ baseDir,
159
+ source,
160
+ });
161
+ } catch (err) {
162
+ logger.warn({ path: skillFile, err }, 'Failed to parse skill');
163
+ }
164
+ }
165
+ } catch (err) {
166
+ logger.warn({ dir, err }, 'Failed to scan skills directory');
167
+ }
168
+
169
+ return skills;
170
+ }
171
+
172
+ function sanitizeSkillDirName(name: string): string {
173
+ const normalized = name
174
+ .trim()
175
+ .toLowerCase()
176
+ .replace(/[^a-z0-9_-]+/g, '-')
177
+ .replace(/-+/g, '-')
178
+ .replace(/^-+|-+$/g, '');
179
+ return normalized || 'skill';
180
+ }
181
+
182
+ function stableSkillDirName(name: string): string {
183
+ const base = sanitizeSkillDirName(name);
184
+ const hash = createHash('sha1').update(name).digest('hex').slice(0, 8);
185
+ return `${base}-${hash}`;
186
+ }
187
+
188
+ function resolveSyncedSkillTarget(
189
+ skill: SkillCandidate,
190
+ workspaceDir: string,
191
+ ): { rootDir: string; targetDir: string; targetSkillFile: string } {
192
+ // Keep project skills under /workspace/skills so script paths like
193
+ // "skills/<skill>/scripts/..." remain valid inside the agent container.
194
+ if (skill.source === 'project') {
195
+ const projectRoot = path.resolve(PROJECT_SKILLS_DIR);
196
+ const skillBaseDir = path.resolve(skill.baseDir);
197
+ if (pathWithin(projectRoot, skillBaseDir)) {
198
+ const rel = path.relative(projectRoot, skillBaseDir);
199
+ const rootDir = path.join(workspaceDir, 'skills');
200
+ const targetDir = path.join(rootDir, rel);
201
+ return {
202
+ rootDir,
203
+ targetDir,
204
+ targetSkillFile: path.join(targetDir, 'SKILL.md'),
205
+ };
206
+ }
207
+ }
208
+
209
+ const rootDir = path.join(workspaceDir, SYNCED_SKILLS_DIR);
210
+ const dirName = stableSkillDirName(skill.name);
211
+ const targetDir = path.join(rootDir, dirName);
212
+ return {
213
+ rootDir,
214
+ targetDir,
215
+ targetSkillFile: path.join(targetDir, 'SKILL.md'),
216
+ };
217
+ }
218
+
219
+ function syncSkillIntoWorkspace(skill: SkillCandidate, workspaceDir: string): string {
220
+ const { rootDir, targetDir, targetSkillFile } = resolveSyncedSkillTarget(skill, workspaceDir);
221
+ fs.mkdirSync(rootDir, { recursive: true });
222
+
223
+ if (!pathWithin(rootDir, targetDir)) {
224
+ throw new Error(`Unsafe synced skill path: ${targetDir}`);
225
+ }
226
+
227
+ let shouldSync = true;
228
+ try {
229
+ if (fs.existsSync(targetSkillFile)) {
230
+ const srcStat = fs.statSync(skill.filePath);
231
+ const dstStat = fs.statSync(targetSkillFile);
232
+ shouldSync = dstStat.mtimeMs < srcStat.mtimeMs;
233
+ }
234
+ } catch {
235
+ shouldSync = true;
236
+ }
237
+
238
+ if (shouldSync) {
239
+ fs.rmSync(targetDir, { recursive: true, force: true });
240
+ fs.cpSync(skill.baseDir, targetDir, { recursive: true, force: true });
241
+ }
242
+
243
+ return targetSkillFile;
244
+ }
245
+
246
+ function normalizeSkillLookup(value: string): string {
247
+ return value
248
+ .trim()
249
+ .toLowerCase()
250
+ .replace(/[\s_]+/g, '-');
251
+ }
252
+
253
+ function findInvocableSkill(skills: Skill[], rawName: string): Skill | null {
254
+ const target = rawName.trim().toLowerCase();
255
+ if (!target) return null;
256
+ const normalizedTarget = normalizeSkillLookup(rawName);
257
+ return skills.find((skill) => {
258
+ if (!skill.userInvocable) return false;
259
+ const name = skill.name.toLowerCase();
260
+ if (name === target) return true;
261
+ return normalizeSkillLookup(skill.name) === normalizedTarget;
262
+ }) || null;
263
+ }
264
+
265
+ function parseSkillInvocation(content: string, skills: Skill[]): { skill: Skill; args: string } | null {
266
+ const trimmed = content.trim();
267
+ if (!trimmed.startsWith('/')) return null;
268
+
269
+ const commandMatch = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
270
+ if (!commandMatch) return null;
271
+
272
+ const commandName = (commandMatch[1] || '').trim();
273
+ const remainder = (commandMatch[2] || '').trim();
274
+ if (!commandName) return null;
275
+
276
+ const lowerCommand = commandName.toLowerCase();
277
+ if (lowerCommand === 'skill') {
278
+ if (!remainder) return null;
279
+ const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/);
280
+ if (!skillMatch) return null;
281
+ const skill = findInvocableSkill(skills, skillMatch[1] || '');
282
+ if (!skill) return null;
283
+ return { skill, args: (skillMatch[2] || '').trim() };
284
+ }
285
+
286
+ if (lowerCommand.startsWith('skill:')) {
287
+ const skillName = lowerCommand.slice('skill:'.length).trim();
288
+ if (!skillName) return null;
289
+ const skill = findInvocableSkill(skills, skillName);
290
+ if (!skill) return null;
291
+ return { skill, args: remainder };
292
+ }
293
+
294
+ const directSkill = findInvocableSkill(skills, commandName);
295
+ if (!directSkill) return null;
296
+ return { skill: directSkill, args: remainder };
297
+ }
298
+
299
+ function loadSkillBody(skill: Skill): string {
300
+ try {
301
+ const raw = fs.readFileSync(skill.filePath, 'utf-8');
302
+ const { body } = parseFrontmatter(raw);
303
+ if (body.length <= MAX_INVOKED_SKILL_CHARS) return body;
304
+ return `${body.slice(0, MAX_INVOKED_SKILL_CHARS)}\n\n[truncated]`;
305
+ } catch (err) {
306
+ logger.warn({ skill: skill.name, path: skill.filePath, err }, 'Failed to load SKILL.md body');
307
+ return '';
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Expand explicit skill command invocations into a deterministic user payload.
313
+ * Supports:
314
+ * - /skill <name> [input]
315
+ * - /skill:<name> [input]
316
+ * - /<name> [input] (user-invocable skills)
317
+ */
318
+ export function expandSkillInvocation(content: string, skills: Skill[]): string {
319
+ const invocation = parseSkillInvocation(content, skills);
320
+ if (!invocation) return content;
321
+
322
+ const body = loadSkillBody(invocation.skill);
323
+ const args = invocation.args || '(none)';
324
+
325
+ const lines = [
326
+ `[Explicit skill invocation] Use the "${invocation.skill.name}" skill for this request.`,
327
+ `Skill file: ${invocation.skill.location}`,
328
+ `Skill input: ${args}`,
329
+ ];
330
+
331
+ if (body) {
332
+ lines.push(
333
+ '',
334
+ '<skill_instructions>',
335
+ body,
336
+ '</skill_instructions>',
337
+ );
338
+ } else {
339
+ lines.push('Read the skill file with the `read` tool and follow it.');
340
+ }
341
+
342
+ return lines.join('\n');
343
+ }
344
+
345
+ /**
346
+ * Load all skills with precedence:
347
+ * codex/claude managed < project skills < agent workspace skills.
348
+ * Any non-workspace skill selected by precedence is mirrored into workspace so
349
+ * the container can read it via /workspace/... paths.
350
+ */
351
+ export function loadSkills(agentId: string): Skill[] {
352
+ const workspaceDir = path.resolve(agentWorkspaceDir(agentId));
353
+ fs.mkdirSync(workspaceDir, { recursive: true });
354
+
355
+ const workspaceSkills = scanSkillsDir(path.join(workspaceDir, 'skills'), 'workspace');
356
+ const projectSkills = scanSkillsDir(PROJECT_SKILLS_DIR, 'project');
357
+ const managedSkills = resolveManagedSkillsDirs()
358
+ .flatMap(({ source, dir }) => scanSkillsDir(dir, source));
359
+
360
+ const byName = new Map<string, SkillCandidate>();
361
+
362
+ // Lowest to highest precedence.
363
+ for (const skill of managedSkills) byName.set(skill.name, skill);
364
+ for (const skill of projectSkills) byName.set(skill.name, skill);
365
+ for (const skill of workspaceSkills) byName.set(skill.name, skill);
366
+
367
+ const resolved: Skill[] = [];
368
+ for (const skill of byName.values()) {
369
+ try {
370
+ let containerSkillPath = asContainerPath(workspaceDir, path.resolve(skill.filePath));
371
+ if (!containerSkillPath) {
372
+ const syncedSkillFile = syncSkillIntoWorkspace(skill, workspaceDir);
373
+ containerSkillPath = asContainerPath(workspaceDir, path.resolve(syncedSkillFile));
374
+ }
375
+ if (!containerSkillPath) {
376
+ logger.warn({ skill: skill.name, path: skill.filePath }, 'Could not resolve container-readable skill path');
377
+ continue;
378
+ }
379
+
380
+ resolved.push({
381
+ ...skill,
382
+ location: containerSkillPath,
383
+ });
384
+ } catch (err) {
385
+ logger.warn({ skill: skill.name, err }, 'Failed to resolve skill location');
386
+ }
387
+ }
388
+
389
+ return resolved.sort((a, b) => a.name.localeCompare(b.name));
390
+ }
391
+
392
+ /**
393
+ * Build compact CLAUDE/OpenClaw-style skill prompt metadata.
394
+ */
395
+ export function buildSkillsPrompt(skills: Skill[]): string {
396
+ const promptCandidates = skills
397
+ .filter((skill) => !skill.disableModelInvocation)
398
+ .slice(0, MAX_SKILLS_IN_PROMPT);
399
+ if (promptCandidates.length === 0) return '';
400
+
401
+ const lines: string[] = [
402
+ '## Skills (mandatory)',
403
+ 'Before replying: scan <available_skills> <description> entries.',
404
+ '- If exactly one skill clearly applies: read its SKILL.md at <location> with `read`, then follow it.',
405
+ '- If multiple could apply: choose the most specific one, then read/follow it.',
406
+ '- If none clearly apply: do not read any SKILL.md.',
407
+ 'Constraints: never read more than one skill up front; only read after selecting.',
408
+ '',
409
+ '<available_skills>',
410
+ ];
411
+
412
+ let chars = 0;
413
+ for (const skill of promptCandidates) {
414
+ const block = [
415
+ ' <skill>',
416
+ ` <name>${escapeXml(skill.name)}</name>`,
417
+ ` <description>${escapeXml(skill.description || skill.name)}</description>`,
418
+ ` <location>${escapeXml(skill.location)}</location>`,
419
+ ' </skill>',
420
+ ];
421
+ const serialized = block.join('\n');
422
+ if (chars + serialized.length > MAX_SKILLS_PROMPT_CHARS) break;
423
+ lines.push(...block);
424
+ chars += serialized.length;
425
+ }
426
+
427
+ lines.push('</available_skills>');
428
+ return lines.join('\n');
429
+ }