@vincemakes/kiso-skills-ext 0.49.0 → 0.50.1

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.
@@ -9,24 +9,33 @@
9
9
  * one line of the system prompt, sorted by directory name:
10
10
  * Available skills (load with read_skill):
11
11
  * - <name>: <description>
12
- * A SKILL.md without frontmatter is skipped with a warning line at the
13
- * tail of that index (soft failure — the mcp philosophy). No/empty skills
14
- * dir → an empty extension, never an error.
12
+ * The index has a budget (3,000 chars, ADR-0033 Amendment 2): over it,
13
+ * every name stays and the descriptions are cut to one common length,
14
+ * down to names only — a skill is never dropped from the index.
15
+ * A broken entry (no frontmatter, no description, a broken link, a
16
+ * duplicate name) is skipped — a soft failure, the mcp philosophy — and
17
+ * reported to the PERSON (the catalog's `broken`: /skills, the reload
18
+ * line), never in the resident prompt: the model cannot repair a skill
19
+ * directory (ADR-0033 Amendment 1). No valid skill → an empty extension,
20
+ * never an error.
15
21
  *
16
- * Tier 2 (on demand): the read_skill tool returns the FULL SKILL.md (capped
17
- * at 32KB with a truncation note); an unknown name is an honest,
22
+ * Tier 2 (on demand): the read_skill tool returns the SKILL.md (capped at
23
+ * 32KB with a truncation note) — its body alone when the frontmatter holds
24
+ * only what the index already carries (ADR-0033 Amendment 2); an unknown name is an honest,
18
25
  * actionable error listing the installed skills. Files other than
19
- * SKILL.md are NOT auto-loaded — the body tells the model to read them
20
- * with read_file by relative path (the progressive third tier; zero new
21
- * mechanisms).
26
+ * SKILL.md are NOT auto-loaded: the model reads one when the body names
27
+ * it, with read_skill's `file` — a path relative to the skill's own
28
+ * directory, confined to it (ADR-0033 Amendment 1: the read_file
29
+ * convention could never reach a skill's directory, which is outside the
30
+ * workspace).
22
31
  *
23
32
  * Compatible with Claude Code skills: the frontmatter name/description
24
33
  * subset parses CC skill files — drop one in and it works.
25
34
  */
26
35
 
27
- import { readdirSync, readFileSync, statSync } from "node:fs";
36
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
28
37
  import { homedir } from "node:os";
29
- import { join } from "node:path";
38
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
30
39
 
31
40
  const MAX_DESCRIPTION = 200;
32
41
  const MAX_BODY = 32 * 1024;
