bopodev-api 0.1.31 → 0.1.33
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/package.json +10 -4
- package/src/app.ts +2 -0
- package/src/lib/agent-config.ts +58 -10
- package/src/lib/builtin-bopo-skills/bopodev-control-plane.md +123 -0
- package/src/lib/builtin-bopo-skills/bopodev-create-agent.md +90 -0
- package/src/lib/builtin-bopo-skills/index.ts +36 -0
- package/src/lib/builtin-bopo-skills/para-memory-files.md +48 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/routes/agents.ts +21 -7
- package/src/routes/assistant.ts +109 -0
- package/src/routes/companies.ts +112 -1
- package/src/routes/observability.ts +299 -1
- package/src/services/company-assistant-brain.ts +50 -0
- package/src/services/company-assistant-cli.ts +388 -0
- package/src/services/company-assistant-context-snapshot.ts +287 -0
- package/src/services/company-assistant-llm.ts +375 -0
- package/src/services/company-assistant-service.ts +1012 -0
- package/src/services/company-file-archive-service.ts +445 -0
- package/src/services/company-file-import-service.ts +279 -0
- package/src/services/company-skill-file-service.ts +558 -0
- package/src/services/governance-service.ts +11 -3
- package/src/services/heartbeat-service/heartbeat-run.ts +45 -8
- package/src/services/memory-file-service.ts +70 -0
- package/src/services/template-catalog.ts +19 -6
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import TurndownService from "turndown";
|
|
5
|
+
import { isInsidePath, resolveCompanySkillsPath } from "../lib/instance-paths";
|
|
6
|
+
|
|
7
|
+
const MAX_OBSERVABILITY_FILES = 200;
|
|
8
|
+
const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
|
|
9
|
+
const SKILL_MD = "SKILL.md";
|
|
10
|
+
export const SKILL_LINK_BASENAME = ".bopo-skill-link.json";
|
|
11
|
+
const SKILL_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
12
|
+
const TEXT_EXT = new Set([".md", ".yaml", ".yml", ".txt", ".json"]);
|
|
13
|
+
|
|
14
|
+
const STARTER_SKILL_MD = `---
|
|
15
|
+
name: new-skill
|
|
16
|
+
description: >
|
|
17
|
+
One-line description of when to use this skill.
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# New skill
|
|
21
|
+
|
|
22
|
+
Add guidance for the agent here.
|
|
23
|
+
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
export type CompanySkillPackageListItem = {
|
|
27
|
+
skillId: string;
|
|
28
|
+
linkedUrl: string | null;
|
|
29
|
+
linkLastFetchedAt: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function assertCompanySkillId(skillId: string): string {
|
|
33
|
+
const trimmed = skillId.trim();
|
|
34
|
+
if (trimmed !== skillId || !SKILL_ID_RE.test(trimmed)) {
|
|
35
|
+
throw new Error("Invalid skill id: use letters, digits, underscores, and hyphens only.");
|
|
36
|
+
}
|
|
37
|
+
return trimmed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertSkillRelativePath(relativePath: string): string {
|
|
41
|
+
const normalized = relativePath.trim().replace(/\\/g, "/");
|
|
42
|
+
if (!normalized || normalized.includes("..") || normalized.startsWith("/")) {
|
|
43
|
+
throw new Error("Invalid relative path.");
|
|
44
|
+
}
|
|
45
|
+
const base = normalized.split("/").pop() ?? "";
|
|
46
|
+
const lower = base.toLowerCase();
|
|
47
|
+
const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : "";
|
|
48
|
+
if (!TEXT_EXT.has(ext)) {
|
|
49
|
+
throw new Error("Only text skill files (.md, .yaml, .yml, .txt, .json) are allowed.");
|
|
50
|
+
}
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function skillRoot(companyId: string, skillId: string) {
|
|
55
|
+
const id = assertCompanySkillId(skillId);
|
|
56
|
+
const skillsRoot = resolveCompanySkillsPath(companyId);
|
|
57
|
+
const root = join(skillsRoot, id);
|
|
58
|
+
if (!isInsidePath(skillsRoot, root)) {
|
|
59
|
+
throw new Error("Invalid skill path.");
|
|
60
|
+
}
|
|
61
|
+
return { skillsRoot, root, id };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function skillDirHasSkillMd(root: string): Promise<boolean> {
|
|
65
|
+
try {
|
|
66
|
+
const s = await stat(join(root, SKILL_MD));
|
|
67
|
+
return s.isFile();
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type CompanySkillLinkRecord = {
|
|
74
|
+
url: string;
|
|
75
|
+
lastFetchedAt: string | null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export async function readOptionalSkillLinkRecord(root: string): Promise<CompanySkillLinkRecord | null> {
|
|
79
|
+
try {
|
|
80
|
+
const raw = await readFile(join(root, SKILL_LINK_BASENAME), "utf8");
|
|
81
|
+
const parsed: unknown = JSON.parse(raw);
|
|
82
|
+
if (!parsed || typeof parsed !== "object" || !("url" in parsed)) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const urlRaw = (parsed as { url: unknown }).url;
|
|
86
|
+
if (typeof urlRaw !== "string" || !urlRaw.trim()) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const url = assertImportUrl(urlRaw.trim()).toString();
|
|
90
|
+
const lastRaw = (parsed as { lastFetchedAt?: unknown }).lastFetchedAt;
|
|
91
|
+
const lastFetchedAt =
|
|
92
|
+
typeof lastRaw === "string" && lastRaw.trim() ? lastRaw.trim() : null;
|
|
93
|
+
return { url, lastFetchedAt };
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function readOptionalSkillLinkUrl(root: string): Promise<string | null> {
|
|
100
|
+
const rec = await readOptionalSkillLinkRecord(root);
|
|
101
|
+
return rec?.url ?? null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function writeSkillLinkMetadata(root: string, url: string): Promise<{ lastFetchedAt: string }> {
|
|
105
|
+
const lastFetchedAt = new Date().toISOString();
|
|
106
|
+
await writeFile(
|
|
107
|
+
join(root, SKILL_LINK_BASENAME),
|
|
108
|
+
JSON.stringify({ url, lastFetchedAt }, null, 2),
|
|
109
|
+
"utf8"
|
|
110
|
+
);
|
|
111
|
+
return { lastFetchedAt };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function listCompanySkillPackages(input: { companyId: string; maxSkills?: number }) {
|
|
115
|
+
const skillsRoot = resolveCompanySkillsPath(input.companyId);
|
|
116
|
+
await mkdir(skillsRoot, { recursive: true });
|
|
117
|
+
const maxSkills = Math.max(1, Math.min(100, input.maxSkills ?? 50));
|
|
118
|
+
const entries = await readdir(skillsRoot, { withFileTypes: true });
|
|
119
|
+
const items: CompanySkillPackageListItem[] = [];
|
|
120
|
+
for (const ent of entries) {
|
|
121
|
+
if (!ent.isDirectory() || ent.name.startsWith(".")) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!SKILL_ID_RE.test(ent.name)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const skillDir = join(skillsRoot, ent.name);
|
|
128
|
+
const hasMd = await skillDirHasSkillMd(skillDir);
|
|
129
|
+
const linkRec = await readOptionalSkillLinkRecord(skillDir);
|
|
130
|
+
const linkedUrl = linkRec?.url ?? null;
|
|
131
|
+
if (!hasMd && !linkedUrl) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
items.push({
|
|
135
|
+
skillId: ent.name,
|
|
136
|
+
linkedUrl,
|
|
137
|
+
linkLastFetchedAt: linkRec?.lastFetchedAt ?? null
|
|
138
|
+
});
|
|
139
|
+
if (items.length >= maxSkills) {
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
items.sort((a, b) => a.skillId.localeCompare(b.skillId));
|
|
144
|
+
return { skillsRoot, items };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function walkSkillTextFiles(skillDir: string, maxFiles: number): Promise<string[]> {
|
|
148
|
+
const collected: string[] = [];
|
|
149
|
+
const queue = [skillDir];
|
|
150
|
+
while (queue.length > 0 && collected.length < maxFiles) {
|
|
151
|
+
const current = queue.shift();
|
|
152
|
+
if (!current) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (collected.length >= maxFiles) {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const absolutePath = join(current, entry.name);
|
|
161
|
+
if (entry.isDirectory()) {
|
|
162
|
+
if (!entry.name.startsWith(".")) {
|
|
163
|
+
queue.push(absolutePath);
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (entry.name.startsWith(".")) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const lower = entry.name.toLowerCase();
|
|
171
|
+
const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : "";
|
|
172
|
+
if (TEXT_EXT.has(ext)) {
|
|
173
|
+
collected.push(relative(skillDir, absolutePath).replace(/\\/g, "/"));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return collected.sort((a, b) => a.localeCompare(b));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function listCompanySkillFiles(input: { companyId: string; skillId: string; maxFiles?: number }) {
|
|
181
|
+
const { root } = await skillRoot(input.companyId, input.skillId);
|
|
182
|
+
await mkdir(root, { recursive: true });
|
|
183
|
+
const hasMd = await skillDirHasSkillMd(root);
|
|
184
|
+
const linkedUrl = await readOptionalSkillLinkUrl(root);
|
|
185
|
+
if (!hasMd && !linkedUrl) {
|
|
186
|
+
throw new Error("Skill not found.");
|
|
187
|
+
}
|
|
188
|
+
const maxFiles = Math.max(1, Math.min(MAX_OBSERVABILITY_FILES, input.maxFiles ?? 100));
|
|
189
|
+
const relativePaths = hasMd ? await walkSkillTextFiles(root, maxFiles) : [SKILL_MD];
|
|
190
|
+
return { root, relativePaths, hasLocalSkillMd: hasMd };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function readCompanySkillFile(input: {
|
|
194
|
+
companyId: string;
|
|
195
|
+
skillId: string;
|
|
196
|
+
relativePath: string;
|
|
197
|
+
}) {
|
|
198
|
+
const { root } = await skillRoot(input.companyId, input.skillId);
|
|
199
|
+
const rel = assertSkillRelativePath(input.relativePath);
|
|
200
|
+
const candidate = resolve(root, rel);
|
|
201
|
+
if (!isInsidePath(root, candidate)) {
|
|
202
|
+
throw new Error("Requested path is outside of skill directory.");
|
|
203
|
+
}
|
|
204
|
+
const hasMd = await skillDirHasSkillMd(root);
|
|
205
|
+
const linkedUrl = await readOptionalSkillLinkUrl(root);
|
|
206
|
+
if (rel === SKILL_MD && linkedUrl && !hasMd) {
|
|
207
|
+
const content = await fetchSkillMarkdownFromUrl(new URL(linkedUrl));
|
|
208
|
+
const sizeBytes = Buffer.byteLength(content, "utf8");
|
|
209
|
+
return {
|
|
210
|
+
relativePath: rel,
|
|
211
|
+
content,
|
|
212
|
+
sizeBytes
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const info = await stat(candidate);
|
|
216
|
+
if (!info.isFile()) {
|
|
217
|
+
throw new Error("Requested path is not a file.");
|
|
218
|
+
}
|
|
219
|
+
if (info.size > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
220
|
+
throw new Error("File exceeds size limit.");
|
|
221
|
+
}
|
|
222
|
+
const content = await readFile(candidate, "utf8");
|
|
223
|
+
return {
|
|
224
|
+
relativePath: rel,
|
|
225
|
+
content,
|
|
226
|
+
sizeBytes: info.size
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function writeCompanySkillFile(input: {
|
|
231
|
+
companyId: string;
|
|
232
|
+
skillId: string;
|
|
233
|
+
relativePath: string;
|
|
234
|
+
content: string;
|
|
235
|
+
}) {
|
|
236
|
+
const { root } = await skillRoot(input.companyId, input.skillId);
|
|
237
|
+
const rel = assertSkillRelativePath(input.relativePath);
|
|
238
|
+
const candidate = resolve(root, rel);
|
|
239
|
+
if (!isInsidePath(root, candidate)) {
|
|
240
|
+
throw new Error("Requested path is outside of skill directory.");
|
|
241
|
+
}
|
|
242
|
+
const hasMd = await skillDirHasSkillMd(root);
|
|
243
|
+
const linkedUrl = await readOptionalSkillLinkUrl(root);
|
|
244
|
+
if (linkedUrl && !hasMd && rel !== SKILL_MD) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
"This skill is linked from a URL. Save SKILL.md to your workspace first, then you can add other files."
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const bytes = Buffer.byteLength(input.content, "utf8");
|
|
250
|
+
if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
251
|
+
throw new Error("Content exceeds size limit.");
|
|
252
|
+
}
|
|
253
|
+
const parent = dirname(candidate);
|
|
254
|
+
if (!isInsidePath(root, parent)) {
|
|
255
|
+
throw new Error("Invalid parent directory.");
|
|
256
|
+
}
|
|
257
|
+
await mkdir(parent, { recursive: true });
|
|
258
|
+
await writeFile(candidate, input.content, { encoding: "utf8" });
|
|
259
|
+
const info = await stat(candidate);
|
|
260
|
+
return {
|
|
261
|
+
relativePath: rel,
|
|
262
|
+
sizeBytes: info.size
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Remove `skills/<id>/` entirely (local files and linked-skill pointer). */
|
|
267
|
+
export async function deleteCompanySkillPackage(input: { companyId: string; skillId: string }) {
|
|
268
|
+
const { root, id } = await skillRoot(input.companyId, input.skillId);
|
|
269
|
+
let exists = false;
|
|
270
|
+
try {
|
|
271
|
+
const st = await stat(root);
|
|
272
|
+
exists = st.isDirectory();
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
|
|
275
|
+
if (code === "ENOENT") {
|
|
276
|
+
throw new Error("Skill not found.");
|
|
277
|
+
}
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
if (!exists) {
|
|
281
|
+
throw new Error("Skill not found.");
|
|
282
|
+
}
|
|
283
|
+
const hasMd = await skillDirHasSkillMd(root);
|
|
284
|
+
const linkedUrl = await readOptionalSkillLinkUrl(root);
|
|
285
|
+
if (!hasMd && !linkedUrl) {
|
|
286
|
+
throw new Error("Skill not found.");
|
|
287
|
+
}
|
|
288
|
+
await rm(root, { recursive: true, force: true });
|
|
289
|
+
return { skillId: id };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Create `skills/<id>/SKILL.md` if the package does not exist yet. */
|
|
293
|
+
export async function createCompanySkillPackage(input: { companyId: string; skillId: string }) {
|
|
294
|
+
const { root, id } = await skillRoot(input.companyId, input.skillId);
|
|
295
|
+
await mkdir(root, { recursive: true });
|
|
296
|
+
const manifest = join(root, SKILL_MD);
|
|
297
|
+
try {
|
|
298
|
+
await stat(manifest);
|
|
299
|
+
throw new Error("A skill with this id already exists.");
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (error instanceof Error && error.message === "A skill with this id already exists.") {
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
await writeFile(manifest, STARTER_SKILL_MD, { encoding: "utf8" });
|
|
306
|
+
return { skillId: id, relativePath: SKILL_MD };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const IMPORT_MAX_BYTES = MAX_OBSERVABILITY_FILE_BYTES;
|
|
310
|
+
const IMPORT_TIMEOUT_MS = 20_000;
|
|
311
|
+
const ALLOWED_IMPORT_HOSTS = new Set([
|
|
312
|
+
"raw.githubusercontent.com",
|
|
313
|
+
"gist.githubusercontent.com",
|
|
314
|
+
"skills.sh",
|
|
315
|
+
"www.skills.sh"
|
|
316
|
+
]);
|
|
317
|
+
|
|
318
|
+
/** skills.sh renders the skill as HTML; we scrape the embedded prose block and convert to markdown. */
|
|
319
|
+
const SKILLS_SH_PROSE_RE =
|
|
320
|
+
/SKILL\.md<\/span><\/div><div class="prose[^"]*">([\s\S]*?)<\/div><\/div><\/div><div class="\s*lg:col-span-3">/;
|
|
321
|
+
|
|
322
|
+
let turndownSingleton: TurndownService | null = null;
|
|
323
|
+
|
|
324
|
+
function getTurndown(): TurndownService {
|
|
325
|
+
if (!turndownSingleton) {
|
|
326
|
+
turndownSingleton = new TurndownService({
|
|
327
|
+
headingStyle: "atx",
|
|
328
|
+
codeBlockStyle: "fenced",
|
|
329
|
+
bulletListMarker: "-"
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return turndownSingleton;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isSkillsShHost(hostname: string): boolean {
|
|
336
|
+
return hostname === "skills.sh" || hostname === "www.skills.sh";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function looksLikeHtmlDocument(text: string): boolean {
|
|
340
|
+
const head = text.slice(0, 8000).trimStart();
|
|
341
|
+
return head.startsWith("<!DOCTYPE") || head.startsWith("<html");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function extractSkillsShProseHtml(pageHtml: string): string | null {
|
|
345
|
+
const m = pageHtml.match(SKILLS_SH_PROSE_RE);
|
|
346
|
+
return m?.[1]?.trim() ? m[1] : null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function htmlProseFragmentToMarkdown(htmlFragment: string): string {
|
|
350
|
+
return getTurndown().turndown(htmlFragment).trim();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function assertImportUrl(urlString: string): URL {
|
|
354
|
+
let url: URL;
|
|
355
|
+
try {
|
|
356
|
+
url = new URL(urlString.trim());
|
|
357
|
+
} catch {
|
|
358
|
+
throw new Error("Invalid URL.");
|
|
359
|
+
}
|
|
360
|
+
if (url.protocol !== "https:") {
|
|
361
|
+
throw new Error("Only https URLs are allowed.");
|
|
362
|
+
}
|
|
363
|
+
if (!ALLOWED_IMPORT_HOSTS.has(url.hostname)) {
|
|
364
|
+
throw new Error(`Host not allowed. Use one of: ${[...ALLOWED_IMPORT_HOSTS].join(", ")}`);
|
|
365
|
+
}
|
|
366
|
+
return url;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function parseYamlNameFromSkillFrontmatter(markdown: string): string | null {
|
|
370
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
371
|
+
if (!match) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
const block = match[1] ?? "";
|
|
375
|
+
const lineMatch = block.match(/^\s*name:\s*(.+)$/m);
|
|
376
|
+
if (!lineMatch) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
let v = (lineMatch[1] ?? "").trim();
|
|
380
|
+
if (
|
|
381
|
+
(v.startsWith('"') && v.endsWith('"')) ||
|
|
382
|
+
(v.startsWith("'") && v.endsWith("'"))
|
|
383
|
+
) {
|
|
384
|
+
v = v.slice(1, -1).trim();
|
|
385
|
+
}
|
|
386
|
+
return v.length > 0 ? v : null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function inferSkillIdFromUrlPath(url: URL): string {
|
|
390
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
391
|
+
if (parts.length === 0) {
|
|
392
|
+
return "linked-skill";
|
|
393
|
+
}
|
|
394
|
+
const last = parts[parts.length - 1] ?? "";
|
|
395
|
+
const base =
|
|
396
|
+
last.toLowerCase() === "skill.md" && parts.length >= 2 ? (parts[parts.length - 2] ?? last) : last;
|
|
397
|
+
return base.length > 0 ? base : "linked-skill";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Maps a title or path segment to a valid company skill folder id. */
|
|
401
|
+
function slugifyForCompanySkillId(raw: string): string {
|
|
402
|
+
const s = raw
|
|
403
|
+
.trim()
|
|
404
|
+
.toLowerCase()
|
|
405
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
406
|
+
.replace(/^-+|-+$/g, "")
|
|
407
|
+
.replace(/-+/g, "-");
|
|
408
|
+
return s.length > 0 ? s : "linked-skill";
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Single fetch; used when linking from URL so SKILL.md and link metadata stay in sync. */
|
|
412
|
+
async function resolveIdAndMarkdownForUrlLink(input: {
|
|
413
|
+
url: URL;
|
|
414
|
+
explicitSkillId?: string;
|
|
415
|
+
}): Promise<{ id: string; markdown: string }> {
|
|
416
|
+
const markdown = await fetchSkillMarkdownFromUrl(input.url);
|
|
417
|
+
if (input.explicitSkillId !== undefined && input.explicitSkillId.trim()) {
|
|
418
|
+
return { id: assertCompanySkillId(input.explicitSkillId), markdown };
|
|
419
|
+
}
|
|
420
|
+
const fromFrontmatter = parseYamlNameFromSkillFrontmatter(markdown);
|
|
421
|
+
const candidate = fromFrontmatter ?? inferSkillIdFromUrlPath(input.url);
|
|
422
|
+
const id = slugifyForCompanySkillId(candidate);
|
|
423
|
+
try {
|
|
424
|
+
return { id: assertCompanySkillId(id), markdown };
|
|
425
|
+
} catch {
|
|
426
|
+
return {
|
|
427
|
+
id: assertCompanySkillId(slugifyForCompanySkillId(inferSkillIdFromUrlPath(input.url))),
|
|
428
|
+
markdown
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function fetchSkillMarkdownFromUrl(url: URL): Promise<string> {
|
|
434
|
+
const controller = new AbortController();
|
|
435
|
+
const timer = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_MS);
|
|
436
|
+
try {
|
|
437
|
+
const res = await fetch(url, {
|
|
438
|
+
method: "GET",
|
|
439
|
+
redirect: "follow",
|
|
440
|
+
signal: controller.signal,
|
|
441
|
+
headers: { Accept: "text/html,text/plain,text/markdown,application/xhtml+xml,*/*" }
|
|
442
|
+
});
|
|
443
|
+
if (!res.ok) {
|
|
444
|
+
throw new Error(`Fetch failed with status ${res.status}.`);
|
|
445
|
+
}
|
|
446
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
447
|
+
if (buf.byteLength > IMPORT_MAX_BYTES) {
|
|
448
|
+
throw new Error("Downloaded file exceeds size limit.");
|
|
449
|
+
}
|
|
450
|
+
const text = new TextDecoder("utf8", { fatal: false }).decode(buf);
|
|
451
|
+
if (!text.trim()) {
|
|
452
|
+
throw new Error("Downloaded file is empty.");
|
|
453
|
+
}
|
|
454
|
+
if (isSkillsShHost(url.hostname) && looksLikeHtmlDocument(text)) {
|
|
455
|
+
const fragment = extractSkillsShProseHtml(text);
|
|
456
|
+
if (!fragment) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
"Could not read skill text from this skills.sh page (layout changed or not a skill detail page). Try a raw GitHub URL to SKILL.md."
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const md = htmlProseFragmentToMarkdown(fragment);
|
|
462
|
+
if (!md.trim()) {
|
|
463
|
+
throw new Error("Converted skills.sh content was empty.");
|
|
464
|
+
}
|
|
465
|
+
return `${md}\n`;
|
|
466
|
+
}
|
|
467
|
+
if (looksLikeHtmlDocument(text)) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
"URL returned HTML, not markdown. Use skills.sh skill pages, or raw.githubusercontent.com / gist.githubusercontent.com links to SKILL.md."
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
return text;
|
|
473
|
+
} finally {
|
|
474
|
+
clearTimeout(timer);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Download SKILL.md, write `.bopo-skill-link.json` with url and lastFetchedAt (legacy link-only dirs still fetch on read until refreshed). */
|
|
479
|
+
export async function linkCompanySkillFromUrl(input: {
|
|
480
|
+
companyId: string;
|
|
481
|
+
url: string;
|
|
482
|
+
/** When omitted, id is taken from the skill frontmatter `name` or the URL path. */
|
|
483
|
+
skillId?: string;
|
|
484
|
+
}) {
|
|
485
|
+
const url = assertImportUrl(input.url);
|
|
486
|
+
const { id, markdown } = await resolveIdAndMarkdownForUrlLink({
|
|
487
|
+
url,
|
|
488
|
+
explicitSkillId: input.skillId
|
|
489
|
+
});
|
|
490
|
+
const { root } = await skillRoot(input.companyId, id);
|
|
491
|
+
await mkdir(root, { recursive: true });
|
|
492
|
+
await writeFile(join(root, SKILL_MD), markdown, { encoding: "utf8" });
|
|
493
|
+
const { lastFetchedAt } = await writeSkillLinkMetadata(root, url.toString());
|
|
494
|
+
return { skillId: id, url: url.toString(), lastFetchedAt };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Re-fetch from the URL stored in `.bopo-skill-link.json` and overwrite local SKILL.md. */
|
|
498
|
+
export async function refreshCompanySkillFromUrl(input: { companyId: string; skillId: string }) {
|
|
499
|
+
const { root, id } = await skillRoot(input.companyId, input.skillId);
|
|
500
|
+
const record = await readOptionalSkillLinkRecord(root);
|
|
501
|
+
if (!record?.url) {
|
|
502
|
+
throw new Error("Skill is not linked from a URL.");
|
|
503
|
+
}
|
|
504
|
+
const markdown = await fetchSkillMarkdownFromUrl(new URL(record.url));
|
|
505
|
+
await writeFile(join(root, SKILL_MD), markdown, { encoding: "utf8" });
|
|
506
|
+
const { lastFetchedAt } = await writeSkillLinkMetadata(root, record.url);
|
|
507
|
+
return { skillId: id, url: record.url, lastFetchedAt };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export async function materializeLinkedSkillsForRuntime(
|
|
511
|
+
companyId: string,
|
|
512
|
+
options?: { enabledSkillIds?: string[] }
|
|
513
|
+
): Promise<{
|
|
514
|
+
root: string | null;
|
|
515
|
+
cleanup: () => Promise<void>;
|
|
516
|
+
}> {
|
|
517
|
+
const { skillsRoot, items } = await listCompanySkillPackages({ companyId, maxSkills: 100 });
|
|
518
|
+
const tmpRoot = await mkdtemp(join(tmpdir(), "bopodev-linked-skills-"));
|
|
519
|
+
const allow = options?.enabledSkillIds;
|
|
520
|
+
let written = 0;
|
|
521
|
+
try {
|
|
522
|
+
for (const item of items) {
|
|
523
|
+
if (allow !== undefined && !allow.includes(item.skillId)) {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (!item.linkedUrl) {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const dir = join(skillsRoot, item.skillId);
|
|
530
|
+
if (await skillDirHasSkillMd(dir)) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
const body = await fetchSkillMarkdownFromUrl(new URL(item.linkedUrl));
|
|
535
|
+
const dest = join(tmpRoot, item.skillId);
|
|
536
|
+
await mkdir(dest, { recursive: true });
|
|
537
|
+
await writeFile(join(dest, SKILL_MD), body, "utf8");
|
|
538
|
+
written += 1;
|
|
539
|
+
} catch (error) {
|
|
540
|
+
// eslint-disable-next-line no-console
|
|
541
|
+
console.error("[linked-skills] materialize fetch failed", item.skillId, error);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (written === 0) {
|
|
545
|
+
await rm(tmpRoot, { recursive: true, force: true });
|
|
546
|
+
return { root: null, cleanup: async () => {} };
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
root: tmpRoot,
|
|
550
|
+
cleanup: async () => {
|
|
551
|
+
await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
} catch (error) {
|
|
555
|
+
await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
@@ -75,7 +75,8 @@ const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
|
|
|
75
75
|
sandboxMode: z.enum(["workspace_write", "full_access"]).optional(),
|
|
76
76
|
allowWebSearch: z.boolean().optional()
|
|
77
77
|
})
|
|
78
|
-
.optional()
|
|
78
|
+
.optional(),
|
|
79
|
+
enabledSkillIds: z.array(z.string().min(1)).max(64).nullable().optional()
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
const activateGoalPayloadSchema = z.object({
|
|
@@ -239,7 +240,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
239
240
|
)
|
|
240
241
|
);
|
|
241
242
|
const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
|
|
242
|
-
|
|
243
|
+
let runtimeConfig = normalizeRuntimeConfig({
|
|
243
244
|
runtimeConfig: parsed.data.runtimeConfig,
|
|
244
245
|
legacy: {
|
|
245
246
|
runtimeCommand: parsed.data.runtimeCommand,
|
|
@@ -252,10 +253,17 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
252
253
|
runtimeTimeoutSec: parsed.data.runtimeTimeoutSec,
|
|
253
254
|
interruptGraceSec: parsed.data.interruptGraceSec,
|
|
254
255
|
runtimeEnv: parsed.data.runtimeEnv,
|
|
255
|
-
runPolicy: parsed.data.runPolicy
|
|
256
|
+
runPolicy: parsed.data.runPolicy,
|
|
257
|
+
enabledSkillIds: parsed.data.enabledSkillIds
|
|
256
258
|
},
|
|
257
259
|
defaultRuntimeCwd
|
|
258
260
|
});
|
|
261
|
+
const rc = parsed.data.runtimeConfig;
|
|
262
|
+
const hasEnabledSkillIdsKey =
|
|
263
|
+
rc !== undefined && rc !== null && typeof rc === "object" && "enabledSkillIds" in rc;
|
|
264
|
+
if (!hasEnabledSkillIdsKey && parsed.data.enabledSkillIds === undefined) {
|
|
265
|
+
runtimeConfig = { ...runtimeConfig, enabledSkillIds: [] };
|
|
266
|
+
}
|
|
259
267
|
if (runtimeConfig.runtimeCwd) {
|
|
260
268
|
try {
|
|
261
269
|
runtimeConfig.runtimeCwd = assertRuntimeCwdForCompany(companyId, runtimeConfig.runtimeCwd, "runtimeCwd");
|