This commit is contained in:
ZacharyZhang-NY committed 2026-04-04 12:03:50 -04:00
1 parent 66158e222e
commit 67c757f481
314 files changed
+37945 -22962

No files matched your search

+1 -888
View File
@@ -1,888 +1 @@
// SkillService -- full skill lifecycle: import, CRUD, assignment, token budget,
// update detection. Factory pattern following createOrchestrationService.
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join, resolve, basename, dirname, relative, posix } from "node:path";
import type { AppDatabase } from "./database";
import type { DesktopResult } from "@shared/contracts";
import type {
FileInventoryEntry,
SkillRecord,
SkillImportResult,
SkillSourceType,
SkillUpdateStatus,
RuntimeSkillEntry,
} from "./skill-types.js";
import {
parseFrontmatterMarkdown,
classifyInventoryKind,
deriveTrustLevel,
estimateTokens,
} from "./skill-format.js";
import {
deriveCanonicalSkillKey,
normalizeSkillSlug,
hashSkillValue,
parseSkillImportSource,
} from "./skill-keys.js";
import {
resolveGitHubCommitSha,
fetchGitHubSkills,
} from "./skill-github.js";
import { TOKEN_BUDGETS } from "./skill-types.js";
// Types
interface SkillServiceDependencies {
db: AppDatabase;
profileDir: string;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
// Factory
export function createSkillService({ db, profileDir, logger }: SkillServiceDependencies) {
// ---- Result helpers ----
function ok<T>(data: T): DesktopResult<T> {
return { ok: true, data };
}
function fail(code: string, message: string): DesktopResult<never> {
return { ok: false, error: { code, message } };
}
// ---- Path helpers (D-10: skills stored in profileDir/skills/{companyId}/) ----
function skillsRoot(companyId: string): string {
return join(profileDir, "skills", companyId);
}
function skillFilesDir(companyId: string, slug: string): string {
return join(skillsRoot(companyId), slug);
}
function runtimeDir(companyId: string): string {
return join(skillsRoot(companyId), "__runtime__");
}
/**
* Write SKILL.md (and tracked inventory files from GitHub downloads)
* to the local skill directory.
*/
async function materializeSkillFiles(
companyId: string,
slug: string,
markdown: string,
_inventory: FileInventoryEntry[],
): Promise<string> {
const dir = skillFilesDir(companyId, slug);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "SKILL.md"), markdown, "utf-8");
return dir;
}
/** Remove a skill's file directory from disk. */
async function cleanupSkillFiles(companyId: string, slug: string): Promise<void> {
const dir = skillFilesDir(companyId, slug);
if (existsSync(dir)) {
await rm(dir, { recursive: true, force: true });
}
}
function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") return null;
const parts = parsed.pathname.split("/").filter(Boolean);
if (parts.length < 2) return null;
return {
owner: parts[0]!,
repo: parts[1]!.replace(/\.git$/i, ""),
};
} catch {
return null;
}
}
/**
* Import skills from any supported source.
* Routes to the correct handler based on parseSkillImportSource.
*/
async function importFromSource(
companyId: string,
rawSource: string,
): Promise<DesktopResult<SkillImportResult[]>> {
try {
const parsed = parseSkillImportSource(rawSource);
const source = parsed.resolvedSource;
// skills.sh resolved sources are always GitHub URLs
if (parsed.originalSkillsShUrl) {
const results = await importFromGitHub(
companyId,
source,
parsed.requestedSkillSlug,
parsed.originalSkillsShUrl,
);
return ok(results);
}
// GitHub URL
if (source.includes("github.com")) {
const results = await importFromGitHub(
companyId,
source,
parsed.requestedSkillSlug,
null,
);
return ok(results);
}
// Local path (absolute path, drive letter on Windows, or ~ home dir)
if (
source.startsWith("/") ||
/^[A-Za-z]:/.test(source) ||
source.startsWith("~")
) {
const results = await importFromLocal(companyId, source);
return ok(results);
}
// HTTP/HTTPS URL
if (/^https?:\/\//i.test(source)) {
const results = await importFromUrl(companyId, source);
return ok(results);
}
return fail("invalid_source", `Unrecognized skill source format: ${rawSource}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error("Skill import failed:", message);
return fail("import_error", message);
}
}
async function importFromGitHub(
companyId: string,
url: string,
requestedSlug: string | null,
originalSkillsShUrl: string | null,
): Promise<SkillImportResult[]> {
const ghParsed = parseGitHubUrl(url);
if (!ghParsed) {
throw new Error(`Invalid GitHub URL: ${url}`);
}
const { owner, repo } = ghParsed;
// Resolve latest commit SHA
const sha = await resolveGitHubCommitSha(owner, repo, undefined, logger);
if (!sha) {
throw new Error(`Failed to resolve commit SHA for ${owner}/${repo}`);
}
// Fetch SKILL.md files from repo
const { skills: githubSkills, commitSha } = await fetchGitHubSkills(
owner, repo, sha, requestedSlug, logger,
);
if (githubSkills.length === 0) {
throw new Error(`No SKILL.md files found in ${owner}/${repo}`);
}
const results: SkillImportResult[] = [];
const sourceType: SkillSourceType = originalSkillsShUrl ? "skills_sh" : "github";
const nowTs = new Date().toISOString();
for (const ghSkill of githubSkills) {
const parsed = parseFrontmatterMarkdown(ghSkill.markdown);
const rawSlug =
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
normalizeSkillSlug(
ghSkill.skillMdPath === "SKILL.md"
? repo
: basename(dirname(ghSkill.skillMdPath)),
) ??
repo;
// Build file inventory from the tree paths
const skillDir =
ghSkill.skillMdPath === "SKILL.md"
? ""
: ghSkill.skillMdPath.slice(0, ghSkill.skillMdPath.lastIndexOf("/"));
const prefix = skillDir ? `${skillDir}/` : "";
const inventory: FileInventoryEntry[] = [
{ path: "SKILL.md", kind: "skill" as const },
...ghSkill.inventory.map((filePath) => {
const relativePath = prefix ? filePath.slice(prefix.length) : filePath;
return {
path: relativePath,
kind: classifyInventoryKind(relativePath),
};
}),
];
const metadata: Record<string, unknown> = {
sourceKind: "github",
owner,
repo,
ref: commitSha,
trackingRef: "HEAD",
...(originalSkillsShUrl ? { originalSkillsShUrl } : {}),
};
const key = deriveCanonicalSkillKey(companyId, {
slug: rawSlug,
sourceType,
sourceLocator: url,
metadata,
});
const trustLevel = deriveTrustLevel(inventory);
const name =
(typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ??
rawSlug;
const description =
typeof parsed.frontmatter.description === "string"
? parsed.frontmatter.description
: null;
const skillId = randomUUID();
// Materialize files to disk
await materializeSkillFiles(companyId, rawSlug, ghSkill.markdown, inventory);
// Upsert to database
const { isUpdate } = db.upsertCompanySkill({
id: skillId,
companyId,
key,
slug: rawSlug,
name,
description,
markdown: ghSkill.markdown,
sourceType,
sourceLocator: url,
sourceRef: commitSha,
trustLevel,
compatibility: "compatible",
fileInventory: JSON.stringify(inventory),
metadata: JSON.stringify(metadata),
createdAt: nowTs,
updatedAt: nowTs,
});
results.push({ skillId, key, name, isUpdate });
logger.info(`Skill ${isUpdate ? "updated" : "imported"}: ${key} from ${owner}/${repo}`);
}
return results;
}
async function importFromLocal(
companyId: string,
localPath: string,
): Promise<SkillImportResult[]> {
const resolvedPath = resolve(localPath.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? ""));
if (!existsSync(resolvedPath)) {
throw new Error(`Local path does not exist: ${resolvedPath}`);
}
const stat = statSync(resolvedPath);
const results: SkillImportResult[] = [];
const nowTs = new Date().toISOString();
if (stat.isFile()) {
// Single file treated as a skill
const markdown = readFileSync(resolvedPath, "utf-8");
const parsed = parseFrontmatterMarkdown(markdown);
const rawSlug =
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
normalizeSkillSlug(basename(resolvedPath, ".md")) ??
"skill";
const inventory: FileInventoryEntry[] = [
{ path: "SKILL.md", kind: "skill" },
];
const trustLevel = deriveTrustLevel(inventory);
const contentHash = hashSkillValue(markdown);
const key = deriveCanonicalSkillKey(companyId, {
slug: rawSlug,
sourceType: "local_path",
sourceLocator: resolvedPath,
metadata: null,
});
const skillId = randomUUID();
await materializeSkillFiles(companyId, rawSlug, markdown, inventory);
const { isUpdate } = db.upsertCompanySkill({
id: skillId,
companyId,
key,
slug: rawSlug,
name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug,
description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null,
markdown,
sourceType: "local_path",
sourceLocator: resolvedPath,
sourceRef: contentHash,
trustLevel,
compatibility: "compatible",
fileInventory: JSON.stringify(inventory),
metadata: null,
createdAt: nowTs,
updatedAt: nowTs,
});
results.push({ skillId, key, name: rawSlug, isUpdate });
logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local file: ${key}`);
return results;
}
// Directory: walk to find SKILL.md files
const skillMdFiles = findSkillMdFiles(resolvedPath);
if (skillMdFiles.length === 0) {
throw new Error(`No SKILL.md files found in ${resolvedPath}`);
}
for (const skillMdPath of skillMdFiles) {
const skillDir = dirname(skillMdPath);
const markdown = readFileSync(skillMdPath, "utf-8");
const parsed = parseFrontmatterMarkdown(markdown);
const rawSlug =
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
normalizeSkillSlug(basename(skillDir)) ??
"skill";
// Build inventory from directory listing
const inventory = buildLocalInventory(skillDir, skillMdPath);
const trustLevel = deriveTrustLevel(inventory);
const contentHash = hashSkillValue(markdown);
const key = deriveCanonicalSkillKey(companyId, {
slug: rawSlug,
sourceType: "local_path",
sourceLocator: skillDir,
metadata: null,
});
const skillId = randomUUID();
await materializeSkillFiles(companyId, rawSlug, markdown, inventory);
const { isUpdate } = db.upsertCompanySkill({
id: skillId,
companyId,
key,
slug: rawSlug,
name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug,
description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null,
markdown,
sourceType: "local_path",
sourceLocator: skillDir,
sourceRef: contentHash,
trustLevel,
compatibility: "compatible",
fileInventory: JSON.stringify(inventory),
metadata: null,
createdAt: nowTs,
updatedAt: nowTs,
});
results.push({ skillId, key, name: rawSlug, isUpdate });
logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local path: ${key}`);
}
return results;
}
async function importFromUrl(
companyId: string,
url: string,
): Promise<SkillImportResult[]> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch skill from URL: ${response.status} ${url}`);
}
const markdown = await response.text();
const parsed = parseFrontmatterMarkdown(markdown);
const nowTs = new Date().toISOString();
// Derive slug from frontmatter or URL path
let urlSlug: string | null = null;
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split("/").filter(Boolean);
const lastPart = pathParts[pathParts.length - 1] ?? "";
urlSlug = normalizeSkillSlug(lastPart.replace(/\.md$/i, ""));
} catch {
// ignore URL parsing errors
}
const rawSlug =
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
urlSlug ??
"skill";
// Single-file skill: only SKILL.md
const inventory: FileInventoryEntry[] = [
{ path: "SKILL.md", kind: "skill" },
];
const trustLevel: "markdown_only" = "markdown_only";
const key = deriveCanonicalSkillKey(companyId, {
slug: rawSlug,
sourceType: "url",
sourceLocator: url,
metadata: null,
});
const skillId = randomUUID();
await materializeSkillFiles(companyId, rawSlug, markdown, inventory);
const { isUpdate } = db.upsertCompanySkill({
id: skillId,
companyId,
key,
slug: rawSlug,
name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug,
description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null,
markdown,
sourceType: "url",
sourceLocator: url,
sourceRef: null,
trustLevel,
compatibility: "compatible",
fileInventory: JSON.stringify(inventory),
metadata: null,
createdAt: nowTs,
updatedAt: nowTs,
});
const results: SkillImportResult[] = [
{ skillId, key, name: rawSlug, isUpdate },
];
logger.info(`Skill ${isUpdate ? "updated" : "imported"} from URL: ${key}`);
return results;
}
function listForCompany(companyId: string): SkillRecord[] {
return db.listCompanySkills(companyId);
}
function getDetail(companyId: string, skillId: string): DesktopResult<SkillRecord> {
const skill = db.getCompanySkill(companyId, skillId);
if (!skill) return fail("not_found", `Skill ${skillId} not found`);
return ok(skill);
}
/** Delete a skill: (1) assignments cascade via FK, (2) files from disk, (3) DB record per D-12. */
async function deleteSkill(
companyId: string,
skillId: string,
): Promise<DesktopResult<void>> {
const skill = db.getCompanySkill(companyId, skillId);
if (!skill) return fail("not_found", `Skill ${skillId} not found`);
try {
// D-12 order: (1) assignments cascade via FK on delete,
// (2) delete files from disk
await cleanupSkillFiles(companyId, skill.slug);
// (3) delete DB record (cascades agent_skill_assignments via FK)
db.deleteCompanySkill(companyId, skillId);
logger.info(`Skill deleted: ${skill.key}`);
return ok(undefined);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error("Skill deletion failed:", message);
return fail("delete_error", message);
}
}
function assignToAgent(
companyId: string,
agentId: string,
skillId: string,
): DesktopResult<void> {
const skill = db.getCompanySkill(companyId, skillId);
if (!skill) return fail("not_found", `Skill ${skillId} not found for company`);
db.assignSkillToAgent(agentId, skillId);
return ok(undefined);
}
function removeFromAgent(
_companyId: string,
agentId: string,
skillId: string,
): DesktopResult<void> {
db.removeSkillFromAgent(agentId, skillId);
return ok(undefined);
}
async function checkUpdateStatus(
companyId: string,
skillId: string,
): Promise<DesktopResult<SkillUpdateStatus>> {
const skill = db.getCompanySkill(companyId, skillId);
if (!skill) return fail("not_found", `Skill ${skillId} not found`);
// Only GitHub/skills_sh sources support update checks
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") {
return ok({
skillId: skill.id,
key: skill.key,
currentRef: skill.sourceRef,
latestRef: null,
hasUpdate: false,
});
}
// Parse owner/repo from metadata
const metadata = skill.metadata;
const owner = typeof metadata?.owner === "string" ? metadata.owner : null;
const repo = typeof metadata?.repo === "string" ? metadata.repo : null;
if (!owner || !repo) {
// Try to parse from sourceLocator URL
const ghParsed = skill.sourceLocator ? parseGitHubUrl(skill.sourceLocator) : null;
if (!ghParsed) {
return ok({
skillId: skill.id,
key: skill.key,
currentRef: skill.sourceRef,
latestRef: null,
hasUpdate: false,
});
}
const latestRef = await resolveGitHubCommitSha(ghParsed.owner, ghParsed.repo, undefined, logger);
return ok({
skillId: skill.id,
key: skill.key,
currentRef: skill.sourceRef,
latestRef,
hasUpdate: latestRef != null && latestRef !== skill.sourceRef,
});
}
const trackingRef = typeof metadata?.trackingRef === "string" ? metadata.trackingRef : undefined;
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, logger);
return ok({
skillId: skill.id,
key: skill.key,
currentRef: skill.sourceRef,
latestRef,
hasUpdate: latestRef != null && latestRef !== skill.sourceRef,
});
}
/**
* Build the list of RuntimeSkillEntry for adapter injection.
* Applies token budget enforcement: skills sorted by creation date,
* accumulated until budget exceeded, then skipped with warning.
*/
function listRuntimeSkillEntries(
companyId: string,
agentId: string,
adapterType: string,
): RuntimeSkillEntry[] {
const allSkills = db.listCompanySkills(companyId);
const agentAssignments = db.listAgentSkillAssignments(agentId);
const skills = allSkills.filter((s) => agentAssignments.includes(s.id));
// Sort by creation date (oldest first -- first imported gets priority)
const sorted = [...skills].sort(
(a, b) => a.createdAt.localeCompare(b.createdAt),
);
const budget = TOKEN_BUDGETS[adapterType] ?? 30000;
let accumulated = 0;
const entries: RuntimeSkillEntry[] = [];
for (const skill of sorted) {
const tokenEstimate = estimateTokens(skill.markdown);
if (accumulated + tokenEstimate > budget) {
logger.warn(
`Token budget exceeded for ${adapterType}: skipping skill "${skill.name}" (${tokenEstimate} tokens, budget ${budget}, used ${accumulated})`,
);
continue;
}
accumulated += tokenEstimate;
entries.push({
runtimeName: skill.slug,
sourcePath: skillFilesDir(companyId, skill.slug),
name: skill.name,
tokenEstimate,
});
}
return entries;
}
function findSkillMdFiles(rootDir: string): string[] {
const found: string[] = [];
function walk(dir: string) {
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.name === "SKILL.md") {
found.push(fullPath);
}
}
} catch {
// Skip unreadable directories
}
}
walk(rootDir);
return found;
}
function buildLocalInventory(skillDir: string, skillMdPath: string): FileInventoryEntry[] {
const inventory: FileInventoryEntry[] = [
{ path: "SKILL.md", kind: "skill" },
];
try {
const entries = readdirSync(skillDir, { withFileTypes: true, recursive: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
const entryPath = entry.parentPath
? join(entry.parentPath, entry.name)
: join(skillDir, entry.name);
const fullPath = resolve(entryPath);
if (fullPath === skillMdPath) continue;
const relativePath = relative(skillDir, fullPath).replace(/\\/g, "/");
inventory.push({
path: relativePath,
kind: classifyInventoryKind(relativePath),
});
}
} catch {
// If directory listing fails, return just the SKILL.md entry
}
return inventory;
}
/**
* Scan all workspace local paths for a company, looking for
* `.agents/skills/` directories containing SKILL.md files.
* Auto-imports any found skills that are not already in the database.
* Cross-platform: uses path.join for correct separators.
*/
async function scanWorkspaceSkills(
companyId: string,
): Promise<SkillImportResult[]> {
const allResults: SkillImportResult[] = [];
// 1. Scan profile skills directory: {profileDir}/skills/{companyId}/
// This is where builtin and user-installed skills live
const profileSkillsDir = skillsRoot(companyId);
if (existsSync(profileSkillsDir)) {
try {
const stat = statSync(profileSkillsDir);
if (stat.isDirectory()) {
const results = await importFromLocal(companyId, profileSkillsDir);
allResults.push(...results);
logger.info(`Scanned profile skills: ${profileSkillsDir} — imported ${results.length} skill(s)`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`Failed to scan profile skills at ${profileSkillsDir}: ${message}`);
}
}
// 2. Scan workspace .agentcompany/skills/ directories
const workspacePaths = db.listWorkspaceLocalPaths(companyId);
for (const wsPath of workspacePaths) {
const agentcompanySkillsDir = join(wsPath, ".agentcompany", "skills");
if (!existsSync(agentcompanySkillsDir)) continue;
try {
const stat = statSync(agentcompanySkillsDir);
if (!stat.isDirectory()) continue;
const results = await importFromLocal(companyId, agentcompanySkillsDir);
allResults.push(...results);
logger.info(
`Scanned workspace skills: ${agentcompanySkillsDir} — imported ${results.length} skill(s)`,
);
} catch (err) {
// Log but don't fail the entire scan if one workspace has issues
const message = err instanceof Error ? err.message : String(err);
logger.warn(`Failed to scan workspace skills at ${agentcompanySkillsDir}: ${message}`);
}
}
return allResults;
}
/**
* Resolve the path to bundled builtin skills.
* Cross-platform: works on macOS, Windows, and Linux.
*
* Production (packaged app):
* macOS: AppName.app/Contents/Resources/builtin-skills/
* Windows: {install dir}/resources/builtin-skills/
* Linux: {install dir}/resources/builtin-skills/
* Resolved via `process.resourcesPath` (set by Electron on all platforms).
*
* Development (electron-vite dev):
* CWD is the project root → {project}/builtin-skills/
* No __dirname dependency (ESM polyfill varies across bundlers).
*/
function getBuiltinSkillsDir(): string | null {
// 1. Production: Electron packages extraResources into process.resourcesPath
// process.resourcesPath is always defined in Electron (all platforms)
if (process.resourcesPath) {
const resourcesPath = join(process.resourcesPath, "builtin-skills");
if (existsSync(resourcesPath)) return resourcesPath;
}
// 2. Development: electron-vite dev sets CWD to project root
const cwdPath = join(process.cwd(), "builtin-skills");
if (existsSync(cwdPath)) return cwdPath;
return null;
}
/**
* Seed all builtin skills for a company from the bundled builtin-skills directory.
* Idempotent: skips skills that already exist (by key).
* Called once during company bootstrap.
*/
async function seedBuiltinSkills(companyId: string): Promise<SkillImportResult[]> {
const builtinDir = getBuiltinSkillsDir();
if (!builtinDir) {
logger.warn("[skills] Builtin skills directory not found — skipping seed.");
return [];
}
const allResults: SkillImportResult[] = [];
// Walk each category directory (basic-for-all, software-engineer, etc.)
let categories: string[];
try {
categories = readdirSync(builtinDir).filter(name => {
if (name.startsWith(".")) return false;
try { return statSync(join(builtinDir, name)).isDirectory(); } catch { return false; }
});
} catch {
logger.warn(`[skills] Failed to read builtin skills directory: ${builtinDir}`);
return [];
}
for (const category of categories) {
const categoryDir = join(builtinDir, category);
try {
const results = await importFromLocal(companyId, categoryDir);
// Tag each imported skill with builtin metadata so we know its category
for (const result of results) {
try {
const skill = db.getCompanySkill(companyId, result.skillId);
if (skill) {
const existingMeta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
existingMeta.builtinCategory = category;
existingMeta.sourceKind = "builtin";
db.updateCompanySkillMetadata(companyId, result.skillId, JSON.stringify(existingMeta));
}
} catch { /* non-fatal metadata update */ }
}
allResults.push(...results);
logger.info(`[skills] Seeded ${results.length} builtin skill(s) from category: ${category}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`[skills] Failed to seed builtin category ${category}: ${message}`);
}
}
return allResults;
}
/**
* Auto-assign all skills from the "basic-for-all" builtin category to an agent.
*/
function assignBasicSkillsToAgent(companyId: string, agentId: string): number {
const allSkills = db.listCompanySkills(companyId);
let assigned = 0;
for (const skill of allSkills) {
try {
const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
if (meta.builtinCategory === "basic-for-all") {
db.assignSkillToAgent(agentId, skill.id);
assigned++;
}
} catch { /* skip malformed metadata */ }
}
return assigned;
}
/**
* Auto-assign role-appropriate builtin skills to an agent based on its role/department.
* CEO gets all categories. Other agents get basic-for-all + role-matched categories.
*/
function assignRoleSkillsToAgent(companyId: string, agentId: string, role: string, department?: string | null): number {
const allSkills = db.listCompanySkills(companyId);
let assigned = 0;
// Map roles/departments to skill categories
const roleCategories: Record<string, string[]> = {
ceo: ["basic-for-all", "software-engineer", "document-process", "frontend-design", "marketing", "iOS-Dev", "my-writing-style", "Video-maker"],
engineer: ["basic-for-all", "software-engineer"],
designer: ["basic-for-all", "frontend-design"],
marketing: ["basic-for-all", "marketing", "my-writing-style"],
writer: ["basic-for-all", "document-process", "my-writing-style"],
ios: ["basic-for-all", "software-engineer", "iOS-Dev"],
mobile: ["basic-for-all", "software-engineer", "iOS-Dev"],
video: ["basic-for-all", "Video-maker"],
frontend: ["basic-for-all", "software-engineer", "frontend-design"],
};
const normalizedRole = role.toLowerCase();
const normalizedDept = department?.toLowerCase() ?? "";
// Find matching categories
let categories = roleCategories[normalizedRole] ?? ["basic-for-all"];
// Also check department
for (const [key, cats] of Object.entries(roleCategories)) {
if (normalizedDept.includes(key) || normalizedRole.includes(key)) {
categories = [...new Set([...categories, ...cats])];
}
}
// If CEO role, assign all
if (normalizedRole === "ceo" || normalizedRole.includes("chief")) {
categories = roleCategories.ceo!;
}
for (const skill of allSkills) {
try {
const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
if (meta.builtinCategory && categories.includes(meta.builtinCategory)) {
db.assignSkillToAgent(agentId, skill.id);
assigned++;
}
} catch { /* skip */ }
}
return assigned;
}
return {
importFromSource,
importFromGitHub,
importFromLocal,
importFromUrl,
listForCompany,
getDetail,
deleteSkill,
assignToAgent,
removeFromAgent,
checkUpdateStatus,
listRuntimeSkillEntries,
scanWorkspaceSkills,
seedBuiltinSkills,
assignBasicSkillsToAgent,
assignRoleSkillsToAgent,
};
}
export { createSkillService } from "./skills/index";