feat(quick-260327-x64): add skill sync types and resolution utilities to adapter-utils

- Add AdapterSkillSyncMode, AdapterSkillState, AdapterSkillOrigin, AdapterSkillEntry, AdapterSkillSnapshot, AdapterSkillContext types to types.ts
- Add optional listSkills/syncSkills methods to ServerAdapterModule interface
- Export new skill types from index.ts
- Add skill resolution helpers to server-utils.ts: resolveAgentCompanySkillsDir, listAgentCompanySkillEntries, readInstalledSkillTargets, buildPersistentSkillSnapshot, readAgentCompanyRuntimeSkillEntries, readAgentCompanySkillSyncPreference, resolveAgentCompanyDesiredSkillNames, writeAgentCompanySkillSyncPreference, ensureAgentCompanySkillSymlink, removeMaintainerOnlySkillSymlinks
This commit is contained in:
ZacharyZhang-NY committed 2026-03-27 23:59:28 -04:00
1 parent 80fa4221dc
commit ed4988c5aa
3 files changed
+534 -2

No files matched your search

+6
View File
@@ -12,6 +12,12 @@ export type {
AdapterEnvironmentTestStatus,
AdapterEnvironmentTestResult,
AdapterEnvironmentTestContext,
AdapterSkillSyncMode,
AdapterSkillState,
AdapterSkillOrigin,
AdapterSkillEntry,
AdapterSkillSnapshot,
AdapterSkillContext,
AdapterSessionCodec,
AdapterModel,
HireApprovedPayload,
+473 -2
View File
@@ -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<string, InstalledSkillTarget>;
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<string | null> {
const candidates = [
...AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)),
...additionalCandidates.map((candidate) => path.resolve(candidate)),
];
const seenRoots = new Set<string>();
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<AgentCompanySkillEntry[]> {
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<Map<string, InstalledSkillTarget>> {
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
const out = new Map<string, InstalledSkillTarget>();
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<string, unknown>,
moduleDir: string,
additionalCandidates: string[] = [],
): Promise<AgentCompanySkillEntry[]> {
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<string, unknown>): {
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<string, unknown>;
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<string, unknown>,
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<string, unknown>,
desiredSkills: string[],
): Record<string, unknown> {
const next = { ...config };
const raw = next.agentCompanySkillSync;
const current =
typeof raw === "object" && raw !== null && !Array.isArray(raw)
? { ...(raw as Record<string, unknown>) }
: {};
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<void> = (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<string>,
): Promise<string[]> {
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 [];
}
}
+55
View File
@@ -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<string, unknown>;
}
export interface AdapterEnvironmentTestContext {
companyId: string;
adapterType: string;
@@ -174,6 +227,8 @@ export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
listSkills?: (ctx: AdapterSkillContext) => Promise<AdapterSkillSnapshot>;
syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise<AdapterSkillSnapshot>;
sessionCodec?: AdapterSessionCodec;
supportsLocalAgentJwt?: boolean;
models?: AdapterModel[];