update
This commit is contained in:
1 parent
66158e222e
commit
67c757f481
314 files changed
+37945
-22962
No files matched your search
+1
-888
@@ -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";
|
||||
Reference in new issue
Block a user