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
@@ -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}`,
|
||||
|
||||
Reference in new issue
Block a user