@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.
package/frockbot.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "schemaVersion": 3,
3
+ "id": "skills",
4
+ "displayName": "Skills",
5
+ "version": "0.0.1",
6
+ "compatibility": {
7
+ "frockbot": ">=0.0.1"
8
+ },
9
+ "contributions": {
10
+ "runtime": {
11
+ "entry": "./agent"
12
+ }
13
+ },
14
+ "permissions": ["skills:author"]
15
+ }
package/package.json CHANGED
@@ -1,14 +1,44 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-skills",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./agent": "./src/agent.ts",
9
+ "./catalog": "./src/catalog.ts",
10
+ "./frockbot.json": "./frockbot.json",
11
+ "./managed": "./src/managed.ts",
12
+ "./manifest": "./src/manifest.ts",
13
+ "./package.json": "./package.json",
14
+ "./plugin-index": "./src/plugin-index.ts",
15
+ "./quota": "./src/quota.ts",
16
+ "./skill-md": "./src/skill-md.ts",
17
+ "./testing": "./src/testing.ts",
18
+ "./write": "./src/write.ts"
19
+ },
20
+ "frockbot": {
21
+ "manifest": "./frockbot.json"
22
+ },
23
+ "scripts": {
24
+ "test": "bun test src",
25
+ "typecheck": "tsc --noEmit -p tsconfig.json"
26
+ },
27
+ "dependencies": {
28
+ "@frockbot/kernel-agent-loop": "0.1.1",
29
+ "@frockbot/kernel-contracts": "0.1.1",
30
+ "cordis": "4.0.0-rc.8"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "1.4.0",
34
+ "typescript": "^7.0.2"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
6
39
  "repository": {
7
40
  "type": "git",
8
41
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
42
  "directory": "packages/plugin-skills"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
43
  }
14
44
  }
