@hybridaione/hybridclaw 0.1.21 → 0.1.22

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.
package/dist/skills.js CHANGED
@@ -1,19 +1,47 @@
1
1
  /**
2
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.
3
+ * The system prompt includes skill metadata + location, and inlines full
4
+ * bodies for skills marked `always: true`.
5
5
  */
6
6
  import fs from 'fs';
7
7
  import os from 'os';
8
8
  import path from 'path';
9
9
  import { createHash } from 'crypto';
10
+ import { fileURLToPath } from 'url';
10
11
  import { agentWorkspaceDir } from './ipc.js';
11
12
  import { logger } from './logger.js';
12
- const PROJECT_SKILLS_DIR = path.join(process.cwd(), 'skills');
13
+ import { getRuntimeConfig } from './runtime-config.js';
14
+ import { guardSkillDirectory } from './skills-guard.js';
15
+ const WORKSPACE_SKILLS_DIR = path.join(process.cwd(), 'skills');
16
+ const PROJECT_AGENTS_SKILLS_DIR = path.join(process.cwd(), '.agents', 'skills');
13
17
  const SYNCED_SKILLS_DIR = '.synced-skills';
14
18
  const MAX_SKILLS_IN_PROMPT = 150;
15
19
  const MAX_SKILLS_PROMPT_CHARS = 30_000;
16
20
  const MAX_INVOKED_SKILL_CHARS = 35_000;
21
+ const MAX_ALWAYS_CHARS = 10_000;
22
+ const MAX_SKILL_COMMAND_NAME_LENGTH = 32;
23
+ const RESERVED_SKILL_COMMAND_NAMES = new Set([
24
+ 'help',
25
+ 'clear',
26
+ 'compact',
27
+ 'new',
28
+ 'status',
29
+ 'bot',
30
+ 'bots',
31
+ 'rag',
32
+ 'info',
33
+ 'stop',
34
+ 'abort',
35
+ 'exit',
36
+ 'quit',
37
+ 'q',
38
+ 'model',
39
+ 'sessions',
40
+ 'audit',
41
+ 'schedule',
42
+ 'skill',
43
+ ]);
44
+ const warnedBlockedSkills = new Set();
17
45
  function normalizeLineEndings(raw) {
18
46
  return raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
19
47
  }
@@ -28,7 +56,7 @@ function parseFrontmatter(raw) {
28
56
  const normalized = normalizeLineEndings(raw);
29
57
  const match = normalized.match(/^---\n([\s\S]*?)\n---\n?/);
30
58
  if (!match) {
31
- return { meta: {}, body: normalized.trim() };
59
+ return { meta: {}, body: normalized.trim(), block: '' };
32
60
  }
33
61
  const block = match[1] || '';
34
62
  const body = normalized.slice(match[0].length).trim();
@@ -43,7 +71,7 @@ function parseFrontmatter(raw) {
43
71
  continue;
44
72
  meta[key] = value;
45
73
  }
46
- return { meta, body };
74
+ return { meta, body, block };
47
75
  }
