| import { Buffer } from "node:buffer"; |
| import { spawn } from "node:child_process"; |
| import { randomUUID } from "node:crypto"; |
| import { promises as fs } from "node:fs"; |
| import path from "node:path"; |
| import { |
| PLUGIN_STATE_SCOPE_KINDS, |
| definePlugin, |
| runWorker, |
| type PaperclipPlugin, |
| type PluginContext, |
| type PluginEntityQuery, |
| type PluginEvent, |
| type PluginHealthDiagnostics, |
| type PluginJobContext, |
| type PluginLauncherRegistration, |
| type PluginWebhookInput, |
| type PluginWorkspace, |
| type PluginStateScopeKind, |
| type ScopeKey, |
| type ToolResult, |
| type ToolRunContext, |
| } from "@paperclipai/plugin-sdk"; |
| import type { Goal, Issue } from "@paperclipai/shared"; |
| import { |
| DEFAULT_CONFIG, |
| JOB_KEYS, |
| PLUGIN_ID, |
| RUNTIME_LAUNCHER, |
| SAFE_COMMANDS, |
| STREAM_CHANNELS, |
| TOOL_NAMES, |
| WEBHOOK_KEYS, |
| } from "./constants.js"; |
|
|
| type KitchenSinkConfig = { |
| showSidebarEntry?: boolean; |
| showSidebarPanel?: boolean; |
| showProjectSidebarItem?: boolean; |
| showCommentAnnotation?: boolean; |
| showCommentContextMenuItem?: boolean; |
| enableWorkspaceDemos?: boolean; |
| enableProcessDemos?: boolean; |
| secretRefExample?: string; |
| httpDemoUrl?: string; |
| allowedCommands?: string[]; |
| workspaceScratchFile?: string; |
| }; |
|
|
| type DemoRecord = { |
| id: string; |
| level: "info" | "warning" | "error"; |
| source: string; |
| message: string; |
| createdAt: string; |
| data?: unknown; |
| }; |
|
|
| type ProcessResult = { |
| commandKey: string; |
| cwd: string; |
| code: number | null; |
| stdout: string; |
| stderr: string; |
| startedAt: string; |
| finishedAt: string; |
| }; |
|
|
| const recentRecords: DemoRecord[] = []; |
| const runtimeLaunchers = new Map<string, PluginLauncherRegistration>(); |
| let currentContext: PluginContext | null = null; |
| let lastProcessResult: ProcessResult | null = null; |
|
|
| function isScopeKind(value: unknown): value is PluginStateScopeKind { |
| return typeof value === "string" && PLUGIN_STATE_SCOPE_KINDS.includes(value as PluginStateScopeKind); |
| } |
|
|
| function summarizeError(error: unknown): string { |
| if (error instanceof Error) return error.message; |
| return String(error); |
| } |
|
|
| function pushRecord(record: Omit<DemoRecord, "id" | "createdAt">): DemoRecord { |
| const next: DemoRecord = { |
| id: randomUUID(), |
| createdAt: new Date().toISOString(), |
| ...record, |
| }; |
| recentRecords.unshift(next); |
| if (recentRecords.length > 50) recentRecords.length = 50; |
| return next; |
| } |
|
|
| async function getConfig(ctx: PluginContext): Promise<KitchenSinkConfig> { |
| const config = await ctx.config.get(); |
| return { |
| ...DEFAULT_CONFIG, |
| ...(config as KitchenSinkConfig), |
| }; |
| } |
|
|
| async function writeInstanceState(ctx: PluginContext, stateKey: string, value: unknown): Promise<void> { |
| await ctx.state.set({ scopeKind: "instance", stateKey }, value); |
| } |
|
|
| async function readInstanceState<T = unknown>(ctx: PluginContext, stateKey: string): Promise<T | null> { |
| return await ctx.state.get({ scopeKind: "instance", stateKey }) as T | null; |
| } |
|
|
| async function resolveWorkspace( |
| ctx: PluginContext, |
| companyId: string, |
| projectId: string, |
| workspaceId?: string, |
| ): Promise<PluginWorkspace> { |
| const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); |
| if (workspaces.length === 0) { |
| throw new Error("No workspaces configured for this project"); |
| } |
| if (!workspaceId) return workspaces[0]!; |
| const workspace = workspaces.find((entry) => entry.id === workspaceId); |
| if (!workspace) { |
| throw new Error("Workspace not found"); |
| } |
| return workspace; |
| } |
|
|
| function ensureInsideWorkspace(workspacePath: string, relativePath: string): string { |
| const root = path.resolve(workspacePath); |
| const resolved = path.resolve(root, relativePath); |
| const relative = path.relative(root, resolved); |
| if (relative.startsWith("..") || path.isAbsolute(relative)) { |
| throw new Error("Requested path escapes the selected workspace"); |
| } |
| return resolved; |
| } |
|
|
| function parseJsonish(value: string): unknown { |
| const trimmed = value.trim(); |
| if (trimmed.length === 0) return ""; |
| try { |
| return JSON.parse(trimmed) as unknown; |
| } catch { |
| return value; |
| } |
| } |
|
|
| function parseScopeKey(params: Record<string, unknown>): ScopeKey { |
| const scopeKind = isScopeKind(params.scopeKind) ? params.scopeKind : "instance"; |
| const scopeId = typeof params.scopeId === "string" && params.scopeId.length > 0 ? params.scopeId : undefined; |
| const namespace = typeof params.namespace === "string" && params.namespace.length > 0 ? params.namespace : undefined; |
| const stateKey = typeof params.stateKey === "string" && params.stateKey.length > 0 |
| ? params.stateKey |
| : "demo"; |
| return { scopeKind, scopeId, namespace, stateKey }; |
| } |
|
|
| async function runCuratedCommand( |
| ctx: PluginContext, |
| config: KitchenSinkConfig, |
| companyId: string, |
| projectId: string, |
| workspaceId: string | undefined, |
| commandKey: string, |
| ): Promise<ProcessResult> { |
| if (!config.enableProcessDemos) { |
| throw new Error("Process demos are disabled in plugin settings"); |
| } |
| const allowedCommands = new Set(config.allowedCommands ?? DEFAULT_CONFIG.allowedCommands); |
| if (!allowedCommands.has(commandKey)) { |
| throw new Error(`Command "${commandKey}" is not allowed by plugin settings`); |
| } |
| const definition = SAFE_COMMANDS.find((entry) => entry.key === commandKey); |
| if (!definition) { |
| throw new Error(`Unknown curated command "${commandKey}"`); |
| } |
| const workspace = await resolveWorkspace(ctx, companyId, projectId, workspaceId); |
| const cwd = workspace.path; |
| const startedAt = new Date().toISOString(); |
| const child = spawn(definition.command, definition.args, { |
| cwd, |
| stdio: ["ignore", "pipe", "pipe"], |
| }); |
|
|
| let stdout = ""; |
| let stderr = ""; |
|
|
| child.stdout.on("data", (chunk) => { |
| stdout += String(chunk); |
| }); |
| child.stderr.on("data", (chunk) => { |
| stderr += String(chunk); |
| }); |
|
|
| const code = await new Promise<number | null>((resolve, reject) => { |
| child.on("error", reject); |
| child.on("close", resolve); |
| }); |
|
|
| const result: ProcessResult = { |
| commandKey, |
| cwd, |
| code, |
| stdout: stdout.trim(), |
| stderr: stderr.trim(), |
| startedAt, |
| finishedAt: new Date().toISOString(), |
| }; |
| lastProcessResult = result; |
| pushRecord({ |
| level: code === 0 ? "info" : "warning", |
| source: "process", |
| message: `Ran curated command "${commandKey}"`, |
| data: { code, cwd }, |
| }); |
| await ctx.metrics.write("process.run", 1, { command: commandKey, exit_code: String(code ?? -1) }); |
| return result; |
| } |
|
|
| function getCurrentCompanyId(params: Record<string, unknown>): string { |
| const companyId = typeof params.companyId === "string" ? params.companyId : ""; |
| if (!companyId) { |
| throw new Error("companyId is required"); |
| } |
| return companyId; |
| } |
|
|
| function getListLimit(params: Record<string, unknown>, fallback = 50): number { |
| const value = typeof params.limit === "number" ? params.limit : Number(params.limit ?? fallback); |
| if (!Number.isFinite(value)) return fallback; |
| return Math.max(1, Math.min(200, Math.floor(value))); |
| } |
|
|
| async function listIssuesForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise<Issue[]> { |
| return await ctx.issues.list({ companyId, limit, offset: 0 }); |
| } |
|
|
| async function listGoalsForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise<Goal[]> { |
| return await ctx.goals.list({ companyId, limit, offset: 0 }); |
| } |
|
|
| function recentRecordsSnapshot(): DemoRecord[] { |
| return recentRecords.slice(0, 20); |
| } |
|
|
| function runtimeLaunchersSnapshot(): PluginLauncherRegistration[] { |
| return [...runtimeLaunchers.values()]; |
| } |
|
|
| async function registerDataHandlers(ctx: PluginContext): Promise<void> { |
| ctx.data.register("plugin-config", async () => { |
| return await getConfig(ctx); |
| }); |
|
|
| ctx.data.register("overview", async (params) => { |
| const companyId = typeof params.companyId === "string" ? params.companyId : ""; |
| const config = await getConfig(ctx); |
| const companies = await ctx.companies.list({ limit: 200, offset: 0 }); |
| const projects = companyId ? await ctx.projects.list({ companyId, limit: 200, offset: 0 }) : []; |
| const issues = companyId ? await listIssuesForCompany(ctx, companyId, 200) : []; |
| const goals = companyId ? await listGoalsForCompany(ctx, companyId, 200) : []; |
| const agents = companyId ? await ctx.agents.list({ companyId, limit: 200, offset: 0 }) : []; |
| const lastJob = await readInstanceState(ctx, "last-job-run"); |
| const lastWebhook = await readInstanceState(ctx, "last-webhook"); |
| const entityRecords = await ctx.entities.list({ limit: 10 } satisfies PluginEntityQuery); |
| return { |
| pluginId: PLUGIN_ID, |
| version: ctx.manifest.version, |
| capabilities: ctx.manifest.capabilities, |
| config, |
| runtimeLaunchers: runtimeLaunchersSnapshot(), |
| recentRecords: recentRecordsSnapshot(), |
| counts: { |
| companies: companies.length, |
| projects: projects.length, |
| issues: issues.length, |
| goals: goals.length, |
| agents: agents.length, |
| entities: entityRecords.length, |
| }, |
| lastJob, |
| lastWebhook, |
| lastProcessResult, |
| streamChannels: STREAM_CHANNELS, |
| safeCommands: SAFE_COMMANDS, |
| manifest: { |
| jobs: ctx.manifest.jobs ?? [], |
| webhooks: ctx.manifest.webhooks ?? [], |
| tools: ctx.manifest.tools ?? [], |
| }, |
| }; |
| }); |
|
|
| ctx.data.register("companies", async (params) => { |
| return await ctx.companies.list({ limit: getListLimit(params), offset: 0 }); |
| }); |
|
|
| ctx.data.register("projects", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| return await ctx.projects.list({ companyId, limit: getListLimit(params), offset: 0 }); |
| }); |
|
|
| ctx.data.register("issues", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| return await listIssuesForCompany(ctx, companyId, getListLimit(params)); |
| }); |
|
|
| ctx.data.register("goals", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| return await listGoalsForCompany(ctx, companyId, getListLimit(params)); |
| }); |
|
|
| ctx.data.register("agents", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| return await ctx.agents.list({ companyId, limit: getListLimit(params), offset: 0 }); |
| }); |
|
|
| ctx.data.register("workspaces", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const projectId = typeof params.projectId === "string" ? params.projectId : ""; |
| if (!projectId) return []; |
| return await ctx.projects.listWorkspaces(projectId, companyId); |
| }); |
|
|
| ctx.data.register("state-value", async (params) => { |
| const input = parseScopeKey(params); |
| const value = await ctx.state.get(input); |
| return { |
| scope: input, |
| value, |
| }; |
| }); |
|
|
| ctx.data.register("entities", async (params) => { |
| const query: PluginEntityQuery = { |
| entityType: typeof params.entityType === "string" && params.entityType.length > 0 ? params.entityType : undefined, |
| scopeKind: isScopeKind(params.scopeKind) ? params.scopeKind : undefined, |
| scopeId: typeof params.scopeId === "string" && params.scopeId.length > 0 ? params.scopeId : undefined, |
| limit: typeof params.limit === "number" ? params.limit : 25, |
| offset: 0, |
| }; |
| return await ctx.entities.list(query); |
| }); |
|
|
| ctx.data.register("comment-context", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const issueId = typeof params.issueId === "string" ? params.issueId : ""; |
| const commentId = typeof params.commentId === "string" ? params.commentId : ""; |
| if (!issueId || !commentId) return null; |
| const comments = await ctx.issues.listComments(issueId, companyId); |
| const comment = comments.find((entry) => entry.id === commentId) ?? null; |
| if (!comment) return null; |
| return { |
| commentId: comment.id, |
| issueId, |
| preview: comment.body.slice(0, 160), |
| length: comment.body.length, |
| copiedCount: (await ctx.entities.list({ |
| entityType: "copied-comment", |
| scopeKind: "issue", |
| scopeId: issueId, |
| limit: 100, |
| offset: 0, |
| })).filter((entry) => entry.externalId === commentId).length, |
| }; |
| }); |
|
|
| ctx.data.register("entity-context", async (params) => { |
| const companyId = typeof params.companyId === "string" ? params.companyId : ""; |
| const entityId = typeof params.entityId === "string" ? params.entityId : ""; |
| const entityType = typeof params.entityType === "string" ? params.entityType : ""; |
| if (!companyId || !entityId || !entityType) return null; |
|
|
| if (entityType === "project") { |
| return await ctx.projects.get(entityId, companyId); |
| } |
| if (entityType === "issue") { |
| return await ctx.issues.get(entityId, companyId); |
| } |
| if (entityType === "goal") { |
| return await ctx.goals.get(entityId, companyId); |
| } |
| if (entityType === "agent") { |
| return await ctx.agents.get(entityId, companyId); |
| } |
| return { entityId, entityType, companyId }; |
| }); |
| } |
|
|
| async function registerActionHandlers(ctx: PluginContext): Promise<void> { |
| ctx.actions.register("emit-demo-event", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const message = typeof params.message === "string" && params.message.trim().length > 0 |
| ? params.message.trim() |
| : "Kitchen Sink demo event"; |
| await ctx.events.emit("demo-event", companyId, { |
| message, |
| source: "kitchen-sink", |
| emittedAt: new Date().toISOString(), |
| }); |
| pushRecord({ |
| level: "info", |
| source: "events.emit", |
| message, |
| data: { companyId }, |
| }); |
| await ctx.metrics.write("demo.events.emitted", 1, { source: "manual" }); |
| return { ok: true, message }; |
| }); |
|
|
| ctx.actions.register("write-scoped-state", async (params) => { |
| const input = parseScopeKey(params); |
| const valueInput = typeof params.value === "string" ? params.value : JSON.stringify(params.value ?? ""); |
| const value = parseJsonish(valueInput); |
| await ctx.state.set(input, value); |
| pushRecord({ |
| level: "info", |
| source: "state", |
| message: `Wrote state key ${input.stateKey}`, |
| data: input, |
| }); |
| await ctx.metrics.write("demo.state.write", 1, { scope: input.scopeKind }); |
| return { ok: true, scope: input, value }; |
| }); |
|
|
| ctx.actions.register("delete-scoped-state", async (params) => { |
| const input = parseScopeKey(params); |
| await ctx.state.delete(input); |
| pushRecord({ |
| level: "warning", |
| source: "state", |
| message: `Deleted state key ${input.stateKey}`, |
| data: input, |
| }); |
| return { ok: true, scope: input }; |
| }); |
|
|
| ctx.actions.register("upsert-entity", async (params) => { |
| const title = typeof params.title === "string" && params.title.length > 0 ? params.title : "Kitchen Sink Entity"; |
| const entityType = typeof params.entityType === "string" && params.entityType.length > 0 |
| ? params.entityType |
| : "demo-record"; |
| const scopeKind = isScopeKind(params.scopeKind) |
| ? params.scopeKind |
| : "instance"; |
| const scopeId = typeof params.scopeId === "string" && params.scopeId.length > 0 ? params.scopeId : undefined; |
| const status = typeof params.status === "string" && params.status.length > 0 ? params.status : "active"; |
| const data = typeof params.data === "string" ? parseJsonish(params.data) : params.data; |
| const record = await ctx.entities.upsert({ |
| entityType, |
| scopeKind, |
| scopeId, |
| externalId: typeof params.externalId === "string" && params.externalId.length > 0 ? params.externalId : randomUUID(), |
| title, |
| status, |
| data: typeof data === "object" && data !== null ? data as Record<string, unknown> : { value: data }, |
| }); |
| pushRecord({ |
| level: "info", |
| source: "entities", |
| message: `Upserted entity ${record.entityType}`, |
| data: { id: record.id, scopeKind: record.scopeKind }, |
| }); |
| return record; |
| }); |
|
|
| ctx.actions.register("create-issue", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const title = typeof params.title === "string" && params.title.trim().length > 0 |
| ? params.title.trim() |
| : "Kitchen Sink demo issue"; |
| const description = typeof params.description === "string" ? params.description : undefined; |
| const projectId = typeof params.projectId === "string" && params.projectId.length > 0 ? params.projectId : undefined; |
| const issue = await ctx.issues.create({ companyId, projectId, title, description }); |
| pushRecord({ |
| level: "info", |
| source: "issues.create", |
| message: `Created issue ${issue.title}`, |
| data: { issueId: issue.id }, |
| }); |
| await ctx.activity.log({ |
| companyId, |
| entityType: "issue", |
| entityId: issue.id, |
| message: `Kitchen Sink created issue "${issue.title}"`, |
| metadata: { plugin: PLUGIN_ID }, |
| }); |
| return issue; |
| }); |
|
|
| ctx.actions.register("advance-issue-status", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const issueId = typeof params.issueId === "string" ? params.issueId : ""; |
| const status = typeof params.status === "string" ? params.status : ""; |
| if (!issueId || !status) { |
| throw new Error("issueId and status are required"); |
| } |
| const issue = await ctx.issues.update(issueId, { status: status as Issue["status"] }, companyId); |
| pushRecord({ |
| level: "info", |
| source: "issues.update", |
| message: `Updated issue ${issue.id} to ${issue.status}`, |
| }); |
| return issue; |
| }); |
|
|
| ctx.actions.register("create-goal", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const title = typeof params.title === "string" && params.title.trim().length > 0 |
| ? params.title.trim() |
| : "Kitchen Sink demo goal"; |
| const description = typeof params.description === "string" ? params.description : undefined; |
| const goal = await ctx.goals.create({ companyId, title, description, level: "team", status: "planned" }); |
| pushRecord({ |
| level: "info", |
| source: "goals.create", |
| message: `Created goal ${goal.title}`, |
| data: { goalId: goal.id }, |
| }); |
| return goal; |
| }); |
|
|
| ctx.actions.register("advance-goal-status", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const goalId = typeof params.goalId === "string" ? params.goalId : ""; |
| const status = typeof params.status === "string" ? params.status : ""; |
| if (!goalId || !status) { |
| throw new Error("goalId and status are required"); |
| } |
| const goal = await ctx.goals.update(goalId, { status: status as Goal["status"] }, companyId); |
| pushRecord({ |
| level: "info", |
| source: "goals.update", |
| message: `Updated goal ${goal.id} to ${goal.status}`, |
| }); |
| return goal; |
| }); |
|
|
| ctx.actions.register("write-activity", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const entityType = typeof params.entityType === "string" ? params.entityType : undefined; |
| const entityId = typeof params.entityId === "string" ? params.entityId : undefined; |
| const message = typeof params.message === "string" && params.message.length > 0 |
| ? params.message |
| : "Kitchen Sink wrote an activity entry"; |
| await ctx.activity.log({ |
| companyId, |
| entityType, |
| entityId, |
| message, |
| metadata: { plugin: PLUGIN_ID }, |
| }); |
| pushRecord({ |
| level: "info", |
| source: "activity", |
| message, |
| data: { entityType, entityId }, |
| }); |
| return { ok: true }; |
| }); |
|
|
| ctx.actions.register("write-metric", async (params) => { |
| const value = typeof params.value === "number" ? params.value : Number(params.value ?? 1); |
| const name = typeof params.name === "string" && params.name.length > 0 ? params.name : "manual"; |
| await ctx.metrics.write(`demo.${name}`, Number.isFinite(value) ? value : 1, { source: "manual" }); |
| pushRecord({ |
| level: "info", |
| source: "metrics", |
| message: `Wrote metric demo.${name}`, |
| data: { value }, |
| }); |
| return { ok: true, value }; |
| }); |
|
|
| ctx.actions.register("http-fetch", async (params) => { |
| const config = await getConfig(ctx); |
| const url = typeof params.url === "string" && params.url.length > 0 |
| ? params.url |
| : config.httpDemoUrl || DEFAULT_CONFIG.httpDemoUrl; |
| const started = Date.now(); |
| const response = await ctx.http.fetch(url, { method: "GET" }); |
| const body = await response.text(); |
| const result = { |
| ok: response.ok, |
| status: response.status, |
| url, |
| durationMs: Date.now() - started, |
| body: body.slice(0, 2000), |
| }; |
| pushRecord({ |
| level: response.ok ? "info" : "warning", |
| source: "http", |
| message: `Fetched ${url}`, |
| data: { status: response.status }, |
| }); |
| return result; |
| }); |
|
|
| ctx.actions.register("resolve-secret", async (params) => { |
| const config = await getConfig(ctx); |
| const secretRef = typeof params.secretRef === "string" && params.secretRef.length > 0 |
| ? params.secretRef |
| : config.secretRefExample || ""; |
| if (!secretRef) { |
| throw new Error("No secret reference configured"); |
| } |
| const resolved = await ctx.secrets.resolve(secretRef); |
| pushRecord({ |
| level: "info", |
| source: "secrets", |
| message: `Resolved secret reference ${secretRef}`, |
| }); |
| return { |
| secretRef, |
| resolvedLength: resolved.length, |
| preview: resolved.length > 0 ? `${resolved.slice(0, 2)}***` : "", |
| }; |
| }); |
|
|
| ctx.actions.register("run-process", async (params) => { |
| const config = await getConfig(ctx); |
| const companyId = getCurrentCompanyId(params); |
| const projectId = typeof params.projectId === "string" ? params.projectId : ""; |
| const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined; |
| const commandKey = typeof params.commandKey === "string" ? params.commandKey : "pwd"; |
| if (!projectId) throw new Error("projectId is required"); |
| return await runCuratedCommand(ctx, config, companyId, projectId, workspaceId, commandKey); |
| }); |
|
|
| ctx.actions.register("read-workspace-file", async (params) => { |
| const config = await getConfig(ctx); |
| if (!config.enableWorkspaceDemos) { |
| throw new Error("Workspace demos are disabled in plugin settings"); |
| } |
| const companyId = getCurrentCompanyId(params); |
| const projectId = typeof params.projectId === "string" ? params.projectId : ""; |
| const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined; |
| const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0 |
| ? params.relativePath |
| : config.workspaceScratchFile || DEFAULT_CONFIG.workspaceScratchFile; |
| if (!projectId) throw new Error("projectId is required"); |
| const workspace = await resolveWorkspace(ctx, companyId, projectId, workspaceId); |
| const fullPath = ensureInsideWorkspace(workspace.path, relativePath); |
| const content = await fs.readFile(fullPath, "utf8"); |
| return { |
| workspaceId: workspace.id, |
| relativePath, |
| content, |
| }; |
| }); |
|
|
| ctx.actions.register("write-workspace-scratch", async (params) => { |
| const config = await getConfig(ctx); |
| if (!config.enableWorkspaceDemos) { |
| throw new Error("Workspace demos are disabled in plugin settings"); |
| } |
| const companyId = getCurrentCompanyId(params); |
| const projectId = typeof params.projectId === "string" ? params.projectId : ""; |
| const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined; |
| const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0 |
| ? params.relativePath |
| : config.workspaceScratchFile || DEFAULT_CONFIG.workspaceScratchFile; |
| const content = typeof params.content === "string" ? params.content : "Kitchen Sink workspace demo"; |
| if (!projectId) throw new Error("projectId is required"); |
| const workspace = await resolveWorkspace(ctx, companyId, projectId, workspaceId); |
| const fullPath = ensureInsideWorkspace(workspace.path, relativePath); |
| await fs.writeFile(fullPath, content, "utf8"); |
| pushRecord({ |
| level: "info", |
| source: "workspace", |
| message: `Wrote scratch file ${relativePath}`, |
| data: { workspaceId: workspace.id }, |
| }); |
| return { |
| workspaceId: workspace.id, |
| relativePath, |
| bytes: Buffer.byteLength(content, "utf8"), |
| }; |
| }); |
|
|
| ctx.actions.register("start-progress-stream", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const steps = typeof params.steps === "number" ? params.steps : 5; |
| void (async () => { |
| ctx.streams.open(STREAM_CHANNELS.progress, companyId); |
| try { |
| for (let index = 1; index <= steps; index += 1) { |
| ctx.streams.emit(STREAM_CHANNELS.progress, { |
| step: index, |
| total: steps, |
| message: `Progress step ${index}/${steps}`, |
| }); |
| await new Promise((resolve) => setTimeout(resolve, 350)); |
| } |
| } finally { |
| ctx.streams.close(STREAM_CHANNELS.progress); |
| } |
| })(); |
| return { ok: true, channel: STREAM_CHANNELS.progress }; |
| }); |
|
|
| ctx.actions.register("invoke-agent", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const agentId = typeof params.agentId === "string" ? params.agentId : ""; |
| const prompt = typeof params.prompt === "string" && params.prompt.length > 0 |
| ? params.prompt |
| : "Kitchen Sink test invocation"; |
| if (!agentId) throw new Error("agentId is required"); |
| const result = await ctx.agents.invoke(agentId, companyId, { prompt, reason: "Kitchen Sink plugin demo" }); |
| pushRecord({ |
| level: "info", |
| source: "agents.invoke", |
| message: `Invoked agent ${agentId}`, |
| data: result, |
| }); |
| return result; |
| }); |
|
|
| ctx.actions.register("pause-agent", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const agentId = typeof params.agentId === "string" ? params.agentId : ""; |
| if (!agentId) throw new Error("agentId is required"); |
| return await ctx.agents.pause(agentId, companyId); |
| }); |
|
|
| ctx.actions.register("resume-agent", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const agentId = typeof params.agentId === "string" ? params.agentId : ""; |
| if (!agentId) throw new Error("agentId is required"); |
| return await ctx.agents.resume(agentId, companyId); |
| }); |
|
|
| ctx.actions.register("ask-agent", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const agentId = typeof params.agentId === "string" ? params.agentId : ""; |
| const prompt = typeof params.prompt === "string" && params.prompt.length > 0 |
| ? params.prompt |
| : "Say hello from the Kitchen Sink plugin."; |
| if (!agentId) throw new Error("agentId is required"); |
|
|
| ctx.streams.open(STREAM_CHANNELS.agentChat, companyId); |
| const session = await ctx.agents.sessions.create(agentId, companyId, { |
| reason: "Kitchen Sink plugin chat demo", |
| }); |
|
|
| await ctx.agents.sessions.sendMessage(session.sessionId, companyId, { |
| prompt, |
| reason: "Kitchen Sink demo", |
| onEvent: (event) => { |
| ctx.streams.emit(STREAM_CHANNELS.agentChat, { |
| eventType: event.eventType, |
| stream: event.stream, |
| message: event.message, |
| payload: event.payload, |
| }); |
| if (event.eventType === "done" || event.eventType === "error") { |
| ctx.streams.close(STREAM_CHANNELS.agentChat); |
| } |
| }, |
| }); |
|
|
| pushRecord({ |
| level: "info", |
| source: "agent.sessions", |
| message: `Started agent session ${session.sessionId}`, |
| data: { agentId, sessionId: session.sessionId }, |
| }); |
| return { channel: STREAM_CHANNELS.agentChat, sessionId: session.sessionId }; |
| }); |
|
|
| ctx.actions.register("copy-comment-context", async (params) => { |
| const companyId = getCurrentCompanyId(params); |
| const issueId = typeof params.issueId === "string" ? params.issueId : ""; |
| const commentId = typeof params.commentId === "string" ? params.commentId : ""; |
| if (!issueId || !commentId) { |
| throw new Error("issueId and commentId are required"); |
| } |
| const comments = await ctx.issues.listComments(issueId, companyId); |
| const comment = comments.find((entry) => entry.id === commentId); |
| if (!comment) { |
| throw new Error("Comment not found"); |
| } |
| const record = await ctx.entities.upsert({ |
| entityType: "copied-comment", |
| scopeKind: "issue", |
| scopeId: issueId, |
| externalId: comment.id, |
| title: `Copied comment ${comment.id.slice(0, 8)}`, |
| status: "captured", |
| data: { |
| commentId: comment.id, |
| issueId, |
| body: comment.body, |
| }, |
| }); |
| pushRecord({ |
| level: "info", |
| source: "comments", |
| message: `Copied comment ${comment.id} into plugin entities`, |
| data: { recordId: record.id }, |
| }); |
| return record; |
| }); |
| } |
|
|
| async function registerToolHandlers(ctx: PluginContext): Promise<void> { |
| ctx.tools.register( |
| TOOL_NAMES.echo, |
| { |
| displayName: "Kitchen Sink Echo", |
| description: "Echoes the provided message back to the caller.", |
| parametersSchema: { |
| type: "object", |
| properties: { |
| message: { type: "string" }, |
| }, |
| required: ["message"], |
| }, |
| }, |
| async (params, runCtx): Promise<ToolResult> => { |
| const payload = params as { message?: string }; |
| return { |
| content: payload.message ?? "No message provided", |
| data: { |
| runCtx, |
| message: payload.message ?? "", |
| }, |
| }; |
| }, |
| ); |
|
|
| ctx.tools.register( |
| TOOL_NAMES.companySummary, |
| { |
| displayName: "Kitchen Sink Company Summary", |
| description: "Summarizes current company counts from the Paperclip APIs.", |
| parametersSchema: { type: "object", properties: {} }, |
| }, |
| async (_params, runCtx): Promise<ToolResult> => { |
| const projects = await ctx.projects.list({ companyId: runCtx.companyId, limit: 50, offset: 0 }); |
| const issues = await ctx.issues.list({ companyId: runCtx.companyId, limit: 50, offset: 0 }); |
| const goals = await ctx.goals.list({ companyId: runCtx.companyId, limit: 50, offset: 0 }); |
| const agents = await ctx.agents.list({ companyId: runCtx.companyId, limit: 50, offset: 0 }); |
| return { |
| content: `Company has ${projects.length} projects, ${issues.length} issues, ${goals.length} goals, and ${agents.length} agents.`, |
| data: { |
| companyId: runCtx.companyId, |
| projects: projects.length, |
| issues: issues.length, |
| goals: goals.length, |
| agents: agents.length, |
| }, |
| }; |
| }, |
| ); |
|
|
| ctx.tools.register( |
| TOOL_NAMES.createIssue, |
| { |
| displayName: "Kitchen Sink Create Issue", |
| description: "Creates an issue in the current run context.", |
| parametersSchema: { |
| type: "object", |
| properties: { |
| title: { type: "string" }, |
| description: { type: "string" }, |
| }, |
| required: ["title"], |
| }, |
| }, |
| async (params, runCtx): Promise<ToolResult> => { |
| const payload = params as { title?: string; description?: string }; |
| if (!payload.title) { |
| return { error: "title is required" }; |
| } |
| const issue = await ctx.issues.create({ |
| companyId: runCtx.companyId, |
| projectId: runCtx.projectId, |
| title: payload.title, |
| description: payload.description, |
| }); |
| return { |
| content: `Created issue ${issue.title}`, |
| data: issue, |
| }; |
| }, |
| ); |
| } |
|
|
| async function registerEventHandlers(ctx: PluginContext): Promise<void> { |
| ctx.events.on("issue.created", async (event: PluginEvent) => { |
| pushRecord({ |
| level: "info", |
| source: "events.subscribe", |
| message: "Observed issue.created", |
| data: event, |
| }); |
| }); |
|
|
| ctx.events.on("issue.updated", async (event: PluginEvent) => { |
| pushRecord({ |
| level: "info", |
| source: "events.subscribe", |
| message: "Observed issue.updated", |
| data: event, |
| }); |
| }); |
|
|
| ctx.events.on(`plugin.${PLUGIN_ID}.demo-event`, async (event: PluginEvent) => { |
| pushRecord({ |
| level: "info", |
| source: "plugin-event", |
| message: "Observed plugin demo event", |
| data: event, |
| }); |
| }); |
| } |
|
|
| async function registerJobs(ctx: PluginContext): Promise<void> { |
| ctx.jobs.register(JOB_KEYS.heartbeat, async (job: PluginJobContext) => { |
| const payload = { |
| jobKey: job.jobKey, |
| runId: job.runId, |
| trigger: job.trigger, |
| scheduledAt: job.scheduledAt, |
| completedAt: new Date().toISOString(), |
| }; |
| await writeInstanceState(ctx, "last-job-run", payload); |
| pushRecord({ |
| level: "info", |
| source: "jobs", |
| message: "Kitchen Sink demo job ran", |
| data: payload, |
| }); |
| await ctx.metrics.write("jobs.demo_heartbeat", 1, { trigger: job.trigger }); |
| }); |
| } |
|
|
| const plugin: PaperclipPlugin = definePlugin({ |
| async setup(ctx) { |
| currentContext = ctx; |
| runtimeLaunchers.set(RUNTIME_LAUNCHER.id, RUNTIME_LAUNCHER); |
| ctx.launchers.register(RUNTIME_LAUNCHER); |
| pushRecord({ |
| level: "info", |
| source: "setup", |
| message: "Kitchen Sink plugin setup complete", |
| data: { pluginId: PLUGIN_ID }, |
| }); |
| await registerEventHandlers(ctx); |
| await registerJobs(ctx); |
| await registerDataHandlers(ctx); |
| await registerActionHandlers(ctx); |
| await registerToolHandlers(ctx); |
| }, |
|
|
| async onHealth(): Promise<PluginHealthDiagnostics> { |
| const ctx = currentContext; |
| const config = ctx ? await getConfig(ctx) : DEFAULT_CONFIG; |
| return { |
| status: "ok", |
| message: "Kitchen Sink plugin ready", |
| details: { |
| recordsTracked: recentRecords.length, |
| runtimeLaunchers: runtimeLaunchers.size, |
| processDemosEnabled: config.enableProcessDemos === true, |
| workspaceDemosEnabled: config.enableWorkspaceDemos !== false, |
| }, |
| }; |
| }, |
|
|
| async onConfigChanged(newConfig) { |
| pushRecord({ |
| level: "info", |
| source: "config", |
| message: "Kitchen Sink config changed", |
| data: newConfig, |
| }); |
| }, |
|
|
| async onValidateConfig(config) { |
| const errors: string[] = []; |
| const warnings: string[] = []; |
| const typed = config as KitchenSinkConfig; |
| if (typed.httpDemoUrl && typeof typed.httpDemoUrl !== "string") { |
| errors.push("httpDemoUrl must be a string"); |
| } |
| if (typed.allowedCommands && !Array.isArray(typed.allowedCommands)) { |
| errors.push("allowedCommands must be an array"); |
| } |
| if (Array.isArray(typed.allowedCommands)) { |
| const allowed = new Set<string>(SAFE_COMMANDS.map((command) => command.key)); |
| const invalid = typed.allowedCommands.filter((value) => typeof value !== "string" || !allowed.has(value)); |
| if (invalid.length > 0) { |
| errors.push(`allowedCommands contains unsupported values: ${invalid.join(", ")}`); |
| } |
| } |
| if (typed.enableProcessDemos) { |
| warnings.push("Process demos run local child processes and are intended only for trusted development environments."); |
| } |
| return { |
| ok: errors.length === 0, |
| warnings, |
| errors, |
| }; |
| }, |
|
|
| async onWebhook(input: PluginWebhookInput) { |
| const payload = { |
| endpointKey: input.endpointKey, |
| requestId: input.requestId, |
| rawBody: input.rawBody, |
| parsedBody: input.parsedBody, |
| receivedAt: new Date().toISOString(), |
| }; |
| const ctx = currentContext; |
| if (ctx) { |
| await writeInstanceState(ctx, "last-webhook", payload); |
| } |
| pushRecord({ |
| level: "info", |
| source: "webhook", |
| message: `Received webhook ${input.endpointKey}`, |
| data: payload, |
| }); |
| if (input.endpointKey !== WEBHOOK_KEYS.demo) { |
| throw new Error(`Unsupported webhook endpoint "${input.endpointKey}"`); |
| } |
| }, |
|
|
| async onShutdown() { |
| pushRecord({ |
| level: "warning", |
| source: "shutdown", |
| message: "Kitchen Sink plugin shutting down", |
| }); |
| }, |
| }); |
|
|
| export default plugin; |
| runWorker(plugin, import.meta.url); |
|
|