From ceb18c77dbb811dc04baa6762b7cb379b44f8dd8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 16 Mar 2026 20:37:05 -0500 Subject: [PATCH] feat: generate README.md and org chart SVG in company exports Adds auto-generated README.md with company summary, agent table, project list, and getting-started instructions. Includes an SVG org chart image in images/org-chart.svg using the same layout algorithm as the UI. Co-Authored-By: Paperclip --- server/src/services/company-export-readme.ts | 294 +++++++++++++++++++ server/src/services/company-portability.ts | 13 + 2 files changed, 307 insertions(+) create mode 100644 server/src/services/company-export-readme.ts diff --git a/server/src/services/company-export-readme.ts b/server/src/services/company-export-readme.ts new file mode 100644 index 00000000..2648ad09 --- /dev/null +++ b/server/src/services/company-export-readme.ts @@ -0,0 +1,294 @@ +/** + * Generates README.md and org chart SVG for company exports. + */ +import type { CompanyPortabilityManifest } from "@paperclipai/shared"; + +// ── Org chart layout (mirrors ui/src/pages/OrgChart.tsx) ──────────────── + +const CARD_W = 200; +const CARD_H = 72; +const GAP_X = 32; +const GAP_Y = 64; +const PADDING = 40; + +interface OrgNode { + id: string; + name: string; + role: string; + reports: OrgNode[]; +} + +interface LayoutNode { + id: string; + name: string; + role: string; + x: number; + y: number; + children: LayoutNode[]; +} + +function subtreeWidth(node: OrgNode): number { + if (node.reports.length === 0) return CARD_W; + const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0); + const gaps = (node.reports.length - 1) * GAP_X; + return Math.max(CARD_W, childrenW + gaps); +} + +function layoutTree(node: OrgNode, x: number, y: number): LayoutNode { + const totalW = subtreeWidth(node); + const layoutChildren: LayoutNode[] = []; + + if (node.reports.length > 0) { + const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0); + const gaps = (node.reports.length - 1) * GAP_X; + let cx = x + (totalW - childrenW - gaps) / 2; + + for (const child of node.reports) { + const cw = subtreeWidth(child); + layoutChildren.push(layoutTree(child, cx, y + CARD_H + GAP_Y)); + cx += cw + GAP_X; + } + } + + return { + id: node.id, + name: node.name, + role: node.role, + x: x + (totalW - CARD_W) / 2, + y, + children: layoutChildren, + }; +} + +function flattenLayout(nodes: LayoutNode[]): LayoutNode[] { + const result: LayoutNode[] = []; + function walk(n: LayoutNode) { + result.push(n); + n.children.forEach(walk); + } + nodes.forEach(walk); + return result; +} + +function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: LayoutNode }> { + const edges: Array<{ parent: LayoutNode; child: LayoutNode }> = []; + function walk(n: LayoutNode) { + for (const c of n.children) { + edges.push({ parent: n, child: c }); + walk(c); + } + } + nodes.forEach(walk); + return edges; +} + +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + "\u2026" : s; +} + +const ROLE_LABELS: Record = { + ceo: "CEO", + cto: "CTO", + cmo: "CMO", + cfo: "CFO", + coo: "COO", + vp: "VP", + manager: "Manager", + engineer: "Engineer", + agent: "Agent", +}; + +/** + * Build an org-tree from the manifest agent list using reportsToSlug. + */ +function buildOrgTree(agents: CompanyPortabilityManifest["agents"]): OrgNode[] { + const bySlug = new Map(agents.map((a) => [a.slug, a])); + const childrenOf = new Map(); + + for (const agent of agents) { + const node: OrgNode = { + id: agent.slug, + name: agent.name, + role: agent.role, + reports: [], + }; + const parentKey = agent.reportsToSlug ?? null; + const siblings = childrenOf.get(parentKey) ?? []; + siblings.push(node); + childrenOf.set(parentKey, siblings); + } + + // Build tree recursively + function attach(nodes: OrgNode[]): OrgNode[] { + for (const node of nodes) { + node.reports = childrenOf.get(node.id) ?? []; + attach(node.reports); + } + return nodes; + } + + // Roots are agents whose reportsToSlug is null or points to a non-existent agent + const roots: OrgNode[] = []; + for (const [parentKey, children] of childrenOf.entries()) { + if (parentKey === null || !bySlug.has(parentKey)) { + roots.push(...children); + } + } + return attach(roots); +} + +/** + * Generate an SVG org chart from the manifest agents. + * Returns null if there are no agents. + */ +export function generateOrgChartSvg(manifest: CompanyPortabilityManifest): string | null { + if (manifest.agents.length === 0) return null; + + const roots = buildOrgTree(manifest.agents); + if (roots.length === 0) return null; + + // Layout all roots side by side + const layoutRoots: LayoutNode[] = []; + let x = PADDING; + for (const root of roots) { + const w = subtreeWidth(root); + layoutRoots.push(layoutTree(root, x, PADDING)); + x += w + GAP_X; + } + + const allNodes = flattenLayout(layoutRoots); + const edges = collectEdges(layoutRoots); + + // Compute canvas bounds + let maxX = 0; + let maxY = 0; + for (const n of allNodes) { + maxX = Math.max(maxX, n.x + CARD_W); + maxY = Math.max(maxY, n.y + CARD_H); + } + const svgW = maxX + PADDING; + const svgH = maxY + PADDING; + + const lines: string[] = []; + lines.push(``); + lines.push(``); + lines.push(``); + + // Draw edges (bezier connectors) + for (const { parent, child } of edges) { + const x1 = parent.x + CARD_W / 2; + const y1 = parent.y + CARD_H; + const x2 = child.x + CARD_W / 2; + const y2 = child.y; + const midY = (y1 + y2) / 2; + lines.push(``); + } + + // Draw cards + for (const node of allNodes) { + const roleLabel = ROLE_LABELS[node.role] ?? node.role; + lines.push(``); + lines.push(` `); + lines.push(` ${escapeXml(truncate(node.name, 22))}`); + lines.push(` ${escapeXml(roleLabel)}`); + lines.push(``); + } + + lines.push(``); + return lines.join("\n"); +} + +/** + * Generate the README.md content for a company export. + */ +export function generateReadme( + manifest: CompanyPortabilityManifest, + options: { + companyName: string; + companyDescription: string | null; + hasOrgChart: boolean; + }, +): string { + const lines: string[] = []; + + lines.push(`# ${options.companyName}`); + lines.push(""); + if (options.companyDescription) { + lines.push(`> ${options.companyDescription}`); + lines.push(""); + } + + if (options.hasOrgChart) { + lines.push(`![Org Chart](images/org-chart.svg)`); + lines.push(""); + } + + // What's Inside table + lines.push("## What's Inside"); + lines.push(""); + lines.push("This is an [Agent Company](https://paperclip.ing) package."); + lines.push(""); + + const counts: Array<[string, number]> = []; + if (manifest.agents.length > 0) counts.push(["Agents", manifest.agents.length]); + if (manifest.projects.length > 0) counts.push(["Projects", manifest.projects.length]); + if (manifest.skills.length > 0) counts.push(["Skills", manifest.skills.length]); + if (manifest.issues.length > 0) counts.push(["Tasks", manifest.issues.length]); + + if (counts.length > 0) { + lines.push("| Content | Count |"); + lines.push("|---------|-------|"); + for (const [label, count] of counts) { + lines.push(`| ${label} | ${count} |`); + } + lines.push(""); + } + + // Agents table + if (manifest.agents.length > 0) { + lines.push("### Agents"); + lines.push(""); + lines.push("| Agent | Role | Reports To |"); + lines.push("|-------|------|------------|"); + for (const agent of manifest.agents) { + const roleLabel = ROLE_LABELS[agent.role] ?? agent.role; + const reportsTo = agent.reportsToSlug ?? "\u2014"; + lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`); + } + lines.push(""); + } + + // Projects list + if (manifest.projects.length > 0) { + lines.push("### Projects"); + lines.push(""); + for (const project of manifest.projects) { + const desc = project.description ? ` \u2014 ${project.description}` : ""; + lines.push(`- **${project.name}**${desc}`); + } + lines.push(""); + } + + // Getting Started + lines.push("## Getting Started"); + lines.push(""); + lines.push("```bash"); + lines.push("pnpm paperclipai company import --from ./ --target new --include company,agents"); + lines.push("```"); + lines.push(""); + lines.push("See [Paperclip](https://paperclip.ing) for more information."); + lines.push(""); + + // Footer + lines.push("---"); + lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`); + lines.push(""); + + return lines.join("\n"); +} diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index be311c6c..93dc4817 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -35,6 +35,7 @@ import { import { notFound, unprocessable } from "../errors.js"; import { accessService } from "./access.js"; import { agentService } from "./agents.js"; +import { generateOrgChartSvg, generateReadme } from "./company-export-readme.js"; import { companySkillService } from "./company-skills.js"; import { companyService } from "./companies.js"; import { issueService } from "./issues.js"; @@ -1937,6 +1938,18 @@ export function companyPortabilityService(db: Db) { resolved.manifest.includes = include; resolved.manifest.envInputs = dedupeEnvInputs(envInputs); resolved.warnings.unshift(...warnings); + + // Generate org chart SVG and README.md + const orgChartSvg = generateOrgChartSvg(resolved.manifest); + if (orgChartSvg) { + files["images/org-chart.svg"] = orgChartSvg; + } + files["README.md"] = generateReadme(resolved.manifest, { + companyName: company.name, + companyDescription: company.description ?? null, + hasOrgChart: orgChartSvg !== null, + }); + return { rootPath, manifest: resolved.manifest,