Merge remote-tracking branch 'public-gh/master' into paperclip-routines

* public-gh/master: (46 commits)
  chore(lockfile): refresh pnpm-lock.yaml (#1377)
  fix: manage codex home per company by default
  Ensure agent home directories exist before use
  Handle directory entries in imported zip archives
  Fix portability import and org chart test blockers
  Fix PR verify failures after merge
  fix: address greptile follow-up feedback
  Address remaining Greptile portability feedback
  docs: clarify quickstart npx usage
  Add guarded dev restart handling
  Fix PAP-576 settings toggles and transcript default
  Add username log censor setting
  fix: use standard toggle component for permission controls
  fix: add missing setPrincipalPermission mock in portability tests
  fix: use fixed 1280x640 dimensions for org chart export image
  Adjust default CEO onboarding task copy
  fix: link Agent Company to agentcompanies.io in export README
  fix: strip agents and projects sections from COMPANY.md export body
  fix: default company export page to README.md instead of first file
  Add default agent instructions bundle
  ...

# Conflicts:
#	packages/adapters/pi-local/src/server/execute.ts
#	packages/db/src/migrations/meta/0039_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
#	server/src/__tests__/agent-permissions-routes.test.ts
#	server/src/__tests__/agent-skills-routes.test.ts
#	server/src/services/company-portability.ts
#	skills/paperclip/references/company-skills.md
#	ui/src/api/agents.ts
This commit is contained in:
dotta
2026-03-20 15:04:55 -05:00
96 changed files with 15366 additions and 1684 deletions

View File

@@ -8,6 +8,7 @@ import { redactCurrentUserValue } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js";
import { logger } from "../middleware/logger.js";
import type { PluginEventBus } from "./plugin-event-bus.js";
import { instanceSettingsService } from "./instance-settings.js";
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
@@ -34,8 +35,13 @@ export interface LogActivityInput {
}
export async function logActivity(db: Db, input: LogActivityInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
};
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null;
const redactedDetails = sanitizedDetails
? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions)
: null;
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: input.actorType,

View File

