@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.
- package/dist/kiso-skills.mjs +123 -28
- package/index.d.ts +2 -1
- package/package.json +2 -2
package/dist/kiso-skills.mjs
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
17
|
-
*
|
|
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
|
|
20
|
-
* with
|
|
21
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
273
|
-
*
|
|
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: "
|
|
279
|
-
parameters: {
|
|
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(
|
|
384
|
+
text = readFileSync(target, "utf8");
|
|
296
385
|
} catch (err) {
|
|
297
|
-
return { content: `[skills] cannot read ${
|
|
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 —
|
|
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
|
|
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.
|
|
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.
|
|
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"
|