Ship skills built-in: bundle the catalogue, assign per hire, force humanizer
Skills no longer come from the user's ~/.agents library - most users have none, and the CEO branch that compiled the entire catalogue into its prompt overflowed the model context (1.1MB) and failed every CEO run. - Bundle "built-in skills/" with the app (extraResources) and stage it at startup into <profile>/builtin-skills as one dir-per-skill copy - both bundle shapes (SKILL.md dirs and flat category .md files) normalize into the same importable layout - Import the staged catalogue per org at startup and inside onboarding, so the CEO's first run already carries it - Forced house style: resolveRuntimeSnapshot unions the humanizer skill into every agent's runtime (CEO included) as a resolution rule - no binding row to forget, delete, or bypass; revoke_skill refuses it - CEO staffs hires with skills: hire_agent accepts a skills array, grant_skill/revoke_skill adjust later, list_skills now shows descriptions from skill frontmatter - Kill the c_suite compile-all branch; everyone resolves bindings-only, capped at 200k chars with omissions named in the prompt - Remove the Skills settings page and its import/sync/test IPC surface; drop ~/.agents discovery and the AGENTCOMPANY_NEW_SKILLS_ROOT seam in favor of AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR - Tests: staging/import/forced-resolution/cap units, real-bundle CI check, e2e asserts the tab is gone and the catalogue self-imports
This commit is contained in:
1 parent
55c043f8b4
commit
86aa6fdb29
318 files changed
+77467
-520
No files matched your search
@@ -28,7 +28,7 @@ import { AutomationService } from "../services/automation-service";
|
||||
import { MeetingService } from "../services/meeting-service";
|
||||
import { SprintService } from "../services/sprint-service";
|
||||
import { ComposioConnectionService } from "../services/composio/composio-connection-service";
|
||||
import { syncSkillsFromDisk } from "../services/skill-discovery";
|
||||
import { importStagedSkills, stageBuiltinSkillsLibrary } from "../services/builtin-skills";
|
||||
import { coalesce } from "./coalesce";
|
||||
import { normalizeExternalBrowserUrl } from "./external-url";
|
||||
|
||||
@@ -36,6 +36,10 @@ export interface IpcDeps {
|
||||
connection: DatabaseConnection;
|
||||
mainWindow: BrowserWindow;
|
||||
artifactRoot: string;
|
||||
/** Bundled built-in skills source (repo folder in dev, resources when packaged). */
|
||||
builtinSkillsSource: string;
|
||||
/** Normalized on-disk skill library under the profile directory. */
|
||||
builtinSkillsLibrary: string;
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
@@ -94,9 +98,17 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.bootstrapOnboarding, (_event, input) => {
|
||||
try {
|
||||
let summary;
|
||||
let summary: { orgId: string } | undefined;
|
||||
// Staging happens before the transaction (file mirroring), the import
|
||||
// inside it: the CEO's kickoff run can be claimed within seconds of
|
||||
// commit and must already find the forced humanizer skill.
|
||||
const staged = stageBuiltinSkillsLibrary(deps.builtinSkillsSource, deps.builtinSkillsLibrary);
|
||||
deps.connection.transaction(() => {
|
||||
summary = onboarding.bootstrap(input);
|
||||
const report = importStagedSkills(skills, summary.orgId, staged);
|
||||
if (report.failures.length > 0) {
|
||||
console.warn(`[skills] onboarding import failures (${report.failures.length}): ${report.failures.slice(0, 3).join("; ")}`);
|
||||
}
|
||||
});
|
||||
notifyDataChanged();
|
||||
return ok(summary);
|
||||
@@ -264,13 +276,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
return fail("provider_cancel_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.importSkillDirectory, (_event, input) => mutate(() => skills.importDirectory(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.syncSkillsFromDisk, (_event, input) => mutate(() => {
|
||||
syncSkillsFromDisk(deps.connection.db, skills, input.orgId);
|
||||
return null;
|
||||
}));
|
||||
ipcMain.handle(IPC_CHANNELS.resolveAgentSkills, (_event, input) => mutate(() => skills.resolveRuntimeSnapshot(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.runSkillTests, (_event, input) => mutate(() => skills.runTests(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createSocialAccount, (_event, input) => mutate(() => social.createAccount(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createScheduledPost, (_event, input) => mutate(() => social.createScheduledPost(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.setScheduledPostState, (_event, input) => mutate(() => social.setScheduledPostState(input)));
|
||||
|
||||
+34
-14
@@ -5,7 +5,7 @@ import type { DatabaseSync } from "node:sqlite";
|
||||
import { DatabaseConnection } from "./database/connection";
|
||||
import { createMainWindow } from "./app/create-window";
|
||||
import { registerIpcHandlers } from "./app/ipc-handlers";
|
||||
import { syncSkillsFromDisk } from "./services/skill-discovery";
|
||||
import { builtinSkillsSourceOverride, importStagedSkills, stageBuiltinSkillsLibrary } from "./services/builtin-skills";
|
||||
import { SkillService } from "./services/skill-service";
|
||||
import { startProviderBackgroundWorker } from "./app/provider-background-worker";
|
||||
import { recoverInterruptedWork } from "./app/startup-recovery";
|
||||
@@ -35,12 +35,16 @@ if (!app.requestSingleInstanceLock()) {
|
||||
language,
|
||||
});
|
||||
|
||||
const builtinSkillsSource = resolveBuiltinSkillsSource();
|
||||
const builtinSkillsLibrary = join(profileDir, "builtin-skills");
|
||||
registerIpcHandlers({
|
||||
connection,
|
||||
mainWindow: win,
|
||||
artifactRoot: join(profileDir, "artifacts"),
|
||||
builtinSkillsSource,
|
||||
builtinSkillsLibrary,
|
||||
});
|
||||
syncAllOrganizationSkills(connection.db, join(profileDir, "artifacts"));
|
||||
syncAllOrganizationSkills(connection, join(profileDir, "artifacts"), builtinSkillsSource, builtinSkillsLibrary);
|
||||
const stopProviderBackgroundWorker = startProviderBackgroundWorker({
|
||||
db: connection.db,
|
||||
mainWindow: win,
|
||||
@@ -73,19 +77,35 @@ function storedLanguage(db: DatabaseSync): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the on-disk skill library for every organization at startup. One
|
||||
* organization's failure (a broken workspace path, say) must not block the
|
||||
* rest — or the app.
|
||||
* The bundled skill catalogue: the repo folder during development, the
|
||||
* extraResources copy in a packaged build, or an explicit override for
|
||||
* hermetic tests (same seam pattern as AGENTCOMPANY_NEW_PROFILE_DIR).
|
||||
*/
|
||||
function syncAllOrganizationSkills(db: DatabaseSync, artifactRoot: string): void {
|
||||
const skills = new SkillService(db, artifactRoot);
|
||||
const orgs = db.prepare("select id from organizations").all() as Array<{ id: string }>;
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
syncSkillsFromDisk(db, skills, org.id);
|
||||
} catch {
|
||||
// Broken directories are reported per entry inside the sync; anything
|
||||
// beyond that is a per-org environment fault that must not stop startup.
|
||||
function resolveBuiltinSkillsSource(): string {
|
||||
const override = builtinSkillsSourceOverride();
|
||||
if (override) return override;
|
||||
if (app.isPackaged) return join(process.resourcesPath, "built-in-skills");
|
||||
return join(app.getAppPath(), "built-in skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the bundled skill catalogue into the profile library and imports it
|
||||
* for every organization at startup. Per-skill failures are reported inside
|
||||
* the sync; a broken bundle is loud in the log but must not stop the app.
|
||||
*/
|
||||
function syncAllOrganizationSkills(connection: DatabaseConnection, artifactRoot: string, sourceRoot: string, libraryRoot: string): void {
|
||||
try {
|
||||
const staged = stageBuiltinSkillsLibrary(sourceRoot, libraryRoot);
|
||||
const skills = new SkillService(connection.db, artifactRoot);
|
||||
const orgs = connection.db.prepare("select id from organizations").all() as Array<{ id: string }>;
|
||||
for (const org of orgs) {
|
||||
const report = connection.transaction(() => importStagedSkills(skills, org.id, staged));
|
||||
if (report.imported > 0 || report.failures.length > 0) {
|
||||
console.info(`[skills] org ${org.id}: imported ${report.imported}, skipped ${report.skipped}, failures ${report.failures.length}`);
|
||||
for (const failure of report.failures.slice(0, 5)) console.warn(`[skills] ${failure}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[skills] built-in skill sync failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { requireString } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
import { SkillService } from "./skill-service";
|
||||
import type { SkillManifest } from "./skill-manifest";
|
||||
import { FORCED_SKILL_SLUGS, SkillService } from "./skill-service";
|
||||
|
||||
/** Company skill library visibility for any agent: what exists, who holds it. */
|
||||
export function listSkillsTool(deps: AgentToolDeps, ctx: AgentToolContext): string {
|
||||
const skills = deps.db.prepare(
|
||||
"select id, name, slug from skill_definitions where org_id = ? and status = 'active' order by slug asc",
|
||||
).all(ctx.orgId) as Array<{ id: string; name: string; slug: string }>;
|
||||
const skills = deps.db.prepare(`
|
||||
select sd.id, sd.name, sd.slug, sr.manifest_json
|
||||
from skill_definitions sd
|
||||
left join skill_revisions sr on sr.id = (
|
||||
select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1
|
||||
)
|
||||
where sd.org_id = ? and sd.status = 'active'
|
||||
order by sd.slug asc
|
||||
`).all(ctx.orgId) as Array<{ id: string; name: string; slug: string; manifest_json: string | null }>;
|
||||
if (skills.length === 0) {
|
||||
return "The company has no skills yet. The board imports them on the Settings page.";
|
||||
return "The company skill library is empty. Built-in skills load at startup; if this persists, the installation is broken.";
|
||||
}
|
||||
const bindings = deps.db.prepare(
|
||||
"select skill_id, agent_id from skill_bindings where org_id = ? and enabled = 1",
|
||||
@@ -18,14 +25,22 @@ export function listSkillsTool(deps: AgentToolDeps, ctx: AgentToolContext): stri
|
||||
(deps.db.prepare("select id, name from agents where org_id = ?").all(ctx.orgId) as Array<{ id: string; name: string }>)
|
||||
.map((row) => [row.id, row.name]),
|
||||
);
|
||||
const forced = new Set<string>(FORCED_SKILL_SLUGS);
|
||||
const lines = skills.map((skill) => {
|
||||
const summary = skillSummary(skill.manifest_json);
|
||||
if (forced.has(skill.slug)) {
|
||||
return `- ${skill.name} (${skill.slug})${summary}: house style — every agent carries it automatically`;
|
||||
}
|
||||
const holders = bindings
|
||||
.filter((binding) => binding.skill_id === skill.id)
|
||||
.map((binding) => agentNames.get(binding.agent_id))
|
||||
.filter(Boolean);
|
||||
return `- ${skill.name} (${skill.slug}): ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`;
|
||||
return `- ${skill.name} (${skill.slug})${summary}: ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`;
|
||||
});
|
||||
return ["Company skills (the CEO carries all of them; teammates need a grant):", ...lines].join("\n");
|
||||
return [
|
||||
"Company skill library (grant by slug with grant_skill, or pass skills to hire_agent):",
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** CEO-only: hand a skill to a teammate, or take it back. */
|
||||
@@ -37,6 +52,11 @@ export function grantSkillTool(deps: AgentToolDeps, ctx: AgentToolContext, args:
|
||||
}
|
||||
const agentName = requireString(args.agent_name, "agent_name");
|
||||
const skillSlug = requireString(args.skill, "skill").trim().toLowerCase();
|
||||
if ((FORCED_SKILL_SLUGS as readonly string[]).includes(skillSlug)) {
|
||||
return mode === "grant"
|
||||
? `${agentName} already carries ${skillSlug} automatically — it is company house style for every agent.`
|
||||
: `ERROR: ${skillSlug} is company house style and cannot be revoked from anyone.`;
|
||||
}
|
||||
const target = deps.db.prepare(
|
||||
"select id, name from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' limit 1",
|
||||
).get(ctx.orgId, agentName) as { id: string; name: string } | undefined;
|
||||
@@ -57,3 +77,44 @@ export function grantSkillTool(deps: AgentToolDeps, ctx: AgentToolContext, args:
|
||||
skillService.unbind({ orgId: ctx.orgId, agentId: target.id, skillId: skill.id });
|
||||
return `${target.name} no longer has the ${skill.name} skill.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a set of library skills to a freshly hired agent. Unknown slugs are
|
||||
* reported back to the caller (the hiring CEO) instead of failing the hire.
|
||||
*/
|
||||
export function bindSkillSlugs(deps: AgentToolDeps, orgId: string, agentId: string, slugs: string[]): { granted: string[]; unknown: string[] } {
|
||||
const skillService = new SkillService(deps.db, "");
|
||||
const granted: string[] = [];
|
||||
const unknown: string[] = [];
|
||||
for (const raw of slugs) {
|
||||
const slug = raw.trim().toLowerCase();
|
||||
if (!slug || (FORCED_SKILL_SLUGS as readonly string[]).includes(slug)) continue;
|
||||
const row = deps.db.prepare(`
|
||||
select sd.id as skill_id, sd.name, sr.id as revision_id
|
||||
from skill_definitions sd
|
||||
join skill_revisions sr on sr.id = (
|
||||
select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1
|
||||
)
|
||||
where sd.org_id = ? and sd.slug = ? and sd.status = 'active'
|
||||
`).get(orgId, slug) as { skill_id: string; name: string; revision_id: string } | undefined;
|
||||
if (!row) {
|
||||
unknown.push(slug);
|
||||
continue;
|
||||
}
|
||||
skillService.bind({ orgId, agentId, skillId: row.skill_id, revisionId: row.revision_id });
|
||||
granted.push(slug);
|
||||
}
|
||||
return { granted, unknown };
|
||||
}
|
||||
|
||||
function skillSummary(manifestJson: string | null): string {
|
||||
if (!manifestJson) return "";
|
||||
try {
|
||||
const manifest = JSON.parse(manifestJson) as SkillManifest;
|
||||
if (!manifest.description) return "";
|
||||
const compact = manifest.description.replace(/\s+/g, " ").trim();
|
||||
return compact ? ` — ${compact.length > 140 ? `${compact.slice(0, 137)}...` : compact}` : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { ChatService } from "./chat-service";
|
||||
import { canonicalMentionHandle } from "../../shared/mention-handles";
|
||||
import { readMessagesTool } from "./agent-read-messages";
|
||||
import { grantConnectorTool, listConnectorsTool } from "./agent-connector-tools";
|
||||
import { grantSkillTool, listSkillsTool } from "./agent-skill-tools";
|
||||
import { bindSkillSlugs, grantSkillTool, listSkillsTool } from "./agent-skill-tools";
|
||||
import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-tools";
|
||||
import { describeSend } from "./agent-message-presence";
|
||||
import { optionalString, optionalStringArray, requireString } from "./agent-tool-args";
|
||||
@@ -40,7 +40,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
name: "hire_agent",
|
||||
description:
|
||||
"Add one direct report to your team. During team building, call this 3-5 times to staff the roles you need before delegating work.",
|
||||
"Add one direct report to your team. During team building, call this 3-5 times to staff the roles you need before delegating work. Staff every hire with the library skills their role needs (check list_skills; several are fine) — those skills become mandatory practice in the hire's runs.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
@@ -49,6 +49,11 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
||||
role: { type: "string", description: "Their role/title, e.g. 'Growth Marketer'." },
|
||||
department: { type: "string", enum: [...ORGANIZATION_DEPARTMENTS], description: "The company department the hire belongs to." },
|
||||
model: { type: "string", description: "Optional model id; defaults to your own model." },
|
||||
skills: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Skill slugs from list_skills this hire must work with, e.g. ['engineering-backend-architect']. Every agent also carries the humanizer house-style skill automatically.",
|
||||
},
|
||||
},
|
||||
required: ["name", "role", "department"],
|
||||
},
|
||||
@@ -254,6 +259,7 @@ function hireAgent(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord)
|
||||
const role = requireString(args.role, "role");
|
||||
const department = requireString(args.department, "department");
|
||||
const model = optionalString(args.model) ?? ctx.agent.modelId;
|
||||
const requestedSkills = optionalStringArray(args.skills, "skills");
|
||||
const agentId = deps.organization.createAgent({
|
||||
orgId: ctx.orgId,
|
||||
name,
|
||||
@@ -268,7 +274,10 @@ function hireAgent(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord)
|
||||
heartbeatEnabled: true,
|
||||
heartbeatIntervalSec: HEARTBEAT_INTERVAL_SEC,
|
||||
});
|
||||
return `Hired ${name} as ${role} in ${department} (agent_id=${agentId}).`;
|
||||
const { granted, unknown } = bindSkillSlugs(deps, ctx.orgId, agentId, requestedSkills);
|
||||
const skillNote = granted.length > 0 ? ` They carry: ${granted.join(", ")} (plus the automatic humanizer house style).` : "";
|
||||
const unknownNote = unknown.length > 0 ? ` Unknown skills skipped: ${unknown.join(", ")} — run list_skills and grant_skill to fix.` : "";
|
||||
return `Hired ${name} as ${role} in ${department} (agent_id=${agentId}).${skillNote}${unknownNote}`;
|
||||
}
|
||||
|
||||
function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord, options: AgentToolDispatchOptions): string {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { RISK_LEVEL } from "../../shared/domain";
|
||||
import { loadSkillManifest, type SkillManifest } from "./skill-manifest";
|
||||
import type { SkillService } from "./skill-service";
|
||||
|
||||
/**
|
||||
* The skill catalogue ships with the app: `built-in skills/` in the repo, and
|
||||
* `resources/built-in-skills` in a packaged build. The user never manages it.
|
||||
*
|
||||
* The bundle holds two shapes:
|
||||
* - a directory per skill with a SKILL.md (and optional skill.manifest.json),
|
||||
* e.g. `humanizer/`;
|
||||
* - category directories of flat one-file skills, e.g.
|
||||
* `engineering/engineering-backend-architect.md`.
|
||||
*
|
||||
* Staging normalizes both into one library layout the import machinery
|
||||
* understands — `<libraryRoot>/<slug>/SKILL.md` — a real copy on disk, never a
|
||||
* reference back into the bundle. Import then publishes every staged skill the
|
||||
* org does not carry yet into the immutable artifact store.
|
||||
*/
|
||||
|
||||
export interface StagedBuiltinSkill {
|
||||
slug: string;
|
||||
directory: string;
|
||||
manifest: SkillManifest;
|
||||
}
|
||||
|
||||
export interface BuiltinSkillSyncReport {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failures: string[];
|
||||
}
|
||||
|
||||
/** Test seam, same pattern as AGENTCOMPANY_NEW_PROFILE_DIR. */
|
||||
export function builtinSkillsSourceOverride(): string | undefined {
|
||||
return process.env.AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the bundled catalogue into the normalized on-disk library and
|
||||
* returns every staged skill. Idempotent: re-staging overwrites in place so
|
||||
* an app update refreshes the library on next launch.
|
||||
*/
|
||||
export function stageBuiltinSkillsLibrary(sourceRoot: string, libraryRoot: string): StagedBuiltinSkill[] {
|
||||
if (!existsSync(sourceRoot)) {
|
||||
throw new Error(`Built-in skills bundle was not found: ${sourceRoot}`);
|
||||
}
|
||||
const staged: StagedBuiltinSkill[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of readdirSync(sourceRoot, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const sourceDir = join(sourceRoot, entry.name);
|
||||
if (existsSync(join(sourceDir, "SKILL.md")) || existsSync(join(sourceDir, "skill.manifest.json"))) {
|
||||
staged.push(stageDirectorySkill(sourceDir, entry.name, libraryRoot, seen));
|
||||
continue;
|
||||
}
|
||||
staged.push(...stageCategorySkills(sourceDir, libraryRoot, seen));
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
/** Publishes every staged skill the org does not already carry. */
|
||||
export function importStagedSkills(skills: SkillService, orgId: string, staged: StagedBuiltinSkill[]): BuiltinSkillSyncReport {
|
||||
const report: BuiltinSkillSyncReport = { imported: 0, skipped: 0, failures: [] };
|
||||
for (const unit of staged) {
|
||||
try {
|
||||
if (skills.hasSkill(orgId, unit.slug)) {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
skills.importManifest(orgId, unit.directory, unit.manifest, unit.slug);
|
||||
report.imported += 1;
|
||||
} catch (error) {
|
||||
report.failures.push(`${unit.slug}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
function stageDirectorySkill(sourceDir: string, dirName: string, libraryRoot: string, seen: Set<string>): StagedBuiltinSkill {
|
||||
const slug = uniqueSlug(slugify(dirName), seen, sourceDir);
|
||||
const target = join(libraryRoot, slug);
|
||||
mkdirSync(target, { recursive: true });
|
||||
cpSync(sourceDir, target, { recursive: true });
|
||||
const manifest = existsSync(join(target, "skill.manifest.json"))
|
||||
? loadSkillManifest(target)
|
||||
: manifestFromSkillMarkdown(readFileSync(join(target, "SKILL.md"), "utf8"), dirName);
|
||||
return { slug, directory: target, manifest };
|
||||
}
|
||||
|
||||
function stageCategorySkills(categoryDir: string, libraryRoot: string, seen: Set<string>): StagedBuiltinSkill[] {
|
||||
const staged: StagedBuiltinSkill[] = [];
|
||||
for (const entry of readdirSync(categoryDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) continue;
|
||||
if (entry.name.toLowerCase() === "readme.md") continue;
|
||||
const body = readFileSync(join(categoryDir, entry.name), "utf8");
|
||||
const frontmatter = parseFrontmatter(body);
|
||||
// A category file without a name: header is documentation, not a skill.
|
||||
if (!frontmatter.name) continue;
|
||||
const stem = entry.name.replace(/\.md$/i, "");
|
||||
const slug = uniqueSlug(slugify(stem), seen, join(categoryDir, entry.name));
|
||||
const target = join(libraryRoot, slug);
|
||||
mkdirSync(target, { recursive: true });
|
||||
writeFileSync(join(target, "SKILL.md"), body, "utf8");
|
||||
staged.push({
|
||||
slug,
|
||||
directory: target,
|
||||
manifest: derivedManifest(frontmatter.name, frontmatter.version, frontmatter.description),
|
||||
});
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
function manifestFromSkillMarkdown(markdown: string, fallbackName: string): SkillManifest {
|
||||
const frontmatter = parseFrontmatter(markdown);
|
||||
return derivedManifest(frontmatter.name ?? fallbackName, frontmatter.version, frontmatter.description);
|
||||
}
|
||||
|
||||
function derivedManifest(name: string, version: string | undefined, description: string | undefined): SkillManifest {
|
||||
return {
|
||||
name,
|
||||
version: version && /^\d+\.\d+\.\d+$/.test(version) ? version : "1.0.0",
|
||||
entrypoint: "SKILL.md",
|
||||
capabilities: [],
|
||||
requiredTools: [],
|
||||
compatibleModels: [],
|
||||
riskLevel: RISK_LEVEL.LOW,
|
||||
tests: [],
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
...(description ? { description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
interface SkillFrontmatter {
|
||||
name?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML-frontmatter reader for the three fields skills declare. Handles
|
||||
* inline values (`description: text`) and block scalars (`description: |`)
|
||||
* because the bundled skills use both.
|
||||
*/
|
||||
export function parseFrontmatter(markdown: string): SkillFrontmatter {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
|
||||
if (!match) return {};
|
||||
const lines = match[1].split(/\r?\n/);
|
||||
const result: SkillFrontmatter = {};
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const field = /^(name|version|description)\s*:\s*(.*)$/.exec(lines[index]);
|
||||
if (!field) continue;
|
||||
const key = field[1] as keyof SkillFrontmatter;
|
||||
const inline = field[2].trim();
|
||||
if (inline && !/^[|>][+-]?$/.test(inline)) {
|
||||
result[key] = stripQuotes(inline);
|
||||
continue;
|
||||
}
|
||||
const block: string[] = [];
|
||||
while (index + 1 < lines.length && (/^\s+\S/.test(lines[index + 1]) || lines[index + 1].trim() === "")) {
|
||||
block.push(lines[index + 1].trim());
|
||||
index += 1;
|
||||
}
|
||||
const value = block.join(" ").replace(/\s+/g, " ").trim();
|
||||
if (value) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
return value.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function uniqueSlug(slug: string, seen: Set<string>, source: string): string {
|
||||
if (!slug) throw new Error(`Built-in skill has no usable slug: ${source}`);
|
||||
if (seen.has(slug)) throw new Error(`Built-in skill slug collision: ${slug} (${source})`);
|
||||
seen.add(slug);
|
||||
return slug;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { RISK_LEVEL } from "../../shared/domain";
|
||||
import { loadSkillManifest, type SkillManifest } from "./skill-manifest";
|
||||
import type { SkillService } from "./skill-service";
|
||||
|
||||
/**
|
||||
* Skills live on disk the way developers keep them: one directory per skill
|
||||
* under the user's global `~/.agents/skills` and under each workspace's
|
||||
* `.agents/skills`. A directory qualifies with either a full
|
||||
* skill.manifest.json or just a SKILL.md; for the latter the manifest is
|
||||
* derived (name from frontmatter or the directory name, SKILL.md as the
|
||||
* entrypoint). Paths are joined per platform — Windows, macOS, and Linux
|
||||
* all resolve the same way.
|
||||
*/
|
||||
|
||||
export interface SkillSyncReport {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failures: string[];
|
||||
}
|
||||
|
||||
export function defaultGlobalSkillsRoot(): string {
|
||||
// Same isolation seam as AGENTCOMPANY_NEW_PROFILE_DIR: tests point this at
|
||||
// a temporary directory so they never read the developer's real library.
|
||||
return process.env.AGENTCOMPANY_NEW_SKILLS_ROOT ?? join(homedir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
export function discoverSkillDirectories(workspaceRoots: string[], globalRoot: string = defaultGlobalSkillsRoot()): string[] {
|
||||
const roots = [globalRoot, ...workspaceRoots.map((root) => join(root, ".agents", "skills"))];
|
||||
const found: string[] = [];
|
||||
for (const root of roots) {
|
||||
if (!existsSync(root)) continue;
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = join(root, entry.name);
|
||||
if (existsSync(join(dir, "skill.manifest.json")) || existsSync(join(dir, "SKILL.md"))) {
|
||||
found.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Full manifest when present; otherwise derived from SKILL.md. */
|
||||
export function manifestForDirectory(directoryPath: string): SkillManifest {
|
||||
if (existsSync(join(directoryPath, "skill.manifest.json"))) {
|
||||
return loadSkillManifest(directoryPath);
|
||||
}
|
||||
const skillMd = readFileSync(join(directoryPath, "SKILL.md"), "utf8");
|
||||
return {
|
||||
name: frontmatterName(skillMd) ?? basename(directoryPath),
|
||||
version: "1.0.0",
|
||||
entrypoint: "SKILL.md",
|
||||
capabilities: [],
|
||||
requiredTools: [],
|
||||
compatibleModels: [],
|
||||
riskLevel: RISK_LEVEL.LOW,
|
||||
tests: [],
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports every discovered skill the org does not already carry. Individual
|
||||
* bad directories are reported and skipped so one broken entry cannot block
|
||||
* the rest of the library.
|
||||
*/
|
||||
export function syncSkillsFromDisk(
|
||||
db: DatabaseSync,
|
||||
skills: SkillService,
|
||||
orgId: string,
|
||||
globalRoot: string = defaultGlobalSkillsRoot(),
|
||||
): SkillSyncReport {
|
||||
const workspaceRoots = (db.prepare("select local_path from workspaces where org_id = ?").all(orgId) as Array<{ local_path: string }>)
|
||||
.map((row) => row.local_path);
|
||||
const report: SkillSyncReport = { imported: 0, skipped: 0, failures: [] };
|
||||
for (const directory of discoverSkillDirectories(workspaceRoots, globalRoot)) {
|
||||
try {
|
||||
const manifest = manifestForDirectory(directory);
|
||||
if (skills.hasSkill(orgId, manifest.name)) {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
skills.importManifest(orgId, directory, manifest);
|
||||
report.imported += 1;
|
||||
} catch (error) {
|
||||
report.failures.push(`${directory}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
function frontmatterName(markdown: string): string | null {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
|
||||
if (!match) return null;
|
||||
const nameLine = match[1].split(/\r?\n/).find((line) => /^name\s*:/.test(line.trim()));
|
||||
if (!nameLine) return null;
|
||||
const value = nameLine.split(":").slice(1).join(":").trim().replace(/^["']|["']$/g, "");
|
||||
return value || null;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export interface SkillManifest {
|
||||
tests: string[];
|
||||
inputs: unknown;
|
||||
outputs: unknown;
|
||||
/** One-line summary shown when agents browse the library. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function loadSkillManifest(directoryPath: string, requestedVersion?: string): SkillManifest {
|
||||
@@ -42,6 +44,9 @@ function normalizeManifest(raw: Record<string, unknown>): SkillManifest {
|
||||
inputs: raw.inputs ?? {},
|
||||
outputs: raw.outputs ?? {},
|
||||
};
|
||||
if (typeof raw.description === "string" && raw.description.trim()) {
|
||||
manifest.description = raw.description.trim();
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,20 @@ function slugify(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* House-style skills every agent carries on every run, CEO included. They are
|
||||
* a resolution rule, not a binding row, so no creation path — hire tool,
|
||||
* onboarding, tests, raw SQL — can produce an agent without them.
|
||||
*/
|
||||
export const FORCED_SKILL_SLUGS = ["humanizer"] as const;
|
||||
|
||||
/**
|
||||
* Compiled skill prompts stop packing past this size so one over-granted
|
||||
* agent cannot overflow the model's context window. Skills pack whole, forced
|
||||
* skills first, and anything omitted is named in the prompt.
|
||||
*/
|
||||
export const MAX_COMPILED_PROMPT_CHARS = 200_000;
|
||||
|
||||
export class SkillService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly operations: OperationsService;
|
||||
@@ -28,14 +42,14 @@ export class SkillService {
|
||||
return this.importManifest(input.orgId, input.directoryPath, manifest);
|
||||
}
|
||||
|
||||
/** True when the org already carries a skill with this name. */
|
||||
hasSkill(orgId: string, name: string): boolean {
|
||||
const slug = slugify(name);
|
||||
/** True when the org already carries a skill under this slug (or name). */
|
||||
hasSkill(orgId: string, slugOrName: string): boolean {
|
||||
const slug = slugify(slugOrName);
|
||||
return Boolean(this.db.prepare("select id from skill_definitions where org_id = ? and slug = ?").get(orgId, slug));
|
||||
}
|
||||
|
||||
importManifest(orgId: string, directoryPath: string, manifest: SkillManifest): string {
|
||||
const slug = slugify(manifest.name);
|
||||
importManifest(orgId: string, directoryPath: string, manifest: SkillManifest, explicitSlug?: string): string {
|
||||
const slug = explicitSlug ?? slugify(manifest.name);
|
||||
if (!slug) throw new Error("Skill name could not be derived.");
|
||||
const now = new Date().toISOString();
|
||||
const skillId = createId("skl");
|
||||
@@ -139,77 +153,97 @@ export class SkillService {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a run for this agent would compile at least one skill: the CEO
|
||||
* carries every active skill by role; other agents need enabled bindings.
|
||||
* True when a run for this agent would compile at least one skill: every
|
||||
* agent carries the forced house-style skills once the catalogue holds
|
||||
* them, plus whatever bindings the CEO granted.
|
||||
*/
|
||||
hasRuntimeSkills(orgId: string, agentId: string): boolean {
|
||||
if (this.isCSuite(orgId, agentId)) {
|
||||
return Boolean(this.db.prepare(`
|
||||
select sd.id from skill_definitions sd
|
||||
join skill_revisions sr on sr.skill_id = sd.id
|
||||
where sd.org_id = ? and sd.status = 'active'
|
||||
limit 1
|
||||
`).get(orgId));
|
||||
}
|
||||
const forced = this.db.prepare(`
|
||||
select sd.id from skill_definitions sd
|
||||
join skill_revisions sr on sr.skill_id = sd.id
|
||||
where sd.org_id = ? and sd.status = 'active' and sd.slug in (${forcedSlugPlaceholders()})
|
||||
limit 1
|
||||
`).get(orgId, ...FORCED_SKILL_SLUGS);
|
||||
if (forced) return true;
|
||||
return Boolean(this.db.prepare(
|
||||
"select id from skill_bindings where org_id = ? and agent_id = ? and enabled = 1 limit 1",
|
||||
).get(orgId, agentId));
|
||||
}
|
||||
|
||||
private isCSuite(orgId: string, agentId: string): boolean {
|
||||
const agent = this.db.prepare("select role_level from agents where id = ? and org_id = ?")
|
||||
.get(agentId, orgId) as { role_level: string } | undefined;
|
||||
if (!agent) throw new Error("Agent was not found.");
|
||||
return agent.role_level === "c_suite";
|
||||
}
|
||||
|
||||
/**
|
||||
* Freezes the exact skill set this run carries: forced house-style skills
|
||||
* first (identical for everyone, CEO included), then the agent's enabled
|
||||
* bindings in priority order, deduplicated per skill. Skills pack whole
|
||||
* until MAX_COMPILED_PROMPT_CHARS; whatever does not fit is named in the
|
||||
* compiled prompt instead of silently overflowing the model's context.
|
||||
*/
|
||||
resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string {
|
||||
const cSuite = this.isCSuite(input.orgId, input.agentId);
|
||||
const existingSnapshot = this.db.prepare("select id from skill_runtime_snapshots where agent_id = ? and run_id = ?")
|
||||
.get(input.agentId, input.runId) as { id: string } | undefined;
|
||||
if (existingSnapshot) throw new Error("Skill runtime snapshot already exists for this run.");
|
||||
const rows = (cSuite
|
||||
? this.db.prepare(`
|
||||
select
|
||||
sr.id as revision_id,
|
||||
sr.version_semver,
|
||||
sr.manifest_json,
|
||||
sa.object_key
|
||||
from skill_definitions sd
|
||||
join skill_revisions sr on sr.id = (
|
||||
select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1
|
||||
)
|
||||
join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown')
|
||||
where sd.org_id = ? and sd.status = 'active'
|
||||
order by sr.published_at asc
|
||||
`).all(input.orgId)
|
||||
: this.db.prepare(`
|
||||
select
|
||||
sr.id as revision_id,
|
||||
sr.version_semver,
|
||||
sr.manifest_json,
|
||||
sa.object_key
|
||||
from skill_bindings sb
|
||||
join skill_revisions sr on sr.id = sb.pinned_revision_id
|
||||
join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown')
|
||||
where sb.org_id = ? and sb.agent_id = ? and sb.enabled = 1
|
||||
order by sb.priority asc, sr.published_at asc
|
||||
`).all(input.orgId, input.agentId)) as unknown as RuntimeSkillRow[];
|
||||
const forcedRows = this.db.prepare(`
|
||||
select
|
||||
sd.id as skill_id,
|
||||
sr.id as revision_id,
|
||||
sr.version_semver,
|
||||
sr.manifest_json,
|
||||
sa.object_key
|
||||
from skill_definitions sd
|
||||
join skill_revisions sr on sr.id = (
|
||||
select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1
|
||||
)
|
||||
join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown')
|
||||
where sd.org_id = ? and sd.status = 'active' and sd.slug in (${forcedSlugPlaceholders()})
|
||||
order by sd.slug asc
|
||||
`).all(input.orgId, ...FORCED_SKILL_SLUGS) as unknown as RuntimeSkillRow[];
|
||||
const boundRows = this.db.prepare(`
|
||||
select
|
||||
sb.skill_id as skill_id,
|
||||
sr.id as revision_id,
|
||||
sr.version_semver,
|
||||
sr.manifest_json,
|
||||
sa.object_key
|
||||
from skill_bindings sb
|
||||
join skill_revisions sr on sr.id = sb.pinned_revision_id
|
||||
join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown')
|
||||
where sb.org_id = ? and sb.agent_id = ? and sb.enabled = 1
|
||||
order by sb.priority asc, sr.published_at asc
|
||||
`).all(input.orgId, input.agentId) as unknown as RuntimeSkillRow[];
|
||||
const rows: RuntimeSkillRow[] = [];
|
||||
const seenSkillIds = new Set<string>();
|
||||
for (const row of [...forcedRows, ...boundRows]) {
|
||||
if (seenSkillIds.has(row.skill_id)) continue;
|
||||
seenSkillIds.add(row.skill_id);
|
||||
rows.push(row);
|
||||
}
|
||||
if (rows.length === 0) throw new Error("Agent has no enabled skills.");
|
||||
|
||||
const compiledParts: string[] = [];
|
||||
const tools = new Set<string>();
|
||||
const revisionIds: string[] = [];
|
||||
const omitted: string[] = [];
|
||||
let compiledLength = 0;
|
||||
for (const row of rows) {
|
||||
if (!existsSync(row.object_key)) throw new Error(`Skill artifact is missing: ${row.object_key}`);
|
||||
const manifest = JSON.parse(row.manifest_json) as SkillManifest;
|
||||
for (const tool of manifest.requiredTools) tools.add(tool);
|
||||
revisionIds.push(row.revision_id);
|
||||
compiledParts.push([
|
||||
const part = [
|
||||
`# Skill: ${manifest.name}`,
|
||||
`Version: ${row.version_semver}`,
|
||||
readFileSync(row.object_key, "utf8").trim(),
|
||||
].join("\n\n"));
|
||||
].join("\n\n");
|
||||
if (compiledParts.length > 0 && compiledLength + part.length > MAX_COMPILED_PROMPT_CHARS) {
|
||||
omitted.push(manifest.name);
|
||||
continue;
|
||||
}
|
||||
for (const tool of manifest.requiredTools) tools.add(tool);
|
||||
revisionIds.push(row.revision_id);
|
||||
compiledParts.push(part);
|
||||
compiledLength += part.length;
|
||||
}
|
||||
if (omitted.length > 0) {
|
||||
compiledParts.push(
|
||||
`# Skills omitted for size\n\nThese granted skills did not fit this run's prompt budget: ${omitted.join(", ")}. Ask the CEO to trim grants if they are essential.`,
|
||||
);
|
||||
}
|
||||
|
||||
const snapshotId = createId("srs");
|
||||
@@ -307,6 +341,10 @@ export class SkillService {
|
||||
}
|
||||
}
|
||||
|
||||
function forcedSlugPlaceholders(): string {
|
||||
return FORCED_SKILL_SLUGS.map(() => "?").join(", ");
|
||||
}
|
||||
|
||||
function artifactType(relativePath: string, manifest: SkillManifest): string {
|
||||
if (relativePath === "skill.manifest.json") return "manifest";
|
||||
if (relativePath === manifest.entrypoint) return "entrypoint";
|
||||
@@ -315,6 +353,7 @@ function artifactType(relativePath: string, manifest: SkillManifest): string {
|
||||
}
|
||||
|
||||
interface RuntimeSkillRow {
|
||||
skill_id: string;
|
||||
revision_id: string;
|
||||
version_semver: string;
|
||||
manifest_json: string;
|
||||
|
||||
@@ -120,7 +120,7 @@ function capabilityBriefing(input: AgentInstructionContext): string {
|
||||
);
|
||||
if (input.isCeo) {
|
||||
lines.push(
|
||||
"You are the CEO: you automatically carry every connected service and every company skill. Access for everyone else is yours to manage — when you delegate work that needs an external service or a skill, grant it to that teammate first with grant_connector or grant_skill (match the tool to the job), and take it back with revoke_connector or revoke_skill when the work is done. A teammate without the grant cannot use the tool, so grant before you assign.",
|
||||
"You are the CEO: you automatically carry every connected service, and access for everyone else is yours to manage — when you delegate work that needs an external service, grant it first with grant_connector and take it back with revoke_connector when done. The company also ships a built-in skill library: staff every hire with the skills their role needs by passing skills to hire_agent (several per person is normal), and adjust later with grant_skill / revoke_skill. A teammate without the grant cannot use the tool or skill, so grant before you assign. The humanizer skill is company house style — every agent, you included, carries it automatically and must write like a person, not like an AI.",
|
||||
);
|
||||
} else if (input.connectorNames.length === 0) {
|
||||
lines.push(
|
||||
@@ -194,7 +194,7 @@ export function buildTaskPrompt(input: {
|
||||
if (input.skillRuntimeSnapshot) {
|
||||
lines.push(
|
||||
"",
|
||||
"Skill runtime:",
|
||||
"Skills you carry this run — these are mandatory working practice, not suggestions. Apply each one to how you work and what you produce:",
|
||||
input.skillRuntimeSnapshot.compiled_prompt,
|
||||
"",
|
||||
`Tool allowlist: ${input.skillRuntimeSnapshot.tools_manifest}`,
|
||||
|
||||
@@ -56,10 +56,6 @@ const api: DesktopApi = {
|
||||
createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input),
|
||||
streamProviderResponse: (input) => invoke(IPC_CHANNELS.streamProviderResponse, input),
|
||||
cancelProviderResponse: (input) => invoke(IPC_CHANNELS.cancelProviderResponse, input),
|
||||
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
|
||||
syncSkillsFromDisk: (input) => invoke(IPC_CHANNELS.syncSkillsFromDisk, input),
|
||||
resolveAgentSkills: (input) => invoke(IPC_CHANNELS.resolveAgentSkills, input),
|
||||
runSkillTests: (input) => invoke(IPC_CHANNELS.runSkillTests, input),
|
||||
createSocialAccount: (input) => invoke(IPC_CHANNELS.createSocialAccount, input),
|
||||
launchSocialLogin: (input) => invoke(IPC_CHANNELS.launchSocialLogin, input),
|
||||
createScheduledPost: (input) => invoke(IPC_CHANNELS.createScheduledPost, input),
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ProvidersPanel } from "./settings/ProvidersPanel";
|
||||
import { SkillsPanel } from "./settings/SkillsPanel";
|
||||
import { WorkspacePanel } from "./settings/WorkspacePanel";
|
||||
import { AdvancedPanel } from "./settings/AdvancedPanel";
|
||||
import { t } from "../i18n/i18n";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, requireActiveEmployee, requireActiveOrganization } from "./types";
|
||||
|
||||
type SettingsTab = "general" | "providers" | "skills" | "advanced";
|
||||
type SettingsTab = "general" | "providers" | "advanced";
|
||||
|
||||
interface NavSpec {
|
||||
id: SettingsTab;
|
||||
@@ -17,7 +16,6 @@ interface NavSpec {
|
||||
const TABS: NavSpec[] = [
|
||||
{ id: "general", label: t("General") },
|
||||
{ id: "providers", label: t("Providers") },
|
||||
{ id: "skills", label: t("Skills") },
|
||||
{ id: "advanced", label: t("Advanced") },
|
||||
];
|
||||
|
||||
@@ -31,16 +29,6 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
const providers = useMemo(() => snapshot.providers.filter((item) => item.orgId === orgId), [snapshot.providers, orgId]);
|
||||
const providerAuthProfiles = useMemo(() => snapshot.providerAuthProfiles.filter((item) => item.orgId === orgId), [snapshot.providerAuthProfiles, orgId]);
|
||||
const providerRateLimits = useMemo(() => snapshot.providerRateLimits.filter((item) => item.orgId === orgId), [snapshot.providerRateLimits, orgId]);
|
||||
const skills = useMemo(() => snapshot.skillDefinitions.filter((item) => item.orgId === orgId), [snapshot.skillDefinitions, orgId]);
|
||||
const skillRevisions = useMemo(() => {
|
||||
const skillIds = new Set(skills.map((skill) => skill.id));
|
||||
return snapshot.skillRevisions.filter((revision) => skillIds.has(revision.skillId));
|
||||
}, [snapshot.skillRevisions, skills]);
|
||||
const skillTests = useMemo(() => {
|
||||
const revisionIds = new Set(skillRevisions.map((revision) => revision.id));
|
||||
return snapshot.skillTests.filter((test) => revisionIds.has(test.revisionId));
|
||||
}, [snapshot.skillTests, skillRevisions]);
|
||||
const skillBindings = useMemo(() => snapshot.skillBindings.filter((item) => item.orgId === orgId), [snapshot.skillBindings, orgId]);
|
||||
const workspaces = useMemo(() => snapshot.workspaces.filter((item) => item.orgId === orgId), [snapshot.workspaces, orgId]);
|
||||
const providerRequests = useMemo(() => snapshot.providerRequests.filter((item) => item.orgId === orgId), [snapshot.providerRequests, orgId]);
|
||||
const hiringPolicies = useMemo(() => snapshot.hiringPolicies.filter((item) => item.orgId === orgId), [snapshot.hiringPolicies, orgId]);
|
||||
@@ -68,17 +56,6 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
{tab === "skills" && (
|
||||
<SkillsPanel
|
||||
orgId={orgId}
|
||||
skills={skills}
|
||||
revisions={skillRevisions}
|
||||
bindings={skillBindings}
|
||||
tests={skillTests}
|
||||
agents={agents}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
{tab === "advanced" && (
|
||||
<AdvancedPanel
|
||||
orgId={orgId}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Button, Field, Icon, Pill } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import { t } from "../../i18n/i18n";
|
||||
import type {
|
||||
AgentRecord,
|
||||
SkillBindingRecord,
|
||||
SkillDefinitionRecord,
|
||||
SkillRevisionRecord,
|
||||
SkillTestRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
|
||||
interface SkillsPanelProps {
|
||||
orgId: string;
|
||||
skills: SkillDefinitionRecord[];
|
||||
revisions: SkillRevisionRecord[];
|
||||
bindings: SkillBindingRecord[];
|
||||
tests: SkillTestRecord[];
|
||||
agents: AgentRecord[];
|
||||
actions: SnapshotActions;
|
||||
}
|
||||
|
||||
export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents, actions }: SkillsPanelProps) {
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
// The on-disk skill library (global ~/.agents/skills and each workspace's
|
||||
// .agents/skills) syncs in when the panel opens.
|
||||
useEffect(() => {
|
||||
void actions.run(() => window.agentCompany.syncSkillsFromDisk({ orgId }));
|
||||
}, [orgId, actions]);
|
||||
|
||||
const importSkill = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.importSkillDirectory({
|
||||
orgId,
|
||||
directoryPath: String(form.get("directoryPath") ?? "").trim(),
|
||||
versionSemver: String(form.get("versionSemver") ?? "").trim(),
|
||||
})).then((result) => { if (result) setImportOpen(false); });
|
||||
};
|
||||
|
||||
const runTests = (revisionId: string) => {
|
||||
void actions.run(() => window.agentCompany.runSkillTests({ orgId, revisionId }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page reveal-row">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<h1 className="page-h1" style={{ margin: 0 }}>{t("Skills")}</h1>
|
||||
<div className="reveal" style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" onClick={() => setImportOpen(true)}><Icon name="plus" size={16} /> {t("Import")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) 132px" }}
|
||||
>
|
||||
<div>{t("Skill")}</div><div>{t("Version")}</div><div>{t("Tests")}</div><div>{t("Held by")}</div><div>{t("Actions")}</div>
|
||||
</div>
|
||||
{skills.map((skill, index) => {
|
||||
const last = index === skills.length - 1;
|
||||
const latest = revisions.find((revision) => revision.skillId === skill.id);
|
||||
const skillBindings = bindings.filter((binding) => binding.skillId === skill.id && binding.enabled);
|
||||
const revisionTests = latest ? tests.filter((test) => test.revisionId === latest.id) : [];
|
||||
return (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="table-row"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) 132px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{skill.name}</div>
|
||||
<div style={{ color: "var(--text-3)", fontSize: 12 }}>{skill.slug}</div>
|
||||
</div>
|
||||
<Pill>{latest ? latest.versionSemver : "—"}</Pill>
|
||||
<Pill>{testSummary(revisionTests)}</Pill>
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
{skillBindings.map((binding) => (
|
||||
<Pill key={binding.id}>{agentName(binding.agentId, agents)}</Pill>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ minWidth: 0 }}>
|
||||
<Button size="small" onClick={() => { if (latest && revisionTests.length > 0) runTests(latest.id); }} disabled={!latest || revisionTests.length === 0}>{t("Run tests")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Dialog title={t("Import skill")} open={importOpen} submitLabel={t("Import")} onClose={() => setImportOpen(false)} onSubmit={importSkill}>
|
||||
<Field label={t("Directory path")}><input className="input" name="directoryPath" required /></Field>
|
||||
<Field label={t("Version (semver)")}><input className="input" name="versionSemver" required defaultValue="1.0.0" /></Field>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function agentName(agentId: string, agents: AgentRecord[]): string {
|
||||
return agents.find((agent) => agent.id === agentId)?.name ?? agentId;
|
||||
}
|
||||
|
||||
function testSummary(tests: SkillTestRecord[]): string {
|
||||
if (tests.length === 0) return t("No tests");
|
||||
const completed = tests.filter((test) => test.lastResult);
|
||||
if (completed.length === 0) return t("Never run");
|
||||
const passed = completed.filter((test) => testStatus(test) === "passed").length;
|
||||
const failed = completed.length - passed;
|
||||
if (failed > 0) return t("{n} failed", { n: failed });
|
||||
return t("{passed}/{total} passed", { passed, total: tests.length });
|
||||
}
|
||||
|
||||
function testStatus(test: SkillTestRecord): "passed" | "failed" {
|
||||
if (!test.lastResult) return "failed";
|
||||
try {
|
||||
const result = JSON.parse(test.lastResult) as { status?: unknown };
|
||||
return result.status === "passed" ? "passed" : "failed";
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
@@ -49,18 +49,14 @@ import type {
|
||||
EditMessageInput,
|
||||
ImportFileInput,
|
||||
OpenFileInput,
|
||||
ImportSkillDirectoryInput,
|
||||
GrantRoleInput,
|
||||
LaunchProviderSignInInput,
|
||||
LaunchSocialLoginInput,
|
||||
ResolveAgentSkillsInput,
|
||||
ResolveMigrationReconciliationInput,
|
||||
RunChaosDrillInput,
|
||||
RunEvalSuiteInput,
|
||||
RunSdlcReviewInput,
|
||||
RunSkillTestsInput,
|
||||
RunTaskInput,
|
||||
SyncSkillsFromDiskInput,
|
||||
RevokeProviderCredentialInput,
|
||||
SaveProviderCredentialInput,
|
||||
ConnectComposioToolkitInput,
|
||||
@@ -152,10 +148,6 @@ export interface DesktopApi {
|
||||
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
streamProviderResponse(input: StreamProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
cancelProviderResponse(input: CancelProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
syncSkillsFromDisk(input: SyncSkillsFromDiskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
resolveAgentSkills(input: ResolveAgentSkillsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
runSkillTests(input: RunSkillTestsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createSocialAccount(input: CreateSocialAccountInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
launchSocialLogin(input: LaunchSocialLoginInput): Promise<DesktopResult<null>>;
|
||||
createScheduledPost(input: CreateScheduledPostInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -33,10 +33,6 @@ export const IPC_CHANNELS = {
|
||||
createProviderResponse: "agentcompany:create-provider-response",
|
||||
streamProviderResponse: "agentcompany:stream-provider-response",
|
||||
cancelProviderResponse: "agentcompany:cancel-provider-response",
|
||||
importSkillDirectory: "agentcompany:import-skill-directory",
|
||||
syncSkillsFromDisk: "agentcompany:sync-skills-from-disk",
|
||||
resolveAgentSkills: "agentcompany:resolve-agent-skills",
|
||||
runSkillTests: "agentcompany:run-skill-tests",
|
||||
createSocialAccount: "agentcompany:create-social-account",
|
||||
launchSocialLogin: "agentcompany:launch-social-login",
|
||||
createScheduledPost: "agentcompany:create-scheduled-post",
|
||||
|
||||
Reference in new issue
Block a user