@frockbot/plugin-bot-template 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/frockbot.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "schemaVersion": 4,
3
+ "id": "bot-template",
4
+ "displayName": "Bot templates",
5
+ "version": "0.0.1",
6
+ "compatibility": { "frockbot": ">=0.0.1" },
7
+ "dependencies": {
8
+ "settings": ">=0.0.1",
9
+ "shell": ">=0.0.1",
10
+ "ui-theme": ">=0.0.1"
11
+ },
12
+ "contributions": {
13
+ "backend": [
14
+ { "entry": "./backend", "host": "gateway" },
15
+ { "entry": "./user", "host": "user" }
16
+ ],
17
+ "runtime": { "entry": "./agent" },
18
+ "client": {
19
+ "entry": "./client",
20
+ "mounts": [
21
+ {
22
+ "slot": "frockbot.bot-settings-primary-sections",
23
+ "order": 20
24
+ },
25
+ { "slot": "frockbot.bot-settings-sections", "order": 21 }
26
+ ],
27
+ "outlets": []
28
+ }
29
+ },
30
+ "permissions": ["bot:self-manage"],
31
+ "configuration": {
32
+ "settings": [],
33
+ "connectionTypes": [],
34
+ "capabilities": [
35
+ {
36
+ "id": "bot-template-export",
37
+ "kind": "tool",
38
+ "connectionTypes": [],
39
+ "admission": { "turnTypes": ["chat"] }
40
+ }
41
+ ]
42
+ }
43
+ }
package/package.json CHANGED
@@ -1,14 +1,55 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-bot-template",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./agent": "./src/agent.ts",
9
+ "./backend": "./src/backend.ts",
10
+ "./client": "./src/client/index.ts",
11
+ "./frockbot.json": "./frockbot.json",
12
+ "./manifest": "./src/manifest.ts",
13
+ "./package.json": "./package.json",
14
+ "./scrub": "./src/scrub.ts",
15
+ "./shared": "./src/shared.ts",
16
+ "./user": "./src/user.ts"
17
+ },
18
+ "frockbot": {
19
+ "manifest": "./frockbot.json"
20
+ },
21
+ "scripts": {
22
+ "test": "bun test src",
23
+ "build": "vite build",
24
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json"
25
+ },
26
+ "dependencies": {
27
+ "@frockbot/client-core": "0.1.0",
28
+ "@frockbot/client-ui": "0.1.0",
29
+ "@frockbot/configuration-core": "0.1.0",
30
+ "@frockbot/connection-core": "0.1.0",
31
+ "@frockbot/kernel-agent-loop": "0.1.0",
32
+ "@frockbot/kernel-contracts": "0.1.0",
33
+ "@frockbot/plugin-settings": "0.1.0",
34
+ "@frockbot/plugin-shell": "0.1.0",
35
+ "@frockbot/template-core": "0.1.0",
36
+ "cordis": "4.0.0-rc.8",
37
+ "vue": "3.5.41"
38
+ },
39
+ "devDependencies": {
40
+ "@frockbot/plugin-tools": "0.1.0",
41
+ "@types/bun": "1.4.0",
42
+ "@vitejs/plugin-vue": "6.0.8",
43
+ "typescript": "5.9.3",
44
+ "vite": "8.2.2",
45
+ "vue-tsc": "3.3.10"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
6
50
  "repository": {
7
51
  "type": "git",
8
52
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
53
  "directory": "packages/plugin-bot-template"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
54
  }
14
55
  }
