@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/catalog.ts
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
// The Skills loader: what a Turn is allowed to load as instructions.
|
|
2
|
+
//
|
|
3
|
+
// FOUR SOURCES, ONE CATALOG. This module loads the Bot's two instruction roots
|
|
4
|
+
// — its own, and the User-global one every Bot of that User shares (ADR 0016)
|
|
5
|
+
// — and assembles them with the sources that are not durable-root files at all
|
|
6
|
+
// — the managed set compiled into this Package's artifact (`./managed.ts`) and
|
|
7
|
+
// the index over the User's installed Catalog entries (`./plugin-index.ts`).
|
|
8
|
+
// Those two never meet `isLoadableSkillSourceV1`, because they are not
|
|
9
|
+
// Workspace files: they are a Package contributing prompt content, which the
|
|
10
|
+
// constitution already permits, and they are pinned by the Turn's Composition
|
|
11
|
+
// and the User's Catalog pin respectively. The predicate below still decides
|
|
12
|
+
// every question it decided before, about every file it decided it for.
|
|
13
|
+
//
|
|
14
|
+
// "The kernel treats every Workspace file as data. Only Skills under a Bot's
|
|
15
|
+
// instruction roots — its own and its User's — written under the Bot's own
|
|
16
|
+
// authority or its User's, are loaded as instructions." That sentence is
|
|
17
|
+
// decided in exactly one place —
|
|
18
|
+
// `isLoadableSkillSourceV1` in `@frockbot/kernel-contracts` — and this module
|
|
19
|
+
// calls it. There is no second opinion here and no override: a candidate the
|
|
20
|
+
// predicate refuses is recorded as a refusal and never read as an instruction.
|
|
21
|
+
//
|
|
22
|
+
// HIBERNATION SEAM. This loader reaches the Workspace only through the
|
|
23
|
+
// kernel-declared `WorkspaceReadsV1`. It never calls the Computer interface,
|
|
24
|
+
// never provisions or wakes a Computer, and holds no provider type. "The Agent
|
|
25
|
+
// loop, Memory, Skills, Package composition, and Routines function correctly
|
|
26
|
+
// while the Computer is hibernated and do not wake it." Whoever supplies
|
|
27
|
+
// `WorkspaceReadsV1` owns that promise: the durable-root sync (ADR 0013) backs
|
|
28
|
+
// an instruction root from object storage, so a read here is an object-storage
|
|
29
|
+
// read whether or not a Computer host is running. Swapping that implementation
|
|
30
|
+
// is invisible to everything below.
|
|
31
|
+
//
|
|
32
|
+
// Every value read here is untrusted: a durable root synchronizes
|
|
33
|
+
// bidirectionally with object storage, so a path, a writer, and a generation
|
|
34
|
+
// can all arrive from the Computer side. Refusals are a declared variant, not
|
|
35
|
+
// a throw, because a hostile or malformed file in an instruction root must not
|
|
36
|
+
// abort the Turn that enumerated it.
|
|
37
|
+
import {
|
|
38
|
+
formatSkillRefV1,
|
|
39
|
+
isLoadableSkillSourceV1,
|
|
40
|
+
isSkillRefSlugV1,
|
|
41
|
+
SKILL_REF_SOURCES_V1,
|
|
42
|
+
type SkillRefSourceV1,
|
|
43
|
+
type SkillRefV1,
|
|
44
|
+
type SkillSourceV1,
|
|
45
|
+
type WorkspaceEntryV1,
|
|
46
|
+
type WorkspaceInstructionRootV1,
|
|
47
|
+
type WorkspaceReadsV1,
|
|
48
|
+
} from "@frockbot/kernel-contracts";
|
|
49
|
+
import { loadManagedSkillsV1 } from "./managed.js";
|
|
50
|
+
import {
|
|
51
|
+
loadPluginSkillsV1,
|
|
52
|
+
type PluginSkillsSourceV1,
|
|
53
|
+
} from "./plugin-index.js";
|
|
54
|
+
import {
|
|
55
|
+
SKILL_FILE_NAME,
|
|
56
|
+
isSkillDocumentPathV1,
|
|
57
|
+
parseSkillDocumentV1,
|
|
58
|
+
SKILL_MAX_FILE_BYTES,
|
|
59
|
+
} from "./skill-md.js";
|
|
60
|
+
|
|
61
|
+
/** The Bot whose instruction root is being loaded, and its User. */
|
|
62
|
+
export interface SkillOwnerV1 {
|
|
63
|
+
userId: string;
|
|
64
|
+
botId: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Most `list` pages walked before enumeration stops. */
|
|
68
|
+
export const SKILL_MAX_LIST_PAGES = 8;
|
|
69
|
+
/**
|
|
70
|
+
* Most `list` pages walked while counting a root for the quota.
|
|
71
|
+
*
|
|
72
|
+
* Counting is not loading: the catalog stops at `SKILL_MAX_CATALOG_ENTRIES` and
|
|
73
|
+
* a truncated catalog is a recorded refusal, but a truncated *count* would make
|
|
74
|
+
* the quota unenforceable, so this bound is generous enough to cover the
|
|
75
|
+
* largest configurable `maxSkillsPerBot` at any plausible page size, and being
|
|
76
|
+
* hit is itself an unavailable answer rather than a smaller number.
|
|
77
|
+
*/
|
|
78
|
+
export const SKILL_MAX_COUNT_LIST_PAGES = 256;
|
|
79
|
+
/** Most Skills carried in one catalog. Beyond this, the rest are refused. */
|
|
80
|
+
export const SKILL_MAX_CATALOG_ENTRIES = 200;
|
|
81
|
+
|
|
82
|
+
/** One Skill this Turn may use, with the exact generation it came from. */
|
|
83
|
+
export interface LoadedSkillV1 {
|
|
84
|
+
/**
|
|
85
|
+
* Where the Skill is listed. Relative to the Bot's instruction root for a
|
|
86
|
+
* `bot` Skill; the synthetic `managed/<slug>/SKILL.md` or
|
|
87
|
+
* `plugin/<packageId>/<slug>/SKILL.md` for the two sources that are not
|
|
88
|
+
* durable-root files at all.
|
|
89
|
+
*/
|
|
90
|
+
path: string;
|
|
91
|
+
/**
|
|
92
|
+
* The ref that names this Skill for invocation and for `skill_load`.
|
|
93
|
+
*
|
|
94
|
+
* Optional only for a `bot` Skill whose directory is not a well-formed slug:
|
|
95
|
+
* an instruction root is an ordinary durable root, so a `SKILL.md` can sit
|
|
96
|
+
* anywhere, and such a Skill is still listed and still loadable by path — it
|
|
97
|
+
* just has no name the composer can attach. Every managed and plugin Skill
|
|
98
|
+
* always has one.
|
|
99
|
+
*/
|
|
100
|
+
ref?: SkillRefV1;
|
|
101
|
+
/**
|
|
102
|
+
* Who this Skill is attributed to in the rendered catalog: the shared-tier
|
|
103
|
+
* attribution GrokBot spells `[via]`. A Bot's own Skill carries none.
|
|
104
|
+
*/
|
|
105
|
+
by?: string;
|
|
106
|
+
name: string;
|
|
107
|
+
description: string;
|
|
108
|
+
body: string;
|
|
109
|
+
generationId: string;
|
|
110
|
+
contentHash: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type SkillRefusalKindV1 =
|
|
114
|
+
| "authority"
|
|
115
|
+
| "malformed"
|
|
116
|
+
| "oversized"
|
|
117
|
+
| "unreadable"
|
|
118
|
+
| "over-catalog"
|
|
119
|
+
| "over-source-cap";
|
|
120
|
+
|
|
121
|
+
/** A candidate that was not loaded, and why. Recorded, never thrown. */
|
|
122
|
+
export interface SkillRefusalV1 {
|
|
123
|
+
path: string;
|
|
124
|
+
kind: SkillRefusalKindV1;
|
|
125
|
+
reason: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface SkillCatalogV1 {
|
|
129
|
+
owner: SkillOwnerV1;
|
|
130
|
+
skills: LoadedSkillV1[];
|
|
131
|
+
refusals: SkillRefusalV1[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function botInstructionRootV1(
|
|
135
|
+
owner: SkillOwnerV1,
|
|
136
|
+
): WorkspaceInstructionRootV1 {
|
|
137
|
+
return {
|
|
138
|
+
kind: "bot-instructions",
|
|
139
|
+
userId: owner.userId,
|
|
140
|
+
botId: owner.botId,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The User-global instruction root, shared by every Bot this User owns.
|
|
146
|
+
*
|
|
147
|
+
* It is named from the owner rather than passed in, exactly as the Bot root
|
|
148
|
+
* is: a caller cannot ask this Package to load another User's Skills, because
|
|
149
|
+
* there is no argument with which to ask.
|
|
150
|
+
*/
|
|
151
|
+
export function userInstructionRootV1(
|
|
152
|
+
owner: SkillOwnerV1,
|
|
153
|
+
): WorkspaceInstructionRootV1 {
|
|
154
|
+
return { kind: "user-instructions", userId: owner.userId };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The two roots a Turn's Skills may come out of, in catalog order. */
|
|
158
|
+
export function skillInstructionRootsV1(
|
|
159
|
+
owner: SkillOwnerV1,
|
|
160
|
+
): { source: "bot" | "user"; root: WorkspaceInstructionRootV1 }[] {
|
|
161
|
+
return [
|
|
162
|
+
{ source: "bot", root: botInstructionRootV1(owner) },
|
|
163
|
+
{ source: "user", root: userInstructionRootV1(owner) },
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function emptySkillCatalogV1(owner: SkillOwnerV1): SkillCatalogV1 {
|
|
168
|
+
return { owner, skills: [], refusals: [] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sourceOf(entry: WorkspaceEntryV1): SkillSourceV1 {
|
|
172
|
+
// The writer of record is the one the *generation* carries. A caller cannot
|
|
173
|
+
// supply a writer alongside the file: authority follows the recorded write.
|
|
174
|
+
return {
|
|
175
|
+
path: entry.path,
|
|
176
|
+
writer: entry.generation.writer,
|
|
177
|
+
generation: entry.generation,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function describeWriter(source: SkillSourceV1): string {
|
|
182
|
+
const writer = source.writer;
|
|
183
|
+
if (writer.kind === "first-party") {
|
|
184
|
+
return `first-party Package "${writer.packageId}"`;
|
|
185
|
+
}
|
|
186
|
+
if (writer.kind === "user") return `User "${writer.userId}"`;
|
|
187
|
+
if (writer.kind === "bot") return `Bot "${writer.botId}"`;
|
|
188
|
+
// Nothing recorded a writer: a process on the Computer wrote the file
|
|
189
|
+
// outside the Workspace file surface, so no authority can be read off it.
|
|
190
|
+
return "no recorded writer (written outside the Workspace file surface)";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* How a loaded Skill is attributed in the rendered catalog, or `undefined`
|
|
195
|
+
* when the Bot wrote it itself and there is nothing to disclose.
|
|
196
|
+
*
|
|
197
|
+
* A Skill written by the Bot's User, or by another of the User's Bots in the
|
|
198
|
+
* User-global root, is a Skill the reading Bot did not author. Saying so in
|
|
199
|
+
* the catalog line is the shared-tier attribution GrokBot spells `[via]`, and
|
|
200
|
+
* it is what makes a shared tier honest: the Bot is told whose instruction it
|
|
201
|
+
* is about to follow.
|
|
202
|
+
*/
|
|
203
|
+
function attributionFor(
|
|
204
|
+
source: SkillSourceV1,
|
|
205
|
+
owner: SkillOwnerV1,
|
|
206
|
+
): string | undefined {
|
|
207
|
+
const writer = source.writer;
|
|
208
|
+
if (writer.kind === "user") return "your User";
|
|
209
|
+
if (writer.kind === "bot") {
|
|
210
|
+
return writer.botId === owner.botId ? undefined : `Bot "${writer.botId}"`;
|
|
211
|
+
}
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function describeRoot(source: SkillSourceV1): string {
|
|
216
|
+
const root = source.path.root;
|
|
217
|
+
if (root.kind === "package-declared") {
|
|
218
|
+
return `a root declared by Package "${root.packageId}"`;
|
|
219
|
+
}
|
|
220
|
+
if (root.kind === "user-memory") return "the User Memory root";
|
|
221
|
+
if (root.kind === "project-memory") {
|
|
222
|
+
return `Project "${root.projectId}"'s Memory root`;
|
|
223
|
+
}
|
|
224
|
+
if (root.kind === "bot-memory") return `Bot "${root.botId}"'s Memory root`;
|
|
225
|
+
if (root.kind === "user-instructions") {
|
|
226
|
+
return `User "${root.userId}"'s instruction root`;
|
|
227
|
+
}
|
|
228
|
+
return `Bot "${root.botId}"'s instruction root`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* How many Skills a root already holds, or why that is not knowable.
|
|
233
|
+
*
|
|
234
|
+
* There is no "probably fine" answer here. A quota that falls back to zero on
|
|
235
|
+
* an unreadable listing is a quota that can never refuse, so the failure is a
|
|
236
|
+
* declared variant the caller must handle.
|
|
237
|
+
*/
|
|
238
|
+
export type SkillCountOutcomeV1 =
|
|
239
|
+
{ status: "ok"; count: number } | { status: "unavailable"; reason: string };
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Counts the `SKILL.md` files under a root, walking the listing with the
|
|
243
|
+
* store's own cursor.
|
|
244
|
+
*
|
|
245
|
+
* Only an entry whose last segment is `SKILL.md`, inside a directory, counts —
|
|
246
|
+
* `isSkillDocumentPathV1` decides it. An instruction root is an ordinary durable
|
|
247
|
+
* root — a Bot's notes, an installer's leavings, and a Skill's own supporting
|
|
248
|
+
* files all live there — so counting *files* would refuse a Skill on a quota
|
|
249
|
+
* about Skills, and would make the page bound a bound on files rather than on
|
|
250
|
+
* what the quota measures.
|
|
251
|
+
*
|
|
252
|
+
* `stopAfter` is what keeps the bound on Skills. The only question the quota
|
|
253
|
+
* asks is whether the root already holds more than it allows, so once the
|
|
254
|
+
* count passes that number the answer cannot change, and the walk stops
|
|
255
|
+
* wherever it is. A root with more files than the page bound can walk is then
|
|
256
|
+
* still countable whenever its *Skills* exceed the cap; only a root that is
|
|
257
|
+
* both enormous and under its Skill cap is `unavailable`, and that is the
|
|
258
|
+
* honest answer, because the quota is checked against what the root already
|
|
259
|
+
* holds and an incomplete count is not a smaller count.
|
|
260
|
+
*/
|
|
261
|
+
export async function countSkillDocumentsV1(
|
|
262
|
+
reads: WorkspaceReadsV1,
|
|
263
|
+
root: WorkspaceInstructionRootV1,
|
|
264
|
+
options: { stopAfter?: number } = {},
|
|
265
|
+
): Promise<SkillCountOutcomeV1> {
|
|
266
|
+
const stopAfter = options.stopAfter;
|
|
267
|
+
let count = 0;
|
|
268
|
+
let cursor: string | undefined;
|
|
269
|
+
for (let page = 0; page < SKILL_MAX_COUNT_LIST_PAGES; page += 1) {
|
|
270
|
+
const outcome = await reads.list(
|
|
271
|
+
cursor === undefined ? { root } : { root, cursor },
|
|
272
|
+
);
|
|
273
|
+
if (outcome.status !== "ok") {
|
|
274
|
+
return {
|
|
275
|
+
status: "unavailable",
|
|
276
|
+
reason: `the instruction root could not be listed: ${outcome.reason}`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
count += outcome.entries.filter((entry) =>
|
|
280
|
+
isSkillDocumentPathV1(entry.path.path),
|
|
281
|
+
).length;
|
|
282
|
+
if (!outcome.cursor) return { status: "ok", count };
|
|
283
|
+
if (stopAfter !== undefined && count > stopAfter) {
|
|
284
|
+
return { status: "ok", count };
|
|
285
|
+
}
|
|
286
|
+
cursor = outcome.cursor;
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
status: "unavailable",
|
|
290
|
+
reason: `the instruction root did not finish listing within ${SKILL_MAX_COUNT_LIST_PAGES} pages`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Enumerates one instruction root's Skill candidates and loads the ones the
|
|
296
|
+
* constitution allows. Pure with respect to the Workspace: it reads, and never
|
|
297
|
+
* writes.
|
|
298
|
+
*
|
|
299
|
+
* The root defaults to the Bot's own, which is what every caller wanted while
|
|
300
|
+
* there was only one. Passing the User-global root loads the shared tier, and
|
|
301
|
+
* `isLoadableSkillSourceV1` decides both the same way: nothing here widens
|
|
302
|
+
* what may be loaded, it only says which root is being walked. `source` is the
|
|
303
|
+
* ref source the loaded Skills are named under, and it must match the root —
|
|
304
|
+
* which is why both come from `skillInstructionRootsV1` rather than from a
|
|
305
|
+
* caller's two independent arguments.
|
|
306
|
+
*/
|
|
307
|
+
export async function loadSkillCatalogV1(
|
|
308
|
+
reads: WorkspaceReadsV1,
|
|
309
|
+
owner: SkillOwnerV1,
|
|
310
|
+
options: {
|
|
311
|
+
maxSkills?: number;
|
|
312
|
+
root?: WorkspaceInstructionRootV1;
|
|
313
|
+
source?: "bot" | "user";
|
|
314
|
+
} = {},
|
|
315
|
+
): Promise<SkillCatalogV1> {
|
|
316
|
+
const maxSkills = options.maxSkills ?? SKILL_MAX_CATALOG_ENTRIES;
|
|
317
|
+
const root = options.root ?? botInstructionRootV1(owner);
|
|
318
|
+
const refSource = options.source ?? "bot";
|
|
319
|
+
const catalog = emptySkillCatalogV1(owner);
|
|
320
|
+
const entries: WorkspaceEntryV1[] = [];
|
|
321
|
+
let cursor: string | undefined;
|
|
322
|
+
for (let page = 0; page < SKILL_MAX_LIST_PAGES; page += 1) {
|
|
323
|
+
const outcome = await reads.list(
|
|
324
|
+
cursor === undefined ? { root } : { root, cursor },
|
|
325
|
+
);
|
|
326
|
+
if (outcome.status !== "ok") {
|
|
327
|
+
// "unavailable" is an ordinary answer, not an error condition: an
|
|
328
|
+
// instruction root that cannot be read yields no instructions and says
|
|
329
|
+
// so, rather than failing the Turn.
|
|
330
|
+
catalog.refusals.push({
|
|
331
|
+
path: "",
|
|
332
|
+
kind: "unreadable",
|
|
333
|
+
reason: `the ${refSource} instruction root could not be listed: ${outcome.reason}`,
|
|
334
|
+
});
|
|
335
|
+
return catalog;
|
|
336
|
+
}
|
|
337
|
+
entries.push(...outcome.entries);
|
|
338
|
+
if (!outcome.cursor) break;
|
|
339
|
+
cursor = outcome.cursor;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const candidates = entries
|
|
343
|
+
.filter((entry) => isSkillDocumentPathV1(entry.path.path))
|
|
344
|
+
.sort((left, right) => left.path.path.localeCompare(right.path.path));
|
|
345
|
+
|
|
346
|
+
for (const entry of candidates) {
|
|
347
|
+
const source = sourceOf(entry);
|
|
348
|
+
const path = source.path.path;
|
|
349
|
+
if (!isLoadableSkillSourceV1(source, owner)) {
|
|
350
|
+
catalog.refusals.push({
|
|
351
|
+
path,
|
|
352
|
+
kind: "authority",
|
|
353
|
+
reason: `written by ${describeWriter(source)} under ${describeRoot(source)}; only this Bot or its User, under this Bot's own instruction root, may write an instruction`,
|
|
354
|
+
});
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (catalog.skills.length >= maxSkills) {
|
|
358
|
+
catalog.refusals.push({
|
|
359
|
+
path,
|
|
360
|
+
kind: "over-catalog",
|
|
361
|
+
reason: `the Skill catalog is bounded at ${maxSkills} entries`,
|
|
362
|
+
});
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (source.generation.size > SKILL_MAX_FILE_BYTES) {
|
|
366
|
+
catalog.refusals.push({
|
|
367
|
+
path,
|
|
368
|
+
kind: "oversized",
|
|
369
|
+
reason: `the Skill is ${source.generation.size} bytes; the bound is ${SKILL_MAX_FILE_BYTES}`,
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const read = await reads.read(source.path);
|
|
374
|
+
if (read.status !== "ok") {
|
|
375
|
+
catalog.refusals.push({
|
|
376
|
+
path,
|
|
377
|
+
kind: "unreadable",
|
|
378
|
+
reason: `the Skill could not be read: ${read.reason}`,
|
|
379
|
+
});
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (read.file.generation.generationId !== source.generation.generationId) {
|
|
383
|
+
// The generation changed between listing and reading. Refuse rather than
|
|
384
|
+
// load a body whose writer this Turn never checked.
|
|
385
|
+
catalog.refusals.push({
|
|
386
|
+
path,
|
|
387
|
+
kind: "unreadable",
|
|
388
|
+
reason: "the Skill changed generation while it was being loaded",
|
|
389
|
+
});
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (read.file.bytes.byteLength > SKILL_MAX_FILE_BYTES) {
|
|
393
|
+
catalog.refusals.push({
|
|
394
|
+
path,
|
|
395
|
+
kind: "oversized",
|
|
396
|
+
reason: `the Skill is ${read.file.bytes.byteLength} bytes; the bound is ${SKILL_MAX_FILE_BYTES}`,
|
|
397
|
+
});
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const parsed = parseSkillDocumentV1(
|
|
401
|
+
new TextDecoder().decode(read.file.bytes),
|
|
402
|
+
);
|
|
403
|
+
if (parsed.status !== "ok") {
|
|
404
|
+
catalog.refusals.push({ path, kind: "malformed", reason: parsed.reason });
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const slug = skillSlugFromDocumentPathV1(path);
|
|
408
|
+
catalog.skills.push({
|
|
409
|
+
path,
|
|
410
|
+
...(slug
|
|
411
|
+
? { ref: { schemaVersion: 1 as const, source: refSource, slug } }
|
|
412
|
+
: {}),
|
|
413
|
+
...(attributionFor(source, owner)
|
|
414
|
+
? { by: attributionFor(source, owner) as string }
|
|
415
|
+
: {}),
|
|
416
|
+
name: parsed.document.name,
|
|
417
|
+
description: parsed.document.description,
|
|
418
|
+
body: parsed.document.body,
|
|
419
|
+
generationId: source.generation.generationId,
|
|
420
|
+
contentHash: source.generation.contentHash,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
return catalog;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Per-source bounds on one Turn's Skill catalog, mirroring `MEMORY_*_CAPS_V1`.
|
|
428
|
+
*
|
|
429
|
+
* The four numbers are per source rather than one total because the sources do
|
|
430
|
+
* not compete for the same thing: a Bot's own Skills are its self-modification
|
|
431
|
+
* surface and should be generous, the managed set is fixed and small, and a
|
|
432
|
+
* plugin-borne set is written by whoever published the Package and is the one
|
|
433
|
+
* a hostile publisher could inflate. `totalBytes` bounds what the *rendered
|
|
434
|
+
* block* costs — names, descriptions and paths, since bodies are never
|
|
435
|
+
* injected — so a catalog cannot crowd out the conversation no matter how the
|
|
436
|
+
* per-source counts land.
|
|
437
|
+
*
|
|
438
|
+
* Every drop is a recorded `over-source-cap` refusal, so a truncated catalog is
|
|
439
|
+
* visible in durable state rather than silently changing the Bot's behaviour.
|
|
440
|
+
*/
|
|
441
|
+
export interface SkillCatalogCapsV1 {
|
|
442
|
+
bot: number;
|
|
443
|
+
user: number;
|
|
444
|
+
managed: number;
|
|
445
|
+
plugin: number;
|
|
446
|
+
totalBytes: number;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export const SKILL_CATALOG_CAPS_V1: SkillCatalogCapsV1 = {
|
|
450
|
+
bot: 40,
|
|
451
|
+
user: 40,
|
|
452
|
+
managed: 8,
|
|
453
|
+
plugin: 24,
|
|
454
|
+
totalBytes: 16_384,
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/** One source's contribution to a Turn's catalog. */
|
|
458
|
+
export interface SkillSourceResultV1 {
|
|
459
|
+
skills: LoadedSkillV1[];
|
|
460
|
+
refusals: SkillRefusalV1[];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The four sources, keyed as the canonical ordering names them. */
|
|
464
|
+
export type SkillCatalogSourcesV1 = Partial<
|
|
465
|
+
Record<SkillRefSourceV1, SkillSourceResultV1>
|
|
466
|
+
>;
|
|
467
|
+
|
|
468
|
+
function catalogCostOf(skill: LoadedSkillV1): number {
|
|
469
|
+
const encoder = new TextEncoder();
|
|
470
|
+
return (
|
|
471
|
+
encoder.encode(skill.name).byteLength +
|
|
472
|
+
encoder.encode(skill.description).byteLength +
|
|
473
|
+
encoder.encode(skill.path).byteLength
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function orderingKeyOf(skill: LoadedSkillV1): string {
|
|
478
|
+
return skill.ref
|
|
479
|
+
? `${skill.ref.packageId ?? ""}\u0000${skill.ref.slug}`
|
|
480
|
+
: `\uffff${skill.path}`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Assembles one Turn's catalog from its sources.
|
|
485
|
+
*
|
|
486
|
+
* Ordering is `bot` → `user` → `managed` → `plugin`, then by ref within a
|
|
487
|
+
* source, and it is a *deterministic ordering only*: refs are globally unique,
|
|
488
|
+
* so nothing here shadows anything. Two Skills may share a name; the rendered
|
|
489
|
+
* block disambiguates those by ref, which is what makes a User's edit visible
|
|
490
|
+
* on every Bot instead of silently losing to a same-named local one.
|
|
491
|
+
*/
|
|
492
|
+
export function assembleSkillCatalogV1(
|
|
493
|
+
owner: SkillOwnerV1,
|
|
494
|
+
sources: SkillCatalogSourcesV1,
|
|
495
|
+
caps: SkillCatalogCapsV1 = SKILL_CATALOG_CAPS_V1,
|
|
496
|
+
): SkillCatalogV1 {
|
|
497
|
+
const catalog = emptySkillCatalogV1(owner);
|
|
498
|
+
let bytes = 0;
|
|
499
|
+
for (const source of SKILL_REF_SOURCES_V1) {
|
|
500
|
+
const result = sources[source];
|
|
501
|
+
if (!result) continue;
|
|
502
|
+
catalog.refusals.push(...result.refusals);
|
|
503
|
+
const cap = caps[source];
|
|
504
|
+
const ordered = [...result.skills].sort((left, right) =>
|
|
505
|
+
orderingKeyOf(left).localeCompare(orderingKeyOf(right)),
|
|
506
|
+
);
|
|
507
|
+
let admitted = 0;
|
|
508
|
+
for (const skill of ordered) {
|
|
509
|
+
if (admitted >= cap) {
|
|
510
|
+
catalog.refusals.push({
|
|
511
|
+
path: skill.path,
|
|
512
|
+
kind: "over-source-cap",
|
|
513
|
+
reason: `the ${source} Skill source is bounded at ${cap} entries in one catalog`,
|
|
514
|
+
});
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const cost = catalogCostOf(skill);
|
|
518
|
+
if (bytes + cost > caps.totalBytes) {
|
|
519
|
+
catalog.refusals.push({
|
|
520
|
+
path: skill.path,
|
|
521
|
+
kind: "over-source-cap",
|
|
522
|
+
reason: `the Skill catalog is bounded at ${caps.totalBytes} rendered bytes`,
|
|
523
|
+
});
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
bytes += cost;
|
|
527
|
+
admitted += 1;
|
|
528
|
+
catalog.skills.push(skill);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return catalog;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The whole catalog one Turn runs under: the Bot's own instruction root, the
|
|
536
|
+
* User-global instruction root its User's Bots share, the managed set compiled
|
|
537
|
+
* into this Package, and the index over the User's installed Catalog entries —
|
|
538
|
+
* assembled, ordered and capped.
|
|
539
|
+
*
|
|
540
|
+
* Both roots are read every Turn, through the same `WorkspaceReadsV1` and the
|
|
541
|
+
* same predicate. A User-global root that holds nothing contributes an empty
|
|
542
|
+
* source rather than being skipped: it was read, and saying so is what makes a
|
|
543
|
+
* missing Skill a fact about the root instead of a fact about the loader.
|
|
544
|
+
*/
|
|
545
|
+
export async function loadFullSkillCatalogV1(
|
|
546
|
+
reads: WorkspaceReadsV1,
|
|
547
|
+
owner: SkillOwnerV1,
|
|
548
|
+
options: {
|
|
549
|
+
pluginSkills?: PluginSkillsSourceV1;
|
|
550
|
+
managed?: boolean;
|
|
551
|
+
caps?: SkillCatalogCapsV1;
|
|
552
|
+
} = {},
|
|
553
|
+
): Promise<SkillCatalogV1> {
|
|
554
|
+
const sources: SkillCatalogSourcesV1 = {};
|
|
555
|
+
for (const { source, root } of skillInstructionRootsV1(owner)) {
|
|
556
|
+
const loaded = await loadSkillCatalogV1(reads, owner, { root, source });
|
|
557
|
+
sources[source] = { skills: loaded.skills, refusals: loaded.refusals };
|
|
558
|
+
}
|
|
559
|
+
if (options.managed !== false) {
|
|
560
|
+
sources.managed = await loadManagedSkillsV1();
|
|
561
|
+
}
|
|
562
|
+
if (options.pluginSkills) {
|
|
563
|
+
sources.plugin = await loadPluginSkillsV1(options.pluginSkills);
|
|
564
|
+
}
|
|
565
|
+
return assembleSkillCatalogV1(owner, sources, options.caps);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The progressive-disclosure prompt block, in GrokBot's shape: the catalog is
|
|
570
|
+
* injected every Turn as `<agent_skills>` with each Skill's ref, source and
|
|
571
|
+
* description; bodies are not. The Bot reads a body on demand with
|
|
572
|
+
* `skill_load`, and is told that mentioning a Skill is not running it
|
|
573
|
+
* (`docs/research/grokbot-computer.md` §2.8).
|
|
574
|
+
*
|
|
575
|
+
* `source` and `by` are rendered because they change what a Skill *is*: a
|
|
576
|
+
* managed one is first-party and unchangeable, a plugin one arrived with
|
|
577
|
+
* something the User installed, and a Bot's own is one it wrote. A duplicated
|
|
578
|
+
* name is qualified by its ref, since names are not unique and refs are.
|
|
579
|
+
*/
|
|
580
|
+
export function renderSkillCatalogPromptV1(catalog: SkillCatalogV1): string {
|
|
581
|
+
if (catalog.skills.length === 0) return "";
|
|
582
|
+
const counts = new Map<string, number>();
|
|
583
|
+
for (const skill of catalog.skills) {
|
|
584
|
+
counts.set(skill.name, (counts.get(skill.name) ?? 0) + 1);
|
|
585
|
+
}
|
|
586
|
+
const entries = catalog.skills.map((skill) => {
|
|
587
|
+
const ref = skill.ref ? formatSkillRefV1(skill.ref) : undefined;
|
|
588
|
+
const source = skill.ref?.source ?? "bot";
|
|
589
|
+
const name =
|
|
590
|
+
(counts.get(skill.name) ?? 0) > 1 && ref
|
|
591
|
+
? `${skill.name} (${ref})`
|
|
592
|
+
: skill.name;
|
|
593
|
+
const attributes = [
|
|
594
|
+
`name="${escapeAttribute(name)}"`,
|
|
595
|
+
`source="${escapeAttribute(source)}"`,
|
|
596
|
+
...(ref ? [`ref="${escapeAttribute(ref)}"`] : []),
|
|
597
|
+
`path="${escapeAttribute(skill.path)}"`,
|
|
598
|
+
...(skill.by ? [`by="${escapeAttribute(skill.by)}"`] : []),
|
|
599
|
+
].join(" ");
|
|
600
|
+
return ` <skill ${attributes}>${escapeText(skill.description)}</skill>`;
|
|
601
|
+
});
|
|
602
|
+
return [
|
|
603
|
+
"<agent_skills>",
|
|
604
|
+
...entries,
|
|
605
|
+
"</agent_skills>",
|
|
606
|
+
"These are your Skills: recipes you wrote, or your User wrote, for you; the managed ones ship with FrockBot; the plugin ones came with a Package your User installed.",
|
|
607
|
+
"Only names, refs, paths and descriptions are listed above. Call skill_load with a ref to read a Skill's full instructions before you follow it.",
|
|
608
|
+
"Mentioning a Skill is not running it.",
|
|
609
|
+
].join("\n");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The slug a Skill document path carries, or `undefined` when the path names
|
|
614
|
+
* no usable slug.
|
|
615
|
+
*
|
|
616
|
+
* An instruction root is an ordinary durable root, so a `SKILL.md` can sit
|
|
617
|
+
* anywhere a Bot or a shell put it. The slug is the directory that holds it,
|
|
618
|
+
* and a directory that is not a well-formed slug simply has no ref: the Skill
|
|
619
|
+
* is still listed and still loadable by path, it just cannot be invoked from
|
|
620
|
+
* the composer, which is the honest answer rather than an invented name.
|
|
621
|
+
*/
|
|
622
|
+
export function skillSlugFromDocumentPathV1(path: string): string | undefined {
|
|
623
|
+
const segments = path.split("/");
|
|
624
|
+
if (segments[segments.length - 1] !== SKILL_FILE_NAME) return undefined;
|
|
625
|
+
const slug = segments[segments.length - 2];
|
|
626
|
+
return isSkillRefSlugV1(slug) ? slug : undefined;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The ref that names a loaded Skill, or `undefined` when it has none.
|
|
631
|
+
*
|
|
632
|
+
* The loader that produced the Skill already decided this — a managed Skill's
|
|
633
|
+
* ref is its slug, a plugin Skill's is qualified by its Package, and a Bot's
|
|
634
|
+
* own comes from its directory — so this reads the recorded ref rather than
|
|
635
|
+
* re-deriving one from a path that no longer determines the source.
|
|
636
|
+
*/
|
|
637
|
+
export function skillRefForLoadedSkillV1(
|
|
638
|
+
skill: LoadedSkillV1,
|
|
639
|
+
): SkillRefV1 | undefined {
|
|
640
|
+
return skill.ref;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Resolves an invoked ref against what this Turn actually loaded.
|
|
645
|
+
*
|
|
646
|
+
* `undefined` is the whole answer for an unresolvable ref: the caller fails
|
|
647
|
+
* the command with a visible reason rather than dropping the invocation, so a
|
|
648
|
+
* User who asked for a Skill is never silently answered without it.
|
|
649
|
+
*/
|
|
650
|
+
export function resolveSkillRefV1(
|
|
651
|
+
catalog: SkillCatalogV1,
|
|
652
|
+
ref: SkillRefV1,
|
|
653
|
+
): LoadedSkillV1 | undefined {
|
|
654
|
+
const wanted = formatSkillRefV1(ref);
|
|
655
|
+
return catalog.skills.find(
|
|
656
|
+
(skill) =>
|
|
657
|
+
skill.ref !== undefined && formatSkillRefV1(skill.ref) === wanted,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** One Skill the User invoked, with the ref that named it. */
|
|
662
|
+
export interface InvokedSkillV1 {
|
|
663
|
+
ref: SkillRefV1;
|
|
664
|
+
skill: LoadedSkillV1;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The invoked Skills' bodies, expanded for the Turn's first step.
|
|
669
|
+
*
|
|
670
|
+
* This is what makes `/` an *invocation* rather than a mention. The catalog
|
|
671
|
+
* block above says bodies are read on demand and that mentioning a Skill is
|
|
672
|
+
* not running it; that stays true for every Skill the User did not invoke.
|
|
673
|
+
* The expansion sits in the system prompt so the exact instructions the model
|
|
674
|
+
* received are reconstructable from the Turn's `model/request` alone.
|
|
675
|
+
*/
|
|
676
|
+
export function renderInvokedSkillsPromptV1(
|
|
677
|
+
invoked: readonly InvokedSkillV1[],
|
|
678
|
+
): string {
|
|
679
|
+
if (invoked.length === 0) return "";
|
|
680
|
+
const blocks = invoked.map((entry) =>
|
|
681
|
+
[
|
|
682
|
+
` <skill ref="${escapeAttribute(formatSkillRefV1(entry.ref))}" name="${escapeAttribute(entry.skill.name)}" path="${escapeAttribute(entry.skill.path)}" generation="${escapeAttribute(entry.skill.generationId)}">`,
|
|
683
|
+
escapeText(entry.skill.body),
|
|
684
|
+
" </skill>",
|
|
685
|
+
].join("\n"),
|
|
686
|
+
);
|
|
687
|
+
return [
|
|
688
|
+
"<invoked_skills>",
|
|
689
|
+
...blocks,
|
|
690
|
+
"</invoked_skills>",
|
|
691
|
+
"Your User invoked these Skills for this message. Their full instructions are above; follow them.",
|
|
692
|
+
].join("\n");
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function escapeAttribute(value: string): string {
|
|
696
|
+
return escapeText(value).replace(/"/g, """);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function escapeText(value: string): string {
|
|
700
|
+
return value
|
|
701
|
+
.replace(/&/g, "&")
|
|
702
|
+
.replace(/</g, "<")
|
|
703
|
+
.replace(/>/g, ">");
|
|
704
|
+
}
|