@@ -0,0 +1,360 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SessionStore, type Session } from "@frockbot/kernel-contracts";
3
+ import { Context } from "cordis";
4
+ import {
5
+ createSkillLoadTool,
6
+ createSkillWriteTool,
7
+ openSkillTurnPositionV1,
8
+ SkillCatalog,
9
+ } from "./agent.ts";
10
+ import { botInstructionRootV1 } from "./catalog.ts";
11
+ import { FakeWorkspace, skillMarkdown } from "./testing.ts";
12
+
13
+ const OWNER = { userId: "user-1", botId: "bot-1" };
14
+ const OWN_ROOT = botInstructionRootV1(OWNER);
15
+ const WRITER = { sessionId: "user-1:bot-1", turnId: "turn-4", runId: "run-9" };
16
+ const BOT_WRITER = { kind: "bot" as const, botId: "bot-1", ...WRITER };
17
+
18
+ const CONTEXT = {
19
+ botId: "bot-1",
20
+ agentId: "bot-1",
21
+ sessionId: "user-1:bot-1",
22
+ compositionGenerationId: "2026-08-31T00:00:00.000Z:0123456789abcdef",
23
+ turnType: "chat" as const,
24
+ effectId: "tool:1:1:0",
25
+ signal: new AbortController().signal,
26
+ };
27
+
28
+ async function openSession(): Promise<{
29
+ session: Session;
30
+ sessions: { get(id: string): Session | undefined };
31
+ dispose(): Promise<void>;
32
+ }> {
33
+ const root = new Context();
34
+ await root.plugin(SessionStore);
35
+ const session = root.sessions.create("user-1:bot-1");
36
+ session.appendBatch([
37
+ { type: "turn/start", turn: 4 },
38
+ { type: "step/start", turn: 4, step: 2 },
39
+ ]);
40
+ return {
41
+ session,
42
+ sessions: root.sessions,
43
+ dispose: () => root.fiber.dispose(),
44
+ };
45
+ }
46
+
47
+ describe("the Skill catalog", () => {
48
+ test("records exactly what it injected on the Turn", async () => {
49
+ const workspace = await FakeWorkspace.seeded([
50
+ {
51
+ root: OWN_ROOT,
52
+ path: "skills/kept/SKILL.md",
53
+ text: skillMarkdown("kept", "Use this when keeping.", "Body."),
54
+ writer: BOT_WRITER,
55
+ },
56
+ {
57
+ root: OWN_ROOT,
58
+ path: "skills/refused/SKILL.md",
59
+ text: skillMarkdown("refused", "Use this never.", "Body."),
60
+ writer: { kind: "first-party", packageId: "memory" },
61
+ },
62
+ ]);
63
+ const { session, dispose } = await openSession();
64
+ const catalog = new SkillCatalog(OWNER, workspace);
65
+
66
+ await catalog.refresh(4, session);
67
+
68
+ const injected = session.events.find(
69
+ (event) => event.type === "skill/injected",
70
+ );
71
+ // Ordering is the catalog's: the Bot's own Skills, then the managed set
72
+ // this Package compiles in. Nothing else is installed in this fixture.
73
+ expect(injected).toMatchObject({
74
+ type: "skill/injected",
75
+ turn: 4,
76
+ skills: [
77
+ { path: "skills/kept/SKILL.md", name: "kept" },
78
+ { path: "managed/add-connector/SKILL.md" },
79
+ { path: "managed/export-bot-template/SKILL.md" },
80
+ { path: "managed/import-bot-template/SKILL.md" },
81
+ { path: "managed/learn-from-demonstration/SKILL.md" },
82
+ ],
83
+ });
84
+ expect(
85
+ injected?.type === "skill/injected" ? injected.refusals : [],
86
+ ).toHaveLength(1);
87
+ expect(
88
+ injected?.type === "skill/injected"
89
+ ? injected.skills[0]?.generationId
90
+ : undefined,
91
+ ).toBe(catalog.current().skills[0]?.generationId);
92
+ expect(catalog.loadedTurn()).toBe(4);
93
+ await dispose();
94
+ });
95
+ });
96
+
97
+ describe("the skill_load tool", () => {
98
+ test("discloses a loaded body and nothing else", async () => {
99
+ const workspace = await FakeWorkspace.seeded([
100
+ {
101
+ root: OWN_ROOT,
102
+ path: "skills/kept/SKILL.md",
103
+ text: skillMarkdown("kept", "Use this when keeping.", "Recipe body."),
104
+ writer: BOT_WRITER,
105
+ },
106
+ {
107
+ root: OWN_ROOT,
108
+ path: "skills/refused/SKILL.md",
109
+ text: skillMarkdown("refused", "Use this never.", "Forbidden body."),
110
+ writer: { kind: "user", userId: "user-2" },
111
+ },
112
+ ]);
113
+ const { session, dispose } = await openSession();
114
+ const catalog = new SkillCatalog(OWNER, workspace);
115
+ await catalog.refresh(4, session);
116
+ const tool = createSkillLoadTool(catalog);
117
+
118
+ const loaded = await tool.execute(
119
+ { path: "skills/kept/SKILL.md" },
120
+ CONTEXT,
121
+ );
122
+ expect(loaded.isError).toBe(false);
123
+ expect(loaded.content).toContain("Recipe body.");
124
+
125
+ const refused = await tool.execute(
126
+ { path: "skills/refused/SKILL.md" },
127
+ CONTEXT,
128
+ );
129
+ expect(refused.isError).toBe(true);
130
+ expect(refused.content).not.toContain("Forbidden body.");
131
+ await dispose();
132
+ });
133
+ });
134
+
135
+ describe("the skill_write tool", () => {
136
+ test("records intent, writes with Bot provenance, then records the generation", async () => {
137
+ const workspace = new FakeWorkspace();
138
+ const { session, sessions, dispose } = await openSession();
139
+ const tool = createSkillWriteTool(
140
+ { owner: OWNER, reads: workspace, files: workspace },
141
+ WRITER,
142
+ sessions,
143
+ );
144
+
145
+ const result = await tool.execute(
146
+ {
147
+ name: "Daily standup",
148
+ description: "Use this when assembling the weekday standup.",
149
+ body: "# Steps\n1. Ask.",
150
+ },
151
+ CONTEXT,
152
+ );
153
+
154
+ expect(result.isError).toBe(false);
155
+ const intent = session.events.find(
156
+ (event) => event.type === "skill/write-intent",
157
+ );
158
+ const written = session.events.find(
159
+ (event) => event.type === "skill/written",
160
+ );
161
+ expect(intent).toMatchObject({
162
+ turn: 4,
163
+ step: 2,
164
+ path: "skills/daily-standup/SKILL.md",
165
+ });
166
+ expect(written).toMatchObject({ path: "skills/daily-standup/SKILL.md" });
167
+ expect(intent!.seq).toBeLessThan(written!.seq);
168
+
169
+ const stored = await workspace.stat({
170
+ root: OWN_ROOT,
171
+ path: "skills/daily-standup/SKILL.md",
172
+ });
173
+ expect(stored.status).toBe("ok");
174
+ expect(
175
+ stored.status === "ok" ? stored.entry.generation.writer : undefined,
176
+ ).toEqual({ kind: "bot", botId: "bot-1", ...WRITER });
177
+
178
+ // The Skill it wrote is loadable on the next Turn, by its own authority.
179
+ const catalog = new SkillCatalog(OWNER, workspace);
180
+ await catalog.refresh(5, session);
181
+ expect(
182
+ catalog
183
+ .current()
184
+ .skills.filter((skill) => skill.ref?.source === "bot")
185
+ .map((skill) => skill.name),
186
+ ).toEqual(["Daily standup"]);
187
+ await dispose();
188
+ });
189
+
190
+ test("refuses a breach of the bounded per-Bot Skill quota, visibly", async () => {
191
+ const workspace = new FakeWorkspace();
192
+ const { sessions, dispose } = await openSession();
193
+ const tool = createSkillWriteTool(
194
+ {
195
+ owner: OWNER,
196
+ reads: workspace,
197
+ files: workspace,
198
+ quota: {
199
+ schemaVersion: 1,
200
+ maxSkillsPerBot: 1,
201
+ maxSkillsPerUser: 1,
202
+ maxSkillBytes: 65_536,
203
+ },
204
+ },
205
+ WRITER,
206
+ sessions,
207
+ );
208
+
209
+ const first = await tool.execute(
210
+ { name: "one", description: "Use this when first.", body: "Body." },
211
+ CONTEXT,
212
+ );
213
+ expect(first.isError).toBe(false);
214
+ const second = await tool.execute(
215
+ { name: "two", description: "Use this when second.", body: "Body." },
216
+ CONTEXT,
217
+ );
218
+ expect(second.isError).toBe(true);
219
+ expect(second.content).toContain("the quota allows 1");
220
+
221
+ const third = await tool.execute(
222
+ {
223
+ name: "one",
224
+ description: "Use this when superseding.",
225
+ body: "New body.",
226
+ },
227
+ CONTEXT,
228
+ );
229
+ // Superseding an existing Skill does not grow the root, so it is admitted.
230
+ expect(third.isError).toBe(false);
231
+ await dispose();
232
+ });
233
+
234
+ test("counts every page of the root, so the 201st Skill is refused", async () => {
235
+ const workspace = new FakeWorkspace();
236
+ // The store's default page is 100, so 200 Skills span more than one page.
237
+ // A single unpaged count would see 100 and admit the 201st forever.
238
+ workspace.listPageSize = 100;
239
+ for (let index = 0; index < 200; index += 1) {
240
+ const slug = `held-${String(index).padStart(3, "0")}`;
241
+ await workspace.seed({
242
+ root: OWN_ROOT,
243
+ path: `skills/${slug}/SKILL.md`,
244
+ text: skillMarkdown(slug, "Use this when counting.", "Body."),
245
+ writer: BOT_WRITER,
246
+ });
247
+ }
248
+ const { sessions, dispose } = await openSession();
249
+ const tool = createSkillWriteTool(
250
+ { owner: OWNER, reads: workspace, files: workspace },
251
+ WRITER,
252
+ sessions,
253
+ );
254
+
255
+ const result = await tool.execute(
256
+ {
257
+ name: "two hundred and one",
258
+ description: "Use this when exceeding.",
259
+ body: "Body.",
260
+ },
261
+ CONTEXT,
262
+ );
263
+
264
+ expect(result.isError).toBe(true);
265
+ expect(result.content).toContain("this Bot holds 200 Skills");
266
+ expect(
267
+ workspace.calls.some((call) =>
268
+ call.startsWith("write:skills/two-hundred-and-one/"),
269
+ ),
270
+ ).toBe(false);
271
+ await dispose();
272
+ });
273
+
274
+ test("refuses the write when the instruction root cannot be listed", async () => {
275
+ const workspace = new FakeWorkspace();
276
+ workspace.listFailure = {
277
+ status: "unavailable",
278
+ reason: "the bucket is unreachable",
279
+ };
280
+ const { session, sessions, dispose } = await openSession();
281
+ const tool = createSkillWriteTool(
282
+ { owner: OWNER, reads: workspace, files: workspace },
283
+ WRITER,
284
+ sessions,
285
+ );
286
+
287
+ const result = await tool.execute(
288
+ { name: "unbounded", description: "Use this when blind.", body: "Body." },
289
+ CONTEXT,
290
+ );
291
+
292
+ // An unreadable listing makes the quota unknowable, so the write is
293
+ // refused visibly rather than proceeding against a count of zero.
294
+ expect(result.isError).toBe(true);
295
+ expect(result.content).toContain("quota cannot be enforced");
296
+ expect(workspace.calls.some((call) => call.startsWith("write:"))).toBe(
297
+ false,
298
+ );
299
+ expect(
300
+ session.events.some((event) => event.type === "skill/write-intent"),
301
+ ).toBe(false);
302
+ await dispose();
303
+ });
304
+
305
+ test("refuses a name carrying control characters, before any write", async () => {
306
+ const workspace = new FakeWorkspace();
307
+ const { sessions, dispose } = await openSession();
308
+ const tool = createSkillWriteTool(
309
+ { owner: OWNER, reads: workspace, files: workspace },
310
+ WRITER,
311
+ sessions,
312
+ );
313
+
314
+ const result = await tool.execute(
315
+ {
316
+ name: "broken\nname: injected",
317
+ description: "Use this when breaking the frontmatter.",
318
+ body: "Body.",
319
+ },
320
+ CONTEXT,
321
+ );
322
+
323
+ expect(result.isError).toBe(true);
324
+ expect(result.content).toContain("newlines or control characters");
325
+ expect(workspace.calls).toEqual([]);
326
+ await dispose();
327
+ });
328
+
329
+ test("refuses input it cannot decode without touching the Workspace", async () => {
330
+ const workspace = new FakeWorkspace();
331
+ const { sessions, dispose } = await openSession();
332
+ const tool = createSkillWriteTool(
333
+ { owner: OWNER, reads: workspace, files: workspace },
334
+ WRITER,
335
+ sessions,
336
+ );
337
+ expect(tool.validate?.({ name: "a" })).toBe(false);
338
+ const result = await tool.execute({ name: "a", description: "b" }, CONTEXT);
339
+ expect(result.isError).toBe(true);
340
+ expect(workspace.calls).toEqual([]);
341
+ await dispose();
342
+ });
343
+ });
344
+
345
+ describe("the recorded step", () => {
346
+ test("refuses to record against a closed step", async () => {
347
+ const root = new Context();
348
+ await root.plugin(SessionStore);
349
+ const session = root.sessions.create("closed");
350
+ session.appendBatch([
351
+ { type: "turn/start", turn: 1 },
352
+ { type: "step/start", turn: 1, step: 1 },
353
+ { type: "step/end", turn: 1, step: 1, outcome: "completed" },
354
+ ]);
355
+ expect(() => openSkillTurnPositionV1(session)).toThrow(
356
+ "no open step to record against",
357
+ );
358
+ await root.fiber.dispose();
359
+ });
360
+ });