@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/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
+ }
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -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
+ }