diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 956e7431..e816100c 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -12,6 +12,12 @@ export type { AdapterEnvironmentTestStatus, AdapterEnvironmentTestResult, AdapterEnvironmentTestContext, + AdapterSkillSyncMode, + AdapterSkillState, + AdapterSkillOrigin, + AdapterSkillEntry, + AdapterSkillSnapshot, + AdapterSkillContext, AdapterSessionCodec, AdapterModel, HireApprovedPayload, diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 0b1e67d2..31255ef4 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1,8 +1,8 @@ import { spawn, type ChildProcess } from "node:child_process"; -import { constants as fsConstants, promises as fs } from "node:fs"; +import { constants as fsConstants, promises as fs, type Dirent } from "node:fs"; import path from "node:path"; import { StringDecoder } from "node:string_decoder"; -import type { ManagedSkillRef } from "./types.js"; +import type { AdapterSkillEntry, AdapterSkillSnapshot, ManagedSkillRef } from "./types.js"; /** * Decode a Buffer as GBK (CP936). Used as a fallback when UTF-8 decoding @@ -498,3 +498,474 @@ export async function createManagedSkillSymlinks( } } } + +// --------------------------------------------------------------------------- +// Skill resolution helpers -- ported from Paperclip reference with +// AgentCompany naming. These utilities let each adapter discover, list, +// resolve, and synchronize skills from a shared skills directory. +// --------------------------------------------------------------------------- + +const AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES = [ + "../../skills", + "../../../../../skills", +]; + +export interface AgentCompanySkillEntry { + key: string; + runtimeName: string; + source: string; + required?: boolean; + requiredReason?: string | null; +} + +export interface InstalledSkillTarget { + targetPath: string | null; + kind: "symlink" | "directory" | "file"; +} + +interface PersistentSkillSnapshotOptions { + adapterType: string; + availableEntries: AgentCompanySkillEntry[]; + desiredSkills: string[]; + installed: Map; + skillsHome: string; + locationLabel?: string | null; + installedDetail?: string | null; + missingDetail: string; + externalConflictDetail: string; + externalDetail: string; + warnings?: string[]; +} + +function normalizePathSlashes(value: string): string { + return value.replaceAll("\\", "/"); +} + +function isMaintainerOnlySkillTarget(candidate: string): boolean { + return normalizePathSlashes(candidate).includes("/.agents/skills/"); +} + +function skillLocationLabel(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function buildManagedSkillOrigin(entry: { required?: boolean }): Pick< + AdapterSkillEntry, + "origin" | "originLabel" | "readOnly" +> { + if (entry.required) { + return { + origin: "paperclip_required", + originLabel: "Required by AgentCompany", + readOnly: false, + }; + } + return { + origin: "company_managed", + originLabel: "Managed by AgentCompany", + readOnly: false, + }; +} + +function resolveInstalledEntryTarget( + skillsHome: string, + entryName: string, + dirent: Dirent, + linkedPath: string | null, +): InstalledSkillTarget { + const fullPath = path.join(skillsHome, entryName); + if (dirent.isSymbolicLink()) { + return { + targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, + kind: "symlink", + }; + } + if (dirent.isDirectory()) { + return { targetPath: fullPath, kind: "directory" }; + } + return { targetPath: fullPath, kind: "file" }; +} + +function normalizeConfiguredAgentCompanyRuntimeSkills(value: unknown): AgentCompanySkillEntry[] { + if (!Array.isArray(value)) return []; + const out: AgentCompanySkillEntry[] = []; + for (const rawEntry of value) { + const entry = parseObject(rawEntry); + const key = asString(entry.key, asString(entry.name, "")).trim(); + const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim(); + const source = asString(entry.source, "").trim(); + if (!key || !runtimeName || !source) continue; + out.push({ + key, + runtimeName, + source, + required: asBoolean(entry.required, false), + requiredReason: + typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0 + ? entry.requiredReason.trim() + : null, + }); + } + return out; +} + +function canonicalizeDesiredAgentCompanySkillReference( + reference: string, + availableEntries: Array<{ key: string; runtimeName?: string | null }>, +): string { + const normalizedReference = reference.trim().toLowerCase(); + if (!normalizedReference) return ""; + + const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference); + if (exactKey) return exactKey.key; + + const byRuntimeName = availableEntries.filter((entry) => + typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference, + ); + if (byRuntimeName.length === 1) return byRuntimeName[0]!.key; + + const slugMatches = availableEntries.filter((entry) => + entry.key.trim().toLowerCase().split("/").pop() === normalizedReference, + ); + if (slugMatches.length === 1) return slugMatches[0]!.key; + + return normalizedReference; +} + +// --------------------------------------------------------------------------- +// Exported skill resolution functions +// --------------------------------------------------------------------------- + +/** + * Resolve the AgentCompany skills root directory from relative path candidates. + */ +export async function resolveAgentCompanySkillsDir( + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const candidates = [ + ...AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)), + ...additionalCandidates.map((candidate) => path.resolve(candidate)), + ]; + const seenRoots = new Set(); + + for (const root of candidates) { + if (seenRoots.has(root)) continue; + seenRoots.add(root); + const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false); + if (isDirectory) return root; + } + + return null; +} + +/** + * List skill entries from the AgentCompany skills directory. + */ +export async function listAgentCompanySkillEntries( + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const root = await resolveAgentCompanySkillsDir(moduleDir, additionalCandidates); + if (!root) return []; + + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + key: `agentcompany/agentcompany/${entry.name}`, + runtimeName: entry.name, + source: path.join(root, entry.name), + required: true, + requiredReason: "Bundled AgentCompany skills are always available for local adapters.", + })); + } catch { + return []; + } +} + +/** + * Read installed skill targets from a skill home directory (symlinks, dirs, files). + */ +export async function readInstalledSkillTargets(skillsHome: string): Promise> { + const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); + const out = new Map(); + for (const entry of entries) { + const fullPath = path.join(skillsHome, entry.name); + const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null; + out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath)); + } + return out; +} + +/** + * Build an AdapterSkillSnapshot for persistent-mode adapters (Gemini, Codex). + * Compares available entries against installed state and desired skill list. + */ +export function buildPersistentSkillSnapshot( + options: PersistentSkillSnapshotOptions, +): AdapterSkillSnapshot { + const { + adapterType, + availableEntries, + desiredSkills, + installed, + skillsHome, + locationLabel, + installedDetail, + missingDetail, + externalConflictDetail, + externalDetail, + } = options; + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = []; + const warnings = [...(options.warnings ?? [])]; + + for (const available of availableEntries) { + const installedEntry = installed.get(available.runtimeName) ?? null; + const desired = desiredSet.has(available.key); + let state: AdapterSkillEntry["state"] = "available"; + let managed = false; + let detail: string | null = null; + + if (installedEntry?.targetPath === available.source) { + managed = true; + state = desired ? "installed" : "stale"; + detail = installedDetail ?? null; + } else if (installedEntry) { + state = "external"; + detail = desired ? externalConflictDetail : externalDetail; + } else if (desired) { + state = "missing"; + detail = missingDetail; + } + + entries.push({ + key: available.key, + runtimeName: available.runtimeName, + desired, + managed, + state, + sourcePath: available.source, + targetPath: path.join(skillsHome, available.runtimeName), + detail, + required: Boolean(available.required), + requiredReason: available.requiredReason ?? null, + ...buildManagedSkillOrigin(available), + }); + } + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the AgentCompany skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + sourcePath: null, + targetPath: null, + detail: "AgentCompany cannot find this skill in the local runtime skills directory.", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableEntries.some((entry) => entry.runtimeName === name)) continue; + entries.push({ + key: name, + runtimeName: name, + desired: false, + managed: false, + state: "external", + origin: "user_installed", + originLabel: "User-installed", + locationLabel: skillLocationLabel(locationLabel), + readOnly: true, + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: externalDetail, + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType, + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +/** + * Read runtime skill entries from adapter config or discover from module directory. + */ +export async function readAgentCompanyRuntimeSkillEntries( + config: Record, + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const configuredEntries = normalizeConfiguredAgentCompanyRuntimeSkills(config.agentCompanyRuntimeSkills); + if (configuredEntries.length > 0) return configuredEntries; + return listAgentCompanySkillEntries(moduleDir, additionalCandidates); +} + +/** + * Read the desired skill sync preference from adapter config. + */ +export function readAgentCompanySkillSyncPreference(config: Record): { + explicit: boolean; + desiredSkills: string[]; +} { + const raw = config.agentCompanySkillSync; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return { explicit: false, desiredSkills: [] }; + } + const syncConfig = raw as Record; + const desiredValues = syncConfig.desiredSkills; + const desired = Array.isArray(desiredValues) + ? desiredValues + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + : []; + return { + explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"), + desiredSkills: Array.from(new Set(desired)), + }; +} + +/** + * Resolve the canonical desired skill list from config and available entries. + * Required skills are always included; explicit preferences are canonicalized. + */ +export function resolveAgentCompanyDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>, +): string[] { + const preference = readAgentCompanySkillSyncPreference(config); + const requiredSkills = availableEntries + .filter((entry) => entry.required) + .map((entry) => entry.key); + if (!preference.explicit) { + return Array.from(new Set(requiredSkills)); + } + const desiredSkills = preference.desiredSkills + .map((reference) => canonicalizeDesiredAgentCompanySkillReference(reference, availableEntries)) + .filter(Boolean); + return Array.from(new Set([...requiredSkills, ...desiredSkills])); +} + +/** + * Write desired skills back to adapter config. + */ +export function writeAgentCompanySkillSyncPreference( + config: Record, + desiredSkills: string[], +): Record { + const next = { ...config }; + const raw = next.agentCompanySkillSync; + const current = + typeof raw === "object" && raw !== null && !Array.isArray(raw) + ? { ...(raw as Record) } + : {}; + current.desiredSkills = Array.from( + new Set( + desiredSkills + .map((value) => value.trim()) + .filter(Boolean), + ), + ); + next.agentCompanySkillSync = current; + return next; +} + +/** + * Create or repair a symlink from source to target skill directory. + * Returns the action taken: "created", "repaired", or "skipped". + */ +export async function ensureAgentCompanySkillSymlink( + source: string, + target: string, + linkSkill: (source: string, target: string) => Promise = (linkSource, linkTarget) => + fs.symlink(linkSource, linkTarget), +): Promise<"created" | "repaired" | "skipped"> { + const existing = await fs.lstat(target).catch(() => null); + if (!existing) { + await linkSkill(source, target); + return "created"; + } + + if (!existing.isSymbolicLink()) { + return "skipped"; + } + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return "skipped"; + + const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); + if (resolvedLinkedPath === source) { + return "skipped"; + } + + const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false); + if (linkedPathExists) { + return "skipped"; + } + + await fs.unlink(target); + await linkSkill(source, target); + return "repaired"; +} + +/** + * Remove symlinks in skillsHome that point to maintainer-only paths + * (.agents/skills/) and are not in the allowed set. + */ +export async function removeMaintainerOnlySkillSymlinks( + skillsHome: string, + allowedSkillNames: Iterable, +): Promise { + const allowed = new Set(Array.from(allowedSkillNames)); + try { + const entries = await fs.readdir(skillsHome, { withFileTypes: true }); + const removed: string[] = []; + for (const entry of entries) { + if (allowed.has(entry.name)) continue; + + const target = path.join(skillsHome, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (!existing?.isSymbolicLink()) continue; + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) continue; + + const resolvedLinkedPath = path.isAbsolute(linkedPath) + ? linkedPath + : path.resolve(path.dirname(target), linkedPath); + if ( + !isMaintainerOnlySkillTarget(linkedPath) && + !isMaintainerOnlySkillTarget(resolvedLinkedPath) + ) { + continue; + } + + await fs.unlink(target); + removed.push(entry.name); + } + + return removed; + } catch { + return []; + } +} diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index cd541036..5e1bf93e 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -137,6 +137,59 @@ export interface AdapterEnvironmentTestResult { testedAt: string; } +// --------------------------------------------------------------------------- +// Skill sync types -- adapter-level skill state and synchronization +// --------------------------------------------------------------------------- + +export type AdapterSkillSyncMode = "unsupported" | "persistent" | "ephemeral"; + +export type AdapterSkillState = + | "available" + | "configured" + | "installed" + | "missing" + | "stale" + | "external"; + +export type AdapterSkillOrigin = + | "company_managed" + | "paperclip_required" + | "user_installed" + | "external_unknown"; + +export interface AdapterSkillEntry { + key: string; + runtimeName: string | null; + desired: boolean; + managed: boolean; + required?: boolean; + requiredReason?: string | null; + state: AdapterSkillState; + origin?: AdapterSkillOrigin; + originLabel?: string | null; + locationLabel?: string | null; + readOnly?: boolean; + sourcePath?: string | null; + targetPath?: string | null; + detail?: string | null; +} + +export interface AdapterSkillSnapshot { + adapterType: string; + supported: boolean; + mode: AdapterSkillSyncMode; + desiredSkills: string[]; + entries: AdapterSkillEntry[]; + warnings: string[]; +} + +export interface AdapterSkillContext { + agentId: string; + companyId: string; + adapterType: string; + config: Record; +} + export interface AdapterEnvironmentTestContext { companyId: string; adapterType: string; @@ -174,6 +227,8 @@ export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; testEnvironment(ctx: AdapterEnvironmentTestContext): Promise; + listSkills?: (ctx: AdapterSkillContext) => Promise; + syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise; sessionCodec?: AdapterSessionCodec; supportsLocalAgentJwt?: boolean; models?: AdapterModel[];