48
76
  function parseBool(raw, fallback) {
49
77
  if (!raw)
@@ -66,6 +94,257 @@ function escapeXml(text) {
66
94
  function toPosixPath(p) {
67
95
  return p.split(path.sep).join('/');
68
96
  }
97
+ function isRecord(value) {
98
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
99
+ }
100
+ function leadingWhitespaceCount(line) {
101
+ let count = 0;
102
+ while (count < line.length) {
103
+ const ch = line[count];
104
+ if (ch !== ' ' && ch !== '\t')
105
+ break;
106
+ count += 1;
107
+ }
108
+ return count;
109
+ }
110
+ function parseInlineStringList(raw) {
111
+ const trimmed = stripQuotes(raw.trim());
112
+ if (!trimmed)
113
+ return [];
114
+ if (trimmed === '[]')
115
+ return [];
116
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
117
+ const inner = trimmed.slice(1, -1).trim();
118
+ if (!inner)
119
+ return [];
120
+ return inner
121
+ .split(',')
122
+ .map((item) => stripQuotes(item.trim()))
123
+ .filter(Boolean);
124
+ }
125
+ return [trimmed];
126
+ }
127
+ function normalizeStringList(raw) {
128
+ if (Array.isArray(raw)) {
129
+ return raw
130
+ .map((item) => (typeof item === 'string' ? item.trim() : String(item ?? '').trim()))
131
+ .filter(Boolean);
132
+ }
133
+ if (typeof raw === 'string') {
134
+ const inline = parseInlineStringList(raw);
135
+ if (inline.length > 0)
136
+ return inline;
137
+ return raw
138
+ .split(',')
139
+ .map((item) => item.trim())
140
+ .filter(Boolean);
141
+ }
142
+ return [];
143
+ }
144
+ function tryParseJsonObject(raw) {
145
+ const trimmed = stripQuotes(raw.trim());
146
+ if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('[')))
147
+ return null;
148
+ try {
149
+ const parsed = JSON.parse(trimmed);
150
+ if (isRecord(parsed))
151
+ return parsed;
152
+ }
153
+ catch {
154
+ // ignore invalid JSON-ish values
155
+ }
156
+ return null;
157
+ }
158
+ function extractTopLevelSection(block, key) {
159
+ const lines = block.split('\n');
160
+ for (let i = 0; i < lines.length; i += 1) {
161
+ const line = lines[i] || '';
162
+ const match = line.match(/^([ \t]*)([\w-]+):\s*(.*)$/);
163
+ if (!match)
164
+ continue;
165
+ const indent = (match[1] || '').length;
166
+ const candidate = (match[2] || '').trim();
167
+ if (indent !== 0 || candidate !== key)
168
+ continue;
169
+ const inline = (match[3] || '').trim();
170
+ const children = [];
171
+ let j = i + 1;
172
+ while (j < lines.length) {
173
+ const next = lines[j] || '';
174
+ const trimmed = next.trim();
175
+ if (!trimmed) {
176
+ children.push(next);
177
+ j += 1;
178
+ continue;
179
+ }
180
+ const nextIndent = leadingWhitespaceCount(next);
181
+ if (nextIndent <= indent)
182
+ break;
183
+ children.push(next);
184
+ j += 1;
185
+ }
186
+ return { inline, children };
187
+ }
188
+ return null;
189
+ }
190
+ function parseSectionChildren(children) {
191
+ const parsed = new Map();
192
+ for (let i = 0; i < children.length;) {
193
+ const line = children[i] || '';
194
+ const trimmed = line.trim();
195
+ if (!trimmed) {
196
+ i += 1;
197
+ continue;
198
+ }
199
+ const match = trimmed.match(/^([\w-]+):\s*(.*)$/);
200
+ if (!match) {
201
+ i += 1;
202
+ continue;
203
+ }
204
+ const key = (match[1] || '').trim();
205
+ const inline = (match[2] || '').trim();
206
+ const indent = leadingWhitespaceCount(line);
207
+ const nested = [];
208
+ i += 1;
209
+ while (i < children.length) {
210
+ const next = children[i] || '';
211
+ const nextTrimmed = next.trim();
212
+ if (!nextTrimmed) {
213
+ nested.push(next);
214
+ i += 1;
215
+ continue;
216
+ }
217
+ const nextIndent = leadingWhitespaceCount(next);
218
+ if (nextIndent <= indent)
219
+ break;
220
+ nested.push(next);
221
+ i += 1;
222
+ }
223
+ if (key)
224
+ parsed.set(key, { inline, children: nested });
225
+ }
226
+ return parsed;
227
+ }
228
+ function parseSectionStringList(section) {
229
+ if (!section)
230
+ return [];
231
+ const inline = parseInlineStringList(section.inline);
232
+ if (inline.length > 0 || section.inline.trim() === '[]')
233
+ return inline;
234
+ const values = [];
235
+ for (const line of section.children) {
236
+ const trimmed = line.trim();
237
+ const match = trimmed.match(/^-\s*(.+)$/);
238
+ if (!match)
239
+ continue;
240
+ const value = stripQuotes((match[1] || '').trim());
241
+ if (value)
242
+ values.push(value);
243
+ }
244
+ return values;
245
+ }
246
+ function parseRequiresFromFrontmatter(frontmatter) {
247
+ const fromInlineJson = frontmatter.meta.requires ? tryParseJsonObject(frontmatter.meta.requires) : null;
248
+ if (fromInlineJson) {
249
+ return {
250
+ bins: normalizeStringList(fromInlineJson.bins),
251
+ env: normalizeStringList(fromInlineJson.env),
252
+ };
253
+ }
254
+ const section = extractTopLevelSection(frontmatter.block, 'requires');
255
+ if (!section)
256
+ return { bins: [], env: [] };
257
+ const inlineJson = tryParseJsonObject(section.inline);
258
+ if (inlineJson) {
259
+ return {
260
+ bins: normalizeStringList(inlineJson.bins),
261
+ env: normalizeStringList(inlineJson.env),
262
+ };
263
+ }
264
+ const fields = parseSectionChildren(section.children);
265
+ return {
266
+ bins: parseSectionStringList(fields.get('bins')),
267
+ env: parseSectionStringList(fields.get('env')),
268
+ };
269
+ }
270
+ function parseHybridClawMetadata(frontmatter) {
271
+ const normalizeMetadata = (raw) => {
272
+ const hybridRaw = isRecord(raw.hybridclaw) ? raw.hybridclaw : raw;
273
+ return {
274
+ tags: normalizeStringList(hybridRaw.tags),
275
+ relatedSkills: normalizeStringList(hybridRaw.related_skills ?? hybridRaw.relatedSkills),
276
+ };
277
+ };
278
+ const fromInlineJson = frontmatter.meta.metadata ? tryParseJsonObject(frontmatter.meta.metadata) : null;
279
+ if (fromInlineJson)
280
+ return normalizeMetadata(fromInlineJson);
281
+ const metadataSection = extractTopLevelSection(frontmatter.block, 'metadata');
282
+ if (!metadataSection)
283
+ return { tags: [], relatedSkills: [] };
284
+ const metadataInlineJson = tryParseJsonObject(metadataSection.inline);
285
+ if (metadataInlineJson)
286
+ return normalizeMetadata(metadataInlineJson);
287
+ const metadataFields = parseSectionChildren(metadataSection.children);
288
+ const hybridSection = metadataFields.get('hybridclaw');
289
+ if (!hybridSection)
290
+ return { tags: [], relatedSkills: [] };
291
+ const hybridInlineJson = tryParseJsonObject(hybridSection.inline);
292
+ if (hybridInlineJson)
293
+ return normalizeMetadata(hybridInlineJson);
294
+ const hybridFields = parseSectionChildren(hybridSection.children);
295
+ return {
296
+ tags: parseSectionStringList(hybridFields.get('tags')),
297
+ relatedSkills: parseSectionStringList(hybridFields.get('related_skills')),
298
+ };
299
+ }
300
+ let cachedPathEnv = '';
301
+ let cachedPathExt = '';
302
+ const hasBinaryCache = new Map();
303
+ function hasBinary(binName) {
304
+ const bin = binName.trim();
305
+ if (!bin)
306
+ return false;
307
+ const currentPath = process.env.PATH || '';
308
+ const currentPathExt = process.platform === 'win32' ? (process.env.PATHEXT || '') : '';
309
+ if (cachedPathEnv !== currentPath || cachedPathExt !== currentPathExt) {
310
+ cachedPathEnv = currentPath;
311
+ cachedPathExt = currentPathExt;
312
+ hasBinaryCache.clear();
313
+ }
314
+ const cached = hasBinaryCache.get(bin);
315
+ if (cached != null)
316
+ return cached;
317
+ const exts = process.platform === 'win32'
318
+ ? ['', ...currentPathExt.split(';').map((ext) => ext.trim()).filter(Boolean)]
319
+ : [''];
320
+ for (const part of currentPath.split(path.delimiter).filter(Boolean)) {
321
+ for (const ext of exts) {
322
+ const candidate = path.join(part, `${bin}${ext}`);
323
+ try {
324
+ fs.accessSync(candidate, fs.constants.X_OK);
325
+ hasBinaryCache.set(bin, true);
326
+ return true;
327
+ }
328
+ catch {
329
+ // continue scanning
330
+ }
331
+ }
332
+ }
333
+ hasBinaryCache.set(bin, false);
334
+ return false;
335
+ }
336
+ function checkEligibility(skill) {
337
+ const missing = [];
338
+ for (const bin of skill.requires?.bins ?? []) {
339
+ if (!hasBinary(bin))
340
+ missing.push(`bin:${bin}`);
341
+ }
342
+ for (const envVar of skill.requires?.env ?? []) {
343
+ if (!process.env[envVar])
344
+ missing.push(`env:${envVar}`);
345
+ }
346
+ return { available: missing.length === 0, missing };
347
+ }
69
348
  function pathWithin(root, target) {
70
349
  const rel = path.relative(root, target);
71
350
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
@@ -76,18 +355,31 @@ function asContainerPath(workspaceDir, absolutePath) {
76
355
  const rel = toPosixPath(path.relative(workspaceDir, absolutePath));
77
356
  return rel ? `/workspace/${rel}` : '/workspace';
78
357
  }
79
- function resolveManagedSkillsDirs() {
358
+ function resolveUserPath(raw) {
359
+ const value = raw.trim();
360
+ if (!value)
361
+ return '';
362
+ if (value === '~')
363
+ return os.homedir();
364
+ if (value.startsWith('~/') || value.startsWith('~\\')) {
365
+ return path.join(os.homedir(), value.slice(2));
366
+ }
367
+ return path.resolve(value);
368
+ }
369
+ function resolveBundledSkillsDir() {
370
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
371
+ const bundledDir = path.resolve(moduleDir, '..', 'skills');
372
+ return fs.existsSync(bundledDir) ? bundledDir : null;
373
+ }
374
+ function resolveCodexSkillsDirs() {
80
375
  const home = os.homedir();
81
- const dirs = [
82
- { source: 'codex', dir: path.join(home, '.codex', 'skills') },
83
- { source: 'claude', dir: path.join(home, '.claude', 'skills') },
84
- ];
376
+ const dirs = [path.join(home, '.codex', 'skills')];
85
377
  const codexHome = process.env.CODEX_HOME?.trim();
86
378
  if (codexHome) {
87
- dirs.unshift({ source: 'codex', dir: path.join(codexHome, 'skills') });
379
+ dirs.unshift(path.join(codexHome, 'skills'));
88
380
  }
89
381
  const seen = new Set();
90
- return dirs.filter(({ dir }) => {
382
+ return dirs.filter((dir) => {
91
383
  const resolved = path.resolve(dir);
92
384
  if (seen.has(resolved))
93
385
  return false;
@@ -109,15 +401,24 @@ function scanSkillsDir(dir, source) {
109
401
  continue;
110
402
  try {
111
403
  const raw = fs.readFileSync(skillFile, 'utf-8');
112
- const { meta } = parseFrontmatter(raw);
404
+ const frontmatter = parseFrontmatter(raw);
405
+ const { meta } = frontmatter;
113
406
  const name = (meta.name || entry.name).trim();
114
407
  if (!name)
115
408
  continue;
409
+ const always = parseBool(meta.always, false);
410
+ const requires = parseRequiresFromFrontmatter(frontmatter);
411
+ const metadataHybridClaw = parseHybridClawMetadata(frontmatter);
116
412
  skills.push({
117
413
  name,
118
414
  description: (meta.description || '').trim(),
119
415
  userInvocable: parseBool(meta['user-invocable'], true),
120
416
  disableModelInvocation: parseBool(meta['disable-model-invocation'], false),
417
+ always,
418
+ requires,
419
+ metadata: {
420
+ hybridclaw: metadataHybridClaw,
421
+ },
121
422
  filePath: skillFile,
122
423
  baseDir,
123
424
  source,
@@ -148,13 +449,13 @@ function stableSkillDirName(name) {
148
449
  return `${base}-${hash}`;
149
450
  }
150
451
  function resolveSyncedSkillTarget(skill, workspaceDir) {
151
- // Keep project skills under /workspace/skills so script paths like
452
+ // Keep workspace skills under /workspace/skills so script paths like
152
453
  // "skills/<skill>/scripts/..." remain valid inside the agent container.
153
- if (skill.source === 'project') {
154
- const projectRoot = path.resolve(PROJECT_SKILLS_DIR);
454
+ if (skill.source === 'workspace') {
455
+ const workspaceRoot = path.resolve(WORKSPACE_SKILLS_DIR);
155
456
  const skillBaseDir = path.resolve(skill.baseDir);
156
- if (pathWithin(projectRoot, skillBaseDir)) {
157
- const rel = path.relative(projectRoot, skillBaseDir);
457
+ if (pathWithin(workspaceRoot, skillBaseDir)) {
458
+ const rel = path.relative(workspaceRoot, skillBaseDir);
158
459
  const rootDir = path.join(workspaceDir, 'skills');
159
460
  const targetDir = path.join(rootDir, rel);
160
461
  return {
@@ -164,6 +465,21 @@ function resolveSyncedSkillTarget(skill, workspaceDir) {
164
465
  };
165
466
  }
166
467
  }
468
+ // Keep project .agents skills under /workspace/.agents/skills for path-compat.
469
+ if (skill.source === 'agents-project') {
470
+ const projectAgentsRoot = path.resolve(PROJECT_AGENTS_SKILLS_DIR);
471
+ const skillBaseDir = path.resolve(skill.baseDir);
472
+ if (pathWithin(projectAgentsRoot, skillBaseDir)) {
473
+ const rel = path.relative(projectAgentsRoot, skillBaseDir);
474
+ const rootDir = path.join(workspaceDir, '.agents', 'skills');
475
+ const targetDir = path.join(rootDir, rel);
476
+ return {
477
+ rootDir,
478
+ targetDir,
479
+ targetSkillFile: path.join(targetDir, 'SKILL.md'),
480
+ };
481
+ }
482
+ }
167
483
  const rootDir = path.join(workspaceDir, SYNCED_SKILLS_DIR);
168
484
  const dirName = stableSkillDirName(skill.name);
169
485
  const targetDir = path.join(rootDir, dirName);
@@ -202,6 +518,56 @@ function normalizeSkillLookup(value) {
202
518
  .toLowerCase()
203
519
  .replace(/[\s_]+/g, '-');
204
520
  }
521
+ function sanitizeCommandName(name) {
522
+ return name
523
+ .toLowerCase()
524
+ .replace(/[^a-z0-9]+/g, '-')
525
+ .replace(/^-+|-+$/g, '')
526
+ .slice(0, MAX_SKILL_COMMAND_NAME_LENGTH);
527
+ }
528
+ function resolveUniqueCommandName(baseName, usedNames) {
529
+ const normalizedBase = (baseName || 'skill').slice(0, MAX_SKILL_COMMAND_NAME_LENGTH);
530
+ if (!usedNames.has(normalizedBase)) {
531
+ usedNames.add(normalizedBase);
532
+ return normalizedBase;
533
+ }
534
+ for (let index = 2; index < 10_000; index += 1) {
535
+ const suffix = `-${index}`;
536
+ const prefixLen = Math.max(1, MAX_SKILL_COMMAND_NAME_LENGTH - suffix.length);
537
+ const candidate = `${normalizedBase.slice(0, prefixLen)}${suffix}`;
538
+ if (usedNames.has(candidate))
539
+ continue;
540
+ usedNames.add(candidate);
541
+ return candidate;
542
+ }
543
+ return null;
544
+ }
545
+ function buildSkillCommandSpecs(skills) {
546
+ const used = new Set(Array.from(RESERVED_SKILL_COMMAND_NAMES.values()));
547
+ const specs = [];
548
+ for (const skill of skills) {
549
+ if (!skill.userInvocable)
550
+ continue;
551
+ const base = sanitizeCommandName(skill.name);
552
+ const name = resolveUniqueCommandName(base, used);
553
+ if (!name)
554
+ continue;
555
+ specs.push({
556
+ name,
557
+ skillName: skill.name,
558
+ skill,
559
+ });
560
+ }
561
+ return specs;
562
+ }
563
+ function findSkillCommand(skillCommands, rawName) {
564
+ const lowered = rawName.trim().toLowerCase();
565
+ if (!lowered)
566
+ return null;
567
+ const sanitized = sanitizeCommandName(rawName);
568
+ return skillCommands.find((entry) => (entry.name === lowered ||
569
+ (sanitized && entry.name === sanitized))) || null;
570
+ }
205
571
  function findInvocableSkill(skills, rawName) {
206
572
  const target = rawName.trim().toLowerCase();
207
573
  if (!target)
@@ -220,6 +586,7 @@ function parseSkillInvocation(content, skills) {
220
586
  const trimmed = content.trim();
221
587
  if (!trimmed.startsWith('/'))
222
588
  return null;
589
+ const skillCommands = buildSkillCommandSpecs(skills);
223
590
  const commandMatch = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
224
591
  if (!commandMatch)
225
592
  return null;
@@ -234,32 +601,35 @@ function parseSkillInvocation(content, skills) {
234
601
  const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/);
235
602
  if (!skillMatch)
236
603
  return null;
237
- const skill = findInvocableSkill(skills, skillMatch[1] || '');
604
+ const explicitName = (skillMatch[1] || '').trim();
605
+ const explicitSkill = findInvocableSkill(skills, explicitName);
606
+ const skill = explicitSkill || findSkillCommand(skillCommands, explicitName)?.skill || null;
238
607
  if (!skill)
239
608
  return null;
240
609
  return { skill, args: (skillMatch[2] || '').trim() };
241
610
  }
242
611
  if (lowerCommand.startsWith('skill:')) {
243
- const skillName = lowerCommand.slice('skill:'.length).trim();
612
+ const skillName = commandName.slice('skill:'.length).trim();
244
613
  if (!skillName)
245
614
  return null;
246
- const skill = findInvocableSkill(skills, skillName);
615
+ const explicitSkill = findInvocableSkill(skills, skillName);
616
+ const skill = explicitSkill || findSkillCommand(skillCommands, skillName)?.skill || null;
247
617
  if (!skill)
248
618
  return null;
249
619
  return { skill, args: remainder };
250
620
  }
251
- const directSkill = findInvocableSkill(skills, commandName);
252
- if (!directSkill)
621
+ const directSkillCommand = findSkillCommand(skillCommands, commandName);
622
+ if (!directSkillCommand)
253
623
  return null;
254
- return { skill: directSkill, args: remainder };
624
+ return { skill: directSkillCommand.skill, args: remainder };
255
625
  }
256
- function loadSkillBody(skill) {
626
+ function loadSkillBody(skill, maxChars) {
257
627
  try {
258
628
  const raw = fs.readFileSync(skill.filePath, 'utf-8');
259
629
  const { body } = parseFrontmatter(raw);
260
- if (body.length <= MAX_INVOKED_SKILL_CHARS)
630
+ if (body.length <= maxChars)
261
631
  return body;
262
- return `${body.slice(0, MAX_INVOKED_SKILL_CHARS)}\n\n[truncated]`;
632
+ return `${body.slice(0, maxChars)}\n\n[truncated]`;
263
633
  }
264
634
  catch (err) {
265
635
  logger.warn({ skill: skill.name, path: skill.filePath, err }, 'Failed to load SKILL.md body');
@@ -277,7 +647,7 @@ export function expandSkillInvocation(content, skills) {
277
647
  const invocation = parseSkillInvocation(content, skills);
278
648
  if (!invocation)
279
649
  return content;
280
- const body = loadSkillBody(invocation.skill);
650
+ const body = loadSkillBody(invocation.skill, MAX_INVOKED_SKILL_CHARS);
281
651
  const args = invocation.args || '(none)';
282
652
  const lines = [
283
653
  `[Explicit skill invocation] Use the "${invocation.skill.name}" skill for this request.`,
@@ -294,27 +664,70 @@ export function expandSkillInvocation(content, skills) {
294
664
  }
295
665
  /**
296
666
  * Load all skills with precedence:
297
- * codex/claude managed < project skills < agent workspace skills.
667
+ * extra < bundled < codex < claude < agents-personal < agents-project < workspace.
298
668
  * Any non-workspace skill selected by precedence is mirrored into workspace so
299
669
  * the container can read it via /workspace/... paths.
300
670
  */
301
671
  export function loadSkills(agentId) {
302
672
  const workspaceDir = path.resolve(agentWorkspaceDir(agentId));
303
673
  fs.mkdirSync(workspaceDir, { recursive: true });
304
- const workspaceSkills = scanSkillsDir(path.join(workspaceDir, 'skills'), 'workspace');
305
- const projectSkills = scanSkillsDir(PROJECT_SKILLS_DIR, 'project');
306
- const managedSkills = resolveManagedSkillsDirs()
307
- .flatMap(({ source, dir }) => scanSkillsDir(dir, source));
674
+ const config = getRuntimeConfig();
675
+ const extraDirs = (config.skills?.extraDirs ?? [])
676
+ .map((dir) => resolveUserPath(dir))
677
+ .filter(Boolean);
678
+ const bundledSkillsDir = resolveBundledSkillsDir();
679
+ const codexDirs = resolveCodexSkillsDirs();
680
+ const claudeSkillsDir = path.join(os.homedir(), '.claude', 'skills');
681
+ const agentsPersonalSkillsDir = path.join(os.homedir(), '.agents', 'skills');
682
+ const extraSkills = extraDirs.flatMap((dir) => scanSkillsDir(dir, 'extra'));
683
+ const bundledSkills = bundledSkillsDir ? scanSkillsDir(bundledSkillsDir, 'bundled') : [];
684
+ const codexSkills = codexDirs.flatMap((dir) => scanSkillsDir(dir, 'codex'));
685
+ const claudeSkills = scanSkillsDir(claudeSkillsDir, 'claude');
686
+ const agentsPersonalSkills = scanSkillsDir(agentsPersonalSkillsDir, 'agents-personal');
687
+ const projectAgentsSkills = scanSkillsDir(PROJECT_AGENTS_SKILLS_DIR, 'agents-project');
688
+ const workspaceSkills = scanSkillsDir(WORKSPACE_SKILLS_DIR, 'workspace');
308
689
  const byName = new Map();
309
690
  // Lowest to highest precedence.
310
- for (const skill of managedSkills)
691
+ for (const skill of extraSkills)
692
+ byName.set(skill.name, skill);
693
+ for (const skill of bundledSkills)
311
694
  byName.set(skill.name, skill);
312
- for (const skill of projectSkills)
695
+ for (const skill of codexSkills)
696
+ byName.set(skill.name, skill);
697
+ for (const skill of claudeSkills)
698
+ byName.set(skill.name, skill);
699
+ for (const skill of agentsPersonalSkills)
700
+ byName.set(skill.name, skill);
701
+ for (const skill of projectAgentsSkills)
313
702
  byName.set(skill.name, skill);
314
703
  for (const skill of workspaceSkills)
315
704
  byName.set(skill.name, skill);
705
+ const eligible = Array.from(byName.values())
706
+ .filter((skill) => checkEligibility(skill).available);
707
+ const guarded = eligible.filter((skill) => {
708
+ const decision = guardSkillDirectory({
709
+ skillName: skill.name,
710
+ skillPath: skill.baseDir,
711
+ sourceTag: skill.source,
712
+ });
713
+ if (decision.allowed)
714
+ return true;
715
+ const fingerprint = `${path.resolve(skill.baseDir)}:${decision.result.verdict}:${decision.result.findings.length}`;
716
+ if (!warnedBlockedSkills.has(fingerprint)) {
717
+ warnedBlockedSkills.add(fingerprint);
718
+ logger.warn({
719
+ skill: skill.name,
720
+ source: skill.source,
721
+ trustLevel: decision.result.trustLevel,
722
+ verdict: decision.result.verdict,
723
+ findings: decision.result.findings.length,
724
+ reason: decision.reason,
725
+ }, 'Blocked skill by security scanner');
726
+ }
727
+ return false;
728
+ });
316
729
  const resolved = [];
317
- for (const skill of byName.values()) {
730
+ for (const skill of guarded) {
318
731
  try {
319
732
  let containerSkillPath = asContainerPath(workspaceDir, path.resolve(skill.filePath));
320
733
  if (!containerSkillPath) {
@@ -345,32 +758,54 @@ export function buildSkillsPrompt(skills) {
345
758
  .slice(0, MAX_SKILLS_IN_PROMPT);
346
759
  if (promptCandidates.length === 0)
347
760
  return '';
348
- const lines = [
349
- '## Skills (mandatory)',
350
- 'Before replying: scan <available_skills> <description> entries.',
351
- '- If exactly one skill clearly applies: read its SKILL.md at <location> with `read`, then follow it.',
352
- '- If multiple could apply: choose the most specific one, then read/follow it.',
353
- '- If none clearly apply: do not read any SKILL.md.',
354
- 'Constraints: never read more than one skill up front; only read after selecting.',
355
- '',
356
- '<available_skills>',
357
- ];
358
- let chars = 0;
359
- for (const skill of promptCandidates) {
761
+ const lines = [];
762
+ const embeddedAlways = new Set();
763
+ const demotedAlways = [];
764
+ let alwaysChars = 0;
765
+ for (const skill of promptCandidates.filter((candidate) => candidate.always)) {
766
+ const body = loadSkillBody(skill, Number.MAX_SAFE_INTEGER);
767
+ if (!body) {
768
+ demotedAlways.push(skill);
769
+ continue;
770
+ }
360
771
  const block = [
361
- ' <skill>',
362
- ` <name>${escapeXml(skill.name)}</name>`,
363
- ` <description>${escapeXml(skill.description || skill.name)}</description>`,
364
- ` <location>${escapeXml(skill.location)}</location>`,
365
- ' </skill>',
772
+ `<skill_always name="${escapeXml(skill.name)}" path="${escapeXml(skill.location)}">`,
773
+ body,
774
+ '</skill_always>',
366
775
  ];
367
776
  const serialized = block.join('\n');
368
- if (chars + serialized.length > MAX_SKILLS_PROMPT_CHARS)
369
- break;
370
- lines.push(...block);
371
- chars += serialized.length;
777
+ if (alwaysChars + serialized.length > MAX_ALWAYS_CHARS) {
778
+ demotedAlways.push(skill);
779
+ continue;
780
+ }
781
+ lines.push(...block, '');
782
+ alwaysChars += serialized.length;
783
+ embeddedAlways.add(skill.name);
372
784
  }
373
- lines.push('</available_skills>');
374
- return lines.join('\n');
785
+ if (demotedAlways.length > 0) {
786
+ const demotedNames = demotedAlways.map((skill) => skill.name).join(', ');
787
+ lines.push(`⚠️ maxAlwaysChars=${MAX_ALWAYS_CHARS} exceeded; demoted to summary: ${demotedNames}`, '');
788
+ }
789
+ const summaryCandidates = promptCandidates.filter((skill) => !embeddedAlways.has(skill.name));
790
+ if (summaryCandidates.length > 0) {
791
+ lines.push('<available_skills>');
792
+ let chars = 0;
793
+ for (const skill of summaryCandidates) {
794
+ const block = [
795
+ ' <skill>',
796
+ ` <name>${escapeXml(skill.name)}</name>`,
797
+ ` <description>${escapeXml(skill.description || skill.name)}</description>`,
798
+ ` <location>${escapeXml(skill.location)}</location>`,
799
+ ' </skill>',
800
+ ];
801
+ const serialized = block.join('\n');
802
+ if (chars + serialized.length > MAX_SKILLS_PROMPT_CHARS)
803
+ break;
804
+ lines.push(...block);
805
+ chars += serialized.length;
806
+ }
807
+ lines.push('</available_skills>');
808
+ }
809
+ return lines.join('\n').trim();
375
810
  }
376
811
  //# sourceMappingURL=skills.js.map