mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
477 lines
17 KiB
TypeScript
477 lines
17 KiB
TypeScript
import {
|
|
definePlugin,
|
|
runWorker,
|
|
type Issue,
|
|
type PluginBridgeRequestContext,
|
|
type PluginContext,
|
|
type PluginIssueRelationSummary,
|
|
type ToolResult,
|
|
} from "@paperclipai/plugin-sdk";
|
|
import {
|
|
briefPreferencesSchema,
|
|
listBriefCardsInputSchema,
|
|
pinBriefCardInputSchema,
|
|
updateBriefPreferencesInputSchema,
|
|
} from "./contracts.js";
|
|
import {
|
|
buildDeterministicBriefCard,
|
|
type BriefsApprovalInput,
|
|
type BriefsCommentInput,
|
|
type BriefsDocumentInput,
|
|
type BriefsIssueInput,
|
|
type BriefsRelationInput,
|
|
type BriefsRunInput,
|
|
type BriefsSourceBundle,
|
|
type BriefsWorkProductInput,
|
|
type DeterministicBriefOptions,
|
|
} from "./deterministic-card-service.js";
|
|
import {
|
|
BRIEFING_ANALYST_AGENT_KEY,
|
|
BRIEFS_MANAGED_ROUTINE_KEYS,
|
|
BRIEFS_MANAGED_SKILL_KEYS,
|
|
BRIEFS_PROJECT_KEY,
|
|
MANUAL_REFRESH_ROUTINE_KEY,
|
|
} from "./manifest.js";
|
|
import {
|
|
hardenGeneratedSummaryOptions,
|
|
sanitizeBriefSourceBundle,
|
|
} from "./safety.js";
|
|
import { createBriefsStore } from "./store.js";
|
|
|
|
function objectParam(value: unknown, name: string): Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`${name} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function stringParam(value: unknown, name: string): string {
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
throw new Error(`${name} is required`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function assertTrustedBridgeUserScope(userId: string, request?: PluginBridgeRequestContext): void {
|
|
const actorUserId = request?.actor?.userId;
|
|
if (!actorUserId) {
|
|
throw new Error("Briefs user-scoped UI calls require a signed-in user");
|
|
}
|
|
if (actorUserId !== userId) {
|
|
throw new Error("Briefs user scope mismatch");
|
|
}
|
|
}
|
|
|
|
function numberParam(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : null;
|
|
}
|
|
|
|
function oneParagraph(value: string): string {
|
|
return value.replace(/\s+/g, " ").trim().slice(0, 260);
|
|
}
|
|
|
|
function summaryFailureReason(value: unknown): DeterministicBriefOptions["summaryFailureReason"] {
|
|
return value === "truncation_failed" || value === "budget_capped" || value === "safety_block" || value === "model_error"
|
|
? value
|
|
: "model_error";
|
|
}
|
|
|
|
function summaryOptions(input: Record<string, unknown>): DeterministicBriefOptions {
|
|
if (input.budgetCapped === true) {
|
|
return {
|
|
summaryStatus: "fallback",
|
|
summaryFailureReason: "budget_capped",
|
|
};
|
|
}
|
|
const summary = typeof input.summary === "string" ? oneParagraph(input.summary) : "";
|
|
if (!summary) {
|
|
return {
|
|
summaryStatus: "fallback",
|
|
summaryFailureReason: summaryFailureReason(input.summaryFailureReason),
|
|
};
|
|
}
|
|
return {
|
|
summaryStatus: "ok",
|
|
summaryParagraph: summary,
|
|
summaryModel: typeof input.summaryModel === "string" && input.summaryModel.trim() ? input.summaryModel.trim() : "cheap-model",
|
|
summaryTokensIn: numberParam(input.summaryTokensIn),
|
|
summaryTokensOut: numberParam(input.summaryTokensOut),
|
|
generatedByAgentId: typeof input.generatedByAgentId === "string" ? input.generatedByAgentId : null,
|
|
generatedByRunId: typeof input.generatedByRunId === "string" ? input.generatedByRunId : null,
|
|
allowGeneratedSummary: input.allowGeneratedSummary === true,
|
|
};
|
|
}
|
|
|
|
async function saveBriefCard(
|
|
ctx: PluginContext,
|
|
store: ReturnType<typeof createBriefsStore>,
|
|
bundle: BriefsSourceBundle,
|
|
options: DeterministicBriefOptions,
|
|
) {
|
|
const safeBundle = sanitizeBriefSourceBundle(bundle);
|
|
const safeOptions = hardenGeneratedSummaryOptions(safeBundle, options);
|
|
const card = await store.saveCard(buildDeterministicBriefCard(safeBundle, safeOptions));
|
|
await ctx.activity.log({
|
|
companyId: card.companyId,
|
|
message: `Updated briefing card "${card.title}"`,
|
|
entityType: "plugin:briefs:card",
|
|
entityId: card.id,
|
|
metadata: {
|
|
userId: card.userId,
|
|
state: card.state,
|
|
rootIssueId: card.rootIssueId,
|
|
summaryStatus: card.summaryStatus,
|
|
summaryModel: card.snapshot.summaryModel,
|
|
summaryTokensIn: card.snapshot.summaryTokensIn,
|
|
summaryTokensOut: card.snapshot.summaryTokensOut,
|
|
summaryFailureReason: card.snapshot.summaryFailureReason,
|
|
},
|
|
});
|
|
return card;
|
|
}
|
|
|
|
function issueToBriefIssue(issue: Issue): BriefsIssueInput {
|
|
return {
|
|
id: issue.id,
|
|
companyId: issue.companyId,
|
|
parentId: issue.parentId,
|
|
title: issue.title,
|
|
identifier: issue.identifier,
|
|
status: issue.status,
|
|
priority: issue.priority,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
createdByUserId: issue.createdByUserId,
|
|
activeRecoveryAction: issue.activeRecoveryAction ?? null,
|
|
blockerAttention: issue.blockerAttention ? { reason: issue.blockerAttention.reason } : null,
|
|
executionState: issue.executionState ?? null,
|
|
createdAt: issue.createdAt,
|
|
updatedAt: issue.updatedAt,
|
|
completedAt: issue.completedAt,
|
|
};
|
|
}
|
|
|
|
function relationToBriefRelation(summary: PluginIssueRelationSummary | undefined): BriefsRelationInput {
|
|
return {
|
|
blockedBy: (summary?.blockedBy ?? []).map((blocker) => ({
|
|
id: blocker.id,
|
|
identifier: blocker.identifier,
|
|
title: blocker.title,
|
|
status: blocker.status,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function runToBriefRun(run: BriefsRunInput | Awaited<ReturnType<PluginContext["issues"]["summaries"]["getOrchestration"]>>["runs"][number]): BriefsRunInput {
|
|
return {
|
|
id: run.id,
|
|
companyId: "companyId" in run ? run.companyId : "",
|
|
issueId: run.issueId,
|
|
agentId: "agentId" in run ? run.agentId : null,
|
|
status: run.status,
|
|
error: run.error ?? null,
|
|
startedAt: run.startedAt ?? null,
|
|
finishedAt: run.finishedAt ?? null,
|
|
createdAt: run.createdAt,
|
|
};
|
|
}
|
|
|
|
async function buildBundleFromIssueTree(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
userId: string;
|
|
rootIssueId: string;
|
|
}): Promise<BriefsSourceBundle> {
|
|
const subtree = await ctx.issues.getSubtree(input.rootIssueId, input.companyId, {
|
|
includeRoot: true,
|
|
includeRelations: true,
|
|
includeDocuments: true,
|
|
includeActiveRuns: true,
|
|
includeAssignees: true,
|
|
});
|
|
const orchestration = await ctx.issues.summaries.getOrchestration({
|
|
issueId: input.rootIssueId,
|
|
companyId: input.companyId,
|
|
includeSubtree: true,
|
|
billingCode: "plugin-briefs:refresh",
|
|
});
|
|
const comments: BriefsCommentInput[] = [];
|
|
const documents: BriefsDocumentInput[] = [];
|
|
const workProducts: BriefsWorkProductInput[] = [];
|
|
for (const issue of subtree.issues) {
|
|
const issueComments = await ctx.issues.listComments(issue.id, input.companyId);
|
|
comments.push(...issueComments.map((comment) => ({
|
|
id: comment.id,
|
|
companyId: comment.companyId,
|
|
issueId: comment.issueId,
|
|
authorUserId: comment.authorUserId,
|
|
body: comment.body,
|
|
createdAt: comment.createdAt,
|
|
})));
|
|
documents.push(...(subtree.documents?.[issue.id] ?? []).map((document) => ({
|
|
id: document.id,
|
|
companyId: document.companyId,
|
|
issueId: document.issueId,
|
|
key: document.key,
|
|
title: document.title,
|
|
createdByUserId: document.createdByUserId,
|
|
updatedByUserId: document.updatedByUserId,
|
|
updatedAt: document.updatedAt,
|
|
})));
|
|
workProducts.push(...(issue.workProducts ?? []).map((product) => ({
|
|
id: product.id,
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
title: product.title,
|
|
status: product.status,
|
|
createdAt: product.createdAt,
|
|
updatedAt: product.updatedAt,
|
|
})));
|
|
}
|
|
|
|
const activeRuns = Object.fromEntries(
|
|
Object.entries(subtree.activeRuns ?? {}).map(([issueId, runs]) => [
|
|
issueId,
|
|
runs.map((run) => ({ ...runToBriefRun(run), companyId: input.companyId })),
|
|
]),
|
|
);
|
|
const root = subtree.issues.find((issue) => issue.id === input.rootIssueId) ?? subtree.issues[0];
|
|
if (!root) throw new Error(`Issue tree is empty: ${input.rootIssueId}`);
|
|
|
|
return {
|
|
companyId: input.companyId,
|
|
userId: input.userId,
|
|
rootIssueId: input.rootIssueId,
|
|
title: root.title,
|
|
groupingDescription: `Issue tree rooted at ${root.identifier ?? root.id}: ${root.title}`,
|
|
issues: subtree.issues.map(issueToBriefIssue),
|
|
relations: Object.fromEntries(subtree.issues.map((issue) => [
|
|
issue.id,
|
|
relationToBriefRelation(subtree.relations?.[issue.id]),
|
|
])),
|
|
activeRuns,
|
|
runs: orchestration.runs.map((run) => ({ ...runToBriefRun(run), companyId: input.companyId })),
|
|
comments,
|
|
documents,
|
|
approvals: orchestration.approvals.map((approval): BriefsApprovalInput => ({
|
|
id: approval.id,
|
|
companyId: input.companyId,
|
|
issueId: approval.issueId,
|
|
status: approval.status,
|
|
reviewerUserId: null,
|
|
decidedByUserId: approval.decidedByUserId,
|
|
createdAt: approval.createdAt,
|
|
decidedAt: approval.decidedAt,
|
|
})),
|
|
workProducts,
|
|
relevantAgentIds: Object.keys(subtree.assignees ?? {}),
|
|
};
|
|
}
|
|
|
|
async function refreshIssueTree(
|
|
ctx: PluginContext,
|
|
store: ReturnType<typeof createBriefsStore>,
|
|
rawInput: Record<string, unknown>,
|
|
) {
|
|
const companyId = stringParam(rawInput.companyId, "companyId");
|
|
const userId = stringParam(rawInput.userId, "userId");
|
|
const rootIssueId = stringParam(rawInput.rootIssueId, "rootIssueId");
|
|
const card = await saveBriefCard(
|
|
ctx,
|
|
store,
|
|
await buildBundleFromIssueTree(ctx, { companyId, userId, rootIssueId }),
|
|
{
|
|
...summaryOptions(rawInput),
|
|
generatedByAgentId: typeof rawInput.generatedByAgentId === "string" ? rawInput.generatedByAgentId : null,
|
|
generatedByRunId: typeof rawInput.generatedByRunId === "string" ? rawInput.generatedByRunId : null,
|
|
},
|
|
);
|
|
return { card };
|
|
}
|
|
|
|
const plugin = definePlugin({
|
|
async setup(ctx) {
|
|
const store = createBriefsStore(ctx.db);
|
|
|
|
ctx.data.register("cards", async (params, request) => {
|
|
const input = listBriefCardsInputSchema.parse(params);
|
|
assertTrustedBridgeUserScope(input.userId, request);
|
|
return {
|
|
cards: await store.listCards(input),
|
|
};
|
|
});
|
|
|
|
ctx.data.register("preferences", async (params, request) => {
|
|
const input = objectParam(params, "params");
|
|
const companyId = typeof input.companyId === "string" ? input.companyId : "";
|
|
const userId = typeof input.userId === "string" ? input.userId : "";
|
|
if (!companyId || !userId) {
|
|
throw new Error("companyId and userId are required");
|
|
}
|
|
assertTrustedBridgeUserScope(userId, request);
|
|
return {
|
|
preferences: await store.loadPreferences({ companyId, userId }),
|
|
};
|
|
});
|
|
|
|
ctx.data.register("page", async (params, request) => {
|
|
const input = objectParam(params, "params");
|
|
const companyId = typeof input.companyId === "string" ? input.companyId : "";
|
|
const userId = typeof input.userId === "string" ? input.userId : "";
|
|
const includeHidden = Boolean(input.includeHidden);
|
|
if (!companyId || !userId) {
|
|
throw new Error("companyId and userId are required");
|
|
}
|
|
assertTrustedBridgeUserScope(userId, request);
|
|
const [cards, preferences] = await Promise.all([
|
|
store.listCards({ companyId, userId, includeHidden }),
|
|
store.loadPreferences({ companyId, userId }),
|
|
]);
|
|
return {
|
|
cards,
|
|
preferences,
|
|
fetchedAt: new Date().toISOString(),
|
|
};
|
|
});
|
|
|
|
ctx.actions.register("save-deterministic-card", async (params) => {
|
|
const input = objectParam(params, "params");
|
|
const bundle = objectParam(input.bundle, "bundle") as BriefsSourceBundle;
|
|
const options = (input.options && typeof input.options === "object" ? input.options : {}) as DeterministicBriefOptions;
|
|
const card = await saveBriefCard(ctx, store, bundle, options);
|
|
return { card };
|
|
});
|
|
|
|
ctx.actions.register("refresh-issue-tree", async (params) => {
|
|
return refreshIssueTree(ctx, store, objectParam(params, "params"));
|
|
});
|
|
|
|
ctx.actions.register("reconcile-managed-agent", async (params) => {
|
|
return ctx.agents.managed.reconcile(BRIEFING_ANALYST_AGENT_KEY, stringParam(params.companyId, "companyId"));
|
|
});
|
|
|
|
ctx.actions.register("reconcile-managed-project", async (params) => {
|
|
return ctx.projects.managed.reconcile(BRIEFS_PROJECT_KEY, stringParam(params.companyId, "companyId"));
|
|
});
|
|
|
|
ctx.actions.register("reconcile-managed-skills", async (params) => {
|
|
const companyId = stringParam(params.companyId, "companyId");
|
|
return {
|
|
managedSkills: await Promise.all(
|
|
BRIEFS_MANAGED_SKILL_KEYS.map((skillKey) => ctx.skills.managed.reconcile(skillKey, companyId)),
|
|
),
|
|
};
|
|
});
|
|
|
|
ctx.actions.register("reconcile-managed-routines", async (params) => {
|
|
const companyId = stringParam(params.companyId, "companyId");
|
|
return {
|
|
managedRoutines: await Promise.all(
|
|
BRIEFS_MANAGED_ROUTINE_KEYS.map((routineKey) => ctx.routines.managed.reconcile(routineKey, companyId)),
|
|
),
|
|
};
|
|
});
|
|
|
|
ctx.actions.register("reconcile-managed-resources", async (params) => {
|
|
const companyId = stringParam(params.companyId, "companyId");
|
|
const managedProject = await ctx.projects.managed.reconcile(BRIEFS_PROJECT_KEY, companyId);
|
|
const managedSkills = await Promise.all(
|
|
BRIEFS_MANAGED_SKILL_KEYS.map((skillKey) => ctx.skills.managed.reconcile(skillKey, companyId)),
|
|
);
|
|
const managedAgent = await ctx.agents.managed.reconcile(BRIEFING_ANALYST_AGENT_KEY, companyId);
|
|
const managedRoutines = await Promise.all(
|
|
BRIEFS_MANAGED_ROUTINE_KEYS.map((routineKey) => ctx.routines.managed.reconcile(routineKey, companyId)),
|
|
);
|
|
return { managedProject, managedSkills, managedAgent, managedRoutines };
|
|
});
|
|
|
|
ctx.actions.register("run-manual-refresh-routine", async (params) => {
|
|
return ctx.routines.managed.run(MANUAL_REFRESH_ROUTINE_KEY, stringParam(params.companyId, "companyId"));
|
|
});
|
|
|
|
ctx.actions.register("pin-card", async (params, request) => {
|
|
const input = pinBriefCardInputSchema.parse(params);
|
|
assertTrustedBridgeUserScope(input.userId, request);
|
|
await store.setPinned(input);
|
|
await ctx.activity.log({
|
|
companyId: input.companyId,
|
|
message: `${input.pinned ? "Pinned" : "Unpinned"} briefing card`,
|
|
entityType: "plugin:briefs:card",
|
|
entityId: input.cardId,
|
|
metadata: { userId: input.userId, pinned: input.pinned },
|
|
});
|
|
return { ok: true };
|
|
});
|
|
|
|
ctx.actions.register("update-preferences", async (params, request) => {
|
|
const input = updateBriefPreferencesInputSchema.parse(params);
|
|
assertTrustedBridgeUserScope(input.userId, request);
|
|
const preferences = briefPreferencesSchema.parse(input);
|
|
await store.upsertPreferences(preferences);
|
|
await ctx.activity.log({
|
|
companyId: preferences.companyId,
|
|
message: "Updated briefing preferences",
|
|
entityType: "plugin:briefs:preferences",
|
|
entityId: preferences.userId,
|
|
metadata: preferences,
|
|
});
|
|
return { preferences };
|
|
});
|
|
|
|
ctx.tools.register("briefs_list_cards", {
|
|
displayName: "List Briefing Cards",
|
|
description: "List current Briefing cards for a company/user pair.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "briefs_list_cards")?.parametersSchema ?? { type: "object" },
|
|
}, async (params): Promise<ToolResult> => {
|
|
const input = objectParam(params, "params");
|
|
const cards = await store.listCards(listBriefCardsInputSchema.parse(input));
|
|
return {
|
|
content: cards.length ? cards.map((card) => `${card.slug}: ${card.state} (${card.summaryStatus})`).join("\n") : "No Briefing cards found.",
|
|
data: { cards },
|
|
};
|
|
});
|
|
|
|
ctx.tools.register("briefs_save_card", {
|
|
displayName: "Save Briefing Card",
|
|
description: "Save a deterministic or generated Briefing card from a source bundle.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "briefs_save_card")?.parametersSchema ?? { type: "object" },
|
|
}, async (params, runCtx): Promise<ToolResult> => {
|
|
const input = objectParam(params, "params");
|
|
const bundle = objectParam(input.bundle, "bundle") as BriefsSourceBundle;
|
|
const rawOptions = input.options && typeof input.options === "object" ? input.options as Record<string, unknown> : {};
|
|
const card = await saveBriefCard(ctx, store, bundle, {
|
|
...summaryOptions(rawOptions),
|
|
generatedByAgentId: runCtx.agentId,
|
|
generatedByRunId: runCtx.runId,
|
|
});
|
|
return {
|
|
content: `Saved Briefing card ${card.slug} with ${card.state}/${card.summaryStatus}.`,
|
|
data: { card },
|
|
};
|
|
});
|
|
|
|
ctx.tools.register("briefs_refresh_issue_tree", {
|
|
displayName: "Refresh Briefing Issue Tree",
|
|
description: "Build and save a Briefing card for one Paperclip issue tree using deterministic fallback state.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "briefs_refresh_issue_tree")?.parametersSchema ?? { type: "object" },
|
|
}, async (params, runCtx): Promise<ToolResult> => {
|
|
const input = objectParam(params, "params");
|
|
const result = await refreshIssueTree(ctx, store, {
|
|
...input,
|
|
generatedByAgentId: runCtx.agentId,
|
|
generatedByRunId: runCtx.runId,
|
|
});
|
|
return {
|
|
content: `Refreshed Briefing card ${result.card.slug} with ${result.card.state}/${result.card.summaryStatus}.`,
|
|
data: result,
|
|
};
|
|
});
|
|
},
|
|
|
|
async onHealth() {
|
|
return { status: "ok", message: "Briefs deterministic card service ready" };
|
|
},
|
|
});
|
|
|
|
export default plugin;
|
|
runWorker(plugin, import.meta.url);
|