mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
Add managed plugin skill contracts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user