Fix runtime skill injection across adapters
This commit is contained in:
@@ -16,6 +16,7 @@ describe("claude local skill sync", () => {
|
||||
expect(snapshot.mode).toBe("ephemeral");
|
||||
expect(snapshot.supported).toBe(true);
|
||||
expect(snapshot.desiredSkills).toContain("paperclip");
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
|
||||
});
|
||||
|
||||
@@ -31,8 +32,8 @@ describe("claude local skill sync", () => {
|
||||
},
|
||||
}, ["paperclip"]);
|
||||
|
||||
expect(snapshot.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(snapshot.desiredSkills).toContain("paperclip");
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("available");
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("configured");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,8 @@ describe("codex local skill sync", () => {
|
||||
|
||||
const before = await listCodexSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(before.desiredSkills).toContain("paperclip");
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
||||
|
||||
const after = await syncCodexSkills(ctx, ["paperclip"]);
|
||||
@@ -47,7 +48,7 @@ describe("codex local skill sync", () => {
|
||||
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
@@ -80,8 +81,8 @@ describe("codex local skill sync", () => {
|
||||
} as const;
|
||||
|
||||
const after = await syncCodexSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toThrow();
|
||||
expect(after.desiredSkills).toContain("paperclip");
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,13 @@ type CapturePayload = {
|
||||
paperclipEnvKeys: string[];
|
||||
};
|
||||
|
||||
async function createSkillDir(root: string, name: string) {
|
||||
const skillDir = path.join(root, name);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
describe("cursor execute", () => {
|
||||
it("injects paperclip env vars and prompt note by default", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-"));
|
||||
@@ -179,4 +186,77 @@ describe("cursor execute", () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("injects company-library runtime skills into the Cursor skills home before execution", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-runtime-skill-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "agent");
|
||||
const runtimeSkillsRoot = path.join(root, "runtime-skills");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCursorCommand(commandPath);
|
||||
|
||||
const paperclipDir = await createSkillDir(runtimeSkillsRoot, "paperclip");
|
||||
const asciiHeartDir = await createSkillDir(runtimeSkillsRoot, "ascii-heart");
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-3",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Cursor Coder",
|
||||
adapterType: "cursor",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "auto",
|
||||
paperclipRuntimeSkills: [
|
||||
{
|
||||
name: "paperclip",
|
||||
source: paperclipDir,
|
||||
required: true,
|
||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
||||
},
|
||||
{
|
||||
name: "ascii-heart",
|
||||
source: asciiHeartDir,
|
||||
},
|
||||
],
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["ascii-heart"],
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect((await fs.lstat(path.join(root, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(path.join(root, ".cursor", "skills", "ascii-heart"))).toBe(
|
||||
await fs.realpath(asciiHeartDir),
|
||||
);
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,13 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function createSkillDir(root: string, name: string) {
|
||||
const skillDir = path.join(root, name);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
describe("cursor local skill sync", () => {
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
@@ -39,7 +46,8 @@ describe("cursor local skill sync", () => {
|
||||
|
||||
const before = await listCursorSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(before.desiredSkills).toContain("paperclip");
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
||||
|
||||
const after = await syncCursorSkills(ctx, ["paperclip"]);
|
||||
@@ -47,7 +55,53 @@ describe("cursor local skill sync", () => {
|
||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
it("recognizes company-library runtime skills supplied outside the bundled Paperclip directory", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-runtime-skills-home-");
|
||||
const runtimeSkills = await makeTempDir("paperclip-cursor-runtime-skills-src-");
|
||||
cleanupDirs.add(home);
|
||||
cleanupDirs.add(runtimeSkills);
|
||||
|
||||
const paperclipDir = await createSkillDir(runtimeSkills, "paperclip");
|
||||
const asciiHeartDir = await createSkillDir(runtimeSkills, "ascii-heart");
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-3",
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipRuntimeSkills: [
|
||||
{
|
||||
name: "paperclip",
|
||||
source: paperclipDir,
|
||||
required: true,
|
||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
||||
},
|
||||
{
|
||||
name: "ascii-heart",
|
||||
source: asciiHeartDir,
|
||||
},
|
||||
],
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["ascii-heart"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listCursorSkills(ctx);
|
||||
expect(before.warnings).toEqual([]);
|
||||
expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]);
|
||||
expect(before.entries.find((entry) => entry.name === "ascii-heart")?.state).toBe("missing");
|
||||
|
||||
const after = await syncCursorSkills(ctx, ["ascii-heart"]);
|
||||
expect(after.warnings).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "ascii-heart")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
@@ -80,8 +134,8 @@ describe("cursor local skill sync", () => {
|
||||
} as const;
|
||||
|
||||
const after = await syncCursorSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).rejects.toThrow();
|
||||
expect(after.desiredSkills).toContain("paperclip");
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,8 @@ describe("gemini local skill sync", () => {
|
||||
|
||||
const before = await listGeminiSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(before.desiredSkills).toContain("paperclip");
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
||||
|
||||
const after = await syncGeminiSkills(ctx, ["paperclip"]);
|
||||
@@ -47,7 +48,7 @@ describe("gemini local skill sync", () => {
|
||||
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-gemini-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
@@ -80,8 +81,8 @@ describe("gemini local skill sync", () => {
|
||||
} as const;
|
||||
|
||||
const after = await syncGeminiSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).rejects.toThrow();
|
||||
expect(after.desiredSkills).toContain("paperclip");
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,8 @@ describe("opencode local skill sync", () => {
|
||||
const before = await listOpenCodeSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
|
||||
expect(before.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(before.desiredSkills).toContain("paperclip");
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
||||
|
||||
const after = await syncOpenCodeSkills(ctx, ["paperclip"]);
|
||||
@@ -48,7 +49,7 @@ describe("opencode local skill sync", () => {
|
||||
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-opencode-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
@@ -81,8 +82,8 @@ describe("opencode local skill sync", () => {
|
||||
} as const;
|
||||
|
||||
const after = await syncOpenCodeSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).rejects.toThrow();
|
||||
expect(after.desiredSkills).toContain("paperclip");
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,8 @@ describe("pi local skill sync", () => {
|
||||
|
||||
const before = await listPiSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(before.desiredSkills).toContain("paperclip");
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
||||
|
||||
const after = await syncPiSkills(ctx, ["paperclip"]);
|
||||
@@ -47,7 +48,7 @@ describe("pi local skill sync", () => {
|
||||
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-pi-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
@@ -80,8 +81,8 @@ describe("pi local skill sync", () => {
|
||||
} as const;
|
||||
|
||||
const after = await syncPiSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).rejects.toThrow();
|
||||
expect(after.desiredSkills).toContain("paperclip");
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
agentService,
|
||||
accessService,
|
||||
approvalService,
|
||||
companySkillService,
|
||||
heartbeatService,
|
||||
issueApprovalService,
|
||||
issueService,
|
||||
@@ -66,6 +67,7 @@ export function agentRoutes(db: Db) {
|
||||
const heartbeat = heartbeatService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
const companySkills = companySkillService(db);
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
|
||||
function canCreateAgents(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
|
||||
@@ -354,6 +356,14 @@ export function agentRoutes(db: Db) {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildRuntimeSkillConfig(companyId: string, config: Record<string, unknown>) {
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId);
|
||||
return {
|
||||
...config,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
}
|
||||
|
||||
function redactForRestrictedAgentView(agent: Awaited<ReturnType<typeof svc.getById>>) {
|
||||
if (!agent) return null;
|
||||
return {
|
||||
@@ -493,7 +503,9 @@ export function agentRoutes(db: Db) {
|
||||
const preference = readPaperclipSkillSyncPreference(
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
);
|
||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, preference.desiredSkills));
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.name);
|
||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills]))));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -501,11 +513,12 @@ export function agentRoutes(db: Db) {
|
||||
agent.companyId,
|
||||
agent.adapterConfig,
|
||||
);
|
||||
const runtimeSkillConfig = await buildRuntimeSkillConfig(agent.companyId, runtimeConfig);
|
||||
const snapshot = await adapter.listSkills({
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
adapterType: agent.adapterType,
|
||||
config: runtimeConfig,
|
||||
config: runtimeSkillConfig,
|
||||
});
|
||||
res.json(snapshot);
|
||||
});
|
||||
@@ -522,13 +535,16 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
await assertCanUpdateAgent(req, agent);
|
||||
|
||||
const desiredSkills = Array.from(
|
||||
const requestedSkills = Array.from(
|
||||
new Set(
|
||||
(req.body.desiredSkills as string[])
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.name);
|
||||
const desiredSkills = Array.from(new Set([...requiredSkills, ...requestedSkills]));
|
||||
const nextAdapterConfig = writePaperclipSkillSyncPreference(
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
desiredSkills,
|
||||
@@ -553,19 +569,23 @@ export function agentRoutes(db: Db) {
|
||||
updated.companyId,
|
||||
updated.adapterConfig,
|
||||
);
|
||||
const runtimeSkillConfig = {
|
||||
...runtimeConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
const snapshot = adapter?.syncSkills
|
||||
? await adapter.syncSkills({
|
||||
agentId: updated.id,
|
||||
companyId: updated.companyId,
|
||||
adapterType: updated.adapterType,
|
||||
config: runtimeConfig,
|
||||
config: runtimeSkillConfig,
|
||||
}, desiredSkills)
|
||||
: adapter?.listSkills
|
||||
? await adapter.listSkills({
|
||||
agentId: updated.id,
|
||||
companyId: updated.companyId,
|
||||
adapterType: updated.adapterType,
|
||||
config: runtimeConfig,
|
||||
config: runtimeSkillConfig,
|
||||
})
|
||||
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySkills } from "@paperclipai/db";
|
||||
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
|
||||
import type {
|
||||
CompanySkill,
|
||||
CompanySkillCreateRequest,
|
||||
@@ -20,7 +22,6 @@ import type {
|
||||
CompanySkillUsageAgent,
|
||||
} from "@paperclipai/shared";
|
||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { findServerAdapter } from "../adapters/index.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
@@ -959,11 +960,15 @@ export function companySkillService(db: Db) {
|
||||
agent.companyId,
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
);
|
||||
const runtimeSkillEntries = await listRuntimeSkillEntries(agent.companyId);
|
||||
const snapshot = await adapter.listSkills({
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
adapterType: agent.adapterType,
|
||||
config: runtimeConfig,
|
||||
config: {
|
||||
...runtimeConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
},
|
||||
});
|
||||
actualState = snapshot.entries.find((entry) => entry.name === slug)?.state
|
||||
?? (snapshot.supported ? "missing" : "unsupported");
|
||||
@@ -1219,6 +1224,56 @@ export function companySkillService(db: Db) {
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
async function materializeRuntimeSkillFiles(companyId: string, skill: CompanySkill) {
|
||||
const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__");
|
||||
const skillDir = path.resolve(runtimeRoot, skill.slug);
|
||||
await fs.rm(skillDir, { recursive: true, force: true });
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
|
||||
for (const entry of skill.fileInventory) {
|
||||
const detail = await readFile(companyId, skill.id, entry.path).catch(() => null);
|
||||
if (!detail) continue;
|
||||
const targetPath = path.resolve(skillDir, entry.path);
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, detail.content, "utf8");
|
||||
}
|
||||
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
async function listRuntimeSkillEntries(companyId: string): Promise<PaperclipSkillEntry[]> {
|
||||
await ensureBundledSkills(companyId);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(companySkills)
|
||||
.where(eq(companySkills.companyId, companyId))
|
||||
.orderBy(asc(companySkills.name), asc(companySkills.slug));
|
||||
|
||||
const out: PaperclipSkillEntry[] = [];
|
||||
for (const row of rows) {
|
||||
const skill = toCompanySkill(row);
|
||||
const sourceKind = asString(getSkillMeta(skill).sourceKind);
|
||||
let source = normalizeSkillDirectory(skill);
|
||||
if (!source) {
|
||||
source = await materializeRuntimeSkillFiles(companyId, skill).catch(() => null);
|
||||
}
|
||||
if (!source) continue;
|
||||
|
||||
const required = sourceKind === "paperclip_bundled";
|
||||
out.push({
|
||||
name: skill.slug,
|
||||
source,
|
||||
required,
|
||||
requiredReason: required
|
||||
? "Bundled Paperclip skills are always available for local adapters."
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((left, right) => left.name.localeCompare(right.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function importPackageFiles(companyId: string, files: Record<string, string>): Promise<CompanySkill[]> {
|
||||
await ensureBundledSkills(companyId);
|
||||
const normalizedFiles = normalizePackageFileMap(files);
|
||||
@@ -1330,5 +1385,6 @@ export function companySkillService(db: Db) {
|
||||
importFromSource,
|
||||
importPackageFiles,
|
||||
installUpdate,
|
||||
listRuntimeSkillEntries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||
import { costService } from "./costs.js";
|
||||
import { companySkillService } from "./company-skills.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||
@@ -555,6 +556,7 @@ function resolveNextSessionState(input: {
|
||||
export function heartbeatService(db: Db) {
|
||||
const runLogStore = getRunLogStore();
|
||||
const secretsSvc = secretService(db);
|
||||
const companySkills = companySkillService(db);
|
||||
const issuesSvc = issueService(db);
|
||||
const activeRunExecutions = new Set<string>();
|
||||
|
||||
@@ -1463,6 +1465,11 @@ export function heartbeatService(db: Db) {
|
||||
agent.companyId,
|
||||
mergedConfig,
|
||||
);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||
const runtimeConfig = {
|
||||
...resolvedConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
const issueRef = issueId
|
||||
? await db
|
||||
.select({
|
||||
@@ -1761,7 +1768,7 @@ export function heartbeatService(db: Db) {
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
config: resolvedConfig,
|
||||
config: runtimeConfig,
|
||||
context,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
|
||||
Reference in New Issue
Block a user