diff --git a/coverage/lcov-report/src/cli/format-event.ts.html b/coverage/lcov-report/src/cli/format-event.ts.html new file mode 100644 index 0000000..ae35e92 --- /dev/null +++ b/coverage/lcov-report/src/cli/format-event.ts.html @@ -0,0 +1,502 @@ + + + + +
++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 | + + +1x + + + + + + + +1x + + + + + + + + +2x +2x +2x +2x + + + + + + + + + + +2x +2x +2x + + + + +13x +13x + +12x +12x +12x + +2x +2x + + +10x + +13x +1x +1x +1x +1x + + +9x + +3x + + +3x +3x +3x +3x +3x +3x +1x +1x +2x +1x +1x +1x +1x +1x +1x +1x + + + +3x + + +6x + +2x + + +2x +2x +2x +2x +2x +2x + + +2x + + +4x + +2x + + +2x +2x +2x +2x +2x +2x +2x +2x +1x +1x + +2x +2x +1x +1x +1x + + +2x + + + + +2x + + +2x +1x + + + | import pc from "picocolors";
+
+function asErrorText(value: unknown): string {
+ Eif (typeof value === "string") return value;
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
+ const obj = value as Record<string, unknown>;
+ const message =
+ (typeof obj.message === "string" && obj.message) ||
+ (typeof obj.error === "string" && obj.error) ||
+ (typeof obj.code === "string" && obj.code) ||
+ "";
+ Iif (message) return message;
+ try {
+ return JSON.stringify(obj);
+ } catch {
+ return "";
+ }
+}
+
+function printToolResult(block: Record<string, unknown>): void {
+ const isError = block.is_error === true;
+ let text = "";
+ if (typeof block.content === "string") {
+ text = block.content;
+ } else if (EArray.isArray(block.content)) {
+ const parts: string[] = [];
+ for (const part of block.content) {
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
+ const record = part as Record<string, unknown>;
+ if (typeof record.text === "string") parts.push(record.text);
+ }
+ text = parts.join("\n");
+ }
+
+ console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
+ Eif (text) {
+ console.log((isError ? pc.red : pc.gray)(text));
+ }
+}
+
+export function printClaudeStreamEvent(raw: string, debug: boolean): void {
+ const line = raw.trim();
+ if (!line) return;
+
+ let parsed: Record<string, unknown> | null = null;
+ try {
+ parsed = JSON.parse(line) as Record<string, unknown>;
+ } catch {
+ console.log(line);
+ return;
+ }
+
+ const type = typeof parsed.type === "string" ? parsed.type : "";
+
+ if (type === "system" && parsed.subtype === "init") {
+ const model = typeof parsed.model === "string" ? parsed.model : "unknown";
+ const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
+ console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : ""})`));
+ return;
+ }
+
+ if (type === "assistant") {
+ const message =
+ typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
+ ? (parsed.message as Record<string, unknown>)
+ : {};
+ const content = Array.isArray(message.content) ? message.content : [];
+ for (const blockRaw of content) {
+ Iif (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
+ const block = blockRaw as Record<string, unknown>;
+ const blockType = typeof block.type === "string" ? block.type : "";
+ if (blockType === "text") {
+ const text = typeof block.text === "string" ? block.text : "";
+ Eif (text) console.log(pc.green(`assistant: ${text}`));
+ } else if (blockType === "thinking") {
+ const text = typeof block.thinking === "string" ? block.thinking : "";
+ Eif (text) console.log(pc.gray(`thinking: ${text}`));
+ } else if (EblockType === "tool_use") {
+ const name = typeof block.name === "string" ? block.name : "unknown";
+ console.log(pc.yellow(`tool_call: ${name}`));
+ Eif (block.input !== undefined) {
+ console.log(pc.gray(JSON.stringify(block.input, null, 2)));
+ }
+ }
+ }
+ return;
+ }
+
+ if (type === "user") {
+ const message =
+ typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
+ ? (parsed.message as Record<string, unknown>)
+ : {};
+ const content = Array.isArray(message.content) ? message.content : [];
+ for (const blockRaw of content) {
+ Iif (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
+ const block = blockRaw as Record<string, unknown>;
+ Eif (typeof block.type === "string" && block.type === "tool_result") {
+ printToolResult(block);
+ }
+ }
+ return;
+ }
+
+ if (type === "result") {
+ const usage =
+ typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
+ ? (parsed.usage as Record<string, unknown>)
+ : {};
+ const input = Number(usage.input_tokens ?? 0);
+ const output = Number(usage.output_tokens ?? 0);
+ const cached = Number(usage.cache_read_input_tokens ?? 0);
+ const cost = Number(parsed.total_cost_usd ?? 0);
+ const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
+ const isError = parsed.is_error === true;
+ const resultText = typeof parsed.result === "string" ? parsed.result : "";
+ if (resultText) {
+ console.log(pc.green("result:"));
+ console.log(resultText);
+ }
+ const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
+ if (subtype.startsWith("error") || isError || errors.length > 0) {
+ console.log(pc.red(`claude_result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`));
+ Eif (errors.length > 0) {
+ console.log(pc.red(`claude_errors: ${errors.join(" | ")}`));
+ }
+ }
+ console.log(
+ pc.blue(
+ `tokens: in=${Number.isFinite(input) ? input : 0} out=${Number.isFinite(output) ? output : 0} cached=${Number.isFinite(cached) ? cached : 0} cost=$${Number.isFinite(cost) ? cost.toFixed(6) : "0.000000"}`,
+ ),
+ );
+ return;
+ }
+
+ if (debug) {
+ console.log(pc.gray(line));
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| format-event.ts | +
+
+ |
+ 79.78% | +75/94 | +64.13% | +93/145 | +100% | +3/3 | +84.52% | +71/84 | +
| index.ts | +
+
+ |
+ 0% | +0/0 | +0% | +0/0 | +0% | +0/0 | +0% | +0/0 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 | + | export { printClaudeStreamEvent } from "./format-event.js";
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 | + + + + + + + + + + + + + + + + + + + + + + + + + + +6x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +6x + + | // NOTE: These types must match what Paperclip's SchemaConfigFields component
+// expects. Paperclip's server at GET /api/adapters/:type/config-schema
+// calls adapter.getConfigSchema() and the UI reads the JSON — types are only
+// used at build time here. The Paperclip types in @paperclipai/adapter-utils
+// may lag behind; these locals are the source of truth for this adapter.
+
+interface ConfigFieldOption {
+ label: string;
+ value: string;
+ group?: string;
+}
+
+type ConfigFieldSchema =
+ | { type: "text"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
+ | { type: "number"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
+ | { type: "toggle"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
+ | { type: "select"; key: string; label: string; hint?: string; options: ConfigFieldOption[]; default?: unknown; meta?: Record<string, unknown> }
+ | { type: "textarea"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
+ | { type: "combobox"; key: string; label: string; hint?: string; options?: ConfigFieldOption[]; default?: unknown; meta?: Record<string, unknown> };
+
+interface AdapterConfigSchema {
+ fields: ConfigFieldSchema[];
+}
+
+export function getConfigSchema(): AdapterConfigSchema {
+ // model, effort, instructionsFilePath, timeoutSec, graceSec are provided
+ // by the platform UI and must not be duplicated here.
+ const fields: ConfigFieldSchema[] = [
+ // Core Claude fields
+ {
+ type: "number",
+ key: "maxTurnsPerRun",
+ label: "Max Turns Per Run",
+ hint: "Maximum number of agentic turns (tool calls) per heartbeat run. 0 means unlimited.",
+ default: 1000,
+ },
+ // Kubernetes
+ {
+ type: "text",
+ key: "serviceAccountName",
+ label: "Service Account",
+ hint: "Service Account name for Job pods. Defaults to the cluster default.",
+ },
+ {
+ type: "text",
+ key: "namespace",
+ label: "Namespace",
+ hint: "Kubernetes namespace for Jobs. Defaults to the Deployment namespace.",
+ },
+ {
+ type: "text",
+ key: "image",
+ label: "Container Image",
+ hint: "Override the container image used for Job pods. Defaults to the running Deployment image.",
+ },
+ {
+ type: "select",
+ key: "imagePullPolicy",
+ label: "Image Pull Policy",
+ hint: "Image pull policy for the container image.",
+ options: [
+ { value: "IfNotPresent", label: "IfNotPresent" },
+ { value: "Always", label: "Always" },
+ { value: "Never", label: "Never" },
+ ],
+ },
+ {
+ type: "text",
+ key: "kubeconfig",
+ label: "Kubeconfig Path",
+ hint: "Absolute path to a kubeconfig file on disk. Defaults to in-cluster service account auth.",
+ },
+ {
+ type: "number",
+ key: "ttlSecondsAfterFinished",
+ label: "TTL Seconds After Finished",
+ hint: "Auto-cleanup delay for completed Jobs in seconds. Default: 300.",
+ },
+ {
+ type: "toggle",
+ key: "retainJobs",
+ label: "Retain Jobs",
+ hint: "Skip cleanup of completed Jobs for debugging purposes.",
+ },
+ {
+ type: "toggle",
+ key: "reattachOrphanedJobs",
+ label: "Reattach to Orphaned Jobs",
+ hint: "If a prior K8s Job for the same agent/task/session is still running (e.g. Paperclip restarted mid-run), attach to it and stream its output instead of blocking the new run. When false, any non-terminal orphan blocks the new run. Default: on.",
+ default: true,
+ },
+ // Resource Limits
+ {
+ type: "text",
+ key: "resources.requests.cpu",
+ label: "CPU Request",
+ hint: "CPU request for Job pods (e.g. 100m, 0.5, 1).",
+ },
+ {
+ type: "text",
+ key: "resources.requests.memory",
+ label: "Memory Request",
+ hint: "Memory request for Job pods (e.g. 128Mi, 512Mi, 1Gi).",
+ },
+ {
+ type: "text",
+ key: "resources.limits.cpu",
+ label: "CPU Limit",
+ hint: "CPU limit for Job pods (e.g. 100m, 0.5, 1).",
+ },
+ {
+ type: "text",
+ key: "resources.limits.memory",
+ label: "Memory Limit",
+ hint: "Memory limit for Job pods (e.g. 128Mi, 512Mi, 1Gi).",
+ },
+ // Scheduling
+ {
+ type: "textarea",
+ key: "nodeSelector",
+ label: "Node Selector",
+ hint: "Node selector for Job pods. One key=value per line (e.g. disktype=ssd).",
+ },
+ {
+ type: "textarea",
+ key: "tolerations",
+ label: "Tolerations",
+ hint: "Tolerations for Job pods as JSON array.",
+ },
+ {
+ type: "textarea",
+ key: "labels",
+ label: "Labels",
+ hint: "Extra labels added to Job metadata. One key=value per line.",
+ },
+ // Output filtering (RTK-compatible)
+ {
+ type: "toggle",
+ key: "enableRtk",
+ label: "Enable Output Filtering",
+ hint: "Truncate oversized tool outputs before they reach the model, reducing token consumption. Implemented natively in Node.js — no external binary required. Installs a PostToolUse hook in ~/.claude/settings.json for each run.",
+ default: false,
+ },
+ {
+ type: "number",
+ key: "rtkMaxOutputBytes",
+ label: "Max Tool Output Bytes",
+ hint: "Maximum bytes of tool output to pass to the model when output filtering is enabled. Outputs exceeding this threshold are truncated with a summary. Default: 50000.",
+ default: 50000,
+ },
+ ];
+
+ return { fields };
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 | + + + + + + + + + + + + + + + + + + +147x + + + +82x +82x + + + + + + + + + +65x + +65x +21x +21x +21x + + + +44x +18x +18x +18x +1x + + +26x +3x +3x +3x +3x +3x +3x +3x +3x + +3x + + + +23x +19x +19x + + +4x + + + + + + + + +27x +27x + + + + + + + +39x +39x +39x +39x + +39x + +35x +35x + + +4x + + +39x +39x +58x + +39x +30x + + + + + + + +20x +20x +20x +2x + + + +60x +60x + + +60x + + +58x +58x + + + + +58x +58x + + +58x +58x + +60x +48x +48x + + + | /**
+ * Line-level dedup filter for the K8s log stream.
+ *
+ * The K8s log follow stream can reconnect with an overlapping `sinceSeconds`
+ * window (integer-second granularity + a safety buffer), which replays a few
+ * seconds of recent output on every reconnect. Without dedup those replayed
+ * lines appear as duplicate events in the streaming UI — the same assistant
+ * text block shows up between every subsequent tool call (FAR-123).
+ *
+ * The filter operates at the chunk → line level: chunks are split on `\n`,
+ * incomplete trailing content is buffered until the next chunk, and each
+ * complete line is emitted at most once. JSON-shaped Claude stream-json
+ * events are keyed by their stable structural IDs; non-JSON lines pass
+ * through unchanged so genuinely-repeated status lines are not swallowed.
+ */
+
+type Parsed = Record<string, unknown>;
+
+function asString(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+
+function asRecord(value: unknown): Parsed | null {
+ Iif (typeof value !== "object" || value === null || Array.isArray(value)) return null;
+ return value as Parsed;
+}
+
+/**
+ * Build a stable dedup key for a Claude stream-json event. Returns `null`
+ * when the event is not a recognized Claude event — those lines fall back to
+ * raw-content hashing so non-JSON output (paperclip status lines, shell
+ * output) is never deduped by identity.
+ */
+export function eventDedupKey(event: Parsed): string | null {
+ const type = asString(event.type);
+
+ if (type === "system") {
+ const subtype = asString(event.subtype);
+ const sessionId = asString(event.session_id);
+ Eif (subtype === "init" && sessionId) return `system:init:${sessionId}`;
+ return null;
+ }
+
+ if (type === "assistant") {
+ const message = asRecord(event.message);
+ const id = message ? asString(message.id) : "";
+ if (id) return `assistant:${id}`;
+ return null;
+ }
+
+ if (type === "user") {
+ const message = asRecord(event.message);
+ const content = message && Array.isArray(message.content) ? message.content : [];
+ const toolUseIds: string[] = [];
+ for (const entry of content) {
+ const block = asRecord(entry);
+ Iif (!block) continue;
+ const toolUseId = asString(block.tool_use_id);
+ Eif (toolUseId) toolUseIds.push(toolUseId);
+ }
+ Eif (toolUseIds.length > 0) return `user:tool_result:${toolUseIds.join(",")}`;
+ return null;
+ }
+
+ if (type === "result") {
+ const sessionId = asString(event.session_id);
+ return sessionId ? `result:${sessionId}` : "result:unknown";
+ }
+
+ return null;
+}
+
+/**
+ * Stateful line-level dedup filter. Emits `filter(chunk)` output through
+ * the caller — preserves original chunk formatting (including trailing
+ * newlines) for lines that pass the dedup check.
+ */
+export class LogLineDedupFilter {
+ private buffer = "";
+ private readonly seenKeys = new Set<string>();
+
+ /**
+ * Process a chunk and return the subset that should be forwarded.
+ * Incomplete trailing content (no terminating newline) is buffered and
+ * emitted on the next chunk that completes the line (or on flush()).
+ */
+ filter(chunk: string): string {
+ Iif (!chunk) return "";
+ const combined = this.buffer + chunk;
+ const endsWithNewline = combined.endsWith("\n");
+ const parts = combined.split("\n");
+
+ if (endsWithNewline) {
+ // Discard the final empty element — last line was complete.
+ parts.pop();
+ this.buffer = "";
+ } else {
+ // Last element is an incomplete line — hold it for the next chunk.
+ this.buffer = parts.pop() ?? "";
+ }
+
+ const out: string[] = [];
+ for (const line of parts) {
+ if (this.shouldEmit(line)) out.push(line);
+ }
+ if (out.length === 0) return "";
+ return out.join("\n") + "\n";
+ }
+
+ /**
+ * Flush any incomplete trailing content. Called when the stream ends
+ * without a terminating newline so the final partial line isn't lost.
+ */
+ flush(): string {
+ const pending = this.buffer;
+ this.buffer = "";
+ if (!pending) return "";
+ return this.shouldEmit(pending) ? pending : "";
+ }
+
+ private shouldEmit(line: string): boolean {
+ const trimmed = line.trim();
+ Iif (!trimmed) return true;
+
+ // Only attempt dedup on JSON-shaped lines; pass shell/text output through.
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return true;
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch {
+ return true;
+ }
+
+ const event = asRecord(parsed);
+ Iif (!event) return true;
+
+ // Recognized Claude stream-json event → structural key.
+ const structuralKey = eventDedupKey(event);
+ const key = structuralKey ?? `raw:${trimmed}`;
+
+ if (this.seenKeys.has(key)) return false;
+ this.seenKeys.add(key);
+ return true;
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 | + +1x + + + + + + + + +1x + + + + + + + + +5x + + + + + + + + +5x + + | import type { AdapterModel } from "@paperclipai/adapter-utils";
+
+const DIRECT_MODELS: AdapterModel[] = [
+ { id: "claude-opus-4-7", label: "Claude Opus 4.7" },
+ { id: "claude-opus-4-6", label: "Claude Opus 4.6" },
+ { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
+ { id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
+ { id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
+ { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
+];
+
+const BEDROCK_MODELS: AdapterModel[] = [
+ { id: "us.anthropic.claude-opus-4-7", label: "Bedrock Opus 4.7" },
+ { id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
+ { id: "us.anthropic.claude-sonnet-4-6", label: "Bedrock Sonnet 4.6" },
+ { id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", label: "Bedrock Sonnet 4.5" },
+ { id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
+];
+
+function isBedrockEnv(): boolean {
+ return (
+ process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
+ process.env.CLAUDE_CODE_USE_BEDROCK === "true" ||
+ (typeof process.env.ANTHROPIC_BEDROCK_BASE_URL === "string" &&
+ process.env.ANTHROPIC_BEDROCK_BASE_URL.trim().length > 0)
+ );
+}
+
+export async function listK8sModels(): Promise<AdapterModel[]> {
+ return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 | + + + + + + + + + + + + + + + + + + + + +1x + + +9x +7x +4x +3x + + + +8x + + + + +8x + + + +8x +8x +8x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +8x +8x +8x + + + + +8x + +8x +8x + + + +8x + + + + + + + + + + + + + + + + + + + + + + + + + + + + +8x +8x +8x +8x +8x + +1x + + + + + + + + + + + +1x +8x + + + +1x + + | 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 validatePathComponent(value: string, fieldName: string): void {
+ if (value.trim().length === 0) throw new Error(`Invalid ${fieldName}: must not be empty`);
+ if (value.includes("/") || value.includes("\\")) throw new Error(`Invalid ${fieldName}: must not contain path separators`);
+ if (value.includes("..")) throw new Error(`Invalid ${fieldName}: must not contain ".."`);
+ if (value.includes("\0")) throw new Error(`Invalid ${fieldName}: must not contain null bytes`);
+}
+
+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;
+ validatePathComponent(companyId, "companyId");
+ validatePathComponent(instanceId, "instanceId");
+ return path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-prompt-cache");
+}
+
+async function hashPathContents(
+ candidate: string,
+ hash: ReturnType<typeof createHash>,
+ relativePath: string,
+ seenDirectories: Set<string>,
+): Promise<void> {
+ 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<string> {
+ const hash = createHash("sha256");
+ hash.update("paperclip-claude-prompt-bundle:v1\n");
+ Iif (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<void> {
+ 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<ClaudePromptBundle> {
+ 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;
+ Iif (instructionsFilePath && instructionsContents) {
+ await ensureReadableFile(instructionsFilePath, instructionsContents);
+ }
+
+ return { bundleKey, rootDir, addDir: rootDir, instructionsFilePath };
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 | + + + + + + + + + + + +1x + + + + +9x +9x +9x +9x +9x + +9x + + + + + + + + + + + + + + + + + +9x + +9x +5x +1x +1x + + + + + + + + + + + + + + +9x +2x +1x + + + + + + + + + + + + + + + +9x + +9x + + + + + + + + + + +8x + + + + + + +1x + + | import type {
+ AdapterSkillContext,
+ AdapterSkillSnapshot,
+ AdapterSkillEntry,
+} from "@paperclipai/adapter-utils";
+import {
+ readPaperclipRuntimeSkillEntries,
+ resolvePaperclipDesiredSkillNames,
+ readInstalledSkillTargets,
+} from "@paperclipai/adapter-utils/server-utils";
+import path from "node:path";
+
+const SKILLS_HOME = "/paperclip/.claude/skills";
+
+async function buildK8sSkillSnapshot(
+ config: Record<string, unknown>,
+): Promise<AdapterSkillSnapshot> {
+ const availableEntries = await readPaperclipRuntimeSkillEntries(config, import.meta.dirname ?? __dirname);
+ const availableByKey = new Map(availableEntries.map((e) => [e.key, e]));
+ const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
+ const desiredSet = new Set(desiredSkills);
+ const installed = await readInstalledSkillTargets(SKILLS_HOME);
+
+ const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
+ key: entry.key,
+ runtimeName: entry.runtimeName,
+ desired: desiredSet.has(entry.key),
+ managed: true,
+ state: desiredSet.has(entry.key) ? "configured" : "available",
+ origin: entry.required ? "paperclip_required" : "company_managed",
+ originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
+ readOnly: false,
+ sourcePath: entry.source,
+ targetPath: null,
+ detail: desiredSet.has(entry.key)
+ ? "Materialized into the PVC-backed Claude prompt bundle before each K8s Job run."
+ : null,
+ required: Boolean(entry.required),
+ requiredReason: entry.requiredReason ?? null,
+ }));
+
+ const warnings: string[] = [];
+
+ for (const desiredSkill of desiredSkills) {
+ if (availableByKey.has(desiredSkill)) continue;
+ warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
+ entries.push({
+ key: desiredSkill,
+ runtimeName: null,
+ desired: true,
+ managed: true,
+ state: "missing",
+ origin: "external_unknown",
+ originLabel: "External or unavailable",
+ readOnly: false,
+ sourcePath: undefined,
+ targetPath: undefined,
+ detail: "Paperclip cannot find this skill in the runtime skills directory.",
+ });
+ }
+
+ for (const [name, installedEntry] of installed.entries()) {
+ if (availableEntries.some((e) => e.runtimeName === name)) continue;
+ entries.push({
+ key: name,
+ runtimeName: name,
+ desired: false,
+ managed: false,
+ state: "external",
+ origin: "user_installed",
+ originLabel: "User-installed",
+ locationLabel: "~/.claude/skills",
+ readOnly: true,
+ sourcePath: null,
+ targetPath: installedEntry.targetPath ?? path.join(SKILLS_HOME, name),
+ detail: "Installed outside Paperclip management in the Claude skills home.",
+ });
+ }
+
+ entries.sort((a, b) => a.key.localeCompare(b.key));
+
+ return {
+ adapterType: "claude_k8s",
+ supported: true,
+ mode: "ephemeral",
+ desiredSkills,
+ entries,
+ warnings,
+ };
+}
+
+export async function listK8sSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
+ return buildK8sSkillSnapshot(ctx.config);
+}
+
+export async function syncK8sSkills(
+ ctx: AdapterSkillContext,
+ _desiredSkills: string[],
+): Promise<AdapterSkillSnapshot> {
+ return buildK8sSkillSnapshot(ctx.config);
+}
+ |