@frockbot/plugin-bot-template 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 +43 -0
- package/package.json +47 -6
- package/src/agent.test.ts +194 -0
- package/src/agent.ts +221 -0
- package/src/backend.test.ts +326 -0
- package/src/backend.ts +262 -0
- package/src/client/BotTemplateImportSection.vue +325 -0
- package/src/client/BotTemplateSection.vue +338 -0
- package/src/client/index.ts +210 -0
- package/src/client/state.ts +43 -0
- package/src/env.d.ts +6 -0
- package/src/import-apply.test.ts +455 -0
- package/src/import.test.ts +266 -0
- package/src/import.ts +292 -0
- package/src/index.ts +9 -0
- package/src/manifest.ts +3 -0
- package/src/scrub.test.ts +528 -0
- package/src/scrub.ts +523 -0
- package/src/shared.ts +620 -0
- package/src/user.test.ts +361 -0
- package/src/user.ts +913 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +31 -0
- package/README.md +0 -3
package/src/scrub.ts
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
// Building the pack — the only place scrubbing happens.
|
|
2
|
+
//
|
|
3
|
+
// The register's contract, verbatim (`docs/research/grokbot-computer.md` line
|
|
4
|
+
// 326-328): the host **never falls back to the owner's live files** — a
|
|
5
|
+
// selected item whose content is missing is filtered out rather than re-read;
|
|
6
|
+
// scrubbing lives **only in the pack arguments, never in the live files**;
|
|
7
|
+
// managed, plugin and built-in Skills are always excluded.
|
|
8
|
+
//
|
|
9
|
+
// So this module is a pure function. It takes a description of what the Bot
|
|
10
|
+
// already is and returns a `BotTemplateV1`; it reads nothing, writes nothing,
|
|
11
|
+
// and cannot reach a Workspace, a Connection, or a keyring even by accident.
|
|
12
|
+
// Every row of the scrub matrix is decided here and nowhere else, which is what
|
|
13
|
+
// makes the matrix testable as a table of plain objects.
|
|
14
|
+
//
|
|
15
|
+
// What is refused, and why:
|
|
16
|
+
//
|
|
17
|
+
// Memory, transcripts, unread state, Computer files a template is
|
|
18
|
+
// public-shareable and Memory is the User's facts under a durable root
|
|
19
|
+
// (ADR 0015 records the divergence from GrokBot's `memory:[…]`).
|
|
20
|
+
// Connections, `connectionId`, `safeMetadata`, Assignments "A Bot receives
|
|
21
|
+
// authority solely through an explicit, durable Assignment and, when
|
|
22
|
+
// required, a Connection." An import must not inherit either.
|
|
23
|
+
// `PackageInstallationView.values` setup fields may hold keys.
|
|
24
|
+
// The model assignment it names a Connection.
|
|
25
|
+
import {
|
|
26
|
+
MAX_TEMPLATE_ROUTINE_PROMPT_BYTES_V1,
|
|
27
|
+
MAX_TEMPLATE_SKILL_BODY_BYTES_V1,
|
|
28
|
+
MAX_TEMPLATE_PACKAGES_V1,
|
|
29
|
+
MAX_TEMPLATE_ROUTINES_V1,
|
|
30
|
+
MAX_TEMPLATE_SERVERS_V1,
|
|
31
|
+
MAX_TEMPLATE_SKILLS_V1,
|
|
32
|
+
decodeBotTemplateV1,
|
|
33
|
+
type BotTemplateV1,
|
|
34
|
+
type TemplateMcpServerV1,
|
|
35
|
+
type TemplatePackageV1,
|
|
36
|
+
type TemplateRoutineV1,
|
|
37
|
+
type TemplateSheepRecipeV1,
|
|
38
|
+
type TemplateSkillV1,
|
|
39
|
+
} from "@frockbot/template-core";
|
|
40
|
+
import type {
|
|
41
|
+
TemplateExportSummaryV1,
|
|
42
|
+
TemplateOmissionReasonV1,
|
|
43
|
+
TemplateOmissionV1,
|
|
44
|
+
} from "./shared.js";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One Skill candidate, as the Bot's own catalog presents it.
|
|
48
|
+
*
|
|
49
|
+
* `source` and `writer` are carried rather than pre-filtered so the matrix is
|
|
50
|
+
* decided here: the Skills loader already refuses an unattributed writer, and
|
|
51
|
+
* this refuses it again. Two independent refusals of the same rule is the
|
|
52
|
+
* point — an instruction that reached a durable root outside the Workspace file
|
|
53
|
+
* surface is data, never an instruction, and never a thing a template teaches
|
|
54
|
+
* someone else's Bot to run.
|
|
55
|
+
*/
|
|
56
|
+
export interface TemplateSkillCandidateV1 {
|
|
57
|
+
source: "bot" | "managed" | "plugin";
|
|
58
|
+
slug?: string;
|
|
59
|
+
name: string;
|
|
60
|
+
description?: string;
|
|
61
|
+
/** Absent when the body failed to load. Such a Skill is dropped, never re-read. */
|
|
62
|
+
body?: string;
|
|
63
|
+
writer: { kind: "bot" | "user" | "first-party" | "unattributed" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One Routine candidate, in the shape `RoutineViewV1` already has. */
|
|
67
|
+
export interface TemplateRoutineCandidateV1 {
|
|
68
|
+
routineId: string;
|
|
69
|
+
name: string;
|
|
70
|
+
prompt: string;
|
|
71
|
+
schedule?: string;
|
|
72
|
+
trigger?: { kind: "webhook" };
|
|
73
|
+
timezone: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One installed Package, in the shape `PackageInstallationView` already has. */
|
|
77
|
+
export interface TemplatePackageCandidateV1 {
|
|
78
|
+
packageId: string;
|
|
79
|
+
version: string;
|
|
80
|
+
state: "installed" | "disabled" | "failed";
|
|
81
|
+
catalogId?: string;
|
|
82
|
+
catalogGeneration?: string;
|
|
83
|
+
provenance?: "first-party" | "catalog";
|
|
84
|
+
/** Setup values. Present here only so the omission can be counted. */
|
|
85
|
+
values?: Record<string, unknown>;
|
|
86
|
+
/** The Catalog's own display name, when the pinned generation still has it. */
|
|
87
|
+
displayName?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One Connection candidate.
|
|
92
|
+
*
|
|
93
|
+
* `settings` is the only field carried, and only `url` and `transport` are ever
|
|
94
|
+
* read out of it. A `ConnectionView` also has `connectionId`, `safeMetadata`,
|
|
95
|
+
* `authorization` and `generation`; none of them is in this shape, so no
|
|
96
|
+
* refactor can leak one by forgetting to strip it.
|
|
97
|
+
*/
|
|
98
|
+
export interface TemplateConnectionCandidateV1 {
|
|
99
|
+
packageId: string;
|
|
100
|
+
connectionTypeId: string;
|
|
101
|
+
displayName: string;
|
|
102
|
+
state: string;
|
|
103
|
+
/** Whether this Connection Type needs a credential the importer must supply. */
|
|
104
|
+
keyed: boolean;
|
|
105
|
+
settings?: { url?: unknown; transport?: unknown };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface TemplateSourceV1 {
|
|
109
|
+
botId: string;
|
|
110
|
+
profile: {
|
|
111
|
+
name: string;
|
|
112
|
+
title?: string;
|
|
113
|
+
description?: string;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* The recipe the exported profile carries: this Bot's own generated sheep.
|
|
117
|
+
*
|
|
118
|
+
* A `SheepRecipeV1` is four layer ids — deterministic, tiny, and nobody's
|
|
119
|
+
* photograph — so it travels (ADR 0015, D1).
|
|
120
|
+
*/
|
|
121
|
+
sheep: TemplateSheepRecipeV1;
|
|
122
|
+
skills: readonly TemplateSkillCandidateV1[];
|
|
123
|
+
routines: readonly TemplateRoutineCandidateV1[];
|
|
124
|
+
packages: readonly TemplatePackageCandidateV1[];
|
|
125
|
+
connections: readonly TemplateConnectionCandidateV1[];
|
|
126
|
+
/** True when the Bot has a model assignment; it names a Connection, so it goes. */
|
|
127
|
+
hasModelAssignment?: boolean;
|
|
128
|
+
/** How many Assignments the Bot holds; counted, never carried. */
|
|
129
|
+
assignmentCount?: number;
|
|
130
|
+
sourceCatalogGeneration?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface TemplateBuildResultV1 {
|
|
134
|
+
template: BotTemplateV1;
|
|
135
|
+
summary: TemplateExportSummaryV1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A private-network or non-https URL never reaches a template.
|
|
140
|
+
*
|
|
141
|
+
* `plugin-mcp/src/ssrf.ts` refuses one on the way *out* of the deployment. A
|
|
142
|
+
* template travels further than that: it is handed to another User, whose
|
|
143
|
+
* deployment would be the one making the request. So the same classifier runs
|
|
144
|
+
* here, and a server that fails it is exported as a placeholder with no URL at
|
|
145
|
+
* all rather than as a public server someone else's Bot would dial.
|
|
146
|
+
*/
|
|
147
|
+
const BLOCKED_HOST_SUFFIXES = [
|
|
148
|
+
".local",
|
|
149
|
+
".internal",
|
|
150
|
+
".localhost",
|
|
151
|
+
".home.arpa",
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
155
|
+
"localhost",
|
|
156
|
+
"metadata.google.internal",
|
|
157
|
+
"metadata",
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
function isIpv4Literal(host: string): number[] | undefined {
|
|
161
|
+
const parts = host.split(".");
|
|
162
|
+
if (parts.length !== 4) return undefined;
|
|
163
|
+
const octets = parts.map((part) =>
|
|
164
|
+
/^\d{1,3}$/.test(part) ? Number(part) : Number.NaN,
|
|
165
|
+
);
|
|
166
|
+
if (octets.some((octet) => !Number.isInteger(octet) || octet > 255)) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
return octets;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isPrivateIpv4(octets: number[]): boolean {
|
|
173
|
+
const [a = 0, b = 0] = octets;
|
|
174
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
175
|
+
if (a === 169 && b === 254) return true;
|
|
176
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
177
|
+
if (a === 192 && b === 168) return true;
|
|
178
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
179
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
180
|
+
if (a >= 224) return true;
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isPrivateIpv6(host: string): boolean {
|
|
185
|
+
const inner =
|
|
186
|
+
host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
187
|
+
if (!inner.includes(":")) return false;
|
|
188
|
+
if (inner === "::" || inner === "::1") return true;
|
|
189
|
+
if (/^f[cd]/.test(inner) || /^fe[89ab]/.test(inner)) return true;
|
|
190
|
+
const mapped = inner.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
191
|
+
if (mapped?.[1]) {
|
|
192
|
+
const octets = isIpv4Literal(mapped[1]);
|
|
193
|
+
return octets ? isPrivateIpv4(octets) : true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The shareable form of a server URL, or `undefined` when it must not travel. */
|
|
199
|
+
export function shareableServerUrlV1(value: unknown): string | undefined {
|
|
200
|
+
if (typeof value !== "string" || !value || value.length > 2_048) {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
const url = URL.parse(value);
|
|
204
|
+
if (!url || url.protocol !== "https:" || url.username || url.password) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
const host = url.hostname.toLowerCase();
|
|
208
|
+
if (BLOCKED_HOSTNAMES.has(host)) return undefined;
|
|
209
|
+
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
const octets = isIpv4Literal(host);
|
|
213
|
+
if (octets && isPrivateIpv4(octets)) return undefined;
|
|
214
|
+
if (isPrivateIpv6(host)) return undefined;
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A slug for a template entry, derived from a readable name.
|
|
220
|
+
*
|
|
221
|
+
* Names become roles (line 330): the slug is what an importing Bot's own
|
|
222
|
+
* instruction root and Routine list will use, so it is derived from the name
|
|
223
|
+
* rather than copied from an id that means something only in the source
|
|
224
|
+
* deployment.
|
|
225
|
+
*/
|
|
226
|
+
export function templateSlugV1(value: string, fallback: string): string {
|
|
227
|
+
const slug = value
|
|
228
|
+
.toLowerCase()
|
|
229
|
+
.normalize("NFKD")
|
|
230
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
231
|
+
.replace(/^-|-$/g, "")
|
|
232
|
+
.slice(0, 96);
|
|
233
|
+
return slug || fallback;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function uniqueSlug(slug: string, taken: Set<string>): string {
|
|
237
|
+
if (!taken.has(slug)) {
|
|
238
|
+
taken.add(slug);
|
|
239
|
+
return slug;
|
|
240
|
+
}
|
|
241
|
+
for (let suffix = 2; ; suffix += 1) {
|
|
242
|
+
const candidate = `${slug.slice(0, 90)}-${suffix}`;
|
|
243
|
+
if (!taken.has(candidate)) {
|
|
244
|
+
taken.add(candidate);
|
|
245
|
+
return candidate;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
class Omissions {
|
|
251
|
+
private readonly counts = new Map<TemplateOmissionReasonV1, number>();
|
|
252
|
+
|
|
253
|
+
add(reason: TemplateOmissionReasonV1, by = 1): void {
|
|
254
|
+
if (by <= 0) return;
|
|
255
|
+
this.counts.set(reason, (this.counts.get(reason) ?? 0) + by);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
list(): TemplateOmissionV1[] {
|
|
259
|
+
return [...this.counts.entries()].map(([reason, count]) => ({
|
|
260
|
+
reason,
|
|
261
|
+
count,
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function scrubSkills(
|
|
267
|
+
source: TemplateSourceV1,
|
|
268
|
+
omissions: Omissions,
|
|
269
|
+
): TemplateSkillV1[] {
|
|
270
|
+
const slugs = new Set<string>();
|
|
271
|
+
const skills: TemplateSkillV1[] = [];
|
|
272
|
+
for (const candidate of source.skills) {
|
|
273
|
+
if (candidate.source === "managed") {
|
|
274
|
+
omissions.add("managed-skill");
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (candidate.source === "plugin") {
|
|
278
|
+
omissions.add("plugin-skill");
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (
|
|
282
|
+
candidate.writer.kind === "unattributed" ||
|
|
283
|
+
candidate.writer.kind === "first-party"
|
|
284
|
+
) {
|
|
285
|
+
omissions.add("unattributed-skill");
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
// No fallback. A Skill whose body did not load, or whose directory is not
|
|
289
|
+
// a well-formed slug, is dropped here; nothing re-reads the owner's live
|
|
290
|
+
// instruction root to fill the gap.
|
|
291
|
+
if (
|
|
292
|
+
!candidate.body ||
|
|
293
|
+
candidate.body.length > MAX_TEMPLATE_SKILL_BODY_BYTES_V1
|
|
294
|
+
) {
|
|
295
|
+
omissions.add("unreadable-skill");
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (skills.length >= MAX_TEMPLATE_SKILLS_V1) {
|
|
299
|
+
omissions.add("unreadable-skill");
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const slug = uniqueSlug(
|
|
303
|
+
candidate.slug ?? templateSlugV1(candidate.name, "skill"),
|
|
304
|
+
slugs,
|
|
305
|
+
);
|
|
306
|
+
skills.push({
|
|
307
|
+
slug,
|
|
308
|
+
name: candidate.name.slice(0, 100),
|
|
309
|
+
...(candidate.description === undefined
|
|
310
|
+
? {}
|
|
311
|
+
: { description: candidate.description.slice(0, 2_000) }),
|
|
312
|
+
body: candidate.body,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return skills;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function scrubRoutines(source: TemplateSourceV1): TemplateRoutineV1[] {
|
|
319
|
+
const slugs = new Set<string>();
|
|
320
|
+
const routines: TemplateRoutineV1[] = [];
|
|
321
|
+
for (const candidate of source.routines) {
|
|
322
|
+
if (routines.length >= MAX_TEMPLATE_ROUTINES_V1) break;
|
|
323
|
+
if (!candidate.prompt) continue;
|
|
324
|
+
const webhook = candidate.trigger?.kind === "webhook";
|
|
325
|
+
routines.push({
|
|
326
|
+
slug: uniqueSlug(templateSlugV1(candidate.name, "routine"), slugs),
|
|
327
|
+
name: candidate.name.slice(0, 100),
|
|
328
|
+
prompt: candidate.prompt.slice(0, MAX_TEMPLATE_ROUTINE_PROMPT_BYTES_V1),
|
|
329
|
+
// A webhook Routine carries its kind and nothing else. The key and its
|
|
330
|
+
// digest never leave the Bot Durable Object that minted them, and a
|
|
331
|
+
// template is a weaker place still.
|
|
332
|
+
...(webhook || !candidate.schedule
|
|
333
|
+
? {}
|
|
334
|
+
: { schedule: candidate.schedule.slice(0, 256) }),
|
|
335
|
+
timezone: candidate.timezone.slice(0, 64) || "UTC",
|
|
336
|
+
...(webhook
|
|
337
|
+
? { triggerKind: "webhook" as const }
|
|
338
|
+
: candidate.schedule
|
|
339
|
+
? { triggerKind: "cron" as const }
|
|
340
|
+
: {}),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return routines;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function scrubPackages(
|
|
347
|
+
source: TemplateSourceV1,
|
|
348
|
+
omissions: Omissions,
|
|
349
|
+
): TemplatePackageV1[] {
|
|
350
|
+
const packages: TemplatePackageV1[] = [];
|
|
351
|
+
const seen = new Set<string>();
|
|
352
|
+
for (const candidate of source.packages) {
|
|
353
|
+
if (candidate.values !== undefined) omissions.add("package-values");
|
|
354
|
+
if (candidate.state !== "installed") continue;
|
|
355
|
+
if (
|
|
356
|
+
candidate.provenance === "first-party" ||
|
|
357
|
+
candidate.provenance === undefined ||
|
|
358
|
+
!candidate.catalogId
|
|
359
|
+
) {
|
|
360
|
+
// Nothing to install: a first-party Package is compiled into whatever
|
|
361
|
+
// application the importer is running, so a reference would be noise.
|
|
362
|
+
omissions.add("first-party-package");
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (seen.has(candidate.catalogId)) continue;
|
|
366
|
+
if (packages.length >= MAX_TEMPLATE_PACKAGES_V1) continue;
|
|
367
|
+
seen.add(candidate.catalogId);
|
|
368
|
+
packages.push({
|
|
369
|
+
packageId: candidate.packageId,
|
|
370
|
+
catalogId: candidate.catalogId,
|
|
371
|
+
version: candidate.version.slice(0, 100),
|
|
372
|
+
displayName: (candidate.displayName || candidate.packageId).slice(0, 100),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return packages;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function scrubServers(
|
|
379
|
+
source: TemplateSourceV1,
|
|
380
|
+
omissions: Omissions,
|
|
381
|
+
): TemplateMcpServerV1[] {
|
|
382
|
+
const servers: TemplateMcpServerV1[] = [];
|
|
383
|
+
for (const candidate of source.connections) {
|
|
384
|
+
// Every Connection is omitted as a Connection: what may travel is a
|
|
385
|
+
// *description* of the server it points at, never the Connection itself.
|
|
386
|
+
omissions.add("connection");
|
|
387
|
+
if (servers.length >= MAX_TEMPLATE_SERVERS_V1) continue;
|
|
388
|
+
if (candidate.state !== "ready") continue;
|
|
389
|
+
// What may travel is a description of a *server*, and a Connection is one
|
|
390
|
+
// only when it names an endpoint. A model account, a provider grant, or
|
|
391
|
+
// any other Connection has nothing a recipe could describe, so it is
|
|
392
|
+
// omitted as a Connection and nothing else — rather than becoming a
|
|
393
|
+
// placeholder telling the importer to connect something that is not a
|
|
394
|
+
// server at all.
|
|
395
|
+
if (candidate.settings?.url === undefined) continue;
|
|
396
|
+
const url = shareableServerUrlV1(candidate.settings.url);
|
|
397
|
+
if (candidate.keyed) {
|
|
398
|
+
if (url === undefined) omissions.add("private-network-server");
|
|
399
|
+
// A keyed server is always a placeholder: the importer supplies their own
|
|
400
|
+
// key, and the URL is not carried at all, so a custom server behind a
|
|
401
|
+
// private network cannot be pointed at from someone else's deployment.
|
|
402
|
+
servers.push({
|
|
403
|
+
kind: "needs-connection",
|
|
404
|
+
name: candidate.displayName.slice(0, 100),
|
|
405
|
+
connectionTypeId: candidate.connectionTypeId,
|
|
406
|
+
hint: "This server needs your own Connection and credential.",
|
|
407
|
+
});
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (url === undefined) {
|
|
411
|
+
omissions.add("private-network-server");
|
|
412
|
+
servers.push({
|
|
413
|
+
kind: "needs-connection",
|
|
414
|
+
name: candidate.displayName.slice(0, 100),
|
|
415
|
+
connectionTypeId: candidate.connectionTypeId,
|
|
416
|
+
hint: "This server's address is not reachable from another deployment.",
|
|
417
|
+
});
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const transport =
|
|
421
|
+
candidate.settings?.transport === "sse" ? "sse" : "streamable-http";
|
|
422
|
+
servers.push({
|
|
423
|
+
kind: "public",
|
|
424
|
+
name: candidate.displayName.slice(0, 100),
|
|
425
|
+
url,
|
|
426
|
+
transport,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return servers;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Build one template from what the Bot already is. Pure; never re-reads. */
|
|
433
|
+
export function buildBotTemplateV1(
|
|
434
|
+
source: TemplateSourceV1,
|
|
435
|
+
): TemplateBuildResultV1 {
|
|
436
|
+
const omissions = new Omissions();
|
|
437
|
+
if (source.hasModelAssignment) omissions.add("model");
|
|
438
|
+
omissions.add("assignment", source.assignmentCount ?? 0);
|
|
439
|
+
// Memory is never read, so there is nothing to count; the omission is
|
|
440
|
+
// recorded unconditionally because it is the one a User most needs told.
|
|
441
|
+
omissions.add("memory");
|
|
442
|
+
|
|
443
|
+
const skills = scrubSkills(source, omissions);
|
|
444
|
+
const routines = scrubRoutines(source);
|
|
445
|
+
const packages = scrubPackages(source, omissions);
|
|
446
|
+
const mcpServers = scrubServers(source, omissions);
|
|
447
|
+
|
|
448
|
+
const template = decodeBotTemplateV1({
|
|
449
|
+
schemaVersion: 1,
|
|
450
|
+
profile: {
|
|
451
|
+
name: source.profile.name.slice(0, 100),
|
|
452
|
+
...(source.profile.title
|
|
453
|
+
? { title: source.profile.title.slice(0, 120) }
|
|
454
|
+
: {}),
|
|
455
|
+
...(source.profile.description
|
|
456
|
+
? { description: source.profile.description.slice(0, 10_000) }
|
|
457
|
+
: {}),
|
|
458
|
+
avatar: { kind: "sheep", recipe: source.sheep },
|
|
459
|
+
},
|
|
460
|
+
skills,
|
|
461
|
+
routines,
|
|
462
|
+
packages,
|
|
463
|
+
mcpServers,
|
|
464
|
+
...(source.sourceCatalogGeneration
|
|
465
|
+
? { sourceCatalogGeneration: source.sourceCatalogGeneration }
|
|
466
|
+
: {}),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
template,
|
|
471
|
+
summary: {
|
|
472
|
+
schemaVersion: 1,
|
|
473
|
+
botId: source.botId,
|
|
474
|
+
skills: template.skills.length,
|
|
475
|
+
routines: template.routines.length,
|
|
476
|
+
packages: template.packages.length,
|
|
477
|
+
publicServers: template.mcpServers.filter(
|
|
478
|
+
(server) => server.kind === "public",
|
|
479
|
+
).length,
|
|
480
|
+
needsConnection: template.mcpServers.filter(
|
|
481
|
+
(server) => server.kind === "needs-connection",
|
|
482
|
+
).length,
|
|
483
|
+
omitted: omissions.list(),
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** One line per section, for the `agent-card` a Bot returns. */
|
|
489
|
+
export function describeTemplateSummaryV1(
|
|
490
|
+
summary: TemplateExportSummaryV1,
|
|
491
|
+
): string {
|
|
492
|
+
const packed = [
|
|
493
|
+
`${summary.skills} Skill${summary.skills === 1 ? "" : "s"}`,
|
|
494
|
+
`${summary.routines} Routine${summary.routines === 1 ? "" : "s"}`,
|
|
495
|
+
`${summary.packages} Package${summary.packages === 1 ? "" : "s"}`,
|
|
496
|
+
`${summary.publicServers} public MCP server${summary.publicServers === 1 ? "" : "s"}`,
|
|
497
|
+
].join(", ");
|
|
498
|
+
const scrubbed: string[] = ["Memory", "Connections", "Assignments"];
|
|
499
|
+
if (summary.needsConnection > 0) {
|
|
500
|
+
scrubbed.push(
|
|
501
|
+
`${summary.needsConnection} server${summary.needsConnection === 1 ? "" : "s"} left as a placeholder`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
for (const omission of summary.omitted) {
|
|
505
|
+
if (omission.reason === "managed-skill") {
|
|
506
|
+
scrubbed.push(`${omission.count} managed Skill(s)`);
|
|
507
|
+
}
|
|
508
|
+
if (omission.reason === "plugin-skill") {
|
|
509
|
+
scrubbed.push(`${omission.count} plugin Skill(s)`);
|
|
510
|
+
}
|
|
511
|
+
if (omission.reason === "unattributed-skill") {
|
|
512
|
+
scrubbed.push(`${omission.count} Skill(s) with no recorded writer`);
|
|
513
|
+
}
|
|
514
|
+
if (omission.reason === "package-values") {
|
|
515
|
+
scrubbed.push("Package setup values");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return [
|
|
519
|
+
`Packed ${packed}.`,
|
|
520
|
+
`Scrubbed: ${scrubbed.join("; ")}.`,
|
|
521
|
+
"Nothing is shared until you choose a visibility.",
|
|
522
|
+
].join(" ");
|
|
523
|
+
}
|