@vincemakes/kiso-skills-ext 0.39.2 → 0.40.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/README.md +21 -0
- package/dist/kiso-skills.mjs +113 -19
- package/index.d.ts +19 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,6 +17,27 @@ Skill directories: `~/.kiso/skills/<name>/SKILL.md` (or the project-level
|
|
|
17
17
|
`.kiso/skills/` after the trust gate). No configuration file — the
|
|
18
18
|
extension scans the skills dir at startup.
|
|
19
19
|
|
|
20
|
+
## Invoking a skill yourself
|
|
21
|
+
|
|
22
|
+
`/skill <name> [args]` sends a skill as your turn: its SKILL.md body, then
|
|
23
|
+
your args after a blank line. `/<name> [args]` does the same when no
|
|
24
|
+
built-in command has that name — a built-in always wins. `/skills` lists
|
|
25
|
+
what is installed, where each skill lives, and any that cannot load with
|
|
26
|
+
the reason.
|
|
27
|
+
|
|
28
|
+
The frontmatter keys read are `name`, `description` and `user-invocable`.
|
|
29
|
+
Values may be plain, quoted (`"…"` or `'…'`), or YAML block scalars (`>` or
|
|
30
|
+
`|`, with the text on the following indented lines); the index shows the
|
|
31
|
+
description on one line. A `>` or `|` with nothing under it is reported as
|
|
32
|
+
a skill that cannot load.
|
|
33
|
+
|
|
34
|
+
A skill whose frontmatter says `user-invocable: false` stays in the
|
|
35
|
+
model's index and out of your reach. Only the literal `false` counts; a
|
|
36
|
+
skill without the key is invocable.
|
|
37
|
+
|
|
38
|
+
The body is sent as written — there is no placeholder substitution; your
|
|
39
|
+
args follow it. A body over 32,768 characters is refused, not cut.
|
|
40
|
+
|
|
20
41
|
## Versioning
|
|
21
42
|
|
|
22
43
|
The version counter is this package's own. It is pinned exactly by the kiso
|
package/dist/kiso-skills.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Tier 1 (resident): the skills index — every ${KISO_SKILLS_DIR:-~/.kiso/
|
|
6
6
|
* skills}/<name>/SKILL.md's frontmatter (a --- wrapped YAML SUBSET; only
|
|
7
|
-
* name/description are read, by a hand-written parser — no
|
|
7
|
+
* name/description/user-invocable are read, by a hand-written parser — no
|
|
8
|
+
* deps) becomes
|
|
8
9
|
* one line of the system prompt, sorted by directory name:
|
|
9
10
|
* Available skills (load with read_skill):
|
|
10
11
|
* - <name>: <description>
|
|
@@ -41,7 +42,8 @@ export default async function createSkillsExtension() {
|
|
|
41
42
|
const { index, broken } = loadIndex(skillsDir);
|
|
42
43
|
// finding #8: no persistent resources — SKILL.md files are read per call;
|
|
43
44
|
// nothing is spawned or connected — no dispose is needed, explicitly.
|
|
44
|
-
|
|
45
|
+
const catalog = skillsCatalog(index, broken);
|
|
46
|
+
if (index.length === 0 && broken.length === 0) return { name: "skills", skills: 0, tools: [], catalog };
|
|
45
47
|
const tools = index.length > 0 ? [readSkillTool(index, broken)] : [];
|
|
46
48
|
return {
|
|
47
49
|
name: "skills",
|
|
@@ -50,6 +52,10 @@ export default async function createSkillsExtension() {
|
|
|
50
52
|
// it here rather than walking the directory again — one scan, one
|
|
51
53
|
// answer, and no second count free to disagree with this one.
|
|
52
54
|
skills: index.length,
|
|
55
|
+
// 0.40.0: the same scan, handed to the CLI for `/skill` and
|
|
56
|
+
// `/skills` — the person's door and the model's index cannot list
|
|
57
|
+
// different skills, because they are one list.
|
|
58
|
+
catalog,
|
|
53
59
|
systemPrompt: { append: skillsPromptAppend(index, broken) },
|
|
54
60
|
};
|
|
55
61
|
}
|
|
@@ -73,9 +79,9 @@ function loadIndex(skillsDir) {
|
|
|
73
79
|
} else if (d.isSymbolicLink()) {
|
|
74
80
|
try {
|
|
75
81
|
if (statSync(join(skillsDir, d.name)).isDirectory()) dirs.push(d.name);
|
|
76
|
-
else brokenLinks.push(
|
|
82
|
+
else brokenLinks.push({ dir: d.name, reason: "symlink target is not a directory" });
|
|
77
83
|
} catch {
|
|
78
|
-
brokenLinks.push(
|
|
84
|
+
brokenLinks.push({ dir: d.name, reason: "broken symlink" });
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
}
|
|
@@ -90,38 +96,126 @@ function loadIndex(skillsDir) {
|
|
|
90
96
|
try {
|
|
91
97
|
text = readFileSync(path, "utf8");
|
|
92
98
|
} catch {
|
|
93
|
-
broken.push(
|
|
99
|
+
broken.push({ dir, reason: "no SKILL.md" });
|
|
94
100
|
continue;
|
|
95
101
|
}
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
broken.push(
|
|
102
|
+
const parsed = parseFrontmatter(text);
|
|
103
|
+
if (parsed === null) {
|
|
104
|
+
broken.push({ dir, reason: "no frontmatter" });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const { meta, emptyBlocks } = parsed;
|
|
108
|
+
// RF-2: a `>`/`|` with nothing under it is a mistake in the file, not
|
|
109
|
+
// an empty value — say so, rather than "no description"
|
|
110
|
+
if (emptyBlocks.has("name") || emptyBlocks.has("description")) {
|
|
111
|
+
broken.push({ dir, reason: "empty block scalar" });
|
|
99
112
|
continue;
|
|
100
113
|
}
|
|
101
114
|
const name = (meta.name ?? dir).trim();
|
|
102
|
-
|
|
115
|
+
// RF-2: a name is matched against `/skill <name>` and printed on one
|
|
116
|
+
// index line — a `|` block or a quoted "\n" cannot make it two
|
|
117
|
+
if (/[\r\n]/.test(name)) {
|
|
118
|
+
broken.push({ dir, reason: "name is not one line" });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// the index is ONE line per skill: only a value that CARRIES a newline
|
|
122
|
+
// (a `|` block, a quoted "\n") is collapsed — a plain value's bytes are
|
|
123
|
+
// exactly what they were before RF-2, inner spacing included
|
|
124
|
+
const rawDescription = meta.description ?? "";
|
|
125
|
+
let description = (/[\r\n]/.test(rawDescription) ? rawDescription.replace(/\s+/g, " ") : rawDescription).trim();
|
|
103
126
|
if (description === "") {
|
|
104
|
-
broken.push(
|
|
127
|
+
broken.push({ dir, reason: "no description" });
|
|
105
128
|
continue;
|
|
106
129
|
}
|
|
107
130
|
if (description.length > MAX_DESCRIPTION) description = `${description.slice(0, MAX_DESCRIPTION)}…[truncated]`;
|
|
108
|
-
|
|
131
|
+
// `user-invocable: false` (the key other harnesses use) keeps a skill
|
|
132
|
+
// out of the person's reach and in the model's. Only the literal
|
|
133
|
+
// `false` counts: absence cannot be told apart from a skill written
|
|
134
|
+
// before the key existed, so absence is invocable.
|
|
135
|
+
index.push({ name, description, dir, path, userInvocable: meta["user-invocable"] !== "false" });
|
|
109
136
|
}
|
|
110
137
|
return { index, broken };
|
|
111
138
|
}
|
|
112
139
|
|
|
113
|
-
/**
|
|
114
|
-
*
|
|
140
|
+
/** 0.40.0 — what the CLI reads to let a PERSON invoke a skill. `body` reads
|
|
141
|
+
* the file at call time (finding #8: nothing is held), strips the
|
|
142
|
+
* frontmatter, and refuses — never truncates — a body over the cap: a
|
|
143
|
+
* skill cut in half is a different instruction than the one written. */
|
|
144
|
+
function skillsCatalog(index, broken) {
|
|
145
|
+
return {
|
|
146
|
+
entries: index.map(({ name, description, dir, path, userInvocable }) => ({ name, description, dir, path, userInvocable })),
|
|
147
|
+
broken: broken.map(({ dir, reason }) => ({ dir, reason })),
|
|
148
|
+
body(name) {
|
|
149
|
+
const skill = index.find((s) => s.name === name);
|
|
150
|
+
if (skill === undefined) return { error: "not installed" };
|
|
151
|
+
let text;
|
|
152
|
+
try {
|
|
153
|
+
text = readFileSync(skill.path, "utf8");
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return { error: `cannot read ${skill.path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
156
|
+
}
|
|
157
|
+
const end = text.indexOf("\n---", 4);
|
|
158
|
+
const body = text.slice(end + 4).replace(/^[^\n]*\n?/, "").trim();
|
|
159
|
+
if (body.length > MAX_BODY) return { error: `over the ${MAX_BODY.toLocaleString("en-US")}-character skill cap (${body.length.toLocaleString("en-US")} characters)` };
|
|
160
|
+
return { body };
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The --- wrapped YAML subset: top-level `key: value` lines; the loader
|
|
166
|
+
* reads `name`, `description` and `user-invocable` (everything else is
|
|
167
|
+
* ignored). Null = no valid frontmatter.
|
|
168
|
+
*
|
|
169
|
+
* RF-2: skills written for other harnesses use real YAML, so a value may
|
|
170
|
+
* be a BLOCK scalar (`>` folds its lines with spaces, `|` keeps them; an
|
|
171
|
+
* optional chomping `+`/`-` is accepted) whose text is the following lines
|
|
172
|
+
* indented deeper than the key, or a double/single-QUOTED scalar. The flat
|
|
173
|
+
* reader indexed `description: >` as the literal ">" and dropped the text.
|
|
174
|
+
* A block indicator with no indented lines records the key in
|
|
175
|
+
* `emptyBlocks` — the caller names it a broken entry, never an empty
|
|
176
|
+
* description. Anchors, flow collections and nesting stay out of scope:
|
|
177
|
+
* the index needs three string keys. */
|
|
115
178
|
function parseFrontmatter(text) {
|
|
116
179
|
if (!text.startsWith("---\n")) return null;
|
|
117
180
|
const end = text.indexOf("\n---", 4);
|
|
118
181
|
if (end < 0) return null;
|
|
182
|
+
const lines = text.slice(4, end).split("\n");
|
|
119
183
|
const meta = {};
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
184
|
+
const emptyBlocks = new Set();
|
|
185
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
186
|
+
const m = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[i]);
|
|
187
|
+
if (m === null) continue;
|
|
188
|
+
const [, key, rawValue] = m;
|
|
189
|
+
const value = rawValue.trim();
|
|
190
|
+
if (/^[>|][+-]?$/.test(value)) {
|
|
191
|
+
// the block: every following line indented deeper than the key
|
|
192
|
+
// (a blank line inside the block belongs to it)
|
|
193
|
+
const block = [];
|
|
194
|
+
while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || (lines[i + 1].trim() === "" && block.length > 0))) block.push(lines[(i += 1)]);
|
|
195
|
+
while (block.length > 0 && block[block.length - 1].trim() === "") block.pop();
|
|
196
|
+
if (block.length === 0) {
|
|
197
|
+
emptyBlocks.add(key);
|
|
198
|
+
meta[key] = "";
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const indent = Math.min(...block.filter((l) => l.trim() !== "").map((l) => /^\s*/.exec(l)[0].length));
|
|
202
|
+
const body = block.map((l) => l.slice(indent));
|
|
203
|
+
meta[key] = value.startsWith(">") ? body.map((l) => l.trim()).join(" ").replace(/ {2,}/g, " ").trim() : body.join("\n").trim();
|
|
204
|
+
} else {
|
|
205
|
+
meta[key] = unquote(value);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { meta, emptyBlocks };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** RF-2: a quoted scalar's text — `"…"` with its backslash escapes, `'…'`
|
|
212
|
+
* with `''` for a quote. Anything else is returned as written. */
|
|
213
|
+
function unquote(value) {
|
|
214
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
215
|
+
return value.slice(1, -1).replace(/\\(["\\nt])/g, (_, c) => (c === "n" ? "\n" : c === "t" ? "\t" : c));
|
|
123
216
|
}
|
|
124
|
-
return
|
|
217
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replace(/''/g, "'");
|
|
218
|
+
return value;
|
|
125
219
|
}
|
|
126
220
|
|
|
127
221
|
/** Tier 1: the resident index — one line per skill, sorted by directory
|
|
@@ -129,14 +223,14 @@ function parseFrontmatter(text) {
|
|
|
129
223
|
function skillsPromptAppend(index, broken) {
|
|
130
224
|
const lines = index.map((s) => `- ${s.name}: ${s.description}`);
|
|
131
225
|
const warning =
|
|
132
|
-
broken.length > 0 ? `\n[skills] skipped ${broken.length} broken skill(s): ${broken.join(", ")}` : "";
|
|
226
|
+
broken.length > 0 ? `\n[skills] skipped ${broken.length} broken skill(s): ${broken.map((b) => `${b.dir} (${b.reason})`).join(", ")}` : "";
|
|
133
227
|
return `Available skills (load with read_skill):\n${lines.join("\n")}${warning}`;
|
|
134
228
|
}
|
|
135
229
|
|
|
136
230
|
/** Tier 2: read_skill — the full SKILL.md (≤32KB), or an honest,
|
|
137
231
|
* actionable unknown-name error listing the installed skills. */
|
|
138
232
|
function readSkillTool(index, broken) {
|
|
139
|
-
const brokenNote = broken.length > 0 ? ` (${broken.length} broken skill(s) skipped: ${broken.map((b) => b.
|
|
233
|
+
const brokenNote = broken.length > 0 ? ` (${broken.length} broken skill(s) skipped: ${broken.map((b) => b.dir).join(", ")})` : "";
|
|
140
234
|
return {
|
|
141
235
|
name: "read_skill",
|
|
142
236
|
description: "load a skill's SKILL.md (the available-skills list is in the system prompt)",
|
package/index.d.ts
CHANGED
|
@@ -11,7 +11,25 @@ import type { KisoExtension } from "@vincemakes/kiso-core";
|
|
|
11
11
|
* caller wanting the number does not walk the directory a second time and
|
|
12
12
|
* get a second answer free to disagree with this one. Absent means the
|
|
13
13
|
* load reported none — never a reason to guess. */
|
|
14
|
-
type SkillsExtension = KisoExtension & { readonly skills?: number };
|
|
14
|
+
type SkillsExtension = KisoExtension & { readonly skills?: number; readonly catalog?: SkillsCatalog };
|
|
15
|
+
|
|
16
|
+
/** 0.40.0: the same scan, for the CLI's `/skill` and `/skills`. `body` reads
|
|
17
|
+
* the file at call time and returns the SKILL.md body without its
|
|
18
|
+
* frontmatter, or the reason it cannot (over the cap, unreadable). */
|
|
19
|
+
export interface SkillsCatalog {
|
|
20
|
+
readonly entries: readonly {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly description: string;
|
|
23
|
+
/** the directory under the skills root the skill was found in */
|
|
24
|
+
readonly dir: string;
|
|
25
|
+
readonly path: string;
|
|
26
|
+
/** false only for `user-invocable: false` — the model may still load it */
|
|
27
|
+
readonly userInvocable: boolean;
|
|
28
|
+
}[];
|
|
29
|
+
/** the loader's own reason per skipped entry — the words the model's warning line uses */
|
|
30
|
+
readonly broken: readonly { readonly dir: string; readonly reason: string }[];
|
|
31
|
+
body(name: string): { readonly body: string } | { readonly error: string };
|
|
32
|
+
}
|
|
15
33
|
|
|
16
34
|
declare const createSkillsExtension: () => SkillsExtension | Promise<SkillsExtension>;
|
|
17
35
|
export default createSkillsExtension;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-skills-ext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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.40.1",
|
|
27
27
|
"@types/node": "^26.1.2",
|
|
28
28
|
"typescript": "^5.7.2",
|
|
29
29
|
"vitest": "^3.0.0"
|