@@ -53,11 +62,12 @@ export default async function createSkillsExtension(options = {}) {
53
62
  // finding #8: no persistent resources — SKILL.md files are read per call;
54
63
  // nothing is spawned or connected — no dispose is needed, explicitly.
55
64
  const catalog = skillsCatalog(index, broken);
56
- if (index.length === 0 && broken.length === 0) return { name: "skills", skills: 0, tools: [], catalog };
57
- const tools = index.length > 0 ? [readSkillTool(index, broken)] : [];
65
+ // no valid skill: no index, no tool — broken entries reach the person
66
+ // through the catalog, not the model's prompt (ADR-0033 Amendment 1)
67
+ if (index.length === 0) return { name: "skills", skills: 0, tools: [], catalog };
58
68
  return {
59
69
  name: "skills",
60
- tools,
70
+ tools: [readSkillTool(index, broken)],
61
71
  // §2.5: how many skills THIS load indexed. The CLI's reload line reads
62
72
  // it here rather than walking the directory again — one scan, one
63
73
  // answer, and no second count free to disagree with this one.
@@ -66,7 +76,7 @@ export default async function createSkillsExtension(options = {}) {
66
76
  // `/skills` — the person's door and the model's index cannot list
67
77
  // different skills, because they are one list.
68
78
  catalog,
69
- systemPrompt: { append: skillsPromptAppend(index, broken) },
79
+ systemPrompt: { append: skillsPromptAppend(index) },
70
80
  };
71
81
  }
72
82
 
@@ -261,22 +271,91 @@ function unquote(value) {
261
271
  }
262
272
 
263
273
  /** Tier 1: the resident index — one line per skill, sorted by directory
264
- * name, a warning line for every broken entry at the tail. */
265
- function skillsPromptAppend(index, broken) {
266
- const lines = index.map((s) => `- ${s.name}: ${s.description}`);
267
- const warning =
268
- broken.length > 0 ? `\n[skills] skipped ${broken.length} broken skill(s): ${broken.map((b) => `${b.dir} (${b.reason})`).join(", ")}` : "";
269
- return `Available skills (load with read_skill):\n${lines.join("\n")}${warning}`;
274
+ * name. Broken entries are not in it (ADR-0033 Amendment 1): they are
275
+ * installation diagnostics, for the person. */
276
+ const INDEX_BUDGET = 3000;
277
+ const MIN_DESCRIPTION = 16;
278
+ function skillsPromptAppend(index) {
279
+ const head = "Available skills (load with read_skill):";
280
+ const full = index.map((s) => `- ${s.name}: ${s.description}`);
281
+ if (head.length + full.reduce((n, l) => n + 1 + l.length, 0) <= INDEX_BUDGET) return `${head}\n${full.join("\n")}`;
282
+ // K3: over the budget, every name stays; the descriptions share what is
283
+ // left evenly (the longest are cut first, to one common cap) — and when
284
+ // even short descriptions do not fit, the index is names only. A skill
285
+ // is never dropped: losing a description loses detail, losing a name
286
+ // loses the skill.
287
+ const room = INDEX_BUDGET - head.length - index.reduce((n, s) => n + 1 + `- ${s.name}: `.length, 0);
288
+ const cost = (cap) => index.reduce((n, s) => n + Math.min(s.description.length, cap), 0);
289
+ if (room < index.length * MIN_DESCRIPTION) return `${head}\n${index.map((s) => `- ${s.name}`).join("\n")}`;
290
+ let lo = MIN_DESCRIPTION, hi = Math.max(...index.map((s) => s.description.length));
291
+ while (lo < hi) {
292
+ const mid = Math.ceil((lo + hi) / 2);
293
+ if (cost(mid) <= room) lo = mid;
294
+ else hi = mid - 1;
295
+ }
296
+ const cut = (d) => (d.length <= lo ? d : `${d.slice(0, lo - 1).trimEnd()}…`);
297
+ return `${head}\n${index.map((s) => `- ${s.name}: ${cut(s.description)}`).join("\n")}`;
298
+ }
299
+
300
+ /** Tier 3 (ADR-0033 Amendment 1): a file inside the skill's own
301
+ * directory, by a path relative to it. Containment, in order: no
302
+ * absolute path, no `..` segment, the real path (symlinks resolved) still
303
+ * inside the real skill directory, a regular file. The file is only READ
304
+ * — a script comes back as text; running it is shell's business, under
305
+ * shell's permission. */
306
+ function skillFile(skill, file) {
307
+ if (isAbsolute(file)) return { error: `file must be relative to the skill's directory: ${file}` };
308
+ if (file.split(/[\\/]/).includes("..")) return { error: `file escapes the skill's directory: ${file}` };
309
+ const root = dirname(skill.path);
310
+ let full;
311
+ try {
312
+ const rootReal = realpathSync(root);
313
+ full = realpathSync(resolve(root, file));
314
+ const rel = relative(rootReal, full);
315
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return { error: `file escapes the skill's directory: ${file}` };
316
+ } catch {
317
+ return { error: `no file ${file} in skill "${skill.name}"` };
318
+ }
319
+ // a file removed between the containment check and here is a tool
320
+ // error, never a throw (the realpath → stat window)
321
+ let st;
322
+ try {
323
+ st = statSync(full);
324
+ } catch {
325
+ return { error: `no file ${file} in skill "${skill.name}"` };
326
+ }
327
+ if (st.isDirectory()) return { error: `${file} is a directory — name a file in it` };
328
+ if (!st.isFile()) return { error: `${file} is not a regular file` };
329
+ return { full };
330
+ }
331
+
332
+ const CONSUMED = new Set(["name", "description", "user-invocable"]);
333
+ function bodyIfOnlyConsumed(text) {
334
+ if (!text.startsWith("---")) return text;
335
+ const end = text.indexOf("\n---", 3);
336
+ if (end < 0) return text;
337
+ const keys = text.slice(3, end).split(/\r?\n/).map((l) => /^([A-Za-z0-9_-]+)\s*:/.exec(l)?.[1]).filter((k) => k !== undefined);
338
+ if (keys.length === 0 || !keys.every((k) => CONSUMED.has(k))) return text;
339
+ return text.slice(end + 4).replace(/^[^\n]*\n?/, "");
270
340
  }
271
341
 
272
- /** Tier 2: read_skill — the full SKILL.md (≤32KB), or an honest,
273
- * actionable unknown-name error listing the installed skills. */
342
+ /** Tier 2: read_skill — the full SKILL.md (≤32KB), or with `file` one of
343
+ * the skill's own files (Tier 3, ≤32KB); an unknown name is an honest,
344
+ * actionable error listing the installed skills. */
274
345
  function readSkillTool(index, broken) {
275
346
  const brokenNote = broken.length > 0 ? ` (${broken.length} broken skill(s) skipped: ${broken.map((b) => b.dir).join(", ")})` : "";
276
347
  return {
277
348
  name: "read_skill",
278
- description: "load a skill's SKILL.md (the available-skills list is in the system prompt)",
279
- parameters: { type: "object", properties: { name: { type: "string", minLength: 1 } }, required: ["name"], additionalProperties: false },
349
+ description: "Load an installed skill, or one of its files",
350
+ parameters: {
351
+ type: "object",
352
+ properties: {
353
+ name: { type: "string", minLength: 1 },
354
+ file: { type: "string", minLength: 1, description: "A file in the skill's directory, relative to it; omit for SKILL.md" },
355
+ },
356
+ required: ["name"],
357
+ additionalProperties: false,
358
+ },
280
359
  // Plan B: every active tool contributes one inventory line
281
360
  promptSnippet: "read_skill — load an installed skill",
282
361
  execute: async (input) => {
@@ -284,20 +363,36 @@ function readSkillTool(index, broken) {
284
363
  const skill = index.find((s) => s.name === name);
285
364
  if (skill === undefined) {
286
365
  const names = index.length > 0 ? index.map((s) => s.name).join(", ") : "(none installed)";
366
+ // the one moment a broken entry is the model's business: it asked
367
+ // for it by name — say why it is not there
368
+ const skipped = broken.find((b) => b.dir === name);
369
+ const why = skipped !== undefined ? ` — "${name}" was skipped: ${skipped.reason}` : "";
287
370
  return {
288
- content: `[skills] unknown skill "${name}" — available: ${names}${brokenNote}`,
371
+ content: `[skills] unknown skill "${name}"${why} — available: ${names}${brokenNote}`,
289
372
  isError: true,
290
373
  errorKind: "invalid_input",
291
374
  };
292
375
  }
376
+ let target = skill.path;
377
+ if (input?.file !== undefined) {
378
+ const found = skillFile(skill, String(input.file));
379
+ if (found.error !== undefined) return { content: `[skills] ${found.error}`, isError: true, errorKind: "invalid_input" };
380
+ target = found.full;
381
+ }
293
382
  let text;
294
383
  try {
295
- text = readFileSync(skill.path, "utf8");
384
+ text = readFileSync(target, "utf8");
296
385
  } catch (err) {
297
- return { content: `[skills] cannot read ${skill.path}: ${err instanceof Error ? err.message : String(err)}`, isError: true, errorKind: "fatal" };
386
+ return { content: `[skills] cannot read ${target}: ${err instanceof Error ? err.message : String(err)}`, isError: true, errorKind: "fatal" };
298
387
  }
388
+ // the cap is the whole contract: nothing reads past it (the old
389
+ // note sent the model to read_file, which cannot reach this
390
+ // directory)
391
+ // K2: SKILL.md's frontmatter repeats the index line when it carries only
392
+ // what the loader consumed; any other key stays, with the block
393
+ if (target === skill.path) text = bodyIfOnlyConsumed(text);
299
394
  if (text.length > MAX_BODY) {
300
- text = `${text.slice(0, MAX_BODY)}\n…[truncated at ${MAX_BODY} chars — read the rest with read_file]`;
395
+ text = `${text.slice(0, MAX_BODY)}\n…[truncated at ${MAX_BODY} chars — read_skill returns at most this much of a file]`;
301
396
  }
302
397
  return { content: text, isError: false };
303
398
  },
package/index.d.ts CHANGED
@@ -28,7 +28,8 @@ export interface SkillsCatalogEntry {
28
28
 
29
29
  export interface SkillsCatalog {
30
30
  readonly entries: readonly SkillsCatalogEntry[];
31
- /** the loader's own reason per skipped entry — the words the model's warning line uses */
31
+ /** the loader's own reason per skipped entry — the person's diagnostics (ADR-0033
32
+ * Amendment 1: not in the model's prompt; read_skill names it when asked) */
32
33
  readonly broken: readonly { readonly dir: string; readonly reason: string }[];
33
34
  body(name: string): { readonly body: string } | { readonly error: string };
34
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-skills-ext",
3
- "version": "0.49.0",
3
+ "version": "0.50.1",
4
4
  "description": "kiso official skills extension \u2014 two-tier progressive skills, kernel untouched",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  "test": "vitest run"
24
24
  },
25
25
  "devDependencies": {
26
- "@vincemakes/kiso-core": "0.49.0",
26
+ "@vincemakes/kiso-core": "0.50.1",
27
27
  "@types/node": "^26.1.2",
28
28
  "typescript": "^5.7.2",
29
29
  "vitest": "^3.0.0"