Add managed plugin skill contracts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 09:31:58 -05:00
1 parent d0e9cc76f2
commit 268673e84c
17 files changed
+918 -24

No files matched your search

@@ -44,6 +44,7 @@ import { pluginStateStore } from "./plugin-state-store.js";
import { pluginDatabaseService } from "./plugin-database.js";
import { pluginManagedAgentService } from "./plugin-managed-agents.js";
import { pluginManagedRoutineService } from "./plugin-managed-routines.js";
import { pluginManagedSkillService } from "./plugin-managed-skills.js";
import {
assertConfiguredLocalFolder,
assertWritableConfiguredLocalFolder,
@@ -509,6 +510,11 @@ export function buildHostServices(
manifest: options.manifest,
pluginWorkerManager: options.pluginWorkerManager,
});
const managedSkills = pluginManagedSkillService(db, {
pluginId,
pluginKey,
manifest: options.manifest,
});
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
@@ -1224,6 +1230,24 @@ export function buildHostServices(
},
},
skills: {
async managedGet(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedSkills.get(params.skillKey, companyId);
},
async managedReconcile(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedSkills.reconcile(params.skillKey, companyId);
},
async managedReset(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedSkills.reset(params.skillKey, companyId);
},
},
issues: {
async list(params) {
const companyId = ensureCompanyId(params.companyId);
+64 -10
View File
@@ -126,6 +126,33 @@ function applyInstructionTemplateVariables(
return next;
}
function declaredInstructionFiles(
declaration: PluginManagedAgentDeclaration,
variables: Record<string, string | null | undefined>,
) {
const instructionDeclaration = declaration.instructions;
if (!instructionDeclaration?.content && !instructionDeclaration?.files) return null;
const entryFile = instructionDeclaration.entryFile ?? "AGENTS.md";
const files = { ...(instructionDeclaration.files ?? {}) };
if (instructionDeclaration.content !== undefined) {
files[entryFile] = instructionDeclaration.content;
}
if (files[entryFile] === undefined) {
files[entryFile] = "";
}
return {
entryFile,
files: Object.fromEntries(
Object.entries(files).map(([filePath, content]) => [
filePath,
applyInstructionTemplateVariables(content, variables),
]),
),
};
}
function rowIsManagedAgent(
row: typeof agents.$inferSelect,
pluginKey: string,
@@ -299,19 +326,18 @@ export function pluginManagedAgentService(
companyId: string,
agent: Agent,
declaration: PluginManagedAgentDeclaration,
options: { replaceExisting: boolean },
materializeOptions: { replaceExisting: boolean },
): Promise<Agent> {
const instructionDeclaration = declaration.instructions;
if (!instructionDeclaration?.content) return agent;
const entryFile = instructionDeclaration.entryFile ?? "AGENTS.md";
const variables = await optionsForInstructionVariables(companyId);
const declared = declaredInstructionFiles(declaration, variables);
if (!declared) return agent;
const materialized = await instructions.materializeManagedBundle(
agent,
{ [entryFile]: applyInstructionTemplateVariables(instructionDeclaration.content, variables) },
declared.files,
{
entryFile,
replaceExisting: options.replaceExisting,
entryFile: declared.entryFile,
replaceExisting: materializeOptions.replaceExisting,
clearLegacyPromptTemplate: true,
},
);
@@ -325,6 +351,33 @@ export function pluginManagedAgentService(
return (updated as Agent | null) ?? { ...agent, adapterConfig: materialized.adapterConfig };
}
async function managedInstructionDefaultDrift(
companyId: string,
agent: Agent | null,
declaration: PluginManagedAgentDeclaration,
): Promise<PluginManagedAgentResolution["defaultDrift"]> {
if (!agent) return null;
const variables = await optionsForInstructionVariables(companyId);
const declared = declaredInstructionFiles(declaration, variables);
if (!declared) return null;
let exported: Awaited<ReturnType<typeof instructions.exportFiles>>;
try {
exported = await instructions.exportFiles(agent);
} catch {
return { entryFile: declared.entryFile, changedFiles: [declared.entryFile] };
}
const paths = new Set([...Object.keys(declared.files), ...Object.keys(exported.files)]);
const changedFiles = [...paths]
.filter((filePath) => (exported.files[filePath] ?? null) !== (declared.files[filePath] ?? null))
.sort((left, right) => left.localeCompare(right));
if (exported.entryFile !== declared.entryFile && !changedFiles.includes(declared.entryFile)) {
changedFiles.unshift(declared.entryFile);
}
return changedFiles.length > 0 ? { entryFile: declared.entryFile, changedFiles } : null;
}
async function optionsForInstructionVariables(companyId: string) {
return options.instructionTemplateVariables ? options.instructionTemplateVariables(companyId) : {};
}
@@ -333,13 +386,13 @@ export function pluginManagedAgentService(
return options.pluginKey;
}
function resolution(
async function resolution(
companyId: string,
declaration: PluginManagedAgentDeclaration,
agent: Agent | null,
status: PluginManagedAgentResolution["status"],
approvalId?: string | null,
): PluginManagedAgentResolution {
): Promise<PluginManagedAgentResolution> {
return {
pluginKey: options.pluginKey,
resourceKind: "agent",
@@ -349,6 +402,7 @@ export function pluginManagedAgentService(
agent,
status,
approvalId: approvalId ?? null,
defaultDrift: await managedInstructionDefaultDrift(companyId, agent, declaration),
};
}
@@ -0,0 +1,279 @@
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
pluginManagedResources,
} from "@paperclipai/db";
import { normalizeAgentUrlKey } from "@paperclipai/shared";
import type {
CompanySkill,
PaperclipPluginManifestV1,
PluginManagedSkillDeclaration,
PluginManagedSkillResolution,
} from "@paperclipai/shared";
import { notFound } from "../errors.js";
import { logActivity } from "./activity-log.js";
import { companySkillService } from "./company-skills.js";
const MANAGED_SKILL_RESOURCE_KIND = "skill";
interface PluginManagedSkillServiceOptions {
pluginId: string;
pluginKey: string;
manifest?: PaperclipPluginManifestV1 | null;
}
function pluginKeySlug(pluginKey: string) {
return normalizeAgentUrlKey(pluginKey) ?? "plugin";
}
function canonicalSkillKey(pluginKey: string, skillKey: string) {
return `plugin/${pluginKeySlug(pluginKey)}/${skillKey}`;
}
function yamlString(value: string) {
return JSON.stringify(value);
}
function buildDefaultMarkdown(
pluginKey: string,
declaration: PluginManagedSkillDeclaration,
) {
const description = declaration.description?.trim() || `${declaration.displayName} plugin skill.`;
return [
"---",
`name: ${yamlString(declaration.displayName)}`,
`description: ${yamlString(description)}`,
`key: ${yamlString(canonicalSkillKey(pluginKey, declaration.skillKey))}`,
"---",
"",
`# ${declaration.displayName}`,
"",
description,
"",
].join("\n");
}
function withManagedSkillKey(markdown: string, canonicalKey: string) {
const keyLine = `key: ${yamlString(canonicalKey)}`;
const normalized = markdown.replace(/\r\n/g, "\n");
const frontmatter = /^---\n([\s\S]*?)\n---(\n?)/.exec(normalized);
if (!frontmatter) {
return [
"---",
keyLine,
"---",
"",
normalized,
].join("\n");
}
const currentBody = frontmatter[1] ?? "";
const nextBody = /^key\s*:/m.test(currentBody)
? currentBody.replace(/^key\s*:.*$/m, keyLine)
: [currentBody, keyLine].filter(Boolean).join("\n");
return `---\n${nextBody}\n---${frontmatter[2] ?? ""}${normalized.slice(frontmatter[0].length)}`;
}
function buildPackageFiles(
pluginKey: string,
declaration: PluginManagedSkillDeclaration,
) {
const root = declaration.skillKey;
const canonicalKey = canonicalSkillKey(pluginKey, declaration.skillKey);
const files: Record<string, string> = {
[`${root}/SKILL.md`]: declaration.markdown?.trim()
? withManagedSkillKey(declaration.markdown, canonicalKey)
: buildDefaultMarkdown(pluginKey, declaration),
};
for (const file of declaration.files ?? []) {
files[`${root}/${file.path}`] = file.content;
}
return files;
}
function buildSkillDefaults(
pluginKey: string,
declaration: PluginManagedSkillDeclaration,
) {
return {
skillKey: declaration.skillKey,
displayName: declaration.displayName,
slug: declaration.slug ?? declaration.skillKey,
description: declaration.description ?? null,
canonicalKey: canonicalSkillKey(pluginKey, declaration.skillKey),
files: [
"SKILL.md",
...(declaration.files ?? []).map((file) => file.path),
],
};
}
function resolution(
pluginKey: string,
companyId: string,
declaration: PluginManagedSkillDeclaration,
skill: CompanySkill | null,
status: PluginManagedSkillResolution["status"],
): PluginManagedSkillResolution {
return {
pluginKey,
resourceKind: "skill",
resourceKey: declaration.skillKey,
companyId,
skillId: skill?.id ?? null,
skill,
status,
};
}
export function pluginManagedSkillService(
db: Db,
options: PluginManagedSkillServiceOptions,
) {
const skills = companySkillService(db);
function declarationFor(skillKey: string) {
const declaration = options.manifest?.skills?.find((skill) => skill.skillKey === skillKey);
if (!declaration) {
throw notFound(`Managed skill declaration not found: ${skillKey}`);
}
return declaration;
}
async function getBinding(companyId: string, skillKey: string) {
return db
.select()
.from(pluginManagedResources)
.where(and(
eq(pluginManagedResources.companyId, companyId),
eq(pluginManagedResources.pluginId, options.pluginId),
eq(pluginManagedResources.resourceKind, MANAGED_SKILL_RESOURCE_KIND),
eq(pluginManagedResources.resourceKey, skillKey),
))
.then((rows) => rows[0] ?? null);
}
async function upsertBinding(
companyId: string,
declaration: PluginManagedSkillDeclaration,
skillId: string,
) {
const defaultsJson = buildSkillDefaults(options.pluginKey, declaration);
const existing = await getBinding(companyId, declaration.skillKey);
if (existing) {
return db
.update(pluginManagedResources)
.set({
resourceId: skillId,
defaultsJson,
updatedAt: new Date(),
})
.where(eq(pluginManagedResources.id, existing.id))
.returning()
.then((rows) => rows[0]);
}
return db
.insert(pluginManagedResources)
.values({
companyId,
pluginId: options.pluginId,
pluginKey: options.pluginKey,
resourceKind: MANAGED_SKILL_RESOURCE_KIND,
resourceKey: declaration.skillKey,
resourceId: skillId,
defaultsJson,
})
.returning()
.then((rows) => rows[0]);
}
async function getSkill(companyId: string, skillId: string) {
return skills.getById(companyId, skillId);
}
async function importDeclaredSkill(
companyId: string,
declaration: PluginManagedSkillDeclaration,
mode: "reconcile" | "reset",
) {
const beforeByKey = mode === "reconcile"
? await skills.getByKey(companyId, canonicalSkillKey(options.pluginKey, declaration.skillKey))
: null;
const results = await skills.importPackageFiles(
companyId,
buildPackageFiles(options.pluginKey, declaration),
{ onConflict: "replace" },
);
const imported = results.find((result) =>
result.skill.key === canonicalSkillKey(options.pluginKey, declaration.skillKey)
|| result.originalSlug === (declaration.slug ?? declaration.skillKey)
|| result.originalSlug === declaration.skillKey
)?.skill ?? results[0]?.skill ?? null;
if (!imported) {
throw notFound(`Managed skill was not imported: ${declaration.skillKey}`);
}
await upsertBinding(companyId, declaration, imported.id);
const status: PluginManagedSkillResolution["status"] = mode === "reset"
? "reset"
: beforeByKey
? "relinked"
: "created";
return { skill: imported, status };
}
async function get(skillKey: string, companyId: string) {
const declaration = declarationFor(skillKey);
const binding = await getBinding(companyId, skillKey);
if (!binding) return resolution(options.pluginKey, companyId, declaration, null, "missing");
const skill = await getSkill(companyId, binding.resourceId);
return resolution(options.pluginKey, companyId, declaration, skill, skill ? "resolved" : "missing");
}
async function reconcile(skillKey: string, companyId: string) {
const declaration = declarationFor(skillKey);
const current = await get(skillKey, companyId);
if (current.skill) {
await upsertBinding(companyId, declaration, current.skill.id);
return current;
}
const imported = await importDeclaredSkill(companyId, declaration, "reconcile");
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_skill.reconciled",
entityType: "company_skill",
entityId: imported.skill.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.skillKey,
status: imported.status,
},
});
return resolution(options.pluginKey, companyId, declaration, imported.skill, imported.status);
}
async function reset(skillKey: string, companyId: string) {
const declaration = declarationFor(skillKey);
const imported = await importDeclaredSkill(companyId, declaration, "reset");
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_skill.reset",
entityType: "company_skill",
entityId: imported.skill.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.skillKey,
},
});
return resolution(options.pluginKey, companyId, declaration, imported.skill, "reset");
}
return {
get,
reconcile,
reset,
};
}