@@ -0,0 +1,194 @@
1
+ // The Bot's own export tool: what it admits, what it stages, and what it says.
2
+ import { describe, expect, it } from "bun:test";
3
+ import {
4
+ SessionStore,
5
+ type Session,
6
+ type ToolCall,
7
+ type ToolExecutionContext,
8
+ type TurnTypeV1,
9
+ } from "@frockbot/kernel-contracts";
10
+ import { ToolRegistry } from "@frockbot/plugin-tools";
11
+ import { Context } from "cordis";
12
+ import {
13
+ BOT_EXPORT_TEMPLATE_TOOL_V1,
14
+ createBotTemplateRuntimePlugin,
15
+ stageCommandIdV1,
16
+ } from "./agent.ts";
17
+ import type { TemplateShareReceiptV1 } from "./shared.ts";
18
+
19
+ const SESSION_ID = "user-1:budget";
20
+ const SHARE_ID = `user-1.${"a".repeat(32)}`;
21
+
22
+ function receipt(commandId: string): TemplateShareReceiptV1 {
23
+ return {
24
+ schemaVersion: 1,
25
+ commandId,
26
+ status: "applied",
27
+ share: {
28
+ schemaVersion: 1,
29
+ shareId: SHARE_ID,
30
+ hash: "b".repeat(64),
31
+ botId: "budget",
32
+ visibility: "private",
33
+ createdAt: "2026-08-31T00:00:00.000Z",
34
+ },
35
+ summary: {
36
+ schemaVersion: 1,
37
+ botId: "budget",
38
+ skills: 2,
39
+ routines: 1,
40
+ packages: 0,
41
+ publicServers: 0,
42
+ needsConnection: 1,
43
+ omitted: [{ reason: "memory", count: 1 }],
44
+ },
45
+ };
46
+ }
47
+
48
+ async function mount(
49
+ stage: (input: {
50
+ commandId: string;
51
+ botId: string;
52
+ }) => Promise<TemplateShareReceiptV1> = (input) =>
53
+ Promise.resolve(receipt(input.commandId)),
54
+ ) {
55
+ const root = new Context();
56
+ await root.plugin(SessionStore);
57
+ await root.plugin(ToolRegistry);
58
+ const session: Session = root.sessions.create(SESSION_ID);
59
+ session.appendBatch([
60
+ { type: "turn/start", turn: 1 },
61
+ { type: "step/start", turn: 1, step: 0 },
62
+ ]);
63
+ await root.plugin(
64
+ createBotTemplateRuntimePlugin({
65
+ owner: { userId: "user-1", botId: "budget" },
66
+ stageTemplate: stage,
67
+ }),
68
+ );
69
+ return { root, session, dispose: () => root.fiber.dispose() };
70
+ }
71
+
72
+ function contextFor(turnType: TurnTypeV1): ToolExecutionContext {
73
+ return {
74
+ botId: "budget",
75
+ agentId: "budget",
76
+ sessionId: SESSION_ID,
77
+ compositionGenerationId: "2026-08-31T00:00:00.000Z:0123456789abcdef",
78
+ turnType,
79
+ effectId: "tool:1:0:0",
80
+ signal: new AbortController().signal,
81
+ };
82
+ }
83
+
84
+ const call: ToolCall = {
85
+ id: "call-1",
86
+ name: BOT_EXPORT_TEMPLATE_TOOL_V1,
87
+ input: {},
88
+ };
89
+
90
+ async function invoke(
91
+ mounted: Awaited<ReturnType<typeof mount>>,
92
+ turnType: TurnTypeV1,
93
+ ) {
94
+ const context = contextFor(turnType);
95
+ const preparation = await mounted.root.tools.prepare(call, context);
96
+ if (preparation.kind === "denied") return preparation.result;
97
+ return mounted.root.tools.executePrepared(preparation, context);
98
+ }
99
+
100
+ describe("bot_export_template", () => {
101
+ it("is offered on chat turns only", async () => {
102
+ const mounted = await mount();
103
+ try {
104
+ const names = (turnType: TurnTypeV1) =>
105
+ mounted.root.tools.schemas({ turnType }).map((tool) => tool.name);
106
+ expect(names("chat")).toContain(BOT_EXPORT_TEMPLATE_TOOL_V1);
107
+ for (const turnType of ["automation", "subagent"] as const) {
108
+ expect(names(turnType)).not.toContain(BOT_EXPORT_TEMPLATE_TOOL_V1);
109
+ }
110
+ } finally {
111
+ await mounted.dispose();
112
+ }
113
+ });
114
+
115
+ it("declares itself idempotent", async () => {
116
+ const mounted = await mount();
117
+ try {
118
+ const preparation = await mounted.root.tools.prepare(
119
+ call,
120
+ contextFor("chat"),
121
+ );
122
+ expect(preparation.kind).toBe("ready");
123
+ if (preparation.kind === "ready") {
124
+ expect(preparation.idempotent).toBe(true);
125
+ }
126
+ } finally {
127
+ await mounted.dispose();
128
+ }
129
+ });
130
+
131
+ it("stages with a command id derived from the occurrence", async () => {
132
+ const staged: string[] = [];
133
+ const mounted = await mount((input) => {
134
+ staged.push(input.commandId);
135
+ return Promise.resolve(receipt(input.commandId));
136
+ });
137
+ try {
138
+ await invoke(mounted, "chat");
139
+ await invoke(mounted, "chat");
140
+ expect(staged).toEqual([
141
+ stageCommandIdV1("tool:1:0:0"),
142
+ stageCommandIdV1("tool:1:0:0"),
143
+ ]);
144
+ } finally {
145
+ await mounted.dispose();
146
+ }
147
+ });
148
+
149
+ it("records an agent-card naming what was packed and scrubbed", async () => {
150
+ const mounted = await mount();
151
+ try {
152
+ const result = await invoke(mounted, "chat");
153
+ expect(result.isError).toBe(false);
154
+ expect(result.content).toContain(SHARE_ID);
155
+ const sends = mounted.session.events.filter(
156
+ (event) => event.type === "send/to-user",
157
+ );
158
+ expect(sends).toHaveLength(1);
159
+ const payload = (sends[0] as { payload: Record<string, unknown> })
160
+ .payload;
161
+ expect(payload.type).toBe("agent-card");
162
+ expect(payload.title).toBe("Bot template staged");
163
+ expect(String(payload.body)).toContain("Memory");
164
+ expect(String(payload.body)).toContain(
165
+ "Nothing is shared until you choose",
166
+ );
167
+ } finally {
168
+ await mounted.dispose();
169
+ }
170
+ });
171
+
172
+ it("never leaves the Turn, so the card is not the last word", async () => {
173
+ const mounted = await mount();
174
+ try {
175
+ const result = await invoke(mounted, "chat");
176
+ expect(result.endsTurn).toBeUndefined();
177
+ } finally {
178
+ await mounted.dispose();
179
+ }
180
+ });
181
+
182
+ it("reports a staging failure as a refusal, not a throw", async () => {
183
+ const mounted = await mount(() =>
184
+ Promise.reject(new Error("the User authority is unavailable")),
185
+ );
186
+ try {
187
+ const result = await invoke(mounted, "chat");
188
+ expect(result.isError).toBe(true);
189
+ expect(result.content).toContain("the User authority is unavailable");
190
+ } finally {
191
+ await mounted.dispose();
192
+ }
193
+ });
194
+ });
package/src/agent.ts ADDED
@@ -0,0 +1,221 @@
1
+ // The Bot's own `bot_export_template`.
2
+ //
3
+ // GATE. Register line 446: `create_bot_share_json` is gated by `gates.botShare`
4
+ // — a turn-type gate. The manifest Capability declares `turnTypes: ["chat"]`,
5
+ // and the registration reads that ceiling back out of the manifest rather than
6
+ // restating it, so an automation or subagent Turn is never offered
7
+ // this tool. Packing a Bot into a shareable recipe is a thing a User is in the
8
+ // room for.
9
+ //
10
+ // AUTHORITY. The tool *stages*, and staging is always `visibility: "private"`.
11
+ // "Publication beyond the authoring User is a User action": choosing `link` or
12
+ // `public` is a click in Bot settings, and there is no tool argument, no
13
+ // second tool, and no host method here that could do it. What the tool returns
14
+ // is an `agent-card` naming what was packed and what was scrubbed — the one
15
+ // voice a Bot has to its User (`kernel-contracts/src/send-to-user.ts`).
16
+ //
17
+ // REPLAY. `idempotent: true`, and honestly so: the staging command id is
18
+ // derived from the durable tool-call occurrence, so a replay after eviction
19
+ // carries the same `commandId`, meets the receipt the User Durable Object
20
+ // already wrote, and reports the share it already made instead of staging a
21
+ // second one.
22
+ import {
23
+ decodeTurnTypeV1,
24
+ type Session,
25
+ type ToolDefinition,
26
+ type ToolExecutionContext,
27
+ type ToolExecutionResult,
28
+ type TurnTypeV1,
29
+ } from "@frockbot/kernel-contracts";
30
+ // Merges the Agent loop's event declarations into the cordis Context type.
31
+ import type {} from "@frockbot/kernel-agent-loop/agent";
32
+ import type { Plugin } from "cordis";
33
+ import manifest from "../frockbot.json" with { type: "json" };
34
+ import { describeTemplateSummaryV1 } from "./scrub.js";
35
+ import type { TemplateShareReceiptV1 } from "./shared.js";
36
+
37
+ export const BOT_EXPORT_TEMPLATE_TOOL_V1 = "bot_export_template";
38
+ export const BOT_TEMPLATE_EXPORT_CAPABILITY_V1 = "bot-template-export";
39
+
40
+ /** The User and Bot one admitted Turn's export runs as. */
41
+ export interface BotTemplateOwnerV1 {
42
+ userId: string;
43
+ botId: string;
44
+ }
45
+
46
+ /**
47
+ * The host seam, supplied by the Bot Durable Object for one admitted Turn.
48
+ *
49
+ * Exactly one method, and it is the User's own `template/stage` command. There
50
+ * is no `setVisibility` here and no `revoke`: a Bot cannot publish, so the seam
51
+ * it holds cannot express publication.
52
+ */
53
+ export interface BotTemplateRuntimeHostV1 {
54
+ owner: BotTemplateOwnerV1;
55
+ stageTemplate(input: {
56
+ commandId: string;
57
+ botId: string;
58
+ }): Promise<TemplateShareReceiptV1>;
59
+ }
60
+
61
+ /** The manifest's own ceiling for a Capability, read back from the manifest. */
62
+ export function botTemplateAdmissionCeilingV1(
63
+ capabilityId: string,
64
+ ): readonly TurnTypeV1[] | undefined {
65
+ const capabilities = (
66
+ manifest as {
67
+ configuration?: {
68
+ capabilities?: Array<{
69
+ id: string;
70
+ admission?: { turnTypes: string[] };
71
+ }>;
72
+ };
73
+ }
74
+ ).configuration?.capabilities;
75
+ const turnTypes = capabilities?.find(
76
+ (candidate) => candidate.id === capabilityId,
77
+ )?.admission?.turnTypes;
78
+ if (!turnTypes) return undefined;
79
+ return turnTypes.map((turnType) =>
80
+ decodeTurnTypeV1(
81
+ turnType,
82
+ `bot-template capability "${capabilityId}" admission`,
83
+ ),
84
+ );
85
+ }
86
+
87
+ function refusal(reason: string): ToolExecutionResult {
88
+ return { content: reason, isError: true };
89
+ }
90
+
91
+ /** The open step a send belongs to, mirroring the Shell's own rule. */
92
+ function openStepPositionV1(
93
+ session: Session,
94
+ tool: string,
95
+ ): { turn: number; step: number } {
96
+ const started = session.events.findLast(
97
+ (event) => event.type === "step/start",
98
+ );
99
+ const ended = session.events.findLast((event) => event.type === "step/end");
100
+ if (started?.type !== "step/start") {
101
+ throw new Error(`${tool} has no open step to record against`);
102
+ }
103
+ if (
104
+ ended?.type === "step/end" &&
105
+ ended.turn === started.turn &&
106
+ ended.step === started.step
107
+ ) {
108
+ throw new Error(`${tool} has no open step to record against`);
109
+ }
110
+ return { turn: started.turn, step: started.step };
111
+ }
112
+
113
+ /** A `commandId` derived from the occurrence, so a retry reuses one receipt. */
114
+ export function stageCommandIdV1(effectId: string): string {
115
+ return `template-stage-${effectId.replace(/[^a-zA-Z0-9._-]/g, "-")}`.slice(
116
+ 0,
117
+ 120,
118
+ );
119
+ }
120
+
121
+ const DESCRIPTION = [
122
+ "Pack yourself into a shareable Bot template: a recipe, not a backup.",
123
+ "It carries your name, description, your own Skills, your Routines' prompts,",
124
+ "the Catalog Packages your User installed, and public MCP server addresses.",
125
+ "It never carries Memory, credentials, Connections, Assignments, your model,",
126
+ "webhook keys, uploaded images, or anything from your Computer.",
127
+ "The template is staged privately and shared with nobody:",
128
+ "only your User can choose to publish it, from Bot settings.",
129
+ ].join(" ");
130
+
131
+ export function createBotExportTemplateTool(
132
+ host: BotTemplateRuntimeHostV1,
133
+ sessions: { get(sessionId: string): Session | undefined },
134
+ ): ToolDefinition {
135
+ return {
136
+ name: BOT_EXPORT_TEMPLATE_TOOL_V1,
137
+ description: DESCRIPTION,
138
+ inputSchema: {
139
+ type: "object",
140
+ properties: {},
141
+ additionalProperties: false,
142
+ },
143
+ admission: { turnTypes: ["chat"] },
144
+ // The staging command id is derived from the occurrence, so re-running
145
+ // meets the durable receipt and reports the same share.
146
+ idempotent: true,
147
+ validate: (input: unknown) =>
148
+ input === undefined ||
149
+ (typeof input === "object" &&
150
+ input !== null &&
151
+ !Array.isArray(input) &&
152
+ Object.keys(input).length === 0),
153
+ execute: async (
154
+ _input: unknown,
155
+ context: ToolExecutionContext,
156
+ ): Promise<ToolExecutionResult> => {
157
+ let receipt: TemplateShareReceiptV1;
158
+ try {
159
+ receipt = await host.stageTemplate({
160
+ commandId: stageCommandIdV1(context.effectId),
161
+ botId: host.owner.botId,
162
+ });
163
+ } catch (error) {
164
+ return refusal(
165
+ `${BOT_EXPORT_TEMPLATE_TOOL_V1} failed: ${error instanceof Error ? error.message : String(error)}`,
166
+ );
167
+ }
168
+ const body = receipt.summary
169
+ ? describeTemplateSummaryV1(receipt.summary)
170
+ : "The template was staged privately. Nothing is shared until your User chooses a visibility.";
171
+ const session = sessions.get(context.sessionId);
172
+ if (session) {
173
+ try {
174
+ session.append({
175
+ type: "send/to-user",
176
+ ...openStepPositionV1(session, BOT_EXPORT_TEMPLATE_TOOL_V1),
177
+ occurrenceId: context.effectId,
178
+ payload: {
179
+ type: "agent-card",
180
+ agentId: host.owner.botId,
181
+ title: "Bot template staged",
182
+ body,
183
+ },
184
+ });
185
+ await session.flush();
186
+ } catch {
187
+ // The share is already durable. A card that could not be recorded is
188
+ // a missing card, never a reason to look as if the export failed.
189
+ }
190
+ }
191
+ return {
192
+ content: `Staged a private Bot template (${receipt.share.shareId}). ${body}`,
193
+ isError: false,
194
+ };
195
+ },
196
+ };
197
+ }
198
+
199
+ /**
200
+ * The runtime Contribution. One tool, bounded by the manifest's own ceiling,
201
+ * and registered only when the host seam exists — a Turn with no User authority
202
+ * to stage through is a Turn where the tool is simply absent.
203
+ */
204
+ export function createBotTemplateRuntimePlugin(
205
+ host: BotTemplateRuntimeHostV1,
206
+ ): Plugin.Function {
207
+ const plugin: Plugin.Function = (ctx) => {
208
+ const ceiling = botTemplateAdmissionCeilingV1(
209
+ BOT_TEMPLATE_EXPORT_CAPABILITY_V1,
210
+ );
211
+ const dispose = ctx.tools.register(
212
+ createBotExportTemplateTool(host, ctx.sessions),
213
+ ceiling ? { admissionCeiling: ceiling } : undefined,
214
+ );
215
+ return () => dispose();
216
+ };
217
+ plugin.inject = ["tools", "sessions"];
218
+ return plugin;
219
+ }
220
+
221
+ export default createBotTemplateRuntimePlugin;