@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 +15 -0
- package/package.json +36 -6
- package/src/agent.test.ts +360 -0
- package/src/agent.ts +678 -0
- package/src/catalog.test.ts +364 -0
- package/src/catalog.ts +704 -0
- package/src/index.ts +8 -0
- package/src/managed.ts +233 -0
- package/src/manifest.ts +3 -0
- package/src/plugin-index.ts +194 -0
- package/src/quota.ts +159 -0
- package/src/skill-md.test.ts +98 -0
- package/src/skill-md.ts +163 -0
- package/src/sources.test.ts +760 -0
- package/src/testing.ts +175 -0
- package/src/write.ts +181 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./agent.js";
|
|
2
|
+
export * from "./catalog.js";
|
|
3
|
+
export * from "./managed.js";
|
|
4
|
+
export * from "./plugin-index.js";
|
|
5
|
+
export { default as skillsManifest } from "./manifest.js";
|
|
6
|
+
export * from "./quota.js";
|
|
7
|
+
export * from "./skill-md.js";
|
|
8
|
+
export * from "./testing.js";
|
package/src/managed.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// The managed Skills: first-party recipes compiled into this Package.
|
|
2
|
+
//
|
|
3
|
+
// PARITY. GrokBot ships four read-only skills under
|
|
4
|
+
// `managed-skills/skills/<slug>/SKILL.md` — `add-connector`,
|
|
5
|
+
// `export-bot-template`, `import-bot-template`, `learn-from-demonstration` —
|
|
6
|
+
// which `update_state` cannot edit (`docs/research/grokbot-computer.md` lines
|
|
7
|
+
// 73-74, 284-285). The four slugs and the shape of what each one is for are
|
|
8
|
+
// mirrored here; every body is written for FrockBot's own tools, because a
|
|
9
|
+
// recipe that names another product's tools is not a recipe.
|
|
10
|
+
//
|
|
11
|
+
// WHERE THEY LIVE, AND WHY IT IS NOT A ROOT. These are string constants in
|
|
12
|
+
// this module, so they are bytes of the `plugin-skills` artifact and nothing
|
|
13
|
+
// else. That is the whole design: "the kernel treats every Workspace file as
|
|
14
|
+
// data. Only Skills under the Bot's own instruction root, written under the
|
|
15
|
+
// Bot's own authority or its User's, are loaded as instructions" stays exactly
|
|
16
|
+
// true, because a managed Skill is not a Workspace file at all. It is a
|
|
17
|
+
// Package contributing prompt content — which the constitution already
|
|
18
|
+
// permits, and which the Composition already pins: the Turn's
|
|
19
|
+
// `CompositionPinV1.artifactSetHash` covers the artifact these bytes live in,
|
|
20
|
+
// so the reconstructed prompt is exact without any second store to consult.
|
|
21
|
+
//
|
|
22
|
+
// READ-ONLY follows from the same fact. There is no path from `skill_write` to
|
|
23
|
+
// an artifact, so `scope: "managed"` is refused rather than routed anywhere.
|
|
24
|
+
import {
|
|
25
|
+
parseSkillDocumentV1,
|
|
26
|
+
SKILL_FILE_NAME,
|
|
27
|
+
isSkillSlugV1,
|
|
28
|
+
} from "./skill-md.js";
|
|
29
|
+
import type { LoadedSkillV1, SkillRefusalV1 } from "./catalog.js";
|
|
30
|
+
|
|
31
|
+
/** The directory prefix a managed Skill's synthetic path carries. */
|
|
32
|
+
export const MANAGED_SKILL_PATH_PREFIX = "managed";
|
|
33
|
+
|
|
34
|
+
/** Who a managed Skill is attributed to in the rendered catalog. */
|
|
35
|
+
export const MANAGED_SKILL_ATTRIBUTION = "FrockBot";
|
|
36
|
+
|
|
37
|
+
/** One bundled `SKILL.md`, exactly as it would sit on disk. */
|
|
38
|
+
export interface ManagedSkillDocumentV1 {
|
|
39
|
+
slug: string;
|
|
40
|
+
text: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ADD_CONNECTOR = `---
|
|
44
|
+
name: Add connector
|
|
45
|
+
description: Use this when the User wants to connect an app, MCP server, or model provider that this Bot cannot already reach.
|
|
46
|
+
---
|
|
47
|
+
# Add a connector
|
|
48
|
+
|
|
49
|
+
You cannot install a Package or create a Connection yourself: both are your
|
|
50
|
+
User's acts, in their own settings. What you can do is find the right entry and
|
|
51
|
+
tell them precisely what to do.
|
|
52
|
+
|
|
53
|
+
1. Name the gap. Say which tool you looked for and did not find, so the User
|
|
54
|
+
knows what installing this changes about what you can do.
|
|
55
|
+
2. Look in the Package Catalog for an entry that covers it. An entry that is
|
|
56
|
+
already installed shows in your prompt as the Package it contributes; one
|
|
57
|
+
that is not will not be there at all.
|
|
58
|
+
3. Report to the User with \`send_to_user\`: the entry's display name, what it
|
|
59
|
+
would let you do, and the single sentence "Install it under Settings →
|
|
60
|
+
Plugins, then assign it to me."
|
|
61
|
+
4. If the entry needs an API key or an OAuth sign-in, say so before they start,
|
|
62
|
+
and say what the key is for. Never ask the User to paste a secret into the
|
|
63
|
+
conversation: a Connection's credentials belong in the Connection, never in
|
|
64
|
+
a Turn's transcript, and never in your Memory.
|
|
65
|
+
5. Stop there and wait. Do not retry the missing tool in a loop; the install
|
|
66
|
+
becomes visible to you on a later Turn, not this one.
|
|
67
|
+
|
|
68
|
+
If the User asks you to do it for them, say plainly that you cannot, and why:
|
|
69
|
+
installing a Package widens what you are allowed to do, and self-modification
|
|
70
|
+
never widens your own authority.
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
const EXPORT_BOT_TEMPLATE = `---
|
|
74
|
+
name: Export bot template
|
|
75
|
+
description: Use this when the User wants to reuse this Bot's setup for another Bot, or to keep a record of how it is configured.
|
|
76
|
+
---
|
|
77
|
+
# Export a bot template
|
|
78
|
+
|
|
79
|
+
A template is a written description of a Bot's setup, not a file format and not
|
|
80
|
+
a copy of anything secret.
|
|
81
|
+
|
|
82
|
+
1. Gather what actually defines this Bot: its name and description, the
|
|
83
|
+
Packages its behaviour depends on, the Capability Assignments it runs under,
|
|
84
|
+
the model it is assigned, and the Skills under its own instruction root.
|
|
85
|
+
2. Read each Skill you intend to include with \`skill_load\` before you describe
|
|
86
|
+
it. Describing a Skill from its catalog line alone is describing a name.
|
|
87
|
+
3. Write the template as Markdown, in this order: Identity, Packages,
|
|
88
|
+
Assignments, Model, Skills, Notes. Under Skills, give each Skill's slug,
|
|
89
|
+
name, description, and full body — that is what makes the template
|
|
90
|
+
importable.
|
|
91
|
+
4. Exclude every credential. Connection ids, API keys, OAuth tokens, and
|
|
92
|
+
account identifiers are the User's, not the template's. Name the *kind* of
|
|
93
|
+
Connection each Assignment needs and stop there.
|
|
94
|
+
5. Hand the template to the User with \`send_to_user\`. If they want it kept,
|
|
95
|
+
write it into your own Memory with \`memory_write\`, not into a Skill: a
|
|
96
|
+
template is a record, and a Skill is a recipe.
|
|
97
|
+
|
|
98
|
+
Say explicitly which parts of the setup a template cannot carry — Connections
|
|
99
|
+
and any grant the User made — so nobody expects an import to reproduce them.
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
const IMPORT_BOT_TEMPLATE = `---
|
|
103
|
+
name: Import bot template
|
|
104
|
+
description: Use this when the User gives you a bot template and wants a Bot set up from it.
|
|
105
|
+
---
|
|
106
|
+
# Import a bot template
|
|
107
|
+
|
|
108
|
+
1. Read the template through once before you change anything, and say back what
|
|
109
|
+
it will produce: a Bot's name, its Skills, and what it will still be missing.
|
|
110
|
+
2. Decide where it lands. If the template is for a new Bot, use \`bot_create\` to
|
|
111
|
+
add one to your User's flock; it starts with no Assignments and follows your
|
|
112
|
+
User's default model, exactly as one the User creates in the sidebar does.
|
|
113
|
+
If it is for you, use \`bot_update\` for the identity fields and continue.
|
|
114
|
+
3. Recreate the Skills you can. For each Skill in the template, call
|
|
115
|
+
\`skill_write\` with its name, description, body, and slug. They land under
|
|
116
|
+
your own instruction root and become visible on your next Turn, not this
|
|
117
|
+
one, so do not try to run one immediately after writing it.
|
|
118
|
+
4. Stop at every grant. Packages, Capability Assignments, Connections, and
|
|
119
|
+
model choices are your User's to make. List each one the template needs and
|
|
120
|
+
ask for it with \`send_to_user\`; do not attempt a workaround that reaches
|
|
121
|
+
the same capability by another route.
|
|
122
|
+
5. Report what was created, what was skipped, and what the User still has to
|
|
123
|
+
do. A half-imported template that reads as finished is worse than one that
|
|
124
|
+
names its gaps.
|
|
125
|
+
|
|
126
|
+
If the template names a Skill for another Bot, you cannot write it there. Say
|
|
127
|
+
so rather than writing it to yourself under a changed name.
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
const LEARN_FROM_DEMONSTRATION = `---
|
|
131
|
+
name: Learn from demonstration
|
|
132
|
+
description: Use this when the User has walked you through a task and wants you to be able to repeat it.
|
|
133
|
+
---
|
|
134
|
+
# Turn a demonstration into a Skill
|
|
135
|
+
|
|
136
|
+
The demonstration is whatever the User just showed you: a transcript, a series
|
|
137
|
+
of steps they narrated, or a run you performed together. Your job is to turn it
|
|
138
|
+
into a recipe you can follow later without them.
|
|
139
|
+
|
|
140
|
+
1. Recover the actual sequence. Read back over this conversation, and use
|
|
141
|
+
\`memory_search\` for anything the User told you earlier that the steps
|
|
142
|
+
depend on. Do not invent a step you did not see.
|
|
143
|
+
2. Separate the recipe from the instance. Names, dates, ids, and amounts from
|
|
144
|
+
the demonstration are examples, not the Skill. Replace each one with what it
|
|
145
|
+
was an example *of*.
|
|
146
|
+
3. Check it against what you can actually do. A step that needs a tool you do
|
|
147
|
+
not have is a step the Skill must ask the User for, not one to write as
|
|
148
|
+
though it will work.
|
|
149
|
+
4. Write it with \`skill_write\`: a short name, a description that starts "Use
|
|
150
|
+
this when …" so your future self can tell from the catalog line alone
|
|
151
|
+
whether it applies, and a numbered body. Keep the body under a page.
|
|
152
|
+
5. Confirm with the User: give the slug, the description, and the steps, and
|
|
153
|
+
ask whether anything is wrong. A Skill is an instruction you wrote for
|
|
154
|
+
yourself, so a wrong one is a durable mistake.
|
|
155
|
+
|
|
156
|
+
The Skill is visible to you on your next Turn, not this one. Do not claim to
|
|
157
|
+
have run it in the Turn that wrote it — mentioning a Skill is not running it.
|
|
158
|
+
`;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The bundled documents, in slug order. Ordering is fixed here rather than
|
|
162
|
+
* sorted later so the catalog a Turn assembles is the same on every host.
|
|
163
|
+
*/
|
|
164
|
+
export const MANAGED_SKILL_DOCUMENTS_V1: readonly ManagedSkillDocumentV1[] = [
|
|
165
|
+
{ slug: "add-connector", text: ADD_CONNECTOR },
|
|
166
|
+
{ slug: "export-bot-template", text: EXPORT_BOT_TEMPLATE },
|
|
167
|
+
{ slug: "import-bot-template", text: IMPORT_BOT_TEMPLATE },
|
|
168
|
+
{ slug: "learn-from-demonstration", text: LEARN_FROM_DEMONSTRATION },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
/** The synthetic path a managed Skill is listed and loadable under. */
|
|
172
|
+
export function managedSkillPathV1(slug: string): string {
|
|
173
|
+
return `${MANAGED_SKILL_PATH_PREFIX}/${slug}/${SKILL_FILE_NAME}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function sha256Hex(text: string): Promise<string> {
|
|
177
|
+
const digest = await crypto.subtle.digest(
|
|
178
|
+
"SHA-256",
|
|
179
|
+
new TextEncoder().encode(text),
|
|
180
|
+
);
|
|
181
|
+
return [...new Uint8Array(digest)]
|
|
182
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
183
|
+
.join("");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Parses the bundled documents into loaded Skills.
|
|
188
|
+
*
|
|
189
|
+
* A malformed bundled document is a recorded refusal, never a throw. The
|
|
190
|
+
* bodies here are first-party and reviewed, but a Turn that dies because one
|
|
191
|
+
* of them was mis-edited would take the Bot's whole prompt with it, and a
|
|
192
|
+
* loader with two failure modes has one too many: every other source in this
|
|
193
|
+
* catalog answers a bad document with a refusal, and so does this one.
|
|
194
|
+
*
|
|
195
|
+
* The generation of a managed Skill is its content hash. There is no mutable
|
|
196
|
+
* store to version it against — the bytes are the artifact's — so the hash is
|
|
197
|
+
* the only honest name for "which one this Turn used", and the artifact set
|
|
198
|
+
* hash the Composition pins is what makes that name reproducible.
|
|
199
|
+
*/
|
|
200
|
+
export async function loadManagedSkillsV1(
|
|
201
|
+
documents: readonly ManagedSkillDocumentV1[] = MANAGED_SKILL_DOCUMENTS_V1,
|
|
202
|
+
): Promise<{ skills: LoadedSkillV1[]; refusals: SkillRefusalV1[] }> {
|
|
203
|
+
const skills: LoadedSkillV1[] = [];
|
|
204
|
+
const refusals: SkillRefusalV1[] = [];
|
|
205
|
+
for (const document of documents) {
|
|
206
|
+
const path = managedSkillPathV1(document.slug);
|
|
207
|
+
if (!isSkillSlugV1(document.slug)) {
|
|
208
|
+
refusals.push({
|
|
209
|
+
path,
|
|
210
|
+
kind: "malformed",
|
|
211
|
+
reason: `the managed Skill slug "${document.slug}" is not a well-formed slug`,
|
|
212
|
+
});
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const parsed = parseSkillDocumentV1(document.text);
|
|
216
|
+
if (parsed.status !== "ok") {
|
|
217
|
+
refusals.push({ path, kind: "malformed", reason: parsed.reason });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const contentHash = await sha256Hex(document.text);
|
|
221
|
+
skills.push({
|
|
222
|
+
path,
|
|
223
|
+
ref: { schemaVersion: 1, source: "managed", slug: document.slug },
|
|
224
|
+
by: MANAGED_SKILL_ATTRIBUTION,
|
|
225
|
+
name: parsed.document.name,
|
|
226
|
+
description: parsed.document.description,
|
|
227
|
+
body: parsed.document.body,
|
|
228
|
+
generationId: contentHash,
|
|
229
|
+
contentHash,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return { skills, refusals };
|
|
233
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Plugin-borne Skills: an index, never a copy.
|
|
2
|
+
//
|
|
3
|
+
// PARITY. GrokBot's `plugin-skills/cache.json` "is only an *index* of skills
|
|
4
|
+
// that arrived with installed plugins, mapping pluginId → name → filePath into
|
|
5
|
+
// `plugins/cache/…` — no `SKILL.md` lives there"
|
|
6
|
+
// (`docs/research/grokbot-computer.md` lines 74, 285-286). This module is that
|
|
7
|
+
// index. A Skill contributed by an installed Package is read from the Catalog
|
|
8
|
+
// entry at the generation the User is pinned to, and is never written into any
|
|
9
|
+
// instruction root: nothing is copied, so nothing can go stale, and an
|
|
10
|
+
// uninstall removes the Skill by removing the installation it was indexed
|
|
11
|
+
// from — visible on the Bot's next admitted Turn, like every other Skill edit.
|
|
12
|
+
//
|
|
13
|
+
// AUTHORITY. A plugin Skill is not a Workspace file and therefore never meets
|
|
14
|
+
// `isLoadableSkillSourceV1`. It is a Package contributing prompt content,
|
|
15
|
+
// admitted by an explicit act of the User — the install — and pinned to one
|
|
16
|
+
// immutable Catalog generation. That is the same standing a Package's tools
|
|
17
|
+
// have, and it is why the constitution's rule about instruction roots is
|
|
18
|
+
// untouched by this file.
|
|
19
|
+
//
|
|
20
|
+
// SEAM. This module names no Catalog type and reaches no bucket. The host
|
|
21
|
+
// supplies an already-read index through `PluginSkillsSourceV1`, so the Package
|
|
22
|
+
// stays testable with a plain object and the Cloudflare adapter keeps the R2
|
|
23
|
+
// read where the other object-storage reads live.
|
|
24
|
+
import {
|
|
25
|
+
parseSkillDocumentV1,
|
|
26
|
+
renderSkillDocumentV1,
|
|
27
|
+
skillSlugFromNameV1,
|
|
28
|
+
} from "./skill-md.js";
|
|
29
|
+
import type { LoadedSkillV1, SkillRefusalV1 } from "./catalog.js";
|
|
30
|
+
|
|
31
|
+
/** The directory prefix a plugin Skill's synthetic path carries. */
|
|
32
|
+
export const PLUGIN_SKILL_PATH_PREFIX = "plugin";
|
|
33
|
+
|
|
34
|
+
/** One Skill as a Catalog entry declares it. */
|
|
35
|
+
export interface PluginSkillDeclarationV1 {
|
|
36
|
+
name: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
/** The Markdown recipe. Absent means the entry announced a Skill it does not ship. */
|
|
39
|
+
body?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One installed Package's Skills, at the generation the User is pinned to. */
|
|
43
|
+
export interface PluginSkillPackageV1 {
|
|
44
|
+
packageId: string;
|
|
45
|
+
catalogId: string;
|
|
46
|
+
/** The immutable Catalog generation the declarations were read from. */
|
|
47
|
+
generation: string;
|
|
48
|
+
skills: readonly PluginSkillDeclarationV1[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What reading the index produced. `unavailable` is a declared answer, not a
|
|
53
|
+
* throw: a Catalog that cannot be read yields no plugin Skills and says so in
|
|
54
|
+
* `skill/injected.refusals`, rather than failing the Turn.
|
|
55
|
+
*/
|
|
56
|
+
export type PluginSkillsOutcomeV1 =
|
|
57
|
+
| { status: "ok"; packages: readonly PluginSkillPackageV1[] }
|
|
58
|
+
| { status: "unavailable"; reason: string };
|
|
59
|
+
|
|
60
|
+
/** The host seam: the User's installed Catalog entries, read once per Turn. */
|
|
61
|
+
export interface PluginSkillsSourceV1 {
|
|
62
|
+
read(): Promise<PluginSkillsOutcomeV1>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The synthetic path a plugin Skill is listed and loadable under. */
|
|
66
|
+
export function pluginSkillPathV1(packageId: string, slug: string): string {
|
|
67
|
+
return `${PLUGIN_SKILL_PATH_PREFIX}/${packageId}/${slug}/SKILL.md`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PACKAGE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
71
|
+
|
|
72
|
+
async function sha256Hex(text: string): Promise<string> {
|
|
73
|
+
const digest = await crypto.subtle.digest(
|
|
74
|
+
"SHA-256",
|
|
75
|
+
new TextEncoder().encode(text),
|
|
76
|
+
);
|
|
77
|
+
return [...new Uint8Array(digest)]
|
|
78
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
79
|
+
.join("");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Builds the plugin-borne half of a Turn's Skill catalog.
|
|
84
|
+
*
|
|
85
|
+
* Every declaration is re-rendered as a `SKILL.md` and parsed by this
|
|
86
|
+
* Package's own parser before it is admitted. The Catalog decoder already
|
|
87
|
+
* bounded the strings; parsing them here is what makes a plugin Skill *the
|
|
88
|
+
* same kind of thing* as every other Skill — same name and description bounds,
|
|
89
|
+
* same refusal for a body that is empty — rather than a second format that
|
|
90
|
+
* happens to reach the same prompt.
|
|
91
|
+
*/
|
|
92
|
+
export async function loadPluginSkillsV1(
|
|
93
|
+
source: PluginSkillsSourceV1,
|
|
94
|
+
): Promise<{ skills: LoadedSkillV1[]; refusals: SkillRefusalV1[] }> {
|
|
95
|
+
const skills: LoadedSkillV1[] = [];
|
|
96
|
+
const refusals: SkillRefusalV1[] = [];
|
|
97
|
+
const outcome = await source.read();
|
|
98
|
+
if (outcome.status !== "ok") {
|
|
99
|
+
refusals.push({
|
|
100
|
+
path: PLUGIN_SKILL_PATH_PREFIX,
|
|
101
|
+
kind: "unreadable",
|
|
102
|
+
reason: `the installed Packages' Skills could not be indexed: ${outcome.reason}`,
|
|
103
|
+
});
|
|
104
|
+
return { skills, refusals };
|
|
105
|
+
}
|
|
106
|
+
const ordered = [...outcome.packages].sort((left, right) =>
|
|
107
|
+
left.packageId.localeCompare(right.packageId),
|
|
108
|
+
);
|
|
109
|
+
for (const installed of ordered) {
|
|
110
|
+
if (!PACKAGE_ID_PATTERN.test(installed.packageId)) {
|
|
111
|
+
refusals.push({
|
|
112
|
+
path: `${PLUGIN_SKILL_PATH_PREFIX}/${installed.packageId}`,
|
|
113
|
+
kind: "malformed",
|
|
114
|
+
reason: `Package id "${installed.packageId}" cannot name a Skill ref`,
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
for (const declared of installed.skills) {
|
|
120
|
+
const slug = skillSlugFromNameV1(declared.name);
|
|
121
|
+
const path = pluginSkillPathV1(
|
|
122
|
+
installed.packageId,
|
|
123
|
+
slug ?? declared.name,
|
|
124
|
+
);
|
|
125
|
+
if (!slug) {
|
|
126
|
+
refusals.push({
|
|
127
|
+
path,
|
|
128
|
+
kind: "malformed",
|
|
129
|
+
reason: `the Skill name "${declared.name}" yields no usable slug`,
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (seen.has(slug)) {
|
|
134
|
+
// Two declarations reducing to one slug would be one ref naming two
|
|
135
|
+
// bodies. The first wins and the second is recorded, rather than
|
|
136
|
+
// silently shadowing it.
|
|
137
|
+
refusals.push({
|
|
138
|
+
path,
|
|
139
|
+
kind: "malformed",
|
|
140
|
+
reason: `Package "${installed.packageId}" declares "${slug}" more than once`,
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
seen.add(slug);
|
|
145
|
+
if (declared.body === undefined) {
|
|
146
|
+
refusals.push({
|
|
147
|
+
path,
|
|
148
|
+
kind: "malformed",
|
|
149
|
+
reason: "the Catalog entry lists this Skill without a body",
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (declared.description === undefined) {
|
|
154
|
+
// The description is the only part of a Skill always in the prompt, so
|
|
155
|
+
// a Skill without one is a name the Bot can never decide to use.
|
|
156
|
+
refusals.push({
|
|
157
|
+
path,
|
|
158
|
+
kind: "malformed",
|
|
159
|
+
reason: "the Catalog entry lists this Skill without a description",
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const text = renderSkillDocumentV1({
|
|
164
|
+
name: declared.name,
|
|
165
|
+
description: declared.description,
|
|
166
|
+
body: declared.body,
|
|
167
|
+
});
|
|
168
|
+
const parsed = parseSkillDocumentV1(text);
|
|
169
|
+
if (parsed.status !== "ok") {
|
|
170
|
+
refusals.push({ path, kind: "malformed", reason: parsed.reason });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
skills.push({
|
|
174
|
+
path,
|
|
175
|
+
ref: {
|
|
176
|
+
schemaVersion: 1,
|
|
177
|
+
source: "plugin",
|
|
178
|
+
slug,
|
|
179
|
+
packageId: installed.packageId,
|
|
180
|
+
},
|
|
181
|
+
by: `Package "${installed.packageId}"`,
|
|
182
|
+
name: parsed.document.name,
|
|
183
|
+
description: parsed.document.description,
|
|
184
|
+
body: parsed.document.body,
|
|
185
|
+
// The generation is the pinned Catalog generation the body was read
|
|
186
|
+
// from, so `skill/injected` names an immutable, content-addressed set
|
|
187
|
+
// of objects rather than "whatever the pointer said".
|
|
188
|
+
generationId: `catalog:${installed.generation}`,
|
|
189
|
+
contentHash: await sha256Hex(text),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { skills, refusals };
|
|
194
|
+
}
|
package/src/quota.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The bounded per-User Skill quota.
|
|
2
|
+
//
|
|
3
|
+
// "Generation creation rate, artifact size, retained generations, Workspace
|
|
4
|
+
// disk ... are bounded by durable per-User quotas; exceeding a quota refuses
|
|
5
|
+
// the operation and records a visible failure."
|
|
6
|
+
//
|
|
7
|
+
// `plugin-authoring`'s `AuthoringQuotaConfigV1` does not fit: its three limits
|
|
8
|
+
// are a Package's source size, a Bot's retained *Composition* generations, and
|
|
9
|
+
// a daily authored-generation rate reserved in the User Durable Object against
|
|
10
|
+
// an authoring `effectId`. A Skill produces no Composition generation and no
|
|
11
|
+
// artifact, so two of the three limits have no meaning here, and reserving a
|
|
12
|
+
// daily unit for an edit to a Markdown file would refuse the Bot's own
|
|
13
|
+
// instruction root for the rest of the day. What bounds a Skill is Workspace
|
|
14
|
+
// disk: how many Skills a root keeps and how large each one may be. Those are
|
|
15
|
+
// the limits declared here, and they are checked against what the instruction
|
|
16
|
+
// root already holds rather than against a durable counter, so a resumed Turn
|
|
17
|
+
// that rewrites the same Skill consumes nothing.
|
|
18
|
+
//
|
|
19
|
+
// The count limit is per root, not per Bot: a Bot's own instruction root and
|
|
20
|
+
// the User-global root its User's Bots share are counted separately, because a
|
|
21
|
+
// quota bounds one root's growth and a shared root has a different population
|
|
22
|
+
// of writers.
|
|
23
|
+
//
|
|
24
|
+
// The limits live in the Package, not in the User Durable Object, until the
|
|
25
|
+
// durable-root sync of ADR 0013 exists to make "Workspace disk" measurable;
|
|
26
|
+
// `docs/plans/slice-2.md` Step 2 records that as the open half.
|
|
27
|
+
|
|
28
|
+
export interface SkillQuotaConfigV1 {
|
|
29
|
+
schemaVersion: 1;
|
|
30
|
+
/** Most Skills one Bot's own instruction root may hold. */
|
|
31
|
+
maxSkillsPerBot: number;
|
|
32
|
+
/**
|
|
33
|
+
* Most Skills the User-global instruction root may hold.
|
|
34
|
+
*
|
|
35
|
+
* A separate limit rather than a shared one, because the two roots bound
|
|
36
|
+
* different things: the Bot root bounds one Bot's self-modification, and the
|
|
37
|
+
* User root bounds a tier every Bot of that User reads and any one of them
|
|
38
|
+
* can write. Counting them together would let one Bot's authoring exhaust a
|
|
39
|
+
* root the others share, and the refusal would name the wrong root.
|
|
40
|
+
*/
|
|
41
|
+
maxSkillsPerUser: number;
|
|
42
|
+
/** Largest single `SKILL.md`, in bytes. */
|
|
43
|
+
maxSkillBytes: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const SKILL_QUOTA_DEFAULTS_V1: SkillQuotaConfigV1 = {
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
maxSkillsPerBot: 200,
|
|
49
|
+
maxSkillsPerUser: 200,
|
|
50
|
+
maxSkillBytes: 65_536,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Which root a Skill write lands in, and therefore which count bounds it. */
|
|
54
|
+
export type SkillQuotaScopeV1 = "bot" | "user";
|
|
55
|
+
|
|
56
|
+
/** The Skill-count limit governing one root. */
|
|
57
|
+
export function skillCountLimitV1(
|
|
58
|
+
scope: SkillQuotaScopeV1,
|
|
59
|
+
config: SkillQuotaConfigV1 = SKILL_QUOTA_DEFAULTS_V1,
|
|
60
|
+
): number {
|
|
61
|
+
return scope === "user" ? config.maxSkillsPerUser : config.maxSkillsPerBot;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SkillQuotaLimitV1 = "skill-count" | "skill-bytes";
|
|
65
|
+
|
|
66
|
+
export type SkillQuotaOutcomeV1 =
|
|
67
|
+
| { status: "within" }
|
|
68
|
+
| {
|
|
69
|
+
status: "refused";
|
|
70
|
+
limitName: SkillQuotaLimitV1;
|
|
71
|
+
reason: string;
|
|
72
|
+
used: number;
|
|
73
|
+
limit: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Checks one Skill write against the quota. Never throws for a breach: a quota
|
|
78
|
+
* breach is an observable outcome the tool result reports.
|
|
79
|
+
*
|
|
80
|
+
* `existingSkills` counts the Skills already under the root, and `replaces`
|
|
81
|
+
* says whether this write supersedes one of them — superseding a Skill does
|
|
82
|
+
* not grow the root, so it is admitted at the limit.
|
|
83
|
+
*/
|
|
84
|
+
export function checkSkillQuotaV1(
|
|
85
|
+
request: {
|
|
86
|
+
bytes: number;
|
|
87
|
+
existingSkills: number;
|
|
88
|
+
replaces: boolean;
|
|
89
|
+
/** The root being written; the Bot's own when unsaid. */
|
|
90
|
+
scope?: SkillQuotaScopeV1;
|
|
91
|
+
},
|
|
92
|
+
config: SkillQuotaConfigV1 = SKILL_QUOTA_DEFAULTS_V1,
|
|
93
|
+
): SkillQuotaOutcomeV1 {
|
|
94
|
+
const scope = request.scope ?? "bot";
|
|
95
|
+
const limit = skillCountLimitV1(scope, config);
|
|
96
|
+
if (request.bytes > config.maxSkillBytes) {
|
|
97
|
+
return {
|
|
98
|
+
status: "refused",
|
|
99
|
+
limitName: "skill-bytes",
|
|
100
|
+
reason: `the Skill is ${request.bytes} bytes; the quota allows ${config.maxSkillBytes}`,
|
|
101
|
+
used: request.bytes,
|
|
102
|
+
limit: config.maxSkillBytes,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (!request.replaces && request.existingSkills >= limit) {
|
|
106
|
+
return {
|
|
107
|
+
status: "refused",
|
|
108
|
+
limitName: "skill-count",
|
|
109
|
+
reason:
|
|
110
|
+
scope === "user"
|
|
111
|
+
? `this User's shared instruction root holds ${request.existingSkills} Skills; the quota allows ${limit}`
|
|
112
|
+
: `this Bot holds ${request.existingSkills} Skills; the quota allows ${limit}`,
|
|
113
|
+
used: request.existingSkills,
|
|
114
|
+
limit,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { status: "within" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function decodeSkillQuotaConfigV1(
|
|
121
|
+
input: unknown,
|
|
122
|
+
label = "skill quota configuration",
|
|
123
|
+
): SkillQuotaConfigV1 {
|
|
124
|
+
if (input === undefined) return { ...SKILL_QUOTA_DEFAULTS_V1 };
|
|
125
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
126
|
+
throw new Error(`${label} must be an object`);
|
|
127
|
+
}
|
|
128
|
+
const value = input as Record<string, unknown>;
|
|
129
|
+
const keys = [
|
|
130
|
+
"schemaVersion",
|
|
131
|
+
"maxSkillsPerBot",
|
|
132
|
+
"maxSkillsPerUser",
|
|
133
|
+
"maxSkillBytes",
|
|
134
|
+
];
|
|
135
|
+
if (
|
|
136
|
+
value.schemaVersion !== 1 ||
|
|
137
|
+
Object.keys(value).length !== keys.length ||
|
|
138
|
+
!keys.every((key) => Object.hasOwn(value, key))
|
|
139
|
+
) {
|
|
140
|
+
throw new Error(`${label} is invalid`);
|
|
141
|
+
}
|
|
142
|
+
const bounded = (name: string, maximum: number): number => {
|
|
143
|
+
const candidate = value[name];
|
|
144
|
+
if (
|
|
145
|
+
!Number.isSafeInteger(candidate) ||
|
|
146
|
+
(candidate as number) < 1 ||
|
|
147
|
+
(candidate as number) > maximum
|
|
148
|
+
) {
|
|
149
|
+
throw new Error(`${label}.${name} is out of range`);
|
|
150
|
+
}
|
|
151
|
+
return candidate as number;
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
schemaVersion: 1,
|
|
155
|
+
maxSkillsPerBot: bounded("maxSkillsPerBot", 10_000),
|
|
156
|
+
maxSkillsPerUser: bounded("maxSkillsPerUser", 10_000),
|
|
157
|
+
maxSkillBytes: bounded("maxSkillBytes", 1_048_576),
|
|
158
|
+
};
|
|
159
|
+
}
|