@@ -6,22 +6,24 @@ import { redactCurrentUserText } from "../log-redaction.js";
import { agentService } from "./agents.js";
import { budgetService } from "./budgets.js";
import { notifyHireApproved } from "./hire-hook.js";
function redactApprovalComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
import { instanceSettingsService } from "./instance-settings.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const budgets = budgetService(db);
const instanceSettings = instanceSettingsService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses);
type ApprovalRecord = typeof approvals.$inferSelect;
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
function redactApprovalComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function getExistingApproval(id: string) {
const existing = await db
.select()
@@ -230,6 +232,7 @@ export function approvalService(db: Db) {
listComments: async (approvalId: string) => {
const existing = await getExistingApproval(approvalId);
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return db
.select()
.from(approvalComments)
@@ -240,7 +243,7 @@ export function approvalService(db: Db) {
),
)
.orderBy(asc(approvalComments.createdAt))
.then((comments) => comments.map(redactApprovalComment));
.then((comments) => comments.map((comment) => redactApprovalComment(comment, censorUsernameInLogs)));
},
addComment: async (
@@ -249,7 +252,10 @@ export function approvalService(db: Db) {
actor: { agentId?: string; userId?: string },
) => {
const existing = await getExistingApproval(approvalId);
const redactedBody = redactCurrentUserText(body);
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
return db
.insert(approvalComments)
.values({
@@ -260,7 +266,7 @@ export function approvalService(db: Db) {
body: redactedBody,
})
.returning()
.then((rows) => redactApprovalComment(rows[0]));
.then((rows) => redactApprovalComment(rows[0], currentUserRedactionOptions.enabled));
},
};
}

View File

@@ -55,6 +55,19 @@ function mermaidEscape(s: string): string {
return s.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** Build a display label for a skill's source, linking to GitHub when available. */
function skillSourceLabel(skill: CompanyPortabilityManifest["skills"][number]): string {
if (skill.sourceLocator) {
// For GitHub or URL sources, render as a markdown link
if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url") {
return `[${skill.sourceType}](${skill.sourceLocator})`;
}
return skill.sourceLocator;
}
if (skill.sourceType === "local") return "local";
return skill.sourceType ?? "\u2014";
}
/**
* Generate the README.md content for a company export.
*/
@@ -74,17 +87,16 @@ export function generateReadme(
lines.push("");
}
// Org chart as Mermaid diagram
const mermaid = generateOrgChartMermaid(manifest.agents);
if (mermaid) {
lines.push(mermaid);
// Org chart image (generated during export as images/org-chart.png)
if (manifest.agents.length > 0) {
lines.push("![Org Chart](images/org-chart.png)");
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("> This is an [Agent Company](https://agentcompanies.io) package from [Paperclip](https://paperclip.ing)");
lines.push("");
const counts: Array<[string, number]> = [];
@@ -127,6 +139,20 @@ export function generateReadme(
lines.push("");
}
// Skills list
if (manifest.skills.length > 0) {
lines.push("### Skills");
lines.push("");
lines.push("| Skill | Description | Source |");
lines.push("|-------|-------------|--------|");
for (const skill of manifest.skills) {
const desc = skill.description ?? "\u2014";
const source = skillSourceLabel(skill);
lines.push(`| ${skill.name} | ${desc} | ${source} |`);
}
lines.push("");
}
// Getting Started
lines.push("## Getting Started");
lines.push("");

View File

@@ -42,16 +42,63 @@ import { agentService } from "./agents.js";
import { agentInstructionsService } from "./agent-instructions.js";
import { assetService } from "./assets.js";
import { generateReadme } from "./company-export-readme.js";
import { renderOrgChartPng, type OrgNode } from "../routes/org-chart-svg.js";
import { companySkillService } from "./company-skills.js";
import { companyService } from "./companies.js";
import { issueService } from "./issues.js";
import { projectService } from "./projects.js";
/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */
function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
const ROLE_LABELS: Record<string, string> = {
ceo: "Chief Executive", cto: "Technology", cmo: "Marketing",
cfo: "Finance", coo: "Operations", vp: "VP", manager: "Manager",
engineer: "Engineer", agent: "Agent",
};
const bySlug = new Map(agents.map((a) => [a.slug, a]));
const childrenOf = new Map<string | null, typeof agents>();
for (const a of agents) {
const parent = a.reportsToSlug ?? null;
const list = childrenOf.get(parent) ?? [];
list.push(a);
childrenOf.set(parent, list);
}
const build = (parentSlug: string | null): OrgNode[] => {
const members = childrenOf.get(parentSlug) ?? [];
return members.map((m) => ({
id: m.slug,
name: m.name,
role: ROLE_LABELS[m.role] ?? m.role,
status: "active",
reports: build(m.slug),
}));
};
// Find roots: agents whose reportsToSlug is null or points to a non-existent slug
const roots = agents.filter((a) => !a.reportsToSlug || !bySlug.has(a.reportsToSlug));
const rootSlugs = new Set(roots.map((r) => r.slug));
// Start from null parent, but also include orphans
const tree = build(null);
for (const root of roots) {
if (root.reportsToSlug && !bySlug.has(root.reportsToSlug)) {
// Orphan root (parent slug doesn't exist)
tree.push({
id: root.slug,
name: root.name,
role: ROLE_LABELS[root.role] ?? root.role,
status: "active",
reports: build(root.slug),
});
}
}
return tree;
}
const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
company: true,
agents: true,
projects: false,
issues: false,
skills: false,
};
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
@@ -119,7 +166,7 @@ function deriveManifestSkillKey(
const sourceKind = asString(metadata?.sourceKind);
const owner = normalizeSkillSlug(asString(metadata?.owner));
const repo = normalizeSkillSlug(asString(metadata?.repo));
if ((sourceType === "github" || sourceKind === "github") && owner && repo) {
if ((sourceType === "github" || sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) {
return `${owner}/${repo}/${slug}`;
}
if (sourceKind === "paperclip_bundled") {
@@ -246,10 +293,10 @@ function deriveSkillExportDirCandidates(
pushSuffix("paperclip");
}
if (skill.sourceType === "github") {
if (skill.sourceType === "github" || skill.sourceType === "skills_sh") {
pushSuffix(asString(metadata?.repo));
pushSuffix(asString(metadata?.owner));
pushSuffix("github");
pushSuffix(skill.sourceType === "skills_sh" ? "skills_sh" : "github");
} else if (skill.sourceType === "url") {
try {
pushSuffix(skill.sourceLocator ? new URL(skill.sourceLocator).host : null);
@@ -304,10 +351,12 @@ function isSensitiveEnvKey(key: string) {
normalized === "token" ||
normalized.endsWith("_token") ||
normalized.endsWith("-token") ||
normalized.includes("apikey") ||
normalized.includes("api_key") ||
normalized.includes("api-key") ||
normalized.includes("access_token") ||
normalized.includes("access-token") ||
normalized.includes("auth") ||
normalized.includes("auth_token") ||
normalized.includes("auth-token") ||
normalized.includes("authorization") ||
@@ -317,6 +366,7 @@ function isSensitiveEnvKey(key: string) {
normalized.includes("password") ||
normalized.includes("credential") ||
normalized.includes("jwt") ||
normalized.includes("privatekey") ||
normalized.includes("private_key") ||
normalized.includes("private-key") ||
normalized.includes("cookie") ||
@@ -515,6 +565,7 @@ function normalizeInclude(input?: Partial<CompanyPortabilityInclude>): CompanyPo
agents: input?.agents ?? DEFAULT_INCLUDE.agents,
projects: input?.projects ?? DEFAULT_INCLUDE.projects,
issues: input?.issues ?? DEFAULT_INCLUDE.issues,
skills: input?.skills ?? DEFAULT_INCLUDE.skills,
};
}
@@ -826,6 +877,7 @@ function extractPortableEnvInputs(
if (isPlainRecord(binding) && binding.type === "plain") {
const defaultValue = asString(binding.value);
const isSensitive = isSensitiveEnvKey(key);
const portability = defaultValue && isAbsoluteCommand(defaultValue)
? "system_dependent"
: "portable";
@@ -836,9 +888,9 @@ function extractPortableEnvInputs(
key,
description: `Optional default for ${key} on agent ${agentSlug}`,
agentSlug,
kind: "plain",
kind: isSensitive ? "secret" : "plain",
requirement: "optional",
defaultValue: defaultValue ?? "",
defaultValue: isSensitive ? "" : defaultValue ?? "",
portability,
});
continue;
@@ -1147,6 +1199,7 @@ function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: stri
agents: filtered.manifest.agents.length > 0,
projects: filtered.manifest.projects.length > 0,
issues: filtered.manifest.issues.length > 0,
skills: filtered.manifest.skills.length > 0,
};
return filtered;
@@ -1178,7 +1231,7 @@ async function buildSkillSourceEntry(skill: CompanySkill) {
};
}
if (skill.sourceType === "github") {
if (skill.sourceType === "github" || skill.sourceType === "skills_sh") {
const owner = asString(metadata?.owner);
const repo = asString(metadata?.repo);
const repoSkillDir = asString(metadata?.repoSkillDir);
@@ -1207,7 +1260,7 @@ function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkill
if (expandReferencedSkills) return false;
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
if (asString(metadata?.sourceKind) === "paperclip_bundled") return true;
return skill.sourceType === "github" || skill.sourceType === "url";
return skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url";
}
async function buildReferencedSkillMarkdown(skill: CompanySkill) {
@@ -1254,17 +1307,6 @@ async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
return buildMarkdown(frontmatter, parsed.body);
}
function renderCompanyAgentsSection(agentSummaries: Array<{ slug: string; name: string }>) {
const lines = ["# Agents", ""];
if (agentSummaries.length === 0) {
lines.push("- _none_");
return lines.join("\n");
}
for (const agent of agentSummaries) {
lines.push(`- ${agent.slug} - ${agent.name}`);
}
return lines.join("\n");
}
function parseYamlScalar(rawValue: string): unknown {
const trimmed = rawValue.trim();
@@ -1610,6 +1652,7 @@ function buildManifestFromPackageFiles(
agents: true,
projects: projectPaths.length > 0,
issues: taskPaths.length > 0,
skills: skillPaths.length > 0,
},
company: {
path: resolvedCompanyPath,
@@ -2005,6 +2048,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
(input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0)
? true
: input.include?.issues,
skills: input.skills && input.skills.length > 0 ? true : input.include?.skills,
});
const company = await companies.getById(companyId);
if (!company) throw notFound("Company not found");
@@ -2017,7 +2061,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
const companySkillRows = await companySkills.listFull(companyId);
const companySkillRows = include.skills || include.agents ? await companySkills.listFull(companyId) : [];
if (include.agents) {
const skipped = allAgentRows.length - liveAgentRows.length;
if (skipped > 0) {
@@ -2159,19 +2203,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
const companyPath = "COMPANY.md";
const companyBodySections: string[] = [];
if (include.agents) {
const companyAgentSummaries = agentRows.map((agent) => ({
slug: idToSlug.get(agent.id) ?? "agent",
name: agent.name,
}));
companyBodySections.push(renderCompanyAgentsSection(companyAgentSummaries));
}
if (selectedProjectRows.length > 0) {
companyBodySections.push(
["# Projects", "", ...selectedProjectRows.map((project) => `- ${projectSlugById.get(project.id) ?? project.id} - ${project.name}`)].join("\n"),
);
}
files[companyPath] = buildMarkdown(
{
name: company.name,
@@ -2179,7 +2210,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
schema: "agentcompanies/v1",
slug: rootPath,
},
companyBodySections.join("\n\n").trim(),
"",
);
if (include.company && company.logoAssetId) {
@@ -2418,10 +2449,22 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
agents: resolved.manifest.agents.length > 0,
projects: resolved.manifest.projects.length > 0,
issues: resolved.manifest.issues.length > 0,
skills: resolved.manifest.skills.length > 0,
};
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
resolved.warnings.unshift(...warnings);
// Generate org chart PNG from manifest agents
if (resolved.manifest.agents.length > 0) {
try {
const orgNodes = buildOrgTreeFromManifest(resolved.manifest.agents);
const pngBuffer = await renderOrgChartPng(orgNodes);
finalFiles["images/org-chart.png"] = bufferToPortableBinaryFile(pngBuffer, "image/png");
} catch {
// Non-fatal: export still works without the org chart image
}
}
if (!input.selectedFiles || input.selectedFiles.some((entry) => normalizePortablePath(entry) === "README.md")) {
finalFiles["README.md"] = generateReadme(resolved.manifest, {
companyName: company.name,
@@ -2440,6 +2483,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
agents: resolved.manifest.agents.length > 0,
projects: resolved.manifest.projects.length > 0,
issues: resolved.manifest.issues.length > 0,
skills: resolved.manifest.skills.length > 0,
};
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
resolved.warnings.unshift(...warnings);
@@ -2502,6 +2546,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
agents: requestedInclude.agents && manifest.agents.length > 0,
projects: requestedInclude.projects && manifest.projects.length > 0,
issues: requestedInclude.issues && manifest.issues.length > 0,
skills: requestedInclude.skills && manifest.skills.length > 0,
};
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
if (mode === "agent_safe" && collisionStrategy === "replace") {
@@ -2962,9 +3007,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
existingProjectSlugToId.set(existing.urlKey, existing.id);
}
const importedSkills = await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), {
onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy),
});
const importedSkills = include.skills || include.agents
? await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), {
onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy),
})
: [];
const desiredSkillRefMap = new Map<string, string>();
for (const importedSkill of importedSkills) {
desiredSkillRefMap.set(importedSkill.originalKey, importedSkill.skill.key);

View File

@@ -5,7 +5,7 @@ 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 { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
import type {
CompanySkill,
@@ -66,6 +66,7 @@ export type ImportPackageSkillResult = {
type ParsedSkillImportSource = {
resolvedSource: string;
requestedSkillSlug: string | null;
originalSkillsShUrl: string | null;
warnings: string[];
};
@@ -251,7 +252,7 @@ function deriveCanonicalSkillKey(
const owner = normalizeSkillSlug(asString(metadata?.owner));
const repo = normalizeSkillSlug(asString(metadata?.repo));
if ((input.sourceType === "github" || sourceKind === "github") && owner && repo) {
if ((input.sourceType === "github" || input.sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) {
return `${owner}/${repo}/${slug}`;
}
@@ -376,6 +377,28 @@ function parseYamlBlock(
index = nested.nextIndex;
continue;
}
const inlineObjectSeparator = remainder.indexOf(":");
if (
inlineObjectSeparator > 0 &&
!remainder.startsWith("\"") &&
!remainder.startsWith("{") &&
!remainder.startsWith("[")
) {
const key = remainder.slice(0, inlineObjectSeparator).trim();
const rawValue = remainder.slice(inlineObjectSeparator + 1).trim();
const nextObject: Record<string, unknown> = {
[key]: parseYamlScalar(rawValue),
};
if (index < lines.length && lines[index]!.indent > indentLevel) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
if (isPlainRecord(nested.value)) {
Object.assign(nextObject, nested.value);
}
index = nested.nextIndex;
}
values.push(nextObject);
continue;
}
values.push(parseYamlScalar(remainder));
}
return { value: values, nextIndex: index };
@@ -561,11 +584,13 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport
throw unprocessable("Skill source is required.");
}
// Key-style imports (org/repo/skill) originate from the skills.sh registry
if (!/^https?:\/\//i.test(normalizedSource) && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizedSource)) {
const [owner, repo, skillSlugRaw] = normalizedSource.split("/");
return {
resolvedSource: `https://github.com/${owner}/${repo}`,
requestedSkillSlug: normalizeSkillSlug(skillSlugRaw),
originalSkillsShUrl: `https://skills.sh/${owner}/${repo}/${skillSlugRaw}`,
warnings,
};
}
@@ -574,6 +599,19 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport
return {
resolvedSource: `https://github.com/${normalizedSource}`,
requestedSkillSlug,
originalSkillsShUrl: null,
warnings,
};
}
// Detect skills.sh URLs and resolve to GitHub: https://skills.sh/org/repo/skill → org/repo/skill key
const skillsShMatch = normalizedSource.match(/^https?:\/\/(?:www\.)?skills\.sh\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\/([A-Za-z0-9_.-]+))?(?:[?#].*)?$/i);
if (skillsShMatch) {
const [, owner, repo, skillSlugRaw] = skillsShMatch;
return {
resolvedSource: `https://github.com/${owner}/${repo}`,
requestedSkillSlug: skillSlugRaw ? normalizeSkillSlug(skillSlugRaw) : requestedSkillSlug,
originalSkillsShUrl: normalizedSource,
warnings,
};
}
@@ -581,6 +619,7 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport
return {
resolvedSource: normalizedSource,
requestedSkillSlug,
originalSkillsShUrl: null,
warnings,
};
}
@@ -787,12 +826,11 @@ export async function readLocalSkillImportFromDirectory(
const markdown = await fs.readFile(skillFilePath, "utf8");
const parsed = parseFrontmatterMarkdown(markdown);
const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(resolvedSkillDir));
const skillKey = readCanonicalSkillKey(
parsed.frontmatter,
isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null,
);
const parsedMetadata = isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null;
const skillKey = readCanonicalSkillKey(parsed.frontmatter, parsedMetadata);
const metadata = {
...(skillKey ? { skillKey } : {}),
...(parsedMetadata ?? {}),
sourceKind: "local_path",
...(options?.metadata ?? {}),
};
@@ -860,12 +898,11 @@ async function readLocalSkillImports(companyId: string, sourcePath: string): Pro
const markdown = await fs.readFile(resolvedPath, "utf8");
const parsed = parseFrontmatterMarkdown(markdown);
const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(path.dirname(resolvedPath)));
const skillKey = readCanonicalSkillKey(
parsed.frontmatter,
isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null,
);
const parsedMetadata = isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null;
const skillKey = readCanonicalSkillKey(parsed.frontmatter, parsedMetadata);
const metadata = {
...(skillKey ? { skillKey } : {}),
...(parsedMetadata ?? {}),
sourceKind: "local_path",
};
const inventory: CompanySkillFileInventoryEntry[] = [
@@ -1281,6 +1318,18 @@ function deriveSkillSourceInfo(skill: CompanySkill): {
};
}
if (skill.sourceType === "skills_sh") {
const owner = asString(metadata.owner) ?? null;
const repo = asString(metadata.repo) ?? null;
return {
editable: false,
editableReason: "Skills.sh-managed skills are read-only.",
sourceLabel: skill.sourceLocator ?? (owner && repo ? `${owner}/${repo}` : null),
sourceBadge: "skills_sh",
sourcePath: null,
};
}
if (skill.sourceType === "github") {
const owner = asString(metadata.owner) ?? null;
const repo = asString(metadata.repo) ?? null;
@@ -1532,7 +1581,7 @@ export function companySkillService(db: Db) {
const skill = await getById(skillId);
if (!skill || skill.companyId !== companyId) return null;
if (skill.sourceType !== "github") {
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") {
return {
supported: false,
reason: "Only GitHub-managed skills support update checks.",
@@ -1592,7 +1641,7 @@ export function companySkillService(db: Db) {
} else {
throw notFound("Skill file not found");
}
} else if (skill.sourceType === "github") {
} else if (skill.sourceType === "github" || skill.sourceType === "skills_sh") {
const metadata = getSkillMeta(skill);
const owner = asString(metadata.owner);
const repo = asString(metadata.repo);
@@ -2191,10 +2240,63 @@ export function companySkillService(db: Db) {
: "No skills were found in the provided source.",
);
}
// Override sourceType/sourceLocator for skills imported via skills.sh
if (parsed.originalSkillsShUrl) {
for (const skill of filteredSkills) {
skill.sourceType = "skills_sh";
skill.sourceLocator = parsed.originalSkillsShUrl;
if (skill.metadata) {
(skill.metadata as Record<string, unknown>).sourceKind = "skills_sh";
}
skill.key = deriveCanonicalSkillKey(companyId, skill);
}
}
const imported = await upsertImportedSkills(companyId, filteredSkills);
return { imported, warnings };
}
async function deleteSkill(companyId: string, skillId: string): Promise<CompanySkill | null> {
const row = await db
.select()
.from(companySkills)
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const skill = toCompanySkill(row);
// Remove from any agent desiredSkills that reference this skill
const agentRows = await agents.list(companyId);
const allSkills = await listFull(companyId);
for (const agent of agentRows) {
const config = agent.adapterConfig as Record<string, unknown>;
const preference = readPaperclipSkillSyncPreference(config);
const referencesSkill = preference.desiredSkills.some((ref) => {
const resolved = resolveSkillReference(allSkills, ref);
return resolved.skill?.id === skillId;
});
if (referencesSkill) {
const filtered = preference.desiredSkills.filter((ref) => {
const resolved = resolveSkillReference(allSkills, ref);
return resolved.skill?.id !== skillId;
});
await agents.update(agent.id, {
adapterConfig: writePaperclipSkillSyncPreference(config, filtered),
});
}
}
// Delete DB row
await db
.delete(companySkills)
.where(eq(companySkills.id, skillId));
// Clean up materialized runtime files
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
return skill;
}
return {
list,
listFull,
@@ -2209,6 +2311,7 @@ export function companySkillService(db: Db) {
readFile,
updateFile,
createLocalSkill,
deleteSkill,
importFromSource,
scanProjectWorkspaces,
importPackageFiles,

View File

@@ -0,0 +1,27 @@
import fs from "node:fs/promises";
const DEFAULT_AGENT_BUNDLE_FILES = {
default: ["AGENTS.md"],
ceo: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"],
} as const;
type DefaultAgentBundleRole = keyof typeof DEFAULT_AGENT_BUNDLE_FILES;
function resolveDefaultAgentBundleUrl(role: DefaultAgentBundleRole, fileName: string) {
return new URL(`../onboarding-assets/${role}/${fileName}`, import.meta.url);
}
export async function loadDefaultAgentInstructionsBundle(role: DefaultAgentBundleRole): Promise<Record<string, string>> {
const fileNames = DEFAULT_AGENT_BUNDLE_FILES[role];
const entries = await Promise.all(
fileNames.map(async (fileName) => {
const content = await fs.readFile(resolveDefaultAgentBundleUrl(role, fileName), "utf8");
return [fileName, content] as const;
}),
);
return Object.fromEntries(entries);
}
export function resolveDefaultAgentInstructionsBundleRole(role: string): DefaultAgentBundleRole {
return role === "ceo" ? "ceo" : "default";
}

View File

@@ -721,6 +721,9 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const getCurrentUserRedactionOptions = async () => ({
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
});
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
@@ -1320,8 +1323,13 @@ export function heartbeatService(db: Db) {
payload?: Record<string, unknown>;
},
) {
const sanitizedMessage = event.message ? redactCurrentUserText(event.message) : event.message;
const sanitizedPayload = event.payload ? redactCurrentUserValue(event.payload) : event.payload;
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const sanitizedMessage = event.message
? redactCurrentUserText(event.message, currentUserRedactionOptions)
: event.message;
const sanitizedPayload = event.payload
? redactCurrentUserValue(event.payload, currentUserRedactionOptions)
: event.payload;
await db.insert(heartbeatRunEvents).values({
companyId: run.companyId,
@@ -2138,7 +2146,11 @@ export function heartbeatService(db: Db) {
repoRef: executionWorkspace.repoRef,
branchName: executionWorkspace.branchName,
worktreePath: executionWorkspace.worktreePath,
agentHome: resolveDefaultAgentWorkspaceDir(agent.id),
agentHome: await (async () => {
const home = resolveDefaultAgentWorkspaceDir(agent.id);
await fs.mkdir(home, { recursive: true });
return home;
})(),
};
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
const runtimeServiceIntents = (() => {
@@ -2259,8 +2271,9 @@ export function heartbeatService(db: Db) {
})
.where(eq(heartbeatRuns.id, runId));
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
const sanitizedChunk = redactCurrentUserText(chunk);
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
const ts = new Date().toISOString();
@@ -2510,6 +2523,7 @@ export function heartbeatService(db: Db) {
? null
: redactCurrentUserText(
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
currentUserRedactionOptions,
),
errorCode:
outcome === "timed_out"
@@ -2577,7 +2591,10 @@ export function heartbeatService(db: Db) {
}
await finalizeAgentStatus(agent.id, outcome);
} catch (err) {
const message = redactCurrentUserText(err instanceof Error ? err.message : "Unknown adapter failure");
const message = redactCurrentUserText(
err instanceof Error ? err.message : "Unknown adapter failure",
await getCurrentUserRedactionOptions(),
);
logger.error({ err, runId }, "heartbeat execution failed");
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
@@ -3615,7 +3632,7 @@ export function heartbeatService(db: Db) {
store: run.logStore,
logRef: run.logRef,
...result,
content: redactCurrentUserText(result.content),
content: redactCurrentUserText(result.content, await getCurrentUserRedactionOptions()),
};
},

View File

@@ -1,8 +1,11 @@
import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db";
import {
instanceGeneralSettingsSchema,
type InstanceGeneralSettings,
instanceExperimentalSettingsSchema,
type InstanceExperimentalSettings,
type PatchInstanceGeneralSettings,
type InstanceSettings,
type PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
@@ -10,21 +13,36 @@ import { eq } from "drizzle-orm";
const DEFAULT_SINGLETON_KEY = "default";
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
};
}
return {
censorUsernameInLogs: false,
};
}
function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
};
}
return {
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
};
}
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
return {
id: row.id,
general: normalizeGeneralSettings(row.general),
experimental: normalizeExperimentalSettings(row.experimental),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
@@ -45,6 +63,7 @@ export function instanceSettingsService(db: Db) {
.insert(instanceSettings)
.values({
singletonKey: DEFAULT_SINGLETON_KEY,
general: {},
experimental: {},
createdAt: now,
updatedAt: now,
@@ -63,11 +82,34 @@ export function instanceSettingsService(db: Db) {
return {
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
getGeneral: async (): Promise<InstanceGeneralSettings> => {
const row = await getOrCreateRow();
return normalizeGeneralSettings(row.general);
},
getExperimental: async (): Promise<InstanceExperimentalSettings> => {
const row = await getOrCreateRow();
return normalizeExperimentalSettings(row.experimental);
},
updateGeneral: async (patch: PatchInstanceGeneralSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextGeneral = normalizeGeneralSettings({
...normalizeGeneralSettings(current.general),
...patch,
});
const now = new Date();
const [updated] = await db
.update(instanceSettings)
.set({
general: { ...nextGeneral },
updatedAt: now,
})
.where(eq(instanceSettings.id, current.id))
.returning();
return toInstanceSettings(updated ?? current);
},
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextExperimental = normalizeExperimentalSettings({

View File

@@ -100,13 +100,6 @@ type IssueUserContextInput = {
updatedAt: Date | string;
};
function redactIssueComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null;
@@ -323,6 +316,13 @@ function withActiveRuns(
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function assertAssignableAgent(companyId: string, agentId: string) {
const assignee = await db
.select({
@@ -1225,7 +1225,8 @@ export function issueService(db: Db) {
);
const comments = limit ? await query.limit(limit) : await query;
return comments.map(redactIssueComment);
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return comments.map((comment) => redactIssueComment(comment, censorUsernameInLogs));
},
getCommentCursor: async (issueId: string) => {
@@ -1257,14 +1258,15 @@ export function issueService(db: Db) {
},
getComment: (commentId: string) =>
db
instanceSettings.getGeneral().then(({ censorUsernameInLogs }) =>
db
.select()
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows) => {
const comment = rows[0] ?? null;
return comment ? redactIssueComment(comment) : null;
}),
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
})),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db
@@ -1275,7 +1277,10 @@ export function issueService(db: Db) {
if (!issue) throw notFound("Issue not found");
const redactedBody = redactCurrentUserText(body);
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
const [comment] = await db
.insert(issueComments)
.values({
@@ -1293,7 +1298,7 @@ export function issueService(db: Db) {
.set({ updatedAt: new Date() })
.where(eq(issues.id, issueId));
return redactIssueComment(comment);
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
},
createAttachment: async (input: {

View File

@@ -5,6 +5,7 @@ import type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationSta
import { asc, desc, eq, inArray, isNull, or, and } from "drizzle-orm";
import { notFound } from "../errors.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import { instanceSettingsService } from "./instance-settings.js";
import { getWorkspaceOperationLogStore } from "./workspace-operation-log-store.js";
type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect;
@@ -69,6 +70,7 @@ export interface WorkspaceOperationRecorder {
}
export function workspaceOperationService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const logStore = getWorkspaceOperationLogStore();
async function getById(id: string) {
@@ -105,6 +107,9 @@ export function workspaceOperationService(db: Db) {
},
async recordOperation(recordInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const startedAt = new Date();
const id = randomUUID();
const handle = await logStore.begin({
@@ -116,7 +121,7 @@ export function workspaceOperationService(db: Db) {
let stderrExcerpt = "";
const append = async (stream: "stdout" | "stderr" | "system", chunk: string | null | undefined) => {
if (!chunk) return;
const sanitizedChunk = redactCurrentUserText(chunk);
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
await logStore.append(handle, {
@@ -137,7 +142,10 @@ export function workspaceOperationService(db: Db) {
status: "running",
logStore: handle.store,
logRef: handle.logRef,
metadata: redactCurrentUserValue(recordInput.metadata ?? null) as Record<string, unknown> | null,
metadata: redactCurrentUserValue(
recordInput.metadata ?? null,
currentUserRedactionOptions,
) as Record<string, unknown> | null,
startedAt,
});
createdIds.push(id);
@@ -162,6 +170,7 @@ export function workspaceOperationService(db: Db) {
logCompressed: finalized.compressed,
metadata: redactCurrentUserValue(
combineMetadata(recordInput.metadata, result.metadata),
currentUserRedactionOptions,
) as Record<string, unknown> | null,
finishedAt,
updatedAt: finishedAt,
@@ -241,7 +250,9 @@ export function workspaceOperationService(db: Db) {
store: operation.logStore,
logRef: operation.logRef,
...result,
content: redactCurrentUserText(result.content),
content: redactCurrentUserText(result.content, {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
}),
};
},
};