diff --git a/src/server/execute.ts b/src/server/execute.ts index 87916c4..8de2a35 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -1,5 +1,15 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { asString, asNumber, asBoolean, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { + asString, + asNumber, + asBoolean, + parseObject, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { prepareClaudePromptBundle } from "./prompt-cache.js"; import { parseClaudeStreamJson, describeClaudeFailure, @@ -619,6 +629,38 @@ export async function execute(ctx: AdapterExecutionContext): Promise } | null = null; + // Prepare the prompt bundle (skills + instructions) on the server filesystem. + // The K8s Job pod mounts the same PVC at /paperclip, so bundle paths written + // here are accessible inside the pod at the identical absolute path. + const skillEntries = await readPaperclipRuntimeSkillEntries(config, import.meta.dirname ?? __dirname); + const desiredSkillNames = new Set(resolvePaperclipDesiredSkillNames(config, skillEntries)); + const desiredSkills = skillEntries.filter((e) => desiredSkillNames.has(e.key)); + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + let instructionsContents: string | null = null; + if (instructionsFilePath) { + try { + const raw = await fs.readFile(instructionsFilePath, "utf-8"); + const pathDirective = + `\nThe above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsFileDir}. ` + + `This base directory is authoritative for sibling instruction files such as ` + + `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`; + instructionsContents = raw + pathDirective; + } catch (err) { + await onLog( + "stderr", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + const promptBundle = await prepareClaudePromptBundle({ + companyId: ctx.agent.companyId, + skills: desiredSkills, + instructionsContents, + onLog, + }); + if (reattachTarget) { jobName = reattachTarget.jobName; namespace = reattachTarget.namespace; @@ -646,7 +688,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(claudeArgs).toContain("--dangerously-skip-permissions"); }); - it("adds --append-system-prompt-file when instructionsFilePath set", () => { + it("adds --append-system-prompt-file (config fallback) when instructionsFilePath set and no session", () => { ctx.config = { instructionsFilePath: "/paperclip/instructions.md" }; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).toContain("--append-system-prompt-file"); expect(claudeArgs).toContain("/paperclip/instructions.md"); }); + it("omits --append-system-prompt-file on session resume (avoids token waste)", () => { + ctx.config = { instructionsFilePath: "/paperclip/instructions.md" }; + ctx.runtime.sessionId = "sess_existing"; + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).not.toContain("--append-system-prompt-file"); + }); + + it("adds --add-dir when promptBundle is provided", () => { + const promptBundle = { + bundleKey: "abc123", + rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + instructionsFilePath: null, + }; + const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle }); + expect(claudeArgs).toContain("--add-dir"); + expect(claudeArgs).toContain(promptBundle.addDir); + }); + + it("uses bundle instructionsFilePath for --append-system-prompt-file when promptBundle provided", () => { + const promptBundle = { + bundleKey: "abc123", + rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + instructionsFilePath: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123/agent-instructions.md", + }; + ctx.config = { instructionsFilePath: "/raw/path/AGENTS.md" }; + const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle }); + expect(claudeArgs).toContain("--append-system-prompt-file"); + const idx = claudeArgs.indexOf("--append-system-prompt-file"); + expect(claudeArgs[idx + 1]).toBe(promptBundle.instructionsFilePath); + expect(claudeArgs).not.toContain("/raw/path/AGENTS.md"); + }); + + it("omits --append-system-prompt-file from bundle on session resume", () => { + const promptBundle = { + bundleKey: "abc123", + rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + instructionsFilePath: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123/agent-instructions.md", + }; + ctx.runtime.sessionId = "sess_existing"; + const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle }); + expect(claudeArgs).not.toContain("--append-system-prompt-file"); + // --add-dir must still be present even on resume + expect(claudeArgs).toContain("--add-dir"); + }); + + it("omits --add-dir when no promptBundle", () => { + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).not.toContain("--add-dir"); + }); + it("appends extraArgs when configured", () => { ctx.config = { extraArgs: ["--no-input", "--verbose"] }; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); diff --git a/src/server/job-manifest.ts b/src/server/job-manifest.ts index 56569c0..179d0ca 100644 --- a/src/server/job-manifest.ts +++ b/src/server/job-manifest.ts @@ -10,6 +10,7 @@ import { renderTemplate, } from "@paperclipai/adapter-utils/server-utils"; import { createHash } from "node:crypto"; +import type { ClaudePromptBundle } from "./prompt-cache.js"; /** * Build the shell command prefix that installs a native Node.js PostToolUse @@ -175,6 +176,8 @@ function parseKeyValueConfig(raw: unknown): Record { export interface JobBuildInput { ctx: AdapterExecutionContext; selfPod: SelfPodInfo; + /** Prepared prompt bundle (skills + instructions). When provided, --add-dir and --append-system-prompt-file use bundle paths. */ + promptBundle?: ClaudePromptBundle | null; } /** When the prompt exceeds the env-var size limit, the manifest uses a @@ -327,7 +330,7 @@ function buildEnvVars( } export function buildJobManifest(input: JobBuildInput): JobBuildResult { - const { ctx, selfPod } = input; + const { ctx, selfPod, promptBundle } = input; const { runId, agent, runtime, config: rawConfig, context } = ctx; const config = parseObject(rawConfig); @@ -403,14 +406,22 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { }; // Build Claude CLI args - const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + // Prefer the bundle's materialized instructions file over the raw config path. + // Never inject --append-system-prompt-file on session resumes — the instructions + // are already in the session cache and re-injecting wastes tokens. + const rawInstructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const effectiveInstructionsFilePath = + promptBundle?.instructionsFilePath ?? (rawInstructionsFilePath || null); const claudeArgs = ["--print", "-", "--output-format", "stream-json", "--verbose"]; if (runtimeSessionId) claudeArgs.push("--resume", runtimeSessionId); if (dangerouslySkipPermissions) claudeArgs.push("--dangerously-skip-permissions"); if (model) claudeArgs.push("--model", model); if (effort) claudeArgs.push("--effort", effort); if (maxTurns > 0) claudeArgs.push("--max-turns", String(maxTurns)); - if (instructionsFilePath) claudeArgs.push("--append-system-prompt-file", instructionsFilePath); + if (effectiveInstructionsFilePath && !runtimeSessionId) { + claudeArgs.push("--append-system-prompt-file", effectiveInstructionsFilePath); + } + if (promptBundle) claudeArgs.push("--add-dir", promptBundle.addDir); if (extraArgs.length > 0) claudeArgs.push(...extraArgs); // Build env vars diff --git a/src/server/prompt-cache.ts b/src/server/prompt-cache.ts new file mode 100644 index 0000000..6ab06fb --- /dev/null +++ b/src/server/prompt-cache.ts @@ -0,0 +1,150 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { + type PaperclipSkillEntry, + ensurePaperclipSkillSymlink, +} from "@paperclipai/adapter-utils/server-utils"; + +export interface ClaudePromptBundle { + bundleKey: string; + /** Absolute path to the bundle root directory (contains .claude/skills/ and agent-instructions.md). */ + rootDir: string; + /** Value to pass as --add-dir to the Claude CLI. */ + addDir: string; + /** Path to the materialized instructions file, or null if no instructions were provided. */ + instructionsFilePath: string | null; +} + +const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; + +function resolveManagedClaudePromptCacheRoot(companyId: string): string { + const paperclipHome = + (typeof process.env.PAPERCLIP_HOME === "string" && process.env.PAPERCLIP_HOME.trim().length > 0 + ? process.env.PAPERCLIP_HOME.trim() + : null) ?? + path.resolve(os.homedir(), ".paperclip"); + const instanceId = + (typeof process.env.PAPERCLIP_INSTANCE_ID === "string" && process.env.PAPERCLIP_INSTANCE_ID.trim().length > 0 + ? process.env.PAPERCLIP_INSTANCE_ID.trim() + : null) ?? DEFAULT_PAPERCLIP_INSTANCE_ID; + return path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-prompt-cache"); +} + +async function hashPathContents( + candidate: string, + hash: ReturnType, + relativePath: string, + seenDirectories: Set, +): Promise { + const stat = await fs.lstat(candidate); + if (stat.isSymbolicLink()) { + hash.update(`symlink:${relativePath}\n`); + const resolved = await fs.realpath(candidate).catch(() => null); + if (!resolved) { + hash.update("missing\n"); + return; + } + await hashPathContents(resolved, hash, relativePath, seenDirectories); + return; + } + if (stat.isDirectory()) { + const realDir = await fs.realpath(candidate).catch(() => candidate); + hash.update(`dir:${relativePath}\n`); + if (seenDirectories.has(realDir)) { + hash.update("loop\n"); + return; + } + seenDirectories.add(realDir); + const entries = await fs.readdir(candidate, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const childRelativePath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name; + await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories); + } + return; + } + if (stat.isFile()) { + hash.update(`file:${relativePath}\n`); + hash.update(await fs.readFile(candidate)); + hash.update("\n"); + return; + } + hash.update(`other:${relativePath}:${stat.mode}\n`); +} + +async function buildClaudePromptBundleKey(input: { + skills: PaperclipSkillEntry[]; + instructionsContents: string | null; +}): Promise { + const hash = createHash("sha256"); + hash.update("paperclip-claude-prompt-bundle:v1\n"); + if (input.instructionsContents) { + hash.update("instructions\n"); + hash.update(input.instructionsContents); + hash.update("\n"); + } else { + hash.update("instructions:none\n"); + } + const sortedSkills = [...input.skills].sort((a, b) => a.runtimeName.localeCompare(b.runtimeName)); + for (const entry of sortedSkills) { + hash.update(`skill:${entry.key}:${entry.runtimeName}\n`); + await hashPathContents(entry.source, hash, entry.runtimeName, new Set()); + } + return hash.digest("hex"); +} + +async function ensureReadableFile(targetPath: string, contents: string): Promise { + try { + await fs.access(targetPath, fsConstants.R_OK); + return; + } catch { + // Fall through and materialize the file. + } + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`; + try { + await fs.writeFile(tempPath, contents, "utf8"); + await fs.rename(tempPath, targetPath); + } catch (err) { + const targetReadable = await fs.access(targetPath, fsConstants.R_OK).then(() => true).catch(() => false); + if (!targetReadable) throw err; + } finally { + await fs.rm(tempPath, { force: true }).catch(() => {}); + } +} + +export async function prepareClaudePromptBundle(input: { + companyId: string; + skills: PaperclipSkillEntry[]; + instructionsContents: string | null; + onLog: AdapterExecutionContext["onLog"]; +}): Promise { + const { companyId, skills, instructionsContents, onLog } = input; + const bundleKey = await buildClaudePromptBundleKey({ skills, instructionsContents }); + const rootDir = path.join(resolveManagedClaudePromptCacheRoot(companyId), bundleKey); + const skillsHome = path.join(rootDir, ".claude", "skills"); + await fs.mkdir(skillsHome, { recursive: true }); + + for (const entry of skills) { + const target = path.join(skillsHome, entry.runtimeName); + try { + await ensurePaperclipSkillSymlink(entry.source, target); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to materialize Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + + const instructionsFilePath = instructionsContents ? path.join(rootDir, "agent-instructions.md") : null; + if (instructionsFilePath && instructionsContents) { + await ensureReadableFile(instructionsFilePath, instructionsContents); + } + + return { bundleKey, rootDir, addDir: rootDir, instructionsFilePath }; +} diff --git a/src/server/skills.ts b/src/server/skills.ts index c76d8a5..8f3d4bb 100644 --- a/src/server/skills.ts +++ b/src/server/skills.ts @@ -33,7 +33,7 @@ async function buildK8sSkillSnapshot( sourcePath: entry.source, targetPath: null, detail: desiredSet.has(entry.key) - ? "Injected via prompt bundle into ephemeral K8s Job pods." + ? "Materialized into the PVC-backed Claude prompt bundle before each K8s Job run." : null, required: Boolean(entry.required), requiredReason: entry.requiredReason ?? null,