@frockbot/plugin-skills 0.0.0 → 0.1.0

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/agent.ts ADDED
@@ -0,0 +1,678 @@
1
+ // The Skills runtime Contribution.
2
+ //
3
+ // Three responsibilities, and no authority of its own:
4
+ //
5
+ // 1. Load the Bot's Skills once per admitted Turn, through the
6
+ // kernel-declared `WorkspaceReadsV1`. "Skills are files under the Bot's
7
+ // instruction roots. An edit is visible to the Bot on its next admitted
8
+ // Turn" — so the catalog is loaded at the Turn's first step and reused for
9
+ // every later step of that Turn, and an edit made mid-Turn is not visible
10
+ // until the next one.
11
+ // 2. Record what it injected. "What Memory enters a model request, and when,
12
+ // is Package policy, and the session event log records exactly what was
13
+ // injected, so an injection gap is visible in durable state rather than
14
+ // silently changing the Bot's behavior." A Skill is an instruction, so the
15
+ // same rule binds: `skill/injected` names every loaded Skill with its
16
+ // generation, and every refused candidate with its reason.
17
+ // 3. Offer the two tools: `skill_load` reads one body on demand (progressive
18
+ // disclosure, GrokBot parity), `skill_write` authors a Skill into one of
19
+ // the Bot's instruction roots — its own (self-modification) or its User's
20
+ // shared root, which every Bot of that User reads (ADR 0016).
21
+ //
22
+ // It never calls the Computer interface and never wakes a Computer; see the
23
+ // hibernation seam documented in `./catalog.ts`.
24
+ import type {
25
+ Session,
26
+ SkillRefV1,
27
+ ToolDefinition,
28
+ ToolExecutionContext,
29
+ WorkspaceFilesV1,
30
+ WorkspaceReadsV1,
31
+ WorkspaceWriteRequestV1,
32
+ } from "@frockbot/kernel-contracts";
33
+ import { formatSkillRefV1, parseSkillRefV1 } from "@frockbot/kernel-contracts";
34
+ // Merges the Agent loop's event declarations into the cordis Context type.
35
+ import type {} from "@frockbot/kernel-agent-loop/agent";
36
+ import type { Plugin } from "cordis";
37
+ import {
38
+ botInstructionRootV1,
39
+ countSkillDocumentsV1,
40
+ emptySkillCatalogV1,
41
+ type InvokedSkillV1,
42
+ loadFullSkillCatalogV1,
43
+ renderInvokedSkillsPromptV1,
44
+ renderSkillCatalogPromptV1,
45
+ resolveSkillRefV1,
46
+ type SkillCatalogV1,
47
+ type SkillOwnerV1,
48
+ } from "./catalog.js";
49
+ import type { PluginSkillsSourceV1 } from "./plugin-index.js";
50
+ import { writeSkillDocumentV1 } from "./write.js";
51
+ import {
52
+ checkSkillQuotaV1,
53
+ SKILL_QUOTA_DEFAULTS_V1,
54
+ type SkillQuotaConfigV1,
55
+ type SkillQuotaScopeV1,
56
+ } from "./quota.js";
57
+ import {
58
+ isSkillSlugV1,
59
+ skillSlugFromNameV1,
60
+ SKILL_MAX_DESCRIPTION_LENGTH,
61
+ SKILL_MAX_NAME_LENGTH,
62
+ } from "./skill-md.js";
63
+
64
+ /** Bot write provenance: the Session and Turn that authored a Skill. */
65
+ export interface SkillWriterIdentityV1 {
66
+ sessionId: string;
67
+ turnId: string;
68
+ runId: string;
69
+ }
70
+
71
+ /**
72
+ * The host seam this Package receives. The Durable Object supplies it for one
73
+ * admitted Turn: `reads` is always present, `files` and `writer` only when the
74
+ * Turn may author, so a Bot cannot write a Skill outside a Turn whose Session
75
+ * and Turn its provenance can name.
76
+ */
77
+ export interface SkillsRuntimeHostV1 {
78
+ owner: SkillOwnerV1;
79
+ reads: WorkspaceReadsV1;
80
+ files?: WorkspaceFilesV1;
81
+ writer?: SkillWriterIdentityV1;
82
+ quota?: SkillQuotaConfigV1;
83
+ /**
84
+ * The index over the User's installed Catalog entries. Absent when the
85
+ * deployment has no Catalog, and the Turn then carries no plugin-borne
86
+ * Skills — which is the true answer, not a failure.
87
+ */
88
+ pluginSkills?: PluginSkillsSourceV1;
89
+ }
90
+
91
+ export async function sha256HexV1(text: string): Promise<string> {
92
+ const digest = await crypto.subtle.digest(
93
+ "SHA-256",
94
+ new TextEncoder().encode(text),
95
+ );
96
+ return [...new Uint8Array(digest)]
97
+ .map((byte) => byte.toString(16).padStart(2, "0"))
98
+ .join("");
99
+ }
100
+
101
+ /** The turn and step a Skill write is recorded under. */
102
+ export interface SkillTurnPositionV1 {
103
+ turn: number;
104
+ step: number;
105
+ }
106
+
107
+ /**
108
+ * The open step a Skill event belongs to. The session log is the
109
+ * reconstruction surface, so an event without its turn and step would not
110
+ * replay in place.
111
+ */
112
+ export function openSkillTurnPositionV1(session: Session): SkillTurnPositionV1 {
113
+ const started = session.events.findLast(
114
+ (event) => event.type === "step/start",
115
+ );
116
+ const ended = session.events.findLast((event) => event.type === "step/end");
117
+ if (started?.type !== "step/start") {
118
+ throw new Error("a Skill write has no open step to record against");
119
+ }
120
+ if (
121
+ ended?.type === "step/end" &&
122
+ ended.turn === started.turn &&
123
+ ended.step === started.step
124
+ ) {
125
+ throw new Error("a Skill write has no open step to record against");
126
+ }
127
+ return { turn: started.turn, step: started.step };
128
+ }
129
+
130
+ /** What resolving a Turn's invoked refs produced. Declared, never thrown. */
131
+ export type SkillInvocationOutcomeV1 =
132
+ | { status: "ok"; invoked: InvokedSkillV1[] }
133
+ | { status: "unresolved"; reason: string };
134
+
135
+ /**
136
+ * The Turn-scoped catalog. Deep module, small surface: `refresh` is the only
137
+ * way a catalog changes, and `current` is what the prompt and `skill_load`
138
+ * both read, so those two can never disagree about what this Turn loaded.
139
+ */
140
+ export class SkillCatalog {
141
+ #owner: SkillOwnerV1;
142
+ #reads: WorkspaceReadsV1;
143
+ #pluginSkills: PluginSkillsSourceV1 | undefined;
144
+ #catalog: SkillCatalogV1;
145
+ #turn: number | undefined;
146
+ #invoked: InvokedSkillV1[] = [];
147
+ #invokedTurn: number | undefined;
148
+ #step: { turn: number; step: number } | undefined;
149
+
150
+ constructor(
151
+ owner: SkillOwnerV1,
152
+ reads: WorkspaceReadsV1,
153
+ pluginSkills?: PluginSkillsSourceV1,
154
+ ) {
155
+ this.#owner = owner;
156
+ this.#reads = reads;
157
+ this.#pluginSkills = pluginSkills;
158
+ this.#catalog = emptySkillCatalogV1(owner);
159
+ }
160
+
161
+ current(): SkillCatalogV1 {
162
+ return this.#catalog;
163
+ }
164
+
165
+ loadedTurn(): number | undefined {
166
+ return this.#turn;
167
+ }
168
+
169
+ /** Loads the Turn's Skills and records the injection in the session log. */
170
+ async refresh(turn: number, session: Session): Promise<SkillCatalogV1> {
171
+ this.#catalog = await loadFullSkillCatalogV1(this.#reads, this.#owner, {
172
+ ...(this.#pluginSkills ? { pluginSkills: this.#pluginSkills } : {}),
173
+ });
174
+ this.#turn = turn;
175
+ session.append({
176
+ type: "skill/injected",
177
+ turn,
178
+ skills: this.#catalog.skills.map((skill) => ({
179
+ path: skill.path,
180
+ name: skill.name,
181
+ generationId: skill.generationId,
182
+ contentHash: skill.contentHash,
183
+ // Whose Skill it is, when it is not this Bot's own. A shared tier
184
+ // whose durable record did not say who wrote the instruction would
185
+ // make "the Bot ran under an instruction it did not author" invisible.
186
+ ...(skill.by ? { by: skill.by } : {}),
187
+ })),
188
+ refusals: this.#catalog.refusals.map((refusal) => ({
189
+ path: refusal.path,
190
+ reason: `${refusal.kind}: ${refusal.reason}`,
191
+ })),
192
+ });
193
+ await session.flush();
194
+ return this.#catalog;
195
+ }
196
+
197
+ /**
198
+ * Resolves the Skills one Turn's input invoked, and records each one.
199
+ *
200
+ * An unresolvable ref is a declared failure, never a silent drop: a User who
201
+ * typed `/daily-standup` and got an answer that ignored it would have no way
202
+ * to tell. The caller turns this into a blocked Turn with the reason.
203
+ */
204
+ async invoke(
205
+ turn: number,
206
+ session: Session,
207
+ refs: readonly SkillRefV1[],
208
+ ): Promise<SkillInvocationOutcomeV1> {
209
+ const invoked: InvokedSkillV1[] = [];
210
+ for (const ref of refs) {
211
+ const skill = resolveSkillRefV1(this.#catalog, ref);
212
+ if (!skill) {
213
+ return {
214
+ status: "unresolved",
215
+ reason: `no Skill "${formatSkillRefV1(ref)}" is available to this Bot on this Turn`,
216
+ };
217
+ }
218
+ invoked.push({ ref, skill });
219
+ }
220
+ if (invoked.length > 0) {
221
+ session.appendBatch(
222
+ invoked.map((entry) => ({
223
+ type: "skill/invoked" as const,
224
+ turn,
225
+ ref: entry.ref,
226
+ generationId: entry.skill.generationId,
227
+ contentHash: entry.skill.contentHash,
228
+ })),
229
+ );
230
+ await session.flush();
231
+ }
232
+ this.#invoked = invoked;
233
+ this.#invokedTurn = turn;
234
+ return { status: "ok", invoked };
235
+ }
236
+
237
+ /**
238
+ * The Skills whose bodies belong in the request being assembled right now.
239
+ *
240
+ * Empty unless this is the first step of the Turn that invoked them: an
241
+ * invocation expands once, into the step the User's message enters, and the
242
+ * later steps of the same Turn run on the conversation the expansion already
243
+ * produced.
244
+ */
245
+ invokedFor(turn: number, step: number): readonly InvokedSkillV1[] {
246
+ return turn === this.#invokedTurn && step === 1 ? this.#invoked : [];
247
+ }
248
+
249
+ /** The step whose request the prompt is being assembled for. */
250
+ enterStep(turn: number, step: number): void {
251
+ this.#step = { turn, step };
252
+ }
253
+
254
+ /** The invoked bodies for the open step, as the prompt section renders them. */
255
+ currentInvoked(): readonly InvokedSkillV1[] {
256
+ const open = this.#step;
257
+ return open ? this.invokedFor(open.turn, open.step) : [];
258
+ }
259
+
260
+ /** Drops the catalog, so the next Turn reloads it rather than reusing it. */
261
+ invalidate(): void {
262
+ this.#catalog = emptySkillCatalogV1(this.#owner);
263
+ this.#turn = undefined;
264
+ this.#invoked = [];
265
+ this.#invokedTurn = undefined;
266
+ this.#step = undefined;
267
+ }
268
+ }
269
+
270
+ const SKILL_LOAD_INPUT_SCHEMA = {
271
+ type: "object",
272
+ properties: {
273
+ path: {
274
+ type: "string",
275
+ description:
276
+ "The Skill's ref exactly as listed in <agent_skills> — bot/daily-standup, managed/add-connector, or plugin/<packageId>/<slug>. The path listed beside it is also accepted.",
277
+ },
278
+ },
279
+ required: ["path"],
280
+ additionalProperties: false,
281
+ } as const;
282
+
283
+ const SKILL_WRITE_INPUT_SCHEMA = {
284
+ type: "object",
285
+ properties: {
286
+ name: { type: "string", description: "The Skill's display name." },
287
+ description: {
288
+ type: "string",
289
+ description:
290
+ 'When to use this Skill, phrased as "Use this when ...". This is the only part always in your prompt.',
291
+ },
292
+ body: {
293
+ type: "string",
294
+ description: "The Markdown recipe the Skill runs through.",
295
+ },
296
+ slug: {
297
+ type: "string",
298
+ description:
299
+ "Optional directory slug, lowercase letters, digits and hyphens. Derived from the name when omitted. Reuse a slug to supersede that Skill.",
300
+ },
301
+ scope: {
302
+ type: "string",
303
+ enum: ["bot", "user"],
304
+ description:
305
+ "Where the Skill is written: your own instruction root (bot, the default), or your User's shared root (user), where every one of their Bots can read it. Managed and plugin Skills are not editable this way.",
306
+ },
307
+ },
308
+ required: ["name", "description", "body"],
309
+ additionalProperties: false,
310
+ } as const;
311
+
312
+ /** C0 controls, DEL, and the C1 range: never valid in a frontmatter scalar. */
313
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/;
314
+
315
+ /**
316
+ * Where a `skill_write` lands.
317
+ *
318
+ * All four sources are named so a refusal can be specific about *why* two of
319
+ * them are not writable, rather than reading as an unknown-field error. `bot`
320
+ * and `user` are the two instruction roots (ADR 0016) and both are written the
321
+ * same way, with the Bot's own provenance recorded. `managed` and `plugin` are
322
+ * not durable-root files at all — one is bytes of a first-party artifact, the
323
+ * other an index over a pinned Catalog generation — so neither has a write
324
+ * path to route to.
325
+ */
326
+ export type SkillWriteScopeV1 = "bot" | "user" | "managed" | "plugin";
327
+
328
+ const SKILL_WRITE_SCOPES: readonly SkillWriteScopeV1[] = [
329
+ "bot",
330
+ "user",
331
+ "managed",
332
+ "plugin",
333
+ ];
334
+
335
+ /** Why a scope is refused, or `undefined` when it is writable. GrokBot's own wording for managed. */
336
+ export function skillWriteScopeRefusalV1(
337
+ scope: SkillWriteScopeV1,
338
+ ): string | undefined {
339
+ const target = skillWriteTargetV1(scope);
340
+ return target.status === "refused" ? target.reason : undefined;
341
+ }
342
+
343
+ /**
344
+ * The instruction root a scope writes, or the reason there is none.
345
+ *
346
+ * Declared rather than narrowed at the call site: the two writable scopes are
347
+ * exactly the two instruction roots, and this is the one place that says so,
348
+ * so a caller cannot reach the write path holding `managed`.
349
+ */
350
+ export type SkillWriteTargetV1 =
351
+ | { status: "writable"; scope: SkillQuotaScopeV1 }
352
+ | { status: "refused"; reason: string };
353
+
354
+ export function skillWriteTargetV1(
355
+ scope: SkillWriteScopeV1,
356
+ ): SkillWriteTargetV1 {
357
+ switch (scope) {
358
+ case "bot":
359
+ case "user":
360
+ return { status: "writable", scope };
361
+ case "managed":
362
+ case "plugin":
363
+ return {
364
+ status: "refused",
365
+ reason: "managed skills are not editable this way",
366
+ };
367
+ }
368
+ }
369
+
370
+ interface SkillWriteInputV1 {
371
+ name: string;
372
+ description: string;
373
+ body: string;
374
+ slug?: string;
375
+ scope?: SkillWriteScopeV1;
376
+ }
377
+
378
+ function decodeSkillWriteInputV1(input: unknown): SkillWriteInputV1 {
379
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
380
+ throw new Error("skill_write input must be an object");
381
+ }
382
+ const value = input as Record<string, unknown>;
383
+ const allowed = ["name", "description", "body", "slug", "scope"];
384
+ if (!Object.keys(value).every((key) => allowed.includes(key))) {
385
+ throw new Error("skill_write input has unknown fields");
386
+ }
387
+ const text = (key: string, maximum: number, singleLine: boolean): string => {
388
+ const candidate = value[key];
389
+ if (
390
+ typeof candidate !== "string" ||
391
+ candidate.trim().length === 0 ||
392
+ candidate.length > maximum
393
+ ) {
394
+ throw new Error(`skill_write ${key} must be a bounded string`);
395
+ }
396
+ // `name` and `description` become one frontmatter line each. A newline or
397
+ // a control character there renders a `SKILL.md` this Package's own parser
398
+ // refuses, so the Bot would have written a Skill it can never load.
399
+ if (singleLine && CONTROL_CHARACTERS.test(candidate)) {
400
+ throw new Error(
401
+ `skill_write ${key} must not contain newlines or control characters`,
402
+ );
403
+ }
404
+ return candidate.trim();
405
+ };
406
+ const decoded: SkillWriteInputV1 = {
407
+ name: text("name", SKILL_MAX_NAME_LENGTH, true),
408
+ description: text("description", SKILL_MAX_DESCRIPTION_LENGTH, true),
409
+ body: text("body", 65_536, false),
410
+ };
411
+ if (value.slug !== undefined) {
412
+ if (!isSkillSlugV1(value.slug)) {
413
+ throw new Error("skill_write slug is invalid");
414
+ }
415
+ decoded.slug = value.slug;
416
+ }
417
+ if (value.scope !== undefined) {
418
+ const scope = SKILL_WRITE_SCOPES.find(
419
+ (candidate) => candidate === value.scope,
420
+ );
421
+ if (!scope) throw new Error("skill_write scope is invalid");
422
+ decoded.scope = scope;
423
+ }
424
+ return decoded;
425
+ }
426
+
427
+ export function createSkillLoadTool(catalog: SkillCatalog): ToolDefinition {
428
+ return {
429
+ name: "skill_load",
430
+ // A general work tool: the full toolset an `executor` subagent gets, and
431
+ // not part of the narrow reach of `browserUse`, `computerUse`, or the two
432
+ // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
433
+ admission: { subagentRoles: ["executor"] },
434
+ description:
435
+ "Read one of your Skills in full. Pass the path listed in <agent_skills>. Only Skills listed there can be loaded.",
436
+ inputSchema: SKILL_LOAD_INPUT_SCHEMA as unknown as Record<string, unknown>,
437
+ idempotent: true,
438
+ validate: (input: unknown) =>
439
+ !!input &&
440
+ typeof input === "object" &&
441
+ typeof (input as { path?: unknown }).path === "string",
442
+ execute: (input: unknown) => {
443
+ const named = String((input as { path: string }).path).trim();
444
+ const loaded = catalog.current().skills;
445
+ // A ref first, then the path. Both are printed in `<agent_skills>`, and
446
+ // a ref is the only form that names a managed or plugin Skill, since
447
+ // neither is a file under any root the Bot could path into.
448
+ const ref = parseSkillRefV1(named);
449
+ const skill =
450
+ (ref
451
+ ? loaded.find(
452
+ (candidate) =>
453
+ candidate.ref !== undefined &&
454
+ formatSkillRefV1(candidate.ref) === formatSkillRefV1(ref),
455
+ )
456
+ : undefined) ?? loaded.find((candidate) => candidate.path === named);
457
+ if (!skill) {
458
+ // A candidate refused as an instruction is not readable here either:
459
+ // `skill_load` discloses only what this Turn actually loaded.
460
+ return Promise.resolve({
461
+ content: `No Skill "${named}" is loaded for this Turn. Use only the refs listed in <agent_skills>.`,
462
+ isError: true,
463
+ });
464
+ }
465
+ return Promise.resolve({
466
+ content: [
467
+ `# ${skill.name}`,
468
+ `${skill.ref ? `Ref: ${formatSkillRefV1(skill.ref)}\n` : ""}Path: ${skill.path} (generation ${skill.generationId})`,
469
+ "",
470
+ skill.body,
471
+ ].join("\n"),
472
+ isError: false,
473
+ });
474
+ },
475
+ };
476
+ }
477
+
478
+ function writeRefusal(reason: string): { content: string; isError: boolean } {
479
+ return { content: `skill_write was refused: ${reason}`, isError: true };
480
+ }
481
+
482
+ /**
483
+ * The effect id one Skill write is recorded under.
484
+ *
485
+ * The root is part of it. The two instruction roots address Skills by the same
486
+ * relative path, so `bot` and `user` writes of one slug with one body would
487
+ * otherwise share an id, and a replay could match the wrong recorded effect.
488
+ * `bot` keeps its historical form, so ids already in durable logs still match.
489
+ */
490
+ function effectIdOf(
491
+ scope: SkillQuotaScopeV1,
492
+ path: string,
493
+ contentHash: string,
494
+ ): string {
495
+ return scope === "bot"
496
+ ? `skill:${path}:${contentHash}`
497
+ : `skill:${scope}:${path}:${contentHash}`;
498
+ }
499
+
500
+ export function createSkillWriteTool(
501
+ host: SkillsRuntimeHostV1 & { files: WorkspaceFilesV1 },
502
+ writer: SkillWriterIdentityV1,
503
+ sessions: { get(sessionId: string): Session | undefined },
504
+ ): ToolDefinition {
505
+ const quota = host.quota ?? SKILL_QUOTA_DEFAULTS_V1;
506
+ return {
507
+ name: "skill_write",
508
+ // A general work tool: the full toolset an `executor` subagent gets, and
509
+ // not part of the narrow reach of `browserUse`, `computerUse`, or the two
510
+ // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
511
+ admission: { subagentRoles: ["executor"] },
512
+ description:
513
+ "Write a Skill: a Markdown recipe stored under your own instruction root, or under your User's shared root where all of their Bots can read it. It becomes visible to you on your next Turn, not this one.",
514
+ inputSchema: SKILL_WRITE_INPUT_SCHEMA as unknown as Record<string, unknown>,
515
+ idempotent: false,
516
+ validate: (input: unknown) => {
517
+ try {
518
+ decodeSkillWriteInputV1(input);
519
+ return true;
520
+ } catch {
521
+ return false;
522
+ }
523
+ },
524
+ execute: async (input: unknown, context: ToolExecutionContext) => {
525
+ let decoded: SkillWriteInputV1;
526
+ try {
527
+ decoded = decodeSkillWriteInputV1(input);
528
+ } catch (error) {
529
+ return writeRefusal(
530
+ error instanceof Error ? error.message : String(error),
531
+ );
532
+ }
533
+ const target = skillWriteTargetV1(decoded.scope ?? "bot");
534
+ if (target.status === "refused") return writeRefusal(target.reason);
535
+ const scope = target.scope;
536
+ const slug = decoded.slug ?? skillSlugFromNameV1(decoded.name);
537
+ if (!slug) {
538
+ return writeRefusal(
539
+ "the Skill name yields no usable slug; pass an explicit slug",
540
+ );
541
+ }
542
+ const session = sessions.get(context.sessionId);
543
+ if (!session) {
544
+ return writeRefusal(
545
+ `session "${context.sessionId}" is unavailable, so the intent cannot be recorded`,
546
+ );
547
+ }
548
+ let position: { turn: number; step: number };
549
+ try {
550
+ position = openSkillTurnPositionV1(session);
551
+ } catch (error) {
552
+ return writeRefusal(
553
+ error instanceof Error ? error.message : String(error),
554
+ );
555
+ }
556
+ // One write path, shared with the template import (`./write.ts`); the
557
+ // only thing that differs between them is the writer, and here it is
558
+ // this Bot inside the Turn whose Session and Turn it names.
559
+ const outcome = await writeSkillDocumentV1(
560
+ host.files,
561
+ host.owner,
562
+ {
563
+ kind: "bot",
564
+ botId: host.owner.botId,
565
+ sessionId: writer.sessionId,
566
+ turnId: writer.turnId,
567
+ runId: writer.runId,
568
+ },
569
+ {
570
+ slug,
571
+ name: decoded.name,
572
+ description: decoded.description,
573
+ body: decoded.body,
574
+ },
575
+ {
576
+ scope,
577
+ quota,
578
+ // Intent before effect, and durable before the write is attempted.
579
+ onIntent: async ({ path: relativePath, contentHash }) => {
580
+ session.append({
581
+ type: "skill/write-intent",
582
+ ...position,
583
+ effectId: effectIdOf(scope, relativePath, contentHash),
584
+ path: relativePath,
585
+ contentHash,
586
+ });
587
+ await session.flush();
588
+ },
589
+ },
590
+ );
591
+ if (outcome.status === "refused") return writeRefusal(outcome.reason);
592
+ session.append({
593
+ type: "skill/written",
594
+ ...position,
595
+ effectId: effectIdOf(scope, outcome.path, outcome.contentHash),
596
+ path: outcome.path,
597
+ generationId: outcome.generationId,
598
+ contentHash: outcome.contentHash,
599
+ });
600
+ // The model must not be told it succeeded before the record is durable.
601
+ await session.flush();
602
+ return {
603
+ content: [
604
+ `Wrote Skill "${decoded.name}" to ${outcome.path} as generation ${outcome.generationId}.`,
605
+ scope === "user"
606
+ ? "It is under your User's shared instruction root, with your provenance recorded, so every one of their Bots can read it and will be told you wrote it."
607
+ : "It is under your own instruction root with your provenance recorded.",
608
+ "Your Skill catalog is fixed for this Turn, so it appears in <agent_skills> on your next Turn.",
609
+ ].join(" "),
610
+ isError: false,
611
+ };
612
+ },
613
+ };
614
+ }
615
+
616
+ /**
617
+ * The runtime Contribution. Registers the prompt section, `skill_load`, and —
618
+ * only when the host supplies a writable Workspace and Bot provenance —
619
+ * `skill_write`.
620
+ */
621
+ export function createSkillsRuntimePlugin(
622
+ host: SkillsRuntimeHostV1,
623
+ ): Plugin.Function {
624
+ const plugin: Plugin.Function = (ctx) => {
625
+ const catalog = new SkillCatalog(host.owner, host.reads, host.pluginSkills);
626
+ const disposers: Array<() => void> = [];
627
+ disposers.push(
628
+ ctx.systemPrompt.register({
629
+ id: "skills",
630
+ order: 90,
631
+ render: () =>
632
+ [
633
+ renderSkillCatalogPromptV1(catalog.current()),
634
+ renderInvokedSkillsPromptV1(catalog.currentInvoked()),
635
+ ]
636
+ .filter((block) => block.length > 0)
637
+ .join("\n\n"),
638
+ }),
639
+ );
640
+ disposers.push(ctx.tools.register(createSkillLoadTool(catalog)));
641
+ if (host.files && host.writer) {
642
+ disposers.push(
643
+ ctx.tools.register(
644
+ createSkillWriteTool(
645
+ { ...host, files: host.files },
646
+ host.writer,
647
+ ctx.sessions,
648
+ ),
649
+ ),
650
+ );
651
+ }
652
+ disposers.push(
653
+ ctx.on("agent/pre-step", async (agent, inputs, turn, step, next) => {
654
+ // Once per Turn, at its first step: "an edit is visible to the Bot on
655
+ // its next admitted Turn", so a Skill written mid-Turn does not change
656
+ // the instructions the Turn is already running under.
657
+ if (step === 1 || catalog.loadedTurn() !== turn) {
658
+ await catalog.refresh(turn, agent.session);
659
+ }
660
+ catalog.enterStep(turn, step);
661
+ if (step === 1) {
662
+ const refs = inputs.flatMap((input) => input.skills ?? []);
663
+ const outcome = await catalog.invoke(turn, agent.session, refs);
664
+ if (outcome.status === "unresolved") {
665
+ return { kind: "reject", reason: outcome.reason };
666
+ }
667
+ }
668
+ return next();
669
+ }),
670
+ );
671
+ return () => {
672
+ for (const dispose of disposers.toReversed()) dispose();
673
+ catalog.invalidate();
674
+ };
675
+ };
676
+ plugin.inject = ["tools", "systemPrompt", "sessions"];
677
+ return plugin;
678
+ }