@frockbot/plugin-skills 0.0.0 → 0.1.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.
@@ -0,0 +1,364 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ WorkspaceReadsV1,
4
+ WorkspaceRootV1,
5
+ } from "@frockbot/kernel-contracts";
6
+ import {
7
+ botInstructionRootV1,
8
+ countSkillDocumentsV1,
9
+ loadSkillCatalogV1,
10
+ renderSkillCatalogPromptV1,
11
+ SKILL_MAX_CATALOG_ENTRIES,
12
+ } from "./catalog.js";
13
+ import { FakeWorkspace, skillMarkdown } from "./testing.js";
14
+
15
+ const OWNER = { userId: "user-1", botId: "bot-1" };
16
+ const OWN_ROOT = botInstructionRootV1(OWNER);
17
+ const OTHER_BOT_ROOT: WorkspaceRootV1 = {
18
+ kind: "bot-instructions",
19
+ userId: "user-1",
20
+ botId: "bot-2",
21
+ };
22
+ const MEMORY_ROOT: WorkspaceRootV1 = {
23
+ kind: "bot-memory",
24
+ userId: "user-1",
25
+ botId: "bot-1",
26
+ };
27
+
28
+ const BOT_WRITER = {
29
+ kind: "bot" as const,
30
+ botId: "bot-1",
31
+ sessionId: "session-1",
32
+ turnId: "turn-1",
33
+ runId: "run-1",
34
+ };
35
+ const USER_WRITER = { kind: "user" as const, userId: "user-1" };
36
+
37
+ describe("the Skills loader", () => {
38
+ test("loads only Skills the Bot or its User wrote under its own root", async () => {
39
+ const workspace = await FakeWorkspace.seeded([
40
+ {
41
+ root: OWN_ROOT,
42
+ path: "skills/bot-authored/SKILL.md",
43
+ text: skillMarkdown(
44
+ "bot-authored",
45
+ "Use this when the Bot wrote it.",
46
+ "Body.",
47
+ ),
48
+ writer: BOT_WRITER,
49
+ },
50
+ {
51
+ root: OWN_ROOT,
52
+ path: "skills/user-authored/SKILL.md",
53
+ text: skillMarkdown(
54
+ "user-authored",
55
+ "Use this when the User wrote it.",
56
+ "Body.",
57
+ ),
58
+ writer: USER_WRITER,
59
+ },
60
+ {
61
+ root: OWN_ROOT,
62
+ path: "skills/package-authored/SKILL.md",
63
+ text: skillMarkdown("package-authored", "Use this never.", "Body."),
64
+ writer: { kind: "first-party", packageId: "memory" },
65
+ },
66
+ {
67
+ root: OWN_ROOT,
68
+ path: "skills/other-bot-authored/SKILL.md",
69
+ text: skillMarkdown("other-bot", "Use this never.", "Body."),
70
+ writer: { ...BOT_WRITER, botId: "bot-2" },
71
+ },
72
+ {
73
+ root: OWN_ROOT,
74
+ path: "skills/other-user-authored/SKILL.md",
75
+ text: skillMarkdown("other-user", "Use this never.", "Body."),
76
+ writer: { kind: "user", userId: "user-2" },
77
+ },
78
+ ]);
79
+
80
+ const catalog = await loadSkillCatalogV1(workspace, OWNER);
81
+
82
+ expect(catalog.skills.map((skill) => skill.name)).toEqual([
83
+ "bot-authored",
84
+ "user-authored",
85
+ ]);
86
+ expect(
87
+ catalog.refusals.map((refusal) => [refusal.path, refusal.kind]),
88
+ ).toEqual([
89
+ ["skills/other-bot-authored/SKILL.md", "authority"],
90
+ ["skills/other-user-authored/SKILL.md", "authority"],
91
+ ["skills/package-authored/SKILL.md", "authority"],
92
+ ]);
93
+ // Every refused candidate was refused before its body was ever read.
94
+ expect(workspace.calls).not.toContain(
95
+ "read:skills/package-authored/SKILL.md",
96
+ );
97
+ });
98
+
99
+ // A Bot with a shell can drop a SKILL.md into any root the Computer can
100
+ // reach, going around the Workspace file surface entirely. Nothing records
101
+ // who wrote it, so it is listed as `unattributed` and refused here.
102
+ test("refuses a Skill whose writer was never recorded", async () => {
103
+ const workspace = await FakeWorkspace.seeded([
104
+ {
105
+ root: OWN_ROOT,
106
+ path: "skills/dropped-by-shell/SKILL.md",
107
+ text: skillMarkdown(
108
+ "dropped-by-shell",
109
+ "Use this never.",
110
+ "Body written by a shell command.",
111
+ ),
112
+ writer: { kind: "unattributed" },
113
+ },
114
+ {
115
+ root: OWN_ROOT,
116
+ path: "skills/bot-authored/SKILL.md",
117
+ text: skillMarkdown(
118
+ "bot-authored",
119
+ "Use this when the Bot wrote it.",
120
+ "Body.",
121
+ ),
122
+ writer: BOT_WRITER,
123
+ },
124
+ ]);
125
+
126
+ const catalog = await loadSkillCatalogV1(workspace, OWNER);
127
+
128
+ expect(catalog.skills.map((skill) => skill.name)).toEqual(["bot-authored"]);
129
+ expect(catalog.refusals).toHaveLength(1);
130
+ expect(catalog.refusals[0]?.path).toBe("skills/dropped-by-shell/SKILL.md");
131
+ expect(catalog.refusals[0]?.kind).toBe("authority");
132
+ expect(catalog.refusals[0]?.reason).toContain("no recorded writer");
133
+ // The refusal happened before the body was ever read.
134
+ expect(workspace.calls).not.toContain(
135
+ "read:skills/dropped-by-shell/SKILL.md",
136
+ );
137
+ });
138
+
139
+ test("never loads a file outside the instruction root, however it is named", async () => {
140
+ const workspace = await FakeWorkspace.seeded([
141
+ {
142
+ root: MEMORY_ROOT,
143
+ path: "skills/memory-shaped/SKILL.md",
144
+ text: skillMarkdown("memory-shaped", "Use this never.", "Body."),
145
+ writer: BOT_WRITER,
146
+ },
147
+ {
148
+ root: OTHER_BOT_ROOT,
149
+ path: "skills/other-root/SKILL.md",
150
+ text: skillMarkdown("other-root", "Use this never.", "Body."),
151
+ writer: BOT_WRITER,
152
+ },
153
+ ]);
154
+
155
+ // A Workspace that answered a listing of the Bot's root with foreign
156
+ // entries would still get nowhere: the predicate decides, not the caller.
157
+ const leaky = {
158
+ read: (path: Parameters<typeof workspace.read>[0]) =>
159
+ workspace.read(path),
160
+ stat: (path: Parameters<typeof workspace.stat>[0]) =>
161
+ workspace.stat(path),
162
+ list: () =>
163
+ workspace.list({ root: MEMORY_ROOT }).then((memory) =>
164
+ workspace.list({ root: OTHER_BOT_ROOT }).then((other) =>
165
+ memory.status === "ok" && other.status === "ok"
166
+ ? {
167
+ status: "ok" as const,
168
+ entries: [...memory.entries, ...other.entries],
169
+ }
170
+ : memory,
171
+ ),
172
+ ),
173
+ };
174
+
175
+ const catalog = await loadSkillCatalogV1(leaky, OWNER);
176
+ expect(catalog.skills).toEqual([]);
177
+ expect(catalog.refusals).toHaveLength(2);
178
+ expect(
179
+ catalog.refusals.every((refusal) => refusal.kind === "authority"),
180
+ ).toBe(true);
181
+ });
182
+
183
+ test("refuses a malformed Skill without failing the load", async () => {
184
+ const workspace = await FakeWorkspace.seeded([
185
+ {
186
+ root: OWN_ROOT,
187
+ path: "skills/broken/SKILL.md",
188
+ text: "# no frontmatter\n",
189
+ writer: BOT_WRITER,
190
+ },
191
+ {
192
+ root: OWN_ROOT,
193
+ path: "skills/good/SKILL.md",
194
+ text: skillMarkdown("good", "Use this when it parses.", "Body."),
195
+ writer: BOT_WRITER,
196
+ },
197
+ {
198
+ root: OWN_ROOT,
199
+ path: "notes.md",
200
+ text: "not a Skill",
201
+ writer: BOT_WRITER,
202
+ },
203
+ ]);
204
+
205
+ const catalog = await loadSkillCatalogV1(workspace, OWNER);
206
+ expect(catalog.skills.map((skill) => skill.name)).toEqual(["good"]);
207
+ expect(catalog.refusals).toEqual([
208
+ {
209
+ path: "skills/broken/SKILL.md",
210
+ kind: "malformed",
211
+ reason: "SKILL.md must open with a --- frontmatter fence",
212
+ },
213
+ ]);
214
+ });
215
+
216
+ test("an unreadable instruction root yields no instructions and says so", async () => {
217
+ const workspace = await FakeWorkspace.seeded([]);
218
+ workspace.listFailure = {
219
+ status: "unavailable",
220
+ reason: "the durable root is not synchronized",
221
+ };
222
+ const catalog = await loadSkillCatalogV1(workspace, OWNER);
223
+ expect(catalog.skills).toEqual([]);
224
+ expect(catalog.refusals[0]?.kind).toBe("unreadable");
225
+ });
226
+
227
+ test("bounds the catalog", async () => {
228
+ const workspace = new FakeWorkspace();
229
+ for (let index = 0; index < 4; index += 1) {
230
+ await workspace.seed({
231
+ root: OWN_ROOT,
232
+ path: `skills/s${index}/SKILL.md`,
233
+ text: skillMarkdown(`s${index}`, "Use this when counting.", "Body."),
234
+ writer: BOT_WRITER,
235
+ });
236
+ }
237
+ const catalog = await loadSkillCatalogV1(workspace, OWNER, {
238
+ maxSkills: 2,
239
+ });
240
+ expect(catalog.skills).toHaveLength(2);
241
+ expect(catalog.refusals.map((refusal) => refusal.kind)).toEqual([
242
+ "over-catalog",
243
+ "over-catalog",
244
+ ]);
245
+ expect(SKILL_MAX_CATALOG_ENTRIES).toBe(200);
246
+ });
247
+
248
+ test("renders the catalog as a progressive-disclosure prompt block", async () => {
249
+ const workspace = await FakeWorkspace.seeded([
250
+ {
251
+ root: OWN_ROOT,
252
+ path: "skills/standup/SKILL.md",
253
+ text: skillMarkdown(
254
+ "Daily standup",
255
+ "Use this when assembling the <weekday> standup.",
256
+ "Secret body text.",
257
+ ),
258
+ writer: USER_WRITER,
259
+ },
260
+ ]);
261
+ const rendered = renderSkillCatalogPromptV1(
262
+ await loadSkillCatalogV1(workspace, OWNER),
263
+ );
264
+ expect(rendered).toContain("<agent_skills>");
265
+ expect(rendered).toContain(
266
+ '<skill name="Daily standup" source="bot" ref="bot/standup" path="skills/standup/SKILL.md" by="your User">Use this when assembling the &lt;weekday&gt; standup.</skill>',
267
+ );
268
+ expect(rendered).toContain("Mentioning a Skill is not running it.");
269
+ // Progressive disclosure: the body is never in the prompt.
270
+ expect(rendered).not.toContain("Secret body text.");
271
+ expect(
272
+ renderSkillCatalogPromptV1({ owner: OWNER, skills: [], refusals: [] }),
273
+ ).toBe("");
274
+ });
275
+ });
276
+
277
+ describe("counting a root against the Skill quota", () => {
278
+ test("counts Skills, not the files that sit beside them", async () => {
279
+ const workspace = new FakeWorkspace();
280
+ for (let index = 0; index < 250; index += 1) {
281
+ await workspace.seed({
282
+ root: OWN_ROOT,
283
+ path: `notes/note-${String(index).padStart(3, "0")}.md`,
284
+ text: "Not a Skill.",
285
+ writer: BOT_WRITER,
286
+ });
287
+ }
288
+ for (let index = 0; index < 5; index += 1) {
289
+ await workspace.seed({
290
+ root: OWN_ROOT,
291
+ path: `skills/s${index}/SKILL.md`,
292
+ text: skillMarkdown(`s${index}`, "Use this when counting.", "Body."),
293
+ writer: BOT_WRITER,
294
+ });
295
+ }
296
+
297
+ const counted = await countSkillDocumentsV1(workspace, OWN_ROOT);
298
+
299
+ expect(counted).toEqual({ status: "ok", count: 5 });
300
+ // Walked with the store's own cursor: one listing, three pages of 100.
301
+ expect(
302
+ workspace.calls.filter((call) => call.startsWith("list:")),
303
+ ).toHaveLength(3);
304
+ });
305
+
306
+ test("the walk is bounded by Skills, so a huge root is still countable", async () => {
307
+ // A root with far more files than the page bound can walk, holding more
308
+ // Skills than the quota allows. The quota's question is already answered
309
+ // once the count passes the cap, so the walk stops rather than reporting
310
+ // a root it could not finish as uncountable.
311
+ const endless: WorkspaceReadsV1 = {
312
+ read: () =>
313
+ Promise.resolve({ status: "not-found", reason: "unused in this test" }),
314
+ stat: () =>
315
+ Promise.resolve({ status: "not-found", reason: "unused in this test" }),
316
+ list: (request) => {
317
+ const page = Number(request.cursor ?? "0");
318
+ return Promise.resolve({
319
+ status: "ok",
320
+ entries: [
321
+ {
322
+ path: { root: OWN_ROOT, path: `skills/s${page}/SKILL.md` },
323
+ generation: {
324
+ schemaVersion: 1 as const,
325
+ generationId: `${String(page).padStart(9, "0")}`,
326
+ contentHash: "0".repeat(64),
327
+ size: 1,
328
+ writer: BOT_WRITER,
329
+ writtenAt: new Date(0).toISOString(),
330
+ },
331
+ },
332
+ ...Array.from({ length: 99 }, (_, index) => ({
333
+ path: {
334
+ root: OWN_ROOT,
335
+ path: `notes/${page}-${index}.md`,
336
+ },
337
+ generation: {
338
+ schemaVersion: 1 as const,
339
+ generationId: `${String(page).padStart(9, "0")}-${index}`,
340
+ contentHash: "0".repeat(64),
341
+ size: 1,
342
+ writer: BOT_WRITER,
343
+ writtenAt: new Date(0).toISOString(),
344
+ },
345
+ })),
346
+ ],
347
+ cursor: String(page + 1),
348
+ });
349
+ },
350
+ };
351
+
352
+ const counted = await countSkillDocumentsV1(endless, OWN_ROOT, {
353
+ stopAfter: 200,
354
+ });
355
+
356
+ expect(counted.status).toBe("ok");
357
+ if (counted.status !== "ok") return;
358
+ expect(counted.count).toBeGreaterThan(200);
359
+ // Without a cap the same root is uncountable, which is the honest answer.
360
+ expect(await countSkillDocumentsV1(endless, OWN_ROOT)).toMatchObject({
361
+ status: "unavailable",
362
+ });
363
+ });
364
+ });